@jant/core 0.6.16 → 0.7.1

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 (592) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/export.js +3 -1
  4. package/bin/commands/import-site.js +749 -220
  5. package/bin/commands/reset-password.js +21 -8
  6. package/bin/commands/setup.js +136 -0
  7. package/bin/commands/site/export.js +71 -34
  8. package/bin/commands/site/pull-media.js +2 -6
  9. package/bin/commands/site/snapshot/export.js +19 -61
  10. package/bin/commands/site/snapshot/import.js +23 -24
  11. package/bin/lib/d1-query.js +92 -18
  12. package/bin/lib/hugo-markdown.js +4 -0
  13. package/bin/lib/node-env.js +40 -4
  14. package/bin/lib/site-pull-media.js +21 -28
  15. package/bin/lib/site-selection.js +94 -29
  16. package/bin/lib/site-snapshot.js +338 -3
  17. package/bin/lib/sql-export.js +68 -5
  18. package/bin/lib/wrangler-cli.js +37 -2
  19. package/bin/lib/wrangler-json.js +35 -0
  20. package/bin/lib/zip-archive.js +187 -0
  21. package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
  22. package/dist/client/.vite/manifest.json +255 -90
  23. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  24. package/dist/client/_assets/chunks/create-editor-B-m7X7S5.js +261 -0
  25. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  26. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  27. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  28. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  29. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  30. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  31. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  32. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  33. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  34. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  35. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  36. package/dist/client/_assets/chunks/sortable-list-BJyd-LXE.js +2 -0
  37. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  38. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  39. package/dist/client/_assets/chunks/unsafe-svg-BkscJx69.js +2 -0
  40. package/dist/client/_assets/client-DAhoqPdr.css +2 -0
  41. package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
  42. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  43. package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
  44. package/dist/client/_assets/client-knSEyUJO.js +295 -0
  45. package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
  46. package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
  47. package/dist/github-sync-Gw4orAHk.js +8398 -0
  48. package/dist/index.js +2 -5
  49. package/dist/node.js +11 -9
  50. package/package.json +15 -5
  51. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  52. package/src/__tests__/basecoat-variants.test.ts +121 -0
  53. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  54. package/src/__tests__/dev-scripts.test.ts +203 -0
  55. package/src/__tests__/export-collection-order.test.ts +276 -0
  56. package/src/__tests__/export-feed-ids.test.ts +184 -0
  57. package/src/__tests__/export-feed-order.test.ts +294 -0
  58. package/src/__tests__/export-hugo-build.test.ts +390 -0
  59. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  60. package/src/__tests__/export-service.test.ts +842 -28
  61. package/src/__tests__/export-smart-collection.test.ts +329 -0
  62. package/src/__tests__/helpers/component-css.ts +91 -0
  63. package/src/__tests__/helpers/db.ts +33 -0
  64. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  65. package/src/__tests__/helpers/hugo-site.ts +146 -0
  66. package/src/__tests__/import-site-command.test.ts +487 -1
  67. package/src/__tests__/mise-config.test.ts +75 -4
  68. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  69. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  70. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  71. package/src/__tests__/snapshot-settings.test.ts +97 -0
  72. package/src/__tests__/snapshot-tables.test.ts +219 -0
  73. package/src/__tests__/sql-export.test.ts +173 -0
  74. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  75. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  76. package/src/__tests__/zip-archive.test.ts +106 -0
  77. package/src/app.tsx +86 -22
  78. package/src/auth.ts +18 -1
  79. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  80. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  81. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  82. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  83. package/src/client/__tests__/compose-launch.test.ts +62 -1
  84. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  85. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  86. package/src/client/__tests__/copy-field.test.ts +162 -0
  87. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  88. package/src/client/__tests__/image-processor.test.ts +324 -2
  89. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  90. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  91. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  92. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  93. package/src/client/__tests__/toast.test.ts +23 -0
  94. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  95. package/src/client/__tests__/video-processor.test.ts +181 -0
  96. package/src/client/audio-processor.ts +24 -15
  97. package/src/client/audio-waveform.ts +49 -0
  98. package/src/client/collection-dialog-host.ts +72 -0
  99. package/src/client/collection-navigation.ts +51 -16
  100. package/src/client/collection-page-actions.ts +26 -0
  101. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  102. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  103. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  104. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  105. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  106. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  107. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  108. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  109. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  110. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  111. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  112. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  113. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  114. package/src/client/components/__tests__/jant-settings-general.test.ts +561 -172
  115. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  116. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  117. package/src/client/components/collection-dialog-types.ts +49 -0
  118. package/src/client/components/collection-manager-types.ts +32 -1
  119. package/src/client/components/collection-types.ts +1 -15
  120. package/src/client/components/compose-types.ts +75 -1
  121. package/src/client/components/jant-collection-dialog.ts +722 -0
  122. package/src/client/components/jant-collection-directory.ts +567 -182
  123. package/src/client/components/jant-collection-form.ts +53 -232
  124. package/src/client/components/jant-command-palette.ts +100 -49
  125. package/src/client/components/jant-compose-dialog.ts +1486 -707
  126. package/src/client/components/jant-compose-editor.ts +673 -336
  127. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  128. package/src/client/components/jant-config-editor.ts +65 -53
  129. package/src/client/components/jant-confirm-dialog.ts +13 -11
  130. package/src/client/components/jant-locale-picker.ts +418 -0
  131. package/src/client/components/jant-media-lightbox.ts +146 -136
  132. package/src/client/components/jant-nav-manager.ts +690 -366
  133. package/src/client/components/jant-post-menu.ts +1086 -282
  134. package/src/client/components/jant-post-picker.ts +324 -0
  135. package/src/client/components/jant-repo-picker-types.ts +6 -1
  136. package/src/client/components/jant-repo-picker.ts +137 -108
  137. package/src/client/components/jant-settings-avatar.ts +31 -27
  138. package/src/client/components/jant-settings-general.ts +622 -543
  139. package/src/client/components/jant-settings-language.ts +1087 -0
  140. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  141. package/src/client/components/jant-text-preview.ts +94 -86
  142. package/src/client/components/nav-manager-types.ts +62 -1
  143. package/src/client/components/settings-types.ts +14 -29
  144. package/src/client/components/smart-collection-conditions.ts +285 -0
  145. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  146. package/src/client/compose-bridge.ts +214 -141
  147. package/src/client/compose-launch.ts +76 -2
  148. package/src/client/compose-shortcuts.ts +6 -8
  149. package/src/client/compose-triggers.ts +50 -0
  150. package/src/client/copy-field.ts +108 -0
  151. package/src/client/custom-url-menu.ts +60 -4
  152. package/src/client/icons.ts +14 -0
  153. package/src/client/image-plan.ts +165 -0
  154. package/src/client/image-processor.ts +195 -117
  155. package/src/client/image-worker-client.ts +45 -0
  156. package/src/client/image-worker.ts +135 -0
  157. package/src/client/lazy-entries.ts +95 -0
  158. package/src/client/lazy-slugify.ts +2 -2
  159. package/src/client/media-scroll-hint.ts +43 -2
  160. package/src/client/mediabunny.ts +29 -0
  161. package/src/client/post-picker.ts +41 -0
  162. package/src/client/runtime-paths.ts +74 -1
  163. package/src/client/settings-bridge.ts +4 -10
  164. package/src/client/site-header-nav.js +18 -6
  165. package/src/client/smart-collection-dialog-host.ts +116 -0
  166. package/src/client/smart-collection-page-actions.ts +208 -0
  167. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  168. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  169. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  170. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  171. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  172. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  173. package/src/client/tiptap/bubble-menu.ts +47 -16
  174. package/src/client/tiptap/extensions.ts +0 -3
  175. package/src/client/tiptap/floating-position.ts +216 -2
  176. package/src/client/tiptap/inline-image-upload.ts +7 -3
  177. package/src/client/tiptap/link-toolbar.ts +22 -9
  178. package/src/client/tiptap/structural-keymap.ts +158 -47
  179. package/src/client/toast.ts +13 -0
  180. package/src/client/types/vite-worker.d.ts +12 -0
  181. package/src/client/upload-with-metadata.ts +3 -13
  182. package/src/client/video-processor.ts +351 -107
  183. package/src/client-auth.ts +14 -31
  184. package/src/client-compose.ts +19 -0
  185. package/src/client-manage.ts +13 -0
  186. package/src/client-settings.ts +14 -0
  187. package/src/client-site.ts +10 -3
  188. package/src/client.ts +4 -0
  189. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  190. package/src/db/__tests__/d1-query.test.ts +56 -1
  191. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  192. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  193. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  194. package/src/db/migrations/0032_married_tomas.sql +3 -0
  195. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  196. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  197. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  198. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  199. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  200. package/src/db/migrations/meta/_journal.json +21 -0
  201. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  202. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  203. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  204. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  205. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  206. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  207. package/src/db/migrations/pg/meta/_journal.json +21 -0
  208. package/src/db/pg/schema.ts +195 -27
  209. package/src/db/post-visibility.ts +117 -0
  210. package/src/db/schema.ts +222 -27
  211. package/src/i18n/__tests__/detect.test.ts +26 -81
  212. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  213. package/src/i18n/context.tsx +12 -2
  214. package/src/i18n/detect.ts +7 -66
  215. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  216. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  217. package/src/i18n/locales/public/en.po +490 -158
  218. package/src/i18n/locales/public/en.ts +1 -1
  219. package/src/i18n/locales/public/zh-Hans.po +487 -155
  220. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  221. package/src/i18n/locales/public/zh-Hant.po +487 -155
  222. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  223. package/src/i18n/locales/settings/en.po +555 -73
  224. package/src/i18n/locales/settings/en.ts +1 -1
  225. package/src/i18n/locales/settings/zh-Hans.po +601 -119
  226. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  227. package/src/i18n/locales/settings/zh-Hant.po +614 -132
  228. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  229. package/src/i18n/locales.ts +94 -0
  230. package/src/i18n/middleware.ts +12 -2
  231. package/src/i18n/supported-locales.ts +80 -0
  232. package/src/lib/__tests__/api-settings.test.ts +11 -2
  233. package/src/lib/__tests__/asset-path.test.ts +92 -0
  234. package/src/lib/__tests__/deferred.test.ts +90 -0
  235. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  236. package/src/lib/__tests__/discover.test.ts +410 -0
  237. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  238. package/src/lib/__tests__/feed.test.ts +1526 -15
  239. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  240. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  241. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  242. package/src/lib/__tests__/http-cache.test.ts +162 -0
  243. package/src/lib/__tests__/image.test.ts +27 -1
  244. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  245. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  246. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  247. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  248. package/src/lib/__tests__/markdown.test.ts +10 -0
  249. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  250. package/src/lib/__tests__/navigation.test.ts +135 -1
  251. package/src/lib/__tests__/resolve-config.test.ts +131 -3
  252. package/src/lib/__tests__/schemas.test.ts +69 -5
  253. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  254. package/src/lib/__tests__/slug.test.ts +70 -0
  255. package/src/lib/__tests__/slugify.test.ts +85 -0
  256. package/src/lib/__tests__/storage.test.ts +32 -0
  257. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  258. package/src/lib/__tests__/timeline.test.ts +87 -0
  259. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  260. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  261. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  262. package/src/lib/__tests__/url.test.ts +78 -72
  263. package/src/lib/__tests__/view-language.test.ts +413 -0
  264. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  265. package/src/lib/address.ts +57 -0
  266. package/src/lib/api-posts.ts +12 -0
  267. package/src/lib/asset-path.ts +98 -1
  268. package/src/lib/build-env.ts +16 -0
  269. package/src/lib/collection-groups.ts +5 -1
  270. package/src/lib/collection-paths.ts +3 -10
  271. package/src/lib/constants.ts +28 -3
  272. package/src/lib/copy-field.ts +36 -0
  273. package/src/lib/crypto.ts +40 -0
  274. package/src/lib/csp-builder.ts +2 -1
  275. package/src/lib/deferred.ts +79 -0
  276. package/src/lib/discover-ping.ts +141 -0
  277. package/src/lib/discover.ts +439 -0
  278. package/src/lib/env.ts +134 -0
  279. package/src/lib/errors.ts +33 -0
  280. package/src/lib/feed-policy.ts +236 -2
  281. package/src/lib/feed.ts +926 -73
  282. package/src/lib/filter-dimensions.ts +1349 -0
  283. package/src/lib/github-sync-repo-name.ts +45 -0
  284. package/src/lib/github-sync-site-config.ts +3 -0
  285. package/src/lib/hosted-signin.ts +1 -1
  286. package/src/lib/http-cache.ts +148 -0
  287. package/src/lib/hugo-markdown.ts +62 -0
  288. package/src/lib/icons.ts +9 -2
  289. package/src/lib/ids.ts +3 -0
  290. package/src/lib/image.ts +17 -4
  291. package/src/lib/jant-docs.ts +42 -0
  292. package/src/lib/lang-detect.ts +308 -0
  293. package/src/lib/markdown-manager.ts +392 -2
  294. package/src/lib/media-helpers.ts +13 -1
  295. package/src/lib/navigation.ts +78 -11
  296. package/src/lib/pagination.ts +28 -24
  297. package/src/lib/per-language-surfaces.ts +67 -0
  298. package/src/lib/post-body-html.ts +11 -4
  299. package/src/lib/post-meta.ts +19 -0
  300. package/src/lib/render.tsx +55 -7
  301. package/src/lib/resolve-config.ts +111 -6
  302. package/src/lib/schemas.ts +251 -15
  303. package/src/lib/site-header-fragment.tsx +2 -1
  304. package/src/lib/site-unavailable-page.ts +47 -0
  305. package/src/lib/slug.ts +51 -7
  306. package/src/lib/slugify.ts +113 -0
  307. package/src/lib/startup-config.ts +4 -6
  308. package/src/lib/storage.ts +7 -0
  309. package/src/lib/summary.ts +50 -0
  310. package/src/lib/thread-fold.ts +161 -0
  311. package/src/lib/time.ts +32 -0
  312. package/src/lib/timeline.ts +43 -2
  313. package/src/lib/tiptap-render.ts +1 -2
  314. package/src/lib/tiptap-to-markdown.ts +13 -7
  315. package/src/lib/upload.ts +16 -13
  316. package/src/lib/url.ts +159 -86
  317. package/src/lib/version.ts +10 -8
  318. package/src/lib/view-language.ts +436 -0
  319. package/src/lib/view.ts +53 -25
  320. package/src/lib/viewer-context.ts +62 -0
  321. package/src/middleware/__tests__/auth.test.ts +70 -2
  322. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  323. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  324. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  325. package/src/middleware/__tests__/session.test.ts +181 -33
  326. package/src/middleware/auth.ts +50 -31
  327. package/src/middleware/cache-control.ts +75 -10
  328. package/src/middleware/config.ts +6 -6
  329. package/src/middleware/error-handler.ts +12 -0
  330. package/src/middleware/onboarding.ts +32 -10
  331. package/src/middleware/secure-headers.ts +1 -1
  332. package/src/middleware/session.ts +96 -8
  333. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  334. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  335. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  336. package/src/node/__tests__/cli-setup.test.ts +163 -0
  337. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  338. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  339. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  340. package/src/node/__tests__/runtime.test.ts +75 -0
  341. package/src/node/index.ts +3 -0
  342. package/src/node/request-handler.ts +3 -1
  343. package/src/preset.css +76 -22
  344. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  345. package/src/routes/api/__tests__/collections.test.ts +114 -0
  346. package/src/routes/api/__tests__/discover.test.ts +222 -0
  347. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  348. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  349. package/src/routes/api/__tests__/posts.test.ts +24 -0
  350. package/src/routes/api/__tests__/settings.test.ts +24 -3
  351. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  352. package/src/routes/api/__tests__/upload.test.ts +34 -0
  353. package/src/routes/api/collections.ts +49 -6
  354. package/src/routes/api/custom-urls.ts +0 -1
  355. package/src/routes/api/discover.ts +95 -0
  356. package/src/routes/api/export.ts +6 -3
  357. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  358. package/src/routes/api/internal/sites.ts +0 -1
  359. package/src/routes/api/nav-items.ts +55 -8
  360. package/src/routes/api/posts.ts +161 -0
  361. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  362. package/src/routes/api/public/archive.ts +77 -81
  363. package/src/routes/api/public/posts.ts +44 -7
  364. package/src/routes/api/smart-collections.ts +132 -0
  365. package/src/routes/api/telegram.ts +2 -40
  366. package/src/routes/api/upload.ts +10 -3
  367. package/src/routes/api/uploads.ts +1 -2
  368. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  369. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  370. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  371. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  372. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  373. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  374. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  375. package/src/routes/auth/setup.tsx +934 -186
  376. package/src/routes/auth/signin.tsx +10 -8
  377. package/src/routes/compose.tsx +9 -0
  378. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  379. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  380. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  381. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  382. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  383. package/src/routes/dash/custom-urls.tsx +1 -37
  384. package/src/routes/dash/settings.tsx +714 -111
  385. package/src/routes/discover-announce.ts +96 -0
  386. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  387. package/src/routes/feed/feed.ts +69 -40
  388. package/src/routes/feed/sitemap.ts +95 -11
  389. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  390. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  391. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  392. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  393. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  394. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  395. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  396. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  397. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  398. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  399. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  400. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  401. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  402. package/src/routes/pages/archive.tsx +487 -379
  403. package/src/routes/pages/collection.tsx +94 -96
  404. package/src/routes/pages/collections.tsx +38 -50
  405. package/src/routes/pages/featured.tsx +43 -18
  406. package/src/routes/pages/home.tsx +25 -6
  407. package/src/routes/pages/language.tsx +113 -0
  408. package/src/routes/pages/latest.tsx +26 -16
  409. package/src/routes/pages/new.tsx +3 -0
  410. package/src/routes/pages/page.tsx +228 -45
  411. package/src/routes/pages/partials.tsx +41 -0
  412. package/src/routes/pages/search.tsx +22 -5
  413. package/src/routes/pages/smart-collection.tsx +340 -0
  414. package/src/routes/pages/subscribe.tsx +182 -0
  415. package/src/runtime/__tests__/index.test.ts +19 -0
  416. package/src/runtime/__tests__/node.test.ts +129 -4
  417. package/src/runtime/__tests__/readiness.test.ts +23 -0
  418. package/src/runtime/cloudflare.ts +21 -2
  419. package/src/runtime/index.ts +27 -1
  420. package/src/runtime/node.ts +67 -1
  421. package/src/runtime/readiness.ts +15 -0
  422. package/src/runtime/site.ts +95 -24
  423. package/src/services/__tests__/auth.test.ts +116 -7
  424. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  425. package/src/services/__tests__/collection.test.ts +108 -4
  426. package/src/services/__tests__/custom-url.test.ts +34 -0
  427. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  428. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  429. package/src/services/__tests__/language.test.ts +495 -0
  430. package/src/services/__tests__/media.test.ts +31 -0
  431. package/src/services/__tests__/navigation.test.ts +213 -10
  432. package/src/services/__tests__/path.test.ts +236 -0
  433. package/src/services/__tests__/post-language.test.ts +830 -0
  434. package/src/services/__tests__/post-timeline.test.ts +127 -0
  435. package/src/services/__tests__/post.test.ts +120 -9
  436. package/src/services/__tests__/settings.test.ts +228 -17
  437. package/src/services/__tests__/site-admin.test.ts +5 -5
  438. package/src/services/__tests__/smart-collection.test.ts +651 -0
  439. package/src/services/about-page.ts +22 -1
  440. package/src/services/auth.ts +51 -49
  441. package/src/services/bootstrap.ts +408 -39
  442. package/src/services/collection-directory-position.ts +76 -0
  443. package/src/services/collection.ts +331 -46
  444. package/src/services/custom-url.ts +29 -24
  445. package/src/services/export-theme/assets/client-site.css +1 -1
  446. package/src/services/export-theme/assets/client-site.js +123 -123
  447. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  448. package/src/services/export-theme/layouts/_default/list.html +2 -63
  449. package/src/services/export-theme/layouts/_default/rss.xml +103 -38
  450. package/src/services/export-theme/layouts/collections/list.html +3 -3
  451. package/src/services/export-theme/layouts/featured/list.html +1 -4
  452. package/src/services/export-theme/layouts/index.html +40 -26
  453. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  454. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  455. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  456. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  457. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  458. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  459. package/src/services/export-theme/layouts/partials/head.html +1 -1
  460. package/src/services/export-theme/layouts/partials/header.html +5 -3
  461. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  462. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  463. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  464. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  465. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  466. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  467. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  468. package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
  469. package/src/services/export-theme/layouts/post/list.html +1 -1
  470. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  471. package/src/services/export-theme/styles/main.css +13 -2
  472. package/src/services/export-theme/theme.toml +1 -1
  473. package/src/services/export.ts +880 -72
  474. package/src/services/github-app-installations.ts +171 -4
  475. package/src/services/github-sync.ts +69 -15
  476. package/src/services/index.ts +34 -3
  477. package/src/services/language.ts +412 -0
  478. package/src/services/mcp.ts +22 -2
  479. package/src/services/media.ts +22 -7
  480. package/src/services/navigation.ts +429 -67
  481. package/src/services/path.ts +254 -6
  482. package/src/services/post.ts +1234 -190
  483. package/src/services/search.ts +39 -2
  484. package/src/services/settings.ts +238 -43
  485. package/src/services/site-admin.ts +27 -14
  486. package/src/services/site.ts +160 -32
  487. package/src/services/smart-collection.ts +636 -0
  488. package/src/services/telegram.ts +20 -22
  489. package/src/services/upload-session.ts +1 -3
  490. package/src/style-author.css +27 -0
  491. package/src/styles/components-author.css +1622 -0
  492. package/src/styles/components.css +24 -1571
  493. package/src/styles/site-media.css +16 -2
  494. package/src/styles/tokens.css +82 -12
  495. package/src/styles/ui-author.css +7602 -0
  496. package/src/styles/ui.css +1090 -7216
  497. package/src/types/app-context.ts +23 -0
  498. package/src/types/bindings.ts +8 -0
  499. package/src/types/config.ts +94 -15
  500. package/src/types/constants.ts +188 -16
  501. package/src/types/entities.ts +87 -1
  502. package/src/types/operations.ts +74 -2
  503. package/src/types/props.ts +157 -31
  504. package/src/types/views.ts +55 -1
  505. package/src/ui/__tests__/font-themes.test.ts +52 -12
  506. package/src/ui/compose/ComposeDialog.tsx +201 -7
  507. package/src/ui/compose/ComposePrompt.tsx +1 -5
  508. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  509. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  510. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  511. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  512. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  513. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  514. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  515. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  516. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  517. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  518. package/src/ui/dash/settings/GeneralContent.tsx +277 -113
  519. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  520. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  521. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  522. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  523. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  524. package/src/ui/dash/settings/discover-copy.ts +116 -0
  525. package/src/ui/feed/LinkCard.tsx +55 -45
  526. package/src/ui/feed/NoteCard.tsx +9 -4
  527. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  528. package/src/ui/feed/QuoteCard.tsx +55 -30
  529. package/src/ui/feed/ThreadPreview.tsx +6 -2
  530. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  531. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  532. package/src/ui/feed/thread-preview-state.ts +20 -6
  533. package/src/ui/font-themes.ts +39 -29
  534. package/src/ui/layouts/BaseLayout.tsx +155 -10
  535. package/src/ui/layouts/SiteLayout.tsx +192 -34
  536. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  537. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  538. package/src/ui/pages/ArchivePage.tsx +282 -289
  539. package/src/ui/pages/CollectionPage.tsx +86 -76
  540. package/src/ui/pages/CollectionsPage.tsx +5 -0
  541. package/src/ui/pages/ComposePage.tsx +8 -0
  542. package/src/ui/pages/PostPage.tsx +47 -0
  543. package/src/ui/pages/SearchPage.tsx +3 -3
  544. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  545. package/src/ui/pages/SubscribePage.tsx +236 -0
  546. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  547. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  548. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  549. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  550. package/src/ui/shared/CollectionsManager.tsx +70 -17
  551. package/src/ui/shared/CopyField.tsx +127 -0
  552. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  553. package/src/ui/shared/MediaGallery.tsx +125 -46
  554. package/src/ui/shared/Pagination.tsx +20 -5
  555. package/src/ui/shared/PostFooter.tsx +20 -11
  556. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  557. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  558. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  559. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  560. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  561. package/src/ui/shared/archive-labels.ts +98 -0
  562. package/src/ui/shared/collection-management-labels.ts +94 -16
  563. package/src/ui/shared/navigation-labels.ts +45 -71
  564. package/src/ui/shared/post-article-attributes.ts +2 -0
  565. package/src/ui/shared/smart-collection-labels.ts +343 -0
  566. package/dist/app-C75Zc8dD.js +0 -6
  567. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  568. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  569. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  570. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  571. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  572. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  573. package/dist/env-BPYViDJJ.js +0 -281
  574. package/dist/export-Dl5KCiEs.js +0 -5562
  575. package/dist/github-api-BNF35Lkx.js +0 -176
  576. package/dist/github-app-DdiD-bC4.js +0 -275
  577. package/dist/github-sync-Bqg62IvV.js +0 -4
  578. package/dist/github-sync-HdK2EgvJ.js +0 -431
  579. package/dist/url-CbLAtlZe.js +0 -900
  580. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  581. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  582. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  583. package/src/client/collection-created-notice.ts +0 -66
  584. package/src/client/collection-form-bridge.ts +0 -191
  585. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  586. package/src/client/components/jant-post-form.ts +0 -422
  587. package/src/client/components/post-form-template.ts +0 -352
  588. package/src/client/components/post-form-types.ts +0 -96
  589. package/src/client/media-metadata.ts +0 -247
  590. package/src/client/post-form-bridge.ts +0 -193
  591. package/src/client/tiptap/exitable-marks.ts +0 -73
  592. 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
 
