@jant/core 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
package/src/lib/feed.ts CHANGED
@@ -10,10 +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";
14
22
  import { getLinkPreviewProviderLabel } from "./link-preview.js";
15
23
  import { extractDisplayDomain } from "./url.js";
16
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/";
17
35
 
18
36
  /**
19
37
  * Escape special XML characters.
@@ -143,6 +161,20 @@ interface SinglePostContentOptions {
143
161
  * through Atom fields.
144
162
  */
145
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;
146
178
  }
147
179
 
148
180
  function renderLinkedText(text: string, href?: string): string {
@@ -150,7 +182,37 @@ function renderLinkedText(text: string, href?: string): string {
150
182
  return href ? `<a href="${escapeXml(href)}">${label}</a>` : label;
151
183
  }
152
184
 
153
- 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(
154
216
  post: PostView,
155
217
  permalinkUrl?: string,
156
218
  ): string[] {
@@ -179,6 +241,30 @@ function renderInlinePostHeader(
179
241
  return parts;
180
242
  }
181
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
+
182
268
  /**
183
269
  * Render a star rating as HTML for feed content.
184
270
  */
@@ -283,7 +369,6 @@ function renderMediaItem(
283
369
  ): string {
284
370
  const category = getMediaCategory(item.mimeType);
285
371
  const url = escapeXml(toAbsoluteFeedUrl(item.url, siteUrl));
286
- const name = item.originalName ?? "";
287
372
  const altText = item.altText ?? "";
288
373
  const caption = item.altText?.trim() || "";
289
374
  const meta = getMediaMeta(item);
@@ -300,22 +385,38 @@ function renderMediaItem(
300
385
  }
301
386
 
302
387
  if (category === "video") {
303
- const poster = toAbsoluteFeedUrl(
304
- item.posterUrl || item.thumbnailUrl,
305
- siteUrl,
306
- );
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
+ : "";
307
407
  const dims =
308
408
  item.width && item.height
309
409
  ? ` width="${item.width}" height="${item.height}"`
310
410
  : "";
311
- // Prefix the caption with a ▶ glyph as a video cue. A CSS overlay would
312
- // be stripped by most feed-reader sanitizers, so a plain-text play
313
- // character is the only marker that renders reliably everywhere. Link
314
- // only the "Watch video" action label (so it's clickable like the
315
- // thumbnail and reads cleanly to screen readers); metadata stays outside
316
- // the link in parens, matching the audio/text/document attachment style.
317
411
  const metaSuffix = meta ? ` (${escapeXml(meta)})` : "";
318
- 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
+ );
319
420
  }
320
421
 
321
422
  if (category === "audio") {
@@ -325,9 +426,9 @@ function renderMediaItem(
325
426
  }
326
427
 
327
428
  if (category === "text") {
328
- const previewHref = postPermalinkUrl
329
- ? escapeXml(`${postPermalinkUrl}/text/${item.id}`)
330
- : url;
429
+ const previewHref = escapeXml(
430
+ getMediaPageUrl(item, siteUrl, postPermalinkUrl),
431
+ );
331
432
  const linkText = buildAttachmentLinkText(item, "Attached text");
332
433
  // Prefer character count over byte size — more meaningful for text.
333
434
  const textMeta =
@@ -356,9 +457,64 @@ function renderMediaForFeed(
356
457
  postPermalinkUrl?: string,
357
458
  ): string {
358
459
  if (media.length === 0) return "";
359
- return media
460
+ const items = media
360
461
  .map((item) => renderMediaItem(item, siteUrl, postPermalinkUrl))
361
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
+ });
362
518
  }
363
519
 
364
520
  /**
@@ -377,51 +533,88 @@ function buildSinglePostContent(
377
533
  const parts: string[] = [];
378
534
 
379
535
  if (options.inline) {
380
- parts.push(...renderInlinePostHeader(post, permalinkUrl));
536
+ const header = renderInlinePostHeader(post, permalinkUrl);
537
+ if (header) parts.push(header);
381
538
  }
382
539
 
540
+ let quoteRendered = false;
383
541
  if (post.format === "quote" && post.quoteText) {
384
542
  const sourceName = post.title || "";
385
543
  const sourceUrl = post.url || "";
386
544
  const attribution = sourceName || sourceUrl;
387
545
  const cite = sourceUrl ? ` cite="${escapeXml(sourceUrl)}"` : "";
388
- parts.push(
389
- `<blockquote${cite}><p>${escapeXml(post.quoteText)}</p></blockquote>`,
390
- );
391
- if (attribution) {
392
- const source = sourceUrl
546
+ const quoteHtml = renderPlainTextHtml(post.quoteText);
547
+ const source = attribution
548
+ ? sourceUrl
393
549
  ? `<a href="${escapeXml(sourceUrl)}">${escapeXml(sourceName || extractDisplayDomain(sourceUrl) || sourceUrl)}</a>`
394
- : 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) {
395
562
  parts.push(`<p>— ${source}</p>`);
396
563
  }
397
564
  }
398
565
 
399
- const linkPreviewHtml = renderLinkPreviewForFeed(post, siteUrl);
400
- if (linkPreviewHtml) {
401
- parts.push(linkPreviewHtml);
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
+ }
402
573
  }
403
574
 
404
- if (post.bodyHtml) {
405
- parts.push(
406
- absolutizeFeedHtmlUrls(stripUnsafeFeedHtml(post.bodyHtml), siteUrl),
407
- );
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));
408
588
  }
409
589
 
410
- const mediaHtml = renderMediaForFeed(post.media, siteUrl, permalinkUrl);
411
- if (mediaHtml) {
412
- parts.push(mediaHtml);
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
+ }
413
599
  }
414
600
 
415
601
  if (post.rating && post.rating > 0) {
416
602
  parts.push(renderRatingHtml(post.rating));
417
603
  }
418
604
 
419
- 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) {
420
611
  parts.push(`<p>${escapeXml(getFeedSummaryText(post))}</p>`);
421
612
  }
422
613
 
423
- // For link posts, append a ★ permalink back to the blog post (Daring Fireball style)
424
- 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) {
425
618
  parts.push(
426
619
  `<p><a href="${escapeXml(permalinkUrl)}" title="Permalink">&nbsp;★&nbsp;</a></p>`,
427
620
  );
@@ -430,6 +623,158 @@ function buildSinglePostContent(
430
623
  return parts.join("\n");
431
624
  }
432
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
+
433
778
  /**
434
779
  * Build the full HTML content for a feed entry, including thread replies.
435
780
  *
@@ -445,34 +790,352 @@ function buildFeedContent(
445
790
  const rootContent = buildSinglePostContent(post, siteUrl, permalinkUrl);
446
791
  const replies = post.threadReplies;
447
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.
448
795
  if (!replies || replies.length === 0) {
449
796
  return rootContent;
450
797
  }
451
798
 
452
- const parts = [rootContent];
799
+ const rootPermalink =
800
+ permalinkUrl ?? new URL(post.permalink, siteUrl).toString();
801
+ const parts = [rootContent, renderPostTailMeta(post, rootPermalink)];
453
802
 
454
803
  for (const reply of replies) {
455
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.
456
807
  parts.push("<hr/>");
457
- parts.push(
458
- `<p><small><time datetime="${escapeXml(reply.publishedAt)}">${escapeXml(reply.publishedAtFormatted)}</time></small></p>`,
459
- );
460
808
  parts.push(
461
809
  buildSinglePostContent(reply, siteUrl, replyPermalink, { inline: true }),
462
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),
894
+ );
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>`);
463
899
  }
464
900
 
901
+ for (const reply of fold.trailingReplies) {
902
+ appendPost(reply, absolutePermalink(reply), true);
903
+ }
904
+
905
+ appendPost(fold.latestReply, absolutePermalink(fold.latestReply), true);
906
+
465
907
  return parts.join("\n");
466
908
  }
467
909
 
468
- function getEntryMedia(post: FeedPostView): MediaView[] {
469
- 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);
470
1105
  for (const reply of post.threadReplies ?? []) {
471
- media.push(...reply.media);
1106
+ media.push(...collect(reply));
472
1107
  }
473
1108
  return media;
474
1109
  }
475
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
+
476
1139
  /**
477
1140
  * Default Atom feed renderer.
478
1141
  *
@@ -480,11 +1143,28 @@ function getEntryMedia(post: FeedPostView): MediaView[] {
480
1143
  * @returns Atom XML string
481
1144
  */
482
1145
  export function defaultFeedRenderer(data: FeedData): string {
483
- 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;
484
1161
  const feedTitle = title ?? siteName;
485
1162
 
486
- const entries = posts
487
- .map((post) => {
1163
+ const feedEntries = posts.map((post) => buildFeedEntry(post, siteUrl));
1164
+
1165
+ const entries = feedEntries
1166
+ .map((entry) => {
1167
+ const { post } = entry;
488
1168
  const permalinkUrl = new URL(post.permalink, siteUrl).toString();
489
1169
  const escapedPermalink = escapeXml(permalinkUrl);
490
1170
  // Link-format posts point <link rel="alternate"> to the original URL
@@ -493,7 +1173,6 @@ export function defaultFeedRenderer(data: FeedData): string {
493
1173
  ? escapeXml(alternateUrl)
494
1174
  : escapedPermalink;
495
1175
  const title = getAtomTitle(post);
496
- const summary = getFeedSummaryText(post);
497
1176
  const publishedAt = post.feedPublishedAt ?? post.publishedAt;
498
1177
  const updatedAt = post.feedUpdatedAt ?? post.updatedAt;
499
1178
 
@@ -502,10 +1181,20 @@ export function defaultFeedRenderer(data: FeedData): string {
502
1181
  ? `\n <link href="${escapedPermalink}" rel="related"/>`
503
1182
  : "";
504
1183
 
505
- // One <link rel="enclosure"> per attachment so podcast/offline readers
506
- // can fetch them. Atom omits length when size is unknown; mimeType is
507
- // always known from the upload pipeline.
508
- 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")
509
1198
  .map((m) => {
510
1199
  const lengthAttr =
511
1200
  m.size != null && m.size > 0 ? ` length="${m.size}"` : "";
@@ -516,15 +1205,95 @@ export function defaultFeedRenderer(data: FeedData): string {
516
1205
  })
517
1206
  .join("");
518
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
+
519
1289
  return `
520
1290
  <entry>
521
1291
  <title>${escapeXml(title)}</title>
522
1292
  <link href="${alternateLink}" rel="alternate"/>${relatedLink}${enclosureLinks}
523
- <id>${escapedPermalink}</id>
1293
+ <id>${escapedPermalink}</id>${idElement}
524
1294
  <published>${publishedAt}</published>
525
- <updated>${updatedAt}</updated>
526
- <summary type="text">${escapeXml(summary)}</summary>
527
- <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>
528
1297
  </entry>`;
529
1298
  })
530
1299
  .join("");
@@ -537,19 +1306,92 @@ export function defaultFeedRenderer(data: FeedData): string {
537
1306
  const feedUpdated =
538
1307
  posts
539
1308
  .map((post) => post.feedUpdatedAt ?? post.updatedAt)
540
- .reduce<
541
- string | null
542
- >((latest, value) => (latest === null || value > latest ? value : latest), null) ??
543
- new Date().toISOString();
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
+ : "";
544
1386
 
545
1387
  return `<?xml version="1.0" encoding="UTF-8"?>
546
- <feed xmlns="http://www.w3.org/2005/Atom">
1388
+ <feed xmlns="http://www.w3.org/2005/Atom"${jantNs}${mediaNs}${langAttr}>
547
1389
  <title>${escapeXml(feedTitle)}</title>
548
- <subtitle>${escapeXml(siteDescription)}</subtitle>
1390
+ <subtitle>${escapeXml(siteDescription)}</subtitle>${authorBlock}${iconBlock}
549
1391
  <link href="${escapeXml(siteUrl)}" rel="alternate"/>
550
- <link href="${escapeXml(selfUrl)}" rel="self"/>
1392
+ <link href="${escapeXml(selfUrl)}" rel="self"/>${alternateFeedLinks}
551
1393
  <id>${escapeXml(selfUrl)}</id>
552
- <updated>${feedUpdated}</updated>
1394
+ <updated>${feedUpdated}</updated>${discoverElement}
553
1395
  ${entries}
554
1396
  </feed>`;
555
1397
  }
@@ -567,15 +1409,15 @@ export interface SitemapUrlEntry {
567
1409
  /** ISO date (YYYY-MM-DD) or full ISO datetime */
568
1410
  lastmod?: string;
569
1411
  changefreq?:
570
- | "always"
571
- | "hourly"
572
- | "daily"
573
- | "weekly"
574
- | "monthly"
575
- | "yearly"
576
- | "never";
1412
+ "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
577
1413
  /** "0.0" – "1.0" */
578
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[];
579
1421
  }
580
1422
 
581
1423
  /** One `<sitemap>` entry inside a `<sitemapindex>`. */
@@ -604,12 +1446,23 @@ export function renderSitemapUrlSet(entries: SitemapUrlEntry[]): string {
604
1446
  if (entry.priority) {
605
1447
  parts.push(` <priority>${escapeXml(entry.priority)}</priority>`);
606
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
+ }
607
1454
  return ` <url>\n${parts.join("\n")}\n </url>`;
608
1455
  })
609
1456
  .join("\n");
610
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
+
611
1464
  return `<?xml version="1.0" encoding="UTF-8"?>
612
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
1465
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xhtmlNs}>
613
1466
  ${urls}
614
1467
  </urlset>`;
615
1468
  }