edgepress 2026.9.3

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 (80) hide show
  1. package/README.md +70 -0
  2. package/config.yml +146 -0
  3. package/content/pages/home/en.md +84 -0
  4. package/content/pages/home/zh-cn.md +84 -0
  5. package/content/pages/plugin-development/en.md +108 -0
  6. package/content/pages/plugin-development/zh-cn.md +108 -0
  7. package/content/pages/privacy-policy/en.md +73 -0
  8. package/content/pages/privacy-policy/zh-cn.md +73 -0
  9. package/content/pages/project-introduction/en.md +88 -0
  10. package/content/pages/project-introduction/zh-cn.md +88 -0
  11. package/content/pages/quick-start/en.md +147 -0
  12. package/content/pages/quick-start/zh-cn.md +147 -0
  13. package/content/pages/theme-development/en.md +106 -0
  14. package/content/pages/theme-development/zh-cn.md +106 -0
  15. package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +169 -0
  16. package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +169 -0
  17. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +30 -0
  18. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +30 -0
  19. package/content/posts/2026-09-25-edgepress-release-2026-9-2/en.md +28 -0
  20. package/content/posts/2026-09-25-edgepress-release-2026-9-2/zh-cn.md +28 -0
  21. package/content/posts/2026-09-25-edgepress-release-2026-9-3/en.md +39 -0
  22. package/content/posts/2026-09-25-edgepress-release-2026-9-3/zh-cn.md +39 -0
  23. package/edgepress.config.mjs +18 -0
  24. package/languages/base/en.json +55 -0
  25. package/languages/packs/zh-CN.json +55 -0
  26. package/package.json +59 -0
  27. package/plugins/consent/index.js +40 -0
  28. package/project-compatibility.json +48 -0
  29. package/src/assets.js +181 -0
  30. package/src/build.js +277 -0
  31. package/src/cli.js +344 -0
  32. package/src/concurrency.js +26 -0
  33. package/src/config.js +396 -0
  34. package/src/content.js +160 -0
  35. package/src/generators.js +263 -0
  36. package/src/i18n.js +64 -0
  37. package/src/maintenance.js +122 -0
  38. package/src/markdown.js +65 -0
  39. package/src/page-blocks.js +350 -0
  40. package/src/page-check.js +416 -0
  41. package/src/plugin-api.js +75 -0
  42. package/src/preview.js +167 -0
  43. package/src/report-visuals.js +480 -0
  44. package/src/theme.js +161 -0
  45. package/src/worker.js +139 -0
  46. package/static/.gitkeep +0 -0
  47. package/static/edgepress/language-select.js +5 -0
  48. package/static/edgepress/plugins/advertising/google-adsense.js +26 -0
  49. package/static/edgepress/plugins/captcha/cloudflare-turnstile.js +28 -0
  50. package/static/edgepress/plugins/captcha/google-recaptcha.js +27 -0
  51. package/static/edgepress/plugins/captcha/hcaptcha.js +27 -0
  52. package/static/edgepress/plugins/consent/manager.js +180 -0
  53. package/static/edgepress/plugins/statistics/baidu-tongji.js +8 -0
  54. package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +9 -0
  55. package/static/edgepress/plugins/statistics/google-analytics.js +13 -0
  56. package/static/edgepress/plugins/tracking/google-tag-manager.js +10 -0
  57. package/static/edgepress/plugins/tracking/meta-pixel.js +18 -0
  58. package/static/edgepress/search.js +84 -0
  59. package/static/edgepress-markdown-guide.svg +21 -0
  60. package/templates/gitignore +6 -0
  61. package/templates/site-readme.md +20 -0
  62. package/themes/atelier/assets/style.css +120 -0
  63. package/themes/atelier/layouts/layout.html +17 -0
  64. package/themes/atelier/layouts/partials/footer.html +4 -0
  65. package/themes/atelier/layouts/partials/head.html +17 -0
  66. package/themes/atelier/layouts/partials/header.html +5 -0
  67. package/themes/atelier/theme.json +6 -0
  68. package/themes/default/assets/style.css +157 -0
  69. package/themes/default/layouts/layout.html +12 -0
  70. package/themes/default/layouts/partials/footer.html +4 -0
  71. package/themes/default/layouts/partials/head.html +19 -0
  72. package/themes/default/layouts/partials/header.html +5 -0
  73. package/themes/default/theme.json +6 -0
  74. package/themes/signal/assets/style.css +113 -0
  75. package/themes/signal/layouts/layout.html +12 -0
  76. package/themes/signal/layouts/partials/footer.html +4 -0
  77. package/themes/signal/layouts/partials/head.html +17 -0
  78. package/themes/signal/layouts/partials/header.html +5 -0
  79. package/themes/signal/theme.json +6 -0
  80. package/wrangler.jsonc +13 -0
