mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
390fcf88f3
* fix(plugin-previews): validate baked preview metadata against shipped plugins The Home Community gallery renders whatever data/plugin-previews/manifest.json names, and nothing kept that metadata in sync with reality: #4815 deleted 19 example plugins but left all 19 manifest entries behind, #4040 shipped a card showing another plugin's imagery, a failed re-bake silently leaves the old clip serving outdated content, and durationMs recorded the intended walk time rather than the encoded file (23 entries carried holdMs > real duration, so the card's idle loop pointed past the end of the clip). - add scripts/check-plugin-preview-manifest.ts to pnpm guard: every manifest entry must name a shipped plugin, own its clip keys (<id>/<hash>/... or the legacy <id>.<hash>....), carry a coherent fingerprint, and never share a clip with another entry; prune the 19 orphaned entries it caught - bake validation (BAKE_VERSION 5): reject blank clips (whole-clip luma range via ffprobe signalstats), record durationMs from the encoded file, clamp holdMs to it, and write a bake-report.json naming skipped/stale/blank plugins instead of swallowing the failure - strict mode (--strict / PREVIEW_STRICT=1) fails the run when a bake left broken metadata behind; the pre-merge validation workflow now runs strict while the post-merge/nightly publishers stay lenient * fix(plugins): make the hyperframes example self-contained The example loaded cdn.tailwindcss.com (a render-blocking head script the page never used — every style is inline) plus Google Fonts. Inside the sandboxed gallery iframe or on a slow network the stalled script showed a long blank instead of the composition, and the CI bake raced the webfonts. Drop both: the page now renders from local system font stacks with zero external requests, so the live preview, the detail iframe, and the baked clip all show the same content. * fix(plugin-previews): address review — fail fast on probe errors, widen strict trigger paths - probeClipMs/clipLumaRange now throw instead of returning null: ffprobe is required validation infrastructure, and a swallowed probe error silently disabled exactly the checks this PR adds. A thrown error becomes an 'error …' skip, is recorded in bake-report.json under 'errors', and fails strict mode — while the routine skips every sweep has (non-html plugins 404ing the preview route) still never trip strict. - bake-plugin-previews-pr.yml now also triggers on plugins/community/**, .github/actions/bake-previews/**, and itself, so community-plugin preview changes and bake-recipe changes cannot merge without the strict pre-merge validation. * fix(plugin-previews): bake static pages as a held still instead of skipping forever The strict pre-merge bake on this PR caught five manifest entries that could NEVER refresh: example-dating-web, example-html-ppt-zhangzara-8-bit-orbit, example-frame-logo-outro, od-new-generation, example-orbit-notion. All five are (near-)static pages — the CDP screencast only delivers frames when something repaints, so they produce 1-4 frames and the old 'frames < 5' guard skipped them on every bake, nightly included, leaving their committed entries permanently stale (their old clips are the 103-210ms degenerate files in the manifest today). Encode <5-frame captures as a still instead: hold the last frame for the idle-loop span (half before the trailing repeated concat entry, half inherited by it, summing to ~HOLD_MS). Only a zero-frame capture is a real failure now. The blank-luma check still rejects stills of nothing. * fix(plugin-previews): never persist a manifest entry without a fingerprint When the preview fingerprint fetch failed, the loop still rendered and — on an otherwise successful bake — persisted an entry with hash: null and un-fingerprinted keys: metadata the content-hash reuse skip can never match again and the new manifest guard rejects. Treat a missing fingerprint as an infrastructure error instead: skip the plugin, record it under errors in bake-report.json (strict mode exits non-zero), and leave any committed entry untouched for the next sweep. --------- Co-authored-by: Tuola Ge <gexingli@refly.ai>
191 lines
7.7 KiB
TypeScript
191 lines
7.7 KiB
TypeScript
/* ─────────────────────────────────────────────────────────────────────────
|
|
* scripts/check-plugin-preview-manifest.ts
|
|
*
|
|
* Guard for the baked plugin-preview manifest (data/plugin-previews/
|
|
* manifest.json) — the metadata the Home "Community" gallery trusts to pair
|
|
* every plugin card with its pre-rendered clip. The gallery faithfully shows
|
|
* whatever this file names, so drifted metadata is directly user-visible:
|
|
* a deleted plugin's entry lingers forever (#4815 removed 19 example plugins
|
|
* and left all 19 manifest entries behind), or an entry resolves to another
|
|
* plugin's clip and the card renders the wrong content entirely (#4040, the
|
|
* shamoni card showing luxury-botanical's imagery).
|
|
*
|
|
* Checks:
|
|
* 1. no orphans — every entry names a plugin that still ships under
|
|
* plugins/_official or plugins/community;
|
|
* 2. clip ownership — an entry's video/poster keys belong to its own plugin
|
|
* id, either `<id>/<hash>/…` (directory layout) or `<id>.<hash>.…`
|
|
* (legacy flat layout);
|
|
* 3. hash coherence — the fingerprint embedded in the keys matches the
|
|
* entry's recorded `hash`;
|
|
* 4. no two entries share a clip (the #4040 failure shape);
|
|
* 5. durationMs / holdMs, when present, are positive finite numbers.
|
|
*
|
|
* Run standalone: `pnpm exec tsx scripts/check-plugin-preview-manifest.ts`
|
|
* Or as part of `pnpm guard` (registered in scripts/guard.ts).
|
|
* ─────────────────────────────────────────────────────────────────────── */
|
|
|
|
import { readFile, readdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
const MANIFEST_REPO_PATH = "data/plugin-previews/manifest.json";
|
|
// The id universe the manifest may reference: every bundled plugin the CI
|
|
// daemon can serve a preview for. Community is included so a community
|
|
// plugin's bake never reads as an orphan.
|
|
const PLUGIN_ROOTS = ["plugins/_official", "plugins/community"];
|
|
const HASH_PATTERN = /^[0-9a-f]{16}$/;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
/** Every plugin id (`name` in open-design.json) shipped under the given roots. */
|
|
export async function collectPluginIds(
|
|
roots: readonly string[] = PLUGIN_ROOTS,
|
|
): Promise<Set<string>> {
|
|
const ids = new Set<string>();
|
|
const pending = roots.map((root) => path.join(repoRoot, root));
|
|
while (pending.length > 0) {
|
|
const dir = pending.pop()!;
|
|
let entries;
|
|
try {
|
|
entries = await readdir(dir, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
pending.push(path.join(dir, entry.name));
|
|
continue;
|
|
}
|
|
if (entry.name !== "open-design.json") continue;
|
|
try {
|
|
const manifest: unknown = JSON.parse(await readFile(path.join(dir, entry.name), "utf8"));
|
|
if (isRecord(manifest) && typeof manifest.name === "string" && manifest.name.length > 0) {
|
|
ids.add(manifest.name);
|
|
}
|
|
} catch {
|
|
// A malformed plugin manifest is plugin-spec territory, not this
|
|
// guard's; the plugin simply contributes no id.
|
|
}
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function keyBelongsToPlugin(key: string, id: string): boolean {
|
|
return key.startsWith(`${id}/`) || key.startsWith(`${id}.`);
|
|
}
|
|
|
|
function keyCarriesHash(key: string, id: string, hash: string): boolean {
|
|
return key.startsWith(`${id}/${hash}/`) || key.startsWith(`${id}.${hash}.`);
|
|
}
|
|
|
|
/** Pure validation over a parsed manifest; returns human-readable violations. */
|
|
export function validatePreviewManifest(
|
|
manifest: unknown,
|
|
pluginIds: ReadonlySet<string>,
|
|
): string[] {
|
|
const violations: string[] = [];
|
|
if (!isRecord(manifest) || !isRecord(manifest.previews)) {
|
|
return [`${MANIFEST_REPO_PATH}: expected a { previews: { <id>: … } } object`];
|
|
}
|
|
|
|
const claimedKeys = new Map<string, string[]>();
|
|
for (const [id, entry] of Object.entries(manifest.previews)) {
|
|
if (!isRecord(entry)) {
|
|
violations.push(`${MANIFEST_REPO_PATH}: ${id}: entry must be an object`);
|
|
continue;
|
|
}
|
|
|
|
const { video, poster, hash, durationMs, holdMs } = entry;
|
|
for (const [field, value] of [["video", video], ["poster", poster]] as const) {
|
|
if (typeof value !== "string" || value.length === 0) {
|
|
violations.push(`${MANIFEST_REPO_PATH}: ${id}: ${field} must be a non-empty string`);
|
|
continue;
|
|
}
|
|
if (!keyBelongsToPlugin(value, id)) {
|
|
violations.push(
|
|
`${MANIFEST_REPO_PATH}: ${id}: ${field} key "${value}" does not belong to this plugin id — the card would render another plugin's clip`,
|
|
);
|
|
}
|
|
const owners = claimedKeys.get(value) ?? [];
|
|
owners.push(id);
|
|
claimedKeys.set(value, owners);
|
|
}
|
|
|
|
if (hash !== undefined) {
|
|
if (typeof hash !== "string" || !HASH_PATTERN.test(hash)) {
|
|
violations.push(`${MANIFEST_REPO_PATH}: ${id}: hash must be 16 lowercase hex chars`);
|
|
} else {
|
|
for (const [field, value] of [["video", video], ["poster", poster]] as const) {
|
|
if (typeof value === "string" && keyBelongsToPlugin(value, id) && !keyCarriesHash(value, id, hash)) {
|
|
violations.push(
|
|
`${MANIFEST_REPO_PATH}: ${id}: ${field} key "${value}" does not carry the recorded hash ${hash} — the entry points at a clip baked from different content`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [field, value] of [["durationMs", durationMs], ["holdMs", holdMs]] as const) {
|
|
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) {
|
|
violations.push(`${MANIFEST_REPO_PATH}: ${id}: ${field} must be a positive finite number`);
|
|
}
|
|
}
|
|
|
|
if (!pluginIds.has(id)) {
|
|
violations.push(
|
|
`${MANIFEST_REPO_PATH}: ${id}: no such plugin ships in ${PLUGIN_ROOTS.join(" or ")} — remove the stale entry (deleting a plugin must also delete its baked-preview entry)`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const [key, owners] of claimedKeys) {
|
|
if (owners.length > 1) {
|
|
violations.push(
|
|
`${MANIFEST_REPO_PATH}: clip key "${key}" is claimed by ${owners.length} entries (${owners.join(", ")}) — cards would show duplicate imagery`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
export async function checkPluginPreviewManifest(): Promise<boolean> {
|
|
let manifest: unknown;
|
|
try {
|
|
manifest = JSON.parse(await readFile(path.join(repoRoot, MANIFEST_REPO_PATH), "utf8"));
|
|
} catch (error) {
|
|
console.error(
|
|
`Plugin preview manifest check failed: ${MANIFEST_REPO_PATH} could not be parsed: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const pluginIds = await collectPluginIds();
|
|
const violations = validatePreviewManifest(manifest, pluginIds);
|
|
if (violations.length > 0) {
|
|
console.error("Plugin preview manifest violations:");
|
|
for (const violation of violations) console.error(`- ${violation}`);
|
|
return false;
|
|
}
|
|
|
|
const count = isRecord(manifest) && isRecord(manifest.previews)
|
|
? Object.keys(manifest.previews).length
|
|
: 0;
|
|
console.log(
|
|
`Plugin preview manifest check passed: ${count} entr${count === 1 ? "y" : "ies"} consistent with ${pluginIds.size} shipped plugins.`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const ok = await checkPluginPreviewManifest();
|
|
if (!ok) process.exitCode = 1;
|
|
}
|