znode-plugin 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -218,7 +218,7 @@ export default function setup(host: IBrowserHost): IBrowserPlugin {
218
218
  ```
219
219
 
220
220
  **Slots:** `layout.header`, `layout.footer`, `header.search`, `product.card`, `product.image`, `product.variants`,
221
- `product.addToCart`, `product.layout`, `productList.layout`, `addToCart.notification`, `login.form`,
221
+ `product.addToCart`, `product.layout`, `product.basicInfo`, `productList.layout`, `addToCart.notification`, `login.form`,
222
222
  `checkout.address`, `checkout.shippingOptions`. Server slots (in `server.tsx`): `layout.root`, `login.layout`, `login.page`.
223
223
 
224
224
  ### Add a page-builder widget
@@ -291,6 +291,8 @@ Run them with `npm run <name>` inside a project (the scripts call `znode … .`)
291
291
  | `npm run link-store -- <store repo>` | `znode link-store <repo> .` | connect the store's types |
292
292
  | | `npx znode install <zip> [pluginsDir]` | install a released zip into a store's plugin folder |
293
293
  | | `npx znode create <theme\|plugin\|widget\|content> <name>` | a minimal starter of another kind |
294
+ | | `npx znode eject` | list the store components a package can take over (each has a slot that hands the component over) |
295
+ | | `npx znode eject <Name> [--force]` | copy the store's component into `src/components/<Name>.tsx` (imports turned into store aliases, runtime wrapper removed) and register it in `src/browser.tsx` under its slot; prints any npm package the copy needs. The copy is yours: store fixes no longer reach it |
294
296
 
295
297
  The plugin folder is taken from: the command's argument, else the `RUNTIME_PLUGINS_DIR` environment variable, else
296
298
  `znode.local.json`.
@@ -56,36 +56,88 @@ const MODULES = {
56
56
  // the server-only functions a browser part may not need, and the hooks a server part cannot call, stay importable:
57
57
  // the store's module for that side decides (a hook in a server part fails as it would in a Next.js server component)
58
58
 
59
- // The store's own modules a browser part may import by the store's path, so a theme's own component (a copy of the
60
- // store's, say) keeps the store's imports instead of re-creating them: relative paths become "@znode/..." paths (the
61
- // store publishes them: packages/extensions/src/client/store-modules.ts; keep the two lists the same). Types for
62
- // package authors: sdk/store-modules.d.ts. Kinds: component (rendered by the store's React), function, object (its
63
- // properties read when used, its methods bound to it).
64
- const STORE_MODULES = {
65
- lodash: { debounce: "function" },
66
- "next-intl": { useTranslations: "function" },
67
- "@znode/logger": { logClient: "object" },
68
- "@znode/logger/server": { AREA: "object" },
69
- "@znode/constants/settings": { SETTINGS: "object" },
70
- "@znode/constants/user-activity-event": { USER_ACTIVITY_EVENT: "object" },
71
- "@znode/utils/common": { getValidatedRedirectPath: "function" },
72
- "@znode/base-components/components/common/icons": { LoadingSpinner: "component", SearchIcon: "component", ZIcons: "component" },
73
- "@znode/base-components/components/common/button/Button": { default: "component" },
74
- "@znode/base-components/components/common/input/Input": { default: "component" },
75
- "@znode/base-components/components/common/image": { CustomImage: "component" },
76
- "@znode/base-components/http-request": {
77
- getHydratedSuggestionSearch: "function",
78
- getHydratedTypeaheadSearchContent: "function",
79
- getSearchRedirectURL: "function",
80
- userActivityLog: "function",
81
- },
82
- "@znode/base-components/components/layout-components/header/barcode-scanner/BarcodeScanner": { BarcodeScanner: "component" },
83
- "@znode/base-components/components/layout-components/header/search/Search": { DynamicVoiceSearch: "component" },
84
- "@znode/base-components/components/layout-components/header/search-box": { SearchBox: "component" },
85
- "@znode/base-components/components/layout-components/header/search-box/TypeaheadSearch": { TypeaheadSearch: "component" },
86
- "@znode/base-components/components/layout-components/header/search-box/HydratedSearchList": { HydratedSearchList: "component" },
87
- };
88
- Object.assign(MODULES, STORE_MODULES);
59
+ // The store's own modules a browser part may import by the store's path (Generated Store Module Registry): any
60
+ // @znode/... import the package's tsconfig.store.json resolves to a store file the store's registry has (its
61
+ // packages/extensions/store-module-registry.json, generated by scripts/gen-store-module-registry.mjs from the store's
62
+ // source: its client modules), and the npm packages the store shares (lodash, next-intl). Each becomes a stand-in that
63
+ // reads the store's module, which the store loads before the package's bundle (the build lists the keys in the
64
+ // package's znode-package.json as storeImports). No list here: what the store has is what a package can use.
65
+ const SHARED_PACKAGES = new Set(["lodash", "next-intl"]);
66
+ const STORE = "store:";
67
+
68
+ const STORE_FILE = "\0znode-store-file:";
69
+ const asFile = (base) =>
70
+ [base, `${base}.ts`, `${base}.tsx`, path.join(base, "index.ts"), path.join(base, "index.tsx")].find((file) => fs.existsSync(file) && fs.statSync(file).isFile());
71
+ const keyOfFile = (file, store) => path.relative(store.root, file).split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
72
+
73
+ // an import, with the package's tsconfig.store.json paths (or relative to a store file), to the store's file
74
+ function storeFile(source, store, from) {
75
+ if (from && source.startsWith(".")) return asFile(path.resolve(path.dirname(from), source));
76
+ for (const [alias, [target]] of Object.entries(store.paths)) {
77
+ if (alias.endsWith("/*")) {
78
+ const prefix = alias.slice(0, -1);
79
+ if (source.startsWith(prefix)) return asFile(path.join(store.root, target.replace("*", source.slice(prefix.length))));
80
+ } else if (source === alias) return asFile(path.join(store.root, target));
81
+ }
82
+ return null;
83
+ }
84
+
85
+ // that file's registry key (its path from the store root without extension or /index, as the store's generator writes it)
86
+ function storeKey(source, store) {
87
+ if (SHARED_PACKAGES.has(source)) return source;
88
+ const file = storeFile(source, store);
89
+ return file ? keyOfFile(file, store) : null;
90
+ }
91
+
92
+ // the store file that defines an exported name, following the barrels (export * from, export { a as b } from): a
93
+ // theme's import { CustomImage } from ".../common/image" is loaded from common/image/CustomImage.tsx alone, not the whole
94
+ // barrel, whose other files may be server-only (the store's own bundler drops those for a static named import)
95
+ function definingFile(file, name, store, seen = new Set()) {
96
+ if (!file || seen.has(`${file}#${name}`)) return null;
97
+ seen.add(`${file}#${name}`);
98
+ const source = fs.readFileSync(file, "utf8");
99
+ const escaped = name.replace(/\$/g, "\\$");
100
+ if (name === "default" ? /export\s+default\b/.test(source) : new RegExp(`export\\s+(?:declare\\s+)?(?:async\\s+)?(?:const|let|var|function\\*?|class|enum)\\s+${escaped}\\b`).test(source)) return file;
101
+ for (const match of source.matchAll(/export\s*\{([^}]*)\}\s*(?:from\s*["']([^"']+)["'])?/g)) {
102
+ for (const part of match[1].split(",").map((item) => item.trim()).filter(Boolean)) {
103
+ const [inner, outer = inner] = part.replace(/^type\s+/, "").split(/\s+as\s+/).map((item) => item.trim());
104
+ if (outer !== name) continue;
105
+ return match[2] ? definingFile(storeFile(match[2], store, file), inner, store, seen) : file;
106
+ }
107
+ }
108
+ for (const match of source.matchAll(/export\s*\*\s*from\s*["']([^"']+)["']/g)) {
109
+ const found = definingFile(storeFile(match[1], store, file), name, store, seen);
110
+ if (found) return found;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ // a package module's named imports from the store, split by the store file that defines each name
116
+ function splitStoreImports(code, store) {
117
+ return code.replace(/import\s*\{([^}]*)\}\s*from\s*["'](@znode\/[^"']+)["'];?/g, (whole, names, source) => {
118
+ const barrel = storeFile(source, store);
119
+ if (!barrel) return whole;
120
+ const byFile = new Map();
121
+ for (const part of names.split(",").map((item) => item.trim()).filter(Boolean)) {
122
+ const [name] = part.split(/\s+as\s+/).map((item) => item.trim());
123
+ const file = definingFile(barrel, name, store) || barrel;
124
+ if (!byFile.has(file)) byFile.set(file, []);
125
+ byFile.get(file).push(part);
126
+ }
127
+ return [...byFile].map(([file, parts]) => `import { ${parts.join(", ")} } from ${JSON.stringify(STORE_FILE + keyOfFile(file, store))};`).join("\n");
128
+ });
129
+ }
130
+
131
+ // the stand-in for a store module: the store's module itself (loaded before the bundle), its names as rollup's
132
+ // synthetic named exports; a name missing on the module is read from its default (an npm CommonJS package)
133
+ function storeStandIn(key) {
134
+ return [
135
+ `const loaded = (globalThis.__znodeNextClient || {})[${JSON.stringify(key)}];`,
136
+ `if (!loaded) throw new Error(${JSON.stringify(`"${key}" was not loaded by the store: the package needs a store with the Generated Store Module Registry`)});`,
137
+ `export const __store = new Proxy(loaded, { get: (module, name) => (name in module ? module[name] : module.default?.[name]) });`,
138
+ `export default loaded.default;`,
139
+ ].join("\n");
140
+ }
89
141
 
