@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Language View Routes
3
+ *
4
+ * Every reader-facing surface, served a second time under a language prefix
5
+ * (`/en`, `/zh-hant`). The primary language keeps the unprefixed root, so this
6
+ * group only ever handles the other languages.
7
+ *
8
+ * Two things about how this is wired are load-bearing:
9
+ *
10
+ * 1. **The group is always mounted.** Which prefixes are languages is
11
+ * per-site configuration loaded by `withConfig()` on each request, and one
12
+ * app instance serves many sites in hosted mode — so the routing table
13
+ * cannot encode the language set. The decision is made per request instead.
14
+ *
15
+ * 2. **The decision lives inside each handler, not in middleware.** Hono
16
+ * middleware cannot decline a route: `next()` proceeds *into* the handlers
17
+ * that already matched. A gate that has to fall through to the
18
+ * `path_registry` catch-all therefore has to be the handler itself, which
19
+ * is what `langGet()` welds on at registration time.
20
+ *
21
+ * Mount this immediately before the catch-all page routes, after every static
22
+ * route group, so `/archive` reaches the archive rather than being read as a
23
+ * language named "archive".
24
+ */
25
+
26
+ import { Hono } from "hono";
27
+ import type { Context, Next } from "hono";
28
+ import type { Bindings } from "../../types.js";
29
+ import type { AppVariables } from "../../types/app-context.js";
30
+ import { resolveLanguageView } from "../../lib/view-language.js";
31
+ import {
32
+ redirectLegacyArchiveFeed,
33
+ renderArchiveFeed,
34
+ renderArchiveRoute,
35
+ } from "./archive.js";
36
+ import {
37
+ renderCollectionSelectionFeedRoute,
38
+ renderCollectionSelectionRoute,
39
+ } from "./collection.js";
40
+ import { renderCollectionsDirectory } from "./collections.js";
41
+ import { renderSubscribePage } from "./subscribe.js";
42
+ import {
43
+ redirectLegacyFeaturedFeed,
44
+ renderFeaturedFeed,
45
+ renderFeaturedPage,
46
+ } from "./featured.js";
47
+ import { renderHomePage } from "./home.js";
48
+ import {
49
+ redirectLatestToHome,
50
+ redirectLegacyLatestFeed,
51
+ renderLatestFeed,
52
+ } from "./latest.js";
53
+ import { renderRegisteredPath } from "./page.js";
54
+ import { renderSearchPage } from "./search.js";
55
+ import { renderMainFeed } from "../feed/feed.js";
56
+
57
+ type Env = { Bindings: Bindings; Variables: AppVariables };
58
+
59
+ export const languageRoutes = new Hono<Env>();
60
+
61
+ type ViewHandler = (c: Context<Env>) => Response | Promise<Response>;
62
+
63
+ /**
64
+ * Register a surface under the language prefix, gated on the prefix actually
65
+ * being one of this site's languages.
66
+ *
67
+ * @param path - Path within the language prefix, such as `/archive`
68
+ * @param handler - The same handler the root namespace uses
69
+ */
70
+ function langGet(path: string, handler: ViewHandler): void {
71
+ languageRoutes.get(path, async (c: Context<Env>, next: Next) => {
72
+ const decision = resolveLanguageView(c);
73
+
74
+ if (decision.kind === "pass") return next();
75
+ if (decision.kind === "redirect") return c.redirect(decision.to, 301);
76
+
77
+ c.set("viewLang", decision.lang);
78
+ // Drives `<html lang>`, the CJK font profile, and feed language tags. The
79
+ // message catalog deliberately stays on the base locale: public chrome is
80
+ // English on every view, exactly as it is at the root.
81
+ c.set("lang", decision.lang);
82
+
83
+ return handler(c);
84
+ });
85
+ }
86
+
87
+ langGet("/", renderHomePage);
88
+ langGet("/feed", renderMainFeed);
89
+
90
+ langGet("/latest", redirectLatestToHome);
91
+ langGet("/latest/feed", renderLatestFeed);
92
+ langGet("/latest/feed/atom.xml", redirectLegacyLatestFeed);
93
+
94
+ langGet("/featured", renderFeaturedPage);
95
+ langGet("/featured/feed", renderFeaturedFeed);
96
+ langGet("/featured/feed/atom.xml", redirectLegacyFeaturedFeed);
97
+
98
+ langGet("/archive", renderArchiveRoute);
99
+ langGet("/archive/feed", renderArchiveFeed);
100
+ langGet("/archive/feed/atom.xml", redirectLegacyArchiveFeed);
101
+
102
+ langGet("/search", renderSearchPage);
103
+
104
+ langGet("/subscribe", renderSubscribePage);
105
+
106
+ langGet("/collections", renderCollectionsDirectory);
107
+ langGet("/collections/:slug", renderCollectionSelectionRoute);
108
+ langGet("/collections/:slug/feed", renderCollectionSelectionFeedRoute);
109
+
110
+ // Registered last: everything else resolves through the path registry, the
111
+ // same way the root namespace does. Anything that is not a language prefix
112
+ // falls through from here to the root catch-all.
113
+ langGet("/*", renderRegisteredPath);
@@ -5,22 +5,32 @@
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
+ import type { Context } from "hono";
8
9
  import type { Bindings } from "../../types.js";
