@design-twin/dtwin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
@@ -0,0 +1,409 @@
1
+ // export-reader.ts — the READING side of an export: the one module every reader of the export layout asks, beside the
2
+ // modules that WRITE it (pages-layout.ts the pages/ tree, design-system-layout.ts the design-system split, both written
3
+ // by write-out.ts). It owns six things, and no reader re-derives any of them:
4
+ //
5
+ // 1) WHICH export: exportRoot(dir) — the dir given, else FIGMA_EXPORT_DIR, else this project's export (project-layout
6
+ // findExportDir: design/export, or design/ on the older flat layout). write-out.ts writes where this reads. A
7
+ // reader handed the design dir instead asks exportInDesignDir.
8
+ // 2) EVERY SCREEN: readExportIndex(root). An export indexes its screens twice: the root pages/index.json (`layers`,
9
+ // flattened across pages, plus `pageDirs`) and each page's own pages/<dir>/index.json. The two disagree by design:
10
+ // a page walk REPLACES the root index with that walk's pages alone (pages-layout.ts buildPageLayout, written whole
11
+ // by write-out.ts), while the page indexes of an earlier walk stay on disk and stay true. So "the root's `layers`"
12
+ // is not "every screen": a screen the first walk exported is in its page's index only once a second walk covers
13
+ // other pages. The index gives one answer:
14
+ // - rows: every row of the root index, then of every page index on disk (each `pageDirs` entry's index, then
15
+ // every pages/<dir>/index.json), each screen once. Two rows are one screen by the WRITER's identity
16
+ // (pages-layout.ts sameScreenRow, the rule mergeScreenIndex replaces a row by: the same file, or the same node
17
+ // of the same Figma file — the key when both rows carry one, else the file name; an unstamped row and a
18
+ // stamped one by their pageId), so a reader never keeps a row the next write would replace. The root's row
19
+ // (the newest write) beats a page index's. Between two page indexes' rows of one screen — a page renamed in
20
+ // Figma between pulls files the screen under a second dir, and the old dir's index keeps the old row — the
21
+ // row whose screen file was written last wins: every export rewrites the screen's file, while an index's own
22
+ // write time says nothing of its rows (a single-screen pull rewrites a page index and carries its other rows
23
+ // over unchanged).
24
+ // - isExported(id): some index lists the node.
25
+ // - fileOf(row): the row's screen file, inside the export root only (a hand-edited `file` with `..` reads nothing).
26
+ // - exportedAtOf(row): when the row's screen was pulled — its own stamp, else (a page walk stamps its root index
27
+ // once) the root index's, for a row the root index lists.
28
+ // - problems: every index that is there but unusable, by its export-relative name.
29
+ // 3) THE DESIGN SYSTEM'S STAMP: readDesignSystemStamp(root) — design-system.json's exportedAt/file/colorProfile, else
30
+ // those a split part carries (design-system-layout.ts stamps every part).
31
+ // 4) A MANIFEST POINTER: designSystemPart(root, part) — the file design-system.json's `files.<part>` names, inside the
32
+ // export only, or one sentence saying why there is none.
33
+ // 5) THE COMPONENT CATALOG: readComponentCatalog(root) — its rows and where they came from: the `files.componentsLocal`
34
+ // pointer (the split layout), else design-system.json's own inline `components` (an export from before the split);
35
+ // or why there is none.
36
+ // 6) FRESHNESS, PER SOURCE: readExportFreshness(root) — the design system's stamp (3), the page walk's, every screen (2)
37
+ // grouped by the Figma file it came from, and the newest screen. export-verdict.ts judges it (attribution, age,
38
+ // staleness), which is what doctor's export summary and figma_status's snapshot / lastScreenExport report.
39
+ //
40
+ // Nothing here throws: a missing or unreadable file adds nothing, and a reader that reports it gets the reason.
41
+ import fs from "node:fs";
42
+ import path from "node:path";
43
+ import { EXPORT_DIR, EXPORT_SUBDIR, findExportDir, looksLikeExportDir } from "./project-layout.ts";
44
+ import { sameScreenRow } from "./pages-layout.ts";
45
+ import { DESIGN_SYSTEM_DIR, DESIGN_SYSTEM_FILES } from "./design-system-layout.ts";
46
+ import { isRecord } from "./json-util.ts";
47
+ import { readJson } from "./read-json.ts";
48
+ import type { DocGuard, ReadResult } from "./read-json.ts";
49
+ import type { DesignSystemManifest, DesignSystemStamp, IndexRow, PageDirEntry } from "./doc-types.ts";
50
+
51
+ const PAGES = "pages";
52
+ const INDEX = "index.json";
53
+
54
+ /** The export root a reader reads: `dir` when given, else FIGMA_EXPORT_DIR, else this project's export (project-layout
55
+ * findExportDir: design/export, or design/ on the older flat layout) — absolute, resolved against `cwd`. */
56
+ export function exportRoot(dir?: string | null, cwd: string = process.cwd()): string {
57
+ return path.resolve(cwd, dir || process.env.FIGMA_EXPORT_DIR || findExportDir(cwd).rel);
58
+ }
59
+
60
+ /** The export a design dir holds, by findExportDir's rule one level down: `<designDir>/export`, else `<designDir>`
61
+ * itself when that is an export (the older flat layout). For a reader handed the design dir rather than the export. */
62
+ export function exportInDesignDir(designDir: string): string {
63
+ const sub = path.join(designDir, EXPORT_SUBDIR);
64
+ return !looksLikeExportDir(sub) && looksLikeExportDir(designDir) ? path.resolve(designDir) : path.resolve(sub);
65
+ }
66
+
67
+ /** `base` joined with a "/"-separated relative path, resolved, when it lies strictly inside `within` (default `base`) —
68
+ * else null (a pointer or a referenceImage.path with `..` segments never reads a file outside the export). Lexical
69
+ * only: no file is touched, and a symlink placed inside is followed (no writer here makes one). */
70
+ export function resolveInside(base: string, rel: string, within: string = base): string | null {
71
+ const root = path.resolve(within), file = path.resolve(base, ...rel.split("/"));
72
+ return file.startsWith(root + path.sep) ? file : null;
73
+ }
74
+
75
+ /** A pointer an export document holds ("/"-separated, relative to the export root), resolved inside the root only;
76
+ * an absolute pointer is refused too. */
77
+ const inside = (root: string, rel: unknown): string | null => (typeof rel === "string" && rel && !path.isAbsolute(rel) ? resolveInside(root, rel) : null);
78
+ /** A file an export document names ("/"-separated, relative to the export root — an index row's `file`), absolute,
79
+ * inside the root only; null for anything else (an absolute path, `..` leaving the root, a non-string). */
80
+ export const exportFileOf = (root: string, rel: unknown): string | null => inside(root, rel);
81
+
82
+ /** An export file that is there but unusable: its name relative to the export root ("/"-spelt), and why. */
83
+ export interface ExportProblem { readonly file: string; readonly error: string }
84
+ const relOf = (root: string, file: string): string => path.relative(root, file).split(path.sep).join("/");
85
+ const problemOf = (root: string, file: string, read: ReadResult<unknown>): ExportProblem | null =>
86
+ "error" in read && !read.missing ? { file: relOf(root, file), error: read.error } : null;
87
+
88
+ // ---------------------------------------------------------------- the index's guards
89
+ // Each checks the fields this module reads, and nothing more. A null row (an older index could hold one) is skipped.
90
+ // Each `Object.assign` is marked pure so a bundle that inlines this module for one helper drops the guards it never calls.
91
+ const optStr = (x: unknown): boolean => x === undefined || typeof x === "string";
92
+ const isIndexRowLike = (x: unknown): x is IndexRow => isRecord(x) && typeof x.id === "string" && typeof x.name === "string";
93
+ const isRowList = (x: unknown): x is Array<IndexRow | null> => Array.isArray(x) && x.every((r) => r === null || isIndexRowLike(r));
94
+ /** pages/index.json as read: `pageDirs` entries are checked for their `dir`/`index` pointers only. */
95
+ interface RootIndexOnDisk { pageDirs: Array<Partial<Pick<PageDirEntry, "dir" | "index">>>; layers?: Array<IndexRow | null>; exportedAt?: string; file?: unknown; sourceFile?: unknown }
96
+ const isPagesRootIndex: DocGuard<RootIndexOnDisk> = /* @__PURE__ */ Object.assign(
97
+ (x: unknown): x is RootIndexOnDisk => isRecord(x) && Array.isArray(x.pageDirs) && x.pageDirs.every((d) => isRecord(d) && optStr(d.dir) && optStr(d.index))
98
+ && (x.layers === undefined || isRowList(x.layers)) && optStr(x.exportedAt),
99
+ { expected: "the export's pages/index.json: an object with a `pageDirs` array (and `layers`, when present, an array of {id, name, file})" });
100
+ const isPageIndex: DocGuard<{ layers: Array<IndexRow | null> }> = /* @__PURE__ */ Object.assign(
101
+ (x: unknown): x is { layers: Array<IndexRow | null> } => isRecord(x) && isRowList(x.layers),
102
+ { expected: "a page index (pages/<Page>/index.json): an object with a `layers` array of {id, name, file}" });
103
+
104
+ // ---------------------------------------------------------------- where the index lives
105
+ /** A screen export's sibling file of `kind`: `<stem>.vars.json` (its variable slice) or `<stem>.assets.json` (its asset
106
+ * manifest) — pages-layout.ts screenPaths' names, beside the screen. */
107
+ export function screenSiblingFile(screenFile: string, kind: "vars" | "assets"): string {
108
+ return screenFile.replace(/\.json$/i, "") + "." + kind + ".json";
109
+ }
110
+
111
+ /** The root index file of the export at `exportRoot`. */
112
+ export const rootIndexFile = (exportRoot: string): string => path.join(exportRoot, PAGES, INDEX);
113
+ /** Whether `exportRoot` holds an export index (pages/index.json). */
114
+ export const hasExportIndex = (exportRoot: string): boolean => fs.existsSync(rootIndexFile(exportRoot));
115
+
116
+ /** The root of the pages/ tree a screen file sits in — `<root>/pages/<dir>/<Screen>.json` — whether or not its root
117
+ * index is there (readExportIndex still reads the page indexes of such a tree); else null. */
118
+ export function pagesRootOfScreen(screenFile: string): string | null {
119
+ const pages = path.dirname(path.dirname(path.resolve(screenFile)));
120
+ return path.basename(pages) === PAGES ? path.dirname(pages) : null;
121
+ }
122
+
123
+ /** The export root a screen file sits in — `<root>/pages/<dir>/<Screen>.json`, with `<root>/pages/index.json` there —
124
+ * else null (a legacy design/<Screen>.json, a file outside any export). */
125
+ export function exportRootOfScreen(screenFile: string): string | null {
126
+ const root = pagesRootOfScreen(screenFile);
127
+ return root !== null && hasExportIndex(root) ? root : null;
128
+ }
129
+
130
+ /** The export root a mistaken dir points at: the first ancestor holding pages/index.json, else one that sits just below
131
+ * (`<dir>/export`, `<dir>/design/export`). null when there is none. */
132
+ export function findExportRoot(dir: string): string | null {
133
+ const abs = path.resolve(dir);
134
+ for (let d = path.dirname(abs); ; d = path.dirname(d)) {
135
+ if (hasExportIndex(d)) return d;
136
+ if (path.dirname(d) === d) break;
137
+ }
138
+ for (const sub of [EXPORT_SUBDIR, EXPORT_DIR]) {
139
+ if (hasExportIndex(path.join(abs, sub))) return path.join(abs, sub);
140
+ }
141
+ return null;
142
+ }
143
+
144
+ // ---------------------------------------------------------------- every screen
145
+ /** One export's index, read once. */
146
+ export interface ExportIndex {
147
+ /** the export root, as given */
148
+ readonly root: string;
149
+ /** pages/index.json was read */
150
+ readonly indexed: boolean;
151
+ /** why pages/index.json is there but unusable; null when it was read or is absent */
152
+ readonly rootProblem: string | null;
153
+ /** every index that is there but unusable (the root's among them): its rows are not in `rows` */
154
+ readonly problems: readonly ExportProblem[];
155
+ /** every indexed screen once: the root index's rows, then the page indexes' (see the top of the file) */
156
+ readonly rows: readonly IndexRow[];
157
+ /** pages/index.json's own `exportedAt` (a page walk stamps its root index once); undefined when absent */
158
+ readonly rootExportedAt: string | undefined;
159
+ /** the Figma file pages/index.json names: its `file`, else its `sourceFile`, when a non-empty string */
160
+ readonly rootSourceFile: string | undefined;
161
+ /** some index lists the node */
162
+ isExported(id: string): boolean;
163
+ /** the row's screen file (absolute), when its `file` lies inside the export root; else null */
164
+ fileOf(row: Pick<IndexRow, "file">): string | null;
165
+ /** when the row's screen was pulled: its own `exportedAt`, else the root index's for a row the root index lists (a
166
+ * page walk stamps its root index once); undefined when neither is there */
167
+ exportedAtOf(row: IndexRow): string | undefined;
168
+ }
169
+
170
+ // Every page index file, each once (absolute): each pageDirs entry's `index` pointer (else pages/<dir>/index.json), then
171
+ // every other pages/<dir>/index.json on disk — a page an earlier walk exported is listed by no pageDirs entry any more.
172
+ function pageIndexFiles(root: string, pageDirs: RootIndexOnDisk["pageDirs"]): string[] {
173
+ const files: string[] = [];
174
+ const add = (f: string | null): void => { if (f !== null && !files.includes(path.resolve(f))) files.push(path.resolve(f)); };
175
+ const ofDir = (dir: string | undefined): string | null =>
176
+ dir && !/[\\/]/.test(dir) && dir !== ".." && dir !== "." ? path.join(root, PAGES, dir, INDEX) : null;
177
+ for (const pd of pageDirs) add(pd.index ? inside(root, pd.index) : ofDir(pd.dir));
178
+ let dirs: string[] = [];
179
+ try { dirs = fs.readdirSync(path.join(root, PAGES), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort(); } catch { /* no pages/ */ }
180
+ for (const d of dirs) add(ofDir(d));
181
+ return files;
182
+ }
183
+
184
+ /** The export at `exportRoot`'s index: every screen row once, under one merge rule. Never throws. */
185
+ export function readExportIndex(exportRoot: string): ExportIndex {
186
+ const root = exportRoot;
187
+ const rootFile = rootIndexFile(root);
188
+ const read = readJson(rootFile, isPagesRootIndex);
189
+ const rootDoc = "doc" in read ? read.doc : null;
190
+ const problems: ExportProblem[] = [];
191
+ const rootProblem = problemOf(root, rootFile, read);
192
+ if (rootProblem) problems.push(rootProblem);
193
+ const rows: IndexRow[] = [];
194
+ const byId = new Map<string, IndexRow[]>();
195
+ const files = new Set<string>();
196
+ const fromRoot = new Set<IndexRow>();
197
+ // when the row's screen file was last written; -1 when it cannot be read (it loses to any row whose file is there)
198
+ const written = new Map<IndexRow, number>();
199
+ const writtenAt = (row: IndexRow): number => {
200
+ let t = written.get(row);
201
+ if (t === undefined) {
202
+ const f = inside(root, row.file);
203
+ try { t = f === null ? -1 : fs.statSync(f).mtimeMs; } catch { t = -1; }
204
+ written.set(row, t);
205
+ }
206
+ return t;
207
+ };
208
+ const hasFile = (row: IndexRow): boolean => { const file: unknown = row.file; return typeof file === "string" && file !== ""; };
209
+ // A row with no `file` (hand-edited; the guards ask only for id and name) shares no file with another such row.
210
+ const probeOf = (row: IndexRow): IndexRow => (hasFile(row) ? row : { ...row, file: "\u0000no file " + rows.length });
211
+ // sameScreenRow(older, newer) is the writer's rule, and it is not symmetric: asked with the newer row second
212
+ const sameScreen = (older: IndexRow, newer: IndexRow): boolean => (hasFile(older) && older.file === newer.file) || sameScreenRow(probeOf(older), newer);
213
+ const keep = (row: IndexRow, isRoot: boolean): void => {
214
+ rows.push(row);
215
+ const bucket = byId.get(row.id);
216
+ if (bucket) bucket.push(row); else byId.set(row.id, [row]);
217
+ if (hasFile(row)) files.add(row.file);
218
+ if (isRoot) fromRoot.add(row);
219
+ };
220
+ // one screen, one row (see the top of the file). The root's rows come first: the writer merged them already, and the
221
+ // first of two it left is kept. A page index's row is dropped beside a root row of its screen; beside page rows of its
222
+ // screen it replaces every one of them when its screen file was written after each, else it is dropped.
223
+ const take = (row: IndexRow, isRoot: boolean): void => {
224
+ const candidates = [...(byId.get(row.id) ?? []), ...(hasFile(row) && files.has(row.file) ? rows.filter((k) => k.file === row.file && k.id !== row.id) : [])];
225
+ if (isRoot) { if (!candidates.some((k) => sameScreen(row, k))) keep(row, true); return; }
226
+ const newer = (k: IndexRow): boolean => writtenAt(row) > writtenAt(k);
227
+ const conflicts = candidates.filter((k) => (fromRoot.has(k) || !newer(k) ? sameScreen(row, k) : sameScreen(k, row)));
228
+ if (!conflicts.length) { keep(row, false); return; }
229
+ if (conflicts.some((k) => fromRoot.has(k) || !newer(k))) return;
230
+ const at = Math.min(...conflicts.map((k) => rows.indexOf(k))); // the earliest: removing the others leaves it in place
231
+ for (const k of conflicts) {
232
+ rows.splice(rows.indexOf(k), 1);
233
+ const bucket = byId.get(k.id) ?? [];
234
+ bucket.splice(bucket.indexOf(k), 1);
235
+ if (!bucket.length) byId.delete(k.id);
236
+ if (hasFile(k) && !rows.some((r) => r.file === k.file)) files.delete(k.file);
237
+ }
238
+ keep(row, false);
239
+ rows.splice(rows.length - 1, 1);
240
+ rows.splice(at, 0, row);
241
+ };
242
+ for (const row of rootDoc?.layers ?? []) if (row) take(row, true);
243
+ for (const f of pageIndexFiles(root, rootDoc?.pageDirs ?? [])) {
244
+ const page = readJson(f, isPageIndex);
245
+ const problem = problemOf(root, f, page);
246
+ if (problem) problems.push(problem);
247
+ if ("doc" in page) for (const row of page.doc.layers) if (row) take(row, false);
248
+ }
249
+ const rootStamp = rootDoc?.exportedAt || undefined;
250
+ const label = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined);
251
+ const rootLabel = rootDoc ? label(rootDoc.file) ?? label(rootDoc.sourceFile) : undefined;
252
+ return {
253
+ root,
254
+ indexed: rootDoc !== null,
255
+ rootProblem: rootProblem && rootProblem.error,
256
+ problems,
257
+ rows,
258
+ rootExportedAt: rootStamp,
259
+ rootSourceFile: rootLabel,
260
+ isExported: (id) => byId.has(id),
261
+ fileOf: (row) => inside(root, row.file),
262
+ exportedAtOf: (row) => (typeof row.exportedAt === "string" && row.exportedAt) || (fromRoot.has(row) ? rootStamp : undefined),
263
+ };
264
+ }
265
+
266
+ // ---------------------------------------------------------------- the design system
267
+ /** A part design-system.json's `files` map points at. */
268
+ export type DesignSystemPart = keyof DesignSystemManifest["files"];
269
+ /** design-system.json as a reader sees it: the stamp and the `files` pointer map (each pointer checked when followed).
270
+ * A pre-split export inlined `components` here instead. */
271
+ export interface DesignSystemManifestView extends DesignSystemStamp { files?: Record<string, unknown>; components?: unknown }
272
+ const isStampLike = (x: unknown): x is DesignSystemStamp => isRecord(x) && optStr(x.exportedAt) && optStr(x.file) && optStr(x.colorProfile);
273
+ const isDesignSystemManifest: DocGuard<DesignSystemManifestView> = /* @__PURE__ */ Object.assign(
274
+ (x: unknown): x is DesignSystemManifestView => isStampLike(x) && isRecord(x) && (x.files === undefined || isRecord(x.files)),
275
+ { expected: "a design-system manifest: an object whose `exportedAt`, `file` and `colorProfile`, when present, are strings and whose `files`, when present, is an object of pointers" });
276
+ const isStampedPart: DocGuard<DesignSystemStamp> = /* @__PURE__ */ Object.assign(isStampLike,
277
+ { expected: "a design-system part: an object whose `exportedAt`, `file` and `colorProfile`, when present, are strings" });
278
+
279
+ /** design-system.json of the export at `root`. */
280
+ export const designSystemManifestFile = (root: string): string => path.join(root, DESIGN_SYSTEM_FILES.MANIFEST);
281
+ /** design-system.json, read and checked (the reason, when unusable, follows the file name). */
282
+ export const readDesignSystemManifest = (root: string): ReadResult<DesignSystemManifestView> => readJson(designSystemManifestFile(root), isDesignSystemManifest);
283
+
284
+ const stampOf = (doc: DesignSystemStamp): DesignSystemStamp => {
285
+ const s: DesignSystemStamp = {};
286
+ if (doc.exportedAt !== undefined) s.exportedAt = doc.exportedAt;
287
+ if (doc.file !== undefined) s.file = doc.file;
288
+ if (doc.colorProfile !== undefined) s.colorProfile = doc.colorProfile;
289
+ return s;
290
+ };
291
+
292
+ /** The design system's stamp and the file it was read from — design-system.json's, else that of
293
+ * design-system/tokens.json (every split part carries it); of the two, the first that carries `exportedAt`, else the
294
+ * first usable. null when neither is there and usable; `problems` names each that is there but unusable. */
295
+ export function readDesignSystemStamp(root: string): { stamp: DesignSystemStamp | null; from: string | null; problems: ExportProblem[] } {
296
+ const problems: ExportProblem[] = [];
297
+ const manifestFile = designSystemManifestFile(root);
298
+ const manifest = readDesignSystemManifest(root);
299
+ if ("doc" in manifest && manifest.doc.exportedAt) return { stamp: stampOf(manifest.doc), from: manifestFile, problems };
300
+ const mp = problemOf(root, manifestFile, manifest);
301
+ if (mp) problems.push(mp);
302
+ const tokensFile = path.join(root, DESIGN_SYSTEM_DIR, DESIGN_SYSTEM_FILES.TOKENS);
303
+ const tokens = readJson(tokensFile, isStampedPart);
304
+ const tp = problemOf(root, tokensFile, tokens);
305
+ if (tp) problems.push(tp);
306
+ if ("doc" in tokens && tokens.doc.exportedAt) return { stamp: stampOf(tokens.doc), from: tokensFile, problems };
307
+ if ("doc" in manifest) return { stamp: stampOf(manifest.doc), from: manifestFile, problems };
308
+ return "doc" in tokens ? { stamp: stampOf(tokens.doc), from: tokensFile, problems } : { stamp: null, from: null, problems };
309
+ }
310
+
311
+ // A pointer of a manifest already read: the file, inside the export only, or why there is none.
312
+ function partOf(root: string, manifest: ReadResult<DesignSystemManifestView>, part: DesignSystemPart): { file: string } | { error: string } {
313
+ const file = designSystemManifestFile(root);
314
+ if ("error" in manifest) {
315
+ return { error: manifest.missing
316
+ ? `no design export found at ${file} — run an export first (a --design-system or full pull)`
317
+ : `${file} ${manifest.error} — re-export with a current dtwin` };
318
+ }
319
+ const rel = manifest.doc.files?.[part];
320
+ if (typeof rel !== "string" || !rel) return { error: `${file} has no files.${part} pointer — re-export with a current dtwin` };
321
+ const target = inside(root, rel);
322
+ return target === null ? { error: `${file} files.${part} ('${rel}') leads outside the export — re-export with a current dtwin` } : { file: target };
323
+ }
324
+
325
+ /** The file design-system.json's `files.<part>` pointer names (absolute), inside the export only — else why there is
326
+ * none, as one sentence naming the manifest. A reader that cannot go on without the part says it; one that can, may. */
327
+ export function designSystemPart(root: string, part: DesignSystemPart): { file: string } | { error: string } {
328
+ return partOf(root, readDesignSystemManifest(root), part);
329
+ }
330
+
331
+ // ---------------------------------------------------------------- the component catalog
332
+ /** The export's component catalog: its rows and the file they were read from — `pointer`: the file design-system.json's
333
+ * `files.componentsLocal` names (the split layout); `inline`: design-system.json itself, whose own `components` array an
334
+ * export from before the split carries — or why there is none (`missing`: no design-system.json at all, i.e. nothing
335
+ * exported yet, which a reader that can go on without the catalog may pass over). Rows are as on disk: each reader
336
+ * checks the fields it reads. */
337
+ export type ComponentCatalog =
338
+ | { readonly file: string; readonly source: "pointer" | "inline"; readonly components: readonly unknown[] }
339
+ | { readonly error: string; readonly missing: boolean };
340
+ const isCatalogDoc: DocGuard<{ components: unknown[] }> = /* @__PURE__ */ Object.assign(
341
+ (x: unknown): x is { components: unknown[] } => isRecord(x) && Array.isArray(x.components),
342
+ { expected: "a component catalog: an object with a `components` array" });
343
+
344
+ /** The component catalog of the export at `root`: the `files.componentsLocal` pointer when the manifest has one, else
345
+ * the manifest's inline `components` (a pre-split export). Never throws. */
346
+ export function readComponentCatalog(root: string): ComponentCatalog {
347
+ const manifest = readDesignSystemManifest(root);
348
+ if ("doc" in manifest && manifest.doc.files?.componentsLocal === undefined && Array.isArray(manifest.doc.components)) {
349
+ return { file: designSystemManifestFile(root), source: "inline", components: manifest.doc.components };
350
+ }
351
+ const part = partOf(root, manifest, "componentsLocal");
352
+ if ("error" in part) return { error: part.error, missing: "error" in manifest && manifest.missing === true };
353
+ const catalog = readJson(part.file, isCatalogDoc);
354
+ return "doc" in catalog
355
+ ? { file: part.file, source: "pointer", components: catalog.doc.components }
356
+ : { error: `${part.file} ${catalog.error}`, missing: false };
357
+ }
358
+
359
+ // ---------------------------------------------------------------- freshness, per source
360
+ /** The screens one Figma file contributed: rows sharing a `sourceFile` (null: rows pulled before rows were stamped). */
361
+ export interface ScreenSource {
362
+ readonly sourceFile: string | null;
363
+ readonly count: number;
364
+ /** the newest parseable stamp among them (exportedAtOf); undefined when none has one */
365
+ readonly newestAt: string | undefined;
366
+ /** the row that stamp is on; null when none has one */
367
+ readonly newestRow: IndexRow | null;
368
+ }
369
+ /** How fresh the export at `root` is, per source, read once: the design system's stamp, the page walk's, every screen
370
+ * grouped by the Figma file it came from, and the newest screen. */
371
+ export interface ExportFreshness {
372
+ readonly root: string;
373
+ /** readDesignSystemStamp's answer, when there is one: the stamp and the file (absolute) it was read from */
374
+ readonly designSystem: { readonly stamp: DesignSystemStamp; readonly from: string } | null;
375
+ /** pages/index.json's own stamp and the Figma file it names, when it was read */
376
+ readonly index: { readonly from: string; readonly exportedAt: string | undefined; readonly sourceFile: string | undefined } | null;
377
+ /** every indexed screen (readExportIndex), grouped by source in first-seen order */
378
+ readonly screens: readonly ScreenSource[];
379
+ /** the row with the newest parseable stamp (a tie goes to the later row: merges append the newest pull) */
380
+ readonly newestScreen: { readonly row: IndexRow; readonly exportedAt: string; readonly t: number } | null;
381
+ /** every manifest, part or index that is there but unusable — the design system's first, as a reader reports them */
382
+ readonly problems: readonly ExportProblem[];
383
+ }
384
+
385
+ /** The export at `root`'s freshness, per source. Never throws. */
386
+ export function readExportFreshness(root: string): ExportFreshness {
387
+ const ix = readExportIndex(root);
388
+ const ds = readDesignSystemStamp(root);
389
+ const groups = new Map<string | null, { count: number; newestAt: string | undefined; newestRow: IndexRow | null; t: number }>();
390
+ let newestScreen: ExportFreshness["newestScreen"] = null;
391
+ for (const row of ix.rows) {
392
+ const at = ix.exportedAtOf(row);
393
+ const t = at ? Date.parse(at) : NaN;
394
+ const key = typeof row.sourceFile === "string" && row.sourceFile ? row.sourceFile : null;
395
+ let g = groups.get(key);
396
+ if (!g) { g = { count: 0, newestAt: undefined, newestRow: null, t: -Infinity }; groups.set(key, g); }
397
+ g.count++;
398
+ if (at && !Number.isNaN(t) && t > g.t) { g.newestAt = at; g.newestRow = row; g.t = t; }
399
+ if (row.id && at && !Number.isNaN(t) && (!newestScreen || t >= newestScreen.t)) newestScreen = { row, exportedAt: at, t };
400
+ }
401
+ return {
402
+ root,
403
+ designSystem: ds.stamp && ds.from ? { stamp: ds.stamp, from: ds.from } : null,
404
+ index: ix.indexed ? { from: rootIndexFile(root), exportedAt: ix.rootExportedAt, sourceFile: ix.rootSourceFile } : null,
405
+ screens: [...groups].map(([sourceFile, g]) => ({ sourceFile, count: g.count, newestAt: g.newestAt, newestRow: g.newestRow })),
406
+ newestScreen,
407
+ problems: [...ds.problems, ...ix.problems],
408
+ };
409
+ }