edgepress 2026.9.8 → 2026.9.10

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.
@@ -0,0 +1,31 @@
1
+ ---
2
+ title: EdgePress 2026 年 9 月更新:第 10 次需求
3
+ author: toewpq
4
+ date: 2026-09-27
5
+ lang: zh-CN
6
+ slug: edgepress-release-2026-9-10
7
+ tags:
8
+ - 发布
9
+ - 多语言
10
+ - 隐私
11
+ - 主题
12
+ description: 修复跨语言切换后的隐私选择,支持本地化站内链接,改进文章标签、搜索脚本和 SVG 图标。
13
+ ---
14
+
15
+ 这是 EdgePress 在 2026 年 9 月针对第 10 次需求的更新。
16
+
17
+ ## 切换语言后保留隐私选择
18
+
19
+ 隐私选择现在使用跨语言一致的配置校验,切换页面语言时会继续生效。现有的旧版选择会自动迁移;当告知日期或服务配置变化时,仍会重新征求选择。
20
+
21
+ ## 按当前语言生成站内链接
22
+
23
+ 页面块链接可使用 `/[launge]/quick-start/`。生成页面时,这个标记会替换为当前语言路径;默认语言会省略语言段。中文首页的指南链接现在会打开对应的中文页面。
24
+
25
+ ## 改进文章标签和搜索
26
+
27
+ 文章底部会以清楚可见的链接显示标签,并可打开对应的标签页。搜索脚本只在搜索页面加载,标签使用原生链接,无需 JavaScript。
28
+
29
+ ## 重绘图标并简化隐私说明
30
+
31
+ 重绘了设置和插头 SVG 图标。隐私政策以日常用语说明选择保存在访客设备上,不再显示内部存储键名称。
@@ -24,7 +24,8 @@
24
24
  "searchResultCount": "Matching articles: {count}.",
25
25
  "markdownTaskComplete": "Task complete",
26
26
  "markdownTaskIncomplete": "Task incomplete",
27
- "postContents": "On this page",
27
+ "postContents": "Contents",
28
+ "closeContents": "Close contents",
28
29
  "feed": "Atom feed",
29
30
  "languageName": "English",
30
31
  "mainNavigationLabel": "Main navigation",
@@ -37,26 +38,29 @@
37
38
  "postAuthor": "By {author}",
38
39
  "privacySettings": "Privacy settings",
39
40
  "privacyNotice": "Privacy choices",
40
- "privacyIntro": "Optional services stay off until you choose. Accept or reject them now, or review each service before saving a custom choice.",
41
- "acceptOptional": "Accept all",
41
+ "privacyIntro": "Optional services stay off until you choose.",
42
+ "acceptOptional": "Allow all",
42
43
  "rejectOptional": "Reject optional",
43
44
  "savePreferences": "Save choices",
44
45
  "optionalServices": "Optional services",
45
- "noIntegrations": "No optional services are configured.",
46
+ "noIntegrations": "No optional services are active.",
46
47
  "privacyPolicy": "Privacy policy",
47
- "privacyController": "Site operator",
48
+ "privacyController": "Operator",
49
+ "privacyContact": "Contact",
48
50
  "servicePurpose": "Purpose",
49
51
  "serviceDataCategories": "Data used",
50
52
  "serviceRecipient": "Recipient",
51
53
  "serviceRetention": "Retention",
52
- "reviewDetails": "Review data and service settings",
53
- "managePrivacy": "Manage choices",
54
- "essentialStorage": "Your choice is saved in this browser for up to {days} days. This preference storage is required to remember your choice.",
54
+ "servicePrivacyDetails": "Privacy information",
55
+ "servicePrivacyLink": "Open policy",
56
+ "reviewDetails": "Details",
57
+ "essentialStorage": "Choice saved on this device for up to {days} days.",
55
58
  "pluginTracking": "Tracking",
56
59
  "pluginStatistics": "Statistics",
57
60
  "pluginAdvertising": "Advertising",
58
61
  "pluginCaptcha": "Human verification",
59
62
  "consentReload": "Your choice was saved. Reloading to apply the updated settings.",
60
63
  "agentSummary": "Project guides and reference pages for the EdgePress website builder.",
61
- "closePrivacy": "Close settings"
64
+ "closePrivacy": "Close settings",
65
+ "consentStoredOnDevice": "This device"
62
66
  }
@@ -25,6 +25,7 @@
25
25
  "markdownTaskComplete": "任务已完成",
26
26
  "markdownTaskIncomplete": "任务未完成",
27
27
  "postContents": "文章目录",
