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
@@ -1,30 +1,30 @@
1
- import {renderMarkdown} from './markdown.js';
2
- const escape=value=>String(value).replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
3
-
4
- // Git is a build-time source of history. Readers see changed passages, not patches.
5
- export function changedPassages(diff){
6
- const groups=[];let before=[],after=[];
7
- const finish=()=>{if(before.length||after.length)groups.push({before:before.join('\n'),after:after.join('\n')});before=[];after=[];};
8
- for(const line of String(diff).split(/\r?\n/)){
9
- if(/^(?:diff --git|index |--- |\+\+\+ |\\)/.test(line))continue;
10
- if(line.startsWith('-'))before.push(line.slice(1));
11
- else if(line.startsWith('+'))after.push(line.slice(1));
12
- else finish();
13
- }
14
- finish();return groups.filter(group=>group.before.trim()||group.after.trim());
15
- }
16
- export async function renderRevisionCards(diff,label,images,options={}){
17
- const groups=changedPassages(diff);if(!groups.length)return '';
18
- const cards=[];
19
- for(const group of groups){
20
- const sides=[];
21
- for(const side of ['before','after'])if(group[side].trim()){
22
- const key=side==='before'?(group.after.trim()?'postChangeBefore':'postChangeRemoved'):(group.before.trim()?'postChangeAfter':'postChangeAdded');
23
- const known=new Set(Object.values(images?.[side]||{}));
24
- const content=(await renderMarkdown(group[side],{allowImages:!!images,allowVideo:false,...(options.linkResolver?{linkResolver:options.linkResolver}:{}),...(images?{imageResolver:href=>images[side]?.[href]||(known.has(href)?href:null)}:{})})).replace(/<h[1-6]\b[^>]*>/g,'<p class="post-change-heading">').replace(/<\/h[1-6]>/g,'</p>');
25
- sides.push('<div class="post-change-side post-change-'+side+'"><p class="post-change-label">'+escape(label(key))+'</p><div class="post-change-content">'+content+'</div></div>');
26
- }
27
- cards.push('<div class="post-change-passage">'+sides.join('')+'</div>');
28
- }
29
- return '<details class="post-changes"><summary>'+escape(label('postChanges'))+'</summary><div class="post-changes-list">'+cards.join('')+'</div></details>';
30
- }
1
+ import {renderMarkdown} from './markdown.js';
2
+ const escape=value=>String(value).replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
3
+
4
+ // Git is a build-time source of history. Readers see changed passages, not patches.
5
+ export function changedPassages(diff){
6
+ const groups=[];let before=[],after=[];
7
+ const finish=()=>{if(before.length||after.length)groups.push({before:before.join('\n'),after:after.join('\n')});before=[];after=[];};
8
+ for(const line of String(diff).split(/\r?\n/)){
9
+ if(/^(?:diff --git|index |--- |\+\+\+ |\\)/.test(line))continue;
10
+ if(line.startsWith('-'))before.push(line.slice(1));
11
+ else if(line.startsWith('+'))after.push(line.slice(1));
12
+ else finish();
13
+ }
14
+ finish();return groups.filter(group=>group.before.trim()||group.after.trim());
15
+ }
16
+ export async function renderRevisionCards(diff,label,images,options={}){
17
+ const groups=changedPassages(diff);if(!groups.length)return '';
18
+ const cards=[];
19
+ for(const group of groups){
20
+ const sides=[];
21
+ for(const side of ['before','after'])if(group[side].trim()){
22
+ const key=side==='before'?(group.after.trim()?'postChangeBefore':'postChangeRemoved'):(group.before.trim()?'postChangeAfter':'postChangeAdded');
23
+ const known=new Set(Object.values(images?.[side]||{}));
24
+ const content=(await renderMarkdown(group[side],{allowImages:!!images,allowVideo:false,...(options.linkResolver?{linkResolver:options.linkResolver}:{}),...(images?{imageResolver:href=>images[side]?.[href]||(known.has(href)?href:null)}:{})})).replace(/<h[1-6]\b[^>]*>/g,'<p class="post-change-heading">').replace(/<\/h[1-6]>/g,'</p>');
25
+ sides.push('<div class="post-change-side post-change-'+side+'"><p class="post-change-label">'+escape(label(key))+'</p><div class="post-change-content">'+content+'</div></div>');
26
+ }
27
+ cards.push('<div class="post-change-passage">'+sides.join('')+'</div>');
28
+ }
29
+ return '<details class="post-changes"><summary>'+escape(label('postChanges'))+'</summary><div class="post-changes-list">'+cards.join('')+'</div></details>';
30
+ }
package/src/rss.js ADDED
@@ -0,0 +1,100 @@
1
+ import { renderMarkdown, plainText } from './markdown.js';
2
+ import { localizedContentUrl, localizedUrl } from './i18n.js';
3
+ import { authorForPost, normalizeCategory } from './post-details.js';
4
+
5
+ function xml(value) {
6
+ return String(value ?? '')
7
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, '')
8
+ .replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[char]);
9
+ }
10
+
11
+ function decodeAttribute(value) {
12
+ return String(value).replace(/&(?:amp|quot|apos|lt|gt);/gi, entity => ({
13
+ '&amp;': '&', '&quot;': '"', '&apos;': "'", '&lt;': '<', '&gt;': '>'
14
+ })[entity.toLowerCase()]);
15
+ }
16
+
17
+ // Feed HTML has no reliable base URL. Its images and internal links must work
18
+ // independently of the original article page, without adding active embeds.
19
+ export function absoluteContentUrls(html, articleUrl) {
20
+ return String(html).replace(/\b(href|src)="([^"]*)"/g, (attribute, name, encoded) => {
21
+ const value = decodeAttribute(encoded);
22
+ if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(value)) return attribute;
23
+ try {
24
+ const url = new URL(value, articleUrl);
25
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return attribute;
26
+ return name + '="' + url.href.replace(/&/g, '&amp;').replace(/"/g, '&quot;') + '"';
27
+ } catch {
28
+ return attribute;
29
+ }
30
+ });
31
+ }
32
+
33
+ function canonicalUrl(post, config) {
34
+ return config.site.url + '/' + String(post.path).split('/').filter(Boolean).map(encodeURIComponent).join('/') + '/';
35
+ }
36
+
37
+ function lastChanged(post) {
38
+ return post.updated instanceof Date && post.updated > post.date ? post.updated : post.date;
39
+ }
40
+
41
+ function categories(post) {
42
+ const category = normalizeCategory(post.category);
43
+ return [...new Set([...(category === 'uncategorized' ? [] : [category]), ...(post.tags || [])])];
44
+ }
45
+
46
+ function isSiteImage(href, config) {
47
+ const value = String(href ?? '');
48
+ if (value.startsWith('/') && !value.startsWith('//')) return value;
49
+ try {
50
+ const url = new URL(value);
51
+ return url.protocol === 'https:' && url.origin === new URL(config.site.url).origin ? value : '';
52
+ } catch { return ''; }
53
+ }
54
+
55
+ // Returns a single standard RSS 2.0 document; no hand-maintained XML assets.
56
+ export async function generateRssRoute(posts, config, locale) {
57
+ const prefix = locale === config.i18n.defaultLocale ? '' : locale + '/';
58
+ const homeUrl = config.site.url + localizedUrl(config, locale, '');
59
+ const rssUrl = config.site.url + localizedUrl(config, locale, 'rss.xml');
60
+ const items = await Promise.all(posts.slice(0, 20).map(async post => {
61
+ const url = canonicalUrl(post, config);
62
+ // Re-render static Markdown with the normal sanitizer. Omit interactive
63
+ // embed/player markup and remote images that may bypass reader consent.
64
+ const rendered = await renderMarkdown(post.markdown || '', {
65
+ ...config.markdown,
66
+ linkResolver: href => localizedContentUrl(config, post.locale, href),
67
+ imageResolver: href => isSiteImage(href, config),
68
+ allowVideo: false
69
+ });
70
+ return {
71
+ url, title: post.title,
72
+ author: authorForPost(post, config).name || config.site.title,
73
+ locale: post.locale, published: post.date, updated: lastChanged(post),
74
+ summary: plainText(post.description || post.markdown).slice(0, 220),
75
+ content: absoluteContentUrls(rendered, url), categories: categories(post)
76
+ };
77
+ }));
78
+ const lastBuild = items.reduce((latest, item) => item.updated > latest ? item.updated : latest, new Date(0));
79
+ const rssItems = items.map(item =>
80
+ '<item><title>' + xml(item.title) + '</title><link>' + xml(item.url) + '</link>' +
81
+ '<guid isPermaLink="true">' + xml(item.url) + '</guid>' +
82
+ '<pubDate>' + item.published.toUTCString() + '</pubDate>' +
83
+ '<dc:date>' + item.updated.toISOString() + '</dc:date>' +
84
+ '<dc:creator>' + xml(item.author) + '</dc:creator>' +
85
+ '<dc:language>' + xml(item.locale) + '</dc:language>' +
86
+ '<description>' + xml(item.summary) + '</description>' +
87
+ item.categories.map(category => '<category>' + xml(category) + '</category>').join('') +
88
+ '<content:encoded>' + xml(item.content) + '</content:encoded></item>'
89
+ ).join('');
90
+ const rss = '<?xml version="1.0" encoding="utf-8"?>' +
91
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" ' +
92
+ 'xmlns:content="http://purl.org/rss/1.0/modules/content/" ' +
93
+ 'xmlns:dc="http://purl.org/dc/elements/1.1/"><channel>' +
94
+ '<title>' + xml(config.site.title) + '</title><link>' + xml(homeUrl) + '</link>' +
95
+ '<description>' + xml(config.site.description) + '</description><language>' + xml(locale) + '</language>' +
96
+ '<lastBuildDate>' + lastBuild.toUTCString() + '</lastBuildDate>' +
97
+ '<atom:link href="' + xml(rssUrl) + '" rel="self" type="application/rss+xml"/>' +
98
+ rssItems + '</channel></rss>';
99
+ return { path: prefix + 'rss.xml', body: rss, contentType: 'application/rss+xml; charset=utf-8' };
100
+ }
package/src/theme.js CHANGED
@@ -28,7 +28,7 @@ function navigationItems(config, locale, items, currentPath = '', scope = 'prima
28
28
  return values.map((item, index) => {
29
29
  const path = item.url === '@home' ? localizedUrl(config, locale, '') :
30
30
  item.url === '@archives' ? localizedUrl(config, locale, 'archives/') :
31
- item.url === '@feed' ? localizedUrl(config, locale, 'feed.xml') :
31
+ item.url === '@feed' ? localizedUrl(config, locale, 'rss.xml') :
32
32
  item.url === '@search' ? localizedUrl(config, locale, 'search/') :
33
33
  item.url.startsWith('/') ? localizedUrl(config, locale, item.url) : item.url;
34
34
  const label = item.labels?.[locale] ?? item.labels?.[config.i18n.defaultLocale] ?? translate(config, locale, item.key);
@@ -38,7 +38,7 @@ function navigationItems(config, locale, items, currentPath = '', scope = 'prima
38
38
  if (item.children?.length) {
39
39
  const choices = [{ path, label, target:item.target }, ...item.children.map(child => ({
40
40
  path: child.url === '@home' ? localizedUrl(config, locale, '') : child.url === '@archives' ? localizedUrl(config, locale, 'archives/') :
41
- child.url === '@feed' ? localizedUrl(config, locale, 'feed.xml') : child.url === '@search' ? localizedUrl(config, locale, 'search/') :
41
+ child.url === '@feed' ? localizedUrl(config, locale, 'rss.xml') : child.url === '@search' ? localizedUrl(config, locale, 'search/') :
42
42
  child.url.startsWith('/') ? localizedUrl(config, locale, child.url) : child.url,
43
43
  label: child.labels?.[locale] ?? child.labels?.[config.i18n.defaultLocale] ?? translate(config, locale, child.key), target:child.target
44
44
  }))];
@@ -150,7 +150,7 @@ export async function renderLayout(config, extensions, page, body) {
150
150
  stylesheet,
151
151
  homePath: localizedUrl(config, locale, ''),
152
152
  archivesPath: localizedUrl(config, locale, 'archives/'),
153
- feedPath: localizedUrl(config, locale, 'feed.xml'),
153
+ feedPath: localizedUrl(config, locale, 'rss.xml'),
154
154
  homeLabel: translate(config, locale, 'home'),
155
155
  archivesLabel: translate(config, locale, 'archives'),
156
156
  feedLabel: translate(config, locale, 'feed'),
@@ -194,7 +194,7 @@ export async function renderLayout(config, extensions, page, body) {
194
194
  scripts.push('<script id="edgepress-external-links-config" type="application/json">' + JSON.stringify(settings).replace(/</g, '\\u003c') + '</script><script type="module" src="/edgepress/external-links.js"></script>');
195
195
  }
196
196
  if (/<main\b/i.test(filtered)) scripts.push('<script type="module" src="/edgepress/webmcp.js"></script>');
197
- if (/data-edgepress-media-viewer(?:\s|=|>)/i.test(filtered)) {
197
+ if (/data-edgepress-(?:media-viewer|video)(?:\s|=|>)/i.test(filtered)) {
198
198
  filtered = filtered.replace(/<\/head\s*>/i, '<link rel="stylesheet" href="/edgepress/media-viewer.css"><link rel="stylesheet" href="/edgepress/image-viewer.css"></head>');
199
199
  scripts.push('<script type="module" src="/edgepress/media-viewer.js"></script>');
200
200
  if (filtered.includes('data-media-service')) scripts.push('<script defer src="/edgepress/services-consent.js"></script>');
@@ -0,0 +1,36 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import {posix} from 'node:path';
3
+
4
+ // Offline-generated sidecars keep duration and posters available without requesting media.
5
+ export async function collectVideoMetadata(assets) {
6
+ const known = new Map(assets.map(item => [item.path, item])), result = new Map();
7
+ for (const video of assets.filter(item => /\.(?:mp4|webm|ogv|ogg)$/i.test(item.path))) {
8
+ const sidecar = known.get(video.path + '.json');
9
+ if (!sidecar) continue;
10
+ const data = JSON.parse((sidecar.content ?? await readFile(sidecar.source)).toString());
11
+ const duration = Number(data.duration), width = Number(data.width), height = Number(data.height);
12
+ if (![duration, width, height].every(value => Number.isFinite(value) && value > 0)) throw new Error('Invalid video metadata: ' + sidecar.path);
13
+ if (typeof data.poster !== 'string' || /[\\?#\x00-\x1f]|^[a-z]+:|^\/\//i.test(data.poster)) throw new Error('Invalid video poster: ' + sidecar.path);
14
+ const poster = posix.normalize(posix.join(posix.dirname(video.path), data.poster));
15
+ if (!known.has(poster) || !/\.(?:jpe?g|png|webp|avif)$/i.test(poster)) throw new Error('Missing local video poster: ' + sidecar.path);
16
+ result.set('/' + video.path, {duration, width, height, poster: '/' + poster});
17
+ }
18
+ return result;
19
+ }
20
+
21
+ export function reserveVideoMetadata(html, metadata, path, origin) {
22
+ return html.replace(/<video\b([^>]*)>([\s\S]*?)<\/video>/gi, (tag, attributes, content) => {
23
+ const src = attributes.match(/\ssrc=["']([^"']+)["']/i)?.[1] ?? content.match(/<source\b[^>]*\ssrc=["']([^"']+)["']/i)?.[1];
24
+ if (!src) return tag;
25
+ let url;
26
+ try { url = new URL(src.replaceAll('&amp;', '&'), new URL('/' + path, origin)); } catch { return tag; }
27
+ if (url.origin !== new URL(origin).origin) return tag;
28
+ const data = metadata.get(decodeURI(url.pathname));
29
+ if (!data) return tag;
30
+ const escape = value => String(value).replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
31
+ for (const [name, value] of Object.entries({'data-duration': data.duration, poster: data.poster, width: data.width, height: data.height})) {
32
+ if (!new RegExp('\\s' + name + '\\s*=', 'i').test(attributes)) attributes += ' ' + name + '="' + escape(value) + '"';
33
+ }
34
+ return '<video' + attributes + '>' + content + '</video>';
35
+ });
36
+ }