@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Slug generation from free text.
3
+ *
4
+ * Lives apart from `url.ts` on purpose: `limax` drags the whole `pinyin-pro`
5
+ * dictionary (≈390 KB minified) into any bundle that imports it. Keeping the
6
+ * dictionary behind this single module lets the browser load it lazily via
7
+ * `client/lazy-slugify.ts`, while `url.ts` stays cheap enough for the public
8
+ * bundle. The build guard in `vite.shared.ts` fails the client build if
9
+ * `pinyin-pro` ever lands in an entry chunk again.
10
+ */
11
+
12
+ import limax from "limax";
13
+
14
+ import {
15
+ HAN_RE,
16
+ HANGUL_RE,
17
+ KANA_RE,
18
+ hangulToLatin,
19
+ kanaToRomaji,
20
+ } from "./translit.js";
21
+
22
+ const HAN_GLOBAL_RE = /\p{Script=Han}/gu;
23
+ const CONTENT_CHAR_RE = /[\p{L}\p{N}]/u;
24
+ const ASCII_ALNUM_RE = /[a-zA-Z0-9]/;
25
+
26
+ /**
27
+ * Minimum fraction of a title's letters/digits the transliteration pipeline
28
+ * must handle for the result to be usable. Below this, too much meaning is
29
+ * lost (e.g. mixed kanji+kana Japanese where the semantic core is kanji, or
30
+ * scripts limax drops entirely) and `slugify` returns "" so callers fall back
31
+ * to their random-ID path instead of shipping a garbled slug.
32
+ */
33
+ const SLUG_COVERAGE_THRESHOLD = 0.75;
34
+
35
+ /** Per-character limax probe cache: does this char transliterate to anything? */
36
+ const charCoverageCache = new Map<string, boolean>();
37
+
38
+ function isCharCovered(char: string): boolean {
39
+ if (ASCII_ALNUM_RE.test(char)) return true;
40
+ let covered = charCoverageCache.get(char);
41
+ if (covered === undefined) {
42
+ covered = limax(char, { tone: false }).length > 0;
43
+ charCoverageCache.set(char, covered);
44
+ }
45
+ return covered;
46
+ }
47
+
48
+ function slugCoverage(text: string, japanese: boolean): number {
49
+ let content = 0;
50
+ let covered = 0;
51
+ for (const char of text) {
52
+ if (!CONTENT_CHAR_RE.test(char)) continue;
53
+ content++;
54
+ if (KANA_RE.test(char) || HANGUL_RE.test(char)) {
55
+ covered++;
56
+ } else if (japanese && HAN_RE.test(char)) {
57
+ // Kanji in a kana-containing title is dropped, never pinyin-read.
58
+ } else if (isCharCovered(char)) {
59
+ covered++;
60
+ }
61
+ }
62
+ return content === 0 ? 0 : covered / content;
63
+ }
64
+
65
+ /**
66
+ * Converts text to a URL-friendly slug.
67
+ *
68
+ * Transliterates i18n text to Latin before slugifying: Korean hangul via
69
+ * Revised Romanization, Japanese kana via Hepburn romaji (both in-house, see
70
+ * `translit.ts`), everything else (Han → pinyin, Cyrillic, accents, …) via
71
+ * limax. When the title contains kana it is treated as Japanese and Han
72
+ * characters are dropped rather than misread as Chinese pinyin; Han without
73
+ * kana is treated as Chinese.
74
+ *
75
+ * Returns `""` when transliteration would lose too much of the title (see
76
+ * `SLUG_COVERAGE_THRESHOLD`) — every caller treats an empty base as "no
77
+ * usable title" and falls back to a random ID, which beats publishing a
78
+ * garbled slug.
79
+ *
80
+ * @param text - The text to convert to a slug
81
+ * @returns The slugified string, or `""` when no usable slug can be derived
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * slugify("Hello World! This is a Test.");
86
+ * // Returns: "hello-world-this-is-a-test"
87
+ *
88
+ * slugify("书评");
89
+ * // Returns: "shu-ping"
90
+ *
91
+ * slugify("안녕하세요 세계");
92
+ * // Returns: "annyeonghaseyo-segye"
93
+ *
94
+ * slugify("ハンバーガー");
95
+ * // Returns: "hanbaga"
96
+ *
97
+ * slugify("日本語のタイトルです");
98
+ * // Returns: "" (kanji carries the meaning and can't be transliterated)
99
+ * ```
100
+ */
101
+ export function slugify(text: string): string {
102
+ const japanese = KANA_RE.test(text);
103
+ let pre = text;
104
+ if (japanese) {
105
+ pre = kanaToRomaji(pre.replace(HAN_GLOBAL_RE, " "));
106
+ }
107
+ if (HANGUL_RE.test(pre)) {
108
+ pre = hangulToLatin(pre);
109
+ }
110
+ const base = limax(pre, { tone: false }).replace(/_/g, "-");
111
+ if (!base) return "";
112
+ return slugCoverage(text, japanese) >= SLUG_COVERAGE_THRESHOLD ? base : "";
113
+ }
@@ -1,4 +1,5 @@
1
1
  import { escapeHtml } from "./html.js";
