@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,436 @@
1
+ /**
2
+ * View language: which language's browsing surface a request is asking for.
3
+ *
4
+ * A multilingual site keeps its primary language at the root (`/`, `/archive`,
5
+ * `/feed`) and gives every other language a URL prefix (`/en`, `/zh-hant`).
6
+ * The prefix selects a *view* — it filters the timeline, feeds and collection
7
+ * pages to that language — and it does not change any post's own address.
8
+ *
9
+ * Everything a handler needs to stay inside its view goes through
10
+ * `toViewPath()`, so a page rendered at `/en` links to `/en?page=2` rather
11
+ * than throwing the reader back to the primary language on the second page.
12
+ */
13
+
14
+ import type { Context } from "hono";
15
+ import type { Bindings } from "../types/bindings.js";
16
+ import type { AppVariables } from "../types/app-context.js";
17
+ import type {
18
+ LanguageAlternate,
19
+ LanguageSwitcherOption,
20
+ } from "../types/views.js";
21
+ import { toLanguagePrefix } from "../i18n/locales.js";
22
+ import { getOrBuildEntry } from "../i18n/supported-locales.js";
23
+ import { isPerLanguageSurface } from "./per-language-surfaces.js";
24
+ import { toAbsoluteSiteUrl, toPublicPath } from "./url.js";
25
+
26
+ type ViewContext = Context<{ Bindings: Bindings; Variables: AppVariables }>;
27
+
28
+ export type { LanguageAlternate, LanguageSwitcherOption };
29
+
30
+ /**
31
+ * What the first path segment of a request means.
32
+ *
33
+ * - `pass` — not a language prefix; the request belongs to another route.
34
+ * - `redirect` — a language prefix that has no view right now (the primary
35
+ * language's own prefix, or any prefix left over from before multilingual
36
+ * was switched off). Old links and feed subscriptions keep working.
37
+ * - `view` — serve this language's view.
38
+ */
39
+ export type LanguageViewDecision =
40
+ | { kind: "pass" }
41
+ | { kind: "redirect"; to: string }
42
+ | { kind: "view"; lang: string };
43
+
44
+ const PASS: LanguageViewDecision = { kind: "pass" };
45
+
46
+ /** First segment of an internal path, without its slashes. */
47
+ function firstSegment(path: string): string {
48
+ const rest = path.startsWith("/") ? path.slice(1) : path;
49
+ const slash = rest.indexOf("/");
50
+ return slash === -1 ? rest : rest.slice(0, slash);
51
+ }
52
+
53
+ /** The same path with its first segment removed, always rooted at `/`. */
54
+ function stripFirstSegment(path: string): string {
55
+ const rest = path.startsWith("/") ? path.slice(1) : path;
56
+ const slash = rest.indexOf("/");
57
+ return slash === -1 ? "/" : rest.slice(slash);
58
+ }
59
+
60
+ /** Query string of the current request, including `?`, or an empty string. */
61
+ function queryString(c: ViewContext): string {
62
+ const index = c.req.url.indexOf("?");
63
+ return index === -1 ? "" : c.req.url.slice(index);
64
+ }
65
+
66
+ /**
67
+ * Decide what the language prefix in the current URL means.
68
+ *
69
+ * A site that has never configured a second language gets `pass` for every
70
+ * request — its posts are free to use any first segment they like, including
71
+ * ones that happen to look like language tags (`/is`, `/it`, `/no`).
72
+ *
73
+ * @param c - Request context, after `withConfig()`
74
+ * @returns How the request should be handled
75
+ * @example
76
+ * // On a site serving zh-Hans (primary) and en:
77
+ * resolveLanguageView(c); // GET /en/archive → { kind: "view", lang: "en" }
78
+ * resolveLanguageView(c); // GET /zh-hans → { kind: "redirect", to: "/" }
79
+ * resolveLanguageView(c); // GET /hello → { kind: "pass" }
80
+ */
81
+ export function resolveLanguageView(c: ViewContext): LanguageViewDecision {
82
+ const { siteLanguage, additionalLanguages, multilingualEnabled } =
83
+ c.var.appConfig;
84
+
85
+ const primaryPrefix = toLanguagePrefix(siteLanguage);
86
+ const configured = additionalLanguages.filter(
87
+ (tag) => toLanguagePrefix(tag) !== primaryPrefix,
88
+ );
89
+ if (configured.length === 0) return PASS;
90
+
91
+ const segment = firstSegment(c.req.path).toLowerCase();
92
+ if (!segment) return PASS;
93
+
94
+ const redirect = (): LanguageViewDecision => ({
95
+ kind: "redirect",
96
+ to: `${toPublicPath(
97
+ stripFirstSegment(c.req.path),
98
+ c.var.appConfig.sitePathPrefix,
99
+ )}${queryString(c)}`,
100
+ });
101
+
102
+ // The primary language is served from the root, so its own prefix is only
103
+ // ever an alias for it (§4.3).
104
+ if (segment === primaryPrefix) return redirect();
105
+
106
+ const match = configured.find((tag) => toLanguagePrefix(tag) === segment);
107
+ if (!match) return PASS;
108
+
109
+ return multilingualEnabled ? { kind: "view", lang: match } : redirect();
110
+ }
111
+
112
+ /**
113
+ * Every URL prefix a path on this site can arrive under.
114
+ *
115
+ * The site's own languages, the primary one included: its prefix has no view
116
+ * of its own but still redirects to the root, so `/zh-hans/hello` names the
117
+ * same page as `/hello`. Empty on a site with one language, where a first
118
+ * segment that looks like a language tag is just a post's slug.
119
+ *
120
+ * Use it to resolve an address the author pasted; requests themselves are
121
+ * decided by {@link resolveLanguageView}, which knows view from redirect.
122
+ *
123
+ * @param config - Site language settings
124
+ * @returns Prefixes without slashes, e.g. `["zh-hans", "en"]`
125
+ */
126
+ export function languageUrlPrefixes(config: {
127
+ siteLanguage: string;
128
+ additionalLanguages: readonly string[];
129
+ }): string[] {
130
+ const primaryPrefix = toLanguagePrefix(config.siteLanguage);
131
+ const others = config.additionalLanguages
132
+ .map((tag) => toLanguagePrefix(tag))
133
+ .filter((prefix) => prefix !== primaryPrefix);
134
+
135
+ return others.length === 0 ? [] : [primaryPrefix, ...new Set(others)];
136
+ }
137
+
138
+ /**
139
+ * Whether the current URL carries a language prefix.
140
+ *
141
+ * Distinct from `getViewLang()`: the root URLs of a multilingual site show the
142
+ * primary language, so they *have* a view language while having no prefix.
143
+ *
144
+ * @param c - Request context
145
+ * @returns True when the request came in under `/en`-style URLs
146
+ */
147
+ export function isPrefixedLanguageView(c: ViewContext): boolean {
148
+ return c.var.viewLang !== undefined;
149
+ }
150
+
151
+ /**
152
+ * The language the current view shows, for filtering content.
153
+ *
154
+ * On a multilingual site the root is the primary language's view, so this
155
+ * returns the primary language there rather than "no filter" — otherwise `/`
156
+ * would show every language while `/en` showed one, and the primary language
157
+ * would have no view of its own.
158
+ *
159
+ * @param c - Request context
160
+ * @returns Canonical BCP 47 tag, or `null` when the site is single-language
161
+ */
162
+ export function getViewLang(c: ViewContext): string | null {
163
+ if (c.var.viewLang) return c.var.viewLang;
164
+
165
+ const { siteLanguage, additionalLanguages, multilingualEnabled } =
166
+ c.var.appConfig;
167
+ if (!multilingualEnabled) return null;
168
+
169
+ const primaryPrefix = toLanguagePrefix(siteLanguage);
170
+ const hasSecondLanguage = additionalLanguages.some(
171
+ (tag) => toLanguagePrefix(tag) !== primaryPrefix,
172
+ );
173
+ return hasSecondLanguage ? siteLanguage : null;
174
+ }
175
+
176
+ /**
177
+ * URL prefix of the current view, for building in-view links.
178
+ *
179
+ * @param c - Request context
180
+ * @returns `/en`-style prefix, or an empty string on the root view
181
+ */
182
+ export function viewBasePath(c: ViewContext): string {
183
+ const lang = c.var.viewLang;
184
+ return lang ? `/${toLanguagePrefix(lang)}` : "";
185
+ }
186
+
187
+ /**
188
+ * URL prefix of the view a given language is served under.
189
+ *
190
+ * A post page belongs to its own language's site: its chrome — logo, nav,
191
+ * search — links into that language's view, so a reader on a Japanese post
192
+ * stays among Japanese surfaces. Post pages render at language-neutral URLs,
193
+ * so this is derived from the post's language rather than the request path.
194
+ *
195
+ * @param c - Request context
196
+ * @param lang - Content language of the page, or null/undefined when unknown
197
+ * @returns `/ja`-style prefix for an active non-primary language, otherwise
198
+ * an empty string (the root view)
199
+ * @example
200
+ * languageScopeBasePath(c, "ja"); // "/ja" on a zh-Hans + ja site
201
+ * languageScopeBasePath(c, "zh-Hans"); // "" — the primary lives at the root
202
+ */
203
+ export function languageScopeBasePath(
204
+ c: ViewContext,
205
+ lang: string | null | undefined,
206
+ ): string {
207
+ if (!lang) return "";
208
+ const { siteLanguage } = c.var.appConfig;
209
+ const prefix = toLanguagePrefix(lang);
210
+ if (prefix === toLanguagePrefix(siteLanguage)) return "";
211
+ const active = getViewLanguages(c).some(
212
+ (tag) => toLanguagePrefix(tag) === prefix,
213
+ );
214
+ return active ? `/${prefix}` : "";
215
+ }
216
+
217
+ /**
218
+ * Build a public path that stays inside the current language view.
219
+ *
220
+ * Use this for every in-page link a handler generates — pagination, filter
221
+ * chips, feed autodiscovery, JSON-LD. Post permalinks are deliberately *not*
222
+ * view-scoped: a post has one address whatever view it was reached from.
223
+ *
224
+ * @param c - Request context
225
+ * @param path - Internal app path such as `/archive`
226
+ * @returns Public path, prefixed by the view language and the site path prefix
227
+ * @example
228
+ * toViewPath(c, "/"); // "/" at the root, "/en" under the English view
229
+ * toViewPath(c, "/archive"); // "/archive" or "/en/archive"
230
+ */
231
+ export function toViewPath(c: ViewContext, path = "/"): string {
232
+ const base = viewBasePath(c);
233
+ const { sitePathPrefix } = c.var.appConfig;
234
+ if (!base) return toPublicPath(path, sitePathPrefix);
235
+ const suffix = path === "/" || path === "" ? "" : path;
236
+ return toPublicPath(`${base}${suffix}`, sitePathPrefix);
237
+ }
238
+
239
+ /**
240
+ * The request path with the language prefix removed.
241
+ *
242
+ * Handlers that resolve a path against the site's own content — the
243
+ * `path_registry` catch-all above all — work on this, so `/en/hello` and
244
+ * `/hello` resolve to the same row.
245
+ *
246
+ * @param c - Request context
247
+ * @returns Internal path rooted at `/`, without the view prefix
248
+ */
249
+ export function viewRelativePath(c: ViewContext): string {
250
+ return c.var.viewLang ? stripFirstSegment(c.req.path) : c.req.path;
251
+ }
252
+
253
+ // The table of per-language surfaces lives in its own module so the client can
254
+ // import it too — a link built in the browser needs the same answer.
255
+ export { isPerLanguageSurface };
256
+
257
+ /**
258
+ * Build a path inside an arbitrary language's view.
259
+ *
260
+ * Used by the language switcher, which links out of the current view.
261
+ *
262
+ * @param c - Request context
263
+ * @param lang - Target language tag, or `null` for the primary view
264
+ * @param path - Internal app path such as `/archive`
265
+ * @returns Public path in that language's view
266
+ */
267
+ export function toLanguagePath(
268
+ c: ViewContext,
269
+ lang: string | null,
270
+ path = "/",
271
+ ): string {
272
+ const { sitePathPrefix, siteLanguage } = c.var.appConfig;
273
+ const isPrimary =
274
+ !lang || toLanguagePrefix(lang) === toLanguagePrefix(siteLanguage);
275
+ if (isPrimary) return toPublicPath(path, sitePathPrefix);
276
+ const suffix = path === "/" || path === "" ? "" : path;
277
+ return toPublicPath(`/${toLanguagePrefix(lang)}${suffix}`, sitePathPrefix);
278
+ }
279
+
280
+ /**
281
+ * Every language the site serves a view for, primary first.
282
+ *
283
+ * @param c - Request context
284
+ * @returns Canonical tags, or an empty array when multilingual is off
285
+ */
286
+ export function getViewLanguages(c: ViewContext): string[] {
287
+ const { siteLanguage, additionalLanguages, multilingualEnabled } =
288
+ c.var.appConfig;
289
+ if (!multilingualEnabled) return [];
290
+ const primaryPrefix = toLanguagePrefix(siteLanguage);
291
+ const rest = additionalLanguages.filter(
292
+ (tag) => toLanguagePrefix(tag) !== primaryPrefix,
293
+ );
294
+ return rest.length === 0 ? [] : [siteLanguage, ...rest];
295
+ }
296
+
297
+ /**
298
+ * The site's languages as the composer offers them.
299
+ *
300
+ * Empty on a single-language site, which is what makes the composer show no
301
+ * language UI at all — an author who never turned multilingual content on
302
+ * should never meet it.
303
+ *
304
+ * @param c - Request context
305
+ * @returns Tag and native label per language, primary first
306
+ */
307
+ export function buildComposeLanguages(
308
+ c: ViewContext,
309
+ ): Array<{ tag: string; label: string }> {
310
+ return getViewLanguages(c).map((tag) => ({
311
+ tag,
312
+ label: getOrBuildEntry(tag).native,
313
+ }));
314
+ }
315
+
316
+ /**
317
+ * Build the site's language switcher for the current page.
318
+ *
319
+ * The switcher means "take me to this language's site", not "translate this
320
+ * page": from a list surface it goes to that surface in the other language,
321
+ * from a post it goes to the translation when one exists, and from anywhere
322
+ * without a counterpart in that language — a post with no translation, a
323
+ * settings page — it goes to that language's home. It is never disabled and
324
+ * never warns: a reader who lands on a language's home page has still
325
+ * arrived somewhere, and it never links to a 404.
326
+ *
327
+ * @param c - Request context
328
+ * @param options - Per-language destinations, and where to go without one
329
+ * @returns Switcher entries in configured order, or an empty array when the
330
+ * site serves one language
331
+ * @example
332
+ * buildLanguageSwitcher(c); // list surfaces: same path, each language
333
+ * buildLanguageSwitcher(c, {
334
+ * hrefByLanguage: new Map([["en", "/my-post"]]),
335
+ * fallbackPath: "/",
336
+ * }); // a post: its translations, else that language's home
337
+ */
338
+ export function buildLanguageSwitcher(
339
+ c: ViewContext,
340
+ options: {
341
+ hrefByLanguage?: ReadonlyMap<string, string>;
342
+ fallbackPath?: string;
343
+ /** Language to mark as current. Defaults to the view's own language. */
344
+ currentLang?: string;
345
+ } = {},
346
+ ): LanguageSwitcherOption[] {
347
+ const languages = getViewLanguages(c);
348
+ if (languages.length === 0) return [];
349
+
350
+ const currentPath = viewRelativePath(c);
351
+ const fallbackPath =
352
+ options.fallbackPath ??
353
+ (isPerLanguageSurface(currentPath) ? currentPath : "/");
354
+ const currentPrefix = toLanguagePrefix(
355
+ options.currentLang || getViewLang(c) || c.var.appConfig.siteLanguage,
356
+ );
357
+ const primaryPrefix = toLanguagePrefix(c.var.appConfig.siteLanguage);
358
+
359
+ return languages.map((lang) => {
360
+ const override = options.hrefByLanguage?.get(lang);
361
+ return {
362
+ lang,
363
+ label: getOrBuildEntry(lang).native,
364
+ href: override
365
+ ? toPublicPath(override, c.var.appConfig.sitePathPrefix)
366
+ : toLanguagePath(c, lang, fallbackPath),
367
+ isCurrent: toLanguagePrefix(lang) === currentPrefix,
368
+ isPrimary: toLanguagePrefix(lang) === primaryPrefix,
369
+ };
370
+ });
371
+ }
372
+
373
+ /**
374
+ * `hreflang` alternates for the surface the current request is rendering.
375
+ *
376
+ * List surfaces exist once per language at the same path, so every language's
377
+ * copy of the current path is an alternate of it.
378
+ *
379
+ * An `x-default` alternate pointing at the primary language closes the set,
380
+ * so a reader whose language the site does not publish lands somewhere
381
+ * deliberate. Surfaces where it has no defined meaning — Atom feeds — opt out
382
+ * with `xDefault: false`.
383
+ *
384
+ * Returns an empty array on a single-language site, and when the site has no
385
+ * absolute URL configured — an hreflang `href` must be absolute to be honoured.
386
+ *
387
+ * The request's own query string rides along by default, so a filtered surface
388
+ * points at the same filter in each language. A page that also emits a
389
+ * `rel="canonical"` must pass `query` instead: every member of an hreflang set
390
+ * has to be a canonical URL, so the self-referential alternate cannot carry
391
+ * params the canonical strips — the two tags would give one page two
392
+ * identities.
393
+ *
394
+ * @param c - Request context
395
+ * @param options - `query` overrides the request's own query string, leading
396
+ * `?` included; `xDefault: false` omits the trailing `x-default` entry
397
+ * @returns Alternates for this surface, primary first
398
+ * @example
399
+ * // Rendering /en/archive on a zh-Hans + en site:
400
+ * buildSurfaceAlternates(c);
401
+ * // [{hreflang: "zh-Hans", href: "https://…/archive"},
402
+ * // {hreflang: "en", href: "https://…/en/archive"},
403
+ * // {hreflang: "x-default", href: "https://…/archive"}]
404
+ * @example
405
+ * // On /archive?utm_source=nl, whose canonical is /archive:
406
+ * buildSurfaceAlternates(c, { query: "" });
407
+ */
408
+ export function buildSurfaceAlternates(
409
+ c: ViewContext,
410
+ options?: { query?: string; xDefault?: boolean },
411
+ ): LanguageAlternate[] {
412
+ const languages = getViewLanguages(c);
413
+ const { siteUrl, sitePathPrefix } = c.var.appConfig;
414
+ if (languages.length === 0 || !siteUrl) return [];
415
+
416
+ const path = viewRelativePath(c);
417
+ const query = options?.query ?? queryString(c);
418
+ const primaryPrefix = toLanguagePrefix(c.var.appConfig.siteLanguage);
419
+
420
+ const alternates = languages.map((lang) => {
421
+ const prefix = toLanguagePrefix(lang);
422
+ const internal =
423
+ prefix === primaryPrefix ? path : `/${prefix}${path === "/" ? "" : path}`;
424
+ return {
425
+ hreflang: lang,
426
+ href: `${toAbsoluteSiteUrl(internal, siteUrl, sitePathPrefix)}${query}`,
427
+ };
428
+ });
429
+
430
+ // A reader whose language the site does not publish lands on the primary one.
431
+ const primary = alternates[0];
432
+ if (primary && options?.xDefault !== false) {
433
+ alternates.push({ hreflang: "x-default", href: primary.href });
434
+ }
435
+ return alternates;
436
+ }
package/src/lib/view.ts CHANGED
@@ -32,10 +32,19 @@ import {
32
32
  formatDate,
33
33
  formatTime,
34
34
  formatRelativeTime,
35
+ formatYearMonthLabel,
35
36
  } from "./time.js";