@@ -196,7 +200,11 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
196
200
  collectionEntries: body.collectionEntries,
197
201
  replyToId: body.replyToId,
198
202
  quietReply: body.quietReply,
203
+ language: body.language,
204
+ translationOfId: body.translationOfId,
199
205
  publishedAt: body.publishedAt,
206
+ createdAt: body.createdAt,
207
+ updatedAt: body.updatedAt,
200
208
  },
201
209
  body.attachments,
202
210
  {
@@ -269,6 +277,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
269
277
  collectionIds: body.collectionIds,
270
278
  collectionEntries: body.collectionEntries,
271
279
  publishedAt: body.publishedAt,
280
+ language: body.language,
272
281
  },
273
282
  body.attachments,
274
283
  {
@@ -313,6 +322,158 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
313
322
  );
314
323
  });
315
324
 
325
+ // =============================================================================
326
+ // Language and translations
327
+ // =============================================================================
328
+
329
+ const SetLanguageSchema = z.object({ language: ContentLanguageSchema });
330
+ const TranslationCandidatesQuerySchema = z.object({
331
+ q: z.string().trim().min(1).max(200),
332
+ limit: z.coerce.number().int().min(1).max(20).optional().default(8),
333
+ });
334
+ const LinkTranslationSchema = z.object({ postId: PostIdSchema });
335
+
336
+ /**
337
+ * Set the content language of a whole Thread.
338
+ *
339
+ * Thread-wide because `post.language` is uniform inside a Thread — see the
340
+ * service method for why every language filter depends on that.
341
+ */
342
+ postsApiRoutes.put("/:id/language", requireAuthApi(), async (c) => {
343
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
344
+ const { language } = parseValidated(SetLanguageSchema, await c.req.json());
345
+
346
+ await c.var.services.posts.setThreadLanguage(id, language);
347
+ await triggerGitHubSyncInline(c);
348
+
349
+ return c.json({ success: true, language });
350
+ });
351
+
352
+ /** List the Thread roots this post is a translation of. */
353
+ postsApiRoutes.get("/:id/translations", requireAuthApi(), async (c) => {
354
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
355
+ const post = assertFound(await c.var.services.posts.getById(id), "Post");
356
+ const translations = await c.var.services.posts.listTranslations(
357
+ post.threadId,
358
+ );
359
+
360
+ return c.json({
361
+ translations: translations.map((translation) => ({
362
+ id: translation.id,
363
+ slug: translation.slug,
364
+ title: translation.title,
365
+ // What to show in a list: notes are usually untitled, and a slug is not
366
+ // a name.
367
+ label: getPostDisplayTitle(translation) || translation.slug,
368
+ language: translation.language,
369
+ })),
370
+ });
371
+ });
372
+
373
+ /**
374
+ * Posts this one could be linked to as a translation.
375
+ *
376
+ * The eligibility rules live in the service; the menu only renders what comes
377
+ * back, so it never offers a post the link would refuse.
378
+ */
379
+ postsApiRoutes.get(
380
+ "/:id/translations/candidates",
381
+ requireAuthApi(),
382
+ async (c) => {
383
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
384
+ const { q, limit } = parseValidated(
385
+ TranslationCandidatesQuerySchema,
386
+ c.req.query(),
387
+ );
388
+
389
+ const candidates = await c.var.services.posts.listTranslationCandidates(
390
+ id,
391
+ {
392
+ query: q,
393
+ limit,
394
+ },
395
+ );
396
+
397
+ return c.json({
398
+ candidates: candidates.map((post) => ({
399
+ id: post.id,
400
+ slug: post.slug,
401
+ title: post.title,
402
+ label: getPostDisplayTitle(post) || post.slug,
403
+ language: post.language,
404
+ })),
405
+ });
406
+ },
407
+ );
408
+
409
+ /**
410
+ * The Thread a pasted address names, and whether it can be linked to this one.
411
+ *
412
+ * Separate from the candidate search so a URL never quietly becomes search
413
+ * words: this endpoint only ever answers about one address, and answers with a
414
+ * reason when the Thread it names is not eligible.
415
+ */
416
+ postsApiRoutes.get("/:id/translations/resolve", requireAuthApi(), async (c) => {
417
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
418
+ const { url } = parseValidated(AddressQuerySchema, c.req.query());
419
+
420
+ const path = requestInternalPath(c, url);
421
+ if (path === null) {
422
+ return c.json({ resolution: { kind: "external", address: url.trim() } });
423
+ }
424
+
425
+ const resolution = await c.var.services.posts.resolveTranslationCandidate(
426
+ id,
427
+ path,
428
+ );
429
+ const address = toPublicPath(path, c.var.appConfig.sitePathPrefix);
430
+
431
+ if (resolution.status === "ok") {
432
+ const post = resolution.post;
433
+ return c.json({
434
+ resolution: {
435
+ kind: "ok",
436
+ address,
437
+ candidate: {
438
+ id: post.id,
439
+ slug: post.slug,
440
+ label: getPostDisplayTitle(post) || post.slug,
441
+ language: post.language,
442
+ },
443
+ },
444
+ });
445
+ }
446
+
447
+ return c.json({
448
+ resolution: {
449
+ kind: resolution.status,
450
+ address,
451
+ ...("language" in resolution ? { language: resolution.language } : {}),
452
+ },
453
+ });
454
+ });
455
+
456
+ /** Link two already-published Threads as translations of each other. */
457
+ postsApiRoutes.post("/:id/translations", requireAuthApi(), async (c) => {
458
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
459
+ const { postId } = parseValidated(LinkTranslationSchema, await c.req.json());
460
+
461
+ await c.var.services.posts.linkTranslation(id, postId);
462
+ await triggerGitHubSyncInline(c);
463
+
464
+ return c.json({ success: true });
465
+ });
466
+
467
+ /** Take this Thread out of its translation group. */
468
+ postsApiRoutes.delete("/:id/translations", requireAuthApi(), async (c) => {
469
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
470
+
471
+ await c.var.services.posts.unlinkTranslation(id);
472
+ await triggerGitHubSyncInline(c);
473
+
474
+ return c.json({ success: true });
475
+ });
476
+
316
477
  // Delete post (requires auth)
