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,142 +1,142 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { access, mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
4
- import { tmpdir } from 'node:os';
5
- import { basename, dirname, resolve } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
- import { spawnSync } from 'node:child_process';
8
- import { createServer } from 'node:http';
9
- import { chromium } from 'playwright';
10
- import { markdownFeatureCoverage } from '../src/page-check.js';
11
- import { checkCompatibility } from '../src/maintenance.js';
12
- import { auditBrowser, deviceProfiles } from '../src/browser-audit.js';
13
-
14
- const project = fileURLToPath(new URL('../', import.meta.url));
15
- async function fixture() {
16
- const root = await mkdtemp(resolve(tmpdir(), 'edgepress-audit-test-'));
17
- for (const directory of ['content/pages/home', 'static', 'src', 'languages/base', 'themes/fixture/assets', 'themes/fixture/layouts'])
18
- await mkdir(resolve(root, directory), { recursive: true });
19
- await writeFile(resolve(root, 'edgepress.config.mjs'), 'export default {paths:{theme:"themes/fixture"},i18n:{defaultLocale:"en",languagePacks:[]}};');
20
- await writeFile(resolve(root, 'config.yml'), 'site:\n title: Audit fixture\n url: https://audit.example.org\n navigation:\n - key: front\n url: "@home"\n labels: {en: Home}\n - key: notes\n url: "@archives"\n labels: {en: Notes}\nplugins:\n consent:\n enabled: false\n proposedDate: 2026-10-02\n effectiveDate: 2026-10-02\n');
21
- await writeFile(resolve(root, 'languages/base/en.json'), await readFile(resolve(project, 'languages/base/en.json')));
22
- await writeFile(resolve(root, 'project-compatibility.json'), await readFile(resolve(project, 'project-compatibility.json')));
23
- await writeFile(resolve(root, 'wrangler.jsonc'), JSON.stringify({ main: 'src/index.js', compatibility_date: '2026-10-01', assets: { directory: 'dist', binding: 'ASSETS' } }));
24
- await writeFile(resolve(root, 'src/index.js'), 'export default {fetch(){return new Response("OK")}};');
25
- await writeFile(resolve(root, 'content/pages/home/en.md'), '---\ntitle: Audit fixture\nlang: en\nhomepage: true\nblocks:\n - columns: 1\n cells:\n - - type: hero\n title: Audit fixture\n text: A site with no articles.\n---\n');
26
- await writeFile(resolve(root, 'themes/fixture/layouts/layout.html'), '<html lang="{{site.language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{{page.title}}</title><meta name="description" content="{{page.description}}"><link rel="canonical" href="{{page.canonical}}"><link rel="stylesheet" href="{{site.stylesheet}}"><script type="application/ld+json">{{{page.jsonLd}}}</script></head><body><a class="skip-link" href="#main">Skip to content</a><header><nav aria-label="Main navigation">{{{site.primaryNavigationHtml}}}</nav></header><main id="main" tabindex="-1">{{{page.content}}}</main><footer>Fixture footer</footer></body></html>');
27
- await writeFile(resolve(root, 'themes/fixture/assets/style.css'), 'body{margin:0;min-height:100vh;display:flex;flex-direction:column;font:16px system-ui}main{flex:1;max-width:100%;padding:16px}nav{display:flex;gap:20px}nav a{min-height:44px}a:focus-visible,button:focus-visible,input:focus-visible{outline:3px solid blue;outline-offset:2px}.skip-link{position:absolute;top:-100px}.skip-link:focus{top:0}');
28
- return root;
29
- }
30
- async function cleanup(root) {
31
- assert.equal(dirname(root).toLowerCase(), resolve(tmpdir()).toLowerCase());
32
- assert.ok(basename(root).startsWith('edgepress-audit-test-'));
33
- await rm(root, { recursive: true, force: true });
34
- }
35
-
36
- test('Markdown self-check covers the renderer without any published article', async () => {
37
- const report = await markdownFeatureCoverage();
38
- assert.equal(report.status, 'pass');
39
- assert.equal(report.checks, 26);
40
- assert.equal(report.passed, report.checks);
41
- assert.match(report.documents[0].path, /not published/);
42
- });
43
-
44
- test('doctor reads Wrangler main and reports missing, unsafe or incompatible entries', async () => {
45
- const root = await fixture();
46
- try {
47
- assert.equal((await checkCompatibility({ root })).status, 'pass');
48
- const configFile = resolve(root, 'wrangler.jsonc');
49
- const config = JSON.parse(await readFile(configFile, 'utf8'));
50
- await writeFile(configFile, JSON.stringify({...config,main:undefined}));
51
- assert.equal((await checkCompatibility({root})).status,'pass','Assets-only hosting requires no Worker entry');
52
- for (const entry of [12, 'src/missing.js', project + '/src/worker.js']) {
53
- await writeFile(configFile, JSON.stringify({ ...config, main: entry }));
54
- const report = await checkCompatibility({ root });
55
- assert.equal(report.status, 'fail');
56
- assert.ok(report.issues.some(issue => issue.severity === 'error'));
57
- }
58
- await writeFile(configFile, JSON.stringify(config));
59
- await writeFile(resolve(root, 'src/index.js'), 'import fs from "node:fs";');
60
- assert.match((await checkCompatibility({ root })).issues[0].message, /Node.js built-in/);
61
- } finally { await cleanup(root); }
62
- });
63
-
64
- test('check and doctor work on a page-only project with no posts directory', { timeout: 180000 }, async () => {
65
- const root = await fixture();
66
- try {
67
- for (const command of ['doctor', 'check']) {
68
- const result = spawnSync(process.execPath, [resolve(project, 'src/cli.js'), command], { cwd: root, encoding: 'utf8', timeout: 150000 });
69
- assert.equal(result.status, 0, result.stderr + result.stdout);
70
- assert.doesNotMatch(result.stderr + result.stdout, /syntax guide post was not generated|ENOENT/);
71
- }
72
- await assert.rejects(access(resolve(root, 'content/posts')), { code: 'ENOENT' });
73
- await assert.rejects(access(resolve(root, 'dist/markdown-syntax-guide')), { code: 'ENOENT' });
74
- } finally { await cleanup(root); }
75
- });
76
-
77
- test('ordinary articles need no special tutorial slug to pass the audit', { timeout: 180000 }, async () => {
78
- const root = await fixture();
79
- try {
80
- await mkdir(resolve(root, 'content/posts/note'), { recursive: true });
81
- await writeFile(resolve(root, 'content/posts/note/en.md'), '---\ntitle: My first note\nlang: en\ndate: 2026-10-02\nslug: note\n---\nAn ordinary **Markdown** article.\n');
82
- const result = spawnSync(process.execPath, [resolve(project, 'src/cli.js'), 'check'], { cwd: root, encoding: 'utf8', timeout: 150000 });
83
- assert.equal(result.status, 0, result.stderr + result.stdout);
84
- assert.match(await readFile(resolve(root, 'dist/2026/10/note/index.html'), 'utf8'), /My first note/);
85
- } finally { await cleanup(root); }
86
- });
87
-
88
- async function installedBrowser() {
89
- const candidates = [process.env.EDGEPRESS_BROWSER, 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '/usr/bin/chromium', '/usr/bin/google-chrome', chromium.executablePath()];
90
- for (const path of candidates.filter(Boolean)) { try { await access(path); return path; } catch {} }
91
- }
92
-
93
- test('device/AX/keyboard simulations pass accessible pages and catch inaccessible ones', { timeout: 120000 }, async t => {
94
- const executablePath = await installedBrowser();
95
- if (!executablePath) { t.skip('No installed Chromium browser; browser simulation was not performed.'); return; }
96
- const good = '<!doctype html><html lang="en"><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{margin:0}a:focus-visible,.scroller:focus-visible{outline:3px solid blue}.skip{position:absolute;top:-100px}.skip:focus{top:0}.scroller{max-width:100%;overflow:auto}.wide{width:1000px;height:50px}</style></head><body><a class="skip" href="#main">Skip to main</a><nav aria-label="Navigation"><a href="#section">Read more</a></nav><main id="main" tabindex="-1"><h1>Accessible fixture</h1><h2 id="section">Section</h2><div class="scroller" role="region" aria-label="Scroll one"><div class="wide">Wide content one</div></div><div class="scroller" role="region" aria-label="Scroll two"><div class="wide">Wide content two</div></div></main></body></html>';
97
- const bad = good.replace('aria-label="Navigation"', '').replace('</main>', '<button></button></main>').replace('outline:3px solid blue', 'outline:none');
98
- const trap = good.replace('</main>', '<a href="#section">Unreachable link</a></main>').replace('</body>', '<script>document.addEventListener("keydown",event=>{if(event.key==="Tab"&&!event.shiftKey&&document.activeElement.textContent==="Read more"){event.preventDefault();document.querySelector(".skip").focus();}})</script></body>');
99
- const smooth=good.replace('body{margin:0}','html{scroll-behavior:smooth}button:focus-visible{outline:3px solid blue}body{margin:0}').replace('</main>','<div style="height:2200px"></div><button>After the fold</button></main>');
100
- const server = createServer((request, response) => { response.setHeader('content-type', 'text/html'); response.end(request.url === '/bad' ? bad : request.url === '/trap' ? trap : request.url==='/smooth'?smooth:good); });
101
- await new Promise(done => server.listen(0, '127.0.0.1', done));
102
- const browser = await chromium.launch({ executablePath, headless: true, args: ['--mute-audio', '--disable-notifications'] });
103
- try {
104
- const page = await browser.newPage();
105
- const session = await page.context().newCDPSession(page);
106
- const send = (method, params) => session.send(method, params);
107
- await send('Accessibility.enable');
108
- for (const device of deviceProfiles) for (const colorScheme of ['light', 'dark']) {
109
- const profile = { ...device, colorScheme };
110
- await send('Emulation.setDeviceMetricsOverride', { width: device.width, height: device.height, deviceScaleFactor: device.deviceScaleFactor, mobile: device.mobile });
111
- await send('Emulation.setTouchEmulationEnabled', { enabled: device.touch, maxTouchPoints: 5 });
112
- await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: colorScheme }] });
113
- await page.goto('http://127.0.0.1:' + server.address().port + '/');
114
- const report = await auditBrowser(send, profile);
115
- assert.equal(report.status, 'pass', JSON.stringify(report.checks.filter(item => !item.passed)));
116
- }
117
- await page.goto('http://127.0.0.1:' + server.address().port + '/bad');
118
- const report = await auditBrowser(send, { ...deviceProfiles[2], colorScheme: 'dark' });
119
- assert.equal(report.status, 'fail');
120
- assert.ok(report.checks.some(item => item.group === 'semantics' && !item.passed));
121
- assert.ok(report.checks.some(item => item.group === 'keyboard' && !item.passed));
122
- await page.goto('http://127.0.0.1:' + server.address().port + '/trap');
123
- const trapped = await auditBrowser(send, { ...deviceProfiles[2], colorScheme: 'dark' });
124
- assert.equal(trapped.status, 'fail');
125
- assert.ok(trapped.checks.some(item => item.name === 'Native page wrap does not skip tabbable controls' && !item.passed));
126
- await page.goto('http://127.0.0.1:'+server.address().port+'/smooth');
127
- const scrolled=await auditBrowser(send,{...deviceProfiles[2],colorScheme:'dark'});
128
- assert.equal(scrolled.status,'pass',JSON.stringify(scrolled.checks.filter(item=>!item.passed)));
129
- assert.ok(scrolled.trace.some(item=>item.name==='After the fold'));
130
-
131
- } finally { await browser.close(); await new Promise(done => server.close(done)); }
132
- });
133
-
134
-
135
- test('clean clears generated cache even when there is no reports directory',async()=>{
136
- const root=await fixture();
137
- try{
138
- await mkdir(resolve(root,'.edgepress/stale'),{recursive:true});await writeFile(resolve(root,'.edgepress/stale/output'),'cached');
139
- const result=spawnSync(process.execPath,[resolve(project,'src/cli.js'),'clean'],{cwd:root,encoding:'utf8',windowsHide:true});
140
- assert.equal(result.status,0,result.stderr);await assert.rejects(access(resolve(root,'.edgepress/stale')),error=>error.code==='ENOENT');
141
- }finally{await cleanup(root);}
142
- });
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { access, mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, dirname, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { createServer } from 'node:http';
9
+ import { chromium } from 'playwright';
10
+ import { markdownFeatureCoverage } from '../src/page-check.js';
11
+ import { checkCompatibility } from '../src/maintenance.js';
12
+ import { auditBrowser, deviceProfiles } from '../src/browser-audit.js';
13
+
14
+ const project = fileURLToPath(new URL('../', import.meta.url));
15
+ async function fixture() {
16
+ const root = await mkdtemp(resolve(tmpdir(), 'edgepress-audit-test-'));
17
+ for (const directory of ['content/pages/home', 'static', 'src', 'languages/base', 'themes/fixture/assets', 'themes/fixture/layouts'])
18
+ await mkdir(resolve(root, directory), { recursive: true });
19
+ await writeFile(resolve(root, 'edgepress.config.mjs'), 'export default {paths:{theme:"themes/fixture"},i18n:{defaultLocale:"en",languagePacks:[]}};');
20
+ await writeFile(resolve(root, 'config.yml'), 'site:\n title: Audit fixture\n url: https://audit.example.org\n navigation:\n - key: front\n url: "@home"\n labels: {en: Home}\n - key: notes\n url: "@archives"\n labels: {en: Notes}\nplugins:\n consent:\n enabled: false\n proposedDate: 2026-10-02\n effectiveDate: 2026-10-02\n');
21
+ await writeFile(resolve(root, 'languages/base/en.json'), await readFile(resolve(project, 'languages/base/en.json')));
22
+ await writeFile(resolve(root, 'project-compatibility.json'), await readFile(resolve(project, 'project-compatibility.json')));
23
+ await writeFile(resolve(root, 'wrangler.jsonc'), JSON.stringify({ main: 'src/index.js', compatibility_date: '2026-10-01', assets: { directory: 'dist', binding: 'ASSETS' } }));
24
+ await writeFile(resolve(root, 'src/index.js'), 'export default {fetch(){return new Response("OK")}};');
25
+ await writeFile(resolve(root, 'content/pages/home/en.md'), '---\ntitle: Audit fixture\nlang: en\nhomepage: true\nblocks:\n - columns: 1\n cells:\n - - type: hero\n title: Audit fixture\n text: A site with no articles.\n---\n');
26
+ await writeFile(resolve(root, 'themes/fixture/layouts/layout.html'), '<html lang="{{site.language}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{{page.title}}</title><meta name="description" content="{{page.description}}"><link rel="canonical" href="{{page.canonical}}"><link rel="stylesheet" href="{{site.stylesheet}}"><script type="application/ld+json">{{{page.jsonLd}}}</script></head><body><a class="skip-link" href="#main">Skip to content</a><header><nav aria-label="Main navigation">{{{site.primaryNavigationHtml}}}</nav></header><main id="main" tabindex="-1">{{{page.content}}}</main><footer>Fixture footer</footer></body></html>');
27
+ await writeFile(resolve(root, 'themes/fixture/assets/style.css'), 'body{margin:0;min-height:100vh;display:flex;flex-direction:column;font:16px system-ui}main{flex:1;max-width:100%;padding:16px}nav{display:flex;gap:20px}nav a{min-height:44px}a:focus-visible,button:focus-visible,input:focus-visible{outline:3px solid blue;outline-offset:2px}.skip-link{position:absolute;top:-100px}.skip-link:focus{top:0}');
28
+ return root;
29
+ }
30
+ async function cleanup(root) {
31
+ assert.equal(dirname(root).toLowerCase(), resolve(tmpdir()).toLowerCase());
32
+ assert.ok(basename(root).startsWith('edgepress-audit-test-'));
33
+ await rm(root, { recursive: true, force: true });
34
+ }
35
+
36
+ test('Markdown self-check covers the renderer without any published article', async () => {
37
+ const report = await markdownFeatureCoverage();
38
+ assert.equal(report.status, 'pass');
39
+ assert.equal(report.checks, 26);
40
+ assert.equal(report.passed, report.checks);
41
+ assert.match(report.documents[0].path, /not published/);
42
+ });
43
+
44
+ test('doctor reads Wrangler main and reports missing, unsafe or incompatible entries', async () => {
45
+ const root = await fixture();
46
+ try {
47
+ assert.equal((await checkCompatibility({ root })).status, 'pass');
48
+ const configFile = resolve(root, 'wrangler.jsonc');
49
+ const config = JSON.parse(await readFile(configFile, 'utf8'));
50
+ await writeFile(configFile, JSON.stringify({...config,main:undefined}));
51
+ assert.equal((await checkCompatibility({root})).status,'pass','Assets-only hosting requires no Worker entry');
52
+ for (const entry of [12, 'src/missing.js', project + '/src/worker.js']) {
53
+ await writeFile(configFile, JSON.stringify({ ...config, main: entry }));
54
+ const report = await checkCompatibility({ root });
55
+ assert.equal(report.status, 'fail');
56
+ assert.ok(report.issues.some(issue => issue.severity === 'error'));
57
+ }
58
+ await writeFile(configFile, JSON.stringify(config));
59
+ await writeFile(resolve(root, 'src/index.js'), 'import fs from "node:fs";');
60
+ assert.match((await checkCompatibility({ root })).issues[0].message, /Node.js built-in/);
61
+ } finally { await cleanup(root); }
62
+ });
63
+
64
+ test('check and doctor work on a page-only project with no posts directory', { timeout: 180000 }, async () => {
65
+ const root = await fixture();
66
+ try {
67
+ for (const command of ['doctor', 'check']) {
68
+ const result = spawnSync(process.execPath, [resolve(project, 'src/cli.js'), command], { cwd: root, encoding: 'utf8', timeout: 150000 });
69
+ assert.equal(result.status, 0, result.stderr + result.stdout);
70
+ assert.doesNotMatch(result.stderr + result.stdout, /syntax guide post was not generated|ENOENT/);
71
+ }
72
+ await assert.rejects(access(resolve(root, 'content/posts')), { code: 'ENOENT' });
73
+ await assert.rejects(access(resolve(root, 'dist/markdown-syntax-guide')), { code: 'ENOENT' });
74
+ } finally { await cleanup(root); }
75
+ });
76
+
77
+ test('ordinary articles need no special tutorial slug to pass the audit', { timeout: 180000 }, async () => {
78
+ const root = await fixture();
79
+ try {
80
+ await mkdir(resolve(root, 'content/posts/note'), { recursive: true });
81
+ await writeFile(resolve(root, 'content/posts/note/en.md'), '---\ntitle: My first note\nlang: en\ndate: 2026-10-02\nslug: note\n---\nAn ordinary **Markdown** article.\n');
82
+ const result = spawnSync(process.execPath, [resolve(project, 'src/cli.js'), 'check'], { cwd: root, encoding: 'utf8', timeout: 150000 });
83
+ assert.equal(result.status, 0, result.stderr + result.stdout);
84
+ assert.match(await readFile(resolve(root, 'dist/2026/10/note/index.html'), 'utf8'), /My first note/);
85
+ } finally { await cleanup(root); }
86
+ });
87
+
88
+ async function installedBrowser() {
89
+ const candidates = [process.env.EDGEPRESS_BROWSER, 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', '/usr/bin/chromium', '/usr/bin/google-chrome', chromium.executablePath()];
90
+ for (const path of candidates.filter(Boolean)) { try { await access(path); return path; } catch {} }
91
+ }
92
+
93
+ test('device/AX/keyboard simulations pass accessible pages and catch inaccessible ones', { timeout: 120000 }, async t => {
94
+ const executablePath = await installedBrowser();
95
+ if (!executablePath) { t.skip('No installed Chromium browser; browser simulation was not performed.'); return; }
96
+ const good = '<!doctype html><html lang="en"><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>body{margin:0}a:focus-visible,.scroller:focus-visible{outline:3px solid blue}.skip{position:absolute;top:-100px}.skip:focus{top:0}.scroller{max-width:100%;overflow:auto}.wide{width:1000px;height:50px}</style></head><body><a class="skip" href="#main">Skip to main</a><nav aria-label="Navigation"><a href="#section">Read more</a></nav><main id="main" tabindex="-1"><h1>Accessible fixture</h1><h2 id="section">Section</h2><div class="scroller" role="region" aria-label="Scroll one"><div class="wide">Wide content one</div></div><div class="scroller" role="region" aria-label="Scroll two"><div class="wide">Wide content two</div></div></main></body></html>';
97
+ const bad = good.replace('aria-label="Navigation"', '').replace('</main>', '<button></button></main>').replace('outline:3px solid blue', 'outline:none');
98
+ const trap = good.replace('</main>', '<a href="#section">Unreachable link</a></main>').replace('</body>', '<script>document.addEventListener("keydown",event=>{if(event.key==="Tab"&&!event.shiftKey&&document.activeElement.textContent==="Read more"){event.preventDefault();document.querySelector(".skip").focus();}})</script></body>');
99
+ const smooth=good.replace('body{margin:0}','html{scroll-behavior:smooth}button:focus-visible{outline:3px solid blue}body{margin:0}').replace('</main>','<div style="height:2200px"></div><button>After the fold</button></main>');
100
+ const server = createServer((request, response) => { response.setHeader('content-type', 'text/html'); response.end(request.url === '/bad' ? bad : request.url === '/trap' ? trap : request.url==='/smooth'?smooth:good); });
101
+ await new Promise(done => server.listen(0, '127.0.0.1', done));
102
+ const browser = await chromium.launch({ executablePath, headless: true, args: ['--mute-audio', '--disable-notifications'] });
103
+ try {
104
+ const page = await browser.newPage();
105
+ const session = await page.context().newCDPSession(page);
106
+ const send = (method, params) => session.send(method, params);
107
+ await send('Accessibility.enable');
108
+ for (const device of deviceProfiles) for (const colorScheme of ['light', 'dark']) {
109
+ const profile = { ...device, colorScheme };
110
+ await send('Emulation.setDeviceMetricsOverride', { width: device.width, height: device.height, deviceScaleFactor: device.deviceScaleFactor, mobile: device.mobile });
111
+ await send('Emulation.setTouchEmulationEnabled', { enabled: device.touch, maxTouchPoints: 5 });
112
+ await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: colorScheme }] });
113
+ await page.goto('http://127.0.0.1:' + server.address().port + '/');
114
+ const report = await auditBrowser(send, profile);
115
+ assert.equal(report.status, 'pass', JSON.stringify(report.checks.filter(item => !item.passed)));
116
+ }
117
+ await page.goto('http://127.0.0.1:' + server.address().port + '/bad');
118
+ const report = await auditBrowser(send, { ...deviceProfiles[2], colorScheme: 'dark' });
119
+ assert.equal(report.status, 'fail');
120
+ assert.ok(report.checks.some(item => item.group === 'semantics' && !item.passed));
121
+ assert.ok(report.checks.some(item => item.group === 'keyboard' && !item.passed));
122
+ await page.goto('http://127.0.0.1:' + server.address().port + '/trap');
123
+ const trapped = await auditBrowser(send, { ...deviceProfiles[2], colorScheme: 'dark' });
124
+ assert.equal(trapped.status, 'fail');
125
+ assert.ok(trapped.checks.some(item => item.name === 'Native page wrap does not skip tabbable controls' && !item.passed));
126
+ await page.goto('http://127.0.0.1:'+server.address().port+'/smooth');
127
+ const scrolled=await auditBrowser(send,{...deviceProfiles[2],colorScheme:'dark'});
128
+ assert.equal(scrolled.status,'pass',JSON.stringify(scrolled.checks.filter(item=>!item.passed)));
129
+ assert.ok(scrolled.trace.some(item=>item.name==='After the fold'));
130
+
131
+ } finally { await browser.close(); await new Promise(done => server.close(done)); }
132
+ });
133
+
134
+
135
+ test('clean clears generated cache even when there is no reports directory',async()=>{
136
+ const root=await fixture();
137
+ try{
138
+ await mkdir(resolve(root,'.edgepress/stale'),{recursive:true});await writeFile(resolve(root,'.edgepress/stale/output'),'cached');
139
+ const result=spawnSync(process.execPath,[resolve(project,'src/cli.js'),'clean'],{cwd:root,encoding:'utf8',windowsHide:true});
140
+ assert.equal(result.status,0,result.stderr);await assert.rejects(access(resolve(root,'.edgepress/stale')),error=>error.code==='ENOENT');
141
+ }finally{await cleanup(root);}
142
+ });
@@ -1,110 +1,110 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { fileURLToPath } from 'node:url';
4
- import { loadConfig } from '../src/config.js';
5
- import { loadLanguagePacks } from '../src/i18n.js';
6
- import { generateBuiltinRoutes } from '../src/generators.js';
7
- import { renderIcon } from '../src/icons.js';
8
- import { renderBlocks } from '../src/page-blocks.js';
9
-
10
- const root = fileURLToPath(new URL('../', import.meta.url));
11
- const extensions = { filter: async (_name, value) => value };
12
- async function configForTest() {
13
- const config = await loadConfig(root);
14
- await loadLanguagePacks(config);
15
- config.pagination.perPage = 1;
16
- config.site.url = 'https://fixture.example.org';
17
- config.site.seo.image = '/edgepress/brand/og-image.png';
18
- return config;
19
- }
20
- async function fixtureRoutes() {
21
- const config = await configForTest();
22
- const posts = Array.from({ length: 32 }, (_, index) => ({
23
- locale: 'en', title: 'Entry ' + index, date: new Date(Date.UTC(2026, 0, index + 1)),
24
- path: 'entries/' + index + '/', bundlePath: 'posts/entry-' + index, tags: [], description: 'A readable summary.',
25
- markdown: 'A readable summary.', html: '<p>A readable summary.</p>'
26
- }));
27
- return generateBuiltinRoutes({ posts, pages: [] }, config, extensions);
28
- }
29
-
30
- test('journal routes exclude project categories while article profiles and tags remain visible', async () => {
31
- const config = await configForTest();
32
- config.site.archive.categories = ['博客', '未分类'];
33
- config.site.author = { name: 'Site author', avatar: '/images/authors/default.png' };
34
- config.site.navigation = [{ key: 'projects', url: '/projects/', labels: { en: 'Projects', 'zh-CN': '项目' },
35
- children: [{ key: 'edgepress', url: '/edgepress/', labels: { en: 'EdgePress' } }] }];
36
- const posts = ['blog', undefined, 'edgepress'].map((category, index) => ({
37
- locale: 'en', category, title: 'Entry ' + index, date: new Date('2026-10-02'),
38
- path: 'entries/' + index + '/', bundlePath: 'entry-' + index, tags: ['<unsafe>', 'edgepress'],
39
- author: index === 0 ? 'Guest <author>' : '', authorAvatar: index === 0 ? '/images/authors/guest.png' : '',
40
- description: 'Readable summary', markdown: 'word '.repeat(201), html: '<p>Readable text</p>'
41
- }));
42
- const routes = await generateBuiltinRoutes({ posts, pages: [] }, config, extensions);
43
- const get = path => routes.find(route => route.path === path)?.body;
44
- assert.equal(routes.filter(route => /^archives\/(?:page\/\d+\/)?index.html$/.test(route.path)).length, 2);
45
- for (const route of routes.filter(route => /^archives\//.test(route.path))) assert.doesNotMatch(route.body, /href="\/entries\/2\/"/);
46
- assert.match(get('categories/edgepress/index.html'), /href="\/entries\/2\/"/);
47
- assert.ok(routes.every(route => !route.path.includes('tags/')));
48
- const article = get('entries/0/index.html');
49
- assert.match(article, /Guest &lt;author&gt;/);
50
- assert.match(article, /class="author-avatar" src="\/images\/authors\/guest.png" width="40" height="40" alt=""/);
51
- assert.match(article, /data-reading-minutes="2"/);
52
- assert.match(article, /class="post-tag">&lt;unsafe&gt;<\/span>/);
53
- assert.doesNotMatch(article, /rel="tag"|<script>unsafe/);
54
- assert.match(get('entries/1/index.html'), /Site author/);
55
- assert.match(get('zh-CN/archives/index.html'), /value="\/zh-CN\/edgepress\/"/);
56
- assert.match(article, /data-navigation-select aria-label="Projects"/);
57
- assert.match(article, /<noscript><a href="\/projects\/">Projects<\/a><a href="\/edgepress\/">EdgePress<\/a><\/noscript>/);
58
- assert.match(article, /src="\/edgepress\/navigation-select.js"/);
59
- });
60
-
61
- test('library icon paths accept only known names and safe CSS classes', () => {
62
- assert.match(renderIcon('book-open', 'toc-icon compact'), /class="toc-icon compact icon-library"/);
63
- assert.match(renderIcon('book-open'), /src="\/edgepress\/icons\/book-open\.svg"[^>]*alt="" aria-hidden="true"/);
64
- for (const name of ['../search', '__proto__', 'home?x', null]) assert.throws(() => renderIcon(name));
65
- assert.throws(() => renderIcon('home', 'icon" onerror="alert(1)'));
66
- assert.doesNotMatch(renderIcon('home'), /<svg|<use/);
67
- });
68
-
69
- test('native archives have numbered pages, gaps and correct first/last navigation', async () => {
70
- const routes = await fixtureRoutes();
71
- const get = (path) => routes.find((route) => route.path === path)?.body;
72
- const middle = get('archives/page/14/index.html');
73
- assert.ok(middle);
74
- assert.match(middle, /class="post-list archive-post-list"/);
75
- assert.match(middle, /aria-current="page">14<\/span>/);
76
- assert.match(middle, /href="\/archives\/" aria-label="Page 1"/);
77
- assert.match(middle, /href="\/archives\/page\/32\/" aria-label="Page 32"/);
78
- assert.equal((middle.match(/class="pagination-ellipsis"/g) || []).length, 2);
79
- assert.match(middle, /rel="prev" href="\/archives\/page\/13\/"/);
80
- assert.match(middle, /rel="next" href="\/archives\/page\/15\/"/);
81
- assert.doesNotMatch(get('archives/index.html'), /rel="prev"/);
82
- assert.doesNotMatch(get('archives/page/32/index.html'), /rel="next"/);
83
- });
84
-
85
- test('post lists omit redundant language codes and label fallback languages by name', async () => {
86
- const routes = await fixtureRoutes();
87
- const same = routes.find((route) => route.path === 'archives/index.html').body;
88
- const fallback = routes.find((route) => route.path === 'zh-CN/archives/index.html').body;
89
- assert.doesNotMatch(same, /class="post-language"/);
90
- assert.match(fallback, /class="post-language" lang="en">English<\/span>/);
91
- assert.doesNotMatch(fallback, />en<\/span>|>zh-CN<\/span>/);
92
- assert.match(fallback, /aria-label="第 2 页"/);
93
- assert.match(fallback, /<article class="post-card" lang="en">/);
94
- });
95
-
96
- test('homepage artwork supports a typed WebP source and rejects unsafe URLs', async () => {
97
- const config = await configForTest();
98
- const hero = { type: 'hero', title: 'Hello', bannerSrc: '/home.png', bannerAlt: 'Friendly bears',
99
- bannerWidth: 1536, bannerHeight: 1024, bannerWebpSrcset: [{ src: '/home.webp', width: 1536 }] };
100
- const render = (value) => renderBlocks([{ columns: 1, cells: [[value]] }], { config, locale: 'en', site: { posts: [], pages: [] }, isHomepage: true });
101
- assert.match(await render(hero), /<picture class="hero-banner-picture"><source type="image\/webp"/);
102
- await assert.rejects(render({ ...hero, bannerWebpSrcset: [{ src: 'javascript:alert(1)', width: 1536 }] }));
103
- await assert.rejects(render({ ...hero, bannerWidth: 0 }));
104
- });
105
-
106
- test('share metadata resolves local images against the configured site URL', async () => {
107
- const routes = await fixtureRoutes();
108
- assert.match(routes.find((route) => route.path === 'index.html').body,
109
- /property="og:image" content="https:\/\/fixture\.example\.org\/edgepress\/brand\/og-image\.png"/);
110
- });
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadConfig } from '../src/config.js';
5
+ import { loadLanguagePacks } from '../src/i18n.js';
6
+ import { generateBuiltinRoutes } from '../src/generators.js';
7
+ import { renderIcon } from '../src/icons.js';
8
+ import { renderBlocks } from '../src/page-blocks.js';
9
+
10
+ const root = fileURLToPath(new URL('../', import.meta.url));
11
+ const extensions = { filter: async (_name, value) => value };
12
+ async function configForTest() {
13
+ const config = await loadConfig(root);
14
+ await loadLanguagePacks(config);
15
+ config.pagination.perPage = 1;
16
+ config.site.url = 'https://fixture.example.org';
17
+ config.site.seo.image = '/edgepress/brand/og-image.png';
18
+ return config;
19
+ }
20
+ async function fixtureRoutes() {
21
+ const config = await configForTest();
22
+ const posts = Array.from({ length: 32 }, (_, index) => ({
23
+ locale: 'en', title: 'Entry ' + index, date: new Date(Date.UTC(2026, 0, index + 1)),
24
+ path: 'entries/' + index + '/', bundlePath: 'posts/entry-' + index, tags: [], description: 'A readable summary.',
25
+ markdown: 'A readable summary.', html: '<p>A readable summary.</p>'
26
+ }));
27
+ return generateBuiltinRoutes({ posts, pages: [] }, config, extensions);
28
+ }
29
+
30
+ test('journal routes exclude project categories while article profiles and tags remain visible', async () => {
31
+ const config = await configForTest();
32
+ config.site.archive.categories = ['博客', '未分类'];
33
+ config.site.author = { name: 'Site author', avatar: '/images/authors/default.png' };
34
+ config.site.navigation = [{ key: 'projects', url: '/projects/', labels: { en: 'Projects', 'zh-CN': '项目' },
35
+ children: [{ key: 'edgepress', url: '/edgepress/', labels: { en: 'EdgePress' } }] }];
36
+ const posts = ['blog', undefined, 'edgepress'].map((category, index) => ({
37
+ locale: 'en', category, title: 'Entry ' + index, date: new Date('2026-10-02'),
38
+ path: 'entries/' + index + '/', bundlePath: 'entry-' + index, tags: ['<unsafe>', 'edgepress'],
39
+ author: index === 0 ? 'Guest <author>' : '', authorAvatar: index === 0 ? '/images/authors/guest.png' : '',
40
+ description: 'Readable summary', markdown: 'word '.repeat(201), html: '<p>Readable text</p>'
41
+ }));
42
+ const routes = await generateBuiltinRoutes({ posts, pages: [] }, config, extensions);
43
+ const get = path => routes.find(route => route.path === path)?.body;
44
+ assert.equal(routes.filter(route => /^archives\/(?:page\/\d+\/)?index.html$/.test(route.path)).length, 2);
45
+ for (const route of routes.filter(route => /^archives\//.test(route.path))) assert.doesNotMatch(route.body, /href="\/entries\/2\/"/);
46
+ assert.match(get('categories/edgepress/index.html'), /href="\/entries\/2\/"/);
47
+ assert.ok(routes.every(route => !route.path.includes('tags/')));
48
+ const article = get('entries/0/index.html');
49
+ assert.match(article, /Guest &lt;author&gt;/);
50
+ assert.match(article, /class="author-avatar" src="\/images\/authors\/guest.png" width="40" height="40" alt=""/);
51
+ assert.match(article, /data-reading-minutes="2"/);
52
+ assert.match(article, /class="post-tag">&lt;unsafe&gt;<\/span>/);
53
+ assert.doesNotMatch(article, /rel="tag"|<script>unsafe/);
54
+ assert.match(get('entries/1/index.html'), /Site author/);
55
+ assert.match(get('zh-CN/archives/index.html'), /value="\/zh-CN\/edgepress\/"/);
56
+ assert.match(article, /data-navigation-select aria-label="Projects"/);
57
+ assert.match(article, /<noscript><a href="\/projects\/">Projects<\/a><a href="\/edgepress\/">EdgePress<\/a><\/noscript>/);
58
+ assert.match(article, /src="\/edgepress\/navigation-select.js"/);
59
+ });
60
+
61
+ test('library icon paths accept only known names and safe CSS classes', () => {
62
+ assert.match(renderIcon('book-open', 'toc-icon compact'), /class="toc-icon compact icon-library"/);
63
+ assert.match(renderIcon('book-open'), /src="\/edgepress\/icons\/book-open\.svg"[^>]*alt="" aria-hidden="true"/);
64
+ for (const name of ['../search', '__proto__', 'home?x', null]) assert.throws(() => renderIcon(name));
65
+ assert.throws(() => renderIcon('home', 'icon" onerror="alert(1)'));
66
+ assert.doesNotMatch(renderIcon('home'), /<svg|<use/);
67
+ });
68
+
69
+ test('native archives have numbered pages, gaps and correct first/last navigation', async () => {
70
+ const routes = await fixtureRoutes();
71
+ const get = (path) => routes.find((route) => route.path === path)?.body;
72
+ const middle = get('archives/page/14/index.html');
73
+ assert.ok(middle);
74
+ assert.match(middle, /class="post-list archive-post-list"/);
75
+ assert.match(middle, /aria-current="page">14<\/span>/);
76
+ assert.match(middle, /href="\/archives\/" aria-label="Page 1"/);
77
+ assert.match(middle, /href="\/archives\/page\/32\/" aria-label="Page 32"/);
78
+ assert.equal((middle.match(/class="pagination-ellipsis"/g) || []).length, 2);
79
+ assert.match(middle, /rel="prev" href="\/archives\/page\/13\/"/);
80
+ assert.match(middle, /rel="next" href="\/archives\/page\/15\/"/);
81
+ assert.doesNotMatch(get('archives/index.html'), /rel="prev"/);
82
+ assert.doesNotMatch(get('archives/page/32/index.html'), /rel="next"/);
83
+ });
84
+
85
+ test('post lists omit redundant language codes and label fallback languages by name', async () => {
86
+ const routes = await fixtureRoutes();
87
+ const same = routes.find((route) => route.path === 'archives/index.html').body;
88
+ const fallback = routes.find((route) => route.path === 'zh-CN/archives/index.html').body;
89
+ assert.doesNotMatch(same, /class="post-language"/);
90
+ assert.match(fallback, /class="post-language" lang="en">English<\/span>/);
91
+ assert.doesNotMatch(fallback, />en<\/span>|>zh-CN<\/span>/);
92
+ assert.match(fallback, /aria-label="第 2 页"/);
93
+ assert.match(fallback, /<article class="post-card" lang="en">/);
94
+ });
95
+
96
+ test('homepage artwork supports a typed WebP source and rejects unsafe URLs', async () => {
97
+ const config = await configForTest();
98
+ const hero = { type: 'hero', title: 'Hello', bannerSrc: '/home.png', bannerAlt: 'Friendly bears',
99
+ bannerWidth: 1536, bannerHeight: 1024, bannerWebpSrcset: [{ src: '/home.webp', width: 1536 }] };
100
+ const render = (value) => renderBlocks([{ columns: 1, cells: [[value]] }], { config, locale: 'en', site: { posts: [], pages: [] }, isHomepage: true });
101
+ assert.match(await render(hero), /<picture class="hero-banner-picture"><source type="image\/webp"/);
102
+ await assert.rejects(render({ ...hero, bannerWebpSrcset: [{ src: 'javascript:alert(1)', width: 1536 }] }));
103
+ await assert.rejects(render({ ...hero, bannerWidth: 0 }));
104
+ });
105
+
106
+ test('share metadata resolves local images against the configured site URL', async () => {
107
+ const routes = await fixtureRoutes();
108
+ assert.match(routes.find((route) => route.path === 'index.html').body,
109
+ /property="og:image" content="https:\/\/fixture\.example\.org\/edgepress\/brand\/og-image\.png"/);
110
+ });
@@ -12,9 +12,9 @@ test('cloud dependency is pinned to a published source commit and excludes the r
12
12
  assert.equal(lock.packages['node_modules/@jsw-teams/document-viewer'].resolved, source);
13
13
  assert.ok(!manifest.files.includes('vendor'));
14
14
  assert.ok(!lock.packages['node_modules/@file-viewer/ppt']);
15
- assert.equal(manifest.overrides['@file-viewer/doc'].dompurify, '3.4.16');
15
+ assert.equal(manifest.overrides['@file-viewer/doc'].dompurify, '3.4.17');
16
16
  const sanitizerVersions = Object.entries(lock.packages).filter(([path]) => path.endsWith('node_modules/dompurify')).map(([, dependency]) => dependency.version);
17
- assert.deepEqual(sanitizerVersions, ['3.4.16']);
17
+ assert.deepEqual(sanitizerVersions, ['3.4.17']);
18
18
  const require = createRequire(import.meta.url);
19
19
  const root = resolve(dirname(require.resolve('@jsw-teams/document-viewer')), '..');
20
20
  const component = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));