@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
package/src/lib/feed.ts CHANGED
@@ -10,9 +10,28 @@
10
10
  * ```
11
11
  */
12
12
 
13
- import type { FeedData, FeedPostView, MediaView, PostView } from "../types.js";
13
+ import type {
14
+ FeedData,
15
+ FeedPostView,
16
+ LanguageAlternate,
17
+ MediaView,
18
+ PostView,
19
+ } from "../types.js";
20
+ import { DISCOVER_NAMESPACE_URI } from "./discover.js";
21
+ import { extractTimelineSummary } from "./summary.js";
22
+ import { getLinkPreviewProviderLabel } from "./link-preview.js";
14
23
  import { extractDisplayDomain } from "./url.js";
15
24
  import { getMediaCategory } from "./upload.js";
25
+ import { foldThreadReplies } from "./thread-fold.js";
26
+ import type { ThreadFold } from "./thread-fold.js";
27
+
28
+ /**
29
+ * Media RSS namespace. Atom's own `<link rel="enclosure">` has no slots for
30
+ * pixel dimensions, duration, or a description, so the richer per-attachment
31
+ * metadata rides in this extension alongside it — enclosure stays because it
32
+ * is the only attachment mechanism a plain Atom parser understands.
33
+ */
34
+ const MEDIA_RSS_NAMESPACE_URI = "http://search.yahoo.com/mrss/";
16
35
 
17
36
  /**
18
37
  * Escape special XML characters.
@@ -40,6 +59,49 @@ function escapeCdata(str: string): string {
40
59
  return str.replaceAll("]]>", "]]]]><![CDATA[>");
41
60
  }
42
61
 
62
+ /**
63
+ * Resolve a URL for use outside the feed document's browser context.
64
+ *
65
+ * Feed readers do not consistently resolve root-relative URLs found inside
66
+ * Atom HTML content or enclosure attributes, so every non-fragment URL must
67
+ * carry its own origin.
68
+ */
69
+ function toAbsoluteFeedUrl(url: string, siteUrl: string): string {
70
+ const normalizedUrl = url.trim();
71
+ if (!normalizedUrl || normalizedUrl.startsWith("#")) return normalizedUrl;
72
+
73
+ try {
74
+ const baseUrl = siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`;
75
+ return new URL(normalizedUrl, baseUrl).toString();
76
+ } catch {
77
+ return normalizedUrl;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Resolve navigational and media URL attributes inside trusted post HTML.
83
+ *
84
+ * Fragment-only links stay local to the rendered feed entry so footnotes and
85
+ * other in-entry references continue to work.
86
+ */
87
+ function absolutizeFeedHtmlUrls(html: string, siteUrl: string): string {
88
+ return html.replaceAll(
89
+ /(\s)(href|poster|src)=(["'])([^"']*)\3/gi,
90
+ (
91
+ match,
92
+ whitespace: string,
93
+ attribute: string,
94
+ quote: string,
95
+ url: string,
96
+ ) => {
97
+ const absoluteUrl = toAbsoluteFeedUrl(url, siteUrl);
98
+ return absoluteUrl
99
+ ? `${whitespace}${attribute}=${quote}${absoluteUrl}${quote}`
100
+ : match;
101
+ },
102
+ );
103
+ }
104
+
43
105
  /**
44
106
  * Strip embedded content that is unsafe or unsupported in feed readers.
45
107
  *
@@ -99,6 +161,20 @@ interface SinglePostContentOptions {
99
161
  * through Atom fields.
100
162
  */
101
163
  inline?: boolean;
164
+ /**
165
+ * Render only what a summary carries: the timeline's truncated body, the
166
+ * quoted text, the rating. Media, link previews and the ★ permalink are the
167
+ * post's content, and a consumer reads those from `<content>` or from the
168
+ * Media RSS elements.
169
+ */
170
+ summary?: boolean;
171
+ /**
172
+ * The post's timeline summary, read only in summary mode. `buildFeedEntry`
173
+ * computes it once per post because three elements ask for it, so this is
174
+ * handed in rather than derived here. Null means the post has no TipTap
175
+ * document to truncate and keeps its full body.
176
+ */
177
+ timelineSummary?: TimelineSummary | null;
102
178
  }
103
179
 
104
180
  function renderLinkedText(text: string, href?: string): string {
@@ -106,7 +182,37 @@ function renderLinkedText(text: string, href?: string): string {
106
182
  return href ? `<a href="${escapeXml(href)}">${label}</a>` : label;
107
183
  }
108
184
 
109
- function renderInlinePostHeader(
185
+ /**
186
+ * The title and source line a reply needs, since only the entry's own root has
187
+ * `<title>` and `link[@rel="alternate"]` to carry them.
188
+ *
189
+ * Wrapped in `<header>`, which is the whole point of the element here: a
190
+ * consumer drawing its own card takes this post's title, target and preview
191
+ * from its `<jant:post>` row, and has to drop what the text already says or
192
+ * print it twice. Removing one named element beats matching "a leading `<p>`
193
+ * holding a link, then an `<h2>`" — a body's own first paragraph can be
194
+ * exactly that. The rule ends up uniform: drop any `<header>`, draw the chrome
195
+ * from the row. A root block has none to drop and needs no special case.
196
+ *
197
+ * A reader that knows nothing about any of this still sees the heading, which
198
+ * is why the markup stays in `<summary>` rather than moving to the row alone.
199
+ *
200
+ * @param post - The reply being rendered inline
201
+ * @param permalinkUrl - Absolute permalink, for a titled note's heading link
202
+ * @returns A `<header>` block, or "" when the post has no chrome to show
203
+ * @example
204
+ * renderInlinePostHeader(linkReply, url);
205
+ * // '<header><p><a …>example.com</a></p><h2><a …>Title</a></h2></header>'
206
+ */
207
+ function renderInlinePostHeader(post: PostView, permalinkUrl?: string): string {
208
+ return wrapInlineHeader(collectInlineHeaderParts(post, permalinkUrl));
209
+ }
210
+
211
+ function wrapInlineHeader(parts: string[]): string {
212
+ return parts.length > 0 ? `<header>${parts.join("")}</header>` : "";
213
+ }
214
+
215
+ function collectInlineHeaderParts(
110
216
  post: PostView,
111
217
  permalinkUrl?: string,
112
218
  ): string[] {
@@ -135,6 +241,30 @@ function renderInlinePostHeader(
135
241
  return parts;
136
242
  }
137
243
 
244
+ /**
245
+ * Render author-authored plain text as feed-safe HTML, preserving its breaks.
246
+ *
247
+ * The site keeps quote line breaks with `white-space: pre-line`, but feed
248
+ * readers strip CSS, so the breaks have to be structural: a blank line starts
249
+ * a new `<p>`, a single newline becomes a `<br/>`.
250
+ *
251
+ * @param text - Raw plain text as the author typed it
252
+ * @returns One or more `<p>` blocks, or an empty string for blank input
253
+ * @example
254
+ * renderPlainTextHtml("one\ntwo\n\nthree")
255
+ * // => "<p>one<br/>two</p>\n<p>three</p>"
256
+ */
257
+ function renderPlainTextHtml(text: string): string {
258
+ return text
259
+ .replaceAll("\r\n", "\n")
260
+ .replaceAll("\r", "\n")
261
+ .split(/\n{2,}/)
262
+ .map((block) => block.trim())
263
+ .filter((block) => block.length > 0)
264
+ .map((block) => `<p>${escapeXml(block).replaceAll("\n", "<br/>")}</p>`)
265
+ .join("\n");
266
+ }
267
+
138
268
  /**
139
269
  * Render a star rating as HTML for feed content.
140
270
  */
@@ -144,6 +274,35 @@ function renderRatingHtml(rating: number): string {
144
274
  return `<p>${filled}${empty} ${rating}/5</p>`;
145
275
  }
146
276
 
277
+ /**
278
+ * Render a Link post preview as feed-safe HTML.
279
+ *
280
+ * Feed readers commonly strip CSS overlays and embedded players, so video
281
+ * previews use a linked thumbnail plus a visible provider-aware action.
282
+ * Non-video Link previews keep the linked thumbnail without a video label.
283
+ */
284
+ function renderLinkPreviewForFeed(post: PostView, siteUrl: string): string {
285
+ if (post.format !== "link") return "";
286
+
287
+ const imageUrl = post.previewImageUrl?.trim();
288
+ const linkUrl = post.url?.trim();
289
+ if (!imageUrl || !linkUrl) return "";
290
+
291
+ const isVideo = post.previewKind?.trim().toLowerCase() === "video";
292
+ const providerLabel = getLinkPreviewProviderLabel(post.previewProvider);
293
+ const fallbackAlt = isVideo
294
+ ? providerLabel
295
+ ? `${providerLabel} video`
296
+ : "Video preview"
297
+ : "Link preview";
298
+ const altText = post.title?.trim() || fallbackAlt;
299
+ const caption = isVideo
300
+ ? `<figcaption><a href="${escapeXml(linkUrl)}">▶ ${providerLabel ? `Watch on ${providerLabel}` : "Watch video"}</a></figcaption>`
301
+ : "";
302
+
303
+ return `<figure><a href="${escapeXml(linkUrl)}"><img src="${escapeXml(toAbsoluteFeedUrl(imageUrl, siteUrl))}" alt="${escapeXml(altText)}"/></a>${caption}</figure>`;
304
+ }
305
+
147
306
  function formatFeedBytes(bytes: number): string {
148
307
  if (bytes < 1024) return `${bytes} B`;
149
308
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
@@ -203,10 +362,13 @@ function buildAttachmentLinkText(
203
362
  * and duration metadata when known. Text attachments link to the rendered
204
363
  * preview page when a post permalink is available.
205
364
  */
206
- function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
365
+ function renderMediaItem(
366
+ item: MediaView,
367
+ siteUrl: string,
368
+ postPermalinkUrl?: string,
369
+ ): string {
207
370
  const category = getMediaCategory(item.mimeType);
208
- const url = escapeXml(item.url);
209
- const name = item.originalName ?? "";
371
+ const url = escapeXml(toAbsoluteFeedUrl(item.url, siteUrl));
210
372
  const altText = item.altText ?? "";
211
373
  const caption = item.altText?.trim() || "";
212
374
  const meta = getMediaMeta(item);
@@ -223,19 +385,38 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
223
385
  }
224
386
 
225
387
  if (category === "video") {
226
- const poster = item.posterUrl || item.thumbnailUrl;
388
+ // One rendering whether or not the clip has a poster frame — the still is
389
+ // an attribute here, not a branch. `preload="none"` is load-bearing: a
390
+ // reader painting a timeline must not start pulling a 28 MB file.
391
+ //
392
+ // `thumbnailUrl` is a real still only for images; the media pipeline
393
+ // leaves it pointing at the file itself for everything else, so a clip
394
+ // with no poster key would otherwise poster itself with its own MP4.
395
+ //
396
+ // The link sits in the `<figcaption>`, outside the `<video>`, rather than
397
+ // as its fallback child: a sanitizer that drops a disallowed element takes
398
+ // its children with it, and out here the clip stays reachable whichever
399
+ // way a reader's allowlist goes. That makes inlining the player a pure
400
+ // upgrade over the old poster-thumbnail rendering — worst case it degrades
401
+ // to exactly the link that rendering already offered.
402
+ const posterSource = (item.posterUrl || item.thumbnailUrl)?.trim();
403
+ const posterAttr =
404
+ posterSource && posterSource !== item.url
405
+ ? ` poster="${escapeXml(toAbsoluteFeedUrl(posterSource, siteUrl))}"`
406
+ : "";
227
407
  const dims =
228
408
  item.width && item.height
229
409
  ? ` width="${item.width}" height="${item.height}"`
230
410
  : "";
231
- // Prefix the caption with a ▶ glyph as a video cue. A CSS overlay would
232
- // be stripped by most feed-reader sanitizers, so a plain-text play
233
- // character is the only marker that renders reliably everywhere. Link
234
- // only the "Watch video" action label (so it's clickable like the
235
- // thumbnail and reads cleanly to screen readers); metadata stays outside
236
- // the link in parens, matching the audio/text/document attachment style.
237
411
  const metaSuffix = meta ? ` (${escapeXml(meta)})` : "";
238
- return `<figure><a href="${url}"><img src="${escapeXml(poster)}" alt="${escapeXml(altText || name)}"${dims}/></a><figcaption><a href="${url}">▶ Watch video</a>${metaSuffix}</figcaption></figure>`;
412
+ // `<video>` has no `alt`, so the description a poster image used to carry
413
+ // moves into the caption rather than being dropped.
414
+ const altSuffix = caption ? `: ${escapeXml(caption)}` : "";
415
+ return (
416
+ `<figure><video controls preload="none"${posterAttr}${dims}>` +
417
+ `<source src="${url}" type="${escapeXml(cleanMimeType(item.mimeType))}"/>` +
418
+ `</video><figcaption><a href="${url}">▶ Watch video</a>${metaSuffix}${altSuffix}</figcaption></figure>`
419
+ );
239
420
  }
240
421
 
241
422
  if (category === "audio") {
@@ -245,9 +426,9 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
245
426
  }
246
427
 
247
428
  if (category === "text") {
248
- const previewHref = postPermalinkUrl
249
- ? escapeXml(`${postPermalinkUrl}/text/${item.id}`)
250
- : url;
429
+ const previewHref = escapeXml(
430
+ getMediaPageUrl(item, siteUrl, postPermalinkUrl),
431
+ );
251
432
  const linkText = buildAttachmentLinkText(item, "Attached text");
252
433
  // Prefer character count over byte size — more meaningful for text.
253
434
  const textMeta =
@@ -272,12 +453,68 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
272
453
  */
273
454
  function renderMediaForFeed(
274
455
  media: MediaView[],
456
+ siteUrl: string,
275
457
  postPermalinkUrl?: string,
276
458
  ): string {
277
459
  if (media.length === 0) return "";
278
- return media
279
- .map((item) => renderMediaItem(item, postPermalinkUrl))
460
+ const items = media
461
+ .map((item) => renderMediaItem(item, siteUrl, postPermalinkUrl))
280
462
  .join("\n");
463
+ // The site lays a post's attachments out as one horizontally scrolling strip
464
+ // and marks that container `data-post-media`, which is part of the markup
465
+ // contract themes and external scripts already read. Carrying the same
466
+ // container into the feed lets a consumer style the strip instead of
467
+ // reassembling it from the Media RSS elements — and in a thread it puts each
468
+ // post's attachments next to that post's own text, which a flat list cannot.
469
+ // A reader that ignores the attribute stacks the figures exactly as before.
470
+ return `<div data-post-media>\n${items}\n</div>`;
471
+ }
472
+
473
+ /** The timeline's truncated rendering of a post, and whether it was cut. */
474
+ interface TimelineSummary {
475
+ html: string;
476
+ hasMore: boolean;
477
+ }
478
+
479
+ /**
480
+ * The truncated body the site's timeline renders for this post, at the same
481
+ * boundary the page uses.
482
+ *
483
+ * Derived here rather than read off `PostView.summaryHtml`, which exists only
484
+ * for titled posts: an untitled note expands in place on the page, so the card
485
+ * renders the full body with a break marker and never needs a second HTML
486
+ * string. `NoteCard` reads `summaryHtml ?? bodyHtml`, so populating it for
487
+ * notes to serve the feed would silently switch the site off expand-in-place.
488
+ * `PostView.body` carries the source document, so the feed derives its own.
489
+ *
490
+ * A Quote is not cut at all. `QuoteCard` passes `bodyHtml` straight through and
491
+ * nothing clamps `.feed-quote-commentary`, so the site shows a quote's
492
+ * commentary whole however long it runs — and `<summary>` is the timeline's
493
+ * rendering, not a shorter one of the feed's own. The quoted text is not cut
494
+ * either, so a Quote arrives entire on both halves.
495
+ *
496
+ * This is the one exception. An untitled note's body *is* cut here even though
497
+ * the site renders it whole, because the site hides the tail with CSS the
498
+ * reader strips.
499
+ *
500
+ * Each call parses and renders the TipTap document, and three elements of an
501
+ * entry want the answer — `<summary>`, the `<jant:post>` row, and
502
+ * `<jant:truncated/>`. `buildFeedEntry` therefore calls this once per post
503
+ * and the elements read `FeedEntry.summaryPosts`; nothing else should call it
504
+ * inside the entry render.
505
+ *
506
+ * @param post - Post view data, carrying the TipTap document in `body`
507
+ * @returns Truncated HTML and whether content continues, null when the post has
508
+ * no TipTap document to truncate or is a Quote
509
+ * @example
510
+ * getTimelineSummary(post) // { html: "<p>Intro</p>", hasMore: true }
511
+ */
512
+ function getTimelineSummary(post: PostView): TimelineSummary | null {
513
+ if (post.format === "quote") return null;
514
+
515
+ return extractTimelineSummary(post.body, !!post.title, {
516
+ namespace: post.id,
517
+ });
281
518
  }
282
519
 
283
520
  /**
@@ -289,50 +526,95 @@ function renderMediaForFeed(
289
526
  */
290
527
  function buildSinglePostContent(
291
528
  post: PostView,
529
+ siteUrl: string,
292
530
  permalinkUrl?: string,
293
531
  options: SinglePostContentOptions = {},
294
532
  ): string {
295
533
  const parts: string[] = [];
296
534
 
297
535
  if (options.inline) {
298
- parts.push(...renderInlinePostHeader(post, permalinkUrl));
536
+ const header = renderInlinePostHeader(post, permalinkUrl);
537
+ if (header) parts.push(header);
299
538
  }
300
539
 
540
+ let quoteRendered = false;
301
541
  if (post.format === "quote" && post.quoteText) {
302
542
  const sourceName = post.title || "";
303
543
  const sourceUrl = post.url || "";
304
544
  const attribution = sourceName || sourceUrl;
305
545
  const cite = sourceUrl ? ` cite="${escapeXml(sourceUrl)}"` : "";
306
- parts.push(
307
- `<blockquote${cite}><p>${escapeXml(post.quoteText)}</p></blockquote>`,
308
- );
309
- if (attribution) {
310
- const source = sourceUrl
546
+ const quoteHtml = renderPlainTextHtml(post.quoteText);
547
+ const source = attribution
548
+ ? sourceUrl
311
549
  ? `<a href="${escapeXml(sourceUrl)}">${escapeXml(sourceName || extractDisplayDomain(sourceUrl) || sourceUrl)}</a>`
312
- : escapeXml(attribution);
550
+ : escapeXml(attribution)
551
+ : "";
552
+ if (quoteHtml) {
553
+ // `<figure>`/`<figcaption>` is how the site card groups the quote with
554
+ // its source (`h-cite`), and how this renderer already pairs media with
555
+ // a caption. A loose `<p>— source</p>` sibling said neither.
556
+ const caption = source ? `<figcaption>— ${source}</figcaption>` : "";
557
+ parts.push(
558
+ `<figure><blockquote${cite}>${quoteHtml}</blockquote>${caption}</figure>`,
559
+ );
560
+ quoteRendered = true;
561
+ } else if (source) {
313
562
  parts.push(`<p>— ${source}</p>`);
314
563
  }
315
564
  }
316
565
 
317
- if (post.bodyHtml) {
318
- parts.push(stripUnsafeFeedHtml(post.bodyHtml));
566
+ // The preview image is media: `<media:thumbnail>` carries it for a consumer
567
+ // laying out its own card, so the summary has no reason to repeat the markup.
568
+ if (!options.summary) {
569
+ const linkPreviewHtml = renderLinkPreviewForFeed(post, siteUrl);
570
+ if (linkPreviewHtml) {
571
+ parts.push(linkPreviewHtml);
572
+ }
319
573
  }
320
574
 
321
- const mediaHtml = renderMediaForFeed(post.media, permalinkUrl);
322
- if (mediaHtml) {
323
- parts.push(mediaHtml);
575
+ // In summary mode the body is the timeline's truncated rendering; a post
576
+ // without a TipTap document (legacy plain-text rows) has nothing to truncate
577
+ // and keeps its full body.
578
+ const bodyHtml = options.summary
579
+ ? (options.timelineSummary?.html ?? post.bodyHtml)
580
+ : post.bodyHtml;
581
+
582
+ if (bodyHtml) {
583
+ // The site draws a hairline between a quote and the author's commentary
584
+ // (`.feed-quote-commentary::before` in ui.css). Feed readers strip CSS, so
585
+ // that separator only survives as an element.
586
+ if (quoteRendered) parts.push("<hr/>");
587
+ parts.push(absolutizeFeedHtmlUrls(stripUnsafeFeedHtml(bodyHtml), siteUrl));
588
+ }
589
+
590
+ // Media belongs to `<content>` and to `<media:content>`, not here. A feed
591
+ // cannot express the timeline's justified row anyway — the consumer computes
592
+ // it from the dimensions on `<media:content>` — so a consumer that lays out
593
+ // its own card wants this field to be the text and nothing else.
594
+ if (!options.summary) {
595
+ const mediaHtml = renderMediaForFeed(post.media, siteUrl, permalinkUrl);
596
+ if (mediaHtml) {
597
+ parts.push(mediaHtml);
598
+ }
324
599
  }
325
600
 
326
601
  if (post.rating && post.rating > 0) {
327
602
  parts.push(renderRatingHtml(post.rating));
328
603
  }
329
604
 
330
- if (parts.length === 0) {
605
+ // An entry's content cannot be empty, so a post with nothing but a title or
606
+ // a URL falls back to its plain-text projection. A summary can be empty —
607
+ // that is what "there is no shorter rendering" looks like — and must be: a
608
+ // post carrying only attachments has no text to summarise, and the fallback
609
+ // would put a bare `Post #<id>` where a teaser belongs.
610
+ if (parts.length === 0 && !options.summary) {
331
611
  parts.push(`<p>${escapeXml(getFeedSummaryText(post))}</p>`);
332
612
  }
333
613
 
334
- // For link posts, append a ★ permalink back to the blog post (Daring Fireball style)
335
- if (post.format === "link" && permalinkUrl) {
614
+ // For link posts, append a ★ permalink back to the blog post (Daring Fireball
615
+ // style). A feed convention, not something the site's card shows, so it stays
616
+ // out of the summary.
617
+ if (post.format === "link" && permalinkUrl && !options.summary) {
336
618
  parts.push(
337
619
  `<p><a href="${escapeXml(permalinkUrl)}" title="Permalink">&nbsp;★&nbsp;</a></p>`,
338
620
  );
@@ -341,6 +623,158 @@ function buildSinglePostContent(
341
623
  return parts.join("\n");
342
624
  }
343
625
 
626
+ /**
627
+ * Declare that an entry is a thread, and give its shape.
628
+ *
629
+ * The text constructs answer this only to something willing to parse HTML: the
630
+ * tail meta marks the joints, and the gap link states the hidden count in a
631
+ * sentence. A consumer that reads the entry's elements and draws its own card
632
+ * from `<summary>` plus `<media:content>` should not have to. Absence is the
633
+ * whole of its rule: no element, not a thread.
634
+ *
635
+ * `hidden` is stated rather than derived. How many posts fold away is the
636
+ * site's decision, and `posts - 2` only holds while that decision is "keep the
637
+ * root and the newest reply" — a consumer that derived it would silently
638
+ * disagree with the summary the day the rule changed. It also carries the count
639
+ * as a number, which the gap link only has as hardcoded English.
640
+ *
641
+ * @param entry - The entry, with its fold and the posts its summary renders
642
+ * @param siteUrl - Site base URL for absolute permalinks
643
+ * @returns The element, newline-prefixed, or "" when the entry is a lone post
644
+ * @example
645
+ * renderThreadElement(entry, "https://example.com");
646
+ * // '\n <jant:thread posts="4" hidden="2" gap="…/r1" latest="…/r3"/>'
647
+ */
648
+ function renderThreadElement(entry: FeedEntry, siteUrl: string): string {
649
+ const { post, fold, summaryPosts } = entry;
650
+ if (!fold) return "";
651
+ const replies = post.threadReplies ?? [];
652
+
653
+ const attrs = [
654
+ `posts="${replies.length + 1}"`,
655
+ `hidden="${fold.hiddenCount}"`,
656
+ ];
657
+ if (fold.hiddenCount > 0 && fold.firstHiddenReply) {
658
+ attrs.push(
659
+ `gap="${escapeXml(toAbsoluteFeedUrl(fold.firstHiddenReply.permalink, siteUrl))}"`,
660
+ );
661
+ }
662
+ attrs.push(
663
+ `latest="${escapeXml(toAbsoluteFeedUrl(fold.latestReply.permalink, siteUrl))}"`,
664
+ );
665
+
666
+ // One row per post, in thread order. `<jant:format>` above describes the
667
+ // entry, which is the root — a reply that is a Quote or a Link says so
668
+ // nowhere else, and a consumer laying the thread out itself rather than
669
+ // injecting the HTML cannot see the `<blockquote>` that would have told it.
670
+ //
671
+ // This is where `gap` and `latest` point, and where `media:content`'s
672
+ // `jant:post` resolves: the attribute references, this declares.
673
+ //
674
+ // A row carries what Atom itself would put on this post's entry — its links,
675
+ // its title, its date — plus what Jant adds at entry level: `format` and
676
+ // `truncated`. Never the body, a summary, or an excerpt: `<content>` remains
677
+ // the only place the posts' words appear, and this stays a table of contents.
678
+ //
679
+ // The root gets the same row as every reply, repeating what the entry
680
+ // already says about it. A consumer walking the rows should not have to know
681
+ // that one of them is described somewhere else instead.
682
+ const rows = [post, ...replies]
683
+ .map((member) => {
684
+ const rowAttrs = [
685
+ `href="${escapeXml(toAbsoluteFeedUrl(member.permalink, siteUrl))}"`,
686
+ `format="${escapeXml(member.format)}"`,
687
+ `published="${escapeXml(member.publishedAt)}"`,
688
+ ];
689
+
690
+ // The entry's own rule for `<title>`, applied per post: a quote's
691
+ // attribution is not its title, and `getAtomTitle` is where that is
692
+ // decided, so a quote row carries none.
693
+ const rowTitle = getAtomTitle(member);
694
+ if (rowTitle) rowAttrs.push(`title="${escapeXml(rowTitle)}"`);
695
+
696
+ // Where a Link post points — the row's answer to the entry's
697
+ // `link[rel="alternate"]`, which only ever describes the root.
698
+ if (member.format === "link" && member.url) {
699
+ rowAttrs.push(`url="${escapeXml(member.url)}"`);
700
+ }
701
+
702
+ // A Link post's preview image, the row's `media:thumbnail`. It is a
703
+ // scrape of someone else's page rather than a published file, which is
704
+ // why it is an attribute here and not a `media:content` of its own.
705
+ const rowThumbnail = member.previewImageUrl?.trim();
706
+ if (rowThumbnail) {
707
+ rowAttrs.push(
708
+ `thumbnail="${escapeXml(toAbsoluteFeedUrl(rowThumbnail, siteUrl))}"`,
709
+ );
710
+ }
711
+
712
+ // Whether the fold hid this post. Every other decision on this row is
713
+ // declared rather than inferred, and this was the last one a consumer
714
+ // had to reverse-engineer from the summary's shape — which does not
715
+ // work: a photo with no caption renders on the site and contributes no
716
+ // text, so it leaves the summary looking exactly like a folded post.
717
+ // Written only when true, the same as `truncated`, so a thread that
718
+ // hides nothing pays nothing. The count of these equals `@hidden`.
719
+ if (!summaryPosts.has(member)) {
720
+ rowAttrs.push(`folded="true"`);
721
+ }
722
+
723
+ // Truncation happens only in `<summary>`, and only to the posts the fold
724
+ // renders. A post it hid has no block to cut — and no timeline summary
725
+ // in the entry — so its row never carries this: absence means "not cut
726
+ // here", never "shown whole".
727
+ if (summaryPosts.get(member)?.hasMore === true) {
728
+ rowAttrs.push(`truncated="true"`);
729
+ }
730
+
731
+ return `\n <jant:post ${rowAttrs.join(" ")}/>`;
732
+ })
733
+ .join("");
734
+
735
+ return `\n <jant:thread ${attrs.join(" ")}>${rows}\n </jant:thread>`;
736
+ }
737
+
738
+ /**
739
+ * Close one post's block inside a thread with its own dated permalink.
740
+ *
741
+ * A thread arrives as one entry, so both text constructs run several posts
742
+ * together and a consumer has to find the joints. `<hr/>` cannot say where
743
+ * they are — it also separates a quote from its commentary, and the author can
744
+ * type one — so the joint has to be something only this renderer emits.
745
+ *
746
+ * The site already has the shape: `PostFooter`'s `PostPublishedLink` puts the
747
+ * timestamp *after* the post and links it, carrying microformats2 `u-url` and
748
+ * `dt-published` inside its `h-entry`. Emitting the same thing here means a
749
+ * consumer with an mf2 parser gets the segmentation for free, and one without
750
+ * still has an unambiguous marker: a `<time datetime>` wrapped in an `<a>` is
751
+ * a shape no post body produces, and it survives a reader that strips `class`.
752
+ *
753
+ * Trailing, not leading, for a reason beyond matching the site: with markers
754
+ * after each block the rule is "every marker ends the block before it", which
755
+ * holds for the root as well. A leading marker leaves the root's own block
756
+ * unnamed. The root therefore carries one too, and the redundancy with the
757
+ * entry's `<published>` is confined to threads, where the date has stopped
758
+ * being the entry's only one.
759
+ *
760
+ * `title` is left off: the site's is a translated sentence, and the feed's
761
+ * strings are hardcoded English already.
762
+ *
763
+ * @param post - The post whose block is ending
764
+ * @param permalinkUrl - Absolute permalink of that post
765
+ * @returns A paragraph carrying the post's date, linked to the post
766
+ * @example
767
+ * renderPostTailMeta(reply, "https://example.com/reply-1");
768
+ * // '<p><small><a href="…" class="u-url"><time class="dt-published" …>…</time></a></small></p>'
769
+ */
770
+ function renderPostTailMeta(post: PostView, permalinkUrl: string): string {
771
+ return (
772
+ `<p><small><a href="${escapeXml(permalinkUrl)}" class="u-url">` +
773
+ `<time class="dt-published" datetime="${escapeXml(post.publishedAt)}">` +
774
+ `${escapeXml(post.publishedAtFormatted)}</time></a></small></p>`
775
+ );
776
+ }
777
+
344
778
  /**
345
779
  * Build the full HTML content for a feed entry, including thread replies.
346
780
  *
@@ -353,35 +787,355 @@ function buildFeedContent(
353
787
  siteUrl: string,
354
788
  permalinkUrl?: string,
355
789
  ): string {
356
- const rootContent = buildSinglePostContent(post, permalinkUrl);
790
+ const rootContent = buildSinglePostContent(post, siteUrl, permalinkUrl);
357
791
  const replies = post.threadReplies;
358
792
 
793
+ // A standalone post needs no marker: the entry's own `<published>` dates it,
794
+ // and there is no second block to tell it apart from.
359
795
  if (!replies || replies.length === 0) {
360
796
  return rootContent;
361
797
  }
362
798
 
363
- const parts = [rootContent];
799
+ const rootPermalink =
800
+ permalinkUrl ?? new URL(post.permalink, siteUrl).toString();
801
+ const parts = [rootContent, renderPostTailMeta(post, rootPermalink)];
364
802
 
365
803
  for (const reply of replies) {
366
804
  const replyPermalink = new URL(reply.permalink, siteUrl).toString();
805
+ // Kept as the visual joint the site draws between cards. It is no longer
806
+ // the structural one — the tail meta above is.
367
807
  parts.push("<hr/>");
368
808
  parts.push(
369
- `<p><small><time datetime="${escapeXml(reply.publishedAt)}">${escapeXml(reply.publishedAtFormatted)}</time></small></p>`,
809
+ buildSinglePostContent(reply, siteUrl, replyPermalink, { inline: true }),
810
+ );
811
+ parts.push(renderPostTailMeta(reply, replyPermalink));
812
+ }
813
+
814
+ return parts.join("\n");
815
+ }
816
+
817
+ /**
818
+ * Build the HTML for a feed entry's `<summary>` — the entry's text, as the
819
+ * timeline shows it: truncated at the same boundary, quoted text and rating
820
+ * included, a thread folded to its root, a gap link and its newest reply.
821
+ *
822
+ * In a thread each rendered post's block closes with `renderPostTailMeta`, so
823
+ * a consumer can tell the root's words from the reply's and attribute either
824
+ * to a permalink — which is also how it lines the text up with the entry's
825
+ * `<media:content>`, each of which names its own post.
826
+ *
827
+ * Text only. Media is in `<content>` and, structured, in the Media RSS
828
+ * elements — and it has to be read from there anyway, because a feed cannot
829
+ * express the timeline's justified row and a consumer has to compute it from
830
+ * the dimensions. So the field a consumer reaches for to draw its own card
831
+ * carries the words, and nothing it would have to strip back out.
832
+ *
833
+ * Comes back empty for a post with no text at all — a photo with no caption —
834
+ * and the caller drops the element there. That is the only reason it is ever
835
+ * missing, which makes `summary ?? ""` the whole of a consumer's rule; Atom
836
+ * only mandates a summary for `src`/base64 content (RFC 4287 §4.1.2).
837
+ *
838
+ * @param entry - The entry, with its fold and the posts its summary renders
839
+ * @param siteUrl - Site base URL for absolute permalinks
840
+ * @param permalinkUrl - Absolute permalink URL for the root post
841
+ * @returns The entry's text as the timeline renders it, or "" when it has none
842
+ * @example
843
+ * buildFeedSummary(entry, "https://example.com", "https://example.com/hello")
844
+ * // "<p>Intro</p>\n<p><small><a …><time …>Mar 19, 2026</time></a></small></p>
845
+ * // \n<hr/>\n<p><small><a …>2 more posts</a></small></p>…"
846
+ */
847
+ function buildFeedSummary(
848
+ entry: FeedEntry,
849
+ siteUrl: string,
850
+ permalinkUrl?: string,
851
+ ): string {
852
+ const { post, fold, summaryPosts } = entry;
853
+ const rootPermalink =
854
+ permalinkUrl ?? new URL(post.permalink, siteUrl).toString();
855
+
856
+ const parts: string[] = [];
857
+
858
+ const appendPost = (
859
+ member: PostView,
860
+ memberPermalink: string,
861
+ inline: boolean,
862
+ ) => {
863
+ const markup = buildSinglePostContent(member, siteUrl, memberPermalink, {
864
+ inline,
865
+ summary: true,
866
+ timelineSummary: summaryPosts.get(member),
867
+ });
868
+ if (!markup) return;
869
+ if (parts.length > 0) parts.push("<hr/>");
870
+ parts.push(markup);
871
+ // A thread runs several posts through one field, so each block closes with
872
+ // its own dated permalink. A lone post needs no marker: `<published>`
873
+ // dates it and there is no second block to tell it apart from.
874
+ if (fold) parts.push(renderPostTailMeta(member, memberPermalink));
875
+ };
876
+
877
+ appendPost(post, rootPermalink, false);
878
+
879
+ if (!fold) return parts.join("\n");
880
+
881
+ const absolutePermalink = (member: PostView) =>
882
+ new URL(member.permalink, siteUrl).toString();
883
+
884
+ for (const reply of fold.leadingReplies) {
885
+ appendPost(reply, absolutePermalink(reply), true);
886
+ }
887
+
888
+ if (fold.hiddenCount > 0 && fold.firstHiddenReply) {
889
+ // The same gap the site draws: a link to the first post it hides, not just
890
+ // a count. It matters more here — a feed has no toggle to expand context,
891
+ // so the link is the only way through.
892
+ const gapHref = escapeXml(
893
+ toAbsoluteFeedUrl(fold.firstHiddenReply.permalink, siteUrl),
370
894
  );
371
- parts.push(buildSinglePostContent(reply, replyPermalink, { inline: true }));
895
+ const label =
896
+ fold.hiddenCount === 1 ? "1 more post" : `${fold.hiddenCount} more posts`;
897
+ if (parts.length > 0) parts.push("<hr/>");
898
+ parts.push(`<p><small><a href="${gapHref}">${label}</a></small></p>`);
899
+ }
900
+
901
+ for (const reply of fold.trailingReplies) {
902
+ appendPost(reply, absolutePermalink(reply), true);
372
903
  }
373
904
 
905
+ appendPost(fold.latestReply, absolutePermalink(fold.latestReply), true);
906
+
374
907
  return parts.join("\n");
375
908
  }
376
909
 
377
- function getEntryMedia(post: FeedPostView): MediaView[] {
378
- const media = [...post.media];
910
+ /**
911
+ * Whether the summary's text is shorter than the post's own.
912
+ *
913
+ * `<summary>` is sent whenever an entry has text, so its presence says nothing
914
+ * about truncation — but a consumer drawing the timeline needs to know whether
915
+ * to offer the "Read more" the site offers. Covers the newest reply too, since
916
+ * that is the post a thread's summary shows in full.
917
+ *
918
+ * @param entry - The entry, with the posts its summary renders
919
+ * @returns true when some text in the summary was cut
920
+ * @example
921
+ * isEntryTruncated(longArticleEntry); // true
922
+ */
923
+ function isEntryTruncated(entry: FeedEntry): boolean {
924
+ for (const summary of entry.summaryPosts.values()) {
925
+ if (summary?.hasMore === true) return true;
926
+ }
927
+ return false;
928
+ }
929
+
930
+ /**
931
+ * What one entry's elements share, derived once.
932
+ *
933
+ * `<summary>`, the `<jant:post>` rows and `<jant:truncated/>` all ask which
934
+ * posts the fold shows and where each is cut; `<jant:thread>` and the gap
935
+ * link ask for the fold; the enclosures, the Media RSS elements and the
936
+ * namespace declaration all ask for the attachments. Each answer used to be
937
+ * re-derived at every asking, and the timeline summary is the expensive one —
938
+ * it parses and renders the post's TipTap document — so a page of six-post
939
+ * threads paid three renders per post for one rendering. The entry derives
940
+ * them here and every element reads the result. Per request, not cached
941
+ * across them: the feed route's own cache is the only one.
942
+ */
943
+ interface FeedEntry {
944
+ post: FeedPostView;
945
+ /** The thread's fold, or null when the entry is a lone post. */
946
+ fold: ThreadFold<PostView> | null;
947
+ /**
948
+ * The posts `<summary>` renders, root first, each with its timeline summary
949
+ * — null when the post has no TipTap document to truncate. The fold decides
950
+ * which replies survive; this is the one place that turns it into
951
+ * "everything with a block", which is what `folded` and `truncated` are
952
+ * asked about, per row and at the entry.
953
+ */
954
+ summaryPosts: Map<PostView, TimelineSummary | null>;
955
+ /** Every attachment in the thread, root first, each naming its post. */
956
+ media: EntryMedia[];
957
+ }
958
+
959
+ /**
960
+ * Derive what an entry's elements share, once.
961
+ *
962
+ * The fold has already dropped a reply that falls in two windows, so each
963
+ * post below appears once and its summary is rendered once.
964
+ *
965
+ * @param post - Root post view data
966
+ * @param siteUrl - Site base URL, for the attachments' post permalinks
967
+ * @returns The entry, ready for every element that describes it
968
+ * @example
969
+ * buildFeedEntry(root, "https://example.com").summaryPosts.has(reply);
970
+ * // whether the summary shows it
971
+ */
972
+ function buildFeedEntry(post: FeedPostView, siteUrl: string): FeedEntry {
973
+ const fold = foldThreadReplies(post.threadReplies ?? []);
974
+ const shown: PostView[] = fold
975
+ ? [post, ...fold.leadingReplies, ...fold.trailingReplies, fold.latestReply]
976
+ : [post];
977
+ return {
978
+ post,
979
+ fold,
980
+ summaryPosts: new Map(
981
+ shown.map((member) => [member, getTimelineSummary(member)] as const),
982
+ ),
983
+ media: getEntryMedia(post, siteUrl),
984
+ };
985
+ }
986
+
987
+ /**
988
+ * Media RSS `medium` for an attachment. The vocabulary is fixed
989
+ * (image/audio/video/document/executable), so everything that is not a
990
+ * playable or visual file is a document.
991
+ */
992
+ function getMediaRssMedium(mimeType: string): string {
993
+ const category = getMediaCategory(mimeType);
994
+ if (category === "image") return "image";
995
+ if (category === "video") return "video";
996
+ if (category === "audio") return "audio";
997
+ return "document";
998
+ }
999
+
1000
+ /**
1001
+ * Render one attachment as `<media:content>`, carrying the dimensions,
1002
+ * duration, size, and description that Atom's `<link rel="enclosure">` has
1003
+ * nowhere to put.
1004
+ *
1005
+ * @param item - Attachment view data
1006
+ * @param siteUrl - Site base URL, for absolutizing stored paths
1007
+ * @param inThread - Whether the entry carries a whole thread, in which case
1008
+ * every attachment names its post
1009
+ * @returns A `<media:content>` element, newline-prefixed for entry indentation
1010
+ * @example
1011
+ * renderMediaRssContent(photo, "https://example.com", false)
1012
+ * // '\n <media:content url="…" type="image/jpeg" medium="image" …/>'
1013
+ */
1014
+ function renderMediaRssContent(
1015
+ { item, postPermalinkUrl }: EntryMedia,
1016
+ siteUrl: string,
1017
+ inThread: boolean,
1018
+ ): string {
1019
+ const fileUrl = toAbsoluteFeedUrl(item.url, siteUrl);
1020
+ const attrs = [
1021
+ `url="${escapeXml(fileUrl)}"`,
1022
+ `type="${escapeXml(item.mimeType)}"`,
1023
+ `medium="${getMediaRssMedium(item.mimeType)}"`,
1024
+ ];
1025
+
1026
+ if (item.size != null && item.size > 0) attrs.push(`fileSize="${item.size}"`);
1027
+ if (item.width != null && item.width > 0) attrs.push(`width="${item.width}"`);
1028
+ if (item.height != null && item.height > 0) {
1029
+ attrs.push(`height="${item.height}"`);
1030
+ }
1031
+ if (item.durationSeconds != null && item.durationSeconds > 0) {
1032
+ attrs.push(`duration="${Math.round(item.durationSeconds)}"`);
1033
+ }
1034
+
1035
+ // Where to send someone who clicks the attachment, after the Media RSS
1036
+ // attributes so the foreign one stays out of their way. Only carried when it
1037
+ // is not the file itself — today that means text attachments — so a consumer
1038
+ // reads `jant:page ?? url` and needs no rule per attachment kind.
1039
+ const pageUrl = getMediaPageUrl(item, siteUrl, postPermalinkUrl);
1040
+ if (pageUrl !== fileUrl) {
1041
+ attrs.push(`jant:page="${escapeXml(pageUrl)}"`);
1042
+ }
1043
+
1044
+ // Which post in the thread carries this file. An entry's media is drawn from
1045
+ // the whole thread, so without this the list is flat and a consumer laying
1046
+ // out the folded card would hang a hidden reply's photo under the root.
1047
+ //
1048
+ // In a thread every attachment carries it, the root's included. `jant:page`
1049
+ // earns its omission because guessing wrong there only lands you on the file
1050
+ // instead of its page; guessing wrong here hangs a photo under the wrong
1051
+ // post. A bare `<media:content>` should answer "whose is this" by itself
1052
+ // rather than through a rule about what a missing attribute means.
1053
+ //
1054
+ // A lone post's entry has exactly one post, so nothing there is ambiguous
1055
+ // and the attribute would only repeat `<id>` on every file.
1056
+ if (inThread) {
1057
+ attrs.push(`jant:post="${escapeXml(postPermalinkUrl)}"`);
1058
+ }
1059
+
1060
+ const children: string[] = [];
1061
+ const title = item.originalName?.trim();
1062
+ if (title) {
1063
+ children.push(
1064
+ `<media:title type="plain">${escapeXml(title)}</media:title>`,
1065
+ );
1066
+ }
1067
+ // Alt text describes a picture; a text attachment has none but carries an
1068
+ // excerpt, which is what the site prints on its card. One slot, because to a
1069
+ // consumer both answer "what is this file".
1070
+ const description = item.altText?.trim() || item.summary?.trim();
1071
+ if (description) {
1072
+ children.push(
1073
+ `<media:description type="plain">${escapeXml(description)}</media:description>`,
1074
+ );
1075
+ }
1076
+ const thumbnail = (item.posterUrl || item.thumbnailUrl)?.trim();
1077
+ if (thumbnail && thumbnail !== item.url) {
1078
+ children.push(
1079
+ `<media:thumbnail url="${escapeXml(toAbsoluteFeedUrl(thumbnail, siteUrl))}"/>`,
1080
+ );
1081
+ }
1082
+
1083
+ return children.length > 0
1084
+ ? `\n <media:content ${attrs.join(" ")}>${children.join("")}</media:content>`
1085
+ : `\n <media:content ${attrs.join(" ")}/>`;
1086
+ }
1087
+
1088
+ /**
1089
+ * One attachment plus the post it hangs off, since an entry's media is drawn
1090
+ * from a whole thread and a text attachment's page lives under its own post.
1091
+ */
1092
+ interface EntryMedia {
1093
+ item: MediaView;
1094
+ /** Absolute permalink of the post carrying this attachment. */
1095
+ postPermalinkUrl: string;
1096
+ }
1097
+
1098
+ function getEntryMedia(post: FeedPostView, siteUrl: string): EntryMedia[] {
1099
+ const collect = (from: PostView): EntryMedia[] => {
1100
+ const postPermalinkUrl = new URL(from.permalink, siteUrl).toString();
1101
+ return from.media.map((item) => ({ item, postPermalinkUrl }));
1102
+ };
1103
+
1104
+ const media = collect(post);
379
1105
  for (const reply of post.threadReplies ?? []) {
380
- media.push(...reply.media);
1106
+ media.push(...collect(reply));
381
1107
  }
382
1108
  return media;
383
1109
  }
384
1110
 
1111
+ /**
1112
+ * The URL a browser can usefully open for an attachment.
1113
+ *
1114
+ * For a picture, a clip or a PDF that is the file itself. A text attachment's
1115
+ * file is markdown or plain text, which a browser downloads or dumps unstyled,
1116
+ * so it points at the page that renders it instead. Consumers laying out their
1117
+ * own card follow this rather than `url`, which stays the file for fetching
1118
+ * and for the enclosure.
1119
+ *
1120
+ * @param item - Attachment view data
1121
+ * @param siteUrl - Site base URL, for absolutizing a stored path
1122
+ * @param postPermalinkUrl - Absolute permalink of the post carrying it
1123
+ * @returns An absolute URL worth opening in a browser
1124
+ * @example
1125
+ * getMediaPageUrl(note, "https://example.com", "https://example.com/hn2v7")
1126
+ * // "https://example.com/hn2v7/text/med_01m13xhg"
1127
+ */
1128
+ function getMediaPageUrl(
1129
+ item: MediaView,
1130
+ siteUrl: string,
1131
+ postPermalinkUrl?: string,
1132
+ ): string {
1133
+ if (getMediaCategory(item.mimeType) === "text" && postPermalinkUrl) {
1134
+ return `${postPermalinkUrl}/text/${item.id}`;
1135
+ }
1136
+ return toAbsoluteFeedUrl(item.url, siteUrl);
1137
+ }
1138
+
385
1139
  /**
386
1140
  * Default Atom feed renderer.
387
1141
  *
@@ -389,11 +1143,28 @@ function getEntryMedia(post: FeedPostView): MediaView[] {
389
1143
  * @returns Atom XML string
390
1144
  */
391
1145
  export function defaultFeedRenderer(data: FeedData): string {
392
- const { siteName, siteDescription, siteUrl, title, selfUrl, posts } = data;
1146
+ const {
1147
+ siteName,
1148
+ siteDescription,
1149
+ siteUrl,
1150
+ siteLanguage,
1151
+ title,
1152
+ selfUrl,
1153
+ posts,
1154
+ siteIconUrl,
1155
+ discover,
1156
+ discoverFeedUrl,
1157
+ discoverFeaturedFeedUrl,
1158
+ discoverStatusUrl,
1159
+ languageAlternates,
1160
+ } = data;
393
1161
  const feedTitle = title ?? siteName;
394
1162
 
395
- const entries = posts
396
- .map((post) => {
1163
+ const feedEntries = posts.map((post) => buildFeedEntry(post, siteUrl));
1164
+
1165
+ const entries = feedEntries
1166
+ .map((entry) => {
1167
+ const { post } = entry;
397
1168
  const permalinkUrl = new URL(post.permalink, siteUrl).toString();
398
1169
  const escapedPermalink = escapeXml(permalinkUrl);
399
1170
  // Link-format posts point <link rel="alternate"> to the original URL
@@ -402,7 +1173,6 @@ export function defaultFeedRenderer(data: FeedData): string {
402
1173
  ? escapeXml(alternateUrl)
403
1174
  : escapedPermalink;
404
1175
  const title = getAtomTitle(post);
405
- const summary = getFeedSummaryText(post);
406
1176
  const publishedAt = post.feedPublishedAt ?? post.publishedAt;
407
1177
  const updatedAt = post.feedUpdatedAt ?? post.updatedAt;
408
1178
 
@@ -411,43 +1181,217 @@ export function defaultFeedRenderer(data: FeedData): string {
411
1181
  ? `\n <link href="${escapedPermalink}" rel="related"/>`
412
1182
  : "";
413
1183
 
414
- // One <link rel="enclosure"> per attachment so podcast/offline readers
415
- // can fetch them. Atom omits length when size is unknown; mimeType is
416
- // always known from the upload pipeline.
417
- const enclosureLinks = getEntryMedia(post)
1184
+ // One <link rel="enclosure"> per attachment the content cannot already
1185
+ // show, so podcast/offline readers can fetch it. Atom omits length when
1186
+ // size is unknown; mimeType is always known from the upload pipeline.
1187
+ //
1188
+ // Images are excluded: the content already renders them full size inside
1189
+ // a link to the original, so an enclosure adds nothing a plain Atom
1190
+ // parser could not already see — it only asks readers with an attachment
1191
+ // shelf to list the picture a second time under the post. Every podcast
1192
+ // feed works this way, enclosing the audio it cannot inline while
1193
+ // leaving its inline show-note images alone.
1194
+ const isThreadEntry = (post.threadReplies?.length ?? 0) > 0;
1195
+ const enclosureLinks = entry.media
1196
+ .map(({ item }) => item)
1197
+ .filter((m) => getMediaCategory(m.mimeType) !== "image")
418
1198
  .map((m) => {
419
1199
  const lengthAttr =
420
1200
  m.size != null && m.size > 0 ? ` length="${m.size}"` : "";
421
1201
  const titleAttr = m.originalName
422
1202
  ? ` title="${escapeXml(m.originalName)}"`
423
1203
  : "";
424
- return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(m.url)}"${lengthAttr}${titleAttr}/>`;
1204
+ return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(toAbsoluteFeedUrl(m.url, siteUrl))}"${lengthAttr}${titleAttr}/>`;
425
1205
  })
426
1206
  .join("");
427
1207
 
1208
+ // The same attachments again, with the metadata Atom's `<link>` cannot
1209
+ // carry — pixel dimensions, duration, alt text, poster. Enclosure stays
1210
+ // above because it is what a plain Atom parser reads; this is the layer
1211
+ // a reader that knows Media RSS can lay out without fetching the file.
1212
+ const mediaContentElements = entry.media
1213
+ .map((m) => renderMediaRssContent(m, siteUrl, isThreadEntry))
1214
+ .join("");
1215
+
1216
+ // The entry's representative image, for readers that lay out cards or a
1217
+ // grid. A link post's preview is a thumbnail of someone else's page, not
1218
+ // a file the author published, so it gets no `rel="enclosure"` — that
1219
+ // would tell podcast and download clients to fetch it as content. Left
1220
+ // out, a reader has to scrape the first `<img>` out of the content HTML.
1221
+ // Dimensions are unknown: the URL is a scale-down transform of a stored
1222
+ // key, and Media RSS makes width/height optional.
1223
+ const previewImageUrl = post.previewImageUrl?.trim();
1224
+ const thumbnailElement = previewImageUrl
1225
+ ? `\n <media:thumbnail url="${escapeXml(toAbsoluteFeedUrl(previewImageUrl, siteUrl))}"/>`
1226
+ : "";
1227
+
1228
+ // What kind of post this is, in Jant's own namespace. Atom has no field
1229
+ // for it, and `<category>` is the wrong place: a reader would show
1230
+ // "quote" as a tag the author never wrote. A namespaced element is
1231
+ // invisible to readers that do not know it.
1232
+ //
1233
+ // A link post is already tellable from the `rel="related"` above, but a
1234
+ // quote and an untitled note are identical from the feed alone — both
1235
+ // carry an empty `<title>` and a body — so anything reading feeds had to
1236
+ // fetch the post's page to tell them apart. It rides on every entry
1237
+ // rather than only the ambiguous ones, because a consumer should be able
1238
+ // to read one field instead of inferring three cases.
1239
+ //
1240
+ // Core's own three formats, not a consumer's rendering distinctions: a
1241
+ // titled note is still a note here, and whether that is drawn as an
1242
+ // article is the reader's call, made from this and `<title>`.
1243
+ const formatElement = `\n <jant:format>${escapeXml(post.format)}</jant:format>`;
1244
+
1245
+ // The post's own ID, which never changes. `<id>` is the permalink, and
1246
+ // stays so — changing it would make every subscriber's reader show the
1247
+ // feed again — but a permalink moves when a slug is renamed or the site
1248
+ // changes domain. A consumer that has to recognise the same post across
1249
+ // either keys on this, and it is what the Discover status endpoint takes.
1250
+ const idElement = `\n <jant:id>${escapeXml(post.id)}</jant:id>`;
1251
+
1252
+ // The site's own tags for this post, which Atom has a field for — unlike
1253
+ // `<jant:format>` above, a collection is a label the author chose, so
1254
+ // showing it as one is right. Replies inherit their root's, and the site
1255
+ // prints them on the root alone, so they ride on the entry once.
1256
+ // `jant:page` because a single collection lives in the root URL
1257
+ // namespace and a site path prefix makes it unguessable from the term.
1258
+ const categoryElements = post.collections
1259
+ .map(
1260
+ (collection) =>
1261
+ `\n <category term="${escapeXml(collection.slug)}" label="${escapeXml(collection.title)}" jant:page="${escapeXml(toAbsoluteFeedUrl(collection.url, siteUrl))}"/>`,
1262
+ )
1263
+ .join("");
1264
+
1265
+ // Whether the summary's text was cut. `<summary>` is present whenever
1266
+ // there is text, so only this says whether the site would offer a
1267
+ // "Read more" — a consumer drawing its own timeline cannot tell without
1268
+ // fetching and comparing the content.
1269
+ const truncatedElement = isEntryTruncated(entry)
1270
+ ? "\n <jant:truncated/>"
1271
+ : "";
1272
+
1273
+ // Whether this entry is a whole thread, and how much of it the summary
1274
+ // folded away. The text says so only in prose a consumer would have to
1275
+ // parse.
1276
+ const threadElement = renderThreadElement(entry, siteUrl);
1277
+
1278
+ // `<summary>` is the entry's text, `<content>` the post's full page. It
1279
+ // is present whenever there is text — a bare note repeats itself here,
1280
+ // which costs the words and nothing else, and buys a field that means one
1281
+ // thing on its own rather than one defined by what content happens to
1282
+ // hold. Missing therefore says exactly one thing: this post has no text.
1283
+ const contentMarkup = buildFeedContent(post, siteUrl, permalinkUrl);
1284
+ const summaryMarkup = buildFeedSummary(entry, siteUrl, permalinkUrl);
1285
+ const summaryElement = summaryMarkup
1286
+ ? `\n <summary type="html"><![CDATA[${escapeCdata(summaryMarkup)}]]></summary>`
1287
+ : "";
1288
+
428
1289
  return `
429
1290
  <entry>
430
1291
  <title>${escapeXml(title)}</title>
431
1292
  <link href="${alternateLink}" rel="alternate"/>${relatedLink}${enclosureLinks}
432
- <id>${escapedPermalink}</id>
1293
+ <id>${escapedPermalink}</id>${idElement}
433
1294
  <published>${publishedAt}</published>
434
- <updated>${updatedAt}</updated>
435
- <summary type="text">${escapeXml(summary)}</summary>
436
- <content type="html"><![CDATA[${escapeCdata(buildFeedContent(post, siteUrl, permalinkUrl))}]]></content>
1295
+ <updated>${updatedAt}</updated>${formatElement}${threadElement}${truncatedElement}${categoryElements}${thumbnailElement}${mediaContentElements}${summaryElement}
1296
+ <content type="html"><![CDATA[${escapeCdata(contentMarkup)}]]></content>
437
1297
  </entry>`;
438
1298
  })
439
1299
  .join("");
440
1300
 
441
- const now = new Date().toISOString();
1301
+ // The feed's own <updated> is the newest entry timestamp, not the current
1302
+ // time — stamping "now" on every render tells every reader the feed changed
1303
+ // on every poll, which is both untrue and useless for change detection.
1304
+ // ISO 8601 from toISOString() is UTC and zero-padded, so lexical max is
1305
+ // chronological max. Empty feeds fall back to now, since Atom requires it.
1306
+ const feedUpdated =
1307
+ posts
1308
+ .map((post) => post.feedUpdatedAt ?? post.updatedAt)
1309
+ .reduce<string | null>(
1310
+ (latest, value) => (latest === null || value > latest ? value : latest),
1311
+ null,
1312
+ ) ?? new Date().toISOString();
1313
+
1314
+ // A feed states its own language: at the root that is the site's, and in a
1315
+ // language view it is that view's, so a reader subscribing to /en/feed gets
1316
+ // a feed their reader can label and their screen reader can pronounce.
1317
+ const langAttr = siteLanguage ? ` xml:lang="${escapeXml(siteLanguage)}"` : "";
1318
+
1319
+ // The jant namespace is only declared when something in it is emitted, the
1320
+ // same way the sitemap declares xhtml only for alternates. What lives in it:
1321
+ // the Discover declaration below, and every entry's format and ID — so only
1322
+ // a feed with neither, which means an empty feed on a site that has never
1323
+ // answered Discover, leaves it out.
1324
+ const jantNs =
1325
+ discover || posts.length > 0
1326
+ ? ` xmlns:jant="${escapeXml(DISCOVER_NAMESPACE_URI)}"`
1327
+ : "";
1328
+
1329
+ // Media RSS rides along only when an entry actually carries an attachment or
1330
+ // a representative image, on the same rule as the jant namespace above.
1331
+ const mediaNs = feedEntries.some(
1332
+ (entry) =>
1333
+ entry.media.length > 0 || Boolean(entry.post.previewImageUrl?.trim()),
1334
+ )
1335
+ ? ` xmlns:media="${escapeXml(MEDIA_RSS_NAMESPACE_URI)}"`
1336
+ : "";
1337
+
1338
+ // Sibling-language feeds. `type` is carried because Atom forbids two
1339
+ // `rel="alternate"` links sharing a type/hreflang pair, and the site's own
1340
+ // HTML alternate above has neither.
1341
+ const alternateFeedLinks = (languageAlternates ?? [])
1342
+ .map(
1343
+ (alternate) =>
1344
+ `\n <link href="${escapeXml(alternate.href)}" rel="alternate" type="application/atom+xml" hreflang="${escapeXml(alternate.hreflang)}"/>`,
1345
+ )
1346
+ .join("");
1347
+
1348
+ // The Discover declaration. It rides in every feed the site emits, so a
1349
+ // crawler holding any one of them learns the site's answer and which feeds
1350
+ // to poll for it. `feed` is omitted when the site is not listed — there is
1351
+ // nothing to point at. `featured` names the featured feed beside it under
1352
+ // `latest`, so a directory can keep featured posts and everything else on
1353
+ // separate lists without guessing the address from the site root. `status`
1354
+ // is where the directory asks whether posts it already holds are still in
1355
+ // those feeds, which the feeds themselves cannot say about a post that has
1356
+ // merely been pushed past their length.
1357
+ const discoverFeedAttr = discoverFeedUrl
1358
+ ? ` feed="${escapeXml(discoverFeedUrl)}"`
1359
+ : "";
1360
+ const discoverFeaturedAttr = discoverFeaturedFeedUrl
1361
+ ? ` featured="${escapeXml(discoverFeaturedFeedUrl)}"`
1362
+ : "";
1363
+ const discoverStatusAttr = discoverStatusUrl
1364
+ ? ` status="${escapeXml(discoverStatusUrl)}"`
1365
+ : "";
1366
+ const discoverElement = discover
1367
+ ? `\n <jant:discover${discoverFeedAttr}${discoverFeaturedAttr}${discoverStatusAttr}>${escapeXml(discover)}</jant:discover>`
1368
+ : "";
1369
+
1370
+ // The feed's title is composed — "<site> - Latest posts" — because a reader's
1371
+ // sidebar sorts by it and one site's feeds should stay together there. That
1372
+ // leaves nothing carrying the site's own name, which is what a directory
1373
+ // needs to label a blog. `atom:author` is where a single-author blog's name
1374
+ // belongs anyway, and every feed reader already knows what to do with it.
1375
+ const authorBlock = siteName
1376
+ ? `\n <author><name>${escapeXml(siteName)}</name></author>`
1377
+ : "";
1378
+
1379
+ // `atom:icon` is the site's avatar. A reader puts it in its sidebar, and a
1380
+ // directory listing this blog has nowhere else to read one from — the feed
1381
+ // is the only machine-readable surface it is guaranteed to have fetched.
1382
+ // Atom requires an IRI, so a stored path is resolved against the site URL.
1383
+ const iconBlock = siteIconUrl
1384
+ ? `\n <icon>${escapeXml(toAbsoluteFeedUrl(siteIconUrl, siteUrl))}</icon>`
1385
+ : "";
442
1386
 
443
1387
  return `<?xml version="1.0" encoding="UTF-8"?>
444
- <feed xmlns="http://www.w3.org/2005/Atom">
1388
+ <feed xmlns="http://www.w3.org/2005/Atom"${jantNs}${mediaNs}${langAttr}>
445
1389
  <title>${escapeXml(feedTitle)}</title>
446
- <subtitle>${escapeXml(siteDescription)}</subtitle>
1390
+ <subtitle>${escapeXml(siteDescription)}</subtitle>${authorBlock}${iconBlock}
447
1391
  <link href="${escapeXml(siteUrl)}" rel="alternate"/>
448
- <link href="${escapeXml(selfUrl)}" rel="self"/>
1392
+ <link href="${escapeXml(selfUrl)}" rel="self"/>${alternateFeedLinks}
449
1393
  <id>${escapeXml(selfUrl)}</id>
450
- <updated>${now}</updated>
1394
+ <updated>${feedUpdated}</updated>${discoverElement}
451
1395
  ${entries}
452
1396
  </feed>`;
453
1397
  }
@@ -465,15 +1409,15 @@ export interface SitemapUrlEntry {
465
1409
  /** ISO date (YYYY-MM-DD) or full ISO datetime */
466
1410
  lastmod?: string;
467
1411
  changefreq?:
468
- | "always"
469
- | "hourly"
470
- | "daily"
471
- | "weekly"
472
- | "monthly"
473
- | "yearly"
474
- | "never";
1412
+ "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
475
1413
  /** "0.0" – "1.0" */
476
1414
  priority?: string;
1415
+ /**
1416
+ * Other-language versions of this URL. Sitemap `hreflang` groups must be
1417
+ * reciprocal and self-inclusive, so this lists every member of the group,
1418
+ * this URL included.
1419
+ */
1420
+ alternates?: LanguageAlternate[];
477
1421
  }
478
1422
 
479
1423
  /** One `<sitemap>` entry inside a `<sitemapindex>`. */
@@ -502,12 +1446,23 @@ export function renderSitemapUrlSet(entries: SitemapUrlEntry[]): string {
502
1446
  if (entry.priority) {
503
1447
  parts.push(` <priority>${escapeXml(entry.priority)}</priority>`);
504
1448
  }
1449
+ for (const alternate of entry.alternates ?? []) {
1450
+ parts.push(
1451
+ ` <xhtml:link rel="alternate" hreflang="${escapeXml(alternate.hreflang)}" href="${escapeXml(alternate.href)}"/>`,
1452
+ );
1453
+ }
505
1454
  return ` <url>\n${parts.join("\n")}\n </url>`;
506
1455
  })
507
1456
  .join("\n");
508
1457
 
1458
+ // The xhtml namespace is only meaningful for `<xhtml:link>` alternates, so
1459
+ // it is declared only when some URL carries them.
1460
+ const xhtmlNs = entries.some((entry) => entry.alternates?.length)
1461
+ ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"'
1462
+ : "";
1463
+
509
1464
  return `<?xml version="1.0" encoding="UTF-8"?>
510
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
1465
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xhtmlNs}>
511
1466
  ${urls}
512
1467
  </urlset>`;
513
1468
  }