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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edgepress",
3
- "version": "2026.9.6",
3
+ "version": "2026.9.8",
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": {
@@ -17,8 +17,7 @@
17
17
  "edgepress.config.mjs",
18
18
  "project-compatibility.json",
19
19
  "wrangler.jsonc",
20
- "README.md",
21
- "CHANGELOG.txt"
20
+ "README.md"
22
21
  ],
23
22
  "repository": {
24
23
  "type": "git",
@@ -21,7 +21,9 @@ export default function consentManager(api) {
21
21
  const ui = Object.fromEntries([
22
22
  'privacySettings', 'privacyNotice', 'privacyIntro', 'acceptOptional', 'rejectOptional',
23
23
  'savePreferences', 'optionalServices', 'noIntegrations', 'privacyPolicy', 'privacyController',
24
- 'servicePurpose', 'serviceRetention', 'consentReload', 'closePrivacy'
24
+ 'servicePurpose', 'serviceDataCategories', 'serviceRecipient', 'serviceRetention', 'consentReload',
25
+ 'closePrivacy', 'reviewDetails', 'managePrivacy', 'essentialStorage', 'pluginTracking',
26
+ 'pluginStatistics', 'pluginAdvertising', 'pluginCaptcha'
25
27
  ].map((key) => [key, translate(config, locale, key)]));
26
28
  const services = ['tracking', 'statistics', 'advertising', 'captcha'].flatMap((group) =>
27
29
  config.browserPlugins[group].enabled ? config.browserPlugins[group].services.map((service) => ({ ...service, category: group })) : []
@@ -32,7 +34,7 @@ export default function consentManager(api) {
32
34
  integrations: services
33
35
  };
34
36
  if (privacy.policyUrl.startsWith('/')) privacy.policyUrl = localizedUrl(config, locale, privacy.policyUrl);
35
- const payload = safeJson({ privacy, ui, siteLanguage: locale });
37
+ const payload = safeJson({ privacy, ui, siteLanguage: locale, defaultLocale: config.i18n.defaultLocale });
36
38
  const integration = '<script type="application/json" id="edgepress-privacy-config">' + payload + '</script>' +
37
39
  '<script src="/edgepress/plugins/consent/manager.js" defer></script>';
38
40
  return html.replace(/<\/body\s*>/i, integration + '</body>');
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "frameworkVersion": "2026.9.6",
3
+ "frameworkVersion": "2026.9.8",
4
4
  "requirements": {
5
5
  "nodeMajor": 22,
6
6
  "nodeMinimumVersion": "22.12.0",
@@ -27,7 +27,11 @@
27
27
  { "name": "PDF-only audit output and temporary screenshot capture", "stage": "experimental", "since": "2026.9.4" },
28
28
  { "name": "Cloudflare one-click deployment guidance for existing and new projects", "stage": "stable", "since": "2026.9.5" },
29
29
  { "name": "Localized Posts navigation and responsive code copy controls", "stage": "experimental", "since": "2026.9.6" },
30
- { "name": "Linked tables of contents on Markdown post pages", "stage": "experimental", "since": "2026.9.6" }
30
+ { "name": "Linked tables of contents on Markdown post pages", "stage": "experimental", "since": "2026.9.6" },
31
+ { "name": "Consent-nested browser services and generated privacy disclosures", "stage": "experimental", "since": "2026.9.7" },
32
+ { "name": "Responsive homepage imagery and chronological all-post archives", "stage": "experimental", "since": "2026.9.7" },
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" }
31
35
  ],
32
36
  "releaseHistory": [
33
37
  {
@@ -64,7 +68,19 @@
64
68
  "version": "2026.9.6",
65
69
  "date": "2026-09-25",
66
70
  "request": 6,
67
- "notes": "Add a localized Posts link, accessible code-copy controls, responsive command wrapping, and linked post contents; refresh the bundled theme palettes and set the canonical site URL and author metadata."
71
+ "notes": "Add a localized Posts link, accessible code-copy controls, responsive command wrapping, linked post contents, and refreshed bundled theme palettes."
72
+ },
73
+ {
74
+ "version": "2026.9.7",
75
+ "date": "2026-09-26",
76
+ "request": 7,
77
+ "notes": "Add consent-gated Cloudflare Web Analytics and configuration-generated privacy disclosures; optimize responsive homepage imagery and all-post archive ordering; improve Markdown excerpts and mobile consent controls; remove obsolete report cleanup code."
78
+ },
79
+ {
80
+ "version": "2026.9.8",
81
+ "date": "2026-09-26",
82
+ "request": 8,
83
+ "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."
68
84
  }
69
85
  ]
70
86
  }
package/src/build.js CHANGED
@@ -4,14 +4,15 @@ import { dirname, relative, resolve, sep } from 'node:path';
4
4
  import { hostname } from 'node:os';
5
5
  import { mapLimit } from './concurrency.js';
6
6
  import { loadConfig } from './config.js';
7
- import { readDocuments } from './content.js';
7
+ import { readDocuments, sortPostsNewest } from './content.js';
8
+ import { plainText } from './markdown.js';
8
9
  import { createExtensions } from './plugin-api.js';
9
10
  import { collectAssets, rewriteAssetLinks, writeAssets } from './assets.js';
10
11
  import { loadLanguagePacks } from './i18n.js';
11
12
  import { generateBuiltinRoutes } from './generators.js';
12
13
 
13
14
  const RENDER_CACHE_VERSION = 3;
14
- const MARKDOWN_SECURITY_POLICY_VERSION = 3;
15
+ const MARKDOWN_SECURITY_POLICY_VERSION = 4;
15
16
 
16
17
  async function readCache(file) {
17
18
  try {
@@ -222,7 +223,7 @@ export async function buildSite(root = process.cwd(), options = {}) {
222
223
  if (typeof html !== 'string') throw new Error('Markdown renderers must return HTML strings for ' + document.file);
223
224
  if (renderer.cacheable) nextCache[signature] = html;
224
225
  const rendered = { ...document, html };
225
- rendered.description = rendered.description || rendered.markdown.replace(/\s+/g, ' ').slice(0, 180);
226
+ rendered.description = rendered.description || plainText(rendered.markdown).slice(0, 180);
226
227
  return rendered;
227
228
  });
228
229
 
@@ -230,7 +231,7 @@ export async function buildSite(root = process.cwd(), options = {}) {
230
231
  const visible = documents.filter((document) => !document.draft && publishedByLocalDate(document, now));
231
232
  let site = {
232
233
  config,
233
- posts: visible.filter((document) => document.kind === 'posts').sort((a, b) => b.date - a.date),
234
+ posts: sortPostsNewest(visible.filter((document) => document.kind === 'posts')),
234
235
  pages: visible.filter((document) => document.kind === 'pages')
235
236
  };
236
237
  site = await extensions.filter('content:rendered', site, { config });
package/src/cli.js CHANGED
@@ -292,17 +292,7 @@ async function clean(config) {
292
292
  try {
293
293
  const info = await lstat(toolsDirectory);
294
294
  if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('Refusing to clean reports through an unsafe tools path: ' + toolsDirectory);
295
- for (const name of ['page-check.json', 'page-check.md', 'page-check.pdf', 'page-check-visual.html']) {
296
- await rm(resolve(toolsDirectory, name), { force: true });
297
- }
298
- const screenshots = resolve(toolsDirectory, 'page-check-screenshots');
299
- try {
300
- const screenshotsInfo = await lstat(screenshots);
301
- if (screenshotsInfo.isSymbolicLink() || !screenshotsInfo.isDirectory()) throw new Error('Refusing to clean an unsafe screenshot path: ' + screenshots);
302
- await rm(screenshots, { recursive: true, force: true });
303
- } catch (error) {
304
- if (error.code !== 'ENOENT') throw error;
305
- }
295
+ await rm(resolve(toolsDirectory, 'page-check.pdf'), { force: true });
306
296
  } catch (error) {
307
297
  if (error.code !== 'ENOENT') throw error;
308
298
  }
package/src/config.js CHANGED
@@ -20,7 +20,7 @@ const defaults = {
20
20
  policyUrl: '/privacy-policy/'
21
21
  },
22
22
  browserPlugins: {
23
- consent: { enabled: true, version: '1', expiresDays: 180 },
23
+ consent: { enabled: true, proposedDate: '', effectiveDate: '', expiresDays: 180 },
24
24
  tracking: { enabled: true, services: [] },
25
25
  statistics: { enabled: true, services: [] },
26
26
  advertising: { enabled: true, services: [] },
@@ -51,6 +51,31 @@ function assertString(value, label) {
51
51
  if (typeof value !== 'string' || !value.trim()) throw new Error(label + ' must be a non-empty string');
52
52
  }
53
53
 
54
+ function isDateOnly(value) {
55
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
56
+ const date = new Date(value + 'T00:00:00.000Z');
57
+ return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value;
58
+ }
59
+
60
+ function validateLocalizedServiceText(value, label, maxLength, locales) {
61
+ if (typeof value === 'string') {
62
+ if (!value.trim() || value.length > maxLength) throw new Error(label + ' must be a non-empty string of at most ' + maxLength + ' characters');
63
+ return;
64
+ }
65
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
66
+ throw new Error(label + ' must be a string or a locale-to-string map');
67
+ }
68
+ for (const [locale, text] of Object.entries(value)) {
69
+ if (!locales.includes(locale)) throw new Error(label + ' uses unsupported locale ' + locale);
70
+ if (typeof text !== 'string' || !text.trim() || text.length > maxLength) {
71
+ throw new Error(label + '.' + locale + ' must be a non-empty string of at most ' + maxLength + ' characters');
72
+ }
73
+ }
74
+ for (const locale of locales) if (!Object.prototype.hasOwnProperty.call(value, locale)) {
75
+ throw new Error(label + ' needs a translation for every configured locale, including ' + locale);
76
+ }
77
+ }
78
+
54
79
  const integrationProviders = {
55
80
  'google-tag-manager': { group: 'tracking', credential: 'containerId', pattern: /^GTM-[A-Z0-9]{4,20}$/i },
56
81
  'meta-pixel': { group: 'tracking', credential: 'pixelId', pattern: /^[0-9]{6,20}$/ },
@@ -169,6 +194,29 @@ function validatePrivacyConfig(config) {
169
194
  if (!validPolicyUrl || /[\x00-\x20]/.test(policyUrl)) throw new Error('privacy.policyUrl must be a safe site path or HTTP URL');
170
195
  }
171
196
 
197
+ function normalizeBrowserPlugins(value) {
198
+ if (value === undefined) return {};
199
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('config.yml plugins must be an object');
200
+ for (const key of Object.keys(value)) {
201
+ if (key !== 'consent') {
202
+ throw new Error('Unsupported plugin configuration group: ' + key + '; place browser services under plugins.consent.' + key);
203
+ }
204
+ }
205
+ if (value.consent === undefined) return {};
206
+ const input = value.consent;
207
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('plugins.consent must be an object');
208
+ const consent = {};
209
+ const normalized = {};
210
+ const groups = ['tracking', 'statistics', 'advertising', 'captcha'];
211
+ for (const [key, section] of Object.entries(input)) {
212
+ if (groups.includes(key)) normalized[key] = section;
213
+ else if (['enabled', 'proposedDate', 'effectiveDate', 'expiresDays'].includes(key)) consent[key] = section;
214
+ else throw new Error('Unsupported plugins.consent option: ' + key);
215
+ }
216
+ normalized.consent = consent;
217
+ return normalized;
218
+ }
219
+
172
220
  function validateBrowserPlugins(config) {
173
221
  const plugins = config.browserPlugins;
174
222
  const allowedGroups = ['consent', 'tracking', 'statistics', 'advertising', 'captcha'];
@@ -176,33 +224,43 @@ function validateBrowserPlugins(config) {
176
224
  for (const key of Object.keys(plugins)) if (!allowedGroups.includes(key)) throw new Error('Unsupported plugin configuration group: ' + key);
177
225
  const consent = plugins.consent;
178
226
  if (!consent || typeof consent !== 'object' || Array.isArray(consent) || typeof consent.enabled !== 'boolean' ||
179
- typeof consent.version !== 'string' || !consent.version.trim() || !Number.isInteger(consent.expiresDays) ||
227
+ !isDateOnly(consent.proposedDate) ||
228
+ (consent.effectiveDate !== '' && consent.effectiveDate !== undefined && !isDateOnly(consent.effectiveDate)) ||
229
+ !Number.isInteger(consent.expiresDays) ||
180
230
  consent.expiresDays < 1 || consent.expiresDays > 730) {
181
- throw new Error('plugins.consent needs enabled, version, and expiresDays from 1 to 730');
231
+ throw new Error('plugins.consent needs enabled, a YYYY-MM-DD proposedDate, an optional YYYY-MM-DD effectiveDate, and expiresDays from 1 to 730');
182
232
  }
183
- for (const key of Object.keys(consent)) if (!['enabled', 'version', 'expiresDays'].includes(key)) {
233
+ if (consent.effectiveDate && consent.effectiveDate < consent.proposedDate) {
234
+ throw new Error('plugins.consent.effectiveDate cannot be earlier than proposedDate');
235
+ }
236
+ for (const key of Object.keys(consent)) if (!['enabled', 'proposedDate', 'effectiveDate', 'expiresDays'].includes(key)) {
184
237
  throw new Error('Unsupported plugins.consent option: ' + key);
185
238
  }
186
239
  const ids = new Set();
187
240
  let servicesCount = 0;
188
241
  for (const group of ['tracking', 'statistics', 'advertising', 'captcha']) {
242
+ const groupPath = 'plugins.consent.' + group;
189
243
  const section = plugins[group];
190
244
  if (!section || typeof section !== 'object' || Array.isArray(section) || typeof section.enabled !== 'boolean' || !Array.isArray(section.services)) {
191
- throw new Error('plugins.' + group + ' needs enabled and a services array');
245
+ throw new Error(groupPath + ' needs enabled and a services array');
192
246
  }
193
- for (const key of Object.keys(section)) if (!['enabled', 'services'].includes(key)) throw new Error('Unsupported plugins.' + group + ' option: ' + key);
194
- if (!section.enabled && section.services.length) throw new Error('Clear plugins.' + group + '.services or enable the plugin group');
247
+ for (const key of Object.keys(section)) if (!['enabled', 'services'].includes(key)) throw new Error('Unsupported ' + groupPath + ' option: ' + key);
248
+ if (!section.enabled && section.services.length) throw new Error('Clear ' + groupPath + '.services or enable the plugin group');
195
249
  servicesCount += section.services.length;
196
250
  for (const service of section.services) {
197
- if (!service || typeof service !== 'object' || Array.isArray(service)) throw new Error('Each plugins.' + group + '.services item must be an object');
251
+ if (!service || typeof service !== 'object' || Array.isArray(service)) throw new Error('Each ' + groupPath + '.services item must be an object');
198
252
  const { id, provider, purpose } = service;
199
253
  if (typeof id !== 'string' || !/^[a-z][a-z0-9-]{1,63}$/.test(id) || ids.has(id)) throw new Error('Each configured service needs a unique lowercase id');
200
254
  ids.add(id);
201
255
  const definition = integrationProviders[provider];
202
- if (!definition || definition.group !== group) throw new Error('Unsupported provider for plugins.' + group + ': ' + provider);
203
- if (typeof purpose !== 'string' || !purpose.trim() || purpose.length > 500) throw new Error('Service ' + id + ' needs a short purpose description');
204
- if (typeof service.retention !== 'string' || !service.retention.trim() || service.retention.length > 300) throw new Error('Service ' + id + ' needs a retention description');
205
- const allowedKeys = new Set(['id', 'provider', 'purpose', 'retention', definition.credential]);
256
+ if (!definition || definition.group !== group) throw new Error('Unsupported provider for ' + groupPath + ': ' + provider);
257
+ const locales = config.i18n.locales;
258
+ validateLocalizedServiceText(service.name || provider, 'Service ' + id + '.name', 120, locales);
259
+ validateLocalizedServiceText(purpose, 'Service ' + id + '.purpose', 500, locales);
260
+ validateLocalizedServiceText(service.dataCategories, 'Service ' + id + '.dataCategories', 600, locales);
261
+ validateLocalizedServiceText(service.recipient, 'Service ' + id + '.recipient', 200, locales);
262
+ validateLocalizedServiceText(service.retention, 'Service ' + id + '.retention', 300, locales);
263
+ const allowedKeys = new Set(['id', 'provider', 'name', 'purpose', 'dataCategories', 'recipient', 'retention', definition.credential]);
206
264
  for (const key of Object.keys(service)) if (!allowedKeys.has(key)) throw new Error('Unsupported option for service ' + id + ': ' + key);
207
265
  const credential = service[definition.credential];
208
266
  if (typeof credential !== 'string' || !definition.pattern.test(credential)) throw new Error('Service ' + id + ' needs a valid public ' + definition.credential);
@@ -287,14 +345,13 @@ export async function loadConfig(root = process.cwd()) {
287
345
  }
288
346
  config.site = merge(config.site, siteYaml.site);
289
347
  config.privacy = merge(config.privacy, siteYaml.privacy);
290
- config.browserPlugins = merge(config.browserPlugins, siteYaml.plugins);
348
+ config.browserPlugins = merge(config.browserPlugins, normalizeBrowserPlugins(siteYaml.plugins));
291
349
  assertString(config.site.title, 'site.title');
292
350
  assertString(config.site.language, 'site.language');
293
351
  if (!isCanonicalLocale(config.site.language)) throw new Error('site.language must be a valid canonical language tag');
294
352
  if (typeof config.site.description !== 'string') throw new Error('site.description must be a string');
295
353
  validateSiteConfig(config);
296
354
  validatePrivacyConfig(config);
297
- validateBrowserPlugins(config);
298
355
  if (typeof config.markdown.gfm !== 'boolean' || typeof config.markdown.breaks !== 'boolean') {
299
356
  throw new Error('markdown.gfm and markdown.breaks must be booleans');
300
357
  }
@@ -329,6 +386,7 @@ export async function loadConfig(root = process.cwd()) {
329
386
  throw new Error('i18n.languagePacks must not contain duplicates');
330
387
  }
331
388
  config.i18n.locales = [config.i18n.defaultLocale, ...config.i18n.languagePacks];
389
+ validateBrowserPlugins(config);
332
390
 
333
391
  const siteUrl = String(config.site.url ?? '').trim();
334
392
  if (siteUrl) {
package/src/content.js CHANGED
@@ -3,6 +3,27 @@ import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
3
3
  import { parse as parseYaml } from 'yaml';
4
4
  import { mapLimit } from './concurrency.js';
5
5
 
6
+ export function sortPostsNewest(posts) {
7
+ return [...posts].sort((left, right) => {
8
+ const dateOrder = right.date - left.date;
9
+ if (dateOrder) return dateOrder;
10
+ const leftPath = String(left.path);
11
+ const rightPath = String(right.path);
12
+ return rightPath.localeCompare(leftPath, undefined, { numeric: true, sensitivity: 'base' });
13
+ });
14
+ }
15
+
16
+ export function postsForLocale(posts, locale, defaultLocale) {
17
+ const byBundle = new Map();
18
+ for (const post of posts) {
19
+ const current = byBundle.get(post.bundlePath);
20
+ if (!current || post.locale === locale || (current.locale !== locale && post.locale === defaultLocale)) {
21
+ byBundle.set(post.bundlePath, post);
22
+ }
23
+ }
24
+ return sortPostsNewest([...byBundle.values()]);
25
+ }
26
+
6
27
  async function walkMarkdown(directory) {
7
28
  let entries;
8
29
  try {
@@ -174,7 +195,7 @@ export async function readDocuments(config) {
174
195
  }
175
196
  if (locale !== config.i18n.defaultLocale && metadata.homepage !== true) path = locale + '/' + path;
176
197
  return {
177
- kind, file, relativePath, title, slug, date, locale,
198
+ kind, file, relativePath, bundlePath, title, slug, date, locale,
178
199
  author: kind === 'posts' ? normalizeAuthor(metadata.author, file) : '',
179
200
  videoId: (kind === 'posts' ? 'post-' : 'page-') + slugify(bundlePath + '-' + locale.toLowerCase()),
180
201
  video: kind === 'posts' ? normalizeVideo(metadata.video, file) : null,
package/src/generators.js CHANGED
@@ -1,8 +1,8 @@
1
- import { plainText } from './markdown.js';
1
+ import { plainText, renderMarkdownExcerpt } from './markdown.js';
2
2
  import { renderBlocks } from './page-blocks.js';
3
3
  import { renderLayout } from './theme.js';
4
4
  import { localizedUrl, translate } from './i18n.js';
5
- import { slugify } from './content.js';
5
+ import { postsForLocale, slugify, sortPostsNewest } from './content.js';
6
6
  import { mapLimit } from './concurrency.js';
7
7
 
8
8
  function escapeHtml(value) {
@@ -120,12 +120,13 @@ function renderPostAuthor(post, locale, config) {
120
120
  return '<span class="post-author">' + escapeHtml(translate(config, locale, 'postAuthor').replace('{author}', post.author)) + '</span>';
121
121
  }
122
122
 
123
- function renderPostCard(post, locale, config) {
124
- const summary = post.description || plainText(post.markdown).slice(0, 220);
125
- return '<article class="post-card"><h2><a href="' + escapeHtml(urlFor(post.path)) + '">' + escapeHtml(post.title) + '</a></h2>' +
126
- '<p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, locale)) + '</time>' +
127
- (post.author ? ' · ' + renderPostAuthor(post, locale, config) : '') + '</p><p>' +
128
- escapeHtml(summary) + '</p></article>';
123
+ async function renderPostCard(post, locale, config, showLanguage = false) {
124
+ const summary = await renderMarkdownExcerpt(post.description || post.markdown, config.markdown);
125
+ return '<article class="post-card" lang="' + escapeHtml(post.locale) + '"><h2><a href="' + escapeHtml(urlFor(post.path)) + '">' + escapeHtml(post.title) + '</a></h2>' +
126
+ '<p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, post.locale)) + '</time>' +
127
+ (showLanguage ? ' · <span class="post-language" lang="' + escapeHtml(post.locale) + '">' + escapeHtml(post.locale) + '</span>' : '') +
128
+ (post.author ? ' · ' + renderPostAuthor(post, locale, config) : '') + '</p><div class="post-excerpt" lang="' + escapeHtml(post.locale) + '">' +
129
+ summary + '</div></article>';
129
130
  }
130
131
 
131
132
  async function pageBodyForDocument(page, site, config, locale, isHomepage = false, renderContext = {}) {
@@ -168,16 +169,17 @@ export async function generateBuiltinRoutes(site, config, extensions) {
168
169
  homepageLocales.add(page.locale);
169
170
  }
170
171
 
172
+ const allPosts = sortPostsNewest(site.posts);
171
173
  for (const locale of config.i18n.locales) {
172
- const posts = site.posts.filter((post) => post.locale === locale).sort((a, b) => b.date - a.date);
174
+ const posts = allPosts.filter((post) => post.locale === locale);
175
+ const archivePosts = postsForLocale(allPosts, locale, defaultLocale);
173
176
  const pages = site.pages.filter((page) => page.locale === locale && !page.homepage);
174
177
  const homePage = homepageDocument(site.pages, locale, defaultLocale);
175
178
  const latestPostsPageSize = paginatedLatestPostsSize(homePage);
176
179
  if (latestPostsPageSize !== null && (!Number.isInteger(latestPostsPageSize) || latestPostsPageSize < 1 || latestPostsPageSize > 12)) {
177
180
  throw new Error('A paginated latest-posts block must use a count from 1 to 12');
178
181
  }
179
- const homepagePosts = posts.length ? posts : site.posts.filter((post) => post.locale === defaultLocale).sort((a, b) => b.date - a.date);
180
- const homepageArchivePages = latestPostsPageSize ? Math.ceil(homepagePosts.length / latestPostsPageSize) : 0;
182
+ const homepageArchivePages = latestPostsPageSize ? Math.ceil(archivePosts.length / latestPostsPageSize) : 0;
181
183
  const prefix = locale === defaultLocale ? '' : locale + '/';
182
184
  const label = (key) => translate(config, locale, key);
183
185
  const localeHomeUrl = localizedUrl(config, locale, '');
@@ -205,12 +207,13 @@ export async function generateBuiltinRoutes(site, config, extensions) {
205
207
  }
206
208
  }));
207
209
  } else {
208
- const totalPages = Math.max(1, Math.ceil(posts.length / config.pagination.perPage));
210
+ const totalPages = Math.max(1, Math.ceil(archivePosts.length / config.pagination.perPage));
209
211
  for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
210
- const subset = posts.slice((pageNumber - 1) * config.pagination.perPage, pageNumber * config.pagination.perPage);
212
+ const subset = archivePosts.slice((pageNumber - 1) * config.pagination.perPage, pageNumber * config.pagination.perPage);
213
+ const cards = await Promise.all(subset.map((post) => renderPostCard(post, locale, config, true)));
211
214
  const body = '<section class="intro"><h1>' + escapeHtml(config.site.title) + '</h1><p>' + escapeHtml(config.site.description) +
212
215
  '</p></section><section class="post-list" aria-label="' + escapeHtml(label('latestPosts')) + '">' +
213
- (subset.length ? subset.map((post) => renderPostCard(post, locale, config)).join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') +
216
+ (cards.length ? cards.join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') +
214
217
  '</section>' + (pageNumber === 1 && pages.length ? '<section class="page-links"><h2>' + escapeHtml(label('pages')) + '</h2><ul>' +
215
218
  pages.map((page) => '<li><a href="' + escapeHtml(urlFor(page.path)) + '">' + escapeHtml(page.title) + '</a></li>').join('') + '</ul></section>' : '') +
216
219
  (totalPages > 1 ? '<nav class="pagination" aria-label="' + escapeHtml(label('pagination')) + '">' +
@@ -258,9 +261,10 @@ export async function generateBuiltinRoutes(site, config, extensions) {
258
261
 
259
262
  if (!homePage) allPagePaths.add(localizedUrl(config, locale, ''));
260
263
  const archivePageSize = latestPostsPageSize || config.pagination.perPage;
261
- const archivePageCount = Math.max(1, Math.ceil(posts.length / archivePageSize));
264
+ const archivePageCount = Math.max(1, Math.ceil(archivePosts.length / archivePageSize));
262
265
  for (let pageNumber = 1; pageNumber <= archivePageCount; pageNumber += 1) {
263
- const subset = posts.slice((pageNumber - 1) * archivePageSize, pageNumber * archivePageSize);
266
+ const subset = archivePosts.slice((pageNumber - 1) * archivePageSize, pageNumber * archivePageSize);
267
+ const cards = await Promise.all(subset.map((post) => renderPostCard(post, locale, config, true)));
264
268
  const archiveUrl = pageNumber === 1 ? 'archives/' : 'archives/page/' + pageNumber + '/';
265
269
  const pagination = archivePageCount > 1
266
270
  ? '<nav class="pagination" aria-label="' + escapeHtml(label('pagination')) + '">' +
@@ -269,7 +273,7 @@ export async function generateBuiltinRoutes(site, config, extensions) {
269
273
  (pageNumber < archivePageCount ? '<a rel="next" href="' + escapeHtml(localizedUrl(config, locale, 'archives/page/' + (pageNumber + 1) + '/')) + '">' +
270
274
  escapeHtml(label('older')) + '</a>' : '') + '</nav>' : '';
271
275
  const archiveBody = '<section><h1>' + escapeHtml(label('archives')) + '</h1>' +
272
- (subset.length ? subset.map((post) => renderPostCard(post, locale, config)).join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') + pagination + '</section>';
276
+ (cards.length ? cards.join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') + pagination + '</section>';
273
277
  routes.push(await pageRoute(prefix + (pageNumber === 1 ? 'archives/index.html' : 'archives/page/' + pageNumber + '/index.html'),
274
278
  pageNumber === 1 ? label('archives') : label('archives') + ' · ' + pageNumber, label('allPosts'), archiveBody, locale, config, extensions,
275
279
  { urlPath: localizedUrl(config, locale, archiveUrl) }));
@@ -283,15 +287,16 @@ export async function generateBuiltinRoutes(site, config, extensions) {
283
287
  tags.get(slug).posts.push(post);
284
288
  }
285
289
  const tagRoutes = await mapLimit([...tags.entries()], config.concurrency, async ([slug, value]) => {
290
+ const cards = await Promise.all(value.posts.map((post) => renderPostCard(post, locale, config)));
286
291
  const body = '<section><h1>' + escapeHtml(label('tag')) + ': ' + escapeHtml(value.label) + '</h1>' +
287
- value.posts.map((post) => renderPostCard(post, locale, config)).join('') + '</section>';
292
+ cards.join('') + '</section>';
288
293
  return pageRoute(prefix + 'tags/' + slug + '/index.html', label('tag') + ': ' + value.label,
289
294
  label('tag') + ' ' + value.label, body, locale, config, extensions);
290
295
  });
291
296
  routes.push(...tagRoutes);
292
297
 
293
298
  const searchable = [
294
- ...posts.map((post) => ({ title: post.title, author: post.author, date: post.date.toISOString(), url: urlFor(post.path), summary: post.description || plainText(post.markdown).slice(0, 220), content: plainText(post.markdown), type: 'post' })),
299
+ ...posts.map((post) => ({ title: post.title, author: post.author, date: post.date.toISOString(), url: urlFor(post.path), summary: plainText(post.description || post.markdown).slice(0, 220), content: plainText(post.markdown), type: 'post' })),
295
300
  ...pages.map((page) => ({ title: page.title, url: urlFor(page.path), summary: page.description || plainText(page.markdown).slice(0, 220), type: 'page' }))
296
301
  ];
297
302
  routes.push(fileRoute(prefix + 'search.json', JSON.stringify(searchable), 'application/json; charset=utf-8'));
@@ -312,7 +317,7 @@ export async function generateBuiltinRoutes(site, config, extensions) {
312
317
  const entries = posts.slice(0, 20).map((post) => '<entry><title>' + escapeXml(post.title) + '</title>' +
313
318
  (post.author ? '<author><name>' + escapeXml(post.author) + '</name></author>' : '') + '<link href="' +
314
319
  escapeXml(base + urlFor(post.path)) + '"/><id>' + escapeXml(base + urlFor(post.path)) + '</id><updated>' + post.date.toISOString() +
315
- '</updated><summary>' + escapeXml(post.description || plainText(post.markdown).slice(0, 220)) + '</summary></entry>').join('');
320
+ '</updated><summary>' + escapeXml(plainText(post.description || post.markdown).slice(0, 220)) + '</summary></entry>').join('');
316
321
  routes.push(fileRoute(prefix + 'feed.xml', '<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>' +
317
322
  escapeXml(config.site.title) + '</title><id>' + escapeXml(base || 'urn:edgepress:site') + '</id><updated>' +
318
323
  (posts[0]?.date.toISOString() || new Date(0).toISOString()) + '</updated>' + entries + '</feed>', 'application/atom+xml; charset=utf-8'));
package/src/i18n.js CHANGED
@@ -53,6 +53,12 @@ export function translate(config, locale, key) {
53
53
  return selected?.[key] ?? base?.[key] ?? key;
54
54
  }
55
55
 
56
+ export function translateValue(config, locale, value) {
57
+ if (typeof value === 'string') return value;
58
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
59
+ return value[locale] ?? value[config.i18n.defaultLocale] ?? Object.values(value).find((text) => typeof text === 'string') ?? '';
60
+ }
61
+
56
62
  export function localePrefix(config, locale) {
57
63
  return locale === config.i18n.defaultLocale ? '' : '/' + locale;
58
64
  }
package/src/markdown.js CHANGED
@@ -7,9 +7,9 @@ const allowedTags = [
7
7
  'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'h1', 'h2',
8
8
  'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'i', 'img', 'kbd', 'li', 'main',
9
9
  'mark', 'nav', 'ol', 'p', 'pre', 'q', 'rp', 'rt', 'rtc', 'ruby', 's',
10
- 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary', 'sup',
11
- 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u', 'ul',
12
- 'var', 'wbr'
10
+ 'samp', 'section', 'small', 'source', 'span', 'strong', 'sub', 'summary', 'sup',
11
+ 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'track', 'tr', 'u', 'ul',
12
+ 'var', 'video', 'wbr'
13
13
  ];
14
14
 
15
15
  const sanitizeOptions = {
@@ -20,9 +20,12 @@ const sanitizeOptions = {
20
20
  img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
21
21
  li: ['value'],
22
22
  ol: ['start'],
23
+ source: ['src', 'type'],
23
24
  span: ['class', 'role', 'aria-label'],
24
25
  td: ['align', 'colspan', 'rowspan'],
25
- th: ['align', 'colspan', 'rowspan']
26
+ th: ['align', 'colspan', 'rowspan'],
27
+ track: ['kind', 'src', 'srclang', 'label', 'default'],
28
+ video: ['aria-label', 'controls', 'playsinline', 'poster', 'preload', 'width', 'height']
26
29
  },
27
30
  allowedSchemes: ['http', 'https', 'mailto', 'tel'],
28
31
  allowProtocolRelative: false,
@@ -35,10 +38,59 @@ const sanitizeOptions = {
35
38
  }
36
39
  };
37
40
 
41
+ function videoMimeType(href) {
42
+ const path = String(href ?? '').split(/[?#]/, 1)[0].toLowerCase();
43
+ const extension = path.match(/\.(mp4|webm|ogv|ogg)$/)?.[1];
44
+ return ({ mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', ogg: 'video/ogg' })[extension] || '';
45
+ }
46
+
47
+ function safeVideoHref(value) {
48
+ const href = String(value ?? '').trim();
49
+ if (!href || /[\x00-\x20\\]/.test(href) || href.startsWith('//')) {
50
+ throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs');
51
+ }
52
+ if (href.startsWith('/')) return href;
53
+ let url;
54
+ try { url = new URL(href, 'https://edgepress.invalid'); }
55
+ catch { throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs'); }
56
+ if (url.username || url.password || !['http:', 'https:'].includes(url.protocol)) {
57
+ throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs');
58
+ }
59
+ return href;
60
+ }
61
+
62
+ function escapeHtml(value) {
63
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
64
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
65
+ })[char]);
66
+ }
67
+
68
+ function markdownRenderer(options) {
69
+ const renderer = new marked.Renderer();
70
+ const renderImage = renderer.image.bind(renderer);
71
+ renderer.image = (token) => {
72
+ const mimeType = videoMimeType(token.href);
73
+ if (mimeType) {
74
+ const description = String(token.text ?? '').trim();
75
+ if (!description) throw new Error('Markdown videos need descriptive alternative text');
76
+ const href = escapeHtml(safeVideoHref(token.href));
77
+ if (options.allowVideo === false) return '<span>' + escapeHtml(description) + '</span>';
78
+ const caption = token.title ? '<figcaption>' + escapeHtml(token.title) + '</figcaption>' : '';
79
+ return '<figure class="markdown-video"><video controls playsinline preload="none" aria-label="' +
80
+ escapeHtml(description) + '"><source src="' + href + '" type="' + mimeType + '"><a href="' + href + '">' +
81
+ escapeHtml(description) + '</a></video>' + caption + '</figure>';
82
+ }
83
+ if (options.allowImages === false) return '<span>' + escapeHtml(token.text || '') + '</span>';
84
+ return renderImage(token);
85
+ };
86
+ return renderer;
87
+ }
88
+
38
89
  export async function renderMarkdown(source, options = {}) {
39
90
  let html = await marked.parse(source, {
40
91
  gfm: options.gfm !== false,
41
- breaks: options.breaks === true
92
+ breaks: options.breaks === true,
93
+ renderer: markdownRenderer(options)
42
94
  });
43
95
  // GFM task markers become named static status icons. They remain readable to
44
96
  // assistive technology without exposing disabled, unlabeled form controls.
@@ -55,11 +107,38 @@ export async function renderMarkdown(source, options = {}) {
55
107
  return sanitizeHtml(html, sanitizeOptions);
56
108
  }
57
109
 
110
+ export async function renderMarkdownExcerpt(source, options = {}) {
111
+ const markdown = String(source ?? '');
112
+ const tokens = marked.lexer(markdown, { gfm: options.gfm !== false });
113
+ const paragraph = tokens.find((token) => token.type === 'paragraph');
114
+ if (!paragraph) return '<p>' + escapeHtml(plainText(markdown).slice(0, 220)) + '</p>';
115
+ return renderMarkdown(paragraph.raw, { ...options, allowImages: false, allowVideo: false });
116
+ }
117
+
58
118
  export function plainText(source) {
59
- return String(source)
60
- .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
61
- .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
62
- .replace(/[#>*_~|-]/g, ' ')
119
+ const html = marked.parse(String(source ?? ''), { gfm: true });
120
+ const namedEntities = {
121
+ amp: '&', apos: "'", copy: '©', gt: '>', hellip: '…', ldquo: '“', lsquo: '‘',
122
+ lt: '<', mdash: '—', nbsp: ' ', ndash: '–', quot: '"', rdquo: '”', rsquo: '’'
123
+ };
124
+ return String(html)
125
+ .replace(/<!--[\s\S]*?-->/g, ' ')
126
+ .replace(/<(script|style|textarea|option|xmp)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
127
+ .replace(/<img\b([^>]*)>/gi, (_match, attributes) => {
128
+ const alt = attributes.match(/\balt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
129
+ return alt ? ' ' + (alt[1] ?? alt[2] ?? alt[3]) + ' ' : ' ';
130
+ })
131
+ .replace(/<[^>]*>/g, ' ')
132
+ .replace(/&(#x[\da-f]+|#\d+|[a-z][a-z\d]+);/gi, (match, entity) => {
133
+ if (entity[0] === '#') {
134
+ const hex = entity[1]?.toLowerCase() === 'x';
135
+ const point = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
136
+ return Number.isInteger(point) && point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
137
+ ? String.fromCodePoint(point)
138
+ : match;
139
+ }
140
+ return namedEntities[entity.toLowerCase()] ?? match;
141
+ })
63
142
  .replace(/\s+/g, ' ')
64
143
  .trim();
65
144
  }