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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/config.yml +110 -63
  3. package/content/pages/home/en.md +9 -1
  4. package/content/pages/home/zh-cn.md +9 -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 +7 -4
  8. package/content/pages/privacy-policy/zh-cn.md +7 -4
  9. package/content/pages/theme-development/en.md +1 -0
  10. package/content/pages/theme-development/zh-cn.md +1 -0
  11. package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +9 -1
  12. package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +9 -1
  13. package/content/posts/2026-09-25-edgepress-release-2026-9-6/en.md +2 -2
  14. package/content/posts/2026-09-25-edgepress-release-2026-9-6/zh-cn.md +2 -2
  15. package/content/posts/2026-09-26-edgepress-release-2026-9-7/en.md +26 -0
  16. package/content/posts/2026-09-26-edgepress-release-2026-9-7/zh-cn.md +26 -0
  17. package/content/posts/2026-09-26-edgepress-release-2026-9-8/en.md +26 -0
  18. package/content/posts/2026-09-26-edgepress-release-2026-9-8/zh-cn.md +26 -0
  19. package/languages/base/en.json +7 -2
  20. package/languages/packs/zh-CN.json +7 -2
  21. package/package.json +2 -3
  22. package/plugins/consent/index.js +4 -2
  23. package/project-compatibility.json +19 -3
  24. package/src/build.js +5 -4
  25. package/src/cli.js +1 -11
  26. package/src/config.js +72 -14
  27. package/src/content.js +22 -1
  28. package/src/generators.js +25 -20
  29. package/src/i18n.js +6 -0
  30. package/src/markdown.js +88 -9
  31. package/src/page-blocks.js +61 -16
  32. package/src/page-check.js +73 -24
  33. package/src/report-visuals.js +111 -16
  34. package/static/edgepress/brand/mascot-209.webp +0 -0
  35. package/static/edgepress/brand/mascot-418.png +0 -0
  36. package/static/edgepress/brand/mascot-418.webp +0 -0
  37. package/static/edgepress/plugins/consent/manager.js +204 -71
  38. package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +1 -0
  39. package/themes/atelier/assets/style.css +50 -19
  40. package/themes/default/assets/style.css +50 -19
  41. package/themes/signal/assets/style.css +50 -19
  42. package/CHANGELOG.txt +0 -12
@@ -1,6 +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
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 + ' · ' + service.provider) +
62
- '</h3><p><strong>' + escapeHtml(translate(context.config, context.locale, 'servicePurpose')) + ':</strong> ' + escapeHtml(service.purpose) +
63
- '</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>';
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.filter((post) => post.locale === context.locale ||
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) => '<article class="post-card"><h3><a href="' + escapeHtml('/' + post.path.split('/').filter(Boolean).join('/') + (post.path.endsWith('/') ? '/' : '')) + '">' +
78
- escapeHtml(post.title) + '</a></h3><p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, context.locale)) +
79
- '</time>' + (post.author ? ' · ' + authorLabel(post) : '') + '</p><p>' + escapeHtml(post.description || plainText(post.markdown).slice(0, 220)) + '</p></article>').join('');
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 mascot = mascotSrc ? '<img class="hero-mascot" src="' + escapeHtml(mascotSrc) + '" alt="' +
145
- escapeHtml(mascotAlt) + '" width="1222" height="1287" decoding="async">' : '';
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 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));
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>' + 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>';
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, 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
  };
@@ -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
- 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');
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);
@@ -127,13 +127,42 @@ async function startHeadlessBrowser(executable, profile) {
127
127
  await withTimeout(loaded, 20000, 'Screenshot page load timed out');
128
128
  await send('Runtime.evaluate', { expression: 'new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 150)))', awaitPromise: true });
129
129
  };
