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 +14 -5
- package/lib/next-imports.mjs +59 -2
- package/lib/znode-package.mjs +244 -47
- package/package.json +1 -1
- package/types/extension-api.d.ts +1 -0
- package/types/images.d.ts +40 -0
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`
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
package/lib/next-imports.mjs
CHANGED
|
@@ -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" };
|
package/lib/znode-package.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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")
|
|
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(
|
|
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
|
-
|
|
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
|
|
783
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1055
|
-
//
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
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"), {
|
|
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
package/types/extension-api.d.ts
CHANGED
|
@@ -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
|
+
}
|