9
10
  import type { AppVariables } from "../../types/app-context.js";
10
- import { toPublicPath } from "../../lib/url.js";
11
+ import { toViewPath } from "../../lib/view-language.js";
11
12
  import { defaultFeedRenderer } from "../../lib/feed.js";
12
- import { buildFeedData, parseFormatQuery, renderFeed } from "../feed/feed.js";
13
+ import { renderFeed } from "../../lib/feed-policy.js";
14
+ import { buildFeedData, parseFormatQuery } from "../feed/feed.js";
13
15
 
14
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
17
 
16
18
  export const latestRoutes = new Hono<Env>();
17
19
 
18
- latestRoutes.get("/", async (c) => {
19
- return c.redirect(toPublicPath("/", c.var.appConfig.sitePathPrefix), 302);
20
- });
20
+ /** `/latest` is an alias for the view's home timeline. */
21
+ export function redirectLatestToHome(c: Context<Env>): Response {
22
+ return c.redirect(toViewPath(c, "/"), 302);
23
+ }
21
24
 
22
- // Atom — /latest/feed (canonical latest feed; accepts ?format=note|link|quote)
23
- latestRoutes.get("/feed", async (c) => {
25
+ /**
26
+ * Render the latest Atom feed for the current view language.
27
+ *
28
+ * Accepts `?format=note|link|quote`.
29
+ *
30
+ * @param c - Hono context
31
+ * @returns Atom feed response
32
+ */
33
+ export async function renderLatestFeed(c: Context<Env>): Promise<Response> {
24
34
  const format = parseFormatQuery(c);
25
35
  const feedData = await buildFeedData(c, {
26
36
  kind: "latest",
@@ -28,16 +38,16 @@ latestRoutes.get("/feed", async (c) => {
28
38
  format,
29
39
  });
30
40
  return renderFeed(defaultFeedRenderer(feedData));
31
- });
41
+ }
32
42
 
33
- // Legacy atom.xml suffix → canonical /latest/feed (preserves ?format=)
34
- latestRoutes.get("/feed/atom.xml", (c) => {
35
- const sitePathPrefix = c.var.appConfig.sitePathPrefix;
43
+ /** Legacy atom.xml suffix → canonical /latest/feed, preserving `?format=`. */
44
+ export function redirectLegacyLatestFeed(c: Context<Env>): Response {
36
45
  const qs = c.req.url.includes("?")
37
46
  ? c.req.url.slice(c.req.url.indexOf("?"))
38
47
  : "";
39
- return c.redirect(
40
- `${toPublicPath("/latest/feed", sitePathPrefix)}${qs}`,
41
- 308,
42
- );
43
- });
48
+ return c.redirect(`${toViewPath(c, "/latest/feed")}${qs}`, 308);
49
+ }
50
+
51
+ latestRoutes.get("/", redirectLatestToHome);
52
+ latestRoutes.get("/feed", renderLatestFeed);
53
+ latestRoutes.get("/feed/atom.xml", redirectLegacyLatestFeed);
@@ -8,6 +8,7 @@ import { buildPageTitle } from "../../lib/page-title.js";
8
8
  import { renderPublicPage } from "../../lib/render.js";
9
9
  import { getI18n } from "../../i18n/index.js";
10
10
  import { ComposePage } from "../../ui/pages/ComposePage.js";
11
+ import { buildComposeLanguages, getViewLang } from "../../lib/view-language.js";
11
12
  import { SETTINGS_KEYS } from "../../lib/constants.js";
12
13
 
13
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -43,6 +44,8 @@ newPostRoutes.get("/new", async (c) => {
43
44
  uploadMaxFileSize={c.var.appConfig.uploadMaxFileSize}
44
45
  closeHref="/"
45
46
  slashCommandDiscovered={slashCommandDiscovered}
47
+ languages={buildComposeLanguages(c)}
48
+ contextLanguage={getViewLang(c)}
46
49
  />
47
50
  ),
48
51
  });
@@ -25,14 +25,27 @@ import {
25
25
  import { buildArticleJsonLd } from "../../lib/structured-data.js";
26
26
  import {
27
27
  toAbsoluteAssetUrl,
28
+ toAbsoluteSiteUrl,
28
29
  toPublicHref,
29
30
  toPublicPath,
30
31
  } from "../../lib/url.js";
32
+ import {
33
+ buildLanguageSwitcher,
34
+ isPrefixedLanguageView,
35
+ toViewPath,
36
+ viewRelativePath,
37
+ type LanguageAlternate,
38
+ } from "../../lib/view-language.js";
39
+ import { getOrBuildEntry } from "../../i18n/supported-locales.js";
31
40
  import { ABOUT_PAGE_SLUG } from "../../services/about-page.js";
32
41
  import { isTextAttachment } from "../../services/media.js";
33
42
  import type { Post } from "../../types.js";
34
43
  import { renderArchivePage } from "./archive.js";
35
44
  import { renderCollectionFeed, renderCollectionPage } from "./collection.js";
45
+ import {
46
+ renderSmartCollectionFeed,
47
+ renderSmartCollectionPage,
48
+ } from "./smart-collection.js";
36
49
 
37
50
  type Env = { Bindings: Bindings; Variables: AppVariables };
38
51
 
@@ -102,7 +115,9 @@ async function renderPostWithTextPreview(
102
115
  post: Post,
103
116
  autoOpen: TextPreviewAutoOpen,
104
117
  ) {
105
- const navDataPromise = getNavigationData(c);
118
+ // A post page's chrome lives in the post's own language view (its URL
119
+ // carries no prefix, so `viewLang` can't say).
120
+ const navDataPromise = getNavigationData(c, { languageScope: post.language });
106
121
  const display = await assemblePostPageDisplay(c, post, {
107
122
  isAuthenticated: true,
108
123
  });
@@ -147,6 +162,7 @@ async function renderPostWithTextPreview(
147
162
  articleModifiedTime: display.articleModifiedTime,
148
163
  jsonLd: buildPostJsonLd(c, display, meta, canonicalHref, navData.siteName),
149
164
  navData,
165
+ composeContextLanguage: post.language,
150
166
  content: (
151
167
  <>
152
168
  <PostPage
@@ -205,13 +221,82 @@ function canRenderDraftAboutEditor(
205
221
  );
206
222
  }
207
223
 
224
+ /**
225
+ * Where each of a post's languages lives.
226
+ *
227
+ * A translated post is not the same URL in another language — it is a
228
+ * different post with its own address — so both the `hreflang` alternates and
229
+ * the language switcher are built from the translation group rather than from
230
+ * the current path.
231
+ *
232
+ * `pathByLanguage` always contains the post itself, so the switcher marks the
233
+ * language the reader is in even when nothing has been translated yet. The
234
+ * alternates stay empty in that case: a lone self-referential `hreflang` says
235
+ * nothing.
236
+ *
237
+ * @param c - Hono context
238
+ * @param post - The post being rendered
239
+ * @returns Head alternates and per-language destinations for the switcher
240
+ */
241
+ async function buildTranslationLinks(
242
+ c: Context<Env>,
243
+ post: Post,
244
+ ): Promise<{
245
+ alternates: LanguageAlternate[];
246
+ pathByLanguage: Map<string, string>;
247
+ }> {
248
+ const { siteUrl, sitePathPrefix } = c.var.appConfig;
249
+ const pathByLanguage = new Map<string, string>();
250
+ if (!post.language) return { alternates: [], pathByLanguage };
251
+
252
+ const translations = post.translationGroupId
253
+ ? await c.var.services.posts.listTranslations(post.threadId)
254
+ : [];
255
+ // Only the versions a reader can actually open. A draft or private
256
+ // translation answers the same URL with a 404, and an `hreflang` pointing
257
+ // at a 404 is worse than no `hreflang` at all — the switcher then falls
258
+ // back to that language's home, which is always somewhere real (§5).
259
+ const siblings = translations.filter(
260
+ (other): other is Post & { language: string } =>
261
+ Boolean(other.language) &&
262
+ other.status === "published" &&
263
+ other.visibility !== "private",
264
+ );
265
+
266
+ // Prefer each post's custom URL when it has one, the same way permalinks do,
267
+ // so a link never points at a URL that immediately redirects.
268
+ const aliasesMap = await c.var.services.paths.getPostAliases([
269
+ post.id,
270
+ ...siblings.map((other) => other.id),
271
+ ]);
272
+ const pathFor = (target: Post) =>
273
+ `/${aliasesMap.get(target.id)?.[0] ?? target.slug}`;
274
+
275
+ const group = [post as Post & { language: string }, ...siblings];
276
+ for (const target of group) {
277
+ pathByLanguage.set(target.language, pathFor(target));
278
+ }
279
+
280
+ const alternates =
281
+ siblings.length > 0 && siteUrl
282
+ ? group.map((target) => ({
283
+ hreflang: target.language,
284
+ href: toAbsoluteSiteUrl(pathFor(target), siteUrl, sitePathPrefix),
285
+ }))
286
+ : [];
287
+
288
+ return { alternates, pathByLanguage };
289
+ }
290
+
208
291
  async function renderPost(
209
292
  c: Context<Env>,
210
293
  post: Post,
211
294
  options: { allowDraft?: boolean; isPreview?: boolean } = {},
212
295
  ) {
213
- // Start navData fetch immediately — it's independent of thread/media queries
214
- const navDataPromise = getNavigationData(c);
296
+ // Start navData fetch immediately — it's independent of thread/media queries.
297
+ // The chrome is scoped to the post's language: a Japanese post's logo and
298
+ // nav lead into /ja, since the post URL itself carries no language.
299
+ const navDataPromise = getNavigationData(c, { languageScope: post.language });
215
300
  const display = await assemblePostPageDisplay(c, post, {
216
301
  // Private-post access is validated before renderPost() is called.
217
302
  isAuthenticated: true,
@@ -224,6 +309,22 @@ async function renderPost(
224
309
 
225
310
  const navData = await navDataPromise;
226
311
  const meta = buildPostMeta(post, navData.siteName);
312
+
313
+ // A post is rendered in its own language, whatever surface led here — this
314
+ // drives `<html lang>`, screen-reader pronunciation, and the CJK font
315
+ // profile (Simplified, Traditional, Japanese and Korean share code points
316
+ // but not glyphs).
317
+ if (post.language) c.set("lang", post.language);
318
+ const { alternates, pathByLanguage } = await buildTranslationLinks(c, post);
319
+ // The reader-facing half of the same data: a line under the post, so someone
320
+ // who cannot read this language does not have to find the switcher.
321
+ const translationLinks = [...pathByLanguage]
322
+ .filter(([tag]) => tag !== post.language)
323
+ .map(([tag, path]) => ({
324
+ href: toPublicPath(path, c.var.appConfig.sitePathPrefix),
325
+ label: getOrBuildEntry(tag).native,
326
+ lang: tag,
327
+ }));
227
328
  const postView =
228
329
  options.isPreview && display.postView.status === "draft"
229
330
  ? {
@@ -260,12 +361,24 @@ async function renderPost(
260
361
  display.threadPostViews,
261
362
  c.var.appConfig.siteUrl,
262
363
  );
364
+ const draftEditHref = `${toPublicPath(
365
+ `/preview/${post.slug}`,
366
+ c.var.appConfig.sitePathPrefix,
367
+ )}?edit=1`;
263
368
 
264
369
  return renderPublicPage(c, {
265
370
  title: previewTitle
266
371
  ? buildPageTitle(previewTitle, meta.title, navData.siteName)
267
372
  : buildPageTitle(meta.title, navData.siteName),
268
373
  description: meta.description,
374
+ ...(alternates.length > 0 ? { alternateLanguages: alternates } : {}),
375
+ // "Take me to this language's site": the translation when one exists, and
376
+ // that language's home page when it does not (§5).
377
+ languageSwitcher: buildLanguageSwitcher(c, {
378
+ hrefByLanguage: pathByLanguage,
379
+ fallbackPath: "/",
380
+ ...(post.language ? { currentLang: post.language } : {}),
381
+ }),
269
382
  ...(post.status === "published"
270
383
  ? {
271
384
  canonicalHref,
@@ -286,10 +399,10 @@ async function renderPost(
286
399
  }
287
400
  : {}),
288
401
  navData,
402
+ composeContextLanguage: post.language,
289
403
  ...(options.isPreview
290
404
  ? {
291
- pageChrome: <DraftPreviewBar />,
292
- showComposeDialog: false,
405
+ pageChrome: <DraftPreviewBar editHref={draftEditHref} />,
293
406
  noindex: true,
294
407
  }
295
408
  : {}),
@@ -298,6 +411,7 @@ async function renderPost(
298
411
  post={postView}
299
412
  threadPosts={threadPostViews}
300
413
  isPreview={options.isPreview}
414
+ translations={translationLinks}
301
415
  />
302
416
  ),
303
417
  });
@@ -318,11 +432,25 @@ pageRoutes.get("/preview/:slug", requireAuth(), async (c) => {
318
432
  return renderPost(c, post, { allowDraft: true, isPreview: true });
319
433
  });
320
434
 
321
- // Catch-all for path-registry backed post URLs, aliases, and redirects
322
- pageRoutes.get("/*", async (c) => {
323
- const fullPath = c.req.path.slice(1); // Remove leading /
435
+ /**
436
+ * Resolve a path through the path registry: posts, collections, their aliases,
437
+ * archive URLs, stored redirects, and text-attachment deep links.
438
+ *
439
+ * Serves both the root namespace and every language view. A language view
440
+ * resolves the same rows — the prefix is stripped first — but a post reached
441
+ * that way redirects to its one canonical address, because a post's URL never
442
+ * encodes a language.
443
+ *
444
+ * @param c - Hono context
445
+ * @returns The resolved page, a redirect, or 404
446
+ */
447
+ export async function renderRegisteredPath(c: Context<Env>): Promise<Response> {
448
+ const fullPath = viewRelativePath(c).slice(1); // Remove leading /
324
449
  if (!fullPath) return c.notFound();
325
450
  const sitePathPrefix = c.var.appConfig.sitePathPrefix;
451
+ // Posts keep one language-neutral address; every other surface stays inside
452
+ // the view the reader is browsing.
453
+ const inLanguageView = isPrefixedLanguageView(c);
326
454
 
327
455
  // Explicit application routes are registered before this catch-all. Any
328
456
  // unmatched path under a reserved prefix is therefore an application 404,
@@ -333,8 +461,13 @@ pageRoutes.get("/*", async (c) => {
333
461
  // but can never shadow an explicit or reserved application route.
334
462
  const resolved = await c.var.services.paths.resolve(fullPath);
335
463
  if (resolved?.kind === "redirect" && resolved.redirectToPath) {
464
+ const target = `/${resolved.redirectToPath}`;
465
+ // Follow the stored redirect within the current view; the hop that lands
466
+ // on a post then applies the canonical-address rule below.
336
467
  return c.redirect(
337
- toPublicHref(`/${resolved.redirectToPath}`, sitePathPrefix),
468
+ toPublicHref(target) === target
469
+ ? toViewPath(c, target)
470
+ : toPublicHref(target, sitePathPrefix),
338
471
  resolved.redirectType ?? 301,
339
472
  );
340
473
  }
@@ -345,6 +478,17 @@ pageRoutes.get("/*", async (c) => {
345
478
 
346
479
  const resolvedCollection =
347
480
  await c.var.services.paths.resolve(collectionPath);
481
+
482
+ if (resolvedCollection?.smartCollectionId) {
483
+ const smartCollection = await c.var.services.smartCollections.getById(
484
+ resolvedCollection.smartCollectionId,
485
+ );
486
+ if (!smartCollection) return c.notFound();
487
+
488
+ const result = await renderSmartCollectionFeed(c, smartCollection.slug);
489
+ return result ?? c.notFound();
490
+ }
491
+
348
492
  if (resolvedCollection?.collectionId) {
349
493
  const collection = await c.var.services.collections.getById(
350
494
  resolvedCollection.collectionId,
@@ -357,10 +501,7 @@ pageRoutes.get("/*", async (c) => {
357
501
  collection.id,
358
502
  );
359
503
  if (alias) {
360
- return c.redirect(
361
- toPublicPath(`/${alias.path}/feed`, sitePathPrefix),
362
- 301,
363
- );
504
+ return c.redirect(toViewPath(c, `/${alias.path}/feed`), 301);
364
505
  }
365
506
 
366
507
  const result = await renderCollectionFeed(c, collection.slug);
@@ -409,6 +550,12 @@ pageRoutes.get("/*", async (c) => {
409
550
  }
410
551
  }
411
552
 
553
+ // A text attachment belongs to a post, so it inherits that post's single
554
+ // canonical address rather than existing once per language view.
555
+ if (inLanguageView) {
556
+ return c.redirect(toPublicPath(`/${fullPath}`, sitePathPrefix), 301);
557
+ }
558
+
412
559
  // Verify the media belongs to this post and is a Jant-composed text
413
560
  // attachment. Plain text-file uploads (.md, .txt, .csv) also carry
414
561
  // mediaKind === "text" but lack the split HTML/JSON sibling layout
@@ -461,20 +608,34 @@ pageRoutes.get("/*", async (c) => {
461
608
  if (!navData.isAuthenticated) return c.notFound();
462
609
  }
463
610
 
464
- // If accessed via slug but an alias exists, redirect to the alias
611
+ // If accessed via slug but an alias exists, the alias is canonical.
612
+ let canonicalPath = `/${fullPath}`;
465
613
  if (resolved.kind === "slug") {
466
614
  const alias = await c.var.services.customUrls.getByTarget(
467
615
  "post",
468
616
  post.id,
469
617
  );
470
- if (alias) {
471
- return c.redirect(toPublicPath(`/${alias.path}`, sitePathPrefix), 301);
472
- }
618
+ if (alias) canonicalPath = `/${alias.path}`;
619
+ }
620
+
621
+ // One address per post: a language-prefixed URL is only ever a way in.
622
+ if (inLanguageView || canonicalPath !== `/${fullPath}`) {
623
+ return c.redirect(toPublicPath(canonicalPath, sitePathPrefix), 301);
473
624
  }
474
625
 
475
626
  return renderPost(c, post, { allowDraft });
476
627
  }
477
628
 
629
+ if (resolved.smartCollectionId) {
630
+ const smartCollection = await c.var.services.smartCollections.getById(
631
+ resolved.smartCollectionId,
632
+ );
633
+ if (!smartCollection) return c.notFound();
634
+
635
+ const result = await renderSmartCollectionPage(c, smartCollection.slug);
636
+ return result ?? c.notFound();
637
+ }
638
+
478
639
  if (resolved.collectionId) {
479
640
  const collection = await c.var.services.collections.getById(
480
641
  resolved.collectionId,
@@ -487,7 +648,7 @@ pageRoutes.get("/*", async (c) => {
487
648
  collection.id,
488
649
  );
489
650
  if (alias) {
490
- return c.redirect(toPublicPath(`/${alias.path}`, sitePathPrefix), 301);
651
+ return c.redirect(toViewPath(c, `/${alias.path}`), 301);
491
652
  }
492
653
 
493
654
  const result = await renderCollectionPage(c, collection.slug);
@@ -506,4 +667,7 @@ pageRoutes.get("/*", async (c) => {
506
667
  }
507
668
 
508
669
  return c.notFound();
509
- });
670
+ }
671
+
672
+ // Catch-all for path-registry backed post URLs, aliases, and redirects
673
+ pageRoutes.get("/*", renderRegisteredPath);
@@ -59,6 +59,47 @@ partialPageRoutes.get("/_/post-card/:postId", async (c) => {
59
59
  );
60
60
  });
61
61
 
62
+ /**
63
+ * A Post rendered as its detail page, with every control taken off.
64
+ *
65
+ * For surfaces that show one Post inside another context — the composer
66
+ * writing a translation of it, so far. Those need the Post to *look* like
67
+ * itself, because its structure is part of what the author is working from,
68
+ * but they must not offer to act on it: the actions belong to the Post being
69
+ * written, not to the one being read.
70
+ *
71
+ * Not a variant of `/_/post-view`: that one renders the whole Thread and is
72
+ * what a page swaps itself for. This renders the root alone.
73
+ */
74
+ partialPageRoutes.get("/_/post-preview/:postId", async (c) => {
75
+ const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
76
+ const postView = await assemblePostCardView(c, postId, {
77
+ isAuthenticated: c.var.isAuthenticated,
78
+ });
79
+
80
+ if (!postView) {
81
+ return c.notFound();
82
+ }
83
+
84
+ return c.html(
85
+ <I18nProvider c={c}>
86
+ <TimelineItemFromPost
87
+ post={postView}
88
+ mode="detail"
89
+ display={{
90
+ hideStatusBadges: true,
91
+ showFullBody: true,
92
+ // `hideTimestamp` is deliberately absent: a titled detail post puts
93
+ // its date in the header, and the card only drops the footer's copy
94
+ // when this is left undefined. Stating it either way prints the date
95
+ // twice.
96
+ footer: { hideActions: true, hideReply: true },
97
+ }}
98
+ />
99
+ </I18nProvider>,
100
+ );
101
+ });
102
+
62
103
  partialPageRoutes.get("/_/post-view/:postId", async (c) => {
63
104
  const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
64
105
  const display = await assemblePostPageDisplay(c, postId, {
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
+ import type { Context } from "hono";
6
7
  import type { Bindings, SearchResult } from "../../types.js";
7
8
  import type { AppVariables } from "../../types/app-context.js";
8
9
  import { SearchPage } from "../../ui/pages/SearchPage.js";
@@ -10,12 +11,22 @@ import { getNavigationData } from "../../lib/navigation.js";
10
11
  import { buildPageTitle } from "../../lib/page-title.js";
11
12
  import { renderPublicPage } from "../../lib/render.js";
12
13
  import { createMediaContext, toSearchResultViews } from "../../lib/view.js";
14
+ import {
15
+ buildSurfaceAlternates,
16
+ getViewLang,
17
+ } from "../../lib/view-language.js";
13
18
 
14
19
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
20
 
16
21
  export const searchRoutes = new Hono<Env>();
17
22
 
18
- searchRoutes.get("/", async (c) => {
23
+ /**
24
+ * Render the search page for the current view language.
25
+ *
26
+ * @param c - Hono context
27
+ * @returns Search page response
28
+ */
29
+ export async function renderSearchPage(c: Context<Env>): Promise<Response> {
19
30
  const query = c.req.query("q") || "";
20
31
  const pageParam = c.req.query("page");
21
32
  const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
@@ -35,6 +46,7 @@ searchRoutes.get("/", async (c) => {
35
46
  limit: pageSize + 1,
36
47
  offset: (page - 1) * pageSize,
37
48
  status: ["published"],
49
+ lang: getViewLang(c) ?? undefined,
38
50
  });
39
51
 
40
52
  hasMore = results.length > pageSize;
@@ -72,6 +84,7 @@ searchRoutes.get("/", async (c) => {
72
84
  query ? `Search: ${query}` : "Search",
73
85
  navData.siteName,
74
86
  ),
87
+ alternateLanguages: buildSurfaceAlternates(c),
75
88
  navData,
76
89
  content: (
77
90
  <SearchPage
@@ -80,9 +93,11 @@ searchRoutes.get("/", async (c) => {
80
93
  error={error}
81
94
  hasMore={hasMore}
82
95
  page={page}
83
- sitePathPrefix={navData.sitePathPrefix}
96
+ basePath={navData.basePath}
84
97
  isAuthenticated={navData.isAuthenticated}
85
98
  />
86
99
  ),
87
100
  });
88
- });
101
+ }
102
+
103
+ searchRoutes.get("/", renderSearchPage);