@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.
- package/LICENSE +21 -0
- package/README.md +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- 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
|
+
}
|