znode-plugin 0.1.8 → 0.1.10

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
@@ -282,47 +282,58 @@ components, so it is reviewed like any base change), and rebuild / restart the w
282
282
  ### Take over a whole store page
283
283
 
284
284
  A store **page** (a route, `apps/webstore/src/app/**/page.tsx`) runs on the server, so it gets a **server slot** around
285
- its content. Use the page's component name (its `export default function <Name>`):
285
+ the whole route, and `eject` gives you a **copy of the route**, as it does for a component. Use the page's name (its
286
+ `export default function <Name>`):
286
287
 
287
288
  ```
288
289
  $ npx znode eject ForgotPasswordPage
289
290
  ForgotPasswordPage has no slot yet, so it cannot be ejected.
290
291
  Create a slot for ForgotPasswordPage in the base store now? [y/N] y
291
- Slot name (area.name) [forgotPassword.page]:
292
+ Slot name (area.name) [forgotPassword.page]: forgotPassword.page
292
293
  creating slot "forgotPassword.page" around ForgotPasswordPage (apps/webstore/src/app/[locale]/forgot-password/page.tsx) ...
293
- ejected ForgotPasswordPage (server slot "forgotPassword.page") -> src/server/pages/ForgotPasswordPage.tsx
294
+ added "storeServer" to "permissions" in znode-package.json (its server part imports store modules)
295
+ ejected ForgotPasswordPage (a copy of apps/webstore/src/app/[locale]/forgot-password/page.tsx, server slot "forgotPassword.page") -> src/server/pages/ForgotPasswordPage.tsx
294
296
  registered "forgotPassword.page": EjectedForgotPasswordPage in src/server.tsx (serverSlots)
295
297
  ```
296
298
 
297
- In the store, the page's content is wrapped (inside its `NextIntlClientProvider`, so the page's texts still reach it):
299
+ In the store, the route's function becomes `<Name>Base` and its default export is wrapped (the server version of
300
+ `withRuntimeWrapper`; with no package for the slot the store's page renders as it is):
298
301
 
299
302
  ```tsx
300
- <NextIntlClientProvider messages={...}>
301
- <RuntimeServerSlot name="forgotPassword.page">
302
- <ForgotPassword />
303
- </RuntimeServerSlot>
304
- </NextIntlClientProvider>
303
+ async function ForgotPasswordPageBase() { … unchanged … }
304
+ export default withRuntimeServerPage("forgotPassword.page", ForgotPasswordPageBase);
305
305
  ```
306
306
 
307
- In your package you get a server component to build the page in. `children` is the store's page content:
307
+ In your package you get the route itself, to change as you like:
308
308
 
309
309
  ```tsx
310
- // src/server/pages/ForgotPasswordPage.tsx
311
- export async function ForgotPasswordPage(props: { children?: ReactNode }, context: IServerSlotContext) {
310
+ // src/server/pages/ForgotPasswordPage.tsx (a copy of the store's route)
311
+ import { ForgotPassword } from "@znode/base-components/components/forgot-password";
312
+ import { NextIntlClientProvider } from "next-intl";
313
+ import { getResourceMessages } from "@znode/utils/server";
314
+
315
+ export async function ForgotPasswordPage() {
316
+ const userPasswordMessages = await getResourceMessages("UserPassword");
317
+ const commonMessages = await getResourceMessages("Common");
312
318
  return (
313
- <div className="my-forgot-page">
319
+ <NextIntlClientProvider messages={{ ...userPasswordMessages, ...commonMessages }}>
314
320
  <h1>Reset your password</h1>
315
- <aside>Help text, images, data fetched here (async is fine)</aside>
316
- {props.children /* the store's form; leave it out to render a page of your own */}
317
- </div>
321
+ <ForgotPassword />
322
+ </NextIntlClientProvider>
318
323
  );
319
324
  }
320
325
  ```
321
326
 
322
- - It is not a copy of the route: the route's server code (agents, `getResourceMessages`, data) stays the store's. Your
323
- component runs on the server only (async, no hooks): put interactive parts in a browser slot, e.g. change the form
324
- itself with `npx znode eject ForgotPassword` (slot `account.forgotPassword`).
325
- - A page that already has a server slot (`login.page`, `layout.root`) is ejected straight away (`npx znode eject LoginPage`).
327
+ - **props**: the route's own (`params`, `searchParams`) and `children` (the store's page, if you want to keep it);
328
+ **context** (second argument): `store`, `settings`. It runs on the server only (async, no hooks).
329
+ - **Store imports in a server part** come from the store's **server registry**, an allow-list the store generates
330
+ (`packages/extensions/store-server-module-registry.json`): agents (`@znode/agents/*`), the texts and store context
331
+ (`getResourceMessages`, `fetchMessages`, `getPortalHeader`), the server logger, and client components and modules.
332
+ Session, cookie, token, cache and API-client internals are not in it: the build stops on them and says so.
333
+ The package needs the permission `"storeServer"` (eject adds it); a sandboxed package cannot have it.
334
+ - The route's `generateMetadata` (page title) stays the store's.
335
+ - To change only the form, eject the client component instead: `npx znode eject ForgotPassword`.
336
+ - A page whose slot is **inside** the page (`login.page`, `layout.root`) gives a starter that renders `children`, not a copy.
326
337
  - Only the slot: `npx znode create-slot ForgotPasswordPage forgotPassword.page` (`--dry-run` to preview).
327
338
 
328
339
  ### Add a page-builder widget
@@ -130,10 +130,12 @@ function splitStoreImports(code, store) {
130
130
 
131
131
  // the stand-in for a store module: the store's module itself (loaded before the bundle), its names as rollup's
132
132
  // synthetic named exports; a name missing on the module is read from its default (an npm CommonJS package)
133
- function storeStandIn(key) {
133
+ // (a server part reads the store's server set, __znodeStoreServer: next-modules.server.ts loads it before the bundle)
134
+ function storeStandIn(key, part) {
135
+ const global = part === "server" ? "__znodeStoreServer" : "__znodeNextClient";
134
136
  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
+ `const loaded = (globalThis.${global} || {})[${JSON.stringify(key)}];`,
138
+ `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${part === "server" ? " (server registry)" : ""}`)});`,
137
139
  `export const __store = new Proxy(loaded, { get: (module, name) => (name in module ? module[name] : module.default?.[name]) });`,
138
140
  `export default loaded.default;`,
139
141
  ].join("\n");
@@ -325,14 +327,24 @@ export function znodeNext({ part, prefix, outDir, fonts, store, storeImports = [
325
327
  resolveId(source) {
326
328
  if (MODULES[source]) return VIRTUAL + source;
327
329
  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
330
  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 server part imports from the store's server registry (an allow-list: agents, texts and store context, logger,
332
+ // client modules), a browser part from the client registry
333
+ const registry = part === "server" ? store.serverRegistry : store.registry;
334
+ const registryFile = part === "server" ? "store-server-module-registry.json" : "store-module-registry.json";
335
+ if (!registry)
336
+ this.error(
337
+ `"${source}": the store at ${store.root} has no packages/extensions/${registryFile} (run node scripts/gen-store-module-registry.mjs there, or update the store)${part === "server" ? ": a server part can import store modules only from a store with the server registry" : ""}`
338
+ );
331
339
  // a named import was split to the file that defines it (transform); a default or namespace import is the module
332
340
  const key = source.startsWith(STORE_FILE) ? source.slice(STORE_FILE.length) : storeKey(source, store);
333
341
  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)`);
