edgepress 2026.9.4 → 2026.9.6

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 (52) hide show
  1. package/CHANGELOG.txt +12 -0
  2. package/README.md +6 -4
  3. package/config.yml +11 -6
  4. package/content/pages/home/en.md +7 -0
  5. package/content/pages/home/zh-cn.md +8 -1
  6. package/content/pages/privacy-policy/en.md +1 -1
  7. package/content/pages/privacy-policy/zh-cn.md +1 -1
  8. package/content/pages/quick-start/en.md +3 -0
  9. package/content/pages/quick-start/zh-cn.md +3 -0
  10. package/content/pages/theme-development/en.md +23 -0
  11. package/content/pages/theme-development/zh-cn.md +23 -0
  12. package/content/posts/2026-09-23-edgepress-on-the-edge/en.md +3 -1
  13. package/content/posts/2026-09-23-edgepress-on-the-edge/zh-cn.md +3 -1
  14. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +2 -0
  15. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +2 -0
  16. package/content/posts/2026-09-25-edgepress-release-2026-9-2/en.md +2 -0
  17. package/content/posts/2026-09-25-edgepress-release-2026-9-2/zh-cn.md +2 -0
  18. package/content/posts/2026-09-25-edgepress-release-2026-9-3/en.md +2 -0
  19. package/content/posts/2026-09-25-edgepress-release-2026-9-3/zh-cn.md +2 -0
  20. package/content/posts/2026-09-25-edgepress-release-2026-9-4/en.md +2 -0
  21. package/content/posts/2026-09-25-edgepress-release-2026-9-4/zh-cn.md +2 -0
  22. package/content/posts/2026-09-25-edgepress-release-2026-9-5/en.md +19 -0
  23. package/content/posts/2026-09-25-edgepress-release-2026-9-5/zh-cn.md +19 -0
  24. package/content/posts/2026-09-25-edgepress-release-2026-9-6/en.md +31 -0
  25. package/content/posts/2026-09-25-edgepress-release-2026-9-6/zh-cn.md +31 -0
  26. package/languages/base/en.json +2 -0
  27. package/languages/packs/zh-CN.json +2 -0
  28. package/package.json +4 -2
  29. package/project-compatibility.json +17 -2
  30. package/src/cli.js +7 -2
  31. package/src/content.js +33 -0
  32. package/src/generators.js +111 -14
  33. package/src/icons.js +16 -0
  34. package/src/maintenance.js +0 -27
  35. package/src/page-blocks.js +29 -8
  36. package/src/page-check.js +6 -1
  37. package/src/theme.js +13 -2
  38. package/static/edgepress/brand/edgepress-mark.svg +7 -0
  39. package/static/edgepress/brand/mascot.png +0 -0
  40. package/static/edgepress/brand/og-image.png +0 -0
  41. package/static/edgepress/brand/og-image.svg +46 -0
  42. package/static/edgepress/code-copy.js +55 -0
  43. package/static/edgepress/icons.svg +24 -0
  44. package/themes/atelier/assets/style.css +48 -1
  45. package/themes/atelier/layouts/partials/head.html +5 -0
  46. package/themes/atelier/layouts/partials/header.html +1 -1
  47. package/themes/default/assets/style.css +49 -1
  48. package/themes/default/layouts/partials/head.html +3 -0
  49. package/themes/default/layouts/partials/header.html +1 -1
  50. package/themes/signal/assets/style.css +48 -1
  51. package/themes/signal/layouts/partials/head.html +5 -0
  52. package/themes/signal/layouts/partials/header.html +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "frameworkVersion": "2026.9.4",
3
+ "frameworkVersion": "2026.9.6",
4
4
  "requirements": {
5
5
  "nodeMajor": 22,
6
6
  "nodeMinimumVersion": "22.12.0",
@@ -24,7 +24,10 @@
24
24
  { "name": "Markdown syntax verification and responsive post screenshots", "stage": "experimental", "since": "2026.9.2" },
25
25
  { "name": "Compact localized language selector", "stage": "experimental", "since": "2026.9.2" },
26
26
  { "name": "npm theme installation and blank theme scaffolds", "stage": "experimental", "since": "2026.9.3" },
27
- { "name": "PDF-only audit output and temporary screenshot capture", "stage": "experimental", "since": "2026.9.4" }
27
+ { "name": "PDF-only audit output and temporary screenshot capture", "stage": "experimental", "since": "2026.9.4" },
28
+ { "name": "Cloudflare one-click deployment guidance for existing and new projects", "stage": "stable", "since": "2026.9.5" },
29
+ { "name": "Localized Posts navigation and responsive code copy controls", "stage": "experimental", "since": "2026.9.6" },
30
+ { "name": "Linked tables of contents on Markdown post pages", "stage": "experimental", "since": "2026.9.6" }
28
31
  ],
29
32
  "releaseHistory": [
30
33
  {
@@ -50,6 +53,18 @@
50
53
  "date": "2026-09-25",
51
54
  "request": 4,
52
55
  "notes": "Keep audit output as a single accessible PDF with embedded screenshots, remove the templates directory and package archive, document Workers Builds initialization troubleshooting, and pin the Wrangler compatibility date to the first of the month."
56
+ },
57
+ {
58
+ "version": "2026.9.5",
59
+ "date": "2026-09-25",
60
+ "request": 5,
61
+ "notes": "Clarify that the Cloudflare one-click deployment button creates a new repository copy and Worker, explain unique naming and the path for updating existing Workers, and publish the guidance in README and bilingual quick-start pages."
62
+ },
63
+ {
64
+ "version": "2026.9.6",
65
+ "date": "2026-09-25",
66
+ "request": 6,
67
+ "notes": "Add a localized Posts link, accessible code-copy controls, responsive command wrapping, and linked post contents; refresh the bundled theme palettes and set the canonical site URL and author metadata."
53
68
  }
54
69
  ]
