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,198 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { resolve } from 'node:path';
6
+ import { extname } from 'node:path';
7
+ import { chromium, firefox } from 'playwright';
8
+ import { classifyLink, linkOrigin } from '../static/edgepress/external-links-policy.js';
9
+ import { loadConfig } from '../src/config.js';
10
+ import { loadLanguagePacks } from '../src/i18n.js';
11
+ import { collectAssets, rewriteAssetLinks } from '../src/assets.js';
12
+ import { createExtensions } from '../src/plugin-api.js';
13
+ import { renderLayout } from '../src/theme.js';
14
+
15
+ const settings = { enabled: true, siteUrl: 'https://site.test', trustedOrigins: ['https://trusted.test'] };
16
+
17
+ test('external links use exact origins and reject executable URLs, credentials and invalid trust settings', () => {
18
+ for (const href of ['/article/', '#section', 'https://site.test/article/', 'https://trusted.test/path', 'mailto:hello@example.test', 'tel:+1234567']) assert.equal(classifyLink(href, settings, 'https://site.test/page/').kind, 'native');
19
+ for (const href of ['https://trusted.test.attacker.test', 'https://child.trusted.test', 'http://trusted.test', 'https://trusted.test:8443', '//other.test/path']) assert.equal(classifyLink(href, settings, 'https://site.test/page/').kind, 'external');
20
+ for (const href of ['javascript:alert(1)', 'java\nscript:alert(1)', 'data:text/html,hello', 'https://trusted.test@other.test/', 'file:///tmp/private']) assert.equal(classifyLink(href, settings, 'https://site.test/').kind, 'blocked');
21
+ // A cross-origin <base> or an absolute GitHub file URL must not silently become trusted.
22
+ const outsideBase = 'https://untrusted.test/embedded/';
23
+ assert.equal(classifyLink('https://untrusted.test/file.mp4', settings, outsideBase).kind, 'external');
24
+ assert.equal(classifyLink('/file.mp4', settings, outsideBase).kind, 'external');
25
+ const githubFilm = 'https://github.com/jsw-teams/ai-video-production-workflow/blob/main/projects/guoqing/final.mp4';
26
+ const githubTarget = classifyLink(githubFilm, settings, 'https://site.test/article/');
27
+ assert.equal(githubTarget.kind, 'external');
28
+ assert.equal(githubTarget.url, githubFilm);
29
+ assert.equal(linkOrigin('https://trusted.test/'), 'https://trusted.test');
30
+ for (const origin of ['https://*.test', 'https://trusted.test/path', 'https://user@trusted.test', 'javascript:alert(1)', 'https://trusted.test?next=other', 'https://trusted.test#hash']) assert.throws(() => linkOrigin(origin));
31
+ });
32
+
33
+ test('prompts are opt-in, localized and fingerprint their complete dependency graph', async () => {
34
+ const config = await loadConfig(process.cwd());
35
+ await loadLanguagePacks(config);
36
+ const extensions = await createExtensions(config);
37
+ const page = { title: 'External links', locale: 'en', urlPath: '/links/' };
38
+ assert.ok(!(await renderLayout(config, extensions, page, '<main>Links</main>')).includes('edgepress-external-links-config'));
39
+ config.site.externalLinks = settings;
40
+ const html = await renderLayout(config, extensions, page, '<main>Links</main>');
41
+ assert.ok(html.includes('edgepress-external-links-config'));
42
+ const bundle = await collectAssets(config);
43
+ assert.match(bundle.urlMap['/edgepress/external-links.js'], /\.[a-f0-9]{16}\.js$/);
44
+ const runtime = bundle.assets.find(asset => '/' + asset.path === bundle.urlMap['/edgepress/external-links.js']).content.toString();
45
+ assert.match(runtime, /function classifyLink\(/);
46
+ assert.doesNotMatch(runtime, /from["']\.\/external-links-policy/);
47
+ assert.match(rewriteAssetLinks(html, bundle.urlMap), /external-links\.[a-f0-9]{16}\.css/);
48
+ });
49
+
50
+ test('site settings normalize exact origins and reject invalid prompt configuration', async () => {
51
+ const root = await mkdtemp(resolve(tmpdir(), 'edgepress-link-settings-'));
52
+ try {
53
+ await writeFile(resolve(root, 'edgepress.config.mjs'), 'export default { i18n: { defaultLocale: "en", languagePacks: [] } };');
54
+ const consent = 'plugins:\n consent:\n enabled: false\n proposedDate: 2026-10-09\n';
55
+ await writeFile(resolve(root, 'config.yml'), consent + 'site:\n url: https://site.test\n externalLinks:\n enabled: true\n trustedOrigins: [https://trusted.test/, https://trusted.test]\n');
56
+ assert.deepEqual((await loadConfig(root)).site.externalLinks.trustedOrigins, ['https://trusted.test']);
57
+ for (const configuration of ['enabled: yes', 'trustedOrigins: [https://trusted.test/path]', 'trustedOrigins: [https://*.test]', 'trustedOrigins: [12]', 'enabled: true\n redirect: https://other.test']) {
58
+ await writeFile(resolve(root, 'config.yml'), consent + 'site:\n url: https://site.test\n externalLinks:\n ' + configuration + '\n');
59
+ await assert.rejects(loadConfig(root), /externalLinks|Trusted sites/);
60
+ }
61
+ await writeFile(resolve(root, 'config.yml'), consent + 'site:\n externalLinks:\n enabled: true\n');
62
+ await assert.rejects(loadConfig(root), /site.url/);
63
+ } finally { await rm(root, { recursive: true, force: true }); }
64
+ });
65
+
66
+ for (const [name, engine] of [['Chromium', chromium], ['Firefox', firefox]]) {
67
+ test(name + ' preserves native links with localized keyboard-accessible theme-adaptive prompts', { timeout: 90000 }, async () => {
68
+ const config = await loadConfig(process.cwd());
69
+ config.site.url = settings.siteUrl;
70
+ config.site.externalLinks = { enabled: true, trustedOrigins: settings.trustedOrigins };
71
+ await loadLanguagePacks(config);
72
+ const extensions = await createExtensions(config);
73
+ const bundle = await collectAssets(config);
74
+ const assets = new Map(bundle.assets.map(asset => ['/' + asset.path, asset]));
75
+ const browser = await engine.launch({ headless: true, args: name === 'Chromium' ? ['--disable-extensions'] : [] });
76
+ try {
77
+ for (const [locale,title,back,proceed] of [['en', "You're leaving this site", 'Return', 'Continue to website'], ['zh-CN', '即将离开本站', '返回', '继续前往']]) {
78
+ const context = await browser.newContext({ viewport: { width: 320, height: 720 }, colorScheme: 'dark', reducedMotion: 'reduce' });
79
+ const page = await context.newPage();
80
+ const requests = [];
81
+ const errors = [];
82
+ page.on('pageerror', error => errors.push(error.message));
83
+ const content = '<main><a id="external" href="https://other.test/read?q=%3Cscript%3E#chapter">Other site</a><a id="github-film" href="https://github.com/jsw-teams/ai-video-production-workflow/blob/main/projects/guoqing/final.mp4">GitHub film</a><a id="long-url" href="https://other.test/path/' + 'a'.repeat(180) + '?x=1#part">Long URL</a><a id="root-url" href="https://other.test/">Site root</a><a id="trusted" href="https://trusted.test/path">Trusted site</a><a id="internal" href="#section">Inside</a><a id="new-tab" href="https://other.test/new" target="_blank">New tab</a><a id="download" href="https://other.test/file" download>Download</a><a id="auth" href="https://other.test/auth" data-external-link-skip>Sign in</a><section id="section">Section</section></main>';
84
+ const html = rewriteAssetLinks(await renderLayout(config, extensions, { title: 'Links', locale, urlPath: '/links/' }, content.replace('<main>', '<div>').replace('</main>', '</div>')), bundle.urlMap);
85
+ await context.route('**/*', async route => {
86
+ const url = new URL(route.request().url());
87
+ if (url.origin !== settings.siteUrl) { requests.push(url.href); return route.fulfill({ contentType: 'text/html', body: '<title>Destination</title><p>Arrived</p>' }); }
88
+ if (url.pathname === '/links/') return route.fulfill({ contentType: 'text/html', body: html });
89
+ const asset = assets.get(url.pathname);
90
+ if (!asset) return route.fulfill({ status: 404, body: '' });
91
+ return route.fulfill({ contentType: ({ '.js': 'text/javascript', '.css': 'text/css', '.woff2': 'font/woff2' })[extname(url.pathname)] || 'application/octet-stream', body: asset.content || await readFile(asset.source) });
92
+ });
93
+ await page.goto(settings.siteUrl + '/links/');
94
+ await page.locator('.privacy-reject').click();
95
+ await page.addStyleTag({ content: 'body{background:#18252f;color:#e2e8f0} :root{--surface:#18252f;--paper:#18252f;--ink:#e2e8f0;--accent:#8fc7ff}' });
96
+ const anchor = page.locator('#external');
97
+ await anchor.focus(); await anchor.press('Enter');
98
+ const dialog = page.getByRole('dialog');
99
+ await dialog.waitFor();
100
+ assert.equal(await dialog.getByRole('heading').textContent(), title);
101
+ assert.equal(await dialog.locator('.edgepress-external-url').textContent(), 'https://other.test/read?q=%3Cscript%3E#chapter');
102
+ assert.equal(await dialog.locator('.edgepress-external-url').count(), 1);
103
+ assert.equal(await dialog.locator('.edgepress-external-domain, .edgepress-external-origin').count(), 0);
104
+ assert.equal(await dialog.locator('.edgepress-external-url').evaluate(element => element.scrollWidth <= element.clientWidth), true);
105
+ assert.equal(await dialog.getByRole('button', { name: back, exact: true }).evaluate(element => element === document.activeElement), true);
106
+ assert.deepEqual(requests, []);
107
+ assert.equal(await dialog.evaluate(element => getComputedStyle(element).backgroundColor), 'rgb(24, 37, 47)');
108
+ assert.equal(await dialog.evaluate(element => element.getBoundingClientRect().right <= innerWidth), true);
109
+ assert.equal(await dialog.locator('script').count(), 0);
110
+ assert.equal(await anchor.getAttribute('href'), 'https://other.test/read?q=%3Cscript%3E#chapter');
111
+ await page.keyboard.press('Tab');
112
+ assert.equal(await dialog.getByRole('button', { name: proceed, exact: true }).evaluate(element => element === document.activeElement), true);
113
+ await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
114
+ assert.equal(await dialog.getByRole('button', { name: proceed, exact: true }).evaluate(element => element === document.activeElement), true);
115
+ await page.keyboard.press('Escape');
116
+ assert.equal(await anchor.evaluate(element => element === document.activeElement), true);
117
+ assert.equal(await page.locator('main').evaluate(element => element.inert), false);
118
+ await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })));
119
+ await anchor.click();
120
+ await dialog.waitFor();
121
+ await page.evaluate(() => document.body.append(document.createElement('aside')));
122
+ assert.equal(await page.locator('body > aside').evaluate(element => element.inert), true);
123
+ await page.keyboard.press('Escape');
124
+ assert.equal(await page.locator('body > aside').evaluate(element => element.inert), false);
125
+ await page.locator('#github-film').click();
126
+ await dialog.waitFor();
127
+ assert.equal(await dialog.locator('.edgepress-external-url').textContent(), 'https://github.com/jsw-teams/ai-video-production-workflow/blob/main/projects/guoqing/final.mp4');
128
+ assert.deepEqual(requests, [], 'The video host must not be contacted before visitor confirmation');
129
+ await page.keyboard.press('Escape');
130
+ await page.locator('#long-url').click();
131
+ await dialog.waitFor();
132
+ assert.equal(await dialog.locator('.edgepress-external-url').textContent(), 'https://other.test/path/' + 'a'.repeat(180) + '?x=1#part');
133
+ assert.equal(await dialog.locator('.edgepress-external-url').evaluate(element => element.scrollWidth <= element.clientWidth), true);
134
+ await page.keyboard.press('Escape');
135
+ await page.locator('#root-url').click();
136
+ await dialog.waitFor();
137
+ assert.equal(await dialog.locator('.edgepress-external-url').textContent(), 'https://other.test/');
138
+ assert.equal(await dialog.locator('.edgepress-external-url').count(), 1);
139
+ await page.keyboard.press('Escape');
140
+ await page.locator('#internal').click();
141
+ assert.ok(page.url().endsWith('#section'));
142
+ await anchor.click();
143
+ await anchor.evaluate(element => element.href = 'https://changed.test/');
144
+ await dialog.getByRole('button', { name: proceed, exact: true }).click();
145
+ assert.deepEqual(requests, []);
146
+ await anchor.evaluate(element => element.href = 'https://other.test/read');
147
+ await anchor.click();
148
+ await dialog.getByRole('button', { name: proceed, exact: true }).click();
149
+ await page.getByText('Arrived', { exact: true }).waitFor();
150
+ assert.deepEqual(requests, ['https://other.test/read']);
151
+ await page.goto(settings.siteUrl + '/links/');
152
+ await page.locator('#new-tab').click();
153
+ const popupPromise = page.waitForEvent('popup');
154
+ await page.getByRole('dialog').getByRole('button', { name: proceed, exact: true }).click();
155
+ const popup = await popupPromise;
156
+ await popup.waitForLoadState();
157
+ assert.equal(await popup.evaluate(() => window.opener), null);
158
+ await popup.close();
159
+ for (const [selector, properties] of [['#external', { ctrlKey: true }], ['#external', { metaKey: true }], ['#external', { shiftKey: true }], ['#external', { button: 1 }], ['#download', {}], ['#auth', {}]]) {
160
+ const native = await page.locator(selector).evaluate((element, properties) => {
161
+ let intercepted = false;
162
+ window.addEventListener('click', event => { intercepted = event.defaultPrevented; event.preventDefault(); }, { once: true });
163
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...properties }));
164
+ return !intercepted && document.querySelector('.edgepress-external-overlay').hidden;
165
+ }, properties);
166
+ assert.equal(native, true);
167
+ }
168
+ for (const properties of [{}, { ctrlKey: true }, { metaKey: true }, { button: 1 }]) {
169
+ assert.equal(await page.evaluate(properties => {
170
+ const unsafe = document.createElement('a');
171
+ unsafe.href = 'javascript:window.unsafeNavigation=true';
172
+ document.body.append(unsafe);
173
+ let intercepted = false;
174
+ window.addEventListener('click', event => { intercepted = event.defaultPrevented; event.preventDefault(); }, { once: true });
175
+ unsafe.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...properties }));
176
+ unsafe.remove();
177
+ return intercepted;
178
+ }, properties), true);
179
+ }
180
+ await page.emulateMedia({ colorScheme: 'light', forcedColors: 'active' });
181
+ await page.locator('#external').click();
182
+ assert.equal(await page.getByRole('dialog').getByRole('button', { name: back, exact: true }).evaluate(element => getComputedStyle(element).appearance), 'none');
183
+ await page.keyboard.press('Escape');
184
+ await page.locator('#trusted').click();
185
+ await page.getByText('Arrived', { exact: true }).waitFor();
186
+ const noScript = await browser.newContext({ javaScriptEnabled: false });
187
+ await noScript.route('**/*', route => route.fulfill({ contentType: 'text/html', body: new URL(route.request().url()).hostname === 'other.test' ? '<p>Without scripts</p>' : html }));
188
+ const plain = await noScript.newPage();
189
+ await plain.goto(settings.siteUrl + '/links/');
190
+ await plain.locator('#external').click();
191
+ await plain.getByText('Without scripts', { exact: true }).waitFor();
192
+ await noScript.close();
193
+ assert.deepEqual(errors, []);
194
+ await context.close();
195
+ }
196
+ } finally { await browser.close(); }
197
+ });
198
+ }
Binary file
@@ -0,0 +1,99 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFile, mkdtemp, rm} from 'node:fs/promises';
4
+ import {createHash} from 'node:crypto';
5
+ import {tmpdir} from 'node:os';
6
+ import {resolve, extname} from 'node:path';
7
+ import {fileURLToPath} from 'node:url';
8
+ import {chromium} from 'playwright';
9
+ import {loadConfig} from '../src/config.js';
10
+ import {loadLanguagePacks} from '../src/i18n.js';
11
+ import {collectAssets, writeAssets, rewriteAssetLinks} from '../src/assets.js';
12
+ import {renderLayout} from '../src/theme.js';
13
+
14
+ const root = fileURLToPath(new URL('../', import.meta.url));
15
+ test('mutable fonts and font metadata never receive a directory-wide immutable policy', async () => {
16
+ const temporary = await mkdtemp(resolve(tmpdir(), 'edgepress-font-cache-'));
17
+ try {
18
+ await writeAssets({existingHeaders: '', assets: ['fonts/regular.0123456789abcdef.woff2', 'fonts/bold.abcdef0123456789.woff2', 'fonts/custom.woff2', 'fonts/provenance.json'].map(path => ({path, content: Buffer.from('fixture')}))}, temporary);
19
+ const headers = await readFile(resolve(temporary, '_headers'), 'utf8');
20
+ assert.doesNotMatch(headers, /\/fonts\/\*/);
21
+ assert.doesNotMatch(headers, /\/(?:fonts\/custom\.woff2|fonts\/provenance\.json)\n\s+Cache-Control:/);
22
+ assert.match(headers, /\/fonts\/regular\.0123456789abcdef\.woff2\n\s+Cache-Control: public, max-age=31536000, immutable/);
23
+ } finally { await rm(temporary, {recursive: true, force: true}); }
24
+ });
25
+ test('bundled fonts retain provenance, integrity and immutable CSS dependency graphs', async () => {
26
+ const config = await loadConfig(root), bundle = await collectAssets(config);
27
+ const faces = bundle.assets.find(asset => '/' + asset.path === bundle.urlMap['/edgepress/fonts/faces.css']);
28
+ const css = bundle.assets.find(asset => '/' + asset.path === bundle.urlMap['/edgepress/fonts.css']).content.toString();
29
+ assert.match(css, /fonts\/faces\.[a-f0-9]{16}\.css/);
30
+ assert.match(faces.content.toString(), /unicode-range:/);
31
+ assert.doesNotMatch(css + faces.content.toString(), /https?:|local\(/);
32
+ const provenance = JSON.parse(await readFile(resolve(root, 'static/edgepress/fonts/provenance.json')));
33
+ for (const family of provenance) {
34
+ assert.equal(family.metadata.license.type, 'OFL-1.1');
35
+ assert.match(await readFile(resolve(root, 'static/edgepress/fonts', family.license), 'utf8'), /SIL Open Font License/);
36
+ for (const asset of family.files) {
37
+ const bytes = await readFile(resolve(root, 'static/edgepress/fonts', asset.file));
38
+ assert.equal(createHash('sha256').update(bytes).digest('hex'), asset.sha256);
39
+ assert.ok(asset.file.includes('.' + asset.sha256.slice(0, 16) + '.woff2'));
40
+ assert.equal(bytes.subarray(0, 4).toString(), 'wOF2');
41
+ }
42
+ }
43
+ const temporary = await mkdtemp(resolve(tmpdir(), 'edgepress-font-headers-'));
44
+ try {
45
+ await writeAssets(bundle, temporary);
46
+ const headers = await readFile(resolve(temporary, '_headers'), 'utf8');
47
+ assert.ok(headers.includes('/edgepress/fonts/*.woff2\n Cache-Control: public, max-age=31536000, immutable'));
48
+ assert.ok(headers.split('\n').filter(line => line && !/^\s/.test(line)).length <= 100);
49
+ } finally { await rm(temporary, {recursive: true, force: true}); }
50
+ });
51
+
52
+ test('English, simplified and traditional text use downloaded fonts without OS glyphs', async () => {
53
+ const config = await loadConfig(root); await loadLanguagePacks(config);
54
+ config.i18n.locales = ['en']; // Isolate Latin loading from the Chinese language menu label.
55
+ config.site.title = config.site.header.brandLabel = 'Font fixture';
56
+ config.site.footer = 'Local fonts';
57
+ config.site.navigation = []; config.site.footerNavigation = [];
58
+ const bundle = await collectAssets(config), assets = new Map(bundle.assets.map(asset => ['/' + asset.path, asset]));
59
+ const body = '<div class="post-content"><p id="sample">EdgePress café Ελληνικά Привет</p><p id="serif" style="font-family:var(--edgepress-font-serif)">Readable stories</p><p id="notice" class="image-lightbox-status">Unavailable</p></div>';
60
+ const html = rewriteAssetLinks(await renderLayout(config, {filter: async (_name, value) => value}, {title: 'Font coverage', locale: 'en'}, body), bundle.urlMap);
61
+ const browser = await chromium.launch({headless: true, ...(process.platform === 'win32' ? {channel: 'msedge'} : {})});
62
+ try {
63
+ const context = await browser.newContext(), page = await context.newPage(), requested = [], errors = [];
64
+ page.on('pageerror', error => errors.push(error.message));
65
+ await context.route('**/*', async route => {
66
+ const url = new URL(route.request().url()); assert.equal(url.origin, 'https://fonts.example');
67
+ if (url.pathname === '/') return route.fulfill({contentType: 'text/html', body: html});
68
+ const asset = assets.get(url.pathname);
69
+ if (!asset) return route.fulfill({status: 404, body: ''});
70
+ if (url.pathname.endsWith('.woff2')) requested.push(url.pathname);
71
+ return route.fulfill({contentType: {'.css': 'text/css', '.js': 'text/javascript', '.woff2': 'font/woff2', '.svg': 'image/svg+xml'}[extname(url.pathname)] || 'application/octet-stream', body: asset.content || await readFile(asset.source)});
72
+ });
73
+ await page.goto('https://fonts.example/');
74
+ await page.evaluate(() => document.fonts.ready);
75
+ const client = await context.newCDPSession(page); await client.send('DOM.enable'); await client.send('CSS.enable');
76
+ async function renderedFonts(selector) {
77
+ const {root: document} = await client.send('DOM.getDocument');
78
+ const {nodeId} = await client.send('DOM.querySelector', {nodeId: document.nodeId, selector});
79
+ const {fonts} = await client.send('CSS.getPlatformFontsForNode', {nodeId});
80
+ assert.ok(fonts.length, 'No rendered glyphs: ' + selector);
81
+ assert.ok(fonts.every(font => font.isCustomFont), 'Used OS font: ' + JSON.stringify(fonts));
82
+ return fonts;
83
+ }
84
+ await renderedFonts('#sample'); await renderedFonts('#serif'); await renderedFonts('#notice');
85
+ assert.ok(!requested.some(path => /noto-sans-(?:sc|tc)-/.test(path)), 'English page downloaded CJK fonts');
86
+ for (const [lang, text, family] of [['zh-CN', '写文建站海边的下午你好世界龘', 'Noto Sans SC'], ['zh-TW', '寫文建站多媒體檢視器讓分享更簡單臺灣', 'Noto Sans TC']]) {
87
+ await page.evaluate(({lang, text}) => {
88
+ document.documentElement.lang = lang;
89
+ document.querySelector('#sample').textContent = text;
90
+ document.querySelector('#serif').textContent = text;
91
+ document.querySelector('#notice').textContent = text;
92
+ document.querySelector('#sample').style.fontFamily = 'var(--edgepress-font-sans)';
93
+ }, {lang, text});
94
+ await page.evaluate(() => document.fonts.ready);
95
+ for (const selector of ['#sample', '#serif', '#notice']) assert.ok((await renderedFonts(selector)).some(font => font.familyName.startsWith(family)));
96
+ }
97
+ assert.deepEqual(errors, []);
98
+ } finally { await browser.close(); }
99
+ });
@@ -0,0 +1,15 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {mkdtemp,writeFile,rm} from 'node:fs/promises';
4
+ import {tmpdir} from 'node:os';
5
+ import {join} from 'node:path';
6
+ import {collectImageDimensions,reserveImageDimensions} from '../src/image-dimensions.js';
7
+ test('local content images reserve their real aspect ratio without fetching other origins or replacing explicit dimensions',async()=>{
8
+ const root=await mkdtemp(join(tmpdir(),'image-dimensions-'));try{const source=join(root,'portrait.svg');await writeFile(source,'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="240"></svg>');const dimensions=await collectImageDimensions([{path:'images/p.svg',source}]);
9
+ const rewrite=html=>reserveImageDimensions(html,dimensions,'chapter/index.html','https://site.example');assert.match(rewrite('<img src="../images/p.svg" alt="Portrait">'),/width="120" height="240"/);assert.match(rewrite('<img src="/images/p.svg" width="60">'),/height="120"/);assert.match(rewrite('<img src="/images/p.svg" height="80">'),/width="40"/);const explicit='<img src="/images/p.svg" width="60" height="60">';assert.equal(rewrite(explicit),explicit);assert.equal(rewrite('<img src="https://other.example/images/p.svg">'),'<img src="https://other.example/images/p.svg">');assert.equal(rewrite('<img src="/%ZZ.svg">'),'<img src="/%ZZ.svg">');
10
+ assert.match(rewrite('<img src="/images/p.svg" loading="lazy">'),/width="120" height="240"/);
11
+ const styled=rewrite('<img src="/images/p.svg" loading="lazy" style="border:0">');assert.match(styled,/style="border:0"/);assert.equal(rewrite(styled),styled);
12
+ const authored='<img src="/images/p.svg" loading="lazy" width="60" height="60" style="aspect-ratio:1 / 1">';assert.equal(rewrite(authored),authored);
13
+ const inert='<img data-src="/images/p.svg">';assert.equal(rewrite(inert),inert);assert.match(rewrite('<img data-width="60" src="/images/p.svg">'),/width="120" height="240"/);
14
+ }finally{await rm(root,{recursive:true,force:true});}
15
+ });
@@ -0,0 +1,36 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFile} from 'node:fs/promises';
4
+ import {fileURLToPath} from 'node:url';
5
+ import {extname} from 'node:path';
6
+ import {chromium} from 'playwright';
7
+ import {loadConfig} from '../src/config.js';
8
+ import {loadLanguagePacks} from '../src/i18n.js';
9
+ import {collectAssets,rewriteAssetLinks} from '../src/assets.js';
10
+ import {renderLayout} from '../src/theme.js';
11
+ import {renderBlocks} from '../src/page-blocks.js';
12
+ import {renderMarkdown} from '../src/markdown.js';
13
+ import {contentPermissions} from '../src/consent-csp.js';
14
+
15
+ test('content originals are validated and participate in CSP without permitting inert embeds',async()=>{
16
+ const config=await loadConfig(fileURLToPath(new URL('../',import.meta.url))),context={config,locale:'en'};
17
+ const html=await renderBlocks([{columns:1,cells:[[{type:'image',src:'https://preview.example/p.png',originalSrc:'https://original.example/p.png',alt:'Picture'}]]}],context);assert.match(html,/data-original="https:\/\/original.example\/p.png"/);
18
+ await assert.rejects(renderBlocks([{columns:1,cells:[[{type:'image',src:'/image.png',originalSrc:'javascript:alert(1)',alt:'Picture'}]]}],context));
19
+ const markdown=await renderMarkdown('<img src="/p.png" data-original="javascript:alert(1)" alt="Picture">');assert.doesNotMatch(markdown,/javascript|data-original/);
20
+ const permissions=contentPermissions([{body:html+'<template><img data-original="https://blocked.example/p.png"></template>'}]);assert.deepEqual(permissions['img-src'],['https://preview.example','https://original.example']);
21
+ });
22
+
23
+ test('article and Pages images open the shared viewer; originals wait for clicks and blocked services remain untouched',async()=>{
24
+ const config=await loadConfig(fileURLToPath(new URL('../',import.meta.url)));await loadLanguagePacks(config);const bundle=await collectAssets(config),assets=new Map(bundle.assets.map(item=>['/'+item.path,item]));
25
+ assert.match(bundle.urlMap['/edgepress/image-viewer-lib.js'],/\.[a-f0-9]{16}\.js$/);assert.doesNotMatch(assets.get(bundle.urlMap['/edgepress/image-viewer-lib.js']).content.toString(),/hls\.js|cdn\.plyr|cloudflarestream/);
26
+ const body='<article><div class="post-content"><p>Keep the article visible.</p><img src="/preview.svg" data-original="https://pictures.example/original.svg" alt="Article picture"><a href="/other-page/"><img src="/preview.svg" alt="Navigation picture"></a><div data-edgepress-oembed="blocked"><img src="/preview.svg" data-original="https://pictures.example/blocked.svg" alt="Blocked picture"></div></div><figure class="image-block"><img src="/preview.svg" alt="Pages picture"></figure><article class="media-text-block"><figure><img src="/preview.svg" alt="Text and picture"></figure></article></article>';
27
+ const html=rewriteAssetLinks(await renderLayout(config,{filter:async(_name,value)=>value},{title:'Images',locale:'zh-CN',urlPath:'/images/'},body),bundle.urlMap),svg='<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="700"><rect width="1000" height="700" fill="#388b65"/></svg>';
28
+ const browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{})});
29
+ try{for(const width of [320,1280]){const context=await browser.newContext({viewport:{width,height:844}}),page=await context.newPage(),errors=[];let originals=0;
30
+ page.on('pageerror',error=>errors.push(error.message));await context.route('**/*',async route=>{const url=new URL(route.request().url());if(url.hostname==='pictures.example'){assert.notEqual(url.pathname,'/blocked.svg');originals++;return route.fulfill({contentType:'image/svg+xml',body:svg});}
31
+ if(url.pathname==='/images/')return route.fulfill({contentType:'text/html',body:html});if(url.pathname==='/preview.svg')return route.fulfill({contentType:'image/svg+xml',body:svg});const asset=assets.get(url.pathname);if(!asset)return route.fulfill({status:404,body:''});const types={'.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.webp':'image/webp'};return route.fulfill({contentType:types[extname(url.pathname)]||'text/plain',body:asset.content||await readFile(asset.source)});
32
+ });await page.goto('https://site.example/images/');await page.getByRole('button',{name:'放大图片: Article picture'}).waitFor();assert.equal(originals,0);assert.equal(await page.locator('[alt="Navigation picture"]').getAttribute('role'),null);assert.equal(await page.locator('[alt="Blocked picture"]').getAttribute('role'),null);
33
+ await page.getByRole('button',{name:'放大图片: Article picture'}).click();await page.locator('.image-lightbox[open]').waitFor();await page.waitForFunction(()=>document.querySelector('.image-lightbox-photo').naturalWidth>0);assert.equal(originals,1);await page.getByRole('button',{name:'关闭图片',exact:true}).click();await page.locator('.image-lightbox').waitFor({state:'detached'});assert.equal(await page.locator('[alt="Article picture"]').evaluate(image=>image===document.activeElement),true);
34
+ await page.getByRole('button',{name:'放大图片: Text and picture'}).click();await page.locator('.image-lightbox[open]').waitFor();await page.keyboard.press('Escape');await page.locator('.image-lightbox').waitFor({state:'detached'});await page.getByRole('button',{name:'放大图片: Pages picture'}).click();await page.locator('.image-lightbox[open]').waitFor();await page.keyboard.press('Escape');await page.locator('.image-lightbox').waitFor({state:'detached'});assert.equal(originals,1);assert.deepEqual(errors,[]);assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await context.close();
35
+ }}finally{await browser.close();}
36
+ });
@@ -0,0 +1,66 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {mkdtemp, mkdir, readFile, writeFile, rm, readdir} from 'node:fs/promises';
4
+ import {tmpdir} from 'node:os';
5
+ import {resolve} from 'node:path';
6
+ import {pathToFileURL} from 'node:url';
7
+ import {getLanguageService, TextDocument} from 'vscode-json-languageservice';
8
+ import {loadConfig} from '../src/config.js';
9
+ import {parseDocumentSource, readDocuments} from '../src/content.js';
10
+
11
+ const page = {title:'JSON page', lang:'en', homepage:true, blocks:[{columns:2,cells:[[{type:'heading',level:2,text:'Left'},{type:'text',text:'Text with "quotes"\nand 中文'}],[{type:'text',text:'Right'}]]}]};
12
+
13
+ test('shipped Pages resolve their local schema and validate through the VS Code service', async () => {
14
+ const service=getLanguageService({schemaRequestService:uri=>{
15
+ const url=new URL(uri);assert.equal(url.protocol,'file:');return readFile(url,'utf8');
16
+ },workspaceContext:{resolveRelativePath:(relative,resource)=>new URL(relative,resource).href}});
17
+ const files=await readdir('content/pages',{recursive:true});
18
+ assert.ok(files.filter(file=>file.endsWith('.json')).length);
19
+ for(const file of files.filter(file=>file.endsWith('.json'))){
20
+ const absolute=resolve('content/pages',file),text=await readFile(absolute,'utf8');
21
+ assert.ok(JSON.parse(text).$schema,file+' must link its schema');
22
+ const document=TextDocument.create(pathToFileURL(absolute).href,'json',1,text);
23
+ assert.deepEqual(await service.doValidation(document,service.parseJSONDocument(document)),[],file);
24
+ }
25
+ });
26
+
27
+ test('JSON and legacy YAML Pages use the same locale, routes, blocks and validation', async () => {
28
+ const root = await mkdtemp(resolve(tmpdir(), 'edgepress-json-content-'));
29
+ try {
30
+ const config = await loadConfig(process.cwd());
31
+ config.resolvedPaths.content = root;
32
+ await mkdir(resolve(root, 'pages/home'), {recursive: true});
33
+ const file = resolve(root, 'pages/home/en.json');
34
+ await writeFile(file, JSON.stringify(page));
35
+ const [document] = await readDocuments(config);
36
+ assert.equal(document.path, '');assert.equal(document.locale, 'en');
37
+ assert.deepEqual(document.blocks, page.blocks);assert.equal(document.markdown, '');
38
+ await writeFile(resolve(root, 'pages/home/en.md'), '---\nblocks: []\n---\n');
39
+ await assert.rejects(readDocuments(config), /Duplicate language/);
40
+ await rm(resolve(root, 'pages/home/en.md'));
41
+ await writeFile(file, JSON.stringify({...page, lang:'zh-CN'}));
42
+ await assert.rejects(readDocuments(config), /lang must match/);
43
+ await writeFile(file, '{broken');
44
+ await assert.rejects(readDocuments(config), /Invalid page JSON.*en\.json/);
45
+ assert.throws(() => parseDocumentSource('[]', file), /must be an object/);
46
+ } finally {await rm(root,{recursive:true,force:true});}
47
+ });
48
+
49
+ test('VS Code JSON service validates rows, cells and elements and provides their nested outline and folding', async () => {
50
+ const schema = JSON.parse(await readFile('schemas/pages.schema.json', 'utf8'));
51
+ const service = getLanguageService({});
52
+ const text = JSON.stringify(page, null, 2);
53
+ const document = TextDocument.create('file:///page.json', 'json', 1, text);
54
+ const parsed = service.parseJSONDocument(document);
55
+ assert.deepEqual(await service.doValidation(document, parsed, undefined, schema), []);
56
+ const outline = service.findDocumentSymbols2(document, parsed);
57
+ const blocks = outline.find(symbol => symbol.name === 'blocks');
58
+ const cells = blocks.children[0].children.find(symbol => symbol.name === 'cells');
59
+ assert.equal(cells.children.length, 2);
60
+ assert.equal(cells.children[0].children.length, 2);
61
+ const folds = service.getFoldingRanges(document);
62
+ for (const symbol of [blocks, cells, cells.children[0], cells.children[0].children[0]]) assert.ok(folds.some(range => range.startLine === symbol.range.start.line && range.endLine >= symbol.range.end.line - 1), symbol.name+' folds independently');
63
+ const broken = structuredClone(page);broken.blocks[0].cells.pop();
64
+ const invalid = TextDocument.create('file:///invalid.json', 'json', 1, JSON.stringify(broken, null, 2));
65
+ assert.ok((await service.doValidation(invalid, service.parseJSONDocument(invalid), undefined, schema)).length);
66
+ });
@@ -0,0 +1,51 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { loadConfig } from '../src/config.js';
4
+ import { loadLanguagePacks } from '../src/i18n.js';
5
+ import { renderBlocks } from '../src/page-blocks.js';
6
+ import { renderLayout } from '../src/theme.js';
7
+ import { collectAssets } from '../src/assets.js';
8
+
9
+ const configForTests = async () => {
10
+ const config = await loadConfig(process.cwd());
11
+ await loadLanguagePacks(config);
12
+ return config;
13
+ };
14
+ const rowsFor = items => [{ columns: 1, cells: [[{ type: 'link-directory', title: 'Independent sites', items }]] }];
15
+ const contextFor = config => ({ config, locale: 'en', isHomepage: false, site: { posts: [], pages: [] } });
16
+
17
+ test('link-directory renders data-driven rows and safely escapes URLs and descriptions', async () => {
18
+ const config = await configForTests();
19
+ const items = [
20
+ { title: 'Journal & notes', text: 'Stories with <code> examples', url: 'https://notes.example.org/posts/?ref=a&b=c' },
21
+ { title: 'Local guide', text: 'How to submit', url: '/contact/' }
22
+ ];
23
+ const html = await renderBlocks(rowsFor(items), contextFor(config));
24
+ assert.match(html, /class="link-directory-section"/);
25
+ assert.match(html, /<ul class="link-directory-items" role="list">/);
26
+ assert.doesNotMatch(html, /link-directory-number|<ol/);
27
+ assert.equal((html.match(/class="link-directory-entry"/g) || []).length, items.length);
28
+ assert.match(html, /notes\.example\.org/);
29
+ assert.match(html, /Journal &amp; notes/);
30
+ assert.match(html, /&lt;code&gt;/);
31
+ assert.match(html, /ref=a&amp;b=c/);
32
+ assert.match(html, /href="\/contact\/"/);
33
+ assert.doesNotMatch(html, /<img|feature-card|<script/i);
34
+ for (const bad of ['javascript:alert(1)', '//host.example/evil', 'https://name:password@host.example']) {
35
+ await assert.rejects(renderBlocks(rowsFor([{ title: 'Bad', url: bad }]), contextFor(config)), /safe site path|HTTP/);
36
+ }
37
+ });
38
+
39
+ test('link-directory uses the shared conditional stylesheet, including hash rewriting', async () => {
40
+ const config = await configForTests();
41
+ const rows = rowsFor([{ title: 'Example', url: 'https://example.org/' }]);
42
+ const content = await renderBlocks(rows, contextFor(config));
43
+ const extensions = { filter: async (_name, html) => html };
44
+ const html = await renderLayout(config, extensions, { title: 'Links', locale: 'en', urlPath: '/links/' }, '<main>' + content + '</main>');
45
+ assert.match(html, /href="\/edgepress\/link-directory\.css"/);
46
+ const empty = await renderLayout(config, extensions, { title: 'Empty', locale: 'en', urlPath: '/empty/' }, '<main><p>No directory</p></main>');
47
+ assert.doesNotMatch(empty, /link-directory\.css/);
48
+ const bundle = await collectAssets(config);
49
+ const stylesheet = bundle.urlMap['/edgepress/link-directory.css'];
50
+ assert.match(stylesheet, /\/edgepress\/link-directory\.[a-f0-9]{16}\.css/);
51
+ });
@@ -0,0 +1,34 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {renderLocalTool} from '../src/local-tool.js';
4
+ import {renderBlocks} from '../src/page-blocks.js';
5
+ import {rewriteAssetLinks} from '../src/assets.js';
6
+ const manifest = {'/study.js':'/study.1111111111111111.js', '/study.css':'/study.2222222222222222.css', '/data/bank.json':'/data/bank.json'};
7
+ const context = {config:{assetManifest:manifest}};
8
+ const block = {type:'local-tool',id:'study',title:'Local search',script:'/study.js',stylesheet:'/study.css',data:'/data/bank.json',fallback:'Enable JavaScript to search.'};
9
+
10
+ test('JSON Pages render trusted local tools and fingerprint their executable assets', async () => {
11
+ const body = await renderBlocks([{columns:1,cells:[[block]]}], context);
12
+ const output = rewriteAssetLinks(body, manifest);
13
+ assert.match(output, /data-edgepress-local-tool/);
14
+ assert.match(output, /data-tool-data="\/data\/bank.json"/);
15
+ assert.match(output, /study\.1111111111111111\.js/);
16
+ assert.match(output, /study\.2222222222222222\.css/);
17
+ assert.match(output, /<noscript><p>Enable JavaScript/);
18
+ });
19
+
20
+ test('local tools reject remote, traversal, encoded, executable-data and missing assets', () => {
21
+ for (const script of ['https://vendor.example/study.js','//vendor.example/study.js','/../study.js','/x/./study.js','/x/%2e%2e/study.js','javascript:alert(1)','/missing.js','/study.js?x=1']) {
22
+ assert.throws(() => renderLocalTool({...block,script},context), /local-tool.script/);
23
+ }
24
+ assert.throws(() => renderLocalTool({...block,data:'https://vendor.example/bank.json'}, context), /local-tool.data/);
25
+ assert.throws(() => renderLocalTool({...block,stylesheet:'/study.js'}, context), /local-tool.stylesheet/);
26
+ });
27
+
28
+ test('local tool titles and fallback text remain escaped and unsupported configuration fails', () => {
29
+ const html = renderLocalTool({...block,title:'<img onerror=x>',text:'<script>evil</script>',fallback:'<script>evil</script>'},context);
30
+ assert.ok(!html.includes('<img onerror'));
31
+ assert.match(html,/&lt;script&gt;evil/);
32
+ assert.throws(() => renderLocalTool({...block,id:'bad"id'},context),/local-tool.id/);
33
+ assert.throws(() => renderLocalTool({...block,endpoint:'/api'},context),/Unsupported local-tool/);
34
+ });