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
package/src/markdown.js CHANGED
@@ -7,9 +7,9 @@ const allowedTags = [
7
7
  'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'h1', 'h2',
8
8
  'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'i', 'img', 'kbd', 'li', 'main',
9
9
  'mark', 'nav', 'ol', 'p', 'pre', 'q', 'rp', 'rt', 'rtc', 'ruby', 's',
10
- 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary', 'sup',
11
- 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u', 'ul',
12
- 'var', 'wbr'
10
+ 'samp', 'section', 'small', 'source', 'span', 'strong', 'sub', 'summary', 'sup',
11
+ 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'track', 'tr', 'u', 'ul',
12
+ 'var', 'video', 'wbr'
13
13
  ];
14
14
 
15
15
  const sanitizeOptions = {
@@ -20,9 +20,12 @@ const sanitizeOptions = {
20
20
  img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
21
21
  li: ['value'],
22
22
  ol: ['start'],
23
+ source: ['src', 'type'],
23
24
  span: ['class', 'role', 'aria-label'],
24
25
  td: ['align', 'colspan', 'rowspan'],
25
- th: ['align', 'colspan', 'rowspan']
26
+ th: ['align', 'colspan', 'rowspan'],
27
+ track: ['kind', 'src', 'srclang', 'label', 'default'],
28
+ video: ['aria-label', 'controls', 'playsinline', 'poster', 'preload', 'width', 'height']
26
29
  },
27
30
  allowedSchemes: ['http', 'https', 'mailto', 'tel'],
28
31
  allowProtocolRelative: false,
@@ -35,10 +38,59 @@ const sanitizeOptions = {
35
38
  }
36
39
  };
37
40
 
