@commercebuild/extension 0.0.17 → 0.0.19

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.
@@ -1,18 +1,11 @@
1
1
  {
2
2
  "//": "AUTO-GENERATED from packages/extension-host-libs — do not edit. Regenerate with `yarn generate-extension-host-types`.",
3
- "external": [
4
- "react",
5
- "react-dom",
6
- "firebase/app",
7
- "firebase/firestore"
8
- ],
3
+ "external": ["react", "react-dom", "firebase/app", "firebase/firestore"],
9
4
  "globals": {
10
5
  "react": "cb.lib.React",
11
6
  "react-dom": "cb.lib.ReactDOM",
12
7
  "firebase/app": "cb.lib.firebase.app",
13
8
  "firebase/firestore": "cb.lib.firebase.firestore"
14
9
  },
15
- "forbidden": [
16
- "firebase/firestore/lite"
17
- ]
10
+ "forbidden": ["firebase/firestore/lite"]
18
11
  }
@@ -5,7 +5,7 @@
5
5
  "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
6
  "module": "ESNext",
7
7
  "skipLibCheck": true,
8
- "moduleResolution": "node",
8
+ "moduleResolution": "bundler",
9
9
  "allowImportingTsExtensions": true,
10
10
  "resolveJsonModule": true,
11
11
  "isolatedModules": true,
@@ -66,6 +66,32 @@ function cbManifestPlugin() {
66
66
  used.add(spec);
67
67
  }
68
68
  }
69
+
70
+ // The admin invocation MERGES its per-bundle hostLibs into the
71
+ // manifest the storefront build just emitted — never a union
72
+ // list (a union would make every shopper preload admin-only host
73
+ // libs). The `admin` key's presence doubles as the "this upload
74
+ // has an admin bundle" signal for the store admin's loader.
75
+ if (buildTarget === "admin") {
76
+ const manifestPath = path.resolve(
77
+ process.cwd(),
78
+ "dist/cb-manifest.json",
79
+ );
80
+ if (!existsSync(manifestPath)) {
81
+ throw new Error(
82
+ "dist/cb-manifest.json is missing — the storefront build must run before the admin build",
83
+ );
84
+ }
85
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
86
+ manifest.admin = { hostLibs: [...used].sort() };
87
+ this.emitFile({
88
+ type: "asset",
89
+ fileName: "cb-manifest.json",
90
+ source: JSON.stringify(manifest, null, 2) + "\n",
91
+ });
92
+ return;
93
+ }
94
+
69
95
  let connections = [];
70
96
  let declaredHostLibs = [];
71
97
  try {
@@ -132,20 +158,114 @@ function forbiddenImportsPlugin() {
132
158
  };
133
159
  }
134
160
 
135
- const jsEntry = path.resolve(process.cwd(), "src/index.js");
136
- const tsEntry = path.resolve(process.cwd(), "src/index.ts");
137
- const entryFile = existsSync(tsEntry) ? tsEntry : jsEntry;
161
+ // UN-3206: the OPTIONAL admin area (src/admin/) compiles to a second,
162
+ // self-contained bundle — dist/admin.js + admin.css — rendered inside
163
+ // the store admin. CB_BUILD_TARGET=admin selects it; the CLI always
164
+ // runs the storefront build first and the admin one only when the
165
+ // admin entry exists (see scripts/cli.js). Both bundles share the
166
+ // CBExtension global/banner protocol: they execute in different
167
+ // documents and never collide.
168
+ const buildTarget =
169
+ process.env.CB_BUILD_TARGET === "admin" ? "admin" : "storefront";
170
+
171
+ function resolveEntry(dir) {
172
+ const ts = path.resolve(process.cwd(), dir, "index.ts");
173
+ const js = path.resolve(process.cwd(), dir, "index.js");
174
+ return existsSync(ts) ? ts : existsSync(js) ? js : null;
175
+ }
176
+
177
+ const storefrontEntry =
178
+ resolveEntry("src") ?? path.resolve(process.cwd(), "src/index.js");
179
+ const adminEntry = resolveEntry("src/admin");
180
+
181
+ if (buildTarget === "admin") {
182
+ if (!adminEntry) {
183
+ throw new Error(
184
+ "CB_BUILD_TARGET=admin but src/admin/index.ts does not exist",
185
+ );
186
+ }
187
+ if (!existsSync(path.resolve(process.cwd(), "src/admin/styles/index.css"))) {
188
+ throw new Error(
189
+ "src/admin/index.ts requires src/admin/styles/index.css (the admin stylesheet entry)",
190
+ );
191
+ }
192
+ }
193
+
194
+ const entryFile = buildTarget === "admin" ? adminEntry : storefrontEntry;
195
+
196
+ // Import boundary rules, mirroring the in-browser editor compiler in
197
+ // cb-store (apps/storeadmin extension-editor esbuild plugin) — the two
198
+ // pipelines MUST agree, or code that fails in editor preview would
199
+ // still publish:
200
+ // - src/admin/** may import only src/admin/** and src/shared/**;
201
+ // - src/shared/** may import only src/shared/**;
202
+ // - nothing outside src/admin/** may import src/admin/**.
203
+ // These keep each bundle's module set structurally clean, which is what
204
+ // makes the per-target Tailwind source scopes exact.
205
+ function projectRelative(file) {
206
+ const abs = path.resolve(file.split("?")[0]);
207
+ const rel = path.relative(process.cwd(), abs).split(path.sep).join("/");
208
+ return rel.startsWith("..") ? null : rel;
209
+ }
210
+
211
+ export function importBoundaryViolation(importer, resolved) {
212
+ const fromAdmin = importer.startsWith("src/admin/");
213
+ const fromShared = importer.startsWith("src/shared/");
214
+ const toAdmin = resolved.startsWith("src/admin/");
215
+ const toShared = resolved.startsWith("src/shared/");
216
+ if (fromAdmin && !toAdmin && !toShared) {
217
+ return "files under src/admin/ may only import src/admin/ or src/shared/ (the admin bundle must stay independent of storefront code)";
218
+ }
219
+ if (fromShared && !toShared) {
220
+ return "files under src/shared/ may only import src/shared/ (shared code is part of both bundles)";
221
+ }
222
+ if (!fromAdmin && toAdmin) {
223
+ return "only files under src/admin/ may import src/admin/ (admin code never ships in the storefront bundle)";
224
+ }
225
+ return null;
226
+ }
227
+
228
+ function importBoundaryPlugin() {
229
+ return {
230
+ name: "cb-import-boundaries",
231
+ enforce: "pre",
232
+ resolveId(source, importer) {
233
+ if (!importer || !source.startsWith(".")) return null;
234
+ const from = projectRelative(importer);
235
+ // Directory prefixes are all the rules need — a missing extension
236
+ // or /index resolution never changes which subtree a path is in.
237
+ const to = projectRelative(
238
+ path.resolve(path.dirname(importer.split("?")[0]), source),
239
+ );
240
+ if (!from || !to) return null;
241
+ const violation = importBoundaryViolation(from, to);
242
+ if (violation) {
243
+ throw new Error(
244
+ `Forbidden import of "${source}" from "${from}": ${violation}`,
245
+ );
246
+ }
247
+ return null;
248
+ },
249
+ };
250
+ }
138
251
 
