edgepress 2026.9.5 → 2026.9.8

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 (66) hide show
  1. package/README.md +5 -3
  2. package/config.yml +121 -69
  3. package/content/pages/home/en.md +15 -0
  4. package/content/pages/home/zh-cn.md +16 -1
  5. package/content/pages/plugin-development/en.md +5 -4
  6. package/content/pages/plugin-development/zh-cn.md +5 -4
  7. package/content/pages/privacy-policy/en.md +8 -5
  8. package/content/pages/privacy-policy/zh-cn.md +8 -5
  9. package/content/pages/theme-development/en.md +24 -0
  10. package/content/pages/theme-development/zh-cn.md +24 -0
  11. package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +12 -2
  12. package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +12 -2
  13. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +2 -0
  14. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +2 -0
  15. package/content/posts/2026-09-25-edgepress-release-2026-9-2/en.md +2 -0
  16. package/content/posts/2026-09-25-edgepress-release-2026-9-2/zh-cn.md +2 -0
  17. package/content/posts/2026-09-25-edgepress-release-2026-9-3/en.md +2 -0
  18. package/content/posts/2026-09-25-edgepress-release-2026-9-3/zh-cn.md +2 -0
  19. package/content/posts/2026-09-25-edgepress-release-2026-9-4/en.md +2 -0
  20. package/content/posts/2026-09-25-edgepress-release-2026-9-4/zh-cn.md +2 -0
  21. package/content/posts/2026-09-25-edgepress-release-2026-9-5/en.md +2 -0
  22. package/content/posts/2026-09-25-edgepress-release-2026-9-5/zh-cn.md +2 -0
  23. package/content/posts/2026-09-25-edgepress-release-2026-9-6/en.md +31 -0
  24. package/content/posts/2026-09-25-edgepress-release-2026-9-6/zh-cn.md +31 -0
  25. package/content/posts/2026-09-26-edgepress-release-2026-9-7/en.md +26 -0
  26. package/content/posts/2026-09-26-edgepress-release-2026-9-7/zh-cn.md +26 -0
  27. package/content/posts/2026-09-26-edgepress-release-2026-9-8/en.md +26 -0
  28. package/content/posts/2026-09-26-edgepress-release-2026-9-8/zh-cn.md +26 -0
  29. package/languages/base/en.json +9 -2
  30. package/languages/packs/zh-CN.json +9 -2
  31. package/package.json +2 -1
  32. package/plugins/consent/index.js +4 -2
  33. package/project-compatibility.json +26 -2
  34. package/src/build.js +5 -4
  35. package/src/cli.js +8 -13
  36. package/src/config.js +72 -14
  37. package/src/content.js +55 -1
  38. package/src/generators.js +125 -23
  39. package/src/i18n.js +6 -0
  40. package/src/icons.js +16 -0
  41. package/src/maintenance.js +0 -27
  42. package/src/markdown.js +88 -9
  43. package/src/page-blocks.js +84 -18
  44. package/src/page-check.js +79 -25
  45. package/src/report-visuals.js +111 -16
  46. package/src/theme.js +13 -2
  47. package/static/edgepress/brand/edgepress-mark.svg +7 -0
  48. package/static/edgepress/brand/mascot-209.webp +0 -0
  49. package/static/edgepress/brand/mascot-418.png +0 -0
  50. package/static/edgepress/brand/mascot-418.webp +0 -0
  51. package/static/edgepress/brand/mascot.png +0 -0
  52. package/static/edgepress/brand/og-image.png +0 -0
  53. package/static/edgepress/brand/og-image.svg +46 -0
  54. package/static/edgepress/code-copy.js +55 -0
  55. package/static/edgepress/icons.svg +24 -0
  56. package/static/edgepress/plugins/consent/manager.js +204 -71
  57. package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +1 -0
  58. package/themes/atelier/assets/style.css +98 -20
  59. package/themes/atelier/layouts/partials/head.html +5 -0
  60. package/themes/atelier/layouts/partials/header.html +1 -1
  61. package/themes/default/assets/style.css +99 -20
  62. package/themes/default/layouts/partials/head.html +3 -0
  63. package/themes/default/layouts/partials/header.html +1 -1
  64. package/themes/signal/assets/style.css +98 -20
  65. package/themes/signal/layouts/partials/head.html +5 -0
  66. package/themes/signal/layouts/partials/header.html +1 -1
