@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -6,7 +6,9 @@
6
6
 
7
7
  import type { Context } from "hono";
8
8
  import type { Collection, NavItem, NavItemView } from "../types.js";
9
+ import { isFeedNavKey } from "../types/constants.js";
9
10
  import { toNavItemViews } from "./view.js";
11
+ import { languageScopeBasePath, viewBasePath } from "./view-language.js";
10
12
  import { render as renderMarkdown, toPlainText } from "./markdown.js";
11
13
 
12
14
  /**
@@ -15,7 +17,15 @@ import { render as renderMarkdown, toPlainText } from "./markdown.js";
15
17
  export interface NavigationData {
16
18
  links: NavItemView[];
17
19
  currentPath: string;
20
+ /** Deployment path prefix, such as `/blog`. */
18
21
  sitePathPrefix: string;
22
+ /**
23
+ * Public path prefix for reader-facing surfaces. Equals `sitePathPrefix`
24
+ * outside a language view, and carries the language prefix inside one
25
+ * (`/blog/en`). Pass this — not `sitePathPrefix` — to anything building
26
+ * links to the home page, archive, search, feeds or collections.
27
+ */
28
+ basePath: string;
19
29
  siteName: string;
20
30
  /** Plain-text description for meta tags and RSS/Atom feeds */
21
31
  siteDescription: string;
@@ -23,11 +33,37 @@ export interface NavigationData {
23
33
  siteDescriptionHtml?: string;
24
34
  isAuthenticated: boolean;
25
35
  collections: Collection[];
26
- siteAvatarUrl?: string;
36
+ /** Header/drawer mark, already sized down — not the canonical asset. */
37
+ siteAvatarThumbUrl?: string;
27
38
  showHeaderAvatar?: boolean;
28
39
  siteFooterHtml?: string;
29
40
  }
30
41
 
42
+ /**
43
+ * Collect the collections and smart collections already placed in navigation.
44
+ *
45
+ * The directory offers both kinds the same "Add to Navigation" action, so it
46
+ * needs one list to check against; TypeID prefixes (`col_`, `smc_`) keep the
47
+ * two apart without a second field.
48
+ *
49
+ * @param links - Navigation items as rendered for this request
50
+ * @returns TypeIDs of every collection and smart collection in navigation
51
+ * @example
52
+ * const placed = collectNavigationCollectionIds(navData.links);
53
+ * // ["col_01abc", "smc_01xyz"]
54
+ */
55
+ export function collectNavigationCollectionIds(links: NavItemView[]): string[] {
56
+ return links.flatMap((item) => {
57
+ if (item.type === "collection" && item.collectionId) {
58
+ return [item.collectionId];
59
+ }
60
+ if (item.type === "smart_collection" && item.smartCollectionId) {
61
+ return [item.smartCollectionId];
62
+ }
63
+ return [];
64
+ });
65
+ }
66
+
31
67
  /**
32
68
  * Fetch navigation data for public pages.
33
69
  *
@@ -49,20 +85,45 @@ export interface NavigationData {
49
85
  */
50
86
  export async function getNavigationData(
51
87
  c: Context,
52
- options?: { preloadedItems?: NavItem[]; includeCollections?: boolean },
88
+ options?: {
89
+ preloadedItems?: NavItem[];
90
+ includeCollections?: boolean;
91
+ /**
92
+ * Content language whose view the page's chrome should live in. Post
93
+ * pages pass their post's language here: their URLs carry no language
94
+ * prefix, so `viewLang` is never set on them, yet a Japanese post's
95
+ * logo and nav should lead to `/ja`, not to the primary view.
96
+ */
97
+ languageScope?: string | null;
98
+ },
53
99
  ): Promise<NavigationData> {
100
+ const appConfig = c.var.appConfig;
101
+ const langBase =
102
+ options?.languageScope !== undefined
103
+ ? languageScopeBasePath(c, options.languageScope)
104
+ : viewBasePath(c);
105
+ // An empty prefix means the primary language's view, whose nav items already
106
+ // point where they should — so the whole primary-language site, multilingual
107
+ // or not, never pays for the translation lookup.
108
+ const scopeLanguage = langBase
109
+ ? (options?.languageScope ?? c.var.viewLang ?? null)
110
+ : null;
111
+
54
112
  // Callers that already fetched nav items can pass them in to avoid a
55
113
  // redundant DB round-trip.
56
114
  const savedItems =
57
- options?.preloadedItems ?? (await c.var.services.navItems.list());
115
+ options?.preloadedItems ??
116
+ (await c.var.services.navItems.list({ language: scopeLanguage }));
58
117
  const currentPath = c.var.publicPath;
59
- const appConfig = c.var.appConfig;
60
- // Keep the saved RSS item untouched so its placement and custom label come
61
- // back when feeds are re-enabled. Only the rendered projection is filtered.
118
+ // Keep the saved feed items untouched so their placement and custom labels
119
+ // come back when feeds are re-enabled. Only the rendered projection is
120
+ // filtered. Both entries go: `rss` addresses a feed that is off, and
121
+ // `subscribe` a page that 404s without one.
62
122
  const items = appConfig.rssFeedsEnabled
63
123
  ? savedItems
64
124
  : savedItems.filter(
65
- (item: NavItem) => item.type !== "system" || item.systemKey !== "rss",
125
+ (item: NavItem) =>
126
+ item.type !== "system" || !isFeedNavKey(item.systemKey),
66
127
  );
67
128
 
68
129
  const siteName = appConfig.siteName;
@@ -80,8 +141,10 @@ export async function getNavigationData(
80
141
  })
