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.
Files changed (57) hide show
  1. package/README.md +12 -2
  2. package/config.yml +171 -171
  3. package/content/assets/edgepress/brand/sources/export-manifest.json +181 -181
  4. package/content/assets/edgepress-markdown-guide.svg +21 -21
  5. package/content/guides/markdown-media.md +38 -0
  6. package/content/guides/product-demos.md +30 -30
  7. package/content/pages/deployment/en.json +1 -1
  8. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
  9. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
  10. package/languages/base/en.json +98 -98
  11. package/languages/packs/zh-CN.json +98 -98
  12. package/package.json +90 -90
  13. package/src/assets.js +187 -186
  14. package/src/browser-audit.js +158 -158
  15. package/src/build.js +293 -291
  16. package/src/cli.js +364 -364
  17. package/src/content.js +233 -233
  18. package/src/data-saver.js +3 -3
  19. package/src/generators.js +414 -420
  20. package/src/i18n.js +86 -86
  21. package/src/markdown.js +195 -160
  22. package/src/page-check.js +1 -1
  23. package/src/plugin-api.js +77 -77
  24. package/src/report-visuals.js +642 -642
  25. package/src/revision-cards.js +30 -30
  26. package/src/rss.js +100 -0
  27. package/src/theme.js +4 -4
  28. package/src/video-metadata.js +36 -0
  29. package/static/edgepress/fonts/faces.css +2217 -2217
  30. package/static/edgepress/media-viewer.js +67 -10
  31. package/static/edgepress/project-demo.js +83 -83
  32. package/static/edgepress/search.js +84 -84
  33. package/tests/assets-runtime.test.mjs +26 -26
  34. package/tests/audit.test.mjs +142 -142
  35. package/tests/brand-regression.test.mjs +110 -110
  36. package/tests/document-package.test.mjs +2 -2
  37. package/tests/embed-syntax.test.mjs +68 -68
  38. package/tests/image-viewer.test.mjs +36 -36
  39. package/tests/markdown-media.test.mjs +119 -0
  40. package/tests/oembed-layout.test.mjs +6 -1
  41. package/tests/project-demo.test.mjs +73 -73
  42. package/tests/release-version.test.mjs +28 -0
  43. package/tests/rss.test.mjs +54 -0
  44. package/tests/video-metadata.test.mjs +28 -0
  45. package/tests/writing.test.mjs +83 -83
  46. package/themes/atelier/assets/style.css +340 -340
  47. package/themes/default/assets/style.css +371 -371
  48. package/themes/default/layouts/partials/head.html +1 -1
  49. package/themes/folio/assets/style.css +515 -515
  50. package/themes/folio/theme.json +5 -5
  51. package/themes/signal/assets/style.css +332 -332
  52. package/tools/README.md +10 -10
  53. package/tools/assets/generate-brand-assets.py +160 -160
  54. package/tools/assets/video-preview.mjs +15 -0
  55. package/tools/recordings/demo-workbench.mjs +53 -53
  56. package/tools/recordings/desktop-recording.mjs +70 -70
  57. 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
+ }