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
@@ -0,0 +1,263 @@
1
+ import { plainText } from './markdown.js';
2
+ import { renderBlocks } from './page-blocks.js';
3
+ import { renderLayout } from './theme.js';
4
+ import { localizedUrl, translate } from './i18n.js';
5
+ import { slugify } from './content.js';
6
+ import { mapLimit } from './concurrency.js';
7
+
8
+ function escapeHtml(value) {
9
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
10
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
11
+ })[char]);
12
+ }
13
+
14
+ function escapeXml(value) {
15
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
16
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'
17
+ })[char]);
18
+ }
19
+
20
+ function urlFor(path) {
21
+ const parts = String(path ?? '').split('/').filter(Boolean).map(encodeURIComponent);
22
+ return '/' + parts.join('/') + (String(path).endsWith('/') && parts.length ? '/' : '');
23
+ }
24
+
25
+ function tagSlug(tag) {
26
+ if (/[^\x00-\x7F]/.test(tag)) return 'tag-' + Array.from(tag).map((char) => char.codePointAt(0).toString(16)).join('-');
27
+ return slugify(tag);
28
+ }
29
+
30
+ function dateLabel(date, language) {
31
+ return new Intl.DateTimeFormat(language, { dateStyle: 'long', timeZone: 'UTC' }).format(date);
32
+ }
33
+
34
+ function fileRoute(path, body, contentType = 'text/html; charset=utf-8') {
35
+ return { path, body, contentType };
36
+ }
37
+
38
+ function urlPathForRoute(path) {
39
+ const value = String(path).replace(/\\/g, '/');
40
+ if (value === 'index.html') return '/';
41
+ if (value.endsWith('/index.html')) return '/' + value.slice(0, -'index.html'.length);
42
+ return '/' + value;
43
+ }
44
+
45
+ async function pageRoute(path, title, description, body, locale, config, extensions, metadata = {}) {
46
+ const urlPath = metadata.urlPath || urlPathForRoute(path);
47
+ const structuredData = metadata.structuredData ?? {
48
+ '@context': 'https://schema.org',
49
+ '@type': 'WebPage',
50
+ name: title,
51
+ description: description || config.site.description,
52
+ url: config.site.url ? config.site.url + urlPath : undefined,
53
+ inLanguage: locale,
54
+ isPartOf: config.site.url ? { '@type': 'WebSite', name: config.site.title, url: config.site.url } : undefined
55
+ };
56
+ let html = await renderLayout(config, extensions, {
57
+ title,
58
+ description,
59
+ locale,
60
+ urlPath,
61
+ keywords: metadata.keywords,
62
+ image: metadata.image,
63
+ robots: metadata.robots,
64
+ structuredData
65
+ }, body);
66
+ if (config.preview) {
67
+ html = html.replace(/<\/body>/i, '<script defer src="/__edgepress_preview.js"></script></body>');
68
+ }
69
+ return fileRoute(path, html);
70
+ }
71
+
72
+ function renderPostCard(post, locale) {
73
+ const summary = post.description || plainText(post.markdown).slice(0, 220);
74
+ return '<article class="post-card"><h2><a href="' + escapeHtml(urlFor(post.path)) + '">' + escapeHtml(post.title) + '</a></h2>' +
75
+ '<p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, locale)) + '</time></p><p>' +
76
+ escapeHtml(summary) + '</p></article>';
77
+ }
78
+
79
+ async function pageBodyForDocument(page, site, config, locale, isHomepage = false) {
80
+ const content = await renderBlocks(page.blocks, { config, locale, site, isHomepage });
81
+ const firstElement = page.blocks[0]?.cells?.[0]?.[0];
82
+ const needsTitle = !isHomepage || firstElement?.type !== 'hero';
83
+ const title = needsTitle ? '<header class="page-title"><h1>' + escapeHtml(page.title) + '</h1></header>' : '';
84
+ return '<article class="page-builder">' + title + content + '</article>';
85
+ }
86
+
87
+ function homepageDocument(pages, locale, defaultLocale) {
88
+ return pages.find((page) => page.homepage && page.locale === locale) ||
89
+ pages.find((page) => page.homepage && page.locale === defaultLocale) || null;
90
+ }
91
+
92
+ export async function generateBuiltinRoutes(site, config, extensions) {
93
+ const routes = [];
94
+ const allPagePaths = new Set();
95
+ const defaultLocale = config.i18n.defaultLocale;
96
+ const homepages = site.pages.filter((page) => page.homepage);
97
+ const homepageLocales = new Set();
98
+ for (const page of homepages) {
99
+ if (homepageLocales.has(page.locale)) throw new Error('Only one homepage may be configured for locale ' + page.locale);
100
+ homepageLocales.add(page.locale);
101
+ }
102
+
103
+ for (const locale of config.i18n.locales) {
104
+ const posts = site.posts.filter((post) => post.locale === locale).sort((a, b) => b.date - a.date);
105
+ const pages = site.pages.filter((page) => page.locale === locale && !page.homepage);
106
+ const homePage = homepageDocument(site.pages, locale, defaultLocale);
107
+ const prefix = locale === defaultLocale ? '' : locale + '/';
108
+ const label = (key) => translate(config, locale, key);
109
+ const localeHomeUrl = localizedUrl(config, locale, '');
110
+ allPagePaths.add(localeHomeUrl);
111
+
112
+ if (homePage) {
113
+ const body = await pageBodyForDocument(homePage, site, config, locale, true);
114
+ routes.push(await pageRoute(prefix + 'index.html', homePage.title, homePage.description || config.site.description,
115
+ body, locale, config, extensions, {
116
+ urlPath: localeHomeUrl,
117
+ keywords: homePage.keywords,
118
+ image: homePage.image,
119
+ robots: homePage.robots,
120
+ structuredData: {
121
+ '@context': 'https://schema.org', '@type': 'WebSite', name: config.site.title,
122
+ description: homePage.description || config.site.description,
123
+ url: config.site.url ? config.site.url + localeHomeUrl : undefined,
124
+ inLanguage: locale,
125
+ publisher: config.site.seo.author ? { '@type': 'Organization', name: config.site.seo.author } : undefined
126
+ }
127
+ }));
128
+ } else {
129
+ const totalPages = Math.max(1, Math.ceil(posts.length / config.pagination.perPage));
130
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
131
+ const subset = posts.slice((pageNumber - 1) * config.pagination.perPage, pageNumber * config.pagination.perPage);
132
+ const body = '<section class="intro"><h1>' + escapeHtml(config.site.title) + '</h1><p>' + escapeHtml(config.site.description) +
133
+ '</p></section><section class="post-list" aria-label="' + escapeHtml(label('latestPosts')) + '">' +
134
+ (subset.length ? subset.map((post) => renderPostCard(post, locale)).join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') +
135
+ '</section>' + (pageNumber === 1 && pages.length ? '<section class="page-links"><h2>' + escapeHtml(label('pages')) + '</h2><ul>' +
136
+ pages.map((page) => '<li><a href="' + escapeHtml(urlFor(page.path)) + '">' + escapeHtml(page.title) + '</a></li>').join('') + '</ul></section>' : '') +
137
+ (totalPages > 1 ? '<nav class="pagination" aria-label="' + escapeHtml(label('pagination')) + '">' +
138
+ (pageNumber > 1 ? '<a href="' + escapeHtml(pageNumber === 2 ? localizedUrl(config, locale, '') : localizedUrl(config, locale, 'page/' + (pageNumber - 1) + '/')) + '">' + escapeHtml(label('newer')) + '</a>' : '') +
139
+ (pageNumber < totalPages ? '<a href="' + escapeHtml(localizedUrl(config, locale, 'page/' + (pageNumber + 1) + '/')) + '">' + escapeHtml(label('older')) + '</a>' : '') + '</nav>' : '');
140
+ const routePath = pageNumber === 1 ? prefix + 'index.html' : prefix + 'page/' + pageNumber + '/index.html';
141
+ routes.push(await pageRoute(routePath, pageNumber === 1 ? config.site.title : 'Page ' + pageNumber,
142
+ config.site.description, body, locale, config, extensions));
143
+ }
144
+ }
145
+
146
+ for (const post of posts) allPagePaths.add(urlFor(post.path));
147
+ const postRoutes = await mapLimit(posts, config.concurrency, async (post) => {
148
+ const body = '<article class="post"><header><h1>' + escapeHtml(post.title) + '</h1><p class="meta"><time datetime="' + post.date.toISOString() + '">' +
149
+ escapeHtml(dateLabel(post.date, locale)) + '</time></p></header><div class="post-content">' + post.html + '</div>' +
150
+ (post.tags.length ? '<p class="tags">' + escapeHtml(label('tags')) + ': ' + post.tags.map((tag) => '<a href="' +
151
+ escapeHtml(localizedUrl(config, locale, 'tags/' + tagSlug(tag) + '/')) + '">' + escapeHtml(tag) + '</a>').join(' ') + '</p>' : '') + '</article>';
152
+ const canonicalPath = urlFor(post.path);
153
+ return pageRoute(post.path + 'index.html', post.title, post.description || plainText(post.markdown).slice(0, 160), body,
154
+ locale, config, extensions, {
155
+ urlPath: canonicalPath, keywords: post.tags, image: post.image, robots: post.robots,
156
+ structuredData: {
157
+ '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title,
158
+ description: post.description || plainText(post.markdown).slice(0, 160),
159
+ datePublished: post.date.toISOString(), dateModified: post.date.toISOString(),
160
+ author: { '@type': 'Person', name: config.site.seo.author || config.site.title },
161
+ mainEntityOfPage: config.site.url ? config.site.url + canonicalPath : undefined,
162
+ inLanguage: locale
163
+ }
164
+ });
165
+ });
166
+ routes.push(...postRoutes);
167
+
168
+ for (const page of pages) allPagePaths.add(urlFor(page.path));
169
+ const pageRoutes = await mapLimit(pages, config.concurrency, async (page) => {
170
+ const body = await pageBodyForDocument(page, site, config, locale, false);
171
+ return pageRoute(page.path ? page.path + 'index.html' : prefix + 'index.html', page.title,
172
+ page.description || config.site.description, body, locale, config, extensions, {
173
+ urlPath: urlFor(page.path), keywords: page.keywords, image: page.image, robots: page.robots
174
+ });
175
+ });
176
+ routes.push(...pageRoutes);
177
+
178
+ if (!homePage) allPagePaths.add(localizedUrl(config, locale, ''));
179
+ const archiveBody = '<section><h1>' + escapeHtml(label('archives')) + '</h1>' +
180
+ (posts.length ? posts.map((post) => renderPostCard(post, locale)).join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') + '</section>';
181
+ routes.push(await pageRoute(prefix + 'archives/index.html', label('archives'), label('allPosts'), archiveBody, locale, config, extensions));
182
+
183
+ const tags = new Map();
184
+ for (const post of posts) for (const tag of post.tags) {
185
+ const slug = tagSlug(tag);
186
+ if (!tags.has(slug)) tags.set(slug, { label: tag, posts: [] });
187
+ tags.get(slug).posts.push(post);
188
+ }
189
+ const tagRoutes = await mapLimit([...tags.entries()], config.concurrency, async ([slug, value]) => {
190
+ const body = '<section><h1>' + escapeHtml(label('tag')) + ': ' + escapeHtml(value.label) + '</h1>' +
191
+ value.posts.map((post) => renderPostCard(post, locale)).join('') + '</section>';
192
+ return pageRoute(prefix + 'tags/' + slug + '/index.html', label('tag') + ': ' + value.label,
193
+ label('tag') + ' ' + value.label, body, locale, config, extensions);
194
+ });
195
+ routes.push(...tagRoutes);
196
+
197
+ const searchable = [
198
+ ...posts.map((post) => ({ title: post.title, date: post.date.toISOString(), url: urlFor(post.path), summary: post.description || plainText(post.markdown).slice(0, 220), content: plainText(post.markdown), type: 'post' })),
199
+ ...pages.map((page) => ({ title: page.title, url: urlFor(page.path), summary: page.description || plainText(page.markdown).slice(0, 220), type: 'page' }))
200
+ ];
201
+ routes.push(fileRoute(prefix + 'search.json', JSON.stringify(searchable), 'application/json; charset=utf-8'));
202
+
203
+ const searchBody = '<section class="local-search-page"><h1>' + escapeHtml(label('searchPosts')) + '</h1>' +
204
+ '<p>' + escapeHtml(label('searchIntro')) + '</p><form class="local-search-form" data-edgepress-search data-index="' +
205
+ escapeHtml(localizedUrl(config, locale, 'search.json')) + '"><label for="edgepress-search-query">' + escapeHtml(label('searchLabel')) +
206
+ '</label><div class="local-search-controls"><input id="edgepress-search-query" name="q" type="search" autocomplete="off" ' +
207
+ 'aria-describedby="edgepress-search-hint"><button type="submit">' + escapeHtml(label('searchButton')) + '</button></div>' +
208
+ '<p id="edgepress-search-hint">' + escapeHtml(label('searchHint')) + '</p></form><p id="edgepress-search-status" role="status" aria-live="polite">' +
209
+ escapeHtml(label('searchPrompt')) + '</p><ol id="edgepress-search-results" class="local-search-results"></ol>' +
210
+ '<noscript><p>' + escapeHtml(label('searchNeedsJavaScript')) + '</p></noscript></section>' +
211
+ '<script defer src="/edgepress/search.js"></script>';
212
+ routes.push(await pageRoute(prefix + 'search/index.html', label('searchPosts'), label('searchIntro'), searchBody,
213
+ locale, config, extensions));
214
+
215
+ const base = config.site.url;
216
+ const entries = posts.slice(0, 20).map((post) => '<entry><title>' + escapeXml(post.title) + '</title><link href="' +
217
+ escapeXml(base + urlFor(post.path)) + '"/><id>' + escapeXml(base + urlFor(post.path)) + '</id><updated>' + post.date.toISOString() +
218
+ '</updated><summary>' + escapeXml(post.description || plainText(post.markdown).slice(0, 220)) + '</summary></entry>').join('');
219
+ routes.push(fileRoute(prefix + 'feed.xml', '<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>' +
220
+ escapeXml(config.site.title) + '</title><id>' + escapeXml(base || 'urn:edgepress:site') + '</id><updated>' +
221
+ (posts[0]?.date.toISOString() || new Date(0).toISOString()) + '</updated>' + entries + '</feed>', 'application/atom+xml; charset=utf-8'));
222
+ }
223
+
224
+ const base = config.site.url;
225
+ const sitemap = '<?xml version="1.0" encoding="utf-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
226
+ [...allPagePaths].map((path) => '<url><loc>' + escapeXml(base + path) + '</loc></url>').join('') + '</urlset>';
227
+ routes.push(fileRoute('sitemap.xml', sitemap, 'application/xml; charset=utf-8'));
228
+ const agentNames = config.site.agentSeo.enabled ? config.site.agentSeo.agentCrawlers : [];
229
+ const robots = ['User-agent: *', 'Allow: /', ...agentNames.flatMap((name) => ['', 'User-agent: ' + name, 'Allow: /']), '',
230
+ 'Sitemap: ' + (base ? base + '/sitemap.xml' : '/sitemap.xml'), ''].join('\n');
231
+ routes.push(fileRoute('robots.txt', robots, 'text/plain; charset=utf-8'));
232
+ if (config.preview) {
233
+ routes.push(fileRoute('__edgepress_preview.json', JSON.stringify({ buildId: config.previewBuildId }), 'application/json; charset=utf-8'));
234
+ routes.push(fileRoute('__edgepress_preview.js', '(()=>{let seen="";const poll=async()=>{try{const response=await fetch("/__edgepress_preview.json?ts="+Date.now(),{cache:"no-store"});if(!response.ok)return;const state=await response.json();if(seen&&state.buildId!==seen){window.location.reload();return}seen=state.buildId}catch{}};void poll();window.setInterval(poll,1000)})();', 'text/javascript; charset=utf-8'));
235
+ }
236
+ if (config.site.agentSeo.enabled && config.site.agentSeo.llmsTxt) {
237
+ const sections = [...site.pages.filter((page) => !page.draft), ...site.posts.slice(0, 30)];
238
+ const lines = ['# ' + config.site.title, '', config.site.agentSeo.summary || config.site.description, ''];
239
+ if (config.site.agentSeo.topics.length) lines.push('Topics: ' + config.site.agentSeo.topics.join(', '), '');
240
+ lines.push('## Project pages', '');
241
+ for (const page of sections.filter((item) => item.kind === 'pages')) lines.push('- [' + page.title + '](' + base + urlFor(page.path) + '): ' + (page.description || plainText(page.markdown).slice(0, 240)));
242
+ lines.push('', '## Recent posts', '');
243
+ for (const post of sections.filter((item) => item.kind === 'posts').slice(0, 30)) lines.push('- [' + post.title + '](' + base + urlFor(post.path) + '): ' + (post.description || plainText(post.markdown).slice(0, 240)));
244
+ routes.push(fileRoute('llms.txt', lines.join('\n') + '\n', 'text/plain; charset=utf-8'));
245
+ }
246
+
247
+ for (const locale of config.i18n.locales) {
248
+ const prefix = locale === defaultLocale ? '' : locale + '/';
249
+ const notFoundTitle = translate(config, locale, 'notFound');
250
+ const notFoundText = translate(config, locale, 'notFoundText');
251
+ const notFoundBody = '<section class="error-page"><p class="error-code" aria-hidden="true">404</p><h1>' + escapeHtml(notFoundTitle) +
252
+ '</h1><p>' + escapeHtml(notFoundText) + '</p><p><a class="button" href="' + escapeHtml(localizedUrl(config, locale, '')) + '">' +
253
+ escapeHtml(translate(config, locale, 'returnHome')) + '</a></p></section>';
254
+ routes.push(await pageRoute(prefix + '404.html', notFoundTitle, notFoundText, notFoundBody, locale, config, extensions, { robots: 'noindex,follow' }));
255
+ const forbiddenTitle = translate(config, locale, 'forbidden');
256
+ const forbiddenText = translate(config, locale, 'forbiddenText');
257
+ const forbiddenBody = '<section class="error-page"><p class="error-code" aria-hidden="true">403</p><h1>' + escapeHtml(forbiddenTitle) +
258
+ '</h1><p>' + escapeHtml(forbiddenText) + '</p><p><a class="button" href="' + escapeHtml(localizedUrl(config, locale, '')) + '">' +
259
+ escapeHtml(translate(config, locale, 'returnHome')) + '</a></p></section>';
260
+ routes.push(await pageRoute(prefix + '403.html', forbiddenTitle, forbiddenText, forbiddenBody, locale, config, extensions, { robots: 'noindex,nofollow' }));
261
+ }
262
+ return routes;
263
+ }
package/src/i18n.js ADDED
@@ -0,0 +1,64 @@
1
+ import { readFile, realpath } from 'node:fs/promises';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
3
+
4
+ const MAX_LANGUAGE_FILE_BYTES = 64 * 1024;
5
+
6
+ function isInside(root, target) {
7
+ const path = relative(root, target);
8
+ return path !== '..' && !path.startsWith('..' + sep) && !isAbsolute(path);
9
+ }
10
+
11
+ async function loadDictionary(root, directory, locale, label) {
12
+ const directoryPath = resolve(root, directory);
13
+ const file = resolve(directoryPath, locale + '.json');
14
+ const realRoot = await realpath(root);
15
+ const realDirectory = await realpath(directoryPath);
16
+ if (!isInside(realRoot, realDirectory)) throw new Error(label + ' language directory resolves outside the project root: ' + directoryPath);
17
+ const realFile = await realpath(file).catch((error) => {
18
+ if (error.code === 'ENOENT') throw new Error('Missing ' + label + ' language file: ' + file);
19
+ throw error;
20
+ });
21
+ if (!isInside(realDirectory, realFile)) throw new Error(label + ' language file resolves outside its language directory: ' + file);
22
+ const source = await readFile(realFile);
23
+ if (source.byteLength > MAX_LANGUAGE_FILE_BYTES) throw new Error(label + ' language file exceeds 64 KB: ' + file);
24
+ let parsed;
25
+ try { parsed = JSON.parse(source.toString('utf8')); }
26
+ catch (error) { throw new Error('Invalid JSON in ' + label + ' language file: ' + file, { cause: error }); }
27
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
28
+ throw new Error(label + ' language file must contain an object of string translations: ' + file);
29
+ }
30
+ const dictionary = Object.create(null);
31
+ for (const [id, value] of Object.entries(parsed)) {
32
+ if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(id)) throw new Error('Invalid translation id in ' + file + ': ' + id);
33
+ if (typeof value !== 'string') throw new Error('Translation values must be strings in ' + file + ': ' + id);
34
+ dictionary[id] = value;
35
+ }
36
+ return dictionary;
37
+ }
38
+
39
+ export async function loadLanguagePacks(config) {
40
+ const baseLocale = config.i18n.defaultLocale;
41
+ const translationsByLocale = Object.create(null);
42
+ translationsByLocale[baseLocale] = await loadDictionary(config.root, 'languages/base', baseLocale, 'Base');
43
+ for (const locale of config.i18n.languagePacks) {
44
+ translationsByLocale[locale] = await loadDictionary(config.root, 'languages/packs', locale, 'Language pack');
45
+ }
46
+ config.i18n.translationsByLocale = translationsByLocale;
47
+ return translationsByLocale;
48
+ }
49
+
50
+ export function translate(config, locale, key) {
51
+ const base = config.i18n.translationsByLocale?.[config.i18n.defaultLocale];
52
+ const selected = config.i18n.translationsByLocale?.[locale];
53
+ return selected?.[key] ?? base?.[key] ?? key;
54
+ }
55
+
56
+ export function localePrefix(config, locale) {
57
+ return locale === config.i18n.defaultLocale ? '' : '/' + locale;
58
+ }
59
+
60
+ export function localizedUrl(config, locale, path) {
61
+ const prefix = localePrefix(config, locale);
62
+ const suffix = String(path ?? '').replace(/^\/+/, '');
63
+ return prefix + '/' + suffix;
64
+ }
@@ -0,0 +1,122 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+
4
+ async function readJson(file) {
5
+ return JSON.parse(await readFile(file, 'utf8'));
6
+ }
7
+
8
+ function versionAtLeast(current, minimum) {
9
+ const currentParts = current.split('.').map(Number);
10
+ const minimumParts = minimum.split('.').map(Number);
11
+ if (currentParts.length !== 3 || minimumParts.length !== 3 || [...currentParts, ...minimumParts].some((part) => !Number.isInteger(part))) {
12
+ return false;
13
+ }
14
+ for (let index = 0; index < 3; index += 1) {
15
+ if (currentParts[index] !== minimumParts[index]) return currentParts[index] > minimumParts[index];
16
+ }
17
+ return true;
18
+ }
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
+ export async function checkCompatibility(config) {
44
+ const root = config.root;
45
+ const manifest = await readJson(resolve(root, 'project-compatibility.json'));
46
+ const wranglerText = await readFile(resolve(root, 'wrangler.jsonc'), 'utf8');
47
+ const wrangler = JSON.parse(wranglerText.replace(/^\s*\/\/.*$/gm, '').replace(/,\s*([}\]])/g, '$1'));
48
+ const issues = [];
49
+ const nodeVersion = process.versions.node;
50
+ const minimumNodeVersion = manifest.requirements.nodeMinimumVersion || (manifest.requirements.nodeMajor + '.0.0');
51
+ if (!versionAtLeast(nodeVersion, minimumNodeVersion)) {
52
+ issues.push({ severity: 'error', message: 'Build requires Node.js ' + minimumNodeVersion + ' or newer.' });
53
+ }
54
+ const workerDate = String(wrangler.compatibility_date ?? '');
55
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(workerDate)) {
56
+ issues.push({ severity: 'error', message: 'wrangler.jsonc needs a valid compatibility_date.' });
57
+ } else if (workerDate < manifest.requirements.minimumWorkersDate) {
58
+ issues.push({ severity: 'error', message: 'Workers compatibility_date is older than the supported minimum.' });
59
+ }
60
+ if (!wrangler.assets || !wrangler.assets.directory || wrangler.assets.binding !== 'ASSETS') {
61
+ issues.push({ severity: 'error', message: 'Configure the ASSETS binding and static assets directory in Wrangler.' });
62
+ }
63
+ const workerSource = await readFile(resolve(root, 'src/worker.js'), 'utf8');
64
+ if (/from\s+['"]node:|require\s*\(\s*['"]node:/i.test(workerSource)) {
65
+ issues.push({ severity: 'error', message: 'The Worker entry imports a Node.js built-in; keep build-only APIs out of src/worker.js.' });
66
+ }
67
+ const first = wrangler.assets?.run_worker_first;
68
+ if (!Array.isArray(first) || !first.includes('/api/*')) {
69
+ issues.push({ severity: 'warning', message: 'Keep Worker-first routing limited to API paths so static pages bypass script execution.' });
70
+ }
71
+
72
+ const report = {
73
+ generatedAt: new Date().toISOString(),
74
+ status: issues.some((issue) => issue.severity === 'error') ? 'fail' : issues.length ? 'warning' : 'pass',
75
+ runtime: { node: process.versions.node, workersDate: workerDate || 'missing' },
76
+ requirements: manifest.requirements,
77
+ features: manifest.features,
78
+ issues
79
+ };
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
+ return report;
85
+ }
86
+
87
+ export async function writeIterationPlan(config, pageReport, compatibilityReport) {
88
+ const actions = [];
89
+ for (const issue of pageReport.issues) {
90
+ actions.push({ source: 'page-check', severity: issue.severity, action: 'Review ' + issue.page + ': ' + issue.message });
91
+ }
92
+ for (const issue of compatibilityReport.issues) {
93
+ actions.push({ source: 'compatibility', severity: issue.severity, action: issue.message });
94
+ }
95
+ actions.push({
96
+ source: 'security',
97
+ severity: 'info',
98
+ action: 'Review dependency advisories with edgepress security; update dependencies in a reviewed branch.'
99
+ });
100
+ const report = {
101
+ generatedAt: new Date().toISOString(),
102
+ mode: 'report-only',
103
+ status: actions.some((item) => item.severity === 'error') ? 'review-required' : 'ready-for-review',
104
+ actions,
105
+ safety: {
106
+ sourceFilesChanged: false,
107
+ dependenciesChanged: false,
108
+ deploymentTriggered: false
109
+ }
110
+ };
111
+ const directory = resolve(config.resolvedPaths.cache, 'reports');
112
+ await mkdir(directory, { recursive: true });
113
+ await writeFile(resolve(directory, 'iteration-plan.json'), JSON.stringify(report, null, 2), 'utf8');
114
+ await writeFile(resolve(directory, 'iteration-plan.md'), [
115
+ '# EdgePress iteration plan',
116
+ '',
117
+ 'Mode: report only. No source, dependency, or deployment changes were made.',
118
+ '',
119
+ ...actions.map((item) => '- [' + item.severity.toUpperCase() + '] ' + item.action)
120
+ ].join('\n'), 'utf8');
121
+ return report;
122
+ }
@@ -0,0 +1,65 @@
1
+ import { marked } from 'marked';
2
+ import sanitizeHtml from 'sanitize-html';
3
+
4
+ const allowedTags = [
5
+ 'a', 'abbr', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', 'blockquote',
6
+ 'br', 'caption', 'cite', 'code', 'data', 'dd', 'del', 'details', 'dfn',
7
+ 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'h1', 'h2',
8
+ 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'i', 'img', 'kbd', 'li', 'main',
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'
13
+ ];
14
+
15
+ const sanitizeOptions = {
16
+ allowedTags,
17
+ allowedAttributes: {
18
+ a: ['href', 'target', 'rel'],
19
+ code: ['class'],
20
+ img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
21
+ li: ['value'],
22
+ ol: ['start'],
23
+ span: ['class', 'role', 'aria-label'],
24
+ td: ['align', 'colspan', 'rowspan'],
25
+ th: ['align', 'colspan', 'rowspan']
26
+ },
27
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
28
+ allowProtocolRelative: false,
29
+ nonTextTags: ['script', 'style', 'textarea', 'option', 'xmp'],
30
+ transformTags: {
31
+ a: (_tagName, attributes) => ({
32
+ tagName: 'a',
33
+ attribs: { ...attributes, rel: 'noopener noreferrer' }
34
+ })
35
+ }
36
+ };
37
+
38
+ export async function renderMarkdown(source, options = {}) {
39
+ let html = await marked.parse(source, {
40
+ gfm: options.gfm !== false,
41
+ breaks: options.breaks === true
42
+ });
43
+ // GFM task markers become named static status icons. They remain readable to
44
+ // assistive technology without exposing disabled, unlabeled form controls.
45
+ html = html.replace(/<input\b([^>]*)>/gi, (_match, attributes) => {
46
+ const checked = /\bchecked(?:\s|=|$)/i.test(attributes);
47
+ const label = checked ? (options.taskLabels?.complete || 'Task complete') :
48
+ (options.taskLabels?.incomplete || 'Task incomplete');
49
+ const safeLabel = String(label).replace(/[&<>"']/g, (char) => ({
50
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
51
+ })[char]);
52
+ return '<span class="markdown-task-status" role="img" aria-label="' +
53
+ safeLabel + '">' + (checked ? '✓' : '□') + '</span>';
54
+ });
55
+ return sanitizeHtml(html, sanitizeOptions);
56
+ }
57
+
58
+ export function plainText(source) {
59
+ return String(source)
60
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
61
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
62
+ .replace(/[#>*_~|-]/g, ' ')
63
+ .replace(/\s+/g, ' ')
64
+ .trim();
65
+ }