@jant/core 0.3.35 → 0.3.37

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 (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -1,20 +1,16 @@
1
1
  import { describe, it, expect, beforeEach } from "vitest";
2
2
  import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
3
  import { createNavItemService } from "../navigation.js";
4
- import { createPageService } from "../page.js";
5
- import { createPathRegistryService } from "../path-registry.js";
6
4
  import type { Database } from "../../db/index.js";
7
5
 
8
6
  describe("NavItemService", () => {
9
7
  let db: Database;
10
8
  let navItemService: ReturnType<typeof createNavItemService>;
11
- let pageService: ReturnType<typeof createPageService>;
12
9
 
13
10
  beforeEach(() => {
14
11
  const testDb = createTestDatabase();
15
12
  db = testDb.db as unknown as Database;
16
13
  navItemService = createNavItemService(db);
17
- pageService = createPageService(db, createPathRegistryService(db));
18
14
  });
19
15
 
20
16
  describe("create", () => {
@@ -28,29 +24,13 @@ describe("NavItemService", () => {
28
24
  expect(item.type).toBe("link");
29
25
  expect(item.label).toBe("Home");
30
26
  expect(item.url).toBe("/");
31
- expect(item.pageId).toBeNull();
32
- expect(item.position).toBe(0);
33
- expect(item.id).toBe(1);
34
- });
35
-
36
- it("creates a page-type nav item with pageId", async () => {
37
- const page = await pageService.create({ slug: "about", title: "About" });
38
-
39
- const item = await navItemService.create({
40
- type: "page",
41
- label: "About",
42
- url: "/about",
43
- pageId: page.id,
44
- });
45
-
46
- expect(item.type).toBe("page");
47
- expect(item.label).toBe("About");
48
- expect(item.url).toBe("/about");
49
- expect(item.pageId).toBe(page.id);
27
+ expect(typeof item.position).toBe("string");
28
+ expect(typeof item.id).toBe("string");
29
+ expect(item.id.length).toBeGreaterThan(0);
50
30
  });
51
31
 
52
32
  it("auto-increments position for subsequent items", async () => {
53
- await navItemService.create({
33
+ const first = await navItemService.create({
54
34
  type: "link",
55
35
  label: "Home",
56
36
  url: "/",
@@ -61,7 +41,7 @@ describe("NavItemService", () => {
61
41
  url: "/archive",
62
42
  });
63
43
 
64
- expect(second.position).toBe(1);
44
+ expect(second.position > first.position).toBe(true);
65
45
  });
66
46
 
67
47
  it("uses provided position when specified", async () => {
@@ -69,10 +49,28 @@ describe("NavItemService", () => {
69
49
  type: "link",
70
50
  label: "Home",
71
51
  url: "/",
72
- position: 5,
52
+ position: "z99",
53
+ });
54
+
55
+ expect(item.position).toBe("z99");
56
+ });
57
+
58
+ it("rejects duplicate provided positions", async () => {
59
+ await navItemService.create({
60
+ type: "link",
61
+ label: "Home",
62
+ url: "/",
63
+ position: "m0",
73
64
  });
74
65
 
75
- expect(item.position).toBe(5);
66
+ await expect(
67
+ navItemService.create({
68
+ type: "link",
69
+ label: "Archive",
70
+ url: "/archive",
71
+ position: "m0",
72
+ }),
73
+ ).rejects.toThrow();
76
74
  });
77
75
 
78
76
  it("sets createdAt and updatedAt timestamps", async () => {
@@ -103,7 +101,9 @@ describe("NavItemService", () => {
103
101
  });
104
102
 
105
103
  it("returns null for non-existent ID", async () => {
106
- const found = await navItemService.getById(9999);
104
+ const found = await navItemService.getById(
105
+ "00000000-0000-0000-0000-000000009999",
106
+ );
107
107
  expect(found).toBeNull();
108
108
  });
109
109
  });
@@ -119,19 +119,19 @@ describe("NavItemService", () => {
119
119
  type: "link",
120
120
  label: "C",
121
121
  url: "/c",
122
- position: 2,
122
+ position: "c0",
123
123
  });
124
124
  await navItemService.create({
125
125
  type: "link",
126
126
  label: "A",
127
127
  url: "/a",
128
- position: 0,
128
+ position: "a0",
129
129
  });
130
130
  await navItemService.create({
131
- type: "page",
131
+ type: "link",
132
132
  label: "B",
133
133
  url: "/b",
134
- position: 1,
134
+ position: "b0",
135
135
  });
136
136
 
137
137
  const items = await navItemService.list();
@@ -142,25 +142,21 @@ describe("NavItemService", () => {
142
142
  });
143
143
 
144
144
  it("returns items with correct types", async () => {
145
- const page = await pageService.create({ slug: "about", title: "About" });
146
-
147
145
  await navItemService.create({
148
146
  type: "link",
149
147
  label: "External",
150
148
  url: "https://example.com",
151
149
  });
152
150
  await navItemService.create({
153
- type: "page",
154
- label: "About",
155
- url: "/about",
156
- pageId: page.id,
151
+ type: "system",
152
+ label: "Settings",
153
+ url: "/settings",
157
154
  });
158
155
 
159
156
  const items = await navItemService.list();
160
157
  expect(items).toHaveLength(2);
161
158
  expect(items[0]?.type).toBe("link");
162
- expect(items[1]?.type).toBe("page");
163
- expect(items[1]?.pageId).toBe(page.id);
159
+ expect(items[1]?.type).toBe("system");
164
160
  });
165
161
  });
166
162
 
@@ -196,24 +192,6 @@ describe("NavItemService", () => {
196
192
  expect(updated?.label).toBe("Blog");
197
193
  });
198
194
 
199
- it("updates a nav item's type", async () => {
200
- const page = await pageService.create({ slug: "about", title: "About" });
201
-
202
- const created = await navItemService.create({
203
- type: "link",
204
- label: "About",
205
- url: "/about",
206
- });
207
-
208
- const updated = await navItemService.update(created.id, {
209
- type: "page",
210
- pageId: page.id,
211
- });
212
-
213
- expect(updated?.type).toBe("page");
214
- expect(updated?.pageId).toBe(page.id);
215
- });
216
-
217
195
  it("updates updatedAt timestamp", async () => {
218
196
  const created = await navItemService.create({
219
197
  type: "link",
@@ -229,7 +207,10 @@ describe("NavItemService", () => {
229
207
  });
230
208
 
231
209
  it("returns null for non-existent ID", async () => {
232
- const result = await navItemService.update(9999, { label: "Nope" });
210
+ const result = await navItemService.update(
211
+ "00000000-0000-0000-0000-000000009999",
212
+ { label: "Nope" },
213
+ );
233
214
  expect(result).toBeNull();
234
215
  });
235
216
  });
@@ -250,13 +231,15 @@ describe("NavItemService", () => {
250
231
  });
251
232
 
252
233
  it("returns false for non-existent ID", async () => {
253
- const result = await navItemService.delete(9999);
234
+ const result = await navItemService.delete(
235
+ "00000000-0000-0000-0000-000000009999",
236
+ );
254
237
  expect(result).toBe(false);
255
238
  });
256
239
  });
257
240
 
258
- describe("reorder", () => {
259
- it("updates positions to match array order", async () => {
241
+ describe("move", () => {
242
+ it("moves an item between two others", async () => {
260
243
  const a = await navItemService.create({
261
244
  type: "link",
262
245
  label: "A",
@@ -273,16 +256,74 @@ describe("NavItemService", () => {
273
256
  url: "/c",
274
257
  });
275
258
 
276
- // Reverse the order
277
- await navItemService.reorder([c.id, b.id, a.id]);
259
+ // Move C between A and B
260
+ await navItemService.move(c.id, a.id, b.id);
261
+
262
+ const items = await navItemService.list();
263
+ expect(items[0]?.label).toBe("A");
264
+ expect(items[1]?.label).toBe("C");
265
+ expect(items[2]?.label).toBe("B");
266
+ });
267
+
268
+ it("moves an item to the beginning", async () => {
269
+ const a = await navItemService.create({
270
+ type: "link",
271
+ label: "A",
272
+ url: "/a",
273
+ });
274
+ await navItemService.create({
275
+ type: "link",
276
+ label: "B",
277
+ url: "/b",
278
+ });
279
+ const c = await navItemService.create({
280
+ type: "link",
281
+ label: "C",
282
+ url: "/c",
283
+ });
284
+
285
+ // Move C before A
286
+ await navItemService.move(c.id, null, a.id);
278
287
 
279
288
  const items = await navItemService.list();
280
289
  expect(items[0]?.label).toBe("C");
281
- expect(items[0]?.position).toBe(0);
282
- expect(items[1]?.label).toBe("B");
283
- expect(items[1]?.position).toBe(1);
290
+ expect(items[1]?.label).toBe("A");
291
+ expect(items[2]?.label).toBe("B");
292
+ });
293
+
294
+ it("moves an item to the end", async () => {
295
+ const a = await navItemService.create({
296
+ type: "link",
297
+ label: "A",
298
+ url: "/a",
299
+ });
300
+ await navItemService.create({
301
+ type: "link",
302
+ label: "B",
303
+ url: "/b",
304
+ });
305
+ const c = await navItemService.create({
306
+ type: "link",
307
+ label: "C",
308
+ url: "/c",
309
+ });
310
+
311
+ // Move A after C
312
+ await navItemService.move(a.id, c.id, null);
313
+
314
+ const items = await navItemService.list();
315
+ expect(items[0]?.label).toBe("B");
316
+ expect(items[1]?.label).toBe("C");
284
317
  expect(items[2]?.label).toBe("A");
285
- expect(items[2]?.position).toBe(2);
318
+ });
319
+
320
+ it("returns null for non-existent item", async () => {
321
+ const result = await navItemService.move(
322
+ "00000000-0000-0000-0000-000000009999",
323
+ null,
324
+ null,
325
+ );
326
+ expect(result).toBeNull();
286
327
  });
287
328
  });
288
329
  });
@@ -1,7 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from "vitest";
2
2
  import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
3
  import { createPostService } from "../post.js";
4
- import { createPathRegistryService } from "../path-registry.js";
5
4
  import type { Database } from "../../db/index.js";
6
5
 
7
6
  describe("PostService - Timeline features", () => {
@@ -11,20 +10,20 @@ describe("PostService - Timeline features", () => {
11
10
  beforeEach(() => {
12
11
  const testDb = createTestDatabase();
13
12
  db = testDb.db as unknown as Database;
14
- postService = createPostService(db, createPathRegistryService(db));
13
+ postService = createPostService(db, { slugIdLength: 5 });
15
14
  });
16
15
 
17
16
  describe("format filter", () => {
18
17
  it("filters by format", async () => {
19
- await postService.create({ format: "note", body: "a note" });
18
+ await postService.create({ format: "note", bodyMarkdown: "a note" });
20
19
  await postService.create({
21
20
  format: "link",
22
- body: "a link",
21
+ bodyMarkdown: "a link",
23
22
  url: "https://example.com",
24
23
  });
25
24
  await postService.create({
26
25
  format: "quote",
27
- body: "a quote",
26
+ bodyMarkdown: "a quote",
28
27
  quoteText: "something wise",
29
28
  });
30
29
 
@@ -36,17 +35,17 @@ describe("PostService - Timeline features", () => {
36
35
  it("combines format and status filters", async () => {
37
36
  await postService.create({
38
37
  format: "note",
39
- body: "published note",
38
+ bodyMarkdown: "published note",
40
39
  status: "published",
41
40
  });
42
41
  await postService.create({
43
42
  format: "note",
44
- body: "draft note",
43
+ bodyMarkdown: "draft note",
45
44
  status: "draft",
46
45
  });
47
46
  await postService.create({
48
47
  format: "link",
49
- body: "published link",
48
+ bodyMarkdown: "published link",
50
49
  status: "published",
51
50
  url: "https://example.com",
52
51
  });
@@ -70,16 +69,16 @@ describe("PostService - Timeline features", () => {
70
69
  it("returns preview replies for a thread root", async () => {
71
70
  const root = await postService.create({
72
71
  format: "note",
73
- body: "root",
72
+ bodyMarkdown: "root",
74
73
  });
75
74
  await postService.create({
76
75
  format: "note",
77
- body: "reply 1",
76
+ bodyMarkdown: "reply 1",
78
77
  replyToId: root.id,
79
78
  });
80
79
  await postService.create({
81
80
  format: "note",
82
- body: "reply 2",
81
+ bodyMarkdown: "reply 2",
83
82
  replyToId: root.id,
84
83
  });
85
84
 
@@ -87,19 +86,19 @@ describe("PostService - Timeline features", () => {
87
86
  const replies = previews.get(root.id);
88
87
  expect(replies).toBeDefined();
89
88
  expect(replies).toHaveLength(2);
90
- expect(replies?.[0]?.body).toBe("reply 1");
91
- expect(replies?.[1]?.body).toBe("reply 2");
89
+ expect(replies?.[0]?.bodyText).toBe("reply 1");
90
+ expect(replies?.[1]?.bodyText).toBe("reply 2");
92
91
  });
93
92
 
94
93
  it("limits preview replies to previewCount", async () => {
95
94
  const root = await postService.create({
96
95
  format: "note",
97
- body: "root",
96
+ bodyMarkdown: "root",
98
97
  });
99
98
  for (let i = 0; i < 5; i++) {
100
99
  await postService.create({
101
100
  format: "note",
102
- body: `reply ${i}`,
101
+ bodyMarkdown: `reply ${i}`,
103
102
  replyToId: root.id,
104
103
  });
105
104
  }
@@ -107,19 +106,19 @@ describe("PostService - Timeline features", () => {
107
106
  const previews = await postService.getThreadPreviews([root.id], 2);
108
107
  const replies = previews.get(root.id);
109
108
  expect(replies).toHaveLength(2);
110
- expect(replies?.[0]?.body).toBe("reply 0");
111
- expect(replies?.[1]?.body).toBe("reply 1");
109
+ expect(replies?.[0]?.bodyText).toBe("reply 0");
110
+ expect(replies?.[1]?.bodyText).toBe("reply 1");
112
111
  });
113
112
 
114
113
  it("defaults to 3 preview replies", async () => {
115
114
  const root = await postService.create({
116
115
  format: "note",
117
- body: "root",
116
+ bodyMarkdown: "root",
118
117
  });
119
118
  for (let i = 0; i < 5; i++) {
120
119
  await postService.create({
121
120
  format: "note",
122
- body: `reply ${i}`,
121
+ bodyMarkdown: `reply ${i}`,
123
122
  replyToId: root.id,
124
123
  });
125
124
  }
@@ -132,20 +131,20 @@ describe("PostService - Timeline features", () => {
132
131
  it("handles multiple thread roots", async () => {
133
132
  const root1 = await postService.create({
134
133
  format: "note",
135
- body: "root 1",
134
+ bodyMarkdown: "root 1",
136
135
  });
137
136
  const root2 = await postService.create({
138
137
  format: "note",
139
- body: "root 2",
138
+ bodyMarkdown: "root 2",
140
139
  });
141
140
  await postService.create({
142
141
  format: "note",
143
- body: "reply to root 1",
142
+ bodyMarkdown: "reply to root 1",
144
143
  replyToId: root1.id,
145
144
  });
146
145
  await postService.create({
147
146
  format: "note",
148
- body: "reply to root 2",
147
+ bodyMarkdown: "reply to root 2",
149
148
  replyToId: root2.id,
150
149
  });
151
150
 
@@ -161,16 +160,16 @@ describe("PostService - Timeline features", () => {
161
160
  it("excludes deleted replies", async () => {
162
161
  const root = await postService.create({
163
162
  format: "note",
164
- body: "root",
163
+ bodyMarkdown: "root",
165
164
  });
166
165
  const reply1 = await postService.create({
167
166
  format: "note",
168
- body: "reply 1",
167
+ bodyMarkdown: "reply 1",
169
168
  replyToId: root.id,
170
169
  });
171
170
  await postService.create({
172
171
  format: "note",
173
- body: "reply 2",
172
+ bodyMarkdown: "reply 2",
174
173
  replyToId: root.id,
175
174
  });
176
175
 
@@ -179,13 +178,36 @@ describe("PostService - Timeline features", () => {
179
178
  const previews = await postService.getThreadPreviews([root.id]);
180
179
  const replies = previews.get(root.id);
181
180
  expect(replies).toHaveLength(1);
182
- expect(replies?.[0]?.body).toBe("reply 2");
181
+ expect(replies?.[0]?.bodyText).toBe("reply 2");
182
+ });
183
+
184
+ it("excludes draft replies", async () => {
185
+ const root = await postService.create({
186
+ format: "note",
187
+ bodyMarkdown: "root",
188
+ });
189
+ await postService.create({
190
+ format: "note",
191
+ bodyMarkdown: "published reply",
192
+ replyToId: root.id,
193
+ });
194
+ await postService.create({
195
+ format: "note",
196
+ bodyMarkdown: "draft reply",
197
+ replyToId: root.id,
198
+ status: "draft",
199
+ });
200
+
201
+ const previews = await postService.getThreadPreviews([root.id]);
202
+ const replies = previews.get(root.id);
203
+ expect(replies).toHaveLength(1);
204
+ expect(replies?.[0]?.bodyText).toBe("published reply");
183
205
  });
184
206
 
185
207
  it("returns empty for roots with no replies", async () => {
186
208
  const root = await postService.create({
187
209
  format: "note",
188
- body: "root with no replies",
210
+ bodyMarkdown: "root with no replies",
189
211
  });
190
212
 
191
213
  const previews = await postService.getThreadPreviews([root.id]);
@@ -193,22 +215,173 @@ describe("PostService - Timeline features", () => {
193
215
  });
194
216
  });
195
217
 
218
+ describe("getThreadTimelineContext", () => {
219
+ it("returns empty map for empty input", async () => {
220
+ const result = await postService.getThreadTimelineContext([]);
221
+ expect(result.size).toBe(0);
222
+ });
223
+
224
+ it("returns latestReply with no parentReply for a 2-post thread", async () => {
225
+ const root = await postService.create({
226
+ format: "note",
227
+ bodyMarkdown: "root",
228
+ });
229
+ const reply = await postService.create({
230
+ format: "note",
231
+ bodyMarkdown: "only reply",
232
+ replyToId: root.id,
233
+ });
234
+
235
+ const result = await postService.getThreadTimelineContext([root.id]);
236
+ const ctx = result.get(root.id);
237
+ expect(ctx).toBeDefined();
238
+ expect(ctx?.latestReply.id).toBe(reply.id);
239
+ expect(ctx?.parentReply).toBeNull();
240
+ expect(ctx?.totalReplyCount).toBe(1);
241
+ });
242
+
243
+ it("returns latestReply + parentReply for a 3-post thread", async () => {
244
+ const root = await postService.create({
245
+ format: "note",
246
+ bodyMarkdown: "root",
247
+ });
248
+ const reply1 = await postService.create({
249
+ format: "note",
250
+ bodyMarkdown: "reply 1",
251
+ replyToId: root.id,
252
+ });
253
+ const reply2 = await postService.create({
254
+ format: "note",
255
+ bodyMarkdown: "reply 2",
256
+ replyToId: reply1.id,
257
+ });
258
+
259
+ const result = await postService.getThreadTimelineContext([root.id]);
260
+ const ctx = result.get(root.id);
261
+ expect(ctx).toBeDefined();
262
+ expect(ctx?.latestReply.id).toBe(reply2.id);
263
+ expect(ctx?.parentReply?.id).toBe(reply1.id);
264
+ expect(ctx?.totalReplyCount).toBe(2);
265
+ });
266
+
267
+ it("returns correct totalReplyCount for 4+ post thread", async () => {
268
+ const root = await postService.create({
269
+ format: "note",
270
+ bodyMarkdown: "root",
271
+ });
272
+ let prev = root;
273
+ for (let i = 0; i < 5; i++) {
274
+ prev = await postService.create({
275
+ format: "note",
276
+ bodyMarkdown: `reply ${i}`,
277
+ replyToId: prev.id,
278
+ });
279
+ }
280
+
281
+ const result = await postService.getThreadTimelineContext([root.id]);
282
+ const ctx = result.get(root.id);
283
+ expect(ctx).toBeDefined();
284
+ expect(ctx?.latestReply.bodyText).toBe("reply 4");
285
+ expect(ctx?.parentReply?.bodyText).toBe("reply 3");
286
+ expect(ctx?.totalReplyCount).toBe(5);
287
+ });
288
+
289
+ it("excludes deleted replies", async () => {
290
+ const root = await postService.create({
291
+ format: "note",
292
+ bodyMarkdown: "root",
293
+ });
294
+ const reply1 = await postService.create({
295
+ format: "note",
296
+ bodyMarkdown: "reply 1",
297
+ replyToId: root.id,
298
+ });
299
+ const reply2 = await postService.create({
300
+ format: "note",
301
+ bodyMarkdown: "reply 2",
302
+ replyToId: reply1.id,
303
+ });
304
+
305
+ // Delete the latest reply — reply1 should become the latest
306
+ await postService.delete(reply2.id);
307
+
308
+ const result = await postService.getThreadTimelineContext([root.id]);
309
+ const ctx = result.get(root.id);
310
+ expect(ctx).toBeDefined();
311
+ expect(ctx?.latestReply.id).toBe(reply1.id);
312
+ expect(ctx?.totalReplyCount).toBe(1);
313
+ });
314
+
315
+ it("excludes draft replies from thread context", async () => {
316
+ const root = await postService.create({
317
+ format: "note",
318
+ bodyMarkdown: "root",
319
+ });
320
+ const publishedReply = await postService.create({
321
+ format: "note",
322
+ bodyMarkdown: "published reply",
323
+ replyToId: root.id,
324
+ });
325
+ await postService.create({
326
+ format: "note",
327
+ bodyMarkdown: "draft reply",
328
+ replyToId: root.id,
329
+ status: "draft",
330
+ });
331
+
332
+ const result = await postService.getThreadTimelineContext([root.id]);
333
+ const ctx = result.get(root.id);
334
+ expect(ctx).toBeDefined();
335
+ expect(ctx?.latestReply.id).toBe(publishedReply.id);
336
+ expect(ctx?.totalReplyCount).toBe(1);
337
+ });
338
+
339
+ it("handles multiple roots in batch", async () => {
340
+ const root1 = await postService.create({
341
+ format: "note",
342
+ bodyMarkdown: "root 1",
343
+ });
344
+ const root2 = await postService.create({
345
+ format: "note",
346
+ bodyMarkdown: "root 2",
347
+ });
348
+ const r1Reply = await postService.create({
349
+ format: "note",
350
+ bodyMarkdown: "reply to root 1",
351
+ replyToId: root1.id,
352
+ });
353
+ const r2Reply = await postService.create({
354
+ format: "note",
355
+ bodyMarkdown: "reply to root 2",
356
+ replyToId: root2.id,
357
+ });
358
+
359
+ const result = await postService.getThreadTimelineContext([
360
+ root1.id,
361
+ root2.id,
362
+ ]);
363
+ expect(result.size).toBe(2);
364
+ expect(result.get(root1.id)?.latestReply.id).toBe(r1Reply.id);
365
+ expect(result.get(root2.id)?.latestReply.id).toBe(r2Reply.id);
366
+ });
367
+ });
368
+
196
369
  describe("timeline assembly", () => {
197
370
  it("fetches published non-reply posts for the timeline", async () => {
198
371
  const root = await postService.create({
199
372
  format: "note",
200
- body: "a published note",
373
+ bodyMarkdown: "a published note",
201
374
  status: "published",
202
375
  });
203
376
  await postService.create({
204
377
  format: "note",
205
- body: "a reply",
378
+ bodyMarkdown: "a reply",
206
379
  status: "published",
207
380
  replyToId: root.id,
208
381
  });
209
382
  await postService.create({
210
383
  format: "note",
211
- body: "a draft",
384
+ bodyMarkdown: "a draft",
212
385
  status: "draft",
213
386
  });
214
387
 
@@ -219,7 +392,7 @@ describe("PostService - Timeline features", () => {
219
392
  });
220
393
 
221
394
  expect(posts).toHaveLength(1);
222
- expect(posts[0]?.body).toBe("a published note");
395
+ expect(posts[0]?.bodyText).toBe("a published note");
223
396
  });
224
397
  });
225
398
  });