@jant/core 0.6.16 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (592) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/export.js +3 -1
  4. package/bin/commands/import-site.js +749 -220
  5. package/bin/commands/reset-password.js +21 -8
  6. package/bin/commands/setup.js +136 -0
  7. package/bin/commands/site/export.js +71 -34
  8. package/bin/commands/site/pull-media.js +2 -6
  9. package/bin/commands/site/snapshot/export.js +19 -61
  10. package/bin/commands/site/snapshot/import.js +23 -24
  11. package/bin/lib/d1-query.js +92 -18
  12. package/bin/lib/hugo-markdown.js +4 -0
  13. package/bin/lib/node-env.js +40 -4
  14. package/bin/lib/site-pull-media.js +21 -28
  15. package/bin/lib/site-selection.js +94 -29
  16. package/bin/lib/site-snapshot.js +338 -3
  17. package/bin/lib/sql-export.js +68 -5
  18. package/bin/lib/wrangler-cli.js +37 -2
  19. package/bin/lib/wrangler-json.js +35 -0
  20. package/bin/lib/zip-archive.js +187 -0
  21. package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
  22. package/dist/client/.vite/manifest.json +255 -90
  23. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  24. package/dist/client/_assets/chunks/create-editor-B-m7X7S5.js +261 -0
  25. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  26. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  27. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  28. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  29. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  30. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  31. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  32. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  33. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  34. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  35. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  36. package/dist/client/_assets/chunks/sortable-list-BJyd-LXE.js +2 -0
  37. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  38. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  39. package/dist/client/_assets/chunks/unsafe-svg-BkscJx69.js +2 -0
  40. package/dist/client/_assets/client-DAhoqPdr.css +2 -0
  41. package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
  42. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  43. package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
  44. package/dist/client/_assets/client-knSEyUJO.js +295 -0
  45. package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
  46. package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
  47. package/dist/github-sync-Gw4orAHk.js +8398 -0
  48. package/dist/index.js +2 -5
  49. package/dist/node.js +11 -9
  50. package/package.json +15 -5
  51. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  52. package/src/__tests__/basecoat-variants.test.ts +121 -0
  53. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  54. package/src/__tests__/dev-scripts.test.ts +203 -0
  55. package/src/__tests__/export-collection-order.test.ts +276 -0
  56. package/src/__tests__/export-feed-ids.test.ts +184 -0
  57. package/src/__tests__/export-feed-order.test.ts +294 -0
  58. package/src/__tests__/export-hugo-build.test.ts +390 -0
  59. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  60. package/src/__tests__/export-service.test.ts +842 -28
  61. package/src/__tests__/export-smart-collection.test.ts +329 -0
  62. package/src/__tests__/helpers/component-css.ts +91 -0
  63. package/src/__tests__/helpers/db.ts +33 -0
  64. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  65. package/src/__tests__/helpers/hugo-site.ts +146 -0
  66. package/src/__tests__/import-site-command.test.ts +487 -1
  67. package/src/__tests__/mise-config.test.ts +75 -4
  68. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  69. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  70. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  71. package/src/__tests__/snapshot-settings.test.ts +97 -0
  72. package/src/__tests__/snapshot-tables.test.ts +219 -0
  73. package/src/__tests__/sql-export.test.ts +173 -0
  74. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  75. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  76. package/src/__tests__/zip-archive.test.ts +106 -0
  77. package/src/app.tsx +86 -22
  78. package/src/auth.ts +18 -1
  79. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  80. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  81. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  82. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  83. package/src/client/__tests__/compose-launch.test.ts +62 -1
  84. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  85. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  86. package/src/client/__tests__/copy-field.test.ts +162 -0
  87. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  88. package/src/client/__tests__/image-processor.test.ts +324 -2
  89. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  90. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  91. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  92. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  93. package/src/client/__tests__/toast.test.ts +23 -0
  94. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  95. package/src/client/__tests__/video-processor.test.ts +181 -0
  96. package/src/client/audio-processor.ts +24 -15
  97. package/src/client/audio-waveform.ts +49 -0
  98. package/src/client/collection-dialog-host.ts +72 -0
  99. package/src/client/collection-navigation.ts +51 -16
  100. package/src/client/collection-page-actions.ts +26 -0
  101. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  102. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  103. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  104. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  105. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  106. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  107. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  108. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  109. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  110. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  111. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  112. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  113. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  114. package/src/client/components/__tests__/jant-settings-general.test.ts +561 -172
  115. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  116. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  117. package/src/client/components/collection-dialog-types.ts +49 -0
  118. package/src/client/components/collection-manager-types.ts +32 -1
  119. package/src/client/components/collection-types.ts +1 -15
  120. package/src/client/components/compose-types.ts +75 -1
  121. package/src/client/components/jant-collection-dialog.ts +722 -0
  122. package/src/client/components/jant-collection-directory.ts +567 -182
  123. package/src/client/components/jant-collection-form.ts +53 -232
  124. package/src/client/components/jant-command-palette.ts +100 -49
  125. package/src/client/components/jant-compose-dialog.ts +1486 -707
  126. package/src/client/components/jant-compose-editor.ts +673 -336
  127. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  128. package/src/client/components/jant-config-editor.ts +65 -53
  129. package/src/client/components/jant-confirm-dialog.ts +13 -11
  130. package/src/client/components/jant-locale-picker.ts +418 -0
  131. package/src/client/components/jant-media-lightbox.ts +146 -136
  132. package/src/client/components/jant-nav-manager.ts +690 -366
  133. package/src/client/components/jant-post-menu.ts +1086 -282
  134. package/src/client/components/jant-post-picker.ts +324 -0
  135. package/src/client/components/jant-repo-picker-types.ts +6 -1
  136. package/src/client/components/jant-repo-picker.ts +137 -108
  137. package/src/client/components/jant-settings-avatar.ts +31 -27
  138. package/src/client/components/jant-settings-general.ts +622 -543
  139. package/src/client/components/jant-settings-language.ts +1087 -0
  140. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  141. package/src/client/components/jant-text-preview.ts +94 -86
  142. package/src/client/components/nav-manager-types.ts +62 -1
  143. package/src/client/components/settings-types.ts +14 -29
  144. package/src/client/components/smart-collection-conditions.ts +285 -0
  145. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  146. package/src/client/compose-bridge.ts +214 -141
  147. package/src/client/compose-launch.ts +76 -2
  148. package/src/client/compose-shortcuts.ts +6 -8
  149. package/src/client/compose-triggers.ts +50 -0
  150. package/src/client/copy-field.ts +108 -0
  151. package/src/client/custom-url-menu.ts +60 -4
  152. package/src/client/icons.ts +14 -0
  153. package/src/client/image-plan.ts +165 -0
  154. package/src/client/image-processor.ts +195 -117
  155. package/src/client/image-worker-client.ts +45 -0
  156. package/src/client/image-worker.ts +135 -0
  157. package/src/client/lazy-entries.ts +95 -0
  158. package/src/client/lazy-slugify.ts +2 -2
  159. package/src/client/media-scroll-hint.ts +43 -2
  160. package/src/client/mediabunny.ts +29 -0
  161. package/src/client/post-picker.ts +41 -0
  162. package/src/client/runtime-paths.ts +74 -1
  163. package/src/client/settings-bridge.ts +4 -10
  164. package/src/client/site-header-nav.js +18 -6
  165. package/src/client/smart-collection-dialog-host.ts +116 -0
  166. package/src/client/smart-collection-page-actions.ts +208 -0
  167. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  168. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  169. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  170. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  171. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  172. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  173. package/src/client/tiptap/bubble-menu.ts +47 -16
  174. package/src/client/tiptap/extensions.ts +0 -3
  175. package/src/client/tiptap/floating-position.ts +216 -2
  176. package/src/client/tiptap/inline-image-upload.ts +7 -3
  177. package/src/client/tiptap/link-toolbar.ts +22 -9
  178. package/src/client/tiptap/structural-keymap.ts +158 -47
  179. package/src/client/toast.ts +13 -0
  180. package/src/client/types/vite-worker.d.ts +12 -0
  181. package/src/client/upload-with-metadata.ts +3 -13
  182. package/src/client/video-processor.ts +351 -107
  183. package/src/client-auth.ts +14 -31
  184. package/src/client-compose.ts +19 -0
  185. package/src/client-manage.ts +13 -0
  186. package/src/client-settings.ts +14 -0
  187. package/src/client-site.ts +10 -3
  188. package/src/client.ts +4 -0
  189. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  190. package/src/db/__tests__/d1-query.test.ts +56 -1
  191. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  192. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  193. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  194. package/src/db/migrations/0032_married_tomas.sql +3 -0
  195. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  196. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  197. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  198. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  199. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  200. package/src/db/migrations/meta/_journal.json +21 -0
  201. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  202. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  203. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  204. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  205. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  206. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  207. package/src/db/migrations/pg/meta/_journal.json +21 -0
  208. package/src/db/pg/schema.ts +195 -27
  209. package/src/db/post-visibility.ts +117 -0
  210. package/src/db/schema.ts +222 -27
  211. package/src/i18n/__tests__/detect.test.ts +26 -81
  212. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  213. package/src/i18n/context.tsx +12 -2
  214. package/src/i18n/detect.ts +7 -66
  215. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  216. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  217. package/src/i18n/locales/public/en.po +490 -158
  218. package/src/i18n/locales/public/en.ts +1 -1
  219. package/src/i18n/locales/public/zh-Hans.po +487 -155
  220. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  221. package/src/i18n/locales/public/zh-Hant.po +487 -155
  222. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  223. package/src/i18n/locales/settings/en.po +555 -73
  224. package/src/i18n/locales/settings/en.ts +1 -1
  225. package/src/i18n/locales/settings/zh-Hans.po +601 -119
  226. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  227. package/src/i18n/locales/settings/zh-Hant.po +614 -132
  228. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  229. package/src/i18n/locales.ts +94 -0
  230. package/src/i18n/middleware.ts +12 -2
  231. package/src/i18n/supported-locales.ts +80 -0
  232. package/src/lib/__tests__/api-settings.test.ts +11 -2
  233. package/src/lib/__tests__/asset-path.test.ts +92 -0
  234. package/src/lib/__tests__/deferred.test.ts +90 -0
  235. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  236. package/src/lib/__tests__/discover.test.ts +410 -0
  237. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  238. package/src/lib/__tests__/feed.test.ts +1526 -15
  239. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  240. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  241. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  242. package/src/lib/__tests__/http-cache.test.ts +162 -0
  243. package/src/lib/__tests__/image.test.ts +27 -1
  244. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  245. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  246. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  247. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  248. package/src/lib/__tests__/markdown.test.ts +10 -0
  249. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  250. package/src/lib/__tests__/navigation.test.ts +135 -1
  251. package/src/lib/__tests__/resolve-config.test.ts +131 -3
  252. package/src/lib/__tests__/schemas.test.ts +69 -5
  253. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  254. package/src/lib/__tests__/slug.test.ts +70 -0
  255. package/src/lib/__tests__/slugify.test.ts +85 -0
  256. package/src/lib/__tests__/storage.test.ts +32 -0
  257. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  258. package/src/lib/__tests__/timeline.test.ts +87 -0
  259. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  260. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  261. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  262. package/src/lib/__tests__/url.test.ts +78 -72
  263. package/src/lib/__tests__/view-language.test.ts +413 -0
  264. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  265. package/src/lib/address.ts +57 -0
  266. package/src/lib/api-posts.ts +12 -0
  267. package/src/lib/asset-path.ts +98 -1
  268. package/src/lib/build-env.ts +16 -0
  269. package/src/lib/collection-groups.ts +5 -1
  270. package/src/lib/collection-paths.ts +3 -10
  271. package/src/lib/constants.ts +28 -3
  272. package/src/lib/copy-field.ts +36 -0
  273. package/src/lib/crypto.ts +40 -0
  274. package/src/lib/csp-builder.ts +2 -1
  275. package/src/lib/deferred.ts +79 -0
  276. package/src/lib/discover-ping.ts +141 -0
  277. package/src/lib/discover.ts +439 -0
  278. package/src/lib/env.ts +134 -0
  279. package/src/lib/errors.ts +33 -0
  280. package/src/lib/feed-policy.ts +236 -2
  281. package/src/lib/feed.ts +926 -73
  282. package/src/lib/filter-dimensions.ts +1349 -0
  283. package/src/lib/github-sync-repo-name.ts +45 -0
  284. package/src/lib/github-sync-site-config.ts +3 -0
  285. package/src/lib/hosted-signin.ts +1 -1
  286. package/src/lib/http-cache.ts +148 -0
  287. package/src/lib/hugo-markdown.ts +62 -0
  288. package/src/lib/icons.ts +9 -2
  289. package/src/lib/ids.ts +3 -0
  290. package/src/lib/image.ts +17 -4
  291. package/src/lib/jant-docs.ts +42 -0
  292. package/src/lib/lang-detect.ts +308 -0
  293. package/src/lib/markdown-manager.ts +392 -2
  294. package/src/lib/media-helpers.ts +13 -1
  295. package/src/lib/navigation.ts +78 -11
  296. package/src/lib/pagination.ts +28 -24
  297. package/src/lib/per-language-surfaces.ts +67 -0
  298. package/src/lib/post-body-html.ts +11 -4
  299. package/src/lib/post-meta.ts +19 -0
  300. package/src/lib/render.tsx +55 -7
  301. package/src/lib/resolve-config.ts +111 -6
  302. package/src/lib/schemas.ts +251 -15
  303. package/src/lib/site-header-fragment.tsx +2 -1
  304. package/src/lib/site-unavailable-page.ts +47 -0
  305. package/src/lib/slug.ts +51 -7
  306. package/src/lib/slugify.ts +113 -0
  307. package/src/lib/startup-config.ts +4 -6
  308. package/src/lib/storage.ts +7 -0
  309. package/src/lib/summary.ts +50 -0
  310. package/src/lib/thread-fold.ts +161 -0
  311. package/src/lib/time.ts +32 -0
  312. package/src/lib/timeline.ts +43 -2
  313. package/src/lib/tiptap-render.ts +1 -2
  314. package/src/lib/tiptap-to-markdown.ts +13 -7
  315. package/src/lib/upload.ts +16 -13
  316. package/src/lib/url.ts +159 -86
  317. package/src/lib/version.ts +10 -8
  318. package/src/lib/view-language.ts +436 -0
  319. package/src/lib/view.ts +53 -25
  320. package/src/lib/viewer-context.ts +62 -0
  321. package/src/middleware/__tests__/auth.test.ts +70 -2
  322. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  323. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  324. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  325. package/src/middleware/__tests__/session.test.ts +181 -33
  326. package/src/middleware/auth.ts +50 -31
  327. package/src/middleware/cache-control.ts +75 -10
  328. package/src/middleware/config.ts +6 -6
  329. package/src/middleware/error-handler.ts +12 -0
  330. package/src/middleware/onboarding.ts +32 -10
  331. package/src/middleware/secure-headers.ts +1 -1
  332. package/src/middleware/session.ts +96 -8
  333. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  334. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  335. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  336. package/src/node/__tests__/cli-setup.test.ts +163 -0
  337. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  338. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  339. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  340. package/src/node/__tests__/runtime.test.ts +75 -0
  341. package/src/node/index.ts +3 -0
  342. package/src/node/request-handler.ts +3 -1
  343. package/src/preset.css +76 -22
  344. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  345. package/src/routes/api/__tests__/collections.test.ts +114 -0
  346. package/src/routes/api/__tests__/discover.test.ts +222 -0
  347. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  348. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  349. package/src/routes/api/__tests__/posts.test.ts +24 -0
  350. package/src/routes/api/__tests__/settings.test.ts +24 -3
  351. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  352. package/src/routes/api/__tests__/upload.test.ts +34 -0
  353. package/src/routes/api/collections.ts +49 -6
  354. package/src/routes/api/custom-urls.ts +0 -1
  355. package/src/routes/api/discover.ts +95 -0
  356. package/src/routes/api/export.ts +6 -3
  357. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  358. package/src/routes/api/internal/sites.ts +0 -1
  359. package/src/routes/api/nav-items.ts +55 -8
  360. package/src/routes/api/posts.ts +161 -0
  361. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  362. package/src/routes/api/public/archive.ts +77 -81
  363. package/src/routes/api/public/posts.ts +44 -7
  364. package/src/routes/api/smart-collections.ts +132 -0
  365. package/src/routes/api/telegram.ts +2 -40
  366. package/src/routes/api/upload.ts +10 -3
  367. package/src/routes/api/uploads.ts +1 -2
  368. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  369. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  370. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  371. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  372. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  373. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  374. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  375. package/src/routes/auth/setup.tsx +934 -186
  376. package/src/routes/auth/signin.tsx +10 -8
  377. package/src/routes/compose.tsx +9 -0
  378. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  379. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  380. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  381. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  382. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  383. package/src/routes/dash/custom-urls.tsx +1 -37
  384. package/src/routes/dash/settings.tsx +714 -111
  385. package/src/routes/discover-announce.ts +96 -0
  386. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  387. package/src/routes/feed/feed.ts +69 -40
  388. package/src/routes/feed/sitemap.ts +95 -11
  389. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  390. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  391. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  392. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  393. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  394. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  395. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  396. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  397. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  398. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  399. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  400. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  401. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  402. package/src/routes/pages/archive.tsx +487 -379
  403. package/src/routes/pages/collection.tsx +94 -96
  404. package/src/routes/pages/collections.tsx +38 -50
  405. package/src/routes/pages/featured.tsx +43 -18
  406. package/src/routes/pages/home.tsx +25 -6
  407. package/src/routes/pages/language.tsx +113 -0
  408. package/src/routes/pages/latest.tsx +26 -16
  409. package/src/routes/pages/new.tsx +3 -0
  410. package/src/routes/pages/page.tsx +228 -45
  411. package/src/routes/pages/partials.tsx +41 -0
  412. package/src/routes/pages/search.tsx +22 -5
  413. package/src/routes/pages/smart-collection.tsx +340 -0
  414. package/src/routes/pages/subscribe.tsx +182 -0
  415. package/src/runtime/__tests__/index.test.ts +19 -0
  416. package/src/runtime/__tests__/node.test.ts +129 -4
  417. package/src/runtime/__tests__/readiness.test.ts +23 -0
  418. package/src/runtime/cloudflare.ts +21 -2
  419. package/src/runtime/index.ts +27 -1
  420. package/src/runtime/node.ts +67 -1
  421. package/src/runtime/readiness.ts +15 -0
  422. package/src/runtime/site.ts +95 -24
  423. package/src/services/__tests__/auth.test.ts +116 -7
  424. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  425. package/src/services/__tests__/collection.test.ts +108 -4
  426. package/src/services/__tests__/custom-url.test.ts +34 -0
  427. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  428. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  429. package/src/services/__tests__/language.test.ts +495 -0
  430. package/src/services/__tests__/media.test.ts +31 -0
  431. package/src/services/__tests__/navigation.test.ts +213 -10
  432. package/src/services/__tests__/path.test.ts +236 -0
  433. package/src/services/__tests__/post-language.test.ts +830 -0
  434. package/src/services/__tests__/post-timeline.test.ts +127 -0
  435. package/src/services/__tests__/post.test.ts +120 -9
  436. package/src/services/__tests__/settings.test.ts +228 -17
  437. package/src/services/__tests__/site-admin.test.ts +5 -5
  438. package/src/services/__tests__/smart-collection.test.ts +651 -0
  439. package/src/services/about-page.ts +22 -1
  440. package/src/services/auth.ts +51 -49
  441. package/src/services/bootstrap.ts +408 -39
  442. package/src/services/collection-directory-position.ts +76 -0
  443. package/src/services/collection.ts +331 -46
  444. package/src/services/custom-url.ts +29 -24
  445. package/src/services/export-theme/assets/client-site.css +1 -1
  446. package/src/services/export-theme/assets/client-site.js +123 -123
  447. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  448. package/src/services/export-theme/layouts/_default/list.html +2 -63
  449. package/src/services/export-theme/layouts/_default/rss.xml +103 -38
  450. package/src/services/export-theme/layouts/collections/list.html +3 -3
  451. package/src/services/export-theme/layouts/featured/list.html +1 -4
  452. package/src/services/export-theme/layouts/index.html +40 -26
  453. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  454. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  455. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  456. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  457. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  458. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  459. package/src/services/export-theme/layouts/partials/head.html +1 -1
  460. package/src/services/export-theme/layouts/partials/header.html +5 -3
  461. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  462. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  463. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  464. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  465. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  466. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  467. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  468. package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
  469. package/src/services/export-theme/layouts/post/list.html +1 -1
  470. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  471. package/src/services/export-theme/styles/main.css +13 -2
  472. package/src/services/export-theme/theme.toml +1 -1
  473. package/src/services/export.ts +880 -72
  474. package/src/services/github-app-installations.ts +171 -4
  475. package/src/services/github-sync.ts +69 -15
  476. package/src/services/index.ts +34 -3
  477. package/src/services/language.ts +412 -0
  478. package/src/services/mcp.ts +22 -2
  479. package/src/services/media.ts +22 -7
  480. package/src/services/navigation.ts +429 -67
  481. package/src/services/path.ts +254 -6
  482. package/src/services/post.ts +1234 -190
  483. package/src/services/search.ts +39 -2
  484. package/src/services/settings.ts +238 -43
  485. package/src/services/site-admin.ts +27 -14
  486. package/src/services/site.ts +160 -32
  487. package/src/services/smart-collection.ts +636 -0
  488. package/src/services/telegram.ts +20 -22
  489. package/src/services/upload-session.ts +1 -3
  490. package/src/style-author.css +27 -0
  491. package/src/styles/components-author.css +1622 -0
  492. package/src/styles/components.css +24 -1571
  493. package/src/styles/site-media.css +16 -2
  494. package/src/styles/tokens.css +82 -12
  495. package/src/styles/ui-author.css +7602 -0
  496. package/src/styles/ui.css +1090 -7216
  497. package/src/types/app-context.ts +23 -0
  498. package/src/types/bindings.ts +8 -0
  499. package/src/types/config.ts +94 -15
  500. package/src/types/constants.ts +188 -16
  501. package/src/types/entities.ts +87 -1
  502. package/src/types/operations.ts +74 -2
  503. package/src/types/props.ts +157 -31
  504. package/src/types/views.ts +55 -1
  505. package/src/ui/__tests__/font-themes.test.ts +52 -12
  506. package/src/ui/compose/ComposeDialog.tsx +201 -7
  507. package/src/ui/compose/ComposePrompt.tsx +1 -5
  508. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  509. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  510. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  511. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  512. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  513. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  514. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  515. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  516. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  517. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  518. package/src/ui/dash/settings/GeneralContent.tsx +277 -113
  519. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  520. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  521. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  522. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  523. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  524. package/src/ui/dash/settings/discover-copy.ts +116 -0
  525. package/src/ui/feed/LinkCard.tsx +55 -45
  526. package/src/ui/feed/NoteCard.tsx +9 -4
  527. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  528. package/src/ui/feed/QuoteCard.tsx +55 -30
  529. package/src/ui/feed/ThreadPreview.tsx +6 -2
  530. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  531. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  532. package/src/ui/feed/thread-preview-state.ts +20 -6
  533. package/src/ui/font-themes.ts +39 -29
  534. package/src/ui/layouts/BaseLayout.tsx +155 -10
  535. package/src/ui/layouts/SiteLayout.tsx +192 -34
  536. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  537. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  538. package/src/ui/pages/ArchivePage.tsx +282 -289
  539. package/src/ui/pages/CollectionPage.tsx +86 -76
  540. package/src/ui/pages/CollectionsPage.tsx +5 -0
  541. package/src/ui/pages/ComposePage.tsx +8 -0
  542. package/src/ui/pages/PostPage.tsx +47 -0
  543. package/src/ui/pages/SearchPage.tsx +3 -3
  544. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  545. package/src/ui/pages/SubscribePage.tsx +236 -0
  546. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  547. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  548. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  549. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  550. package/src/ui/shared/CollectionsManager.tsx +70 -17
  551. package/src/ui/shared/CopyField.tsx +127 -0
  552. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  553. package/src/ui/shared/MediaGallery.tsx +125 -46
  554. package/src/ui/shared/Pagination.tsx +20 -5
  555. package/src/ui/shared/PostFooter.tsx +20 -11
  556. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  557. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  558. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  559. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  560. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  561. package/src/ui/shared/archive-labels.ts +98 -0
  562. package/src/ui/shared/collection-management-labels.ts +94 -16
  563. package/src/ui/shared/navigation-labels.ts +45 -71
  564. package/src/ui/shared/post-article-attributes.ts +2 -0
  565. package/src/ui/shared/smart-collection-labels.ts +343 -0
  566. package/dist/app-C75Zc8dD.js +0 -6
  567. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  568. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  569. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  570. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  571. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  572. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  573. package/dist/env-BPYViDJJ.js +0 -281
  574. package/dist/export-Dl5KCiEs.js +0 -5562
  575. package/dist/github-api-BNF35Lkx.js +0 -176
  576. package/dist/github-app-DdiD-bC4.js +0 -275
  577. package/dist/github-sync-Bqg62IvV.js +0 -4
  578. package/dist/github-sync-HdK2EgvJ.js +0 -431
  579. package/dist/url-CbLAtlZe.js +0 -900
  580. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  581. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  582. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  583. package/src/client/collection-created-notice.ts +0 -66
  584. package/src/client/collection-form-bridge.ts +0 -191
  585. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  586. package/src/client/components/jant-post-form.ts +0 -422
  587. package/src/client/components/post-form-template.ts +0 -352
  588. package/src/client/components/post-form-types.ts +0 -96
  589. package/src/client/media-metadata.ts +0 -247
  590. package/src/client/post-form-bridge.ts +0 -193
  591. package/src/client/tiptap/exitable-marks.ts +0 -73
  592. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -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
  });
