znode-plugin 0.1.9 → 0.1.10

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.
Files changed (2) hide show
  1. package/lib/znode-package.mjs +192 -42
  2. package/package.json +1 -1
@@ -49,7 +49,7 @@
49
49
  import fs from "node:fs";
50
50
  import path from "node:path";
51
51
  import { execFileSync, spawnSync } from "node:child_process";
52
- import { createRequire } from "node:module";
52
+ import { builtinModules, createRequire } from "node:module";
53
53
  import { fileURLToPath, pathToFileURL } from "node:url";
54
54
  import vm from "node:vm";
55
55
  import { TEMPLATES } from "./templates.mjs";
@@ -235,6 +235,24 @@ function build(target) {
235
235
  // the store modules its server part imports (the store's server registry, permission storeServer)
236
236
  const serverStoreImports = [];
237
237
  const store = linkedStore(dir);
238
+ // the npm packages its sources import: those the store does not share and the package does not have yet are installed
239
+ // at the store's version before anything compiles (--no-install: named instead); another version than the store's warns
240
+ if (store) {
241
+ const sources = [];
242
+ const walk = (folder) => {
243
+ if (!fs.existsSync(folder)) return;
244
+ for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
245
+ const file = path.join(folder, entry.name);
246
+ if (entry.isDirectory()) walk(file);
247
+ else if (/\.(tsx?|jsx?|mjs)$/.test(entry.name)) sources.push(file);
248
+ }
249
+ };
250
+ walk(path.join(dir, "src"));
251
+ const specs = sources.flatMap((file) => npmImportsOf(fs.readFileSync(file, "utf8")));
252
+ ensureStorePackages(dir, store, specs, { install: !process.argv.includes("--no-install") });
253
+ const drift = versionDrift(dir, store, specs);
254
+ if (drift.length) console.log(`warning installed at another version than the store's: ${drift.join(", ")} (npm install --save-exact <pkg>@<store version>)`);
255
+ }
238
256
  // A package with its own tsconfig.json (extending the store's tsconfig.base.json) is checked against the store's real
239
257
  // types (every @znode alias, no declarations to write), then compiled file by file without type information, JSX as
240
258
  // Next.js does (react/jsx-runtime: the store's). Without one, the package compiles as before (tsc, JSX factory h).