130
- const screenshot = async () => {
131
- const result = await send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false });
132
- return Buffer.from(result.data, 'base64');
130
+ const screenshot = async ({ hideConsent = false, clipSelector = '' } = {}) => {
131
+ let previousDisplay;
132
+ if (hideConsent) {
133
+ const hidden = await send('Runtime.evaluate', {
134
+ expression: '(() => { const node = document.querySelector("[data-consent-ui]"); if (!node) return null; const old = node.style.display; node.style.display = "none"; return old; })()',
135
+ returnByValue: true
136
+ });
137
+ previousDisplay = hidden.result.value;
138
+ }
139
+ try {
140
+ let clip;
141
+ if (clipSelector) {
142
+ const measured = await send('Runtime.evaluate', {
143
+ expression: '(() => { const node = document.querySelector("' + clipSelector + '"); if (!node) return null; const rect = node.getBoundingClientRect(); return JSON.stringify({x:Math.max(0,rect.left),y:Math.max(0,rect.top),width:Math.min(window.innerWidth,rect.right)-Math.max(0,rect.left),height:Math.min(window.innerHeight,rect.bottom)-Math.max(0,rect.top)}); })()',
144
+ returnByValue: true
145
+ });
146
+ if (!measured.result.value) return null;
147
+ const rect = JSON.parse(measured.result.value);
148
+ if (rect.width <= 0 || rect.height <= 0) return null;
149
+ clip = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scale: 1 };
150
+ }
151
+ const result = await send('Page.captureScreenshot', {
152
+ format: 'png', fromSurface: true, captureBeyondViewport: false, ...(clip ? { clip } : {})
153
+ });
154
+ return Buffer.from(result.data, 'base64');
155
+ } finally {
156
+ if (hideConsent && previousDisplay !== undefined && previousDisplay !== null) {
157
+ await send('Runtime.evaluate', {
158
+ expression: '(() => { const node = document.querySelector("[data-consent-ui]"); if (node) node.style.display = ' + JSON.stringify(previousDisplay) + '; })()'
159
+ });
160
+ }
161
+ }
133
162
  };
