@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
@@ -27,9 +27,13 @@ import type {
27
27
  } from "../../types.js";
28
28
  import type { AppVariables } from "../../types/app-context.js";
29
29
  import { defaultFeedRenderer } from "../../lib/feed.js";
30
+ import {
31
+ getFeedEntryUpdatedAt,
32
+ getRssPublishedBefore,
33
+ RSS_FEED_CACHE_CONTROL,
34
+ } from "../../lib/feed-policy.js";
30
35
  import { buildMediaMap } from "../../lib/media-helpers.js";
31
36
  import { getI18n } from "../../i18n/index.js";
32
- import { toISOString } from "../../lib/time.js";
33
37
  import { FORMATS } from "../../types/constants.js";
34
38
 
35
39
  import { createMediaContext, toPostViews } from "../../lib/view.js";
@@ -118,6 +122,7 @@ function collectReplyIds(threadMap: Map<string, Post[]>): string[] {
118
122
  async function buildLatestFeedData(
119
123
  c: Context<Env>,
120
124
  feedLimit: number,
125
+ publishedBefore: number,
121
126
  format?: Format,
122
127
  ): Promise<{ posts: Post[]; postViews: FeedPostView[] }> {
123
128
  const posts = await c.var.services.posts.list({
@@ -127,6 +132,7 @@ async function buildLatestFeedData(
127
132
  excludePrivate: true,
128
133
  format,
129
134
  ignorePinnedSort: true,
135
+ publishedBefore,
130
136
  limit: feedLimit,
131
137
  });
132
138
 
@@ -135,7 +141,7 @@ async function buildLatestFeedData(
135
141
 
136
142
  const mediaCtx = createMediaContext(c.var.appConfig);
137
143
  const [threadMap, mediaMap, aliasesMap] = await Promise.all([
138
- c.var.services.posts.getPublishedThreads(rootIds),
144
+ c.var.services.posts.getPublishedThreads(rootIds, { publishedBefore }),
139
145
  loadMediaMap(c, postIds, mediaCtx),
140
146
  c.var.services.paths.getPostAliases(postIds),
141
147
  ]);
@@ -162,9 +168,10 @@ async function buildLatestFeedData(
162
168
  aliasMap,
163
169
  ).map((postView, index) => {
164
170
  const post = posts[index] as (typeof posts)[number];
171
+ const thread = threadMap.get(post.id);
165
172
  return {
166
173
  ...postView,
167
- feedUpdatedAt: toISOString(post.lastActivityAt),
174
+ feedUpdatedAt: getFeedEntryUpdatedAt(post, thread),
168
175
  threadReplies: buildThreadReplies(
169
176
  post.id,
170
177
  threadMap,
@@ -187,10 +194,12 @@ async function buildLatestFeedData(
187
194
  async function buildFeaturedFeedData(
188
195
  c: Context<Env>,
189
196
  feedLimit: number,
197
+ publishedBefore: number,
190
198
  ): Promise<{ posts: Post[]; postViews: FeedPostView[] }> {
191
199
  const rootIds = await c.var.services.posts.listFeaturedThreadRootIds({
192
200
  status: "published",
193
201
  excludePrivate: true,
202
+ publishedBefore,
194
203
  limit: feedLimit,
195
204
  });
196
205
 
@@ -198,7 +207,9 @@ async function buildFeaturedFeedData(
198
207
  return { posts: [], postViews: [] };
199
208
  }
200
209
 
201
- const threadMap = await c.var.services.posts.getPublishedThreads(rootIds);
210
+ const threadMap = await c.var.services.posts.getPublishedThreads(rootIds, {
211
+ publishedBefore,
212
+ });
202
213
 
203
214
  // Extract root posts in the same order as rootIds
204
215
  const posts: Post[] = [];
@@ -241,16 +252,11 @@ async function buildFeaturedFeedData(
241
252
  aliasMap,
242
253
  ).map((postView, index) => {
243
254
  const post = posts[index] as (typeof posts)[number];
244
-
245
- // feedUpdatedAt = max(lastActivityAt, featuredAt)
246
- const lastActivity = toISOString(post.lastActivityAt);
247
- const featuredIso = post.featuredAt ? toISOString(post.featuredAt) : null;
248
- const feedUpdatedAt =
249
- featuredIso && featuredIso > lastActivity ? featuredIso : lastActivity;
255
+ const thread = threadMap.get(post.id);
250
256
 
251
257
  return {
252
258
  ...postView,
253
- feedUpdatedAt,
259
+ feedUpdatedAt: getFeedEntryUpdatedAt(post, thread),
254
260
  threadReplies: buildThreadReplies(post.id, threadMap, mediaMap, mediaCtx),
255
261
  };
256
262
  });
@@ -280,12 +286,15 @@ export async function buildFeedData(
280
286
  const siteUrl = appConfig.siteUrl;
281
287
  const siteLanguage = appConfig.siteLanguage;
282
288
  const feedLimit = appConfig.rssFeedLimit;
289
+ const publishedBefore = getRssPublishedBefore(
290
+ appConfig.rssPublishDelaySeconds,
291
+ );
283
292
  const kind = opts.kind;
284
293
 
285
294
  const { postViews } =
286
295
  kind === "featured"
287
- ? await buildFeaturedFeedData(c, feedLimit)
288
- : await buildLatestFeedData(c, feedLimit, opts.format);
296
+ ? await buildFeaturedFeedData(c, feedLimit, publishedBefore)
297
+ : await buildLatestFeedData(c, feedLimit, publishedBefore, opts.format);
289
298
 
290
299
  return {
291
300
  siteName,
@@ -333,7 +342,7 @@ export function renderFeed(xml: string) {
333
342
  return new Response(xml, {
334
343
  headers: {
335
344
  "Content-Type": "application/atom+xml; charset=utf-8",
336
- "Cache-Control": "public, max-age=180",
345
+ "Cache-Control": RSS_FEED_CACHE_CONTROL,
337
346
  },
338
347
  });
339
348
  }
@@ -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({
@@ -133,3 +165,60 @@ describe("archive page legacy param redirect", () => {
133
165
  expect(res.headers.get("location")).toBe("/archive?title=any");
134
166
  });
135
167
  });
168
+
169
+ describe("archive page ordering", () => {
170
+ it("orders threads by their root publication time, not recent replies", async () => {
171
+ const { app, services } = setupApp();
172
+ const olderThread = await services.posts.create({
173
+ format: "note",
174
+ title: "Older active thread",
175
+ bodyMarkdown: "older root",
176
+ publishedAt: Date.UTC(2024, 0, 1) / 1000,
177
+ });
178
+ await services.posts.create({
179
+ format: "note",
180
+ bodyMarkdown: "recent reply",
181
+ replyToId: olderThread.id,
182
+ publishedAt: Date.UTC(2026, 0, 1) / 1000,
183
+ });
184
+ await services.posts.create({
185
+ format: "note",
186
+ title: "Newer root thread",
187
+ bodyMarkdown: "newer root",
188
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
189
+ });
190
+
191
+ const res = await app.request("/archive");
192
+ expect(res.status).toBe(200);
193
+ const html = await res.text();
194
+
195
+ expect(html.indexOf("Newer root thread")).toBeLessThan(
196
+ html.indexOf("Older active thread"),
197
+ );
198
+ });
199
+
200
+ it("keeps newer posts ahead of older pinned posts", async () => {
201
+ const { app, services } = setupApp();
202
+ await services.posts.create({
203
+ format: "note",
204
+ title: "Older pinned post",
205
+ bodyMarkdown: "older",
206
+ pinned: true,
207
+ publishedAt: Date.UTC(2025, 0, 1) / 1000,
208
+ });
209
+ await services.posts.create({
210
+ format: "note",
211
+ title: "Newer unpinned post",
212
+ bodyMarkdown: "newer",
213
+ publishedAt: Date.UTC(2026, 0, 1) / 1000,
214
+ });
215
+
216
+ const res = await app.request("/archive?view=list");
217
+ expect(res.status).toBe(200);
218
+ const html = await res.text();
219
+
220
+ expect(html.indexOf("Newer unpinned post")).toBeLessThan(
221
+ html.indexOf("Older pinned post"),
222
+ );
223
+ });
224
+ });
@@ -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
  );
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { pageRoutes } from "../page.js";
4
+
5
+ function createPageTestApp(options: { authenticated?: boolean } = {}) {
6
+ const testApp = createTestApp(options);
7
+ testApp.app.route("/", pageRoutes);
8
+ return testApp;
9
+ }
10
+
11
+ async function createDraft(
12
+ services: ReturnType<typeof createTestApp>["services"],
13
+ title = "A private draft",
14
+ ) {
15
+ return services.posts.create({
16
+ format: "note",
17
+ title,
18
+ bodyMarkdown: `${title} body`,
19
+ status: "draft",
20
+ });
21
+ }
22
+
23
+ describe("draft preview route", () => {
24
+ it("redirects anonymous visitors to sign in and preserves the preview path", async () => {
25
+ const { app, services } = createPageTestApp();
26
+ const draft = await createDraft(services);
27
+
28
+ const res = await app.request(`/preview/${draft.slug}`, {
29
+ redirect: "manual",
30
+ });
31
+
32
+ expect(res.status).toBe(302);
33
+ expect(res.headers.get("Location")).toBe(
34
+ `/signin?redirect=${encodeURIComponent(`/preview/${draft.slug}`)}`,
35
+ );
36
+ });
37
+
38
+ it("renders the public post view with explicit private preview signals", async () => {
39
+ const { app, services } = createPageTestApp({ authenticated: true });
40
+ const draft = await createDraft(services);
41
+
42
+ const res = await app.request(`/preview/${draft.slug}`);
43
+
44
+ expect(res.status).toBe(200);
45
+ expect(res.headers.get("Cache-Control")).toBe("private, no-store");
46
+ expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
47
+
48
+ const html = await res.text();
49
+ expect(html).toContain(`data-post-id="${draft.id}"`);
50
+ expect(html).toContain("A private draft body");
51
+ expect(html).toContain("data-preview-status");
52
+ expect(html).toContain("Draft preview");
53
+ expect(html).toContain("This post isn’t published.");
54
+ expect(html).toContain('<meta name="robots" content="noindex, nofollow"');
55
+ expect(html).not.toContain('rel="canonical"');
56
+ expect(html).not.toContain("application/ld+json");
57
+ expect(html).not.toContain("article:published_time");
58
+ expect(html).not.toContain('id="compose-dialog"');
59
+ });
60
+
61
+ it("renders every draft in a saved thread", async () => {
62
+ const { app, services } = createPageTestApp({ authenticated: true });
63
+ const root = await createDraft(services, "Thread root");
64
+ const reply = await services.posts.create({
65
+ format: "quote",
66
+ quoteText: "Unpublished reply quote",
67
+ status: "draft",
68
+ replyToId: root.id,
69
+ });
70
+
71
+ const res = await app.request(`/preview/${root.slug}`);
72
+
73
+ expect(res.status).toBe(200);
74
+ const html = await res.text();
75
+ expect(html).toContain("Thread root body");
76
+ expect(html).toContain("Unpublished reply quote");
77
+ expect(html).toContain(`/preview/${reply.slug}`);
78
+ });
79
+
80
+ it("redirects a published post preview to its public permalink", async () => {
81
+ const { app, services } = createPageTestApp({ authenticated: true });
82
+ const post = await services.posts.create({
83
+ format: "note",
84
+ bodyMarkdown: "Already public",
85
+ status: "published",
86
+ });
87
+
88
+ const res = await app.request(`/preview/${post.slug}`, {
89
+ redirect: "manual",
90
+ });
91
+
92
+ expect(res.status).toBe(302);
93
+ expect(res.headers.get("Location")).toBe(`/${post.slug}`);
94
+ });
95
+
96
+ it("returns 404 for the preview root and unknown draft slugs", async () => {
97
+ const { app } = createPageTestApp({ authenticated: true });
98
+
99
+ expect((await app.request("/preview")).status).toBe(404);
100
+ expect((await app.request("/preview/missing-draft")).status).toBe(404);
101
+ });
102
+ });
103
+
104
+ describe("reserved path and redirect precedence", () => {
105
+ it("ignores a legacy redirect that collides with the preview route", async () => {
106
+ const { app, services } = createPageTestApp({ authenticated: true });
107
+ const draft = await createDraft(services);
108
+ await services.paths.create({
109
+ path: `preview/${draft.slug}`,
110
+ kind: "redirect",
111
+ redirectToPath: "elsewhere",
112
+ redirectType: 301,
113
+ });
114
+
115
+ const res = await app.request(`/preview/${draft.slug}`, {
116
+ redirect: "manual",
117
+ });
118
+
119
+ expect(res.status).toBe(200);
120
+ expect(await res.text()).toContain(`data-post-id="${draft.id}"`);
121
+ });
122
+
123
+ it("makes a legacy canonical preview path inert", async () => {
124
+ const { app, services } = createPageTestApp({ authenticated: true });
125
+ const post = await services.posts.create({
126
+ format: "note",
127
+ bodyMarkdown: "Old preview post",
128
+ status: "published",
129
+ });
130
+ await services.paths.updatePostSlug(post.id, "preview");
131
+
132
+ const res = await app.request("/preview", { redirect: "manual" });
133
+
134
+ expect(res.status).toBe(404);
135
+ });
136
+
137
+ it("keeps ordinary redirects working and ahead of derived collection feeds", async () => {
138
+ const { app, services } = createPageTestApp();
139
+ const collection = await services.collections.create({
140
+ slug: "reading",
141
+ title: "Reading",
142
+ });
143
+ const post = await services.posts.create({
144
+ format: "note",
145
+ bodyMarkdown: "Book log",
146
+ status: "published",
147
+ });
148
+ await services.collections.addThread(collection.id, post.id);
149
+ await services.paths.create({
150
+ path: "reading/feed",
151
+ kind: "redirect",
152
+ redirectToPath: "archive",
153
+ redirectType: 302,
154
+ });
155
+ await services.paths.create({
156
+ path: "old-link",
157
+ kind: "redirect",
158
+ redirectToPath: "reading",
159
+ redirectType: 301,
160
+ });
161
+
162
+ const feedRes = await app.request("/reading/feed", {
163
+ redirect: "manual",
164
+ });
165
+ expect(feedRes.status).toBe(302);
166
+ expect(feedRes.headers.get("Location")).toBe("/archive");
167
+
168
+ const ordinaryRes = await app.request("/old-link", {
169
+ redirect: "manual",
170
+ });
171
+ expect(ordinaryRes.status).toBe(301);
172
+ expect(ordinaryRes.headers.get("Location")).toBe("/reading");
173
+ });
174
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { searchRoutes } from "../search.js";
4
+
5
+ describe("Search Page Routes", () => {
6
+ it("keeps public HTML search available when anonymous JSON APIs are off", async () => {
7
+ const { app, services } = createTestApp({
8
+ authenticated: false,
9
+ fts: true,
10
+ });
11
+ await services.settings.set("PUBLIC_API_ENABLED", "false");
12
+ app.route("/search", searchRoutes);
13
+
14
+ const response = await app.request("/search?q=marker");
15
+
16
+ expect(response.status).toBe(200);
17
+ });
18
+
19
+ it("hides visible Thread collection tags on a matching child post", async () => {
20
+ const { app, services } = createTestApp({ fts: true });
21
+ app.route("/search", searchRoutes);
22
+
23
+ const collection = await services.collections.create({
24
+ slug: "search-thread",
25
+ title: "Search Thread Collection",
26
+ });
27
+ const root = await services.posts.create({
28
+ format: "note",
29
+ bodyMarkdown: "Thread root",
30
+ });
31
+ await services.posts.create({
32
+ format: "note",
33
+ bodyMarkdown: "Unique child search marker",
34
+ replyToId: root.id,
35
+ });
36
+ await services.collections.addThread(collection.id, root.id);
37
+
38
+ const response = await app.request("/search?q=marker");
39
+
40
+ expect(response.status).toBe(200);
41
+ const html = await response.text();
42
+ expect(html).toContain("Unique child search");
43
+ expect(html).toContain("<mark>marker</mark>");
44
+ expect(html).not.toContain("Search Thread Collection");
45
+ });
46
+
47
+ it("shows Thread collection tags on a matching root post", async () => {
48
+ const { app, services } = createTestApp({ fts: true });
49
+ app.route("/search", searchRoutes);
50
+
51
+ const collection = await services.collections.create({
52
+ slug: "search-root",
53
+ title: "Root Search Collection",
54
+ });
55
+ const root = await services.posts.create({
56
+ format: "note",
57
+ bodyMarkdown: "Unique root search marker",
58
+ });
59
+ await services.collections.addThread(collection.id, root.id);
60
+
61
+ const response = await app.request("/search?q=marker");
62
+
63
+ expect(response.status).toBe(200);
64
+ const html = await response.text();
65
+ expect(html).toContain("Unique root search");
66
+ expect(html).toContain("<mark>marker</mark>");
67
+ expect(html).toContain("Root Search Collection");
68
+ });
69
+ });