139
252
  const baseBuildConfig = {
140
253
  lib: {
141
254
  entry: entryFile,
142
255
  name: "CBExtension",
143
256
  formats: ["umd"],
144
- fileName: (format, entryName) =>
145
- `${entryName}.${format === "es" ? "mjs" : "js"}`,
257
+ // Both entries are index.ts, so the entry name can't distinguish
258
+ // the bundles name the artifacts by target explicitly.
259
+ fileName: () => (buildTarget === "admin" ? "admin.js" : "index.js"),
260
+ cssFileName: buildTarget === "admin" ? "admin" : "style",
146
261
  },
147
262
  minify: "esbuild",
148
263
  sourcemap: false,
264
+ // The admin invocation writes into the dist/ the storefront build
265
+ // just produced — vite's default would empty it and delete
266
+ // index.js/style.css/cb-manifest.json (which the manifest merge
267
+ // below depends on).
268
+ emptyOutDir: buildTarget !== "admin",
149
269
  rollupOptions: {
150
270
  external: hostLibs.external,
151
271
  output: {
@@ -153,7 +273,9 @@ const baseBuildConfig = {
153
273
  globals: hostLibs.globals,
154
274
  assetFileNames: (assetInfo) => {
155
275
  if (assetInfo.name.endsWith(".css")) {
156
- return assetInfo.originalFileName || assetInfo.name || "asset.css";
276
+ // The lib css asset is named per target here — both entries
277
+ // are index.ts, so nothing upstream distinguishes them.
278
+ return buildTarget === "admin" ? "admin.css" : "style.css";
157
279
  }
158
280
  return assetInfo.name;
159
281
  },
@@ -164,6 +286,7 @@ const baseBuildConfig = {
164
286
  const prodConfig = {
165
287
  plugins: [
166
288
  forbiddenImportsPlugin(),
289
+ importBoundaryPlugin(),
167
290
  cbManifestPlugin(),
168
291
  react({
169
292
  jsxRuntime: "classic",
@@ -205,6 +328,7 @@ export default defineConfig(({ mode, command }) => {
205
328
  root: ".",
206
329
  plugins: [
207
330
  forbiddenImportsPlugin(),
331
+ importBoundaryPlugin(),
208
332
  cbManifestPlugin(),
209
333
  ViteHMRNotifierPlugin(),
210
334
  react({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercebuild/extension",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "types": "./types/index.d.ts",
5
5
  "exports": {
6
6
  ".": {
@@ -34,5 +34,9 @@
34
34
  },
35
35
  "bin": {
36
36
  "commercebuild-extension": "./scripts/cli.js"
37
+ },
38
+ "scripts": {
39
+ "test": "node scripts/test-build.mjs",
40
+ "release": "npm whoami || npm login && pnpm publish --no-git-checks"
37
41
  }
38
- }
42
+ }
@@ -19,9 +19,7 @@
19
19
  "$defs": {
20
20
  "connection": {
21
21
  "type": "object",
22
- "required": [
23
- "name"
24
- ],
22
+ "required": ["name"],
25
23
  "properties": {
26
24
  "name": {
27
25
  "type": "string",
@@ -30,17 +28,11 @@
30
28
  },
31
29
  "provider": {
32
30
  "description": "Backing service. Absent means \"firebase\".",
33
- "enum": [
34
- "firebase"
35
- ]
31
+ "enum": ["firebase"]
36
32
  },
37
33
  "scope": {
38
- "description": "\"store\" (the default): the merchant supplies the config per store under Installed Apps → Configure data — do not put a config here. \"app\": the config is fixed in this declaration (required below), owned by the app and not merchant-overridable.",
39
- "enum": [
40
- "store",
41
- "app"
42
- ],
43
- "default": "store"
34
+ "description": "\"store\": the merchant supplies the config per store under Installed Apps → Configure data — do not put a config here. \"app\": the config is fixed in this declaration (required below), owned by the app and not merchant-overridable. Omitted: inferred — a declaration with a \"config\" is app-scoped, one without is store-scoped.",
35
+ "enum": ["store", "app"]
44
36
  },
45
37
  "label": {
46
38
  "type": "string",
@@ -52,6 +44,10 @@
52
44
  },
53
45
  "config": {
54
46
  "$ref": "#/$defs/firebaseConfig"
47
+ },
48
+ "oidcEnabled": {
49
+ "type": "boolean",
50
+ "description": "Federate the signed-in shopper's CB Store identity into this connection's Firebase Auth (Firestore Rules then read the cb_* claims). App-scoped declarations only — the merchant accepts an identity-sharing notice once at install/first configuration. On a store-scoped connection the merchant controls this from Installed Apps → Configure data instead, and a flag here is ignored."
55
51
  }
56
52
  },
57
53
  "additionalProperties": false,
@@ -63,14 +59,10 @@
63
59
  "const": "app"
64
60
  }
65
61
  },
66
- "required": [
67
- "scope"
68
- ]
62
+ "required": ["scope"]
69
63
  },
70
64
  "then": {
71
- "required": [
72
- "config"
73
- ]
65
+ "required": ["config"]
74
66
  }
75
67
  }
76
68
  ]
@@ -78,18 +70,13 @@
78
70
  "firebaseConfig": {
79
71
  "type": "object",
80
72
  "description": "Firebase web config (the firebaseConfig snippet from the Firebase console). Public by design — access control lives in the project's Security Rules.",
81
- "required": [
82
- "apiKey",
83
- "projectId"
84
- ],
73
+ "required": ["apiKey", "projectId"],
85
74
  "properties": {
86
75
  "apiKey": {
87
- "type": "string",
88
- "minLength": 1
76
+ "type": "string"
89
77
  },
90
78
  "projectId": {
91
- "type": "string",
92
- "minLength": 1
79
+ "type": "string"
93
80
  },
94
81
  "authDomain": {
95
82
  "type": "string"
@@ -105,6 +92,9 @@
105
92
  },
106
93
  "measurementId": {
107
94
  "type": "string"
95
+ },
96
+ "databaseId": {
97
+ "type": "string"
108
98
  }
109
99
  },
110
100
  "additionalProperties": true
package/scripts/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execSync } from "child_process";
4
+ import { existsSync } from "fs";
4
5
  import path from "path";
5
6
  import { fileURLToPath } from "url";
6
7
  import { displayError } from "./utils.mjs";
@@ -8,18 +9,40 @@ const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = path.dirname(__filename);
9
10
  const args = process.argv.slice(2);
10
11
 
12
+ const viteConfigPath = path.resolve(__dirname, "../config/vite.config.mjs");
13
+
14
+ // Two independent bundles from one project: the storefront build always
15
+ // runs; the admin build runs only when the OPTIONAL admin area exists,
16
+ // and always AFTER the storefront one — its cb-manifest plugin merges
17
+ // an `admin` section into the manifest the storefront build emitted,
18
+ // and it keeps dist/ intact (emptyOutDir: false in the vite config).
19
+ function hasAdminEntry() {
20
+ return (
21
+ existsSync(path.resolve(process.cwd(), "src/admin/index.ts")) ||
22
+ existsSync(path.resolve(process.cwd(), "src/admin/index.js"))
23
+ );
24
+ }
25
+
26
+ function build() {
27
+ execSync(`vite build --config ${viteConfigPath}`, { stdio: "inherit" });
28
+ if (hasAdminEntry()) {
29
+ execSync(`vite build --config ${viteConfigPath}`, {
30
+ stdio: "inherit",
31
+ env: { ...process.env, CB_BUILD_TARGET: "admin" },
32
+ });
33
+ }
34
+ }
35
+
11
36
  if (args.includes("dev")) {
12
37
  try {
13
- const viteConfigPath = path.resolve(__dirname, "../config/vite.config.mjs");
14
- execSync(`vite build --config ${viteConfigPath}`, { stdio: "inherit" });
38
+ build();
15
39
  execSync(`vite --config ${viteConfigPath}`, { stdio: "inherit" });
16
40
  } catch (error) {
17
41
  displayError("Failed to start commercebuild extension server:", error);
18
42
  }
19
43
  } else if (args.includes("build")) {
20
44
  try {
21
- const viteConfigPath = path.resolve(__dirname, "../config/vite.config.mjs");
22
- execSync(`vite build --config ${viteConfigPath}`, { stdio: "inherit" });
45
+ build();
23
46
  } catch (error) {
24
47
  displayError("Failed to build with commercebuild extension:", error);
25
48
  }
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Integration test for the dual-bundle build (UN-3206): builds a scratch
4
+ * fixture project with the real vite pipeline and asserts the artifact
5
+ * contract the hosts depend on — storefront trio untouched by the admin
6
+ * invocation, per-target CSS scan scopes, cb-manifest merge semantics,
7
+ * and the import boundary rules. Run with `node scripts/test-build.mjs`.
8
+ *
9
+ * These scopes/rules MUST stay in lockstep with the in-browser editor
10
+ * compiler in cb-store (apps/storeadmin extension-editor) — this test is
11
+ * the canonical side of that parity contract.
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { execFileSync } from "node:child_process";
15
+ import {
16
+ cpSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ mkdtempSync,
20
+ readFileSync,
21
+ rmSync,
22
+ symlinkSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
30
+ const viteBin = path.join(pkgRoot, "node_modules", ".bin", "vite");
31
+ const viteConfig = path.join(pkgRoot, "config", "vite.config.mjs");
32
+
33
+ const fixture = mkdtempSync(path.join(tmpdir(), "cb-ext-build-"));
34
+
35
+ function write(rel, content) {
36
+ const target = path.join(fixture, rel);
37
+ mkdirSync(path.dirname(target), { recursive: true });
38
+ writeFileSync(target, content);
39
+ }
40
+
41
+ const STOREFRONT_CSS = `@reference "tailwindcss/theme.css";
42
+ @import "tailwindcss/utilities.css" layer(utilities) source(none);
43
+ @config "@commercebuild/extension/config/tailwind.config.js";
44
+ @source "../";
45
+ @source not "../admin";
46
+ `;
47
+
48
+ const ADMIN_CSS = `@reference "tailwindcss/theme.css";
49
+ @import "tailwindcss/utilities.css" layer(utilities) source(none);
50
+ @config "@commercebuild/extension/config/tailwind.config.js";
51
+ @source "../";
52
+ @source "../../shared";
53
+ `;
54
+
55
+ function scaffold() {
56
+ write(
57
+ "commercebuild.json",
58
+ JSON.stringify({
59
+ id: "fixture",
60
+ name: "Fixture",
61
+ type: "extension",
62
+ description: "",
63
+ version: "0.0.1",
64
+ cbApiVersion: "^0.6",
65
+ }),
66
+ );
67
+ write(
68
+ "src/index.ts",
69
+ 'import "./styles/index.css";\nexport * as components from "./cms";\nexport * as pages from "./pages";\n',
70
+ );
71
+ write("src/cms/index.ts", "export {};\n");
72
+ write("src/pages/index.ts", 'export * as Shop from "./shop";\n');
73
+ write(
74
+ "src/pages/shop.tsx",
75
+ 'import React from "react";\nimport { shared } from "../shared/util";\nexport default function Shop() {\n const [n] = React.useState(0);\n return <div className="p-1">{shared()}{n}</div>;\n}\n',
76
+ );
77
+ write(
78
+ "src/shared/util.tsx",
79
+ 'import React from "react";\nexport function shared() {\n return <span className="p-2">shared</span>;\n}\n',
80
+ );
81
+ write("src/styles/index.css", STOREFRONT_CSS);
82
+ write(
83
+ "src/admin/index.ts",
84
+ 'import "./styles/index.css";\nexport * as adminPages from "./pages";\n',
85
+ );
86
+ write("src/admin/pages/index.ts", 'export * as Dashboard from "./dashboard";\n');
87
+ write(
88
+ "src/admin/pages/dashboard.tsx",
89
+ 'import React from "react";\nimport { getFirestore } from "firebase/firestore";\nimport { shared } from "../../shared/util";\nexport default function Dashboard() {\n const [n] = React.useState(0);\n return <div className="p-3">{String(!!getFirestore)}{shared()}{n}</div>;\n}\n',
90
+ );
91
+ write("src/admin/styles/index.css", ADMIN_CSS);
92
+
93
+ // The fixture resolves the package (for @config) and tailwindcss (for
94
+ // the layer stylesheets) through symlinks into this repo's own
95
+ // installation — no network install.
96
+ const nm = path.join(fixture, "node_modules");
97
+ mkdirSync(path.join(nm, "@commercebuild"), { recursive: true });
98
+ symlinkSync(pkgRoot, path.join(nm, "@commercebuild", "extension"));
99
+ // pnpm layout: this entry is itself a symlink into the store — a
100
+ // symlink to a symlink resolves fine.
101
+ symlinkSync(
102
+ path.join(pkgRoot, "node_modules", "tailwindcss"),
103
+ path.join(nm, "tailwindcss"),
104
+ );
105
+ }
106
+
107
+ function build(env = {}) {
108
+ execFileSync(viteBin, ["build", "--config", viteConfig], {
109
+ cwd: fixture,
110
+ stdio: "pipe",
111
+ env: { ...process.env, ...env },
112
+ });
113
+ }
114
+
115
+ function dist(name) {
116
+ return readFileSync(path.join(fixture, "dist", name), "utf8");
117
+ }
118
+
119
+ try {
120
+ scaffold();
121
+
122
+ // --- Dual build: storefront first, then admin (matching cli.js). ---
123
+ build();
124
+ build({ CB_BUILD_TARGET: "admin" });
125
+
126
+ for (const file of [
127
+ "index.js",
128
+ "style.css",
129
+ "admin.js",
130
+ "admin.css",
131
+ "cb-manifest.json",
132
+ ]) {
133
+ assert.ok(
134
+ existsSync(path.join(fixture, "dist", file)),
135
+ `dist/${file} exists after the dual build`,
136
+ );
137
+ }
138
+
139
+ // Per-target CSS scan scopes (p-1=storefront page, p-2=shared, p-3=admin).
140
+ const style = dist("style.css");
141
+ assert.match(style, /\.p-1\b/, "style.css has storefront utilities");
142
+ assert.match(style, /\.p-2\b/, "style.css has shared utilities");
143
+ assert.doesNotMatch(style, /\.p-3\b/, "style.css has NO admin utilities");
144
+ const adminCss = dist("admin.css");
145
+ assert.doesNotMatch(adminCss, /\.p-1\b/, "admin.css has NO storefront utilities");
146
+ assert.match(adminCss, /\.p-2\b/, "admin.css has shared utilities");
147
+ assert.match(adminCss, /\.p-3\b/, "admin.css has admin utilities");
148
+
149
+ // Manifest merge: storefront fields retained, admin.hostLibs per-bundle.
150
+ const manifest = JSON.parse(dist("cb-manifest.json"));
151
+ assert.equal(manifest.cbApiVersion, "^0.6");
152
+ assert.deepEqual(manifest.hostLibs, ["react"]);
153
+ assert.deepEqual(manifest.admin, {
154
+ hostLibs: ["firebase/firestore", "react"],
155
+ });
156
+
157
+ // Both bundles carry the meta banner and the shared global protocol.
158
+ assert.match(dist("admin.js"), /__CBExtensionMeta/);
159
+ assert.match(dist("admin.js"), /CBExtension/);
160
+ assert.match(dist("index.js"), /__CBExtensionMeta/);
161
+
162
+ // --- Boundary rules fail the build, all three directions. ---
163
+ const violations = [
164
+ [
165
+ "src/admin/pages/bad.ts",
166
+ 'export { x } from "../../cms/bad-target";\n',
167
+ "src/admin/pages/index.ts",
168
+ './bad',
169
+ ],
170
+ [
171
+ "src/pages/bad.ts",
172
+ 'export { helper } from "../admin/helper";\n',
173
+ "src/pages/index.ts",
174
+ './bad',
175
+ ],
176
+ [
177
+ "src/shared/bad.ts",
178
+ 'export { helper } from "../admin/helper";\n',
179
+ "src/pages/index.ts",
180
+ '../shared/bad',
181
+ ],
182
+ ];
183
+ write("src/cms/bad-target.ts", "export const x = 1;\n");
184
+ write("src/admin/helper.ts", "export const helper = 1;\n");
185
+ for (const [file, source, barrel, specifier] of violations) {
186
+ write(file, source);
187
+ const original = readFileSync(path.join(fixture, barrel), "utf8");
188
+ write(barrel, `${original}export * from "${specifier}";\n`);
189
+ let failed = false;
190
+ try {
191
+ build(file.startsWith("src/admin/") ? { CB_BUILD_TARGET: "admin" } : {});
192
+ } catch (err) {
193
+ failed = true;
194
+ assert.match(
195
+ String(err.stderr ?? err),
196
+ /Forbidden import/,
197
+ `${file} violation names the rule`,
198
+ );
199
+ }
200
+ assert.ok(failed, `${file} boundary violation fails the build`);
201
+ rmSync(path.join(fixture, file));
202
+ write(barrel, original);
203
+ }
204
+
205
+ // --- Admin-less project: classic three artifacts, nothing extra. ---
206
+ rmSync(path.join(fixture, "src/admin"), { recursive: true });
207
+ rmSync(path.join(fixture, "dist"), { recursive: true });
208
+ build();
209
+ assert.ok(existsSync(path.join(fixture, "dist", "index.js")));
210
+ assert.ok(existsSync(path.join(fixture, "dist", "style.css")));
211
+ assert.ok(!existsSync(path.join(fixture, "dist", "admin.js")));
212
+ assert.ok(!existsSync(path.join(fixture, "dist", "admin.css")));
213
+ assert.equal(JSON.parse(dist("cb-manifest.json")).admin, undefined);
214
+ // Admin invocation without an admin entry is an explicit error.
215
+ assert.throws(
216
+ () => build({ CB_BUILD_TARGET: "admin" }),
217
+ /src\/admin\/index\.ts/,
218
+ );
219
+
220
+ console.log("extension dual-build integration test: OK");
221
+ } finally {
222
+ rmSync(fixture, { recursive: true, force: true });
223
+ }
package/types/css.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Stylesheet imports are side-effect only: the build extracts them into
2
+ // the bundle's CSS artifact (style.css / admin.css) and they resolve to
3
+ // no JS value. This ambient declaration is what lets the entry barrels'
4
+ // `import "./styles/index.css"` type-check — newer TypeScript
5
+ // (noUncheckedSideEffectImports) errors on side-effect imports that
6
+ // resolve to neither a module nor a type declaration.
7
+ declare module "*.css";
package/types/global.d.ts CHANGED
@@ -8,7 +8,12 @@ import React__default, { CSSProperties } from "react";
8
8
  import * as ReactDOM from "react-dom";
9
9
  import * as FirebaseAppNs from "firebase/app";
10
10
  import * as FirebaseFirestoreNs from "firebase/firestore";
11
- import { cart_v2, catalog_v1, Platform, customer_v1 } from "@commercebuild/platform-api";
11
+ import {
12
+ cart_v2,
13
+ catalog_v1,
14
+ Platform,
15
+ customer_v1,
16
+ } from "@commercebuild/platform-api";
12
17
  import * as icons from "lucide-react";
13
18
  import * as uis from "@commercebuild/ui";
14
19
  import {
@@ -23,7 +28,7 @@ declare function useLanagueTranslater(): {
23
28
  _t: (k: string, obj?: any) => string | undefined;
24
29
  t: (k: string) => string;
25
30
  tt: (k: string, obj?: any) => (kk: string) => string;
26
- tmsg: (k: string) => any;
31
+ tmsg: (kk: string) => string;
27
32
  tlink: (uri: string) => string;
28
33
  talink: (uri: string) => string;
29
34
  trlink: (uri: string) => string;
@@ -43,6 +48,7 @@ type CartRequestItem = Omit<
43
48
  unitPrice?: number;
44
49
  detailPageUrl?: string;
45
50
  };
51
+ type PostItemsSearchRequest = catalog_v1.PostItemsSearchRequest;
46
52
  type PostItemsSearchItem = catalog_v1.PostItemsSearchItem;
47
53
  type PostItemsSearchResponse = catalog_v1.PostItemsSearchResponse;
48
54
  type GetItemDetailResponse = catalog_v1.GetItemDetailResponse;
@@ -76,12 +82,95 @@ declare function AddToCart({
76
82
  fullWidth,
77
83
  }: AddToCartProps): React.JSX.Element;
78
84
 
85
+ interface GroupSectionItem {
86
+ sectionKey: "group";
87
+ id?: string;
88
+ visible?:
89
+ | boolean
90
+ | {
91
+ value?: boolean;
92
+ default?: boolean;
93
+ };
94
+ gap?: string;
95
+ justify?: string;
96
+ align?: string;
97
+ items?: SectionItem[];
98
+ }
99
+ interface LeafSectionItem {
100
+ sectionKey: string;
101
+ id?: string;
102
+ visible?:
103
+ | boolean
104
+ | {
105
+ value?: boolean;
106
+ default?: boolean;
107
+ };
108
+ }
109
+ /**
110
+ * A leaf section that renders authored rich-text using the variable
111
+ * resolution pipeline. Carried by the `customText` discriminator so the
112
+ * variant-specific fields stay off the shared base.
113
+ */
114
+ interface CustomTextLeafSectionItem extends LeafSectionItem {
115
+ sectionKey: "customText";
116
+ customLabel?: string;
117
+ customLabelVisible?: boolean;
118
+ customValue?: string;
119
+ }
120
+ type SectionItem =
121
+ | GroupSectionItem
122
+ | CustomTextLeafSectionItem
123
+ | LeafSectionItem;
124
+
125
+ interface StandardCardLeafField extends LeafSectionItem {
126
+ sectionKey:
127
+ | "image"
128
+ | "item_code"
129
+ | "title"
130
+ | "stock"
131
+ | "price"
132
+ | "add_to_cart";
133
+ }
134
+ type CardLeafField = StandardCardLeafField | CustomTextLeafSectionItem;
135
+ type CardField = CardLeafField | GroupSectionItem;
136
+ /**
137
+ * One column of a list-view row. Columns sit left-to-right; the entries inside
138
+ * a column stack vertically, and a `group` entry puts several of them on one
139
+ * row (the same primitive the tile card uses).
140
+ */
141
+ interface CardColumn {
142
+ id?: string;
143
+ visible?:
144
+ | boolean
145
+ | {
146
+ value?: boolean;
147
+ default?: boolean;
148
+ };
149
+ /** Key into LIST_COLUMN_WIDTH. */
150
+ width?: string;
151
+ /** Key into LIST_COLUMN_ALIGN. */
152
+ align?: string;
153
+ items?: CardField[];
154
+ }
155
+
79
156
  declare function CategoryProduct({
80
157
  product,
81
158
  imagePriority,
159
+ schemeKey,
160
+ fields,
161
+ listColumns,
162
+ imageSizes,
82
163
  }: {
83
164
  product: PostItemsSearchItem;
84
165
  imagePriority?: boolean;
166
+ /** Product Card theme scheme chosen on the listing block. */
167
+ schemeKey?: string;
168
+ /** Per-block field override; undefined leaves the scheme in charge. */
169
+ fields?: CardField[];
170
+ /** List-view columns; undefined renders the stacked tile card. */
171
+ listColumns?: CardColumn[];
172
+ /** Overrides the card's default `sizes` (list thumbnails are much smaller). */
173
+ imageSizes?: string;
85
174
  }): React.JSX.Element;
86
175
 
87
176
  declare function CategoryBreadcrumb({
@@ -102,13 +191,22 @@ declare function CategorySidebar({
102
191
  aggregation,
103
192
  currentFilters,
104
193
  isDrawer,
194
+ sections,
195
+ showCounts,
105
196
  }: {
106
197
  category: Category;
107
198
  url: string;
108
199
  aggregation?: PostItemsSearchResponse["aggregation"];
109
200
  currentFilters: Record<string, string[]>;
110
201
  isDrawer?: boolean;
111
- }): React.JSX.Element | null;
202
+ /**
203
+ * Structural elements to render, in order, from the listing block's
204
+ * `filterSections` repeater. Undefined keeps the historic order.
205
+ */
206
+ sections?: string[];
207
+ /** Show the match count beside each facet name and value. */
208
+ showCounts?: boolean;
209
+ }): React__default.JSX.Element | null;
112
210
 
113
211
  declare function CategorySort({
114
212
  url,
@@ -125,9 +223,36 @@ declare function CategorySort({
125
223
  declare function CategoryProducts({
126
224
  hasSiderbar,
127
225
  items,
226
+ columnsClass,
227
+ schemeKey,
228
+ fields,
229
+ listColumns,
230
+ autoScroll,
231
+ total,
128
232
  }: {
129
233
  hasSiderbar?: boolean;
130
234
  items: PostItemsSearchItem[];
235
+ /**
236
+ * Literal Tailwind column classes from the listing block's frozen map.
237
+ * Never interpolated — `grid-cols-${n}` compiles to nothing.
238
+ * The default reproduces the pre-CMS grid exactly.
239
+ */
240
+ columnsClass?: string;
241
+ schemeKey?: string;
242
+ fields?: CardField[];
243
+ /**
244
+ * List-view columns. Non-empty switches the grid to a single column of rows
245
+ * and hands each card its own layout — `columnsClass` is then unused.
246
+ */
247
+ listColumns?: CardColumn[];
248
+ /**
249
+ * The route's own search request, replayed a page at a time as the shopper
250
+ * scrolls. `null` keeps the grid to the server-rendered page, which is what
251
+ * page-number pagination wants.
252
+ */
253
+ autoScroll?: PostItemsSearchRequest | null;
254
+ /** Total matches, so auto-scroll knows when to stop. */
255
+ total?: number;
131
256
  }): React.JSX.Element;
132
257
 
133
258
  declare function CategoryProductsPageSize({
@@ -149,13 +274,120 @@ declare function ProductTitle({
149
274
  product: GetItemDetailResponse;
150
275
  }): React.JSX.Element;
151
276
 
277
+ /**
278
+ * Shared Constants Types
279
+ *
280
+ * Type definitions for constants used across the theme editor.
281
+ * These establish common interfaces for Tailwind CSS constants and configuration.
282
+ */
283
+
284
+ /**
285
+ * Box Shadow Config Interface
286
+ * Configuration for box shadow utilities
287
+ * Consolidated from cms/utils/tailwind-utils.ts
288
+ */
289
+ interface BoxShadowConfig {
290
+ preset?: string;
291
+ custom?: {
292
+ offsetX: string;
293
+ offsetY: string;
294
+ blurRadius: string;
295
+ spreadRadius?: string;
296
+ color: string;
297
+ opacity?: string;
298
+ inset?: boolean;
299
+ };
300
+ useCustom?: boolean;
301
+ }
302
+
303
+ /**
304
+ * Border Width Interface
305
+ * Interface for border width configuration
306
+ * Consolidated from cms/components/property/border-controller.tsx
307
+ */
308
+ interface BorderWidth {
309
+ top: string;
310
+ right: string;
311
+ bottom: string;
312
+ left: string;
313
+ }
314
+ /**
315
+ * Border Radius Interface
316
+ * Interface for border radius configuration
317
+ * Consolidated from cms/components/property/border-controller.tsx
318
+ */
319
+ interface BorderRadius {
320
+ topLeft: string;
321
+ topRight: string;
322
+ bottomRight: string;
323
+ bottomLeft: string;
324
+ }
325
+ /**
326
+ * Border Interface
327
+ * Interface for border configuration
328
+ * Consolidated from cms/components/property/border-controller.tsx
329
+ */
330
+ interface Border {
331
+ width: BorderWidth;
332
+ style: string;
333
+ color: string;
334
+ /** Border color opacity 0–100 (UN-3049). Optional; undefined ⇒ fully opaque. */
335
+ opacity?: string;
336
+ radius: BorderRadius;
337
+ }
338
+
339
+ type ImageFit = "cover" | "contain" | "fill" | "none";
340
+ /** Aspect vocabulary shared with the Product Card scheme (UN-3049). */
341
+ type ImageAspect = "square" | "landscape" | "portrait" | "wide";
342
+ interface ImageSurfaceSettings {
343
+ border?: Border;
344
+ shadow?: BoxShadowConfig;
345
+ bgColor?: string;
346
+ }
347
+
152
348
  type ThumbnailPosition = "top" | "right" | "bottom" | "left";
349
+ /**
350
+ * How the desktop thumbnail strip presents itself (UN-3295).
351
+ *
352
+ * `carousel` is the shipped behaviour: one scrollable rail with prev/next.
353
+ * `stack` shows a fixed number of thumbnails and expands the rest in place.
354
+ * Stack applies to `top` and `bottom` only — a `left`/`right` strip is one
355
+ * `--thumb-w` column wide (80px by default), too narrow for the control.
356
+ */
357
+ type ThumbnailLayout = "carousel" | "stack";
358
+ /** Main-image controls (UN-3285): presentation + its own Border/Shadow. */
359
+ interface ProductImageDisplaySettings extends ImageSurfaceSettings {
360
+ aspect?: ImageAspect;
361
+ fit?: ImageFit;
362
+ }
363
+ /**
364
+ * Thumbnail controls (UN-3285). Width and height are independent px values;
365
+ * both default to the legacy 80px box. Desktop-only (sm+) — on mobile the
366
+ * carousel IS the gallery and its slides stay full-width.
367
+ */
368
+ interface ProductThumbnailDisplaySettings extends ImageSurfaceSettings {
369
+ width?: number | "";
370
+ height?: number | "";
371
+ /** Gap between thumbnails, px. Unset = the 10px default; explicit 0 holds. */
372
+ gap?: number | "";
373
+ /** Gap between the strip and the main image, px. Unset = the 10px default. */
374
+ imageGap?: number | "";
375
+ fit?: ImageFit;
376
+ /** Desktop strip presentation (UN-3295). Unset = the shipped carousel. */
377
+ layout?: ThumbnailLayout;
378
+ /** Thumbnails shown before "Show more", stack layout only. Unset = 4. */
379
+ showMoreAfter?: number | "";
380
+ }
153
381
  declare const ProductImages: ({
154
382
  product,
155
383
  thumbnailPosition,
384
+ imageSettings,
385
+ thumbnailSettings,
156
386
  }: {
157
387
  product: GetItemDetailResponse;
158
388
  thumbnailPosition?: ThumbnailPosition;
389
+ imageSettings?: ProductImageDisplaySettings;
390
+ thumbnailSettings?: ProductThumbnailDisplaySettings;
159
391
  }) => React.JSX.Element;
160
392
 
161
393
  declare function ProductPrice({
@@ -306,31 +538,27 @@ interface FirebaseConnectionConfig {
306
538
  messagingSenderId?: string;
307
539
  appId?: string;
308
540
  measurementId?: string;
541
+ /**
542
+ * Firestore database to use when the project has more than one. NOT part
543
+ * of the console's firebaseConfig snippet — the SDK has no notion of a
544
+ * default database in its options, so this rides along as an extra key
545
+ * (initializeApp keeps unknown keys on `app.options`) and the app passes
546
+ * it to `getFirestore(app, databaseId)`. Empty/absent = `(default)`.
547
+ */
548
+ databaseId?: string;
309
549
  }
310
550
 
311
551
  /**
312
- * Per-extension render scope.
313
- *
314
- * Extension code has no runtime identity of its own — components are
315
- * bare functions and `cb` is one page-global object, while a single
316
- * React tree can hold components from several different extensions at
317
- * once. This context supplies that identity lexically: the host wraps
318
- * every extension render site (renderExtensionComponent's boundary and
319
- * the extension page route) in a provider carrying the registration id
320
- * and the extension's resolved per-store configuration. Contract hooks
321
- * like cb.utils.useFirebaseApp() read it, so the same component gets
322
- * its own extension's connections wherever it renders — including the
323
- * synthetic "preview" / "local" registrations, which just carry their
324
- * own scope value.
552
+ * Shared implementation — see @commercebuild/extension-host-libs
553
+ * (extension-scope.tsx). Only the TYPE re-exports below stay special:
554
+ * they are re-exported into the generated author-facing declaration,
555
+ * and tsup's dts build force-externalizes anything imported by package
556
+ * name (a storefront dependency) authors can't install the workspace
557
+ * package, so its types must inline via a RELATIVE path. A type-only
558
+ * import is erased at runtime, so the bundler never sees this path;
559
+ * runtime members keep importing the package normally.
325
560
  */
326
561
 
327
- /**
328
- * The provider type family (config shapes, the ResolvedConnection
329
- * discriminated union) is owned by the provider registry in
330
- * @commercebuild/extension-host-libs — one place declares them for the
331
- * storefront, storeadmin and the config JSON Schema alike. This module
332
- * re-exports the contract-facing names.
333
- */
334
562
  type CbFirebaseConfig = FirebaseConnectionConfig;
335
563
 
336
564
  /**
@@ -567,7 +795,9 @@ interface CbHostApi {
567
795
  * render a fallback for that case. With no argument: the store's
568
796
  * only connection when exactly one is configured, else the one
569
797
  * named "default". Never call initializeApp yourself; pass this app
570
- * to the product entry points (e.g. getFirestore(app)).
798
+ * to the product entry points. Honour a named database:
799
+ * `const id = (app.options as { databaseId?: string }).databaseId;`
800
+ * `const db = id ? getFirestore(app, id) : getFirestore(app);`
571
801
  */
572
802
  useFirebaseApp: (
573
803
  connection?: string,
package/types/index.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  import "./global";
2
+ import "./css";