@1agh/maude 0.58.1 → 0.58.3
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/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +129 -23
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- 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/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/client/styles/6-acp-chat.css +12 -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 +1491 -1491
- 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 +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -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 +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -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 +306 -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 +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +755 -32
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- 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 +13 -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/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- 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-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -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 +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-canvas-path.test.ts +200 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -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-path-pull.test.ts +465 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +434 -1
- package/apps/studio/test/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file figma/types.ts — the normalized Figma node tree (DDR-216).
|
|
3
|
+
* @scope apps/studio/figma/types.ts
|
|
4
|
+
* @purpose The ONE shape both ingestion doors emit and all three translators
|
|
5
|
+
* consume. `client.ts` (REST, Phase 1) produces it today; a future
|
|
6
|
+
* `fig-decode.ts` (Phase 6) only has to produce the same shape and
|
|
7
|
+
* it inherits `to-strokes` / `to-artboard` / `to-tokens` for free.
|
|
8
|
+
* That seam is also what makes Phase 6's Tier-2 differential smoke
|
|
9
|
+
* possible at all — the same document through both doors must
|
|
10
|
+
* normalize to the same tree.
|
|
11
|
+
*
|
|
12
|
+
* @invariant FIELD NAMES MIRROR THE REST API deliberately. Renaming them into
|
|
13
|
+
* a prettier house vocabulary would make the Phase-6 decoder's job
|
|
14
|
+
* "translate twice" instead of "emit this shape", and would make a
|
|
15
|
+
* differential diff read as a wall of false positives.
|
|
16
|
+
*
|
|
17
|
+
* @invariant EVERY STRING FROM A DOCUMENT IS UNTRUSTED. `name` and
|
|
18
|
+
* `characters` in particular: a TEXT node's layer name DEFAULTS to
|
|
19
|
+
* its own content, so `name` is user text on a real file. They are
|
|
20
|
+
* carried verbatim through normalization on purpose — sanitization
|
|
21
|
+
* belongs at the emission sinks (DDR-216 D6), where the target
|
|
22
|
+
* grammar is known — and are marked UNTRUSTED at every declaration
|
|
23
|
+
* so nobody interpolates one into JSX, a path or a shell argument
|
|
24
|
+
* on the way past.
|
|
25
|
+
*
|
|
26
|
+
* @invariant DEPENDENCY-FREE. No `node:*`, no network, no filesystem — the
|
|
27
|
+
* normalizer walks an already-parsed object and nothing else.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// ── Caps (DDR-216 D5) ───────────────────────────────────────────────────────
|
|
31
|
+
// Pre-translation HARD REFUSALS. Each fails with a clear, actionable message —
|
|
32
|
+
// never an OOM, never a truncated best-effort tree. Measured baseline: a real
|
|
33
|
+
// first-party page is 449 KB / 4 125 nodes / depth 13 in the metadata-only
|
|
34
|
+
// projection, and the full REST payload (fills, strokes, effects, typeStyle per
|
|
35
|
+
// node) is materially larger.
|
|
36
|
+
|
|
37
|
+
/** ~18× the measured real page's metadata projection. */
|
|
38
|
+
export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
39
|
+
/** ~5× the measured 4 125. */
|
|
40
|
+
export const MAX_NODE_COUNT = 20_000;
|
|
41
|
+
/** ~5× the measured 13. Also bounds every recursive walk structurally. */
|
|
42
|
+
export const MAX_TREE_DEPTH = 64;
|
|
43
|
+
|
|
44
|
+
/** Keys that must never be copied off an untrusted object (DDR-172 Decision 3). */
|
|
45
|
+
const POLLUTING_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
46
|
+
|
|
47
|
+
// ── Node vocabulary ─────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The node types both doors can produce. Sourced from the `.fig` container's
|
|
51
|
+
* own embedded schema (`NodeType`, read out of the committed fixtures), so the
|
|
52
|
+
* Phase-6 decoder has nothing to guess and this union needs no widening later.
|
|
53
|
+
*/
|
|
54
|
+
export type FigmaNodeType =
|
|
55
|
+
| 'DOCUMENT'
|
|
56
|
+
| 'CANVAS'
|
|
57
|
+
| 'FRAME'
|
|
58
|
+
| 'GROUP'
|
|
59
|
+
| 'SECTION'
|
|
60
|
+
| 'COMPONENT'
|
|
61
|
+
| 'COMPONENT_SET'
|
|
62
|
+
| 'INSTANCE'
|
|
63
|
+
| 'VECTOR'
|
|
64
|
+
| 'BOOLEAN_OPERATION'
|
|
65
|
+
| 'STAR'
|
|
66
|
+
| 'LINE'
|
|
67
|
+
| 'ELLIPSE'
|
|
68
|
+
| 'RECTANGLE'
|
|
69
|
+
| 'ROUNDED_RECTANGLE'
|
|
70
|
+
| 'REGULAR_POLYGON'
|
|
71
|
+
| 'TEXT'
|
|
72
|
+
| 'SLICE'
|
|
73
|
+
| 'STICKY'
|
|
74
|
+
| 'SHAPE_WITH_TEXT'
|
|
75
|
+
| 'CONNECTOR'
|
|
76
|
+
| 'CODE_BLOCK'
|
|
77
|
+
| 'WIDGET'
|
|
78
|
+
| 'STAMP'
|
|
79
|
+
| 'TABLE'
|
|
80
|
+
| 'MEDIA'
|
|
81
|
+
| 'EMBED'
|
|
82
|
+
| 'LINK_UNFURL'
|
|
83
|
+
| 'WASHI_TAPE'
|
|
84
|
+
| 'UNKNOWN';
|
|
85
|
+
|
|
86
|
+
const KNOWN_NODE_TYPES = new Set<string>([
|
|
87
|
+
'DOCUMENT',
|
|
88
|
+
'CANVAS',
|
|
89
|
+
'FRAME',
|
|
90
|
+
'GROUP',
|
|
91
|
+
'SECTION',
|
|
92
|
+
'COMPONENT',
|
|
93
|
+
'COMPONENT_SET',
|
|
94
|
+
'INSTANCE',
|
|
95
|
+
'VECTOR',
|
|
96
|
+
'BOOLEAN_OPERATION',
|
|
97
|
+
'STAR',
|
|
98
|
+
'LINE',
|
|
99
|
+
'ELLIPSE',
|
|
100
|
+
'RECTANGLE',
|
|
101
|
+
'ROUNDED_RECTANGLE',
|
|
102
|
+
'REGULAR_POLYGON',
|
|
103
|
+
'TEXT',
|
|
104
|
+
'SLICE',
|
|
105
|
+
'STICKY',
|
|
106
|
+
'SHAPE_WITH_TEXT',
|
|
107
|
+
'CONNECTOR',
|
|
108
|
+
'CODE_BLOCK',
|
|
109
|
+
'WIDGET',
|
|
110
|
+
'STAMP',
|
|
111
|
+
'TABLE',
|
|
112
|
+
'MEDIA',
|
|
113
|
+
'EMBED',
|
|
114
|
+
'LINK_UNFURL',
|
|
115
|
+
'WASHI_TAPE',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
export interface FigmaRect {
|
|
119
|
+
x: number;
|
|
120
|
+
y: number;
|
|
121
|
+
width: number;
|
|
122
|
+
height: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface FigmaColor {
|
|
126
|
+
r: number;
|
|
127
|
+
g: number;
|
|
128
|
+
b: number;
|
|
129
|
+
a: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface FigmaPaint {
|
|
133
|
+
type: string; // SOLID | GRADIENT_LINEAR | IMAGE | …
|
|
134
|
+
visible: boolean;
|
|
135
|
+
opacity?: number;
|
|
136
|
+
color?: FigmaColor;
|
|
137
|
+
/** Present on IMAGE paints — the handle `/v1/images` resolves. */
|
|
138
|
+
imageRef?: string;
|
|
139
|
+
gradientStops?: Array<{ position: number; color: FigmaColor }>;
|
|
140
|
+
gradientHandlePositions?: Array<{ x: number; y: number }>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface FigmaEffect {
|
|
144
|
+
type: string; // DROP_SHADOW | INNER_SHADOW | LAYER_BLUR | BACKGROUND_BLUR
|
|
145
|
+
visible: boolean;
|
|
146
|
+
color?: FigmaColor;
|
|
147
|
+
offset?: { x: number; y: number };
|
|
148
|
+
radius?: number;
|
|
149
|
+
spread?: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Figma's `style` block on a TEXT node. */
|
|
153
|
+
export interface FigmaTypeStyle {
|
|
154
|
+
fontFamily?: string; // UNTRUSTED
|
|
155
|
+
fontPostScriptName?: string; // UNTRUSTED
|
|
156
|
+
fontWeight?: number;
|
|
157
|
+
fontSize?: number;
|
|
158
|
+
lineHeightPx?: number;
|
|
159
|
+
letterSpacing?: number;
|
|
160
|
+
textAlignHorizontal?: string;
|
|
161
|
+
textAlignVertical?: string;
|
|
162
|
+
textCase?: string;
|
|
163
|
+
textDecoration?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One normalized node. Optional everywhere by design: both doors see partial
|
|
168
|
+
* documents (a `getFileNodes` projection carries less than `getFile`), and a
|
|
169
|
+
* translator that copes with an absent field copes with both doors.
|
|
170
|
+
*/
|
|
171
|
+
export interface FigmaNode {
|
|
172
|
+
/** `^[0-9]+:[0-9]+$` in practice — the ONLY string safe to derive an identifier from. */
|
|
173
|
+
id: string;
|
|
174
|
+
type: FigmaNodeType;
|
|
175
|
+
/** UNTRUSTED — a TEXT node's layer name defaults to its own content. */
|
|
176
|
+
name: string;
|
|
177
|
+
/** False ⇒ the node is not emitted at all (DDR-216 D6b). */
|
|
178
|
+
visible: boolean;
|
|
179
|
+
absoluteBoundingBox?: FigmaRect;
|
|
180
|
+
/**
|
|
181
|
+
* What is actually DRAWN — geometry plus stroke weight, arrowheads and
|
|
182
|
+
* effects. Differs from `absoluteBoundingBox` by more than a rounding error
|
|
183
|
+
* on stroked paths: a horizontal arrow's geometric box has height 0.0001
|
|
184
|
+
* while its render bounds are 22.09. Placing such a node at its geometric
|
|
185
|
+
* box renders it into nothing, which is how nine flow arrows imported
|
|
186
|
+
* "successfully" and were invisible.
|
|
187
|
+
*/
|
|
188
|
+
absoluteRenderBounds?: FigmaRect;
|
|
189
|
+
rotation?: number;
|
|
190
|
+
opacity?: number;
|
|
191
|
+
blendMode?: string;
|
|
192
|
+
clipsContent?: boolean;
|
|
193
|
+
|
|
194
|
+
// ── design-side layout ──
|
|
195
|
+
layoutMode?: 'HORIZONTAL' | 'VERTICAL' | 'NONE';
|
|
196
|
+
itemSpacing?: number;
|
|
197
|
+
paddingLeft?: number;
|
|
198
|
+
paddingRight?: number;
|
|
199
|
+
paddingTop?: number;
|
|
200
|
+
paddingBottom?: number;
|
|
201
|
+
primaryAxisAlignItems?: string;
|
|
202
|
+
counterAxisAlignItems?: string;
|
|
203
|
+
layoutWrap?: string;
|
|
204
|
+
|
|
205
|
+
// ── paint ──
|
|
206
|
+
fills?: FigmaPaint[];
|
|
207
|
+
strokes?: FigmaPaint[];
|
|
208
|
+
strokeWeight?: number;
|
|
209
|
+
effects?: FigmaEffect[];
|
|
210
|
+
cornerRadius?: number;
|
|
211
|
+
rectangleCornerRadii?: number[];
|
|
212
|
+
|
|
213
|
+
// ── text ──
|
|
214
|
+
/** UNTRUSTED — the literal user text. */
|
|
215
|
+
characters?: string;
|
|
216
|
+
style?: FigmaTypeStyle;
|
|
217
|
+
|
|
218
|
+
// ── FigJam ──
|
|
219
|
+
shapeType?: string;
|
|
220
|
+
/** A real node id — the shape `ArrowBind.hostId` wants (DDR-216 D9). */
|
|
221
|
+
connectorStart?: string;
|
|
222
|
+
connectorEnd?: string;
|
|
223
|
+
connectorStartCap?: string;
|
|
224
|
+
connectorEndCap?: string;
|
|
225
|
+
connectorLineType?: string;
|
|
226
|
+
|
|
227
|
+
// ── component semantics (display-only; no runtime equivalent) ──
|
|
228
|
+
componentId?: string;
|
|
229
|
+
|
|
230
|
+
children?: FigmaNode[];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** What either door hands the translators. */
|
|
234
|
+
export interface NormalizedDocument {
|
|
235
|
+
/** Charset-validated by `url.ts` — never free text. */
|
|
236
|
+
fileKey: string;
|
|
237
|
+
/** `design` | `board`, from the URL shape or the `.fig` prelude. */
|
|
238
|
+
surface: 'design' | 'board';
|
|
239
|
+
/** Which door produced this — recorded for the Tier-2 differential diff. */
|
|
240
|
+
origin: 'rest' | 'fig';
|
|
241
|
+
root: FigmaNode;
|
|
242
|
+
/** Post-normalization counts, so callers report rather than re-walk. */
|
|
243
|
+
nodeCount: number;
|
|
244
|
+
maxDepth: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export class FigmaCapError extends Error {
|
|
248
|
+
readonly cap: 'nodes' | 'depth' | 'bytes';
|
|
249
|
+
constructor(cap: 'nodes' | 'depth' | 'bytes', message: string) {
|
|
250
|
+
super(message);
|
|
251
|
+
this.name = 'FigmaCapError';
|
|
252
|
+
this.cap = cap;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Normalization ───────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function str(v: unknown): string | undefined {
|
|
259
|
+
return typeof v === 'string' ? v : undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function num(v: unknown): number | undefined {
|
|
263
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function bool(v: unknown, fallback: boolean): boolean {
|
|
267
|
+
return typeof v === 'boolean' ? v : fallback;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function rect(v: unknown): FigmaRect | undefined {
|
|
271
|
+
if (!v || typeof v !== 'object') return undefined;
|
|
272
|
+
const r = v as Record<string, unknown>;
|
|
273
|
+
const x = num(r.x);
|
|
274
|
+
const y = num(r.y);
|
|
275
|
+
const width = num(r.width);
|
|
276
|
+
const height = num(r.height);
|
|
277
|
+
if (x === undefined || y === undefined || width === undefined || height === undefined) {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
return { x, y, width, height };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function color(v: unknown): FigmaColor | undefined {
|
|
284
|
+
if (!v || typeof v !== 'object') return undefined;
|
|
285
|
+
const c = v as Record<string, unknown>;
|
|
286
|
+
const r = num(c.r);
|
|
287
|
+
const g = num(c.g);
|
|
288
|
+
const b = num(c.b);
|
|
289
|
+
if (r === undefined || g === undefined || b === undefined) return undefined;
|
|
290
|
+
return { r, g, b, a: num(c.a) ?? 1 };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function paints(v: unknown): FigmaPaint[] | undefined {
|
|
294
|
+
if (!Array.isArray(v)) return undefined;
|
|
295
|
+
const out: FigmaPaint[] = [];
|
|
296
|
+
// Bounded: a node with thousands of paints is not a real node, and this walk
|
|
297
|
+
// runs once per node under MAX_NODE_COUNT.
|
|
298
|
+
for (const item of v.slice(0, 32)) {
|
|
299
|
+
if (!item || typeof item !== 'object') continue;
|
|
300
|
+
const p = item as Record<string, unknown>;
|
|
301
|
+
const type = str(p.type);
|
|
302
|
+
if (!type) continue;
|
|
303
|
+
const entry: FigmaPaint = { type, visible: bool(p.visible, true) };
|
|
304
|
+
const o = num(p.opacity);
|
|
305
|
+
if (o !== undefined) entry.opacity = o;
|
|
306
|
+
const col = color(p.color);
|
|
307
|
+
if (col) entry.color = col;
|
|
308
|
+
const ref = str(p.imageRef);
|
|
309
|
+
if (ref) entry.imageRef = ref;
|
|
310
|
+
if (Array.isArray(p.gradientStops)) {
|
|
311
|
+
const stops: Array<{ position: number; color: FigmaColor }> = [];
|
|
312
|
+
for (const s of p.gradientStops.slice(0, 32)) {
|
|
313
|
+
if (!s || typeof s !== 'object') continue;
|
|
314
|
+
const sr = s as Record<string, unknown>;
|
|
315
|
+
const pos = num(sr.position);
|
|
316
|
+
const sc = color(sr.color);
|
|
317
|
+
if (pos !== undefined && sc) stops.push({ position: pos, color: sc });
|
|
318
|
+
}
|
|
319
|
+
if (stops.length) entry.gradientStops = stops;
|
|
320
|
+
}
|
|
321
|
+
out.push(entry);
|
|
322
|
+
}
|
|
323
|
+
return out.length ? out : undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function effects(v: unknown): FigmaEffect[] | undefined {
|
|
327
|
+
if (!Array.isArray(v)) return undefined;
|
|
328
|
+
const out: FigmaEffect[] = [];
|
|
329
|
+
for (const item of v.slice(0, 32)) {
|
|
330
|
+
if (!item || typeof item !== 'object') continue;
|
|
331
|
+
const e = item as Record<string, unknown>;
|
|
332
|
+
const type = str(e.type);
|
|
333
|
+
if (!type) continue;
|
|
334
|
+
const entry: FigmaEffect = { type, visible: bool(e.visible, true) };
|
|
335
|
+
const col = color(e.color);
|
|
336
|
+
if (col) entry.color = col;
|
|
337
|
+
const off = e.offset as Record<string, unknown> | undefined;
|
|
338
|
+
if (off && typeof off === 'object') {
|
|
339
|
+
const ox = num(off.x);
|
|
340
|
+
const oy = num(off.y);
|
|
341
|
+
if (ox !== undefined && oy !== undefined) entry.offset = { x: ox, y: oy };
|
|
342
|
+
}
|
|
343
|
+
const radius = num(e.radius);
|
|
344
|
+
if (radius !== undefined) entry.radius = radius;
|
|
345
|
+
const spread = num(e.spread);
|
|
346
|
+
if (spread !== undefined) entry.spread = spread;
|
|
347
|
+
out.push(entry);
|
|
348
|
+
}
|
|
349
|
+
return out.length ? out : undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function typeStyle(v: unknown): FigmaTypeStyle | undefined {
|
|
353
|
+
if (!v || typeof v !== 'object') return undefined;
|
|
354
|
+
const s = v as Record<string, unknown>;
|
|
355
|
+
const out: FigmaTypeStyle = {};
|
|
356
|
+
const assignStr = (k: keyof FigmaTypeStyle, raw: unknown) => {
|
|
357
|
+
const val = str(raw);
|
|
358
|
+
if (val !== undefined) (out as Record<string, unknown>)[k] = val;
|
|
359
|
+
};
|
|
360
|
+
const assignNum = (k: keyof FigmaTypeStyle, raw: unknown) => {
|
|
361
|
+
const val = num(raw);
|
|
362
|
+
if (val !== undefined) (out as Record<string, unknown>)[k] = val;
|
|
363
|
+
};
|
|
364
|
+
assignStr('fontFamily', s.fontFamily);
|
|
365
|
+
assignStr('fontPostScriptName', s.fontPostScriptName);
|
|
366
|
+
assignNum('fontWeight', s.fontWeight);
|
|
367
|
+
assignNum('fontSize', s.fontSize);
|
|
368
|
+
assignNum('lineHeightPx', s.lineHeightPx);
|
|
369
|
+
assignNum('letterSpacing', s.letterSpacing);
|
|
370
|
+
assignStr('textAlignHorizontal', s.textAlignHorizontal);
|
|
371
|
+
assignStr('textAlignVertical', s.textAlignVertical);
|
|
372
|
+
assignStr('textCase', s.textCase);
|
|
373
|
+
assignStr('textDecoration', s.textDecoration);
|
|
374
|
+
return Object.keys(out).length ? out : undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function nodeType(v: unknown): FigmaNodeType {
|
|
378
|
+
const t = str(v);
|
|
379
|
+
return t && KNOWN_NODE_TYPES.has(t) ? (t as FigmaNodeType) : 'UNKNOWN';
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
interface WalkState {
|
|
383
|
+
count: number;
|
|
384
|
+
maxDepth: number;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Recursively normalize one raw API node.
|
|
389
|
+
*
|
|
390
|
+
* Depth is checked BEFORE descent and the node count BEFORE each node is
|
|
391
|
+
* built, so a document engineered to be deep or wide trips a cap rather than
|
|
392
|
+
* exhausting the stack or the heap. `__proto__`/`constructor`/`prototype` keys
|
|
393
|
+
* are never read off the input — every field is pulled by explicit name, which
|
|
394
|
+
* is a stronger version of the DDR-172 Decision 3 guard: there is no generic
|
|
395
|
+
* key-copy loop here to pollute.
|
|
396
|
+
*/
|
|
397
|
+
function normalizeNode(raw: unknown, depth: number, state: WalkState): FigmaNode | null {
|
|
398
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
399
|
+
if (depth > MAX_TREE_DEPTH) {
|
|
400
|
+
throw new FigmaCapError(
|
|
401
|
+
'depth',
|
|
402
|
+
`Figma document nests deeper than ${MAX_TREE_DEPTH} levels — import a specific frame instead`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
const r = raw as Record<string, unknown>;
|
|
406
|
+
|
|
407
|
+
const id = str(r.id);
|
|
408
|
+
if (!id) return null; // a node with no id cannot be referenced, stamped, or bound
|
|
409
|
+
|
|
410
|
+
state.count += 1;
|
|
411
|
+
if (state.count > MAX_NODE_COUNT) {
|
|
412
|
+
throw new FigmaCapError(
|
|
413
|
+
'nodes',
|
|
414
|
+
`Figma document has more than ${MAX_NODE_COUNT} nodes — import a specific frame instead`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (depth > state.maxDepth) state.maxDepth = depth;
|
|
418
|
+
|
|
419
|
+
const node: FigmaNode = {
|
|
420
|
+
id,
|
|
421
|
+
type: nodeType(r.type),
|
|
422
|
+
// UNTRUSTED, carried verbatim — see the file-level invariant.
|
|
423
|
+
name: str(r.name) ?? '',
|
|
424
|
+
visible: bool(r.visible, true),
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const assign = <K extends keyof FigmaNode>(key: K, value: FigmaNode[K] | undefined) => {
|
|
428
|
+
if (value !== undefined) node[key] = value;
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
assign('absoluteBoundingBox', rect(r.absoluteBoundingBox));
|
|
432
|
+
assign('absoluteRenderBounds', rect(r.absoluteRenderBounds));
|
|
433
|
+
assign('rotation', num(r.rotation));
|
|
434
|
+
assign('opacity', num(r.opacity));
|
|
435
|
+
assign('blendMode', str(r.blendMode));
|
|
436
|
+
assign('clipsContent', typeof r.clipsContent === 'boolean' ? r.clipsContent : undefined);
|
|
437
|
+
|
|
438
|
+
const layoutMode = str(r.layoutMode);
|
|
439
|
+
if (layoutMode === 'HORIZONTAL' || layoutMode === 'VERTICAL' || layoutMode === 'NONE') {
|
|
440
|
+
node.layoutMode = layoutMode;
|
|
441
|
+
}
|
|
442
|
+
assign('itemSpacing', num(r.itemSpacing));
|
|
443
|
+
assign('paddingLeft', num(r.paddingLeft));
|
|
444
|
+
assign('paddingRight', num(r.paddingRight));
|
|
445
|
+
assign('paddingTop', num(r.paddingTop));
|
|
446
|
+
assign('paddingBottom', num(r.paddingBottom));
|
|
447
|
+
assign('primaryAxisAlignItems', str(r.primaryAxisAlignItems));
|
|
448
|
+
assign('counterAxisAlignItems', str(r.counterAxisAlignItems));
|
|
449
|
+
assign('layoutWrap', str(r.layoutWrap));
|
|
450
|
+
|
|
451
|
+
assign('fills', paints(r.fills));
|
|
452
|
+
assign('strokes', paints(r.strokes));
|
|
453
|
+
assign('strokeWeight', num(r.strokeWeight));
|
|
454
|
+
assign('effects', effects(r.effects));
|
|
455
|
+
assign('cornerRadius', num(r.cornerRadius));
|
|
456
|
+
if (Array.isArray(r.rectangleCornerRadii)) {
|
|
457
|
+
const radii = r.rectangleCornerRadii
|
|
458
|
+
.slice(0, 4)
|
|
459
|
+
.map((n) => num(n))
|
|
460
|
+
.filter((n): n is number => n !== undefined);
|
|
461
|
+
if (radii.length) node.rectangleCornerRadii = radii;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// UNTRUSTED, carried verbatim.
|
|
465
|
+
assign('characters', str(r.characters));
|
|
466
|
+
assign('style', typeStyle(r.style));
|
|
467
|
+
|
|
468
|
+
assign('shapeType', str(r.shapeType));
|
|
469
|
+
assign('connectorStart', connectorEndpointId(r.connectorStart));
|
|
470
|
+
assign('connectorEnd', connectorEndpointId(r.connectorEnd));
|
|
471
|
+
assign('connectorStartCap', str(r.connectorStartCap));
|
|
472
|
+
assign('connectorEndCap', str(r.connectorEndCap));
|
|
473
|
+
assign('connectorLineType', str(r.connectorLineType));
|
|
474
|
+
assign('componentId', str(r.componentId));
|
|
475
|
+
|
|
476
|
+
if (Array.isArray(r.children)) {
|
|
477
|
+
const kids: FigmaNode[] = [];
|
|
478
|
+
for (const child of r.children) {
|
|
479
|
+
const normalized = normalizeNode(child, depth + 1, state);
|
|
480
|
+
if (normalized) kids.push(normalized);
|
|
481
|
+
}
|
|
482
|
+
if (kids.length) node.children = kids;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return node;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* A connector endpoint is `{ endpointNodeId, magnet }` (bound) or
|
|
490
|
+
* `{ position }` (free-floating). Only the bound form carries a host id — and
|
|
491
|
+
* a free endpoint must NOT be mistaken for one, or T5 mints a bind to nothing.
|
|
492
|
+
*/
|
|
493
|
+
function connectorEndpointId(v: unknown): string | undefined {
|
|
494
|
+
if (!v || typeof v !== 'object') return undefined;
|
|
495
|
+
const e = v as Record<string, unknown>;
|
|
496
|
+
return str(e.endpointNodeId);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Normalize a raw `GET /v1/files/:key` (or `/nodes`) document into the shape
|
|
501
|
+
* both doors share. Throws `FigmaCapError` on a hard refusal — callers turn
|
|
502
|
+
* that into the user-facing "import a specific frame instead" message.
|
|
503
|
+
*/
|
|
504
|
+
export function normalizeDocument(
|
|
505
|
+
raw: unknown,
|
|
506
|
+
meta: { fileKey: string; surface: 'design' | 'board'; origin?: 'rest' | 'fig' }
|
|
507
|
+
): NormalizedDocument {
|
|
508
|
+
const state: WalkState = { count: 0, maxDepth: 0 };
|
|
509
|
+
const root = normalizeNode(raw, 0, state);
|
|
510
|
+
if (!root) throw new FigmaCapError('nodes', 'Figma document has no readable root node');
|
|
511
|
+
return {
|
|
512
|
+
fileKey: meta.fileKey,
|
|
513
|
+
surface: meta.surface,
|
|
514
|
+
origin: meta.origin ?? 'rest',
|
|
515
|
+
root,
|
|
516
|
+
nodeCount: state.count,
|
|
517
|
+
maxDepth: state.maxDepth,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Depth-first walk over a normalized tree. Iterative — no stack growth. */
|
|
522
|
+
export function walkNodes(root: FigmaNode, visit: (node: FigmaNode, depth: number) => void): void {
|
|
523
|
+
const stack: Array<{ node: FigmaNode; depth: number }> = [{ node: root, depth: 0 }];
|
|
524
|
+
while (stack.length > 0) {
|
|
525
|
+
const entry = stack.pop();
|
|
526
|
+
if (!entry) break;
|
|
527
|
+
visit(entry.node, entry.depth);
|
|
528
|
+
const kids = entry.node.children;
|
|
529
|
+
if (!kids) continue;
|
|
530
|
+
for (let i = kids.length - 1; i >= 0; i--) {
|
|
531
|
+
stack.push({ node: kids[i], depth: entry.depth + 1 });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Guard used by the normalizer's own tests and by the `.fig` door later. */
|
|
537
|
+
export function isPollutingKey(key: string): boolean {
|
|
538
|
+
return POLLUTING_KEYS.has(key);
|
|
539
|
+
}
|