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/i18n.js CHANGED
@@ -1,86 +1,86 @@
1
- import { readFile, realpath, stat } from 'node:fs/promises';
2
- import {fileURLToPath} from 'node:url';
3
- import { isAbsolute, relative, resolve, sep } from 'node:path';
4
-
5
- const MAX_LANGUAGE_FILE_BYTES = 64 * 1024;
6
-
7
- function isInside(root, target) {
8
- const path = relative(root, target);
9
- return path !== '..' && !path.startsWith('..' + sep) && !isAbsolute(path);
10
- }
11
-
12
- async function loadDictionary(root, directory, locale, label) {
13
- const directoryPath = resolve(root, directory);
14
- const file = resolve(directoryPath, locale + '.json');
15
- const realRoot = await realpath(root);
16
- const realDirectory = await realpath(directoryPath);
17
- if (!isInside(realRoot, realDirectory)) throw new Error(label + ' language directory resolves outside the project root: ' + directoryPath);
18
- const realFile = await realpath(file).catch((error) => {
19
- if (error.code === 'ENOENT') throw new Error('Missing ' + label + ' language file: ' + file);
20
- throw error;
21
- });
22
- if (!isInside(realDirectory, realFile)) throw new Error(label + ' language file resolves outside its language directory: ' + file);
23
- const source = await readFile(realFile);
24
- if (source.byteLength > MAX_LANGUAGE_FILE_BYTES) throw new Error(label + ' language file exceeds 64 KB: ' + file);
25
- let parsed;
26
- try { parsed = JSON.parse(source.toString('utf8')); }
27
- catch (error) { throw new Error('Invalid JSON in ' + label + ' language file: ' + file, { cause: error }); }
28
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
29
- throw new Error(label + ' language file must contain an object of string translations: ' + file);
30
- }
31
- const dictionary = Object.create(null);
32
- for (const [id, value] of Object.entries(parsed)) {
33
- if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(id)) throw new Error('Invalid translation id in ' + file + ': ' + id);
34
- if (typeof value !== 'string') throw new Error('Translation values must be strings in ' + file + ': ' + id);
35
- dictionary[id] = value;
36
- }
37
- return dictionary;
38
- }
39
-
40
- export async function loadLanguagePacks(config) {
41
- const baseLocale = config.i18n.defaultLocale;
42
- const translationsByLocale = Object.create(null);
43
- const builtinRoot=fileURLToPath(new URL('../',import.meta.url)),english=await loadDictionary(builtinRoot,'languages/base','en','Built-in');
44
- async function defaults(locale){
45
- if(locale==='en')return english;
46
- const file=resolve(builtinRoot,'languages/packs',locale+'.json');
47
- const exists=await stat(file).then(()=>true,error=>{if(error.code==='ENOENT')return false;throw error;});
48
- return exists?{...english,...await loadDictionary(builtinRoot,'languages/packs',locale,'Built-in')}:english;
49
- }
50
- translationsByLocale[baseLocale] = {...await defaults(baseLocale),...await loadDictionary(config.root, 'languages/base', baseLocale, 'Base')};
51
- for (const locale of config.i18n.languagePacks) {
52
- translationsByLocale[locale] = {...await defaults(locale),...await loadDictionary(config.root, 'languages/packs', locale, 'Language pack')};
53
- }
54
- config.i18n.translationsByLocale = translationsByLocale;
55
- return translationsByLocale;
56
- }
57
-
58
- export function translate(config, locale, key) {
59
- const base = config.i18n.translationsByLocale?.[config.i18n.defaultLocale];
60
- const selected = config.i18n.translationsByLocale?.[locale];
61
- return selected?.[key] ?? base?.[key] ?? key;
62
- }
63
-
64
- export function translateValue(config, locale, value) {
65
- if (typeof value === 'string') return value;
66
- if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
67
- return value[locale] ?? value[config.i18n.defaultLocale] ?? Object.values(value).find((text) => typeof text === 'string') ?? '';
68
- }
69
-
70
- export function localePrefix(config, locale) {
71
- return locale === config.i18n.defaultLocale ? '' : '/' + locale;
72
- }
73
-
74
- export function localizedUrl(config, locale, path) {
75
- const prefix = localePrefix(config, locale);
76
- const suffix = String(path ?? '').replace(/^\/+/, '');
77
- return prefix + '/' + suffix;
78
- }
79
-
80
-
81
- export function localizedContentUrl(config, locale, value, label = 'Content URL') {
82
- const url=String(value),token=/^\/(?:\[launge\]|%5blaunge%5d)(?=\/|$)/i;
83
- if(!url.startsWith('/'))return url;
84
- if(/\[launge\]|%5blaunge%5d/i.test(url)&&!token.test(url))throw new Error(label+' may use [launge] only as the first site-path segment');
85
- return token.test(url)?localePrefix(config,locale)+(url.replace(token,'')||'/'):url;
86
- }
1
+ import { readFile, realpath, stat } from 'node:fs/promises';
2
+ import {fileURLToPath} from 'node:url';
3
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+
5
+ const MAX_LANGUAGE_FILE_BYTES = 64 * 1024;
6
+
7
+ function isInside(root, target) {
8
+ const path = relative(root, target);
9
+ return path !== '..' && !path.startsWith('..' + sep) && !isAbsolute(path);
10
+ }
11
+
12
+ async function loadDictionary(root, directory, locale, label) {
13
+ const directoryPath = resolve(root, directory);
14
+ const file = resolve(directoryPath, locale + '.json');
15
+ const realRoot = await realpath(root);
16
+ const realDirectory = await realpath(directoryPath);
17
+ if (!isInside(realRoot, realDirectory)) throw new Error(label + ' language directory resolves outside the project root: ' + directoryPath);
18
+ const realFile = await realpath(file).catch((error) => {
19
+ if (error.code === 'ENOENT') throw new Error('Missing ' + label + ' language file: ' + file);
20
+ throw error;
21
+ });
22
+ if (!isInside(realDirectory, realFile)) throw new Error(label + ' language file resolves outside its language directory: ' + file);
23
+ const source = await readFile(realFile);
24
+ if (source.byteLength > MAX_LANGUAGE_FILE_BYTES) throw new Error(label + ' language file exceeds 64 KB: ' + file);
25
+ let parsed;
26
+ try { parsed = JSON.parse(source.toString('utf8')); }
27
+ catch (error) { throw new Error('Invalid JSON in ' + label + ' language file: ' + file, { cause: error }); }
28
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
29
+ throw new Error(label + ' language file must contain an object of string translations: ' + file);
30
+ }
31
+ const dictionary = Object.create(null);
32
+ for (const [id, value] of Object.entries(parsed)) {
33
+ if (!/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/.test(id)) throw new Error('Invalid translation id in ' + file + ': ' + id);
34
+ if (typeof value !== 'string') throw new Error('Translation values must be strings in ' + file + ': ' + id);
35
+ dictionary[id] = value;
36
+ }
37
+ return dictionary;
38
+ }
39
+
40
+ export async function loadLanguagePacks(config) {
41
+ const baseLocale = config.i18n.defaultLocale;
42
+ const translationsByLocale = Object.create(null);
43
+ const builtinRoot=fileURLToPath(new URL('../',import.meta.url)),english=await loadDictionary(builtinRoot,'languages/base','en','Built-in');
44
+ async function defaults(locale){
45
+ if(locale==='en')return english;
46
+ const file=resolve(builtinRoot,'languages/packs',locale+'.json');
47
+ const exists=await stat(file).then(()=>true,error=>{if(error.code==='ENOENT')return false;throw error;});
48
+ return exists?{...english,...await loadDictionary(builtinRoot,'languages/packs',locale,'Built-in')}:english;
49
+ }
50
+ translationsByLocale[baseLocale] = {...await defaults(baseLocale),...await loadDictionary(config.root, 'languages/base', baseLocale, 'Base')};
51
+ for (const locale of config.i18n.languagePacks) {
52
+ translationsByLocale[locale] = {...await defaults(locale),...await loadDictionary(config.root, 'languages/packs', locale, 'Language pack')};
53
+ }
54
+ config.i18n.translationsByLocale = translationsByLocale;
55
+ return translationsByLocale;
56
+ }
57
+
58
+ export function translate(config, locale, key) {
59
+ const base = config.i18n.translationsByLocale?.[config.i18n.defaultLocale];
60
+ const selected = config.i18n.translationsByLocale?.[locale];
61
+ return selected?.[key] ?? base?.[key] ?? key;
62
+ }
63
+
64
+ export function translateValue(config, locale, value) {
65
+ if (typeof value === 'string') return value;
66
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
67
+ return value[locale] ?? value[config.i18n.defaultLocale] ?? Object.values(value).find((text) => typeof text === 'string') ?? '';
68
+ }
69
+
70
+ export function localePrefix(config, locale) {
71
+ return locale === config.i18n.defaultLocale ? '' : '/' + locale;
72
+ }
73
+
74
+ export function localizedUrl(config, locale, path) {
75
+ const prefix = localePrefix(config, locale);
76
+ const suffix = String(path ?? '').replace(/^\/+/, '');
77
+ return prefix + '/' + suffix;
78
+ }
79
+
80
+
81
+ export function localizedContentUrl(config, locale, value, label = 'Content URL') {
82
+ const url=String(value),token=/^\/(?:\[launge\]|%5blaunge%5d)(?=\/|$)/i;
83
+ if(!url.startsWith('/'))return url;
84
+ if(/\[launge\]|%5blaunge%5d/i.test(url)&&!token.test(url))throw new Error(label+' may use [launge] only as the first site-path segment');
85
+ return token.test(url)?localePrefix(config,locale)+(url.replace(token,'')||'/'):url;
86
+ }
package/src/markdown.js CHANGED
@@ -1,106 +1,106 @@
1
- import { marked, Marked } from 'marked';
1
+ import { marked, Marked } from 'marked';
2
2
  import {parseEmbed} from './embed.js';
