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
@@ -1,7 +1,13 @@
1
+ import {renderServiceBlock} from './service-block.js';
2
+ import {renderLocalTool} from './local-tool.js';
3
+ import {renderDocument} from './document.js';
4
+ import {renderMediaViewer} from './media-viewer.js';
5
+ import {renderEmbed, renderPageText} from './embed.js';
1
6
  import { renderMarkdownExcerpt } from './markdown.js';
2
- import { translate, translateValue } from './i18n.js';
7
+ import { localizedContentUrl, translate, translateValue } from './i18n.js';
3
8
  import { isIconName, renderIcon } from './icons.js';
4
9
  import { postsForLocale } from './content.js';
10
+ import { categoryFilter, filterPostsByCategory, categorySlug, sortPinnedPosts } from './post-details.js';
5
11
 
6
12
  const captchaProviders = new Set(['cloudflare-turnstile', 'google-recaptcha', 'hcaptcha']);
7
13
 
@@ -30,16 +36,7 @@ function safeUrl(value, label) {
30
36
  }
31
37
 
32
38
  function localizedSiteUrl(value, label, context) {
33
- const url = safeUrl(value, label);
34
- if (!url.startsWith('/')) return url;
35
- const localeToken = /^\/\[launge\](?=\/|$)/i;
36
- if (/\[launge\]/i.test(url) && !localeToken.test(url)) {
37
- throw new Error(label + ' may use [launge] only as the first site-path segment');
38
- }
39
- if (!localeToken.test(url)) return url;
40
- const suffix = url.replace(localeToken, '');
41
- const prefix = context.locale === context.config.i18n.defaultLocale ? '' : '/' + context.locale;
42
- return prefix + (suffix || '/');
39
+ return localizedContentUrl(context.config,context.locale,safeUrl(value,label),label);
43
40
  }
44
41
 
45
42
  function list(value, label, min = 1, max = 50) {
@@ -61,15 +58,14 @@ function imageCandidates(value, label) {
61
58
  }).join(', ');
62
59
  }
63
60
 
