@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,10 +27,15 @@ import type {
27
27
  import { FORMATS, MEDIA_KINDS } from "../../types.js";
28
28
  import { ArchivePage } from "../../ui/pages/ArchivePage.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 { getNavigationData } from "../../lib/navigation.js";
31
36
  import { buildPageTitle } from "../../lib/page-title.js";
32
37
  import { renderPublicPage } from "../../lib/render.js";
33
- import { formatYearMonth, toISOString } from "../../lib/time.js";
38
+ import { formatYearMonth } from "../../lib/time.js";
34
39
  import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
35
40
  import {
36
41
  createMediaContext,
@@ -207,6 +212,8 @@ function buildArchivePostFilters(
207
212
  hasMedia: params.hasMedia,
208
213
  hasTitle: params.hasTitle,
209
214
  hasReplies: params.hasReplies,
215
+ sortBy: "published",
216
+ ignorePinnedSort: true,
210
217
  };
211
218
  }
212
219
 
@@ -456,7 +463,9 @@ export async function renderArchivePage(
456
463
  isAuthenticated={navData.isAuthenticated}
457
464
  sitePathPrefix={navData.sitePathPrefix}
458
465
  timeZone={appConfig.timeZone}
459
- feedHref={`/archive/feed${feedQuery}`}
466
+ feedHref={
467
+ appConfig.rssFeedsEnabled ? `/archive/feed${feedQuery}` : undefined
468
+ }
460
469
  />
461
470
  ),
462
471
  });