28
+ "closeContents": "关闭目录",
28
29
  "feed": "Atom 订阅",
29
30
  "languageName": "简体中文",
30
31
  "mainNavigationLabel": "主导航",
@@ -37,26 +38,29 @@
37
38
  "postAuthor": "作者:{author}",
38
39
  "privacySettings": "隐私设置",
39
40
  "privacyNotice": "隐私选择",
40
- "privacyIntro": "可选服务默认关闭。你可以立即全部接受或拒绝,也可以先查看各项服务并保存自定义选择。",
41
- "acceptOptional": "全部接受",
41
+ "privacyIntro": "可选服务仅在你选择后启用。",
42
+ "acceptOptional": "全部允许",
42
43
  "rejectOptional": "拒绝可选服务",
43
44
  "savePreferences": "保存选择",
44
45
  "optionalServices": "可选服务",
45
- "noIntegrations": "尚未配置可选服务。",
46
+ "noIntegrations": "当前没有启用可选服务。",
46
47
  "privacyPolicy": "隐私政策",
47
- "privacyController": "网站运营者",
48
+ "privacyController": "运营者",
49
+ "privacyContact": "联系邮箱",
48
50
  "servicePurpose": "用途",
49
51
  "serviceDataCategories": "涉及的数据",
50
52
  "serviceRecipient": "接收方",
51
53
  "serviceRetention": "保留期限",
52
- "reviewDetails": "查看数据详情并管理服务",
53
- "managePrivacy": "管理选择",
54
- "essentialStorage": "此浏览器会保存你的选择,最长 {days} 天。这项偏好存储用于记住你的选择。",
54
+ "servicePrivacyDetails": "隐私说明",
55
+ "servicePrivacyLink": "查看政策",
56
+ "reviewDetails": "详情",
57
+ "essentialStorage": "选择保存在此设备,最长 {days} 天。",
55
58
  "pluginTracking": "跟踪",
56
59
  "pluginStatistics": "统计",
57
60
  "pluginAdvertising": "广告",
58
61
  "pluginCaptcha": "人机验证",
59
62
  "consentReload": "选择已保存,正在重新载入以应用设置。",
60
63
  "agentSummary": "EdgePress 网站构建器的项目指南与参考文档。",
61
- "closePrivacy": "关闭设置"
64
+ "closePrivacy": "关闭设置",
65
+ "consentStoredOnDevice": "此设备"
62
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edgepress",
3
- "version": "2026.9.8",
3
+ "version": "2026.9.10",
4
4
  "description": "A static website builder for Cloudflare Workers with editable pages, Markdown posts, local search, themes, and consent-gated integrations.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,9 +20,9 @@ export default function consentManager(api) {
20
20
  const locale = page?.locale || config.i18n.defaultLocale;
21
21
  const ui = Object.fromEntries([
22
22
  'privacySettings', 'privacyNotice', 'privacyIntro', 'acceptOptional', 'rejectOptional',
23
- 'savePreferences', 'optionalServices', 'noIntegrations', 'privacyPolicy', 'privacyController',
24
- 'servicePurpose', 'serviceDataCategories', 'serviceRecipient', 'serviceRetention', 'consentReload',
25
- 'closePrivacy', 'reviewDetails', 'managePrivacy', 'essentialStorage', 'pluginTracking',
23
+ 'savePreferences', 'optionalServices', 'noIntegrations', 'privacyPolicy', 'privacyController', 'privacyContact',
24
+ 'serviceDataCategories', 'serviceRecipient', 'serviceRetention', 'servicePrivacyDetails', 'servicePrivacyLink', 'consentReload',
25
+ 'closePrivacy', 'reviewDetails', 'essentialStorage', 'pluginTracking',
26
26
  'pluginStatistics', 'pluginAdvertising', 'pluginCaptcha'
27
27
  ].map((key) => [key, translate(config, locale, key)]));
