@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,165 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
- import { createPathRegistryService } from "../path-registry.js";
4
- import { ValidationError, ConflictError } from "../../lib/errors.js";
5
- import type { Database } from "../../db/index.js";
6
-
7
- describe("PathRegistryService", () => {
8
- let db: Database;
9
- let pathRegistry: ReturnType<typeof createPathRegistryService>;
10
-
11
- beforeEach(() => {
12
- const testDb = createTestDatabase();
13
- db = testDb.db as unknown as Database;
14
- pathRegistry = createPathRegistryService(db);
15
- });
16
-
17
- describe("claim", () => {
18
- it("claims a path successfully", async () => {
19
- const entry = await pathRegistry.claim("about", "page", 1);
20
-
21
- expect(entry.path).toBe("about");
22
- expect(entry.ownerType).toBe("page");
23
- expect(entry.ownerId).toBe(1);
24
- expect(entry.createdAt).toBeGreaterThan(0);
25
- });
26
-
27
- it("normalizes the path before claiming", async () => {
28
- const entry = await pathRegistry.claim(" /About/ ", "page", 1);
29
- expect(entry.path).toBe("about");
30
- });
31
-
32
- it("rejects reserved paths", async () => {
33
- await expect(pathRegistry.claim("dash", "page", 1)).rejects.toThrow(
34
- ValidationError,
35
- );
36
- await expect(pathRegistry.claim("api", "page", 1)).rejects.toThrow(
37
- ValidationError,
38
- );
39
- await expect(pathRegistry.claim("search", "page", 1)).rejects.toThrow(
40
- ValidationError,
41
- );
42
- });
43
-
44
- it("rejects reserved paths regardless of casing", async () => {
45
- await expect(pathRegistry.claim("DASH", "page", 1)).rejects.toThrow(
46
- ValidationError,
47
- );
48
- });
49
-
50
- it("throws ConflictError when path is already claimed by another entity", async () => {
51
- await pathRegistry.claim("about", "page", 1);
52
-
53
- await expect(pathRegistry.claim("about", "post", 2)).rejects.toThrow(
54
- ConflictError,
55
- );
56
- });
57
-
58
- it("is idempotent for the same owner", async () => {
59
- const first = await pathRegistry.claim("about", "page", 1);
60
- const second = await pathRegistry.claim("about", "page", 1);
61
-
62
- expect(second.path).toBe(first.path);
63
- expect(second.ownerType).toBe(first.ownerType);
64
- expect(second.ownerId).toBe(first.ownerId);
65
- });
66
-
67
- it("allows multi-level paths", async () => {
68
- const entry = await pathRegistry.claim("2024/01/my-post", "post", 1);
69
- expect(entry.path).toBe("2024/01/my-post");
70
- });
71
- });
72
-
73
- describe("release", () => {
74
- it("releases a claimed path", async () => {
75
- await pathRegistry.claim("about", "page", 1);
76
- await pathRegistry.release("about");
77
-
78
- const entry = await pathRegistry.getByPath("about");
79
- expect(entry).toBeNull();
80
- });
81
-
82
- it("normalizes the path before releasing", async () => {
83
- await pathRegistry.claim("about", "page", 1);
84
- await pathRegistry.release(" /About/ ");
85
-
86
- const entry = await pathRegistry.getByPath("about");
87
- expect(entry).toBeNull();
88
- });
89
-
90
- it("is a no-op for unclaimed paths", async () => {
91
- // Should not throw
92
- await pathRegistry.release("nonexistent");
93
- });
94
- });
95
-
96
- describe("releaseByOwner", () => {
97
- it("releases all paths for a specific owner", async () => {
98
- await pathRegistry.claim("about", "page", 1);
99
- await pathRegistry.claim("contact", "page", 1);
100
- await pathRegistry.claim("blog", "page", 2);
101
-
102
- await pathRegistry.releaseByOwner("page", 1);
103
-
104
- expect(await pathRegistry.getByPath("about")).toBeNull();
105
- expect(await pathRegistry.getByPath("contact")).toBeNull();
106
- // Different owner's path should remain
107
- expect(await pathRegistry.getByPath("blog")).not.toBeNull();
108
- });
109
-
110
- it("does not affect other owner types", async () => {
111
- await pathRegistry.claim("about", "page", 1);
112
- await pathRegistry.claim("my-post", "post", 1);
113
-
114
- await pathRegistry.releaseByOwner("page", 1);
115
-
116
- expect(await pathRegistry.getByPath("about")).toBeNull();
117
- expect(await pathRegistry.getByPath("my-post")).not.toBeNull();
118
- });
119
- });
120
-
121
- describe("getByPath", () => {
122
- it("returns entry for claimed path", async () => {
123
- await pathRegistry.claim("about", "page", 1);
124
-
125
- const entry = await pathRegistry.getByPath("about");
126
- expect(entry).not.toBeNull();
127
- expect(entry?.ownerType).toBe("page");
128
- expect(entry?.ownerId).toBe(1);
129
- });
130
-
131
- it("normalizes the lookup path", async () => {
132
- await pathRegistry.claim("about", "page", 1);
133
-
134
- const entry = await pathRegistry.getByPath(" /About/ ");
135
- expect(entry).not.toBeNull();
136
- });
137
-
138
- it("returns null for unclaimed path", async () => {
139
- const entry = await pathRegistry.getByPath("nonexistent");
140
- expect(entry).toBeNull();
141
- });
142
- });
143
-
144
- describe("isAvailable", () => {
145
- it("returns true for unclaimed, non-reserved paths", async () => {
146
- expect(await pathRegistry.isAvailable("about")).toBe(true);
147
- });
148
-
149
- it("returns false for reserved paths", async () => {
150
- expect(await pathRegistry.isAvailable("dash")).toBe(false);
151
- expect(await pathRegistry.isAvailable("api")).toBe(false);
152
- });
153
-
154
- it("returns false for claimed paths", async () => {
155
- await pathRegistry.claim("about", "page", 1);
156
- expect(await pathRegistry.isAvailable("about")).toBe(false);
157
- });
158
-
159
- it("returns true after a path is released", async () => {
160
- await pathRegistry.claim("about", "page", 1);
161
- await pathRegistry.release("about");
162
- expect(await pathRegistry.isAvailable("about")).toBe(true);
163
- });
164
- });
165
- });
@@ -1,159 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
- import { createRedirectService } from "../redirect.js";
4
- import { createPageService } from "../page.js";
5
- import { createPostService } from "../post.js";
6
- import { createPathRegistryService } from "../path-registry.js";
7
- import { ConflictError } from "../../lib/errors.js";
8
- import type { Database } from "../../db/index.js";
9
-
10
- describe("RedirectService", () => {
11
- let db: Database;
12
- let redirectService: ReturnType<typeof createRedirectService>;
13
- let pageService: ReturnType<typeof createPageService>;
14
- let postService: ReturnType<typeof createPostService>;
15
- let pathRegistry: ReturnType<typeof createPathRegistryService>;
16
-
17
- beforeEach(() => {
18
- const testDb = createTestDatabase();
19
- db = testDb.db as unknown as Database;
20
- pathRegistry = createPathRegistryService(db);
21
- redirectService = createRedirectService(db, pathRegistry);
22
- pageService = createPageService(db, pathRegistry);
23
- postService = createPostService(db, pathRegistry);
24
- });
25
-
26
- describe("create", () => {
27
- it("creates a 301 redirect by default", async () => {
28
- const redirect = await redirectService.create("/old-path", "/new-path");
29
-
30
- expect(redirect.fromPath).toBe("old-path"); // normalizePath removes leading slash
31
- expect(redirect.toPath).toBe("/new-path");
32
- expect(redirect.type).toBe(301);
33
- expect(redirect.id).toBe(1);
34
- });
35
-
36
- it("creates a 302 redirect", async () => {
37
- const redirect = await redirectService.create(
38
- "/temp",
39
- "/destination",
40
- 302,
41
- );
42
-
43
- expect(redirect.type).toBe(302);
44
- });
45
-
46
- it("normalizes from path", async () => {
47
- const redirect = await redirectService.create(
48
- " /OLD-PATH/ ",
49
- "/new-path",
50
- );
51
-
52
- expect(redirect.fromPath).toBe("old-path");
53
- });
54
-
55
- it("replaces existing redirect for same from path", async () => {
56
- await redirectService.create("/old", "/first");
57
- const second = await redirectService.create("/old", "/second");
58
-
59
- expect(second.toPath).toBe("/second");
60
-
61
- const list = await redirectService.list();
62
- expect(list).toHaveLength(1);
63
- });
64
- });
65
-
66
- describe("getByPath", () => {
67
- it("finds redirect by from path", async () => {
68
- await redirectService.create("/old-page", "/new-page");
69
-
70
- const found = await redirectService.getByPath("/old-page");
71
- expect(found).not.toBeNull();
72
- expect(found?.toPath).toBe("/new-page");
73
- });
74
-
75
- it("normalizes the lookup path", async () => {
76
- await redirectService.create("/old-page", "/new-page");
77
-
78
- const found = await redirectService.getByPath(" /OLD-PAGE/ ");
79
- expect(found).not.toBeNull();
80
- });
81
-
82
- it("returns null for non-existent path", async () => {
83
- const found = await redirectService.getByPath("/nonexistent");
84
- expect(found).toBeNull();
85
- });
86
- });
87
-
88
- describe("delete", () => {
89
- it("deletes a redirect by ID", async () => {
90
- const redirect = await redirectService.create("/old", "/new");
91
- const result = await redirectService.delete(redirect.id);
92
-
93
- expect(result).toBe(true);
94
-
95
- const found = await redirectService.getByPath("/old");
96
- expect(found).toBeNull();
97
- });
98
-
99
- it("returns false for non-existent ID", async () => {
100
- const result = await redirectService.delete(9999);
101
- expect(result).toBe(false);
102
- });
103
- });
104
-
105
- describe("list", () => {
106
- it("returns empty array when no redirects exist", async () => {
107
- const redirects = await redirectService.list();
108
- expect(redirects).toEqual([]);
109
- });
110
-
111
- it("returns all redirects", async () => {
112
- await redirectService.create("/old-a", "/new-a");
113
- await redirectService.create("/old-b", "/new-b");
114
- await redirectService.create("/old-c", "/new-c");
115
-
116
- const redirects = await redirectService.list();
117
- expect(redirects).toHaveLength(3);
118
- });
119
- });
120
-
121
- describe("path registry integration", () => {
122
- it("rejects redirect that conflicts with a page", async () => {
123
- await pageService.create({ slug: "about", title: "About" });
124
-
125
- await expect(
126
- redirectService.create("/about", "/new-about"),
127
- ).rejects.toThrow(ConflictError);
128
- });
129
-
130
- it("rejects redirect that conflicts with a post path", async () => {
131
- await postService.create({
132
- format: "note",
133
- body: "test",
134
- path: "my-post",
135
- });
136
-
137
- await expect(
138
- redirectService.create("/my-post", "/elsewhere"),
139
- ).rejects.toThrow(ConflictError);
140
- });
141
-
142
- it("allows upsert for existing redirect (same type)", async () => {
143
- await redirectService.create("/old", "/first");
144
- const second = await redirectService.create("/old", "/second");
145
-
146
- expect(second.toPath).toBe("/second");
147
-
148
- const list = await redirectService.list();
149
- expect(list).toHaveLength(1);
150
- });
151
-
152
- it("releases path on delete", async () => {
153
- const redirect = await redirectService.create("/old", "/new");
154
- await redirectService.delete(redirect.id);
155
-
156
- expect(await pathRegistry.isAvailable("old")).toBe(true);
157
- });
158
- });
159
- });
@@ -1,203 +0,0 @@
1
- /**
2
- * Page Service
3
- *
4
- * CRUD operations for standalone pages (about, now, etc.)
5
- */
6
-
7
- import { eq, desc, sql, and } from "drizzle-orm";
8
- import type { Database } from "../db/index.js";
9
- import { pages, navItems } from "../db/schema.js";
10
- import { now } from "../lib/time.js";
11
- import { render as renderMarkdown } from "../lib/markdown.js";
12
- import type { Page, Status, CreatePage, UpdatePage } from "../types.js";
13
- import type { PathRegistryService } from "./path-registry.js";
14
- import { ConflictError } from "../lib/errors.js";
15
-
16
- export interface PageFilters {
17
- status?: Status;
18
- }
19
-
20
- export interface PageService {
21
- getById(id: number): Promise<Page | null>;
22
- getBySlug(slug: string): Promise<Page | null>;
23
- list(filters?: PageFilters): Promise<Page[]>;
24
- listNotInNav(): Promise<Page[]>;
25
- create(data: CreatePage): Promise<Page>;
26
- update(id: number, data: UpdatePage): Promise<Page | null>;
27
- delete(id: number): Promise<boolean>;
28
- }
29
-
30
- /** Check if an error is a SQLite UNIQUE constraint violation (D1 or better-sqlite3) */
31
- function isUniqueConstraintError(err: unknown): boolean {
32
- const msg = String(err);
33
- return msg.includes("UNIQUE constraint") || msg.includes("SQLITE_CONSTRAINT");
34
- }
35
-
36
- export function createPageService(
37
- db: Database,
38
- pathRegistry: PathRegistryService,
39
- ): PageService {
40
- function toPage(row: typeof pages.$inferSelect): Page {
41
- return {
42
- id: row.id,
43
- slug: row.slug,
44
- title: row.title,
45
- body: row.body,
46
- bodyHtml: row.bodyHtml,
47
- status: row.status as Status,
48
- createdAt: row.createdAt,
49
- updatedAt: row.updatedAt,
50
- };
51
- }
52
-
53
- return {
54
- async getById(id) {
55
- const result = await db
56
- .select()
57
- .from(pages)
58
- .where(eq(pages.id, id))
59
- .limit(1);
60
- return result[0] ? toPage(result[0]) : null;
61
- },
62
-
63
- async getBySlug(slug) {
64
- const result = await db
65
- .select()
66
- .from(pages)
67
- .where(eq(pages.slug, slug))
68
- .limit(1);
69
- return result[0] ? toPage(result[0]) : null;
70
- },
71
-
72
- async list(filters?: PageFilters) {
73
- const conditions = [];
74
- if (filters?.status) {
75
- conditions.push(eq(pages.status, filters.status));
76
- }
77
- const rows = await db
78
- .select()
79
- .from(pages)
80
- .where(conditions.length > 0 ? and(...conditions) : undefined)
81
- .orderBy(desc(pages.createdAt));
82
- return rows.map(toPage);
83
- },
84
-
85
- async listNotInNav() {
86
- const rows = await db
87
- .select()
88
- .from(pages)
89
- .where(
90
- sql`${pages.id} NOT IN (SELECT ${navItems.pageId} FROM ${navItems} WHERE ${navItems.pageId} IS NOT NULL)`,
91
- )
92
- .orderBy(desc(pages.createdAt));
93
- return rows.map(toPage);
94
- },
95
-
96
- async create(data) {
97
- // Validate and reserve path before DB insert — throws friendly
98
- // ConflictError/ValidationError instead of a raw UNIQUE constraint error.
99
- // Uses placeholder owner ID; corrected to real ID after insert.
100
- await pathRegistry.claim(data.slug, "page", 0);
101
-
102
- const timestamp = now();
103
- const bodyHtml = data.body ? renderMarkdown(data.body) : null;
104
-
105
- let page: Page;
106
- try {
107
- const result = await db
108
- .insert(pages)
109
- .values({
110
- slug: data.slug,
111
- title: data.title ?? null,
112
- body: data.body ?? null,
113
- bodyHtml,
114
- status: data.status ?? "published",
115
- createdAt: timestamp,
116
- updatedAt: timestamp,
117
- })
118
- .returning();
119
-
120
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
121
- page = toPage(result[0]!);
122
- } catch (err) {
123
- await pathRegistry.release(data.slug);
124
- // Surface DB unique constraint failures as a friendly error
125
- if (isUniqueConstraintError(err)) {
126
- throw new ConflictError(`Slug "${data.slug}" is already in use`);
127
- }
128
- throw err;
129
- }
130
-
131
- // Update registry with actual page ID
132
- await pathRegistry.release(data.slug);
133
- await pathRegistry.claim(data.slug, "page", page.id);
134
-
135
- return page;
136
- },
137
-
138
- async update(id, data) {
139
- const existing = await this.getById(id);
140
- if (!existing) return null;
141
-
142
- const slugChanging =
143
- data.slug !== undefined && data.slug !== existing.slug;
144
-
145
- // If slug is changing, claim the new path first (validates before modifying)
146
- if (slugChanging) {
147
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by slugChanging check
148
- await pathRegistry.claim(data.slug!, "page", id);
149
- }
150
-
151
- const timestamp = now();
152
- const updates: Partial<typeof pages.$inferInsert> = {
153
- updatedAt: timestamp,
154
- };
155
-
156
- if (data.slug !== undefined) updates.slug = data.slug;
157
- if (data.title !== undefined) updates.title = data.title;
158
- if (data.status !== undefined) updates.status = data.status;
159
-
160
- if (data.body !== undefined) {
161
- updates.body = data.body;
162
- updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
163
- }
164
-
165
- // If slug changed, update related nav_items
166
- if (slugChanging) {
167
- await db
168
- .update(navItems)
169
- .set({ url: `/${data.slug}`, updatedAt: timestamp })
170
- .where(eq(navItems.pageId, id));
171
- }
172
-
173
- // If title changed, update related nav_items label
174
- if (data.title !== undefined && data.title !== existing.title) {
175
- await db
176
- .update(navItems)
177
- .set({ label: data.title ?? existing.slug, updatedAt: timestamp })
178
- .where(eq(navItems.pageId, id));
179
- }
180
-
181
- const result = await db
182
- .update(pages)
183
- .set(updates)
184
- .where(eq(pages.id, id))
185
- .returning();
186
-
187
- // Release old slug from registry after successful update
188
- if (slugChanging) {
189
- await pathRegistry.release(existing.slug);
190
- }
191
-
192
- return result[0] ? toPage(result[0]) : null;
193
- },
194
-
195
- async delete(id) {
196
- // Release path registry entries for this page
197
- await pathRegistry.releaseByOwner("page", id);
198
- // nav_items with page_id FK have ON DELETE CASCADE, so they auto-delete
199
- const result = await db.delete(pages).where(eq(pages.id, id)).returning();
200
- return result.length > 0;
201
- },
202
- };
203
- }
@@ -1,160 +0,0 @@
1
- /**
2
- * Path Registry Service
3
- *
4
- * Central registry for URL path ownership. Every entity (page, post, redirect)
5
- * that claims a URL path registers it here. The table's PRIMARY KEY on path
6
- * provides DB-level uniqueness. Reserved system paths are rejected at the
7
- * service layer.
8
- */
9
-
10
- import { eq, and } from "drizzle-orm";
11
- import type { Database } from "../db/index.js";
12
- import { pathRegistry } from "../db/schema.js";
13
- import { now } from "../lib/time.js";
14
- import { normalizePath } from "../lib/url.js";
15
- import { isReservedPath } from "../lib/constants.js";
16
- import { ValidationError, ConflictError } from "../lib/errors.js";
17
-
18
- export type OwnerType = "page" | "post" | "redirect";
19
-
20
- export interface PathRegistryEntry {
21
- path: string;
22
- ownerType: OwnerType;
23
- ownerId: number;
24
- createdAt: number;
25
- }
26
-
27
- export interface PathRegistryService {
28
- /**
29
- * Claim a path for an entity. Rejects reserved paths and conflicts.
30
- * Idempotent: re-claiming the same path for the same owner is a no-op.
31
- *
32
- * @param path - The URL path to claim
33
- * @param ownerType - The type of entity claiming the path
34
- * @param ownerId - The ID of the entity claiming the path
35
- * @returns The registry entry
36
- */
37
- claim(
38
- path: string,
39
- ownerType: OwnerType,
40
- ownerId: number,
41
- ): Promise<PathRegistryEntry>;
42
-
43
- /**
44
- * Release a claimed path.
45
- *
46
- * @param path - The URL path to release
47
- */
48
- release(path: string): Promise<void>;
49
-
50
- /**
51
- * Release all paths owned by a specific entity.
52
- *
53
- * @param ownerType - The type of entity
54
- * @param ownerId - The ID of the entity
55
- */
56
- releaseByOwner(ownerType: OwnerType, ownerId: number): Promise<void>;
57
-
58
- /**
59
- * Look up a path in the registry.
60
- *
61
- * @param path - The URL path to look up
62
- * @returns The registry entry, or null if not claimed
63
- */
64
- getByPath(path: string): Promise<PathRegistryEntry | null>;
65
-
66
- /**
67
- * Check if a path is available (not reserved and not claimed).
68
- *
69
- * @param path - The URL path to check
70
- * @returns true if the path is available
71
- */
72
- isAvailable(path: string): Promise<boolean>;
73
- }
74
-
75
- export function createPathRegistryService(db: Database): PathRegistryService {
76
- function toEntry(row: typeof pathRegistry.$inferSelect): PathRegistryEntry {
77
- return {
78
- path: row.path,
79
- ownerType: row.ownerType as OwnerType,
80
- ownerId: row.ownerId,
81
- createdAt: row.createdAt,
82
- };
83
- }
84
-
85
- return {
86
- async claim(path, ownerType, ownerId) {
87
- const normalized = normalizePath(path);
88
-
89
- if (isReservedPath(normalized)) {
90
- throw new ValidationError(
91
- `Path "${normalized}" is reserved and cannot be used`,
92
- );
93
- }
94
-
95
- // Check existing claim
96
- const existing = await db
97
- .select()
98
- .from(pathRegistry)
99
- .where(eq(pathRegistry.path, normalized))
100
- .limit(1);
101
-
102
- if (existing[0]) {
103
- const entry = toEntry(existing[0]);
104
- // Idempotent: same owner re-claiming is a no-op
105
- if (entry.ownerType === ownerType && entry.ownerId === ownerId) {
106
- return entry;
107
- }
108
- throw new ConflictError(`Path "${normalized}" is already in use`);
109
- }
110
-
111
- const timestamp = now();
112
- await db.insert(pathRegistry).values({
113
- path: normalized,
114
- ownerType,
115
- ownerId,
116
- createdAt: timestamp,
117
- });
118
-
119
- return { path: normalized, ownerType, ownerId, createdAt: timestamp };
120
- },
121
-
122
- async release(path) {
123
- const normalized = normalizePath(path);
124
- await db.delete(pathRegistry).where(eq(pathRegistry.path, normalized));
125
- },
126
-
127
- async releaseByOwner(ownerType, ownerId) {
128
- await db
129
- .delete(pathRegistry)
130
- .where(
131
- and(
132
- eq(pathRegistry.ownerType, ownerType),
133
- eq(pathRegistry.ownerId, ownerId),
134
- ),
135
- );
136
- },
137
-
138
- async getByPath(path) {
139
- const normalized = normalizePath(path);
140
- const result = await db
141
- .select()
142
- .from(pathRegistry)
143
- .where(eq(pathRegistry.path, normalized))
144
- .limit(1);
145
- return result[0] ? toEntry(result[0]) : null;
146
- },
147
-
148
- async isAvailable(path) {
149
- const normalized = normalizePath(path);
150
- if (isReservedPath(normalized)) return false;
151
-
152
- const existing = await db
153
- .select()
154
- .from(pathRegistry)
155
- .where(eq(pathRegistry.path, normalized))
156
- .limit(1);
157
- return existing.length === 0;
158
- },
159
- };
160
- }