@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,483 @@
1
+ // The ONE definition of the pages/ layout an export lands in — shared by both writers.
2
+ //
3
+ // layers.json bundling EVERY exported top-level node into one file was measured at 24MB on a real
4
+ // design-system export, unworkable for an agent that only wants to load one at a time. So it is split further:
5
+ // one directory per Figma PAGE (mirroring Figma's own Page > Frame containment — a page can hold many
6
+ // layers, never the reverse; "layer" is Figma's OWN term for any object in the file, per its Layers
7
+ // panel — "screen" stays reserved for a hand-picked single selection, not this indiscriminate sweep),
8
+ // one small file per layer inside it (safe(name) + safe(id), so same-named frames on different pages
9
+ // still can't collide — and pages themselves are bucketed by their ID, since page NAMES aren't unique
10
+ // either), a per-page index.json (so an agent working ONE page never has to load every
11
+ // OTHER page's manifest), plus a slim root pages/index.json carrying the run-wide manifest (manifest
12
+ // is accumulated per-RUN, not per-layer — see run.ts) and a `pageDirs` pointer to each page's
13
+ // directory + index file.
14
+ //
15
+ // TWO writers materialise this: bridge/src/figma-pull.ts writes real nested directories, and the plugin's
16
+ // browser-download path (figma-plugin/src/main.ts) cannot — the HTML spec says user agents "should
17
+ // ignore any directory or path information provided by ... any download attribute", so `pages/Foo/bar
18
+ // .json` lands flat under a mangled name and every `file:` pointer with a slash was already dangling.
19
+ // It encodes the same hierarchy in the FILENAME instead. That separator is the ONLY difference, so it
20
+ // is the only knob here — everything else (bucketing, dir disambiguation, file naming, index shape,
21
+ // pageDirs) is computed once, in this file, and each caller only writes bytes. Kept dependency-free
22
+ // (no Node imports, ES2019-safe) so the bridge CLI can import it and esbuild can inline it into the
23
+ // plugin bundle — it type-checks under the plugin's tsconfig as well as the bridge's.
24
+
25
+ import type { LayerFile, IndexRow, PageDirEntry, PageIndex, PagesRootIndex, LayersDoc } from "./doc-types.ts";
26
+ import { ifDefined } from "./json-util.ts";
27
+
28
+ /** One file a layout builder emits: where it lands (joined with the caller's separator — emit this
29
+ * verbatim) and its contents as an object (the caller decides how to stringify it). */
30
+ export interface PageLayoutFile<T> {
31
+ path: string;
32
+ data: T;
33
+ }
34
+
35
+ export interface PageLayout {
36
+ /**
37
+ * layersDoc minus layers/index, plus the computed `pageDirs` — one entry per page, carrying
38
+ * `{page, pageId?, dir, index, layers}`. Pages are bucketed by `pageId`; `page` is a display name
39
+ * and is NOT unique (Figma allows two pages of the same name), so `pageId` is the field to match
40
+ * on. It is absent on exports written before page ids were emitted. Becomes pages/index.json.
41
+ */
42
+ meta: PagesRootIndex;
43
+ layerFiles: Array<PageLayoutFile<LayerFile>>;
44
+ /** One per page: where its index.json lands, and what goes in it (`{page, pageId?, layers}`). */
45
+ indexFiles: Array<PageLayoutFile<PageIndex>>;
46
+ /** Path of the root pages index, joined with the caller's separator. */
47
+ rootIndex: string;
48
+ }
49
+
50
+ /** The fields the title/text walkers below read. Any IR node — or a partial one, or `{}` — fits. */
51
+ export interface TextWalkNode {
52
+ name?: string;
53
+ type?: string;
54
+ text?: string;
55
+ /** a hidden DESCENDANT and its subtree are skipped by every walker; the walk's own root is read even
56
+ * when hidden (a hidden frame pulled on its own keeps its title and texts) */
57
+ hidden?: boolean;
58
+ /** any iterable: an IR node's array, or the plugin's lazy live-node adapter (collect.ts titleOf) */
59
+ children?: Iterable<TextWalkNode>;
60
+ }
61
+
62
+ // Filesystem-boundary sanitiser for names that become paths. Asset filenames are NOT derived here —
63
+ // the plugin names each asset (assets.ts) and the node tree's `asset` path is built from that same
64
+ // string, so re-deriving the convention could silently point every path at a missing file.
65
+ export function safe(id: unknown): string {
66
+ return String(id).replace(/[^a-zA-Z0-9]/g, "_");
67
+ }
68
+
69
+ // The inverse of safe() over a node id. safe() folds both of an id's separators to "_" — the ":" inside
70
+ // each "<digits>:<digits>" pair and the ";" that chains an instance sublayer's path — so the inverse
71
+ // rebuilds them from the shape a Figma id has: a plain id is ONE pair ("12:34"); an instance-sublayer id
72
+ // (what a --node or --selection pull of a layer inside an instance files under) is "I" + the instance's
73
+ // pair + one pair per level below it, ";"-joined ("I12:34;56:78"), so never fewer than two pairs. Every
74
+ // other shape — an odd number count, a lone "I12_34", several pairs without the "I" — is no id the
75
+ // writers here put in a filename, and gives null.
76
+ const SAFE_NODE_ID_RE = /^(?:\d+_\d+|I\d+_\d+(?:_\d+_\d+)+)$/;
77
+
78
+ /** `12_34` -> "12:34", `I12_34_56_78` -> "I12:34;56:78"; null for anything safe() never makes of a node id. */
79
+ export function nodeIdFromSafe(safeId: string): string | null {
80
+ if (!SAFE_NODE_ID_RE.test(safeId)) return null;
81
+ // The shape is checked, so the numbers pair up left to right: each pair gets its ":", the "_" left
82
+ // between pairs is the ";".
83
+ return safeId.replace(/(\d+)_(\d+)/g, "$1:$2").replace(/_/g, ";");
84
+ }
85
+
86
+ /**
87
+ * The node id a `<safe(name)>__<safe(id)>` stem (buildPageLayout's layer files, screenPaths' `base`) carries,
88
+ * or null when it carries none. The split is at the LAST "__": a safe() name can hold "__" itself (or end in
89
+ * "_"), a safe() id never does. Takes the stem only — a caller holding a file name drops its tail first.
90
+ */
91
+ export function nodeIdFromStem(stem: unknown): string | null {
92
+ const s = String(stem || "");
93
+ const i = s.lastIndexOf("__");
94
+ return i < 0 ? null : nodeIdFromSafe(s.slice(i + 2));
95
+ }
96
+
97
+ /**
98
+ * The ONE stem a screen's files share, whichever pull shape wrote them: `<safe(name)>__<safe(id)>` ("layer" when the
99
+ * layer has no name). The id makes it unique (two frames called `Popup` are two stems) and nodeIdFromStem reads it
100
+ * back; the name is only for a person reading the tree. buildPageLayout's layer files and screenPaths' `base` are
101
+ * both this.
102
+ */
103
+ export function screenStem(name: unknown, id: unknown): string {
104
+ return safe(name || "layer") + "__" + safe(id);
105
+ }
106
+
107
+ // --------------------------------------------------------- title / texts
108
+ //
109
+ // The Figma LAYER name is not the name a user reads on screen: `positions ` (trailing space) IS "Jet
110
+ // Roles"; `Studio Configurations` IS "Guided Policies". A user who types the name they see gets
111
+ // nothing from the index unless the index also carries that visible title. It must come from the
112
+ // frame's OWN title slot, never from a sidebar/nav label drawn on every sibling screen ("Guided Policies"
113
+ // also appears as a `Sub titles` nav item on all three Optimization-management
114
+ // screens) — so the rule is: the first TEXT node inside a descendant literally named "Page Title",
115
+ // and ONLY that; anywhere else and we would rather carry no title than a wrong one.
116
+ // Every walker below skips a hidden CHILD (and so its subtree), never the node it was called on: `shown`.
117
+ const shown = (c: TextWalkNode | null | undefined): c is TextWalkNode => !!c && typeof c === "object" && !c.hidden;
118
+
119
+ export function firstByName(node: TextWalkNode | null | undefined, name: string): TextWalkNode | null {
120
+ if (!node || typeof node !== "object") return null;
121
+ if (node.name === name) return node;
122
+ for (const c of node.children || []) {
123
+ const found = shown(c) ? firstByName(c, name) : null;
124
+ if (found) return found;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ // Figma's own placeholder for an unbound TEXT override inside a component instance renders the
130
+ // literal string "Text" (verified in the real export: a `Page Title` instance's leading `Breadcrumb`
131
+ // child carries one, "Back to Employees" style nav trails do too) — never a real title, so it is
132
+ // excluded rather than trusted as "the first text we found".
133
+ export const PLACEHOLDER_TEXT: ReadonlySet<string> = new Set(["text", "label"]);
134
+
135
+ export function firstText(node: TextWalkNode | null | undefined): string | null {
136
+ if (!node || typeof node !== "object") return null;
137
+ if (node.type === "TEXT" && typeof node.text === "string") {
138
+ const t = node.text.trim();
139
+ if (t && !PLACEHOLDER_TEXT.has(t.toLowerCase())) return node.text;
140
+ }
141
+ for (const c of node.children || []) {
142
+ const found = shown(c) ? firstText(c) : null;
143
+ if (found) return found;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ // Within the Page Title slot, the layer that actually carries the visible title is itself named
149
+ // "Title" (verified across all five real screens in the test export: `Page Title > Title(FRAME) >
150
+ // Title Side(FRAME) > Title(TEXT)`, sitting AFTER a `Breadcrumb`/back-link instance that comes first
151
+ // in reading order but carries only nav chrome and unbound placeholder text). Preferring the node
152
+ // literally named "Title" over "first TEXT in the subtree" is what keeps the breadcrumb from winning.
153
+ function firstNamedText(node: TextWalkNode | null | undefined, name: string): string | null {
154
+ if (!node || typeof node !== "object") return null;
155
+ if (node.type === "TEXT" && node.name === name && typeof node.text === "string" && node.text.trim()) {
156
+ return node.text;
157
+ }
158
+ for (const c of node.children || []) {
159
+ const found = shown(c) ? firstNamedText(c, name) : null;
160
+ if (found) return found;
161
+ }
162
+ return null;
163
+ }
164
+
165
+ // The frame's own `Page Title`-shaped instance's text first (a value that is
166
+ // scoped to THIS frame, not repeated on every sibling), else the first TEXT node in the whole tree
167
+ // in reading order (placeholder text excluded either way). If nothing yields a non-empty string,
168
+ // return undefined — JSON.stringify drops an undefined value, so the row simply carries no `title`
169
+ // (never a guessed/wrong one).
170
+ export function deriveTitle(root: TextWalkNode | null | undefined): string | undefined {
171
+ const slot = firstByName(root, "Page Title");
172
+ if (slot) {
173
+ const named = firstNamedText(slot, "Title");
174
+ if (named) return named;
175
+ const anyText = firstText(slot);
176
+ if (anyText) return anyText;
177
+ }
178
+ const fallback = firstText(root);
179
+ return fallback || undefined;
180
+ }
181
+
182
+ // The first N distinct, non-empty text strings in the tree, in reading order — a coarse fingerprint
183
+ // a text search (or a human eyeballing the index) can match against, independent of the layer name.
184
+ export function collectTexts(root: TextWalkNode | null | undefined, limit?: number): string[] {
185
+ const n = limit || 8;
186
+ const seen = new Set<string>();
187
+ const out: string[] = [];
188
+ (function walk(node: TextWalkNode | null | undefined, isRoot: boolean): void {
189
+ if (!node || typeof node !== "object" || (node.hidden && !isRoot) || out.length >= n) return;
190
+ if (node.type === "TEXT" && typeof node.text === "string") {
191
+ const t = node.text.trim();
192
+ if (t && !seen.has(t)) {
193
+ seen.add(t);
194
+ out.push(t);
195
+ }
196
+ }
197
+ for (const c of node.children || []) {
198
+ if (out.length >= n) break;
199
+ walk(c, false);
200
+ }
201
+ })(root, true);
202
+ return out;
203
+ }
204
+
205
+ // A page's build state: its identity, its directory, and the index rows collected for it.
206
+ interface PageBucket {
207
+ page: string;
208
+ pageId?: string;
209
+ dir: string;
210
+ index: string;
211
+ entries: IndexRow[];
212
+ }
213
+
214
+ /**
215
+ * @param layersDoc the collector's layersDoc ({layers, index, ...manifest fields})
216
+ * @param sep path separator: "/" for real directories, "__" for flat download names
217
+ */
218
+ export function buildPageLayout(layersDoc: LayersDoc | null | undefined, sep: string): PageLayout {
219
+ const doc: LayersDoc = layersDoc || {};
220
+ const { layers, index, ...rest } = doc;
221
+ const join = (...parts: string[]): string => ["pages"].concat(parts).join(sep);
222
+ const byPage = new Map<string, PageBucket>(); // page IDENTITY (id, or name pre-pageId) -> { dir, entries: [{...index[i], file}] }
223
+ const pages: PageBucket[] = [];
224
+ const layerFiles: Array<PageLayoutFile<LayerFile>> = [];
225
+ // safe() folds every non-alphanumeric to "_", so DISTINCT page names collide ("Design System" /
226
+ // "Design/System" / "Design-System" all -> "Design_System"). Sharing a directory meant the second
227
+ // page's index.json OVERWROTE the first's, leaving the first page's layer files on disk but absent
228
+ // from every index — invisible to any agent following the manifest. Layer filenames already carry
229
+ // an id suffix for exactly this reason; page dirs get the same.
230
+ const usedDirs = new Set<string>();
231
+ const uniqueDir = (name: string): string => {
232
+ const base = safe(name) || "page";
233
+ if (!usedDirs.has(base)) { usedDirs.add(base); return base; }
234
+ let i = 2;
235
+ while (usedDirs.has(base + "_" + i)) i++;
236
+ usedDirs.add(base + "_" + i);
237
+ return base + "_" + i;
238
+ };
239
+ (layers || []).forEach((l, i) => {
240
+ const pageName = l.page || "(no page)";
241
+ // Bucket on the page ID, not its name. Figma's Plugin API documents NO uniqueness constraint on
242
+ // PageNode.name (it is just "the name that appears in the layers panel", user-editable), and two
243
+ // pages really can both be called "Screens" — collect.ts's resolveOne refuses an ambiguous name
244
+ // for that reason. Keyed by name, those two DISTINCT pages merged into one bucket: one dir, one
245
+ // index listing both, and a pageDirs entry claiming a layer count for a page that doesn't exist
246
+ // as one page in Figma. uniqueDir() never fired, because only one bucket was ever created.
247
+ // Fall back to the name when pageId is absent so exports written before it still lay out.
248
+ const key = l.pageId || "name:" + pageName;
249
+ let bucket = byPage.get(key);
250
+ if (!bucket) {
251
+ // The DIRECTORY still comes from the name (dirs are for humans reading the tree); uniqueDir()
252
+ // suffixes the duplicate, exactly as it already did for distinct names that fold alike.
253
+ const dir = uniqueDir(pageName);
254
+ // `index` is a POINTER, like every layer entry's `file`, not something the consumer reassembles
255
+ // from `dir` — a consumer rebuilding `pages/<dir>/index.json` would be right on exactly one of
256
+ // the two layouts. Emitting the real relative path on both makes the rule uniform: open
257
+ // `<outDir>/<index>` verbatim, whichever layout you were handed.
258
+ bucket = { page: pageName, ...ifDefined("pageId", l.pageId), dir, index: join(dir, "index.json"), entries: [] };
259
+ byPage.set(key, bucket);
260
+ pages.push(bucket);
261
+ }
262
+ const base = screenStem(l.name, l.id) + ".json";
263
+ // layersDoc.sourceFile is stamped by write-out.ts's writeExport (from what figma-pull.ts
264
+ // resolved the connected client to be) — carry it onto every per-layer file too, same field a
265
+ // single-screen pull's writeScreen stamps, so doctor's exportSourceCounts() reads one field regardless
266
+ // of which pull shape produced the screen.
267
+ const src = layersDoc && layersDoc.sourceFile ? { sourceFile: layersDoc.sourceFile } : {};
268
+ // the index row carries the file's key too (mergeScreenIndex tells two files of one name apart by it)
269
+ const rowSrc = layersDoc && layersDoc.sourceFile && layersDoc.sourceFileKey ? { ...src, sourceFileKey: layersDoc.sourceFileKey } : src;
270
+ layerFiles.push({
271
+ path: join(bucket.dir, base),
272
+ data: { name: l.name, id: l.id, ...ifDefined("page", l.page), ...ifDefined("pageId", l.pageId), ...src, tree: l.tree, ...ifDefined("reference", l.reference), ...ifDefined("devResources", l.devResources) },
273
+ });
274
+ // title/texts come from THIS layer's own tree — computed here, once,
275
+ // so both the per-page index and the root index (below) carry the same values for the same layer.
276
+ const title = l.tree ? deriveTitle(l.tree) : undefined;
277
+ const texts = l.tree ? collectTexts(l.tree) : undefined;
278
+ // The collector pushes `layers` and `index` in lockstep (collect.ts), so row i exists. If a
279
+ // hand-built layersDoc ever runs short, the row is undefined and the entry is written without its
280
+ // name/id, exactly as the spread of undefined always did. Object.assign (not a spread) because it
281
+ // keeps that runtime while its typing (`{} & (Row | undefined) & …`) drops the undefined branch;
282
+ // same keys, same order, same values as `{ ...row, title, texts, ...rowSrc, file }`.
283
+ bucket.entries.push(Object.assign({}, (index || [])[i], { title, texts, ...rowSrc, file: join(bucket.dir, base) }));
284
+ });
285
+ // `pageId` is what makes two same-named entries tellable apart by a consumer — without it the only
286
+ // difference between them would be the disambiguating "_2" on a directory name, which is a
287
+ // filesystem artefact, not identity. Omitted (not null) on pre-pageId exports: JSON.stringify drops
288
+ // an undefined value, so the field's ABSENCE means "this export predates page ids", not "no page".
289
+ const pageDirs: PageDirEntry[] = pages.map((b) => ({ page: b.page, ...ifDefined("pageId", b.pageId), dir: b.dir, index: b.index, layers: b.entries.length }));
290
+ // A consumer is told to read the ROOT index (extract/SKILL.md), not walk every pageDirs.index one
291
+ // hop down — so the root carries the same per-screen rows the per-page index does,
292
+ // flattened across every page. Additive: pageDirs keeps its own shape/consumers (doctor.ts et al).
293
+ // Object.assign onto the rest-copy (not a fresh literal) keeps the established key order: the
294
+ // manifest fields first, then pageDirs, then layers.
295
+ const meta: PagesRootIndex = Object.assign(rest, { pageDirs, layers: pages.flatMap((b) => b.entries) });
296
+ // The per-page index CONTENTS, not just its path — same reasoning as layerFiles. Both writers would
297
+ // otherwise spell `{ page, layers: entries }` out themselves, which would put the one shape this module exists to
298
+ // single-source back into two files; they only stringify and write.
299
+ const indexFiles: Array<PageLayoutFile<PageIndex>> = pages.map((b) => ({ path: b.index, data: { page: b.page, ...ifDefined("pageId", b.pageId), layers: b.entries } }));
300
+ // `pages`/`byPage` stay BUILD STATE, deliberately not returned: the same page list is already public
301
+ // as meta.pageDirs (dir + index + layer count), and returning a second view of it invited each writer
302
+ // to pick a different one — three shapes to keep in sync for one list. Writers mkdir from pageDirs.
303
+ return { meta, layerFiles, indexFiles, rootIndex: join("index.json") };
304
+ }
305
+
306
+
307
+ // ---------------------------------------------------------------- the single-screen twin
308
+ //
309
+ // A --node/--selection pull does not land FLAT as design/<Screen>.json, named from the layer alone. That
310
+ // would give a frame whose name ends in a space a file ending in `_`, let a second frame called `Popup`
311
+ // silently overwrite the first, and leave the pull's variable slice and its assets with nowhere
312
+ // per-screen to live — while a --page pull of the very same frame writes the nested layout above.
313
+ // Two layouts for one kind of content, and every downstream skill would have to know which one it was
314
+ // looking at.
315
+ //
316
+ // So a screen files into the SAME tree: pages/<PageDir>/<Name>__<id>.json, with its token slice and
317
+ // its asset index as siblings. The difference from buildPageLayout is arrival, not shape — a page
318
+ // pull writes every layer at once and can compute its index in one pass, whereas screens accumulate
319
+ // one pull at a time, so the indexes here MERGE with whatever is already on disk (mergeScreenIndex
320
+ // below). The caller supplies the previous index; this module stays pure.
321
+ //
322
+ // `id` in the filename is the frame's own node id, which is what makes two same-named frames
323
+ // distinguishable — the same reason buildPageLayout suffixes its layer files.
324
+ export const NO_PAGE_DIR = "_unfiled";
325
+
326
+ /** Where a page/node identity can sit on the single-screen result screenPaths is handed. `nodes` is the screen doc's
327
+ * node list: its first node is the root, whose id stands in for a reply that carries no nodeId. */
328
+ interface ScreenIdentity {
329
+ page?: string;
330
+ pageId?: string;
331
+ nodeId?: string;
332
+ nodes?: ReadonlyArray<{ id?: string } | null | undefined>;
333
+ }
334
+
335
+ /**
336
+ * What screenPaths reads: the plugin's single-screen result (`{screenName, screen: ScreenExport, …}`,
337
+ * whose `screen` carries the page/node identity) or a bare screen doc carrying it at top level. On a
338
+ * ScreenExport itself `screen` is the human label string — which has no identity fields, exactly as
339
+ * `"label".page` is undefined in plain JS.
340
+ */
341
+ export interface ScreenPathsInput extends ScreenIdentity {
342
+ screenName?: string;
343
+ screen?: string | ScreenIdentity;
344
+ }
345
+
346
+ /** Where ONE single-screen export's files land. The node id is part of the base name because a frame
347
+ * NAME does not identify a frame — two frames called `Popup` on one page are two screens. */
348
+ export interface ScreenPaths {
349
+ /** The page's display name, or null on an export written before page identity was emitted. */
350
+ page: string | null;
351
+ pageId: string | null;
352
+ nodeId: string | null;
353
+ /** The sanitised page directory under pages/ (`_unfiled` when the page is unknown). */
354
+ dir: string;
355
+ /** `<safeName>__<safeNodeId>` — the stem every sibling file below shares. */
356
+ base: string;
357
+ screen: string;
358
+ variables: string;
359
+ assets: string;
360
+ index: string;
361
+ rootIndex: string;
362
+ }
363
+
364
+ export function screenPaths(screenDoc: ScreenPathsInput, sep: string): ScreenPaths {
365
+ const join = (...parts: string[]): string => ["pages"].concat(parts).join(sep);
366
+ const inner = screenDoc.screen && typeof screenDoc.screen === "object" ? screenDoc.screen : undefined;
367
+ const page = screenDoc.page || (inner && inner.page);
368
+ const pageId = screenDoc.pageId || (inner && inner.pageId);
369
+ // An export written before the plugin emitted page identity still has to land somewhere, and
370
+ // somewhere PREDICTABLE — a bucket named for what it is beats inventing a page that was never read.
371
+ const dir = page ? safe(page) : NO_PAGE_DIR;
372
+ // The root node's id when the reply names none: the index row (write-out.ts writeScreen) is keyed on that same id,
373
+ // so the file's stem and its row always name one node.
374
+ const root = inner && inner.nodes ? inner.nodes[0] : undefined;
375
+ const nodeId = screenDoc.nodeId || (inner && inner.nodeId) || (root && typeof root.id === "string" ? root.id : undefined);
376
+ const base = nodeId ? screenStem(screenDoc.screenName, nodeId) : safe(screenDoc.screenName || "screen");
377
+ return {
378
+ page: page || null,
379
+ pageId: pageId || null,
380
+ nodeId: nodeId || null,
381
+ dir,
382
+ base,
383
+ screen: join(dir, base + ".json"),
384
+ variables: join(dir, base + ".vars.json"),
385
+ assets: join(dir, base + ".assets.json"),
386
+ index: join(dir, "index.json"),
387
+ rootIndex: join("index.json"),
388
+ };
389
+ }
390
+
391
+ // Merge one screen's entry into a page index (or the root index's pageDirs), keyed on the FILE path —
392
+ // which is unique per (page, name, node id) by construction, so re-pulling the same frame replaces its
393
+ // row instead of appending a duplicate, and pulling a different frame with the same name adds one — and
394
+ // on the node id within one Figma file, so re-pulling a renamed frame replaces its old row too.
395
+ //
396
+ // `prev` is whatever is on disk (read back as untyped JSON — possibly written by an older version, or
397
+ // hand-edited), so it is narrowed, never trusted: rows already there are carried through as `unknown`.
398
+ // Only the row THIS pull adds is typed.
399
+
400
+ /** One single-screen row (write-out.ts writeScreen `entry`): an IndexRow whose page identity is
401
+ * `null` on an unfiled screen (screenPaths' `page`/`pageId`), not absent. */
402
+ export type ScreenIndexRow = Omit<IndexRow, "page" | "pageId"> & { page?: string | null; pageId?: string | null };
403
+
404
+ /** A page's index.json after a merge: the typed identity, the carried-through rows, and any other
405
+ * field the previous index held (kept verbatim). */
406
+ export interface MergedPageIndex {
407
+ [field: string]: unknown;
408
+ page: string | null | undefined;
409
+ pageId: string | null | undefined;
410
+ layers: unknown[];
411
+ }
412
+
413
+ /** pages/index.json after a single-screen merge (see mergeRootIndex). */
414
+ export interface MergedRootIndex {
415
+ [field: string]: unknown;
416
+ pageDirs: unknown[];
417
+ layers?: unknown[];
418
+ }
419
+
420
+ // Not json-util's isRecord: an array counts here, so a previous index that is an array is merged over (its
421
+ // elements land on the result under their indices) instead of being replaced by {}.
422
+ const isObjectLike = (x: unknown): x is Record<string, unknown> => !!x && typeof x === "object";
423
+
424
+ // Keeps a previous row unless it is the one being replaced: the same file, or the same node of the same Figma file
425
+ // under another name — a frame renamed in Figma and pulled again lands in a new file, and its old row would leave
426
+ // two rows with one id, which a lookup by id can never resolve. Node ids repeat across Figma files, so:
427
+ // - two rows that both name their Figma file are one node only when they name the same one: when both carry a
428
+ // sourceFileKey the key decides (a file renamed in Figma keeps its key; two files can share a name), otherwise
429
+ // the same sourceFile (a file name);
430
+ // - a row without a sourceFile (pulled before rows were stamped) is replaced by a stamped pull of the same pageId,
431
+ // whatever the page is called now — a page renamed in Figma since keeps its id. Page ids repeat across files too
432
+ // (every file's first page is 0:1), so an unstamped row can be dropped for a pull of another file; that risk is
433
+ // taken for unstamped rows only, which a stamped re-pull of their own file would otherwise never replace;
434
+ // - two rows of which the incoming one is unstamped are one node only when both name the same page: the same pageId
435
+ // and the same page name.
436
+ // Beside an unstamped row, or for an unstamped incoming row, no pageId on the incoming row keeps the row. A truthy non-object has no `.file`, so it is kept.
437
+ const samePage = (l: Record<string, unknown>, entry: ScreenIndexRow): boolean =>
438
+ !!entry.pageId && l.pageId === entry.pageId && (l.page ?? null) === (entry.page ?? null);
439
+ const sameFigmaFile = (l: Record<string, unknown>, entry: ScreenIndexRow): boolean => {
440
+ if (l.sourceFile && entry.sourceFile) {
441
+ return l.sourceFileKey && entry.sourceFileKey ? l.sourceFileKey === entry.sourceFileKey : l.sourceFile === entry.sourceFile;
442
+ }
443
+ if (entry.sourceFile) return !!entry.pageId && l.pageId === entry.pageId;
444
+ return samePage(l, entry);
445
+ };
446
+ const notSameRow = (entry: ScreenIndexRow) => (l: unknown): boolean => {
447
+ if (!isObjectLike(l)) return !!l;
448
+ if (l.file === entry.file) return false;
449
+ if (l.id !== entry.id) return true;
450
+ return !sameFigmaFile(l, entry);
451
+ };
452
+
453
+ /** Whether the index row `older` is the screen `newer` names — the row a merge of `newer` replaces (the rules above).
454
+ * The one identity every reader of the indexes merges rows by (export-reader.ts). */
455
+ export const sameScreenRow = (older: unknown, newer: ScreenIndexRow): boolean => isObjectLike(older) && !notSameRow(newer)(older);
456
+
457
+ export function mergeScreenIndex(prev: unknown, entry: ScreenIndexRow): MergedPageIndex {
458
+ const base: Record<string, unknown> = isObjectLike(prev) ? prev : {};
459
+ const layers: unknown[] = Array.isArray(base.layers) ? base.layers.filter(notSameRow(entry)) : [];
460
+ layers.push(entry);
461
+ return Object.assign({}, base, { page: entry.page, pageId: entry.pageId, layers });
462
+ }
463
+
464
+ // The root pages/index.json is the ONE entry point a consumer opens without knowing which pull shape
465
+ // produced the tree: it lists every page directory, whether that page arrived as a whole-page sweep
466
+ // or as single screens pulled one at a time.
467
+ // `entry` is the SAME row shape a full-page pull puts in meta.layers (name, id, title, texts, file,
468
+ // …) — so the root index looks identical to a consumer regardless of which pull shape produced it
469
+ // (the root is the ONE file every skill is told to read).
470
+ export function mergeRootIndex(prev: unknown, paths: ScreenPaths, layerCount: number, entry?: ScreenIndexRow): MergedRootIndex {
471
+ const base: Record<string, unknown> = isObjectLike(prev) ? prev : {};
472
+ const pageDirs: unknown[] = Array.isArray(base.pageDirs)
473
+ ? base.pageDirs.filter((p: unknown) => (isObjectLike(p) ? p.dir !== paths.dir : !!p))
474
+ : [];
475
+ pageDirs.push({ page: paths.page, pageId: paths.pageId, dir: paths.dir, index: paths.index, layers: layerCount });
476
+ const result: MergedRootIndex = Object.assign({}, base, { pageDirs });
477
+ if (entry) {
478
+ const layers: unknown[] = Array.isArray(base.layers) ? base.layers.filter(notSameRow(entry)) : [];
479
+ layers.push(entry);
480
+ result.layers = layers;
481
+ }
482
+ return result;
483
+ }