@@ -24,15 +24,30 @@ import {
24
24
  } from "../../lib/post-display.js";
25
25
  import { buildArticleJsonLd } from "../../lib/structured-data.js";
26
26
  import {
27
+ normalizePath,
27
28
  toAbsoluteAssetUrl,
29
+ toAbsoluteSiteUrl,
28
30
  toPublicHref,
29
31
  toPublicPath,
30
32
  } from "../../lib/url.js";
33
+ import {
34
+ buildLanguageSwitcher,
35
+ isPrefixedLanguageView,
36
+ toViewPath,
37
+ viewRelativePath,
38
+ type LanguageAlternate,
39
+ } from "../../lib/view-language.js";
40
+ import { getOrBuildEntry } from "../../i18n/supported-locales.js";
31
41
  import { ABOUT_PAGE_SLUG } from "../../services/about-page.js";
32
42
  import { isTextAttachment } from "../../services/media.js";
43
+ import type { ResolvedPath } from "../../services/path.js";
33
44
  import type { Post } from "../../types.js";
34
45
  import { renderArchivePage } from "./archive.js";
35
46
  import { renderCollectionFeed, renderCollectionPage } from "./collection.js";
47
+ import {
48
+ renderSmartCollectionFeed,
49
+ renderSmartCollectionPage,
50
+ } from "./smart-collection.js";
36
51
 