3
3
  import {parseDocument} from './document.js';
4
- import sanitizeHtml from 'sanitize-html';
5
-
6
- const allowedTags = [
7
- 'a', 'abbr', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', 'blockquote',
8
- 'br', 'caption', 'cite', 'code', 'data', 'dd', 'del', 'details', 'dfn',
9
- 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'h1', 'h2',
10
- 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'i', 'img', 'kbd', 'li', 'main',
11
- 'mark', 'nav', 'ol', 'p', 'pre', 'q', 'rp', 'rt', 'rtc', 'ruby', 's',
12
- 'samp', 'section', 'small', 'source', 'span', 'strong', 'sub', 'summary', 'sup', 'button',
13
- 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'track', 'tr', 'u', 'ul',
14
- 'template', 'var', 'video', 'wbr'
15
- ];
16
-
17
- const sanitizeOptions = {
18
- allowedTags,
19
- allowedAttributes: {
20
- a: ['href', 'target', 'rel'],
4
+ import sanitizeHtml from 'sanitize-html';
5
+
6
+ const allowedTags = [
7
+ 'a', 'abbr', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', 'blockquote',
8
+ 'br', 'caption', 'cite', 'code', 'data', 'dd', 'del', 'details', 'dfn',
9
+ 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'h1', 'h2',
10
+ 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'i', 'img', 'kbd', 'li', 'main',
11
+ 'mark', 'nav', 'ol', 'p', 'pre', 'q', 'rp', 'rt', 'rtc', 'ruby', 's',
12
+ 'samp', 'section', 'small', 'source', 'span', 'strong', 'sub', 'summary', 'sup', 'button',
13
+ 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'track', 'tr', 'u', 'ul',
14
+ 'template', 'var', 'video', 'wbr'
15
+ ];
16
+
17
+ const sanitizeOptions = {
18
+ allowedTags,
19
+ allowedAttributes: {
20
+ a: ['href', 'target', 'rel'],
21
21
  button: ['type', 'data-oembed-load', 'data-service-consent-settings'],
22
- code: ['class'],
23
- img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'data-original'],
22
+ code: ['class'],
23
+ img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'data-original'],
24
24
  figure: ['class', 'data-edgepress-oembed', 'data-oembed-url', 'data-oembed-width', 'data-oembed-height', 'data-oembed-disabled', 'data-oembed-unavailable', 'data-edgepress-document', 'data-document-src', 'data-document-title', 'data-document-format', 'data-document-preview', 'data-document-service'],
25
25
  div: ['class', 'data-oembed-status', 'data-oembed-placeholder', 'aria-hidden', 'data-document-mount'],
26
- p: ['data-oembed-notice', 'class', 'role'],
27
- template: ['data-oembed-data'],
28
- li: ['value'],
29
- ol: ['start'],
30
- source: ['src', 'type'],
31
- span: ['class', 'role', 'aria-label'],
32
- td: ['align', 'colspan', 'rowspan'],
33
- th: ['align', 'colspan', 'rowspan'],
34
- track: ['kind', 'src', 'srclang', 'label', 'default'],
35
- video: ['aria-label', 'controls', 'playsinline', 'poster', 'preload', 'width', 'height']
36
- },
37
- allowedSchemes: ['http', 'https', 'mailto', 'tel'],
38
- allowProtocolRelative: false,
39
- nonTextTags: ['script', 'style', 'textarea', 'option', 'xmp'],
40
- transformTags: {
41
- img: (_tagName,attributes)=>{if(attributes['data-original']){try{attributes['data-original']=safeVideoHref(attributes['data-original']);}catch{delete attributes['data-original'];}}return {tagName:'img',attribs:attributes};},
42
- a: (_tagName, attributes) => ({
43
- tagName: 'a',
44
- attribs: { ...attributes, rel: 'noopener noreferrer' }
45
- })
46
- }
47
- };
48
-
49
- function videoMimeType(href) {
50
- const path = String(href ?? '').split(/[?#]/, 1)[0].toLowerCase();
51
- const extension = path.match(/\.(mp4|webm|ogv|ogg)$/)?.[1];
52
- return ({ mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', ogg: 'video/ogg' })[extension] || '';
53
- }
54
-
55
- function safeVideoHref(value) {
56
- const href = String(value ?? '').trim();
57
- if (!href || /[\x00-\x20\\]/.test(href) || href.startsWith('//')) {
58
- throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs');
59
- }
60
- if (href.startsWith('/')) return href;
61
- let url;
62
- try { url = new URL(href, 'https://edgepress.invalid'); }
63
- catch { throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs'); }
64
- if (url.username || url.password || !['http:', 'https:'].includes(url.protocol)) {
65
- throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs');
66
- }
67
- return href;
68
- }
69
-
70
- function escapeHtml(value) {
71
- return String(value ?? '').replace(/[&<>"']/g, (char) => ({
72
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
73
- })[char]);
74
- }
75
-
76
- function markdownRenderer(options) {
77
- const renderer = new marked.Renderer();
78
- const renderLink=renderer.link;
79
- renderer.link=function(token){return renderLink.call(this,{...token,href:options.linkResolver?options.linkResolver(token.href):token.href});};
80
- const renderImage = renderer.image;
81
- renderer.image = function(token) {
82
- const mimeType = videoMimeType(token.href);
83
- if (mimeType) {
84
- const description = String(token.text ?? '').trim();
85
- if (!description) throw new Error('Markdown videos need descriptive alternative text');
86
- const href = escapeHtml(safeVideoHref(token.href));
87
- if (options.allowVideo === false) return '<span>' + escapeHtml(description) + '</span>';
88
- const caption = token.title ? '<figcaption>' + escapeHtml(token.title) + '</figcaption>' : '';
89
- return '<figure class="markdown-video"><video controls playsinline preload="none" aria-label="' +
90
- escapeHtml(description) + '"><source src="' + href + '" type="' + mimeType + '"><a href="' + href + '">' +
91
- escapeHtml(description) + '</a></video>' + caption + '</figure>';
92
- }
93
- if (options.allowImages === false) return '<span>' + escapeHtml(token.text || '') + '</span>';
94
- if(options.imageResolver){const href=options.imageResolver(token.href);if(!href)return '<span>'+escapeHtml(token.text||'')+'</span>';return renderImage.call(this,{...token,href});}
95
- return renderImage.call(this,token);
96
- };
97
- return renderer;
98
- }
99
-
26
+ p: ['data-oembed-notice', 'class', 'role'],
27
+ template: ['data-oembed-data'],
28
+ li: ['value'],
29
+ ol: ['start'],
30
+ source: ['src', 'type'],
31
+ span: ['class', 'role', 'aria-label'],
32
+ td: ['align', 'colspan', 'rowspan'],
33
+ th: ['align', 'colspan', 'rowspan'],
34
+ track: ['kind', 'src', 'srclang', 'label', 'default'],
35
+ video: ['data-edgepress-video', 'aria-label', 'controls', 'playsinline', 'poster', 'preload', 'width', 'height']
36
+ },
37
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
38
+ allowProtocolRelative: false,
39
+ nonTextTags: ['script', 'style', 'textarea', 'option', 'xmp'],
40
+ transformTags: {
41
+ img: (_tagName,attributes)=>{if(attributes['data-original']){try{attributes['data-original']=safeVideoHref(attributes['data-original']);}catch{delete attributes['data-original'];}}return {tagName:'img',attribs:attributes};},
42
+ a: (_tagName, attributes) => ({
43
+ tagName: 'a',
44
+ attribs: { ...attributes, rel: 'noopener noreferrer' }
45
+ })
46
+ }
47
+ };
48
+
49
+ function videoMimeType(href) {
50
+ const path = String(href ?? '').split(/[?#]/, 1)[0].toLowerCase();
51
+ const extension = path.match(/\.(mp4|webm|ogv|ogg)$/)?.[1];
52
+ return ({ mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', ogg: 'video/ogg' })[extension] || '';
53
+ }
54
+
55
+ function safeVideoHref(value) {
56
+ const href = String(value ?? '').trim();
57
+ if (!href || /[\x00-\x20\\]/.test(href) || href.startsWith('//')) {
58
+ throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs');
59
+ }
60
+ if (href.startsWith('/')) return href;
61
+ let url;
62
+ try { url = new URL(href, 'https://edgepress.invalid'); }
63
+ catch { throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs'); }
64
+ if (url.username || url.password || !['http:', 'https:'].includes(url.protocol)) {
65
+ throw new Error('Markdown video sources must be safe relative paths or HTTP(S) URLs');
66
+ }
67
+ return href;
68
+ }
69
+
70
+ function escapeHtml(value) {
71
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
72
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
73
+ })[char]);
74
+ }
75
+
76
+ function markdownRenderer(options) {
77
+ const renderer = new marked.Renderer();
78
+ const renderLink=renderer.link;
79
+ renderer.link=function(token){return renderLink.call(this,{...token,href:options.linkResolver?options.linkResolver(token.href):token.href});};
80
+ const renderImage = renderer.image;
81
+ renderer.image = function(token) {
82
+ const mimeType = videoMimeType(token.href);
83
+ if (mimeType) {
84
+ const description = String(token.text ?? '').trim();
85
+ if (!description) throw new Error('Markdown videos need descriptive alternative text');
86
+ const href = escapeHtml(safeVideoHref(token.href));
87
+ if (options.allowVideo === false) return '<span>' + escapeHtml(description) + '</span>';
88
+ const caption = token.title ? '<figcaption>' + escapeHtml(token.title) + '</figcaption>' : '';
89
+ return '<figure class="markdown-video"><video data-edgepress-video controls playsinline preload="none" aria-label="' +
90
+ escapeHtml(description) + '"><source src="' + href + '" type="' + mimeType + '"><a href="' + href + '">' +
91
+ escapeHtml(description) + '</a></video>' + caption + '</figure>';
92
+ }
93
+ if (options.allowImages === false) return '<span>' + escapeHtml(token.text || '') + '</span>';
94
+ if(options.imageResolver){const href=options.imageResolver(token.href);if(!href)return '<span>'+escapeHtml(token.text||'')+'</span>';return renderImage.call(this,{...token,href});}
95
+ return renderImage.call(this,token);
96
+ };
97
+ return renderer;
98
+ }
99
+
100
100
  const embedSyntax = {name:'embed',level:'block',
101
- start:source=>{const offset=source.search(/\n {0,3}!embed\[/);return offset<0?undefined:offset+1;},
102
- tokenizer:source=>{const embed=parseEmbed(source);return embed?{type:'embed',...embed}:undefined;},
103
- renderer:()=>''
101
+ start:source=>{const offset=source.search(/\n {0,3}!embed\[/);return offset<0?undefined:offset+1;},
102
+ tokenizer:source=>{const embed=parseEmbed(source);return embed?{type:'embed',...embed}:undefined;},
103
+ renderer:()=>''
104
104
  };
105
105
 
106
106
  const documentSyntax = { name: 'document', level: 'block',
@@ -108,66 +108,101 @@ const documentSyntax = { name: 'document', level: 'block',
108
108
  tokenizer: source => { const parsed = parseDocument(source); return parsed ? { type: 'document', ...parsed } : undefined; },
109
109
  renderer: token => '<p>' + escapeHtml(token.title) + '</p>'
110
110
  };
111
-
112
- export async function renderMarkdown(source, options = {}) {
113
- const parser = new Marked({gfm: options.gfm !== false, breaks: options.breaks === true, renderer: markdownRenderer(options)});
114
- parser.use({ extensions: [{ ...documentSyntax, renderer: token => options.documentRenderer ? options.documentRenderer(token) : documentSyntax.renderer(token) }] });
115
- if (options.embedRenderer) parser.use({
116
- async: true,
117
- extensions: [{...embedSyntax, renderer: token => token.html}],
118
- walkTokens: async token => { if (token.type === 'embed') token.html = await options.embedRenderer(token); }
119
- });
120
- let html = await parser.parse(source);
121
- // GFM task markers become named static status icons. They remain readable to
122
- // assistive technology without exposing disabled, unlabeled form controls.
123
- html = html.replace(/<input\b([^>]*)>/gi, (_match, attributes) => {
124
- const checked = /\bchecked(?:\s|=|$)/i.test(attributes);
125
- const label = checked ? (options.taskLabels?.complete || 'Task complete') :
126
- (options.taskLabels?.incomplete || 'Task incomplete');
127
- const safeLabel = String(label).replace(/[&<>"']/g, (char) => ({
128
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
129
- })[char]);
130
- return '<span class="markdown-task-status" role="img" aria-label="' +
131
- safeLabel + '">' + (checked ? '✓' : '□') + '</span>';
132
- });
133
- return sanitizeHtml(html, options.imageResolver?{...sanitizeOptions,transformTags:{...sanitizeOptions.transformTags,img:(_tag,attributes)=>{
134
- const src=options.imageResolver(attributes.src);
135
- return src?{tagName:'img',attribs:{src,alt:attributes.alt||'',loading:'lazy'}}:{tagName:'span',attribs:{},text:attributes.alt||''};
136
- }}}:sanitizeOptions);
137
- }
138
-
139
- export async function renderMarkdownExcerpt(source, options = {}) {
140
- const markdown = String(source ?? '');
141
- const tokens = new Marked({gfm:options.gfm!==false}).use({extensions:[{...embedSyntax}, {...documentSyntax}]}).lexer(markdown);
142
- const paragraph = tokens.find((token) => token.type === 'paragraph');
143
- if (!paragraph) return '<p>' + escapeHtml(plainText(markdown).slice(0, 220)) + '</p>';
144
- return renderMarkdown(paragraph.raw, { ...options, allowImages: false, allowVideo: false });
145
- }
146
-
147
- export function plainText(source) {
148
- const html = new Marked({gfm:true}).use({extensions:[{...embedSyntax}, {...documentSyntax}]}).parse(String(source ?? ''));
149
- const namedEntities = {
150
- amp: '&', apos: "'", copy: '©', gt: '>', hellip: '…', ldquo: '“', lsquo: '‘',
151
- lt: '<', mdash: '—', nbsp: ' ', ndash: '–', quot: '"', rdquo: '”', rsquo: '’'
152
- };
153
- return String(html)
154
- .replace(/<!--[\s\S]*?-->/g, ' ')
155
- .replace(/<(script|style|textarea|option|xmp)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
156
- .replace(/<img\b([^>]*)>/gi, (_match, attributes) => {
157
- const alt = attributes.match(/\balt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
158
- return alt ? ' ' + (alt[1] ?? alt[2] ?? alt[3]) + ' ' : ' ';
159
- })
160
- .replace(/<[^>]*>/g, ' ')
161
- .replace(/&(#x[\da-f]+|#\d+|[a-z][a-z\d]+);/gi, (match, entity) => {
162
- if (entity[0] === '#') {
163
- const hex = entity[1]?.toLowerCase() === 'x';
164
- const point = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
165
- return Number.isInteger(point) && point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
166
- ? String.fromCodePoint(point)
167
- : match;
168
- }
169
- return namedEntities[entity.toLowerCase()] ?? match;
170
- })
171
- .replace(/\s+/g, ' ')
172
- .trim();
173
- }
111
+
112
+ // CommonMark treats punctuation beside a CJK letter as an intraword delimiter.
113
+ // Accept the bracket/punctuation forms authors use in CJK prose, while letting
114
+ // the ordinary lexer handle escapes, code, links and non-CJK emphasis.
115
+ const cjkStrong = {
116
+ name: 'cjkStrong', level: 'inline', childTokens: ['tokens'],
117
+ start: source => { const index = source.indexOf('**'); return index < 0 ? undefined : index; },
118
+ tokenizer(source) {
119
+ if (!source.startsWith('**') || source.startsWith('***') || /^\*\*\s/.test(source)) return;
120
+ for (let end = source.indexOf('**', 2); end >= 0; end = source.indexOf('**', end + 2)) {
121
+ const text = source.slice(2, end);
122
+ if (source[end + 2] === '*' || /\s$/.test(text) || !text) continue;
123
+ const escapes = text.match(/\\+$/)?.[0].length || 0;
124
+ if (escapes % 2) continue;
125
+ const cjk = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
126
+ const bracketed = /^[(《「『【]/u.test(text) || /[)》」』】]$/u.test(text);
127
+ const cjkBoundary = /\p{P}$/u.test(text) && cjk.test(source[end + 2] || '');
128
+ if (!bracketed && !cjkBoundary) return;
129
+ return { type: 'cjkStrong', raw: source.slice(0, end + 2), text, tokens: this.lexer.inlineTokens(text) };
130
+ }
131
+ },
132
+ renderer(token) { return '<strong>' + this.parser.parseInline(token.tokens) + '</strong>'; }
133
+ };
134
+
135
+ function markdownParser(options = {}) {
136
+ const parser = new Marked({ gfm: options.gfm !== false, breaks: options.breaks === true, renderer: markdownRenderer(options) });
137
+ parser.use({ extensions: [cjkStrong, { ...documentSyntax, renderer: token => options.documentRenderer ? options.documentRenderer(token) : documentSyntax.renderer(token) }] });
138
+ return parser;
139
+ }
140
+
141
+ export function hasMarkdownVideo(source) {
142
+ const parser = markdownParser();
143
+ let found = false;
144
+ parser.walkTokens(parser.lexer(String(source || '')), token => { if (token.type === 'image' && videoMimeType(token.href)) found = true; });
145
+ return found;
146
+ }
147
+
148
+ export async function renderMarkdown(source, options = {}) {
149
+ const parser = markdownParser(options);
150
+ if (options.embedRenderer) parser.use({
151
+ async: true,
152
+ extensions: [{...embedSyntax, renderer: token => token.html}],
153
+ walkTokens: async token => { if (token.type === 'embed') token.html = await options.embedRenderer(token); }
154
+ });
155
+ let html = await parser.parse(source);
156
+ // GFM task markers become named static status icons. They remain readable to
157
+ // assistive technology without exposing disabled, unlabeled form controls.
158
+ html = html.replace(/<input\b([^>]*)>/gi, (_match, attributes) => {
159
+ const checked = /\bchecked(?:\s|=|$)/i.test(attributes);
160
+ const label = checked ? (options.taskLabels?.complete || 'Task complete') :
161
+ (options.taskLabels?.incomplete || 'Task incomplete');
162
+ const safeLabel = String(label).replace(/[&<>"']/g, (char) => ({
163
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
164
+ })[char]);
165
+ return '<span class="markdown-task-status" role="img" aria-label="' +
166
+ safeLabel + '">' + (checked ? '✓' : '□') + '</span>';
167
+ });
168
+ return sanitizeHtml(html, options.imageResolver?{...sanitizeOptions,transformTags:{...sanitizeOptions.transformTags,img:(_tag,attributes)=>{
169
+ const src=options.imageResolver(attributes.src);
170
+ return src?{tagName:'img',attribs:{src,alt:attributes.alt||'',loading:'lazy'}}:{tagName:'span',attribs:{},text:attributes.alt||''};
171
+ }}}:sanitizeOptions);
172
+ }
173
+
174
+ export async function renderMarkdownExcerpt(source, options = {}) {
175
+ const markdown = String(source ?? '');
176
+ const tokens = markdownParser(options).use({extensions:[{...embedSyntax}]}).lexer(markdown);
177
+ const paragraph = tokens.find((token) => token.type === 'paragraph');
178
+ if (!paragraph) return '<p>' + escapeHtml(plainText(markdown).slice(0, 220)) + '</p>';
179
+ return renderMarkdown(paragraph.raw, { ...options, allowImages: false, allowVideo: false });
180
+ }
181
+
182
+ export function plainText(source) {
183
+ const html = markdownParser().use({extensions:[{...embedSyntax}]}).parse(String(source ?? ''));
184
+ const namedEntities = {
185
+ amp: '&', apos: "'", copy: '©', gt: '>', hellip: '…', ldquo: '“', lsquo: '‘',
186
+ lt: '<', mdash: '—', nbsp: ' ', ndash: '–', quot: '"', rdquo: '”', rsquo: '’'
187
+ };
188
+ return String(html)
189
+ .replace(/<!--[\s\S]*?-->/g, ' ')
190
+ .replace(/<(script|style|textarea|option|xmp)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
191
+ .replace(/<img\b([^>]*)>/gi, (_match, attributes) => {
192
+ const alt = attributes.match(/\balt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
193
+ return alt ? ' ' + (alt[1] ?? alt[2] ?? alt[3]) + ' ' : ' ';
194
+ })
195
+ .replace(/<[^>]*>/g, ' ')
196
+ .replace(/&(#x[\da-f]+|#\d+|[a-z][a-z\d]+);/gi, (match, entity) => {
197
+ if (entity[0] === '#') {
198
+ const hex = entity[1]?.toLowerCase() === 'x';
199
+ const point = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
200
+ return Number.isInteger(point) && point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
201
+ ? String.fromCodePoint(point)
202
+ : match;
203
+ }
204
+ return namedEntities[entity.toLowerCase()] ?? match;
205
+ })
206
+ .replace(/\s+/g, ' ')
207
+ .trim();
208
+ }
package/src/page-check.js CHANGED
@@ -96,7 +96,7 @@ export async function markdownFeatureCoverage() {
96
96
  catch { unsafeVideoRejected = true; }
97
97
  const videoChecks = [
98
98
  ['image-shaped video syntax renders an accessible native player with deferred media',
99
- /<video controls playsinline preload="none" aria-label="A kite crossing a field"><source src="https:\/\/media\.example\.org\/field-recording\.mp4" type="video\/mp4"/i.test(videoHtml)],
99
+ /<video\b(?=[^>]*\bcontrols\b)(?=[^>]*\bplaysinline\b)(?=[^>]*\bpreload="none")(?=[^>]*\baria-label="A kite crossing a field")[^>]*><source src="https:\/\/media\.example\.org\/field-recording\.mp4" type="video\/mp4"/i.test(videoHtml)],
100
100
  ['video title renders as a caption', /<figcaption>Wind test<\/figcaption>/i.test(videoHtml)],
101
101
  ['unsafe video URL is rejected', unsafeVideoRejected],
102
102
  ['post-list excerpt preserves inline Markdown formatting', /<strong>formatted<\/strong>[\s\S]*?<em>emphasis<\/em>[\s\S]*?<code>code<\/code>/i.test(excerptHtml)]