@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
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Announcing a site to its directory, from whichever route just gave it a
3
+ * reason to.
4
+ *
5
+ * Two routes have one: the settings page, whenever the owner turns Discover on
6
+ * or off, and first-run setup, when they answer the same question for the first
7
+ * time. Both send the same thing to the same place, so they send it through the
8
+ * same function rather than through two that drift.
9
+ *
10
+ * The transport lives in `lib/discover-ping.ts` and the protocol in
11
+ * `lib/discover.ts`. What is here is the part that needs a request: the
12
+ * deployment's endpoint, the site's own address, and the runtime's hook for
13
+ * work that outlives the response.
14
+ */
15
+
16
+ import type { Context } from "hono";
17
+ import type { Bindings } from "../types.js";
18
+ import type { AppVariables } from "../types/app-context.js";
19
+ import { runDeferred } from "../lib/deferred.js";
20
+ import {
21
+ getDiscoverFeedPath,
22
+ parseDiscoverSetting,
23
+ resolveDiscoverMode,
24
+ } from "../lib/discover.js";
25
+ import { getDiscoverDefault, getDiscoverPingUrl } from "../lib/env.js";
26
+ import { toAbsoluteSiteUrl } from "../lib/url.js";
27
+
28
+ type Env = { Bindings: Bindings; Variables: AppVariables };
29
+
30
+ /**
31
+ * Tell the configured directory where this site's feed is, in the background.
32
+ *
33
+ * Never awaited by its caller. A directory takes up to twelve seconds to give
34
+ * up on, and neither a settings save nor the last screen of setup may hang on
35
+ * one — nor fail because one is down. The outcome is recorded either way, so
36
+ * the owner can tell "announced" from "never got through"; the settings page's
37
+ * status block is where that shows, and it carries a Retry.
38
+ *
39
+ * @param c - Request context, for the runtime's background-work hook
40
+ * @param storedValue - The stored Discover choice. Passed in rather than read
41
+ * from `c.var.allSettings`, which is the snapshot taken before the request
42
+ * ran — on the save that triggers this it still holds the previous answer.
43
+ * @returns Whether an announcement was started at all
44
+ */
45
+ export function announceInBackground(
46
+ c: Context<Env>,
47
+ storedValue: string | undefined,
48
+ ): boolean {
49
+ const { appConfig } = c.var;
50
+ const endpoint = getDiscoverPingUrl(c.env);
51
+ if (!endpoint) return false;
52
+
53
+ // Read the mode back through the same derivation the feed uses, so a site
54
+ // that cannot actually be polled — feeds switched off, `noindex` set, demo
55
+ // mode — never announces an address that would answer 404.
56
+ const mode = resolveDiscoverMode({
57
+ storedValue,
58
+ defaultValue: getDiscoverDefault(c.env),
59
+ demoMode: appConfig.demoMode,
60
+ noindex: appConfig.noindex,
61
+ rssFeedsEnabled: appConfig.rssFeedsEnabled,
62
+ });
63
+ // A site that has just switched Discover off resolves to `none` and so has
64
+ // no feed of its own to name — but that is exactly the site with something
65
+ // to say, and the declaration a directory needs to read sits in every feed,
66
+ // not only the one it was polling. `/latest/feed` is the address that is
67
+ // always served, so the stop is sent there.
68
+ //
69
+ // Only for an owner's own `off`, never for the other ways a site resolves to
70
+ // `none`: a demo site or one with feeds switched off would be naming an
71
+ // address that answers 404.
72
+ const feedPath =
73
+ getDiscoverFeedPath(mode) ??
74
+ (parseDiscoverSetting(storedValue) === "off" &&
75
+ appConfig.rssFeedsEnabled &&
76
+ !appConfig.demoMode
77
+ ? getDiscoverFeedPath("latest")
78
+ : null);
79
+ if (!feedPath) return false;
80
+
81
+ // Deferred, so the caller answers without waiting on a directory. The helper
82
+ // is what keeps the orphaned promise from taking a Node process down with
83
+ // it: `announceToDiscover` already resolves rather than throws, and this is
84
+ // what holds if it ever stops being.
85
+ runDeferred(c, "Discover announcement", async () => {
86
+ await c.var.services.settings.announceToDiscover({
87
+ endpoint,
88
+ feedUrl: toAbsoluteSiteUrl(
89
+ feedPath,
90
+ appConfig.siteUrl,
91
+ appConfig.sitePathPrefix,
92
+ ),
93
+ });
94
+ });
95
+ return true;
96
+ }
@@ -6,6 +6,7 @@ import type { AppVariables } from "../../../types/app-context.js";
6
6
  import {
7
7
  createTestDatabase,
8
8
  DEFAULT_TEST_SITE_ID,
9
+ enforceD1BoundParameterLimit,
9
10
  } from "../../../__tests__/helpers/db.js";
