@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
package/src/json-util.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Small helpers for the untyped-JSON boundary, shared by bridge/ and design-to-code/ (the layer may
|
|
2
|
+
// import bridge/src; bridge cannot import the layer, so both-sided helpers live here). Dependency-free.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An empty null-prototype record. Use it wherever keys come from untrusted input (component keys, token
|
|
6
|
+
* names, a map's `kind`): on a plain `{}` those names can resolve to — or write onto — Object.prototype
|
|
7
|
+
* members ("constructor", "__proto__", "toString"). `Object.create(null)` itself is typed `any`; this is
|
|
8
|
+
* the one place that asserts its shape.
|
|
9
|
+
*/
|
|
10
|
+
export function nullProto<T>(): Record<string, T> {
|
|
11
|
+
return Object.create(null) as Record<string, T>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A plain JSON object: not null, not an array. */
|
|
15
|
+
export function isRecord(x: unknown): x is Record<string, unknown> {
|
|
16
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Array.isArray for an `unknown`, narrowing to `unknown[]` (the built-in narrows to an untyped array). */
|
|
20
|
+
export function isUnknownArray(x: unknown): x is unknown[] {
|
|
21
|
+
return Array.isArray(x);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A string[] guard for untyped JSON. */
|
|
25
|
+
export function isStringArray(x: unknown): x is string[] {
|
|
26
|
+
return Array.isArray(x) && x.every((v) => typeof v === "string");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `{ [key]: v }` when `v` is defined, `{}` when it is not — for spreading an optional key into an object
|
|
31
|
+
* literal (`{ a, ...ifDefined("b", b), c }`) under exactOptionalPropertyTypes, where `b: undefined` no
|
|
32
|
+
* longer type-checks against `b?: T`. Output is byte-identical to writing `b: b`: JSON.stringify already
|
|
33
|
+
* drops an undefined-valued key, and the spread sits where the key was, so the key order is unchanged.
|
|
34
|
+
* Only `undefined` is dropped — `null`, `""`, `0` and `false` are kept, exactly as JSON.stringify keeps them.
|
|
35
|
+
*/
|
|
36
|
+
export function ifDefined<K extends string, V>(key: K, v: V | undefined): Partial<Record<K, V>> {
|
|
37
|
+
const o: Partial<Record<K, V>> = {};
|
|
38
|
+
if (v !== undefined) o[key] = v;
|
|
39
|
+
return o;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** JSON with object keys sorted at every depth (array order kept; undefined members dropped) — the canonical form a
|
|
43
|
+
* hash is taken of (design-to-code plan-binding.ts, probe-steps.ts stepsSha256, plan-record.ts). */
|
|
44
|
+
export function canonical(v: unknown): string {
|
|
45
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`;
|
|
46
|
+
if (v && typeof v === "object") {
|
|
47
|
+
const entries = Object.entries(v).filter(([, x]) => x !== undefined).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
48
|
+
return `{${entries.map(([k, x]) => `${JSON.stringify(k)}:${canonical(x)}`).join(",")}}`;
|
|
49
|
+
}
|
|
50
|
+
return JSON.stringify(v ?? null);
|
|
51
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The ONE definition of how a LIBRARY-file catalog is split across files — the sibling of
|
|
2
|
+
// design-system-layout.ts, deliberately reusing its filenames and its slim-manifest shape.
|
|
3
|
+
//
|
|
4
|
+
// WHY A SECOND LAYOUT AT ALL. The two catalogs answer different questions and must not overwrite each
|
|
5
|
+
// other:
|
|
6
|
+
// design/design-system/ = the catalog of the DESIGN file you are building screens from. Its
|
|
7
|
+
// variables are the subset your screens actually reference, so it is what
|
|
8
|
+
// codegen should emit — generating CSS from the full library would dump
|
|
9
|
+
// every unused primitive into the output.
|
|
10
|
+
// design/libraries/<lib>/ = the COMPLETE catalog of one library file, exported by running the plugin
|
|
11
|
+
// inside that library. Its job is lookup/enrichment, not codegen: it is
|
|
12
|
+
// where you resolve what a token family really contains, and where a
|
|
13
|
+
// component's REAL property definitions live (the design file can only
|
|
14
|
+
// infer those from the instances that happen to be present).
|
|
15
|
+
// The join between them is `key` — durable cross-file identity, present on variables, collections,
|
|
16
|
+
// styles and components — NOT names, which collide across libraries.
|
|
17
|
+
//
|
|
18
|
+
// DIRECTORY IDENTITY is `<slug>-<fileKey8>`: a library is a FILE, and fileKey survives a rename, so a
|
|
19
|
+
// renamed library keeps writing to the same directory instead of forking into a second one. The slug is
|
|
20
|
+
// only there to keep the path human-readable. When figma.fileKey is unavailable the plugin says so in
|
|
21
|
+
// hygiene and we fall back to the slug alone — a rename then forks the directory, which is why it is
|
|
22
|
+
// warned about rather than silently absorbed.
|
|
23
|
+
//
|
|
24
|
+
// Kept dependency-free (no Node imports, ES2019-safe) for the same reason as its siblings: the bridge
|
|
25
|
+
// CLI imports it and esbuild inlines it into the plugin bundle, so the disk writer and the
|
|
26
|
+
// browser-download writer emit the identical set of files.
|
|
27
|
+
|
|
28
|
+
import type { PageLayoutFile } from "./pages-layout.ts";
|
|
29
|
+
import { ifDefined } from "./json-util.ts";
|
|
30
|
+
import { DESIGN_SYSTEM_FILES, STYLE_KINDS, byStyleKey, stylesOf } from "./design-system-layout.ts";
|
|
31
|
+
import type {
|
|
32
|
+
TokensDoc, StylesDoc, HygieneDoc, CatalogComponent, ComponentsCatalog, DesignSystemDoc, LibraryStamp, LibraryCounts, LibraryManifest,
|
|
33
|
+
LibrariesIndexRow, LibrariesIndex,
|
|
34
|
+
} from "./doc-types.ts";
|
|
35
|
+
|
|
36
|
+
/** Every document the library split writes, in `files` order (the manifest last). */
|
|
37
|
+
export type LibraryFileData =
|
|
38
|
+
| (TokensDoc & LibraryStamp) | (StylesDoc & LibraryStamp) | (ComponentsCatalog & LibraryStamp) | (HygieneDoc & LibraryStamp) | LibraryManifest;
|
|
39
|
+
|
|
40
|
+
export interface LibraryLayout {
|
|
41
|
+
/** Every file to write, INCLUDING the directory's own index.json (last). */
|
|
42
|
+
files: Array<PageLayoutFile<LibraryFileData>>;
|
|
43
|
+
/** The slim manifest alone: stamp + `source` + `files` pointer map + `counts` + `publish`. */
|
|
44
|
+
manifest: LibraryManifest;
|
|
45
|
+
counts: LibraryCounts;
|
|
46
|
+
/** The full relative directory the parts live under ("libraries/<slug>-<fileKey8>"). */
|
|
47
|
+
dir: string;
|
|
48
|
+
/** Just the leaf directory name — the identity a libraries/index.json row is keyed on. */
|
|
49
|
+
dirName: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const ROOT = "libraries";
|
|
53
|
+
export const INDEX = "index.json"; // per-directory self-description; also the name of the libraries/ index
|
|
54
|
+
// The token, style and hygiene parts keep design-system/'s file names (the style files one per STYLE_KINDS row); the
|
|
55
|
+
// catalog differs: a library's own full definitions are components.json (design-system/ splits local and
|
|
56
|
+
// library-sampled components).
|
|
57
|
+
const { TOKENS, HYGIENE } = DESIGN_SYSTEM_FILES;
|
|
58
|
+
export const COMPONENTS = "components.json";
|
|
59
|
+
/** Every file buildLibraryLayout writes in a libraries/<dir>/, in `files` order (the manifest, INDEX, last). */
|
|
60
|
+
export const LIBRARY_FILE_NAMES: readonly string[] = [TOKENS, ...STYLE_KINDS.map((row) => row.file), COMPONENTS, HYGIENE, INDEX];
|
|
61
|
+
|
|
62
|
+
// Filesystem-safe, stable, lowercase. Same spirit as pages-layout.ts's `safe`.
|
|
63
|
+
function slug(s: unknown): string {
|
|
64
|
+
return String(s || "library")
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
67
|
+
.replace(/^-+|-+$/g, "")
|
|
68
|
+
.slice(0, 40) || "library";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param ds the plugin's designSystem doc, as produced by collectLibraryFile
|
|
73
|
+
* @param sep path separator: "/" for real directories, "__" for flat download names.
|
|
74
|
+
* @returns `files` is every file to write INCLUDING the directory's own index.json (last).
|
|
75
|
+
*/
|
|
76
|
+
export function buildLibraryLayout(ds: DesignSystemDoc | null | undefined, sep: string): LibraryLayout {
|
|
77
|
+
const d: DesignSystemDoc = ds || {};
|
|
78
|
+
const s = sep || "/";
|
|
79
|
+
const source = d.source;
|
|
80
|
+
const fileKey = source && source.fileKey ? String(source.fileKey) : "";
|
|
81
|
+
// Only the first 8 chars: enough to disambiguate two libraries, short enough to keep the path
|
|
82
|
+
// readable. The FULL key is recorded in the index, so nothing depends on the truncation.
|
|
83
|
+
const dirName = slug((source && source.libraryName) || d.file) + (fileKey ? "-" + fileKey.slice(0, 8) : "");
|
|
84
|
+
const dir = ROOT + s + dirName;
|
|
85
|
+
const join = (name: string): string => dir + s + name;
|
|
86
|
+
|
|
87
|
+
// The stamp every split file carries, plus `source`. `exportedAt`/`file` stay top-level and
|
|
88
|
+
// unmodified so export-verdict.ts and design-to-code/drift-lint.ts keep reading them off whichever file they
|
|
89
|
+
// are handed; `source` is additive and tells a consumer this is a library catalog, not a design
|
|
90
|
+
// file's — a distinction that is otherwise invisible, because inside the library file every object
|
|
91
|
+
// is local and `remote` is therefore false on all of them.
|
|
92
|
+
const stamp: LibraryStamp = {
|
|
93
|
+
...ifDefined("exportedAt", d.exportedAt), ...ifDefined("file", d.file), ...ifDefined("colorProfile", d.colorProfile), ...ifDefined("source", d.source),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const variables = Array.isArray(d.variables) ? d.variables : [];
|
|
97
|
+
const collections = Array.isArray(d.collections) ? d.collections : [];
|
|
98
|
+
const hygiene = Array.isArray(d.hygiene) ? d.hygiene : [];
|
|
99
|
+
|
|
100
|
+
// Inside a library file every component IS local, so there is no local/library partition to make
|
|
101
|
+
// (design-system-layout.ts splits on `remote` because a design file's catalog genuinely mixes both).
|
|
102
|
+
// A remote entry here would belong to a DIFFERENT library — that library's export to make, not this
|
|
103
|
+
// one's — so it is dropped and reported rather than filed under this library's name.
|
|
104
|
+
const all: CatalogComponent[] = Array.isArray(d.components) ? d.components : [];
|
|
105
|
+
const components = all.filter((c) => !(c && c.remote === true));
|
|
106
|
+
const foreign = all.length - components.length;
|
|
107
|
+
const hyg = foreign
|
|
108
|
+
? hygiene.concat(
|
|
109
|
+
foreign + " component(s) consumed from ANOTHER library were dropped from this catalog — " +
|
|
110
|
+
"export that library separately; a library's catalog only claims what it defines."
|
|
111
|
+
)
|
|
112
|
+
: hygiene;
|
|
113
|
+
|
|
114
|
+
const files: Array<PageLayoutFile<LibraryFileData>> = [
|
|
115
|
+
{ path: join(TOKENS), data: Object.assign({}, stamp, { collections, variables }) },
|
|
116
|
+
...STYLE_KINDS.map((row) => ({ path: join(row.file), data: Object.assign({}, stamp, { styles: stylesOf(d.styles, row.kind) }) })),
|
|
117
|
+
{ path: join(COMPONENTS), data: Object.assign({}, stamp, { components }) },
|
|
118
|
+
{ path: join(HYGIENE), data: Object.assign({}, stamp, { hygiene: hyg }) },
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
// Publish status is only meaningful in library mode, so summarise it where a human will see it: how
|
|
122
|
+
// much of this library is actually live for consumers vs. sitting unpublished in the file.
|
|
123
|
+
const publishCounts: Record<string, number> = {};
|
|
124
|
+
for (const c of components) if (c && c.publish) publishCounts[c.publish] = (publishCounts[c.publish] || 0) + 1;
|
|
125
|
+
for (const v of variables) if (v && v.publish) publishCounts[v.publish] = (publishCounts[v.publish] || 0) + 1;
|
|
126
|
+
|
|
127
|
+
const counts: LibraryCounts = {
|
|
128
|
+
collections: collections.length,
|
|
129
|
+
variables: variables.length,
|
|
130
|
+
...byStyleKey((row) => stylesOf(d.styles, row.kind).length),
|
|
131
|
+
components: components.length,
|
|
132
|
+
hygiene: hyg.length,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const manifest: LibraryManifest = Object.assign({}, stamp, {
|
|
136
|
+
// Same pointer-map shape as design-system.json: relative paths exactly as written, separator and
|
|
137
|
+
// all, so a consumer joins them against the export dir and needs to know nothing else.
|
|
138
|
+
files: {
|
|
139
|
+
tokens: join(TOKENS),
|
|
140
|
+
...byStyleKey((row) => join(row.file)),
|
|
141
|
+
components: join(COMPONENTS),
|
|
142
|
+
hygiene: join(HYGIENE),
|
|
143
|
+
},
|
|
144
|
+
counts,
|
|
145
|
+
...(Object.keys(publishCounts).length ? { publish: publishCounts } : {}),
|
|
146
|
+
});
|
|
147
|
+
files.push({ path: join(INDEX), data: manifest });
|
|
148
|
+
|
|
149
|
+
return { files, manifest, counts, dir, dirName };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The libraries/ index: one row per library exported so far. Merged rather than overwritten, because
|
|
154
|
+
* each library is a SEPARATE plugin run in a SEPARATE file — a second library's export must not erase
|
|
155
|
+
* the first one's row. Rows are keyed by directory name (fileKey-derived), so re-exporting the same
|
|
156
|
+
* library replaces its own row and nothing else.
|
|
157
|
+
*
|
|
158
|
+
* @param prev the previously written index (untyped JSON read back from disk), or null/undefined
|
|
159
|
+
* on the first export
|
|
160
|
+
* @param built the return value of buildLibraryLayout
|
|
161
|
+
* @returns the index to write
|
|
162
|
+
*/
|
|
163
|
+
export function mergeLibrariesIndex(prev: unknown, built: LibraryLayout): LibrariesIndex {
|
|
164
|
+
const prevLibraries = prev && typeof prev === "object" && "libraries" in prev ? prev.libraries : undefined;
|
|
165
|
+
const rows: unknown[] = Array.isArray(prevLibraries) ? prevLibraries.slice() : [];
|
|
166
|
+
const m = built.manifest;
|
|
167
|
+
const source = m.source;
|
|
168
|
+
const row: LibrariesIndexRow = {
|
|
169
|
+
dir: built.dirName,
|
|
170
|
+
...ifDefined("libraryName", (source && source.libraryName) || m.file),
|
|
171
|
+
...ifDefined("file", m.file),
|
|
172
|
+
...ifDefined("fileKey", source && source.fileKey),
|
|
173
|
+
...ifDefined("collectionKeys", source && source.collectionKeys),
|
|
174
|
+
...ifDefined("exportedAt", m.exportedAt),
|
|
175
|
+
counts: m.counts,
|
|
176
|
+
...ifDefined("publish", m.publish),
|
|
177
|
+
index: built.dirName + "/" + INDEX,
|
|
178
|
+
};
|
|
179
|
+
const i = rows.findIndex((r) => dirOf(r) === built.dirName);
|
|
180
|
+
if (i >= 0) rows[i] = row;
|
|
181
|
+
else rows.push(row);
|
|
182
|
+
rows.sort((a, b) => String(dirOf(a)).localeCompare(String(dirOf(b))));
|
|
183
|
+
// No top-level `file`/`exportedAt` stamp here on purpose: this index spans SEVERAL Figma files, and
|
|
184
|
+
// `file` is contractually "which one file did this come from" (export-verdict.ts reads it as
|
|
185
|
+
// sourceFile). Each row carries its own stamp instead, so per-library staleness stays visible.
|
|
186
|
+
return { libraries: rows, generatedAt: new Date().toISOString() };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A previous row's `dir`, read the way plain JS reads `r.dir` (undefined for null/undefined, and for
|
|
190
|
+
// anything without a `dir`) — rows on disk are untyped JSON.
|
|
191
|
+
function dirOf(r: unknown): unknown {
|
|
192
|
+
return r !== null && typeof r === "object" && "dir" in r ? r.dir : undefined;
|
|
193
|
+
}
|
package/src/node-id.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Figma node-id parsing — the ONE place that knows what a node id looks like and how it hides in a
|
|
2
|
+
// Figma URL. Both Node-side consumers (the MCP write/read server and the seed-components CLI) import
|
|
3
|
+
// this instead of carrying their own regex pair, which had already drifted into disagreeing about
|
|
4
|
+
// which ids are valid.
|
|
5
|
+
//
|
|
6
|
+
// A link may carry the id in any of four forms:
|
|
7
|
+
// dash "1-2" · colon "1:2" · percent-encoded "1%3A2" · nested-instance path "I1-2;3-4"
|
|
8
|
+
// The Plugin API wants the COLON form, so that's the canonical form everything here returns.
|
|
9
|
+
|
|
10
|
+
// The node-id token inside a URL, captured loosely and validated by normalizeNodeId. Matching loosely
|
|
11
|
+
// matters: a regex that only accepted `[0-9]+-[0-9]+` silently skipped the other three forms.
|
|
12
|
+
export const ID = "[A-Za-z0-9%:;_-]+";
|
|
13
|
+
const NODE_ID_RE = new RegExp("node-id=(" + ID + ")");
|
|
14
|
+
|
|
15
|
+
// URL is a global in Node, but NOT in the Figma plugin's ES2019 lib (no DOM types there), and this
|
|
16
|
+
// module type-checks under both tsconfigs. A module-scoped ambient declaration of exactly the surface
|
|
17
|
+
// used below — emits nothing, so at runtime it is the real global (in a realm without one, the
|
|
18
|
+
// `new URL` throws and parseNodeId's catch falls through to the loose match, as it always did).
|
|
19
|
+
declare const URL: new (input: string) => { searchParams: { get(name: string): string | null } };
|
|
20
|
+
|
|
21
|
+
function decode(s: string): string {
|
|
22
|
+
try { return decodeURIComponent(s); } catch (e) { return s; } // malformed escape — use it verbatim
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The colon form: "1:2", or a nested-instance path "I1:2;3:4".
|
|
26
|
+
const COLON_ID_RE = /^I?\d+:\d+(?:[;:]\d+:\d+)*$/;
|
|
27
|
+
|
|
28
|
+
/** True when `s` already IS a node id in the Plugin API's colon form ("1:2", "I1:2;3:4") — what a query
|
|
29
|
+
* typed as an id looks like, as opposed to a name. A bare number is not one here (it reads as a name). */
|
|
30
|
+
export function isNodeId(s: string): boolean {
|
|
31
|
+
return COLON_ID_RE.test(s);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A raw node-id token -> the Plugin API's colon form ("1:2"), or undefined when it isn't an id.
|
|
35
|
+
function normalizeNodeId(raw: unknown): string | undefined {
|
|
36
|
+
if (!raw) return undefined;
|
|
37
|
+
const s = decode(String(raw).trim()).replace(/-/g, ":");
|
|
38
|
+
// "1:2", "I1:2;3:4" (nested instance path), or a bare numeric id.
|
|
39
|
+
return COLON_ID_RE.test(s) || /^\d+$/.test(s) ? s : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A full Figma URL, a `…node-id=…` fragment, or a bare id -> colon form, or undefined when there's
|
|
43
|
+
// no id to be found (callers surface their own friendly hint).
|
|
44
|
+
export function parseNodeId(input: unknown): string | undefined {
|
|
45
|
+
if (!input) return undefined;
|
|
46
|
+
const s = String(input).trim();
|
|
47
|
+
try {
|
|
48
|
+
// searchParams already percent-decodes, so this branch hands normalizeNodeId a decoded token.
|
|
49
|
+
const nid = new URL(s).searchParams.get("node-id");
|
|
50
|
+
if (nid) return normalizeNodeId(nid);
|
|
51
|
+
} catch (e) { /* not a full URL — fall through to the loose match */ }
|
|
52
|
+
const m = s.match(NODE_ID_RE);
|
|
53
|
+
if (m) return normalizeNodeId(m[1]);
|
|
54
|
+
return normalizeNodeId(s); // bare id
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The LENIENT twin of parseNodeId, and the form every consumer actually wants: normalize when the
|
|
58
|
+
// token is recognisable, otherwise pass the raw token through untouched so the plugin — not this
|
|
59
|
+
// parser — gets to be the authority on what resolves. Every front-end (plugin collector, CLI, MCP)
|
|
60
|
+
// was spelling `parseNodeId(x) || x` itself, in three slightly different ways; that put half of this
|
|
61
|
+
// module's contract back into three files, which is the exact drift it exists to prevent.
|
|
62
|
+
export function toNodeId(raw: unknown): string {
|
|
63
|
+
return parseNodeId(raw) || (raw == null ? "" : String(raw));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// NODE_ID_RE/normalizeNodeId stay module-private: exporting them invited a consumer to re-derive
|
|
67
|
+
// half the contract, which is the drift this file exists to prevent. isNodeId hands out the colon-form
|
|
68
|
+
// grammar as a predicate, so a consumer asking "is this already an id?" needs no regex of its own. ID is
|
|
69
|
+
// exported only because seed-components.ts composes it into its own source-scanning regexes.
|