domma-cms 0.22.1 → 0.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.22.1",
3
+ "version": "0.22.2",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -0,0 +1 @@
1
+ export const PRESET_COLOUR_VARS={primary:"--dm-primary",success:"--dm-success",danger:"--dm-danger",warning:"--dm-warning",info:"--dm-info",neutral:"--dm-text-muted"};const o=/^#[0-9a-fA-F]{6}$/;export function colourToCss(t){return typeof t!="string"?"":PRESET_COLOUR_VARS[t]?`var(${PRESET_COLOUR_VARS[t]})`:o.test(t)?t:""}export function makeBadgeEl(t){if(!t||t.text==null||t.text==="")return null;const n=document.createElement("span");n.className="dm-menu-badge",n.textContent=String(t.text);const r=t.variant?colourToCss(t.variant):"";return r&&(n.style.background=r,n.style.color="#fff"),n}export function applyColour(t,n){const r=colourToCss(n);r&&(t.style.color=r)}export function applyPill(t,n){if(!n)return;t.classList.add("dm-nav-pill");const r=n.variant?colourToCss(n.variant):"";n.style==="outline"?(t.classList.add("dm-nav-pill--outline"),r&&(t.style.borderColor=r,t.style.color=r)):r&&(t.style.background=r,t.style.color="#fff")}
package/scripts/build.js CHANGED
@@ -1,246 +1,263 @@
1
- /**
2
- * Build script — creates a publishable staging copy in _publish/
3
- * Browser-facing JS/CSS is minified; server-side code is copied as-is.
4
- * Run: node scripts/build.js
5
- */
6
-
7
- import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'fs';
8
- import {readdir} from 'fs/promises';
9
- import {createHash} from 'crypto';
10
- import {dirname, join, relative} from 'path';
11
- import * as esbuild from 'esbuild';
12
-
13
- const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
14
- const OUT = join(ROOT, '_publish');
15
-
16
- // Files/dirs copied verbatim (server-side, config, docs)
17
- const COPY_AS_IS = [
18
- 'server',
19
- 'bin',
20
- 'config',
21
- 'scripts',
22
- 'package.json',
23
- 'public/js/package.json',
24
- 'README.md',
25
- 'CLAUDE.md',
26
- 'LICENSE',
27
- '.npmignore',
28
- ];
29
-
30
- // Glob patterns for browser-facing assets to minify
31
- const MINIFY_PATTERNS = [
32
- 'admin/js/**/*.js',
33
- 'admin/css/**/*.css',
34
- 'public/js/**/*.js',
35
- 'public/css/**/*.css',
36
- 'plugins/*/public/**/*.js',
37
- 'plugins/*/public/**/*.css',
38
- ];
39
-
40
- // Files within plugins that are copied as-is (server-side plugin code)
41
- const PLUGIN_AS_IS_PATTERNS = [
42
- 'plugins/**/plugin.js',
43
- 'plugins/**/config.js',
44
- 'plugins/**/routes/**/*.js',
45
- 'plugins/**/services/**/*.js',
46
- 'plugins/**/views/**/*.js',
47
- 'plugins/**/*.json',
48
- 'plugins/**/*.html',
49
- ];
50
-
51
- // HTML templates in admin (copied as-is)
52
- const ADMIN_HTML_PATTERNS = [
53
- 'admin/**/*.html',
54
- ];
55
-
56
- /**
57
- * Recursively list all files under a directory, returning paths relative to ROOT.
58
- * @param {string} dir - Absolute path to the directory to scan.
59
- * @returns {Promise<string[]>}
60
- */
61
- async function listFilesRecursive(dir) {
62
- const entries = await readdir(dir, {recursive: true, withFileTypes: true});
63
- return entries
64
- .filter(e => e.isFile())
65
- .map(e => relative(ROOT, join(e.parentPath ?? e.path, e.name)));
66
- }
67
-
68
- /**
69
- * Match files relative to ROOT against a simple glob pattern.
70
- * Supports `**` (any path segment), `*` (any non-separator chars), and `{a,b}` alternation.
71
- * @param {string} pattern
72
- * @returns {Promise<string[]>}
73
- */
74
- async function collectFiles(pattern) {
75
- // Expand top-level {a,b,...} alternation into individual patterns
76
- const braceMatch = pattern.match(/^\{([^}]+)\}\/(.+)$/);
77
- if (braceMatch) {
78
- const alts = braceMatch[1].split(',');
79
- const rest = braceMatch[2];
80
- const results = await Promise.all(alts.map(a => collectFiles(a + '/' + rest)));
81
- return [...new Set(results.flat())];
82
- }
83
-
84
- // Determine the top-level directory from the pattern (part before first wildcard)
85
- const parts = pattern.split('/');
86
- let baseDir = ROOT;
87
- for (const part of parts) {
88
- if (part.includes('*') || part.includes('{')) break;
89
- baseDir = join(baseDir, part);
90
- }
91
-
92
- if (!existsSync(baseDir)) return [];
93
-
94
- const allFiles = await listFilesRecursive(baseDir);
95
-
96
- // Convert glob pattern to a RegExp
97
- const reStr = pattern
98
- .replace(/[.+^${}()|[\]\\]/g, (c) => (c === '{' || c === '}' ? c : '\\' + c))
99
- .replace(/\{([^}]+)\}/g, (_, alts) => '(?:' + alts.split(',').join('|') + ')')
100
- .replace(/\*\*\//g, '(?:[^/]+/){0,}') // **/ → zero-or-more path segments
101
- .replace(/\*\*/g, '(.+)')
102
- .replace(/(?<!\()\*/g, '([^/]+)');
103
- const re = new RegExp('^' + reStr + '$');
104
-
105
- return allFiles.filter(f => re.test(f.replace(/\\/g, '/')));
106
- }
107
-
108
- async function minifyFile(relPath, loader) {
109
- const src = readFileSync(join(ROOT, relPath), 'utf8');
110
- const result = await esbuild.transform(src, {
111
- loader,
112
- minify: true,
113
- target: 'es2020',
114
- });
115
- const outPath = join(OUT, relPath);
116
- mkdirSync(dirname(outPath), {recursive: true});
117
- writeFileSync(outPath, result.code);
118
- }
119
-
120
- /**
121
- * Compute a short content hash for a file, used for cache-busting view entries.
122
- * Returns the first 8 hex chars of SHA-256, or null if the file can't be read.
123
- *
124
- * @param {string} filePath - Absolute path to the file
125
- * @returns {string|null}
126
- */
127
- function contentHash(filePath) {
128
- try {
129
- const content = readFileSync(filePath);
130
- return createHash('sha256').update(content).digest('hex').slice(0, 8);
131
- } catch {
132
- return null;
133
- }
134
- }
135
-
136
- /**
137
- * Rewrite admin.views entry paths in every plugin.json under _publish/plugins/
138
- * with a content-hash version (?v=<hash>), eliminating the need for manual ?v=N bumps.
139
- */
140
- async function stampPluginVersions() {
141
- const pluginJsonFiles = await collectFiles('plugins/*/plugin.json');
142
- for (const rel of pluginJsonFiles) {
143
- const outPath = join(OUT, rel);
144
- if (!existsSync(outPath)) continue;
145
-
146
- let manifest;
147
- try { manifest = JSON.parse(readFileSync(outPath, 'utf8')); } catch { continue; }
148
- if (!manifest.admin?.views) continue;
149
-
150
- let changed = false;
151
- for (const [viewName, viewDef] of Object.entries(manifest.admin.views)) {
152
- const entryBase = viewDef.entry.split('?')[0];
153
- const entryFilePath = join(OUT, 'plugins', entryBase);
154
- const hash = contentHash(entryFilePath);
155
- if (hash) {
156
- manifest.admin.views[viewName] = { ...viewDef, entry: `${entryBase}?v=${hash}` };
157
- changed = true;
158
- }
159
- }
160
-
161
- if (changed) writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n');
162
- }
163
- }
164
-
165
- async function build() {
166
- // Clean and recreate _publish/
167
- if (existsSync(OUT)) rmSync(OUT, {recursive: true, force: true});
168
- mkdirSync(OUT, {recursive: true});
169
-
170
- // Copy server-side dirs/files verbatim
171
- for (const item of COPY_AS_IS) {
172
- const src = join(ROOT, item);
173
- if (existsSync(src)) {
174
- const dest = join(OUT, item);
175
- mkdirSync(dirname(dest), {recursive: true});
176
- cpSync(src, dest, {recursive: true});
177
- }
178
- }
179
-
180
- // Strip prepublishOnly from _publish/package.json — the guard is for the
181
- // source tree only; running npm publish inside _publish/ must succeed.
182
- const pkgPath = join(OUT, 'package.json');
183
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
184
- delete pkg.scripts?.prepublishOnly;
185
- writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
186
-
187
- // Copy plugin server-side code verbatim
188
- const pluginAsIs = await collectFiles('{' + PLUGIN_AS_IS_PATTERNS.join(',') + '}');
189
- for (const rel of pluginAsIs) {
190
- const outPath = join(OUT, rel);
191
- mkdirSync(dirname(outPath), {recursive: true});
192
- cpSync(join(ROOT, rel), outPath);
193
- }
194
-
195
- // Copy admin HTML templates verbatim
196
- const adminHtml = await collectFiles(ADMIN_HTML_PATTERNS[0]);
197
- for (const rel of adminHtml) {
198
- const outPath = join(OUT, rel);
199
- mkdirSync(dirname(outPath), {recursive: true});
200
- cpSync(join(ROOT, rel), outPath);
201
- }
202
-
203
- // Copy Domma tools assets (gitignored but tracked for distribution)
204
- const dommaTools = [
205
- 'admin/dist/domma/domma-tools.css',
206
- 'admin/dist/domma/domma-tools.min.js',
207
- ];
208
- for (const rel of dommaTools) {
209
- const src = join(ROOT, rel);
210
- if (existsSync(src)) {
211
- const outPath = join(OUT, rel);
212
- mkdirSync(dirname(outPath), {recursive: true});
213
- cpSync(src, outPath);
214
- }
215
- }
216
-
217
- // Minify browser-facing JS and CSS
218
- let jsCount = 0;
219
- let cssCount = 0;
220
-
221
- // Collect all minify targets, deduplicate against plugin as-is
222
- const pluginAsIsSet = new Set(pluginAsIs);
223
-
224
- for (const pattern of MINIFY_PATTERNS) {
225
- const files = await collectFiles(pattern);
226
- for (const rel of files) {
227
- if (pluginAsIsSet.has(rel)) continue; // skip plugin server-side JS
228
- const loader = rel.endsWith('.css') ? 'css' : 'js';
229
- await minifyFile(rel, loader);
230
- if (loader === 'js') jsCount++;
231
- else cssCount++;
232
- }
233
- }
234
-
235
- const totalCopied = COPY_AS_IS.filter(i => existsSync(join(ROOT, i))).length
236
- + pluginAsIs.length + adminHtml.length;
237
-
238
- await stampPluginVersions();
239
-
240
- console.log(`Built into _publish/ — JS: ${jsCount} minified, CSS: ${cssCount} minified, ${totalCopied} entries copied`);
241
- }
242
-
243
- build().catch(err => {
244
- console.error('Build failed:', err.message);
245
- process.exit(1);
246
- });
1
+ /**
2
+ * Build script — creates a publishable staging copy in _publish/
3
+ * Browser-facing JS/CSS is minified; server-side code is copied as-is.
4
+ * Run: node scripts/build.js
5
+ */
6
+
7
+ import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'fs';
8
+ import {readdir} from 'fs/promises';
9
+ import {createHash} from 'crypto';
10
+ import {dirname, join, relative} from 'path';
11
+ import * as esbuild from 'esbuild';
12
+ import {verifyAssetImports, formatReport} from './verify-assets.mjs';
13
+
14
+ const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
15
+ const OUT = join(ROOT, '_publish');
16
+
17
+ // Files/dirs copied verbatim (server-side, config, docs)
18
+ const COPY_AS_IS = [
19
+ 'server',
20
+ 'bin',
21
+ 'config',
22
+ 'scripts',
23
+ 'package.json',
24
+ 'public/js/package.json',
25
+ 'README.md',
26
+ 'CLAUDE.md',
27
+ 'LICENSE',
28
+ '.npmignore',
29
+ ];
30
+
31
+ // Glob patterns for browser-facing assets to minify. NOTE: both .js AND .mjs
32
+ // must be listed — the browser ESM modules served raw (e.g. public/js/*.mjs)
33
+ // use the .mjs extension, and omitting it silently drops those files from the
34
+ // published package (this is the bug that shipped 0.22.1 without menu-decor.mjs).
35
+ const MINIFY_PATTERNS = [
36
+ 'admin/js/**/*.js',
37
+ 'admin/js/**/*.mjs',
38
+ 'admin/css/**/*.css',
39
+ 'public/js/**/*.js',
40
+ 'public/js/**/*.mjs',
41
+ 'public/css/**/*.css',
42
+ 'plugins/*/public/**/*.js',
43
+ 'plugins/*/public/**/*.mjs',
44
+ 'plugins/*/public/**/*.css',
45
+ ];
46
+
47
+ // Files within plugins that are copied as-is (server-side plugin code)
48
+ const PLUGIN_AS_IS_PATTERNS = [
49
+ 'plugins/**/plugin.js',
50
+ 'plugins/**/config.js',
51
+ 'plugins/**/routes/**/*.js',
52
+ 'plugins/**/services/**/*.js',
53
+ 'plugins/**/views/**/*.js',
54
+ 'plugins/**/*.json',
55
+ 'plugins/**/*.html',
56
+ ];
57
+
58
+ // HTML templates in admin (copied as-is)
59
+ const ADMIN_HTML_PATTERNS = [
60
+ 'admin/**/*.html',
61
+ ];
62
+
63
+ /**
64
+ * Recursively list all files under a directory, returning paths relative to ROOT.
65
+ * @param {string} dir - Absolute path to the directory to scan.
66
+ * @returns {Promise<string[]>}
67
+ */
68
+ async function listFilesRecursive(dir) {
69
+ const entries = await readdir(dir, {recursive: true, withFileTypes: true});
70
+ return entries
71
+ .filter(e => e.isFile())
72
+ .map(e => relative(ROOT, join(e.parentPath ?? e.path, e.name)));
73
+ }
74
+
75
+ /**
76
+ * Match files relative to ROOT against a simple glob pattern.
77
+ * Supports `**` (any path segment), `*` (any non-separator chars), and `{a,b}` alternation.
78
+ * @param {string} pattern
79
+ * @returns {Promise<string[]>}
80
+ */
81
+ async function collectFiles(pattern) {
82
+ // Expand top-level {a,b,...} alternation into individual patterns
83
+ const braceMatch = pattern.match(/^\{([^}]+)\}\/(.+)$/);
84
+ if (braceMatch) {
85
+ const alts = braceMatch[1].split(',');
86
+ const rest = braceMatch[2];
87
+ const results = await Promise.all(alts.map(a => collectFiles(a + '/' + rest)));
88
+ return [...new Set(results.flat())];
89
+ }
90
+
91
+ // Determine the top-level directory from the pattern (part before first wildcard)
92
+ const parts = pattern.split('/');
93
+ let baseDir = ROOT;
94
+ for (const part of parts) {
95
+ if (part.includes('*') || part.includes('{')) break;
96
+ baseDir = join(baseDir, part);
97
+ }
98
+
99
+ if (!existsSync(baseDir)) return [];
100
+
101
+ const allFiles = await listFilesRecursive(baseDir);
102
+
103
+ // Convert glob pattern to a RegExp
104
+ const reStr = pattern
105
+ .replace(/[.+^${}()|[\]\\]/g, (c) => (c === '{' || c === '}' ? c : '\\' + c))
106
+ .replace(/\{([^}]+)\}/g, (_, alts) => '(?:' + alts.split(',').join('|') + ')')
107
+ .replace(/\*\*\//g, '(?:[^/]+/){0,}') // **/ → zero-or-more path segments
108
+ .replace(/\*\*/g, '(.+)')
109
+ .replace(/(?<!\()\*/g, '([^/]+)');
110
+ const re = new RegExp('^' + reStr + '$');
111
+
112
+ return allFiles.filter(f => re.test(f.replace(/\\/g, '/')));
113
+ }
114
+
115
+ async function minifyFile(relPath, loader) {
116
+ const src = readFileSync(join(ROOT, relPath), 'utf8');
117
+ const result = await esbuild.transform(src, {
118
+ loader,
119
+ minify: true,
120
+ target: 'es2020',
121
+ });
122
+ const outPath = join(OUT, relPath);
123
+ mkdirSync(dirname(outPath), {recursive: true});
124
+ writeFileSync(outPath, result.code);
125
+ }
126
+
127
+ /**
128
+ * Compute a short content hash for a file, used for cache-busting view entries.
129
+ * Returns the first 8 hex chars of SHA-256, or null if the file can't be read.
130
+ *
131
+ * @param {string} filePath - Absolute path to the file
132
+ * @returns {string|null}
133
+ */
134
+ function contentHash(filePath) {
135
+ try {
136
+ const content = readFileSync(filePath);
137
+ return createHash('sha256').update(content).digest('hex').slice(0, 8);
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Rewrite admin.views entry paths in every plugin.json under _publish/plugins/
145
+ * with a content-hash version (?v=<hash>), eliminating the need for manual ?v=N bumps.
146
+ */
147
+ async function stampPluginVersions() {
148
+ const pluginJsonFiles = await collectFiles('plugins/*/plugin.json');
149
+ for (const rel of pluginJsonFiles) {
150
+ const outPath = join(OUT, rel);
151
+ if (!existsSync(outPath)) continue;
152
+
153
+ let manifest;
154
+ try { manifest = JSON.parse(readFileSync(outPath, 'utf8')); } catch { continue; }
155
+ if (!manifest.admin?.views) continue;
156
+
157
+ let changed = false;
158
+ for (const [viewName, viewDef] of Object.entries(manifest.admin.views)) {
159
+ const entryBase = viewDef.entry.split('?')[0];
160
+ const entryFilePath = join(OUT, 'plugins', entryBase);
161
+ const hash = contentHash(entryFilePath);
162
+ if (hash) {
163
+ manifest.admin.views[viewName] = { ...viewDef, entry: `${entryBase}?v=${hash}` };
164
+ changed = true;
165
+ }
166
+ }
167
+
168
+ if (changed) writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n');
169
+ }
170
+ }
171
+
172
+ async function build() {
173
+ // Clean and recreate _publish/
174
+ if (existsSync(OUT)) rmSync(OUT, {recursive: true, force: true});
175
+ mkdirSync(OUT, {recursive: true});
176
+
177
+ // Copy server-side dirs/files verbatim
178
+ for (const item of COPY_AS_IS) {
179
+ const src = join(ROOT, item);
180
+ if (existsSync(src)) {
181
+ const dest = join(OUT, item);
182
+ mkdirSync(dirname(dest), {recursive: true});
183
+ cpSync(src, dest, {recursive: true});
184
+ }
185
+ }
186
+
187
+ // Strip prepublishOnly from _publish/package.json — the guard is for the
188
+ // source tree only; running npm publish inside _publish/ must succeed.
189
+ const pkgPath = join(OUT, 'package.json');
190
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
191
+ delete pkg.scripts?.prepublishOnly;
192
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
193
+
194
+ // Copy plugin server-side code verbatim
195
+ const pluginAsIs = await collectFiles('{' + PLUGIN_AS_IS_PATTERNS.join(',') + '}');
196
+ for (const rel of pluginAsIs) {
197
+ const outPath = join(OUT, rel);
198
+ mkdirSync(dirname(outPath), {recursive: true});
199
+ cpSync(join(ROOT, rel), outPath);
200
+ }
201
+
202
+ // Copy admin HTML templates verbatim
203
+ const adminHtml = await collectFiles(ADMIN_HTML_PATTERNS[0]);
204
+ for (const rel of adminHtml) {
205
+ const outPath = join(OUT, rel);
206
+ mkdirSync(dirname(outPath), {recursive: true});
207
+ cpSync(join(ROOT, rel), outPath);
208
+ }
209
+
210
+ // Copy Domma tools assets (gitignored but tracked for distribution)
211
+ const dommaTools = [
212
+ 'admin/dist/domma/domma-tools.css',
213
+ 'admin/dist/domma/domma-tools.min.js',
214
+ ];
215
+ for (const rel of dommaTools) {
216
+ const src = join(ROOT, rel);
217
+ if (existsSync(src)) {
218
+ const outPath = join(OUT, rel);
219
+ mkdirSync(dirname(outPath), {recursive: true});
220
+ cpSync(src, outPath);
221
+ }
222
+ }
223
+
224
+ // Minify browser-facing JS and CSS
225
+ let jsCount = 0;
226
+ let cssCount = 0;
227
+
228
+ // Collect all minify targets, deduplicate against plugin as-is
229
+ const pluginAsIsSet = new Set(pluginAsIs);
230
+
231
+ for (const pattern of MINIFY_PATTERNS) {
232
+ const files = await collectFiles(pattern);
233
+ for (const rel of files) {
234
+ if (pluginAsIsSet.has(rel)) continue; // skip plugin server-side JS
235
+ const loader = rel.endsWith('.css') ? 'css' : 'js';
236
+ await minifyFile(rel, loader);
237
+ if (loader === 'js') jsCount++;
238
+ else cssCount++;
239
+ }
240
+ }
241
+
242
+ const totalCopied = COPY_AS_IS.filter(i => existsSync(join(ROOT, i))).length
243
+ + pluginAsIs.length + adminHtml.length;
244
+
245
+ await stampPluginVersions();
246
+
247
+ console.log(`Built into _publish/ — JS: ${jsCount} minified, CSS: ${cssCount} minified, ${totalCopied} entries copied`);
248
+
249
+ // Integrity gate — fail the build (and therefore `make pack` / `make
250
+ // release-npm`) if the staged set ships any ESM import pointing at a file
251
+ // that isn't there. This is what stops a "shipped code that imports a missing
252
+ // file" release (the 0.22.1 menu-decor.mjs incident) from ever publishing.
253
+ const integrity = await verifyAssetImports({projectRoot: OUT});
254
+ console.log(formatReport(integrity, '_publish/'));
255
+ if (!integrity.ok) {
256
+ throw new Error(`asset integrity check failed — ${integrity.missing.length} dangling import(s); refusing to package a broken release`);
257
+ }
258
+ }
259
+
260
+ build().catch(err => {
261
+ console.error('Build failed:', err.message);
262
+ process.exit(1);
263
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Asset Import Verifier — build/publish gate
3
+ *
4
+ * Statically resolves every local ESM import under the browser-facing code
5
+ * (admin/ + public/) against the files that actually exist, and fails if any
6
+ * specifier points at a missing file ("dangling import").
7
+ *
8
+ * Why this exists
9
+ * ---------------
10
+ * The admin SPA and public site ship UNMINIFIED-then-minified ESM that browsers
11
+ * import natively (no bundler). If a release ships consumer code that imports a
12
+ * new module but forgets to include the module file (as 0.22.1 did with
13
+ * /public/js/menu-decor.mjs), every page 404s on the import and the whole site
14
+ * breaks — with no signal but a browser console error, and it cascades to every
15
+ * downstream project that copies these dirs on update. A bundler would catch
16
+ * this at build time; because we don't bundle, this gate stands in for that.
17
+ *
18
+ * Usage
19
+ * -----
20
+ * node scripts/verify-assets.mjs [root] # default root: repo root
21
+ * Exits 1 (with a report) on any dangling import, 0 otherwise. Wired into
22
+ * scripts/build.js so `make pack` / `make release-npm` cannot ship a broken set.
23
+ *
24
+ * Scope (deliberately conservative — zero false positives is the whole point):
25
+ * only STATIC, LOCAL specifiers with an explicit extension are checked — the
26
+ * only kind this codebase emits. Bare (npm) specifiers, URLs, dynamic/template
27
+ * specifiers, commented-out imports, and extension-less paths are all ignored.
28
+ */
29
+
30
+ import path from 'node:path';
31
+ import fs from 'node:fs/promises';
32
+ import { existsSync } from 'node:fs';
33
+
34
+ const SCAN_EXTS = new Set(['.js', '.mjs']);
35
+ const CHECK_EXTS = new Set(['.js', '.mjs', '.css']);
36
+
37
+ const FROM_RE = /\bfrom\s*(['"])([^'"]+)\1/g;
38
+ const SIDE_EFFECT_RE = /(?:^|[;{}\s])import\s*(['"])([^'"]+)\1/g;
39
+ const DYNAMIC_RE = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
40
+
41
+ function isCheckableSpecifier(spec) {
42
+ if (typeof spec !== 'string' || !spec) return false;
43
+ if (!(spec.startsWith('/') || spec.startsWith('./') || spec.startsWith('../'))) return false;
44
+ const clean = spec.replace(/[?#].*$/, '');
45
+ return CHECK_EXTS.has(path.extname(clean).toLowerCase());
46
+ }
47
+
48
+ function resolveSpecifier(spec, importer, projectRoot) {
49
+ const clean = spec.replace(/[?#].*$/, '');
50
+ if (clean.startsWith('/')) return path.join(projectRoot, clean); // served /public/... → root/public/...
51
+ return path.resolve(path.dirname(importer), clean);
52
+ }
53
+
54
+ /**
55
+ * Strip // and /* *\/ comments while preserving string-literal bodies, so a
56
+ * commented-out import isn't flagged and a "https://…" URL isn't mistaken for a
57
+ * comment. Both are false positives, fatal for a publish gate.
58
+ */
59
+ function stripComments(source) {
60
+ let out = '';
61
+ let state = 'code';
62
+ for (let i = 0; i < source.length; i++) {
63
+ const c = source[i];
64
+ const n = source[i + 1];
65
+ switch (state) {
66
+ case 'code':
67
+ if (c === '/' && n === '/') { state = 'line'; i++; }
68
+ else if (c === '/' && n === '*') { state = 'block'; i++; }
69
+ else if (c === "'") { state = 'single'; out += c; }
70
+ else if (c === '"') { state = 'double'; out += c; }
71
+ else if (c === '`') { state = 'template'; out += c; }
72
+ else out += c;
73
+ break;
74
+ case 'line':
75
+ if (c === '\n') { state = 'code'; out += c; }
76
+ break;
77
+ case 'block':
78
+ if (c === '*' && n === '/') { state = 'code'; i++; }
79
+ break;
80
+ case 'single':
81
+ case 'double':
82
+ case 'template': {
83
+ out += c;
84
+ const quote = state === 'single' ? "'" : state === 'double' ? '"' : '`';
85
+ if (c === '\\') { out += source[i + 1] ?? ''; i++; }
86
+ else if (c === quote) state = 'code';
87
+ break;
88
+ }
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function extractSpecifiers(rawSource) {
95
+ const source = stripComments(rawSource);
96
+ const out = new Set();
97
+ for (const re of [FROM_RE, SIDE_EFFECT_RE, DYNAMIC_RE]) {
98
+ re.lastIndex = 0;
99
+ let m;
100
+ while ((m = re.exec(source)) !== null) {
101
+ if (isCheckableSpecifier(m[2])) out.add(m[2]);
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ async function listScannable(dir) {
108
+ const result = [];
109
+ let entries;
110
+ try {
111
+ entries = await fs.readdir(dir, { withFileTypes: true });
112
+ } catch {
113
+ return result;
114
+ }
115
+ for (const entry of entries) {
116
+ const full = path.join(dir, entry.name);
117
+ let isDir = entry.isDirectory();
118
+ let isFile = entry.isFile();
119
+ if (entry.isSymbolicLink()) {
120
+ try {
121
+ const st = await fs.stat(full);
122
+ isDir = st.isDirectory();
123
+ isFile = st.isFile();
124
+ } catch { continue; }
125
+ }
126
+ if (isDir) {
127
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
128
+ result.push(...await listScannable(full));
129
+ } else if (isFile && SCAN_EXTS.has(path.extname(entry.name).toLowerCase())) {
130
+ result.push(full);
131
+ }
132
+ }
133
+ return result;
134
+ }
135
+
136
+ export async function verifyAssetImports({ projectRoot, roots = ['admin', 'public'] }) {
137
+ const missing = [];
138
+ let scanned = 0;
139
+ for (const rel of roots) {
140
+ const rootAbs = path.join(projectRoot, rel);
141
+ if (!existsSync(rootAbs)) continue;
142
+ for (const file of await listScannable(rootAbs)) {
143
+ scanned++;
144
+ let source;
145
+ try { source = await fs.readFile(file, 'utf8'); } catch { continue; }
146
+ for (const spec of extractSpecifiers(source)) {
147
+ const resolved = resolveSpecifier(spec, file, projectRoot);
148
+ if (!existsSync(resolved)) {
149
+ missing.push({
150
+ importer: path.relative(projectRoot, file),
151
+ specifier: spec,
152
+ resolved: path.relative(projectRoot, resolved),
153
+ });
154
+ }
155
+ }
156
+ }
157
+ }
158
+ return { ok: missing.length === 0, scanned, missing };
159
+ }
160
+
161
+ export function formatReport(result, label) {
162
+ if (result.ok) {
163
+ return `[verify-assets] OK — ${result.scanned} files scanned, all local imports resolve (${label}).`;
164
+ }
165
+ const lines = [
166
+ '',
167
+ ' ╔════════════════════════════════════════════════════════════════╗',
168
+ ' ║ ASSET INTEGRITY FAILURE — dangling front-end imports detected ║',
169
+ ' ╚════════════════════════════════════════════════════════════════╝',
170
+ ` Context: ${label}`,
171
+ ` ${result.missing.length} import(s) reference files that do not exist:`,
172
+ '',
173
+ ];
174
+ for (const m of result.missing) {
175
+ lines.push(` ✗ ${m.importer}`);
176
+ lines.push(` imports "${m.specifier}" → missing ${m.resolved}`);
177
+ }
178
+ lines.push('');
179
+ lines.push(' A page that loads one of these modules will 404 and fail to render.');
180
+ lines.push(' Add the missing file (or remove the import) before building/publishing.');
181
+ lines.push('');
182
+ return lines.join('\n');
183
+ }
184
+
185
+ if (import.meta.url === `file://${process.argv[1]}`) {
186
+ const projectRoot = path.resolve(process.argv[2] || process.cwd());
187
+ const result = await verifyAssetImports({ projectRoot });
188
+ console.log(formatReport(result, projectRoot));
189
+ process.exit(result.ok ? 0 : 1);
190
+ }