@agent-native/core 0.84.30 → 0.84.34

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.
Files changed (65) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +24 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/session-replay.ts +5 -0
  5. package/corpus/core/src/client/use-action.ts +5 -1
  6. package/corpus/core/src/server/ssr-handler.ts +12 -0
  7. package/corpus/core/src/vite/action-types-plugin.ts +6 -2
  8. package/corpus/core/src/vite/client.ts +152 -17
  9. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +13 -1
  10. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-keep-inlined-css-without-live-resource-loa.md +6 -0
  11. package/corpus/templates/design/AGENTS.md +18 -0
  12. package/corpus/templates/design/actions/apply-source-edit.ts +87 -0
  13. package/corpus/templates/design/actions/import-design-source.ts +158 -0
  14. package/corpus/templates/design/actions/list-source-files.ts +52 -0
  15. package/corpus/templates/design/actions/navigate.ts +4 -2
  16. package/corpus/templates/design/actions/preview-source-edit.ts +85 -0
  17. package/corpus/templates/design/actions/read-source-file.ts +55 -0
  18. package/corpus/templates/design/actions/resolve-selection-source.ts +101 -0
  19. package/corpus/templates/design/actions/view-screen.ts +43 -1
  20. package/corpus/templates/design/app/components/design/CodeWorkbenchHost.tsx +630 -0
  21. package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +519 -0
  22. package/corpus/templates/design/app/hooks/use-navigation-state.ts +32 -6
  23. package/corpus/templates/design/app/i18n/zh-TW.ts +40 -0
  24. package/corpus/templates/design/app/i18n-data.ts +508 -0
  25. package/corpus/templates/design/app/pages/DesignEditor.tsx +171 -9
  26. package/corpus/templates/design/changelog/2026-07-01-design-can-import-figma-paste-fig-files-and-standalone-html.md +6 -0
  27. package/corpus/templates/design/changelog/2026-07-01-design-code-workspace.md +6 -0
  28. package/corpus/templates/design/package.json +3 -0
  29. package/corpus/templates/design/server/handlers/import-design-file.ts +168 -0
  30. package/corpus/templates/design/server/lib/figma-import/clipboard.ts +122 -0
  31. package/corpus/templates/design/server/lib/figma-import/decode.ts +455 -0
  32. package/corpus/templates/design/server/lib/figma-import/processor.ts +116 -0
  33. package/corpus/templates/design/server/lib/figma-import/render-html.ts +397 -0
  34. package/corpus/templates/design/server/lib/figma-import/types.ts +60 -0
  35. package/corpus/templates/design/server/lib/import-design-files.ts +314 -0
  36. package/corpus/templates/design/server/routes/api/import-design-file.post.ts +1 -0
  37. package/corpus/templates/design/server/source-workspace.ts +215 -0
  38. package/corpus/templates/design/shared/design-source-capabilities.ts +5 -5
  39. package/corpus/templates/design/shared/source-workspace.ts +149 -0
  40. package/dist/client/session-replay.d.ts +1 -0
  41. package/dist/client/session-replay.d.ts.map +1 -1
  42. package/dist/client/session-replay.js +2 -0
  43. package/dist/client/session-replay.js.map +1 -1
  44. package/dist/client/use-action.d.ts +5 -1
  45. package/dist/client/use-action.d.ts.map +1 -1
  46. package/dist/client/use-action.js.map +1 -1
  47. package/dist/collab/awareness.d.ts +2 -2
  48. package/dist/collab/awareness.d.ts.map +1 -1
  49. package/dist/collab/routes.d.ts +1 -1
  50. package/dist/notifications/routes.d.ts +3 -3
  51. package/dist/observability/routes.d.ts +3 -3
  52. package/dist/progress/routes.d.ts +1 -1
  53. package/dist/resources/handlers.d.ts +3 -3
  54. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  55. package/dist/server/ssr-handler.d.ts.map +1 -1
  56. package/dist/server/ssr-handler.js +7 -0
  57. package/dist/server/ssr-handler.js.map +1 -1
  58. package/dist/server/transcribe-voice.d.ts +1 -1
  59. package/dist/vite/action-types-plugin.d.ts.map +1 -1
  60. package/dist/vite/action-types-plugin.js +6 -2
  61. package/dist/vite/action-types-plugin.js.map +1 -1
  62. package/dist/vite/client.d.ts.map +1 -1
  63. package/dist/vite/client.js +132 -11
  64. package/dist/vite/client.js.map +1 -1
  65. package/package.json +1 -1
