@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -7,6 +7,7 @@ import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../types/app-context.js";
8
8
  import { z } from "zod";
9
9
  import {
10
+ ContentLanguageSchema,
10
11
  CreatePostApiSchema,
11
12
  UpdatePostApiSchema,
12
13
  FormatSchema,
@@ -16,7 +17,10 @@ import {
16
17
  } from "../../lib/schemas.js";
17
18
  import { requireAuthApi } from "../../middleware/auth.js";
18
19
  import { toApiAttachment, toApiPost } from "../../lib/api-posts.js";
20
+ import { getPostDisplayTitle } from "../../lib/post-meta.js";
19
21
  import { assertFound, NotFoundError, parseIdParam } from "../../lib/errors.js";
22
+ import { AddressQuerySchema, requestInternalPath } from "../../lib/address.js";
23
+ import { toPublicPath } from "../../lib/url.js";
20
24
  import { ID_PREFIX } from "../../lib/ids.js";
21
25
  import { triggerGitHubSyncInline } from "../../lib/github-sync-trigger.js";
22
26
 
@@ -131,12 +135,15 @@ postsApiRoutes.get("/:id/content", requireAuthApi(), async (c) => {
131
135
  postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
132
136
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
133
137
 
134
- // Fetch post, media, and collections in parallel (all keyed by the same id)
135
- const [post, mediaList, threadCollections] = await Promise.all([
136
- c.var.services.posts.getById(id),
137
- c.var.services.media.getByPostId(id),
138
- c.var.services.collections.getCollectionsByPostId(id),
139
- ]);
138
+ // Fetch post, media, collections and thread position in parallel (all keyed
139
+ // by the same id)
140
+ const [post, mediaList, threadCollections, threadPosition] =
141
+ await Promise.all([
142
+ c.var.services.posts.getById(id),
143
+ c.var.services.media.getByPostId(id),
144
+ c.var.services.collections.getCollectionsByPostId(id),
145
+ c.var.services.posts.getThreadPosition(id),
146
+ ]);
140
147
  const foundPost = assertFound(post, "Post");
141
148
  const {
142
149
  r2PublicUrl,
@@ -147,8 +154,8 @@ postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
147
154
  } = c.var.appConfig;
148
155
  const collectionIds = threadCollections.map((col) => col.id);
149
156
 
150
- return c.json(
151
- toApiPost(foundPost, {
157
+ return c.json({
158
+ ...toApiPost(foundPost, {
152
159
  collectionIds,
153
160
  attachments: mediaList.map((m) =>
154
161
  toApiAttachment(
@@ -161,7 +168,8 @@ postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
161
168
  ),
162
169
  ),
163
170
  }),
164
- );
171
+ threadPosition,
172
+ });
165
173
  });
166
174
 
167
175
  // Create post (requires auth)
@@ -192,6 +200,8 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
192
200
  collectionEntries: body.collectionEntries,
193
201
  replyToId: body.replyToId,
194
202
  quietReply: body.quietReply,
203
+ language: body.language,
204
+ translationOfId: body.translationOfId,
195
205
  publishedAt: body.publishedAt,
196
206
  },
197
207
  body.attachments,
@@ -265,6 +275,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
265
275
  collectionIds: body.collectionIds,
266
276
  collectionEntries: body.collectionEntries,
267
277
  publishedAt: body.publishedAt,
278
+ language: body.language,
268
279
  },
269
280
  body.attachments,
270
281
  {
@@ -309,6 +320,158 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
309
320
  );
310
321
  });
311
322
 