317
478
  postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
318
479
  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);
@@ -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,6 +67,16 @@ 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;
64
80
  /** Reply published without announcing its Thread. Always false on roots. */
65
81
  quietReply: boolean;
66
82
  pinnedAt: number | null;
@@ -91,8 +107,25 @@ export type PublicPostMarkdownResponse = PublicPostBaseResponse & {
91
107
  };
92
108
 
93
109
  export type PublicPostResponse =
94
- | PublicPostRenderedResponse
95
- | PublicPostMarkdownResponse;
110
+ PublicPostRenderedResponse | PublicPostMarkdownResponse;
111
+
112
+ /**
113
+ * The public Markdown for a post, or null for a historical body that isn't
114
+ * TipTap JSON. One unreadable row answers null and is logged, rather than
115
+ * failing the whole listing; `bodyHtml` still falls back to stored HTML.
116
+ */
117
+ function toPublicBodyMarkdown(postId: string, body: string): string | null {
118
+ try {
119
+ return tiptapJsonToMarkdown(body);
120
+ } catch (error) {
121
+ // eslint-disable-next-line no-console -- A skipped body must leave a trace
122
+ console.error(
123
+ `Couldn't convert the body of post ${postId} to Markdown`,
124
+ error,
125
+ );
126
+ return null;
127
+ }
128
+ }
96
129
 
