@jant/core 0.6.15 → 0.7.0

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 (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -51,6 +51,65 @@ function makeFeedData(post: FeedPostView): FeedData {
51
51
  }
52
52
 
53
53
  describe("feed renderers", () => {
54
+ // Stamping the render time on <updated> tells every reader the feed changed
55
+ // on every poll, which is untrue and useless for change detection.
56
+ it("dates the feed by its newest entry, not the render time", () => {
57
+ const xml = defaultFeedRenderer({
58
+ ...makeFeedData(makePostView()),
59
+ posts: [
60
+ makePostView({
61
+ id: "a",
62
+ permalink: "/a",
63
+ feedUpdatedAt: "2026-01-02T00:00:00.000Z",
64
+ }),
65
+ makePostView({
66
+ id: "b",
67
+ permalink: "/b",
68
+ feedUpdatedAt: "2026-05-09T00:00:00.000Z",
69
+ }),
70
+ makePostView({
71
+ id: "c",
72
+ permalink: "/c",
73
+ feedUpdatedAt: "2026-03-04T00:00:00.000Z",
74
+ }),
75
+ ],
76
+ });
77
+
78
+ const feedUpdated = /<id>[^<]*<\/id>\s*<updated>([^<]+)<\/updated>/.exec(
79
+ xml,
80
+ )?.[1];
81
+ expect(feedUpdated).toBe("2026-05-09T00:00:00.000Z");
82
+ });
83
+
84
+ // A directory listing this blog has no other machine-readable place to read
85
+ // an avatar from, so the feed is where it has to be.
86
+ it("emits the site avatar as an absolute atom:icon", () => {
87
+ const xml = defaultFeedRenderer({
88
+ ...makeFeedData(makePostView()),
89
+ siteIconUrl: "/media/avatar.png",
90
+ });
91
+
92
+ expect(xml).toContain("<icon>https://example.com/media/avatar.png</icon>");
93
+ });
94
+
95
+ it("leaves atom:icon out when the site has no avatar", () => {
96
+ const xml = defaultFeedRenderer(makeFeedData(makePostView()));
97
+
98
+ expect(xml).not.toContain("<icon>");
99
+ });
100
+
101
+ it("falls back to the render time for an empty feed", () => {
102
+ const xml = defaultFeedRenderer({
103
+ ...makeFeedData(makePostView()),
104
+ posts: [],
105
+ });
106
+
107
+ const feedUpdated = /<id>[^<]*<\/id>\s*<updated>([^<]+)<\/updated>/.exec(
108
+ xml,
109
+ )?.[1];
110
+ expect(feedUpdated).toMatch(/^\d{4}-\d{2}-\d{2}T/);
111
+ });
112
+
54
113
  it("keeps Atom entry titles empty for untitled posts and strips script tags from content", () => {
55
114
  const xml = defaultFeedRenderer(
56
115
  makeFeedData(
@@ -65,7 +124,10 @@ describe("feed renderers", () => {
65
124
  );
66
125
 
67
126
  expect(xml).toContain("<title></title>");
68
- expect(xml).toContain('<summary type="text">哈哈哈😍</summary>');
127
+ // The summary carries the entry's text whether or not it was truncated.
128
+ expect(xml).toContain(
129
+ '<summary type="html"><![CDATA[<p>哈哈哈😍</p>]]></summary>',
130
+ );
69
131
  expect(xml).toContain("<![CDATA[<p>哈哈哈😍</p>]]>");
70
132
  expect(xml).not.toContain("data-jant-meta");
71
133
  expect(xml).not.toContain('{"kind":"text"}');
@@ -110,6 +172,24 @@ describe("feed renderers", () => {
110
172
  expect(xml).not.toContain("<iframe");
111
173
  });
112
174
 
175
+ it("resolves relative links and media URLs inside post HTML", () => {
176
+ const post = makePostView({
177
+ bodyHtml:
178
+ '<p><a href="/related">Related</a> <a href="#footnote">Footnote</a></p>' +
179
+ '<img src="/media/inline.jpg" alt="Inline">' +
180
+ '<video poster="/media/poster.jpg"><source src="/media/clip.mp4"></video>' +
181
+ '<span data-src="/leave-this-relative"></span>',
182
+ });
183
+ const xml = defaultFeedRenderer(makeFeedData(post));
184
+
185
+ expect(xml).toContain('href="https://example.com/related"');
186
+ expect(xml).toContain('href="#footnote"');
187
+ expect(xml).toContain('src="https://example.com/media/inline.jpg"');
188
+ expect(xml).toContain('poster="https://example.com/media/poster.jpg"');
189
+ expect(xml).toContain('src="https://example.com/media/clip.mp4"');
190
+ expect(xml).toContain('data-src="/leave-this-relative"');
191
+ });
192
+
113
193
  it("does not expose quote attribution as feed title", () => {
114
194
  const xml = defaultFeedRenderer(
115
195
  makeFeedData(
@@ -126,10 +206,89 @@ describe("feed renderers", () => {
126
206
 
127
207
  expect(xml).toContain("<title></title>");
128
208
  expect(xml).toContain(
129
- '<summary type="text">What stands in the way becomes the way.</summary>',
209
+ '<blockquote cite="https://example.com/meditations">' +
210
+ "<p>What stands in the way becomes the way.</p></blockquote>",
130
211
  );
131
212
  expect(xml).toContain("Marcus Aurelius");
132
213
  expect(xml).toContain("https://example.com/meditations");
214
+ // The quoted text is the entry's text, so it rides in the summary too.
215
+ expect(xml).toContain(
216
+ '<summary type="html"><![CDATA[<figure><blockquote cite=',
217
+ );
218
+ });
219
+
220
+ // The site keeps quote line breaks with `white-space: pre-line`; feed
221
+ // readers strip CSS, so the breaks have to be markup or the quote collapses
222
+ // into one run-on paragraph.
223
+ it("keeps quote line breaks as markup", () => {
224
+ const xml = defaultFeedRenderer(
225
+ makeFeedData(
226
+ makePostView({
227
+ format: "quote",
228
+ quoteText: "Roses are red\nViolets are blue\n\nSo it goes.",
229
+ summary: undefined,
230
+ excerpt: undefined,
231
+ }),
232
+ ),
233
+ );
234
+
235
+ expect(xml).toContain(
236
+ "<blockquote><p>Roses are red<br/>Violets are blue</p>\n<p>So it goes.</p></blockquote>",
237
+ );
238
+ });
239
+
240
+ it("normalizes CRLF line breaks in quotes", () => {
241
+ const xml = defaultFeedRenderer(
242
+ makeFeedData(
243
+ makePostView({
244
+ format: "quote",
245
+ quoteText: "First line\r\nSecond line",
246
+ summary: undefined,
247
+ excerpt: undefined,
248
+ }),
249
+ ),
250
+ );
251
+
252
+ const content =
253
+ /<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
254
+ xml,
255
+ )?.[1];
256
+ expect(content).toBe(
257
+ "<figure><blockquote><p>First line<br/>Second line</p></blockquote></figure>",
258
+ );
259
+ });
260
+
261
+ it("escapes quote text before inserting line-break markup", () => {
262
+ const xml = defaultFeedRenderer(
263
+ makeFeedData(
264
+ makePostView({
265
+ format: "quote",
266
+ quoteText: "<script>alert(1)</script>\nsecond & last",
267
+ summary: undefined,
268
+ excerpt: undefined,
269
+ }),
270
+ ),
271
+ );
272
+
273
+ expect(xml).toContain(
274
+ "<blockquote><p>&lt;script&gt;alert(1)&lt;/script&gt;<br/>second &amp; last</p></blockquote>",
275
+ );
276
+ });
277
+
278
+ it("falls back to the summary when a quote holds only whitespace", () => {
279
+ const xml = defaultFeedRenderer(
280
+ makeFeedData(
281
+ makePostView({
282
+ format: "quote",
283
+ quoteText: " \n ",
284
+ summary: "A quote worth keeping",
285
+ excerpt: undefined,
286
+ }),
287
+ ),
288
+ );
289
+
290
+ expect(xml).not.toContain("<blockquote");
291
+ expect(xml).toContain("<p>A quote worth keeping</p>");
133
292
  });
134
293
 
135
294
  it("link posts point <link> to original URL with ★ permalink back to blog", () => {
@@ -158,6 +317,64 @@ describe("feed renderers", () => {
158
317
  );
159
318
  });
160
319
 
320
+ it("renders YouTube Link previews as a linked thumbnail with a provider-aware action", () => {
321
+ const post = makePostView({
322
+ format: "link",
323
+ title: "A useful video",
324
+ url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
325
+ bodyHtml: "<p>My notes on the video.</p>",
326
+ previewKind: "video",
327
+ previewProvider: "youtube",
328
+ previewImageUrl: "/media/previews/youtube.jpg",
329
+ });
330
+ const xml = defaultFeedRenderer(makeFeedData(post));
331
+
332
+ expect(xml).toContain(
333
+ '<figure><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"><img src="https://example.com/media/previews/youtube.jpg" alt="A useful video"/></a><figcaption><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">▶ Watch on YouTube</a></figcaption></figure>',
334
+ );
335
+ // The reference comes before the commentary. Scoped to the content: the
336
+ // summary carries the commentary without the preview, so a whole-document
337
+ // search would find the note there first.
338
+ const content =
339
+ /<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
340
+ xml,
341
+ )?.[1] ?? "";
342
+ expect(content.indexOf("<figure>")).toBeLessThan(
343
+ content.indexOf("My notes on the video."),
344
+ );
345
+ expect(xml).not.toContain("<iframe");
346
+ expect(xml).not.toContain('rel="enclosure"');
347
+ });
348
+
349
+ it("renders non-video Link previews without a video action", () => {
350
+ const post = makePostView({
351
+ format: "link",
352
+ title: "Illustrated article",
353
+ url: "https://external.com/illustrated",
354
+ previewKind: "image",
355
+ previewImageUrl: "https://example.com/previews/article.jpg",
356
+ });
357
+ const xml = defaultFeedRenderer(makeFeedData(post));
358
+
359
+ expect(xml).toContain(
360
+ '<figure><a href="https://external.com/illustrated"><img src="https://example.com/previews/article.jpg" alt="Illustrated article"/></a></figure>',
361
+ );
362
+ expect(xml).not.toContain("Watch video");
363
+ expect(xml).not.toContain("Watch on");
364
+ });
365
+
366
+ it("keeps the text fallback when a Link post has no preview image", () => {
367
+ const post = makePostView({
368
+ format: "link",
369
+ title: "Text-only link",
370
+ url: "https://external.com/text-only",
371
+ });
372
+ const xml = defaultFeedRenderer(makeFeedData(post));
373
+
374
+ expect(xml).toContain("<![CDATA[<p>Text-only link</p>");
375
+ expect(xml).not.toContain("<figure>");
376
+ });
377
+
161
378
  it("note posts still link to blog permalink without ★", () => {
162
379
  const post = makePostView({
163
380
  format: "note",
@@ -185,7 +402,7 @@ describe("feed renderers", () => {
185
402
  expect(xml).toContain("<updated>2026-03-20T09:45:00.000Z</updated>");
186
403
  });
187
404
 
188
- it("renders thread replies with hr separator and time element", () => {
405
+ it("renders thread replies with hr separator and a dated permalink", () => {
189
406
  const reply = makePostView({
190
407
  id: "reply-1",
191
408
  permalink: "/reply-1",
@@ -210,7 +427,9 @@ describe("feed renderers", () => {
210
427
 
211
428
  expect(xml).toContain("<p>Root content</p>");
212
429
  expect(xml).toContain("<hr/>");
213
- expect(xml).toContain('<time datetime="2026-03-19T12:00:00.000Z">');
430
+ expect(xml).toContain(
431
+ '<time class="dt-published" datetime="2026-03-19T12:00:00.000Z">',
432
+ );
214
433
  expect(xml).toContain("<p>This is a reply</p>");
215
434
  });
216
435
 
@@ -272,6 +491,38 @@ describe("feed renderers", () => {
272
491
  );
273
492
  });
274
493
 
494
+ it("renders a YouTube Link preview inside a thread reply", () => {
495
+ const reply = makePostView({
496
+ id: "reply-video",
497
+ permalink: "/reply-video",
498
+ slug: "reply-video",
499
+ format: "link",
500
+ title: "Thread video",
501
+ url: "https://youtu.be/dQw4w9WgXcQ",
502
+ previewKind: "video",
503
+ previewProvider: "youtube",
504
+ previewImageUrl: "/media/previews/thread-video.jpg",
505
+ });
506
+
507
+ const xml = defaultFeedRenderer(
508
+ makeFeedData(
509
+ makePostView({
510
+ title: "Thread Root",
511
+ bodyHtml: "<p>Root content</p>",
512
+ threadReplies: [reply],
513
+ }),
514
+ ),
515
+ );
516
+
517
+ const replyTitle =
518
+ '<h2><a href="https://youtu.be/dQw4w9WgXcQ">Thread video</a></h2>';
519
+ const preview =
520
+ '<figure><a href="https://youtu.be/dQw4w9WgXcQ"><img src="https://example.com/media/previews/thread-video.jpg" alt="Thread video"/></a><figcaption><a href="https://youtu.be/dQw4w9WgXcQ">▶ Watch on YouTube</a></figcaption></figure>';
521
+ expect(xml).toContain(replyTitle);
522
+ expect(xml).toContain(preview);
523
+ expect(xml.indexOf(replyTitle)).toBeLessThan(xml.indexOf(preview));
524
+ });
525
+
275
526
  it("includes thread reply media as Atom enclosures on the combined entry", () => {
276
527
  const reply = makePostView({
277
528
  id: "reply-1",
@@ -331,12 +582,12 @@ describe("feed renderers", () => {
331
582
  '<img src="https://example.com/media/photo.jpg" alt="A red bicycle" width="1200" height="800"/>',
332
583
  );
333
584
  expect(xml).toContain("<figcaption>A red bicycle</figcaption>");
334
- expect(xml).toContain(
335
- '<link rel="enclosure" type="image/jpeg" href="https://example.com/media/photo.jpg" length="245000"',
336
- );
585
+ // The content already shows the picture inside a link to the original, so
586
+ // an enclosure would only ask an attachment shelf to list it again.
587
+ expect(xml).not.toContain('rel="enclosure"');
337
588
  });
338
589
 
339
- it("renders video attachments as poster + caption (never inline <video>)", () => {
590
+ it("inlines a video player with the poster as an attribute", () => {
340
591
  const post = makePostView({
341
592
  media: [
342
593
  makeMediaView({
@@ -354,12 +605,19 @@ describe("feed renderers", () => {
354
605
  });
355
606
  const xml = defaultFeedRenderer(makeFeedData(post));
356
607
 
357
- expect(xml).not.toContain("<video");
358
608
  expect(xml).toContain(
359
- '<img src="https://example.com/media/clip-poster.jpg"',
609
+ '<video controls preload="none" ' +
610
+ 'poster="https://example.com/media/clip-poster.jpg" ' +
611
+ 'width="1920" height="1080">' +
612
+ '<source src="https://example.com/media/clip.mp4" type="video/mp4"/>' +
613
+ "</video>",
360
614
  );
361
- // The action label is a link (not just the thumbnail); metadata sits
362
- // outside the link in parens, matching the audio/text attachment style.
615
+ // The poster is an attribute now, not an <img> the reader might show
616
+ // beside the player.
617
+ expect(xml).not.toContain("<img");
618
+ // The action label stays a link outside the <video>, so it survives a
619
+ // sanitizer that drops the player; metadata sits outside the link in
620
+ // parens, matching the audio/text attachment style.
363
621
  expect(xml).toContain(
364
622
  '<figcaption><a href="https://example.com/media/clip.mp4">▶ Watch video</a> (0:42 · 1.1 MB)</figcaption>',
365
623
  );
@@ -368,6 +626,102 @@ describe("feed renderers", () => {
368
626
  );
369
627
  });
370
628
 
629
+ // The media pipeline only transforms image MIME types, so a video's
630
+ // `thumbnailUrl` is the file itself. Feeding that to `<img src>` produced a
631
+ // broken image pointing at an MP4.
632
+ it("omits the poster for a video with no poster frame", () => {
633
+ const xml = defaultFeedRenderer(
634
+ makeFeedData(
635
+ makePostView({
636
+ media: [
637
+ makeMediaView({
638
+ id: "med_clip",
639
+ url: "https://example.com/media/clip.mp4",
640
+ thumbnailUrl: "https://example.com/media/clip.mp4",
641
+ mimeType: "video/mp4",
642
+ size: 28_000_000,
643
+ width: 960,
644
+ height: 448,
645
+ originalName: "clip.mp4",
646
+ }),
647
+ ],
648
+ }),
649
+ ),
650
+ );
651
+
652
+ // No <img>, and crucially no poster pointing at the video file itself.
653
+ expect(xml).not.toContain("<img");
654
+ expect(xml).not.toContain("poster=");
655
+ expect(xml).toContain(
656
+ '<video controls preload="none" width="960" height="448">' +
657
+ '<source src="https://example.com/media/clip.mp4" type="video/mp4"/>' +
658
+ "</video>",
659
+ );
660
+ // The play cue, the link, and the size all survive without the still.
661
+ expect(xml).toContain(
662
+ '<figcaption><a href="https://example.com/media/clip.mp4">▶ Watch video</a> (26.7 MB)</figcaption>',
663
+ );
664
+ // Media RSS must not invent a thumbnail out of the video either.
665
+ expect(xml).not.toContain("<media:thumbnail");
666
+ // The enclosure is what lets a reader offer its own player.
667
+ expect(xml).toContain(
668
+ '<link rel="enclosure" type="video/mp4" href="https://example.com/media/clip.mp4"',
669
+ );
670
+ });
671
+
672
+ // `<video>` has no `alt` attribute, so a described clip would lose its
673
+ // description entirely if the caption did not carry it.
674
+ it("moves a video's alt text into its caption", () => {
675
+ const xml = defaultFeedRenderer(
676
+ makeFeedData(
677
+ makePostView({
678
+ media: [
679
+ makeMediaView({
680
+ id: "med_described",
681
+ url: "https://example.com/media/described.mp4",
682
+ thumbnailUrl: "https://example.com/media/described.mp4",
683
+ mimeType: "video/mp4",
684
+ altText: "A heron takes off",
685
+ }),
686
+ ],
687
+ }),
688
+ ),
689
+ );
690
+
691
+ expect(xml).toContain(
692
+ '<figcaption><a href="https://example.com/media/described.mp4">' +
693
+ "▶ Watch video</a>: A heron takes off</figcaption>",
694
+ );
695
+ });
696
+
697
+ it("resolves relative attachment, poster, and enclosure URLs", () => {
698
+ const post = makePostView({
699
+ media: [
700
+ makeMediaView({
701
+ id: "med_local_vid",
702
+ url: "/media/local-clip.mp4",
703
+ thumbnailUrl: "/media/local-clip-thumb.jpg",
704
+ posterUrl: "/media/local-clip-poster.jpg",
705
+ mimeType: "video/mp4",
706
+ }),
707
+ ],
708
+ });
709
+ const xml = defaultFeedRenderer(makeFeedData(post));
710
+
711
+ expect(xml).toContain(
712
+ 'poster="https://example.com/media/local-clip-poster.jpg"',
713
+ );
714
+ expect(xml).toContain(
715
+ '<source src="https://example.com/media/local-clip.mp4" type="video/mp4"/>',
716
+ );
717
+ expect(xml).toContain(
718
+ '<a href="https://example.com/media/local-clip.mp4">▶ Watch video</a>',
719
+ );
720
+ expect(xml).toContain(
721
+ '<link rel="enclosure" type="video/mp4" href="https://example.com/media/local-clip.mp4"',
722
+ );
723
+ });
724
+
371
725
  it("renders audio attachments as a labeled link with duration and size", () => {
372
726
  const post = makePostView({
373
727
  media: [
@@ -538,3 +892,1328 @@ describe("feed renderers", () => {
538
892
  expect(xml).not.toContain("<figure>");
539
893
  });
540
894
  });
895
+
896
+ describe("feed Discover declaration", () => {
897
+ it("declares the mode and the feeds to poll", () => {
898
+ const xml = defaultFeedRenderer({
899
+ ...makeFeedData(makePostView()),
900
+ discover: "latest",
901
+ discoverFeedUrl: "https://example.com/latest/feed",
902
+ discoverFeaturedFeedUrl: "https://example.com/featured/feed",
903
+ });
904
+
905
+ expect(xml).toContain('xmlns:jant="https://jant.me/ns"');
906
+ expect(xml).toContain(
907
+ '<jant:discover feed="https://example.com/latest/feed" featured="https://example.com/featured/feed">latest</jant:discover>',
908
+ );
909
+ });
910
+
911
+ it("names no featured feed when none was given", () => {
912
+ const xml = defaultFeedRenderer({
913
+ ...makeFeedData(makePostView()),
914
+ discover: "latest",
915
+ discoverFeedUrl: "https://example.com/latest/feed",
916
+ });
917
+
918
+ expect(xml).toContain(
919
+ '<jant:discover feed="https://example.com/latest/feed">latest</jant:discover>',
920
+ );
921
+ });
922
+
923
+ it("points featured sites at the featured feed", () => {
924
+ const xml = defaultFeedRenderer({
925
+ ...makeFeedData(makePostView()),
926
+ discover: "featured",
927
+ discoverFeedUrl: "https://example.com/featured/feed",
928
+ });
929
+
930
+ expect(xml).toContain(
931
+ '<jant:discover feed="https://example.com/featured/feed">featured</jant:discover>',
932
+ );
933
+ });
934
+
935
+ // `none` is an answer, so it is still declared — a crawler that already
936
+ // knows the site has to be told to stop, and silence would read as "this
937
+ // site predates Discover" instead.
938
+ it("declares none without a feed attribute", () => {
939
+ const xml = defaultFeedRenderer({
940
+ ...makeFeedData(makePostView()),
941
+ discover: "none",
942
+ discoverFeedUrl: null,
943
+ });
944
+
945
+ expect(xml).toContain("<jant:discover>none</jant:discover>");
946
+ expect(xml).not.toContain("feed=");
947
+ });
948
+
949
+ it("declares nothing when the field is absent", () => {
950
+ const xml = defaultFeedRenderer(makeFeedData(makePostView()));
951
+
952
+ expect(xml).not.toContain("jant:discover");
953
+ });
954
+
955
+ // The namespace is declared for whatever is emitted in it, and an entry's
956
+ // format is emitted whatever the site answered about Discover. So the only
957
+ // feed that leaves the declaration out is one with nothing in it at all.
958
+ it("declares no namespace when nothing in it is emitted", () => {
959
+ const xml = defaultFeedRenderer({
960
+ ...makeFeedData(makePostView()),
961
+ posts: [],
962
+ });
963
+
964
+ expect(xml).not.toContain("xmlns:jant");
965
+ });
966
+
967
+ it("keeps a sitePathPrefix in the polled feed URL", () => {
968
+ const xml = defaultFeedRenderer({
969
+ ...makeFeedData(makePostView()),
970
+ siteUrl: "https://example.com/blog",
971
+ discover: "latest",
972
+ discoverFeedUrl: "https://example.com/blog/latest/feed",
973
+ });
974
+
975
+ expect(xml).toContain('feed="https://example.com/blog/latest/feed"');
976
+ });
977
+ });
978
+
979
+ describe("feed language alternates", () => {
980
+ it("links each language's copy of the same feed", () => {
981
+ const xml = defaultFeedRenderer({
982
+ ...makeFeedData(makePostView()),
983
+ languageAlternates: [
984
+ { hreflang: "zh-Hans", href: "https://example.com/latest/feed" },
985
+ { hreflang: "en", href: "https://example.com/en/latest/feed" },
986
+ ],
987
+ });
988
+
989
+ expect(xml).toContain(
990
+ '<link href="https://example.com/latest/feed" rel="alternate" type="application/atom+xml" hreflang="zh-Hans"/>',
991
+ );
992
+ expect(xml).toContain(
993
+ '<link href="https://example.com/en/latest/feed" rel="alternate" type="application/atom+xml" hreflang="en"/>',
994
+ );
995
+ });
996
+
997
+ it("emits nothing extra for a single-language site", () => {
998
+ const xml = defaultFeedRenderer({
999
+ ...makeFeedData(makePostView()),
1000
+ languageAlternates: [],
1001
+ });
1002
+
1003
+ expect(xml).not.toContain("hreflang");
1004
+ });
1005
+ });
1006
+
1007
+ describe("feed author", () => {
1008
+ // The feed title is composed, so it is not a name. A consumer that wants to
1009
+ // label the blog needs the site's own name somewhere, and this is the place
1010
+ // Atom already has for it.
1011
+ it("names the blog, separately from the composed feed title", () => {
1012
+ const xml = defaultFeedRenderer({
1013
+ ...makeFeedData(makePostView()),
1014
+ siteName: "A blog",
1015
+ title: "A blog - Latest posts",
1016
+ });
1017
+
1018
+ expect(xml).toContain("<author><name>A blog</name></author>");
1019
+ expect(xml).toContain("<title>A blog - Latest posts</title>");
1020
+ });
1021
+
1022
+ it("escapes a name that contains markup characters", () => {
1023
+ const xml = defaultFeedRenderer({
1024
+ ...makeFeedData(makePostView()),
1025
+ siteName: "Q&A <notes>",
1026
+ });
1027
+
1028
+ expect(xml).toContain(
1029
+ "<author><name>Q&amp;A &lt;notes&gt;</name></author>",
1030
+ );
1031
+ });
1032
+
1033
+ it("emits nothing when the site has no name", () => {
1034
+ const xml = defaultFeedRenderer({
1035
+ ...makeFeedData(makePostView()),
1036
+ siteName: "",
1037
+ });
1038
+
1039
+ expect(xml).not.toContain("<author>");
1040
+ });
1041
+ });
1042
+
1043
+ describe("feed entry format", () => {
1044
+ // A quote and an untitled note are identical from the feed alone — both have
1045
+ // an empty <title> and a body — so this element is the only way to tell them
1046
+ // apart without fetching the post's page.
1047
+ it.each([
1048
+ ["note", "note"],
1049
+ ["link", "link"],
1050
+ ["quote", "quote"],
1051
+ ] as const)("declares a %s post as %s", (format, declared) => {
1052
+ const xml = defaultFeedRenderer(
1053
+ makeFeedData(
1054
+ makePostView({
1055
+ format,
1056
+ url: format === "link" ? "https://external.com/article" : undefined,
1057
+ quoteText: format === "quote" ? "A quote worth keeping" : undefined,
1058
+ }),
1059
+ ),
1060
+ );
1061
+
1062
+ expect(xml).toContain(`<jant:format>${declared}</jant:format>`);
1063
+ expect(xml).toContain('xmlns:jant="https://jant.me/ns"');
1064
+ });
1065
+
1066
+ // Core's own three formats. A titled note stays a note; drawing it as an
1067
+ // article is a decision the consumer makes from this and <title>.
1068
+ it("calls a titled note a note", () => {
1069
+ const xml = defaultFeedRenderer(
1070
+ makeFeedData(makePostView({ format: "note", title: "A titled note" })),
1071
+ );
1072
+
1073
+ expect(xml).toContain("<jant:format>note</jant:format>");
1074
+ });
1075
+
1076
+ // Threads arrive as one entry keyed to the root, so the format is the root's.
1077
+ it("declares a thread by its root post", () => {
1078
+ const xml = defaultFeedRenderer(
1079
+ makeFeedData(
1080
+ makePostView({
1081
+ format: "quote",
1082
+ quoteText: "A quote worth keeping",
1083
+ threadReplies: [
1084
+ makePostView({
1085
+ id: "post-2",
1086
+ permalink: "/post-2",
1087
+ format: "note",
1088
+ }),
1089
+ ],
1090
+ }),
1091
+ ),
1092
+ );
1093
+
1094
+ expect(xml).toContain("<jant:format>quote</jant:format>");
1095
+ expect(xml).not.toContain("<jant:format>note</jant:format>");
1096
+ });
1097
+ });
1098
+
1099
+ /**
1100
+ * `<summary>` is the entry's text as the timeline renders it; `<content>` is
1101
+ * the post's full page. Media lives in the content and in the Media RSS
1102
+ * elements, never here.
1103
+ */
1104
+ describe("feed entry summary", () => {
1105
+ function makeBody(paragraphs: string[]): string {
1106
+ return JSON.stringify({
1107
+ type: "doc",
1108
+ content: paragraphs.map((text) => ({
1109
+ type: "paragraph",
1110
+ content: [{ type: "text", text }],
1111
+ })),
1112
+ });
1113
+ }
1114
+
1115
+ const longBody = makeBody([
1116
+ "Alpha ".repeat(40).trim(),
1117
+ "Bravo ".repeat(40).trim(),
1118
+ "Charlie ".repeat(40).trim(),
1119
+ "Delta ".repeat(40).trim(),
1120
+ ]);
1121
+
1122
+ function getSummary(xml: string): string | undefined {
1123
+ return /<summary type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/summary>/.exec(
1124
+ xml,
1125
+ )?.[1];
1126
+ }
1127
+
1128
+ function getContent(xml: string): string | undefined {
1129
+ return /<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
1130
+ xml,
1131
+ )?.[1];
1132
+ }
1133
+
1134
+ it("sends a truncated summary alongside the full content", () => {
1135
+ const xml = defaultFeedRenderer(
1136
+ makeFeedData(
1137
+ makePostView({
1138
+ title: "A long read",
1139
+ body: longBody,
1140
+ bodyHtml: "<p>Alpha</p><p>Bravo</p><p>Charlie</p><p>Delta</p>",
1141
+ }),
1142
+ ),
1143
+ );
1144
+
1145
+ const summary = getSummary(xml);
1146
+ expect(summary).toBeDefined();
1147
+ expect(summary).toContain("Alpha");
1148
+ expect(summary).not.toContain("Delta");
1149
+ expect(getContent(xml)).toContain("Delta");
1150
+ });
1151
+
1152
+ // The field means one thing on its own — "this entry's text" — rather than
1153
+ // one defined by what the content happens to hold, so a short note repeats
1154
+ // itself here. That costs the words and buys `summary ?? ""` as the whole of
1155
+ // a consumer's rule.
1156
+ it("sends the summary even when it repeats the content", () => {
1157
+ const xml = defaultFeedRenderer(
1158
+ makeFeedData(
1159
+ makePostView({
1160
+ body: makeBody(["Just the one line."]),
1161
+ bodyHtml: "<p>Just the one line.</p>",
1162
+ }),
1163
+ ),
1164
+ );
1165
+
1166
+ expect(getSummary(xml)).toBe("<p>Just the one line.</p>");
1167
+ });
1168
+
1169
+ // A feed cannot express the timeline's justified row, so a consumer computes
1170
+ // it from the dimensions on `<media:content>`. Repeating the markup here
1171
+ // would only give it something to strip back out.
1172
+ it("leaves media out of the summary and keeps the rating", () => {
1173
+ const xml = defaultFeedRenderer(
1174
+ makeFeedData(
1175
+ makePostView({
1176
+ body: makeBody(["嘿嘿嘿"]),
1177
+ bodyHtml: "<p>嘿嘿嘿</p>",
1178
+ rating: 4,
1179
+ media: [
1180
+ makeMediaView({
1181
+ url: "https://example.com/media/one.webp",
1182
+ thumbnailUrl: "https://example.com/media/one.webp",
1183
+ mimeType: "image/webp",
1184
+ }),
1185
+ ],
1186
+ }),
1187
+ ),
1188
+ );
1189
+
1190
+ expect(getSummary(xml)).toBe("<p>嘿嘿嘿</p>\n<p>★★★★☆ 4/5</p>");
1191
+ expect(getContent(xml)).toContain("one.webp");
1192
+ });
1193
+
1194
+ it("leaves a link post's preview and ★ permalink out of the summary", () => {
1195
+ const xml = defaultFeedRenderer(
1196
+ makeFeedData(
1197
+ makePostView({
1198
+ format: "link",
1199
+ title: "A useful video",
1200
+ url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
1201
+ body: makeBody(["My notes."]),
1202
+ bodyHtml: "<p>My notes.</p>",
1203
+ previewImageUrl: "/media/previews/youtube.jpg",
1204
+ }),
1205
+ ),
1206
+ );
1207
+
1208
+ expect(getSummary(xml)).toBe("<p>My notes.</p>");
1209
+ const content = getContent(xml);
1210
+ expect(content).toContain("youtube.jpg");
1211
+ expect(content).toContain("★");
1212
+ });
1213
+
1214
+ // Missing says exactly one thing: this post has no text.
1215
+ it("omits the summary for a post with no text at all", () => {
1216
+ const xml = defaultFeedRenderer(
1217
+ makeFeedData(
1218
+ makePostView({
1219
+ media: [
1220
+ makeMediaView({
1221
+ url: "https://example.com/media/1031.pdf",
1222
+ thumbnailUrl: "https://example.com/media/1031.pdf",
1223
+ mimeType: "application/pdf",
1224
+ size: 915872,
1225
+ originalName: "1031.pdf",
1226
+ }),
1227
+ ],
1228
+ }),
1229
+ ),
1230
+ );
1231
+
1232
+ expect(xml).not.toContain("<summary");
1233
+ expect(getContent(xml)).toContain("1031.pdf");
1234
+ });
1235
+
1236
+ // An entry's content may not be empty, so a post with no body, no media and
1237
+ // no title falls back to a bare `Post #<id>`. A summary may be empty, and
1238
+ // must be — that placeholder is not text the author wrote.
1239
+ it("omits the summary rather than falling back to a placeholder", () => {
1240
+ const xml = defaultFeedRenderer(makeFeedData(makePostView()));
1241
+
1242
+ expect(xml).not.toContain("<summary");
1243
+ expect(getContent(xml)).toContain("Post #post-1");
1244
+ });
1245
+
1246
+ // The site draws this separator with `.feed-quote-commentary::before`, which
1247
+ // feed readers strip along with the rest of the CSS.
1248
+ it("separates a quote from its commentary with a rule", () => {
1249
+ const content = getContent(
1250
+ defaultFeedRenderer(
1251
+ makeFeedData(
1252
+ makePostView({
1253
+ format: "quote",
1254
+ title: "Marcus Aurelius",
1255
+ url: "https://example.com/meditations",
1256
+ quoteText: "What stands in the way becomes the way.",
1257
+ bodyHtml: "<p>Still true.</p>",
1258
+ }),
1259
+ ),
1260
+ ),
1261
+ );
1262
+
1263
+ expect(content).toContain(
1264
+ "<figcaption>— " +
1265
+ '<a href="https://example.com/meditations">Marcus Aurelius</a>' +
1266
+ "</figcaption></figure>\n<hr/>\n<p>Still true.</p>",
1267
+ );
1268
+ });
1269
+
1270
+ // The same fold the site's timeline applies: the first two replies as
1271
+ // context, the last three with the newest as the hero, the run between them
1272
+ // collapsed into a gap link.
1273
+ it("folds a long thread the way the timeline does", () => {
1274
+ const reply = (n: number) =>
1275
+ makePostView({
1276
+ id: `reply-${n}`,
1277
+ permalink: `/reply-${n}`,
1278
+ body: makeBody([`Reply ${n} body.`]),
1279
+ bodyHtml: `<p>Reply ${n} body.</p>`,
1280
+ });
1281
+ const xml = defaultFeedRenderer(
1282
+ makeFeedData(
1283
+ makePostView({
1284
+ title: "Thread root",
1285
+ body: longBody,
1286
+ bodyHtml: "<p>Alpha</p><p>Delta</p>",
1287
+ threadReplies: [1, 2, 3, 4, 5, 6, 7].map(reply),
1288
+ }),
1289
+ ),
1290
+ );
1291
+
1292
+ const summary = getSummary(xml);
1293
+ // Leading context.
1294
+ expect(summary).toContain("Reply 1 body.");
1295
+ expect(summary).toContain("Reply 2 body.");
1296
+ // The gap stands for replies 3 and 4, and opens the first of them.
1297
+ expect(summary).toContain(
1298
+ '<p><small><a href="https://example.com/reply-3">2 more posts</a></small></p>',
1299
+ );
1300
+ expect(summary).not.toContain("Reply 3 body.");
1301
+ expect(summary).not.toContain("Reply 4 body.");
1302
+ // Trailing context, then the hero.
1303
+ expect(summary).toContain("Reply 5 body.");
1304
+ expect(summary).toContain("Reply 6 body.");
1305
+ expect(summary).toContain("Reply 7 body.");
1306
+
1307
+ // The content still carries every reply.
1308
+ const content = getContent(xml);
1309
+ for (const n of [1, 2, 3, 4, 5, 6, 7]) {
1310
+ expect(content).toContain(`Reply ${n} body.`);
1311
+ }
1312
+ });
1313
+
1314
+ // Up to six posts fit the fold, so a shorter thread hides nothing at all.
1315
+ it("hides nothing in a thread the fold fits whole", () => {
1316
+ const reply = (n: number) =>
1317
+ makePostView({
1318
+ id: `reply-${n}`,
1319
+ permalink: `/reply-${n}`,
1320
+ body: makeBody([`Reply ${n} body.`]),
1321
+ bodyHtml: `<p>Reply ${n} body.</p>`,
1322
+ });
1323
+ const summary = getSummary(
1324
+ defaultFeedRenderer(
1325
+ makeFeedData(
1326
+ makePostView({
1327
+ body: makeBody(["Root."]),
1328
+ bodyHtml: "<p>Root.</p>",
1329
+ threadReplies: [1, 2, 3, 4, 5].map(reply),
1330
+ }),
1331
+ ),
1332
+ ),
1333
+ );
1334
+
1335
+ for (const n of [1, 2, 3, 4, 5]) {
1336
+ expect(summary).toContain(`Reply ${n} body.`);
1337
+ }
1338
+ expect(summary).not.toContain("more post");
1339
+ });
1340
+
1341
+ it("writes the gap count in the singular for a single hidden post", () => {
1342
+ const reply = (n: number) =>
1343
+ makePostView({
1344
+ id: `reply-${n}`,
1345
+ permalink: `/reply-${n}`,
1346
+ body: makeBody([`Reply ${n}.`]),
1347
+ bodyHtml: `<p>Reply ${n}.</p>`,
1348
+ });
1349
+ // Six replies: two lead, three trail, one falls in the gap.
1350
+ const summary = getSummary(
1351
+ defaultFeedRenderer(
1352
+ makeFeedData(
1353
+ makePostView({
1354
+ body: makeBody(["Root."]),
1355
+ bodyHtml: "<p>Root.</p>",
1356
+ threadReplies: [1, 2, 3, 4, 5, 6].map(reply),
1357
+ }),
1358
+ ),
1359
+ ),
1360
+ );
1361
+
1362
+ expect(summary).toContain(">1 more post</a>");
1363
+ });
1364
+
1365
+ it("keeps a thread's root and newest reply without a gap when none is hidden", () => {
1366
+ const xml = defaultFeedRenderer(
1367
+ makeFeedData(
1368
+ makePostView({
1369
+ body: makeBody(["Root."]),
1370
+ bodyHtml: "<p>Root.</p>",
1371
+ threadReplies: [
1372
+ makePostView({
1373
+ id: "reply-1",
1374
+ permalink: "/reply-1",
1375
+ body: makeBody(["Only reply."]),
1376
+ bodyHtml: "<p>Only reply.</p>",
1377
+ }),
1378
+ ],
1379
+ }),
1380
+ ),
1381
+ );
1382
+
1383
+ const summary = getSummary(xml);
1384
+ expect(summary).toContain("<p>Root.</p>");
1385
+ expect(summary).toContain("<p>Only reply.</p>");
1386
+ expect(summary).not.toContain("more post");
1387
+ });
1388
+
1389
+ // The site renders a quote's commentary whole — `QuoteCard` passes `bodyHtml`
1390
+ // through and nothing clamps `.feed-quote-commentary` — so a summary that cut
1391
+ // it would not be the timeline's rendering.
1392
+ it("keeps a quote's commentary whole, however long it runs", () => {
1393
+ const xml = defaultFeedRenderer(
1394
+ makeFeedData(
1395
+ makePostView({
1396
+ format: "quote",
1397
+ title: "Marcus Aurelius",
1398
+ url: "https://example.com/meditations",
1399
+ quoteText: "What stands in the way becomes the way.",
1400
+ body: longBody,
1401
+ bodyHtml: "<p>Alpha</p><p>Bravo</p><p>Charlie</p><p>Delta</p>",
1402
+ }),
1403
+ ),
1404
+ );
1405
+
1406
+ const summary = getSummary(xml);
1407
+ expect(summary).toContain("<p>Delta</p>");
1408
+ expect(summary).toBe(getContent(xml));
1409
+ expect(xml).not.toContain("<jant:truncated/>");
1410
+ });
1411
+ });
1412
+
1413
+ /**
1414
+ * What an entry says about itself beyond its text: whether the timeline cuts
1415
+ * it, and which collections the author filed it under.
1416
+ */
1417
+ describe("feed entry metadata", () => {
1418
+ function makeBody(paragraphs: string[]): string {
1419
+ return JSON.stringify({
1420
+ type: "doc",
1421
+ content: paragraphs.map((text) => ({
1422
+ type: "paragraph",
1423
+ content: [{ type: "text", text }],
1424
+ })),
1425
+ });
1426
+ }
1427
+
1428
+ // `<summary>` is sent whenever there is text, so only this tells a consumer
1429
+ // whether the site would offer a "Read more".
1430
+ it("marks an entry whose summary text was cut", () => {
1431
+ const xml = defaultFeedRenderer(
1432
+ makeFeedData(
1433
+ makePostView({
1434
+ title: "A long read",
1435
+ body: makeBody([
1436
+ "Alpha ".repeat(90).trim(),
1437
+ "Omega ".repeat(90).trim(),
1438
+ ]),
1439
+ bodyHtml: "<p>Alpha</p><p>Omega</p>",
1440
+ }),
1441
+ ),
1442
+ );
1443
+
1444
+ expect(xml).toContain("<jant:truncated/>");
1445
+ });
1446
+
1447
+ it("leaves the mark off an entry the timeline shows in full", () => {
1448
+ const xml = defaultFeedRenderer(
1449
+ makeFeedData(
1450
+ makePostView({
1451
+ body: makeBody(["Short enough."]),
1452
+ bodyHtml: "<p>Short enough.</p>",
1453
+ }),
1454
+ ),
1455
+ );
1456
+
1457
+ expect(xml).not.toContain("<jant:truncated/>");
1458
+ });
1459
+
1460
+ it("marks an entry whose newest reply was cut", () => {
1461
+ const xml = defaultFeedRenderer(
1462
+ makeFeedData(
1463
+ makePostView({
1464
+ body: makeBody(["Root."]),
1465
+ bodyHtml: "<p>Root.</p>",
1466
+ threadReplies: [
1467
+ makePostView({
1468
+ id: "reply-1",
1469
+ permalink: "/reply-1",
1470
+ title: "A long reply",
1471
+ body: makeBody([
1472
+ "Alpha ".repeat(90).trim(),
1473
+ "Omega ".repeat(90).trim(),
1474
+ ]),
1475
+ bodyHtml: "<p>Alpha</p><p>Omega</p>",
1476
+ }),
1477
+ ],
1478
+ }),
1479
+ ),
1480
+ );
1481
+
1482
+ expect(xml).toContain("<jant:truncated/>");
1483
+ });
1484
+
1485
+ // A collection is a label the author chose, which is what `<category>` is
1486
+ // for — unlike the format, which no author typed.
1487
+ it("files an entry under its collections", () => {
1488
+ const xml = defaultFeedRenderer(
1489
+ makeFeedData(
1490
+ makePostView({
1491
+ collections: [
1492
+ { slug: "reading", title: "Reading", url: "/reading" },
1493
+ { slug: "notes", title: "Field Notes", url: "/notes" },
1494
+ ],
1495
+ }),
1496
+ ),
1497
+ );
1498
+
1499
+ // A single collection lives in the root URL namespace and a site path
1500
+ // prefix makes it unguessable from the term, so the URL rides along.
1501
+ expect(xml).toContain(
1502
+ '<category term="reading" label="Reading" jant:page="https://example.com/reading"/>',
1503
+ );
1504
+ expect(xml).toContain(
1505
+ '<category term="notes" label="Field Notes" jant:page="https://example.com/notes"/>',
1506
+ );
1507
+ });
1508
+
1509
+ it("emits no categories for a post in no collection", () => {
1510
+ expect(defaultFeedRenderer(makeFeedData(makePostView()))).not.toContain(
1511
+ "<category",
1512
+ );
1513
+ });
1514
+ });
1515
+
1516
+ describe("feed attachment metadata", () => {
1517
+ it("describes an image with Media RSS instead of an enclosure", () => {
1518
+ const xml = defaultFeedRenderer(
1519
+ makeFeedData(
1520
+ makePostView({
1521
+ media: [
1522
+ makeMediaView({
1523
+ url: "https://example.com/media/photo.jpg",
1524
+ thumbnailUrl: "https://example.com/media/photo-thumb.jpg",
1525
+ mimeType: "image/jpeg",
1526
+ size: 245000,
1527
+ width: 1600,
1528
+ height: 1200,
1529
+ altText: "A quiet street",
1530
+ originalName: "photo.jpg",
1531
+ }),
1532
+ ],
1533
+ }),
1534
+ ),
1535
+ );
1536
+
1537
+ expect(xml).toContain('xmlns:media="http://search.yahoo.com/mrss/"');
1538
+ expect(xml).toContain(
1539
+ '<media:content url="https://example.com/media/photo.jpg" ' +
1540
+ 'type="image/jpeg" medium="image" fileSize="245000" ' +
1541
+ 'width="1600" height="1200">',
1542
+ );
1543
+ expect(xml).toContain(
1544
+ '<media:description type="plain">A quiet street</media:description>',
1545
+ );
1546
+ expect(xml).toContain(
1547
+ '<media:thumbnail url="https://example.com/media/photo-thumb.jpg"/>',
1548
+ );
1549
+ // No enclosure for a picture the content already renders in full.
1550
+ expect(xml).not.toContain('rel="enclosure"');
1551
+ });
1552
+
1553
+ // An enclosure is for a file the content cannot inline. Audio is exactly
1554
+ // that; a picture the reader is already looking at is not.
1555
+ it("encloses audio but not the image beside it, and describes both", () => {
1556
+ const xml = defaultFeedRenderer(
1557
+ makeFeedData(
1558
+ makePostView({
1559
+ media: [
1560
+ makeMediaView({
1561
+ id: "med_img",
1562
+ url: "https://example.com/media/photo.jpg",
1563
+ thumbnailUrl: "https://example.com/media/photo.jpg",
1564
+ mimeType: "image/jpeg",
1565
+ size: 245000,
1566
+ }),
1567
+ makeMediaView({
1568
+ id: "med_audio",
1569
+ url: "https://example.com/media/song.mp3",
1570
+ thumbnailUrl: "https://example.com/media/song.mp3",
1571
+ mimeType: "audio/mpeg",
1572
+ size: 5242880,
1573
+ durationSeconds: 201,
1574
+ originalName: "song.mp3",
1575
+ }),
1576
+ ],
1577
+ }),
1578
+ ),
1579
+ );
1580
+
1581
+ expect(xml).toContain(
1582
+ '<link rel="enclosure" type="audio/mpeg" href="https://example.com/media/song.mp3"',
1583
+ );
1584
+ expect(xml).not.toContain('<link rel="enclosure" type="image/jpeg"');
1585
+
1586
+ // Media RSS still describes both — it is a metadata layer, not a shelf.
1587
+ expect(xml).toContain('medium="image"');
1588
+ expect(xml).toContain('medium="audio"');
1589
+ expect(xml).toContain('duration="201"');
1590
+ });
1591
+
1592
+ it("carries duration for timed media and resolves relative URLs", () => {
1593
+ const xml = defaultFeedRenderer(
1594
+ makeFeedData(
1595
+ makePostView({
1596
+ media: [
1597
+ makeMediaView({
1598
+ url: "/media/clip.mp4",
1599
+ thumbnailUrl: "/media/clip-poster.jpg",
1600
+ mimeType: "video/mp4",
1601
+ durationSeconds: 42.4,
1602
+ width: 1920,
1603
+ height: 1080,
1604
+ }),
1605
+ ],
1606
+ }),
1607
+ ),
1608
+ );
1609
+
1610
+ expect(xml).toContain(
1611
+ '<media:content url="https://example.com/media/clip.mp4" ' +
1612
+ 'type="video/mp4" medium="video" width="1920" height="1080" ' +
1613
+ 'duration="42">',
1614
+ );
1615
+ });
1616
+
1617
+ it("calls non-visual attachments documents", () => {
1618
+ const xml = defaultFeedRenderer(
1619
+ makeFeedData(
1620
+ makePostView({
1621
+ media: [
1622
+ makeMediaView({
1623
+ url: "https://example.com/media/spec.pdf",
1624
+ thumbnailUrl: "https://example.com/media/spec.pdf",
1625
+ mimeType: "application/pdf",
1626
+ originalName: "spec.pdf",
1627
+ }),
1628
+ ],
1629
+ }),
1630
+ ),
1631
+ );
1632
+
1633
+ expect(xml).toContain('medium="document"');
1634
+ expect(xml).toContain('<media:title type="plain">spec.pdf</media:title>');
1635
+ });
1636
+
1637
+ // Card and grid views need a representative image. Without one declared, a
1638
+ // reader has to scrape the first `<img>` out of the content HTML.
1639
+ it("declares a link preview as the entry's representative image", () => {
1640
+ const xml = defaultFeedRenderer(
1641
+ makeFeedData(
1642
+ makePostView({
1643
+ format: "link",
1644
+ url: "https://external.com/article",
1645
+ title: "An article",
1646
+ previewImageUrl: "/previews/article.jpg",
1647
+ }),
1648
+ ),
1649
+ );
1650
+
1651
+ expect(xml).toContain('xmlns:media="http://search.yahoo.com/mrss/"');
1652
+ expect(xml).toContain(
1653
+ '<media:thumbnail url="https://example.com/previews/article.jpg"/>',
1654
+ );
1655
+ // A scraped thumbnail of someone else's page is not a published file, so
1656
+ // it must not tell podcast and download clients to fetch it.
1657
+ expect(xml).not.toContain('rel="enclosure"');
1658
+ });
1659
+
1660
+ // The site lays a post's attachments out as one strip and marks the container
1661
+ // `data-post-media`. Carrying it into the feed lets a consumer style the
1662
+ // strip instead of reassembling it from the Media RSS elements.
1663
+ it("groups a post's attachments in one container", () => {
1664
+ const content =
1665
+ /<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
1666
+ defaultFeedRenderer(
1667
+ makeFeedData(
1668
+ makePostView({
1669
+ bodyHtml: "<p>Look.</p>",
1670
+ media: [
1671
+ makeMediaView({
1672
+ id: "med_a",
1673
+ url: "https://example.com/media/a.webp",
1674
+ thumbnailUrl: "https://example.com/media/a.webp",
1675
+ mimeType: "image/webp",
1676
+ }),
1677
+ makeMediaView({
1678
+ id: "med_b",
1679
+ url: "https://example.com/media/b.webp",
1680
+ thumbnailUrl: "https://example.com/media/b.webp",
1681
+ mimeType: "image/webp",
1682
+ }),
1683
+ ],
1684
+ }),
1685
+ ),
1686
+ ),
1687
+ )?.[1];
1688
+
1689
+ expect(content).toContain("<div data-post-media>");
1690
+ expect(content?.match(/<div data-post-media>/g)).toHaveLength(1);
1691
+ expect(content).toContain("a.webp");
1692
+ expect(content).toContain("b.webp");
1693
+ });
1694
+
1695
+ // A flat list cannot say which post in a thread an attachment belongs to.
1696
+ // The content can, because each post's strip sits beside its own text.
1697
+ it("gives each post in a thread its own container", () => {
1698
+ const content =
1699
+ /<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
1700
+ defaultFeedRenderer(
1701
+ makeFeedData(
1702
+ makePostView({
1703
+ bodyHtml: "<p>Root.</p>",
1704
+ media: [
1705
+ makeMediaView({
1706
+ id: "med_root",
1707
+ url: "https://example.com/media/root.webp",
1708
+ thumbnailUrl: "https://example.com/media/root.webp",
1709
+ mimeType: "image/webp",
1710
+ }),
1711
+ ],
1712
+ threadReplies: [
1713
+ makePostView({
1714
+ id: "reply-1",
1715
+ permalink: "/reply-1",
1716
+ bodyHtml: "<p>Reply.</p>",
1717
+ media: [
1718
+ makeMediaView({
1719
+ id: "med_reply",
1720
+ url: "https://example.com/media/reply.webp",
1721
+ thumbnailUrl: "https://example.com/media/reply.webp",
1722
+ mimeType: "image/webp",
1723
+ }),
1724
+ ],
1725
+ }),
1726
+ ],
1727
+ }),
1728
+ ),
1729
+ ),
1730
+ )?.[1];
1731
+
1732
+ expect(content?.match(/<div data-post-media>/g)).toHaveLength(2);
1733
+ // Each strip sits after its own post's text.
1734
+ expect(content?.indexOf("root.webp")).toBeLessThan(
1735
+ content?.indexOf("Reply.") ?? -1,
1736
+ );
1737
+ expect(content?.indexOf("Reply.")).toBeLessThan(
1738
+ content?.indexOf("reply.webp") ?? -1,
1739
+ );
1740
+ });
1741
+
1742
+ // A markdown file is not worth opening raw — the browser downloads it or
1743
+ // dumps it unstyled — so a text attachment points at the page that renders
1744
+ // it while `url` stays the file.
1745
+ it("points a text attachment at the page that renders it", () => {
1746
+ const xml = defaultFeedRenderer(
1747
+ makeFeedData(
1748
+ makePostView({
1749
+ permalink: "/hn2v7",
1750
+ media: [
1751
+ makeMediaView({
1752
+ id: "med_txt",
1753
+ url: "https://cdn.example.com/files/med_txt.md",
1754
+ thumbnailUrl: "https://cdn.example.com/files/med_txt.md",
1755
+ mimeType: "text/markdown; charset=utf-8",
1756
+ size: 21,
1757
+ summary: "松松哈哈哈",
1758
+ originalName: "attached-text.md",
1759
+ }),
1760
+ ],
1761
+ }),
1762
+ ),
1763
+ );
1764
+
1765
+ expect(xml).toContain('jant:page="https://example.com/hn2v7/text/med_txt"');
1766
+ // `url` stays the file, for fetching and for the enclosure.
1767
+ expect(xml).toContain('url="https://cdn.example.com/files/med_txt.md"');
1768
+ // A text attachment has no alt text; its excerpt is what the card prints.
1769
+ expect(xml).toContain(
1770
+ '<media:description type="plain">松松哈哈哈</media:description>',
1771
+ );
1772
+ });
1773
+
1774
+ it("gives an attachment no page of its own when the file is the page", () => {
1775
+ const xml = defaultFeedRenderer(
1776
+ makeFeedData(
1777
+ makePostView({
1778
+ media: [
1779
+ makeMediaView({
1780
+ url: "https://example.com/media/photo.jpg",
1781
+ thumbnailUrl: "https://example.com/media/photo.jpg",
1782
+ mimeType: "image/jpeg",
1783
+ }),
1784
+ ],
1785
+ }),
1786
+ ),
1787
+ );
1788
+
1789
+ expect(xml).not.toContain("jant:page");
1790
+ });
1791
+
1792
+ it("leaves the Media RSS namespace out of a feed with no attachments", () => {
1793
+ const xml = defaultFeedRenderer(makeFeedData(makePostView()));
1794
+ expect(xml).not.toContain("xmlns:media");
1795
+ expect(xml).not.toContain("<media:content");
1796
+ expect(xml).not.toContain("<media:thumbnail");
1797
+ });
1798
+ });
1799
+
1800
+ /**
1801
+ * A thread arrives as one entry, so both text constructs run several posts
1802
+ * together. These are the three things that let a consumer take it apart:
1803
+ * which entries are threads, where one post's words end, and which post owns
1804
+ * each file.
1805
+ */
1806
+ describe("feed entry thread segmentation", () => {
1807
+ function makeBody(paragraphs: string[]): string {
1808
+ return JSON.stringify({
1809
+ type: "doc",
1810
+ content: paragraphs.map((text) => ({
1811
+ type: "paragraph",
1812
+ content: [{ type: "text", text }],
1813
+ })),
1814
+ });
1815
+ }
1816
+
1817
+ function getSummary(xml: string): string | undefined {
1818
+ return /<summary type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/summary>/.exec(
1819
+ xml,
1820
+ )?.[1];
1821
+ }
1822
+
1823
+ function getContent(xml: string): string | undefined {
1824
+ return /<content type="html"><!\[CDATA\[([\s\S]*?)\]\]><\/content>/.exec(
1825
+ xml,
1826
+ )?.[1];
1827
+ }
1828
+
1829
+ function makeReply(n: number, overrides: Partial<FeedPostView> = {}) {
1830
+ return makePostView({
1831
+ id: `reply-${n}`,
1832
+ permalink: `/reply-${n}`,
1833
+ slug: `reply-${n}`,
1834
+ body: makeBody([`Reply ${n} body.`]),
1835
+ bodyHtml: `<p>Reply ${n} body.</p>`,
1836
+ publishedAt: `2026-03-2${n}T00:00:00.000Z`,
1837
+ publishedAtFormatted: `Mar 2${n}, 2026`,
1838
+ ...overrides,
1839
+ });
1840
+ }
1841
+
1842
+ function makeRoot(overrides: Partial<FeedPostView> = {}) {
1843
+ return makePostView({
1844
+ body: makeBody(["Root body."]),
1845
+ bodyHtml: "<p>Root body.</p>",
1846
+ ...overrides,
1847
+ });
1848
+ }
1849
+
1850
+ // The root's own marker is what makes the rule "every marker ends the block
1851
+ // before it" hold. Without it the root and the first reply share a segment.
1852
+ it("closes the root's own block in a thread, not just the replies'", () => {
1853
+ const summary = getSummary(
1854
+ defaultFeedRenderer(
1855
+ makeFeedData(makeRoot({ threadReplies: [makeReply(1)] })),
1856
+ ),
1857
+ );
1858
+
1859
+ expect(summary).toContain(
1860
+ '<p>Root body.</p>\n<p><small><a href="https://example.com/post-1" class="u-url"><time class="dt-published" datetime="2026-03-19T00:00:00.000Z">Mar 19, 2026</time></a></small></p>',
1861
+ );
1862
+ expect(summary).toContain(
1863
+ '<p>Reply 1 body.</p>\n<p><small><a href="https://example.com/reply-1" class="u-url"><time class="dt-published" datetime="2026-03-21T00:00:00.000Z">Mar 21, 2026</time></a></small></p>',
1864
+ );
1865
+ });
1866
+
1867
+ // A reader prints <published> itself, so a lone post that also ended with
1868
+ // its own date would show it twice.
1869
+ it("leaves a standalone entry unmarked", () => {
1870
+ const xml = defaultFeedRenderer(makeFeedData(makeRoot()));
1871
+
1872
+ expect(getSummary(xml)).not.toContain("dt-published");
1873
+ expect(getContent(xml)).not.toContain("dt-published");
1874
+ expect(xml).not.toContain("<jant:thread");
1875
+ });
1876
+
1877
+ // The marker ends a block, so a post that contributed no block to the
1878
+ // summary — a photo with no caption — must not leave one behind.
1879
+ it("marks no block for a thread root with no text", () => {
1880
+ const summary = getSummary(
1881
+ defaultFeedRenderer(
1882
+ makeFeedData(
1883
+ makePostView({
1884
+ media: [
1885
+ makeMediaView({
1886
+ url: "https://example.com/media/photo.jpg",
1887
+ mimeType: "image/jpeg",
1888
+ }),
1889
+ ],
1890
+ threadReplies: [makeReply(1)],
1891
+ }),
1892
+ ),
1893
+ ),
1894
+ );
1895
+
1896
+ expect(summary).not.toContain("https://example.com/post-1");
1897
+ expect(summary).toContain('href="https://example.com/reply-1"');
1898
+ });
1899
+
1900
+ it("closes every reply's block in the content, in order", () => {
1901
+ const content = getContent(
1902
+ defaultFeedRenderer(
1903
+ makeFeedData(makeRoot({ threadReplies: [makeReply(1), makeReply(2)] })),
1904
+ ),
1905
+ );
1906
+
1907
+ const markers = [...(content ?? "").matchAll(/class="u-url"/g)];
1908
+ expect(markers).toHaveLength(3);
1909
+ expect(content?.indexOf("Reply 1 body.")).toBeLessThan(
1910
+ content?.indexOf("Reply 2 body.") ?? -1,
1911
+ );
1912
+ });
1913
+
1914
+ it("declares a thread's shape so a consumer need not read the HTML", () => {
1915
+ const xml = defaultFeedRenderer(
1916
+ makeFeedData(
1917
+ makeRoot({
1918
+ threadReplies: [1, 2, 3, 4, 5, 6, 7].map((n) => makeReply(n)),
1919
+ }),
1920
+ ),
1921
+ );
1922
+
1923
+ // Seven replies: 1 and 2 lead, 5-7 trail, 3 and 4 fall in the gap.
1924
+ expect(xml).toContain(
1925
+ '<jant:thread posts="8" hidden="2" gap="https://example.com/reply-3" latest="https://example.com/reply-7">',
1926
+ );
1927
+ });
1928
+
1929
+ // One reply is the hero, so nothing folds away and there is nowhere to send
1930
+ // a reader who wants the middle.
1931
+ it("declares no gap when a thread hides nothing", () => {
1932
+ const xml = defaultFeedRenderer(
1933
+ makeFeedData(makeRoot({ threadReplies: [makeReply(1)] })),
1934
+ );
1935
+
1936
+ expect(xml).toContain(
1937
+ '<jant:thread posts="2" hidden="0" latest="https://example.com/reply-1">',
1938
+ );
1939
+ });
1940
+
1941
+ // `<jant:format>` describes the entry, which is the root. A reply that is a
1942
+ // Quote says so nowhere else — the `<blockquote>` in the content is not a
1943
+ // declaration a consumer laying the thread out itself can read.
1944
+ it("lists every post in the thread with its own format and date", () => {
1945
+ const xml = defaultFeedRenderer(
1946
+ makeFeedData(
1947
+ makeRoot({
1948
+ threadReplies: [
1949
+ makeReply(1, {
1950
+ format: "quote",
1951
+ title: "Marcus Aurelius",
1952
+ quoteText: "What stands in the way becomes the way.",
1953
+ }),
1954
+ ],
1955
+ }),
1956
+ ),
1957
+ );
1958
+
1959
+ expect(xml).toContain(
1960
+ '<jant:post href="https://example.com/post-1" format="note" published="2026-03-19T00:00:00.000Z"/>',
1961
+ );
1962
+ expect(xml).toContain(
1963
+ '<jant:post href="https://example.com/reply-1" format="quote" published="2026-03-21T00:00:00.000Z"/>',
1964
+ );
1965
+ // The entry still declares the root's format, unchanged.
1966
+ expect(xml).toContain("<jant:format>note</jant:format>");
1967
+ });
1968
+
1969
+ // A row carries what Atom would put on this post's entry. `<title>` and
1970
+ // `link[rel="alternate"]` only ever describe the root, so a titled reply and
1971
+ // a Link reply had nowhere to say either.
1972
+ it("gives a row the title, target and preview its own entry would carry", () => {
1973
+ const xml = defaultFeedRenderer(
1974
+ makeFeedData(
1975
+ makeRoot({
1976
+ threadReplies: [
1977
+ makeReply(1, {
1978
+ format: "link",
1979
+ title: "The AeroPress guide",
1980
+ url: "https://other.example/aeropress",
1981
+ previewImageUrl: "/media/preview.jpg",
1982
+ }),
1983
+ makeReply(2, { title: "A titled reply" }),
1984
+ ],
1985
+ }),
1986
+ ),
1987
+ );
1988
+
1989
+ expect(xml).toContain(
1990
+ '<jant:post href="https://example.com/reply-1" format="link" published="2026-03-21T00:00:00.000Z"' +
1991
+ ' title="The AeroPress guide" url="https://other.example/aeropress"' +
1992
+ ' thumbnail="https://example.com/media/preview.jpg"/>',
1993
+ );
1994
+ expect(xml).toContain(
1995
+ '<jant:post href="https://example.com/reply-2" format="note" published="2026-03-22T00:00:00.000Z" title="A titled reply"/>',
1996
+ );
1997
+ });
1998
+
1999
+ // The entry's own `<title>` is empty for a quote because the attribution is
2000
+ // not a title. A row follows the same rule.
2001
+ it("keeps a quote reply's attribution out of its row title", () => {
2002
+ const xml = defaultFeedRenderer(
2003
+ makeFeedData(
2004
+ makeRoot({
2005
+ threadReplies: [
2006
+ makeReply(1, {
2007
+ format: "quote",
2008
+ title: "Marcus Aurelius",
2009
+ quoteText: "What stands in the way becomes the way.",
2010
+ }),
2011
+ ],
2012
+ }),
2013
+ ),
2014
+ );
2015
+
2016
+ expect(xml).toContain(
2017
+ '<jant:post href="https://example.com/reply-1" format="quote" published="2026-03-21T00:00:00.000Z"/>',
2018
+ );
2019
+ expect(xml).not.toContain('title="Marcus Aurelius"');
2020
+ });
2021
+
2022
+ // Truncation lives in `<summary>`, which renders the root and the newest
2023
+ // reply. A folded post has no block to cut, so its row must stay silent
2024
+ // rather than claim it arrived whole.
2025
+ it("marks every cut post's row, and no folded post's", () => {
2026
+ const longBody = makeBody([
2027
+ "Alpha ".repeat(80).trim(),
2028
+ "Bravo ".repeat(80).trim(),
2029
+ "Charlie ".repeat(80).trim(),
2030
+ "Delta ".repeat(80).trim(),
2031
+ ]);
2032
+ const xml = defaultFeedRenderer(
2033
+ makeFeedData(
2034
+ makeRoot({
2035
+ threadReplies: [1, 2, 3, 4, 5, 6, 7].map((n) =>
2036
+ makeReply(n, { body: longBody }),
2037
+ ),
2038
+ }),
2039
+ ),
2040
+ );
2041
+
2042
+ // Reply 1 leads and reply 7 is the hero: both render, so both can be cut.
2043
+ expect(xml).toContain(
2044
+ '<jant:post href="https://example.com/reply-1" format="note" published="2026-03-21T00:00:00.000Z" truncated="true"/>',
2045
+ );
2046
+ expect(xml).toContain(
2047
+ '<jant:post href="https://example.com/reply-7" format="note" published="2026-03-27T00:00:00.000Z" truncated="true"/>',
2048
+ );
2049
+ // Reply 3 is behind the gap. Its body is just as long, and its row says it
2050
+ // was folded rather than that it arrived whole.
2051
+ expect(xml).toContain(
2052
+ '<jant:post href="https://example.com/reply-3" format="note" published="2026-03-23T00:00:00.000Z" folded="true"/>',
2053
+ );
2054
+ // The entry still answers the single-card reader with one boolean.
2055
+ expect(xml).toContain("<jant:truncated/>");
2056
+ });
2057
+
2058
+ // Every other decision on a row is declared. This one used to be left to a
2059
+ // consumer reading the summary's shape, which misreads a photo with no
2060
+ // caption: the site renders it, it contributes no text, and it comes out
2061
+ // looking exactly like a post the fold hid.
2062
+ it("declares which posts the fold hid", () => {
2063
+ const xml = defaultFeedRenderer(
2064
+ makeFeedData(
2065
+ makeRoot({
2066
+ threadReplies: [1, 2, 3, 4, 5, 6, 7].map((n) => makeReply(n)),
2067
+ }),
2068
+ ),
2069
+ );
2070
+
2071
+ const folded = [...xml.matchAll(/<jant:post [^>]*folded="true"[^>]*\/>/g)];
2072
+ expect(folded).toHaveLength(2);
2073
+ // The same two the gap stands for, and the same count `@hidden` reports.
2074
+ expect(xml).toContain(
2075
+ '<jant:post href="https://example.com/reply-3" format="note" published="2026-03-23T00:00:00.000Z" folded="true"/>',
2076
+ );
2077
+ expect(xml).toContain(
2078
+ '<jant:post href="https://example.com/reply-4" format="note" published="2026-03-24T00:00:00.000Z" folded="true"/>',
2079
+ );
2080
+ expect(xml).toContain('hidden="2"');
2081
+ });
2082
+
2083
+ // A caption-less photo is a normal Jant post. It renders on the site, so its
2084
+ // row must not claim the fold hid it — a consumer that believed otherwise
2085
+ // would drop its attachments and the post would vanish.
2086
+ it("leaves a rendered post with no text unfolded", () => {
2087
+ const xml = defaultFeedRenderer(
2088
+ makeFeedData(
2089
+ makeRoot({
2090
+ threadReplies: [
2091
+ makePostView({
2092
+ id: "reply-1",
2093
+ permalink: "/reply-1",
2094
+ slug: "reply-1",
2095
+ media: [
2096
+ makeMediaView({
2097
+ url: "https://example.com/media/photo.jpg",
2098
+ thumbnailUrl: "https://example.com/media/photo.jpg",
2099
+ mimeType: "image/jpeg",
2100
+ }),
2101
+ ],
2102
+ }),
2103
+ makeReply(2),
2104
+ ],
2105
+ }),
2106
+ ),
2107
+ );
2108
+
2109
+ expect(xml).toContain('hidden="0"');
2110
+ expect(xml).not.toContain('folded="true"');
2111
+ // It contributes nothing to the summary, which is exactly why the row has
2112
+ // to speak for it.
2113
+ expect(getSummary(xml)).not.toContain("https://example.com/reply-1");
2114
+ expect(xml).toContain('jant:post="https://example.com/reply-1"');
2115
+ });
2116
+
2117
+ // A reply carries chrome the entry's own fields carry for the root, so a
2118
+ // consumer drawing native cards has to drop it before redrawing from the
2119
+ // row. Matching it by shape is fragile — a body can open with a link.
2120
+ it("wraps a reply's title and source line in a header element", () => {
2121
+ const xml = defaultFeedRenderer(
2122
+ makeFeedData(
2123
+ makeRoot({
2124
+ threadReplies: [
2125
+ makeReply(1, {
2126
+ format: "link",
2127
+ title: "The AeroPress guide",
2128
+ url: "https://other.example/aeropress",
2129
+ }),
2130
+ ],
2131
+ }),
2132
+ ),
2133
+ );
2134
+
2135
+ expect(getSummary(xml)).toContain(
2136
+ '<header><p><a href="https://other.example/aeropress">other.example</a></p>' +
2137
+ '<h2><a href="https://other.example/aeropress">The AeroPress guide</a></h2></header>',
2138
+ );
2139
+ // The root's chrome stays in the entry's own fields, so its block has no
2140
+ // header to drop and a consumer needs no special case for it.
2141
+ expect(getSummary(xml)?.indexOf("<header>")).toBeGreaterThan(
2142
+ getSummary(xml)?.indexOf("Root body.") ?? -1,
2143
+ );
2144
+ });
2145
+
2146
+ it("gives a reply with no title and no source no header at all", () => {
2147
+ const xml = defaultFeedRenderer(
2148
+ makeFeedData(makeRoot({ threadReplies: [makeReply(1)] })),
2149
+ );
2150
+
2151
+ expect(getSummary(xml)).not.toContain("<header>");
2152
+ });
2153
+
2154
+ it("lists no thread rows on a lone post", () => {
2155
+ const xml = defaultFeedRenderer(makeFeedData(makeRoot()));
2156
+
2157
+ expect(xml).not.toContain("<jant:post");
2158
+ expect(xml).not.toContain("jant:post=");
2159
+ });
2160
+
2161
+ it("names the post carrying every attachment in a thread, the root's included", () => {
2162
+ const xml = defaultFeedRenderer(
2163
+ makeFeedData(
2164
+ makeRoot({
2165
+ media: [
2166
+ makeMediaView({
2167
+ id: "med_root",
2168
+ url: "https://example.com/media/beans.jpg",
2169
+ thumbnailUrl: "https://example.com/media/beans.jpg",
2170
+ mimeType: "image/jpeg",
2171
+ }),
2172
+ ],
2173
+ threadReplies: [
2174
+ makeReply(1, {
2175
+ media: [
2176
+ makeMediaView({
2177
+ id: "med_reply",
2178
+ url: "https://example.com/media/cup.jpg",
2179
+ thumbnailUrl: "https://example.com/media/cup.jpg",
2180
+ mimeType: "image/jpeg",
2181
+ }),
2182
+ ],
2183
+ }),
2184
+ ],
2185
+ }),
2186
+ ),
2187
+ );
2188
+
2189
+ // The root's file names its post too. Guessing wrong here hangs a photo
2190
+ // under the wrong post, which is not what a missing attribute should mean.
2191
+ expect(xml).toContain(
2192
+ '<media:content url="https://example.com/media/beans.jpg" type="image/jpeg" medium="image" jant:post="https://example.com/post-1"/>',
2193
+ );
2194
+ expect(xml).toContain(
2195
+ '<media:content url="https://example.com/media/cup.jpg" type="image/jpeg" medium="image" jant:post="https://example.com/reply-1"/>',
2196
+ );
2197
+ });
2198
+
2199
+ // A lone post's entry has exactly one post, so the attribute would only
2200
+ // repeat `<id>` on every file.
2201
+ it("leaves a lone post's attachment unattributed", () => {
2202
+ const xml = defaultFeedRenderer(
2203
+ makeFeedData(
2204
+ makeRoot({
2205
+ media: [
2206
+ makeMediaView({
2207
+ url: "https://example.com/media/beans.jpg",
2208
+ thumbnailUrl: "https://example.com/media/beans.jpg",
2209
+ mimeType: "image/jpeg",
2210
+ }),
2211
+ ],
2212
+ }),
2213
+ ),
2214
+ );
2215
+
2216
+ expect(xml).toContain("<media:content");
2217
+ expect(xml).not.toContain("jant:post=");
2218
+ });
2219
+ });