@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
@@ -11,6 +11,8 @@ const TEST_COLLECTION_ID = "col_test00000000000000000000001";
11
11
  const TEST_COLLECTION_ID_2 = "col_test00000000000000000000002";
12
12
  const TEST_POST_ID = "pst_test00000000000000000000001";
13
13
  const TEST_POST_ID_2 = "pst_test00000000000000000000002";
14
+ const TEST_POST_ID_3 = "pst_test00000000000000000000003";
15
+ const TEST_POST_ID_4 = "pst_test00000000000000000000004";
14
16
 
15
17
  function insertTestPath(
16
18
  sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
@@ -76,6 +78,7 @@ function insertTestPost(
76
78
  id: string;
77
79
  slug: string;
78
80
  title: string | null;
81
+ format?: "note" | "link" | "quote";
79
82
  status?: "draft" | "published";
80
83
  visibility?: "public" | "latest_hidden" | "private";
81
84
  },
@@ -87,11 +90,12 @@ function insertTestPost(
87
90
  id, site_id, format, status, visibility, title, thread_id,
88
91
  published_at, last_activity_at, created_at, updated_at
89
92
  )
90
- VALUES (?, ?, 'note', ?, ?, ?, ?, ?, ?, ?, ?)`,
93
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
91
94
  )
92
95
  .run(
93
96
  input.id,
94
97
  DEFAULT_TEST_SITE_ID,
98
+ input.format ?? "note",
95
99
  input.status ?? "published",
96
100
  input.visibility ?? "latest_hidden",
97
101
  input.title,
@@ -232,15 +236,71 @@ describe("NavItemService", () => {
232
236
  }),
233
237
  ).rejects.toThrow("Built-in navigation item already exists");
234
238
  });
239
+
240
+ it("creates a page nav item from an eligible titled note", async () => {
241
+ insertTestPost(sqlite, {
242
+ id: TEST_POST_ID,
243
+ slug: "about-me",
244
+ title: "About me",
245
+ });
246
+
247
+ const item = await navItemService.create({
248
+ type: "page",
249
+ postId: TEST_POST_ID,
250
+ });
251
+
252
+ expect(item).toMatchObject({
253
+ type: "page",
254
+ postId: TEST_POST_ID,
255
+ label: "About me",
256
+ url: "/about-me",
257
+ placement: "header",
258
+ });
259
+ });
260
+
261
+ it("rejects duplicate and ineligible page nav items", async () => {
262
+ insertTestPost(sqlite, {
263
+ id: TEST_POST_ID,
264
+ slug: "about",
265
+ title: "About",
266
+ });
267
+ insertTestPost(sqlite, {
268
+ id: TEST_POST_ID_2,
269
+ slug: "private-page",
270
+ title: "Private page",
271
+ visibility: "private",
272
+ });
273
+ await navItemService.create({ type: "page", postId: TEST_POST_ID });
274
+
275
+ await expect(
276
+ navItemService.create({ type: "page", postId: TEST_POST_ID }),
277
+ ).rejects.toThrow("Page already added to navigation");
278
+ await expect(
279
+ navItemService.create({ type: "page", postId: TEST_POST_ID_2 }),
280
+ ).rejects.toThrow("Page must be a published, non-private titled note");
281
+ });
282
+
283
+ it("truncates a page's default navigation label", async () => {
284
+ insertTestPost(sqlite, {
285
+ id: TEST_POST_ID,
286
+ slug: "long-title",
287
+ title: "A".repeat(150),
288
+ });
289
+
290
+ const item = await navItemService.create({
291
+ type: "page",
292
+ postId: TEST_POST_ID,
293
+ });
294
+
295
+ expect(item.label).toBe("A".repeat(100));
296
+ });
235
297
  });
236
298
 
237
- describe("ensureSystemDefaults", () => {
238
- it("creates the six default system items once", async () => {
239
- const created = await navItemService.ensureSystemDefaults();
299
+ describe("materializeDefaultNavigation", () => {
300
+ it("creates the five-item default profile without Latest", async () => {
301
+ const created = await navItemService.materializeDefaultNavigation();
240
302
 
241
- expect(created).toHaveLength(6);
242
303
  expect(created.map((item) => item.systemKey)).toEqual([
243
- "latest",
244
304
  "featured",
245
305
  "collections",
246
306
  "archive",
@@ -251,39 +311,86 @@ describe("NavItemService", () => {
251
311
  "header",
252
312
  "header",
253
313
  "header",
254
- "header",
255
314
  "more",
256
315
  "more",
257
316
  ]);
317
+ expect(created.map((item) => item.systemKey)).not.toContain("latest");
258
318
  });
259
319
 
260
- it("creates only missing default system items", async () => {
261
- await navItemService.create({
320
+ it("preserves an existing optional Latest item", async () => {
321
+ const latest = await navItemService.create({
262
322
  type: "system",
263
- systemKey: "archive",
323
+ systemKey: "latest",
324
+ });
325
+ const customizedLatest = await navItemService.update(latest.id, {
326
+ label: "Recent writing",
327
+ placement: "more",
328
+ position: "a3",
264
329
  });
265
330
 
266
- const created = await navItemService.ensureSystemDefaults();
331
+ const created = await navItemService.materializeDefaultNavigation();
267
332
  const items = await navItemService.list();
268
333
 
269
334
  expect(created.map((item) => item.systemKey)).toEqual([
270
- "latest",
271
335
  "featured",
272
336
  "collections",
337
+ "archive",
273
338
  "rss",
274
339
  "settings",
275
340
  ]);
341
+ expect(items.find((item) => item.id === latest.id)).toEqual(
342
+ customizedLatest,
343
+ );
276
344
  expect(items.filter((item) => item.type === "system")).toHaveLength(6);
277
345
  });
278
346
 
279
- it("is idempotent when defaults already exist", async () => {
280
- await navItemService.ensureSystemDefaults();
347
+ it("only appends missing profile items during recovery", async () => {
348
+ const archive = await navItemService.create({
349
+ type: "system",
350
+ systemKey: "archive",
351
+ });
352
+ const customizedArchive = await navItemService.update(archive.id, {
353
+ label: "All posts",
354
+ placement: "more",
355
+ position: "a2",
356
+ });
357
+ const customLink = await navItemService.create({
358
+ type: "link",
359
+ label: "Elsewhere",
360
+ url: "https://example.com",
361
+ position: "a3",
362
+ });
363
+
364
+ const created = await navItemService.materializeDefaultNavigation();
365
+ const items = await navItemService.list();
366
+
367
+ expect(created.map((item) => item.systemKey)).toEqual([
368
+ "featured",
369
+ "collections",
370
+ "rss",
371
+ "settings",
372
+ ]);
373
+ expect(items.slice(0, 2).map((item) => item.id)).toEqual([
374
+ archive.id,
375
+ customLink.id,
376
+ ]);
377
+ expect(items.find((item) => item.id === archive.id)).toEqual(
378
+ customizedArchive,
379
+ );
380
+ expect(items.find((item) => item.id === customLink.id)).toEqual(
381
+ customLink,
382
+ );
383
+ expect(items.map((item) => item.systemKey)).not.toContain("latest");
384
+ });
385
+
386
+ it("is idempotent when the current profile already exists", async () => {
387
+ await navItemService.materializeDefaultNavigation();
281
388
 
282
- const created = await navItemService.ensureSystemDefaults();
389
+ const created = await navItemService.materializeDefaultNavigation();
283
390
  const items = await navItemService.list();
284
391
 
285
392
  expect(created).toEqual([]);
286
- expect(items.filter((item) => item.type === "system")).toHaveLength(6);
393
+ expect(items.filter((item) => item.type === "system")).toHaveLength(5);
287
394
  });
288
395
  });
289
396
 
@@ -361,8 +468,53 @@ describe("NavItemService", () => {
361
468
  });
362
469
  });
363
470
 
471
+ describe("listPageCandidates", () => {
472
+ it("returns eligible notes by title and excludes added pages", async () => {
473
+ insertTestPost(sqlite, {
474
+ id: TEST_POST_ID,
475
+ slug: "about",
476
+ title: "About this site",
477
+ visibility: "latest_hidden",
478
+ });
479
+ insertTestPost(sqlite, {
480
+ id: TEST_POST_ID_2,
481
+ slug: "another-note",
482
+ title: "Another note",
483
+ visibility: "public",
484
+ });
485
+ insertTestPost(sqlite, {
486
+ id: TEST_POST_ID_3,
487
+ slug: "about-link",
488
+ title: "About link",
489
+ format: "link",
490
+ });
491
+ insertTestPost(sqlite, {
492
+ id: TEST_POST_ID_4,
493
+ slug: "about-draft",
494
+ title: "About draft",
495
+ status: "draft",
496
+ });
497
+
498
+ expect(
499
+ await navItemService.listPageCandidates({ query: "ABOUT" }),
500
+ ).toEqual([
501
+ {
502
+ id: TEST_POST_ID,
503
+ title: "About this site",
504
+ slug: "about",
505
+ updatedAt: expect.any(Number),
506
+ },
507
+ ]);
508
+
509
+ await navItemService.create({ type: "page", postId: TEST_POST_ID });
510
+ expect(
511
+ await navItemService.listPageCandidates({ query: "about" }),
512
+ ).toEqual([]);
513
+ });
514
+ });
515
+
364
516
  describe("listSuggestedLinks", () => {
365
- it("suggests a published /about page as a normal link", async () => {
517
+ it("suggests a published /about page as a page nav item", async () => {
366
518
  insertTestPost(sqlite, {
367
519
  id: TEST_POST_ID,
368
520
  slug: "about",
@@ -377,7 +529,8 @@ describe("NavItemService", () => {
377
529
  label: "About me",
378
530
  url: "/about",
379
531
  targetType: "page",
380
- navItemType: "link",
532
+ navItemType: "page",
533
+ postId: TEST_POST_ID,
381
534
  },
382
535
  ]);
383
536
  });
@@ -786,6 +939,32 @@ describe("NavItemService", () => {
786
939
  });
787
940
  });
788
941
 
942
+ describe("page nav items", () => {
943
+ beforeEach(() => {
944
+ insertTestPost(sqlite, {
945
+ id: TEST_POST_ID,
946
+ slug: "about",
947
+ title: "About",
948
+ });
949
+ });
950
+
951
+ it("allows label updates but keeps the page URL managed", async () => {
952
+ const item = await navItemService.create({
953
+ type: "page",
954
+ postId: TEST_POST_ID,
955
+ });
956
+
957
+ const updated = await navItemService.update(item.id, {
958
+ label: "Start here",
959
+ });
960
+ expect(updated?.label).toBe("Start here");
961
+
962
+ await expect(
963
+ navItemService.update(item.id, { url: "/other" }),
964
+ ).rejects.toThrow("Page navigation URLs are managed automatically");
965
+ });
966
+ });
967
+
789
968
  describe("getCollectionFreshness", () => {
790
969
  beforeEach(() => {
791
970
  insertTestCollection(sqlite, TEST_COLLECTION_ID, "design", "Design");
@@ -810,7 +989,7 @@ describe("NavItemService", () => {
810
989
  // Add it to the collection recently
811
990
  sqlite
812
991
  .prepare(
813
- `INSERT INTO post_collection (site_id, post_id, collection_id, created_at, position)
992
+ `INSERT INTO thread_collection (site_id, thread_id, collection_id, created_at, position)
814
993
  VALUES (?, ?, ?, ?, 0)`,
815
994
  )