@@ -127,13 +127,42 @@ async function startHeadlessBrowser(executable, profile) {
127
127
  await withTimeout(loaded, 20000, 'Screenshot page load timed out');
128
128
  await send('Runtime.evaluate', { expression: 'new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 150)))', awaitPromise: true });
129
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');
130
+ const screenshot = async ({ hideConsent = false, clipSelector = '' } = {}) => {
131
+ let previousDisplay;
132
+ if (hideConsent) {
133
+ const hidden = await send('Runtime.evaluate', {
134
+ expression: '(() => { const node = document.querySelector("[data-consent-ui]"); if (!node) return null; const old = node.style.display; node.style.display = "none"; return old; })()',
135
+ returnByValue: true
136
+ });
137
+ previousDisplay = hidden.result.value;
138
+ }
139
+ try {
140
+ let clip;
141
+ if (clipSelector) {
142
+ const measured = await send('Runtime.evaluate', {
143
+ expression: '(() => { const node = document.querySelector("' + clipSelector + '"); if (!node) return null; const rect = node.getBoundingClientRect(); return JSON.stringify({x:Math.max(0,rect.left),y:Math.max(0,rect.top),width:Math.min(window.innerWidth,rect.right)-Math.max(0,rect.left),height:Math.min(window.innerHeight,rect.bottom)-Math.max(0,rect.top)}); })()',
144
+ returnByValue: true
145
+ });
146
+ if (!measured.result.value) return null;
147
+ const rect = JSON.parse(measured.result.value);
148
+ if (rect.width <= 0 || rect.height <= 0) return null;
149
+ clip = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1 };
150
+ }
151
+ const result = await send('Page.captureScreenshot', {
152
+ format: 'png', fromSurface: true, captureBeyondViewport: false, ...(clip ? { clip } : {})
153
+ });
154
+ return Buffer.from(result.data, 'base64');
155
+ } finally {
156
+ if (hideConsent && previousDisplay !== undefined && previousDisplay !== null) {
157
+ await send('Runtime.evaluate', {
158
+ expression: '(() => { const node = document.querySelector("[data-consent-ui]"); if (node) node.style.display = ' + JSON.stringify(previousDisplay) + '; })()'
159
+ });
160
+ }
161
+ }
133
162
  };
