znode-plugin 0.1.9 → 0.1.11

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
@@ -248,11 +248,20 @@ package. The store then renders your copy in its place; everything else stays th
248
248
  npx znode eject ProductBasicInfo # or by its slot: npx znode eject product.basicInfo
249
249
  ```
250
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.
251
+ It copies the store's file to `src/components/ProductBasicInfo.tsx` and registers it in `src/browser.tsx` under its
252
+ slot. On the way:
253
+
254
+ - **Store imports** become the store's `@znode/...` aliases, or the store barrel that re-exports them
255
+ (`../../stores/modal` -> `@znode/base-components/stores`).
256
+ - **npm packages** the copy needs are installed at the **store's exact version** (`installed (store versions):
257
+ zustand@4.5.7`); the ones the store shares (`next-intl`, `lodash`) are not installed: the store's copy is used, and
258
+ `link-store` gives you their types. `--no-install` only prints the `npm install` line.
259
+ - **Images** it imports from the store (`../../assets/no-image.png`) are copied into `src/images/`. The build ships them
260
+ with the package and turns the import into `{ src, width, height }`, as Next.js does, so `<Image src={noImage}>` works
261
+ unchanged. Your own code can import images the same way (`import logo from "../images/logo.png"`).
262
+
263
+ Edit the copy, then `npm run build` and `npm run dev`. The copy is yours: store fixes to the component no longer reach
264
+ it, so compare it with the store's file after a store upgrade. `npx znode eject` with no name lists what can be ejected.
256
265
 
257
266
  **The component has no slot yet?** `eject` offers to create one in the **base store** (the store you linked), then ejects:
258
267
 
@@ -150,6 +150,40 @@ const VALUES = {
150
150
 
151
151
  const FONT_MODULE = "next/font/google";
152
152
  const VIRTUAL = "\0znode-next:";
153
+
154
+ // Image imports, as Next.js does them (import noImage from "./no-image.png"): the file goes into the package (dist; the
155
+ // store serves a package's files at /api/extensions/files/<file>) and the import becomes { src, width, height }, what
156
+ // next/image takes for a static import, so code written for the store works unchanged.
157
+ const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg|ico)$/i;
158
+ const FILES_URL = "/api/extensions/files";
159
+
160
+ // the image's size from its header (png, gif, jpeg, svg); 0 when it cannot be read (webp, avif, ico: pass width and
161
+ // height to next/image yourself)
162
+ function imageSize(b, ext) {
163
+ try {
164
+ if (ext === ".png" && b.readUInt32BE(0) === 0x89504e47) return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
165
+ if (ext === ".gif" && b.toString("ascii", 0, 3) === "GIF") return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) };
166
+ if ((ext === ".jpg" || ext === ".jpeg") && b[0] === 0xff && b[1] === 0xd8) {
167
+ for (let i = 2; i + 9 < b.length; ) {
168
+ if (b[i] !== 0xff) break;
169
+ const marker = b[i + 1];
170
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) };
171
+ i += 2 + b.readUInt16BE(i + 2);
172
+ }
173
+ }
174
+ if (ext === ".svg") {
175
+ const text = b.toString("utf8");
176
+ const tag = /<svg\b[^>]*>/i.exec(text)?.[0] || "";
177
+ const width = /\bwidth=["']([\d.]+)/.exec(tag)?.[1];
178
+ const height = /\bheight=["']([\d.]+)/.exec(tag)?.[1];
179
+ const box = /\bviewBox=["'][\d.\s,-]*?([\d.]+)[\s,]+([\d.]+)["']/.exec(tag);
180
+ return { width: Number(width || box?.[1] || 0), height: Number(height || box?.[2] || 0) };
181
+ }
182
+ } catch {
183
+ // a header too short to read: no size
184
+ }
185
+ return { width: 0, height: 0 };
186
+ }
153
187
  const CHROME = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
154
188
 
155
189
  // the stand-in module: each name reads the store's module when used, never at load (a server part is also loaded where
@@ -321,11 +355,22 @@ async function transformFonts(code, id, context) {
321
355
  * The rollup plugin. part: "server" | "browser"; prefix: "<name>-<version>"; outDir: dist; fonts: collects what
322
356
  * next/font/google built (files, css), which the build adds to the package's assets and stylesheet.
323
357
  */
324
- export function znodeNext({ part, prefix, outDir, fonts, store, storeImports = [] }) {
358
+ export function znodeNext({ part, prefix, outDir, fonts, store, storeImports = [], sourceRoot, buildRoot }) {
325
359
  return {
326
360
  name: "znode-next",
327
- resolveId(source) {
361
+ resolveId(source, importer) {
328
362
  if (MODULES[source]) return VIRTUAL + source;
363
+ // an image the package's own code imports: the build compiles src/ into .build/ (code only), so a relative image
364
+ // path is looked up next to the source file the compiled one came from
365
+ if (IMAGE.test(source) && importer && source.startsWith(".")) {
366
+ const beside = path.resolve(path.dirname(importer), source);
367
+ if (fs.existsSync(beside)) return beside;
368
+ if (sourceRoot && buildRoot) {
369
+ const inSource = path.resolve(sourceRoot, path.relative(buildRoot, path.dirname(importer)), source);
370
+ if (fs.existsSync(inSource)) return inSource;
371
+ }
372
+ this.error(`image "${source}" (imported by ${path.basename(importer)}) not found: put it under src/ (e.g. src/assets/) and import it from there`);
373
+ }
329
374
  if (SHARED_PACKAGES.has(source) || source.startsWith("@znode/") || source.startsWith(STORE_FILE)) {
330
375
  if (!store) this.error(`"${source}" is a store module: connect the store first (npm run link-store -- <store repo>), which writes tsconfig.store.json`);
331
376
  // a server part imports from the store's server registry (an allow-list: agents, texts and store context, logger,
@@ -356,6 +401,18 @@ export function znodeNext({ part, prefix, outDir, fonts, store, storeImports = [
356
401
  return null;
357
402
  },
358
403
  load(id) {
404
+ if (IMAGE.test(id) && fs.existsSync(id)) {
405
+ // one copy per image, named by the package and a hash of its content (all packages' files share the store's
406
+ // plugin folder); it goes into the package's assets through the fonts list, whose files the build ships
407
+ const content = fs.readFileSync(id);
408
+ const hash = crypto.createHash("sha1").update(content).digest("hex").slice(0, 8);
409
+ const ext = path.extname(id).toLowerCase();
410
+ const name = `${prefix}-${path.basename(id, path.extname(id)).replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}${ext}`;
411
+ fs.writeFileSync(path.join(outDir, name), content);
412
+ if (!fonts.some((font) => font.files?.includes(name))) fonts.push({ files: [name], css: "" });
413
+ const { width, height } = imageSize(content, ext);
414
+ return `export default ${JSON.stringify({ src: `${FILES_URL}/${name}`, width, height })};`;
415
+ }
359
416
  if (!id.startsWith(VIRTUAL)) return null;
360
417
  const name = id.slice(VIRTUAL.length);
361
418
  if (name.startsWith(STORE)) return { code: storeStandIn(name.slice(STORE.length), part), syntheticNamedExports: "__store" };
@@ -49,7 +49,8 @@
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
+ import { createHash } from "node:crypto";
53
54
  import { fileURLToPath, pathToFileURL } from "node:url";
54
55
  import vm from "node:vm";
55
56
  import { TEMPLATES } from "./templates.mjs";
@@ -235,6 +236,24 @@ function build(target) {
235
236
  // the store modules its server part imports (the store's server registry, permission storeServer)
236
237
  const serverStoreImports = [];
237
238
  const store = linkedStore(dir);
239
+ // the npm packages its sources import: those the store does not share and the package does not have yet are installed
240
+ // at the store's version before anything compiles (--no-install: named instead); another version than the store's warns
241
+ if (store) {
242
+ const sources = [];
243
+ const walk = (folder) => {
244
+ if (!fs.existsSync(folder)) return;
245
+ for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
246
+ const file = path.join(folder, entry.name);
247
+ if (entry.isDirectory()) walk(file);
248
+ else if (/\.(tsx?|jsx?|mjs)$/.test(entry.name)) sources.push(file);
249
+ }
250
+ };
251
+ walk(path.join(dir, "src"));
252
+ const specs = sources.flatMap((file) => npmImportsOf(fs.readFileSync(file, "utf8")));
253
+ ensureStorePackages(dir, store, specs, { install: !process.argv.includes("--no-install") });
254
+ const drift = versionDrift(dir, store, specs);
255
+ if (drift.length) console.log(`warning installed at another version than the store's: ${drift.join(", ")} (npm install --save-exact <pkg>@<store version>)`);
256
+ }
238
257
  // A package with its own tsconfig.json (extending the store's tsconfig.base.json) is checked against the store's real