2
+ import { getJantDocsUrl } from "./jant-docs.js";
2
3
  import {
3
4
  getAuthSecret,
4
5
  getHostedControlPlaneBaseUrl,
@@ -94,8 +95,7 @@ function getAuthSecretErrorHtml(kind: AuthSecretIssueKind): string {
94
95
  return renderConfigurationErrorPage({
95
96
  title: titleByKind[kind],
96
97
  bodyHtml: `${leadByKind[kind]}${runtimeInstructions}`,
97
- docsHref:
98
- "https://github.com/jant-me/jant/blob/main/docs/configuration.md#required",
98
+ docsHref: getJantDocsUrl("configuration#required"),
99
99
  });
100
100
  }
101
101
 
@@ -219,8 +219,7 @@ function getHostBasedConfigurationErrorHtml(
219
219
  return renderConfigurationErrorPage({
220
220
  title: "Hosted configuration is incomplete",
221
221
  bodyHtml: `<p>Jant is running with <code>SITE_RESOLUTION_MODE=host-based</code>, so hosted control-plane integration must be configured before the instance can serve requests.</p><ul>${itemsHtml}</ul>`,
222
- docsHref:
223
- "https://github.com/jant-me/jant/blob/main/docs/configuration.md#hosted-control-plane-integration-variables",
222
+ docsHref: getJantDocsUrl("configuration"),
224
223
  });
225
224
  }
226
225
 
@@ -243,8 +242,7 @@ export function getRuntimeConfigurationErrorPage(message: string): string {
243
242
  return renderConfigurationErrorPage({
244
243
  title: "Configuration Error",
245
244
  bodyHtml: `<p>${escapeHtml(message)}</p><p>Update your environment or instance data, then restart Jant.</p>`,
246
- docsHref:
247
- "https://github.com/jant-me/jant/blob/main/docs/configuration.md#site-resolution",
245
+ docsHref: getJantDocsUrl("configuration"),
248
246
  });
249
247
  }
250
248
 
@@ -432,6 +432,13 @@ export function createS3Driver(config: S3DriverConfig): StorageDriver {
432
432
  // uploaded object checksums during finalize, so only enable response
433
433
  // checksum verification when a request explicitly asks for it.
434
434
  responseChecksumValidation: "WHEN_REQUIRED",
435
+ // Presigning runs with an empty body, so the SDK's default
436
+ // "WHEN_SUPPORTED" request checksums bake CRC32-of-nothing
437
+ // (x-amz-checksum-crc32=AAAAAA==) into every presigned PUT URL. The
438
+ // browser then uploads real bytes and the storage provider rejects
439
+ // the mismatch. Only send a request checksum when the caller asked
440
+ // for one (presignPut passes ChecksumSHA256 explicitly).
441
+ requestChecksumCalculation: "WHEN_REQUIRED",
435
442
  });