342
+ if (!registry.has(key)) {
343
+ this.error(
344
+ part === "server"
345
+ ? `${source.startsWith(STORE_FILE) ? `store module ${key} (a named import's defining file)` : `"${source}" (${key})`} is not available to a package's server part: the store's server registry allows agents, texts and store context (getResourceMessages, fetchMessages, getPortalHeader), the server logger and client modules, not session, cookie, token, cache or API-client internals (or the store's registry is older: node scripts/gen-store-module-registry.mjs in the store repo)`
346
+ : `"${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)`
347
+ );
336
348
  }
337
349
  if (!storeImports.includes(key)) storeImports.push(key);
338
350
  return VIRTUAL + STORE + key;
@@ -346,12 +358,12 @@ export function znodeNext({ part, prefix, outDir, fonts, store, storeImports = [
346
358
  load(id) {
347
359
  if (!id.startsWith(VIRTUAL)) return null;
348
360
  const name = id.slice(VIRTUAL.length);
349
- if (name.startsWith(STORE)) return { code: storeStandIn(name.slice(STORE.length)), syntheticNamedExports: "__store" };
361
+ if (name.startsWith(STORE)) return { code: storeStandIn(name.slice(STORE.length), part), syntheticNamedExports: "__store" };
350
362
  return standIn(name, part);
351
363
  },
352
364
  async transform(code, id) {
353
365
  if (id.startsWith(VIRTUAL)) return null;
354
- const split = part === "browser" && store && /from\s*["']@znode\//.test(code) ? splitStoreImports(code, store) : code;
366
+ const split = store && /from\s*["']@znode\//.test(code) ? splitStoreImports(code, store) : code;
355
367
  const output = await transformFonts(split, id, { prefix, outDir, fonts });
356
368
  if (output === null) return split === code ? null : { code: split, map: null };
357
369
  return { code: output, map: null };
@@ -41,14 +41,15 @@
41
41
  // (attribute: the value comes from a Znode global attribute, "Code" for the store's, or
42
42
  // { "code": "...", "levels": ["store", "account", "user"] }; the most specific level wins)
43
43
  // marketplace: "license", "keywords", "homepage", "compatibility": { "webstore": ">=10.0.0" },
44
- // "permissions": ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads"] (what it does beyond UI;
45
- // "uploads": its routes take files, multipart/form-data up to RUNTIME_PLUGIN_UPLOAD_BYTES)
44
+ // "permissions": ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads", "storeServer"] (what it does beyond UI;
45
+ // "uploads": its routes take files, multipart/form-data up to RUNTIME_PLUGIN_UPLOAD_BYTES;
46
+ // "storeServer": its server part imports store modules from the store's server registry)
46
47
  // "sandbox": true (its server part runs in a separate, permission-limited Node process: routes and overrides only)
47
48
  // }
48
49
  import fs from "node:fs";
49
50
  import path from "node:path";
50
51
  import { execFileSync, spawnSync } from "node:child_process";
51
- import { createRequire } from "node:module";
52
+ import { builtinModules, createRequire } from "node:module";
52
53
  import { fileURLToPath, pathToFileURL } from "node:url";
53
54
  import vm from "node:vm";
54
55
  import { TEMPLATES } from "./templates.mjs";
@@ -79,7 +80,9 @@ const MAX_BYTES = 10 * 1024 * 1024;
79
80
  const SETTING_TYPES = ["string", "number", "boolean", "list", "json", "object", "array"];
80
81
  const ATTRIBUTE_LEVELS = ["store", "account", "user"];
81
82
  const ATTRIBUTE_CODE = /^[A-Za-z][A-Za-z0-9_]{1,99}$/;
82
- const PERMISSIONS = ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads"];
83
+ // storeServer: its server part imports store modules (agents, texts and store context, logger, client components) from the
84
+ // store's server registry, e.g. a page ejected with `znode eject <Page>`; not in a sandboxed package
85
+ const PERMISSIONS = ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads", "storeServer"];
83
86
 
84
87
  const fail = (message) => {
85
88
  console.error(`znode-package: ${message}`);
@@ -209,7 +212,10 @@ function linkedStore(dir) {
209
212
  const root = path.resolve(dir, compilerOptions.baseUrl);
210
213
  const registryFile = path.join(root, "packages/extensions/store-module-registry.json");
211
214
  const registry = fs.existsSync(registryFile) ? readJson(registryFile).modules : null;
212
- return { root, paths: compilerOptions.paths || {}, registry };
215
+ // the store modules a package's server part may import (an allow-list, permission storeServer)
216
+ const serverRegistryFile = path.join(root, "packages/extensions/store-server-module-registry.json");
217
+ const serverRegistry = fs.existsSync(serverRegistryFile) ? readJson(serverRegistryFile).modules : null;
218
+ return { root, paths: compilerOptions.paths || {}, registry, serverRegistry };
213
219
  }
214
220
 
215
221
  function build(target) {
@@ -226,7 +232,27 @@ function build(target) {
226
232
  const fonts = [];
227
233
  // the store modules its browser part imports (Generated Store Module Registry keys), for the store to load first
228
234
  const storeImports = [];
235
+ // the store modules its server part imports (the store's server registry, permission storeServer)
236
+ const serverStoreImports = [];
229
237
  const store = linkedStore(dir);
238
+ // the npm packages its sources import: those the store does not share and the package does not have yet are installed
239
+ // at the store's version before anything compiles (--no-install: named instead); another version than the store's warns
240
+ if (store) {
241
+ const sources = [];
242
+ const walk = (folder) => {
243
+ if (!fs.existsSync(folder)) return;
244
+ for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
245
+ const file = path.join(folder, entry.name);
246
+ if (entry.isDirectory()) walk(file);
247
+ else if (/\.(tsx?|jsx?|mjs)$/.test(entry.name)) sources.push(file);
248
+ }
249
+ };
250
+ walk(path.join(dir, "src"));
251
+ const specs = sources.flatMap((file) => npmImportsOf(fs.readFileSync(file, "utf8")));
252
+ ensureStorePackages(dir, store, specs, { install: !process.argv.includes("--no-install") });
253
+ const drift = versionDrift(dir, store, specs);
254
+ if (drift.length) console.log(`warning installed at another version than the store's: ${drift.join(", ")} (npm install --save-exact <pkg>@<store version>)`);
255
+ }
230
256
  // A package with its own tsconfig.json (extending the store's tsconfig.base.json) is checked against the store's real
231
257
  // types (every @znode alias, no declarations to write), then compiled file by file without type information, JSX as
232
258
  // Next.js does (react/jsx-runtime: the store's). Without one, the package compiles as before (tsc, JSX factory h).
@@ -282,6 +308,7 @@ function build(target) {
282
308
  `const storeImports = [];`,
283
309
  `const store = ${JSON.stringify(store)};`,
284
310
  `if (store?.registry) store.registry = new Set(store.registry);`,
311
+ `if (store?.serverRegistry) store.serverRegistry = new Set(store.serverRegistry);`,
285
312
  `export default {`,
286
313
  ` input: ${JSON.stringify(compiled)},`,
287
314
  ` output: { file: ${JSON.stringify(path.join(out, target))}, format: "es", sourcemap: true },`,
@@ -294,7 +321,8 @@ function build(target) {
294
321
  );
295
322
  execFileSync(process.execPath, [ROLLUP(), "--config", config, "--silent"], { stdio: "inherit" });
296
323
  if (fs.existsSync(fontsFile)) fonts.push(...JSON.parse(fs.readFileSync(fontsFile, "utf8")));
297
- if (fs.existsSync(storeImportsFile)) storeImports.push(...JSON.parse(fs.readFileSync(storeImportsFile, "utf8")));
324
+ // the server part's store imports (server registry) are loaded by the store's server, the browser part's by the browser
325
+ if (fs.existsSync(storeImportsFile)) (part === "server" ? serverStoreImports : storeImports).push(...JSON.parse(fs.readFileSync(storeImportsFile, "utf8")));
298
326
  files[part] = target;
299
327
  }
300
328
  fs.rmSync(tmp, { recursive: true, force: true });
@@ -336,7 +364,19 @@ function build(target) {
336
364
  );
337
365
  }
338
366
  const { build: _build, styles: _styles, assets: _assets, dedupeStyles: _dedupe, ...rest } = manifest;
339
- fs.writeFileSync(path.join(out, MANIFEST), JSON.stringify({ ...rest, files, ...(storeImports.length ? { storeImports: [...new Set(storeImports)].sort() } : {}) }, null, 2) + "\n");
367
+ fs.writeFileSync(
368
+ path.join(out, MANIFEST),
369
+ JSON.stringify(
370
+ {
371
+ ...rest,
372
+ files,
373
+ ...(storeImports.length ? { storeImports: [...new Set(storeImports)].sort() } : {}),
374
+ ...(serverStoreImports.length ? { serverStoreImports: [...new Set(serverStoreImports)].sort() } : {}),
375
+ },
376
+ null,
377
+ 2
378
+ ) + "\n"
379
+ );
340
380
  console.log(`built ${manifest.kind} ${manifest.name}@${manifest.version}: ${fs.readdirSync(out).length} files`);
341
381
  return { manifest, out };
342
382
  }
@@ -487,6 +527,8 @@ function install(source, storeDir) {
487
527
  ...(manifest.widgets?.length ? { widgets: manifest.widgets } : {}),
488
528
  // the store modules its browser bundle imports: the store loads them first (Generated Store Module Registry)
489
529
  ...(Array.isArray(manifest.storeImports) && manifest.storeImports.length ? { storeImports: manifest.storeImports.map(String) } : {}),
530
+ // and the store modules its server bundle imports (server registry): the store's server loads them first
531
+ ...(Array.isArray(manifest.serverStoreImports) && manifest.serverStoreImports.length ? { serverStoreImports: manifest.serverStoreImports.map(String) } : {}),
490
532
  ...(manifest.overrides ? { overrides: manifest.overrides } : {}),
491
533
  ...(manifest.serverPages?.length ? { serverPages: manifest.serverPages } : {}),
492
534
  ...(manifest.sandbox === true ? { sandbox: true } : {}),
@@ -593,6 +635,8 @@ function check(target) {
593
635
  if (/\bznode\s*\(/.test(server)) used.add("znodeApi");
594
636
  if (/\bfetch\s*\(|\bhttps?\.request\s*\(/.test(server)) used.add("externalNetwork");
595
637
  if (/\.files\b/.test(server)) used.add("uploads");
638
+ if (/^\s*import\s+(?!type\b)[^;]*?from\s*["']@znode\/(?!types\/)/m.test(server)) used.add("storeServer");
639
+ if (used.has("storeServer") && manifest.sandbox === true) errors.push(`its server part imports store modules ("storeServer"), which a sandboxed package cannot: its routes run in their own process`);
596
640
  const declared = new Set(manifest.permissions || []);
597
641
  for (const item of used) if (!declared.has(item)) errors.push(`uses "${item}" without declaring it in "permissions"`);
598
642
  for (const item of declared) if (!used.has(item)) warnings.push(`declares "${item}" but no code uses it (the review asks why)`);
@@ -678,12 +722,134 @@ function linkStore(storeRepo, project = ".") {
678
722
  const options = config.compilerOptions || {};
679
723
  const toSlash = (value) => value.split(path.sep).join("/");
680
724
  const file = path.resolve(project, "tsconfig.store.json");
725
+ // the npm packages the store shares at runtime (next-intl, lodash, ...): never installed in the package (the build uses
726
+ // the store's copy), so their types come from the store's own node_modules, at the store's version
727
+ const repo = path.dirname(base);
728
+ const sharedPaths = {};
729
+ for (const pkg of storeSharedPackages(repo)) {
730
+ const types = path.join(repo, "node_modules", "@types", pkg.replace(/^@/, "").replace("/", "__"));
731
+ const dir = path.join(repo, "node_modules", pkg);
732
+ if (fs.existsSync(path.join(types, "package.json"))) sharedPaths[pkg] = [toSlash(types)];
733
+ else if (fs.existsSync(path.join(dir, "package.json"))) {
734
+ sharedPaths[pkg] = [toSlash(dir)];
735
+ sharedPaths[`${pkg}/*`] = [`${toSlash(dir)}/*`];
736
+ }
737
+ }
681
738
  const linked = {
682
739
  extends: "znode-plugin/tsconfig.json",
683
- compilerOptions: { baseUrl: toSlash(path.resolve(path.dirname(base), options.baseUrl || ".")), paths: options.paths || {} },
740
+ compilerOptions: { baseUrl: toSlash(path.resolve(repo, options.baseUrl || ".")), paths: { ...sharedPaths, ...(options.paths || {}) } },
684
741
  };
685
742
  fs.writeFileSync(file, JSON.stringify(linked, null, 2) + "\n");
686
- console.log(`linked ${file} to ${base} (${Object.keys(linked.compilerOptions.paths).length} store aliases)`);
743
+ const shared = Object.keys(sharedPaths).filter((key) => !key.endsWith("/*"));
744
+ console.log(
745
+ `linked ${file} to ${base} (${Object.keys(options.paths || {}).length} store aliases${shared.length ? `; types of the packages the store shares: ${shared.join(", ")}` : ""})`
746
+ );
747
+ }
748
+
749
+ // ---- the store's npm packages: shared at runtime, or installed in the package at the store's version -------------------
750
+ // react, react-dom, next and @znode/* are the store's at runtime (stand-ins, never bundled); so are the store's shared
751
+ // packages (its registry's sharedPackages: next-intl, lodash, ...). Any other npm package a package imports is bundled
752
+ // into it, so it must be installed in the package: at the version the store has installed, so a copied store component
753
+ // behaves as it does in the store. Installed exactly (no ^), from the npm registry, by eject and by build.
754
+ const RUNTIME_PROVIDED = /^(react|react-dom|next|@znode)(\/|$)/;
755
+ const DEFAULT_SHARED = ["lodash", "next-intl"];
756
+ const packageNameOf = (spec) => (spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]);
757
+
758
+ function storeSharedPackages(root) {
759
+ for (const name of ["store-module-registry.json", "store-server-module-registry.json"]) {
760
+ const file = path.join(root, "packages/extensions", name);
761
+ try {
762
+ const list = readJson(file).sharedPackages;
763
+ if (Array.isArray(list) && list.length) return list.map(String);
764
+ } catch {
765
+ // no registry, or one from before sharedPackages: the default list
766
+ }
767
+ }
768
+ return DEFAULT_SHARED;
769
+ }
770
+
771
+ // the version the store has installed (its node_modules), else its package.json range without ^ / ~
772
+ function storePackageVersion(root, pkg) {
773
+ try {
774
+ return readJson(path.join(root, "node_modules", pkg, "package.json")).version;
775
+ } catch {
776
+ try {
777
+ const manifest = readJson(path.join(root, "package.json"));
778
+ const range = { ...(manifest.devDependencies || {}), ...(manifest.dependencies || {}) }[pkg];
779
+ return range ? range.replace(/^[\^~]/, "") : null;
780
+ } catch {
781
+ return null;
782
+ }
783
+ }
784
+ }
785
+
786
+ const installedIn = (project, pkg) => fs.existsSync(path.join(project, "node_modules", pkg, "package.json"));
787
+
788
+ // the npm packages code imports (bare specifiers, type-only imports included: their types are needed too)
789
+ const npmImportsOf = (code) =>
790
+ [...code.matchAll(/(?:^|[^\w$.])(?:import|export)\s*(?:[^"';]*?\s+from\s*)?["']([^"'.][^"']*)["']|import\(\s*["']([^"'.][^"']*)["']\s*\)/g)]
791
+ .map((match) => match[1] || match[2])
792
+ .filter((spec) => !spec.startsWith("node:") && !builtinModules.includes(packageNameOf(spec)));
793
+
794
+ /**
795
+ * The npm packages `specs` need in the package at `project`: the store's runtime and shared packages are left out, the
796
+ * others not installed yet are installed at the store's version (with their @types package when the store has one), or
797
+ * only named when install is false. Returns what was done, for the caller's message.
798
+ */
799
+ function ensureStorePackages(project, store, specs, { install = true } = {}) {
800
+ const root = store.root;
801
+ const shared = new Set(storeSharedPackages(root));
802
+ const wanted = new Map();
803
+ const typesWanted = new Map();
804
+ const notInStore = new Set();
805
+ const sharedUsed = new Set();
806
+ for (const pkg of new Set(specs.map(packageNameOf))) {
807
+ if (RUNTIME_PROVIDED.test(pkg)) continue;
808
+ if (shared.has(pkg)) {
809
+ sharedUsed.add(pkg);
810
+ continue;
811
+ }
812
+ if (installedIn(project, pkg)) continue;
813
+ const version = storePackageVersion(root, pkg);
814
+ if (!version) {
815
+ notInStore.add(pkg);
816
+ continue;
817
+ }
818
+ wanted.set(pkg, `${pkg}@${version}`);
819
+ const typesName = `@types/${pkg.replace(/^@/, "").replace("/", "__")}`;
820
+ const typesVersion = fs.existsSync(path.join(root, "node_modules", typesName, "package.json")) ? storePackageVersion(root, typesName) : null;
821
+ if (typesVersion && !installedIn(project, typesName)) typesWanted.set(typesName, `${typesName}@${typesVersion}`);
822
+ }
823
+ const run = (args) => spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { cwd: project, stdio: "inherit", shell: process.platform === "win32" });
824
+ let installed = [];
825
+ if (wanted.size) {
826
+ const list = [...wanted.values()];
827
+ if (install) {
828
+ console.log(`installing the store's versions: ${list.join(" ")}`);
829
+ const result = run(["install", "--save-exact", "--no-audit", "--no-fund", ...list]);
830
+ if (result.status !== 0) fail(`npm install failed: run it yourself in ${project}: npm install --save-exact ${list.join(" ")}`);
831
+ if (typesWanted.size) run(["install", "--save-dev", "--save-exact", "--no-audit", "--no-fund", ...typesWanted.values()]);
832
+ installed = [...list, ...typesWanted.values()];
833
+ } else {
834
+ console.log(`needs the store's versions (not installed: --no-install): npm install --save-exact ${list.join(" ")}`);
835
+ if (typesWanted.size) console.log(` and its types: npm install --save-dev --save-exact ${[...typesWanted.values()].join(" ")}`);
836
+ }
837
+ }
838
+ for (const pkg of notInStore) console.log(`warning "${pkg}" is not a dependency of the store: install it yourself (npm install ${pkg})`);
839
+ return { installed, shared: [...sharedUsed], notInStore: [...notInStore] };
840
+ }
841
+
842
+ // packages installed in the package at another version than the store's (after a store upgrade): a warning, the store's
843
+ // version is what its components were written against
844
+ function versionDrift(project, store, specs) {
845
+ const drift = [];
846
+ for (const pkg of new Set(specs.map(packageNameOf))) {
847
+ if (RUNTIME_PROVIDED.test(pkg) || !installedIn(project, pkg)) continue;
848
+ const mine = readJson(path.join(project, "node_modules", pkg, "package.json")).version;
849
+ const theirs = fs.existsSync(path.join(store.root, "node_modules", pkg, "package.json")) ? storePackageVersion(store.root, pkg) : null;
850
+ if (theirs && mine !== theirs) drift.push(`${pkg} ${mine} (store: ${theirs})`);
851
+ }
852
+ return drift;
687
853
  }
688
854
 
689
855
  // ---- eject: a store component copied into the package, in place of the store's, through its slot ----------------
@@ -753,8 +919,25 @@ function ejectableComponents(root) {
753
919
  return found.sort((a, b) => a.name.localeCompare(b.name));
754
920
  }
755
921
 
756
- // a store file's import path as the package can import it: the store alias that maps to that file, without extension
757
- function storeAlias(store, absolute) {
922
+ // a store file that re-exports `target` (export * from / export { … } from), followed through nested barrels
923
+ function reExports(barrel, target, seen = new Set()) {
924
+ if (!barrel || seen.has(barrel) || !fs.existsSync(barrel)) return false;
925
+ seen.add(barrel);
926
+ const asFile = (base) =>
927
+ [base, `${base}.ts`, `${base}.tsx`, path.join(base, "index.ts"), path.join(base, "index.tsx")].find((file) => fs.existsSync(file) && fs.statSync(file).isFile());
928
+ for (const match of fs.readFileSync(barrel, "utf8").matchAll(/export\s*(?:\*|\{[^}]*\})\s*from\s*["'](\.{1,2}\/[^"']+)["']/g)) {
929
+ const file = asFile(path.resolve(path.dirname(barrel), match[1]));
930
+ if (!file) continue;
931
+ if (file.split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "") === target) return true;
932
+ if (reExports(file, target, seen)) return true;
933
+ }
934
+ return false;
935
+ }
936
+
937
+ // a store file's import path as the package can import it: the store alias that maps to that file, without extension;
938
+ // else, for named imports (statement: the import's text up to its path), an exact alias whose barrel re-exports the file
939
+ // (".../stores/modal" -> "@znode/base-components/stores"): export * does not carry a default, so a default import is not
940
+ function storeAlias(store, absolute, { statement = "" } = {}) {
758
941
  const target = absolute.split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
759
942
  let best = null;
760
943
  const norm = (value) => path.resolve(store.root, value).split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
@@ -769,7 +952,14 @@ function storeAlias(store, absolute) {
769
952
  if (target.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) best = { prefix, alias: alias.slice(0, -1) };
770
953
  }
771
954
  }
772
- return best ? best.alias + target.slice(best.prefix.length) : null;
955
+ if (best) return best.alias + target.slice(best.prefix.length);
956
+ const namedOnly = /^import\s*(?:type\s+)?\{/.test(statement.trim()) || /^export\s/.test(statement.trim());
957
+ if (!namedOnly) return null;
958
+ for (const [alias, targets] of Object.entries(store.paths)) {
959
+ if (alias.endsWith("/*") || !alias.startsWith("@znode/")) continue;
960
+ if (targets.some((pattern) => reExports(path.resolve(store.root, pattern), target))) return alias;
961
+ }
962
+ return null;
773
963
  }
774
964
 
775
965
  // a question on the terminal (only asked when stdin is a terminal); one line reader for the whole run, so answers
@@ -798,10 +988,85 @@ const suggestSlot = (name) => {
798
988
  // eject a store page (a server slot around a route's content): not a copy of the route (its server code, agents and
799
989
  // data stay the store's), a server component of the package's own in src/server/pages/<Page>.tsx, registered under the
800
990
  // slot in src/server.tsx, that renders the store's page content (children) to start with
801
- function ejectServerSlot(project, s, force) {
991
+ // A whole route (withRuntimeServerPage): a COPY of the route instead, as component eject copies a component: the route's
992
+ // file with its function as `export async function <Page>(props, context)` (props: the route's params and searchParams,
993
+ // children: the store's page), its imports as the store's aliases, which a server part imports from the store's server
994
+ // registry (permission storeServer, added to znode-package.json).
995
+ function ejectRouteCopy(project, s, store) {
996
+ const name = s.page;
997
+ const storeFile = path.join(store.root, s.files[0]);
998
+ let text = fs.readFileSync(storeFile, "utf8");
999
+ // the copy is the plain page: no wrapper (the slot is what puts it in place)
1000
+ text = text.replace(/^import\s*\{\s*withRuntimeServerPage\s*\}\s*from\s*["'][^"']+["'];?[ \t]*\r?\n/m, "");
1001
+ text = text.replace(/(?:^\/\/ runtime packages render the whole page instead[^\n]*\n(?:\/\/[^\n]*\n)*)?^export\s+default\s+withRuntimeServerPage\([^)]*\);?[ \t]*\r?\n?/m, "");
1002
+ text = text.replace(new RegExp(`^(async\\s+)?function\\s+${s.base}\\s*\\(`, "m"), (whole, isAsync) => `export ${isAsync || ""}function ${name}(`);
1003
+ // relative imports become the store's aliases (the package lives elsewhere)
1004
+ const warnings = [];
1005
+ text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after, offset, source) => {
1006
+ const alias = storeAlias(store, path.resolve(path.dirname(storeFile), spec), { statement: source.slice(source.lastIndexOf("import", offset), offset) });
1007
+ if (!alias) {
1008
+ warnings.push(`import ${spec}: no store alias for it; change it by hand`);
1009
+ return whole;
1010
+ }
1011
+ return `${before}${alias}${after}`;
1012
+ });
1013
+ const header =
1014
+ `// Ejected from the store: ${s.files[0]} (${new Date().toISOString().slice(0, 10)}), with \`znode eject ${name}\`: a copy of the\n` +
1015
+ `// whole route. The store renders it instead of its own page through the server slot "${s.name}" (src/server.tsx\n` +
1016
+ `// serverSlots), on the server only. props: the route's params and searchParams, and children (the store's page);\n` +
1017
+ `// context (second argument): store, settings. Its @znode imports come from the store's server registry (permission\n` +
1018
+ `// storeServer): agents, texts and store context, the server logger, client components. It is yours now: the store's\n` +
1019
+ `// later changes to this page do not reach it, so compare it with ${s.files[0]} after a store upgrade.\n`;
1020
+ text = header + text;
1021
+
1022
+ // what the store's server registry does not allow: named now, the build stops on it too
1023
+ if (store.serverRegistry) {
1024
+ const allowed = new Set(store.serverRegistry);
1025
+ for (const m of text.matchAll(/^\s*import\s+(?!type\b)[^;]*?from\s*["'](@znode\/[^"']+)["']/gm)) {
1026
+ if (m[1].startsWith("@znode/types/")) continue;
1027
+ const file = (() => {
1028
+ for (const [alias, [target]] of Object.entries(store.paths)) {
1029
+ if (alias.endsWith("/*") && m[1].startsWith(alias.slice(0, -1))) return target.replace("*", m[1].slice(alias.length - 1));
1030
+ if (alias === m[1]) return target;
1031
+ }
1032
+ return null;
1033
+ })();
1034
+ const key = file && file.replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
1035
+ // a barrel (utils/server's index, a component folder) is checked name by name at build time, each name from the file
1036
+ // that defines it; only a module that is not a barrel and not allowed is named here
1037
+ const barrel = /\/index\.tsx?$/.test(file || "") || [...allowed].some((allowedKey) => allowedKey.startsWith(`${key}/`));
1038
+ if (key && !allowed.has(key) && !barrel) warnings.push(`${m[1]}: not in the store's server registry; the build will stop on it (use an agent or context.znode instead)`);
1039
+ }
1040
+ } else warnings.push("the linked store has no server registry (packages/extensions/store-server-module-registry.json): update the store, or the build stops on the copy's @znode imports");
1041
+
1042
+ // the permission it needs
1043
+ const manifestFile = path.join(project, MANIFEST);
1044
+ const manifest = readJson(manifestFile);
1045
+ if (!(manifest.permissions || []).includes("storeServer")) {
1046
+ manifest.permissions = [...(manifest.permissions || []), "storeServer"];
1047
+ fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + "\n");
1048
+ console.log(`added "storeServer" to "permissions" in ${MANIFEST} (its server part imports store modules)`);
1049
+ }
1050
+ return { text, warnings };
1051
+ }
1052
+
1053
+ function ejectServerSlot(project, s, force, store, noInstall = false) {
802
1054
  const name = s.page;
803
1055
  const out = path.join(project, "src", "server", "pages", `${name}.tsx`);
804
1056
  if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${name} --force`);
1057
+ if (s.route) {
1058
+ const { text, warnings } = ejectRouteCopy(project, s, store);
1059
+ fs.mkdirSync(path.dirname(out), { recursive: true });
1060
+ fs.writeFileSync(out, text);
1061
+ console.log(`ejected ${name} (a copy of ${s.files[0]}, server slot "${s.name}") -> ${path.relative(project, out)}`);
1062
+ registerServerSlot(project, s, name);
1063
+ const deps = ensureStorePackages(project, store, npmImportsOf(text), { install: !noInstall });
1064
+ if (deps.installed.length) console.log(`installed (store versions): ${deps.installed.join(", ")}`);
1065
+ if (deps.shared.length) console.log(`shared by the store (not installed; types from the linked store): ${deps.shared.join(", ")}`);
1066
+ for (const warning of warnings) console.log(`warning ${warning}`);
1067
+ console.log(`\nNext: edit ${path.relative(project, out)}, then npm run build (it names any store import a server part cannot use) and npm run dev.`);
1068
+ return;
1069
+ }
805
1070
  const route = s.components[0] || name;
806
1071
  const text =
807
1072
  `import type { ReactNode } from "react";\n` +
@@ -822,36 +1087,41 @@ function ejectServerSlot(project, s, force) {
822
1087
  fs.writeFileSync(out, text);
823
1088
  console.log(`ejected ${name} (server slot "${s.name}") -> ${path.relative(project, out)}`);
824
1089
 
1090
+ registerServerSlot(project, s, name);
1091
+ console.log(`\nNext: edit ${path.relative(project, out)}, then npm run build and npm run dev and open the page.`);
1092
+ }
1093
+
1094
+ // src/server.tsx: import { <Page> as Ejected<Page> } from "./server/pages/<Page>.js" and serverSlots: { "<slot>": … }
1095
+ function registerServerSlot(project, s, name) {
825
1096
  const server = path.join(project, "src", "server.tsx");
826
1097
  const local = `Ejected${name}`;
827
1098
  const importLine = `import { ${name} as ${local} } from "./server/pages/${name}.js";`;
828
1099
  const entry = `"${s.name}": ${local},`;
829
1100
  if (!fs.existsSync(server)) {
830
1101
  console.log(`warning src/server.tsx not found: register it yourself: ${importLine} and serverSlots: { ${entry} }`);
831
- } else {
832
- let source = fs.readFileSync(server, "utf8");
833
- if (new RegExp(`["']${s.name.replace(/\./g, "\\.")}["']\\s*:`).test(source)) {
834
- console.log(`warning src/server.tsx already fills "${s.name}": point it at ${local} (${importLine}) yourself`);
835
- } else {
836
- if (/serverSlots\s*:\s*\{/.test(source)) source = source.replace(/serverSlots\s*:\s*\{/, `serverSlots: {\n // ejected (znode eject ${name}): the store renders this around / instead of its page\n ${entry}`);
837
- else if (/apiVersion\s*:\s*\d+\s*,/.test(source)) source = source.replace(/(apiVersion\s*:\s*\d+\s*,)/, `$1\n serverSlots: {\n ${entry}\n },`);
838
- else {
839
- console.log(`warning no "serverSlots" in src/server.tsx: add ${importLine} and serverSlots: { ${entry} }`);
840
- source = null;
841
- }
842
- if (source) {
843
- const imports = [...source.matchAll(/^import [^;]+;[ \t]*$/gm)];
844
- const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
845
- source = source.slice(0, at) + `\n${importLine}` + source.slice(at);
846
- fs.writeFileSync(server, source);
847
- console.log(`registered "${s.name}": ${local} in src/server.tsx (serverSlots)`);
848
- }
849
- }
1102
+ return;
850
1103
  }
851
- console.log(`\nNext: edit ${path.relative(project, out)}, then npm run build and npm run dev and open the page.`);
1104
+ let source = fs.readFileSync(server, "utf8");
1105
+ if (new RegExp(`["']${s.name.replace(/\./g, "\\.")}["']\\s*:`).test(source)) {
1106
+ console.log(`warning src/server.tsx already fills "${s.name}": point it at ${local} (${importLine}) yourself`);
1107
+ return;
1108
+ }
1109
+ if (/serverSlots\s*:\s*\{/.test(source)) source = source.replace(/serverSlots\s*:\s*\{/, `serverSlots: {\n // ejected (znode eject ${name}): the store renders this around / instead of its page\n ${entry}`);
1110
+ else if (/apiVersion\s*:\s*\d+\s*,/.test(source)) source = source.replace(/(apiVersion\s*:\s*\d+\s*,)/, `$1\n serverSlots: {\n ${entry}\n },`);
1111
+ else {
1112
+ console.log(`warning no "serverSlots" in src/server.tsx: add ${importLine} and serverSlots: { ${entry} }`);
1113
+ return;
1114
+ }
1115
+ if (!source.includes(importLine)) {
1116
+ const imports = [...source.matchAll(/^import [^;]+;[ \t]*$/gm)];
1117
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
1118
+ source = source.slice(0, at) + `\n${importLine}` + source.slice(at);
1119
+ }
1120
+ fs.writeFileSync(server, source);
1121
+ console.log(`registered "${s.name}": ${local} in src/server.tsx (serverSlots)`);
852
1122
  }
853
1123
 
854
- async function eject(name, force, { slot: slotArg, yes } = {}) {
1124
+ async function eject(name, force, { slot: slotArg, yes, noInstall } = {}) {
855
1125
  const project = path.resolve(".");
856
1126
  if (!fs.existsSync(path.join(project, MANIFEST))) fail(`run znode eject in a package folder (the one with ${MANIFEST})`);
857
1127
  const store = linkedStore(project);
@@ -862,20 +1132,22 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
862
1132
  for (const c of components) console.log(` ${c.name.padEnd(34)} ${c.slot.padEnd(26)} ${path.relative(store.root, c.file).split(path.sep).join("/")}`);
863
1133
  const pages = storeSlots(store.root).filter((s) => s.page);
864
1134
  if (pages.length) {
865
- console.log("\nStore pages (server slot around the page's content; you get a server starter in src/server/pages/):\n");
866
- for (const s of pages) console.log(` ${s.page.padEnd(34)} ${s.name.padEnd(26)} ${s.files[0]}`);
1135
+ console.log("\nStore pages (server slot: a whole route is copied, a slot inside a page gives a starter; src/server/pages/):\n");
1136
+ for (const s of pages) console.log(` ${s.page.padEnd(34)} ${s.name.padEnd(26)} ${s.route ? "whole route, copy " : "inside the page "} ${s.files[0]}`);
867
1137
  }
868
- console.log("\nznode eject <Name> copies one into src/components/ (or a page starter into src/server/pages/) and registers it");
1138
+ console.log("\nznode eject <Name> copies one into src/components/ (a page into src/server/pages/) and registers it");
869
1139
  return;
870
1140
  }
871
1141
  const find = (list) => list.find((item) => item.name.toLowerCase() === name.toLowerCase() || item.slot.toLowerCase() === name.toLowerCase());
872
- // a route page with a server slot around its content: the package gets a server starter for it (src/server.tsx)
873
- const findServer = () =>
874
- storeSlots(store.root).find((s) => s.page && [s.page.toLowerCase(), s.name.toLowerCase()].includes(name.toLowerCase()));
1142
+ // a route page with a server slot: around the whole route (a copy of the route), else inside it (a starter)
1143
+ const findServer = () => {
1144
+ const pages = storeSlots(store.root).filter((s) => s.page && [s.page.toLowerCase(), s.name.toLowerCase()].includes(name.toLowerCase()));
1145
+ return pages.find((s) => s.route) || pages[0];
1146
+ };
875
1147
  let c = find(components);
876
1148
  if (!c) {
877
1149
  const server = findServer();
878
- if (server) return ejectServerSlot(project, server, force);
1150
+ if (server) return ejectServerSlot(project, server, force, store, noInstall);
879
1151
  }
880
1152
  if (!c) {
881
1153
  // no slot hands this component over yet: offer to create one in the base store (what znode create-slot does), then eject
@@ -907,7 +1179,7 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
907
1179
  const server = findServer();
908
1180
  if (server) {
909
1181
  console.log("");
910
- return ejectServerSlot(project, server, force);
1182
+ return ejectServerSlot(project, server, force, store, noInstall);
911
1183
  }
912
1184
  fail(`the slot "${slot}" was created, but ${name} is still not found as ejectable; check the store diff`);
913
1185
  }
@@ -919,15 +1191,15 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
919
1191
  const warnings = [];
920
1192
  let text = fs.readFileSync(c.file, "utf8");
921
1193
  // relative imports become the store's aliases (the package lives elsewhere); a style file stays the store's
922
- text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after) => {
1194
+ text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after, offset, source) => {
923
1195
  const absolute = path.resolve(path.dirname(c.file), spec);
924
1196
  if (/\.(s?css)$/.test(spec)) {
925
1197
  warnings.push(`style import ${spec}: kept out of the copy; the store's own stylesheet is loaded where the store renders it, add your CSS to src/styles/`);
926
1198
  return `${before}${spec}${after}`;
927
1199
  }
928
- const alias = storeAlias(store, fs.existsSync(absolute) || /\.\w+$/.test(spec) ? absolute : absolute);
1200
+ const alias = storeAlias(store, absolute, { statement: source.slice(source.lastIndexOf("import", offset), offset) });
929
1201
  if (!alias) {
930
- warnings.push(`import ${spec}: no store alias for it; change it by hand`);
1202
+ warnings.push(`import ${spec}: no store alias for it (nor a store barrel that re-exports its names); change it by hand`);
931
1203
  return whole;
932
1204
  }
933
1205
  return `${before}${alias}${after}`;
@@ -946,32 +1218,11 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
946
1218
  fs.writeFileSync(out, text);
947
1219
  console.log(`ejected ${c.name} -> ${path.relative(project, out)}`);
948
1220
 
949
- // npm packages the copy imports that this package does not have (react, next and the store's @znode modules come from
950
- // the store): install them at the store's version, so the types match and the build finds them
951
- const PROVIDED = /^(react|react-dom|next|next-intl|@znode)(\/|$)/;
952
- const storePackage = (() => {
953
- try {
954
- return readJson(path.join(store.root, "package.json"));
955
- } catch {
956
- return {};
957
- }
958
- })();
959
- const storeDeps = { ...(storePackage.devDependencies || {}), ...(storePackage.dependencies || {}) };
960
- const missing = new Map();
961
- for (const m of text.matchAll(/(?:from|import)\s*\(?\s*["']([^."'/][^"']*)["']/g)) {
962
- const spec = m[1];
963
- if (PROVIDED.test(spec)) continue;
964
- const pkg = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
965
- try {
966
- createRequire(path.join(project, "package.json")).resolve(`${pkg}/package.json`);
967
- } catch {
968
- missing.set(pkg, storeDeps[pkg] ? `${pkg}@${storeDeps[pkg].replace(/^[\^~]/, "")}` : pkg);
969
- }
970
- }
971
- if (missing.size) {
972
- console.log(`\nThe copy imports npm packages this package does not have (bundled into the package when it builds):`);
973
- console.log(` npm install ${[...missing.values()].join(" ")}`);
974
- }
1221
+ // npm packages the copy imports: the store's shared ones are used from the store (types through link-store), the others
1222
+ // are installed in this package at the store's version (bundled into the package when it builds)
1223
+ const deps = ensureStorePackages(project, store, npmImportsOf(text), { install: !noInstall });
1224
+ if (deps.installed.length) console.log(`installed (store versions): ${deps.installed.join(", ")}`);
1225
+ if (deps.shared.length) console.log(`shared by the store (not installed; types from the linked store): ${deps.shared.join(", ")}`);
975
1226
 
976
1227
  // register the copy under the slot in src/browser.tsx
977
1228
  const browser = path.join(project, "src", "browser.tsx");
@@ -1064,53 +1315,30 @@ function openTagEnd(text, start) {
1064
1315
  return -1;
1065
1316
  }
1066
1317
 
1067
- // A route page gets a server slot around what it returns: <RuntimeServerSlot name="…"> … </RuntimeServerSlot>, inside a
1068
- // NextIntlClientProvider at the top (so the slot's components, and the store's, keep the page's texts). A package fills
1069
- // it in src/server.tsx (serverSlots): it gets the page's content as children and the store / settings as context.
1318
+ // A route page gets a server slot around the WHOLE route: its function becomes <Name>Base and the default export is
1319
+ // withRuntimeServerPage("<slot>", <Name>Base) (packages/extensions/src/with-runtime-server-page.tsx), the server version of
1320
+ // withRuntimeWrapper. A package that fills the slot (src/server.tsx serverSlots) gets the route's props (params,
1321
+ // searchParams) and the store's page as children, and renders the page instead: `znode eject <Name>` gives it a copy of
1322
+ // the route to start from. The route's data loading, texts and metadata stay the store's when no package fills it.
1323
+ const SERVER_PAGE_WRAPPER = "@znode/extensions/with-runtime-server-page";
1070
1324
  function routeServerSlot(file, name, slot, rel) {
1071
1325
  const before = fs.readFileSync(file, "utf8");
1072
1326
  if (/^\s*["']use client["']/.test(before)) fail(`${rel(file)} is a client page ("use client"): a server slot cannot go there; add a <RuntimeSlot> by hand`);
1073
- const fnAt = before.search(new RegExp(`export\\s+default\\s+(?:async\\s+)?function\\s+${name}\\s*\\(`));
1074
- const existing = before.slice(fnAt).match(/<RuntimeServerSlot\s+name=["']([\w.]+)["']/);
1075
- if (existing) fail(`${name} (${rel(file)}) already has the server slot "${existing[1]}": fill that one (npx znode slot list)`);
1076
- const ret = before.lastIndexOf("return (");
1077
- if (ret < fnAt) fail(`${name} (${rel(file)}) does not end with return (<…>): put <RuntimeServerSlot name="${slot}"> around its content by hand`);
1078
- const open = ret + "return ".length;
1079
- const close = closingParen(before, open);
1080
- if (close < 0) fail(`${rel(file)}: could not find the end of ${name}'s return (…): put <RuntimeServerSlot name="${slot}"> around its content by hand`);
1081
- let start = open + 1;
1082
- let end = close;
1083
- const providerAt = before.slice(start, end).search(/^\s*<NextIntlClientProvider\b/);
1084
- if (providerAt === 0) {
1085
- const tagStart = before.indexOf("<NextIntlClientProvider", start);
1086
- const tagClose = before.lastIndexOf("</NextIntlClientProvider>", close);
1087
- const tagEnd = openTagEnd(before, tagStart);
1088
- if (tagEnd > 0 && tagClose > tagEnd && before[tagEnd - 1] !== "/") {
1089
- start = tagEnd + 1;
1090
- end = tagClose;
1091
- }
1092
- }
1093
- const inner = before.slice(start, end);
1094
- const lead = inner.match(/^\s*/)[0];
1095
- const trail = inner.match(/\s*$/)[0];
1096
- const core = inner.slice(lead.length, inner.length - trail.length);
1097
- const indent = lead.includes("\n") ? lead.slice(lead.lastIndexOf("\n") + 1) : "";
1098
- const body = core
1099
- .split("\n")
1100
- .map((line, i) => (i === 0 || !line.trim() ? line : ` ${line}`))
1101
- .join("\n");
1102
- const wrapped = `${lead}<RuntimeServerSlot name="${slot}">\n${indent} ${body}\n${indent}</RuntimeServerSlot>${trail}`;
1103
- let after = before.slice(0, start) + wrapped + before.slice(end);
1104
- // a note above the return, and the import
1105
- // (the text up to the return is unchanged, so its position still holds)
1106
- const lineStart = before.lastIndexOf("\n", ret) + 1;
1107
- const retIndent = before.slice(lineStart, ret);
1108
- after = after.slice(0, lineStart) + `${retIndent}// runtime packages: server components around the page's content (server slot ${slot}, znode create-slot)\n` + after.slice(lineStart);
1109
- if (!/import\s*\{[^}]*\bRuntimeServerSlot\b/.test(after)) {
1110
- const imports = [...after.matchAll(/^import [^;]+;[ \t]*$/gm)];
1111
- const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
1112
- after = after.slice(0, at) + `${at ? "\n" : ""}import { RuntimeServerSlot } from "@znode/extensions/RuntimeServerSlot";${at ? "" : "\n"}` + after.slice(at);
1113
- }
1327
+ const wrapped = before.match(/withRuntimeServerPage\(\s*["']([\w.]+)["']/);
1328
+ if (wrapped) fail(`${name} (${rel(file)}) already has the server slot "${wrapped[1]}" around the whole route: fill or eject that one (npx znode eject ${name})`);
1329
+ const definition = new RegExp(`export\\s+default\\s+(async\\s+)?function\\s+${name}\\s*\\(`);
1330
+ const match = before.match(definition);
1331
+ if (!match) fail(`${rel(file)}: no "export default function ${name}(" to wrap`);
1332
+ const baseName = `${name}Base`;
1333
+ if (new RegExp(`\\b${baseName}\\b`).test(before)) fail(`${rel(file)} already has a ${baseName}: wrap it by hand`);
1334
+ let after = before.replace(definition, `${match[1] || ""}function ${baseName}(`);
1335
+ after =
1336
+ after.trimEnd() +
1337
+ `\n\n// runtime packages render the whole page instead through the server slot "${slot}" (znode create-slot; src/server.tsx\n` +
1338
+ `// serverSlots); with none, it renders as it is\nexport default withRuntimeServerPage("${slot}", ${baseName});\n`;
1339
+ const imports = [...after.matchAll(/^import [^;]+;[ \t]*$/gm)];
1340
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
1341
+ after = after.slice(0, at) + `${at ? "\n" : ""}import { withRuntimeServerPage } from "${SERVER_PAGE_WRAPPER}";${at ? "" : "\n"}` + after.slice(at);
1114
1342
  return { file, before, after };
1115
1343
  }
1116
1344
 
@@ -1234,7 +1462,7 @@ function createSlot(name, slot, { dryRun, file: chosen, fromEject } = {}) {
1234
1462
  end,
1235
1463
  0,
1236
1464
  isRoute
1237
- ? `// ${slot.padEnd(20)} (server slot, src/server.tsx serverSlots) the ${where} route's content (children) (znode create-slot)`
1465
+ ? `// ${slot.padEnd(20)} (server slot, src/server.tsx serverSlots) the whole ${where} route (withRuntimeServerPage) props: params, searchParams; children = the store's page (znode create-slot)`
1238
1466
  : `// ${slot.padEnd(20)} the store's ${name} itself (children) props: every prop ${name} gets (znode create-slot)`
1239
1467
  );
1240
1468
  changes.push({ file: typesFile, before, after: lines.join("\n") });
@@ -1252,7 +1480,7 @@ function createSlot(name, slot, { dryRun, file: chosen, fromEject } = {}) {
1252
1480
  row + 1,
1253
1481
  0,
1254
1482
  isRoute
1255
- ? `| \`${slot}\` (server slot) | none: \`<RuntimeServerSlot>\` written directly in \`${rel(target)}\` (znode create-slot) | the ${where} route's content (\`${name}\`) | none (context: store, settings); keep \`children\`, wrap it, or render your own page (\`znode eject ${name}\`) |`
1483
+ ? `| \`${slot}\` (server slot) | \`withRuntimeServerPage\` (\`with-runtime-server-page.tsx\`) on the route's default export in \`${rel(target)}\` (znode create-slot) | the whole ${where} route (\`${name}\`, its children: the store's page) | the route's props (params, searchParams); context: store, settings; keep \`children\`, wrap it, or render a copy of the page (\`znode eject ${name}\`, permission storeServer) |`
1256
1484
  : `| \`${slot}\` | \`${wrapper}\` (\`${CREATED_SLOTS_FILE.replace("packages/extensions/src/", "")}\`) | \`${where}\` (its children: the component itself) | every ${name} prop; keep \`children\`, wrap it, or render a copy instead (\`znode eject ${name}\`) |`
1257
1485
  );
1258
1486
  changes.push({ file: docsFile, before, after: lines.join("\n") });
@@ -1350,6 +1578,16 @@ function storeSlots(root) {
1350
1578
  files: [relFile],
1351
1579
  });
1352
1580
  }
1581
+ // a whole route with its server slot around it: export default withRuntimeServerPage("<slot>", <Page>Base) in a route
1582
+ // file; `znode eject <Page>` copies the route into the package
1583
+ const relFile = path.relative(root, file).split(path.sep).join("/");
1584
+ if (/^apps\/[^/]+\/src\/app\//.test(relFile)) {
1585
+ for (const m of text.matchAll(/export\s+default\s+withRuntimeServerPage\(\s*["']([\w.]+)["']\s*,\s*(\w+)\s*\)/g)) {
1586
+ const page = m[2].replace(/Base$/, "");
1587
+ const where = "app/" + relFile.replace(/^apps\/[^/]+\/src\/app\/(\[locale\]\/)?/, "").replace(/\.tsx$/, "");
1588
+ slots.set(m[1], { name: m[1], side: "server", handsOver: true, wrapper: null, components: [`${page} (${where}, whole route)`], page, route: true, base: m[2], eject: page, files: [relFile] });
1589
+ }
1590
+ }
1353
1591
  }
1354
1592
  return [...slots.values()].sort((a, b) => a.name.localeCompare(b.name));
1355
1593
  }
@@ -1426,7 +1664,11 @@ if (command === "eject") {
1426
1664
  // eject <Name> [--force] [--slot <area.name>] [--yes]: --slot / --yes create a missing slot without asking
1427
1665
  const slotAt = args.indexOf("--slot");
1428
1666
  const name = args.find((arg, index) => index > 0 && !arg.startsWith("-") && !(slotAt >= 0 && index === slotAt + 1));
1429
- await eject(name, args.includes("--force"), { slot: slotAt >= 0 ? args[slotAt + 1] : undefined, yes: args.includes("--yes") || args.includes("-y") });
1667
+ await eject(name, args.includes("--force"), {
1668
+ slot: slotAt >= 0 ? args[slotAt + 1] : undefined,
1669
+ yes: args.includes("--yes") || args.includes("-y"),
1670
+ noInstall: args.includes("--no-install"),
1671
+ });
1430
1672
  process.exit(0);
1431
1673
  }
1432
1674
  if (command === "link-store") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "znode-plugin",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Build, check, pack and deploy Znode runtime plugins and themes: the SDK behind every create-znode-plugin project.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",