90
142
  // react's values, the same in every React 19 build and never read from the store at load
91
143
  const VALUES = {
@@ -267,30 +319,42 @@ async function transformFonts(code, id, context) {
267
319
  * The rollup plugin. part: "server" | "browser"; prefix: "<name>-<version>"; outDir: dist; fonts: collects what
268
320
  * next/font/google built (files, css), which the build adds to the package's assets and stylesheet.
269
321
  */
270
- export function znodeNext({ part, prefix, outDir, fonts }) {
322
+ export function znodeNext({ part, prefix, outDir, fonts, store, storeImports = [] }) {
271
323
  return {
272
324
  name: "znode-next",
273
325
  resolveId(source) {
274
- if (STORE_MODULES[source] && part === "server") {
275
- this.error(`"${source}" is a store module for browser parts (src/browser.tsx), not server parts`);
276
- }
277
326
  if (MODULES[source]) return VIRTUAL + source;
327
+ if (SHARED_PACKAGES.has(source) || source.startsWith("@znode/") || source.startsWith(STORE_FILE)) {
328
+ if (part === "server") this.error(`"${source}" is a store module for browser parts (src/browser.tsx), not server parts`);
329
+ if (!store) this.error(`"${source}" is a store module: connect the store first (npm run link-store -- <store repo>), which writes tsconfig.store.json`);
330
+ if (!store.registry) this.error(`"${source}": the store at ${store.root} has no packages/extensions/store-module-registry.json (run node scripts/gen-store-module-registry.mjs there, or update the store)`);
331
+ // a named import was split to the file that defines it (transform); a default or namespace import is the module
332
+ const key = source.startsWith(STORE_FILE) ? source.slice(STORE_FILE.length) : storeKey(source, store);
333
+ if (!key) this.error(`"${source}" is not a store module (no file for it under the store's tsconfig paths)`);
334
+ if (!store.registry.has(key)) {
335
+ this.error(`"${source}" (${key}) is not in the store's module registry: it is server-only, or the store's registry is older than this file (regenerate it: node scripts/gen-store-module-registry.mjs in the store repo)`);
336
+ }
337
+ if (!storeImports.includes(key)) storeImports.push(key);
338
+ return VIRTUAL + STORE + key;
339
+ }
278
340
  if (source === FONT_MODULE) return null;
279
341
  if (source === "next" || source.startsWith("next/")) {
280
- this.error(`"${source}" is not available to packages. Supported: ${[...Object.keys(MODULES), FONT_MODULE].filter((id) => !STORE_MODULES[id]).join(", ")}`);
281
- }
282
- if (source.startsWith("@znode/")) {
283
- this.error(`"${source}" is not available to packages. Store components a package can import: ${Object.keys(STORE_MODULES).join(", ")}`);
342
+ this.error(`"${source}" is not available to packages. Supported: ${[...Object.keys(MODULES), FONT_MODULE].join(", ")}`);
284
343
  }
285
344
  return null;
286
345
  },
287
346
  load(id) {
288
- return id.startsWith(VIRTUAL) ? standIn(id.slice(VIRTUAL.length), part) : null;
347
+ if (!id.startsWith(VIRTUAL)) return null;
348
+ const name = id.slice(VIRTUAL.length);
349
+ if (name.startsWith(STORE)) return { code: storeStandIn(name.slice(STORE.length)), syntheticNamedExports: "__store" };
350
+ return standIn(name, part);
289
351
  },
290
352
  async transform(code, id) {
291
353
  if (id.startsWith(VIRTUAL)) return null;
292
- const output = await transformFonts(code, id, { prefix, outDir, fonts });
293
- return output === null ? null : { code: output, map: null };
354
+ const split = part === "browser" && store && /from\s*["']@znode\//.test(code) ? splitStoreImports(code, store) : code;
355
+ const output = await transformFonts(split, id, { prefix, outDir, fonts });
356
+ if (output === null) return split === code ? null : { code: split, map: null };
357
+ return { code: output, map: null };
294
358
  },
295
359
  };
296
360
  }