36
37
  import { getCollectionPagePath } from "./collection-paths.js";
37
38
  import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
38
- import { extractSummaryHtml, extractBodyText } from "./summary.js";
39
+ import {
40
+ extractSummaryHtml,
41
+ extractBodyText,
42
+ ARTICLE_SUMMARY_MAX_BLOCKS,
43
+ ARTICLE_SUMMARY_MAX_CHARS,
44
+ NOTE_SUMMARY_MAX_BLOCKS,
45
+ NOTE_SUMMARY_MAX_CHARS,
46
+ NOTE_SUMMARY_MIN_HIDDEN_CHARS,
47
+ } from "./summary.js";
39
48
  import { renderTiptapDocumentAroundBoundary } from "./tiptap-render.js";
40
49
  import { highlightText } from "./search-snippet.js";
41
50
  import { isFullUrl, toPublicPath, toSameSitePath } from "./url.js";
@@ -140,15 +149,6 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
140
149
  // Post Conversions
141
150
  // =============================================================================
142
151
 
143
- /** Feed summary limits for titled, article-style posts (the excerpt is a teaser). */
144
- const ARTICLE_SUMMARY_MAX_BLOCKS = 5;
145
- const ARTICLE_SUMMARY_MAX_CHARS = 500;
146
- /** Larger feed summary limits for untitled notes — the body itself is the content. */
147
- const NOTE_SUMMARY_MAX_BLOCKS = 10;
148
- const NOTE_SUMMARY_MAX_CHARS = 1500;
149
- /** Don't truncate an untitled note just to hide a tail under this many chars. */
150
- const NOTE_SUMMARY_MIN_HIDDEN_CHARS = 200;
151
-
152
152
  /**
153
153
  * Splice a zero-width marker into rendered body HTML at a summary boundary.
154
154
  *
@@ -401,11 +401,42 @@ export function toPostView(
401
401
  collections,
402
402
  replyToId: post.replyToId ?? undefined,
403
403
  threadRootId: post.replyToId ? post.threadId : undefined,
404
+ // Language is a Thread property, carried on the root. Repeating it on every
405
+ // reply would invite per-reply controls that the data model does not allow.
406
+ language: post.replyToId ? undefined : (post.language ?? undefined),
404
407
  isLastInThread: isLastInThread ?? true,
405
408
  body: post.body ?? undefined,
406
409
  };
407
410
  }
408
411
 
412
+ /**
413
+ * Resolves the unpublished draft sitting at the end of a Thread, if any.
414
+ *
415
+ * Both maps come from the same ordering over the same rows, differing only in
416
+ * whether drafts are eligible — so when they disagree, the draft-inclusive
417
+ * answer is by definition unpublished. No status lookup needed.
418
+ *
419
+ * @param threadId - Thread root ID to look up
420
+ * @param publishedTails - Tails from `getThreadTailIds(ids)`
421
+ * @param draftInclusiveTails - Tails from `getThreadTailIds(ids, { includeDrafts: true })`
422
+ * @returns The trailing draft's Post ID, or undefined when the Thread ends
423
+ * on a published Post
424
+ * @example
425
+ * ```ts
426
+ * const draftTailId = resolveDraftTailId(post.threadId, tails, draftTails);
427
+ * if (draftTailId) view.draftTailId = draftTailId;
428
+ * ```
429
+ */
430
+ export function resolveDraftTailId(
431
+ threadId: string,
432
+ publishedTails: Map<string, string>,
433
+ draftInclusiveTails: Map<string, string>,
434
+ ): string | undefined {
435
+ const withDrafts = draftInclusiveTails.get(threadId);
436
+ if (!withDrafts) return undefined;
437
+ return withDrafts === publishedTails.get(threadId) ? undefined : withDrafts;
438
+ }
439
+
409
440
  /**
410
441
  * Batch converts PostWithMedia[] to PostView[].
411
442
  *
@@ -479,6 +510,31 @@ export function toPostViewsFromPosts(
479
510
  * @param currentPath - Current URL path for active state
480
511
  * @param isAuthenticated - Whether the user is logged in (affects system settings item)
481
512
  */
