znode-plugin 0.1.8 → 0.1.9

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,8 +41,9 @@
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";
@@ -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,6 +232,8 @@ 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);
230
238
  // A package with its own tsconfig.json (extending the store's tsconfig.base.json) is checked against the store's real
231
239
  // types (every @znode alias, no declarations to write), then compiled file by file without type information, JSX as
@@ -282,6 +290,7 @@ function build(target) {
282
290
  `const storeImports = [];`,
283
291
  `const store = ${JSON.stringify(store)};`,
284
292
  `if (store?.registry) store.registry = new Set(store.registry);`,
293
+ `if (store?.serverRegistry) store.serverRegistry = new Set(store.serverRegistry);`,
285
294
  `export default {`,
286
295
  ` input: ${JSON.stringify(compiled)},`,
287
296
  ` output: { file: ${JSON.stringify(path.join(out, target))}, format: "es", sourcemap: true },`,
@@ -294,7 +303,8 @@ function build(target) {
294
303
  );
295
304
  execFileSync(process.execPath, [ROLLUP(), "--config", config, "--silent"], { stdio: "inherit" });
296
305
  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")));
306
+ // the server part's store imports (server registry) are loaded by the store's server, the browser part's by the browser
307
+ if (fs.existsSync(storeImportsFile)) (part === "server" ? serverStoreImports : storeImports).push(...JSON.parse(fs.readFileSync(storeImportsFile, "utf8")));
298
308
  files[part] = target;
299
309
  }
300
310
  fs.rmSync(tmp, { recursive: true, force: true });
@@ -336,7 +346,19 @@ function build(target) {
336
346
  );
337
347
  }
338
348
  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");
349
+ fs.writeFileSync(
350
+ path.join(out, MANIFEST),
351
+ JSON.stringify(
352
+ {
353
+ ...rest,
354
+ files,
355
+ ...(storeImports.length ? { storeImports: [...new Set(storeImports)].sort() } : {}),
356
+ ...(serverStoreImports.length ? { serverStoreImports: [...new Set(serverStoreImports)].sort() } : {}),
357
+ },
358
+ null,
359
+ 2
360
+ ) + "\n"
361
+ );
340
362
  console.log(`built ${manifest.kind} ${manifest.name}@${manifest.version}: ${fs.readdirSync(out).length} files`);
341
363
  return { manifest, out };
342
364
  }