134
163
  const metrics = async () => {
135
164
  const result = await send('Runtime.evaluate', {
136
- expression: 'JSON.stringify({viewportWidth:window.innerWidth,documentWidth:document.documentElement.scrollWidth})',
165
+ expression: '(() => { const manager = document.querySelector("[data-consent-ui]"); const oldDisplay = manager?.style.display; if (manager) manager.style.display = "none"; const pageDocumentWidth = Math.max(document.documentElement.scrollWidth,document.body?.scrollWidth || 0); if (manager) manager.style.display = oldDisplay; const panel = manager?.querySelector(".privacy-panel"); const rect = manager?.getBoundingClientRect(); const panelRect = panel?.getBoundingClientRect(); const buttons = [...(panel?.querySelectorAll("button") || [])]; const buttonsFit = !panelRect || buttons.every(button => { const r = button.getBoundingClientRect(); return r.left >= panelRect.left - 1 && r.right <= panelRect.right + 1; }); const consentInnerNoOverflow = !panel || (panel.scrollWidth <= panel.clientWidth + 1 && buttonsFit); const consentWithinViewport = !rect || (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1); const accept = panel?.querySelector(".privacy-accept"); const reject = panel?.querySelector(".privacy-reject"); const ar = accept?.getBoundingClientRect(); const rr = reject?.getBoundingClientRect(); const consentActionsBalanced = Boolean(accept && reject && Math.abs(ar.width-rr.width)<1 && Math.abs(ar.height-rr.height)<1 && getComputedStyle(accept).backgroundColor===getComputedStyle(reject).backgroundColor); const switches = [...(panel?.querySelectorAll(".privacy-service-toggle input") || [])]; const consentOptInDefault = switches.every(input => !input.checked); const serviceCards = [...(panel?.querySelectorAll(".privacy-service-setting") || [])]; const consentServiceDisclosures = serviceCards.length===0 || serviceCards.every(card => card.querySelectorAll(".privacy-service-disclosures dt").length>=4); const consentDetails = Boolean(panel?.querySelector(".privacy-details>summary")); return JSON.stringify({viewportWidth:innerWidth,documentWidth:document.documentElement.scrollWidth,pageDocumentWidth,consentExists:Boolean(manager),consentWithinViewport,consentInnerNoOverflow,consentActionsBalanced,consentOptInDefault,consentServiceDisclosures,consentDetails,consentWidth:rect?.width || 0,consentHeight:rect?.height || 0}); })()',
137
166
  returnByValue: true
138
167
  });
139
168
  return JSON.parse(result.result.value);
@@ -189,7 +218,11 @@ const mimeTypes = {
189
218
  function reportHtml(report, screenshots) {
190
219
  const documents = report.documents || [];
191
220
  const reportReading = report.visualEvidence?.reportAccessibility || { status: 'pending', checks: 0, passed: 0 };
192
- const typeLabel = (type) => ({ post: 'Posts', page: 'Pages', homepage: 'Homepages', system: 'System routes' })[type] || type;
221
+ const consentChecks = report.consentUi?.checks || 0;
222
+ const consentPassed = report.consentUi?.passed || 0;
223
+ const consentErrors = Math.max(0, consentChecks - consentPassed);
224
+ const consentScore = consentChecks ? Math.floor(consentPassed / consentChecks * 100) + '%' : '—';
225
+ const typeLabel = (type) => ({ post: 'Posts', page: 'Pages', homepage: 'Homepages', system: 'System routes', consent: 'Consent interface' })[type] || type;
193
226
  const statusTable = '<table><caption>Audit results</caption><thead><tr><th scope="col">Audit</th><th scope="col">Status</th>' +
194
227
  '<th scope="col">Score</th><th scope="col">Checks</th><th scope="col">Errors</th><th scope="col">Warnings</th></tr></thead><tbody>' +
195
228
  '<tr><th scope="row">Accessibility</th><td>' + escapeHtml(report.accessibility.status) + '</td><td>' + report.accessibility.score +
@@ -198,6 +231,8 @@ function reportHtml(report, screenshots) {
198
231
  '%</td><td>' + report.agentFriendliness.checks + '</td><td>' + report.agentFriendliness.errors + '</td><td>' + report.agentFriendliness.warnings + '</td></tr>' +
199
232
  '<tr><th scope="row">Markdown rendering</th><td>' + escapeHtml(report.markdownRendering?.status || 'not run') + '</td><td>—</td><td>' +
200
233
  (report.markdownRendering?.checks || 0) + '</td><td>' + ((report.markdownRendering?.checks || 0) - (report.markdownRendering?.passed || 0)) + '</td><td>0</td></tr>' +
234
+ '<tr><th scope="row">Consent interface</th><td>' + escapeHtml(report.consentUi?.status || 'not checked') + '</td><td>' + consentScore +
235
+ '</td><td>' + consentChecks + '</td><td>' + consentErrors + '</td><td>0</td></tr>' +
201
236
  '<tr><th scope="row">Report reading structure</th><td>' + escapeHtml(reportReading.status) + '</td><td>' +
202
237
  Math.floor(reportReading.passed / Math.max(1, reportReading.checks) * 100) + '%</td><td>' + reportReading.checks +
203
238
  '</td><td>' + (reportReading.checks - reportReading.passed) + '</td><td>0</td></tr>' +
@@ -217,11 +252,13 @@ function reportHtml(report, screenshots) {
217
252
  ? '<ul>' + report.issues.map((item) => '<li><strong>' + escapeHtml(item.severity + ' · ' + item.category) +
218
253
  '</strong> ' + escapeHtml(item.page) + ' — ' + escapeHtml(item.message) + '</li>').join('') + '</ul>'
219
254
  : '<p>No findings.</p>';
220
- const screenshotGroups = ['post', 'page', 'homepage', 'system'].map((type) => {
255
+ const screenshotGroups = ['post', 'page', 'homepage', 'system', 'consent'].map((type) => {
221
256
  const items = screenshots.filter((item) => item.type === type);
222
257
  if (!items.length) return '';
223
258
  return '<section><h3>' + typeLabel(type) + ' screenshots</h3>' + items.map((item) => {
224
- const dimensions = item.viewportWidth + ' CSS pixels wide; document width ' + item.documentWidth + ' CSS pixels';
259
+ const dimensions = item.capture === 'consent'
260
+ ? 'Consent component ' + Math.round(item.consentWidth || 0) + ' by ' + Math.round(item.consentHeight || 0) + ' CSS pixels; within viewport: ' + Boolean(item.consentWithinViewport)
261
+ : item.viewportWidth + ' CSS pixels wide; page content width ' + item.documentWidth + ' CSS pixels with consent hidden';
225
262
  const alt = 'Screenshot of ' + (item.title || item.page) + ' at the ' + item.viewport + ' viewport, ' +
226
263
  item.width + ' by ' + item.height + ' pixels; ' + dimensions + '.';
227
264
  return '<h4>' + escapeHtml(item.title || item.page) + ' · ' + escapeHtml(item.viewport) + '</h4><figure><img src="./page-check-screenshots/' +
@@ -232,12 +269,15 @@ function reportHtml(report, screenshots) {
232
269
  }).join('');
233
270
  const markdownDetails = (report.markdownRendering?.documents || []).map((document) => '<section><h3>' + escapeHtml(document.path) +
234
271
  '</h4><ul>' + document.checks.map((item) => '<li>' + (item.passed ? 'Pass' : 'Fail') + ' — ' + escapeHtml(item.feature) + '</li>').join('') + '</ul></section>').join('');
272
+ const consentDetails = [...(report.consentUi?.results || []), ...(report.consentUi?.visualChecks || [])]
273
+ .map((item) => '<li>' + (item.passed ? 'Pass' : 'Fail') + ' — ' + escapeHtml(item.name ||
274
+ (item.page + ' at ' + item.viewport + ' viewport')) + '</li>').join('');
235
275
  return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
236
276
  '<title>EdgePress accessibility and content report</title><style>@page{size:A4;margin:15mm}*{box-sizing:border-box}html{scroll-behavior:smooth}body{font:12px/1.55 Arial,sans-serif;color:#202520;background:#fff;margin:0 auto;padding:16px;max-width:1100px}header,main,footer{display:block}h1{font-size:25px;line-height:1.2}h2{font-size:19px;margin:26px 0 8px}h3{font-size:15px;margin:20px 0 8px}h4{font-size:12px;margin:16px 0 6px}p{margin:6px 0}a{color:#174c35;text-decoration:underline;text-underline-offset:2px}a:focus-visible{outline:3px solid #9a481e;outline-offset:3px}.skip-link{position:absolute;left:-10000px;top:auto}.skip-link:focus{left:16px;top:16px;padding:8px;background:#fff;border:2px solid #174c35;z-index:2}.report-nav ul{display:flex;gap:18px;flex-wrap:wrap;padding-left:20px}table{width:100%;border-collapse:collapse;margin:10px 0 20px;table-layout:fixed}caption{text-align:left;font-weight:bold;padding:0 0 6px}th,td{padding:7px;border:1px solid #879188;text-align:left;vertical-align:top;overflow-wrap:anywhere}thead th{background:#e9eee9}tbody th{background:#f5f6f3}li{margin:4px 0;overflow-wrap:anywhere}code{overflow-wrap:anywhere}.status{font-weight:bold;text-transform:uppercase}.appendix{break-before:page}figure{margin:0 0 20px;break-inside:avoid}figure img{display:block;max-width:100%;max-height:220mm;width:auto;height:auto;margin:0 auto;border:1px solid #879188;object-fit:contain}figcaption{font-size:10px;color:#39443b;margin-top:5px}section{break-inside:auto}footer{border-top:1px solid #879188;margin-top:24px;padding-top:10px}@media(max-width:600px){body{padding:12px}.report-nav ul{display:block}table{font-size:11px}th,td{padding:5px}}@media print{body{max-width:none;padding:0}.report-nav{display:none}a{color:#202520}.appendix{break-before:page}h2,h3,h4{break-after:avoid}figure{break-inside:avoid}}</style></head>' +
237
277
  '<body><a class="skip-link" href="#main">Skip to report</a><header><h1>EdgePress accessibility and content report</h1><p>Generated ' +
238
278
  escapeHtml(report.generatedAt) + ' · Overall status: <span class="status">' + escapeHtml(report.status) + '</span>.</p>' +
239
279
  '<nav class="report-nav" aria-label="Report sections"><ul><li><a href="#summary">Summary</a></li><li><a href="#content">Content reviewed</a></li>' +
240
- '<li><a href="#findings">Findings</a></li><li><a href="#markdown">Markdown rendering</a></li><li><a href="#evidence">Screenshots</a></li><li><a href="#scope">Scope</a></li></ul></nav></header>' +
280
+ '<li><a href="#findings">Findings</a></li><li><a href="#markdown">Markdown rendering</a></li><li><a href="#consent">Consent interface</a></li><li><a href="#evidence">Screenshots</a></li><li><a href="#scope">Scope</a></li></ul></nav></header>' +
241
281
  '<main id="main"><section id="summary"><h2>Audit summary</h2>' + statusTable + summary + '<p>Generated HTML documents: ' + report.pages +
242
282
  ' · HTML bytes: ' + report.metrics.htmlBytes + ' · Stylesheet references: ' + report.metrics.stylesheets +
243
283
  ' · Script references: ' + report.metrics.scripts + '.</p></section>' +
@@ -245,7 +285,9 @@ function reportHtml(report, screenshots) {
245
285
  documentSections + '</section><section id="findings"><h2>Findings</h2>' + findings + '</section>' +
246
286
  '<section id="markdown"><h2>Markdown rendering verification</h2><p>' + escapeHtml(String(report.markdownRendering?.passed || 0)) + ' of ' +
247
287
  escapeHtml(String(report.markdownRendering?.checks || 0)) + ' syntax checks passed across the tutorial post locales.</p>' + markdownDetails + '</section>' +
248
- '<section id="evidence" class="appendix"><h2>Responsive page screenshots</h2><p>Mobile screenshots use a 390 by 844 CSS pixel viewport. Each caption includes the measured document width so horizontal overflow is visible in the report.</p>' +
288
+ '<section id="consent"><h2>Consent interface checks</h2><p>Consent component checks run separately from page-content layout checks.</p>' +
289
+ (consentDetails ? '<ul>' + consentDetails + '</ul>' : '<p>No consent UI checks were run.</p>') + '</section>' +
290
+ '<section id="evidence" class="appendix"><h2>Responsive and consent screenshots</h2><p>Page screenshots use a 390 by 844 CSS pixel mobile viewport and hide the consent component while measuring page content. Separate consent screenshots crop the component at desktop and mobile sizes.</p>' +
249
291
  screenshotGroups + '</section><section id="scope"><h2>Scope and limits</h2><ul>' + report.scope.map((item) => '<li>' + escapeHtml(item) + '</li>').join('') +
250
292
  '</ul></section></main><footer><p>EdgePress local PDF report. Screenshot details are included as evidence and may be small when printed.</p></footer></body></html>';
251
293
  }
@@ -407,11 +449,25 @@ export async function addVisualEvidence(config, report) {
407
449
  const digest = createHash('sha256').update(relativeFile + ':' + viewport.name).digest('hex').slice(0, 10);
408
450
  const file = 'page-' + digest + '.png';
409
451
  const target = resolve(evidenceDirectory, file);
410
- screenshots.push({ page: relativeFile, type: document.type, title: document.title, viewport: viewport.name, width: viewport.width,
452
+ screenshots.push({ page: relativeFile, type: document.type, capture: 'page', title: document.title, viewport: viewport.name, width: viewport.width,
411
453
  height: viewport.height, file, target,
412
454
  label: relativeFile + ' · ' + viewport.name + ' (' + viewport.width + '×' + viewport.height + ')' });
413
455
  }
414
456
  }
457
+ if (config.browserPlugins.consent.enabled) {
458
+ const availableSet = new Set(available);
459
+ for (const relativeFile of new Set([...homePaths, ...privacyPaths])) {
460
+ if (!availableSet.has(relativeFile)) continue;
461
+ for (const viewport of [desktop, mobile]) {
462
+ const digest = createHash('sha256').update('consent:' + relativeFile + ':' + viewport.name).digest('hex').slice(0, 10);
463
+ const file = 'consent-' + digest + '.png';
464
+ screenshots.push({ page: relativeFile, type: 'consent', capture: 'consent', title: 'Privacy choices',
465
+ viewport: viewport.name, width: viewport.width, height: viewport.height, file,
466
+ target: resolve(evidenceDirectory, file), label: 'Privacy choices · ' + relativeFile + ' · ' + viewport.name +
467
+ ' (' + viewport.width + '×' + viewport.height + ')' });
468
+ }
469
+ }
470
+ }
415
471
  reportServer = await startReportServer(config.resolvedPaths.output, evidenceDirectory, htmlPath);
416
472
  browserSession = await startHeadlessBrowser(browser, profile);
417
473
  for (const screenshot of screenshots) {
@@ -419,19 +475,57 @@ export async function addVisualEvidence(config, report) {
419
475
  await browserSession.setViewport(viewport);
420
476
  await browserSession.navigate('http://127.0.0.1:' + reportServer.port + siteAddress(screenshot.page));
421
477
  const dimensions = await browserSession.metrics();
422
- await writeFile(screenshot.target, await browserSession.screenshot());
478
+ if (screenshot.capture === 'consent') {
479
+ report.consentUi.visualChecks ||= [];
480
+ const checks = [
481
+ ['component stays inside the viewport', dimensions.consentExists && dimensions.consentWithinViewport],
482
+ ['component content stays inside its own box', dimensions.consentExists && dimensions.consentInnerNoOverflow],
483
+ ['accept and reject actions have equal visual weight', dimensions.consentExists && dimensions.consentActionsBalanced],
484
+ ['optional services begin unchecked', dimensions.consentExists && dimensions.consentOptInDefault],
485
+ ['expanded service details disclose all configured fields', dimensions.consentExists && dimensions.consentServiceDisclosures && dimensions.consentDetails]
486
+ ];
487
+ report.consentUi.visualChecks.push(...checks.map(([name, passed]) => ({ page: screenshot.page, viewport: viewport.name,
488
+ name, passed, consentWidth: dimensions.consentWidth, consentHeight: dimensions.consentHeight })));
489
+ const withinViewport = dimensions.consentExists && dimensions.consentWithinViewport;
490
+ const noInnerOverflow = dimensions.consentExists && dimensions.consentInnerNoOverflow;
491
+ if (!dimensions.consentExists) {
492
+ report.issues.push({ severity: 'error', category: 'consent-ui', page: screenshot.page,
493
+ message: 'The consent manager did not appear in the browser.' });
494
+ } else if (!withinViewport || !noInnerOverflow) {
495
+ report.issues.push({ severity: 'error', category: 'consent-ui', page: screenshot.page,
496
+ message: 'Consent UI exceeded its viewport or its own content box at the ' + viewport.name + ' size.' });
497
+ }
498
+ }
499
+ const image = screenshot.capture === 'consent'
500
+ ? await browserSession.screenshot({ clipSelector: '[data-consent-ui]' })
501
+ : await browserSession.screenshot({ hideConsent: config.browserPlugins.consent.enabled });
502
+ if (!image) {
503
+ report.issues.push({ severity: 'error', category: screenshot.capture === 'consent' ? 'consent-ui' : 'visual-evidence',
504
+ page: screenshot.page, message: 'The requested screenshot area was not present in the browser.' });
505
+ continue;
506
+ }
507
+ await writeFile(screenshot.target, image);
423
508
  const imageInfo = await lstat(screenshot.target);
424
509
  if (!imageInfo.isFile() || imageInfo.size < 1000) throw new Error('Headless browser produced an empty screenshot');
425
510
  screenshot.viewportWidth = dimensions.viewportWidth;
426
- screenshot.documentWidth = dimensions.documentWidth;
511
+ screenshot.documentWidth = dimensions.pageDocumentWidth;
512
+ screenshot.consentWidth = dimensions.consentWidth;
513
+ screenshot.consentHeight = dimensions.consentHeight;
514
+ screenshot.consentWithinViewport = dimensions.consentWithinViewport;
427
515
  if (viewport.name === 'mobile' && dimensions.viewportWidth !== viewport.width) {
428
516
  report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
429
517
  message: 'Mobile viewport measured ' + dimensions.viewportWidth + ' CSS pixels instead of ' + viewport.width + '.' });
430
- } else if (viewport.name === 'mobile' && dimensions.documentWidth > dimensions.viewportWidth) {
518
+ } else if (screenshot.capture === 'page' && viewport.name === 'mobile' && dimensions.pageDocumentWidth > dimensions.viewportWidth) {
431
519
  report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
432
- message: 'Mobile document width is ' + dimensions.documentWidth + ' CSS pixels for a ' + dimensions.viewportWidth + ' pixel viewport.' });
520
+ message: 'Page content width is ' + dimensions.pageDocumentWidth + ' CSS pixels for a ' + dimensions.viewportWidth + ' pixel viewport; consent UI is measured separately.' });
433
521
  }
434
522
  }
523
+ if (report.consentUi?.visualChecks) {
524
+ const visualChecks = report.consentUi.visualChecks;
525
+ report.consentUi.checks += visualChecks.length;
526
+ report.consentUi.passed += visualChecks.filter((item) => item.passed).length;
527
+ report.consentUi.status = report.consentUi.passed === report.consentUi.checks ? 'pass' : 'fail';
528
+ }
435
529
  report.errors = report.issues.filter((item) => item.severity === 'error').length;
436
530
  report.warnings = report.issues.filter((item) => item.severity === 'warning').length;
437
531
  report.status = report.errors ? 'fail' : report.warnings ? 'warning' : 'pass';
@@ -440,9 +534,10 @@ export async function addVisualEvidence(config, report) {
440
534
  if (findings.some((item) => item.severity === 'error')) document.status = 'fail';
441
535
  else if (findings.length) document.status = 'warning';
442
536
  }
443
- report.visualEvidence = { status: 'complete', pdf: 'tools/page-check.pdf', screenshots: screenshots.map((item) => ({
444
- page: item.page, type: item.type, title: item.title, viewport: item.viewport, width: item.width, height: item.height,
537
+ report.visualEvidence = { status: 'complete', pdf: 'tools/page-check.pdf', screenshots: screenshots.filter((item) => item.viewportWidth).map((item) => ({
538
+ page: item.page, type: item.type, capture: item.capture, title: item.title, viewport: item.viewport, width: item.width, height: item.height,
445
539
  viewportWidth: item.viewportWidth, documentWidth: item.documentWidth,
540
+ consentWidth: item.consentWidth, consentHeight: item.consentHeight, consentWithinViewport: item.consentWithinViewport,
446
541
  label: item.label
447
542
  })) };
448
543
  const initialHtml = reportHtml(report, screenshots);