134
163
  const metrics = async () => {
135
164
  const result = await send('Runtime.evaluate', {
136
- expression: 'JSON.stringify({viewportWidth:window.innerWidth,documentWidth:document.documentElement.scrollWidth})',
165
+ expression: '(() => { const manager = document.querySelector("[data-consent-ui]"); const oldDisplay = manager?.style.display; if (manager) manager.style.display = "none"; const pageDocumentWidth = Math.max(document.documentElement.scrollWidth,document.body?.scrollWidth || 0); if (manager) manager.style.display = oldDisplay; const panel = manager?.querySelector(".privacy-panel"); const rect = manager?.getBoundingClientRect(); const panelRect = panel?.getBoundingClientRect(); const buttons = [...(panel?.querySelectorAll("button") || [])]; const buttonsFit = !panelRect || buttons.every(button => { const r = button.getBoundingClientRect(); return r.left >= panelRect.left - 1 && r.right <= panelRect.right + 1; }); const consentInnerNoOverflow = !panel || (panel.scrollWidth <= panel.clientWidth + 1 && buttonsFit); const consentWithinViewport = !rect || (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1); const accept = panel?.querySelector(".privacy-accept"); const reject = panel?.querySelector(".privacy-reject"); const ar = accept?.getBoundingClientRect(); const rr = reject?.getBoundingClientRect(); const consentActionsBalanced = Boolean(accept && reject && Math.abs(ar.width-rr.width)<1 && Math.abs(ar.height-rr.height)<1 && getComputedStyle(accept).backgroundColor===getComputedStyle(reject).backgroundColor); const switches = [...(panel?.querySelectorAll(".privacy-service-toggle input") || [])]; const consentOptInDefault = switches.every(input => !input.checked); const serviceCards = [...(panel?.querySelectorAll(".privacy-service-setting") || [])]; const consentServiceDisclosures = serviceCards.length===0 || serviceCards.every(card => card.querySelectorAll(".privacy-service-disclosures dt").length>=4); const consentDetails = Boolean(panel?.querySelector(".privacy-details>summary")); return JSON.stringify({viewportWidth:innerWidth,documentWidth:document.documentElement.scrollWidth,pageDocumentWidth,consentExists:Boolean(manager),consentWithinViewport,consentInnerNoOverflow,consentActionsBalanced,consentOptInDefault,consentServiceDisclosures,consentDetails,consentWidth:rect?.width || 0,consentHeight:rect?.height || 0}); })()',
137
166
  returnByValue: true
138
167
  });
139
168
  return JSON.parse(result.result.value);
@@ -189,7 +218,11 @@ const mimeTypes = {
189
218
  function reportHtml(report, screenshots) {
190
219
  const documents = report.documents || [];
191
220
  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;
221
+ const consentChecks = report.consentUi?.checks || 0;
222
+ const consentPassed = report.consentUi?.passed || 0;
223
+ const consentErrors = Math.max(0, consentChecks - consentPassed);
224
+ const consentScore = consentChecks ? Math.floor(consentPassed / consentChecks * 100) + '%' : '—';
225
+ const typeLabel = (type) => ({ post: 'Posts', page: 'Pages', homepage: 'Homepages', system: 'System routes', consent: 'Consent interface' })[type] || type;
193
226
  const statusTable = '<table><caption>Audit results</caption><thead><tr><th scope="col">Audit</th><th scope="col">Status</th>' +
194
227
  '<th scope="col">Score</th><th scope="col">Checks</th><th scope="col">Errors</th><th scope="col">Warnings</th></tr></thead><tbody>' +
195
228
  '<tr><th scope="row">Accessibility</th><td>' + escapeHtml(report.accessibility.status) + '</td><td>' + report.accessibility.score +
@@ -198,6 +231,8 @@ function reportHtml(report, screenshots) {
198
231
  '%</td><td>' + report.agentFriendliness.checks + '</td><td>' + report.agentFriendliness.errors + '</td><td>' + report.agentFriendliness.warnings + '</td></tr>' +
199
232
  '<tr><th scope="row">Markdown rendering</th><td>' + escapeHtml(report.markdownRendering?.status || 'not run') + '</td><td>—</td><td>' +
200
233
  (report.markdownRendering?.checks || 0) + '</td><td>' + ((report.markdownRendering?.checks || 0) - (report.markdownRendering?.passed || 0)) + '</td><td>0</td></tr>' +
234
+ '<tr><th scope="row">Consent interface</th><td>' + escapeHtml(report.consentUi?.status || 'not checked') + '</td><td>' + consentScore +
235
+ '</td><td>' + consentChecks + '</td><td>' + consentErrors + '</td><td>0</td></tr>' +
201
236
  '<tr><th scope="row">Report reading structure</th><td>' + escapeHtml(reportReading.status) + '</td><td>' +
202
237
  Math.floor(reportReading.passed / Math.max(1, reportReading.checks) * 100) + '%</td><td>' + reportReading.checks +
203
238
  '</td><td>' + (reportReading.checks - reportReading.passed) + '</td><td>0</td></tr>' +
@@ -217,11 +252,13 @@ function reportHtml(report, screenshots) {
217
252
  ? '<ul>' + report.issues.map((item) => '<li><strong>' + escapeHtml(item.severity + ' · ' + item.category) +
218
253
  '</strong> ' + escapeHtml(item.page) + ' — ' + escapeHtml(item.message) + '</li>').join('') + '</ul>'
219
254
  : '<p>No findings.</p>';
220
- const screenshotGroups = ['post', 'page', 'homepage', 'system'].map((type) => {
255
+ const screenshotGroups = ['post', 'page', 'homepage', 'system', 'consent'].map((type) => {
221
256
  const items = screenshots.filter((item) => item.type === type);
222
257
  if (!items.length) return '';
223
258
  return '<section><h3>' + typeLabel(type) + ' screenshots</h3>' + items.map((item) => {
224
- const dimensions = item.viewportWidth + ' CSS pixels wide; document width ' + item.documentWidth + ' CSS pixels';
259
+ const dimensions = item.capture === 'consent'
260
+ ? 'Consent component ' + Math.round(item.consentWidth || 0) + ' by ' + Math.round(item.consentHeight || 0) + ' CSS pixels; within viewport: ' + Boolean(item.consentWithinViewport)
261
+ : item.viewportWidth + ' CSS pixels wide; page content width ' + item.documentWidth + ' CSS pixels with consent hidden';
225
262
  const alt = 'Screenshot of ' + (item.title || item.page) + ' at the ' + item.viewport + ' viewport, ' +
226
263
  item.width + ' by ' + item.height + ' pixels; ' + dimensions + '.';
227
264
  return '<h4>' + escapeHtml(item.title || item.page) + ' · ' + escapeHtml(item.viewport) + '</h4><figure><img src="./page-check-screenshots/' +
@@ -232,12 +269,15 @@ function reportHtml(report, screenshots) {
232
269
  }).join('');
233
270
  const markdownDetails = (report.markdownRendering?.documents || []).map((document) => '<section><h3>' + escapeHtml(document.path) +
234
271
  '</h4><ul>' + document.checks.map((item) => '<li>' + (item.passed ? 'Pass' : 'Fail') + ' — ' + escapeHtml(item.feature) + '</li>').join('') + '</ul></section>').join('');
272
+ const consentDetails = [...(report.consentUi?.results || []), ...(report.consentUi?.visualChecks || [])]
273
+ .map((item) => '<li>' + (item.passed ? 'Pass' : 'Fail') + ' — ' + escapeHtml(item.name ||
274
+ (item.page + ' at ' + item.viewport + ' viewport')) + '</li>').join('');
235
275
  return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
236
276
  '<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
277
  '<body><a class="skip-link" href="#main">Skip to report</a><header><h1>EdgePress accessibility and content report</h1><p>Generated ' +
238
278
  escapeHtml(report.generatedAt) + ' · Overall status: <span class="status">' + escapeHtml(report.status) + '</span>.</p>' +
239
279
  '<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>' +
280
+ '<li><a href="#findings">Findings</a></li><li><a href="#markdown">Markdown rendering</a></li><li><a href="#consent">Consent interface</a></li><li><a href="#evidence">Screenshots</a></li><li><a href="#scope">Scope</a></li></ul></nav></header>' +
241
281
  '<main id="main"><section id="summary"><h2>Audit summary</h2>' + statusTable + summary + '<p>Generated HTML documents: ' + report.pages +
242
282
  ' · HTML bytes: ' + report.metrics.htmlBytes + ' · Stylesheet references: ' + report.metrics.stylesheets +
243
283
  ' · Script references: ' + report.metrics.scripts + '.</p></section>' +
@@ -245,7 +285,9 @@ function reportHtml(report, screenshots) {
245
285
  documentSections + '</section><section id="findings"><h2>Findings</h2>' + findings + '</section>' +
246
286
  '<section id="markdown"><h2>Markdown rendering verification</h2><p>' + escapeHtml(String(report.markdownRendering?.passed || 0)) + ' of ' +
247
287
  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>' +
288
+ '<section id="consent"><h2>Consent interface checks</h2><p>Consent component checks run separately from page-content layout checks.</p>' +
289
+ (consentDetails ? '<ul>' + consentDetails + '</ul>' : '<p>No consent UI checks were run.</p>') + '</section>' +
290
+ '<section id="evidence" class="appendix"><h2>Responsive and consent screenshots</h2><p>Page screenshots use a 390 by 844 CSS pixel mobile viewport and hide the consent component while measuring page content. Separate consent screenshots crop the component at desktop and mobile sizes.</p>' +
249
291
  screenshotGroups + '</section><section id="scope"><h2>Scope and limits</h2><ul>' + report.scope.map((item) => '<li>' + escapeHtml(item) + '</li>').join('') +
250
292
  '</ul></section></main><footer><p>EdgePress local PDF report. Screenshot details are included as evidence and may be small when printed.</p></footer></body></html>';
251
293
  }
@@ -407,11 +449,25 @@ export async function addVisualEvidence(config, report) {
407
449
  const digest = createHash('sha256').update(relativeFile + ':' + viewport.name).digest('hex').slice(0, 10);
408
450
  const file = 'page-' + digest + '.png';
409
451
  const target = resolve(evidenceDirectory, file);
410
- screenshots.push({ page: relativeFile, type: document.type, title: document.title, viewport: viewport.name, width: viewport.width,
452
+ screenshots.push({ page: relativeFile, type: document.type, capture: 'page', title: document.title, viewport: viewport.name, width: viewport.width,
411
453
  height: viewport.height, file, target,
412
454
  label: relativeFile + ' · ' + viewport.name + ' (' + viewport.width + '×' + viewport.height + ')' });
413
455
  }
414
456
  }
457
+ if (config.browserPlugins.consent.enabled) {
458
+ const availableSet = new Set(available);
459
+ for (const relativeFile of new Set([...homePaths, ...privacyPaths])) {
460
+ if (!availableSet.has(relativeFile)) continue;
461
+ for (const viewport of [desktop, mobile]) {
462
+ const digest = createHash('sha256').update('consent:' + relativeFile + ':' + viewport.name).digest('hex').slice(0, 10);
463
+ const file = 'consent-' + digest + '.png';
464
+ screenshots.push({ page: relativeFile, type: 'consent', capture: 'consent', title: 'Privacy choices',
465
+ viewport: viewport.name, width: viewport.width, height: viewport.height, file,
466
+ target: resolve(evidenceDirectory, file), label: 'Privacy choices · ' + relativeFile + ' · ' + viewport.name +
467
+ ' (' + viewport.width + '×' + viewport.height + ')' });
468
+ }
469
+ }
470
+ }
415
471
  reportServer = await startReportServer(config.resolvedPaths.output, evidenceDirectory, htmlPath);
416
472
  browserSession = await startHeadlessBrowser(browser, profile);
417
473
  for (const screenshot of screenshots) {
@@ -419,19 +475,57 @@ export async function addVisualEvidence(config, report) {
419
475
  await browserSession.setViewport(viewport);
420
476
  await browserSession.navigate('http://127.0.0.1:' + reportServer.port + siteAddress(screenshot.page));
421
477
  const dimensions = await browserSession.metrics();
422
- await writeFile(screenshot.target, await browserSession.screenshot());
478
+ if (screenshot.capture === 'consent') {
479
+ report.consentUi.visualChecks ||= [];
480
+ const checks = [
481
+ ['component stays inside the viewport', dimensions.consentExists && dimensions.consentWithinViewport],
482
+ ['component content stays inside its own box', dimensions.consentExists && dimensions.consentInnerNoOverflow],
483
+ ['accept and reject actions have equal visual weight', dimensions.consentExists && dimensions.consentActionsBalanced],
484
+ ['optional services begin unchecked', dimensions.consentExists && dimensions.consentOptInDefault],
485
+ ['expanded service details disclose all configured fields', dimensions.consentExists && dimensions.consentServiceDisclosures && dimensions.consentDetails]
486
+ ];
487
+ report.consentUi.visualChecks.push(...checks.map(([name, passed]) => ({ page: screenshot.page, viewport: viewport.name,
488
+ name, passed, consentWidth: dimensions.consentWidth, consentHeight: dimensions.consentHeight })));
489
+ const withinViewport = dimensions.consentExists && dimensions.consentWithinViewport;
490
+ const noInnerOverflow = dimensions.consentExists && dimensions.consentInnerNoOverflow;
491
+ if (!dimensions.consentExists) {
492
+ report.issues.push({ severity: 'error', category: 'consent-ui', page: screenshot.page,
493
+ message: 'The consent manager did not appear in the browser.' });
494
+ } else if (!withinViewport || !noInnerOverflow) {
495
+ report.issues.push({ severity: 'error', category: 'consent-ui', page: screenshot.page,
496
+ message: 'Consent UI exceeded its viewport or its own content box at the ' + viewport.name + ' size.' });
497
+ }
498
+ }
499
+ const image = screenshot.capture === 'consent'
500
+ ? await browserSession.screenshot({ clipSelector: '[data-consent-ui]' })
501
+ : await browserSession.screenshot({ hideConsent: config.browserPlugins.consent.enabled });
502
+ if (!image) {
503
+ report.issues.push({ severity: 'error', category: screenshot.capture === 'consent' ? 'consent-ui' : 'visual-evidence',
504
+ page: screenshot.page, message: 'The requested screenshot area was not present in the browser.' });
505
+ continue;
506
+ }
507
+ await writeFile(screenshot.target, image);
423
508
  const imageInfo = await lstat(screenshot.target);
424
509
  if (!imageInfo.isFile() || imageInfo.size < 1000) throw new Error('Headless browser produced an empty screenshot');
425
510
  screenshot.viewportWidth = dimensions.viewportWidth;
426
- screenshot.documentWidth = dimensions.documentWidth;
511
+ screenshot.documentWidth = dimensions.pageDocumentWidth;
512
+ screenshot.consentWidth = dimensions.consentWidth;
513
+ screenshot.consentHeight = dimensions.consentHeight;
514
+ screenshot.consentWithinViewport = dimensions.consentWithinViewport;
427
515
  if (viewport.name === 'mobile' && dimensions.viewportWidth !== viewport.width) {
428
516
  report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
429
517
  message: 'Mobile viewport measured ' + dimensions.viewportWidth + ' CSS pixels instead of ' + viewport.width + '.' });
430
- } else if (viewport.name === 'mobile' && dimensions.documentWidth > dimensions.viewportWidth) {
518
+ } else if (screenshot.capture === 'page' && viewport.name === 'mobile' && dimensions.pageDocumentWidth > dimensions.viewportWidth) {
431
519
  report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
432
- message: 'Mobile document width is ' + dimensions.documentWidth + ' CSS pixels for a ' + dimensions.viewportWidth + ' pixel viewport.' });
520
+ message: 'Page content width is ' + dimensions.pageDocumentWidth + ' CSS pixels for a ' + dimensions.viewportWidth + ' pixel viewport; consent UI is measured separately.' });
433
521
  }
434
522
  }
523
+ if (report.consentUi?.visualChecks) {
524
+ const visualChecks = report.consentUi.visualChecks;
525
+ report.consentUi.checks += visualChecks.length;
526
+ report.consentUi.passed += visualChecks.filter((item) => item.passed).length;
527
+ report.consentUi.status = report.consentUi.passed === report.consentUi.checks ? 'pass' : 'fail';
528
+ }
435
529
  report.errors = report.issues.filter((item) => item.severity === 'error').length;
436
530
  report.warnings = report.issues.filter((item) => item.severity === 'warning').length;
437
531
  report.status = report.errors ? 'fail' : report.warnings ? 'warning' : 'pass';
@@ -440,9 +534,10 @@ export async function addVisualEvidence(config, report) {
440
534
  if (findings.some((item) => item.severity === 'error')) document.status = 'fail';
441
535
  else if (findings.length) document.status = 'warning';
442
536
  }
443
- report.visualEvidence = { status: 'complete', pdf: 'tools/page-check.pdf', screenshots: screenshots.map((item) => ({
444
- page: item.page, type: item.type, title: item.title, viewport: item.viewport, width: item.width, height: item.height,
537
+ report.visualEvidence = { status: 'complete', pdf: 'tools/page-check.pdf', screenshots: screenshots.filter((item) => item.viewportWidth).map((item) => ({
538
+ page: item.page, type: item.type, capture: item.capture, title: item.title, viewport: item.viewport, width: item.width, height: item.height,
445
539
  viewportWidth: item.viewportWidth, documentWidth: item.documentWidth,
540
+ consentWidth: item.consentWidth, consentHeight: item.consentHeight, consentWithinViewport: item.consentWithinViewport,
446
541
  label: item.label
447
542
  })) };
448
543
  const initialHtml = reportHtml(report, screenshots);
package/src/theme.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { readFile, realpath } from 'node:fs/promises';
2
2
  import { relative, resolve, sep } from 'node:path';
3
3
  import { localePrefix, localizedUrl, translate } from './i18n.js';
4
+ import { isIconName, renderIcon } from './icons.js';
5
+ const defaultNavigationIcons = {
6
+ home: 'home', guides: 'book-open', 'quick-start': 'rocket', posts: 'newspaper',
7
+ search: 'search', privacy: 'shield-check', feed: 'rss'
8
+ };
4
9
 
5
10
  function isInside(root, target) {
6
11
  const rel = relative(root, target);
@@ -27,7 +32,9 @@ function navigationItems(config, locale, items) {
27
32
  item.url === '@search' ? localizedUrl(config, locale, 'search/') :
28
33
  item.url.startsWith('/') ? localizedUrl(config, locale, item.url) : item.url;
29
34
  const label = item.labels?.[locale] ?? item.labels?.[config.i18n.defaultLocale] ?? translate(config, locale, item.key);
30
- return '<a href="' + escapeHtml(path) + '">' + escapeHtml(label) + '</a>';
35
+ const iconName = item.icon || defaultNavigationIcons[item.key];
36
+ const icon = isIconName(iconName) ? renderIcon(iconName, 'navigation-icon') : '';
37
+ return '<a href="' + escapeHtml(path) + '">' + icon + escapeHtml(label) + '</a>';
31
38
  }).join('');
32
39
  }
33
40
 
@@ -157,5 +164,9 @@ export async function renderLayout(config, extensions, page, body) {
157
164
  const html = '<!doctype html>' + rendered;
158
165
  const filtered = await extensions.filter('html:afterLayout', html, context);
159
166
  if (typeof filtered !== 'string') throw new Error('html:afterLayout filters must return a string');
160
- return filtered;
167
+ if (/src=["']\/edgepress\/code-copy\.js["']/i.test(filtered)) return filtered;
168
+ const codeCopyScript = '<script defer src="/edgepress/code-copy.js"></script>';
169
+ return /<\/body\s*>/i.test(filtered)
170
+ ? filtered.replace(/<\/body\s*>/i, codeCopyScript + '</body>')
171
+ : filtered + codeCopyScript;
161
172
  }
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
2
+ <rect width="48" height="48" rx="14" fill="#315E49"/>
3
+ <path d="M12 10.5a2 2 0 0 1 2-2h13.5L36 17v20.5a2 2 0 0 1-2 2H14a2 2 0 0 1-2-2z" fill="#FBF8EF"/>
4
+ <path d="M27.5 8.5V17H36" fill="#E98263"/>
5
+ <path d="M18 23h12m-12 6h12m-12 6h8" stroke="#315E49" stroke-width="2.6" stroke-linecap="round"/>
6
+ <path d="M27.5 8.5 36 17" stroke="#D6E4D8" stroke-width="1.2"/>
7
+ </svg>
@@ -0,0 +1,46 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
2
+ <defs>
3
+ <linearGradient id="paper" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fbfaf5"/><stop offset="1" stop-color="#eee9dd"/></linearGradient>
4
+ <linearGradient id="field" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#dfeae0"/><stop offset="1" stop-color="#c9ddcf"/></linearGradient>
5
+ <filter id="card-shadow" x="-20%" y="-20%" width="140%" height="150%"><feDropShadow dx="0" dy="16" stdDeviation="18" flood-color="#183729" flood-opacity=".12"/></filter>
6
+ <pattern id="dots" width="25" height="25" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1.4" fill="#8da891" opacity=".42"/></pattern>
7
+ </defs>
8
+ <rect width="1200" height="630" rx="28" fill="url(#paper)"/>
9
+ <circle cx="1100" cy="62" r="245" fill="#f1e5d3" opacity=".75"/>
10
+ <circle cx="964" cy="333" r="242" fill="url(#field)"/>
11
+ <circle cx="964" cy="333" r="217" fill="url(#dots)" opacity=".7"/>
12
+ <path d="M680 463c104-93 172-139 274-139s184 47 255 121" fill="none" stroke="#74937b" stroke-width="2" stroke-dasharray="4 10" opacity=".55"/>
13
+ <circle cx="703" cy="442" r="7" fill="#e98263"/>
14
+ <circle cx="1182" cy="442" r="7" fill="#315e49"/>
15
+ <g transform="translate(68 67)">
16
+ <rect width="52" height="52" rx="16" fill="#315e49"/>
17
+ <path d="M13 12a2 2 0 0 1 2-2h15l9 9v21a2 2 0 0 1-2 2H15a2 2 0 0 1-2-2z" fill="#fbf8ef"/>
18
+ <path d="M30 10v9h9" fill="#e98263"/>
19
+ <path d="M19 25h13m-13 6h13m-13 6h9" stroke="#315e49" stroke-width="2.5" stroke-linecap="round"/>
20
+ <text x="70" y="36" fill="#26382d" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="28" font-weight="700" letter-spacing="-.7">EdgePress</text>
21
+ </g>
22
+ <g fill="#26382d" font-family="Inter,Segoe UI,Arial,sans-serif">
23
+ <text x="72" y="235" font-size="60" font-weight="750" letter-spacing="-2.7">Build for the edge.</text>
24
+ <text x="72" y="306" font-size="60" font-weight="750" letter-spacing="-2.7">Publish with clarity.</text>
25
+ <text x="75" y="365" fill="#64756a" font-size="23" font-weight="450">A thoughtful way to build a fast, flexible website.</text>
26
+ <text x="75" y="400" fill="#64756a" font-size="23" font-weight="450">Markdown, editable pages, themes, and Workers.</text>
27
+ </g>
28
+ <g filter="url(#card-shadow)">
29
+ <rect x="72" y="460" width="516" height="91" rx="18" fill="#fffdf8" stroke="#e1ddd3"/>
30
+ <path d="M102 487h24l9 9v28h-33z" fill="#dce9dd" stroke="#315e49" stroke-width="1.5" stroke-linejoin="round"/>
31
+ <path d="M126 487v9h9m-24 8h16m-16 7h16" fill="none" stroke="#315e49" stroke-width="1.7" stroke-linecap="round"/>
32
+ <text x="151" y="510" fill="#26382d" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="17" font-weight="650">Open source. Built for publishing.</text>
33
+ <text x="151" y="532" fill="#748176" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="14">Clear source in. A lightweight site out.</text>
34
+ </g>
35
+ <g transform="translate(950 55)">
36
+ <rect x="12" y="10" width="167" height="41" rx="20.5" fill="#fffdf8" stroke="#c7d7c9"/>
37
+ <circle cx="37" cy="30.5" r="5" fill="#e98263"/>
38
+ <text x="54" y="36" fill="#45634e" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="13" font-weight="700" letter-spacing="1.4">PAGE BY PAGE</text>
39
+ </g>
40
+ <image href="mascot.png" x="755" y="72" width="430" height="478" preserveAspectRatio="xMidYMid meet"/>
41
+ <g transform="translate(936 557)">
42
+ <rect width="208" height="36" rx="18" fill="#315e49"/>
43
+ <text x="104" y="24" text-anchor="middle" fill="#fbf8ef" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="12" font-weight="700" letter-spacing="1.5">PUBLISH WITH PURPOSE</text>
44
+ </g>
45
+ <path d="M1107 125c10-19 14-21 21-23-4 11-4 18-1 27-8-4-13-4-20-4Z" fill="#e98263"/>
46
+ </svg>
@@ -0,0 +1,55 @@
1
+ const copyLabels = document.documentElement.lang.toLowerCase().startsWith('zh')
2
+ ? { copy: '复制', copied: '已复制到剪贴板。', failed: '复制失败,请手动选择并复制代码。', label: '复制代码' }
3
+ : { copy: 'Copy', copied: 'Copied to clipboard.', failed: 'Copy failed. Select and copy the code manually.', label: 'Copy code' };
4
+
5
+ function fallbackCopy(value) {
6
+ const field = document.createElement('textarea');
7
+ field.value = value;
8
+ field.setAttribute('readonly', '');
9
+ field.tabIndex = -1;
10
+ field.setAttribute('aria-hidden', 'true');
11
+ field.style.position = 'fixed';
12
+ field.style.inset = '0 auto auto -10000px';
13
+ document.body.append(field);
14
+ field.select();
15
+ let copied = false;
16
+ try { copied = document.execCommand('copy'); } catch { /* Clipboard permissions may deny the fallback. */ }
17
+ field.remove();
18
+ return copied;
19
+ }
20
+
21
+ for (const pre of document.querySelectorAll('pre[data-copyable-code]')) {
22
+ if (pre.parentElement?.classList.contains('code-copy-block')) continue;
23
+ const wrapper = document.createElement('div');
24
+ wrapper.className = 'code-copy-block';
25
+ const toolbar = document.createElement('div');
26
+ toolbar.className = 'code-copy-toolbar';
27
+ const button = document.createElement('button');
28
+ button.className = 'code-copy-button';
29
+ button.type = 'button';
30
+ button.textContent = copyLabels.copy;
31
+ button.setAttribute('aria-label', copyLabels.label);
32
+ const status = document.createElement('span');
33
+ status.className = 'code-copy-status';
34
+ status.setAttribute('role', 'status');
35
+ status.setAttribute('aria-live', 'polite');
36
+ toolbar.append(button);
37
+ pre.before(wrapper);
38
+ wrapper.append(toolbar, pre, status);
39
+
40
+ button.addEventListener('click', async () => {
41
+ const code = pre.querySelector('code') || pre;
42
+ let copied = false;
43
+ try {
44
+ if (navigator.clipboard?.writeText) {
45
+ await navigator.clipboard.writeText(code.textContent || '');
46
+ copied = true;
47
+ } else {
48
+ copied = fallbackCopy(code.textContent || '');
49
+ }
50
+ } catch {
51
+ copied = fallbackCopy(code.textContent || '');
52
+ }
53
+ status.textContent = copied ? copyLabels.copied : copyLabels.failed;
54
+ });
55
+ }
@@ -0,0 +1,24 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg">
2
+ <symbol id="home" viewBox="0 0 24 24"><path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-6v-7h-4v7H4a1 1 0 0 1-1-1z"/><path d="M8 8h8"/></symbol>
3
+ <symbol id="book-open" viewBox="0 0 24 24"><path d="M12 7v14M12 8c-2.5-2-5.5-2-9-1v13c3.5-1 6.5-1 9 1M12 8c2.5-2 5.5-2 9-1v13c-3.5-1-6.5-1-9 1"/><path d="M6 11h3m6 0h3"/></symbol>
4
+ <symbol id="rocket" viewBox="0 0 24 24"><path d="M12 15 9 12c1-5 4-8 11-9 0 7-4 10-8 11Z"/><path d="m9 12-4 1-2 4 5-1m4-1 1 4 4 2-1-5"/><circle cx="15" cy="8" r="1.5"/><path d="M7 18c-1 2-3 3-4 3 0-2 1-4 3-5"/></symbol>
5
+ <symbol id="newspaper" viewBox="0 0 24 24"><path d="M5 4h15v15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"/><path d="M3 8h5v5H3m8-5h6m-6 4h6m-11 4h11m-11 3h7"/></symbol>
6
+ <symbol id="search" viewBox="0 0 24 24"><circle cx="10.8" cy="10.8" r="6.8"/><path d="m16 16 5 5"/></symbol>
7
+ <symbol id="shield-check" viewBox="0 0 24 24"><path d="M12 3 20 6v5c0 5-3.2 8.2-8 10-4.8-1.8-8-5-8-10V6z"/><path d="m8.5 12 2.3 2.3 4.8-5"/></symbol>
8
+ <symbol id="rss" viewBox="0 0 24 24"><circle cx="5" cy="19" r="1"/><path d="M4 11a9 9 0 0 1 9 9M4 5a15 15 0 0 1 15 15"/></symbol>
9
+ <symbol id="file-text" viewBox="0 0 24 24"><path d="M6 3h8l5 5v13H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z"/><path d="M14 3v6h5m-10 4h6m-6 4h6"/></symbol>
10
+ <symbol id="layers" viewBox="0 0 24 24"><path d="m12 3 9 5-9 5-9-5z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/></symbol>
11
+ <symbol id="code" viewBox="0 0 24 24"><path d="m8 7-5 5 5 5m8-10 5 5-5 5m-3-12-2 14"/></symbol>
12
+ <symbol id="puzzle" viewBox="0 0 24 24"><path d="M4 4h5a2.5 2.5 0 1 1 5 0h6v6a2.5 2.5 0 1 0 0 5v5h-6a2.5 2.5 0 1 0-5 0H4v-6a2.5 2.5 0 1 1 0-5z"/></symbol>
13
+ <symbol id="plug" viewBox="0 0 24 24"><path d="M9 3v6m6-6v6m-9 0h12v3a6 6 0 0 1-6 6v3m0-9v6"/></symbol>
14
+ <symbol id="cloud" viewBox="0 0 24 24"><path d="M7 18a5 5 0 1 1 1-9.9A6 6 0 0 1 19 10a4 4 0 0 1 0 8z"/><path d="m9 14 3-3 3 3m-3-3v7"/></symbol>
15
+ <symbol id="palette" viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 0 18h1.2a2.3 2.3 0 0 0 1.6-4c-.7-.7-.2-2 1-2H18a3 3 0 0 0 3-3c0-5-4-9-9-9Z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7.5" r="1"/><circle cx="15" cy="8" r="1"/></symbol>
16
+ <symbol id="globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18m0-18a14 14 0 0 0 0 18"/></symbol>
17
+ <symbol id="help-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.6 2.6 0 1 1 4.8 1.4c-.8 1-2.3 1.4-2.3 3m0 3h.01"/></symbol>
18
+ <symbol id="arrow-up-right" viewBox="0 0 24 24"><path d="M7 17 17 7M8 7h9v9"/></symbol>
19
+ <symbol id="check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="m8 12 2.7 2.7L16.5 9"/></symbol>
20
+ <symbol id="layout-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="5" rx="1.5"/><rect x="13" y="10" width="8" height="11" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/></symbol>
21
+ <symbol id="terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3m6 0h4"/></symbol>
22
+ <symbol id="settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="m19.4 15 .1.1 1.1.9-1.1 2-1.4-.4a8 8 0 0 1-1.7 1l-.3 1.4h-2.2l-.3-1.4a8 8 0 0 1-1.7-1l-1.4.4-1.1-2 1.1-.9A8 8 0 0 1 8.5 14L7 13.7v-2.4l1.5-.3a8 8 0 0 1 .9-1.7l-.5-1.4 2-1.2 1 1.1a8 8 0 0 1 2 0l1-1.1 2 1.2-.5 1.4a8 8 0 0 1 .9 1.7l1.5.3v2.4l-1.5.3"/></symbol>
23
+ <symbol id="sparkles" viewBox="0 0 24 24"><path d="m12 3 1.4 5.6L19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4zM19 16l.7 2.3L22 19l-2.3.7L19 22l-.7-2.3L16 19l2.3-.7z"/></symbol>
24
+ </svg>