28
28
  const services = ['tracking', 'statistics', 'advertising', 'captcha'].flatMap((group) =>
@@ -34,7 +34,21 @@ export default function consentManager(api) {
34
34
  integrations: services
35
35
  };
36
36
  if (privacy.policyUrl.startsWith('/')) privacy.policyUrl = localizedUrl(config, locale, privacy.policyUrl);
37
- const payload = safeJson({ privacy, ui, siteLanguage: locale, defaultLocale: config.i18n.defaultLocale });
37
+ const choiceFingerprint = JSON.stringify({
38
+ controller: config.privacy.controller,
39
+ policyUrl: config.privacy.policyUrl,
40
+ consent: config.browserPlugins.consent,
41
+ integrations: services
42
+ });
43
+ const legacyChoiceFingerprints = config.i18n.locales.map((legacyLocale) => JSON.stringify({
44
+ controller: config.privacy.controller,
45
+ policyUrl: config.privacy.policyUrl.startsWith('/')
46
+ ? localizedUrl(config, legacyLocale, config.privacy.policyUrl)
47
+ : config.privacy.policyUrl,
48
+ consent: config.browserPlugins.consent,
49
+ integrations: services
50
+ }));
51
+ const payload = safeJson({ privacy, ui, siteLanguage: locale, defaultLocale: config.i18n.defaultLocale, choiceFingerprint, legacyChoiceFingerprints });
38
52
  const integration = '<script type="application/json" id="edgepress-privacy-config">' + payload + '</script>' +
39
53
  '<script src="/edgepress/plugins/consent/manager.js" defer></script>';
40
54
  return html.replace(/<\/body\s*>/i, integration + '</body>');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "frameworkVersion": "2026.9.8",
3
+ "frameworkVersion": "2026.9.10",
4
4
  "requirements": {
5
5
  "nodeMajor": 22,
6
6
  "nodeMinimumVersion": "22.12.0",
@@ -31,7 +31,9 @@
31
31
  { "name": "Consent-nested browser services and generated privacy disclosures", "stage": "experimental", "since": "2026.9.7" },
32
32
  { "name": "Responsive homepage imagery and chronological all-post archives", "stage": "experimental", "since": "2026.9.7" },
33
33
  { "name": "Date-versioned localized consent disclosures with isolated responsive checks", "stage": "experimental", "since": "2026.9.8" },
34
- { "name": "Markdown video syntax and locale-deduplicated chronological post lists", "stage": "experimental", "since": "2026.9.8" }
34
+ { "name": "Markdown video syntax and locale-deduplicated chronological post lists", "stage": "experimental", "since": "2026.9.8" },
35
+ { "name": "Floating post contents and compact consent controls", "stage": "experimental", "since": "2026.9.9" },
36
+ { "name": "Locale-stable consent choices and localized page-block links", "stage": "experimental", "since": "2026.9.10" }
35
37
  ],
36
38
  "releaseHistory": [
37
39
  {
@@ -81,6 +83,18 @@
81
83
  "date": "2026-09-26",
82
84
  "request": 8,
83
85
  "notes": "Replace consent version counters with proposed and effective dates; localize service data disclosures; audit the consent component independently from page layout; add Markdown video rendering; fix unique localized archive ordering and Markdown excerpts."
86
+ },
87
+ {
88
+ "version": "2026.9.9",
89
+ "date": "2026-09-26",
90
+ "request": 9,
91
+ "notes": "Add a floating post contents widget, compact consent controls, supplier privacy links, and fuller bilingual privacy disclosures."
92
+ },
93
+ {
94
+ "version": "2026.9.10",
95
+ "date": "2026-09-27",
96
+ "request": 10,
97
+ "notes": "Keep consent choices across locale changes and migrate existing records; add localized page-block links; make article tags clear; load search only where needed; redraw icons and simplify privacy wording."
84
98
  }
85
99
  ]
86
100
  }