239
258
  // types (every @znode alias, no declarations to write), then compiled file by file without type information, JSX as
240
259
  // Next.js does (react/jsx-runtime: the store's). Without one, the package compiles as before (tsc, JSX factory h).
@@ -295,7 +314,8 @@ function build(target) {
295
314
  ` input: ${JSON.stringify(compiled)},`,
296
315
  ` output: { file: ${JSON.stringify(path.join(out, target))}, format: "es", sourcemap: true },`,
297
316
  ` plugins: [`,
298
- ` znodeNext({ part: ${JSON.stringify(part)}, prefix: ${JSON.stringify(prefix)}, outDir: ${JSON.stringify(out)}, fonts, store, storeImports }),`,
317
+ // sourceRoot / buildRoot: an image a compiled file imports is found next to its source file (src/ -> .build/)
318
+ ` znodeNext({ part: ${JSON.stringify(part)}, prefix: ${JSON.stringify(prefix)}, outDir: ${JSON.stringify(out)}, fonts, store, storeImports, sourceRoot: ${JSON.stringify(path.dirname(path.join(dir, entry)))}, buildRoot: ${JSON.stringify(tmp)} }),`,
299
319
  ` { name: "fonts-out", writeBundle() { fs.writeFileSync(${JSON.stringify(fontsFile)}, JSON.stringify(fonts)); fs.writeFileSync(${JSON.stringify(storeImportsFile)}, JSON.stringify(storeImports)); } },`,
300
320
  ` ],`,
301
321
  `};`,
@@ -311,9 +331,10 @@ function build(target) {
311
331
 
312
332
  // styles: concatenated into one stylesheet, after the fonts next/font/google built; assets (fonts, images) keep their
313
333
  // names, which the stylesheet refers to
314
- if (manifest.styles?.length || fonts.length) {
334
+ // (the fonts list also carries the images the code imports, with no CSS: next-imports.mjs)
335
+ if (manifest.styles?.length || fonts.some((font) => font.css)) {
315
336
  const css = [
316
- ...fonts.map((font) => font.css),
337
+ ...fonts.map((font) => font.css).filter(Boolean),
317
338
  ...(manifest.styles || []).flatMap((pattern) => glob(dir, pattern)).map((file) => fs.readFileSync(file, "utf8")),
318
339
  ].join("\n");
319
340
  files.styles = [`${prefix}.css`];
@@ -551,7 +572,10 @@ function create(kind, name, dir) {
551
572
  fs.mkdirSync(path.dirname(full), { recursive: true });
552
573
  fs.writeFileSync(full, typeof content === "string" ? content : JSON.stringify(content, null, 2) + "\n");
553
574
  }
554
- if (kind !== "content") fs.copyFileSync(path.join(HERE, "..", "types", "extension-api.d.ts"), path.join(target, "src", "extension-api.d.ts"));
575
+ if (kind !== "content") {
576
+ // the API types, with the image-import types they reference
577
+ for (const file of ["extension-api.d.ts", "images.d.ts"]) fs.copyFileSync(path.join(HERE, "..", "types", file), path.join(target, "src", file));
578
+ }
555
579
  console.log(`created ${kind} ${name} in ${target}`);
556
580
  console.log(" next: fill in label, publisher and description in znode-package.json, then run its build-and-upload.sh");
557
581
  }
@@ -704,12 +728,134 @@ function linkStore(storeRepo, project = ".") {
704
728
  const options = config.compilerOptions || {};
705
729
  const toSlash = (value) => value.split(path.sep).join("/");
706
730
  const file = path.resolve(project, "tsconfig.store.json");
731
+ // the npm packages the store shares at runtime (next-intl, lodash, ...): never installed in the package (the build uses
732
+ // the store's copy), so their types come from the store's own node_modules, at the store's version
733
+ const repo = path.dirname(base);
734
+ const sharedPaths = {};
735
+ for (const pkg of storeSharedPackages(repo)) {
736
+ const types = path.join(repo, "node_modules", "@types", pkg.replace(/^@/, "").replace("/", "__"));
737
+ const dir = path.join(repo, "node_modules", pkg);
738
+ if (fs.existsSync(path.join(types, "package.json"))) sharedPaths[pkg] = [toSlash(types)];
739
+ else if (fs.existsSync(path.join(dir, "package.json"))) {
740
+ sharedPaths[pkg] = [toSlash(dir)];
741
+ sharedPaths[`${pkg}/*`] = [`${toSlash(dir)}/*`];
742
+ }
743
+ }
707
744
  const linked = {
708
745
  extends: "znode-plugin/tsconfig.json",
709
- compilerOptions: { baseUrl: toSlash(path.resolve(path.dirname(base), options.baseUrl || ".")), paths: options.paths || {} },
746
+ compilerOptions: { baseUrl: toSlash(path.resolve(repo, options.baseUrl || ".")), paths: { ...sharedPaths, ...(options.paths || {}) } },
710
747
  };
711
748
  fs.writeFileSync(file, JSON.stringify(linked, null, 2) + "\n");
712
- console.log(`linked ${file} to ${base} (${Object.keys(linked.compilerOptions.paths).length} store aliases)`);
749
+ const shared = Object.keys(sharedPaths).filter((key) => !key.endsWith("/*"));
750
+ console.log(
751
+ `linked ${file} to ${base} (${Object.keys(options.paths || {}).length} store aliases${shared.length ? `; types of the packages the store shares: ${shared.join(", ")}` : ""})`
752
+ );
753
+ }
754
+
755
+ // ---- the store's npm packages: shared at runtime, or installed in the package at the store's version -------------------
756
+ // react, react-dom, next and @znode/* are the store's at runtime (stand-ins, never bundled); so are the store's shared
757
+ // packages (its registry's sharedPackages: next-intl, lodash, ...). Any other npm package a package imports is bundled
758
+ // into it, so it must be installed in the package: at the version the store has installed, so a copied store component
759
+ // behaves as it does in the store. Installed exactly (no ^), from the npm registry, by eject and by build.
760
+ const RUNTIME_PROVIDED = /^(react|react-dom|next|@znode)(\/|$)/;
761
+ const DEFAULT_SHARED = ["lodash", "next-intl"];
762
+ const packageNameOf = (spec) => (spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0]);
763
+
764
+ function storeSharedPackages(root) {
765
+ for (const name of ["store-module-registry.json", "store-server-module-registry.json"]) {
766
+ const file = path.join(root, "packages/extensions", name);
767
+ try {
768
+ const list = readJson(file).sharedPackages;
769
+ if (Array.isArray(list) && list.length) return list.map(String);
770
+ } catch {
771
+ // no registry, or one from before sharedPackages: the default list
772
+ }
773
+ }
774
+ return DEFAULT_SHARED;
775
+ }
776
+
777
+ // the version the store has installed (its node_modules), else its package.json range without ^ / ~
778
+ function storePackageVersion(root, pkg) {
779
+ try {
780
+ return readJson(path.join(root, "node_modules", pkg, "package.json")).version;
781
+ } catch {
782
+ try {
783
+ const manifest = readJson(path.join(root, "package.json"));
784
+ const range = { ...(manifest.devDependencies || {}), ...(manifest.dependencies || {}) }[pkg];
785
+ return range ? range.replace(/^[\^~]/, "") : null;
786
+ } catch {
787
+ return null;
788
+ }
789
+ }
790
+ }
791
+
792
+ const installedIn = (project, pkg) => fs.existsSync(path.join(project, "node_modules", pkg, "package.json"));
793
+
794
+ // the npm packages code imports (bare specifiers, type-only imports included: their types are needed too)
795
+ const npmImportsOf = (code) =>
796
+ [...code.matchAll(/(?:^|[^\w$.])(?:import|export)\s*(?:[^"';]*?\s+from\s*)?["']([^"'.][^"']*)["']|import\(\s*["']([^"'.][^"']*)["']\s*\)/g)]
797
+ .map((match) => match[1] || match[2])
798
+ .filter((spec) => !spec.startsWith("node:") && !builtinModules.includes(packageNameOf(spec)));
799
+
800
+ /**
801
+ * The npm packages `specs` need in the package at `project`: the store's runtime and shared packages are left out, the
802
+ * others not installed yet are installed at the store's version (with their @types package when the store has one), or
803
+ * only named when install is false. Returns what was done, for the caller's message.
804
+ */
805
+ function ensureStorePackages(project, store, specs, { install = true } = {}) {
806
+ const root = store.root;
807
+ const shared = new Set(storeSharedPackages(root));
808
+ const wanted = new Map();
809
+ const typesWanted = new Map();
810
+ const notInStore = new Set();
811
+ const sharedUsed = new Set();
812
+ for (const pkg of new Set(specs.map(packageNameOf))) {
813
+ if (RUNTIME_PROVIDED.test(pkg)) continue;
814
+ if (shared.has(pkg)) {
815
+ sharedUsed.add(pkg);
816
+ continue;
817
+ }
818
+ if (installedIn(project, pkg)) continue;
819
+ const version = storePackageVersion(root, pkg);
820
+ if (!version) {
821
+ notInStore.add(pkg);
822
+ continue;
823
+ }
824
+ wanted.set(pkg, `${pkg}@${version}`);
825
+ const typesName = `@types/${pkg.replace(/^@/, "").replace("/", "__")}`;
826
+ const typesVersion = fs.existsSync(path.join(root, "node_modules", typesName, "package.json")) ? storePackageVersion(root, typesName) : null;
827
+ if (typesVersion && !installedIn(project, typesName)) typesWanted.set(typesName, `${typesName}@${typesVersion}`);
828
+ }
829
+ const run = (args) => spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { cwd: project, stdio: "inherit", shell: process.platform === "win32" });
830
+ let installed = [];
831
+ if (wanted.size) {
832
+ const list = [...wanted.values()];
833
+ if (install) {
834
+ console.log(`installing the store's versions: ${list.join(" ")}`);
835
+ const result = run(["install", "--save-exact", "--no-audit", "--no-fund", ...list]);
836
+ if (result.status !== 0) fail(`npm install failed: run it yourself in ${project}: npm install --save-exact ${list.join(" ")}`);
837
+ if (typesWanted.size) run(["install", "--save-dev", "--save-exact", "--no-audit", "--no-fund", ...typesWanted.values()]);
838
+ installed = [...list, ...typesWanted.values()];
839
+ } else {
840
+ console.log(`needs the store's versions (not installed: --no-install): npm install --save-exact ${list.join(" ")}`);
841
+ if (typesWanted.size) console.log(` and its types: npm install --save-dev --save-exact ${[...typesWanted.values()].join(" ")}`);
842
+ }
843
+ }
844
+ for (const pkg of notInStore) console.log(`warning "${pkg}" is not a dependency of the store: install it yourself (npm install ${pkg})`);
845
+ return { installed, shared: [...sharedUsed], notInStore: [...notInStore] };
846
+ }
847
+
848
+ // packages installed in the package at another version than the store's (after a store upgrade): a warning, the store's
849
+ // version is what its components were written against
850
+ function versionDrift(project, store, specs) {
851
+ const drift = [];
852
+ for (const pkg of new Set(specs.map(packageNameOf))) {
853
+ if (RUNTIME_PROVIDED.test(pkg) || !installedIn(project, pkg)) continue;
854
+ const mine = readJson(path.join(project, "node_modules", pkg, "package.json")).version;
855
+ const theirs = fs.existsSync(path.join(store.root, "node_modules", pkg, "package.json")) ? storePackageVersion(store.root, pkg) : null;
856
+ if (theirs && mine !== theirs) drift.push(`${pkg} ${mine} (store: ${theirs})`);
857
+ }
858
+ return drift;
713
859
  }
714
860
 
715
861
  // ---- eject: a store component copied into the package, in place of the store's, through its slot ----------------
@@ -779,8 +925,45 @@ function ejectableComponents(root) {
779
925
  return found.sort((a, b) => a.name.localeCompare(b.name));
780
926
  }
781
927
 
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) {
928
+ // a store image a copied file imports (../../assets/no-image.png): copied into the package's src/images (not src/assets,
929
+ // which a package ships as it is: an imported image is shipped by the build, which turns the import into
930
+ // { src, width, height }), and the import's new path from the copy
931
+ const STORE_ASSET = /\.(png|jpe?g|gif|webp|avif|svg|ico)$/i;
932
+ function copyStoreAsset(project, absolute, copyFile) {
933
+ if (!fs.existsSync(absolute)) return null;
934
+ const assets = path.join(project, "src", "images");
935
+ fs.mkdirSync(assets, { recursive: true });
936
+ const content = fs.readFileSync(absolute);
937
+ let local = path.join(assets, path.basename(absolute));
938
+ // another image with the same name already there: this one gets a hash in its name
939
+ if (fs.existsSync(local) && !fs.readFileSync(local).equals(content)) {
940
+ const ext = path.extname(absolute);
941
+ local = path.join(assets, `${path.basename(absolute, ext)}-${createHash("sha1").update(content).digest("hex").slice(0, 6)}${ext}`);
942
+ }
943
+ if (!fs.existsSync(local)) fs.writeFileSync(local, content);
944
+ const spec = path.relative(path.dirname(copyFile), local).split(path.sep).join("/");
945
+ return { spec: spec.startsWith(".") ? spec : `./${spec}`, file: path.relative(project, local).split(path.sep).join("/") };
946
+ }
947
+
948
+ // a store file that re-exports `target` (export * from / export { … } from), followed through nested barrels
949
+ function reExports(barrel, target, seen = new Set()) {
950
+ if (!barrel || seen.has(barrel) || !fs.existsSync(barrel)) return false;
951
+ seen.add(barrel);
952
+ const asFile = (base) =>
953
+ [base, `${base}.ts`, `${base}.tsx`, path.join(base, "index.ts"), path.join(base, "index.tsx")].find((file) => fs.existsSync(file) && fs.statSync(file).isFile());
954
+ for (const match of fs.readFileSync(barrel, "utf8").matchAll(/export\s*(?:\*|\{[^}]*\})\s*from\s*["'](\.{1,2}\/[^"']+)["']/g)) {
955
+ const file = asFile(path.resolve(path.dirname(barrel), match[1]));
956
+ if (!file) continue;
957
+ if (file.split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "") === target) return true;
958
+ if (reExports(file, target, seen)) return true;
959
+ }
960
+ return false;
961
+ }
962
+
963
+ // a store file's import path as the package can import it: the store alias that maps to that file, without extension;
964
+ // else, for named imports (statement: the import's text up to its path), an exact alias whose barrel re-exports the file
965
+ // (".../stores/modal" -> "@znode/base-components/stores"): export * does not carry a default, so a default import is not
966
+ function storeAlias(store, absolute, { statement = "" } = {}) {
784
967
  const target = absolute.split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
785
968
  let best = null;
786
969
  const norm = (value) => path.resolve(store.root, value).split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
@@ -795,7 +978,14 @@ function storeAlias(store, absolute) {
795
978
  if (target.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) best = { prefix, alias: alias.slice(0, -1) };
796
979
  }
797
980
  }
