@jant/core 0.6.16 → 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 (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -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-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  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-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  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__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -81,6 +81,23 @@ describe("feed renderers", () => {
81
81
  expect(feedUpdated).toBe("2026-05-09T00:00:00.000Z");
82
82
  });
83
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
+
84
101
  it("falls back to the render time for an empty feed", () => {
85
102
  const xml = defaultFeedRenderer({
86
103
  ...makeFeedData(makePostView()),
@@ -107,7 +124,10 @@ describe("feed renderers", () => {
107
124
  );
108
125
 
109
126
  expect(xml).toContain("<title></title>");
110
- 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
+ );
111
131
  expect(xml).toContain("<![CDATA[<p>哈哈哈😍</p>]]>");
112
132
  expect(xml).not.toContain("data-jant-meta");
113
133
  expect(xml).not.toContain('{"kind":"text"}');
@@ -186,10 +206,89 @@ describe("feed renderers", () => {
186
206
 
187
207
  expect(xml).toContain("<title></title>");
188
208
  expect(xml).toContain(
189
- '<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>",
190
211
  );
191
212
  expect(xml).toContain("Marcus Aurelius");
192
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>");
193
292
  });
194
293
 
195
294
  it("link posts point <link> to original URL with ★ permalink back to blog", () => {
@@ -233,8 +332,15 @@ describe("feed renderers", () => {
233
332
  expect(xml).toContain(
234
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>',
235
334
  );
236
- expect(xml.indexOf("<figure>")).toBeLessThan(
237
- xml.indexOf("My notes on the video."),
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."),
238
344
  );
239
345
  expect(xml).not.toContain("<iframe");
240
346
  expect(xml).not.toContain('rel="enclosure"');
@@ -296,7 +402,7 @@ describe("feed renderers", () => {
296
402
  expect(xml).toContain("<updated>2026-03-20T09:45:00.000Z</updated>");
297
403
  });
298
404
 
299
- it("renders thread replies with hr separator and time element", () => {
405
+ it("renders thread replies with hr separator and a dated permalink", () => {
300
406
  const reply = makePostView({
301
407
  id: "reply-1",
302
408
  permalink: "/reply-1",
@@ -321,7 +427,9 @@ describe("feed renderers", () => {
321
427
 
322
428
  expect(xml).toContain("<p>Root content</p>");
323
429
  expect(xml).toContain("<hr/>");
324
- 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
+ );
325
433
  expect(xml).toContain("<p>This is a reply</p>");
326
434
  });
327
435
 
@@ -474,12 +582,12 @@ describe("feed renderers", () => {
474
582
  '<img src="https://example.com/media/photo.jpg" alt="A red bicycle" width="1200" height="800"/>',
475
583
  );
476
584
  expect(xml).toContain("<figcaption>A red bicycle</figcaption>");
477
- expect(xml).toContain(
478
- '<link rel="enclosure" type="image/jpeg" href="https://example.com/media/photo.jpg" length="245000"',
479
- );
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"');
480
588
  });
481
589
 
482
- it("renders video attachments as poster + caption (never inline <video>)", () => {
590
+ it("inlines a video player with the poster as an attribute", () => {
483
591
  const post = makePostView({
484
592
  media: [
485
593
  makeMediaView({
@@ -497,12 +605,19 @@ describe("feed renderers", () => {
497
605
  });
498
606
  const xml = defaultFeedRenderer(makeFeedData(post));
499
607
 
500
- expect(xml).not.toContain("<video");
501
608
  expect(xml).toContain(
502
- '<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>",
503
614
  );
504
- // The action label is a link (not just the thumbnail); metadata sits
505
- // 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.
506
621
  expect(xml).toContain(
507
622
  '<figcaption><a href="https://example.com/media/clip.mp4">▶ Watch video</a> (0:42 · 1.1 MB)</figcaption>',
508
623
  );
@@ -511,6 +626,74 @@ describe("feed renderers", () => {
511
626
  );
512
627
  });
513
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
+
514
697
  it("resolves relative attachment, poster, and enclosure URLs", () => {
515
698
  const post = makePostView({
516
699
  media: [
@@ -526,7 +709,10 @@ describe("feed renderers", () => {
526
709
  const xml = defaultFeedRenderer(makeFeedData(post));
527
710
 
528
711
  expect(xml).toContain(
529
- '<a href="https://example.com/media/local-clip.mp4"><img src="https://example.com/media/local-clip-poster.jpg"',
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"/>',
530
716
  );
531
717
  expect(xml).toContain(
532
718
  '<a href="https://example.com/media/local-clip.mp4">▶ Watch video</a>',
@@ -706,3 +892,1328 @@ describe("feed renderers", () => {
706
892
  expect(xml).not.toContain("<figure>");
707
893
  });
708
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
+ });