@ikas/component-cli 1.4.0-beta.23 → 1.4.0-beta.231

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 (211) hide show
  1. package/dist/build-tools.d.ts +37 -0
  2. package/dist/build-tools.d.ts.map +1 -0
  3. package/dist/build-tools.js +36 -0
  4. package/dist/build-tools.js.map +1 -0
  5. package/dist/commands/add-sections-to-page.d.ts +3 -0
  6. package/dist/commands/add-sections-to-page.d.ts.map +1 -0
  7. package/dist/commands/add-sections-to-page.js +39 -0
  8. package/dist/commands/add-sections-to-page.js.map +1 -0
  9. package/dist/commands/add-to-page.js +1 -1
  10. package/dist/commands/add-to-page.js.map +1 -1
  11. package/dist/commands/add.d.ts.map +1 -1
  12. package/dist/commands/add.js +11 -2
  13. package/dist/commands/add.js.map +1 -1
  14. package/dist/commands/build.d.ts.map +1 -1
  15. package/dist/commands/build.js +23 -170
  16. package/dist/commands/build.js.map +1 -1
  17. package/dist/commands/config-texts.d.ts +39 -0
  18. package/dist/commands/config-texts.d.ts.map +1 -0
  19. package/dist/commands/config-texts.js +192 -0
  20. package/dist/commands/config-texts.js.map +1 -0
  21. package/dist/commands/config.d.ts +45 -0
  22. package/dist/commands/config.d.ts.map +1 -1
  23. package/dist/commands/config.js +1205 -154
  24. package/dist/commands/config.js.map +1 -1
  25. package/dist/commands/create-design-tokens.d.ts +24 -0
  26. package/dist/commands/create-design-tokens.d.ts.map +1 -0
  27. package/dist/commands/create-design-tokens.js +388 -0
  28. package/dist/commands/create-design-tokens.js.map +1 -0
  29. package/dist/commands/create-global-variable.d.ts +3 -0
  30. package/dist/commands/create-global-variable.d.ts.map +1 -0
  31. package/dist/commands/create-global-variable.js +73 -0
  32. package/dist/commands/create-global-variable.js.map +1 -0
  33. package/dist/commands/create-page.d.ts +3 -0
  34. package/dist/commands/create-page.d.ts.map +1 -0
  35. package/dist/commands/create-page.js +31 -0
  36. package/dist/commands/create-page.js.map +1 -0
  37. package/dist/commands/delete-page.d.ts +3 -0
  38. package/dist/commands/delete-page.d.ts.map +1 -0
  39. package/dist/commands/delete-page.js +28 -0
  40. package/dist/commands/delete-page.js.map +1 -0
  41. package/dist/commands/delete-theme-globals.d.ts +4 -0
  42. package/dist/commands/delete-theme-globals.d.ts.map +1 -0
  43. package/dist/commands/delete-theme-globals.js +50 -0
  44. package/dist/commands/delete-theme-globals.js.map +1 -0
  45. package/dist/commands/dev.d.ts.map +1 -1
  46. package/dist/commands/dev.js +328 -39
  47. package/dist/commands/dev.js.map +1 -1
  48. package/dist/commands/enable-builtin-pages.d.ts +3 -0
  49. package/dist/commands/enable-builtin-pages.d.ts.map +1 -0
  50. package/dist/commands/enable-builtin-pages.js +28 -0
  51. package/dist/commands/enable-builtin-pages.js.map +1 -0
  52. package/dist/commands/get-component-props.d.ts +3 -0
  53. package/dist/commands/get-component-props.d.ts.map +1 -0
  54. package/dist/commands/get-component-props.js +32 -0
  55. package/dist/commands/get-component-props.js.map +1 -0
  56. package/dist/commands/get-page-by-type.d.ts +3 -0
  57. package/dist/commands/get-page-by-type.d.ts.map +1 -0
  58. package/dist/commands/get-page-by-type.js +25 -0
  59. package/dist/commands/get-page-by-type.js.map +1 -0
  60. package/dist/commands/get-section-values.d.ts +3 -0
  61. package/dist/commands/get-section-values.d.ts.map +1 -0
  62. package/dist/commands/get-section-values.js +43 -0
  63. package/dist/commands/get-section-values.js.map +1 -0
  64. package/dist/commands/import.js +1 -1
  65. package/dist/commands/import.js.map +1 -1
  66. package/dist/commands/list-builtin-pages.d.ts +3 -0
  67. package/dist/commands/list-builtin-pages.d.ts.map +1 -0
  68. package/dist/commands/list-builtin-pages.js +28 -0
  69. package/dist/commands/list-builtin-pages.js.map +1 -0
  70. package/dist/commands/list-entities.d.ts +3 -0
  71. package/dist/commands/list-entities.d.ts.map +1 -0
  72. package/dist/commands/list-entities.js +32 -0
  73. package/dist/commands/list-entities.js.map +1 -0
  74. package/dist/commands/list-imported.js +1 -1
  75. package/dist/commands/list-imported.js.map +1 -1
  76. package/dist/commands/list-page-sections.d.ts +3 -0
  77. package/dist/commands/list-page-sections.d.ts.map +1 -0
  78. package/dist/commands/list-page-sections.js +31 -0
  79. package/dist/commands/list-page-sections.js.map +1 -0
  80. package/dist/commands/list-pages.js +1 -1
  81. package/dist/commands/list-pages.js.map +1 -1
  82. package/dist/commands/list-theme-globals.d.ts +3 -0
  83. package/dist/commands/list-theme-globals.d.ts.map +1 -0
  84. package/dist/commands/list-theme-globals.js +22 -0
  85. package/dist/commands/list-theme-globals.js.map +1 -0
  86. package/dist/commands/list-translations.d.ts +3 -0
  87. package/dist/commands/list-translations.d.ts.map +1 -0
  88. package/dist/commands/list-translations.js +33 -0
  89. package/dist/commands/list-translations.js.map +1 -0
  90. package/dist/commands/move-page-section.d.ts +3 -0
  91. package/dist/commands/move-page-section.d.ts.map +1 -0
  92. package/dist/commands/move-page-section.js +62 -0
  93. package/dist/commands/move-page-section.js.map +1 -0
  94. package/dist/commands/publish-theme.d.ts +3 -0
  95. package/dist/commands/publish-theme.d.ts.map +1 -0
  96. package/dist/commands/publish-theme.js +29 -0
  97. package/dist/commands/publish-theme.js.map +1 -0
  98. package/dist/commands/publish.d.ts.map +1 -1
  99. package/dist/commands/publish.js +72 -3
  100. package/dist/commands/publish.js.map +1 -1
  101. package/dist/commands/remove-page-section.d.ts +3 -0
  102. package/dist/commands/remove-page-section.d.ts.map +1 -0
  103. package/dist/commands/remove-page-section.js +40 -0
  104. package/dist/commands/remove-page-section.js.map +1 -0
  105. package/dist/commands/search-products.d.ts +3 -0
  106. package/dist/commands/search-products.d.ts.map +1 -0
  107. package/dist/commands/search-products.js +40 -0
  108. package/dist/commands/search-products.js.map +1 -0
  109. package/dist/commands/translation-languages.d.ts +3 -0
  110. package/dist/commands/translation-languages.d.ts.map +1 -0
  111. package/dist/commands/translation-languages.js +43 -0
  112. package/dist/commands/translation-languages.js.map +1 -0
  113. package/dist/commands/update-builtin-page-prop.d.ts +3 -0
  114. package/dist/commands/update-builtin-page-prop.d.ts.map +1 -0
  115. package/dist/commands/update-builtin-page-prop.js +49 -0
  116. package/dist/commands/update-builtin-page-prop.js.map +1 -0
  117. package/dist/commands/update-global-variable.d.ts +3 -0
  118. package/dist/commands/update-global-variable.d.ts.map +1 -0
  119. package/dist/commands/update-global-variable.js +70 -0
  120. package/dist/commands/update-global-variable.js.map +1 -0
  121. package/dist/commands/update-page-sections.d.ts +3 -0
  122. package/dist/commands/update-page-sections.d.ts.map +1 -0
  123. package/dist/commands/update-page-sections.js +42 -0
  124. package/dist/commands/update-page-sections.js.map +1 -0
  125. package/dist/commands/update-section-prop.d.ts +3 -0
  126. package/dist/commands/update-section-prop.d.ts.map +1 -0
  127. package/dist/commands/update-section-prop.js +81 -0
  128. package/dist/commands/update-section-prop.js.map +1 -0
  129. package/dist/commands/update-translations.d.ts +3 -0
  130. package/dist/commands/update-translations.d.ts.map +1 -0
  131. package/dist/commands/update-translations.js +45 -0
  132. package/dist/commands/update-translations.js.map +1 -0
  133. package/dist/commands/upload-image.d.ts +3 -0
  134. package/dist/commands/upload-image.d.ts.map +1 -0
  135. package/dist/commands/upload-image.js +38 -0
  136. package/dist/commands/upload-image.js.map +1 -0
  137. package/dist/commands/upload-images.d.ts +3 -0
  138. package/dist/commands/upload-images.d.ts.map +1 -0
  139. package/dist/commands/upload-images.js +48 -0
  140. package/dist/commands/upload-images.js.map +1 -0
  141. package/dist/index.d.ts.map +1 -1
  142. package/dist/index.js +66 -1
  143. package/dist/index.js.map +1 -1
  144. package/dist/types.d.ts +77 -0
  145. package/dist/types.d.ts.map +1 -1
  146. package/dist/utils/archive-project.d.ts +50 -0
  147. package/dist/utils/archive-project.d.ts.map +1 -0
  148. package/dist/utils/archive-project.js +178 -0
  149. package/dist/utils/archive-project.js.map +1 -0
  150. package/dist/utils/compile.d.ts +17 -2
  151. package/dist/utils/compile.d.ts.map +1 -1
  152. package/dist/utils/compile.js +742 -109
  153. package/dist/utils/compile.js.map +1 -1
  154. package/dist/utils/component-helpers.d.ts +236 -4
  155. package/dist/utils/component-helpers.d.ts.map +1 -1
  156. package/dist/utils/component-helpers.js +656 -37
  157. package/dist/utils/component-helpers.js.map +1 -1
  158. package/dist/utils/css-import-resolver.d.ts +4 -1
  159. package/dist/utils/css-import-resolver.d.ts.map +1 -1
  160. package/dist/utils/css-import-resolver.js +9 -1
  161. package/dist/utils/css-import-resolver.js.map +1 -1
  162. package/dist/utils/dev-server-auth.d.ts +28 -0
  163. package/dist/utils/dev-server-auth.d.ts.map +1 -0
  164. package/dist/utils/dev-server-auth.js +79 -0
  165. package/dist/utils/dev-server-auth.js.map +1 -0
  166. package/dist/utils/editor-action-client.d.ts +20 -3
  167. package/dist/utils/editor-action-client.d.ts.map +1 -1
  168. package/dist/utils/editor-action-client.js +81 -19
  169. package/dist/utils/editor-action-client.js.map +1 -1
  170. package/dist/utils/esm-transform.d.ts +30 -0
  171. package/dist/utils/esm-transform.d.ts.map +1 -1
  172. package/dist/utils/esm-transform.js +169 -2
  173. package/dist/utils/esm-transform.js.map +1 -1
  174. package/dist/utils/find-component-for-file.d.ts +12 -0
  175. package/dist/utils/find-component-for-file.d.ts.map +1 -0
  176. package/dist/utils/find-component-for-file.js +24 -0
  177. package/dist/utils/find-component-for-file.js.map +1 -0
  178. package/dist/utils/forbid-deep-imports.d.ts +3 -0
  179. package/dist/utils/forbid-deep-imports.d.ts.map +1 -0
  180. package/dist/utils/forbid-deep-imports.js +54 -0
  181. package/dist/utils/forbid-deep-imports.js.map +1 -0
  182. package/dist/utils/forbid-external-packages.d.ts +26 -0
  183. package/dist/utils/forbid-external-packages.d.ts.map +1 -0
  184. package/dist/utils/forbid-external-packages.js +99 -0
  185. package/dist/utils/forbid-external-packages.js.map +1 -0
  186. package/dist/utils/i18n.d.ts +98 -0
  187. package/dist/utils/i18n.d.ts.map +1 -0
  188. package/dist/utils/i18n.js +408 -0
  189. package/dist/utils/i18n.js.map +1 -0
  190. package/dist/utils/load-image.d.ts +16 -0
  191. package/dist/utils/load-image.d.ts.map +1 -0
  192. package/dist/utils/load-image.js +50 -0
  193. package/dist/utils/load-image.js.map +1 -0
  194. package/dist/utils/storefront-t.d.ts +17 -0
  195. package/dist/utils/storefront-t.d.ts.map +1 -0
  196. package/dist/utils/storefront-t.js +187 -0
  197. package/dist/utils/storefront-t.js.map +1 -0
  198. package/dist/utils/vendor-storefront.d.ts +13 -0
  199. package/dist/utils/vendor-storefront.d.ts.map +1 -0
  200. package/dist/utils/vendor-storefront.js +91 -0
  201. package/dist/utils/vendor-storefront.js.map +1 -0
  202. package/dist/utils/websocket-server.d.ts +165 -7
  203. package/dist/utils/websocket-server.d.ts.map +1 -1
  204. package/dist/utils/websocket-server.js +326 -45
  205. package/dist/utils/websocket-server.js.map +1 -1
  206. package/package.json +19 -2
  207. package/templates/add/section.css +11 -0
  208. package/dist/commands/create.d.ts +0 -4
  209. package/dist/commands/create.d.ts.map +0 -1
  210. package/dist/commands/create.js +0 -176
  211. package/dist/commands/create.js.map +0 -1