81
142
  : undefined;
82
143
 
83
- // Avatar URL and display flag come from appConfig
84
- const siteAvatarUrl = appConfig.siteAvatarUrl || undefined;
144
+ // Avatar URL and display flag come from appConfig. The chrome takes the
145
+ // sized variant: the full-resolution asset belongs to the social card and
146
+ // the settings editor, not to a 32px mark on every page.
147
+ const siteAvatarThumbUrl = appConfig.siteAvatarThumbUrl || undefined;
85
148
  const showHeaderAvatar = appConfig.showHeaderAvatar;
86
149
 
87
150
  // Render footer markdown
@@ -107,6 +170,8 @@ export async function getNavigationData(
107
170
  ? await c.var.services.navItems.getCollectionFreshness(collectionNavIds)
108
171
  : undefined;
109
172
 
173
+ const basePath = `${appConfig.sitePathPrefix}${langBase}`;
174
+
110
175
  const links = toNavItemViews(
111
176
  items,
112
177
  currentPath,
@@ -114,6 +179,7 @@ export async function getNavigationData(
114
179
  appConfig.sitePathPrefix,
115
180
  collectionFreshness,
116
181
  appConfig.siteOrigin,
182
+ basePath,
117
183
  );
118
184
 
119
185
  // Only load collections when authenticated (for compose dialog)
@@ -125,13 +191,14 @@ export async function getNavigationData(
125
191
  links,
126
192
  currentPath,
127
193
  sitePathPrefix: appConfig.sitePathPrefix,
194
+ basePath,
128
195
  siteName,
129
196
  siteDescription,
130
197
  siteDescriptionHtml,
131
198
  isAuthenticated,
132
199
  collections,
133
- siteAvatarUrl,
134
- showHeaderAvatar: showHeaderAvatar && !!siteAvatarUrl,
200
+ siteAvatarThumbUrl,
201
+ showHeaderAvatar: showHeaderAvatar && !!siteAvatarThumbUrl,
135
202
  siteFooterHtml,
136
203
  };
137
204
  }
@@ -38,10 +38,18 @@ export function formatPageLabel(
38
38
  return `Page ${String(currentPage)}`;
39
39
  }
40
40
 
41
+ /** Slots a numbered pagination control fills once there are more pages. */
42
+ const PAGE_SLOT_COUNT = 7;
43
+
41
44
  /**
42
45
  * Computes which page numbers to display in a numbered pagination control.
43
- * Always includes: first page, last page, current page, and 1 page on each side of current.
44
- * Gaps between non-consecutive pages are represented by 0 (ellipsis marker).
46
+ *
47
+ * Seven or fewer pages are all shown. Past that, the control always fills
48
+ * exactly seven slots — the first page, the last page, and a three-page
49
+ * window around the current page — so it keeps the same width, and the
50
+ * Previous/Next links stay put, as the reader moves through pages. Skipped
51
+ * pages are represented by 0 (ellipsis marker). An ellipsis always stands in
52
+ * for at least two pages; where it would hide just one, that page is shown.
45
53
  *
46
54
  * @param currentPage - The current active page (1-indexed)
47
55
  * @param totalPages - Total number of pages
@@ -50,36 +58,32 @@ export function formatPageLabel(
50
58
  * @example
51
59
  * ```ts
52
60
  * getPageNumbers(1, 5) // [1, 2, 3, 4, 5]
53
- * getPageNumbers(1, 20) // [1, 2, 0, 20]
54
- * getPageNumbers(10, 20) // [1, 0, 9, 10, 11, 0, 20]
61
+ * getPageNumbers(1, 19) // [1, 2, 3, 4, 5, 0, 19]
62
+ * getPageNumbers(10, 19) // [1, 0, 9, 10, 11, 0, 19]
63
+ * getPageNumbers(19, 19) // [1, 0, 15, 16, 17, 18, 19]
55
64
  * ```
56
65
  */
57
66
  export function getPageNumbers(
58
67
  currentPage: number,
59
68
  totalPages: number,
60
69
  ): number[] {
61
- if (totalPages <= 7) {
70
+ if (totalPages <= PAGE_SLOT_COUNT) {
62
71
  return Array.from({ length: totalPages }, (_, i) => i + 1);
63
72
  }
64
73
 
65
- const pages = new Set<number>();
66
- pages.add(1);
67
- pages.add(totalPages);
68
- pages.add(currentPage);
69
- if (currentPage > 1) pages.add(currentPage - 1);
70
- if (currentPage < totalPages) pages.add(currentPage + 1);
71
-
72
- const sorted = [...pages].sort((a, b) => a - b);
73
-
74
- // Insert 0 for gaps
75
- const result: number[] = [];
76
- for (let i = 0; i < sorted.length; i++) {
77
- const current = sorted[i] as number;
78
- if (i > 0 && current - (sorted[i - 1] as number) > 1) {
79
- result.push(0); // ellipsis marker
80
- }
81
- result.push(current);
82
- }
74
+ // The second slot holds page 2 or an ellipsis, and the second-to-last slot
75
+ // holds page totalPages - 1 or an ellipsis, so the window around the current
76
+ // page starts no earlier than page 3 and ends no later than totalPages - 2.
77
+ const windowStart = Math.min(Math.max(currentPage - 1, 3), totalPages - 4);
78
+ const windowEnd = windowStart + 2;
83
79
 
84
- return result;
80
+ return [
81
+ 1,
82
+ windowStart === 3 ? 2 : 0,
83
+ windowStart,
84
+ windowStart + 1,
85
+ windowEnd,
86
+ windowEnd === totalPages - 2 ? totalPages - 1 : 0,
87
+ totalPages,
88
+ ];
85
89
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Which reader surfaces exist once per language.
3
+ *
4
+ * Mirrors the `langGet()` table in `routes/pages/language.tsx` — the two must
5
+ * change together. Kept in its own module, free of server imports, because the
6
+ * client needs the same answer: `viewPath()` uses it to decide whether a link
7
+ * carries the current view's language prefix, and getting it wrong in either
8
+ * direction is a bug you can see — a missing prefix drops the reader into
9
+ * another language, an extra one lands on a 404.
10
+ *
11
+ * Everything not listed here — the dashboard, auth, collection editors, and
12
+ * post permalinks — has one address site-wide.
13
+ */
14
+
15
+ /** Surfaces served per language at exactly this path. */
16
+ const PER_LANGUAGE_SURFACES = new Set([
17
+ "/",
18
+ "/feed",
19
+ "/latest",
20
+ "/featured",
21
+ "/archive",
22
+ "/search",
23
+ "/subscribe",
24
+ "/collections",
25
+ ]);
26
+
27
+ /**
28
+ * Surfaces whose sub-paths are all per-language too. `/collections/` is absent
29
+ * on purpose: aggregate selections and their feeds are, but nothing else
30
+ * nested under it is.
31
+ */
32
+ const PER_LANGUAGE_SURFACE_PREFIXES = ["/latest/", "/featured/", "/archive/"];
33
+
34
+ const COLLECTIONS_PREFIX = "/collections/";
35
+
36
+ /** `/collections/{selection}` and its feed, and nothing else under it. */
37
+ function isCollectionSelectionSurface(path: string): boolean {
38
+ const rest = path.slice(COLLECTIONS_PREFIX.length);
39
+ if (!rest) return false;
40
+
41
+ const segments = rest.split("/");
42
+ if (segments[0] === "") return false;
43
+ if (segments.length === 1) return true;
44
+ return segments.length === 2 && segments[1] === "feed";
45
+ }
46
+
47
+ /**
48
+ * Whether a path exists in every language's view.
49
+ *
50
+ * @param path - Internal app path, without any language prefix. A query string
51
+ * or fragment is ignored, so callers can pass a built link as-is.
52
+ * @returns True when `/ja{path}` is a page rather than a 404
53
+ * @example
54
+ * isPerLanguageSurface("/archive?media=any"); // true — /ja/archive exists
55
+ * isPerLanguageSurface("/collections/a+b/feed"); // true — /ja/collections/a+b/feed exists
56
+ * isPerLanguageSurface("/settings/language"); // false — the dash is one place
57
+ */
58
+ export function isPerLanguageSurface(path: string): boolean {
59
+ const bare = path.split(/[?#]/, 1)[0] ?? path;
60
+ if (PER_LANGUAGE_SURFACES.has(bare)) return true;
61
+ if (bare.startsWith(COLLECTIONS_PREFIX)) {
62
+ return isCollectionSelectionSurface(bare);
63
+ }
64
+ return PER_LANGUAGE_SURFACE_PREFIXES.some((prefix) =>
65
+ bare.startsWith(prefix),
66
+ );
67
+ }
@@ -9,7 +9,7 @@ import type { Context } from "hono";
9
9
  import type { Bindings, Post, PostView } from "../types.js";
10
10
  import type { AppVariables } from "../types/app-context.js";
11
11
  import { buildMediaMap } from "./media-helpers.js";
12
- import { createMediaContext, toPostView } from "./view.js";
12
+ import { createMediaContext, resolveDraftTailId, toPostView } from "./view.js";
13
13
 
14
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
15
 
@@ -82,7 +82,12 @@ function resolvePostSocialImage(
82
82
  interface PostDisplayOptions {
83
83
  isAuthenticated?: boolean;
84
84
  allowDraft?: boolean;
85
- /** Include draft members when assembling a thread for an authenticated preview. */
85
+ /**
86
+ * Force draft members into the assembled thread. The author already sees
87
+ * them whenever they are signed in; this only covers the draft-preview
88
+ * route, where the request is authenticated but `isAuthenticated` has
89
+ * already been forced to `true` for private-post access.
90
+ */
86
91
  includeDraftThread?: boolean;
87
92
  }
88
93
 
@@ -120,11 +125,16 @@ export async function assemblePostCardView(
120
125
  }
121
126
 
122
127
  const mediaCtx = createMediaContext(c.var.appConfig);
123
- const [rawMediaMap, collectionsMap, lastPostMap, aliasesMap] =
128
+ const [rawMediaMap, collectionsMap, lastPostMap, draftTailMap, aliasesMap] =
124
129
  await Promise.all([
125
130
  c.var.services.media.getByPostIds([post.id]),
126
131
  c.var.services.collections.getCollectionsByPostIds([post.id]),
127
- c.var.services.posts.getLastPostIdsByThread([post.threadId]),
132
+ c.var.services.posts.getThreadTailIds([post.threadId]),
133
+ c.var.isAuthenticated
134
+ ? c.var.services.posts.getThreadTailIds([post.threadId], {
135
+ includeDrafts: true,
136
+ })
137
+ : Promise.resolve(new Map<string, string>()),
128
138
  c.var.services.paths.getPostAliases([post.id]),
129
139
  ]);
130
140
 
@@ -144,6 +154,13 @@ export async function assemblePostCardView(
144
154
  aliasesMap.get(post.id)?.[0],
145
155
  );
146
156
 
157
+ const draftTailId = resolveDraftTailId(
158
+ post.threadId,
159
+ lastPostMap,
160
+ draftTailMap,
161
+ );
162
+ if (draftTailId) view.draftTailId = draftTailId;
163
+
147
164
  return view;
148
165
  }
149
166
 
@@ -170,12 +187,19 @@ export async function assemblePostPageDisplay(
170
187
  }
171
188
 
172
189
  const mediaCtx = createMediaContext(c.var.appConfig);
190
+ // Drafts render for the author so the thread they see ends where the server
191
+ // thinks it ends — otherwise a trailing draft silently blocks replies while
192
+ // staying invisible. Read the real auth state rather than the option, which
193
+ // callers force to `true` to bypass the private-post check.
194
+ const includeDrafts = Boolean(
195
+ options?.includeDraftThread || c.var.isAuthenticated,
196
+ );
173
197
  const threadPosts = (
174
198
  await c.var.services.posts.getThread(post.threadId)
175
199
  ).filter(
176
200
  (threadPost) =>
177
201
  threadPost.status === "published" ||
178
- (options?.includeDraftThread && threadPost.status === "draft"),
202
+ (includeDrafts && threadPost.status === "draft"),
179
203
  );
180
204
 
181
205
  const allPostIds =
@@ -219,13 +243,22 @@ export async function assemblePostPageDisplay(
219
243
  )
220
244
  : undefined;
221
245
 
222
- const socialImage = resolvePostSocialImage(postView, threadPostViews);
246
+ // Page metadata describes what a reader gets, so it never draws on drafts:
247
+ // an unpublished reply must not supply the og:image or bump the modified
248
+ // time. Falls back to the post itself when nothing in the thread is
249
+ // published (the draft-preview route, which is noindex anyway).
250
+ const publishedViews = threadPostViews?.filter(
251
+ (view) => view.status === "published",
252
+ );
253
+ const metaViews = publishedViews?.length ? publishedViews : [postView];
254
+
255
+ const socialImage = resolvePostSocialImage(postView, metaViews);
223
256
 
224
- const rootView = threadPostViews?.[0] ?? postView;
225
- const allViews = threadPostViews ?? [postView];
257
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- metaViews is never empty
258
+ const rootView = metaViews[0]!;
226
259
  // ISO 8601 strings from toISOString() are all UTC and zero-padded, so
227
260
  // lexical comparison matches chronological order.
228
- const articleModifiedTime = allViews
261
+ const articleModifiedTime = metaViews
229
262
  .map((p) => p.updatedAt)
230
263
  .reduce((latest, t) => (t > latest ? t : latest));
231
264
 
@@ -97,6 +97,25 @@ function getDescriptionCandidate(post: Post): string {
97
97
  return "";
98
98
  }
99
99
 
100
+ /**
101
+ * What to call a Post in a list, when it may have no title of its own.
102
+ *
103
+ * Notes are usually untitled, so falling back to the slug would show readers
104
+ * and authors a URL fragment where they expect a sentence. The chain is the
105
+ * same one browser titles and Open Graph tags already use: title, then the
106
+ * quoted text, then the summary, then the opening of the body, then a link's
107
+ * domain.
108
+ *
109
+ * @param post - The Post to name
110
+ * @returns A short plain-text label, or an empty string when there is nothing
111
+ * to derive one from
112
+ * @example
113
+ * getPostDisplayTitle(untitledNote); // "An untitled note about espresso."
114
+ */
115
+ export function getPostDisplayTitle(post: Post): string {
116
+ return getTitleCandidate(post);
117
+ }
118
+
100
119
  export interface PostMeta {
101
120
  title: string;
102
121
  description?: string;
@@ -9,9 +9,20 @@ import type { Context } from "hono";
9
9
  import type { Child } from "hono/jsx";
10
10
  import type { SiteLayoutProps } from "../types.js";
11
11
  import { SETTINGS_KEYS } from "./constants.js";
12
- import { BaseLayout, type ToastProps } from "../ui/layouts/BaseLayout.js";
12
+ import {
13
+ BaseLayout,
14
+ type PageFeedLink,
15
+ type ToastProps,
16
+ } from "../ui/layouts/BaseLayout.js";
13
17
  import { SiteLayout } from "../ui/layouts/SiteLayout.js";
14
18
  import type { NavigationData } from "./navigation.js";
19
+ import {
20
+ buildComposeLanguages,
21
+ buildLanguageSwitcher,
22
+ getViewLang,
23
+ type LanguageAlternate,
24
+ type LanguageSwitcherOption,
25
+ } from "./view-language.js";
15
26
 
16
27
  export interface RenderPublicPageOptions {
17
28
  /** Page title for <title> tag */
@@ -45,6 +56,23 @@ export interface RenderPublicPageOptions {
45
56
  * thread root).
46
57
  */
47
58
  canonicalHref?: string;
59
+ /**
60
+ * `hreflang` alternates for this page. List surfaces pass
61
+ * `buildSurfaceAlternates(c)`; a post passes its translations. Pages that
62
+ * exist once for the whole site pass nothing.
63
+ */
64
+ alternateLanguages?: LanguageAlternate[];
65
+ /**
66
+ * The feed this page publishes, for `<link rel="alternate">` autodiscovery.
67
+ * Set on pages with a feed of their own — a collection, the archive — so a
68
+ * reader's extension finds that feed rather than only the site-wide ones.
69
+ */
70
+ pageFeed?: PageFeedLink;
71
+ /**
72
+ * Language switcher entries. Defaults to the current surface in each of the
73
+ * site's languages; a post overrides it with its translations.
74
+ */
75
+ languageSwitcher?: LanguageSwitcherOption[];
48
76
  /** Navigation data (from getNavigationData) */
49
77
  navData: NavigationData;
50
78
  /** Page content JSX to render inside SiteLayout */
@@ -57,14 +85,24 @@ export interface RenderPublicPageOptions {
57
85
  toast?: ToastProps;
58
86
  /** Whether to render the shared compose dialog shell */
59
87
  showComposeDialog?: boolean;
60
- /** Override the site-wide crawler setting for sensitive utility pages. */
61
- noindex?: boolean;
88
+ /**
89
+ * Override the site-wide crawler setting for this page. `true` means
90
+ * `noindex, nofollow`; `"follow"` keeps the URL unindexed while leaving its
91
+ * links crawlable. See `BaseLayoutProps["noindex"]`.
92
+ */
93
+ noindex?: boolean | "follow";
62
94
  /** Whether to render the site header */
63
95
  showHeader?: boolean;
64
96
  /** Whether to render the home branding credit after the site footer */
65
97
  showHomeBranding?: boolean;
66
98
  /** When set, the mobile compose FAB pre-selects this collection. */
67
99
  composeCollectionId?: string;
100
+ /**
101
+ * Content language of the page the composer opens from, for its default.
102
+ * List surfaces leave this unset and get the view language; a post page
103
+ * passes its post's language, since its URL carries no view prefix.
104
+ */
105
+ composeContextLanguage?: string | null;
68
106
  }
69
107
 
70
108
  /**
@@ -99,6 +137,9 @@ export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
99
137
  articleModifiedTime,
100
138
  jsonLd,
101
139
  canonicalHref,
140
+ alternateLanguages,
141
+ pageFeed,
142
+ languageSwitcher,
102
143
  navData,
103
144
  content,
104
145
  pageChrome,
@@ -109,6 +150,7 @@ export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
109
150
  showHeader,
110
151
  showHomeBranding,
111
152
  composeCollectionId,
153
+ composeContextLanguage,
112
154
  } = options;
113
155
 
114
156
  // Use siteDescription as meta description fallback when not explicitly provided
@@ -123,9 +165,10 @@ export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
123
165
  links: navData.links,
124
166
  currentPath: navData.currentPath,
125
167
  sitePathPrefix: navData.sitePathPrefix,
168
+ basePath: navData.basePath,
126
169
  isAuthenticated: navData.isAuthenticated,
127
170
  collections: navData.collections,
128
- siteAvatarUrl: navData.siteAvatarUrl,
171
+ siteAvatarThumbUrl: navData.siteAvatarThumbUrl,
129
172
  showHeaderAvatar: navData.showHeaderAvatar,
130
173
  siteDescriptionHtml: navData.siteDescriptionHtml,
131
174
  siteFooterHtml: navData.siteFooterHtml,
@@ -141,11 +184,14 @@ export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
141
184
  allSettings[SETTINGS_KEYS.DISCOVERY_SLASH_COMMAND_AT],
142
185
  ),
143
186
  composeCollectionId,
187
+ languageSwitcher: languageSwitcher ?? buildLanguageSwitcher(c),
188
+ composeLanguages: buildComposeLanguages(c),
189
+ composeContextLanguage: composeContextLanguage ?? getViewLang(c),
144
190
  };
145
191
  const faviconUrl = appConfig.siteAvatarUrl || undefined;
146
192
  const faviconVersion = appConfig.faviconVersion || undefined;
147
- const resolvedNoindex = noindex ?? appConfig.noindex;
148
-
193
+ // `noindex` is passed straight through: BaseLayout reads the site-wide
194
+ // setting from this same context and is the single place the two resolve.
149
195
  return c.html(
150
196
  <BaseLayout
151
197
  title={title}
@@ -162,9 +208,11 @@ export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
162
208
  articleModifiedTime={articleModifiedTime}
163
209
  jsonLd={jsonLd}
164
210
  canonicalHref={canonicalHref}
211
+ alternateLanguages={alternateLanguages}
212
+ pageFeed={pageFeed}
165
213
  faviconUrl={faviconUrl}
166
214
  faviconVersion={faviconVersion}
167
- noindex={resolvedNoindex}
215
+ noindex={noindex}
168
216
  isAuthenticated={navData.isAuthenticated}
169
217
  toast={toast}
170
218
  >