@jant/core 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -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,7 @@ export type PublicPostMarkdownResponse = PublicPostBaseResponse & {
91
107
  };
92
108
 
93
109
  export type PublicPostResponse =
94
- | PublicPostRenderedResponse
95
- | PublicPostMarkdownResponse;
110
+ PublicPostRenderedResponse | PublicPostMarkdownResponse;
96
111
 
97
112
  function isPublicDetailVisible(post: Post | null): post is Post {
98
113
  return (
@@ -151,6 +166,8 @@ export function toPublicPost(
151
166
  previewImageUrl,
152
167
  replyToId: post.replyToId,
153
168
  threadId: post.threadId,
169
+ language: post.language,
170
+ translationGroupId: post.translationGroupId,
154
171
  quietReply: post.quietReply,
155
172
  pinnedAt: post.pinnedAt,
156
173
  featuredAt: post.featuredAt,
@@ -204,10 +221,8 @@ export function toPublicPost(
204
221
  }
205
222
 
206
223
  publicPostsApiRoutes.get("/", async (c) => {
207
- const { format, collection, sort, cursor, limit, content } = parseValidated(
208
- ListPublicPostsQuerySchema,
209
- c.req.query(),
210
- );
224
+ const { format, lang, collection, sort, cursor, limit, content } =
225
+ parseValidated(ListPublicPostsQuerySchema, c.req.query());
211
226
 
212
227
  // Resolve collection slug(s) — accepts comma-separated (e.g. "tech,art")
213
228
  // or "+" separated (e.g. "tech+art"), matching the page URL convention.
@@ -265,6 +280,7 @@ publicPostsApiRoutes.get("/", async (c) => {
265
280
  excludePrivate: true,
266
281
  excludeLatestHidden: true,
267
282
  rootFormat: format,
283
+ lang,
268
284
  sortOrder,
269
285
  cursor: cursor ?? undefined,
270
286
  limit,
@@ -272,6 +288,7 @@ publicPostsApiRoutes.get("/", async (c) => {
272
288
  )
273
289
  : await c.var.services.posts.list({
274
290
  format,
291
+ lang,
275
292
  status: "published",
276
293
  cursor: cursor ?? undefined,
277
294
  limit,
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Smart Collections API Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { z } from "zod";
7
+ import type { Bindings } from "../../types.js";
8
+ import type { AppVariables } from "../../types/app-context.js";
9
+ import { requireAuthApi } from "../../middleware/auth.js";
10
+ import {
11
+ ContentLanguageSchema,
12
+ CreateSmartCollectionSchema,
13
+ SmartCollectionIdSchema,
14
+ SmartCollectionPreviewSchema,
15
+ UpdateSmartCollectionSchema,
16
+ parseValidated,
17
+ } from "../../lib/schemas.js";
18
+ import { assertFound, parseIdParam } from "../../lib/errors.js";
19
+ import { ID_PREFIX } from "../../lib/ids.js";
20
+
21
+ type Env = { Bindings: Bindings; Variables: AppVariables };
22
+
23
+ export const smartCollectionsApiRoutes = new Hono<Env>();
24
+
25
+ /**
26
+ * Address availability, in the shape posts already use.
27
+ *
28
+ * `suggest` derives one from a title; `check` answers whether a typed one is
29
+ * free. Collections answer the same question at `/api/collections/slug`.
30
+ */
31
+ const SlugQuerySchema = z.discriminatedUnion("mode", [
32
+ z.object({
33
+ mode: z.literal("suggest"),
34
+ title: z.string().trim().max(300).optional(),
35
+ smartCollectionId: SmartCollectionIdSchema.optional(),
36
+ }),
37
+ z.object({
38
+ mode: z.literal("check"),
39
+ slug: z.string().trim().toLowerCase().min(1).max(200),
40
+ smartCollectionId: SmartCollectionIdSchema.optional(),
41
+ }),
42
+ ]);
43
+
44
+ const PreviewQuerySchema = z.object({
45
+ lang: ContentLanguageSchema.optional(),
46
+ });
47
+
48
+ // --- Address ---------------------------------------------------------------
49
+ // Declared before `/:id` so the literal path wins.
50
+
51
+ smartCollectionsApiRoutes.get("/slug", requireAuthApi(), async (c) => {
52
+ const query = parseValidated(SlugQuerySchema, c.req.query());
53
+
54
+ if (query.mode === "suggest") {
55
+ const slug = await c.var.services.smartCollections.suggestSlug({
56
+ title: query.title,
57
+ excludeId: query.smartCollectionId,
58
+ });
59
+ return c.json({ slug });
60
+ }
61
+
62
+ const available = await c.var.services.smartCollections.checkSlugAvailability(
63
+ query.slug,
64
+ query.smartCollectionId,
65
+ );
66
+ return c.json({ slug: query.slug, available });
67
+ });
68
+
69
+ // --- Preview ---------------------------------------------------------------
70
+
71
+ /**
72
+ * How many threads a set of conditions would gather, against the site total.
73
+ *
74
+ * POST rather than GET: the conditions are a typed body in the same shape the
75
+ * create endpoint validates, and putting them in a URL would mean inventing a
76
+ * second spelling for them.
77
+ */
78
+ smartCollectionsApiRoutes.post("/preview", requireAuthApi(), async (c) => {
79
+ const body = parseValidated(SmartCollectionPreviewSchema, await c.req.json());
80
+ const query = parseValidated(PreviewQuerySchema, c.req.query());
81
+
82
+ const { count, baseline } = await c.var.services.smartCollections.preview(
83
+ body.selection ?? {},
84
+ { isAuthenticated: c.var.isAuthenticated, lang: query.lang },
85
+ );
86
+
87
+ c.header("Cache-Control", "no-store");
88
+ return c.json({ count, baseline });
89
+ });
90
+
91
+ // --- CRUD ------------------------------------------------------------------
92
+
93
+ smartCollectionsApiRoutes.get("/", requireAuthApi(), async (c) => {
94
+ const query = parseValidated(PreviewQuerySchema, c.req.query());
95
+ const smartCollections =
96
+ await c.var.services.smartCollections.listDirectoryEntries({
97
+ isAuthenticated: c.var.isAuthenticated,
98
+ lang: query.lang,
99
+ });
100
+ return c.json({ smartCollections });
101
+ });
102
+
103
+ smartCollectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
104
+ const body = parseValidated(CreateSmartCollectionSchema, await c.req.json());
105
+ const smartCollection = await c.var.services.smartCollections.create(body);
106
+ return c.json({ smartCollection }, 201);
107
+ });
108
+
109
+ smartCollectionsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
110
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.smartCollection);
111
+ const smartCollection = await c.var.services.smartCollections.getById(id);
112
+ assertFound(smartCollection, "Smart collection");
113
+ return c.json({ smartCollection });
114
+ });
115
+
116
+ smartCollectionsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
117
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.smartCollection);
118
+ const body = parseValidated(UpdateSmartCollectionSchema, await c.req.json());
119
+ const smartCollection = await c.var.services.smartCollections.update(
120
+ id,
121
+ body,
122
+ );
123
+ assertFound(smartCollection, "Smart collection");
124
+ return c.json({ smartCollection });
125
+ });
126
+
127
+ smartCollectionsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
128
+ const id = parseIdParam(c.req.param("id"), ID_PREFIX.smartCollection);
129
+ const deleted = await c.var.services.smartCollections.delete(id);
130
+ assertFound(deleted || null, "Smart collection");
131
+ return c.json({ success: true });
132
+ });
@@ -25,6 +25,7 @@ import {
25
25
  getTelegramWebhookSecret,
26
26
  } from "../../lib/env.js";
