znode-plugin 0.1.7 → 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 +124 -5
- package/lib/next-imports.mjs +21 -9
- package/lib/znode-package.mjs +375 -30
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -217,9 +217,124 @@ export default function setup(host: IBrowserHost): IBrowserPlugin {
|
|
|
217
217
|
}
|
|
218
218
|
```
|
|
219
219
|
|
|
220
|
-
**
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
**Which slots are there?** Ask the store you are linked to; the list is read from its source, so it is always current:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
npx znode slot list # every slot
|
|
224
|
+
npx znode slots product # only the ones matching "product"
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
Slot Side Hands over Store component Eject In this package
|
|
229
|
+
------------------ ------- ---------- ---------------- ---------------- ---------------
|
|
230
|
+
product.basicInfo browser yes ProductBasicInfo ProductBasicInfo yes
|
|
231
|
+
product.card browser yes ProductCard ProductCard
|
|
232
|
+
product.layout browser no ProductBasicInfo -
|
|
233
|
+
layout.root server yes app/layout - yes
|
|
234
|
+
...
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
- **Side**: `browser` slots go in `src/browser.tsx` (`slots`), `server` slots in `src/server.tsx` (`serverSlots`).
|
|
238
|
+
- **Hands over**: `yes` = your component gets the store's rendering as `children` (keep, wrap or replace it); `no` = it
|
|
239
|
+
adds or lays out (e.g. `product.layout` moves the store's parts into your structure).
|
|
240
|
+
- **Eject**: the store component you can copy into your package (next section). **In this package**: you already fill it.
|
|
241
|
+
|
|
242
|
+
### Take over a whole store component (eject)
|
|
243
|
+
|
|
244
|
+
When a slot is not enough and you want to change the component itself (its markup, its logic), copy it into your
|
|
245
|
+
package. The store then renders your copy in its place; everything else stays the store's.
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npx znode eject ProductBasicInfo # or by its slot: npx znode eject product.basicInfo
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
It copies the store's file to `src/components/ProductBasicInfo.tsx` (imports turned into the store's `@znode/...`
|
|
252
|
+
aliases), registers it in `src/browser.tsx` under its slot, and prints any npm package the copy needs
|
|
253
|
+
(`npm install zustand@4.5.5`). Edit the copy, then `npm run build` and `npm run dev`. The copy is yours: store fixes to
|
|
254
|
+
the component no longer reach it, so compare it with the store's file after a store upgrade. `npx znode eject` with no
|
|
255
|
+
name lists what can be ejected.
|
|
256
|
+
|
|
257
|
+
**The component has no slot yet?** `eject` offers to create one in the **base store** (the store you linked), then ejects:
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
$ npx znode eject ProductDetailsPage
|
|
261
|
+
ProductDetailsPage has no slot yet, so it cannot be ejected.
|
|
262
|
+
Create a slot for ProductDetailsPage in the base store now? [y/N] y
|
|
263
|
+
Slot name (area.name) [product.detailsPage]: product.details
|
|
264
|
+
creating slot "product.details" around ProductDetailsPage (packages/base-components/src/...) ...
|
|
265
|
+
ejected ProductDetailsPage -> src/components/ProductDetailsPage.tsx
|
|
266
|
+
registered "product.details": EjectedProductDetailsPage in src/browser.tsx
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Creating a slot edits the base store's **source** (a `Runtime<Component>` wrapper in
|
|
270
|
+
`packages/extensions/src/slots/RuntimeCreatedSlots.tsx`, the component's export wrapped with `withRuntimeWrapper`, the
|
|
271
|
+
slot added to the store's slot list and docs). It never touches `.next` and commits nothing. Before the store renders
|
|
272
|
+
your copy: review the diff in the store repo (`git diff`), commit it there through the store's own flow (it changes base
|
|
273
|
+
components, so it is reviewed like any base change), and rebuild / restart the webstore.
|
|
274
|
+
|
|
275
|
+
- In a script or CI (no questions): `npx znode eject ProductDetailsPage --slot product.details`.
|
|
276
|
+
- Only the slot, no copy: `npx znode create-slot ProductDetailsPage product.details`; add `--dry-run` first to see
|
|
277
|
+
exactly which store files and lines it would change, without writing anything.
|
|
278
|
+
- A server **component** inside base-components cannot get a slot (a browser slot is filled in the browser): it refuses
|
|
279
|
+
an `async` component or one that imports `server-only` / `next/headers` / `fs`, and says to use a client component it
|
|
280
|
+
renders instead, e.g. `ProductDetails` is a server component, so take over `ProductBasicInfo` or `ProductDetailsPage`.
|
|
281
|
+
|
|
282
|
+
### Take over a whole store page
|
|
283
|
+
|
|
284
|
+
A store **page** (a route, `apps/webstore/src/app/**/page.tsx`) runs on the server, so it gets a **server slot** around
|
|
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>`):
|
|
287
|
+
|
|
288
|
+
```
|
|
289
|
+
$ npx znode eject ForgotPasswordPage
|
|
290
|
+
ForgotPasswordPage has no slot yet, so it cannot be ejected.
|
|
291
|
+
Create a slot for ForgotPasswordPage in the base store now? [y/N] y
|
|
292
|
+
Slot name (area.name) [forgotPassword.page]: forgotPassword.page
|
|
293
|
+
creating slot "forgotPassword.page" around ForgotPasswordPage (apps/webstore/src/app/[locale]/forgot-password/page.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
|
|
296
|
+
registered "forgotPassword.page": EjectedForgotPasswordPage in src/server.tsx (serverSlots)
|
|
297
|
+
```
|
|
298
|
+
|
|
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):
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
async function ForgotPasswordPageBase() { … unchanged … }
|
|
304
|
+
export default withRuntimeServerPage("forgotPassword.page", ForgotPasswordPageBase);
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
In your package you get the route itself, to change as you like:
|
|
308
|
+
|
|
309
|
+
```tsx
|
|
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");
|
|
318
|
+
return (
|
|
319
|
+
<NextIntlClientProvider messages={{ ...userPasswordMessages, ...commonMessages }}>
|
|
320
|
+
<h1>Reset your password</h1>
|
|
321
|
+
<ForgotPassword />
|
|
322
|
+
</NextIntlClientProvider>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
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.
|
|
337
|
+
- Only the slot: `npx znode create-slot ForgotPasswordPage forgotPassword.page` (`--dry-run` to preview).
|
|
223
338
|
|
|
224
339
|
### Add a page-builder widget
|
|
225
340
|
|
|
@@ -293,8 +408,8 @@ Run them with `npm run <name>` inside a project (the scripts call `znode … .`)
|
|
|
293
408
|
| | `npx znode create <theme\|plugin\|widget\|content> <name>` | a minimal starter of another kind |
|
|
294
409
|
| | `npx znode slot list [<filter>] [--json]` | every slot the linked store renders, read from its source: browser or server side, whether it hands over what the store renders there (children: keep, wrap or replace it) or only adds / lays out, the store component it is around, what `eject` can copy into it, and whether this package already fills it. `npx znode slots` is the same; a filter matches slot or component names (`npx znode slots product`) |
|
|
295
410
|
| | `npx znode eject` | list the store components a package can take over (each has a slot that hands the component over) |
|
|
296
|
-
| | `npx znode create-slot <Component> <area.name> [--dry-run] [--file <store file>]` | create a slot around a store component that has none, in the **base store's source** (the linked store, not the theme, never `.next`): a `Runtime<Component>` wrapper in `packages/extensions/src/slots/RuntimeCreatedSlots.tsx`, the component's export wrapped with `withRuntimeWrapper`, the slot added to the store's slot list and docs, the module registry regenerated. Refuses server components (`async`, server-only imports): a slot is filled in the browser. Nothing is committed: review the diff in the store, commit it there, rebuild the webstore, then `npx znode eject <Component>`. `create slot` (with a space) works too; `add-slot` (0.1.6) still works |
|
|
297
|
-
| | `npx znode eject <Name> [--force]` | `<Name>` is the component or its slot (`ProductBasicInfo` or `product.basicInfo`). Copy the store's component into `src/components/<Name>.tsx` (imports turned into store aliases, runtime wrapper removed) and register it in `src/browser.tsx` under its slot; prints any npm package the copy needs. The copy is yours: store fixes no longer reach it |
|
|
411
|
+
| | `npx znode create-slot <Component> <area.name> [--dry-run] [--file <store file>]` | create a slot around a store component that has none, in the **base store's source** (the linked store, not the theme, never `.next`): a `Runtime<Component>` wrapper in `packages/extensions/src/slots/RuntimeCreatedSlots.tsx`, the component's export wrapped with `withRuntimeWrapper`, the slot added to the store's slot list and docs, the module registry regenerated. Refuses server components inside base-components (`async`, server-only imports): a browser slot is filled in the browser. For a **route page** (`<Component>` is its `export default function`, e.g. `ForgotPasswordPage`) it puts a **server slot** (`<RuntimeServerSlot>`) around the page's content instead, filled in `src/server.tsx` (`serverSlots`). Nothing is committed: review the diff in the store, commit it there, rebuild the webstore, then `npx znode eject <Component>`. `create slot` (with a space) works too; `add-slot` (0.1.6) still works |
|
|
412
|
+
| | `npx znode eject <Name> [--force] [--slot <area.name>] [--yes]` | `<Name>` is the component or its slot (`ProductBasicInfo` or `product.basicInfo`). Copy the store's component into `src/components/<Name>.tsx` (imports turned into store aliases, runtime wrapper removed) and register it in `src/browser.tsx` under its slot; prints any npm package the copy needs. The copy is yours: store fixes no longer reach it. **No slot yet?** It asks whether to create one in the base store and for its name (suggested from the component, e.g. `ProductDetailsPage` → `product.detailsPage`), does what `create-slot` does, then ejects. `--slot <area.name>` creates it without asking (for scripts / CI); `--yes` skips the confirm only |
|
|
298
413
|
|
|
299
414
|
The plugin folder is taken from: the command's argument, else the `RUNTIME_PLUGINS_DIR` environment variable, else
|
|
300
415
|
`znode.local.json`.
|
|
@@ -372,6 +487,10 @@ folder after an upgrade; delete them by hand if you need the space.
|
|
|
372
487
|
| `installed …` but still no change | the package went to another plugin folder than the running webstore reads: compare `znode.local.json` with the webstore's `RUNTIME_PLUGINS_DIR` (`apps/webstore/.env.local` or `.env`) |
|
|
373
488
|
| `MISSING_MESSAGE` for your label | put browser labels under a namespace the page sends, e.g. `Common` |
|
|
374
489
|
| Theme shows for you but not shoppers | expected: enable it in `plugins.json` (step 7) |
|
|
490
|
+
| `<Name> cannot be ejected` | run it in a terminal and answer `y` to create the slot, or `npx znode eject <Name> --slot <area.name>`; `npx znode slot list` shows what exists |
|
|
491
|
+
| `<Name> is an async server component` | a component inside base-components cannot get a slot: eject a client component it renders instead, or the route page it is on (`npx znode eject <Name>Page`) |
|
|
492
|
+
| `no <Name> found` | use the component's name, or for a page its `export default function <Name>` (e.g. `ForgotPasswordPage`, not the URL); `npx znode slot list` shows what exists |
|
|
493
|
+
| Ejected / new slot but the store still shows its own | the slot was created in the store's source: commit it there and rebuild / restart the webstore; check `npx znode slot list` shows it with **In this package** `yes` |
|
|
375
494
|
|
|
376
495
|
---
|
|
377
496
|
|
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
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
// znode link-store <storeRepoDir> [dir] the store's real types: writes tsconfig.store.json (see README)
|
|
18
18
|
// znode eject [<Name>] [--force] in a package folder: list the store components that can be ejected, or
|
|
19
19
|
// copy one into src/components/ and register it in its slot
|
|
20
|
-
// (<Name> is the component or its slot name)
|
|
20
|
+
// (<Name> is the component or its slot name); no slot yet: it asks
|
|
21
|
+
// to create one in the base store first (--slot <area.name> / --yes)
|
|
21
22
|
// znode create-slot <Component> <area.name> [--dry-run] [--file <f>] a slot around a store component, written into
|
|
22
23
|
// the linked store's source (base developers; review and commit there)
|
|
23
24
|
// znode slot list [<filter>] [--json] every slot the linked store renders, what it hands over, what can be
|
|
@@ -40,8 +41,9 @@
|
|
|
40
41
|
// (attribute: the value comes from a Znode global attribute, "Code" for the store's, or
|
|
41
42
|
// { "code": "...", "levels": ["store", "account", "user"] }; the most specific level wins)
|
|
42
43
|
// marketplace: "license", "keywords", "homepage", "compatibility": { "webstore": ">=10.0.0" },
|
|
43
|
-
// "permissions": ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads"] (what it does beyond UI;
|
|
44
|
-
// "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)
|
|
45
47
|
// "sandbox": true (its server part runs in a separate, permission-limited Node process: routes and overrides only)
|
|
46
48
|
// }
|
|
47
49
|
import fs from "node:fs";
|
|
@@ -78,7 +80,9 @@ const MAX_BYTES = 10 * 1024 * 1024;
|
|
|
78
80
|
const SETTING_TYPES = ["string", "number", "boolean", "list", "json", "object", "array"];
|
|
79
81
|
const ATTRIBUTE_LEVELS = ["store", "account", "user"];
|
|
80
82
|
const ATTRIBUTE_CODE = /^[A-Za-z][A-Za-z0-9_]{1,99}$/;
|
|
81
|
-
|
|
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"];
|
|
82
86
|
|
|
83
87
|
const fail = (message) => {
|
|
84
88
|
console.error(`znode-package: ${message}`);
|
|
@@ -208,7 +212,10 @@ function linkedStore(dir) {
|
|
|
208
212
|
const root = path.resolve(dir, compilerOptions.baseUrl);
|
|
209
213
|
const registryFile = path.join(root, "packages/extensions/store-module-registry.json");
|
|
210
214
|
const registry = fs.existsSync(registryFile) ? readJson(registryFile).modules : null;
|
|
211
|
-
|
|
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 };
|
|
212
219
|
}
|
|
213
220
|
|
|
214
221
|
function build(target) {
|
|
@@ -225,6 +232,8 @@ function build(target) {
|
|
|
225
232
|
const fonts = [];
|
|
226
233
|
// the store modules its browser part imports (Generated Store Module Registry keys), for the store to load first
|
|
227
234
|
const storeImports = [];
|
|
235
|
+
// the store modules its server part imports (the store's server registry, permission storeServer)
|
|
236
|
+
const serverStoreImports = [];
|
|
228
237
|
const store = linkedStore(dir);
|
|
229
238
|
// A package with its own tsconfig.json (extending the store's tsconfig.base.json) is checked against the store's real
|
|
230
239
|
// types (every @znode alias, no declarations to write), then compiled file by file without type information, JSX as
|
|
@@ -281,6 +290,7 @@ function build(target) {
|
|
|
281
290
|
`const storeImports = [];`,
|
|
282
291
|
`const store = ${JSON.stringify(store)};`,
|
|
283
292
|
`if (store?.registry) store.registry = new Set(store.registry);`,
|
|
293
|
+
`if (store?.serverRegistry) store.serverRegistry = new Set(store.serverRegistry);`,
|
|
284
294
|
`export default {`,
|
|
285
295
|
` input: ${JSON.stringify(compiled)},`,
|
|
286
296
|
` output: { file: ${JSON.stringify(path.join(out, target))}, format: "es", sourcemap: true },`,
|
|
@@ -293,7 +303,8 @@ function build(target) {
|
|
|
293
303
|
);
|
|
294
304
|
execFileSync(process.execPath, [ROLLUP(), "--config", config, "--silent"], { stdio: "inherit" });
|
|
295
305
|
if (fs.existsSync(fontsFile)) fonts.push(...JSON.parse(fs.readFileSync(fontsFile, "utf8")));
|
|
296
|
-
|
|
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")));
|
|
297
308
|
files[part] = target;
|
|
298
309
|
}
|
|
299
310
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
@@ -335,7 +346,19 @@ function build(target) {
|
|
|
335
346
|
);
|
|
336
347
|
}
|
|
337
348
|
const { build: _build, styles: _styles, assets: _assets, dedupeStyles: _dedupe, ...rest } = manifest;
|
|
338
|
-
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
|
+
);
|
|
339
362
|
console.log(`built ${manifest.kind} ${manifest.name}@${manifest.version}: ${fs.readdirSync(out).length} files`);
|
|
340
363
|
return { manifest, out };
|
|
341
364
|
}
|
|
@@ -486,6 +509,8 @@ function install(source, storeDir) {
|
|
|
486
509
|
...(manifest.widgets?.length ? { widgets: manifest.widgets } : {}),
|
|
487
510
|
// the store modules its browser bundle imports: the store loads them first (Generated Store Module Registry)
|
|
488
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) } : {}),
|
|
489
514
|
...(manifest.overrides ? { overrides: manifest.overrides } : {}),
|
|
490
515
|
...(manifest.serverPages?.length ? { serverPages: manifest.serverPages } : {}),
|
|
491
516
|
...(manifest.sandbox === true ? { sandbox: true } : {}),
|
|
@@ -592,6 +617,8 @@ function check(target) {
|
|
|
592
617
|
if (/\bznode\s*\(/.test(server)) used.add("znodeApi");
|
|
593
618
|
if (/\bfetch\s*\(|\bhttps?\.request\s*\(/.test(server)) used.add("externalNetwork");
|
|
594
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`);
|
|
595
622
|
const declared = new Set(manifest.permissions || []);
|
|
596
623
|
for (const item of used) if (!declared.has(item)) errors.push(`uses "${item}" without declaring it in "permissions"`);
|
|
597
624
|
for (const item of declared) if (!used.has(item)) warnings.push(`declares "${item}" but no code uses it (the review asks why)`);
|
|
@@ -771,7 +798,163 @@ function storeAlias(store, absolute) {
|
|
|
771
798
|
return best ? best.alias + target.slice(best.prefix.length) : null;
|
|
772
799
|
}
|
|
773
800
|
|
|
774
|
-
|
|
801
|
+
// a question on the terminal (only asked when stdin is a terminal); one line reader for the whole run, so answers
|
|
802
|
+
// typed (or piped) ahead are kept for the next question
|
|
803
|
+
let answers = null;
|
|
804
|
+
async function ask(question) {
|
|
805
|
+
if (!answers) {
|
|
806
|
+
const { createInterface } = await import("node:readline");
|
|
807
|
+
answers = createInterface({ input: process.stdin })[Symbol.asyncIterator]();
|
|
808
|
+
}
|
|
809
|
+
process.stdout.write(question);
|
|
810
|
+
const { value, done } = await answers.next();
|
|
811
|
+
if (done) fail("no answer (input closed)");
|
|
812
|
+
return value.trim();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// a slot name suggested for a component: ForgotPasswordPage -> forgotPassword.page (a page), AddToCart -> add.toCart
|
|
816
|
+
const suggestSlot = (name) => {
|
|
817
|
+
const words = name.match(/[A-Z][a-z0-9]*/g) || [name];
|
|
818
|
+
if (words.length > 1 && /^(Page|Layout)$/.test(words[words.length - 1]))
|
|
819
|
+
return `${words[0].toLowerCase()}${words.slice(1, -1).join("")}.${words[words.length - 1].toLowerCase()}`;
|
|
820
|
+
const [first, ...rest] = words;
|
|
821
|
+
return rest.length ? `${first.toLowerCase()}.${rest[0].toLowerCase()}${rest.slice(1).join("")}` : `${first.toLowerCase()}.main`;
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
// eject a store page (a server slot around a route's content): not a copy of the route (its server code, agents and
|
|
825
|
+
// data stay the store's), a server component of the package's own in src/server/pages/<Page>.tsx, registered under the
|
|
826
|
+
// slot in src/server.tsx, that renders the store's page content (children) to start with
|
|
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) {
|
|
890
|
+
const name = s.page;
|
|
891
|
+
const out = path.join(project, "src", "server", "pages", `${name}.tsx`);
|
|
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
|
+
}
|
|
903
|
+
const route = s.components[0] || name;
|
|
904
|
+
const text =
|
|
905
|
+
`import type { ReactNode } from "react";\n` +
|
|
906
|
+
`import type { IServerSlotContext } from "znode-plugin/types/extension-api";\n\n` +
|
|
907
|
+
`// Ejected with \`znode eject ${name}\` (${new Date().toISOString().slice(0, 10)}): the store's page ${route}, through its server slot\n` +
|
|
908
|
+
`// "${s.name}" (src/server.tsx serverSlots). It runs on the store's server only: it may be async and fetch data, its\n` +
|
|
909
|
+
`// output is in the page's HTML, and it has no hooks or browser code (put interactive parts in a browser slot).\n` +
|
|
910
|
+
`// children is the store's page content (${s.files[0]}): keep it, wrap it, or leave it out and render your own page.\n` +
|
|
911
|
+
`export async function ${name}(props: { children?: ReactNode }, context: IServerSlotContext) {\n` +
|
|
912
|
+
` void context; // context.store.code, context.settings (this package's settings)\n` +
|
|
913
|
+
` return (\n` +
|
|
914
|
+
` <div data-page="${s.name}">\n` +
|
|
915
|
+
` {props.children}\n` +
|
|
916
|
+
` </div>\n` +
|
|
917
|
+
` );\n` +
|
|
918
|
+
`}\n`;
|
|
919
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
920
|
+
fs.writeFileSync(out, text);
|
|
921
|
+
console.log(`ejected ${name} (server slot "${s.name}") -> ${path.relative(project, out)}`);
|
|
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) {
|
|
929
|
+
const server = path.join(project, "src", "server.tsx");
|
|
930
|
+
const local = `Ejected${name}`;
|
|
931
|
+
const importLine = `import { ${name} as ${local} } from "./server/pages/${name}.js";`;
|
|
932
|
+
const entry = `"${s.name}": ${local},`;
|
|
933
|
+
if (!fs.existsSync(server)) {
|
|
934
|
+
console.log(`warning src/server.tsx not found: register it yourself: ${importLine} and serverSlots: { ${entry} }`);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
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)`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function eject(name, force, { slot: slotArg, yes } = {}) {
|
|
775
958
|
const project = path.resolve(".");
|
|
776
959
|
if (!fs.existsSync(path.join(project, MANIFEST))) fail(`run znode eject in a package folder (the one with ${MANIFEST})`);
|
|
777
960
|
const store = linkedStore(project);
|
|
@@ -780,16 +963,61 @@ function eject(name, force) {
|
|
|
780
963
|
if (!name || name === "--list") {
|
|
781
964
|
console.log("Store components a package can eject (the slot that takes your copy):\n");
|
|
782
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("/")}`);
|
|
783
|
-
|
|
966
|
+
const pages = storeSlots(store.root).filter((s) => s.page);
|
|
967
|
+
if (pages.length) {
|
|
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]}`);
|
|
970
|
+
}
|
|
971
|
+
console.log("\nznode eject <Name> copies one into src/components/ (a page into src/server/pages/) and registers it");
|
|
784
972
|
return;
|
|
785
973
|
}
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
974
|
+
const find = (list) => list.find((item) => item.name.toLowerCase() === name.toLowerCase() || item.slot.toLowerCase() === 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
|
+
};
|
|
980
|
+
let c = find(components);
|
|
981
|
+
if (!c) {
|
|
982
|
+
const server = findServer();
|
|
983
|
+
if (server) return ejectServerSlot(project, server, force, store);
|
|
984
|
+
}
|
|
985
|
+
if (!c) {
|
|
986
|
+
// no slot hands this component over yet: offer to create one in the base store (what znode create-slot does), then eject
|
|
987
|
+
const isComponent = /^[A-Z]\w*$/.test(name);
|
|
988
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
989
|
+
const hint =
|
|
990
|
+
` znode eject lists the ones that can (component name or slot name both work)\n` +
|
|
991
|
+
` znode eject ${isComponent ? name : "<Component>"} --slot <area.name> creates the slot in the base store first, then ejects\n` +
|
|
992
|
+
` znode create-slot ${isComponent ? name : "<Component>"} <area.name> only creates the slot (edits the linked store's source)`;
|
|
993
|
+
if (!isComponent || (!interactive && !slotArg)) fail(`${name} cannot be ejected: no store component with that name or slot hands itself over to a slot.\n${hint}`);
|
|
994
|
+
console.log(`${name} has no slot yet, so it cannot be ejected.`);
|
|
995
|
+
console.log(`A slot can be created around it in the base store (${store.root}): it edits the store's source, nothing is committed.`);
|
|
996
|
+
if (!yes && !slotArg) {
|
|
997
|
+
const answer = await ask(`Create a slot for ${name} in the base store now? [y/N] `);
|
|
998
|
+
if (!/^y(es)?$/i.test(answer)) fail(`not created.\n${hint}`);
|
|
999
|
+
}
|
|
1000
|
+
let slot = slotArg;
|
|
1001
|
+
while (!slot) {
|
|
1002
|
+
const suggested = suggestSlot(name);
|
|
1003
|
+
slot = (await ask(`Slot name (area.name) [${suggested}]: `)) || suggested;
|
|
1004
|
+
if (!SLOT_NAME.test(slot)) {
|
|
1005
|
+
console.log(` "${slot}" is not a slot name: area.name in camelCase, e.g. product.details`);
|
|
1006
|
+
slot = "";
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
createSlot(name, slot, { fromEject: true });
|
|
1010
|
+
c = find(ejectableComponents(store.root));
|
|
1011
|
+
if (!c) {
|
|
1012
|
+
const server = findServer();
|
|
1013
|
+
if (server) {
|
|
1014
|
+
console.log("");
|
|
1015
|
+
return ejectServerSlot(project, server, force, store);
|
|
1016
|
+
}
|
|
1017
|
+
fail(`the slot "${slot}" was created, but ${name} is still not found as ejectable; check the store diff`);
|
|
1018
|
+
}
|
|
1019
|
+
console.log("");
|
|
1020
|
+
}
|
|
793
1021
|
|
|
794
1022
|
const out = path.join(project, "src", "components", `${c.name}.tsx`);
|
|
795
1023
|
if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${c.name} --force`);
|
|
@@ -814,6 +1042,8 @@ function eject(name, force) {
|
|
|
814
1042
|
// the copy is the plain component: no runtime wrapper (the slot is what puts it in place)
|
|
815
1043
|
text = text.replace(new RegExp(`^.*import\\s*\\{[^}]*\\b(?:withRuntimeWrapper|${c.wrapper})\\b[^}]*\\}\\s*from\\s*["'][^"']+["'];?\\s*\\n`, "gm"), "");
|
|
816
1044
|
text = text.replace(new RegExp(`withRuntimeWrapper\\(\\s*${c.wrapper}\\s*,\\s*${c.base}\\s*,\\s*"${c.name}"\\s*\\)`), c.base);
|
|
1045
|
+
// and none of the store's comments about that wrapper
|
|
1046
|
+
text = text.replace(/^\/\/ runtime packages render around it\b.*\n/gm, "");
|
|
817
1047
|
const storeFile = path.relative(store.root, c.file).split(path.sep).join("/");
|
|
818
1048
|
const header = `// Ejected from the store: ${storeFile} (${new Date().toISOString().slice(0, 10)}), with \`znode eject ${c.name}\`.\n// The store renders this copy instead of its own through the slot "${c.slot}" (src/browser.tsx). It is yours now: the\n// store's later changes to ${c.name} do not reach it, so compare it with the store's file after a store upgrade.\n`;
|
|
819
1049
|
text = text.startsWith('"use client"') || text.startsWith("'use client'") ? text.replace(/^(["']use client["'];?\s*\n)/, `$1${header}`) : header + text;
|
|
@@ -863,7 +1093,7 @@ function eject(name, force) {
|
|
|
863
1093
|
warnings.push(`src/browser.tsx already fills "${c.slot}": point it at ${local} (${importLine}) yourself`);
|
|
864
1094
|
} else if (/slots\s*:\s*\{/.test(source)) {
|
|
865
1095
|
if (!source.includes(importLine)) {
|
|
866
|
-
const imports = [...source.matchAll(/^import
|
|
1096
|
+
const imports = [...source.matchAll(/^import [^;]+;[ \t]*$/gm)];
|
|
867
1097
|
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
|
|
868
1098
|
source = source.slice(0, at) + `\n${importLine}` + source.slice(at);
|
|
869
1099
|
}
|
|
@@ -902,7 +1132,71 @@ function storeSourceFiles(root) {
|
|
|
902
1132
|
return files;
|
|
903
1133
|
}
|
|
904
1134
|
|
|
905
|
-
|
|
1135
|
+
// the store's route pages and layouts (apps/<app>/src/app/**/page.tsx, layout.tsx)
|
|
1136
|
+
function storeRoutePages(root) {
|
|
1137
|
+
const files = [];
|
|
1138
|
+
const walk = (dir) => {
|
|
1139
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1140
|
+
const file = path.join(dir, entry.name);
|
|
1141
|
+
if (entry.isDirectory()) {
|
|
1142
|
+
if (entry.name !== "node_modules") walk(file);
|
|
1143
|
+
} else if (/^(page|layout)\.tsx$/.test(entry.name)) files.push(file);
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
const apps = path.join(root, "apps");
|
|
1147
|
+
if (fs.existsSync(apps)) for (const app of fs.readdirSync(apps)) if (fs.existsSync(path.join(apps, app, "src/app"))) walk(path.join(apps, app, "src/app"));
|
|
1148
|
+
return files;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// the ")" that closes the "(" at index open (parentheses in the JSX's text are expected to be balanced)
|
|
1152
|
+
function closingParen(text, open) {
|
|
1153
|
+
let depth = 0;
|
|
1154
|
+
for (let i = open; i < text.length; i++) {
|
|
1155
|
+
if (text[i] === "(") depth++;
|
|
1156
|
+
else if (text[i] === ")" && --depth === 0) return i;
|
|
1157
|
+
}
|
|
1158
|
+
return -1;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// the ">" that ends the JSX opening tag starting at index start (attributes in {…} may contain ">", e.g. "=>")
|
|
1162
|
+
function openTagEnd(text, start) {
|
|
1163
|
+
let depth = 0;
|
|
1164
|
+
for (let i = start; i < text.length; i++) {
|
|
1165
|
+
if (text[i] === "{") depth++;
|
|
1166
|
+
else if (text[i] === "}") depth--;
|
|
1167
|
+
else if (text[i] === ">" && depth === 0) return i;
|
|
1168
|
+
}
|
|
1169
|
+
return -1;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
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";
|
|
1178
|
+
function routeServerSlot(file, name, slot, rel) {
|
|
1179
|
+
const before = fs.readFileSync(file, "utf8");
|
|
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`);
|
|
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);
|
|
1196
|
+
return { file, before, after };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function createSlot(name, slot, { dryRun, file: chosen, fromEject } = {}) {
|
|
906
1200
|
const usage = "usage: znode create-slot <Component> <area.name> [--dry-run] [--file <store file>] e.g. znode create-slot ProductBasicInfo product.basicInfo";
|
|
907
1201
|
if (!name || !slot) fail(usage);
|
|
908
1202
|
if (!/^[A-Z]\w*$/.test(name)) fail(`"${name}" is not a component name (PascalCase, e.g. ProductDetails)\n${usage}`);
|
|
@@ -918,6 +1212,7 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
918
1212
|
const wrapperSlots = storeWrapperSlots(root);
|
|
919
1213
|
const taken = Object.entries(wrapperSlots).find(([, value]) => value === slot);
|
|
920
1214
|
if (taken) fail(`slot "${slot}" already exists (${taken[0]}); znode eject lists what it hands over`);
|
|
1215
|
+
if (storeSlots(root).some((s) => s.name === slot)) fail(`slot "${slot}" already exists in the store (npx znode slot list)`);
|
|
921
1216
|
const wrapper = `Runtime${name}`;
|
|
922
1217
|
if (wrapperSlots[wrapper] || fs.readdirSync(slotsDir).some((f) => new RegExp(`export\\s+function\\s+${wrapper}\\b`).test(fs.readFileSync(path.join(slotsDir, f), "utf8"))))
|
|
923
1218
|
fail(`${wrapper} already exists in ${rel(slotsDir)}: ${name} already has a slot (znode eject lists it)`);
|
|
@@ -931,8 +1226,22 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
931
1226
|
if (named.length === 1) candidates = named;
|
|
932
1227
|
else fail(`${name} is defined in more than one store file; pick one with --file:\n ${candidates.map(rel).join("\n ")}`);
|
|
933
1228
|
}
|
|
934
|
-
if (!candidates.length)
|
|
1229
|
+
if (!candidates.length) {
|
|
1230
|
+
// not a component: a route page (apps/<app>/src/app/**/page.tsx or layout.tsx) whose default export is <name>
|
|
1231
|
+
const pages = storeRoutePages(root).filter((f) => new RegExp(`export\\s+default\\s+(?:async\\s+)?function\\s+${name}\\s*\\(`).test(fs.readFileSync(f, "utf8")));
|
|
1232
|
+
if (pages.length > 1) fail(`${name} is the default export of more than one route; pick one with --file:\n ${pages.map(rel).join("\n ")}`);
|
|
1233
|
+
if (!pages.length)
|
|
1234
|
+
fail(
|
|
1235
|
+
`no ${name} found: not a component in packages/base-components/src or packages/page-builder/src, and not the default export of a route page (apps/*/src/app/**/page.tsx or layout.tsx).\n` +
|
|
1236
|
+
` npx znode slot list shows the slots there are; a page's component name is its "export default function <Name>"`
|
|
1237
|
+
);
|
|
1238
|
+
candidates = pages;
|
|
1239
|
+
}
|
|
935
1240
|
const target = candidates[0];
|
|
1241
|
+
const isRoute = /^apps\/[^/]+\/src\/app\//.test(rel(target));
|
|
1242
|
+
let changes;
|
|
1243
|
+
if (isRoute) changes = [routeServerSlot(target, name, slot, rel)];
|
|
1244
|
+
else {
|
|
936
1245
|
let source = fs.readFileSync(target, "utf8");
|
|
937
1246
|
const def = source.match(DEFINES);
|
|
938
1247
|
if (!def) fail(`${rel(target)} does not define ${name} as a function or const component`);
|
|
@@ -986,11 +1295,13 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
986
1295
|
`export function ${wrapper}({ children, ...props }: Readonly<Record<string, unknown> & { children?: ReactNode }>) {\n` +
|
|
987
1296
|
` return (\n <RuntimeSlot name="${slot}" props={props}>\n {children}\n </RuntimeSlot>\n );\n}\n`;
|
|
988
1297
|
|
|
989
|
-
|
|
1298
|
+
changes = [
|
|
990
1299
|
{ file: target, before: fs.readFileSync(target, "utf8"), after: source },
|
|
991
1300
|
{ file: addedFile, before: addedBefore, after: added },
|
|
992
1301
|
];
|
|
1302
|
+
}
|
|
993
1303
|
const warnings = [];
|
|
1304
|
+
const where = rel(target).replace(/^packages\/[\w-]+\/src\//, "").replace(/^apps\/[^/]+\/src\/app\/(\[locale\]\/)?/, "app/").replace(/\.tsx$/, "");
|
|
994
1305
|
|
|
995
1306
|
// the slot list in the SDK types of the store (packages/extensions/src/types.ts), when it has one
|
|
996
1307
|
const typesFile = path.join(root, "packages/extensions/src/types.ts");
|
|
@@ -1001,7 +1312,13 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
1001
1312
|
if (start >= 0) {
|
|
1002
1313
|
let end = start + 1;
|
|
1003
1314
|
while (end < lines.length && /^\s*\/\/\s{3,}/.test(lines[end])) end++;
|
|
1004
|
-
lines.splice(
|
|
1315
|
+
lines.splice(
|
|
1316
|
+
end,
|
|
1317
|
+
0,
|
|
1318
|
+
isRoute
|
|
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)`
|
|
1320
|
+
: `// ${slot.padEnd(20)} the store's ${name} itself (children) props: every prop ${name} gets (znode create-slot)`
|
|
1321
|
+
);
|
|
1005
1322
|
changes.push({ file: typesFile, before, after: lines.join("\n") });
|
|
1006
1323
|
} else warnings.push(`${rel(typesFile)}: no "// Slots (" comment found; add "${slot}" to the slot list yourself`);
|
|
1007
1324
|
}
|
|
@@ -1013,7 +1330,13 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
1013
1330
|
let row = lines.findIndex((line) => line.startsWith("| `product.card`"));
|
|
1014
1331
|
if (row >= 0) {
|
|
1015
1332
|
while (row + 1 < lines.length && lines[row + 1].startsWith("|")) row++;
|
|
1016
|
-
lines.splice(
|
|
1333
|
+
lines.splice(
|
|
1334
|
+
row + 1,
|
|
1335
|
+
0,
|
|
1336
|
+
isRoute
|
|
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) |`
|
|
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}\`) |`
|
|
1339
|
+
);
|
|
1017
1340
|
changes.push({ file: docsFile, before, after: lines.join("\n") });
|
|
1018
1341
|
} else warnings.push(`${rel(docsFile)}: slot table not found; add a "${slot}" row yourself`);
|
|
1019
1342
|
}
|
|
@@ -1035,11 +1358,18 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
1035
1358
|
const registry = path.join(root, "scripts/gen-store-module-registry.mjs");
|
|
1036
1359
|
if (fs.existsSync(registry)) spawnSync(process.execPath, [registry], { cwd: root, stdio: "inherit" });
|
|
1037
1360
|
for (const warning of warnings) console.log(`warning ${warning}`);
|
|
1361
|
+
if (fromEject) {
|
|
1362
|
+
console.log(
|
|
1363
|
+
`\nSlot "${slot}" created in the base store's source (nothing committed, .next untouched). Before the store renders it:\n` +
|
|
1364
|
+
` review the diff in ${root} (git diff), commit it through the store's own flow, rebuild / restart the webstore.`
|
|
1365
|
+
);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1038
1368
|
console.log(
|
|
1039
1369
|
`\nDone (the store's source only: nothing committed, .next untouched). Next:\n` +
|
|
1040
1370
|
` 1. review the diff in ${root} (git diff) and commit it through the store's own flow (hooks, review: it edits base components)\n` +
|
|
1041
1371
|
` 2. rebuild / restart the webstore so it renders the slot\n` +
|
|
1042
|
-
` 3. here: npx znode eject ${name} (or fill "${slot}" in src/browser.tsx with a component of your own)`
|
|
1372
|
+
` 3. here: npx znode eject ${name} (or fill "${slot}" in ${isRoute ? "src/server.tsx serverSlots" : "src/browser.tsx slots"} with a component of your own)`
|
|
1043
1373
|
);
|
|
1044
1374
|
}
|
|
1045
1375
|
|
|
@@ -1074,10 +1404,9 @@ function storeSlots(root) {
|
|
|
1074
1404
|
for (const m of text.matchAll(/<Runtime(Server)?Slot\s+name=["']([\w.]+)["']/g)) {
|
|
1075
1405
|
const name = m[2];
|
|
1076
1406
|
const tag = m[1] ? "RuntimeServerSlot" : "RuntimeSlot";
|
|
1077
|
-
|
|
1078
|
-
const
|
|
1079
|
-
const
|
|
1080
|
-
const wraps = close >= 0 && (next < 0 || close < next);
|
|
1407
|
+
// it wraps something unless its tag closes itself (<RuntimeSlot name="…" … />)
|
|
1408
|
+
const tagEnd = openTagEnd(text, m.index);
|
|
1409
|
+
const wraps = tagEnd > 0 && text[tagEnd - 1] !== "/" && text.indexOf(`</${tag}>`, tagEnd) > 0;
|
|
1081
1410
|
const wrapper = wrappers.filter((w) => w.index < m.index).pop()?.[1];
|
|
1082
1411
|
const relFile = path.relative(root, file).split(path.sep).join("/");
|
|
1083
1412
|
const existing = slots.get(name);
|
|
@@ -1090,16 +1419,29 @@ function storeSlots(root) {
|
|
|
1090
1419
|
? "app/" + relFile.replace(/^apps\/[^/]+\/src\/app\/(\[locale\]\/)?/, "").replace(/\.tsx$/, "")
|
|
1091
1420
|
: path.basename(file, ".tsx");
|
|
1092
1421
|
const around = wrapper ? [...new Set(components[wrapper] || [])] : [where];
|
|
1422
|
+
// a server slot written in a route page wraps that page's content: eject gives the package a starter for it
|
|
1423
|
+
const page = !wrapper && m[1] && wraps && /^apps\//.test(relFile) ? (text.match(/export\s+default\s+(?:async\s+)?function\s+(\w+)/) || [])[1] || null : null;
|
|
1093
1424
|
slots.set(name, {
|
|
1094
1425
|
name,
|
|
1095
1426
|
side: m[1] ? "server" : "browser",
|
|
1096
1427
|
handsOver: wraps,
|
|
1097
1428
|
wrapper: wrapper || null,
|
|
1098
|
-
components: around,
|
|
1099
|
-
|
|
1429
|
+
components: page ? [`${page} (${where})`] : around,
|
|
1430
|
+
page,
|
|
1431
|
+
eject: wrapper && handover[wrapper] === name ? around[0] || null : page,
|
|
1100
1432
|
files: [relFile],
|
|
1101
1433
|
});
|
|
1102
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
|
+
}
|
|
1103
1445
|
}
|
|
1104
1446
|
return [...slots.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
1105
1447
|
}
|
|
@@ -1173,7 +1515,10 @@ if (command === "create") {
|
|
|
1173
1515
|
process.exit(0);
|
|
1174
1516
|
}
|
|
1175
1517
|
if (command === "eject") {
|
|
1176
|
-
eject
|
|
1518
|
+
// eject <Name> [--force] [--slot <area.name>] [--yes]: --slot / --yes create a missing slot without asking
|
|
1519
|
+
const slotAt = args.indexOf("--slot");
|
|
1520
|
+
const name = args.find((arg, index) => index > 0 && !arg.startsWith("-") && !(slotAt >= 0 && index === slotAt + 1));
|
|
1521
|
+
await eject(name, args.includes("--force"), { slot: slotAt >= 0 ? args[slotAt + 1] : undefined, yes: args.includes("--yes") || args.includes("-y") });
|
|
1177
1522
|
process.exit(0);
|
|
1178
1523
|
}
|
|
1179
1524
|
if (command === "link-store") {
|
package/package.json
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "znode-plugin",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/azharuddin-mulla-amla/znode-plugin-npm-registry.git",
|
|
10
|
+
"directory": "packages/znode-plugin"
|
|
11
|
+
},
|
|
7
12
|
"bin": {
|
|
8
13
|
"znode": "lib/znode-package.mjs"
|
|
9
14
|
},
|