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.
- package/README.md +2 -2
- package/config.yml +110 -63
- package/content/pages/home/en.md +9 -1
- package/content/pages/home/zh-cn.md +9 -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 +7 -4
- package/content/pages/privacy-policy/zh-cn.md +7 -4
- package/content/pages/theme-development/en.md +1 -0
- package/content/pages/theme-development/zh-cn.md +1 -0
- package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +9 -1
- package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +9 -1
- package/content/posts/2026-09-25-edgepress-release-2026-9-6/en.md +2 -2
- package/content/posts/2026-09-25-edgepress-release-2026-9-6/zh-cn.md +2 -2
- 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 +7 -2
- package/languages/packs/zh-CN.json +7 -2
- package/package.json +2 -3
- package/plugins/consent/index.js +4 -2
- package/project-compatibility.json +19 -3
- package/src/build.js +5 -4
- package/src/cli.js +1 -11
- package/src/config.js +72 -14
- package/src/content.js +22 -1
- package/src/generators.js +25 -20
- package/src/i18n.js +6 -0
- package/src/markdown.js +88 -9
- package/src/page-blocks.js +61 -16
- package/src/page-check.js +73 -24
- package/src/report-visuals.js +111 -16
- 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/plugins/consent/manager.js +204 -71
- package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +1 -0
- package/themes/atelier/assets/style.css +50 -19
- package/themes/default/assets/style.css +50 -19
- package/themes/signal/assets/style.css +50 -19
- package/CHANGELOG.txt +0 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edgepress",
|
|
3
|
-
"version": "2026.9.
|
|
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",
|
package/plugins/consent/index.js
CHANGED
|
@@ -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', '
|
|
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.
|
|
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,
|
|
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 =
|
|
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
|
|
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')
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
@@ -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 ||
|
|
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
|
-
(
|
|
128
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
(
|
|
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(
|
|
264
|
+
const archivePageCount = Math.max(1, Math.ceil(archivePosts.length / archivePageSize));
|
|
262
265
|
for (let pageNumber = 1; pageNumber <= archivePageCount; pageNumber += 1) {
|
|
263
|
-
const subset =
|
|
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
|
-
(
|
|
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
|
-
|
|
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 ||
|
|
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 ||
|
|
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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
}
|