znode-plugin 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -292,7 +292,8 @@ Run them with `npm run <name>` inside a project (the scripts call `znode … .`)
292
292
  | | `npx znode install <zip> [pluginsDir]` | install a released zip into a store's plugin folder |
293
293
  | | `npx znode create <theme\|plugin\|widget\|content> <name>` | a minimal starter of another kind |
294
294
  | | `npx znode eject` | list the store components a package can take over (each has a slot that hands the component over) |
295
- | | `npx znode eject <Name> [--force]` | 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 |
295
+ | | `npx znode add-slot <Component> <area.name> [--dry-run] [--file <store file>]` | add a slot around a store component that has none, in the **linked store's source** (not the theme, never `.next`): a `Runtime<Component>` wrapper in `packages/extensions/src/slots/RuntimeAddedSlots.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>`. `add slot` (with a space) works too |
296
+ | | `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 |
296
297
 
297
298
  The plugin folder is taken from: the command's argument, else the `RUNTIME_PLUGINS_DIR` environment variable, else
298
299
  `znode.local.json`.
@@ -17,6 +17,9 @@
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)
21
+ // znode add-slot <Component> <area.name> [--dry-run] [--file <f>] a slot around a store component, written into
22
+ // the linked store's source (base developers; review and commit there)
20
23
  //
21
24
  // A package is a folder with znode-package.json (README.md shows a whole project, as create-znode-plugin makes it):
22
25
  // {
@@ -697,8 +700,38 @@ const EJECT_SLOTS = {
697
700
  RuntimeShippingOptions: "checkout.shippingOptions",
698
701
  };
699
702
 
703
+ // the slot each store wrapper renders: every `export function Runtime<X>` in packages/extensions/src/slots with the first
704
+ // <RuntimeSlot name="…"> in its body (so a slot added with `znode add-slot` is ejectable with no SDK release); the
705
+ // hand-kept EJECT_SLOTS above fill in for a store that predates this
706
+ function storeWrapperSlots(root) {
707
+ const slots = { ...EJECT_SLOTS };
708
+ const dir = path.join(root, "packages/extensions/src/slots");
709
+ if (!fs.existsSync(dir)) return slots;
710
+ for (const entry of fs.readdirSync(dir)) {
711
+ if (!/\.tsx$/.test(entry)) continue;
712
+ const text = fs.readFileSync(path.join(dir, entry), "utf8");
713
+ const starts = [...text.matchAll(/export\s+function\s+(Runtime\w+)/g)];
714
+ starts.forEach((m, i) => {
715
+ const body = text.slice(m.index, i + 1 < starts.length ? starts[i + 1].index : undefined);
716
+ // the first slot that is handed the component as children (<RuntimeSlot name="…">…{children}…</RuntimeSlot>); a
717
+ // layout slot (self-closing, the base rendered hidden beside it) cannot take an ejected copy
718
+ for (const open of body.matchAll(/<RuntimeSlot\s+name=["']([\w.]+)["']/g)) {
719
+ const rest = body.slice(open.index + open[0].length);
720
+ const close = rest.indexOf("</RuntimeSlot>");
721
+ const next = rest.indexOf("<RuntimeSlot");
722
+ if (close >= 0 && (next < 0 || close < next) && /\{\s*children\s*\}/.test(rest.slice(0, close))) {
723
+ if (!slots[m[1]]) slots[m[1]] = open[1];
724
+ break;
725
+ }
726
+ }
727
+ });
728
+ }
729
+ return slots;
730
+ }
731
+
700
732
  // every withRuntimeWrapper(<Wrapper>, <Base>, "<Name>") in the store's base components, with the slot it fills
701
733
  function ejectableComponents(root) {
734
+ const EJECT_SLOTS = storeWrapperSlots(root);
702
735
  const found = [];
703
736
  const walk = (dir) => {
704
737
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -748,8 +781,13 @@ function eject(name, force) {
748
781
  console.log("\nznode eject <Name> copies one into src/components/ and registers it in src/browser.tsx");
749
782
  return;
750
783
  }
751
- const c = components.find((item) => item.name.toLowerCase() === name.toLowerCase());
752
- if (!c) fail(`${name} cannot be ejected: it has no slot that hands over the component (znode eject lists the ones that can)`);
784
+ const c = components.find((item) => item.name.toLowerCase() === name.toLowerCase() || item.slot.toLowerCase() === name.toLowerCase());
785
+ if (!c)
786
+ fail(
787
+ `${name} cannot be ejected: no store component with that name or slot hands itself over to a slot.\n` +
788
+ ` znode eject lists the ones that can (component name or slot name both work)\n` +
789
+ ` znode add-slot ${/^[A-Z]/.test(name) ? name : "<Component>"} <area.name> adds a slot for a store component (edits the linked store's source)`
790
+ );
753
791
 
