@jant/core 0.6.14 → 0.6.16

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 (350) hide show
  1. package/bin/commands/import-site.js +88 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-C75Zc8dD.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
  13. package/dist/client/.vite/manifest.json +8 -8
  14. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
  16. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  17. package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
  18. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  19. package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
  20. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  21. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  22. package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
  23. package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
  24. package/dist/index.js +5 -5
  25. package/dist/node.js +7 -7
  26. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  27. package/package.json +11 -10
  28. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  29. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  30. package/src/__tests__/export-hugo-build.test.ts +63 -4
  31. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  32. package/src/__tests__/export-service.test.ts +142 -9
  33. package/src/__tests__/helpers/app.ts +3 -0
  34. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  35. package/src/__tests__/import-site-command.test.ts +65 -0
  36. package/src/__tests__/mise-config.test.ts +22 -0
  37. package/src/__tests__/vite-config.test.ts +45 -0
  38. package/src/app.tsx +16 -3
  39. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  40. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  41. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  42. package/src/client/__tests__/compose-bridge.test.ts +316 -2
  43. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  44. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  45. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  46. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  47. package/src/client/__tests__/toast.test.ts +82 -1
  48. package/src/client/collection-created-notice.ts +66 -0
  49. package/src/client/collection-form-bridge.ts +17 -0
  50. package/src/client/collection-navigation.ts +44 -0
  51. package/src/client/collection-page-actions.ts +57 -1
  52. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  53. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  54. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  55. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
  56. package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
  57. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
  58. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  59. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  60. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  61. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  62. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  63. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  64. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  65. package/src/client/components/collection-manager-types.ts +9 -3
  66. package/src/client/components/compose-format-convert.ts +5 -1
  67. package/src/client/components/compose-types.ts +55 -3
  68. package/src/client/components/jant-collection-directory.ts +165 -30
  69. package/src/client/components/jant-command-palette.ts +14 -7
  70. package/src/client/components/jant-compose-dialog.ts +1542 -694
  71. package/src/client/components/jant-compose-editor.ts +237 -99
  72. package/src/client/components/jant-compose-fullscreen.ts +27 -9
  73. package/src/client/components/jant-config-editor.ts +722 -0
  74. package/src/client/components/jant-media-lightbox.ts +100 -18
  75. package/src/client/components/jant-nav-manager.ts +1283 -54
  76. package/src/client/components/jant-post-form.ts +3 -3
  77. package/src/client/components/jant-post-menu.ts +177 -95
  78. package/src/client/components/nav-manager-types.ts +51 -2
  79. package/src/client/components/post-form-types.ts +1 -1
  80. package/src/client/compose-bridge.ts +147 -123
  81. package/src/client/compose-launch.ts +13 -0
  82. package/src/client/compose-shortcuts.ts +5 -1
  83. package/src/client/feed-video-player.ts +62 -2
  84. package/src/client/footnote-rail.ts +478 -0
  85. package/src/client/hydrate-partial.ts +2 -0
  86. package/src/client/media-lightbox-events.ts +63 -0
  87. package/src/client/post-refresh.ts +135 -0
  88. package/src/client/site-header-fragment.ts +38 -0
  89. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  90. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  91. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  92. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  93. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  94. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  95. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  96. package/src/client/tiptap/bubble-menu.ts +88 -10
  97. package/src/client/tiptap/continuous-lists.ts +102 -0
  98. package/src/client/tiptap/create-editor.ts +3 -0
  99. package/src/client/tiptap/extensions.ts +14 -5
  100. package/src/client/tiptap/link-input-rules.ts +4 -4
  101. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  102. package/src/client/tiptap/slash-commands.ts +8 -1
  103. package/src/client/tiptap/structural-keymap.ts +211 -0
  104. package/src/client/tiptap/tab-indent.ts +8 -22
  105. package/src/client/tiptap/table-control-labels.ts +30 -0
  106. package/src/client/tiptap/table-controls.ts +537 -0
  107. package/src/client/tiptap/table-size-picker.ts +254 -0
  108. package/src/client/toast.ts +133 -39
  109. package/src/client-auth.ts +1 -0
  110. package/src/client.ts +1 -0
  111. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  112. package/src/db/__tests__/d1-query.test.ts +43 -1
  113. package/src/db/__tests__/migration-runner.test.ts +63 -0
  114. package/src/db/__tests__/migrations.test.ts +469 -0
  115. package/src/db/__tests__/thread-activity.test.ts +113 -0
  116. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  117. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  118. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  119. package/src/db/migrations/0028_superb_post.sql +2 -0
  120. package/src/db/migrations/0029_boring_magma.sql +1 -0
  121. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  122. package/src/db/migrations/0031_many_ego.sql +3 -0
  123. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  124. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  125. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  126. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  127. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  128. package/src/db/migrations/meta/_journal.json +35 -0
  129. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  130. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  131. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  132. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  133. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  134. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  135. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  136. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  137. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  138. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  139. package/src/db/migrations/pg/meta/_journal.json +35 -0
  140. package/src/db/pg/schema.ts +46 -20
  141. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  142. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  143. package/src/db/schema.ts +48 -20
  144. package/src/db/thread-activity.ts +101 -0
  145. package/src/i18n/__tests__/detect.test.ts +8 -2
  146. package/src/i18n/detect.ts +14 -5
  147. package/src/i18n/locales/public/en.po +222 -51
  148. package/src/i18n/locales/public/en.ts +1 -1
  149. package/src/i18n/locales/public/zh-Hans.po +216 -45
  150. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  151. package/src/i18n/locales/public/zh-Hant.po +216 -45
  152. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  153. package/src/i18n/locales/settings/en.po +513 -10
  154. package/src/i18n/locales/settings/en.ts +1 -1
  155. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  156. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  157. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  158. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  159. package/src/lib/__tests__/api-settings.test.ts +299 -0
  160. package/src/lib/__tests__/constants.test.ts +5 -0
  161. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  162. package/src/lib/__tests__/feed.test.ts +168 -0
  163. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  164. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  165. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  166. package/src/lib/__tests__/markdown.test.ts +24 -6
  167. package/src/lib/__tests__/navigation.test.ts +59 -2
  168. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  169. package/src/lib/__tests__/post-display.test.ts +64 -0
  170. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  171. package/src/lib/__tests__/slug.test.ts +18 -0
  172. package/src/lib/__tests__/summary.test.ts +82 -0
  173. package/src/lib/__tests__/timeline.test.ts +109 -70
  174. package/src/lib/__tests__/timezones.test.ts +31 -3
  175. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  176. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  177. package/src/lib/__tests__/translit.test.ts +87 -0
  178. package/src/lib/__tests__/url.test.ts +53 -0
  179. package/src/lib/__tests__/view.test.ts +53 -0
  180. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  181. package/src/lib/api-settings.ts +239 -12
  182. package/src/lib/collection-sort.ts +7 -5
  183. package/src/lib/constants.ts +2 -0
  184. package/src/lib/feed-path.ts +25 -0
  185. package/src/lib/feed-policy.ts +64 -0
  186. package/src/lib/feed.ts +113 -11
  187. package/src/lib/footnotes.ts +264 -0
  188. package/src/lib/github-sync-site-config.ts +2 -0
  189. package/src/lib/hugo-markdown.ts +15 -3
  190. package/src/lib/jant-branding.ts +1 -0
  191. package/src/lib/link-preview.ts +25 -0
  192. package/src/lib/markdown-manager.ts +193 -5
  193. package/src/lib/markdown.ts +10 -3
  194. package/src/lib/navigation.ts +16 -3
  195. package/src/lib/post-body-html.ts +131 -0
  196. package/src/lib/post-display.ts +47 -8
  197. package/src/lib/render.tsx +9 -2
  198. package/src/lib/resolve-config.ts +100 -16
  199. package/src/lib/schemas.ts +133 -1
  200. package/src/lib/settings-paths.ts +5 -0
  201. package/src/lib/site-skill.ts +78 -0
  202. package/src/lib/summary.ts +16 -5
  203. package/src/lib/timeline.ts +137 -131
  204. package/src/lib/timezones.ts +54 -14
  205. package/src/lib/tiptap-render.ts +359 -83
  206. package/src/lib/translit.ts +288 -0
  207. package/src/lib/url.ts +123 -12
  208. package/src/lib/view.ts +58 -20
  209. package/src/lib/worker-response-cache.ts +11 -18
  210. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  211. package/src/middleware/config.ts +2 -2
  212. package/src/middleware/public-content-access.ts +87 -0
  213. package/src/node/__tests__/cli-export.test.ts +14 -0
  214. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  215. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  216. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  217. package/src/node/index.ts +2 -1
  218. package/src/preset.css +16 -2
  219. package/src/routes/__tests__/compose.test.ts +51 -0
  220. package/src/routes/__tests__/site-skill.test.ts +112 -0
  221. package/src/routes/api/__tests__/collections.test.ts +91 -19
  222. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  223. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  224. package/src/routes/api/__tests__/palette.test.ts +44 -0
  225. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  226. package/src/routes/api/__tests__/settings.test.ts +217 -0
  227. package/src/routes/api/collections.ts +22 -26
  228. package/src/routes/api/export.ts +2 -0
  229. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  230. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  231. package/src/routes/api/internal/post-body-html.ts +43 -0
  232. package/src/routes/api/internal/sites.ts +24 -0
  233. package/src/routes/api/nav-items.ts +24 -1
  234. package/src/routes/api/posts.ts +14 -10
  235. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  236. package/src/routes/api/public/__tests__/posts.test.ts +246 -0
  237. package/src/routes/api/public/archive.ts +4 -0
  238. package/src/routes/api/public/posts.ts +48 -24
  239. package/src/routes/api/search.ts +3 -0
  240. package/src/routes/api/settings.ts +33 -0
  241. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  242. package/src/routes/compose.tsx +8 -3
  243. package/src/routes/dash/settings.tsx +45 -2
  244. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  245. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  246. package/src/routes/feed/feed.ts +23 -14
  247. package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
  248. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  249. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  250. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  251. package/src/routes/pages/__tests__/preview.test.ts +176 -0
  252. package/src/routes/pages/__tests__/search.test.ts +69 -0
  253. package/src/routes/pages/archive.tsx +90 -22
  254. package/src/routes/pages/collection.tsx +32 -19
  255. package/src/routes/pages/collections.tsx +6 -1
  256. package/src/routes/pages/page.tsx +86 -11
  257. package/src/routes/site-skill.ts +18 -0
  258. package/src/services/__tests__/auth.test.ts +7 -5
  259. package/src/services/__tests__/collection.test.ts +190 -44
  260. package/src/services/__tests__/navigation.test.ts +229 -21
  261. package/src/services/__tests__/post-timeline.test.ts +249 -39
  262. package/src/services/__tests__/post.test.ts +844 -5
  263. package/src/services/__tests__/search.test.ts +48 -1
  264. package/src/services/__tests__/settings.test.ts +8 -0
  265. package/src/services/__tests__/site-admin.test.ts +121 -1
  266. package/src/services/auth.ts +2 -2
  267. package/src/services/bootstrap.ts +3 -2
  268. package/src/services/collection.ts +224 -167
  269. package/src/services/export-theme/assets/client-site.css +1 -1
  270. package/src/services/export-theme/assets/client-site.js +46 -25
  271. package/src/services/export-theme/layouts/_default/list.html +18 -4
  272. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  273. package/src/services/export-theme/layouts/featured/list.html +5 -4
  274. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  275. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  276. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  277. package/src/services/export-theme/styles/main.css +30 -2
  278. package/src/services/export.ts +94 -85
  279. package/src/services/mcp.ts +37 -27
  280. package/src/services/media.ts +1 -1
  281. package/src/services/navigation.ts +185 -46
  282. package/src/services/path.ts +4 -1
  283. package/src/services/post.ts +1142 -342
  284. package/src/services/search.ts +17 -2
  285. package/src/services/settings.ts +2 -10
  286. package/src/services/site-admin.ts +63 -43
  287. package/src/style-cjk-jp.css +1 -1
  288. package/src/style-cjk-kr.css +1 -1
  289. package/src/style-cjk-tc.css +1 -1
  290. package/src/style-cjk.css +1 -1
  291. package/src/styles/components.css +540 -14
  292. package/src/styles/site-media.css +111 -19
  293. package/src/styles/tokens.css +76 -10
  294. package/src/styles/ui.css +2227 -1347
  295. package/src/types/bindings.ts +3 -0
  296. package/src/types/config.ts +243 -12
  297. package/src/types/constants.ts +22 -1
  298. package/src/types/entities.ts +14 -8
  299. package/src/types/operations.ts +18 -10
  300. package/src/types/props.ts +20 -2
  301. package/src/types/raw-assets.d.ts +5 -0
  302. package/src/types/views.ts +22 -4
  303. package/src/ui/__tests__/font-themes.test.ts +42 -30
  304. package/src/ui/compose/ComposeDialog.tsx +109 -19
  305. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  306. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  307. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  308. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  309. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  310. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  311. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  312. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  313. package/src/ui/feed/LinkCard.tsx +2 -12
  314. package/src/ui/feed/LinkPreview.tsx +2 -7
  315. package/src/ui/feed/NoteCard.tsx +10 -20
  316. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  317. package/src/ui/feed/QuoteCard.tsx +2 -12
  318. package/src/ui/feed/ThreadPreview.tsx +26 -34
  319. package/src/ui/feed/TimelineFeed.tsx +2 -2
  320. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  321. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  322. package/src/ui/feed/thread-preview-state.ts +5 -7
  323. package/src/ui/font-themes.ts +74 -26
  324. package/src/ui/layouts/BaseLayout.tsx +22 -8
  325. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  326. package/src/ui/pages/ArchivePage.tsx +131 -24
  327. package/src/ui/pages/CollectionPage.tsx +101 -22
  328. package/src/ui/pages/CollectionsPage.tsx +3 -1
  329. package/src/ui/pages/PostPage.tsx +39 -12
  330. package/src/ui/pages/SearchPage.tsx +6 -14
  331. package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
  332. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  333. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  334. package/src/ui/shared/CollectionsManager.tsx +17 -7
  335. package/src/ui/shared/DraftPreviewBar.tsx +49 -0
  336. package/src/ui/shared/HomePageBranding.tsx +2 -2
  337. package/src/ui/shared/MediaGallery.tsx +1 -0
  338. package/src/ui/shared/PostFooter.tsx +38 -13
  339. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  340. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  341. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  342. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  343. package/src/ui/shared/collection-management-labels.ts +33 -2
  344. package/src/ui/shared/custom-icons.ts +5 -0
  345. package/src/ui/shared/navigation-labels.ts +2 -4
  346. package/src/ui/shared/post-article-attributes.ts +52 -0
  347. package/dist/app-DZDlmh7C.js +0 -6
  348. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  349. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  350. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -14,7 +14,7 @@ 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
