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,47 @@
|
|
|
1
|
+
export const defaultTimeZone = 'Asia/Taipei';
|
|
2
|
+
export function validateTimeZone(value) {
|
|
3
|
+
if (typeof value !== 'string' || !value || value.length > 80) throw new Error('site.timeZone must be an IANA time zone');
|
|
4
|
+
try { new Intl.DateTimeFormat('en',{timeZone:value}).format(); }
|
|
5
|
+
catch { throw new Error('site.timeZone must be a valid IANA time zone'); }
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
function parts(date,timeZone) {
|
|
9
|
+
return Object.fromEntries(new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',
|
|
10
|
+
hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23'}).formatToParts(date).filter(part=>part.type!=='literal').map(part=>[part.type,part.value]));
|
|
11
|
+
}
|
|
12
|
+
const wall = values => `${values.year}-${values.month}-${values.day}T${values.hour}:${values.minute}:${values.second}`;
|
|
13
|
+
function offset(date,timeZone) {
|
|
14
|
+
const values=parts(date,timeZone);
|
|
15
|
+
return Date.parse(wall(values)+'Z')-Math.floor(date.valueOf()/1000)*1000;
|
|
16
|
+
}
|
|
17
|
+
export function publicationTimestamp(date=new Date(),timeZone=defaultTimeZone) {
|
|
18
|
+
const values=parts(date,timeZone),minutes=offset(date,timeZone)/60000;
|
|
19
|
+
const sign=minutes<0?'-':'+';
|
|
20
|
+
return wall(values)+sign+String(Math.floor(Math.abs(minutes)/60)).padStart(2,'0')+':'+String(Math.abs(minutes)%60).padStart(2,'0');
|
|
21
|
+
}
|
|
22
|
+
export function parsePublicationDate(value,timeZone=defaultTimeZone) {
|
|
23
|
+
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value;
|
|
24
|
+
if (typeof value !== 'string') throw new Error('Publication date must be a date or timestamp');
|
|
25
|
+
const match=value.match(/^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})?)?$/);
|
|
26
|
+
if (!match || new Date(match[1]+'T00:00:00Z').toISOString().slice(0,10)!==match[1]) throw new Error('Invalid publication date');
|
|
27
|
+
if (!match[2]) return new Date(match[1]+'T00:00:00Z'); // Calendar date, never a fabricated local midnight.
|
|
28
|
+
const local=match[1]+'T'+match[2]+':'+match[3]+':'+(match[4]||'00');
|
|
29
|
+
const naive=Date.parse(local+(match[5]||'')+'Z');
|
|
30
|
+
if (!Number.isFinite(naive) || new Date(naive).toISOString().slice(0,19)!==local) throw new Error('Invalid publication time');
|
|
31
|
+
if (match[6]) {
|
|
32
|
+
const date=new Date(local+(match[5]||'')+match[6]);
|
|
33
|
+
if (!Number.isNaN(date.valueOf())) return date;
|
|
34
|
+
throw new Error('Invalid publication offset');
|
|
35
|
+
}
|
|
36
|
+
const candidates=new Set();
|
|
37
|
+
// Probe both sides of DST transitions. Ambiguous/nonexistent times require an explicit offset.
|
|
38
|
+
for(const delta of [-172800000,0,172800000]) {
|
|
39
|
+
const candidate=new Date(naive-offset(new Date(naive+delta),timeZone));
|
|
40
|
+
if(wall(parts(candidate,timeZone))===local)candidates.add(candidate.valueOf());
|
|
41
|
+
}
|
|
42
|
+
if(candidates.size!==1)throw new Error('Ambiguous or nonexistent publication time; include an explicit timezone offset');
|
|
43
|
+
return new Date([...candidates][0]);
|
|
44
|
+
}
|
|
45
|
+
export function publicationCalendar(value,date,timeZone=defaultTimeZone) {
|
|
46
|
+
return typeof value==='string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0,10) : publicationTimestamp(date,timeZone).slice(0,10);
|
|
47
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {lstat, mkdir, realpath} from 'node:fs/promises';
|
|
2
|
+
import {resolve, relative, isAbsolute, sep} from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function reportDirectory(root, create = true) {
|
|
5
|
+
const project = await realpath(root);
|
|
6
|
+
for (const directory of [resolve(root, 'tools'), resolve(root, 'tools/reports')]) {
|
|
7
|
+
try {
|
|
8
|
+
const info = await lstat(directory);
|
|
9
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('Report directory must be a real directory: ' + directory);
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (error.code !== 'ENOENT') throw error;
|
|
12
|
+
if (!create) return null;
|
|
13
|
+
await mkdir(directory);
|
|
14
|
+
}
|
|
15
|
+
const path = await realpath(directory), local = relative(project, path);
|
|
16
|
+
if (local === '..' || local.startsWith('..' + sep) || isAbsolute(local)) throw new Error('Report directory must stay inside the project root');
|
|
17
|
+
}
|
|
18
|
+
return resolve(root, 'tools/reports');
|
|
19
|
+
}
|
package/src/report-visuals.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import {reportDirectory} from './report-path.js';
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
3
|
import { access, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { createServer } from 'node:http';
|
|
4
5
|
import { extname, relative, resolve, sep } from 'node:path';
|
|
5
6
|
import { tmpdir } from 'node:os';
|
|
6
7
|
import { spawn } from 'node:child_process';
|
|
8
|
+
import { auditBrowser, deviceProfiles } from './browser-audit.js';
|
|
7
9
|
|
|
8
10
|
function escapeHtml(value) {
|
|
9
11
|
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
|
@@ -64,7 +66,9 @@ function withTimeout(promise, timeoutMs, message) {
|
|
|
64
66
|
async function startHeadlessBrowser(executable, profile) {
|
|
65
67
|
const port = await freePort();
|
|
66
68
|
const child = spawn(executable, [
|
|
67
|
-
'--headless=new', '--
|
|
69
|
+
'--headless=new', '--mute-audio', '--disable-notifications',
|
|
70
|
+
'--disable-sync', '--disable-features=msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion',
|
|
71
|
+
'--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-gpu', '--no-sandbox', '--no-first-run', '--no-default-browser-check',
|
|
68
72
|
'--disable-background-networking', '--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost, EXCLUDE 127.0.0.1',
|
|
69
73
|
'--remote-debugging-port=' + port, '--remote-allow-origins=*', '--user-data-dir=' + profile, 'about:blank'
|
|
70
74
|
], { stdio: 'ignore', windowsHide: true });
|
|
@@ -114,17 +118,29 @@ async function startHeadlessBrowser(executable, profile) {
|
|
|
114
118
|
});
|
|
115
119
|
await send('Page.enable');
|
|
116
120
|
await send('Runtime.enable');
|
|
121
|
+
await send('Accessibility.enable');
|
|
117
122
|
const setViewport = async (viewport) => {
|
|
118
123
|
await send('Emulation.setDeviceMetricsOverride', {
|
|
119
|
-
width: viewport.width, height: viewport.height, deviceScaleFactor:
|
|
124
|
+
width: viewport.width, height: viewport.height, deviceScaleFactor: viewport.deviceScaleFactor || 1,
|
|
125
|
+
mobile: viewport.mobile ?? viewport.name === 'mobile',
|
|
120
126
|
screenWidth: viewport.width, screenHeight: viewport.height
|
|
121
127
|
});
|
|
128
|
+
await send('Emulation.setTouchEmulationEnabled', { enabled: viewport.touch ?? viewport.name === 'mobile', maxTouchPoints: 5 });
|
|
129
|
+
await send('Emulation.setEmulatedMedia', { features: viewport.colorScheme
|
|
130
|
+
? [{ name: 'prefers-color-scheme', value: viewport.colorScheme }] : [] });
|
|
122
131
|
};
|
|
123
132
|
const navigate = async (url) => {
|
|
133
|
+
await send('Page.bringToFront');
|
|
124
134
|
const loaded = waitFor('Page.loadEventFired');
|
|
125
135
|
const result = await send('Page.navigate', { url });
|
|
126
136
|
if (result.errorText) throw new Error('Could not load screenshot page: ' + result.errorText);
|
|
127
|
-
await withTimeout(loaded, 20000, 'Screenshot page load timed out');
|
|
137
|
+
try { await withTimeout(loaded, 20000, 'Screenshot page load timed out: ' + url); }
|
|
138
|
+
catch (error) {
|
|
139
|
+
// Chromium can omit loadEventFired after navigation to an already loaded URL.
|
|
140
|
+
const state=await send('Runtime.evaluate',{expression:'JSON.stringify({url:location.href,state:document.readyState})',returnByValue:true});
|
|
141
|
+
const document=JSON.parse(state.result.value||'{}');
|
|
142
|
+
if(document.url!==url||document.state!=='complete')throw error;
|
|
143
|
+
}
|
|
128
144
|
await send('Runtime.evaluate', { expression: 'new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 150)))', awaitPromise: true });
|
|
129
145
|
};
|
|
130
146
|
const screenshot = async ({ hideConsent = false, clipSelector = '' } = {}) => {
|
|
@@ -186,7 +202,7 @@ async function startHeadlessBrowser(executable, profile) {
|
|
|
186
202
|
await new Promise((resolveExit) => child.once('exit', resolveExit));
|
|
187
203
|
}
|
|
188
204
|
};
|
|
189
|
-
return { setViewport, navigate, screenshot, metrics, printToPdf, close };
|
|
205
|
+
return { setViewport, navigate, screenshot, metrics, printToPdf, close, audit: profile => auditBrowser(send, profile) };
|
|
190
206
|
} catch (error) {
|
|
191
207
|
if (child.exitCode === null) {
|
|
192
208
|
child.kill();
|
|
@@ -222,16 +238,30 @@ function reportHtml(report, screenshots) {
|
|
|
222
238
|
const consentPassed = report.consentUi?.passed || 0;
|
|
223
239
|
const consentErrors = Math.max(0, consentChecks - consentPassed);
|
|
224
240
|
const consentScore = consentChecks ? Math.floor(consentPassed / consentChecks * 100) + '%' : '—';
|
|
241
|
+
const browserChecks = report.browserChecks || { status: 'not run', results: [] };
|
|
242
|
+
const browserRows = [['Device emulation', ['device']], ['Browser structure and accessibility tree', ['structure', 'semantics']], ['Keyboard sample', ['keyboard']]]
|
|
243
|
+
.map(([label, groups]) => {
|
|
244
|
+
const checks = browserChecks.results.flatMap(result => result.checks).filter(check => groups.includes(check.group));
|
|
245
|
+
const errors = checks.filter(check => !check.passed).length;
|
|
246
|
+
const status = ['unavailable', 'incomplete'].includes(browserChecks.status) ? browserChecks.status : checks.length ? errors ? 'fail' : 'pass' : 'not run';
|
|
247
|
+
return '<tr><th scope="row">' + label + '</th><td>' + escapeHtml(status) + '</td><td>—</td><td>' + checks.length + '</td><td>' + errors + '</td><td>0</td></tr>';
|
|
248
|
+
}).join('');
|
|
249
|
+
const browserDetails = '<p>Device profiles run in an isolated headless Chromium browser. Accessibility-tree checks inspect what assistive technology can receive; no physical device or screen reader was operated. Keyboard checks use real Tab, Shift+Tab and Enter events, sampling at most 12 focus stops per page/profile.</p>' +
|
|
250
|
+
'<p>Status: ' + escapeHtml(browserChecks.status) + (browserChecks.reason ? ' · ' + escapeHtml(browserChecks.reason) : '') + '</p>' +
|
|
251
|
+
(browserChecks.results.length ? '<table><caption>Browser simulation results</caption><thead><tr><th scope="col">Page</th><th scope="col">Profile</th><th scope="col">Status</th><th scope="col">Checks</th><th scope="col">Failures</th></tr></thead><tbody>' +
|
|
252
|
+
browserChecks.results.map(result => '<tr><th scope="row">' + escapeHtml(result.page) + '</th><td>' + escapeHtml(result.profile + ' · ' + result.colorScheme) +
|
|
253
|
+
'</td><td>' + escapeHtml(result.status) + '</td><td>' + result.checks.length + '</td><td>' +
|
|
254
|
+
escapeHtml(result.checks.filter(check => !check.passed).map(check => check.group + ': ' + check.name + (check.detail ? ' (' + check.detail + ')' : '')).join('; ')) + '</td></tr>').join('') + '</tbody></table>' : '');
|
|
225
255
|
const typeLabel = (type) => ({ post: 'Posts', page: 'Pages', homepage: 'Homepages', system: 'System routes', consent: 'Consent interface' })[type] || type;
|
|
226
256
|
const statusTable = '<table><caption>Audit results</caption><thead><tr><th scope="col">Audit</th><th scope="col">Status</th>' +
|
|
227
257
|
'<th scope="col">Score</th><th scope="col">Checks</th><th scope="col">Errors</th><th scope="col">Warnings</th></tr></thead><tbody>' +
|
|
228
|
-
'<tr><th scope="row">Accessibility</th><td>' + escapeHtml(report.accessibility.status) + '</td><td>' + report.accessibility.score +
|
|
258
|
+
'<tr><th scope="row">Accessibility (HTML/CSS structure)</th><td>' + escapeHtml(report.accessibility.status) + '</td><td>' + report.accessibility.score +
|
|
229
259
|
'%</td><td>' + report.accessibility.checks + '</td><td>' + report.accessibility.errors + '</td><td>' + report.accessibility.warnings + '</td></tr>' +
|
|
230
260
|
'<tr><th scope="row">Agent friendliness</th><td>' + escapeHtml(report.agentFriendliness.status) + '</td><td>' + report.agentFriendliness.score +
|
|
231
261
|
'%</td><td>' + report.agentFriendliness.checks + '</td><td>' + report.agentFriendliness.errors + '</td><td>' + report.agentFriendliness.warnings + '</td></tr>' +
|
|
232
262
|
'<tr><th scope="row">Markdown rendering</th><td>' + escapeHtml(report.markdownRendering?.status || 'not run') + '</td><td>—</td><td>' +
|
|
233
263
|
(report.markdownRendering?.checks || 0) + '</td><td>' + ((report.markdownRendering?.checks || 0) - (report.markdownRendering?.passed || 0)) + '</td><td>0</td></tr>' +
|
|
234
|
-
'<tr><th scope="row">Consent interface</th><td>' + escapeHtml(report.consentUi?.status || 'not checked') + '</td><td>' + consentScore +
|
|
264
|
+
browserRows + '<tr><th scope="row">Consent interface</th><td>' + escapeHtml(report.consentUi?.status || 'not checked') + '</td><td>' + consentScore +
|
|
235
265
|
'</td><td>' + consentChecks + '</td><td>' + consentErrors + '</td><td>0</td></tr>' +
|
|
236
266
|
'<tr><th scope="row">Report reading structure</th><td>' + escapeHtml(reportReading.status) + '</td><td>' +
|
|
237
267
|
Math.floor(reportReading.passed / Math.max(1, reportReading.checks) * 100) + '%</td><td>' + reportReading.checks +
|
|
@@ -277,14 +307,15 @@ function reportHtml(report, screenshots) {
|
|
|
277
307
|
'<body><a class="skip-link" href="#main">Skip to report</a><header><h1>EdgePress accessibility and content report</h1><p>Generated ' +
|
|
278
308
|
escapeHtml(report.generatedAt) + ' · Overall status: <span class="status">' + escapeHtml(report.status) + '</span>.</p>' +
|
|
279
309
|
'<nav class="report-nav" aria-label="Report sections"><ul><li><a href="#summary">Summary</a></li><li><a href="#content">Content reviewed</a></li>' +
|
|
280
|
-
'<li><a href="#findings">Findings</a></li><li><a href="#markdown">Markdown rendering</a></li><li><a href="#consent">Consent interface</a></li><li><a href="#evidence">Screenshots</a></li><li><a href="#scope">Scope</a></li></ul></nav></header>' +
|
|
310
|
+
'<li><a href="#findings">Findings</a></li><li><a href="#markdown">Markdown rendering</a></li><li><a href="#browser">Browser simulations</a></li><li><a href="#consent">Consent interface</a></li><li><a href="#evidence">Screenshots</a></li><li><a href="#scope">Scope</a></li></ul></nav></header>' +
|
|
281
311
|
'<main id="main"><section id="summary"><h2>Audit summary</h2>' + statusTable + summary + '<p>Generated HTML documents: ' + report.pages +
|
|
282
312
|
' · HTML bytes: ' + report.metrics.htmlBytes + ' · Stylesheet references: ' + report.metrics.stylesheets +
|
|
283
313
|
' · Script references: ' + report.metrics.scripts + '.</p></section>' +
|
|
284
314
|
'<section id="content"><h2>Content reviewed</h2><p>Every generated post and customizable page is listed with its generated route and audit status. Posts are checked alongside pages.</p>' +
|
|
285
315
|
documentSections + '</section><section id="findings"><h2>Findings</h2>' + findings + '</section>' +
|
|
286
316
|
'<section id="markdown"><h2>Markdown rendering verification</h2><p>' + escapeHtml(String(report.markdownRendering?.passed || 0)) + ' of ' +
|
|
287
|
-
escapeHtml(String(report.markdownRendering?.checks || 0)) + ' syntax checks passed
|
|
317
|
+
escapeHtml(String(report.markdownRendering?.checks || 0)) + ' syntax checks passed using an in-memory renderer fixture. No tutorial article needs to be published.</p>' + markdownDetails + '</section>' +
|
|
318
|
+
'<section id="browser"><h2>Device, accessibility-tree and keyboard simulations</h2>' + browserDetails + '</section>' +
|
|
288
319
|
'<section id="consent"><h2>Consent interface checks</h2><p>Consent component checks run separately from page-content layout checks.</p>' +
|
|
289
320
|
(consentDetails ? '<ul>' + consentDetails + '</ul>' : '<p>No consent UI checks were run.</p>') + '</section>' +
|
|
290
321
|
'<section id="evidence" class="appendix"><h2>Responsive and consent screenshots</h2><p>Page screenshots use a 390 by 844 CSS pixel mobile viewport and hide the consent component while measuring page content. Separate consent screenshots crop the component at desktop and mobile sizes.</p>' +
|
|
@@ -362,6 +393,10 @@ async function startReportServer(output, evidenceDirectory, htmlPath) {
|
|
|
362
393
|
catch { target = resolve(target, 'index.html'); info = await lstat(target); }
|
|
363
394
|
} else throw error;
|
|
364
395
|
}
|
|
396
|
+
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
397
|
+
target = resolve(target, 'index.html');
|
|
398
|
+
info = await lstat(target);
|
|
399
|
+
}
|
|
365
400
|
if (info.isSymbolicLink() || !info.isFile()) {
|
|
366
401
|
response.writeHead(404);
|
|
367
402
|
response.end();
|
|
@@ -392,11 +427,12 @@ function siteAddress(relativeFile) {
|
|
|
392
427
|
}
|
|
393
428
|
|
|
394
429
|
export async function addVisualEvidence(config, report) {
|
|
395
|
-
const root =
|
|
396
|
-
const directoryInfo = await lstat(root);
|
|
397
|
-
if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory()) throw new Error('Report directory must be a real directory');
|
|
430
|
+
const root = await reportDirectory(config.root);
|
|
398
431
|
const browser = await browserPath();
|
|
432
|
+
report.browserChecks = { status: 'unavailable', physicalDevices: 'not tested', screenReaders: 'not tested',
|
|
433
|
+
method: 'Headless Chromium device emulation, accessibility tree and CDP keyboard input', keyboardSampleLimit: 12, results: [] };
|
|
399
434
|
if (!browser) {
|
|
435
|
+
report.browserChecks.reason = 'No installed Chromium browser was found.';
|
|
400
436
|
return { status: 'unavailable', reason: 'Install Microsoft Edge, Chrome, or Chromium to capture page screenshots and PDF evidence.' };
|
|
401
437
|
}
|
|
402
438
|
|
|
@@ -407,6 +443,10 @@ export async function addVisualEvidence(config, report) {
|
|
|
407
443
|
const pdfPath = resolve(root, 'page-check.pdf');
|
|
408
444
|
let reportServer;
|
|
409
445
|
let browserSession;
|
|
446
|
+
const browserSessions=[];
|
|
447
|
+
const runInBrowsers=async(items,task)=>Promise.all(browserSessions.map(async(session,worker)=>{
|
|
448
|
+
for(let index=worker;index<items.length;index+=browserSessions.length)await task(session,items[index],index);
|
|
449
|
+
}));
|
|
410
450
|
try {
|
|
411
451
|
await mkdir(evidenceDirectory, { recursive: true });
|
|
412
452
|
const evidenceInfo = await lstat(evidenceDirectory);
|
|
@@ -418,6 +458,8 @@ export async function addVisualEvidence(config, report) {
|
|
|
418
458
|
for (const locale of config.i18n.locales) {
|
|
419
459
|
const prefix = locale === config.i18n.defaultLocale ? '' : locale + '/';
|
|
420
460
|
candidates.add(prefix + 'index.html');
|
|
461
|
+
candidates.add(prefix + 'archives/index.html');
|
|
462
|
+
candidates.add(prefix + 'search/index.html');
|
|
421
463
|
candidates.add(prefix + 'privacy-policy/index.html');
|
|
422
464
|
}
|
|
423
465
|
for (const issue of report.issues) {
|
|
@@ -469,8 +511,38 @@ export async function addVisualEvidence(config, report) {
|
|
|
469
511
|
}
|
|
470
512
|
}
|
|
471
513
|
reportServer = await startReportServer(config.resolvedPaths.output, evidenceDirectory, htmlPath);
|
|
472
|
-
|
|
473
|
-
|
|
514
|
+
for(let worker=0;worker<Math.min(3,available.length||1);worker++){
|
|
515
|
+
const browserProfile=resolve(profile,'browser-'+worker);await mkdir(browserProfile);
|
|
516
|
+
browserSessions.push(await startHeadlessBrowser(browser,browserProfile));
|
|
517
|
+
}
|
|
518
|
+
browserSession=browserSessions[0];
|
|
519
|
+
let reviewed=0;
|
|
520
|
+
const pageResults=new Array(available.length);
|
|
521
|
+
await runInBrowsers(available,async(browserSession,relativeFile,index)=>{
|
|
522
|
+
const results=[];
|
|
523
|
+
for (const device of deviceProfiles) for (const colorScheme of ['light', 'dark']) {
|
|
524
|
+
const emulation = { ...device, colorScheme };
|
|
525
|
+
await browserSession.setViewport(emulation);
|
|
526
|
+
await browserSession.navigate('http://127.0.0.1:' + reportServer.port + siteAddress(relativeFile));
|
|
527
|
+
const result = { page: relativeFile, profile: device.name, colorScheme, ...await browserSession.audit(emulation) };
|
|
528
|
+
results.push(result);
|
|
529
|
+
}
|
|
530
|
+
pageResults[index]=results;
|
|
531
|
+
report.browserChecks.results=pageResults.filter(Boolean).flat();
|
|
532
|
+
reviewed+=1;if(reviewed%25===0||reviewed===available.length)console.log('Browser checks: '+reviewed+'/'+available.length+' pages');
|
|
533
|
+
});
|
|
534
|
+
report.browserChecks.results=pageResults.flat();
|
|
535
|
+
for(const result of report.browserChecks.results)for(const check of result.checks.filter(item=>!item.passed)){
|
|
536
|
+
report.issues.push({severity:'error',category:'browser-'+check.group,page:result.page,
|
|
537
|
+
message:result.profile+'/'+result.colorScheme+': '+check.name+(check.detail?' ('+check.detail+')':'')+'.'});
|
|
538
|
+
}
|
|
539
|
+
report.browserChecks.status = report.browserChecks.results.every(result => result.status === 'pass') ? 'pass' : 'fail';
|
|
540
|
+
if (!report.browserChecks.results.length) {
|
|
541
|
+
report.browserChecks.status = 'unavailable';
|
|
542
|
+
report.browserChecks.reason = 'No generated pages were available for browser checks.';
|
|
543
|
+
}
|
|
544
|
+
let captured=0;
|
|
545
|
+
await runInBrowsers(screenshots,async(browserSession,screenshot)=>{
|
|
474
546
|
const viewport = screenshot.viewport === 'mobile' ? mobile : desktop;
|
|
475
547
|
await browserSession.setViewport(viewport);
|
|
476
548
|
await browserSession.navigate('http://127.0.0.1:' + reportServer.port + siteAddress(screenshot.page));
|
|
@@ -502,7 +574,7 @@ export async function addVisualEvidence(config, report) {
|
|
|
502
574
|
if (!image) {
|
|
503
575
|
report.issues.push({ severity: 'error', category: screenshot.capture === 'consent' ? 'consent-ui' : 'visual-evidence',
|
|
504
576
|
page: screenshot.page, message: 'The requested screenshot area was not present in the browser.' });
|
|
505
|
-
|
|
577
|
+
return;
|
|
506
578
|
}
|
|
507
579
|
await writeFile(screenshot.target, image);
|
|
508
580
|
const imageInfo = await lstat(screenshot.target);
|
|
@@ -519,7 +591,8 @@ export async function addVisualEvidence(config, report) {
|
|
|
519
591
|
report.issues.push({ severity: 'error', category: 'responsive-layout', page: screenshot.page,
|
|
520
592
|
message: 'Page content width is ' + dimensions.pageDocumentWidth + ' CSS pixels for a ' + dimensions.viewportWidth + ' pixel viewport; consent UI is measured separately.' });
|
|
521
593
|
}
|
|
522
|
-
|
|
594
|
+
captured+=1;if(captured%50===0||captured===screenshots.length)console.log('Report screenshots: '+captured+'/'+screenshots.length);
|
|
595
|
+
});
|
|
523
596
|
if (report.consentUi?.visualChecks) {
|
|
524
597
|
const visualChecks = report.consentUi.visualChecks;
|
|
525
598
|
report.consentUi.checks += visualChecks.length;
|
|
@@ -534,7 +607,7 @@ export async function addVisualEvidence(config, report) {
|
|
|
534
607
|
if (findings.some((item) => item.severity === 'error')) document.status = 'fail';
|
|
535
608
|
else if (findings.length) document.status = 'warning';
|
|
536
609
|
}
|
|
537
|
-
report.visualEvidence = { status: 'complete', pdf: 'tools/page-check.pdf', screenshots: screenshots.filter((item) => item.viewportWidth).map((item) => ({
|
|
610
|
+
report.visualEvidence = { status: 'complete', pdf: 'tools/reports/page-check.pdf', screenshots: screenshots.filter((item) => item.viewportWidth).map((item) => ({
|
|
538
611
|
page: item.page, type: item.type, capture: item.capture, title: item.title, viewport: item.viewport, width: item.width, height: item.height,
|
|
539
612
|
viewportWidth: item.viewportWidth, documentWidth: item.documentWidth,
|
|
540
613
|
consentWidth: item.consentWidth, consentHeight: item.consentHeight, consentWithinViewport: item.consentWithinViewport,
|
|
@@ -544,7 +617,7 @@ export async function addVisualEvidence(config, report) {
|
|
|
544
617
|
const reportAccessibility = checkReportReadingStructure(initialHtml);
|
|
545
618
|
report.visualEvidence.reportAccessibility = reportAccessibility;
|
|
546
619
|
for (const check of reportAccessibility.results.filter((item) => !item.passed)) {
|
|
547
|
-
report.issues.push({ severity: 'error', category: 'report-accessibility', page: 'tools/page-check.pdf',
|
|
620
|
+
report.issues.push({ severity: 'error', category: 'report-accessibility', page: 'tools/reports/page-check.pdf',
|
|
548
621
|
message: 'Report reading requirement failed: ' + check.name + '.' });
|
|
549
622
|
}
|
|
550
623
|
report.errors = report.issues.filter((item) => item.severity === 'error').length;
|
|
@@ -562,7 +635,7 @@ export async function addVisualEvidence(config, report) {
|
|
|
562
635
|
await writeFile(pdfPath, pdf);
|
|
563
636
|
return report.visualEvidence;
|
|
564
637
|
} finally {
|
|
565
|
-
|
|
638
|
+
await Promise.allSettled(browserSessions.map(session=>session.close()));
|
|
566
639
|
if (reportServer) await new Promise((resolveClose) => reportServer.server.close(resolveClose));
|
|
567
640
|
await rm(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 250 });
|
|
568
641
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {renderMarkdown} from './markdown.js';
|
|
2
|
+
const escape=value=>String(value).replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
3
|
+
|
|
4
|
+
// Git is a build-time source of history. Readers see changed passages, not patches.
|
|
5
|
+
export function changedPassages(diff){
|
|
6
|
+
const groups=[];let before=[],after=[];
|
|
7
|
+
const finish=()=>{if(before.length||after.length)groups.push({before:before.join('\n'),after:after.join('\n')});before=[];after=[];};
|
|
8
|
+
for(const line of String(diff).split(/\r?\n/)){
|
|
9
|
+
if(/^(?:diff --git|index |--- |\+\+\+ |\\)/.test(line))continue;
|
|
10
|
+
if(line.startsWith('-'))before.push(line.slice(1));
|
|
11
|
+
else if(line.startsWith('+'))after.push(line.slice(1));
|
|
12
|
+
else finish();
|
|
13
|
+
}
|
|
14
|
+
finish();return groups.filter(group=>group.before.trim()||group.after.trim());
|
|
15
|
+
}
|
|
16
|
+
export async function renderRevisionCards(diff,label,images,options={}){
|
|
17
|
+
const groups=changedPassages(diff);if(!groups.length)return '';
|
|
18
|
+
const cards=[];
|
|
19
|
+
for(const group of groups){
|
|
20
|
+
const sides=[];
|
|
21
|
+
for(const side of ['before','after'])if(group[side].trim()){
|
|
22
|
+
const key=side==='before'?(group.after.trim()?'postChangeBefore':'postChangeRemoved'):(group.before.trim()?'postChangeAfter':'postChangeAdded');
|
|
23
|
+
const known=new Set(Object.values(images?.[side]||{}));
|
|
24
|
+
const content=(await renderMarkdown(group[side],{allowImages:!!images,allowVideo:false,...(options.linkResolver?{linkResolver:options.linkResolver}:{}),...(images?{imageResolver:href=>images[side]?.[href]||(known.has(href)?href:null)}:{})})).replace(/<h[1-6]\b[^>]*>/g,'<p class="post-change-heading">').replace(/<\/h[1-6]>/g,'</p>');
|
|
25
|
+
sides.push('<div class="post-change-side post-change-'+side+'"><p class="post-change-label">'+escape(label(key))+'</p><div class="post-change-content">'+content+'</div></div>');
|
|
26
|
+
}
|
|
27
|
+
cards.push('<div class="post-change-passage">'+sides.join('')+'</div>');
|
|
28
|
+
}
|
|
29
|
+
return '<details class="post-changes"><summary>'+escape(label('postChanges'))+'</summary><div class="post-changes-list">'+cards.join('')+'</div></details>';
|
|
30
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {readFile} from 'node:fs/promises';
|
|
3
|
+
import {relative,resolve,sep,extname} from 'node:path';
|
|
4
|
+
import {imageSize} from 'image-size';
|
|
5
|
+
import {renderMarkdown} from './markdown.js';
|
|
6
|
+
|
|
7
|
+
const decode=value=>value.replace(/&(?:amp|quot|apos|lt|gt);/g,entity=>({'&':'&','"':'"',''':"'",'<':'<','>':'>'}[entity]));
|
|
8
|
+
export async function revisionImageReferences(text){
|
|
9
|
+
const html=await renderMarkdown(text,{allowVideo:false}),images=new Map();
|
|
10
|
+
for(const match of html.matchAll(/<img\b[^>]*>/gi)){
|
|
11
|
+
const attr=name=>decode(match[0].match(new RegExp('\\b'+name+'="([^"]*)"','i'))?.[1]||'');
|
|
12
|
+
const src=attr('src');if(src)images.set(src,attr('alt'));
|
|
13
|
+
}
|
|
14
|
+
return images;
|
|
15
|
+
}
|
|
16
|
+
export function revisionImageFile(href,assetsRoot){
|
|
17
|
+
if(!assetsRoot||!href.startsWith('/')||href.startsWith('//'))return null;
|
|
18
|
+
let path;try{path=decodeURIComponent(href.split(/[?#]/)[0]);}catch{return null;}
|
|
19
|
+
if(/[\\\0]/.test(path)||!/\.(?:png|jpe?g|gif|webp|avif|svg)$/i.test(path))return null;
|
|
20
|
+
const root=resolve(assetsRoot),file=resolve(root,'.'+path);return file.startsWith(root+sep)?file:null;
|
|
21
|
+
}
|
|
22
|
+
export async function revisionImageState(text,assetsRoot,root,commit,git){
|
|
23
|
+
const refs=await revisionImageReferences(text),images=new Map();
|
|
24
|
+
for(const [href,alt] of refs){
|
|
25
|
+
const file=revisionImageFile(href,assetsRoot);if(!file)continue;
|
|
26
|
+
try{const path=relative(root,file).split(sep).join('/'),oid=(await git(root,commit?['rev-parse',commit+':'+path]:['hash-object','--',file])).trim();images.set(href,{file,path,oid,alt});}catch{}
|
|
27
|
+
}
|
|
28
|
+
return images;
|
|
29
|
+
}
|
|
30
|
+
export async function snapshotRevisionImages(before,after,diff,root,binaryGit){
|
|
31
|
+
const changed=await revisionImageReferences(diff.split(/\r?\n/).filter(line=>/^[+-](?![+-])/.test(line)).map(line=>line.slice(1)).join('\n'));
|
|
32
|
+
const assets=new Map(),sides={before:Object.create(null),after:Object.create(null)};
|
|
33
|
+
for(const [side,state] of [['before',before],['after',after]])for(const [href,image] of state.images){
|
|
34
|
+
if(!changed.has(href))continue;
|
|
35
|
+
try{
|
|
36
|
+
const content=state.commit?await binaryGit(root,['show',state.commit+':'+image.path]):await readFile(image.file);
|
|
37
|
+
if(content.length>20_000_000)continue;
|
|
38
|
+
const size=imageSize(content);if(!(size.width>0&&size.height>0))continue;
|
|
39
|
+
const hash=createHash('sha256').update(content).digest('hex').slice(0,16),path='edgepress/revisions/image.'+hash+extname(image.file).toLowerCase();
|
|
40
|
+
assets.set(path,{path,content});sides[side][href]='/'+path;
|
|
41
|
+
}catch{ /* Missing historical bytes never produce a broken image request. */ }
|
|
42
|
+
}
|
|
43
|
+
return {revisionImages:sides,revisionAssets:[...assets.values()]};
|
|
44
|
+
}
|
package/src/revisions.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { mkdtemp, writeFile, stat, unlink, rmdir } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, relative, join } from 'node:path';
|
|
6
|
+
import { parse } from 'yaml';
|
|
7
|
+
import {revisionImageReferences,revisionImageFile,revisionImageState,snapshotRevisionImages} from './revision-images.js';
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
const git = (cwd, args) => run('git', args, {cwd, maxBuffer: 2_000_000}).then(result => result.stdout);
|
|
11
|
+
function articleText(source) {
|
|
12
|
+
const match = source.replace(/^\uFEFF/, '').match(/^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)\r?\n([\s\S]*)$/);
|
|
13
|
+
if (!match) return source.replace(/\r\n/g, '\n').trim();
|
|
14
|
+
const metadata = parse(match[1]);
|
|
15
|
+
const body=match[2].replace(/\r\n/g, '\n').trim();
|
|
16
|
+
// A post may expose the same cover in front matter and in its Markdown body.
|
|
17
|
+
// Include the synthetic cover only when the body does not already reference it;
|
|
18
|
+
// otherwise one edit produces two identical before/after passages.
|
|
19
|
+
const image=typeof metadata?.image==='string' && !body.includes(']('+metadata.image+')')
|
|
20
|
+
? '\n\n' : '';
|
|
21
|
+
return '# ' + String(metadata?.title || '') + '\n\n' + image + body;
|
|
22
|
+
}
|
|
23
|
+
async function difference(before, after) {
|
|
24
|
+
const folder = await mkdtemp(join(tmpdir(), 'edgepress-revision-'));
|
|
25
|
+
const left = join(folder, 'before.md'), right = join(folder, 'after.md');
|
|
26
|
+
try {
|
|
27
|
+
await writeFile(left, before + '\n'); await writeFile(right, after + '\n');
|
|
28
|
+
let output;
|
|
29
|
+
try { output = await git(folder, ['diff', '--no-index', '--no-color', '--unified=3', '--', left, right]); }
|
|
30
|
+
catch (error) { if (error.code !== 1) throw error; output = error.stdout; }
|
|
31
|
+
return output.split('\n').filter(line => !/^(diff --git|index |--- |\+\+\+ )/.test(line)).join('\n').trim();
|
|
32
|
+
} finally {
|
|
33
|
+
await Promise.all([unlink(left).catch(()=>{}), unlink(right).catch(()=>{})]);
|
|
34
|
+
await rmdir(folder);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Release numbering and front-matter-only changes do not change article history.
|
|
39
|
+
export async function readPostRevision(file, source, showChanges = false, assetsRoot = null) {
|
|
40
|
+
const empty = {updated: null, changes: ''};
|
|
41
|
+
try {
|
|
42
|
+
const root = (await git(dirname(file), ['rev-parse', '--show-toplevel'])).trim();
|
|
43
|
+
const path = relative(root, file).split('\\').join('/');
|
|
44
|
+
const current = articleText(source),refs=await revisionImageReferences(current);
|
|
45
|
+
const imagePaths=[...refs.keys()].map(href=>revisionImageFile(href,assetsRoot)).filter(Boolean).map(file=>relative(root,file).split('\\').join('/'));
|
|
46
|
+
const entries = (await git(root, ['log', '-n', '20', '--format=%H%x09%cI', '--', path,...imagePaths])).trim().split('\n').filter(Boolean);
|
|
47
|
+
if (!entries.length) return empty;
|
|
48
|
+
const working={text:current,commit:null,images:await revisionImageState(current,assetsRoot,root,null,git)};
|
|
49
|
+
const signature=state=>state.text+'\n'+JSON.stringify([...state.images].map(([href,image])=>[href,image.oid]));
|
|
50
|
+
const result=async(before,after,updated)=>{
|
|
51
|
+
if(!showChanges)return {updated,changes:''};
|
|
52
|
+
let changes=await difference(before.text,after.text);
|
|
53
|
+
for(const [href,image] of after.images){const old=before.images.get(href);if(old&&old.oid!==image.oid&&!changes.includes(href))changes+='\n-!['+old.alt.replace(/[\[\]\r\n]/g,'')+']('+href+')\n+!['+image.alt.replace(/[\[\]\r\n]/g,'')+']('+href+')';}
|
|
54
|
+
const snapshots=await snapshotRevisionImages(before,after,changes,root,(cwd,args)=>run('git',args,{cwd,maxBuffer:20_000_000,encoding:'buffer'}).then(result=>result.stdout));
|
|
55
|
+
return {updated,changes,...snapshots};
|
|
56
|
+
};
|
|
57
|
+
const history = [];
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const [commit, timestamp] = entry.split('\t');
|
|
60
|
+
let text;try{text=articleText(await git(root,['show',commit+':'+path]));}catch{break;}
|
|
61
|
+
const state={text,commit,updated:new Date(timestamp),images:await revisionImageState(text,assetsRoot,root,commit,git)};
|
|
62
|
+
history.push(state);
|
|
63
|
+
if (history.length === 1 && signature(state) !== signature(working)) {
|
|
64
|
+
const updated = (await stat(file)).mtime;
|
|
65
|
+
return result(state,working,updated);
|
|
66
|
+
}
|
|
67
|
+
if (history.length > 1 && signature(history.at(-2)) !== signature(state)) {
|
|
68
|
+
const latest = history.at(-2);
|
|
69
|
+
return result(state,latest,latest.updated);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return empty;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// Missing Git or shallow history must never fabricate previous content.
|
|
75
|
+
return empty;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/rss.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { renderMarkdown, plainText } from './markdown.js';
|
|
2
|
+
import { localizedContentUrl, localizedUrl } from './i18n.js';
|
|
3
|
+
import { authorForPost, normalizeCategory } from './post-details.js';
|
|
4
|
+
|
|
5
|
+
function xml(value) {
|
|
6
|
+
return String(value ?? '')
|
|
7
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g, '')
|
|
8
|
+
.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function decodeAttribute(value) {
|
|
12
|
+
return String(value).replace(/&(?:amp|quot|apos|lt|gt);/gi, entity => ({
|
|
13
|
+
'&': '&', '"': '"', ''': "'", '<': '<', '>': '>'
|
|
14
|
+
})[entity.toLowerCase()]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Feed HTML has no reliable base URL. Its images and internal links must work
|
|
18
|
+
// independently of the original article page, without adding active embeds.
|
|
19
|
+
export function absoluteContentUrls(html, articleUrl) {
|
|
20
|
+
return String(html).replace(/\b(href|src)="([^"]*)"/g, (attribute, name, encoded) => {
|
|
21
|
+
const value = decodeAttribute(encoded);
|
|
22
|
+
if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(value)) return attribute;
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(value, articleUrl);
|
|
25
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return attribute;
|
|
26
|
+
return name + '="' + url.href.replace(/&/g, '&').replace(/"/g, '"') + '"';
|
|
27
|
+
} catch {
|
|
28
|
+
return attribute;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function canonicalUrl(post, config) {
|
|
34
|
+
return config.site.url + '/' + String(post.path).split('/').filter(Boolean).map(encodeURIComponent).join('/') + '/';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function lastChanged(post) {
|
|
38
|
+
return post.updated instanceof Date && post.updated > post.date ? post.updated : post.date;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function categories(post) {
|
|
42
|
+
const category = normalizeCategory(post.category);
|
|
43
|
+
return [...new Set([...(category === 'uncategorized' ? [] : [category]), ...(post.tags || [])])];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isSiteImage(href, config) {
|
|
47
|
+
const value = String(href ?? '');
|
|
48
|
+
if (value.startsWith('/') && !value.startsWith('//')) return value;
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(value);
|
|
51
|
+
return url.protocol === 'https:' && url.origin === new URL(config.site.url).origin ? value : '';
|
|
52
|
+
} catch { return ''; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Returns a single standard RSS 2.0 document; no hand-maintained XML assets.
|
|
56
|
+
export async function generateRssRoute(posts, config, locale) {
|
|
57
|
+
const prefix = locale === config.i18n.defaultLocale ? '' : locale + '/';
|
|
58
|
+
const homeUrl = config.site.url + localizedUrl(config, locale, '');
|
|
59
|
+
const rssUrl = config.site.url + localizedUrl(config, locale, 'rss.xml');
|
|
60
|
+
const items = await Promise.all(posts.slice(0, 20).map(async post => {
|
|
61
|
+
const url = canonicalUrl(post, config);
|
|
62
|
+
// Re-render static Markdown with the normal sanitizer. Omit interactive
|
|
63
|
+
// embed/player markup and remote images that may bypass reader consent.
|
|
64
|
+
const rendered = await renderMarkdown(post.markdown || '', {
|
|
65
|
+
...config.markdown,
|
|
66
|
+
linkResolver: href => localizedContentUrl(config, post.locale, href),
|
|
67
|
+
imageResolver: href => isSiteImage(href, config),
|
|
68
|
+
allowVideo: false
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
url, title: post.title,
|
|
72
|
+
author: authorForPost(post, config).name || config.site.title,
|
|
73
|
+
locale: post.locale, published: post.date, updated: lastChanged(post),
|
|
74
|
+
summary: plainText(post.description || post.markdown).slice(0, 220),
|
|
75
|
+
content: absoluteContentUrls(rendered, url), categories: categories(post)
|
|
76
|
+
};
|
|
77
|
+
}));
|
|
78
|
+
const lastBuild = items.reduce((latest, item) => item.updated > latest ? item.updated : latest, new Date(0));
|
|
79
|
+
const rssItems = items.map(item =>
|
|
80
|
+
'<item><title>' + xml(item.title) + '</title><link>' + xml(item.url) + '</link>' +
|
|
81
|
+
'<guid isPermaLink="true">' + xml(item.url) + '</guid>' +
|
|
82
|
+
'<pubDate>' + item.published.toUTCString() + '</pubDate>' +
|
|
83
|
+
'<dc:date>' + item.updated.toISOString() + '</dc:date>' +
|
|
84
|
+
'<dc:creator>' + xml(item.author) + '</dc:creator>' +
|
|
85
|
+
'<dc:language>' + xml(item.locale) + '</dc:language>' +
|
|
86
|
+
'<description>' + xml(item.summary) + '</description>' +
|
|
87
|
+
item.categories.map(category => '<category>' + xml(category) + '</category>').join('') +
|
|
88
|
+
'<content:encoded>' + xml(item.content) + '</content:encoded></item>'
|
|
89
|
+
).join('');
|
|
90
|
+
const rss = '<?xml version="1.0" encoding="utf-8"?>' +
|
|
91
|
+
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" ' +
|
|
92
|
+
'xmlns:content="http://purl.org/rss/1.0/modules/content/" ' +
|
|
93
|
+
'xmlns:dc="http://purl.org/dc/elements/1.1/"><channel>' +
|
|
94
|
+
'<title>' + xml(config.site.title) + '</title><link>' + xml(homeUrl) + '</link>' +
|
|
95
|
+
'<description>' + xml(config.site.description) + '</description><language>' + xml(locale) + '</language>' +
|
|
96
|
+
'<lastBuildDate>' + lastBuild.toUTCString() + '</lastBuildDate>' +
|
|
97
|
+
'<atom:link href="' + xml(rssUrl) + '" rel="self" type="application/rss+xml"/>' +
|
|
98
|
+
rssItems + '</channel></rss>';
|
|
99
|
+
return { path: prefix + 'rss.xml', body: rss, contentType: 'application/rss+xml; charset=utf-8' };
|
|
100
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {translate, translateValue} from './i18n.js';
|
|
2
|
+
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
3
|
+
export function renderServiceBlock(document, locale, config, id, page = false) {
|
|
4
|
+
const service = config.browserPlugins.services.find(item => item.id === id);
|
|
5
|
+
if (service?.enabled === false) return '';
|
|
6
|
+
if (!service && !config.browserPlugins.services.some(item => item.enabled !== false && item.provider === 'external-widget')) return '';
|
|
7
|
+
if (!service || service.provider !== 'external-widget') throw new Error('Service block needs a registered external-widget: ' + id);
|
|
8
|
+
if (!document?.bundlePath) throw new Error('Service block needs a published document');
|
|
9
|
+
const thread = (page ? 'page:' : '') + document.bundlePath;
|
|
10
|
+
return '<section class="edgepress-service" data-edgepress-service="' + escape(id) + '" data-service-thread="' + escape(thread) + '" data-service-title="' + escape(document.title) + '"><h2>' + escape(translateValue(config, locale, service.name)) + '</h2><button type="button" data-service-load="' + escape(id) + '">' + escape(translate(config, locale, 'serviceLoad')) + '</button><button type="button" data-service-consent-settings>' + escape(translate(config, locale, 'privacySettings')) + '</button></section>';
|
|
11
|
+
}
|
|
12
|
+
export function renderPostServices(document, locale, config) {
|
|
13
|
+
return config.browserPlugins.services.filter(item => item.enabled !== false && item.provider === 'external-widget' && item.placement === 'posts').map(item => renderServiceBlock(document, locale, config, item.id)).join('');
|
|
14
|
+
}
|
|
15
|
+
export function hasServiceBlock(rows) {
|
|
16
|
+
const contains = blocks => blocks.some(block => block.type === 'service' || (block.type === 'section' && contains(block.blocks || [])));
|
|
17
|
+
return (rows || []).some(row => (row.cells || []).some(contains));
|
|
18
|
+
}
|