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.
Files changed (485) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -16
  3. package/config.yml +99 -127
  4. package/content/assets/edgepress/brand/avatar.png +0 -0
  5. package/content/assets/edgepress/brand/deploy-esa.svg +1 -0
  6. package/content/assets/edgepress/brand/home-hero-768.webp +0 -0
  7. package/content/assets/edgepress/brand/home-hero.png +0 -0
  8. package/content/assets/edgepress/brand/home-hero.webp +0 -0
  9. package/content/assets/edgepress/brand/mascot.png +0 -0
  10. package/content/assets/edgepress/brand/mascot.webp +0 -0
  11. package/content/assets/edgepress/brand/og-image.png +0 -0
  12. package/content/assets/edgepress/brand/og-image.webp +0 -0
  13. package/content/assets/edgepress/brand/sources/edgepress-avatar-master.png +0 -0
  14. package/content/assets/edgepress/brand/sources/export-manifest.json +181 -0
  15. package/content/assets/edgepress/brand/sources/prompts.json +12 -0
  16. package/content/assets/edgepress/favicon/apple-touch-icon.png +0 -0
  17. package/content/assets/edgepress/favicon/favicon-16.png +0 -0
  18. package/content/assets/edgepress/favicon/favicon-32.png +0 -0
  19. package/content/assets/edgepress/favicon/favicon-48.png +0 -0
  20. package/content/assets/edgepress/favicon/favicon-64.png +0 -0
  21. package/content/assets/edgepress/favicon/favicon.ico +0 -0
  22. package/content/assets/edgepress/icons/LICENSE.txt +43 -0
  23. package/content/assets/edgepress/icons/arrow-right.svg +16 -0
  24. package/content/assets/edgepress/icons/arrow-up-right.svg +16 -0
  25. package/content/assets/edgepress/icons/book-open.svg +16 -0
  26. package/content/assets/edgepress/icons/check-circle.svg +16 -0
  27. package/content/assets/edgepress/icons/check.svg +15 -0
  28. package/content/assets/edgepress/icons/cloud.svg +15 -0
  29. package/content/assets/edgepress/icons/code.svg +16 -0
  30. package/content/assets/edgepress/icons/cpu.svg +28 -0
  31. package/content/assets/edgepress/icons/file-text.svg +19 -0
  32. package/content/assets/edgepress/icons/github.svg +17 -0
  33. package/content/assets/edgepress/icons/globe.svg +17 -0
  34. package/content/assets/edgepress/icons/help-circle.svg +17 -0
  35. package/content/assets/edgepress/icons/home.svg +16 -0
  36. package/content/assets/edgepress/icons/layers.svg +17 -0
  37. package/content/assets/edgepress/icons/layout-grid.svg +18 -0
  38. package/content/assets/edgepress/icons/mail.svg +16 -0
  39. package/content/assets/edgepress/icons/newspaper.svg +18 -0
  40. package/content/assets/edgepress/icons/palette.svg +19 -0
  41. package/content/assets/edgepress/icons/plug.svg +18 -0
  42. package/content/assets/edgepress/icons/provenance.json +8 -0
  43. package/content/assets/edgepress/icons/puzzle.svg +15 -0
  44. package/content/assets/edgepress/icons/rocket.svg +18 -0
  45. package/content/assets/edgepress/icons/rss.svg +17 -0
  46. package/content/assets/edgepress/icons/search.svg +16 -0
  47. package/content/assets/edgepress/icons/settings.svg +16 -0
  48. package/content/assets/edgepress/icons/shield-check.svg +16 -0
  49. package/content/assets/edgepress/icons/sparkles.svg +18 -0
  50. package/content/assets/edgepress/icons/terminal.svg +16 -0
  51. package/content/assets/edgepress/icons/x.svg +16 -0
  52. package/{static → content/assets}/edgepress-markdown-guide.svg +1 -1
  53. package/content/assets/images/previews/edgepress-en.webp +0 -0
  54. package/content/assets/images/previews/edgepress-revisions-en.png +0 -0
  55. package/content/assets/images/previews/edgepress-workflow-en.90f4650b21013786.gif +0 -0
  56. package/content/assets/images/previews/edgepress-workflow-zh.41f1e0a685f47267.gif +0 -0
  57. package/content/assets/images/previews/edgepress-zh.webp +0 -0
  58. package/content/assets/images/previews/provenance.json +49 -0
  59. package/content/guides/markdown-media.md +38 -0
  60. package/content/guides/platforms.md +15 -0
  61. package/content/guides/product-demos.md +37 -0
  62. package/content/guides/zh-cn.md +62 -0
  63. package/content/pages/deployment/en.json +74 -0
  64. package/content/pages/deployment/zh-cn.json +74 -0
  65. package/content/pages/home/en.json +248 -0
  66. package/content/pages/home/zh-cn.json +248 -0
  67. package/content/pages/local-tools/en.json +14 -0
  68. package/content/pages/local-tools/zh-cn.json +14 -0
  69. package/content/pages/page-authoring/en.json +107 -0
  70. package/content/pages/page-authoring/zh-cn.json +107 -0
  71. package/content/pages/plugin-development/en.json +202 -0
  72. package/content/pages/plugin-development/zh-cn.json +202 -0
  73. package/content/pages/privacy-policy/en.json +171 -0
  74. package/content/pages/privacy-policy/zh-cn.json +171 -0
  75. package/content/pages/project-introduction/en.json +158 -0
  76. package/content/pages/project-introduction/zh-cn.json +158 -0
  77. package/content/pages/quick-start/en.json +325 -0
  78. package/content/pages/quick-start/zh-cn.json +325 -0
  79. package/content/pages/theme-development/en.json +353 -0
  80. package/content/pages/theme-development/zh-cn.json +349 -0
  81. package/content/posts/2026-09-24-edgepress-release-2026-9-1/en.md +1 -1
  82. package/content/posts/2026-09-24-edgepress-release-2026-9-1/zh-cn.md +1 -1
  83. package/content/posts/2026-10-03-edgepress-release-2026-10-3/en.md +16 -0
  84. package/content/posts/2026-10-03-edgepress-release-2026-10-3/zh-cn.md +16 -0
  85. package/content/posts/2026-10-03-edgepress-release-202610-4/en.md +22 -0
  86. package/content/posts/2026-10-03-edgepress-release-202610-4/zh-cn.md +20 -0
  87. package/content/posts/2026-10-03-edgepress-release-202610-5/en.md +15 -0
  88. package/content/posts/2026-10-03-edgepress-release-202610-5/zh-cn.md +15 -0
  89. package/edgeone.json +43 -0
  90. package/esa.jsonc +9 -0
  91. package/languages/base/en.json +75 -10
  92. package/languages/packs/zh-CN.json +74 -9
  93. package/netlify.toml +6 -0
  94. package/package.json +37 -6
  95. package/plugins/consent/index.js +10 -17
  96. package/project-compatibility.json +142 -25
  97. package/schemas/pages.schema.json +74 -0
  98. package/src/assets.js +146 -18
  99. package/src/browser-audit.js +158 -0
  100. package/src/build.js +34 -9
  101. package/src/cli.js +46 -41
  102. package/src/config.js +114 -63
  103. package/src/consent-csp.js +137 -0
  104. package/src/content.js +55 -30
  105. package/src/data-saver.js +194 -0
  106. package/src/deploy.js +28 -0
  107. package/src/document.js +44 -0
  108. package/src/embed.js +60 -0
  109. package/src/generators.js +101 -59
  110. package/src/i18n.js +19 -3
  111. package/src/icons.js +5 -4
  112. package/src/image-dimensions.js +17 -0
  113. package/src/local-tool.js +29 -0
  114. package/src/maintenance.js +25 -8
  115. package/src/markdown-fixture.js +20 -0
  116. package/src/markdown.js +80 -16
  117. package/src/media-assets.js +19 -0
  118. package/src/media-viewer.js +47 -0
  119. package/src/oembed.js +53 -0
  120. package/src/page-blocks.js +124 -54
  121. package/src/page-check.js +29 -39
  122. package/src/plugin-api.js +7 -1
  123. package/src/post-details.js +60 -0
  124. package/src/post-template.js +5 -0
  125. package/src/publication-time.js +47 -0
  126. package/src/report-path.js +19 -0
  127. package/src/report-visuals.js +91 -18
  128. package/src/revision-cards.js +30 -0
  129. package/src/revision-images.js +44 -0
  130. package/src/revisions.js +77 -0
  131. package/src/rss.js +100 -0
  132. package/src/service-block.js +18 -0
  133. package/src/theme.js +59 -9
  134. package/src/video-metadata.js +36 -0
  135. package/static/edgepress/data-saver.css +12 -0
  136. package/static/edgepress/data-saver.js +333 -0
  137. package/static/edgepress/document-viewer.js +35 -0
  138. package/static/edgepress/external-links-policy.js +14 -0
  139. package/static/edgepress/external-links.css +13 -0
  140. package/static/edgepress/external-links.js +120 -0
  141. package/static/edgepress/fonts/faces.css +2217 -0
  142. package/static/edgepress/fonts/noto-sans-OFL.txt +93 -0
  143. package/static/edgepress/fonts/noto-sans-cyrillic-ext-wght-normal.82ff72b28e6610f2.woff2 +0 -0
  144. package/static/edgepress/fonts/noto-sans-cyrillic-wght-normal.6ab64433de6077ca.woff2 +0 -0
  145. package/static/edgepress/fonts/noto-sans-devanagari-wght-normal.ea6a530b75d93de7.woff2 +0 -0
  146. package/static/edgepress/fonts/noto-sans-greek-ext-wght-normal.790b255c10c32e0b.woff2 +0 -0
  147. package/static/edgepress/fonts/noto-sans-greek-wght-normal.7f655207c2f6c140.woff2 +0 -0
  148. package/static/edgepress/fonts/noto-sans-latin-ext-wght-normal.6d5a79315528df19.woff2 +0 -0
  149. package/static/edgepress/fonts/noto-sans-latin-wght-normal.51ca196f49a33e79.woff2 +0 -0
  150. package/static/edgepress/fonts/noto-sans-sc-100-wght-normal.ad5f29028d5699a5.woff2 +0 -0
  151. package/static/edgepress/fonts/noto-sans-sc-101-wght-normal.e189b81fc22ca6af.woff2 +0 -0
  152. package/static/edgepress/fonts/noto-sans-sc-102-wght-normal.941c0c2971fc1d6d.woff2 +0 -0
  153. package/static/edgepress/fonts/noto-sans-sc-103-wght-normal.bafbd591af6493ff.woff2 +0 -0
  154. package/static/edgepress/fonts/noto-sans-sc-104-wght-normal.b0bf80ced89dd61b.woff2 +0 -0
  155. package/static/edgepress/fonts/noto-sans-sc-105-wght-normal.8d8b4e6f4a00621d.woff2 +0 -0
  156. package/static/edgepress/fonts/noto-sans-sc-106-wght-normal.bae74acc629d2e74.woff2 +0 -0
  157. package/static/edgepress/fonts/noto-sans-sc-107-wght-normal.a42eff5010de7b0d.woff2 +0 -0
  158. package/static/edgepress/fonts/noto-sans-sc-108-wght-normal.91882203459867be.woff2 +0 -0
  159. package/static/edgepress/fonts/noto-sans-sc-109-wght-normal.a91f8b038e5ce108.woff2 +0 -0
  160. package/static/edgepress/fonts/noto-sans-sc-110-wght-normal.bbca9081655c4523.woff2 +0 -0
  161. package/static/edgepress/fonts/noto-sans-sc-111-wght-normal.372be010ac0e87cb.woff2 +0 -0
  162. package/static/edgepress/fonts/noto-sans-sc-112-wght-normal.34a4580239eae8dc.woff2 +0 -0
  163. package/static/edgepress/fonts/noto-sans-sc-113-wght-normal.614ab8f616352583.woff2 +0 -0
  164. package/static/edgepress/fonts/noto-sans-sc-114-wght-normal.cf7b8344807a316e.woff2 +0 -0
  165. package/static/edgepress/fonts/noto-sans-sc-115-wght-normal.fb29957c7adccb9a.woff2 +0 -0
  166. package/static/edgepress/fonts/noto-sans-sc-116-wght-normal.a98dc5a726f82141.woff2 +0 -0
  167. package/static/edgepress/fonts/noto-sans-sc-117-wght-normal.d94b5af0c14838af.woff2 +0 -0
  168. package/static/edgepress/fonts/noto-sans-sc-118-wght-normal.76c9ddb85ae4578c.woff2 +0 -0
  169. package/static/edgepress/fonts/noto-sans-sc-119-wght-normal.b3b2374881f4cd93.woff2 +0 -0
  170. package/static/edgepress/fonts/noto-sans-sc-21-wght-normal.c1409c43e55ecff2.woff2 +0 -0
  171. package/static/edgepress/fonts/noto-sans-sc-22-wght-normal.77037ed42ff4e7b0.woff2 +0 -0
  172. package/static/edgepress/fonts/noto-sans-sc-23-wght-normal.38d0df21ac374e93.woff2 +0 -0
  173. package/static/edgepress/fonts/noto-sans-sc-24-wght-normal.8fa21e7e457acf3e.woff2 +0 -0
  174. package/static/edgepress/fonts/noto-sans-sc-25-wght-normal.f760b7a3871d214d.woff2 +0 -0
  175. package/static/edgepress/fonts/noto-sans-sc-26-wght-normal.be8386e7bd7facfe.woff2 +0 -0
  176. package/static/edgepress/fonts/noto-sans-sc-27-wght-normal.4895abcaddbd162e.woff2 +0 -0
  177. package/static/edgepress/fonts/noto-sans-sc-28-wght-normal.82540b6201a9b9ad.woff2 +0 -0
  178. package/static/edgepress/fonts/noto-sans-sc-29-wght-normal.ff21d8fcc60ce391.woff2 +0 -0
  179. package/static/edgepress/fonts/noto-sans-sc-30-wght-normal.1f76f6ffded00b9e.woff2 +0 -0
  180. package/static/edgepress/fonts/noto-sans-sc-31-wght-normal.e6c25be1bb08a63a.woff2 +0 -0
  181. package/static/edgepress/fonts/noto-sans-sc-32-wght-normal.0cda750d7d04a4e1.woff2 +0 -0
  182. package/static/edgepress/fonts/noto-sans-sc-33-wght-normal.b9732b5606bd87c5.woff2 +0 -0
  183. package/static/edgepress/fonts/noto-sans-sc-34-wght-normal.38aa326b4c84902a.woff2 +0 -0
  184. package/static/edgepress/fonts/noto-sans-sc-35-wght-normal.bd98226c8e0aa808.woff2 +0 -0
  185. package/static/edgepress/fonts/noto-sans-sc-36-wght-normal.0db1ca695a491d37.woff2 +0 -0
  186. package/static/edgepress/fonts/noto-sans-sc-37-wght-normal.8df409a4e0a237c2.woff2 +0 -0
  187. package/static/edgepress/fonts/noto-sans-sc-38-wght-normal.672d2d994994a3c3.woff2 +0 -0
  188. package/static/edgepress/fonts/noto-sans-sc-39-wght-normal.272d6d0e153f56e8.woff2 +0 -0
  189. package/static/edgepress/fonts/noto-sans-sc-4-wght-normal.9fafd93ac77b81ff.woff2 +0 -0
  190. package/static/edgepress/fonts/noto-sans-sc-40-wght-normal.5b9ef1ed48420aa7.woff2 +0 -0
  191. package/static/edgepress/fonts/noto-sans-sc-41-wght-normal.30aee47f660fd88a.woff2 +0 -0
  192. package/static/edgepress/fonts/noto-sans-sc-42-wght-normal.ec0014830db682b6.woff2 +0 -0
  193. package/static/edgepress/fonts/noto-sans-sc-43-wght-normal.43df2b5dad9bb835.woff2 +0 -0
  194. package/static/edgepress/fonts/noto-sans-sc-44-wght-normal.acaa6195041b1a92.woff2 +0 -0
  195. package/static/edgepress/fonts/noto-sans-sc-45-wght-normal.8cdb9903f770db21.woff2 +0 -0
  196. package/static/edgepress/fonts/noto-sans-sc-46-wght-normal.6590d0a6fe94571c.woff2 +0 -0
  197. package/static/edgepress/fonts/noto-sans-sc-47-wght-normal.b05afdd32b3bf722.woff2 +0 -0
  198. package/static/edgepress/fonts/noto-sans-sc-48-wght-normal.c9585f7d070700be.woff2 +0 -0
  199. package/static/edgepress/fonts/noto-sans-sc-49-wght-normal.4943895a4dfa5dcf.woff2 +0 -0
  200. package/static/edgepress/fonts/noto-sans-sc-5-wght-normal.e64f57937e3ae4fe.woff2 +0 -0
  201. package/static/edgepress/fonts/noto-sans-sc-50-wght-normal.13e51063d7743cfe.woff2 +0 -0
  202. package/static/edgepress/fonts/noto-sans-sc-51-wght-normal.fd4df1f02c2cb4d7.woff2 +0 -0
  203. package/static/edgepress/fonts/noto-sans-sc-52-wght-normal.06999a39c4c3a7ea.woff2 +0 -0
  204. package/static/edgepress/fonts/noto-sans-sc-53-wght-normal.767b7b9864658d91.woff2 +0 -0
  205. package/static/edgepress/fonts/noto-sans-sc-54-wght-normal.4cd00537a46f6f1a.woff2 +0 -0
  206. package/static/edgepress/fonts/noto-sans-sc-55-wght-normal.0118ec5d8c30b197.woff2 +0 -0
  207. package/static/edgepress/fonts/noto-sans-sc-56-wght-normal.a3e4a78929826930.woff2 +0 -0
  208. package/static/edgepress/fonts/noto-sans-sc-57-wght-normal.7623501009e4a6db.woff2 +0 -0
  209. package/static/edgepress/fonts/noto-sans-sc-58-wght-normal.56ae1a59a32e4a25.woff2 +0 -0
  210. package/static/edgepress/fonts/noto-sans-sc-59-wght-normal.7a377a40e99fd9bc.woff2 +0 -0
  211. package/static/edgepress/fonts/noto-sans-sc-6-wght-normal.17e15c4f0a5bfd92.woff2 +0 -0
  212. package/static/edgepress/fonts/noto-sans-sc-60-wght-normal.3657f66babdf6aff.woff2 +0 -0
  213. package/static/edgepress/fonts/noto-sans-sc-61-wght-normal.23919a2eb11d25dd.woff2 +0 -0
  214. package/static/edgepress/fonts/noto-sans-sc-62-wght-normal.a901edf25bc1cfae.woff2 +0 -0
  215. package/static/edgepress/fonts/noto-sans-sc-63-wght-normal.92f56f713cc0b62f.woff2 +0 -0
  216. package/static/edgepress/fonts/noto-sans-sc-64-wght-normal.4929c9f39a5a4bd9.woff2 +0 -0
  217. package/static/edgepress/fonts/noto-sans-sc-65-wght-normal.8daa61282fb62a11.woff2 +0 -0
  218. package/static/edgepress/fonts/noto-sans-sc-66-wght-normal.6479166925eb462b.woff2 +0 -0
  219. package/static/edgepress/fonts/noto-sans-sc-67-wght-normal.a6b0e11c4c761ffd.woff2 +0 -0
  220. package/static/edgepress/fonts/noto-sans-sc-68-wght-normal.65b77f1fe9d68dae.woff2 +0 -0
  221. package/static/edgepress/fonts/noto-sans-sc-69-wght-normal.b34f765cbbf4d02e.woff2 +0 -0
  222. package/static/edgepress/fonts/noto-sans-sc-70-wght-normal.a99d55c00e4f95b7.woff2 +0 -0
  223. package/static/edgepress/fonts/noto-sans-sc-71-wght-normal.3298bed30dc8d233.woff2 +0 -0
  224. package/static/edgepress/fonts/noto-sans-sc-72-wght-normal.6597209ca9707726.woff2 +0 -0
  225. package/static/edgepress/fonts/noto-sans-sc-73-wght-normal.e7ac1d4930eabd78.woff2 +0 -0
  226. package/static/edgepress/fonts/noto-sans-sc-74-wght-normal.1f0287be83a28e7a.woff2 +0 -0
  227. package/static/edgepress/fonts/noto-sans-sc-75-wght-normal.d9a4bb7bcc2be578.woff2 +0 -0
  228. package/static/edgepress/fonts/noto-sans-sc-76-wght-normal.5a019946bcadcec6.woff2 +0 -0
  229. package/static/edgepress/fonts/noto-sans-sc-77-wght-normal.7c445e85d04f5319.woff2 +0 -0
  230. package/static/edgepress/fonts/noto-sans-sc-78-wght-normal.7d4afb70f641781c.woff2 +0 -0
  231. package/static/edgepress/fonts/noto-sans-sc-79-wght-normal.5798753a721bb4ec.woff2 +0 -0
  232. package/static/edgepress/fonts/noto-sans-sc-80-wght-normal.db887159ca6e46f3.woff2 +0 -0
  233. package/static/edgepress/fonts/noto-sans-sc-81-wght-normal.71e5743bbd225b3d.woff2 +0 -0
  234. package/static/edgepress/fonts/noto-sans-sc-82-wght-normal.8c5094c3785fd04f.woff2 +0 -0
  235. package/static/edgepress/fonts/noto-sans-sc-83-wght-normal.4a60a1ccba905284.woff2 +0 -0
  236. package/static/edgepress/fonts/noto-sans-sc-84-wght-normal.3ed54e59ef4758e3.woff2 +0 -0
  237. package/static/edgepress/fonts/noto-sans-sc-85-wght-normal.e87e0a1342e1778c.woff2 +0 -0
  238. package/static/edgepress/fonts/noto-sans-sc-86-wght-normal.6d3ac3e20959dbcb.woff2 +0 -0
  239. package/static/edgepress/fonts/noto-sans-sc-87-wght-normal.0eeffd5daad5c17b.woff2 +0 -0
  240. package/static/edgepress/fonts/noto-sans-sc-88-wght-normal.b1d9582dfa268463.woff2 +0 -0
  241. package/static/edgepress/fonts/noto-sans-sc-89-wght-normal.8b437aeb9e9fcb5a.woff2 +0 -0
  242. package/static/edgepress/fonts/noto-sans-sc-90-wght-normal.83b0a59e0a5e045a.woff2 +0 -0
  243. package/static/edgepress/fonts/noto-sans-sc-91-wght-normal.382f72b98d4bb9d7.woff2 +0 -0
  244. package/static/edgepress/fonts/noto-sans-sc-97-wght-normal.5e52856457c5cdab.woff2 +0 -0
  245. package/static/edgepress/fonts/noto-sans-sc-98-wght-normal.ffded4e7fd560d93.woff2 +0 -0
  246. package/static/edgepress/fonts/noto-sans-sc-99-wght-normal.54e302c23f11a8fd.woff2 +0 -0
  247. package/static/edgepress/fonts/noto-sans-sc-OFL.txt +93 -0
  248. package/static/edgepress/fonts/noto-sans-sc-cyrillic-wght-normal.5932d441e7126669.woff2 +0 -0
  249. package/static/edgepress/fonts/noto-sans-sc-latin-ext-wght-normal.e316201f3fc493d1.woff2 +0 -0
  250. package/static/edgepress/fonts/noto-sans-sc-latin-wght-normal.c1d919c8701a86ed.woff2 +0 -0
  251. package/static/edgepress/fonts/noto-sans-sc-vietnamese-wght-normal.cba3d00c8db989f0.woff2 +0 -0
  252. package/static/edgepress/fonts/noto-sans-tc-0-wght-normal.63c21eda5d20203f.woff2 +0 -0
  253. package/static/edgepress/fonts/noto-sans-tc-100-wght-normal.87a7c76301c24a28.woff2 +0 -0
  254. package/static/edgepress/fonts/noto-sans-tc-101-wght-normal.c9e83fc4ffd53746.woff2 +0 -0
  255. package/static/edgepress/fonts/noto-sans-tc-102-wght-normal.9a00ae0e5735852b.woff2 +0 -0
  256. package/static/edgepress/fonts/noto-sans-tc-103-wght-normal.7cfe93b3221f4def.woff2 +0 -0
  257. package/static/edgepress/fonts/noto-sans-tc-104-wght-normal.2d5ac7497d84cb99.woff2 +0 -0
  258. package/static/edgepress/fonts/noto-sans-tc-105-wght-normal.0989885bb16bcd9b.woff2 +0 -0
  259. package/static/edgepress/fonts/noto-sans-tc-106-wght-normal.1faa921b035064e7.woff2 +0 -0
  260. package/static/edgepress/fonts/noto-sans-tc-107-wght-normal.2f6006745ae16d69.woff2 +0 -0
  261. package/static/edgepress/fonts/noto-sans-tc-108-wght-normal.a61e48c10a2cf4b9.woff2 +0 -0
  262. package/static/edgepress/fonts/noto-sans-tc-109-wght-normal.f28c767724736d2d.woff2 +0 -0
  263. package/static/edgepress/fonts/noto-sans-tc-110-wght-normal.4b04bd88c3dc7c5d.woff2 +0 -0
  264. package/static/edgepress/fonts/noto-sans-tc-111-wght-normal.b6251e1967932a52.woff2 +0 -0
  265. package/static/edgepress/fonts/noto-sans-tc-112-wght-normal.e264513b32f90660.woff2 +0 -0
  266. package/static/edgepress/fonts/noto-sans-tc-113-wght-normal.46cb347d88f3338e.woff2 +0 -0
  267. package/static/edgepress/fonts/noto-sans-tc-114-wght-normal.9f9e1e0d0ab2aeb4.woff2 +0 -0
  268. package/static/edgepress/fonts/noto-sans-tc-115-wght-normal.653dd2ab7d551b40.woff2 +0 -0
  269. package/static/edgepress/fonts/noto-sans-tc-116-wght-normal.4718bf3627191297.woff2 +0 -0
  270. package/static/edgepress/fonts/noto-sans-tc-117-wght-normal.b241a9409682af5f.woff2 +0 -0
  271. package/static/edgepress/fonts/noto-sans-tc-118-wght-normal.fda2800dc1c06852.woff2 +0 -0
  272. package/static/edgepress/fonts/noto-sans-tc-119-wght-normal.948af36bd63a72c7.woff2 +0 -0
  273. package/static/edgepress/fonts/noto-sans-tc-19-wght-normal.62191cdc3a10e60a.woff2 +0 -0
  274. package/static/edgepress/fonts/noto-sans-tc-20-wght-normal.65c90892c2e01657.woff2 +0 -0
  275. package/static/edgepress/fonts/noto-sans-tc-21-wght-normal.d3f59b094c334866.woff2 +0 -0
  276. package/static/edgepress/fonts/noto-sans-tc-22-wght-normal.b1983a041d7e4e1f.woff2 +0 -0
  277. package/static/edgepress/fonts/noto-sans-tc-23-wght-normal.16e2163b66c7732a.woff2 +0 -0
  278. package/static/edgepress/fonts/noto-sans-tc-24-wght-normal.72c8561976116537.woff2 +0 -0
  279. package/static/edgepress/fonts/noto-sans-tc-25-wght-normal.b2948d928ce4dd54.woff2 +0 -0
  280. package/static/edgepress/fonts/noto-sans-tc-26-wght-normal.d583236b90abedc9.woff2 +0 -0
  281. package/static/edgepress/fonts/noto-sans-tc-27-wght-normal.7d6073ba07597c9b.woff2 +0 -0
  282. package/static/edgepress/fonts/noto-sans-tc-28-wght-normal.517204d716b089cd.woff2 +0 -0
  283. package/static/edgepress/fonts/noto-sans-tc-29-wght-normal.10189a8f8ca38099.woff2 +0 -0
  284. package/static/edgepress/fonts/noto-sans-tc-30-wght-normal.695a71d72dfab3f7.woff2 +0 -0
  285. package/static/edgepress/fonts/noto-sans-tc-31-wght-normal.868d59473ef0e73c.woff2 +0 -0
  286. package/static/edgepress/fonts/noto-sans-tc-32-wght-normal.92efa2d4b3702ec0.woff2 +0 -0
  287. package/static/edgepress/fonts/noto-sans-tc-33-wght-normal.d2274bcd5636197d.woff2 +0 -0
  288. package/static/edgepress/fonts/noto-sans-tc-34-wght-normal.1ac8c3718ee2eed2.woff2 +0 -0
  289. package/static/edgepress/fonts/noto-sans-tc-35-wght-normal.00b3df730d5a4c73.woff2 +0 -0
  290. package/static/edgepress/fonts/noto-sans-tc-36-wght-normal.6963feb6c628b81f.woff2 +0 -0
  291. package/static/edgepress/fonts/noto-sans-tc-37-wght-normal.a1b4bd294af24cd1.woff2 +0 -0
  292. package/static/edgepress/fonts/noto-sans-tc-38-wght-normal.8edb2f7f2955921b.woff2 +0 -0
  293. package/static/edgepress/fonts/noto-sans-tc-39-wght-normal.37bf7394eb3148c6.woff2 +0 -0
  294. package/static/edgepress/fonts/noto-sans-tc-40-wght-normal.3efa655bc48f9133.woff2 +0 -0
  295. package/static/edgepress/fonts/noto-sans-tc-41-wght-normal.48d47014544ebd5d.woff2 +0 -0
  296. package/static/edgepress/fonts/noto-sans-tc-42-wght-normal.214c733a59d423e7.woff2 +0 -0
  297. package/static/edgepress/fonts/noto-sans-tc-43-wght-normal.c2c9876f138bb20f.woff2 +0 -0
  298. package/static/edgepress/fonts/noto-sans-tc-44-wght-normal.28928bfea29db06a.woff2 +0 -0
  299. package/static/edgepress/fonts/noto-sans-tc-45-wght-normal.bf3743f8155fa9d2.woff2 +0 -0
  300. package/static/edgepress/fonts/noto-sans-tc-46-wght-normal.06970903593781c3.woff2 +0 -0
  301. package/static/edgepress/fonts/noto-sans-tc-47-wght-normal.8932ed83a91794d4.woff2 +0 -0
  302. package/static/edgepress/fonts/noto-sans-tc-48-wght-normal.dfd9225628bcf234.woff2 +0 -0
  303. package/static/edgepress/fonts/noto-sans-tc-49-wght-normal.c27d341ad39d44eb.woff2 +0 -0
  304. package/static/edgepress/fonts/noto-sans-tc-50-wght-normal.01a0f4e7dfba0a56.woff2 +0 -0
  305. package/static/edgepress/fonts/noto-sans-tc-51-wght-normal.5a30022af3d30e0f.woff2 +0 -0
  306. package/static/edgepress/fonts/noto-sans-tc-52-wght-normal.4884f032342a3274.woff2 +0 -0
  307. package/static/edgepress/fonts/noto-sans-tc-53-wght-normal.537adcae44511270.woff2 +0 -0
  308. package/static/edgepress/fonts/noto-sans-tc-54-wght-normal.6bc8c7d3e47c6fde.woff2 +0 -0
  309. package/static/edgepress/fonts/noto-sans-tc-55-wght-normal.725a48711f1e1997.woff2 +0 -0
  310. package/static/edgepress/fonts/noto-sans-tc-56-wght-normal.f43ecaa73e0cd8fc.woff2 +0 -0
  311. package/static/edgepress/fonts/noto-sans-tc-57-wght-normal.15b24e9d42a3270f.woff2 +0 -0
  312. package/static/edgepress/fonts/noto-sans-tc-58-wght-normal.9b2d8a71667d3da6.woff2 +0 -0
  313. package/static/edgepress/fonts/noto-sans-tc-59-wght-normal.a68535a6b3289d89.woff2 +0 -0
  314. package/static/edgepress/fonts/noto-sans-tc-6-wght-normal.32cece1ae80c4f2b.woff2 +0 -0
  315. package/static/edgepress/fonts/noto-sans-tc-60-wght-normal.3b2c56a921d1cad4.woff2 +0 -0
  316. package/static/edgepress/fonts/noto-sans-tc-61-wght-normal.54f51f80a0740878.woff2 +0 -0
  317. package/static/edgepress/fonts/noto-sans-tc-62-wght-normal.ba029c5bb6c07bf1.woff2 +0 -0
  318. package/static/edgepress/fonts/noto-sans-tc-63-wght-normal.380919beec4c7f3d.woff2 +0 -0
  319. package/static/edgepress/fonts/noto-sans-tc-64-wght-normal.f96fe0be479d201f.woff2 +0 -0
  320. package/static/edgepress/fonts/noto-sans-tc-65-wght-normal.7d544b80701fe678.woff2 +0 -0
  321. package/static/edgepress/fonts/noto-sans-tc-66-wght-normal.fab669c07a1e3bee.woff2 +0 -0
  322. package/static/edgepress/fonts/noto-sans-tc-67-wght-normal.0001bb744743fdfa.woff2 +0 -0
  323. package/static/edgepress/fonts/noto-sans-tc-68-wght-normal.1a57eda03886b43c.woff2 +0 -0
  324. package/static/edgepress/fonts/noto-sans-tc-69-wght-normal.017fecc749bcd6d5.woff2 +0 -0
  325. package/static/edgepress/fonts/noto-sans-tc-7-wght-normal.cd0f0cac743dbbfe.woff2 +0 -0
  326. package/static/edgepress/fonts/noto-sans-tc-70-wght-normal.79fb9d70b9534ec7.woff2 +0 -0
  327. package/static/edgepress/fonts/noto-sans-tc-71-wght-normal.04a6ea581b5f1195.woff2 +0 -0
  328. package/static/edgepress/fonts/noto-sans-tc-72-wght-normal.d3a4eb35499157b7.woff2 +0 -0
  329. package/static/edgepress/fonts/noto-sans-tc-73-wght-normal.d81f8484404ee465.woff2 +0 -0
  330. package/static/edgepress/fonts/noto-sans-tc-74-wght-normal.4a5da9e0c2702f7d.woff2 +0 -0
  331. package/static/edgepress/fonts/noto-sans-tc-75-wght-normal.8b87f9c6c164513a.woff2 +0 -0
  332. package/static/edgepress/fonts/noto-sans-tc-76-wght-normal.a71463dbfd9fa7b5.woff2 +0 -0
  333. package/static/edgepress/fonts/noto-sans-tc-77-wght-normal.d094414ea752cd52.woff2 +0 -0
  334. package/static/edgepress/fonts/noto-sans-tc-78-wght-normal.04f7c97bd9cbc73b.woff2 +0 -0
  335. package/static/edgepress/fonts/noto-sans-tc-79-wght-normal.1ed089d037a02049.woff2 +0 -0
  336. package/static/edgepress/fonts/noto-sans-tc-8-wght-normal.d052677dfb5db5bd.woff2 +0 -0
  337. package/static/edgepress/fonts/noto-sans-tc-80-wght-normal.6d41ca59cfd50442.woff2 +0 -0
  338. package/static/edgepress/fonts/noto-sans-tc-81-wght-normal.f09bd0afa8a58114.woff2 +0 -0
  339. package/static/edgepress/fonts/noto-sans-tc-82-wght-normal.7920841c526b1b02.woff2 +0 -0
  340. package/static/edgepress/fonts/noto-sans-tc-83-wght-normal.0c6f7f82db8da0d8.woff2 +0 -0
  341. package/static/edgepress/fonts/noto-sans-tc-84-wght-normal.f37c0a853b31265e.woff2 +0 -0
  342. package/static/edgepress/fonts/noto-sans-tc-85-wght-normal.4173bff087653b82.woff2 +0 -0
  343. package/static/edgepress/fonts/noto-sans-tc-86-wght-normal.dac35605ebec15c4.woff2 +0 -0
  344. package/static/edgepress/fonts/noto-sans-tc-87-wght-normal.8ac85fdd0b15a6bd.woff2 +0 -0
  345. package/static/edgepress/fonts/noto-sans-tc-88-wght-normal.61625b0b21e8407f.woff2 +0 -0
  346. package/static/edgepress/fonts/noto-sans-tc-89-wght-normal.a1da3dccbf00698e.woff2 +0 -0
  347. package/static/edgepress/fonts/noto-sans-tc-90-wght-normal.bf08f0355222a782.woff2 +0 -0
  348. package/static/edgepress/fonts/noto-sans-tc-91-wght-normal.2ecf295bba2bfaa3.woff2 +0 -0
  349. package/static/edgepress/fonts/noto-sans-tc-92-wght-normal.36508326bb68115f.woff2 +0 -0
  350. package/static/edgepress/fonts/noto-sans-tc-97-wght-normal.c712899e0fc9e947.woff2 +0 -0
  351. package/static/edgepress/fonts/noto-sans-tc-98-wght-normal.13a648e521d18d7f.woff2 +0 -0
  352. package/static/edgepress/fonts/noto-sans-tc-99-wght-normal.7f8c9a777e64159a.woff2 +0 -0
  353. package/static/edgepress/fonts/noto-sans-tc-OFL.txt +93 -0
  354. package/static/edgepress/fonts/noto-sans-tc-cyrillic-wght-normal.dd0980ced8e46cb3.woff2 +0 -0
  355. package/static/edgepress/fonts/noto-sans-tc-latin-ext-wght-normal.817d96335dc52ce6.woff2 +0 -0
  356. package/static/edgepress/fonts/noto-sans-tc-latin-wght-normal.50cb5fb0a522cfaf.woff2 +0 -0
  357. package/static/edgepress/fonts/noto-sans-tc-vietnamese-wght-normal.2a77fb7b70fa304d.woff2 +0 -0
  358. package/static/edgepress/fonts/noto-sans-vietnamese-wght-normal.791e3f213c155a5b.woff2 +0 -0
  359. package/static/edgepress/fonts/noto-serif-OFL.txt +93 -0
  360. package/static/edgepress/fonts/noto-serif-cyrillic-ext-wght-normal.7c7afb20d33e30ec.woff2 +0 -0
  361. package/static/edgepress/fonts/noto-serif-cyrillic-wght-normal.54e3bd0c4a002b0f.woff2 +0 -0
  362. package/static/edgepress/fonts/noto-serif-greek-ext-wght-normal.9b3f4c4ff3adefd2.woff2 +0 -0
  363. package/static/edgepress/fonts/noto-serif-greek-wght-normal.1252037b6f1d6e86.woff2 +0 -0
  364. package/static/edgepress/fonts/noto-serif-latin-ext-wght-normal.2654f8d1261a420f.woff2 +0 -0
  365. package/static/edgepress/fonts/noto-serif-latin-wght-normal.46281456234014ce.woff2 +0 -0
  366. package/static/edgepress/fonts/noto-serif-math-wght-normal.d4a6aba2cb4636b3.woff2 +0 -0
  367. package/static/edgepress/fonts/noto-serif-vietnamese-wght-normal.fb6a5ed734c2fbe3.woff2 +0 -0
  368. package/static/edgepress/fonts/provenance.json +1356 -0
  369. package/static/edgepress/fonts.css +14 -0
  370. package/static/edgepress/image-viewer.js +23 -0
  371. package/static/edgepress/images.css +4 -0
  372. package/static/edgepress/link-directory.css +14 -0
  373. package/static/edgepress/local-time.js +12 -0
  374. package/static/edgepress/media-viewer.css +1 -0
  375. package/static/edgepress/media-viewer.js +111 -0
  376. package/static/edgepress/navigation-select.js +14 -0
  377. package/static/edgepress/oembed.js +129 -0
  378. package/static/edgepress/plugins/backend/external-api.js +4 -0
  379. package/static/edgepress/plugins/backend/external-widget.js +13 -0
  380. package/static/edgepress/plugins/consent/choices.js +47 -0
  381. package/static/edgepress/plugins/consent/manager.js +56 -74
  382. package/static/edgepress/project-demo.js +93 -0
  383. package/static/edgepress/search.js +2 -2
  384. package/static/edgepress/services-consent.js +3 -0
  385. package/static/edgepress/services.js +15 -0
  386. package/static/edgepress/webmcp.js +86 -0
  387. package/tests/assets-runtime.test.mjs +91 -0
  388. package/tests/audit.test.mjs +142 -0
  389. package/tests/brand-regression.test.mjs +110 -0
  390. package/tests/consent-browser.test.mjs +132 -0
  391. package/tests/consent-config.test.mjs +85 -0
  392. package/tests/consent-csp.test.mjs +90 -0
  393. package/tests/data-saver-loading.test.mjs +158 -0
  394. package/tests/data-saver-network.test.mjs +99 -0
  395. package/tests/data-saver.test.mjs +294 -0
  396. package/tests/document-package.test.mjs +28 -0
  397. package/tests/document-viewer.test.mjs +152 -0
  398. package/tests/edge-issues.test.mjs +65 -0
  399. package/tests/embed-syntax.test.mjs +68 -0
  400. package/tests/external-links.test.mjs +198 -0
  401. package/tests/fixtures/recording.mp4 +0 -0
  402. package/tests/fonts.test.mjs +99 -0
  403. package/tests/image-dimensions.test.mjs +15 -0
  404. package/tests/image-viewer.test.mjs +36 -0
  405. package/tests/json-pages.test.mjs +66 -0
  406. package/tests/link-directory.test.mjs +51 -0
  407. package/tests/local-tool.test.mjs +34 -0
  408. package/tests/markdown-media.test.mjs +119 -0
  409. package/tests/media-block.test.mjs +102 -0
  410. package/tests/navigation.test.mjs +87 -0
  411. package/tests/oembed-layout.test.mjs +98 -0
  412. package/tests/oembed.test.mjs +96 -0
  413. package/tests/page-authoring.test.mjs +27 -0
  414. package/tests/page-formatting.test.mjs +37 -0
  415. package/tests/post-list.test.mjs +96 -0
  416. package/tests/project-demo.test.mjs +73 -0
  417. package/tests/publication-time.test.mjs +43 -0
  418. package/tests/release-version.test.mjs +28 -0
  419. package/tests/revisions.test.mjs +93 -0
  420. package/tests/rss.test.mjs +54 -0
  421. package/tests/services.test.mjs +35 -0
  422. package/tests/theme-layout.test.mjs +95 -0
  423. package/tests/video-metadata.test.mjs +28 -0
  424. package/tests/webmcp.test.mjs +121 -0
  425. package/tests/writing.test.mjs +101 -0
  426. package/themes/atelier/assets/style.css +144 -22
  427. package/themes/atelier/layouts/partials/head.html +8 -1
  428. package/themes/atelier/layouts/partials/header.html +3 -1
  429. package/themes/default/assets/data-saver-theme.css +2 -0
  430. package/themes/default/assets/style.css +139 -23
  431. package/themes/default/layouts/partials/head.html +9 -2
  432. package/themes/default/layouts/partials/header.html +3 -1
  433. package/themes/default/theme.json +1 -0
  434. package/themes/folio/README.md +7 -0
  435. package/themes/folio/assets/data-saver-theme.css +3 -0
  436. package/themes/folio/assets/folio/navigation.js +10 -0
  437. package/themes/folio/assets/style.css +516 -0
  438. package/themes/folio/layouts/layout.html +15 -0
  439. package/themes/folio/layouts/partials/footer.html +6 -0
  440. package/themes/folio/layouts/partials/head.html +30 -0
  441. package/themes/folio/layouts/partials/header.html +8 -0
  442. package/themes/folio/theme.json +7 -0
  443. package/themes/signal/assets/style.css +143 -21
  444. package/themes/signal/layouts/partials/head.html +8 -1
  445. package/themes/signal/layouts/partials/header.html +3 -1
  446. package/tools/README.md +14 -0
  447. package/tools/assets/format-pages.mjs +56 -0
  448. package/tools/assets/generate-brand-assets.py +160 -0
  449. package/tools/assets/sync-fonts.mjs +71 -0
  450. package/tools/assets/sync-icons.mjs +17 -0
  451. package/tools/assets/video-preview.mjs +15 -0
  452. package/tools/recordings/capture-document-demo.mjs +147 -0
  453. package/tools/recordings/demo-workbench.mjs +57 -0
  454. package/tools/recordings/desktop-recording.mjs +92 -0
  455. package/tools/recordings/provision-vm.sh +18 -0
  456. package/tools/recordings/start-vm-desktop.sh +28 -0
  457. package/tools/recordings/vm-desktop.mjs +124 -0
  458. package/tools/verification/README.md +44 -0
  459. package/tools/verification/data-saver-site.mjs +405 -0
  460. package/tools/verification/page-performance.mjs +76 -0
  461. package/tools/verification/verify-brand-assets.py +52 -0
  462. package/tools/verification/verify-brand.mjs +180 -0
  463. package/vercel.json +36 -0
  464. package/wrangler.jsonc +5 -7
  465. package/content/pages/home/en.md +0 -99
  466. package/content/pages/home/zh-cn.md +0 -99
  467. package/content/pages/plugin-development/en.md +0 -109
  468. package/content/pages/plugin-development/zh-cn.md +0 -109
  469. package/content/pages/privacy-policy/en.md +0 -93
  470. package/content/pages/privacy-policy/zh-cn.md +0 -93
  471. package/content/pages/project-introduction/en.md +0 -88
  472. package/content/pages/project-introduction/zh-cn.md +0 -88
  473. package/content/pages/quick-start/en.md +0 -153
  474. package/content/pages/quick-start/zh-cn.md +0 -153
  475. package/content/pages/theme-development/en.md +0 -132
  476. package/content/pages/theme-development/zh-cn.md +0 -132
  477. package/src/worker.js +0 -139
  478. package/static/edgepress/brand/edgepress-mark.svg +0 -7
  479. package/static/edgepress/brand/mascot-209.webp +0 -0
  480. package/static/edgepress/brand/mascot-418.png +0 -0
  481. package/static/edgepress/brand/mascot-418.webp +0 -0
  482. package/static/edgepress/brand/mascot.png +0 -0
  483. package/static/edgepress/brand/og-image.png +0 -0
  484. package/static/edgepress/brand/og-image.svg +0 -46
  485. package/static/edgepress/icons.svg +0 -24
