unframer 4.1.8 → 4.2.0

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 (162) hide show
  1. package/README.md +5 -5
  2. package/dist/babel-jsx.d.ts +9 -0
  3. package/dist/babel-jsx.d.ts.map +1 -1
  4. package/dist/babel-jsx.js +72 -0
  5. package/dist/babel-jsx.js.map +1 -1
  6. package/dist/cli.d.ts +2 -2
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +262 -42
  9. package/dist/cli.js.map +1 -1
  10. package/dist/css-core.d.ts +50 -0
  11. package/dist/css-core.d.ts.map +1 -0
  12. package/dist/css-core.js +231 -0
  13. package/dist/css-core.js.map +1 -0
  14. package/dist/css.d.ts +9 -43
  15. package/dist/css.d.ts.map +1 -1
  16. package/dist/css.js +9 -232
  17. package/dist/css.js.map +1 -1
  18. package/dist/exporter.d.ts +1 -1
  19. package/dist/exporter.d.ts.map +1 -1
  20. package/dist/exporter.js +72 -11
  21. package/dist/exporter.js.map +1 -1
  22. package/dist/exporter.test.js +78 -0
  23. package/dist/exporter.test.js.map +1 -1
  24. package/dist/framer-chunks/{SqliteDatabase-VAKIICSG-R7ZS6CHH.js → SqliteDatabase-VAKIICSG-OPV4MG3Y.js} +5 -4
  25. package/dist/framer-chunks/{chunk-2DZGP7C2.js → chunk-7ZLQTYXQ.js} +5 -24
  26. package/dist/framer-chunks/chunk-IKQSD2QC.js +10 -0
  27. package/dist/framer-chunks/default-blog-sqlite-7ZHEY3GT-TVGUYU6H.js +7 -0
  28. package/dist/framer-chunks/fontshare-4THNDPMZ-FLLTWCDO.js +7 -0
  29. package/dist/framer-chunks/fontshare-B2QLD7YB-5V3XZJAH.js +7 -0
  30. package/dist/framer-chunks/fontshare-O22OBJ3D-FIG3CRN3.js +7 -0
  31. package/dist/framer-chunks/framer-font-45AI7UCZ-CKGC4MJC.js +7 -0
  32. package/dist/framer-chunks/google-3FCAKCAC-AM34UPJK.js +7 -0
  33. package/dist/framer-chunks/google-3SZHWBC6-2VTQEQ7J.js +7 -0
  34. package/dist/framer-chunks/google-GXDJLGJB-JNEJGCGD.js +7 -0
  35. package/dist/framer-chunks/sqlite-wasm-FGP37EAY-NO5QVZDI.js +7 -0
  36. package/dist/framer-chunks/sqlite3-SISQ6ENZ-5WRKGWTY.js +7 -0
  37. package/dist/framer.d.ts +128 -0
  38. package/dist/framer.js +820 -428
  39. package/dist/plugin-mcp-dist/lib/framer.d.ts +1 -7
  40. package/dist/plugin-mcp-dist/lib/framer.d.ts.map +1 -1
  41. package/dist/plugin-mcp-dist/lib/framer.js +90 -23
  42. package/dist/plugin-mcp-dist/lib/framer.js.map +1 -1
  43. package/dist/plugin-mcp-dist/lib/framer.test.d.ts +2 -0
  44. package/dist/plugin-mcp-dist/lib/framer.test.d.ts.map +1 -0
  45. package/dist/plugin-mcp-dist/lib/framer.test.js +214 -0
  46. package/dist/plugin-mcp-dist/lib/framer.test.js.map +1 -0
  47. package/dist/plugin-mcp-dist/lib/mcp-handlers.d.ts.map +1 -1
  48. package/dist/plugin-mcp-dist/lib/mcp-handlers.js +69 -11
  49. package/dist/plugin-mcp-dist/lib/mcp-handlers.js.map +1 -1
  50. package/dist/plugin-mcp-dist/lib/mcp.test.js +340 -364
  51. package/dist/plugin-mcp-dist/lib/mcp.test.js.map +1 -1
  52. package/dist/plugin-mcp-dist/lib/plugin-websocket.d.ts.map +1 -1
  53. package/dist/plugin-mcp-dist/lib/plugin-websocket.js +0 -3
  54. package/dist/plugin-mcp-dist/lib/plugin-websocket.js.map +1 -1
  55. package/dist/plugin-mcp-dist/lib/react-export.d.ts.map +1 -1
  56. package/dist/plugin-mcp-dist/lib/react-export.js +79 -61
  57. package/dist/plugin-mcp-dist/lib/react-export.js.map +1 -1
  58. package/dist/plugin-mcp-dist/lib/schema.d.ts +8 -0
  59. package/dist/plugin-mcp-dist/lib/schema.d.ts.map +1 -1
  60. package/dist/plugin-mcp-dist/lib/schema.js +18 -1
  61. package/dist/plugin-mcp-dist/lib/schema.js.map +1 -1
  62. package/dist/plugin-mcp-dist/lib/store.d.ts +1 -1
  63. package/dist/plugin-mcp-dist/lib/store.d.ts.map +1 -1
  64. package/dist/plugin-mcp-dist/lib/tunnel-integration.test.d.ts +2 -0
  65. package/dist/plugin-mcp-dist/lib/tunnel-integration.test.d.ts.map +1 -0
  66. package/dist/plugin-mcp-dist/lib/tunnel-integration.test.js +147 -0
  67. package/dist/plugin-mcp-dist/lib/tunnel-integration.test.js.map +1 -0
  68. package/dist/plugin-mcp-dist/lib/tunnel.d.ts +46 -0
  69. package/dist/plugin-mcp-dist/lib/tunnel.d.ts.map +1 -0
  70. package/dist/plugin-mcp-dist/lib/tunnel.js +117 -0
  71. package/dist/plugin-mcp-dist/lib/tunnel.js.map +1 -0
  72. package/dist/plugin-mcp-dist/lib/upstream-socket.d.ts +13 -0
  73. package/dist/plugin-mcp-dist/lib/upstream-socket.d.ts.map +1 -0
  74. package/dist/plugin-mcp-dist/lib/upstream-socket.js +56 -0
  75. package/dist/plugin-mcp-dist/lib/upstream-socket.js.map +1 -0
  76. package/dist/plugin-mcp-dist/lib/upstream-socket.test.d.ts +2 -0
  77. package/dist/plugin-mcp-dist/lib/upstream-socket.test.d.ts.map +1 -0
  78. package/dist/plugin-mcp-dist/lib/upstream-socket.test.js +212 -0
  79. package/dist/plugin-mcp-dist/lib/upstream-socket.test.js.map +1 -0
  80. package/dist/plugin-mcp-dist/lib/utils.d.ts +1 -9
  81. package/dist/plugin-mcp-dist/lib/utils.d.ts.map +1 -1
  82. package/dist/plugin-mcp-dist/lib/utils.js +13 -9
  83. package/dist/plugin-mcp-dist/lib/utils.js.map +1 -1
  84. package/dist/react.d.ts +1 -1
  85. package/dist/react.d.ts.map +1 -1
  86. package/dist/react.js +7 -13
  87. package/dist/react.js.map +1 -1
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/package.json +7 -8
  91. package/src/babel-jsx.ts +99 -0
  92. package/src/cli.ts +295 -42
  93. package/src/css-core.ts +277 -0
  94. package/src/css.tsx +10 -276
  95. package/src/exporter.test.ts +82 -0
  96. package/src/exporter.ts +92 -11
  97. package/src/framer.d.ts +128 -0
  98. package/src/framer.js +820 -428
  99. package/src/plugin-mcp-dist/lib/framer.d.ts +2 -5
  100. package/src/plugin-mcp-dist/lib/framer.d.ts.map +1 -1
  101. package/src/plugin-mcp-dist/lib/framer.js +90 -23
  102. package/src/plugin-mcp-dist/lib/framer.js.map +1 -1
  103. package/src/plugin-mcp-dist/lib/framer.test.d.ts +2 -0
  104. package/src/plugin-mcp-dist/lib/framer.test.d.ts.map +1 -0
  105. package/src/plugin-mcp-dist/lib/framer.test.js +213 -0
  106. package/src/plugin-mcp-dist/lib/framer.test.js.map +1 -0
  107. package/src/plugin-mcp-dist/lib/mcp-handlers.d.ts +2 -2
  108. package/src/plugin-mcp-dist/lib/mcp-handlers.d.ts.map +1 -1
  109. package/src/plugin-mcp-dist/lib/mcp-handlers.js +69 -11
  110. package/src/plugin-mcp-dist/lib/mcp-handlers.js.map +1 -1
  111. package/src/plugin-mcp-dist/lib/mcp.test.js +340 -364
  112. package/src/plugin-mcp-dist/lib/mcp.test.js.map +1 -1
  113. package/src/plugin-mcp-dist/lib/plugin-websocket.d.ts.map +1 -1
  114. package/src/plugin-mcp-dist/lib/plugin-websocket.js +0 -3
  115. package/src/plugin-mcp-dist/lib/plugin-websocket.js.map +1 -1
  116. package/src/plugin-mcp-dist/lib/react-export.d.ts +1 -1
  117. package/src/plugin-mcp-dist/lib/react-export.d.ts.map +1 -1
  118. package/src/plugin-mcp-dist/lib/react-export.js +79 -61
  119. package/src/plugin-mcp-dist/lib/react-export.js.map +1 -1
  120. package/src/plugin-mcp-dist/lib/schema.d.ts +11 -3
  121. package/src/plugin-mcp-dist/lib/schema.d.ts.map +1 -1
  122. package/src/plugin-mcp-dist/lib/schema.js +18 -1
  123. package/src/plugin-mcp-dist/lib/schema.js.map +1 -1
  124. package/src/plugin-mcp-dist/lib/tunnel-integration.test.d.ts +2 -0
  125. package/src/plugin-mcp-dist/lib/tunnel-integration.test.d.ts.map +1 -0
  126. package/src/plugin-mcp-dist/lib/tunnel-integration.test.js +146 -0
  127. package/src/plugin-mcp-dist/lib/tunnel-integration.test.js.map +1 -0
  128. package/src/plugin-mcp-dist/lib/tunnel.d.ts +55 -0
  129. package/src/plugin-mcp-dist/lib/tunnel.d.ts.map +1 -0
  130. package/src/plugin-mcp-dist/lib/tunnel.js +116 -0
  131. package/src/plugin-mcp-dist/lib/tunnel.js.map +1 -0
  132. package/src/plugin-mcp-dist/lib/upstream-socket.d.ts +28 -0
  133. package/src/plugin-mcp-dist/lib/upstream-socket.d.ts.map +1 -0
  134. package/src/plugin-mcp-dist/lib/upstream-socket.js +55 -0
  135. package/src/plugin-mcp-dist/lib/upstream-socket.js.map +1 -0
  136. package/src/plugin-mcp-dist/lib/upstream-socket.test.d.ts +5 -0
  137. package/src/plugin-mcp-dist/lib/upstream-socket.test.d.ts.map +1 -0
  138. package/src/plugin-mcp-dist/lib/upstream-socket.test.js +211 -0
  139. package/src/plugin-mcp-dist/lib/upstream-socket.test.js.map +1 -0
  140. package/src/plugin-mcp-dist/lib/utils.d.ts +2 -2
  141. package/src/plugin-mcp-dist/lib/utils.d.ts.map +1 -1
  142. package/src/plugin-mcp-dist/lib/utils.js +13 -9
  143. package/src/plugin-mcp-dist/lib/utils.js.map +1 -1
  144. package/src/react.tsx +7 -19
  145. package/src/styles/framer.css +34 -64
  146. package/src/version.ts +1 -1
  147. package/dist/framer-chunks/default-blog-sqlite-7ZHEY3GT-27R5KAAW.js +0 -9
  148. package/dist/framer-chunks/fontshare-4THNDPMZ-BJQGNHXN.js +0 -27099
  149. package/dist/framer-chunks/fontshare-B2QLD7YB-4BZEAA37.js +0 -119
  150. package/dist/framer-chunks/fontshare-O22OBJ3D-ALBQLFE5.js +0 -825
  151. package/dist/framer-chunks/framer-font-45AI7UCZ-LU7DEIDM.js +0 -7
  152. package/dist/framer-chunks/google-3FCAKCAC-P5EL6KGL.js +0 -623876
  153. package/dist/framer-chunks/google-3SZHWBC6-OBXS3UIH.js +0 -1627
  154. package/dist/framer-chunks/google-GXDJLGJB-HHIXFE4M.js +0 -6885
  155. package/dist/framer-chunks/sqlite-wasm-FGP37EAY-HR6PIAJQ.js +0 -11479
  156. package/dist/framer-chunks/sqlite3-SISQ6ENZ-KMXYXSSV.js +0 -9
  157. package/dist/generated/api-client.d.ts +0 -21
  158. package/dist/generated/api-client.d.ts.map +0 -1
  159. package/dist/generated/api-client.js +0 -27
  160. package/dist/generated/api-client.js.map +0 -1
  161. package/src/generated/api-client.d.ts +0 -1238
  162. package/src/generated/api-client.js +0 -26
