edgepress 2026.10.12 → 2026.10.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/config.yml +171 -171
- package/content/assets/edgepress/brand/sources/export-manifest.json +181 -181
- package/content/assets/edgepress-markdown-guide.svg +21 -21
- package/content/guides/markdown-media.md +38 -0
- package/content/guides/product-demos.md +30 -30
- package/content/pages/deployment/en.json +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
- package/languages/base/en.json +98 -98
- package/languages/packs/zh-CN.json +98 -98
- package/package.json +90 -90
- package/src/assets.js +187 -186
- package/src/browser-audit.js +158 -158
- package/src/build.js +293 -291
- package/src/cli.js +364 -364
- package/src/content.js +233 -233
- package/src/data-saver.js +3 -3
- package/src/generators.js +414 -420
- package/src/i18n.js +86 -86
- package/src/markdown.js +195 -160
- package/src/page-check.js +1 -1
- package/src/plugin-api.js +77 -77
- package/src/report-visuals.js +642 -642
- package/src/revision-cards.js +30 -30
- package/src/rss.js +100 -0
- package/src/theme.js +4 -4
- package/src/video-metadata.js +36 -0
- package/static/edgepress/fonts/faces.css +2217 -2217
- package/static/edgepress/media-viewer.js +67 -10
- package/static/edgepress/project-demo.js +83 -83
- package/static/edgepress/search.js +84 -84
- package/tests/assets-runtime.test.mjs +26 -26
- package/tests/audit.test.mjs +142 -142
- package/tests/brand-regression.test.mjs +110 -110
- package/tests/document-package.test.mjs +2 -2
- package/tests/embed-syntax.test.mjs +68 -68
- package/tests/image-viewer.test.mjs +36 -36
- package/tests/markdown-media.test.mjs +119 -0
- package/tests/oembed-layout.test.mjs +6 -1
- package/tests/project-demo.test.mjs +73 -73
- package/tests/release-version.test.mjs +28 -0
- package/tests/rss.test.mjs +54 -0
- package/tests/video-metadata.test.mjs +28 -0
- package/tests/writing.test.mjs +83 -83
- package/themes/atelier/assets/style.css +340 -340
- package/themes/default/assets/style.css +371 -371
- package/themes/default/layouts/partials/head.html +1 -1
- package/themes/folio/assets/style.css +515 -515
- package/themes/folio/theme.json +5 -5
- package/themes/signal/assets/style.css +332 -332
- package/tools/README.md +10 -10
- package/tools/assets/generate-brand-assets.py +160 -160
- package/tools/assets/video-preview.mjs +15 -0
- package/tools/recordings/demo-workbench.mjs +53 -53
- package/tools/recordings/desktop-recording.mjs +70 -70
- package/tools/recordings/vm-desktop.mjs +59 -59
package/src/build.js
CHANGED
|
@@ -1,301 +1,303 @@
|
|
|
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, sortPostsNewest } from './content.js';
|
|
8
|
-
import { plainText } from './markdown.js';
|
|
9
|
-
import { createExtensions } from './plugin-api.js';
|
|
10
|
-
import { collectAssets, rewriteAssetLinks, writeAssets } from './assets.js';
|
|
11
|
-
import { loadLanguagePacks } from './i18n.js';
|
|
12
|
-
import { contentPermissions, extendConsentPolicy, injectConsentPolicy } from './consent-csp.js';
|
|
13
|
-
import { generateBuiltinRoutes } from './generators.js';
|
|
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, sortPostsNewest } from './content.js';
|
|
8
|
+
import { plainText, hasMarkdownVideo } from './markdown.js';
|
|
9
|
+
import { createExtensions } from './plugin-api.js';
|
|
10
|
+
import { collectAssets, rewriteAssetLinks, writeAssets } from './assets.js';
|
|
11
|
+
import { loadLanguagePacks } from './i18n.js';
|
|
12
|
+
import { contentPermissions, extendConsentPolicy, injectConsentPolicy } from './consent-csp.js';
|
|
13
|
+
import { generateBuiltinRoutes } from './generators.js';
|
|
14
14
|
import {reserveImageDimensions,collectImageDimensions} from './image-dimensions.js';
|
|
15
|
+
import {reserveVideoMetadata} from './video-metadata.js';
|
|
15
16
|
import { dataSaverHtml, configureDataSaver } from './data-saver.js';
|
|
16
|
-
|
|
17
|
-
const RENDER_CACHE_VERSION = 3;
|
|
18
|
-
const MARKDOWN_SECURITY_POLICY_VERSION = 5;
|
|
19
|
-
|
|
20
|
-
async function readCache(file) {
|
|
21
|
-
try {
|
|
22
|
-
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
23
|
-
return parsed.version === RENDER_CACHE_VERSION && parsed.rendered && typeof parsed.rendered === 'object'
|
|
24
|
-
? parsed.rendered
|
|
25
|
-
: {};
|
|
26
|
-
} catch (error) {
|
|
27
|
-
if (error.code === 'ENOENT' || error instanceof SyntaxError) return {};
|
|
28
|
-
throw error;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function markdownRuntimeVersion(root) {
|
|
33
|
-
const lockPath = resolve(root, 'package-lock.json');
|
|
17
|
+
|
|
18
|
+
const RENDER_CACHE_VERSION = 3;
|
|
19
|
+
const MARKDOWN_SECURITY_POLICY_VERSION = 5;
|
|
20
|
+
|
|
21
|
+
async function readCache(file) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
24
|
+
return parsed.version === RENDER_CACHE_VERSION && parsed.rendered && typeof parsed.rendered === 'object'
|
|
25
|
+
? parsed.rendered
|
|
26
|
+
: {};
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error.code === 'ENOENT' || error instanceof SyntaxError) return {};
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function markdownRuntimeVersion(root) {
|
|
34
|
+
const lockPath = resolve(root, 'package-lock.json');
|
|
34
35
|
const rendererSources=await Promise.all(['markdown.js','embed.js','document.js','oembed.js','plugin-api.js','i18n.js'].map(file=>readFile(new URL('./'+file,import.meta.url))));
|
|
35
|
-
const digest=createHash('sha256');for(const source of rendererSources)digest.update(source);
|
|
36
|
-
const implementation=digest.digest('hex');
|
|
37
|
-
try {
|
|
38
|
-
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
|
39
|
-
const marked = lock.packages?.['node_modules/marked']?.version;
|
|
40
|
-
const sanitizer = lock.packages?.['node_modules/sanitize-html']?.version;
|
|
41
|
-
if (typeof marked === 'string' && typeof sanitizer === 'string') return implementation + ':' + marked + ':' + sanitizer;
|
|
42
|
-
throw new Error('package-lock.json is missing the Markdown renderer or sanitizer version');
|
|
43
|
-
} catch (error) {
|
|
44
|
-
if (error.code !== 'ENOENT') throw error;
|
|
45
|
-
return implementation + ':unlocked';
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function routeSegments(routePath) {
|
|
50
|
-
if (typeof routePath !== 'string' || !routePath.trim()) throw new Error('Generated routes need a non-empty path');
|
|
51
|
-
const source = routePath.trim().replace(/\\/g, '/');
|
|
52
|
-
if (/^[A-Za-z]:/.test(source) || /[?#\0]/.test(source)) {
|
|
53
|
-
throw new Error('Invalid generated route path: ' + routePath);
|
|
54
|
-
}
|
|
55
|
-
const isDirectory = source.endsWith('/');
|
|
56
|
-
const clean = source.replace(/^\/+|\/+$/g, '');
|
|
57
|
-
if (!clean) return ['index.html'];
|
|
58
|
-
const parts = clean.split('/');
|
|
59
|
-
for (const part of parts) {
|
|
60
|
-
if (!part || part === '.' || part === '..' || part.includes('%') || part.includes(':') || /[\x00-\x1f\x7f]/.test(part)) {
|
|
61
|
-
throw new Error('Invalid generated route path: ' + routePath);
|
|
62
|
-
}
|
|
63
|
-
let decoded;
|
|
64
|
-
try { decoded = decodeURIComponent(part); }
|
|
65
|
-
catch { throw new Error('Invalid generated route path encoding: ' + routePath); }
|
|
66
|
-
if (decoded === '.' || decoded === '..' || /[\\/\x00-\x1f\x7f]/.test(decoded)) {
|
|
67
|
-
throw new Error('Invalid generated route path: ' + routePath);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return isDirectory ? [...parts, 'index.html'] : parts;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function routeFile(output, routePath) {
|
|
74
|
-
const parts = routeSegments(routePath);
|
|
75
|
-
const result = resolve(output, ...parts);
|
|
76
|
-
if (!result.startsWith(output + sep)) throw new Error('Generated route escapes output directory: ' + routePath);
|
|
77
|
-
return result;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function publishedByLocalDate(document, now) {
|
|
81
|
-
if (document.kind !== 'posts') return true;
|
|
82
|
-
const publishDay = Date.UTC(document.date.getUTCFullYear(), document.date.getUTCMonth(), document.date.getUTCDate());
|
|
83
|
-
const currentDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
|
|
84
|
-
return publishDay <= currentDay;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function validateRoutes(routes, output, assetBundle) {
|
|
88
|
-
if (!Array.isArray(routes)) throw new Error('routes:created filters must return an array');
|
|
89
|
-
const seen = new Set(['_headers']);
|
|
90
|
-
for (const asset of assetBundle.assets) seen.add(asset.path.toLowerCase());
|
|
91
|
-
for (const route of routes) {
|
|
92
|
-
if (!route || typeof route.body !== 'string' || typeof route.path !== 'string') {
|
|
93
|
-
throw new Error('Each route must contain string path and body properties');
|
|
94
|
-
}
|
|
95
|
-
if (route.contentType !== undefined && typeof route.contentType !== 'string') {
|
|
96
|
-
throw new Error('Route contentType must be a string when provided: ' + route.path);
|
|
97
|
-
}
|
|
98
|
-
const file = routeFile(output, route.path);
|
|
99
|
-
const relativePath = relative(output, file).split(sep).join('/').toLowerCase();
|
|
100
|
-
if (seen.has(relativePath)) throw new Error('Duplicate generated route: ' + route.path);
|
|
101
|
-
seen.add(relativePath);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function processIsAlive(pid) {
|
|
106
|
-
try { process.kill(pid, 0); return true; }
|
|
107
|
-
catch (error) { return error.code !== 'ESRCH'; }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export async function acquireBuildLock(directory) {
|
|
111
|
-
await mkdir(directory, { recursive: true });
|
|
112
|
-
const lockPath = resolve(directory, 'build.lock');
|
|
113
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
114
|
-
try {
|
|
115
|
-
const handle = await open(lockPath, 'wx');
|
|
116
|
-
try {
|
|
117
|
-
await handle.writeFile(JSON.stringify({ pid: process.pid, host: hostname(), startedAt: new Date().toISOString() }));
|
|
118
|
-
} catch (error) {
|
|
119
|
-
await handle.close();
|
|
120
|
-
await rm(lockPath, { force: true });
|
|
121
|
-
throw error;
|
|
122
|
-
}
|
|
123
|
-
return async () => {
|
|
124
|
-
await handle.close();
|
|
125
|
-
await rm(lockPath, { force: true });
|
|
126
|
-
};
|
|
127
|
-
} catch (error) {
|
|
128
|
-
if (error.code !== 'EEXIST') throw error;
|
|
129
|
-
let previous;
|
|
130
|
-
try { previous = JSON.parse(await readFile(lockPath, 'utf8')); }
|
|
131
|
-
catch {
|
|
132
|
-
let info;
|
|
133
|
-
try { info = await stat(lockPath); }
|
|
134
|
-
catch (statError) {
|
|
135
|
-
if (statError.code === 'ENOENT') continue;
|
|
136
|
-
throw statError;
|
|
137
|
-
}
|
|
138
|
-
if (Date.now() - info.mtimeMs < 10_000) throw new Error('Another build is already starting for this project');
|
|
139
|
-
throw new Error('Another build may be running; inspect and remove ' + lockPath + ' if it is stale');
|
|
140
|
-
}
|
|
141
|
-
if (previous.host !== hostname() || !Number.isInteger(previous.pid) || await processIsAlive(previous.pid)) {
|
|
142
|
-
throw new Error('Another build is already running for this project');
|
|
143
|
-
}
|
|
144
|
-
await rm(lockPath, { force: true });
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
throw new Error('Unable to acquire the project build lock');
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async function renameOutput(source, target) {
|
|
151
|
-
for(let attempt=0;;attempt++) {
|
|
152
|
-
try{return await rename(source,target);}
|
|
153
|
-
catch(error){
|
|
154
|
-
if(process.platform!=='win32'||!['EPERM','EBUSY'].includes(error.code)||attempt>=5)throw error;
|
|
155
|
-
await new Promise(resolve=>setTimeout(resolve,200*2**attempt));
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function commitOutput(stage, output) {
|
|
161
|
-
const backup = output + '.backup-' + randomUUID();
|
|
162
|
-
let movedPrevious = false;
|
|
163
|
-
try {
|
|
164
|
-
try {
|
|
165
|
-
const info = await lstat(output);
|
|
166
|
-
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
167
|
-
throw new Error('Build output must be a real directory: ' + output);
|
|
168
|
-
}
|
|
169
|
-
await renameOutput(output, backup);
|
|
170
|
-
movedPrevious = true;
|
|
171
|
-
} catch (error) {
|
|
172
|
-
if (error.code !== 'ENOENT') throw error;
|
|
173
|
-
}
|
|
174
|
-
await renameOutput(stage, output);
|
|
175
|
-
} catch (error) {
|
|
176
|
-
if (movedPrevious) {
|
|
177
|
-
try { await renameOutput(backup, output); }
|
|
178
|
-
catch (restoreError) {
|
|
179
|
-
throw new Error('Build commit failed and the previous output could not be restored from ' + backup + ': ' + restoreError.message, { cause: error });
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
throw error;
|
|
183
|
-
}
|
|
184
|
-
if (movedPrevious) {
|
|
185
|
-
try { await rm(backup, { recursive: true, force: true }); }
|
|
186
|
-
catch (error) { console.warn('Built the new site, but could not remove the previous output at ' + backup + ': ' + error.message); }
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async function writeRoutes(routes, stage, assetBundle, extensions, site, config) {
|
|
191
|
-
await mapLimit(routes, config.concurrency, async (route) => {
|
|
192
|
-
const file = routeFile(stage, route.path);
|
|
193
|
-
await mkdir(dirname(file), { recursive: true });
|
|
194
|
-
let body = await extensions.filter('route:body', route.body, { route, site, config });
|
|
195
|
-
if (typeof body !== 'string') throw new Error('route:body filters must return a string for ' + route.path);
|
|
196
|
-
body = rewriteAssetLinks(body, assetBundle.urlMap);
|
|
36
|
+
const digest=createHash('sha256');for(const source of rendererSources)digest.update(source);
|
|
37
|
+
const implementation=digest.digest('hex');
|
|
38
|
+
try {
|
|
39
|
+
const lock = JSON.parse(await readFile(lockPath, 'utf8'));
|
|
40
|
+
const marked = lock.packages?.['node_modules/marked']?.version;
|
|
41
|
+
const sanitizer = lock.packages?.['node_modules/sanitize-html']?.version;
|
|
42
|
+
if (typeof marked === 'string' && typeof sanitizer === 'string') return implementation + ':' + marked + ':' + sanitizer;
|
|
43
|
+
throw new Error('package-lock.json is missing the Markdown renderer or sanitizer version');
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error.code !== 'ENOENT') throw error;
|
|
46
|
+
return implementation + ':unlocked';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function routeSegments(routePath) {
|
|
51
|
+
if (typeof routePath !== 'string' || !routePath.trim()) throw new Error('Generated routes need a non-empty path');
|
|
52
|
+
const source = routePath.trim().replace(/\\/g, '/');
|
|
53
|
+
if (/^[A-Za-z]:/.test(source) || /[?#\0]/.test(source)) {
|
|
54
|
+
throw new Error('Invalid generated route path: ' + routePath);
|
|
55
|
+
}
|
|
56
|
+
const isDirectory = source.endsWith('/');
|
|
57
|
+
const clean = source.replace(/^\/+|\/+$/g, '');
|
|
58
|
+
if (!clean) return ['index.html'];
|
|
59
|
+
const parts = clean.split('/');
|
|
60
|
+
for (const part of parts) {
|
|
61
|
+
if (!part || part === '.' || part === '..' || part.includes('%') || part.includes(':') || /[\x00-\x1f\x7f]/.test(part)) {
|
|
62
|
+
throw new Error('Invalid generated route path: ' + routePath);
|
|
63
|
+
}
|
|
64
|
+
let decoded;
|
|
65
|
+
try { decoded = decodeURIComponent(part); }
|
|
66
|
+
catch { throw new Error('Invalid generated route path encoding: ' + routePath); }
|
|
67
|
+
if (decoded === '.' || decoded === '..' || /[\\/\x00-\x1f\x7f]/.test(decoded)) {
|
|
68
|
+
throw new Error('Invalid generated route path: ' + routePath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return isDirectory ? [...parts, 'index.html'] : parts;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function routeFile(output, routePath) {
|
|
75
|
+
const parts = routeSegments(routePath);
|
|
76
|
+
const result = resolve(output, ...parts);
|
|
77
|
+
if (!result.startsWith(output + sep)) throw new Error('Generated route escapes output directory: ' + routePath);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function publishedByLocalDate(document, now) {
|
|
82
|
+
if (document.kind !== 'posts') return true;
|
|
83
|
+
const publishDay = Date.UTC(document.date.getUTCFullYear(), document.date.getUTCMonth(), document.date.getUTCDate());
|
|
84
|
+
const currentDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
|
|
85
|
+
return publishDay <= currentDay;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateRoutes(routes, output, assetBundle) {
|
|
89
|
+
if (!Array.isArray(routes)) throw new Error('routes:created filters must return an array');
|
|
90
|
+
const seen = new Set(['_headers']);
|
|
91
|
+
for (const asset of assetBundle.assets) seen.add(asset.path.toLowerCase());
|
|
92
|
+
for (const route of routes) {
|
|
93
|
+
if (!route || typeof route.body !== 'string' || typeof route.path !== 'string') {
|
|
94
|
+
throw new Error('Each route must contain string path and body properties');
|
|
95
|
+
}
|
|
96
|
+
if (route.contentType !== undefined && typeof route.contentType !== 'string') {
|
|
97
|
+
throw new Error('Route contentType must be a string when provided: ' + route.path);
|
|
98
|
+
}
|
|
99
|
+
const file = routeFile(output, route.path);
|
|
100
|
+
const relativePath = relative(output, file).split(sep).join('/').toLowerCase();
|
|
101
|
+
if (seen.has(relativePath)) throw new Error('Duplicate generated route: ' + route.path);
|
|
102
|
+
seen.add(relativePath);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function processIsAlive(pid) {
|
|
107
|
+
try { process.kill(pid, 0); return true; }
|
|
108
|
+
catch (error) { return error.code !== 'ESRCH'; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function acquireBuildLock(directory) {
|
|
112
|
+
await mkdir(directory, { recursive: true });
|
|
113
|
+
const lockPath = resolve(directory, 'build.lock');
|
|
114
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
115
|
+
try {
|
|
116
|
+
const handle = await open(lockPath, 'wx');
|
|
117
|
+
try {
|
|
118
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, host: hostname(), startedAt: new Date().toISOString() }));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
await handle.close();
|
|
121
|
+
await rm(lockPath, { force: true });
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
return async () => {
|
|
125
|
+
await handle.close();
|
|
126
|
+
await rm(lockPath, { force: true });
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error.code !== 'EEXIST') throw error;
|
|
130
|
+
let previous;
|
|
131
|
+
try { previous = JSON.parse(await readFile(lockPath, 'utf8')); }
|
|
132
|
+
catch {
|
|
133
|
+
let info;
|
|
134
|
+
try { info = await stat(lockPath); }
|
|
135
|
+
catch (statError) {
|
|
136
|
+
if (statError.code === 'ENOENT') continue;
|
|
137
|
+
throw statError;
|
|
138
|
+
}
|
|
139
|
+
if (Date.now() - info.mtimeMs < 10_000) throw new Error('Another build is already starting for this project');
|
|
140
|
+
throw new Error('Another build may be running; inspect and remove ' + lockPath + ' if it is stale');
|
|
141
|
+
}
|
|
142
|
+
if (previous.host !== hostname() || !Number.isInteger(previous.pid) || await processIsAlive(previous.pid)) {
|
|
143
|
+
throw new Error('Another build is already running for this project');
|
|
144
|
+
}
|
|
145
|
+
await rm(lockPath, { force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw new Error('Unable to acquire the project build lock');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function renameOutput(source, target) {
|
|
152
|
+
for(let attempt=0;;attempt++) {
|
|
153
|
+
try{return await rename(source,target);}
|
|
154
|
+
catch(error){
|
|
155
|
+
if(process.platform!=='win32'||!['EPERM','EBUSY'].includes(error.code)||attempt>=5)throw error;
|
|
156
|
+
await new Promise(resolve=>setTimeout(resolve,200*2**attempt));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function commitOutput(stage, output) {
|
|
162
|
+
const backup = output + '.backup-' + randomUUID();
|
|
163
|
+
let movedPrevious = false;
|
|
164
|
+
try {
|
|
165
|
+
try {
|
|
166
|
+
const info = await lstat(output);
|
|
167
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
168
|
+
throw new Error('Build output must be a real directory: ' + output);
|
|
169
|
+
}
|
|
170
|
+
await renameOutput(output, backup);
|
|
171
|
+
movedPrevious = true;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error.code !== 'ENOENT') throw error;
|
|
174
|
+
}
|
|
175
|
+
await renameOutput(stage, output);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (movedPrevious) {
|
|
178
|
+
try { await renameOutput(backup, output); }
|
|
179
|
+
catch (restoreError) {
|
|
180
|
+
throw new Error('Build commit failed and the previous output could not be restored from ' + backup + ': ' + restoreError.message, { cause: error });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
if (movedPrevious) {
|
|
186
|
+
try { await rm(backup, { recursive: true, force: true }); }
|
|
187
|
+
catch (error) { console.warn('Built the new site, but could not remove the previous output at ' + backup + ': ' + error.message); }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function writeRoutes(routes, stage, assetBundle, extensions, site, config) {
|
|
192
|
+
await mapLimit(routes, config.concurrency, async (route) => {
|
|
193
|
+
const file = routeFile(stage, route.path);
|
|
194
|
+
await mkdir(dirname(file), { recursive: true });
|
|
195
|
+
let body = await extensions.filter('route:body', route.body, { route, site, config });
|
|
196
|
+
if (typeof body !== 'string') throw new Error('route:body filters must return a string for ' + route.path);
|
|
197
|
+
body = rewriteAssetLinks(body, assetBundle.urlMap);
|
|
197
198
|
body = reserveImageDimensions(body, assetBundle.imageDimensions, route.path, site.config.site.url);
|
|
199
|
+
body = reserveVideoMetadata(body, assetBundle.videoMetadata, route.path, site.config.site.url);
|
|
198
200
|
body = dataSaverHtml(body, config, assetBundle.urlMap);
|
|
199
|
-
body = injectConsentPolicy(body, assetBundle.existingHeaders);
|
|
200
|
-
await writeFile(file, body, 'utf8');
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export async function buildSite(root = process.cwd(), options = {}) {
|
|
205
|
-
const started = Date.now();
|
|
201
|
+
body = injectConsentPolicy(body, assetBundle.existingHeaders);
|
|
202
|
+
await writeFile(file, body, 'utf8');
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function buildSite(root = process.cwd(), options = {}) {
|
|
207
|
+
const started = Date.now();
|
|
206
208
|
const config = await loadConfig(root);
|
|
207
209
|
await configureDataSaver(config);
|
|
208
|
-
if (options.preview === true) {
|
|
209
|
-
config.preview = true;
|
|
210
|
-
config.previewBuildId = randomUUID();
|
|
211
|
-
}
|
|
212
|
-
const releaseLock = await acquireBuildLock(config.resolvedPaths.cache);
|
|
213
|
-
const output = config.resolvedPaths.output;
|
|
214
|
-
const stage = resolve(dirname(output), '.' + output.split(/[\\/]/).pop() + '.stage-' + randomUUID());
|
|
215
|
-
|
|
216
|
-
try {
|
|
217
|
-
await loadLanguagePacks(config);
|
|
218
|
-
const extensions = await createExtensions(config);
|
|
210
|
+
if (options.preview === true) {
|
|
211
|
+
config.preview = true;
|
|
212
|
+
config.previewBuildId = randomUUID();
|
|
213
|
+
}
|
|
214
|
+
const releaseLock = await acquireBuildLock(config.resolvedPaths.cache);
|
|
215
|
+
const output = config.resolvedPaths.output;
|
|
216
|
+
const stage = resolve(dirname(output), '.' + output.split(/[\\/]/).pop() + '.stage-' + randomUUID());
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
await loadLanguagePacks(config);
|
|
220
|
+
const extensions = await createExtensions(config);
|
|
219
221
|
const sourceDocuments = await readDocuments(config);
|
|
220
|
-
const loadedDocuments = await extensions.filter('content:loaded', sourceDocuments, { config });
|
|
222
|
+
const loadedDocuments = await extensions.filter('content:loaded', sourceDocuments, { config });
|
|
221
223
|
if (!Array.isArray(loadedDocuments)) throw new Error('content:loaded filters must return an array');
|
|
222
|
-
const assetBundle = await collectAssets(config, { documentViewer: loadedDocuments.some(document => /!document\[|"type":"document"/.test(JSON.stringify(document))), mediaViewer: loadedDocuments.some(document => /"type":"media-viewer"/.test(JSON.stringify(document))) });
|
|
223
|
-
|
|
224
|
-
const cacheFile = resolve(config.resolvedPaths.cache, 'render-cache.json');
|
|
225
|
-
const oldCache = await readCache(cacheFile);
|
|
226
|
-
const nextCache = Object.create(null);
|
|
227
|
-
const runtimeVersion = await markdownRuntimeVersion(config.root);
|
|
228
|
-
const renderer = extensions.renderer('.md');
|
|
229
|
-
if (!renderer) throw new Error('No renderer is registered for Markdown files');
|
|
230
|
-
|
|
231
|
-
const documents = await mapLimit(loadedDocuments, config.concurrency, async (document) => {
|
|
232
|
-
if (document.kind === 'pages') {
|
|
233
|
-
return { ...document, html: '' };
|
|
234
|
-
}
|
|
235
|
-
const signature = createHash('sha256')
|
|
236
|
-
.update(document.markdown + '\n' + JSON.stringify(config.markdown) + '\n' + document.locale + '\n' +
|
|
224
|
+
const assetBundle = await collectAssets(config, { documentViewer: loadedDocuments.some(document => /!document\[|"type":"document"/.test(JSON.stringify(document))), mediaViewer: loadedDocuments.some(document => document.video || hasMarkdownVideo(document.markdown) || /"type":"media-viewer"/.test(JSON.stringify(document))) });
|
|
225
|
+
|
|
226
|
+
const cacheFile = resolve(config.resolvedPaths.cache, 'render-cache.json');
|
|
227
|
+
const oldCache = await readCache(cacheFile);
|
|
228
|
+
const nextCache = Object.create(null);
|
|
229
|
+
const runtimeVersion = await markdownRuntimeVersion(config.root);
|
|
230
|
+
const renderer = extensions.renderer('.md');
|
|
231
|
+
if (!renderer) throw new Error('No renderer is registered for Markdown files');
|
|
232
|
+
|
|
233
|
+
const documents = await mapLimit(loadedDocuments, config.concurrency, async (document) => {
|
|
234
|
+
if (document.kind === 'pages') {
|
|
235
|
+
return { ...document, html: '' };
|
|
236
|
+
}
|
|
237
|
+
const signature = createHash('sha256')
|
|
238
|
+
.update(document.markdown + '\n' + JSON.stringify(config.markdown) + '\n' + document.locale + '\n' +
|
|
237
239
|
MARKDOWN_SECURITY_POLICY_VERSION + '\n' + runtimeVersion + '\n' + JSON.stringify(config.browserPlugins) + '\n' + config.site.url)
|
|
238
|
-
.digest('hex');
|
|
239
|
-
let html = renderer.cacheable ? oldCache[signature] : undefined;
|
|
240
|
-
if (html === undefined) html = await renderer.render(document.markdown, { config, document });
|
|
241
|
-
if (typeof html !== 'string') throw new Error('Markdown renderers must return HTML strings for ' + document.file);
|
|
242
|
-
if (renderer.cacheable) nextCache[signature] = html;
|
|
243
|
-
const rendered = { ...document, html };
|
|
244
|
-
rendered.description = rendered.description || plainText(rendered.markdown).slice(0, 180);
|
|
245
|
-
return rendered;
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
const now = new Date();
|
|
249
|
-
const visible = documents.filter((document) => !document.draft && publishedByLocalDate(document, now));
|
|
250
|
-
const revisionAssets=new Map(visible.flatMap(document=>document.revisionAssets??[]).map(asset=>[asset.path,asset]));
|
|
251
|
-
assetBundle.assets.push(...revisionAssets.values());
|
|
252
|
-
for(const [path,size] of await collectImageDimensions([...revisionAssets.values()]))assetBundle.imageDimensions.set(path,size);
|
|
253
|
-
let site = {
|
|
254
|
-
config,
|
|
255
|
-
posts: sortPostsNewest(visible.filter((document) => document.kind === 'posts')),
|
|
256
|
-
pages: visible.filter((document) => document.kind === 'pages')
|
|
257
|
-
};
|
|
258
|
-
site = await extensions.filter('content:rendered', site, { config });
|
|
259
|
-
if (!site || !Array.isArray(site.posts) || !Array.isArray(site.pages)) {
|
|
260
|
-
throw new Error('content:rendered filters must return a site object with posts and pages arrays');
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
let routes = await generateBuiltinRoutes(site, config, extensions);
|
|
264
|
-
for (const generator of extensions.generators.values()) {
|
|
265
|
-
const generated = await generator({
|
|
266
|
-
site,
|
|
267
|
-
config,
|
|
268
|
-
renderLayout: (page, body) => import('./theme.js').then((module) => module.renderLayout(config, extensions, page, body))
|
|
269
|
-
});
|
|
270
|
-
if (generated == null) continue;
|
|
271
|
-
routes.push(...(Array.isArray(generated) ? generated : [generated]));
|
|
272
|
-
}
|
|
273
|
-
routes = await extensions.filter('routes:created', routes, { site, config });
|
|
274
|
-
validateRoutes(routes, output, assetBundle);
|
|
275
|
-
config.contentCsp = contentPermissions(routes);
|
|
276
|
-
assetBundle.existingHeaders = extendConsentPolicy(assetBundle.existingHeaders, config);
|
|
277
|
-
|
|
278
|
-
await mkdir(dirname(output), { recursive: true });
|
|
279
|
-
await mkdir(stage, { recursive: false });
|
|
280
|
-
await writeAssets(assetBundle, stage, config.concurrency);
|
|
281
|
-
await writeRoutes(routes, stage, assetBundle, extensions, site, config);
|
|
282
|
-
await commitOutput(stage, output);
|
|
283
|
-
|
|
284
|
-
const cacheTemporary = cacheFile + '.tmp-' + randomUUID();
|
|
285
|
-
try {
|
|
286
|
-
await writeFile(cacheTemporary, JSON.stringify({ version: RENDER_CACHE_VERSION, rendered: nextCache }), 'utf8');
|
|
287
|
-
await rename(cacheTemporary, cacheFile);
|
|
288
|
-
} catch (error) {
|
|
289
|
-
await rm(cacheTemporary, { force: true }).catch(() => {});
|
|
290
|
-
console.warn('Built the new site, but could not update the render cache: ' + error.message);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
console.log('Built ' + routes.length + ' routes from ' + documents.length + ' documents in ' + (Date.now() - started) + 'ms');
|
|
294
|
-
return { routes: routes.length, documents: documents.length, output };
|
|
295
|
-
} catch (error) {
|
|
296
|
-
await rm(stage, { recursive: true, force: true }).catch(() => {});
|
|
297
|
-
throw error;
|
|
298
|
-
} finally {
|
|
299
|
-
await releaseLock();
|
|
300
|
-
}
|
|
301
|
-
}
|
|
240
|
+
.digest('hex');
|
|
241
|
+
let html = renderer.cacheable ? oldCache[signature] : undefined;
|
|
242
|
+
if (html === undefined) html = await renderer.render(document.markdown, { config, document });
|
|
243
|
+
if (typeof html !== 'string') throw new Error('Markdown renderers must return HTML strings for ' + document.file);
|
|
244
|
+
if (renderer.cacheable) nextCache[signature] = html;
|
|
245
|
+
const rendered = { ...document, html };
|
|
246
|
+
rendered.description = rendered.description || plainText(rendered.markdown).slice(0, 180);
|
|
247
|
+
return rendered;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const now = new Date();
|
|
251
|
+
const visible = documents.filter((document) => !document.draft && publishedByLocalDate(document, now));
|
|
252
|
+
const revisionAssets=new Map(visible.flatMap(document=>document.revisionAssets??[]).map(asset=>[asset.path,asset]));
|
|
253
|
+
assetBundle.assets.push(...revisionAssets.values());
|
|
254
|
+
for(const [path,size] of await collectImageDimensions([...revisionAssets.values()]))assetBundle.imageDimensions.set(path,size);
|
|
255
|
+
let site = {
|
|
256
|
+
config,
|
|
257
|
+
posts: sortPostsNewest(visible.filter((document) => document.kind === 'posts')),
|
|
258
|
+
pages: visible.filter((document) => document.kind === 'pages')
|
|
259
|
+
};
|
|
260
|
+
site = await extensions.filter('content:rendered', site, { config });
|
|
261
|
+
if (!site || !Array.isArray(site.posts) || !Array.isArray(site.pages)) {
|
|
262
|
+
throw new Error('content:rendered filters must return a site object with posts and pages arrays');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let routes = await generateBuiltinRoutes(site, config, extensions);
|
|
266
|
+
for (const generator of extensions.generators.values()) {
|
|
267
|
+
const generated = await generator({
|
|
268
|
+
site,
|
|
269
|
+
config,
|
|
270
|
+
renderLayout: (page, body) => import('./theme.js').then((module) => module.renderLayout(config, extensions, page, body))
|
|
271
|
+
});
|
|
272
|
+
if (generated == null) continue;
|
|
273
|
+
routes.push(...(Array.isArray(generated) ? generated : [generated]));
|
|
274
|
+
}
|
|
275
|
+
routes = await extensions.filter('routes:created', routes, { site, config });
|
|
276
|
+
validateRoutes(routes, output, assetBundle);
|
|
277
|
+
config.contentCsp = contentPermissions(routes);
|
|
278
|
+
assetBundle.existingHeaders = extendConsentPolicy(assetBundle.existingHeaders, config);
|
|
279
|
+
|
|
280
|
+
await mkdir(dirname(output), { recursive: true });
|
|
281
|
+
await mkdir(stage, { recursive: false });
|
|
282
|
+
await writeAssets(assetBundle, stage, config.concurrency);
|
|
283
|
+
await writeRoutes(routes, stage, assetBundle, extensions, site, config);
|
|
284
|
+
await commitOutput(stage, output);
|
|
285
|
+
|
|
286
|
+
const cacheTemporary = cacheFile + '.tmp-' + randomUUID();
|
|
287
|
+
try {
|
|
288
|
+
await writeFile(cacheTemporary, JSON.stringify({ version: RENDER_CACHE_VERSION, rendered: nextCache }), 'utf8');
|
|
289
|
+
await rename(cacheTemporary, cacheFile);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
await rm(cacheTemporary, { force: true }).catch(() => {});
|
|
292
|
+
console.warn('Built the new site, but could not update the render cache: ' + error.message);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
console.log('Built ' + routes.length + ' routes from ' + documents.length + ' documents in ' + (Date.now() - started) + 'ms');
|
|
296
|
+
return { routes: routes.length, documents: documents.length, output };
|
|
297
|
+
} catch (error) {
|
|
298
|
+
await rm(stage, { recursive: true, force: true }).catch(() => {});
|
|
299
|
+
throw error;
|
|
300
|
+
} finally {
|
|
301
|
+
await releaseLock();
|
|
302
|
+
}
|
|
303
|
+
}
|