27
27
  import { timingSafeEqualText } from "../../lib/crypto.js";
28
+ import { runDeferred } from "../../lib/deferred.js";
28
29
  import {
29
30
  answerCallbackQuery,
30
31
  buildDeepLink,
@@ -316,7 +317,7 @@ async function processUpdate(
316
317
  posterFileId: media.posterFileId ?? null,
317
318
  });
318
319
 
319
- runDeferred(c, async () => {
320
+ runDeferred(c, "Telegram", async () => {
320
321
  await sleep(ALBUM_BUFFER_DELAY_MS);
321
322
  const claimed = await telegram.claimAlbumGroup(botId, mediaGroupId);
322
323
  if (claimed.length === 0) return;
@@ -371,45 +372,6 @@ function sleep(ms: number): Promise<void> {
371
372
  return new Promise((resolve) => setTimeout(resolve, ms));
372
373
  }
373
374
 
374
- /**
375
- * Schedules background work that must outlive the current HTTP response so
376
- * Telegram can deliver the next webhook in this chat without waiting.
377
- *
378
- * On Cloudflare Workers `c.executionCtx.waitUntil` keeps the worker alive
379
- * until the promise settles. In Node / tests there's no such API, so we just
380
- * let the promise float — Node's event loop keeps running it. Either way the
381
- * promise has its own try/catch so unhandled rejections never bubble up.
382
- *
383
- * Background work also tracks the pending-promises array we register on the
384
- * env binding for tests so a vitest can `await` them after advancing timers.
385
- */
386
- function runDeferred(
387
- c: {
388
- env: Bindings;
389
- executionCtx?: { waitUntil: (promise: Promise<unknown>) => void };
390
- },
391
- work: () => Promise<void>,
392
- ): void {
393
- const promise = work().catch((err) => {
394
- const message = err instanceof Error ? err.message : String(err);
395
- // eslint-disable-next-line no-console -- Background failures must be visible.
396
- console.error(`[Jant] Telegram background error: ${message}`);
397
- });
398
- try {
399
- c.executionCtx?.waitUntil(promise);
400
- } catch {
401
- // executionCtx not available (e.g. Node, tests) — promise still runs.
402
- }
403
- const pending = (
404
- c.env as Bindings & {
405
- __telegramPending?: Promise<unknown>[];
406
- }
407
- ).__telegramPending;
408
- if (Array.isArray(pending)) {
409
- pending.push(promise);
410
- }
411
- }
412
-
413
375
  /**
414
376
  * Convert a message's caption + caption_entities into markdown, returning
415
377
  * `null` when there's nothing to record. Centralized so single-media and album
@@ -116,8 +116,7 @@ function scheduleExpiredUploadCleanup(
116
116
  .catch(() => {});
117
117
 
118
118
  let executionCtx:
119
- | { waitUntil: (promise: Promise<unknown>) => void }
120
- | undefined;
119
+ { waitUntil: (promise: Promise<unknown>) => void } | undefined;
121
120
  try {
122
121
  executionCtx = c.executionCtx;
123
122
  } catch {