323
+ // =============================================================================
324
+ // Language and translations
325
+ // =============================================================================
326
+
327
+ const SetLanguageSchema = z.object({ language: ContentLanguageSchema });
328
+ const TranslationCandidatesQuerySchema = z.object({
329
+ q: z.string().trim().min(1).max(200),
330
+ limit: z.coerce.number().int().min(1).max(20).optional().default(8),
331
+ });
332
+ const LinkTranslationSchema = z.object({ postId: PostIdSchema });
333
+
334
+ /**
335
+ * Set the content language of a whole Thread.
336
+ *
337
+ * Thread-wide because `post.language` is uniform inside a Thread — see the
338
+ * service method for why every language filter depends on that.
339
+ */
340
+ postsApiRoutes.put("/:id/language", requireAuthApi(), async (c) => {
341
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
342
+ const { language } = parseValidated(SetLanguageSchema, await c.req.json());
343
+
344
+ await c.var.services.posts.setThreadLanguage(id, language);
345
+ await triggerGitHubSyncInline(c);
346
+
347
+ return c.json({ success: true, language });
348
+ });
349
+
350
+ /** List the Thread roots this post is a translation of. */
351
+ postsApiRoutes.get("/:id/translations", requireAuthApi(), async (c) => {
352
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
353
+ const post = assertFound(await c.var.services.posts.getById(id), "Post");
354
+ const translations = await c.var.services.posts.listTranslations(
355
+ post.threadId,
356
+ );
357
+
358
+ return c.json({
359
+ translations: translations.map((translation) => ({
360
+ id: translation.id,
361
+ slug: translation.slug,
362
+ title: translation.title,
363
+ // What to show in a list: notes are usually untitled, and a slug is not
364
+ // a name.
365
+ label: getPostDisplayTitle(translation) || translation.slug,
366
+ language: translation.language,
367
+ })),
368
+ });
369
+ });
370
+
371
+ /**
372
+ * Posts this one could be linked to as a translation.
373
+ *
374
+ * The eligibility rules live in the service; the menu only renders what comes
375
+ * back, so it never offers a post the link would refuse.
376
+ */
377
+ postsApiRoutes.get(
378
+ "/:id/translations/candidates",
379
+ requireAuthApi(),
380
+ async (c) => {
381
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
382
+ const { q, limit } = parseValidated(
383
+ TranslationCandidatesQuerySchema,
384
+ c.req.query(),
385
+ );
386
+
387
+ const candidates = await c.var.services.posts.listTranslationCandidates(
388
+ id,
389
+ {
390
+ query: q,
391
+ limit,
392
+ },
393
+ );
394
+
395
+ return c.json({
396
+ candidates: candidates.map((post) => ({
397
+ id: post.id,
398
+ slug: post.slug,
399
+ title: post.title,
400
+ label: getPostDisplayTitle(post) || post.slug,
401
+ language: post.language,
402
+ })),
403
+ });
404
+ },
405
+ );
406
+
407
+ /**
408
+ * The Thread a pasted address names, and whether it can be linked to this one.
409
+ *
410
+ * Separate from the candidate search so a URL never quietly becomes search
411
+ * words: this endpoint only ever answers about one address, and answers with a
412
+ * reason when the Thread it names is not eligible.
413
+ */
414
+ postsApiRoutes.get("/:id/translations/resolve", requireAuthApi(), async (c) => {
415
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
416
+ const { url } = parseValidated(AddressQuerySchema, c.req.query());
417
+
418
+ const path = requestInternalPath(c, url);
419
+ if (path === null) {
420
+ return c.json({ resolution: { kind: "external", address: url.trim() } });
421
+ }
422
+
423
+ const resolution = await c.var.services.posts.resolveTranslationCandidate(
424
+ id,
425
+ path,
426
+ );
427
+ const address = toPublicPath(path, c.var.appConfig.sitePathPrefix);
428
+
429
+ if (resolution.status === "ok") {
430
+ const post = resolution.post;
431
+ return c.json({
432
+ resolution: {
433
+ kind: "ok",
434
+ address,
435
+ candidate: {
436
+ id: post.id,
437
+ slug: post.slug,
438
+ label: getPostDisplayTitle(post) || post.slug,
439
+ language: post.language,
440
+ },
441
+ },
442
+ });
443
+ }
444
+
445
+ return c.json({
446
+ resolution: {
447
+ kind: resolution.status,
448
+ address,
449
+ ...("language" in resolution ? { language: resolution.language } : {}),
450
+ },
451
+ });
452
+ });
453
+
454
+ /** Link two already-published Threads as translations of each other. */
455
+ postsApiRoutes.post("/:id/translations", requireAuthApi(), async (c) => {
456
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
457
+ const { postId } = parseValidated(LinkTranslationSchema, await c.req.json());
458
+
459
+ await c.var.services.posts.linkTranslation(id, postId);
460
+ await triggerGitHubSyncInline(c);
461
+
462
+ return c.json({ success: true });
463
+ });
464
+
465
+ /** Take this Thread out of its translation group. */
466
+ postsApiRoutes.delete("/:id/translations", requireAuthApi(), async (c) => {
467
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
468
+
469
+ await c.var.services.posts.unlinkTranslation(id);
470
+ await triggerGitHubSyncInline(c);
471
+
472
+ return c.json({ success: true });
473
+ });
474
+
312
475
  // Delete post (requires auth)
