znode-plugin 0.1.10 → 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" };
@@ -50,6 +50,7 @@ import fs from "node:fs";
50
50
  import path from "node:path";
51
51
  import { execFileSync, spawnSync } from "node:child_process";
52
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";
@@ -313,7 +314,8 @@ function build(target) {
313
314
  ` input: ${JSON.stringify(compiled)},`,
314
315
  ` output: { file: ${JSON.stringify(path.join(out, target))}, format: "es", sourcemap: true },`,
315
316
  ` plugins: [`,
316
- ` 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)} }),`,
317
319
  ` { name: "fonts-out", writeBundle() { fs.writeFileSync(${JSON.stringify(fontsFile)}, JSON.stringify(fonts)); fs.writeFileSync(${JSON.stringify(storeImportsFile)}, JSON.stringify(storeImports)); } },`,
318
320
  ` ],`,
319
321
  `};`,
@@ -329,9 +331,10 @@ function build(target) {
329
331
 
330
332
  // styles: concatenated into one stylesheet, after the fonts next/font/google built; assets (fonts, images) keep their
331
333
  // names, which the stylesheet refers to
332
- 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)) {
333
336
  const css = [
334
- ...fonts.map((font) => font.css),
337
+ ...fonts.map((font) => font.css).filter(Boolean),
335
338
  ...(manifest.styles || []).flatMap((pattern) => glob(dir, pattern)).map((file) => fs.readFileSync(file, "utf8")),
336
339
  ].join("\n");
337
340
  files.styles = [`${prefix}.css`];
@@ -569,7 +572,10 @@ function create(kind, name, dir) {
569
572
  fs.mkdirSync(path.dirname(full), { recursive: true });
570
573
  fs.writeFileSync(full, typeof content === "string" ? content : JSON.stringify(content, null, 2) + "\n");
571
574
  }
572
- 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
+ }
573
579
  console.log(`created ${kind} ${name} in ${target}`);
574
580
  console.log(" next: fill in label, publisher and description in znode-package.json, then run its build-and-upload.sh");
575
581
  }
@@ -919,6 +925,26 @@ function ejectableComponents(root) {
919
925
  return found.sort((a, b) => a.name.localeCompare(b.name));
920
926
  }
921
927
 
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
+
922
948
  // a store file that re-exports `target` (export * from / export { … } from), followed through nested barrels
923
949
  function reExports(barrel, target, seen = new Set()) {
924
950
  if (!barrel || seen.has(barrel) || !fs.existsSync(barrel)) return false;
@@ -1003,9 +1029,18 @@ function ejectRouteCopy(project, s, store) {
1003
1029
  // relative imports become the store's aliases (the package lives elsewhere)
1004
1030
  const warnings = [];
1005
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
+ }
1006
1041
  const alias = storeAlias(store, path.resolve(path.dirname(storeFile), spec), { statement: source.slice(source.lastIndexOf("import", offset), offset) });
1007
1042
  if (!alias) {
1008
- 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`);
1009
1044
  return whole;
1010
1045
  }
1011
1046
  return `${before}${alias}${after}`;
@@ -1189,6 +1224,8 @@ async function eject(name, force, { slot: slotArg, yes, noInstall } = {}) {
1189
1224
  const out = path.join(project, "src", "components", `${c.name}.tsx`);
1190
1225
  if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${c.name} --force`);
1191
1226
  const warnings = [];
1227
+ // the store images the copy imports, copied into src/images
1228
+ const copiedAssets = new Set();
1192
1229
  let text = fs.readFileSync(c.file, "utf8");
1193
1230
  // relative imports become the store's aliases (the package lives elsewhere); a style file stays the store's
1194
1231
  text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after, offset, source) => {
@@ -1197,6 +1234,15 @@ async function eject(name, force, { slot: slotArg, yes, noInstall } = {}) {
1197
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/`);
1198
1235
  return `${before}${spec}${after}`;
1199
1236
  }
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
+ }
1200
1246
  const alias = storeAlias(store, absolute, { statement: source.slice(source.lastIndexOf("import", offset), offset) });
1201
1247
  if (!alias) {
1202
1248
  warnings.push(`import ${spec}: no store alias for it (nor a store barrel that re-exports its names); change it by hand`);
@@ -1217,6 +1263,7 @@ async function eject(name, force, { slot: slotArg, yes, noInstall } = {}) {
1217
1263
  fs.mkdirSync(path.dirname(out), { recursive: true });
1218
1264
  fs.writeFileSync(out, text);
1219
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)`);
1220
1267
 
1221
1268
  // npm packages the copy imports: the store's shared ones are used from the store (types through link-store), the others
1222
1269
  // are installed in this package at the store's version (bundled into the package when it builds)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "znode-plugin",
3
- "version": "0.1.10",
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
+ }