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.
- package/README.md +5 -3
- package/config.yml +121 -69
- package/content/pages/home/en.md +15 -0
- package/content/pages/home/zh-cn.md +16 -1
- package/content/pages/plugin-development/en.md +5 -4
- package/content/pages/plugin-development/zh-cn.md +5 -4
- package/content/pages/privacy-policy/en.md +8 -5
- package/content/pages/privacy-policy/zh-cn.md +8 -5
- package/content/pages/theme-development/en.md +24 -0
- package/content/pages/theme-development/zh-cn.md +24 -0
- package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +12 -2
- package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +12 -2
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +2 -0
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-2/en.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-2/zh-cn.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-3/en.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-3/zh-cn.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-4/en.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-4/zh-cn.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-5/en.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-5/zh-cn.md +2 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-6/en.md +31 -0
- package/content/posts/2026-09-25-edgepress-release-2026-9-6/zh-cn.md +31 -0
- package/content/posts/2026-09-26-edgepress-release-2026-9-7/en.md +26 -0
- package/content/posts/2026-09-26-edgepress-release-2026-9-7/zh-cn.md +26 -0
- package/content/posts/2026-09-26-edgepress-release-2026-9-8/en.md +26 -0
- package/content/posts/2026-09-26-edgepress-release-2026-9-8/zh-cn.md +26 -0
- package/languages/base/en.json +9 -2
- package/languages/packs/zh-CN.json +9 -2
- package/package.json +2 -1
- package/plugins/consent/index.js +4 -2
- package/project-compatibility.json +26 -2
- package/src/build.js +5 -4
- package/src/cli.js +8 -13
- package/src/config.js +72 -14
- package/src/content.js +55 -1
- package/src/generators.js +125 -23
- package/src/i18n.js +6 -0
- package/src/icons.js +16 -0
- package/src/maintenance.js +0 -27
- package/src/markdown.js +88 -9
- package/src/page-blocks.js +84 -18
- package/src/page-check.js +79 -25
- package/src/report-visuals.js +111 -16
- package/src/theme.js +13 -2
- package/static/edgepress/brand/edgepress-mark.svg +7 -0
- package/static/edgepress/brand/mascot-209.webp +0 -0
- package/static/edgepress/brand/mascot-418.png +0 -0
- package/static/edgepress/brand/mascot-418.webp +0 -0
- package/static/edgepress/brand/mascot.png +0 -0
- package/static/edgepress/brand/og-image.png +0 -0
- package/static/edgepress/brand/og-image.svg +46 -0
- package/static/edgepress/code-copy.js +55 -0
- package/static/edgepress/icons.svg +24 -0
- package/static/edgepress/plugins/consent/manager.js +204 -71
- package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +1 -0
- package/themes/atelier/assets/style.css +98 -20
- package/themes/atelier/layouts/partials/head.html +5 -0
- package/themes/atelier/layouts/partials/header.html +1 -1
- package/themes/default/assets/style.css +99 -20
- package/themes/default/layouts/partials/head.html +3 -0
- package/themes/default/layouts/partials/header.html +1 -1
- package/themes/signal/assets/style.css +98 -20
- package/themes/signal/layouts/partials/head.html +5 -0
- 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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
|
194
|
-
if (!section.enabled && section.services.length) throw new Error('Clear
|
|
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
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
return '<
|
|
75
|
-
|
|
76
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
(
|
|
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
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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 ||
|
|
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
|
+
}
|
package/src/maintenance.js
CHANGED
|
@@ -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
|
|