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