37
52
  type Env = { Bindings: Bindings; Variables: AppVariables };
38
53
 
@@ -55,10 +70,10 @@ interface TextPreviewAutoOpen {
55
70
  * content at every reply URL. Point the canonical to the thread root so
56
71
  * crawlers consolidate ranking on one URL.
57
72
  *
58
- * The root post is always at index 0 of `threadPostViews` (getThread orders
59
- * by createdAt ASC, and the DB check constraint guarantees root has the
60
- * smallest createdAt in its thread). When `threadPostViews` is undefined the
61
- * post is not part of a multi-post thread, so the post itself is the root.
73
+ * The root post is always at index 0 of `threadPostViews`: Thread order puts
74
+ * the root first even when a reply is older than it. When `threadPostViews` is
75
+ * undefined the post is not part of a multi-post thread, so the post itself is
76
+ * the root.
62
77
  */
63
78
  function buildPostCanonicalHref(
64
79
  postView: { permalink: string },
@@ -102,7 +117,9 @@ async function renderPostWithTextPreview(
102
117
  post: Post,
103
118
  autoOpen: TextPreviewAutoOpen,
104
119
  ) {
105
- const navDataPromise = getNavigationData(c);
120
+ // A post page's chrome lives in the post's own language view (its URL
121
+ // carries no prefix, so `viewLang` can't say).
122
+ const navDataPromise = getNavigationData(c, { languageScope: post.language });
106
123
  const display = await assemblePostPageDisplay(c, post, {
107
124
  isAuthenticated: true,
108
125
  });
@@ -147,6 +164,7 @@ async function renderPostWithTextPreview(
147
164
  articleModifiedTime: display.articleModifiedTime,
148
165
  jsonLd: buildPostJsonLd(c, display, meta, canonicalHref, navData.siteName),
149
166
  navData,
167
+ composeContextLanguage: post.language,
150
168
  content: (
151
169
  <>
152
170
  <PostPage
@@ -205,13 +223,82 @@ function canRenderDraftAboutEditor(
205
223
  );
206
224
  }
207
225
 
226
+ /**
227
+ * Where each of a post's languages lives.
228
+ *
229
+ * A translated post is not the same URL in another language — it is a
230
+ * different post with its own address — so both the `hreflang` alternates and
231
+ * the language switcher are built from the translation group rather than from
232
+ * the current path.
233
+ *
234
+ * `pathByLanguage` always contains the post itself, so the switcher marks the
235
+ * language the reader is in even when nothing has been translated yet. The
236
+ * alternates stay empty in that case: a lone self-referential `hreflang` says
237
+ * nothing.
238
+ *
239
+ * @param c - Hono context
240
+ * @param post - The post being rendered
241
+ * @returns Head alternates and per-language destinations for the switcher
242
+ */
243
+ async function buildTranslationLinks(
244
+ c: Context<Env>,
245
+ post: Post,
246
+ ): Promise<{
247
+ alternates: LanguageAlternate[];
248
+ pathByLanguage: Map<string, string>;
249
+ }> {
250
+ const { siteUrl, sitePathPrefix } = c.var.appConfig;
251
+ const pathByLanguage = new Map<string, string>();
252
+ if (!post.language) return { alternates: [], pathByLanguage };
253
+
254
+ const translations = post.translationGroupId
255
+ ? await c.var.services.posts.listTranslations(post.threadId)
256
+ : [];
257
+ // Only the versions a reader can actually open. A draft or private
258
+ // translation answers the same URL with a 404, and an `hreflang` pointing
259
+ // at a 404 is worse than no `hreflang` at all — the switcher then falls
260
+ // back to that language's home, which is always somewhere real (§5).
261
+ const siblings = translations.filter(
262
+ (other): other is Post & { language: string } =>
263
+ Boolean(other.language) &&
264
+ other.status === "published" &&
265
+ other.visibility !== "private",
266
+ );
267
+
268
+ // Prefer each post's custom URL when it has one, the same way permalinks do,
269
+ // so a link never points at a URL that immediately redirects.
270
+ const aliasesMap = await c.var.services.paths.getPostAliases([
271
+ post.id,
272
+ ...siblings.map((other) => other.id),
273
+ ]);
274
+ const pathFor = (target: Post) =>
275
+ `/${aliasesMap.get(target.id)?.[0] ?? target.slug}`;
276
+
277
+ const group = [post as Post & { language: string }, ...siblings];
278
+ for (const target of group) {
279
+ pathByLanguage.set(target.language, pathFor(target));
280
+ }
281
+
282
+ const alternates =
283
+ siblings.length > 0 && siteUrl
284
+ ? group.map((target) => ({
285
+ hreflang: target.language,
286
+ href: toAbsoluteSiteUrl(pathFor(target), siteUrl, sitePathPrefix),
287
+ }))
288
+ : [];
289
+
290
+ return { alternates, pathByLanguage };
291
+ }
292
+
208
293
  async function renderPost(
209
294
  c: Context<Env>,
210
295
  post: Post,
211
296
  options: { allowDraft?: boolean; isPreview?: boolean } = {},
212
297
  ) {
213
- // Start navData fetch immediately — it's independent of thread/media queries
214
- const navDataPromise = getNavigationData(c);
298
+ // Start navData fetch immediately — it's independent of thread/media queries.
299
+ // The chrome is scoped to the post's language: a Japanese post's logo and
300
+ // nav lead into /ja, since the post URL itself carries no language.
301
+ const navDataPromise = getNavigationData(c, { languageScope: post.language });
215
302
  const display = await assemblePostPageDisplay(c, post, {
216
303
  // Private-post access is validated before renderPost() is called.
217
304
  isAuthenticated: true,
@@ -224,6 +311,22 @@ async function renderPost(
224
311
 
225
312
  const navData = await navDataPromise;
226
313
  const meta = buildPostMeta(post, navData.siteName);
314
+
315
+ // A post is rendered in its own language, whatever surface led here — this
316
+ // drives `<html lang>`, screen-reader pronunciation, and the CJK font
317
+ // profile (Simplified, Traditional, Japanese and Korean share code points
318
+ // but not glyphs).
319
+ if (post.language) c.set("lang", post.language);
320
+ const { alternates, pathByLanguage } = await buildTranslationLinks(c, post);
321
+ // The reader-facing half of the same data: a line under the post, so someone
322
+ // who cannot read this language does not have to find the switcher.
323
+ const translationLinks = [...pathByLanguage]
324
+ .filter(([tag]) => tag !== post.language)
325
+ .map(([tag, path]) => ({
326
+ href: toPublicPath(path, c.var.appConfig.sitePathPrefix),
327
+ label: getOrBuildEntry(tag).native,
328
+ lang: tag,
329
+ }));
227
330
  const postView =
228
331
  options.isPreview && display.postView.status === "draft"
229
332
  ? {
@@ -270,6 +373,14 @@ async function renderPost(
270
373
  ? buildPageTitle(previewTitle, meta.title, navData.siteName)
271
374
  : buildPageTitle(meta.title, navData.siteName),
272
375
  description: meta.description,
376
+ ...(alternates.length > 0 ? { alternateLanguages: alternates } : {}),
377
+ // "Take me to this language's site": the translation when one exists, and
378
+ // that language's home page when it does not (§5).
379
+ languageSwitcher: buildLanguageSwitcher(c, {
380
+ hrefByLanguage: pathByLanguage,
381
+ fallbackPath: "/",
382
+ ...(post.language ? { currentLang: post.language } : {}),
383
+ }),
273
384
  ...(post.status === "published"
274
385
  ? {
275
386
  canonicalHref,
@@ -290,6 +401,7 @@ async function renderPost(
290
401
  }
291
402
  : {}),
292
403
  navData,
404
+ composeContextLanguage: post.language,
293
405
  ...(options.isPreview
294
406
  ? {
295
407
  pageChrome: <DraftPreviewBar editHref={draftEditHref} />,
@@ -301,6 +413,7 @@ async function renderPost(
301
413
  post={postView}
302
414
  threadPosts={threadPostViews}
303
415
  isPreview={options.isPreview}
416
+ translations={translationLinks}
304
417
  />
305
418
  ),
306
419
  });
@@ -321,11 +434,48 @@ pageRoutes.get("/preview/:slug", requireAuth(), async (c) => {
321
434
  return renderPost(c, post, { allowDraft: true, isPreview: true });
322
435
  });
323
436
 
324
- // Catch-all for path-registry backed post URLs, aliases, and redirects
325
- pageRoutes.get("/*", async (c) => {
326
- const fullPath = c.req.path.slice(1); // Remove leading /
437
+ /**
438
+ * Resolve a stored path, reusing the lookup the stored-redirect middleware
439
+ * already made for this request's own address.
440
+ *
441
+ * Only that one address is reusable: a `/feed` suffix or a text deep link asks
442
+ * about a different path, and a language view strips its prefix before asking,
443
+ * so the stored form is compared rather than assumed.
444
+ *
445
+ * @param c - Hono context
446
+ * @param storedPath - Registry path form, without a leading slash
447
+ * @returns The resolved record, or null when nothing is registered there
448
+ */
449
+ async function resolveStoredPath(
450
+ c: Context<Env>,
451
+ storedPath: string,
452
+ ): Promise<ResolvedPath | null> {
453
+ const lookup = c.var.pathLookup;
454
+ if (lookup && lookup.path === normalizePath(storedPath)) {
455
+ return lookup.record;
456
+ }
457
+ return c.var.services.paths.resolve(storedPath);
458
+ }
459
+
460
+ /**
461
+ * Resolve a path through the path registry: posts, collections, their aliases,
462
+ * archive URLs, stored redirects, and text-attachment deep links.
463
+ *
464
+ * Serves both the root namespace and every language view. A language view
465
+ * resolves the same rows — the prefix is stripped first — but a post reached
466
+ * that way redirects to its one canonical address, because a post's URL never
467
+ * encodes a language.
468
+ *
469
+ * @param c - Hono context
470
+ * @returns The resolved page, a redirect, or 404
471
+ */
472
+ export async function renderRegisteredPath(c: Context<Env>): Promise<Response> {
473
+ const fullPath = viewRelativePath(c).slice(1); // Remove leading /
327
474
  if (!fullPath) return c.notFound();
328
475
  const sitePathPrefix = c.var.appConfig.sitePathPrefix;
476
+ // Posts keep one language-neutral address; every other surface stays inside
477
+ // the view the reader is browsing.
478
+ const inLanguageView = isPrefixedLanguageView(c);
329
479
 
330
480
  // Explicit application routes are registered before this catch-all. Any
331
481
  // unmatched path under a reserved prefix is therefore an application 404,
@@ -334,10 +484,15 @@ pageRoutes.get("/*", async (c) => {
334
484
 
335
485
  // Stored redirects outrank the normal post/collection/deep-link resolvers,
336
486
  // but can never shadow an explicit or reserved application route.
337
- const resolved = await c.var.services.paths.resolve(fullPath);
487
+ const resolved = await resolveStoredPath(c, fullPath);
338
488
  if (resolved?.kind === "redirect" && resolved.redirectToPath) {
489
+ const target = `/${resolved.redirectToPath}`;
490
+ // Follow the stored redirect within the current view; the hop that lands
491
+ // on a post then applies the canonical-address rule below.
339
492
  return c.redirect(
340
- toPublicHref(`/${resolved.redirectToPath}`, sitePathPrefix),
493
+ toPublicHref(target) === target
494
+ ? toViewPath(c, target)
495
+ : toPublicHref(target, sitePathPrefix),
341
496
  resolved.redirectType ?? 301,
342
497
  );
343
498
  }
@@ -348,6 +503,17 @@ pageRoutes.get("/*", async (c) => {
348
503
 
349
504
  const resolvedCollection =
350
505
  await c.var.services.paths.resolve(collectionPath);
506
+
507
+ if (resolvedCollection?.smartCollectionId) {
508
+ const smartCollection = await c.var.services.smartCollections.getById(
509
+ resolvedCollection.smartCollectionId,
510
+ );
511
+ if (!smartCollection) return c.notFound();
512
+
513
+ const result = await renderSmartCollectionFeed(c, smartCollection.slug);
514
+ return result ?? c.notFound();
515
+ }
516
+
351
517
  if (resolvedCollection?.collectionId) {
352
518
  const collection = await c.var.services.collections.getById(
353
519
  resolvedCollection.collectionId,
@@ -360,10 +526,7 @@ pageRoutes.get("/*", async (c) => {
360
526
  collection.id,
361
527
  );
362
528
  if (alias) {
363
- return c.redirect(
364
- toPublicPath(`/${alias.path}/feed`, sitePathPrefix),
365
- 301,
366
- );
529
+ return c.redirect(toViewPath(c, `/${alias.path}/feed`), 301);
367
530
  }
368
531
 
369
532
  const result = await renderCollectionFeed(c, collection.slug);
@@ -390,26 +553,31 @@ pageRoutes.get("/*", async (c) => {
390
553
  const resolvedPost = await c.var.services.paths.resolve(slugPart);
391
554
 
392
555
  if (resolvedPost?.postId) {
393
- const post = await c.var.services.posts.getById(resolvedPost.postId);
556
+ const loaded = await c.var.services.posts.getWithCanonicalAlias(
557
+ resolvedPost.postId,
558
+ );
559
+ const post = loaded?.post;
394
560
  if (!post || post.status === "draft") return c.notFound();
395
561
 
396
- if (post.visibility === "private") {
397
- const navData = await getNavigationData(c);
398
- if (!navData.isAuthenticated) return c.notFound();
562
+ if (post.visibility === "private" && !c.var.isAuthenticated) {
563
+ return c.notFound();
399
564
  }
400
565
 
401
566
  // Redirect slug → alias if one exists (same pattern as post pages)
402
- if (resolvedPost.kind === "slug") {
403
- const alias = await c.var.services.customUrls.getByTarget(
404
- "post",
405
- post.id,
567
+ if (resolvedPost.kind === "slug" && loaded.canonicalAlias) {
568
+ return c.redirect(
569
+ toPublicPath(
570
+ `${loaded.canonicalAlias}/text/${mediaId}`,
571
+ sitePathPrefix,
572
+ ),
573
+ 301,
406
574
  );
407
- if (alias) {
408
- return c.redirect(
409
- toPublicPath(`/${alias.path}/text/${mediaId}`, sitePathPrefix),
410
- 301,
411
- );
412
- }
575
+ }
576
+
577
+ // A text attachment belongs to a post, so it inherits that post's single
578
+ // canonical address rather than existing once per language view.
579
+ if (inLanguageView) {
580
+ return c.redirect(toPublicPath(`/${fullPath}`, sitePathPrefix), 301);
413
581
  }
414
582
 
415
583
  // Verify the media belongs to this post and is a Jant-composed text
@@ -453,31 +621,43 @@ pageRoutes.get("/*", async (c) => {
453
621
  }
454
622
 
455
623
  if (resolved.postId) {
456
- const post = await c.var.services.posts.getById(resolved.postId);
624
+ const loaded = await c.var.services.posts.getWithCanonicalAlias(
625
+ resolved.postId,
626
+ );
627
+ const post = loaded?.post;
457
628
  if (!post) return c.notFound();
458
629
 
459
630
  const allowDraft = canRenderDraftAboutEditor(c, fullPath, post);
460
631
  if (post.status === "draft" && !allowDraft) return c.notFound();
461
632
 
462
- if (post.visibility === "private") {
463
- const navData = await getNavigationData(c);
464
- if (!navData.isAuthenticated) return c.notFound();
633
+ if (post.visibility === "private" && !c.var.isAuthenticated) {
634
+ return c.notFound();
465
635
  }
466
636
 
467
- // If accessed via slug but an alias exists, redirect to the alias
468
- if (resolved.kind === "slug") {
469
- const alias = await c.var.services.customUrls.getByTarget(
470
- "post",
471
- post.id,
472
- );
473
- if (alias) {
474
- return c.redirect(toPublicPath(`/${alias.path}`, sitePathPrefix), 301);
475
- }
637
+ // If accessed via slug but an alias exists, the alias is canonical.
638
+ const canonicalPath =
639
+ resolved.kind === "slug" && loaded.canonicalAlias
640
+ ? loaded.canonicalAlias
641
+ : `/${fullPath}`;
642
+
643
+ // One address per post: a language-prefixed URL is only ever a way in.
644
+ if (inLanguageView || canonicalPath !== `/${fullPath}`) {
645
+ return c.redirect(toPublicPath(canonicalPath, sitePathPrefix), 301);
476
646
  }
477
647
 
478
648
  return renderPost(c, post, { allowDraft });
479
649
  }
480
650
 
651
+ if (resolved.smartCollectionId) {
652
+ const smartCollection = await c.var.services.smartCollections.getById(
653
+ resolved.smartCollectionId,
654
+ );
655
+ if (!smartCollection) return c.notFound();
656
+
657
+ const result = await renderSmartCollectionPage(c, smartCollection.slug);
658
+ return result ?? c.notFound();
659
+ }
660
+
481
661
  if (resolved.collectionId) {
482
662
  const collection = await c.var.services.collections.getById(
483
663
  resolved.collectionId,
@@ -490,7 +670,7 @@ pageRoutes.get("/*", async (c) => {
490
670
  collection.id,
491
671
  );
492
672
  if (alias) {
493
- return c.redirect(toPublicPath(`/${alias.path}`, sitePathPrefix), 301);
673
+ return c.redirect(toViewPath(c, `/${alias.path}`), 301);
494
674
  }
495
675
 
496
676
  const result = await renderCollectionPage(c, collection.slug);
@@ -509,4 +689,7 @@ pageRoutes.get("/*", async (c) => {
509
689
  }
510
690
 
511
691
  return c.notFound();
512
- });
692
+ }
693
+
694
+ // Catch-all for path-registry backed post URLs, aliases, and redirects
695
+ 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, {