436
443
  return {
437
444
  send: (cmd: unknown) => client.send(cmd as never),
@@ -22,6 +22,21 @@ interface TiptapNode {
22
22
  attrs?: Record<string, unknown>;
23
23
  }
24
24
 
25
+ /**
26
+ * Summary limits for titled, article-style posts — the excerpt is a teaser
27
+ * and the reader continues on the post's own page.
28
+ */
29
+ export const ARTICLE_SUMMARY_MAX_BLOCKS = 5;
30
+ export const ARTICLE_SUMMARY_MAX_CHARS = 500;
31
+ /**
32
+ * Larger limits for untitled notes — the body itself is the content, so the
33
+ * preview only exists to cap runaway length.
34
+ */
35
+ export const NOTE_SUMMARY_MAX_BLOCKS = 10;
36
+ export const NOTE_SUMMARY_MAX_CHARS = 1500;
37
+ /** Don't truncate an untitled note just to hide a tail under this many chars. */
38
+ export const NOTE_SUMMARY_MIN_HIDDEN_CHARS = 200;
39
+
25
40
  /**
26
41
  * Block node types that carry user-visible content for summary extraction.
27
42
  * Structural nodes (horizontalRule, moreBreak, image) are excluded.
@@ -348,3 +363,38 @@ export function extractSummaryHtml(
348
363
  breakAtIndex: lastSelectedIdx + 1,
349
364
  };
350
365
  }
366
+
367
+ /**
368
+ * Extract the summary the site's timeline shows for a post, at the boundary
369
+ * the timeline uses.
370
+ *
371
+ * Wraps {@link extractSummaryHtml} with the limits that decide where a card
372
+ * stops: a titled post is an article whose excerpt is a teaser, an untitled one
373
+ * is a note whose body is the content and only needs a cap. Callers that need
374
+ * that boundary — the feed renderer, the theme exporter — share this rather
375
+ * than repeating five numbers each.
376
+ *
377
+ * @param bodyJson - TipTap JSON body, or null/undefined for a post without one
378
+ * @param hasTitle - Whether the post has a title, which makes it an article
379
+ * @param renderOptions - Renderer options, notably the footnote namespace
380
+ * @returns The truncated HTML and whether content continues, or null when
381
+ * there is no TipTap document to truncate
382
+ * @example
383
+ * extractTimelineSummary(post.body, !!post.title, { namespace: post.id });
384
+ * // { html: "<p>Intro</p>", hasMore: true }
385
+ */
386
+ export function extractTimelineSummary(
387
+ bodyJson: string | null | undefined,
388
+ hasTitle: boolean,
389
+ renderOptions: TiptapRenderOptions = {},
390
+ ): { html: string; hasMore: boolean } | null {
391
+ if (!bodyJson) return null;
392
+ const result = extractSummaryHtml(
393
+ bodyJson,
394
+ hasTitle ? ARTICLE_SUMMARY_MAX_BLOCKS : NOTE_SUMMARY_MAX_BLOCKS,
395
+ hasTitle ? ARTICLE_SUMMARY_MAX_CHARS : NOTE_SUMMARY_MAX_CHARS,
396
+ hasTitle ? 0 : NOTE_SUMMARY_MIN_HIDDEN_CHARS,
397
+ renderOptions,
398
+ );
399
+ return result ? { html: result.html, hasMore: result.hasMore } : null;
400
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Thread Fold
3
+ *
4
+ * How much of a thread the timeline shows, and how much it folds away.
5
+ *
6
+ * A thread appears in two places that cannot select its posts the same way.
7
+ * The site paints a page of timeline items and must not load whole threads to
8
+ * do it, so `getThreadTimelineContext` ranks replies in SQL and hands the
9
+ * buckets down. The feed already holds every reply — `<content>` carries the
10
+ * whole chain — so it slices the array it has. Two access paths, one rule.
11
+ *
12
+ * This module owns what they share: the thresholds, and the arithmetic that
13
+ * turns a selection into a hidden count. Whoever changes the shape of the fold
14
+ * changes it here, and both surfaces move together.
15
+ *
16
+ * ## The fold
17
+ *
18
+ * ```
19
+ * root · reply 1 · reply 2 · [N more posts] · 3rd-newest · 2nd-newest · newest
20
+ * ```
21
+ *
22
+ * Six posts at most. A thread that fits arrives whole, with no gap at all —
23
+ * the gap appears only once something is actually left out.
24
+ *
25
+ * ## Where the gap points
26
+ *
27
+ * **At the first post it hides.** The link stands for a run of posts, and it
28
+ * opens the earliest of them, so a reader following "3 more posts" lands on
29
+ * the first of those three.
30
+ *
31
+ * It used to open the last *visible* post instead, on the reasoning that the
32
+ * detail page would then start just above the missing stretch, like a
33
+ * bookmark. That was a deliberate choice and it reads well on the site — but
34
+ * it makes the link mean something other than what its label says, and the
35
+ * feed cannot follow it: `<jant:thread>`'s `gap` attribute is documented as
36
+ * the first hidden post, and a consumer joining it against the `<jant:post>`
37
+ * rows would be handed a post that is on screen. One rule beats two, so both
38
+ * surfaces open the first hidden post.
39
+ */
40
+
41
+ import type { Post, PostView } from "../types.js";
42
+
43
+ /**
44
+ * Replies kept from the start of a thread, as context for the newest post.
45
+ *
46
+ * Also fixes where the SQL path's rank cut-off falls, so the two selections
47
+ * stay the same fold.
48
+ */
49
+ export const THREAD_LEADING_REPLIES = 2;
50
+
51
+ /**
52
+ * Replies kept from the end of a thread, the newest one included — it is the
53
+ * hero, and the two before it are its immediate context.
54
+ */
55
+ export const THREAD_TRAILING_REPLIES = 3;
56
+
57
+ /** Anything with an id, which is all the dedupe needs. */
58
+ type Identified = Pick<Post, "id"> | Pick<PostView, "id">;
59
+
60
+ /**
61
+ * How many replies a fold leaves unshown.
62
+ *
63
+ * Deduplicated, because on a short thread the buckets overlap: with four
64
+ * replies the first is also the third-newest, and counting it twice would
65
+ * report posts hidden that are on screen.
66
+ *
67
+ * @param selection - The replies the fold shows, and the thread's total
68
+ * @returns Replies the fold hides, never negative
69
+ * @example
70
+ * getThreadHiddenCount({ leadingReplies: [r1, r2], trailingReplies: [r6, r7],
71
+ * latestReply: r8, totalReplyCount: 8 }); // 3
72
+ */
73
+ export function getThreadHiddenCount({
74
+ leadingReplies,
75
+ trailingReplies,
76
+ latestReply,
77
+ totalReplyCount,
78
+ }: {
79
+ leadingReplies: Identified[];
80
+ trailingReplies: Identified[];
81
+ latestReply: Identified;
82
+ totalReplyCount: number;
83
+ }): number {
84
+ const shown = new Set(
85
+ [...leadingReplies, ...trailingReplies, latestReply].map((post) => post.id),
86
+ );
87
+ return Math.max(0, totalReplyCount - shown.size);
88
+ }
89
+
90
+ /** What the fold shows of a thread, and what it hides. */
91
+ export interface ThreadFold<T> {
92
+ /** Earliest replies, kept as context above the gap. */
93
+ leadingReplies: T[];
94
+ /** Replies just before the newest, kept as its immediate context. */
95
+ trailingReplies: T[];
96
+ /** The newest reply — the hero the timeline builds the card around. */
97
+ latestReply: T;
98
+ /** First reply the fold hides, which is where the gap link points. */
99
+ firstHiddenReply: T | null;
100
+ /** How many replies the fold hides. */
101
+ hiddenCount: number;
102
+ }
103
+
104
+ /**
105
+ * Fold a thread whose replies are already in hand.
106
+ *
107
+ * Takes the same slices the SQL path ranks for: the first
108
+ * `THREAD_LEADING_REPLIES`, and the last `THREAD_TRAILING_REPLIES` with the
109
+ * newest as the hero. `getPublishedThreads` orders replies by
110
+ * `(threadId, createdAt, id)` ascending, which is the window function's own
111
+ * `ORDER BY`, so position in this array *is* the rank — no approximation.
112
+ *
113
+ * @param replies - Every published reply, oldest first
114
+ * @returns The fold, or null when the thread has no replies
115
+ * @example
116
+ * foldThreadReplies([r1, r2, r3, r4, r5, r6, r7, r8]);
117
+ * // leading [r1, r2], trailing [r6, r7], latest r8, firstHidden r3, hidden 3
118
+ */
119
+ export function foldThreadReplies<T extends Identified>(
120
+ replies: T[],
121
+ ): ThreadFold<T> | null {
122
+ const latestReply = replies.at(-1);
123
+ if (!latestReply) return null;
124
+
125
+ // A short thread's windows overlap, and the hero is inside both of them on
126
+ // the shortest ones: with a single reply, that reply is the newest *and* the
127
+ // whole leading window. Each list therefore drops whatever an earlier one
128
+ // already shows, hero first — the same order `ThreadPreview` dedupes in when
129
+ // it walks the buckets the SQL path hands it.
130
+ const leadingReplies = replies
131
+ .slice(0, THREAD_LEADING_REPLIES)
132
+ .filter((reply) => reply.id !== latestReply.id);
133
+
134
+ const leadingIds = new Set(leadingReplies.map((reply) => reply.id));
135
+ const trailingReplies = replies
136
+ .slice(-THREAD_TRAILING_REPLIES, -1)
137
+ .filter(
138
+ (reply) => reply.id !== latestReply.id && !leadingIds.has(reply.id),
139
+ );
140
+
141
+ const shown = new Set([
142
+ ...leadingIds,
143
+ ...trailingReplies.map((reply) => reply.id),
144
+ latestReply.id,
145
+ ]);
146
+
147
+ return {
148
+ leadingReplies,
149
+ trailingReplies,
150
+ latestReply,
151
+ // In thread order, so the gap link lands on the earliest post it stands
152
+ // for rather than somewhere in the middle of the run it hides.
153
+ firstHiddenReply: replies.find((reply) => !shown.has(reply.id)) ?? null,
154
+ hiddenCount: getThreadHiddenCount({
155
+ leadingReplies,
156
+ trailingReplies,
157
+ latestReply,
158
+ totalReplyCount: replies.length,
159
+ }),
160
+ };
161
+ }
package/src/lib/time.ts CHANGED
@@ -223,3 +223,35 @@ export function formatYearMonth(timestamp: number, timeZone = "UTC"): string {
223
223
  const month = parts.find((part) => part.type === "month")?.value ?? "01";
224
224
  return `${year}-${month}`;
225
225
  }
226
+
227
+ /**
228
+ * Formats a "YYYY-MM" grouping key as a readable month label.
229
+ *
230
+ * The label is always en-US, matching the archive's other month copy — the
231
+ * grid header and the list marker read from this one function so their labels
232
+ * cannot drift apart.
233
+ *
234
+ * @param yearMonth - Year-month string in "YYYY-MM" format
235
+ * @returns Month label such as "February 2024", or null when the key is malformed
236
+ *
237
+ * @example
238
+ * ```ts
239
+ * const label = formatYearMonthLabel("2024-02");
240
+ * // Returns: "February 2024"
241
+ * ```
242
+ */
243
+ export function formatYearMonthLabel(yearMonth: string): string | null {
244
+ const [year, month] = yearMonth.split("-");
245
+ if (!year || !month) return null;
246
+
247
+ const yearNumber = parseInt(year, 10);
248
+ const monthNumber = parseInt(month, 10);
249
+ if (!Number.isInteger(yearNumber) || !Number.isInteger(monthNumber)) {
250
+ return null;
251
+ }
252
+
253
+ return new Date(yearNumber, monthNumber - 1).toLocaleDateString("en-US", {
254
+ year: "numeric",
255
+ month: "long",
256
+ });
257
+ }
@@ -14,10 +14,25 @@ import type {
14
14
  } from "../types.js";
15
15
  import type { AppVariables } from "../types/app-context.js";
16
16
  import { buildMediaMap } from "./media-helpers.js";
17
- import { createMediaContext, toPostView } from "./view.js";
17
+ import { createMediaContext, resolveDraftTailId, toPostView } from "./view.js";
18
+ import { getViewLang } from "./view-language.js";
19
+ import { toPublicPath } from "./url.js";
18
20
 
19
21
  type Env = { Bindings: Bindings; Variables: AppVariables };
20
22
 
23
+ /**
24
+ * Language filter for the view the request is being served in.
25
+ *
26
+ * Read from the context rather than passed in, so a caller cannot forget it
27
+ * and quietly leak another language's posts into a language view.
28
+ *
29
+ * @param c - Hono context
30
+ * @returns Canonical tag to filter on, or undefined on the root view
31
+ */
32
+ function viewLangFilter(c: Context<Env>): string | undefined {
33
+ return getViewLang(c) ?? undefined;
34
+ }
35
+
21
36
  /**
22
37
  * Result from assembling a timeline page.
23
38
  */
@@ -51,13 +66,32 @@ async function buildTimelineItems(
51
66
  // Batch load media, collections, and latest-reply contexts in parallel
52
67
  const postIds = posts.map((p) => p.id);
53
68
  const mediaCtx = createMediaContext(c.var.appConfig);
54
- const [rawMediaMap, collectionsMap, threadContexts, aliasesMap] =
55
- await Promise.all([
56
- c.var.services.media.getByPostIds(postIds),
57
- c.var.services.collections.getCollectionsByPostIds(postIds),
58
- c.var.services.posts.getThreadTimelineContext(postIds),
59
- c.var.services.paths.getPostAliases(postIds),
60
- ]);
69
+ const threadIds = posts.map((p) => p.threadId);
70
+ const [
71
+ rawMediaMap,
72
+ collectionsMap,
73
+ threadContexts,
74
+ aliasesMap,
75
+ publishedTails,
76
+ draftInclusiveTails,
77
+ ] = await Promise.all([
78
+ c.var.services.media.getByPostIds(postIds),
79
+ c.var.services.collections.getCollectionsByPostIds(postIds),
80
+ c.var.services.posts.getThreadTimelineContext(postIds),
81
+ c.var.services.paths.getPostAliases(postIds),
82
+ // The feed never renders drafts — it would turn a reading surface into a
83
+ // workspace. So the author's trailing draft stays hidden here, and the
84
+ // reply affordance carries its ID instead of silently offering a reply
85
+ // the server will refuse.
86
+ c.var.isAuthenticated
87
+ ? c.var.services.posts.getThreadTailIds(threadIds)
88
+ : Promise.resolve(new Map<string, string>()),
89
+ c.var.isAuthenticated
90
+ ? c.var.services.posts.getThreadTailIds(threadIds, {
91
+ includeDrafts: true,
92
+ })
93
+ : Promise.resolve(new Map<string, string>()),
94
+ ]);
61
95
  const mediaMap = buildMediaMap(
62
96
  rawMediaMap,
63
97
  mediaCtx.r2PublicUrl,
@@ -69,11 +103,15 @@ async function buildTimelineItems(
69
103
 
70
104
  // Batch load media for the bounded leading/trailing thread context.
71
105
  const contextPostIds = new Set<string>();
106
+ const gapPostIds = new Set<string>();
72
107
  for (const ctx of threadContexts.values()) {
73
108
  contextPostIds.add(ctx.latestReply.id);
74
109
  for (const reply of [...ctx.leadingReplies, ...ctx.trailingReplies]) {
75
110
  contextPostIds.add(reply.id);
76
111
  }
112
+ // The gap target is never rendered, so it needs no media or collections —
113
+ // only the alias that decides its address.
114
+ if (ctx.firstHiddenReply) gapPostIds.add(ctx.firstHiddenReply.id);
77
115
  }
78
116
  const [contextMediaMap, contextCollectionsMap, contextAliasesMap] =
79
117
  contextPostIds.size > 0
@@ -93,7 +131,12 @@ async function buildTimelineItems(
93
131
  c.var.services.collections.getCollectionsByPostIds([
94
132
  ...contextPostIds,
95
133
  ]),
96
- c.var.services.paths.getPostAliases([...contextPostIds]),
134
+ // Gap targets ride along here and nowhere else: their address is
135
+ // all the gap link needs.
136
+ c.var.services.paths.getPostAliases([
137
+ ...contextPostIds,
138
+ ...gapPostIds,
139
+ ]),
97
140
  ])
98
141
  : [new Map(), new Map(), new Map<string, string[]>()];
99
142
 
@@ -113,6 +156,12 @@ async function buildTimelineItems(
113
156
  firstAlias(post.id),
114
157
  );
115
158
 
159
+ const draftTailId = resolveDraftTailId(
160
+ post.threadId,
161
+ publishedTails,
162
+ draftInclusiveTails,
163
+ );
164
+
116
165
  const threadCtx = threadContexts.get(post.id);
117
166
 
118
167
  if (threadCtx) {
@@ -142,9 +191,10 @@ async function buildTimelineItems(
142
191
  },
143
192
  mediaCtx,
144
193
  contextCollectionsMap.get(threadCtx.latestReply.id),
145
- true, // latestReply is the last post in the thread
194
+ true, // latestReply is the last published post in the thread
146
195
  firstContextAlias(threadCtx.latestReply.id),
147
196
  );
197
+ if (draftTailId) latestReplyView.draftTailId = draftTailId;
148
198
 
149
199
  return {
150
200
  post: postView,
@@ -152,11 +202,21 @@ async function buildTimelineItems(
152
202
  leadingReplies: leadingReplyViews,
153
203
  trailingReplies: trailingReplyViews,
154
204
  latestReply: latestReplyView,
205
+ gapHref: threadCtx.firstHiddenReply
206
+ ? toPublicPath(
207
+ `/${firstContextAlias(threadCtx.firstHiddenReply.id) ?? threadCtx.firstHiddenReply.slug}`,
208
+ mediaCtx.sitePathPrefix,
209
+ )
210
+ : undefined,
155
211
  totalReplyCount: threadCtx.totalReplyCount,
156
212
  },
157
213
  };
158
214
  }
159
215
 
216
+ // No published replies, so the root carries the reply affordance — and any
217
+ // trailing draft with it.
218
+ if (draftTailId) postView.draftTailId = draftTailId;
219
+
160
220
  return { post: postView };
161
221
  });
162
222
  }