package/src/cli.ts CHANGED
@@ -8,7 +8,6 @@ import './sentry.js'
8
8
  import { input, select, password } from '@inquirer/prompts'
9
9
 
10
10
  import { bundle, StyleToken, createExampleComponentCode } from './exporter.js'
11
- import { createClient } from './generated/api-client.js'
12
11
  import { generateStackblitzFiles } from './stackblitz.js'
13
12
 
14
13
  import { goke, wrapJsonSchema } from 'goke'
@@ -17,11 +16,12 @@ import { promisify } from 'util'
17
16
 
18
17
  import fs from 'fs'
19
18
  import path, { basename } from 'path'
20
- import { BreakpointSizes, defaultBreakpointSizes } from './css.js'
19
+ import { BreakpointSizes, defaultBreakpointSizes } from './css-core.js'
21
20
  import {
22
21
  componentNameToPath,
23
22
  dedent,
24
23
  isTruthy,
24
+ kebabCase,
25
25
  logger,
26
26
  sleep,
27
27
  spinner,
@@ -43,7 +43,7 @@ export const cli = goke('unframer')
43
43
 
44
44
  let defaultOutDir = 'framer'
45
45
 
46
- cli.command('[projectId]', 'Run unframer with optional project ID')
46
+ cli.command('[...projectIds]', 'Run unframer with one or more project IDs')
47
47
  .option('--outDir <dir>', 'Output directory')
48
48
  .option(
49
49
  '--external [package]',
@@ -53,7 +53,7 @@ cli.command('[projectId]', 'Run unframer with optional project ID')
53
53
  .option('--jsx', 'Output jsx code instead of minified .js code')
54
54
  .option('--debug', 'Enable debug logging')
55
55
  .option('--metafile', 'Generate meta.json file with build metadata')
56
- .action(async function main(projectId, options) {
56
+ .action(async function main(projectIds, options) {
57
57
  const outDir = options.outDir || defaultOutDir
58
58
  const jsx = options.jsx ?? true
59
59
  const external_ = options.external
@@ -69,17 +69,35 @@ cli.command('[projectId]', 'Run unframer with optional project ID')
69
69
  const controller = new AbortController()
70
70
  const signal = controller.signal
71
71
  const watch = options.watch
72
- if (projectId) {
73
- const { config, cwd, websiteUrl } = await configFromFetch({
74
- allExternal,
75
- externalPackages,
76
- outDir,
77
- projectId,
78
- })
72
+ if (projectIds.length > 0) {
73
+ if (watch && projectIds.length > 1) {
74
+ console.error('--watch is only supported with a single project ID')
75
+ process.exit(1)
76
+ }
77
+
78
+ // Fetch configs for all project IDs
79
+ const fetchResults = await Promise.all(
80
+ projectIds.map((projectId) => {
81
+ return configFromFetch({
82
+ allExternal,
83
+ externalPackages,
84
+ outDir,
85
+ projectId,
86
+ })
87
+ }),
88
+ )
89
+
90
+ // Merge configs when multiple projects are provided, otherwise use single config directly
91
+ const mergedConfig = fetchResults.length === 1
92
+ ? fetchResults[0].config
93
+ : mergeConfigs(fetchResults.map((r) => r.config))
94
+ const cwd = fetchResults[0].cwd
95
+ const websiteUrl = fetchResults[0].websiteUrl
96
+
79
97
  const { rebuild, buildContext } = await bundle({
80
98
  config: {
81
99
  jsx,
82
- ...config,
100
+ ...mergedConfig,
83
101
  },
84
102
  watch,
85
103
  cwd,
@@ -420,14 +438,15 @@ const hasMcpCommand = cliArgs.includes('mcp')
420
438
  const hasProjectOption = cliArgs.some((arg) => {
421
439
  return arg === '--project' || arg.startsWith('--project=')
422
440
  })
423
- const shouldUseServerApiForProjectOption = hasMcpCommand && hasProjectOption
424
- const mcpMode = shouldUseServerApiForProjectOption
441
+ const hasMcpEvalCommand = hasMcpCommand && cliArgs.includes('eval')
442
+ const hasServerApiEnv = Boolean(process.env.FRAMER_PROJECT_URL || process.env.FRAMER_API_KEY)
443
+ const shouldUseServerApi = hasMcpCommand && (hasProjectOption || hasMcpEvalCommand || hasServerApiEnv)
444
+ const mcpMode = shouldUseServerApi
425
445
  ? 'server-api'
426
446
  : config.mode || (config.mcpUrl ? 'plugin' : undefined)
427
447
 
428
448
  if (mcpMode === 'server-api') {
429
449
  // Server API mode - use framer-api directly
430
- // Commands are registered via registerServerApiCommands below
431
450
  await registerServerApiCommands()
432
451
  } else {
433
452
  // Plugin mode - use MCP transport
@@ -468,9 +487,112 @@ async function registerServerApiCommands() {
468
487
  // Dynamic import to avoid loading framer-api in plugin mode
469
488
  const { connect } = await import('framer-api')
470
489
 
490
+ // `mcp eval` — run arbitrary framer-api code against a project.
491
+ // Useful for testing API calls before implementing them in MCP tools.
492
+ // The `framer` client is available as a local variable inside the evaluated code.
493
+ cli.command('mcp eval [code]', 'Evaluate arbitrary framer-api code against a project')
494
+ .option(
495
+ '--project <url>',
496
+ 'Framer project URL. Also reads FRAMER_PROJECT_URL env var.',
497
+ )
498
+ .action(async (code: string | undefined, options: { project?: string }) => {
499
+ // Read code from argument or stdin
500
+ let evalCode = code
501
+ if (!evalCode && !process.stdin.isTTY) {
502
+ const chunks: Buffer[] = []
503
+ for await (const chunk of process.stdin) {
504
+ chunks.push(chunk)
505
+ }
506
+ evalCode = Buffer.concat(chunks).toString('utf-8').trim()
507
+ }
508
+ if (!evalCode) {
509
+ console.error('No code provided. Pass as argument or pipe via stdin.')
510
+ process.exit(1)
511
+ }
512
+
513
+ const projectUrl =
514
+ options.project ||
515
+ process.env.FRAMER_PROJECT_URL ||
516
+ loadConfig().framerProjectUrl
517
+ const apiKey =
518
+ process.env.FRAMER_API_KEY || loadConfig().framerApiKey
519
+
520
+ if (!projectUrl) {
521
+ console.error('Project URL required. Use --project option, FRAMER_PROJECT_URL env var, or set during login.')
522
+ process.exit(1)
523
+ }
524
+ if (!apiKey) {
525
+ console.error('API key required. Set FRAMER_API_KEY env var or run `unframer mcp login` first.')
526
+ process.exit(1)
527
+ }
528
+
529
+ let framerClient: Awaited<ReturnType<typeof connect>> | undefined
530
+ let actionError: unknown
531
+
532
+ try {
533
+ spinner.start('Connecting to Framer...')
534
+ framerClient = await connect(projectUrl, apiKey)
535
+ spinner.stop('Connected')
536
+
537
+ // Build an async function body. If the code is a single expression
538
+ // (no semicolons and no newlines), wrap it as a return so the result is captured.
539
+ const isExpression = !evalCode.includes(';') && !evalCode.includes('\n')
540
+ const functionBody = isExpression
541
+ ? `return (${evalCode})`
542
+ : evalCode
543
+
544
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as
545
+ new (arg: string, body: string) => (framer: unknown) => Promise<unknown>
546
+ const fn = new AsyncFunction('framer', functionBody)
547
+ const result = await fn(framerClient)
548
+
549
+ if (result !== undefined) {
550
+ const output = (() => {
551
+ try {
552
+ return JSON.stringify(result, null, 2)
553
+ } catch {
554
+ return String(result)
555
+ }
556
+ })()
557
+ console.log(output)
558
+ }
559
+ } catch (error) {
560
+ actionError = error
561
+ } finally {
562
+ if (framerClient) {
563
+ try {
564
+ await framerClient.disconnect()
565
+ } catch (disconnectError) {
566
+ logger.error('Failed disconnecting from Framer:', disconnectError)
567
+ }
568
+ }
569
+ }
570
+
571
+ if (actionError) {
572
+ console.error('Eval failed:', actionError instanceof Error ? actionError.message : actionError)
573
+ if (actionError instanceof Error && actionError.stack) {
574
+ console.error(actionError.stack)
575
+ }
576
+ process.exit(1)
577
+ }
578
+ })
579
+
471
580
  // Import tool definitions and handler from plugin-mcp
472
581
  // Note: Run `pnpm --filter plugin-mcp build && pnpm --filter plugin-mcp gen-unframer` first
473
- const { mcpTools, mcpToolHandler } = await import('./plugin-mcp-dist/lib/mcp-handlers.js')
582
+ let mcpTools: Awaited<typeof import('./plugin-mcp-dist/lib/mcp-handlers.js')>['mcpTools'] | undefined
583
+ let mcpToolHandler: Awaited<typeof import('./plugin-mcp-dist/lib/mcp-handlers.js')>['mcpToolHandler'] | undefined
584
+ try {
585
+ const imported = await import('./plugin-mcp-dist/lib/mcp-handlers.js')
586
+ mcpTools = imported.mcpTools
587
+ mcpToolHandler = imported.mcpToolHandler
588
+ } catch (error) {
589
+ if (hasMcpEvalCommand) {
590
+ // eval command only needs framer-api, not plugin-mcp handlers
591
+ logger.log('Could not load plugin-mcp handlers:', error instanceof Error ? error.message : error)
592
+ } else {
593
+ throw new Error('Failed to load plugin-mcp handlers. Run: pnpm --filter plugin-mcp tsc --incremental && pnpm --filter plugin-mcp gen-unframer', { cause: error })
594
+ }
595
+ }
474
596
  if (!mcpTools || !mcpToolHandler) {
475
597
  return
476
598
  }
@@ -614,6 +736,7 @@ async function registerServerApiCommands() {
614
736
  }
615
737
  })
616
738
  }
739
+
617
740
  }
618
741
 
619
742
  export type Config = {
@@ -663,6 +786,133 @@ type ComponentInstanceInPage = {
663
786
  webPageId: string
664
787
  }
665
788
 
789
+ /**
790
+ * Merge multiple project configs into a single Config so esbuild bundles all
791
+ * components together and deduplicates shared chunks automatically.
792
+ *
793
+ * - Components maps are merged; on name collision the second project's component
794
+ * is prefixed with the project name to disambiguate.
795
+ * - First project's projectId/projectName/fullFramerProjectId are used as the
796
+ * primary identifiers (framerSiteId in ContextProviders).
797
+ * - Arrays (componentBreakpoints, tokens, locales, framerWebPages, etc.) are concatenated.
798
+ */
799
+ function mergeConfigs(configs: Config[]): Config {
800
+ if (configs.length === 0) {
801
+ throw new Error('mergeConfigs called with empty array')
802
+ }
803
+ if (configs.length === 1) {
804
+ return configs[0]
805
+ }
806
+
807
+ const first = configs[0]
808
+
809
+ // Merge components maps, disambiguating name collisions.
810
+ // Track rename maps per config so we can update breakpoints and instances.
811
+ const mergedComponents: Record<string, string> = {}
812
+ const renameMaps: Map<string, string>[] = []
813
+
814
+ for (const config of configs) {
815
+ const projectSlug = kebabCase(config.projectName || config.projectId || '')
816
+ const renameMap = new Map<string, string>()
817
+ renameMaps.push(renameMap)
818
+
819
+ for (const [name, url] of Object.entries(config.components)) {
820
+ if (mergedComponents[name]) {
821
+ // Name collision: prefix with project slug, ensure uniqueness with numeric suffix
822
+ const base = projectSlug ? `${projectSlug}/${name}` : `${config.projectId}/${name}`
823
+ const disambiguated = uniqueComponentName(base, mergedComponents)
824
+ logger.log(`Component name collision for "${name}", renaming to "${disambiguated}"`)
825
+ renameMap.set(name, disambiguated)
826
+ mergedComponents[disambiguated] = url
827
+ } else {
828
+ mergedComponents[name] = url
829
+ }
830
+ }
831
+ }
832
+
833
+ // Merge componentBreakpoints, updating componentName for any renamed components
834
+ const mergedBreakpoints = configs.flatMap((config, i) => {
835
+ const renameMap = renameMaps[i]
836
+ return (config.componentBreakpoints || []).map((bp) => {
837
+ const renamed = renameMap.get(bp.componentName)
838
+ if (renamed) {
839
+ return { ...bp, componentName: renamed }
840
+ }
841
+ return bp
842
+ })
843
+ })
844
+
845
+ // Merge tokens, deduplicate by id (keep first occurrence)
846
+ const seenTokenIds = new Set<string>()
847
+ const mergedTokens = configs.flatMap((config) => {
848
+ return (config.tokens || []).filter((token) => {
849
+ if (seenTokenIds.has(token.id)) {
850
+ return false
851
+ }
852
+ seenTokenIds.add(token.id)
853
+ return true
854
+ })
855
+ })
856
+
857
+ // Merge locales, deduplicate by code
858
+ const seenLocaleCodes = new Set<string>()
859
+ const mergedLocales = configs.flatMap((config) => {
860
+ return (config.locales || []).filter((locale) => {
861
+ if (seenLocaleCodes.has(locale.code)) {
862
+ return false
863
+ }
864
+ seenLocaleCodes.add(locale.code)
865
+ return true
866
+ })
867
+ })
868
+
869
+ // Merge framerWebPages
870
+ const mergedWebPages = configs.flatMap((config) => {
871
+ return config.framerWebPages || []
872
+ })
873
+
874
+ // Merge componentInstancesInIndexPage, updating componentPathSlug for renamed components
875
+ const mergedInstances = configs.flatMap((config, i) => {
876
+ const renameMap = renameMaps[i]
877
+ return (config.componentInstancesInIndexPage || []).map((instance) => {
878
+ const renamed = renameMap.get(instance.componentPathSlug)
879
+ if (renamed) {
880
+ return { ...instance, componentPathSlug: renamed }
881
+ }
882
+ return instance
883
+ })
884
+ }).sort((a, b) => {
885
+ return a.pageOrdering - b.pageOrdering
886
+ })
887
+
888
+ const projectNames = configs
889
+ .map((c) => c.projectName)
890
+ .filter(isTruthy)
891
+ spinner.info(`Merging ${configs.length} projects: ${projectNames.join(', ')}`)
892
+
893
+ return {
894
+ ...first,
895
+ components: mergedComponents,
896
+ componentBreakpoints: mergedBreakpoints,
897
+ tokens: mergedTokens,
898
+ locales: mergedLocales,
899
+ framerWebPages: mergedWebPages,
900
+ componentInstancesInIndexPage: mergedInstances,
901
+ }
902
+ }
903
+
904
+ /** Generate a unique component name by appending a numeric suffix if the base already exists */
905
+ function uniqueComponentName(base: string, existing: Record<string, string>): string {
906
+ if (!existing[base]) {
907
+ return base
908
+ }
909
+ let index = 2
910
+ while (existing[`${base}-${index}`]) {
911
+ index++
912
+ }
913
+ return `${base}-${index}`
914
+ }
915
+
666
916
  export async function configFromFetch({
667
917
  projectId,
668
918
  externalPackages = [] as string[],
@@ -672,38 +922,41 @@ export async function configFromFetch({
672
922
  }) {
673
923
  logger.log(`Fetching config for project ${projectId}`)
674
924
 
675
- const url = process.env.UNFRAMER_SERVER_URL
676
- if (url) {
677
- console.log(`using server url ${url}`)
925
+ const baseUrl = (
926
+ process.env.UNFRAMER_SERVER_URL || 'https://unframer.co'
927
+ ).replace(/\/$/, '')
928
+ if (process.env.UNFRAMER_SERVER_URL) {
929
+ console.log(`using server url ${baseUrl}`)
678
930
  }
679
- const client = await createClient({
680
- url: url || 'https://unframer.co',
681
- headers: {
682
- 'X-Agent':
683
- agent ||
684
- (process.env.GITHUB_ACTIONS === 'true'
685
- ? 'github-actions'
686
- : 'cli'),
687
- },
688
- })
689
931
 
690
932
  spinner.start(`Fetching config for project ${projectId}`)
691
- const { data, error } = await client.api.plugins.reactExportPlugin
692
- .project({ projectId })
693
- .get()
694
- if (error) {
695
- if (error.status === 402) {
696
- const rawValue = error.value
697
- const buyUrl = rawValue?.buyUrl
933
+ const agentHeader =
934
+ agent ||
935
+ (process.env.GITHUB_ACTIONS === 'true' ? 'github-actions' : 'cli')
936
+ const response = await fetch(
937
+ `${baseUrl}/api/plugins/reactExportPlugin/project/${encodeURIComponent(projectId)}`,
938
+ { headers: { 'X-Agent': agentHeader } },
939
+ )
940
+ const text = await response.text()
941
+ const parsed = (() => {
942
+ try {
943
+ return JSON.parse(text)
944
+ } catch {
945
+ return text
946
+ }
947
+ })()
948
+
949
+ if (!response.ok) {
950
+ if (response.status === 402) {
951
+ const buyUrl = parsed?.buyUrl
698
952
 
699
953
  const message = (() => {
700
- if (typeof rawValue === 'object' && rawValue?.message) {
701
- return String(rawValue.message)
954
+ if (typeof parsed === 'object' && parsed?.message) {
955
+ return String(parsed.message)
702
956
  }
703
957
  return 'A React Export subscription is required to download components.'
704
958
  })()
705
959
 
706
- // spinner.error(details)
707
960
  spinner.error(message)
708
961
  console.info('')
709
962
  console.info(
@@ -712,12 +965,12 @@ export async function configFromFetch({
712
965
  console.info(green(buyUrl))
713
966
  console.info()
714
967
  process.exit(1)
715
- // throw new Error(details, { cause: error })
716
968
  }
717
969
  spinner.error('Error fetching project data:')
718
- console.error(error)
719
- throw error
970
+ console.error(parsed)
971
+ throw new Error(`HTTP ${response.status}`, { cause: parsed })
720
972
  }
973
+ const data = parsed
721
974
  spinner.info(`Got Framer project data`)
722
975
  const websiteUrl = data?.project?.websiteUrl
723
976