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.
- package/README.md +12 -2
- package/config.yml +171 -171
- package/content/assets/edgepress/brand/sources/export-manifest.json +181 -181
- package/content/assets/edgepress-markdown-guide.svg +21 -21
- package/content/guides/markdown-media.md +38 -0
- package/content/guides/product-demos.md +30 -30
- package/content/pages/deployment/en.json +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
- package/languages/base/en.json +98 -98
- package/languages/packs/zh-CN.json +98 -98
- package/package.json +90 -90
- package/src/assets.js +187 -186
- package/src/browser-audit.js +158 -158
- package/src/build.js +293 -291
- package/src/cli.js +364 -364
- package/src/content.js +233 -233
- package/src/data-saver.js +3 -3
- package/src/generators.js +414 -420
- package/src/i18n.js +86 -86
- package/src/markdown.js +195 -160
- package/src/page-check.js +1 -1
- package/src/plugin-api.js +77 -77
- package/src/report-visuals.js +642 -642
- package/src/revision-cards.js +30 -30
- package/src/rss.js +100 -0
- package/src/theme.js +4 -4
- package/src/video-metadata.js +36 -0
- package/static/edgepress/fonts/faces.css +2217 -2217
- package/static/edgepress/media-viewer.js +67 -10
- package/static/edgepress/project-demo.js +83 -83
- package/static/edgepress/search.js +84 -84
- package/tests/assets-runtime.test.mjs +26 -26
- package/tests/audit.test.mjs +142 -142
- package/tests/brand-regression.test.mjs +110 -110
- package/tests/document-package.test.mjs +2 -2
- package/tests/embed-syntax.test.mjs +68 -68
- package/tests/image-viewer.test.mjs +36 -36
- package/tests/markdown-media.test.mjs +119 -0
- package/tests/oembed-layout.test.mjs +6 -1
- package/tests/project-demo.test.mjs +73 -73
- package/tests/release-version.test.mjs +28 -0
- package/tests/rss.test.mjs +54 -0
- package/tests/video-metadata.test.mjs +28 -0
- package/tests/writing.test.mjs +83 -83
- package/themes/atelier/assets/style.css +340 -340
- package/themes/default/assets/style.css +371 -371
- package/themes/default/layouts/partials/head.html +1 -1
- package/themes/folio/assets/style.css +515 -515
- package/themes/folio/theme.json +5 -5
- package/themes/signal/assets/style.css +332 -332
- package/tools/README.md +10 -10
- package/tools/assets/generate-brand-assets.py +160 -160
- package/tools/assets/video-preview.mjs +15 -0
- package/tools/recordings/demo-workbench.mjs +53 -53
- package/tools/recordings/desktop-recording.mjs +70 -70
- package/tools/recordings/vm-desktop.mjs +59 -59
package/src/revision-cards.js
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
import {renderMarkdown} from './markdown.js';
|
|
2
|
-
const escape=value=>String(value).replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function decodeAttribute(value) {
|
|
12
|
+
return String(value).replace(/&(?:amp|quot|apos|lt|gt);/gi, entity => ({
|
|
13
|
+
'&': '&', '"': '"', ''': "'", '<': '<', '>': '>'
|
|
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, '&').replace(/"/g, '"') + '"';
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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('&', '&'), 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('&', '&').replaceAll('"', '"').replaceAll('<', '<');
|
|
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
|
+
}
|