@@ -615,6 +624,12 @@ async function buildArchiveFeedData(
615
624
  const collection = params.collectionSlug
616
625
  ? await services.collections.getBySlug(params.collectionSlug)
617
626
  : undefined;
627
+ const rssPublishedBefore = getRssPublishedBefore(
628
+ appConfig.rssPublishDelaySeconds,
629
+ );
630
+ const yearPublishedBefore = params.validYear
631
+ ? Date.UTC(params.validYear + 1, 0, 1) / 1000
632
+ : undefined;
618
633
 
619
634
  // Feed mirrors the unauthenticated archive page: published + non-private,
620
635
  // including Hidden-from-Latest. /archive is the canonical "all posts" view,
@@ -634,9 +649,12 @@ async function buildArchiveFeedData(
634
649
  ...(params.validYear
635
650
  ? {
636
651
  publishedAfter: Date.UTC(params.validYear, 0, 1) / 1000,
637
- publishedBefore: Date.UTC(params.validYear + 1, 0, 1) / 1000,
638
652
  }
639
653
  : {}),
654
+ publishedBefore:
655
+ yearPublishedBefore === undefined
656
+ ? rssPublishedBefore
657
+ : Math.min(yearPublishedBefore, rssPublishedBefore),
640
658
  limit: appConfig.rssFeedLimit,
641
659
  };
642
660
 
@@ -648,7 +666,9 @@ async function buildArchiveFeedData(
648
666
  // Batch load media, aliases, and thread replies
649
667
  const postIds = posts.map((p) => p.id);
650
668
  const [threadMap, rawMediaMap, aliasesMap] = await Promise.all([
651
- services.posts.getPublishedThreads(rootIds),
669
+ services.posts.getPublishedThreads(rootIds, {
670
+ publishedBefore: rssPublishedBefore,
671
+ }),
652
672
  services.media.getByPostIds(postIds),
653
673
  services.paths.getPostAliases(postIds),
654
674
  ]);
@@ -702,9 +722,9 @@ async function buildArchiveFeedData(
702
722
  aliasMap,
703
723
  ).map((postView, index) => {
704
724
  const post = posts[index] as (typeof posts)[number];
725
+ const thread = threadMap.get(post.id);
705
726
 
706
727
  // Build thread replies
707
- const thread = threadMap.get(post.id);
708
728
  const replies =
709
729
  thread && thread.length > 1
710
730
  ? toPostViews(
@@ -720,7 +740,7 @@ async function buildArchiveFeedData(
720
740
 
721
741
  return {
722
742
  ...postView,
723
- feedUpdatedAt: toISOString(post.lastActivityAt),
743
+ feedUpdatedAt: getFeedEntryUpdatedAt(post, thread),
724
744
  threadReplies: replies,
725
745
  };
726
746
  });
@@ -748,7 +768,7 @@ archiveRoutes.get("/feed", async (c) => {
748
768
  return new Response(defaultFeedRenderer(feedData), {
749
769
  headers: {
750
770
  "Content-Type": "application/atom+xml; charset=utf-8",
751
- "Cache-Control": "public, max-age=180",
771
+ "Cache-Control": RSS_FEED_CACHE_CONTROL,
752
772
  },
753
773
  });
754
774
  });
@@ -19,9 +19,13 @@ import {
19
19
  } from "../../lib/collection-sort.js";
20
20
  import { assembleCollectionTimeline } from "../../lib/timeline.js";
21
21
  import { defaultFeedRenderer } from "../../lib/feed.js";
22
+ import {
23
+ getFeedEntryUpdatedAt,
24
+ getRssPublishedBefore,
25
+ RSS_FEED_CACHE_CONTROL,
26
+ } from "../../lib/feed-policy.js";
22
27
  import { toPlainText as markdownToPlainText } from "../../lib/markdown.js";
23
28
  import { buildMediaMap } from "../../lib/media-helpers.js";
24
- import { toISOString } from "../../lib/time.js";
25
29
  import { createMediaContext, toPostViews } from "../../lib/view.js";
26
30
  import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
27
31
  import {
@@ -147,16 +151,17 @@ export async function renderCollectionPage(
147
151
  );
148
152
  const isAggregate = selection.collections.length > 1;
149
153
 
150
- const ratedPostCount = await c.var.services.posts.countUpTo(
151
- {
154
+ const ratedThreadCount =
155
+ await c.var.services.posts.countCollectionThreadRootsUpToForCollections(
152
156
  collectionIds,
153
- status: "published",
154
- excludePrivate: !navData.isAuthenticated,
155
- hasRating: true,
156
- },
157
- 2,
158
- );
159
- const showRatingSort = supportsCollectionRatingSort(ratedPostCount);
157
+ {
158
+ status: "published",
159
+ excludePrivate: !navData.isAuthenticated,
160
+ hasRating: true,
161
+ },
162
+ 2,
163
+ );
164
+ const showRatingSort = supportsCollectionRatingSort(ratedThreadCount);
160
165
  const requestedDefaultSort = isAggregate
161
166
  ? "newest"
162
167
  : primaryCollection.sortOrder;
@@ -219,7 +224,17 @@ export async function renderCollectionPage(
219
224
  defaultSort={defaultSort}
220
225
  showRatingSort={showRatingSort}
221
226
  isAuthenticated={navData.isAuthenticated}
227
+ isInNavigation={navData.links.some(
228
+ (item) =>
229
+ item.type === "collection" &&
230
+ item.collectionId === primaryCollection.id,
231
+ )}
222
232
  sitePathPrefix={navData.sitePathPrefix}
233
+ feedHref={
234
+ c.var.appConfig.rssFeedsEnabled
235
+ ? `${canonicalPagePath}/feed`
236
+ : undefined
237
+ }
223
238
  />
224
239
  ),
225
240
  });
@@ -254,6 +269,9 @@ export async function renderCollectionFeed(
254
269
  const siteUrl = appConfig.siteUrl;
255
270
  const siteLanguage = appConfig.siteLanguage;
256
271
  const feedLimit = appConfig.rssFeedLimit;
272
+ const publishedBefore = getRssPublishedBefore(
273
+ appConfig.rssPublishDelaySeconds,
274
+ );
257
275
  const primaryCollection = selection.collections[0];
258
276
  if (!primaryCollection) return null;
259
277
 
@@ -264,6 +282,7 @@ export async function renderCollectionFeed(
264
282
  status: "published",
265
283
  excludePrivate: true,
266
284
  ignoreCollectionPinnedSort: true,
285
+ publishedBefore,
267
286
  limit: feedLimit,
268
287
  },
269
288
  );
@@ -274,7 +293,7 @@ export async function renderCollectionFeed(
274
293
 
275
294
  const postIds = posts.map((post) => post.id);
276
295
  const [threadMap, rawMediaMap, aliasesMap] = await Promise.all([
277
- c.var.services.posts.getPublishedThreads(rootIds),
296
+ c.var.services.posts.getPublishedThreads(rootIds, { publishedBefore }),
278
297
  c.var.services.media.getByPostIds(postIds),
279
298
  c.var.services.paths.getPostAliases(postIds),
280
299
  ]);
@@ -345,15 +364,9 @@ export async function renderCollectionFeed(
345
364
  )
346
365
  : undefined;
347
366
 
348
- // feedUpdatedAt = max(lastActivityAt, collectedAt)
349
- const lastActivity = toISOString(post.lastActivityAt);
350
- const collectedIso = collectedAt ? toISOString(collectedAt) : null;
351
- const feedUpdatedAt =
352
- collectedIso && collectedIso > lastActivity ? collectedIso : lastActivity;
353
-
354
367
  return {
355
368
  ...postView,
356
- feedUpdatedAt,
369
+ feedUpdatedAt: getFeedEntryUpdatedAt(post, thread, [collectedAt]),
357
370
  threadReplies: replies,
358
371
  };
359
372
  });
@@ -382,7 +395,7 @@ export async function renderCollectionFeed(
382
395
  return new Response(xml, {
383
396
  headers: {
384
397
  "Content-Type": "application/atom+xml; charset=utf-8",
385
- "Cache-Control": "public, max-age=180",
398
+ "Cache-Control": RSS_FEED_CACHE_CONTROL,
386
399
  },
387
400
  });
388
401
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Collections Listing Page Route
3
3
  *
4
- * Lists all collections with their post counts.
4
+ * Lists all collections with their Thread counts.
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
@@ -70,6 +70,11 @@ collectionsPageRoutes.get("/", async (c) => {
70
70
  <CollectionsPage
71
71
  items={directoryData.items}
72
72
  isAuthenticated={navData.isAuthenticated ?? false}
73
+ navigationCollectionIds={navData.links.flatMap((item) =>
74
+ item.type === "collection" && item.collectionId
75
+ ? [item.collectionId]
76
+ : [],
77
+ )}
73
78
  sitePathPrefix={navData.sitePathPrefix}
74
79
  siteOrigin={c.var.appConfig.siteOrigin}
75
80
  />
@@ -5,14 +5,19 @@
5
5
  * Must be registered last.
6
6
  */
7
7
 
8
+ import { msg } from "@lingui/core/macro";
8
9
  import { Hono, type Context } from "hono";
9
10
  import type { Bindings } from "../../types.js";
10
11
  import type { AppVariables } from "../../types/app-context.js";
12
+ import { getI18n } from "../../i18n/index.js";
13
+ import { requireAuth } from "../../middleware/auth.js";
11
14
  import { PostPage } from "../../ui/pages/PostPage.js";
15
+ import { DraftPreviewBar } from "../../ui/shared/DraftPreviewBar.js";
12
16
  import { getNavigationData } from "../../lib/navigation.js";
13
17
  import { renderPublicPage } from "../../lib/render.js";
14
18
  import { buildPageTitle } from "../../lib/page-title.js";
15
19
  import { buildPostMeta } from "../../lib/post-meta.js";
20
+ import { isReservedPath } from "../../lib/constants.js";
16
21
  import {
17
22
  assemblePostPageDisplay,
18
23
  type PostPageDisplayData,
@@ -203,7 +208,7 @@ function canRenderDraftAboutEditor(
203
208
  async function renderPost(
204
209
  c: Context<Env>,
205
210
  post: Post,
206
- options: { allowDraft?: boolean } = {},
211
+ options: { allowDraft?: boolean; isPreview?: boolean } = {},
207
212
  ) {
208
213
  // Start navData fetch immediately — it's independent of thread/media queries
209
214
  const navDataPromise = getNavigationData(c);
@@ -211,6 +216,7 @@ async function renderPost(
211
216
  // Private-post access is validated before renderPost() is called.
212
217
  isAuthenticated: true,
213
218
  allowDraft: options.allowDraft,
219
+ includeDraftThread: options.isPreview,
214
220
  });
215
221
  if (!display) {
216
222
  return c.notFound();
@@ -218,6 +224,37 @@ async function renderPost(
218
224
 
219
225
  const navData = await navDataPromise;
220
226
  const meta = buildPostMeta(post, navData.siteName);
227
+ const postView =
228
+ options.isPreview && display.postView.status === "draft"
229
+ ? {
230
+ ...display.postView,
231
+ permalink: toPublicPath(
232
+ `/preview/${display.postView.slug}`,
233
+ c.var.appConfig.sitePathPrefix,
234
+ ),
235
+ }
236
+ : display.postView;
237
+ const threadPostViews = options.isPreview
238
+ ? display.threadPostViews?.map((threadPost) =>
239
+ threadPost.status === "draft"
240
+ ? {
241
+ ...threadPost,
242
+ permalink: toPublicPath(
243
+ `/preview/${threadPost.slug}`,
244
+ c.var.appConfig.sitePathPrefix,
245
+ ),
246
+ }
247
+ : threadPost,
248
+ )
249
+ : display.threadPostViews;
250
+ const previewTitle = options.isPreview
251
+ ? getI18n(c)._(
252
+ msg({
253
+ message: "Draft preview",
254
+ comment: "@context: Browser title prefix for a draft preview page",
255
+ }),
256
+ )
257
+ : null;
221
258
  const canonicalHref = buildPostCanonicalHref(
222
259
  display.postView,
223
260
  display.threadPostViews,
@@ -225,7 +262,9 @@ async function renderPost(
225
262
  );
226
263
 
227
264
  return renderPublicPage(c, {
228
- title: buildPageTitle(meta.title, navData.siteName),
265
+ title: previewTitle
266
+ ? buildPageTitle(previewTitle, meta.title, navData.siteName)
267
+ : buildPageTitle(meta.title, navData.siteName),
229
268
  description: meta.description,
230
269
  ...(post.status === "published"
231
270
  ? {
@@ -247,18 +286,59 @@ async function renderPost(
247
286
  }
248
287
  : {}),
249
288
  navData,
289
+ ...(options.isPreview
290
+ ? {
291
+ pageChrome: <DraftPreviewBar />,
292
+ showComposeDialog: false,
293
+ noindex: true,
294
+ }
295
+ : {}),
250
296
  content: (
251
- <PostPage post={display.postView} threadPosts={display.threadPostViews} />
297
+ <PostPage
298
+ post={postView}
299
+ threadPosts={threadPostViews}
300
+ isPreview={options.isPreview}
301
+ />
252
302
  ),
253
303
  });
254
304
  }
255
305
 
306
+ pageRoutes.get("/preview/:slug", requireAuth(), async (c) => {
307
+ const post = await c.var.services.posts.getBySlug(c.req.param("slug"));
308
+ if (!post) return c.notFound();
309
+
310
+ if (post.status === "published") {
311
+ return c.redirect(
312
+ toPublicPath(`/${post.slug}`, c.var.appConfig.sitePathPrefix),
313
+ );
314
+ }
315
+
316
+ c.header("Cache-Control", "private, no-store");
317
+ c.header("X-Robots-Tag", "noindex, nofollow");
318
+ return renderPost(c, post, { allowDraft: true, isPreview: true });
319
+ });
320
+
256
321
  // Catch-all for path-registry backed post URLs, aliases, and redirects
257
322
  pageRoutes.get("/*", async (c) => {
258
323
  const fullPath = c.req.path.slice(1); // Remove leading /
259
324
  if (!fullPath) return c.notFound();
260
325
  const sitePathPrefix = c.var.appConfig.sitePathPrefix;
261
326
 
327
+ // Explicit application routes are registered before this catch-all. Any
328
+ // unmatched path under a reserved prefix is therefore an application 404,
329
+ // never a legacy alias or redirect from path_registry.
330
+ if (isReservedPath(fullPath)) return c.notFound();
331
+
332
+ // Stored redirects outrank the normal post/collection/deep-link resolvers,
333
+ // but can never shadow an explicit or reserved application route.
334
+ const resolved = await c.var.services.paths.resolve(fullPath);
335
+ if (resolved?.kind === "redirect" && resolved.redirectToPath) {
336
+ return c.redirect(
337
+ toPublicHref(`/${resolved.redirectToPath}`, sitePathPrefix),
338
+ resolved.redirectType ?? 301,
339
+ );
340
+ }
341
+
262
342
  if (fullPath.endsWith("/feed")) {
263
343
  const collectionPath = fullPath.slice(0, -"/feed".length);
264
344
  if (!collectionPath) return c.notFound();
@@ -360,16 +440,8 @@ pageRoutes.get("/*", async (c) => {
360
440
  }
361
441
  }
362
442
 
363
- const resolved = await c.var.services.paths.resolve(fullPath);
364
443
  if (!resolved) return c.notFound();
365
444
 
366
- if (resolved.kind === "redirect" && resolved.redirectToPath) {
367
- return c.redirect(
368
- toPublicHref(`/${resolved.redirectToPath}`, sitePathPrefix),
369
- resolved.redirectType ?? 301,
370
- );
371
- }
372
-
373
445
  if (resolved.kind === "archive" && resolved.archiveQuery) {
374
446
  const overrides = Object.fromEntries(
375
447
  new URLSearchParams(resolved.archiveQuery),
@@ -0,0 +1,18 @@
1
+ import { Hono } from "hono";
2
+ import { renderSiteSkill } from "../lib/site-skill.js";
3
+ import type { Bindings } from "../types.js";
4
+ import type { AppVariables } from "../types/app-context.js";
5
+
6
+ type Env = { Bindings: Bindings; Variables: AppVariables };
7
+
8
+ export const siteSkillRoutes = new Hono<Env>();
9
+
10
+ siteSkillRoutes.get("/skill.md", (c) => {
11
+ return new Response(renderSiteSkill(c.var.appConfig.siteUrl), {
12
+ headers: {
13
+ "Cache-Control": "public, max-age=300, s-maxage=3600",
14
+ "Content-Type": "text/markdown; charset=utf-8",
15
+ "X-Robots-Tag": "noindex",
16
+ },
17
+ });
18
+ });
@@ -11,7 +11,7 @@ import {
11
11
  media,
12
12
  navItems,
13
13
  pathRegistry,
14
- postCollections,
14
+ threadCollections,
15
15
  posts,
16
16
  session,
17
17
  settings as settingsTable,
@@ -165,9 +165,9 @@ describe("AuthService", () => {
165
165
  updatedAt: timestamp,
166
166
  });
167
167
 
168
- await db.insert(postCollections).values({
168
+ await db.insert(threadCollections).values({
169
169
  siteId: DEFAULT_TEST_SITE_ID,
170
- postId: "pst_01km9authdelete00000000000",
170
+ threadId: "pst_01km9authdelete00000000000",
171
171
  collectionId: "col_01km9authdelete00000000000",
172
172
  createdAt: timestamp,
173
173
  });
@@ -245,7 +245,9 @@ describe("AuthService", () => {
245
245
  const remainingDirectoryItems = await db
246
246
  .select()
247
247
  .from(collectionDirectoryItems);
248
- const remainingPostCollections = await db.select().from(postCollections);
248
+ const remainingThreadCollections = await db
249
+ .select()
250
+ .from(threadCollections);
249
251
  const remainingNavItems = await db.select().from(navItems);
250
252
  const remainingApiTokens = await db.select().from(apiTokens);
251
253
  const existingSites = await db.select().from(sites);
@@ -262,7 +264,7 @@ describe("AuthService", () => {
262
264
  expect(remainingCollections).toHaveLength(0);
263
265
  expect(remainingPaths).toHaveLength(0);
264
266
  expect(remainingDirectoryItems).toHaveLength(0);
265
- expect(remainingPostCollections).toHaveLength(0);
267
+ expect(remainingThreadCollections).toHaveLength(0);
266
268
  expect(remainingNavItems).toHaveLength(0);
267
269
  expect(remainingApiTokens).toHaveLength(0);
268
270
  expect(existingSites).toHaveLength(0);