@@ -704,12 +722,134 @@ function linkStore(storeRepo, project = ".") {
704
722
  const options = config.compilerOptions || {};
705
723
  const toSlash = (value) => value.split(path.sep).join("/");
706
724
  const file = path.resolve(project, "tsconfig.store.json");
725
+ // the npm packages the store shares at runtime (next-intl, lodash, ...): never installed in the package (the build uses
726
+ // the store's copy), so their types come from the store's own node_modules, at the store's version
727
+ const repo = path.dirname(base);
728
+ const sharedPaths = {};
729
+ for (const pkg of storeSharedPackages(repo)) {
730
+ const types = path.join(repo, "node_modules", "@types", pkg.replace(/^@/, "").replace("/", "__"));
731
+ const dir = path.join(repo, "node_modules", pkg);
732
+ if (fs.existsSync(path.join(types, "package.json"))) sharedPaths[pkg] = [toSlash(types)];
733
+ else if (fs.existsSync(path.join(dir, "package.json"))) {
734
+ sharedPaths[pkg] = [toSlash(dir)];
735
+ sharedPaths[`${pkg}/*`] = [`${toSlash(dir)}/*`];
736
+ }
737
+ }
707
738
  const linked = {
708
739
  extends: "znode-plugin/tsconfig.json",
709
- compilerOptions: { baseUrl: toSlash(path.resolve(path.dirname(base), options.baseUrl || ".")), paths: options.paths || {} },
740
+ compilerOptions: { baseUrl: toSlash(path.resolve(repo, options.baseUrl || ".")), paths: { ...sharedPaths, ...(options.paths || {}) } },
710
741
  };
711
742
  fs.writeFileSync(file, JSON.stringify(linked, null, 2) + "\n");
712
- console.log(`linked ${file} to ${base} (${Object.keys(linked.compilerOptions.paths).length} store aliases)`);
743
+ const shared = Object.keys(sharedPaths).filter((key) => !key.endsWith("/*"));
744
+ console.log(
745
+ `linked ${file} to ${base} (${Object.keys(options.paths || {}).length} store aliases${shared.length ? `; types of the packages the store shares: ${shared.join(", ")}` : ""})`
746
+ );
747
+ }
748
+
749
+ // ---- the store's npm packages: shared at runtime, or installed in the package at the store's version -------------------
750
+ // react, react-dom, next and @znode/* are the store's at runtime (stand-ins, never bundled); so are the store's shared
751
+ // packages (its registry's sharedPackages: next-intl, lodash, ...). Any other npm package a package imports is bundled
752
+ // into it, so it must be installed in the package: at the version the store has installed, so a copied store component
753
+ // behaves as it does in the store. Installed exactly (no ^), from the npm registry, by eject and by build.
754
+ const RUNTIME_PROVIDED = /^(react|react-dom|next|@znode)(\/|$)/;
755
+ const DEFAULT_SHARED = ["lodash", "next-intl"];
756
+ const packageNameOf = (spec) => (spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]);
757
+
758
+ function storeSharedPackages(root) {
759
+ for (const name of ["store-module-registry.json", "store-server-module-registry.json"]) {
760
+ const file = path.join(root, "packages/extensions", name);
761
+ try {
762
+ const list = readJson(file).sharedPackages;
763
+ if (Array.isArray(list) && list.length) return list.map(String);
764
+ } catch {
765
+ // no registry, or one from before sharedPackages: the default list
766
+ }
767
+ }
768
+ return DEFAULT_SHARED;
769
+ }
770
+
771
+ // the version the store has installed (its node_modules), else its package.json range without ^ / ~
772
+ function storePackageVersion(root, pkg) {
773
+ try {
774
+ return readJson(path.join(root, "node_modules", pkg, "package.json")).version;
775
+ } catch {
776
+ try {
777
+ const manifest = readJson(path.join(root, "package.json"));
778
+ const range = { ...(manifest.devDependencies || {}), ...(manifest.dependencies || {}) }[pkg];
779
+ return range ? range.replace(/^[\^~]/, "") : null;
780
+ } catch {
781
+ return null;
782
+ }
783
+ }
784
+ }
785
+
786
+ const installedIn = (project, pkg) => fs.existsSync(path.join(project, "node_modules", pkg, "package.json"));
787
+
788
+ // the npm packages code imports (bare specifiers, type-only imports included: their types are needed too)
789
+ const npmImportsOf = (code) =>
790
+ [...code.matchAll(/(?:^|[^\w$.])(?:import|export)\s*(?:[^"';]*?\s+from\s*)?["']([^"'.][^"']*)["']|import\(\s*["']([^"'.][^"']*)["']\s*\)/g)]
791
+ .map((match) => match[1] || match[2])
792
+ .filter((spec) => !spec.startsWith("node:") && !builtinModules.includes(packageNameOf(spec)));
793
+
794
+ /**
795
+ * The npm packages `specs` need in the package at `project`: the store's runtime and shared packages are left out, the
796
+ * others not installed yet are installed at the store's version (with their @types package when the store has one), or
797
+ * only named when install is false. Returns what was done, for the caller's message.
798
+ */
799
+ function ensureStorePackages(project, store, specs, { install = true } = {}) {
800
+ const root = store.root;
801
+ const shared = new Set(storeSharedPackages(root));
802
+ const wanted = new Map();
803
+ const typesWanted = new Map();
804
+ const notInStore = new Set();
805
+ const sharedUsed = new Set();
806
+ for (const pkg of new Set(specs.map(packageNameOf))) {
807
+ if (RUNTIME_PROVIDED.test(pkg)) continue;
808
+ if (shared.has(pkg)) {
809
+ sharedUsed.add(pkg);
810
+ continue;
811
+ }
812
+ if (installedIn(project, pkg)) continue;
813
+ const version = storePackageVersion(root, pkg);
814
+ if (!version) {
815
+ notInStore.add(pkg);
816
+ continue;
817
+ }
818
+ wanted.set(pkg, `${pkg}@${version}`);
819
+ const typesName = `@types/${pkg.replace(/^@/, "").replace("/", "__")}`;
820
+ const typesVersion = fs.existsSync(path.join(root, "node_modules", typesName, "package.json")) ? storePackageVersion(root, typesName) : null;
821
+ if (typesVersion && !installedIn(project, typesName)) typesWanted.set(typesName, `${typesName}@${typesVersion}`);
822
+ }
823
+ const run = (args) => spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { cwd: project, stdio: "inherit", shell: process.platform === "win32" });
824
+ let installed = [];
825
+ if (wanted.size) {
826
+ const list = [...wanted.values()];
827
+ if (install) {
828
+ console.log(`installing the store's versions: ${list.join(" ")}`);
829
+ const result = run(["install", "--save-exact", "--no-audit", "--no-fund", ...list]);
830
+ if (result.status !== 0) fail(`npm install failed: run it yourself in ${project}: npm install --save-exact ${list.join(" ")}`);
831
+ if (typesWanted.size) run(["install", "--save-dev", "--save-exact", "--no-audit", "--no-fund", ...typesWanted.values()]);
832
+ installed = [...list, ...typesWanted.values()];
833
+ } else {
834
+ console.log(`needs the store's versions (not installed: --no-install): npm install --save-exact ${list.join(" ")}`);
835
+ if (typesWanted.size) console.log(` and its types: npm install --save-dev --save-exact ${[...typesWanted.values()].join(" ")}`);
836
+ }
837
+ }
838
+ for (const pkg of notInStore) console.log(`warning "${pkg}" is not a dependency of the store: install it yourself (npm install ${pkg})`);
839
+ return { installed, shared: [...sharedUsed], notInStore: [...notInStore] };
840
+ }
841
+
842
+ // packages installed in the package at another version than the store's (after a store upgrade): a warning, the store's
843
+ // version is what its components were written against
844
+ function versionDrift(project, store, specs) {
845
+ const drift = [];
846
+ for (const pkg of new Set(specs.map(packageNameOf))) {
847
+ if (RUNTIME_PROVIDED.test(pkg) || !installedIn(project, pkg)) continue;
848
+ const mine = readJson(path.join(project, "node_modules", pkg, "package.json")).version;
849
+ const theirs = fs.existsSync(path.join(store.root, "node_modules", pkg, "package.json")) ? storePackageVersion(store.root, pkg) : null;
850
+ if (theirs && mine !== theirs) drift.push(`${pkg} ${mine} (store: ${theirs})`);
851
+ }
852
+ return drift;
713
853
  }