@@ -314,18 +374,22 @@ export async function assembleTimeline(
314
374
  const excludePrivate = !(options?.isAuthenticated ?? false);
315
375
 
316
376
  // Count + list are independent — run in parallel
377
+ const lang = viewLangFilter(c);
378
+
317
379
  const [totalCount, posts] = await Promise.all([
318
380
  c.var.services.posts.count({
319
381
  status: "published",
320
382
  excludeReplies: true,
321
383
  excludeLatestHidden: true,
322
384
  excludePrivate,
385
+ lang,
323
386
  }),
324
387
  c.var.services.posts.list({
325
388
  status: "published",
326
389
  excludeReplies: true,
327
390
  excludeLatestHidden: true,
328
391
  excludePrivate,
392
+ lang,
329
393
  limit: pageSize,
330
394
  offset,
331
395
  }),
@@ -390,14 +454,18 @@ export async function assembleFeaturedTimeline(
390
454
  const offset = (page - 1) * pageSize;
391
455
  const excludePrivate = !(options?.isAuthenticated ?? false);
392
456
 
457
+ const lang = viewLangFilter(c);
458
+
393
459
  const [totalCount, rootIds] = await Promise.all([
394
460
  c.var.services.posts.countFeaturedThreadRoots({
395
461
  status: "published",
396
462
  excludePrivate,
463
+ lang,
397
464
  }),
398
465
  c.var.services.posts.listFeaturedThreadRootIds({
399
466
  status: "published",
400
467
  excludePrivate,
468
+ lang,
401
469
  limit: pageSize,
402
470
  offset,
403
471
  }),
@@ -452,6 +520,7 @@ export async function assembleCollectionTimeline(
452
520
  const page = Math.max(1, options.page ?? 1);
453
521
  const offset = (page - 1) * pageSize;
454
522
  const excludePrivate = !(options.isAuthenticated ?? false);
523
+ const lang = viewLangFilter(c);
455
524
 
456
525
  const [totalCount, rootIds] = await Promise.all([
457
526
  c.var.services.posts.countCollectionThreadRootsForCollections(
@@ -459,6 +528,7 @@ export async function assembleCollectionTimeline(
459
528
  {
460
529
  status: "published",
461
530
  excludePrivate,
531
+ lang,
462
532
  },
463
533
  ),
464
534
  c.var.services.posts.listCollectionThreadRootIdsForCollections(
@@ -466,6 +536,7 @@ export async function assembleCollectionTimeline(
466
536
  {
467
537
  status: "published",
468
538
  excludePrivate,
539
+ lang,
469
540
  sortOrder: options.sortOrder,
470
541
  limit: pageSize,
471
542
  offset,
@@ -14,7 +14,7 @@ import {
14
14
  import { renderEmbedFromAttrs } from "./embed-render.js";
15
15
  import { escapeHtml } from "./html.js";
16
16
  import { renderPublishedImageFigure } from "./rich-image.js";
17
- import { sanitizeUrl } from "./url.js";
17
+ import { sanitizeRichTextHref } from "./url.js";
18
18
 
19
19
  interface TiptapMark {
20
20
  type: string;
@@ -48,8 +48,7 @@ export interface TiptapRenderOptions {
48
48
  }
49
49
 
50
50
  export type TiptapRenderResult =
51
- | { ok: true; html: string }
52
- | { ok: false; error: string };
51
+ { ok: true; html: string } | { ok: false; error: string };
53
52
 
54
53
  interface FootnoteReferencePlan {
55
54
  definitionId: string;
@@ -324,7 +323,9 @@ const MARK_RENDERERS: Record<string, MarkRenderer> = {
324
323
  strike: (html) => `<s>${html}</s>`,
325
324
  code: (html) => `<code>${html}</code>`,
326
325
  link: (html, mark) => {
327
- const href = escapeHtml(sanitizeUrl(getStringAttr(mark.attrs, "href")));
326
+ const href = escapeHtml(
327
+ sanitizeRichTextHref(getStringAttr(mark.attrs, "href")),
328
+ );
328
329
  const target = getStringAttr(mark.attrs, "target");
329
330
  const targetAttr = target ? ` target="${escapeHtml(target)}"` : "";
330
331
  const relAttr = target ? ' rel="noopener noreferrer"' : "";