@jant/core 0.3.36 → 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 (271) 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/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -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");
154
+ });
155
+
156
+ it("accepts empty slug (transforms to undefined)", () => {
157
+ const result = CreatePostSchema.parse({ ...validPost, slug: "" });
158
+ expect(result.slug).toBeUndefined();
99
159
  });
100
160
 
101
- it("accepts empty path (transforms to undefined)", () => {
102
- const result = CreatePostSchema.parse({ ...validPost, path: "" });
103
- expect(result.path).toBeUndefined();
161
+ it("normalizes uppercase slug", () => {
162
+ const result = CreatePostSchema.parse({ ...validPost, slug: "MyPost" });
163
+ expect(result.slug).toBe("mypost");
104
164
  });
105
165
 
106
- it("accepts multi-level path", () => {
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();
126
- });
127
-
128
- it("rejects invalid path format (special chars)", () => {
129
- expect(() =>
130
- CreatePostSchema.parse({ ...validPost, path: "my post!" }),
131
- ).toThrow();
179
+ expect(result.slug).toBe("my-post");
132
180
  });
133
181
 
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");
@@ -232,12 +267,23 @@ describe("CreatePostSchema", () => {
232
267
  });
233
268
 
234
269
  it("accepts visibility values", () => {
235
- for (const v of ["listed", "featured", "unlisted"]) {
270
+ for (const v of ["public", "unlisted", "private"]) {
236
271
  const result = CreatePostSchema.parse({ ...validPost, visibility: v });
237
272
  expect(result.visibility).toBe(v);
238
273
  }
239
274
  });
240
275
 
276
+ it("accepts featured as boolean", () => {
277
+ const result = CreatePostSchema.parse({ ...validPost, featured: true });
278
+ expect(result.featured).toBe(true);
279
+ });
280
+
281
+ it("rejects featured as non-boolean (other than 'on')", () => {
282
+ expect(() =>
283
+ CreatePostSchema.parse({ ...validPost, featured: "invalid" }),
284
+ ).toThrow();
285
+ });
286
+
241
287
  it("rejects invalid visibility", () => {
242
288
  expect(() =>
243
289
  CreatePostSchema.parse({ ...validPost, visibility: "hidden" }),
@@ -254,14 +300,62 @@ describe("CreatePostSchema", () => {
254
300
  expect(result.pinned).toBe(true);
255
301
  });
256
302
 
257
- it("accepts optional quoteText", () => {
303
+ it("accepts optional quoteText for quote posts", () => {
258
304
  const result = CreatePostSchema.parse({
259
305
  ...validPost,
306
+ format: "quote",
260
307
  quoteText: "A wise person once said...",
261
308
  });
262
309
  expect(result.quoteText).toBe("A wise person once said...");
263
310
  });
264
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
+
265
359
  it("accepts optional rating (1-5)", () => {
266
360
  for (const rating of [1, 2, 3, 4, 5]) {
267
361
  const result = CreatePostSchema.parse({ ...validPost, rating });
@@ -286,12 +380,18 @@ describe("CreatePostSchema", () => {
286
380
  expect(result.rating).toBeUndefined();
287
381
  });
288
382
 
289
- it("accepts optional collectionIds as array of positive integers", () => {
383
+ it("accepts optional collectionIds as array of non-empty strings", () => {
290
384
  const result = CreatePostSchema.parse({
291
385
  ...validPost,
292
- collectionIds: [1, 2, 3],
386
+ collectionIds: ["col-1", "col-2", "col-3"],
293
387
  });
294
- 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();
295
395
  });
296
396
 
297
397
  it("accepts empty string collectionIds (transforms to undefined)", () => {
@@ -319,6 +419,24 @@ describe("CreatePostSchema", () => {
319
419
  expect(() => CreatePostSchema.parse({})).toThrow();
320
420
  expect(() => CreatePostSchema.parse({ body: "hello" })).toThrow();
321
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
+ });
322
440
  });
323
441
 
324
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");