816
995
  .run(DEFAULT_TEST_SITE_ID, "pst_test001", TEST_COLLECTION_ID, ts);
@@ -838,7 +1017,7 @@ describe("NavItemService", () => {
838
1017
  );
839
1018
  sqlite
840
1019
  .prepare(
841
- `INSERT INTO post_collection (site_id, post_id, collection_id, created_at, position)
1020
+ `INSERT INTO thread_collection (site_id, thread_id, collection_id, created_at, position)
842
1021
  VALUES (?, ?, ?, ?, 0)`,
843
1022
  )
844
1023
  .run(DEFAULT_TEST_SITE_ID, "pst_test002", TEST_COLLECTION_ID, oldTs);
@@ -849,6 +1028,35 @@ describe("NavItemService", () => {
849
1028
  expect(result.has(TEST_COLLECTION_ID)).toBe(false);
850
1029
  });
851
1030
 
1031
+ it("detects freshness from a recent root edit even when Thread activity is old", async () => {
1032
+ const oldTs = now() - 60 * 60 * 72;
1033
+ const recentTs = now();
1034
+ sqlite
1035
+ .prepare(
1036
+ `INSERT INTO post (id, site_id, thread_id, format, status, visibility, created_at, updated_at, last_activity_at)
1037
+ VALUES (?, ?, ?, 'note', 'published', 'public', ?, ?, ?)`,
1038
+ )
1039
+ .run(
1040
+ "pst_edited",
1041
+ DEFAULT_TEST_SITE_ID,
1042
+ "pst_edited",
1043
+ oldTs,
1044
+ recentTs,
1045
+ oldTs,
1046
+ );
1047
+ sqlite
1048
+ .prepare(
1049
+ `INSERT INTO thread_collection (site_id, thread_id, collection_id, created_at, position)
1050
+ VALUES (?, ?, ?, ?, 0)`,
1051
+ )
1052
+ .run(DEFAULT_TEST_SITE_ID, "pst_edited", TEST_COLLECTION_ID, oldTs);
1053
+
1054
+ const result = await navItemService.getCollectionFreshness([
1055
+ TEST_COLLECTION_ID,
1056
+ ]);
1057
+ expect(result.get(TEST_COLLECTION_ID)).toBe(recentTs);
1058
+ });
1059
+
852
1060
  it("detects freshness from recent thread replies", async () => {
853
1061
  const oldTs = now() - 60 * 60 * 72; // 72 hours ago
854
1062
  const recentTs = now();
@@ -871,7 +1079,7 @@ describe("NavItemService", () => {
871
1079
  // Add the root to the collection (old)
872
1080
  sqlite
873
1081
  .prepare(
874
- `INSERT INTO post_collection (site_id, post_id, collection_id, created_at, position)
1082
+ `INSERT INTO thread_collection (site_id, thread_id, collection_id, created_at, position)
875
1083
  VALUES (?, ?, ?, ?, 0)`,
876
1084
  )
877
1085
  .run(DEFAULT_TEST_SITE_ID, "pst_root", TEST_COLLECTION_ID, oldTs);
@@ -1,9 +1,10 @@
1
+ import { eq } from "drizzle-orm";
1
2
  import { describe, it, expect, beforeEach } from "vitest";
2
3
  import {
3
4
  createTestDatabase,
4
5
  DEFAULT_TEST_SITE_ID,
5
6
  } from "../../__tests__/helpers/db.js";
6
- import { postCollections } from "../../db/schema.js";
7
+ import { posts, threadCollections } from "../../db/schema.js";
7
8
  import { createPostService } from "../post.js";
8
9
  import { createCollectionService } from "../collection.js";
9
10
  import type { Database } from "../../db/index.js";
@@ -236,7 +237,7 @@ describe("PostService - Timeline features", () => {
236
237
  expect(result.size).toBe(0);
237
238
  });
238
239
 
239
- it("returns second and latest as the same post for a 2-post thread", async () => {
240
+ it("returns the only reply as leading and latest context", async () => {
240
241
  const root = await postService.create({
241
242
  format: "note",
242
243
  bodyMarkdown: "root",
@@ -250,13 +251,13 @@ describe("PostService - Timeline features", () => {
250
251
  const result = await postService.getThreadTimelineContext([root.id]);
251
252
  const ctx = result.get(root.id);
252
253
  expect(ctx).toBeDefined();
253
- expect(ctx?.secondReply?.id).toBe(reply.id);
254
- expect(ctx?.penultimateReply).toBeNull();
254
+ expect(ctx?.leadingReplies.map((post) => post.id)).toEqual([reply.id]);
255
+ expect(ctx?.trailingReplies).toEqual([]);
255
256
  expect(ctx?.latestReply.id).toBe(reply.id);
256
257
  expect(ctx?.totalReplyCount).toBe(1);
257
258
  });
258
259
 
259
- it("returns second, penultimate, and latest slots for a 3-post thread", async () => {
260
+ it("returns overlapping leading and trailing context for short threads", async () => {
260
261
  const root = await postService.create({
261
262
  format: "note",
262
263
  bodyMarkdown: "root",
@@ -275,13 +276,16 @@ describe("PostService - Timeline features", () => {
275
276
  const result = await postService.getThreadTimelineContext([root.id]);
276
277
  const ctx = result.get(root.id);
277
278
  expect(ctx).toBeDefined();
278
- expect(ctx?.secondReply?.id).toBe(reply1.id);
279
- expect(ctx?.penultimateReply?.id).toBe(reply1.id);
279
+ expect(ctx?.leadingReplies.map((post) => post.id)).toEqual([
280
+ reply1.id,
281
+ reply2.id,
282
+ ]);
283
+ expect(ctx?.trailingReplies.map((post) => post.id)).toEqual([reply1.id]);
280
284
  expect(ctx?.latestReply.id).toBe(reply2.id);
281
285
  expect(ctx?.totalReplyCount).toBe(2);
282
286
  });
283
287
 
284
- it("returns second, penultimate, and latest slots for longer threads", async () => {
288
+ it("returns the first two and last three reply positions for longer threads", async () => {
285
289
  const root = await postService.create({
286
290
  format: "note",
287
291
  bodyMarkdown: "root",
@@ -298,8 +302,14 @@ describe("PostService - Timeline features", () => {
298
302
  const result = await postService.getThreadTimelineContext([root.id]);
299
303
  const ctx = result.get(root.id);
300
304
  expect(ctx).toBeDefined();
301
- expect(ctx?.secondReply?.bodyText).toBe("reply 0");
302
- expect(ctx?.penultimateReply?.bodyText).toBe("reply 3");
305
+ expect(ctx?.leadingReplies.map((post) => post.bodyText)).toEqual([
306
+ "reply 0",
307
+ "reply 1",
308
+ ]);
309
+ expect(ctx?.trailingReplies.map((post) => post.bodyText)).toEqual([
310
+ "reply 2",
311
+ "reply 3",
312
+ ]);
303
313
  expect(ctx?.latestReply.bodyText).toBe("reply 4");
304
314
  expect(ctx?.totalReplyCount).toBe(5);
305
315
  });
@@ -326,7 +336,7 @@ describe("PostService - Timeline features", () => {
326
336
  const result = await postService.getThreadTimelineContext([root.id]);
327
337
  const ctx = result.get(root.id);
328
338
  expect(ctx).toBeDefined();
329
- expect(ctx?.secondReply?.id).toBe(reply1.id);
339
+ expect(ctx?.leadingReplies.map((post) => post.id)).toEqual([reply1.id]);
330
340
  expect(ctx?.latestReply.id).toBe(reply1.id);
331
341
  expect(ctx?.totalReplyCount).toBe(1);
332
342
  });
@@ -351,7 +361,9 @@ describe("PostService - Timeline features", () => {
351
361
  const result = await postService.getThreadTimelineContext([root.id]);
352
362
  const ctx = result.get(root.id);
353
363
  expect(ctx).toBeDefined();
354
- expect(ctx?.secondReply?.id).toBe(publishedReply.id);
364
+ expect(ctx?.leadingReplies.map((post) => post.id)).toEqual([
365
+ publishedReply.id,
366
+ ]);
355
367
  expect(ctx?.latestReply.id).toBe(publishedReply.id);
356
368
  expect(ctx?.totalReplyCount).toBe(1);
357
369
  });
@@ -381,8 +393,8 @@ describe("PostService - Timeline features", () => {
381
393
  root2.id,
382
394
  ]);
383
395
  expect(result.size).toBe(2);
384
- expect(result.get(root1.id)?.secondReply?.id).toBe(r1Reply.id);
385
- expect(result.get(root2.id)?.secondReply?.id).toBe(r2Reply.id);
396
+ expect(result.get(root1.id)?.leadingReplies[0]?.id).toBe(r1Reply.id);
397
+ expect(result.get(root2.id)?.leadingReplies[0]?.id).toBe(r2Reply.id);
386
398
  expect(result.get(root1.id)?.latestReply.id).toBe(r1Reply.id);
387
399
  expect(result.get(root2.id)?.latestReply.id).toBe(r2Reply.id);
388
400
  });
@@ -464,6 +476,39 @@ describe("PostService - Timeline features", () => {
464
476
  });
465
477
  });
466
478
 
479
+ describe("countCollectionThreadRootsForCollections", () => {
480
+ it("counts rated Threads once even when multiple children are rated", async () => {
481
+ const collection = await collectionService.create({
482
+ slug: "rated-threads",
483
+ title: "Rated Threads",
484
+ });
485
+ const ratedRoot = await postService.create({
486
+ format: "note",
487
+ bodyMarkdown: "Rated root",
488
+ rating: 5,
489
+ });
490
+ await postService.create({
491
+ format: "note",
492
+ bodyMarkdown: "Rated child",
493
+ rating: 4,
494
+ replyToId: ratedRoot.id,
495
+ });
496
+ const unratedRoot = await postService.create({
497
+ format: "note",
498
+ bodyMarkdown: "Unrated root",
499
+ });
500
+ await collectionService.addThread(collection.id, ratedRoot.id);
501
+ await collectionService.addThread(collection.id, unratedRoot.id);
502
+
503
+ const count = await postService.countCollectionThreadRootsForCollections(
504
+ [collection.id],
505
+ { status: "published", hasRating: true },
506
+ );
507
+
508
+ expect(count).toBe(1);
509
+ });
510
+ });
511
+
467
512
  describe("listCollectionThreadRootIds", () => {
468
513
  it("bumps a hidden collection thread when a non-quiet reply is published", async () => {
469
514
  const collection = await collectionService.create({
@@ -482,8 +527,8 @@ describe("PostService - Timeline features", () => {
482
527
  publishedAt: 2000,
483
528
  });
484
529
 
485
- await collectionService.addPost(collection.id, hiddenRoot.id);
486
- await collectionService.addPost(collection.id, newerRoot.id);
530
+ await collectionService.addThread(collection.id, hiddenRoot.id);
531
+ await collectionService.addThread(collection.id, newerRoot.id);
487
532
 
488
533
  await postService.create({
489
534
  format: "note",
@@ -519,8 +564,8 @@ describe("PostService - Timeline features", () => {
519
564
  publishedAt: 2000,
520
565
  });
521
566
 
522
- await collectionService.addPost(collection.id, olderRoot.id);
523
- await collectionService.addPost(collection.id, newerRoot.id);
567
+ await collectionService.addThread(collection.id, olderRoot.id);
568
+ await collectionService.addThread(collection.id, newerRoot.id);
524
569
 
525
570
  await postService.create({
526
571
  format: "note",
@@ -540,6 +585,40 @@ describe("PostService - Timeline features", () => {
540
585
 
541
586
  expect(rootIds).toEqual([newerRoot.id, olderRoot.id]);
542
587
  });
588
+
589
+ it("orders a Thread by a newer root edit", async () => {
590
+ const collection = await collectionService.create({
591
+ slug: "edited",
592
+ title: "Edited",
593
+ });
594
+ const editedRoot = await postService.create({
595
+ format: "note",
596
+ bodyMarkdown: "Edited root",
597
+ publishedAt: 1000,
598
+ });
599
+ const newerRoot = await postService.create({
600
+ format: "note",
601
+ bodyMarkdown: "Newer root",
602
+ publishedAt: 2000,
603
+ });
604
+
605
+ await collectionService.addThread(collection.id, editedRoot.id);
606
+ await collectionService.addThread(collection.id, newerRoot.id);
607
+ await db
608
+ .update(posts)
609
+ .set({ createdAt: 500, updatedAt: 3000 })
610
+ .where(eq(posts.id, editedRoot.id));
611
+
612
+ const rootIds = await postService.listCollectionThreadRootIds(
613
+ collection.id,
614
+ {
615
+ status: "published",
616
+ sortOrder: "newest",
617
+ },
618
+ );
619
+
620
+ expect(rootIds).toEqual([editedRoot.id, newerRoot.id]);
621
+ });
543
622
  });
544
623
 
545
624
  describe("listCollectionFeedEntries", () => {
@@ -562,16 +641,16 @@ describe("PostService - Timeline features", () => {
562
641
  bodyMarkdown: "Second root",
563
642
  });
564
643
 
565
- await db.insert(postCollections).values([
644
+ await db.insert(threadCollections).values([
566
645
  {
567
646
  siteId: DEFAULT_TEST_SITE_ID,
568
- postId: collectedReply.id,
647
+ threadId: firstRoot.id,
569
648
  collectionId: collection.id,
570
649
  createdAt: 100,
571
650
  },
572
651
  {
573
652
  siteId: DEFAULT_TEST_SITE_ID,
574
- postId: secondRoot.id,
653
+ threadId: secondRoot.id,
575
654
  collectionId: collection.id,
576
655
  createdAt: 200,
577
656
  },
@@ -607,17 +686,17 @@ describe("PostService - Timeline features", () => {
607
686
  publishedAt: 2000,
608
687
  });
609
688
 
610
- await db.insert(postCollections).values([
689
+ await db.insert(threadCollections).values([
611
690
  {
612
691
  siteId: DEFAULT_TEST_SITE_ID,
613
- postId: olderRoot.id,
692
+ threadId: olderRoot.id,
614
693
  collectionId: collection.id,
615
694
  createdAt: 100,
616
695
  pinnedAt: 5000,
617
696
  },
618
697
  {
619
698
  siteId: DEFAULT_TEST_SITE_ID,
620
- postId: newerRoot.id,
699
+ threadId: newerRoot.id,
621
700
  collectionId: collection.id,
622
701
  createdAt: 200,
623
702
  },
@@ -670,22 +749,22 @@ describe("PostService - Timeline features", () => {
670
749
  bodyMarkdown: "Second root",
671
750
  });
672
751
 
673
- await db.insert(postCollections).values([
752
+ await db.insert(threadCollections).values([
674
753
  {
675
754
  siteId: DEFAULT_TEST_SITE_ID,
676
- postId: sharedRoot.id,
755
+ threadId: sharedRoot.id,
677
756
  collectionId: smart.id,
678
757
  createdAt: 100,
679
758
  },
680
759
  {
681
760
  siteId: DEFAULT_TEST_SITE_ID,
682
- postId: sharedReply.id,
761
+ threadId: sharedRoot.id,
683
762
  collectionId: movies.id,
684
763
  createdAt: 300,
685
764
  },
686
765
  {
687
766
  siteId: DEFAULT_TEST_SITE_ID,
688
- postId: secondRoot.id,
767
+ threadId: secondRoot.id,
689
768
  collectionId: movies.id,
690
769
  createdAt: 200,
691
770
  },
@@ -722,8 +801,8 @@ describe("PostService - Timeline features", () => {
722
801
  publishedAt: 2000,
723
802
  });
724
803
 
725
- await collectionService.addPost(collection.id, hiddenRoot.id);
726
- await collectionService.addPost(collection.id, newerRoot.id);
804
+ await collectionService.addThread(collection.id, hiddenRoot.id);
805
+ await collectionService.addThread(collection.id, newerRoot.id);
727
806
 
728
807
  await postService.create({
729
808
  format: "note",