714
854
 
715
855
  // ---- eject: a store component copied into the package, in place of the store's, through its slot ----------------
@@ -779,8 +919,25 @@ function ejectableComponents(root) {
779
919
  return found.sort((a, b) => a.name.localeCompare(b.name));
780
920
  }
781
921
 
782
- // a store file's import path as the package can import it: the store alias that maps to that file, without extension
783
- function storeAlias(store, absolute) {
922
+ // a store file that re-exports `target` (export * from / export { … } from), followed through nested barrels
923
+ function reExports(barrel, target, seen = new Set()) {
924
+ if (!barrel || seen.has(barrel) || !fs.existsSync(barrel)) return false;
925
+ seen.add(barrel);
926
+ const asFile = (base) =>
927
+ [base, `${base}.ts`, `${base}.tsx`, path.join(base, "index.ts"), path.join(base, "index.tsx")].find((file) => fs.existsSync(file) && fs.statSync(file).isFile());
928
+ for (const match of fs.readFileSync(barrel, "utf8").matchAll(/export\s*(?:\*|\{[^}]*\})\s*from\s*["'](\.{1,2}\/[^"']+)["']/g)) {
929
+ const file = asFile(path.resolve(path.dirname(barrel), match[1]));
930
+ if (!file) continue;
931
+ if (file.split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "") === target) return true;
932
+ if (reExports(file, target, seen)) return true;
933
+ }
934
+ return false;
935
+ }
936
+
937
+ // a store file's import path as the package can import it: the store alias that maps to that file, without extension;
938
+ // else, for named imports (statement: the import's text up to its path), an exact alias whose barrel re-exports the file
939
+ // (".../stores/modal" -> "@znode/base-components/stores"): export * does not carry a default, so a default import is not
940
+ function storeAlias(store, absolute, { statement = "" } = {}) {
784
941
  const target = absolute.split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
785
942
  let best = null;
786
943
  const norm = (value) => path.resolve(store.root, value).split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
@@ -795,7 +952,14 @@ function storeAlias(store, absolute) {
795
952
  if (target.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) best = { prefix, alias: alias.slice(0, -1) };
796
953
  }
797
954
  }