513
+ /**
514
+ * System nav destinations that exist once per language view.
515
+ *
516
+ * `settings` is absent on purpose: the dashboard is a single, language-neutral
517
+ * surface, so a reader browsing `/en` still signs in at `/signin`.
518
+ */
519
+ const LANGUAGE_SCOPED_SYSTEM_KEYS = new Set<string>([
520
+ "latest",
521
+ "featured",
522
+ "collections",
523
+ "archive",
524
+ "rss",
525
+ ]);
526
+
527
+ /** Whether a nav item points at a surface that a language view re-serves. */
528
+ function isLanguageScopedNavItem(item: NavItem): boolean {
529
+ if (item.type === "system") {
530
+ return LANGUAGE_SCOPED_SYSTEM_KEYS.has(item.systemKey ?? "");
531
+ }
532
+ // Collections keep their slug but filter by language; posts and free-form
533
+ // links have one address each. A smart collection is a collection in this
534
+ // respect too — `/en/quotes` is the English half of the same conditions.
535
+ return item.type === "collection" || item.type === "smart_collection";
536
+ }
537
+
482
538
  export function toNavItemView(
483
539
  item: NavItem,
484
540
  currentPath: string,
@@ -486,6 +542,7 @@ export function toNavItemView(
486
542
  sitePathPrefix = "",
487
543
  collectionFreshness?: Map<string, number>,
488
544
  siteOrigin = "",
545
+ basePath = sitePathPrefix,
489
546
  ): NavItemView {
490
547
  let url = item.url;
491
548
  let label = item.label;
@@ -518,14 +575,15 @@ export function toNavItemView(
518
575
  // so strip it back to a path and skip external-link affordances.
519
576
  const sameSitePath = toSameSitePath(url, siteOrigin);
520
577
  const isExternal = sameSitePath === null && isFullUrl(url);
578
+ const linkPrefix = isLanguageScopedNavItem(item) ? basePath : sitePathPrefix;
521
579
  const publicUrl = isExternal
522
580
  ? url
523
- : toPublicPath(sameSitePath ?? url, sitePathPrefix);
581
+ : toPublicPath(sameSitePath ?? url, linkPrefix);
524
582
 
525
583
  let isActive = false;
526
584
  if (!isExternal) {
527
- if (publicUrl === sitePathPrefix || publicUrl === "/") {
528
- isActive = currentPath === (sitePathPrefix || "/");
585
+ if (publicUrl === linkPrefix || publicUrl === "/") {
586
+ isActive = currentPath === (linkPrefix || "/");
529
587
  } else {
530
588
  isActive =
531
589
  currentPath === publicUrl || currentPath.startsWith(`${publicUrl}/`);
@@ -543,9 +601,11 @@ export function toNavItemView(
543
601
  type: item.type as NavItemType,
544
602
  systemKey: item.systemKey,
545
603
  collectionId: item.collectionId,
604
+ smartCollectionId: item.smartCollectionId,
546
605
  postId: item.postId,
547
606
  label,
548
607
  url: publicUrl,
608
+ targetTitle: item.targetTitle,
549
609
  placement: item.placement as NavItemPlacement,
550
610
  isActive,
551
611
  isExternal,
@@ -568,6 +628,7 @@ export function toNavItemViews(
568
628
  sitePathPrefix = "",
569
629
  collectionFreshness?: Map<string, number>,
570
630
  siteOrigin = "",
631
+ basePath = sitePathPrefix,
571
632
  ): NavItemView[] {
572
633
  return items.map((item) =>
573
634
  toNavItemView(
@@ -577,6 +638,7 @@ export function toNavItemViews(
577
638
  sitePathPrefix,
578
639
  collectionFreshness,
579
640
  siteOrigin,
641
+ basePath,
580
642
  ),
581
643
  );
582
644
  }
@@ -675,11 +737,8 @@ export function toArchiveGroups(
675
737
  const [year, month] = yearMonth.split("-");
676
738
  if (!year || !month) continue;
677
739
 
678
- const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
679
- const label = date.toLocaleDateString("en-US", {
680
- year: "numeric",
681
- month: "long",
682
- });
740
+ const label = formatYearMonthLabel(yearMonth);
741
+ if (!label) continue;
683
742
 
684
743
  groups.push({
685
744
  year,
@@ -709,11 +768,8 @@ export function toArchiveGroupsWithMedia(
709
768
  const [year, month] = yearMonth.split("-");
710
769
  if (!year || !month) continue;
711
770
 
712
- const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
713
- const label = date.toLocaleDateString("en-US", {
714
- year: "numeric",
715
- month: "long",
716
- });
771
+ const label = formatYearMonthLabel(yearMonth);
772
+ if (!label) continue;
717
773
 
718
774
  groups.push({
719
775
  year,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Request-scoped viewer state for Hono JSX renders.
3
+ *
4
+ * Whether the visitor is the signed-in author decides what a Post card should
5
+ * even emit. The post menu trigger, the reply trigger, and three of the four
6
+ * status badges only ever become visible under `body[data-authenticated]` or
7
+ * an article state an anonymous reader cannot reach, so for a logged-out
8
+ * visitor they are markup that can never be seen — several hundred DOM nodes
9
+ * of invisible chrome on a full timeline, paid for in style and layout on
10
+ * every load.
11
+ *
12
+ * The flag is bound once per render, alongside the i18n instance and from the
13
+ * same source (`c.var.isAuthenticated`), rather than threaded through the six
14
+ * component layers between a page and `PostFooter`. It must stay that same
15
+ * flag: `BaseLayout` writes it into `data-authenticated`, and a gate that
16
+ * disagrees with the CSS reveal either hides the author's own controls or
17
+ * brings the dead markup back.
18
+ *
19
+ * Renders are synchronous and single-threaded per request, so a module-level
20
+ * binding is safe here — the same reasoning the i18n context runs on.
21
+ */
22
+
23
+ /**
24
+ * Whether the current render is for the signed-in author.
25
+ *
26
+ * Defaults to `false` so a render that never bound a viewer emits the reader's
27
+ * markup. That is the safe direction: the author's controls are hidden until
28
+ * something says otherwise, rather than leaking into an anonymous page.
29
+ */
30
+ let currentIsAuthor = false;
31
+
32
+ /**
33
+ * Bind the viewer for the render that is about to run.
34
+ *
35
+ * Called by the render provider, not by components.
36
+ *
37
+ * @param isAuthor - Whether this request carries the author's session
38
+ * @returns Nothing
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * bindViewer(c.get("isAuthenticated") === true);
43
+ * ```
44
+ */
45
+ export function bindViewer(isAuthor: boolean): void {
46
+ currentIsAuthor = isAuthor;
47
+ }
48
+
49
+ /**
50
+ * Read the current render's viewer.
51
+ *
52
+ * @returns `isAuthor` — whether the signed-in author is looking at this page
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * const { isAuthor } = useViewer();
57
+ * return isAuthor ? <PostMenuTriggerButton /> : null;
58
+ * ```
59
+ */
60
+ export function useViewer(): { isAuthor: boolean } {
61
+ return { isAuthor: currentIsAuthor };
62
+ }