Files
hyperframes/packages/core/src/figma/manifest.ts
T
Vance IngallsandClaude Fable 5 3900caaaa9 feat(core,cli): media-use interop — shared index.md regen + description/entity on figma imports (#1927)
Post-release review of media-use ↔ figma coupling (spec §13.1):
- figma asset imports now regenerate .media/index.md, the agent-readable
  inventory media-use maintains — format locked byte-identical via a
  cross-runner parity test against media-use's own index-gen.mjs
- figma asset --description/--entity land in the manifest record, the
  index table, and <img alt>; component rasterize auto-describes with
  the node name. Named brand marks become visible to media-use's
  resolve --entity lookups.
- spec §13.1 records the review verdict (loose coupling correct) and
  the follow-up queue (shared media-ledger module, global cache for
  figma assets, media-use version-keyed idempotency)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:56:43 -07:00

138 lines
4.7 KiB
TypeScript

import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { readJsonlValues } from "./jsonl";
import type { FigmaManifestRecord } from "./types";
const MANIFEST_FILE = "manifest.jsonl";
// Mirrors the media-use `.media/` layout for interop (one shared inventory).
const TYPE_DIRS: Record<FigmaManifestRecord["type"], string> = {
image: "images",
video: "video",
};
export function mediaDir(projectDir: string): string {
return join(projectDir, ".media");
}
export function manifestPath(projectDir: string): string {
return join(mediaDir(projectDir), MANIFEST_FILE);
}
export function typeDirPath(projectDir: string, type: FigmaManifestRecord["type"]): string {
return join(mediaDir(projectDir), TYPE_DIRS[type]);
}
/**
* Guards figma-OWNED rows only — media-use rows (bgm/sfx/icon, no
* provenance.source) legitimately fail this and are filtered out of
* figma's read-view. The shared file is the inventory; each writer
* reads its own rows. `nextId` is the one cross-writer concern and
* scans the full file.
*/
export function isFigmaManifestRecord(value: unknown): value is FigmaManifestRecord {
if (typeof value !== "object" || value === null) return false;
if (!("id" in value) || !("type" in value) || !("path" in value) || !("source" in value))
return false;
if (
typeof value.id !== "string" ||
(value.type !== "image" && value.type !== "video") ||
typeof value.path !== "string"
)
return false;
if (typeof value.source !== "string") return false;
if (!("provenance" in value)) return false;
const provenance = value.provenance;
if (typeof provenance !== "object" || provenance === null) return false;
if (!("source" in provenance) || !("fileKey" in provenance) || !("nodeId" in provenance))
return false;
return (
provenance.source === "figma" &&
typeof provenance.fileKey === "string" &&
typeof provenance.nodeId === "string"
);
}
export function readManifest(projectDir: string): FigmaManifestRecord[] {
return readJsonlValues(manifestPath(projectDir)).filter(isFigmaManifestRecord);
}
export function appendRecord(projectDir: string, record: FigmaManifestRecord): void {
mkdirSync(typeDirPath(projectDir, record.type), { recursive: true });
appendFileSync(manifestPath(projectDir), JSON.stringify(record) + "\n");
}
export function findByFigmaNode(
projectDir: string,
fileKey: string,
nodeId: string,
): FigmaManifestRecord | null {
return findAllByFigmaNode(projectDir, fileKey, nodeId)[0] ?? null;
}
/** EVERY row for a node — reuse gates must check all (format, scale, version)
* tuples, not just the oldest, or a second tuple defeats idempotency forever. */
export function findAllByFigmaNode(
projectDir: string,
fileKey: string,
nodeId: string,
): FigmaManifestRecord[] {
return readManifest(projectDir).filter(
(r) =>
r.provenance.source === "figma" &&
r.provenance.fileKey === fileKey &&
r.provenance.nodeId === nodeId,
);
}
/** Rewrite one row in place by id, preserving every other line (other
* writers' rows included) byte-for-byte. */
export function updateRecord(projectDir: string, record: FigmaManifestRecord): void {
const p = manifestPath(projectDir);
const lines = readFileSync(p, "utf8").split(/\r?\n/);
const out = lines.map((line) => {
const trimmed = line.trim();
if (trimmed.length === 0) return line;
try {
const parsed: unknown = JSON.parse(trimmed);
if (isFigmaManifestRecord(parsed) && parsed.id === record.id) return JSON.stringify(record);
} catch {
// non-JSON line — preserve untouched
}
return line;
});
writeFileSync(p, out.join("\n"));
}
export function nextId(projectDir: string, type: FigmaManifestRecord["type"]): string {
const re = new RegExp(`^${type}_(\\d+)$`);
let max = 0;
// Scan EVERY writer's rows (media-use included), not just figma-owned ones —
// ids and file paths are shared across the inventory, so a figma id minted
// from a figma-only view would collide with media-use's image_NNN files.
const p = manifestPath(projectDir);
if (!existsSync(p)) return `${type}_001`;
for (const line of readFileSync(p, "utf8").split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
if (
typeof parsed !== "object" ||
parsed === null ||
!("id" in parsed) ||
typeof parsed.id !== "string"
)
continue;
const m = parsed.id.match(re);
const n = m?.[1];
if (n !== undefined) max = Math.max(max, Number.parseInt(n, 10));
}
return `${type}_${String(max + 1).padStart(3, "0")}`;
}