diff --git a/README.md b/README.md index 6adbe1df..e09e726f 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,15 @@ The generator is deterministic and runs inside Figma's plugin sandbox. It does n ## Output targets -| Target | Available output modes | -| ------------ | ----------------------------------------------------------- | -| HTML | HTML, React (JSX), Svelte, styled-components | -| Tailwind CSS | HTML, React (JSX), Twig; supports Tailwind 3 and Tailwind 4 | -| Flutter | Full app, stateless widget, or snippet | -| SwiftUI | Preview, `View` struct, or snippet | +| Target | Available output modes | +| ------------- | ----------------------------------------------------------- | +| HTML | HTML, React (JSX), Svelte, styled-components | +| Tailwind CSS | HTML, React (JSX), Twig; supports Tailwind 3 and Tailwind 4 | +| Flutter | Full app, stateless widget, or snippet | +| SwiftUI | Preview, `View` struct, or snippet | +| Design Bundle | JSON manifest + exported assets, zipped (see below) | + +Design Bundle is different from the other four rows: it isn't finished code, it's a target-neutral snapshot of the selection's layout, styling, and content for another tool to read. See [Design Bundle export](#design-bundle-export) below. The plugin can also package generated code and local image assets into downloadable starters: @@ -46,6 +49,17 @@ The plugin can also package generated code and local image assets into downloada These exports are deliberately small and dependency-light. They are starting points, not generated production applications. +## Design Bundle export + +Alongside the four code targets above, the plugin can export the same normalized node tree as a **Design Bundle** instead of code: a `design-bundle.json` manifest plus an `assets/` folder of exported raster and vector images, packaged as a zip. It's meant to be consumed by another tool, not pasted into an application directly — think of it as the "Normalize" stage of [How conversion works](#how-conversion-works) written to disk, before any framework-specific "Generate" step runs. + +A couple of things make it different from the other four targets: + +- **Multiple top-level layers in one export.** Where the code targets work from a single converted selection, a Design Bundle turns each top-level layer in your selection into its own named entry in the bundle's `designs` array — useful for exporting several distinct sections or pages in one pass. +- **No code-specific tuning.** None of the "What you can tune" options below apply; the bundle carries the resolved layout and style data itself, and leaves interpreting it (as CMS content blocks, a design system, or anything else) up to whatever reads the bundle. + +Export a bundle from the toolbar button next to the framework tabs. The bundle's shape is documented via TSDoc comments on the `DesignBundle*` types in [`packages/types/src/types.ts`](packages/types/src/types.ts) — start there for field-level detail. + ## What you can tune Options appear only when they apply to the selected target: @@ -160,14 +174,15 @@ pnpm format:check # Check formatting without writing ### Repository structure -| Path | Purpose | -| -------------------- | ---------------------------------------------------------------------------------------- | -| `packages/backend` | Figma node processing, intermediate representation, code generators, and project exports | -| `packages/plugin-ui` | Shared React interface used by the plugin and interactive website demo | -| `packages/types` | Shared settings, message, preview, and output types | -| `packages/tsconfig` | Shared TypeScript configuration | -| `apps/plugin` | Figma controller and UI entry points; builds `code.js` and `index.html` | -| `apps/web` | Public website, interactive preview, privacy page, and comparison guides | +| Path | Purpose | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `packages/backend` | Figma node processing, intermediate representation, code generators, and project exports | +| `packages/backend/src/designBundle` | Design Bundle export — serializes the normalized node tree to `design-bundle.json` + assets instead of code | +| `packages/plugin-ui` | Shared React interface used by the plugin and interactive website demo | +| `packages/types` | Shared settings, message, preview, and output types | +| `packages/tsconfig` | Shared TypeScript configuration | +| `apps/plugin` | Figma controller and UI entry points; builds `code.js` and `index.html` | +| `apps/web` | Public website, interactive preview, privacy page, and comparison guides | ## Contributing and support diff --git a/apps/plugin/plugin-src/code.ts b/apps/plugin/plugin-src/code.ts index 47f5fdb6..eab6e36d 100644 --- a/apps/plugin/plugin-src/code.ts +++ b/apps/plugin/plugin-src/code.ts @@ -9,6 +9,7 @@ import { generateProjectZip, postSettingsChanged, replaceProjectImagePlaceholders, + buildDesignBundle, } from "backend"; import { nodesToJSON } from "backend/src/altNodes/jsonNodeConversion"; import { oldConvertNodesToAltNodes } from "backend/src/altNodes/oldAltConversion"; @@ -94,6 +95,7 @@ const initSettings = async () => { let isLoading = false; let isDownloadingProject = false; let rerunAfterDownload = false; +let isExportingDesignBundle = false; const safeRun = async (settings: PluginSettings) => { console.log( "[DEBUG] safeRun - Called with isLoading =", @@ -455,6 +457,42 @@ const standardMode = async () => { void safeRun(userPluginSettings); } } + } else if (msg.type === "export-design-bundle") { + if (isExportingDesignBundle) { + figma.ui.postMessage({ + type: "design-bundle-error", + error: "A design bundle export is already in progress.", + }); + return; + } + + const selection = [...figma.currentPage.selection]; + isExportingDesignBundle = true; + try { + const result = await buildDesignBundle(selection, userPluginSettings); + const zip = result.zip.buffer.slice( + result.zip.byteOffset, + result.zip.byteOffset + result.zip.byteLength, + ); + figma.ui.postMessage({ + type: "design-bundle-zip", + zip, + fileName: result.fileName, + designCount: result.designCount, + assetCount: result.assetCount, + warnings: result.warnings, + }); + } catch (error) { + console.error("Design bundle export failed:", error); + figma.ui.postMessage({ + type: "design-bundle-error", + error: `Failed to create design bundle: ${ + error instanceof Error ? error.message : "Unknown error occurred" + }`, + }); + } finally { + isExportingDesignBundle = false; + } } else if (msg.type === "pluginSettingWillChange") { const { key, value } = msg as SettingWillChangeMessage; console.log(`[DEBUG] Setting changed: ${key} = ${value}`); diff --git a/apps/plugin/ui-src/App.tsx b/apps/plugin/ui-src/App.tsx index 96eb1464..bae66fbc 100644 --- a/apps/plugin/ui-src/App.tsx +++ b/apps/plugin/ui-src/App.tsx @@ -14,6 +14,8 @@ import { DownloadProjectFormat, ProjectDownloadErrorMessage, ProjectZipMessage, + DesignBundleZipMessage, + DesignBundleErrorMessage, } from "types"; import { postUISettingsChangingMessage } from "./messaging"; import copy from "copy-to-clipboard"; @@ -29,6 +31,9 @@ interface AppState { warnings: Warning[]; isDownloadingProject: boolean; projectDownloadError: string | null; + isExportingDesignBundle: boolean; + designBundleExportError: string | null; + designBundleWarnings: Warning[]; } const emptyPreview = { size: { width: 0, height: 0 }, content: "" }; @@ -56,6 +61,9 @@ export default function App() { warnings: [], isDownloadingProject: false, projectDownloadError: null, + isExportingDesignBundle: false, + designBundleExportError: null, + designBundleWarnings: [], }); const rootStyles = getComputedStyle(document.documentElement); @@ -157,6 +165,39 @@ export default function App() { break; } + case "design-bundle-zip": { + const bundleMessage = untypedMessage as DesignBundleZipMessage; + const blob = new Blob([bundleMessage.zip], { + type: "application/zip", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = bundleMessage.fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + setState((prevState) => ({ + ...prevState, + isExportingDesignBundle: false, + designBundleExportError: null, + designBundleWarnings: bundleMessage.warnings ?? [], + })); + break; + } + + case "design-bundle-error": { + const bundleError = untypedMessage as DesignBundleErrorMessage; + setState((prevState) => ({ + ...prevState, + isExportingDesignBundle: false, + designBundleExportError: bundleError.error, + designBundleWarnings: [], + })); + break; + } + default: break; } @@ -208,6 +249,22 @@ export default function App() { "*", ); }; + const handleExportDesignBundle = () => { + if (state.isExportingDesignBundle) { + return; + } + + setState((prevState) => ({ + ...prevState, + isExportingDesignBundle: true, + designBundleExportError: null, + designBundleWarnings: [], + })); + parent.postMessage( + { pluginMessage: { type: "export-design-bundle" } }, + "*", + ); + }; const darkMode = isDarkFigmaBackground(figmaColorBgValue); @@ -237,6 +294,10 @@ export default function App() { onDownloadProject={handleDownloadProject} isDownloadingProject={state.isDownloadingProject} projectDownloadError={state.projectDownloadError} + onExportDesignBundle={handleExportDesignBundle} + isExportingDesignBundle={state.isExportingDesignBundle} + designBundleExportError={state.designBundleExportError} + designBundleWarnings={state.designBundleWarnings} /> ); diff --git a/packages/backend/src/altNodes/jsonNodeConversion.ts b/packages/backend/src/altNodes/jsonNodeConversion.ts index d9b23f32..671cd56d 100644 --- a/packages/backend/src/altNodes/jsonNodeConversion.ts +++ b/packages/backend/src/altNodes/jsonNodeConversion.ts @@ -341,13 +341,34 @@ const processNodePair = async ( parentCumulativeRotation + (jsonNode.rotation || 0), ); - // Push the processed group children directly + // Push the processed group children directly. A GROUP has no Auto + // Layout of its own, so whatever arrangement its children had (e.g. + // two buttons placed side by side) exists only via their raw x/y — + // once the GROUP node itself is discarded here, that arrangement + // has no other representation. Mark each resulting node with a + // bundle-only `inlinedFromGroup` flag rather than reusing the real + // `layoutPositioning: "ABSOLUTE"` field: this conversion path is + // shared by every codegen target (HTML, Tailwind, Flutter, SwiftUI, + // Compose), and `layoutPositioning` feeds real per-target behavior + // there (see `common/commonPosition.ts`'s `commonIsAbsolutePosition`, + // and the Flutter/Compose backends) as well as this file's own + // `adjustChildrenOrder`/`isRelative` checks below — stamping it here + // would silently change output for every target, not just the + // Design Bundle. `designBundleTree.ts`'s `isAbsoluteInAutoLayout` + // check reads this bundle-only flag in addition to the real field, + // so only the Design Bundle path captures inlined former-GROUP + // children as explicitly positioned. Their x/y were already + // computed above relative to `parentNode` (the group's own parent, + // not the discarded group), via the absoluteBoundingBox diff — so + // no coordinate rebasing is needed here, only the flag. if (processedChild !== null) { - if (Array.isArray(processedChild)) { - processedChildren.push(...processedChild); - } else { - processedChildren.push(processedChild); + const resultNodes = Array.isArray(processedChild) + ? processedChild + : [processedChild]; + for (const resultNode of resultNodes) { + (resultNode as any).inlinedFromGroup = true; } + processedChildren.push(...resultNodes); } } } @@ -366,6 +387,32 @@ const processNodePair = async ( (jsonNode as any).parent = parentNode; } + // `jsonNode` originates entirely from `node.exportAsync({ format: + // "JSON_REST_V1" })` (nodesToJSON, above) — a static snapshot in + // Figma's REST API v1 shape, not live Plugin API property access. + // Found via a real, reproducible case: six related-product Cards with + // Figma's per-child "Position: Absolute" toggle enabled (no GROUP + // involved — confirmed directly in Figma), inside a real HORIZONTAL + // Auto Layout "Card grid" parent. Every one of them rendered with zero + // positioning at all — not wrong coordinates, nothing — meaning + // `layout.position` was never captured + // (`designBundleTree.ts`'s `isAbsoluteInAutoLayout` check reads + // `node.layoutPositioning === "ABSOLUTE"`, which depends entirely on + // this field surviving from that snapshot). `layoutPositioning` (the + // per-child Auto Layout "position absolutely" override) is a + // comparatively recent Figma feature — plausible the frozen REST API + // v1 export format simply never included it, even though it's + // declared in this project's own `api_types.ts` (a hand-written type, + // not a guarantee the export payload actually populates it). The live + // `figmaNode` parameter (the real Plugin API SceneNode, available at + // every level of this recursion) is authoritative here regardless of + // what the snapshot did or didn't carry — read it directly as an + // override whenever present, rather than trusting the snapshot alone + // for this one property. + if ("layoutPositioning" in figmaNode && (figmaNode as any).layoutPositioning) { + (jsonNode as any).layoutPositioning = (figmaNode as any).layoutPositioning; + } + // Ensure node has a unique name with simple numbering const cleanName = jsonNode.name.trim(); diff --git a/packages/backend/src/designBundle/designBundleAssets.ts b/packages/backend/src/designBundle/designBundleAssets.ts new file mode 100644 index 00000000..e4d8cfdc --- /dev/null +++ b/packages/backend/src/designBundle/designBundleAssets.ts @@ -0,0 +1,146 @@ +import { DesignBundleAsset } from "types"; +import { addWarning } from "../common/commonConversionWarnings"; +import { encodeUtf8Text } from "./designBundleUtils"; + +export interface ExportedDesignBundleAsset { + fileName: string; + bytes: Uint8Array; +} + +export interface DesignBundleAssetExportResult { + exported: ExportedDesignBundleAsset[]; + // Ids (DesignBundleAsset.id) of assets that failed to export — a missing + // node, a getImageByHash miss, or a thrown exportAsync/getBytesAsync call. + // `buildDesignBundle` (designBundleMain.ts) uses this to drop the asset + // from the manifest's `assets[]` (and any DesignNode.assetRef/ + // backgroundAssetRef pointing at it) so `design-bundle.json` never + // references a file that doesn't actually exist in the zip's /assets — + // previously a failed export was only ever logged as a warning, leaving + // the dangling reference in place. + failedAssetIds: string[]; +} + +// Shared with designBundleTree.ts so the manifest's `DesignBundleAsset.scale` +// field always matches the constraint actually passed to `exportAsync` +// below, rather than a second hardcoded "2" drifting out of sync with it. +export const DESIGN_BUNDLE_RASTER_SCALE = 2; + +// Caps how many assets are exported concurrently. Fully sequential export +// makes total time grow linearly with selection size for no benefit — each +// `exportAsync`/`getBytesAsync` call is an independent round trip through +// Figma's renderer, not CPU-bound work competing for the same resource, so a +// small in-flight limit shortens wall-clock time on large selections without +// the unbounded memory/scheduling cost of firing every export at once. +const ASSET_EXPORT_CONCURRENCY = 4; + +/** + * Explicit Images-API asset export. FigmaToCode's default codegen path + * leaves image `src` as placehold.co placeholders and never calls + * `exportAsync` for plain layout/text output — the Design Bundle needs real + * files regardless of which codegen path (if any) is otherwise in use, so + * this is a standalone step over the asset manifest `buildDesignNode` + * already collected, not a reuse of any HTML/Tailwind/etc. image handling. + * + * Raster (IMAGE) nodes export as PNG at 2x. Vector + * (VECTOR/STAR/POLYGON/BOOLEAN_OPERATION/LINE) nodes export as SVG so a + * downstream consumer can inline them directly instead of rasterizing. + * Exports run with bounded concurrency (see ASSET_EXPORT_CONCURRENCY) rather + * than one at a time. + */ +export const exportDesignBundleAssets = async ( + assets: DesignBundleAsset[], +): Promise => { + const exported: ExportedDesignBundleAsset[] = []; + const failedAssetIds: string[] = []; + + const exportOne = async (asset: DesignBundleAsset): Promise => { + // A background-image asset (DesignNode.backgroundAssetRef, not + // assetRef) carries `imageHash` instead — resolved via + // `figma.getImageByHash`, not `node.exportAsync()`. The containing + // node also has real child content painted on top of this fill (the + // whole reason it's a background-image asset rather than a normal + // leaf IMAGE asset — see designBundleTree.ts's matching comment on + // `backgroundAssetRef`), so exporting *that node* would flatten the + // children into the raster too. `getImageByHash` resolves the fill's + // own raw bytes directly, independent of anything else the node + // renders. Figma's REST API v1 calls this same value `imageRef`; the + // Plugin API's `getImageByHash` accepts it under the name `hash` — + // same underlying image reference. + if (asset.imageHash) { + try { + const image = figma.getImageByHash(asset.imageHash); + if (!image) { + addWarning( + `Could not export background-image asset (${asset.fileName}) — image hash ${asset.imageHash} not found.`, + ); + failedAssetIds.push(asset.id); + return; + } + const bytes = await image.getBytesAsync(); + exported.push({ fileName: asset.fileName, bytes }); + } catch (error) { + addWarning( + `Failed exporting background-image asset ${asset.fileName}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + failedAssetIds.push(asset.id); + } + return; + } + + const figmaNode = (await figma.getNodeByIdAsync( + asset.figmaNodeId, + )) as (SceneNode & ExportMixin) | null; + + if (!figmaNode || !("exportAsync" in figmaNode)) { + addWarning( + `Could not export asset for node ${asset.figmaNodeId} (${asset.fileName}) — node missing or not exportable.`, + ); + failedAssetIds.push(asset.id); + return; + } + + try { + if (asset.kind === "vector") { + const svg = await figmaNode.exportAsync({ format: "SVG_STRING" }); + exported.push({ + fileName: asset.fileName, + bytes: encodeUtf8Text(svg), + }); + } else { + const bytes = await figmaNode.exportAsync({ + format: "PNG", + constraint: { type: "SCALE", value: DESIGN_BUNDLE_RASTER_SCALE }, + }); + exported.push({ fileName: asset.fileName, bytes }); + } + } catch (error) { + addWarning( + `Failed exporting asset ${asset.fileName}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + failedAssetIds.push(asset.id); + } + }; + + // Simple bounded worker pool: each of up to ASSET_EXPORT_CONCURRENCY + // workers pulls the next asset off a shared cursor and exports it, so at + // most that many exports are ever in flight at once. `exported`/ + // `failedAssetIds` are mutated by `exportOne` directly rather than + // collected per-worker, since downstream consumption (designBundleMain.ts, + // generateDesignBundleZip) keys off `fileName`/`asset.id`, not array order. + let nextIndex = 0; + const worker = async (): Promise => { + while (nextIndex < assets.length) { + const asset = assets[nextIndex]; + nextIndex += 1; + await exportOne(asset); + } + }; + const workerCount = Math.min(ASSET_EXPORT_CONCURRENCY, assets.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + + return { exported, failedAssetIds }; +}; diff --git a/packages/backend/src/designBundle/designBundleMain.ts b/packages/backend/src/designBundle/designBundleMain.ts new file mode 100644 index 00000000..6d4561a9 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleMain.ts @@ -0,0 +1,169 @@ +import { DesignBundle, DesignBundleAsset, DesignBundleStyles, DesignNode, PluginSettings } from "types"; +import { nodesToJSON } from "../altNodes/jsonNodeConversion"; +import { addWarning, clearWarnings, warnings } from "../common/commonConversionWarnings"; +import { buildDesignNode, resetDesignBundleTreeState } from "./designBundleTree"; +import { collectTextStyleIds, resolveTextStyles } from "./designBundleTextStyles"; +import { exportDesignBundleAssets } from "./designBundleAssets"; +import { generateDesignBundleZip } from "./designBundleZip"; + +// Clears assetRef/backgroundAssetRef on any node pointing at an asset that +// failed to export (see exportDesignBundleAssets' failedAssetIds) — run +// after filtering those ids out of the manifest's assets[] so a design's +// nodes never reference an asset id that no longer appears anywhere in the +// bundle (the whole point of the failedAssetIds plumbing; filtering +// assets[] alone would just move the dangling reference from assets[] to +// designs[].root...children[]). +const clearFailedAssetRefs = (node: DesignNode, failedAssetIds: Set) => { + if (node.assetRef && failedAssetIds.has(node.assetRef)) { + delete node.assetRef; + } + if (node.backgroundAssetRef && failedAssetIds.has(node.backgroundAssetRef)) { + delete node.backgroundAssetRef; + } + for (const child of node.children ?? []) { + clearFailedAssetRefs(child, failedAssetIds); + } +}; + +export const DESIGN_BUNDLE_SOURCE_TOOL = "FigmaToCode-fork/design-bundle@0.1.0"; + +const toKebab = (value: string) => + (value || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); + +export interface DesignBundleExportResult { + zip: Uint8Array; + fileName: string; + designCount: number; + assetCount: number; + warnings: string[]; +} + +/** + * Entry point: turns the current Figma selection into a Design Bundle zip + * (design-bundle.json + /assets). + * + * Reuses `nodesToJSON` for the actual node-tree normalization (Auto Layout, + * variables, styled text segments, empty-frame flattening, GROUP inlining — + * all already handled there and already multi-selection-safe) rather than + * re-deriving any of that. This module's only job is mapping that + * AltNode-shaped output onto the bundle's `DesignNode` shape and wiring up + * the explicit asset-export step (exportDesignBundleAssets, below). + */ +export const buildDesignBundle = async ( + selection: readonly SceneNode[], + settings: PluginSettings, +): Promise => { + if (selection.length === 0) { + throw new Error("Please select at least one layer to export."); + } + + clearWarnings(); + resetDesignBundleTreeState(); + + const convertedSelection = await nodesToJSON(selection, settings); + + if (convertedSelection.length !== selection.length) { + // nodesToJSON can return more entries than the input selection when a + // top-level GROUP gets inlined into multiple sibling nodes (see + // jsonNodeConversion.ts) — a top-level GROUP breaks the otherwise + // clean 1:1 mapping between selected layers and designs[] entries. + // Matched by node id below (rather than array index) so this doesn't + // silently pair a converted entry with the wrong original selection + // layer once the two arrays are out of step. + console.warn( + "[design-bundle] convertedSelection count does not match selection count " + + "(likely a top-level GROUP was inlined) — matching by node id instead of index.", + ); + } + + // Keyed by id so a converted entry is only ever paired with the + // selected layer it actually came from — an index-based lookup + // (`selection[index]`) silently drifts out of alignment as soon as one + // top-level GROUP expands into multiple entries, pairing every + // subsequent design with the wrong original layer's name instead of + // just failing to find one. + const selectionById = new Map(selection.map((s) => [s.id, s])); + + const assets: DesignBundleAsset[] = []; + const styles: DesignBundleStyles = { colors: {}, textStyles: {} }; + + const designs = convertedSelection.map((node: any) => { + const root = buildDesignNode(node, assets, styles, undefined); + const originalNode = selectionById.get(root.id); + return { + figmaNodeId: root.id, + // Raw, as-authored Figma layer name only — no slug/title. + // Falls back to the converted node's own name when no original + // selection entry shares this id (e.g. this design came from an + // inlined GROUP's child, which was never itself a top-level + // selection entry — see mismatch note above). + layerName: originalNode?.name ?? node.name ?? root.uniqueName, + root, + }; + }); + + // Named-text-style resolution: a separate async pass after tree-building, + // since Figma's style lookup (getStyleByIdAsync) is async and + // buildDesignNode itself is kept synchronous (see designBundleTextStyles.ts). + const textStyleIds = new Set(); + for (const design of designs) { + collectTextStyleIds(design.root, textStyleIds); + } + const textStyleWarnings = await resolveTextStyles(textStyleIds, styles.textStyles); + // Routed through addWarning (not a bare console.warn) so these actually + // reach the plugin UI's WarningsPanel — a bare console.warn here would + // never surface these to the user. + for (const w of textStyleWarnings) addWarning(w); + + const { exported: exportedAssets, failedAssetIds } = await exportDesignBundleAssets(assets); + + // Drop any asset that failed to export from the manifest — otherwise + // design-bundle.json lists an asset with no corresponding file in the + // zip's /assets (exportDesignBundleAssets already logged a warning for + // each one via addWarning). Also clear any assetRef/backgroundAssetRef + // in the design tree that pointed at one of these, so nothing in the + // manifest references a dropped id. + const failedAssetIdSet = new Set(failedAssetIds); + const finalAssets = + failedAssetIdSet.size > 0 + ? assets.filter((asset) => !failedAssetIdSet.has(asset.id)) + : assets; + if (failedAssetIdSet.size > 0) { + for (const design of designs) { + clearFailedAssetRefs(design.root, failedAssetIdSet); + } + } + + const bundle: DesignBundle = { + schemaVersion: 1, + meta: { + figmaFileKey: figma.fileKey ?? "", + figmaFileName: figma.root.name, + figmaPageName: figma.currentPage.name, + exportedAt: new Date().toISOString(), + exportedBy: DESIGN_BUNDLE_SOURCE_TOOL, + sourceTool: "FigmaToCode-fork", + }, + designs, + assets: finalAssets, + styles, + }; + + const zip = generateDesignBundleZip(bundle, exportedAssets); + const rootLabel = + designs.length === 1 + ? toKebab(designs[0].layerName) + : toKebab(figma.currentPage.name) || "design-bundle"; + const fileName = `${rootLabel || "design-bundle"}-design-bundle.zip`; + + return { + zip, + fileName, + designCount: designs.length, + assetCount: finalAssets.length, + warnings: [...warnings], + }; +}; diff --git a/packages/backend/src/designBundle/designBundleTextStyles.ts b/packages/backend/src/designBundle/designBundleTextStyles.ts new file mode 100644 index 00000000..7ce29d42 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleTextStyles.ts @@ -0,0 +1,99 @@ +import { DesignBundleTextStyle, DesignNode } from "types"; +import { commonLineHeight } from "../common/commonTextHeightSpacing"; + +/** + * Best-effort numeric font-weight string from a Figma FontName's `style` + * (e.g. "Regular", "Semi Bold", "Black Italic"). Figma's TextStyle object + * has no numeric weight field directly — only the human-readable style + * name — so this is a keyword match, most-specific pattern first (checking + * "semi bold" before the plainer "bold" substring, etc.). Falls back to + * "400" for anything unrecognized rather than guessing further. + */ +export const fontStyleToWeight = (styleName: string | undefined): string => { + const style = (styleName ?? "").toLowerCase(); + const patterns: Array<[RegExp, string]> = [ + [/thin/, "100"], + [/extra ?light|ultra ?light/, "200"], + [/\blight\b/, "300"], + [/medium/, "500"], + [/extra ?bold|ultra ?bold/, "800"], + [/semi ?bold|demi ?bold/, "600"], + [/\bbold\b/, "700"], + [/black|heavy/, "900"], + [/regular|normal/, "400"], + ]; + for (const [pattern, weight] of patterns) { + if (pattern.test(style)) return weight; + } + return "400"; +}; + +/** Recursively collects every distinct textStyleId referenced by a design's TEXT nodes. */ +export const collectTextStyleIds = (node: DesignNode, into: Set = new Set()): Set => { + for (const segment of node.text?.segments ?? []) { + if (segment.textStyleId) into.add(segment.textStyleId); + } + for (const child of node.children) { + collectTextStyleIds(child, into); + } + return into; +}; + +/** + * Resolves a set of textStyleIds against Figma's style registry + * (`getStyleByIdAsync`) into the bundle's `styles.textStyles` dictionary. + * Done as a separate pass after tree-building rather than inline in + * `buildDesignNode`, since `buildDesignNode` is synchronous (matches the + * existing colors/variables handling in `designBundleTree.ts`, which never + * needs an async call because bound-variable data is already present + * synchronously on the paint object) and style resolution requires an + * async Figma API call. Failures for an individual id are logged and + * skipped rather than aborting the whole export — a missing/deleted style + * shouldn't block the bundle. + */ +export const resolveTextStyles = async ( + textStyleIds: ReadonlySet, + target: Record, +): Promise => { + const warnings: string[] = []; + + await Promise.all( + Array.from(textStyleIds).map(async (id) => { + if (target[id]) return; + try { + const style = await figma.getStyleByIdAsync(id); + if (!style || style.type !== "TEXT") { + warnings.push(`[design-bundle] textStyleId "${id}" did not resolve to a text style — skipped.`); + return; + } + const textStyle = style as TextStyle; + const fontSize = textStyle.fontSize ?? 0; + // Same unit as DesignBundleTextSegment.lineHeight (a px-per-fontSize + // ratio, not raw px/percent) — computed the same way mapTextSegments + // does in designBundleTree.ts, via the shared commonLineHeight + // helper, so both are directly comparable. + let lineHeightRatio = 0; + try { + const lineHeightPx = textStyle.lineHeight ? commonLineHeight(textStyle.lineHeight, fontSize) : 0; + lineHeightRatio = fontSize > 0 ? (lineHeightPx || 0) / fontSize : 0; + } catch { + lineHeightRatio = 0; + } + + target[id] = { + name: textStyle.name, + fontFamily: textStyle.fontName?.family ?? "", + fontSize, + fontWeight: fontStyleToWeight(textStyle.fontName?.style), + lineHeight: lineHeightRatio, + }; + } catch (error) { + warnings.push( + `[design-bundle] Failed to resolve textStyleId "${id}": ${(error as Error).message}`, + ); + } + }), + ); + + return warnings; +}; diff --git a/packages/backend/src/designBundle/designBundleTree.ts b/packages/backend/src/designBundle/designBundleTree.ts new file mode 100644 index 00000000..29fba629 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleTree.ts @@ -0,0 +1,679 @@ +import { + DesignBundleAsset, + DesignBundleBlendMode, + DesignBundleColorStyle, + DesignBundleEffect, + DesignBundleFill, + DesignBundleGradient, + DesignBundleNodeStyle, + DesignBundleStyles, + DesignBundleTextSegment, + DesignNode, + DesignNodeType, +} from "types"; +import { commonLetterSpacing, commonLineHeight } from "../common/commonTextHeightSpacing"; +import { DESIGN_BUNDLE_RASTER_SCALE } from "./designBundleAssets"; + +// The tree produced by `nodesToJSON` (packages/backend/src/altNodes/jsonNodeConversion.ts) +// is a standard Figma REST API v1 `Node` (packages/backend/src/api_types.ts) plus a handful +// of AltNode extras (`x/y/width/height`, `uniqueName`, `cumulativeRotation`, `canBeFlattened`, +// `styledTextSegments`). There is no single exported type for that combination, so we work +// against a loosely-typed shape here rather than fighting the type system — consistent with +// how the rest of the backend (code.ts, jsonNodeConversion.ts) already treats +// `convertedSelection` as `any`. +export type ConvertedNode = any; + +const VECTOR_LIKE_TYPES = new Set([ + "VECTOR", + "STAR", + "POLYGON", + "BOOLEAN_OPERATION", + "LINE", +]); + +let assetCounter = 0; +let nameCounters: Map = new Map(); +// Primary asset-dedup mechanism — keyed on the node's identity *within +// its master Component definition*, not on the specific Instance's own node +// id. See assetIdentityKeyFor's doc comment below for the ID-shape this +// relies on. Session-scoped, same lifetime/reset semantics as +// assetCounter/nameCounters above. +let assetIdentityMap: Map = new Map(); + +export const resetDesignBundleTreeState = () => { + assetCounter = 0; + nameCounters = new Map(); + assetIdentityMap = new Map(); +}; + +// Figma's REST API v1 (what nodesToJSON's whole tree is built from — +// see the ConvertedNode comment above) gives every node *inside* an +// Instance an id of the shape `I{instanceId};{masterChildId}` — confirmed +// directly against real exported bundles (e.g. `I2011:161;1:1468`). The +// part after the first semicolon is that node's own id *inside the master +// Component definition*, and is identical across every Instance of that +// component regardless of which design placed it — Figma's node-id space is +// unique file-wide, so this substring alone (no separate componentId lookup +// needed) already uniquely identifies "the same original node." A node +// that's directly part of a design's own tree (not inside any Instance) has +// a plain id with no semicolon and never matches — it is always exported +// fresh. +// +// Deliberately identity-based, not content-based: a downstream consumer is +// free to layer a separate content-hash pass on top for anything this +// doesn't explain. This only recognizes "the same node position inside the +// same component," and deliberately assumes no per-instance content +// overrides on shared header/footer content. A real override would +// currently dedupe silently wrong; revisit if that assumption ever proves +// false in practice. +const INSTANCE_DESCENDANT_ID = /^I[^;]+;(.+)$/; +const assetIdentityKeyFor = (nodeId: string): string | undefined => + INSTANCE_DESCENDANT_ID.exec(nodeId)?.[1]; + +const toSlug = (value: string) => + (value || "layer") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "layer"; + +const nextAssetFileName = (uniqueName: string, ext: string): string => { + assetCounter += 1; + const slug = toSlug(uniqueName); + const count = (nameCounters.get(slug) ?? 0) + 1; + nameCounters.set(slug, count); + const suffix = String(count).padStart(2, "0"); + return `assets/${slug}-${suffix}.${ext}`; +}; + +const rgbToHex = (color: { r: number; g: number; b: number }): string => { + const toHex = (channel: number) => + Math.round(Math.max(0, Math.min(1, channel)) * 255) + .toString(16) + .padStart(2, "0"); + return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`.toUpperCase(); +}; + +const rgbaToHex8 = (color: { r: number; g: number; b: number; a?: number }): string => { + const alpha = color.a ?? 1; + const toHex = (channel: number) => + Math.round(Math.max(0, Math.min(1, channel)) * 255) + .toString(16) + .padStart(2, "0"); + return `${rgbToHex(color)}${toHex(alpha)}`; +}; + +const findImageFill = (node: ConvertedNode): any | undefined => { + const fills = node.fills; + if (!Array.isArray(fills)) return undefined; + return fills.find((fill: any) => fill?.type === "IMAGE" && fill.visible !== false); +}; + +const hasImageFill = (node: ConvertedNode): boolean => findImageFill(node) !== undefined; + +const hasRealChildren = (node: ConvertedNode): boolean => + Array.isArray(node.children) && node.children.length > 0; + +const classifyNodeType = (node: ConvertedNode): DesignNodeType => { + if (node.type === "TEXT") return "TEXT"; + if (VECTOR_LIKE_TYPES.has(node.type)) return "VECTOR"; + // Only collapse an image-filled node to a flattened IMAGE leaf when it has + // no real children. Originally this collapsed *any* image-filled node + // regardless of children — validated against a synthetic "hero banner with + // an overlaid heading" fixture and found to silently drop the heading, a + // real content-loss bug. A frame with both an image fill and child + // content now stays a FRAME so its children survive; the background + // image itself is still not representable in style.fills (schema only + // models solid/gradient fills) — see the `backgroundAssetRef` handling + // further down for how that gap is covered instead. + if (hasImageFill(node) && !hasRealChildren(node)) return "IMAGE"; + if (node.type === "RECTANGLE" || node.type === "ELLIPSE") return "RECTANGLE"; + return "FRAME"; +}; + +const resolveCornerRadius = (node: ConvertedNode): number => { + if (typeof node.cornerRadius === "number") return node.cornerRadius; + if (Array.isArray(node.rectangleCornerRadii)) { + const [topLeft, topRight, bottomRight, bottomLeft] = node.rectangleCornerRadii; + if (topLeft === topRight && topLeft === bottomRight && topLeft === bottomLeft) { + return topLeft ?? 0; + } + // Schema v1 only carries a single cornerRadius number — non-uniform + // corners are approximated by their largest corner rather than dropped. + return Math.max(topLeft ?? 0, topRight ?? 0, bottomRight ?? 0, bottomLeft ?? 0); + } + if (typeof node.topLeftRadius === "number") { + return Math.max( + node.topLeftRadius ?? 0, + node.topRightRadius ?? 0, + node.bottomRightRadius ?? 0, + node.bottomLeftRadius ?? 0, + ); + } + return 0; +}; + +// Figma's `paint.color.a` (alpha baked into the fill's own color) and +// `paint.opacity` (the fill's separate "opacity" slider) are two distinct +// fields that blend together — Figma's own doc comment on Paint.opacity: +// "colors within the paint can also have opacity values which would blend +// with this" — so they're combined into one effective alpha here, at the +// point of capture, rather than carried through as two separate numbers +// with no real downstream use for keeping them apart. `undefined` (not just +// `1`) is treated as "fully opaque" for both, matching Figma's own default. +const fillOpacity = (paint: any): number | undefined => { + const colorAlpha = typeof paint.color?.a === "number" ? paint.color.a : 1; + const paintOpacity = typeof paint.opacity === "number" ? paint.opacity : 1; + const combined = colorAlpha * paintOpacity; + return combined < 1 ? combined : undefined; +}; + +// The three gradient kinds CSS can render natively. GRADIENT_DIAMOND is +// deliberately absent — no CSS equivalent, so it's left collapsed to a flat +// fallback color rather than approximated. +const GRADIENT_KIND_BY_PAINT_TYPE: Record = { + GRADIENT_LINEAR: "LINEAR", + GRADIENT_RADIAL: "RADIAL", + GRADIENT_ANGULAR: "ANGULAR", +}; + +// Structured gradient data (stops + Figma's own raw handle geometry, +// unconverted — see DesignBundleGradient's doc comment in types.ts for why +// the trig stays out of this step). Returns undefined for GRADIENT_DIAMOND, +// any unrecognized gradient kind, or if Figma's own gradientStops/ +// gradientHandlePositions are missing on this paint — mapFill's caller +// still gets a flat `hex` fallback in every case via the first stop. +const mapGradient = (paint: any): DesignBundleGradient | undefined => { + const kind = GRADIENT_KIND_BY_PAINT_TYPE[paint.type as string]; + if (!kind) return undefined; + const stops = Array.isArray(paint.gradientStops) ? paint.gradientStops : []; + const handles = Array.isArray(paint.gradientHandlePositions) ? paint.gradientHandlePositions : []; + if (stops.length === 0 || handles.length === 0) return undefined; + const paintOpacity = typeof paint.opacity === "number" ? paint.opacity : 1; + return { + kind, + stops: stops.map((stop: any) => ({ + hex: rgbaToHex8({ ...stop.color, a: (stop.color?.a ?? 1) * paintOpacity }), + position: typeof stop.position === "number" ? stop.position : 0, + })), + handles: handles.map((handle: any) => ({ x: handle?.x ?? 0, y: handle?.y ?? 0 })), + }; +}; + +const mapFill = ( + paint: any, + styles: DesignBundleStyles, +): DesignBundleFill | null => { + if (!paint || paint.visible === false) return null; + if (paint.type === "IMAGE") return null; // handled via node.assetRef instead + + const variableId: string | undefined = paint.boundVariables?.color?.id; + if (variableId && !styles.colors[variableId]) { + const entry: DesignBundleColorStyle = { + name: paint.boundVariables?.color?.name ?? variableId, + hex: paint.color ? rgbToHex(paint.color) : "#000000", + }; + styles.colors[variableId] = entry; + } + + if (paint.type === "SOLID") { + return { + type: "SOLID", + hex: paint.color ? rgbToHex(paint.color) : undefined, + variableRef: variableId, + opacity: fillOpacity(paint), + }; + } + + if (typeof paint.type === "string" && paint.type.startsWith("GRADIENT")) { + // Always carry a flat-color fallback — the first stop's own + // color, with its alpha already combined with the paint's overall + // opacity, as an 8-digit hex so no separate `opacity` field is + // needed on the fallback either. Covers GRADIENT_DIAMOND and any + // future gradient kind a downstream consumer can't render as real CSS. + // Without this, any gradient-filled node would render with *no* + // background whatsoever — not just for the GRADIENT_DIAMOND case. + const firstStopColor = Array.isArray(paint.gradientStops) ? paint.gradientStops[0]?.color : undefined; + const paintOpacity = typeof paint.opacity === "number" ? paint.opacity : 1; + const fallbackHex = firstStopColor + ? rgbaToHex8({ ...firstStopColor, a: (firstStopColor.a ?? 1) * paintOpacity }) + : undefined; + return { + type: "GRADIENT", + hex: fallbackHex, + variableRef: variableId, + gradient: mapGradient(paint), + }; + } + + return { type: "OTHER", variableRef: variableId, opacity: fillOpacity(paint) }; +}; + +const mapStrokes = (node: ConvertedNode) => { + const strokes = Array.isArray(node.strokes) ? node.strokes : []; + const weight = typeof node.strokeWeight === "number" ? node.strokeWeight : 1; + return strokes + .filter((stroke: any) => stroke?.visible !== false && stroke?.color) + .map((stroke: any) => ({ hex: rgbToHex(stroke.color), weight })); +}; + +const mapEffects = (node: ConvertedNode): DesignBundleEffect[] => { + const effects = Array.isArray(node.effects) ? node.effects : []; + return effects + .filter((effect: any) => effect?.visible !== false) + .map((effect: any) => { + if (effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW") { + return { + type: effect.type, + x: effect.offset?.x ?? 0, + y: effect.offset?.y ?? 0, + blur: effect.radius ?? 0, + hex: effect.color ? rgbaToHex8(effect.color) : undefined, + // Only meaningful for shadows — Figma's own `spread`, already + // present on the raw effect object, is carried straight + // through here. + spread: typeof effect.spread === "number" ? effect.spread : undefined, + }; + } + return { type: effect.type, blur: effect.radius ?? 0 }; + }); +}; + +// The node's own layer opacity (`HasBlendModeAndOpacityTrait.opacity` +// in the REST API v1 shape — every node type carries this), distinct from +// any individual fill's opacity above (see DesignBundleNodeStyle.opacity's +// doc comment in types.ts for why these aren't collapsed together). +// `undefined`/missing is Figma's own default for "fully opaque." +const nodeOpacity = (node: ConvertedNode): number | undefined => { + const value = typeof node.opacity === "number" ? node.opacity : 1; + return value < 1 ? value : undefined; +}; + +// Figma's 18 `BlendMode` values -> the 13 CSS `mix-blend-mode` has a +// native keyword for. PASS_THROUGH/NORMAL map to `undefined` (no +// blending, same as this schema's other sparse-field opacity/gradient +// conventions) rather than being listed here with no value — they're +// absent from this table entirely, so the fallthrough `undefined` return +// below covers them along with LINEAR_BURN/LINEAR_DODGE (no CSS +// equivalent) and any future/unrecognized blend mode. +const CSS_BLEND_MODE_BY_FIGMA_BLEND_MODE: Record = { + MULTIPLY: "multiply", + SCREEN: "screen", + OVERLAY: "overlay", + DARKEN: "darken", + LIGHTEN: "lighten", + COLOR_DODGE: "color-dodge", + COLOR_BURN: "color-burn", + HARD_LIGHT: "hard-light", + SOFT_LIGHT: "soft-light", + DIFFERENCE: "difference", + EXCLUSION: "exclusion", + HUE: "hue", + SATURATION: "saturation", + COLOR: "color", + LUMINOSITY: "luminosity", +}; + +const nodeBlendMode = (node: ConvertedNode): DesignBundleBlendMode | undefined => { + return CSS_BLEND_MODE_BY_FIGMA_BLEND_MODE[node.blendMode as string]; +}; + +const mapStyle = ( + node: ConvertedNode, + styles: DesignBundleStyles, +): DesignBundleNodeStyle => { + const fills = Array.isArray(node.fills) + ? (node.fills + .map((fill: any) => mapFill(fill, styles)) + .filter(Boolean) as DesignBundleFill[]) + : []; + return { + fills, + strokes: mapStrokes(node), + cornerRadius: resolveCornerRadius(node), + effects: mapEffects(node), + opacity: nodeOpacity(node), + blendMode: nodeBlendMode(node), + }; +}; + +const sizingValue = ( + sizingMode: string | undefined, + fixedValue: number | undefined, +): "fill" | "hug" | number => { + if (sizingMode === "FILL") return "fill"; + if (sizingMode === "HUG") return "hug"; + return typeof fixedValue === "number" ? Math.round(fixedValue) : 0; +}; + +const mapTextSegments = ( + node: ConvertedNode, + uniqueName: string, + styles: DesignBundleStyles, +): DesignBundleTextSegment[] => { + const segments = Array.isArray(node.styledTextSegments) + ? node.styledTextSegments + : []; + + if (segments.length === 0) { + // Fallback for nodes where per-run segmentation wasn't collected + // (see jsonNodeConversion.ts — segments are only gathered when the + // source node's style actually varies at the run level). `node.style` + // here is the raw REST API v1 `TypeStyle` (see jsonNodeConversion.ts — + // `Object.assign(jsonNode, jsonNode.style)` — `style` itself survives + // alongside the flattened copy), which does carry `lineHeightPx` + // (declared in api_types.ts) even though it isn't read elsewhere in + // this file — compute the same px-per-fontSize ratio the segmented + // path below uses instead of hardcoding 0, which silently dropped + // line-height for any text node without per-run style variation. + const fallbackFill = mapFill(node.fills?.[0], styles); + const fallbackFontSize = node.style?.fontSize ?? 0; + const fallbackLineHeightPx = node.style?.lineHeightPx; + const fallbackLineHeight = + typeof fallbackLineHeightPx === "number" && fallbackFontSize > 0 + ? fallbackLineHeightPx / fallbackFontSize + : 0; + return [ + { + uniqueId: `${uniqueName}_span`, + characters: node.characters ?? "", + fontFamily: node.style?.fontFamily ?? "", + fontSize: fallbackFontSize, + fontWeight: String(node.style?.fontWeight ?? "400"), + lineHeight: fallbackLineHeight, + letterSpacing: node.style?.letterSpacing ?? 0, + textCase: node.style?.textCase ?? "ORIGINAL", + textDecoration: node.style?.textDecoration ?? "NONE", + fillHex: fallbackFill?.hex, + fillRef: fallbackFill?.variableRef, + fillOpacity: fallbackFill?.opacity, + }, + ]; + } + + return segments.map((segment: any, index: number) => { + const fontSize = segment.fontSize ?? 0; + const lineHeightPx = segment.lineHeight + ? safeLineHeight(segment.lineHeight, fontSize) + : 0; + const letterSpacing = segment.letterSpacing + ? safeLetterSpacing(segment.letterSpacing, fontSize) + : 0; + + // Reuses mapFill (same hex+variableRef resolution node-level fills + // already get, including registering variable-bound colors into + // styles.colors) rather than only grabbing the variable id like + // before — that silently dropped color entirely for any text run + // using a plain, non-variable-bound color, which is the common case. + const textFill = mapFill(segment.fills?.[0], styles); + + return { + // The converter (jsonNodeConversion.ts) already assigns each segment a + // `uniqueId` — 1-based, zero-padded (`_span_01`, `_span_02`, ...) for + // multi-segment text, `_span` for a lone segment. Prefer that value + // over regenerating one here (0-based, unpadded) so the two don't + // disagree; only fall back to a freshly generated id if the segment + // somehow arrived without one. + uniqueId: segment.uniqueId ?? `${uniqueName}_span_${index}`, + characters: segment.characters ?? "", + fontFamily: segment.fontName?.family ?? segment.fontFamily ?? "", + fontSize, + fontWeight: String(segment.fontWeight ?? "400"), + lineHeight: fontSize > 0 ? lineHeightPx / fontSize : 0, + letterSpacing, + textCase: segment.textCase ?? "ORIGINAL", + textDecoration: segment.textDecoration ?? "NONE", + fillHex: textFill?.hex, + fillRef: textFill?.variableRef, + fillOpacity: textFill?.opacity, + // Already requested in getStyledTextSegments' field list + // (jsonNodeConversion.ts) and threaded straight through here. + textStyleId: segment.textStyleId || undefined, + }; + }); +}; + +// Wrapped so a malformed/unexpected LineHeight or LetterSpacing shape +// (e.g. from a node that isn't a real live Figma TEXT node, seen while +// testing against non-Auto-Layout content) degrades to 0 instead +// of throwing and aborting the whole export. +const safeLineHeight = (lineHeight: any, fontSize: number): number => { + try { + return commonLineHeight(lineHeight, fontSize) || 0; + } catch { + return 0; + } +}; +const safeLetterSpacing = (letterSpacing: any, fontSize: number): number => { + try { + return commonLetterSpacing(letterSpacing, fontSize) || 0; + } catch { + return 0; + } +}; + +/** + * Recursively converts one converted (AltNode-shaped) tree into a Design + * Bundle `DesignNode` tree. Mutates `assets` and `styles` as it walks, + * collecting an assets manifest for IMAGE/VECTOR leaves, and a resolved + * colors dictionary for anything bound to a Figma variable. + */ +export const buildDesignNode = ( + node: ConvertedNode, + assets: DesignBundleAsset[], + styles: DesignBundleStyles, + parentLayoutMode: string | undefined, + // This node's index among its original parent's children (Figma's + // paint/z-order — see the `paintOrder` field doc in types.ts). Only the + // recursive call site below passes this; the root call + // (designBundleMain.ts) omits it, since a `designs[].root` entry has no + // real siblings within the bundle. + siblingIndex?: number, +): DesignNode => { + const uniqueName: string = node.uniqueName ?? node.name ?? node.id; + const type = classifyNodeType(node); + + const layout: DesignNode["layout"] = { + mode: (node.layoutMode as any) ?? "NONE", + primaryAxisAlign: (node.primaryAxisAlignItems as any) ?? "MIN", + counterAxisAlign: (node.counterAxisAlignItems as any) ?? "MIN", + gap: node.itemSpacing ?? 0, + padding: { + top: node.paddingTop ?? 0, + right: node.paddingRight ?? 0, + bottom: node.paddingBottom ?? 0, + left: node.paddingLeft ?? 0, + }, + sizing: { + width: sizingValue(node.layoutSizingHorizontal, node.width), + height: sizingValue(node.layoutSizingVertical, node.height), + }, + }; + // Figma's Auto Layout wrap — `NO_WRAP` (the default) is never + // recorded, matching this schema's general convention for + // default-valued fields. `counterAxisSpacing` (row gap) only has real + // meaning when wrap is on. + if (node.layoutWrap === "WRAP") { + layout.wrap = true; + if (typeof node.counterAxisSpacing === "number") { + layout.rowGap = node.counterAxisSpacing; + } + } + // Position carries meaning when either the *parent* lays its children out + // freely (mode NONE), or this specific node opts out of its parent's Auto + // Layout flow (`layoutPositioning: "ABSOLUTE"`, Figma's per-child escape + // hatch available even inside a HORIZONTAL/VERTICAL auto-layout parent). + // The first version of this check only looked at the parent's overall + // mode and silently dropped x/y for absolutely-positioned children of an + // auto-layout frame — caught by a synthetic "decorative blob inside a + // vertical form" fixture. Root designs[] entries have no parent, so + // position is always included there. + // `inlinedFromGroup` is a Design-Bundle-only flag set by + // jsonNodeConversion.ts for children of an inlined GROUP (see its + // comment there) — kept separate from the real `layoutPositioning` + // field so this bundle-specific treatment doesn't leak into the other + // codegen targets that share that conversion path. + const isAbsoluteInAutoLayout = + node.layoutPositioning === "ABSOLUTE" || node.inlinedFromGroup === true; + if ( + parentLayoutMode === undefined || + parentLayoutMode === "NONE" || + isAbsoluteInAutoLayout + ) { + layout.position = { + x: Math.round(node.x ?? 0), + y: Math.round(node.y ?? 0), + }; + } + + const designNode: DesignNode = { + id: node.id, + uniqueName, + type, + layout, + style: mapStyle(node, styles), + children: [], + // Index within *this specific call's* parent — i.e. relative to + // whatever `node`'s immediate parent was at the point this walk + // reached it. Never a global/whole-tree counter. That single, uniform + // rule is what makes this work correctly both for a repeated + // component's own internal children (e.g. a header's logo/nav/button + // get 0/1/2, relative to the header — correct regardless of which + // design the header came from, or how many designs reuse the same + // header) *and* for the case where the header node itself, as it sits + // in one specific design's root.children, carries its own paintOrder + // equal to its index in *that* design's root — the value a downstream + // consumer needs to remember where the header used to sit if it ever + // extracts that node out of the array entirely. + paintOrder: siblingIndex, + }; + + // Capture Figma's main-component id, independent of what `type` + // above collapsed to. Already present on the REST-v1 JSON export this + // whole tree is built from (api_types.ts's InstanceNode shape) — no + // extra Figma API call required. + // + // Two cases, both need to resolve to the *same* id so an instance and + // its own main component group together: + // - INSTANCE nodes carry `componentId`, pointing at their main + // component's node id. + // - The main COMPONENT (or COMPONENT_SET) node itself has no + // `componentId` field — it doesn't reference itself — but Figma's + // `componentId` on an instance *is* the main component's own `id`. So + // a COMPONENT/COMPONENT_SET node self-references its own `id` here. + // Found live: a Figma file's "master" page for a component (where the + // component is actually defined, not just instanced) holds the real + // COMPONENT node, not an INSTANCE — without this, that page's + // header/footer wouldn't group with every other page's instances of + // the same component, breaking cross-design grouping for exactly the + // one design that matters most for defining the part. + if (node.type === "INSTANCE" && typeof node.componentId === "string") { + designNode.componentId = node.componentId; + } else if ( + (node.type === "COMPONENT" || node.type === "COMPONENT_SET") && + typeof node.id === "string" + ) { + designNode.componentId = node.id; + } + + if (type === "TEXT") { + // Only CENTER/RIGHT/JUSTIFIED are ever recorded — LEFT (Figma's + // most common default) is deliberately omitted rather than captured + // as an explicit "LEFT" value, matching this schema's general + // convention of never emitting a value that's already the default. + const align = + node.textAlignHorizontal === "CENTER" || + node.textAlignHorizontal === "RIGHT" || + node.textAlignHorizontal === "JUSTIFIED" + ? node.textAlignHorizontal + : undefined; + designNode.text = { segments: mapTextSegments(node, uniqueName, styles), ...(align ? { align } : {}) }; + } + + if (type === "IMAGE" || type === "VECTOR") { + // Reuse an already-registered asset for the same master-component + // node, rather than re-exporting/re-registering an identical copy for + // every Instance. See assetIdentityKeyFor's doc comment. + const identityKey = assetIdentityKeyFor(node.id); + const existing = identityKey ? assetIdentityMap.get(identityKey) : undefined; + if (existing) { + designNode.assetRef = existing.id; + return designNode; + } + + const ext = type === "IMAGE" ? "png" : "svg"; + const fileName = nextAssetFileName(uniqueName, ext); + const assetId = `asset_${String(assets.length + 1).padStart(2, "0")}`; + const asset: DesignBundleAsset = { + id: assetId, + figmaNodeId: node.id, + fileName, + kind: type === "IMAGE" ? "raster" : "vector", + width: Math.round(node.width ?? 0), + height: Math.round(node.height ?? 0), + // Only raster (PNG) exports have a fixed pixel scale relative to + // `width`/`height` above — see exportDesignBundleAssets. Vector + // (SVG) assets scale losslessly, so `scale` is left unset for those. + ...(type === "IMAGE" ? { scale: DESIGN_BUNDLE_RASTER_SCALE } : {}), + }; + assets.push(asset); + if (identityKey) { + assetIdentityMap.set(identityKey, asset); + } + designNode.assetRef = assetId; + // IMAGE/VECTOR nodes are treated as leaves — matches the schema's own + // examples, and avoids emitting redundant child markup for content a + // downstream consumer would just discard in favor of the exported asset. + return designNode; + } + + // This node stayed a FRAME/RECTANGLE (not collapsed to a leaf IMAGE + // above) specifically because it has real children — see + // classifyNodeType above. That means it can still have its own image + // fill sitting *behind* those children (a photographic hero background + // behind an overlay + heading text, the motivating real case), which + // style.fills never captures (SOLID/GRADIENT only). Registered as a + // distinct asset kind — `imageHash` set, not `figmaNodeId`-exportable the + // normal way — since there's no API to export just this one fill in + // isolation from a node that also has other content painted on top of it. + const backgroundFill = findImageFill(node); + if (backgroundFill && typeof backgroundFill.imageRef === "string") { + // Same identity-based dedup as the leaf IMAGE/VECTOR branch above + // — a repeated component instance's own background-image fill (e.g. a + // Frame background inside a duplicated header/footer) shouldn't be + // re-registered per Instance either. + const identityKey = assetIdentityKeyFor(node.id); + const existing = identityKey ? assetIdentityMap.get(identityKey) : undefined; + if (existing) { + designNode.backgroundAssetRef = existing.id; + } else { + const fileName = nextAssetFileName(`${uniqueName}_bg`, "png"); + const assetId = `asset_${String(assets.length + 1).padStart(2, "0")}`; + // Note: unlike the leaf IMAGE/VECTOR branch above, this asset is + // resolved via `figma.getImageByHash(...).getBytesAsync()` (see + // exportDesignBundleAssets), which returns the fill's own raw image + // bytes as-is — no `exportAsync` SCALE constraint is applied here, + // so `scale` is intentionally left unset rather than assumed to be 2x. + const asset: DesignBundleAsset = { + id: assetId, + figmaNodeId: node.id, + fileName, + kind: "raster", + width: Math.round(node.width ?? 0), + height: Math.round(node.height ?? 0), + imageHash: backgroundFill.imageRef, + }; + assets.push(asset); + if (identityKey) { + assetIdentityMap.set(identityKey, asset); + } + designNode.backgroundAssetRef = assetId; + } + } + + const children = Array.isArray(node.children) ? node.children : []; + designNode.children = children.map((child: ConvertedNode, index: number) => + buildDesignNode(child, assets, styles, layout.mode, index), + ); + + return designNode; +}; diff --git a/packages/backend/src/designBundle/designBundleUtils.ts b/packages/backend/src/designBundle/designBundleUtils.ts new file mode 100644 index 00000000..e7bc8ee4 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleUtils.ts @@ -0,0 +1,19 @@ +import { strToU8 } from "fflate"; + +// Figma's plugin sandbox does not provide the `TextEncoder` global (it's a +// restricted JS environment, not a browser or Node) — confirmed at runtime +// via `TextEncoder is not defined` when exporting SVG assets. Every place +// that needs UTF-8 bytes from a string must go through this manual +// fallback rather than assuming `TextEncoder` exists. +// +// The fallback uses `fflate`'s `strToU8` (already a dependency — see +// designBundleZip.ts's `zipSync` import — so this doesn't pull in anything +// new) instead of the old `unescape(encodeURIComponent(...))` trick, which +// relies on a deprecated global and does the same UTF-8-bytes-from-string +// job less directly. +export const encodeUtf8Text = (text: string): Uint8Array => { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(text); + } + return strToU8(text); +}; diff --git a/packages/backend/src/designBundle/designBundleZip.ts b/packages/backend/src/designBundle/designBundleZip.ts new file mode 100644 index 00000000..125ddb51 --- /dev/null +++ b/packages/backend/src/designBundle/designBundleZip.ts @@ -0,0 +1,31 @@ +import { zipSync } from "fflate"; +import { DesignBundle } from "types"; +import { ExportedDesignBundleAsset } from "./designBundleAssets"; +import { encodeUtf8Text as encodeText } from "./designBundleUtils"; + +/** + * Packages a Design Bundle as a zip: `design-bundle.json` at the root plus + * an `assets/` folder containing every exported raster/vector asset, + * referenced from the manifest by relative path. + */ +export const generateDesignBundleZip = ( + bundle: DesignBundle, + assets: ExportedDesignBundleAsset[], +): Uint8Array => { + const files: Record = { + "design-bundle.json": encodeText(JSON.stringify(bundle, null, 2)), + }; + + for (const asset of assets) { + files[asset.fileName] = asset.bytes; + } + + try { + return zipSync(files, { level: 6 }); + } catch (error) { + console.error("Design bundle zip creation failed:", error); + throw new Error( + "Failed to create design bundle archive. The selection might be too large or complex.", + ); + } +}; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 3a636fb1..9e007360 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -10,3 +10,4 @@ export { } from "./zipGenerator"; export { run } from "./code"; export * from "./messaging"; +export { buildDesignBundle } from "./designBundle/designBundleMain"; diff --git a/packages/plugin-ui/src/PluginUI.tsx b/packages/plugin-ui/src/PluginUI.tsx index 25ac2278..7ba00d4b 100644 --- a/packages/plugin-ui/src/PluginUI.tsx +++ b/packages/plugin-ui/src/PluginUI.tsx @@ -21,7 +21,7 @@ import { } from "./codegenPreferenceOptions"; import Loading from "./components/Loading"; import { useEffect, useState } from "react"; -import { InfoIcon } from "lucide-react"; +import { InfoIcon, PackageOpen, LoaderCircle } from "lucide-react"; import React from "react"; import { Button } from "./components/ui/button"; import { ScrollArea } from "./components/ui/scroll-area"; @@ -44,6 +44,10 @@ type PluginUIProps = { onDownloadProject?: (format: DownloadProjectFormat) => void; isDownloadingProject?: boolean; projectDownloadError?: string | null; + onExportDesignBundle?: () => void; + isExportingDesignBundle?: boolean; + designBundleExportError?: string | null; + designBundleWarnings?: Warning[]; }; const frameworks: Framework[] = ["HTML", "Tailwind", "Flutter", "SwiftUI"]; @@ -133,6 +137,28 @@ export const PluginUI = (props: PluginUIProps) => { showAbout={showAbout} setShowAbout={setShowAbout} /> + {props.onExportDesignBundle && ( + + )} + {(props.designBundleExportError || + (props.designBundleWarnings?.length ?? 0) > 0) && ( +
+ {props.designBundleExportError && ( +

+ {props.designBundleExportError} +

+ )} + {props.designBundleWarnings && + props.designBundleWarnings.length > 0 && ( + + )} +
+ )}
; +} + +export interface DesignBundleFill { + type: DesignBundleFillType; + hex?: string; + variableRef?: string; + // This fill's own *combined* opacity — Figma's `paint.color.a` (alpha + // baked into the color itself) and `paint.opacity` (the paint's + // separate "opacity" slider) are two distinct fields that blend + // together (Figma's own doc comment on Paint.opacity: "colors within + // the paint can also have opacity values which would blend with + // this"), so they're collapsed into one number here rather than + // carried as two — there's no meaningful reason for a consumer to + // ever want them separately, they represent the same + // "how see-through is this fill" concept. Omitted (undefined) when + // fully opaque (1), matching this schema's existing sparse-field + // convention (e.g. `layout.position`). Deliberately NOT collapsed + // together with the node's own `style.opacity` below — that's a + // different, non-collapsible axis (see that field's comment). + // For a GRADIENT fill this is always undefined — each stop already + // carries its own combined alpha (see DesignBundleGradientStop.hex + // above), so there's no single opacity number left to apply on top. + opacity?: number; + // Present only when `type === "GRADIENT"` and Figma's paint kind is + // one of the three CSS can represent (LINEAR/RADIAL/ANGULAR). + // DIAMOND-kind (and any future unrecognized gradient kind) omits this + // and falls back to `hex` only. + gradient?: DesignBundleGradient; +} +export interface DesignBundleStroke { + hex: string; + weight: number; +} +export interface DesignBundleEffect { + type: string; + x?: number; + y?: number; + blur?: number; + hex?: string; + // DROP_SHADOW/INNER_SHADOW only — Figma's own `spread` (expands a drop + // shadow / contracts an inner shadow; undefined defaults to 0, same as + // Figma's own default). Maps directly to CSS box-shadow's + // spread-radius value with no conversion — the sign/growth semantics + // already match. + spread?: number; +} +// The 13 of Figma's 18 blend modes CSS `mix-blend-mode` has a native +// keyword for — a plain kebab-case rename in every case (MULTIPLY -> +// "multiply", etc.). PASS_THROUGH/NORMAL are deliberately absent: both +// mean "no blending," so `DesignBundleNodeStyle.blendMode` is left +// undefined for them rather than modeled as a value (same sparse-field +// convention as `opacity`). LINEAR_BURN and LINEAR_DODGE are also +// absent — CSS has no equivalent (they're a different blend formula +// than color-burn/color-dodge, not just a naming difference). +export type DesignBundleBlendMode = + | "multiply" + | "screen" + | "overlay" + | "darken" + | "lighten" + | "color-dodge" + | "color-burn" + | "hard-light" + | "soft-light" + | "difference" + | "exclusion" + | "hue" + | "saturation" + | "color" + | "luminosity"; + +export interface DesignBundleNodeStyle { + fills: DesignBundleFill[]; + strokes: DesignBundleStroke[]; + cornerRadius: number; + effects: DesignBundleEffect[]; + // The *node's own* layer opacity (Figma's `node.opacity`, the + // "Opacity" field in the right-hand panel for the whole layer) — + // distinct from any individual fill's opacity above. This affects the + // node's entire rendered result as a group: background, strokes, text, + // every descendant — not just one fill layer. A node can legitimately + // have both a translucent fill *and* fully-opaque child content sitting + // on top of it (e.g. a card with a dimmed background but readable + // text); collapsing this into a per-fill alpha would incorrectly fade + // that content too, which real Figma rendering never does. Maps to CSS + // `opacity` on the node's own wrapping element, not a color-channel + // adjustment. Omitted (undefined) when fully opaque (1). + opacity?: number; + // The *node's own* Blending mode (Figma's `node.blendMode`, same + // right-hand-panel struct as `opacity` above, `HasBlendModeAndOpacityTrait` + // in the REST API v1 shape) — scoped deliberately to this one node-level + // field, not per-fill or per-effect blend modes (Figma also allows a + // blend mode on an individual paint or shadow effect, a much rarer, + // finer-grained case left out of scope here — same "narrower gap" + // treatment). Maps to CSS `mix-blend-mode` on the node's own wrapping + // element. Omitted (undefined) for PASS_THROUGH/NORMAL (no blending) + // and for LINEAR_BURN/LINEAR_DODGE (no CSS equivalent). + blendMode?: DesignBundleBlendMode; +} +export type DesignBundleSizeValue = "fill" | "hug" | number; +export interface DesignBundleLayout { + mode: "NONE" | "HORIZONTAL" | "VERTICAL"; + primaryAxisAlign: "MIN" | "CENTER" | "MAX" | "SPACE_BETWEEN"; + counterAxisAlign: "MIN" | "CENTER" | "MAX" | "BASELINE"; + gap: number; + padding: { top: number; right: number; bottom: number; left: number }; + sizing: { width: DesignBundleSizeValue; height: DesignBundleSizeValue }; + // Populated only when the *parent* frame's layout.mode is "NONE" (i.e. + // the parent uses absolute positioning) — coordinates are meaningless + // outside that case, since Auto Layout computes a child's position + // itself. + position?: { x: number; y: number }; + // Figma's Auto Layout "wrap" (`layoutWrap: "WRAP"`) — a real, distinct + // layout mechanism from `position` above; a wrapped, fixed-width + // HORIZONTAL container can look identical to an absolutely-positioned + // one at a glance, so this is captured as its own explicit field + // rather than inferred. CSS's `flex-wrap: wrap` is the literal + // equivalent. Only ever `true` — the non-default case (`NO_WRAP`) is + // never recorded explicitly, matching this schema's usual + // default-omission convention. + wrap?: boolean; + // Figma's `counterAxisSpacing` — the gap between wrapped *rows/tracks*, + // distinct from `gap` above (which is the item gap along the main + // axis). Only meaningful, and only ever populated, when `wrap` is true. + // Maps to CSS `gap`'s row-gap component (`gap: {rowGap}px {gap}px`) + // rather than reusing `gap` for both axes, in case a design's item + // spacing and row spacing genuinely differ. + rowGap?: number; +} +export interface DesignBundleTextSegment { + uniqueId: string; + characters: string; + fontFamily: string; + fontSize: number; + fontWeight: string; + lineHeight: number; + letterSpacing: number; + textCase: string; + textDecoration: string; + // Figma's named text style id for this run, when the run has one applied. + // Resolves via bundle.styles.textStyles[textStyleId] -> DesignBundleTextStyle. + // The primary heading/paragraph signal for a downstream consumer, + // ahead of the fontSize/fontWeight fallback heuristic. + textStyleId?: string; + // Text fill color. `fillHex` is always populated when the run has a + // solid fill at all (the literal resolved color); `fillRef` is only set + // when that fill is bound to a Figma variable. Previously only fillRef + // was captured, which silently dropped color for any text run using a + // plain, non-variable-bound color — the common case. Both now mirror + // DesignBundleFill's hex+variableRef pairing (mapFill in + // designBundleTree.ts) rather than introducing a different shape. + fillHex?: string; + fillRef?: string; + // Mirrors DesignBundleFill.opacity (same combined color.a * paint.opacity + // calculation, via the same mapFill/fillOpacity path) — a text run's own + // fill can be translucent same as any other fill. Omitted when opaque. + fillOpacity?: number; +} +export type DesignNodeType = "FRAME" | "TEXT" | "IMAGE" | "VECTOR" | "RECTANGLE"; +export interface DesignNode { + id: string; + uniqueName: string; + type: DesignNodeType; + layout: DesignBundleLayout; + style: DesignBundleNodeStyle; + // Figma's `textAlignHorizontal`, node-level (not per-run — Figma + // models horizontal alignment as a property of the whole TEXT node, + // not individual styled runs, unlike fontFamily/fontSize/etc. above). + // Omitted entirely — not just set to "LEFT" — when Figma's own value + // is "LEFT", since that's the CSS default and there's no reason to + // emit a redundant `text-align: left`. + text?: { segments: DesignBundleTextSegment[]; align?: "CENTER" | "RIGHT" | "JUSTIFIED" }; + assetRef?: string; + // Figma's main-component id, present when this node was originally an + // INSTANCE (already available synchronously on the REST API v1 JSON + // export this uses — no extra API call needed). Populated regardless + // of what `type` above collapses to (INSTANCE always maps to FRAME/ + // RECTANGLE here, same as any other frame — see classifyNodeType). + // Lets a downstream consumer recognize repeated instances of the same + // component by real identity rather than falling back to fragile + // layer-name matching. + componentId?: string; + // This node's index among its original parent's children at the point + // the tree was walked — i.e. Figma's own paint/z-order (`children[]` + // array order is paint order, not visual position). Captured as an + // explicit field, independent of this node's *current* position in + // any `children[]` array, so it survives a node being pulled out of + // that array entirely and re-rooted elsewhere — a downstream consumer + // that reorganizes the tree (e.g. lifting a repeated header/footer out + // into its own reusable unit) otherwise has no way to know whether + // that node was originally above or below some other, now-unrelated + // sibling in paint order once they're split apart. + // Root `designs[].root` entries have no real parent/siblings within + // the bundle, so this is omitted (undefined) there — same convention + // as `layout.position` being root-conditional. + // + // Deliberately a plain ordinal (0 = painted first/bottommost in normal + // top-down z stacking), not a pre-computed CSS z-index — leaving a + // downstream consumer free to decide its own sign/offset convention + // (e.g. `z-index: {paintOrder}` or `-{paintOrder}`) rather than baking + // a CSS-specific decision into this target-neutral bundle. + paintOrder?: number; + // A FRAME/RECTANGLE's own background *image* fill — distinct from + // `assetRef` (leaf IMAGE/VECTOR nodes, where the exported asset *is* + // the node's entire visual content) and distinct from `style.fills` + // (which only ever models SOLID/GRADIENT paints, never IMAGE — see + // `classifyNodeType`'s doc comment in designBundleTree.ts). A node + // with both an image fill *and* real children stays a FRAME so its + // children survive as separate, editable content, but that leaves the + // background image itself needing its own place to live — e.g. an + // overlay frame sitting in front of a photographic hero background + // that would otherwise never make it into the bundle at all. Resolves + // the same way `assetRef` does — via `bundle.assets[]`, keyed by this + // id — a downstream consumer renders it as a CSS `background-image`, + // layered under any `style.fills` background-color (and under any + // real children rendered on top, same as Figma's own paint order for + // this exact configuration). + backgroundAssetRef?: string; + children: DesignNode[]; +} +export interface DesignBundleAsset { + id: string; + figmaNodeId: string; + fileName: string; + kind: "raster" | "vector"; + width: number; + height: number; + // Present only for a background-image asset (referenced via a + // DesignNode's `backgroundAssetRef`, not `assetRef`). Figma has no API + // to export "just this one fill" from a node that also has other + // visual content (children) painted on top of it — calling the usual + // `node.exportAsync()` on the *containing* frame would flatten those + // children into the raster too, which is exactly why such a frame + // keeps its children as separate, real content instead of a flattened + // image. `imageHash` is the paint's own image reference (Figma REST + // API v1 calls this `imageRef`; the Plugin API's `getImageByHash` + // accepts the same underlying value) — resolving the fill's raw bytes + // directly, independent of whatever else the containing node renders. + imageHash?: string; + // Multiplier between this asset's `width`/`height` (the node's logical + // layout size) and the exported file's actual pixel dimensions. Raster + // (PNG) assets are exported at a fixed 2x scale (see + // `exportDesignBundleAssets` in designBundleAssets.ts) — without this, + // a downstream consumer has no way to know the PNG is 2x without + // decoding it and comparing dimensions itself. Omitted for vector (SVG) + // assets, which have no fixed pixel scale. + scale?: number; +} +export interface DesignBundleColorStyle { + name: string; + hex: string; +} +export interface DesignBundleTextStyle { + name: string; + fontFamily: string; + fontSize: number; + fontWeight: string; + lineHeight: number; +} +export interface DesignBundleStyles { + colors: Record; + textStyles: Record; +} +export interface DesignBundleDesign { + figmaNodeId: string; + layerName: string; + root: DesignNode; +} +export interface DesignBundleMeta { + figmaFileKey: string; + figmaFileName: string; + figmaPageName: string; + exportedAt: string; + exportedBy: string; + sourceTool: string; +} +export interface DesignBundle { + schemaVersion: 1; + meta: DesignBundleMeta; + designs: DesignBundleDesign[]; + assets: DesignBundleAsset[]; + styles: DesignBundleStyles; +} +export type ExportDesignBundleMessage = Message & { + type: "export-design-bundle"; +}; +export type DesignBundleZipMessage = Message & { + type: "design-bundle-zip"; + zip: ArrayBuffer; + fileName: string; + designCount: number; + assetCount: number; + warnings: string[]; +}; +export type DesignBundleErrorMessage = Message & { + type: "design-bundle-error"; + error: string; +}; + // Nodes export type ParentNode = BaseNode & ChildrenMixin;