97
130
  function isPublicDetailVisible(post: Post | null): post is Post {
98
131
  return (
@@ -151,6 +184,8 @@ export function toPublicPost(
151
184
  previewImageUrl,
152
185
  replyToId: post.replyToId,
153
186
  threadId: post.threadId,
187
+ language: post.language,
188
+ translationGroupId: post.translationGroupId,
154
189
  quietReply: post.quietReply,
155
190
  pinnedAt: post.pinnedAt,
156
191
  featuredAt: post.featuredAt,
@@ -179,7 +214,9 @@ export function toPublicPost(
179
214
  const contentFields =
180
215
  options?.content === "markdown"
181
216
  ? {
182
- bodyMarkdown: post.body ? tiptapJsonToMarkdown(post.body) : null,
217
+ bodyMarkdown: post.body
218
+ ? toPublicBodyMarkdown(post.id, post.body)
219
+ : null,
183
220
  }
184
221
  : {
185
222
  bodyHtml: post.bodyHtml,
@@ -204,10 +241,8 @@ export function toPublicPost(
204
241
  }
205
242
 
206
243
  publicPostsApiRoutes.get("/", async (c) => {
207
- const { format, collection, sort, cursor, limit, content } = parseValidated(
208
- ListPublicPostsQuerySchema,
209
- c.req.query(),
210
- );
244
+ const { format, lang, collection, sort, cursor, limit, content } =
245
+ parseValidated(ListPublicPostsQuerySchema, c.req.query());
211
246
 
212
247
  // Resolve collection slug(s) — accepts comma-separated (e.g. "tech,art")
213
248
  // or "+" separated (e.g. "tech+art"), matching the page URL convention.
@@ -265,6 +300,7 @@ publicPostsApiRoutes.get("/", async (c) => {
265
300
  excludePrivate: true,
266
301
  excludeLatestHidden: true,
267
302
  rootFormat: format,
303
+ lang,
268
304
  sortOrder,
269
305
  cursor: cursor ?? undefined,
270
306
  limit,
@@ -272,6 +308,7 @@ publicPostsApiRoutes.get("/", async (c) => {
272
308
  )
273
309
  : await c.var.services.posts.list({
274
310
  format,
311
+ lang,
275
312
  status: "published",
276
313
  cursor: cursor ?? undefined,
277
314
  limit,