@@ -111,6 +111,7 @@ import {
111
111
  IconDownload,
112
112
  IconClipboard,
113
113
  IconFileExport,
114
+ IconFileImport,
114
115
  IconPlayerPlay,
115
116
  IconDeviceFloppy,
116
117
  IconRocket,
@@ -149,6 +150,10 @@ import {
149
150
  CanvasContextMenu,
150
151
  type CanvasContextMenuHandle,
151
152
  } from "@/components/design/CanvasContextMenu";
153
+ import {
154
+ CodeWorkbenchHost,
155
+ type CodeWorkbenchActiveFile,
156
+ } from "@/components/design/CodeWorkbenchHost";
152
157
  import {
153
158
  DesignCanvas,
154
159
  type IframeContextMenuPayload,
@@ -161,6 +166,7 @@ import {
161
166
  DesignExtensionsPanel,
162
167
  type DesignExtensionSlotContext,
163
168
  } from "@/components/design/DesignExtensionsPanel";
169
+ import { DesignImportPanel } from "@/components/design/DesignImportPanel";
164
170
  import {
165
171
  EditPanel,
166
172
  type InspectCodeData,
@@ -550,18 +556,34 @@ export function getDesignEditorStateUrlSearch(args: {
550
556
  currentSearch: string;
551
557
  viewMode: "single" | "overview";
552
558
  screenId?: string | null;
559
+ codeFileId?: string | null;
560
+ codeFilename?: string | null;
553
561
  selectionId?: string | null;
562
+ leftPanel?: DesignLeftPanel | null;
554
563
  zoom?: number | null;
555
564
  }) {
556
565
  const params = new URLSearchParams(args.currentSearch);
557
566
  params.set("view", args.viewMode);
567
+ if (args.leftPanel && args.leftPanel !== "file") {
568
+ params.set("panel", args.leftPanel);
569
+ } else {
570
+ params.delete("panel");
571
+ }
558
572
  if (args.screenId) {
559
573
  params.set("screen", args.screenId);
560
574
  } else {
561
575
  params.delete("screen");
562
576
  }
563
- params.delete("fileId");
564
- params.delete("filename");
577
+ if (args.leftPanel === "code" && args.codeFileId) {
578
+ params.set("fileId", args.codeFileId);
579
+ } else {
580
+ params.delete("fileId");
581
+ }
582
+ if (args.leftPanel === "code" && !args.codeFileId && args.codeFilename) {
583
+ params.set("filename", args.codeFilename);
584
+ } else {
585
+ params.delete("filename");
586
+ }
565
587
  if (args.selectionId) {
566
588
  params.set("selection", args.selectionId);
567
589
  } else {
@@ -3495,13 +3517,22 @@ function AgentNativeMenuMark({ className }: { className?: string }) {
3495
3517
  );
3496
3518
  }
3497
3519
 
3498
- type DesignLeftPanel = "file" | "agent" | "assets" | "tools" | "tokens";
3520
+ type DesignLeftPanel =
3521
+ | "file"
3522
+ | "agent"
3523
+ | "assets"
3524
+ | "tools"
3525
+ | "tokens"
3526
+ | "import"
3527
+ | "code";
3499
3528
 
3500
3529
  const INITIAL_GENERATION_DISABLED_LEFT_PANELS = new Set<DesignLeftPanel>([
3501
3530
  "file",
3502
3531
  "assets",
3503
3532
  "tools",
3504
3533
  "tokens",
3534
+ "import",
3535
+ "code",
3505
3536
  ]);
3506
3537
 
3507
3538
  function normalizeDesignLeftPanel(value: unknown): DesignLeftPanel | undefined {
@@ -3510,7 +3541,9 @@ function normalizeDesignLeftPanel(value: unknown): DesignLeftPanel | undefined {
3510
3541
  value === "agent" ||
3511
3542
  value === "assets" ||
3512
3543
  value === "tools" ||
3513
- value === "tokens"
3544
+ value === "tokens" ||
3545
+ value === "import" ||
3546
+ value === "code"
3514
3547
  ? value
3515
3548
  : undefined;
3516
3549
  }
@@ -3553,6 +3586,11 @@ function DesignWorkspaceRail({
3553
3586
  label: t("designEditor.leftRail.assets"),
3554
3587
  icon: <IconPhoto className="size-[15px]" />,
3555
3588
  },
3589
+ {
3590
+ panel: "import",
3591
+ label: t("designEditor.leftRail.import"),
3592
+ icon: <IconFileImport className="size-[15px]" />,
3593
+ },
3556
3594
  {
3557
3595
  panel: "tools",
3558
3596
  label: t("designEditor.leftRail.tools"),
@@ -3564,6 +3602,11 @@ function DesignWorkspaceRail({
3564
3602
  icon: <IconAssembly className="size-[15px]" />,
3565
3603
  },
3566
3604
  ];
3605
+ const codeItem = {
3606
+ panel: "code" as const,
3607
+ label: "Code" /* i18n-ignore */,
3608
+ icon: <IconCode className="size-[15px]" />,
3609
+ };
3567
3610
 
3568
3611
  return (
3569
3612
  <nav
@@ -3622,6 +3665,51 @@ function DesignWorkspaceRail({
3622
3665
  </Tooltip>
3623
3666
  );
3624
3667
  })}
3668
+ <div className="mt-auto flex w-full flex-col items-center border-t border-border/70 pt-3">
3669
+ <Tooltip>
3670
+ <TooltipTrigger asChild>
3671
+ <button
3672
+ type="button"
3673
+ aria-label={codeItem.label}
3674
+ aria-disabled={disabledPanels?.has(codeItem.panel) || undefined}
3675
+ aria-current={
3676
+ activePanel === codeItem.panel ? "page" : undefined
3677
+ }
3678
+ tabIndex={disabledPanels?.has(codeItem.panel) ? -1 : undefined}
3679
+ onClick={(event) => {
3680
+ if (disabledPanels?.has(codeItem.panel)) {
3681
+ event.preventDefault();
3682
+ return;
3683
+ }
3684
+ onPanelChange(codeItem.panel);
3685
+ }}
3686
+ className={cn(
3687
+ "group flex w-12 cursor-pointer flex-col items-center justify-start gap-1 rounded-none text-[10px] font-[450] leading-none text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]",
3688
+ disabledPanels?.has(codeItem.panel) &&
3689
+ "cursor-default opacity-35 hover:text-muted-foreground",
3690
+ activePanel === codeItem.panel && "text-foreground",
3691
+ )}
3692
+ >
3693
+ <span
3694
+ className={cn(
3695
+ "flex size-8 items-center justify-center rounded-lg transition-colors",
3696
+ activePanel === codeItem.panel
3697
+ ? "bg-[var(--design-editor-selection-color)] text-[var(--design-editor-accent-color)]"
3698
+ : "text-muted-foreground group-hover:bg-[var(--design-editor-layer-hover-color)] group-hover:text-foreground",
3699
+ disabledPanels?.has(codeItem.panel) &&
3700
+ "group-hover:bg-transparent group-hover:text-muted-foreground",
3701
+ )}
3702
+ >
3703
+ {codeItem.icon}
3704
+ </span>
3705
+ <span className="max-w-full truncate leading-none">
3706
+ {codeItem.label}
3707
+ </span>
3708
+ </button>
3709
+ </TooltipTrigger>
3710
+ <TooltipContent side="right">{codeItem.label}</TooltipContent>
3711
+ </Tooltip>
3712
+ </div>
3625
3713
  </div>
3626
3714
  {onMotionToggle ? (
3627
3715
  <div className="mt-4 flex w-full flex-col items-center border-t border-border/70 pt-3">
@@ -4608,6 +4696,8 @@ export default function DesignEditor() {
4608
4696
  useState<InspectorTab>("design");
4609
4697
  const [activeLeftPanel, setActiveLeftPanel] =
4610
4698
  useState<DesignLeftPanel>("file");
4699
+ const [activeCodeFile, setActiveCodeFile] =
4700
+ useState<CodeWorkbenchActiveFile | null>(null);
4611
4701
  const initialSearchCommandAppliedForIdRef = useRef<string | null>(null);
4612
4702
  const initialUrlSelectionHydratedForIdRef = useRef<string | null>(null);
4613
4703
  const [leftSidebarWidth, setLeftSidebarWidth] = useState(256);
@@ -4896,11 +4986,17 @@ export default function DesignEditor() {
4896
4986
  event.stopPropagation();
4897
4987
  event.currentTarget.setPointerCapture?.(event.pointerId);
4898
4988
  const startX = event.clientX;
4899
- const startWidth = side === "left" ? leftSidebarWidth : rightSidebarWidth;
4989
+ const codePanelOpen = side === "left" && activeLeftPanel === "code";
4990
+ const startWidth =
4991
+ side === "left"
4992
+ ? codePanelOpen
4993
+ ? Math.max(leftSidebarWidth, 640)
4994
+ : Math.min(leftSidebarWidth, 420)
4995
+ : rightSidebarWidth;
4900
4996
  const setWidth =
4901
4997
  side === "left" ? setLeftSidebarWidth : setRightSidebarWidth;
4902
- const minWidth = side === "left" ? 220 : 240;
4903
- const maxWidth = side === "left" ? 420 : 390;
4998
+ const minWidth = side === "left" ? (codePanelOpen ? 520 : 220) : 240;
4999
+ const maxWidth = side === "left" ? (codePanelOpen ? 860 : 420) : 390;
4904
5000
  const previousCursor = document.body.style.cursor;
4905
5001
  const previousUserSelect = document.body.style.userSelect;
4906
5002
  const dragShield = document.createElement("div");
@@ -4939,7 +5035,7 @@ export default function DesignEditor() {
4939
5035
  window.addEventListener("pointerup", cleanup);
4940
5036
  window.addEventListener("pointercancel", cleanup);
4941
5037
  },
4942
- [leftSidebarWidth, rightSidebarWidth],
5038
+ [activeLeftPanel, leftSidebarWidth, rightSidebarWidth],
4943
5039
  );
4944
5040
  // Undo/redo state driven by Y.UndoManager
4945
5041
  const [canUndo, setCanUndo] = useState(false);
@@ -9134,6 +9230,14 @@ export default function DesignEditor() {
9134
9230
  activeTool,
9135
9231
  inspectorTab: activeInspectorTab,
9136
9232
  leftPanel: activeLeftPanel,
9233
+ codeWorkspace: {
9234
+ open: activeLeftPanel === "code",
9235
+ backendKind: activeCodeFile?.backendKind ?? "virtual-inline",
9236
+ activePath: activeCodeFile?.path ?? null,
9237
+ activeFileId: activeCodeFile?.fileId ?? null,
9238
+ dirty: activeCodeFile?.dirty ?? false,
9239
+ versionHash: activeCodeFile?.versionHash ?? null,
9240
+ },
9137
9241
  // §8 DesignNavigationState additions — dock + breakpoint context
9138
9242
  dock: { kind: "motion" as const, open: motionDockOpen },
9139
9243
  motion: {
@@ -9202,6 +9306,7 @@ export default function DesignEditor() {
9202
9306
  activeTool: selection.activeTool,
9203
9307
  inspectorTab: selection.inspectorTab,
9204
9308
  leftPanel: selection.leftPanel,
9309
+ codeWorkspace: selection.codeWorkspace,
9205
9310
  dock: selection.dock,
9206
9311
  motion: selection.motion,
9207
9312
  breakpoint: selection.breakpoint,
@@ -9243,6 +9348,7 @@ export default function DesignEditor() {
9243
9348
  activeTool,
9244
9349
  activeInspectorTab,
9245
9350
  activeLeftPanel,
9351
+ activeCodeFile,
9246
9352
  overviewSelectedScreenIds,
9247
9353
  viewMode,
9248
9354
  zoom,
@@ -14352,6 +14458,9 @@ ${serializedHtml}
14352
14458
  currentSearch: location.search,
14353
14459
  viewMode,
14354
14460
  screenId: activeFile?.id ?? activeFileId,
14461
+ leftPanel: activeLeftPanel,
14462
+ codeFileId: activeLeftPanel === "code" ? activeCodeFile?.fileId : null,
14463
+ codeFilename: activeLeftPanel === "code" ? activeCodeFile?.path : null,
14355
14464
  selectionId:
14356
14465
  selectedUrlSelectionId ??
14357
14466
  (preserveInitialRouteSelection ? initialRouteSelectionId : null),
@@ -14369,6 +14478,9 @@ ${serializedHtml}
14369
14478
  }, [
14370
14479
  activeFile?.id,
14371
14480
  activeFileId,
14481
+ activeCodeFile?.fileId,
14482
+ activeCodeFile?.path,
14483
+ activeLeftPanel,
14372
14484
  codeLayerOwnerByNodeId.size,
14373
14485
  files,
14374
14486
  id,
@@ -16287,7 +16399,14 @@ ${serializedHtml}
16287
16399
  </div>
16288
16400
  );
16289
16401
 
16290
- const leftContentWidth = Math.max(leftSidebarWidth, 320);
16402
+ const leftContentWidth =
16403
+ activeLeftPanel === "code"
16404
+ ? Math.max(leftSidebarWidth, 640)
16405
+ : Math.max(Math.min(leftSidebarWidth, 420), 320);
16406
+ const routeCodeFileId =
16407
+ activeLeftPanel === "code" ? searchParams.get("fileId") : null;
16408
+ const routeCodeFilename =
16409
+ activeLeftPanel === "code" ? searchParams.get("filename") : null;
16291
16410
  return (
16292
16411
  // h-full not flex-1: the parent <main> uses overflow-y-auto, not flex,
16293
16412
  // so flex-1 on the child doesn't resolve to the available height. h-full
@@ -16765,6 +16884,23 @@ ${serializedHtml}
16765
16884
  />
16766
16885
  )}
16767
16886
  </div>
16887
+ <div
16888
+ className={cn(
16889
+ "min-h-0 flex-1 flex-col overflow-hidden",
16890
+ activeLeftPanel === "import" ? "flex" : "hidden",
16891
+ )}
16892
+ >
16893
+ {canEditDesign ? (
16894
+ <DesignImportPanel context={designExtensionContext} />
16895
+ ) : (
16896
+ <ReadOnlyEditorPanel
16897
+ title={"Import requires editor access" /* i18n-ignore */}
16898
+ description={
16899
+ "Ask an owner for edit access before importing files into this design." /* i18n-ignore */
16900
+ }
16901
+ />
16902
+ )}
16903
+ </div>
16768
16904
  <div
16769
16905
  className={cn(
16770
16906
  "min-h-0 flex-1 flex-col overflow-hidden",
@@ -16815,6 +16951,32 @@ ${serializedHtml}
16815
16951
  />
16816
16952
  )}
16817
16953
  </div>
16954
+ <div
16955
+ className={cn(
16956
+ "min-h-0 flex-1 flex-col overflow-hidden",
16957
+ activeLeftPanel === "code" ? "flex" : "hidden",
16958
+ )}
16959
+ >
16960
+ {id ? (
16961
+ <CodeWorkbenchHost
16962
+ designId={id}
16963
+ activeFileId={
16964
+ activeLeftPanel === "code"
16965
+ ? routeCodeFileId
16966
+ : (activeFile?.id ?? activeFileId)
16967
+ }
16968
+ activeFilename={
16969
+ activeLeftPanel === "code"
16970
+ ? routeCodeFilename
16971
+ : activeFile?.filename
16972
+ }
16973
+ selectedNodeId={selectedElementLayerId}
16974
+ selectedSelector={selectedCanvasSelector}
16975
+ canEdit={canEditDesign}
16976
+ onActiveFileChange={setActiveCodeFile}
16977
+ />
16978
+ ) : null}
16979
+ </div>
16818
16980
  </div>
16819
16981
  <div
16820
16982
  role="separator"
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Design can import Figma paste, .fig files, and standalone HTML from a new Import tab.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Designs can be edited in a new Code workspace from the left rail.
@@ -24,12 +24,15 @@
24
24
  "@paper-design/shaders-react": "0.0.76",
25
25
  "@tabler/icons-react": "catalog:",
26
26
  "drizzle-orm": "^0.45.2",
27
+ "fzstd": "^0.1.1",
27
28
  "h3": "^2.0.1-rc.20",
28
29
  "html2canvas": "^1.4.1",
29
30
  "isbot": "^5",
30
31
  "jspdf": "^4.2.1",
31
32
  "jszip": "^3.10.1",
33
+ "kiwi-schema": "^0.5.0",
32
34
  "nanoid": "^5.1.9",
35
+ "pako": "^3.0.0",
33
36
  "pdf-parse": "^2.4.5",
34
37
  "yjs": "^13.6.31",
35
38
  "zod": "^4.3.6"
@@ -0,0 +1,168 @@
1
+ import path from "node:path";
2
+
3
+ import { getSession } from "@agent-native/core/server";
4
+ import { assertAccess } from "@agent-native/core/sharing";
5
+ import {
6
+ defineEventHandler,
7
+ getQuery,
8
+ getRequestHeader,
9
+ readMultipartFormData,
10
+ setResponseStatus,
11
+ } from "h3";
12
+
13
+ import { isFigKiwiBuffer, isZipBuffer } from "../lib/figma-import/decode.js";
14
+ import { importFigmaBuffer } from "../lib/figma-import/processor.js";
15
+ import {
16
+ normalizeImportedHtmlDocument,
17
+ saveImportedDesignFiles,
18
+ type ImportedDesignFile,
19
+ } from "../lib/import-design-files.js";
20
+
21
+ const MAX_HTML_BYTES = 2 * 1024 * 1024;
22
+ const MAX_FIG_BYTES = 50 * 1024 * 1024;
23
+ const TOTAL_BODY_LIMIT = MAX_FIG_BYTES + 1024 * 1024;
24
+
25
+ function fieldText(
26
+ parts: Awaited<ReturnType<typeof readMultipartFormData>>,
27
+ name: string,
28
+ ) {
29
+ const part = parts?.find((candidate) => candidate.name === name);
30
+ return part?.data
31
+ ? Buffer.from(part.data).toString("utf8").trim()
32
+ : undefined;
33
+ }
34
+
35
+ function statusForError(message: string): number {
36
+ if (/unauthorized/i.test(message)) return 401;
37
+ if (/access|permission|not allowed/i.test(message)) return 403;
38
+ if (/too large|max/i.test(message)) return 413;
39
+ return 400;
40
+ }
41
+
42
+ export const importDesignFile = defineEventHandler(async (event) => {
43
+ const session = await getSession(event).catch(() => null);
44
+ if (!session?.email) {
45
+ setResponseStatus(event, 401);
46
+ return { error: "Unauthorized" };
47
+ }
48
+
49
+ const rawContentLength = getRequestHeader(event, "content-length");
50
+ const contentLength = Number(rawContentLength);
51
+ if (!rawContentLength || !Number.isFinite(contentLength)) {
52
+ setResponseStatus(event, 411);
53
+ return { error: "Content-Length header is required" };
54
+ }
55
+ if (contentLength > TOTAL_BODY_LIMIT) {
56
+ setResponseStatus(event, 413);
57
+ return { error: "Request body too large" };
58
+ }
59
+
60
+ try {
61
+ const query = getQuery(event);
62
+ const queryDesignId =
63
+ typeof query.designId === "string" ? query.designId.trim() : undefined;
64
+ let accessChecked = false;
65
+ if (queryDesignId) {
66
+ await assertAccess("design", queryDesignId, "editor");
67
+ accessChecked = true;
68
+ }
69
+
70
+ const parts = await readMultipartFormData(event);
71
+ const bodyDesignId = fieldText(parts, "designId");
72
+ if (queryDesignId && bodyDesignId && bodyDesignId !== queryDesignId) {
73
+ setResponseStatus(event, 400);
74
+ return { error: "Mismatched designId" };
75
+ }
76
+ const designId = queryDesignId ?? bodyDesignId;
77
+ const filePart = parts?.find((part) => part.name === "file" && part.data);
78
+ if (!designId) {
79
+ setResponseStatus(event, 400);
80
+ return { error: "Missing designId" };
81
+ }
82
+ if (!accessChecked) {
83
+ await assertAccess("design", designId, "editor");
84
+ }
85
+ if (!filePart?.data) {
86
+ setResponseStatus(event, 400);
87
+ return { error: "No file uploaded" };
88
+ }
89
+
90
+ const originalName = filePart.filename || "import";
91
+ const ext = path.extname(originalName).toLowerCase();
92
+ const data = Buffer.from(filePart.data);
93
+
94
+ if (ext === ".html" || ext === ".htm") {
95
+ if (data.length > MAX_HTML_BYTES) {
96
+ throw new Error("HTML file is too large (max 2 MB).");
97
+ }
98
+ const saved = await saveImportedDesignFiles({
99
+ designId,
100
+ sourceType: "html-upload",
101
+ files: [
102
+ {
103
+ filename: originalName,
104
+ fileType: "html",
105
+ content: normalizeImportedHtmlDocument(
106
+ data.toString("utf8"),
107
+ "uploaded HTML file",
108
+ ),
109
+ source: { sourceType: "html-upload", originalName },
110
+ },
111
+ ],
112
+ });
113
+ return {
114
+ importKind: "html",
115
+ ...saved,
116
+ stats: { sourceKind: "html-upload", frameCount: saved.files.length },
117
+ };
118
+ }
119
+
120
+ if (ext !== ".fig") {
121
+ throw new Error("Unsupported file type. Upload .fig, .html, or .htm.");
122
+ }
123
+ if (data.length > MAX_FIG_BYTES) {
124
+ throw new Error("Figma file is too large (max 50 MB).");
125
+ }
126
+ if (!isFigKiwiBuffer(data) && !isZipBuffer(data)) {
127
+ throw new Error(
128
+ "Figma file contents do not match .fig or fig-kiwi format.",
129
+ );
130
+ }
131
+
132
+ const imported = await importFigmaBuffer({
133
+ buffer: data,
134
+ filename: originalName,
135
+ sourceKind: "fig-file",
136
+ });
137
+ const files: ImportedDesignFile[] = imported.files.map((file) => ({
138
+ filename: file.filename,
139
+ fileType: "html",
140
+ content: file.content,
141
+ source: { ...file.source, originalName },
142
+ preferredFrame: {
143
+ title:
144
+ typeof file.source?.frameName === "string"
145
+ ? file.source.frameName
146
+ : undefined,
147
+ width: file.width,
148
+ height: file.height,
149
+ },
150
+ }));
151
+ const saved = await saveImportedDesignFiles({
152
+ designId,
153
+ sourceType: "fig-file",
154
+ files,
155
+ warnings: imported.warnings,
156
+ });
157
+ return {
158
+ importKind: "fig",
159
+ ...saved,
160
+ stats: imported.stats,
161
+ };
162
+ } catch (error) {
163
+ const message =
164
+ error instanceof Error ? error.message : "File import failed.";
165
+ setResponseStatus(event, statusForError(message));
166
+ return { error: message };
167
+ }
168
+ });
@@ -0,0 +1,122 @@
1
+ import type { FigmaClipboardMeta, ParsedFigmaClipboardHtml } from "./types.js";
2
+
3
+ const MAX_CLIPBOARD_HTML_BYTES = 2 * 1024 * 1024;
4
+ const MAX_DECODED_BUFFER_BYTES = 25 * 1024 * 1024;
5
+ const FIG_KIWI_MAGIC = Buffer.from("fig-kiwi", "utf8");
6
+
7
+ function byteLength(value: string): number {
8
+ return Buffer.byteLength(value, "utf8");
9
+ }
10
+
11
+ function stripWrapper(value: string, marker: "figmeta" | "figma"): string {
12
+ return value
13
+ .trim()
14
+ .replace(new RegExp(`^<!--\\(${marker}\\)`, "i"), "")
15
+ .replace(new RegExp(`\\(/${marker}\\)-->$`, "i"), "")
16
+ .trim();
17
+ }
18
+
19
+ function decodeBase64(value: string, label: string): Buffer {
20
+ const normalized = value.replace(/\s+/g, "");
21
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
22
+ throw new Error(`${label} is not valid base64.`);
23
+ }
24
+ const decoded = Buffer.from(normalized, "base64");
25
+ const roundTrip = decoded.toString("base64").replace(/=+$/, "");
26
+ if (roundTrip !== normalized.replace(/=+$/, "")) {
27
+ throw new Error(`${label} is malformed base64.`);
28
+ }
29
+ return decoded;
30
+ }
31
+
32
+ function extractDataAttribute(html: string, attribute: string): string | null {
33
+ const pattern = new RegExp(`${attribute}\\s*=\\s*(["'])([\\s\\S]*?)\\1`, "i");
34
+ const match = pattern.exec(html);
35
+ return match?.[2] ?? null;
36
+ }
37
+
38
+ function removeFigmaHiddenSpans(html: string): string {
39
+ return html
40
+ .replace(
41
+ /<span\b[^>]*\bdata-metadata\s*=\s*(["'])[\s\S]*?\1[^>]*>\s*<\/span>/gi,
42
+ "",
43
+ )
44
+ .replace(
45
+ /<span\b[^>]*\bdata-buffer\s*=\s*(["'])[\s\S]*?\1[^>]*>\s*<\/span>/gi,
46
+ "",
47
+ )
48
+ .replace(/<meta\b[^>]*charset[^>]*>/gi, "")
49
+ .trim();
50
+ }
51
+
52
+ function normalizeMeta(value: unknown): FigmaClipboardMeta {
53
+ const raw =
54
+ value && typeof value === "object" && !Array.isArray(value)
55
+ ? (value as Record<string, unknown>)
56
+ : {};
57
+ const selectedNodeData =
58
+ typeof raw.selectedNodeData === "string" ? raw.selectedNodeData : undefined;
59
+ const selectedNodeId = selectedNodeData?.split("|")[0]?.trim() || undefined;
60
+ return {
61
+ fileKey: typeof raw.fileKey === "string" ? raw.fileKey : undefined,
62
+ pasteID:
63
+ typeof raw.pasteID === "string" || typeof raw.pasteID === "number"
64
+ ? raw.pasteID
65
+ : undefined,
66
+ dataType: typeof raw.dataType === "string" ? raw.dataType : undefined,
67
+ environment:
68
+ typeof raw.environment === "string" ? raw.environment : undefined,
69
+ selectedNodeData,
70
+ selectedNodeId,
71
+ raw,
72
+ };
73
+ }
74
+
75
+ export function looksLikeFigmaClipboardHtml(html: string): boolean {
76
+ return /\(figmeta\)|\(figma\)|data-metadata=|data-buffer=/i.test(html);
77
+ }
78
+
79
+ export function parseFigmaClipboardHtml(
80
+ html: string,
81
+ ): ParsedFigmaClipboardHtml {
82
+ if (byteLength(html) > MAX_CLIPBOARD_HTML_BYTES) {
83
+ throw new Error("Clipboard HTML is too large to import (max 2 MB).");
84
+ }
85
+
86
+ const metadataAttr = extractDataAttribute(html, "data-metadata");
87
+ const bufferAttr = extractDataAttribute(html, "data-buffer");
88
+ const fallbackHtml = removeFigmaHiddenSpans(html);
89
+
90
+ let meta: FigmaClipboardMeta | null = null;
91
+ if (metadataAttr) {
92
+ const metadataBase64 = stripWrapper(metadataAttr, "figmeta");
93
+ const metadataJson = decodeBase64(
94
+ metadataBase64,
95
+ "Figma metadata",
96
+ ).toString("utf8");
97
+ meta = normalizeMeta(JSON.parse(metadataJson));
98
+ }
99
+
100
+ let buffer: Buffer | undefined;
101
+ if (bufferAttr) {
102
+ const bufferBase64 = stripWrapper(bufferAttr, "figma");
103
+ buffer = decodeBase64(bufferBase64, "Figma buffer");
104
+ if (buffer.length > MAX_DECODED_BUFFER_BYTES) {
105
+ throw new Error(
106
+ "Figma clipboard buffer is too large to import (max 25 MB).",
107
+ );
108
+ }
109
+ }
110
+
111
+ const hasFigmaBuffer =
112
+ !!buffer &&
113
+ buffer.length >= FIG_KIWI_MAGIC.length &&
114
+ buffer.subarray(0, FIG_KIWI_MAGIC.length).equals(FIG_KIWI_MAGIC);
115
+
116
+ return {
117
+ meta,
118
+ buffer,
119
+ hasFigmaBuffer,
120
+ fallbackHtml: fallbackHtml || undefined,
121
+ };
122
+ }