edgepress 2026.10.12 → 2026.10.13

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 (57) hide show
  1. package/README.md +12 -2
  2. package/config.yml +171 -171
  3. package/content/assets/edgepress/brand/sources/export-manifest.json +181 -181
  4. package/content/assets/edgepress-markdown-guide.svg +21 -21
  5. package/content/guides/markdown-media.md +38 -0
  6. package/content/guides/product-demos.md +30 -30
  7. package/content/pages/deployment/en.json +1 -1
  8. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
  9. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
  10. package/languages/base/en.json +98 -98
  11. package/languages/packs/zh-CN.json +98 -98
  12. package/package.json +90 -90
  13. package/src/assets.js +187 -186
  14. package/src/browser-audit.js +158 -158
  15. package/src/build.js +293 -291
  16. package/src/cli.js +364 -364
  17. package/src/content.js +233 -233
  18. package/src/data-saver.js +3 -3
  19. package/src/generators.js +414 -420
  20. package/src/i18n.js +86 -86
  21. package/src/markdown.js +195 -160
  22. package/src/page-check.js +1 -1
  23. package/src/plugin-api.js +77 -77
  24. package/src/report-visuals.js +642 -642
  25. package/src/revision-cards.js +30 -30
  26. package/src/rss.js +100 -0
  27. package/src/theme.js +4 -4
  28. package/src/video-metadata.js +36 -0
  29. package/static/edgepress/fonts/faces.css +2217 -2217
  30. package/static/edgepress/media-viewer.js +67 -10
  31. package/static/edgepress/project-demo.js +83 -83
  32. package/static/edgepress/search.js +84 -84
  33. package/tests/assets-runtime.test.mjs +26 -26
  34. package/tests/audit.test.mjs +142 -142
  35. package/tests/brand-regression.test.mjs +110 -110
  36. package/tests/document-package.test.mjs +2 -2
  37. package/tests/embed-syntax.test.mjs +68 -68
  38. package/tests/image-viewer.test.mjs +36 -36
  39. package/tests/markdown-media.test.mjs +119 -0
  40. package/tests/oembed-layout.test.mjs +6 -1
  41. package/tests/project-demo.test.mjs +73 -73
  42. package/tests/release-version.test.mjs +28 -0
  43. package/tests/rss.test.mjs +54 -0
  44. package/tests/video-metadata.test.mjs +28 -0
  45. package/tests/writing.test.mjs +83 -83
  46. package/themes/atelier/assets/style.css +340 -340
  47. package/themes/default/assets/style.css +371 -371
  48. package/themes/default/layouts/partials/head.html +1 -1
  49. package/themes/folio/assets/style.css +515 -515
  50. package/themes/folio/theme.json +5 -5
  51. package/themes/signal/assets/style.css +332 -332
  52. package/tools/README.md +10 -10
  53. package/tools/assets/generate-brand-assets.py +160 -160
  54. package/tools/assets/video-preview.mjs +15 -0
  55. package/tools/recordings/demo-workbench.mjs +53 -53
  56. package/tools/recordings/desktop-recording.mjs +70 -70
  57. package/tools/recordings/vm-desktop.mjs +59 -59
