edgepress 2026.9.3

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 (80) hide show
  1. package/README.md +70 -0
  2. package/config.yml +146 -0
  3. package/content/pages/home/en.md +84 -0
  4. package/content/pages/home/zh-cn.md +84 -0
  5. package/content/pages/plugin-development/en.md +108 -0
  6. package/content/pages/plugin-development/zh-cn.md +108 -0
  7. package/content/pages/privacy-policy/en.md +73 -0
  8. package/content/pages/privacy-policy/zh-cn.md +73 -0
  9. package/content/pages/project-introduction/en.md +88 -0
  10. package/content/pages/project-introduction/zh-cn.md +88 -0
  11. package/content/pages/quick-start/en.md +147 -0
  12. package/content/pages/quick-start/zh-cn.md +147 -0
  13. package/content/pages/theme-development/en.md +106 -0
  14. package/content/pages/theme-development/zh-cn.md +106 -0
  15. package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +169 -0
  16. package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +169 -0
  17. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +30 -0
  18. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +30 -0
  19. package/content/posts/2026-09-25-edgepress-release-2026-9-2/en.md +28 -0
  20. package/content/posts/2026-09-25-edgepress-release-2026-9-2/zh-cn.md +28 -0
  21. package/content/posts/2026-09-25-edgepress-release-2026-9-3/en.md +39 -0
  22. package/content/posts/2026-09-25-edgepress-release-2026-9-3/zh-cn.md +39 -0
  23. package/edgepress.config.mjs +18 -0
  24. package/languages/base/en.json +55 -0
  25. package/languages/packs/zh-CN.json +55 -0
  26. package/package.json +59 -0
  27. package/plugins/consent/index.js +40 -0
  28. package/project-compatibility.json +48 -0
  29. package/src/assets.js +181 -0
  30. package/src/build.js +277 -0
  31. package/src/cli.js +344 -0
  32. package/src/concurrency.js +26 -0
  33. package/src/config.js +396 -0
  34. package/src/content.js +160 -0
  35. package/src/generators.js +263 -0
  36. package/src/i18n.js +64 -0
  37. package/src/maintenance.js +122 -0
  38. package/src/markdown.js +65 -0
  39. package/src/page-blocks.js +350 -0
  40. package/src/page-check.js +416 -0
  41. package/src/plugin-api.js +75 -0
  42. package/src/preview.js +167 -0
  43. package/src/report-visuals.js +480 -0
  44. package/src/theme.js +161 -0
  45. package/src/worker.js +139 -0
  46. package/static/.gitkeep +0 -0
  47. package/static/edgepress/language-select.js +5 -0
  48. package/static/edgepress/plugins/advertising/google-adsense.js +26 -0
  49. package/static/edgepress/plugins/captcha/cloudflare-turnstile.js +28 -0
  50. package/static/edgepress/plugins/captcha/google-recaptcha.js +27 -0
  51. package/static/edgepress/plugins/captcha/hcaptcha.js +27 -0
  52. package/static/edgepress/plugins/consent/manager.js +180 -0
  53. package/static/edgepress/plugins/statistics/baidu-tongji.js +8 -0
  54. package/static/edgepress/plugins/statistics/cloudflare-web-analytics.js +9 -0
  55. package/static/edgepress/plugins/statistics/google-analytics.js +13 -0
  56. package/static/edgepress/plugins/tracking/google-tag-manager.js +10 -0
  57. package/static/edgepress/plugins/tracking/meta-pixel.js +18 -0
  58. package/static/edgepress/search.js +84 -0
  59. package/static/edgepress-markdown-guide.svg +21 -0
  60. package/templates/gitignore +6 -0
  61. package/templates/site-readme.md +20 -0
  62. package/themes/atelier/assets/style.css +120 -0
  63. package/themes/atelier/layouts/layout.html +17 -0
  64. package/themes/atelier/layouts/partials/footer.html +4 -0
  65. package/themes/atelier/layouts/partials/head.html +17 -0
  66. package/themes/atelier/layouts/partials/header.html +5 -0
  67. package/themes/atelier/theme.json +6 -0
  68. package/themes/default/assets/style.css +157 -0
  69. package/themes/default/layouts/layout.html +12 -0
  70. package/themes/default/layouts/partials/footer.html +4 -0
  71. package/themes/default/layouts/partials/head.html +19 -0
  72. package/themes/default/layouts/partials/header.html +5 -0
  73. package/themes/default/theme.json +6 -0
  74. package/themes/signal/assets/style.css +113 -0
  75. package/themes/signal/layouts/layout.html +12 -0
  76. package/themes/signal/layouts/partials/footer.html +4 -0
  77. package/themes/signal/layouts/partials/head.html +17 -0
  78. package/themes/signal/layouts/partials/header.html +5 -0
  79. package/themes/signal/theme.json +6 -0
  80. package/wrangler.jsonc +13 -0