798
- return best ? best.alias + target.slice(best.prefix.length) : null;
955
+ if (best) return best.alias + target.slice(best.prefix.length);
956
+ const namedOnly = /^import\s*(?:type\s+)?\{/.test(statement.trim()) || /^export\s/.test(statement.trim());
957
+ if (!namedOnly) return null;
958
+ for (const [alias, targets] of Object.entries(store.paths)) {
959
+ if (alias.endsWith("/*") || !alias.startsWith("@znode/")) continue;
960
+ if (targets.some((pattern) => reExports(path.resolve(store.root, pattern), target))) return alias;
961
+ }
962
+ return null;
799
963
  }
800
964
 
801
965
  // a question on the terminal (only asked when stdin is a terminal); one line reader for the whole run, so answers
@@ -838,8 +1002,8 @@ function ejectRouteCopy(project, s, store) {
838
1002
  text = text.replace(new RegExp(`^(async\\s+)?function\\s+${s.base}\\s*\\(`, "m"), (whole, isAsync) => `export ${isAsync || ""}function ${name}(`);
839
1003
  // relative imports become the store's aliases (the package lives elsewhere)
840
1004
  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));
1005
+ text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after, offset, source) => {
1006
+ const alias = storeAlias(store, path.resolve(path.dirname(storeFile), spec), { statement: source.slice(source.lastIndexOf("import", offset), offset) });
843
1007
  if (!alias) {
844
1008
  warnings.push(`import ${spec}: no store alias for it; change it by hand`);
845
1009
  return whole;
@@ -886,7 +1050,7 @@ function ejectRouteCopy(project, s, store) {
886
1050
  return { text, warnings };
887
1051
  }
888
1052
 
889
- function ejectServerSlot(project, s, force, store) {
1053
+ function ejectServerSlot(project, s, force, store, noInstall = false) {
890
1054
  const name = s.page;
891
1055
  const out = path.join(project, "src", "server", "pages", `${name}.tsx`);
892
1056
  if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${name} --force`);
@@ -896,6 +1060,9 @@ function ejectServerSlot(project, s, force, store) {
896
1060
  fs.writeFileSync(out, text);
897
1061
  console.log(`ejected ${name} (a copy of ${s.files[0]}, server slot "${s.name}") -> ${path.relative(project, out)}`);
898
1062
  registerServerSlot(project, s, name);
1063
+ const deps = ensureStorePackages(project, store, npmImportsOf(text), { install: !noInstall });
1064
+ if (deps.installed.length) console.log(`installed (store versions): ${deps.installed.join(", ")}`);
1065
+ if (deps.shared.length) console.log(`shared by the store (not installed; types from the linked store): ${deps.shared.join(", ")}`);
899
1066
  for (const warning of warnings) console.log(`warning ${warning}`);
900
1067
  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
1068
  return;
@@ -954,7 +1121,7 @@ function registerServerSlot(project, s, name) {
954
1121
  console.log(`registered "${s.name}": ${local} in src/server.tsx (serverSlots)`);
955
1122
  }
956
1123
 
957
- async function eject(name, force, { slot: slotArg, yes } = {}) {
1124
+ async function eject(name, force, { slot: slotArg, yes, noInstall } = {}) {
958
1125
  const project = path.resolve(".");
959
1126
  if (!fs.existsSync(path.join(project, MANIFEST))) fail(`run znode eject in a package folder (the one with ${MANIFEST})`);
960
1127
  const store = linkedStore(project);
@@ -980,7 +1147,7 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
980
1147
  let c = find(components);
981
1148
  if (!c) {
982
1149
  const server = findServer();
983
- if (server) return ejectServerSlot(project, server, force, store);
1150
+ if (server) return ejectServerSlot(project, server, force, store, noInstall);
984
1151
  }
985
1152
  if (!c) {
986
1153
  // no slot hands this component over yet: offer to create one in the base store (what znode create-slot does), then eject
@@ -1012,7 +1179,7 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
1012
1179
  const server = findServer();
1013
1180
  if (server) {
1014
1181
  console.log("");
1015
- return ejectServerSlot(project, server, force, store);
1182
+ return ejectServerSlot(project, server, force, store, noInstall);
1016
1183
  }
1017
1184
  fail(`the slot "${slot}" was created, but ${name} is still not found as ejectable; check the store diff`);
1018
1185
  }
@@ -1024,15 +1191,15 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
1024
1191
  const warnings = [];
1025
1192
  let text = fs.readFileSync(c.file, "utf8");
1026
1193
  // relative imports become the store's aliases (the package lives elsewhere); a style file stays the store's
1027
- text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after) => {
1194
+ text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after, offset, source) => {
1028
1195
  const absolute = path.resolve(path.dirname(c.file), spec);
1029
1196
  if (/\.(s?css)$/.test(spec)) {
1030
1197
  warnings.push(`style import ${spec}: kept out of the copy; the store's own stylesheet is loaded where the store renders it, add your CSS to src/styles/`);
1031
1198
  return `${before}${spec}${after}`;
1032
1199
  }
1033
- const alias = storeAlias(store, fs.existsSync(absolute) || /\.\w+$/.test(spec) ? absolute : absolute);
1200
+ const alias = storeAlias(store, absolute, { statement: source.slice(source.lastIndexOf("import", offset), offset) });
1034
1201
  if (!alias) {
1035
- warnings.push(`import ${spec}: no store alias for it; change it by hand`);
1202
+ warnings.push(`import ${spec}: no store alias for it (nor a store barrel that re-exports its names); change it by hand`);
1036
1203
  return whole;
1037
1204
  }
1038
1205
  return `${before}${alias}${after}`;
@@ -1051,32 +1218,11 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
1051
1218
  fs.writeFileSync(out, text);
1052
1219
  console.log(`ejected ${c.name} -> ${path.relative(project, out)}`);
1053
1220
 
1054
- // npm packages the copy imports that this package does not have (react, next and the store's @znode modules come from
1055
- // the store): install them at the store's version, so the types match and the build finds them
1056
- const PROVIDED = /^(react|react-dom|next|next-intl|@znode)(\/|$)/;
1057
- const storePackage = (() => {
1058
- try {
1059
- return readJson(path.join(store.root, "package.json"));
1060
- } catch {
1061
- return {};
1062
- }
1063
- })();
1064
- const storeDeps = { ...(storePackage.devDependencies || {}), ...(storePackage.dependencies || {}) };
1065
- const missing = new Map();
1066
- for (const m of text.matchAll(/(?:from|import)\s*\(?\s*["']([^."'/][^"']*)["']/g)) {
1067
- const spec = m[1];
1068
- if (PROVIDED.test(spec)) continue;
1069
- const pkg = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
1070
- try {
1071
- createRequire(path.join(project, "package.json")).resolve(`${pkg}/package.json`);
1072
- } catch {
1073
- missing.set(pkg, storeDeps[pkg] ? `${pkg}@${storeDeps[pkg].replace(/^[\^~]/, "")}` : pkg);
1074
- }
1075
- }
1076
- if (missing.size) {
1077
- console.log(`\nThe copy imports npm packages this package does not have (bundled into the package when it builds):`);
1078
- console.log(` npm install ${[...missing.values()].join(" ")}`);
1079
- }
1221
+ // npm packages the copy imports: the store's shared ones are used from the store (types through link-store), the others
1222
+ // are installed in this package at the store's version (bundled into the package when it builds)
1223
+ const deps = ensureStorePackages(project, store, npmImportsOf(text), { install: !noInstall });
1224
+ if (deps.installed.length) console.log(`installed (store versions): ${deps.installed.join(", ")}`);
1225
+ if (deps.shared.length) console.log(`shared by the store (not installed; types from the linked store): ${deps.shared.join(", ")}`);
1080
1226
 
1081
1227
  // register the copy under the slot in src/browser.tsx
1082
1228
  const browser = path.join(project, "src", "browser.tsx");
@@ -1518,7 +1664,11 @@ if (command === "eject") {
1518
1664
  // eject <Name> [--force] [--slot <area.name>] [--yes]: --slot / --yes create a missing slot without asking
1519
1665
  const slotAt = args.indexOf("--slot");
1520
1666
  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") });
1667
+ await eject(name, args.includes("--force"), {
1668
+ slot: slotAt >= 0 ? args[slotAt + 1] : undefined,
1669
+ yes: args.includes("--yes") || args.includes("-y"),
1670
+ noInstall: args.includes("--no-install"),
1671
+ });
1522
1672
  process.exit(0);
1523
1673
  }
1524
1674
  if (command === "link-store") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "znode-plugin",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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",