Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 29 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions apps/plugin/plugin-src/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 =",
Expand Down Expand Up @@ -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<unknown>;
console.log(`[DEBUG] Setting changed: ${key} = ${value}`);
Expand Down
61 changes: 61 additions & 0 deletions apps/plugin/ui-src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
DownloadProjectFormat,
ProjectDownloadErrorMessage,
ProjectZipMessage,
DesignBundleZipMessage,
DesignBundleErrorMessage,
} from "types";
import { postUISettingsChangingMessage } from "./messaging";
import copy from "copy-to-clipboard";
Expand All @@ -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: "" };
Expand Down Expand Up @@ -56,6 +61,9 @@ export default function App() {
warnings: [],
isDownloadingProject: false,
projectDownloadError: null,
isExportingDesignBundle: false,
designBundleExportError: null,
designBundleWarnings: [],
});

const rootStyles = getComputedStyle(document.documentElement);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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}
/>
</div>
);
Expand Down
57 changes: 52 additions & 5 deletions packages/backend/src/altNodes/jsonNodeConversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
Expand All @@ -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();

Expand Down
Loading