package/src/config.js ADDED
@@ -0,0 +1,396 @@
1
+ import { access, readFile, realpath, stat } from 'node:fs/promises';
2
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { parse as parseYaml } from 'yaml';
5
+
6
+ const defaults = {
7
+ site: {
8
+ title: 'EdgePress', description: '', url: '', language: 'en',
9
+ head: { titleSuffix: '', titleSeparator: ' · ' },
10
+ header: { showBrand: true, brandLabel: '' },
11
+ components: { header: true, primaryNavigation: true, languageNavigation: true, footer: true, footerNavigation: true },
12
+ navigation: [],
13
+ footerNavigation: [],
14
+ footer: '',
15
+ seo: { author: '', keywords: [], image: '', robots: 'index,follow' },
16
+ agentSeo: { enabled: true, summary: '', topics: [], llmsTxt: true, agentCrawlers: ['GPTBot', 'ClaudeBot', 'PerplexityBot'] }
17
+ },
18
+ privacy: {
19
+ controller: { name: '', contact: '' },
20
+ policyUrl: '/privacy-policy/'
21
+ },
22
+ browserPlugins: {
23
+ consent: { enabled: true, version: '1', expiresDays: 180 },
24
+ tracking: { enabled: true, services: [] },
25
+ statistics: { enabled: true, services: [] },
26
+ advertising: { enabled: true, services: [] },
27
+ captcha: { enabled: true, services: [] }
28
+ },
29
+ paths: { content: 'content', static: 'static', theme: 'themes/default', output: 'dist', cache: '.edgepress' },
30
+ permalink: '/:year/:month/:slug/',
31
+ pagination: { perPage: 10 },
32
+ markdown: { gfm: true, breaks: false },
33
+ i18n: { defaultLocale: 'en', languagePacks: [] },
34
+ concurrency: 8,
35
+ plugins: []
36
+ };
37
+
38
+ function merge(base, override) {
39
+ const result = { ...base };
40
+ for (const [key, value] of Object.entries(override ?? {})) {
41
+ if (value && typeof value === 'object' && !Array.isArray(value) && base[key] && typeof base[key] === 'object') {
42
+ result[key] = merge(base[key], value);
43
+ } else {
44
+ result[key] = value;
45
+ }
46
+ }
47
+ return result;
48
+ }
49
+
50
+ function assertString(value, label) {
51
+ if (typeof value !== 'string' || !value.trim()) throw new Error(label + ' must be a non-empty string');
52
+ }
53
+
54
+ const integrationProviders = {
55
+ 'google-tag-manager': { group: 'tracking', credential: 'containerId', pattern: /^GTM-[A-Z0-9]{4,20}$/i },
56
+ 'meta-pixel': { group: 'tracking', credential: 'pixelId', pattern: /^[0-9]{6,20}$/ },
57
+ 'cloudflare-web-analytics': { group: 'statistics', credential: 'token', pattern: /^[A-Fa-f0-9-]{20,64}$/ },
58
+ 'google-analytics': { group: 'statistics', credential: 'measurementId', pattern: /^(?:G|GT)-[A-Z0-9]{4,24}$/i },
59
+ 'baidu-tongji': { group: 'statistics', credential: 'siteId', pattern: /^[A-Fa-f0-9]{16,64}$/ },
60
+ 'google-adsense': { group: 'advertising', credential: 'clientId', pattern: /^ca-pub-[0-9]{10,24}$/ },
61
+ 'cloudflare-turnstile': { group: 'captcha', credential: 'siteKey', pattern: /^[A-Za-z0-9_-]{20,128}$/ },
62
+ 'google-recaptcha': { group: 'captcha', credential: 'siteKey', pattern: /^[A-Za-z0-9_-]{20,256}$/ },
63
+ 'hcaptcha': { group: 'captcha', credential: 'siteKey', pattern: /^[A-Za-z0-9_-]{20,256}$/ }
64
+ };
65
+
66
+ async function readSiteYaml(root) {
67
+ const file = resolve(root, 'config.yml');
68
+ const realRoot = await realpath(root);
69
+ let realFile;
70
+ try { realFile = await realpath(file); }
71
+ catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
72
+ if (!isPathInside(realRoot, realFile)) throw new Error('config.yml must stay inside the project root');
73
+ const source = await readFile(realFile);
74
+ if (source.byteLength > 64 * 1024) throw new Error('config.yml exceeds 64 KB');
75
+ let parsed;
76
+ try { parsed = parseYaml(source.toString('utf8'), { uniqueKeys: true, maxAliasCount: 20 }) ?? {}; }
77
+ catch (error) { throw new Error('Invalid YAML in config.yml: ' + error.message, { cause: error }); }
78
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('config.yml must contain an object');
79
+ for (const key of Object.keys(parsed)) if (!['site', 'privacy', 'plugins'].includes(key)) throw new Error('Unsupported config.yml section: ' + key);
80
+ return parsed;
81
+ }
82
+
83
+ function validateSiteConfig(config) {
84
+ if (!config.site.seo || typeof config.site.seo !== 'object' || Array.isArray(config.site.seo)) throw new Error('site.seo must be an object');
85
+ if (typeof config.site.footer !== 'string') throw new Error('site.footer must be a string');
86
+ for (const field of ['author', 'image', 'robots']) {
87
+ if (typeof config.site.seo[field] !== 'string') throw new Error('site.seo.' + field + ' must be a string');
88
+ }
89
+ if (!Array.isArray(config.site.seo.keywords) || config.site.seo.keywords.some((item) => typeof item !== 'string')) {
90
+ throw new Error('site.seo.keywords must be an array of strings');
91
+ }
92
+ const agentSeo = config.site.agentSeo;
93
+ if (!agentSeo || typeof agentSeo !== 'object' || Array.isArray(agentSeo) || typeof agentSeo.enabled !== 'boolean' || typeof agentSeo.llmsTxt !== 'boolean' || typeof agentSeo.summary !== 'string' ||
94
+ !Array.isArray(agentSeo.topics) || agentSeo.topics.some((item) => typeof item !== 'string') ||
95
+ !Array.isArray(agentSeo.agentCrawlers) || agentSeo.agentCrawlers.some((item) => typeof item !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(item))) {
96
+ throw new Error('site.agentSeo must define enabled, summary, topics, llmsTxt, and valid agentCrawlers');
97
+ }
98
+ for (const key of Object.keys(config.site.agentSeo)) if (!['enabled', 'summary', 'topics', 'llmsTxt', 'agentCrawlers'].includes(key)) {
99
+ throw new Error('Unsupported site.agentSeo option: ' + key);
100
+ }
101
+ const { head, header, components } = config.site;
102
+ if (!head || typeof head !== 'object' || Array.isArray(head) || typeof head.titleSuffix !== 'string' ||
103
+ typeof head.titleSeparator !== 'string' || head.titleSuffix.length > 120 || head.titleSeparator.length > 20 ||
104
+ /[<>\r\n]/.test(head.titleSuffix + head.titleSeparator)) {
105
+ throw new Error('site.head needs a short plain-text titleSuffix and titleSeparator');
106
+ }
107
+ for (const key of Object.keys(head)) if (!['titleSuffix', 'titleSeparator'].includes(key)) throw new Error('Unsupported site.head option: ' + key);
108
+ if (!header || typeof header !== 'object' || Array.isArray(header) || typeof header.showBrand !== 'boolean' || typeof header.brandLabel !== 'string') {
109
+ throw new Error('site.header needs showBrand and brandLabel');
110
+ }
111
+ for (const key of Object.keys(header)) if (!['showBrand', 'brandLabel'].includes(key)) throw new Error('Unsupported site.header option: ' + key);
112
+ const componentKeys = ['header', 'primaryNavigation', 'languageNavigation', 'footer', 'footerNavigation'];
113
+ if (!components || typeof components !== 'object' || Array.isArray(components)) throw new Error('site.components must be an object');
114
+ for (const key of Object.keys(components)) if (!componentKeys.includes(key)) throw new Error('Unsupported site.components option: ' + key);
115
+ for (const key of componentKeys) if (typeof components[key] !== 'boolean') throw new Error('site.components.' + key + ' must be a boolean');
116
+
117
+ function validateNavigation(items, field) {
118
+ if (!Array.isArray(items) || items.length > 30) throw new Error(field + ' must be an array with at most 30 items');
119
+ const keys = new Set();
120
+ for (const item of items) {
121
+ if (!item || typeof item !== 'object' || Array.isArray(item) || typeof item.key !== 'string' ||
122
+ !/^[a-z][a-z0-9-]{0,63}$/.test(item.key) || keys.has(item.key) || typeof item.url !== 'string' || !item.url.trim()) {
123
+ throw new Error(field + ' items need a unique key and URL');
124
+ }
125
+ keys.add(item.key);
126
+ for (const key of Object.keys(item)) if (!['key', 'url', 'labels'].includes(key)) throw new Error('Unsupported ' + field + ' item option: ' + key);
127
+ const url = item.url.trim();
128
+ if (!['@home', '@archives', '@feed', '@search'].includes(url)) {
129
+ const safeLocal = url.startsWith('/') && !url.startsWith('//') && !/[\\\x00-\x20]/.test(url) && !/^(?:javascript|data|vbscript):/i.test(url);
130
+ let safeExternal = false;
131
+ if (!safeLocal) {
132
+ try {
133
+ const parsed = new URL(url);
134
+ safeExternal = ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password;
135
+ } catch { safeExternal = false; }
136
+ }
137
+ if (!safeLocal && !safeExternal) throw new Error(field + ' URL must be a safe site path or HTTP URL');
138
+ }
139
+ if (item.labels !== undefined) {
140
+ if (!item.labels || typeof item.labels !== 'object' || Array.isArray(item.labels)) throw new Error(field + '.labels must be a locale-to-label object');
141
+ for (const [locale, label] of Object.entries(item.labels)) {
142
+ if (!/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(locale) || typeof label !== 'string' || !label.trim() || label.length > 120) {
143
+ throw new Error(field + '.labels values must be short locale-specific strings');
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ validateNavigation(config.site.navigation, 'site.navigation');
150
+ validateNavigation(config.site.footerNavigation, 'site.footerNavigation');
151
+ }
152
+
153
+ function validatePrivacyConfig(config) {
154
+ const privacy = config.privacy;
155
+ for (const key of Object.keys(privacy)) if (!['controller', 'policyUrl'].includes(key)) throw new Error('Unsupported privacy option: ' + key);
156
+ if (!privacy.controller || typeof privacy.controller.name !== 'string' || typeof privacy.controller.contact !== 'string') {
157
+ throw new Error('privacy.controller.name and privacy.controller.contact must be strings');
158
+ }
159
+ for (const key of Object.keys(privacy.controller)) if (!['name', 'contact'].includes(key)) throw new Error('Unsupported privacy.controller option: ' + key);
160
+ if (typeof privacy.policyUrl !== 'string' || !privacy.policyUrl.trim()) throw new Error('privacy.policyUrl must be a non-empty URL or site path');
161
+ const policyUrl = privacy.policyUrl.trim();
162
+ let validPolicyUrl = policyUrl.startsWith('/') && !policyUrl.startsWith('//') && !/[?#\\]/.test(policyUrl);
163
+ if (!validPolicyUrl) {
164
+ try {
165
+ const parsed = new URL(policyUrl);
166
+ validPolicyUrl = ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password && !parsed.search && !parsed.hash;
167
+ } catch { validPolicyUrl = false; }
168
+ }
169
+ if (!validPolicyUrl || /[\x00-\x20]/.test(policyUrl)) throw new Error('privacy.policyUrl must be a safe site path or HTTP URL');
170
+ }
171
+
172
+ function validateBrowserPlugins(config) {
173
+ const plugins = config.browserPlugins;
174
+ const allowedGroups = ['consent', 'tracking', 'statistics', 'advertising', 'captcha'];
175
+ if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) throw new Error('plugins in config.yml must be an object');
176
+ for (const key of Object.keys(plugins)) if (!allowedGroups.includes(key)) throw new Error('Unsupported plugin configuration group: ' + key);
177
+ const consent = plugins.consent;
178
+ if (!consent || typeof consent !== 'object' || Array.isArray(consent) || typeof consent.enabled !== 'boolean' ||
179
+ typeof consent.version !== 'string' || !consent.version.trim() || !Number.isInteger(consent.expiresDays) ||
180
+ consent.expiresDays < 1 || consent.expiresDays > 730) {
181
+ throw new Error('plugins.consent needs enabled, version, and expiresDays from 1 to 730');
182
+ }
183
+ for (const key of Object.keys(consent)) if (!['enabled', 'version', 'expiresDays'].includes(key)) {
184
+ throw new Error('Unsupported plugins.consent option: ' + key);
185
+ }
186
+ const ids = new Set();
187
+ let servicesCount = 0;
188
+ for (const group of ['tracking', 'statistics', 'advertising', 'captcha']) {
189
+ const section = plugins[group];
190
+ 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');
192
+ }
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');
195
+ servicesCount += section.services.length;
196
+ 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');
198
+ const { id, provider, purpose } = service;
199
+ 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
+ ids.add(id);
201
+ 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]);
206
+ for (const key of Object.keys(service)) if (!allowedKeys.has(key)) throw new Error('Unsupported option for service ' + id + ': ' + key);
207
+ const credential = service[definition.credential];
208
+ if (typeof credential !== 'string' || !definition.pattern.test(credential)) throw new Error('Service ' + id + ' needs a valid public ' + definition.credential);
209
+ }
210
+ }
211
+ if (servicesCount && !consent.enabled) throw new Error('plugins.consent must be enabled when browser services are configured');
212
+ if (servicesCount) {
213
+ assertString(config.privacy.controller.name, 'privacy.controller.name');
214
+ assertString(config.privacy.controller.contact, 'privacy.controller.contact');
215
+ }
216
+ }
217
+
218
+ function isCanonicalLocale(value) {
219
+ try { return Intl.getCanonicalLocales(value)[0] === value; }
220
+ catch { return false; }
221
+ }
222
+
223
+ function isInside(root, target) {
224
+ const relativePath = relative(root, resolve(root, target));
225
+ return relativePath !== '' &&
226
+ relativePath !== '..' &&
227
+ !relativePath.startsWith('..' + sep) &&
228
+ !isAbsolute(relativePath);
229
+ }
230
+
231
+ function isPathInside(root, target) {
232
+ const relativePath = relative(root, target);
233
+ return relativePath !== '' && relativePath !== '..' &&
234
+ !relativePath.startsWith('..' + sep) && !isAbsolute(relativePath);
235
+ }
236
+
237
+ async function realpathWithMissingTail(path) {
238
+ let candidate = resolve(path);
239
+ const missing = [];
240
+ while (true) {
241
+ let actual;
242
+ try {
243
+ actual = await realpath(candidate);
244
+ }
245
+ catch (error) {
246
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
247
+ const parent = dirname(candidate);
248
+ if (parent === candidate) throw error;
249
+ missing.push(basename(candidate));
250
+ candidate = parent;
251
+ continue;
252
+ }
253
+ if (!(await stat(actual)).isDirectory()) throw new Error('A configured path has a non-directory parent: ' + path);
254
+ return resolve(actual, ...missing.reverse());
255
+ }
256
+ }
257
+
258
+ function pathsOverlap(left, right) {
259
+ return left === right || isPathInside(left, right) || isPathInside(right, left);
260
+ }
261
+
262
+ export async function loadConfig(root = process.cwd()) {
263
+ root = resolve(root);
264
+ const configFile = resolve(root, 'edgepress.config.mjs');
265
+ let userConfig = {};
266
+ try {
267
+ await access(configFile);
268
+ const loaded = await import(pathToFileURL(configFile).href + '?v=' + Date.now());
269
+ userConfig = loaded.default ?? {};
270
+ } catch (error) {
271
+ if (error.code !== 'ENOENT') throw error;
272
+ }
273
+ if (!userConfig || typeof userConfig !== 'object' || Array.isArray(userConfig)) {
274
+ throw new Error('edgepress.config.mjs must export a configuration object');
275
+ }
276
+
277
+ const config = merge(defaults, userConfig);
278
+ const siteYaml = await readSiteYaml(root);
279
+ if (siteYaml.site !== undefined && (!siteYaml.site || typeof siteYaml.site !== 'object' || Array.isArray(siteYaml.site))) {
280
+ throw new Error('config.yml site must be an object');
281
+ }
282
+ if (siteYaml.privacy !== undefined && (!siteYaml.privacy || typeof siteYaml.privacy !== 'object' || Array.isArray(siteYaml.privacy))) {
283
+ throw new Error('config.yml privacy must be an object');
284
+ }
285
+ if (siteYaml.plugins !== undefined && (!siteYaml.plugins || typeof siteYaml.plugins !== 'object' || Array.isArray(siteYaml.plugins))) {
286
+ throw new Error('config.yml plugins must be an object');
287
+ }
288
+ config.site = merge(config.site, siteYaml.site);
289
+ config.privacy = merge(config.privacy, siteYaml.privacy);
290
+ config.browserPlugins = merge(config.browserPlugins, siteYaml.plugins);
291
+ assertString(config.site.title, 'site.title');
292
+ assertString(config.site.language, 'site.language');
293
+ if (!isCanonicalLocale(config.site.language)) throw new Error('site.language must be a valid canonical language tag');
294
+ if (typeof config.site.description !== 'string') throw new Error('site.description must be a string');
295
+ validateSiteConfig(config);
296
+ validatePrivacyConfig(config);
297
+ validateBrowserPlugins(config);
298
+ if (typeof config.markdown.gfm !== 'boolean' || typeof config.markdown.breaks !== 'boolean') {
299
+ throw new Error('markdown.gfm and markdown.breaks must be booleans');
300
+ }
301
+ assertString(config.permalink, 'permalink');
302
+ if (/[?#\\\0]/.test(config.permalink) || /(^|\/)\.\.?($|\/)/.test(config.permalink)) {
303
+ throw new Error('permalink must be a URL path without query, fragment, or traversal segments');
304
+ }
305
+ const unknownPermalinkTokens = [...config.permalink.matchAll(/:([A-Za-z][A-Za-z0-9]*)/g)]
306
+ .map((match) => match[1]).filter((token) => !['year', 'month', 'day', 'slug', 'title'].includes(token));
307
+ if (unknownPermalinkTokens.length) throw new Error('Unknown permalink token: :' + unknownPermalinkTokens[0]);
308
+ if (!Number.isInteger(config.pagination.perPage) || config.pagination.perPage < 1) {
309
+ throw new Error('pagination.perPage must be a positive integer');
310
+ }
311
+ if (!Number.isInteger(config.concurrency) || config.concurrency < 1 || config.concurrency > 64) {
312
+ throw new Error('concurrency must be an integer between 1 and 64');
313
+ }
314
+ if (!Array.isArray(config.plugins)) throw new Error('plugins must be an array');
315
+ if ('messages' in config.i18n || 'labels' in config.i18n || 'locales' in config.i18n) {
316
+ throw new Error('Define translations in languages/base and languages/packs; use i18n.languagePacks instead of inline messages or locales');
317
+ }
318
+ if (typeof config.i18n.defaultLocale !== 'string' || !isCanonicalLocale(config.i18n.defaultLocale)) {
319
+ throw new Error('i18n.defaultLocale must be a valid canonical language tag');
320
+ }
321
+ if (!Array.isArray(config.i18n.languagePacks)) throw new Error('i18n.languagePacks must be an array of language tags');
322
+ for (const locale of config.i18n.languagePacks) {
323
+ if (typeof locale !== 'string' || !/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(locale) || !isCanonicalLocale(locale)) {
324
+ throw new Error('Each i18n.languagePacks entry must be a canonical language tag such as zh-CN');
325
+ }
326
+ if (locale === config.i18n.defaultLocale) throw new Error('The base language must not also be listed as a language pack');
327
+ }
328
+ if (new Set(config.i18n.languagePacks).size !== config.i18n.languagePacks.length) {
329
+ throw new Error('i18n.languagePacks must not contain duplicates');
330
+ }
331
+ config.i18n.locales = [config.i18n.defaultLocale, ...config.i18n.languagePacks];
332
+
333
+ const siteUrl = String(config.site.url ?? '').trim();
334
+ if (siteUrl) {
335
+ let parsedSiteUrl;
336
+ try { parsedSiteUrl = new URL(siteUrl); }
337
+ catch { throw new Error('site.url must be an absolute HTTP or HTTPS URL'); }
338
+ if (!['http:', 'https:'].includes(parsedSiteUrl.protocol) || parsedSiteUrl.username || parsedSiteUrl.password || parsedSiteUrl.search || parsedSiteUrl.hash) {
339
+ throw new Error('site.url must be an absolute HTTP or HTTPS URL without credentials, query, or fragment');
340
+ }
341
+ config.site.url = (parsedSiteUrl.origin + parsedSiteUrl.pathname).replace(/\/+$/, '');
342
+ } else {
343
+ config.site.url = '';
344
+ }
345
+ config.root = root;
346
+ config.resolvedPaths = {};
347
+ config.realPaths = {};
348
+ const realRoot = await realpath(root);
349
+ for (const [name, value] of Object.entries(config.paths)) {
350
+ assertString(value, 'paths.' + name);
351
+ if (isAbsolute(value)) throw new Error('paths.' + name + ' must be relative to the project root');
352
+ const target = resolve(root, value);
353
+ if (!isInside(root, target)) throw new Error('paths.' + name + ' must stay inside the project root');
354
+ const realTarget = await realpathWithMissingTail(target);
355
+ if (!isPathInside(realRoot, realTarget)) {
356
+ throw new Error('paths.' + name + ' must resolve inside the project root, including through symbolic links');
357
+ }
358
+ config.resolvedPaths[name] = target;
359
+ config.realPaths[name] = realTarget;
360
+ }
361
+ const output = config.resolvedPaths.output;
362
+ const cache = config.resolvedPaths.cache;
363
+ if (output === root || cache === root) throw new Error('paths.output and paths.cache cannot be the project root');
364
+ const protectedPaths = ['content', 'static', 'theme'].map((name) => ({
365
+ name: 'paths.' + name,
366
+ path: config.resolvedPaths[name],
367
+ real: config.realPaths[name]
368
+ }));
369
+ for (const name of ['src', 'languages', 'tools']) {
370
+ const path = resolve(root, name);
371
+ const real = await realpathWithMissingTail(path);
372
+ if (!isPathInside(realRoot, real)) throw new Error(name + ' must resolve inside the project root, including through symbolic links');
373
+ protectedPaths.push({ name, path, real });
374
+ }
375
+ for (let left = 0; left < protectedPaths.length; left += 1) {
376
+ for (let right = left + 1; right < protectedPaths.length; right += 1) {
377
+ const a = protectedPaths[left];
378
+ const b = protectedPaths[right];
379
+ if (pathsOverlap(a.path, b.path) || pathsOverlap(a.real, b.real)) {
380
+ throw new Error(a.name + ' and ' + b.name + ' must not overlap');
381
+ }
382
+ }
383
+ }
384
+ for (const protectedPath of protectedPaths) {
385
+ if (pathsOverlap(output, protectedPath.path) || pathsOverlap(config.realPaths.output, protectedPath.real)) {
386
+ throw new Error('paths.output must not overlap ' + protectedPath.name);
387
+ }
388
+ if (pathsOverlap(cache, protectedPath.path) || pathsOverlap(config.realPaths.cache, protectedPath.real)) {
389
+ throw new Error('paths.cache must not overlap ' + protectedPath.name);
390
+ }
391
+ }
392
+ if (pathsOverlap(output, cache) || pathsOverlap(config.realPaths.output, config.realPaths.cache)) {
393
+ throw new Error('paths.output and paths.cache must not overlap');
394
+ }
395
+ return config;
396
+ }
package/src/content.js ADDED
@@ -0,0 +1,160 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, relative, resolve, sep } from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { mapLimit } from './concurrency.js';
5
+
6
+ async function walkMarkdown(directory) {
7
+ let entries;
8
+ try {
9
+ entries = await readdir(directory, { withFileTypes: true });
10
+ } catch (error) {
11
+ if (error.code === 'ENOENT') return [];
12
+ throw error;
13
+ }
14
+ entries.sort((a, b) => a.name.localeCompare(b.name));
15
+ const found = [];
16
+ for (const entry of entries) {
17
+ const fullPath = resolve(directory, entry.name);
18
+ if (entry.isDirectory()) found.push(...await walkMarkdown(fullPath));
19
+ else if (entry.isFile() && extname(entry.name).toLowerCase() === '.md') found.push(fullPath);
20
+ }
21
+ return found;
22
+ }
23
+
24
+ function splitFrontmatter(source, file) {
25
+ const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/);
26
+ if (lines[0] !== '---') return { metadata: {}, markdown: source };
27
+ const end = lines.findIndex((line, index) => index > 0 && (line === '---' || line === '...'));
28
+ if (end < 0) throw new Error('Unclosed YAML front matter in ' + file);
29
+ const metadata = parseYaml(lines.slice(1, end).join('\n')) ?? {};
30
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
31
+ throw new Error('Front matter must be a YAML object in ' + file);
32
+ }
33
+ return { metadata, markdown: lines.slice(end + 1).join('\n') };
34
+ }
35
+
36
+ export function slugify(value) {
37
+ const slug = String(value)
38
+ .normalize('NFKC')
39
+ .toLowerCase()
40
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
41
+ .replace(/^-+|-+$/g, '');
42
+ return slug || 'post';
43
+ }
44
+
45
+ function normalizeTags(value) {
46
+ if (value == null || value === '') return [];
47
+ return (Array.isArray(value) ? value : [value]).map(String).map((tag) => tag.trim()).filter(Boolean);
48
+ }
49
+
50
+ function normalizeDate(value, filename, kind, file) {
51
+ if (value instanceof Date) {
52
+ if (!Number.isNaN(value.valueOf())) return value;
53
+ throw new Error('Invalid date in ' + file);
54
+ }
55
+ if (value != null) {
56
+ const date = new Date(value);
57
+ if (!Number.isNaN(date.valueOf())) return date;
58
+ throw new Error('Invalid date in ' + file);
59
+ }
60
+ const match = filename.match(/^(\d{4}-\d{2}-\d{2})-/);
61
+ if (match) {
62
+ const date = new Date(match[1] + 'T00:00:00Z');
63
+ if (!Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === match[1]) return date;
64
+ throw new Error('Invalid date in filename: ' + file);
65
+ }
66
+ if (kind === 'posts') throw new Error('Posts need a valid date in front matter or a YYYY-MM-DD filename: ' + file);
67
+ return new Date(0);
68
+ }
69
+
70
+ function normalizePagePath(value, file) {
71
+ if (typeof value !== 'string' || !value.trim()) throw new Error('Page folder or slug must be a non-empty path: ' + file);
72
+ const source = value.trim();
73
+ if (source.startsWith('/') || source.includes('\\') || /[%?#\0]/.test(source)) throw new Error('Page path contains unsafe URL characters: ' + file);
74
+ const segments = source.split('/');
75
+ if (segments.some((part) => !part || part === '.' || part === '..')) throw new Error('Page path contains an empty or traversal segment: ' + file);
76
+ const parts = segments.map((part) => slugify(part));
77
+ if (parts.some((part) => !part)) throw new Error('Page path contains an invalid segment: ' + file);
78
+ return parts.join('/');
79
+ }
80
+
81
+ export async function readDocuments(config) {
82
+ const root = config.resolvedPaths.content;
83
+ const groups = [['posts', resolve(root, 'posts')], ['pages', resolve(root, 'pages')]];
84
+ const files = [];
85
+ for (const [kind, directory] of groups) {
86
+ for (const file of await walkMarkdown(directory)) files.push({ kind, file });
87
+ }
88
+ return await mapLimit(files, config.concurrency, async ({ kind, file }) => {
89
+ const source = await readFile(file, 'utf8');
90
+ const { metadata, markdown } = splitFrontmatter(source, file);
91
+ const contentRoot = resolve(root, kind);
92
+ const relativePath = relative(contentRoot, file).split(sep).join('/');
93
+ const bundleDirectory = dirname(file);
94
+ const bundlePath = relative(contentRoot, bundleDirectory).split(sep).join('/');
95
+ if (!bundlePath || bundlePath === '.' || bundlePath.startsWith('..')) {
96
+ throw new Error('Each content document must have its own folder with one file per language: ' + file);
97
+ }
98
+ const bundleName = basename(bundleDirectory);
99
+ const languageFileName = basename(file, extname(file));
100
+ const languageFile = languageFileName.toLowerCase();
101
+ const fileLocale = config.i18n.locales.find((locale) => locale.toLowerCase() === languageFile);
102
+ if (!fileLocale || languageFileName !== languageFile || extname(file).toLowerCase() !== '.md') {
103
+ throw new Error('Content language files must be named after a configured locale, such as en.md or zh-cn.md: ' + file);
104
+ }
105
+ const declaredLocale = metadata.lang === undefined ? fileLocale :
106
+ config.i18n.locales.find((locale) => locale.toLowerCase() === String(metadata.lang).toLowerCase());
107
+ if (!declaredLocale) throw new Error('Unsupported lang value in ' + file + '; use the language filename and configure its language pack');
108
+ if (declaredLocale !== fileLocale) throw new Error('Front matter lang must match the content filename locale in ' + file);
109
+ const locale = fileLocale;
110
+ const fallbackSlug = bundlePath.replace(/\\/g, '/');
111
+ const fallbackName = bundleName.replace(/^\d{4}-\d{2}-\d{2}-/, '');
112
+ const fallbackTitle = fallbackName.replace(/[-_]/g, ' ');
113
+ const title = String(metadata.title ?? fallbackTitle).trim();
114
+ if (!title) throw new Error('Document needs a non-empty title: ' + file);
115
+ if (metadata.homepage !== undefined && typeof metadata.homepage !== 'boolean') {
116
+ throw new Error('homepage must be a boolean in ' + file);
117
+ }
118
+ if (metadata.homepage === true && kind !== 'pages') throw new Error('Only pages can be marked as the homepage: ' + file);
119
+ if (metadata.blocks !== undefined && (kind !== 'pages' || !Array.isArray(metadata.blocks) || metadata.blocks.length > 100)) {
120
+ throw new Error('blocks must be an array of at most 100 items on a page: ' + file);
121
+ }
122
+ if (kind === 'pages' && (!Array.isArray(metadata.blocks) || metadata.blocks.length === 0)) {
123
+ throw new Error('Pages need an ordered blocks array; use Markdown only for posts: ' + file);
124
+ }
125
+ if (kind === 'pages' && markdown.trim()) {
126
+ throw new Error('Page body must be empty; move content into page blocks: ' + file);
127
+ }
128
+ const rawSlug = metadata.slug ?? (kind === 'posts' ? fallbackName : fallbackSlug);
129
+ const normalizedPagePath = kind === 'pages' && metadata.homepage !== true ? normalizePagePath(rawSlug, file) : '';
130
+ const slug = slugify(String(rawSlug).split('/').pop());
131
+ const date = normalizeDate(metadata.date, bundleName, kind, file);
132
+ let path;
133
+ if (kind === 'posts') {
134
+ path = config.permalink
135
+ .replace(/:year/g, String(date.getUTCFullYear()))
136
+ .replace(/:month/g, String(date.getUTCMonth() + 1).padStart(2, '0'))
137
+ .replace(/:day/g, String(date.getUTCDate()).padStart(2, '0'))
138
+ .replace(/:slug/g, slug)
139
+ .replace(/:title/g, slug)
140
+ .replace(/^\/+|\/+$/g, '') + '/';
141
+ } else {
142
+ const localePrefix = locale === config.i18n.defaultLocale ? '' : locale + '/';
143
+ path = metadata.homepage === true ? localePrefix : normalizedPagePath + '/';
144
+ }
145
+ if (locale !== config.i18n.defaultLocale && metadata.homepage !== true) path = locale + '/' + path;
146
+ return {
147
+ kind, file, relativePath, title, slug, date, locale,
148
+ tags: normalizeTags(metadata.tags),
149
+ description: String(metadata.description ?? ''),
150
+ keywords: normalizeTags(metadata.keywords),
151
+ robots: typeof metadata.robots === 'string' ? metadata.robots : '',
152
+ image: typeof metadata.image === 'string' ? metadata.image : '',
153
+ homepage: metadata.homepage === true,
154
+ blocks: metadata.blocks ?? [],
155
+ draft: metadata.draft === true,
156
+ path,
157
+ markdown: markdown.trim()
158
+ };
159
+ });
160
+ }