edgepress 2026.10.1 → 2026.10.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +216 -16
- package/config.yml +99 -127
- package/content/assets/edgepress/brand/avatar.png +0 -0
- package/content/assets/edgepress/brand/deploy-esa.svg +1 -0
- package/content/assets/edgepress/brand/home-hero-768.webp +0 -0
- package/content/assets/edgepress/brand/home-hero.png +0 -0
- package/content/assets/edgepress/brand/home-hero.webp +0 -0
- package/content/assets/edgepress/brand/mascot.png +0 -0
- package/content/assets/edgepress/brand/mascot.webp +0 -0
- package/content/assets/edgepress/brand/og-image.png +0 -0
- package/content/assets/edgepress/brand/og-image.webp +0 -0
- package/content/assets/edgepress/brand/sources/edgepress-avatar-master.png +0 -0
- package/content/assets/edgepress/brand/sources/export-manifest.json +181 -0
- package/content/assets/edgepress/brand/sources/prompts.json +12 -0
- package/content/assets/edgepress/favicon/apple-touch-icon.png +0 -0
- package/content/assets/edgepress/favicon/favicon-16.png +0 -0
- package/content/assets/edgepress/favicon/favicon-32.png +0 -0
- package/content/assets/edgepress/favicon/favicon-48.png +0 -0
- package/content/assets/edgepress/favicon/favicon-64.png +0 -0
- package/content/assets/edgepress/favicon/favicon.ico +0 -0
- package/content/assets/edgepress/icons/LICENSE.txt +43 -0
- package/content/assets/edgepress/icons/arrow-right.svg +16 -0
- package/content/assets/edgepress/icons/arrow-up-right.svg +16 -0
- package/content/assets/edgepress/icons/book-open.svg +16 -0
- package/content/assets/edgepress/icons/check-circle.svg +16 -0
- package/content/assets/edgepress/icons/check.svg +15 -0
- package/content/assets/edgepress/icons/cloud.svg +15 -0
- package/content/assets/edgepress/icons/code.svg +16 -0
- package/content/assets/edgepress/icons/cpu.svg +28 -0
- package/content/assets/edgepress/icons/file-text.svg +19 -0
- package/content/assets/edgepress/icons/github.svg +17 -0
- package/content/assets/edgepress/icons/globe.svg +17 -0
- package/content/assets/edgepress/icons/help-circle.svg +17 -0
- package/content/assets/edgepress/icons/home.svg +16 -0
- package/content/assets/edgepress/icons/layers.svg +17 -0
- package/content/assets/edgepress/icons/layout-grid.svg +18 -0
- package/content/assets/edgepress/icons/mail.svg +16 -0
- package/content/assets/edgepress/icons/newspaper.svg +18 -0
- package/content/assets/edgepress/icons/palette.svg +19 -0
- package/content/assets/edgepress/icons/plug.svg +18 -0
- package/content/assets/edgepress/icons/provenance.json +8 -0
- package/content/assets/edgepress/icons/puzzle.svg +15 -0
- package/content/assets/edgepress/icons/rocket.svg +18 -0
- package/content/assets/edgepress/icons/rss.svg +17 -0
- package/content/assets/edgepress/icons/search.svg +16 -0
- package/content/assets/edgepress/icons/settings.svg +16 -0
- package/content/assets/edgepress/icons/shield-check.svg +16 -0
- package/content/assets/edgepress/icons/sparkles.svg +18 -0
- package/content/assets/edgepress/icons/terminal.svg +16 -0
- package/content/assets/edgepress/icons/x.svg +16 -0
- package/{static → content/assets}/edgepress-markdown-guide.svg +1 -1
- package/content/assets/images/previews/edgepress-en.webp +0 -0
- package/content/assets/images/previews/edgepress-revisions-en.png +0 -0
- package/content/assets/images/previews/edgepress-workflow-en.90f4650b21013786.gif +0 -0
- package/content/assets/images/previews/edgepress-workflow-zh.41f1e0a685f47267.gif +0 -0
- package/content/assets/images/previews/edgepress-zh.webp +0 -0
- package/content/assets/images/previews/provenance.json +49 -0
- package/content/guides/markdown-media.md +38 -0
- package/content/guides/platforms.md +15 -0
- package/content/guides/product-demos.md +37 -0
- package/content/guides/zh-cn.md +62 -0
- package/content/pages/deployment/en.json +74 -0
- package/content/pages/deployment/zh-cn.json +74 -0
- package/content/pages/home/en.json +248 -0
- package/content/pages/home/zh-cn.json +248 -0
- package/content/pages/local-tools/en.json +14 -0
- package/content/pages/local-tools/zh-cn.json +14 -0
- package/content/pages/page-authoring/en.json +107 -0
- package/content/pages/page-authoring/zh-cn.json +107 -0
- package/content/pages/plugin-development/en.json +202 -0
- package/content/pages/plugin-development/zh-cn.json +202 -0
- package/content/pages/privacy-policy/en.json +171 -0
- package/content/pages/privacy-policy/zh-cn.json +171 -0
- package/content/pages/project-introduction/en.json +158 -0
- package/content/pages/project-introduction/zh-cn.json +158 -0
- package/content/pages/quick-start/en.json +325 -0
- package/content/pages/quick-start/zh-cn.json +325 -0
- package/content/pages/theme-development/en.json +353 -0
- package/content/pages/theme-development/zh-cn.json +349 -0
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
- package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
- package/content/posts/2026-10-03-edgepress-release-2026-10-3/en.md +16 -0
- package/content/posts/2026-10-03-edgepress-release-2026-10-3/zh-cn.md +16 -0
- package/content/posts/2026-10-03-edgepress-release-202610-4/en.md +22 -0
- package/content/posts/2026-10-03-edgepress-release-202610-4/zh-cn.md +20 -0
- package/content/posts/2026-10-03-edgepress-release-202610-5/en.md +15 -0
- package/content/posts/2026-10-03-edgepress-release-202610-5/zh-cn.md +15 -0
- package/edgeone.json +43 -0
- package/esa.jsonc +9 -0
- package/languages/base/en.json +75 -10
- package/languages/packs/zh-CN.json +74 -9
- package/netlify.toml +6 -0
- package/package.json +37 -6
- package/plugins/consent/index.js +10 -17
- package/project-compatibility.json +142 -25
- package/schemas/pages.schema.json +74 -0
- package/src/assets.js +146 -18
- package/src/browser-audit.js +158 -0
- package/src/build.js +34 -9
- package/src/cli.js +46 -41
- package/src/config.js +114 -63
- package/src/consent-csp.js +137 -0
- package/src/content.js +55 -30
- package/src/data-saver.js +194 -0
- package/src/deploy.js +28 -0
- package/src/document.js +44 -0
- package/src/embed.js +60 -0
- package/src/generators.js +101 -59
- package/src/i18n.js +19 -3
- package/src/icons.js +5 -4
- package/src/image-dimensions.js +17 -0
- package/src/local-tool.js +29 -0
- package/src/maintenance.js +25 -8
- package/src/markdown-fixture.js +20 -0
- package/src/markdown.js +80 -16
- package/src/media-assets.js +19 -0
- package/src/media-viewer.js +47 -0
- package/src/oembed.js +53 -0
- package/src/page-blocks.js +124 -54
- package/src/page-check.js +29 -39
- package/src/plugin-api.js +7 -1
- package/src/post-details.js +60 -0
- package/src/post-template.js +5 -0
- package/src/publication-time.js +47 -0
- package/src/report-path.js +19 -0
- package/src/report-visuals.js +91 -18
- package/src/revision-cards.js +30 -0
- package/src/revision-images.js +44 -0
- package/src/revisions.js +77 -0
- package/src/rss.js +100 -0
- package/src/service-block.js +18 -0
- package/src/theme.js +59 -9
- package/src/video-metadata.js +36 -0
- package/static/edgepress/data-saver.css +12 -0
- package/static/edgepress/data-saver.js +333 -0
- package/static/edgepress/document-viewer.js +35 -0
- package/static/edgepress/external-links-policy.js +14 -0
- package/static/edgepress/external-links.css +13 -0
- package/static/edgepress/external-links.js +120 -0
- package/static/edgepress/fonts/faces.css +2217 -0
- package/static/edgepress/fonts/noto-sans-OFL.txt +93 -0
- package/static/edgepress/fonts/noto-sans-cyrillic-ext-wght-normal.82ff72b28e6610f2.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-cyrillic-wght-normal.6ab64433de6077ca.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-devanagari-wght-normal.ea6a530b75d93de7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-greek-ext-wght-normal.790b255c10c32e0b.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-greek-wght-normal.7f655207c2f6c140.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-latin-ext-wght-normal.6d5a79315528df19.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-latin-wght-normal.51ca196f49a33e79.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-100-wght-normal.ad5f29028d5699a5.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-101-wght-normal.e189b81fc22ca6af.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-102-wght-normal.941c0c2971fc1d6d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-103-wght-normal.bafbd591af6493ff.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-104-wght-normal.b0bf80ced89dd61b.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-105-wght-normal.8d8b4e6f4a00621d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-106-wght-normal.bae74acc629d2e74.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-107-wght-normal.a42eff5010de7b0d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-108-wght-normal.91882203459867be.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-109-wght-normal.a91f8b038e5ce108.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-110-wght-normal.bbca9081655c4523.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-111-wght-normal.372be010ac0e87cb.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-112-wght-normal.34a4580239eae8dc.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-113-wght-normal.614ab8f616352583.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-114-wght-normal.cf7b8344807a316e.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-115-wght-normal.fb29957c7adccb9a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-116-wght-normal.a98dc5a726f82141.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-117-wght-normal.d94b5af0c14838af.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-118-wght-normal.76c9ddb85ae4578c.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-119-wght-normal.b3b2374881f4cd93.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-21-wght-normal.c1409c43e55ecff2.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-22-wght-normal.77037ed42ff4e7b0.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-23-wght-normal.38d0df21ac374e93.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-24-wght-normal.8fa21e7e457acf3e.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-25-wght-normal.f760b7a3871d214d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-26-wght-normal.be8386e7bd7facfe.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-27-wght-normal.4895abcaddbd162e.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-28-wght-normal.82540b6201a9b9ad.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-29-wght-normal.ff21d8fcc60ce391.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-30-wght-normal.1f76f6ffded00b9e.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-31-wght-normal.e6c25be1bb08a63a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-32-wght-normal.0cda750d7d04a4e1.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-33-wght-normal.b9732b5606bd87c5.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-34-wght-normal.38aa326b4c84902a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-35-wght-normal.bd98226c8e0aa808.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-36-wght-normal.0db1ca695a491d37.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-37-wght-normal.8df409a4e0a237c2.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-38-wght-normal.672d2d994994a3c3.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-39-wght-normal.272d6d0e153f56e8.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-4-wght-normal.9fafd93ac77b81ff.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-40-wght-normal.5b9ef1ed48420aa7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-41-wght-normal.30aee47f660fd88a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-42-wght-normal.ec0014830db682b6.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-43-wght-normal.43df2b5dad9bb835.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-44-wght-normal.acaa6195041b1a92.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-45-wght-normal.8cdb9903f770db21.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-46-wght-normal.6590d0a6fe94571c.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-47-wght-normal.b05afdd32b3bf722.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-48-wght-normal.c9585f7d070700be.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-49-wght-normal.4943895a4dfa5dcf.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-5-wght-normal.e64f57937e3ae4fe.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-50-wght-normal.13e51063d7743cfe.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-51-wght-normal.fd4df1f02c2cb4d7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-52-wght-normal.06999a39c4c3a7ea.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-53-wght-normal.767b7b9864658d91.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-54-wght-normal.4cd00537a46f6f1a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-55-wght-normal.0118ec5d8c30b197.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-56-wght-normal.a3e4a78929826930.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-57-wght-normal.7623501009e4a6db.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-58-wght-normal.56ae1a59a32e4a25.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-59-wght-normal.7a377a40e99fd9bc.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-6-wght-normal.17e15c4f0a5bfd92.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-60-wght-normal.3657f66babdf6aff.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-61-wght-normal.23919a2eb11d25dd.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-62-wght-normal.a901edf25bc1cfae.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-63-wght-normal.92f56f713cc0b62f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-64-wght-normal.4929c9f39a5a4bd9.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-65-wght-normal.8daa61282fb62a11.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-66-wght-normal.6479166925eb462b.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-67-wght-normal.a6b0e11c4c761ffd.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-68-wght-normal.65b77f1fe9d68dae.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-69-wght-normal.b34f765cbbf4d02e.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-70-wght-normal.a99d55c00e4f95b7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-71-wght-normal.3298bed30dc8d233.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-72-wght-normal.6597209ca9707726.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-73-wght-normal.e7ac1d4930eabd78.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-74-wght-normal.1f0287be83a28e7a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-75-wght-normal.d9a4bb7bcc2be578.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-76-wght-normal.5a019946bcadcec6.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-77-wght-normal.7c445e85d04f5319.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-78-wght-normal.7d4afb70f641781c.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-79-wght-normal.5798753a721bb4ec.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-80-wght-normal.db887159ca6e46f3.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-81-wght-normal.71e5743bbd225b3d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-82-wght-normal.8c5094c3785fd04f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-83-wght-normal.4a60a1ccba905284.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-84-wght-normal.3ed54e59ef4758e3.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-85-wght-normal.e87e0a1342e1778c.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-86-wght-normal.6d3ac3e20959dbcb.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-87-wght-normal.0eeffd5daad5c17b.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-88-wght-normal.b1d9582dfa268463.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-89-wght-normal.8b437aeb9e9fcb5a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-90-wght-normal.83b0a59e0a5e045a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-91-wght-normal.382f72b98d4bb9d7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-97-wght-normal.5e52856457c5cdab.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-98-wght-normal.ffded4e7fd560d93.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-99-wght-normal.54e302c23f11a8fd.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-OFL.txt +93 -0
- package/static/edgepress/fonts/noto-sans-sc-cyrillic-wght-normal.5932d441e7126669.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-latin-ext-wght-normal.e316201f3fc493d1.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-latin-wght-normal.c1d919c8701a86ed.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-sc-vietnamese-wght-normal.cba3d00c8db989f0.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-0-wght-normal.63c21eda5d20203f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-100-wght-normal.87a7c76301c24a28.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-101-wght-normal.c9e83fc4ffd53746.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-102-wght-normal.9a00ae0e5735852b.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-103-wght-normal.7cfe93b3221f4def.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-104-wght-normal.2d5ac7497d84cb99.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-105-wght-normal.0989885bb16bcd9b.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-106-wght-normal.1faa921b035064e7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-107-wght-normal.2f6006745ae16d69.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-108-wght-normal.a61e48c10a2cf4b9.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-109-wght-normal.f28c767724736d2d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-110-wght-normal.4b04bd88c3dc7c5d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-111-wght-normal.b6251e1967932a52.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-112-wght-normal.e264513b32f90660.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-113-wght-normal.46cb347d88f3338e.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-114-wght-normal.9f9e1e0d0ab2aeb4.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-115-wght-normal.653dd2ab7d551b40.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-116-wght-normal.4718bf3627191297.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-117-wght-normal.b241a9409682af5f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-118-wght-normal.fda2800dc1c06852.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-119-wght-normal.948af36bd63a72c7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-19-wght-normal.62191cdc3a10e60a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-20-wght-normal.65c90892c2e01657.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-21-wght-normal.d3f59b094c334866.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-22-wght-normal.b1983a041d7e4e1f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-23-wght-normal.16e2163b66c7732a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-24-wght-normal.72c8561976116537.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-25-wght-normal.b2948d928ce4dd54.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-26-wght-normal.d583236b90abedc9.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-27-wght-normal.7d6073ba07597c9b.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-28-wght-normal.517204d716b089cd.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-29-wght-normal.10189a8f8ca38099.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-30-wght-normal.695a71d72dfab3f7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-31-wght-normal.868d59473ef0e73c.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-32-wght-normal.92efa2d4b3702ec0.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-33-wght-normal.d2274bcd5636197d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-34-wght-normal.1ac8c3718ee2eed2.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-35-wght-normal.00b3df730d5a4c73.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-36-wght-normal.6963feb6c628b81f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-37-wght-normal.a1b4bd294af24cd1.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-38-wght-normal.8edb2f7f2955921b.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-39-wght-normal.37bf7394eb3148c6.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-40-wght-normal.3efa655bc48f9133.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-41-wght-normal.48d47014544ebd5d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-42-wght-normal.214c733a59d423e7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-43-wght-normal.c2c9876f138bb20f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-44-wght-normal.28928bfea29db06a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-45-wght-normal.bf3743f8155fa9d2.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-46-wght-normal.06970903593781c3.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-47-wght-normal.8932ed83a91794d4.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-48-wght-normal.dfd9225628bcf234.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-49-wght-normal.c27d341ad39d44eb.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-50-wght-normal.01a0f4e7dfba0a56.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-51-wght-normal.5a30022af3d30e0f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-52-wght-normal.4884f032342a3274.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-53-wght-normal.537adcae44511270.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-54-wght-normal.6bc8c7d3e47c6fde.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-55-wght-normal.725a48711f1e1997.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-56-wght-normal.f43ecaa73e0cd8fc.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-57-wght-normal.15b24e9d42a3270f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-58-wght-normal.9b2d8a71667d3da6.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-59-wght-normal.a68535a6b3289d89.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-6-wght-normal.32cece1ae80c4f2b.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-60-wght-normal.3b2c56a921d1cad4.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-61-wght-normal.54f51f80a0740878.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-62-wght-normal.ba029c5bb6c07bf1.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-63-wght-normal.380919beec4c7f3d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-64-wght-normal.f96fe0be479d201f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-65-wght-normal.7d544b80701fe678.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-66-wght-normal.fab669c07a1e3bee.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-67-wght-normal.0001bb744743fdfa.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-68-wght-normal.1a57eda03886b43c.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-69-wght-normal.017fecc749bcd6d5.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-7-wght-normal.cd0f0cac743dbbfe.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-70-wght-normal.79fb9d70b9534ec7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-71-wght-normal.04a6ea581b5f1195.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-72-wght-normal.d3a4eb35499157b7.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-73-wght-normal.d81f8484404ee465.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-74-wght-normal.4a5da9e0c2702f7d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-75-wght-normal.8b87f9c6c164513a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-76-wght-normal.a71463dbfd9fa7b5.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-77-wght-normal.d094414ea752cd52.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-78-wght-normal.04f7c97bd9cbc73b.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-79-wght-normal.1ed089d037a02049.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-8-wght-normal.d052677dfb5db5bd.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-80-wght-normal.6d41ca59cfd50442.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-81-wght-normal.f09bd0afa8a58114.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-82-wght-normal.7920841c526b1b02.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-83-wght-normal.0c6f7f82db8da0d8.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-84-wght-normal.f37c0a853b31265e.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-85-wght-normal.4173bff087653b82.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-86-wght-normal.dac35605ebec15c4.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-87-wght-normal.8ac85fdd0b15a6bd.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-88-wght-normal.61625b0b21e8407f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-89-wght-normal.a1da3dccbf00698e.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-90-wght-normal.bf08f0355222a782.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-91-wght-normal.2ecf295bba2bfaa3.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-92-wght-normal.36508326bb68115f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-97-wght-normal.c712899e0fc9e947.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-98-wght-normal.13a648e521d18d7f.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-99-wght-normal.7f8c9a777e64159a.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-OFL.txt +93 -0
- package/static/edgepress/fonts/noto-sans-tc-cyrillic-wght-normal.dd0980ced8e46cb3.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-latin-ext-wght-normal.817d96335dc52ce6.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-latin-wght-normal.50cb5fb0a522cfaf.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-tc-vietnamese-wght-normal.2a77fb7b70fa304d.woff2 +0 -0
- package/static/edgepress/fonts/noto-sans-vietnamese-wght-normal.791e3f213c155a5b.woff2 +0 -0
- package/static/edgepress/fonts/noto-serif-OFL.txt +93 -0
- package/static/edgepress/fonts/noto-serif-cyrillic-ext-wght-normal.7c7afb20d33e30ec.woff2 +0 -0
- package/static/edgepress/fonts/noto-serif-cyrillic-wght-normal.54e3bd0c4a002b0f.woff2 +0 -0
- package/static/edgepress/fonts/noto-serif-greek-ext-wght-normal.9b3f4c4ff3adefd2.woff2 +0 -0
- package/static/edgepress/fonts/noto-serif-greek-wght-normal.1252037b6f1d6e86.woff2 +0 -0
- package/static/edgepress/fonts/noto-serif-latin-ext-wght-normal.2654f8d1261a420f.woff2 +0 -0
- package/static/edgepress/fonts/noto-serif-latin-wght-normal.46281456234014ce.woff2 +0 -0
- package/static/edgepress/fonts/noto-serif-math-wght-normal.d4a6aba2cb4636b3.woff2 +0 -0
- package/static/edgepress/fonts/noto-serif-vietnamese-wght-normal.fb6a5ed734c2fbe3.woff2 +0 -0
- package/static/edgepress/fonts/provenance.json +1356 -0
- package/static/edgepress/fonts.css +14 -0
- package/static/edgepress/image-viewer.js +23 -0
- package/static/edgepress/images.css +4 -0
- package/static/edgepress/link-directory.css +14 -0
- package/static/edgepress/local-time.js +12 -0
- package/static/edgepress/media-viewer.css +1 -0
- package/static/edgepress/media-viewer.js +111 -0
- package/static/edgepress/navigation-select.js +14 -0
- package/static/edgepress/oembed.js +129 -0
- package/static/edgepress/plugins/backend/external-api.js +4 -0
- package/static/edgepress/plugins/backend/external-widget.js +13 -0
- package/static/edgepress/plugins/consent/choices.js +47 -0
- package/static/edgepress/plugins/consent/manager.js +56 -74
- package/static/edgepress/project-demo.js +93 -0
- package/static/edgepress/search.js +2 -2
- package/static/edgepress/services-consent.js +3 -0
- package/static/edgepress/services.js +15 -0
- package/static/edgepress/webmcp.js +86 -0
- package/tests/assets-runtime.test.mjs +91 -0
- package/tests/audit.test.mjs +142 -0
- package/tests/brand-regression.test.mjs +110 -0
- package/tests/consent-browser.test.mjs +132 -0
- package/tests/consent-config.test.mjs +85 -0
- package/tests/consent-csp.test.mjs +90 -0
- package/tests/data-saver-loading.test.mjs +158 -0
- package/tests/data-saver-network.test.mjs +99 -0
- package/tests/data-saver.test.mjs +294 -0
- package/tests/document-package.test.mjs +28 -0
- package/tests/document-viewer.test.mjs +152 -0
- package/tests/edge-issues.test.mjs +65 -0
- package/tests/embed-syntax.test.mjs +68 -0
- package/tests/external-links.test.mjs +198 -0
- package/tests/fixtures/recording.mp4 +0 -0
- package/tests/fonts.test.mjs +99 -0
- package/tests/image-dimensions.test.mjs +15 -0
- package/tests/image-viewer.test.mjs +36 -0
- package/tests/json-pages.test.mjs +66 -0
- package/tests/link-directory.test.mjs +51 -0
- package/tests/local-tool.test.mjs +34 -0
- package/tests/markdown-media.test.mjs +119 -0
- package/tests/media-block.test.mjs +102 -0
- package/tests/navigation.test.mjs +87 -0
- package/tests/oembed-layout.test.mjs +98 -0
- package/tests/oembed.test.mjs +96 -0
- package/tests/page-authoring.test.mjs +27 -0
- package/tests/page-formatting.test.mjs +37 -0
- package/tests/post-list.test.mjs +96 -0
- package/tests/project-demo.test.mjs +73 -0
- package/tests/publication-time.test.mjs +43 -0
- package/tests/release-version.test.mjs +28 -0
- package/tests/revisions.test.mjs +93 -0
- package/tests/rss.test.mjs +54 -0
- package/tests/services.test.mjs +35 -0
- package/tests/theme-layout.test.mjs +95 -0
- package/tests/video-metadata.test.mjs +28 -0
- package/tests/webmcp.test.mjs +121 -0
- package/tests/writing.test.mjs +101 -0
- package/themes/atelier/assets/style.css +144 -22
- package/themes/atelier/layouts/partials/head.html +8 -1
- package/themes/atelier/layouts/partials/header.html +3 -1
- package/themes/default/assets/data-saver-theme.css +2 -0
- package/themes/default/assets/style.css +139 -23
- package/themes/default/layouts/partials/head.html +9 -2
- package/themes/default/layouts/partials/header.html +3 -1
- package/themes/default/theme.json +1 -0
- package/themes/folio/README.md +7 -0
- package/themes/folio/assets/data-saver-theme.css +3 -0
- package/themes/folio/assets/folio/navigation.js +10 -0
- package/themes/folio/assets/style.css +516 -0
- package/themes/folio/layouts/layout.html +15 -0
- package/themes/folio/layouts/partials/footer.html +6 -0
- package/themes/folio/layouts/partials/head.html +30 -0
- package/themes/folio/layouts/partials/header.html +8 -0
- package/themes/folio/theme.json +7 -0
- package/themes/signal/assets/style.css +143 -21
- package/themes/signal/layouts/partials/head.html +8 -1
- package/themes/signal/layouts/partials/header.html +3 -1
- package/tools/README.md +14 -0
- package/tools/assets/format-pages.mjs +56 -0
- package/tools/assets/generate-brand-assets.py +160 -0
- package/tools/assets/sync-fonts.mjs +71 -0
- package/tools/assets/sync-icons.mjs +17 -0
- package/tools/assets/video-preview.mjs +15 -0
- package/tools/recordings/capture-document-demo.mjs +147 -0
- package/tools/recordings/demo-workbench.mjs +57 -0
- package/tools/recordings/desktop-recording.mjs +92 -0
- package/tools/recordings/provision-vm.sh +18 -0
- package/tools/recordings/start-vm-desktop.sh +28 -0
- package/tools/recordings/vm-desktop.mjs +124 -0
- package/tools/verification/README.md +44 -0
- package/tools/verification/data-saver-site.mjs +405 -0
- package/tools/verification/page-performance.mjs +76 -0
- package/tools/verification/verify-brand-assets.py +52 -0
- package/tools/verification/verify-brand.mjs +180 -0
- package/vercel.json +36 -0
- package/wrangler.jsonc +5 -7
- package/content/pages/home/en.md +0 -99
- package/content/pages/home/zh-cn.md +0 -99
- package/content/pages/plugin-development/en.md +0 -109
- package/content/pages/plugin-development/zh-cn.md +0 -109
- package/content/pages/privacy-policy/en.md +0 -93
- package/content/pages/privacy-policy/zh-cn.md +0 -93
- package/content/pages/project-introduction/en.md +0 -88
- package/content/pages/project-introduction/zh-cn.md +0 -88
- package/content/pages/quick-start/en.md +0 -153
- package/content/pages/quick-start/zh-cn.md +0 -153
- package/content/pages/theme-development/en.md +0 -132
- package/content/pages/theme-development/zh-cn.md +0 -132
- package/src/worker.js +0 -139
- package/static/edgepress/brand/edgepress-mark.svg +0 -7
- package/static/edgepress/brand/mascot-209.webp +0 -0
- package/static/edgepress/brand/mascot-418.png +0 -0
- package/static/edgepress/brand/mascot-418.webp +0 -0
- package/static/edgepress/brand/mascot.png +0 -0
- package/static/edgepress/brand/og-image.png +0 -0
- package/static/edgepress/brand/og-image.svg +0 -46
- package/static/edgepress/icons.svg +0 -24
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { renderBlocks } from '../src/page-blocks.js';
|
|
4
|
+
import { postsForLocale } from '../src/content.js';
|
|
5
|
+
import { categorySlug, categoryFilter, readingMinutes, safeAvatar, authorForPost } from '../src/post-details.js';
|
|
6
|
+
|
|
7
|
+
const config = {
|
|
8
|
+
site: { archive: { categories: ['博客', '未分类'] } },
|
|
9
|
+
markdown: { gfm: true, breaks: false },
|
|
10
|
+
i18n: { defaultLocale: 'en', translationsByLocale: { en: { noPosts: 'No posts', allPosts: 'All posts' } } }
|
|
11
|
+
};
|
|
12
|
+
function post(id, locale, category, day = 1) {
|
|
13
|
+
return { bundlePath: id, locale, category, tags: ['edgepress'], title: id + '-' + locale, path: locale + '/' + id + '/',
|
|
14
|
+
date: new Date('2026-09-' + String(day).padStart(2, '0') + 'T00:00:00Z'), description: '**Formatted** excerpt', author: '' };
|
|
15
|
+
}
|
|
16
|
+
function render(block, posts, locale = 'en') {
|
|
17
|
+
return renderBlocks([{ columns: 1, cells: [[{ type: 'post-list', title: 'Project articles', ...block }]] }],
|
|
18
|
+
{ config, locale, site: { posts } });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('pinned and recent lists display each article once per page, filling remaining slots',async()=>{
|
|
22
|
+
const posts=[{...post('latest','en','blog',5),pinned:true},post('second','en','blog',4),post('third','en','blog',3)];
|
|
23
|
+
const rows=[{columns:2,cells:[[
|
|
24
|
+
{type:'section',title:'Featured',blocks:[{type:'post-list',title:'Pinned',category:'blog',count:1}]}
|
|
25
|
+
],[{type:'latest-posts',title:'Recent',category:'blog',count:2}]]}];
|
|
26
|
+
const context={config,locale:'en',site:{posts}};
|
|
27
|
+
const html=await renderBlocks(rows,context);
|
|
28
|
+
assert.equal((html.match(/latest-en/g)||[]).length,1);
|
|
29
|
+
assert.match(html,/second-en/);assert.match(html,/third-en/);
|
|
30
|
+
assert.equal((html.match(/class="post-card"/g)||[]).length,3);
|
|
31
|
+
assert.match(await renderBlocks([{columns:1,cells:[[{type:'latest-posts',title:'Other page',count:1}]]}],context),/latest-en/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('categories select translations, sort before limiting, and ignore tags', async () => {
|
|
35
|
+
const posts = [post('old', 'en', 'edgepress'), post('new', 'en', 'EdgePress', 3),
|
|
36
|
+
post('new', 'zh-SG', 'edgepress', 3), post('other', 'zh-SG', 'blog', 4)];
|
|
37
|
+
const html = await render({ category: 'edgepress', count: 1 }, posts, 'zh-SG');
|
|
38
|
+
assert.match(html, /new-zh-SG/);
|
|
39
|
+
assert.doesNotMatch(html, /new-en|other-zh-SG|old-en/);
|
|
40
|
+
assert.match(html, /href="\/zh-SG\/categories\/edgepress\/"/);
|
|
41
|
+
assert.match(html, /<strong>Formatted<\/strong>/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('journal accepts Chinese aliases and missing categories, excluding project articles', async () => {
|
|
45
|
+
const html = await render({ categories: ['博客', '未分類'] }, [post('blog', 'en', 'blog'), post('new', 'en'), post('project', 'en', 'edgepress')]);
|
|
46
|
+
assert.match(html, /blog-en/);
|
|
47
|
+
assert.match(html, /new-en/);
|
|
48
|
+
assert.doesNotMatch(html, /project-en/);
|
|
49
|
+
assert.deepEqual(categoryFilter(['博客', 'BLOG', '未分类']), ['blog', 'uncategorized']);
|
|
50
|
+
assert.equal(categorySlug('技术'), 'category-6280-672f');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('empty results, validation, and archive-matched homepage pagination', async () => {
|
|
54
|
+
assert.match(await render({ category: 'edgepress' }, []), /No posts/);
|
|
55
|
+
for (const tag of ['edgepress', '', null]) await assert.rejects(render({ tag }, []), /no longer supported/);
|
|
56
|
+
await assert.rejects(render({ category: 'edgepress', paginate: true }, []), /same categories/);
|
|
57
|
+
await assert.rejects(render({ count: 13 }, []), /1 to 12/);
|
|
58
|
+
await assert.rejects(render({ category: 'blog', categories: ['blog'] }, []), /not both/);
|
|
59
|
+
assert.match(await render({ type: 'latest-posts', paginate: true, categories: ['博客', '未分类'] }, [post('blog','en','blog')]), /blog-en/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('author defaults and avatar validation preserve safe paths without script URLs', () => {
|
|
63
|
+
assert.deepEqual(authorForPost({ author: 'Guest', authorAvatar: '/guest.png' }, { site: { author: { name: 'Site', avatar: '/site.png' } } }), { name: 'Guest', avatar: '/guest.png' });
|
|
64
|
+
assert.equal(safeAvatar('https://example.org/avatar.png'), 'https://example.org/avatar.png');
|
|
65
|
+
for (const src of ['javascript:alert(1)', '//elsewhere/avatar.png', 'http://example.org/a.png', '/bad\\a.png', 'https://user:pass@example.org/a.png']) assert.throws(() => safeAvatar(src), /safe site path/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('reading estimates count Chinese characters and English words together', () => {
|
|
69
|
+
assert.equal(readingMinutes(''), 1);
|
|
70
|
+
assert.equal(readingMinutes('word '.repeat(201)), 2);
|
|
71
|
+
assert.equal(readingMinutes('文'.repeat(301)), 2);
|
|
72
|
+
assert.equal(readingMinutes('文'.repeat(150)+' word'.repeat(100)), 1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('fallback language selection stays stable when neither requested nor default language exists', () => {
|
|
76
|
+
const simplified = post('only-Chinese', 'zh-SG', 'blog');
|
|
77
|
+
const traditional = post('only-Chinese', 'zh-TW', 'blog');
|
|
78
|
+
assert.equal(postsForLocale([traditional, simplified], 'en', 'en')[0].locale, 'zh-SG');
|
|
79
|
+
assert.equal(postsForLocale([simplified, traditional], 'en', 'en')[0].locale, 'zh-SG');
|
|
80
|
+
assert.equal(postsForLocale([simplified, traditional], 'zh-TW', 'en')[0].locale, 'zh-TW');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('post-list places pinned articles before the count limit while latest-posts keep date order', async () => {
|
|
84
|
+
const old = {...post('old-pin','en','edgepress',1),pinned:true};
|
|
85
|
+
const recent = post('recent','en','edgepress',8);
|
|
86
|
+
const translated = {...post('old-pin','zh-TW','edgepress',1),pinned:true};
|
|
87
|
+
const posts=[recent,old,translated];
|
|
88
|
+
const featured=await render({category:'edgepress',count:1},posts,'zh-TW');
|
|
89
|
+
assert.match(featured,/old-pin-zh-TW/);
|
|
90
|
+
assert.match(featured,/post-pinned/);
|
|
91
|
+
assert.doesNotMatch(featured,/recent-en|old-pin-en/);
|
|
92
|
+
const latest=await render({type:'latest-posts',category:'edgepress',count:1},posts);
|
|
93
|
+
assert.match(latest,/recent-en/);
|
|
94
|
+
assert.doesNotMatch(latest,/old-pin-en/);
|
|
95
|
+
assert.equal(posts[0],recent,'Sorting must not mutate the source used by archives and feeds');
|
|
96
|
+
});
|
|
@@ -0,0 +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
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {spawnSync} from 'node:child_process';
|
|
4
|
+
import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises';
|
|
5
|
+
import {tmpdir} from 'node:os';
|
|
6
|
+
import {join} from 'node:path';
|
|
7
|
+
import {parsePublicationDate,publicationTimestamp,validateTimeZone} from '../src/publication-time.js';
|
|
8
|
+
import {readDocuments} from '../src/content.js';
|
|
9
|
+
|
|
10
|
+
test('publication timezone defaults to Taipei and calendar dates remain dates',()=>{
|
|
11
|
+
assert.equal(parsePublicationDate('2026-10-03').toISOString(),'2026-10-03T00:00:00.000Z');
|
|
12
|
+
assert.equal(parsePublicationDate('2026-10-03T09:15').toISOString(),'2026-10-03T01:15:00.000Z');
|
|
13
|
+
assert.equal(parsePublicationDate('2026-10-03 09:15:30.123').toISOString(),'2026-10-03T01:15:30.123Z');
|
|
14
|
+
assert.equal(parsePublicationDate('2026-10-03T09:15:00+02:00').toISOString(),'2026-10-03T07:15:00.000Z');
|
|
15
|
+
assert.equal(publicationTimestamp(new Date('2026-10-02T20:00:00Z')),'2026-10-03T04:00:00+08:00');
|
|
16
|
+
assert.equal(parsePublicationDate('2026-10-03T09:15','America/Los_Angeles').toISOString(),'2026-10-03T16:15:00.000Z');
|
|
17
|
+
for(const value of ['2026-02-30','2026-10-03T24:00','2026-10-03T08:70'])assert.throws(()=>parsePublicationDate(value));
|
|
18
|
+
assert.throws(()=>validateTimeZone('not/a-zone'),/time zone/);
|
|
19
|
+
assert.throws(()=>parsePublicationDate('2026-03-08T02:30','America/New_York'),/nonexistent/);
|
|
20
|
+
assert.throws(()=>parsePublicationDate('2026-11-01T01:30','America/New_York'),/Ambiguous/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('build host timezone cannot affect publication parsing or writing timestamps',()=>{
|
|
24
|
+
const module=new URL('../src/publication-time.js',import.meta.url).href;
|
|
25
|
+
const script=`import {parsePublicationDate,publicationTimestamp} from ${JSON.stringify(module)}; console.log(parsePublicationDate('2026-10-03T09:15').toISOString(), publicationTimestamp(new Date('2026-10-02T20:00:00Z')));`;
|
|
26
|
+
const outputs=['UTC','America/Los_Angeles','Asia/Tokyo'].map(TZ=>{
|
|
27
|
+
const result=spawnSync(process.execPath,['--input-type=module','-e',script],{encoding:'utf8',env:{...process.env,TZ}});
|
|
28
|
+
assert.equal(result.status,0,result.stderr);return result.stdout;
|
|
29
|
+
});
|
|
30
|
+
assert.equal(new Set(outputs).size,1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('month boundary permalink follows written publication date, not UTC conversion',async()=>{
|
|
34
|
+
const folder=await mkdtemp(join(tmpdir(),'edgepress-date-'));
|
|
35
|
+
try{
|
|
36
|
+
await mkdir(join(folder,'posts/article'),{recursive:true});
|
|
37
|
+
await writeFile(join(folder,'posts/article/en.md'),'---\ntitle: Article\ndate: 2026-10-01T01:00\nlang: en\n---\nText');
|
|
38
|
+
const config={site:{timeZone:'Asia/Taipei'},resolvedPaths:{content:folder},i18n:{defaultLocale:'en',locales:['en']},concurrency:1,permalink:'/:year/:month/:day/:slug/'};
|
|
39
|
+
const [post]=await readDocuments(config);assert.equal(post.date.toISOString(),'2026-09-30T17:00:00.000Z');assert.equal(post.path,'2026/10/01/article/');assert.equal(post.dateOnly,false);
|
|
40
|
+
await writeFile(join(folder,'posts/article/en.md'),'---\ntitle: Article\ndate: 2026-10-01\nlang: en\n---\nText');
|
|
41
|
+
const [dateOnly]=await readDocuments(config);assert.equal(dateOnly.dateOnly,true);assert.equal(dateOnly.path,post.path);
|
|
42
|
+
}finally{await rm(folder,{recursive:true,force:true});}
|
|
43
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {execFile} from 'node:child_process';
|
|
4
|
+
import {promisify} from 'node:util';
|
|
5
|
+
import {mkdtemp,writeFile,readFile,rm,realpath,mkdir,cp} from 'node:fs/promises';
|
|
6
|
+
import {tmpdir} from 'node:os';
|
|
7
|
+
import {join,relative,isAbsolute} from 'node:path';
|
|
8
|
+
import {chromium} from 'playwright';
|
|
9
|
+
import {readPostRevision} from '../src/revisions.js';
|
|
10
|
+
import {changedPassages,renderRevisionCards} from '../src/revision-cards.js';
|
|
11
|
+
import {revisionImageFile} from '../src/revision-images.js';
|
|
12
|
+
import {collectImageDimensions} from '../src/image-dimensions.js';
|
|
13
|
+
import {writeAssets} from '../src/assets.js';
|
|
14
|
+
import {buildSite} from '../src/build.js';
|
|
15
|
+
const exec=promisify(execFile);
|
|
16
|
+
const source=(body,extra='')=>`---\ntitle: Article\n${extra}---\n${body}\n`;
|
|
17
|
+
test('builder restores actual old/new images, detects same-path pixel changes, and fingerprints only recent snapshots',async t=>{
|
|
18
|
+
const folder=await mkdtemp(join(tmpdir(),'edgepress-image-history-')),assetsRoot=join(folder,'content/assets'),postRoot=join(folder,'content/posts/2000-01-01-scene'),file=join(postRoot,'en.md');
|
|
19
|
+
t.after(async()=>{const parent=await realpath(tmpdir()),target=await realpath(folder),path=relative(parent,target);assert.ok(!isAbsolute(path)&&!path.startsWith('..'));await rm(target,{recursive:true,force:true});});
|
|
20
|
+
await mkdir(assetsRoot,{recursive:true});await mkdir(postRoot,{recursive:true});const git=args=>exec('git',args,{cwd:folder});await git(['init','-q']);await git(['config','user.name','Test']);await git(['config','user.email','test@example.invalid']);
|
|
21
|
+
const svg=color=>Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><rect width="120" height="80" fill="'+color+'"/></svg>');
|
|
22
|
+
const commit=async()=>{await git(['add','.']);await git(['commit','-qm','update']);};
|
|
23
|
+
const old=svg('red'),now=svg('blue');await writeFile(join(assetsRoot,'old.svg'),old);await writeFile(file,source('Previous wording.\n\n'));await commit();
|
|
24
|
+
await rm(join(assetsRoot,'old.svg'));await writeFile(join(assetsRoot,'new.svg'),now);await writeFile(file,source('Clearer wording.\n\n'));await commit();
|
|
25
|
+
let current=await readFile(file,'utf8'),revision=await readPostRevision(file,current,true,assetsRoot);assert.equal(revision.revisionAssets.length,2);assert.ok(revision.revisionAssets.some(asset=>asset.content.equals(old)));assert.ok(revision.revisionAssets.some(asset=>asset.content.equals(now)));
|
|
26
|
+
const labels={postChanges:'Recent changes',postChangeBefore:'Previously',postChangeAfter:'Now',postChangeAdded:'Added',postChangeRemoved:'Removed'},html=await renderRevisionCards(revision.changes,key=>labels[key],revision.revisionImages);assert.match(html,/Previous wording/);assert.match(html,/Clearer wording/);assert.equal((html.match(/<img /g)||[]).length,2);assert.doesNotMatch(html,/src="\/(?:old|new)\.svg"/);assert.match(html,/loading="lazy"/);
|
|
27
|
+
const output=join(folder,'output');await writeAssets({assets:revision.revisionAssets,existingHeaders:''},output);for(const asset of revision.revisionAssets)assert.deepEqual(await readFile(join(output,asset.path)),asset.content);assert.match(await readFile(join(output,'_headers'),'utf8'),/max-age=31536000, immutable/);const dimensions=await collectImageDimensions(revision.revisionAssets);assert.equal(dimensions.size,2);
|
|
28
|
+
await mkdir(join(folder,'languages/base'),{recursive:true});await writeFile(join(folder,'languages/base/en.json'),'{}');await writeFile(join(folder,'config.yml'),'site:\n title: Revision fixture\n url: https://revision.example\n language: en\nplugins:\n consent:\n enabled: false\n proposedDate: 2026-10-06\n expiresDays: 180\n');await cp(new URL('../themes/default',import.meta.url),join(folder,'themes/default'),{recursive:true});
|
|
29
|
+
await buildSite(folder);const published=await readFile(join(folder,'dist/2000/01/scene/index.html'),'utf8');assert.match(published,/Most recent changes/);assert.match(published,/<img[^>]*src="\/edgepress\/revisions\/image\.[a-f0-9]{16}\.svg"[^>]*width="120"[^>]*height="80"/);for(const asset of revision.revisionAssets)assert.deepEqual(await readFile(join(folder,'dist',asset.path)),asset.content);
|
|
30
|
+
await writeFile(join(assetsRoot,'new.svg'),svg('green'));await commit();revision=await readPostRevision(file,current,true,assetsRoot);assert.equal(revision.revisionAssets.length,2);assert.equal(revision.revisionImages.before['/new.svg']===revision.revisionImages.after['/new.svg'],false);assert.doesNotMatch(revision.changes,/Previous wording/);
|
|
31
|
+
await writeFile(file,source('Clearer wording.','image: /new.svg\n'));await commit();await writeFile(file,source('Clearer wording.','image: /old.svg\n'));await commit();revision=await readPostRevision(file,await readFile(file,'utf8'),true,assetsRoot);assert.match(revision.changes,/-!\[Cover\]\(\/new.svg\)/);assert.match(revision.changes,/\+!\[Cover\]\(\/old.svg\)/);assert.equal(revision.revisionAssets.length,1,'Missing bytes retain description without a broken request');
|
|
32
|
+
for(const href of ['https://outside.invalid/photo.png','//outside.invalid/a.png','/../en.md','/%2e%2e/secret.png','/a\\b.png'])assert.equal(revisionImageFile(href,assetsRoot),null);
|
|
33
|
+
});
|
|
34
|
+
test('article updates show safe readable before/after passages rather than Git patch data',async()=>{
|
|
35
|
+
const patch='@@ -1,3 +1,3 @@\n unchanged\n-Previous **explanation**\n+Updated **explanation** <script>bad()</script>\n unchanged\n@@ -9 +9,2 @@\n+An added [link](javascript:alert(1))\n';
|
|
36
|
+
assert.equal(changedPassages(patch).length,2);
|
|
37
|
+
const labels={postChanges:'Recent changes',postChangeBefore:'Previously',postChangeAfter:'Now',postChangeAdded:'Added',postChangeRemoved:'Removed'};
|
|
38
|
+
const html=await renderRevisionCards(patch,key=>labels[key]);
|
|
39
|
+
assert.match(html,/Previously/);assert.match(html,/Now/);assert.match(html,/<strong>explanation<\/strong>/);assert.match(html,/Added/);
|
|
40
|
+
assert.doesNotMatch(html,/@@|diff --git|unchanged|<script|javascript:|bad\(\)/);
|
|
41
|
+
assert.equal(await renderRevisionCards('@@ -1 +1 @@\n unchanged',key=>labels[key]),'');
|
|
42
|
+
assert.doesNotMatch(await renderRevisionCards('-# Old heading\n+# New heading',key=>labels[key]),/<h[1-6][ >]/);
|
|
43
|
+
const pictures=await renderRevisionCards('-\n+',key=>labels[key]);assert.match(pictures,/Old cover/);assert.match(pictures,/New cover/);assert.doesNotMatch(pictures,/<img|removed.svg|new.webp/);
|
|
44
|
+
});
|
|
45
|
+
test('revision cards wrap on narrow screens, follow each theme and support keyboard disclosure',async()=>{
|
|
46
|
+
const browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
|
|
47
|
+
const labels={postChanges:'What changed',postChangeBefore:'Previously',postChangeAfter:'Now',postChangeAdded:'Added',postChangeRemoved:'Removed'};
|
|
48
|
+
const html=await renderRevisionCards('-Images open directly in the page.\n+Click a picture to open the **full-size viewer**. Close it to return to your reading position.\n unchanged\n+The player adapts to your website colors.',key=>labels[key]);
|
|
49
|
+
try{for(const theme of ['default','folio','signal','atelier'])for(const width of [320,1000])for(const mode of ['light','dark']){
|
|
50
|
+
const context=await browser.newContext({viewport:{width,height:700},colorScheme:mode}),page=await context.newPage();
|
|
51
|
+
const css=await readFile(new URL('../themes/'+theme+'/assets/style.css',import.meta.url),'utf8');
|
|
52
|
+
await page.setContent('<!doctype html><html lang="en" data-theme="'+mode+'"><head><title>Article update preview</title><style>'+css+'</style></head><body><main><article class="post"><header><h1>Small improvements, explained.</h1></header>'+html+'</article></main></body></html>');
|
|
53
|
+
await page.locator('summary').focus();await page.keyboard.press('Enter');assert.equal(await page.locator('details').getAttribute('open'),'');
|
|
54
|
+
assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1),theme+' '+mode+' '+width);
|
|
55
|
+
await page.evaluate(()=>document.documentElement.style.setProperty('--accent','#7242b4'));const color=await page.locator('.post-change-after').first().evaluate(node=>getComputedStyle(node).borderInlineStartColor);assert.equal(color,'rgb(114, 66, 180)');
|
|
56
|
+
if(process.env.EDGEPRESS_CAPTURE && theme==='default' && width===1000 && mode==='light'){await mkdir('content/assets/images/previews',{recursive:true});await page.locator('main').screenshot({path:'content/assets/images/previews/edgepress-revisions-en.png'});}
|
|
57
|
+
await context.close();
|
|
58
|
+
}}finally{await browser.close();}
|
|
59
|
+
});
|
|
60
|
+
test('actual content history excludes metadata and hides the diff by default',async t=>{
|
|
61
|
+
const prefix=join(tmpdir(),'edgepress-history-');const folder=await mkdtemp(prefix);
|
|
62
|
+
t.after(async()=>{const parent=await realpath(tmpdir()), target=await realpath(folder), path=relative(parent,target);assert.ok(!isAbsolute(path)&&!path.startsWith('..')&&target.startsWith(prefix));await rm(target,{recursive:true,force:true});});
|
|
63
|
+
const git=args=>exec('git',args,{cwd:folder});const file=join(folder,'en.md');
|
|
64
|
+
await git(['init','-q']);await git(['config','user.name','Test']);await git(['config','user.email','test@example.invalid']);
|
|
65
|
+
const commit=async text=>{await writeFile(file,text);await git(['add','en.md']);await git(['commit','-qm','update']);};
|
|
66
|
+
await commit(source('Original content'));assert.deepEqual(await readPostRevision(file,source('Original content'),true),{updated:null,changes:''});
|
|
67
|
+
await commit(source('Changed content <script>danger</script>'));
|
|
68
|
+
await commit(source('Changed content <script>danger</script>','pinned: true\n'));
|
|
69
|
+
const current=await readFile(file,'utf8');const hidden=await readPostRevision(file,current);
|
|
70
|
+
assert.ok(hidden.updated instanceof Date);assert.equal(hidden.changes,'');
|
|
71
|
+
const visible=await readPostRevision(file,current,true);
|
|
72
|
+
assert.equal(visible.updated.toISOString(),hidden.updated.toISOString());
|
|
73
|
+
assert.match(visible.changes,/-Original content/);assert.match(visible.changes,/\+Changed content/);assert.doesNotMatch(visible.changes,/pinned:|diff --git|before\.md|after\.md/);
|
|
74
|
+
await writeFile(file,source('Working content'));
|
|
75
|
+
assert.match((await readPostRevision(file,source('Working content'),true)).changes,/\+Working content/);
|
|
76
|
+
assert.deepEqual(await readPostRevision(join(folder,'untracked.md'),source('Never published'),true),{updated:null,changes:''});
|
|
77
|
+
});
|
|
78
|
+
test('reader timezone changes timestamps while preserving date-only publication dates',async()=>{
|
|
79
|
+
const script=await readFile(new URL('../static/edgepress/local-time.js',import.meta.url),'utf8');
|
|
80
|
+
const browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
|
|
81
|
+
try{
|
|
82
|
+
const values=[];
|
|
83
|
+
for(const timeZone of ['Asia/Tokyo','America/Los_Angeles']) {
|
|
84
|
+
const context=await browser.newContext({timezoneId:timeZone});const page=await context.newPage();
|
|
85
|
+
await page.setContent('<time data-local-time data-time-locale="en" datetime="2026-10-03T00:30:00Z"></time><time data-local-time data-time-locale="en" data-date-only="true" datetime="2026-10-03T00:00:00Z"></time>');
|
|
86
|
+
await page.addScriptTag({content:script});values.push(await page.locator('time').allTextContents());
|
|
87
|
+
assert.equal(await page.locator('time').last().getAttribute('title'),'2026-10-03');
|
|
88
|
+
await context.close();
|
|
89
|
+
}
|
|
90
|
+
assert.match(values[0][0],/October 3/);assert.match(values[1][0],/October 2/);
|
|
91
|
+
assert.equal(values[0][1],values[1][1]);assert.match(values[0][1],/October 3/);
|
|
92
|
+
}finally{await browser.close();}
|
|
93
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { generateRssRoute, absoluteContentUrls } from '../src/rss.js';
|
|
4
|
+
|
|
5
|
+
const config = {
|
|
6
|
+
site: { url: 'https://example.test', title: 'A & B', description: 'News <analysis>', author: { name: 'Editor' } },
|
|
7
|
+
i18n: { defaultLocale: 'en', locales: ['en', 'zh-CN'] },
|
|
8
|
+
markdown: { gfm: true, breaks: false }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function post(overrides = {}) {
|
|
12
|
+
return {
|
|
13
|
+
title: 'Title & <tag>', author: 'Writer', locale: 'en', path: '2026/10/scene/',
|
|
14
|
+
date: new Date('2026-10-15T09:00:00Z'), updated: new Date('2026-10-17T10:00:00Z'),
|
|
15
|
+
category: 'science', tags: ['AI', 'science'],
|
|
16
|
+
description: 'Summary & details',
|
|
17
|
+
markdown: '## Introduction\n\nLong **article** text with [local link](/notes/?a=1&b=2), a  and <script>unsafe()</script>.\n\nA second paragraph.',
|
|
18
|
+
...overrides
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('RSS 2.0 gives AI readers a stable canonical item, full safe HTML, and correct update metadata', async () => {
|
|
23
|
+
const route = await generateRssRoute([post()], config, 'en');
|
|
24
|
+
assert.equal(route.path, 'rss.xml');
|
|
25
|
+
assert.equal(route.contentType, 'application/rss+xml; charset=utf-8');
|
|
26
|
+
assert.match(route.body, /<rss version="2\.0" /);
|
|
27
|
+
assert.match(route.body, /<atom:link href="https:\/\/example\.test\/rss\.xml" rel="self" type="application\/rss\+xml"\/>/);
|
|
28
|
+
assert.match(route.body, /<title>Title & <tag><\/title>/);
|
|
29
|
+
assert.match(route.body, /<guid isPermaLink="true">https:\/\/example\.test\/2026\/10\/scene\/<\/guid>/);
|
|
30
|
+
assert.match(route.body, /<dc:creator>Writer<\/dc:creator>/);
|
|
31
|
+
assert.match(route.body, /<dc:date>2026-10-17T10:00:00\.000Z<\/dc:date>/);
|
|
32
|
+
assert.match(route.body, /<pubDate>Thu, 15 Oct 2026 09:00:00 GMT<\/pubDate>/);
|
|
33
|
+
assert.match(route.body, /<lastBuildDate>Sat, 17 Oct 2026 10:00:00 GMT<\/lastBuildDate>/);
|
|
34
|
+
assert.match(route.body, /<content:encoded>[\s\S]*Introduction[\s\S]*second paragraph[\s\S]*<\/content:encoded>/i);
|
|
35
|
+
assert.match(route.body, /https:\/\/example\.test\/images\/scene\.webp/);
|
|
36
|
+
assert.match(route.body, /https:\/\/example\.test\/notes\/\?a=1&amp;b=2/);
|
|
37
|
+
assert.doesNotMatch(route.body, /unsafe\(\)|<script|<iframe/);
|
|
38
|
+
assert.equal((route.body.match(/<category>/g) || []).length, 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('RSS locale paths, item limit, empty feeds and special characters', async () => {
|
|
42
|
+
const localized = await generateRssRoute([post({ title: '中文 & 新闻', locale: 'zh-CN', path: 'zh-CN/中文/' })], config, 'zh-CN');
|
|
43
|
+
assert.equal(localized.path, 'zh-CN/rss.xml');
|
|
44
|
+
assert.match(localized.body, /<language>zh-CN<\/language>/);
|
|
45
|
+
assert.match(localized.body, /<dc:language>zh-CN<\/dc:language>/);
|
|
46
|
+
assert.match(localized.body, /%E4%B8%AD%E6%96%87/);
|
|
47
|
+
assert.match(localized.body, /中文 & 新闻/);
|
|
48
|
+
const empty = await generateRssRoute([], config, 'en');
|
|
49
|
+
assert.doesNotMatch(empty.body, /<item>/);
|
|
50
|
+
const limited = await generateRssRoute(Array.from({ length: 22 }, (_, i) => post({ title: String(i) })), config, 'en');
|
|
51
|
+
assert.equal((limited.body.match(/<item>/g) || []).length, 20);
|
|
52
|
+
assert.match(absoluteContentUrls('<a href="../x?a=1&b=2">X</a>', 'https://example.test/section/article/'),
|
|
53
|
+
/href="https:\/\/example\.test\/section\/x\?a=1&b=2"/);
|
|
54
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {callService} from '../static/edgepress/services.js';
|
|
4
|
+
import {choiceSnapshot} from '../static/edgepress/plugins/consent/choices.js';
|
|
5
|
+
import {extendConsentPolicy} from '../src/consent-csp.js';
|
|
6
|
+
import {deploymentCommand} from '../src/deploy.js';
|
|
7
|
+
|
|
8
|
+
test('optional API calls use a fixed endpoint and header actions after current service consent',async()=>{
|
|
9
|
+
const names=['document','localStorage','sessionStorage','fetch'];
|
|
10
|
+
const original=Object.fromEntries(names.map(name=>[name,Object.getOwnPropertyDescriptor(globalThis,name)]));
|
|
11
|
+
const privacy={controller:{name:'Publisher',contact:'publisher@example.com'},policyUrl:'/privacy/',consent:{proposedDate:'2026-10-03',expiresDays:180},integrations:[{id:'lookup',provider:'external-api',backendUrl:'https://service.example/backend/cloudflare/'}]};
|
|
12
|
+
const config={privacy,choiceFingerprint:choiceSnapshot(privacy)};
|
|
13
|
+
let saved=null,calls=0;
|
|
14
|
+
try {
|
|
15
|
+
globalThis.document={getElementById:()=>({textContent:JSON.stringify(config)}),cookie:''};
|
|
16
|
+
globalThis.localStorage=globalThis.sessionStorage={getItem:()=>saved};
|
|
17
|
+
globalThis.fetch=async(address,options)=>{calls++;assert.equal(address.href,'https://service.example/backend/cloudflare/');assert.equal(options.credentials,'omit');assert.equal(options.headers.get('X-Service-Action'),'resolve');assert.equal(options.headers.get('X-Service-Context'),'example.com');return Response.json({ok:true});};
|
|
18
|
+
await assert.rejects(callService('lookup','resolve',{headers:{'X-Service-Context':'example.com'}}),/consent/);assert.equal(calls,0);
|
|
19
|
+
saved=JSON.stringify({proposedDate:'2026-10-03',effectiveDate:'',fingerprint:config.choiceFingerprint,expiresAt:Date.now()+60000,allowed:['lookup']});
|
|
20
|
+
await callService('lookup','resolve',{headers:{'X-Service-Context':'example.com'}});assert.equal(calls,1);
|
|
21
|
+
for(const path of ['https://attacker.example','//attacker.example','../private','%2e%2e/private','\\private'])await assert.rejects(callService('lookup',path));
|
|
22
|
+
config.privacy.integrations[0].backendUrl='https://changed.example/';config.choiceFingerprint=choiceSnapshot(config.privacy);
|
|
23
|
+
await assert.rejects(callService('lookup','resolve'),/consent/);assert.equal(calls,1);
|
|
24
|
+
}finally{for(const [name,descriptor] of Object.entries(original)){if(descriptor)Object.defineProperty(globalThis,name,descriptor);else delete globalThis[name];}}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('CSP permits only enabled configured service origins and deployment selects native static CLIs',()=>{
|
|
28
|
+
assert.deepEqual(deploymentCommand('netlify','dist'),['netlify-cli','deploy','--prod','--dir','dist']);
|
|
29
|
+
const header="/*\n Content-Security-Policy: default-src 'self'; script-src 'self'; connect-src 'none'";
|
|
30
|
+
const services=[{enabled:false,provider:'external-widget',moduleUrl:'https://comments.example/widget.js',backendUrl:'https://comments.example'}];
|
|
31
|
+
assert.equal(extendConsentPolicy(header,{browserPlugins:{services}}),header);
|
|
32
|
+
services[0].enabled=true;const policy=extendConsentPolicy(header,{browserPlugins:{services}});
|
|
33
|
+
assert.match(policy,/script-src 'self' https:\/\/comments.example/);assert.match(policy,/frame-src 'self' https:\/\/comments.example/);assert.doesNotMatch(policy,/connect-src 'none'/);
|
|
34
|
+
assert.deepEqual(deploymentCommand('vercel'),['vercel','deploy','--prod']);assert.deepEqual(deploymentCommand('edgeone','dist',['-n','site']),['edgeone','pages','deploy','dist','-n','site']);assert.throws(()=>deploymentCommand('invalid'),/Platform/);
|
|
35
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { chromium } from 'playwright';
|
|
7
|
+
import { loadConfig } from '../src/config.js';
|
|
8
|
+
import { loadLanguagePacks } from '../src/i18n.js';
|
|
9
|
+
import { createExtensions } from '../src/plugin-api.js';
|
|
10
|
+
import { renderBlocks } from '../src/page-blocks.js';
|
|
11
|
+
import { renderLayout } from '../src/theme.js';
|
|
12
|
+
|
|
13
|
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
14
|
+
const portrait = '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="900"><rect width="300" height="900" fill="#7d9688"/></svg>';
|
|
15
|
+
const landscape = '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675"><rect width="1200" height="675" fill="#b6b09a"/></svg>';
|
|
16
|
+
|
|
17
|
+
test('theme layouts keep navigation, portrait media, columns and floating controls readable', async () => {
|
|
18
|
+
const browser = await chromium.launch({ headless: true });
|
|
19
|
+
try {
|
|
20
|
+
for (const theme of ['default', 'atelier', 'signal', 'folio']) {
|
|
21
|
+
const config = await loadConfig(root);
|
|
22
|
+
await loadLanguagePacks(config);
|
|
23
|
+
config.resolvedPaths.theme = config.realPaths.theme = resolve(root, 'themes', theme);
|
|
24
|
+
config.site.title = config.site.header.brandLabel = 'Signal';
|
|
25
|
+
config.site.navigation = ['home', 'archives', 'search'].map(key => ({ key, url: '@' + key }));
|
|
26
|
+
config.site.navigation.push({ key: 'about', url: '/about/', labels: { en: 'About', 'zh-CN': '关于' } });
|
|
27
|
+
const extensions = await createExtensions(config);
|
|
28
|
+
const blocks = [
|
|
29
|
+
{ columns: 1, cells: [[{ type: 'hero', title: 'A readable magazine spread', text: 'A whole paragraph introducing the current issue.',
|
|
30
|
+
bannerSrc: '/landscape.svg', bannerAlt: 'Landscape fixture', bannerWidth: 1200, bannerHeight: 675,
|
|
31
|
+
bannerWebpSrcset: undefined }]] },
|
|
32
|
+
{ columns: 2, cells: [[{ type: 'image', src: '/portrait.svg', alt: 'Complete portrait fixture', caption: 'A complete caption below the image.' }],
|
|
33
|
+
[{ type: 'code', title: 'Local command', code: 'npm run build' }]] },
|
|
34
|
+
{ columns: 1, cells: [[{ type: 'media-text', mediaType: 'image', src: '/portrait.svg', alt: 'Another complete portrait',
|
|
35
|
+
caption: 'No crop and no horizontal overflow.', placement: 'right', title: 'Media with text', text: 'A complete argument stays alongside the illustration.' }]] }
|
|
36
|
+
];
|
|
37
|
+
const content = await renderBlocks(blocks, { config, locale: 'en', site: { posts: [], pages: [] }, isHomepage: true });
|
|
38
|
+
const controls = '<div class="post-toc-widget"><button class="post-toc-toggle">Contents</button><div class="post-toc-panel" hidden><ol><li><a href="#main">Section</a></li></ol></div></div>' +
|
|
39
|
+
'<form class="local-search-form"><div class="local-search-controls"><input type="search" aria-label="Search"><button>Search</button></div></form>';
|
|
40
|
+
const html = await renderLayout(config, extensions, { title: 'Fixture', locale: 'en', urlPath: '/' }, '<article class="page-builder">' + content + controls + '</article>');
|
|
41
|
+
const context = await browser.newContext();
|
|
42
|
+
await context.route('**/*', async route => {
|
|
43
|
+
const path = new URL(route.request().url()).pathname;
|
|
44
|
+
if (path === '/') return route.fulfill({ contentType: 'text/html', body: html });
|
|
45
|
+
if (path === '/style.css') return route.fulfill({ contentType: 'text/css', body: await readFile(resolve(root, 'themes', theme, 'assets/style.css')) });
|
|
46
|
+
if (path === '/portrait.svg' || path === '/landscape.svg') return route.fulfill({ contentType: 'image/svg+xml', body: path === '/portrait.svg' ? portrait : landscape });
|
|
47
|
+
if (path.startsWith('/folio/')) return route.fulfill({ contentType: 'text/javascript', body: await readFile(resolve(root, 'themes', theme, 'assets', '.' + path)) });
|
|
48
|
+
try { return route.fulfill({ body: await readFile(resolve(root, 'static', '.' + path)), contentType: path.endsWith('.js') ? 'text/javascript' : path.endsWith('.svg') ? 'image/svg+xml' : 'image/png' }); }
|
|
49
|
+
catch { return route.fulfill({ status: 404, body: '' }); }
|
|
50
|
+
});
|
|
51
|
+
const page = await context.newPage();
|
|
52
|
+
for (const colorScheme of ['light', 'dark']) for (const width of [320, 390, 768, 1440]) {
|
|
53
|
+
await page.setViewportSize({ width, height: 900 });
|
|
54
|
+
await page.emulateMedia({ colorScheme });
|
|
55
|
+
await page.goto('https://layout.invalid/');
|
|
56
|
+
await page.locator('.privacy-settings-button').waitFor();
|
|
57
|
+
const label = `${theme} ${width} ${colorScheme}`;
|
|
58
|
+
assert.equal(await page.locator('main h1').count(), 1, label);
|
|
59
|
+
if (theme === 'folio') assert(await page.locator('.brand-description').evaluate(p => { const style=getComputedStyle(p); return style.display !== 'none' && style.whiteSpace !== 'nowrap' && p.scrollWidth <= p.clientWidth + 1 && p.scrollHeight <= p.clientHeight + 1; }), label + ': clipped masthead description');
|
|
60
|
+
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), label + ': overflow');
|
|
61
|
+
const brand = await page.locator('.brand').boundingBox();
|
|
62
|
+
const locale = await page.locator('.locale-nav').boundingBox();
|
|
63
|
+
const nav = await page.locator('.primary-navigation').boundingBox();
|
|
64
|
+
const overlaps = (a,b) => a.x < b.x+b.width-1 && a.x+a.width > b.x+1 && a.y < b.y+b.height-1 && a.y+a.height > b.y+1;
|
|
65
|
+
assert(!overlaps(brand,locale) && !overlaps(brand,nav) && !overlaps(locale,nav), label + ': header overlap');
|
|
66
|
+
for (const image of await page.locator('.image-block img,.media-text-block img,.hero-banner').all()) {
|
|
67
|
+
await image.scrollIntoViewIfNeeded();
|
|
68
|
+
await image.evaluate(img => img.decode());
|
|
69
|
+
const ratio = await image.evaluate(img => { const box = img.getBoundingClientRect(); return box.width / box.height / (img.naturalWidth / img.naturalHeight); });
|
|
70
|
+
assert(Math.abs(ratio-1) < .01, label + ': image cropped or distorted (' + ratio + ')');
|
|
71
|
+
}
|
|
72
|
+
const figure = await page.locator('.image-block').boundingBox();
|
|
73
|
+
const cell = await page.locator('.image-block').locator('..').boundingBox();
|
|
74
|
+
assert(Math.abs(figure.x-cell.x) < 1 && Math.abs(figure.width-cell.width) < 1, label + ': figure inset');
|
|
75
|
+
const copy = await page.locator('.code-widget').boundingBox();
|
|
76
|
+
const copyCell = await page.locator('.code-widget').locator('..').boundingBox();
|
|
77
|
+
assert(Math.abs(copy.x-copyCell.x) < 1, label + ': code inset');
|
|
78
|
+
assert(!overlaps(await page.locator('.post-toc-toggle').boundingBox(), await page.locator('.privacy-settings-button').boundingBox()), label + ': controls overlap');
|
|
79
|
+
assert.equal(await page.locator('.post-toc-panel ol').evaluate(list => getComputedStyle(list).listStyleType), 'none', label + ': numbered TOC');
|
|
80
|
+
assert(await page.locator('input[type="search"]').evaluate(input => {
|
|
81
|
+
const style = getComputedStyle(input);
|
|
82
|
+
const luminance = value => { const rgb = value.match(/[\d.]+/g).slice(0,3).map(Number).map(x=>{x/=255;return x<=.04045?x/12.92:((x+.055)/1.055)**2.4;}); return rgb[0]*.2126+rgb[1]*.7152+rgb[2]*.0722; };
|
|
83
|
+
const a=luminance(style.color),b=luminance(style.backgroundColor);
|
|
84
|
+
return (Math.max(a,b)+.05)/(Math.min(a,b)+.05)>=4.5;
|
|
85
|
+
}), label + ': unreadable input');
|
|
86
|
+
if (theme === 'folio' && width === 1440) {
|
|
87
|
+
const title = await page.locator('.hero-main').boundingBox();
|
|
88
|
+
const image = await page.locator('.hero-banner').boundingBox();
|
|
89
|
+
assert(image.x >= title.x + title.width, label + ': spread does not have two columns');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
await context.close();
|
|
93
|
+
}
|
|
94
|
+
} finally { await browser.close(); }
|
|
95
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {collectVideoMetadata, reserveVideoMetadata} from '../src/video-metadata.js';
|
|
4
|
+
import {dataSaverHtml, configureDataSaver} from '../src/data-saver.js';
|
|
5
|
+
import {loadConfig} from '../src/config.js';
|
|
6
|
+
|
|
7
|
+
test('local video sidecars reserve measured previews without downloading media and survive text-view gating', async () => {
|
|
8
|
+
const assets = [
|
|
9
|
+
{path:'media/clip.mp4'}, {path:'media/clip.mp4.poster.jpg'},
|
|
10
|
+
{path:'media/clip.mp4.json', content:Buffer.from(JSON.stringify({duration:150.001,width:960,height:540,poster:'clip.mp4.poster.jpg'}))}
|
|
11
|
+
];
|
|
12
|
+
const metadata = await collectVideoMetadata(assets);
|
|
13
|
+
const source = '<figure><video data-edgepress-video preload="none" aria-label="Clip"><source src="/media/clip.mp4" type="video/mp4"></video><figcaption>Caption</figcaption></figure>';
|
|
14
|
+
const html = reserveVideoMetadata(source, metadata, '2026/10/report/index.html', 'https://site.example');
|
|
15
|
+
assert.match(html, /data-duration="150\.001" poster="\/media\/clip\.mp4\.poster\.jpg" width="960" height="540"/);
|
|
16
|
+
assert.equal(reserveVideoMetadata(html, metadata, 'index.html', 'https://site.example'),html);
|
|
17
|
+
assert.equal(reserveVideoMetadata(source.replace('/media/clip.mp4','https://other.example/media/clip.mp4'),metadata,'index.html','https://site.example'),source.replace('/media/clip.mp4','https://other.example/media/clip.mp4'));
|
|
18
|
+
assert.match(reserveVideoMetadata(source.replace('preload="none"','preload="none" poster="/chosen.jpg"'),metadata,'index.html','https://site.example'),/poster="\/chosen.jpg"/);
|
|
19
|
+
const config = await loadConfig(process.cwd()); config.site.dataSaver.enabled=true; await configureDataSaver(config);
|
|
20
|
+
const gated = dataSaverHtml('<html><head></head><body>'+html+'</body></html>',config,{});
|
|
21
|
+
assert.match(gated,/<template data-edgepress-data-html>.*data-duration="150\.001".*poster="\/media\/clip\.mp4\.poster\.jpg"/s);
|
|
22
|
+
assert.match(gated,/<\/div><figcaption>Caption<\/figcaption>/);
|
|
23
|
+
assert.deepEqual(await collectVideoMetadata([{path:'media/clip.mp4'}]),new Map());
|
|
24
|
+
for (const change of [{duration:0},{poster:'https://external.example/image.jpg'},{poster:'missing.jpg'}]) {
|
|
25
|
+
const bad=assets.map(item=>item.path.endsWith('.json')?{...item,content:Buffer.from(JSON.stringify({duration:150,width:960,height:540,poster:'clip.mp4.poster.jpg',...change}))}:item);
|
|
26
|
+
await assert.rejects(collectVideoMetadata(bad));
|
|
27
|
+
}
|
|
28
|
+
});
|