@@ -0,0 +1,132 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFile} from 'node:fs/promises';
4
+ import {fileURLToPath} from 'node:url';
5
+ import {resolve} from 'node:path';
6
+ import {chromium} from 'playwright';
7
+ import {loadConfig} from '../src/config.js';
8
+ import consentManager from '../plugins/consent/index.js';
9
+ import {choiceSnapshot,readChoice} from '../static/edgepress/plugins/consent/choices.js';
10
+ const root=fileURLToPath(new URL('../',import.meta.url));
11
+
12
+ test('text view resumes the requested widget after consent, retries failure and never loads other allowed widgets', async () => {
13
+ const config = await loadConfig(root);
14
+ const origin = new URL(config.site.url).origin;
15
+ const service = {id:'discussion',provider:'external-widget',moduleUrl:'https://comments.example.com/widget.js',placement:'posts',backendUrl:'https://comments.example.com',name:'Discussion',purpose:'Read discussions.',dataCategories:'Comments.',recipient:'Publisher',retention:'Until removed.',privacyUrl:'https://comments.example.com/privacy'};
16
+ config.browserPlugins.services = [service, {...service, id:'other', moduleUrl:'https://comments.example.com/other.js'}];
17
+ let filter;
18
+ consentManager({registerFilter:(_name, callback) => {filter = callback;}});
19
+ const body = '<main>文章 Article 繁體</main><section data-edgepress-service="discussion"><button data-service-load="discussion">Load discussion</button></section><section data-edgepress-service="other"><button data-service-load="other">Load other</button></section>';
20
+ const html = filter('<!doctype html><html data-edgepress-data-mode="text"><body>' + body + '</body></html>', {config,page:{locale:'en'}});
21
+ const browser = await chromium.launch({headless:true,args:['--disable-extensions']});
22
+ try {
23
+ for (const blocked of [false, true]) {
24
+ const context = await browser.newContext();
25
+ if (blocked) await context.addInitScript(() => Object.defineProperty(window,'localStorage',{get(){throw new Error('Denied');}}));
26
+ const vendors = [];
27
+ await context.route('**/*', async route => {
28
+ const address = new URL(route.request().url());
29
+ if (address.origin !== origin) {
30
+ vendors.push(address.pathname);
31
+ return route.fulfill({contentType:'text/javascript',body:'export function mount(root){globalThis.attempts=(globalThis.attempts||0)+1;if(globalThis.attempts===1)throw new Error("Retry fixture");root.textContent="Mounted discussion";}'});
32
+ }
33
+ if (address.pathname.startsWith('/edgepress/')) {
34
+ try { return route.fulfill({contentType:address.pathname.endsWith('.js')?'text/javascript':'image/svg+xml',body:await readFile(resolve(root,'static',address.pathname.slice(1)))}); }
35
+ catch { return route.fulfill({status:404}); }
36
+ }
37
+ return route.fulfill({contentType:'text/html',body:html});
38
+ });
39
+ const page = await context.newPage();
40
+ const errors = [];
41
+ page.on('pageerror', error => errors.push(error.message));
42
+ await page.goto(origin + '/article/');
43
+ await page.locator('.privacy-accept').click();
44
+ await page.waitForTimeout(150);
45
+ assert.deepEqual(vendors, []);
46
+ await page.getByRole('button',{name:'Load discussion',exact:true}).click();
47
+ await page.waitForFunction(() => globalThis.attempts === 1);
48
+ await page.waitForFunction(() => !document.querySelector('[data-service-load=discussion]').disabled);
49
+ await page.getByRole('button',{name:'Load discussion',exact:true}).click();
50
+ await page.getByText('Mounted discussion',{exact:true}).waitFor();
51
+ assert.deepEqual(vendors, ['/widget.js']);
52
+ await page.reload();
53
+ await page.locator('.privacy-settings-button').waitFor();
54
+ assert.equal(await page.locator('.privacy-panel').isVisible(), false);
55
+ await page.waitForTimeout(150);
56
+ assert.deepEqual(vendors, ['/widget.js']);
57
+ await page.getByRole('button',{name:'Load discussion',exact:true}).click();
58
+ await page.waitForFunction(() => globalThis.attempts === 1);
59
+ assert.deepEqual(vendors, ['/widget.js','/widget.js']);
60
+ assert.deepEqual(errors, []);
61
+ await context.close();
62
+ }
63
+ const context = await browser.newContext();
64
+ await context.route('**/*', async route => {
65
+ const address = new URL(route.request().url());
66
+ if (address.origin !== origin) return route.fulfill({contentType:'text/javascript',body:'export function mount(root){root.textContent="Consented discussion";}'});
67
+ if (address.pathname.startsWith('/edgepress/')) {
68
+ try { return route.fulfill({contentType:address.pathname.endsWith('.js')?'text/javascript':'image/svg+xml',body:await readFile(resolve(root,'static',address.pathname.slice(1)))}); }
69
+ catch { return route.fulfill({status:404}); }
70
+ }
71
+ return route.fulfill({contentType:'text/html',body:html});
72
+ });
73
+ const page = await context.newPage();
74
+ await page.goto(origin + '/article/');
75
+ await page.locator('.privacy-close').click();
76
+ await page.getByRole('button',{name:'Load discussion',exact:true}).click();
77
+ await page.locator('.privacy-panel').waitFor({state:'visible'});
78
+ await page.locator('.privacy-accept').click();
79
+ await page.getByText('Consented discussion',{exact:true}).waitFor();
80
+ assert.equal(await page.getByRole('button',{name:'Load other',exact:true}).isVisible(),true);
81
+ await context.close();
82
+ } finally { await browser.close(); }
83
+ });
84
+
85
+ test('saved formal choices survive the provider rename, while an actual endpoint change requires consent',()=>{
86
+ const original=Object.fromEntries(['localStorage','sessionStorage','document','location'].map(name=>[name,Object.getOwnPropertyDescriptor(globalThis,name)]));
87
+ const values=new Map();const store={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
88
+ const privacy={controller:{name:'Publisher',contact:'mail@example.com'},policyUrl:'/privacy/',consent:{enabled:true,proposedDate:'2026-10-03',effectiveDate:'2026-10-03',expiresDays:180},integrations:[{id:'github-comments',provider:'external-widget',moduleUrl:'https://comments.example.com/widget.js',placement:'posts',backendUrl:'https://website.example',name:'CommentNest',purpose:'Read comments'}]};
89
+ const previous=structuredClone(privacy);previous.integrations[0].provider='github-comments';previous.integrations[0].name='GitHub comments';delete previous.integrations[0].backendUrl;
90
+ const saved={proposedDate:'2026-10-03',effectiveDate:'2026-10-03',expiresAt:Date.now()+86400000,allowed:['github-comments'],fingerprint:JSON.stringify(previous)};
91
+ const config={privacy,siteOrigin:'https://website.example/',choiceFingerprint:'current-fingerprint',choiceSnapshot:choiceSnapshot(privacy)};
92
+ try {
93
+ Object.assign(globalThis,{localStorage:store,sessionStorage:store,document:{cookie:''},location:{protocol:'https:'}});values.set('edgepress-privacy-selection',JSON.stringify(saved));
94
+ assert.equal(readChoice(config),null,'A legacy same-origin service must not grant consent to an independent backend');
95
+ values.set('edgepress-privacy-selection',JSON.stringify(saved));const changed=structuredClone(config);changed.privacy.integrations[0].backendUrl='https://new-service.example';changed.choiceSnapshot=choiceSnapshot(changed.privacy);changed.choiceFingerprint='different-fingerprint';assert.equal(readChoice(changed),null);
96
+ }finally{for(const [name,descriptor] of Object.entries(original)){if(descriptor)Object.defineProperty(globalThis,name,descriptor);else delete globalThis[name];}}
97
+ });
98
+ test('privacy choices survive refresh, pages, locales, cosmetic rebuilds and blocked localStorage; withdrawal stays off',async()=>{
99
+ const config=await loadConfig(root),origin=new URL(config.site.url).origin;
100
+ const integration={id:'discussion',provider:'external-widget',moduleUrl:'https://comments.example.com/widget.js',placement:'posts',backendUrl:'https://comments.example.com',category:'backend',name:'Discussion',purpose:'Read discussions.',dataCategories:'Identity and comments.',recipient:'GitHub',retention:'Until removed.',privacyUrl:'https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement'};
101
+ config.browserPlugins.services=[integration];
102
+ let filter;consentManager({registerFilter:(_name,fn)=>{filter=fn;}});
103
+ const browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
104
+ try {for(const blocked of [false,true]) {
105
+ const context=await browser.newContext();
106
+ if(blocked)await context.addInitScript(()=>{Object.defineProperty(window,'localStorage',{get(){throw new Error('Storage denied');}});});
107
+ let loads=0,cosmetic=false,policyChanged=false;
108
+ await context.route('**/*',async route=>{
109
+ const url=new URL(route.request().url());
110
+ if(url.origin!==origin)return route.fulfill({status:404,body:''});
111
+ if(url.pathname.endsWith('/external-widget.js'))return route.fulfill({contentType:'text/javascript',body:'export function load(){fetch("/integration-probe");}'});
112
+ if(url.pathname==='/integration-probe'){loads++;return route.fulfill({body:'ok'});}
113
+ if(url.pathname.startsWith('/edgepress/')) {
114
+ try{return route.fulfill({contentType:url.pathname.endsWith('.js')?'text/javascript':'image/svg+xml',body:await readFile(resolve(root,'static',url.pathname.slice(1)))});}catch{return route.fulfill({status:404,body:''});}
115
+ }
116
+ const current=structuredClone(config);
117
+ if(cosmetic)current.browserPlugins.services[0].name='Localized new presentation';
118
+ if(policyChanged)current.browserPlugins.consent.effectiveDate=new Date(Date.parse(current.browserPlugins.consent.effectiveDate+'T00:00:00Z')+86400000).toISOString().slice(0,10);
119
+ const html=filter('<!doctype html><html><body><main>Article</main></body></html>',{config:current,page:{locale:url.pathname.includes('translated')?'zh-CN':'en'}});
120
+ return route.fulfill({contentType:'text/html',body:html});
121
+ });
122
+ const page=await context.newPage();await page.goto(origin+'/article/');await page.locator('.privacy-panel').waitFor({state:'visible'});assert.equal(loads,0);assert.equal(await page.locator('.privacy-category').count(),0);assert.equal(await page.locator('.privacy-service-toggle strong').textContent(),'Discussion');
123
+ await page.locator('input[value=discussion]').check();await page.locator('.privacy-save').click();await page.waitForFunction(()=>document.querySelector('.privacy-panel').hidden);await page.waitForTimeout(50);assert.equal(loads,1);
124
+ for(const path of ['/article/','/translated/article/','/another/']) {await page.goto(origin+path);await page.locator('.privacy-settings-button').waitFor();await page.waitForTimeout(50);assert.ok(await page.locator('.privacy-panel').isHidden());assert.ok(await page.locator('input[value=discussion]').isChecked());}
125
+ assert.equal(loads,4);
126
+ cosmetic=true;await page.reload();await page.locator('.privacy-settings-button').waitFor();await page.waitForTimeout(50);assert.ok(await page.locator('.privacy-panel').isHidden());assert.equal(loads,5);
127
+ await page.locator('.privacy-settings-button').click();await page.locator('input[value=discussion]').uncheck();await Promise.all([page.waitForEvent('load'),page.locator('.privacy-save').click()]);await page.locator('.privacy-settings-button').waitFor();await page.waitForTimeout(50);assert.equal(loads,5);assert.ok(await page.locator('.privacy-panel').isHidden());
128
+ await page.reload();await page.locator('.privacy-settings-button').waitFor();assert.ok(await page.locator('.privacy-panel').isHidden());assert.ok(!await page.locator('input[value=discussion]').isChecked());
129
+ policyChanged=true;await page.reload();await page.locator('.privacy-panel').waitFor({state:'visible'});assert.ok(!await page.locator('input[value=discussion]').isChecked());assert.equal(loads,5);
130
+ await context.close();
131
+ }}finally{await browser.close();}
132
+ });
@@ -0,0 +1,85 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {mkdtemp,writeFile,readFile,rm} from 'node:fs/promises';
4
+ import {tmpdir} from 'node:os';
5
+ import {resolve} from 'node:path';
6
+ import {fileURLToPath} from 'node:url';
7
+ import {parse,stringify} from 'yaml';
8
+ import {loadConfig} from '../src/config.js';
9
+ import {loadLanguagePacks} from '../src/i18n.js';
10
+ import consentManager from '../plugins/consent/index.js';
11
+ import {generateBuiltinRoutes} from '../src/generators.js';
12
+ import {renderBlocks} from '../src/page-blocks.js';
13
+
14
+ const root=fileURLToPath(new URL('../',import.meta.url));
15
+ const source=parse(await readFile(resolve(root,'config.yml'),'utf8'));
16
+ source.plugins.consent.services.push({id:'github-comments',provider:'external-widget',moduleUrl:'https://comments.example.com/commentnest/widget.js',placement:'posts',backendUrl:'https://comments.example.com',name:'GitHub comments',purpose:'Read discussions.',dataCategories:'Identity and comments.',recipient:'GitHub',retention:'Until removed.',privacyUrl:'https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement'});
17
+
18
+ test('consent payload follows YAML names, dates, expiry and enabled services; no old fingerprints survive',async()=>{
19
+ const folder=await mkdtemp(resolve(tmpdir(),'reporelay-consent-'));
20
+ try {
21
+ const settings=structuredClone(source);
22
+ settings.plugins.consent.expiresDays=37;
23
+ settings.plugins.consent.proposedDate='2026-10-03';
24
+ settings.plugins.consent.effectiveDate='2026-10-04';
25
+ for (const service of settings.plugins.consent.services) service.enabled = service.provider === 'external-widget';
26
+ settings.plugins.consent.services.find(service => service.provider === 'external-widget').name='Configured discussion name';
27
+ await writeFile(resolve(folder,'config.yml'),stringify(settings));
28
+ await writeFile(resolve(folder,'edgepress.config.mjs'),"export default {i18n:{defaultLocale:'en',languagePacks:['zh-CN']}};");
29
+ const config=await loadConfig(folder);
30
+ config.i18n.translationsByLocale={en:{}};
31
+ let filter;
32
+ consentManager({registerFilter:(_key,value)=>{filter=value;}});
33
+ const html=filter('<html><body></body></html>',{config,page:{locale:'en'}});
34
+ const payload=JSON.parse(html.match(/id="edgepress-privacy-config">([\s\S]*?)<\/script>/)[1]);
35
+ assert.equal(payload.privacy.consent.expiresDays,37);
36
+ assert.equal(payload.privacy.consent.effectiveDate,'2026-10-04');
37
+ assert.deepEqual(payload.privacy.integrations.map(item=>item.id),['github-comments']);
38
+ assert.equal(payload.privacy.integrations[0].name,'Configured discussion name');
39
+ assert.equal(payload.legacyChoiceFingerprints,undefined);
40
+ assert.equal(config.browserPlugins.services.filter(service => service.enabled === false).length,settings.plugins.consent.services.filter(service=>service.enabled===false).length,'Disabled services are retained without loading them');
41
+ assert.ok(!Object.hasOwn(payload.privacy.integrations[0], 'category'));
42
+ assert.ok(!Object.hasOwn(payload.ui, 'pluginBackend'));
43
+ } finally {await rm(folder,{recursive:true,force:true});}
44
+ });
45
+
46
+ test('disabling external services removes slots and publishes no service context',async()=>{
47
+ const config=await loadConfig(root);await loadLanguagePacks(config);
48
+ config.browserPlugins.services.forEach(service => service.enabled = false);
49
+ const post={kind:'posts',bundlePath:'fixture',title:'Article',locale:'en',path:'fixture/',date:new Date('2026-10-03'),
50
+ tags:[],category:'blog',description:'Article',markdown:'Text',html:'<p>Text</p>'};
51
+ const routes=await generateBuiltinRoutes({posts:[post],pages:[]},config,{filter:async(_key,value)=>value});
52
+ const article=routes.find(route=>route.path==='fixture/index.html');
53
+ assert.doesNotMatch(article.body,/data-edgepress-service|services-consent\.js/);
54
+ assert.equal(routes.find(route=>route.path==='edgepress/service-contexts.json').body,'[]');
55
+ const html=await renderBlocks([{columns:1,cells:[[{type:'service',integration:'github-comments'}]]}],{config,locale:'en',site:{posts:[]}});
56
+ assert.doesNotMatch(html,/data-edgepress-service/);
57
+ });
58
+
59
+ test('explicit service slots publish stable context across translations and reject duplicate blocks',async()=>{
60
+ const config=await loadConfig(root);await loadLanguagePacks(config);
61
+ config.browserPlugins.services=structuredClone(source.plugins.consent.services);
62
+ const blocks=[{columns:1,cells:[[{type:'service',integration:'github-comments'}]]}];
63
+ const pages=['en','zh-SG'].map(locale=>({kind:'pages',bundlePath:'home',title:'Home',locale,path:locale==='en'?'':locale+'/',homepage:true,blocks}));
64
+ const routes=await generateBuiltinRoutes({posts:[],pages},config,{filter:async(_key,value)=>value});
65
+ assert.match(routes.find(route=>route.path==='index.html').body,/data-service-thread="page:home"/);
66
+ assert.deepEqual(JSON.parse(routes.find(route=>route.path==='edgepress/service-contexts.json').body),[{thread:'page:home',title:'Home'}]);
67
+ const duplicate=[{columns:1,cells:[[{type:'service',integration:'github-comments'},{type:'service',integration:'github-comments'}]]}];
68
+ await assert.rejects(renderBlocks(duplicate,{config,locale:'en',document:pages[0],site:{posts:[]}}),/only one/);
69
+ });
70
+
71
+
72
+ test('flat consent configuration rejects category presets and validates per-service enablement',async()=>{
73
+ const folder=await mkdtemp(resolve(tmpdir(),'edgepress-services-'));
74
+ try {
75
+ await writeFile(resolve(folder,'edgepress.config.mjs'),"export default {i18n:{defaultLocale:'en',languagePacks:[]}};");
76
+ const settings=structuredClone(source);settings.plugins.consent.services=[];
77
+ settings.plugins.consent.statistics={enabled:true,services:[]};
78
+ await writeFile(resolve(folder,'config.yml'),stringify(settings));
79
+ await assert.rejects(loadConfig(folder),/plugins.consent.services/);
80
+ delete settings.plugins.consent.statistics;
81
+ settings.plugins.consent.services=[{...source.plugins.consent.services.find(service=>service.provider==='external-widget'),enabled:'yes'}];
82
+ await writeFile(resolve(folder,'config.yml'),stringify(settings));
83
+ await assert.rejects(loadConfig(folder),/enabled must be a boolean/);
84
+ }finally{await rm(folder,{recursive:true});}
85
+ });
@@ -0,0 +1,90 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {chromium} from 'playwright';
4
+ import {extendConsentPolicy,injectConsentPolicy,contentPermissions,validateServiceCsp,serializeConsentHeaders} from '../src/consent-csp.js';
5
+ import { dataSaverInlineAssets } from '../src/data-saver.js';
6
+
7
+ test('critical waiting assets receive exact CSP hashes without granting arbitrary inline scripts',async()=>{
8
+ const config={site:{dataSaver:{enabled:true}},browserPlugins:{services:[]}};
9
+ const inline=dataSaverInlineAssets(config);
10
+ const headers=extendConsentPolicy("/*\n Content-Security-Policy: default-src 'self'; script-src 'self'; script-src-elem 'self'; style-src 'self'; style-src-elem 'self'",config);
11
+ assert(headers.includes(inline.scriptHash));
12
+ assert(headers.includes(inline.styleHash));
13
+ assert.doesNotMatch(headers,/unsafe-inline|unsafe-eval/);
14
+ assert.match(extendConsentPolicy('',config),/style-src 'self' 'unsafe-inline';/);
15
+ const browser=await chromium.launch({headless:true,args:['--disable-extensions']});
16
+ try {
17
+ const page=await browser.newPage();
18
+ await page.route('**/*',route=>route.fulfill({contentType:'text/html',body:injectConsentPolicy('<html><head><script id="edgepress-data-saver-config" type="application/json">{"enabled":true,"labels":{},"mode":"full"}</script><style>'+inline.style+'</style><script>'+inline.script+'</script><script>window.untrustedInline=true</script></head><body><main>Reading</main></body></html>',headers)}));
19
+ await page.goto('https://example.test/');
20
+ await page.waitForFunction(()=>document.documentElement.dataset.edgepressDataReady === 'true');
21
+ assert.equal(await page.evaluate(()=>window.untrustedInline),undefined);
22
+ assert.equal(await page.getAttribute('html','data-edgepress-data-mode'),'full');
23
+ assert.equal(await page.locator('body').evaluate(element=>getComputedStyle(element).visibility),'visible');
24
+ }finally{await browser.close();}
25
+ });
26
+
27
+ test('CSP is generated without source headers and removes disabled service permissions',()=>{
28
+ const services=[{provider:'external-widget',backendUrl:'https://comments.example',moduleUrl:'https://comments.example/widget.js'},{provider:'cloudflare-web-analytics'},{enabled:false,provider:'oembed',backendUrl:'https://disabled.example',embedOrigins:['https://disabled-media.example']}];
29
+ const config={browserPlugins:{services,consent:{enabled:true}}};
30
+ const headers=extendConsentPolicy('',config);
31
+ assert.match(headers,/Content-Security-Policy: default-src 'self'/);
32
+ assert.match(headers,/script-src 'self' https:\/\/comments.example https:\/\/static.cloudflareinsights.com/);
33
+ assert.match(headers,/connect-src 'self' https:\/\/comments.example https:\/\/cloudflareinsights.com/);
34
+ assert.doesNotMatch(headers,/disabled/);
35
+ assert.doesNotMatch(headers,/unsafe-eval/);
36
+ services[0].enabled=false;services[1].enabled=false;
37
+ assert.doesNotMatch(extendConsentPolicy('',config),/comments.example|cloudflareinsights/);
38
+ const custom={csp:{'connect-src':['https://api.example','https://*.media.example']}};
39
+ assert.doesNotThrow(()=>validateServiceCsp(custom));
40
+ for(const csp of [{'script-src':["'unsafe-inline'"]},{'connect-src':['https://user:pass@example.com']},{'default-src':['https://example.com']},{'connect-src':['https://example.com; script-src *']}])assert.throws(()=>validateServiceCsp({csp}));
41
+ });
42
+
43
+ test('global source policy and content media survive generation without granting inert embeds permission',()=>{
44
+ const config={browserPlugins:{services:[]},contentCsp:contentPermissions([{body:'<img src="https://pictures.example/image.webp"><video src="https://video.example/movie.mp4"></video><template><img src="https://disabled.example/a"></template>'}])};
45
+ const headers=extendConsentPolicy("/*\n Content-Security-Policy: default-src 'self'; connect-src 'self' https://uploads.example; frame-ancestors 'none'",config);
46
+ const html=injectConsentPolicy('<html><head><title>Site</title></head><body></body></html>',headers);
47
+ assert.match(html,/http-equiv="Content-Security-Policy"/);assert.match(html,/https:\/\/uploads.example/);assert.match(html,/https:\/\/pictures.example/);assert.match(html,/https:\/\/video.example/);
48
+ assert.doesNotMatch(html,/frame-ancestors|disabled.example/);assert.match(headers,/frame-ancestors 'none'/);
49
+ const large=injectConsentPolicy('<html><head data-theme="test"><meta charset="utf-8"><title>繁體中文</title></head><body>简体中文</body></html>',extendConsentPolicy('',{browserPlugins:{services:[]},contentCsp:{'img-src':Array.from({length:24},(_,index)=>'https://image-'+index+'.example')}}));
50
+ assert(Buffer.byteLength(large.slice(0,large.indexOf('<meta charset="utf-8">')+22)) < 1024);
51
+ assert.equal((large.match(/<meta charset=/g)||[]).length,1);
52
+ assert(large.indexOf('<meta charset=') < large.indexOf('http-equiv="Content-Security-Policy"'));
53
+ for (const head of ['', '<meta charset="windows-1252">', '<title>'+'測試'.repeat(500)+'</title><meta charset="utf-8">']) {
54
+ const normalized = injectConsentPolicy('<!doctype html><html><head>'+head+'</head><body>中文</body></html>','');
55
+ assert(Buffer.byteLength(normalized.slice(0,normalized.indexOf('<meta charset="utf-8">')+22)) < 1024);
56
+ assert.equal((normalized.match(/<meta charset=/g)||[]).length,1);
57
+ assert.doesNotMatch(normalized,/windows-1252/);
58
+ }
59
+ const json=JSON.stringify({example:'<html><head></head><body><img src="https://metadata.example/a"></body></html>'});
60
+ assert.equal(injectConsentPolicy(json,headers),json);assert.deepEqual(contentPermissions([{body:json,contentType:'application/json'}]),{});
61
+ });
62
+
63
+ test('HTML CSP enforces configured origins on static platforms without custom response headers',async()=>{
64
+ const headers=extendConsentPolicy('',{browserPlugins:{services:[{provider:'external-api',backendUrl:'https://allowed.example'}]}});
65
+ const browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
66
+ try{const context=await browser.newContext();let allowed=0,blocked=0;
67
+ await context.route('**/*',route=>{const url=new URL(route.request().url());if(url.origin==='https://allowed.example'){allowed++;return route.fulfill({body:'OK',headers:{'Access-Control-Allow-Origin':'*'}});}if(url.origin==='https://blocked.example'){blocked++;return route.fulfill({body:'BAD'});}return route.fulfill({contentType:'text/html',body:injectConsentPolicy('<html><head></head><body></body></html>',headers)});});
68
+ const page=await context.newPage();await page.goto('https://site.example/');
69
+ const result=await page.evaluate(async()=>{let rejected=false;await fetch('https://allowed.example/api');try{await fetch('https://blocked.example/api');}catch{rejected=true;}return rejected;});
70
+ assert.equal(result,true);assert.equal(allowed,1);assert.equal(blocked,0);await context.close();
71
+ }finally{await browser.close();}
72
+ });
73
+
74
+ test('large CSP policies obey the 2000-character header limit without intersecting allowed origin lists',async()=>{
75
+ const origins=Array.from({length:24},(_,i)=>'https://media-'+i+'.provider.example');
76
+ const source=extendConsentPolicy('',{browserPlugins:{services:[{provider:'oembed',backendUrl:'https://allowed.example',embedOrigins:origins}]}});
77
+ assert.ok(source.split('\n').some(line=>line.length>2000));
78
+ const serialized=serializeConsentHeaders(source),policies=serialized.split('\n').filter(line=>/^\s*Content-Security-Policy:/.test(line)).map(line=>line.replace(/^\s*Content-Security-Policy:\s*/,''));
79
+ assert.ok(policies.length>1);assert.ok(serialized.split('\n').every(line=>line.length<=2000));
80
+ const browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
81
+ try{const context=await browser.newContext();let blocked=0,frames=0;
82
+ await context.route('**/*',route=>{const url=new URL(route.request().url());if(url.origin==='https://site.example')return route.fulfill({contentType:'text/html',headers:{'Content-Security-Policy':policies.join(', ')},body:'<html><head></head><body></body></html>'});if(url.origin==='https://blocked.example')blocked++;if(url.pathname==='/embed')frames++;return route.fulfill({contentType:url.pathname==='/embed'?'text/html':'text/plain',body:'OK',headers:{'Access-Control-Allow-Origin':'*'}});});
83
+ const page=await context.newPage();await page.goto('https://site.example/');
84
+ const result=await page.evaluate(async origins=>{for(const origin of origins)await fetch(origin+'/api');let rejected=false;try{await fetch('https://blocked.example/api');}catch{rejected=true;}return rejected;},['https://allowed.example',...origins]);
85
+ assert.equal(result,true);assert.equal(blocked,0);
86
+ await page.evaluate(origin=>{const frame=document.createElement('iframe');frame.src=origin+'/embed';document.body.append(frame);},origins[0]);await page.frameLocator('iframe').locator('body').waitFor();assert.equal(frames,1);
87
+ assert.equal(await page.evaluate(()=>new Promise((resolve,reject)=>{const worker=new Worker(URL.createObjectURL(new Blob(['postMessage(42)'],{type:'text/javascript'})));worker.onmessage=event=>{worker.terminate();resolve(event.data);};worker.onerror=()=>reject(new Error('Configured blob Worker was blocked'));})),42);
88
+ await context.close();
89
+ }finally{await browser.close();}
90
+ });
@@ -0,0 +1,158 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { chromium, firefox } from 'playwright';
5
+ import { loadConfig } from '../src/config.js';
6
+ import { loadLanguagePacks } from '../src/i18n.js';
7
+ import { configureDataSaver, dataSaverHtml } from '../src/data-saver.js';
8
+
9
+ test('full view discovers critical assets early and never waits for font styles', async () => {
10
+ const config = await loadConfig(process.cwd());
11
+ await loadLanguagePacks(config);
12
+ config.site.dataSaver = {enabled:true,mode:'full',debug:false,detectSlowConnection:false,respectBrowserPreference:false};
13
+ await configureDataSaver(config);
14
+ const html = dataSaverHtml('<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="/full.css"><link rel="stylesheet" href="/edgepress/fonts.css"><script defer src="/control.js"></script></head><body><header>Navigation</header><main><h1>Readable 中文</h1></main><footer>Footer</footer></body></html>',config);
15
+ const settings = JSON.parse(html.match(/id="edgepress-data-saver-config"[^>]*>([\s\S]*?)<\/script>/)[1]);
16
+ assert(settings.fullPreloads.some(hint=>hint.href === '/full.css'));
17
+ assert(settings.fullPreloads.some(hint=>hint.href === '/control.js'));
18
+ assert(!settings.fullPreloads.some(hint=>/fonts/.test(hint.href)));
19
+ const browser = await chromium.launch({headless:true,args:['--disable-extensions']});
20
+ try {
21
+ const context = await browser.newContext();
22
+ let releaseFont;
23
+ const font = new Promise(resolve=>{releaseFont=resolve;});
24
+ let fontStarted;
25
+ const requested = new Promise(resolve=>{fontStarted=resolve;});
26
+ await context.route('**/*',async route=>{
27
+ const path = new URL(route.request().url()).pathname;
28
+ if (path === '/edgepress/fonts.css') { fontStarted(); await font; }
29
+ if (path.endsWith('.css')) return route.fulfill({contentType:'text/css',body:'body{font-family:system-ui}main{padding:1rem}'});
30
+ if (path.endsWith('.js')) return route.fulfill({contentType:'text/javascript',body:'document.documentElement.dataset.controlBound="true";'});
31
+ return route.fulfill({contentType:'text/html; charset=utf-8',body:html});
32
+ });
33
+ const page = await context.newPage();
34
+ await page.goto('https://loading.test/',{waitUntil:'domcontentloaded'});
35
+ await page.waitForFunction(()=>document.documentElement.dataset.edgepressDataReady === 'true');
36
+ assert.equal(await page.locator('main').isVisible(),true);
37
+ assert.equal(await page.getAttribute('html','data-control-bound'),'true');
38
+ await requested;
39
+ assert.equal(await page.locator('[data-data-wait]').isVisible(),false);
40
+ releaseFont();
41
+ await context.close();
42
+ } finally { await browser.close(); }
43
+ });
44
+
45
+ test('unfinished or failed CSS never exposes an unstyled page and retry restores reading', {timeout:30000}, async () => {
46
+ const config = await loadConfig(process.cwd());
47
+ await loadLanguagePacks(config);
48
+ config.site.dataSaver = {enabled:true,mode:'full',debug:false,detectSlowConnection:true,respectBrowserPreference:false,promptAfterMs:150};
49
+ await configureDataSaver(config);
50
+ const html = dataSaverHtml('<html lang="en"><head><meta charset="utf-8"><link rel="stylesheet" href="/full.css"></head><body><header>Navigation</header><main><h1>Readable page</h1></main><footer>Footer</footer></body></html>',config);
51
+ const browser = await chromium.launch({headless:true,args:['--disable-extensions']});
52
+ try {
53
+ const context = await browser.newContext();
54
+ let release;
55
+ const pending = new Promise(resolve=>{release=resolve;});
56
+ let failed = false;
57
+ await context.route('**/*',async route=>{
58
+ const path = new URL(route.request().url()).pathname;
59
+ if (path === '/full.css') {
60
+ await pending;
61
+ if (!failed) { failed = true; return route.abort(); }
62
+ }
63
+ if (path.endsWith('.css')) return route.fulfill({contentType:'text/css',body:'body{margin:0}main{padding:1rem}'});
64
+ return route.fulfill({contentType:'text/html',body:html});
65
+ });
66
+ const page = await context.newPage();
67
+ await page.goto('https://loading.test/',{waitUntil:'domcontentloaded'});
68
+ await page.waitForTimeout(4500);
69
+ assert(await page.evaluate(()=>performance.getEntriesByName('first-contentful-paint').length>0),'The waiting UI must paint before the pending stylesheet completes');
70
+ assert.equal(await page.locator('main').isVisible(),false);
71
+ await page.locator('[data-data-dismiss]').click();
72
+ assert.equal(await page.locator('main').isVisible(),false);
73
+ assert.equal(await page.locator('[data-data-mode=text]').isVisible(),true);
74
+ release();
75
+ await page.locator('[data-data-retry]').waitFor({state:'visible'});
76
+ assert.equal(await page.locator('main').isVisible(),false);
77
+ assert.match(await page.locator('[data-data-progress-label]').innerText(),/could not load/);
78
+ await Promise.all([page.waitForNavigation({waitUntil:'domcontentloaded'}),page.locator('[data-data-retry]').click()]);
79
+ await page.waitForFunction(()=>document.documentElement.dataset.edgepressDataReady==='true');
80
+ assert.equal(await page.locator('main').isVisible(),true);
81
+ await context.close();
82
+ }finally{await browser.close();}
83
+ });
84
+
85
+ test('loading choices and measured progress remain stable after late completion and guard the debug entry', async () => {
86
+ const config = await loadConfig(process.cwd());
87
+ await loadLanguagePacks(config);
88
+ config.site.dataSaver = { enabled:true, debug:false, mode:'auto', detectSlowConnection:true, respectBrowserPreference:true, promptAfterMs:150 };
89
+ await configureDataSaver(config);
90
+ const runtime = await readFile('static/edgepress/data-saver.js');
91
+ const css = await readFile('static/edgepress/data-saver.css');
92
+ const image = await readFile('content/assets/edgepress/favicon/favicon-16.png');
93
+ const source = '<html lang="zh-CN"><head><meta charset="utf-8"><link rel="stylesheet" href="/full.css"></head><body><header><nav><a href="/second/">第二頁</a></nav></header><main><h1>繁體 简体 Article</h1><img src="/slow.png" loading="lazy" width="100" height="100" alt="有意义的图像"></main><footer>頁腳 Footer</footer></body></html>';
94
+ const html = dataSaverHtml(source, config);
95
+ for (const engine of [chromium,firefox]) {
96
+ const browser = await engine.launch({ headless:true, ...(engine === chromium ? {args:['--disable-extensions']} : {}) });
97
+ try {
98
+ for (const colorScheme of ['light','dark']) {
99
+ const context = await browser.newContext({viewport:{width:320,height:700},colorScheme,reducedMotion:'reduce'});
100
+ let current = html;
101
+ const requests = [];
102
+ let finishImage;
103
+ const released = new Promise(resolve => { finishImage = resolve; });
104
+ await context.route('**/*', async route => {
105
+ const path = new URL(route.request().url()).pathname;
106
+ requests.push(path);
107
+ if (path.endsWith('.js')) return route.fulfill({contentType:'text/javascript',body:runtime});
108
+ if (path === '/full.css') return route.fulfill({contentType:'text/css',body:'body{margin:0}main,header,footer{padding:1rem}'});
109
+ if (path.endsWith('.css')) return route.fulfill({contentType:'text/css',body:css});
110
+ if (path === '/slow.png') { await released; return route.fulfill({contentType:'image/png',body:image}); }
111
+ return route.fulfill({contentType:'text/html',body:path.endsWith('404.html') ? '<main><h1>404</h1></main>' : current});
112
+ });
113
+ const page = await context.newPage();
114
+ await page.goto('https://example.test/zh-CN/',{waitUntil:'domcontentloaded'});
115
+ await page.locator('[data-data-offer]').waitFor({state:'visible'});
116
+ assert.equal(await page.locator('main').isVisible(),false);
117
+ assert.match(await page.locator('[data-data-wait]').innerText(),/正在准备页面|首屏图片|页面样式/);
118
+ assert.equal(await page.locator('link[rel=preload][as=image]').count(),0);
119
+ assert.equal(await page.locator('[data-data-progress]').count(),1);
120
+ await page.locator('[data-data-mode=text]').focus();
121
+ finishImage();
122
+ await page.waitForTimeout(350);
123
+ assert.equal(await page.locator('[data-data-offer]').isVisible(),true);
124
+ assert.equal(await page.locator('main').isVisible(),false);
125
+ assert.equal(await page.evaluate(() => document.activeElement.dataset.dataMode),'text');
126
+ assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
127
+ const bounds = await page.locator('[data-data-offer]').boundingBox();
128
+ assert(bounds.x >= 0 && bounds.x + bounds.width <= 320);
129
+ if (process.env.DATA_SAVER_SCREENSHOTS && engine === chromium) await page.screenshot({path:process.env.DATA_SAVER_SCREENSHOTS + '/waiting-' + colorScheme + '.png'});
130
+ await Promise.all([page.waitForNavigation(),page.keyboard.press('Enter')]);
131
+ assert.equal(await page.getAttribute('html','data-edgepress-data-mode'),'text');
132
+ await page.waitForFunction(() => document.documentElement.dataset.edgepressDataReady === 'true');
133
+ await page.waitForFunction(() => document.activeElement.tagName === 'MAIN');
134
+ assert.equal(await page.locator('[data-data-wait]').isVisible(),false);
135
+ assert.match(await page.locator('main').innerText(),/繁體 简体 Article/);
136
+ assert.match(await page.locator('footer').innerText(),/頁腳 Footer/);
137
+ assert.match(await page.locator('body').evaluate(element => getComputedStyle(element).fontFamily),/Microsoft JhengHei/);
138
+ await page.goto('https://example.test/zh-CN/?data-save');
139
+ await page.waitForURL('**/zh-CN/404.html');
140
+ assert.equal(await page.locator('h1').innerText(),'404');
141
+ config.site.dataSaver.debug = true;
142
+ current = dataSaverHtml(source,config);
143
+ requests.length = 0;
144
+ await page.goto('https://example.test/zh-CN/?data-save');
145
+ assert.equal(await page.getAttribute('html','data-edgepress-data-mode'),'text');
146
+ assert(!requests.includes('/full.css'));
147
+ assert(!requests.includes('/slow.png'));
148
+ assert.equal(await page.locator('link[rel=preload]').count(),0);
149
+ await page.goto('https://example.test/zh-CN/?data-save=invalid');
150
+ await page.waitForURL('**/zh-CN/404.html');
151
+ await page.goto('https://example.test/zh-CN/?code=oauth&state=example');
152
+ assert.equal(new URL(page.url()).searchParams.get('code'),'oauth');
153
+ config.site.dataSaver.debug = false;
154
+ await context.close();
155
+ }
156
+ } finally { await browser.close(); }
157
+ }
158
+ });
@@ -0,0 +1,99 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:http';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { chromium } from 'playwright';
6
+ import { loadConfig } from '../src/config.js';
7
+ import { loadLanguagePacks } from '../src/i18n.js';
8
+ import { configureDataSaver, dataSaverHtml } from '../src/data-saver.js';
9
+
10
+ test('100 KB/s visitors can use the waiting-screen switch before parsing finishes without downloading media or engines', { timeout:30000 }, async () => {
11
+ const config = await loadConfig(process.cwd());
12
+ await loadLanguagePacks(config);
13
+ config.site.dataSaver = { enabled:true, debug:false, mode:'auto', detectSlowConnection:true, respectBrowserPreference:false, promptAfterMs:5000 };
14
+ await configureDataSaver(config);
15
+ const runtime = await readFile('static/edgepress/data-saver.js');
16
+ const css = await readFile('static/edgepress/data-saver.css');
17
+ const html = dataSaverHtml('<html lang="zh-CN"><head><meta charset="utf-8"><link rel="stylesheet" href="/full.css"><script defer src="/controls.js"></script><script defer src="/engine.js" data-data-optional></script></head><body><header><nav><a href="/second/">下一頁</a></nav></header><main><h1>可閱讀的文章</h1><p>文字內容與網站導航。</p><button id="action">操作</button><img src="/large.png" alt="圖像描述"></main><footer>獨立頁腳</footer></body></html>',config);
18
+ const requests = [];
19
+ const timers = new Set();
20
+ const server = createServer((request,response) => {
21
+ const path = new URL(request.url,'http://localhost').pathname;
22
+ requests.push(path);
23
+ response.setHeader('Cache-Control','no-store');
24
+ if (path === '/controls.js') return response.writeHead(200,{'Content-Type':'text/javascript'}).end('window.controlsLoaded=true;document.querySelector("#action").addEventListener("click",()=>{window.controlUsed=true;});');
25
+ if (path.endsWith('.js')) return response.writeHead(200,{'Content-Type':'text/javascript'}).end(runtime);
26
+ if (path.endsWith('.css')) return response.writeHead(200,{'Content-Type':'text/css'}).end(path === '/full.css' ? 'body{margin:0}' + '/*' + 'x'.repeat(600000) + '*/' : css);
27
+ if (path.endsWith('.png')) return response.writeHead(200,{'Content-Type':'image/png'}).end(Buffer.alloc(1000000));
28
+ const split = html.indexOf('<header>');
29
+ response.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});
30
+ response.write(html.slice(0,split));
31
+ const timer = setTimeout(() => { timers.delete(timer); response.end(html.slice(split)); },3000);
32
+ timers.add(timer);
33
+ response.on('close',() => { clearTimeout(timer); timers.delete(timer); });
34
+ });
35
+ await new Promise(resolve => server.listen(0,'127.0.0.1',resolve));
36
+ const browser = await chromium.launch({headless:true,args:['--disable-extensions']});
37
+ try {
38
+ const context = await browser.newContext({viewport:{width:390,height:844},colorScheme:'dark'});
39
+ await context.addInitScript(() => Object.defineProperty(navigator,'connection',{get:() => ({saveData:true,effectiveType:'2g'})}));
40
+ const page = await context.newPage();
41
+ const session = await context.newCDPSession(page);
42
+ await session.send('Network.enable');
43
+ await session.send('Network.emulateNetworkConditions',{offline:false,latency:150,downloadThroughput:100*1024,uploadThroughput:100*1024});
44
+ const started = performance.now();
45
+ await page.goto('http://127.0.0.1:' + server.address().port,{waitUntil:'commit'});
46
+ const button = page.locator('[data-data-mode=text]');
47
+ await button.waitFor({state:'visible'});
48
+ const offeredAfterMs = Math.round(performance.now()-started);
49
+ assert(offeredAfterMs < 2500,'Waiting entry must not wait for HTML completion: ' + offeredAfterMs);
50
+ assert.equal(await page.evaluate(() => document.readyState),'loading');
51
+ assert.equal(await page.getAttribute('html','data-edgepress-data-mode'),'full');
52
+ assert.equal(await page.locator('[data-data-progress]').isVisible(),true);
53
+ assert(requests.includes('/full.css'));
54
+ const textRequestStart = requests.length;
55
+ await button.focus();
56
+ await Promise.all([page.waitForNavigation({waitUntil:'domcontentloaded'}),page.keyboard.press('Enter')]);
57
+ await page.waitForFunction(() => document.documentElement.dataset.edgepressDataReady === 'true');
58
+ assert(!requests.slice(textRequestStart).includes('/full.css'));
59
+ const readableAfterMs = Math.round(performance.now()-started);
60
+ assert(readableAfterMs < 6000,'Text reading must not wait ten seconds: ' + readableAfterMs);
61
+ assert.equal(await page.locator('main').isVisible(),true);
62
+ assert.equal(await page.locator('header nav').isVisible(),true);
63
+ assert.equal(await page.locator('footer').innerText(),'獨立頁腳');
64
+ assert.match(await page.locator('main').innerText(),/圖像描述/);
65
+ assert(!requests.some(path => ['/large.png','/engine.js'].includes(path)),requests.join('\n'));
66
+ assert.equal(await page.evaluate(() => document.activeElement.tagName),'MAIN');
67
+ assert.equal(await page.evaluate(() => window.controlsLoaded),true);
68
+ await page.locator('#action').click();
69
+ assert.equal(await page.evaluate(() => window.controlUsed),true);
70
+ console.log(JSON.stringify({bandwidthKBs:100,latencyMs:150,offeredAfterMs,readableAfterMs,requests}));
71
+ await context.close();
72
+ requests.length = 0;
73
+ const pending = await browser.newContext({viewport:{width:390,height:844},colorScheme:'light'});
74
+ const loadingPage = await pending.newPage();
75
+ const loadingSession = await pending.newCDPSession(loadingPage);
76
+ await loadingSession.send('Network.enable');
77
+ await loadingSession.send('Network.emulateNetworkConditions',{offline:false,latency:150,downloadThroughput:100*1024,uploadThroughput:100*1024});
78
+ await loadingPage.goto('http://127.0.0.1:' + server.address().port,{waitUntil:'domcontentloaded'});
79
+ await loadingPage.waitForFunction(() => document.querySelector('[data-data-progress]').max >= 2);
80
+ assert(requests.includes('/full.css'));
81
+ assert.equal(await loadingPage.locator('[data-data-mode=text]').isVisible(),true);
82
+ assert.match(await loadingPage.locator('[data-data-progress-label]').innerText(),/页面样式/);
83
+ assert.equal(await loadingPage.getAttribute('html','data-edgepress-data-mode'),'full');
84
+ const switchStarted = performance.now();
85
+ await Promise.all([loadingPage.waitForNavigation({waitUntil:'domcontentloaded'}),loadingPage.locator('[data-data-mode=text]').click()]);
86
+ await loadingPage.locator('main').waitFor({state:'visible'});
87
+ const switchAfterMs = Math.round(performance.now()-switchStarted);
88
+ assert(switchAfterMs < 6000,'Pending full CSS must not delay the text switch: ' + switchAfterMs);
89
+ assert(!requests.includes('/large.png'));
90
+ assert(!requests.includes('/engine.js'));
91
+ console.log(JSON.stringify({pendingFullStyleSwitchAfterMs:switchAfterMs}));
92
+ await pending.close();
93
+ } finally {
94
+ await browser.close();
95
+ for (const timer of timers) clearTimeout(timer);
96
+ server.closeAllConnections();
97
+ await new Promise(resolve => server.close(resolve));
98
+ }
99
+ });