798
- return best ? best.alias + target.slice(best.prefix.length) : null;
981
+ if (best) return best.alias + target.slice(best.prefix.length);
982
+ const namedOnly = /^import\s*(?:type\s+)?\{/.test(statement.trim()) || /^export\s/.test(statement.trim());
983
+ if (!namedOnly) return null;
984
+ for (const [alias, targets] of Object.entries(store.paths)) {
985
+ if (alias.endsWith("/*") || !alias.startsWith("@znode/")) continue;
986
+ if (targets.some((pattern) => reExports(path.resolve(store.root, pattern), target))) return alias;
987
+ }
988
+ return null;
799
989
  }
800
990
 
801
991
  // a question on the terminal (only asked when stdin is a terminal); one line reader for the whole run, so answers
@@ -838,10 +1028,19 @@ function ejectRouteCopy(project, s, store) {
838
1028
  text = text.replace(new RegExp(`^(async\\s+)?function\\s+${s.base}\\s*\\(`, "m"), (whole, isAsync) => `export ${isAsync || ""}function ${name}(`);
839
1029
  // relative imports become the store's aliases (the package lives elsewhere)
840
1030
  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));
1031
+ text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after, offset, source) => {
1032
+ // a store image: copied into src/images, imported from there (the build ships it)
1033
+ if (STORE_ASSET.test(spec)) {
1034
+ const asset = copyStoreAsset(project, path.resolve(path.dirname(storeFile), spec), path.join(project, "src", "server", "pages", `${name}.tsx`));
1035
+ if (!asset) {
1036
+ warnings.push(`image ${spec}: not found in the store; add it under src/images and import it from there`);
1037
+ return whole;
1038
+ }
1039
+ return `${before}${asset.spec}${after}`;
1040
+ }
1041
+ const alias = storeAlias(store, path.resolve(path.dirname(storeFile), spec), { statement: source.slice(source.lastIndexOf("import", offset), offset) });
843
1042
  if (!alias) {
844
- warnings.push(`import ${spec}: no store alias for it; change it by hand`);
1043
+ warnings.push(`import ${spec}: no store alias for it (nor a store barrel that re-exports its names); change it by hand`);
845
1044
  return whole;
846
1045
  }
847
1046
  return `${before}${alias}${after}`;
@@ -886,7 +1085,7 @@ function ejectRouteCopy(project, s, store) {
886
1085
  return { text, warnings };
887
1086
  }
888
1087
 
889
- function ejectServerSlot(project, s, force, store) {
1088
+ function ejectServerSlot(project, s, force, store, noInstall = false) {
890
1089
  const name = s.page;
891
1090
  const out = path.join(project, "src", "server", "pages", `${name}.tsx`);
892
1091
  if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${name} --force`);
@@ -896,6 +1095,9 @@ function ejectServerSlot(project, s, force, store) {
896
1095
  fs.writeFileSync(out, text);
897
1096
  console.log(`ejected ${name} (a copy of ${s.files[0]}, server slot "${s.name}") -> ${path.relative(project, out)}`);
898
1097
  registerServerSlot(project, s, name);
1098
+ const deps = ensureStorePackages(project, store, npmImportsOf(text), { install: !noInstall });
1099
+ if (deps.installed.length) console.log(`installed (store versions): ${deps.installed.join(", ")}`);
1100
+ if (deps.shared.length) console.log(`shared by the store (not installed; types from the linked store): ${deps.shared.join(", ")}`);
899
1101
  for (const warning of warnings) console.log(`warning ${warning}`);
900
1102
  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
1103
  return;
@@ -954,7 +1156,7 @@ function registerServerSlot(project, s, name) {
954
1156
  console.log(`registered "${s.name}": ${local} in src/server.tsx (serverSlots)`);
955
1157
  }
956
1158
 
957
- async function eject(name, force, { slot: slotArg, yes } = {}) {
1159
+ async function eject(name, force, { slot: slotArg, yes, noInstall } = {}) {
958
1160
  const project = path.resolve(".");
959
1161
  if (!fs.existsSync(path.join(project, MANIFEST))) fail(`run znode eject in a package folder (the one with ${MANIFEST})`);
960
1162
  const store = linkedStore(project);
@@ -980,7 +1182,7 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
980
1182
  let c = find(components);
981
1183
  if (!c) {
982
1184
  const server = findServer();
983
- if (server) return ejectServerSlot(project, server, force, store);
1185
+ if (server) return ejectServerSlot(project, server, force, store, noInstall);
984
1186
  }
985
1187
  if (!c) {
986
1188
  // no slot hands this component over yet: offer to create one in the base store (what znode create-slot does), then eject
@@ -1012,7 +1214,7 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
1012
1214
  const server = findServer();
1013
1215
  if (server) {
1014
1216
  console.log("");
1015
- return ejectServerSlot(project, server, force, store);
1217
+ return ejectServerSlot(project, server, force, store, noInstall);
1016
1218
  }
1017
1219
  fail(`the slot "${slot}" was created, but ${name} is still not found as ejectable; check the store diff`);
1018
1220
  }
@@ -1022,17 +1224,28 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
1022
1224
  const out = path.join(project, "src", "components", `${c.name}.tsx`);
1023
1225
  if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${c.name} --force`);
1024
1226
  const warnings = [];
1227
+ // the store images the copy imports, copied into src/images
1228
+ const copiedAssets = new Set();
1025
1229
  let text = fs.readFileSync(c.file, "utf8");
1026
1230
  // 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) => {
1231
+ text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after, offset, source) => {
1028
1232
  const absolute = path.resolve(path.dirname(c.file), spec);
1029
1233
  if (/\.(s?css)$/.test(spec)) {
1030
1234
  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
1235
  return `${before}${spec}${after}`;
1032
1236
  }
1033
- const alias = storeAlias(store, fs.existsSync(absolute) || /\.\w+$/.test(spec) ? absolute : absolute);
1237
+ if (STORE_ASSET.test(spec)) {
1238
+ const asset = copyStoreAsset(project, absolute, out);
1239
+ if (!asset) {
1240
+ warnings.push(`image ${spec}: not found in the store; add it under src/images and import it from there`);
1241
+ return whole;
1242
+ }
1243
+ copiedAssets.add(asset.file);
1244
+ return `${before}${asset.spec}${after}`;
1245
+ }
1246
+ const alias = storeAlias(store, absolute, { statement: source.slice(source.lastIndexOf("import", offset), offset) });
1034
1247
  if (!alias) {
1035
- warnings.push(`import ${spec}: no store alias for it; change it by hand`);
1248
+ warnings.push(`import ${spec}: no store alias for it (nor a store barrel that re-exports its names); change it by hand`);
1036
1249
  return whole;
1037
1250
  }
1038
1251
  return `${before}${alias}${after}`;
@@ -1050,33 +1263,13 @@ async function eject(name, force, { slot: slotArg, yes } = {}) {
1050
1263
  fs.mkdirSync(path.dirname(out), { recursive: true });
1051
1264
  fs.writeFileSync(out, text);
1052
1265
  console.log(`ejected ${c.name} -> ${path.relative(project, out)}`);
1266
+ if (copiedAssets.size) console.log(`copied the store's images it imports: ${[...copiedAssets].join(", ")} (shipped with the package)`);
1053
1267
 
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
- }
1268
+ // npm packages the copy imports: the store's shared ones are used from the store (types through link-store), the others
1269
+ // are installed in this package at the store's version (bundled into the package when it builds)
1270
+ const deps = ensureStorePackages(project, store, npmImportsOf(text), { install: !noInstall });
1271
+ if (deps.installed.length) console.log(`installed (store versions): ${deps.installed.join(", ")}`);
1272
+ if (deps.shared.length) console.log(`shared by the store (not installed; types from the linked store): ${deps.shared.join(", ")}`);
1080
1273
 
1081
1274
  // register the copy under the slot in src/browser.tsx
1082
1275
  const browser = path.join(project, "src", "browser.tsx");
@@ -1518,7 +1711,11 @@ if (command === "eject") {
1518
1711
  // eject <Name> [--force] [--slot <area.name>] [--yes]: --slot / --yes create a missing slot without asking
1519
1712
  const slotAt = args.indexOf("--slot");
1520
1713
  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") });
1714
+ await eject(name, args.includes("--force"), {
1715
+ slot: slotAt >= 0 ? args[slotAt + 1] : undefined,
1716
+ yes: args.includes("--yes") || args.includes("-y"),
1717
+ noInstall: args.includes("--no-install"),
1718
+ });
1522
1719
  process.exit(0);
1523
1720
  }
1524
1721
  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.11",
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",
@@ -1,3 +1,4 @@
1
+ /// <reference path="./images.d.ts" />
1
2
  // Znode runtime extension API, version 1: what a theme, plugin or widget package builds against.
2
3
  // A package imports nothing from the webstore; everything comes in through these hooks and the browser host.
3
4
 
@@ -0,0 +1,40 @@
1
+ // Image imports in a package, as in a Next.js app: import noImage from "./no-image.png". The build ships the file with the
2
+ // package and the import is { src, width, height } (what next/image takes for a static import; width and height are 0
3
+ // for webp, avif and ico: pass them to next/image yourself).
4
+ interface IZnodeStaticImage {
5
+ src: string;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ declare module "*.png" {
10
+ const image: IZnodeStaticImage;
11
+ export default image;
12
+ }
13
+ declare module "*.jpg" {
14
+ const image: IZnodeStaticImage;
15
+ export default image;
16
+ }
17
+ declare module "*.jpeg" {
18
+ const image: IZnodeStaticImage;
19
+ export default image;
20
+ }
21
+ declare module "*.gif" {
22
+ const image: IZnodeStaticImage;
23
+ export default image;
24
+ }
25
+ declare module "*.webp" {
26
+ const image: IZnodeStaticImage;
27
+ export default image;
28
+ }
29
+ declare module "*.avif" {
30
+ const image: IZnodeStaticImage;
31
+ export default image;
32
+ }
33
+ declare module "*.ico" {
34
+ const image: IZnodeStaticImage;
35
+ export default image;
36
+ }
37
+ declare module "*.svg" {
38
+ const image: IZnodeStaticImage;
39
+ export default image;
40
+ }