@1agh/maude 0.58.2 → 0.59.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 (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,1103 @@
1
+ /**
2
+ * @file figma/from-codegen.ts — Figma Dev Mode codegen → one Maude artboard.
3
+ * @scope apps/studio/figma/from-codegen.ts
4
+ * @purpose Convert the React + Tailwind MODULE the local Dev Mode server
5
+ * returns into a `DCArtboard` a Maude canvas renders and a human
6
+ * edits — for ONE frame, on explicit invocation (`--explode`).
7
+ *
8
+ * @rationale Two earlier routes were built and both measured broken on the same
9
+ * live file (DDR-219 § Context). Route 1 derived layout from the node
10
+ * tree, which means reimplementing auto-layout, constraints,
11
+ * clipping, blend modes and vector networks in CSS — the bug surface
12
+ * grows with the fidelity of the source. Route 2 rendered each frame
13
+ * to an image, which is faithful and inert. This route asks Figma for
14
+ * the layout ALREADY RESOLVED and converts the result. The editability
15
+ * bar is met, not abandoned — but it is met by taking Figma's DOM
16
+ * rather than by deriving one.
17
+ *
18
+ * @invariant THE MODULE STAYS REACT. The first spike flattened to HTML by
19
+ * stripping with regexes, and on the first real screen it rendered
20
+ * `type IconsProps = …` as visible body text — exactly the defect
21
+ * class the "named parser, never regex" rule exists to prevent.
22
+ * A real response is a MODULE: asset constants, a type alias, a
23
+ * parameterized helper component, then the default export. Maude
24
+ * canvases ARE React, so keeping the helper is both natively
25
+ * renderable and strictly MORE editable than inlining it fourteen
26
+ * times.
27
+ *
28
+ * @invariant EVERY IDENTIFIER IS REGENERATED (DDR-216 D6 via DDR-219 D4).
29
+ * D6 calls the identifier space "airtight — there is no Figma string
30
+ * in it at all", and that holds only because every name comes from
31
+ * `identifierFromNodeId`. Codegen returns React source carrying its
32
+ * OWN names, derived from layer names
33
+ * (`Component231320F78B2B43C7B5A04A6Ff8B6244C45005C` was measured).
34
+ * So: component names, parameter names and local names are ALL
35
+ * discarded and regenerated. Only string VALUES survive, escaped.
36
+ *
37
+ * @invariant ELEMENT AND ATTRIBUTE ALLOWLISTS, NEVER DENYLISTS (D5 rule 3). A
38
+ * denylist has to remember `<script>`, `<style>`, `<iframe>`,
39
+ * `<foreignObject>`, `on*`, `href`, `dangerouslySetInnerHTML`.
40
+ * An allowlist does not.
41
+ *
42
+ * @invariant THE PARSER'S LEAF ENUMERATION IS THE CONTROL — not `sanitize.ts`
43
+ * "in full", which is not a thing that can be done (D4): every
44
+ * export there is a FIELD-LEVEL string function, and
45
+ * `jsxStringLiteral` run over a JSX document destroys the markup.
46
+ * What is true is that every string this module *extracts* — text
47
+ * node, attribute value, class token — goes through the right
48
+ * field-level function on the way out.
49
+ *
50
+ * @invariant A PARSE ERROR REFUSES THE FRAME (D5 rule 4). Never a partial
51
+ * artboard: a half-converted screen that renders is this feature's
52
+ * signature failure mode (report success, deliver something else).
53
+ *
54
+ * @invariant ASSET URLS ARE DISCARDED (D6). The response carries loopback
55
+ * `assets/<sha1>.svg` constants served by the Dev Mode server
56
+ * itself; every one is
57
+ * thrown away and the artwork is re-requested BY NODE ID through the
58
+ * existing `/v1/images` lane — same frozen host allowlist, same byte
59
+ * sniff, same DDR-167 SVG lane, same `renderKey` dedupe. There is no
60
+ * new URL surface, and the local server's loopback links (which
61
+ * `_fetch-asset.mjs` refuses three ways, correctly) never arise.
62
+ *
63
+ * @invariant `Object.create(null)` FOR EVERY MAP KEYED BY A PARSED STRING
64
+ * (D5 rule 5), and `__proto__`/`constructor`/`prototype` are skipped
65
+ * wherever a key comes from the document.
66
+ *
67
+ * @dependency `oxc-parser` — and this is the named-parser decision D5 rule 1
68
+ * asks for, not an implementation detail. It is ALREADY a production
69
+ * dependency of `apps/studio` (`package.json` `dependencies`), with
70
+ * all seven platform bindings already staged in `devDependencies`,
71
+ * and it ALREADY parses third-party-authored TSX in this exact repo
72
+ * (`canvas-pipeline.ts` parses every canvas; `ripple.ts`,
73
+ * `clip-ops.ts`, `canvas-lib-inline.ts` likewise). So the two costs
74
+ * D5 named — "a different risk class from a JS parser" and "it drags
75
+ * per-platform staging (D12)" — are both ALREADY PAID by shipped
76
+ * code. Choosing it adds no dependency, no staging and no new
77
+ * platform matrix; hand-rolling a TSX parser would add several
78
+ * hundred lines of novel code whose whole job is to be correct on
79
+ * hostile input, which is the strictly worse trade. DDR-042 records
80
+ * the bun-compile workaround it needs, and that is already in place.
81
+ */
82
+
83
+ import { parseSync } from 'oxc-parser';
84
+ import { FontSubstitutions, type FontToken } from './codegen-fonts.ts';
85
+ import {
86
+ cssPropToCamel,
87
+ isAllowedArbitraryProperty,
88
+ isCodegenColor,
89
+ isCodegenKeyword,
90
+ isCodegenLength,
91
+ isCodegenLengthList,
92
+ isCodegenNumber,
93
+ isCodegenShortValueList,
94
+ } from './codegen-values.ts';
95
+ import {
96
+ attrValue,
97
+ cleanText,
98
+ type ImportReport,
99
+ identifierFromNodeId,
100
+ jsxStringLiteral,
101
+ reportToken,
102
+ } from './sanitize.ts';
103
+
104
+ // Re-exported for the existing from-codegen tests and callers; the helper now
105
+ // lives in sanitize.ts because the `.fig` door needs the same bounding
106
+ // (DDR-221 A8/F1) and `attrValue` is NOT sufficient — it maps rejected
107
+ // characters to SPACES, so a bounded label can still read as prose.
108
+ export { reportToken };
109
+
110
+ import type { DsToken } from './style-map.ts';
111
+ import { mapClassName, type TailwindContext } from './tailwind-map.ts';
112
+
113
+ // ── Caps (D5 rule 2 — the response's OWN input-side bounds) ─────────────────
114
+ //
115
+ // D5's existing 512 KB is an OUTPUT cap and `client.ts`'s 8 MB is enforced on a
116
+ // REST response this one never traverses. Measured: a real 375×812 screen is
117
+ // 32 KB of code.
118
+
119
+ /** Source bytes we will parse at all. */
120
+ export const MAX_SOURCE_BYTES = 512 * 1024;
121
+ /** Emitted JSX bytes per artboard (D5). */
122
+ export const MAX_OUTPUT_BYTES = 512 * 1024;
123
+ /** JSX nodes across the whole module. */
124
+ export const MAX_JSX_NODES = 5_000;
125
+ /** JSX nesting depth. Measured max on a real file: 13. */
126
+ export const MAX_JSX_DEPTH = 40;
127
+ /** Helper components. Figma emits one per component variant set. */
128
+ export const MAX_COMPONENTS = 64;
129
+ /** One text run. Longer is truncated and reported, never dropped. */
130
+ export const MAX_TEXT_LEN = 2_000;
131
+ /** Module-level `const` asset declarations. */
132
+ export const MAX_ASSET_CONSTS = 512;
133
+
134
+ /**
135
+ * Elements this converter will emit. D5 rule 3, verbatim, plus `br`.
136
+ *
137
+ * Measured surface on a real screen: `div` ×132, `img` ×34, `p` ×26 and one
138
+ * helper component. The list is already generous against that.
139
+ *
140
+ * `svg` is deliberately ABSENT. Inline third-party SVG is a whole other risk
141
+ * class — the one DDR-167 exists for — and codegen hands vectors over as
142
+ * `<img src>` anyway, which is the containment the render route also relies on.
143
+ */
144
+ const ELEMENT_ALLOWLIST: ReadonlySet<string> = new Set([
145
+ 'div',
146
+ 'span',
147
+ 'p',
148
+ 'ul',
149
+ 'ol',
150
+ 'li',
151
+ 'img',
152
+ 'br',
153
+ 'h1',
154
+ 'h2',
155
+ 'h3',
156
+ 'h4',
157
+ 'h5',
158
+ 'h6',
159
+ ]);
160
+
161
+ /** Elements that may not have children. */
162
+ const VOID_ELEMENTS: ReadonlySet<string> = new Set(['img', 'br']);
163
+
164
+ /** Attributes read off the source. `className` is CONSUMED, never emitted. */
165
+ const ATTRIBUTE_ALLOWLIST: ReadonlySet<string> = new Set([
166
+ 'className',
167
+ 'style',
168
+ 'src',
169
+ 'alt',
170
+ 'data-node-id',
171
+ ]);
172
+
173
+ /** A Figma node id, including the instance form `I425:2940;0:95`. Charset
174
+ * excludes every character that could terminate a JSX attribute literal. */
175
+ const NODE_ID_ATTR_RE = /^[A-Za-z0-9:;_-]{1,120}$/;
176
+
177
+ /** Keys that must never be written into a map built from parsed input. */
178
+ const FORBIDDEN_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype']);
179
+
180
+ /** Refusal. Its message is code-owned and reaches verb stdout (D10). */
181
+ export class CodegenConvertError extends Error {
182
+ readonly reason: string;
183
+ constructor(reason: string) {
184
+ super(`codegen conversion refused: ${reason}`);
185
+ this.name = 'CodegenConvertError';
186
+ this.reason = reason;
187
+ }
188
+ }
189
+
190
+ export interface ConvertOptions {
191
+ /** The frame's Figma node id — the identifier space's only root. */
192
+ nodeId: string;
193
+ /** Already `attrValue`-bounded by the caller. */
194
+ label: string;
195
+ width: number;
196
+ height: number;
197
+ kind: string;
198
+ tokens?: readonly DsToken[];
199
+ fontTokens?: readonly FontToken[];
200
+ threshold?: number;
201
+ report: ImportReport;
202
+ }
203
+
204
+ export interface PendingCodegenAsset {
205
+ nodeId: string;
206
+ format: 'svg';
207
+ placeholder: string;
208
+ }
209
+
210
+ export interface ConvertResult {
211
+ /** The `<DCArtboard>…</DCArtboard>` block, ready to splice into a canvas. */
212
+ artboardJsx: string;
213
+ /** Helper component declarations, for module scope above the canvas export. */
214
+ helpers: string;
215
+ /** Artwork to fetch by node id through the existing `/v1/images` lane (D6). */
216
+ pendingAssets: PendingCodegenAsset[];
217
+ /** Distinct Tailwind utilities the mapper did not know. */
218
+ unmappedUtilities: string[];
219
+ bytes: number;
220
+ /**
221
+ * The root element's own `data-node-id`, for the open-document cross-check.
222
+ * `null` when the response did not carry one.
223
+ */
224
+ rootNodeId: string | null;
225
+ /**
226
+ * The root element's `data-name`, `attrValue`-bounded — the raw layer name is
227
+ * NEVER kept. Compared against the stored frame label by the caller and then
228
+ * discarded; it exists only to answer "is this the document we imported?"
229
+ * (DDR-219 probe finding 1 / residual 8).
230
+ */
231
+ rootName: string;
232
+ }
233
+
234
+ // ── AST helpers ─────────────────────────────────────────────────────────────
235
+
236
+ /* biome-ignore-start lint/suspicious/noExplicitAny: oxc AST nodes are
237
+ heterogeneous; every access below is shape-checked at its use site, which is
238
+ the same discipline canvas-pipeline.ts states for the same parser. */
239
+ type Node = any;
240
+
241
+ /** oxc preserves `(expr)`. Every expression read must unwrap it first. */
242
+ function unparen(n: Node): Node {
243
+ let cur = n;
244
+ for (let i = 0; i < 8 && cur && cur.type === 'ParenthesizedExpression'; i += 1)
245
+ cur = cur.expression;
246
+ return cur;
247
+ }
248
+
249
+ function isStringLiteral(n: Node): boolean {
250
+ return n && n.type === 'Literal' && typeof n.value === 'string';
251
+ }
252
+
253
+ function jsxName(n: Node): string | null {
254
+ if (!n) return null;
255
+ if (n.type === 'JSXIdentifier') return String(n.name);
256
+ // `<Foo.Bar>` / `<svg:use>` — refused by returning null, never resolved.
257
+ return null;
258
+ }
259
+
260
+ function attrName(a: Node): string | null {
261
+ if (a.type !== 'JSXAttribute') return null;
262
+ return jsxName(a.name);
263
+ }
264
+
265
+ // ── The converter ───────────────────────────────────────────────────────────
266
+
267
+ interface ComponentDecl {
268
+ /** The regenerated name. The source name never survives. */
269
+ name: string;
270
+ /** original prop name → regenerated parameter name. */
271
+ props: Map<string, string>;
272
+ /** original prop name → its string-literal default, already escaped. */
273
+ defaults: Map<string, string>;
274
+ /** original local const → regenerated name. */
275
+ locals: Map<string, string>;
276
+ /** The prop that carried `className`, renamed to a STYLE object. */
277
+ styleProp: string | null;
278
+ node: Node;
279
+ }
280
+
281
+ /**
282
+ * Convert one codegen module.
283
+ *
284
+ * The whole function is a single pass with hard caps and no recovery: anything
285
+ * outside the supported subset throws, and the caller turns that into a refusal
286
+ * with a `codegen-unavailable` disposition. There is deliberately no "best
287
+ * effort" branch — a design importer that guesses is worse than one that stops.
288
+ */
289
+ export function convertCodegenModule(source: string, opts: ConvertOptions): ConvertResult {
290
+ if (source.length > MAX_SOURCE_BYTES) throw new CodegenConvertError('response over the size cap');
291
+ if (source.length === 0) throw new CodegenConvertError('empty response');
292
+
293
+ const parsed = parseSync('figma-codegen.tsx', source, { sourceType: 'module' });
294
+ if (parsed.errors && parsed.errors.length > 0) {
295
+ // The parser's own message can quote the source, so it is NOT propagated
296
+ // (D10: every byte this verb prints is code-owned).
297
+ throw new CodegenConvertError('response did not parse as a TSX module');
298
+ }
299
+
300
+ const tw: TailwindContext = {
301
+ ...(opts.tokens ? { tokens: opts.tokens } : {}),
302
+ ...(opts.fontTokens ? { fontTokens: opts.fontTokens } : {}),
303
+ ...(opts.threshold !== undefined ? { threshold: opts.threshold } : {}),
304
+ };
305
+ const root = identifierFromNodeId(opts.nodeId);
306
+ const fonts = new FontSubstitutions();
307
+ const unmapped = new Set<string>();
308
+ const pendingAssets: PendingCodegenAsset[] = [];
309
+ const assetPlaceholders = new Map<string, string>();
310
+
311
+ /** Asset constant name → its (discarded) URL. Kept only so a `src={ident}`
312
+ * can be RECOGNISED as an asset reference rather than an unknown identifier. */
313
+ const assetConsts: Set<string> = new Set();
314
+ const components: ComponentDecl[] = [];
315
+ const byOriginalName = new Map<string, ComponentDecl>();
316
+ let rootComponent: ComponentDecl | null = null;
317
+ let jsxNodes = 0;
318
+
319
+ // ── Pass 1 — top-level shape ──
320
+ for (const stmt of parsed.program.body as Node[]) {
321
+ switch (stmt.type) {
322
+ case 'VariableDeclaration': {
323
+ for (const d of stmt.declarations) {
324
+ if (d.id?.type !== 'Identifier' || !isStringLiteral(unparen(d.init))) {
325
+ throw new CodegenConvertError('unsupported module-level declaration');
326
+ }
327
+ if (assetConsts.size >= MAX_ASSET_CONSTS) {
328
+ throw new CodegenConvertError('too many asset constants');
329
+ }
330
+ // The URL is READ AND DROPPED HERE. Nothing downstream can reach it,
331
+ // which is what makes D6 structural rather than a convention.
332
+ assetConsts.add(String(d.id.name));
333
+ }
334
+ break;
335
+ }
336
+ case 'TSTypeAliasDeclaration':
337
+ case 'TSInterfaceDeclaration':
338
+ // Types are dropped wholesale — we regenerate plain-JS signatures, so
339
+ // there is nothing for a type to describe. (The first spike left these
340
+ // in the output and they rendered as body text.)
341
+ break;
342
+ case 'FunctionDeclaration': {
343
+ const decl = declareComponent(stmt, root, components.length);
344
+ components.push(decl);
345
+ if (stmt.id?.name) byOriginalName.set(String(stmt.id.name), decl);
346
+ break;
347
+ }
348
+ case 'ExportDefaultDeclaration': {
349
+ const inner = stmt.declaration;
350
+ if (inner?.type !== 'FunctionDeclaration') {
351
+ throw new CodegenConvertError('default export is not a component');
352
+ }
353
+ const decl = declareComponent(inner, root, components.length);
354
+ rootComponent = decl;
355
+ if (inner.id?.name) byOriginalName.set(String(inner.id.name), decl);
356
+ break;
357
+ }
358
+ default:
359
+ throw new CodegenConvertError('unsupported module-level statement');
360
+ }
361
+ if (components.length > MAX_COMPONENTS) throw new CodegenConvertError('too many components');
362
+ }
363
+ if (!rootComponent) throw new CodegenConvertError('no default-exported component');
364
+
365
+ // ── Emission ──
366
+
367
+ /** Escape + bound one text run; empty means "nothing to emit". */
368
+ function textChild(raw: string): string | null {
369
+ const trimmed = raw.replace(/\s+/g, ' ');
370
+ if (trimmed.trim().length === 0) return null;
371
+ const clean = cleanText(trimmed, MAX_TEXT_LEN);
372
+ if (clean.strippedHidden) {
373
+ opts.report.add(opts.nodeId, 'TEXT', 'hidden-chars-dropped', 'codegen text');
374
+ }
375
+ if (clean.truncated) opts.report.add(opts.nodeId, 'TEXT', 'truncated-text', 'codegen text');
376
+ if (clean.text.trim().length === 0) return null;
377
+ return `{${jsxStringLiteral(clean.text)}}`;
378
+ }
379
+
380
+ /** A validated declaration map → a JSX style-object literal. */
381
+ function styleLiteral(decls: Record<string, string>): string {
382
+ const parts: string[] = [];
383
+ for (const [k, v] of Object.entries(decls)) {
384
+ // Keys come from OUR tables, never from the document — but the guard is
385
+ // free and this is a map that a future edit could start feeding parsed
386
+ // keys into.
387
+ if (FORBIDDEN_KEYS.has(k) || !/^[A-Za-z][A-Za-z0-9]{0,48}$/.test(k)) continue;
388
+ parts.push(`${k}: ${jsxStringLiteral(v)}`);
389
+ }
390
+ return `{ ${parts.join(', ')} }`;
391
+ }
392
+
393
+ /**
394
+ * Read an inline `style={{…}}`. Figma emits exactly one on a real screen
395
+ * (`fontVariationSettings`), but it is a direct write into the style object so
396
+ * it gets the same property allowlist as the bracket utilities.
397
+ */
398
+ function inlineStyle(expr: Node): Record<string, string> {
399
+ const out: Record<string, string> = Object.create(null);
400
+ const obj = unparen(expr);
401
+ if (obj?.type !== 'ObjectExpression') return out;
402
+ for (const prop of obj.properties as Node[]) {
403
+ if (prop.type !== 'Property' || prop.computed) continue;
404
+ const key = prop.key?.type === 'Identifier' ? String(prop.key.name) : prop.key?.value;
405
+ if (typeof key !== 'string' || FORBIDDEN_KEYS.has(key)) continue;
406
+ const camel = cssPropToCamel(key);
407
+ // The allowlist is spelled in kebab-case, so probe both spellings rather
408
+ // than assuming which form the document used.
409
+ const kebab = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
410
+ if (!isAllowedArbitraryProperty(kebab)) {
411
+ unmapped.add(`style:${attrValue(key, 24)}`);
412
+ continue;
413
+ }
414
+ const value = unparen(prop.value);
415
+ const raw =
416
+ value?.type === 'Literal' &&
417
+ (typeof value.value === 'string' || typeof value.value === 'number')
418
+ ? String(value.value)
419
+ : null;
420
+ if (raw === null) {
421
+ unmapped.add(`style:${attrValue(key, 24)}`);
422
+ continue;
423
+ }
424
+ if (
425
+ !isCodegenKeyword(raw) &&
426
+ !isCodegenLength(raw) &&
427
+ !isCodegenNumber(raw) &&
428
+ !isCodegenLengthList(raw) &&
429
+ !isCodegenShortValueList(raw) &&
430
+ !isCodegenColor(raw) &&
431
+ !/^"[A-Za-z]{1,8}" -?\d{1,4}$/.test(raw)
432
+ ) {
433
+ unmapped.add(`style:${attrValue(key, 24)}`);
434
+ continue;
435
+ }
436
+ out[camel] = raw;
437
+ }
438
+ return out;
439
+ }
440
+
441
+ /**
442
+ * Turn a `className` attribute into style. Returns the SPREAD sources so a
443
+ * component that forwards its incoming style still composes.
444
+ *
445
+ * Supported, because these are what a real response contains:
446
+ * `className="a b"`, `className={"a b"}`,
447
+ * `className={someProp || "a b"}` (the forwarding idiom),
448
+ * `` className={`a b ${someProp}`} ``.
449
+ * Anything else is a BOUNDED DEGRADATION, not a refusal: the classes are lost,
450
+ * the element survives, and `codegen-utility-unmapped` says so. Refusing a
451
+ * whole frame over one dynamic class string would trade a small visual loss
452
+ * for a total one.
453
+ */
454
+ function classNameToStyle(
455
+ value: Node,
456
+ scope: ComponentDecl | null
457
+ ): { decls: Record<string, string>; spreads: string[] } {
458
+ const decls: Record<string, string> = Object.create(null);
459
+ const spreads: string[] = [];
460
+ const take = (literal: string): void => {
461
+ const mapped = mapClassName(literal, tw);
462
+ Object.assign(decls, mapped.declarations);
463
+ for (const u of mapped.unmapped) unmapped.add(attrValue(u, 40) || 'unprintable');
464
+ for (const f of mapped.substitutedFonts)
465
+ fonts.note({ css: '', substituted: true, requested: f });
466
+ };
467
+ const ident = (n: Node): boolean => {
468
+ if (n?.type !== 'Identifier' || !scope) return false;
469
+ const renamed = scope.props.get(String(n.name)) ?? scope.locals.get(String(n.name));
470
+ if (!renamed) return false;
471
+ spreads.push(renamed);
472
+ return true;
473
+ };
474
+
475
+ if (isStringLiteral(value)) {
476
+ take(String(value.value));
477
+ return { decls, spreads };
478
+ }
479
+ if (value?.type !== 'JSXExpressionContainer') {
480
+ unmapped.add('className:unsupported');
481
+ return { decls, spreads };
482
+ }
483
+ const expr = unparen(value.expression);
484
+ if (isStringLiteral(expr)) {
485
+ take(String(expr.value));
486
+ return { decls, spreads };
487
+ }
488
+ if (expr?.type === 'LogicalExpression' && (expr.operator === '||' || expr.operator === '??')) {
489
+ const left = unparen(expr.left);
490
+ const right = unparen(expr.right);
491
+ // `className || "fallback"` — the fallback IS the design's own styling and
492
+ // the prop is the caller's override. Merged prop-last so the override wins,
493
+ // which is a superset of the source's either/or semantics.
494
+ if (isStringLiteral(right)) take(String(right.value));
495
+ if (!ident(left)) unmapped.add('className:dynamic');
496
+ return { decls, spreads };
497
+ }
498
+ if (expr?.type === 'TemplateLiteral') {
499
+ for (const q of expr.quasis as Node[]) take(String(q.value?.cooked ?? ''));
500
+ for (const e of expr.expressions as Node[]) {
501
+ if (!ident(unparen(e))) unmapped.add('className:dynamic');
502
+ }
503
+ return { decls, spreads };
504
+ }
505
+ unmapped.add('className:dynamic');
506
+ return { decls, spreads };
507
+ }
508
+
509
+ /** Emit one JSX element/child. `nodeIdCtx` is the nearest `data-node-id`. */
510
+ function emitNode(
511
+ n: Node,
512
+ scope: ComponentDecl | null,
513
+ depth: number,
514
+ nodeIdCtx: string
515
+ ): string | null {
516
+ if (depth > MAX_JSX_DEPTH) throw new CodegenConvertError('JSX nesting over the depth cap');
517
+ jsxNodes += 1;
518
+ if (jsxNodes > MAX_JSX_NODES) throw new CodegenConvertError('JSX node count over the cap');
519
+
520
+ const node = unparen(n);
521
+ if (!node) return null;
522
+
523
+ switch (node.type) {
524
+ case 'JSXText':
525
+ return textChild(String(node.value ?? ''));
526
+ case 'Literal':
527
+ return typeof node.value === 'string' ? textChild(node.value) : null;
528
+ case 'JSXFragment': {
529
+ const kids = emitChildren(node.children, scope, depth + 1, nodeIdCtx);
530
+ return kids.length > 0 ? `<>\n${kids.join('\n')}\n</>` : null;
531
+ }
532
+ case 'JSXExpressionContainer': {
533
+ const expr = unparen(node.expression);
534
+ if (!expr || expr.type === 'JSXEmptyExpression') return null;
535
+ if (isStringLiteral(expr)) return textChild(String(expr.value));
536
+ if (expr.type === 'TemplateLiteral') {
537
+ // Figma wraps text containing `&` or `{` in a template literal
538
+ // (`` {`Ancient Near East & Egypt`} ``) rather than escaping it. With
539
+ // no interpolations it is just text; WITH one it would be a value this
540
+ // converter cannot resolve, so that refuses rather than dropping the
541
+ // dynamic half and shipping a sentence with a hole in it.
542
+ if ((expr.expressions ?? []).length > 0) {
543
+ throw new CodegenConvertError('interpolated template as a child');
544
+ }
545
+ return textChild(
546
+ (expr.quasis ?? []).map((q: Node) => String(q.value?.cooked ?? '')).join('')
547
+ );
548
+ }
549
+ if (expr.type === 'LogicalExpression' && expr.operator === '&&') {
550
+ // `{isAccount && (<div/>)}` — Figma's variant idiom.
551
+ const test = unparen(expr.left);
552
+ if (!scope || test?.type !== 'Identifier') {
553
+ throw new CodegenConvertError('unsupported conditional child');
554
+ }
555
+ const renamed = scope.locals.get(String(test.name)) ?? scope.props.get(String(test.name));
556
+ if (!renamed) throw new CodegenConvertError('unsupported conditional child');
557
+ const body = emitNode(expr.right, scope, depth + 1, nodeIdCtx);
558
+ return body ? `{${renamed} && (\n${indent(body, 1)}\n)}` : null;
559
+ }
560
+ if (expr.type === 'ConditionalExpression') {
561
+ const test = unparen(expr.test);
562
+ if (!scope || test?.type !== 'Identifier') {
563
+ throw new CodegenConvertError('unsupported conditional child');
564
+ }
565
+ const renamed = scope.locals.get(String(test.name)) ?? scope.props.get(String(test.name));
566
+ if (!renamed) throw new CodegenConvertError('unsupported conditional child');
567
+ const a = emitNode(expr.consequent, scope, depth + 1, nodeIdCtx) ?? 'null';
568
+ const b = emitNode(expr.alternate, scope, depth + 1, nodeIdCtx) ?? 'null';
569
+ return `{${renamed} ? (\n${indent(a, 1)}\n) : (\n${indent(b, 1)}\n)}`;
570
+ }
571
+ throw new CodegenConvertError('unsupported JSX expression');
572
+ }
573
+ case 'JSXElement':
574
+ return emitElement(node, scope, depth, nodeIdCtx);
575
+ default:
576
+ throw new CodegenConvertError('unsupported JSX child');
577
+ }
578
+ }
579
+
580
+ function emitChildren(
581
+ children: Node[] | undefined,
582
+ scope: ComponentDecl | null,
583
+ depth: number,
584
+ nodeIdCtx: string
585
+ ): string[] {
586
+ const out: string[] = [];
587
+ for (const c of children ?? []) {
588
+ const emitted = emitNode(c, scope, depth, nodeIdCtx);
589
+ if (emitted) out.push(indent(emitted, 1));
590
+ }
591
+ return out;
592
+ }
593
+
594
+ function emitElement(
595
+ node: Node,
596
+ scope: ComponentDecl | null,
597
+ depth: number,
598
+ parentNodeId: string
599
+ ): string {
600
+ const rawName = jsxName(node.openingElement?.name);
601
+ if (!rawName) throw new CodegenConvertError('unsupported element name');
602
+
603
+ const component = byOriginalName.get(rawName);
604
+ const isComponent = component !== undefined;
605
+ if (!isComponent && !ELEMENT_ALLOWLIST.has(rawName)) {
606
+ // Refusal, not a drop. This feature's history is losing content while
607
+ // reporting success; an element outside the allowlist is a structural
608
+ // surprise and it stops the frame.
609
+ throw new CodegenConvertError('element outside the allowlist');
610
+ }
611
+
612
+ const decls: Record<string, string> = Object.create(null);
613
+ const spreads: string[] = [];
614
+ const attrs: string[] = [];
615
+ let ownNodeId = parentNodeId;
616
+ let src: string | null = null;
617
+ let hasSrc = false;
618
+
619
+ for (const a of (node.openingElement?.attributes ?? []) as Node[]) {
620
+ if (a.type === 'JSXSpreadAttribute') {
621
+ throw new CodegenConvertError('spread attribute');
622
+ }
623
+ const name = attrName(a);
624
+ if (!name) continue;
625
+
626
+ if (isComponent) {
627
+ // A helper's props are its own vocabulary. Only string-literal values
628
+ // pass, and only for props the declaration actually declared.
629
+ if (name === 'className') {
630
+ const st = classNameToStyle(a.value, scope);
631
+ Object.assign(decls, st.decls);
632
+ spreads.push(...st.spreads);
633
+ continue;
634
+ }
635
+ const renamed = component.props.get(name);
636
+ const v = a.value;
637
+ if (renamed && isStringLiteral(v)) {
638
+ attrs.push(`${renamed}={${jsxStringLiteral(String(v.value))}}`);
639
+ } else if (
640
+ renamed &&
641
+ v?.type === 'JSXExpressionContainer' &&
642
+ isStringLiteral(unparen(v.expression))
643
+ ) {
644
+ attrs.push(`${renamed}={${jsxStringLiteral(String(unparen(v.expression).value))}}`);
645
+ }
646
+ continue;
647
+ }
648
+
649
+ if (!ATTRIBUTE_ALLOWLIST.has(name)) continue;
650
+
651
+ if (name === 'className') {
652
+ const st = classNameToStyle(a.value, scope);
653
+ Object.assign(decls, st.decls);
654
+ spreads.push(...st.spreads);
655
+ continue;
656
+ }
657
+ if (name === 'style') {
658
+ const v = a.value;
659
+ if (v?.type === 'JSXExpressionContainer') Object.assign(decls, inlineStyle(v.expression));
660
+ continue;
661
+ }
662
+ if (name === 'data-node-id') {
663
+ const raw = isStringLiteral(a.value) ? String(a.value.value) : '';
664
+ if (NODE_ID_ATTR_RE.test(raw)) ownNodeId = raw;
665
+ continue;
666
+ }
667
+ if (name === 'alt') {
668
+ const raw = isStringLiteral(a.value) ? String(a.value.value) : '';
669
+ attrs.push(`alt=${JSON.stringify(attrValue(raw, 64))}`);
670
+ continue;
671
+ }
672
+ if (name === 'src') {
673
+ hasSrc = true;
674
+ const v = a.value;
675
+ const expr = v?.type === 'JSXExpressionContainer' ? unparen(v.expression) : null;
676
+ // ONLY a reference to a module-level asset constant is recognised. A
677
+ // literal URL is refused rather than downloaded — there is no URL entry
678
+ // point into the asset lane and this route must not invent one (D6).
679
+ if (expr?.type === 'Identifier' && assetConsts.has(String(expr.name))) {
680
+ src = assetFor(ownNodeId);
681
+ }
682
+ }
683
+ }
684
+
685
+ if (rawName === 'img' && hasSrc && src === null) {
686
+ opts.report.add(ownNodeId, 'ASSET', 'asset-skipped', 'unrecognized codegen src');
687
+ }
688
+
689
+ const styleParts: string[] = [];
690
+ if (Object.keys(decls).length > 0) styleParts.push(`...${styleLiteral(decls)}`);
691
+ for (const s of spreads) styleParts.push(`...${s}`);
692
+ const styleAttr =
693
+ styleParts.length > 0
694
+ ? styleParts.length === 1 && styleParts[0].startsWith('...{')
695
+ ? ` style={${styleLiteral(decls)}}`
696
+ : ` style={{ ${styleParts.join(', ')} }}`
697
+ : '';
698
+
699
+ const name = isComponent ? component.name : rawName;
700
+ const provenance =
701
+ !isComponent && ownNodeId && ownNodeId !== parentNodeId
702
+ ? ` data-figma-node=${JSON.stringify(ownNodeId)}`
703
+ : '';
704
+ const srcAttr = src ? ` src=${JSON.stringify(src)}` : '';
705
+ const extra = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
706
+ const open = `<${name}${styleAttr}${srcAttr}${extra}${provenance}`;
707
+
708
+ if (isComponent || VOID_ELEMENTS.has(rawName)) {
709
+ if (!isComponent && (node.children ?? []).length > 0) {
710
+ throw new CodegenConvertError('void element with children');
711
+ }
712
+ return `${open} />`;
713
+ }
714
+
715
+ const kids = emitChildren(node.children, scope, depth + 1, ownNodeId);
716
+ if (kids.length === 0) return `${open} />`;
717
+ return `${open}>\n${kids.join('\n')}\n</${name}>`;
718
+ }
719
+
720
+ /** One placeholder per source NODE, so `renderKey` dedupe does the rest (D6). */
721
+ function assetFor(nodeId: string): string {
722
+ const existing = assetPlaceholders.get(nodeId);
723
+ if (existing) return existing;
724
+ const placeholder = `/assets/pending-codegen-${nodeId.replace(/[^0-9]+/g, '-')}.svg`;
725
+ assetPlaceholders.set(nodeId, placeholder);
726
+ pendingAssets.push({ nodeId, format: 'svg', placeholder });
727
+ return placeholder;
728
+ }
729
+
730
+ /** A component's `return` — the one statement shape that produces output. */
731
+ function componentBody(decl: ComponentDecl): string {
732
+ const body = decl.node.body?.body ?? [];
733
+ let ret: Node | null = null;
734
+ const consts: string[] = [];
735
+ for (const stmt of body as Node[]) {
736
+ if (stmt.type === 'VariableDeclaration') {
737
+ for (const d of stmt.declarations) {
738
+ const renamed = decl.locals.get(String(d.id?.name ?? ''));
739
+ const init = unparen(d.init);
740
+ // The only local a real response declares is a variant test —
741
+ // `const isAccount = property1 === "account"`. Nothing else is
742
+ // admitted, because anything else is code we would be re-emitting
743
+ // without understanding it.
744
+ if (!renamed || !init || init.type !== 'BinaryExpression') {
745
+ throw new CodegenConvertError('unsupported local declaration');
746
+ }
747
+ if (init.operator !== '===' && init.operator !== '!==') {
748
+ throw new CodegenConvertError('unsupported local declaration');
749
+ }
750
+ const left = unparen(init.left);
751
+ const right = unparen(init.right);
752
+ const leftName = left?.type === 'Identifier' ? decl.props.get(String(left.name)) : null;
753
+ if (!leftName || !isStringLiteral(right)) {
754
+ throw new CodegenConvertError('unsupported local declaration');
755
+ }
756
+ consts.push(
757
+ ` const ${renamed} = ${leftName} ${init.operator} ${jsxStringLiteral(String(right.value))};`
758
+ );
759
+ }
760
+ continue;
761
+ }
762
+ if (stmt.type === 'ReturnStatement') {
763
+ ret = stmt.argument;
764
+ continue;
765
+ }
766
+ throw new CodegenConvertError('unsupported statement in a component');
767
+ }
768
+ if (!ret) throw new CodegenConvertError('component returns nothing');
769
+ const jsx = emitNode(ret, decl, 0, opts.nodeId);
770
+ if (!jsx) throw new CodegenConvertError('component produced no output');
771
+ return `${consts.join('\n')}${consts.length ? '\n' : ''} return (\n${indent(jsx, 2)}\n );`;
772
+ }
773
+
774
+ const helperSources: string[] = [];
775
+ for (const decl of components) {
776
+ helperSources.push(`function ${decl.name}(${paramList(decl)}) {\n${componentBody(decl)}\n}`);
777
+ }
778
+ const rootJsx = (() => {
779
+ const body = componentBody(rootComponent);
780
+ // The root component's body is inlined into the artboard rather than kept as
781
+ // a component: an artboard IS the component boundary here, and one fewer
782
+ // indirection is one fewer thing the selection ladder drills through.
783
+ const m = /return \(\n([\s\S]*)\n {2}\);$/.exec(body);
784
+ if (!m) throw new CodegenConvertError('root component has an unsupported shape');
785
+ return m[1];
786
+ })();
787
+
788
+ fonts.flush(opts.report, opts.nodeId);
789
+ for (const u of [...unmapped].sort().slice(0, 40)) {
790
+ opts.report.add(opts.nodeId, 'CSS', 'codegen-utility-unmapped', reportToken(u));
791
+ }
792
+ if (unmapped.size > 40) {
793
+ opts.report.add(
794
+ opts.nodeId,
795
+ 'CSS',
796
+ 'codegen-utility-unmapped',
797
+ `and ${unmapped.size - 40} more`
798
+ );
799
+ }
800
+
801
+ const abId = identifierFromNodeId(opts.nodeId).toLowerCase().replace(/_/g, '-');
802
+ const artboardJsx = ` <DCArtboard
803
+ id=${JSON.stringify(abId)}
804
+ label=${JSON.stringify(`${opts.label} · codegen`)}
805
+ width={${Math.max(1, Math.round(opts.width))}}
806
+ height={${Math.max(1, Math.round(opts.height))}}
807
+ kind=${JSON.stringify(ARTBOARD_KINDS.has(opts.kind) ? opts.kind : 'digital')}
808
+ layout="block"
809
+ >
810
+ ${indent(rootJsx, 2)}
811
+ </DCArtboard>`;
812
+
813
+ const helpers = helperSources.join('\n\n');
814
+ const bytes = artboardJsx.length + helpers.length;
815
+ if (bytes > MAX_OUTPUT_BYTES)
816
+ throw new CodegenConvertError('emitted artboard over the output cap');
817
+
818
+ const rootMeta = rootIdentity(rootComponent);
819
+
820
+ return {
821
+ artboardJsx,
822
+ helpers,
823
+ pendingAssets,
824
+ unmappedUtilities: [...unmapped].sort(),
825
+ bytes,
826
+ rootNodeId: rootMeta.nodeId,
827
+ rootName: rootMeta.name,
828
+ };
829
+ }
830
+
831
+ /**
832
+ * The root element's `data-node-id` + bounded `data-name`.
833
+ *
834
+ * Read straight off the AST rather than off the emitted output, because the
835
+ * emitter deliberately drops `data-name` (a raw layer name) and omits
836
+ * `data-figma-node` on the root. This is the ONLY place that name is read, it is
837
+ * bounded on the way out, and the caller compares it and throws it away.
838
+ */
839
+ function rootIdentity(decl: ComponentDecl): { nodeId: string | null; name: string } {
840
+ let nodeId: string | null = null;
841
+ let name = '';
842
+ for (const stmt of (decl.node.body?.body ?? []) as Node[]) {
843
+ if (stmt.type !== 'ReturnStatement') continue;
844
+ const el = unparen(stmt.argument);
845
+ if (el?.type !== 'JSXElement') break;
846
+ for (const a of (el.openingElement?.attributes ?? []) as Node[]) {
847
+ const n = attrName(a);
848
+ if (!isStringLiteral(a.value)) continue;
849
+ if (n === 'data-node-id' && NODE_ID_ATTR_RE.test(String(a.value.value))) {
850
+ nodeId = String(a.value.value);
851
+ }
852
+ if (n === 'data-name') name = attrValue(String(a.value.value), 64);
853
+ }
854
+ break;
855
+ }
856
+ return { nodeId, name };
857
+ }
858
+
859
+ /**
860
+ * The existing `<DCArtboard id="…">`'s own size and label.
861
+ *
862
+ * Read from the CANVAS rather than only from `.meta.json` because the canvas is
863
+ * the live truth: sizes are JSX-authoritative (DDR-027), a user may have resized
864
+ * the board since the import, and canvases imported before `figma.frames[]`
865
+ * carried `w`/`h` have no stored size at all. Returns `null` when the artboard
866
+ * is absent — the caller turns that into a refusal.
867
+ */
868
+ /**
869
+ * Does this source parse as a TSX module?
870
+ *
871
+ * Exported so the WRITE path can gate on it. DDR-219 D8 says "build out-of-tree,
872
+ * validate it parses, then write", and the first implementation spliced by byte
873
+ * range and renamed straight onto the live path — the validation existed only in
874
+ * the comment (post-implementation review F2).
875
+ */
876
+ export function parsesAsModule(source: string): boolean {
877
+ try {
878
+ return parseSync('canvas.tsx', source, { sourceType: 'module' }).errors.length === 0;
879
+ } catch {
880
+ return false;
881
+ }
882
+ }
883
+
884
+ /**
885
+ * The closed set of artboard kinds (`canvas-lib.tsx`'s `ArtboardKind`).
886
+ *
887
+ * An ALLOWLIST, because `kind` lands in an emitted JSX opening tag. The first
888
+ * version took it from a `.meta.json` field with no bound at all, which made a
889
+ * peer-authored sidecar an injection vector into canvas source — see
890
+ * `explodeArtboard`'s note. A closed enum is the whole vocabulary, so nothing
891
+ * legitimate is lost by refusing everything else.
892
+ */
893
+ export const ARTBOARD_KINDS: ReadonlySet<string> = new Set(['digital', 'print', 'web', 'video']);
894
+
895
+ export function readArtboardBox(
896
+ canvasSource: string,
897
+ artboardId: string
898
+ ): { width: number; height: number; label: string; kind: string } | null {
899
+ const parsed = parseSync('canvas.tsx', canvasSource, { sourceType: 'module' });
900
+ if (parsed.errors && parsed.errors.length > 0) return null;
901
+
902
+ let found: { width: number; height: number; label: string; kind: string } | null = null;
903
+ const numeric = (a: Node): number | null => {
904
+ const v = a?.value;
905
+ if (v?.type === 'JSXExpressionContainer') {
906
+ const e = unparen(v.expression);
907
+ if (e?.type === 'Literal' && typeof e.value === 'number') return e.value;
908
+ }
909
+ if (v?.type === 'Literal' && typeof v.value === 'number') return v.value;
910
+ return null;
911
+ };
912
+ const walk = (n: Node): void => {
913
+ if (!n || typeof n !== 'object' || found) return;
914
+ if (Array.isArray(n)) {
915
+ for (const c of n) walk(c);
916
+ return;
917
+ }
918
+ if (n.type === 'JSXElement' && jsxName(n.openingElement?.name) === 'DCArtboard') {
919
+ let id: string | null = null;
920
+ let width: number | null = null;
921
+ let height: number | null = null;
922
+ let label = '';
923
+ // Default, not a passthrough: an unrecognised kind is refused rather than
924
+ // carried, because this value is re-emitted into a JSX opening tag.
925
+ let kind = 'digital';
926
+ for (const a of (n.openingElement?.attributes ?? []) as Node[]) {
927
+ const k = attrName(a);
928
+ if (k === 'id' && isStringLiteral(a.value)) id = String(a.value.value);
929
+ if (k === 'label' && isStringLiteral(a.value)) label = attrValue(String(a.value.value), 64);
930
+ if (k === 'kind' && isStringLiteral(a.value) && ARTBOARD_KINDS.has(String(a.value.value))) {
931
+ kind = String(a.value.value);
932
+ }
933
+ if (k === 'width') width = numeric(a);
934
+ if (k === 'height') height = numeric(a);
935
+ }
936
+ if (id === artboardId && width !== null && height !== null) {
937
+ found = { width, height, label, kind };
938
+ return;
939
+ }
940
+ }
941
+ for (const k of Object.keys(n)) {
942
+ if (k === 'type' || k === 'start' || k === 'end') continue;
943
+ walk(n[k]);
944
+ }
945
+ };
946
+ walk(parsed.program);
947
+ return found;
948
+ }
949
+
950
+ /**
951
+ * Replace ONE `<DCArtboard id="…">` subtree in an existing canvas, and hoist the
952
+ * helper components to module scope.
953
+ *
954
+ * Parsed, not regexed, for the same reason the converter is: a canvas is a real
955
+ * TSX file and `<DCArtboard` appears in its header comment. A byte-range
956
+ * replacement over a located AST node cannot mistake prose for markup.
957
+ *
958
+ * @throws CodegenConvertError when the artboard id is not present exactly once —
959
+ * the write model refuses rather than guessing which one was meant.
960
+ */
961
+ export function spliceArtboard(
962
+ canvasSource: string,
963
+ opts: { artboardId: string; artboardJsx: string; helpers: string; banner: string }
964
+ ): string {
965
+ const parsed = parseSync('canvas.tsx', canvasSource, { sourceType: 'module' });
966
+ if (parsed.errors && parsed.errors.length > 0) {
967
+ throw new CodegenConvertError('target canvas does not parse');
968
+ }
969
+
970
+ const hits: Array<{ start: number; end: number }> = [];
971
+ let exportStart = -1;
972
+
973
+ const walk = (n: Node): void => {
974
+ if (!n || typeof n !== 'object') return;
975
+ if (Array.isArray(n)) {
976
+ for (const c of n) walk(c);
977
+ return;
978
+ }
979
+ if (n.type === 'ExportDefaultDeclaration' && exportStart < 0) exportStart = n.start;
980
+ if (n.type === 'JSXElement' && jsxName(n.openingElement?.name) === 'DCArtboard') {
981
+ for (const a of (n.openingElement?.attributes ?? []) as Node[]) {
982
+ if (attrName(a) !== 'id') continue;
983
+ const v = a.value;
984
+ const literal = isStringLiteral(v)
985
+ ? String(v.value)
986
+ : v?.type === 'JSXExpressionContainer' && isStringLiteral(unparen(v.expression))
987
+ ? String(unparen(v.expression).value)
988
+ : null;
989
+ if (literal === opts.artboardId) hits.push({ start: n.start, end: n.end });
990
+ }
991
+ }
992
+ for (const k of Object.keys(n)) {
993
+ if (k === 'type' || k === 'start' || k === 'end') continue;
994
+ walk(n[k]);
995
+ }
996
+ };
997
+ walk(parsed.program);
998
+
999
+ if (hits.length === 0) throw new CodegenConvertError('artboard id not found in the canvas');
1000
+ if (hits.length > 1) throw new CodegenConvertError('artboard id is not unique in the canvas');
1001
+ if (exportStart < 0) throw new CodegenConvertError('canvas has no default export');
1002
+
1003
+ const hit = hits[0];
1004
+ if (hit.start <= exportStart) throw new CodegenConvertError('artboard is outside the export');
1005
+
1006
+ // Both edits are byte offsets into the SAME original source, and the export
1007
+ // always starts before the artboard it contains — so splicing from the end
1008
+ // backwards keeps the earlier offset valid without a source-map library.
1009
+ const preamble = `${opts.banner}\n${opts.helpers ? `${opts.helpers}\n\n` : ''}`;
1010
+ // The artboard block carries its own leading indentation.
1011
+ const artboard = opts.artboardJsx.replace(/^\s+/, '');
1012
+ return (
1013
+ canvasSource.slice(0, exportStart) +
1014
+ preamble +
1015
+ canvasSource.slice(exportStart, hit.start) +
1016
+ artboard +
1017
+ canvasSource.slice(hit.end)
1018
+ );
1019
+ }
1020
+
1021
+ /**
1022
+ * Declare a component with a FULLY REGENERATED signature.
1023
+ *
1024
+ * Every name here — the component, each parameter, each local — is derived from
1025
+ * the frame's node id and an index. Nothing from the response's identifier space
1026
+ * survives, which is the only way DDR-216 D6's "airtight" claim stays true on a
1027
+ * route that receives third-party source (DDR-219 D4).
1028
+ */
1029
+ function declareComponent(fn: Node, rootId: string, index: number): ComponentDecl {
1030
+ const decl: ComponentDecl = {
1031
+ name: `${rootId}_C${index}`,
1032
+ props: new Map(),
1033
+ defaults: new Map(),
1034
+ locals: new Map(),
1035
+ styleProp: null,
1036
+ node: fn,
1037
+ };
1038
+
1039
+ const params = fn.params ?? [];
1040
+ if (params.length > 1) throw new CodegenConvertError('component takes more than one parameter');
1041
+ if (params.length === 1) {
1042
+ const p = params[0];
1043
+ if (p.type !== 'ObjectPattern') throw new CodegenConvertError('non-destructured props');
1044
+ let i = 0;
1045
+ for (const prop of p.properties as Node[]) {
1046
+ if (prop.type !== 'Property' || prop.computed) {
1047
+ throw new CodegenConvertError('unsupported prop pattern');
1048
+ }
1049
+ const key = prop.key?.type === 'Identifier' ? String(prop.key.name) : null;
1050
+ if (!key || FORBIDDEN_KEYS.has(key)) throw new CodegenConvertError('unsupported prop name');
1051
+ const renamed = `p${i}`;
1052
+ i += 1;
1053
+ decl.props.set(key, renamed);
1054
+ // `className` becomes a STYLE object: this converter turns classes into
1055
+ // inline style everywhere else, so a forwarded `className` would be the
1056
+ // one string in the output nothing consumes.
1057
+ if (key === 'className') decl.styleProp = renamed;
1058
+ const value = prop.value;
1059
+ if (value?.type === 'AssignmentPattern') {
1060
+ const right = unparen(value.right);
1061
+ if (!isStringLiteral(right)) throw new CodegenConvertError('unsupported prop default');
1062
+ decl.defaults.set(key, jsxStringLiteral(String(right.value)));
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ // Locals are enumerated up front so a JSX body can reference one declared
1068
+ // above it without the emitter needing a second pass.
1069
+ let li = 0;
1070
+ for (const stmt of (fn.body?.body ?? []) as Node[]) {
1071
+ if (stmt.type !== 'VariableDeclaration') continue;
1072
+ for (const d of stmt.declarations) {
1073
+ if (d.id?.type !== 'Identifier')
1074
+ throw new CodegenConvertError('unsupported local declaration');
1075
+ decl.locals.set(String(d.id.name), `v${li}`);
1076
+ li += 1;
1077
+ }
1078
+ }
1079
+ return decl;
1080
+ }
1081
+
1082
+ function paramList(decl: ComponentDecl): string {
1083
+ if (decl.props.size === 0) return '';
1084
+ const parts: string[] = [];
1085
+ for (const [original, renamed] of decl.props) {
1086
+ if (renamed === decl.styleProp) {
1087
+ parts.push(`${renamed} = {}`);
1088
+ continue;
1089
+ }
1090
+ const def = decl.defaults.get(original);
1091
+ parts.push(def ? `${renamed} = ${def}` : renamed);
1092
+ }
1093
+ return `{ ${parts.join(', ')} }`;
1094
+ }
1095
+
1096
+ function indent(text: string, levels: number): string {
1097
+ const pad = ' '.repeat(levels);
1098
+ return text
1099
+ .split('\n')
1100
+ .map((line) => (line.length > 0 ? pad + line : line))
1101
+ .join('\n');
1102
+ }
1103
+ /* biome-ignore-end lint/suspicious/noExplicitAny: see the note above. */