@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.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- 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. */
|