edgepress 2026.9.5 → 2026.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +5 -3
  2. package/config.yml +121 -69
  3. package/content/pages/home/en.md +15 -0
  4. package/content/pages/home/zh-cn.md +16 -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 +8 -5
  8. package/content/pages/privacy-policy/zh-cn.md +8 -5
  9. package/content/pages/theme-development/en.md +24 -0
  10. package/content/pages/theme-development/zh-cn.md +24 -0
  11. package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +12 -2
  12. package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +12 -2
  13. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +2 -0
  14. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +2 -0
  15. package/content/posts/2026-09-25-edgepress-release-2026-9-2/en.md +2 -0
  16. package/content/posts/2026-09-25-edgepress-release-2026-9-2/zh-cn.md +2 -0
  17. package/content/posts/2026-09-25-edgepress-release-2026-9-3/en.md +2 -0
  18. package/content/posts/2026-09-25-edgepress-release-2026-9-3/zh-cn.md +2 -0
  19. package/content/posts/2026-09-25-edgepress-release-2026-9-4/en.md +2 -0
  20. package/content/posts/2026-09-25-edgepress-release-2026-9-4/zh-cn.md +2 -0
  21. package/content/posts/2026-09-25-edgepress-release-2026-9-5/en.md +2 -0
  22. package/content/posts/2026-09-25-edgepress-release-2026-9-5/zh-cn.md +2 -0
  23. package/content/posts/2026-09-25-edgepress-release-2026-9-6/en.md +31 -0
  24. package/content/posts/2026-09-25-edgepress-release-2026-9-6/zh-cn.md +31 -0
  25. package/content/posts/2026-09-26-edgepress-release-2026-9-7/en.md +26 -0
  26. package/content/posts/2026-09-26-edgepress-release-2026-9-7/zh-cn.md +26 -0
  27. package/content/posts/2026-09-26-edgepress-release-2026-9-8/en.md +26 -0
  28. package/content/posts/2026-09-26-edgepress-release-2026-9-8/zh-cn.md +26 -0
  29. package/languages/base/en.json +9 -2
  30. package/languages/packs/zh-CN.json +9 -2
  31. package/package.json +2 -1
  32. package/plugins/consent/index.js +4 -2
  33. package/project-compatibility.json +26 -2
  34. package/src/build.js +5 -4
  35. package/src/cli.js +8 -13
  36. package/src/config.js +72 -14
  37. package/src/content.js +55 -1
  38. package/src/generators.js +125 -23
  39. package/src/i18n.js +6 -0
  40. package/src/icons.js +16 -0
  41. package/src/maintenance.js +0 -27
  42. package/src/markdown.js +88 -9
  43. package/src/page-blocks.js +84 -18
  44. package/src/page-check.js +79 -25
  45. package/src/report-visuals.js +111 -16
  46. package/src/theme.js +13 -2
  47. package/static/edgepress/brand/edgepress-mark.svg +7 -0
  48. package/static/edgepress/brand/mascot-209.webp +0 -0
  49. package/static/edgepress/brand/mascot-418.png +0 -0
  50. package/static/edgepress/brand/mascot-418.webp +0 -0
  51. package/static/edgepress/brand/mascot.png +0 -0
  52. package/static/edgepress/brand/og-image.png +0 -0
  53. package/static/edgepress/brand/og-image.svg +46 -0
  54. package/static/edgepress/code-copy.js +55 -0
  55. package/static/edgepress/icons.svg +24 -0
  56. package/static/edgepress/plugins/consent/manager.js +204 -71
  57. package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +1 -0
  58. package/themes/atelier/assets/style.css +98 -20
  59. package/themes/atelier/layouts/partials/head.html +5 -0
  60. package/themes/atelier/layouts/partials/header.html +1 -1
  61. package/themes/default/assets/style.css +99 -20
  62. package/themes/default/layouts/partials/head.html +3 -0
  63. package/themes/default/layouts/partials/header.html +1 -1
  64. package/themes/signal/assets/style.css +98 -20
  65. package/themes/signal/layouts/partials/head.html +5 -0
  66. package/themes/signal/layouts/partials/header.html +1 -1
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 {
@@ -47,6 +68,36 @@ function normalizeTags(value) {
47
68
  return (Array.isArray(value) ? value : [value]).map(String).map((tag) => tag.trim()).filter(Boolean);
48
69
  }
49
70
 
71
+ function normalizeAuthor(value, file) {
72
+ if (value === undefined || value === null || value === '') return '';
73
+ if (typeof value !== 'string' || !value.trim() || value.length > 160) {
74
+ throw new Error('author must be a non-empty string of at most 160 characters in ' + file);
75
+ }
76
+ return value.trim();
77
+ }
78
+
79
+ function normalizeVideo(value, file) {
80
+ if (value === undefined) return null;
81
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('video must be an object in ' + file);
82
+ for (const key of Object.keys(value)) {
83
+ if (!['src', 'captions', 'captionLanguage', 'captionLabel', 'poster', 'alt', 'title', 'description'].includes(key)) {
84
+ throw new Error('Unsupported video field in ' + file + ': ' + key);
85
+ }
86
+ }
87
+ const localAsset = /^\/edgepress\/media\/[a-z0-9-]+\.(?:mp4|vtt|jpe?g)$/i;
88
+ for (const field of ['src', 'captions', 'poster']) {
89
+ if (typeof value[field] !== 'string' || !localAsset.test(value[field])) {
90
+ throw new Error('video.' + field + ' must reference a local /edgepress/media asset in ' + file);
91
+ }
92
+ }
93
+ if (typeof value.captionLanguage !== 'string' || !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(value.captionLanguage) ||
94
+ typeof value.captionLabel !== 'string' || !value.captionLabel.trim() || typeof value.alt !== 'string' || !value.alt.trim() ||
95
+ typeof value.title !== 'string' || !value.title.trim() || typeof value.description !== 'string' || !value.description.trim()) {
96
+ throw new Error('video needs a language, labels, accessible description, title, and summary in ' + file);
97
+ }
98
+ return value;
99
+ }
100
+
50
101
  function normalizeDate(value, filename, kind, file) {
51
102
  if (value instanceof Date) {
52
103
  if (!Number.isNaN(value.valueOf())) return value;
@@ -144,7 +195,10 @@ export async function readDocuments(config) {
144
195
  }
145
196
  if (locale !== config.i18n.defaultLocale && metadata.homepage !== true) path = locale + '/' + path;
146
197
  return {
147
- kind, file, relativePath, title, slug, date, locale,
198
+ kind, file, relativePath, bundlePath, title, slug, date, locale,
199
+ author: kind === 'posts' ? normalizeAuthor(metadata.author, file) : '',
200
+ videoId: (kind === 'posts' ? 'post-' : 'page-') + slugify(bundlePath + '-' + locale.toLowerCase()),
201
+ video: kind === 'posts' ? normalizeVideo(metadata.video, file) : null,
148
202
  tags: normalizeTags(metadata.tags),
149
203
  description: String(metadata.description ?? ''),
150
204
  keywords: normalizeTags(metadata.keywords),
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) {
@@ -42,6 +42,51 @@ function urlPathForRoute(path) {
42
42
  return '/' + value;
43
43
  }
44
44
 
45
+ function decodeHeadingEntities(value) {
46
+ return value.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (match, entity) => {
47
+ const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
48
+ if (entity[0] === '#') {
49
+ const hex = entity[1]?.toLowerCase() === 'x';
50
+ const point = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
51
+ return Number.isFinite(point) && point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : match;
52
+ }
53
+ return named[entity.toLowerCase()] ?? match;
54
+ });
55
+ }
56
+
57
+ function addPostContents(html, locale, config) {
58
+ const headings = [];
59
+ const counts = new Map();
60
+ const content = html.replace(/<h([2-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi, (whole, levelText, attributes, inner) => {
61
+ const label = decodeHeadingEntities(inner.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim());
62
+ if (!label) return whole;
63
+ const base = slugify(label);
64
+ const count = (counts.get(base) || 0) + 1;
65
+ counts.set(base, count);
66
+ const id = count === 1 ? base : base + '-' + count;
67
+ headings.push({ id, label, level: Number(levelText) });
68
+ const safeAttributes = attributes.replace(/\s+id\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
69
+ return '<h' + levelText + safeAttributes + ' id="' + escapeHtml(id) + '">' + inner + '</h' + levelText + '>';
70
+ });
71
+ if (!headings.length) return { html: content, toc: '' };
72
+ const items = headings.map((heading) => '<li class="post-toc-level-' + heading.level + '"><a href="#' +
73
+ escapeHtml(heading.id) + '">' + escapeHtml(heading.label) + '</a></li>').join('');
74
+ 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>';
77
+ return { html: content, toc };
78
+ }
79
+
80
+ function renderPostVideo(video, locale) {
81
+ if (!video) return '';
82
+ const videoTitle = escapeHtml(video.title);
83
+ return '<figure class="post-video"><video controls playsinline preload="metadata" poster="' + escapeHtml(video.poster) + '" aria-label="' +
84
+ escapeHtml(video.alt) + '"><source src="' + escapeHtml(video.src) + '" type="video/mp4"><track kind="captions" src="' +
85
+ escapeHtml(video.captions) + '" srclang="' + escapeHtml(video.captionLanguage) + '" label="' + escapeHtml(video.captionLabel) + '" default>' +
86
+ '<a href="' + escapeHtml(video.src) + '">' + (locale.toLowerCase().startsWith('zh') ? '下载 MP4 视频' : 'Download the MP4 video') +
87
+ '</a></video><figcaption><strong>' + videoTitle + '</strong><br>' + escapeHtml(video.description) + '</figcaption></figure>';
88
+ }
89
+
45
90
  async function pageRoute(path, title, description, body, locale, config, extensions, metadata = {}) {
46
91
  const urlPath = metadata.urlPath || urlPathForRoute(path);
47
92
  const structuredData = metadata.structuredData ?? {
@@ -53,6 +98,7 @@ async function pageRoute(path, title, description, body, locale, config, extensi
53
98
  inLanguage: locale,
54
99
  isPartOf: config.site.url ? { '@type': 'WebSite', name: config.site.title, url: config.site.url } : undefined
55
100
  };
101
+ body = body.replace(/<pre\b/gi, '<pre data-copyable-code');
56
102
  let html = await renderLayout(config, extensions, {
57
103
  title,
58
104
  description,
@@ -69,21 +115,44 @@ async function pageRoute(path, title, description, body, locale, config, extensi
69
115
  return fileRoute(path, html);
70
116
  }
71
117
 
72
- function renderPostCard(post, locale) {
73
- const summary = post.description || plainText(post.markdown).slice(0, 220);
74
- return '<article class="post-card"><h2><a href="' + escapeHtml(urlFor(post.path)) + '">' + escapeHtml(post.title) + '</a></h2>' +
75
- '<p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, locale)) + '</time></p><p>' +
76
- escapeHtml(summary) + '</p></article>';
118
+ function renderPostAuthor(post, locale, config) {
119
+ if (!post.author) return '';
120
+ return '<span class="post-author">' + escapeHtml(translate(config, locale, 'postAuthor').replace('{author}', post.author)) + '</span>';
121
+ }
122
+
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>';
77
130
  }
78
131
 
79
- async function pageBodyForDocument(page, site, config, locale, isHomepage = false) {
80
- const content = await renderBlocks(page.blocks, { config, locale, site, isHomepage });
132
+ async function pageBodyForDocument(page, site, config, locale, isHomepage = false, renderContext = {}) {
133
+ const content = await renderBlocks(page.blocks, { config, locale, site, isHomepage, ...renderContext });
81
134
  const firstElement = page.blocks[0]?.cells?.[0]?.[0];
82
135
  const needsTitle = !isHomepage || firstElement?.type !== 'hero';
83
136
  const title = needsTitle ? '<header class="page-title"><h1>' + escapeHtml(page.title) + '</h1></header>' : '';
84
137
  return '<article class="page-builder">' + title + content + '</article>';
85
138
  }
86
139
 
140
+ function paginatedLatestPostsSize(page) {
141
+ function find(value) {
142
+ if (Array.isArray(value)) {
143
+ for (const item of value) {
144
+ const result = find(item);
145
+ if (result !== null) return result;
146
+ }
147
+ return null;
148
+ }
149
+ if (!value || typeof value !== 'object') return null;
150
+ if (value.type === 'latest-posts' && value.paginate === true) return value.count ?? 3;
151
+ return find(value.cells) ?? find(value.blocks);
152
+ }
153
+ return find(page?.blocks);
154
+ }
155
+
87
156
  function homepageDocument(pages, locale, defaultLocale) {
88
157
  return pages.find((page) => page.homepage && page.locale === locale) ||
89
158
  pages.find((page) => page.homepage && page.locale === defaultLocale) || null;
@@ -100,17 +169,29 @@ export async function generateBuiltinRoutes(site, config, extensions) {
100
169
  homepageLocales.add(page.locale);
101
170
  }
102
171
 
172
+ const allPosts = sortPostsNewest(site.posts);
103
173
  for (const locale of config.i18n.locales) {
104
- 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);
105
176
  const pages = site.pages.filter((page) => page.locale === locale && !page.homepage);
106
177
  const homePage = homepageDocument(site.pages, locale, defaultLocale);
178
+ const latestPostsPageSize = paginatedLatestPostsSize(homePage);
179
+ if (latestPostsPageSize !== null && (!Number.isInteger(latestPostsPageSize) || latestPostsPageSize < 1 || latestPostsPageSize > 12)) {
180
+ throw new Error('A paginated latest-posts block must use a count from 1 to 12');
181
+ }
182
+ const homepageArchivePages = latestPostsPageSize ? Math.ceil(archivePosts.length / latestPostsPageSize) : 0;
107
183
  const prefix = locale === defaultLocale ? '' : locale + '/';
108
184
  const label = (key) => translate(config, locale, key);
109
185
  const localeHomeUrl = localizedUrl(config, locale, '');
110
186
  allPagePaths.add(localeHomeUrl);
111
187
 
112
188
  if (homePage) {
113
- const body = await pageBodyForDocument(homePage, site, config, locale, true);
189
+ const latestPostsPagination = latestPostsPageSize ? {
190
+ totalPages: homepageArchivePages,
191
+ archiveUrl: localizedUrl(config, locale, 'archives/'),
192
+ olderUrl: localizedUrl(config, locale, 'archives/page/2/')
193
+ } : null;
194
+ const body = await pageBodyForDocument(homePage, site, config, locale, true, { latestPostsPagination });
114
195
  routes.push(await pageRoute(prefix + 'index.html', homePage.title, homePage.description || config.site.description,
115
196
  body, locale, config, extensions, {
116
197
  urlPath: localeHomeUrl,
@@ -126,12 +207,13 @@ export async function generateBuiltinRoutes(site, config, extensions) {
126
207
  }
127
208
  }));
128
209
  } else {
129
- 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));
130
211
  for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
131
- 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)));
132
214
  const body = '<section class="intro"><h1>' + escapeHtml(config.site.title) + '</h1><p>' + escapeHtml(config.site.description) +
133
215
  '</p></section><section class="post-list" aria-label="' + escapeHtml(label('latestPosts')) + '">' +
134
- (subset.length ? subset.map((post) => renderPostCard(post, locale)).join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') +
216
+ (cards.length ? cards.join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') +
135
217
  '</section>' + (pageNumber === 1 && pages.length ? '<section class="page-links"><h2>' + escapeHtml(label('pages')) + '</h2><ul>' +
136
218
  pages.map((page) => '<li><a href="' + escapeHtml(urlFor(page.path)) + '">' + escapeHtml(page.title) + '</a></li>').join('') + '</ul></section>' : '') +
137
219
  (totalPages > 1 ? '<nav class="pagination" aria-label="' + escapeHtml(label('pagination')) + '">' +
@@ -145,8 +227,10 @@ export async function generateBuiltinRoutes(site, config, extensions) {
145
227
 
146
228
  for (const post of posts) allPagePaths.add(urlFor(post.path));
147
229
  const postRoutes = await mapLimit(posts, config.concurrency, async (post) => {
230
+ const renderedMarkdown = addPostContents(post.html, locale, config);
148
231
  const body = '<article class="post"><header><h1>' + escapeHtml(post.title) + '</h1><p class="meta"><time datetime="' + post.date.toISOString() + '">' +
149
- escapeHtml(dateLabel(post.date, locale)) + '</time></p></header><div class="post-content">' + post.html + '</div>' +
232
+ escapeHtml(dateLabel(post.date, locale)) + '</time>' + (post.author ? ' · ' + renderPostAuthor(post, locale, config) : '') +
233
+ '</p></header>' + renderPostVideo(post.video, locale) + renderedMarkdown.toc + '<div class="post-content">' + renderedMarkdown.html + '</div>' +
150
234
  (post.tags.length ? '<p class="tags">' + escapeHtml(label('tags')) + ': ' + post.tags.map((tag) => '<a href="' +
151
235
  escapeHtml(localizedUrl(config, locale, 'tags/' + tagSlug(tag) + '/')) + '">' + escapeHtml(tag) + '</a>').join(' ') + '</p>' : '') + '</article>';
152
236
  const canonicalPath = urlFor(post.path);
@@ -157,7 +241,7 @@ export async function generateBuiltinRoutes(site, config, extensions) {
157
241
  '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title,
158
242
  description: post.description || plainText(post.markdown).slice(0, 160),
159
243
  datePublished: post.date.toISOString(), dateModified: post.date.toISOString(),
160
- author: { '@type': 'Person', name: config.site.seo.author || config.site.title },
244
+ author: { '@type': 'Person', name: post.author || config.site.seo.author || config.site.title },
161
245
  mainEntityOfPage: config.site.url ? config.site.url + canonicalPath : undefined,
162
246
  inLanguage: locale
163
247
  }
@@ -176,9 +260,25 @@ export async function generateBuiltinRoutes(site, config, extensions) {
176
260
  routes.push(...pageRoutes);
177
261
 
178
262
  if (!homePage) allPagePaths.add(localizedUrl(config, locale, ''));
179
- const archiveBody = '<section><h1>' + escapeHtml(label('archives')) + '</h1>' +
180
- (posts.length ? posts.map((post) => renderPostCard(post, locale)).join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') + '</section>';
181
- routes.push(await pageRoute(prefix + 'archives/index.html', label('archives'), label('allPosts'), archiveBody, locale, config, extensions));
263
+ const archivePageSize = latestPostsPageSize || config.pagination.perPage;
264
+ const archivePageCount = Math.max(1, Math.ceil(archivePosts.length / archivePageSize));
265
+ for (let pageNumber = 1; pageNumber <= archivePageCount; pageNumber += 1) {
266
+ const subset = archivePosts.slice((pageNumber - 1) * archivePageSize, pageNumber * archivePageSize);
267
+ const cards = await Promise.all(subset.map((post) => renderPostCard(post, locale, config, true)));
268
+ const archiveUrl = pageNumber === 1 ? 'archives/' : 'archives/page/' + pageNumber + '/';
269
+ const pagination = archivePageCount > 1
270
+ ? '<nav class="pagination" aria-label="' + escapeHtml(label('pagination')) + '">' +
271
+ (pageNumber > 1 ? '<a rel="prev" href="' + escapeHtml(pageNumber === 2 ? localizedUrl(config, locale, 'archives/') :
272
+ localizedUrl(config, locale, 'archives/page/' + (pageNumber - 1) + '/')) + '">' + escapeHtml(label('newer')) + '</a>' : '') +
273
+ (pageNumber < archivePageCount ? '<a rel="next" href="' + escapeHtml(localizedUrl(config, locale, 'archives/page/' + (pageNumber + 1) + '/')) + '">' +
274
+ escapeHtml(label('older')) + '</a>' : '') + '</nav>' : '';
275
+ const archiveBody = '<section><h1>' + escapeHtml(label('archives')) + '</h1>' +
276
+ (cards.length ? cards.join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') + pagination + '</section>';
277
+ routes.push(await pageRoute(prefix + (pageNumber === 1 ? 'archives/index.html' : 'archives/page/' + pageNumber + '/index.html'),
278
+ pageNumber === 1 ? label('archives') : label('archives') + ' · ' + pageNumber, label('allPosts'), archiveBody, locale, config, extensions,
279
+ { urlPath: localizedUrl(config, locale, archiveUrl) }));
280
+ allPagePaths.add(localizedUrl(config, locale, archiveUrl));
281
+ }
182
282
 
183
283
  const tags = new Map();
184
284
  for (const post of posts) for (const tag of post.tags) {
@@ -187,15 +287,16 @@ export async function generateBuiltinRoutes(site, config, extensions) {
187
287
  tags.get(slug).posts.push(post);
188
288
  }
189
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)));
190
291
  const body = '<section><h1>' + escapeHtml(label('tag')) + ': ' + escapeHtml(value.label) + '</h1>' +
191
- value.posts.map((post) => renderPostCard(post, locale)).join('') + '</section>';
292
+ cards.join('') + '</section>';
192
293
  return pageRoute(prefix + 'tags/' + slug + '/index.html', label('tag') + ': ' + value.label,
193
294
  label('tag') + ' ' + value.label, body, locale, config, extensions);
194
295
  });
195
296
  routes.push(...tagRoutes);
196
297
 
197
298
  const searchable = [
198
- ...posts.map((post) => ({ title: post.title, 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' })),
199
300
  ...pages.map((page) => ({ title: page.title, url: urlFor(page.path), summary: page.description || plainText(page.markdown).slice(0, 220), type: 'page' }))
200
301
  ];
201
302
  routes.push(fileRoute(prefix + 'search.json', JSON.stringify(searchable), 'application/json; charset=utf-8'));
@@ -213,9 +314,10 @@ export async function generateBuiltinRoutes(site, config, extensions) {
213
314
  locale, config, extensions));
214
315
 
215
316
  const base = config.site.url;
216
- const entries = posts.slice(0, 20).map((post) => '<entry><title>' + escapeXml(post.title) + '</title><link href="' +
317
+ const entries = posts.slice(0, 20).map((post) => '<entry><title>' + escapeXml(post.title) + '</title>' +
318
+ (post.author ? '<author><name>' + escapeXml(post.author) + '</name></author>' : '') + '<link href="' +
217
319
  escapeXml(base + urlFor(post.path)) + '"/><id>' + escapeXml(base + urlFor(post.path)) + '</id><updated>' + post.date.toISOString() +
218
- '</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('');
219
321
  routes.push(fileRoute(prefix + 'feed.xml', '<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>' +
220
322
  escapeXml(config.site.title) + '</title><id>' + escapeXml(base || 'urn:edgepress:site') + '</id><updated>' +
221
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/icons.js ADDED
@@ -0,0 +1,16 @@
1
+ const iconNames = new Set([
2
+ 'home', 'book-open', 'rocket', 'newspaper', 'search', 'shield-check', 'rss', 'file-text',
3
+ 'layers', 'code', 'puzzle', 'plug', 'cloud', 'palette', 'globe', 'help-circle', 'arrow-up-right',
4
+ 'check-circle', 'layout-grid', 'terminal', 'settings', 'sparkles'
5
+ ]);
6
+
7
+ export function isIconName(name) {
8
+ return typeof name === 'string' && iconNames.has(name);
9
+ }
10
+
11
+ export function renderIcon(name, className = 'icon') {
12
+ if (!isIconName(name)) throw new Error('Unsupported EdgePress icon: ' + String(name));
13
+ if (!/^[a-z][a-z0-9-]*$/.test(className)) throw new Error('Icon class must be a simple CSS identifier');
14
+ return '<svg class="' + className + '" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><use href="/edgepress/icons.svg#' +
15
+ name + '"></use></svg>';
16
+ }
@@ -17,29 +17,6 @@ function versionAtLeast(current, minimum) {
17
17
  return true;
18
18
  }
19
19
 
20
- function markdown(report) {
21
- const lines = [
22
- '# EdgePress compatibility report',
23
- '',
24
- 'Status: **' + report.status + '**',
25
- '',
26
- 'Node.js: ' + report.runtime.node,
27
- 'Minimum required Node.js: ' + report.requirements.nodeMinimumVersion,
28
- 'Workers compatibility date: ' + report.runtime.workersDate,
29
- 'Minimum required date: ' + report.requirements.minimumWorkersDate,
30
- '',
31
- '## Findings',
32
- ''
33
- ];
34
- if (!report.issues.length) lines.push('No compatibility findings.');
35
- for (const issue of report.issues) lines.push('- **' + issue.severity.toUpperCase() + '** ' + issue.message);
36
- lines.push('', '## Feature lifecycle', '');
37
- for (const feature of report.features) {
38
- lines.push('- ' + feature.name + ': ' + feature.stage + ' (since ' + feature.since + ')');
39
- }
40
- return lines.join('\n');
41
- }
42
-
43
20
  export async function checkCompatibility(config) {
44
21
  const root = config.root;
45
22
  const manifest = await readJson(resolve(root, 'project-compatibility.json'));
@@ -77,10 +54,6 @@ export async function checkCompatibility(config) {
77
54
  features: manifest.features,
78
55
  issues
79
56
  };
80
- const directory = resolve(config.resolvedPaths.cache, 'reports');
81
- await mkdir(directory, { recursive: true });
82
- await writeFile(resolve(directory, 'compatibility.json'), JSON.stringify(report, null, 2), 'utf8');
83
- await writeFile(resolve(directory, 'compatibility.md'), markdown(report), 'utf8');
84
57
  return report;
85
58
  }
86
59