64
- function enabledIntegration(config, id, category) {
65
- const section = config.browserPlugins[category];
66
- const match = section?.enabled && section.services.find((item) => item.id === id);
67
- if (!match) throw new Error('Page block requires a configured ' + category + ' service with id: ' + id);
61
+ function enabledIntegration(config, id) {
62
+ const match = config.browserPlugins.services.find(item => item.enabled !== false && item.id === id);
63
+ if (!match) throw new Error('Page block requires a configured service with id: ' + id);
68
64
  return match;
69
65
  }
70
66
 
71
- function dateLabel(date, locale) {
72
- return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(date);
67
+ function dateLabel(date, locale, timeZone = 'UTC', dateOnly = true) {
68
+ return new Intl.DateTimeFormat(locale, dateOnly?{dateStyle:'long',timeZone}:{year:'numeric',month:'long',day:'numeric',hour:'2-digit',minute:'2-digit',timeZoneName:'short',timeZone}).format(date);
73
69
  }
74
70
 
75
71
  function dateOnlyLabel(value, locale) {
@@ -77,21 +73,11 @@ function dateOnlyLabel(value, locale) {
77
73
  }
78
74
 
79
75
  function renderPrivacyServices(context) {
80
- const groups = [
81
- ['tracking', translate(context.config, context.locale, 'pluginTracking')],
82
- ['statistics', translate(context.config, context.locale, 'pluginStatistics')],
83
- ['advertising', translate(context.config, context.locale, 'pluginAdvertising')],
84
- ['captcha', translate(context.config, context.locale, 'pluginCaptcha')]
85
- ];
86
- const configured = groups.flatMap(([key, label]) => {
87
- const section = context.config.browserPlugins[key];
88
- return (section?.enabled ? section.services : []).map((service) => ({ ...service, category: label }));
89
- });
76
+ const configured = context.config.browserPlugins.services.filter(service => service.enabled !== false);
90
77
  if (!configured.length) {
91
- return '<p class="privacy-empty">' + escapeHtml(text(context.emptyText, 'privacy-services.emptyText', 2000)) + '</p>';
78
+ return '<p class="privacy-services-empty">' + escapeHtml(text(context.emptyText, 'privacy-services.emptyText', 2000)) + '</p>';
92
79
  }
93
- return '<ul class="privacy-service-list">' + configured.map((service) => '<li><h3>' + escapeHtml(service.category + ' · ' +
94
- translateValue(context.config, context.locale, service.name || service.provider)) + '</h3><p><strong>' +
80
+ return '<ul class="privacy-service-list">' + configured.map((service) => '<li><h3>' + escapeHtml(translateValue(context.config, context.locale, service.name || service.provider)) + '</h3><p><strong>' +
95
81
  escapeHtml(translate(context.config, context.locale, 'servicePurpose')) + ':</strong> ' +
96
82
  escapeHtml(translateValue(context.config, context.locale, service.purpose)) + '</p><p><strong>' +
97
83
  escapeHtml(translate(context.config, context.locale, 'serviceDataCategories')) + ':</strong> ' +
@@ -109,23 +95,37 @@ async function renderLatestPosts(block, context) {
109
95
  if (!Number.isInteger(count) || count < 1 || count > 12) throw new Error('latest-posts.count must be an integer from 1 to 12');
110
96
  const paginate = block.paginate ?? false;
111
97
  if (typeof paginate !== 'boolean') throw new Error('latest-posts.paginate must be a boolean');
112
- const posts = postsForLocale(context.site.posts, context.locale, context.config.i18n.defaultLocale);
98
+ if ('tag' in block) throw new Error('post-list.tag is no longer supported; use category/categories and show tags on article pages');
99
+ if ('category' in block && 'categories' in block) throw new Error('Use category or categories, not both');
100
+ const categories = categoryFilter(block.category ?? block.categories ?? (block.type === 'latest-posts' ? context.config.site?.archive?.categories : undefined));
101
+ const archiveCategories = categoryFilter(context.config.site?.archive?.categories);
102
+ const sameArchive = [...categories].sort().join('\0') === [...archiveCategories].sort().join('\0');
103
+ if (paginate && (block.type === 'post-list' || !sameArchive)) throw new Error('Homepage pagination must use the same categories as site.archive.categories');
104
+ const selected = filterPostsByCategory(postsForLocale(context.site.posts, context.locale, context.config.i18n.defaultLocale), categories);
105
+ const ordered = block.type === 'post-list' ? sortPinnedPosts(selected) : selected;
106
+ const posts = ordered.filter(post => !context.displayedPostBundles?.has(post.bundlePath));
107
+ const displayed = posts.slice(0, count);
108
+ for (const post of displayed) context.displayedPostBundles?.add(post.bundlePath);
113
109
  const title = escapeHtml(text(block.title, 'latest-posts.title', 200));
114
110
  const authorLabel = (post) => post.author ? '<span class="post-author">' + escapeHtml(translate(context.config, context.locale, 'postAuthor')
115
111
  .replace('{author}', post.author)) + '</span>' : '';
116
- const cards = (await Promise.all(posts.slice(0, count).map(async (post) => {
117
- const excerpt = await renderMarkdownExcerpt(post.description || post.markdown, context.config.markdown);
112
+ const cards = (await Promise.all(displayed.map(async (post) => {
113
+ const excerpt = await renderMarkdownExcerpt(post.description || post.markdown, {...context.config.markdown,linkResolver:value=>localizedContentUrl(context.config,post.locale,value)});
118
114
  return '<article class="post-card" lang="' + escapeHtml(post.locale) + '"><h3><a href="' + escapeHtml('/' + post.path.split('/').filter(Boolean).join('/') + (post.path.endsWith('/') ? '/' : '')) + '">' +
119
- escapeHtml(post.title) + '</a></h3><p class="meta"><time datetime="' + post.date.toISOString() + '">' + escapeHtml(dateLabel(post.date, context.locale)) +
120
- '</time>' + (post.author ? ' · ' + authorLabel(post) : '') + '</p><div class="post-excerpt" lang="' + escapeHtml(post.locale) + '">' + excerpt + '</div></article>';
115
+ escapeHtml(post.title) + '</a></h3>' + (block.type === 'post-list' && post.pinned ? '<span class="post-pinned">' + escapeHtml(translate(context.config, context.locale, 'postPinned')) + '</span>' : '') + '<p class="meta"><time data-local-time data-time-locale="' + escapeHtml(post.locale || context.locale) + '" data-date-only="' + (post.dateOnly === true) + '" datetime="' + (post.dateOnly ? post.date.toISOString().slice(0,10) : post.date.toISOString()) + '">' + escapeHtml(dateLabel(post.date, post.locale || context.locale, post.dateOnly ? 'UTC' : context.config.site.timeZone, post.dateOnly)) +
116
+ '</time>' + (post.author ? ' ' + authorLabel(post) : '') + '</p><div class="post-excerpt" lang="' + escapeHtml(post.locale) + '">' + excerpt + '</div></article>';
121
117
  }))).join('');
122
118
  const pagination = paginate && context.latestPostsPagination?.totalPages > 1
123
119
  ? '<nav class="pagination latest-posts-pagination" aria-label="' + escapeHtml(translate(context.config, context.locale, 'pagination')) + '">' +
124
120
  '<a rel="next" href="' + escapeHtml(context.latestPostsPagination.olderUrl) + '">' + escapeHtml(translate(context.config, context.locale, 'older')) + '</a>' +
125
121
  '<a href="' + escapeHtml(context.latestPostsPagination.archiveUrl) + '">' + escapeHtml(translate(context.config, context.locale, 'allPosts')) + '</a></nav>'
126
122
  : '';
123
+ const morePath = categories.length === 1 ? '/[launge]/categories/' + categorySlug(categories[0]) + '/' : sameArchive ? '/[launge]/archives/' : '';
124
+ const categoryArchive = !paginate && morePath && posts.length > count
125
+ ? '<p class="post-list-more"><a href="' + escapeHtml(localizedSiteUrl(morePath, 'post-list.categoryArchive', context)) + '">' +
126
+ escapeHtml(translate(context.config, context.locale, categories.length === 1 ? 'allCategoryPosts' : 'allPosts')) + '</a></p>' : '';
127
127
  return '<section class="latest-posts"><h2>' + title + '</h2>' +
128
- (cards ? '<div class="post-list">' + cards + '</div>' : '<p>' + escapeHtml(translate(context.config, context.locale, 'noPosts')) + '</p>') + pagination + '</section>';
128
+ (cards ? '<div class="post-list">' + cards + '</div>' : '<p>' + escapeHtml(translate(context.config, context.locale, 'noPosts')) + '</p>') + pagination + categoryArchive + '</section>';
129
129
  }
130
130
 
131
131
  function renderMediaText(block, context) {
@@ -138,7 +138,15 @@ function renderMediaText(block, context) {
138
138
  if (!['left', 'right'].includes(placement)) throw new Error('media-text.placement must be left or right');
139
139
  let media;
140
140
  if (mediaType === 'image') {
141
- media = '<img src="' + src + '" alt="' + alt + '" loading="lazy" decoding="async">';
141
+ const original=block.originalSrc?' data-original="'+escapeHtml(safeUrl(block.originalSrc,'media-text.originalSrc'))+'"':'';
142
+ media = '<img src="' + src + '"'+original+' alt="' + alt + '" loading="lazy" decoding="async">';
143
+ if (block.animationSrc) {
144
+ const animation = safeUrl(block.animationSrc, 'media-text.animationSrc');
145
+ if (!animation.startsWith('/') || !/\.(mp4|webm)$/i.test(animation)) throw new Error('media-text.animationSrc must be a local MP4 or WebM');
146
+ const title = escapeHtml(block.title || block.alt);
147
+ const seekName = 'edgepress-demo-seek-' + (context.demoCount = (context.demoCount || 0) + 1);
148
+ media = '<div class="project-demo" data-project-demo data-animation="' + escapeHtml(animation) + '" data-play="' + escapeHtml(translate(context.config, context.locale, 'demoPlay')) + '" data-resume="' + escapeHtml(translate(context.config, context.locale, 'demoResume')) + '" data-pause="' + escapeHtml(translate(context.config, context.locale, 'demoPause')) + '" data-replay="' + escapeHtml(translate(context.config, context.locale, 'demoReplay')) + '" data-loading="' + escapeHtml(translate(context.config, context.locale, 'demoLoading')) + '" data-error="' + escapeHtml(translate(context.config, context.locale, 'demoError')) + '"><div class="project-demo-media">' + media + '<video muted playsinline preload="none" hidden aria-label="' + title + '"></video></div><button type="button" data-demo-toggle aria-pressed="false" aria-label="' + escapeHtml(translate(context.config, context.locale, 'demoPlay')) + ': ' + title + '">' + escapeHtml(translate(context.config, context.locale, 'demoPlay')) + '</button><input name="' + seekName + '" class="project-demo-seek" data-demo-seek type="range" min="0" max="1" value="0" step="0.1" disabled aria-label="' + escapeHtml(translate(context.config, context.locale, 'demoSeek')) + ': ' + title + '"><span class="project-demo-time" data-demo-time>0:00 / 0:00</span><span class="project-demo-status" role="status"></span></div>';
149
+ }
142
150
  } else {
143
151
  if (!alt.trim()) throw new Error('media-text.alt must describe the video');
144
152
  const videoSrc = escapeHtml(safeUrl(block.src, 'media-text.src'));
@@ -166,11 +174,28 @@ async function renderBlock(block, context, depth, index) {
166
174
  if (depth > 4) throw new Error('Page block nesting may not exceed 4 levels');
167
175
  const type = text(block.type, 'block.type', 40);
168
176
  switch (type) {
177
+ case 'local-tool': return renderLocalTool(block, context);
178
+ case 'document': return renderDocument(block, context);
179
+ case 'media-viewer': return renderMediaViewer(block, context);
169
180
  case 'hero': {
170
181
  const heading = text(block.title, 'hero.title', 240);
171
182
  const level = context.isHomepage && index === 0 ? 'h1' : 'h2';
172
183
  const eyebrow = text(block.eyebrow, 'hero.eyebrow', 120, true);
173
184
  const description = text(block.text, 'hero.text', 1000, true);
185
+ const bannerSrc = block.bannerSrc === undefined ? '' : safeUrl(block.bannerSrc, 'hero.bannerSrc');
186
+ const bannerAlt = block.bannerAlt === undefined ? '' : text(block.bannerAlt, 'hero.bannerAlt', 500);
187
+ if (Boolean(bannerSrc) !== Boolean(bannerAlt)) throw new Error('hero.bannerSrc and hero.bannerAlt must be provided together');
188
+ const bannerSrcset = block.bannerSrcset === undefined ? '' : imageCandidates(block.bannerSrcset, 'hero.bannerSrcset');
189
+ if (bannerSrcset && !bannerSrc) throw new Error('hero.bannerSrcset requires hero.bannerSrc');
190
+ const bannerWebpSrcset = block.bannerWebpSrcset === undefined ? '' : imageCandidates(block.bannerWebpSrcset, 'hero.bannerWebpSrcset');
191
+ if (bannerWebpSrcset && !bannerSrc) throw new Error('hero.bannerWebpSrcset requires hero.bannerSrc');
192
+ const bannerSizes = escapeHtml(text(block.bannerSizes ?? '(max-width: 620px) 90vw, 680px', 'hero.bannerSizes', 200));
193
+ const bannerWidth = block.bannerWidth;
194
+ const bannerHeight = block.bannerHeight;
195
+ if (bannerSrc && (!Number.isInteger(bannerWidth) || bannerWidth < 1 || bannerWidth > 10000 ||
196
+ !Number.isInteger(bannerHeight) || bannerHeight < 1 || bannerHeight > 10000)) {
197
+ throw new Error('hero banner dimensions must be integers from 1 to 10000');
198
+ }
174
199
  const mascotSrc = block.mascotSrc === undefined ? '' : safeUrl(block.mascotSrc, 'hero.mascotSrc');
175
200
  const mascotAlt = block.mascotAlt === undefined ? '' : text(block.mascotAlt, 'hero.mascotAlt', 500);
176
201
  if (Boolean(mascotSrc) !== Boolean(mascotAlt)) throw new Error('hero.mascotSrc and hero.mascotAlt must be provided together');
@@ -202,9 +227,14 @@ async function renderBlock(block, context, depth, index) {
202
227
  ? '<picture class="hero-mascot-picture"><source type="image/webp" srcset="' + mascotWebpSrcset + '" sizes="' + mascotSizes + '">' + mascotImage + '</picture>'
203
228
  : mascotImage;
204
229
  const aside = mascot || highlightPanel ? '<div class="hero-aside">' + mascot + highlightPanel + '</div>' : '';
205
- return '<section class="hero-block' + (aside ? ' hero-block--split' : '') + '"><div class="hero-main">' +
230
+ const bannerImage = bannerSrc ? '<img class="hero-banner" src="' + escapeHtml(bannerSrc) + '" alt="' + escapeHtml(bannerAlt) +
231
+ '" width="' + bannerWidth + '" height="' + bannerHeight + '"' + (bannerSrcset ? ' srcset="' + bannerSrcset +
232
+ '" sizes="(max-width: 620px) calc(100vw - 32px), (max-width: 1168px) calc(100vw - 48px), 1120px"' : '') +
233
+ ' decoding="async" fetchpriority="high">' : '';
234
+ const banner = bannerWebpSrcset ? '<picture class="hero-banner-picture"><source type="image/webp" srcset="' + bannerWebpSrcset + '" sizes="' + bannerSizes + '">' + bannerImage + '</picture>' : bannerImage;
235
+ return '<section class="hero-block' + (aside ? ' hero-block--split' : '') + (banner ? ' hero-block--banner' : '') + '"><div class="hero-main">' +
206
236
  (eyebrow ? '<p class="eyebrow">' + escapeHtml(eyebrow) + '</p>' : '') + '<' + level + '>' + escapeHtml(heading) + '</' + level + '>' +
207
- (description ? '<p class="hero-copy">' + escapeHtml(description) + '</p>' : '') + action + '</div>' + aside + '</section>';
237
+ (description ? '<p class="hero-copy">' + escapeHtml(description) + '</p>' : '') + action + '</div>' + aside + banner + '</section>';
208
238
  }
209
239
  case 'section': {
210
240
  const heading = text(block.title, 'section.title', 200, true);
@@ -242,7 +272,7 @@ async function renderBlock(block, context, depth, index) {
242
272
  let paragraphs = block.paragraphs;
243
273
  if (paragraphs === undefined && typeof block.text === 'string') paragraphs = block.text.split(/\n\s*\n/).filter(Boolean);
244
274
  if (!Array.isArray(paragraphs) || paragraphs.length < 1 || paragraphs.length > 30) throw new Error('text needs a text value or 1 to 30 paragraphs');
245
- return '<div class="text-widget">' + paragraphs.map((paragraph) => '<p>' + escapeHtml(text(paragraph, 'text paragraph', 4000)) + '</p>').join('') + '</div>';
275
+ return '<div class="text-widget">' + (await Promise.all(paragraphs.map(paragraph => renderPageText(text(paragraph, 'text paragraph', 4000), context)))).join('') + '</div>';
246
276
  }
247
277
  case 'media-text': return renderMediaText(block, context);
248
278
  case 'display-text': {
@@ -289,7 +319,7 @@ async function renderBlock(block, context, depth, index) {
289
319
  const rows = list(block.rows, 'data-table.rows', 1, 100);
290
320
  const title = text(block.title, 'data-table.title', 200);
291
321
  for (const row of rows) if (!Array.isArray(row) || row.length !== headers.length) throw new Error('Each data-table row must match the number of headers');
292
- return '<div class="table-widget"><table><caption>' + escapeHtml(title) + '</caption><thead><tr>' + headers.map((item) =>
322
+ return '<div class="table-widget" tabindex="0" role="region" aria-label="'+escapeHtml(title)+'"><table><caption>' + escapeHtml(title) + '</caption><thead><tr>' + headers.map((item) =>
293
323
  '<th scope="col">' + escapeHtml(text(item, 'data-table header', 200)) + '</th>').join('') + '</tr></thead><tbody>' +
294
324
  rows.map((row) => '<tr>' + row.map((cell) => '<td>' + escapeHtml(text(cell, 'data-table cell', 1000)) + '</td>').join('') + '</tr>').join('') +
295
325
  '</tbody></table></div>';
@@ -343,6 +373,22 @@ async function renderBlock(block, context, depth, index) {
343
373
  '</span><span class="privacy-controller-separator" aria-hidden="true">·</span><span><strong>' + contactLabel +
344
374
  ':</strong> ' + contactValue + '</span></p>';
345
375
  }
376
+ case 'link-directory': {
377
+ const items = list(block.items, 'link-directory.items', 1, 100);
378
+ const heading = text(block.title, 'link-directory.title', 200);
379
+ const entries = items.map((item) => {
380
+ const name = escapeHtml(text(item?.title, 'link-directory item title', 200));
381
+ const summary = escapeHtml(text(item?.text, 'link-directory item text', 1000, true));
382
+ const href = localizedSiteUrl(item?.url, 'link-directory item url', context);
383
+ const host = /^https?:\/\//i.test(href) ? new URL(href).host : '';
384
+ return '<li class="link-directory-entry"><a class="link-directory-link" href="' + escapeHtml(href) + '">' +
385
+ '<span class="link-directory-main"><strong class="link-directory-name">' + name + '</strong>' +
386
+ (summary ? '<span class="link-directory-summary">' + summary + '</span>' : '') + '</span>' +
387
+ '<span class="link-directory-destination" aria-hidden="true">' + (host ? '<span>' + escapeHtml(host) + '</span>' : '') +
388
+ '<span class="link-directory-arrow">↗</span></span></a></li>';
389
+ }).join('');
390
+ return '<section class="link-directory-section"><h2>' + escapeHtml(heading) + '</h2><ul class="link-directory-items" role="list">' + entries + '</ul></section>';
391
+ }
346
392
  case 'feature-grid': {
347
393
  const items = list(block.items, 'feature-grid.items', 1, 12);
348
394
  const heading = text(block.title, 'feature-grid.title', 200);
@@ -362,7 +408,11 @@ async function renderBlock(block, context, depth, index) {
362
408
  const src = safeUrl(block.src, 'image.src');
363
409
  const alt = text(block.alt, 'image.alt', 500);
364
410
  const caption = text(block.caption, 'image.caption', 500, true);
365
- return '<figure class="image-block"><img src="' + escapeHtml(src) + '" alt="' + escapeHtml(alt) + '" loading="lazy" decoding="async">' +
411
+ const original=block.originalSrc?' data-original="'+escapeHtml(safeUrl(block.originalSrc,'image.originalSrc'))+'"':'';
412
+ const srcset = block.srcset === undefined ? '' : imageCandidates(block.srcset, 'image.srcset');
413
+ const responsive = srcset ? ' srcset="' + srcset + '" sizes="' + escapeHtml(text(block.sizes ?? '(max-width: 640px) calc(100vw - 32px), 560px', 'image.sizes', 200)) + '"' : '';
414
+ if (block.sizes !== undefined && !srcset) throw new Error('image.sizes requires image.srcset');
415
+ return '<figure class="image-block"><img src="' + escapeHtml(src) + '"'+original+responsive+' alt="' + escapeHtml(alt) + '" loading="lazy" decoding="async">' +
366
416
  (caption ? '<figcaption>' + escapeHtml(caption) + '</figcaption>' : '') + '</figure>';
367
417
  }
368
418
  case 'quote': {
@@ -384,14 +434,22 @@ async function renderBlock(block, context, depth, index) {
384
434
  return '<section class="faq-block"><h2>' + title + '</h2>' + items.map((item) => '<details><summary>' +
385
435
  escapeHtml(text(item?.question, 'faq.question', 500)) + '</summary><p>' + escapeHtml(text(item?.answer, 'faq.answer', 4000)) + '</p></details>').join('') + '</section>';
386
436
  }
437
+ case 'oembed': return renderEmbed(block, context);
438
+ case 'service': {
439
+ const id = text(block.integration, 'service.integration', 64);
440
+ if (context.servicesRendered.has(id)) throw new Error('A page may contain only one block per service');
441
+ context.servicesRendered.add(id);
442
+ return renderServiceBlock(context.document, context.locale, context.config, id, true);
443
+ }
444
+ case 'post-list':
387
445
  case 'latest-posts': return renderLatestPosts(block, context);
388
446
  case 'ad-slot': {
389
- const integration = enabledIntegration(context.config, block.integration, 'advertising');
447
+ const integration = enabledIntegration(context.config, block.integration);
390
448
  return '<div class="ad-slot" data-edgepress-ad="' + escapeHtml(integration.id) + '" aria-label="' + escapeHtml(text(block.label, 'ad-slot.label', 120)) + '"></div>';
391
449
  }
392
450
  case 'captcha': {
393
451
  if (!captchaProviders.has(block.provider)) throw new Error('captcha.provider must be a supported consent-gated provider');
394
- const integration = context.config.browserPlugins.captcha.enabled && context.config.browserPlugins.captcha.services.find((item) => item.provider === block.provider);
452
+ const integration = context.config.browserPlugins.services.find(item => item.enabled !== false && item.provider === block.provider);
395
453
  if (!integration) throw new Error('captcha block needs a configured consent-gated integration: ' + block.provider);
396
454
  const siteKey = integration.siteKey;
397
455
  return '<div class="captcha-block" data-edgepress-captcha="' + escapeHtml(block.provider) + '" data-sitekey="' + escapeHtml(siteKey) + '" aria-label="' + escapeHtml(text(block.label, 'captcha.label', 120)) + '"></div>';
@@ -406,28 +464,40 @@ async function renderElements(blocks, context, depth = 0) {
406
464
  for (let index = 0; index < blocks.length; index += 1) {
407
465
  context.blockCount = (context.blockCount || 0) + 1;
408
466
  if (context.blockCount > 200) throw new Error('A page may contain at most 200 blocks including nested blocks');
409
- rendered.push(await renderBlock(blocks[index], context, depth, index));
467
+ try {
468
+ rendered.push(await renderBlock(blocks[index], context, depth, index));
469
+ } catch (error) {
470
+ throw new Error('elements[' + index + '] (element ' + (index + 1) + '): ' + error.message, {cause: error});
471
+ }
410
472
  }
411
473
  return rendered.join('\n');
412
474
  }
413
475
 
414
476
  export async function renderBlocks(rows, context) {
477
+ context = {...context, displayedPostBundles:new Set(), servicesRendered:new Set(), localToolIds:new Set(), blockCount:0, demoCount:0};
415
478
  if (!Array.isArray(rows) || rows.length > 100) throw new Error('Page layout needs an array of at most 100 rows');
416
479
  const rendered = [];
417
- for (const row of rows) {
480
+ for (const [rowIndex, row] of rows.entries()) {
481
+ const rowLabel = 'blocks[' + rowIndex + '] (row ' + (rowIndex + 1) + ')';
418
482
  if (!row || typeof row !== 'object' || Array.isArray(row) || 'type' in row) {
419
- throw new Error('Page layout rows start with a columns count; put element types inside cells');
483
+ throw new Error(rowLabel + ': Page layout rows start with a columns count; put element types inside cells');
420
484
  }
421
- for (const key of Object.keys(row)) if (!['columns', 'cells'].includes(key)) throw new Error('Unsupported page row option: ' + key);
485
+ for (const key of Object.keys(row)) if (!['columns', 'cells'].includes(key)) throw new Error(rowLabel + ': Unsupported page row option: ' + key);
422
486
  const columns = row.columns;
423
- if (!Number.isInteger(columns) || columns < 1 || columns > 4) throw new Error('Each page row needs a columns count from 1 to 4');
424
- if (!Array.isArray(row.cells) || row.cells.length !== columns) throw new Error('Each row needs one cells entry per configured column');
487
+ if (!Number.isInteger(columns) || columns < 1 || columns > 4) throw new Error(rowLabel + ': Each page row needs a columns count from 1 to 4');
488
+ if (!Array.isArray(row.cells) || row.cells.length !== columns) throw new Error(rowLabel + ': Each row needs one cells entry per configured column (columns: ' + columns + '; cells: ' + (Array.isArray(row.cells) ? row.cells.length : 'not a list') + ')');
425
489
  context.blockCount = (context.blockCount || 0) + 1;
426
490
  if (context.blockCount > 200) throw new Error('A page may contain at most 200 blocks including nested blocks');
427
- const cells = await Promise.all(row.cells.map(async (blocks, index) => {
428
- if (!Array.isArray(blocks) || !blocks.length) throw new Error('Every page row cell needs at least one element block');
429
- return '<div class="page-builder-cell" data-cell="' + (index + 1) + '">' + await renderElements(blocks, context, 1) + '</div>';
430
- }));
491
+ const cells = [];
492
+ for (const [index,blocks] of row.cells.entries()) {
493
+ const cellLabel = rowLabel + '.cells[' + index + '] (cell ' + (index + 1) + ')';
494
+ if (!Array.isArray(blocks) || !blocks.length) throw new Error(cellLabel + ': Every page row cell needs at least one element block; use cells: [[{ type: text, text: "Hello" }]]');
495
+ try {
496
+ cells.push('<div class="page-builder-cell" data-cell="' + (index + 1) + '">' + await renderElements(blocks, context, 1) + '</div>');
497
+ } catch (error) {
498
+ throw new Error(cellLabel + ': ' + error.message, {cause: error});
499
+ }
500
+ }
431
501
  rendered.push('<div class="page-builder-row" data-columns="' + columns + '">' + cells.join('') + '</div>');
432
502
  }
433
503
  return rendered.join('\n');
package/src/page-check.js CHANGED
@@ -1,7 +1,9 @@
1
- import { access, lstat, mkdir, readFile, readdir, realpath } from 'node:fs/promises';
2
- import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
1
+ import { access, lstat, readFile, readdir } from 'node:fs/promises';
2
+ import { extname, relative, resolve, sep } from 'node:path';
3
3
  import { addVisualEvidence } from './report-visuals.js';
4
4
  import { renderMarkdown, renderMarkdownExcerpt } from './markdown.js';
5
+ import { markdownFixture } from './markdown-fixture.js';
6
+ import {reportDirectory} from './report-path.js';
5
7
 
6
8
  async function htmlFiles(directory) {
7
9
  let entries;
@@ -61,7 +63,7 @@ async function contentDocuments(output, config) {
61
63
  return documents;
62
64
  }
63
65
 
64
- async function markdownFeatureCoverage(root, files, sourceByFile) {
66
+ export async function markdownFeatureCoverage() {
65
67
  const expected = [
66
68
  ['headings 2 through 6', /<h2\b[\s\S]*?<h3\b[\s\S]*?<h4\b[\s\S]*?<h5\b[\s\S]*?<h6\b/i],
67
69
  ['Setext heading syntax', /<h2\b[^>]*>Setext (?:heading level two|二级标题示例)<\/h2>/i],
@@ -72,11 +74,11 @@ async function markdownFeatureCoverage(root, files, sourceByFile) {
72
74
  ['inline and fenced code', /<code\b[\s\S]*?<\/code>[\s\S]*?<pre\b[\s\S]*?<code\b[^>]*class="language-js"/i],
73
75
  ['tilde-fenced and four-space-indented code', /class="language-text"[\s\S]*?<pre\b[^>]*><code[^>]*>const indented =/i],
74
76
  ['code spans containing a literal backtick', (html) => html.includes('<code>`</code>')],
75
- ['escaped Markdown punctuation and character references', /\\\*[\s\S]*?&lt;[\s\S]*?&amp;/i],
77
+ ['escaped Markdown punctuation and character references', (html) => html.includes('<p>*escaped punctuation* &lt; &amp;</p>')],
76
78
  ['inline and reference links', /href="\/quick-start\/"/i],
77
79
  ['GFM automatic links', /href="https:\/\/www\.markdownguide\.org\//i],
78
80
  ['angle-bracket, www, and email automatic links', /href="https:\/\/commonmark\.org"[\s\S]*?href="http:\/\/www\.example\.com"[\s\S]*?href="mailto:team@example\.com"/i],
79
- ['images with alternative text', /<img\b[^>]*src="\/edgepress-markdown-guide\.svg"[^>]*alt="[^"]+"/i],
81
+ ['images with alternative text', /<img\b[^>]*src="\/edgepress-markdown-guide\.png"[^>]*alt="[^"]+"/i],
80
82
  ['image-shaped video Markdown syntax documented', (html) => html.includes('field-recording.mp4')],
81
83
  ['nested lists with inline formatting', /<li>[\s\S]*?<strong\b[\s\S]*?<ol\b[\s\S]*?<li>/i],
82
84
  ['task lists with accessible status names', /markdown-task-status" role="img" aria-label="[^"]+"/i],
@@ -94,34 +96,26 @@ async function markdownFeatureCoverage(root, files, sourceByFile) {
94
96
  catch { unsafeVideoRejected = true; }
95
97
  const videoChecks = [
96
98
  ['image-shaped video syntax renders an accessible native player with deferred media',
97
- /<video controls playsinline preload="none" aria-label="A kite crossing a field"><source src="https:\/\/media\.example\.org\/field-recording\.mp4" type="video\/mp4"/i.test(videoHtml)],
99
+ /<video\b(?=[^>]*\bcontrols\b)(?=[^>]*\bplaysinline\b)(?=[^>]*\bpreload="none")(?=[^>]*\baria-label="A kite crossing a field")[^>]*><source src="https:\/\/media\.example\.org\/field-recording\.mp4" type="video\/mp4"/i.test(videoHtml)],
98
100
  ['video title renders as a caption', /<figcaption>Wind test<\/figcaption>/i.test(videoHtml)],
99
101
  ['unsafe video URL is rejected', unsafeVideoRejected],
100
102
  ['post-list excerpt preserves inline Markdown formatting', /<strong>formatted<\/strong>[\s\S]*?<em>emphasis<\/em>[\s\S]*?<code>code<\/code>/i.test(excerptHtml)]
101
103
  ];
102
- const guides = files.filter((file) => /markdown-syntax-guide\/index\.html$/i.test(file.replace(/\\/g, '/')));
103
- const documents = guides.map((file) => {
104
- const html = sourceByFile.get(file) || '';
105
- const content = html.match(/<div class="post-content">([\s\S]*?)<\/div>/i)?.[1] || '';
106
- const checks = [
107
- ...expected.map(([feature, matcher]) => ({ feature, passed: typeof matcher === 'function' ? matcher(content) : matcher.test(content) })),
108
- ...videoChecks.map(([feature, passed]) => ({ feature, passed }))
109
- ];
110
- return { path: relative(root, file).replace(/\\/g, '/'), checks, passed: checks.filter((item) => item.passed).length };
111
- });
104
+ const content = await renderMarkdown(markdownFixture);
105
+ const checks = [
106
+ ...expected.map(([feature, matcher]) => ({ feature, passed: typeof matcher === 'function' ? matcher(content) : matcher.test(content) })),
107
+ ...videoChecks.map(([feature, passed]) => ({ feature, passed }))
108
+ ];
109
+ const documents = [{ path: 'Built-in renderer fixture (not published)', checks,
110
+ passed: checks.filter((item) => item.passed).length }];
112
111
  return {
113
- status: guides.length && documents.every((document) => document.checks.every((item) => item.passed)) ? 'pass' : 'fail',
114
- checks: (expected.length + videoChecks.length) * Math.max(1, guides.length),
112
+ status: checks.every((item) => item.passed) ? 'pass' : 'fail',
113
+ checks: checks.length,
115
114
  passed: documents.reduce((total, document) => total + document.passed, 0),
116
115
  documents
117
116
  };
118
117
  }
119
118
 
120
- function isInside(root, path) {
121
- const rel = relative(root, path);
122
- return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel);
123
- }
124
-
125
119
  async function resolveLocalLink(output, pageFile, href) {
126
120
  let url;
127
121
  try { url = new URL(href, 'https://edgepress.invalid/' + relative(output, pageFile).split(sep).join('/')); }
@@ -184,10 +178,7 @@ export async function checkPages(config) {
184
178
  pageIds.set(file, new Set([...html.matchAll(/\bid\s*=\s*["']([^"']+)["']/gi)].map((match) => match[1])));
185
179
  metrics.htmlBytes += Buffer.byteLength(html);
186
180
  }
187
- const markdownRendering = await markdownFeatureCoverage(output, files, sourceByFile);
188
- if (!markdownRendering.documents.length) {
189
- addFinding('error', 'markdown-rendering', 'content/posts', 'The Markdown syntax guide post was not generated.');
190
- }
181
+ const markdownRendering = await markdownFeatureCoverage();
191
182
  for (const document of markdownRendering.documents) {
192
183
  for (const check of document.checks.filter((item) => !item.passed)) {
193
184
  addFinding('error', 'markdown-rendering', document.path, 'Markdown syntax did not render: ' + check.feature + '.');
@@ -298,6 +289,8 @@ export async function checkPages(config) {
298
289
  try { managerScript = await readFile(managerAsset, 'utf8'); }
299
290
  catch { /* The missing manager is reported below. */ }
300
291
  }
292
+ const choiceAsset = all.find(file => /edgepress[\\/]plugins[\\/]consent[\\/]choices\.[a-f0-9]{16}\.js$/i.test(file));
293
+ const choicesScript = choiceAsset ? await readFile(choiceAsset, 'utf8') : '';
301
294
  const htmlWithConsent = files.filter((file) => {
302
295
  const html = sourceByFile.get(file) || '';
303
296
  return html.includes('edgepress-privacy-config') && /\/edgepress\/plugins\/consent\/manager\.[a-f0-9]{16}\.js/i.test(html);
@@ -309,7 +302,7 @@ export async function checkPages(config) {
309
302
  managerScript.includes('serviceRetention') && managerScript.includes('servicePrivacyDetails') &&
310
303
  managerScript.includes('integration.privacyUrl'));
311
304
  consentCheck('stored choices are tied to proposed and effective dates',
312
- managerScript.includes('proposedDate: consent.proposedDate') && managerScript.includes('effectiveDate: consent.effectiveDate'));
305
+ managerScript.includes('makeChoice(') && /proposedDate:\s*consent\.proposedDate/.test(choicesScript) && /effectiveDate:\s*consent\.effectiveDate/.test(choicesScript));
313
306
  consentCheck('new visitors start with every optional service off', managerScript.includes('checkbox.checked = false'));
314
307
  consentCheck('accept and reject controls share the same component styling',
315
308
  /\.privacy-manager \.privacy-panel \.privacy-accept\s*,\s*\.privacy-manager \.privacy-panel \.privacy-reject\s*\{[^}]*background:[^}]*\}/.test(styles));
@@ -395,22 +388,15 @@ export async function checkPages(config) {
395
388
  hashedAssets,
396
389
  issues,
397
390
  scope: [
398
- 'Accessibility checks inspect generated HTML and CSS. They do not measure color contrast, screen-reader behavior, keyboard interactions in a browser, or dynamic vendor widgets.',
391
+ 'Structural accessibility checks inspect generated HTML and CSS. Browser checks separately sample device emulation, the browser accessibility tree, and real Tab/Shift+Tab/Enter events when Chromium is available.',
399
392
  'Page layout is measured with the consent manager hidden. The consent manager has separate component checks and screenshots, so its size does not change the page-content overflow result.',
400
393
  'Agent-friendliness checks inspect metadata, structured data, internal links, robots.txt, sitemap.xml, and llms.txt. They do not guarantee crawler indexing or answer quality.',
401
394
  'This automated report is not a legal-compliance assessment or a substitute for manual review.',
402
- 'Screenshots capture generated local pages at desktop and mobile viewports. They do not replace manual keyboard, zoom, contrast, or assistive-technology review.'
395
+ 'Headless phone/tablet profiles and accessibility-tree inspection are simulations, not physical devices or NVDA, Narrator, VoiceOver, or TalkBack. Keyboard sampling does not cover every possible interaction; manual zoom, contrast and assistive-technology review remain necessary.',
396
+ 'Markdown self-checks render a built-in fixture in memory without publishing tutorial articles or requiring a posts directory.'
403
397
  ]
404
398
  };
405
- const requestedDirectory = resolve(config.root, 'tools');
406
- await mkdir(requestedDirectory, { recursive: true });
407
- const directoryInfo = await lstat(requestedDirectory);
408
- if (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory()) {
409
- throw new Error('Page-check report directory must be a real directory: ' + requestedDirectory);
410
- }
411
- const root = await realpath(config.root);
412
- const directory = await realpath(requestedDirectory);
413
- if (!isInside(root, directory)) throw new Error('Page-check report directory must stay inside the project root');
399
+ const directory = await reportDirectory(config.root);
414
400
  const pdfPath = resolve(directory, 'page-check.pdf');
415
401
  try {
416
402
  const info = await lstat(pdfPath);
@@ -424,6 +410,10 @@ export async function checkPages(config) {
424
410
  issues.push({ severity: 'warning', category: 'visual-evidence', page: 'report', message: report.visualEvidence.reason || 'PDF generation failed.' });
425
411
  }
426
412
  } catch (error) {
413
+ if (report.browserChecks && report.browserChecks.status !== 'pass' && report.browserChecks.status !== 'fail') {
414
+ report.browserChecks.status = report.browserChecks.results.length ? 'incomplete' : 'unavailable';
415
+ report.browserChecks.reason = error.message;
416
+ }
427
417
  report.visualEvidence = { status: 'failed', reason: error.message };
428
418
  issues.push({ severity: 'warning', category: 'visual-evidence', page: 'report', message: 'Screenshot or PDF generation failed: ' + error.message });
429
419
  }
package/src/plugin-api.js CHANGED
@@ -2,6 +2,9 @@ import { isAbsolute, relative, resolve, sep } from 'node:path';
2
2
  import { realpath } from 'node:fs/promises';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { renderMarkdown } from './markdown.js';
5
+ import {renderEmbed} from './embed.js';
6
+ import {renderDocument} from './document.js';
7
+ import {localizedContentUrl} from './i18n.js';
5
8
 
6
9
  export async function createExtensions(config) {
7
10
  const filters = new Map();
@@ -12,10 +15,13 @@ export async function createExtensions(config) {
12
15
  const selected = context.config.i18n.translationsByLocale?.[locale];
13
16
  return renderMarkdown(source, {
14
17
  ...context.config.markdown,
18
+ linkResolver:value=>localizedContentUrl(context.config,locale,value),
15
19
  taskLabels: {
16
20
  complete: selected?.markdownTaskComplete ?? base?.markdownTaskComplete ?? 'Task complete',
17
21
  incomplete: selected?.markdownTaskIncomplete ?? base?.markdownTaskIncomplete ?? 'Task incomplete'
18
- }
22
+ },
23
+ embedRenderer: block => renderEmbed(block, {config: context.config, locale}),
24
+ documentRenderer: block => renderDocument(block, {config: context.config, locale})
19
25
  });
20
26
  }, cacheable: true }]]);
21
27
 
@@ -0,0 +1,60 @@
1
+ import { plainText } from './markdown.js';
2
+
3
+ export function normalizeCategory(value) {
4
+ if (value === undefined || value === null || value === '') return 'uncategorized';
5
+ if (typeof value !== 'string' || value.length > 120 || !value.trim()) throw new Error('category must be a short, non-empty string');
6
+ const category = value.trim().normalize('NFKC').toLowerCase();
7
+ if (['blog', '博客'].includes(category)) return 'blog';
8
+ if (['uncategorized', 'uncategorised', '未分类', '未分類'].includes(category)) return 'uncategorized';
9
+ return category;
10
+ }
11
+
12
+ export function categoryFilter(value) {
13
+ if (value === undefined) return [];
14
+ const values = typeof value === 'string' ? [value] : value;
15
+ if (!Array.isArray(values) || values.length > 20 || values.some(item => typeof item !== 'string' || !item.trim())) {
16
+ throw new Error('categories must be a category string or an array of at most 20 non-empty strings');
17
+ }
18
+ return [...new Set(values.map(normalizeCategory))];
19
+ }
20
+
21
+ export function filterPostsByCategory(posts, value) {
22
+ const categories = categoryFilter(value);
23
+ return categories.length ? posts.filter(post => categories.includes(normalizeCategory(post.category))) : posts;
24
+ }
25
+
26
+ export function categorySlug(value) {
27
+ const category = normalizeCategory(value);
28
+ if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(category)) return category;
29
+ return 'category-' + Array.from(category).map(char => char.codePointAt(0).toString(16)).join('-');
30
+ }
31
+
32
+ export function safeAvatar(value, label = 'author avatar') {
33
+ if (value === undefined || value === null || value === '') return '';
34
+ if (typeof value !== 'string' || value.length > 2048 || /[\x00-\x20\\]/.test(value) || value.startsWith('//')) {
35
+ throw new Error(label + ' must be a safe site path or HTTPS URL');
36
+ }
37
+ const url = new URL(value, 'https://edgepress.invalid');
38
+ if (url.protocol !== 'https:' || url.username || url.password || (!value.startsWith('/') && !value.startsWith('https://'))) {
39
+ throw new Error(label + ' must be a safe site path or HTTPS URL');
40
+ }
41
+ return value;
42
+ }
43
+
44
+ export function authorForPost(post, config) {
45
+ return { name: post.author || config.site.author?.name || config.site.seo?.author || '',
46
+ avatar: post.authorAvatar || config.site.author?.avatar || '' };
47
+ }
48
+
49
+ export function readingMinutes(markdown) {
50
+ const content = plainText(markdown);
51
+ const cjk = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;
52
+ const characters = (content.match(cjk) || []).length;
53
+ const words = (content.replace(cjk, ' ').match(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu) || []).length;
54
+ return Math.max(1, Math.ceil(characters / 300 + words / 200));
55
+ }
56
+
57
+ export function sortPinnedPosts(posts) {
58
+ return [...posts].sort((left,right) => Number(right.pinned === true) - Number(left.pinned === true) ||
59
+ right.date - left.date || left.bundlePath.localeCompare(right.bundlePath));
60
+ }
@@ -0,0 +1,5 @@
1
+ // The CLI writes the metadata. Authors can start with ordinary Markdown prose.
2
+ export function postTemplate(title, date, locale, starter) {
3
+ return '---\ntitle: ' + JSON.stringify(title) + '\ndate: ' + date + '\nlang: ' + locale +
4
+ '\ncategory: uncategorized\ntags: []\n---\n\n' + starter + '\n';
5
+ }