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,119 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { extname } from 'node:path';
5
+ import { chromium } from 'playwright';
6
+ import { renderMarkdown, renderMarkdownExcerpt, plainText, hasMarkdownVideo } from '../src/markdown.js';
7
+ import { loadConfig } from '../src/config.js';
8
+ import { loadLanguagePacks } from '../src/i18n.js';
9
+ import { collectAssets, rewriteAssetLinks } from '../src/assets.js';
10
+ import { renderLayout } from '../src/theme.js';
11
+ import { configureDataSaver, dataSaverHtml } from '../src/data-saver.js';
12
+ import {reserveVideoMetadata} from '../src/video-metadata.js';
13
+
14
+ test('CJK bold with closing punctuation renders beside following prose without rewriting code or URLs', async () => {
15
+ for (const source of ['前文**结果(XXX)**后文', '**(XXX)**后文', '前文**(XXX)** 后文', '前文**result (XXX)**后文', '前文**说明。**后文']) {
16
+ assert.match(await renderMarkdown(source), /<strong>/);
17
+ assert.doesNotMatch(plainText(source), /\*\*/);
18
+ assert.match(await renderMarkdownExcerpt(source), /<strong>/);
19
+ }
20
+ assert.match(await renderMarkdown('**外层 *内部*(测试)**后文'), /<strong>外层 <em>内部<\/em>(测试)<\/strong>后文/);
21
+ assert.match(await renderMarkdown('`**(XXX)**`'), /<code>\*\*(XXX)\*\*<\/code>/);
22
+ assert.doesNotMatch(await renderMarkdown('\\**(XXX)**'), /<strong>/);
23
+ assert.match(await renderMarkdown('a**punctuation!**b'), /a\*\*punctuation!\*\*b/);
24
+ assert.doesNotMatch(await renderMarkdown('**(XXX) **后文'), /<strong>/);
25
+ assert.match(decodeURIComponent(await renderMarkdown('[link](https://example.com/**(XXX)**)')), /href="https:\/\/example.com\/\*\*(XXX)\*\*"/);
26
+ });
27
+
28
+ test('Markdown videos request the shared engine, including reference links, while code examples remain inert', async () => {
29
+ assert.equal(hasMarkdownVideo('![Recording](/recording.mp4)'), true);
30
+ assert.equal(hasMarkdownVideo('![Recording][clip]\n\n[clip]: /recording.mp4'), true);
31
+ assert.equal(hasMarkdownVideo('`![Recording](/recording.mp4)`'), false);
32
+ assert.equal(hasMarkdownVideo('```md\n![Recording](/recording.mp4)\n```'), false);
33
+ const html = await renderMarkdown('![Recording](/recording.mp4 "A caption")');
34
+ assert.match(html, /data-edgepress-video/);
35
+ assert.match(html, /preload="none"/);
36
+ await assert.rejects(renderMarkdown('![Unsafe](javascript:bad.mp4)'), /safe relative/);
37
+ const config = await loadConfig(process.cwd());
38
+ await loadLanguagePacks(config);
39
+ const layout = await renderLayout(config, { filter: async (_key, value) => value }, { title: 'Recording', locale: 'en', urlPath: '/recording/' }, '<main>' + html + '</main>');
40
+ assert.match(layout, /src="\/edgepress\/media-viewer.js"/);
41
+ const bundle = await collectAssets(config, { mediaViewer: hasMarkdownVideo('![Recording](/recording.mp4)') });
42
+ assert.match(bundle.urlMap['/edgepress/media-player-lib.js'], /\.[a-f0-9]{16}\.js$/);
43
+ config.site.dataSaver.enabled = true;
44
+ await configureDataSaver(config);
45
+ const gated = dataSaverHtml(rewriteAssetLinks(layout, bundle.urlMap), config, bundle.urlMap);
46
+ assert.match(gated, /data-edgepress-data-media data-data-feature="gallery"/);
47
+ assert.match(gated, /data-data-feature="gallery"[^>]*>.*?media-viewer\.[a-f0-9]{16}\.js/s);
48
+ assert.match(gated, /data-data-description>Recording/);
49
+ assert.match(gated, /<\/div><figcaption>A caption<\/figcaption>/);
50
+ });
51
+
52
+ test('inline local files use the shared player, wait for Play, seek, pause out of view and mount after text-view demand', { timeout: 60000 }, async () => {
53
+ const config = await loadConfig(process.cwd());
54
+ config.site.url = 'https://site.example';
55
+ await loadLanguagePacks(config);
56
+ const bundle = await collectAssets(config, { mediaViewer: true });
57
+ const assets = new Map(bundle.assets.map(asset => ['/' + asset.path, asset]));
58
+ const bytes = await readFile(new URL('./fixtures/recording.mp4', import.meta.url));
59
+ const body = reserveVideoMetadata(await renderMarkdown('前文**结果(XXX)**后文\n\n![Silent recording](/recording.mp4 "Recording")'),new Map([['/recording.mp4',{duration:12,width:320,height:180,poster:'/poster.svg'}]]),'index.html','https://site.example');
60
+ const script = '<script type="module" src="/edgepress/media-viewer.js"></script>';
61
+ const browser = await chromium.launch({ headless: true, args: ['--disable-extensions'] });
62
+ try {
63
+ for (const width of [320, 1280]) {
64
+ const context = await browser.newContext({ viewport: { width, height: 850 } });
65
+ const page = await context.newPage();
66
+ const requests = [], errors = [];
67
+ page.on('pageerror', error => errors.push(error.message));
68
+ const html = rewriteAssetLinks('<!doctype html><html lang="en"><head><style>body{margin:0}figure{margin:0}video{width:100%}</style><link rel="stylesheet" href="/edgepress/media-viewer.css"></head><body><main>' + body + '<div style="height:1600px"></div></main>' + script + '</body></html>', bundle.urlMap);
69
+ await page.route('https://site.example/**', async route => {
70
+ const request = route.request(), path = new URL(request.url()).pathname;
71
+ requests.push(path);
72
+ if (path === '/') return route.fulfill({ contentType: 'text/html', body: html });
73
+ if (path === '/poster.svg') return route.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180"><rect width="320" height="180" fill="green"/></svg>'});
74
+ if (path === '/recording.mp4') {
75
+ const range = request.headers().range?.match(/bytes=(\d+)-(\d*)/);
76
+ const start = range ? Number(range[1]) : 0;
77
+ const end = range?.[2] ? Math.min(Number(range[2]), bytes.length - 1) : bytes.length - 1;
78
+ return route.fulfill({ status: range ? 206 : 200, contentType: 'video/mp4', headers: { 'Accept-Ranges': 'bytes', ...(range ? { 'Content-Range': `bytes ${start}-${end}/${bytes.length}` } : {}) }, body: bytes.subarray(start, end + 1) });
79
+ }
80
+ const asset = assets.get(path);
81
+ return asset ? route.fulfill({ contentType: ({ '.js': 'text/javascript', '.css': 'text/css' })[extname(path)] || 'application/octet-stream', body: asset.content || await readFile(asset.source) }) : route.fulfill({ status: 404, body: '' });
82
+ });
83
+ await page.goto('https://site.example/');
84
+ const player = page.locator('.markdown-video .media-player');
85
+ await player.waitFor();
86
+ assert.equal(requests.includes('/recording.mp4'), false);
87
+ assert.equal(await player.locator('.plyr__time--duration').textContent(),'00:12');
88
+ await page.waitForFunction(()=>document.querySelector('.media-player').classList.contains('plyr__poster-enabled'));
89
+ await player.locator('video').evaluate(video => { video.muted = true; });
90
+ await player.locator('.plyr__control--overlaid').click();
91
+ await page.waitForFunction(() => document.querySelector('video').currentTime > 0.2);
92
+ const video = player.locator('video');
93
+ const duration = await video.evaluate(video => video.duration);
94
+ await video.evaluate(video => { video.pause(); });
95
+ await player.locator('input[data-plyr="seek"]').evaluate(input => { input.value = '50'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); });
96
+ await page.waitForFunction(duration => Math.abs(document.querySelector('video').currentTime - duration / 2) < 0.3, duration);
97
+ await player.locator('[data-plyr="play"]').last().click();
98
+ await page.evaluate(() => scrollTo(0, document.documentElement.scrollHeight));
99
+ await page.waitForFunction(() => document.querySelector('video').paused);
100
+ assert.ok(await video.evaluate(video => video.currentTime > 0));
101
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
102
+ assert.deepEqual(errors, []);
103
+ await context.close();
104
+ }
105
+ const context = await browser.newContext();
106
+ const page = await context.newPage();
107
+ await page.route('https://site.example/**', async route => {
108
+ const path = new URL(route.request().url()).pathname;
109
+ if (path === '/') return route.fulfill({ contentType: 'text/html', body: rewriteAssetLinks('<html><head></head><body><main><div id="target"></div></main>' + script + '</body></html>', bundle.urlMap) });
110
+ const asset = assets.get(path);
111
+ return asset ? route.fulfill({ contentType: extname(path) === '.css' ? 'text/css' : 'text/javascript', body: asset.content || await readFile(asset.source) }) : route.fulfill({ status: 404, body: '' });
112
+ });
113
+ await page.goto('https://site.example/');
114
+ assert.equal(await page.locator('.media-player').count(), 0);
115
+ await page.evaluate(body => { document.getElementById('target').innerHTML = body; document.dispatchEvent(new Event('edgepress:data-media')); }, body);
116
+ await page.locator('.media-player').waitFor();
117
+ await context.close();
118
+ } finally { await browser.close(); }
119
+ });
@@ -0,0 +1,102 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { extname } from 'node:path';
5
+ import { chromium } from 'playwright';
6
+ import { loadConfig } from '../src/config.js';
7
+ import { loadLanguagePacks } from '../src/i18n.js';
8
+ import { collectAssets, rewriteAssetLinks } from '../src/assets.js';
9
+ import { renderBlocks } from '../src/page-blocks.js';
10
+ import { renderMediaViewer } from '../src/media-viewer.js';
11
+ import { choiceSnapshot } from '../static/edgepress/plugins/consent/choices.js';
12
+
13
+ const block = { type: 'media-viewer', title: 'Local attachments', items: [
14
+ { type: 'image', src: '/first.svg', originalSrc: '/original.svg', alt: 'First attachment' },
15
+ { type: 'image', src: '/second.svg', alt: 'Second attachment' },
16
+ { type: 'video', src: '/recording.mp4', label: 'Silent test recording', poster: '/poster.svg', silent: true, duration: 5 }
17
+ ] };
18
+
19
+ async function configuration() {
20
+ const config = await loadConfig(process.cwd());
21
+ config.site.url = 'https://site.example';
22
+ await loadLanguagePacks(config);
23
+ return config;
24
+ }
25
+
26
+ test('Pages media blocks validate content, escape labels and keep every resource inert', async () => {
27
+ const config = await configuration();
28
+ const context = { config, locale: 'en' };
29
+ const html = await renderBlocks([{ columns: 1, cells: [[block]] }], context);
30
+ assert.match(html, /data-edgepress-media-viewer/);
31
+ assert.equal((html.match(/<template>/g) || []).length, 3);
32
+ assert.doesNotMatch(html, /<video[^>]*(?:\scontrols|\sautoplay|\ssrc=)/);
33
+ assert.match(renderMediaViewer({ ...block, title: '<script>bad</script>' }, context), /&lt;script&gt;/);
34
+ for (const item of [{ type: 'image', src: 'javascript:alert(1)', alt: 'Unsafe' }, { type: 'image', src: '/first.svg', alt: '' }, { type: 'video', src: '/recording.mp4', label: 'No captions' }]) assert.throws(() => renderMediaViewer({ ...block, items: [item] }, context));
35
+ const remote = { ...block, service: 'media', items: [{ type: 'image', src: 'https://files.example/first.svg', alt: 'Consented image' }] };
36
+ assert.throws(() => renderMediaViewer(remote, context), /registered external-api/);
37
+ config.browserPlugins.services = [{ id: 'media', provider: 'external-api', backendUrl: 'https://files.example', enabled: true, purpose: 'Preview selected media', csp: { 'img-src': ['https://files.example'] } }];
38
+ assert.match(renderMediaViewer(remote, context), /data-media-service="media"/);
39
+ assert.throws(() => renderMediaViewer({ ...remote, items: [{ ...remote.items[0], originalSrc: 'https://unapproved.example/original.svg' }] }, context), /CSP origin/);
40
+ config.browserPlugins.services[0].enabled = false;
41
+ assert.doesNotMatch(renderMediaViewer(remote, context), /data-edgepress-media-viewer|<template>/);
42
+ });
43
+
44
+ test('media blocks reuse hashed local components, select only one attachment and wait for play and consent', { timeout: 60000 }, async () => {
45
+ const config = await configuration();
46
+ const bundle = await collectAssets(config, { mediaViewer: true });
47
+ const assets = new Map(bundle.assets.map(asset => ['/' + asset.path, asset]));
48
+ for (const name of ['media-viewer.js', 'media-gallery-lib.js', 'media-player-lib.js', 'media-player.css']) assert.match(bundle.urlMap['/edgepress/' + name], /\.[a-f0-9]{16}\.(?:js|css)$/);
49
+ const remote = renderMediaViewer({ ...block, service: 'media', items: [{ type: 'image', src: 'https://files.example/remote.svg', alt: 'Consented image' }] }, { config: { ...config, browserPlugins: { services: [{ id: 'media', provider: 'external-api', backendUrl: 'https://files.example', purpose: 'Preview', csp: { 'img-src': ['https://files.example'] } }] } }, locale: 'en' });
50
+ const settings = { privacy: { consent: { expiresDays: 30 }, integrations: [{ id: 'media', provider: 'external-api', backendUrl: 'https://files.example', enabled: true }] } };
51
+ settings.choiceFingerprint = settings.choiceSnapshot = choiceSnapshot(settings.privacy);
52
+ const html = rewriteAssetLinks('<!doctype html><html lang="en"><head><title>Media block</title><link rel="stylesheet" href="/edgepress/media-viewer.css"><link rel="stylesheet" href="/edgepress/image-viewer.css"></head><body><main>' + renderMediaViewer(block, { config, locale: 'en' }) + remote + '</main><script id="edgepress-privacy-config" type="application/json">' + JSON.stringify(settings) + '</script><script type="module" src="/edgepress/media-viewer.js"></script></body></html>', bundle.urlMap);
53
+ const browser = await chromium.launch({ headless: true, args: ['--disable-extensions'] });
54
+ try {
55
+ for (const width of [320, 1280]) {
56
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
57
+ const requests = [];
58
+ const errors = [];
59
+ page.on('pageerror', error => errors.push(error.message));
60
+ await page.route('**/*', async route => {
61
+ const url = new URL(route.request().url());
62
+ requests.push(url.pathname);
63
+ if (url.pathname === '/') return route.fulfill({ contentType: 'text/html', body: html });
64
+ if (url.pathname.endsWith('.svg')) return route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400"><rect width="600" height="400" fill="teal"/></svg>' });
65
+ if (url.pathname === '/recording.mp4') return route.fulfill({ contentType: 'video/mp4', body: await readFile(new URL('./fixtures/recording.mp4', import.meta.url)) });
66
+ const asset = assets.get(url.pathname);
67
+ const types = { '.js': 'text/javascript', '.css': 'text/css' };
68
+ return asset ? route.fulfill({ contentType: types[extname(url.pathname)] || 'application/octet-stream', body: asset.content || await readFile(asset.source) }) : route.fulfill({ status: 404, body: '' });
69
+ });
70
+ await page.goto('https://site.example/');
71
+ const local = page.locator('[data-edgepress-media-viewer]').first();
72
+ await local.locator('.gallery-stage').getByRole('button').waitFor();
73
+ assert.ok(requests.includes('/first.svg'));
74
+ assert.ok(!requests.includes('/second.svg') && !requests.includes('/original.svg') && !requests.includes('/remote.svg'));
75
+ assert.ok(!requests.some(path => /media-player-lib|recording\.mp4/.test(path)));
76
+ await local.locator('.gallery-stage').getByRole('button').click();
77
+ await page.locator('.image-lightbox img').waitFor();
78
+ assert.ok(requests.includes('/original.svg'));
79
+ await page.keyboard.press('Escape');
80
+ await local.locator('[data-next]').click();
81
+ await local.locator('.gallery-stage img').waitFor();
82
+ await local.locator('[data-next]').click();
83
+ await local.locator('.plyr').waitFor();
84
+ assert.ok(!requests.includes('/recording.mp4'));
85
+ await local.locator('.plyr__control--overlaid').click();
86
+ await page.waitForFunction(() => !!document.querySelector('video[src]'));
87
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
88
+ await page.evaluate(async choiceUrl => {
89
+ const { makeChoice, persistChoice } = await import(choiceUrl);
90
+ const settings = JSON.parse(document.getElementById('edgepress-privacy-config').textContent);
91
+ persistChoice(makeChoice(settings, ['media']));
92
+ document.dispatchEvent(new CustomEvent('edgepress:service-ready', { detail: { id: 'media' } }));
93
+ }, bundle.urlMap['/edgepress/plugins/consent/choices.js']);
94
+ await page.locator('[data-media-service] .gallery-stage img').waitFor();
95
+ assert.ok(requests.includes('/remote.svg'));
96
+ await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); dispatchEvent(new Event('storage')); });
97
+ assert.equal(await page.locator('[data-media-service] .gallery-stage img').count(), 0);
98
+ assert.deepEqual(errors, []);
99
+ await page.close();
100
+ }
101
+ } finally { await browser.close(); }
102
+ });
@@ -0,0 +1,87 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFile,readdir} from 'node:fs/promises';
4
+ import {resolve} from 'node:path';
5
+ import {fileURLToPath} from 'node:url';
6
+ import {chromium} from 'playwright';
7
+ import {loadConfig} from '../src/config.js';
8
+ import {loadLanguagePacks} from '../src/i18n.js';
9
+ import {renderLayout} from '../src/theme.js';
10
+
11
+ const root=fileURLToPath(new URL('../',import.meta.url));
12
+ const extensions={filter:async(_key,value)=>value};
13
+ test('navigation and footer keep external URLs and new-tab targets, including dropdown choices',async()=>{
14
+ const config=await fixture('default');
15
+ config.site.navigation[0].children.push({key:'share',url:'https://share.js.gripe',target:'_blank',labels:{en:'ishare'}});
16
+ config.site.footerNavigation=[{key:'share',url:'https://share.js.gripe',target:'_blank',labels:{en:'ishare'}}];
17
+ const html=await renderLayout(config,extensions,{locale:'zh-CN',urlPath:'/zh-CN/'},'<h1>Fixture</h1>');
18
+ assert.match(html,/<option value="https:\/\/share.js.gripe" data-target="_blank">ishare/);
19
+ assert.match(html,/<a href="https:\/\/share.js.gripe" target="_blank" rel="noopener noreferrer">ishare<\/a>/);
20
+ assert.doesNotMatch(html,/\/zh-CN\/https:/);
21
+ });
22
+ async function fixture(theme){
23
+ const config=await loadConfig(root);
24
+ await loadLanguagePacks(config);
25
+ config.site.url='https://navigation.invalid';
26
+ config.site.navigation=[{key:'projects',url:'/projects/',labels:{en:'Projects'},children:[{key:'edgepress',url:'/edgepress/',labels:{en:'EdgePress'}}]}];
27
+ config.resolvedPaths.theme=config.realPaths.theme=resolve(root,'themes',theme);
28
+ return config;
29
+ }
30
+ test('navigation uses a disabled display placeholder and preserves actual parent/child links',async()=>{
31
+ const config=await fixture('default');
32
+ const html=await renderLayout(config,extensions,{locale:'en',title:'Fixture',urlPath:'/edgepress/guide/'},'<h1>Fixture</h1>');
33
+ assert.match(html,/<option value="" selected disabled hidden>Projects<\/option>/);
34
+ assert.match(html,/<option value="\/projects\/">Projects<\/option>/);
35
+ assert.match(html,/<option value="\/edgepress\/">EdgePress<\/option>/);
36
+ assert.match(html,/<noscript><a href="\/projects\/">Projects<\/a><a href="\/edgepress\/">EdgePress<\/a><\/noscript>/);
37
+ });
38
+ test('keyboard parent selection, child selection, return navigation and no-JS links across themes/locales',async t=>{
39
+ let browser;
40
+ try{browser=await chromium.launch({headless:true,...(process.platform==='win32'?{channel:'msedge'}:{}),args:['--mute-audio','--disable-notifications']});}
41
+ catch(error){t.skip('Headless browser unavailable: '+error.message.split('\n')[0]);return;}
42
+ try{
43
+ let cases=0;
44
+ const themes=(await readdir(resolve(root,'themes'),{withFileTypes:true})).filter(item=>item.isDirectory()).map(item=>item.name);
45
+ for(const theme of themes){
46
+ const config=await fixture(theme);
47
+ for(const locale of config.i18n.locales) for(const width of [390,1440]) for(const colorScheme of ['light','dark']){
48
+ const context=await browser.newContext({viewport:{width,height:900},colorScheme});
49
+ const prefix=locale===config.i18n.defaultLocale?'/':'/'+locale+'/';
50
+ await context.route('**/*',async route=>{
51
+ const url=new URL(route.request().url());
52
+ if(url.origin!==config.site.url)return route.abort();
53
+ if(url.pathname==='/style.css')return route.fulfill({contentType:'text/css',body:await readFile(resolve(root,'themes',theme,'assets/style.css'),'utf8')});
54
+ if(/^\/edgepress\/[a-z0-9./-]+\.(js|png)$/i.test(url.pathname)&&!url.pathname.includes('..')){
55
+ try{return route.fulfill({body:await readFile(resolve(root,'static',url.pathname.slice(1))),contentType:url.pathname.endsWith('.js')?'text/javascript':'image/png'});}catch{return route.fulfill({status:404,body:''});}
56
+ }
57
+ return route.fulfill({contentType:'text/html',body:await renderLayout(config,extensions,{locale,title:'Fixture',urlPath:url.pathname},'<h1>Fixture</h1>')});
58
+ });
59
+ const page=await context.newPage();
60
+ await page.goto(config.site.url+prefix+'archives/');
61
+ const selector=page.locator('[data-navigation-select]').first();
62
+ await selector.waitFor({state:'visible'});
63
+ assert.equal(await selector.inputValue(),'');
64
+ await selector.focus();
65
+ assert.ok(await selector.evaluate(node=>parseFloat(getComputedStyle(node).outlineWidth)>=2));
66
+ await Promise.all([page.waitForURL(config.site.url+prefix+'projects/'),page.keyboard.press('ArrowDown')]);
67
+ await Promise.all([page.waitForURL(config.site.url+prefix+'edgepress/'),selector.selectOption(prefix+'edgepress/')]);
68
+ assert.equal(await selector.inputValue(),'');
69
+ await Promise.all([page.waitForURL(config.site.url+prefix+'projects/'),selector.selectOption(prefix+'projects/')]);
70
+ await page.goBack();
71
+ assert.equal(await selector.inputValue(),'');
72
+ assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));
73
+ await context.close();cases++;
74
+ }
75
+ const context=await browser.newContext({javaScriptEnabled:false});
76
+ await context.route('**/*',async route=>{
77
+ if(route.request().resourceType()!=='document')return route.abort();
78
+ return route.fulfill({contentType:'text/html',body:await renderLayout(config,extensions,{locale:'en',title:'Fixture',urlPath:'/'},'<h1>Fixture</h1>')});
79
+ });
80
+ const page=await context.newPage();await page.goto(config.site.url);
81
+ assert.ok(await page.locator('noscript a[href="/projects/"]').isVisible());
82
+ assert.ok(await page.locator('noscript a[href="/edgepress/"]').isVisible());
83
+ await context.close();
84
+ }
85
+ assert.ok(cases>=themes.length*8);
86
+ }finally{await browser.close();}
87
+ });
@@ -0,0 +1,98 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
+ import { chromium } from 'playwright';
6
+ import { loadConfig } from '../src/config.js';
7
+ import { loadLanguagePacks } from '../src/i18n.js';
8
+ import { renderEmbed } from '../src/embed.js';
9
+ import { configureDataSaver, dataSaverHtml, dataSaverLayout } from '../src/data-saver.js';
10
+
11
+ test('embed entry stays compact until requested, retries failures and keeps keyboard focus without preconsent requests', async () => {
12
+ const config = await loadConfig(process.cwd());
13
+ await loadLanguagePacks(config);
14
+ config.site.dataSaver = { enabled:true, mode:'text', debug:false, detectSlowConnection:false, respectBrowserPreference:true };
15
+ await configureDataSaver(config);
16
+ const origin = 'https://embed-layout.test';
17
+ const service = { id: 'shared-media', provider: 'oembed', enabled: true, name: 'Shared media', backendUrl: 'https://media.example.test' };
18
+ config.browserPlugins.services = [service];
19
+ const block = await renderEmbed({ integration: service.id, url: service.backendUrl + '/s/example', title: 'A meaningful shared story' }, { config, locale: 'en' });
20
+ const browser = await chromium.launch({ headless: true, args: ['--disable-extensions'] });
21
+ try {
22
+ for (const theme of ['default', 'folio', 'signal']) for (const colorScheme of ['light', 'dark']) for (const mode of ['full','text']) {
23
+ const context = await browser.newContext({ viewport: { width: 320, height: 900 }, colorScheme, reducedMotion: 'reduce' });
24
+ let calls = 0, requests = 0;
25
+ const themeStyle = await readFile(resolve('themes',theme,'assets/style.css'));
26
+ const textVariant = await readFile(resolve(config.resolvedPaths.theme,'assets',config.dataSaverTheme.stylesheet));
27
+ const textLayout = await dataSaverLayout(config,[{path:'style.css',content:themeStyle},{path:config.dataSaverTheme.stylesheet,content:textVariant}]);
28
+ await context.route('**/*', async route => {
29
+ const url = new URL(route.request().url());
30
+ if (url.origin === service.backendUrl) {
31
+ requests++;
32
+ if (url.pathname === '/api') {
33
+ calls++;
34
+ await new Promise(resolve => setTimeout(resolve, 250));
35
+ if (calls === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: '{}' });
36
+ return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ version: '1.0', type: 'rich', width: 640, height: 360, title: 'Shared story', html: '<iframe src="' + service.backendUrl + '/embed/example"></iframe>' }) });
37
+ }
38
+ return route.fulfill({ contentType: 'text/html', body: '<p>Shared story content</p>' });
39
+ }
40
+ assert.equal(url.origin, origin);
41
+ if (url.pathname.endsWith('/choices.js')) return route.fulfill({ contentType: 'text/javascript', body: 'export function readChoice(){return {allowed:window.testAllowed?["shared-media"]:[]}}' });
42
+ if (url.pathname === '/style.css') return route.fulfill({ contentType: 'text/css', body: await readFile(resolve('themes', theme, 'assets/style.css')) });
43
+ if (url.pathname === '/edgepress/data-saver-layout.css') return route.fulfill({contentType:'text/css',body:textLayout.content});
44
+ if (url.pathname === '/data-saver-theme.css') return route.fulfill({contentType:'text/css',body:await readFile(resolve('themes',theme === 'signal' ? 'folio' : theme,'assets/data-saver-theme.css'))});
45
+ if (url.pathname.startsWith('/edgepress/')) return route.fulfill({ contentType: url.pathname.endsWith('.js') ? 'text/javascript' : 'text/css', body: await readFile(resolve('static', '.' + url.pathname.replace('.aaaaaaaaaaaaaaaa',''))) });
46
+ const html = '<!doctype html><html><head><link rel="stylesheet" href="/style.css"></head><body><main>' + block + '</main><script id="edgepress-privacy-config" type="application/json">' + JSON.stringify({ privacy: { integrations: [service] }, ui: { embedNeedsConsent: 'Allow {service} to load media.', embedLoading: 'Loading {service}…', embedUnavailable: 'Media unavailable. Try again.', embedLoaded: 'Loaded {service}.' } }) + '</script><script type="module" src="/edgepress/oembed.aaaaaaaaaaaaaaaa.js"></script></body></html>';
47
+ return route.fulfill({ contentType:'text/html', body:mode === 'text' ? dataSaverHtml(html,config) : html });
48
+ });
49
+ const page = await context.newPage();
50
+ const errors = [];
51
+ page.on('pageerror', error => errors.push(error.message));
52
+ await page.goto(origin);
53
+ if (mode === 'text') await page.waitForFunction(()=>document.documentElement.dataset.edgepressDataReady === 'true');
54
+ // Full mode loads optional scripts after the first animation frame; do not
55
+ // click until the oEmbed module has finished attaching its handlers.
56
+ if (mode === 'full') await page.waitForFunction(() => Boolean(document.querySelector('[data-edgepress-oembed]')?.style.getPropertyValue('--oembed-ratio')));
57
+ await page.evaluate(() => document.addEventListener('edgepress:privacy-open', () => window.testOpened = (window.testOpened || 0) + 1));
58
+ const entry = page.locator('[data-edgepress-oembed]');
59
+ const button = entry.locator('[data-oembed-load]');
60
+ assert.equal(await entry.locator('[data-oembed-status]').evaluate(element => element.getBoundingClientRect().height), 0);
61
+ assert((await entry.boundingBox()).height < 300);
62
+ assert.equal(requests, 0);
63
+ await button.focus(); await page.keyboard.press('Enter');
64
+ // This is an event counter, so deferred text-view styles must not make
65
+ // the assertion depend on the browser scheduling animation frames.
66
+ await page.waitForFunction(() => window.testOpened > 0, null, { polling: 50 });
67
+ assert.equal(await page.evaluate(()=>window.testOpened),1);
68
+ assert.equal(requests, 0);
69
+ assert.equal(await button.isEnabled(), true);
70
+ await page.evaluate(() => { window.testAllowed = true; });
71
+ await button.focus(); await page.keyboard.press('Enter');
72
+ await page.waitForFunction(() => document.querySelector('[data-edgepress-oembed]').getAttribute('aria-busy') === 'true');
73
+ assert.equal(await button.isEnabled(), false);
74
+ const pendingBounds = await entry.locator('[data-oembed-placeholder]').boundingBox();
75
+ assert(mode === 'text' ? pendingBounds === null : pendingBounds.height <= 60);
76
+ await entry.locator('[data-oembed-notice]').getByText('Media unavailable. Try again.', { exact: true }).waitFor();
77
+ await page.waitForFunction(() => !document.querySelector('[data-oembed-load]').disabled);
78
+ assert.equal(await button.isEnabled(), true);
79
+ assert.equal(await entry.locator('[data-oembed-status]').evaluate(element => element.getBoundingClientRect().height), 0);
80
+ await page.evaluate(()=>document.dispatchEvent(new CustomEvent('edgepress:service-ready',{detail:{id:'shared-media'}})));
81
+ await page.waitForTimeout(300);
82
+ assert.equal(calls,1);
83
+ assert.equal(await entry.getAttribute('data-oembed-unavailable'),'true');
84
+ await button.focus(); await page.keyboard.press('Enter');
85
+ await page.waitForFunction(() => document.querySelector('[data-edgepress-oembed]').dataset.loaded === 'true');
86
+ await page.frameLocator('iframe').getByText('Shared story content').waitFor();
87
+ assert.equal(calls, 2);
88
+ assert.equal(requests, 3);
89
+ assert.equal(await page.evaluate(() => document.activeElement.textContent), 'A meaningful shared story');
90
+ assert.equal(await entry.getAttribute('aria-busy'), 'false');
91
+ assert.equal(await button.count(), 0);
92
+ assert.equal(await entry.locator('[data-oembed-notice]').isVisible(), false);
93
+ assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
94
+ assert.deepEqual(errors, []);
95
+ await context.close();
96
+ }
97
+ } finally { await browser.close(); }
98
+ });
@@ -0,0 +1,96 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFile} from 'node:fs/promises';
4
+ import {fileURLToPath} from 'node:url';
5
+ import {resolve} from 'node:path';
6
+ import {chromium} from 'playwright';
7
+ import {loadConfig} from '../src/config.js';
8
+ import {loadLanguagePacks} from '../src/i18n.js';
9
+ import {renderBlocks} from '../src/page-blocks.js';
10
+ import consentManager from '../plugins/consent/index.js';
11
+ import {extendConsentPolicy} from '../src/consent-csp.js';
12
+ import {resolveEmbed,sanitizeEmbed} from '../src/oembed.js';
13
+ import {readDocuments} from '../src/content.js';
14
+ import {generateBuiltinRoutes} from '../src/generators.js';
15
+ import {createExtensions} from '../src/plugin-api.js';
16
+ import {mkdtemp,rm} from 'node:fs/promises';
17
+ import {tmpdir} from 'node:os';
18
+ const root=fileURLToPath(new URL('../',import.meta.url));
19
+ test('posts use the same consent-controlled oEmbed blocks as Pages',async()=>{
20
+ const config=await loadConfig(root);await loadLanguagePacks(config);const service='https://ishare.example';config.browserPlugins.services=[{id:'ishare',provider:'oembed',backendUrl:service,enabled:true,name:'ishare',purpose:'Load the shared post.',dataCategories:'Requested media and IP address.',recipient:'ishare',retention:'Until deletion.',privacyUrl:service+'/privacy/'}];
21
+ const document=(await readDocuments(config)).find(item=>item.kind==='posts'&&item.locale==='en');assert.ok(document);const extensions=await createExtensions(config);const post={...document,html:await extensions.renderer('.md').render('A seaside field note.\n\n!embed[ishare]('+service+'/s/'+'a'.repeat(32)+')',{config,document})};
22
+ const routes=await generateBuiltinRoutes({posts:[post],pages:[]},config,await createExtensions(config));const page=routes.find(route=>route.path===post.path+'index.html');assert.ok(page);assert.match(page.body,/A seaside field note/);assert.match(page.body,/data-edgepress-oembed="ishare"/);assert.doesNotMatch(page.body,/<iframe/);
23
+ });
24
+ test('Markdown embed syntax keeps the post body as the source of truth',async()=>{
25
+ const config=await loadConfig(root);await loadLanguagePacks(config);config.browserPlugins.services=[{id:'ishare',provider:'oembed',backendUrl:'https://ishare.example',sourceOrigins:['https://ishare.example'],enabled:true,name:'ishare'}];
26
+ const extensions=await createExtensions(config),renderer=extensions.renderer('.md');
27
+ const html=await renderer.render('Before\n\n!embed[ishare](https://ishare.example/s/'+'a'.repeat(32)+')\n\nAfter',{config,document:{locale:'en'}});
28
+ assert.match(html,/data-edgepress-oembed="ishare"/);assert.match(html,/data-oembed-placeholder/);assert.doesNotMatch(html,/<iframe/);
29
+ const ordinary=await renderer.render('![A photo](https://ishare.example/photo.jpg)',{config,document:{locale:'en'}});assert.match(ordinary,/<img/);assert.doesNotMatch(ordinary,/data-edgepress-oembed/);
30
+ });
31
+ test('oEmbed automatically loads visible content after consent and on return, and strips unsafe vendor HTML',async()=>{
32
+ const config=await loadConfig(root),origin=new URL(config.site.url).origin,service='https://share.js.gripe';
33
+ config.browserPlugins.services=[{id:'ishare',provider:'oembed',backendUrl:service,enabled:true,name:'ishare',purpose:'Load shared images and videos.',dataCategories:'IP address and requested media.',recipient:'ishare',retention:'Until publisher deletion.',privacyUrl:service+'/privacy.html'}];
34
+ const rows=[{columns:1,cells:[[{type:'oembed',integration:'ishare',url:service+'/s/'+'a'.repeat(32),title:'Shared image'}]]}];
35
+ const blocks=await renderBlocks(rows,{config,locale:'en'});assert.match(blocks,/data-edgepress-oembed/);assert.doesNotMatch(blocks,/<iframe/);
36
+ await assert.rejects(renderBlocks([{columns:1,cells:[[{type:'oembed',integration:'ishare',url:'https://evil.example/a'}]]}],{config,locale:'en'}),/origin/);
37
+ const policy=extendConsentPolicy('/*\n Content-Security-Policy: default-src \'self\'; connect-src \'none\'',config);assert.match(policy,/frame-src 'self' https:\/\/share.js.gripe/);assert.match(policy,/img-src 'self' https:\/\/share.js.gripe/);
38
+ let filter;consentManager({registerFilter:(_name,fn)=>{filter=fn;}});
39
+ const browser=await chromium.launch({headless:true,args:['--disable-extensions']});
40
+ try{
41
+ const context=await browser.newContext();let calls=0,frames=0;
42
+ await context.route('**/*',async route=>{const url=new URL(route.request().url());
43
+ if(url.origin===service){if(url.pathname==='/api'){calls++;assert.equal(route.request().headers()['x-service-action'],'oembed');assert.equal(decodeURIComponent(route.request().headers()['x-service-resource']),service+'/s/'+'a'.repeat(32));return route.fulfill({contentType:'application/json',body:JSON.stringify({version:'1.0',type:'rich',title:'Image',width:640,height:480,html:'<iframe src="'+service+'/embed/'+'a'.repeat(32)+'" onload="window.bad=true" style="position:fixed"></iframe>'})});}frames++;return route.fulfill({contentType:'text/html',body:'Author / source<script>window.addEventListener("message",event=>{if(event.origin==="'+origin+'"&&event.data.type==="edgepress:embed-theme")window.hostTheme=event.data.colors;});window.parent.postMessage({type:"edgepress:embed-ready"},"'+origin+'");window.parent.postMessage({type:"edgepress:embed-size",height:555},"'+origin+'");</script>'});}
44
+ if(url.origin!==origin)throw Error('Unexpected external request '+url.href);
45
+ if(url.pathname.startsWith('/edgepress/')){try{return route.fulfill({contentType:url.pathname.endsWith('.js')?'text/javascript':'image/svg+xml',body:await readFile(resolve(root,'static',url.pathname.slice(1)))});}catch{return route.fulfill({status:404,body:''});}}
46
+ return route.fulfill({contentType:'text/html',body:filter('<!doctype html><html style="--accent:#7242b4"><body><main>'+blocks+'</main><script type="module" src="/edgepress/oembed.js"></script></body></html>',{config,page:{locale:'en'}})});
47
+ });
48
+ const page=await context.newPage();await page.goto(origin+'/embedding/');await page.locator('.privacy-panel').waitFor({state:'visible'});assert.equal(calls,0);
49
+ await page.locator('input[value=ishare]').check();assert.equal(calls,0);await page.locator('.privacy-save').click();
50
+ await page.locator('[data-oembed-status] iframe').waitFor();assert.equal(calls,1);assert.equal(await page.locator('.edgepress-oembed a').count(),1);assert.equal(await page.locator('.edgepress-oembed figcaption').innerText(),'Shared image');assert.equal(await page.locator('[data-oembed-notice]').isVisible(),false);assert.equal(await page.locator('[data-oembed-load]').count(),0);assert.equal(await page.locator('iframe').getAttribute('onload'),null);assert.equal(await page.locator('iframe').getAttribute('sandbox'),'allow-scripts allow-same-origin allow-presentation');
51
+ await page.reload();await page.locator('[data-oembed-status] iframe').waitFor();assert.equal(calls,2);
52
+ await page.waitForFunction(()=>document.querySelector('iframe').style.height==='555px');const child=page.frames().find(frame=>frame.url().startsWith(service));await child.waitForFunction(()=>window.hostTheme?.['--accent']==='#7242b4');
53
+ await page.evaluate(service=>window.dispatchEvent(new MessageEvent('message',{origin:service,source:window,data:{type:'edgepress:embed-size',height:3000}})),service);assert.equal(await page.locator('iframe').evaluate(frame=>frame.style.height),'555px');
54
+ const sanitized=await page.evaluate(async()=>{const {trustedEmbed}=await import('/edgepress/oembed.js');for(const html of ['<iframe src="https://evil.example/a"></iframe>','<script>alert(1)</script>','<iframe src="https://share.js.gripe/embed/a"></iframe><img src="https://evil.example/a">']){try{trustedEmbed({version:'1.0',type:'rich',width:640,height:480,html},'https://share.js.gripe',document);return false;}catch{}}return true;});assert.equal(sanitized,true);
55
+ if(!await page.locator('.privacy-panel').isVisible()){await page.locator('.privacy-settings-button').focus();await page.keyboard.press('Enter');}await page.locator('.privacy-panel').waitFor({state:'visible'});await page.locator('input[value=ishare]').uncheck();await Promise.all([page.waitForEvent('load'),page.locator('.privacy-save').click()]);await page.locator('[data-oembed-load]').waitFor();assert.equal(await page.locator('[data-oembed-notice]').isVisible(),true);assert.equal(calls,2);assert.equal(await page.locator('iframe').count(),0);
56
+ await context.close();
57
+ }finally{await browser.close();}
58
+ });
59
+
60
+ test('third-party metadata is cached, mixed Pages cells wait for consent, and YouTube/X/TikTok render repeatedly on demand',async()=>{
61
+ const directory=await mkdtemp(resolve(tmpdir(),'edgepress-oembed-'));
62
+ const config=await loadConfig(root),origin=new URL(config.site.url).origin;
63
+ const youtube=config.browserPlugins.services.find(s=>s.id==='youtube'),x=config.browserPlugins.services.find(s=>s.id==='x-posts'),tiktok={...structuredClone(youtube),id:'custom-video-fixture',name:'Custom video fixture',backendUrl:'https://www.tiktok.com',oembedEndpoint:'https://www.tiktok.com/oembed',sourceOrigins:['https://www.tiktok.com'],embedOrigins:['https://www.tiktok.com'],embedScripts:['https://www.tiktok.com/embed.js']};
64
+ await loadLanguagePacks(config);config.browserPlugins.services=[youtube,x,tiktok];config.resolvedPaths.cache=directory;
65
+ const data={version:'1.0',type:'video',width:640,height:360,title:'Video',html:'<iframe src="https://www.youtube.com/embed/jNQXAC9IVRw" onload="window.bad=true"></iframe><script>window.bad=true</script>'};
66
+ let lookups=0;const fetcher=async()=>{lookups++;return new Response(JSON.stringify(data));};
67
+ let browser;
68
+ try{
69
+ await resolveEmbed(youtube,'https://www.youtube.com/watch?v=jNQXAC9IVRw',config,fetcher);
70
+ assert.equal((await resolveEmbed(youtube,'https://www.youtube.com/watch?v=jNQXAC9IVRw',config,fetcher)).type,'video');assert.equal(lookups,1);
71
+ const tweet={version:'1.0',type:'rich',width:550,height:null,html:'<blockquote class="twitter-tweet"><p>Shared text</p><a href="https://x.com/user/status/123">Original</a></blockquote><script src="https://evil.example/a.js"></script>'};
72
+ const oldFetch=globalThis.fetch;globalThis.fetch=async address=>new Response(JSON.stringify(String(address).includes('tiktok')?{version:'1.0',type:'rich',width:325,height:700,html:'<blockquote class="tiktok-embed" data-video-id="123"><section>Video</section></blockquote>'}:tweet));
73
+ let blocks;try{blocks=await renderBlocks([{columns:2,cells:[[{type:'text',heading:'Context',text:'Text beside media'},{type:'oembed',integration:'x-posts',url:'https://x.com/user/status/123'},{type:'oembed',integration:'custom-video-fixture',url:'https://www.tiktok.com/@user/video/123'},{type:'oembed',integration:'custom-video-fixture',url:'https://www.tiktok.com/@user/video/124'}],[{type:'oembed',integration:'youtube',url:'https://www.youtube.com/watch?v=jNQXAC9IVRw',caption:'Video description'}]]}],{config,locale:'en'});}finally{globalThis.fetch=oldFetch;}
74
+ assert.match(blocks,/data-oembed-data/);assert.doesNotMatch(blocks,/<iframe|<script/);assert.match(blocks,/Video description/);
75
+ assert.doesNotMatch(sanitizeEmbed(data,youtube).html,/onload|script/);
76
+ let filter;consentManager({registerFilter:(_n,fn)=>filter=fn});
77
+ browser=await chromium.launch({headless:true,args:['--disable-extensions']});const context=await browser.newContext({viewport:{width:390,height:844}});let vendor=0;
78
+ await context.route('**/*',async route=>{
79
+ const url=new URL(route.request().url());
80
+ if(url.origin==='https://www.youtube.com'){assert.equal(route.request().headers().referer,origin+'/');vendor++;return route.fulfill({contentType:'text/html',body:'Player'});}
81
+ if(url.origin==='https://www.tiktok.com'){vendor++;return route.fulfill({contentType:'text/javascript',body:'globalThis.tiktokRuns=(globalThis.tiktokRuns||0)+1;document.querySelectorAll("blockquote.tiktok-embed").forEach(block=>block.dataset.tiktokRendered="true");'});}
82
+ if(url.origin==='https://platform.x.com'){vendor++;return route.fulfill({contentType:'text/javascript',body:'globalThis.twttr={widgets:{load(root){const block=root.querySelector(".twitter-tweet");if(block)block.dataset.rendered="true"}}};'});}
83
+ assert.equal(url.origin,origin,'No unconfigured origin is contacted');
84
+ if(url.pathname.startsWith('/edgepress/')){try{return route.fulfill({body:await readFile(resolve(root,'static',url.pathname.slice(1))),contentType:url.pathname.endsWith('.js')?'text/javascript':'image/svg+xml'});}catch{return route.fulfill({status:404,body:''});}}
85
+ return route.fulfill({contentType:'text/html',body:filter('<!doctype html><html><head><style>*{box-sizing:border-box}.page-row{display:grid;grid-template-columns:1fr}.page-cell{min-width:0}body{margin:8px}</style></head><body><main>'+blocks+'</main><script type="module" src="/edgepress/oembed.js"></script></body></html>',{config,page:{locale:'en'}})});
86
+ });
87
+ const page=await context.newPage();await page.goto(origin+'/mixed/');await page.locator('.privacy-panel').waitFor({state:'visible'});assert.equal(vendor,0);assert.match(await page.locator('[data-edgepress-oembed=youtube] [data-oembed-notice]').innerText(),/YouTube.*unloaded/i);
88
+ await page.locator('input[value=youtube]').check();await page.locator('input[value=x-posts]').check();await page.locator('input[value=custom-video-fixture]').check();assert.equal(vendor,0);await page.locator('.privacy-save').click();
89
+ for(const root of await page.locator('[data-edgepress-oembed]').all()){await root.scrollIntoViewIfNeeded();await page.waitForFunction(node=>node.dataset.loaded==='true',await root.elementHandle());}
90
+ await page.locator('iframe').scrollIntoViewIfNeeded();await page.frameLocator('iframe').getByText('Player',{exact:true}).waitFor();await page.locator('.twitter-tweet[data-rendered=true]').waitFor();await page.locator('blockquote[data-tiktok-rendered=true]').nth(1).waitFor();assert.ok(vendor>=3&&vendor<=4);assert.equal(await page.evaluate(()=>window.tiktokRuns),2);assert.equal(await page.evaluate(()=>!!window.bad),false);assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
91
+ await page.reload();for(const root of await page.locator('[data-edgepress-oembed]').all()){await root.scrollIntoViewIfNeeded();await page.waitForFunction(node=>node.dataset.loaded==='true',await root.elementHandle());}await page.locator('blockquote[data-tiktok-rendered=true]').nth(1).waitFor();await page.locator('iframe').scrollIntoViewIfNeeded();await page.frameLocator('iframe').getByText('Player',{exact:true}).waitFor();assert.ok(vendor>=6&&vendor<=8);assert.equal(await page.evaluate(()=>window.tiktokRuns),2);await context.close();
92
+ youtube.enabled=false;x.enabled=false;assert.equal(await renderBlocks([{columns:1,cells:[[{type:'oembed',integration:'youtube',url:'https://www.youtube.com/watch?v=jNQXAC9IVRw'}]]}],{config,locale:'en'}).then(html=>html.includes('data-edgepress-oembed')),false);
93
+ assert.match(await renderBlocks([{columns:1,cells:[[{type:'oembed',integration:'youtube',url:'https://www.youtube.com/watch?v=jNQXAC9IVRw'}]]}],{config,locale:'en'}),/disabled|turned off/i);
94
+ assert.equal(extendConsentPolicy('/*\n Content-Security-Policy: default-src \'self\'',config).includes('youtube'),false);
95
+ }finally{if(browser)await browser.close();await rm(directory,{recursive:true,force:true});}
96
+ });
@@ -0,0 +1,27 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {readFile} from 'node:fs/promises';
4
+ import {renderBlocks} from '../src/page-blocks.js';
5
+ import {loadConfig} from '../src/config.js';
6
+ import {loadLanguagePacks} from '../src/i18n.js';
7
+
8
+ async function context(){const config=await loadConfig(process.cwd());await loadLanguagePacks(config);return {config,locale:'en',site:{posts:[],pages:[]}};}
9
+
10
+ test('the published JSON example parses and renders with the same ordered containment',async()=>{
11
+ const guide=JSON.parse(await readFile('content/pages/page-authoring/en.json','utf8'));
12
+ const example=guide.blocks.flatMap(row=>row.cells.flat()).find(block=>block.type==='code'&&block.language==='json').code;
13
+ const page=JSON.parse(example);
14
+ const equivalent=[{columns:2,cells:[[{type:'heading',level:2,text:'About this project'},{type:'text',text:'A small tool for everyday writing.'}],[{type:'link-list',title:'Read more',items:[{label:'Project',url:'/[launge]/project-introduction/'}]}]]}];
15
+ assert.deepEqual(page.blocks,equivalent);
16
+ assert.equal(await renderBlocks(page.blocks,await context()),await renderBlocks(equivalent,await context()));
17
+ });
18
+
19
+ test('a missing cell identifies the row and actual column/cell counts',async()=>{
20
+ await assert.rejects(renderBlocks([{columns:1,cells:[[{type:'text',text:'First'}]]},{columns:2,cells:[[{type:'text',text:'Second'}]]}],await context()),/blocks\[1\] \(row 2\).*columns: 2; cells: 1/);
21
+ });
22
+
23
+ test('nested invalid content identifies its row, cell and element without weakening validation',async()=>{
24
+ const rows=[{columns:2,cells:[[{type:'text',text:'Safe'}],[{type:'section',title:'Links',blocks:[{type:'link-list',title:'Unsafe',items:[{label:'Bad',url:'javascript:alert(1)'}]}]}]]}];
25
+ await assert.rejects(renderBlocks(rows,await context()),/blocks\[0\].*cells\[1\].*elements\[0\].*elements\[0\].*safe site path/);
26
+ assert.match(await renderBlocks([{columns:1,cells:[[{type:'text',text:'<script>alert(1)</script>'}]]}],await context()),/&lt;script&gt;/);
27
+ });
@@ -0,0 +1,37 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {mkdtemp, mkdir, readFile, writeFile, rm, access} from 'node:fs/promises';
4
+ import {tmpdir} from 'node:os';
5
+ import {resolve} from 'node:path';
6
+ import {parseDocumentSource} from '../src/content.js';
7
+ import {formatPageSource, formatPages} from '../tools/assets/format-pages.mjs';
8
+
9
+ test('JSON conversion preserves nested elements, metadata, multiline text and punctuation', () => {
10
+ const source = '---\ntitle: "A: page"\nblocks:\n - columns: 2\n cells:\n - - type: section\n blocks:\n - type: text\n text: |\n Quote: "Hello"\n # literal text 中文 []{}\n - - type: text\n text: Second cell\ndraft: false\n---\n\n';
11
+ const result = formatPageSource(source);
12
+ assert.deepEqual(JSON.parse(result), parseDocumentSource(source, 'page.md').metadata);
13
+ assert.equal(formatPageSource(result, {file: 'page.json'}), result);
14
+ assert.throws(() => formatPageSource(source + 'Do not discard this body'), /body content/);
15
+ });
16
+
17
+ test('migration preflights duplicate locale files and only deletes Markdown after writing its equivalent JSON', async () => {
18
+ const root = await mkdtemp(resolve(tmpdir(), 'edgepress-page-json-'));
19
+ try {
20
+ const folder = resolve(root, 'content/pages/home');
21
+ await mkdir(folder, {recursive: true});
22
+ const source = '---\nlang: en\nhomepage: true\nblocks: [{columns: 1, cells: [[{type: text, text: Hello}]]}]\n---\n';
23
+ await writeFile(resolve(folder, 'en.md'), source);
24
+ assert.equal((await formatPages(root)).length, 1);
25
+ assert.equal(await readFile(resolve(folder, 'en.md'), 'utf8'), source);
26
+ await writeFile(resolve(folder, 'en.json'), '{}');
27
+ await assert.rejects(formatPages(root, {write: true}), /Duplicate/);
28
+ assert.equal(await readFile(resolve(folder, 'en.md'), 'utf8'), source);
29
+ await rm(resolve(folder, 'en.json'));
30
+ await formatPages(root, {write: true});
31
+ await assert.rejects(access(resolve(folder, 'en.md')));
32
+ const {$schema, ...page} = JSON.parse(await readFile(resolve(folder, 'en.json'), 'utf8'));
33
+ assert.match($schema, /schemas\/pages\.schema\.json$/);
34
+ assert.deepEqual(page, parseDocumentSource(source, 'en.md').metadata);
35
+ assert.deepEqual(await formatPages(root), []);
36
+ } finally { await rm(root, {recursive: true, force: true}); }
37
+ });