domma-cms 0.21.0 → 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/admin/css/admin.css +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/menu-editor.html +1 -0
- package/admin/js/views/menu-editor.js +13 -12
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/menu-decor.mjs +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +263 -246
- package/scripts/verify-assets.mjs +190 -0
- package/server/services/markdown.js +19 -3
- package/server/services/menus.js +102 -0
- package/server/services/renderer.js +23 -7
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
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
'plugins
|
|
43
|
-
'plugins
|
|
44
|
-
'plugins
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
'
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
+
});
|