@agent-native/core 0.119.0 → 0.119.1
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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +8 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/ingestion/figma-node-to-html.ts +20 -99
- package/corpus/core/src/ingestion/figma-paint-math.ts +371 -0
- package/corpus/core/src/ingestion/index.ts +20 -0
- package/corpus/templates/assets/app/routes/library.tsx +17 -9
- package/corpus/templates/design/AGENTS.md +18 -5
- package/corpus/templates/design/FIGMA_INTEROPERABILITY.md +25 -12
- package/corpus/templates/design/actions/hydrate-figma-paste-images.ts +115 -0
- package/corpus/templates/design/actions/import-figma-clipboard.ts +83 -18
- package/corpus/templates/design/actions/import-figma-frame.ts +34 -21
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +28 -1
- package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +106 -11
- package/corpus/templates/design/app/components/design/FigmaHydrationDialog.tsx +273 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +7 -7
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +57 -9
- package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +10 -0
- package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +8 -1
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +3 -0
- package/corpus/templates/design/app/i18n-data.ts +150 -2
- package/corpus/templates/design/app/lib/design-file-upload.ts +85 -0
- package/corpus/templates/design/app/lib/design-import.ts +19 -1
- package/corpus/templates/design/app/lib/figma-clipboard.ts +110 -10
- package/corpus/templates/design/changelog/2026-07-20-paste-figma-frames-directly-onto-the-canvas-no-token-needed.md +6 -0
- package/corpus/templates/design/changelog/2026-07-21-fig-upload-frame-url-and-rate-limit-errors.md +5 -0
- package/corpus/templates/design/changelog/2026-07-22-connecting-a-figma-token-now-actually-fills-in-a-pasted-desi.md +6 -0
- package/corpus/templates/design/changelog/2026-07-22-figma-line-arrow-vectors-and-stroked-icons-now-render-instea.md +6 -0
- package/corpus/templates/design/changelog/2026-07-22-fill-a-no-token-figma-paste-s-images-by-dropping-the-origina.md +6 -0
- package/corpus/templates/design/changelog/2026-07-22-imported-and-pasted-figma-frames-now-render-faithfully-fixed.md +6 -0
- package/corpus/templates/design/server/handlers/import-design-file.ts +50 -0
- package/corpus/templates/design/server/lib/fig-file-decoder.ts +90 -49
- package/corpus/templates/design/server/lib/fig-file-import.ts +19 -5
- package/corpus/templates/design/server/lib/fig-file-to-html.ts +583 -78
- package/corpus/templates/design/server/lib/figma-clipboard-local-decode.ts +238 -0
- package/corpus/templates/design/server/lib/figma-image-hydration.ts +389 -0
- package/corpus/templates/design/server/lib/figma-node-import.ts +136 -36
- package/corpus/templates/design/server/register-secrets.ts +14 -2
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/ingestion/figma-node-to-html.d.ts.map +1 -1
- package/dist/ingestion/figma-node-to-html.js +14 -88
- package/dist/ingestion/figma-node-to-html.js.map +1 -1
- package/dist/ingestion/figma-paint-math.d.ts +157 -0
- package/dist/ingestion/figma-paint-math.d.ts.map +1 -0
- package/dist/ingestion/figma-paint-math.js +274 -0
- package/dist/ingestion/figma-paint-math.js.map +1 -0
- package/dist/ingestion/index.d.ts +1 -0
- package/dist/ingestion/index.d.ts.map +1 -1
- package/dist/ingestion/index.js +1 -0
- package/dist/ingestion/index.js.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +7 -7
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
- package/src/ingestion/figma-node-to-html.ts +20 -99
- package/src/ingestion/figma-paint-math.ts +371 -0
- package/src/ingestion/index.ts +20 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local kiwi decode path for Figma clipboard pastes.
|
|
3
|
+
*
|
|
4
|
+
* When no Figma access token is configured, a clipboard paste still carries
|
|
5
|
+
* the full fig-kiwi binary buffer: geometry, auto-layout, text, fills, and
|
|
6
|
+
* effects are all present in the kiwi message. This module decodes that
|
|
7
|
+
* buffer locally using the same decoder as the .fig upload path, synthesizes
|
|
8
|
+
* an editable HTML screen per top-level frame, and annotates IMAGE fill
|
|
9
|
+
* elements with `data-figma-image-ref` attributes so that a later call to
|
|
10
|
+
* `hydrate-figma-paste-images` can fill them in once the user connects their
|
|
11
|
+
* Figma access token.
|
|
12
|
+
*
|
|
13
|
+
* Images are NOT available in the clipboard buffer — Figma stores image bytes
|
|
14
|
+
* server-side and only includes a 20-byte SHA-1 hash in the kiwi message.
|
|
15
|
+
* Elements with image fills render as `about:blank` placeholders until
|
|
16
|
+
* `hydrate-figma-paste-images` resolves and mirrors the real URLs.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { assertSafeDecodedFigDocument, decodeFig } from "./fig-file-decoder.js";
|
|
20
|
+
import {
|
|
21
|
+
type FigNode,
|
|
22
|
+
type Guid,
|
|
23
|
+
guidKey,
|
|
24
|
+
renderHtmlTemplates,
|
|
25
|
+
} from "./fig-file-to-html.js";
|
|
26
|
+
import {
|
|
27
|
+
normalizeImportedHtmlDocument,
|
|
28
|
+
type ImportedDesignFile,
|
|
29
|
+
} from "./import-design-files.js";
|
|
30
|
+
|
|
31
|
+
// 8 MB binary cap. Above this the caller should use an upload handle; below
|
|
32
|
+
// it the action payload carries the base64 directly.
|
|
33
|
+
const MAX_CLIPBOARD_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
const MAX_CLIPBOARD_NODES = 75_000;
|
|
36
|
+
const MAX_CLIPBOARD_FRAMES = 50;
|
|
37
|
+
const MAX_FRAME_HTML_BYTES = 4 * 1024 * 1024;
|
|
38
|
+
const MAX_TOTAL_HTML_BYTES = 24 * 1024 * 1024;
|
|
39
|
+
|
|
40
|
+
export interface ClipboardLocalDecodeResult {
|
|
41
|
+
files: ImportedDesignFile[];
|
|
42
|
+
warnings: string[];
|
|
43
|
+
unresolvedImageRefs: string[];
|
|
44
|
+
stats: {
|
|
45
|
+
sourceKind: "figma-clipboard-local-kiwi";
|
|
46
|
+
format: "kiwi" | "zip";
|
|
47
|
+
version?: number;
|
|
48
|
+
frameCount: number;
|
|
49
|
+
nodeCount: number;
|
|
50
|
+
unresolvedImageCount: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Return the `guidKey` string for all nodes whose parentIndex.guid does not
|
|
56
|
+
* point to any other node in the flat nodeChanges list. These are the "roots"
|
|
57
|
+
* that need a synthetic CANVAS parent so `renderHtmlTemplates` can traverse
|
|
58
|
+
* the hierarchy starting from its expected DOCUMENT→CANVAS→FRAME structure.
|
|
59
|
+
*/
|
|
60
|
+
function findOrphanRoots(nodeChanges: FigNode[]): FigNode[] {
|
|
61
|
+
const ownKeys = new Set(nodeChanges.map((n) => guidKey(n.guid)));
|
|
62
|
+
return nodeChanges.filter((n) => {
|
|
63
|
+
const pk = guidKey(n.parentIndex?.guid);
|
|
64
|
+
return !pk || !ownKeys.has(pk);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wrap an orphaned nodeChanges array (clipboard format: selected subtree
|
|
70
|
+
* without a DOCUMENT/CANVAS container) in synthetic DOCUMENT and CANVAS
|
|
71
|
+
* nodes so `renderHtmlTemplates` can find the top-level frame hierarchy.
|
|
72
|
+
*
|
|
73
|
+
* The synthetic node GUIDs use `sessionID = maxExisting + 1` to guarantee
|
|
74
|
+
* no collision with real clipboard node GUIDs.
|
|
75
|
+
*/
|
|
76
|
+
function normalizeClipboardDocument(document: unknown): unknown {
|
|
77
|
+
const doc = document as {
|
|
78
|
+
nodeChanges?: FigNode[];
|
|
79
|
+
blobs?: unknown[];
|
|
80
|
+
};
|
|
81
|
+
const nodeChanges = doc.nodeChanges;
|
|
82
|
+
if (!Array.isArray(nodeChanges)) return document;
|
|
83
|
+
|
|
84
|
+
// Already has a DOCUMENT node → renderer can handle it as-is.
|
|
85
|
+
if (nodeChanges.some((n) => n.type === "DOCUMENT")) return document;
|
|
86
|
+
|
|
87
|
+
const maxSession = nodeChanges.reduce(
|
|
88
|
+
(m, n) => Math.max(m, n.guid?.sessionID ?? 0),
|
|
89
|
+
0,
|
|
90
|
+
);
|
|
91
|
+
const synBase = maxSession + 1;
|
|
92
|
+
const docGuid: Guid = { sessionID: synBase, localID: 0 };
|
|
93
|
+
const pageGuid: Guid = { sessionID: synBase, localID: 1 };
|
|
94
|
+
|
|
95
|
+
const orphans = findOrphanRoots(nodeChanges);
|
|
96
|
+
|
|
97
|
+
const documentNode: FigNode = {
|
|
98
|
+
guid: docGuid,
|
|
99
|
+
type: "DOCUMENT",
|
|
100
|
+
name: "Document",
|
|
101
|
+
};
|
|
102
|
+
const canvasNode: FigNode = {
|
|
103
|
+
guid: pageGuid,
|
|
104
|
+
type: "CANVAS",
|
|
105
|
+
name: "Clipboard",
|
|
106
|
+
parentIndex: { guid: docGuid, position: "0.5" },
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Shallow-copy the orphan nodes, pointing their parentIndex to the
|
|
110
|
+
// synthetic CANVAS. Non-orphan nodes keep their original parentIndex.
|
|
111
|
+
const orphanKeys = new Set(orphans.map((n) => guidKey(n.guid)));
|
|
112
|
+
const patchedNodes = nodeChanges.map((n) => {
|
|
113
|
+
if (!orphanKeys.has(guidKey(n.guid))) return n;
|
|
114
|
+
return {
|
|
115
|
+
...n,
|
|
116
|
+
parentIndex: {
|
|
117
|
+
guid: pageGuid,
|
|
118
|
+
position: n.parentIndex?.position ?? "0.5",
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
...doc,
|
|
125
|
+
nodeChanges: [documentNode, canvasNode, ...patchedNodes],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decode a base64 fig-kiwi clipboard buffer into editable HTML screens.
|
|
131
|
+
*
|
|
132
|
+
* @param options.bufferBase64 - Base64 string of the raw fig-kiwi bytes.
|
|
133
|
+
* @param options.fileKey - Figma file key from the clipboard's figmeta.
|
|
134
|
+
* @param options.originalName - Human-readable name for warnings/source metadata.
|
|
135
|
+
*/
|
|
136
|
+
export async function importFigmaClipboardFromBuffer(options: {
|
|
137
|
+
bufferBase64: string;
|
|
138
|
+
fileKey: string;
|
|
139
|
+
originalName?: string;
|
|
140
|
+
}): Promise<ClipboardLocalDecodeResult> {
|
|
141
|
+
const { bufferBase64, fileKey, originalName = "figma-paste" } = options;
|
|
142
|
+
|
|
143
|
+
// Base64 → binary with cap check.
|
|
144
|
+
const bufferBytes = Buffer.from(bufferBase64, "base64");
|
|
145
|
+
if (bufferBytes.length > MAX_CLIPBOARD_BUFFER_BYTES) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Figma clipboard buffer is too large for local decode (max 8 MB). Use a Figma access token for direct REST import instead.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const decoded = decodeFig(bufferBytes);
|
|
152
|
+
assertSafeDecodedFigDocument(decoded.document);
|
|
153
|
+
|
|
154
|
+
// Count nodes before synthesis to report against the cap.
|
|
155
|
+
const rawDoc = decoded.document as { nodeChanges?: FigNode[] };
|
|
156
|
+
const nodeCount = rawDoc.nodeChanges?.length ?? 0;
|
|
157
|
+
if (nodeCount > MAX_CLIPBOARD_NODES) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Figma clipboard has too many nodes (${nodeCount}; max ${MAX_CLIPBOARD_NODES}). Import a smaller selection.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const normalizedDoc = normalizeClipboardDocument(decoded.document);
|
|
164
|
+
|
|
165
|
+
// Empty imageMap so all IMAGE fills are treated as unresolved. The renderer
|
|
166
|
+
// will stamp data-figma-image-ref on affected elements via trackUnresolvedImageRefs.
|
|
167
|
+
const rendered = renderHtmlTemplates(normalizedDoc, {
|
|
168
|
+
imageMap: new Map(),
|
|
169
|
+
missingImageUrl: "about:blank",
|
|
170
|
+
trackUnresolvedImageRefs: true,
|
|
171
|
+
maxFrames: MAX_CLIPBOARD_FRAMES,
|
|
172
|
+
maxFrameOutputBytes: MAX_FRAME_HTML_BYTES,
|
|
173
|
+
maxTotalOutputBytes: MAX_TOTAL_HTML_BYTES,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (rendered.frames.length === 0) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
"No editable frames were found in the Figma clipboard. Copy a top-level frame before pasting.",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const unresolvedRefs = Array.from(rendered.unresolvedImageRefs ?? []);
|
|
183
|
+
|
|
184
|
+
let totalHtmlBytes = 0;
|
|
185
|
+
const files: ImportedDesignFile[] = rendered.frames.map((frame) => {
|
|
186
|
+
const content = normalizeImportedHtmlDocument(
|
|
187
|
+
frame.html,
|
|
188
|
+
`figma clipboard local-kiwi decode ${originalName}`,
|
|
189
|
+
);
|
|
190
|
+
const htmlBytes = Buffer.byteLength(content, "utf8");
|
|
191
|
+
totalHtmlBytes += htmlBytes;
|
|
192
|
+
if (totalHtmlBytes > MAX_TOTAL_HTML_BYTES) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"Figma clipboard import generated too much HTML (max 24 MB). Import a smaller selection.",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
filename: frame.fileName,
|
|
199
|
+
fileType: "html" as const,
|
|
200
|
+
content,
|
|
201
|
+
source: {
|
|
202
|
+
sourceType: "figma-clipboard-local-kiwi",
|
|
203
|
+
figmaFileKey: fileKey,
|
|
204
|
+
figmaNodeName: frame.frameName,
|
|
205
|
+
figFormat: decoded.format,
|
|
206
|
+
figVersion: decoded.version,
|
|
207
|
+
unresolvedImageRefs:
|
|
208
|
+
unresolvedRefs.length > 0 ? unresolvedRefs : undefined,
|
|
209
|
+
},
|
|
210
|
+
preferredFrame: {
|
|
211
|
+
title: frame.frameName,
|
|
212
|
+
width: frame.width,
|
|
213
|
+
height: frame.height,
|
|
214
|
+
},
|
|
215
|
+
} satisfies ImportedDesignFile;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const warnings: string[] = [];
|
|
219
|
+
if (unresolvedRefs.length > 0) {
|
|
220
|
+
warnings.push(
|
|
221
|
+
`${unresolvedRefs.length} image${unresolvedRefs.length === 1 ? "" : "s"} could not be loaded without a Figma access token. Connect Figma to fill them in.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
files,
|
|
227
|
+
warnings,
|
|
228
|
+
unresolvedImageRefs: unresolvedRefs,
|
|
229
|
+
stats: {
|
|
230
|
+
sourceKind: "figma-clipboard-local-kiwi",
|
|
231
|
+
format: decoded.format,
|
|
232
|
+
version: decoded.version,
|
|
233
|
+
frameCount: rendered.frames.length,
|
|
234
|
+
nodeCount,
|
|
235
|
+
unresolvedImageCount: unresolvedRefs.length,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the `data-figma-image-ref` placeholders a no-token clipboard/`.fig`
|
|
3
|
+
* decode stamps onto unresolved IMAGE fills. Trap: the clipboard carries only
|
|
4
|
+
* hashes, never bytes; the `.fig`'s embedded `images/` bytes — keyed by the same
|
|
5
|
+
* SHA-1 the kiwi IMAGE fills reference — are what `resolveFigImageHashes` mirrors
|
|
6
|
+
* token-free. REST (`hydrate-figma-paste-images`) resolves the same hashes with a token.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
agentEnterDocument,
|
|
11
|
+
agentLeaveDocument,
|
|
12
|
+
} from "@agent-native/core/collab";
|
|
13
|
+
import { uploadFile } from "@agent-native/core/file-upload";
|
|
14
|
+
import { accessFilter, assertAccess } from "@agent-native/core/sharing";
|
|
15
|
+
import { and, eq } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
import { getDb, schema } from "../db/index.js";
|
|
18
|
+
import {
|
|
19
|
+
readLiveSourceFile,
|
|
20
|
+
writeInlineSourceFile,
|
|
21
|
+
type SourceWorkspaceFile,
|
|
22
|
+
} from "../source-workspace.js";
|
|
23
|
+
import { mutateDesignData } from "./design-data-mutation.js";
|
|
24
|
+
import { decodeFig, type DecodedFigImage } from "./fig-file-decoder.js";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// HTML helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const DATA_IMAGE_REF_ATTR_RE = /\sdata-figma-image-ref="([^"]*)"/;
|
|
31
|
+
|
|
32
|
+
// Must match the renderer's real placeholder form `url('about:blank')` (single
|
|
33
|
+
// quotes survive style-attr escaping); lenient to the `"` entity form for
|
|
34
|
+
// older screens, with the back-reference keeping the quotes matched.
|
|
35
|
+
const IMAGE_URL_PLACEHOLDER_RE = /url\(("|')about:blank\1\)/g;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wrap `url()` exactly as the renderer's escaped `style="…"` would, so hydrated
|
|
39
|
+
* fills are byte-identical to natively rendered image fills.
|
|
40
|
+
*/
|
|
41
|
+
function cssUrlInAttr(url: string): string {
|
|
42
|
+
const inner = url
|
|
43
|
+
.replace(/'/g, "%27")
|
|
44
|
+
.replace(/&/g, "&")
|
|
45
|
+
.replace(/"/g, """)
|
|
46
|
+
.replace(/</g, "<");
|
|
47
|
+
return `url('${inner}')`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function collectImageRefHashes(html: string): string[] {
|
|
51
|
+
const hashes = new Set<string>();
|
|
52
|
+
for (const m of html.matchAll(/<[^>]+>/g)) {
|
|
53
|
+
const ref = m[0].match(DATA_IMAGE_REF_ATTR_RE);
|
|
54
|
+
if (ref?.[1]) {
|
|
55
|
+
for (const h of ref[1].trim().split(/\s+/)) {
|
|
56
|
+
if (h) hashes.add(h);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return Array.from(hashes);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fill `url('about:blank')` placeholders on `data-figma-image-ref` elements: the
|
|
65
|
+
* Nth placeholder maps to the Nth hash in the space-separated attr value.
|
|
66
|
+
*/
|
|
67
|
+
export function hydrateImageRefsInHtml(
|
|
68
|
+
html: string,
|
|
69
|
+
resolvedUrls: Map<string, string>,
|
|
70
|
+
): { html: string; resolved: number; missing: string[] } {
|
|
71
|
+
let resolvedCount = 0;
|
|
72
|
+
const missing: string[] = [];
|
|
73
|
+
|
|
74
|
+
const newHtml = html.replace(/<[^>]+>/g, (tag) => {
|
|
75
|
+
const refMatch = tag.match(DATA_IMAGE_REF_ATTR_RE);
|
|
76
|
+
if (!refMatch?.[1]) return tag;
|
|
77
|
+
|
|
78
|
+
const hashes = refMatch[1].trim().split(/\s+/).filter(Boolean);
|
|
79
|
+
if (hashes.length === 0) return tag;
|
|
80
|
+
|
|
81
|
+
const unresolvedHashes: string[] = [];
|
|
82
|
+
let hashIdx = 0;
|
|
83
|
+
|
|
84
|
+
let newTag = tag.replace(IMAGE_URL_PLACEHOLDER_RE, (match) => {
|
|
85
|
+
if (hashIdx >= hashes.length) return match;
|
|
86
|
+
const hash = hashes[hashIdx++]!;
|
|
87
|
+
const durableUrl = resolvedUrls.get(hash);
|
|
88
|
+
if (!durableUrl) {
|
|
89
|
+
unresolvedHashes.push(hash);
|
|
90
|
+
return match;
|
|
91
|
+
}
|
|
92
|
+
resolvedCount++;
|
|
93
|
+
return cssUrlInAttr(durableUrl);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Hashes beyond url() occurrences (shouldn't happen with our renderer).
|
|
97
|
+
while (hashIdx < hashes.length) {
|
|
98
|
+
unresolvedHashes.push(hashes[hashIdx++]!);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
missing.push(...unresolvedHashes);
|
|
102
|
+
|
|
103
|
+
if (unresolvedHashes.length === 0) {
|
|
104
|
+
newTag = newTag.replace(/\s+data-figma-image-ref="[^"]*"/, "");
|
|
105
|
+
} else if (unresolvedHashes.length < hashes.length) {
|
|
106
|
+
newTag = newTag.replace(
|
|
107
|
+
/data-figma-image-ref="[^"]*"/,
|
|
108
|
+
`data-figma-image-ref="${unresolvedHashes.join(" ")}"`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return newTag;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return { html: newHtml, resolved: resolvedCount, missing };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Shared file load + persist
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
export interface HydratableFile {
|
|
123
|
+
workspaceFile: SourceWorkspaceFile;
|
|
124
|
+
designId: string;
|
|
125
|
+
/** Present only when the screen was imported via a REST clipboard path. */
|
|
126
|
+
figmaFileKey?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Scoped, editor-access load of one HTML design file, plus the originating Figma
|
|
131
|
+
* file key when recorded. Throws identical not-found/non-HTML errors for both resolvers.
|
|
132
|
+
*/
|
|
133
|
+
export async function loadHydratableFile(
|
|
134
|
+
fileId: string,
|
|
135
|
+
): Promise<HydratableFile> {
|
|
136
|
+
const db = getDb();
|
|
137
|
+
const [row] = await db
|
|
138
|
+
.select({
|
|
139
|
+
id: schema.designFiles.id,
|
|
140
|
+
designId: schema.designFiles.designId,
|
|
141
|
+
filename: schema.designFiles.filename,
|
|
142
|
+
fileType: schema.designFiles.fileType,
|
|
143
|
+
content: schema.designFiles.content,
|
|
144
|
+
designData: schema.designs.data,
|
|
145
|
+
})
|
|
146
|
+
.from(schema.designFiles)
|
|
147
|
+
.innerJoin(
|
|
148
|
+
schema.designs,
|
|
149
|
+
eq(schema.designFiles.designId, schema.designs.id),
|
|
150
|
+
)
|
|
151
|
+
.where(
|
|
152
|
+
and(
|
|
153
|
+
eq(schema.designFiles.id, fileId),
|
|
154
|
+
accessFilter(schema.designs, schema.designShares),
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
.limit(1);
|
|
158
|
+
|
|
159
|
+
if (!row) throw new Error(`File not found: ${fileId}`);
|
|
160
|
+
if (row.fileType !== "html") {
|
|
161
|
+
throw new Error("hydrate-figma-paste-images only supports HTML files.");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await assertAccess("design", row.designId, "editor");
|
|
165
|
+
|
|
166
|
+
let figmaFileKey: string | undefined;
|
|
167
|
+
try {
|
|
168
|
+
const designData: unknown = row.designData
|
|
169
|
+
? JSON.parse(row.designData)
|
|
170
|
+
: {};
|
|
171
|
+
const screenMeta = (designData as Record<string, unknown>)?.screenMetadata;
|
|
172
|
+
if (screenMeta && typeof screenMeta === "object") {
|
|
173
|
+
const fileMeta = (screenMeta as Record<string, unknown>)[fileId];
|
|
174
|
+
if (fileMeta && typeof fileMeta === "object") {
|
|
175
|
+
const key = (fileMeta as Record<string, unknown>).figmaFileKey;
|
|
176
|
+
if (typeof key === "string" && key) figmaFileKey = key;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// JSON.parse failure — figmaFileKey stays undefined; the REST caller
|
|
181
|
+
// surfaces its own missing-key error, the .fig path does not need one.
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const workspaceFile: SourceWorkspaceFile = {
|
|
185
|
+
id: row.id,
|
|
186
|
+
designId: row.designId,
|
|
187
|
+
filename: row.filename,
|
|
188
|
+
fileType: "html",
|
|
189
|
+
content: row.content ?? "",
|
|
190
|
+
createdAt: null,
|
|
191
|
+
updatedAt: null,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return { workspaceFile, designId: row.designId, figmaFileKey };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface ApplyHydrationResult {
|
|
198
|
+
resolved: number;
|
|
199
|
+
missing: number;
|
|
200
|
+
skipped: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Hydrate placeholders and persist (CAS + collab sync), pruning
|
|
205
|
+
* `screenMetadata.unresolvedImageRefs`. Never writes when nothing resolved.
|
|
206
|
+
*/
|
|
207
|
+
export async function applyHydration(opts: {
|
|
208
|
+
file: SourceWorkspaceFile;
|
|
209
|
+
designId: string;
|
|
210
|
+
fileId: string;
|
|
211
|
+
liveContent: string;
|
|
212
|
+
liveVersionHash: string;
|
|
213
|
+
requestedHashes: string[];
|
|
214
|
+
resolvedUrls: Map<string, string>;
|
|
215
|
+
}): Promise<ApplyHydrationResult> {
|
|
216
|
+
const { file, designId, fileId, liveContent, liveVersionHash } = opts;
|
|
217
|
+
const {
|
|
218
|
+
html: hydratedHtml,
|
|
219
|
+
resolved,
|
|
220
|
+
missing,
|
|
221
|
+
} = hydrateImageRefsInHtml(liveContent, opts.resolvedUrls);
|
|
222
|
+
|
|
223
|
+
const uniqueMissing = Array.from(new Set(missing));
|
|
224
|
+
const skipped = opts.requestedHashes.length - opts.resolvedUrls.size;
|
|
225
|
+
|
|
226
|
+
if (resolved === 0) {
|
|
227
|
+
return { resolved: 0, missing: uniqueMissing.length, skipped };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
agentEnterDocument(fileId);
|
|
231
|
+
try {
|
|
232
|
+
await writeInlineSourceFile({
|
|
233
|
+
designId,
|
|
234
|
+
file,
|
|
235
|
+
content: hydratedHtml,
|
|
236
|
+
expectedVersionHash: liveVersionHash,
|
|
237
|
+
});
|
|
238
|
+
} finally {
|
|
239
|
+
agentLeaveDocument(fileId);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await mutateDesignData({
|
|
243
|
+
designId,
|
|
244
|
+
mutate: (current) => {
|
|
245
|
+
const screenMeta = current.screenMetadata;
|
|
246
|
+
if (!screenMeta || typeof screenMeta !== "object") return current;
|
|
247
|
+
const fileMeta = (screenMeta as Record<string, unknown>)[fileId];
|
|
248
|
+
if (!fileMeta || typeof fileMeta !== "object") return current;
|
|
249
|
+
const updatedFileMeta: Record<string, unknown> = {
|
|
250
|
+
...(fileMeta as Record<string, unknown>),
|
|
251
|
+
};
|
|
252
|
+
if (uniqueMissing.length > 0) {
|
|
253
|
+
updatedFileMeta.unresolvedImageRefs = uniqueMissing;
|
|
254
|
+
} else {
|
|
255
|
+
delete updatedFileMeta.unresolvedImageRefs;
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
...current,
|
|
259
|
+
screenMetadata: {
|
|
260
|
+
...(screenMeta as Record<string, unknown>),
|
|
261
|
+
[fileId]: updatedFileMeta,
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
isApplied: (persisted) => {
|
|
266
|
+
const screenMeta = persisted.screenMetadata;
|
|
267
|
+
if (!screenMeta || typeof screenMeta !== "object") return false;
|
|
268
|
+
const fileMeta = (screenMeta as Record<string, unknown>)[fileId];
|
|
269
|
+
if (!fileMeta || typeof fileMeta !== "object") return false;
|
|
270
|
+
const refs = (fileMeta as Record<string, unknown>).unresolvedImageRefs;
|
|
271
|
+
return uniqueMissing.length === 0
|
|
272
|
+
? refs === undefined || (Array.isArray(refs) && refs.length === 0)
|
|
273
|
+
: Array.isArray(refs) && refs.length === uniqueMissing.length;
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return { resolved, missing: uniqueMissing.length, skipped };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Token-free `.fig` resolver
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
const FIG_HYDRATE_UPLOAD_CONCURRENCY = 4;
|
|
285
|
+
|
|
286
|
+
function mimeTypeForExt(ext: string): string {
|
|
287
|
+
if (ext === "jpg" || ext === "jpeg") return "image/jpeg";
|
|
288
|
+
if (ext === "png") return "image/png";
|
|
289
|
+
if (ext === "webp") return "image/webp";
|
|
290
|
+
if (ext === "gif") return "image/gif";
|
|
291
|
+
return "application/octet-stream";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Decode a `.fig` and index its embedded images by SHA-1 hash. Decode once per
|
|
296
|
+
* upload and reuse the index across screens — a multi-screen hydration must not
|
|
297
|
+
* re-parse the whole (up to 50 MB) file per screen.
|
|
298
|
+
*/
|
|
299
|
+
export function indexFigImages(figBytes: Buffer): Map<string, DecodedFigImage> {
|
|
300
|
+
const byHash = new Map<string, DecodedFigImage>();
|
|
301
|
+
for (const image of decodeFig(figBytes).images) byHash.set(image.hash, image);
|
|
302
|
+
return byHash;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Match placeholder hashes against a `.fig`'s embedded `images/` bytes (keyed by
|
|
307
|
+
* the same SHA-1 the paste stamped) and mirror matches to durable storage. No
|
|
308
|
+
* Figma token or REST call.
|
|
309
|
+
*/
|
|
310
|
+
export async function resolveFigImageHashes(opts: {
|
|
311
|
+
figImages: Map<string, DecodedFigImage>;
|
|
312
|
+
hashes: string[];
|
|
313
|
+
ownerEmail: string;
|
|
314
|
+
uploader?: typeof uploadFile;
|
|
315
|
+
}): Promise<Map<string, string>> {
|
|
316
|
+
const { figImages, hashes, ownerEmail } = opts;
|
|
317
|
+
const uploader = opts.uploader ?? uploadFile;
|
|
318
|
+
|
|
319
|
+
const wanted = hashes.filter((h) => figImages.has(h));
|
|
320
|
+
const resolved = new Map<string, string>();
|
|
321
|
+
|
|
322
|
+
for (
|
|
323
|
+
let offset = 0;
|
|
324
|
+
offset < wanted.length;
|
|
325
|
+
offset += FIG_HYDRATE_UPLOAD_CONCURRENCY
|
|
326
|
+
) {
|
|
327
|
+
const batch = wanted.slice(offset, offset + FIG_HYDRATE_UPLOAD_CONCURRENCY);
|
|
328
|
+
await Promise.all(
|
|
329
|
+
batch.map(async (hash) => {
|
|
330
|
+
const image = figImages.get(hash)!;
|
|
331
|
+
try {
|
|
332
|
+
const uploaded = await uploader({
|
|
333
|
+
data: image.bytes,
|
|
334
|
+
filename: `figma-${image.hash}.${image.ext}`,
|
|
335
|
+
mimeType: mimeTypeForExt(image.ext),
|
|
336
|
+
ownerEmail,
|
|
337
|
+
recordAsset: false,
|
|
338
|
+
stableUrl: true,
|
|
339
|
+
});
|
|
340
|
+
if (uploaded?.url) resolved.set(hash, uploaded.url);
|
|
341
|
+
} catch {
|
|
342
|
+
// Skip; a partial resolve still helps and leaves placeholders for a retry.
|
|
343
|
+
}
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return resolved;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Token-free hydration of one screen's image placeholders from an uploaded
|
|
353
|
+
* `.fig`. Takes a pre-decoded image index (see `indexFigImages`) so a
|
|
354
|
+
* multi-screen hydration decodes the file once, not once per screen.
|
|
355
|
+
*/
|
|
356
|
+
export async function hydrateFileImagesFromFig(opts: {
|
|
357
|
+
fileId: string;
|
|
358
|
+
figImages: Map<string, DecodedFigImage>;
|
|
359
|
+
ownerEmail: string;
|
|
360
|
+
uploader?: typeof uploadFile;
|
|
361
|
+
}): Promise<ApplyHydrationResult & { fileId: string }> {
|
|
362
|
+
const { fileId, figImages, ownerEmail } = opts;
|
|
363
|
+
const { workspaceFile, designId } = await loadHydratableFile(fileId);
|
|
364
|
+
const live = await readLiveSourceFile(workspaceFile);
|
|
365
|
+
|
|
366
|
+
const hashes = collectImageRefHashes(live.content);
|
|
367
|
+
if (hashes.length === 0) {
|
|
368
|
+
return { fileId, resolved: 0, missing: 0, skipped: 0 };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const resolvedUrls = await resolveFigImageHashes({
|
|
372
|
+
figImages,
|
|
373
|
+
hashes,
|
|
374
|
+
ownerEmail,
|
|
375
|
+
uploader: opts.uploader,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const result = await applyHydration({
|
|
379
|
+
file: workspaceFile,
|
|
380
|
+
designId,
|
|
381
|
+
fileId,
|
|
382
|
+
liveContent: live.content,
|
|
383
|
+
liveVersionHash: live.versionHash,
|
|
384
|
+
requestedHashes: hashes,
|
|
385
|
+
resolvedUrls,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return { fileId, ...result };
|
|
389
|
+
}
|