znode-plugin 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +114 -5
  2. package/lib/znode-package.mjs +1444 -1083
  3. package/package.json +6 -1
@@ -1,1083 +1,1444 @@
1
- #!/usr/bin/env node
2
- // znode-plugin: build, pack and install Znode runtime packages (themes, plugins, widgets, content).
3
- //
4
- // znode build <packageDir|manifest.json> build into <packageDir>/dist
5
- // znode pack <packageDir|manifest.json> build, then zip it as
6
- // <packageDir>/releases/<name>-<version>.zpkg.zip
7
- // znode install <zip|packageDir> [storeDir] validate and install into the store's plugin
8
- // directory (default: RUNTIME_PLUGINS_DIR);
9
- // the registry entry keeps its settings and stores
10
- // znode create <theme|plugin|widget|content> <name> [dir]
11
- // a starter package that builds as it is
12
- // (default dir ./<name>)
13
- // znode check <packageDir> marketplace review: metadata, permissions against
14
- // the code, settings, size, server-render format
15
- // znode deploy <packageDir> [storeDir] pack, then install into the store's plugin directory
16
- // znode dev <packageDir> [storeDir] deploy, then again on every change under src/ or to the manifest
17
- // znode link-store <storeRepoDir> [dir] the store's real types: writes tsconfig.store.json (see README)
18
- // znode eject [<Name>] [--force] in a package folder: list the store components that can be ejected, or
19
- // copy one into src/components/ and register it in its slot
20
- // (<Name> is the component or its slot name)
21
- // znode add-slot <Component> <area.name> [--dry-run] [--file <f>] a slot around a store component, written into
22
- // the linked store's source (base developers; review and commit there)
23
- //
24
- // A package is a folder with znode-package.json (README.md shows a whole project, as create-znode-plugin makes it):
25
- // {
26
- // "name": "artifi", "version": "1.0.0", "kind": "plugin" | "theme" | "widget" | "content", "apiVersion": 1,
27
- // "label": "...", "publisher": "...", "description": "...", "parent": "<theme>", (parent: themes only)
28
- // "build": { "server": "src/server.ts", "browser": "src/browser.tsx" }, (TypeScript entry points)
29
- // "styles": ["src/styles/*.css"], "assets": ["src/assets/fonts/*.woff2"], "messages": "src/i18n/labels.json",
30
- // "pages": { "/": "src/pages/home.json" }, "widgets": ["HeroBanner"],
31
- // code imports Next.js as an app does: next/image, next/link, next/navigation, next/font/google, react
32
- // (next-imports.mjs: the store's own modules at runtime; Google fonts downloaded into the package when it builds)
33
- // "overrides": { "/api/place-order": "place-order" }, (store BFF routes it takes over, by one of its routes)
34
- // "serverPages": ["/my-page"], (whole pages at new URLs, rendered by its server part's serverPages)
35
- // "dedupeStyles": true, (styles: drop the rules the store already has; STORE_URL = a page of a running store)
36
- // "settingsSchema": { "websiteId": { "type": "string", "required": true, "browser": true, "label": "..." },
37
- // "banner": { "type": "string", "browser": true, "attribute": "StoreBannerText" } },
38
- // (attribute: the value comes from a Znode global attribute, "Code" for the store's, or
39
- // { "code": "...", "levels": ["store", "account", "user"] }; the most specific level wins)
40
- // marketplace: "license", "keywords", "homepage", "compatibility": { "webstore": ">=10.0.0" },
41
- // "permissions": ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads"] (what it does beyond UI;
42
- // "uploads": its routes take files, multipart/form-data up to RUNTIME_PLUGIN_UPLOAD_BYTES)
43
- // "sandbox": true (its server part runs in a separate, permission-limited Node process: routes and overrides only)
44
- // }
45
- import fs from "node:fs";
46
- import path from "node:path";
47
- import { execFileSync, spawnSync } from "node:child_process";
48
- import { createRequire } from "node:module";
49
- import { fileURLToPath, pathToFileURL } from "node:url";
50
- import vm from "node:vm";
51
- import { TEMPLATES } from "./templates.mjs";
52
-
53
- const HERE = path.dirname(fileURLToPath(import.meta.url));
54
- const MANIFEST = "znode-package.json";
55
- // the build tools (TypeScript, rollup, adm-zip, postcss): this package's own dependencies, wherever npm put them
56
- // (ZNODE_TOOLS: a node_modules folder to use instead)
57
- const require = process.env.ZNODE_TOOLS ? createRequire(path.join(process.env.ZNODE_TOOLS, "noop.js")) : createRequire(import.meta.url);
58
- // a dependency's folder (its package.json's), found from what it resolves to
59
- function packageDir(name) {
60
- for (let dir = path.dirname(require.resolve(name)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
61
- const file = path.join(dir, "package.json");
62
- if (fs.existsSync(file) && JSON.parse(fs.readFileSync(file, "utf8")).name === name) return dir;
63
- }
64
- throw new Error(`znode-plugin: ${name} is not installed`);
65
- }
66
- const TSC = () => path.join(packageDir("typescript"), "bin", "tsc");
67
- const ROLLUP = () => path.join(packageDir("rollup"), "dist", "bin", "rollup");
68
- const AdmZip = require("adm-zip");
69
-
70
- const API_VERSION = 1;
71
- const KINDS = ["theme", "plugin", "widget", "content"];
72
- const NAME = /^[a-z0-9][a-z0-9-]{1,48}$/;
73
- const VERSION = /^\d+\.\d+\.\d+(-[0-9a-z.-]+)?$/i;
74
- const SERVED = new Set([".mjs", ".map", ".css", ".woff2", ".woff", ".ttf", ".otf", ".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".json"]);
75
- const MAX_BYTES = 10 * 1024 * 1024;
76
- const SETTING_TYPES = ["string", "number", "boolean", "list", "json", "object", "array"];
77
- const ATTRIBUTE_LEVELS = ["store", "account", "user"];
78
- const ATTRIBUTE_CODE = /^[A-Za-z][A-Za-z0-9_]{1,99}$/;
79
- const PERMISSIONS = ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads"];
80
-
81
- const fail = (message) => {
82
- console.error(`znode-package: ${message}`);
83
- process.exit(1);
84
- };
85
- const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
86
- const glob = (dir, pattern) => {
87
- const folder = path.join(dir, path.dirname(pattern));
88
- const re = new RegExp(
89
- `^${path
90
- .basename(pattern)
91
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
92
- .replace(/\*/g, ".*")}$`
93
- );
94
- return fs.existsSync(folder)
95
- ? fs
96
- .readdirSync(folder)
97
- .filter((name) => re.test(name))
98
- .map((name) => path.join(folder, name))
99
- : [];
100
- };
101
-
102
- function validateManifest(manifest, where) {
103
- if (!NAME.test(manifest.name || "")) fail(`${where}: name must be lowercase letters, digits and dashes`);
104
- if (!VERSION.test(manifest.version || "")) fail(`${where}: version must be semver (1.2.3)`);
105
- if (!KINDS.includes(manifest.kind)) fail(`${where}: kind must be one of ${KINDS.join(", ")}`);
106
- if (manifest.apiVersion !== API_VERSION) fail(`${where}: apiVersion ${manifest.apiVersion} isn't supported (this store runs ${API_VERSION})`);
107
- if (manifest.parent && manifest.kind !== "theme") fail(`${where}: only a theme has a parent`);
108
- // whole pages its server part renders (IRuntimePlugin.serverPages), at new lowercase URLs the store doesn't use
109
- if (manifest.serverPages !== undefined) {
110
- if (!Array.isArray(manifest.serverPages)) fail(`${where}: serverPages must be a list of paths ("/my-page")`);
111
- // the source manifest names the entry (build.server), the built one the file (files.server)
112
- if (manifest.serverPages.length && !manifest.build?.server && !manifest.files?.server) fail(`${where}: serverPages need a server part (build.server)`);
113
- for (const page of manifest.serverPages) {
114
- if (!/^\/[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/.test(String(page))) fail(`${where}: serverPages "${page}" must be a lowercase path like /my-page`);
115
- if (/^\/(api|runtime-page|_next|login|signup|account|cart|checkout|search|product|category|404|error|maintenance)(\/|$)/.test(page)) fail(`${where}: serverPages "${page}" is a store route`);
116
- }
117
- }
118
- // store BFF routes the package takes over: "/api/<path>" -> one of its own routes
119
- for (const [route, target] of Object.entries(manifest.overrides || {})) {
120
- if (!/^\/api\/[A-Za-z0-9/_-]+$/.test(route) || /^\/api\/extensions(\/|$)/.test(route)) fail(`${where}: override "${route}" must be a store route under /api/`);
121
- if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(String(target))) fail(`${where}: override "${route}" must name one of the package's routes`);
122
- }
123
- for (const [key, field] of Object.entries(manifest.settingsSchema || {})) {
124
- if (field?.type !== undefined && !SETTING_TYPES.includes(field.type)) fail(`${where}: setting "${key}" has type "${field.type}" (one of ${SETTING_TYPES.join(", ")})`);
125
- const attribute = field?.attribute;
126
- if (attribute === undefined) continue;
127
- const code = typeof attribute === "string" ? attribute : attribute?.code;
128
- if (!ATTRIBUTE_CODE.test(String(code || ""))) fail(`${where}: setting "${key}" names attribute "${code}" (letters, digits and _, as Znode attribute codes)`);
129
- const levels = typeof attribute === "string" ? ["store"] : attribute.levels || ["store"];
130
- if (!Array.isArray(levels) || !levels.length || levels.some((level) => !ATTRIBUTE_LEVELS.includes(level))) {
131
- fail(`${where}: setting "${key}" attribute levels must be some of ${ATTRIBUTE_LEVELS.join(", ")}`);
132
- }
133
- }
134
- if (manifest.sandbox !== undefined && typeof manifest.sandbox !== "boolean") fail(`${where}: sandbox must be true or false`);
135
- if (manifest.permissions !== undefined && (!Array.isArray(manifest.permissions) || manifest.permissions.some((item) => !PERMISSIONS.includes(item)))) {
136
- fail(`${where}: permissions must be some of ${PERMISSIONS.join(", ")}`);
137
- }
138
- }
139
-
140
- // ---- build: TypeScript entry points -> one ES module each (tsc + rollup), plus styles, assets, labels, pages ----
141
- // a package folder (its znode-package.json) or one of several manifests in a folder (<name>.znode-package.json)
142
- function resolvePackage(target) {
143
- const isFile = fs.statSync(target).isFile();
144
- const dir = isFile ? path.dirname(target) : target;
145
- const manifestFile = isFile ? target : path.join(dir, MANIFEST);
146
- return { dir, manifestFile, outName: path.basename(manifestFile) === MANIFEST ? "dist" : `dist-${path.basename(manifestFile, `.${MANIFEST}`)}` };
147
- }
148
-
149
- // The package's tsconfig check. tsc also checks the store files the package imports; their errors are the store's, not
150
- // the package's: listed as warnings. An error in the package's own files stops the build.
151
- function typeCheck(dir, tsconfigFile) {
152
- let output = "";
153
- try {
154
- execFileSync(process.execPath, [TSC(), "-p", tsconfigFile, "--noEmit", "--pretty", "false"], {
155
- cwd: dir,
156
- encoding: "utf8",
157
- stdio: ["ignore", "pipe", "pipe"],
158
- });
159
- return;
160
- } catch (error) {
161
- output = `${error.stdout || ""}${error.stderr || ""}`;
162
- }
163
- // one entry per error: "file(line,col): error TSnnnn: message" and its indented continuation lines
164
- const errors = output.split(/\r?\n(?=\S)/).filter((entry) => /^.+?\(\d+,\d+\): error TS\d+:/.test(entry));
165
- if (!errors.length) fail(`tsconfig check failed:\n${output}`);
166
- // inside the package folder: a relative path that does not climb out (on Windows a file on another drive comes back
167
- // absolute from path.relative, which is outside too)
168
- const inPackage = (entry) => {
169
- const relative = path.relative(dir, path.resolve(dir, entry.slice(0, entry.indexOf("("))));
170
- return !relative.startsWith("..") && !path.isAbsolute(relative);
171
- };
172
- const own = errors.filter(inPackage);
173
- const store = errors.filter((entry) => !inPackage(entry));
174
- if (store.length) console.log(`warning ${store.length} type error(s) in store files the package imports (the store's to fix):\n ${store.join("\n ")}`);
175
- if (own.length) fail(`type errors in the package:\n ${own.join("\n ")}`);
176
- }
177
-
178
- // every .ts / .tsx under srcRoot compiled to .js under outRoot, one file at a time and without resolving imports (the
179
- // tsconfig check has done the types): types removed, JSX as Next.js compiles it (react/jsx-runtime)
180
- function transpileSources(srcRoot, outRoot) {
181
- const ts = require("typescript");
182
- const walk = (folder) =>
183
- fs.readdirSync(folder, { withFileTypes: true }).flatMap((item) => {
184
- const file = path.join(folder, item.name);
185
- if (item.isDirectory()) return walk(file);
186
- return /\.tsx?$/.test(item.name) && !item.name.endsWith(".d.ts") ? [file] : [];
187
- });
188
- for (const file of walk(srcRoot)) {
189
- const { outputText } = ts.transpileModule(fs.readFileSync(file, "utf8"), {
190
- fileName: file,
191
- compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022, jsx: ts.JsxEmit.ReactJSX, jsxImportSource: "react" },
192
- });
193
- const target = path.join(outRoot, path.relative(srcRoot, file)).replace(/\.tsx?$/, ".js");
194
- fs.mkdirSync(path.dirname(target), { recursive: true });
195
- fs.writeFileSync(target, outputText);
196
- }
197
- }
198
-
199
- // The store the package is linked to (tsconfig.store.json, written by link-store): its root, its @znode paths and its
200
- // Generated Store Module Registry (packages/extensions/store-module-registry.json), for the package's store imports
201
- function linkedStore(dir) {
202
- const file = path.join(dir, "tsconfig.store.json");
203
- if (!fs.existsSync(file)) return null;
204
- const { compilerOptions = {} } = readJson(file);
205
- if (!compilerOptions.baseUrl) return null;
206
- const root = path.resolve(dir, compilerOptions.baseUrl);
207
- const registryFile = path.join(root, "packages/extensions/store-module-registry.json");
208
- const registry = fs.existsSync(registryFile) ? readJson(registryFile).modules : null;
209
- return { root, paths: compilerOptions.paths || {}, registry };
210
- }
211
-
212
- function build(target) {
213
- const { dir, manifestFile, outName } = resolvePackage(target);
214
- const manifest = readJson(manifestFile);
215
- validateManifest(manifest, path.basename(manifestFile));
216
- const out = path.join(dir, outName);
217
- fs.rmSync(out, { recursive: true, force: true });
218
- fs.mkdirSync(out, { recursive: true });
219
- const tmp = path.join(dir, ".build");
220
- const files = {};
221
- const prefix = `${manifest.name}-${manifest.version}`;
222
- // next/font/google fonts built into the package (next-imports.mjs): their files and @font-face CSS
223
- const fonts = [];
224
- // the store modules its browser part imports (Generated Store Module Registry keys), for the store to load first
225
- const storeImports = [];
226
- const store = linkedStore(dir);
227
- // A package with its own tsconfig.json (extending the store's tsconfig.base.json) is checked against the store's real
228
- // types (every @znode alias, no declarations to write), then compiled file by file without type information, JSX as
229
- // Next.js does (react/jsx-runtime: the store's). Without one, the package compiles as before (tsc, JSX factory h).
230
- const tsconfigFile = path.join(dir, "tsconfig.json");
231
- const projectMode = fs.existsSync(tsconfigFile);
232
- if (projectMode && Object.values(manifest.build || {}).length) typeCheck(dir, tsconfigFile);
233
-
234
- for (const part of ["server", "browser"]) {
235
- const entry = manifest.build?.[part];
236
- if (!entry) continue;
237
- fs.rmSync(tmp, { recursive: true, force: true });
238
- if (projectMode) transpileSources(path.dirname(path.join(dir, entry)), tmp);
239
- else execFileSync(
240
- process.execPath,
241
- [
242
- TSC(),
243
- "--target",
244
- "es2022",
245
- "--module",
246
- "es2022",
247
- "--moduleResolution",
248
- "bundler",
249
- "--lib",
250
- "es2022,dom",
251
- "--skipLibCheck",
252
- // import React from "react", as the store's own components do
253
- "--allowSyntheticDefaultImports",
254
- "--jsx",
255
- "react",
256
- "--jsxFactory",
257
- "h",
258
- "--jsxFragmentFactory",
259
- "Fragment",
260
- "--outDir",
261
- tmp,
262
- path.join(dir, entry),
263
- ],
264
- { stdio: "inherit" }
265
- );
266
- const compiled = path.join(tmp, path.basename(entry).replace(/\.tsx?$/, ".js"));
267
- const target = `${prefix}.${part}.mjs`;
268
- // rollup with the Next.js imports plugin (next-imports.mjs), from a config file: the plugin downloads fonts, which
269
- // is async, in rollup's own process; what it built comes back in fonts.json
270
- const config = path.join(tmp, "rollup.config.mjs");
271
- const fontsFile = path.join(tmp, "fonts.json");
272
- const storeImportsFile = path.join(tmp, "store-imports.json");
273
- fs.writeFileSync(
274
- config,
275
- [
276
- `import fs from "node:fs";`,
277
- `import { znodeNext } from ${JSON.stringify(pathToFileURL(path.join(HERE, "next-imports.mjs")).href)};`,
278
- `const fonts = [];`,
279
- `const storeImports = [];`,
280
- `const store = ${JSON.stringify(store)};`,
281
- `if (store?.registry) store.registry = new Set(store.registry);`,
282
- `export default {`,
283
- ` input: ${JSON.stringify(compiled)},`,
284
- ` output: { file: ${JSON.stringify(path.join(out, target))}, format: "es", sourcemap: true },`,
285
- ` plugins: [`,
286
- ` znodeNext({ part: ${JSON.stringify(part)}, prefix: ${JSON.stringify(prefix)}, outDir: ${JSON.stringify(out)}, fonts, store, storeImports }),`,
287
- ` { name: "fonts-out", writeBundle() { fs.writeFileSync(${JSON.stringify(fontsFile)}, JSON.stringify(fonts)); fs.writeFileSync(${JSON.stringify(storeImportsFile)}, JSON.stringify(storeImports)); } },`,
288
- ` ],`,
289
- `};`,
290
- ].join("\n")
291
- );
292
- execFileSync(process.execPath, [ROLLUP(), "--config", config, "--silent"], { stdio: "inherit" });
293
- if (fs.existsSync(fontsFile)) fonts.push(...JSON.parse(fs.readFileSync(fontsFile, "utf8")));
294
- if (fs.existsSync(storeImportsFile)) storeImports.push(...JSON.parse(fs.readFileSync(storeImportsFile, "utf8")));
295
- files[part] = target;
296
- }
297
- fs.rmSync(tmp, { recursive: true, force: true });
298
-
299
- // styles: concatenated into one stylesheet, after the fonts next/font/google built; assets (fonts, images) keep their
300
- // names, which the stylesheet refers to
301
- if (manifest.styles?.length || fonts.length) {
302
- const css = [
303
- ...fonts.map((font) => font.css),
304
- ...(manifest.styles || []).flatMap((pattern) => glob(dir, pattern)).map((file) => fs.readFileSync(file, "utf8")),
305
- ].join("\n");
306
- files.styles = [`${prefix}.css`];
307
- fs.writeFileSync(path.join(out, files.styles[0]), css);
308
- // rules the store's own stylesheets already have, loaded again later, would beat its responsive variants
309
- if (manifest.dedupeStyles) {
310
- const target = path.join(out, files.styles[0]);
311
- execFileSync(process.execPath, [path.join(HERE, "css-dedupe.mjs"), target, process.env.STORE_URL || "http://staplespromo.localhost:3000/", target], { stdio: "inherit" });
312
- }
313
- }
314
- files.assets = (manifest.assets || [])
315
- .flatMap((pattern) => glob(dir, pattern))
316
- .map((file) => {
317
- fs.copyFileSync(file, path.join(out, path.basename(file)));
318
- return path.basename(file);
319
- })
320
- // the font files next/font/google downloaded, already in dist
321
- .concat(fonts.flatMap((font) => font.files));
322
- if (manifest.messages) {
323
- files.messages = `${prefix}.labels.json`;
324
- fs.copyFileSync(path.join(dir, manifest.messages), path.join(out, files.messages));
325
- }
326
- if (manifest.pages) {
327
- files.pages = Object.fromEntries(
328
- Object.entries(manifest.pages).map(([route, file], index) => {
329
- const target = `${prefix}.page${index}.json`;
330
- fs.copyFileSync(path.join(dir, file), path.join(out, target));
331
- return [route, target];
332
- })
333
- );
334
- }
335
- const { build: _build, styles: _styles, assets: _assets, dedupeStyles: _dedupe, ...rest } = manifest;
336
- fs.writeFileSync(path.join(out, MANIFEST), JSON.stringify({ ...rest, files, ...(storeImports.length ? { storeImports: [...new Set(storeImports)].sort() } : {}) }, null, 2) + "\n");
337
- console.log(`built ${manifest.kind} ${manifest.name}@${manifest.version}: ${fs.readdirSync(out).length} files`);
338
- return { manifest, out };
339
- }
340
-
341
- function pack(target) {
342
- const { manifest, out } = build(target);
343
- const { dir } = resolvePackage(target);
344
- const zip = new AdmZip();
345
- for (const name of fs.readdirSync(out)) zip.addLocalFile(path.join(out, name));
346
- fs.mkdirSync(path.join(dir, "releases"), { recursive: true });
347
- const file = path.join(dir, "releases", `${manifest.name}-${manifest.version}.zpkg.zip`);
348
- zip.writeZip(file);
349
- console.log(`packed ${path.basename(file)} (${fs.statSync(file).size} bytes)`);
350
- }
351
-
352
- // ---- install: validate, copy into the plugin directory, upsert the registry entry -------------------------------
353
- // Installs into one folder can run at the same time (several pipelines, an admin upload): each takes the folder's lock
354
- // (an atomic mkdir) for the read-change-write of plugins.json, writes it atomically (a temp file renamed over it, so the
355
- // running webstore never reads half a file) and keeps the previous registry in .history/ (the last HISTORY_KEEP).
356
- const LOCK_WAIT_MS = 60000;
357
- const LOCK_STALE_MS = 120000;
358
- const HISTORY_KEEP = 50;
359
- const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
360
-
361
- function lockRegistry(storeDir) {
362
- fs.mkdirSync(storeDir, { recursive: true });
363
- const lock = path.join(storeDir, ".plugins.lock");
364
- const deadline = Date.now() + LOCK_WAIT_MS;
365
- for (;;) {
366
- try {
367
- fs.mkdirSync(lock);
368
- break;
369
- } catch (error) {
370
- if (error.code !== "EEXIST") throw error;
371
- // a lock left by a crashed install
372
- try {
373
- if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) fs.rmSync(lock, { recursive: true, force: true });
374
- } catch {
375
- // removed meanwhile
376
- }
377
- if (Date.now() > deadline) fail(`the registry in ${storeDir} is locked by another install (remove ${lock} if none is running)`);
378
- sleep(25 + Math.floor(Math.random() * 50));
379
- }
380
- }
381
- const release = () => fs.rmSync(lock, { recursive: true, force: true });
382
- process.on("exit", release);
383
- return () => {
384
- release();
385
- process.off("exit", release);
386
- };
387
- }
388
-
389
- function writeRegistry(registryFile, registry) {
390
- const dir = path.dirname(registryFile);
391
- if (fs.existsSync(registryFile)) {
392
- const history = path.join(dir, ".history");
393
- fs.mkdirSync(history, { recursive: true });
394
- fs.copyFileSync(registryFile, path.join(history, `plugins-${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}.json`));
395
- const old = fs
396
- .readdirSync(history)
397
- .filter((name) => name.startsWith("plugins-"))
398
- .sort();
399
- for (const name of old.slice(0, Math.max(0, old.length - HISTORY_KEEP))) fs.rmSync(path.join(history, name), { force: true });
400
- }
401
- const temp = `${registryFile}.tmp-${process.pid}`;
402
- fs.writeFileSync(temp, JSON.stringify(registry, null, 2) + "\n");
403
- fs.renameSync(temp, registryFile);
404
- }
405
- const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
406
-
407
- // the store's settings over the package defaults; an object setting (labels, panels) keeps the store's values and
408
- // gains the keys a new version adds
409
- function mergeSettings(defaults, previous) {
410
- const merged = { ...defaults, ...previous };
411
- for (const [key, value] of Object.entries(defaults)) {
412
- if (isPlainObject(value) && isPlainObject(previous[key])) merged[key] = { ...value, ...previous[key] };
413
- }
414
- return merged;
415
- }
416
-
417
- function install(source, storeDir) {
418
- let entries;
419
- if (fs.statSync(source).isDirectory()) {
420
- // a package folder's build output (dist; package in packages built before the dist folder)
421
- const built = ["dist", "package"].map((name) => path.join(source, name)).find((dir) => fs.existsSync(path.join(dir, MANIFEST)));
422
- const dir = built || source;
423
- entries = fs.readdirSync(dir).map((name) => ({ name, data: fs.readFileSync(path.join(dir, name)) }));
424
- } else {
425
- entries = new AdmZip(source)
426
- .getEntries()
427
- .filter((entry) => !entry.isDirectory)
428
- .map((entry) => ({ name: entry.entryName, data: entry.getData() }));
429
- }
430
- const manifestEntry = entries.find((entry) => entry.name === "znode-package.json");
431
- if (!manifestEntry) fail("no znode-package.json in the package");
432
- const manifest = JSON.parse(manifestEntry.data.toString("utf8"));
433
- validateManifest(manifest, "package");
434
-
435
- const total = entries.reduce((sum, entry) => sum + entry.data.length, 0);
436
- if (total > MAX_BYTES) fail(`package is ${total} bytes; the limit is ${MAX_BYTES}`);
437
- const files = entries.filter((entry) => entry.name !== "znode-package.json");
438
- for (const entry of files) {
439
- if (entry.name !== path.basename(entry.name)) fail(`"${entry.name}": a package is flat, no folders`);
440
- if (!SERVED.has(path.extname(entry.name).toLowerCase())) fail(`"${entry.name}": file type not allowed`);
441
- }
442
- const has = (name) => files.some((entry) => entry.name === name);
443
- const referenced = [
444
- manifest.files?.server,
445
- manifest.files?.browser,
446
- manifest.files?.messages,
447
- ...(manifest.files?.styles || []),
448
- ...Object.values(manifest.files?.pages || {}),
449
- ].filter(Boolean);
450
- for (const name of referenced) if (!has(name)) fail(`znode-package.json names "${name}", which isn't in the package`);
451
-
452
- const registryFile = path.join(storeDir, "plugins.json");
453
- const unlock = lockRegistry(storeDir);
454
- const registry = fs.existsSync(registryFile) ? readJson(registryFile) : { plugins: [] };
455
- // a package never replaces a file another installed package uses
456
- const owners = new Map();
457
- for (const item of registry.plugins) {
458
- if (item.name === manifest.name) continue;
459
- for (const name of [item.file, item.browser, item.messages, ...(item.styles || []), ...Object.values(item.pages || {})]) if (name) owners.set(name, item.name);
460
- }
461
- for (const entry of files) if (owners.has(entry.name)) fail(`"${entry.name}" belongs to package ${owners.get(entry.name)}`);
462
-
463
- fs.mkdirSync(storeDir, { recursive: true });
464
- for (const entry of files) fs.writeFileSync(path.join(storeDir, entry.name), entry.data);
465
-
466
- const previous = registry.plugins.find((item) => item.name === manifest.name);
467
- const defaults = Object.fromEntries(
468
- Object.entries(manifest.settingsSchema || {})
469
- .filter(([, field]) => field.default !== undefined)
470
- .map(([key, field]) => [key, field.default])
471
- );
472
- const entry = {
473
- name: manifest.name,
474
- kind: manifest.kind === "widget" ? "plugin" : manifest.kind,
475
- version: manifest.version,
476
- label: manifest.label,
477
- publisher: manifest.publisher,
478
- ...(manifest.parent ? { parent: manifest.parent } : {}),
479
- ...(manifest.files?.server ? { file: manifest.files.server } : {}),
480
- ...(manifest.files?.browser ? { browser: manifest.files.browser } : {}),
481
- ...(manifest.files?.styles?.length ? { styles: manifest.files.styles } : {}),
482
- ...(manifest.files?.messages ? { messages: manifest.files.messages } : {}),
483
- ...(manifest.files?.pages ? { pages: manifest.files.pages } : {}),
484
- ...(manifest.widgets?.length ? { widgets: manifest.widgets } : {}),
485
- // the store modules its browser bundle imports: the store loads them first (Generated Store Module Registry)
486
- ...(Array.isArray(manifest.storeImports) && manifest.storeImports.length ? { storeImports: manifest.storeImports.map(String) } : {}),
487
- ...(manifest.overrides ? { overrides: manifest.overrides } : {}),
488
- ...(manifest.serverPages?.length ? { serverPages: manifest.serverPages } : {}),
489
- ...(manifest.sandbox === true ? { sandbox: true } : {}),
490
- // the webstore accepts files (multipart) only for a package that declares them
491
- ...((manifest.permissions || []).includes("uploads") ? { uploads: true } : {}),
492
- ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}),
493
- // where it runs and its settings are the store owner's: an upgrade keeps them; a new package starts switched off
494
- enabled: previous ? previous.enabled : false,
495
- stores: previous ? previous.stores : [],
496
- settings: mergeSettings(defaults, previous?.settings || {}),
497
- };
498
- if (previous) registry.plugins[registry.plugins.indexOf(previous)] = entry;
499
- else registry.plugins.push(entry);
500
- writeRegistry(registryFile, registry);
501
- // the replaced version's files (an upgrade's old bundles and stylesheets) leave the plugin directory
502
- const kept = new Set(files.map((file) => file.name));
503
- for (const name of [previous?.file, previous?.browser, previous?.messages, ...(previous?.styles || []), ...Object.values(previous?.pages || {})]) {
504
- if (name && !kept.has(name) && name === path.basename(name)) fs.rmSync(path.join(storeDir, name), { force: true });
505
- }
506
-
507
- unlock();
508
-
509
- const missing = Object.entries(manifest.settingsSchema || {}).filter(([key, field]) => field.required && (entry.settings[key] === undefined || entry.settings[key] === ""));
510
- console.log(
511
- `installed ${manifest.kind} ${manifest.name}@${manifest.version}${previous ? ` (was ${previous.version || "unversioned"})` : ""}: ${files.length} files, ${entry.enabled ? "enabled" : "disabled"} for ${JSON.stringify(entry.stores)}`
512
- );
513
- if (missing.length) console.log(` settings to fill in before enabling: ${missing.map(([key]) => key).join(", ")}`);
514
- }
515
-
516
- // ---- create: a starter package of a kind (templates.mjs), with the API typings next to its sources ---------------
517
- function create(kind, name, dir) {
518
- if (!TEMPLATES[kind]) fail(`kind must be one of ${Object.keys(TEMPLATES).join(", ")}`);
519
- if (!NAME.test(name || "")) fail("name must be lowercase letters, digits and dashes (2 to 49)");
520
- const target = path.resolve(dir || path.join(process.cwd(), name));
521
- if (fs.existsSync(target) && fs.readdirSync(target).length) fail(`${target} exists and is not empty`);
522
- for (const [file, content] of Object.entries(TEMPLATES[kind](name))) {
523
- const full = path.join(target, file);
524
- fs.mkdirSync(path.dirname(full), { recursive: true });
525
- fs.writeFileSync(full, typeof content === "string" ? content : JSON.stringify(content, null, 2) + "\n");
526
- }
527
- if (kind !== "content") fs.copyFileSync(path.join(HERE, "..", "types", "extension-api.d.ts"), path.join(target, "src", "extension-api.d.ts"));
528
- console.log(`created ${kind} ${name} in ${target}`);
529
- console.log(" next: fill in label, publisher and description in znode-package.json, then run its build-and-upload.sh");
530
- }
531
-
532
- // ---- check: what a marketplace review looks at, from the package's own files ------------------------------------
533
- const HOOK_NAMES = ["onCartChanged", "beforeAddToCart", "afterAddToCart", "mapCartItems"];
534
-
535
- // the code an entry pulls in: the entry and the files it imports by relative path, followed (a package's server and
536
- // browser parts share a folder, so a folder read would mix them); type-only imports are skipped
537
- function sourceOf(dir, entry) {
538
- if (!entry) return "";
539
- const seen = new Set();
540
- const sources = [];
541
- const visit = (file) => {
542
- if (seen.has(file) || !fs.existsSync(file)) return;
543
- seen.add(file);
544
- const code = fs.readFileSync(file, "utf8");
545
- sources.push(code);
546
- for (const [, typeOnly, spec] of code.matchAll(/\bimport\s+(type\s+)?[^;]*?from\s+["'](\.[^"']+)["']/g)) {
547
- if (typeOnly) continue;
548
- const base = path.resolve(path.dirname(file), spec).replace(/\.(m?js)$/, "");
549
- const found = [".ts", ".tsx", ".js", ".mjs", "/index.ts", "/index.tsx"].map((ext) => base + ext).find((candidate) => fs.existsSync(candidate));
550
- if (found) visit(found);
551
- }
552
- };
553
- visit(path.join(dir, entry));
554
- return sources.join("\n");
555
- }
556
-
557
- // the server renders a browser bundle in this format from one compiled copy for all stores
558
- // (packages/extensions/src/server-instances.ts); any other is imported once per store, and Node keeps every copy
559
- function serverRenderFormat(code) {
560
- const match = /\bexport\s*(?:\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*,?\s*\}|default\s+([A-Za-z_$][\w$]*))\s*;?\s*(?:\/\/#\s*sourceMappingURL=\S*\s*)?$/.exec(code);
561
- if (!match || /(^|[^\w$.])import\s*\(/.test(code.slice(0, match.index))) return false;
562
- try {
563
- new vm.Script(`(function () {\n"use strict";\n${code.slice(0, match.index)}\nreturn ${match[1] || match[2]};\n})`);
564
- return true;
565
- } catch {
566
- return false;
567
- }
568
- }
569
-
570
- function check(target) {
571
- const { dir, manifestFile } = resolvePackage(target);
572
- const manifest = readJson(manifestFile);
573
- validateManifest(manifest, path.basename(manifestFile));
574
- const errors = [];
575
- const warnings = [];
576
-
577
- // the listing
578
- for (const key of ["label", "publisher", "description"]) if (!String(manifest[key] || "").trim()) errors.push(`"${key}" is missing (the marketplace shows it)`);
579
- if (/describe what it does/i.test(manifest.description || "") || manifest.publisher === "Your company") errors.push("the starter's publisher or description is still in place");
580
- else if (String(manifest.description || "").length < 30) warnings.push('"description" is short: say what it does for the store');
581
- for (const key of ["license", "compatibility"]) if (!manifest[key]) warnings.push(`"${key}" is missing`);
582
-
583
- // permissions against the code
584
- const server = sourceOf(dir, manifest.build?.server);
585
- const browser = sourceOf(dir, manifest.build?.browser);
586
- const used = new Set();
587
- if (/\broutes\s*[:=,}]/.test(server)) used.add("routes");
588
- if (HOOK_NAMES.some((hook) => new RegExp(`\\b${hook}\\b`).test(server))) used.add("cartHooks");
589
- if (Object.keys(manifest.overrides || {}).length) used.add("overrides");
590
- if (/\bznode\s*\(/.test(server)) used.add("znodeApi");
591
- if (/\bfetch\s*\(|\bhttps?\.request\s*\(/.test(server)) used.add("externalNetwork");
592
- if (/\.files\b/.test(server)) used.add("uploads");
593
- const declared = new Set(manifest.permissions || []);
594
- for (const item of used) if (!declared.has(item)) errors.push(`uses "${item}" without declaring it in "permissions"`);
595
- for (const item of declared) if (!used.has(item)) warnings.push(`declares "${item}" but no code uses it (the review asks why)`);
596
- if (/\beval\s*\(|new Function\s*\(/.test(server + browser)) errors.push("uses eval or new Function");
597
- if (/document\.cookie/.test(browser)) warnings.push("reads cookies in the browser: keep secrets on the server (routes)");
598
-
599
- // settings
600
- for (const [key, field] of Object.entries(manifest.settingsSchema || {})) {
601
- if (!field.label) warnings.push(`setting "${key}" has no label (the admin shows it)`);
602
- if (field.required && field.default !== undefined) warnings.push(`setting "${key}" is required but has a default`);
603
- if (field.browser && /secret|password|token|api_?key/i.test(key)) errors.push(`setting "${key}" looks secret but is sent to the browser ("browser": true)`);
604
- }
605
- if (manifest.kind === "theme" && !Object.values(manifest.settingsSchema || {}).some((field) => field.attribute)) {
606
- warnings.push("no setting is bound to a Znode attribute: per-store branding then needs a registry change per store");
607
- }
608
-
609
- // the built files (pack first)
610
- const out = path.join(dir, "dist");
611
- if (fs.existsSync(out)) {
612
- let total = 0;
613
- for (const name of fs.readdirSync(out)) {
614
- const size = fs.statSync(path.join(out, name)).size;
615
- total += size;
616
- if (name.endsWith(".browser.mjs")) {
617
- if (size > 512 * 1024) warnings.push(`${name} is ${Math.round(size / 1024)} KB: every page of the store loads it`);
618
- if (!serverRenderFormat(fs.readFileSync(path.join(out, name), "utf8")))
619
- warnings.push(`${name} is not in the server-render format (no imports, one default export): the server keeps a copy per store`);
620
- }
621
- if (name.endsWith(".css") && size > 200 * 1024) warnings.push(`${name} is ${Math.round(size / 1024)} KB`);
622
- }
623
- if (total > MAX_BYTES) errors.push(`dist is ${Math.round(total / 1048576)} MB (limit 10 MB)`);
624
- } else warnings.push("not built yet: pack it to check sizes and the server-render format");
625
-
626
- for (const item of errors) console.log(`error ${item}`);
627
- for (const item of warnings) console.log(`warning ${item}`);
628
- console.log(`check ${manifest.name}@${manifest.version}: ${errors.length} errors, ${warnings.length} warnings`);
629
- if (errors.length) process.exit(1);
630
- }
631
-
632
- // ---- project commands: deploy, dev, link-store ------------------------------------------------------------------
633
-
634
- const needStore = (storeDir) =>
635
- storeDir || fail("needs the store's plugin folder: pass it after the package, or set RUNTIME_PLUGINS_DIR");
636
-
637
- // pack, then install the zip it made into the store's plugin folder
638
- function deploy(target, storeDir) {
639
- pack(target);
640
- const { dir, manifestFile } = resolvePackage(target);
641
- const manifest = readJson(manifestFile);
642
- install(path.join(dir, "releases", `${manifest.name}-${manifest.version}.zpkg.zip`), storeDir);
643
- }
644
-
645
- // deploy now, then again whenever the package's sources or manifest change; the store picks a new file up on the next
646
- // request (it reloads a package when its files change), so a page refresh shows the change
647
- function dev(target, storeDir) {
648
- const { dir } = resolvePackage(target);
649
- const self = fileURLToPath(import.meta.url);
650
- const run = () => spawnSync(process.execPath, [self, "deploy", dir, storeDir], { stdio: "inherit" });
651
- run();
652
- let timer = null;
653
- const changed = (file) => {
654
- if (!file || /(^|[\\/])(dist|releases|\.build|node_modules)([\\/]|$)/.test(String(file))) return;
655
- clearTimeout(timer);
656
- timer = setTimeout(() => {
657
- console.log(`znode dev: ${file} changed, deploying`);
658
- run();
659
- }, 300);
660
- };
661
- fs.watch(path.join(dir, "src"), { recursive: true }, (_event, file) => changed(file && path.join("src", file)));
662
- fs.watch(dir, (_event, file) => file === MANIFEST && changed(file));
663
- console.log(`znode dev: watching ${dir} (Ctrl+C to stop)`);
664
- }
665
-
666
- // the store's real types for the editor and the build check: tsconfig.store.json (git-ignored, per machine) is this
667
- // package's preset plus the store repo's aliases (every @znode path of its tsconfig.base.json, from the store's folder).
668
- // Only the aliases are taken: the store's own options (its rootDir among them) would refuse a project kept elsewhere.
669
- function linkStore(storeRepo, project = ".") {
670
- const base = path.resolve(storeRepo, "tsconfig.base.json");
671
- if (!fs.existsSync(base)) fail(`${base} not found: pass the store repo's folder (the one with tsconfig.base.json)`);
672
- const ts = require("typescript");
673
- const { config, error } = ts.readConfigFile(base, ts.sys.readFile);
674
- if (error) fail(`${base}: ${ts.flattenDiagnosticMessageText(error.messageText, "\n")}`);
675
- const options = config.compilerOptions || {};
676
- const toSlash = (value) => value.split(path.sep).join("/");
677
- const file = path.resolve(project, "tsconfig.store.json");
678
- const linked = {
679
- extends: "znode-plugin/tsconfig.json",
680
- compilerOptions: { baseUrl: toSlash(path.resolve(path.dirname(base), options.baseUrl || ".")), paths: options.paths || {} },
681
- };
682
- fs.writeFileSync(file, JSON.stringify(linked, null, 2) + "\n");
683
- console.log(`linked ${file} to ${base} (${Object.keys(linked.compilerOptions.paths).length} store aliases)`);
684
- }
685
-
686
- // ---- eject: a store component copied into the package, in place of the store's, through its slot ----------------
687
- // A store component that has a runtime slot whose children are the component itself (withRuntimeWrapper(Wrapper, Base,
688
- // "Name") where it is defined) can be taken over by a package: `znode eject <Name>` copies its file from the linked store
689
- // into src/components/<Name>.tsx (relative imports turned into the store's @znode aliases, the runtime wrapper removed so
690
- // the copy is the plain component) and registers it in src/browser.tsx under the slot, so the store renders the copy
691
- // instead of its own. `znode eject` lists the components that can be ejected.
692
- const EJECT_SLOTS = {
693
- RuntimeProductBasicInfo: "product.basicInfo",
694
- RuntimeProductCard: "product.card",
695
- RuntimeProductImage: "product.image",
696
- RuntimeAddToCart: "product.addToCart",
697
- RuntimeProductVariants: "product.variants",
698
- RuntimeAddToCartNotification: "addToCart.notification",
699
- RuntimeCheckoutAddress: "checkout.address",
700
- RuntimeShippingOptions: "checkout.shippingOptions",
701
- };
702
-
703
- // the slot each store wrapper renders: every `export function Runtime<X>` in packages/extensions/src/slots with the first
704
- // <RuntimeSlot name="…"> in its body (so a slot added with `znode add-slot` is ejectable with no SDK release); the
705
- // hand-kept EJECT_SLOTS above fill in for a store that predates this
706
- function storeWrapperSlots(root) {
707
- const slots = { ...EJECT_SLOTS };
708
- const dir = path.join(root, "packages/extensions/src/slots");
709
- if (!fs.existsSync(dir)) return slots;
710
- for (const entry of fs.readdirSync(dir)) {
711
- if (!/\.tsx$/.test(entry)) continue;
712
- const text = fs.readFileSync(path.join(dir, entry), "utf8");
713
- const starts = [...text.matchAll(/export\s+function\s+(Runtime\w+)/g)];
714
- starts.forEach((m, i) => {
715
- const body = text.slice(m.index, i + 1 < starts.length ? starts[i + 1].index : undefined);
716
- // the first slot that is handed the component as children (<RuntimeSlot name="…">…{children}…</RuntimeSlot>); a
717
- // layout slot (self-closing, the base rendered hidden beside it) cannot take an ejected copy
718
- for (const open of body.matchAll(/<RuntimeSlot\s+name=["']([\w.]+)["']/g)) {
719
- const rest = body.slice(open.index + open[0].length);
720
- const close = rest.indexOf("</RuntimeSlot>");
721
- const next = rest.indexOf("<RuntimeSlot");
722
- if (close >= 0 && (next < 0 || close < next) && /\{\s*children\s*\}/.test(rest.slice(0, close))) {
723
- if (!slots[m[1]]) slots[m[1]] = open[1];
724
- break;
725
- }
726
- }
727
- });
728
- }
729
- return slots;
730
- }
731
-
732
- // every withRuntimeWrapper(<Wrapper>, <Base>, "<Name>") in the store's base components, with the slot it fills
733
- function ejectableComponents(root) {
734
- const EJECT_SLOTS = storeWrapperSlots(root);
735
- const found = [];
736
- const walk = (dir) => {
737
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
738
- const file = path.join(dir, entry.name);
739
- if (entry.isDirectory()) {
740
- if (entry.name !== "node_modules") walk(file);
741
- } else if (/\.tsx?$/.test(entry.name)) {
742
- const text = fs.readFileSync(file, "utf8");
743
- for (const m of text.matchAll(/withRuntimeWrapper\(\s*(\w+)\s*,\s*(\w+)\s*,\s*"(\w+)"\s*\)/g)) {
744
- if (EJECT_SLOTS[m[1]]) found.push({ name: m[3], wrapper: m[1], base: m[2], slot: EJECT_SLOTS[m[1]], file });
745
- }
746
- }
747
- }
748
- };
749
- for (const dir of ["packages/base-components/src", "packages/page-builder/src"]) if (fs.existsSync(path.join(root, dir))) walk(path.join(root, dir));
750
- return found.sort((a, b) => a.name.localeCompare(b.name));
751
- }
752
-
753
- // a store file's import path as the package can import it: the store alias that maps to that file, without extension
754
- function storeAlias(store, absolute) {
755
- const target = absolute.split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
756
- let best = null;
757
- const norm = (value) => path.resolve(store.root, value).split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
758
- // an exact alias for this very module ("@znode/base-components/stores": [".../stores/index.ts"]) wins
759
- for (const [alias, targets] of Object.entries(store.paths)) {
760
- if (!alias.endsWith("/*") && targets.some((pattern) => norm(pattern) === target)) return alias;
761
- }
762
- for (const [alias, targets] of Object.entries(store.paths)) {
763
- if (!alias.endsWith("/*")) continue;
764
- for (const pattern of targets) {
765
- const prefix = path.resolve(store.root, pattern.replace(/\*$/, "")).split(path.sep).join("/").replace(/\/$/, "") + "/";
766
- if (target.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) best = { prefix, alias: alias.slice(0, -1) };
767
- }
768
- }
769
- return best ? best.alias + target.slice(best.prefix.length) : null;
770
- }
771
-
772
- function eject(name, force) {
773
- const project = path.resolve(".");
774
- if (!fs.existsSync(path.join(project, MANIFEST))) fail(`run znode eject in a package folder (the one with ${MANIFEST})`);
775
- const store = linkedStore(project);
776
- if (!store) fail("the package is not linked to a store: npm run link-store -- <store repo>");
777
- const components = ejectableComponents(store.root);
778
- if (!name || name === "--list") {
779
- console.log("Store components a package can eject (the slot that takes your copy):\n");
780
- for (const c of components) console.log(` ${c.name.padEnd(34)} ${c.slot.padEnd(26)} ${path.relative(store.root, c.file).split(path.sep).join("/")}`);
781
- console.log("\nznode eject <Name> copies one into src/components/ and registers it in src/browser.tsx");
782
- return;
783
- }
784
- const c = components.find((item) => item.name.toLowerCase() === name.toLowerCase() || item.slot.toLowerCase() === name.toLowerCase());
785
- if (!c)
786
- fail(
787
- `${name} cannot be ejected: no store component with that name or slot hands itself over to a slot.\n` +
788
- ` znode eject lists the ones that can (component name or slot name both work)\n` +
789
- ` znode add-slot ${/^[A-Z]/.test(name) ? name : "<Component>"} <area.name> adds a slot for a store component (edits the linked store's source)`
790
- );
791
-
792
- const out = path.join(project, "src", "components", `${c.name}.tsx`);
793
- if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${c.name} --force`);
794
- const warnings = [];
795
- let text = fs.readFileSync(c.file, "utf8");
796
- // relative imports become the store's aliases (the package lives elsewhere); a style file stays the store's
797
- text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after) => {
798
- const absolute = path.resolve(path.dirname(c.file), spec);
799
- if (/\.(s?css)$/.test(spec)) {
800
- 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/`);
801
- return `${before}${spec}${after}`;
802
- }
803
- const alias = storeAlias(store, fs.existsSync(absolute) || /\.\w+$/.test(spec) ? absolute : absolute);
804
- if (!alias) {
805
- warnings.push(`import ${spec}: no store alias for it; change it by hand`);
806
- return whole;
807
- }
808
- return `${before}${alias}${after}`;
809
- });
810
- // drop scss/css side-effect imports that point into the store (they cannot be bundled into a package)
811
- text = text.replace(/^import\s+["'][^"']+\.s?css["'];?\s*\n/gm, "");
812
- // the copy is the plain component: no runtime wrapper (the slot is what puts it in place)
813
- text = text.replace(new RegExp(`^.*import\\s*\\{[^}]*\\b(?:withRuntimeWrapper|${c.wrapper})\\b[^}]*\\}\\s*from\\s*["'][^"']+["'];?\\s*\\n`, "gm"), "");
814
- text = text.replace(new RegExp(`withRuntimeWrapper\\(\\s*${c.wrapper}\\s*,\\s*${c.base}\\s*,\\s*"${c.name}"\\s*\\)`), c.base);
815
- const storeFile = path.relative(store.root, c.file).split(path.sep).join("/");
816
- const header = `// Ejected from the store: ${storeFile} (${new Date().toISOString().slice(0, 10)}), with \`znode eject ${c.name}\`.\n// The store renders this copy instead of its own through the slot "${c.slot}" (src/browser.tsx). It is yours now: the\n// store's later changes to ${c.name} do not reach it, so compare it with the store's file after a store upgrade.\n`;
817
- text = text.startsWith('"use client"') || text.startsWith("'use client'") ? text.replace(/^(["']use client["'];?\s*\n)/, `$1${header}`) : header + text;
818
- fs.mkdirSync(path.dirname(out), { recursive: true });
819
- fs.writeFileSync(out, text);
820
- console.log(`ejected ${c.name} -> ${path.relative(project, out)}`);
821
-
822
- // npm packages the copy imports that this package does not have (react, next and the store's @znode modules come from
823
- // the store): install them at the store's version, so the types match and the build finds them
824
- const PROVIDED = /^(react|react-dom|next|next-intl|@znode)(\/|$)/;
825
- const storePackage = (() => {
826
- try {
827
- return readJson(path.join(store.root, "package.json"));
828
- } catch {
829
- return {};
830
- }
831
- })();
832
- const storeDeps = { ...(storePackage.devDependencies || {}), ...(storePackage.dependencies || {}) };
833
- const missing = new Map();
834
- for (const m of text.matchAll(/(?:from|import)\s*\(?\s*["']([^."'/][^"']*)["']/g)) {
835
- const spec = m[1];
836
- if (PROVIDED.test(spec)) continue;
837
- const pkg = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
838
- try {
839
- createRequire(path.join(project, "package.json")).resolve(`${pkg}/package.json`);
840
- } catch {
841
- missing.set(pkg, storeDeps[pkg] ? `${pkg}@${storeDeps[pkg].replace(/^[\^~]/, "")}` : pkg);
842
- }
843
- }
844
- if (missing.size) {
845
- console.log(`\nThe copy imports npm packages this package does not have (bundled into the package when it builds):`);
846
- console.log(` npm install ${[...missing.values()].join(" ")}`);
847
- }
848
-
849
- // register the copy under the slot in src/browser.tsx
850
- const browser = path.join(project, "src", "browser.tsx");
851
- const exportsDefault = new RegExp(`export\\s+default\\s+${c.name}\\b`).test(text) || new RegExp(`export\\s+default\\s+${c.base}\\b`).test(text);
852
- const local = `Ejected${c.name}`;
853
- const importLine = exportsDefault
854
- ? `import ${local} from "./components/${c.name}.js";`
855
- : `import { ${c.name} as ${local} } from "./components/${c.name}.js";`;
856
- if (!fs.existsSync(browser)) {
857
- warnings.push(`src/browser.tsx not found: register it yourself: ${importLine} and slots: { "${c.slot}": ${local} }`);
858
- } else {
859
- let source = fs.readFileSync(browser, "utf8");
860
- if (new RegExp(`["']${c.slot.replace(".", "\\.")}["']\\s*:`).test(source)) {
861
- warnings.push(`src/browser.tsx already fills "${c.slot}": point it at ${local} (${importLine}) yourself`);
862
- } else if (/slots\s*:\s*\{/.test(source)) {
863
- if (!source.includes(importLine)) {
864
- const imports = [...source.matchAll(/^import .*;\s*$/gm)];
865
- const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
866
- source = source.slice(0, at) + `\n${importLine}` + source.slice(at);
867
- }
868
- source = source.replace(/slots\s*:\s*\{\s*\}/, `slots: {\n "${c.slot}": ${local},\n }`);
869
- if (!source.includes(`"${c.slot}": ${local}`)) source = source.replace(/slots\s*:\s*\{/, `slots: {\n // ejected (znode eject ${c.name}): the store renders this copy instead of its own\n "${c.slot}": ${local},`);
870
- fs.writeFileSync(browser, source);
871
- console.log(`registered "${c.slot}": ${local} in src/browser.tsx`);
872
- } else {
873
- warnings.push(`no "slots" in src/browser.tsx: add ${importLine} and slots: { "${c.slot}": ${local} }`);
874
- }
875
- }
876
- for (const warning of warnings) console.log(`warning ${warning}`);
877
- console.log(`\nNext: npm run build (it names any store import a package cannot use), then npm run dev and open a page that shows ${c.name}.`);
878
- }
879
-
880
- // ---- add-slot: a runtime slot around a store component, written into the linked store's source ------------------
881
- // `znode add-slot <Component> <area.name>` does in the store what a base developer did by hand for product.basicInfo:
882
- // a Runtime<Component> wrapper (packages/extensions/src/slots/RuntimeAddedSlots.tsx) that renders <RuntimeSlot name>
883
- // with the component's props and the component itself as children, and the component's export wrapped with
884
- // withRuntimeWrapper, so every place that renders it gets the slot. It edits source only (never .next, never git):
885
- // review the diff, commit it through the store's own flow, rebuild the webstore; then `znode eject <Component>` works.
886
- const ADDED_SLOTS_FILE = "packages/extensions/src/slots/RuntimeAddedSlots.tsx";
887
- const SLOT_NAME = /^[a-z][A-Za-z0-9]*(\.[a-z][A-Za-z0-9]*)+$/;
888
-
889
- function storeSourceFiles(root) {
890
- const files = [];
891
- const walk = (dir) => {
892
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
893
- const file = path.join(dir, entry.name);
894
- if (entry.isDirectory()) {
895
- if (entry.name !== "node_modules" && !entry.name.startsWith("__")) walk(file);
896
- } else if (/\.tsx$/.test(entry.name) && !/\.(test|spec|stories)\.tsx$/.test(entry.name)) files.push(file);
897
- }
898
- };
899
- for (const dir of ["packages/base-components/src", "packages/page-builder/src"]) if (fs.existsSync(path.join(root, dir))) walk(path.join(root, dir));
900
- return files;
901
- }
902
-
903
- function addSlot(name, slot, { dryRun, file: chosen }) {
904
- const usage = "usage: znode add-slot <Component> <area.name> [--dry-run] [--file <store file>] e.g. znode add-slot ProductBasicInfo product.basicInfo";
905
- if (!name || !slot) fail(usage);
906
- if (!/^[A-Z]\w*$/.test(name)) fail(`"${name}" is not a component name (PascalCase, e.g. ProductDetails)\n${usage}`);
907
- if (!SLOT_NAME.test(slot)) fail(`"${slot}" is not a slot name: area.name in camelCase, e.g. product.details`);
908
- const store = linkedStore(path.resolve("."));
909
- if (!store) fail("not linked to a store: run it in a package linked with npm run link-store -- <store repo> (add-slot edits that store's source)");
910
- const root = store.root;
911
- const rel = (file) => path.relative(root, file).split(path.sep).join("/");
912
-
913
- // the slot must be new
914
- const slotsDir = path.join(root, "packages/extensions/src/slots");
915
- if (!fs.existsSync(slotsDir)) fail(`${rel(slotsDir)} not found: this store has no runtime slots (packages/extensions)`);
916
- const wrapperSlots = storeWrapperSlots(root);
917
- const taken = Object.entries(wrapperSlots).find(([, value]) => value === slot);
918
- if (taken) fail(`slot "${slot}" already exists (${taken[0]}); znode eject lists what it hands over`);
919
- const wrapper = `Runtime${name}`;
920
- if (wrapperSlots[wrapper] || fs.readdirSync(slotsDir).some((f) => new RegExp(`export\\s+function\\s+${wrapper}\\b`).test(fs.readFileSync(path.join(slotsDir, f), "utf8"))))
921
- fail(`${wrapper} already exists in ${rel(slotsDir)}: ${name} already has a slot (znode eject lists it)`);
922
-
923
- // the file that defines the component
924
- const DEFINES = new RegExp(`^(export\\s+(?:default\\s+)?)?(async\\s+)?function\\s+${name}\\s*[(<]|^(export\\s+)?const\\s+${name}\\s*(?::[^=]+)?=\\s*(async\\b)?`, "m");
925
- let candidates = chosen ? [path.resolve(root, chosen)] : storeSourceFiles(root).filter((f) => DEFINES.test(fs.readFileSync(f, "utf8")));
926
- if (chosen && !fs.existsSync(candidates[0])) fail(`${chosen} not found in ${root}`);
927
- if (candidates.length > 1) {
928
- const named = candidates.filter((f) => path.basename(f, ".tsx") === name);
929
- if (named.length === 1) candidates = named;
930
- else fail(`${name} is defined in more than one store file; pick one with --file:\n ${candidates.map(rel).join("\n ")}`);
931
- }
932
- if (!candidates.length) fail(`no ${name} component found in packages/base-components/src or packages/page-builder/src (only .tsx components can get a slot)`);
933
- const target = candidates[0];
934
- let source = fs.readFileSync(target, "utf8");
935
- const def = source.match(DEFINES);
936
- if (!def) fail(`${rel(target)} does not define ${name} as a function or const component`);
937
-
938
- // only a client-renderable component: a slot is filled in the browser
939
- if (new RegExp(`withRuntimeWrapper\\([^)]*["']${name}["']`).test(source)) fail(`${name} is already wrapped (${rel(target)}): it has a slot, znode eject lists it`);
940
- if (def[2] || def[4])
941
- fail(
942
- `${name} is an async server component (${rel(target)}): a runtime slot is filled in the browser, so it cannot wrap a server component.\n` +
943
- ` Add the slot to a client component it renders instead (e.g. its ProductBasicInfo), or split ${name} into a server part (data) and a client part (UI) and add the slot to the client part.`
944
- );
945
- const serverImport = source.match(/^\s*import\b[^;]*["'](server-only|next\/headers|fs|node:fs)["']/m);
946
- if (serverImport) fail(`${name} imports ${serverImport[1]} (${rel(target)}): it runs on the server only, so a browser slot cannot wrap it`);
947
-
948
- // wrap the export: <Name>Base is the component, <Name> is the wrapped one every caller already imports
949
- const baseName = `${name}Base`;
950
- if (new RegExp(`\\b${baseName}\\b`).test(source)) fail(`${rel(target)} already has a ${baseName}: wrap it by hand`);
951
- const wrapped = `withRuntimeWrapper(${wrapper}, ${baseName}, "${name}")`;
952
- const comment = `// runtime packages render around it through the slot "${slot}" (znode add-slot); with none, it renders as it is`;
953
- if (/^export\s+default\s+/.test(def[0])) {
954
- source = source.replace(def[0], def[0].replace(/^export\s+default\s+/, "").replace(name, baseName));
955
- source = source.trimEnd() + `\n\n${comment}\nconst ${name} = ${wrapped};\n\nexport default ${name};\n`;
956
- } else if (def[1] || def[3]) {
957
- source = source.replace(def[0], def[0].replace(/^export\s+/, "").replace(name, baseName));
958
- source = source.trimEnd() + `\n\n${comment}\nexport const ${name} = ${wrapped};\n`;
959
- } else {
960
- // defined, exported later (export default Name; / export { Name }): the wrapped const goes right before that export
961
- source = source.replace(def[0], def[0].replace(name, baseName));
962
- const exportAt = source.search(new RegExp(`^export\\s+(default\\s+${name}\\b|\\{[^}]*\\b${name}\\b)`, "m"));
963
- const line = `${comment}\nconst ${name} = ${wrapped};\n\n`;
964
- source = exportAt >= 0 ? source.slice(0, exportAt) + line + source.slice(exportAt) : source.trimEnd() + `\n\n${line}`;
965
- }
966
- // statics set on the component belong to the base (they run before the wrapped const exists)
967
- source = source.replace(new RegExp(`\\b${name}\\.(displayName|defaultProps|propTypes)\\b`, "g"), `${baseName}.$1`);
968
- const imports = [`import { withRuntimeWrapper } from "@znode/extensions/slots/with-runtime-wrapper";`, `import { ${wrapper} } from "@znode/extensions/slots/RuntimeAddedSlots";`].filter(
969
- (line) => !(line.includes("withRuntimeWrapper }") && /import\s*\{[^}]*\bwithRuntimeWrapper\b/.test(source))
970
- );
971
- const directive = source.match(/^\s*["']use (client|server)["'];?\s*\n/);
972
- const at = directive ? directive[0].length : 0;
973
- source = source.slice(0, at) + (directive ? "\n" : "") + imports.join("\n") + "\n" + (directive ? "" : "\n") + source.slice(at).replace(/^\n+/, directive ? "" : "");
974
-
975
- // the wrapper
976
- const addedFile = path.join(root, ADDED_SLOTS_FILE);
977
- const addedBefore = fs.existsSync(addedFile) ? fs.readFileSync(addedFile, "utf8") : null;
978
- let added =
979
- addedBefore ??
980
- `"use client";\n\n// Slots added with \`znode add-slot <Component> <area.name>\` (znode-plugin): each Runtime<Component> renders the slot\n// around the store component (withRuntimeWrapper where the component is defined), with the component's props and the\n// component itself as children. With no runtime package for the slot, the component renders as it is.\n\nimport { ReactNode } from "react";\nimport { RuntimeSlot } from "../client/RuntimeSlot";\n`;
981
- added =
982
- added.trimEnd() +
983
- `\n\n/** ${slot}: the store's ${name} (children), with every prop it gets. Added with \`znode add-slot ${name} ${slot}\` (${new Date().toISOString().slice(0, 10)}). */\n` +
984
- `export function ${wrapper}({ children, ...props }: Readonly<Record<string, unknown> & { children?: ReactNode }>) {\n` +
985
- ` return (\n <RuntimeSlot name="${slot}" props={props}>\n {children}\n </RuntimeSlot>\n );\n}\n`;
986
-
987
- const changes = [
988
- { file: target, before: fs.readFileSync(target, "utf8"), after: source },
989
- { file: addedFile, before: addedBefore, after: added },
990
- ];
991
- const warnings = [];
992
-
993
- // the slot list in the SDK types of the store (packages/extensions/src/types.ts), when it has one
994
- const typesFile = path.join(root, "packages/extensions/src/types.ts");
995
- if (fs.existsSync(typesFile)) {
996
- const before = fs.readFileSync(typesFile, "utf8");
997
- const lines = before.split("\n");
998
- const start = lines.findIndex((line) => /^\s*\/\/\s*Slots\s*\(/.test(line));
999
- if (start >= 0) {
1000
- let end = start + 1;
1001
- while (end < lines.length && /^\s*\/\/\s{3,}/.test(lines[end])) end++;
1002
- lines.splice(end, 0, `// ${slot.padEnd(20)} the store's ${name} itself (children) props: every prop ${name} gets (znode add-slot)`);
1003
- changes.push({ file: typesFile, before, after: lines.join("\n") });
1004
- } else warnings.push(`${rel(typesFile)}: no "// Slots (" comment found; add "${slot}" to the slot list yourself`);
1005
- }
1006
- // the slot table in docs/RUNTIME_EXTENSIONS_AGENTS.md, when it has one
1007
- const docsFile = path.join(root, "docs/RUNTIME_EXTENSIONS_AGENTS.md");
1008
- if (fs.existsSync(docsFile)) {
1009
- const before = fs.readFileSync(docsFile, "utf8");
1010
- const lines = before.split("\n");
1011
- let row = lines.findIndex((line) => line.startsWith("| `product.card`"));
1012
- if (row >= 0) {
1013
- while (row + 1 < lines.length && lines[row + 1].startsWith("|")) row++;
1014
- lines.splice(row + 1, 0, `| \`${slot}\` | \`${wrapper}\` (\`${ADDED_SLOTS_FILE.replace("packages/extensions/src/", "")}\`) | \`${rel(target).replace(/^packages\/[\w-]+\/src\//, "").replace(/\.tsx$/, "")}\` (its children: the component itself) | every ${name} prop; keep \`children\`, wrap it, or render a copy instead (\`znode eject ${name}\`) |`);
1015
- changes.push({ file: docsFile, before, after: lines.join("\n") });
1016
- } else warnings.push(`${rel(docsFile)}: slot table not found; add a "${slot}" row yourself`);
1017
- }
1018
-
1019
- console.log(`${dryRun ? "would add" : "adding"} slot "${slot}" around ${name} (${rel(target)}) in ${root}\n`);
1020
- for (const { file, before, after } of changes) {
1021
- const old = new Set((before || "").split("\n"));
1022
- const added = after.split("\n").filter((line) => !old.has(line));
1023
- console.log(`${before === null ? "new " : "edit"} ${rel(file)}`);
1024
- for (const line of added) console.log(` + ${line}`);
1025
- }
1026
- if (dryRun) {
1027
- console.log("\n--dry-run: nothing written. Run it again without --dry-run to write these changes.");
1028
- return;
1029
- }
1030
- for (const { file, after } of changes) fs.writeFileSync(file, after);
1031
-
1032
- // the store's module registry, when the store generates one (the ejected copy's imports are checked against it)
1033
- const registry = path.join(root, "scripts/gen-store-module-registry.mjs");
1034
- if (fs.existsSync(registry)) spawnSync(process.execPath, [registry], { cwd: root, stdio: "inherit" });
1035
- for (const warning of warnings) console.log(`warning ${warning}`);
1036
- console.log(
1037
- `\nDone (the store's source only: nothing committed, .next untouched). Next:\n` +
1038
- ` 1. review the diff in ${root} (git diff) and commit it through the store's own flow (hooks, review: it edits base components)\n` +
1039
- ` 2. rebuild / restart the webstore so it renders the slot\n` +
1040
- ` 3. here: npx znode eject ${name} (or fill "${slot}" in src/browser.tsx with a component of your own)`
1041
- );
1042
- }
1043
-
1044
- const args = process.argv.slice(2);
1045
- // the store's plugin folder: the argument, else RUNTIME_PLUGINS_DIR, else the project's git-ignored znode.local.json
1046
- // ({ "pluginsDir": "…" }, written by create-znode-plugin --plugins-dir) in the folder the command runs in
1047
- function localPluginsDir() {
1048
- try {
1049
- return JSON.parse(fs.readFileSync(path.resolve("znode.local.json"), "utf8")).pluginsDir || undefined;
1050
- } catch {
1051
- return undefined;
1052
- }
1053
- }
1054
- const [command, target, storeDir = process.env.RUNTIME_PLUGINS_DIR || localPluginsDir()] = args;
1055
- if (command === "create") {
1056
- create(args[1], args[2], args[3]);
1057
- process.exit(0);
1058
- }
1059
- if (command === "eject") {
1060
- eject(args.find((arg, index) => index > 0 && !arg.startsWith("--")), args.includes("--force"));
1061
- process.exit(0);
1062
- }
1063
- if (command === "add-slot" || (command === "add" && args[1] === "slot")) {
1064
- const rest = args.slice(command === "add" ? 2 : 1).filter((arg) => arg !== "--");
1065
- const fileAt = rest.indexOf("--file");
1066
- const file = fileAt >= 0 ? rest[fileAt + 1] : undefined;
1067
- const positional = rest.filter((arg, index) => !arg.startsWith("--") && !(fileAt >= 0 && index === fileAt + 1));
1068
- addSlot(positional[0], positional[1], { dryRun: rest.includes("--dry-run"), file });
1069
- process.exit(0);
1070
- }
1071
- if (command === "link-store") {
1072
- if (!target) fail("usage: znode link-store <storeRepoDir> [projectDir]");
1073
- linkStore(target, args[2]);
1074
- process.exit(0);
1075
- }
1076
- if (!target) fail("usage: znode <build|pack|check|install|deploy|dev> <dir|zip> [storeDir], znode create <kind> <name> [dir], znode link-store <storeRepoDir>");
1077
- if (command === "check") check(path.resolve(target));
1078
- else if (command === "build") build(path.resolve(target));
1079
- else if (command === "pack") pack(path.resolve(target));
1080
- else if (command === "install") install(path.resolve(target), path.resolve(needStore(storeDir)));
1081
- else if (command === "deploy") deploy(path.resolve(target), path.resolve(needStore(storeDir)));
1082
- else if (command === "dev") dev(path.resolve(target), path.resolve(needStore(storeDir)));
1083
- else fail(`unknown command "${command}"`);
1
+ #!/usr/bin/env node
2
+ // znode-plugin: build, pack and install Znode runtime packages (themes, plugins, widgets, content).
3
+ //
4
+ // znode build <packageDir|manifest.json> build into <packageDir>/dist
5
+ // znode pack <packageDir|manifest.json> build, then zip it as
6
+ // <packageDir>/releases/<name>-<version>.zpkg.zip
7
+ // znode install <zip|packageDir> [storeDir] validate and install into the store's plugin
8
+ // directory (default: RUNTIME_PLUGINS_DIR);
9
+ // the registry entry keeps its settings and stores
10
+ // znode create <theme|plugin|widget|content> <name> [dir]
11
+ // a starter package that builds as it is
12
+ // (default dir ./<name>)
13
+ // znode check <packageDir> marketplace review: metadata, permissions against
14
+ // the code, settings, size, server-render format
15
+ // znode deploy <packageDir> [storeDir] pack, then install into the store's plugin directory
16
+ // znode dev <packageDir> [storeDir] deploy, then again on every change under src/ or to the manifest
17
+ // znode link-store <storeRepoDir> [dir] the store's real types: writes tsconfig.store.json (see README)
18
+ // znode eject [<Name>] [--force] in a package folder: list the store components that can be ejected, or
19
+ // copy one into src/components/ and register it in its slot
20
+ // (<Name> is the component or its slot name); no slot yet: it asks
21
+ // to create one in the base store first (--slot <area.name> / --yes)
22
+ // znode create-slot <Component> <area.name> [--dry-run] [--file <f>] a slot around a store component, written into
23
+ // the linked store's source (base developers; review and commit there)
24
+ // znode slot list [<filter>] [--json] every slot the linked store renders, what it hands over, what can be
25
+ // ejected into it and what this package fills (also: znode slots)
26
+ //
27
+ // A package is a folder with znode-package.json (README.md shows a whole project, as create-znode-plugin makes it):
28
+ // {
29
+ // "name": "artifi", "version": "1.0.0", "kind": "plugin" | "theme" | "widget" | "content", "apiVersion": 1,
30
+ // "label": "...", "publisher": "...", "description": "...", "parent": "<theme>", (parent: themes only)
31
+ // "build": { "server": "src/server.ts", "browser": "src/browser.tsx" }, (TypeScript entry points)
32
+ // "styles": ["src/styles/*.css"], "assets": ["src/assets/fonts/*.woff2"], "messages": "src/i18n/labels.json",
33
+ // "pages": { "/": "src/pages/home.json" }, "widgets": ["HeroBanner"],
34
+ // code imports Next.js as an app does: next/image, next/link, next/navigation, next/font/google, react
35
+ // (next-imports.mjs: the store's own modules at runtime; Google fonts downloaded into the package when it builds)
36
+ // "overrides": { "/api/place-order": "place-order" }, (store BFF routes it takes over, by one of its routes)
37
+ // "serverPages": ["/my-page"], (whole pages at new URLs, rendered by its server part's serverPages)
38
+ // "dedupeStyles": true, (styles: drop the rules the store already has; STORE_URL = a page of a running store)
39
+ // "settingsSchema": { "websiteId": { "type": "string", "required": true, "browser": true, "label": "..." },
40
+ // "banner": { "type": "string", "browser": true, "attribute": "StoreBannerText" } },
41
+ // (attribute: the value comes from a Znode global attribute, "Code" for the store's, or
42
+ // { "code": "...", "levels": ["store", "account", "user"] }; the most specific level wins)
43
+ // marketplace: "license", "keywords", "homepage", "compatibility": { "webstore": ">=10.0.0" },
44
+ // "permissions": ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads"] (what it does beyond UI;
45
+ // "uploads": its routes take files, multipart/form-data up to RUNTIME_PLUGIN_UPLOAD_BYTES)
46
+ // "sandbox": true (its server part runs in a separate, permission-limited Node process: routes and overrides only)
47
+ // }
48
+ import fs from "node:fs";
49
+ import path from "node:path";
50
+ import { execFileSync, spawnSync } from "node:child_process";
51
+ import { createRequire } from "node:module";
52
+ import { fileURLToPath, pathToFileURL } from "node:url";
53
+ import vm from "node:vm";
54
+ import { TEMPLATES } from "./templates.mjs";
55
+
56
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
57
+ const MANIFEST = "znode-package.json";
58
+ // the build tools (TypeScript, rollup, adm-zip, postcss): this package's own dependencies, wherever npm put them
59
+ // (ZNODE_TOOLS: a node_modules folder to use instead)
60
+ const require = process.env.ZNODE_TOOLS ? createRequire(path.join(process.env.ZNODE_TOOLS, "noop.js")) : createRequire(import.meta.url);
61
+ // a dependency's folder (its package.json's), found from what it resolves to
62
+ function packageDir(name) {
63
+ for (let dir = path.dirname(require.resolve(name)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
64
+ const file = path.join(dir, "package.json");
65
+ if (fs.existsSync(file) && JSON.parse(fs.readFileSync(file, "utf8")).name === name) return dir;
66
+ }
67
+ throw new Error(`znode-plugin: ${name} is not installed`);
68
+ }
69
+ const TSC = () => path.join(packageDir("typescript"), "bin", "tsc");
70
+ const ROLLUP = () => path.join(packageDir("rollup"), "dist", "bin", "rollup");
71
+ const AdmZip = require("adm-zip");
72
+
73
+ const API_VERSION = 1;
74
+ const KINDS = ["theme", "plugin", "widget", "content"];
75
+ const NAME = /^[a-z0-9][a-z0-9-]{1,48}$/;
76
+ const VERSION = /^\d+\.\d+\.\d+(-[0-9a-z.-]+)?$/i;
77
+ const SERVED = new Set([".mjs", ".map", ".css", ".woff2", ".woff", ".ttf", ".otf", ".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".json"]);
78
+ const MAX_BYTES = 10 * 1024 * 1024;
79
+ const SETTING_TYPES = ["string", "number", "boolean", "list", "json", "object", "array"];
80
+ const ATTRIBUTE_LEVELS = ["store", "account", "user"];
81
+ const ATTRIBUTE_CODE = /^[A-Za-z][A-Za-z0-9_]{1,99}$/;
82
+ const PERMISSIONS = ["routes", "cartHooks", "overrides", "znodeApi", "externalNetwork", "uploads"];
83
+
84
+ const fail = (message) => {
85
+ console.error(`znode-package: ${message}`);
86
+ process.exit(1);
87
+ };
88
+ const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
89
+ const glob = (dir, pattern) => {
90
+ const folder = path.join(dir, path.dirname(pattern));
91
+ const re = new RegExp(
92
+ `^${path
93
+ .basename(pattern)
94
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
95
+ .replace(/\*/g, ".*")}$`
96
+ );
97
+ return fs.existsSync(folder)
98
+ ? fs
99
+ .readdirSync(folder)
100
+ .filter((name) => re.test(name))
101
+ .map((name) => path.join(folder, name))
102
+ : [];
103
+ };
104
+
105
+ function validateManifest(manifest, where) {
106
+ if (!NAME.test(manifest.name || "")) fail(`${where}: name must be lowercase letters, digits and dashes`);
107
+ if (!VERSION.test(manifest.version || "")) fail(`${where}: version must be semver (1.2.3)`);
108
+ if (!KINDS.includes(manifest.kind)) fail(`${where}: kind must be one of ${KINDS.join(", ")}`);
109
+ if (manifest.apiVersion !== API_VERSION) fail(`${where}: apiVersion ${manifest.apiVersion} isn't supported (this store runs ${API_VERSION})`);
110
+ if (manifest.parent && manifest.kind !== "theme") fail(`${where}: only a theme has a parent`);
111
+ // whole pages its server part renders (IRuntimePlugin.serverPages), at new lowercase URLs the store doesn't use
112
+ if (manifest.serverPages !== undefined) {
113
+ if (!Array.isArray(manifest.serverPages)) fail(`${where}: serverPages must be a list of paths ("/my-page")`);
114
+ // the source manifest names the entry (build.server), the built one the file (files.server)
115
+ if (manifest.serverPages.length && !manifest.build?.server && !manifest.files?.server) fail(`${where}: serverPages need a server part (build.server)`);
116
+ for (const page of manifest.serverPages) {
117
+ if (!/^\/[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/.test(String(page))) fail(`${where}: serverPages "${page}" must be a lowercase path like /my-page`);
118
+ if (/^\/(api|runtime-page|_next|login|signup|account|cart|checkout|search|product|category|404|error|maintenance)(\/|$)/.test(page)) fail(`${where}: serverPages "${page}" is a store route`);
119
+ }
120
+ }
121
+ // store BFF routes the package takes over: "/api/<path>" -> one of its own routes
122
+ for (const [route, target] of Object.entries(manifest.overrides || {})) {
123
+ if (!/^\/api\/[A-Za-z0-9/_-]+$/.test(route) || /^\/api\/extensions(\/|$)/.test(route)) fail(`${where}: override "${route}" must be a store route under /api/`);
124
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(String(target))) fail(`${where}: override "${route}" must name one of the package's routes`);
125
+ }
126
+ for (const [key, field] of Object.entries(manifest.settingsSchema || {})) {
127
+ if (field?.type !== undefined && !SETTING_TYPES.includes(field.type)) fail(`${where}: setting "${key}" has type "${field.type}" (one of ${SETTING_TYPES.join(", ")})`);
128
+ const attribute = field?.attribute;
129
+ if (attribute === undefined) continue;
130
+ const code = typeof attribute === "string" ? attribute : attribute?.code;
131
+ if (!ATTRIBUTE_CODE.test(String(code || ""))) fail(`${where}: setting "${key}" names attribute "${code}" (letters, digits and _, as Znode attribute codes)`);
132
+ const levels = typeof attribute === "string" ? ["store"] : attribute.levels || ["store"];
133
+ if (!Array.isArray(levels) || !levels.length || levels.some((level) => !ATTRIBUTE_LEVELS.includes(level))) {
134
+ fail(`${where}: setting "${key}" attribute levels must be some of ${ATTRIBUTE_LEVELS.join(", ")}`);
135
+ }
136
+ }
137
+ if (manifest.sandbox !== undefined && typeof manifest.sandbox !== "boolean") fail(`${where}: sandbox must be true or false`);
138
+ if (manifest.permissions !== undefined && (!Array.isArray(manifest.permissions) || manifest.permissions.some((item) => !PERMISSIONS.includes(item)))) {
139
+ fail(`${where}: permissions must be some of ${PERMISSIONS.join(", ")}`);
140
+ }
141
+ }
142
+
143
+ // ---- build: TypeScript entry points -> one ES module each (tsc + rollup), plus styles, assets, labels, pages ----
144
+ // a package folder (its znode-package.json) or one of several manifests in a folder (<name>.znode-package.json)
145
+ function resolvePackage(target) {
146
+ const isFile = fs.statSync(target).isFile();
147
+ const dir = isFile ? path.dirname(target) : target;
148
+ const manifestFile = isFile ? target : path.join(dir, MANIFEST);
149
+ return { dir, manifestFile, outName: path.basename(manifestFile) === MANIFEST ? "dist" : `dist-${path.basename(manifestFile, `.${MANIFEST}`)}` };
150
+ }
151
+
152
+ // The package's tsconfig check. tsc also checks the store files the package imports; their errors are the store's, not
153
+ // the package's: listed as warnings. An error in the package's own files stops the build.
154
+ function typeCheck(dir, tsconfigFile) {
155
+ let output = "";
156
+ try {
157
+ execFileSync(process.execPath, [TSC(), "-p", tsconfigFile, "--noEmit", "--pretty", "false"], {
158
+ cwd: dir,
159
+ encoding: "utf8",
160
+ stdio: ["ignore", "pipe", "pipe"],
161
+ });
162
+ return;
163
+ } catch (error) {
164
+ output = `${error.stdout || ""}${error.stderr || ""}`;
165
+ }
166
+ // one entry per error: "file(line,col): error TSnnnn: message" and its indented continuation lines
167
+ const errors = output.split(/\r?\n(?=\S)/).filter((entry) => /^.+?\(\d+,\d+\): error TS\d+:/.test(entry));
168
+ if (!errors.length) fail(`tsconfig check failed:\n${output}`);
169
+ // inside the package folder: a relative path that does not climb out (on Windows a file on another drive comes back
170
+ // absolute from path.relative, which is outside too)
171
+ const inPackage = (entry) => {
172
+ const relative = path.relative(dir, path.resolve(dir, entry.slice(0, entry.indexOf("("))));
173
+ return !relative.startsWith("..") && !path.isAbsolute(relative);
174
+ };
175
+ const own = errors.filter(inPackage);
176
+ const store = errors.filter((entry) => !inPackage(entry));
177
+ if (store.length) console.log(`warning ${store.length} type error(s) in store files the package imports (the store's to fix):\n ${store.join("\n ")}`);
178
+ if (own.length) fail(`type errors in the package:\n ${own.join("\n ")}`);
179
+ }
180
+
181
+ // every .ts / .tsx under srcRoot compiled to .js under outRoot, one file at a time and without resolving imports (the
182
+ // tsconfig check has done the types): types removed, JSX as Next.js compiles it (react/jsx-runtime)
183
+ function transpileSources(srcRoot, outRoot) {
184
+ const ts = require("typescript");
185
+ const walk = (folder) =>
186
+ fs.readdirSync(folder, { withFileTypes: true }).flatMap((item) => {
187
+ const file = path.join(folder, item.name);
188
+ if (item.isDirectory()) return walk(file);
189
+ return /\.tsx?$/.test(item.name) && !item.name.endsWith(".d.ts") ? [file] : [];
190
+ });
191
+ for (const file of walk(srcRoot)) {
192
+ const { outputText } = ts.transpileModule(fs.readFileSync(file, "utf8"), {
193
+ fileName: file,
194
+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022, jsx: ts.JsxEmit.ReactJSX, jsxImportSource: "react" },
195
+ });
196
+ const target = path.join(outRoot, path.relative(srcRoot, file)).replace(/\.tsx?$/, ".js");
197
+ fs.mkdirSync(path.dirname(target), { recursive: true });
198
+ fs.writeFileSync(target, outputText);
199
+ }
200
+ }
201
+
202
+ // The store the package is linked to (tsconfig.store.json, written by link-store): its root, its @znode paths and its
203
+ // Generated Store Module Registry (packages/extensions/store-module-registry.json), for the package's store imports
204
+ function linkedStore(dir) {
205
+ const file = path.join(dir, "tsconfig.store.json");
206
+ if (!fs.existsSync(file)) return null;
207
+ const { compilerOptions = {} } = readJson(file);
208
+ if (!compilerOptions.baseUrl) return null;
209
+ const root = path.resolve(dir, compilerOptions.baseUrl);
210
+ const registryFile = path.join(root, "packages/extensions/store-module-registry.json");
211
+ const registry = fs.existsSync(registryFile) ? readJson(registryFile).modules : null;
212
+ return { root, paths: compilerOptions.paths || {}, registry };
213
+ }
214
+
215
+ function build(target) {
216
+ const { dir, manifestFile, outName } = resolvePackage(target);
217
+ const manifest = readJson(manifestFile);
218
+ validateManifest(manifest, path.basename(manifestFile));
219
+ const out = path.join(dir, outName);
220
+ fs.rmSync(out, { recursive: true, force: true });
221
+ fs.mkdirSync(out, { recursive: true });
222
+ const tmp = path.join(dir, ".build");
223
+ const files = {};
224
+ const prefix = `${manifest.name}-${manifest.version}`;
225
+ // next/font/google fonts built into the package (next-imports.mjs): their files and @font-face CSS
226
+ const fonts = [];
227
+ // the store modules its browser part imports (Generated Store Module Registry keys), for the store to load first
228
+ const storeImports = [];
229
+ const store = linkedStore(dir);
230
+ // A package with its own tsconfig.json (extending the store's tsconfig.base.json) is checked against the store's real
231
+ // types (every @znode alias, no declarations to write), then compiled file by file without type information, JSX as
232
+ // Next.js does (react/jsx-runtime: the store's). Without one, the package compiles as before (tsc, JSX factory h).
233
+ const tsconfigFile = path.join(dir, "tsconfig.json");
234
+ const projectMode = fs.existsSync(tsconfigFile);
235
+ if (projectMode && Object.values(manifest.build || {}).length) typeCheck(dir, tsconfigFile);
236
+
237
+ for (const part of ["server", "browser"]) {
238
+ const entry = manifest.build?.[part];
239
+ if (!entry) continue;
240
+ fs.rmSync(tmp, { recursive: true, force: true });
241
+ if (projectMode) transpileSources(path.dirname(path.join(dir, entry)), tmp);
242
+ else execFileSync(
243
+ process.execPath,
244
+ [
245
+ TSC(),
246
+ "--target",
247
+ "es2022",
248
+ "--module",
249
+ "es2022",
250
+ "--moduleResolution",
251
+ "bundler",
252
+ "--lib",
253
+ "es2022,dom",
254
+ "--skipLibCheck",
255
+ // import React from "react", as the store's own components do
256
+ "--allowSyntheticDefaultImports",
257
+ "--jsx",
258
+ "react",
259
+ "--jsxFactory",
260
+ "h",
261
+ "--jsxFragmentFactory",
262
+ "Fragment",
263
+ "--outDir",
264
+ tmp,
265
+ path.join(dir, entry),
266
+ ],
267
+ { stdio: "inherit" }
268
+ );
269
+ const compiled = path.join(tmp, path.basename(entry).replace(/\.tsx?$/, ".js"));
270
+ const target = `${prefix}.${part}.mjs`;
271
+ // rollup with the Next.js imports plugin (next-imports.mjs), from a config file: the plugin downloads fonts, which
272
+ // is async, in rollup's own process; what it built comes back in fonts.json
273
+ const config = path.join(tmp, "rollup.config.mjs");
274
+ const fontsFile = path.join(tmp, "fonts.json");
275
+ const storeImportsFile = path.join(tmp, "store-imports.json");
276
+ fs.writeFileSync(
277
+ config,
278
+ [
279
+ `import fs from "node:fs";`,
280
+ `import { znodeNext } from ${JSON.stringify(pathToFileURL(path.join(HERE, "next-imports.mjs")).href)};`,
281
+ `const fonts = [];`,
282
+ `const storeImports = [];`,
283
+ `const store = ${JSON.stringify(store)};`,
284
+ `if (store?.registry) store.registry = new Set(store.registry);`,
285
+ `export default {`,
286
+ ` input: ${JSON.stringify(compiled)},`,
287
+ ` output: { file: ${JSON.stringify(path.join(out, target))}, format: "es", sourcemap: true },`,
288
+ ` plugins: [`,
289
+ ` znodeNext({ part: ${JSON.stringify(part)}, prefix: ${JSON.stringify(prefix)}, outDir: ${JSON.stringify(out)}, fonts, store, storeImports }),`,
290
+ ` { name: "fonts-out", writeBundle() { fs.writeFileSync(${JSON.stringify(fontsFile)}, JSON.stringify(fonts)); fs.writeFileSync(${JSON.stringify(storeImportsFile)}, JSON.stringify(storeImports)); } },`,
291
+ ` ],`,
292
+ `};`,
293
+ ].join("\n")
294
+ );
295
+ execFileSync(process.execPath, [ROLLUP(), "--config", config, "--silent"], { stdio: "inherit" });
296
+ if (fs.existsSync(fontsFile)) fonts.push(...JSON.parse(fs.readFileSync(fontsFile, "utf8")));
297
+ if (fs.existsSync(storeImportsFile)) storeImports.push(...JSON.parse(fs.readFileSync(storeImportsFile, "utf8")));
298
+ files[part] = target;
299
+ }
300
+ fs.rmSync(tmp, { recursive: true, force: true });
301
+
302
+ // styles: concatenated into one stylesheet, after the fonts next/font/google built; assets (fonts, images) keep their
303
+ // names, which the stylesheet refers to
304
+ if (manifest.styles?.length || fonts.length) {
305
+ const css = [
306
+ ...fonts.map((font) => font.css),
307
+ ...(manifest.styles || []).flatMap((pattern) => glob(dir, pattern)).map((file) => fs.readFileSync(file, "utf8")),
308
+ ].join("\n");
309
+ files.styles = [`${prefix}.css`];
310
+ fs.writeFileSync(path.join(out, files.styles[0]), css);
311
+ // rules the store's own stylesheets already have, loaded again later, would beat its responsive variants
312
+ if (manifest.dedupeStyles) {
313
+ const target = path.join(out, files.styles[0]);
314
+ execFileSync(process.execPath, [path.join(HERE, "css-dedupe.mjs"), target, process.env.STORE_URL || "http://staplespromo.localhost:3000/", target], { stdio: "inherit" });
315
+ }
316
+ }
317
+ files.assets = (manifest.assets || [])
318
+ .flatMap((pattern) => glob(dir, pattern))
319
+ .map((file) => {
320
+ fs.copyFileSync(file, path.join(out, path.basename(file)));
321
+ return path.basename(file);
322
+ })
323
+ // the font files next/font/google downloaded, already in dist
324
+ .concat(fonts.flatMap((font) => font.files));
325
+ if (manifest.messages) {
326
+ files.messages = `${prefix}.labels.json`;
327
+ fs.copyFileSync(path.join(dir, manifest.messages), path.join(out, files.messages));
328
+ }
329
+ if (manifest.pages) {
330
+ files.pages = Object.fromEntries(
331
+ Object.entries(manifest.pages).map(([route, file], index) => {
332
+ const target = `${prefix}.page${index}.json`;
333
+ fs.copyFileSync(path.join(dir, file), path.join(out, target));
334
+ return [route, target];
335
+ })
336
+ );
337
+ }
338
+ const { build: _build, styles: _styles, assets: _assets, dedupeStyles: _dedupe, ...rest } = manifest;
339
+ fs.writeFileSync(path.join(out, MANIFEST), JSON.stringify({ ...rest, files, ...(storeImports.length ? { storeImports: [...new Set(storeImports)].sort() } : {}) }, null, 2) + "\n");
340
+ console.log(`built ${manifest.kind} ${manifest.name}@${manifest.version}: ${fs.readdirSync(out).length} files`);
341
+ return { manifest, out };
342
+ }
343
+
344
+ function pack(target) {
345
+ const { manifest, out } = build(target);
346
+ const { dir } = resolvePackage(target);
347
+ const zip = new AdmZip();
348
+ for (const name of fs.readdirSync(out)) zip.addLocalFile(path.join(out, name));
349
+ fs.mkdirSync(path.join(dir, "releases"), { recursive: true });
350
+ const file = path.join(dir, "releases", `${manifest.name}-${manifest.version}.zpkg.zip`);
351
+ zip.writeZip(file);
352
+ console.log(`packed ${path.basename(file)} (${fs.statSync(file).size} bytes)`);
353
+ }
354
+
355
+ // ---- install: validate, copy into the plugin directory, upsert the registry entry -------------------------------
356
+ // Installs into one folder can run at the same time (several pipelines, an admin upload): each takes the folder's lock
357
+ // (an atomic mkdir) for the read-change-write of plugins.json, writes it atomically (a temp file renamed over it, so the
358
+ // running webstore never reads half a file) and keeps the previous registry in .history/ (the last HISTORY_KEEP).
359
+ const LOCK_WAIT_MS = 60000;
360
+ const LOCK_STALE_MS = 120000;
361
+ const HISTORY_KEEP = 50;
362
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
363
+
364
+ function lockRegistry(storeDir) {
365
+ fs.mkdirSync(storeDir, { recursive: true });
366
+ const lock = path.join(storeDir, ".plugins.lock");
367
+ const deadline = Date.now() + LOCK_WAIT_MS;
368
+ for (;;) {
369
+ try {
370
+ fs.mkdirSync(lock);
371
+ break;
372
+ } catch (error) {
373
+ if (error.code !== "EEXIST") throw error;
374
+ // a lock left by a crashed install
375
+ try {
376
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) fs.rmSync(lock, { recursive: true, force: true });
377
+ } catch {
378
+ // removed meanwhile
379
+ }
380
+ if (Date.now() > deadline) fail(`the registry in ${storeDir} is locked by another install (remove ${lock} if none is running)`);
381
+ sleep(25 + Math.floor(Math.random() * 50));
382
+ }
383
+ }
384
+ const release = () => fs.rmSync(lock, { recursive: true, force: true });
385
+ process.on("exit", release);
386
+ return () => {
387
+ release();
388
+ process.off("exit", release);
389
+ };
390
+ }
391
+
392
+ function writeRegistry(registryFile, registry) {
393
+ const dir = path.dirname(registryFile);
394
+ if (fs.existsSync(registryFile)) {
395
+ const history = path.join(dir, ".history");
396
+ fs.mkdirSync(history, { recursive: true });
397
+ fs.copyFileSync(registryFile, path.join(history, `plugins-${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}.json`));
398
+ const old = fs
399
+ .readdirSync(history)
400
+ .filter((name) => name.startsWith("plugins-"))
401
+ .sort();
402
+ for (const name of old.slice(0, Math.max(0, old.length - HISTORY_KEEP))) fs.rmSync(path.join(history, name), { force: true });
403
+ }
404
+ const temp = `${registryFile}.tmp-${process.pid}`;
405
+ fs.writeFileSync(temp, JSON.stringify(registry, null, 2) + "\n");
406
+ fs.renameSync(temp, registryFile);
407
+ }
408
+ const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
409
+
410
+ // the store's settings over the package defaults; an object setting (labels, panels) keeps the store's values and
411
+ // gains the keys a new version adds
412
+ function mergeSettings(defaults, previous) {
413
+ const merged = { ...defaults, ...previous };
414
+ for (const [key, value] of Object.entries(defaults)) {
415
+ if (isPlainObject(value) && isPlainObject(previous[key])) merged[key] = { ...value, ...previous[key] };
416
+ }
417
+ return merged;
418
+ }
419
+
420
+ function install(source, storeDir) {
421
+ let entries;
422
+ if (fs.statSync(source).isDirectory()) {
423
+ // a package folder's build output (dist; package in packages built before the dist folder)
424
+ const built = ["dist", "package"].map((name) => path.join(source, name)).find((dir) => fs.existsSync(path.join(dir, MANIFEST)));
425
+ const dir = built || source;
426
+ entries = fs.readdirSync(dir).map((name) => ({ name, data: fs.readFileSync(path.join(dir, name)) }));
427
+ } else {
428
+ entries = new AdmZip(source)
429
+ .getEntries()
430
+ .filter((entry) => !entry.isDirectory)
431
+ .map((entry) => ({ name: entry.entryName, data: entry.getData() }));
432
+ }
433
+ const manifestEntry = entries.find((entry) => entry.name === "znode-package.json");
434
+ if (!manifestEntry) fail("no znode-package.json in the package");
435
+ const manifest = JSON.parse(manifestEntry.data.toString("utf8"));
436
+ validateManifest(manifest, "package");
437
+
438
+ const total = entries.reduce((sum, entry) => sum + entry.data.length, 0);
439
+ if (total > MAX_BYTES) fail(`package is ${total} bytes; the limit is ${MAX_BYTES}`);
440
+ const files = entries.filter((entry) => entry.name !== "znode-package.json");
441
+ for (const entry of files) {
442
+ if (entry.name !== path.basename(entry.name)) fail(`"${entry.name}": a package is flat, no folders`);
443
+ if (!SERVED.has(path.extname(entry.name).toLowerCase())) fail(`"${entry.name}": file type not allowed`);
444
+ }
445
+ const has = (name) => files.some((entry) => entry.name === name);
446
+ const referenced = [
447
+ manifest.files?.server,
448
+ manifest.files?.browser,
449
+ manifest.files?.messages,
450
+ ...(manifest.files?.styles || []),
451
+ ...Object.values(manifest.files?.pages || {}),
452
+ ].filter(Boolean);
453
+ for (const name of referenced) if (!has(name)) fail(`znode-package.json names "${name}", which isn't in the package`);
454
+
455
+ const registryFile = path.join(storeDir, "plugins.json");
456
+ const unlock = lockRegistry(storeDir);
457
+ const registry = fs.existsSync(registryFile) ? readJson(registryFile) : { plugins: [] };
458
+ // a package never replaces a file another installed package uses
459
+ const owners = new Map();
460
+ for (const item of registry.plugins) {
461
+ if (item.name === manifest.name) continue;
462
+ for (const name of [item.file, item.browser, item.messages, ...(item.styles || []), ...Object.values(item.pages || {})]) if (name) owners.set(name, item.name);
463
+ }
464
+ for (const entry of files) if (owners.has(entry.name)) fail(`"${entry.name}" belongs to package ${owners.get(entry.name)}`);
465
+
466
+ fs.mkdirSync(storeDir, { recursive: true });
467
+ for (const entry of files) fs.writeFileSync(path.join(storeDir, entry.name), entry.data);
468
+
469
+ const previous = registry.plugins.find((item) => item.name === manifest.name);
470
+ const defaults = Object.fromEntries(
471
+ Object.entries(manifest.settingsSchema || {})
472
+ .filter(([, field]) => field.default !== undefined)
473
+ .map(([key, field]) => [key, field.default])
474
+ );
475
+ const entry = {
476
+ name: manifest.name,
477
+ kind: manifest.kind === "widget" ? "plugin" : manifest.kind,
478
+ version: manifest.version,
479
+ label: manifest.label,
480
+ publisher: manifest.publisher,
481
+ ...(manifest.parent ? { parent: manifest.parent } : {}),
482
+ ...(manifest.files?.server ? { file: manifest.files.server } : {}),
483
+ ...(manifest.files?.browser ? { browser: manifest.files.browser } : {}),
484
+ ...(manifest.files?.styles?.length ? { styles: manifest.files.styles } : {}),
485
+ ...(manifest.files?.messages ? { messages: manifest.files.messages } : {}),
486
+ ...(manifest.files?.pages ? { pages: manifest.files.pages } : {}),
487
+ ...(manifest.widgets?.length ? { widgets: manifest.widgets } : {}),
488
+ // the store modules its browser bundle imports: the store loads them first (Generated Store Module Registry)
489
+ ...(Array.isArray(manifest.storeImports) && manifest.storeImports.length ? { storeImports: manifest.storeImports.map(String) } : {}),
490
+ ...(manifest.overrides ? { overrides: manifest.overrides } : {}),
491
+ ...(manifest.serverPages?.length ? { serverPages: manifest.serverPages } : {}),
492
+ ...(manifest.sandbox === true ? { sandbox: true } : {}),
493
+ // the webstore accepts files (multipart) only for a package that declares them
494
+ ...((manifest.permissions || []).includes("uploads") ? { uploads: true } : {}),
495
+ ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}),
496
+ // where it runs and its settings are the store owner's: an upgrade keeps them; a new package starts switched off
497
+ enabled: previous ? previous.enabled : false,
498
+ stores: previous ? previous.stores : [],
499
+ settings: mergeSettings(defaults, previous?.settings || {}),
500
+ };
501
+ if (previous) registry.plugins[registry.plugins.indexOf(previous)] = entry;
502
+ else registry.plugins.push(entry);
503
+ writeRegistry(registryFile, registry);
504
+ // the replaced version's files (an upgrade's old bundles and stylesheets) leave the plugin directory
505
+ const kept = new Set(files.map((file) => file.name));
506
+ for (const name of [previous?.file, previous?.browser, previous?.messages, ...(previous?.styles || []), ...Object.values(previous?.pages || {})]) {
507
+ if (name && !kept.has(name) && name === path.basename(name)) fs.rmSync(path.join(storeDir, name), { force: true });
508
+ }
509
+
510
+ unlock();
511
+
512
+ const missing = Object.entries(manifest.settingsSchema || {}).filter(([key, field]) => field.required && (entry.settings[key] === undefined || entry.settings[key] === ""));
513
+ console.log(
514
+ `installed ${manifest.kind} ${manifest.name}@${manifest.version}${previous ? ` (was ${previous.version || "unversioned"})` : ""}: ${files.length} files, ${entry.enabled ? "enabled" : "disabled"} for ${JSON.stringify(entry.stores)}`
515
+ );
516
+ if (missing.length) console.log(` settings to fill in before enabling: ${missing.map(([key]) => key).join(", ")}`);
517
+ }
518
+
519
+ // ---- create: a starter package of a kind (templates.mjs), with the API typings next to its sources ---------------
520
+ function create(kind, name, dir) {
521
+ if (!TEMPLATES[kind]) fail(`kind must be one of ${Object.keys(TEMPLATES).join(", ")}`);
522
+ if (!NAME.test(name || "")) fail("name must be lowercase letters, digits and dashes (2 to 49)");
523
+ const target = path.resolve(dir || path.join(process.cwd(), name));
524
+ if (fs.existsSync(target) && fs.readdirSync(target).length) fail(`${target} exists and is not empty`);
525
+ for (const [file, content] of Object.entries(TEMPLATES[kind](name))) {
526
+ const full = path.join(target, file);
527
+ fs.mkdirSync(path.dirname(full), { recursive: true });
528
+ fs.writeFileSync(full, typeof content === "string" ? content : JSON.stringify(content, null, 2) + "\n");
529
+ }
530
+ if (kind !== "content") fs.copyFileSync(path.join(HERE, "..", "types", "extension-api.d.ts"), path.join(target, "src", "extension-api.d.ts"));
531
+ console.log(`created ${kind} ${name} in ${target}`);
532
+ console.log(" next: fill in label, publisher and description in znode-package.json, then run its build-and-upload.sh");
533
+ }
534
+
535
+ // ---- check: what a marketplace review looks at, from the package's own files ------------------------------------
536
+ const HOOK_NAMES = ["onCartChanged", "beforeAddToCart", "afterAddToCart", "mapCartItems"];
537
+
538
+ // the code an entry pulls in: the entry and the files it imports by relative path, followed (a package's server and
539
+ // browser parts share a folder, so a folder read would mix them); type-only imports are skipped
540
+ function sourceOf(dir, entry) {
541
+ if (!entry) return "";
542
+ const seen = new Set();
543
+ const sources = [];
544
+ const visit = (file) => {
545
+ if (seen.has(file) || !fs.existsSync(file)) return;
546
+ seen.add(file);
547
+ const code = fs.readFileSync(file, "utf8");
548
+ sources.push(code);
549
+ for (const [, typeOnly, spec] of code.matchAll(/\bimport\s+(type\s+)?[^;]*?from\s+["'](\.[^"']+)["']/g)) {
550
+ if (typeOnly) continue;
551
+ const base = path.resolve(path.dirname(file), spec).replace(/\.(m?js)$/, "");
552
+ const found = [".ts", ".tsx", ".js", ".mjs", "/index.ts", "/index.tsx"].map((ext) => base + ext).find((candidate) => fs.existsSync(candidate));
553
+ if (found) visit(found);
554
+ }
555
+ };
556
+ visit(path.join(dir, entry));
557
+ return sources.join("\n");
558
+ }
559
+
560
+ // the server renders a browser bundle in this format from one compiled copy for all stores
561
+ // (packages/extensions/src/server-instances.ts); any other is imported once per store, and Node keeps every copy
562
+ function serverRenderFormat(code) {
563
+ const match = /\bexport\s*(?:\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*,?\s*\}|default\s+([A-Za-z_$][\w$]*))\s*;?\s*(?:\/\/#\s*sourceMappingURL=\S*\s*)?$/.exec(code);
564
+ if (!match || /(^|[^\w$.])import\s*\(/.test(code.slice(0, match.index))) return false;
565
+ try {
566
+ new vm.Script(`(function () {\n"use strict";\n${code.slice(0, match.index)}\nreturn ${match[1] || match[2]};\n})`);
567
+ return true;
568
+ } catch {
569
+ return false;
570
+ }
571
+ }
572
+
573
+ function check(target) {
574
+ const { dir, manifestFile } = resolvePackage(target);
575
+ const manifest = readJson(manifestFile);
576
+ validateManifest(manifest, path.basename(manifestFile));
577
+ const errors = [];
578
+ const warnings = [];
579
+
580
+ // the listing
581
+ for (const key of ["label", "publisher", "description"]) if (!String(manifest[key] || "").trim()) errors.push(`"${key}" is missing (the marketplace shows it)`);
582
+ if (/describe what it does/i.test(manifest.description || "") || manifest.publisher === "Your company") errors.push("the starter's publisher or description is still in place");
583
+ else if (String(manifest.description || "").length < 30) warnings.push('"description" is short: say what it does for the store');
584
+ for (const key of ["license", "compatibility"]) if (!manifest[key]) warnings.push(`"${key}" is missing`);
585
+
586
+ // permissions against the code
587
+ const server = sourceOf(dir, manifest.build?.server);
588
+ const browser = sourceOf(dir, manifest.build?.browser);
589
+ const used = new Set();
590
+ if (/\broutes\s*[:=,}]/.test(server)) used.add("routes");
591
+ if (HOOK_NAMES.some((hook) => new RegExp(`\\b${hook}\\b`).test(server))) used.add("cartHooks");
592
+ if (Object.keys(manifest.overrides || {}).length) used.add("overrides");
593
+ if (/\bznode\s*\(/.test(server)) used.add("znodeApi");
594
+ if (/\bfetch\s*\(|\bhttps?\.request\s*\(/.test(server)) used.add("externalNetwork");
595
+ if (/\.files\b/.test(server)) used.add("uploads");
596
+ const declared = new Set(manifest.permissions || []);
597
+ for (const item of used) if (!declared.has(item)) errors.push(`uses "${item}" without declaring it in "permissions"`);
598
+ for (const item of declared) if (!used.has(item)) warnings.push(`declares "${item}" but no code uses it (the review asks why)`);
599
+ if (/\beval\s*\(|new Function\s*\(/.test(server + browser)) errors.push("uses eval or new Function");
600
+ if (/document\.cookie/.test(browser)) warnings.push("reads cookies in the browser: keep secrets on the server (routes)");
601
+
602
+ // settings
603
+ for (const [key, field] of Object.entries(manifest.settingsSchema || {})) {
604
+ if (!field.label) warnings.push(`setting "${key}" has no label (the admin shows it)`);
605
+ if (field.required && field.default !== undefined) warnings.push(`setting "${key}" is required but has a default`);
606
+ if (field.browser && /secret|password|token|api_?key/i.test(key)) errors.push(`setting "${key}" looks secret but is sent to the browser ("browser": true)`);
607
+ }
608
+ if (manifest.kind === "theme" && !Object.values(manifest.settingsSchema || {}).some((field) => field.attribute)) {
609
+ warnings.push("no setting is bound to a Znode attribute: per-store branding then needs a registry change per store");
610
+ }
611
+
612
+ // the built files (pack first)
613
+ const out = path.join(dir, "dist");
614
+ if (fs.existsSync(out)) {
615
+ let total = 0;
616
+ for (const name of fs.readdirSync(out)) {
617
+ const size = fs.statSync(path.join(out, name)).size;
618
+ total += size;
619
+ if (name.endsWith(".browser.mjs")) {
620
+ if (size > 512 * 1024) warnings.push(`${name} is ${Math.round(size / 1024)} KB: every page of the store loads it`);
621
+ if (!serverRenderFormat(fs.readFileSync(path.join(out, name), "utf8")))
622
+ warnings.push(`${name} is not in the server-render format (no imports, one default export): the server keeps a copy per store`);
623
+ }
624
+ if (name.endsWith(".css") && size > 200 * 1024) warnings.push(`${name} is ${Math.round(size / 1024)} KB`);
625
+ }
626
+ if (total > MAX_BYTES) errors.push(`dist is ${Math.round(total / 1048576)} MB (limit 10 MB)`);
627
+ } else warnings.push("not built yet: pack it to check sizes and the server-render format");
628
+
629
+ for (const item of errors) console.log(`error ${item}`);
630
+ for (const item of warnings) console.log(`warning ${item}`);
631
+ console.log(`check ${manifest.name}@${manifest.version}: ${errors.length} errors, ${warnings.length} warnings`);
632
+ if (errors.length) process.exit(1);
633
+ }
634
+
635
+ // ---- project commands: deploy, dev, link-store ------------------------------------------------------------------
636
+
637
+ const needStore = (storeDir) =>
638
+ storeDir || fail("needs the store's plugin folder: pass it after the package, or set RUNTIME_PLUGINS_DIR");
639
+
640
+ // pack, then install the zip it made into the store's plugin folder
641
+ function deploy(target, storeDir) {
642
+ pack(target);
643
+ const { dir, manifestFile } = resolvePackage(target);
644
+ const manifest = readJson(manifestFile);
645
+ install(path.join(dir, "releases", `${manifest.name}-${manifest.version}.zpkg.zip`), storeDir);
646
+ }
647
+
648
+ // deploy now, then again whenever the package's sources or manifest change; the store picks a new file up on the next
649
+ // request (it reloads a package when its files change), so a page refresh shows the change
650
+ function dev(target, storeDir) {
651
+ const { dir } = resolvePackage(target);
652
+ const self = fileURLToPath(import.meta.url);
653
+ const run = () => spawnSync(process.execPath, [self, "deploy", dir, storeDir], { stdio: "inherit" });
654
+ run();
655
+ let timer = null;
656
+ const changed = (file) => {
657
+ if (!file || /(^|[\\/])(dist|releases|\.build|node_modules)([\\/]|$)/.test(String(file))) return;
658
+ clearTimeout(timer);
659
+ timer = setTimeout(() => {
660
+ console.log(`znode dev: ${file} changed, deploying`);
661
+ run();
662
+ }, 300);
663
+ };
664
+ fs.watch(path.join(dir, "src"), { recursive: true }, (_event, file) => changed(file && path.join("src", file)));
665
+ fs.watch(dir, (_event, file) => file === MANIFEST && changed(file));
666
+ console.log(`znode dev: watching ${dir} (Ctrl+C to stop)`);
667
+ }
668
+
669
+ // the store's real types for the editor and the build check: tsconfig.store.json (git-ignored, per machine) is this
670
+ // package's preset plus the store repo's aliases (every @znode path of its tsconfig.base.json, from the store's folder).
671
+ // Only the aliases are taken: the store's own options (its rootDir among them) would refuse a project kept elsewhere.
672
+ function linkStore(storeRepo, project = ".") {
673
+ const base = path.resolve(storeRepo, "tsconfig.base.json");
674
+ if (!fs.existsSync(base)) fail(`${base} not found: pass the store repo's folder (the one with tsconfig.base.json)`);
675
+ const ts = require("typescript");
676
+ const { config, error } = ts.readConfigFile(base, ts.sys.readFile);
677
+ if (error) fail(`${base}: ${ts.flattenDiagnosticMessageText(error.messageText, "\n")}`);
678
+ const options = config.compilerOptions || {};
679
+ const toSlash = (value) => value.split(path.sep).join("/");
680
+ const file = path.resolve(project, "tsconfig.store.json");
681
+ const linked = {
682
+ extends: "znode-plugin/tsconfig.json",
683
+ compilerOptions: { baseUrl: toSlash(path.resolve(path.dirname(base), options.baseUrl || ".")), paths: options.paths || {} },
684
+ };
685
+ fs.writeFileSync(file, JSON.stringify(linked, null, 2) + "\n");
686
+ console.log(`linked ${file} to ${base} (${Object.keys(linked.compilerOptions.paths).length} store aliases)`);
687
+ }
688
+
689
+ // ---- eject: a store component copied into the package, in place of the store's, through its slot ----------------
690
+ // A store component that has a runtime slot whose children are the component itself (withRuntimeWrapper(Wrapper, Base,
691
+ // "Name") where it is defined) can be taken over by a package: `znode eject <Name>` copies its file from the linked store
692
+ // into src/components/<Name>.tsx (relative imports turned into the store's @znode aliases, the runtime wrapper removed so
693
+ // the copy is the plain component) and registers it in src/browser.tsx under the slot, so the store renders the copy
694
+ // instead of its own. `znode eject` lists the components that can be ejected.
695
+ const EJECT_SLOTS = {
696
+ RuntimeProductBasicInfo: "product.basicInfo",
697
+ RuntimeProductCard: "product.card",
698
+ RuntimeProductImage: "product.image",
699
+ RuntimeAddToCart: "product.addToCart",
700
+ RuntimeProductVariants: "product.variants",
701
+ RuntimeAddToCartNotification: "addToCart.notification",
702
+ RuntimeCheckoutAddress: "checkout.address",
703
+ RuntimeShippingOptions: "checkout.shippingOptions",
704
+ };
705
+
706
+ // the slot each store wrapper renders: every `export function Runtime<X>` in packages/extensions/src/slots with the first
707
+ // <RuntimeSlot name="…"> in its body (so a slot added with `znode create-slot` is ejectable with no SDK release); the
708
+ // hand-kept EJECT_SLOTS above fill in for a store that predates this
709
+ function storeWrapperSlots(root) {
710
+ const slots = { ...EJECT_SLOTS };
711
+ const dir = path.join(root, "packages/extensions/src/slots");
712
+ if (!fs.existsSync(dir)) return slots;
713
+ for (const entry of fs.readdirSync(dir)) {
714
+ if (!/\.tsx$/.test(entry)) continue;
715
+ const text = fs.readFileSync(path.join(dir, entry), "utf8");
716
+ const starts = [...text.matchAll(/export\s+function\s+(Runtime\w+)/g)];
717
+ starts.forEach((m, i) => {
718
+ const body = text.slice(m.index, i + 1 < starts.length ? starts[i + 1].index : undefined);
719
+ // the first slot that is handed the component as children (<RuntimeSlot name="…">…{children}…</RuntimeSlot>); a
720
+ // layout slot (self-closing, the base rendered hidden beside it) cannot take an ejected copy
721
+ for (const open of body.matchAll(/<RuntimeSlot\s+name=["']([\w.]+)["']/g)) {
722
+ const rest = body.slice(open.index + open[0].length);
723
+ const close = rest.indexOf("</RuntimeSlot>");
724
+ const next = rest.indexOf("<RuntimeSlot");
725
+ if (close >= 0 && (next < 0 || close < next) && /\{\s*children\s*\}/.test(rest.slice(0, close))) {
726
+ if (!slots[m[1]]) slots[m[1]] = open[1];
727
+ break;
728
+ }
729
+ }
730
+ });
731
+ }
732
+ return slots;
733
+ }
734
+
735
+ // every withRuntimeWrapper(<Wrapper>, <Base>, "<Name>") in the store's base components, with the slot it fills
736
+ function ejectableComponents(root) {
737
+ const EJECT_SLOTS = storeWrapperSlots(root);
738
+ const found = [];
739
+ const walk = (dir) => {
740
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
741
+ const file = path.join(dir, entry.name);
742
+ if (entry.isDirectory()) {
743
+ if (entry.name !== "node_modules") walk(file);
744
+ } else if (/\.tsx?$/.test(entry.name)) {
745
+ const text = fs.readFileSync(file, "utf8");
746
+ for (const m of text.matchAll(/withRuntimeWrapper\(\s*(\w+)\s*,\s*(\w+)\s*,\s*"(\w+)"\s*\)/g)) {
747
+ if (EJECT_SLOTS[m[1]]) found.push({ name: m[3], wrapper: m[1], base: m[2], slot: EJECT_SLOTS[m[1]], file });
748
+ }
749
+ }
750
+ }
751
+ };
752
+ for (const dir of ["packages/base-components/src", "packages/page-builder/src"]) if (fs.existsSync(path.join(root, dir))) walk(path.join(root, dir));
753
+ return found.sort((a, b) => a.name.localeCompare(b.name));
754
+ }
755
+
756
+ // a store file's import path as the package can import it: the store alias that maps to that file, without extension
757
+ function storeAlias(store, absolute) {
758
+ const target = absolute.split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
759
+ let best = null;
760
+ const norm = (value) => path.resolve(store.root, value).split(path.sep).join("/").replace(/\.(tsx?|jsx?)$/, "").replace(/\/index$/, "");
761
+ // an exact alias for this very module ("@znode/base-components/stores": [".../stores/index.ts"]) wins
762
+ for (const [alias, targets] of Object.entries(store.paths)) {
763
+ if (!alias.endsWith("/*") && targets.some((pattern) => norm(pattern) === target)) return alias;
764
+ }
765
+ for (const [alias, targets] of Object.entries(store.paths)) {
766
+ if (!alias.endsWith("/*")) continue;
767
+ for (const pattern of targets) {
768
+ const prefix = path.resolve(store.root, pattern.replace(/\*$/, "")).split(path.sep).join("/").replace(/\/$/, "") + "/";
769
+ if (target.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) best = { prefix, alias: alias.slice(0, -1) };
770
+ }
771
+ }
772
+ return best ? best.alias + target.slice(best.prefix.length) : null;
773
+ }
774
+
775
+ // a question on the terminal (only asked when stdin is a terminal); one line reader for the whole run, so answers
776
+ // typed (or piped) ahead are kept for the next question
777
+ let answers = null;
778
+ async function ask(question) {
779
+ if (!answers) {
780
+ const { createInterface } = await import("node:readline");
781
+ answers = createInterface({ input: process.stdin })[Symbol.asyncIterator]();
782
+ }
783
+ process.stdout.write(question);
784
+ const { value, done } = await answers.next();
785
+ if (done) fail("no answer (input closed)");
786
+ return value.trim();
787
+ }
788
+
789
+ // a slot name suggested for a component: ForgotPasswordPage -> forgotPassword.page (a page), AddToCart -> add.toCart
790
+ const suggestSlot = (name) => {
791
+ const words = name.match(/[A-Z][a-z0-9]*/g) || [name];
792
+ if (words.length > 1 && /^(Page|Layout)$/.test(words[words.length - 1]))
793
+ return `${words[0].toLowerCase()}${words.slice(1, -1).join("")}.${words[words.length - 1].toLowerCase()}`;
794
+ const [first, ...rest] = words;
795
+ return rest.length ? `${first.toLowerCase()}.${rest[0].toLowerCase()}${rest.slice(1).join("")}` : `${first.toLowerCase()}.main`;
796
+ };
797
+
798
+ // eject a store page (a server slot around a route's content): not a copy of the route (its server code, agents and
799
+ // data stay the store's), a server component of the package's own in src/server/pages/<Page>.tsx, registered under the
800
+ // slot in src/server.tsx, that renders the store's page content (children) to start with
801
+ function ejectServerSlot(project, s, force) {
802
+ const name = s.page;
803
+ const out = path.join(project, "src", "server", "pages", `${name}.tsx`);
804
+ if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${name} --force`);
805
+ const route = s.components[0] || name;
806
+ const text =
807
+ `import type { ReactNode } from "react";\n` +
808
+ `import type { IServerSlotContext } from "znode-plugin/types/extension-api";\n\n` +
809
+ `// Ejected with \`znode eject ${name}\` (${new Date().toISOString().slice(0, 10)}): the store's page ${route}, through its server slot\n` +
810
+ `// "${s.name}" (src/server.tsx serverSlots). It runs on the store's server only: it may be async and fetch data, its\n` +
811
+ `// output is in the page's HTML, and it has no hooks or browser code (put interactive parts in a browser slot).\n` +
812
+ `// children is the store's page content (${s.files[0]}): keep it, wrap it, or leave it out and render your own page.\n` +
813
+ `export async function ${name}(props: { children?: ReactNode }, context: IServerSlotContext) {\n` +
814
+ ` void context; // context.store.code, context.settings (this package's settings)\n` +
815
+ ` return (\n` +
816
+ ` <div data-page="${s.name}">\n` +
817
+ ` {props.children}\n` +
818
+ ` </div>\n` +
819
+ ` );\n` +
820
+ `}\n`;
821
+ fs.mkdirSync(path.dirname(out), { recursive: true });
822
+ fs.writeFileSync(out, text);
823
+ console.log(`ejected ${name} (server slot "${s.name}") -> ${path.relative(project, out)}`);
824
+
825
+ const server = path.join(project, "src", "server.tsx");
826
+ const local = `Ejected${name}`;
827
+ const importLine = `import { ${name} as ${local} } from "./server/pages/${name}.js";`;
828
+ const entry = `"${s.name}": ${local},`;
829
+ if (!fs.existsSync(server)) {
830
+ console.log(`warning src/server.tsx not found: register it yourself: ${importLine} and serverSlots: { ${entry} }`);
831
+ } else {
832
+ let source = fs.readFileSync(server, "utf8");
833
+ if (new RegExp(`["']${s.name.replace(/\./g, "\\.")}["']\\s*:`).test(source)) {
834
+ console.log(`warning src/server.tsx already fills "${s.name}": point it at ${local} (${importLine}) yourself`);
835
+ } else {
836
+ if (/serverSlots\s*:\s*\{/.test(source)) source = source.replace(/serverSlots\s*:\s*\{/, `serverSlots: {\n // ejected (znode eject ${name}): the store renders this around / instead of its page\n ${entry}`);
837
+ else if (/apiVersion\s*:\s*\d+\s*,/.test(source)) source = source.replace(/(apiVersion\s*:\s*\d+\s*,)/, `$1\n serverSlots: {\n ${entry}\n },`);
838
+ else {
839
+ console.log(`warning no "serverSlots" in src/server.tsx: add ${importLine} and serverSlots: { ${entry} }`);
840
+ source = null;
841
+ }
842
+ if (source) {
843
+ const imports = [...source.matchAll(/^import [^;]+;[ \t]*$/gm)];
844
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
845
+ source = source.slice(0, at) + `\n${importLine}` + source.slice(at);
846
+ fs.writeFileSync(server, source);
847
+ console.log(`registered "${s.name}": ${local} in src/server.tsx (serverSlots)`);
848
+ }
849
+ }
850
+ }
851
+ console.log(`\nNext: edit ${path.relative(project, out)}, then npm run build and npm run dev and open the page.`);
852
+ }
853
+
854
+ async function eject(name, force, { slot: slotArg, yes } = {}) {
855
+ const project = path.resolve(".");
856
+ if (!fs.existsSync(path.join(project, MANIFEST))) fail(`run znode eject in a package folder (the one with ${MANIFEST})`);
857
+ const store = linkedStore(project);
858
+ if (!store) fail("the package is not linked to a store: npm run link-store -- <store repo>");
859
+ const components = ejectableComponents(store.root);
860
+ if (!name || name === "--list") {
861
+ console.log("Store components a package can eject (the slot that takes your copy):\n");
862
+ for (const c of components) console.log(` ${c.name.padEnd(34)} ${c.slot.padEnd(26)} ${path.relative(store.root, c.file).split(path.sep).join("/")}`);
863
+ const pages = storeSlots(store.root).filter((s) => s.page);
864
+ if (pages.length) {
865
+ console.log("\nStore pages (server slot around the page's content; you get a server starter in src/server/pages/):\n");
866
+ for (const s of pages) console.log(` ${s.page.padEnd(34)} ${s.name.padEnd(26)} ${s.files[0]}`);
867
+ }
868
+ console.log("\nznode eject <Name> copies one into src/components/ (or a page starter into src/server/pages/) and registers it");
869
+ return;
870
+ }
871
+ const find = (list) => list.find((item) => item.name.toLowerCase() === name.toLowerCase() || item.slot.toLowerCase() === name.toLowerCase());
872
+ // a route page with a server slot around its content: the package gets a server starter for it (src/server.tsx)
873
+ const findServer = () =>
874
+ storeSlots(store.root).find((s) => s.page && [s.page.toLowerCase(), s.name.toLowerCase()].includes(name.toLowerCase()));
875
+ let c = find(components);
876
+ if (!c) {
877
+ const server = findServer();
878
+ if (server) return ejectServerSlot(project, server, force);
879
+ }
880
+ if (!c) {
881
+ // no slot hands this component over yet: offer to create one in the base store (what znode create-slot does), then eject
882
+ const isComponent = /^[A-Z]\w*$/.test(name);
883
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
884
+ const hint =
885
+ ` znode eject lists the ones that can (component name or slot name both work)\n` +
886
+ ` znode eject ${isComponent ? name : "<Component>"} --slot <area.name> creates the slot in the base store first, then ejects\n` +
887
+ ` znode create-slot ${isComponent ? name : "<Component>"} <area.name> only creates the slot (edits the linked store's source)`;
888
+ if (!isComponent || (!interactive && !slotArg)) fail(`${name} cannot be ejected: no store component with that name or slot hands itself over to a slot.\n${hint}`);
889
+ console.log(`${name} has no slot yet, so it cannot be ejected.`);
890
+ console.log(`A slot can be created around it in the base store (${store.root}): it edits the store's source, nothing is committed.`);
891
+ if (!yes && !slotArg) {
892
+ const answer = await ask(`Create a slot for ${name} in the base store now? [y/N] `);
893
+ if (!/^y(es)?$/i.test(answer)) fail(`not created.\n${hint}`);
894
+ }
895
+ let slot = slotArg;
896
+ while (!slot) {
897
+ const suggested = suggestSlot(name);
898
+ slot = (await ask(`Slot name (area.name) [${suggested}]: `)) || suggested;
899
+ if (!SLOT_NAME.test(slot)) {
900
+ console.log(` "${slot}" is not a slot name: area.name in camelCase, e.g. product.details`);
901
+ slot = "";
902
+ }
903
+ }
904
+ createSlot(name, slot, { fromEject: true });
905
+ c = find(ejectableComponents(store.root));
906
+ if (!c) {
907
+ const server = findServer();
908
+ if (server) {
909
+ console.log("");
910
+ return ejectServerSlot(project, server, force);
911
+ }
912
+ fail(`the slot "${slot}" was created, but ${name} is still not found as ejectable; check the store diff`);
913
+ }
914
+ console.log("");
915
+ }
916
+
917
+ const out = path.join(project, "src", "components", `${c.name}.tsx`);
918
+ if (fs.existsSync(out) && !force) fail(`${path.relative(project, out)} exists: delete it or run znode eject ${c.name} --force`);
919
+ const warnings = [];
920
+ let text = fs.readFileSync(c.file, "utf8");
921
+ // relative imports become the store's aliases (the package lives elsewhere); a style file stays the store's
922
+ text = text.replace(/((?:from|import)\s*\(?\s*["'])(\.{1,2}\/[^"']+)(["'])/g, (whole, before, spec, after) => {
923
+ const absolute = path.resolve(path.dirname(c.file), spec);
924
+ if (/\.(s?css)$/.test(spec)) {
925
+ 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/`);
926
+ return `${before}${spec}${after}`;
927
+ }
928
+ const alias = storeAlias(store, fs.existsSync(absolute) || /\.\w+$/.test(spec) ? absolute : absolute);
929
+ if (!alias) {
930
+ warnings.push(`import ${spec}: no store alias for it; change it by hand`);
931
+ return whole;
932
+ }
933
+ return `${before}${alias}${after}`;
934
+ });
935
+ // drop scss/css side-effect imports that point into the store (they cannot be bundled into a package)
936
+ text = text.replace(/^import\s+["'][^"']+\.s?css["'];?\s*\n/gm, "");
937
+ // the copy is the plain component: no runtime wrapper (the slot is what puts it in place)
938
+ text = text.replace(new RegExp(`^.*import\\s*\\{[^}]*\\b(?:withRuntimeWrapper|${c.wrapper})\\b[^}]*\\}\\s*from\\s*["'][^"']+["'];?\\s*\\n`, "gm"), "");
939
+ text = text.replace(new RegExp(`withRuntimeWrapper\\(\\s*${c.wrapper}\\s*,\\s*${c.base}\\s*,\\s*"${c.name}"\\s*\\)`), c.base);
940
+ // and none of the store's comments about that wrapper
941
+ text = text.replace(/^\/\/ runtime packages render around it\b.*\n/gm, "");
942
+ const storeFile = path.relative(store.root, c.file).split(path.sep).join("/");
943
+ const header = `// Ejected from the store: ${storeFile} (${new Date().toISOString().slice(0, 10)}), with \`znode eject ${c.name}\`.\n// The store renders this copy instead of its own through the slot "${c.slot}" (src/browser.tsx). It is yours now: the\n// store's later changes to ${c.name} do not reach it, so compare it with the store's file after a store upgrade.\n`;
944
+ text = text.startsWith('"use client"') || text.startsWith("'use client'") ? text.replace(/^(["']use client["'];?\s*\n)/, `$1${header}`) : header + text;
945
+ fs.mkdirSync(path.dirname(out), { recursive: true });
946
+ fs.writeFileSync(out, text);
947
+ console.log(`ejected ${c.name} -> ${path.relative(project, out)}`);
948
+
949
+ // npm packages the copy imports that this package does not have (react, next and the store's @znode modules come from
950
+ // the store): install them at the store's version, so the types match and the build finds them
951
+ const PROVIDED = /^(react|react-dom|next|next-intl|@znode)(\/|$)/;
952
+ const storePackage = (() => {
953
+ try {
954
+ return readJson(path.join(store.root, "package.json"));
955
+ } catch {
956
+ return {};
957
+ }
958
+ })();
959
+ const storeDeps = { ...(storePackage.devDependencies || {}), ...(storePackage.dependencies || {}) };
960
+ const missing = new Map();
961
+ for (const m of text.matchAll(/(?:from|import)\s*\(?\s*["']([^."'/][^"']*)["']/g)) {
962
+ const spec = m[1];
963
+ if (PROVIDED.test(spec)) continue;
964
+ const pkg = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
965
+ try {
966
+ createRequire(path.join(project, "package.json")).resolve(`${pkg}/package.json`);
967
+ } catch {
968
+ missing.set(pkg, storeDeps[pkg] ? `${pkg}@${storeDeps[pkg].replace(/^[\^~]/, "")}` : pkg);
969
+ }
970
+ }
971
+ if (missing.size) {
972
+ console.log(`\nThe copy imports npm packages this package does not have (bundled into the package when it builds):`);
973
+ console.log(` npm install ${[...missing.values()].join(" ")}`);
974
+ }
975
+
976
+ // register the copy under the slot in src/browser.tsx
977
+ const browser = path.join(project, "src", "browser.tsx");
978
+ const exportsDefault = new RegExp(`export\\s+default\\s+${c.name}\\b`).test(text) || new RegExp(`export\\s+default\\s+${c.base}\\b`).test(text);
979
+ const local = `Ejected${c.name}`;
980
+ const importLine = exportsDefault
981
+ ? `import ${local} from "./components/${c.name}.js";`
982
+ : `import { ${c.name} as ${local} } from "./components/${c.name}.js";`;
983
+ if (!fs.existsSync(browser)) {
984
+ warnings.push(`src/browser.tsx not found: register it yourself: ${importLine} and slots: { "${c.slot}": ${local} }`);
985
+ } else {
986
+ let source = fs.readFileSync(browser, "utf8");
987
+ if (new RegExp(`["']${c.slot.replace(".", "\\.")}["']\\s*:`).test(source)) {
988
+ warnings.push(`src/browser.tsx already fills "${c.slot}": point it at ${local} (${importLine}) yourself`);
989
+ } else if (/slots\s*:\s*\{/.test(source)) {
990
+ if (!source.includes(importLine)) {
991
+ const imports = [...source.matchAll(/^import [^;]+;[ \t]*$/gm)];
992
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
993
+ source = source.slice(0, at) + `\n${importLine}` + source.slice(at);
994
+ }
995
+ source = source.replace(/slots\s*:\s*\{\s*\}/, `slots: {\n "${c.slot}": ${local},\n }`);
996
+ if (!source.includes(`"${c.slot}": ${local}`)) source = source.replace(/slots\s*:\s*\{/, `slots: {\n // ejected (znode eject ${c.name}): the store renders this copy instead of its own\n "${c.slot}": ${local},`);
997
+ fs.writeFileSync(browser, source);
998
+ console.log(`registered "${c.slot}": ${local} in src/browser.tsx`);
999
+ } else {
1000
+ warnings.push(`no "slots" in src/browser.tsx: add ${importLine} and slots: { "${c.slot}": ${local} }`);
1001
+ }
1002
+ }
1003
+ for (const warning of warnings) console.log(`warning ${warning}`);
1004
+ console.log(`\nNext: npm run build (it names any store import a package cannot use), then npm run dev and open a page that shows ${c.name}.`);
1005
+ }
1006
+
1007
+ // ---- create-slot: a runtime slot around a store component, written into the linked store's source ------------------
1008
+ // `znode create-slot <Component> <area.name>` does in the store what a base developer did by hand for product.basicInfo:
1009
+ // a Runtime<Component> wrapper (packages/extensions/src/slots/RuntimeCreatedSlots.tsx) that renders <RuntimeSlot name>
1010
+ // with the component's props and the component itself as children, and the component's export wrapped with
1011
+ // withRuntimeWrapper, so every place that renders it gets the slot. It edits source only (never .next, never git):
1012
+ // review the diff, commit it through the store's own flow, rebuild the webstore; then `znode eject <Component>` works.
1013
+ const CREATED_SLOTS_FILE = "packages/extensions/src/slots/RuntimeCreatedSlots.tsx";
1014
+ const SLOT_NAME = /^[a-z][A-Za-z0-9]*(\.[a-z][A-Za-z0-9]*)+$/;
1015
+
1016
+ function storeSourceFiles(root) {
1017
+ const files = [];
1018
+ const walk = (dir) => {
1019
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1020
+ const file = path.join(dir, entry.name);
1021
+ if (entry.isDirectory()) {
1022
+ if (entry.name !== "node_modules" && !entry.name.startsWith("__")) walk(file);
1023
+ } else if (/\.tsx$/.test(entry.name) && !/\.(test|spec|stories)\.tsx$/.test(entry.name)) files.push(file);
1024
+ }
1025
+ };
1026
+ for (const dir of ["packages/base-components/src", "packages/page-builder/src"]) if (fs.existsSync(path.join(root, dir))) walk(path.join(root, dir));
1027
+ return files;
1028
+ }
1029
+
1030
+ // the store's route pages and layouts (apps/<app>/src/app/**/page.tsx, layout.tsx)
1031
+ function storeRoutePages(root) {
1032
+ const files = [];
1033
+ const walk = (dir) => {
1034
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1035
+ const file = path.join(dir, entry.name);
1036
+ if (entry.isDirectory()) {
1037
+ if (entry.name !== "node_modules") walk(file);
1038
+ } else if (/^(page|layout)\.tsx$/.test(entry.name)) files.push(file);
1039
+ }
1040
+ };
1041
+ const apps = path.join(root, "apps");
1042
+ if (fs.existsSync(apps)) for (const app of fs.readdirSync(apps)) if (fs.existsSync(path.join(apps, app, "src/app"))) walk(path.join(apps, app, "src/app"));
1043
+ return files;
1044
+ }
1045
+
1046
+ // the ")" that closes the "(" at index open (parentheses in the JSX's text are expected to be balanced)
1047
+ function closingParen(text, open) {
1048
+ let depth = 0;
1049
+ for (let i = open; i < text.length; i++) {
1050
+ if (text[i] === "(") depth++;
1051
+ else if (text[i] === ")" && --depth === 0) return i;
1052
+ }
1053
+ return -1;
1054
+ }
1055
+
1056
+ // the ">" that ends the JSX opening tag starting at index start (attributes in {…} may contain ">", e.g. "=>")
1057
+ function openTagEnd(text, start) {
1058
+ let depth = 0;
1059
+ for (let i = start; i < text.length; i++) {
1060
+ if (text[i] === "{") depth++;
1061
+ else if (text[i] === "}") depth--;
1062
+ else if (text[i] === ">" && depth === 0) return i;
1063
+ }
1064
+ return -1;
1065
+ }
1066
+
1067
+ // A route page gets a server slot around what it returns: <RuntimeServerSlot name="…"> … </RuntimeServerSlot>, inside a
1068
+ // NextIntlClientProvider at the top (so the slot's components, and the store's, keep the page's texts). A package fills
1069
+ // it in src/server.tsx (serverSlots): it gets the page's content as children and the store / settings as context.
1070
+ function routeServerSlot(file, name, slot, rel) {
1071
+ const before = fs.readFileSync(file, "utf8");
1072
+ if (/^\s*["']use client["']/.test(before)) fail(`${rel(file)} is a client page ("use client"): a server slot cannot go there; add a <RuntimeSlot> by hand`);
1073
+ const fnAt = before.search(new RegExp(`export\\s+default\\s+(?:async\\s+)?function\\s+${name}\\s*\\(`));
1074
+ const existing = before.slice(fnAt).match(/<RuntimeServerSlot\s+name=["']([\w.]+)["']/);
1075
+ if (existing) fail(`${name} (${rel(file)}) already has the server slot "${existing[1]}": fill that one (npx znode slot list)`);
1076
+ const ret = before.lastIndexOf("return (");
1077
+ if (ret < fnAt) fail(`${name} (${rel(file)}) does not end with return (<…>): put <RuntimeServerSlot name="${slot}"> around its content by hand`);
1078
+ const open = ret + "return ".length;
1079
+ const close = closingParen(before, open);
1080
+ if (close < 0) fail(`${rel(file)}: could not find the end of ${name}'s return (…): put <RuntimeServerSlot name="${slot}"> around its content by hand`);
1081
+ let start = open + 1;
1082
+ let end = close;
1083
+ const providerAt = before.slice(start, end).search(/^\s*<NextIntlClientProvider\b/);
1084
+ if (providerAt === 0) {
1085
+ const tagStart = before.indexOf("<NextIntlClientProvider", start);
1086
+ const tagClose = before.lastIndexOf("</NextIntlClientProvider>", close);
1087
+ const tagEnd = openTagEnd(before, tagStart);
1088
+ if (tagEnd > 0 && tagClose > tagEnd && before[tagEnd - 1] !== "/") {
1089
+ start = tagEnd + 1;
1090
+ end = tagClose;
1091
+ }
1092
+ }
1093
+ const inner = before.slice(start, end);
1094
+ const lead = inner.match(/^\s*/)[0];
1095
+ const trail = inner.match(/\s*$/)[0];
1096
+ const core = inner.slice(lead.length, inner.length - trail.length);
1097
+ const indent = lead.includes("\n") ? lead.slice(lead.lastIndexOf("\n") + 1) : "";
1098
+ const body = core
1099
+ .split("\n")
1100
+ .map((line, i) => (i === 0 || !line.trim() ? line : ` ${line}`))
1101
+ .join("\n");
1102
+ const wrapped = `${lead}<RuntimeServerSlot name="${slot}">\n${indent} ${body}\n${indent}</RuntimeServerSlot>${trail}`;
1103
+ let after = before.slice(0, start) + wrapped + before.slice(end);
1104
+ // a note above the return, and the import
1105
+ // (the text up to the return is unchanged, so its position still holds)
1106
+ const lineStart = before.lastIndexOf("\n", ret) + 1;
1107
+ const retIndent = before.slice(lineStart, ret);
1108
+ after = after.slice(0, lineStart) + `${retIndent}// runtime packages: server components around the page's content (server slot ${slot}, znode create-slot)\n` + after.slice(lineStart);
1109
+ if (!/import\s*\{[^}]*\bRuntimeServerSlot\b/.test(after)) {
1110
+ const imports = [...after.matchAll(/^import [^;]+;[ \t]*$/gm)];
1111
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length : 0;
1112
+ after = after.slice(0, at) + `${at ? "\n" : ""}import { RuntimeServerSlot } from "@znode/extensions/RuntimeServerSlot";${at ? "" : "\n"}` + after.slice(at);
1113
+ }
1114
+ return { file, before, after };
1115
+ }
1116
+
1117
+ function createSlot(name, slot, { dryRun, file: chosen, fromEject } = {}) {
1118
+ const usage = "usage: znode create-slot <Component> <area.name> [--dry-run] [--file <store file>] e.g. znode create-slot ProductBasicInfo product.basicInfo";
1119
+ if (!name || !slot) fail(usage);
1120
+ if (!/^[A-Z]\w*$/.test(name)) fail(`"${name}" is not a component name (PascalCase, e.g. ProductDetails)\n${usage}`);
1121
+ if (!SLOT_NAME.test(slot)) fail(`"${slot}" is not a slot name: area.name in camelCase, e.g. product.details`);
1122
+ const store = linkedStore(path.resolve("."));
1123
+ if (!store) fail("not linked to a store: run it in a package linked with npm run link-store -- <store repo> (create-slot edits that store's source)");
1124
+ const root = store.root;
1125
+ const rel = (file) => path.relative(root, file).split(path.sep).join("/");
1126
+
1127
+ // the slot must be new
1128
+ const slotsDir = path.join(root, "packages/extensions/src/slots");
1129
+ if (!fs.existsSync(slotsDir)) fail(`${rel(slotsDir)} not found: this store has no runtime slots (packages/extensions)`);
1130
+ const wrapperSlots = storeWrapperSlots(root);
1131
+ const taken = Object.entries(wrapperSlots).find(([, value]) => value === slot);
1132
+ if (taken) fail(`slot "${slot}" already exists (${taken[0]}); znode eject lists what it hands over`);
1133
+ if (storeSlots(root).some((s) => s.name === slot)) fail(`slot "${slot}" already exists in the store (npx znode slot list)`);
1134
+ const wrapper = `Runtime${name}`;
1135
+ if (wrapperSlots[wrapper] || fs.readdirSync(slotsDir).some((f) => new RegExp(`export\\s+function\\s+${wrapper}\\b`).test(fs.readFileSync(path.join(slotsDir, f), "utf8"))))
1136
+ fail(`${wrapper} already exists in ${rel(slotsDir)}: ${name} already has a slot (znode eject lists it)`);
1137
+
1138
+ // the file that defines the component
1139
+ const DEFINES = new RegExp(`^(export\\s+(?:default\\s+)?)?(async\\s+)?function\\s+${name}\\s*[(<]|^(export\\s+)?const\\s+${name}\\s*(?::[^=]+)?=\\s*(async\\b)?`, "m");
1140
+ let candidates = chosen ? [path.resolve(root, chosen)] : storeSourceFiles(root).filter((f) => DEFINES.test(fs.readFileSync(f, "utf8")));
1141
+ if (chosen && !fs.existsSync(candidates[0])) fail(`${chosen} not found in ${root}`);
1142
+ if (candidates.length > 1) {
1143
+ const named = candidates.filter((f) => path.basename(f, ".tsx") === name);
1144
+ if (named.length === 1) candidates = named;
1145
+ else fail(`${name} is defined in more than one store file; pick one with --file:\n ${candidates.map(rel).join("\n ")}`);
1146
+ }
1147
+ if (!candidates.length) {
1148
+ // not a component: a route page (apps/<app>/src/app/**/page.tsx or layout.tsx) whose default export is <name>
1149
+ const pages = storeRoutePages(root).filter((f) => new RegExp(`export\\s+default\\s+(?:async\\s+)?function\\s+${name}\\s*\\(`).test(fs.readFileSync(f, "utf8")));
1150
+ if (pages.length > 1) fail(`${name} is the default export of more than one route; pick one with --file:\n ${pages.map(rel).join("\n ")}`);
1151
+ if (!pages.length)
1152
+ fail(
1153
+ `no ${name} found: not a component in packages/base-components/src or packages/page-builder/src, and not the default export of a route page (apps/*/src/app/**/page.tsx or layout.tsx).\n` +
1154
+ ` npx znode slot list shows the slots there are; a page's component name is its "export default function <Name>"`
1155
+ );
1156
+ candidates = pages;
1157
+ }
1158
+ const target = candidates[0];
1159
+ const isRoute = /^apps\/[^/]+\/src\/app\//.test(rel(target));
1160
+ let changes;
1161
+ if (isRoute) changes = [routeServerSlot(target, name, slot, rel)];
1162
+ else {
1163
+ let source = fs.readFileSync(target, "utf8");
1164
+ const def = source.match(DEFINES);
1165
+ if (!def) fail(`${rel(target)} does not define ${name} as a function or const component`);
1166
+
1167
+ // only a client-renderable component: a slot is filled in the browser
1168
+ if (new RegExp(`withRuntimeWrapper\\([^)]*["']${name}["']`).test(source)) fail(`${name} is already wrapped (${rel(target)}): it has a slot, znode eject lists it`);
1169
+ if (def[2] || def[4])
1170
+ fail(
1171
+ `${name} is an async server component (${rel(target)}): a runtime slot is filled in the browser, so it cannot wrap a server component.\n` +
1172
+ ` Add the slot to a client component it renders instead (e.g. its ProductBasicInfo), or split ${name} into a server part (data) and a client part (UI) and add the slot to the client part.`
1173
+ );
1174
+ const serverImport = source.match(/^\s*import\b[^;]*["'](server-only|next\/headers|fs|node:fs)["']/m);
1175
+ if (serverImport) fail(`${name} imports ${serverImport[1]} (${rel(target)}): it runs on the server only, so a browser slot cannot wrap it`);
1176
+
1177
+ // wrap the export: <Name>Base is the component, <Name> is the wrapped one every caller already imports
1178
+ const baseName = `${name}Base`;
1179
+ if (new RegExp(`\\b${baseName}\\b`).test(source)) fail(`${rel(target)} already has a ${baseName}: wrap it by hand`);
1180
+ const wrapped = `withRuntimeWrapper(${wrapper}, ${baseName}, "${name}")`;
1181
+ const comment = `// runtime packages render around it through the slot "${slot}" (znode create-slot); with none, it renders as it is`;
1182
+ if (/^export\s+default\s+/.test(def[0])) {
1183
+ source = source.replace(def[0], def[0].replace(/^export\s+default\s+/, "").replace(name, baseName));
1184
+ source = source.trimEnd() + `\n\n${comment}\nconst ${name} = ${wrapped};\n\nexport default ${name};\n`;
1185
+ } else if (def[1] || def[3]) {
1186
+ source = source.replace(def[0], def[0].replace(/^export\s+/, "").replace(name, baseName));
1187
+ source = source.trimEnd() + `\n\n${comment}\nexport const ${name} = ${wrapped};\n`;
1188
+ } else {
1189
+ // defined, exported later (export default Name; / export { Name }): the wrapped const goes right before that export
1190
+ source = source.replace(def[0], def[0].replace(name, baseName));
1191
+ const exportAt = source.search(new RegExp(`^export\\s+(default\\s+${name}\\b|\\{[^}]*\\b${name}\\b)`, "m"));
1192
+ const line = `${comment}\nconst ${name} = ${wrapped};\n\n`;
1193
+ source = exportAt >= 0 ? source.slice(0, exportAt) + line + source.slice(exportAt) : source.trimEnd() + `\n\n${line}`;
1194
+ }
1195
+ // statics set on the component belong to the base (they run before the wrapped const exists)
1196
+ source = source.replace(new RegExp(`\\b${name}\\.(displayName|defaultProps|propTypes)\\b`, "g"), `${baseName}.$1`);
1197
+ const imports = [`import { withRuntimeWrapper } from "@znode/extensions/slots/with-runtime-wrapper";`, `import { ${wrapper} } from "@znode/extensions/slots/RuntimeCreatedSlots";`].filter(
1198
+ (line) => !(line.includes("withRuntimeWrapper }") && /import\s*\{[^}]*\bwithRuntimeWrapper\b/.test(source))
1199
+ );
1200
+ const directive = source.match(/^\s*["']use (client|server)["'];?\s*\n/);
1201
+ const at = directive ? directive[0].length : 0;
1202
+ source = source.slice(0, at) + (directive ? "\n" : "") + imports.join("\n") + "\n" + (directive ? "" : "\n") + source.slice(at).replace(/^\n+/, directive ? "" : "");
1203
+
1204
+ // the wrapper
1205
+ const addedFile = path.join(root, CREATED_SLOTS_FILE);
1206
+ const addedBefore = fs.existsSync(addedFile) ? fs.readFileSync(addedFile, "utf8") : null;
1207
+ let added =
1208
+ addedBefore ??
1209
+ `"use client";\n\n// Slots created with \`znode create-slot <Component> <area.name>\` (znode-plugin): each Runtime<Component> renders the slot\n// around the store component (withRuntimeWrapper where the component is defined), with the component's props and the\n// component itself as children. With no runtime package for the slot, the component renders as it is.\n\nimport { ReactNode } from "react";\nimport { RuntimeSlot } from "../client/RuntimeSlot";\n`;
1210
+ added =
1211
+ added.trimEnd() +
1212
+ `\n\n/** ${slot}: the store's ${name} (children), with every prop it gets. Created with \`znode create-slot ${name} ${slot}\` (${new Date().toISOString().slice(0, 10)}). */\n` +
1213
+ `export function ${wrapper}({ children, ...props }: Readonly<Record<string, unknown> & { children?: ReactNode }>) {\n` +
1214
+ ` return (\n <RuntimeSlot name="${slot}" props={props}>\n {children}\n </RuntimeSlot>\n );\n}\n`;
1215
+
1216
+ changes = [
1217
+ { file: target, before: fs.readFileSync(target, "utf8"), after: source },
1218
+ { file: addedFile, before: addedBefore, after: added },
1219
+ ];
1220
+ }
1221
+ const warnings = [];
1222
+ const where = rel(target).replace(/^packages\/[\w-]+\/src\//, "").replace(/^apps\/[^/]+\/src\/app\/(\[locale\]\/)?/, "app/").replace(/\.tsx$/, "");
1223
+
1224
+ // the slot list in the SDK types of the store (packages/extensions/src/types.ts), when it has one
1225
+ const typesFile = path.join(root, "packages/extensions/src/types.ts");
1226
+ if (fs.existsSync(typesFile)) {
1227
+ const before = fs.readFileSync(typesFile, "utf8");
1228
+ const lines = before.split("\n");
1229
+ const start = lines.findIndex((line) => /^\s*\/\/\s*Slots\s*\(/.test(line));
1230
+ if (start >= 0) {
1231
+ let end = start + 1;
1232
+ while (end < lines.length && /^\s*\/\/\s{3,}/.test(lines[end])) end++;
1233
+ lines.splice(
1234
+ end,
1235
+ 0,
1236
+ isRoute
1237
+ ? `// ${slot.padEnd(20)} (server slot, src/server.tsx serverSlots) the ${where} route's content (children) (znode create-slot)`
1238
+ : `// ${slot.padEnd(20)} the store's ${name} itself (children) props: every prop ${name} gets (znode create-slot)`
1239
+ );
1240
+ changes.push({ file: typesFile, before, after: lines.join("\n") });
1241
+ } else warnings.push(`${rel(typesFile)}: no "// Slots (" comment found; add "${slot}" to the slot list yourself`);
1242
+ }
1243
+ // the slot table in docs/RUNTIME_EXTENSIONS_AGENTS.md, when it has one
1244
+ const docsFile = path.join(root, "docs/RUNTIME_EXTENSIONS_AGENTS.md");
1245
+ if (fs.existsSync(docsFile)) {
1246
+ const before = fs.readFileSync(docsFile, "utf8");
1247
+ const lines = before.split("\n");
1248
+ let row = lines.findIndex((line) => line.startsWith("| `product.card`"));
1249
+ if (row >= 0) {
1250
+ while (row + 1 < lines.length && lines[row + 1].startsWith("|")) row++;
1251
+ lines.splice(
1252
+ row + 1,
1253
+ 0,
1254
+ isRoute
1255
+ ? `| \`${slot}\` (server slot) | none: \`<RuntimeServerSlot>\` written directly in \`${rel(target)}\` (znode create-slot) | the ${where} route's content (\`${name}\`) | none (context: store, settings); keep \`children\`, wrap it, or render your own page (\`znode eject ${name}\`) |`
1256
+ : `| \`${slot}\` | \`${wrapper}\` (\`${CREATED_SLOTS_FILE.replace("packages/extensions/src/", "")}\`) | \`${where}\` (its children: the component itself) | every ${name} prop; keep \`children\`, wrap it, or render a copy instead (\`znode eject ${name}\`) |`
1257
+ );
1258
+ changes.push({ file: docsFile, before, after: lines.join("\n") });
1259
+ } else warnings.push(`${rel(docsFile)}: slot table not found; add a "${slot}" row yourself`);
1260
+ }
1261
+
1262
+ console.log(`${dryRun ? "would create" : "creating"} slot "${slot}" around ${name} (${rel(target)}) in ${root}\n`);
1263
+ for (const { file, before, after } of changes) {
1264
+ const old = new Set((before || "").split("\n"));
1265
+ const added = after.split("\n").filter((line) => !old.has(line));
1266
+ console.log(`${before === null ? "new " : "edit"} ${rel(file)}`);
1267
+ for (const line of added) console.log(` + ${line}`);
1268
+ }
1269
+ if (dryRun) {
1270
+ console.log("\n--dry-run: nothing written. Run it again without --dry-run to write these changes.");
1271
+ return;
1272
+ }
1273
+ for (const { file, after } of changes) fs.writeFileSync(file, after);
1274
+
1275
+ // the store's module registry, when the store generates one (the ejected copy's imports are checked against it)
1276
+ const registry = path.join(root, "scripts/gen-store-module-registry.mjs");
1277
+ if (fs.existsSync(registry)) spawnSync(process.execPath, [registry], { cwd: root, stdio: "inherit" });
1278
+ for (const warning of warnings) console.log(`warning ${warning}`);
1279
+ if (fromEject) {
1280
+ console.log(
1281
+ `\nSlot "${slot}" created in the base store's source (nothing committed, .next untouched). Before the store renders it:\n` +
1282
+ ` review the diff in ${root} (git diff), commit it through the store's own flow, rebuild / restart the webstore.`
1283
+ );
1284
+ return;
1285
+ }
1286
+ console.log(
1287
+ `\nDone (the store's source only: nothing committed, .next untouched). Next:\n` +
1288
+ ` 1. review the diff in ${root} (git diff) and commit it through the store's own flow (hooks, review: it edits base components)\n` +
1289
+ ` 2. rebuild / restart the webstore so it renders the slot\n` +
1290
+ ` 3. here: npx znode eject ${name} (or fill "${slot}" in ${isRoute ? "src/server.tsx serverSlots" : "src/browser.tsx slots"} with a component of your own)`
1291
+ );
1292
+ }
1293
+
1294
+ // ---- slot list: every slot the linked store renders, read from its source --------------------------------------
1295
+ // Each <RuntimeSlot name> (browser) and <RuntimeServerSlot name> (server) in the store's packages and apps, with the
1296
+ // store component it is around (the withRuntimeWrapper that renders the wrapper, else the file it is written in),
1297
+ // whether the slot is handed what the store renders there (children: keep, wrap or replace it) or only adds/lays out,
1298
+ // whether `znode eject` can copy the component into it, and whether this package already fills it.
1299
+ function storeSlots(root) {
1300
+ const slots = new Map();
1301
+ const files = [];
1302
+ const walk = (dir) => {
1303
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1304
+ const file = path.join(dir, entry.name);
1305
+ if (entry.isDirectory()) {
1306
+ if (!["node_modules", ".next", "dist", "out"].includes(entry.name) && !entry.name.startsWith("__")) walk(file);
1307
+ } else if (/\.tsx$/.test(entry.name) && !/\.(test|spec|stories)\.tsx$/.test(entry.name)) files.push(file);
1308
+ }
1309
+ };
1310
+ for (const dir of ["packages", "apps"]) if (fs.existsSync(path.join(root, dir))) walk(path.join(root, dir));
1311
+
1312
+ // wrapper -> the store components it is put around
1313
+ const components = {};
1314
+ for (const file of files) {
1315
+ for (const m of fs.readFileSync(file, "utf8").matchAll(/withRuntimeWrapper\(\s*(\w+)\s*,\s*\w+\s*,\s*["'](\w+)["']\s*\)/g)) (components[m[1]] ||= []).push(m[2]);
1316
+ }
1317
+ const handover = storeWrapperSlots(root);
1318
+ const slotsDir = path.join(root, "packages/extensions/src/slots");
1319
+ for (const file of files) {
1320
+ const text = fs.readFileSync(file, "utf8");
1321
+ const wrappers = file.startsWith(slotsDir) ? [...text.matchAll(/export\s+function\s+(Runtime\w+)/g)] : [];
1322
+ for (const m of text.matchAll(/<Runtime(Server)?Slot\s+name=["']([\w.]+)["']/g)) {
1323
+ const name = m[2];
1324
+ const tag = m[1] ? "RuntimeServerSlot" : "RuntimeSlot";
1325
+ // it wraps something unless its tag closes itself (<RuntimeSlot name="…" … />)
1326
+ const tagEnd = openTagEnd(text, m.index);
1327
+ const wraps = tagEnd > 0 && text[tagEnd - 1] !== "/" && text.indexOf(`</${tag}>`, tagEnd) > 0;
1328
+ const wrapper = wrappers.filter((w) => w.index < m.index).pop()?.[1];
1329
+ const relFile = path.relative(root, file).split(path.sep).join("/");
1330
+ const existing = slots.get(name);
1331
+ if (existing) {
1332
+ existing.files.push(relFile);
1333
+ continue;
1334
+ }
1335
+ // a slot written in a route file: the route (app/login/page), else the component file it is written in
1336
+ const where = /^apps\/[^/]+\/src\/app\//.test(relFile)
1337
+ ? "app/" + relFile.replace(/^apps\/[^/]+\/src\/app\/(\[locale\]\/)?/, "").replace(/\.tsx$/, "")
1338
+ : path.basename(file, ".tsx");
1339
+ const around = wrapper ? [...new Set(components[wrapper] || [])] : [where];
1340
+ // a server slot written in a route page wraps that page's content: eject gives the package a starter for it
1341
+ const page = !wrapper && m[1] && wraps && /^apps\//.test(relFile) ? (text.match(/export\s+default\s+(?:async\s+)?function\s+(\w+)/) || [])[1] || null : null;
1342
+ slots.set(name, {
1343
+ name,
1344
+ side: m[1] ? "server" : "browser",
1345
+ handsOver: wraps,
1346
+ wrapper: wrapper || null,
1347
+ components: page ? [`${page} (${where})`] : around,
1348
+ page,
1349
+ eject: wrapper && handover[wrapper] === name ? around[0] || null : page,
1350
+ files: [relFile],
1351
+ });
1352
+ }
1353
+ }
1354
+ return [...slots.values()].sort((a, b) => a.name.localeCompare(b.name));
1355
+ }
1356
+
1357
+ function slotList(filter, json) {
1358
+ const project = path.resolve(".");
1359
+ const store = linkedStore(project);
1360
+ if (!store) fail("not linked to a store: npm run link-store -- <store repo> (the slot list is read from the store's source)");
1361
+ let slots = storeSlots(store.root);
1362
+ // what this package fills (src/browser.tsx slots, src/server.tsx serverSlots)
1363
+ const filled = (file) => (fs.existsSync(path.join(project, "src", file)) ? fs.readFileSync(path.join(project, "src", file), "utf8") : "");
1364
+ const browser = filled("browser.tsx");
1365
+ const server = filled("server.tsx");
1366
+ for (const slot of slots) {
1367
+ const pattern = new RegExp(`["']${slot.name.replace(/\./g, "\\.")}["']\\s*:`);
1368
+ slot.filledByPackage = pattern.test(slot.side === "server" ? server : browser);
1369
+ }
1370
+ if (filter) slots = slots.filter((s) => [s.name, ...s.components].some((value) => value.toLowerCase().includes(filter.toLowerCase())));
1371
+ if (json) {
1372
+ console.log(JSON.stringify({ store: store.root, slots }, null, 2));
1373
+ return;
1374
+ }
1375
+ if (!slots.length) {
1376
+ console.log(filter ? `no slot matches "${filter}"` : "the linked store has no runtime slots");
1377
+ return;
1378
+ }
1379
+ const rows = [["Slot", "Side", "Hands over", "Store component", "Eject", "In this package"]];
1380
+ for (const s of slots) rows.push([s.name, s.side, s.handsOver ? "yes" : "no", s.components.join(", ") || "-", s.eject || "-", s.filledByPackage ? "yes" : ""]);
1381
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)));
1382
+ console.log(`Slots in the linked store (${store.root}):\n`);
1383
+ rows.forEach((row, i) => {
1384
+ console.log(" " + row.map((cell, j) => cell.padEnd(widths[j])).join(" ").trimEnd());
1385
+ if (i === 0) console.log(" " + widths.map((w) => "-".repeat(w)).join(" "));
1386
+ });
1387
+ console.log(
1388
+ `\n Hands over: yes = the slot gets what the store renders there (children): keep it, wrap it or replace it; no = it adds or lays out.\n` +
1389
+ ` Fill one in src/browser.tsx (slots) or src/server.tsx (serverSlots). Eject: npx znode eject <component or slot>.\n` +
1390
+ ` No slot where you need one: npx znode create-slot <Component> <area.name> (in the base store). --json for the full list.`
1391
+ );
1392
+ }
1393
+
1394
+ const args = process.argv.slice(2);
1395
+ // the store's plugin folder: the argument, else RUNTIME_PLUGINS_DIR, else the project's git-ignored znode.local.json
1396
+ // ({ "pluginsDir": "…" }, written by create-znode-plugin --plugins-dir) in the folder the command runs in
1397
+ function localPluginsDir() {
1398
+ try {
1399
+ return JSON.parse(fs.readFileSync(path.resolve("znode.local.json"), "utf8")).pluginsDir || undefined;
1400
+ } catch {
1401
+ return undefined;
1402
+ }
1403
+ }
1404
+ const [command, target, storeDir = process.env.RUNTIME_PLUGINS_DIR || localPluginsDir()] = args;
1405
+ // create-slot <Component> <area.name> (also "create slot …"; add-slot is the 0.1.6 name, kept so it still works)
1406
+ const slotCommand = ["create-slot", "add-slot"].includes(command) ? 1 : (command === "create" || command === "add") && args[1] === "slot" ? 2 : 0;
1407
+ if (slotCommand) {
1408
+ const rest = args.slice(slotCommand).filter((arg) => arg !== "--");
1409
+ const fileAt = rest.indexOf("--file");
1410
+ const file = fileAt >= 0 ? rest[fileAt + 1] : undefined;
1411
+ const positional = rest.filter((arg, index) => !arg.startsWith("--") && !(fileAt >= 0 && index === fileAt + 1));
1412
+ createSlot(positional[0], positional[1], { dryRun: rest.includes("--dry-run"), file });
1413
+ process.exit(0);
1414
+ }
1415
+ // slot list [filter] [--json] (also "slots", "slot", "slot lists")
1416
+ if (command === "slots" || (command === "slot" && (!args[1] || /^lists?$/.test(args[1]) || args[1].startsWith("--")))) {
1417
+ const rest = args.slice(command === "slot" && /^lists?$/.test(args[1] || "") ? 2 : 1);
1418
+ slotList(rest.find((arg) => !arg.startsWith("--")), rest.includes("--json"));
1419
+ process.exit(0);
1420
+ }
1421
+ if (command === "create") {
1422
+ create(args[1], args[2], args[3]);
1423
+ process.exit(0);
1424
+ }
1425
+ if (command === "eject") {
1426
+ // eject <Name> [--force] [--slot <area.name>] [--yes]: --slot / --yes create a missing slot without asking
1427
+ const slotAt = args.indexOf("--slot");
1428
+ const name = args.find((arg, index) => index > 0 && !arg.startsWith("-") && !(slotAt >= 0 && index === slotAt + 1));
1429
+ await eject(name, args.includes("--force"), { slot: slotAt >= 0 ? args[slotAt + 1] : undefined, yes: args.includes("--yes") || args.includes("-y") });
1430
+ process.exit(0);
1431
+ }
1432
+ if (command === "link-store") {
1433
+ if (!target) fail("usage: znode link-store <storeRepoDir> [projectDir]");
1434
+ linkStore(target, args[2]);
1435
+ process.exit(0);
1436
+ }
1437
+ if (!target) fail("usage: znode <build|pack|check|install|deploy|dev> <dir|zip> [storeDir], znode create <kind> <name> [dir], znode link-store <storeRepoDir>");
1438
+ if (command === "check") check(path.resolve(target));
1439
+ else if (command === "build") build(path.resolve(target));
1440
+ else if (command === "pack") pack(path.resolve(target));
1441
+ else if (command === "install") install(path.resolve(target), path.resolve(needStore(storeDir)));
1442
+ else if (command === "deploy") deploy(path.resolve(target), path.resolve(needStore(storeDir)));
1443
+ else if (command === "dev") dev(path.resolve(target), path.resolve(needStore(storeDir)));
1444
+ else fail(`unknown command "${command}"`);