package/src/config.js CHANGED
@@ -260,7 +260,16 @@ function validateBrowserPlugins(config) {
260
260
  validateLocalizedServiceText(service.dataCategories, 'Service ' + id + '.dataCategories', 600, locales);
261
261
  validateLocalizedServiceText(service.recipient, 'Service ' + id + '.recipient', 200, locales);
262
262
  validateLocalizedServiceText(service.retention, 'Service ' + id + '.retention', 300, locales);
263
- const allowedKeys = new Set(['id', 'provider', 'name', 'purpose', 'dataCategories', 'recipient', 'retention', definition.credential]);
263
+ if (typeof service.privacyUrl !== 'string' || service.privacyUrl.length > 2048 || /[\x00-\x20]/.test(service.privacyUrl)) {
264
+ throw new Error('Service ' + id + '.privacyUrl must be a safe HTTPS URL');
265
+ }
266
+ let safePrivacyUrl = false;
267
+ try {
268
+ const parsed = new URL(service.privacyUrl);
269
+ safePrivacyUrl = parsed.protocol === 'https:' && !parsed.username && !parsed.password;
270
+ } catch { safePrivacyUrl = false; }
271
+ if (!safePrivacyUrl) throw new Error('Service ' + id + '.privacyUrl must be a safe HTTPS URL');
272
+ const allowedKeys = new Set(['id', 'provider', 'name', 'purpose', 'dataCategories', 'recipient', 'retention', 'privacyUrl', definition.credential]);
264
273
  for (const key of Object.keys(service)) if (!allowedKeys.has(key)) throw new Error('Unsupported option for service ' + id + ': ' + key);
265
274
  const credential = service[definition.credential];
266
275
  if (typeof credential !== 'string' || !definition.pattern.test(credential)) throw new Error('Service ' + id + ' needs a valid public ' + definition.credential);
package/src/generators.js CHANGED
@@ -4,6 +4,7 @@ import { renderLayout } from './theme.js';
4
4
  import { localizedUrl, translate } from './i18n.js';
5
5
  import { postsForLocale, slugify, sortPostsNewest } from './content.js';
6
6
  import { mapLimit } from './concurrency.js';
7
+ import { renderIcon } from './icons.js';
7
8
 
8
9
  function escapeHtml(value) {
9
10
  return String(value ?? '').replace(/[&<>"']/g, (char) => ({
@@ -72,8 +73,14 @@ function addPostContents(html, locale, config) {
72
73
  const items = headings.map((heading) => '<li class="post-toc-level-' + heading.level + '"><a href="#' +
73
74
  escapeHtml(heading.id) + '">' + escapeHtml(heading.label) + '</a></li>').join('');
74
75
  const contentsLabel = translate(config, locale, 'postContents');
75
- const toc = '<nav class="post-toc" aria-label="' + escapeHtml(contentsLabel) + '"><h2 id="post-toc-title">' +
76
- escapeHtml(contentsLabel) + '</h2><ol>' + items + '</ol></nav>';
76
+ const closeContents = translate(config, locale, 'closeContents');
77
+ const toc = '<aside class="post-toc-widget" data-post-toc hidden>' +
78
+ '<button class="post-toc-toggle" type="button" aria-expanded="false" aria-controls="post-toc-panel" aria-label="' +
79
+ escapeHtml(contentsLabel) + '">' + renderIcon('book-open', 'post-toc-icon') + '<span>' + escapeHtml(contentsLabel) + '</span></button>' +
80
+ '<nav class="post-toc-panel" id="post-toc-panel" aria-labelledby="post-toc-title" aria-label="' +
81
+ escapeHtml(contentsLabel) + '" hidden><div class="post-toc-heading"><h2 id="post-toc-title">' + escapeHtml(contentsLabel) +
82
+ '</h2><button class="post-toc-close" type="button" aria-label="' + escapeHtml(closeContents) + '"><span aria-hidden="true">×</span></button></div>' +
83
+ '<ol>' + items + '</ol></nav></aside>';
77
84
  return { html: content, toc };
78
85
  }
79
86
 
@@ -231,8 +238,9 @@ export async function generateBuiltinRoutes(site, config, extensions) {
231
238
  const body = '<article class="post"><header><h1>' + escapeHtml(post.title) + '</h1><p class="meta"><time datetime="' + post.date.toISOString() + '">' +
232
239
  escapeHtml(dateLabel(post.date, locale)) + '</time>' + (post.author ? ' · ' + renderPostAuthor(post, locale, config) : '') +
233
240
  '</p></header>' + renderPostVideo(post.video, locale) + renderedMarkdown.toc + '<div class="post-content">' + renderedMarkdown.html + '</div>' +
234
- (post.tags.length ? '<p class="tags">' + escapeHtml(label('tags')) + ': ' + post.tags.map((tag) => '<a href="' +
235
- escapeHtml(localizedUrl(config, locale, 'tags/' + tagSlug(tag) + '/')) + '">' + escapeHtml(tag) + '</a>').join(' ') + '</p>' : '') + '</article>';
241
+ (post.tags.length ? '<nav class="post-tags" aria-label="' + escapeHtml(label('tags')) + '"><span class="post-tags-label">' + escapeHtml(label('tags')) + ':</span><ul>' +
242
+ post.tags.map((tag) => '<li><a rel="tag" href="' + escapeHtml(localizedUrl(config, locale, 'tags/' + tagSlug(tag) + '/')) + '">' + escapeHtml(tag) + '</a></li>').join('') +
243
+ '</ul></nav>' : '') + '</article>';
236
244
  const canonicalPath = urlFor(post.path);
237
245
  return pageRoute(post.path + 'index.html', post.title, post.description || plainText(post.markdown).slice(0, 160), body,
238
246
  locale, config, extensions, {
@@ -308,8 +316,7 @@ export async function generateBuiltinRoutes(site, config, extensions) {
308
316
  'aria-describedby="edgepress-search-hint"><button type="submit">' + escapeHtml(label('searchButton')) + '</button></div>' +
309
317
  '<p id="edgepress-search-hint">' + escapeHtml(label('searchHint')) + '</p></form><p id="edgepress-search-status" role="status" aria-live="polite">' +
310
318
  escapeHtml(label('searchPrompt')) + '</p><ol id="edgepress-search-results" class="local-search-results"></ol>' +
311
- '<noscript><p>' + escapeHtml(label('searchNeedsJavaScript')) + '</p></noscript></section>' +
312
- '<script defer src="/edgepress/search.js"></script>';
319
+ '<noscript><p>' + escapeHtml(label('searchNeedsJavaScript')) + '</p></noscript></section>';
313
320
  routes.push(await pageRoute(prefix + 'search/index.html', label('searchPosts'), label('searchIntro'), searchBody,
314
321
  locale, config, extensions));
315
322
 
@@ -29,6 +29,19 @@ function safeUrl(value, label) {
29
29
  return url;
30
30
  }
31
31
 
32
+ function localizedSiteUrl(value, label, context) {
33
+ const url = safeUrl(value, label);
34
+ if (!url.startsWith('/')) return url;
35
+ const localeToken = /^\/\[launge\](?=\/|$)/i;
36
+ if (/\[launge\]/i.test(url) && !localeToken.test(url)) {
37
+ throw new Error(label + ' may use [launge] only as the first site-path segment');
38
+ }
39
+ if (!localeToken.test(url)) return url;
40
+ const suffix = url.replace(localeToken, '');
41
+ const prefix = context.locale === context.config.i18n.defaultLocale ? '' : '/' + context.locale;
42
+ return prefix + (suffix || '/');
43
+ }
44
+
32
45
  function list(value, label, min = 1, max = 50) {
33
46
  if (!Array.isArray(value) || value.length < min || value.length > max) throw new Error(label + ' must contain ' + min + ' to ' + max + ' items');
34
47
  return value;
@@ -86,7 +99,9 @@ function renderPrivacyServices(context) {
86
99
  escapeHtml(translate(context.config, context.locale, 'serviceRecipient')) + ':</strong> ' +
87
100
  escapeHtml(translateValue(context.config, context.locale, service.recipient)) + '</p><p><strong>' +
88
101
  escapeHtml(translate(context.config, context.locale, 'serviceRetention')) + ':</strong> ' +
89
- escapeHtml(translateValue(context.config, context.locale, service.retention)) + '</p></li>').join('') + '</ul>';
102
+ escapeHtml(translateValue(context.config, context.locale, service.retention)) + '</p><p><a class="privacy-vendor-link" href="' +
103
+ escapeHtml(service.privacyUrl) + '" rel="noopener noreferrer">' +
104
+ escapeHtml(translate(context.config, context.locale, 'servicePrivacyDetails')) + '</a></p></li>').join('') + '</ul>';
90
105
  }
91
106
 
92
107
  async function renderLatestPosts(block, context) {
@@ -113,7 +128,7 @@ async function renderLatestPosts(block, context) {
113
128
  (cards ? '<div class="post-list">' + cards + '</div>' : '<p>' + escapeHtml(translate(context.config, context.locale, 'noPosts')) + '</p>') + pagination + '</section>';
114
129
  }
115
130
 
116
- function renderMediaText(block) {
131
+ function renderMediaText(block, context) {
117
132
  const mediaType = text(block.mediaType, 'media-text.mediaType', 10);
118
133
  if (!['image', 'video'].includes(mediaType)) throw new Error('media-text.mediaType must be image or video');
119
134
  const src = escapeHtml(safeUrl(block.src, 'media-text.src'));
@@ -138,7 +153,7 @@ function renderMediaText(block) {
138
153
  const content = text(block.text, 'media-text.text', 2000);
139
154
  let action = '';
140
155
  if (block.cta) {
141
- action = '<p><a class="button" href="' + escapeHtml(safeUrl(block.cta.url, 'media-text.cta.url')) + '">' +
156
+ action = '<p><a class="button" href="' + escapeHtml(localizedSiteUrl(block.cta.url, 'media-text.cta.url', context)) + '">' +
142
157
  escapeHtml(text(block.cta.label, 'media-text.cta.label', 120)) + '</a></p>';
143
158
  }
144
159
  return '<article class="media-text-block media-text-block--' + placement + '"><figure>' + media +
@@ -175,7 +190,7 @@ async function renderBlock(block, context, depth, index) {
175
190
  let action = '';
176
191
  if (block.cta) {
177
192
  const label = escapeHtml(text(block.cta.label, 'hero.cta.label', 120));
178
- const url = escapeHtml(safeUrl(block.cta.url, 'hero.cta.url'));
193
+ const url = escapeHtml(localizedSiteUrl(block.cta.url, 'hero.cta.url', context));
179
194
  action = '<p><a class="button" href="' + url + '">' + label + '</a></p>';
180
195
  }
181
196
  const highlights = block.highlights === undefined ? [] : list(block.highlights, 'hero.highlights', 1, 4);
@@ -229,7 +244,7 @@ async function renderBlock(block, context, depth, index) {
229
244
  if (!Array.isArray(paragraphs) || paragraphs.length < 1 || paragraphs.length > 30) throw new Error('text needs a text value or 1 to 30 paragraphs');
230
245
  return '<div class="text-widget">' + paragraphs.map((paragraph) => '<p>' + escapeHtml(text(paragraph, 'text paragraph', 4000)) + '</p>').join('') + '</div>';
231
246
  }
232
- case 'media-text': return renderMediaText(block);
247
+ case 'media-text': return renderMediaText(block, context);
233
248
  case 'display-text': {
234
249
  const value = escapeHtml(text(block.text, 'display-text.text', 1000));
235
250
  const style = block.style ?? 'editorial';
@@ -244,7 +259,7 @@ async function renderBlock(block, context, depth, index) {
244
259
  return '<nav class="link-list"' + (title ? ' aria-label="' + escapeHtml(title) + '"' : '') + '>' +
245
260
  (title ? '<h3>' + escapeHtml(title) + '</h3>' : '') + '<ul>' + items.map((item) => {
246
261
  const label = escapeHtml(text(item?.label, 'link-list label', 200));
247
- const url = escapeHtml(safeUrl(item?.url, 'link-list.url'));
262
+ const url = escapeHtml(localizedSiteUrl(item?.url, 'link-list.url', context));
248
263
  const description = text(item?.text, 'link-list.text', 500, true);
249
264
  return '<li><a href="' + url + '">' + label + '</a>' + (description ? '<p>' + escapeHtml(description) + '</p>' : '') + '</li>';
250
265
  }).join('') + '</ul></nav>';
@@ -306,7 +321,7 @@ async function renderBlock(block, context, depth, index) {
306
321
  const expiryText = text(block.expiryText, 'privacy-consent.expiryText', 120);
307
322
  if (!expiryText.includes('{days}')) throw new Error('privacy-consent.expiryText must include a {days} placeholder');
308
323
  const consent = context.config.browserPlugins.consent;
309
- return '<dl class="privacy-controller-details"><div><dt>' + storageLabel + '</dt><dd>edgepress-privacy-choice</dd></div>' +
324
+ return '<dl class="privacy-controller-details"><div><dt>' + storageLabel + '</dt><dd>' + escapeHtml(translate(context.config, context.locale, 'consentStoredOnDevice')) + '</dd></div>' +
310
325
  '<div><dt>' + expiryLabel + '</dt><dd>' + escapeHtml(expiryText.replace('{days}', String(consent.expiresDays))) + '</dd></div>' +
311
326
  '<div><dt>' + proposedDateLabel + '</dt><dd>' + escapeHtml(dateOnlyLabel(consent.proposedDate, context.locale)) + '</dd></div>' +
312
327
  (consent.effectiveDate ? '<div><dt>' + effectiveDateLabel + '</dt><dd>' + escapeHtml(dateOnlyLabel(consent.effectiveDate, context.locale)) + '</dd></div>' : '') + '</dl>';
@@ -322,8 +337,11 @@ async function renderBlock(block, context, depth, index) {
322
337
  if (!name || !contact) {
323
338
  return '<aside class="notice-widget notice-widget--warning"><h3>' + missingTitle + '</h3><p>' + missingText + '</p></aside>';
324
339
  }
325
- return '<dl class="privacy-controller-details"><div><dt>' + nameLabel + '</dt><dd>' + escapeHtml(name) +
326
- '</dd></div><div><dt>' + contactLabel + '</dt><dd>' + escapeHtml(contact) + '</dd></div></dl>';
340
+ const email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact);
341
+ const contactValue = email ? '<a href="mailto:' + escapeHtml(contact) + '">' + escapeHtml(contact) + '</a>' : escapeHtml(contact);
342
+ return '<p class="privacy-controller-inline"><span><strong>' + nameLabel + ':</strong> ' + escapeHtml(name) +
343
+ '</span><span class="privacy-controller-separator" aria-hidden="true">·</span><span><strong>' + contactLabel +
344
+ ':</strong> ' + contactValue + '</span></p>';
327
345
  }
328
346
  case 'feature-grid': {
329
347
  const items = list(block.items, 'feature-grid.items', 1, 12);
@@ -331,7 +349,7 @@ async function renderBlock(block, context, depth, index) {
331
349
  const cells = items.map((item) => {
332
350
  const title = escapeHtml(text(item?.title, 'feature title', 200));
333
351
  const description = escapeHtml(text(item?.text, 'feature text', 1000, true));
334
- const href = item?.url ? safeUrl(item.url, 'feature.url') : '';
352
+ const href = item?.url ? localizedSiteUrl(item.url, 'feature.url', context) : '';
335
353
  const iconName = item?.icon === undefined ? '' : text(item.icon, 'feature icon', 40);
336
354
  if (iconName && !isIconName(iconName)) throw new Error('Unsupported feature icon: ' + iconName);
337
355
  const icon = iconName ? renderIcon(iconName, 'feature-icon') : '';
@@ -356,7 +374,7 @@ async function renderBlock(block, context, depth, index) {
356
374
  const title = escapeHtml(text(block.title, 'cta.title', 240));
357
375
  const description = text(block.text, 'cta.text', 1000, true);
358
376
  const label = escapeHtml(text(block.label, 'cta.label', 120));
359
- const url = escapeHtml(safeUrl(block.url, 'cta.url'));
377
+ const url = escapeHtml(localizedSiteUrl(block.url, 'cta.url', context));
360
378
  return '<section class="cta-block"><h2>' + title + '</h2>' + (description ? '<p>' + escapeHtml(description) + '</p>' : '') +
361
379
  '<p><a class="button" href="' + url + '">' + label + '</a></p></section>';
362
380
  }
package/src/page-check.js CHANGED
@@ -304,14 +304,16 @@ export async function checkPages(config) {
304
304
  });
305
305
  consentCheck('privacy controls are injected on every generated page', files.length > 0 && htmlWithConsent.length === files.length);
306
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'));
307
+ consentCheck('service disclosure supports data, recipient, retention, and supplier privacy links',
308
+ managerScript.includes('serviceDataCategories') && managerScript.includes('serviceRecipient') &&
309
+ managerScript.includes('serviceRetention') && managerScript.includes('servicePrivacyDetails') &&
310
+ managerScript.includes('integration.privacyUrl'));
309
311
  consentCheck('stored choices are tied to proposed and effective dates',
310
312
  managerScript.includes('proposedDate: consent.proposedDate') && managerScript.includes('effectiveDate: consent.effectiveDate'));
311
313
  consentCheck('new visitors start with every optional service off', managerScript.includes('checkbox.checked = false'));
312
314
  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
+ /\.privacy-manager \.privacy-panel \.privacy-accept\s*,\s*\.privacy-manager \.privacy-panel \.privacy-reject\s*\{[^}]*background:[^}]*\}/.test(styles));
316
+ const componentClass = /(?:^|[\s>+~])\.privacy-(?:manager|panel|settings-button|close|intro|essential|details|category|service-settings|service-setting|service-toggle|service-toggle-text|service-purpose|service-disclosures|actions|accept|reject|save|empty|policy-link|footer|controller)(?=$|[^\w-])/;
315
317
  let scoped = true;
316
318
  for (const match of styles.matchAll(/([^{}]+)\{[^{}]*\}/g)) {
317
319
  const selector = match[1].trim();
package/src/theme.js CHANGED
@@ -164,9 +164,17 @@ export async function renderLayout(config, extensions, page, body) {
164
164
  const html = '<!doctype html>' + rendered;
165
165
  const filtered = await extensions.filter('html:afterLayout', html, context);
166
166
  if (typeof filtered !== 'string') throw new Error('html:afterLayout filters must return a string');
167
- if (/src=["']\/edgepress\/code-copy\.js["']/i.test(filtered)) return filtered;
168
- const codeCopyScript = '<script defer src="/edgepress/code-copy.js"></script>';
167
+ const scripts = [];
168
+ if (!/src=["']\/edgepress\/code-copy\.js["']/i.test(filtered)) scripts.push('<script defer src="/edgepress/code-copy.js"></script>');
169
+ if (/data-post-toc(?:\s|>)/i.test(filtered) && !/src=["']\/edgepress\/post-toc\.js["']/i.test(filtered)) {
170
+ scripts.push('<script defer src="/edgepress/post-toc.js"></script>');
171
+ }
172
+ if (/data-edgepress-search(?:\s|=|>)/i.test(filtered) && !/src=["']\/edgepress\/search\.js["']/i.test(filtered)) {
173
+ scripts.push('<script defer src="/edgepress/search.js"></script>');
174
+ }
175
+ if (!scripts.length) return filtered;
176
+ const integration = scripts.join('');
169
177
  return /<\/body\s*>/i.test(filtered)
170
- ? filtered.replace(/<\/body\s*>/i, codeCopyScript + '</body>')
171
- : filtered + codeCopyScript;
178
+ ? filtered.replace(/<\/body\s*>/i, integration + '</body>')
179
+ : filtered + integration;
172
180
  }
@@ -10,7 +10,7 @@
10
10
  <symbol id="layers" viewBox="0 0 24 24"><path d="m12 3 9 5-9 5-9-5z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/></symbol>
11
11
  <symbol id="code" viewBox="0 0 24 24"><path d="m8 7-5 5 5 5m8-10 5 5-5 5m-3-12-2 14"/></symbol>
12
12
  <symbol id="puzzle" viewBox="0 0 24 24"><path d="M4 4h5a2.5 2.5 0 1 1 5 0h6v6a2.5 2.5 0 1 0 0 5v5h-6a2.5 2.5 0 1 0-5 0H4v-6a2.5 2.5 0 1 1 0-5z"/></symbol>
13
- <symbol id="plug" viewBox="0 0 24 24"><path d="M9 3v6m6-6v6m-9 0h12v3a6 6 0 0 1-6 6v3m0-9v6"/></symbol>
13
+ <symbol id="plug" viewBox="0 0 24 24"><path d="M9 3v6m6-6v6M6 9h12v2a6 6 0 0 1-12 0V9Z"/><path d="M12 17v4"/></symbol>
14
14
  <symbol id="cloud" viewBox="0 0 24 24"><path d="M7 18a5 5 0 1 1 1-9.9A6 6 0 0 1 19 10a4 4 0 0 1 0 8z"/><path d="m9 14 3-3 3 3m-3-3v7"/></symbol>
15
15
  <symbol id="palette" viewBox="0 0 24 24"><path d="M12 3a9 9 0 1 0 0 18h1.2a2.3 2.3 0 0 0 1.6-4c-.7-.7-.2-2 1-2H18a3 3 0 0 0 3-3c0-5-4-9-9-9Z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7.5" r="1"/><circle cx="15" cy="8" r="1"/></symbol>
16
16
  <symbol id="globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18m0-18a14 14 0 0 0 0 18"/></symbol>
@@ -19,6 +19,6 @@
19
19
  <symbol id="check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="m8 12 2.7 2.7L16.5 9"/></symbol>
20
20
  <symbol id="layout-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="5" rx="1.5"/><rect x="13" y="10" width="8" height="11" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/></symbol>
21
21
  <symbol id="terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3m6 0h4"/></symbol>
22
- <symbol id="settings" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="m19.4 15 .1.1 1.1.9-1.1 2-1.4-.4a8 8 0 0 1-1.7 1l-.3 1.4h-2.2l-.3-1.4a8 8 0 0 1-1.7-1l-1.4.4-1.1-2 1.1-.9A8 8 0 0 1 8.5 14L7 13.7v-2.4l1.5-.3a8 8 0 0 1 .9-1.7l-.5-1.4 2-1.2 1 1.1a8 8 0 0 1 2 0l1-1.1 2 1.2-.5 1.4a8 8 0 0 1 .9 1.7l1.5.3v2.4l-1.5.3"/></symbol>
22
+ <symbol id="settings" viewBox="0 0 24 24"><path d="M10 2h4l.7 2.5a8 8 0 0 1 1.6.7l2.4-1.1 2 3.5-1.9 1.6a8 8 0 0 1 0 1.6l1.9 1.6-2 3.5-2.4-1.1a8 8 0 0 1-1.6.7L14 22h-4l-.7-2.5a8 8 0 0 1-1.6-.7l-2.4 1.1-2-3.5 1.9-1.6a8 8 0 0 1 0-1.6l-1.9-1.6 2-3.5 2.4 1.1a8 8 0 0 1 1.6-.7z"/><circle cx="12" cy="12" r="2.7"/></symbol>
23
23
  <symbol id="sparkles" viewBox="0 0 24 24"><path d="m12 3 1.4 5.6L19 10l-5.6 1.4L12 17l-1.4-5.6L5 10l5.6-1.4zM19 16l.7 2.3L22 19l-2.3.7L19 22l-.7-2.3L16 19l2.3-.7z"/></symbol>
24
24
  </svg>