313
476
  postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
314
477
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
@@ -52,6 +52,117 @@ describe("Public Archive API Routes", () => {
52
52
  expect(hidden.visibility).toBe("latest_hidden");
53
53
  });
54
54
 
55
+ it("answers the visibility question it was asked", async () => {
56
+ const { app, services } = createTestApp({ authenticated: false });
57
+ app.route("/api/public/archive", publicArchiveApiRoutes);
58
+
59
+ const plain = await services.posts.create({
60
+ format: "note",
61
+ title: "Plain root",
62
+ bodyMarkdown: "plain",
63
+ });
64
+ const hidden = await services.posts.create({
65
+ format: "note",
66
+ title: "Hidden root",
67
+ bodyMarkdown: "hidden",
68
+ visibility: "latest_hidden",
69
+ });
70
+ const featured = await services.posts.create({
71
+ format: "note",
72
+ title: "Featured root",
73
+ bodyMarkdown: "featured",
74
+ featured: true,
75
+ });
76
+
77
+ const hiddenOnly = await app.request(
78
+ "/api/public/archive?visibility=hidden",
79
+ );
80
+ expect(hiddenOnly.status).toBe(200);
81
+ const hiddenBody = await hiddenOnly.json();
82
+ expect(hiddenBody.posts.map((p: { id: string }) => p.id)).toEqual([
83
+ hidden.id,
84
+ ]);
85
+
86
+ const featuredOnly = await app.request(
87
+ "/api/public/archive?visibility=featured",
88
+ );
89
+ const featuredBody = await featuredOnly.json();
90
+ expect(featuredBody.posts.map((p: { id: string }) => p.id)).toEqual([
91
+ featured.id,
92
+ ]);
93
+
94
+ const publicOnly = await app.request(
95
+ "/api/public/archive?visibility=public",
96
+ );
97
+ const publicBody = await publicOnly.json();
98
+ const publicIds = publicBody.posts.map((p: { id: string }) => p.id);
99
+ expect(publicIds).toContain(plain.id);
100
+ expect(publicIds).not.toContain(hidden.id);
101
+ });
102
+
103
+ // The failure this guards: an unknown key is stripped by the schema, so a
104
+ // caller asking for something this endpoint cannot serve used to get the
105
+ // whole archive back and no way to tell.
106
+ it("rejects a visibility it cannot serve instead of widening", async () => {
107
+ const { app, services } = createTestApp({ authenticated: false });
108
+ app.route("/api/public/archive", publicArchiveApiRoutes);
109
+
110
+ await services.posts.create({
111
+ format: "note",
112
+ title: "Plain root",
113
+ bodyMarkdown: "plain",
114
+ });
115
+
116
+ for (const value of ["private", "nonsense"]) {
117
+ const res = await app.request(
118
+ `/api/public/archive?visibility=${value}`,
119
+ );
120
+ expect(res.status).toBe(400);
121
+ }
122
+ });
123
+
124
+ // `latest_hidden` is the stored spelling of `hidden`, which the page and
125
+ // the feed both read. This endpoint used to be the one surface that called
126
+ // it nonsense — a value it can serve perfectly, rejected only because it
127
+ // kept a private copy of the vocabulary.
128
+ it("reads the stored spelling of hidden, like every other surface", async () => {
129
+ const { app, services } = createTestApp({ authenticated: false });
130
+ app.route("/api/public/archive", publicArchiveApiRoutes);
131
+
132
+ await services.posts.create({
133
+ format: "note",
134
+ title: "Plain root",
135
+ bodyMarkdown: "plain",
136
+ });
137
+ const hidden = await services.posts.create({
138
+ format: "note",
139
+ title: "Hidden root",
140
+ bodyMarkdown: "hidden",
141
+ visibility: "latest_hidden",
142
+ });
143
+
144
+ const res = await app.request(
145
+ "/api/public/archive?visibility=latest_hidden",
146
+ );
147
+ expect(res.status).toBe(200);
148
+ const body = await res.json();
149
+ expect(body.posts.map((p: { id: string }) => p.id)).toEqual([hidden.id]);
150
+ });
151
+
152
+ it("rejects a parameter it does not know", async () => {
153
+ const { app, services } = createTestApp({ authenticated: false });
154
+ app.route("/api/public/archive", publicArchiveApiRoutes);
155
+
156
+ await services.posts.create({
157
+ format: "note",
158
+ title: "Plain root",
159
+ bodyMarkdown: "plain",
160
+ });
161
+
162
+ const res = await app.request("/api/public/archive?formta=note");
163
+ expect(res.status).toBe(400);
164
+ });
165
+
55
166
  it("supports format and limit filters with cursor", async () => {
56
167
  const { app, services } = createTestApp({ authenticated: false });
57
168
  app.route("/api/public/archive", publicArchiveApiRoutes);
@@ -53,6 +53,44 @@ describe("Public Posts API Routes", () => {
53
53
  expect(body.posts[0]).not.toHaveProperty("body");
54
54
  });
55
55
 
56
+ // Consumers need both axes: `lastActivityAt` answers "when was this
57
+ // announced", `threadUpdatedAt` answers "when did it actually change".
58
+ // Exposing only the first left no way to tell a quiet addition happened.
59
+ it("reports both thread activity timestamps", async () => {
60
+ const { app, services } = createTestApp({ authenticated: false });
61
+ app.route("/api/public/posts", publicPostsApiRoutes);
62
+
63
+ const root = await services.posts.create({
64
+ format: "note",
65
+ title: "Quietly extended",
66
+ bodyMarkdown: "root",
67
+ publishedAt: 1000,
68
+ });
69
+ await services.posts.create({
70
+ format: "note",
71
+ bodyMarkdown: "quiet addendum",
72
+ replyToId: root.id,
73
+ publishedAt: 3000,
74
+ quietReply: true,
75
+ });
76
+
77
+ const res = await app.request("/api/public/posts");
78
+ expect(res.status).toBe(200);
79
+ const body = (await res.json()) as {
80
+ posts: {
81
+ title: string | null;
82
+ quietReply: boolean;
83
+ lastActivityAt: number;
84
+ threadUpdatedAt: number;
85
+ }[];
86
+ };
87
+
88
+ const entry = body.posts.find((p) => p.title === "Quietly extended");
89
+ expect(entry?.lastActivityAt).toBe(1000);
90
+ expect(entry?.threadUpdatedAt).toBe(3000);
91
+ expect(entry?.quietReply).toBe(false);
92
+ });
93
+
56
94
  it("supports format and limit filters", async () => {
57
95
  const { app, services } = createTestApp({ authenticated: false });
58
96
  app.route("/api/public/posts", publicPostsApiRoutes);
@@ -2,8 +2,17 @@ import { Hono } from "hono";
2
2
  import { z } from "zod";
3
3
  import type { Bindings } from "../../../types.js";
4
4
  import type { AppVariables } from "../../../types/app-context.js";
5
- import { MEDIA_KINDS } from "../../../types.js";
6
- import { FormatSchema, parseValidated } from "../../../lib/schemas.js";
5
+ import { PUBLIC_ARCHIVE_VISIBILITIES } from "../../../types.js";
6
+ import { ContentLanguageSchema, parseValidated } from "../../../lib/schemas.js";
7
+ import type { DimensionContext } from "../../../lib/filter-dimensions.js";
8
+ import {
9
+ buildCollectionVocabulary,
10
+ EMPTY_COLLECTION_VOCABULARY,
11
+ parsePostFilterSelectionStrict,
12
+ readCollectionSlugs,
13
+ toPostFilters,
14
+ } from "../../../lib/filter-dimensions.js";
15
+ import { ValidationError } from "../../../lib/errors.js";
7
16
  import { toPublicPost } from "./posts.js";
8
17
  import { requirePublicApiEnabled } from "../../../middleware/public-content-access.js";
9
18
 
@@ -13,104 +22,91 @@ export const publicArchiveApiRoutes = new Hono<Env>();
13
22
 
14
23
  publicArchiveApiRoutes.use("*", requirePublicApiEnabled());
15
24
 
16
- const MediaKindSchema = z.enum(MEDIA_KINDS);
17
- const MEDIA_KIND_LIST = MEDIA_KINDS.join(", ");
18
- const INVALID_MEDIA_MESSAGE =
19
- "Invalid media value. Allowed: any, none, or kinds: " + MEDIA_KIND_LIST;
20
-
21
- const BoolFlagSchema = z.enum(["0", "1"]).transform((value) => value === "1");
22
- const PresenceSchema = z
23
- .enum(["any", "none"])
24
- .transform((value) => value === "any");
25
-
25
+ /**
26
+ * The endpoint's own parameters — everything that is not a filter dimension.
27
+ *
28
+ * The dimensions themselves are read by the shared registry, so this endpoint
29
+ * cannot drift from the page it mirrors. They are listed here only so the strict
30
+ * parser can tell them apart from a parameter nobody understands.
31
+ */
26
32
  const ListPublicArchiveQuerySchema = z.object({
27
- format: FormatSchema.optional(),
28
- collection: z.string().optional(),
29
- year: z.coerce.number().int().min(1971).optional(),
30
- // Kinds list (image,video,...) or presence words: any = posts with any
31
- // attachment, none = posts without attachments.
32
- media: z
33
- .string()
34
- .optional()
35
- .transform((value, ctx) => {
36
- if (!value) return undefined;
37
- if (value === "any" || value === "none") return value;
38
- const parts = value
39
- .split(",")
40
- .map((part) => part.trim())
41
- .filter((part) => part.length > 0);
42
- if (parts.length === 0) return undefined;
43
- const result = z.array(MediaKindSchema).safeParse(parts);
44
- if (!result.success) {
45
- ctx.addIssue({
46
- code: z.ZodIssueCode.custom,
47
- message: INVALID_MEDIA_MESSAGE,
48
- });
49
- return z.NEVER;
50
- }
51
- return result.data;
52
- }),
53
- title: PresenceSchema.optional(),
54
- replies: PresenceSchema.optional(),
55
- // Deprecated: use media=any|none and title=any|none instead.
56
- hasMedia: BoolFlagSchema.optional(),
57
- hasTitle: BoolFlagSchema.optional(),
33
+ /** Restrict to one content language. See `/api/public/posts`. */
34
+ lang: ContentLanguageSchema.optional(),
58
35
  cursor: z.string().optional(),
59
36
  limit: z.coerce.number().int().min(1).max(100).optional().default(20),
60
37
  content: z.enum(["markdown"]).optional(),
61
38
  });
62
39
 
40
+ const ENDPOINT_PARAMS = ["lang", "cursor", "limit", "content"] as const;
41
+
42
+ const INVALID_VISIBILITY_MESSAGE =
43
+ "Invalid visibility value. Allowed: " +
44
+ PUBLIC_ARCHIVE_VISIBILITIES.map((value) =>
45
+ value === "latest_hidden" ? "hidden" : value,
46
+ ).join(", ");
47
+
63
48
  publicArchiveApiRoutes.get("/", async (c) => {
64
- const {
65
- format,
66
- collection,
67
- year,
68
- media,
69
- title,
70
- replies,
71
- hasMedia,
72
- hasTitle,
73
- cursor,
74
- limit,
75
- content,
76
- } = parseValidated(ListPublicArchiveQuerySchema, c.req.query());
49
+ const { lang, cursor, limit, content } = parseValidated(
50
+ ListPublicArchiveQuerySchema,
51
+ c.req.query(),
52
+ );
77
53
 
78
- const mediaKinds = Array.isArray(media) ? media : undefined;
79
- const mediaPresence =
80
- media === "any" ? true : media === "none" ? false : undefined;
54
+ const read = (key: string): string | undefined => c.req.query(key);
81
55
 
82
- let collectionIds: string[] | undefined;
83
- if (collection) {
84
- // Accept both "tech,art" and "tech+art", matching the page URL convention.
85
- const slugExpression = collection.replace(/,/g, "+");
86
- const selection =
87
- await c.var.services.collections.resolveSelection(slugExpression);
88
- if (!selection || selection.collections.length === 0) {
89
- return c.json({ posts: [], nextCursor: null });
90
- }
91
- collectionIds = selection.collections.map((col) => col.id);
56
+ // Resolving a collection slug costs a round trip, so it is paid for only when
57
+ // the caller named one.
58
+ const slugs = readCollectionSlugs(read);
59
+ const ctx: DimensionContext = {
60
+ collections:
61
+ slugs.length > 0
62
+ ? buildCollectionVocabulary(await c.var.services.collections.list())
63
+ : EMPTY_COLLECTION_VOCABULARY,
64
+ };
65
+
66
+ const parsed = parsePostFilterSelectionStrict(
67
+ read,
68
+ Object.keys(c.req.query()),
69
+ ctx,
70
+ { allow: ENDPOINT_PARAMS },
71
+ );
72
+
73
+ // A collection slug that names nothing is answered as an empty result rather
74
+ // than a rejection: the caller's words were understood, and this site simply
75
+ // holds no such collection.
76
+ if (
77
+ !parsed.ok &&
78
+ parsed.issues.every((issue) => issue.param === "collection")
79
+ ) {
80
+ return c.json({ posts: [], nextCursor: null });
81
+ }
82
+ if (!parsed.ok) {
83
+ const first = parsed.issues[0];
84
+ throw new ValidationError(first?.message ?? "Validation failed");
92
85
  }
93
86
 
94
- const publishedAfter =
95
- year !== undefined ? Date.UTC(year, 0, 1) / 1000 : undefined;
96
- const publishedBefore =
97
- year !== undefined ? Date.UTC(year + 1, 0, 1) / 1000 : undefined;
87
+ // `private` names a set no anonymous caller may see. Rejected rather than
88
+ // dropped: silently widening the result to the whole archive would answer a
89
+ // different question under the caller's own words.
90
+ const visibility = parsed.selection.visibility;
91
+ if (
92
+ visibility !== undefined &&
93
+ !(PUBLIC_ARCHIVE_VISIBILITIES as readonly string[]).includes(visibility)
94
+ ) {
95
+ throw new ValidationError(INVALID_VISIBILITY_MESSAGE);
96
+ }
98
97
 
99
98
  const posts = await c.var.services.posts.list({
100
- format,
101
- collectionIds,
99
+ lang,
102
100
  status: "published",
103
101
  cursor: cursor ?? undefined,
104
102
  limit,
105
103
  excludePrivate: true,
106
104
  excludeLatestHidden: false,
107
105
  excludeReplies: true,
108
- publishedAfter,
109
- publishedBefore,
110
- mediaKinds,
111
- hasMedia: mediaPresence ?? hasMedia,
112
- hasTitle: title ?? hasTitle,
113
- hasReplies: replies,
106
+ // Ordering is fixed at newest-published-first here, so the year filter
107
+ // stays on the publication axis — the page's `?sort=` switch has no
108
+ // counterpart on a cursor keyed off the post id.
109
+ ...toPostFilters(parsed.selection, { yearAxis: "published" }),
114
110
  ignorePinnedSort: true,
115
111
  });
116
112
 
@@ -5,6 +5,7 @@ import type { AppVariables } from "../../../types/app-context.js";
5
5
  import { getCollectionPagePath } from "../../../lib/collection-paths.js";
6
6
  import {
7
7
  CollectionSortOrderSchema,
8
+ ContentLanguageSchema,
8
9
  FormatSchema,
9
10
  parseValidated,
10
11
  } from "../../../lib/schemas.js";
@@ -31,6 +32,11 @@ publicPostsApiRoutes.use("*", requirePublicApiEnabled());
31
32
 
32
33
  const ListPublicPostsQuerySchema = z.object({
33
34
  format: FormatSchema.optional(),
35
+ /**
36
+ * Restrict to one content language. Machine surfaces name the language
37
+ * explicitly; browsing surfaces express it as a URL prefix instead.
38
+ */
39
+ lang: ContentLanguageSchema.optional(),
34
40
  collection: z.string().optional(),
35
41
  sort: CollectionSortOrderSchema.optional(),
36
42
  cursor: z.string().optional(),
@@ -61,10 +67,25 @@ export type PublicPostBaseResponse = {
61
67
  previewImageUrl: string | null;
62
68
  replyToId: string | null;
63
69
  threadId: string;
70
+ /**
71
+ * BCP 47 content language, or null on a post written before the site
72
+ * enabled multilingual content.
73
+ */
74
+ language: string | null;
75
+ /**
76
+ * Shared by every translation of this Thread. Null when the Thread has no
77
+ * translations.
78
+ */
79
+ translationGroupId: string | null;
80
+ /** Reply published without announcing its Thread. Always false on roots. */
81
+ quietReply: boolean;
64
82
  pinnedAt: number | null;
65
83
  featuredAt: number | null;
66
84
  publishedAt: number | null;
85
+ /** Root only: newest post in the Thread, quiet replies excluded. */
67
86
  lastActivityAt: number;
87
+ /** Root only: newest post in the Thread, quiet replies included. */
88
+ threadUpdatedAt: number;
68
89
  createdAt: number;
69
90
  updatedAt: number;
70
91
  attachments: ReturnType<typeof toApiAttachment>[];
@@ -86,8 +107,7 @@ export type PublicPostMarkdownResponse = PublicPostBaseResponse & {
86
107
  };
87
108
 
88
109
  export type PublicPostResponse =
89
- | PublicPostRenderedResponse
90
- | PublicPostMarkdownResponse;
110
+ PublicPostRenderedResponse | PublicPostMarkdownResponse;
91
111
 
92
112
  function isPublicDetailVisible(post: Post | null): post is Post {
93
113
  return (
@@ -146,10 +166,14 @@ export function toPublicPost(
146
166
  previewImageUrl,
147
167
  replyToId: post.replyToId,
148
168
  threadId: post.threadId,
169
+ language: post.language,
170
+ translationGroupId: post.translationGroupId,
171
+ quietReply: post.quietReply,
149
172
  pinnedAt: post.pinnedAt,
150
173
  featuredAt: post.featuredAt,
151
174
  publishedAt: post.publishedAt,
152
175
  lastActivityAt: post.lastActivityAt,
176
+ threadUpdatedAt: post.threadUpdatedAt,
153
177
  createdAt: post.createdAt,
154
178
  updatedAt: post.updatedAt,
155
179
  attachments: mediaList.map((media) =>
@@ -197,10 +221,8 @@ export function toPublicPost(
197
221
  }
198
222
 
199
223
  publicPostsApiRoutes.get("/", async (c) => {
200
- const { format, collection, sort, cursor, limit, content } = parseValidated(
201
- ListPublicPostsQuerySchema,
202
- c.req.query(),
203
- );
224
+ const { format, lang, collection, sort, cursor, limit, content } =
225
+ parseValidated(ListPublicPostsQuerySchema, c.req.query());
204
226
 
205
227
  // Resolve collection slug(s) — accepts comma-separated (e.g. "tech,art")
206
228
  // or "+" separated (e.g. "tech+art"), matching the page URL convention.
@@ -258,6 +280,7 @@ publicPostsApiRoutes.get("/", async (c) => {
258
280
  excludePrivate: true,
259
281
  excludeLatestHidden: true,
260
282
  rootFormat: format,
283
+ lang,
261
284
  sortOrder,
262
285
  cursor: cursor ?? undefined,
263
286
  limit,
@@ -265,6 +288,7 @@ publicPostsApiRoutes.get("/", async (c) => {
265
288
  )
266
289
  : await c.var.services.posts.list({
267
290
  format,
291
+ lang,
268
292
  status: "published",
269
293
  cursor: cursor ?? undefined,
270
294
  limit,