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 +31 -20
- package/lib/next-imports.mjs +21 -9
- package/lib/znode-package.mjs +173 -81
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
301
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
319
|
+
<NextIntlClientProvider messages={{ ...userPasswordMessages, ...commonMessages }}>
|
|
314
320
|
<h1>Reset your password</h1>
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
</div>
|
|
321
|
+
<ForgotPassword />
|
|
322
|
+
</NextIntlClientProvider>
|
|
318
323
|
);
|
|
319
324
|
}
|
|
320
325
|
```
|
|
321
326
|
|
|
322
|
-
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
-
|
|
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
|
package/lib/next-imports.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
335
|
-
this.error(
|
|
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 =
|
|
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 };
|
package/lib/znode-package.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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/ (
|
|
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
|
|
873
|
-
const findServer = () =>
|
|
874
|
-
storeSlots(store.root).
|
|
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
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
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
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
const
|
|
1077
|
-
if (
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
|
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) |
|
|
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