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.
- package/README.md +5 -3
- package/config.yml +121 -69
- package/content/pages/home/en.md +15 -0
- package/content/pages/home/zh-cn.md +16 -1
- package/content/pages/plugin-development/en.md +5 -4
- package/content/pages/plugin-development/zh-cn.md +5 -4
- package/content/pages/privacy-policy/en.md +8 -5
- package/content/pages/privacy-policy/zh-cn.md +8 -5
- package/content/pages/theme-development/en.md +24 -0
- package/content/pages/theme-development/zh-cn.md +24 -0
- package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +12 -2
- package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +12 -2
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +2 -0
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-2/en.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-2/zh-cn.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-3/en.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-3/zh-cn.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-4/en.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-4/zh-cn.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-5/en.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-5/zh-cn.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-6/en.md +31 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-6/zh-cn.md +31 -0
- package/content/posts/2026-09-26-edgepress-release-2026-9-7/en.md +26 -0
- package/content/posts/2026-09-26-edgepress-release-2026-9-7/zh-cn.md +26 -0
- package/content/posts/2026-09-26-edgepress-release-2026-9-8/en.md +26 -0
- package/content/posts/2026-09-26-edgepress-release-2026-9-8/zh-cn.md +26 -0
- package/languages/base/en.json +9 -2
- package/languages/packs/zh-CN.json +9 -2
- package/package.json +2 -1
- package/plugins/consent/index.js +4 -2
- package/project-compatibility.json +26 -2
- package/src/build.js +5 -4
- package/src/cli.js +8 -13
- package/src/config.js +72 -14
- package/src/content.js +55 -1
- package/src/generators.js +125 -23
- package/src/i18n.js +6 -0
- package/src/icons.js +16 -0
- package/src/maintenance.js +0 -27
- package/src/markdown.js +88 -9
- package/src/page-blocks.js +84 -18
- package/src/page-check.js +79 -25
- package/src/report-visuals.js +111 -16
- package/src/theme.js +13 -2
- package/static/edgepress/brand/edgepress-mark.svg +7 -0
- package/static/edgepress/brand/mascot-209.webp +0 -0
- package/static/edgepress/brand/mascot-418.png +0 -0
- package/static/edgepress/brand/mascot-418.webp +0 -0
- package/static/edgepress/brand/mascot.png +0 -0
- package/static/edgepress/brand/og-image.png +0 -0
- package/static/edgepress/brand/og-image.svg +46 -0
- package/static/edgepress/code-copy.js +55 -0
- package/static/edgepress/icons.svg +24 -0
- package/static/edgepress/plugins/consent/manager.js +204 -71
- package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +1 -0
- package/themes/atelier/assets/style.css +98 -20
- package/themes/atelier/layouts/partials/head.html +5 -0
- package/themes/atelier/layouts/partials/header.html +1 -1
- package/themes/default/assets/style.css +99 -20
- package/themes/default/layouts/partials/head.html +3 -0
- package/themes/default/layouts/partials/header.html +1 -1
- package/themes/signal/assets/style.css +98 -20
- package/themes/signal/layouts/partials/head.html +5 -0
- 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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
}
|
package/src/page-blocks.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
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 + ' · ' +
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
69
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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="
|
|
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
|
-
|
|
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>' +
|
|
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
|
|
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>' +
|
|
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
|
-
|
|
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
|
|
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 = '';
|
|
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('.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 =
|
|
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))
|
|
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
|
-
|
|
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);
|