41
+ function videoMimeType(href) {
42
+ const path = String(href ?? '').split(/[?#]/, 1)[0].toLowerCase();
43
+ const extension = path.match(/\.(mp4|webm|ogv|ogg)$/)?.[1];
44
+ return ({ mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', ogg: 'video/ogg' })[extension] || '';
45
+ }
46
+
47
+ function safeVideoHref(value) {
48
+ const href = String(value ?? '').trim();
49
+ if (!href || /[\x00-\x20\\]/.test(href) || href.startsWith('//')) {
50
+ throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs');
51
+ }
52
+ if (href.startsWith('/')) return href;
53
+ let url;
54
+ try { url = new URL(href, 'https://edgepress.invalid'); }
55
+ catch { throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs'); }
56
+ if (url.username || url.password || !['http:', 'https:'].includes(url.protocol)) {
57
+ throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs');
58
+ }
59
+ return href;
60
+ }
61
+
62
+ function escapeHtml(value) {
63
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
64
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
65
+ })[char]);
66
+ }
67
+
68
+ function markdownRenderer(options) {
69
+ const renderer = new marked.Renderer();
70
+ const renderImage = renderer.image.bind(renderer);
71
+ renderer.image = (token) => {
72
+ const mimeType = videoMimeType(token.href);
73
+ if (mimeType) {
74
+ const description = String(token.text ?? '').trim();
75
+ if (!description) throw new Error('Markdown videos need descriptive alternative text');
76
+ const href = escapeHtml(safeVideoHref(token.href));
77
+ if (options.allowVideo === false) return '<span>' + escapeHtml(description) + '</span>';
78
+ const caption = token.title ? '<figcaption>' + escapeHtml(token.title) + '</figcaption>' : '';
79
+ return '<figure class="markdown-video"><video controls playsinline preload="none" aria-label="' +
80
+ escapeHtml(description) + '"><source src="' + href + '" type="' + mimeType + '"><a href="' + href + '">' +
81
+ escapeHtml(description) + '</a></video>' + caption + '</figure>';
82
+ }
83
+ if (options.allowImages === false) return '<span>' + escapeHtml(token.text || '') + '</span>';
84
+ return renderImage(token);
85
+ };
86
+ return renderer;
87
+ }
88
+
38
89
  export async function renderMarkdown(source, options = {}) {
39
90
  let html = await marked.parse(source, {
40
91
  gfm: options.gfm !== false,
41
- breaks: options.breaks === true
92
+ breaks: options.breaks === true,
93
+ renderer: markdownRenderer(options)
42
94
  });
43
95
  // GFM task markers become named static status icons. They remain readable to
44
96
  // assistive technology without exposing disabled, unlabeled form controls.
@@ -55,11 +107,38 @@ export async function renderMarkdown(source, options = {}) {
55
107
  return sanitizeHtml(html, sanitizeOptions);
56
108
  }
57
109
 
110
+ export async function renderMarkdownExcerpt(source, options = {}) {
111
+ const markdown = String(source ?? '');
112
+ const tokens = marked.lexer(markdown, { gfm: options.gfm !== false });
113
+ const paragraph = tokens.find((token) => token.type === 'paragraph');
114
+ if (!paragraph) return '<p>' + escapeHtml(plainText(markdown).slice(0, 220)) + '</p>';
115
+ return renderMarkdown(paragraph.raw, { ...options, allowImages: false, allowVideo: false });
116
+ }
117
+
58
118
  export function plainText(source) {
59
- return String(source)
60
- .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
61
- .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
62
- .replace(/[#>*_~|-]/g, ' ')
119
+ const html = marked.parse(String(source ?? ''), { gfm: true });
120
+ const namedEntities = {
121
+ amp: '&', apos: "'", copy: '©', gt: '>', hellip: '…', ldquo: '“', lsquo: '‘',
122
+ lt: '<', mdash: '—', nbsp: ' ', ndash: '–', quot: '"', rdquo: '”', rsquo: '’'
123
+ };
124
+ return String(html)
125
+ .replace(/<!--[\s\S]*?-->/g, ' ')
126
+ .replace(/<(script|style|textarea|option|xmp)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
127
+ .replace(/<img\b([^>]*)>/gi, (_match, attributes) => {
128
+ const alt = attributes.match(/\balt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
129
+ return alt ? ' ' + (alt[1] ?? alt[2] ?? alt[3]) + ' ' : ' ';
130
+ })
131
+ .replace(/<[^>]*>/g, ' ')
132
+ .replace(/&(#x[\da-f]+|#\d+|[a-z][a-z\d]+);/gi, (match, entity) => {
133
+ if (entity[0] === '#') {
134
+ const hex = entity[1]?.toLowerCase() === 'x';
135
+ const point = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
136
+ return Number.isInteger(point) && point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
137
+ ? String.fromCodePoint(point)
138
+ : match;
139
+ }
140
+ return namedEntities[entity.toLowerCase()] ?? match;
141
+ })
63
142
  .replace(/\s+/g, ' ')
64
143
  .trim();
65
144
  }
@@ -1,5 +1,7 @@
1
- import { plainText } from './markdown.js';
2
- import { translate } from './i18n.js';
1
+ import { renderMarkdownExcerpt } from './markdown.js';
2
+ import { translate, translateValue } from './i18n.js';
3
+ import { isIconName, renderIcon } from './icons.js';
4
+ import { postsForLocale } from './content.js';
3
5
 
4
6
  const captchaProviders = new Set(['cloudflare-turnstile', 'google-recaptcha', 'hcaptcha']);
5
7
 
@@ -32,6 +34,20 @@ function list(value, label, min = 1, max = 50) {
32
34
  return value;
33
35
  }
34
36
 
37
+ function imageCandidates(value, label) {
38
+ let previousWidth = 0;
39
+ return list(value, label, 1, 4).map((candidate, index) => {
40
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) throw new Error(label + '[' + index + '] must be an object');
41
+ const src = escapeHtml(safeUrl(candidate.src, label + '[' + index + '].src'));
42
+ const width = candidate.width;
43
+ if (!Number.isInteger(width) || width < 1 || width > 10000 || width <= previousWidth) {
44
+ throw new Error(label + ' widths must be increasing integers from 1 to 10000');
45
+ }
46
+ previousWidth = width;
47
+ return src + ' ' + width + 'w';
48
+ }).join(', ');
49
+ }
50
+
35
51
  function enabledIntegration(config, id, category) {
36
52
  const section = config.browserPlugins[category];
37
53
  const match = section?.enabled && section.services.find((item) => item.id === id);
@@ -43,6 +59,10 @@ function dateLabel(date, locale) {
43
59
  return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(date);
44
60
  }
45
61
 
62
+ function dateOnlyLabel(value, locale) {
63
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(value + 'T00:00:00.000Z'));
64
+ }
65
+
46
66
  function renderPrivacyServices(context) {
47
67
  const groups = [
48
68
  ['tracking', translate(context.config, context.locale, 'pluginTracking')],
@@ -57,22 +77,40 @@ function renderPrivacyServices(context) {
57
77
  if (!configured.length) {
58
78
  return '<p class="privacy-empty">' + escapeHtml(text(context.emptyText, 'privacy-services.emptyText', 2000)) + '</p>';
59
79
  }
60
- return '<ul class="privacy-service-list">' + configured.map((service) => '<li><h3>' + escapeHtml(service.category + ' · ' + service.provider) +
61
- '</h3><p><strong>' + escapeHtml(translate(context.config, context.locale, 'servicePurpose')) + ':</strong> ' + escapeHtml(service.purpose) +
62
- '</p><p><strong>' + escapeHtml(translate(context.config, context.locale, 'serviceRetention')) + ':</strong> ' + escapeHtml(service.retention) + '</p></li>').join('') + '</ul>';
80
+ return '<ul class="privacy-service-list">' + configured.map((service) => '<li><h3>' + escapeHtml(service.category + ' · ' +
81
+ translateValue(context.config, context.locale, service.name || service.provider)) + '</h3><p><strong>' +
82
+ escapeHtml(translate(context.config, context.locale, 'servicePurpose')) + ':</strong> ' +
83
+ escapeHtml(translateValue(context.config, context.locale, service.purpose)) + '</p><p><strong>' +
84
+ escapeHtml(translate(context.config, context.locale, 'serviceDataCategories')) + ':</strong> ' +
85
+ escapeHtml(translateValue(context.config, context.locale, service.dataCategories)) + '</p><p><strong>' +
86
+ escapeHtml(translate(context.config, context.locale, 'serviceRecipient')) + ':</strong> ' +
87
+ escapeHtml(translateValue(context.config, context.locale, service.recipient)) + '</p><p><strong>' +
88
+ escapeHtml(translate(context.config, context.locale, 'serviceRetention')) + ':</strong> ' +
89
+ escapeHtml(translateValue(context.config, context.locale, service.retention)) + '</p></li>').join('') + '</ul>';
63
90
  }
64
91
 
65
- function renderLatestPosts(block, context) {
92
+ async function renderLatestPosts(block, context) {
66
93
  const count = block.count ?? 3;
67
94
  if (!Number.isInteger(count) || count < 1 || count > 12) throw new Error('latest-posts.count must be an integer from 1 to 12');
68
- const posts = context.site.posts.filter((post) => post.locale === context.locale ||
69
- (!context.site.posts.some((item) => item.locale === context.locale) && post.locale === context.config.i18n.defaultLocale)).slice(0, count);
95
+ const paginate = block.paginate ?? false;
96
+ if (typeof paginate !== 'boolean') throw new Error('latest-posts.paginate must be a boolean');
97
+ const posts = postsForLocale(context.site.posts, context.locale, context.config.i18n.defaultLocale);
70
98
  const title = escapeHtml(text(block.title, 'latest-posts.title', 200));
71
- const cards = posts.map((post) => '<article class="post-card"><h3><a href="' + escapeHtml('/' + post.path.split('/').filter(Boolean).join('/') + (post.path.endsWith('/') ? '/' : '')) + '">' +
72
- escapeHtml(post.title) + '</a></h3><p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, context.locale)) +
73
- '</time></p><p>' + escapeHtml(post.description || plainText(post.markdown).slice(0, 220)) + '</p></article>').join('');
99
+ const authorLabel = (post) => post.author ? '<span class="post-author">' + escapeHtml(translate(context.config, context.locale, 'postAuthor')
100
+ .replace('{author}', post.author)) + '</span>' : '';
101
+ const cards = (await Promise.all(posts.slice(0, count).map(async (post) => {
102
+ const excerpt = await renderMarkdownExcerpt(post.description || post.markdown, context.config.markdown);
103
+ return '<article class="post-card" lang="' + escapeHtml(post.locale) + '"><h3><a href="' + escapeHtml('/' + post.path.split('/').filter(Boolean).join('/') + (post.path.endsWith('/') ? '/' : '')) + '">' +
104
+ escapeHtml(post.title) + '</a></h3><p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, context.locale)) +
105
+ '</time>' + (post.author ? ' · ' + authorLabel(post) : '') + '</p><div class="post-excerpt" lang="' + escapeHtml(post.locale) + '">' + excerpt + '</div></article>';
106
+ }))).join('');
107
+ const pagination = paginate && context.latestPostsPagination?.totalPages > 1
108
+ ? '<nav class="pagination latest-posts-pagination" aria-label="' + escapeHtml(translate(context.config, context.locale, 'pagination')) + '">' +
109
+ '<a rel="next" href="' + escapeHtml(context.latestPostsPagination.olderUrl) + '">' + escapeHtml(translate(context.config, context.locale, 'older')) + '</a>' +
110
+ '<a href="' + escapeHtml(context.latestPostsPagination.archiveUrl) + '">' + escapeHtml(translate(context.config, context.locale, 'allPosts')) + '</a></nav>'
111
+ : '';
74
112
  return '<section class="latest-posts"><h2>' + title + '</h2>' +
75
- (cards ? '<div class="post-list">' + cards + '</div>' : '<p>' + escapeHtml(translate(context.config, context.locale, 'noPosts')) + '</p>') + '</section>';
113
+ (cards ? '<div class="post-list">' + cards + '</div>' : '<p>' + escapeHtml(translate(context.config, context.locale, 'noPosts')) + '</p>') + pagination + '</section>';
76
114
  }
77
115
 
78
116
  function renderMediaText(block) {
@@ -88,12 +126,13 @@ function renderMediaText(block) {
88
126
  media = '<img src="' + src + '" alt="' + alt + '" loading="lazy" decoding="async">';
89
127
  } else {
90
128
  if (!alt.trim()) throw new Error('media-text.alt must describe the video');
129
+ const videoSrc = escapeHtml(safeUrl(block.src, 'media-text.src'));
91
130
  const captions = escapeHtml(safeUrl(block.captions, 'media-text.captions'));
92
131
  const language = escapeHtml(text(block.captionLanguage, 'media-text.captionLanguage', 24));
93
132
  if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(language)) throw new Error('media-text.captionLanguage must be a language tag');
94
133
  const trackLabel = escapeHtml(text(block.captionLabel, 'media-text.captionLabel', 120));
95
134
  const poster = block.poster ? ' poster="' + escapeHtml(safeUrl(block.poster, 'media-text.poster')) + '"' : '';
96
- media = '<video controls preload="none" aria-label="' + alt + '"' + poster + '><source src="' + src + '"><track kind="captions" src="' + captions + '" srclang="' + language + '" label="' + trackLabel + '" default></video>';
135
+ media = '<video controls playsinline preload="metadata" aria-label="' + alt + '"' + poster + '><source src="' + videoSrc + '" type="video/mp4"><track kind="captions" src="' + captions + '" srclang="' + language + '" label="' + trackLabel + '" default></video>';
97
136
  }
98
137
  const title = text(block.title, 'media-text.title', 240, true);
99
138
  const content = text(block.text, 'media-text.text', 2000);
@@ -117,6 +156,22 @@ async function renderBlock(block, context, depth, index) {
117
156
  const level = context.isHomepage && index === 0 ? 'h1' : 'h2';
118
157
  const eyebrow = text(block.eyebrow, 'hero.eyebrow', 120, true);
119
158
  const description = text(block.text, 'hero.text', 1000, true);
159
+ const mascotSrc = block.mascotSrc === undefined ? '' : safeUrl(block.mascotSrc, 'hero.mascotSrc');
160
+ const mascotAlt = block.mascotAlt === undefined ? '' : text(block.mascotAlt, 'hero.mascotAlt', 500);
161
+ if (Boolean(mascotSrc) !== Boolean(mascotAlt)) throw new Error('hero.mascotSrc and hero.mascotAlt must be provided together');
162
+ const mascotWebpSrcset = block.mascotWebpSrcset === undefined ? '' : imageCandidates(block.mascotWebpSrcset, 'hero.mascotWebpSrcset');
163
+ if (mascotWebpSrcset && !mascotSrc) throw new Error('hero.mascotWebpSrcset requires hero.mascotSrc');
164
+ if (block.mascotSizes !== undefined && !mascotWebpSrcset) throw new Error('hero.mascotSizes requires hero.mascotWebpSrcset');
165
+ const hasMascotWidth = block.mascotWidth !== undefined;
166
+ const hasMascotHeight = block.mascotHeight !== undefined;
167
+ if (hasMascotWidth !== hasMascotHeight) throw new Error('hero.mascotWidth and hero.mascotHeight must be provided together');
168
+ const mascotWidth = hasMascotWidth ? block.mascotWidth : 1222;
169
+ const mascotHeight = hasMascotHeight ? block.mascotHeight : 1287;
170
+ if (!Number.isInteger(mascotWidth) || mascotWidth < 1 || mascotWidth > 10000 ||
171
+ !Number.isInteger(mascotHeight) || mascotHeight < 1 || mascotHeight > 10000) {
172
+ throw new Error('hero.mascotWidth and hero.mascotHeight must be integers from 1 to 10000');
173
+ }
174
+ const mascotSizes = mascotWebpSrcset ? escapeHtml(text(block.mascotSizes ?? '230px', 'hero.mascotSizes', 200)) : '';
120
175
  let action = '';
121
176
  if (block.cta) {
122
177
  const label = escapeHtml(text(block.cta.label, 'hero.cta.label', 120));
@@ -126,9 +181,15 @@ async function renderBlock(block, context, depth, index) {
126
181
  const highlights = block.highlights === undefined ? [] : list(block.highlights, 'hero.highlights', 1, 4);
127
182
  const highlightPanel = highlights.length ? '<ul class="hero-highlights">' + highlights.map((item) => '<li>' +
128
183
  escapeHtml(text(item, 'hero highlight', 180)) + '</li>').join('') + '</ul>' : '';
129
- return '<section class="hero-block' + (highlightPanel ? ' hero-block--split' : '') + '"><div class="hero-main">' +
184
+ const mascotImage = mascotSrc ? '<img class="hero-mascot" src="' + escapeHtml(mascotSrc) + '" alt="' +
185
+ escapeHtml(mascotAlt) + '" width="' + mascotWidth + '" height="' + mascotHeight + '" decoding="async">' : '';
186
+ const mascot = mascotWebpSrcset
187
+ ? '<picture class="hero-mascot-picture"><source type="image/webp" srcset="' + mascotWebpSrcset + '" sizes="' + mascotSizes + '">' + mascotImage + '</picture>'
188
+ : mascotImage;
189
+ const aside = mascot || highlightPanel ? '<div class="hero-aside">' + mascot + highlightPanel + '</div>' : '';
190
+ return '<section class="hero-block' + (aside ? ' hero-block--split' : '') + '"><div class="hero-main">' +
130
191
  (eyebrow ? '<p class="eyebrow">' + escapeHtml(eyebrow) + '</p>' : '') + '<' + level + '>' + escapeHtml(heading) + '</' + level + '>' +
131
- (description ? '<p class="hero-copy">' + escapeHtml(description) + '</p>' : '') + action + '</div>' + highlightPanel + '</section>';
192
+ (description ? '<p class="hero-copy">' + escapeHtml(description) + '</p>' : '') + action + '</div>' + aside + '</section>';
132
193
  }
133
194
  case 'section': {
134
195
  const heading = text(block.title, 'section.title', 200, true);
@@ -240,13 +301,15 @@ async function renderBlock(block, context, depth, index) {
240
301
  case 'privacy-consent': {
241
302
  const storageLabel = escapeHtml(text(block.storageLabel, 'privacy-consent.storageLabel', 120));
242
303
  const expiryLabel = escapeHtml(text(block.expiryLabel, 'privacy-consent.expiryLabel', 120));
243
- const versionLabel = escapeHtml(text(block.versionLabel, 'privacy-consent.versionLabel', 120));
304
+ const proposedDateLabel = escapeHtml(text(block.proposedDateLabel, 'privacy-consent.proposedDateLabel', 120));
305
+ const effectiveDateLabel = escapeHtml(text(block.effectiveDateLabel, 'privacy-consent.effectiveDateLabel', 120));
244
306
  const expiryText = text(block.expiryText, 'privacy-consent.expiryText', 120);
245
307
  if (!expiryText.includes('{days}')) throw new Error('privacy-consent.expiryText must include a {days} placeholder');
246
308
  const consent = context.config.browserPlugins.consent;
247
309
  return '<dl class="privacy-controller-details"><div><dt>' + storageLabel + '</dt><dd>edgepress-privacy-choice</dd></div>' +
248
310
  '<div><dt>' + expiryLabel + '</dt><dd>' + escapeHtml(expiryText.replace('{days}', String(consent.expiresDays))) + '</dd></div>' +
249
- '<div><dt>' + versionLabel + '</dt><dd>' + escapeHtml(consent.version) + '</dd></div></dl>';
311
+ '<div><dt>' + proposedDateLabel + '</dt><dd>' + escapeHtml(dateOnlyLabel(consent.proposedDate, context.locale)) + '</dd></div>' +
312
+ (consent.effectiveDate ? '<div><dt>' + effectiveDateLabel + '</dt><dd>' + escapeHtml(dateOnlyLabel(consent.effectiveDate, context.locale)) + '</dd></div>' : '') + '</dl>';
250
313
  }
251
314
  case 'privacy-controller': {
252
315
  const controller = context.config.privacy.controller;
@@ -269,7 +332,10 @@ async function renderBlock(block, context, depth, index) {
269
332
  const title = escapeHtml(text(item?.title, 'feature title', 200));
270
333
  const description = escapeHtml(text(item?.text, 'feature text', 1000, true));
271
334
  const href = item?.url ? safeUrl(item.url, 'feature.url') : '';
272
- return '<article class="feature-card"><h3>' + (href ? '<a href="' + escapeHtml(href) + '">' + title + '</a>' : title) + '</h3>' +
335
+ const iconName = item?.icon === undefined ? '' : text(item.icon, 'feature icon', 40);
336
+ if (iconName && !isIconName(iconName)) throw new Error('Unsupported feature icon: ' + iconName);
337
+ const icon = iconName ? renderIcon(iconName, 'feature-icon') : '';
338
+ return '<article class="feature-card">' + icon + '<h3>' + (href ? '<a href="' + escapeHtml(href) + '">' + title + '</a>' : title) + '</h3>' +
273
339
  (description ? '<p>' + description + '</p>' : '') + '</article>';
274
340
  }).join('');
275
341
  return '<section class="feature-section"><h2>' + escapeHtml(heading) + '</h2><div class="feature-grid">' + cells + '</div></section>';
package/src/page-check.js CHANGED
@@ -1,6 +1,7 @@
1
- import { access, lstat, mkdir, readFile, readdir, realpath, rm } from 'node:fs/promises';
1
+ import { access, lstat, mkdir, readFile, readdir, realpath } from 'node:fs/promises';
2
2
  import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
3
3
  import { addVisualEvidence } from './report-visuals.js';
4
+ import { renderMarkdown, renderMarkdownExcerpt } from './markdown.js';
4
5
 
5
6
  async function htmlFiles(directory) {
6
7
  let entries;
@@ -60,7 +61,7 @@ async function contentDocuments(output, config) {
60
61
  return documents;
61
62
  }
62
63
 
63
- function markdownFeatureCoverage(root, files, sourceByFile) {
64
+ async function markdownFeatureCoverage(root, files, sourceByFile) {
64
65
  const expected = [
65
66
  ['headings 2 through 6', /<h2\b[\s\S]*?<h3\b[\s\S]*?<h4\b[\s\S]*?<h5\b[\s\S]*?<h6\b/i],
66
67
  ['Setext heading syntax', /<h2\b[^>]*>Setext (?:heading level two|二级标题示例)<\/h2>/i],
@@ -76,6 +77,7 @@ function markdownFeatureCoverage(root, files, sourceByFile) {
76
77
  ['GFM automatic links', /href="https:\/\/www\.markdownguide\.org\//i],
77
78
  ['angle-bracket, www, and email automatic links', /href="https:\/\/commonmark\.org"[\s\S]*?href="http:\/\/www\.example\.com"[\s\S]*?href="mailto:team@example\.com"/i],
78
79
  ['images with alternative text', /<img\b[^>]*src="\/edgepress-markdown-guide\.svg"[^>]*alt="[^"]+"/i],
80
+ ['image-shaped video Markdown syntax documented', (html) => html.includes('field-recording.mp4')],
79
81
  ['nested lists with inline formatting', /<li>[\s\S]*?<strong\b[\s\S]*?<ol\b[\s\S]*?<li>/i],
80
82
  ['task lists with accessible status names', /markdown-task-status" role="img" aria-label="[^"]+"/i],
81
83
  ['lists nested inside block quotes', /<blockquote>[\s\S]*?<ul\b[\s\S]*?<\/blockquote>/i],
@@ -84,16 +86,32 @@ function markdownFeatureCoverage(root, files, sourceByFile) {
84
86
  ['safe semantic inline HTML', /<kbd>Ctrl<\/kbd>/i],
85
87
  ['sanitization of active script markup', (html) => !/<script\b[^>]*>alert\(/i.test(html)]
86
88
  ];
89
+ const videoMarkdown = '![A kite crossing a field](https://media.example.org/field-recording.mp4 "Wind test")';
90
+ const videoHtml = await renderMarkdown(videoMarkdown);
91
+ const excerptHtml = await renderMarkdownExcerpt('A **formatted** excerpt with _emphasis_ and `code`.');
92
+ let unsafeVideoRejected = false;
93
+ try { await renderMarkdown('![Unsafe video](javascript:alert(1).mp4)'); }
94
+ catch { unsafeVideoRejected = true; }
95
+ const videoChecks = [
96
+ ['image-shaped video syntax renders an accessible native player with deferred media',
97
+ /<video controls playsinline preload="none" aria-label="A kite crossing a field"><source src="https:\/\/media\.example\.org\/field-recording\.mp4" type="video\/mp4"/i.test(videoHtml)],
98
+ ['video title renders as a caption', /<figcaption>Wind test<\/figcaption>/i.test(videoHtml)],
99
+ ['unsafe video URL is rejected', unsafeVideoRejected],
100
+ ['post-list excerpt preserves inline Markdown formatting', /<strong>formatted<\/strong>[\s\S]*?<em>emphasis<\/em>[\s\S]*?<code>code<\/code>/i.test(excerptHtml)]
101
+ ];
87
102
  const guides = files.filter((file) => /markdown-syntax-guide\/index\.html$/i.test(file.replace(/\\/g, '/')));
88
103
  const documents = guides.map((file) => {
89
104
  const html = sourceByFile.get(file) || '';
90
105
  const content = html.match(/<div class="post-content">([\s\S]*?)<\/div>/i)?.[1] || '';
91
- const checks = expected.map(([feature, matcher]) => ({ feature, passed: typeof matcher === 'function' ? matcher(content) : matcher.test(content) }));
106
+ const checks = [
107
+ ...expected.map(([feature, matcher]) => ({ feature, passed: typeof matcher === 'function' ? matcher(content) : matcher.test(content) })),
108
+ ...videoChecks.map(([feature, passed]) => ({ feature, passed }))
109
+ ];
92
110
  return { path: relative(root, file).replace(/\\/g, '/'), checks, passed: checks.filter((item) => item.passed).length };
93
111
  });
94
112
  return {
95
113
  status: guides.length && documents.every((document) => document.checks.every((item) => item.passed)) ? 'pass' : 'fail',
96
- checks: expected.length * Math.max(1, guides.length),
114
+ checks: (expected.length + videoChecks.length) * Math.max(1, guides.length),
97
115
  passed: documents.reduce((total, document) => total + document.passed, 0),
98
116
  documents
99
117
  };
@@ -119,7 +137,12 @@ async function resolveLocalLink(output, pageFile, href) {
119
137
  else candidates.push(resolve(output, clean + '.html'), resolve(output, clean, 'index.html'));
120
138
  for (const candidate of candidates) {
121
139
  if (!candidate.startsWith(output + sep)) continue;
122
- if (await exists(candidate)) return { file: candidate, fragment: url.hash.slice(1) };
140
+ if (await exists(candidate)) {
141
+ let fragment = url.hash.slice(1);
142
+ try { fragment = decodeURIComponent(fragment); }
143
+ catch { return { broken: true, target: href }; }
144
+ return { file: candidate, fragment };
145
+ }
123
146
  }
124
147
  return { broken: true, target: href };
125
148
  }
@@ -161,7 +184,7 @@ export async function checkPages(config) {
161
184
  pageIds.set(file, new Set([...html.matchAll(/\bid\s*=\s*["']([^"']+)["']/gi)].map((match) => match[1])));
162
185
  metrics.htmlBytes += Buffer.byteLength(html);
163
186
  }
164
- const markdownRendering = markdownFeatureCoverage(output, files, sourceByFile);
187
+ const markdownRendering = await markdownFeatureCoverage(output, files, sourceByFile);
165
188
  if (!markdownRendering.documents.length) {
166
189
  addFinding('error', 'markdown-rendering', 'content/posts', 'The Markdown syntax guide post was not generated.');
167
190
  }
@@ -254,13 +277,60 @@ export async function checkPages(config) {
254
277
  const hashedAssets = all.filter((file) => /\.[a-f0-9]{16}\.(?:css|js)$/i.test(file))
255
278
  .map((file) => relative(output, file).split(sep).join('/'));
256
279
  const styleFiles = all.filter((file) => extname(file).toLowerCase() === '.css');
280
+ let styles = '';
257
281
  if (styleFiles.length) {
258
- const styles = (await Promise.all(styleFiles.map((file) => readFile(file, 'utf8')))).join('\n');
282
+ styles = (await Promise.all(styleFiles.map((file) => readFile(file, 'utf8')))).join('\n');
259
283
  inspect('accessibility', !/:focus(?:-visible)?\s*\{[^}]*outline/i.test(styles), 'warning', 'theme', 'No visible keyboard focus outline was found in the generated CSS.');
260
284
  } else {
261
285
  inspect('accessibility', true, 'warning', 'theme', 'No stylesheet was found to inspect for keyboard focus styles.');
262
286
  }
263
287
 
288
+ const consentConfig = config.browserPlugins.consent;
289
+ const consentUiChecks = [];
290
+ if (consentConfig.enabled) {
291
+ const consentCheck = (name, passed) => consentUiChecks.push({ name, passed });
292
+ let managerScript = '';
293
+ const managerAsset = all.find((file) => {
294
+ const path = relative(output, file).split(sep).join('/');
295
+ return /^edgepress\/plugins\/consent\/manager\.[a-f0-9]{16}\.js$/i.test(path);
296
+ });
297
+ if (managerAsset) {
298
+ try { managerScript = await readFile(managerAsset, 'utf8'); }
299
+ catch { /* The missing manager is reported below. */ }
300
+ }
301
+ const htmlWithConsent = files.filter((file) => {
302
+ const html = sourceByFile.get(file) || '';
303
+ return html.includes('edgepress-privacy-config') && /\/edgepress\/plugins\/consent\/manager\.[a-f0-9]{16}\.js/i.test(html);
304
+ });
305
+ consentCheck('privacy controls are injected on every generated page', files.length > 0 && htmlWithConsent.length === files.length);
306
+ consentCheck('consent manager script was generated', Boolean(managerScript));
307
+ consentCheck('service disclosure supports data categories, recipient, and retention',
308
+ managerScript.includes('serviceDataCategories') && managerScript.includes('serviceRecipient') && managerScript.includes('serviceRetention'));
309
+ consentCheck('stored choices are tied to proposed and effective dates',
310
+ managerScript.includes('proposedDate: consent.proposedDate') && managerScript.includes('effectiveDate: consent.effectiveDate'));
311
+ consentCheck('new visitors start with every optional service off', managerScript.includes('checkbox.checked = false'));
312
+ consentCheck('accept and reject controls share the same component styling',
313
+ /\.privacy-panel \.privacy-accept,\.privacy-manager \.privacy-panel \.privacy-reject\s*\{[^}]*background:[^}]*\}/.test(styles));
314
+ const componentClass = /(?:^|[\s>+~])\.privacy-(?:manager|panel|settings-button|close|intro|essential|details|service-preview|preview-item|category|service-settings|service-setting|service-toggle|service-toggle-text|service-disclosures|actions|accept|reject|manage|save|empty|policy-link|controller)(?=$|[^\w-])/;
315
+ let scoped = true;
316
+ for (const match of styles.matchAll(/([^{}]+)\{[^{}]*\}/g)) {
317
+ const selector = match[1].trim();
318
+ if (componentClass.test(selector) && !selector.includes('.privacy-manager')) scoped = false;
319
+ }
320
+ consentCheck('consent component CSS selectors stay within the manager', scoped);
321
+ }
322
+ const consentUi = consentConfig.enabled
323
+ ? {
324
+ status: consentUiChecks.every((item) => item.passed) ? 'pass' : 'fail',
325
+ checks: consentUiChecks.length,
326
+ passed: consentUiChecks.filter((item) => item.passed).length,
327
+ results: consentUiChecks
328
+ }
329
+ : { status: 'not enabled', checks: 0, passed: 0, results: [] };
330
+ for (const check of consentUiChecks.filter((item) => !item.passed)) {
331
+ issues.push({ severity: 'error', category: 'consent-ui', page: 'privacy manager', message: 'Consent UI requirement failed: ' + check.name + '.' });
332
+ }
333
+
264
334
  let robots = '';
265
335
  let sitemap = '';
266
336
  let llms = '';
@@ -314,6 +384,7 @@ export async function checkPages(config) {
314
384
  contentSummary,
315
385
  documents,
316
386
  markdownRendering,
387
+ consentUi,
317
388
  errors,
318
389
  warnings,
319
390
  accessibility,
@@ -323,6 +394,7 @@ export async function checkPages(config) {
323
394
  issues,
324
395
  scope: [
325
396
  'Accessibility checks inspect generated HTML and CSS. They do not measure color contrast, screen-reader behavior, keyboard interactions in a browser, or dynamic vendor widgets.',
397
+ 'Page layout is measured with the consent manager hidden. The consent manager has separate component checks and screenshots, so its size does not change the page-content overflow result.',
326
398
  'Agent-friendliness checks inspect metadata, structured data, internal links, robots.txt, sitemap.xml, and llms.txt. They do not guarantee crawler indexing or answer quality.',
327
399
  'This automated report is not a legal-compliance assessment or a substitute for manual review.',
328
400
  'Screenshots capture generated local pages at desktop and mobile viewports. They do not replace manual keyboard, zoom, contrast, or assistive-technology review.'
@@ -337,24 +409,6 @@ export async function checkPages(config) {
337
409
  const root = await realpath(config.root);
338
410
  const directory = await realpath(requestedDirectory);
339
411
  if (!isInside(root, directory)) throw new Error('Page-check report directory must stay inside the project root');
340
- for (const name of ['page-check.json', 'page-check.md', 'page-check-visual.html']) {
341
- const file = resolve(directory, name);
342
- try {
343
- const info = await lstat(file);
344
- if (info.isSymbolicLink() || !info.isFile()) throw new Error('Refusing an unsafe legacy report path: ' + file);
345
- await rm(file);
346
- } catch (error) {
347
- if (error.code !== 'ENOENT') throw error;
348
- }
349
- }
350
- const screenshotsDirectory = resolve(directory, 'page-check-screenshots');
351
- try {
352
- const info = await lstat(screenshotsDirectory);
353
- if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('Refusing an unsafe legacy screenshots path: ' + screenshotsDirectory);
354
- await rm(screenshotsDirectory, { recursive: true });
355
- } catch (error) {
356
- if (error.code !== 'ENOENT') throw error;
357
- }
358
412
  const pdfPath = resolve(directory, 'page-check.pdf');
359
413
  try {
360
414
  const info = await lstat(pdfPath);