@@ -4,6 +4,10 @@ import * as os from "os";
4
4
  import * as path from "path";
5
5
  import { resolveCssImports } from "./css-import-resolver.js";
6
6
  import { ikasComponentUtilsPlugin } from "./observer-runtime.js";
7
+ import { storefrontTPlugin } from "./storefront-t.js";
8
+ import { forbidDeepStorefrontImportsPlugin } from "./forbid-deep-imports.js";
9
+ import { forbidExternalPackagesPlugin } from "./forbid-external-packages.js";
10
+ import { isVendoredDependencyFile, vendorStorefrontPlugin } from "./vendor-storefront.js";
7
11
  /**
8
12
  * Shim that provides the JSX factory/fragment under collision-proof names.
9
13
  *
@@ -21,26 +25,45 @@ import { ikasComponentUtilsPlugin } from "./observer-runtime.js";
21
25
  const PREACT_JSX_SHIM = path.join(os.tmpdir(), "ikas-preact-jsx-shim.mjs");
22
26
  fs.writeFileSync(PREACT_JSX_SHIM, 'export{h as __ikas_h,Fragment as __ikas_Fragment}from"preact";\n');
23
27
  /**
24
- * External dependencies - these will be provided by the generated storefront
28
+ * External dependencies - these will be provided by the generated storefront.
29
+ *
30
+ * The `@ikas/bp-storefront*` packages are intentionally NOT listed here: they are externalized
31
+ * by `forbidDeepStorefrontImportsPlugin()` instead, which externalizes the allowed ROOT import
32
+ * and rejects private deep subpaths. (esbuild applies this `external` array before plugin
33
+ * onResolve, so keeping them here would let `/dist/...` deep imports slip through unchecked.)
25
34
  */
26
35
  const SERVER_EXTERNALS = [
27
36
  "preact",
28
37
  "preact/hooks",
29
38
  "preact-render-to-string",
30
- "mobx",
31
- "@ikas/bp-storefront",
32
- "@ikas/bp-storefront-models",
33
- "@ikas/bp-storefront-config",
34
- "@ikas/bp-storefront-api"
39
+ "mobx"
35
40
  ];
36
41
  const CLIENT_EXTERNALS = [
37
42
  "preact",
38
43
  "preact/hooks",
39
- "mobx",
40
- "@ikas/bp-storefront",
41
- "@ikas/bp-storefront-models",
42
- "@ikas/bp-storefront-config"
44
+ "mobx"
43
45
  ];