package/src/generators.js CHANGED
@@ -1,420 +1,414 @@
1
- import {renderPostServices,hasServiceBlock} from './service-block.js';
2
- import {renderRevisionCards} from './revision-cards.js';
3
- import { authorForPost, readingMinutes, filterPostsByCategory, categorySlug, normalizeCategory } from './post-details.js';
4
- import { plainText, renderMarkdownExcerpt } from './markdown.js';
5
- import { renderBlocks } from './page-blocks.js';
6
- import { renderLayout } from './theme.js';
7
- import { localizedContentUrl, localizedUrl, translate } from './i18n.js';
8
- import { postsForLocale, slugify, sortPostsNewest } from './content.js';
9
- import { mapLimit } from './concurrency.js';
10
- import { renderIcon } from './icons.js';
11
-
12
- function escapeHtml(value) {
13
- return String(value ?? '').replace(/[&<>"']/g, (char) => ({
14
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
15
- })[char]);
16
- }
17
-
18
- function escapeXml(value) {
19
- return String(value ?? '').replace(/[&<>"']/g, (char) => ({
20
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'
21
- })[char]);
22
- }
23
-
24
- function urlFor(path) {
25
- const parts = String(path ?? '').split('/').filter(Boolean).map(encodeURIComponent);
26
- return '/' + parts.join('/') + (String(path).endsWith('/') && parts.length ? '/' : '');
27
- }
28
-
29
- function dateLabel(date, language, timeZone = 'UTC', dateOnly = true) {
30
- return new Intl.DateTimeFormat(language, dateOnly?{dateStyle:'long',timeZone}:{year:'numeric',month:'long',day:'numeric',hour:'2-digit',minute:'2-digit',timeZoneName:'short',timeZone}).format(date);
31
- }
32
-
33
- function fileRoute(path, body, contentType = 'text/html; charset=utf-8') {
34
- return { path, body, contentType };
35
- }
36
-
37
- function urlPathForRoute(path) {
38
- const value = String(path).replace(/\\/g, '/');
39
- if (value === 'index.html') return '/';
40
- if (value.endsWith('/index.html')) return '/' + value.slice(0, -'index.html'.length);
41
- return '/' + value;
42
- }
43
-
44
- function decodeHeadingEntities(value) {
45
- return value.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (match, entity) => {
46
- const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
47
- if (entity[0] === '#') {
48
- const hex = entity[1]?.toLowerCase() === 'x';
49
- const point = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
50
- return Number.isFinite(point) && point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : match;
51
- }
52
- return named[entity.toLowerCase()] ?? match;
53
- });
54
- }
55
-
56
- function addPostContents(html, locale, config) {
57
- const headings = [];
58
- const counts = new Map();
59
- const content = html.replace(/<h([2-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi, (whole, levelText, attributes, inner) => {
60
- const label = decodeHeadingEntities(inner.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim());
61
- if (!label) return whole;
62
- const base = slugify(label);
63
- const count = (counts.get(base) || 0) + 1;
64
- counts.set(base, count);
65
- const id = count === 1 ? base : base + '-' + count;
66
- headings.push({ id, label, level: Number(levelText) });
67
- const safeAttributes = attributes.replace(/\s+id\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
68
- return '<h' + levelText + safeAttributes + ' id="' + escapeHtml(id) + '">' + inner + '</h' + levelText + '>';
69
- });
70
- if (!headings.length) return { html: content, toc: '' };
71
- const items = headings.map((heading) => '<li class="post-toc-level-' + heading.level + '"><a href="#' +
72
- escapeHtml(heading.id) + '">' + escapeHtml(heading.label) + '</a></li>').join('');
73
- const contentsLabel = translate(config, locale, 'postContents');
74
- const closeContents = translate(config, locale, 'closeContents');
75
- const toc = '<aside class="post-toc-widget" data-post-toc hidden>' +
76
- '<button class="post-toc-toggle" type="button" aria-expanded="false" aria-controls="post-toc-panel" aria-label="' +
77
- escapeHtml(contentsLabel) + '">' + renderIcon('book-open', 'post-toc-icon') + '<span>' + escapeHtml(contentsLabel) + '</span></button>' +
78
- '<nav class="post-toc-panel" id="post-toc-panel" aria-labelledby="post-toc-title" aria-label="' +
79
- escapeHtml(contentsLabel) + '" hidden><div class="post-toc-heading"><h2 id="post-toc-title">' + escapeHtml(contentsLabel) +
80
- '</h2><button class="post-toc-close" type="button" aria-label="' + escapeHtml(closeContents) + '"><span aria-hidden="true">×</span></button></div>' +
81
- '<ol>' + items + '</ol></nav></aside>';
82
- return { html: content, toc };
83
- }
84
-
85
- function renderPostVideo(video, locale) {
86
- if (!video) return '';
87
- const videoTitle = escapeHtml(video.title);
88
- return '<figure class="post-video"><video controls playsinline preload="metadata" poster="' + escapeHtml(video.poster) + '" aria-label="' +
89
- escapeHtml(video.alt) + '"><source src="' + escapeHtml(video.src) + '" type="video/mp4"><track kind="captions" src="' +
90
- escapeHtml(video.captions) + '" srclang="' + escapeHtml(video.captionLanguage) + '" label="' + escapeHtml(video.captionLabel) + '" default>' +
91
- '<a href="' + escapeHtml(video.src) + '">' + (locale.toLowerCase().startsWith('zh') ? '下载 MP4 视频' : 'Download the MP4 video') +
92
- '</a></video><figcaption><strong>' + videoTitle + '</strong><br>' + escapeHtml(video.description) + '</figcaption></figure>';
93
- }
94
-
95
- async function pageRoute(path, title, description, body, locale, config, extensions, metadata = {}) {
96
- const urlPath = metadata.urlPath || urlPathForRoute(path);
97
- const structuredData = metadata.structuredData ?? {
98
- '@context': 'https://schema.org',
99
- '@type': 'WebPage',
100
- name: title,
101
- description: description || config.site.description,
102
- url: config.site.url ? config.site.url + urlPath : undefined,
103
- inLanguage: locale,
104
- isPartOf: config.site.url ? { '@type': 'WebSite', name: config.site.title, url: config.site.url } : undefined
105
- };
106
- body = body.replace(/<pre\b/gi, '<pre data-copyable-code');
107
- let html = await renderLayout(config, extensions, {
108
- title,
109
- description,
110
- locale,
111
- urlPath,
112
- keywords: metadata.keywords,
113
- image: metadata.image,
114
- robots: metadata.robots,
115
- structuredData
116
- }, body);
117
- if (config.preview) {
118
- html = html.replace(/<\/body>/i, '<script defer src="/__edgepress_preview.js"></script></body>');
119
- }
120
- return fileRoute(path, html);
121
- }
122
-
123
- function renderPostAuthor(post, locale, config) {
124
- if (!post.author) return '';
125
- return '<span class="post-author">' + escapeHtml(translate(config, locale, 'postAuthor').replace('{author}', post.author)) + '</span>';
126
- }
127
-
128
- async function renderPostCard(post, locale, config, showLanguage = false) {
129
- const summary = await renderMarkdownExcerpt(post.description || post.markdown, {...config.markdown,linkResolver:value=>localizedContentUrl(config,post.locale,value)});
130
- const postLanguage = showLanguage && post.locale !== locale ? translate(config, post.locale, 'languageName') : '';
131
- return '<article class="post-card" lang="' + escapeHtml(post.locale) + '"><h2><a href="' + escapeHtml(urlFor(post.path)) + '">' + escapeHtml(post.title) + '</a></h2>' +
132
- '<p class="meta"><time data-local-time data-time-locale="' + escapeHtml(post.locale || locale) + '" data-date-only="' + (post.dateOnly === true) + '" datetime="' + (post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString()) + '">' + escapeHtml(dateLabel(post.date, post.locale || locale, post.dateOnly ? 'UTC' : config.site.timeZone, post.dateOnly)) + '</time>' +
133
- (postLanguage ? ' <span class="post-language" lang="' + escapeHtml(post.locale) + '">' + escapeHtml(postLanguage) + '</span>' : '') +
134
- (post.author ? ' ' + renderPostAuthor(post, locale, config) : '') + '</p><div class="post-excerpt" lang="' + escapeHtml(post.locale) + '">' +
135
- summary + '</div></article>';
136
- }
137
-
138
- function paginationPageNumbers(currentPage, totalPages) {
139
- const visible = new Set([1, totalPages]);
140
- for (let page = Math.max(1, currentPage - 2); page <= Math.min(totalPages, currentPage + 2); page += 1) visible.add(page);
141
- const pages = [...visible].sort((left, right) => left - right);
142
- const entries = [];
143
- for (let index = 0; index < pages.length; index += 1) {
144
- const page = pages[index];
145
- const previous = pages[index - 1];
146
- if (previous !== undefined && page - previous > 1) {
147
- if (page - previous === 2) entries.push(previous + 1);
148
- else entries.push(null);
149
- }
150
- entries.push(page);
151
- }
152
- return entries;
153
- }
154
-
155
- function renderPagination(locale, currentPage, totalPages, config, pageUrl) {
156
- const label = (key) => translate(config, locale, key);
157
- const pageLinks = paginationPageNumbers(currentPage, totalPages).map((page) => {
158
- if (page === null) return '<li class="pagination-ellipsis" aria-hidden="true">…</li>';
159
- if (page === currentPage) return '<li><span class="pagination-current" aria-current="page">' + page + '</span></li>';
160
- const pageLabel = label('pageNumber').replace('{page}', String(page));
161
- return '<li><a href="' + escapeHtml(pageUrl(page)) + '" aria-label="' + escapeHtml(pageLabel) + '">' + page + '</a></li>';
162
- }).join('');
163
- const previous = currentPage > 1
164
- ? '<a rel="prev" href="' + escapeHtml(pageUrl(currentPage - 1)) + '">' + escapeHtml(label('newer')) + '</a>' : '';
165
- const next = currentPage < totalPages
166
- ? '<a rel="next" href="' + escapeHtml(pageUrl(currentPage + 1)) + '">' + escapeHtml(label('older')) + '</a>' : '';
167
- return '<nav class="pagination" aria-label="' + escapeHtml(label('pagination')) + '">' + previous +
168
- '<ol class="pagination-pages">' + pageLinks + '</ol>' + next + '</nav>';
169
- }
170
-
171
- async function pageBodyForDocument(page, site, config, locale, isHomepage = false, renderContext = {}) {
172
- const content = await renderBlocks(page.blocks, { config, locale, site, document: page, isHomepage, ...renderContext });
173
- const firstElement = page.blocks[0]?.cells?.[0]?.[0];
174
- const needsTitle = !isHomepage || firstElement?.type !== 'hero';
175
- const title = needsTitle ? '<header class="page-title"><h1>' + escapeHtml(page.title) + '</h1></header>' : '';
176
- return '<article class="page-builder">' + title + content + '</article>';
177
- }
178
-
179
- function paginatedLatestPostsSize(page) {
180
- function find(value) {
181
- if (Array.isArray(value)) {
182
- for (const item of value) {
183
- const result = find(item);
184
- if (result !== null) return result;
185
- }
186
- return null;
187
- }
188
- if (!value || typeof value !== 'object') return null;
189
- if (value.type === 'latest-posts' && value.paginate === true) return value.count ?? 3;
190
- return find(value.cells) ?? find(value.blocks);
191
- }
192
- return find(page?.blocks);
193
- }
194
-
195
- function homepageDocument(pages, locale, defaultLocale) {
196
- return pages.find((page) => page.homepage && page.locale === locale) ||
197
- pages.find((page) => page.homepage && page.locale === defaultLocale) || null;
198
- }
199
-
200
- export async function generateBuiltinRoutes(site, config, extensions) {
201
- const routes = [];
202
- const allPagePaths = new Set();
203
- const defaultLocale = config.i18n.defaultLocale;
204
- const homepages = site.pages.filter((page) => page.homepage);
205
- const homepageLocales = new Set();
206
- for (const page of homepages) {
207
- if (homepageLocales.has(page.locale)) throw new Error('Only one homepage may be configured for locale ' + page.locale);
208
- homepageLocales.add(page.locale);
209
- }
210
-
211
- const allPosts = sortPostsNewest(site.posts);
212
- for (const locale of config.i18n.locales) {
213
- const posts = allPosts.filter((post) => post.locale === locale);
214
- const localizedPosts = postsForLocale(allPosts, locale, defaultLocale);
215
- const archivePosts = filterPostsByCategory(localizedPosts, config.site.archive?.categories);
216
- const pages = site.pages.filter((page) => page.locale === locale && !page.homepage);
217
- const homePage = homepageDocument(site.pages, locale, defaultLocale);
218
- const latestPostsPageSize = paginatedLatestPostsSize(homePage);
219
- if (latestPostsPageSize !== null && (!Number.isInteger(latestPostsPageSize) || latestPostsPageSize < 1 || latestPostsPageSize > 12)) {
220
- throw new Error('A paginated latest-posts block must use a count from 1 to 12');
221
- }
222
- const homepageArchivePages = latestPostsPageSize ? Math.ceil(archivePosts.length / latestPostsPageSize) : 0;
223
- const prefix = locale === defaultLocale ? '' : locale + '/';
224
- const label = (key) => translate(config, locale, key);
225
- const localeHomeUrl = localizedUrl(config, locale, '');
226
- allPagePaths.add(localeHomeUrl);
227
-
228
- if (homePage) {
229
- const latestPostsPagination = latestPostsPageSize ? {
230
- totalPages: homepageArchivePages,
231
- archiveUrl: localizedUrl(config, locale, 'archives/'),
232
- olderUrl: localizedUrl(config, locale, 'archives/page/2/')
233
- } : null;
234
- const body = await pageBodyForDocument(homePage, site, config, locale, true, { latestPostsPagination });
235
- routes.push(await pageRoute(prefix + 'index.html', homePage.title, homePage.description || config.site.description,
236
- body, locale, config, extensions, {
237
- urlPath: localeHomeUrl,
238
- keywords: homePage.keywords,
239
- image: homePage.image,
240
- robots: homePage.robots,
241
- structuredData: {
242
- '@context': 'https://schema.org', '@type': 'WebSite', name: config.site.title,
243
- description: homePage.description || config.site.description,
244
- url: config.site.url ? config.site.url + localeHomeUrl : undefined,
245
- inLanguage: locale,
246
- publisher: config.site.seo.author ? { '@type': 'Organization', name: config.site.seo.author } : undefined
247
- }
248
- }));
249
- } else {
250
- const totalPages = Math.max(1, Math.ceil(archivePosts.length / config.pagination.perPage));
251
- for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
252
- const subset = archivePosts.slice((pageNumber - 1) * config.pagination.perPage, pageNumber * config.pagination.perPage);
253
- const cards = await Promise.all(subset.map((post) => renderPostCard(post, locale, config, true)));
254
- const body = '<section class="intro"><h1>' + escapeHtml(config.site.title) + '</h1><p>' + escapeHtml(config.site.description) +
255
- '</p></section><section class="post-list" aria-label="' + escapeHtml(label('latestPosts')) + '">' +
256
- (cards.length ? '<div class="post-list archive-post-list">' + cards.join('') + '</div>' : '<p>' + escapeHtml(label('noPosts')) + '</p>') +
257
- '</section>' + (pageNumber === 1 && pages.length ? '<section class="page-links"><h2>' + escapeHtml(label('pages')) + '</h2><ul>' +
258
- pages.map((page) => '<li><a href="' + escapeHtml(urlFor(page.path)) + '">' + escapeHtml(page.title) + '</a></li>').join('') + '</ul></section>' : '') +
259
- (totalPages > 1 ? renderPagination(locale, pageNumber, totalPages, config, (page) =>
260
- localizedUrl(config, locale, page === 1 ? '' : 'page/' + page + '/')) : '');
261
- const routePath = pageNumber === 1 ? prefix + 'index.html' : prefix + 'page/' + pageNumber + '/index.html';
262
- routes.push(await pageRoute(routePath, pageNumber === 1 ? config.site.title : 'Page ' + pageNumber,
263
- config.site.description, body, locale, config, extensions));
264
- }
265
- }
266
-
267
- for (const post of posts) allPagePaths.add(urlFor(post.path));
268
- const postRoutes = await mapLimit(posts, config.concurrency, async (post) => {
269
- const renderedMarkdown = addPostContents(post.html, locale, config);
270
- const author = authorForPost(post, config);
271
- const minutes = post.readingMinutes || readingMinutes(post.markdown);
272
- const authorHtml = author.name ? '<span class="post-author">' +
273
- (author.avatar ? '<img class="author-avatar" src="' + escapeHtml(author.avatar) + '" width="40" height="40" alt="" decoding="async">' : '') +
274
- escapeHtml(label('postAuthor').replace('{author}', author.name)) + '</span>' : '';
275
- const tagsHtml = post.tags.length ? '<div class="post-tags" role="group" aria-label="' + escapeHtml(label('tags')) + '"><span class="post-tags-label">' +
276
- escapeHtml(label('tags')) + ':</span>' + post.tags.map(tag => '<span class="post-tag">' + escapeHtml(tag) + '</span>').join('') + '</div>' : '';
277
- const body = '<article class="post"><header><h1>' + escapeHtml(post.title) + '</h1><div class="meta post-byline">' + authorHtml +
278
- '<time data-local-time data-time-locale="' + escapeHtml(post.locale || locale) + '" data-date-only="' + (post.dateOnly === true) + '" datetime="' + (post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString()) + '">' + escapeHtml(dateLabel(post.date, post.locale || locale, post.dateOnly ? 'UTC' : config.site.timeZone, post.dateOnly)) + '</time>' +
279
- '<span class="post-reading-time" data-reading-minutes="' + minutes + '">' + escapeHtml(label('postReadingTime').replace('{minutes}', minutes)) + '</span>' +
280
- (post.updated ? '<span class="post-updated">' + escapeHtml(label('postUpdated')) + ' <time data-local-time data-time-locale="' + escapeHtml(locale) + '" datetime="' + post.updated.toISOString() + '">' + escapeHtml(dateLabel(post.updated, locale, config.site.timeZone, false)) + '</time></span>' : '') +
281
- '</div>' + tagsHtml + '</header>' + (post.showChanges && post.changes ? await renderRevisionCards(post.changes,label,post.revisionImages,{linkResolver:value=>localizedContentUrl(config,locale,value)}) : '') + renderPostVideo(post.video, locale) + renderedMarkdown.toc + '<div class="post-content">' + renderedMarkdown.html + '</div></article>' + renderPostServices(post, locale, config);
282
- const canonicalPath = urlFor(post.path);
283
- return pageRoute(post.path + 'index.html', post.title, post.description || plainText(post.markdown).slice(0, 160), body,
284
- locale, config, extensions, {
285
- urlPath: canonicalPath, keywords: post.tags, image: post.image, robots: post.robots,
286
- structuredData: {
287
- '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title,
288
- description: post.description || plainText(post.markdown).slice(0, 160),
289
- datePublished: post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString(), dateModified: post.updated ? post.updated.toISOString() : post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString(),
290
- author: { '@type': 'Person', name: author.name || config.site.title },
291
- mainEntityOfPage: config.site.url ? config.site.url + canonicalPath : undefined,
292
- inLanguage: locale
293
- }
294
- });
295
- });
296
- routes.push(...postRoutes);
297
-
298
- for (const page of pages) allPagePaths.add(urlFor(page.path));
299
- const pageRoutes = await mapLimit(pages, config.concurrency, async (page) => {
300
- const body = await pageBodyForDocument(page, site, config, locale, false);
301
- return pageRoute(page.path ? page.path + 'index.html' : prefix + 'index.html', page.title,
302
- page.description || config.site.description, body, locale, config, extensions, {
303
- urlPath: urlFor(page.path), keywords: page.keywords, image: page.image, robots: page.robots
304
- });
305
- });
306
- routes.push(...pageRoutes);
307
-
308
- if (!homePage) allPagePaths.add(localizedUrl(config, locale, ''));
309
- const archivePageSize = latestPostsPageSize || config.pagination.perPage;
310
- const archivePageCount = Math.max(1, Math.ceil(archivePosts.length / archivePageSize));
311
- for (let pageNumber = 1; pageNumber <= archivePageCount; pageNumber += 1) {
312
- const subset = archivePosts.slice((pageNumber - 1) * archivePageSize, pageNumber * archivePageSize);
313
- const cards = await Promise.all(subset.map((post) => renderPostCard(post, locale, config, true)));
314
- const archiveUrl = pageNumber === 1 ? 'archives/' : 'archives/page/' + pageNumber + '/';
315
- const pagination = archivePageCount > 1
316
- ? renderPagination(locale, pageNumber, archivePageCount, config, (page) => localizedUrl(config, locale,
317
- page === 1 ? 'archives/' : 'archives/page/' + page + '/')) : '';
318
- const archiveBody = '<section><h1>' + escapeHtml(label('archives')) + '</h1>' +
319
- (cards.length ? '<div class="post-list archive-post-list">' + cards.join('') + '</div>' : '<p>' + escapeHtml(label('noPosts')) + '</p>') + pagination + '</section>';
320
- routes.push(await pageRoute(prefix + (pageNumber === 1 ? 'archives/index.html' : 'archives/page/' + pageNumber + '/index.html'),
321
- pageNumber === 1 ? label('archives') : label('archives') + ' ' + pageNumber, label('allPosts'), archiveBody, locale, config, extensions,
322
- { urlPath: localizedUrl(config, locale, archiveUrl) }));
323
- allPagePaths.add(localizedUrl(config, locale, archiveUrl));
324
- }
325
-
326
- const categories = new Map();
327
- for (const post of localizedPosts) {
328
- const category = normalizeCategory(post.category);
329
- if (!categories.has(category)) categories.set(category, []);
330
- categories.get(category).push(post);
331
- }
332
- for (const [category, categoryPosts] of categories) {
333
- const slug = categorySlug(category);
334
- const title = category === 'blog' ? label('categoryBlog') : category === 'uncategorized' ? label('categoryUncategorized') : category;
335
- const totalPages = Math.ceil(categoryPosts.length / config.pagination.perPage);
336
- const categoryPath = number => 'categories/' + slug + '/' + (number === 1 ? '' : 'page/' + number + '/');
337
- for (let number = 1; number <= totalPages; number += 1) {
338
- const cards = await Promise.all(categoryPosts.slice((number - 1) * config.pagination.perPage, number * config.pagination.perPage)
339
- .map(post => renderPostCard(post, locale, config, true)));
340
- const pagination = totalPages > 1 ? renderPagination(locale, number, totalPages, config,
341
- page => localizedUrl(config, locale, categoryPath(page))) : '';
342
- const body = '<section><h1>' + escapeHtml(title) + '</h1><div class="post-list archive-post-list">' + cards.join('') + '</div>' + pagination + '</section>';
343
- const urlPath = localizedUrl(config, locale, categoryPath(number));
344
- allPagePaths.add(urlPath);
345
- routes.push(await pageRoute(prefix + categoryPath(number) + 'index.html', title, title, body, locale, config, extensions, { urlPath }));
346
- }
347
- }
348
-
349
- const searchable = [
350
- ...posts.map((post) => ({ title: post.title, author: post.author, date: post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString(), dateOnly: post.dateOnly, url: urlFor(post.path), summary: plainText(post.description || post.markdown).slice(0, 220), content: plainText(post.markdown), type: 'post' })),
351
- ...pages.map((page) => ({ title: page.title, url: urlFor(page.path), summary: page.description || plainText(page.markdown).slice(0, 220), type: 'page' }))
352
- ];
353
- routes.push(fileRoute(prefix + 'search.json', JSON.stringify(searchable), 'application/json; charset=utf-8'));
354
-
355
- const searchBody = '<section class="local-search-page"><h1>' + escapeHtml(label('searchPosts')) + '</h1>' +
356
- '<p>' + escapeHtml(label('searchIntro')) + '</p><form class="local-search-form" data-edgepress-search data-index="' +
357
- escapeHtml(localizedUrl(config, locale, 'search.json')) + '"><label for="edgepress-search-query">' + escapeHtml(label('searchLabel')) +
358
- '</label><div class="local-search-controls"><input id="edgepress-search-query" name="q" type="search" autocomplete="off" ' +
359
- '><button type="submit">' + escapeHtml(label('searchButton')) + '</button></div>' +
360
- '</form><p id="edgepress-search-status" role="status" aria-live="polite">' +
361
- '' + '</p><ol id="edgepress-search-results" class="local-search-results"></ol>' +
362
- '<noscript><p>' + escapeHtml(label('searchNeedsJavaScript')) + '</p></noscript></section>';
363
- routes.push(await pageRoute(prefix + 'search/index.html', label('searchPosts'), label('searchIntro'), searchBody,
364
- locale, config, extensions));
365
-
366
- const base = config.site.url;
367
- const entries = posts.slice(0, 20).map((post) => '<entry><title>' + escapeXml(post.title) + '</title>' +
368
- (post.author ? '<author><name>' + escapeXml(post.author) + '</name></author>' : '') + '<link href="' +
369
- escapeXml(base + urlFor(post.path)) + '"/><id>' + escapeXml(base + urlFor(post.path)) + '</id><updated>' + post.date.toISOString() +
370
- '</updated><summary>' + escapeXml(plainText(post.description || post.markdown).slice(0, 220)) + '</summary></entry>').join('');
371
- routes.push(fileRoute(prefix + 'feed.xml', '<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>' +
372
- escapeXml(config.site.title) + '</title><id>' + escapeXml(base || 'urn:edgepress:site') + '</id><updated>' +
373
- (posts[0]?.date.toISOString() || new Date(0).toISOString()) + '</updated>' + entries + '</feed>', 'application/atom+xml; charset=utf-8'));
374
- }
375
-
376
- const base = config.site.url;
377
- const sitemap = '<?xml version="1.0" encoding="utf-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
378
- [...allPagePaths].map((path) => '<url><loc>' + escapeXml(base + path) + '</loc></url>').join('') + '</urlset>';
379
- routes.push(fileRoute('sitemap.xml', sitemap, 'application/xml; charset=utf-8'));
380
- const agentNames = config.site.agentSeo.enabled ? config.site.agentSeo.agentCrawlers : [];
381
- const robots = ['User-agent: *', 'Allow: /', ...agentNames.flatMap((name) => ['', 'User-agent: ' + name, 'Allow: /']), '',
382
- 'Sitemap: ' + (base ? base + '/sitemap.xml' : '/sitemap.xml'), ''].join('\n');
383
- routes.push(fileRoute('robots.txt', robots, 'text/plain; charset=utf-8'));
384
- if (config.preview) {
385
- routes.push(fileRoute('__edgepress_preview.json', JSON.stringify({ buildId: config.previewBuildId }), 'application/json; charset=utf-8'));
386
- 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'));
387
- }
388
- if (config.site.agentSeo.enabled && config.site.agentSeo.llmsTxt) {
389
- const sections = [...site.pages.filter((page) => !page.draft), ...site.posts.slice(0, 30)];
390
- const lines = ['# ' + config.site.title, '', config.site.agentSeo.summary || config.site.description, ''];
391
- if (config.site.agentSeo.topics.length) lines.push('Topics: ' + config.site.agentSeo.topics.join(', '), '');
392
- lines.push('## Project pages', '');
393
- 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)));
394
- lines.push('', '## Recent posts', '');
395
- 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)));
396
- routes.push(fileRoute('llms.txt', lines.join('\n') + '\n', 'text/plain; charset=utf-8'));
397
- }
398
-
399
- for (const locale of config.i18n.locales) {
400
- const prefix = locale === defaultLocale ? '' : locale + '/';
401
- const notFoundTitle = translate(config, locale, 'notFound');
402
- const notFoundText = translate(config, locale, 'notFoundText');
403
- const notFoundBody = '<section class="error-page"><p class="error-code" aria-hidden="true">404</p><h1>' + escapeHtml(notFoundTitle) +
404
- '</h1><p>' + escapeHtml(notFoundText) + '</p><p><a class="button" href="' + escapeHtml(localizedUrl(config, locale, '')) + '">' +
405
- escapeHtml(translate(config, locale, 'returnHome')) + '</a></p></section>';
406
- routes.push(await pageRoute(prefix + '404.html', notFoundTitle, notFoundText, notFoundBody, locale, config, extensions, { robots: 'noindex,follow' }));
407
- const forbiddenTitle = translate(config, locale, 'forbidden');
408
- const forbiddenText = translate(config, locale, 'forbiddenText');
409
- const forbiddenBody = '<section class="error-page"><p class="error-code" aria-hidden="true">403</p><h1>' + escapeHtml(forbiddenTitle) +
410
- '</h1><p>' + escapeHtml(forbiddenText) + '</p><p><a class="button" href="' + escapeHtml(localizedUrl(config, locale, '')) + '">' +
411
- escapeHtml(translate(config, locale, 'returnHome')) + '</a></p></section>';
412
- routes.push(await pageRoute(prefix + '403.html', forbiddenTitle, forbiddenText, forbiddenBody, locale, config, extensions, { robots: 'noindex,nofollow' }));
413
- }
414
- const discussions = config.browserPlugins.services.some(service => service.enabled !== false && service.provider === 'external-widget') ? [
415
- ...site.posts.filter(post => !post.draft).map(post => ({thread:post.bundlePath,title:post.title})),
416
- ...site.pages.filter(page => !page.draft && hasServiceBlock(page.blocks)).map(page => ({thread:'page:' + page.bundlePath,title:page.title}))
417
- ] : [];
418
- routes.push(fileRoute('edgepress/service-contexts.json', JSON.stringify([...new Map(discussions.map(item => [item.thread,item])).values()]), 'application/json; charset=utf-8'));
419
- return routes;
420
- }
1
+ import {renderPostServices,hasServiceBlock} from './service-block.js';
2
+ import {generateRssRoute} from './rss.js';
3
+ import {renderRevisionCards} from './revision-cards.js';
4
+ import { authorForPost, readingMinutes, filterPostsByCategory, categorySlug, normalizeCategory } from './post-details.js';
5
+ import { plainText, renderMarkdownExcerpt } from './markdown.js';
6
+ import { renderBlocks } from './page-blocks.js';
7
+ import { renderLayout } from './theme.js';
8
+ import { localizedContentUrl, localizedUrl, translate } from './i18n.js';
9
+ import { postsForLocale, slugify, sortPostsNewest } from './content.js';
10
+ import { mapLimit } from './concurrency.js';
11
+ import { renderIcon } from './icons.js';
12
+
13
+ function escapeHtml(value) {
14
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
15
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
16
+ })[char]);
17
+ }
18
+
19
+ function escapeXml(value) {
20
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
21
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'
22
+ })[char]);
23
+ }
24
+
25
+ function urlFor(path) {
26
+ const parts = String(path ?? '').split('/').filter(Boolean).map(encodeURIComponent);
27
+ return '/' + parts.join('/') + (String(path).endsWith('/') && parts.length ? '/' : '');
28
+ }
29
+
30
+ function dateLabel(date, language, timeZone = 'UTC', dateOnly = true) {
31
+ return new Intl.DateTimeFormat(language, dateOnly?{dateStyle:'long',timeZone}:{year:'numeric',month:'long',day:'numeric',hour:'2-digit',minute:'2-digit',timeZoneName:'short',timeZone}).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
+ function decodeHeadingEntities(value) {
46
+ return value.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (match, entity) => {
47
+ const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
48
+ if (entity[0] === '#') {
49
+ const hex = entity[1]?.toLowerCase() === 'x';
50
+ const point = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
51
+ return Number.isFinite(point) && point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : match;
52
+ }
53
+ return named[entity.toLowerCase()] ?? match;
54
+ });
55
+ }
56
+
57
+ function addPostContents(html, locale, config) {
58
+ const headings = [];
59
+ const counts = new Map();
60
+ const content = html.replace(/<h([2-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi, (whole, levelText, attributes, inner) => {
61
+ const label = decodeHeadingEntities(inner.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim());
62
+ if (!label) return whole;
63
+ const base = slugify(label);
64
+ const count = (counts.get(base) || 0) + 1;
65
+ counts.set(base, count);
66
+ const id = count === 1 ? base : base + '-' + count;
67
+ headings.push({ id, label, level: Number(levelText) });
68
+ const safeAttributes = attributes.replace(/\s+id\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
69
+ return '<h' + levelText + safeAttributes + ' id="' + escapeHtml(id) + '">' + inner + '</h' + levelText + '>';
70
+ });
71
+ if (!headings.length) return { html: content, toc: '' };
72
+ const items = headings.map((heading) => '<li class="post-toc-level-' + heading.level + '"><a href="#' +
73
+ escapeHtml(heading.id) + '">' + escapeHtml(heading.label) + '</a></li>').join('');
74
+ const contentsLabel = translate(config, locale, 'postContents');
75
+ const closeContents = translate(config, locale, 'closeContents');
76
+ const toc = '<aside class="post-toc-widget" data-post-toc hidden>' +
77
+ '<button class="post-toc-toggle" type="button" aria-expanded="false" aria-controls="post-toc-panel" aria-label="' +
78
+ escapeHtml(contentsLabel) + '">' + renderIcon('book-open', 'post-toc-icon') + '<span>' + escapeHtml(contentsLabel) + '</span></button>' +
79
+ '<nav class="post-toc-panel" id="post-toc-panel" aria-labelledby="post-toc-title" aria-label="' +
80
+ escapeHtml(contentsLabel) + '" hidden><div class="post-toc-heading"><h2 id="post-toc-title">' + escapeHtml(contentsLabel) +
81
+ '</h2><button class="post-toc-close" type="button" aria-label="' + escapeHtml(closeContents) + '"><span aria-hidden="true">×</span></button></div>' +
82
+ '<ol>' + items + '</ol></nav></aside>';
83
+ return { html: content, toc };
84
+ }
85
+
86
+ function renderPostVideo(video, locale) {
87
+ if (!video) return '';
88
+ const videoTitle = escapeHtml(video.title);
89
+ return '<figure class="post-video"><video data-edgepress-video controls playsinline preload="none" poster="' + escapeHtml(video.poster) + '" aria-label="' +
90
+ escapeHtml(video.alt) + '"><source src="' + escapeHtml(video.src) + '" type="video/mp4"><track kind="captions" src="' +
91
+ escapeHtml(video.captions) + '" srclang="' + escapeHtml(video.captionLanguage) + '" label="' + escapeHtml(video.captionLabel) + '" default>' +
92
+ '<a href="' + escapeHtml(video.src) + '">' + (locale.toLowerCase().startsWith('zh') ? '下载 MP4 视频' : 'Download the MP4 video') +
93
+ '</a></video><figcaption><strong>' + videoTitle + '</strong><br>' + escapeHtml(video.description) + '</figcaption></figure>';
94
+ }
95
+
96
+ async function pageRoute(path, title, description, body, locale, config, extensions, metadata = {}) {
97
+ const urlPath = metadata.urlPath || urlPathForRoute(path);
98
+ const structuredData = metadata.structuredData ?? {
99
+ '@context': 'https://schema.org',
100
+ '@type': 'WebPage',
101
+ name: title,
102
+ description: description || config.site.description,
103
+ url: config.site.url ? config.site.url + urlPath : undefined,
104
+ inLanguage: locale,
105
+ isPartOf: config.site.url ? { '@type': 'WebSite', name: config.site.title, url: config.site.url } : undefined
106
+ };
107
+ body = body.replace(/<pre\b/gi, '<pre data-copyable-code');
108
+ let html = await renderLayout(config, extensions, {
109
+ title,
110
+ description,
111
+ locale,
112
+ urlPath,
113
+ keywords: metadata.keywords,
114
+ image: metadata.image,
115
+ robots: metadata.robots,
116
+ structuredData
117
+ }, body);
118
+ if (config.preview) {
119
+ html = html.replace(/<\/body>/i, '<script defer src="/__edgepress_preview.js"></script></body>');
120
+ }
121
+ return fileRoute(path, html);
122
+ }
123
+
124
+ function renderPostAuthor(post, locale, config) {
125
+ if (!post.author) return '';
126
+ return '<span class="post-author">' + escapeHtml(translate(config, locale, 'postAuthor').replace('{author}', post.author)) + '</span>';
127
+ }
128
+
129
+ async function renderPostCard(post, locale, config, showLanguage = false) {
130
+ const summary = await renderMarkdownExcerpt(post.description || post.markdown, {...config.markdown,linkResolver:value=>localizedContentUrl(config,post.locale,value)});
131
+ const postLanguage = showLanguage && post.locale !== locale ? translate(config, post.locale, 'languageName') : '';
132
+ return '<article class="post-card" lang="' + escapeHtml(post.locale) + '"><h2><a href="' + escapeHtml(urlFor(post.path)) + '">' + escapeHtml(post.title) + '</a></h2>' +
133
+ '<p class="meta"><time data-local-time data-time-locale="' + escapeHtml(post.locale || locale) + '" data-date-only="' + (post.dateOnly === true) + '" datetime="' + (post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString()) + '">' + escapeHtml(dateLabel(post.date, post.locale || locale, post.dateOnly ? 'UTC' : config.site.timeZone, post.dateOnly)) + '</time>' +
134
+ (postLanguage ? ' <span class="post-language" lang="' + escapeHtml(post.locale) + '">' + escapeHtml(postLanguage) + '</span>' : '') +
135
+ (post.author ? ' ' + renderPostAuthor(post, locale, config) : '') + '</p><div class="post-excerpt" lang="' + escapeHtml(post.locale) + '">' +
136
+ summary + '</div></article>';
137
+ }
138
+
139
+ function paginationPageNumbers(currentPage, totalPages) {
140
+ const visible = new Set([1, totalPages]);
141
+ for (let page = Math.max(1, currentPage - 2); page <= Math.min(totalPages, currentPage + 2); page += 1) visible.add(page);
142
+ const pages = [...visible].sort((left, right) => left - right);
143
+ const entries = [];
144
+ for (let index = 0; index < pages.length; index += 1) {
145
+ const page = pages[index];
146
+ const previous = pages[index - 1];
147
+ if (previous !== undefined && page - previous > 1) {
148
+ if (page - previous === 2) entries.push(previous + 1);
149
+ else entries.push(null);
150
+ }
151
+ entries.push(page);
152
+ }
153
+ return entries;
154
+ }
155
+
156
+ function renderPagination(locale, currentPage, totalPages, config, pageUrl) {
157
+ const label = (key) => translate(config, locale, key);
158
+ const pageLinks = paginationPageNumbers(currentPage, totalPages).map((page) => {
159
+ if (page === null) return '<li class="pagination-ellipsis" aria-hidden="true">…</li>';
160
+ if (page === currentPage) return '<li><span class="pagination-current" aria-current="page">' + page + '</span></li>';
161
+ const pageLabel = label('pageNumber').replace('{page}', String(page));
162
+ return '<li><a href="' + escapeHtml(pageUrl(page)) + '" aria-label="' + escapeHtml(pageLabel) + '">' + page + '</a></li>';
163
+ }).join('');
164
+ const previous = currentPage > 1
165
+ ? '<a rel="prev" href="' + escapeHtml(pageUrl(currentPage - 1)) + '">' + escapeHtml(label('newer')) + '</a>' : '';
166
+ const next = currentPage < totalPages
167
+ ? '<a rel="next" href="' + escapeHtml(pageUrl(currentPage + 1)) + '">' + escapeHtml(label('older')) + '</a>' : '';
168
+ return '<nav class="pagination" aria-label="' + escapeHtml(label('pagination')) + '">' + previous +
169
+ '<ol class="pagination-pages">' + pageLinks + '</ol>' + next + '</nav>';
170
+ }
171
+
172
+ async function pageBodyForDocument(page, site, config, locale, isHomepage = false, renderContext = {}) {
173
+ const content = await renderBlocks(page.blocks, { config, locale, site, document: page, isHomepage, ...renderContext });
174
+ const firstElement = page.blocks[0]?.cells?.[0]?.[0];
175
+ const needsTitle = !isHomepage || firstElement?.type !== 'hero';
176
+ const title = needsTitle ? '<header class="page-title"><h1>' + escapeHtml(page.title) + '</h1></header>' : '';
177
+ return '<article class="page-builder">' + title + content + '</article>';
178
+ }
179
+
180
+ function paginatedLatestPostsSize(page) {
181
+ function find(value) {
182
+ if (Array.isArray(value)) {
183
+ for (const item of value) {
184
+ const result = find(item);
185
+ if (result !== null) return result;
186
+ }
187
+ return null;
188
+ }
189
+ if (!value || typeof value !== 'object') return null;
190
+ if (value.type === 'latest-posts' && value.paginate === true) return value.count ?? 3;
191
+ return find(value.cells) ?? find(value.blocks);
192
+ }
193
+ return find(page?.blocks);
194
+ }
195
+
196
+ function homepageDocument(pages, locale, defaultLocale) {
197
+ return pages.find((page) => page.homepage && page.locale === locale) ||
198
+ pages.find((page) => page.homepage && page.locale === defaultLocale) || null;
199
+ }
200
+
201
+ export async function generateBuiltinRoutes(site, config, extensions) {
202
+ const routes = [];
203
+ const allPagePaths = new Set();
204
+ const defaultLocale = config.i18n.defaultLocale;
205
+ const homepages = site.pages.filter((page) => page.homepage);
206
+ const homepageLocales = new Set();
207
+ for (const page of homepages) {
208
+ if (homepageLocales.has(page.locale)) throw new Error('Only one homepage may be configured for locale ' + page.locale);
209
+ homepageLocales.add(page.locale);
210
+ }
211
+
212
+ const allPosts = sortPostsNewest(site.posts);
213
+ for (const locale of config.i18n.locales) {
214
+ const posts = allPosts.filter((post) => post.locale === locale);
215
+ const localizedPosts = postsForLocale(allPosts, locale, defaultLocale);
216
+ const archivePosts = filterPostsByCategory(localizedPosts, config.site.archive?.categories);
217
+ const pages = site.pages.filter((page) => page.locale === locale && !page.homepage);
218
+ const homePage = homepageDocument(site.pages, locale, defaultLocale);
219
+ const latestPostsPageSize = paginatedLatestPostsSize(homePage);
220
+ if (latestPostsPageSize !== null && (!Number.isInteger(latestPostsPageSize) || latestPostsPageSize < 1 || latestPostsPageSize > 12)) {
221
+ throw new Error('A paginated latest-posts block must use a count from 1 to 12');
222
+ }
223
+ const homepageArchivePages = latestPostsPageSize ? Math.ceil(archivePosts.length / latestPostsPageSize) : 0;
224
+ const prefix = locale === defaultLocale ? '' : locale + '/';
225
+ const label = (key) => translate(config, locale, key);
226
+ const localeHomeUrl = localizedUrl(config, locale, '');
227
+ allPagePaths.add(localeHomeUrl);
228
+
229
+ if (homePage) {
230
+ const latestPostsPagination = latestPostsPageSize ? {
231
+ totalPages: homepageArchivePages,
232
+ archiveUrl: localizedUrl(config, locale, 'archives/'),
233
+ olderUrl: localizedUrl(config, locale, 'archives/page/2/')
234
+ } : null;
235
+ const body = await pageBodyForDocument(homePage, site, config, locale, true, { latestPostsPagination });
236
+ routes.push(await pageRoute(prefix + 'index.html', homePage.title, homePage.description || config.site.description,
237
+ body, locale, config, extensions, {
238
+ urlPath: localeHomeUrl,
239
+ keywords: homePage.keywords,
240
+ image: homePage.image,
241
+ robots: homePage.robots,
242
+ structuredData: {
243
+ '@context': 'https://schema.org', '@type': 'WebSite', name: config.site.title,
244
+ description: homePage.description || config.site.description,
245
+ url: config.site.url ? config.site.url + localeHomeUrl : undefined,
246
+ inLanguage: locale,
247
+ publisher: config.site.seo.author ? { '@type': 'Organization', name: config.site.seo.author } : undefined
248
+ }
249
+ }));
250
+ } else {
251
+ const totalPages = Math.max(1, Math.ceil(archivePosts.length / config.pagination.perPage));
252
+ for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
253
+ const subset = archivePosts.slice((pageNumber - 1) * config.pagination.perPage, pageNumber * config.pagination.perPage);
254
+ const cards = await Promise.all(subset.map((post) => renderPostCard(post, locale, config, true)));
255
+ const body = '<section class="intro"><h1>' + escapeHtml(config.site.title) + '</h1><p>' + escapeHtml(config.site.description) +
256
+ '</p></section><section class="post-list" aria-label="' + escapeHtml(label('latestPosts')) + '">' +
257
+ (cards.length ? '<div class="post-list archive-post-list">' + cards.join('') + '</div>' : '<p>' + escapeHtml(label('noPosts')) + '</p>') +
258
+ '</section>' + (pageNumber === 1 && pages.length ? '<section class="page-links"><h2>' + escapeHtml(label('pages')) + '</h2><ul>' +
259
+ pages.map((page) => '<li><a href="' + escapeHtml(urlFor(page.path)) + '">' + escapeHtml(page.title) + '</a></li>').join('') + '</ul></section>' : '') +
260
+ (totalPages > 1 ? renderPagination(locale, pageNumber, totalPages, config, (page) =>
261
+ localizedUrl(config, locale, page === 1 ? '' : 'page/' + page + '/')) : '');
262
+ const routePath = pageNumber === 1 ? prefix + 'index.html' : prefix + 'page/' + pageNumber + '/index.html';
263
+ routes.push(await pageRoute(routePath, pageNumber === 1 ? config.site.title : 'Page ' + pageNumber,
264
+ config.site.description, body, locale, config, extensions));
265
+ }
266
+ }
267
+
268
+ for (const post of posts) allPagePaths.add(urlFor(post.path));
269
+ const postRoutes = await mapLimit(posts, config.concurrency, async (post) => {
270
+ const renderedMarkdown = addPostContents(post.html, locale, config);
271
+ const author = authorForPost(post, config);
272
+ const minutes = post.readingMinutes || readingMinutes(post.markdown);
273
+ const authorHtml = author.name ? '<span class="post-author">' +
274
+ (author.avatar ? '<img class="author-avatar" src="' + escapeHtml(author.avatar) + '" width="40" height="40" alt="" decoding="async">' : '') +
275
+ escapeHtml(label('postAuthor').replace('{author}', author.name)) + '</span>' : '';
276
+ const tagsHtml = post.tags.length ? '<div class="post-tags" role="group" aria-label="' + escapeHtml(label('tags')) + '"><span class="post-tags-label">' +
277
+ escapeHtml(label('tags')) + ':</span>' + post.tags.map(tag => '<span class="post-tag">' + escapeHtml(tag) + '</span>').join('') + '</div>' : '';
278
+ const body = '<article class="post"><header><h1>' + escapeHtml(post.title) + '</h1><div class="meta post-byline">' + authorHtml +
279
+ '<time data-local-time data-time-locale="' + escapeHtml(post.locale || locale) + '" data-date-only="' + (post.dateOnly === true) + '" datetime="' + (post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString()) + '">' + escapeHtml(dateLabel(post.date, post.locale || locale, post.dateOnly ? 'UTC' : config.site.timeZone, post.dateOnly)) + '</time>' +
280
+ '<span class="post-reading-time" data-reading-minutes="' + minutes + '">' + escapeHtml(label('postReadingTime').replace('{minutes}', minutes)) + '</span>' +
281
+ (post.updated ? '<span class="post-updated">' + escapeHtml(label('postUpdated')) + ' <time data-local-time data-time-locale="' + escapeHtml(locale) + '" datetime="' + post.updated.toISOString() + '">' + escapeHtml(dateLabel(post.updated, locale, config.site.timeZone, false)) + '</time></span>' : '') +
282
+ '</div>' + tagsHtml + '</header>' + (post.showChanges && post.changes ? await renderRevisionCards(post.changes,label,post.revisionImages,{linkResolver:value=>localizedContentUrl(config,locale,value)}) : '') + renderPostVideo(post.video, locale) + renderedMarkdown.toc + '<div class="post-content">' + renderedMarkdown.html + '</div></article>' + renderPostServices(post, locale, config);
283
+ const canonicalPath = urlFor(post.path);
284
+ return pageRoute(post.path + 'index.html', post.title, post.description || plainText(post.markdown).slice(0, 160), body,
285
+ locale, config, extensions, {
286
+ urlPath: canonicalPath, keywords: post.tags, image: post.image, robots: post.robots,
287
+ structuredData: {
288
+ '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title,
289
+ description: post.description || plainText(post.markdown).slice(0, 160),
290
+ datePublished: post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString(), dateModified: post.updated ? post.updated.toISOString() : post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString(),
291
+ author: { '@type': 'Person', name: author.name || config.site.title },
292
+ mainEntityOfPage: config.site.url ? config.site.url + canonicalPath : undefined,
293
+ inLanguage: locale
294
+ }
295
+ });
296
+ });
297
+ routes.push(...postRoutes);
298
+
299
+ for (const page of pages) allPagePaths.add(urlFor(page.path));
300
+ const pageRoutes = await mapLimit(pages, config.concurrency, async (page) => {
301
+ const body = await pageBodyForDocument(page, site, config, locale, false);
302
+ return pageRoute(page.path ? page.path + 'index.html' : prefix + 'index.html', page.title,
303
+ page.description || config.site.description, body, locale, config, extensions, {
304
+ urlPath: urlFor(page.path), keywords: page.keywords, image: page.image, robots: page.robots
305
+ });
306
+ });
307
+ routes.push(...pageRoutes);
308
+
309
+ if (!homePage) allPagePaths.add(localizedUrl(config, locale, ''));
310
+ const archivePageSize = latestPostsPageSize || config.pagination.perPage;
311
+ const archivePageCount = Math.max(1, Math.ceil(archivePosts.length / archivePageSize));
312
+ for (let pageNumber = 1; pageNumber <= archivePageCount; pageNumber += 1) {
313
+ const subset = archivePosts.slice((pageNumber - 1) * archivePageSize, pageNumber * archivePageSize);
314
+ const cards = await Promise.all(subset.map((post) => renderPostCard(post, locale, config, true)));
315
+ const archiveUrl = pageNumber === 1 ? 'archives/' : 'archives/page/' + pageNumber + '/';
316
+ const pagination = archivePageCount > 1
317
+ ? renderPagination(locale, pageNumber, archivePageCount, config, (page) => localizedUrl(config, locale,
318
+ page === 1 ? 'archives/' : 'archives/page/' + page + '/')) : '';
319
+ const archiveBody = '<section><h1>' + escapeHtml(label('archives')) + '</h1>' +
320
+ (cards.length ? '<div class="post-list archive-post-list">' + cards.join('') + '</div>' : '<p>' + escapeHtml(label('noPosts')) + '</p>') + pagination + '</section>';
321
+ routes.push(await pageRoute(prefix + (pageNumber === 1 ? 'archives/index.html' : 'archives/page/' + pageNumber + '/index.html'),
322
+ pageNumber === 1 ? label('archives') : label('archives') + ' ' + pageNumber, label('allPosts'), archiveBody, locale, config, extensions,
323
+ { urlPath: localizedUrl(config, locale, archiveUrl) }));
324
+ allPagePaths.add(localizedUrl(config, locale, archiveUrl));
325
+ }
326
+
327
+ const categories = new Map();
328
+ for (const post of localizedPosts) {
329
+ const category = normalizeCategory(post.category);
330
+ if (!categories.has(category)) categories.set(category, []);
331
+ categories.get(category).push(post);
332
+ }
333
+ for (const [category, categoryPosts] of categories) {
334
+ const slug = categorySlug(category);
335
+ const title = category === 'blog' ? label('categoryBlog') : category === 'uncategorized' ? label('categoryUncategorized') : category;
336
+ const totalPages = Math.ceil(categoryPosts.length / config.pagination.perPage);
337
+ const categoryPath = number => 'categories/' + slug + '/' + (number === 1 ? '' : 'page/' + number + '/');
338
+ for (let number = 1; number <= totalPages; number += 1) {
339
+ const cards = await Promise.all(categoryPosts.slice((number - 1) * config.pagination.perPage, number * config.pagination.perPage)
340
+ .map(post => renderPostCard(post, locale, config, true)));
341
+ const pagination = totalPages > 1 ? renderPagination(locale, number, totalPages, config,
342
+ page => localizedUrl(config, locale, categoryPath(page))) : '';
343
+ const body = '<section><h1>' + escapeHtml(title) + '</h1><div class="post-list archive-post-list">' + cards.join('') + '</div>' + pagination + '</section>';
344
+ const urlPath = localizedUrl(config, locale, categoryPath(number));
345
+ allPagePaths.add(urlPath);
346
+ routes.push(await pageRoute(prefix + categoryPath(number) + 'index.html', title, title, body, locale, config, extensions, { urlPath }));
347
+ }
348
+ }
349
+
350
+ const searchable = [
351
+ ...posts.map((post) => ({ title: post.title, author: post.author, date: post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString(), dateOnly: post.dateOnly, url: urlFor(post.path), summary: plainText(post.description || post.markdown).slice(0, 220), content: plainText(post.markdown), type: 'post' })),
352
+ ...pages.map((page) => ({ title: page.title, url: urlFor(page.path), summary: page.description || plainText(page.markdown).slice(0, 220), type: 'page' }))
353
+ ];
354
+ routes.push(fileRoute(prefix + 'search.json', JSON.stringify(searchable), 'application/json; charset=utf-8'));
355
+
356
+ const searchBody = '<section class="local-search-page"><h1>' + escapeHtml(label('searchPosts')) + '</h1>' +
357
+ '<p>' + escapeHtml(label('searchIntro')) + '</p><form class="local-search-form" data-edgepress-search data-index="' +
358
+ escapeHtml(localizedUrl(config, locale, 'search.json')) + '"><label for="edgepress-search-query">' + escapeHtml(label('searchLabel')) +
359
+ '</label><div class="local-search-controls"><input id="edgepress-search-query" name="q" type="search" autocomplete="off" ' +
360
+ '><button type="submit">' + escapeHtml(label('searchButton')) + '</button></div>' +
361
+ '</form><p id="edgepress-search-status" role="status" aria-live="polite">' +
362
+ '' + '</p><ol id="edgepress-search-results" class="local-search-results"></ol>' +
363
+ '<noscript><p>' + escapeHtml(label('searchNeedsJavaScript')) + '</p></noscript></section>';
364
+ routes.push(await pageRoute(prefix + 'search/index.html', label('searchPosts'), label('searchIntro'), searchBody,
365
+ locale, config, extensions));
366
+
367
+ routes.push(await generateRssRoute(posts, config, locale));
368
+ }
369
+
370
+ const base = config.site.url;
371
+ const sitemap = '<?xml version="1.0" encoding="utf-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
372
+ [...allPagePaths].map((path) => '<url><loc>' + escapeXml(base + path) + '</loc></url>').join('') + '</urlset>';
373
+ routes.push(fileRoute('sitemap.xml', sitemap, 'application/xml; charset=utf-8'));
374
+ const agentNames = config.site.agentSeo.enabled ? config.site.agentSeo.agentCrawlers : [];
375
+ const robots = ['User-agent: *', 'Allow: /', ...agentNames.flatMap((name) => ['', 'User-agent: ' + name, 'Allow: /']), '',
376
+ 'Sitemap: ' + (base ? base + '/sitemap.xml' : '/sitemap.xml'), ''].join('\n');
377
+ routes.push(fileRoute('robots.txt', robots, 'text/plain; charset=utf-8'));
378
+ if (config.preview) {
379
+ routes.push(fileRoute('__edgepress_preview.json', JSON.stringify({ buildId: config.previewBuildId }), 'application/json; charset=utf-8'));
380
+ 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'));
381
+ }
382
+ if (config.site.agentSeo.enabled && config.site.agentSeo.llmsTxt) {
383
+ const sections = [...site.pages.filter((page) => !page.draft), ...site.posts.slice(0, 30)];
384
+ const lines = ['# ' + config.site.title, '', config.site.agentSeo.summary || config.site.description, ''];
385
+ if (config.site.agentSeo.topics.length) lines.push('Topics: ' + config.site.agentSeo.topics.join(', '), '');
386
+ lines.push('## Project pages', '');
387
+ 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)));
388
+ lines.push('', '## Recent posts', '');
389
+ 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)));
390
+ routes.push(fileRoute('llms.txt', lines.join('\n') + '\n', 'text/plain; charset=utf-8'));
391
+ }
392
+
393
+ for (const locale of config.i18n.locales) {
394
+ const prefix = locale === defaultLocale ? '' : locale + '/';
395
+ const notFoundTitle = translate(config, locale, 'notFound');
396
+ const notFoundText = translate(config, locale, 'notFoundText');
397
+ const notFoundBody = '<section class="error-page"><p class="error-code" aria-hidden="true">404</p><h1>' + escapeHtml(notFoundTitle) +
398
+ '</h1><p>' + escapeHtml(notFoundText) + '</p><p><a class="button" href="' + escapeHtml(localizedUrl(config, locale, '')) + '">' +
399
+ escapeHtml(translate(config, locale, 'returnHome')) + '</a></p></section>';
400
+ routes.push(await pageRoute(prefix + '404.html', notFoundTitle, notFoundText, notFoundBody, locale, config, extensions, { robots: 'noindex,follow' }));
401
+ const forbiddenTitle = translate(config, locale, 'forbidden');
402
+ const forbiddenText = translate(config, locale, 'forbiddenText');
403
+ const forbiddenBody = '<section class="error-page"><p class="error-code" aria-hidden="true">403</p><h1>' + escapeHtml(forbiddenTitle) +
404
+ '</h1><p>' + escapeHtml(forbiddenText) + '</p><p><a class="button" href="' + escapeHtml(localizedUrl(config, locale, '')) + '">' +
405
+ escapeHtml(translate(config, locale, 'returnHome')) + '</a></p></section>';
406
+ routes.push(await pageRoute(prefix + '403.html', forbiddenTitle, forbiddenText, forbiddenBody, locale, config, extensions, { robots: 'noindex,nofollow' }));
407
+ }
408
+ const discussions = config.browserPlugins.services.some(service => service.enabled !== false && service.provider === 'external-widget') ? [
409
+ ...site.posts.filter(post => !post.draft).map(post => ({thread:post.bundlePath,title:post.title})),
410
+ ...site.pages.filter(page => !page.draft && hasServiceBlock(page.blocks)).map(page => ({thread:'page:' + page.bundlePath,title:page.title}))
411
+ ] : [];
412
+ routes.push(fileRoute('edgepress/service-contexts.json', JSON.stringify([...new Map(discussions.map(item => [item.thread,item])).values()]), 'application/json; charset=utf-8'));
413
+ return routes;
414
+ }