@jant/core 0.6.14 → 0.6.15

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 (311) hide show
  1. package/bin/commands/import-site.js +81 -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-BxCOR3Uc.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
  13. package/dist/client/.vite/manifest.json +3 -3
  14. package/dist/client/_assets/client-B_eGKN5p.css +2 -0
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
  16. package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
  17. package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
  18. package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
  19. package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
  20. package/dist/index.js +3 -3
  21. package/dist/node.js +5 -5
  22. package/package.json +11 -10
  23. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  24. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  25. package/src/__tests__/export-hugo-build.test.ts +63 -4
  26. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  27. package/src/__tests__/export-service.test.ts +142 -9
  28. package/src/__tests__/helpers/app.ts +3 -0
  29. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  30. package/src/__tests__/import-site-command.test.ts +65 -0
  31. package/src/__tests__/vite-config.test.ts +45 -0
  32. package/src/app.tsx +16 -3
  33. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  34. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  35. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  36. package/src/client/__tests__/compose-bridge.test.ts +64 -0
  37. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  38. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  39. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  40. package/src/client/__tests__/toast.test.ts +18 -1
  41. package/src/client/collection-created-notice.ts +66 -0
  42. package/src/client/collection-form-bridge.ts +17 -0
  43. package/src/client/collection-navigation.ts +44 -0
  44. package/src/client/collection-page-actions.ts +57 -1
  45. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  46. package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
  47. package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
  48. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
  49. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  50. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  51. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  52. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  53. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  54. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  55. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  56. package/src/client/components/collection-manager-types.ts +9 -3
  57. package/src/client/components/compose-types.ts +8 -0
  58. package/src/client/components/jant-collection-directory.ts +165 -30
  59. package/src/client/components/jant-command-palette.ts +2 -1
  60. package/src/client/components/jant-compose-dialog.ts +85 -21
  61. package/src/client/components/jant-compose-editor.ts +13 -7
  62. package/src/client/components/jant-compose-fullscreen.ts +24 -4
  63. package/src/client/components/jant-config-editor.ts +722 -0
  64. package/src/client/components/jant-media-lightbox.ts +100 -18
  65. package/src/client/components/jant-nav-manager.ts +1283 -54
  66. package/src/client/components/jant-post-form.ts +3 -3
  67. package/src/client/components/jant-post-menu.ts +86 -74
  68. package/src/client/components/nav-manager-types.ts +51 -2
  69. package/src/client/components/post-form-types.ts +1 -1
  70. package/src/client/compose-bridge.ts +4 -1
  71. package/src/client/feed-video-player.ts +62 -2
  72. package/src/client/footnote-rail.ts +478 -0
  73. package/src/client/hydrate-partial.ts +2 -0
  74. package/src/client/media-lightbox-events.ts +63 -0
  75. package/src/client/site-header-fragment.ts +38 -0
  76. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  77. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  78. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  79. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  80. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  81. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  82. package/src/client/tiptap/bubble-menu.ts +88 -10
  83. package/src/client/tiptap/continuous-lists.ts +102 -0
  84. package/src/client/tiptap/create-editor.ts +3 -0
  85. package/src/client/tiptap/extensions.ts +14 -5
  86. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  87. package/src/client/tiptap/slash-commands.ts +8 -1
  88. package/src/client/tiptap/structural-keymap.ts +211 -0
  89. package/src/client/tiptap/tab-indent.ts +8 -22
  90. package/src/client/tiptap/table-control-labels.ts +30 -0
  91. package/src/client/tiptap/table-controls.ts +537 -0
  92. package/src/client/tiptap/table-size-picker.ts +254 -0
  93. package/src/client/toast.ts +8 -6
  94. package/src/client-auth.ts +1 -0
  95. package/src/client.ts +1 -0
  96. package/src/db/__tests__/d1-query.test.ts +43 -1
  97. package/src/db/__tests__/migration-runner.test.ts +63 -0
  98. package/src/db/__tests__/migrations.test.ts +469 -0
  99. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  100. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  101. package/src/db/migrations/0028_superb_post.sql +2 -0
  102. package/src/db/migrations/0029_boring_magma.sql +1 -0
  103. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  104. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  105. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  106. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  107. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  108. package/src/db/migrations/meta/_journal.json +28 -0
  109. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  110. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  111. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  112. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  113. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  114. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  115. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  116. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  117. package/src/db/migrations/pg/meta/_journal.json +28 -0
  118. package/src/db/pg/schema.ts +28 -20
  119. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  120. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  121. package/src/db/schema.ts +28 -20
  122. package/src/i18n/__tests__/detect.test.ts +8 -2
  123. package/src/i18n/detect.ts +14 -5
  124. package/src/i18n/locales/public/en.po +133 -30
  125. package/src/i18n/locales/public/en.ts +1 -1
  126. package/src/i18n/locales/public/zh-Hans.po +128 -25
  127. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  128. package/src/i18n/locales/public/zh-Hant.po +128 -25
  129. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  130. package/src/i18n/locales/settings/en.po +513 -10
  131. package/src/i18n/locales/settings/en.ts +1 -1
  132. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  133. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  134. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  135. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  136. package/src/lib/__tests__/api-settings.test.ts +299 -0
  137. package/src/lib/__tests__/constants.test.ts +5 -0
  138. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  139. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  140. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  141. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  142. package/src/lib/__tests__/markdown.test.ts +8 -6
  143. package/src/lib/__tests__/navigation.test.ts +59 -2
  144. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  145. package/src/lib/__tests__/post-display.test.ts +64 -0
  146. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  147. package/src/lib/__tests__/summary.test.ts +82 -0
  148. package/src/lib/__tests__/timeline.test.ts +109 -70
  149. package/src/lib/__tests__/timezones.test.ts +31 -3
  150. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  151. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  152. package/src/lib/__tests__/view.test.ts +53 -0
  153. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  154. package/src/lib/api-settings.ts +239 -12
  155. package/src/lib/collection-sort.ts +7 -5
  156. package/src/lib/constants.ts +2 -0
  157. package/src/lib/feed-path.ts +25 -0
  158. package/src/lib/feed-policy.ts +64 -0
  159. package/src/lib/footnotes.ts +264 -0
  160. package/src/lib/github-sync-site-config.ts +2 -0
  161. package/src/lib/hugo-markdown.ts +10 -3
  162. package/src/lib/jant-branding.ts +1 -0
  163. package/src/lib/markdown-manager.ts +188 -5
  164. package/src/lib/markdown.ts +10 -3
  165. package/src/lib/navigation.ts +16 -3
  166. package/src/lib/post-body-html.ts +131 -0
  167. package/src/lib/post-display.ts +7 -1
  168. package/src/lib/render.tsx +9 -2
  169. package/src/lib/resolve-config.ts +100 -16
  170. package/src/lib/schemas.ts +133 -1
  171. package/src/lib/settings-paths.ts +5 -0
  172. package/src/lib/site-skill.ts +78 -0
  173. package/src/lib/summary.ts +16 -5
  174. package/src/lib/timeline.ts +98 -122
  175. package/src/lib/timezones.ts +54 -14
  176. package/src/lib/tiptap-render.ts +355 -81
  177. package/src/lib/view.ts +30 -20
  178. package/src/lib/worker-response-cache.ts +11 -18
  179. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  180. package/src/middleware/config.ts +2 -2
  181. package/src/middleware/public-content-access.ts +87 -0
  182. package/src/node/__tests__/cli-export.test.ts +14 -0
  183. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  184. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  185. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  186. package/src/node/index.ts +2 -1
  187. package/src/preset.css +16 -2
  188. package/src/routes/__tests__/compose.test.ts +51 -0
  189. package/src/routes/__tests__/site-skill.test.ts +112 -0
  190. package/src/routes/api/__tests__/collections.test.ts +91 -19
  191. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  192. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  193. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  194. package/src/routes/api/__tests__/settings.test.ts +217 -0
  195. package/src/routes/api/collections.ts +22 -26
  196. package/src/routes/api/export.ts +2 -0
  197. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  198. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  199. package/src/routes/api/internal/post-body-html.ts +43 -0
  200. package/src/routes/api/internal/sites.ts +24 -0
  201. package/src/routes/api/nav-items.ts +24 -1
  202. package/src/routes/api/posts.ts +2 -2
  203. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  204. package/src/routes/api/public/__tests__/posts.test.ts +208 -0
  205. package/src/routes/api/public/archive.ts +4 -0
  206. package/src/routes/api/public/posts.ts +41 -24
  207. package/src/routes/api/search.ts +3 -0
  208. package/src/routes/api/settings.ts +33 -0
  209. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  210. package/src/routes/compose.tsx +2 -1
  211. package/src/routes/dash/settings.tsx +45 -2
  212. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  213. package/src/routes/feed/feed.ts +23 -14
  214. package/src/routes/pages/__tests__/archive-params.test.ts +92 -3
  215. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  216. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  217. package/src/routes/pages/__tests__/preview.test.ts +174 -0
  218. package/src/routes/pages/__tests__/search.test.ts +69 -0
  219. package/src/routes/pages/archive.tsx +27 -7
  220. package/src/routes/pages/collection.tsx +32 -19
  221. package/src/routes/pages/collections.tsx +6 -1
  222. package/src/routes/pages/page.tsx +83 -11
  223. package/src/routes/site-skill.ts +18 -0
  224. package/src/services/__tests__/auth.test.ts +7 -5
  225. package/src/services/__tests__/collection.test.ts +159 -44
  226. package/src/services/__tests__/navigation.test.ts +229 -21
  227. package/src/services/__tests__/post-timeline.test.ts +109 -30
  228. package/src/services/__tests__/post.test.ts +567 -5
  229. package/src/services/__tests__/search.test.ts +48 -1
  230. package/src/services/__tests__/settings.test.ts +8 -0
  231. package/src/services/__tests__/site-admin.test.ts +121 -1
  232. package/src/services/auth.ts +2 -2
  233. package/src/services/bootstrap.ts +3 -2
  234. package/src/services/collection.ts +226 -167
  235. package/src/services/export-theme/assets/client-site.css +1 -1
  236. package/src/services/export-theme/assets/client-site.js +46 -25
  237. package/src/services/export-theme/layouts/_default/list.html +18 -4
  238. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  239. package/src/services/export-theme/layouts/featured/list.html +5 -4
  240. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  241. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  242. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  243. package/src/services/export-theme/styles/main.css +30 -2
  244. package/src/services/export.ts +91 -80
  245. package/src/services/mcp.ts +37 -27
  246. package/src/services/media.ts +1 -1
  247. package/src/services/navigation.ts +185 -46
  248. package/src/services/post.ts +874 -271
  249. package/src/services/search.ts +8 -2
  250. package/src/services/settings.ts +2 -10
  251. package/src/services/site-admin.ts +63 -43
  252. package/src/style-cjk-jp.css +1 -1
  253. package/src/style-cjk-kr.css +1 -1
  254. package/src/style-cjk-tc.css +1 -1
  255. package/src/style-cjk.css +1 -1
  256. package/src/styles/components.css +540 -0
  257. package/src/styles/site-media.css +111 -19
  258. package/src/styles/tokens.css +14 -9
  259. package/src/styles/ui.css +829 -29
  260. package/src/types/bindings.ts +3 -0
  261. package/src/types/config.ts +243 -12
  262. package/src/types/constants.ts +22 -1
  263. package/src/types/entities.ts +9 -8
  264. package/src/types/operations.ts +18 -10
  265. package/src/types/props.ts +8 -2
  266. package/src/types/raw-assets.d.ts +5 -0
  267. package/src/types/views.ts +16 -4
  268. package/src/ui/__tests__/font-themes.test.ts +42 -30
  269. package/src/ui/compose/ComposeDialog.tsx +92 -2
  270. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  271. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  272. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  273. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  274. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  275. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  276. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  277. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  278. package/src/ui/feed/LinkCard.tsx +2 -12
  279. package/src/ui/feed/NoteCard.tsx +10 -20
  280. package/src/ui/feed/QuoteCard.tsx +2 -12
  281. package/src/ui/feed/ThreadPreview.tsx +26 -34
  282. package/src/ui/feed/TimelineFeed.tsx +2 -2
  283. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  284. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  285. package/src/ui/feed/thread-preview-state.ts +5 -7
  286. package/src/ui/font-themes.ts +74 -26
  287. package/src/ui/layouts/BaseLayout.tsx +22 -8
  288. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  289. package/src/ui/pages/CollectionPage.tsx +101 -22
  290. package/src/ui/pages/CollectionsPage.tsx +3 -1
  291. package/src/ui/pages/PostPage.tsx +39 -12
  292. package/src/ui/pages/SearchPage.tsx +6 -14
  293. package/src/ui/pages/__tests__/ArchivePage.test.tsx +7 -0
  294. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  295. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  296. package/src/ui/shared/CollectionsManager.tsx +17 -7
  297. package/src/ui/shared/DraftPreviewBar.tsx +33 -0
  298. package/src/ui/shared/HomePageBranding.tsx +2 -2
  299. package/src/ui/shared/MediaGallery.tsx +1 -0
  300. package/src/ui/shared/PostFooter.tsx +3 -1
  301. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  302. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  303. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  304. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  305. package/src/ui/shared/collection-management-labels.ts +33 -2
  306. package/src/ui/shared/navigation-labels.ts +2 -4
  307. package/src/ui/shared/post-article-attributes.ts +46 -0
  308. package/dist/app-DZDlmh7C.js +0 -6
  309. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  310. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  311. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -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>,
