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,68 +1,68 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import {loadConfig} from '../src/config.js';
4
- import {loadLanguagePacks} from '../src/i18n.js';
5
- import {createExtensions} from '../src/plugin-api.js';
6
- import {renderBlocks} from '../src/page-blocks.js';
7
-
8
- test('posts and Pages share validated embed lines, with inert code examples and disabled services', async () => {
9
- const config = await loadConfig(process.cwd());
10
- await loadLanguagePacks(config);
11
- const service = {id:'youtube', provider:'oembed', backendUrl:'https://www.youtube.com', sourceOrigins:['https://www.youtube.com'], embedOrigins:['https://www.youtube.com'], name:'YouTube', embedPathPattern:'^/watch$', embedTemplate:'https://www.youtube.com/embed/example'};
12
- // The fixture uses a local template and never contacts a real provider.
13
- delete service.embedPathPattern;
14
- config.browserPlugins.services = [service];
15
- const renderer = (await createExtensions(config)).renderer('.md');
16
- const source = '!embed[youtube](https://www.youtube.com/watch?v=example)';
17
- const article = await renderer.render('Before\n' + source + '\nAfter', {config, document:{locale:'en'}});
18
- const page = await renderBlocks([{columns:1, cells:[[{type:'text', text:'Before\n' + source + '\nAfter\n<script>unsafe</script>'}]]}], {config, locale:'en'});
19
- for (const html of [article, page]) {
20
- assert.match(html, /data-edgepress-oembed="youtube"/);
21
- assert.match(html, /data-oembed-data/);
22
- assert.doesNotMatch(html, /<iframe|<script|<p>\s*<figure/);
23
- assert.match(html, /Before/); assert.match(html, /After/);
24
- }
25
- assert.match(page, /&lt;script&gt;unsafe/);
26
- for (const example of ['`' + source + '`', '```md\n' + source + '\n```', '\\' + source]) {
27
- assert.doesNotMatch(await renderer.render(example, {config, document:{locale:'en'}}), /data-edgepress-oembed/);
28
- }
29
- for (const unsafe of ['https://evil.example/video', 'https://user:password@www.youtube.com/watch', 'http://www.youtube.com/watch']) {
30
- await assert.rejects(renderer.render('!embed[youtube](' + unsafe + ')', {config, document:{locale:'en'}}), /registered HTTPS/);
31
- }
32
- await assert.rejects(renderer.render('!embed[unknown](https://www.youtube.com/watch)', {config, document:{locale:'en'}}), /registered oembed service/);
33
- service.enabled = false;
34
- const disabled = await renderer.render(source, {config, document:{locale:'en'}});
35
- assert.match(disabled, /data-oembed-disabled/);
36
- assert.doesNotMatch(disabled, /data-edgepress-oembed|data-oembed-data|<iframe/);
37
- });
38
-
39
-
40
- test('article summaries and search text omit embeds while retaining prose and code examples',async()=>{
41
- const {renderMarkdownExcerpt,plainText}=await import('../src/markdown.js');
42
- const source='!embed[youtube](https://www.youtube.com/watch?v=example)\n\nA quiet afternoon.';
43
- assert.equal((await renderMarkdownExcerpt(source)).trim(),'<p>A quiet afternoon.</p>');
44
- assert.equal(plainText(source),'A quiet afternoon.');
45
- assert.match(plainText('`!embed[youtube](https://www.youtube.com/watch?v=example)`'),/!embed\[youtube\]/);
46
- });
47
-
48
-
49
- test('Markdown article links and excerpts resolve raw and encoded locale placeholders without rewriting other URLs',async()=>{
50
- const {localizedContentUrl}=await import('../src/i18n.js');const {renderMarkdown,renderMarkdownExcerpt}=await import('../src/markdown.js');
51
- const config={i18n:{defaultLocale:'en'}};
52
- for(const locale of ['en','zh-CN']){
53
- const resolve=value=>localizedContentUrl(config,locale,value),prefix=locale==='en'?'':'/'+locale;
54
- for(const token of ['[launge]','%5Blaunge%5D']){
55
- const source='[Guide](/'+token+'/edgepress/guide/)';
56
- for(const render of [renderMarkdown,renderMarkdownExcerpt])assert.match(await render(source,{linkResolver:resolve}),new RegExp('href="'+prefix+'/edgepress/guide/"'));
57
- }
58
- assert.equal(resolve('https://example.com/[launge]/'),'https://example.com/[launge]/');assert.equal(resolve('/explicit/'),'/explicit/');
59
- assert.throws(()=>resolve('/other/[launge]/'),/first site-path segment/);
60
- }
61
- });
62
-
63
-
64
- test('revision comparisons keep localized links valid without loading historical embeds',async()=>{
65
- const {renderRevisionCards}=await import('../src/revision-cards.js');const {localizedContentUrl}=await import('../src/i18n.js');
66
- const html=await renderRevisionCards('-[Old guide](/[launge]/guide/)\n+[Current guide](/[launge]/edgepress/)',key=>key,null,{linkResolver:value=>localizedContentUrl({i18n:{defaultLocale:'en'}},'zh-CN',value)});
67
- assert.match(html,/href="\/zh-CN\/guide\/"/);assert.match(html,/href="\/zh-CN\/edgepress\/"/);assert.doesNotMatch(html,/%5Blaunge|data-edgepress-oembed/);
68
- });
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {loadConfig} from '../src/config.js';
4
+ import {loadLanguagePacks} from '../src/i18n.js';
5
+ import {createExtensions} from '../src/plugin-api.js';
6
+ import {renderBlocks} from '../src/page-blocks.js';
7
+
8
+ test('posts and Pages share validated embed lines, with inert code examples and disabled services', async () => {
9
+ const config = await loadConfig(process.cwd());
10
+ await loadLanguagePacks(config);
11
+ const service = {id:'youtube', provider:'oembed', backendUrl:'https://www.youtube.com', sourceOrigins:['https://www.youtube.com'], embedOrigins:['https://www.youtube.com'], name:'YouTube', embedPathPattern:'^/watch$', embedTemplate:'https://www.youtube.com/embed/example'};
12
+ // The fixture uses a local template and never contacts a real provider.
13
+ delete service.embedPathPattern;
14
+ config.browserPlugins.services = [service];
15
+ const renderer = (await createExtensions(config)).renderer('.md');
16
+ const source = '!embed[youtube](https://www.youtube.com/watch?v=example)';
17
+ const article = await renderer.render('Before\n' + source + '\nAfter', {config, document:{locale:'en'}});
18
+ const page = await renderBlocks([{columns:1, cells:[[{type:'text', text:'Before\n' + source + '\nAfter\n<script>unsafe</script>'}]]}], {config, locale:'en'});
19
+ for (const html of [article, page]) {
20
+ assert.match(html, /data-edgepress-oembed="youtube"/);
21
+ assert.match(html, /data-oembed-data/);
22
+ assert.doesNotMatch(html, /<iframe|<script|<p>\s*<figure/);
23
+ assert.match(html, /Before/); assert.match(html, /After/);
24
+ }
25
+ assert.match(page, /&lt;script&gt;unsafe/);
26
+ for (const example of ['`' + source + '`', '```md\n' + source + '\n```', '\\' + source]) {
27
+ assert.doesNotMatch(await renderer.render(example, {config, document:{locale:'en'}}), /data-edgepress-oembed/);
28
+ }
29
+ for (const unsafe of ['https://evil.example/video', 'https://user:password@www.youtube.com/watch', 'http://www.youtube.com/watch']) {
30
+ await assert.rejects(renderer.render('!embed[youtube](' + unsafe + ')', {config, document:{locale:'en'}}), /registered HTTPS/);
31
+ }
32
+ await assert.rejects(renderer.render('!embed[unknown](https://www.youtube.com/watch)', {config, document:{locale:'en'}}), /registered oembed service/);
33
+ service.enabled = false;
34
+ const disabled = await renderer.render(source, {config, document:{locale:'en'}});
35
+ assert.match(disabled, /data-oembed-disabled/);
36
+ assert.doesNotMatch(disabled, /data-edgepress-oembed|data-oembed-data|<iframe/);
37
+ });
38
+
39
+
40
+ test('article summaries and search text omit embeds while retaining prose and code examples',async()=>{
41
+ const {renderMarkdownExcerpt,plainText}=await import('../src/markdown.js');
42
+ const source='!embed[youtube](https://www.youtube.com/watch?v=example)\n\nA quiet afternoon.';
43
+ assert.equal((await renderMarkdownExcerpt(source)).trim(),'<p>A quiet afternoon.</p>');
44
+ assert.equal(plainText(source),'A quiet afternoon.');
45
+ assert.match(plainText('`!embed[youtube](https://www.youtube.com/watch?v=example)`'),/!embed\[youtube\]/);
46
+ });
47
+
48
+
49
+ test('Markdown article links and excerpts resolve raw and encoded locale placeholders without rewriting other URLs',async()=>{
50
+ const {localizedContentUrl}=await import('../src/i18n.js');const {renderMarkdown,renderMarkdownExcerpt}=await import('../src/markdown.js');
51
+ const config={i18n:{defaultLocale:'en'}};
52
+ for(const locale of ['en','zh-CN']){
53
+ const resolve=value=>localizedContentUrl(config,locale,value),prefix=locale==='en'?'':'/'+locale;
54
+ for(const token of ['[launge]','%5Blaunge%5D']){
55
+ const source='[Guide](/'+token+'/edgepress/guide/)';
56
+ for(const render of [renderMarkdown,renderMarkdownExcerpt])assert.match(await render(source,{linkResolver:resolve}),new RegExp('href="'+prefix+'/edgepress/guide/"'));
57
+ }
58
+ assert.equal(resolve('https://example.com/[launge]/'),'https://example.com/[launge]/');assert.equal(resolve('/explicit/'),'/explicit/');
59
+ assert.throws(()=>resolve('/other/[launge]/'),/first site-path segment/);
60
+ }
61
+ });
62
+
63
+
64
+ test('revision comparisons keep localized links valid without loading historical embeds',async()=>{
65
+ const {renderRevisionCards}=await import('../src/revision-cards.js');const {localizedContentUrl}=await import('../src/i18n.js');
66
+ const html=await renderRevisionCards('-[Old guide](/[launge]/guide/)\n+[Current guide](/[launge]/edgepress/)',key=>key,null,{linkResolver:value=>localizedContentUrl({i18n:{defaultLocale:'en'}},'zh-CN',value)});
67
+ assert.match(html,/href="\/zh-CN\/guide\/"/);assert.match(html,/href="\/zh-CN\/edgepress\/"/);assert.doesNotMatch(html,/%5Blaunge|data-edgepress-oembed/);
68
+ });
@@ -1,36 +1,36 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import {readFile} from 'node:fs/promises';
4
- import {fileURLToPath} from 'node:url';
5
- import {extname} from 'node:path';
6
- import {chromium} from 'playwright';
7
- import {loadConfig} from '../src/config.js';
8
- import {loadLanguagePacks} from '../src/i18n.js';
9
- import {collectAssets,rewriteAssetLinks} from '../src/assets.js';
10
- import {renderLayout} from '../src/theme.js';
11
- import {renderBlocks} from '../src/page-blocks.js';
12
- import {renderMarkdown} from '../src/markdown.js';
13
- import {contentPermissions} from '../src/consent-csp.js';
14
-
15
- test('content originals are validated and participate in CSP without permitting inert embeds',async()=>{
16
- const config=await loadConfig(fileURLToPath(new URL('../',import.meta.url))),context={config,locale:'en'};
17
- const html=await renderBlocks([{columns:1,cells:[[{type:'image',src:'https://preview.example/p.png',originalSrc:'https://original.example/p.png',alt:'Picture'}]]}],context);assert.match(html,/data-original="https:\/\/original.example\/p.png"/);
18
- await assert.rejects(renderBlocks([{columns:1,cells:[[{type:'image',src:'/image.png',originalSrc:'javascript:alert(1)',alt:'Picture'}]]}],context));
19
- const markdown=await renderMarkdown('<img src="/p.png" data-original="javascript:alert(1)" alt="Picture">');assert.doesNotMatch(markdown,/javascript|data-original/);
20
- const permissions=contentPermissions([{body:html+'<template><img data-original="https://blocked.example/p.png"></template>'}]);assert.deepEqual(permissions['img-src'],['https://preview.example','https://original.example']);
21
- });
22
-
23
- test('article and Pages images open the shared viewer; originals wait for clicks and blocked services remain untouched',async()=>{
24
- const config=await loadConfig(fileURLToPath(new URL('../',import.meta.url)));await loadLanguagePacks(config);const bundle=await collectAssets(config),assets=new Map(bundle.assets.map(item=>['/'+item.path,item]));
25
- assert.match(bundle.urlMap['/edgepress/image-viewer-lib.js'],/\.[a-f0-9]{16}\.js$/);assert.doesNotMatch(assets.get(bundle.urlMap['/edgepress/image-viewer-lib.js']).content.toString(),/hls\.js|cdn\.plyr|cloudflarestream/);
26
- const body='<article><div class="post-content"><p>Keep the article visible.</p><img src="/preview.svg" data-original="https://pictures.example/original.svg" alt="Article picture"><a href="/other-page/"><img src="/preview.svg" alt="Navigation picture"></a><div data-edgepress-oembed="blocked"><img src="/preview.svg" data-original="https://pictures.example/blocked.svg" alt="Blocked picture"></div></div><figure class="image-block"><img src="/preview.svg" alt="Pages picture"></figure><article class="media-text-block"><figure><img src="/preview.svg" alt="Text and picture"></figure></article></article>';
27
- const html=rewriteAssetLinks(await renderLayout(config,{filter:async(_name,value)=>value},{title:'Images',locale:'zh-CN',urlPath:'/images/'},body),bundle.urlMap),svg='<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="700"><rect width="1000" height="700" fill="#388b65"/></svg>';
28
- const browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
29
- try{for(const width of [320,1280]){const context=await browser.newContext({viewport:{width,height:844}}),page=await context.newPage(),errors=[];let originals=0;
30
- page.on('pageerror',error=>errors.push(error.message));await context.route('**/*',async route=>{const url=new URL(route.request().url());if(url.hostname==='pictures.example'){assert.notEqual(url.pathname,'/blocked.svg');originals++;return route.fulfill({contentType:'image/svg+xml',body:svg});}
31
- if(url.pathname==='/images/')return route.fulfill({contentType:'text/html',body:html});if(url.pathname==='/preview.svg')return route.fulfill({contentType:'image/svg+xml',body:svg});const asset=assets.get(url.pathname);if(!asset)return route.fulfill({status:404,body:''});const types={'.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.webp':'image/webp'};return route.fulfill({contentType:types[extname(url.pathname)]||'text/plain',body:asset.content||await readFile(asset.source)});
32
- });await page.goto('https://site.example/images/');await page.getByRole('button',{name:'放大图片: Article picture'}).waitFor();assert.equal(originals,0);assert.equal(await page.locator('[alt="Navigation picture"]').getAttribute('role'),null);assert.equal(await page.locator('[alt="Blocked picture"]').getAttribute('role'),null);
33
- await page.getByRole('button',{name:'放大图片: Article picture'}).click();await page.locator('.image-lightbox[open]').waitFor();await page.waitForFunction(()=>document.querySelector('.image-lightbox-photo').naturalWidth>0);assert.equal(originals,1);await page.getByRole('button',{name:'关闭图片',exact:true}).click();await page.locator('.image-lightbox').waitFor({state:'detached'});assert.equal(await page.locator('[alt="Article picture"]').evaluate(image=>image===document.activeElement),true);
34
- await page.getByRole('button',{name:'放大图片: Text and picture'}).click();await page.locator('.image-lightbox[open]').waitFor();await page.keyboard.press('Escape');await page.locator('.image-lightbox').waitFor({state:'detached'});await page.getByRole('button',{name:'放大图片: Pages picture'}).click();await page.locator('.image-lightbox[open]').waitFor();await page.keyboard.press('Escape');await page.locator('.image-lightbox').waitFor({state:'detached'});assert.equal(originals,1);assert.deepEqual(errors,[]);assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await context.close();
35
- }}finally{await browser.close();}
36
- });
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFile} from 'node:fs/promises';
4
+ import {fileURLToPath} from 'node:url';
5
+ import {extname} from 'node:path';
6
+ import {chromium} from 'playwright';
7
+ import {loadConfig} from '../src/config.js';
8
+ import {loadLanguagePacks} from '../src/i18n.js';
9
+ import {collectAssets,rewriteAssetLinks} from '../src/assets.js';
10
+ import {renderLayout} from '../src/theme.js';
11
+ import {renderBlocks} from '../src/page-blocks.js';
12
+ import {renderMarkdown} from '../src/markdown.js';
13
+ import {contentPermissions} from '../src/consent-csp.js';
14
+
15
+ test('content originals are validated and participate in CSP without permitting inert embeds',async()=>{
16
+ const config=await loadConfig(fileURLToPath(new URL('../',import.meta.url))),context={config,locale:'en'};
17
+ const html=await renderBlocks([{columns:1,cells:[[{type:'image',src:'https://preview.example/p.png',originalSrc:'https://original.example/p.png',alt:'Picture'}]]}],context);assert.match(html,/data-original="https:\/\/original.example\/p.png"/);
18
+ await assert.rejects(renderBlocks([{columns:1,cells:[[{type:'image',src:'/image.png',originalSrc:'javascript:alert(1)',alt:'Picture'}]]}],context));
19
+ const markdown=await renderMarkdown('<img src="/p.png" data-original="javascript:alert(1)" alt="Picture">');assert.doesNotMatch(markdown,/javascript|data-original/);
20
+ const permissions=contentPermissions([{body:html+'<template><img data-original="https://blocked.example/p.png"></template>'}]);assert.deepEqual(permissions['img-src'],['https://preview.example','https://original.example']);
21
+ });
22
+
23
+ test('article and Pages images open the shared viewer; originals wait for clicks and blocked services remain untouched',async()=>{
24
+ const config=await loadConfig(fileURLToPath(new URL('../',import.meta.url)));await loadLanguagePacks(config);const bundle=await collectAssets(config),assets=new Map(bundle.assets.map(item=>['/'+item.path,item]));
25
+ assert.match(bundle.urlMap['/edgepress/image-viewer-lib.js'],/\.[a-f0-9]{16}\.js$/);assert.doesNotMatch(assets.get(bundle.urlMap['/edgepress/image-viewer-lib.js']).content.toString(),/hls\.js|cdn\.plyr|cloudflarestream/);
26
+ const body='<article><div class="post-content"><p>Keep the article visible.</p><img src="/preview.svg" data-original="https://pictures.example/original.svg" alt="Article picture"><a href="/other-page/"><img src="/preview.svg" alt="Navigation picture"></a><div data-edgepress-oembed="blocked"><img src="/preview.svg" data-original="https://pictures.example/blocked.svg" alt="Blocked picture"></div></div><figure class="image-block"><img src="/preview.svg" alt="Pages picture"></figure><article class="media-text-block"><figure><img src="/preview.svg" alt="Text and picture"></figure></article></article>';
27
+ const html=rewriteAssetLinks(await renderLayout(config,{filter:async(_name,value)=>value},{title:'Images',locale:'zh-CN',urlPath:'/images/'},body),bundle.urlMap),svg='<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="700"><rect width="1000" height="700" fill="#388b65"/></svg>';
28
+ const browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
29
+ try{for(const width of [320,1280]){const context=await browser.newContext({viewport:{width,height:844}}),page=await context.newPage(),errors=[];let originals=0;
30
+ page.on('pageerror',error=>errors.push(error.message));await context.route('**/*',async route=>{const url=new URL(route.request().url());if(url.hostname==='pictures.example'){assert.notEqual(url.pathname,'/blocked.svg');originals++;return route.fulfill({contentType:'image/svg+xml',body:svg});}
31
+ if(url.pathname==='/images/')return route.fulfill({contentType:'text/html',body:html});if(url.pathname==='/preview.svg')return route.fulfill({contentType:'image/svg+xml',body:svg});const asset=assets.get(url.pathname);if(!asset)return route.fulfill({status:404,body:''});const types={'.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.webp':'image/webp'};return route.fulfill({contentType:types[extname(url.pathname)]||'text/plain',body:asset.content||await readFile(asset.source)});
32
+ });await page.goto('https://site.example/images/');await page.getByRole('button',{name:'放大图片: Article picture'}).waitFor();assert.equal(originals,0);assert.equal(await page.locator('[alt="Navigation picture"]').getAttribute('role'),null);assert.equal(await page.locator('[alt="Blocked picture"]').getAttribute('role'),null);
33
+ await page.getByRole('button',{name:'放大图片: Article picture'}).click();await page.locator('.image-lightbox[open]').waitFor();await page.waitForFunction(()=>document.querySelector('.image-lightbox-photo').naturalWidth>0);assert.equal(originals,1);await page.getByRole('button',{name:'关闭图片',exact:true}).click();await page.locator('.image-lightbox').waitFor({state:'detached'});assert.equal(await page.locator('[alt="Article picture"]').evaluate(image=>image===document.activeElement),true);
34
+ await page.getByRole('button',{name:'放大图片: Text and picture'}).click();await page.locator('.image-lightbox[open]').waitFor();await page.keyboard.press('Escape');await page.locator('.image-lightbox').waitFor({state:'detached'});await page.getByRole('button',{name:'放大图片: Pages picture'}).click();await page.locator('.image-lightbox[open]').waitFor();await page.keyboard.press('Escape');await page.locator('.image-lightbox').waitFor({state:'detached'});assert.equal(originals,1);assert.deepEqual(errors,[]);assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await context.close();
35
+ }}finally{await browser.close();}
36
+ });
@@ -0,0 +1,119 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { extname } from 'node:path';
5
+ import { chromium } from 'playwright';
6
+ import { renderMarkdown, renderMarkdownExcerpt, plainText, hasMarkdownVideo } from '../src/markdown.js';
7
+ import { loadConfig } from '../src/config.js';
8
+ import { loadLanguagePacks } from '../src/i18n.js';
9
+ import { collectAssets, rewriteAssetLinks } from '../src/assets.js';
10
+ import { renderLayout } from '../src/theme.js';
11
+ import { configureDataSaver, dataSaverHtml } from '../src/data-saver.js';
12
+ import {reserveVideoMetadata} from '../src/video-metadata.js';
13
+
14
+ test('CJK bold with closing punctuation renders beside following prose without rewriting code or URLs', async () => {
15
+ for (const source of ['前文**结果(XXX)**后文', '**(XXX)**后文', '前文**(XXX)** 后文', '前文**result (XXX)**后文', '前文**说明。**后文']) {
16
+ assert.match(await renderMarkdown(source), /<strong>/);
17
+ assert.doesNotMatch(plainText(source), /\*\*/);
18
+ assert.match(await renderMarkdownExcerpt(source), /<strong>/);
19
+ }
20
+ assert.match(await renderMarkdown('**外层 *内部*(测试)**后文'), /<strong>外层 <em>内部<\/em>(测试)<\/strong>后文/);
21
+ assert.match(await renderMarkdown('`**(XXX)**`'), /<code>\*\*(XXX)\*\*<\/code>/);
22
+ assert.doesNotMatch(await renderMarkdown('\\**(XXX)**'), /<strong>/);
23
+ assert.match(await renderMarkdown('a**punctuation!**b'), /a\*\*punctuation!\*\*b/);
24
+ assert.doesNotMatch(await renderMarkdown('**(XXX) **后文'), /<strong>/);
25
+ assert.match(decodeURIComponent(await renderMarkdown('[link](https://example.com/**(XXX)**)')), /href="https:\/\/example.com\/\*\*(XXX)\*\*"/);
26
+ });
27
+
28
+ test('Markdown videos request the shared engine, including reference links, while code examples remain inert', async () => {
29
+ assert.equal(hasMarkdownVideo('![Recording](/recording.mp4)'), true);
30
+ assert.equal(hasMarkdownVideo('![Recording][clip]\n\n[clip]: /recording.mp4'), true);
31
+ assert.equal(hasMarkdownVideo('`![Recording](/recording.mp4)`'), false);
32
+ assert.equal(hasMarkdownVideo('```md\n![Recording](/recording.mp4)\n```'), false);
33
+ const html = await renderMarkdown('![Recording](/recording.mp4 "A caption")');
34
+ assert.match(html, /data-edgepress-video/);
35
+ assert.match(html, /preload="none"/);
36
+ await assert.rejects(renderMarkdown('![Unsafe](javascript:bad.mp4)'), /safe relative/);
37
+ const config = await loadConfig(process.cwd());
38
+ await loadLanguagePacks(config);
39
+ const layout = await renderLayout(config, { filter: async (_key, value) => value }, { title: 'Recording', locale: 'en', urlPath: '/recording/' }, '<main>' + html + '</main>');
40
+ assert.match(layout, /src="\/edgepress\/media-viewer.js"/);
41
+ const bundle = await collectAssets(config, { mediaViewer: hasMarkdownVideo('![Recording](/recording.mp4)') });
42
+ assert.match(bundle.urlMap['/edgepress/media-player-lib.js'], /\.[a-f0-9]{16}\.js$/);
43
+ config.site.dataSaver.enabled = true;
44
+ await configureDataSaver(config);
45
+ const gated = dataSaverHtml(rewriteAssetLinks(layout, bundle.urlMap), config, bundle.urlMap);
46
+ assert.match(gated, /data-edgepress-data-media data-data-feature="gallery"/);
47
+ assert.match(gated, /data-data-feature="gallery"[^>]*>.*?media-viewer\.[a-f0-9]{16}\.js/s);
48
+ assert.match(gated, /data-data-description>Recording/);
49
+ assert.match(gated, /<\/div><figcaption>A caption<\/figcaption>/);
50
+ });
51
+
52
+ test('inline local files use the shared player, wait for Play, seek, pause out of view and mount after text-view demand', { timeout: 60000 }, async () => {
53
+ const config = await loadConfig(process.cwd());
54
+ config.site.url = 'https://site.example';
55
+ await loadLanguagePacks(config);
56
+ const bundle = await collectAssets(config, { mediaViewer: true });
57
+ const assets = new Map(bundle.assets.map(asset => ['/' + asset.path, asset]));
58
+ const bytes = await readFile(new URL('./fixtures/recording.mp4', import.meta.url));
59
+ const body = reserveVideoMetadata(await renderMarkdown('前文**结果(XXX)**后文\n\n![Silent recording](/recording.mp4 "Recording")'),new Map([['/recording.mp4',{duration:12,width:320,height:180,poster:'/poster.svg'}]]),'index.html','https://site.example');
60
+ const script = '<script type="module" src="/edgepress/media-viewer.js"></script>';
61
+ const browser = await chromium.launch({ headless: true, args: ['--disable-extensions'] });
62
+ try {
63
+ for (const width of [320, 1280]) {
64
+ const context = await browser.newContext({ viewport: { width, height: 850 } });
65
+ const page = await context.newPage();
66
+ const requests = [], errors = [];
67
+ page.on('pageerror', error => errors.push(error.message));
68
+ const html = rewriteAssetLinks('<!doctype html><html lang="en"><head><style>body{margin:0}figure{margin:0}video{width:100%}</style><link rel="stylesheet" href="/edgepress/media-viewer.css"></head><body><main>' + body + '<div style="height:1600px"></div></main>' + script + '</body></html>', bundle.urlMap);
69
+ await page.route('https://site.example/**', async route => {
70
+ const request = route.request(), path = new URL(request.url()).pathname;
71
+ requests.push(path);
72
+ if (path === '/') return route.fulfill({ contentType: 'text/html', body: html });
73
+ if (path === '/poster.svg') return route.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="320" height="180" fill="green"/></svg>'});
74
+ if (path === '/recording.mp4') {
75
+ const range = request.headers().range?.match(/bytes=(\d+)-(\d*)/);
76
+ const start = range ? Number(range[1]) : 0;
77
+ const end = range?.[2] ? Math.min(Number(range[2]), bytes.length - 1) : bytes.length - 1;
78
+ return route.fulfill({ status: range ? 206 : 200, contentType: 'video/mp4', headers: { 'Accept-Ranges': 'bytes', ...(range ? { 'Content-Range': `bytes ${start}-${end}/${bytes.length}` } : {}) }, body: bytes.subarray(start, end + 1) });
79
+ }
80
+ const asset = assets.get(path);
81
+ return asset ? route.fulfill({ contentType: ({ '.js': 'text/javascript', '.css': 'text/css' })[extname(path)] || 'application/octet-stream', body: asset.content || await readFile(asset.source) }) : route.fulfill({ status: 404, body: '' });
82
+ });
83
+ await page.goto('https://site.example/');
84
+ const player = page.locator('.markdown-video .media-player');
85
+ await player.waitFor();
86
+ assert.equal(requests.includes('/recording.mp4'), false);
87
+ assert.equal(await player.locator('.plyr__time--duration').textContent(),'00:12');
88
+ await page.waitForFunction(()=>document.querySelector('.media-player').classList.contains('plyr__poster-enabled'));
89
+ await player.locator('video').evaluate(video => { video.muted = true; });
90
+ await player.locator('.plyr__control--overlaid').click();
91
+ await page.waitForFunction(() => document.querySelector('video').currentTime > 0.2);
92
+ const video = player.locator('video');
93
+ const duration = await video.evaluate(video => video.duration);
94
+ await video.evaluate(video => { video.pause(); });
95
+ await player.locator('input[data-plyr="seek"]').evaluate(input => { input.value = '50'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); });
96
+ await page.waitForFunction(duration => Math.abs(document.querySelector('video').currentTime - duration / 2) < 0.3, duration);
97
+ await player.locator('[data-plyr="play"]').last().click();
98
+ await page.evaluate(() => scrollTo(0, document.documentElement.scrollHeight));
99
+ await page.waitForFunction(() => document.querySelector('video').paused);
100
+ assert.ok(await video.evaluate(video => video.currentTime > 0));
101
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
102
+ assert.deepEqual(errors, []);
103
+ await context.close();
104
+ }
105
+ const context = await browser.newContext();
106
+ const page = await context.newPage();
107
+ await page.route('https://site.example/**', async route => {
108
+ const path = new URL(route.request().url()).pathname;
109
+ if (path === '/') return route.fulfill({ contentType: 'text/html', body: rewriteAssetLinks('<html><head></head><body><main><div id="target"></div></main>' + script + '</body></html>', bundle.urlMap) });
110
+ const asset = assets.get(path);
111
+ return asset ? route.fulfill({ contentType: extname(path) === '.css' ? 'text/css' : 'text/javascript', body: asset.content || await readFile(asset.source) }) : route.fulfill({ status: 404, body: '' });
112
+ });
113
+ await page.goto('https://site.example/');
114
+ assert.equal(await page.locator('.media-player').count(), 0);
115
+ await page.evaluate(body => { document.getElementById('target').innerHTML = body; document.dispatchEvent(new Event('edgepress:data-media')); }, body);
116
+ await page.locator('.media-player').waitFor();
117
+ await context.close();
118
+ } finally { await browser.close(); }
119
+ });
@@ -51,6 +51,9 @@ test('embed entry stays compact until requested, retries failures and keeps keyb
51
51
  page.on('pageerror', error => errors.push(error.message));
52
52
  await page.goto(origin);
53
53
  if (mode === 'text') await page.waitForFunction(()=>document.documentElement.dataset.edgepressDataReady === 'true');
54
+ // Full mode loads optional scripts after the first animation frame; do not
55
+ // click until the oEmbed module has finished attaching its handlers.
56
+ if (mode === 'full') await page.waitForFunction(() => Boolean(document.querySelector('[data-edgepress-oembed]')?.style.getPropertyValue('--oembed-ratio')));
54
57
  await page.evaluate(() => document.addEventListener('edgepress:privacy-open', () => window.testOpened = (window.testOpened || 0) + 1));
55
58
  const entry = page.locator('[data-edgepress-oembed]');
56
59
  const button = entry.locator('[data-oembed-load]');
@@ -58,7 +61,9 @@ test('embed entry stays compact until requested, retries failures and keeps keyb
58
61
  assert((await entry.boundingBox()).height < 300);
59
62
  assert.equal(requests, 0);
60
63
  await button.focus(); await page.keyboard.press('Enter');
61
- await page.waitForFunction(() => window.testOpened > 0);
64
+ // This is an event counter, so deferred text-view styles must not make
65
+ // the assertion depend on the browser scheduling animation frames.
66
+ await page.waitForFunction(() => window.testOpened > 0, null, { polling: 50 });
62
67
  assert.equal(await page.evaluate(()=>window.testOpened),1);
63
68
  assert.equal(requests, 0);
64
69
  assert.equal(await button.isEnabled(), true);
@@ -1,73 +1,73 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import {readFile} from 'node:fs/promises';
4
- import {createServer} from 'node:http';
5
- import {chromium} from 'playwright';
6
- import {renderBlocks} from '../src/page-blocks.js';
7
- import {loadConfig} from '../src/config.js';
8
- import {loadLanguagePacks} from '../src/i18n.js';
9
- import {renderLayout} from '../src/theme.js';
10
-
11
- test('the complete theme initializes and includes the recording runtime',async()=>{
12
- const config=await loadConfig(process.cwd());await loadLanguagePacks(config);
13
- const html=await renderLayout(config,{filter:async(_key,value)=>value},{title:'Demo',locale:'en',urlPath:'/demo/'},'<main data-project-demo></main>');
14
- assert.match(html,/src="\/edgepress\/project-demo\.js"/);
15
- });
16
-
17
- test('a progressive recording begins playback before the entire response arrives',async()=>{
18
- const bytes=await readFile('tests/fixtures/recording.mp4'),runtime=await readFile('static/edgepress/project-demo.js');let completed=false;
19
- const server=createServer((request,response)=>{
20
- if(request.url==='/demo.js'){response.setHeader('Content-Type','text/javascript');response.end(runtime);return;}
21
- if(request.url==='/recording.mp4'){
22
- response.setHeader('Content-Type','video/mp4');response.setHeader('Content-Length',bytes.length);response.write(bytes.subarray(0,Math.floor(bytes.length*.9)));
23
- const tail=setTimeout(()=>{completed=true;response.end(bytes.subarray(Math.floor(bytes.length*.9)));},3000);response.on('close',()=>clearTimeout(tail));return;
24
- }
25
- response.setHeader('Content-Type','text/html');response.end('<html><body><div data-project-demo data-animation="/recording.mp4" data-play="Play" data-resume="Continue" data-pause="Pause" data-replay="Replay" data-loading="Loading" data-error="Unavailable"><div class="project-demo-media" style="width:320px;height:180px"><video muted playsinline preload="none" aria-label="Demo"></video></div><button>Play</button><span role="status"></span></div><script src="/demo.js"></script></body></html>');
26
- });
27
- await new Promise(done=>server.listen(0,'127.0.0.1',done));const browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
28
- try{const page=await browser.newPage();await page.goto('http://127.0.0.1:'+server.address().port+'/',{waitUntil:'domcontentloaded'});await page.getByRole('button',{name:'Play: Demo'}).click();await page.waitForFunction(()=>document.querySelector('video').currentTime>.2);assert.equal(completed,false,'First playback must not wait for the full file');}
29
- finally{await browser.close();server.closeAllConnections();await new Promise(done=>server.close(done));}
30
- });
31
-
32
- test('recording blocks accept local streaming media and reject foreign or GIF sources',async()=>{
33
- const config={site:{},i18n:{defaultLocale:'en',translationsByLocale:{en:{demoPlay:'Play',demoResume:'Continue',demoPause:'Pause',demoReplay:'Replay',demoLoading:'Loading',demoError:'Unavailable'}}}};
34
- const block={type:'media-text',mediaType:'image',src:'/poster.webp',animationSrc:'/demo.mp4',alt:'Publish a post',title:'Publishing',text:'One story.'};
35
- const render=override=>renderBlocks([{columns:1,cells:[[{...block,...override}]]}],{config,locale:'en',site:{posts:[]}});
36
- assert.match(await render({}),/<video muted playsinline preload="none"/);assert.match(await render({animationSrc:'/demo.webm'}),/data-project-demo/);
37
- for(const animationSrc of ['https://foreign.example/demo.mp4','//foreign.example/demo.mp4','/demo.gif','/file.txt'])await assert.rejects(render({animationSrc}));
38
- });
39
-
40
- test('manual recordings support seeking and preserve the playhead without automatic playback',async()=>{
41
- // Twelve-second silent test-pattern fixture, generated with FFmpeg and faststart.
42
- const bytes=await readFile('tests/fixtures/recording.mp4'),browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
43
- try{
44
- const context=await browser.newContext({viewport:{width:600,height:600}}),page=await context.newPage(),errors=[];let downloads=0;
45
- page.on('pageerror',error=>errors.push(error.message));
46
- await context.route('**/*',async route=>{
47
- const url=new URL(route.request().url());assert.equal(url.hostname,'demo.test');
48
- if(url.pathname==='/demo.js')return route.fulfill({contentType:'text/javascript',body:await readFile('static/edgepress/project-demo.js')});
49
- if(url.pathname==='/demo.mp4'){downloads++;return route.fulfill({contentType:'video/mp4',headers:{'Accept-Ranges':'bytes'},body:bytes});}
50
- return route.fulfill({contentType:'text/html',body:'<html><style>body{margin:0}.spacer{height:2500px}.project-demo-media{position:relative;width:400px;height:225px}.project-demo-media img,.project-demo-media video{position:absolute;width:100%;height:100%}[hidden]{display:none!important}input[type=range]{display:block;width:400px;min-height:44px;touch-action:pan-y}</style><body><div class="spacer"></div><div data-project-demo data-animation="/demo.mp4" data-play="Play" data-resume="Continue" data-pause="Pause" data-replay="Replay" data-loading="Loading" data-error="Unavailable"><div class="project-demo-media"><img alt="Demo" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="><video hidden muted playsinline preload="none" aria-label="Demo"></video></div><button aria-label="Play: Demo">Play</button><input data-demo-seek type="range" min="0" max="1" value="0" step="0.1" disabled aria-label="Demo progress"><span data-demo-time></span><span role="status"></span></div><div class="spacer"></div><script src="/demo.js"></script></body></html>'});
51
- });
52
- await page.goto('https://demo.test/');await page.waitForTimeout(300);assert.equal(downloads,0,'Far-away recordings must not load');
53
- await page.locator('[data-project-demo]').scrollIntoViewIfNeeded();await page.waitForFunction(()=>document.querySelector('video').duration===12);assert.equal(await page.locator('video').evaluate(v=>v.paused),true);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),0);await page.getByRole('button',{name:'Play: Demo'}).click();await page.waitForFunction(()=>document.querySelector('video').currentTime>.4);assert.ok(downloads>0);assert.equal(await page.locator('video').evaluate(v=>v.currentSrc), 'https://demo.test/demo.mp4');
54
- await page.evaluate(()=>scrollTo(0,0));await page.waitForFunction(()=>document.querySelector('video').paused);const paused=await page.locator('video').evaluate(v=>v.currentTime);await page.waitForTimeout(400);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),paused);
55
- await page.locator('[data-project-demo]').scrollIntoViewIfNeeded();await page.waitForTimeout(200);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),paused);await page.getByRole('button',{name:'Continue: Demo'}).click();await page.waitForFunction(at=>document.querySelector('video').currentTime>at+.3,paused);
56
- await page.getByRole('button',{name:'Pause: Demo'}).click();const chosen=await page.locator('video').evaluate(v=>v.currentTime);await page.evaluate(()=>scrollTo(0,0));await page.waitForTimeout(100);await page.locator('[data-project-demo]').scrollIntoViewIfNeeded();await page.waitForTimeout(350);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),chosen);assert.equal(await page.locator('video').evaluate(v=>v.paused),true);
57
- await page.getByRole('button',{name:'Continue: Demo'}).click();await page.waitForFunction(at=>document.querySelector('video').currentTime>at+.3,chosen);
58
- await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide')));const before=await page.locator('video').evaluate(v=>v.currentTime);await page.waitForTimeout(100);await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pageshow')));await page.waitForTimeout(200);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),before);const seek=page.getByRole('slider',{name:'Demo progress'});await seek.fill('6');await seek.dispatchEvent('input');await page.waitForFunction(()=>document.querySelector('video').currentTime===6);assert.equal(await page.locator('video').evaluate(v=>v.paused),true);assert.equal(await page.locator('[data-demo-time]').textContent(),'0:06 / 0:12');const bounds=await seek.boundingBox();
59
- await page.mouse.click(bounds.x+bounds.width*.9,bounds.y+2);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),6,'Range padding must not seek');
60
- await seek.evaluate(input=>{const box=input.getBoundingClientRect();for(const [type,x,y]of [['pointerdown',box.x+80,box.y+22],['pointermove',box.x+82,box.y+50],['pointerup',box.x+82,box.y+50]])input.dispatchEvent(new PointerEvent(type,{pointerId:7,pointerType:'touch',button:0,clientX:x,clientY:y,bubbles:true}));});
61
- assert.equal(await page.locator('video').evaluate(v=>v.currentTime),6,'Vertical touch scrolling must not seek');
62
- const touch=await context.newCDPSession(page);
63
- const swipe=async(points)=>{await touch.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:points[0][0],y:points[0][1]}]});for(const[x,y]of points.slice(1))await touch.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x,y}]});await touch.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});};
64
- await swipe([[bounds.x+80,bounds.y+22],[bounds.x+100,bounds.y+22],[bounds.x+270,bounds.y+22]]);
65
- await page.waitForFunction(()=>document.querySelector('video').currentTime>7);
66
- assert.equal(await page.locator('video').evaluate(v=>v.paused),true,'Real touch seeking preserves pause');
67
- await seek.fill('6');await seek.dispatchEvent('input');await touch.detach();
68
- await page.mouse.move(bounds.x+bounds.width*.5,bounds.y+22);await page.mouse.down();await page.mouse.move(bounds.x+bounds.width*.75,bounds.y+22,{steps:10});await page.mouse.up();await page.waitForFunction(()=>document.querySelector('video').currentTime>8);assert.equal(await page.locator('video').evaluate(v=>v.paused),true);
69
- await seek.fill('6');await seek.dispatchEvent('input');await seek.focus();await seek.press('ArrowRight');await page.waitForFunction(()=>document.querySelector('video').currentTime>6);await page.getByRole('button',{name:'Continue: Demo'}).click();
70
- await page.locator('video').evaluate(v=>v.currentTime=11.8);await page.waitForFunction(()=>document.querySelector('video').ended);await page.evaluate(()=>scrollTo(0,0));await page.waitForTimeout(100);await page.locator('[data-project-demo]').scrollIntoViewIfNeeded();assert.equal(await page.locator('video').evaluate(v=>v.currentTime),12);await page.getByRole('button',{name:'Replay: Demo'}).click();await page.waitForFunction(()=>document.querySelector('video').currentTime>.1&&document.querySelector('video').currentTime<1);
71
- assert.deepEqual(errors,[]);await context.close();
72
- }finally{await browser.close();}
73
- });
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFile} from 'node:fs/promises';
4
+ import {createServer} from 'node:http';
5
+ import {chromium} from 'playwright';
6
+ import {renderBlocks} from '../src/page-blocks.js';
7
+ import {loadConfig} from '../src/config.js';
8
+ import {loadLanguagePacks} from '../src/i18n.js';
9
+ import {renderLayout} from '../src/theme.js';
10
+
11
+ test('the complete theme initializes and includes the recording runtime',async()=>{
12
+ const config=await loadConfig(process.cwd());await loadLanguagePacks(config);
13
+ const html=await renderLayout(config,{filter:async(_key,value)=>value},{title:'Demo',locale:'en',urlPath:'/demo/'},'<main data-project-demo></main>');
14
+ assert.match(html,/src="\/edgepress\/project-demo\.js"/);
15
+ });
16
+
17
+ test('a progressive recording begins playback before the entire response arrives',async()=>{
18
+ const bytes=await readFile('tests/fixtures/recording.mp4'),runtime=await readFile('static/edgepress/project-demo.js');let completed=false;
19
+ const server=createServer((request,response)=>{
20
+ if(request.url==='/demo.js'){response.setHeader('Content-Type','text/javascript');response.end(runtime);return;}
21
+ if(request.url==='/recording.mp4'){
22
+ response.setHeader('Content-Type','video/mp4');response.setHeader('Content-Length',bytes.length);response.write(bytes.subarray(0,Math.floor(bytes.length*.9)));
23
+ const tail=setTimeout(()=>{completed=true;response.end(bytes.subarray(Math.floor(bytes.length*.9)));},3000);response.on('close',()=>clearTimeout(tail));return;
24
+ }
25
+ response.setHeader('Content-Type','text/html');response.end('<html><body><div data-project-demo data-animation="/recording.mp4" data-play="Play" data-resume="Continue" data-pause="Pause" data-replay="Replay" data-loading="Loading" data-error="Unavailable"><div class="project-demo-media" style="width:320px;height:180px"><video muted playsinline preload="none" aria-label="Demo"></video></div><button>Play</button><span role="status"></span></div><script src="/demo.js"></script></body></html>');
26
+ });
27
+ await new Promise(done=>server.listen(0,'127.0.0.1',done));const browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
28
+ try{const page=await browser.newPage();await page.goto('http://127.0.0.1:'+server.address().port+'/',{waitUntil:'domcontentloaded'});await page.getByRole('button',{name:'Play: Demo'}).click();await page.waitForFunction(()=>document.querySelector('video').currentTime>.2);assert.equal(completed,false,'First playback must not wait for the full file');}
29
+ finally{await browser.close();server.closeAllConnections();await new Promise(done=>server.close(done));}
30
+ });
31
+
32
+ test('recording blocks accept local streaming media and reject foreign or GIF sources',async()=>{
33
+ const config={site:{},i18n:{defaultLocale:'en',translationsByLocale:{en:{demoPlay:'Play',demoResume:'Continue',demoPause:'Pause',demoReplay:'Replay',demoLoading:'Loading',demoError:'Unavailable'}}}};
34
+ const block={type:'media-text',mediaType:'image',src:'/poster.webp',animationSrc:'/demo.mp4',alt:'Publish a post',title:'Publishing',text:'One story.'};
35
+ const render=override=>renderBlocks([{columns:1,cells:[[{...block,...override}]]}],{config,locale:'en',site:{posts:[]}});
36
+ assert.match(await render({}),/<video muted playsinline preload="none"/);assert.match(await render({animationSrc:'/demo.webm'}),/data-project-demo/);
37
+ for(const animationSrc of ['https://foreign.example/demo.mp4','//foreign.example/demo.mp4','/demo.gif','/file.txt'])await assert.rejects(render({animationSrc}));
38
+ });
39
+
40
+ test('manual recordings support seeking and preserve the playhead without automatic playback',async()=>{
41
+ // Twelve-second silent test-pattern fixture, generated with FFmpeg and faststart.
42
+ const bytes=await readFile('tests/fixtures/recording.mp4'),browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
43
+ try{
44
+ const context=await browser.newContext({viewport:{width:600,height:600}}),page=await context.newPage(),errors=[];let downloads=0;
45
+ page.on('pageerror',error=>errors.push(error.message));
46
+ await context.route('**/*',async route=>{
47
+ const url=new URL(route.request().url());assert.equal(url.hostname,'demo.test');
48
+ if(url.pathname==='/demo.js')return route.fulfill({contentType:'text/javascript',body:await readFile('static/edgepress/project-demo.js')});
49
+ if(url.pathname==='/demo.mp4'){downloads++;return route.fulfill({contentType:'video/mp4',headers:{'Accept-Ranges':'bytes'},body:bytes});}
50
+ return route.fulfill({contentType:'text/html',body:'<html><style>body{margin:0}.spacer{height:2500px}.project-demo-media{position:relative;width:400px;height:225px}.project-demo-media img,.project-demo-media video{position:absolute;width:100%;height:100%}[hidden]{display:none!important}input[type=range]{display:block;width:400px;min-height:44px;touch-action:pan-y}</style><body><div class="spacer"></div><div data-project-demo data-animation="/demo.mp4" data-play="Play" data-resume="Continue" data-pause="Pause" data-replay="Replay" data-loading="Loading" data-error="Unavailable"><div class="project-demo-media"><img alt="Demo" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="><video hidden muted playsinline preload="none" aria-label="Demo"></video></div><button aria-label="Play: Demo">Play</button><input data-demo-seek type="range" min="0" max="1" value="0" step="0.1" disabled aria-label="Demo progress"><span data-demo-time></span><span role="status"></span></div><div class="spacer"></div><script src="/demo.js"></script></body></html>'});
51
+ });
52
+ await page.goto('https://demo.test/');await page.waitForTimeout(300);assert.equal(downloads,0,'Far-away recordings must not load');
53
+ await page.locator('[data-project-demo]').scrollIntoViewIfNeeded();await page.waitForFunction(()=>document.querySelector('video').duration===12);assert.equal(await page.locator('video').evaluate(v=>v.paused),true);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),0);await page.getByRole('button',{name:'Play: Demo'}).click();await page.waitForFunction(()=>document.querySelector('video').currentTime>.4);assert.ok(downloads>0);assert.equal(await page.locator('video').evaluate(v=>v.currentSrc), 'https://demo.test/demo.mp4');
54
+ await page.evaluate(()=>scrollTo(0,0));await page.waitForFunction(()=>document.querySelector('video').paused);const paused=await page.locator('video').evaluate(v=>v.currentTime);await page.waitForTimeout(400);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),paused);
55
+ await page.locator('[data-project-demo]').scrollIntoViewIfNeeded();await page.waitForTimeout(200);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),paused);await page.getByRole('button',{name:'Continue: Demo'}).click();await page.waitForFunction(at=>document.querySelector('video').currentTime>at+.3,paused);
56
+ await page.getByRole('button',{name:'Pause: Demo'}).click();const chosen=await page.locator('video').evaluate(v=>v.currentTime);await page.evaluate(()=>scrollTo(0,0));await page.waitForTimeout(100);await page.locator('[data-project-demo]').scrollIntoViewIfNeeded();await page.waitForTimeout(350);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),chosen);assert.equal(await page.locator('video').evaluate(v=>v.paused),true);
57
+ await page.getByRole('button',{name:'Continue: Demo'}).click();await page.waitForFunction(at=>document.querySelector('video').currentTime>at+.3,chosen);
58
+ await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide')));const before=await page.locator('video').evaluate(v=>v.currentTime);await page.waitForTimeout(100);await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pageshow')));await page.waitForTimeout(200);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),before);const seek=page.getByRole('slider',{name:'Demo progress'});await seek.fill('6');await seek.dispatchEvent('input');await page.waitForFunction(()=>document.querySelector('video').currentTime===6);assert.equal(await page.locator('video').evaluate(v=>v.paused),true);assert.equal(await page.locator('[data-demo-time]').textContent(),'0:06 / 0:12');const bounds=await seek.boundingBox();
59
+ await page.mouse.click(bounds.x+bounds.width*.9,bounds.y+2);assert.equal(await page.locator('video').evaluate(v=>v.currentTime),6,'Range padding must not seek');
60
+ await seek.evaluate(input=>{const box=input.getBoundingClientRect();for(const [type,x,y]of [['pointerdown',box.x+80,box.y+22],['pointermove',box.x+82,box.y+50],['pointerup',box.x+82,box.y+50]])input.dispatchEvent(new PointerEvent(type,{pointerId:7,pointerType:'touch',button:0,clientX:x,clientY:y,bubbles:true}));});
61
+ assert.equal(await page.locator('video').evaluate(v=>v.currentTime),6,'Vertical touch scrolling must not seek');
62
+ const touch=await context.newCDPSession(page);
63
+ const swipe=async(points)=>{await touch.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:points[0][0],y:points[0][1]}]});for(const[x,y]of points.slice(1))await touch.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x,y}]});await touch.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});};
64
+ await swipe([[bounds.x+80,bounds.y+22],[bounds.x+100,bounds.y+22],[bounds.x+270,bounds.y+22]]);
65
+ await page.waitForFunction(()=>document.querySelector('video').currentTime>7);
66
+ assert.equal(await page.locator('video').evaluate(v=>v.paused),true,'Real touch seeking preserves pause');
67
+ await seek.fill('6');await seek.dispatchEvent('input');await touch.detach();
68
+ await page.mouse.move(bounds.x+bounds.width*.5,bounds.y+22);await page.mouse.down();await page.mouse.move(bounds.x+bounds.width*.75,bounds.y+22,{steps:10});await page.mouse.up();await page.waitForFunction(()=>document.querySelector('video').currentTime>8);assert.equal(await page.locator('video').evaluate(v=>v.paused),true);
69
+ await seek.fill('6');await seek.dispatchEvent('input');await seek.focus();await seek.press('ArrowRight');await page.waitForFunction(()=>document.querySelector('video').currentTime>6);await page.getByRole('button',{name:'Continue: Demo'}).click();
70
+ await page.locator('video').evaluate(v=>v.currentTime=11.8);await page.waitForFunction(()=>document.querySelector('video').ended);await page.evaluate(()=>scrollTo(0,0));await page.waitForTimeout(100);await page.locator('[data-project-demo]').scrollIntoViewIfNeeded();assert.equal(await page.locator('video').evaluate(v=>v.currentTime),12);await page.getByRole('button',{name:'Replay: Demo'}).click();await page.waitForFunction(()=>document.querySelector('video').currentTime>.1&&document.querySelector('video').currentTime<1);
71
+ assert.deepEqual(errors,[]);await context.close();
72
+ }finally{await browser.close();}
73
+ });
@@ -0,0 +1,28 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { nextReleaseVersion } from '../tools/release/next-version.mjs';
4
+
5
+ const october = new Date('2026-10-23T23:30:00Z');
6
+ test('the third number counts releases within the same UTC month, not calendar day', () => {
7
+ assert.equal(nextReleaseVersion({ currentVersion: '2026.10.12', now: october }), '2026.10.13');
8
+ });
9
+ test('a new UTC month resets the update count to one', () => {
10
+ assert.equal(nextReleaseVersion({ currentVersion: '2026.10.12', now: new Date('2026-11-01T00:00:00Z') }), '2026.11.1');
11
+ });
12
+ test('npm versions and repository tags prevent reuse of an existing release number', () => {
13
+ assert.equal(nextReleaseVersion({
14
+ currentVersion: '2026.10.12', tags: ['v2026.10.15', 'v2026.9.8'],
15
+ publishedVersions: ['2026.10.14', '2026.10.16'], now: october
16
+ }), '2026.10.17');
17
+ });
18
+ test('older months do not change the count for the current month', () => {
19
+ assert.equal(nextReleaseVersion({
20
+ currentVersion: '2026.9.8', tags: ['v2026.9.9'], publishedVersions: ['2026.9.10'], now: october
21
+ }), '2026.10.1');
22
+ });
23
+ test('reject unsupported manifests and future-month versions', () => {
24
+ assert.throws(() => nextReleaseVersion({ currentVersion: '2026.10.0', now: october }), /YYYY/);
25
+ assert.throws(() => nextReleaseVersion({
26
+ currentVersion: '2026.10.12', publishedVersions: ['2026.11.1'], now: october
27
+ }), /future-month/);
28
+ });