edgepress 2026.9.6 → 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 +2 -2
- package/config.yml +110 -63
- package/content/pages/home/en.md +9 -1
- package/content/pages/home/zh-cn.md +9 -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 +7 -4
- package/content/pages/privacy-policy/zh-cn.md +7 -4
- package/content/pages/theme-development/en.md +1 -0
- package/content/pages/theme-development/zh-cn.md +1 -0
- package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +9 -1
- package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +9 -1
- package/content/posts/2026-09-25-edgepress-release-2026-9-6/en.md +2 -2
- package/content/posts/2026-09-25-edgepress-release-2026-9-6/zh-cn.md +2 -2
- 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 +7 -2
- package/languages/packs/zh-CN.json +7 -2
- package/package.json +2 -3
- package/plugins/consent/index.js +4 -2
- package/project-compatibility.json +19 -3
- package/src/build.js +5 -4
- package/src/cli.js +1 -11
- package/src/config.js +72 -14
- package/src/content.js +22 -1
- package/src/generators.js +25 -20
- package/src/i18n.js +6 -0
- package/src/markdown.js +88 -9
- package/src/page-blocks.js +61 -16
- package/src/page-check.js +73 -24
- package/src/report-visuals.js +111 -16
- 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/plugins/consent/manager.js +204 -71
- package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +1 -0
- package/themes/atelier/assets/style.css +50 -19
- package/themes/default/assets/style.css +50 -19
- package/themes/signal/assets/style.css +50 -19
- package/CHANGELOG.txt +0 -12
package/src/page-blocks.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { translate } from './i18n.js';
|
|
1
|
+
import { renderMarkdownExcerpt } from './markdown.js';
|
|
2
|
+
import { translate, translateValue } from './i18n.js';
|
|
3
3
|
import { isIconName, renderIcon } from './icons.js';
|
|
4
|
+
import { postsForLocale } from './content.js';
|
|
4
5
|
|
|
5
6
|
const captchaProviders = new Set(['cloudflare-turnstile', 'google-recaptcha', 'hcaptcha']);
|
|
6
7
|
|
|
@@ -33,6 +34,20 @@ function list(value, label, min = 1, max = 50) {
|
|
|
33
34
|
return value;
|
|
34
35
|
}
|
|
35
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
|
+
|
|
36
51
|
function enabledIntegration(config, id, category) {
|
|
37
52
|
const section = config.browserPlugins[category];
|
|
38
53
|
const match = section?.enabled && section.services.find((item) => item.id === id);
|
|
@@ -44,6 +59,10 @@ function dateLabel(date, locale) {
|
|
|
44
59
|
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(date);
|
|
45
60
|
}
|
|
46
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
|
+
|
|
47
66
|
function renderPrivacyServices(context) {
|
|
48
67
|
const groups = [
|
|
49
68
|
['tracking', translate(context.config, context.locale, 'pluginTracking')],
|
|
@@ -58,25 +77,33 @@ function renderPrivacyServices(context) {
|
|
|
58
77
|
if (!configured.length) {
|
|
59
78
|
return '<p class="privacy-empty">' + escapeHtml(text(context.emptyText, 'privacy-services.emptyText', 2000)) + '</p>';
|
|
60
79
|
}
|
|
61
|
-
return '<ul class="privacy-service-list">' + configured.map((service) => '<li><h3>' + escapeHtml(service.category + ' · ' +
|
|
62
|
-
|
|
63
|
-
|
|
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>';
|
|
64
90
|
}
|
|
65
91
|
|
|
66
|
-
function renderLatestPosts(block, context) {
|
|
92
|
+
async function renderLatestPosts(block, context) {
|
|
67
93
|
const count = block.count ?? 3;
|
|
68
94
|
if (!Number.isInteger(count) || count < 1 || count > 12) throw new Error('latest-posts.count must be an integer from 1 to 12');
|
|
69
95
|
const paginate = block.paginate ?? false;
|
|
70
96
|
if (typeof paginate !== 'boolean') throw new Error('latest-posts.paginate must be a boolean');
|
|
71
|
-
const posts = context.site.posts
|
|
72
|
-
(!context.site.posts.some((item) => item.locale === context.locale) && post.locale === context.config.i18n.defaultLocale))
|
|
73
|
-
.sort((a, b) => b.date - a.date);
|
|
97
|
+
const posts = postsForLocale(context.site.posts, context.locale, context.config.i18n.defaultLocale);
|
|
74
98
|
const title = escapeHtml(text(block.title, 'latest-posts.title', 200));
|
|
75
99
|
const authorLabel = (post) => post.author ? '<span class="post-author">' + escapeHtml(translate(context.config, context.locale, 'postAuthor')
|
|
76
100
|
.replace('{author}', post.author)) + '</span>' : '';
|
|
77
|
-
const cards = posts.slice(0, count).map((post) =>
|
|
78
|
-
|
|
79
|
-
'
|
|
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('');
|
|
80
107
|
const pagination = paginate && context.latestPostsPagination?.totalPages > 1
|
|
81
108
|
? '<nav class="pagination latest-posts-pagination" aria-label="' + escapeHtml(translate(context.config, context.locale, 'pagination')) + '">' +
|
|
82
109
|
'<a rel="next" href="' + escapeHtml(context.latestPostsPagination.olderUrl) + '">' + escapeHtml(translate(context.config, context.locale, 'older')) + '</a>' +
|
|
@@ -132,6 +159,19 @@ async function renderBlock(block, context, depth, index) {
|
|
|
132
159
|
const mascotSrc = block.mascotSrc === undefined ? '' : safeUrl(block.mascotSrc, 'hero.mascotSrc');
|
|
133
160
|
const mascotAlt = block.mascotAlt === undefined ? '' : text(block.mascotAlt, 'hero.mascotAlt', 500);
|
|
134
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)) : '';
|
|
135
175
|
let action = '';
|
|
136
176
|
if (block.cta) {
|
|
137
177
|
const label = escapeHtml(text(block.cta.label, 'hero.cta.label', 120));
|
|
@@ -141,8 +181,11 @@ async function renderBlock(block, context, depth, index) {
|
|
|
141
181
|
const highlights = block.highlights === undefined ? [] : list(block.highlights, 'hero.highlights', 1, 4);
|
|
142
182
|
const highlightPanel = highlights.length ? '<ul class="hero-highlights">' + highlights.map((item) => '<li>' +
|
|
143
183
|
escapeHtml(text(item, 'hero highlight', 180)) + '</li>').join('') + '</ul>' : '';
|
|
144
|
-
const
|
|
145
|
-
escapeHtml(mascotAlt) + '" width="
|
|
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;
|
|
146
189
|
const aside = mascot || highlightPanel ? '<div class="hero-aside">' + mascot + highlightPanel + '</div>' : '';
|
|
147
190
|
return '<section class="hero-block' + (aside ? ' hero-block--split' : '') + '"><div class="hero-main">' +
|
|
148
191
|
(eyebrow ? '<p class="eyebrow">' + escapeHtml(eyebrow) + '</p>' : '') + '<' + level + '>' + escapeHtml(heading) + '</' + level + '>' +
|
|
@@ -258,13 +301,15 @@ async function renderBlock(block, context, depth, index) {
|
|
|
258
301
|
case 'privacy-consent': {
|
|
259
302
|
const storageLabel = escapeHtml(text(block.storageLabel, 'privacy-consent.storageLabel', 120));
|
|
260
303
|
const expiryLabel = escapeHtml(text(block.expiryLabel, 'privacy-consent.expiryLabel', 120));
|
|
261
|
-
const
|
|
304
|
+
const proposedDateLabel = escapeHtml(text(block.proposedDateLabel, 'privacy-consent.proposedDateLabel', 120));
|
|
305
|
+
const effectiveDateLabel = escapeHtml(text(block.effectiveDateLabel, 'privacy-consent.effectiveDateLabel', 120));
|
|
262
306
|
const expiryText = text(block.expiryText, 'privacy-consent.expiryText', 120);
|
|
263
307
|
if (!expiryText.includes('{days}')) throw new Error('privacy-consent.expiryText must include a {days} placeholder');
|
|
264
308
|
const consent = context.config.browserPlugins.consent;
|
|
265
309
|
return '<dl class="privacy-controller-details"><div><dt>' + storageLabel + '</dt><dd>edgepress-privacy-choice</dd></div>' +
|
|
266
310
|
'<div><dt>' + expiryLabel + '</dt><dd>' + escapeHtml(expiryText.replace('{days}', String(consent.expiresDays))) + '</dd></div>' +
|
|
267
|
-
'<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>';
|
|
268
313
|
}
|
|
269
314
|
case 'privacy-controller': {
|
|
270
315
|
const controller = context.config.privacy.controller;
|
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
|
};
|
|
@@ -166,7 +184,7 @@ export async function checkPages(config) {
|
|
|
166
184
|
pageIds.set(file, new Set([...html.matchAll(/\bid\s*=\s*["']([^"']+)["']/gi)].map((match) => match[1])));
|
|
167
185
|
metrics.htmlBytes += Buffer.byteLength(html);
|
|
168
186
|
}
|
|
169
|
-
const markdownRendering = markdownFeatureCoverage(output, files, sourceByFile);
|
|
187
|
+
const markdownRendering = await markdownFeatureCoverage(output, files, sourceByFile);
|
|
170
188
|
if (!markdownRendering.documents.length) {
|
|
171
189
|
addFinding('error', 'markdown-rendering', 'content/posts', 'The Markdown syntax guide post was not generated.');
|
|
172
190
|
}
|
|
@@ -259,13 +277,60 @@ export async function checkPages(config) {
|
|
|
259
277
|
const hashedAssets = all.filter((file) => /\.[a-f0-9]{16}\.(?:css|js)$/i.test(file))
|
|
260
278
|
.map((file) => relative(output, file).split(sep).join('/'));
|
|
261
279
|
const styleFiles = all.filter((file) => extname(file).toLowerCase() === '.css');
|
|
280
|
+
let styles = '';
|
|
262
281
|
if (styleFiles.length) {
|
|
263
|
-
|
|
282
|
+
styles = (await Promise.all(styleFiles.map((file) => readFile(file, 'utf8')))).join('\n');
|
|
264
283
|
inspect('accessibility', !/:focus(?:-visible)?\s*\{[^}]*outline/i.test(styles), 'warning', 'theme', 'No visible keyboard focus outline was found in the generated CSS.');
|
|
265
284
|
} else {
|
|
266
285
|
inspect('accessibility', true, 'warning', 'theme', 'No stylesheet was found to inspect for keyboard focus styles.');
|
|
267
286
|
}
|
|
268
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
|
+
|
|
269
334
|
let robots = '';
|
|
270
335
|
let sitemap = '';
|
|
271
336
|
let llms = '';
|
|
@@ -319,6 +384,7 @@ export async function checkPages(config) {
|
|
|
319
384
|
contentSummary,
|
|
320
385
|
documents,
|
|
321
386
|
markdownRendering,
|
|
387
|
+
consentUi,
|
|
322
388
|
errors,
|
|
323
389
|
warnings,
|
|
324
390
|
accessibility,
|
|
@@ -328,6 +394,7 @@ export async function checkPages(config) {
|
|
|
328
394
|
issues,
|
|
329
395
|
scope: [
|
|
330
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.',
|
|
331
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.',
|
|
332
399
|
'This automated report is not a legal-compliance assessment or a substitute for manual review.',
|
|
333
400
|
'Screenshots capture generated local pages at desktop and mobile viewports. They do not replace manual keyboard, zoom, contrast, or assistive-technology review.'
|
|
@@ -342,24 +409,6 @@ export async function checkPages(config) {
|
|
|
342
409
|
const root = await realpath(config.root);
|
|
343
410
|
const directory = await realpath(requestedDirectory);
|
|
344
411
|
if (!isInside(root, directory)) throw new Error('Page-check report directory must stay inside the project root');
|
|
345
|
-
for (const name of ['page-check.json', 'page-check.md', 'page-check-visual.html']) {
|
|
346
|
-
const file = resolve(directory, name);
|
|
347
|
-
try {
|
|
348
|
-
const info = await lstat(file);
|
|
349
|
-
if (info.isSymbolicLink() || !info.isFile()) throw new Error('Refusing an unsafe legacy report path: ' + file);
|
|
350
|
-
await rm(file);
|
|
351
|
-
} catch (error) {
|
|
352
|
-
if (error.code !== 'ENOENT') throw error;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
const screenshotsDirectory = resolve(directory, 'page-check-screenshots');
|
|
356
|
-
try {
|
|
357
|
-
const info = await lstat(screenshotsDirectory);
|
|
358
|
-
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('Refusing an unsafe legacy screenshots path: ' + screenshotsDirectory);
|
|
359
|
-
await rm(screenshotsDirectory, { recursive: true });
|
|
360
|
-
} catch (error) {
|
|
361
|
-
if (error.code !== 'ENOENT') throw error;
|
|
362
|
-
}
|
|
363
412
|
const pdfPath = resolve(directory, 'page-check.pdf');
|
|
364
413
|
try {
|
|
365
414
|
const info = await lstat(pdfPath);
|
package/src/report-visuals.js
CHANGED
|
@@ -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
|
-
|
|
132
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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="
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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: '
|
|
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);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|