@@ -57,12 +67,13 @@ async function buildTimelineItems(
57
67
  mediaCtx.sitePathPrefix,
58
68
  );
59
69
 
60
- // Batch load media for thread preview posts (second + penultimate + latest)
70
+ // Batch load media for the bounded leading/trailing thread context.
61
71
  const contextPostIds = new Set<string>();
62
72
  for (const ctx of threadContexts.values()) {
63
73
  contextPostIds.add(ctx.latestReply.id);
64
- if (ctx.secondReply) contextPostIds.add(ctx.secondReply.id);
65
- if (ctx.penultimateReply) contextPostIds.add(ctx.penultimateReply.id);
74
+ for (const reply of [...ctx.leadingReplies, ...ctx.trailingReplies]) {
75
+ contextPostIds.add(reply.id);
76
+ }
66
77
  }
67
78
  const [contextMediaMap, contextCollectionsMap, contextAliasesMap] =
68
79
  contextPostIds.size > 0
@@ -108,19 +119,21 @@ async function buildTimelineItems(
108
119
  // Thread root is not the last post — hide reply button on it
109
120
  postView.isLastInThread = false;
110
121
 
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;
122
+ const toContextPostView = (reply: Post) =>
123
+ toPostView(
124
+ {
125
+ ...reply,
126
+ mediaAttachments: contextMediaMap.get(reply.id) ?? [],
127
+ },
128
+ mediaCtx,
129
+ contextCollectionsMap.get(reply.id),
130
+ false,
131
+ firstContextAlias(reply.id),
132
+ );
133
+
134
+ const leadingReplyViews = threadCtx.leadingReplies.map(toContextPostView);
135
+ const trailingReplyViews =
136
+ threadCtx.trailingReplies.map(toContextPostView);
124
137
 
125
138
  const latestReplyView = toPostView(
126
139
  {
@@ -133,25 +146,11 @@ async function buildTimelineItems(
133
146
  firstContextAlias(threadCtx.latestReply.id),
134
147
  );
135
148
 
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;
149
-
150
149
  return {
151
150
  post: postView,
152
151
  threadPreview: {
153
- secondReply: secondReplyView,
154
- penultimateReply: penultimateReplyView,
152
+ leadingReplies: leadingReplyViews,
153
+ trailingReplies: trailingReplyViews,
155
154
  latestReply: latestReplyView,
156
155
  totalReplyCount: threadCtx.totalReplyCount,
157
156
  },
@@ -182,13 +181,12 @@ export async function assembleTimelineItems(
182
181
  async function buildCuratedThreadItems(
183
182
  c: Context<Env>,
184
183
  rootIds: string[],
185
- threadsByRootId: Map<string, Post[]>,
186
- selectedPostIdsByThread: CuratedThreadSelectionMap,
187
- collectionPinnedPostIds?: Set<string>,
184
+ threadsByRootId: Map<string, CuratedThreadSource>,
185
+ display: CuratedThreadDisplayOptions,
188
186
  ): Promise<TimelineItemView[]> {
189
187
  const orderedThreads = rootIds
190
- .map((rootId) => threadsByRootId.get(rootId) ?? [])
191
- .filter((thread) => thread.length > 0);
188
+ .map((rootId) => threadsByRootId.get(rootId))
189
+ .filter((thread): thread is CuratedThreadSource => Boolean(thread));
192
190
 
193
191
  if (orderedThreads.length === 0) {
194
192
  return [];
@@ -196,7 +194,7 @@ async function buildCuratedThreadItems(
196
194
 
197
195
  const mediaCtx = createMediaContext(c.var.appConfig);
198
196
  const postIds = orderedThreads.flatMap((thread) =>
199
- thread.map((post) => post.id),
197
+ thread.posts.map(({ post }) => post.id),
200
198
  );
201
199
  const [rawMediaMap, collectionsMap, curatedAliasesMap] = await Promise.all([
202
200
  c.var.services.media.getByPostIds(postIds),
@@ -213,14 +211,15 @@ async function buildCuratedThreadItems(
213
211
  );
214
212
 
215
213
  return orderedThreads.reduce<TimelineItemView[]>((items, thread) => {
216
- const root = thread[0];
217
- if (!root) {
214
+ const rootEntry = thread.posts[0];
215
+ if (!rootEntry || rootEntry.position !== 0) {
218
216
  return items;
219
217
  }
220
218
 
221
- const lastPostId = thread[thread.length - 1]?.id;
222
- const postViews = thread.map((post) =>
223
- toPostView(
219
+ const lastPostId = thread.posts[thread.posts.length - 1]?.post.id;
220
+ const renderedPosts = thread.posts.map(({ post, position }) => ({
221
+ position,
222
+ view: toPostView(
224
223
  {
225
224
  ...post,
226
225
  mediaAttachments: mediaMap.get(post.id) ?? [],
@@ -229,54 +228,34 @@ async function buildCuratedThreadItems(
229
228
  collectionsMap.get(post.id),
230
229
  post.id === lastPostId,
231
230
  curatedAliasesMap.get(post.id)?.[0],
232
- collectionPinnedPostIds?.has(post.id),
231
+ post.id === post.threadId &&
232
+ display.collectionPinnedThreadIds?.has(post.threadId),
233
233
  ),
234
- );
235
- const rootView = postViews[0];
236
- const selectedIds = selectedPostIdsByThread.get(root.id);
234
+ }));
235
+ const rootView = renderedPosts[0]?.view;
237
236
 
238
- if (!rootView || !selectedIds || selectedIds.size === 0) {
237
+ if (!rootView) {
239
238
  return items;
240
239
  }
241
240
 
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
- );
251
-
252
- if (selectedIndices.length === 0) {
253
- return items;
254
- }
255
-
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<
241
+ const segments = renderedPosts.reduce<
261
242
  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];
243
+ >((items, renderedPost, segmentIndex) => {
244
+ const previousPosition =
245
+ segmentIndex === 0
246
+ ? undefined
247
+ : renderedPosts[segmentIndex - 1]?.position;
270
248
 
271
249
  items.push({
272
- post: postView,
250
+ post: renderedPost.view,
273
251
  hiddenBeforeCount:
274
- previousIndex === undefined
275
- ? index === 0
276
- ? 0
277
- : index - 1
278
- : index - previousIndex - 1,
279
- highlighted: selectedIndexSet.has(index),
252
+ previousPosition === undefined
253
+ ? renderedPost.position
254
+ : renderedPost.position - previousPosition - 1,
255
+ highlighted:
256
+ thread.highlightedPostIds.has(renderedPost.view.id) ||
257
+ (display.highlightLatestPost === true &&
258
+ renderedPost.view.id === lastPostId),
280
259
  });
281
260
 
282
261
  return items;
@@ -286,10 +265,10 @@ async function buildCuratedThreadItems(
286
265
  return items;
287
266
  }
288
267
 
289
- const isRootOnlySelection =
268
+ const isStandaloneRoot =
290
269
  segments.length === 1 && segments[0]?.post.id === rootView.id;
291
270
 
292
- if (isRootOnlySelection) {
271
+ if (isStandaloneRoot) {
293
272
  items.push({ post: rootView });
294
273
  return items;
295
274
  }
@@ -298,6 +277,7 @@ async function buildCuratedThreadItems(
298
277
  post: rootView,
299
278
  curatedThread: {
300
279
  rootPost: rootView,
280
+ showContextRatings: display.showContextRatings,
301
281
  segments,
302
282
  },
303
283
  });
@@ -429,36 +409,31 @@ export async function assembleFeaturedTimeline(
429
409
  return { items: [], currentPage: page, totalPages, totalCount };
430
410
  }
431
411
 
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,
412
+ const featuredThreads =
413
+ await c.var.services.posts.getFeaturedThreadTimelineData(rootIds);
414
+ const threadsByRootId = new Map<string, CuratedThreadSource>(
415
+ [...featuredThreads].map(([threadId, thread]) => [
416
+ threadId,
417
+ {
418
+ posts: thread.posts,
419
+ highlightedPostIds: new Set(thread.featuredPostIds),
420
+ },
421
+ ]),
451
422
  );
452
423
 
424
+ const items = await buildCuratedThreadItems(c, rootIds, threadsByRootId, {
425
+ showContextRatings: false,
426
+ });
427
+
453
428
  return { items, currentPage: page, totalPages, totalCount };
454
429
  }
455
430
 
456
431
  /**
457
432
  * Assembles a paginated collection timeline grouped by thread root.
458
433
  *
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.
434
+ * Threads are ordered by collection activity/rating semantics. Every published
435
+ * post in each matching Thread is rendered; Collection membership never selects
436
+ * or hides individual posts within the Thread.
462
437
  *
463
438
  * @param c - Hono context (provides services + appConfig)
464
439
  * @param options - Collection IDs, optional page number, auth state, and sort
@@ -504,27 +479,28 @@ export async function assembleCollectionTimeline(
504
479
  return { items: [], currentPage: page, totalPages, totalCount };
505
480
  }
506
481
 
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]) => [
482
+ const [threadsByRootId, pinnedThreadIds] = await Promise.all([
483
+ c.var.services.posts.getPublishedThreads(rootIds),
484
+ c.var.services.collections.getPinnedThreadIds(options.collectionIds),
485
+ ]);
486
+ const curatedThreadsByRootId = new Map<string, CuratedThreadSource>(
487
+ [...threadsByRootId].map(([threadId, thread]) => [
518
488
  threadId,
519
- new Set(postIds),
489
+ {
490
+ posts: thread.map((post, position) => ({ post, position })),
491
+ highlightedPostIds: new Set<string>(),
492
+ },
520
493
  ]),
521
494
  );
522
495
  const items = await buildCuratedThreadItems(
523
496
  c,
524
497
  rootIds,
525
- threadsByRootId,
526
- selectedPostIdsByThread,
527
- pinnedPostIds,
498
+ curatedThreadsByRootId,
499
+ {
500
+ collectionPinnedThreadIds: pinnedThreadIds,
501
+ highlightLatestPost: true,
502
+ showContextRatings: true,
503
+ },
528
504
  );
529
505
 
530
506
  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
  /**