@@ -487,6 +509,8 @@ function install(source, storeDir) {
487
509
  ...(manifest.widgets?.length ? { widgets: manifest.widgets } : {}),
488
510
  // the store modules its browser bundle imports: the store loads them first (Generated Store Module Registry)
489
511
  ...(Array.isArray(manifest.storeImports) && manifest.storeImports.length ? { storeImports: manifest.storeImports.map(String) } : {}),
512
+ // and the store modules its server bundle imports (server registry): the store's server loads them first
513
+ ...(Array.isArray(manifest.serverStoreImports) && manifest.serverStoreImports.length ? { serverStoreImports: manifest.serverStoreImports.map(String) } : {}),
490
514
  ...(manifest.overrides ? { overrides: manifest.overrides } : {}),
491
515
  ...(manifest.serverPages?.length ? { serverPages: manifest.serverPages } : {}),
492
516
  ...(manifest.sandbox === true ? { sandbox: true } : {}),
@@ -593,6 +617,8 @@ function check(target) {
593
617
  if (/\bznode\s*\(/.test(server)) used.add("znodeApi");
594
618
  if (/\bfetch\s*\(|\bhttps?\.request\s*\(/.test(server)) used.add("externalNetwork");
595
619
  if (/\.files\b/.test(server)) used.add("uploads");
620
+ if (/^\s*import\s+(?!type\b)[^;]*?from\s*["']@znode\/(?!types\/)/m.test(server)) used.add("storeServer");
621
+ 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
622
  const declared = new Set(manifest.permissions || []);
597
623
  for (const item of used) if (!declared.has(item)) errors.push(`uses "${item}" without declaring it in "permissions"`);
598
624
  for (const item of declared) if (!used.has(item)) warnings.push(`declares "${item}" but no code uses it (the review asks why)`);
@@ -798,10 +824,82 @@ const suggestSlot = (name) => {
798
824
  // eject a store page (a server slot around a route's content): not a copy of the route (its server code, agents and
799
825
  // data stay the store's), a server component of the package's own in src/server/pages/<Page>.tsx, registered under the
800
826
  // slot in src/server.tsx, that renders the store's page content (children) to start with
801
- function ejectServerSlot(project, s, force) {
827
+ // A whole route (withRuntimeServerPage): a COPY of the route instead, as component eject copies a component: the route's
828
+ // file with its function as `export async function <Page>(props, context)` (props: the route's params and searchParams,
829
+ // children: the store's page), its imports as the store's aliases, which a server part imports from the store's server
830
+ // registry (permission storeServer, added to znode-package.json).
831
+ function ejectRouteCopy(project, s, store) {
832
+ const name = s.page;
833
+ const storeFile = path.join(store.root, s.files[0]);
834
+ let text = fs.readFileSync(storeFile, "utf8");
835
+ // the copy is the plain page: no wrapper (the slot is what puts it in place)
836
+ text = text.replace(/^import\s*\{\s*withRuntimeServerPage\s*\}\s*from\s*["'][^"']+["'];?[ \t]*\r?\n/m, "");
837
+ text = text.replace(/(?:^\/\/ runtime packages render the whole page instead[^\n]*\n(?:\/\/[^\n]*\n)*)?^export\s+default\s+withRuntimeServerPage\([^)]*\);?[ \t]*\r?\n?/m, "");
838
+ text = text.replace(new RegExp(`^(async\\s+)?function\\s+${s.base}\\s*\\(`, "m"), (whole, isAsync) => `export ${isAsync || ""}function ${name}(`);
839
+ // relative imports become the store's aliases (the package lives elsewhere)
840
+ const warnings = [];
841
+ text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after) => {
842
+ const alias = storeAlias(store, path.resolve(path.dirname(storeFile), spec));
843
+ if (!alias) {
844
+ warnings.push(`import ${spec}: no store alias for it; change it by hand`);
845
+ return whole;
846
+ }
847
+ return `${before}${alias}${after}`;
848
+ });
849
+ const header =
850
+ `// Ejected from the store: ${s.files[0]} (${new Date().toISOString().slice(0, 10)}), with \`znode eject ${name}\`: a copy of the\n` +
851
+ `// whole route. The store renders it instead of its own page through the server slot "${s.name}" (src/server.tsx\n` +
852
+ `// serverSlots), on the server only. props: the route's params and searchParams, and children (the store's page);\n` +
853
+ `// context (second argument): store, settings. Its @znode imports come from the store's server registry (permission\n` +
854
+ `// storeServer): agents, texts and store context, the server logger, client components. It is yours now: the store's\n` +
855
+ `// later changes to this page do not reach it, so compare it with ${s.files[0]} after a store upgrade.\n`;
856
+ text = header + text;
857
+
858
+ // what the store's server registry does not allow: named now, the build stops on it too
859
+ if (store.serverRegistry) {
860
+ const allowed = new Set(store.serverRegistry);
861
+ for (const m of text.matchAll(/^\s*import\s+(?!type\b)[^;]*?from\s*["'](@znode\/[^"']+)["']/gm)) {
862
+ if (m[1].startsWith("@znode/types/")) continue;
863
+ const file = (() => {
864
+ for (const [alias, [target]] of Object.entries(store.paths)) {
865
+ if (alias.endsWith("/*") && m[1].startsWith(alias.slice(0, -1))) return target.replace("*", m[1].slice(alias.length - 1));
866
+ if (alias === m[1]) return target;
867
+ }
868
+ return null;
869
+ })();
870
+ const key = file && file.replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
871
+ // a barrel (utils/server's index, a component folder) is checked name by name at build time, each name from the file
872
+ // that defines it; only a module that is not a barrel and not allowed is named here
873
+ const barrel = /\/index\.tsx?$/.test(file || "") || [...allowed].some((allowedKey) => allowedKey.startsWith(`${key}/`));
874
+ 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)`);
875
+ }
876
+ } 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");
877
+
878
+ // the permission it needs
879
+ const manifestFile = path.join(project, MANIFEST);
880
+ const manifest = readJson(manifestFile);
881
+ if (!(manifest.permissions || []).includes("storeServer")) {
882
+ manifest.permissions = [...(manifest.permissions || []), "storeServer"];
883
+ fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + "\n");
884
+ console.log(`added "storeServer" to "permissions" in ${MANIFEST} (its server part imports store modules)`);
885
+ }
886
+ return { text, warnings };
887
+ }
888
+
889
+ function ejectServerSlot(project, s, force, store) {
802
890
  const name = s.page;
803
891
  const out = path.join(project, "src", "server", "pages", `${name}.tsx`);
804
892
  if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${name} --force`);
893
+ if (s.route) {
894
+ const { text, warnings } = ejectRouteCopy(project, s, store);
895
+ fs.mkdirSync(path.dirname(out), { recursive: true });
896
+ fs.writeFileSync(out, text);
897
+ console.log(`ejected ${name} (a copy of ${s.files[0]}, server slot "${s.name}") -> ${path.relative(project, out)}`);
898
+ registerServerSlot(project, s, name);
899
+ for (const warning of warnings) console.log(`warning ${warning}`);
900
+ 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.`);
901
+ return;
902
+ }
805
903
  const route = s.components[0] || name;
806
904
  const text =
807
905
  `import type { ReactNode } from "react";\n` +
@@ -822,33 +920,38 @@ function ejectServerSlot(project, s, force) {
822
920
  fs.writeFileSync(out, text);
823
921
  console.log(`ejected ${name} (server slot "${s.name}") -> ${path.relative(project, out)}`);
824
922
 
923
+ registerServerSlot(project, s, name);
924
+ console.log(`\nNext: edit ${path.relative(project, out)}, then npm run build and npm run dev and open the page.`);
925
+ }
926
+
927
+ // src/server.tsx: import { <Page> as Ejected<Page> } from "./server/pages/<Page>.js" and serverSlots: { "<slot>": … }
928
+ function registerServerSlot(project, s, name) {
825
929
  const server = path.join(project, "src", "server.tsx");
826
930
  const local = `Ejected${name}`;
827
931
  const importLine = `import { ${name} as ${local} } from "./server/pages/${name}.js";`;
828
932
  const entry = `"${s.name}": ${local},`;
829
933
  if (!fs.existsSync(server)) {
830
934
  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
- }
935
+ return;
850
936
  }
851
- console.log(`\nNext: edit ${path.relative(project, out)}, then npm run build and npm run dev and open the page.`);
937
+ let source = fs.readFileSync(server, "utf8");
938
+ if (new RegExp(`["']${s.name.replace(/\./g, "\\.")}["']\\s*:`).test(source)) {
939
+ console.log(`warning src/server.tsx already fills "${s.name}": point it at ${local} (${importLine}) yourself`);
940
+ return;
941
+ }
942
+ 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}`);
943
+ else if (/apiVersion\s*:\s*\d+\s*,/.test(source)) source = source.replace(/(apiVersion\s*:\s*\d+\s*,)/, `$1\n serverSlots: {\n ${entry}\n },`);
944
+ else {
945
+ console.log(`warning no "serverSlots" in src/server.tsx: add ${importLine} and serverSlots: { ${entry} }`);
946
+ return;
947
+ }
948
+ if (!source.includes(importLine)) {
949
+ const imports = [...source.matchAll(/^import [^;]+;[ \t]*$/gm)];
950
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
951
+ source = source.slice(0, at) + `\n${importLine}` + source.slice(at);
952
+ }
953
+ fs.writeFileSync(server, source);
954
+ console.log(`registered "${s.name}": ${local} in src/server.tsx (serverSlots)`);
852
955
  }
853
956
 
854
957
  async function eject(name, force, { slot: slotArg, yes } = {}) {
@@ -862,20 +965,22 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
862
965
  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
966
  const pages = storeSlots(store.root).filter((s) => s.page);
864
967
  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]}`);
968
+ console.log("\nStore pages (server slot: a whole route is copied, a slot inside a page gives a starter; src/server/pages/):\n");
969
+ 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
970
  }
868
- console.log("\nznode eject <Name> copies one into src/components/ (or a page starter into src/server/pages/) and registers it");
971
+ console.log("\nznode eject <Name> copies one into src/components/ (a page into src/server/pages/) and registers it");
869
972
  return;
870
973
  }
871
974
  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()));
975
+ // a route page with a server slot: around the whole route (a copy of the route), else inside it (a starter)
976
+ const findServer = () => {
977
+ const pages = storeSlots(store.root).filter((s) => s.page && [s.page.toLowerCase(), s.name.toLowerCase()].includes(name.toLowerCase()));
978
+ return pages.find((s) => s.route) || pages[0];
979
+ };
875
980
  let c = find(components);
876
981
  if (!c) {
877
982
  const server = findServer();
878
- if (server) return ejectServerSlot(project, server, force);
983
+ if (server) return ejectServerSlot(project, server, force, store);
879
984
  }
880
985
  if (!c) {
881
986
  // no slot hands this component over yet: offer to create one in the base store (what znode create-slot does), then eject
@@ -907,7 +1012,7 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
907
1012
  const server = findServer();
908
1013
  if (server) {
909
1014
  console.log("");
910
- return ejectServerSlot(project, server, force);
1015
+ return ejectServerSlot(project, server, force, store);
911
1016
  }
912
1017
  fail(`the slot "${slot}" was created, but ${name} is still not found as ejectable; check the store diff`);
913
1018
  }
@@ -1064,53 +1169,30 @@ function openTagEnd(text, start) {
1064
1169
  return -1;
1065
1170
  }
1066
1171
 
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.
1172
+ // A route page gets a server slot around the WHOLE route: its function becomes <Name>Base and the default export is
1173
+ // withRuntimeServerPage("<slot>", <Name>Base) (packages/extensions/src/with-runtime-server-page.tsx), the server version of
1174
+ // withRuntimeWrapper. A package that fills the slot (src/server.tsx serverSlots) gets the route's props (params,
1175
+ // searchParams) and the store's page as children, and renders the page instead: `znode eject <Name>` gives it a copy of
1176
+ // the route to start from. The route's data loading, texts and metadata stay the store's when no package fills it.
1177
+ const SERVER_PAGE_WRAPPER = "@znode/extensions/with-runtime-server-page";
1070
1178
  function routeServerSlot(file, name, slot, rel) {
1071
1179
  const before = fs.readFileSync(file, "utf8");
1072
1180
  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
- }
1181
+ const wrapped = before.match(/withRuntimeServerPage\(\s*["']([\w.]+)["']/);
1182
+ 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})`);
1183
+ const definition = new RegExp(`export\\s+default\\s+(async\\s+)?function\\s+${name}\\s*\\(`);
1184
+ const match = before.match(definition);
1185
+ if (!match) fail(`${rel(file)}: no "export default function ${name}(" to wrap`);
1186
+ const baseName = `${name}Base`;
1187
+ if (new RegExp(`\\b${baseName}\\b`).test(before)) fail(`${rel(file)} already has a ${baseName}: wrap it by hand`);
1188
+ let after = before.replace(definition, `${match[1] || ""}function ${baseName}(`);
1189
+ after =
1190
+ after.trimEnd() +
1191
+ `\n\n// runtime packages render the whole page instead through the server slot "${slot}" (znode create-slot; src/server.tsx\n` +
1192
+ `// serverSlots); with none, it renders as it is\nexport default withRuntimeServerPage("${slot}", ${baseName});\n`;
1193
+ const imports = [...after.matchAll(/^import [^;]+;[ \t]*$/gm)];
1194
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
1195
+ after = after.slice(0, at) + `${at ? "\n" : ""}import { withRuntimeServerPage } from "${SERVER_PAGE_WRAPPER}";${at ? "" : "\n"}` + after.slice(at);
1114
1196
  return { file, before, after };
1115
1197
  }
1116
1198
 
@@ -1234,7 +1316,7 @@ function createSlot(name, slot, { dryRun, file: chosen, fromEject } = {}) {
1234
1316
  end,
1235
1317
  0,
1236
1318
  isRoute
1237
- ? `// ${slot.padEnd(20)} (server slot, src/server.tsx serverSlots) the ${where} route's content (children) (znode create-slot)`
1319
+ ? `// ${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
1320
  : `// ${slot.padEnd(20)} the store's ${name} itself (children) props: every prop ${name} gets (znode create-slot)`
1239
1321
  );
1240
1322
  changes.push({ file: typesFile, before, after: lines.join("\n") });
@@ -1252,7 +1334,7 @@ function createSlot(name, slot, { dryRun, file: chosen, fromEject } = {}) {
1252
1334
  row + 1,
1253
1335
  0,
1254
1336
  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}\`) |`
1337
+ ? `| \`${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
1338
  : `| \`${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
1339
  );
1258
1340
  changes.push({ file: docsFile, before, after: lines.join("\n") });
@@ -1350,6 +1432,16 @@ function storeSlots(root) {
1350
1432
  files: [relFile],
1351
1433
  });
1352
1434
  }
1435
+ // a whole route with its server slot around it: export default withRuntimeServerPage("<slot>", <Page>Base) in a route
1436
+ // file; `znode eject <Page>` copies the route into the package
1437
+ const relFile = path.relative(root, file).split(path.sep).join("/");
1438
+ if (/^apps\/[^/]+\/src\/app\//.test(relFile)) {
1439
+ for (const m of text.matchAll(/export\s+default\s+withRuntimeServerPage\(\s*["']([\w.]+)["']\s*,\s*(\w+)\s*\)/g)) {
1440
+ const page = m[2].replace(/Base$/, "");
1441
+ const where = "app/" + relFile.replace(/^apps\/[^/]+\/src\/app\/(\[locale\]\/)?/, "").replace(/\.tsx$/, "");
1442
+ 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] });
1443
+ }
1444
+ }
1353
1445
  }
1354
1446
  return [...slots.values()].sort((a, b) => a.name.localeCompare(b.name));
1355
1447
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "znode-plugin",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",