@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
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createTestApp } from "../../../__tests__/helpers/app.js";
3
3
  import { archiveRoutes } from "../archive.js";
4
4
 
@@ -21,13 +21,45 @@ async function fetchFeed(
21
21
  return res.text();
22
22
  }
23
23
 
24
- function setupApp() {
25
- const { app, services } = createTestApp({ authenticated: false });
24
+ function setupApp(rssPublishDelaySeconds = 0) {
25
+ const { app, services } = createTestApp({
26
+ authenticated: false,
27
+ rssPublishDelaySeconds,
28
+ });
26
29
  app.route("/archive", archiveRoutes);
27
30
  return { app, services };
28
31
  }
29
32
 
33
+ afterEach(() => {
34
+ vi.restoreAllMocks();
35
+ });
36
+
30
37
  describe("archive feed filter params", () => {
38
+ it("applies the RSS publication delay", async () => {
39
+ const currentTime = 2_000_000;
40
+ vi.spyOn(Date, "now").mockReturnValue(currentTime * 1000);
41
+ const { app, services } = setupApp(300);
42
+ await services.posts.create({
43
+ format: "note",
44
+ title: "Eligible Archive Post",
45
+ bodyMarkdown: "Old enough for RSS",
46
+ status: "published",
47
+ publishedAt: currentTime - 300,
48
+ });
49
+ await services.posts.create({
50
+ format: "note",
51
+ title: "Recent Archive Post",
52
+ bodyMarkdown: "Still inside the edit window",
53
+ status: "published",
54
+ publishedAt: currentTime,
55
+ });
56
+
57
+ const xml = await fetchFeed(app, "");
58
+
59
+ expect(xml).toContain("Eligible Archive Post");
60
+ expect(xml).not.toContain("Recent Archive Post");
61
+ });
62
+
31
63
  it("filters by title with the new param and the legacy fallback", async () => {
32
64
  const { app, services } = setupApp();
33
65
  await services.posts.create({
@@ -84,10 +116,23 @@ describe("archive feed filter params", () => {
84
116
  format: "note",
85
117
  bodyMarkdown: "text only body",
86
118
  });
119
+ await services.media.create({
120
+ filename: "unattached.jpg",
121
+ originalName: "unattached.jpg",
122
+ mimeType: "image/jpeg",
123
+ size: 1024,
124
+ storageKey: "media/unattached.jpg",
125
+ });
87
126
 
88
127
  const fresh = await fetchFeed(app, "?media=none");
89
128
  expect(fresh).toContain("text only body");
90
129
 
130
+ const page = await app.request("/archive?media=none");
131
+ expect(page.status).toBe(200);
132
+ const pageHtml = await page.text();
133
+ expect(pageHtml).toContain('aria-label="1 thread"');
134
+ expect(pageHtml).toContain("text only body");
135
+
91
136
  const legacy = await fetchFeed(app, "?hasMedia=0");
92
137
  expect(legacy).toContain("text only body");
93
138
 
@@ -96,6 +141,134 @@ describe("archive feed filter params", () => {
96
141
  });
97
142
  });
98
143
 
144
+ describe("archive feed ordering", () => {
145
+ // The feed belongs to the archive page, so it uses the page's default axis.
146
+ // Ordering by activity would let a reply to an old Thread reshuffle the
147
+ // feed window under a fixed rssFeedLimit, and readers key entries by id
148
+ // anyway — a moved entry is not a re-surfaced one. /latest/feed is where
149
+ // activity ordering lives.
150
+ it("orders by publication, not by recent replies", async () => {
151
+ const { app, services } = setupApp();
152
+ const oldThread = await services.posts.create({
153
+ format: "note",
154
+ title: "Old thread with a new reply",
155
+ bodyMarkdown: "root from 2019",
156
+ publishedAt: Date.UTC(2019, 0, 1) / 1000,
157
+ });
158
+ await services.posts.create({
159
+ format: "note",
160
+ bodyMarkdown: "reply from 2026",
161
+ replyToId: oldThread.id,
162
+ publishedAt: Date.UTC(2026, 5, 1) / 1000,
163
+ });
164
+ await services.posts.create({
165
+ format: "note",
166
+ title: "Plain newer post",
167
+ bodyMarkdown: "root from 2025",
168
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
169
+ });
170
+
171
+ const xml = await fetchFeed(app, "");
172
+
173
+ expect(xml.indexOf("Plain newer post")).toBeLessThan(
174
+ xml.indexOf("Old thread with a new reply"),
175
+ );
176
+ });
177
+
178
+ it("honors an explicit sort=updated, quiet replies included", async () => {
179
+ const { app, services } = setupApp();
180
+ const oldThread = await services.posts.create({
181
+ format: "note",
182
+ title: "Old thread with a new reply",
183
+ bodyMarkdown: "root from 2019",
184
+ publishedAt: Date.UTC(2019, 0, 1) / 1000,
185
+ });
186
+ await services.posts.create({
187
+ format: "note",
188
+ bodyMarkdown: "quiet addition",
189
+ replyToId: oldThread.id,
190
+ publishedAt: Date.UTC(2026, 5, 1) / 1000,
191
+ quietReply: true,
192
+ });
193
+ await services.posts.create({
194
+ format: "note",
195
+ title: "Plain newer post",
196
+ bodyMarkdown: "root from 2025",
197
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
198
+ });
199
+
200
+ const updated = await fetchFeed(app, "?sort=updated");
201
+ expect(updated.indexOf("Old thread with a new reply")).toBeLessThan(
202
+ updated.indexOf("Plain newer post"),
203
+ );
204
+
205
+ // Same URL param, same axis as the page — the default stays chronological.
206
+ const chronological = await fetchFeed(app, "");
207
+ expect(chronological.indexOf("Plain newer post")).toBeLessThan(
208
+ chronological.indexOf("Old thread with a new reply"),
209
+ );
210
+ });
211
+
212
+ it("scopes a year filter to the axis the feed is sorted on", async () => {
213
+ const { app, services } = setupApp();
214
+ const oldThread = await services.posts.create({
215
+ format: "note",
216
+ title: "Extended in 2026",
217
+ bodyMarkdown: "root from 2019",
218
+ publishedAt: Date.UTC(2019, 0, 1) / 1000,
219
+ });
220
+ await services.posts.create({
221
+ format: "note",
222
+ bodyMarkdown: "addition",
223
+ replyToId: oldThread.id,
224
+ publishedAt: Date.UTC(2026, 5, 1) / 1000,
225
+ });
226
+ await services.posts.create({
227
+ format: "note",
228
+ title: "Published in 2025",
229
+ bodyMarkdown: "root from 2025",
230
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
231
+ });
232
+
233
+ const byActivity = await fetchFeed(app, "?sort=updated&year=2026");
234
+ expect(byActivity).toContain("Extended in 2026");
235
+ expect(byActivity).not.toContain("Published in 2025");
236
+
237
+ const byPublication = await fetchFeed(app, "?year=2025");
238
+ expect(byPublication).toContain("Published in 2025");
239
+ expect(byPublication).not.toContain("Extended in 2026");
240
+ });
241
+
242
+ it("keeps the same order after an old thread gains a reply", async () => {
243
+ const { app, services } = setupApp();
244
+ const first = await services.posts.create({
245
+ format: "note",
246
+ title: "Alpha",
247
+ bodyMarkdown: "alpha root",
248
+ publishedAt: Date.UTC(2020, 0, 1) / 1000,
249
+ });
250
+ await services.posts.create({
251
+ format: "note",
252
+ title: "Beta",
253
+ bodyMarkdown: "beta root",
254
+ publishedAt: Date.UTC(2021, 0, 1) / 1000,
255
+ });
256
+
257
+ const before = await fetchFeed(app, "");
258
+ const orderBefore = before.indexOf("Beta") < before.indexOf("Alpha");
259
+
260
+ await services.posts.create({
261
+ format: "note",
262
+ bodyMarkdown: "late addition",
263
+ replyToId: first.id,
264
+ publishedAt: Date.UTC(2026, 0, 1) / 1000,
265
+ });
266
+
267
+ const after = await fetchFeed(app, "");
268
+ expect(after.indexOf("Beta") < after.indexOf("Alpha")).toBe(orderBefore);
269
+ });
270
+ });
271
+
99
272
  describe("archive page legacy param redirect", () => {
100
273
  it("redirects legacy boolean params to their single-word spelling", async () => {
101
274
  const { app } = setupApp();
@@ -133,3 +306,211 @@ describe("archive page legacy param redirect", () => {
133
306
  expect(res.headers.get("location")).toBe("/archive?title=any");
134
307
  });
135
308
  });
309
+
310
+ describe("archive page ordering", () => {
311
+ it("orders threads by their root publication time, not recent replies", async () => {
312
+ const { app, services } = setupApp();
313
+ const olderThread = await services.posts.create({
314
+ format: "note",
315
+ title: "Older active thread",
316
+ bodyMarkdown: "older root",
317
+ publishedAt: Date.UTC(2024, 0, 1) / 1000,
318
+ });
319
+ await services.posts.create({
320
+ format: "note",
321
+ bodyMarkdown: "recent reply",
322
+ replyToId: olderThread.id,
323
+ publishedAt: Date.UTC(2026, 0, 1) / 1000,
324
+ });
325
+ await services.posts.create({
326
+ format: "note",
327
+ title: "Newer root thread",
328
+ bodyMarkdown: "newer root",
329
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
330
+ });
331
+
332
+ const res = await app.request("/archive");
333
+ expect(res.status).toBe(200);
334
+ const html = await res.text();
335
+
336
+ expect(html.indexOf("Newer root thread")).toBeLessThan(
337
+ html.indexOf("Older active thread"),
338
+ );
339
+ });
340
+
341
+ it("keeps newer posts ahead of older pinned posts", async () => {
342
+ const { app, services } = setupApp();
343
+ await services.posts.create({
344
+ format: "note",
345
+ title: "Older pinned post",
346
+ bodyMarkdown: "older",
347
+ pinned: true,
348
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
349
+ });
350
+ await services.posts.create({
351
+ format: "note",
352
+ title: "Newer unpinned post",
353
+ bodyMarkdown: "newer",
354
+ publishedAt: Date.UTC(2026, 0, 1) / 1000,
355
+ });
356
+
357
+ const res = await app.request("/archive?view=list");
358
+ expect(res.status).toBe(200);
359
+ const html = await res.text();
360
+
361
+ expect(html.indexOf("Newer unpinned post")).toBeLessThan(
362
+ html.indexOf("Older pinned post"),
363
+ );
364
+ });
365
+ });
366
+
367
+ describe("archive page sort=updated", () => {
368
+ /**
369
+ * An old thread that recently gained a reply, plus a younger standalone
370
+ * thread. The two axes disagree about their order, which is the whole point
371
+ * of the toggle.
372
+ */
373
+ async function seedDivergingThreads(services: {
374
+ posts: {
375
+ create: (data: Record<string, unknown>) => Promise<{ id: string }>;
376
+ };
377
+ }) {
378
+ const olderThread = await services.posts.create({
379
+ format: "note",
380
+ title: "Growing thread",
381
+ bodyMarkdown: "root from 2019",
382
+ publishedAt: Date.UTC(2019, 2, 5) / 1000,
383
+ });
384
+ await services.posts.create({
385
+ format: "note",
386
+ bodyMarkdown: "reply from 2026",
387
+ replyToId: olderThread.id,
388
+ publishedAt: Date.UTC(2026, 7, 1) / 1000,
389
+ });
390
+ await services.posts.create({
391
+ format: "note",
392
+ title: "Untouched newer thread",
393
+ bodyMarkdown: "root from 2025",
394
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
395
+ });
396
+ }
397
+
398
+ it("puts recently extended threads first", async () => {
399
+ const { app, services } = setupApp();
400
+ await seedDivergingThreads(services);
401
+
402
+ const res = await app.request("/archive?sort=updated");
403
+ expect(res.status).toBe(200);
404
+ const html = await res.text();
405
+
406
+ expect(html.indexOf("Growing thread")).toBeLessThan(
407
+ html.indexOf("Untouched newer thread"),
408
+ );
409
+ });
410
+
411
+ it("buckets months on the same axis it sorts by", async () => {
412
+ const { app, services } = setupApp();
413
+ await seedDivergingThreads(services);
414
+
415
+ const byPublished = await (await app.request("/archive")).text();
416
+ expect(byPublished).toContain("March 2019");
417
+ expect(byPublished).not.toContain("August 2026");
418
+
419
+ const byActivity = await (
420
+ await app.request("/archive?sort=updated")
421
+ ).text();
422
+ expect(byActivity).toContain("August 2026");
423
+ expect(byActivity).not.toContain("March 2019");
424
+ });
425
+
426
+ it("scopes the year filter to the active axis", async () => {
427
+ const { app, services } = setupApp();
428
+ await seedDivergingThreads(services);
429
+
430
+ const activityYear = await (
431
+ await app.request("/archive?sort=updated&year=2026")
432
+ ).text();
433
+ expect(activityYear).toContain("Growing thread");
434
+ expect(activityYear).not.toContain("Untouched newer thread");
435
+
436
+ const publishedYear = await (
437
+ await app.request("/archive?year=2026")
438
+ ).text();
439
+ expect(publishedYear).not.toContain("Growing thread");
440
+ });
441
+
442
+ it("keeps the default axis unchanged without the param", async () => {
443
+ const { app, services } = setupApp();
444
+ await seedDivergingThreads(services);
445
+
446
+ const html = await (await app.request("/archive")).text();
447
+ expect(html.indexOf("Untouched newer thread")).toBeLessThan(
448
+ html.indexOf("Growing thread"),
449
+ );
450
+ });
451
+
452
+ it("distinguishes the updated view in the document title", async () => {
453
+ const { app, services } = setupApp();
454
+ await seedDivergingThreads(services);
455
+
456
+ const byPublished = await (await app.request("/archive")).text();
457
+ expect(byPublished).toContain("<title>Archive - ");
458
+ expect(byPublished).not.toContain("Recently updated");
459
+
460
+ const byActivity = await (
461
+ await app.request("/archive?sort=updated")
462
+ ).text();
463
+ expect(byActivity).toContain("<title>Archive - Recently updated - ");
464
+ });
465
+
466
+ it("ignores an unknown sort value", async () => {
467
+ const { app, services } = setupApp();
468
+ await seedDivergingThreads(services);
469
+
470
+ const html = await (await app.request("/archive?sort=nonsense")).text();
471
+ expect(html.indexOf("Untouched newer thread")).toBeLessThan(
472
+ html.indexOf("Growing thread"),
473
+ );
474
+ });
475
+
476
+ // The quiet-reply switch promises "won't move the thread to the top of
477
+ // latest". The archive is not Latest — it is the canonical all-posts view,
478
+ // and it already shows Hidden-from-Latest content. So its updated sort
479
+ // reports when the Thread actually changed.
480
+ it("surfaces quietly extended threads even though Latest does not", async () => {
481
+ const { app, services } = setupApp();
482
+ const quietThread = await services.posts.create({
483
+ format: "note",
484
+ title: "Quietly extended thread",
485
+ bodyMarkdown: "root from 2019",
486
+ publishedAt: Date.UTC(2019, 2, 5) / 1000,
487
+ });
488
+ await services.posts.create({
489
+ format: "note",
490
+ bodyMarkdown: "quiet addendum",
491
+ replyToId: quietThread.id,
492
+ publishedAt: Date.UTC(2026, 7, 1) / 1000,
493
+ quietReply: true,
494
+ });
495
+ await services.posts.create({
496
+ format: "note",
497
+ title: "Untouched newer thread",
498
+ bodyMarkdown: "root from 2025",
499
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
500
+ });
501
+
502
+ const html = await (await app.request("/archive?sort=updated")).text();
503
+ expect(html.indexOf("Quietly extended thread")).toBeLessThan(
504
+ html.indexOf("Untouched newer thread"),
505
+ );
506
+ expect(html).toContain("August 2026");
507
+
508
+ // Latest still honors the quiet flag.
509
+ const roots = await services.posts.list({
510
+ status: "published",
511
+ excludeReplies: true,
512
+ excludeLatestHidden: true,
513
+ });
514
+ expect(roots[0]?.title).toBe("Untouched newer thread");
515
+ });
516
+ });
@@ -1,11 +1,11 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createTestApp } from "../../../__tests__/helpers/app.js";
3
3
  import { collectionRoutes } from "../collection.js";
4
4
  import { collectionsPageRoutes } from "../collections.js";
5
5
  import { pageRoutes } from "../page.js";
6
6
 
7
- function createCollectionRoutingTestApp() {
8
- const testApp = createTestApp();
7
+ function createCollectionRoutingTestApp(rssPublishDelaySeconds = 0) {
8
+ const testApp = createTestApp({ rssPublishDelaySeconds });
9
9
  const { app } = testApp;
10
10
 
11
11
  app.use("*", async (c, next) => {
@@ -21,6 +21,10 @@ function createCollectionRoutingTestApp() {
21
21
  return testApp;
22
22
  }
23
23
 
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ });
27
+
24
28
  async function createCollectionWithPost(
25
29
  services: ReturnType<typeof createTestApp>["services"],
26
30
  {
@@ -42,7 +46,7 @@ async function createCollectionWithPost(
42
46
  bodyMarkdown: bodyMarkdown ?? postTitle,
43
47
  status: "published",
44
48
  });
45
- await services.collections.addPost(collection.id, post.id);
49
+ await services.collections.addThread(collection.id, post.id);
46
50
  return { collection, post };
47
51
  }
48
52
 
@@ -50,11 +54,17 @@ describe("Collection Routing", () => {
50
54
  it("renders a single collection at its root path", async () => {
51
55
  const { app, services } = createCollectionRoutingTestApp();
52
56
 
53
- await createCollectionWithPost(services, {
57
+ const { post } = await createCollectionWithPost(services, {
54
58
  slug: "reading",
55
59
  title: "Reading",
56
60
  postTitle: "Book log",
57
61
  });
62
+ await services.posts.create({
63
+ format: "note",
64
+ bodyMarkdown: "Thread follow-up",
65
+ status: "published",
66
+ replyToId: post.id,
67
+ });
58
68
 
59
69
  const res = await app.request("/reading");
60
70
 
@@ -64,6 +74,23 @@ describe("Collection Routing", () => {
64
74
  const html = await res.text();
65
75
  expect(html).toContain("Reading");
66
76
  expect(html).toContain("Book log");
77
+ expect(html).toContain("Thread follow-up");
78
+ });
79
+
80
+ it("hides the collection feed button when feeds are disabled", async () => {
81
+ const { app, services } = createCollectionRoutingTestApp();
82
+ await services.settings.set("RSS_FEEDS_ENABLED", "false");
83
+ await createCollectionWithPost(services, {
84
+ slug: "reading",
85
+ title: "Reading",
86
+ postTitle: "Book log",
87
+ });
88
+
89
+ const response = await app.request("/reading");
90
+ const html = await response.text();
91
+
92
+ expect(response.status).toBe(200);
93
+ expect(html).not.toContain('class="feed-link"');
67
94
  });
68
95
 
69
96
  it("redirects namespaced single-collection paths to the root canonical URL", async () => {
@@ -139,6 +166,37 @@ describe("Collection Routing", () => {
139
166
  expect(xml).toContain("Film log");
140
167
  });
141
168
 
169
+ it("applies the RSS publication delay to Collection feeds", async () => {
170
+ const currentTime = 2_000_000;
171
+ vi.spyOn(Date, "now").mockReturnValue(currentTime * 1000);
172
+ const { app, services } = createCollectionRoutingTestApp(300);
173
+ const collection = await services.collections.create({
174
+ slug: "reading",
175
+ title: "Reading",
176
+ });
177
+ const eligible = await services.posts.create({
178
+ format: "note",
179
+ title: "Eligible Book Log",
180
+ bodyMarkdown: "Old enough for RSS",
181
+ status: "published",
182
+ publishedAt: currentTime - 300,
183
+ });
184
+ const recent = await services.posts.create({
185
+ format: "note",
186
+ title: "Recent Book Log",
187
+ bodyMarkdown: "Still inside the edit window",
188
+ status: "published",
189
+ publishedAt: currentTime,
190
+ });
191
+ await services.collections.addThread(collection.id, eligible.id);
192
+ await services.collections.addThread(collection.id, recent.id);
193
+
194
+ const xml = await (await app.request("/reading/feed")).text();
195
+
196
+ expect(xml).toContain("Eligible Book Log");
197
+ expect(xml).not.toContain("Recent Book Log");
198
+ });
199
+
142
200
  it("redirects canonical collection pages and feeds to collection aliases", async () => {
143
201
  const { app, services } = createCollectionRoutingTestApp();
144
202
 
@@ -30,7 +30,7 @@ describe("Collections Listing Page - Data Logic", () => {
30
30
  );
31
31
  });
32
32
 
33
- it("returns collections with post counts and recent activity", async () => {
33
+ it("returns collections with Thread counts and recent activity", async () => {
34
34
  const recipes = await collectionService.create({
35
35
  slug: "recipes",
36
36
  title: "Recipes",
@@ -40,7 +40,7 @@ describe("Collections Listing Page - Data Logic", () => {
40
40
  title: "Travel",
41
41
  });
42
42
 
43
- // Add posts to recipes collection via junction table
43
+ // Add Threads to the recipes Collection.
44
44
  const p1 = await postService.create({
45
45
  format: "note",
46
46
  bodyMarkdown: "Recipe 1",
@@ -49,8 +49,8 @@ describe("Collections Listing Page - Data Logic", () => {
49
49
  format: "note",
50
50
  bodyMarkdown: "Recipe 2",
51
51
  });
52
- await collectionService.addPost(recipes.id, p1.id);
53
- await collectionService.addPost(recipes.id, p2.id);
52
+ await collectionService.addThread(recipes.id, p1.id);
53
+ await collectionService.addThread(recipes.id, p2.id);
54
54
 
55
55
  const directory = await collectionService.listDirectoryData();
56
56
 
@@ -59,9 +59,9 @@ describe("Collections Listing Page - Data Logic", () => {
59
59
  (c) => c.slug === "recipes",
60
60
  );
61
61
  const travelResult = directory.collections.find((c) => c.slug === "travel");
62
- expect(recipesResult?.postCount).toBe(2);
62
+ expect(recipesResult?.threadCount).toBe(2);
63
63
  expect(recipesResult?.recentActivityAt).toBe(p2.lastActivityAt);
64
- expect(travelResult?.postCount).toBe(0);
64
+ expect(travelResult?.threadCount).toBe(0);
65
65
  expect(travelResult?.recentActivityAt).toBeGreaterThan(0);
66
66
  });
67
67
 
@@ -86,13 +86,13 @@ describe("Collections Listing Page - Data Logic", () => {
86
86
  bodyMarkdown: "Will remain",
87
87
  });
88
88
 
89
- await collectionService.addPost(col.id, post.id);
90
- await collectionService.addPost(col.id, post2.id);
89
+ await collectionService.addThread(col.id, post.id);
90
+ await collectionService.addThread(col.id, post2.id);
91
91
 
92
92
  await postService.delete(post.id);
93
93
 
94
94
  const directory = await collectionService.listDirectoryData();
95
- expect(directory.collections[0]?.postCount).toBe(1);
95
+ expect(directory.collections[0]?.threadCount).toBe(1);
96
96
  expect(directory.collections[0]?.recentActivityAt).toBe(
97
97
  post2.lastActivityAt,
98
98
  );
@@ -39,12 +39,16 @@ describe("Featured Page - Data Logic", () => {
39
39
  bodyMarkdown: "Normal post",
40
40
  status: "published",
41
41
  });
42
- await postService.create({
42
+ // Featuring an unpublished post is refused outright, so the only way a
43
+ // draft carries the flag is by being unpublished after the fact — the
44
+ // case this filter still has to catch.
45
+ const unpublished = await postService.create({
43
46
  format: "note",
44
47
  bodyMarkdown: "Draft featured",
45
48
  featured: true,
46
- status: "draft",
49
+ status: "published",
47
50
  });
51
+ await postService.update(unpublished.id, { status: "draft" });
48
52
 
49
53
  const posts = await postService.list({
50
54
  featured: true,