18
 
19
19
  type Env = { Bindings: Bindings; Variables: AppVariables };
20
20
 
@@ -28,7 +28,17 @@ export interface TimelineResult {
28
28
  totalCount: number;
29
29
  }
30
30
 
31
- type CuratedThreadSelectionMap = Map<string, Set<string>>;
31
+ interface CuratedThreadSource {
32
+ posts: Array<{ post: Post; position: number }>;
33
+ highlightedPostIds: ReadonlySet<string>;
34
+ }
35
+
36
+ interface CuratedThreadDisplayOptions {
37
+ collectionPinnedThreadIds?: ReadonlySet<string>;
38
+ /** Visually focus the newest Post when the Thread itself is the selection. */
39
+ highlightLatestPost?: boolean;
40
+ showContextRatings: boolean;
41
+ }
32
42
 
33
43
  async function buildTimelineItems(
34
44
  c: Context<Env>,
@@ -41,13 +51,32 @@ async function buildTimelineItems(
41
51
  // Batch load media, collections, and latest-reply contexts in parallel
42
52
  const postIds = posts.map((p) => p.id);
43
53
  const mediaCtx = createMediaContext(c.var.appConfig);
44
- const [rawMediaMap, collectionsMap, threadContexts, aliasesMap] =
45
- await Promise.all([
46
- c.var.services.media.getByPostIds(postIds),
47
- c.var.services.collections.getCollectionsByPostIds(postIds),
48
- c.var.services.posts.getThreadTimelineContext(postIds),
49
- c.var.services.paths.getPostAliases(postIds),
50
- ]);
54
+ const threadIds = posts.map((p) => p.threadId);
55
+ const [
56
+ rawMediaMap,
57
+ collectionsMap,
58
+ threadContexts,
59
+ aliasesMap,
60
+ publishedTails,
61
+ draftInclusiveTails,
62
+ ] = await Promise.all([
63
+ c.var.services.media.getByPostIds(postIds),
64
+ c.var.services.collections.getCollectionsByPostIds(postIds),
65
+ c.var.services.posts.getThreadTimelineContext(postIds),
66
+ c.var.services.paths.getPostAliases(postIds),
67
+ // The feed never renders drafts — it would turn a reading surface into a
68
+ // workspace. So the author's trailing draft stays hidden here, and the
69
+ // reply affordance carries its ID instead of silently offering a reply
70
+ // the server will refuse.
71
+ c.var.isAuthenticated
72
+ ? c.var.services.posts.getThreadTailIds(threadIds)
73
+ : Promise.resolve(new Map<string, string>()),
74
+ c.var.isAuthenticated
75
+ ? c.var.services.posts.getThreadTailIds(threadIds, {
76
+ includeDrafts: true,
77
+ })
78
+ : Promise.resolve(new Map<string, string>()),
79
+ ]);
51
80
  const mediaMap = buildMediaMap(
52
81
  rawMediaMap,
53
82
  mediaCtx.r2PublicUrl,
@@ -57,12 +86,13 @@ async function buildTimelineItems(
57
86
  mediaCtx.sitePathPrefix,
58
87
  );
59
88
 
60
- // Batch load media for thread preview posts (second + penultimate + latest)
89
+ // Batch load media for the bounded leading/trailing thread context.
61
90
  const contextPostIds = new Set<string>();
62
91
  for (const ctx of threadContexts.values()) {
63
92
  contextPostIds.add(ctx.latestReply.id);
64
- if (ctx.secondReply) contextPostIds.add(ctx.secondReply.id);
65
- if (ctx.penultimateReply) contextPostIds.add(ctx.penultimateReply.id);
93
+ for (const reply of [...ctx.leadingReplies, ...ctx.trailingReplies]) {
94
+ contextPostIds.add(reply.id);
95
+ }
66
96
  }
67
97
  const [contextMediaMap, contextCollectionsMap, contextAliasesMap] =
68
98
  contextPostIds.size > 0
@@ -102,25 +132,33 @@ async function buildTimelineItems(
102
132
  firstAlias(post.id),
103
133
  );
104
134
 
135
+ const draftTailId = resolveDraftTailId(
136
+ post.threadId,
137
+ publishedTails,
138
+ draftInclusiveTails,
139
+ );
140
+
105
141
  const threadCtx = threadContexts.get(post.id);
106
142
 
107
143
  if (threadCtx) {
108
144
  // Thread root is not the last post — hide reply button on it
109
145
  postView.isLastInThread = false;
110
146
 
111
- const secondReplyView = threadCtx.secondReply
112
- ? toPostView(
113
- {
114
- ...threadCtx.secondReply,
115
- mediaAttachments:
116
- contextMediaMap.get(threadCtx.secondReply.id) ?? [],
117
- },
118
- mediaCtx,
119
- contextCollectionsMap.get(threadCtx.secondReply.id),
120
- threadCtx.secondReply.id === threadCtx.latestReply.id,
121
- firstContextAlias(threadCtx.secondReply.id),
122
- )
123
- : undefined;
147
+ const toContextPostView = (reply: Post) =>
148
+ toPostView(
149
+ {
150
+ ...reply,
151
+ mediaAttachments: contextMediaMap.get(reply.id) ?? [],
152
+ },
153
+ mediaCtx,
154
+ contextCollectionsMap.get(reply.id),
155
+ false,
156
+ firstContextAlias(reply.id),
157
+ );
158
+
159
+ const leadingReplyViews = threadCtx.leadingReplies.map(toContextPostView);
160
+ const trailingReplyViews =
161
+ threadCtx.trailingReplies.map(toContextPostView);
124
162
 
125
163
  const latestReplyView = toPostView(
126
164
  {
@@ -129,35 +167,26 @@ async function buildTimelineItems(
129
167
  },
130
168
  mediaCtx,
131
169
  contextCollectionsMap.get(threadCtx.latestReply.id),
132
- true, // latestReply is the last post in the thread
170
+ true, // latestReply is the last published post in the thread
133
171
  firstContextAlias(threadCtx.latestReply.id),
134
172
  );
135
-
136
- const penultimateReplyView = threadCtx.penultimateReply
137
- ? toPostView(
138
- {
139
- ...threadCtx.penultimateReply,
140
- mediaAttachments:
141
- contextMediaMap.get(threadCtx.penultimateReply.id) ?? [],
142
- },
143
- mediaCtx,
144
- contextCollectionsMap.get(threadCtx.penultimateReply.id),
145
- false, // penultimateReply is not the last post
146
- firstContextAlias(threadCtx.penultimateReply.id),
147
- )
148
- : undefined;
173
+ if (draftTailId) latestReplyView.draftTailId = draftTailId;
149
174
 
150
175
  return {
151
176
  post: postView,
152
177
  threadPreview: {
153
- secondReply: secondReplyView,
154
- penultimateReply: penultimateReplyView,
178
+ leadingReplies: leadingReplyViews,
179
+ trailingReplies: trailingReplyViews,
155
180
  latestReply: latestReplyView,
156
181
  totalReplyCount: threadCtx.totalReplyCount,
157
182
  },
158
183
  };
159
184
  }
160
185
 
186
+ // No published replies, so the root carries the reply affordance — and any
187
+ // trailing draft with it.
188
+ if (draftTailId) postView.draftTailId = draftTailId;
189
+
161
190
  return { post: postView };
162
191
  });
163
192
  }
@@ -182,13 +211,12 @@ export async function assembleTimelineItems(
182
211
  async function buildCuratedThreadItems(
183
212
  c: Context<Env>,
184
213
  rootIds: string[],
185
- threadsByRootId: Map<string, Post[]>,
186
- selectedPostIdsByThread: CuratedThreadSelectionMap,
187
- collectionPinnedPostIds?: Set<string>,
214
+ threadsByRootId: Map<string, CuratedThreadSource>,
215
+ display: CuratedThreadDisplayOptions,
188
216
  ): Promise<TimelineItemView[]> {
189
217
  const orderedThreads = rootIds
190
- .map((rootId) => threadsByRootId.get(rootId) ?? [])
191
- .filter((thread) => thread.length > 0);
218
+ .map((rootId) => threadsByRootId.get(rootId))
219
+ .filter((thread): thread is CuratedThreadSource => Boolean(thread));
192
220
 
193
221
  if (orderedThreads.length === 0) {
194
222
  return [];
@@ -196,7 +224,7 @@ async function buildCuratedThreadItems(
196
224
 
197
225
  const mediaCtx = createMediaContext(c.var.appConfig);
198
226
  const postIds = orderedThreads.flatMap((thread) =>
199
- thread.map((post) => post.id),
227
+ thread.posts.map(({ post }) => post.id),
200
228
  );
201
229
  const [rawMediaMap, collectionsMap, curatedAliasesMap] = await Promise.all([
202
230
  c.var.services.media.getByPostIds(postIds),
@@ -213,14 +241,15 @@ async function buildCuratedThreadItems(
213
241
  );
214
242
 
215
243
  return orderedThreads.reduce<TimelineItemView[]>((items, thread) => {
216
- const root = thread[0];
217
- if (!root) {
244
+ const rootEntry = thread.posts[0];
245
+ if (!rootEntry || rootEntry.position !== 0) {
218
246
  return items;
219
247
  }
220
248
 
221
- const lastPostId = thread[thread.length - 1]?.id;
222
- const postViews = thread.map((post) =>
223
- toPostView(
249
+ const lastPostId = thread.posts[thread.posts.length - 1]?.post.id;
250
+ const renderedPosts = thread.posts.map(({ post, position }) => ({
251
+ position,
252
+ view: toPostView(
224
253
  {
225
254
  ...post,
226
255
  mediaAttachments: mediaMap.get(post.id) ?? [],
@@ -229,54 +258,34 @@ async function buildCuratedThreadItems(
229
258
  collectionsMap.get(post.id),
230
259
  post.id === lastPostId,
231
260
  curatedAliasesMap.get(post.id)?.[0],
232
- collectionPinnedPostIds?.has(post.id),
261
+ post.id === post.threadId &&
262
+ display.collectionPinnedThreadIds?.has(post.threadId),
233
263
  ),
234
- );
235
- const rootView = postViews[0];
236
- const selectedIds = selectedPostIdsByThread.get(root.id);
237
-
238
- if (!rootView || !selectedIds || selectedIds.size === 0) {
239
- return items;
240
- }
241
-
242
- const selectedIndices = postViews.reduce<number[]>(
243
- (indices, post, index) => {
244
- if (selectedIds.has(post.id)) {
245
- indices.push(index);
246
- }
247
- return indices;
248
- },
249
- [],
250
- );
264
+ }));
265
+ const rootView = renderedPosts[0]?.view;
251
266
 
252
- if (selectedIndices.length === 0) {
267
+ if (!rootView) {
253
268
  return items;
254
269
  }
255
270
 
256
- const selectedIndexSet = new Set(selectedIndices);
257
- const visibleIndices = [
258
- ...new Set([0, postViews.length - 1, ...selectedIndices]),
259
- ].sort((left, right) => left - right);
260
- const segments = visibleIndices.reduce<
271
+ const segments = renderedPosts.reduce<
261
272
  NonNullable<TimelineItemView["curatedThread"]>["segments"]
262
- >((items, index, segmentIndex) => {
263
- const postView = postViews[index];
264
- if (!postView) {
265
- return items;
266
- }
267
-
268
- const previousIndex =
269
- segmentIndex === 0 ? undefined : visibleIndices[segmentIndex - 1];
273
+ >((items, renderedPost, segmentIndex) => {
274
+ const previousPosition =
275
+ segmentIndex === 0
276
+ ? undefined
277
+ : renderedPosts[segmentIndex - 1]?.position;
270
278
 
271
279
  items.push({
272
- post: postView,
280
+ post: renderedPost.view,
273
281
  hiddenBeforeCount:
274
- previousIndex === undefined
275
- ? index === 0
276
- ? 0
277
- : index - 1
278
- : index - previousIndex - 1,
279
- highlighted: selectedIndexSet.has(index),
282
+ previousPosition === undefined
283
+ ? renderedPost.position
284
+ : renderedPost.position - previousPosition - 1,
285
+ highlighted:
286
+ thread.highlightedPostIds.has(renderedPost.view.id) ||
287
+ (display.highlightLatestPost === true &&
288
+ renderedPost.view.id === lastPostId),
280
289
  });
281
290
 
282
291
  return items;
@@ -286,10 +295,10 @@ async function buildCuratedThreadItems(
286
295
  return items;
287
296
  }
288
297
 
289
- const isRootOnlySelection =
298
+ const isStandaloneRoot =
290
299
  segments.length === 1 && segments[0]?.post.id === rootView.id;
291
300
 
292
- if (isRootOnlySelection) {
301
+ if (isStandaloneRoot) {
293
302
  items.push({ post: rootView });
294
303
  return items;
295
304
  }
@@ -298,6 +307,7 @@ async function buildCuratedThreadItems(
298
307
  post: rootView,
299
308
  curatedThread: {
300
309
  rootPost: rootView,
310
+ showContextRatings: display.showContextRatings,
301
311
  segments,
302
312
  },
303
313
  });
@@ -429,36 +439,31 @@ export async function assembleFeaturedTimeline(
429
439
  return { items: [], currentPage: page, totalPages, totalCount };
430
440
  }
431
441
 
432
- const threadsByRootId =
433
- await c.var.services.posts.getPublishedThreads(rootIds);
434
- const selectedPostIdsByThread: CuratedThreadSelectionMap = new Map();
435
-
436
- for (const [threadId, thread] of threadsByRootId) {
437
- const selectedIds = thread
438
- .filter((post) => post.featuredAt !== null)
439
- .map((post) => post.id);
440
-
441
- if (selectedIds.length > 0) {
442
- selectedPostIdsByThread.set(threadId, new Set(selectedIds));
443
- }
444
- }
445
-
446
- const items = await buildCuratedThreadItems(
447
- c,
448
- rootIds,
449
- threadsByRootId,
450
- selectedPostIdsByThread,
442
+ const featuredThreads =
443
+ await c.var.services.posts.getFeaturedThreadTimelineData(rootIds);
444
+ const threadsByRootId = new Map<string, CuratedThreadSource>(
445
+ [...featuredThreads].map(([threadId, thread]) => [
446
+ threadId,
447
+ {
448
+ posts: thread.posts,
449
+ highlightedPostIds: new Set(thread.featuredPostIds),
450
+ },
451
+ ]),
451
452
  );
452
453
 
454
+ const items = await buildCuratedThreadItems(c, rootIds, threadsByRootId, {
455
+ showContextRatings: false,
456
+ });
457
+
453
458
  return { items, currentPage: page, totalPages, totalCount };
454
459
  }
455
460
 
456
461
  /**
457
462
  * Assembles a paginated collection timeline grouped by thread root.
458
463
  *
459
- * Threads are ordered by collection membership time (newest/oldest) or by the
460
- * rated posts collected into the thread. Within each thread, collected posts are
461
- * expanded and intervening non-collected posts collapse into hidden-count gaps.
464
+ * Threads are ordered by collection activity/rating semantics. Every published
465
+ * post in each matching Thread is rendered; Collection membership never selects
466
+ * or hides individual posts within the Thread.
462
467
  *
463
468
  * @param c - Hono context (provides services + appConfig)
464
469
  * @param options - Collection IDs, optional page number, auth state, and sort
@@ -504,27 +509,28 @@ export async function assembleCollectionTimeline(
504
509
  return { items: [], currentPage: page, totalPages, totalCount };
505
510
  }
506
511
 
507
- const [threadsByRootId, collectedPostIdsByThread, pinnedPostIds] =
508
- await Promise.all([
509
- c.var.services.posts.getPublishedThreads(rootIds),
510
- c.var.services.posts.getCollectionPostIdsByThreadForCollections(
511
- options.collectionIds,
512
- rootIds,
513
- ),
514
- c.var.services.collections.getPinnedPostIds(options.collectionIds),
515
- ]);
516
- const selectedPostIdsByThread: CuratedThreadSelectionMap = new Map(
517
- [...collectedPostIdsByThread.entries()].map(([threadId, postIds]) => [
512
+ const [threadsByRootId, pinnedThreadIds] = await Promise.all([
513
+ c.var.services.posts.getPublishedThreads(rootIds),
514
+ c.var.services.collections.getPinnedThreadIds(options.collectionIds),
515
+ ]);
516
+ const curatedThreadsByRootId = new Map<string, CuratedThreadSource>(
517
+ [...threadsByRootId].map(([threadId, thread]) => [
518
518
  threadId,
519
- new Set(postIds),
519
+ {
520
+ posts: thread.map((post, position) => ({ post, position })),
521
+ highlightedPostIds: new Set<string>(),
522
+ },
520
523
  ]),
521
524
  );
522
525
  const items = await buildCuratedThreadItems(
523
526
  c,
524
527
  rootIds,
525
- threadsByRootId,
526
- selectedPostIdsByThread,
527
- pinnedPostIds,
528
+ curatedThreadsByRootId,
529
+ {
530
+ collectionPinnedThreadIds: pinnedThreadIds,
531
+ highlightLatestPost: true,
532
+ showContextRatings: true,
533
+ },
528
534
  );
529
535
 
530
536
  return { items, currentPage: page, totalPages, totalCount };
@@ -336,21 +336,28 @@ export const TIMEZONES: TimezoneEntry[] = [
336
336
  },
337
337
  ];
338
338
 
339
- function findTimezoneEntry(value: string): TimezoneEntry | undefined {
340
- return TIMEZONES.find(
341
- (tz) =>
342
- tz.value === value ||
343
- tz.iana.includes(value) ||
344
- tz.legacyValues?.includes(value),
345
- );
339
+ function findLegacyTimezoneEntry(value: string): TimezoneEntry | undefined {
340
+ return TIMEZONES.find((tz) => tz.legacyValues?.includes(value));
341
+ }
342
+
343
+ function resolveRuntimeTimeZone(value: string): string | null {
344
+ try {
345
+ return new Intl.DateTimeFormat("en-US", { timeZone: value })
346
+ .resolvedOptions()
347
+ .timeZone.trim();
348
+ } catch {
349
+ return null;
350
+ }
346
351
  }
347
352
 
348
353
  /**
349
- * Normalizes a timezone value from settings, env, or browser detection into the
350
- * canonical IANA identifier used by the app. Unknown values fall back to UTC.
354
+ * Normalizes a timezone value from settings, env, or browser detection. Any
355
+ * IANA name or fixed-offset identifier supported by the runtime is preserved;
356
+ * historical display labels are mapped to their curated replacement. Unknown
357
+ * values fall back to UTC.
351
358
  *
352
359
  * @param value - Timezone value to normalize
353
- * @returns Canonical IANA timezone identifier
360
+ * @returns Runtime-normalized timezone identifier or UTC
354
361
  *
355
362
  * @example
356
363
  * ```ts
@@ -365,12 +372,17 @@ export function normalizeTimeZone(value: string | null | undefined): string {
365
372
  return "UTC";
366
373
  }
367
374
 
368
- return findTimezoneEntry(trimmed)?.value ?? "UTC";
375
+ const runtimeTimeZone = resolveRuntimeTimeZone(trimmed);
376
+ if (runtimeTimeZone) {
377
+ return runtimeTimeZone;
378
+ }
379
+
380
+ return findLegacyTimezoneEntry(trimmed)?.value ?? "UTC";
369
381
  }
370
382
 
371
383
  /**
372
- * Returns whether the app recognizes a timezone value from the curated list,
373
- * one of its accepted IANA aliases, or a historical legacy value.
384
+ * Returns whether the runtime recognizes a timezone value or the app can map
385
+ * it from a historical display label.
374
386
  *
375
387
  * @param value - Timezone value to validate
376
388
  * @returns `true` when the value can be normalized safely
@@ -381,7 +393,35 @@ export function isSupportedTimeZone(value: string | null | undefined): boolean {
381
393
  return false;
382
394
  }
383
395
 
384
- return findTimezoneEntry(trimmed) !== undefined;
396
+ return (
397
+ resolveRuntimeTimeZone(trimmed) !== null ||
398
+ findLegacyTimezoneEntry(trimmed) !== undefined
399
+ );
400
+ }
401
+
402
+ /**
403
+ * Returns the curated settings options plus a valid current value that is not
404
+ * already represented. This keeps the UI compact without losing an IANA zone
405
+ * or fixed offset detected outside the curated list.
406
+ *
407
+ * @param currentValue - Current stored timezone value
408
+ * @returns Timezone options that can represent the current setting
409
+ */
410
+ export function getTimeZoneOptions(currentValue: string): TimezoneEntry[] {
411
+ const normalized = normalizeTimeZone(currentValue);
412
+ if (TIMEZONES.some((timezone) => timezone.value === normalized)) {
413
+ return TIMEZONES;
414
+ }
415
+
416
+ return [
417
+ ...TIMEZONES,
418
+ {
419
+ value: normalized,
420
+ label: normalized,
421
+ offset: "",
422
+ iana: [normalized],
423
+ },
424
+ ];
385
425
  }
386
426
 
387
427
  /**