754
792
  const out = path.join(project, "src", "components", `${c.name}.tsx`);
755
793
  if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${c.name} --force`);
@@ -839,6 +877,170 @@ function eject(name, force) {
839
877
  console.log(`\nNext: npm run build (it names any store import a package cannot use), then npm run dev and open a page that shows ${c.name}.`);
840
878
  }
841
879
 
880
+ // ---- add-slot: a runtime slot around a store component, written into the linked store's source ------------------
881
+ // `znode add-slot <Component> <area.name>` does in the store what a base developer did by hand for product.basicInfo:
882
+ // a Runtime<Component> wrapper (packages/extensions/src/slots/RuntimeAddedSlots.tsx) that renders <RuntimeSlot name>
883
+ // with the component's props and the component itself as children, and the component's export wrapped with
884
+ // withRuntimeWrapper, so every place that renders it gets the slot. It edits source only (never .next, never git):
885
+ // review the diff, commit it through the store's own flow, rebuild the webstore; then `znode eject <Component>` works.
886
+ const ADDED_SLOTS_FILE = "packages/extensions/src/slots/RuntimeAddedSlots.tsx";
887
+ const SLOT_NAME = /^[a-z][A-Za-z0-9]*(\.[a-z][A-Za-z0-9]*)+$/;
888
+
889
+ function storeSourceFiles(root) {
890
+ const files = [];
891
+ const walk = (dir) => {
892
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
893
+ const file = path.join(dir, entry.name);
894
+ if (entry.isDirectory()) {
895
+ if (entry.name !== "node_modules" && !entry.name.startsWith("__")) walk(file);
896
+ } else if (/\.tsx$/.test(entry.name) && !/\.(test|spec|stories)\.tsx$/.test(entry.name)) files.push(file);
897
+ }
898
+ };
899
+ for (const dir of ["packages/base-components/src", "packages/page-builder/src"]) if (fs.existsSync(path.join(root, dir))) walk(path.join(root, dir));
900
+ return files;
901
+ }
902
+
903
+ function addSlot(name, slot, { dryRun, file: chosen }) {
904
+ const usage = "usage: znode add-slot <Component> <area.name> [--dry-run] [--file <store file>] e.g. znode add-slot ProductBasicInfo product.basicInfo";
905
+ if (!name || !slot) fail(usage);
906
+ if (!/^[A-Z]\w*$/.test(name)) fail(`"${name}" is not a component name (PascalCase, e.g. ProductDetails)\n${usage}`);
907
+ if (!SLOT_NAME.test(slot)) fail(`"${slot}" is not a slot name: area.name in camelCase, e.g. product.details`);
908
+ const store = linkedStore(path.resolve("."));
909
+ if (!store) fail("not linked to a store: run it in a package linked with npm run link-store -- <store repo> (add-slot edits that store's source)");
910
+ const root = store.root;
911
+ const rel = (file) => path.relative(root, file).split(path.sep).join("/");
912
+
913
+ // the slot must be new
914
+ const slotsDir = path.join(root, "packages/extensions/src/slots");
915
+ if (!fs.existsSync(slotsDir)) fail(`${rel(slotsDir)} not found: this store has no runtime slots (packages/extensions)`);
916
+ const wrapperSlots = storeWrapperSlots(root);
917
+ const taken = Object.entries(wrapperSlots).find(([, value]) => value === slot);
918
+ if (taken) fail(`slot "${slot}" already exists (${taken[0]}); znode eject lists what it hands over`);
919
+ const wrapper = `Runtime${name}`;
920
+ if (wrapperSlots[wrapper] || fs.readdirSync(slotsDir).some((f) => new RegExp(`export\\s+function\\s+${wrapper}\\b`).test(fs.readFileSync(path.join(slotsDir, f), "utf8"))))
921
+ fail(`${wrapper} already exists in ${rel(slotsDir)}: ${name} already has a slot (znode eject lists it)`);
922
+
923
+ // the file that defines the component
924
+ const DEFINES = new RegExp(`^(export\\s+(?:default\\s+)?)?(async\\s+)?function\\s+${name}\\s*[(<]|^(export\\s+)?const\\s+${name}\\s*(?::[^=]+)?=\\s*(async\\b)?`, "m");
925
+ let candidates = chosen ? [path.resolve(root, chosen)] : storeSourceFiles(root).filter((f) => DEFINES.test(fs.readFileSync(f, "utf8")));
926
+ if (chosen && !fs.existsSync(candidates[0])) fail(`${chosen} not found in ${root}`);
927
+ if (candidates.length > 1) {
928
+ const named = candidates.filter((f) => path.basename(f, ".tsx") === name);
929
+ if (named.length === 1) candidates = named;
930
+ else fail(`${name} is defined in more than one store file; pick one with --file:\n ${candidates.map(rel).join("\n ")}`);
931
+ }
932
+ if (!candidates.length) fail(`no ${name} component found in packages/base-components/src or packages/page-builder/src (only .tsx components can get a slot)`);
933
+ const target = candidates[0];
934
+ let source = fs.readFileSync(target, "utf8");
935
+ const def = source.match(DEFINES);
936
+ if (!def) fail(`${rel(target)} does not define ${name} as a function or const component`);
937
+
938
+ // only a client-renderable component: a slot is filled in the browser
939
+ if (new RegExp(`withRuntimeWrapper\\([^)]*["']${name}["']`).test(source)) fail(`${name} is already wrapped (${rel(target)}): it has a slot, znode eject lists it`);
940
+ if (def[2] || def[4])
941
+ fail(
942
+ `${name} is an async server component (${rel(target)}): a runtime slot is filled in the browser, so it cannot wrap a server component.\n` +
943
+ ` Add the slot to a client component it renders instead (e.g. its ProductBasicInfo), or split ${name} into a server part (data) and a client part (UI) and add the slot to the client part.`
944
+ );
945
+ const serverImport = source.match(/^\s*import\b[^;]*["'](server-only|next\/headers|fs|node:fs)["']/m);
946
+ if (serverImport) fail(`${name} imports ${serverImport[1]} (${rel(target)}): it runs on the server only, so a browser slot cannot wrap it`);
947
+
948
+ // wrap the export: <Name>Base is the component, <Name> is the wrapped one every caller already imports
949
+ const baseName = `${name}Base`;
950
+ if (new RegExp(`\\b${baseName}\\b`).test(source)) fail(`${rel(target)} already has a ${baseName}: wrap it by hand`);
951
+ const wrapped = `withRuntimeWrapper(${wrapper}, ${baseName}, "${name}")`;
952
+ const comment = `// runtime packages render around it through the slot "${slot}" (znode add-slot); with none, it renders as it is`;
953
+ if (/^export\s+default\s+/.test(def[0])) {
954
+ source = source.replace(def[0], def[0].replace(/^export\s+default\s+/, "").replace(name, baseName));
955
+ source = source.trimEnd() + `\n\n${comment}\nconst ${name} = ${wrapped};\n\nexport default ${name};\n`;
956
+ } else if (def[1] || def[3]) {
957
+ source = source.replace(def[0], def[0].replace(/^export\s+/, "").replace(name, baseName));
958
+ source = source.trimEnd() + `\n\n${comment}\nexport const ${name} = ${wrapped};\n`;
959
+ } else {
960
+ // defined, exported later (export default Name; / export { Name }): the wrapped const goes right before that export
961
+ source = source.replace(def[0], def[0].replace(name, baseName));
962
+ const exportAt = source.search(new RegExp(`^export\\s+(default\\s+${name}\\b|\\{[^}]*\\b${name}\\b)`, "m"));
963
+ const line = `${comment}\nconst ${name} = ${wrapped};\n\n`;
964
+ source = exportAt >= 0 ? source.slice(0, exportAt) + line + source.slice(exportAt) : source.trimEnd() + `\n\n${line}`;
965
+ }
966
+ // statics set on the component belong to the base (they run before the wrapped const exists)
967
+ source = source.replace(new RegExp(`\\b${name}\\.(displayName|defaultProps|propTypes)\\b`, "g"), `${baseName}.$1`);
968
+ const imports = [`import { withRuntimeWrapper } from "@znode/extensions/slots/with-runtime-wrapper";`, `import { ${wrapper} } from "@znode/extensions/slots/RuntimeAddedSlots";`].filter(
969
+ (line) => !(line.includes("withRuntimeWrapper }") && /import\s*\{[^}]*\bwithRuntimeWrapper\b/.test(source))
970
+ );
971
+ const directive = source.match(/^\s*["']use (client|server)["'];?\s*\n/);
972
+ const at = directive ? directive[0].length : 0;
973
+ source = source.slice(0, at) + (directive ? "\n" : "") + imports.join("\n") + "\n" + (directive ? "" : "\n") + source.slice(at).replace(/^\n+/, directive ? "" : "");
974
+
975
+ // the wrapper
976
+ const addedFile = path.join(root, ADDED_SLOTS_FILE);
977
+ const addedBefore = fs.existsSync(addedFile) ? fs.readFileSync(addedFile, "utf8") : null;
978
+ let added =
979
+ addedBefore ??
980
+ `"use client";\n\n// Slots added with \`znode add-slot <Component> <area.name>\` (znode-plugin): each Runtime<Component> renders the slot\n// around the store component (withRuntimeWrapper where the component is defined), with the component's props and the\n// component itself as children. With no runtime package for the slot, the component renders as it is.\n\nimport { ReactNode } from "react";\nimport { RuntimeSlot } from "../client/RuntimeSlot";\n`;
981
+ added =
982
+ added.trimEnd() +
983
+ `\n\n/** ${slot}: the store's ${name} (children), with every prop it gets. Added with \`znode add-slot ${name} ${slot}\` (${new Date().toISOString().slice(0, 10)}). */\n` +
984
+ `export function ${wrapper}({ children, ...props }: Readonly<Record<string, unknown> & { children?: ReactNode }>) {\n` +
985
+ ` return (\n <RuntimeSlot name="${slot}" props={props}>\n {children}\n </RuntimeSlot>\n );\n}\n`;
986
+
987
+ const changes = [
988
+ { file: target, before: fs.readFileSync(target, "utf8"), after: source },
989
+ { file: addedFile, before: addedBefore, after: added },
990
+ ];
991
+ const warnings = [];
992
+
993
+ // the slot list in the SDK types of the store (packages/extensions/src/types.ts), when it has one
994
+ const typesFile = path.join(root, "packages/extensions/src/types.ts");
995
+ if (fs.existsSync(typesFile)) {
996
+ const before = fs.readFileSync(typesFile, "utf8");
997
+ const lines = before.split("\n");
998
+ const start = lines.findIndex((line) => /^\s*\/\/\s*Slots\s*\(/.test(line));
999
+ if (start >= 0) {
1000
+ let end = start + 1;
1001
+ while (end < lines.length && /^\s*\/\/\s{3,}/.test(lines[end])) end++;
1002
+ lines.splice(end, 0, `// ${slot.padEnd(20)} the store's ${name} itself (children) props: every prop ${name} gets (znode add-slot)`);
1003
+ changes.push({ file: typesFile, before, after: lines.join("\n") });
1004
+ } else warnings.push(`${rel(typesFile)}: no "// Slots (" comment found; add "${slot}" to the slot list yourself`);
1005
+ }
1006
+ // the slot table in docs/RUNTIME_EXTENSIONS_AGENTS.md, when it has one
1007
+ const docsFile = path.join(root, "docs/RUNTIME_EXTENSIONS_AGENTS.md");
1008
+ if (fs.existsSync(docsFile)) {
1009
+ const before = fs.readFileSync(docsFile, "utf8");
1010
+ const lines = before.split("\n");
1011
+ let row = lines.findIndex((line) => line.startsWith("| `product.card`"));
1012
+ if (row >= 0) {
1013
+ while (row + 1 < lines.length && lines[row + 1].startsWith("|")) row++;
1014
+ lines.splice(row + 1, 0, `| \`${slot}\` | \`${wrapper}\` (\`${ADDED_SLOTS_FILE.replace("packages/extensions/src/", "")}\`) | \`${rel(target).replace(/^packages\/[\w-]+\/src\//, "").replace(/\.tsx$/, "")}\` (its children: the component itself) | every ${name} prop; keep \`children\`, wrap it, or render a copy instead (\`znode eject ${name}\`) |`);
1015
+ changes.push({ file: docsFile, before, after: lines.join("\n") });
1016
+ } else warnings.push(`${rel(docsFile)}: slot table not found; add a "${slot}" row yourself`);
1017
+ }
1018
+
1019
+ console.log(`${dryRun ? "would add" : "adding"} slot "${slot}" around ${name} (${rel(target)}) in ${root}\n`);
1020
+ for (const { file, before, after } of changes) {
1021
+ const old = new Set((before || "").split("\n"));
1022
+ const added = after.split("\n").filter((line) => !old.has(line));
1023
+ console.log(`${before === null ? "new " : "edit"} ${rel(file)}`);
1024
+ for (const line of added) console.log(` + ${line}`);
1025
+ }
1026
+ if (dryRun) {
1027
+ console.log("\n--dry-run: nothing written. Run it again without --dry-run to write these changes.");
1028
+ return;
1029
+ }
1030
+ for (const { file, after } of changes) fs.writeFileSync(file, after);
1031
+
1032
+ // the store's module registry, when the store generates one (the ejected copy's imports are checked against it)
1033
+ const registry = path.join(root, "scripts/gen-store-module-registry.mjs");
1034
+ if (fs.existsSync(registry)) spawnSync(process.execPath, [registry], { cwd: root, stdio: "inherit" });
1035
+ for (const warning of warnings) console.log(`warning ${warning}`);
1036
+ console.log(
1037
+ `\nDone (the store's source only: nothing committed, .next untouched). Next:\n` +
1038
+ ` 1. review the diff in ${root} (git diff) and commit it through the store's own flow (hooks, review: it edits base components)\n` +
1039
+ ` 2. rebuild / restart the webstore so it renders the slot\n` +
1040
+ ` 3. here: npx znode eject ${name} (or fill "${slot}" in src/browser.tsx with a component of your own)`
1041
+ );
1042
+ }
1043
+
842
1044
  const args = process.argv.slice(2);
843
1045
  // the store's plugin folder: the argument, else RUNTIME_PLUGINS_DIR, else the project's git-ignored znode.local.json
844
1046
  // ({ "pluginsDir": "…" }, written by create-znode-plugin --plugins-dir) in the folder the command runs in
@@ -858,6 +1060,14 @@ if (command === "eject") {
858
1060
  eject(args.find((arg, index) => index > 0 && !arg.startsWith("--")), args.includes("--force"));
859
1061
  process.exit(0);
860
1062
  }
1063
+ if (command === "add-slot" || (command === "add" && args[1] === "slot")) {
1064
+ const rest = args.slice(command === "add" ? 2 : 1).filter((arg) => arg !== "--");
1065
+ const fileAt = rest.indexOf("--file");
1066
+ const file = fileAt >= 0 ? rest[fileAt + 1] : undefined;
1067
+ const positional = rest.filter((arg, index) => !arg.startsWith("--") && !(fileAt >= 0 && index === fileAt + 1));
1068
+ addSlot(positional[0], positional[1], { dryRun: rest.includes("--dry-run"), file });
1069
+ process.exit(0);
1070
+ }
861
1071
  if (command === "link-store") {
862
1072
  if (!target) fail("usage: znode link-store <storeRepoDir> [projectDir]");
863
1073
  linkStore(target, args[2]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "znode-plugin",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",