@jant/core 0.6.16 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -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,6 +401,9 @@ 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
  };
@@ -507,6 +510,31 @@ export function toPostViewsFromPosts(
507
510
  * @param currentPath - Current URL path for active state
508
511
  * @param isAuthenticated - Whether the user is logged in (affects system settings item)
509
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
+
510
538
  export function toNavItemView(
511
539
  item: NavItem,
512
540
  currentPath: string,
@@ -514,6 +542,7 @@ export function toNavItemView(
514
542
  sitePathPrefix = "",
515
543
  collectionFreshness?: Map<string, number>,
516
544
  siteOrigin = "",
545
+ basePath = sitePathPrefix,
517
546
  ): NavItemView {
518
547
  let url = item.url;
519
548
  let label = item.label;
@@ -546,14 +575,15 @@ export function toNavItemView(
546
575
  // so strip it back to a path and skip external-link affordances.
547
576
  const sameSitePath = toSameSitePath(url, siteOrigin);
548
577
  const isExternal = sameSitePath === null && isFullUrl(url);
578
+ const linkPrefix = isLanguageScopedNavItem(item) ? basePath : sitePathPrefix;
549
579
  const publicUrl = isExternal
550
580
  ? url
551
- : toPublicPath(sameSitePath ?? url, sitePathPrefix);
581
+ : toPublicPath(sameSitePath ?? url, linkPrefix);
552
582
 
553
583
  let isActive = false;
554
584
  if (!isExternal) {
555
- if (publicUrl === sitePathPrefix || publicUrl === "/") {
556
- isActive = currentPath === (sitePathPrefix || "/");
585
+ if (publicUrl === linkPrefix || publicUrl === "/") {
586
+ isActive = currentPath === (linkPrefix || "/");
557
587
  } else {
558
588
  isActive =
559
589
  currentPath === publicUrl || currentPath.startsWith(`${publicUrl}/`);
@@ -571,9 +601,11 @@ export function toNavItemView(
571
601
  type: item.type as NavItemType,
572
602
  systemKey: item.systemKey,
573
603
  collectionId: item.collectionId,
604
+ smartCollectionId: item.smartCollectionId,
574
605
  postId: item.postId,
575
606
  label,
576
607
  url: publicUrl,
608
+ targetTitle: item.targetTitle,
577
609
  placement: item.placement as NavItemPlacement,
578
610
  isActive,
579
611
  isExternal,
@@ -596,6 +628,7 @@ export function toNavItemViews(
596
628
  sitePathPrefix = "",
597
629
  collectionFreshness?: Map<string, number>,
598
630
  siteOrigin = "",
631
+ basePath = sitePathPrefix,
599
632
  ): NavItemView[] {
600
633
  return items.map((item) =>
601
634
  toNavItemView(
@@ -605,6 +638,7 @@ export function toNavItemViews(
605
638
  sitePathPrefix,
606
639
  collectionFreshness,
607
640
  siteOrigin,
641
+ basePath,
608
642
  ),
609
643
  );
610
644
  }
@@ -703,11 +737,8 @@ export function toArchiveGroups(
703
737
  const [year, month] = yearMonth.split("-");
704
738
  if (!year || !month) continue;
705
739
 
706
- const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
707
- const label = date.toLocaleDateString("en-US", {
708
- year: "numeric",
709
- month: "long",
710
- });
740
+ const label = formatYearMonthLabel(yearMonth);
741
+ if (!label) continue;
711
742
 
712
743
  groups.push({
713
744
  year,
@@ -737,11 +768,8 @@ export function toArchiveGroupsWithMedia(
737
768
  const [year, month] = yearMonth.split("-");
738
769
  if (!year || !month) continue;
739
770
 
740
- const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
741
- const label = date.toLocaleDateString("en-US", {
742
- year: "numeric",
743
- month: "long",
744
- });
771
+ const label = formatYearMonthLabel(yearMonth);
772
+ if (!label) continue;
745
773
 
746
774
  groups.push({
747
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
+ }