znode-plugin 0.1.7 → 0.1.8
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 +113 -5
- package/lib/znode-package.mjs +277 -24
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -217,9 +217,113 @@ 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
|
+
its content. Use the page's component name (its `export default function <Name>`):
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
$ npx znode eject ForgotPasswordPage
|
|
289
|
+
ForgotPasswordPage has no slot yet, so it cannot be ejected.
|
|
290
|
+
Create a slot for ForgotPasswordPage in the base store now? [y/N] y
|
|
291
|
+
Slot name (area.name) [forgotPassword.page]:
|
|
292
|
+
creating slot "forgotPassword.page" around ForgotPasswordPage (apps/webstore/src/app/[locale]/forgot-password/page.tsx) ...
|
|
293
|
+
ejected ForgotPasswordPage (server slot "forgotPassword.page") -> src/server/pages/ForgotPasswordPage.tsx
|
|
294
|
+
registered "forgotPassword.page": EjectedForgotPasswordPage in src/server.tsx (serverSlots)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
In the store, the page's content is wrapped (inside its `NextIntlClientProvider`, so the page's texts still reach it):
|
|
298
|
+
|
|
299
|
+
```tsx
|
|
300
|
+
<NextIntlClientProvider messages={...}>
|
|
301
|
+
<RuntimeServerSlot name="forgotPassword.page">
|
|
302
|
+
<ForgotPassword />
|
|
303
|
+
</RuntimeServerSlot>
|
|
304
|
+
</NextIntlClientProvider>
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
In your package you get a server component to build the page in. `children` is the store's page content:
|
|
308
|
+
|
|
309
|
+
```tsx
|
|
310
|
+
// src/server/pages/ForgotPasswordPage.tsx
|
|
311
|
+
export async function ForgotPasswordPage(props: { children?: ReactNode }, context: IServerSlotContext) {
|
|
312
|
+
return (
|
|
313
|
+
<div className="my-forgot-page">
|
|
314
|
+
<h1>Reset your password</h1>
|
|
315
|
+
<aside>Help text, images, data fetched here (async is fine)</aside>
|
|
316
|
+
{props.children /* the store's form; leave it out to render a page of your own */}
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
- It is not a copy of the route: the route's server code (agents, `getResourceMessages`, data) stays the store's. Your
|
|
323
|
+
component runs on the server only (async, no hooks): put interactive parts in a browser slot, e.g. change the form
|
|
324
|
+
itself with `npx znode eject ForgotPassword` (slot `account.forgotPassword`).
|
|
325
|
+
- A page that already has a server slot (`login.page`, `layout.root`) is ejected straight away (`npx znode eject LoginPage`).
|
|
326
|
+
- Only the slot: `npx znode create-slot ForgotPasswordPage forgotPassword.page` (`--dry-run` to preview).
|
|
223
327
|
|
|
224
328
|
### Add a page-builder widget
|
|
225
329
|
|
|
@@ -293,8 +397,8 @@ Run them with `npm run <name>` inside a project (the scripts call `znode … .`)
|
|
|
293
397
|
| | `npx znode create <theme\|plugin\|widget\|content> <name>` | a minimal starter of another kind |
|
|
294
398
|
| | `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
399
|
| | `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 |
|
|
400
|
+
| | `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 |
|
|
401
|
+
| | `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
402
|
|
|
299
403
|
The plugin folder is taken from: the command's argument, else the `RUNTIME_PLUGINS_DIR` environment variable, else
|
|
300
404
|
`znode.local.json`.
|
|
@@ -372,6 +476,10 @@ folder after an upgrade; delete them by hand if you need the space.
|
|
|
372
476
|
| `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
477
|
| `MISSING_MESSAGE` for your label | put browser labels under a namespace the page sends, e.g. `Common` |
|
|
374
478
|
| Theme shows for you but not shoppers | expected: enable it in `plugins.json` (step 7) |
|
|
479
|
+
| `<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 |
|
|
480
|
+
| `<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`) |
|
|
481
|
+
| `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 |
|
|
482
|
+
| 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
483
|
|
|
376
484
|
---
|
|
377
485
|
|
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
|
|
@@ -771,7 +772,86 @@ function storeAlias(store, absolute) {
|
|
|
771
772
|
return best ? best.alias + target.slice(best.prefix.length) : null;
|
|
772
773
|
}
|
|
773
774
|
|
|
774
|
-
|
|
775
|
+
// a question on the terminal (only asked when stdin is a terminal); one line reader for the whole run, so answers
|
|
776
|
+
// typed (or piped) ahead are kept for the next question
|
|
777
|
+
let answers = null;
|
|
778
|
+
async function ask(question) {
|
|
779
|
+
if (!answers) {
|
|
780
|
+
const { createInterface } = await import("node:readline");
|
|
781
|
+
answers = createInterface({ input: process.stdin })[Symbol.asyncIterator]();
|
|
782
|
+
}
|
|
783
|
+
process.stdout.write(question);
|
|
784
|
+
const { value, done } = await answers.next();
|
|
785
|
+
if (done) fail("no answer (input closed)");
|
|
786
|
+
return value.trim();
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// a slot name suggested for a component: ForgotPasswordPage -> forgotPassword.page (a page), AddToCart -> add.toCart
|
|
790
|
+
const suggestSlot = (name) => {
|
|
791
|
+
const words = name.match(/[A-Z][a-z0-9]*/g) || [name];
|
|
792
|
+
if (words.length > 1 && /^(Page|Layout)$/.test(words[words.length - 1]))
|
|
793
|
+
return `${words[0].toLowerCase()}${words.slice(1, -1).join("")}.${words[words.length - 1].toLowerCase()}`;
|
|
794
|
+
const [first, ...rest] = words;
|
|
795
|
+
return rest.length ? `${first.toLowerCase()}.${rest[0].toLowerCase()}${rest.slice(1).join("")}` : `${first.toLowerCase()}.main`;
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
// eject a store page (a server slot around a route's content): not a copy of the route (its server code, agents and
|
|
799
|
+
// data stay the store's), a server component of the package's own in src/server/pages/<Page>.tsx, registered under the
|
|
800
|
+
// slot in src/server.tsx, that renders the store's page content (children) to start with
|
|
801
|
+
function ejectServerSlot(project, s, force) {
|
|
802
|
+
const name = s.page;
|
|
803
|
+
const out = path.join(project, "src", "server", "pages", `${name}.tsx`);
|
|
804
|
+
if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${name} --force`);
|
|
805
|
+
const route = s.components[0] || name;
|
|
806
|
+
const text =
|
|
807
|
+
`import type { ReactNode } from "react";\n` +
|
|
808
|
+
`import type { IServerSlotContext } from "znode-plugin/types/extension-api";\n\n` +
|
|
809
|
+
`// Ejected with \`znode eject ${name}\` (${new Date().toISOString().slice(0, 10)}): the store's page ${route}, through its server slot\n` +
|
|
810
|
+
`// "${s.name}" (src/server.tsx serverSlots). It runs on the store's server only: it may be async and fetch data, its\n` +
|
|
811
|
+
`// output is in the page's HTML, and it has no hooks or browser code (put interactive parts in a browser slot).\n` +
|
|
812
|
+
`// children is the store's page content (${s.files[0]}): keep it, wrap it, or leave it out and render your own page.\n` +
|
|
813
|
+
`export async function ${name}(props: { children?: ReactNode }, context: IServerSlotContext) {\n` +
|
|
814
|
+
` void context; // context.store.code, context.settings (this package's settings)\n` +
|
|
815
|
+
` return (\n` +
|
|
816
|
+
` <div data-page="${s.name}">\n` +
|
|
817
|
+
` {props.children}\n` +
|
|
818
|
+
` </div>\n` +
|
|
819
|
+
` );\n` +
|
|
820
|
+
`}\n`;
|
|
821
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
822
|
+
fs.writeFileSync(out, text);
|
|
823
|
+
console.log(`ejected ${name} (server slot "${s.name}") -> ${path.relative(project, out)}`);
|
|
824
|
+
|
|
825
|
+
const server = path.join(project, "src", "server.tsx");
|
|
826
|
+
const local = `Ejected${name}`;
|
|
827
|
+
const importLine = `import { ${name} as ${local} } from "./server/pages/${name}.js";`;
|
|
828
|
+
const entry = `"${s.name}": ${local},`;
|
|
829
|
+
if (!fs.existsSync(server)) {
|
|
830
|
+
console.log(`warning src/server.tsx not found: register it yourself: ${importLine} and serverSlots: { ${entry} }`);
|
|
831
|
+
} else {
|
|
832
|
+
let source = fs.readFileSync(server, "utf8");
|
|
833
|
+
if (new RegExp(`["']${s.name.replace(/\./g, "\\.")}["']\\s*:`).test(source)) {
|
|
834
|
+
console.log(`warning src/server.tsx already fills "${s.name}": point it at ${local} (${importLine}) yourself`);
|
|
835
|
+
} else {
|
|
836
|
+
if (/serverSlots\s*:\s*\{/.test(source)) source = source.replace(/serverSlots\s*:\s*\{/, `serverSlots: {\n // ejected (znode eject ${name}): the store renders this around / instead of its page\n ${entry}`);
|
|
837
|
+
else if (/apiVersion\s*:\s*\d+\s*,/.test(source)) source = source.replace(/(apiVersion\s*:\s*\d+\s*,)/, `$1\n serverSlots: {\n ${entry}\n },`);
|
|
838
|
+
else {
|
|
839
|
+
console.log(`warning no "serverSlots" in src/server.tsx: add ${importLine} and serverSlots: { ${entry} }`);
|
|
840
|
+
source = null;
|
|
841
|
+
}
|
|
842
|
+
if (source) {
|
|
843
|
+
const imports = [...source.matchAll(/^import [^;]+;[ \t]*$/gm)];
|
|
844
|
+
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
|
|
845
|
+
source = source.slice(0, at) + `\n${importLine}` + source.slice(at);
|
|
846
|
+
fs.writeFileSync(server, source);
|
|
847
|
+
console.log(`registered "${s.name}": ${local} in src/server.tsx (serverSlots)`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
console.log(`\nNext: edit ${path.relative(project, out)}, then npm run build and npm run dev and open the page.`);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function eject(name, force, { slot: slotArg, yes } = {}) {
|
|
775
855
|
const project = path.resolve(".");
|
|
776
856
|
if (!fs.existsSync(path.join(project, MANIFEST))) fail(`run znode eject in a package folder (the one with ${MANIFEST})`);
|
|
777
857
|
const store = linkedStore(project);
|
|
@@ -780,16 +860,59 @@ function eject(name, force) {
|
|
|
780
860
|
if (!name || name === "--list") {
|
|
781
861
|
console.log("Store components a package can eject (the slot that takes your copy):\n");
|
|
782
862
|
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
|
-
|
|
863
|
+
const pages = storeSlots(store.root).filter((s) => s.page);
|
|
864
|
+
if (pages.length) {
|
|
865
|
+
console.log("\nStore pages (server slot around the page's content; you get a server starter in src/server/pages/):\n");
|
|
866
|
+
for (const s of pages) console.log(` ${s.page.padEnd(34)} ${s.name.padEnd(26)} ${s.files[0]}`);
|
|
867
|
+
}
|
|
868
|
+
console.log("\nznode eject <Name> copies one into src/components/ (or a page starter into src/server/pages/) and registers it");
|
|
784
869
|
return;
|
|
785
870
|
}
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
);
|
|
871
|
+
const find = (list) => list.find((item) => item.name.toLowerCase() === name.toLowerCase() || item.slot.toLowerCase() === name.toLowerCase());
|
|
872
|
+
// a route page with a server slot around its content: the package gets a server starter for it (src/server.tsx)
|
|
873
|
+
const findServer = () =>
|
|
874
|
+
storeSlots(store.root).find((s) => s.page && [s.page.toLowerCase(), s.name.toLowerCase()].includes(name.toLowerCase()));
|
|
875
|
+
let c = find(components);
|
|
876
|
+
if (!c) {
|
|
877
|
+
const server = findServer();
|
|
878
|
+
if (server) return ejectServerSlot(project, server, force);
|
|
879
|
+
}
|
|
880
|
+
if (!c) {
|
|
881
|
+
// no slot hands this component over yet: offer to create one in the base store (what znode create-slot does), then eject
|
|
882
|
+
const isComponent = /^[A-Z]\w*$/.test(name);
|
|
883
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
884
|
+
const hint =
|
|
885
|
+
` znode eject lists the ones that can (component name or slot name both work)\n` +
|
|
886
|
+
` znode eject ${isComponent ? name : "<Component>"} --slot <area.name> creates the slot in the base store first, then ejects\n` +
|
|
887
|
+
` znode create-slot ${isComponent ? name : "<Component>"} <area.name> only creates the slot (edits the linked store's source)`;
|
|
888
|
+
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}`);
|
|
889
|
+
console.log(`${name} has no slot yet, so it cannot be ejected.`);
|
|
890
|
+
console.log(`A slot can be created around it in the base store (${store.root}): it edits the store's source, nothing is committed.`);
|
|
891
|
+
if (!yes && !slotArg) {
|
|
892
|
+
const answer = await ask(`Create a slot for ${name} in the base store now? [y/N] `);
|
|
893
|
+
if (!/^y(es)?$/i.test(answer)) fail(`not created.\n${hint}`);
|
|
894
|
+
}
|
|
895
|
+
let slot = slotArg;
|
|
896
|
+
while (!slot) {
|
|
897
|
+
const suggested = suggestSlot(name);
|
|
898
|
+
slot = (await ask(`Slot name (area.name) [${suggested}]: `)) || suggested;
|
|
899
|
+
if (!SLOT_NAME.test(slot)) {
|
|
900
|
+
console.log(` "${slot}" is not a slot name: area.name in camelCase, e.g. product.details`);
|
|
901
|
+
slot = "";
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
createSlot(name, slot, { fromEject: true });
|
|
905
|
+
c = find(ejectableComponents(store.root));
|
|
906
|
+
if (!c) {
|
|
907
|
+
const server = findServer();
|
|
908
|
+
if (server) {
|
|
909
|
+
console.log("");
|
|
910
|
+
return ejectServerSlot(project, server, force);
|
|
911
|
+
}
|
|
912
|
+
fail(`the slot "${slot}" was created, but ${name} is still not found as ejectable; check the store diff`);
|
|
913
|
+
}
|
|
914
|
+
console.log("");
|
|
915
|
+
}
|
|
793
916
|
|
|
794
917
|
const out = path.join(project, "src", "components", `${c.name}.tsx`);
|
|
795
918
|
if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${c.name} --force`);
|
|
@@ -814,6 +937,8 @@ function eject(name, force) {
|
|
|
814
937
|
// the copy is the plain component: no runtime wrapper (the slot is what puts it in place)
|
|
815
938
|
text = text.replace(new RegExp(`^.*import\\s*\\{[^}]*\\b(?:withRuntimeWrapper|${c.wrapper})\\b[^}]*\\}\\s*from\\s*["'][^"']+["'];?\\s*\\n`, "gm"), "");
|
|
816
939
|
text = text.replace(new RegExp(`withRuntimeWrapper\\(\\s*${c.wrapper}\\s*,\\s*${c.base}\\s*,\\s*"${c.name}"\\s*\\)`), c.base);
|
|
940
|
+
// and none of the store's comments about that wrapper
|
|
941
|
+
text = text.replace(/^\/\/ runtime packages render around it\b.*\n/gm, "");
|
|
817
942
|
const storeFile = path.relative(store.root, c.file).split(path.sep).join("/");
|
|
818
943
|
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
944
|
text = text.startsWith('"use client"') || text.startsWith("'use client'") ? text.replace(/^(["']use client["'];?\s*\n)/, `$1${header}`) : header + text;
|
|
@@ -863,7 +988,7 @@ function eject(name, force) {
|
|
|
863
988
|
warnings.push(`src/browser.tsx already fills "${c.slot}": point it at ${local} (${importLine}) yourself`);
|
|
864
989
|
} else if (/slots\s*:\s*\{/.test(source)) {
|
|
865
990
|
if (!source.includes(importLine)) {
|
|
866
|
-
const imports = [...source.matchAll(/^import
|
|
991
|
+
const imports = [...source.matchAll(/^import [^;]+;[ \t]*$/gm)];
|
|
867
992
|
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
|
|
868
993
|
source = source.slice(0, at) + `\n${importLine}` + source.slice(at);
|
|
869
994
|
}
|
|
@@ -902,7 +1027,94 @@ function storeSourceFiles(root) {
|
|
|
902
1027
|
return files;
|
|
903
1028
|
}
|
|
904
1029
|
|
|
905
|
-
|
|
1030
|
+
// the store's route pages and layouts (apps/<app>/src/app/**/page.tsx, layout.tsx)
|
|
1031
|
+
function storeRoutePages(root) {
|
|
1032
|
+
const files = [];
|
|
1033
|
+
const walk = (dir) => {
|
|
1034
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1035
|
+
const file = path.join(dir, entry.name);
|
|
1036
|
+
if (entry.isDirectory()) {
|
|
1037
|
+
if (entry.name !== "node_modules") walk(file);
|
|
1038
|
+
} else if (/^(page|layout)\.tsx$/.test(entry.name)) files.push(file);
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
const apps = path.join(root, "apps");
|
|
1042
|
+
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"));
|
|
1043
|
+
return files;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// the ")" that closes the "(" at index open (parentheses in the JSX's text are expected to be balanced)
|
|
1047
|
+
function closingParen(text, open) {
|
|
1048
|
+
let depth = 0;
|
|
1049
|
+
for (let i = open; i < text.length; i++) {
|
|
1050
|
+
if (text[i] === "(") depth++;
|
|
1051
|
+
else if (text[i] === ")" && --depth === 0) return i;
|
|
1052
|
+
}
|
|
1053
|
+
return -1;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// the ">" that ends the JSX opening tag starting at index start (attributes in {…} may contain ">", e.g. "=>")
|
|
1057
|
+
function openTagEnd(text, start) {
|
|
1058
|
+
let depth = 0;
|
|
1059
|
+
for (let i = start; i < text.length; i++) {
|
|
1060
|
+
if (text[i] === "{") depth++;
|
|
1061
|
+
else if (text[i] === "}") depth--;
|
|
1062
|
+
else if (text[i] === ">" && depth === 0) return i;
|
|
1063
|
+
}
|
|
1064
|
+
return -1;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// A route page gets a server slot around what it returns: <RuntimeServerSlot name="…"> … </RuntimeServerSlot>, inside a
|
|
1068
|
+
// NextIntlClientProvider at the top (so the slot's components, and the store's, keep the page's texts). A package fills
|
|
1069
|
+
// it in src/server.tsx (serverSlots): it gets the page's content as children and the store / settings as context.
|
|
1070
|
+
function routeServerSlot(file, name, slot, rel) {
|
|
1071
|
+
const before = fs.readFileSync(file, "utf8");
|
|
1072
|
+
if (/^\s*["']use client["']/.test(before)) fail(`${rel(file)} is a client page ("use client"): a server slot cannot go there; add a <RuntimeSlot> by hand`);
|
|
1073
|
+
const fnAt = before.search(new RegExp(`export\\s+default\\s+(?:async\\s+)?function\\s+${name}\\s*\\(`));
|
|
1074
|
+
const existing = before.slice(fnAt).match(/<RuntimeServerSlot\s+name=["']([\w.]+)["']/);
|
|
1075
|
+
if (existing) fail(`${name} (${rel(file)}) already has the server slot "${existing[1]}": fill that one (npx znode slot list)`);
|
|
1076
|
+
const ret = before.lastIndexOf("return (");
|
|
1077
|
+
if (ret < fnAt) fail(`${name} (${rel(file)}) does not end with return (<…>): put <RuntimeServerSlot name="${slot}"> around its content by hand`);
|
|
1078
|
+
const open = ret + "return ".length;
|
|
1079
|
+
const close = closingParen(before, open);
|
|
1080
|
+
if (close < 0) fail(`${rel(file)}: could not find the end of ${name}'s return (…): put <RuntimeServerSlot name="${slot}"> around its content by hand`);
|
|
1081
|
+
let start = open + 1;
|
|
1082
|
+
let end = close;
|
|
1083
|
+
const providerAt = before.slice(start, end).search(/^\s*<NextIntlClientProvider\b/);
|
|
1084
|
+
if (providerAt === 0) {
|
|
1085
|
+
const tagStart = before.indexOf("<NextIntlClientProvider", start);
|
|
1086
|
+
const tagClose = before.lastIndexOf("</NextIntlClientProvider>", close);
|
|
1087
|
+
const tagEnd = openTagEnd(before, tagStart);
|
|
1088
|
+
if (tagEnd > 0 && tagClose > tagEnd && before[tagEnd - 1] !== "/") {
|
|
1089
|
+
start = tagEnd + 1;
|
|
1090
|
+
end = tagClose;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const inner = before.slice(start, end);
|
|
1094
|
+
const lead = inner.match(/^\s*/)[0];
|
|
1095
|
+
const trail = inner.match(/\s*$/)[0];
|
|
1096
|
+
const core = inner.slice(lead.length, inner.length - trail.length);
|
|
1097
|
+
const indent = lead.includes("\n") ? lead.slice(lead.lastIndexOf("\n") + 1) : "";
|
|
1098
|
+
const body = core
|
|
1099
|
+
.split("\n")
|
|
1100
|
+
.map((line, i) => (i === 0 || !line.trim() ? line : ` ${line}`))
|
|
1101
|
+
.join("\n");
|
|
1102
|
+
const wrapped = `${lead}<RuntimeServerSlot name="${slot}">\n${indent} ${body}\n${indent}</RuntimeServerSlot>${trail}`;
|
|
1103
|
+
let after = before.slice(0, start) + wrapped + before.slice(end);
|
|
1104
|
+
// a note above the return, and the import
|
|
1105
|
+
// (the text up to the return is unchanged, so its position still holds)
|
|
1106
|
+
const lineStart = before.lastIndexOf("\n", ret) + 1;
|
|
1107
|
+
const retIndent = before.slice(lineStart, ret);
|
|
1108
|
+
after = after.slice(0, lineStart) + `${retIndent}// runtime packages: server components around the page's content (server slot ${slot}, znode create-slot)\n` + after.slice(lineStart);
|
|
1109
|
+
if (!/import\s*\{[^}]*\bRuntimeServerSlot\b/.test(after)) {
|
|
1110
|
+
const imports = [...after.matchAll(/^import [^;]+;[ \t]*$/gm)];
|
|
1111
|
+
const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
|
|
1112
|
+
after = after.slice(0, at) + `${at ? "\n" : ""}import { RuntimeServerSlot } from "@znode/extensions/RuntimeServerSlot";${at ? "" : "\n"}` + after.slice(at);
|
|
1113
|
+
}
|
|
1114
|
+
return { file, before, after };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function createSlot(name, slot, { dryRun, file: chosen, fromEject } = {}) {
|
|
906
1118
|
const usage = "usage: znode create-slot <Component> <area.name> [--dry-run] [--file <store file>] e.g. znode create-slot ProductBasicInfo product.basicInfo";
|
|
907
1119
|
if (!name || !slot) fail(usage);
|
|
908
1120
|
if (!/^[A-Z]\w*$/.test(name)) fail(`"${name}" is not a component name (PascalCase, e.g. ProductDetails)\n${usage}`);
|
|
@@ -918,6 +1130,7 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
918
1130
|
const wrapperSlots = storeWrapperSlots(root);
|
|
919
1131
|
const taken = Object.entries(wrapperSlots).find(([, value]) => value === slot);
|
|
920
1132
|
if (taken) fail(`slot "${slot}" already exists (${taken[0]}); znode eject lists what it hands over`);
|
|
1133
|
+
if (storeSlots(root).some((s) => s.name === slot)) fail(`slot "${slot}" already exists in the store (npx znode slot list)`);
|
|
921
1134
|
const wrapper = `Runtime${name}`;
|
|
922
1135
|
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
1136
|
fail(`${wrapper} already exists in ${rel(slotsDir)}: ${name} already has a slot (znode eject lists it)`);
|
|
@@ -931,8 +1144,22 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
931
1144
|
if (named.length === 1) candidates = named;
|
|
932
1145
|
else fail(`${name} is defined in more than one store file; pick one with --file:\n ${candidates.map(rel).join("\n ")}`);
|
|
933
1146
|
}
|
|
934
|
-
if (!candidates.length)
|
|
1147
|
+
if (!candidates.length) {
|
|
1148
|
+
// not a component: a route page (apps/<app>/src/app/**/page.tsx or layout.tsx) whose default export is <name>
|
|
1149
|
+
const pages = storeRoutePages(root).filter((f) => new RegExp(`export\\s+default\\s+(?:async\\s+)?function\\s+${name}\\s*\\(`).test(fs.readFileSync(f, "utf8")));
|
|
1150
|
+
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 ")}`);
|
|
1151
|
+
if (!pages.length)
|
|
1152
|
+
fail(
|
|
1153
|
+
`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` +
|
|
1154
|
+
` npx znode slot list shows the slots there are; a page's component name is its "export default function <Name>"`
|
|
1155
|
+
);
|
|
1156
|
+
candidates = pages;
|
|
1157
|
+
}
|
|
935
1158
|
const target = candidates[0];
|
|
1159
|
+
const isRoute = /^apps\/[^/]+\/src\/app\//.test(rel(target));
|
|
1160
|
+
let changes;
|
|
1161
|
+
if (isRoute) changes = [routeServerSlot(target, name, slot, rel)];
|
|
1162
|
+
else {
|
|
936
1163
|
let source = fs.readFileSync(target, "utf8");
|
|
937
1164
|
const def = source.match(DEFINES);
|
|
938
1165
|
if (!def) fail(`${rel(target)} does not define ${name} as a function or const component`);
|
|
@@ -986,11 +1213,13 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
986
1213
|
`export function ${wrapper}({ children, ...props }: Readonly<Record<string, unknown> & { children?: ReactNode }>) {\n` +
|
|
987
1214
|
` return (\n <RuntimeSlot name="${slot}" props={props}>\n {children}\n </RuntimeSlot>\n );\n}\n`;
|
|
988
1215
|
|
|
989
|
-
|
|
1216
|
+
changes = [
|
|
990
1217
|
{ file: target, before: fs.readFileSync(target, "utf8"), after: source },
|
|
991
1218
|
{ file: addedFile, before: addedBefore, after: added },
|
|
992
1219
|
];
|
|
1220
|
+
}
|
|
993
1221
|
const warnings = [];
|
|
1222
|
+
const where = rel(target).replace(/^packages\/[\w-]+\/src\//, "").replace(/^apps\/[^/]+\/src\/app\/(\[locale\]\/)?/, "app/").replace(/\.tsx$/, "");
|
|
994
1223
|
|
|
995
1224
|
// the slot list in the SDK types of the store (packages/extensions/src/types.ts), when it has one
|
|
996
1225
|
const typesFile = path.join(root, "packages/extensions/src/types.ts");
|
|
@@ -1001,7 +1230,13 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
1001
1230
|
if (start >= 0) {
|
|
1002
1231
|
let end = start + 1;
|
|
1003
1232
|
while (end < lines.length && /^\s*\/\/\s{3,}/.test(lines[end])) end++;
|
|
1004
|
-
lines.splice(
|
|
1233
|
+
lines.splice(
|
|
1234
|
+
end,
|
|
1235
|
+
0,
|
|
1236
|
+
isRoute
|
|
1237
|
+
? `// ${slot.padEnd(20)} (server slot, src/server.tsx serverSlots) the ${where} route's content (children) (znode create-slot)`
|
|
1238
|
+
: `// ${slot.padEnd(20)} the store's ${name} itself (children) props: every prop ${name} gets (znode create-slot)`
|
|
1239
|
+
);
|
|
1005
1240
|
changes.push({ file: typesFile, before, after: lines.join("\n") });
|
|
1006
1241
|
} else warnings.push(`${rel(typesFile)}: no "// Slots (" comment found; add "${slot}" to the slot list yourself`);
|
|
1007
1242
|
}
|
|
@@ -1013,7 +1248,13 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
1013
1248
|
let row = lines.findIndex((line) => line.startsWith("| `product.card`"));
|
|
1014
1249
|
if (row >= 0) {
|
|
1015
1250
|
while (row + 1 < lines.length && lines[row + 1].startsWith("|")) row++;
|
|
1016
|
-
lines.splice(
|
|
1251
|
+
lines.splice(
|
|
1252
|
+
row + 1,
|
|
1253
|
+
0,
|
|
1254
|
+
isRoute
|
|
1255
|
+
? `| \`${slot}\` (server slot) | none: \`<RuntimeServerSlot>\` written directly in \`${rel(target)}\` (znode create-slot) | the ${where} route's content (\`${name}\`) | none (context: store, settings); keep \`children\`, wrap it, or render your own page (\`znode eject ${name}\`) |`
|
|
1256
|
+
: `| \`${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
|
+
);
|
|
1017
1258
|
changes.push({ file: docsFile, before, after: lines.join("\n") });
|
|
1018
1259
|
} else warnings.push(`${rel(docsFile)}: slot table not found; add a "${slot}" row yourself`);
|
|
1019
1260
|
}
|
|
@@ -1035,11 +1276,18 @@ function createSlot(name, slot, { dryRun, file: chosen }) {
|
|
|
1035
1276
|
const registry = path.join(root, "scripts/gen-store-module-registry.mjs");
|
|
1036
1277
|
if (fs.existsSync(registry)) spawnSync(process.execPath, [registry], { cwd: root, stdio: "inherit" });
|
|
1037
1278
|
for (const warning of warnings) console.log(`warning ${warning}`);
|
|
1279
|
+
if (fromEject) {
|
|
1280
|
+
console.log(
|
|
1281
|
+
`\nSlot "${slot}" created in the base store's source (nothing committed, .next untouched). Before the store renders it:\n` +
|
|
1282
|
+
` review the diff in ${root} (git diff), commit it through the store's own flow, rebuild / restart the webstore.`
|
|
1283
|
+
);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1038
1286
|
console.log(
|
|
1039
1287
|
`\nDone (the store's source only: nothing committed, .next untouched). Next:\n` +
|
|
1040
1288
|
` 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
1289
|
` 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)`
|
|
1290
|
+
` 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
1291
|
);
|
|
1044
1292
|
}
|
|
1045
1293
|
|
|
@@ -1074,10 +1322,9 @@ function storeSlots(root) {
|
|
|
1074
1322
|
for (const m of text.matchAll(/<Runtime(Server)?Slot\s+name=["']([\w.]+)["']/g)) {
|
|
1075
1323
|
const name = m[2];
|
|
1076
1324
|
const tag = m[1] ? "RuntimeServerSlot" : "RuntimeSlot";
|
|
1077
|
-
|
|
1078
|
-
const
|
|
1079
|
-
const
|
|
1080
|
-
const wraps = close >= 0 && (next < 0 || close < next);
|
|
1325
|
+
// it wraps something unless its tag closes itself (<RuntimeSlot name="…" … />)
|
|
1326
|
+
const tagEnd = openTagEnd(text, m.index);
|
|
1327
|
+
const wraps = tagEnd > 0 && text[tagEnd - 1] !== "/" && text.indexOf(`</${tag}>`, tagEnd) > 0;
|
|
1081
1328
|
const wrapper = wrappers.filter((w) => w.index < m.index).pop()?.[1];
|
|
1082
1329
|
const relFile = path.relative(root, file).split(path.sep).join("/");
|
|
1083
1330
|
const existing = slots.get(name);
|
|
@@ -1090,13 +1337,16 @@ function storeSlots(root) {
|
|
|
1090
1337
|
? "app/" + relFile.replace(/^apps\/[^/]+\/src\/app\/(\[locale\]\/)?/, "").replace(/\.tsx$/, "")
|
|
1091
1338
|
: path.basename(file, ".tsx");
|
|
1092
1339
|
const around = wrapper ? [...new Set(components[wrapper] || [])] : [where];
|
|
1340
|
+
// a server slot written in a route page wraps that page's content: eject gives the package a starter for it
|
|
1341
|
+
const page = !wrapper && m[1] && wraps && /^apps\//.test(relFile) ? (text.match(/export\s+default\s+(?:async\s+)?function\s+(\w+)/) || [])[1] || null : null;
|
|
1093
1342
|
slots.set(name, {
|
|
1094
1343
|
name,
|
|
1095
1344
|
side: m[1] ? "server" : "browser",
|
|
1096
1345
|
handsOver: wraps,
|
|
1097
1346
|
wrapper: wrapper || null,
|
|
1098
|
-
components: around,
|
|
1099
|
-
|
|
1347
|
+
components: page ? [`${page} (${where})`] : around,
|
|
1348
|
+
page,
|
|
1349
|
+
eject: wrapper && handover[wrapper] === name ? around[0] || null : page,
|
|
1100
1350
|
files: [relFile],
|
|
1101
1351
|
});
|
|
1102
1352
|
}
|
|
@@ -1173,7 +1423,10 @@ if (command === "create") {
|
|
|
1173
1423
|
process.exit(0);
|
|
1174
1424
|
}
|
|
1175
1425
|
if (command === "eject") {
|
|
1176
|
-
eject
|
|
1426
|
+
// eject <Name> [--force] [--slot <area.name>] [--yes]: --slot / --yes create a missing slot without asking
|
|
1427
|
+
const slotAt = args.indexOf("--slot");
|
|
1428
|
+
const name = args.find((arg, index) => index > 0 && !arg.startsWith("-") && !(slotAt >= 0 && index === slotAt + 1));
|
|
1429
|
+
await eject(name, args.includes("--force"), { slot: slotAt >= 0 ? args[slotAt + 1] : undefined, yes: args.includes("--yes") || args.includes("-y") });
|
|
1177
1430
|
process.exit(0);
|
|
1178
1431
|
}
|
|
1179
1432
|
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.8",
|
|
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
|
},
|