@agent-native/core 0.37.1 → 0.37.2
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/dist/brand-kit/fig/decode.d.ts +33 -0
- package/dist/brand-kit/fig/decode.d.ts.map +1 -0
- package/dist/brand-kit/fig/decode.js +358 -0
- package/dist/brand-kit/fig/decode.js.map +1 -0
- package/dist/brand-kit/fig/extract-design-system.d.ts +44 -0
- package/dist/brand-kit/fig/extract-design-system.d.ts.map +1 -0
- package/dist/brand-kit/fig/extract-design-system.js +752 -0
- package/dist/brand-kit/fig/extract-design-system.js.map +1 -0
- package/dist/brand-kit/fig/fig-to-html.d.ts +246 -0
- package/dist/brand-kit/fig/fig-to-html.d.ts.map +1 -0
- package/dist/brand-kit/fig/fig-to-html.js +1506 -0
- package/dist/brand-kit/fig/fig-to-html.js.map +1 -0
- package/dist/brand-kit/fig/index.d.ts +30 -0
- package/dist/brand-kit/fig/index.d.ts.map +1 -0
- package/dist/brand-kit/fig/index.js +43 -0
- package/dist/brand-kit/fig/index.js.map +1 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +303 -69
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +6 -104
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/context-xray/ContextMeter.js +1 -1
- package/dist/client/context-xray/ContextMeter.js.map +1 -1
- package/dist/client/context-xray/ContextSegmentRow.d.ts.map +1 -1
- package/dist/client/context-xray/ContextSegmentRow.js +4 -4
- package/dist/client/context-xray/ContextSegmentRow.js.map +1 -1
- package/dist/client/context-xray/ContextTreemap.d.ts.map +1 -1
- package/dist/client/context-xray/ContextTreemap.js +2 -2
- package/dist/client/context-xray/ContextTreemap.js.map +1 -1
- package/dist/client/context-xray/ContextXRayPanel.d.ts.map +1 -1
- package/dist/client/context-xray/ContextXRayPanel.js +19 -18
- package/dist/client/context-xray/ContextXRayPanel.js.map +1 -1
- package/dist/client/sharing/ShareButton.d.ts +4 -0
- package/dist/client/sharing/ShareButton.d.ts.map +1 -1
- package/dist/client/sharing/ShareButton.js +6 -4
- package/dist/client/sharing/ShareButton.js.map +1 -1
- package/package.json +6 -1
|
@@ -0,0 +1,1506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a decoded Figma document (NODE_CHANGES message) into one HTML
|
|
3
|
+
* file per top-level frame. Each node renders as a <div> (or <span> for
|
|
4
|
+
* TEXT) with an inline `style="..."` covering layout, background, border,
|
|
5
|
+
* radius, shadows, blurs, text, transform, autolayout (flexbox), and
|
|
6
|
+
* opacity/blend. Component instances are inlined and tagged with
|
|
7
|
+
* data-component-name / data-variant-name / data-component-description /
|
|
8
|
+
* data-component-doc-link / data-annotations attributes, matching the
|
|
9
|
+
* figma-plugin smart-export conventions.
|
|
10
|
+
*/
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
/**
|
|
13
|
+
* Split a Figma component master name into a base component name and a
|
|
14
|
+
* variant suffix. Figma uses two conventions:
|
|
15
|
+
*
|
|
16
|
+
* - Slash-separated: "ComponentName/VariantA/VariantB" — everything after
|
|
17
|
+
* the first slash is the variant name (this matches the plugin's
|
|
18
|
+
* smart-export behavior).
|
|
19
|
+
* - Variant-set children: a SYMBOL named like "Style=Action, Size=Large"
|
|
20
|
+
* is one variant inside a parent component-set FRAME. In that case the
|
|
21
|
+
* SYMBOL name IS the variant key/value list and the real component
|
|
22
|
+
* name is the parent FRAME's name (resolved separately by the caller).
|
|
23
|
+
*/
|
|
24
|
+
function isVariantSymbolName(name) {
|
|
25
|
+
if (!name)
|
|
26
|
+
return false;
|
|
27
|
+
// Variant SYMBOL naming: comma-separated `Key=Value` pairs.
|
|
28
|
+
return /^[^/=]+=[^=]+(,\s*[^/=]+=[^=]+)*$/.test(name);
|
|
29
|
+
}
|
|
30
|
+
function splitComponentName(name) {
|
|
31
|
+
if (!name)
|
|
32
|
+
return { base: "", variant: null };
|
|
33
|
+
if (isVariantSymbolName(name))
|
|
34
|
+
return { base: "", variant: name };
|
|
35
|
+
const i = name.indexOf("/");
|
|
36
|
+
if (i < 0)
|
|
37
|
+
return { base: name, variant: null };
|
|
38
|
+
return { base: name.slice(0, i), variant: name.slice(i + 1) };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the canonical component name + variant string for a SYMBOL,
|
|
42
|
+
* walking up to the parent component-set FRAME when the SYMBOL itself is
|
|
43
|
+
* a variant child (e.g. "Style=Action" inside a "Right Element" FRAME).
|
|
44
|
+
*/
|
|
45
|
+
function resolveComponentIdentity(symbol, ctx) {
|
|
46
|
+
const ident = splitComponentName(symbol.name);
|
|
47
|
+
if (ident.base)
|
|
48
|
+
return ident;
|
|
49
|
+
// Variant SYMBOL — use the parent FRAME (component set) as the base name.
|
|
50
|
+
const parentKey = guidKey(symbol.parentIndex?.guid);
|
|
51
|
+
const parent = ctx.byGuid.get(parentKey);
|
|
52
|
+
const parentName = parent?.name?.trim();
|
|
53
|
+
if (parentName) {
|
|
54
|
+
return { base: parentName, variant: ident.variant };
|
|
55
|
+
}
|
|
56
|
+
return { base: symbol.name ?? "", variant: null };
|
|
57
|
+
}
|
|
58
|
+
function htmlToPlain(html) {
|
|
59
|
+
if (!html)
|
|
60
|
+
return "";
|
|
61
|
+
return html
|
|
62
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
63
|
+
.replace(/<\/p>\s*<p[^>]*>/gi, "\n\n")
|
|
64
|
+
.replace(/<[^>]+>/g, "")
|
|
65
|
+
.replace(/ /g, " ")
|
|
66
|
+
.replace(/&/g, "&")
|
|
67
|
+
.replace(/</g, "<")
|
|
68
|
+
.replace(/>/g, ">")
|
|
69
|
+
.replace(/"/g, '"')
|
|
70
|
+
.replace(/'/g, "'")
|
|
71
|
+
.trim();
|
|
72
|
+
}
|
|
73
|
+
function extractDocLinks(html) {
|
|
74
|
+
if (!html)
|
|
75
|
+
return [];
|
|
76
|
+
const out = [];
|
|
77
|
+
const re = /href="([^"]+)"/gi;
|
|
78
|
+
let m;
|
|
79
|
+
while ((m = re.exec(html)) !== null)
|
|
80
|
+
out.push(m[1]);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
export function guidKey(g) {
|
|
84
|
+
return g ? `${g.sessionID}:${g.localID}` : "";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Collect the raw Figma component-prop data on a node into a single object
|
|
88
|
+
* suitable for stringifying into a `props="..."` attribute.
|
|
89
|
+
*
|
|
90
|
+
* - SYMBOL nodes get their `componentPropDefs` (the prop schema)
|
|
91
|
+
* - INSTANCE nodes get the `componentPropAssignments` (overrides) plus any
|
|
92
|
+
* `componentPropRefs` (per-child wirings) and the master's defs for
|
|
93
|
+
* context.
|
|
94
|
+
* - Any node may have its own `componentPropRefs` (e.g. an inner layer
|
|
95
|
+
* bound to a parent component prop).
|
|
96
|
+
*/
|
|
97
|
+
function collectRawProps(node, componentSymbol) {
|
|
98
|
+
const out = {};
|
|
99
|
+
const sym = componentSymbol ?? (node.type === "SYMBOL" ? node : null);
|
|
100
|
+
if (sym?.componentPropDefs?.length)
|
|
101
|
+
out.defs = sym.componentPropDefs;
|
|
102
|
+
if (node.componentPropAssignments &&
|
|
103
|
+
node.componentPropAssignments.length)
|
|
104
|
+
out.assignments = node.componentPropAssignments;
|
|
105
|
+
if (node.componentPropRefs && node.componentPropRefs.length)
|
|
106
|
+
out.refs = node.componentPropRefs;
|
|
107
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
108
|
+
}
|
|
109
|
+
function resolvePropAssignment(a) {
|
|
110
|
+
const ax = a;
|
|
111
|
+
const vv = ax.varValue?.value;
|
|
112
|
+
const v = ax.value;
|
|
113
|
+
const out = {};
|
|
114
|
+
if (typeof vv?.boolValue === "boolean")
|
|
115
|
+
out.bool = vv.boolValue;
|
|
116
|
+
else if (typeof v?.boolValue === "boolean")
|
|
117
|
+
out.bool = v.boolValue;
|
|
118
|
+
if (vv?.textValue?.characters !== undefined)
|
|
119
|
+
out.text = vv.textValue.characters;
|
|
120
|
+
else if (v?.textValue?.characters !== undefined)
|
|
121
|
+
out.text = v.textValue.characters;
|
|
122
|
+
else if (vv?.textIdValue?.value !== undefined)
|
|
123
|
+
out.text = vv.textIdValue.value;
|
|
124
|
+
if (vv?.symbolIdValue?.guid)
|
|
125
|
+
out.guid = vv.symbolIdValue.guid;
|
|
126
|
+
else if (vv?.guidValue)
|
|
127
|
+
out.guid = vv.guidValue;
|
|
128
|
+
else if (v?.guidValue)
|
|
129
|
+
out.guid = v.guidValue;
|
|
130
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
131
|
+
}
|
|
132
|
+
function buildPropEnv(node, inherited) {
|
|
133
|
+
const assignments = (node.componentPropAssignments ?? []);
|
|
134
|
+
if (assignments.length === 0)
|
|
135
|
+
return inherited;
|
|
136
|
+
const next = new Map(inherited);
|
|
137
|
+
for (const a of assignments) {
|
|
138
|
+
const key = guidKey(a.defID);
|
|
139
|
+
if (!key)
|
|
140
|
+
continue;
|
|
141
|
+
const resolved = resolvePropAssignment(a);
|
|
142
|
+
if (resolved)
|
|
143
|
+
next.set(key, resolved);
|
|
144
|
+
}
|
|
145
|
+
return next;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Apply parent-instance prop overrides to a node. Returns either the same
|
|
149
|
+
* node (no overrides), a shallow-cloned node with `textData` / `symbolData`
|
|
150
|
+
* patched, or `null` to indicate the node should be hidden by a VISIBLE
|
|
151
|
+
* prop ref resolving to false.
|
|
152
|
+
*/
|
|
153
|
+
function applyPropRefs(node, env) {
|
|
154
|
+
const refs = (node.componentPropRefs ?? []);
|
|
155
|
+
if (refs.length === 0 || env.size === 0)
|
|
156
|
+
return node;
|
|
157
|
+
let patched = node;
|
|
158
|
+
for (const ref of refs) {
|
|
159
|
+
const v = env.get(guidKey(ref.defID));
|
|
160
|
+
if (!v)
|
|
161
|
+
continue;
|
|
162
|
+
const field = ref.componentPropNodeField;
|
|
163
|
+
if (field === "VISIBLE" && v.bool === false)
|
|
164
|
+
return null;
|
|
165
|
+
if (field === "TEXT_DATA" && v.text !== undefined) {
|
|
166
|
+
patched = {
|
|
167
|
+
...patched,
|
|
168
|
+
textData: { ...(patched.textData ?? {}), characters: v.text },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (field === "OVERRIDDEN_SYMBOL_ID" && v.guid) {
|
|
172
|
+
patched = {
|
|
173
|
+
...patched,
|
|
174
|
+
symbolData: { ...(patched.symbolData ?? {}), symbolID: v.guid },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return patched;
|
|
179
|
+
}
|
|
180
|
+
function buildSymbolOverrideLayer(node) {
|
|
181
|
+
const out = new Map();
|
|
182
|
+
for (const o of node.symbolData?.symbolOverrides ?? []) {
|
|
183
|
+
const guids = o.guidPath?.guids ?? [];
|
|
184
|
+
if (guids.length === 0)
|
|
185
|
+
continue;
|
|
186
|
+
const key = guids.map((g) => guidKey(g)).join("/");
|
|
187
|
+
// Multiple override entries can share the same guidPath (e.g. one with
|
|
188
|
+
// `overriddenSymbolID` for a variant swap and another with layout
|
|
189
|
+
// tweaks). Merge them so neither is lost.
|
|
190
|
+
const existing = out.get(key);
|
|
191
|
+
if (existing) {
|
|
192
|
+
out.set(key, { ...existing, ...o });
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
out.set(key, o);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// `derivedSymbolData` carries pre-computed geometry / text layout for
|
|
199
|
+
// descendants of this instance whose actual definition lives in a remote
|
|
200
|
+
// library (so the local document has only a stub master). Each entry is
|
|
201
|
+
// keyed by a guidPath into the library tree — same coordinate space as
|
|
202
|
+
// `symbolOverrides[].guidPath` — so we can fold them into the same layer.
|
|
203
|
+
// Only fill/stroke geometry are merged; positional fields (`transform`,
|
|
204
|
+
// `size`) and `derivedTextData` are intentionally skipped because they
|
|
205
|
+
// describe library-resolved layout that would overwrite the (already
|
|
206
|
+
// correct) values cached on the local master node.
|
|
207
|
+
for (const d of node.derivedSymbolData ?? []) {
|
|
208
|
+
const guids = d.guidPath?.guids ?? [];
|
|
209
|
+
if (guids.length === 0)
|
|
210
|
+
continue;
|
|
211
|
+
if (!d.fillGeometry?.length && !d.strokeGeometry?.length)
|
|
212
|
+
continue;
|
|
213
|
+
const key = guids.map((g) => guidKey(g)).join("/");
|
|
214
|
+
const patch = {};
|
|
215
|
+
if (d.fillGeometry?.length)
|
|
216
|
+
patch.fillGeometry = d.fillGeometry;
|
|
217
|
+
if (d.strokeGeometry?.length)
|
|
218
|
+
patch.strokeGeometry = d.strokeGeometry;
|
|
219
|
+
const existing = out.get(key);
|
|
220
|
+
out.set(key, existing ? { ...existing, ...patch } : patch);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Apply any matching override entry from the active override layers to a
|
|
226
|
+
* node about to be emitted. Returns `null` if the node is hidden by an
|
|
227
|
+
* override; otherwise returns the (possibly patched) node.
|
|
228
|
+
*/
|
|
229
|
+
function applyOverrideLayers(node, layers, instancePath) {
|
|
230
|
+
if (layers.length === 0)
|
|
231
|
+
return node;
|
|
232
|
+
// The lookup key for THIS node within a layer is the chain of inner
|
|
233
|
+
// INSTANCE overrideKeys we've descended into since that layer was pushed,
|
|
234
|
+
// followed by this node's own overrideKey. Override paths only grow at
|
|
235
|
+
// INSTANCE boundaries — descending through plain frames/groups within the
|
|
236
|
+
// same master keeps the path the same length.
|
|
237
|
+
const nodeKey = guidKey(node.overrideKey ?? node.guid);
|
|
238
|
+
if (!nodeKey)
|
|
239
|
+
return node;
|
|
240
|
+
for (const layer of layers) {
|
|
241
|
+
const prefix = instancePath.slice(layer.startIndex);
|
|
242
|
+
const relKey = prefix.length > 0 ? `${prefix.join("/")}/${nodeKey}` : nodeKey;
|
|
243
|
+
const entry = layer.map.get(relKey);
|
|
244
|
+
if (!entry)
|
|
245
|
+
continue;
|
|
246
|
+
if (entry.visible === false)
|
|
247
|
+
return null;
|
|
248
|
+
// Shallow-merge every field present on the override (except the
|
|
249
|
+
// routing fields and `overriddenSymbolID`, which goes into symbolData).
|
|
250
|
+
// This applies layout overrides like `size`, `textAutoResize`,
|
|
251
|
+
// `stackChildAlignSelf`, `stackCounterSizing`, `textAlignVertical`,
|
|
252
|
+
// styling fields, etc., in addition to text/visibility.
|
|
253
|
+
const merged = { ...node };
|
|
254
|
+
for (const [field, value] of Object.entries(entry)) {
|
|
255
|
+
if (field === "guidPath" || field === "overriddenSymbolID")
|
|
256
|
+
continue;
|
|
257
|
+
if (value === undefined)
|
|
258
|
+
continue;
|
|
259
|
+
merged[field] = value;
|
|
260
|
+
}
|
|
261
|
+
if (entry.overriddenSymbolID) {
|
|
262
|
+
merged.symbolData = {
|
|
263
|
+
...(merged.symbolData ?? {}),
|
|
264
|
+
symbolID: entry.overriddenSymbolID,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
node = merged;
|
|
268
|
+
}
|
|
269
|
+
return node;
|
|
270
|
+
}
|
|
271
|
+
function sanitizeFilename(name, fallback) {
|
|
272
|
+
if (!name)
|
|
273
|
+
return fallback;
|
|
274
|
+
const cleaned = name
|
|
275
|
+
.replace(/[\\/:*?"<>|]/g, "-")
|
|
276
|
+
.replace(/\s+/g, " ")
|
|
277
|
+
.trim()
|
|
278
|
+
.slice(0, 80);
|
|
279
|
+
return cleaned || fallback;
|
|
280
|
+
}
|
|
281
|
+
function escapeHtmlText(s) {
|
|
282
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
283
|
+
}
|
|
284
|
+
function escapeHtmlAttr(s) {
|
|
285
|
+
return s
|
|
286
|
+
.replace(/&/g, "&")
|
|
287
|
+
.replace(/"/g, """)
|
|
288
|
+
.replace(/</g, "<")
|
|
289
|
+
.replace(/\n/g, " ");
|
|
290
|
+
}
|
|
291
|
+
function kebabCase(prop) {
|
|
292
|
+
return prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
293
|
+
}
|
|
294
|
+
function colorToCss(c, alphaMul = 1) {
|
|
295
|
+
if (!c)
|
|
296
|
+
return null;
|
|
297
|
+
const r = Math.round(c.r * 255);
|
|
298
|
+
const g = Math.round(c.g * 255);
|
|
299
|
+
const b = Math.round(c.b * 255);
|
|
300
|
+
const a = c.a * alphaMul;
|
|
301
|
+
if (a >= 0.999)
|
|
302
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
303
|
+
return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`;
|
|
304
|
+
}
|
|
305
|
+
function num(n) {
|
|
306
|
+
if (typeof n !== "number" || !Number.isFinite(n))
|
|
307
|
+
return null;
|
|
308
|
+
return Math.round(n * 100) / 100;
|
|
309
|
+
}
|
|
310
|
+
function tagFor(type) {
|
|
311
|
+
// Everything renders as a real DOM tag rather than a synthetic component.
|
|
312
|
+
// TEXT becomes <span> so it inlines nicely; everything else is <div>.
|
|
313
|
+
if (type === "TEXT")
|
|
314
|
+
return "span";
|
|
315
|
+
return "div";
|
|
316
|
+
}
|
|
317
|
+
const STACK_ALIGN = {
|
|
318
|
+
MIN: "flex-start",
|
|
319
|
+
CENTER: "center",
|
|
320
|
+
MAX: "flex-end",
|
|
321
|
+
BASELINE: "baseline",
|
|
322
|
+
SPACE_BETWEEN: "space-between",
|
|
323
|
+
};
|
|
324
|
+
const TEXT_ALIGN = {
|
|
325
|
+
LEFT: "left",
|
|
326
|
+
CENTER: "center",
|
|
327
|
+
RIGHT: "right",
|
|
328
|
+
JUSTIFIED: "justify",
|
|
329
|
+
};
|
|
330
|
+
function fontWeightFromStyle(style) {
|
|
331
|
+
if (!style)
|
|
332
|
+
return null;
|
|
333
|
+
const s = style.toLowerCase();
|
|
334
|
+
if (s.includes("thin"))
|
|
335
|
+
return 100;
|
|
336
|
+
if (s.includes("extralight") || s.includes("ultralight"))
|
|
337
|
+
return 200;
|
|
338
|
+
if (s.includes("light"))
|
|
339
|
+
return 300;
|
|
340
|
+
if (s.includes("regular") || s === "normal")
|
|
341
|
+
return 400;
|
|
342
|
+
if (s.includes("medium"))
|
|
343
|
+
return 500;
|
|
344
|
+
if (s.includes("semibold") || s.includes("demibold"))
|
|
345
|
+
return 600;
|
|
346
|
+
if (s.includes("extrabold") || s.includes("ultrabold"))
|
|
347
|
+
return 800;
|
|
348
|
+
if (s.includes("black") || s.includes("heavy"))
|
|
349
|
+
return 900;
|
|
350
|
+
if (s.includes("bold"))
|
|
351
|
+
return 700;
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
function lengthFromUnits(v, fontSize) {
|
|
355
|
+
if (!v)
|
|
356
|
+
return null;
|
|
357
|
+
if (v.units === "PIXELS")
|
|
358
|
+
return `${num(v.value)}px`;
|
|
359
|
+
if (v.units === "PERCENT") {
|
|
360
|
+
if (fontSize)
|
|
361
|
+
return `${num((v.value / 100) * fontSize)}px`;
|
|
362
|
+
return `${num(v.value)}%`;
|
|
363
|
+
}
|
|
364
|
+
if (v.units === "RAW")
|
|
365
|
+
return num(v.value);
|
|
366
|
+
return num(v.value);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Normalize a Figma image hash into a hex string. The kiwi decoder emits
|
|
370
|
+
* the hash as a Uint8Array / number[]; the JSON-roundtripped form is
|
|
371
|
+
* already a hex string.
|
|
372
|
+
*/
|
|
373
|
+
function hashToHex(h) {
|
|
374
|
+
if (!h)
|
|
375
|
+
return null;
|
|
376
|
+
if (typeof h === "string")
|
|
377
|
+
return h;
|
|
378
|
+
const arr = h instanceof Uint8Array ? Array.from(h) : h;
|
|
379
|
+
return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Resolve an image hash to a usable URL path. Looks up the actual filename
|
|
383
|
+
* (which may be `<hash>` or `<hash>.png` depending on whether the source
|
|
384
|
+
* was a zip-format or kiwi-format `.fig`) in the ctx imageMap.
|
|
385
|
+
*/
|
|
386
|
+
function imageUrl(hashHex, ctx) {
|
|
387
|
+
const filename = ctx.imageMap.get(hashHex) ?? hashHex;
|
|
388
|
+
const base = ctx.imageRefBase ?? "images";
|
|
389
|
+
return `${base}/${filename}`;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Resolve a style reference (`styleIdForFill` / `styleIdForStroke` /
|
|
393
|
+
* `styleIdForText`) to the actual style node. Style refs come in two
|
|
394
|
+
* flavors: a local `guid` for in-document styles and an `assetRef.key`
|
|
395
|
+
* for library styles. Library style definitions get embedded in the
|
|
396
|
+
* document under the same `key`, so we look them up via `ctx.byKey`.
|
|
397
|
+
*/
|
|
398
|
+
function resolveStyleNode(ref, ctx) {
|
|
399
|
+
if (!ref)
|
|
400
|
+
return undefined;
|
|
401
|
+
if (ref.guid) {
|
|
402
|
+
const n = ctx.byGuid.get(guidKey(ref.guid));
|
|
403
|
+
if (n)
|
|
404
|
+
return n;
|
|
405
|
+
}
|
|
406
|
+
if (ref.assetRef?.key) {
|
|
407
|
+
const n = ctx.byKey.get(ref.assetRef.key);
|
|
408
|
+
if (n)
|
|
409
|
+
return n;
|
|
410
|
+
}
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Resolve the effective fill paints for a node. When the node references a
|
|
415
|
+
* shared FILL style via `styleIdForFill`, the cached `fillPaints` baked
|
|
416
|
+
* into the node may be stale (the design token's actual color can have
|
|
417
|
+
* changed since). Prefer the style node's `fillPaints` whenever a fill
|
|
418
|
+
* style reference is present.
|
|
419
|
+
*
|
|
420
|
+
* Do NOT fall back to `styleIdForText` here: a text style carries
|
|
421
|
+
* typography (font/size/weight/line-height) and its `fillPaints` is just
|
|
422
|
+
* the swatch color used for the style's preview glyphs ("Ag") — typically
|
|
423
|
+
* black regardless of where the style is actually applied. The text node's
|
|
424
|
+
* own `fillPaints` is the source of truth for color.
|
|
425
|
+
*/
|
|
426
|
+
function effectiveFillPaints(node, ctx) {
|
|
427
|
+
const style = resolveStyleNode(node.styleIdForFill, ctx);
|
|
428
|
+
if (style?.fillPaints?.length)
|
|
429
|
+
return style.fillPaints;
|
|
430
|
+
return node.fillPaints;
|
|
431
|
+
}
|
|
432
|
+
function effectiveStrokePaints(node, ctx) {
|
|
433
|
+
const style = resolveStyleNode(node.styleIdForStroke, ctx);
|
|
434
|
+
if (style?.fillPaints?.length)
|
|
435
|
+
return style.fillPaints;
|
|
436
|
+
if (style?.strokePaints?.length)
|
|
437
|
+
return style.strokePaints;
|
|
438
|
+
return node.strokePaints;
|
|
439
|
+
}
|
|
440
|
+
function paintToBackground(p, _node, ctx) {
|
|
441
|
+
if (p.visible === false)
|
|
442
|
+
return null;
|
|
443
|
+
if (p.type === "SOLID")
|
|
444
|
+
return colorToCss(p.color, p.opacity ?? 1);
|
|
445
|
+
if (p.type?.startsWith("GRADIENT") && Array.isArray(p.stops)) {
|
|
446
|
+
const stops = p.stops
|
|
447
|
+
.map((s) => `${colorToCss(s.color, p.opacity ?? 1)} ${num(s.position * 100)}%`)
|
|
448
|
+
.join(", ");
|
|
449
|
+
if (p.type === "GRADIENT_LINEAR")
|
|
450
|
+
return `linear-gradient(${stops})`;
|
|
451
|
+
if (p.type === "GRADIENT_RADIAL")
|
|
452
|
+
return `radial-gradient(${stops})`;
|
|
453
|
+
if (p.type === "GRADIENT_ANGULAR")
|
|
454
|
+
return `conic-gradient(${stops})`;
|
|
455
|
+
if (p.type === "GRADIENT_DIAMOND")
|
|
456
|
+
return `radial-gradient(${stops})`;
|
|
457
|
+
}
|
|
458
|
+
if (p.type === "IMAGE") {
|
|
459
|
+
const hex = hashToHex(p.image?.hash);
|
|
460
|
+
if (hex)
|
|
461
|
+
return `url("${imageUrl(hex, ctx)}")`;
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
function backgroundShorthand(node, ctx) {
|
|
466
|
+
const fills = (effectiveFillPaints(node, ctx) ?? []).filter((f) => f.visible !== false);
|
|
467
|
+
if (fills.length === 0)
|
|
468
|
+
return {};
|
|
469
|
+
const result = {};
|
|
470
|
+
const bgImages = [];
|
|
471
|
+
for (const f of fills) {
|
|
472
|
+
if (f.type === "SOLID" && !result.backgroundColor) {
|
|
473
|
+
const c = colorToCss(f.color, f.opacity ?? 1);
|
|
474
|
+
if (c)
|
|
475
|
+
result.backgroundColor = c;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const v = paintToBackground(f, node, ctx);
|
|
479
|
+
if (v)
|
|
480
|
+
bgImages.push(v);
|
|
481
|
+
if (f.type === "IMAGE") {
|
|
482
|
+
if (f.imageScaleMode === "FILL")
|
|
483
|
+
result.backgroundSize = "cover";
|
|
484
|
+
else if (f.imageScaleMode === "FIT")
|
|
485
|
+
result.backgroundSize = "contain";
|
|
486
|
+
else if (f.imageScaleMode === "TILE") {
|
|
487
|
+
result.backgroundSize = "auto";
|
|
488
|
+
result.backgroundRepeat = "repeat";
|
|
489
|
+
}
|
|
490
|
+
else if (f.imageScaleMode === "STRETCH")
|
|
491
|
+
result.backgroundSize = "100% 100%";
|
|
492
|
+
// Figma centers image fills by default (CSS defaults to top-left, which
|
|
493
|
+
// crops the wrong edges for FILL/FIT). TILE keeps the default origin so
|
|
494
|
+
// the tile pattern starts at top-left.
|
|
495
|
+
if (f.imageScaleMode !== "TILE")
|
|
496
|
+
result.backgroundPosition = "center";
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (bgImages.length > 0)
|
|
500
|
+
result.backgroundImage = bgImages.join(", ");
|
|
501
|
+
return result;
|
|
502
|
+
}
|
|
503
|
+
function borderShorthand(node, ctx) {
|
|
504
|
+
const strokes = (effectiveStrokePaints(node, ctx) ?? []).filter((p) => p.visible !== false);
|
|
505
|
+
const w = node.strokeWeight ?? 0;
|
|
506
|
+
if (strokes.length === 0 || !w)
|
|
507
|
+
return {};
|
|
508
|
+
const first = strokes[0];
|
|
509
|
+
const color = colorToCss(first.color, first.opacity ?? 1) ?? "rgb(0, 0, 0)";
|
|
510
|
+
const decl = `${num(w)}px solid ${color}`;
|
|
511
|
+
if (node.strokeAlign === "OUTSIDE")
|
|
512
|
+
return { outline: decl };
|
|
513
|
+
return { border: decl };
|
|
514
|
+
}
|
|
515
|
+
function radiusStyles(node) {
|
|
516
|
+
const out = {};
|
|
517
|
+
const corners = [
|
|
518
|
+
node.rectangleTopLeftCornerRadius,
|
|
519
|
+
node.rectangleTopRightCornerRadius,
|
|
520
|
+
node.rectangleBottomRightCornerRadius,
|
|
521
|
+
node.rectangleBottomLeftCornerRadius,
|
|
522
|
+
];
|
|
523
|
+
const allEqual = corners.every((c) => c === corners[0]) &&
|
|
524
|
+
typeof corners[0] === "number" &&
|
|
525
|
+
corners[0] > 0;
|
|
526
|
+
if (allEqual) {
|
|
527
|
+
out.borderRadius = `${num(corners[0])}px`;
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
if (corners.some((c) => typeof c === "number" && c > 0)) {
|
|
531
|
+
out.borderTopLeftRadius = `${num(corners[0] ?? 0)}px`;
|
|
532
|
+
out.borderTopRightRadius = `${num(corners[1] ?? 0)}px`;
|
|
533
|
+
out.borderBottomRightRadius = `${num(corners[2] ?? 0)}px`;
|
|
534
|
+
out.borderBottomLeftRadius = `${num(corners[3] ?? 0)}px`;
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
|
|
538
|
+
out.borderRadius = `${num(node.cornerRadius)}px`;
|
|
539
|
+
}
|
|
540
|
+
if (node.type === "ELLIPSE")
|
|
541
|
+
out.borderRadius = "50%";
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
function effectStyles(node) {
|
|
545
|
+
const effects = node.effects?.filter((e) => e.visible !== false) ?? [];
|
|
546
|
+
if (effects.length === 0)
|
|
547
|
+
return {};
|
|
548
|
+
const shadows = [];
|
|
549
|
+
let blur = null;
|
|
550
|
+
let backdropBlur = null;
|
|
551
|
+
for (const e of effects) {
|
|
552
|
+
if (e.type === "DROP_SHADOW") {
|
|
553
|
+
const c = colorToCss(e.color) ?? "rgba(0, 0, 0, 0.25)";
|
|
554
|
+
shadows.push(`${num(e.offset?.x ?? 0)}px ${num(e.offset?.y ?? 0)}px ${num(e.radius ?? 0)}px ${num(e.spread ?? 0)}px ${c}`);
|
|
555
|
+
}
|
|
556
|
+
else if (e.type === "INNER_SHADOW") {
|
|
557
|
+
const c = colorToCss(e.color) ?? "rgba(0, 0, 0, 0.25)";
|
|
558
|
+
shadows.push(`inset ${num(e.offset?.x ?? 0)}px ${num(e.offset?.y ?? 0)}px ${num(e.radius ?? 0)}px ${num(e.spread ?? 0)}px ${c}`);
|
|
559
|
+
}
|
|
560
|
+
else if (e.type === "FOREGROUND_BLUR" || e.type === "LAYER_BLUR") {
|
|
561
|
+
blur = `blur(${num(e.radius ?? 0)}px)`;
|
|
562
|
+
}
|
|
563
|
+
else if (e.type === "BACKGROUND_BLUR") {
|
|
564
|
+
backdropBlur = `blur(${num(e.radius ?? 0)}px)`;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const out = {};
|
|
568
|
+
if (shadows.length)
|
|
569
|
+
out.boxShadow = shadows.join(", ");
|
|
570
|
+
if (blur)
|
|
571
|
+
out.filter = blur;
|
|
572
|
+
if (backdropBlur)
|
|
573
|
+
out.backdropFilter = backdropBlur;
|
|
574
|
+
return out;
|
|
575
|
+
}
|
|
576
|
+
function transformStyle(node) {
|
|
577
|
+
const t = node.transform;
|
|
578
|
+
if (!t)
|
|
579
|
+
return {};
|
|
580
|
+
// Decompose: rotation = atan2(m10, m00), scale assumed 1.
|
|
581
|
+
const angle = Math.atan2(t.m10, t.m00);
|
|
582
|
+
const deg = (angle * 180) / Math.PI;
|
|
583
|
+
if (Math.abs(deg) < 0.01)
|
|
584
|
+
return {};
|
|
585
|
+
return { transform: `rotate(${num(deg)}deg)`, transformOrigin: "top left" };
|
|
586
|
+
}
|
|
587
|
+
function autolayoutStyles(node) {
|
|
588
|
+
if (!node.stackMode || node.stackMode === "NONE")
|
|
589
|
+
return {};
|
|
590
|
+
const out = {
|
|
591
|
+
display: "flex",
|
|
592
|
+
flexDirection: node.stackMode === "VERTICAL" ? "column" : "row",
|
|
593
|
+
};
|
|
594
|
+
if (node.stackPrimaryAlignItems)
|
|
595
|
+
out.justifyContent =
|
|
596
|
+
STACK_ALIGN[node.stackPrimaryAlignItems] ?? "flex-start";
|
|
597
|
+
if (node.stackCounterAlignItems)
|
|
598
|
+
out.alignItems = STACK_ALIGN[node.stackCounterAlignItems] ?? "flex-start";
|
|
599
|
+
if (typeof node.stackSpacing === "number")
|
|
600
|
+
out.gap = `${num(node.stackSpacing)}px`;
|
|
601
|
+
// Padding: prefer per-side; fall back to horizontal/vertical.
|
|
602
|
+
const pl = node.stackPaddingLeft ?? node.stackHorizontalPadding;
|
|
603
|
+
const pr = node.stackPaddingRight ?? node.stackHorizontalPadding;
|
|
604
|
+
const pt = node.stackPaddingTop ?? node.stackVerticalPadding;
|
|
605
|
+
const pb = node.stackPaddingBottom ?? node.stackVerticalPadding;
|
|
606
|
+
if ([pl, pr, pt, pb].some((v) => typeof v === "number" && v !== 0)) {
|
|
607
|
+
out.padding = `${num(pt ?? 0)}px ${num(pr ?? 0)}px ${num(pb ?? 0)}px ${num(pl ?? 0)}px`;
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
function textStyles(node, ctx) {
|
|
612
|
+
if (node.type !== "TEXT")
|
|
613
|
+
return {};
|
|
614
|
+
const out = {};
|
|
615
|
+
// A TEXT node may reference a shared text style (`styleIdForText`) whose
|
|
616
|
+
// font properties (family, weight, size, line-height, letter-spacing,
|
|
617
|
+
// alignment) override the values cached on the node itself. The cached
|
|
618
|
+
// values are often stale snapshots of the master and don't reflect the
|
|
619
|
+
// current style — prefer the style node when present.
|
|
620
|
+
const styleNode = ctx
|
|
621
|
+
? resolveStyleNode(node.styleIdForText, ctx)
|
|
622
|
+
: undefined;
|
|
623
|
+
const fontName = styleNode?.fontName ?? node.fontName;
|
|
624
|
+
const fontSize = typeof styleNode?.fontSize === "number"
|
|
625
|
+
? styleNode.fontSize
|
|
626
|
+
: node.fontSize;
|
|
627
|
+
const lineHeight = styleNode?.lineHeight ?? node.lineHeight;
|
|
628
|
+
const letterSpacing = styleNode?.letterSpacing ?? node.letterSpacing;
|
|
629
|
+
const textAlignHorizontal = styleNode?.textAlignHorizontal ?? node.textAlignHorizontal;
|
|
630
|
+
if (fontName?.family) {
|
|
631
|
+
// Quote families with spaces so the inline style stays valid.
|
|
632
|
+
const fam = fontName.family;
|
|
633
|
+
out.fontFamily = /\s/.test(fam) ? `"${fam}"` : fam;
|
|
634
|
+
}
|
|
635
|
+
const weight = fontWeightFromStyle(fontName?.style);
|
|
636
|
+
if (weight !== null)
|
|
637
|
+
out.fontWeight = weight;
|
|
638
|
+
// Track this family/weight/italic combo so the frame template can request
|
|
639
|
+
// it from Google Fonts in <head>.
|
|
640
|
+
if (ctx && fontName?.family) {
|
|
641
|
+
const italic = !!(fontName.style && /italic|oblique/i.test(fontName.style));
|
|
642
|
+
ctx.fontUsage.add(`${fontName.family}|${weight ?? 400}|${italic ? 1 : 0}`);
|
|
643
|
+
}
|
|
644
|
+
if (fontName?.style && /italic|oblique/i.test(fontName.style)) {
|
|
645
|
+
out.fontStyle = "italic";
|
|
646
|
+
}
|
|
647
|
+
if (typeof fontSize === "number")
|
|
648
|
+
out.fontSize = `${num(fontSize)}px`;
|
|
649
|
+
const lh = lengthFromUnits(lineHeight, fontSize);
|
|
650
|
+
if (lh !== null && lh !== undefined)
|
|
651
|
+
out.lineHeight = lh;
|
|
652
|
+
const ls = lengthFromUnits(letterSpacing, fontSize);
|
|
653
|
+
if (ls !== null && ls !== undefined)
|
|
654
|
+
out.letterSpacing = ls;
|
|
655
|
+
if (textAlignHorizontal)
|
|
656
|
+
out.textAlign = TEXT_ALIGN[textAlignHorizontal] ?? "left";
|
|
657
|
+
// Text color comes from the first SOLID fill paint, dereferenced through
|
|
658
|
+
// any `styleIdForFill` shared style (NOT `styleIdForText`, which is
|
|
659
|
+
// typography-only — see `effectiveFillPaints`).
|
|
660
|
+
if (ctx) {
|
|
661
|
+
const solidFill = effectiveFillPaints(node, ctx)?.find((f) => f.visible !== false && f.type === "SOLID");
|
|
662
|
+
if (solidFill) {
|
|
663
|
+
const c = colorToCss(solidFill.color, solidFill.opacity ?? 1);
|
|
664
|
+
if (c)
|
|
665
|
+
out.color = c;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
function blendModeCss(mode) {
|
|
671
|
+
if (!mode || mode === "NORMAL" || mode === "PASS_THROUGH")
|
|
672
|
+
return null;
|
|
673
|
+
return mode.toLowerCase().replace(/_/g, "-");
|
|
674
|
+
}
|
|
675
|
+
function isAutolayout(parent) {
|
|
676
|
+
return !!(parent && parent.stackMode && parent.stackMode !== "NONE");
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Compose an INSTANCE node with its inlined master's autolayout / padding /
|
|
680
|
+
* sizing properties. The master is the source of truth for how children are
|
|
681
|
+
* arranged; the instance's cached `stack*` fields can be a stale snapshot of
|
|
682
|
+
* a previous variant. Per-axis sizing (`size`) stays on the instance — only
|
|
683
|
+
* the layout description is taken from the master.
|
|
684
|
+
*/
|
|
685
|
+
function withMasterLayout(instance, master) {
|
|
686
|
+
const layoutFields = [
|
|
687
|
+
"stackMode",
|
|
688
|
+
"stackPrimaryAlignItems",
|
|
689
|
+
"stackCounterAlignItems",
|
|
690
|
+
"stackSpacing",
|
|
691
|
+
"stackPaddingLeft",
|
|
692
|
+
"stackPaddingRight",
|
|
693
|
+
"stackPaddingTop",
|
|
694
|
+
"stackPaddingBottom",
|
|
695
|
+
"stackHorizontalPadding",
|
|
696
|
+
"stackVerticalPadding",
|
|
697
|
+
"stackPrimarySizing",
|
|
698
|
+
"stackCounterSizing",
|
|
699
|
+
];
|
|
700
|
+
const merged = { ...instance };
|
|
701
|
+
// If the master defines its own stack direction, the instance's cached
|
|
702
|
+
// stack-related fields are stale (they were captured against whatever
|
|
703
|
+
// variant the instance originally pointed at). Take ALL layout fields
|
|
704
|
+
// from the master wholesale — including `undefined` values — so we don't
|
|
705
|
+
// leak e.g. `stackPrimarySizing="FIXED"` from a HORIZONTAL variant onto a
|
|
706
|
+
// VERTICAL one whose master leaves it undefined (HUG).
|
|
707
|
+
const masterDrivesLayout = typeof master.stackMode === "string" && master.stackMode !== "NONE";
|
|
708
|
+
for (const f of layoutFields) {
|
|
709
|
+
const mv = master[f];
|
|
710
|
+
if (masterDrivesLayout) {
|
|
711
|
+
merged[f] = mv;
|
|
712
|
+
}
|
|
713
|
+
else if (mv !== undefined) {
|
|
714
|
+
merged[f] = mv;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return merged;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Derive the Figma plugin API's `layoutSizingHorizontal` / `layoutSizingVertical`
|
|
721
|
+
* for a node. The kiwi document doesn't store these directly — they're
|
|
722
|
+
* computed from the underlying stack/sizing/grow fields the same way
|
|
723
|
+
* `figma.currentPage.selection[i].layoutSizingHorizontal` is.
|
|
724
|
+
*
|
|
725
|
+
* Returns "FIXED" | "HUG" | "FILL" per axis. The cached `node.size` always
|
|
726
|
+
* carries a baked pixel value, so callers must consult the derived sizing
|
|
727
|
+
* before deciding whether to emit an explicit `width`/`height`.
|
|
728
|
+
*/
|
|
729
|
+
function layoutSizing(node, parent) {
|
|
730
|
+
let horizontal = "FIXED";
|
|
731
|
+
let vertical = "FIXED";
|
|
732
|
+
// 1) Self auto-layout (this node has its own stack). Kiwi default for an
|
|
733
|
+
// omitted `stackPrimarySizing`/`stackCounterSizing` is HUG, not FIXED.
|
|
734
|
+
if (node.stackMode && node.stackMode !== "NONE") {
|
|
735
|
+
const primaryHug = (node.stackPrimarySizing ?? "RESIZE_TO_FIT") !== "FIXED";
|
|
736
|
+
const counterHug = (node.stackCounterSizing ?? "RESIZE_TO_FIT") !== "FIXED";
|
|
737
|
+
if (node.stackMode === "HORIZONTAL") {
|
|
738
|
+
horizontal = primaryHug ? "HUG" : "FIXED";
|
|
739
|
+
vertical = counterHug ? "HUG" : "FIXED";
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
vertical = primaryHug ? "HUG" : "FIXED";
|
|
743
|
+
horizontal = counterHug ? "HUG" : "FIXED";
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
// 2) Non-autolayout frames can still hug content via `resizeToFit`.
|
|
747
|
+
if (!node.stackMode || node.stackMode === "NONE") {
|
|
748
|
+
if (node.resizeToFit) {
|
|
749
|
+
horizontal = "HUG";
|
|
750
|
+
vertical = "HUG";
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// 3) TEXT auto-resize hugs along the indicated axis/axes.
|
|
754
|
+
if (node.type === "TEXT" && node.textAutoResize) {
|
|
755
|
+
if (node.textAutoResize === "WIDTH_AND_HEIGHT") {
|
|
756
|
+
horizontal = "HUG";
|
|
757
|
+
vertical = "HUG";
|
|
758
|
+
}
|
|
759
|
+
else if (node.textAutoResize === "HEIGHT") {
|
|
760
|
+
vertical = "HUG";
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// 4) Auto-layout child of an auto-layout parent: grow/stretch -> FILL.
|
|
764
|
+
if (parent && parent.stackMode && parent.stackMode !== "NONE") {
|
|
765
|
+
const grow = (node.stackChildPrimaryGrow ?? 0) > 0;
|
|
766
|
+
const stretch = node.stackChildAlignSelf === "STRETCH";
|
|
767
|
+
if (parent.stackMode === "HORIZONTAL") {
|
|
768
|
+
if (grow)
|
|
769
|
+
horizontal = "FILL";
|
|
770
|
+
if (stretch)
|
|
771
|
+
vertical = "FILL";
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
if (grow)
|
|
775
|
+
vertical = "FILL";
|
|
776
|
+
if (stretch)
|
|
777
|
+
horizontal = "FILL";
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return { horizontal, vertical };
|
|
781
|
+
}
|
|
782
|
+
function buildCss(node, parent, ctx, isPositioned, vectorLike = false) {
|
|
783
|
+
const css = {};
|
|
784
|
+
const parentFlex = isAutolayout(parent);
|
|
785
|
+
// Position / size — mirrors smart-export:
|
|
786
|
+
// parent is auto-layout -> position: relative, no left/top, dimensions
|
|
787
|
+
// may be replaced by flex hints below.
|
|
788
|
+
// parent is not -> position: absolute with left/top/width/height.
|
|
789
|
+
if (isPositioned) {
|
|
790
|
+
css.position = "absolute";
|
|
791
|
+
if (node.transform) {
|
|
792
|
+
const x = num(node.transform.m02);
|
|
793
|
+
const y = num(node.transform.m12);
|
|
794
|
+
if (x !== null)
|
|
795
|
+
css.left = `${x}px`;
|
|
796
|
+
if (y !== null)
|
|
797
|
+
css.top = `${y}px`;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
else if (parentFlex) {
|
|
801
|
+
css.position = "relative";
|
|
802
|
+
}
|
|
803
|
+
// Decide whether to emit width/height. The Figma plugin API exposes a
|
|
804
|
+
// unified `layoutSizingHorizontal`/`layoutSizingVertical` derived from the
|
|
805
|
+
// raw stack/grow/textAutoResize fields (kiwi doesn't store those derived
|
|
806
|
+
// values). Only emit a pixel dimension on a FIXED axis — HUG and FILL both
|
|
807
|
+
// mean "let CSS size it" via flex / intrinsic content.
|
|
808
|
+
const sizing = layoutSizing(node, parent);
|
|
809
|
+
const emitWidth = sizing.horizontal === "FIXED";
|
|
810
|
+
const emitHeight = sizing.vertical === "FIXED";
|
|
811
|
+
if (node.size) {
|
|
812
|
+
const w = num(node.size.x);
|
|
813
|
+
const h = num(node.size.y);
|
|
814
|
+
if (w !== null && emitWidth)
|
|
815
|
+
css.width = `${w}px`;
|
|
816
|
+
if (h !== null && emitHeight)
|
|
817
|
+
css.height = `${h}px`;
|
|
818
|
+
}
|
|
819
|
+
// Auto-layout child hints (flex-grow / align-self).
|
|
820
|
+
if (parentFlex) {
|
|
821
|
+
if ((node.stackChildPrimaryGrow ?? 0) > 0) {
|
|
822
|
+
css.flex = "1 0 0";
|
|
823
|
+
}
|
|
824
|
+
if (node.stackChildAlignSelf) {
|
|
825
|
+
const a = STACK_ALIGN[node.stackChildAlignSelf];
|
|
826
|
+
if (a)
|
|
827
|
+
css.alignSelf = node.stackChildAlignSelf === "STRETCH" ? "stretch" : a;
|
|
828
|
+
else if (node.stackChildAlignSelf === "STRETCH")
|
|
829
|
+
css.alignSelf = "stretch";
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
// Background (TEXT uses fillPaints for color, not background; vector
|
|
833
|
+
// nodes paint via <path fill> inside the <svg>).
|
|
834
|
+
if (node.type !== "TEXT" && !vectorLike) {
|
|
835
|
+
Object.assign(css, backgroundShorthand(node, ctx));
|
|
836
|
+
}
|
|
837
|
+
// Border / outline (skipped for vector nodes — strokes go on <path>).
|
|
838
|
+
if (!vectorLike)
|
|
839
|
+
Object.assign(css, borderShorthand(node, ctx));
|
|
840
|
+
// Radius
|
|
841
|
+
Object.assign(css, radiusStyles(node));
|
|
842
|
+
// Effects (shadows, blurs)
|
|
843
|
+
Object.assign(css, effectStyles(node));
|
|
844
|
+
// Rotation
|
|
845
|
+
Object.assign(css, transformStyle(node));
|
|
846
|
+
// Text styling
|
|
847
|
+
Object.assign(css, textStyles(node, ctx));
|
|
848
|
+
// Autolayout (flex)
|
|
849
|
+
Object.assign(css, autolayoutStyles(node));
|
|
850
|
+
// Opacity / blend mode / overflow / visibility
|
|
851
|
+
if (typeof node.opacity === "number" && node.opacity < 0.999)
|
|
852
|
+
css.opacity = node.opacity;
|
|
853
|
+
const bm = blendModeCss(node.blendMode);
|
|
854
|
+
if (bm)
|
|
855
|
+
css.mixBlendMode = bm;
|
|
856
|
+
if ((node.type === "FRAME" || node.type === "INSTANCE") &&
|
|
857
|
+
node.frameMaskDisabled === false) {
|
|
858
|
+
css.overflow = "hidden";
|
|
859
|
+
}
|
|
860
|
+
// (Hidden nodes are dropped entirely in emitNode; no display:none needed.)
|
|
861
|
+
return css;
|
|
862
|
+
}
|
|
863
|
+
/** Render a css object as a single inline `style` declaration string. */
|
|
864
|
+
function formatStyleString(css) {
|
|
865
|
+
return Object.entries(css)
|
|
866
|
+
.map(([k, v]) => `${kebabCase(k)}: ${String(v)}`)
|
|
867
|
+
.join("; ");
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Figma's path-command blob format. A stream of:
|
|
871
|
+
* [op:byte] [args:float32 * N]
|
|
872
|
+
* Opcodes (discovered empirically and confirmed against rounded rect /
|
|
873
|
+
* vector / ellipse blobs):
|
|
874
|
+
* 0 = ClosePath (no args)
|
|
875
|
+
* 1 = MoveTo (x, y)
|
|
876
|
+
* 2 = LineTo (x, y)
|
|
877
|
+
* 3 = QuadTo (x1, y1, x, y)
|
|
878
|
+
* 4 = CubicTo (x1, y1, x2, y2, x, y)
|
|
879
|
+
*/
|
|
880
|
+
function decodePathCommands(bytes) {
|
|
881
|
+
if (!bytes || bytes.length === 0)
|
|
882
|
+
return "";
|
|
883
|
+
const out = [];
|
|
884
|
+
const fmt = (n) => {
|
|
885
|
+
if (!Number.isFinite(n))
|
|
886
|
+
return "0";
|
|
887
|
+
const r = Math.round(n * 1000) / 1000;
|
|
888
|
+
return Object.is(r, -0) ? "0" : String(r);
|
|
889
|
+
};
|
|
890
|
+
let i = 0;
|
|
891
|
+
while (i < bytes.length) {
|
|
892
|
+
const op = bytes[i];
|
|
893
|
+
let n = 0;
|
|
894
|
+
let letter = "";
|
|
895
|
+
if (op === 0) {
|
|
896
|
+
letter = "Z";
|
|
897
|
+
n = 0;
|
|
898
|
+
}
|
|
899
|
+
else if (op === 1) {
|
|
900
|
+
letter = "M";
|
|
901
|
+
n = 2;
|
|
902
|
+
}
|
|
903
|
+
else if (op === 2) {
|
|
904
|
+
letter = "L";
|
|
905
|
+
n = 2;
|
|
906
|
+
}
|
|
907
|
+
else if (op === 3) {
|
|
908
|
+
letter = "Q";
|
|
909
|
+
n = 4;
|
|
910
|
+
}
|
|
911
|
+
else if (op === 4) {
|
|
912
|
+
letter = "C";
|
|
913
|
+
n = 6;
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
// Unknown opcode — stop decoding gracefully so we don't run off
|
|
917
|
+
// the end of the buffer.
|
|
918
|
+
break;
|
|
919
|
+
}
|
|
920
|
+
if (i + 1 + n * 4 > bytes.length)
|
|
921
|
+
break;
|
|
922
|
+
const args = [];
|
|
923
|
+
for (let j = 0; j < n; j++)
|
|
924
|
+
args.push(fmt(bytes.readFloatLE(i + 1 + j * 4)));
|
|
925
|
+
out.push(args.length ? `${letter}${args.join(" ")}` : letter);
|
|
926
|
+
i += 1 + n * 4;
|
|
927
|
+
}
|
|
928
|
+
return out.join(" ");
|
|
929
|
+
}
|
|
930
|
+
/** SVG paint attribute (fill / stroke) for the first visible solid paint. */
|
|
931
|
+
function paintToSvgFill(paints) {
|
|
932
|
+
const p = paints?.find((x) => x.visible !== false && x.type === "SOLID");
|
|
933
|
+
if (!p || !p.color)
|
|
934
|
+
return null;
|
|
935
|
+
const c = p.color;
|
|
936
|
+
const r = Math.round(c.r * 255);
|
|
937
|
+
const g = Math.round(c.g * 255);
|
|
938
|
+
const b = Math.round(c.b * 255);
|
|
939
|
+
const opacity = c.a * (p.opacity ?? 1);
|
|
940
|
+
return {
|
|
941
|
+
color: `rgb(${r}, ${g}, ${b})`,
|
|
942
|
+
opacity: opacity < 0.999 ? Number(opacity.toFixed(3)) : undefined,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
const VECTOR_LIKE_TYPES = new Set([
|
|
946
|
+
"VECTOR",
|
|
947
|
+
"BOOLEAN_OPERATION",
|
|
948
|
+
"ELLIPSE",
|
|
949
|
+
"BRUSH",
|
|
950
|
+
"STAR",
|
|
951
|
+
"REGULAR_POLYGON",
|
|
952
|
+
"LINE",
|
|
953
|
+
"VECTOR_PATH",
|
|
954
|
+
]);
|
|
955
|
+
function isVectorLike(node) {
|
|
956
|
+
if (!node.type || !VECTOR_LIKE_TYPES.has(node.type))
|
|
957
|
+
return false;
|
|
958
|
+
if ((node.fillGeometry?.length ?? 0) === 0 &&
|
|
959
|
+
(node.strokeGeometry?.length ?? 0) === 0) {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
// Nodes with an IMAGE fill render better as a regular <div> with
|
|
963
|
+
// `background-image` (and `background-color` as a fallback) than as an
|
|
964
|
+
// <svg> with a <pattern>. Skip the vector path so backgroundShorthand
|
|
965
|
+
// can stack image + color fills via CSS.
|
|
966
|
+
if (node.fillPaints?.some((p) => p.visible !== false && p.type === "IMAGE")) {
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Render a vector-like node as an inline `<svg>`. The element itself keeps
|
|
973
|
+
* the same outer attrs (layer-name, position/size style) as a regular div
|
|
974
|
+
* so it slots into auto-layout / absolute positioning identically; the
|
|
975
|
+
* vector geometry lives inside as `<path>` children.
|
|
976
|
+
*/
|
|
977
|
+
function emitSvgBody(node, ctx, indent, lines) {
|
|
978
|
+
const fillRule = node.fillGeometry?.[0]?.windingRule === "ODD" ? "evenodd" : "nonzero";
|
|
979
|
+
const fillPaint = paintToSvgFill(effectiveFillPaints(node, ctx));
|
|
980
|
+
const strokePaint = paintToSvgFill(effectiveStrokePaints(node, ctx));
|
|
981
|
+
const strokeWeight = node.strokeWeight ?? 0;
|
|
982
|
+
// Fill paths
|
|
983
|
+
for (const g of node.fillGeometry ?? []) {
|
|
984
|
+
if (typeof g.commandsBlob !== "number")
|
|
985
|
+
continue;
|
|
986
|
+
const d = decodePathCommands(ctx.blobs[g.commandsBlob]);
|
|
987
|
+
if (!d)
|
|
988
|
+
continue;
|
|
989
|
+
const attrs = [`d="${d}"`, `fill-rule="${fillRule}"`];
|
|
990
|
+
if (fillPaint) {
|
|
991
|
+
attrs.push(`fill="${fillPaint.color}"`);
|
|
992
|
+
if (fillPaint.opacity !== undefined)
|
|
993
|
+
attrs.push(`fill-opacity="${fillPaint.opacity}"`);
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
attrs.push(`fill="none"`);
|
|
997
|
+
}
|
|
998
|
+
lines.push(`${indent} <path ${attrs.join(" ")} />`);
|
|
999
|
+
}
|
|
1000
|
+
// Stroke paths
|
|
1001
|
+
if (strokePaint && strokeWeight > 0) {
|
|
1002
|
+
for (const g of node.strokeGeometry ?? node.fillGeometry ?? []) {
|
|
1003
|
+
if (typeof g.commandsBlob !== "number")
|
|
1004
|
+
continue;
|
|
1005
|
+
const d = decodePathCommands(ctx.blobs[g.commandsBlob]);
|
|
1006
|
+
if (!d)
|
|
1007
|
+
continue;
|
|
1008
|
+
const attrs = [
|
|
1009
|
+
`d="${d}"`,
|
|
1010
|
+
`fill="none"`,
|
|
1011
|
+
`stroke="${strokePaint.color}"`,
|
|
1012
|
+
`stroke-width="${num(strokeWeight)}"`,
|
|
1013
|
+
];
|
|
1014
|
+
if (strokePaint.opacity !== undefined)
|
|
1015
|
+
attrs.push(`stroke-opacity="${strokePaint.opacity}"`);
|
|
1016
|
+
if (node.strokeJoin)
|
|
1017
|
+
attrs.push(`stroke-linejoin="${node.strokeJoin.toLowerCase()}"`);
|
|
1018
|
+
if (node.strokeCap)
|
|
1019
|
+
attrs.push(`stroke-linecap="${node.strokeCap.toLowerCase()}"`);
|
|
1020
|
+
lines.push(`${indent} <path ${attrs.join(" ")} />`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
function getChildren(node, ctx) {
|
|
1025
|
+
const kids = ctx.childrenOf.get(guidKey(node.guid)) ?? [];
|
|
1026
|
+
return kids.slice().sort((a, b) => {
|
|
1027
|
+
const pa = a.parentIndex?.position ?? "";
|
|
1028
|
+
const pb = b.parentIndex?.position ?? "";
|
|
1029
|
+
return pa < pb ? -1 : pa > pb ? 1 : 0;
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
function buildAttrs(node, parent, ctx, isPositioned, componentSymbol, vectorLike = false) {
|
|
1033
|
+
const attrs = [];
|
|
1034
|
+
// layer-name: emit whenever the node has a name at all (matches the
|
|
1035
|
+
// figma-plugin's smart-export, which always carries the layer name when
|
|
1036
|
+
// present).
|
|
1037
|
+
if (node.name)
|
|
1038
|
+
attrs.push(`layer-name="${escapeHtmlAttr(node.name)}"`);
|
|
1039
|
+
// Component metadata: pulled from the SYMBOL master that an INSTANCE renders,
|
|
1040
|
+
// or from the SYMBOL itself when emitting a master directly.
|
|
1041
|
+
const symbolForMeta = componentSymbol ??
|
|
1042
|
+
(node.type === "SYMBOL" || node.type === "INSTANCE" ? node : null);
|
|
1043
|
+
if (symbolForMeta && symbolForMeta.type === "SYMBOL") {
|
|
1044
|
+
const { base, variant } = resolveComponentIdentity(symbolForMeta, ctx);
|
|
1045
|
+
if (base)
|
|
1046
|
+
attrs.push(`data-component-name="${escapeHtmlAttr(base)}"`);
|
|
1047
|
+
if (variant)
|
|
1048
|
+
attrs.push(`data-variant-name="${escapeHtmlAttr(variant)}"`);
|
|
1049
|
+
// Expose individual variant key/value pairs as parsed JSON so consumers
|
|
1050
|
+
// can read e.g. `Style=Action` directly without re-parsing the variant
|
|
1051
|
+
// string. Mirrors how the figma-plugin surfaces variant props.
|
|
1052
|
+
if (variant && /=/.test(variant)) {
|
|
1053
|
+
const variantProps = {};
|
|
1054
|
+
for (const pair of variant.split(/,\s*/)) {
|
|
1055
|
+
const [key, val] = pair.split("=");
|
|
1056
|
+
if (key && val !== undefined)
|
|
1057
|
+
variantProps[key.trim()] = val.trim();
|
|
1058
|
+
}
|
|
1059
|
+
if (Object.keys(variantProps).length > 0) {
|
|
1060
|
+
const json = JSON.stringify(variantProps).replace(/'/g, "'");
|
|
1061
|
+
attrs.push(`data-variant-props='${json}'`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (symbolForMeta.componentKey)
|
|
1065
|
+
attrs.push(`data-component-key="${escapeHtmlAttr(symbolForMeta.componentKey)}"`);
|
|
1066
|
+
const desc = htmlToPlain(symbolForMeta.description);
|
|
1067
|
+
if (desc)
|
|
1068
|
+
attrs.push(`data-component-description="${escapeHtmlAttr(desc)}"`);
|
|
1069
|
+
const links = extractDocLinks(symbolForMeta.description);
|
|
1070
|
+
if (links.length > 0)
|
|
1071
|
+
attrs.push(`data-component-doc-link="${escapeHtmlAttr(links[0])}"`);
|
|
1072
|
+
if (links.length > 1)
|
|
1073
|
+
attrs.push(`data-component-doc-links="${escapeHtmlAttr(links.join(" | "))}"`);
|
|
1074
|
+
const propDefNames = (symbolForMeta.componentPropDefs ?? [])
|
|
1075
|
+
.map((p) => p.name)
|
|
1076
|
+
.filter(Boolean);
|
|
1077
|
+
if (propDefNames.length > 0)
|
|
1078
|
+
attrs.push(`data-component-props="${escapeHtmlAttr(propDefNames.join(", "))}"`);
|
|
1079
|
+
}
|
|
1080
|
+
// `props`: a stringified JSON object of the raw Figma component props on
|
|
1081
|
+
// this node. Includes the prop definitions on a SYMBOL/INSTANCE and the
|
|
1082
|
+
// assignments/refs that override them.
|
|
1083
|
+
const rawProps = collectRawProps(node, componentSymbol);
|
|
1084
|
+
if (rawProps) {
|
|
1085
|
+
// Use single-quoted attribute value so the inner JSON's double quotes
|
|
1086
|
+
// stay readable (no `"` noise). Escape stray single quotes inside
|
|
1087
|
+
// the JSON for safety.
|
|
1088
|
+
const json = JSON.stringify(rawProps).replace(/'/g, "'");
|
|
1089
|
+
attrs.push(`props='${json}'`);
|
|
1090
|
+
}
|
|
1091
|
+
// Per-node annotations (Figma's annotation feature).
|
|
1092
|
+
if (Array.isArray(node.annotations) && node.annotations.length > 0) {
|
|
1093
|
+
const labels = node.annotations
|
|
1094
|
+
.map((a) => htmlToPlain(a.labelV2 || a.label))
|
|
1095
|
+
.filter(Boolean);
|
|
1096
|
+
if (labels.length > 0)
|
|
1097
|
+
attrs.push(`data-annotations="${escapeHtmlAttr(labels.join(" | "))}"`);
|
|
1098
|
+
}
|
|
1099
|
+
const css = buildCss(node, parent, ctx, isPositioned, vectorLike);
|
|
1100
|
+
if (Object.keys(css).length > 0) {
|
|
1101
|
+
attrs.push(`style="${escapeHtmlAttr(formatStyleString(css))}"`);
|
|
1102
|
+
}
|
|
1103
|
+
return attrs;
|
|
1104
|
+
}
|
|
1105
|
+
function emitNode(node, parent, ctx, depth, parentIsFlex, lines, propEnv = new Map(),
|
|
1106
|
+
/**
|
|
1107
|
+
* Stack of active symbol-override scopes contributed by enclosing
|
|
1108
|
+
* INSTANCEs. Each layer's keys are descendant guidPaths RELATIVE to where
|
|
1109
|
+
* that instance was entered (matching what Figma stores in
|
|
1110
|
+
* `symbolOverrides[].guidPath` and `derivedSymbolData[].guidPath`).
|
|
1111
|
+
* Lookup uses `instancePath.slice(layer.startIndex)` plus the current
|
|
1112
|
+
* node's overrideKey as the leaf segment.
|
|
1113
|
+
*
|
|
1114
|
+
* Outer layers stay active across nested inner instances so that deep
|
|
1115
|
+
* overrides like `[outerInstanceKey, innerNodeKey]` still match.
|
|
1116
|
+
*/
|
|
1117
|
+
overrideLayers = [],
|
|
1118
|
+
/**
|
|
1119
|
+
* Stack of INSTANCE overrideKeys we've descended INTO (i.e., crossed the
|
|
1120
|
+
* instance->master boundary). Plain frame/group nesting does NOT grow this
|
|
1121
|
+
* path. Used together with `overrideLayers` to look up `symbolOverrides` /
|
|
1122
|
+
* `derivedSymbolData` entries that target a descendant by library guidPath.
|
|
1123
|
+
*/
|
|
1124
|
+
instancePath = []) {
|
|
1125
|
+
// Smart-export skips invisible nodes entirely (rather than emitting them
|
|
1126
|
+
// with display:none). Match that so hidden layers don't pollute the
|
|
1127
|
+
// generated HTML.
|
|
1128
|
+
if (node.visible === false)
|
|
1129
|
+
return;
|
|
1130
|
+
// Apply enclosing-instance symbol overrides (variant swap, text override,
|
|
1131
|
+
// visibility flip) targeted at this node by guidPath.
|
|
1132
|
+
const overridden = applyOverrideLayers(node, overrideLayers, instancePath);
|
|
1133
|
+
if (overridden === null)
|
|
1134
|
+
return;
|
|
1135
|
+
node = overridden;
|
|
1136
|
+
// Apply parent-instance prop overrides for this node (text/symbol/swap,
|
|
1137
|
+
// visibility). May hide the node entirely or rewrite its textData /
|
|
1138
|
+
// symbolData before we resolve the inlined symbol below.
|
|
1139
|
+
const patched = applyPropRefs(node, propEnv);
|
|
1140
|
+
if (patched === null)
|
|
1141
|
+
return;
|
|
1142
|
+
node = patched;
|
|
1143
|
+
const indent = " ".repeat(depth);
|
|
1144
|
+
// INSTANCE: inline the master SYMBOL's children inside this element so the
|
|
1145
|
+
// implementation is self-contained. Cycle guard: don't recursively inline a
|
|
1146
|
+
// SYMBOL that's already being inlined further up the chain.
|
|
1147
|
+
let inlinedSymbol = null;
|
|
1148
|
+
if (node.type === "INSTANCE" && node.symbolData?.symbolID) {
|
|
1149
|
+
const symKey = guidKey(node.symbolData.symbolID);
|
|
1150
|
+
if (!ctx.inliningStack.has(symKey)) {
|
|
1151
|
+
const sym = ctx.symbolByGuid.get(symKey);
|
|
1152
|
+
if (sym)
|
|
1153
|
+
inlinedSymbol = sym;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
// When entering an INSTANCE, extend the prop env with its assignments so
|
|
1157
|
+
// descendants (whether the instance's own children or the inlined SYMBOL's
|
|
1158
|
+
// children) see the override values. Likewise build a fresh
|
|
1159
|
+
// symbolOverrides map (overrides scope to a single instance), and reset
|
|
1160
|
+
// the current path so descendant guidPaths are evaluated against the new
|
|
1161
|
+
// master.
|
|
1162
|
+
const childPropEnv = node.type === "INSTANCE" ? buildPropEnv(node, propEnv) : propEnv;
|
|
1163
|
+
// When entering an INSTANCE that will inline a master, descendants live
|
|
1164
|
+
// one level deeper in the instance-path. Push the new override layer with
|
|
1165
|
+
// startIndex pointing at that future depth so its keys (relative paths
|
|
1166
|
+
// inside this instance's master) are evaluated against an empty prefix at
|
|
1167
|
+
// the master's first level. Outer layers stay active so deeper overrides
|
|
1168
|
+
// from enclosing instances still apply across nested boundaries.
|
|
1169
|
+
const childInstancePath = node.type === "INSTANCE" && inlinedSymbol
|
|
1170
|
+
? [...instancePath, guidKey(node.overrideKey ?? node.guid)]
|
|
1171
|
+
: instancePath;
|
|
1172
|
+
let childOverrideLayers = overrideLayers;
|
|
1173
|
+
if (node.type === "INSTANCE") {
|
|
1174
|
+
const map = buildSymbolOverrideLayer(node);
|
|
1175
|
+
if (map.size > 0) {
|
|
1176
|
+
childOverrideLayers = [
|
|
1177
|
+
...overrideLayers,
|
|
1178
|
+
{ startIndex: childInstancePath.length, map },
|
|
1179
|
+
];
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
// A node is rendered as an SVG when it has its own vector geometry, OR
|
|
1183
|
+
// when it's an INSTANCE of a SYMBOL whose root is itself a vector (e.g.
|
|
1184
|
+
// single-shape icon components). For the latter we paint the master's
|
|
1185
|
+
// geometry inside the instance element so the icon actually shows up
|
|
1186
|
+
// instead of an empty div.
|
|
1187
|
+
const selfVector = isVectorLike(node);
|
|
1188
|
+
const symbolVector = !!inlinedSymbol && isVectorLike(inlinedSymbol);
|
|
1189
|
+
const vectorLike = selfVector || symbolVector;
|
|
1190
|
+
const vectorSourceNode = selfVector
|
|
1191
|
+
? node
|
|
1192
|
+
: symbolVector
|
|
1193
|
+
? inlinedSymbol
|
|
1194
|
+
: node;
|
|
1195
|
+
const tag = vectorLike ? "svg" : tagFor(node.type);
|
|
1196
|
+
// Children of a flex (autolayout) parent flow normally; otherwise absolute.
|
|
1197
|
+
const isPositioned = !parentIsFlex;
|
|
1198
|
+
// For INSTANCE nodes with an inlined master, the autolayout / padding /
|
|
1199
|
+
// sizing properties cached on the instance reflect the *previous* master
|
|
1200
|
+
// and become stale after a variant swap. Use the master's values for the
|
|
1201
|
+
// instance's own container styling so the rendered layout matches the
|
|
1202
|
+
// currently-resolved variant.
|
|
1203
|
+
const layoutNode = inlinedSymbol
|
|
1204
|
+
? withMasterLayout(node, inlinedSymbol)
|
|
1205
|
+
: node;
|
|
1206
|
+
const attrs = buildAttrs(layoutNode, parent, ctx, isPositioned, inlinedSymbol, vectorLike);
|
|
1207
|
+
if (vectorLike) {
|
|
1208
|
+
// viewBox prefers the geometry source node's intrinsic size so the
|
|
1209
|
+
// SVG draws correctly when the instance is scaled differently from
|
|
1210
|
+
// the master.
|
|
1211
|
+
const vw = vectorSourceNode.size?.x ?? node.size?.x ?? 0;
|
|
1212
|
+
const vh = vectorSourceNode.size?.y ?? node.size?.y ?? 0;
|
|
1213
|
+
if (vw > 0 && vh > 0) {
|
|
1214
|
+
attrs.push(`viewBox="0 0 ${num(vw)} ${num(vh)}"`);
|
|
1215
|
+
}
|
|
1216
|
+
attrs.push(`xmlns="http://www.w3.org/2000/svg"`);
|
|
1217
|
+
attrs.push(`fill="none"`);
|
|
1218
|
+
}
|
|
1219
|
+
const isFlex = layoutNode.stackMode && layoutNode.stackMode !== "NONE";
|
|
1220
|
+
// Single HTML comment above the element with component name + description
|
|
1221
|
+
// + doc links, when present. (Same metadata is also on data-* attrs.)
|
|
1222
|
+
const symbolForMeta = inlinedSymbol ?? (node.type === "SYMBOL" ? node : null);
|
|
1223
|
+
if (symbolForMeta) {
|
|
1224
|
+
const desc = htmlToPlain(symbolForMeta.description);
|
|
1225
|
+
const links = extractDocLinks(symbolForMeta.description);
|
|
1226
|
+
if (desc || links.length > 0) {
|
|
1227
|
+
const parts = [
|
|
1228
|
+
`Component: ${(symbolForMeta.name ?? "<unnamed>").replace(/--/g, "\u2013")}`,
|
|
1229
|
+
];
|
|
1230
|
+
// HTML comments must not contain "--" — replace any with an en-dash.
|
|
1231
|
+
if (desc)
|
|
1232
|
+
parts.push(desc.replace(/--/g, "\u2013"));
|
|
1233
|
+
if (links.length > 0)
|
|
1234
|
+
parts.push(`docs: ${links.join(", ")}`);
|
|
1235
|
+
lines.push(`${indent}<!-- ${parts.join(" \u2014 ")} -->`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (vectorLike) {
|
|
1239
|
+
emitOpenWithChildren(tag, attrs, indent, lines);
|
|
1240
|
+
emitSvgBody(vectorSourceNode, ctx, indent, lines);
|
|
1241
|
+
lines.push(`${indent}</${tag}>`);
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
if (node.type === "TEXT") {
|
|
1245
|
+
const chars = node.textData?.characters ?? "";
|
|
1246
|
+
if (chars.length === 0) {
|
|
1247
|
+
emitOpenWithChildren(tag, attrs, indent, lines);
|
|
1248
|
+
lines.push(`${indent}</${tag}>`);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
emitOpenWithChildren(tag, attrs, indent, lines);
|
|
1252
|
+
// Preserve newlines in the source by splitting into <br>-separated lines
|
|
1253
|
+
// (HTML otherwise collapses whitespace).
|
|
1254
|
+
const escaped = escapeHtmlText(chars).replace(/\n/g, "<br>");
|
|
1255
|
+
lines.push(`${indent} ${escaped}`);
|
|
1256
|
+
lines.push(`${indent}</${tag}>`);
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
// Pick which children to render: the inlined SYMBOL's, or the node's own.
|
|
1260
|
+
let children;
|
|
1261
|
+
let symKeyForCycle = null;
|
|
1262
|
+
if (inlinedSymbol) {
|
|
1263
|
+
symKeyForCycle = guidKey(inlinedSymbol.guid);
|
|
1264
|
+
ctx.inliningStack.add(symKeyForCycle);
|
|
1265
|
+
children = getChildren(inlinedSymbol, ctx);
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
children = getChildren(node, ctx);
|
|
1269
|
+
}
|
|
1270
|
+
try {
|
|
1271
|
+
if (children.length === 0) {
|
|
1272
|
+
emitOpenWithChildren(tag, attrs, indent, lines);
|
|
1273
|
+
lines.push(`${indent}</${tag}>`);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
emitOpenWithChildren(tag, attrs, indent, lines);
|
|
1277
|
+
// When inlining a SYMBOL, its child positions are relative to the SYMBOL's
|
|
1278
|
+
// own frame, which now coincides with this INSTANCE's frame. So they keep
|
|
1279
|
+
// their original transforms.
|
|
1280
|
+
const childParentIsFlex = inlinedSymbol
|
|
1281
|
+
? !!(inlinedSymbol.stackMode && inlinedSymbol.stackMode !== "NONE")
|
|
1282
|
+
: !!isFlex;
|
|
1283
|
+
const childParentNode = inlinedSymbol ?? node;
|
|
1284
|
+
for (const child of children) {
|
|
1285
|
+
emitNode(child, childParentNode, ctx, depth + 1, childParentIsFlex, lines, childPropEnv, childOverrideLayers, childInstancePath);
|
|
1286
|
+
}
|
|
1287
|
+
lines.push(`${indent}</${tag}>`);
|
|
1288
|
+
}
|
|
1289
|
+
finally {
|
|
1290
|
+
if (symKeyForCycle)
|
|
1291
|
+
ctx.inliningStack.delete(symKeyForCycle);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
function emitOpenWithChildren(tag, attrs, indent, lines) {
|
|
1295
|
+
if (attrs.length === 0) {
|
|
1296
|
+
lines.push(`${indent}<${tag}>`);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
// Single-line for short attribute lists; multi-line otherwise.
|
|
1300
|
+
const oneLine = `${indent}<${tag} ${attrs.join(" ")}>`;
|
|
1301
|
+
if (attrs.length <= 2 && oneLine.length <= 200) {
|
|
1302
|
+
lines.push(oneLine);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
lines.push(`${indent}<${tag}`);
|
|
1306
|
+
for (const a of attrs)
|
|
1307
|
+
lines.push(`${indent} ${a}`);
|
|
1308
|
+
lines.push(`${indent}>`);
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Build a Google Fonts CSS2 URL from the set of font family/weight/italic
|
|
1312
|
+
* combos collected while emitting a frame. Returns null when no fonts are
|
|
1313
|
+
* recorded.
|
|
1314
|
+
*/
|
|
1315
|
+
function buildGoogleFontsUrl(fontUsage) {
|
|
1316
|
+
if (fontUsage.size === 0)
|
|
1317
|
+
return null;
|
|
1318
|
+
const byFamily = new Map();
|
|
1319
|
+
for (const entry of fontUsage) {
|
|
1320
|
+
const [family, weightStr, italicStr] = entry.split("|");
|
|
1321
|
+
if (!family)
|
|
1322
|
+
continue;
|
|
1323
|
+
const weight = Number(weightStr) || 400;
|
|
1324
|
+
const italic = italicStr === "1";
|
|
1325
|
+
if (!byFamily.has(family))
|
|
1326
|
+
byFamily.set(family, []);
|
|
1327
|
+
byFamily.get(family).push({ weight, italic });
|
|
1328
|
+
}
|
|
1329
|
+
const families = [];
|
|
1330
|
+
for (const [family, variants] of byFamily) {
|
|
1331
|
+
const hasItalic = variants.some((v) => v.italic);
|
|
1332
|
+
const weights = Array.from(new Set(variants.map((v) => v.weight))).sort((a, b) => a - b);
|
|
1333
|
+
const famParam = family.replace(/\s+/g, "+");
|
|
1334
|
+
if (hasItalic) {
|
|
1335
|
+
const tuples = variants
|
|
1336
|
+
.map((v) => `${v.italic ? 1 : 0},${v.weight}`)
|
|
1337
|
+
.sort();
|
|
1338
|
+
families.push(`family=${famParam}:ital,wght@${Array.from(new Set(tuples)).join(";")}`);
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
families.push(`family=${famParam}:wght@${weights.join(";")}`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return `https://fonts.googleapis.com/css2?${families.join("&")}&display=swap`;
|
|
1345
|
+
}
|
|
1346
|
+
function emitFrameTemplate(frame, ctx, pageName) {
|
|
1347
|
+
// Reset per-frame font usage; emitNode populates it via textStyles.
|
|
1348
|
+
ctx.fontUsage.clear();
|
|
1349
|
+
const bodyLines = [];
|
|
1350
|
+
emitNode(frame, null, ctx, 1, true, bodyLines, new Map(), [], []);
|
|
1351
|
+
const lines = [];
|
|
1352
|
+
lines.push("<!doctype html>");
|
|
1353
|
+
lines.push(`<!-- Auto-generated from Figma. Frame: ${frame.name ?? "<unnamed>"} (page: ${pageName}) -->`);
|
|
1354
|
+
lines.push("<html>");
|
|
1355
|
+
lines.push("<head>");
|
|
1356
|
+
lines.push(' <meta charset="utf-8">');
|
|
1357
|
+
lines.push(` <title>${escapeHtmlText(`${pageName} \u2014 ${frame.name ?? "frame"}`)}</title>`);
|
|
1358
|
+
// Custom font families used by the frame -> request them from Google
|
|
1359
|
+
// Fonts. (Smart-export does the same for design hand-off so the layout
|
|
1360
|
+
// renders with the intended typography.)
|
|
1361
|
+
const fontsUrl = buildGoogleFontsUrl(ctx.fontUsage);
|
|
1362
|
+
if (fontsUrl) {
|
|
1363
|
+
lines.push(' <link rel="preconnect" href="https://fonts.googleapis.com">');
|
|
1364
|
+
lines.push(' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>');
|
|
1365
|
+
lines.push(` <link rel="stylesheet" href="${escapeHtmlAttr(fontsUrl)}">`);
|
|
1366
|
+
}
|
|
1367
|
+
lines.push("</head>");
|
|
1368
|
+
lines.push("<body>");
|
|
1369
|
+
for (const l of bodyLines)
|
|
1370
|
+
lines.push(l);
|
|
1371
|
+
lines.push("</body>");
|
|
1372
|
+
lines.push("</html>");
|
|
1373
|
+
lines.push("");
|
|
1374
|
+
return lines.join("\n");
|
|
1375
|
+
}
|
|
1376
|
+
const TOP_LEVEL_RENDERABLE_TYPES = new Set(["FRAME", "SYMBOL", "INSTANCE"]);
|
|
1377
|
+
/**
|
|
1378
|
+
* Collect the renderable top-level units on a page. Figma allows SECTION
|
|
1379
|
+
* nodes (and nested sections) to wrap frames; sections are organizational
|
|
1380
|
+
* containers, not standalone designs, so we recurse THROUGH them and
|
|
1381
|
+
* collect the frames inside. Anything that isn't a SECTION or a
|
|
1382
|
+
* renderable type is ignored. Children are returned in document order
|
|
1383
|
+
* (depth-first across sections).
|
|
1384
|
+
*/
|
|
1385
|
+
export function collectTopLevelFrames(parent, childrenOf) {
|
|
1386
|
+
const sortChildren = (kids) => kids.slice().sort((a, b) => {
|
|
1387
|
+
const pa = a.parentIndex?.position ?? "";
|
|
1388
|
+
const pb = b.parentIndex?.position ?? "";
|
|
1389
|
+
return pa < pb ? -1 : pa > pb ? 1 : 0;
|
|
1390
|
+
});
|
|
1391
|
+
const out = [];
|
|
1392
|
+
const visit = (node) => {
|
|
1393
|
+
for (const child of sortChildren(childrenOf.get(guidKey(node.guid)) ?? [])) {
|
|
1394
|
+
if (!child.type)
|
|
1395
|
+
continue;
|
|
1396
|
+
if (child.type === "SECTION") {
|
|
1397
|
+
visit(child);
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (TOP_LEVEL_RENDERABLE_TYPES.has(child.type)) {
|
|
1401
|
+
out.push(child);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
visit(parent);
|
|
1406
|
+
return out;
|
|
1407
|
+
}
|
|
1408
|
+
export function renderHtmlTemplates(document, options = {}) {
|
|
1409
|
+
const doc = document;
|
|
1410
|
+
const nodes = doc.nodeChanges ?? [];
|
|
1411
|
+
// Decode blob bytes once. The kiwi document JSON-serializes blob bytes as
|
|
1412
|
+
// hex strings; Buffer / Uint8Array values may also appear depending on how
|
|
1413
|
+
// the caller decoded the document.
|
|
1414
|
+
const blobs = (doc.blobs ?? []).map((b) => {
|
|
1415
|
+
const v = b?.bytes;
|
|
1416
|
+
if (!v)
|
|
1417
|
+
return Buffer.alloc(0);
|
|
1418
|
+
if (Buffer.isBuffer(v))
|
|
1419
|
+
return v;
|
|
1420
|
+
if (v instanceof Uint8Array)
|
|
1421
|
+
return Buffer.from(v);
|
|
1422
|
+
if (typeof v === "string")
|
|
1423
|
+
return Buffer.from(v, "hex");
|
|
1424
|
+
return Buffer.alloc(0);
|
|
1425
|
+
});
|
|
1426
|
+
const byGuid = new Map();
|
|
1427
|
+
const byKey = new Map();
|
|
1428
|
+
const childrenOf = new Map();
|
|
1429
|
+
const symbolByGuid = new Map();
|
|
1430
|
+
for (const n of nodes) {
|
|
1431
|
+
byGuid.set(guidKey(n.guid), n);
|
|
1432
|
+
if (n.key)
|
|
1433
|
+
byKey.set(n.key, n);
|
|
1434
|
+
if (n.type === "SYMBOL") {
|
|
1435
|
+
symbolByGuid.set(guidKey(n.guid), n);
|
|
1436
|
+
}
|
|
1437
|
+
const pk = guidKey(n.parentIndex?.guid);
|
|
1438
|
+
if (!pk)
|
|
1439
|
+
continue;
|
|
1440
|
+
let arr = childrenOf.get(pk);
|
|
1441
|
+
if (!arr) {
|
|
1442
|
+
arr = [];
|
|
1443
|
+
childrenOf.set(pk, arr);
|
|
1444
|
+
}
|
|
1445
|
+
arr.push(n);
|
|
1446
|
+
}
|
|
1447
|
+
const ctx = {
|
|
1448
|
+
byGuid,
|
|
1449
|
+
byKey,
|
|
1450
|
+
childrenOf,
|
|
1451
|
+
symbolByGuid,
|
|
1452
|
+
imageRefBase: options.imageRefBase,
|
|
1453
|
+
blobs,
|
|
1454
|
+
imageMap: options.imageMap ?? new Map(),
|
|
1455
|
+
fontUsage: new Set(),
|
|
1456
|
+
inliningStack: new Set(),
|
|
1457
|
+
};
|
|
1458
|
+
const documentNode = nodes.find((n) => n.type === "DOCUMENT");
|
|
1459
|
+
if (!documentNode)
|
|
1460
|
+
return { pageCount: 0, frameCount: 0, frames: [] };
|
|
1461
|
+
const allPages = (childrenOf.get(guidKey(documentNode.guid)) ?? []).filter((n) => n.type === "CANVAS" && !n.internalOnly);
|
|
1462
|
+
const selection = options.selection && options.selection.size > 0 ? options.selection : null;
|
|
1463
|
+
const pages = selection
|
|
1464
|
+
? allPages.filter((page) => {
|
|
1465
|
+
if (selection.has(guidKey(page.guid)))
|
|
1466
|
+
return true;
|
|
1467
|
+
const children = childrenOf.get(guidKey(page.guid)) ?? [];
|
|
1468
|
+
return children.some((c) => selection.has(guidKey(c.guid)));
|
|
1469
|
+
})
|
|
1470
|
+
: allPages;
|
|
1471
|
+
const frames = [];
|
|
1472
|
+
for (let pageIdx = 0; pageIdx < pages.length; pageIdx++) {
|
|
1473
|
+
const page = pages[pageIdx];
|
|
1474
|
+
const pageDirName = sanitizeFilename(page.name, `page-${pageIdx + 1}`);
|
|
1475
|
+
const pageSelected = selection?.has(guidKey(page.guid)) ?? false;
|
|
1476
|
+
const pageFrames = collectTopLevelFrames(page, ctx.childrenOf).filter((c) => {
|
|
1477
|
+
if (!selection || pageSelected)
|
|
1478
|
+
return true;
|
|
1479
|
+
return selection.has(guidKey(c.guid));
|
|
1480
|
+
});
|
|
1481
|
+
const seen = new Map();
|
|
1482
|
+
for (let frameIdx = 0; frameIdx < pageFrames.length; frameIdx++) {
|
|
1483
|
+
const frame = pageFrames[frameIdx];
|
|
1484
|
+
const baseFile = sanitizeFilename(frame.name, `frame-${frameIdx + 1}`);
|
|
1485
|
+
const dupeIdx = seen.get(baseFile) ?? 0;
|
|
1486
|
+
seen.set(baseFile, dupeIdx + 1);
|
|
1487
|
+
const fileName = dupeIdx === 0 ? `${baseFile}.html` : `${baseFile}-${dupeIdx + 1}.html`;
|
|
1488
|
+
const pageName = page.name ?? `page-${pageIdx + 1}`;
|
|
1489
|
+
const html = emitFrameTemplate(frame, ctx, pageName);
|
|
1490
|
+
frames.push({
|
|
1491
|
+
pageName,
|
|
1492
|
+
pageDirName,
|
|
1493
|
+
frameName: frame.name ?? `frame-${frameIdx + 1}`,
|
|
1494
|
+
fileName,
|
|
1495
|
+
relativePath: path.posix.join(pageDirName, fileName),
|
|
1496
|
+
html,
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return {
|
|
1501
|
+
pageCount: pages.length,
|
|
1502
|
+
frameCount: frames.length,
|
|
1503
|
+
frames,
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
//# sourceMappingURL=fig-to-html.js.map
|