package/src/build.js ADDED
@@ -0,0 +1,277 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, relative, resolve, sep } from 'node:path';
4
+ import { hostname } from 'node:os';
5
+ import { mapLimit } from './concurrency.js';
6
+ import { loadConfig } from './config.js';
7
+ import { readDocuments } from './content.js';
8
+ import { createExtensions } from './plugin-api.js';
9
+ import { collectAssets, rewriteAssetLinks, writeAssets } from './assets.js';
10
+ import { loadLanguagePacks } from './i18n.js';
11
+ import { generateBuiltinRoutes } from './generators.js';
12
+
13
+ const RENDER_CACHE_VERSION = 3;
14
+ const MARKDOWN_SECURITY_POLICY_VERSION = 3;
15
+
16
+ async function readCache(file) {
17
+ try {
18
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
19
+ return parsed.version === RENDER_CACHE_VERSION && parsed.rendered && typeof parsed.rendered === 'object'
20
+ ? parsed.rendered
21
+ : {};
22
+ } catch (error) {
23
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) return {};
24
+ throw error;
25
+ }
26
+ }
27
+
28
+ async function markdownRuntimeVersion(root) {
29
+ const lockPath = resolve(root, 'package-lock.json');
30
+ const rendererSource = await readFile(new URL('./markdown.js', import.meta.url));
31
+ const implementation = createHash('sha256').update(rendererSource).digest('hex');
32
+ try {
33
+ const lock = JSON.parse(await readFile(lockPath, 'utf8'));
34
+ const marked = lock.packages?.['node_modules/marked']?.version;
35
+ const sanitizer = lock.packages?.['node_modules/sanitize-html']?.version;
36
+ if (typeof marked === 'string' && typeof sanitizer === 'string') return implementation + ':' + marked + ':' + sanitizer;
37
+ throw new Error('package-lock.json is missing the Markdown renderer or sanitizer version');
38
+ } catch (error) {
39
+ if (error.code !== 'ENOENT') throw error;
40
+ return implementation + ':unlocked';
41
+ }
42
+ }
43
+
44
+ function routeSegments(routePath) {
45
+ if (typeof routePath !== 'string' || !routePath.trim()) throw new Error('Generated routes need a non-empty path');
46
+ const source = routePath.trim().replace(/\\/g, '/');
47
+ if (/^[A-Za-z]:/.test(source) || /[?#\0]/.test(source)) {
48
+ throw new Error('Invalid generated route path: ' + routePath);
49
+ }
50
+ const isDirectory = source.endsWith('/');
51
+ const clean = source.replace(/^\/+|\/+$/g, '');
52
+ if (!clean) return ['index.html'];
53
+ const parts = clean.split('/');
54
+ for (const part of parts) {
55
+ if (!part || part === '.' || part === '..' || part.includes('%') || part.includes(':') || /[\x00-\x1f\x7f]/.test(part)) {
56
+ throw new Error('Invalid generated route path: ' + routePath);
57
+ }
58
+ let decoded;
59
+ try { decoded = decodeURIComponent(part); }
60
+ catch { throw new Error('Invalid generated route path encoding: ' + routePath); }
61
+ if (decoded === '.' || decoded === '..' || /[\\/\x00-\x1f\x7f]/.test(decoded)) {
62
+ throw new Error('Invalid generated route path: ' + routePath);
63
+ }
64
+ }
65
+ return isDirectory ? [...parts, 'index.html'] : parts;
66
+ }
67
+
68
+ function routeFile(output, routePath) {
69
+ const parts = routeSegments(routePath);
70
+ const result = resolve(output, ...parts);
71
+ if (!result.startsWith(output + sep)) throw new Error('Generated route escapes output directory: ' + routePath);
72
+ return result;
73
+ }
74
+
75
+ function publishedByLocalDate(document, now) {
76
+ if (document.kind !== 'posts') return true;
77
+ const publishDay = Date.UTC(document.date.getUTCFullYear(), document.date.getUTCMonth(), document.date.getUTCDate());
78
+ const currentDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
79
+ return publishDay <= currentDay;
80
+ }
81
+
82
+ function validateRoutes(routes, output, assetBundle) {
83
+ if (!Array.isArray(routes)) throw new Error('routes:created filters must return an array');
84
+ const seen = new Set(['_headers']);
85
+ for (const asset of assetBundle.assets) seen.add(asset.path.toLowerCase());
86
+ for (const route of routes) {
87
+ if (!route || typeof route.body !== 'string' || typeof route.path !== 'string') {
88
+ throw new Error('Each route must contain string path and body properties');
89
+ }
90
+ if (route.contentType !== undefined && typeof route.contentType !== 'string') {
91
+ throw new Error('Route contentType must be a string when provided: ' + route.path);
92
+ }
93
+ const file = routeFile(output, route.path);
94
+ const relativePath = relative(output, file).split(sep).join('/').toLowerCase();
95
+ if (seen.has(relativePath)) throw new Error('Duplicate generated route: ' + route.path);
96
+ seen.add(relativePath);
97
+ }
98
+ }
99
+
100
+ async function processIsAlive(pid) {
101
+ try { process.kill(pid, 0); return true; }
102
+ catch (error) { return error.code !== 'ESRCH'; }
103
+ }
104
+
105
+ export async function acquireBuildLock(directory) {
106
+ await mkdir(directory, { recursive: true });
107
+ const lockPath = resolve(directory, 'build.lock');
108
+ for (let attempt = 0; attempt < 2; attempt += 1) {
109
+ try {
110
+ const handle = await open(lockPath, 'wx');
111
+ try {
112
+ await handle.writeFile(JSON.stringify({ pid: process.pid, host: hostname(), startedAt: new Date().toISOString() }));
113
+ } catch (error) {
114
+ await handle.close();
115
+ await rm(lockPath, { force: true });
116
+ throw error;
117
+ }
118
+ return async () => {
119
+ await handle.close();
120
+ await rm(lockPath, { force: true });
121
+ };
122
+ } catch (error) {
123
+ if (error.code !== 'EEXIST') throw error;
124
+ let previous;
125
+ try { previous = JSON.parse(await readFile(lockPath, 'utf8')); }
126
+ catch {
127
+ let info;
128
+ try { info = await stat(lockPath); }
129
+ catch (statError) {
130
+ if (statError.code === 'ENOENT') continue;
131
+ throw statError;
132
+ }
133
+ if (Date.now() - info.mtimeMs < 10_000) throw new Error('Another build is already starting for this project');
134
+ throw new Error('Another build may be running; inspect and remove ' + lockPath + ' if it is stale');
135
+ }
136
+ if (previous.host !== hostname() || !Number.isInteger(previous.pid) || await processIsAlive(previous.pid)) {
137
+ throw new Error('Another build is already running for this project');
138
+ }
139
+ await rm(lockPath, { force: true });
140
+ }
141
+ }
142
+ throw new Error('Unable to acquire the project build lock');
143
+ }
144
+
145
+ async function commitOutput(stage, output) {
146
+ const backup = output + '.backup-' + randomUUID();
147
+ let movedPrevious = false;
148
+ try {
149
+ try {
150
+ const info = await lstat(output);
151
+ if (info.isSymbolicLink() || !info.isDirectory()) {
152
+ throw new Error('Build output must be a real directory: ' + output);
153
+ }
154
+ await rename(output, backup);
155
+ movedPrevious = true;
156
+ } catch (error) {
157
+ if (error.code !== 'ENOENT') throw error;
158
+ }
159
+ await rename(stage, output);
160
+ } catch (error) {
161
+ if (movedPrevious) {
162
+ try { await rename(backup, output); }
163
+ catch (restoreError) {
164
+ throw new Error('Build commit failed and the previous output could not be restored from ' + backup + ': ' + restoreError.message, { cause: error });
165
+ }
166
+ }
167
+ throw error;
168
+ }
169
+ if (movedPrevious) {
170
+ try { await rm(backup, { recursive: true, force: true }); }
171
+ catch (error) { console.warn('Built the new site, but could not remove the previous output at ' + backup + ': ' + error.message); }
172
+ }
173
+ }
174
+
175
+ async function writeRoutes(routes, stage, assetBundle, extensions, site, config) {
176
+ await mapLimit(routes, config.concurrency, async (route) => {
177
+ const file = routeFile(stage, route.path);
178
+ await mkdir(dirname(file), { recursive: true });
179
+ let body = await extensions.filter('route:body', route.body, { route, site, config });
180
+ if (typeof body !== 'string') throw new Error('route:body filters must return a string for ' + route.path);
181
+ body = rewriteAssetLinks(body, assetBundle.urlMap);
182
+ await writeFile(file, body, 'utf8');
183
+ });
184
+ }
185
+
186
+ export async function buildSite(root = process.cwd(), options = {}) {
187
+ const started = Date.now();
188
+ const config = await loadConfig(root);
189
+ if (options.preview === true) {
190
+ config.preview = true;
191
+ config.previewBuildId = randomUUID();
192
+ }
193
+ const releaseLock = await acquireBuildLock(config.resolvedPaths.cache);
194
+ const output = config.resolvedPaths.output;
195
+ const stage = resolve(dirname(output), '.' + output.split(/[\\/]/).pop() + '.stage-' + randomUUID());
196
+
197
+ try {
198
+ await loadLanguagePacks(config);
199
+ const extensions = await createExtensions(config);
200
+ const assetBundle = await collectAssets(config);
201
+ const sourceDocuments = await readDocuments(config);
202
+ const loadedDocuments = await extensions.filter('content:loaded', sourceDocuments, { config });
203
+ if (!Array.isArray(loadedDocuments)) throw new Error('content:loaded filters must return an array');
204
+
205
+ const cacheFile = resolve(config.resolvedPaths.cache, 'render-cache.json');
206
+ const oldCache = await readCache(cacheFile);
207
+ const nextCache = Object.create(null);
208
+ const runtimeVersion = await markdownRuntimeVersion(config.root);
209
+ const renderer = extensions.renderer('.md');
210
+ if (!renderer) throw new Error('No renderer is registered for Markdown files');
211
+
212
+ const documents = await mapLimit(loadedDocuments, config.concurrency, async (document) => {
213
+ if (document.kind === 'pages') {
214
+ return { ...document, html: '' };
215
+ }
216
+ const signature = createHash('sha256')
217
+ .update(document.markdown + '\n' + JSON.stringify(config.markdown) + '\n' + document.locale + '\n' +
218
+ MARKDOWN_SECURITY_POLICY_VERSION + '\n' + runtimeVersion)
219
+ .digest('hex');
220
+ let html = renderer.cacheable ? oldCache[signature] : undefined;
221
+ if (html === undefined) html = await renderer.render(document.markdown, { config, document });
222
+ if (typeof html !== 'string') throw new Error('Markdown renderers must return HTML strings for ' + document.file);
223
+ if (renderer.cacheable) nextCache[signature] = html;
224
+ const rendered = { ...document, html };
225
+ rendered.description = rendered.description || rendered.markdown.replace(/\s+/g, ' ').slice(0, 180);
226
+ return rendered;
227
+ });
228
+
229
+ const now = new Date();
230
+ const visible = documents.filter((document) => !document.draft && publishedByLocalDate(document, now));
231
+ let site = {
232
+ config,
233
+ posts: visible.filter((document) => document.kind === 'posts').sort((a, b) => b.date - a.date),
234
+ pages: visible.filter((document) => document.kind === 'pages')
235
+ };
236
+ site = await extensions.filter('content:rendered', site, { config });
237
+ if (!site || !Array.isArray(site.posts) || !Array.isArray(site.pages)) {
238
+ throw new Error('content:rendered filters must return a site object with posts and pages arrays');
239
+ }
240
+
241
+ let routes = await generateBuiltinRoutes(site, config, extensions);
242
+ for (const generator of extensions.generators.values()) {
243
+ const generated = await generator({
244
+ site,
245
+ config,
246
+ renderLayout: (page, body) => import('./theme.js').then((module) => module.renderLayout(config, extensions, page, body))
247
+ });
248
+ if (generated == null) continue;
249
+ routes.push(...(Array.isArray(generated) ? generated : [generated]));
250
+ }
251
+ routes = await extensions.filter('routes:created', routes, { site, config });
252
+ validateRoutes(routes, output, assetBundle);
253
+
254
+ await mkdir(dirname(output), { recursive: true });
255
+ await mkdir(stage, { recursive: false });
256
+ await writeAssets(assetBundle, stage, config.concurrency);
257
+ await writeRoutes(routes, stage, assetBundle, extensions, site, config);
258
+ await commitOutput(stage, output);
259
+
260
+ const cacheTemporary = cacheFile + '.tmp-' + randomUUID();
261
+ try {
262
+ await writeFile(cacheTemporary, JSON.stringify({ version: RENDER_CACHE_VERSION, rendered: nextCache }), 'utf8');
263
+ await rename(cacheTemporary, cacheFile);
264
+ } catch (error) {
265
+ await rm(cacheTemporary, { force: true }).catch(() => {});
266
+ console.warn('Built the new site, but could not update the render cache: ' + error.message);
267
+ }
268
+
269
+ console.log('Built ' + routes.length + ' routes from ' + documents.length + ' documents in ' + (Date.now() - started) + 'ms');
270
+ return { routes: routes.length, documents: documents.length, output };
271
+ } catch (error) {
272
+ await rm(stage, { recursive: true, force: true }).catch(() => {});
273
+ throw error;
274
+ } finally {
275
+ await releaseLock();
276
+ }
277
+ }
package/src/cli.js ADDED
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ import { access, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { spawn } from 'node:child_process';
7
+ import { acquireBuildLock, buildSite } from './build.js';
8
+ import { loadConfig } from './config.js';
9
+ import { slugify } from './content.js';
10
+ import { checkPages } from './page-check.js';
11
+ import { checkCompatibility, writeIterationPlan } from './maintenance.js';
12
+
13
+ async function initializeProject() {
14
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
15
+ const root = process.cwd();
16
+ const directories = ['content', 'languages', 'plugins', 'static', 'themes'];
17
+ const files = ['.gitignore', 'README.md', 'config.yml', 'edgepress.config.mjs', 'project-compatibility.json', 'wrangler.jsonc'];
18
+ const destinations = [...directories, ...files, 'src/worker.js'];
19
+ const existing = [];
20
+ for (const relativePath of destinations) {
21
+ try { await access(resolve(root, relativePath)); existing.push(relativePath); }
22
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
23
+ }
24
+ if (existing.length) throw new Error('EdgePress init found existing project paths and made no changes: ' + existing.join(', '));
25
+
26
+ const packageData = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8'));
27
+ let existingManifest = {};
28
+ let packageManifestExists = false;
29
+ try {
30
+ const manifestPath = resolve(root, 'package.json');
31
+ const manifestInfo = await lstat(manifestPath);
32
+ if (manifestInfo.isSymbolicLink() || !manifestInfo.isFile()) throw new Error('Refusing to edit an unsafe package.json path');
33
+ packageManifestExists = true;
34
+ existingManifest = JSON.parse(await readFile(manifestPath, 'utf8'));
35
+ if (!existingManifest || Array.isArray(existingManifest) || typeof existingManifest !== 'object') throw new Error('package.json must contain a JSON object');
36
+ } catch (error) {
37
+ if (error.code !== 'ENOENT') throw error;
38
+ }
39
+ const projectManifest = {
40
+ ...existingManifest,
41
+ name: existingManifest.name || 'my-edgepress-site',
42
+ version: existingManifest.version || '1.0.0',
43
+ private: existingManifest.private ?? true,
44
+ type: 'module',
45
+ scripts: {
46
+ ...(existingManifest.scripts || {}),
47
+ new: 'edgepress new',
48
+ generate: 'edgepress generate',
49
+ build: 'edgepress build',
50
+ dev: 'edgepress server',
51
+ preview: 'edgepress server',
52
+ check: 'edgepress check',
53
+ doctor: 'edgepress doctor',
54
+ deploy: 'edgepress deploy'
55
+ },
56
+ dependencies: {
57
+ ...(existingManifest.dependencies || {}),
58
+ edgepress: packageData.version,
59
+ wrangler: existingManifest.dependencies?.wrangler ?? packageData.devDependencies?.wrangler ?? '^4.129.1'
60
+ }
61
+ };
62
+ for (const directory of directories) await cp(resolve(packageRoot, directory), resolve(root, directory), { recursive: true, errorOnExist: true });
63
+ for (const file of files) {
64
+ const source = file === '.gitignore' ? resolve(packageRoot, 'templates/gitignore') :
65
+ file === 'README.md' ? resolve(packageRoot, 'templates/site-readme.md') : resolve(packageRoot, file);
66
+ const destination = resolve(root, file);
67
+ await mkdir(dirname(destination), { recursive: true });
68
+ await cp(source, destination, { errorOnExist: true });
69
+ }
70
+ await mkdir(resolve(root, 'src'), { recursive: true });
71
+ await cp(resolve(packageRoot, 'src/worker.js'), resolve(root, 'src/worker.js'), { errorOnExist: true });
72
+ await writeFile(resolve(root, 'package.json'), JSON.stringify(projectManifest, null, 2) + '\n', { flag: packageManifestExists ? 'w' : 'wx' });
73
+ console.log('Created an EdgePress site. Next run npm install, then edgepress server.');
74
+ }
75
+
76
+ async function runWrangler(command, args = []) {
77
+ const wrangler = resolve(process.cwd(), 'node_modules/wrangler/bin/wrangler.js');
78
+ await access(wrangler);
79
+ await new Promise((resolveExit, reject) => {
80
+ const child = spawn(process.execPath, [wrangler, command, ...args], { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
81
+ child.once('error', reject);
82
+ child.once('exit', (code) => {
83
+ if (code === 0) resolveExit();
84
+ else process.exitCode = code ?? 1, resolveExit();
85
+ });
86
+ });
87
+ }
88
+
89
+ async function runSecurityAudit() {
90
+ await new Promise((resolveExit, reject) => {
91
+ const child = spawn('npm', ['audit', '--audit-level=moderate'], {
92
+ cwd: process.cwd(), env: process.env, stdio: 'inherit', shell: process.platform === 'win32', windowsHide: true
93
+ });
94
+ child.once('error', reject);
95
+ child.once('exit', (code) => {
96
+ if (code === 0) resolveExit();
97
+ else process.exitCode = code ?? 1, resolveExit();
98
+ });
99
+ });
100
+ }
101
+
102
+ async function manageTheme(args, config) {
103
+ const [action, requested] = args;
104
+ const themesDirectory = resolve(config.root, 'themes');
105
+ const entries = (await readdir(themesDirectory, { withFileTypes: true }))
106
+ .filter((entry) => entry.isDirectory()).sort((left, right) => left.name.localeCompare(right.name));
107
+ const installed = [];
108
+ for (const entry of entries) {
109
+ try {
110
+ const metadata = JSON.parse(await readFile(resolve(themesDirectory, entry.name, 'theme.json'), 'utf8'));
111
+ installed.push({ id: entry.name, name: metadata.name || entry.name, path: 'themes/' + entry.name });
112
+ } catch (error) {
113
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
114
+ }
115
+ }
116
+ if (action === 'list') {
117
+ for (const theme of installed) console.log((config.resolvedPaths.theme === resolve(config.root, theme.path) ? '* ' : ' ') + theme.id + ' (' + theme.name + ')');
118
+ return;
119
+ }
120
+ if (action === 'create') {
121
+ if (!requested) throw new Error('Usage: edgepress theme create <theme-name>');
122
+ const id = slugify(requested);
123
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error('Theme name must contain letters or numbers and may use spaces or hyphens.');
124
+ const destination = resolve(themesDirectory, id);
125
+ try { await lstat(destination); throw new Error('Theme already exists: ' + id); }
126
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
127
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
128
+ await cp(resolve(packageRoot, 'themes/default'), destination, { recursive: true, errorOnExist: true });
129
+ const metadataPath = resolve(destination, 'theme.json');
130
+ const metadata = JSON.parse(await readFile(metadataPath, 'utf8'));
131
+ metadata.id = id;
132
+ metadata.name = requested.trim();
133
+ await writeFile(metadataPath, JSON.stringify(metadata, null, 2) + '\n', 'utf8');
134
+ const packageManifestPath = resolve(destination, 'package.json');
135
+ const packageManifest = {
136
+ name: 'edgepress-theme-' + id,
137
+ version: '1.0.0',
138
+ description: 'A custom theme for EdgePress.',
139
+ files: ['theme.json', 'layouts', 'assets', 'README.md'],
140
+ edgepress: { theme: id }
141
+ };
142
+ await writeFile(packageManifestPath, JSON.stringify(packageManifest, null, 2) + '\n', 'utf8');
143
+ const readmePath = resolve(destination, 'README.md');
144
+ const readme = '# ' + id + '\n\nAn EdgePress theme package. Edit the shared layout and partials under `layouts/` and add theme styles and assets under `assets/`.\n\n' +
145
+ 'To install this theme in another EdgePress site, publish the package to npm and run:\n\n' +
146
+ ' edgepress theme install edgepress-theme-' + id + '\n\n' +
147
+ 'Keep the skip link, one main landmark, labeled navigation, visible keyboard focus, and a page-level `h1` in the layout.\n';
148
+ await writeFile(readmePath, readme, 'utf8');
149
+ await writeFile(resolve(destination, 'assets/style.css'), '/* Add the colors, typography, spacing, and responsive rules for this theme here. */\n', 'utf8');
150
+ console.log('Created an empty theme at themes/' + id + '. Edit its HTML partials and assets, then run edgepress theme use ' + id + '.');
151
+ return;
152
+ }
153
+ if (action === 'install') {
154
+ if (!requested) throw new Error('Usage: edgepress theme install <npm-package>[@version]');
155
+ const packageSpecPattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[0-9][a-z0-9.+_-]*)?$/i;
156
+ if (!packageSpecPattern.test(requested)) throw new Error('Theme must be an npm package name with an optional version.');
157
+ const packageNameEnd = requested.lastIndexOf('@');
158
+ const packageName = packageNameEnd > requested.indexOf('/') ? requested.slice(0, packageNameEnd) : requested;
159
+ const tempDirectory = await mkdtemp(join(tmpdir(), 'edgepress-theme-'));
160
+ try {
161
+ await new Promise((resolveExit, reject) => {
162
+ const child = spawn('npm', ['install', '--prefix', tempDirectory, '--ignore-scripts', '--no-audit', '--no-fund', requested], {
163
+ cwd: config.root, env: process.env, stdio: 'inherit', shell: process.platform === 'win32', windowsHide: true
164
+ });
165
+ child.once('error', reject);
166
+ child.once('exit', (code) => code === 0 ? resolveExit() : reject(new Error('npm could not install theme package ' + requested)));
167
+ });
168
+ const packageDirectory = resolve(tempDirectory, 'node_modules', ...packageName.split('/'));
169
+ const packageInfo = await lstat(packageDirectory);
170
+ if (!packageInfo.isDirectory() || packageInfo.isSymbolicLink()) throw new Error('Installed theme package is not a regular directory.');
171
+ const themeRoot = await realpath(packageDirectory);
172
+ const readPackageFile = async (name) => {
173
+ const filePath = resolve(themeRoot, name);
174
+ const info = await lstat(filePath);
175
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('Theme package ' + name + ' must be a regular file.');
176
+ const realPath = await realpath(filePath);
177
+ const relativePath = relative(themeRoot, realPath);
178
+ if (relativePath === '..' || relativePath.startsWith('..' + sep) || relativePath.startsWith(sep)) {
179
+ throw new Error('Theme package ' + name + ' resolves outside the package directory.');
180
+ }
181
+ return readFile(realPath, 'utf8');
182
+ };
183
+ const metadata = JSON.parse(await readPackageFile('theme.json'));
184
+ const packageManifest = JSON.parse(await readPackageFile('package.json'));
185
+ const declaredId = metadata.id || packageManifest.edgepress?.theme || packageName.split('/').at(-1).replace(/^edgepress-theme-/, '');
186
+ const id = slugify(declaredId);
187
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error('Theme package must declare a safe id in theme.json or package.json#edgepress.theme.');
188
+ const layoutFile = resolve(themeRoot, 'layouts/layout.html');
189
+ const layoutInfo = await lstat(layoutFile);
190
+ if (!layoutInfo.isFile() || layoutInfo.isSymbolicLink()) throw new Error('Theme package layouts/layout.html must be a regular file.');
191
+ const layoutPath = await realpath(layoutFile);
192
+ const relLayout = relative(themeRoot, layoutPath);
193
+ if (relLayout === '..' || relLayout.startsWith('..' + sep) || relLayout.startsWith(sep)) {
194
+ throw new Error('Theme package layouts/layout.html must be a regular file inside the package.');
195
+ }
196
+ const destination = resolve(themesDirectory, id);
197
+ try { await lstat(destination); throw new Error('Theme already exists: ' + id); }
198
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
199
+ await cp(themeRoot, destination, { recursive: true, errorOnExist: true, dereference: false });
200
+ console.log('Installed ' + (metadata.name || packageName) + ' to themes/' + id + '. Select it with edgepress theme use ' + id + '.');
201
+ } finally {
202
+ await rm(tempDirectory, { recursive: true, force: true });
203
+ }
204
+ return;
205
+ }
206
+ if (action !== 'use' || !requested) throw new Error('Usage: edgepress theme list | edgepress theme create <name> | edgepress theme install <npm-package>[@version] | edgepress theme use <theme-name>');
207
+ const selected = installed.find((theme) => theme.id === requested);
208
+ if (!selected) throw new Error('Theme not found. Run edgepress theme list to see installed themes.');
209
+ const configFile = resolve(config.root, 'edgepress.config.mjs');
210
+ const info = await lstat(configFile);
211
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error('Refusing to edit an unsafe edgepress.config.mjs path');
212
+ const source = await readFile(configFile, 'utf8');
213
+ const pathsBlock = source.match(/(paths\s*:\s*\{)([^}]*)(\})/s);
214
+ if (!pathsBlock || !/^\s*theme\s*:/m.test(pathsBlock[2])) {
215
+ throw new Error('Could not safely find paths.theme in edgepress.config.mjs; set it manually.');
216
+ }
217
+ const updatedPaths = pathsBlock[2].replace(/(^\s*theme\s*:\s*)(["'])[^"']*\2/m, '$1"' + selected.path + '"');
218
+ const updated = source.replace(pathsBlock[0], pathsBlock[1] + updatedPaths + pathsBlock[3]);
219
+ await writeFile(configFile, updated, 'utf8');
220
+ console.log('Selected theme ' + selected.id + '.');
221
+ }
222
+
223
+ async function createPost(title, config) {
224
+ if (!title) throw new Error('Usage: edgepress new "Post title"');
225
+ const releaseLock = await acquireBuildLock(config.resolvedPaths.cache);
226
+ try {
227
+ const today = new Date().toISOString().slice(0, 10);
228
+ const folder = resolve(config.resolvedPaths.content, 'posts', today + '-' + slugify(title));
229
+ const locale = config.i18n.defaultLocale;
230
+ const file = resolve(folder, locale.toLowerCase() + '.md');
231
+ await mkdir(folder, { recursive: true });
232
+ const body = '---\ntitle: ' + JSON.stringify(title) + '\ndate: ' + today + '\nlang: ' + locale + '\ntags: []\n---\n\n# ' + title + '\n\nWrite your post here.\n';
233
+ let handle;
234
+ try { handle = await open(file, 'wx'); }
235
+ catch (error) {
236
+ if (error.code === 'EEXIST') throw new Error('Post already exists: ' + file);
237
+ throw error;
238
+ }
239
+ let writeError;
240
+ try { await handle.writeFile(body, 'utf8'); }
241
+ catch (error) { writeError = error; }
242
+ await handle.close();
243
+ if (writeError) {
244
+ await rm(file, { force: true });
245
+ throw writeError;
246
+ }
247
+ console.log('Created ' + file);
248
+ } finally {
249
+ await releaseLock();
250
+ }
251
+ }
252
+
253
+ async function clean(config) {
254
+ const cache = config.resolvedPaths.cache;
255
+ const releaseLock = await acquireBuildLock(cache);
256
+ try {
257
+ for (const target of [config.resolvedPaths.output]) {
258
+ try {
259
+ const info = await lstat(target);
260
+ if (info.isSymbolicLink()) throw new Error('Refusing to clean a symbolic-link output path: ' + target);
261
+ await rm(target, { recursive: true, force: true });
262
+ } catch (error) {
263
+ if (error.code !== 'ENOENT') throw error;
264
+ }
265
+ }
266
+ const toolsDirectory = resolve(config.root, 'tools');
267
+ try {
268
+ const info = await lstat(toolsDirectory);
269
+ if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('Refusing to clean reports through an unsafe tools path: ' + toolsDirectory);
270
+ for (const name of ['page-check.json', 'page-check.md', 'page-check.pdf', 'page-check-visual.html']) {
271
+ await rm(resolve(toolsDirectory, name), { force: true });
272
+ }
273
+ const screenshots = resolve(toolsDirectory, 'page-check-screenshots');
274
+ try {
275
+ const screenshotsInfo = await lstat(screenshots);
276
+ if (screenshotsInfo.isSymbolicLink() || !screenshotsInfo.isDirectory()) throw new Error('Refusing to clean an unsafe screenshot path: ' + screenshots);
277
+ await rm(screenshots, { recursive: true, force: true });
278
+ } catch (error) {
279
+ if (error.code !== 'ENOENT') throw error;
280
+ }
281
+ } catch (error) {
282
+ if (error.code !== 'ENOENT') throw error;
283
+ }
284
+ let entries = [];
285
+ try { entries = await readdir(cache); }
286
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
287
+ await Promise.all(entries.filter((entry) => entry !== 'build.lock')
288
+ .map((entry) => rm(resolve(cache, entry), { recursive: true, force: true })));
289
+ } finally {
290
+ await releaseLock();
291
+ }
292
+ console.log('Removed generated output and reports.');
293
+ }
294
+
295
+ async function showReport(title, report, reportPath) {
296
+ console.log(title + ': ' + report.status);
297
+ console.log('Report: ' + reportPath);
298
+ }
299
+
300
+ const [command, ...args] = process.argv.slice(2);
301
+ try {
302
+ const config = command === 'new' || command === 'clean' || command === 'check' || command === 'doctor' || command === 'iterate'
303
+ ? await loadConfig()
304
+ : null;
305
+ if (command === '--help' || command === '-h' || command === 'help') {
306
+ console.log('EdgePress commands: init, new, build, generate, server, check, doctor, iterate, theme, secret, security, deploy, clean');
307
+ }
308
+ else if (command === 'build' || command === 'generate') await buildSite();
309
+ else if (command === 'init') await initializeProject();
310
+ else if (command === 'preview' || command === 'dev' || command === 'server') await import('./preview.js');
311
+ else if (command === 'deploy') {
312
+ if (process.env.WORKERS_CI !== '1') await buildSite();
313
+ await runWrangler('deploy', args);
314
+ }
315
+ else if (command === 'secret') {
316
+ if (!['put', 'delete', 'list'].includes(args[0])) throw new Error('Usage: edgepress secret put|delete|list [name]');
317
+ await runWrangler('secret', args);
318
+ }
319
+ else if (command === 'theme') await manageTheme(args, await loadConfig());
320
+ else if (command === 'security') await runSecurityAudit();
321
+ else if (command === 'new') await createPost(args.join(' ').trim(), config);
322
+ else if (command === 'clean') await clean(config);
323
+ else if (command === 'check') {
324
+ await buildSite(config.root);
325
+ const pageReport = await checkPages(config);
326
+ const compatibilityReport = await checkCompatibility(config);
327
+ await showReport('Page check', pageReport, resolve(config.root, 'tools/page-check.md'));
328
+ await showReport('Compatibility check', compatibilityReport, resolve(config.resolvedPaths.cache, 'reports/compatibility.md'));
329
+ if (pageReport.errors || compatibilityReport.issues.some((issue) => issue.severity === 'error')) process.exitCode = 1;
330
+ } else if (command === 'doctor') {
331
+ const report = await checkCompatibility(config);
332
+ await showReport('Compatibility check', report, resolve(config.resolvedPaths.cache, 'reports/compatibility.md'));
333
+ if (report.issues.some((issue) => issue.severity === 'error')) process.exitCode = 1;
334
+ } else if (command === 'iterate') {
335
+ await buildSite(config.root);
336
+ const pageReport = await checkPages(config);
337
+ const compatibilityReport = await checkCompatibility(config);
338
+ const plan = await writeIterationPlan(config, pageReport, compatibilityReport);
339
+ await showReport('Safe iteration plan', plan, resolve(config.resolvedPaths.cache, 'reports/iteration-plan.md'));
340
+ } else throw new Error('Commands: init, build|generate, server, deploy, new, clean, check, doctor, iterate, theme list|create|install|use, security');
341
+ } catch (error) {
342
+ console.error(error?.stack || error);
343
+ process.exitCode = 1;
344
+ }
@@ -0,0 +1,26 @@
1
+ export async function mapLimit(items, limit, fn) {
2
+ if (!Number.isInteger(limit) || limit < 1) throw new TypeError('Concurrency limit must be a positive integer');
3
+ const results = new Array(items.length);
4
+ let next = 0;
5
+ let failure;
6
+ let hasFailure = false;
7
+
8
+ async function worker() {
9
+ while (!hasFailure) {
10
+ const index = next++;
11
+ if (index >= items.length) return;
12
+ try { results[index] = await fn(items[index], index); }
13
+ catch (error) {
14
+ if (!hasFailure) {
15
+ failure = error instanceof Error ? error : new Error(String(error));
16
+ hasFailure = true;
17
+ }
18
+ return;
19
+ }
20
+ }
21
+ }
22
+
23
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
24
+ if (hasFailure) throw failure;
25
+ return results;
26
+ }