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.
- package/README.md +70 -0
- package/config.yml +146 -0
- package/content/pages/home/en.md +84 -0
- package/content/pages/home/zh-cn.md +84 -0
- package/content/pages/plugin-development/en.md +108 -0
- package/content/pages/plugin-development/zh-cn.md +108 -0
- package/content/pages/privacy-policy/en.md +73 -0
- package/content/pages/privacy-policy/zh-cn.md +73 -0
- package/content/pages/project-introduction/en.md +88 -0
- package/content/pages/project-introduction/zh-cn.md +88 -0
- package/content/pages/quick-start/en.md +147 -0
- package/content/pages/quick-start/zh-cn.md +147 -0
- package/content/pages/theme-development/en.md +106 -0
- package/content/pages/theme-development/zh-cn.md +106 -0
- package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +169 -0
- package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +169 -0
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +30 -0
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +30 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-2/en.md +28 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-2/zh-cn.md +28 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-3/en.md +39 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-3/zh-cn.md +39 -0
- package/edgepress.config.mjs +18 -0
- package/languages/base/en.json +55 -0
- package/languages/packs/zh-CN.json +55 -0
- package/package.json +59 -0
- package/plugins/consent/index.js +40 -0
- package/project-compatibility.json +48 -0
- package/src/assets.js +181 -0
- package/src/build.js +277 -0
- package/src/cli.js +344 -0
- package/src/concurrency.js +26 -0
- package/src/config.js +396 -0
- package/src/content.js +160 -0
- package/src/generators.js +263 -0
- package/src/i18n.js +64 -0
- package/src/maintenance.js +122 -0
- package/src/markdown.js +65 -0
- package/src/page-blocks.js +350 -0
- package/src/page-check.js +416 -0
- package/src/plugin-api.js +75 -0
- package/src/preview.js +167 -0
- package/src/report-visuals.js +480 -0
- package/src/theme.js +161 -0
- package/src/worker.js +139 -0
- package/static/.gitkeep +0 -0
- package/static/edgepress/language-select.js +5 -0
- package/static/edgepress/plugins/advertising/google-adsense.js +26 -0
- package/static/edgepress/plugins/captcha/cloudflare-turnstile.js +28 -0
- package/static/edgepress/plugins/captcha/google-recaptcha.js +27 -0
- package/static/edgepress/plugins/captcha/hcaptcha.js +27 -0
- package/static/edgepress/plugins/consent/manager.js +180 -0
- package/static/edgepress/plugins/statistics/baidu-tongji.js +8 -0
- package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +9 -0
- package/static/edgepress/plugins/statistics/google-analytics.js +13 -0
- package/static/edgepress/plugins/tracking/google-tag-manager.js +10 -0
- package/static/edgepress/plugins/tracking/meta-pixel.js +18 -0
- package/static/edgepress/search.js +84 -0
- package/static/edgepress-markdown-guide.svg +21 -0
- package/templates/gitignore +6 -0
- package/templates/site-readme.md +20 -0
- package/themes/atelier/assets/style.css +120 -0
- package/themes/atelier/layouts/layout.html +17 -0
- package/themes/atelier/layouts/partials/footer.html +4 -0
- package/themes/atelier/layouts/partials/head.html +17 -0
- package/themes/atelier/layouts/partials/header.html +5 -0
- package/themes/atelier/theme.json +6 -0
- package/themes/default/assets/style.css +157 -0
- package/themes/default/layouts/layout.html +12 -0
- package/themes/default/layouts/partials/footer.html +4 -0
- package/themes/default/layouts/partials/head.html +19 -0
- package/themes/default/layouts/partials/header.html +5 -0
- package/themes/default/theme.json +6 -0
- package/themes/signal/assets/style.css +113 -0
- package/themes/signal/layouts/layout.html +12 -0
- package/themes/signal/layouts/partials/footer.html +4 -0
- package/themes/signal/layouts/partials/head.html +17 -0
- package/themes/signal/layouts/partials/header.html +5 -0
- package/themes/signal/theme.json +6 -0
- package/wrangler.jsonc +13 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { access, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { extname, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
function escapeHtml(value) {
|
|
9
|
+
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
|
10
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
11
|
+
})[char]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function inside(root, target) {
|
|
15
|
+
const rel = relative(root, target);
|
|
16
|
+
return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolutePath(rel);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isAbsolutePath(value) {
|
|
20
|
+
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith(sep) || value.startsWith('/');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function browserPath() {
|
|
24
|
+
const candidates = [];
|
|
25
|
+
if (process.env.EDGEPRESS_BROWSER) candidates.push(process.env.EDGEPRESS_BROWSER);
|
|
26
|
+
const programFiles = process.env['PROGRAMFILES'];
|
|
27
|
+
const programFilesX86 = process.env['PROGRAMFILES(X86)'];
|
|
28
|
+
const localAppData = process.env['LOCALAPPDATA'];
|
|
29
|
+
if (programFilesX86) candidates.push(resolve(programFilesX86, 'Microsoft/Edge/Application/msedge.exe'));
|
|
30
|
+
if (programFiles) {
|
|
31
|
+
candidates.push(resolve(programFiles, 'Microsoft/Edge/Application/msedge.exe'));
|
|
32
|
+
candidates.push(resolve(programFiles, 'Google/Chrome/Application/chrome.exe'));
|
|
33
|
+
}
|
|
34
|
+
if (localAppData) candidates.push(resolve(localAppData, 'Microsoft/Edge/Application/msedge.exe'));
|
|
35
|
+
candidates.push('/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable', '/usr/bin/chromium',
|
|
36
|
+
'/usr/bin/chromium-browser', '/usr/bin/google-chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
37
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
|
|
38
|
+
for (const candidate of candidates) {
|
|
39
|
+
if (!candidate) continue;
|
|
40
|
+
try { await access(candidate); return candidate; } catch { /* Try the next installed browser. */ }
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function freePort() {
|
|
46
|
+
const server = createServer();
|
|
47
|
+
await new Promise((resolveListen, reject) => {
|
|
48
|
+
server.once('error', reject);
|
|
49
|
+
server.listen(0, '127.0.0.1', resolveListen);
|
|
50
|
+
});
|
|
51
|
+
const port = server.address().port;
|
|
52
|
+
await new Promise((resolveClose, reject) => server.close((error) => error ? reject(error) : resolveClose()));
|
|
53
|
+
return port;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
57
|
+
let timer;
|
|
58
|
+
return Promise.race([
|
|
59
|
+
promise,
|
|
60
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); })
|
|
61
|
+
]).finally(() => clearTimeout(timer));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function startHeadlessBrowser(executable, profile) {
|
|
65
|
+
const port = await freePort();
|
|
66
|
+
const child = spawn(executable, [
|
|
67
|
+
'--headless=new', '--disable-gpu', '--no-sandbox', '--no-first-run', '--no-default-browser-check',
|
|
68
|
+
'--disable-background-networking', '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1',
|
|
69
|
+
'--remote-debugging-port=' + port, '--remote-allow-origins=*', '--user-data-dir=' + profile, 'about:blank'
|
|
70
|
+
], { stdio: 'ignore', windowsHide: true });
|
|
71
|
+
try {
|
|
72
|
+
let targets = [];
|
|
73
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
74
|
+
try {
|
|
75
|
+
targets = await (await fetch('http://127.0.0.1:' + port + '/json/list')).json();
|
|
76
|
+
if (targets.some((target) => target.type === 'page')) break;
|
|
77
|
+
} catch { /* Wait for the headless browser endpoint. */ }
|
|
78
|
+
if (child.exitCode !== null) throw new Error('Headless browser exited during startup');
|
|
79
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
80
|
+
}
|
|
81
|
+
const page = targets.find((target) => target.type === 'page');
|
|
82
|
+
if (!page) throw new Error('Headless browser did not expose a page target');
|
|
83
|
+
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
|
84
|
+
await new Promise((resolveOpen, rejectOpen) => {
|
|
85
|
+
socket.addEventListener('open', resolveOpen, { once: true });
|
|
86
|
+
socket.addEventListener('error', rejectOpen, { once: true });
|
|
87
|
+
});
|
|
88
|
+
let messageId = 0;
|
|
89
|
+
const pending = new Map();
|
|
90
|
+
const eventWaiters = new Map();
|
|
91
|
+
socket.addEventListener('message', (event) => {
|
|
92
|
+
const message = JSON.parse(event.data);
|
|
93
|
+
if (message.id && pending.has(message.id)) {
|
|
94
|
+
const callbacks = pending.get(message.id);
|
|
95
|
+
pending.delete(message.id);
|
|
96
|
+
if (message.error) callbacks.reject(new Error(message.error.message));
|
|
97
|
+
else callbacks.resolve(message.result);
|
|
98
|
+
}
|
|
99
|
+
if (message.method && eventWaiters.has(message.method)) {
|
|
100
|
+
const waiters = eventWaiters.get(message.method);
|
|
101
|
+
eventWaiters.delete(message.method);
|
|
102
|
+
for (const resolveEvent of waiters) resolveEvent(message.params);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
const send = (method, params = {}) => new Promise((resolveMessage, rejectMessage) => {
|
|
106
|
+
const id = ++messageId;
|
|
107
|
+
pending.set(id, { resolve: resolveMessage, reject: rejectMessage });
|
|
108
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
109
|
+
});
|
|
110
|
+
const waitFor = (method) => new Promise((resolveEvent) => {
|
|
111
|
+
const waiters = eventWaiters.get(method) || [];
|
|
112
|
+
waiters.push(resolveEvent);
|
|
113
|
+
eventWaiters.set(method, waiters);
|
|
114
|
+
});
|
|
115
|
+
await send('Page.enable');
|
|
116
|
+
await send('Runtime.enable');
|
|
117
|
+
const setViewport = async (viewport) => {
|
|
118
|
+
await send('Emulation.setDeviceMetricsOverride', {
|
|
119
|
+
width: viewport.width, height: viewport.height, deviceScaleFactor: 1, mobile: viewport.name === 'mobile',
|
|
120
|
+
screenWidth: viewport.width, screenHeight: viewport.height
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
const navigate = async (url) => {
|
|
124
|
+
const loaded = waitFor('Page.loadEventFired');
|
|
125
|
+
const result = await send('Page.navigate', { url });
|
|
126
|
+
if (result.errorText) throw new Error('Could not load screenshot page: ' + result.errorText);
|
|
127
|
+
await withTimeout(loaded, 20000, 'Screenshot page load timed out');
|
|
128
|
+
await send('Runtime.evaluate', { expression: 'new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 150)))', awaitPromise: true });
|
|
129
|
+
};
|
|
130
|
+
const screenshot = async () => {
|
|
131
|
+
const result = await send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false });
|
|
132
|
+
return Buffer.from(result.data, 'base64');
|
|
133
|
+
};
|
|
134
|
+
const metrics = async () => {
|
|
135
|
+
const result = await send('Runtime.evaluate', {
|
|
136
|
+
expression: 'JSON.stringify({viewportWidth:window.innerWidth,documentWidth:document.documentElement.scrollWidth})',
|
|
137
|
+
returnByValue: true
|
|
138
|
+
});
|
|
139
|
+
return JSON.parse(result.result.value);
|
|
140
|
+
};
|
|
141
|
+
const printToPdf = async () => {
|
|
142
|
+
let result;
|
|
143
|
+
try {
|
|
144
|
+
result = await send('Page.printToPDF', {
|
|
145
|
+
printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false,
|
|
146
|
+
generateTaggedPDF: true, generateDocumentOutline: true
|
|
147
|
+
});
|
|
148
|
+
} catch {
|
|
149
|
+
result = await send('Page.printToPDF', { printBackground: true, preferCSSPageSize: true, displayHeaderFooter: false });
|
|
150
|
+
}
|
|
151
|
+
return Buffer.from(result.data, 'base64');
|
|
152
|
+
};
|
|
153
|
+
const close = async () => {
|
|
154
|
+
try { socket.close(); } catch { /* Browser is shutting down. */ }
|
|
155
|
+
if (child.exitCode === null) {
|
|
156
|
+
child.kill();
|
|
157
|
+
await new Promise((resolveExit) => child.once('exit', resolveExit));
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
return { setViewport, navigate, screenshot, metrics, printToPdf, close };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (child.exitCode === null) {
|
|
163
|
+
child.kill();
|
|
164
|
+
await new Promise((resolveExit) => child.once('exit', resolveExit));
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const mimeTypes = {
|
|
171
|
+
'.html': 'text/html; charset=utf-8',
|
|
172
|
+
'.css': 'text/css; charset=utf-8',
|
|
173
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
174
|
+
'.json': 'application/json; charset=utf-8',
|
|
175
|
+
'.svg': 'image/svg+xml',
|
|
176
|
+
'.png': 'image/png',
|
|
177
|
+
'.jpg': 'image/jpeg',
|
|
178
|
+
'.jpeg': 'image/jpeg',
|
|
179
|
+
'.webp': 'image/webp',
|
|
180
|
+
'.ico': 'image/x-icon',
|
|
181
|
+
'.woff': 'font/woff',
|
|
182
|
+
'.woff2': 'font/woff2',
|
|
183
|
+
'.mp4': 'video/mp4',
|
|
184
|
+
'.vtt': 'text/vtt; charset=utf-8',
|
|
185
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
186
|
+
'.txt': 'text/plain; charset=utf-8'
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
function reportHtml(report, screenshots) {
|
|
190
|
+
const documents = report.documents || [];
|
|
191
|
+
const reportReading = report.visualEvidence?.reportAccessibility || { status: 'pending', checks: 0, passed: 0 };
|
|
192
|
+
const typeLabel = (type) => ({ post: 'Posts', page: 'Pages', homepage: 'Homepages', system: 'System routes' })[type] || type;
|
|
193
|
+
const statusTable = '<table><caption>Audit results</caption><thead><tr><th scope="col">Audit</th><th scope="col">Status</th>' +
|
|
194
|
+
'<th scope="col">Score</th><th scope="col">Checks</th><th scope="col">Errors</th><th scope="col">Warnings</th></tr></thead><tbody>' +
|
|
195
|
+
'<tr><th scope="row">Accessibility</th><td>' + escapeHtml(report.accessibility.status) + '</td><td>' + report.accessibility.score +
|
|
196
|
+
'%</td><td>' + report.accessibility.checks + '</td><td>' + report.accessibility.errors + '</td><td>' + report.accessibility.warnings + '</td></tr>' +
|
|
197
|
+
'<tr><th scope="row">Agent friendliness</th><td>' + escapeHtml(report.agentFriendliness.status) + '</td><td>' + report.agentFriendliness.score +
|
|
198
|
+
'%</td><td>' + report.agentFriendliness.checks + '</td><td>' + report.agentFriendliness.errors + '</td><td>' + report.agentFriendliness.warnings + '</td></tr>' +
|
|
199
|
+
'<tr><th scope="row">Markdown rendering</th><td>' + escapeHtml(report.markdownRendering?.status || 'not run') + '</td><td>—</td><td>' +
|
|
200
|
+
(report.markdownRendering?.checks || 0) + '</td><td>' + ((report.markdownRendering?.checks || 0) - (report.markdownRendering?.passed || 0)) + '</td><td>0</td></tr>' +
|
|
201
|
+
'<tr><th scope="row">Report reading structure</th><td>' + escapeHtml(reportReading.status) + '</td><td>' +
|
|
202
|
+
Math.floor(reportReading.passed / Math.max(1, reportReading.checks) * 100) + '%</td><td>' + reportReading.checks +
|
|
203
|
+
'</td><td>' + (reportReading.checks - reportReading.passed) + '</td><td>0</td></tr>' +
|
|
204
|
+
'</tbody></table>';
|
|
205
|
+
const summary = '<table><caption>Reviewed content</caption><thead><tr><th scope="col">Content type</th><th scope="col">Documents</th></tr></thead><tbody>' +
|
|
206
|
+
['post', 'page', 'homepage', 'system'].map((type) => '<tr><th scope="row">' + typeLabel(type) + '</th><td>' +
|
|
207
|
+
documents.filter((item) => item.type === type).length + '</td></tr>').join('') + '</tbody></table>';
|
|
208
|
+
const documentSections = ['post', 'page', 'homepage'].map((type) => {
|
|
209
|
+
const items = documents.filter((item) => item.type === type);
|
|
210
|
+
if (!items.length) return '';
|
|
211
|
+
return '<section><h3>' + typeLabel(type) + ' reviewed</h3><table><caption>' + typeLabel(type) + ' accessibility and agent-friendliness status</caption>' +
|
|
212
|
+
'<thead><tr><th scope="col">Title</th><th scope="col">Generated route</th><th scope="col">Status</th></tr></thead><tbody>' +
|
|
213
|
+
items.map((item) => '<tr><th scope="row">' + escapeHtml(item.title) + '</th><td><code>' + escapeHtml(item.path) + '</code></td><td>' +
|
|
214
|
+
escapeHtml(item.status) + '</td></tr>').join('') + '</tbody></table></section>';
|
|
215
|
+
}).join('');
|
|
216
|
+
const findings = report.issues.length
|
|
217
|
+
? '<ul>' + report.issues.map((item) => '<li><strong>' + escapeHtml(item.severity + ' · ' + item.category) +
|
|
218
|
+
'</strong> ' + escapeHtml(item.page) + ' — ' + escapeHtml(item.message) + '</li>').join('') + '</ul>'
|
|
219
|
+
: '<p>No findings.</p>';
|
|
220
|
+
const screenshotGroups = ['post', 'page', 'homepage', 'system'].map((type) => {
|
|
221
|
+
const items = screenshots.filter((item) => item.type === type);
|
|
222
|
+
if (!items.length) return '';
|
|
223
|
+
return '<section><h3>' + typeLabel(type) + ' screenshots</h3>' + items.map((item) => {
|
|
224
|
+
const dimensions = item.viewportWidth + ' CSS pixels wide; document width ' + item.documentWidth + ' CSS pixels';
|
|
225
|
+
const alt = 'Screenshot of ' + (item.title || item.page) + ' at the ' + item.viewport + ' viewport, ' +
|
|
226
|
+
item.width + ' by ' + item.height + ' pixels; ' + dimensions + '.';
|
|
227
|
+
return '<h4>' + escapeHtml(item.title || item.page) + ' · ' + escapeHtml(item.viewport) + '</h4><figure><img src="./page-check-screenshots/' +
|
|
228
|
+
escapeHtml(item.file) + '" alt="' + escapeHtml(alt) + '"><figcaption><strong>' + escapeHtml(item.title || item.page) +
|
|
229
|
+
'</strong><br><code>' + escapeHtml(item.page) + '</code> · ' + escapeHtml(item.viewport) + ' viewport · ' +
|
|
230
|
+
escapeHtml(dimensions) + '</figcaption></figure>';
|
|
231
|
+
}).join('') + '</section>';
|
|
232
|
+
}).join('');
|
|
233
|
+
const markdownDetails = (report.markdownRendering?.documents || []).map((document) => '<section><h3>' + escapeHtml(document.path) +
|
|
234
|
+
'</h4><ul>' + document.checks.map((item) => '<li>' + (item.passed ? 'Pass' : 'Fail') + ' — ' + escapeHtml(item.feature) + '</li>').join('') + '</ul></section>').join('');
|
|
235
|
+
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
236
|
+
'<title>EdgePress accessibility and content report</title><style>@page{size:A4;margin:15mm}*{box-sizing:border-box}html{scroll-behavior:smooth}body{font:12px/1.55 Arial,sans-serif;color:#202520;background:#fff;margin:0 auto;padding:16px;max-width:1100px}header,main,footer{display:block}h1{font-size:25px;line-height:1.2}h2{font-size:19px;margin:26px 0 8px}h3{font-size:15px;margin:20px 0 8px}h4{font-size:12px;margin:16px 0 6px}p{margin:6px 0}a{color:#174c35;text-decoration:underline;text-underline-offset:2px}a:focus-visible{outline:3px solid #9a481e;outline-offset:3px}.skip-link{position:absolute;left:-10000px;top:auto}.skip-link:focus{left:16px;top:16px;padding:8px;background:#fff;border:2px solid #174c35;z-index:2}.report-nav ul{display:flex;gap:18px;flex-wrap:wrap;padding-left:20px}table{width:100%;border-collapse:collapse;margin:10px 0 20px;table-layout:fixed}caption{text-align:left;font-weight:bold;padding:0 0 6px}th,td{padding:7px;border:1px solid #879188;text-align:left;vertical-align:top;overflow-wrap:anywhere}thead th{background:#e9eee9}tbody th{background:#f5f6f3}li{margin:4px 0;overflow-wrap:anywhere}code{overflow-wrap:anywhere}.status{font-weight:bold;text-transform:uppercase}.appendix{break-before:page}figure{margin:0 0 20px;break-inside:avoid}figure img{display:block;max-width:100%;max-height:220mm;width:auto;height:auto;margin:0 auto;border:1px solid #879188;object-fit:contain}figcaption{font-size:10px;color:#39443b;margin-top:5px}section{break-inside:auto}footer{border-top:1px solid #879188;margin-top:24px;padding-top:10px}@media(max-width:600px){body{padding:12px}.report-nav ul{display:block}table{font-size:11px}th,td{padding:5px}}@media print{body{max-width:none;padding:0}.report-nav{display:none}a{color:#202520}.appendix{break-before:page}h2,h3,h4{break-after:avoid}figure{break-inside:avoid}}</style></head>' +
|
|
237
|
+
'<body><a class="skip-link" href="#main">Skip to report</a><header><h1>EdgePress accessibility and content report</h1><p>Generated ' +
|
|
238
|
+
escapeHtml(report.generatedAt) + ' · Overall status: <span class="status">' + escapeHtml(report.status) + '</span>.</p>' +
|
|
239
|
+
'<nav class="report-nav" aria-label="Report sections"><ul><li><a href="#summary">Summary</a></li><li><a href="#content">Content reviewed</a></li>' +
|
|
240
|
+
'<li><a href="#findings">Findings</a></li><li><a href="#markdown">Markdown rendering</a></li><li><a href="#evidence">Screenshots</a></li><li><a href="#scope">Scope</a></li></ul></nav></header>' +
|
|
241
|
+
'<main id="main"><section id="summary"><h2>Audit summary</h2>' + statusTable + summary + '<p>Generated HTML documents: ' + report.pages +
|
|
242
|
+
' · HTML bytes: ' + report.metrics.htmlBytes + ' · Stylesheet references: ' + report.metrics.stylesheets +
|
|
243
|
+
' · Script references: ' + report.metrics.scripts + '.</p></section>' +
|
|
244
|
+
'<section id="content"><h2>Content reviewed</h2><p>Every generated post and customizable page is listed with its generated route and audit status. Posts are checked alongside pages.</p>' +
|
|
245
|
+
documentSections + '</section><section id="findings"><h2>Findings</h2>' + findings + '</section>' +
|
|
246
|
+
'<section id="markdown"><h2>Markdown rendering verification</h2><p>' + escapeHtml(String(report.markdownRendering?.passed || 0)) + ' of ' +
|
|
247
|
+
escapeHtml(String(report.markdownRendering?.checks || 0)) + ' syntax checks passed across the tutorial post locales.</p>' + markdownDetails + '</section>' +
|
|
248
|
+
'<section id="evidence" class="appendix"><h2>Responsive page screenshots</h2><p>Mobile screenshots use a 390 by 844 CSS pixel viewport. Each caption includes the measured document width so horizontal overflow is visible in the report.</p>' +
|
|
249
|
+
screenshotGroups + '</section><section id="scope"><h2>Scope and limits</h2><ul>' + report.scope.map((item) => '<li>' + escapeHtml(item) + '</li>').join('') +
|
|
250
|
+
'</ul></section></main><footer><p>EdgePress local build report. Use the HTML source for reflowable reading; screenshots are evidence and may contain text too small to read when printed.</p></footer></body></html>';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function checkReportReadingStructure(html) {
|
|
254
|
+
const checks = [];
|
|
255
|
+
const check = (name, passed) => checks.push({ name, passed });
|
|
256
|
+
check('document language', /<html\b[^>]*\blang="[^"]+"/i.test(html));
|
|
257
|
+
check('document title', /<title>[^<]+<\/title>/i.test(html));
|
|
258
|
+
check('skip link and main landmark', /class="skip-link"[^>]*href="#main"/i.test(html) && /<main\b[^>]*id="main"/i.test(html));
|
|
259
|
+
check('one report title', [...html.matchAll(/<h1\b/gi)].length === 1);
|
|
260
|
+
check('labeled report navigation', /<nav\b[^>]*aria-label="[^"]+"/i.test(html));
|
|
261
|
+
const headings = [...html.matchAll(/<h([1-6])\b/gi)].map((match) => Number(match[1]));
|
|
262
|
+
let previous = 0;
|
|
263
|
+
let skipped = false;
|
|
264
|
+
for (const level of headings) {
|
|
265
|
+
if (previous && level > previous + 1) skipped = true;
|
|
266
|
+
previous = level;
|
|
267
|
+
}
|
|
268
|
+
check('sequential heading levels', !skipped);
|
|
269
|
+
const images = [...html.matchAll(/<img\b([^>]*)>/gi)];
|
|
270
|
+
check('described screenshot images', images.length > 0 && images.every((item) => /\balt="[^"]+"/i.test(item[1])));
|
|
271
|
+
const links = [...html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)];
|
|
272
|
+
check('named report links', links.every((item) => /\baria-label="[^"]+"/i.test(item[1]) || item[2].replace(/<[^>]*>/g, '').trim()));
|
|
273
|
+
const tables = [...html.matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)];
|
|
274
|
+
check('captioned tables with scoped headers', tables.length > 0 && tables.every((item) =>
|
|
275
|
+
/<caption>[^<]+<\/caption>/i.test(item[1]) && /<thead\b/i.test(item[1]) &&
|
|
276
|
+
[...item[1].matchAll(/<th\b([^>]*)>/gi)].length > 0 && [...item[1].matchAll(/<th\b([^>]*)>/gi)]
|
|
277
|
+
.every((header) => /\bscope="(?:col|row)"/i.test(header[1]))));
|
|
278
|
+
const ids = [...html.matchAll(/\bid="([^"]+)"/gi)].map((match) => match[1]);
|
|
279
|
+
check('unique in-page section targets', new Set(ids).size === ids.length);
|
|
280
|
+
check('visible keyboard focus', /:focus-visible\s*\{[^}]*outline/i.test(html));
|
|
281
|
+
return { status: checks.every((item) => item.passed) ? 'pass' : 'fail', checks: checks.length,
|
|
282
|
+
passed: checks.filter((item) => item.passed).length, results: checks };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function startReportServer(output, evidenceDirectory, htmlPath) {
|
|
286
|
+
const outputRoot = await realpath(output);
|
|
287
|
+
const evidenceRoot = await realpath(evidenceDirectory);
|
|
288
|
+
const server = createServer(async (request, response) => {
|
|
289
|
+
try {
|
|
290
|
+
const pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname);
|
|
291
|
+
if (pathname === '/report.html') {
|
|
292
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
293
|
+
response.end(await readFile(htmlPath));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
let base;
|
|
297
|
+
let relativePath;
|
|
298
|
+
if (pathname.startsWith('/evidence/') || pathname.startsWith('/page-check-screenshots/')) {
|
|
299
|
+
base = evidenceRoot;
|
|
300
|
+
relativePath = pathname.startsWith('/evidence/')
|
|
301
|
+
? pathname.slice('/evidence/'.length)
|
|
302
|
+
: pathname.slice('/page-check-screenshots/'.length);
|
|
303
|
+
} else {
|
|
304
|
+
base = outputRoot;
|
|
305
|
+
relativePath = pathname.startsWith('/site/') ? pathname.slice('/site/'.length) : pathname.replace(/^\/+/, '');
|
|
306
|
+
}
|
|
307
|
+
let target = resolve(base, relativePath);
|
|
308
|
+
if (!inside(base, target)) {
|
|
309
|
+
response.writeHead(400);
|
|
310
|
+
response.end();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
let info;
|
|
314
|
+
try { info = await lstat(target); }
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (error.code !== 'ENOENT') throw error;
|
|
317
|
+
if (!extname(target)) {
|
|
318
|
+
const fileCandidate = target + '.html';
|
|
319
|
+
try { info = await lstat(fileCandidate); target = fileCandidate; }
|
|
320
|
+
catch { target = resolve(target, 'index.html'); info = await lstat(target); }
|
|
321
|
+
} else throw error;
|
|
322
|
+
}
|
|
323
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
324
|
+
response.writeHead(404);
|
|
325
|
+
response.end();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const realTarget = await realpath(target);
|
|
329
|
+
if (!inside(base, realTarget)) {
|
|
330
|
+
response.writeHead(404);
|
|
331
|
+
response.end();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
response.writeHead(200, { 'content-type': mimeTypes[extname(target).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
|
|
335
|
+
response.end(await readFile(realTarget));
|
|
336
|
+
} catch {
|
|
337
|
+
response.writeHead(404);
|
|
338
|
+
response.end();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
await new Promise((resolveListen, reject) => {
|
|
342
|
+
server.once('error', reject);
|
|
343
|
+
server.listen(0, '127.0.0.1', resolveListen);
|
|
344
|
+
});
|
|
345
|
+
return { server, port: server.address().port };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function siteAddress(relativeFile) {
|
|
349
|
+
return '/site/' + relativeFile.split('/').map(encodeURIComponent).join('/');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function addVisualEvidence(config, report) {
|
|
353
|
+
const root = resolve(config.root, 'tools');
|
|
354
|
+
const evidenceDirectory = resolve(root, 'page-check-screenshots');
|
|
355
|
+
const directoryInfo = await lstat(root);
|
|
356
|
+
if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory()) throw new Error('Report directory must be a real directory');
|
|
357
|
+
await mkdir(evidenceDirectory, { recursive: true });
|
|
358
|
+
const evidenceInfo = await lstat(evidenceDirectory);
|
|
359
|
+
if (evidenceInfo.isSymbolicLink() || !evidenceInfo.isDirectory()) throw new Error('Screenshot directory must be a real directory');
|
|
360
|
+
const browser = await browserPath();
|
|
361
|
+
if (!browser) {
|
|
362
|
+
return { status: 'unavailable', reason: 'Install Microsoft Edge, Chrome, or Chromium to capture page screenshots and PDF evidence.' };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const documents = new Map((report.documents || []).map((item) => [item.path, item]));
|
|
366
|
+
const candidates = new Set((report.documents || [])
|
|
367
|
+
.filter((item) => ['post', 'page', 'homepage'].includes(item.type))
|
|
368
|
+
.map((item) => item.path));
|
|
369
|
+
for (const locale of config.i18n.locales) {
|
|
370
|
+
const prefix = locale === config.i18n.defaultLocale ? '' : locale + '/';
|
|
371
|
+
candidates.add(prefix + 'index.html');
|
|
372
|
+
candidates.add(prefix + 'privacy-policy/index.html');
|
|
373
|
+
}
|
|
374
|
+
for (const issue of report.issues) {
|
|
375
|
+
if (issue.page && issue.page.toLowerCase().endsWith('.html')) candidates.add(issue.page);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const available = [];
|
|
379
|
+
for (const relativeFile of candidates) {
|
|
380
|
+
const target = resolve(config.resolvedPaths.output, relativeFile);
|
|
381
|
+
try {
|
|
382
|
+
const info = await lstat(target);
|
|
383
|
+
if (info.isFile() && !info.isSymbolicLink()) available.push(relativeFile);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (error.code !== 'ENOENT') throw error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const stamp = createHash('sha256').update(report.generatedAt).digest('hex').slice(0, 8);
|
|
390
|
+
const screenshots = [];
|
|
391
|
+
const desktop = { name: 'desktop', width: 1440, height: 1100 };
|
|
392
|
+
const mobile = { name: 'mobile', width: 390, height: 844 };
|
|
393
|
+
const homePaths = new Set(config.i18n.locales.map((locale) => locale === config.i18n.defaultLocale ? 'index.html' : locale + '/index.html'));
|
|
394
|
+
const privacyPaths = new Set(config.i18n.locales.map((locale) =>
|
|
395
|
+
(locale === config.i18n.defaultLocale ? '' : locale + '/') + 'privacy-policy/index.html'));
|
|
396
|
+
for (const relativeFile of available) {
|
|
397
|
+
const document = documents.get(relativeFile) || { type: 'system', title: relativeFile };
|
|
398
|
+
const viewports = homePaths.has(relativeFile) || privacyPaths.has(relativeFile) || document.type === 'post'
|
|
399
|
+
? [desktop, mobile] : [mobile];
|
|
400
|
+
for (const viewport of viewports) {
|
|
401
|
+
const digest = createHash('sha256').update(relativeFile + ':' + viewport.name).digest('hex').slice(0, 10);
|
|
402
|
+
const file = 'page-' + digest + '.png';
|
|
403
|
+
const target = resolve(evidenceDirectory, file);
|
|
404
|
+
try {
|
|
405
|
+
const existing = await lstat(target);
|
|
406
|
+
if (existing.isSymbolicLink() || !existing.isFile()) throw new Error('Refusing an unsafe screenshot path: ' + target);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (error.code !== 'ENOENT') throw error;
|
|
409
|
+
}
|
|
410
|
+
screenshots.push({ page: relativeFile, type: document.type, title: document.title, viewport: viewport.name, width: viewport.width,
|
|
411
|
+
height: viewport.height, file, target,
|
|
412
|
+
label: relativeFile + ' · ' + viewport.name + ' (' + viewport.width + '×' + viewport.height + ')' });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const htmlPath = resolve(root, 'page-check-visual.html');
|
|
416
|
+
const pdfPath = resolve(root, 'page-check.pdf');
|
|
417
|
+
const profile = await mkdtemp(resolve(tmpdir(), 'edgepress-report-' + process.pid + '-' + stamp + '-'));
|
|
418
|
+
let reportServer;
|
|
419
|
+
let browserSession;
|
|
420
|
+
try {
|
|
421
|
+
reportServer = await startReportServer(config.resolvedPaths.output, evidenceDirectory, htmlPath);
|
|
422
|
+
browserSession = await startHeadlessBrowser(browser, profile);
|
|
423
|
+
for (const screenshot of screenshots) {
|
|
424
|
+
const viewport = screenshot.viewport === 'mobile' ? mobile : desktop;
|
|
425
|
+
await browserSession.setViewport(viewport);
|
|
426
|
+
await browserSession.navigate('http://127.0.0.1:' + reportServer.port + siteAddress(screenshot.page));
|
|
427
|
+
const dimensions = await browserSession.metrics();
|
|
428
|
+
await writeFile(screenshot.target, await browserSession.screenshot());
|
|
429
|
+
const imageInfo = await lstat(screenshot.target);
|
|
430
|
+
if (!imageInfo.isFile() || imageInfo.size < 1000) throw new Error('Headless browser produced an empty screenshot');
|
|
431
|
+
screenshot.viewportWidth = dimensions.viewportWidth;
|
|
432
|
+
screenshot.documentWidth = dimensions.documentWidth;
|
|
433
|
+
if (viewport.name === 'mobile' && dimensions.viewportWidth !== viewport.width) {
|
|
434
|
+
report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
|
|
435
|
+
message: 'Mobile viewport measured ' + dimensions.viewportWidth + ' CSS pixels instead of ' + viewport.width + '.' });
|
|
436
|
+
} else if (viewport.name === 'mobile' && dimensions.documentWidth > dimensions.viewportWidth) {
|
|
437
|
+
report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
|
|
438
|
+
message: 'Mobile document width is ' + dimensions.documentWidth + ' CSS pixels for a ' + dimensions.viewportWidth + ' pixel viewport.' });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
report.errors = report.issues.filter((item) => item.severity === 'error').length;
|
|
442
|
+
report.warnings = report.issues.filter((item) => item.severity === 'warning').length;
|
|
443
|
+
report.status = report.errors ? 'fail' : report.warnings ? 'warning' : 'pass';
|
|
444
|
+
for (const document of report.documents || []) {
|
|
445
|
+
const findings = report.issues.filter((item) => item.page === document.path);
|
|
446
|
+
if (findings.some((item) => item.severity === 'error')) document.status = 'fail';
|
|
447
|
+
else if (findings.length) document.status = 'warning';
|
|
448
|
+
}
|
|
449
|
+
report.visualEvidence = { status: 'complete', pdf: 'tools/page-check.pdf', screenshots: screenshots.map((item) => ({
|
|
450
|
+
page: item.page, type: item.type, title: item.title, viewport: item.viewport, width: item.width, height: item.height,
|
|
451
|
+
viewportWidth: item.viewportWidth, documentWidth: item.documentWidth,
|
|
452
|
+
path: 'tools/page-check-screenshots/' + item.file, label: item.label
|
|
453
|
+
})) };
|
|
454
|
+
const initialHtml = reportHtml(report, screenshots);
|
|
455
|
+
const reportAccessibility = checkReportReadingStructure(initialHtml);
|
|
456
|
+
report.visualEvidence.reportAccessibility = reportAccessibility;
|
|
457
|
+
for (const check of reportAccessibility.results.filter((item) => !item.passed)) {
|
|
458
|
+
report.issues.push({ severity: 'error', category: 'report-accessibility', page: 'tools/page-check-visual.html',
|
|
459
|
+
message: 'Report reading requirement failed: ' + check.name + '.' });
|
|
460
|
+
}
|
|
461
|
+
report.errors = report.issues.filter((item) => item.severity === 'error').length;
|
|
462
|
+
report.warnings = report.issues.filter((item) => item.severity === 'warning').length;
|
|
463
|
+
report.status = report.errors ? 'fail' : report.warnings ? 'warning' : 'pass';
|
|
464
|
+
const finalHtml = reportHtml(report, screenshots);
|
|
465
|
+
const finalReading = checkReportReadingStructure(finalHtml);
|
|
466
|
+
report.visualEvidence.reportAccessibility = finalReading;
|
|
467
|
+
if (finalReading.status !== 'pass') throw new Error('The generated accessibility report failed its reading-order checks.');
|
|
468
|
+
await writeFile(htmlPath, finalHtml, 'utf8');
|
|
469
|
+
await browserSession.setViewport({ name: 'desktop', width: 1200, height: 900 });
|
|
470
|
+
await browserSession.navigate('http://127.0.0.1:' + reportServer.port + '/report.html');
|
|
471
|
+
const pdf = await browserSession.printToPdf();
|
|
472
|
+
if (pdf.length < 1000) throw new Error('Headless browser produced an empty PDF');
|
|
473
|
+
await writeFile(pdfPath, pdf);
|
|
474
|
+
return report.visualEvidence;
|
|
475
|
+
} finally {
|
|
476
|
+
if (browserSession) await browserSession.close();
|
|
477
|
+
if (reportServer) await new Promise((resolveClose) => reportServer.server.close(resolveClose));
|
|
478
|
+
await rm(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 250 });
|
|
479
|
+
}
|
|
480
|
+
}
|
package/src/theme.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { localePrefix, localizedUrl, translate } from './i18n.js';
|
|
4
|
+
|
|
5
|
+
function isInside(root, target) {
|
|
6
|
+
const rel = relative(root, target);
|
|
7
|
+
return rel !== '..' && !rel.startsWith('..' + sep) && !rel.startsWith(sep);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function escapeHtml(value) {
|
|
11
|
+
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
|
12
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
13
|
+
})[char]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function navigationItems(config, locale, items) {
|
|
17
|
+
const values = items.length ? items : [
|
|
18
|
+
{ key: 'home', url: '@home' },
|
|
19
|
+
{ key: 'archives', url: '@archives' },
|
|
20
|
+
{ key: 'search', url: '@search' },
|
|
21
|
+
{ key: 'feed', url: '@feed' }
|
|
22
|
+
];
|
|
23
|
+
return values.map((item) => {
|
|
24
|
+
const path = item.url === '@home' ? localizedUrl(config, locale, '') :
|
|
25
|
+
item.url === '@archives' ? localizedUrl(config, locale, 'archives/') :
|
|
26
|
+
item.url === '@feed' ? localizedUrl(config, locale, 'feed.xml') :
|
|
27
|
+
item.url === '@search' ? localizedUrl(config, locale, 'search/') :
|
|
28
|
+
item.url.startsWith('/') ? localizedUrl(config, locale, item.url) : item.url;
|
|
29
|
+
const label = item.labels?.[locale] ?? item.labels?.[config.i18n.defaultLocale] ?? translate(config, locale, item.key);
|
|
30
|
+
return '<a href="' + escapeHtml(path) + '">' + escapeHtml(label) + '</a>';
|
|
31
|
+
}).join('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function lookup(values, path) {
|
|
35
|
+
return path.split('.').reduce((value, key) => value?.[key], values);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function loadLayout(config) {
|
|
39
|
+
if (!config.layoutPromise) {
|
|
40
|
+
config.layoutPromise = (async () => {
|
|
41
|
+
const directory = resolve(config.resolvedPaths.theme, 'layouts');
|
|
42
|
+
const realDirectory = await realpath(directory).catch((error) => {
|
|
43
|
+
if (error.code === 'ENOENT') throw new Error('Selected theme must contain layouts/layout.html: ' + directory);
|
|
44
|
+
throw error;
|
|
45
|
+
});
|
|
46
|
+
if (!isInside(config.realPaths.theme, realDirectory)) throw new Error('Theme layouts must stay inside the selected theme directory');
|
|
47
|
+
const layoutPath = resolve(realDirectory, 'layout.html');
|
|
48
|
+
const realLayout = await realpath(layoutPath);
|
|
49
|
+
if (!isInside(realDirectory, realLayout)) throw new Error('Theme layout must stay inside its layouts directory');
|
|
50
|
+
const source = await readFile(realLayout, 'utf8');
|
|
51
|
+
const partialPattern = /{{>\s*([A-Za-z0-9_-]+)\s*}}/g;
|
|
52
|
+
const loaded = new Map();
|
|
53
|
+
async function expand(template, depth = 0) {
|
|
54
|
+
if (depth > 8) throw new Error('Theme partial nesting exceeds 8 levels');
|
|
55
|
+
const matches = [...template.matchAll(partialPattern)];
|
|
56
|
+
let result = template;
|
|
57
|
+
for (const match of matches) {
|
|
58
|
+
const name = match[1];
|
|
59
|
+
let partial = loaded.get(name);
|
|
60
|
+
if (partial === undefined) {
|
|
61
|
+
const path = resolve(realDirectory, 'partials', name + '.html');
|
|
62
|
+
const realPath = await realpath(path);
|
|
63
|
+
const partialRoot = await realpath(resolve(realDirectory, 'partials'));
|
|
64
|
+
if (!isInside(realDirectory, partialRoot)) throw new Error('Theme partial directory resolves outside its layouts directory');
|
|
65
|
+
if (!isInside(partialRoot, realPath)) throw new Error('Theme partial resolves outside its partials directory: ' + name);
|
|
66
|
+
partial = await readFile(realPath, 'utf8');
|
|
67
|
+
loaded.set(name, partial);
|
|
68
|
+
}
|
|
69
|
+
result = result.replace(match[0], await expand(partial, depth + 1));
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
const expanded = await expand(source);
|
|
74
|
+
if (/{{>/.test(expanded)) throw new Error('Invalid or unresolved theme partial directive');
|
|
75
|
+
return expanded;
|
|
76
|
+
})();
|
|
77
|
+
}
|
|
78
|
+
return config.layoutPromise;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function renderLayout(config, extensions, page, body) {
|
|
82
|
+
const locale = page.locale || config.i18n.defaultLocale;
|
|
83
|
+
const context = { config, page };
|
|
84
|
+
const content = await extensions.filter('html:beforeLayout', body, context);
|
|
85
|
+
if (typeof content !== 'string') throw new Error('html:beforeLayout filters must return a string');
|
|
86
|
+
const stylesheet = config.assetManifest?.['/style.css'] || '/style.css';
|
|
87
|
+
const canonical = config.site.url ? config.site.url + (page.urlPath || localizedUrl(config, locale, '')) : '';
|
|
88
|
+
const description = page.description || config.site.description;
|
|
89
|
+
const titleSuffix = config.site.head.titleSuffix || config.site.title;
|
|
90
|
+
const title = page.title ? page.title + config.site.head.titleSeparator + titleSuffix : titleSuffix;
|
|
91
|
+
const robots = page.robots || config.site.seo.robots || 'index,follow';
|
|
92
|
+
const keywords = (page.keywords || config.site.seo.keywords || []).join(', ');
|
|
93
|
+
const image = page.image || config.site.seo.image || '';
|
|
94
|
+
const currentPrefix = localePrefix(config, locale);
|
|
95
|
+
const currentPath = page.urlPath || localizedUrl(config, locale, '');
|
|
96
|
+
const localeRoute = currentPrefix && currentPath.startsWith(currentPrefix + '/')
|
|
97
|
+
? currentPath.slice(currentPrefix.length)
|
|
98
|
+
: currentPath;
|
|
99
|
+
const localeLinksHtml = '<select id="site-language" data-language-select aria-label="' +
|
|
100
|
+
escapeHtml(translate(config, locale, 'languageLabel')) + '">' + config.i18n.locales.map((item) => {
|
|
101
|
+
const path = (localePrefix(config, item) + (localeRoute.startsWith('/') ? localeRoute : '/' + localeRoute)) || '/';
|
|
102
|
+
return '<option lang="' + escapeHtml(item) + '" value="' + escapeHtml(path) + '"' +
|
|
103
|
+
(item === locale ? ' selected' : '') + '>' + escapeHtml(translate(config, item, 'languageName')) + '</option>';
|
|
104
|
+
}).join('') + '</select>';
|
|
105
|
+
const componentClass = (enabled, className) => enabled ? '' : className + '--hidden';
|
|
106
|
+
const jsonLd = page.structuredData === false ? '' : JSON.stringify(page.structuredData || {
|
|
107
|
+
'@context': 'https://schema.org',
|
|
108
|
+
'@type': 'WebSite',
|
|
109
|
+
name: config.site.title,
|
|
110
|
+
description: config.site.description,
|
|
111
|
+
url: canonical || config.site.url || undefined,
|
|
112
|
+
inLanguage: locale
|
|
113
|
+
}).replace(/</g, '\\u003c');
|
|
114
|
+
const values = {
|
|
115
|
+
site: {
|
|
116
|
+
...config.site,
|
|
117
|
+
language: locale,
|
|
118
|
+
headerClass: componentClass(config.site.components.header, 'site-header'),
|
|
119
|
+
brandClass: componentClass(config.site.header.showBrand, 'brand'),
|
|
120
|
+
headerBrand: config.site.header.brandLabel || config.site.title,
|
|
121
|
+
primaryNavigationClass: componentClass(config.site.components.primaryNavigation, 'primary-navigation'),
|
|
122
|
+
languageNavigationClass: componentClass(config.site.components.languageNavigation, 'locale-nav'),
|
|
123
|
+
footerClass: componentClass(config.site.components.footer, 'site-footer'),
|
|
124
|
+
footerCopyClass: componentClass(Boolean(config.site.footer), 'footer-copy'),
|
|
125
|
+
footerNavigationClass: componentClass(config.site.components.footerNavigation && config.site.footerNavigation.length, 'footer-navigation'),
|
|
126
|
+
primaryNavigationHtml: navigationItems(config, locale, config.site.navigation),
|
|
127
|
+
footerNavigationHtml: navigationItems(config, locale, config.site.footerNavigation),
|
|
128
|
+
localeLinksHtml,
|
|
129
|
+
stylesheet,
|
|
130
|
+
homePath: localizedUrl(config, locale, ''),
|
|
131
|
+
archivesPath: localizedUrl(config, locale, 'archives/'),
|
|
132
|
+
feedPath: localizedUrl(config, locale, 'feed.xml'),
|
|
133
|
+
homeLabel: translate(config, locale, 'home'),
|
|
134
|
+
archivesLabel: translate(config, locale, 'archives'),
|
|
135
|
+
feedLabel: translate(config, locale, 'feed'),
|
|
136
|
+
mainNavigationLabel: translate(config, locale, 'mainNavigationLabel'),
|
|
137
|
+
languageLabel: translate(config, locale, 'languageLabel'),
|
|
138
|
+
footerNavigationLabel: translate(config, locale, 'footerNavigationLabel'),
|
|
139
|
+
skipToContent: translate(config, locale, 'skipToContent'),
|
|
140
|
+
},
|
|
141
|
+
page: { ...page, content, title, description, robots, keywords, image, canonical, jsonLd }
|
|
142
|
+
};
|
|
143
|
+
const template = await loadLayout(config);
|
|
144
|
+
const rendered = template.replace(/{{{\s*([A-Za-z][A-Za-z0-9_.]*)\s*}}}|{{\s*([A-Za-z][A-Za-z0-9_.]*)\s*}}/g,
|
|
145
|
+
(_match, rawKey, key) => {
|
|
146
|
+
const name = rawKey || key;
|
|
147
|
+
const value = lookup(values, name);
|
|
148
|
+
if (value === undefined) throw new Error('Unknown theme placeholder: ' + name);
|
|
149
|
+
if (rawKey) {
|
|
150
|
+
if (!['page.content', 'page.jsonLd', 'site.localeLinksHtml', 'site.primaryNavigationHtml', 'site.footerNavigationHtml'].includes(name)) {
|
|
151
|
+
throw new Error('Only generated content, JSON-LD, and locale navigation may use raw theme placeholders: ' + name);
|
|
152
|
+
}
|
|
153
|
+
return value == null ? '' : String(value);
|
|
154
|
+
}
|
|
155
|
+
return escapeHtml(value);
|
|
156
|
+
});
|
|
157
|
+
const html = '<!doctype html>' + rendered;
|
|
158
|
+
const filtered = await extensions.filter('html:afterLayout', html, context);
|
|
159
|
+
if (typeof filtered !== 'string') throw new Error('html:afterLayout filters must return a string');
|
|
160
|
+
return filtered;
|
|
161
|
+
}
|