46
+ /**
47
+ * The server and client builds MUST compile with the SAME esbuild `target` so
48
+ * syntax is lowered identically and code-split shared chunks come out
49
+ * byte-identical (same content hash) on both sides.
50
+ *
51
+ * If the targets diverge (previously server=node18 vs client=es2020), esbuild
52
+ * lowers modern syntax differently: e.g. a class field (`x = 1`, ES2022) stays
53
+ * native on node18 but is down-leveled into a `__publicField` helper on es2020.
54
+ * That helper lands in the shared runtime-helper chunk, so the helper chunk's
55
+ * hash differs between builds — and every chunk that imports it by hashed name
56
+ * (`./chunk-<hash>.js`) diverges too. The result is "server-only" shared chunks
57
+ * with no client twin, which the client-derived `usedSharedModuleIds` never
58
+ * lists, so they get pruned/dropped downstream ("Could not resolve
59
+ * ./chunk-XXXX.js" at SSR build).
60
+ *
61
+ * es2020 is the floor the browser build needs; node18 executes es2020 fine, so
62
+ * both builds use es2020 and produce identical shared chunks. Keep this the
63
+ * single source of truth for both `platform: node` and `platform: browser`
64
+ * builds — do not set per-build `target` overrides.
65
+ */
66
+ const SHARED_BUILD_TARGET = "es2020";
44
67
  /**
45
68
  * Compile a single component's TSX and CSS
46
69
  */
@@ -52,10 +75,15 @@ export async function compileComponent(entryPath, stylesPath, componentId) {
52
75
  entryPoints: [path.resolve(process.cwd(), entryPath)],
53
76
  format: "esm",
54
77
  platform: "node",
55
- target: "node18",
78
+ target: SHARED_BUILD_TARGET,
56
79
  write: false,
57
80
  external: SERVER_EXTERNALS,
58
- plugins: [ikasComponentUtilsPlugin()],
81
+ plugins: [
82
+ forbidDeepStorefrontImportsPlugin(),
83
+ forbidExternalPackagesPlugin(),
84
+ ikasComponentUtilsPlugin(),
85
+ storefrontTPlugin(),
86
+ ],
59
87
  });
60
88
  // Build client bundle (for hydration)
61
89
  const clientResult = await esbuild.build({
@@ -63,17 +91,37 @@ export async function compileComponent(entryPath, stylesPath, componentId) {
63
91
  entryPoints: [path.resolve(process.cwd(), entryPath)],
64
92
  format: "esm",
65
93
  platform: "browser",
66
- target: "es2020",
94
+ target: SHARED_BUILD_TARGET,
67
95
  write: false,
68
96
  external: CLIENT_EXTERNALS,
69
- plugins: [ikasComponentUtilsPlugin()],
97
+ // Needed for the sibling-CSS collection below: the input list tells us which
98
+ // sub-component directories this entry actually pulled in.
99
+ metafile: true,
100
+ plugins: [
101
+ forbidDeepStorefrontImportsPlugin(),
102
+ forbidExternalPackagesPlugin(),
103
+ ikasComponentUtilsPlugin(),
104
+ storefrontTPlugin(),
105
+ ],
70
106
  });
71
- // Read CSS (with import resolution)
107
+ // Read CSS (with import resolution). Track what the @imports inlined so the
108
+ // sibling pass below cannot append the same file twice.
72
109
  const cssPath = path.resolve(process.cwd(), stylesPath);
110
+ const inlinedCssPaths = new Set();
73
111
  let css = "";
74
112
  if (fs.existsSync(cssPath)) {
75
113
  const rawCss = fs.readFileSync(cssPath, "utf-8");
76
- css = resolveCssImports(rawCss, cssPath);
114
+ css = resolveCssImports(rawCss, cssPath, inlinedCssPaths);
115
+ }
116
+ // Collect each bundled sub-component's sibling styles.css, the same way the
117
+ // multi-component pipeline (`dev`/`publish`) does. Without this, CSS that works
118
+ // under `dev` silently vanishes from a `build` artifact.
119
+ const entryDir = path.dirname(path.resolve(process.cwd(), entryPath));
120
+ for (const depCssPath of collectSourceCssFiles(Object.keys(clientResult.metafile.inputs), entryDir)) {
121
+ if (inlinedCssPaths.has(path.resolve(depCssPath)))
122
+ continue;
123
+ const rawDepCss = fs.readFileSync(depCssPath, "utf-8");
124
+ css += "\n" + resolveCssImports(rawDepCss, depCssPath, inlinedCssPaths);
77
125
  }
78
126
  // Scope CSS
79
127
  const scopedCss = scopeCSS(css, componentId);
@@ -90,16 +138,32 @@ export async function compileComponent(entryPath, stylesPath, componentId) {
90
138
  serverJs: "",
91
139
  clientJs: "",
92
140
  scopedCss: "",
93
- error: error.message || String(error)
141
+ error: withCssImportHint(error.message || String(error))
94
142
  };
95
143
  }
96
144
  }
145
+ // esbuild's raw message when a TSX file does `import "./styles.css"` gives no clue about the
146
+ // ikas conventions; append a hint pointing at the two supported CSS paths.
147
+ function withCssImportHint(message) {
148
+ if (/Cannot import "[^"]*\.css"/.test(message) || /No loader is configured for "\.css" files/.test(message)) {
149
+ return (message +
150
+ "\n\nHint: do not import CSS from TSX. A sub-component's sibling styles.css is collected " +
151
+ "automatically by every command (`dev`, `publish`, `build`) — just leave it next to the " +
152
+ "sub-component. To pull in CSS that has no sibling source file, @import it from the " +
153
+ "parent component's styles.css.");
154
+ }
155
+ return message;
156
+ }
97
157
  /**
98
- * Scope CSS selectors with a component-specific class prefix
158
+ * Scope CSS selectors with a component-specific class prefix, then namespace any
159
+ * document-global CSS identifiers (`@keyframes`, `@font-face` font-family,
160
+ * `@counter-style`) DEFINED in this component so two components — or the host theme —
161
+ * declaring the same name can't collide in the page's global namespace.
99
162
  */
100
163
  export function scopeCSS(css, componentId) {
101
164
  const scopeClass = `cc_${componentId.replace(/[^a-zA-Z0-9]/g, "_")}`;
102
- return scopeCSSWithClasses(css, [scopeClass]);
165
+ const scoped = scopeCSSWithClasses(css, [scopeClass]);
166
+ return renameCollidingGlobals(scoped, `${scopeClass}_`);
103
167
  }
104
168
  /**
105
169
  * Scope CSS selectors with multiple class prefixes (for shared chunks).
@@ -109,63 +173,523 @@ export function scopeCSSMulti(css, componentIds) {
109
173
  const scopeClasses = componentIds.map(id => `cc_${id.replace(/[^a-zA-Z0-9]/g, "_")}`);
110
174
  return scopeCSSWithClasses(css, scopeClasses);
111
175
  }
176
+ // Conditional group at-rules whose blocks nest normal style rules and must be
177
+ // scoped recursively. This is an ALLOWLIST on purpose: at-rules NOT listed here
178
+ // (@keyframes, @font-face, @page, @property, @counter-style, ...) have non-selector
179
+ // bodies — e.g. @keyframes contains `0%`/`from` keyframe selectors that must NOT be
180
+ // prefixed — so their bodies are copied through untouched. Vendor-prefixed conditional
181
+ // rules (@-moz-document) match via the optional prefix group; @-webkit-keyframes does
182
+ // NOT match (the alternation excludes "keyframes"), which is what we want.
183
+ //
184
+ // NOTE: This scoper is duplicated in editor-models/src/helpers/code-component-css-scope.ts
185
+ // (the editor-preview / code-generator path). The CLI ships standalone to npm and cannot
186
+ // import that private package, so the two intentionally diverge. Keep parsing fixes in sync.
187
+ const NESTING_AT_RULES = /^@(?:-\w+-)?(media|supports|container|layer|document|scope|starting-style)\b/i;
112
188
  /**
113
- * Internal: scope CSS selectors with one or more scope class prefixes
189
+ * Internal: scope CSS selectors with one or more scope class prefixes.
190
+ *
191
+ * Character-based walker, NOT line-based: a rule's selector list is everything up
192
+ * to its opening `{`, so lists written across multiple lines (`.a,\n.b { ... }`)
193
+ * are scoped as a whole. The previous line-based implementation only scoped the
194
+ * selector fragment sharing a line with `{`, leaving the other selectors unscoped
195
+ * (they either lost the cascade to scoped rules or leaked into other components).
196
+ *
197
+ * Comments, string literals, and `\` escapes are treated as inert spans (via
198
+ * `skipInertSpan`) by every structural scanner, so a `{`, `}`, `;`, or `,` that
199
+ * appears inside a comment / string / escape can never be mistaken for real
200
+ * structure (which would otherwise drop the scope prefix and leak styles globally).
114
201
  */
