@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
@@ -5,6 +5,9 @@ import {
5
5
  RedirectTypeSchema,
6
6
  CreatePostSchema,
7
7
  UpdatePostSchema,
8
+ SetupSchema,
9
+ SigninSchema,
10
+ normalizeEmail,
8
11
  parseFormData,
9
12
  parseFormDataOptional,
10
13
  validateMediaCount,
@@ -52,6 +55,46 @@ describe("RedirectTypeSchema", () => {
52
55
  });
53
56
  });
54
57
 
58
+ describe("normalizeEmail", () => {
59
+ it("trims and lowercases email addresses", () => {
60
+ expect(normalizeEmail(" User.Name+tag@Example.COM ")).toBe(
61
+ "user.name+tag@example.com",
62
+ );
63
+ });
64
+ });
65
+
66
+ describe("SetupSchema", () => {
67
+ it("normalizes email before returning parsed data", () => {
68
+ const result = SetupSchema.parse({
69
+ siteName: "Jant",
70
+ email: " Admin@Example.COM ",
71
+ password: "password123",
72
+ });
73
+
74
+ expect(result.email).toBe("admin@example.com");
75
+ });
76
+ });
77
+
78
+ describe("SigninSchema", () => {
79
+ it("normalizes email before returning parsed data", () => {
80
+ const result = SigninSchema.parse({
81
+ email: " Admin@Example.COM ",
82
+ password: "password123",
83
+ });
84
+
85
+ expect(result.email).toBe("admin@example.com");
86
+ });
87
+
88
+ it("rejects invalid email after normalization", () => {
89
+ expect(() =>
90
+ SigninSchema.parse({
91
+ email: " not-an-email ",
92
+ password: "password123",
93
+ }),
94
+ ).toThrow();
95
+ });
96
+ });
97
+
55
98
  describe("CreatePostSchema", () => {
56
99
  const validPost = {
57
100
  format: "note",
@@ -67,11 +110,23 @@ describe("CreatePostSchema", () => {
67
110
  });
68
111
 
69
112
  it("accepts all formats", () => {
70
- for (const format of FORMATS) {
71
- expect(() =>
72
- CreatePostSchema.parse({ ...validPost, format }),
73
- ).not.toThrow();
74
- }
113
+ expect(() =>
114
+ CreatePostSchema.parse({ ...validPost, format: "note" }),
115
+ ).not.toThrow();
116
+ expect(() =>
117
+ CreatePostSchema.parse({
118
+ ...validPost,
119
+ format: "link",
120
+ url: "https://example.com",
121
+ }),
122
+ ).not.toThrow();
123
+ expect(() =>
124
+ CreatePostSchema.parse({
125
+ ...validPost,
126
+ format: "quote",
127
+ quoteText: "A wise person once said...",
128
+ }),
129
+ ).not.toThrow();
75
130
  });
76
131
 
77
132
  it("accepts optional title", () => {
@@ -82,88 +137,68 @@ describe("CreatePostSchema", () => {
82
137
  expect(result.title).toBe("My Post");
83
138
  });
84
139
 
85
- it("accepts valid path format", () => {
140
+ it("accepts valid slug format", () => {
86
141
  const result = CreatePostSchema.parse({
87
142
  ...validPost,
88
- path: "my-post-path",
143
+ slug: "my-post-slug",
89
144
  });
90
- expect(result.path).toBe("my-post-path");
145
+ expect(result.slug).toBe("my-post-slug");
91
146
  });
92
147
 
93
- it("accepts single-character path", () => {
148
+ it("accepts single-character slug", () => {
94
149
  const result = CreatePostSchema.parse({
95
150
  ...validPost,
96
- path: "a",
151
+ slug: "a",
97
152
  });
98
- expect(result.path).toBe("a");
153
+ expect(result.slug).toBe("a");
99
154
  });
100
155
 
101
- it("accepts empty path (transforms to undefined)", () => {
102
- const result = CreatePostSchema.parse({ ...validPost, path: "" });
103
- expect(result.path).toBeUndefined();
156
+ it("accepts empty slug (transforms to undefined)", () => {
157
+ const result = CreatePostSchema.parse({ ...validPost, slug: "" });
158
+ expect(result.slug).toBeUndefined();
104
159
  });
105
160
 
106
- it("accepts multi-level path", () => {
161
+ it("normalizes uppercase slug", () => {
162
+ const result = CreatePostSchema.parse({ ...validPost, slug: "MyPost" });
163
+ expect(result.slug).toBe("mypost");
164
+ });
165
+
166
+ it("normalizes special chars in slug", () => {
107
167
  const result = CreatePostSchema.parse({
108
168
  ...validPost,
109
- path: "2024/my-post",
169
+ slug: "my post!",
110
170
  });
111
- expect(result.path).toBe("2024/my-post");
171
+ expect(result.slug).toBe("my-post");
112
172
  });
113
173
 
114
- it("accepts deeply nested path", () => {
174
+ it("normalizes leading hyphen in slug", () => {
115
175
  const result = CreatePostSchema.parse({
116
176
  ...validPost,
117
- path: "2024/01/my-post",
177
+ slug: "-my-post",
118
178
  });
119
- expect(result.path).toBe("2024/01/my-post");
120
- });
121
-
122
- it("rejects invalid path format (uppercase)", () => {
123
- expect(() =>
124
- CreatePostSchema.parse({ ...validPost, path: "MyPost" }),
125
- ).toThrow();
179
+ expect(result.slug).toBe("my-post");
126
180
  });
127
181
 
128
- it("rejects invalid path format (special chars)", () => {
129
- expect(() =>
130
- CreatePostSchema.parse({ ...validPost, path: "my post!" }),
131
- ).toThrow();
132
- });
133
-
134
- it("rejects path starting with hyphen", () => {
135
- expect(() =>
136
- CreatePostSchema.parse({ ...validPost, path: "-my-post" }),
137
- ).toThrow();
138
- });
139
-
140
- it("rejects path ending with hyphen", () => {
141
- expect(() =>
142
- CreatePostSchema.parse({ ...validPost, path: "my-post-" }),
143
- ).toThrow();
144
- });
145
-
146
- it("rejects path with leading slash", () => {
147
- expect(() =>
148
- CreatePostSchema.parse({ ...validPost, path: "/my-post" }),
149
- ).toThrow();
150
- });
151
-
152
- it("rejects path with trailing slash", () => {
153
- expect(() =>
154
- CreatePostSchema.parse({ ...validPost, path: "my-post/" }),
155
- ).toThrow();
182
+ it("normalizes trailing hyphen in slug", () => {
183
+ const result = CreatePostSchema.parse({
184
+ ...validPost,
185
+ slug: "my-post-",
186
+ });
187
+ expect(result.slug).toBe("my-post");
156
188
  });
157
189
 
158
- it("rejects path with consecutive slashes", () => {
159
- expect(() =>
160
- CreatePostSchema.parse({ ...validPost, path: "2024//my-post" }),
161
- ).toThrow();
190
+ it("normalizes slashes in slug", () => {
191
+ const result = CreatePostSchema.parse({
192
+ ...validPost,
193
+ slug: "my/post",
194
+ });
195
+ expect(result.slug).toBe("my-post");
162
196
  });
163
197
 
164
198
  it("accepts valid url", () => {
165
199
  const result = CreatePostSchema.parse({
166
200
  ...validPost,
201
+ format: "link",
167
202
  url: "https://example.com",
168
203
  });
169
204
  expect(result.url).toBe("https://example.com");
@@ -231,14 +266,28 @@ describe("CreatePostSchema", () => {
231
266
  ).toThrow();
232
267
  });
233
268
 
269
+ it("accepts visibility values", () => {
270
+ for (const v of ["public", "unlisted", "private"]) {
271
+ const result = CreatePostSchema.parse({ ...validPost, visibility: v });
272
+ expect(result.visibility).toBe(v);
273
+ }
274
+ });
275
+
234
276
  it("accepts featured as boolean", () => {
235
277
  const result = CreatePostSchema.parse({ ...validPost, featured: true });
236
278
  expect(result.featured).toBe(true);
237
279
  });
238
280
 
239
- it("accepts featured as 'on' (transforms to true)", () => {
240
- const result = CreatePostSchema.parse({ ...validPost, featured: "on" });
241
- expect(result.featured).toBe(true);
281
+ it("rejects featured as non-boolean (other than 'on')", () => {
282
+ expect(() =>
283
+ CreatePostSchema.parse({ ...validPost, featured: "invalid" }),
284
+ ).toThrow();
285
+ });
286
+
287
+ it("rejects invalid visibility", () => {
288
+ expect(() =>
289
+ CreatePostSchema.parse({ ...validPost, visibility: "hidden" }),
290
+ ).toThrow();
242
291
  });
243
292
 
244
293
  it("accepts pinned as boolean", () => {
@@ -251,14 +300,62 @@ describe("CreatePostSchema", () => {
251
300
  expect(result.pinned).toBe(true);
252
301
  });
253
302
 
254
- it("accepts optional quoteText", () => {
303
+ it("accepts optional quoteText for quote posts", () => {
255
304
  const result = CreatePostSchema.parse({
256
305
  ...validPost,
306
+ format: "quote",
257
307
  quoteText: "A wise person once said...",
258
308
  });
259
309
  expect(result.quoteText).toBe("A wise person once said...");
260
310
  });
261
311
 
312
+ it("rejects note posts with a URL", () => {
313
+ expect(() =>
314
+ CreatePostSchema.parse({
315
+ ...validPost,
316
+ url: "https://example.com",
317
+ }),
318
+ ).toThrow("Notes can't include a URL.");
319
+ });
320
+
321
+ it("rejects note posts with quoted text", () => {
322
+ expect(() =>
323
+ CreatePostSchema.parse({
324
+ ...validPost,
325
+ quoteText: "A wise person once said...",
326
+ }),
327
+ ).toThrow("Notes can't include quoted text.");
328
+ });
329
+
330
+ it("rejects link posts without a URL", () => {
331
+ expect(() =>
332
+ CreatePostSchema.parse({
333
+ ...validPost,
334
+ format: "link",
335
+ }),
336
+ ).toThrow("Link posts need a URL.");
337
+ });
338
+
339
+ it("rejects link posts with quoted text", () => {
340
+ expect(() =>
341
+ CreatePostSchema.parse({
342
+ ...validPost,
343
+ format: "link",
344
+ url: "https://example.com",
345
+ quoteText: "A wise person once said...",
346
+ }),
347
+ ).toThrow("Link posts can't include quoted text.");
348
+ });
349
+
350
+ it("rejects quote posts without quoted text", () => {
351
+ expect(() =>
352
+ CreatePostSchema.parse({
353
+ ...validPost,
354
+ format: "quote",
355
+ }),
356
+ ).toThrow("Quote posts need quoted text.");
357
+ });
358
+
262
359
  it("accepts optional rating (1-5)", () => {
263
360
  for (const rating of [1, 2, 3, 4, 5]) {
264
361
  const result = CreatePostSchema.parse({ ...validPost, rating });
@@ -283,12 +380,18 @@ describe("CreatePostSchema", () => {
283
380
  expect(result.rating).toBeUndefined();
284
381
  });
285
382
 
286
- it("accepts optional collectionIds as array of positive integers", () => {
383
+ it("accepts optional collectionIds as array of non-empty strings", () => {
287
384
  const result = CreatePostSchema.parse({
288
385
  ...validPost,
289
- collectionIds: [1, 2, 3],
386
+ collectionIds: ["col-1", "col-2", "col-3"],
290
387
  });
291
- expect(result.collectionIds).toEqual([1, 2, 3]);
388
+ expect(result.collectionIds).toEqual(["col-1", "col-2", "col-3"]);
389
+ });
390
+
391
+ it("rejects collectionIds with empty strings", () => {
392
+ expect(() =>
393
+ CreatePostSchema.parse({ ...validPost, collectionIds: [""] }),
394
+ ).toThrow();
292
395
  });
293
396
 
294
397
  it("accepts empty string collectionIds (transforms to undefined)", () => {
@@ -316,6 +419,24 @@ describe("CreatePostSchema", () => {
316
419
  expect(() => CreatePostSchema.parse({})).toThrow();
317
420
  expect(() => CreatePostSchema.parse({ body: "hello" })).toThrow();
318
421
  });
422
+
423
+ it("accepts bodyMarkdown", () => {
424
+ const result = CreatePostSchema.parse({
425
+ format: "note",
426
+ bodyMarkdown: "Hello **world**",
427
+ });
428
+ expect(result.bodyMarkdown).toBe("Hello **world**");
429
+ });
430
+
431
+ it("rejects both body and bodyMarkdown", () => {
432
+ expect(() =>
433
+ CreatePostSchema.parse({
434
+ format: "note",
435
+ body: '{"type":"doc","content":[]}',
436
+ bodyMarkdown: "Hello",
437
+ }),
438
+ ).toThrow("Provide either body or bodyMarkdown, not both");
439
+ });
319
440
  });
320
441
 
321
442
  describe("UpdatePostSchema", () => {
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { generatePostSlug } from "../slug.js";
3
+
4
+ /** Helper: always-available check */
5
+ const alwaysAvailable = async () => true;
6
+
7
+ /** Helper: never-available check */
8
+ const neverAvailable = async () => false;
9
+
10
+ /** Helper: available after N calls */
11
+ function availableAfter(n: number) {
12
+ let calls = 0;
13
+ return async () => {
14
+ calls++;
15
+ return calls > n;
16
+ };
17
+ }
18
+
19
+ describe("generatePostSlug", () => {
20
+ describe("user-provided slug", () => {
21
+ it("returns user slug when available", async () => {
22
+ const slug = await generatePostSlug({
23
+ slug: "my-post",
24
+ idLength: 5,
25
+ isAvailable: alwaysAvailable,
26
+ });
27
+ expect(slug).toBe("my-post");
28
+ });
29
+
30
+ it("throws ConflictError when slug is taken", async () => {
31
+ await expect(
32
+ generatePostSlug({
33
+ slug: "taken-slug",
34
+ idLength: 5,
35
+ isAvailable: neverAvailable,
36
+ }),
37
+ ).rejects.toThrow("already in use");
38
+ });
39
+
40
+ it("throws ValidationError for reserved slug", async () => {
41
+ await expect(
42
+ generatePostSlug({
43
+ slug: "dash",
44
+ idLength: 5,
45
+ isAvailable: alwaysAvailable,
46
+ }),
47
+ ).rejects.toThrow("reserved");
48
+ });
49
+
50
+ it("prioritizes user slug over title", async () => {
51
+ const slug = await generatePostSlug({
52
+ slug: "custom-slug",
53
+ title: "My Title",
54
+ idLength: 5,
55
+ isAvailable: alwaysAvailable,
56
+ });
57
+ expect(slug).toBe("custom-slug");
58
+ });
59
+ });
60
+
61
+ describe("title-based slug", () => {
62
+ it("generates slug from title", async () => {
63
+ const slug = await generatePostSlug({
64
+ title: "Hello World",
65
+ idLength: 5,
66
+ isAvailable: alwaysAvailable,
67
+ });
68
+ expect(slug).toBe("hello-world");
69
+ });
70
+
71
+ it("appends random suffix on conflict", async () => {
72
+ const slug = await generatePostSlug({
73
+ title: "Hello World",
74
+ idLength: 5,
75
+ isAvailable: availableAfter(1), // first call (base) fails, second succeeds
76
+ });
77
+ expect(slug).toMatch(/^hello-world-[a-z0-9]{5}$/);
78
+ });
79
+
80
+ it("retries with different random suffixes", async () => {
81
+ const slug = await generatePostSlug({
82
+ title: "Test Post",
83
+ idLength: 5,
84
+ isAvailable: availableAfter(3),
85
+ });
86
+ expect(slug).toMatch(/^test-post-[a-z0-9]{5}$/);
87
+ });
88
+
89
+ it("throws after exceeding max retries", async () => {
90
+ await expect(
91
+ generatePostSlug({
92
+ title: "Test Post",
93
+ idLength: 5,
94
+ isAvailable: neverAvailable,
95
+ }),
96
+ ).rejects.toThrow("Could not generate a unique slug");
97
+ });
98
+ });
99
+
100
+ describe("random slug (no title, no slug)", () => {
101
+ it("generates random ID of specified length", async () => {
102
+ const slug = await generatePostSlug({
103
+ idLength: 5,
104
+ isAvailable: alwaysAvailable,
105
+ });
106
+ expect(slug).toMatch(/^[a-z0-9]{5}$/);
107
+ });
108
+
109
+ it("retries on conflict", async () => {
110
+ const slug = await generatePostSlug({
111
+ idLength: 8,
112
+ isAvailable: availableAfter(2),
113
+ });
114
+ expect(slug).toMatch(/^[a-z0-9]{8}$/);
115
+ });
116
+
117
+ it("throws after exceeding max retries", async () => {
118
+ await expect(
119
+ generatePostSlug({
120
+ idLength: 5,
121
+ isAvailable: neverAvailable,
122
+ }),
123
+ ).rejects.toThrow("Could not generate a unique slug");
124
+ });
125
+ });
126
+ });
@@ -3,20 +3,20 @@ import { dsRedirect, dsToast, dsSignals } from "../sse.js";
3
3
 
4
4
  describe("dsRedirect", () => {
5
5
  it("returns a Response with text/html content-type", () => {
6
- const res = dsRedirect("/dash");
6
+ const res = dsRedirect("/settings");
7
7
  expect(res.headers.get("Content-Type")).toBe("text/html");
8
8
  });
9
9
 
10
10
  it("includes Datastar headers for append mode", () => {
11
- const res = dsRedirect("/dash");
11
+ const res = dsRedirect("/settings");
12
12
  expect(res.headers.get("Datastar-Mode")).toBe("append");
13
13
  expect(res.headers.get("Datastar-Selector")).toBe("body");
14
14
  });
15
15
 
16
16
  it("body contains redirect script with correct URL", async () => {
17
- const res = dsRedirect("/dash/posts");
17
+ const res = dsRedirect("/settings/general");
18
18
  const body = await res.text();
19
- expect(body).toContain("window.location.href='/dash/posts'");
19
+ expect(body).toContain("window.location.href='/settings/general'");
20
20
  });
21
21
 
22
22
  it("escapes single quotes in URL", async () => {
@@ -26,7 +26,7 @@ describe("dsRedirect", () => {
26
26
  });
27
27
 
28
28
  it("merges additional headers from plain object", () => {
29
- const res = dsRedirect("/dash", {
29
+ const res = dsRedirect("/settings", {
30
30
  headers: { "Set-Cookie": "session=abc" },
31
31
  });
32
32
  expect(res.headers.get("Set-Cookie")).toBe("session=abc");
@@ -37,7 +37,7 @@ describe("dsRedirect", () => {
37
37
  const headers = new Headers();
38
38
  headers.append("set-cookie", "session=abc; Path=/; HttpOnly");
39
39
  headers.append("set-cookie", "data=xyz; Path=/; Max-Age=300");
40
- const res = dsRedirect("/dash", { headers });
40
+ const res = dsRedirect("/settings", { headers });
41
41
  const cookies = res.headers.getSetCookie();
42
42
  expect(cookies).toHaveLength(2);
43
43
  expect(cookies[0]).toBe("session=abc; Path=/; HttpOnly");