@jant/core 0.6.16 → 0.7.1

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