115
202
  function scopeCSSWithClasses(css, scopeClasses) {
116
- const lines = css.split("\n");
117
- const scopedLines = [];
118
- let inMediaQuery = false;
119
- let keyframesDepth = 0;
120
- for (const line of lines) {
121
- const trimmed = line.trim();
122
- if (!trimmed) {
123
- scopedLines.push(line);
203
+ let out = "";
204
+ for (let i = 0; i < css.length;) {
205
+ const ch = css[i];
206
+ // Copy comments verbatim
207
+ if (ch === "/" && css[i + 1] === "*") {
208
+ const end = css.indexOf("*/", i + 2);
209
+ if (end === -1)
210
+ return out + css.slice(i);
211
+ out += css.slice(i, end + 2);
212
+ i = end + 2;
213
+ continue;
214
+ }
215
+ // Copy whitespace and stray closing braces
216
+ if (/\s/.test(ch) || ch === "}") {
217
+ out += ch;
218
+ i++;
219
+ continue;
220
+ }
221
+ if (ch === "@") {
222
+ const headerEnd = findAtRuleHeaderEnd(css, i);
223
+ // Block-less at-rule (@import, @charset, @layer a;, ...) — copy through
224
+ if (headerEnd >= css.length || css[headerEnd] === ";") {
225
+ out += css.slice(i, Math.min(headerEnd + 1, css.length));
226
+ i = headerEnd + 1;
227
+ continue;
228
+ }
229
+ const header = css.slice(i, headerEnd);
230
+ const blockEnd = findBlockEnd(css, headerEnd + 1);
231
+ const body = css.slice(headerEnd + 1, blockEnd);
232
+ const transformed = NESTING_AT_RULES.test(header.trim())
233
+ ? scopeCSSWithClasses(body, scopeClasses)
234
+ : body;
235
+ // Always emit the closing brace: when the source block is unterminated
236
+ // (blockEnd === css.length) this repairs it, so concatenated component CSS
237
+ // can't leak one component's block over the next.
238
+ out += `${header}{${transformed}}`;
239
+ i = blockEnd + 1;
124
240
  continue;
125
241
  }
126
- if (trimmed.startsWith("@media")) {
127
- inMediaQuery = true;
128
- scopedLines.push(line);
242
+ // Normal style rule: selector list runs up to the rule's opening `{`
243
+ const selEnd = findSelectorListEnd(css, i);
244
+ if (selEnd >= css.length) {
245
+ // Trailing content with no block (e.g. a dangling declaration at EOF) — copy through
246
+ out += css.slice(i);
247
+ break;
248
+ }
249
+ if (css[selEnd] === ";") {
250
+ // A stray top-level `;` (or a stray declaration fragment ending in `;`) — not a rule.
251
+ // DROP it. Copying it through is not enough: per the CSS syntax spec a browser folds a
252
+ // stray `;` into the NEXT rule's prelude when consuming a qualified rule, which makes
253
+ // that selector invalid and drops the whole following rule. Dropping the stray token
254
+ // keeps the next rule valid and correctly scoped.
255
+ i = selEnd + 1;
129
256
  continue;
130
257
  }
131
- if (trimmed.startsWith("@keyframes") || trimmed.startsWith("@-webkit-keyframes")) {
132
- keyframesDepth = 1;
133
- scopedLines.push(line);
258
+ if (css[selEnd] === "}") {
259
+ // Content before a stray closing brace — copy through verbatim and let the main loop
260
+ // emit the `}`.
261
+ out += css.slice(i, selEnd);
262
+ i = selEnd;
134
263
  continue;
135
264
  }
136
- // Inside @keyframes: track brace depth, pass lines through unscoped
137
- if (keyframesDepth > 0) {
138
- for (const ch of trimmed) {
139
- if (ch === "{")
140
- keyframesDepth++;
141
- else if (ch === "}")
142
- keyframesDepth--;
265
+ const selectorList = css.slice(i, selEnd);
266
+ const blockEnd = findBlockEnd(css, selEnd + 1);
267
+ const body = css.slice(selEnd + 1, blockEnd);
268
+ const scopedSelector = splitSelectorList(selectorList)
269
+ .map(s => s.trim())
270
+ .filter(Boolean)
271
+ .flatMap(s => scopeClasses.map(cls => `.${cls} ${s}`))
272
+ .join(", ");
273
+ // Always emit the closing brace (see at-rule branch above for rationale).
274
+ out += `${scopedSelector} {${body}}`;
275
+ i = blockEnd + 1;
276
+ }
277
+ return out;
278
+ }
279
+ /**
280
+ * If an inert span — a `/* ... *\/` comment, a `"..."` / `'...'` string literal, an
281
+ * unquoted `url(...)` token, or a `\x` escape — starts at `s[i]`, return the index just
282
+ * past it; otherwise return -1.
283
+ *
284
+ * Every structural scanner below funnels through this so that delimiters (`{ } ; , ( )`)
285
+ * living inside comments, strings, urls, or escapes are never treated as structure. String
286
+ * scanning honors `\` escapes and stops at a raw newline, matching CSS "bad-string" error
287
+ * recovery (an unterminated string ends at the line break) so a missing quote can't
288
+ * swallow every rule that follows it.
289
+ */
290
+ function skipInertSpan(s, i) {
291
+ const c = s[i];
292
+ if (c === "/" && s[i + 1] === "*") {
293
+ const end = s.indexOf("*/", i + 2);
294
+ return end === -1 ? s.length : end + 2;
295
+ }
296
+ if (c === '"' || c === "'") {
297
+ for (let j = i + 1; j < s.length; j++) {
298
+ const d = s[j];
299
+ if (d === "\\") {
300
+ j++; // escape: skip the next char
301
+ continue;
302
+ }
303
+ if (d === c)
304
+ return j + 1; // closing quote
305
+ if (d === "\n" || d === "\r" || d === "\f")
306
+ return j; // bad-string ends at newline
307
+ }
308
+ return s.length;
309
+ }
310
+ // Unquoted url(...) token: per the CSS tokenizer its content is opaque, so braces,
311
+ // semicolons, and parens inside it must NOT be read as structure (an unbalanced `}` in
312
+ // `url(x}y.png)` or `{` in an inline SVG data-uri would otherwise mis-terminate the
313
+ // enclosing block). A QUOTED url (`url("...")`) is a normal function + string and is left
314
+ // to the string branch above. `url` must be a standalone ident, not the tail of a longer
315
+ // identifier like `myurl(`.
316
+ if ((c === "u" || c === "U") && /^url\(/i.test(s.slice(i, i + 4))) {
317
+ const prev = i > 0 ? s[i - 1] : "";
318
+ if (!/[A-Za-z0-9_-]/.test(prev)) {
319
+ let j = i + 4;
320
+ while (j < s.length && /\s/.test(s[j]))
321
+ j++; // optional leading whitespace
322
+ if (s[j] !== '"' && s[j] !== "'") {
323
+ for (; j < s.length; j++) {
324
+ if (s[j] === "\\") {
325
+ j++; // escaped char inside the url (e.g. `url(foo\)bar)`)
326
+ continue;
327
+ }
328
+ if (s[j] === ")")
329
+ return j + 1; // end of url token
330
+ }
331
+ return s.length; // unterminated url() — consume to EOF (bad-url)
143
332
  }
144
- scopedLines.push(line);
333
+ }
334
+ }
335
+ if (c === "\\")
336
+ return i + 2; // escaped char outside a string (e.g. `.a\,b`)
337
+ return -1;
338
+ }
339
+ /** Find the first top-level `{` or `;` that ends an at-rule's header. Respects `()` / `[]`
340
+ * and inert spans (strings/comments/escapes). */
341
+ function findAtRuleHeaderEnd(css, start) {
342
+ let pDepth = 0;
343
+ for (let j = start; j < css.length;) {
344
+ const skip = skipInertSpan(css, j);
345
+ if (skip !== -1) {
346
+ j = skip;
347
+ continue;
348
+ }
349
+ const c = css[j];
350
+ if (c === "(" || c === "[")
351
+ pDepth++;
352
+ else if (c === ")" || c === "]")
353
+ pDepth--;
354
+ else if (pDepth === 0 && (c === "{" || c === ";"))
355
+ return j;
356
+ j++;
357
+ }
358
+ return css.length;
359
+ }
360
+ /**
361
+ * Find the matching `}` for the block whose body starts at `blockStart` (char after `{`).
362
+ * Inert spans are skipped so braces inside `content: "}"`, comments, or escapes don't
363
+ * terminate the block early.
364
+ */
365
+ function findBlockEnd(css, blockStart) {
366
+ let depth = 1;
367
+ for (let j = blockStart; j < css.length;) {
368
+ const skip = skipInertSpan(css, j);
369
+ if (skip !== -1) {
370
+ j = skip;
145
371
  continue;
146
372
  }
147
- if (trimmed === "}" && inMediaQuery) {
148
- inMediaQuery = false;
149
- scopedLines.push(line);
373
+ const c = css[j];
374
+ if (c === "{")
375
+ depth++;
376
+ else if (c === "}") {
377
+ depth--;
378
+ if (depth === 0)
379
+ return j;
380
+ }
381
+ j++;
382
+ }
383
+ return css.length;
384
+ }
385
+ /** Find the `{`, `}`, or `;` that terminates a selector list. A `}` or `;` here means the
386
+ * run was not a real style rule. Respects `()` / `[]` and inert spans. */
387
+ function findSelectorListEnd(css, start) {
388
+ let pDepth = 0;
389
+ for (let j = start; j < css.length;) {
390
+ const skip = skipInertSpan(css, j);
391
+ if (skip !== -1) {
392
+ j = skip;
150
393
  continue;
151
394
  }
152
- if (!trimmed.includes("{") || trimmed.startsWith("@")) {
153
- scopedLines.push(line);
395
+ const c = css[j];
396
+ if (c === "(" || c === "[")
397
+ pDepth++;
398
+ else if (c === ")" || c === "]")
399
+ pDepth--;
400
+ else if (pDepth === 0 && (c === "{" || c === "}" || c === ";"))
401
+ return j;
402
+ j++;
403
+ }
404
+ return css.length;
405
+ }
406
+ /** Split a selector list on top-level commas. Respects `()` / `[]` and inert spans, so a
407
+ * comma inside a comment, string, or `\` escape (e.g. `.a\,b`) does not split the list. */
408
+ function splitSelectorList(s) {
409
+ const result = [];
410
+ let depth = 0;
411
+ let start = 0;
412
+ for (let i = 0; i < s.length;) {
413
+ const skip = skipInertSpan(s, i);
414
+ if (skip !== -1) {
415
+ i = skip;
154
416
  continue;
155
417
  }
156
- const [selector, rest] = line.split("{");
157
- if (selector && rest !== undefined) {
158
- const scopedSelector = selector
159
- .split(",")
160
- .flatMap((s) => scopeClasses.map(cls => `.${cls} ${s.trim()}`))
161
- .join(", ");
162
- scopedLines.push(`${scopedSelector} {${rest}`);
418
+ const ch = s[i];
419
+ if (ch === "(" || ch === "[")
420
+ depth++;
421
+ else if (ch === ")" || ch === "]")
422
+ depth--;
423
+ else if (ch === "," && depth === 0) {
424
+ result.push(s.slice(start, i));
425
+ start = i + 1;
163
426
  }
164
- else {
165
- scopedLines.push(line);
427
+ i++;
428
+ }
429
+ result.push(s.slice(start));
430
+ return result;
431
+ }
432
+ /* ============================================================================
433
+ * Global-identifier namespacing.
434
+ *
435
+ * `@keyframes`, `@font-face` font-family names, and `@counter-style` names live in a
436
+ * DOCUMENT-GLOBAL namespace — scoping selectors with `.cc_<id>` does not isolate them, so
437
+ * two components (or the theme) defining the same name would collide (last definition wins
438
+ * document-wide). `renameCollidingGlobals` prefixes every such name DEFINED in this CSS,
439
+ * plus its references within the SAME CSS, with the component's scope prefix. Font names are
440
+ * also rewritten inside custom-property values (`--font-head: "The Seasons"`), so
441
+ * `font-family: var(--font-head)` keeps resolving after the prefix.
442
+ *
443
+ * Only names defined in THIS css are renamed — a reference to a name defined elsewhere
444
+ * (global.css, a shared chunk, the theme) is left untouched, so cross-file references keep
445
+ * resolving. Shared-chunk CSS (scopeCSSMulti) is intentionally NOT renamed, to avoid
446
+ * breaking a chunk-defined keyframe referenced from a consuming component's own CSS.
447
+ *
448
+ * NOTE: ported from packages/editor-models/src/helpers/code-component-css-scope.ts
449
+ * (renameCollidingGlobals + helpers, the global.css path's `cc_<projectId>_` variant).
450
+ * Keep parsing/rename fixes in sync between the two.
451
+ * ============================================================================ */
452
+ // Declarations whose VALUE may reference a global identifier of the given type.
453
+ const KEYFRAME_REF_PROPS = new Set(["animation", "animation-name"]);
454
+ const FONT_FAMILY_REF_PROPS = new Set(["font", "font-family"]);
455
+ const COUNTER_STYLE_REF_PROPS = new Set(["list-style", "list-style-type"]);
456
+ function renameCollidingGlobals(css, prefix) {
457
+ // Comment/string/url-masked copy: defining-site regex scans run against this so a name
458
+ // inside a comment or `content: "..."` string can't contribute a rename or be rewritten.
459
+ const scanCss = maskInertSpans(css);
460
+ const keyframeNames = new Set();
461
+ const fontFamilyNames = new Set();
462
+ const counterStyleNames = new Set();
463
+ // One pass for both at-rules: `scanCss` is position-aligned with `css`, so the offset check is
464
+ // only valid against the ORIGINAL string. A second `.replace` on the already-rewritten output
465
+ // would read `scanCss` at shifted offsets and skip every `@counter-style` that follows a
466
+ // renamed `@keyframes` — leaving the definition raw while references get prefixed.
467
+ let out = css.replace(/@(keyframes|counter-style)(\s+)([\w-]+)/g, (m, kind, ws, name, offset) => {
468
+ if (scanCss.substr(offset, kind.length + 1) !== `@${kind}`)
469
+ return m;
470
+ (kind === "keyframes" ? keyframeNames : counterStyleNames).add(name);
471
+ return `@${kind}${ws}${prefix}${name}`;
472
+ });
473
+ out = rewriteFontFaceBlocks(out, fontFamilyNames, prefix);
474
+ if (keyframeNames.size === 0 && fontFamilyNames.size === 0 && counterStyleNames.size === 0) {
475
+ return out;
476
+ }
477
+ return rewriteDeclarationReferences(out, keyframeNames, fontFamilyNames, counterStyleNames, prefix);
478
+ }
479
+ /** Replace every inert span (comment / string / url() / escape) with equal-length spaces so
480
+ * position-based regex scans still align but can't match tokens hidden inside them. */
481
+ function maskInertSpans(css) {
482
+ let out = "";
483
+ for (let i = 0; i < css.length;) {
484
+ const skip = skipInertSpan(css, i);
485
+ if (skip !== -1) {
486
+ out += " ".repeat(skip - i);
487
+ i = skip;
488
+ continue;
166
489
  }
490
+ out += css[i];
491
+ i++;
492
+ }
493
+ return out;
494
+ }
495
+ function rewriteFontFaceBlocks(css, collect, prefix) {
496
+ const matches = [];
497
+ css.replace(/@font-face\s*\{/g, (m, offset) => {
498
+ matches.push({ index: offset, length: m.length });
499
+ return m;
500
+ });
501
+ if (matches.length === 0)
502
+ return css;
503
+ let out = "";
504
+ let lastIdx = 0;
505
+ for (const match of matches) {
506
+ const openBraceIdx = match.index + match.length - 1;
507
+ if (openBraceIdx < lastIdx)
508
+ continue;
509
+ const endIdx = findBlockEnd(css, openBraceIdx + 1);
510
+ const before = css.slice(lastIdx, openBraceIdx + 1);
511
+ const body = css.slice(openBraceIdx + 1, endIdx);
512
+ const rewrittenBody = body.replace(/(font-family\s*:\s*)(?:"([^"]+)"|'([^']+)'|([\w-]+))/i, (_m, pre, dq, sq, bare) => {
513
+ const name = dq || sq || bare;
514
+ if (!name)
515
+ return _m;
516
+ collect.add(name);
517
+ const prefixed = `${prefix}${name}`;
518
+ if (dq)
519
+ return `${pre}"${prefixed}"`;
520
+ if (sq)
521
+ return `${pre}'${prefixed}'`;
522
+ return `${pre}${prefixed}`;
523
+ });
524
+ out += before + rewrittenBody + "}";
525
+ lastIdx = endIdx + 1;
167
526
  }
168
- return scopedLines.join("\n");
527
+ out += css.slice(lastIdx);
528
+ return out;
529
+ }
530
+ function rewriteDeclarationReferences(css, keyframeNames, fontFamilyNames, counterStyleNames, prefix) {
531
+ const keyframeRe = buildBareIdentRegex(keyframeNames);
532
+ const fontFamilyRe = buildFontFamilyValueRegex(fontFamilyNames);
533
+ const counterStyleRe = buildBareIdentRegex(counterStyleNames);
534
+ // counter(<name>, <style>?) / counters(<name>, <string>, <style>?) — the counter-style
535
+ // identifier, when present, is always the LAST optional argument.
536
+ const counterFnRe = counterStyleNames.size > 0
537
+ ? new RegExp(`(\\bcounters?\\([^)]*?,\\s*)(${bareUnion(counterStyleNames)})(\\s*\\))`, "gi")
538
+ : null;
539
+ return mapRuleBlocks(css, block => {
540
+ return mapDeclarations(block, (prop, value) => {
541
+ let v = value;
542
+ const lower = prop.toLowerCase();
543
+ // A custom property (`--font-head: "The Seasons", serif`) is an opaque token list the author
544
+ // feeds to `font-family: var(--font-head)`; the definition it names has been prefixed, so
545
+ // rewrite the font name in there too. Fonts only: keyframe / counter-style names are generic
546
+ // words and a custom property may be consumed by any property (see KEYFRAME_REF_PROPS).
547
+ const isCustomProp = lower.startsWith("--");
548
+ if (keyframeRe && KEYFRAME_REF_PROPS.has(lower)) {
549
+ v = v.replace(keyframeRe, m => `${prefix}${m}`);
550
+ }
551
+ if (fontFamilyRe && (isCustomProp || FONT_FAMILY_REF_PROPS.has(lower))) {
552
+ v = v.replace(fontFamilyRe, (m, dq, sq, bare) => {
553
+ const name = dq || sq || bare;
554
+ if (!name || !fontFamilyNames.has(name))
555
+ return m;
556
+ if (dq !== undefined)
557
+ return `"${prefix}${name}"`;
558
+ if (sq !== undefined)
559
+ return `'${prefix}${name}'`;
560
+ return `${prefix}${name}`;
561
+ });
562
+ }
563
+ if (counterStyleRe && COUNTER_STYLE_REF_PROPS.has(lower)) {
564
+ v = v.replace(counterStyleRe, m => `${prefix}${m}`);
565
+ }
566
+ if (counterFnRe) {
567
+ v = v.replace(counterFnRe, (_m, pre, name, post) => `${pre}${prefix}${name}${post}`);
568
+ }
569
+ return v;
570
+ });
571
+ });
572
+ }
573
+ function bareUnion(names) {
574
+ return Array.from(names).map(escapeRegex).join("|");
575
+ }
576
+ function buildBareIdentRegex(names) {
577
+ if (names.size === 0)
578
+ return null;
579
+ return new RegExp(`(?<![\\w-])(?:${bareUnion(names)})(?![\\w-])`, "g");
580
+ }
581
+ function buildFontFamilyValueRegex(names) {
582
+ if (names.size === 0)
583
+ return null;
584
+ const u = bareUnion(names);
585
+ return new RegExp(`"(${u})"|'(${u})'|(?<![\\w-])(${u})(?![\\w-])`, "g");
586
+ }
587
+ function escapeRegex(s) {
588
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
589
+ }
590
+ /**
591
+ * Walk every top-level rule block `{ ... }` (recursing into nested at-rule contents) and
592
+ * pass the block body through `transform`. Inert spans are skipped so a `{` inside a string
593
+ * or `url(a{b)` is not mistaken for a block. At-rule preambles and inter-rule whitespace
594
+ * are left untouched.
595
+ */
596
+ function mapRuleBlocks(css, transform) {
597
+ let out = "";
598
+ for (let i = 0; i < css.length;) {
599
+ const skip = skipInertSpan(css, i);
600
+ if (skip !== -1) {
601
+ out += css.slice(i, skip);
602
+ i = skip;
603
+ continue;
604
+ }
605
+ if (css[i] === "{") {
606
+ const blockEnd = findBlockEnd(css, i + 1);
607
+ const body = css.slice(i + 1, blockEnd);
608
+ const processed = hasTopLevelBrace(body) ? mapRuleBlocks(body, transform) : transform(body);
609
+ out += "{" + processed + "}";
610
+ i = blockEnd + 1;
611
+ continue;
612
+ }
613
+ out += css[i];
614
+ i++;
615
+ }
616
+ return out;
617
+ }
618
+ /** Inert-span-aware: true iff a real `{` appears outside comments/strings/url()/escapes. */
619
+ function hasTopLevelBrace(s) {
620
+ for (let i = 0; i < s.length;) {
621
+ const skip = skipInertSpan(s, i);
622
+ if (skip !== -1) {
623
+ i = skip;
624
+ continue;
625
+ }
626
+ if (s[i] === "{")
627
+ return true;
628
+ i++;
629
+ }
630
+ return false;
631
+ }
632
+ /**
633
+ * Split a rule block body into `prop: value` declarations on top-level `;`, apply
634
+ * `transform(prop, value)` to each, and rejoin preserving separators. Inert spans are
635
+ * skipped so a `;` inside a string or `url(a;b)` doesn't split a declaration.
636
+ */
637
+ function mapDeclarations(body, transform) {
638
+ const parts = [];
639
+ let start = 0;
640
+ let pDepth = 0;
641
+ for (let i = 0; i < body.length;) {
642
+ const skip = skipInertSpan(body, i);
643
+ if (skip !== -1) {
644
+ i = skip;
645
+ continue;
646
+ }
647
+ const c = body[i];
648
+ if (c === "(" || c === "[" || c === "{")
649
+ pDepth++;
650
+ else if (c === ")" || c === "]" || c === "}")
651
+ pDepth--;
652
+ else if (c === ";" && pDepth === 0) {
653
+ parts.push(body.slice(start, i));
654
+ start = i + 1;
655
+ }
656
+ i++;
657
+ }
658
+ parts.push(body.slice(start));
659
+ return parts
660
+ .map((part, idx) => {
661
+ const isLast = idx === parts.length - 1;
662
+ const colonIdx = findTopLevelColon(part);
663
+ if (colonIdx === -1)
664
+ return part + (isLast ? "" : ";");
665
+ const valueRaw = part.slice(colonIdx + 1);
666
+ const leading = valueRaw.match(/^\s*/)?.[0] ?? "";
667
+ const trailing = valueRaw.match(/\s*$/)?.[0] ?? "";
668
+ const prop = part.slice(0, colonIdx).trim();
669
+ const value = valueRaw.slice(leading.length, valueRaw.length - trailing.length);
670
+ const transformed = transform(prop, value);
671
+ return part.slice(0, colonIdx) + ":" + leading + transformed + trailing + (isLast ? "" : ";");
672
+ })
673
+ .join("");
674
+ }
675
+ function findTopLevelColon(s) {
676
+ let pDepth = 0;
677
+ for (let i = 0; i < s.length;) {
678
+ const skip = skipInertSpan(s, i);
679
+ if (skip !== -1) {
680
+ i = skip;
681
+ continue;
682
+ }
683
+ const c = s[i];
684
+ if (c === "(" || c === "[")
685
+ pDepth++;
686
+ else if (c === ")" || c === "]")
687
+ pDepth--;
688
+ else if (c === ":" && pDepth === 0)
689
+ return i;
690
+ i++;
691
+ }
692
+ return -1;
169
693
  }
170
694
  /**
171
695
  * Common esbuild options shared between server and client builds
@@ -188,12 +712,52 @@ function commonBuildOptions() {
188
712
  sourcemap: false,
189
713
  };
190
714
  }
715
+ /**
716
+ * What `import.meta.env` becomes in a vendored build.
717
+ *
718
+ * The SDK reads Vite variables (`VITE_BASE_URL`, `VITE_PUBLIC_ANALYTICS_URL`, `VITE_ENV`). While it
719
+ * was external, the theme's own Vite build substituted them. Vendoring compiles it with esbuild
720
+ * instead, and `import.meta` is illegal inside the `new Function` the evaluator uses anyway.
721
+ *
722
+ * They are NOT baked in at publish time, even though that would be simpler. `VITE_BASE_URL` names
723
+ * the ikas API the *theme* talks to; a built-in login that authenticated against a different one
724
+ * would hand back a token the rest of the page cannot use. It has to be whatever the host says it
725
+ * is, so it is read from the host at evaluation time — configuration from the host, code from the
726
+ * build, which is the same line every other decision here follows.
727
+ *
728
+ * A host that never sets this gets a thrown TypeError on first evaluation, caught and reported per
729
+ * component. That is deliberate: the alternative, defaulting to `{}`, would silently produce
730
+ * `undefined/ms/google/login/authenticate` and fail as a confusing 404 much later.
731
+ */
732
+ const VENDORED_IMPORT_META_ENV = "globalThis.__ikasBuiltinEnv";
733
+ /**
734
+ * Build the resolution plugins shared by the server and client passes.
735
+ *
736
+ * The storefront plugin is either/or, never both: `forbidDeepStorefrontImportsPlugin` externalizes
737
+ * the SDK root, `vendorStorefrontPlugin` lets it resolve so it gets bundled. Running both would
738
+ * mean whichever registered its `onResolve` first decides, which is not a contract worth having.
739
+ */
740
+ function resolutionPlugins(vendorStorefront) {
741
+ return vendorStorefront
742
+ ? [
743
+ vendorStorefrontPlugin(),
744
+ forbidExternalPackagesPlugin({ allowFrom: isVendoredDependencyFile }),
745
+ ikasComponentUtilsPlugin(),
746
+ storefrontTPlugin(),
747
+ ]
748
+ : [
749
+ forbidDeepStorefrontImportsPlugin(),
750
+ forbidExternalPackagesPlugin(),
751
+ ikasComponentUtilsPlugin(),
752
+ storefrontTPlugin(),
753
+ ];
754
+ }
191
755
  /**
192
756
  * Compile all components together with esbuild code splitting.
193
757
  * Shared code is automatically extracted into chunk files.
194
758
  * When there's no shared code, output is identical to per-component compilation.
195
759
  */
196
- export async function compileAllComponents(components, globalStylesPath) {
760
+ export async function compileAllComponents(components, globalStylesPath, options) {
197
761
  if (components.length === 0) {
198
762
  return {
199
763
  success: true,
@@ -215,19 +779,33 @@ export async function compileAllComponents(components, globalStylesPath) {
215
779
  entryPoints[comp.id] = path.resolve(process.cwd(), comp.entryPath);
216
780
  }
217
781
  try {
218
- // Server build with code splitting
219
- await esbuild.build({
782
+ // Server build with code splitting (metafile enabled for dependency tracking)
783
+ const serverBuildResult = await esbuild.build({
220
784
  ...commonBuildOptions(),
221
785
  entryPoints,
222
786
  format: "esm",
223
- platform: "node",
224
- target: "node18",
787
+ // Vendoring flips the SSR build to browser resolution. Two reasons, both load-bearing:
788
+ //
789
+ // 1. `platform: "node"` picks the node flavour of a vendored dependency, which reaches for
790
+ // node builtins (`uuid` → `crypto`) that no storefront can hand a published artifact.
791
+ // The browser flavour uses `globalThis.crypto`, which SSR on Node 18+ has anyway.
792
+ // 2. esbuild escapes `</script` in string literals on browser and not on node. That single
793
+ // byte changes a chunk's content hash, so the two builds emitted different chunk names
794
+ // for identical code and every importer inherited the split — 154 of 251 chunks had no
795
+ // twin. Same platform, same hashes, one chunk graph.
796
+ //
797
+ // The build is still consumed by `preact-render-to-string` in Node; only module resolution
798
+ // changes, and SERVER_EXTERNALS keeps the SSR renderer external either way.
799
+ platform: options?.vendorStorefront ? "browser" : "node",
800
+ target: SHARED_BUILD_TARGET,
225
801
  external: SERVER_EXTERNALS,
226
- plugins: [ikasComponentUtilsPlugin()],
802
+ ...(options?.vendorStorefront ? { define: { "import.meta.env": VENDORED_IMPORT_META_ENV } } : {}),
803
+ plugins: resolutionPlugins(!!options?.vendorStorefront),
227
804
  splitting: true,
228
805
  outdir: serverDir,
229
806
  entryNames: "[name]",
230
807
  chunkNames: "chunk-[hash]",
808
+ metafile: true,
231
809
  });
232
810
  // Client build with code splitting (metafile enabled for dependency tracking)
233
811
  const clientBuildResult = await esbuild.build({
@@ -235,9 +813,10 @@ export async function compileAllComponents(components, globalStylesPath) {
235
813
  entryPoints,
236
814
  format: "esm",
237
815
  platform: "browser",
238
- target: "es2020",
816
+ target: SHARED_BUILD_TARGET,
239
817
  external: CLIENT_EXTERNALS,
240
- plugins: [ikasComponentUtilsPlugin()],
818
+ ...(options?.vendorStorefront ? { define: { "import.meta.env": VENDORED_IMPORT_META_ENV } } : {}),
819
+ plugins: resolutionPlugins(!!options?.vendorStorefront),
241
820
  splitting: true,
242
821
  outdir: clientDir,
243
822
  entryNames: "[name]",
@@ -278,17 +857,29 @@ export async function compileAllComponents(components, globalStylesPath) {
278
857
  chunks.set(key, { serverJs, clientJs });
279
858
  }
280
859
  }
281
- // Build dependency maps from metafile
282
- const metafile = clientBuildResult.metafile;
860
+ // Build dependency maps from BOTH the server and client metafiles.
861
+ //
862
+ // Only the client metafile used to be consulted here. But SERVER_EXTERNALS
863
+ // differs from CLIENT_EXTERNALS (e.g. preact-render-to-string is external on
864
+ // the server only), so esbuild splits the two builds differently and emits
865
+ // server-only shared chunks whose names have no client twin. Those chunks are
866
+ // written to disk and captured in `chunks` above, yet — with a client-only
867
+ // graph — they never entered any component's chunk closure, so publish pruned
868
+ // them as orphaned. The result: `compiledServerJs` went missing on the shared
869
+ // module and the server bundle imported a `./chunk-XXXX.js` that was never
870
+ // emitted ("Could not resolve ./chunk-XXXX.js"). Merging both graphs keeps
871
+ // server and client chunk closures symmetric.
872
+ const clientMetafile = clientBuildResult.metafile;
873
+ const serverMetafile = serverBuildResult.metafile;
283
874
  const entryDirSet = new Map(); // entryId → absolute dir of entry file
284
875
  for (const comp of components) {
285
876
  entryDirSet.set(comp.id, path.dirname(path.resolve(process.cwd(), comp.entryPath)));
286
877
  }
287
- // Map each output to its input source files
878
+ // CSS is a browser-only concern, so entry/chunk input source maps are derived
879
+ // from the CLIENT metafile only (they feed the CSS collection passes below).
288
880
  const entryInputs = new Map(); // entryId → list of input paths
289
881
  const chunkInputs = new Map(); // chunkId → list of input paths
290
- const chunkConsumers = new Map(); // chunkId → list of entry IDs that import it
291
- for (const [outputPath, outputMeta] of Object.entries(metafile.outputs)) {
882
+ for (const [outputPath, outputMeta] of Object.entries(clientMetafile.outputs)) {
292
883
  const baseName = path.basename(outputPath, ".js");
293
884
  const inputPaths = Object.keys(outputMeta.inputs || {});
294
885
  if (entryIds.has(baseName)) {
@@ -298,40 +889,49 @@ export async function compileAllComponents(components, globalStylesPath) {
298
889
  chunkInputs.set(baseName, inputPaths);
299
890
  }
300
891
  }
301
- // Determine chunk→consumers: which entries import each chunk
302
- for (const [outputPath, outputMeta] of Object.entries(metafile.outputs)) {
303
- const baseName = path.basename(outputPath, ".js");
304
- if (!entryIds.has(baseName))
305
- continue;
306
- const imports = outputMeta.imports || [];
307
- for (const imp of imports) {
308
- const impBaseName = path.basename(imp.path, ".js");
309
- if (chunkInputs.has(impBaseName)) {
310
- if (!chunkConsumers.has(impBaseName)) {
311
- chunkConsumers.set(impBaseName, []);
312
- }
313
- chunkConsumers.get(impBaseName).push(baseName);
314
- }
315
- }
316
- }
317
- // Build chunk→chunk dependency graph and topologically sort chunks
318
- // so that chunks are evaluated in dependency-first order
892
+ // Recognize any emitted chunk — server OR client — by its output basename.
893
+ // `chunks` was populated above from both the server and client output dirs.
894
+ const allChunkIds = new Set(chunks.keys());
895
+ const chunkConsumers = new Map(); // chunkId → list of entry IDs that import it
319
896
  const chunkDeps = new Map(); // chunkId → list of chunk IDs it depends on
320
- for (const chunkId of chunkInputs.keys()) {
897
+ for (const chunkId of allChunkIds) {
321
898
  chunkDeps.set(chunkId, []);
322
899
  }
323
- for (const [outputPath, outputMeta] of Object.entries(metafile.outputs)) {
324
- const baseName = path.basename(outputPath, ".js");
325
- if (!chunkInputs.has(baseName))
326
- continue;
327
- const imports = outputMeta.imports || [];
328
- for (const imp of imports) {
329
- const impBaseName = path.basename(imp.path, ".js");
330
- if (chunkInputs.has(impBaseName) && impBaseName !== baseName) {
331
- chunkDeps.get(baseName).push(impBaseName);
900
+ // Fold one metafile's output graph into the shared consumer/dependency maps.
901
+ // Called for both builds so server-only chunks are tracked alongside client ones.
902
+ const ingestMetafile = (mf) => {
903
+ for (const [outputPath, outputMeta] of Object.entries(mf.outputs)) {
904
+ const baseName = path.basename(outputPath, ".js");
905
+ const imports = outputMeta.imports || [];
906
+ if (entryIds.has(baseName)) {
907
+ // entry → chunk consumer edges
908
+ for (const imp of imports) {
909
+ const impBaseName = path.basename(imp.path, ".js");
910
+ if (!allChunkIds.has(impBaseName))
911
+ continue;
912
+ if (!chunkConsumers.has(impBaseName)) {
913
+ chunkConsumers.set(impBaseName, []);
914
+ }
915
+ const consumers = chunkConsumers.get(impBaseName);
916
+ if (!consumers.includes(baseName))
917
+ consumers.push(baseName);
918
+ }
919
+ }
920
+ else if (allChunkIds.has(baseName)) {
921
+ // chunk → chunk dependency edges
922
+ for (const imp of imports) {
923
+ const impBaseName = path.basename(imp.path, ".js");
924
+ if (allChunkIds.has(impBaseName) && impBaseName !== baseName) {
925
+ const deps = chunkDeps.get(baseName);
926
+ if (!deps.includes(impBaseName))
927
+ deps.push(impBaseName);
928
+ }
929
+ }
332
930
  }
333
931
  }
334
- }
932
+ };
933
+ ingestMetafile(clientMetafile);
934
+ ingestMetafile(serverMetafile);
335
935
  // Kahn's algorithm for topological sort
336
936
  const inDegree = new Map();
337
937
  for (const chunkId of chunkDeps.keys()) {
@@ -390,7 +990,29 @@ export async function compileAllComponents(components, globalStylesPath) {
390
990
  // Resolve CSS: entry CSS + entry-only dependency CSS + chunk CSS
391
991
  const css = new Map();
392
992
  const chunkCss = new Map();
993
+ // CSS files each consumed chunk will emit (same collection the chunk-CSS pass below uses).
994
+ // The entry pass needs these up front: a parent styles.css that `@import`s a SHARED
995
+ // sub-component's styles.css (the documented convention) would otherwise inline a copy of
996
+ // CSS the chunk already carries for every consumer — duplicating each rule per consumer.
997
+ const chunkExcludeDirs = Array.from(entryDirSet.values());
998
+ const chunkCssFileSets = new Map();
999
+ for (const [chunkId, inputPaths] of chunkInputs) {
1000
+ if ((chunkConsumers.get(chunkId) || []).length === 0)
1001
+ continue;
1002
+ const files = collectSourceCssFiles(inputPaths, undefined, chunkExcludeDirs);
1003
+ chunkCssFileSets.set(chunkId, new Set(files.map(f => path.resolve(f))));
1004
+ }
393
1005
  for (const comp of components) {
1006
+ // CSS files already covered for this entry by the chunks it consumes. Only direct
1007
+ // consumers count: a chunk this entry does not consume is scoped to other components,
1008
+ // so its files must still be inlined here when @imported.
1009
+ const chunkCoveredCssPaths = new Set();
1010
+ for (const [chunkId, consumers] of chunkConsumers) {
1011
+ if (!consumers.includes(comp.id))
1012
+ continue;
1013
+ for (const f of chunkCssFileSets.get(chunkId) ?? [])
1014
+ chunkCoveredCssPaths.add(f);
1015
+ }
394
1016
  // 1. Read entry's own styles.css + resolve @imports.
395
1017
  // Track every file inlined here so step 2's source-tree collection doesn't append them again
396
1018
  // (which previously emitted each @imported file twice in compiledCss).
@@ -399,7 +1021,7 @@ export async function compileAllComponents(components, globalStylesPath) {
399
1021
  let cssContent = "";
400
1022
  if (fs.existsSync(cssPath)) {
401
1023
  const rawCss = fs.readFileSync(cssPath, "utf-8");
402
- cssContent = resolveCssImports(rawCss, cssPath, alreadyInlinedPaths);
1024
+ cssContent = resolveCssImports(rawCss, cssPath, alreadyInlinedPaths, chunkCoveredCssPaths);
403
1025
  }
404
1026
  // 2. Collect CSS from entry-only dependencies (source files in entry inputs, not in any chunk)
405
1027
  const allChunkInputPaths = new Set();
@@ -412,20 +1034,23 @@ export async function compileAllComponents(components, globalStylesPath) {
412
1034
  for (const depCssPath of depCss) {
413
1035
  if (alreadyInlinedPaths.has(path.resolve(depCssPath)))
414
1036
  continue;
1037
+ // A sibling CSS can surface here even when its directory is chunk-covered (an
1038
+ // entry-only source file living in a shared sub-component's folder) — the chunk
1039
+ // already carries it, so inlining it again would duplicate every rule.
1040
+ if (chunkCoveredCssPaths.has(path.resolve(depCssPath)))
1041
+ continue;
415
1042
  const rawDepCss = fs.readFileSync(depCssPath, "utf-8");
416
- cssContent += "\n" + resolveCssImports(rawDepCss, depCssPath, alreadyInlinedPaths);
1043
+ cssContent += "\n" + resolveCssImports(rawDepCss, depCssPath, alreadyInlinedPaths, chunkCoveredCssPaths);
417
1044
  }
418
1045
  // 3. Scope combined CSS with entry ID
419
1046
  css.set(comp.id, scopeCSS(cssContent, comp.id));
420
1047
  }
421
- // Process chunk CSS
422
- for (const [chunkId, inputPaths] of chunkInputs) {
1048
+ // Process chunk CSS (file sets were collected above, before the entry pass)
1049
+ for (const chunkId of chunkInputs.keys()) {
423
1050
  const consumers = chunkConsumers.get(chunkId) || [];
424
1051
  if (consumers.length === 0)
425
1052
  continue;
426
- // Collect CSS from all entry dirs to exclude (we only exclude entry file dirs)
427
- const excludeDirs = Array.from(entryDirSet.values());
428
- const chunkCssFiles = collectSourceCssFiles(inputPaths, undefined, excludeDirs);
1053
+ const chunkCssFiles = Array.from(chunkCssFileSets.get(chunkId) ?? []);
429
1054
  if (chunkCssFiles.length === 0)
430
1055
  continue;
431
1056
  let combinedCss = "";
@@ -485,12 +1110,20 @@ export async function compileAllComponents(components, globalStylesPath) {
485
1110
  css: new Map(),
486
1111
  chunkCss: new Map(),
487
1112
  entryChunkIds: new Map(),
488
- error: error.message || String(error)
1113
+ error: withCssImportHint(error.message || String(error))
489
1114
  };
490
1115
  }
491
1116
  finally {
492
- // Clean up temp directory
493
- fs.rmSync(tmpBase, { recursive: true, force: true });
1117
+ // Clean up temp directory. Best-effort: on Windows an antivirus/indexer can briefly hold
1118
+ // handles on the freshly written output files, making rmSync throw EBUSY/EPERM — and a
1119
+ // throw here (inside finally) would replace a successful compilation result with an error.
1120
+ // A leaked tmp dir is harmless; retry a few times, then give up silently.
1121
+ try {
1122
+ fs.rmSync(tmpBase, { recursive: true, force: true, maxRetries: 3 });
1123
+ }
1124
+ catch {
1125
+ // ignore — never fail the build over tmp cleanup
1126
+ }
494
1127
  }
495
1128
  }
496
1129
  /**