55
70
  }
package/src/cli.js CHANGED
@@ -322,6 +322,11 @@ async function showReport(title, report, reportPath) {
322
322
  console.log('Report: ' + reportPath);
323
323
  }
324
324
 
325
+ function showStatus(title, report) {
326
+ console.log(title + ': ' + report.status);
327
+ for (const issue of report.issues) console.log('- ' + issue.severity + ': ' + issue.message);
328
+ }
329
+
325
330
  const [command, ...args] = process.argv.slice(2);
326
331
  try {
327
332
  const config = command === 'new' || command === 'clean' || command === 'check' || command === 'doctor' || command === 'iterate'
@@ -350,11 +355,11 @@ try {
350
355
  const pageReport = await checkPages(config);
351
356
  const compatibilityReport = await checkCompatibility(config);
352
357
  await showReport('Page check', pageReport, resolve(config.root, 'tools/page-check.pdf'));
353
- await showReport('Compatibility check', compatibilityReport, resolve(config.resolvedPaths.cache, 'reports/compatibility.md'));
358
+ showStatus('Compatibility check', compatibilityReport);
354
359
  if (pageReport.errors || compatibilityReport.issues.some((issue) => issue.severity === 'error')) process.exitCode = 1;
355
360
  } else if (command === 'doctor') {
356
361
  const report = await checkCompatibility(config);
357
- await showReport('Compatibility check', report, resolve(config.resolvedPaths.cache, 'reports/compatibility.md'));
362
+ showStatus('Compatibility check', report);
358
363
  if (report.issues.some((issue) => issue.severity === 'error')) process.exitCode = 1;
359
364
  } else if (command === 'iterate') {
360
365
  await buildSite(config.root);
package/src/content.js CHANGED
@@ -47,6 +47,36 @@ function normalizeTags(value) {
47
47
  return (Array.isArray(value) ? value : [value]).map(String).map((tag) => tag.trim()).filter(Boolean);
48
48
  }
49
49
 
50
+ function normalizeAuthor(value, file) {
51
+ if (value === undefined || value === null || value === '') return '';
52
+ if (typeof value !== 'string' || !value.trim() || value.length > 160) {
53
+ throw new Error('author must be a non-empty string of at most 160 characters in ' + file);
54
+ }
55
+ return value.trim();
56
+ }
57
+
58
+ function normalizeVideo(value, file) {
59
+ if (value === undefined) return null;
60
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('video must be an object in ' + file);
61
+ for (const key of Object.keys(value)) {
62
+ if (!['src', 'captions', 'captionLanguage', 'captionLabel', 'poster', 'alt', 'title', 'description'].includes(key)) {
63
+ throw new Error('Unsupported video field in ' + file + ': ' + key);
64
+ }
65
+ }
66
+ const localAsset = /^\/edgepress\/media\/[a-z0-9-]+\.(?:mp4|vtt|jpe?g)$/i;
67
+ for (const field of ['src', 'captions', 'poster']) {
68
+ if (typeof value[field] !== 'string' || !localAsset.test(value[field])) {
69
+ throw new Error('video.' + field + ' must reference a local /edgepress/media asset in ' + file);
70
+ }
71
+ }
72
+ if (typeof value.captionLanguage !== 'string' || !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(value.captionLanguage) ||
73
+ typeof value.captionLabel !== 'string' || !value.captionLabel.trim() || typeof value.alt !== 'string' || !value.alt.trim() ||
74
+ typeof value.title !== 'string' || !value.title.trim() || typeof value.description !== 'string' || !value.description.trim()) {
75
+ throw new Error('video needs a language, labels, accessible description, title, and summary in ' + file);
76
+ }
77
+ return value;
78
+ }
79
+
50
80
  function normalizeDate(value, filename, kind, file) {
51
81
  if (value instanceof Date) {
52
82
  if (!Number.isNaN(value.valueOf())) return value;
@@ -145,6 +175,9 @@ export async function readDocuments(config) {
145
175
  if (locale !== config.i18n.defaultLocale && metadata.homepage !== true) path = locale + '/' + path;
146
176
  return {
147
177
  kind, file, relativePath, title, slug, date, locale,
178
+ author: kind === 'posts' ? normalizeAuthor(metadata.author, file) : '',
179
+ videoId: (kind === 'posts' ? 'post-' : 'page-') + slugify(bundlePath + '-' + locale.toLowerCase()),
180
+ video: kind === 'posts' ? normalizeVideo(metadata.video, file) : null,
148
181
  tags: normalizeTags(metadata.tags),
149
182
  description: String(metadata.description ?? ''),
150
183
  keywords: normalizeTags(metadata.keywords),
package/src/generators.js CHANGED
@@ -42,6 +42,51 @@ function urlPathForRoute(path) {
42
42
  return '/' + value;
43
43
  }
44
44
 
45
+ function decodeHeadingEntities(value) {
46
+ return value.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (match, entity) => {
47
+ const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
48
+ if (entity[0] === '#') {
49
+ const hex = entity[1]?.toLowerCase() === 'x';
50
+ const point = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
51
+ return Number.isFinite(point) && point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : match;
52
+ }
53
+ return named[entity.toLowerCase()] ?? match;
54
+ });
55
+ }
56
+
57
+ function addPostContents(html, locale, config) {
58
+ const headings = [];
59
+ const counts = new Map();
60
+ const content = html.replace(/<h([2-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi, (whole, levelText, attributes, inner) => {
61
+ const label = decodeHeadingEntities(inner.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim());
62
+ if (!label) return whole;
63
+ const base = slugify(label);
64
+ const count = (counts.get(base) || 0) + 1;
65
+ counts.set(base, count);
66
+ const id = count === 1 ? base : base + '-' + count;
67
+ headings.push({ id, label, level: Number(levelText) });
68
+ const safeAttributes = attributes.replace(/\s+id\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
69
+ return '<h' + levelText + safeAttributes + ' id="' + escapeHtml(id) + '">' + inner + '</h' + levelText + '>';
70
+ });
71
+ if (!headings.length) return { html: content, toc: '' };
72
+ const items = headings.map((heading) => '<li class="post-toc-level-' + heading.level + '"><a href="#' +
73
+ escapeHtml(heading.id) + '">' + escapeHtml(heading.label) + '</a></li>').join('');
74
+ const contentsLabel = translate(config, locale, 'postContents');
75
+ const toc = '<nav class="post-toc" aria-label="' + escapeHtml(contentsLabel) + '"><h2 id="post-toc-title">' +
76
+ escapeHtml(contentsLabel) + '</h2><ol>' + items + '</ol></nav>';
77
+ return { html: content, toc };
78
+ }
79
+
80
+ function renderPostVideo(video, locale) {
81
+ if (!video) return '';
82
+ const videoTitle = escapeHtml(video.title);
83
+ return '<figure class="post-video"><video controls playsinline preload="metadata" poster="' + escapeHtml(video.poster) + '" aria-label="' +
84
+ escapeHtml(video.alt) + '"><source src="' + escapeHtml(video.src) + '" type="video/mp4"><track kind="captions" src="' +
85
+ escapeHtml(video.captions) + '" srclang="' + escapeHtml(video.captionLanguage) + '" label="' + escapeHtml(video.captionLabel) + '" default>' +
86
+ '<a href="' + escapeHtml(video.src) + '">' + (locale.toLowerCase().startsWith('zh') ? '下载 MP4 视频' : 'Download the MP4 video') +
87
+ '</a></video><figcaption><strong>' + videoTitle + '</strong><br>' + escapeHtml(video.description) + '</figcaption></figure>';
88
+ }
89
+
45
90
  async function pageRoute(path, title, description, body, locale, config, extensions, metadata = {}) {
46
91
  const urlPath = metadata.urlPath || urlPathForRoute(path);
47
92
  const structuredData = metadata.structuredData ?? {
@@ -53,6 +98,7 @@ async function pageRoute(path, title, description, body, locale, config, extensi
53
98
  inLanguage: locale,
54
99
  isPartOf: config.site.url ? { '@type': 'WebSite', name: config.site.title, url: config.site.url } : undefined
55
100
  };
101
+ body = body.replace(/<pre\b/gi, '<pre data-copyable-code');
56
102
  let html = await renderLayout(config, extensions, {
57
103
  title,
58
104
  description,
@@ -69,21 +115,43 @@ async function pageRoute(path, title, description, body, locale, config, extensi
69
115
  return fileRoute(path, html);
70
116
  }
71
117
 
72
- function renderPostCard(post, locale) {
118
+ function renderPostAuthor(post, locale, config) {
119
+ if (!post.author) return '';
120
+ return '<span class="post-author">' + escapeHtml(translate(config, locale, 'postAuthor').replace('{author}', post.author)) + '</span>';
121
+ }
122
+
123
+ function renderPostCard(post, locale, config) {
73
124
  const summary = post.description || plainText(post.markdown).slice(0, 220);
74
125
  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>' +
126
+ '<p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, locale)) + '</time>' +
127
+ (post.author ? ' · ' + renderPostAuthor(post, locale, config) : '') + '</p><p>' +
76
128
  escapeHtml(summary) + '</p></article>';
77
129
  }
78
130
 
79
- async function pageBodyForDocument(page, site, config, locale, isHomepage = false) {
80
- const content = await renderBlocks(page.blocks, { config, locale, site, isHomepage });
131
+ async function pageBodyForDocument(page, site, config, locale, isHomepage = false, renderContext = {}) {
132
+ const content = await renderBlocks(page.blocks, { config, locale, site, isHomepage, ...renderContext });
81
133
  const firstElement = page.blocks[0]?.cells?.[0]?.[0];
82
134
  const needsTitle = !isHomepage || firstElement?.type !== 'hero';
83
135
  const title = needsTitle ? '<header class="page-title"><h1>' + escapeHtml(page.title) + '</h1></header>' : '';
84
136
  return '<article class="page-builder">' + title + content + '</article>';
85
137
  }
86
138
 
139
+ function paginatedLatestPostsSize(page) {
140
+ function find(value) {
141
+ if (Array.isArray(value)) {
142
+ for (const item of value) {
143
+ const result = find(item);
144
+ if (result !== null) return result;
145
+ }
146
+ return null;
147
+ }
148
+ if (!value || typeof value !== 'object') return null;
149
+ if (value.type === 'latest-posts' && value.paginate === true) return value.count ?? 3;
150
+ return find(value.cells) ?? find(value.blocks);
151
+ }
152
+ return find(page?.blocks);
153
+ }
154
+
87
155
  function homepageDocument(pages, locale, defaultLocale) {
88
156
  return pages.find((page) => page.homepage && page.locale === locale) ||
89
157
  pages.find((page) => page.homepage && page.locale === defaultLocale) || null;
@@ -104,13 +172,24 @@ export async function generateBuiltinRoutes(site, config, extensions) {
104
172
  const posts = site.posts.filter((post) => post.locale === locale).sort((a, b) => b.date - a.date);
105
173
  const pages = site.pages.filter((page) => page.locale === locale && !page.homepage);
106
174
  const homePage = homepageDocument(site.pages, locale, defaultLocale);
175
+ const latestPostsPageSize = paginatedLatestPostsSize(homePage);
176
+ if (latestPostsPageSize !== null && (!Number.isInteger(latestPostsPageSize) || latestPostsPageSize < 1 || latestPostsPageSize > 12)) {
177
+ throw new Error('A paginated latest-posts block must use a count from 1 to 12');
178
+ }
179
+ const homepagePosts = posts.length ? posts : site.posts.filter((post) => post.locale === defaultLocale).sort((a, b) => b.date - a.date);
180
+ const homepageArchivePages = latestPostsPageSize ? Math.ceil(homepagePosts.length / latestPostsPageSize) : 0;
107
181
  const prefix = locale === defaultLocale ? '' : locale + '/';
108
182
  const label = (key) => translate(config, locale, key);
109
183
  const localeHomeUrl = localizedUrl(config, locale, '');
110
184
  allPagePaths.add(localeHomeUrl);
111
185
 
112
186
  if (homePage) {
113
- const body = await pageBodyForDocument(homePage, site, config, locale, true);
187
+ const latestPostsPagination = latestPostsPageSize ? {
188
+ totalPages: homepageArchivePages,
189
+ archiveUrl: localizedUrl(config, locale, 'archives/'),
190
+ olderUrl: localizedUrl(config, locale, 'archives/page/2/')
191
+ } : null;
192
+ const body = await pageBodyForDocument(homePage, site, config, locale, true, { latestPostsPagination });
114
193
  routes.push(await pageRoute(prefix + 'index.html', homePage.title, homePage.description || config.site.description,
115
194
  body, locale, config, extensions, {
116
195
  urlPath: localeHomeUrl,
@@ -131,7 +210,7 @@ export async function generateBuiltinRoutes(site, config, extensions) {
131
210
  const subset = posts.slice((pageNumber - 1) * config.pagination.perPage, pageNumber * config.pagination.perPage);
132
211
  const body = '<section class="intro"><h1>' + escapeHtml(config.site.title) + '</h1><p>' + escapeHtml(config.site.description) +
133
212
  '</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>') +
213
+ (subset.length ? subset.map((post) => renderPostCard(post, locale, config)).join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') +
135
214
  '</section>' + (pageNumber === 1 && pages.length ? '<section class="page-links"><h2>' + escapeHtml(label('pages')) + '</h2><ul>' +
136
215
  pages.map((page) => '<li><a href="' + escapeHtml(urlFor(page.path)) + '">' + escapeHtml(page.title) + '</a></li>').join('') + '</ul></section>' : '') +
137
216
  (totalPages > 1 ? '<nav class="pagination" aria-label="' + escapeHtml(label('pagination')) + '">' +
@@ -145,8 +224,10 @@ export async function generateBuiltinRoutes(site, config, extensions) {
145
224
 
146
225
  for (const post of posts) allPagePaths.add(urlFor(post.path));
147
226
  const postRoutes = await mapLimit(posts, config.concurrency, async (post) => {
227
+ const renderedMarkdown = addPostContents(post.html, locale, config);
148
228
  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>' +
229
+ escapeHtml(dateLabel(post.date, locale)) + '</time>' + (post.author ? ' · ' + renderPostAuthor(post, locale, config) : '') +
230
+ '</p></header>' + renderPostVideo(post.video, locale) + renderedMarkdown.toc + '<div class="post-content">' + renderedMarkdown.html + '</div>' +
150
231
  (post.tags.length ? '<p class="tags">' + escapeHtml(label('tags')) + ': ' + post.tags.map((tag) => '<a href="' +
151
232
  escapeHtml(localizedUrl(config, locale, 'tags/' + tagSlug(tag) + '/')) + '">' + escapeHtml(tag) + '</a>').join(' ') + '</p>' : '') + '</article>';
152
233
  const canonicalPath = urlFor(post.path);
@@ -157,7 +238,7 @@ export async function generateBuiltinRoutes(site, config, extensions) {
157
238
  '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title,
158
239
  description: post.description || plainText(post.markdown).slice(0, 160),
159
240
  datePublished: post.date.toISOString(), dateModified: post.date.toISOString(),
160
- author: { '@type': 'Person', name: config.site.seo.author || config.site.title },
241
+ author: { '@type': 'Person', name: post.author || config.site.seo.author || config.site.title },
161
242
  mainEntityOfPage: config.site.url ? config.site.url + canonicalPath : undefined,
162
243
  inLanguage: locale
163
244
  }
@@ -176,9 +257,24 @@ export async function generateBuiltinRoutes(site, config, extensions) {
176
257
  routes.push(...pageRoutes);
177
258
 
178
259
  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));
260
+ const archivePageSize = latestPostsPageSize || config.pagination.perPage;
261
+ const archivePageCount = Math.max(1, Math.ceil(posts.length / archivePageSize));
262
+ for (let pageNumber = 1; pageNumber <= archivePageCount; pageNumber += 1) {
263
+ const subset = posts.slice((pageNumber - 1) * archivePageSize, pageNumber * archivePageSize);
264
+ const archiveUrl = pageNumber === 1 ? 'archives/' : 'archives/page/' + pageNumber + '/';
265
+ const pagination = archivePageCount > 1
266
+ ? '<nav class="pagination" aria-label="' + escapeHtml(label('pagination')) + '">' +
267
+ (pageNumber > 1 ? '<a rel="prev" href="' + escapeHtml(pageNumber === 2 ? localizedUrl(config, locale, 'archives/') :
268
+ localizedUrl(config, locale, 'archives/page/' + (pageNumber - 1) + '/')) + '">' + escapeHtml(label('newer')) + '</a>' : '') +
269
+ (pageNumber < archivePageCount ? '<a rel="next" href="' + escapeHtml(localizedUrl(config, locale, 'archives/page/' + (pageNumber + 1) + '/')) + '">' +
270
+ escapeHtml(label('older')) + '</a>' : '') + '</nav>' : '';
271
+ const archiveBody = '<section><h1>' + escapeHtml(label('archives')) + '</h1>' +
272
+ (subset.length ? subset.map((post) => renderPostCard(post, locale, config)).join('') : '<p>' + escapeHtml(label('noPosts')) + '</p>') + pagination + '</section>';
273
+ routes.push(await pageRoute(prefix + (pageNumber === 1 ? 'archives/index.html' : 'archives/page/' + pageNumber + '/index.html'),
274
+ pageNumber === 1 ? label('archives') : label('archives') + ' · ' + pageNumber, label('allPosts'), archiveBody, locale, config, extensions,
275
+ { urlPath: localizedUrl(config, locale, archiveUrl) }));
276
+ allPagePaths.add(localizedUrl(config, locale, archiveUrl));
277
+ }
182
278
 
183
279
  const tags = new Map();
184
280
  for (const post of posts) for (const tag of post.tags) {
@@ -188,14 +284,14 @@ export async function generateBuiltinRoutes(site, config, extensions) {
188
284
  }
189
285
  const tagRoutes = await mapLimit([...tags.entries()], config.concurrency, async ([slug, value]) => {
190
286
  const body = '<section><h1>' + escapeHtml(label('tag')) + ': ' + escapeHtml(value.label) + '</h1>' +
191
- value.posts.map((post) => renderPostCard(post, locale)).join('') + '</section>';
287
+ value.posts.map((post) => renderPostCard(post, locale, config)).join('') + '</section>';
192
288
  return pageRoute(prefix + 'tags/' + slug + '/index.html', label('tag') + ': ' + value.label,
193
289
  label('tag') + ' ' + value.label, body, locale, config, extensions);
194
290
  });
195
291
  routes.push(...tagRoutes);
196
292
 
197
293
  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' })),
294
+ ...posts.map((post) => ({ title: post.title, author: post.author, date: post.date.toISOString(), url: urlFor(post.path), summary: post.description || plainText(post.markdown).slice(0, 220), content: plainText(post.markdown), type: 'post' })),
199
295
  ...pages.map((page) => ({ title: page.title, url: urlFor(page.path), summary: page.description || plainText(page.markdown).slice(0, 220), type: 'page' }))
200
296
  ];
201
297
  routes.push(fileRoute(prefix + 'search.json', JSON.stringify(searchable), 'application/json; charset=utf-8'));
@@ -213,7 +309,8 @@ export async function generateBuiltinRoutes(site, config, extensions) {
213
309
  locale, config, extensions));
214
310
 
215
311
  const base = config.site.url;
216
- const entries = posts.slice(0, 20).map((post) => '<entry><title>' + escapeXml(post.title) + '</title><link href="' +
312
+ const entries = posts.slice(0, 20).map((post) => '<entry><title>' + escapeXml(post.title) + '</title>' +
313
+ (post.author ? '<author><name>' + escapeXml(post.author) + '</name></author>' : '') + '<link href="' +
217
314
  escapeXml(base + urlFor(post.path)) + '"/><id>' + escapeXml(base + urlFor(post.path)) + '</id><updated>' + post.date.toISOString() +
218
315
  '</updated><summary>' + escapeXml(post.description || plainText(post.markdown).slice(0, 220)) + '</summary></entry>').join('');
219
316
  routes.push(fileRoute(prefix + 'feed.xml', '<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>' +
package/src/icons.js ADDED
@@ -0,0 +1,16 @@
1
+ const iconNames = new Set([
2
+ 'home', 'book-open', 'rocket', 'newspaper', 'search', 'shield-check', 'rss', 'file-text',
3
+ 'layers', 'code', 'puzzle', 'plug', 'cloud', 'palette', 'globe', 'help-circle', 'arrow-up-right',
4
+ 'check-circle', 'layout-grid', 'terminal', 'settings', 'sparkles'
5
+ ]);
6
+
7
+ export function isIconName(name) {
8
+ return typeof name === 'string' && iconNames.has(name);
9
+ }
10
+
11
+ export function renderIcon(name, className = 'icon') {
12
+ if (!isIconName(name)) throw new Error('Unsupported EdgePress icon: ' + String(name));
13
+ if (!/^[a-z][a-z0-9-]*$/.test(className)) throw new Error('Icon class must be a simple CSS identifier');
14
+ return '<svg class="' + className + '" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><use href="/edgepress/icons.svg#' +
15
+ name + '"></use></svg>';
16
+ }
@@ -17,29 +17,6 @@ function versionAtLeast(current, minimum) {
17
17
  return true;
18
18
  }
19
19
 
20
- function markdown(report) {
21
- const lines = [
22
- '# EdgePress compatibility report',
23
- '',
24
- 'Status: **' + report.status + '**',
25
- '',
26
- 'Node.js: ' + report.runtime.node,
27
- 'Minimum required Node.js: ' + report.requirements.nodeMinimumVersion,
28
- 'Workers compatibility date: ' + report.runtime.workersDate,
29
- 'Minimum required date: ' + report.requirements.minimumWorkersDate,
30
- '',
31
- '## Findings',
32
- ''
33
- ];
34
- if (!report.issues.length) lines.push('No compatibility findings.');
35
- for (const issue of report.issues) lines.push('- **' + issue.severity.toUpperCase() + '** ' + issue.message);
36
- lines.push('', '## Feature lifecycle', '');
37
- for (const feature of report.features) {
38
- lines.push('- ' + feature.name + ': ' + feature.stage + ' (since ' + feature.since + ')');
39
- }
40
- return lines.join('\n');
41
- }
42
-
43
20
  export async function checkCompatibility(config) {
44
21
  const root = config.root;
45
22
  const manifest = await readJson(resolve(root, 'project-compatibility.json'));
@@ -77,10 +54,6 @@ export async function checkCompatibility(config) {
77
54
  features: manifest.features,
78
55
  issues
79
56
  };
80
- const directory = resolve(config.resolvedPaths.cache, 'reports');
81
- await mkdir(directory, { recursive: true });
82
- await writeFile(resolve(directory, 'compatibility.json'), JSON.stringify(report, null, 2), 'utf8');
83
- await writeFile(resolve(directory, 'compatibility.md'), markdown(report), 'utf8');
84
57
  return report;
85
58
  }
86
59
 
@@ -1,5 +1,6 @@
1
1
  import { plainText } from './markdown.js';
2
2
  import { translate } from './i18n.js';
3
+ import { isIconName, renderIcon } from './icons.js';
3
4
 
4
5
  const captchaProviders = new Set(['cloudflare-turnstile', 'google-recaptcha', 'hcaptcha']);
5
6
 
@@ -65,14 +66,24 @@ function renderPrivacyServices(context) {
65
66
  function renderLatestPosts(block, context) {
66
67
  const count = block.count ?? 3;
67
68
  if (!Number.isInteger(count) || count < 1 || count > 12) throw new Error('latest-posts.count must be an integer from 1 to 12');
69
+ const paginate = block.paginate ?? false;
70
+ if (typeof paginate !== 'boolean') throw new Error('latest-posts.paginate must be a boolean');
68
71
  const posts = context.site.posts.filter((post) => post.locale === context.locale ||
69
- (!context.site.posts.some((item) => item.locale === context.locale) && post.locale === context.config.i18n.defaultLocale)).slice(0, count);
72
+ (!context.site.posts.some((item) => item.locale === context.locale) && post.locale === context.config.i18n.defaultLocale))
73
+ .sort((a, b) => b.date - a.date);
70
74
  const title = escapeHtml(text(block.title, 'latest-posts.title', 200));
71
- const cards = posts.map((post) => '<article class="post-card"><h3><a href="' + escapeHtml('/' + post.path.split('/').filter(Boolean).join('/') + (post.path.endsWith('/') ? '/' : '')) + '">' +
75
+ const authorLabel = (post) => post.author ? '<span class="post-author">' + escapeHtml(translate(context.config, context.locale, 'postAuthor')
76
+ .replace('{author}', post.author)) + '</span>' : '';
77
+ const cards = posts.slice(0, count).map((post) => '<article class="post-card"><h3><a href="' + escapeHtml('/' + post.path.split('/').filter(Boolean).join('/') + (post.path.endsWith('/') ? '/' : '')) + '">' +
72
78
  escapeHtml(post.title) + '</a></h3><p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, context.locale)) +
73
- '</time></p><p>' + escapeHtml(post.description || plainText(post.markdown).slice(0, 220)) + '</p></article>').join('');
79
+ '</time>' + (post.author ? ' · ' + authorLabel(post) : '') + '</p><p>' + escapeHtml(post.description || plainText(post.markdown).slice(0, 220)) + '</p></article>').join('');
80
+ const pagination = paginate && context.latestPostsPagination?.totalPages > 1
81
+ ? '<nav class="pagination latest-posts-pagination" aria-label="' + escapeHtml(translate(context.config, context.locale, 'pagination')) + '">' +
82
+ '<a rel="next" href="' + escapeHtml(context.latestPostsPagination.olderUrl) + '">' + escapeHtml(translate(context.config, context.locale, 'older')) + '</a>' +
83
+ '<a href="' + escapeHtml(context.latestPostsPagination.archiveUrl) + '">' + escapeHtml(translate(context.config, context.locale, 'allPosts')) + '</a></nav>'
84
+ : '';
74
85
  return '<section class="latest-posts"><h2>' + title + '</h2>' +
75
- (cards ? '<div class="post-list">' + cards + '</div>' : '<p>' + escapeHtml(translate(context.config, context.locale, 'noPosts')) + '</p>') + '</section>';
86
+ (cards ? '<div class="post-list">' + cards + '</div>' : '<p>' + escapeHtml(translate(context.config, context.locale, 'noPosts')) + '</p>') + pagination + '</section>';
76
87
  }
77
88
 
78
89
  function renderMediaText(block) {
@@ -88,12 +99,13 @@ function renderMediaText(block) {
88
99
  media = '<img src="' + src + '" alt="' + alt + '" loading="lazy" decoding="async">';
89
100
  } else {
90
101
  if (!alt.trim()) throw new Error('media-text.alt must describe the video');
102
+ const videoSrc = escapeHtml(safeUrl(block.src, 'media-text.src'));
91
103
  const captions = escapeHtml(safeUrl(block.captions, 'media-text.captions'));
92
104
  const language = escapeHtml(text(block.captionLanguage, 'media-text.captionLanguage', 24));
93
105
  if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(language)) throw new Error('media-text.captionLanguage must be a language tag');
94
106
  const trackLabel = escapeHtml(text(block.captionLabel, 'media-text.captionLabel', 120));
95
107
  const poster = block.poster ? ' poster="' + escapeHtml(safeUrl(block.poster, 'media-text.poster')) + '"' : '';
96
- media = '<video controls preload="none" aria-label="' + alt + '"' + poster + '><source src="' + src + '"><track kind="captions" src="' + captions + '" srclang="' + language + '" label="' + trackLabel + '" default></video>';
108
+ media = '<video controls playsinline preload="metadata" aria-label="' + alt + '"' + poster + '><source src="' + videoSrc + '" type="video/mp4"><track kind="captions" src="' + captions + '" srclang="' + language + '" label="' + trackLabel + '" default></video>';
97
109
  }
98
110
  const title = text(block.title, 'media-text.title', 240, true);
99
111
  const content = text(block.text, 'media-text.text', 2000);
@@ -117,6 +129,9 @@ async function renderBlock(block, context, depth, index) {
117
129
  const level = context.isHomepage && index === 0 ? 'h1' : 'h2';
118
130
  const eyebrow = text(block.eyebrow, 'hero.eyebrow', 120, true);
119
131
  const description = text(block.text, 'hero.text', 1000, true);
132
+ const mascotSrc = block.mascotSrc === undefined ? '' : safeUrl(block.mascotSrc, 'hero.mascotSrc');
133
+ const mascotAlt = block.mascotAlt === undefined ? '' : text(block.mascotAlt, 'hero.mascotAlt', 500);
134
+ if (Boolean(mascotSrc) !== Boolean(mascotAlt)) throw new Error('hero.mascotSrc and hero.mascotAlt must be provided together');
120
135
  let action = '';
121
136
  if (block.cta) {
122
137
  const label = escapeHtml(text(block.cta.label, 'hero.cta.label', 120));
@@ -126,9 +141,12 @@ async function renderBlock(block, context, depth, index) {
126
141
  const highlights = block.highlights === undefined ? [] : list(block.highlights, 'hero.highlights', 1, 4);
127
142
  const highlightPanel = highlights.length ? '<ul class="hero-highlights">' + highlights.map((item) => '<li>' +
128
143
  escapeHtml(text(item, 'hero highlight', 180)) + '</li>').join('') + '</ul>' : '';
129
- return '<section class="hero-block' + (highlightPanel ? ' hero-block--split' : '') + '"><div class="hero-main">' +
144
+ const mascot = mascotSrc ? '<img class="hero-mascot" src="' + escapeHtml(mascotSrc) + '" alt="' +
145
+ escapeHtml(mascotAlt) + '" width="1222" height="1287" decoding="async">' : '';
146
+ const aside = mascot || highlightPanel ? '<div class="hero-aside">' + mascot + highlightPanel + '</div>' : '';
147
+ return '<section class="hero-block' + (aside ? ' hero-block--split' : '') + '"><div class="hero-main">' +
130
148
  (eyebrow ? '<p class="eyebrow">' + escapeHtml(eyebrow) + '</p>' : '') + '<' + level + '>' + escapeHtml(heading) + '</' + level + '>' +
131
- (description ? '<p class="hero-copy">' + escapeHtml(description) + '</p>' : '') + action + '</div>' + highlightPanel + '</section>';
149
+ (description ? '<p class="hero-copy">' + escapeHtml(description) + '</p>' : '') + action + '</div>' + aside + '</section>';
132
150
  }
133
151
  case 'section': {
134
152
  const heading = text(block.title, 'section.title', 200, true);
@@ -269,7 +287,10 @@ async function renderBlock(block, context, depth, index) {
269
287
  const title = escapeHtml(text(item?.title, 'feature title', 200));
270
288
  const description = escapeHtml(text(item?.text, 'feature text', 1000, true));
271
289
  const href = item?.url ? safeUrl(item.url, 'feature.url') : '';
272
- return '<article class="feature-card"><h3>' + (href ? '<a href="' + escapeHtml(href) + '">' + title + '</a>' : title) + '</h3>' +
290
+ const iconName = item?.icon === undefined ? '' : text(item.icon, 'feature icon', 40);
291
+ if (iconName && !isIconName(iconName)) throw new Error('Unsupported feature icon: ' + iconName);
292
+ const icon = iconName ? renderIcon(iconName, 'feature-icon') : '';
293
+ return '<article class="feature-card">' + icon + '<h3>' + (href ? '<a href="' + escapeHtml(href) + '">' + title + '</a>' : title) + '</h3>' +
273
294
  (description ? '<p>' + description + '</p>' : '') + '</article>';
274
295
  }).join('');
275
296
  return '<section class="feature-section"><h2>' + escapeHtml(heading) + '</h2><div class="feature-grid">' + cells + '</div></section>';
package/src/page-check.js CHANGED
@@ -119,7 +119,12 @@ async function resolveLocalLink(output, pageFile, href) {
119
119
  else candidates.push(resolve(output, clean + '.html'), resolve(output, clean, 'index.html'));
120
120
  for (const candidate of candidates) {
121
121
  if (!candidate.startsWith(output + sep)) continue;
122
- if (await exists(candidate)) return { file: candidate, fragment: url.hash.slice(1) };
122
+ if (await exists(candidate)) {
123
+ let fragment = url.hash.slice(1);
124
+ try { fragment = decodeURIComponent(fragment); }
125
+ catch { return { broken: true, target: href }; }
126
+ return { file: candidate, fragment };
127
+ }
123
128
  }
124
129
  return { broken: true, target: href };
125
130
  }
package/src/theme.js CHANGED
@@ -1,6 +1,11 @@
1
1
  import { readFile, realpath } from 'node:fs/promises';
2
2
  import { relative, resolve, sep } from 'node:path';
3
3
  import { localePrefix, localizedUrl, translate } from './i18n.js';
4
+ import { isIconName, renderIcon } from './icons.js';
5
+ const defaultNavigationIcons = {
6
+ home: 'home', guides: 'book-open', 'quick-start': 'rocket', posts: 'newspaper',
7
+ search: 'search', privacy: 'shield-check', feed: 'rss'
8
+ };
4
9
 
5
10
  function isInside(root, target) {
6
11
  const rel = relative(root, target);
@@ -27,7 +32,9 @@ function navigationItems(config, locale, items) {
27
32
  item.url === '@search' ? localizedUrl(config, locale, 'search/') :
28
33
  item.url.startsWith('/') ? localizedUrl(config, locale, item.url) : item.url;
29
34
  const label = item.labels?.[locale] ?? item.labels?.[config.i18n.defaultLocale] ?? translate(config, locale, item.key);
30
- return '<a href="' + escapeHtml(path) + '">' + escapeHtml(label) + '</a>';
35
+ const iconName = item.icon || defaultNavigationIcons[item.key];
36
+ const icon = isIconName(iconName) ? renderIcon(iconName, 'navigation-icon') : '';
37
+ return '<a href="' + escapeHtml(path) + '">' + icon + escapeHtml(label) + '</a>';
31
38
  }).join('');
32
39
  }
33
40
 
@@ -157,5 +164,9 @@ export async function renderLayout(config, extensions, page, body) {
157
164
  const html = '<!doctype html>' + rendered;
158
165
  const filtered = await extensions.filter('html:afterLayout', html, context);
159
166
  if (typeof filtered !== 'string') throw new Error('html:afterLayout filters must return a string');
160
- return filtered;
167
+ if (/src=["']\/edgepress\/code-copy\.js["']/i.test(filtered)) return filtered;
168
+ const codeCopyScript = '<script defer src="/edgepress/code-copy.js"></script>';
169
+ return /<\/body\s*>/i.test(filtered)
170
+ ? filtered.replace(/<\/body\s*>/i, codeCopyScript + '</body>')
171
+ : filtered + codeCopyScript;
161
172
  }
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
2
+ <rect width="48" height="48" rx="14" fill="#315E49"/>
3
+ <path d="M12 10.5a2 2 0 0 1 2-2h13.5L36 17v20.5a2 2 0 0 1-2 2H14a2 2 0 0 1-2-2z" fill="#FBF8EF"/>
4
+ <path d="M27.5 8.5V17H36" fill="#E98263"/>
5
+ <path d="M18 23h12m-12 6h12m-12 6h8" stroke="#315E49" stroke-width="2.6" stroke-linecap="round"/>
6
+ <path d="M27.5 8.5 36 17" stroke="#D6E4D8" stroke-width="1.2"/>
7
+ </svg>
@@ -0,0 +1,46 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
2
+ <defs>
3
+ <linearGradient id="paper" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fbfaf5"/><stop offset="1" stop-color="#eee9dd"/></linearGradient>
4
+ <linearGradient id="field" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#dfeae0"/><stop offset="1" stop-color="#c9ddcf"/></linearGradient>
5
+ <filter id="card-shadow" x="-20%" y="-20%" width="140%" height="150%"><feDropShadow dx="0" dy="16" stdDeviation="18" flood-color="#183729" flood-opacity=".12"/></filter>
6
+ <pattern id="dots" width="25" height="25" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1.4" fill="#8da891" opacity=".42"/></pattern>
7
+ </defs>
8
+ <rect width="1200" height="630" rx="28" fill="url(#paper)"/>
9
+ <circle cx="1100" cy="62" r="245" fill="#f1e5d3" opacity=".75"/>
10
+ <circle cx="964" cy="333" r="242" fill="url(#field)"/>
11
+ <circle cx="964" cy="333" r="217" fill="url(#dots)" opacity=".7"/>
12
+ <path d="M680 463c104-93 172-139 274-139s184 47 255 121" fill="none" stroke="#74937b" stroke-width="2" stroke-dasharray="4 10" opacity=".55"/>
13
+ <circle cx="703" cy="442" r="7" fill="#e98263"/>
14
+ <circle cx="1182" cy="442" r="7" fill="#315e49"/>
15
+ <g transform="translate(68 67)">
16
+ <rect width="52" height="52" rx="16" fill="#315e49"/>
17
+ <path d="M13 12a2 2 0 0 1 2-2h15l9 9v21a2 2 0 0 1-2 2H15a2 2 0 0 1-2-2z" fill="#fbf8ef"/>
18
+ <path d="M30 10v9h9" fill="#e98263"/>
19
+ <path d="M19 25h13m-13 6h13m-13 6h9" stroke="#315e49" stroke-width="2.5" stroke-linecap="round"/>
20
+ <text x="70" y="36" fill="#26382d" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="28" font-weight="700" letter-spacing="-.7">EdgePress</text>
21
+ </g>
22
+ <g fill="#26382d" font-family="Inter,Segoe UI,Arial,sans-serif">
23
+ <text x="72" y="235" font-size="60" font-weight="750" letter-spacing="-2.7">Build for the edge.</text>
24
+ <text x="72" y="306" font-size="60" font-weight="750" letter-spacing="-2.7">Publish with clarity.</text>
25
+ <text x="75" y="365" fill="#64756a" font-size="23" font-weight="450">A thoughtful way to build a fast, flexible website.</text>
26
+ <text x="75" y="400" fill="#64756a" font-size="23" font-weight="450">Markdown, editable pages, themes, and Workers.</text>
27
+ </g>
28
+ <g filter="url(#card-shadow)">
29
+ <rect x="72" y="460" width="516" height="91" rx="18" fill="#fffdf8" stroke="#e1ddd3"/>
30
+ <path d="M102 487h24l9 9v28h-33z" fill="#dce9dd" stroke="#315e49" stroke-width="1.5" stroke-linejoin="round"/>
31
+ <path d="M126 487v9h9m-24 8h16m-16 7h16" fill="none" stroke="#315e49" stroke-width="1.7" stroke-linecap="round"/>
32
+ <text x="151" y="510" fill="#26382d" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="17" font-weight="650">Open source. Built for publishing.</text>
33
+ <text x="151" y="532" fill="#748176" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="14">Clear source in. A lightweight site out.</text>
34
+ </g>
35
+ <g transform="translate(950 55)">
36
+ <rect x="12" y="10" width="167" height="41" rx="20.5" fill="#fffdf8" stroke="#c7d7c9"/>
37
+ <circle cx="37" cy="30.5" r="5" fill="#e98263"/>
38
+ <text x="54" y="36" fill="#45634e" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="13" font-weight="700" letter-spacing="1.4">PAGE BY PAGE</text>
39
+ </g>
40
+ <image href="mascot.png" x="755" y="72" width="430" height="478" preserveAspectRatio="xMidYMid meet"/>
41
+ <g transform="translate(936 557)">
42
+ <rect width="208" height="36" rx="18" fill="#315e49"/>
43
+ <text x="104" y="24" text-anchor="middle" fill="#fbf8ef" font-family="Inter,Segoe UI,Arial,sans-serif" font-size="12" font-weight="700" letter-spacing="1.5">PUBLISH WITH PURPOSE</text>
44
+ </g>
45
+ <path d="M1107 125c10-19 14-21 21-23-4 11-4 18-1 27-8-4-13-4-20-4Z" fill="#e98263"/>
46
+ </svg>