10
11
  import { posts as postTable } from "../../../db/schema.js";
11
12
  import { createPostService } from "../../../services/post.js";
@@ -22,7 +23,7 @@ import type { Database } from "../../../db/index.js";
22
23
  type Env = { Bindings: Bindings; Variables: AppVariables };
23
24
 
24
25
  function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
25
- const { db } = createTestDatabase();
26
+ const { db, sqlite } = createTestDatabase();
26
27
  const pathService = createPathService(db as never, DEFAULT_TEST_SITE_ID);
27
28
 
28
29
  const services = {
@@ -65,7 +66,12 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
65
66
  app.route("/latest", latestRoutes);
66
67
  app.route("/featured", featuredRoutes);
67
68
 
68
- return { app, services, db: db as unknown as Database };
69
+ return { app, services, db: db as unknown as Database, sqlite };
70
+ }
71
+
72
+ /** Count the `<entry>` elements in an Atom document. */
73
+ function countEntries(xml: string): number {
74
+ return xml.match(/<entry>/g)?.length ?? 0;
69
75
  }
70
76
 
71
77
  afterEach(() => {
@@ -597,6 +603,132 @@ describe("Atom Feed Routes", () => {
597
603
  });
598
604
  });
599
605
 
606
+ describe("Discover fields", () => {
607
+ it("gives every entry the post's own ID beside its permalink id", async () => {
608
+ const { app, services } = createFeedTestApp();
609
+ const post = await services.posts.create({
610
+ format: "note",
611
+ title: "Identified",
612
+ bodyMarkdown: "Body",
613
+ status: "published",
614
+ slug: "identified",
615
+ });
616
+
617
+ const xml = await (await app.request("/latest/feed")).text();
618
+
619
+ expect(xml).toContain(
620
+ `<id>http://localhost:${DEFAULT_APP_PORT}/identified</id>\n <jant:id>${post.id}</jant:id>`,
621
+ );
622
+ });
623
+
624
+ it("names the status endpoint in the declaration while the site is listed", async () => {
625
+ const { app, services } = createFeedTestApp();
626
+ await services.settings.set("DISCOVER", "latest");
627
+
628
+ const xml = await (await app.request("/latest/feed")).text();
629
+
630
+ expect(xml).toContain(
631
+ ` status="http://localhost:${DEFAULT_APP_PORT}/api/discover/posts">latest</jant:discover>`,
632
+ );
633
+ });
634
+
635
+ it("names no status endpoint under none", async () => {
636
+ const { app, services } = createFeedTestApp();
637
+ await services.settings.set("DISCOVER", "off");
638
+
639
+ const xml = await (await app.request("/latest/feed")).text();
640
+
641
+ expect(xml).toContain("<jant:discover>none</jant:discover>");
642
+ });
643
+ });
644
+
645
+ describe("?limit=", () => {
646
+ async function createNotes(
647
+ services: ReturnType<typeof createFeedTestApp>["services"],
648
+ count: number,
649
+ options: { featured?: boolean } = {},
650
+ ) {
651
+ for (let i = 0; i < count; i++) {
652
+ await services.posts.create({
653
+ format: "note",
654
+ title: `Post ${i}`,
655
+ bodyMarkdown: `Body ${i}`,
656
+ status: "published",
657
+ featured: options.featured,
658
+ });
659
+ }
660
+ }
661
+
662
+ it("carries more than RSS_FEED_LIMIT when asked", async () => {
663
+ const { app, services } = createFeedTestApp({ RSS_FEED_LIMIT: "2" });
664
+ await createNotes(services, 4);
665
+
666
+ const xml = await (await app.request("/latest/feed?limit=3")).text();
667
+
668
+ expect(countEntries(xml)).toBe(3);
669
+ expect(xml).toContain("Post 3");
670
+ expect(xml).toContain("Post 1");
671
+ expect(xml).not.toContain("Post 0");
672
+ });
673
+
674
+ it("carries fewer when asked", async () => {
675
+ const { app, services } = createFeedTestApp();
676
+ await createNotes(services, 3);
677
+
678
+ const xml = await (await app.request("/latest/feed?limit=1")).text();
679
+
680
+ expect(countEntries(xml)).toBe(1);
681
+ expect(xml).toContain("Post 2");
682
+ });
683
+
684
+ it("keeps the site's length for a value it cannot read", async () => {
685
+ const { app, services } = createFeedTestApp({ RSS_FEED_LIMIT: "2" });
686
+ await createNotes(services, 4);
687
+
688
+ for (const query of ["limit=abc", "limit=0", "limit=-1", "limit=1.5"]) {
689
+ const res = await app.request(`/latest/feed?${query}`);
690
+ expect(res.status).toBe(200);
691
+ expect(countEntries(await res.text())).toBe(2);
692
+ }
693
+ });
694
+
695
+ it("applies to the featured and main feeds", async () => {
696
+ const { app, services } = createFeedTestApp({ RSS_FEED_LIMIT: "1" });
697
+ await createNotes(services, 3, { featured: true });
698
+
699
+ const featured = await (
700
+ await app.request("/featured/feed?limit=2")
701
+ ).text();
702
+ const main = await (await app.request("/feed?limit=2")).text();
703
+
704
+ expect(countEntries(featured)).toBe(2);
705
+ expect(countEntries(main)).toBe(2);
706
+ });
707
+
708
+ it("leaves the feed's self link on the address without it", async () => {
709
+ const { app, services } = createFeedTestApp();
710
+ await createNotes(services, 1);
711
+
712
+ const xml = await (await app.request("/latest/feed?limit=10")).text();
713
+
714
+ expect(xml).toContain(
715
+ `<link href="http://localhost:${DEFAULT_APP_PORT}/latest/feed" rel="self"/>`,
716
+ );
717
+ });
718
+
719
+ it("renders past a hundred entries within D1's bound-parameter limit", async () => {
720
+ const { app, services, sqlite } = createFeedTestApp();
721
+ await createNotes(services, 120, { featured: true });
722
+ enforceD1BoundParameterLimit(sqlite);
723
+
724
+ for (const path of ["/latest/feed", "/featured/feed"]) {
725
+ const res = await app.request(`${path}?limit=120`);
726
+ expect(res.status).toBe(200);
727
+ expect(countEntries(await res.text())).toBe(120);
728
+ }
729
+ });
730
+ });
731
+
600
732
  describe("RSS_PUBLISH_DELAY_SECONDS env var", () => {
601
733
  it("includes the exact 300-second boundary and excludes newer Posts", async () => {
602
734
  const currentTime = 2_000_000;
@@ -702,6 +834,37 @@ describe("Atom Feed Routes", () => {
702
834
  expect(xml).toContain("<hr/>");
703
835
  });
704
836
 
837
+ it("names a reply by its alias, the way the site does", async () => {
838
+ const { app, services } = createFeedTestApp();
839
+
840
+ const root = await services.posts.create({
841
+ format: "note",
842
+ title: "Aliased Thread Root",
843
+ bodyMarkdown: "Root content",
844
+ status: "published",
845
+ featured: true,
846
+ });
847
+ const reply = await services.posts.create({
848
+ format: "note",
849
+ bodyMarkdown: "Reply content",
850
+ status: "published",
851
+ replyToId: root.id,
852
+ });
853
+ await services.paths.create({
854
+ path: "/notes/the-reply",
855
+ kind: "alias",
856
+ postId: reply.id,
857
+ });
858
+
859
+ for (const path of ["/latest/feed", "/featured/feed"]) {
860
+ const xml = await (await app.request(path)).text();
861
+ // The row, the fold's `latest`, and the inline reply link all name
862
+ // the reply by the address the site uses, not by its slug.
863
+ expect(xml).toContain('href="http://localhost:3000/notes/the-reply"');
864
+ expect(xml).not.toContain(`/${reply.slug}"`);
865
+ }
866
+ });
867
+
705
868
  it("delays new replies inside an already eligible Thread", async () => {
706
869
  const currentTime = 2_000_000;
707
870
  const dateNow = vi.spyOn(Date, "now").mockReturnValue(currentTime * 1000);
@@ -28,9 +28,13 @@ import type {
28
28
  import type { AppVariables } from "../../types/app-context.js";
29
29
  import { defaultFeedRenderer } from "../../lib/feed.js";
30
30
  import {
31
+ buildFeedDiscoveryFields,
32
+ featuredFeedSelection,
31
33
  getFeedEntryUpdatedAt,
34
+ getFeedLimit,
32
35
  getRssPublishedBefore,
33
- RSS_FEED_CACHE_CONTROL,
36
+ latestFeedSelection,
37
+ renderFeed,
34
38
  } from "../../lib/feed-policy.js";
35
39
  import { buildMediaMap } from "../../lib/media-helpers.js";
36
40
  import { getI18n } from "../../i18n/index.js";
@@ -38,6 +42,7 @@ import { FORMATS } from "../../types/constants.js";
38
42
 
39
43
  import { createMediaContext, toPostViews } from "../../lib/view.js";
40
44
  import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
45
+ import { getViewLang, viewBasePath } from "../../lib/view-language.js";
41
46
  import { toPlainText } from "../../lib/markdown.js";
42
47
 
43
48
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -82,12 +87,18 @@ async function loadMediaMap(
82
87
 
83
88
  /**
84
89
  * Build thread replies as PostView[] for a given root post from a thread map.
90
+ *
91
+ * Replies take the alias map too. A reply's permalink is what `<jant:post>`
92
+ * rows, the fold's `gap` and `latest`, and every `media:content` name it by,
93
+ * and a consumer keys on that address — so it has to be the address the site
94
+ * itself uses, which is the reply's first alias when it has one.
85
95
  */
86
96
  function buildThreadReplies(
87
97
  rootId: string,
88
98
  threadMap: Map<string, Post[]>,
89
99
  mediaMap: ReturnType<typeof buildMediaMap>,
90
100
  mediaCtx: ReturnType<typeof createMediaContext>,
101
+ aliasMap: Map<string, string>,
91
102
  ) {
92
103
  const thread = threadMap.get(rootId);
93
104
  if (!thread || thread.length <= 1) return undefined;
@@ -100,9 +111,20 @@ function buildThreadReplies(
100
111
  mediaAttachments: mediaMap.get(r.id) ?? [],
101
112
  })),
102
113
  mediaCtx,
114
+ undefined,
115
+ aliasMap,
103
116
  );
104
117
  }
105
118
 
119
+ /** The first alias of each post that has one: the address the site uses. */
120
+ function toAliasMap(aliasesMap: Map<string, string[]>): Map<string, string> {
121
+ const aliasMap = new Map<string, string>();
122
+ for (const [id, aliases] of aliasesMap) {
123
+ if (aliases[0]) aliasMap.set(id, aliases[0]);
124
+ }
125
+ return aliasMap;
126
+ }
127
+
106
128
  /**
107
129
  * Collect all reply IDs from a thread map (excluding root posts).
108
130
  */
@@ -126,13 +148,12 @@ async function buildLatestFeedData(
126
148
  format?: Format,
127
149
  ): Promise<{ posts: Post[]; postViews: FeedPostView[] }> {
128
150
  const posts = await c.var.services.posts.list({
129
- status: "published",
130
- excludeReplies: true,
131
- excludeLatestHidden: true,
132
- excludePrivate: true,
151
+ ...latestFeedSelection({
152
+ lang: getViewLang(c) ?? undefined,
153
+ publishedBefore,
154
+ }),
133
155
  format,
134
156
  ignorePinnedSort: true,
135
- publishedBefore,
136
157
  limit: feedLimit,
137
158
  });
138
159
 
@@ -146,17 +167,19 @@ async function buildLatestFeedData(
146
167
  c.var.services.paths.getPostAliases(postIds),
147
168
  ]);
148
169
 
149
- // Load media for replies
170
+ // Replies are only known once the threads are, so their media and aliases
171
+ // come in a second round.
150
172
  const replyIds = collectReplyIds(threadMap);
151
- const replyMediaMap =
152
- replyIds.length > 0 ? await loadMediaMap(c, replyIds, mediaCtx) : mediaMap;
173
+ const [replyMediaMap, replyAliasesMap] =
174
+ replyIds.length > 0
175
+ ? await Promise.all([
176
+ loadMediaMap(c, replyIds, mediaCtx),
177
+ c.var.services.paths.getPostAliases(replyIds),
178
+ ])
179
+ : [mediaMap, new Map<string, string[]>()];
153
180
  // Merge reply media into main map
154
181
  const mergedMediaMap = new Map([...mediaMap, ...replyMediaMap]);
155
-
156
- const aliasMap = new Map<string, string>();
157
- for (const [id, aliases] of aliasesMap) {
158
- if (aliases[0]) aliasMap.set(id, aliases[0]);
159
- }
182
+ const aliasMap = toAliasMap(new Map([...aliasesMap, ...replyAliasesMap]));
160
183
 
161
184
  const postViews = toPostViews(
162
185
  posts.map((p) => ({
@@ -177,6 +200,7 @@ async function buildLatestFeedData(
177
200
  threadMap,
178
201
  mergedMediaMap,
179
202
  mediaCtx,
203
+ aliasMap,
180
204
  ),
181
205
  };
182
206
  });
@@ -197,9 +221,10 @@ async function buildFeaturedFeedData(
197
221
  publishedBefore: number,
198
222
  ): Promise<{ posts: Post[]; postViews: FeedPostView[] }> {
199
223
  const rootIds = await c.var.services.posts.listFeaturedThreadRootIds({
200
- status: "published",
201
- excludePrivate: true,
202
- publishedBefore,
224
+ ...featuredFeedSelection({
225
+ lang: getViewLang(c) ?? undefined,
226
+ publishedBefore,
227
+ }),
203
228
  limit: feedLimit,
204
229
  });
205
230
 
@@ -224,7 +249,7 @@ async function buildFeaturedFeedData(
224
249
  const postIds = posts.map((p) => p.id);
225
250
  const mediaCtx = createMediaContext(c.var.appConfig);
226
251
 
227
- // Collect all post IDs (roots + replies) for media loading
252
+ // Collect all post IDs (roots + replies) for media and alias loading
228
253
  const allPostIds = new Set(postIds);
229
254
  for (const thread of threadMap.values()) {
230
255
  for (const post of thread) {
@@ -234,13 +259,9 @@ async function buildFeaturedFeedData(
234
259
 
235
260
  const [mediaMap, aliasesMap] = await Promise.all([
236
261
  loadMediaMap(c, [...allPostIds], mediaCtx),
237
- c.var.services.paths.getPostAliases(postIds),
262
+ c.var.services.paths.getPostAliases([...allPostIds]),
238
263
  ]);
239
-
240
- const aliasMap = new Map<string, string>();
241
- for (const [id, aliases] of aliasesMap) {
242
- if (aliases[0]) aliasMap.set(id, aliases[0]);
243
- }
264
+ const aliasMap = toAliasMap(aliasesMap);
244
265
 
245
266
  const postViews = toPostViews(
246
267
  posts.map((p) => ({
@@ -257,7 +278,13 @@ async function buildFeaturedFeedData(
257
278
  return {
258
279
  ...postView,
259
280
  feedUpdatedAt: getFeedEntryUpdatedAt(post, thread),
260
- threadReplies: buildThreadReplies(post.id, threadMap, mediaMap, mediaCtx),
281
+ threadReplies: buildThreadReplies(
282
+ post.id,
283
+ threadMap,
284
+ mediaMap,
285
+ mediaCtx,
286
+ aliasMap,
287
+ ),
261
288
  };
262
289
  });
263
290
 
@@ -284,8 +311,9 @@ export async function buildFeedData(
284
311
  const siteName = appConfig.siteName;
285
312
  const siteDescription = toPlainText(appConfig.siteDescription);
286
313
  const siteUrl = appConfig.siteUrl;
287
- const siteLanguage = appConfig.siteLanguage;
288
- const feedLimit = appConfig.rssFeedLimit;
314
+ // A language view's feed is that language's feed, so it declares it.
315
+ const siteLanguage = getViewLang(c) ?? appConfig.siteLanguage;
316
+ const feedLimit = getFeedLimit(c);
289
317
  const publishedBefore = getRssPublishedBefore(
290
318
  appConfig.rssPublishDelaySeconds,
291
319
  );
@@ -297,6 +325,7 @@ export async function buildFeedData(
297
325
  : await buildLatestFeedData(c, feedLimit, publishedBefore, opts.format);
298
326
 
299
327
  return {
328
+ ...buildFeedDiscoveryFields(c),
300
329
  siteName,
301
330
  siteDescription,
302
331
  siteUrl,
@@ -318,10 +347,11 @@ export async function buildFeedData(
318
347
  }),
319
348
  )}`,
320
349
  selfUrl: toAbsoluteSiteUrl(
321
- opts.selfPath,
350
+ `${viewBasePath(c)}${opts.selfPath}`,
322
351
  siteUrl,
323
352
  appConfig.sitePathPrefix,
324
353
  ),
354
+ siteIconUrl: appConfig.siteAvatarUrl || undefined,
325
355
  posts: postViews,
326
356
  };
327
357
  }
@@ -338,21 +368,20 @@ export function parseFormatQuery(c: Context<Env>): Format | undefined {
338
368
  return undefined;
339
369
  }
340
370
 
341
- export function renderFeed(xml: string) {
342
- return new Response(xml, {
343
- headers: {
344
- "Content-Type": "application/atom+xml; charset=utf-8",
345
- "Cache-Control": RSS_FEED_CACHE_CONTROL,
346
- },
347
- });
348
- }
349
-
350
- // Atom — /feed
351
- feedRoutes.get("/", async (c) => {
371
+ /**
372
+ * Render the site's main Atom feed for the current view language.
373
+ *
374
+ * @param c - Hono context
375
+ * @returns Atom feed response
376
+ */
377
+ export async function renderMainFeed(c: Context<Env>): Promise<Response> {
352
378
  const kind = c.var.appConfig.mainRssFeed === "latest" ? "latest" : "featured";
353
379
  const feedData = await buildFeedData(c, { kind, selfPath: "/feed" });
354
380
  return renderFeed(defaultFeedRenderer(feedData));
355
- });
381
+ }
382
+
383
+ // Atom — /feed
384
+ feedRoutes.get("/", renderMainFeed);
356
385
 
357
386
  // Legacy — /feed/latest moved to the canonical /latest/feed. Kept
358
387
  // indefinitely as a 308 so old subscribers don't break; preserves the
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { Hono } from "hono";
18
- import type { Bindings } from "../../types.js";
18
+ import type { Bindings, Post } from "../../types.js";
19
19
  import type { AppVariables } from "../../types/app-context.js";
20
20
  import {
21
21
  renderSitemapIndex,
@@ -82,8 +82,12 @@ sitemapRoutes.get("/sitemap.xml", async (c) => {
82
82
  });
83
83
  }
84
84
 
85
- const collections = await c.var.services.collections.list();
86
- if (collections.length > 0) {
85
+ // The shard carries both kinds, so it is listed when either exists.
86
+ const [collections, smartCollections] = await Promise.all([
87
+ c.var.services.collections.list(),
88
+ c.var.services.smartCollections.list(),
89
+ ]);
90
+ if (collections.length > 0 || smartCollections.length > 0) {
87
91
  entries.push({
88
92
  loc: absoluteUrl("/sitemap-collections.xml", siteUrl, sitePathPrefix),
89
93
  });
@@ -125,16 +129,66 @@ sitemapRoutes.get("/:file{sitemap-posts-[0-9]+\\.xml}", async (c) => {
125
129
  limit: SITEMAP_SHARD_SIZE,
126
130
  });
127
131
 
132
+ // `entry.alias` already includes a leading "/" (see `paths.getPostAliases`);
133
+ // slugs are stored raw. Prepending "/" to an alias would create "//path",
134
+ // which `new URL()` reads as protocol-relative and hijacks the hostname.
135
+ const pathOf = (entry: (typeof shardEntries)[number]) =>
136
+ entry.alias ?? `/${entry.slug}`;
137
+
138
+ // A post's URL is language-neutral and listed once. Translations are
139
+ // announced with `xhtml:link` alternates instead, which is what the sitemap
140
+ // protocol asks for — and the only place a crawler can learn about a
141
+ // translation whose own URL lives in a different shard.
142
+ const translated = shardEntries.filter(
143
+ (entry) => entry.translationGroupId && entry.language,
144
+ );
145
+ const translationsMap =
146
+ translated.length > 0
147
+ ? await c.var.services.posts.getTranslationsMap(
148
+ translated.map((entry) => entry.id),
149
+ )
150
+ : new Map<string, Post[]>();
151
+ const siblingIds = [...translationsMap.values()]
152
+ .flat()
153
+ .map((post) => post.id);
154
+ const siblingAliases =
155
+ siblingIds.length > 0
156
+ ? await c.var.services.paths.getPostAliases(siblingIds)
157
+ : new Map<string, string[]>();
158
+
128
159
  const urls: SitemapUrlEntry[] = shardEntries.map((entry) => {
129
- // `entry.alias` already includes a leading "/" (see
130
- // `paths.getPostAliases`); slugs are stored raw. Prepending "/" to an
131
- // alias would create "//path" which `new URL()` interprets as
132
- // protocol-relative and hijacks the hostname.
133
- const path = entry.alias ?? `/${entry.slug}`;
160
+ const path = pathOf(entry);
161
+ const siblings: Post[] = translationsMap.get(entry.id) ?? [];
162
+ const alternates =
163
+ entry.language && siblings.length > 0
164
+ ? [
165
+ {
166
+ hreflang: entry.language,
167
+ href: absoluteUrl(path, siteUrl, sitePathPrefix),
168
+ },
169
+ ...siblings.flatMap((sibling) =>
170
+ sibling.language
171
+ ? [
172
+ {
173
+ hreflang: sibling.language,
174
+ href: absoluteUrl(
175
+ siblingAliases.get(sibling.id)?.[0] ??
176
+ `/${sibling.slug}`,
177
+ siteUrl,
178
+ sitePathPrefix,
179
+ ),
180
+ },
181
+ ]
182
+ : [],
183
+ ),
184
+ ]
185
+ : undefined;
186
+
134
187
  return {
135
188
  loc: absoluteUrl(path, siteUrl, sitePathPrefix),
136
189
  lastmod: toIsoDate(entry.updatedAt),
137
190
  priority: entry.featuredAt ? "0.8" : "0.6",
191
+ ...(alternates ? { alternates } : {}),
138
192
  };
139
193
  });
140
194
 
@@ -156,7 +210,10 @@ sitemapRoutes.get("/sitemap-collections.xml", async (c) => {
156
210
  const { appConfig } = c.var;
157
211
  const { siteUrl, sitePathPrefix } = appConfig;
158
212
 
159
- const collections = await c.var.services.collections.list();
213
+ const [collections, smartCollections] = await Promise.all([
214
+ c.var.services.collections.list(),
215
+ c.var.services.smartCollections.list(),
216
+ ]);
160
217
 
161
218
  // Resolve each collection's canonical URL (alias if one exists, else slug).
162
219
  // The `/collections` directory itself lives in `/sitemap-pages.xml`, since
@@ -176,6 +233,19 @@ sitemapRoutes.get("/sitemap-collections.xml", async (c) => {
176
233
  }),
177
234
  );
178
235
 
236
+ // Smart collections are here unconditionally. A page a reader assembled out
237
+ // of archive parameters is one facet of a combinatorial family and stays out
238
+ // of the index; a smart collection is a page the author declared and gave an
239
+ // address, so it belongs in the sitemap like any other — and unlike an
240
+ // archive URL, it can never be one only its author can see.
241
+ for (const smartCollection of smartCollections) {
242
+ urls.push({
243
+ loc: absoluteUrl(`/${smartCollection.slug}`, siteUrl, sitePathPrefix),
244
+ lastmod: toIsoDate(smartCollection.updatedAt),
245
+ priority: "0.7",
246
+ });
247
+ }
248
+
179
249
  return xmlResponse(renderSitemapUrlSet(urls), CACHE_SHORT);
180
250
  });
181
251
 
@@ -206,12 +276,26 @@ sitemapRoutes.get("/sitemap-pages.xml", async (c) => {
206
276
  changefreq: "daily",
207
277
  });
208
278
 
279
+ // The subscribe page exists only while feeds do, and its content never
280
+ // changes on its own — it lists the same three addresses for the life of the
281
+ // site.
282
+ if (appConfig.rssFeedsEnabled) {
283
+ urls.push({
284
+ loc: absoluteUrl("/subscribe", siteUrl, sitePathPrefix),
285
+ priority: "0.3",
286
+ changefreq: "yearly",
287
+ });
288
+ }
289
+
209
290
  // Include the collections directory landing page when at least one
210
291
  // collection exists. When there are no collections, `/collections` still
211
292
  // renders (as an empty directory), but indexing an empty aggregate page
212
293
  // adds no value.
213
- const collections = await c.var.services.collections.list();
214
- if (collections.length > 0) {
294
+ const [collections, smartCollections] = await Promise.all([
295
+ c.var.services.collections.list(),
296
+ c.var.services.smartCollections.list(),
297
+ ]);
298
+ if (collections.length > 0 || smartCollections.length > 0) {
215
299
  urls.push({
216
300
  loc: absoluteUrl("/collections", siteUrl, sitePathPrefix),
217
301
  priority: "0.5",