@jant/core 0.3.26 → 0.3.28

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 (314) hide show
  1. package/dist/client/client.css +1 -0
  2. package/dist/client/client.js +31561 -0
  3. package/dist/index.js +15209 -15
  4. package/package.json +21 -15
  5. package/src/__tests__/helpers/app.ts +19 -3
  6. package/src/__tests__/helpers/db.ts +44 -0
  7. package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
  8. package/src/app.tsx +112 -173
  9. package/src/auth.ts +4 -1
  10. package/src/client.ts +13 -0
  11. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  12. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  13. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  14. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  15. package/src/db/schema.ts +24 -4
  16. package/src/i18n/locales/en.po +810 -385
  17. package/src/i18n/locales/en.ts +1 -1
  18. package/src/i18n/locales/zh-Hans.po +733 -522
  19. package/src/i18n/locales/zh-Hans.ts +1 -1
  20. package/src/i18n/locales/zh-Hant.po +733 -522
  21. package/src/i18n/locales/zh-Hant.ts +1 -1
  22. package/src/i18n/middleware.ts +7 -11
  23. package/src/index.ts +1 -1
  24. package/src/lib/__tests__/icons.test.ts +178 -0
  25. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  26. package/src/lib/__tests__/schemas.test.ts +12 -6
  27. package/src/lib/__tests__/theme.test.ts +62 -0
  28. package/src/lib/__tests__/timezones.test.ts +1 -1
  29. package/src/lib/__tests__/url.test.ts +12 -0
  30. package/src/lib/__tests__/view.test.ts +1 -5
  31. package/src/lib/avatar-upload.ts +18 -10
  32. package/src/lib/collection-form-bridge.ts +52 -0
  33. package/src/lib/collections-reorder.ts +28 -0
  34. package/src/lib/compose-bridge.ts +251 -0
  35. package/src/lib/errors.ts +116 -0
  36. package/src/lib/excerpt.ts +1 -1
  37. package/src/lib/favicon.ts +3 -5
  38. package/src/lib/html.ts +22 -0
  39. package/src/lib/icon-catalog.ts +181 -0
  40. package/src/lib/icons.ts +202 -0
  41. package/src/lib/navigation.ts +18 -33
  42. package/src/lib/pagination.ts +3 -2
  43. package/src/lib/post-form-bridge.ts +136 -0
  44. package/src/lib/render.tsx +11 -4
  45. package/src/lib/resolve-config.ts +157 -0
  46. package/src/lib/schemas.ts +76 -12
  47. package/src/lib/settings-bridge.ts +139 -0
  48. package/src/lib/storage.ts +37 -16
  49. package/src/lib/theme.ts +5 -7
  50. package/src/lib/timeline.ts +4 -8
  51. package/src/lib/toast.ts +134 -0
  52. package/src/lib/upload.ts +71 -0
  53. package/src/lib/url.ts +9 -1
  54. package/src/lib/version.ts +16 -0
  55. package/src/lib/view.ts +9 -10
  56. package/src/middleware/__tests__/auth.test.ts +6 -28
  57. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  58. package/src/middleware/auth.ts +6 -12
  59. package/src/middleware/config.ts +51 -0
  60. package/src/middleware/error-handler.ts +56 -0
  61. package/src/middleware/onboarding.ts +1 -1
  62. package/src/preset.css +6 -0
  63. package/src/routes/__tests__/compose.test.ts +104 -17
  64. package/src/routes/api/__tests__/collections.test.ts +93 -2
  65. package/src/routes/api/__tests__/posts.test.ts +2 -1
  66. package/src/routes/api/__tests__/settings.test.ts +1 -1
  67. package/src/routes/api/collections.ts +64 -68
  68. package/src/routes/api/nav-items.ts +21 -59
  69. package/src/routes/api/pages.ts +18 -46
  70. package/src/routes/api/posts.ts +64 -86
  71. package/src/routes/api/search.ts +6 -4
  72. package/src/routes/api/settings.ts +8 -24
  73. package/src/routes/api/upload.ts +55 -53
  74. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  75. package/src/routes/auth/reset.tsx +17 -66
  76. package/src/routes/auth/setup.tsx +67 -11
  77. package/src/routes/auth/signin.tsx +44 -8
  78. package/src/routes/compose.tsx +194 -0
  79. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  80. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  81. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  82. package/src/routes/dash/appearance.tsx +173 -0
  83. package/src/routes/dash/collections.tsx +80 -14
  84. package/src/routes/dash/index.tsx +12 -14
  85. package/src/routes/dash/media.tsx +46 -49
  86. package/src/routes/dash/pages.tsx +85 -37
  87. package/src/routes/dash/posts.tsx +60 -23
  88. package/src/routes/dash/redirects.tsx +43 -33
  89. package/src/routes/dash/settings.tsx +234 -214
  90. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  91. package/src/routes/feed/rss.ts +11 -16
  92. package/src/routes/feed/sitemap.ts +15 -9
  93. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  94. package/src/routes/pages/archive.tsx +2 -2
  95. package/src/routes/pages/collection.tsx +76 -9
  96. package/src/routes/pages/collections.tsx +3 -1
  97. package/src/routes/pages/featured.tsx +2 -2
  98. package/src/routes/pages/home.tsx +3 -3
  99. package/src/routes/pages/latest.tsx +2 -2
  100. package/src/routes/pages/page.tsx +2 -2
  101. package/src/routes/pages/post.tsx +2 -2
  102. package/src/routes/pages/search.tsx +2 -2
  103. package/src/services/__tests__/collection.test.ts +324 -34
  104. package/src/services/__tests__/media.test.ts +1 -1
  105. package/src/services/__tests__/page.test.ts +116 -1
  106. package/src/services/auth.ts +88 -0
  107. package/src/services/collection.ts +169 -30
  108. package/src/services/index.ts +8 -3
  109. package/src/services/media.ts +39 -12
  110. package/src/services/navigation.ts +17 -5
  111. package/src/services/page.ts +24 -4
  112. package/src/services/post.ts +87 -19
  113. package/src/services/search.ts +0 -1
  114. package/src/services/settings.ts +21 -13
  115. package/src/style.css +3 -0
  116. package/src/styles/components.css +42 -1
  117. package/src/styles/tokens.css +4 -0
  118. package/src/styles/ui.css +902 -73
  119. package/src/types/app-context.ts +25 -0
  120. package/src/types/bindings.ts +1 -0
  121. package/src/types/config.ts +60 -23
  122. package/src/types/entities.ts +12 -2
  123. package/src/types/lingui-react-macro.d.ts +3 -3
  124. package/src/types/operations.ts +2 -4
  125. package/src/types/views.ts +1 -3
  126. package/src/ui/__tests__/font-themes.test.ts +27 -8
  127. package/src/ui/color-themes.ts +1 -1
  128. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  129. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  130. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  131. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  132. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  133. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  134. package/src/ui/components/collection-types.ts +45 -0
  135. package/src/ui/components/compose-types.ts +75 -0
  136. package/src/ui/components/jant-collection-form.ts +512 -0
  137. package/src/ui/components/jant-compose-dialog.ts +494 -0
  138. package/src/ui/components/jant-compose-editor.ts +799 -0
  139. package/src/ui/components/jant-post-form.ts +290 -0
  140. package/src/ui/components/jant-settings-avatar.ts +231 -0
  141. package/src/ui/components/jant-settings-general.ts +436 -0
  142. package/src/ui/components/post-form-template.ts +260 -0
  143. package/src/ui/components/post-form-types.ts +87 -0
  144. package/src/ui/components/settings-types.ts +62 -0
  145. package/src/ui/compose/ComposeDialog.tsx +141 -385
  146. package/src/ui/compose/ComposePrompt.tsx +3 -3
  147. package/src/ui/dash/PostList.tsx +55 -61
  148. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  149. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  150. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  151. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  152. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  153. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  154. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  155. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  156. package/src/ui/dash/index.ts +1 -1
  157. package/src/ui/dash/posts/PostForm.tsx +248 -0
  158. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  159. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  160. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  161. package/src/ui/font-themes.ts +115 -32
  162. package/src/ui/layouts/BaseLayout.tsx +49 -19
  163. package/src/ui/layouts/DashLayout.tsx +14 -9
  164. package/src/ui/layouts/SiteLayout.tsx +38 -23
  165. package/src/ui/pages/CollectionPage.tsx +12 -2
  166. package/src/ui/pages/CollectionsPage.tsx +27 -27
  167. package/src/ui/pages/HomePage.tsx +15 -6
  168. package/src/ui/pages/SearchPage.tsx +1 -2
  169. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  170. package/src/ui/shared/Pagination.tsx +2 -2
  171. package/dist/app.js +0 -265
  172. package/dist/auth.js +0 -36
  173. package/dist/client.js +0 -13
  174. package/dist/db/index.js +0 -10
  175. package/dist/db/schema.js +0 -224
  176. package/dist/i18n/Trans.js +0 -24
  177. package/dist/i18n/context.js +0 -58
  178. package/dist/i18n/detect.js +0 -26
  179. package/dist/i18n/i18n.js +0 -49
  180. package/dist/i18n/index.js +0 -44
  181. package/dist/i18n/locales/en.js +0 -1
  182. package/dist/i18n/locales/zh-Hans.js +0 -1
  183. package/dist/i18n/locales/zh-Hant.js +0 -1
  184. package/dist/i18n/locales.js +0 -13
  185. package/dist/i18n/middleware.js +0 -30
  186. package/dist/lib/avatar-upload.js +0 -134
  187. package/dist/lib/config.js +0 -143
  188. package/dist/lib/constants.js +0 -50
  189. package/dist/lib/excerpt.js +0 -76
  190. package/dist/lib/favicon.js +0 -102
  191. package/dist/lib/feed.js +0 -123
  192. package/dist/lib/image-processor.js +0 -187
  193. package/dist/lib/image.js +0 -97
  194. package/dist/lib/index.js +0 -7
  195. package/dist/lib/markdown.js +0 -83
  196. package/dist/lib/media-helpers.js +0 -49
  197. package/dist/lib/media-upload.js +0 -104
  198. package/dist/lib/nav-reorder.js +0 -27
  199. package/dist/lib/navigation.js +0 -79
  200. package/dist/lib/pagination.js +0 -44
  201. package/dist/lib/render.js +0 -53
  202. package/dist/lib/schemas.js +0 -174
  203. package/dist/lib/sqid.js +0 -72
  204. package/dist/lib/sse.js +0 -218
  205. package/dist/lib/storage.js +0 -164
  206. package/dist/lib/theme.js +0 -65
  207. package/dist/lib/time.js +0 -159
  208. package/dist/lib/timeline.js +0 -95
  209. package/dist/lib/timezones.js +0 -388
  210. package/dist/lib/url.js +0 -89
  211. package/dist/lib/view.js +0 -217
  212. package/dist/middleware/auth.js +0 -52
  213. package/dist/middleware/onboarding.js +0 -41
  214. package/dist/routes/api/collections.js +0 -124
  215. package/dist/routes/api/nav-items.js +0 -104
  216. package/dist/routes/api/pages.js +0 -91
  217. package/dist/routes/api/posts.js +0 -218
  218. package/dist/routes/api/search.js +0 -48
  219. package/dist/routes/api/settings.js +0 -68
  220. package/dist/routes/api/upload.js +0 -246
  221. package/dist/routes/auth/reset.js +0 -221
  222. package/dist/routes/auth/setup.js +0 -194
  223. package/dist/routes/auth/signin.js +0 -176
  224. package/dist/routes/compose.js +0 -48
  225. package/dist/routes/dash/collections.js +0 -115
  226. package/dist/routes/dash/index.js +0 -118
  227. package/dist/routes/dash/media.js +0 -106
  228. package/dist/routes/dash/pages.js +0 -294
  229. package/dist/routes/dash/posts.js +0 -244
  230. package/dist/routes/dash/redirects.js +0 -257
  231. package/dist/routes/dash/settings.js +0 -379
  232. package/dist/routes/feed/rss.js +0 -62
  233. package/dist/routes/feed/sitemap.js +0 -49
  234. package/dist/routes/pages/archive.js +0 -62
  235. package/dist/routes/pages/collection.js +0 -34
  236. package/dist/routes/pages/collections.js +0 -28
  237. package/dist/routes/pages/featured.js +0 -36
  238. package/dist/routes/pages/home.js +0 -64
  239. package/dist/routes/pages/latest.js +0 -45
  240. package/dist/routes/pages/page.js +0 -68
  241. package/dist/routes/pages/post.js +0 -44
  242. package/dist/routes/pages/search.js +0 -54
  243. package/dist/services/collection.js +0 -109
  244. package/dist/services/index.js +0 -24
  245. package/dist/services/media.js +0 -117
  246. package/dist/services/navigation.js +0 -91
  247. package/dist/services/page.js +0 -84
  248. package/dist/services/post.js +0 -229
  249. package/dist/services/redirect.js +0 -48
  250. package/dist/services/search.js +0 -67
  251. package/dist/services/settings.js +0 -68
  252. package/dist/types/bindings.js +0 -3
  253. package/dist/types/config.js +0 -147
  254. package/dist/types/constants.js +0 -27
  255. package/dist/types/entities.js +0 -3
  256. package/dist/types/lingui-react-macro.d.js +0 -9
  257. package/dist/types/operations.js +0 -3
  258. package/dist/types/props.js +0 -3
  259. package/dist/types/sortablejs.d.js +0 -5
  260. package/dist/types/views.js +0 -5
  261. package/dist/types.js +0 -11
  262. package/dist/ui/color-themes.js +0 -268
  263. package/dist/ui/compose/ComposeDialog.js +0 -467
  264. package/dist/ui/compose/ComposePrompt.js +0 -55
  265. package/dist/ui/dash/ActionButtons.js +0 -46
  266. package/dist/ui/dash/CrudPageHeader.js +0 -22
  267. package/dist/ui/dash/DangerZone.js +0 -36
  268. package/dist/ui/dash/FormatBadge.js +0 -27
  269. package/dist/ui/dash/ListItemRow.js +0 -21
  270. package/dist/ui/dash/PageForm.js +0 -195
  271. package/dist/ui/dash/PostForm.js +0 -395
  272. package/dist/ui/dash/PostList.js +0 -83
  273. package/dist/ui/dash/StatusBadge.js +0 -46
  274. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  275. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  276. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  277. package/dist/ui/dash/index.js +0 -10
  278. package/dist/ui/dash/media/MediaListContent.js +0 -166
  279. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  280. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  281. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  282. package/dist/ui/dash/settings/AccountContent.js +0 -209
  283. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  284. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  285. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  286. package/dist/ui/feed/LinkCard.js +0 -72
  287. package/dist/ui/feed/NoteCard.js +0 -58
  288. package/dist/ui/feed/QuoteCard.js +0 -63
  289. package/dist/ui/feed/ThreadPreview.js +0 -48
  290. package/dist/ui/feed/TimelineFeed.js +0 -41
  291. package/dist/ui/feed/TimelineItem.js +0 -27
  292. package/dist/ui/font-themes.js +0 -36
  293. package/dist/ui/layouts/BaseLayout.js +0 -153
  294. package/dist/ui/layouts/DashLayout.js +0 -141
  295. package/dist/ui/layouts/SiteLayout.js +0 -169
  296. package/dist/ui/pages/ArchivePage.js +0 -143
  297. package/dist/ui/pages/CollectionPage.js +0 -70
  298. package/dist/ui/pages/CollectionsPage.js +0 -76
  299. package/dist/ui/pages/FeaturedPage.js +0 -24
  300. package/dist/ui/pages/HomePage.js +0 -24
  301. package/dist/ui/pages/PostPage.js +0 -55
  302. package/dist/ui/pages/SearchPage.js +0 -122
  303. package/dist/ui/pages/SinglePage.js +0 -23
  304. package/dist/ui/shared/EmptyState.js +0 -27
  305. package/dist/ui/shared/MediaGallery.js +0 -35
  306. package/dist/ui/shared/Pagination.js +0 -195
  307. package/dist/ui/shared/ThreadView.js +0 -108
  308. package/dist/ui/shared/index.js +0 -5
  309. package/dist/vendor/datastar.js +0 -1606
  310. package/src/lib/__tests__/config.test.ts +0 -192
  311. package/src/lib/config.ts +0 -167
  312. package/src/routes/compose.ts +0 -63
  313. package/src/ui/dash/PostForm.tsx +0 -360
  314. package/src/ui/dash/settings/AppearanceContent.tsx +0 -254
@@ -18,7 +18,7 @@ describe("Compose Routes", () => {
18
18
  expect(res.headers.get("Location")).toBe("/signin");
19
19
  });
20
20
 
21
- it("creates a note post and returns redirect", async () => {
21
+ it("creates a note post and returns timeline card via SSE", async () => {
22
22
  const { app, services } = createTestApp({ authenticated: true });
23
23
  app.route("/compose", composeRoutes);
24
24
 
@@ -29,7 +29,13 @@ describe("Compose Routes", () => {
29
29
  });
30
30
 
31
31
  expect(res.status).toBe(200);
32
- expect(res.headers.get("Content-Type")).toBe("text/html");
32
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
33
+
34
+ const text = await res.text();
35
+ // SSE prepends the card to the timeline
36
+ expect(text).toContain("datastar-patch-elements");
37
+ expect(text).toContain('data-format="note"');
38
+ expect(text).toContain("selector #timeline-items");
33
39
 
34
40
  // Verify post was created
35
41
  const posts = await services.posts.list();
@@ -54,6 +60,10 @@ describe("Compose Routes", () => {
54
60
  });
55
61
 
56
62
  expect(res.status).toBe(200);
63
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
64
+
65
+ const text = await res.text();
66
+ expect(text).toContain('data-format="link"');
57
67
 
58
68
  const posts = await services.posts.list();
59
69
  expect(posts).toHaveLength(1);
@@ -77,6 +87,10 @@ describe("Compose Routes", () => {
77
87
  });
78
88
 
79
89
  expect(res.status).toBe(200);
90
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
91
+
92
+ const text = await res.text();
93
+ expect(text).toContain('data-format="quote"');
80
94
 
81
95
  const posts = await services.posts.list();
82
96
  expect(posts).toHaveLength(1);
@@ -84,7 +98,7 @@ describe("Compose Routes", () => {
84
98
  expect(posts[0].quoteText).toBe("The original quote");
85
99
  });
86
100
 
87
- it("creates a draft when status is draft", async () => {
101
+ it("creates a draft and closes dialog with toast", async () => {
88
102
  const { app, services } = createTestApp({ authenticated: true });
89
103
  app.route("/compose", composeRoutes);
90
104
 
@@ -99,6 +113,13 @@ describe("Compose Routes", () => {
99
113
  });
100
114
 
101
115
  expect(res.status).toBe(200);
116
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
117
+
118
+ const text = await res.text();
119
+ // Should close dialog and show toast, not prepend to timeline
120
+ expect(text).toContain("compose-dialog");
121
+ expect(text).toContain("Draft saved");
122
+ expect(text).not.toContain("selector #timeline-items");
102
123
 
103
124
  const posts = await services.posts.list({ includeDrafts: true });
104
125
  expect(posts).toHaveLength(1);
@@ -158,27 +179,20 @@ describe("Compose Routes", () => {
158
179
  expect(attachments[0].id).toBe(media.id);
159
180
  });
160
181
 
161
- it("sets featured and pinned flags", async () => {
162
- const { app, services } = createTestApp({ authenticated: true });
182
+ it("resets compose signals after publishing", async () => {
183
+ const { app } = createTestApp({ authenticated: true });
163
184
  app.route("/compose", composeRoutes);
164
185
 
165
186
  const res = await app.request("/compose", {
166
187
  method: "POST",
167
188
  headers: { "Content-Type": "application/json" },
168
- body: JSON.stringify({
169
- format: "note",
170
- body: "Featured and pinned",
171
- featured: true,
172
- pinned: true,
173
- }),
189
+ body: JSON.stringify({ format: "note", body: "Hello" }),
174
190
  });
175
191
 
176
- expect(res.status).toBe(200);
177
-
178
- const posts = await services.posts.list();
179
- expect(posts).toHaveLength(1);
180
- expect(posts[0].featured).toBe(1);
181
- expect(posts[0].pinned).toBe(1);
192
+ const text = await res.text();
193
+ // SSE should include signal reset
194
+ expect(text).toContain("datastar-patch-signals");
195
+ expect(text).toContain('"_composeLoading":false');
182
196
  });
183
197
 
184
198
  it("returns error when format is missing", async () => {
@@ -196,4 +210,77 @@ describe("Compose Routes", () => {
196
210
  expect(text).toContain("toast-error");
197
211
  });
198
212
  });
213
+
214
+ describe("POST /compose (JSON mode)", () => {
215
+ it("returns JSON for published note", async () => {
216
+ const { app, services } = createTestApp({ authenticated: true });
217
+ app.route("/compose", composeRoutes);
218
+
219
+ const res = await app.request("/compose", {
220
+ method: "POST",
221
+ headers: {
222
+ "Content-Type": "application/json",
223
+ Accept: "application/json",
224
+ },
225
+ body: JSON.stringify({ format: "note", body: "Hello JSON" }),
226
+ });
227
+
228
+ expect(res.status).toBe(200);
229
+ expect(res.headers.get("Content-Type")).toContain("application/json");
230
+
231
+ const data = await res.json();
232
+ expect(data.status).toBe("published");
233
+ expect(data.cardHtml).toContain('data-format="note"');
234
+
235
+ const posts = await services.posts.list();
236
+ expect(posts).toHaveLength(1);
237
+ expect(posts[0].body).toBe("Hello JSON");
238
+ });
239
+
240
+ it("returns JSON for draft", async () => {
241
+ const { app, services } = createTestApp({ authenticated: true });
242
+ app.route("/compose", composeRoutes);
243
+
244
+ const res = await app.request("/compose", {
245
+ method: "POST",
246
+ headers: {
247
+ "Content-Type": "application/json",
248
+ Accept: "application/json",
249
+ },
250
+ body: JSON.stringify({
251
+ format: "note",
252
+ body: "Draft JSON",
253
+ status: "draft",
254
+ }),
255
+ });
256
+
257
+ expect(res.status).toBe(200);
258
+ const data = await res.json();
259
+ expect(data.status).toBe("draft");
260
+ expect(data.toast).toBe("Draft saved.");
261
+
262
+ const posts = await services.posts.list({ includeDrafts: true });
263
+ expect(posts).toHaveLength(1);
264
+ expect(posts[0].status).toBe("draft");
265
+ });
266
+
267
+ it("returns JSON error for invalid input", async () => {
268
+ const { app } = createTestApp({ authenticated: true });
269
+ app.route("/compose", composeRoutes);
270
+
271
+ const res = await app.request("/compose", {
272
+ method: "POST",
273
+ headers: {
274
+ "Content-Type": "application/json",
275
+ Accept: "application/json",
276
+ },
277
+ body: JSON.stringify({ format: "invalid", body: "Hello" }),
278
+ });
279
+
280
+ expect(res.status).toBe(422);
281
+ const data = await res.json();
282
+ expect(data.status).toBe("error");
283
+ expect(data.error).toBeDefined();
284
+ });
285
+ });
199
286
  });
@@ -23,11 +23,11 @@ describe("Collections API Routes", () => {
23
23
  slug: "tech",
24
24
  title: "Tech",
25
25
  });
26
- await services.posts.create({
26
+ const post = await services.posts.create({
27
27
  format: "note",
28
28
  body: "tech post",
29
- collectionId: col.id,
30
29
  });
30
+ await services.collections.addPost(col.id, post.id);
31
31
 
32
32
  const res = await app.request("/api/collections");
33
33
  const body = await res.json();
@@ -246,4 +246,95 @@ describe("Collections API Routes", () => {
246
246
  expect(res.status).toBe(404);
247
247
  });
248
248
  });
249
+
250
+ describe("POST /api/collections/:id/posts", () => {
251
+ it("adds a post to a collection", async () => {
252
+ const { app, services } = createTestApp({ authenticated: true });
253
+ app.route("/api/collections", collectionsApiRoutes);
254
+
255
+ const col = await services.collections.create({
256
+ slug: "tech",
257
+ title: "Tech",
258
+ });
259
+ const post = await services.posts.create({
260
+ format: "note",
261
+ body: "test",
262
+ });
263
+
264
+ const res = await app.request(`/api/collections/${col.id}/posts`, {
265
+ method: "POST",
266
+ headers: { "Content-Type": "application/json" },
267
+ body: JSON.stringify({ postId: post.id }),
268
+ });
269
+
270
+ expect(res.status).toBe(201);
271
+
272
+ const postIds = await services.collections.getPostIds(col.id);
273
+ expect(postIds).toContain(post.id);
274
+ });
275
+
276
+ it("returns 404 for non-existent collection", async () => {
277
+ const { app, services } = createTestApp({ authenticated: true });
278
+ app.route("/api/collections", collectionsApiRoutes);
279
+
280
+ const post = await services.posts.create({
281
+ format: "note",
282
+ body: "test",
283
+ });
284
+
285
+ const res = await app.request("/api/collections/9999/posts", {
286
+ method: "POST",
287
+ headers: { "Content-Type": "application/json" },
288
+ body: JSON.stringify({ postId: post.id }),
289
+ });
290
+
291
+ expect(res.status).toBe(404);
292
+ });
293
+
294
+ it("returns 401 when not authenticated", async () => {
295
+ const { app, services } = createTestApp({ authenticated: false });
296
+ app.route("/api/collections", collectionsApiRoutes);
297
+
298
+ const col = await services.collections.create({
299
+ slug: "tech",
300
+ title: "Tech",
301
+ });
302
+
303
+ const res = await app.request(`/api/collections/${col.id}/posts`, {
304
+ method: "POST",
305
+ headers: { "Content-Type": "application/json" },
306
+ body: JSON.stringify({ postId: 1 }),
307
+ });
308
+
309
+ expect(res.status).toBe(401);
310
+ });
311
+ });
312
+
313
+ describe("DELETE /api/collections/:id/posts/:postId", () => {
314
+ it("removes a post from a collection", async () => {
315
+ const { app, services } = createTestApp({ authenticated: true });
316
+ app.route("/api/collections", collectionsApiRoutes);
317
+
318
+ const col = await services.collections.create({
319
+ slug: "tech",
320
+ title: "Tech",
321
+ });
322
+ const post = await services.posts.create({
323
+ format: "note",
324
+ body: "test",
325
+ });
326
+
327
+ await services.collections.addPost(col.id, post.id);
328
+
329
+ const res = await app.request(
330
+ `/api/collections/${col.id}/posts/${post.id}`,
331
+ { method: "DELETE" },
332
+ );
333
+
334
+ expect(res.status).toBe(200);
335
+
336
+ const postIds = await services.collections.getPostIds(col.id);
337
+ expect(postIds).not.toContain(post.id);
338
+ });
339
+ });
249
340
  });
@@ -275,7 +275,8 @@ describe("Posts API Routes", () => {
275
275
 
276
276
  expect(res.status).toBe(400);
277
277
  const body = await res.json();
278
- expect(body.error).toBe("Validation failed");
278
+ expect(body.error).toContain("Invalid");
279
+ expect(body.code).toBe("VALIDATION_ERROR");
279
280
  });
280
281
 
281
282
  it("returns 400 for missing required fields", async () => {
@@ -94,7 +94,7 @@ describe("Settings API Routes", () => {
94
94
 
95
95
  expect(res.status).toBe(400);
96
96
  const body = await res.json();
97
- expect(body.rejectedKeys).toContain("AUTH_SECRET");
97
+ expect(body.details.rejectedKeys).toContain("AUTH_SECRET");
98
98
  });
99
99
 
100
100
  it("partially applies when mixing editable and env-only keys", async () => {
@@ -3,40 +3,37 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
+ import { z } from "zod";
6
7
  import type { Bindings, SortOrder } from "../../types.js";
7
- import type { AppVariables } from "../../app.js";
8
+ import type { AppVariables } from "../../types/app-context.js";
8
9
  import { requireAuthApi } from "../../middleware/auth.js";
9
- import { z } from "zod";
10
- import { SORT_ORDERS } from "../../types.js";
10
+ import {
11
+ CreateCollectionSchema,
12
+ SortOrderSchema,
13
+ parseValidated,
14
+ } from "../../lib/schemas.js";
15
+ import { assertFound, parseIntParam, NotFoundError } from "../../lib/errors.js";
11
16
 
12
17
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
18
 
14
19
  export const collectionsApiRoutes = new Hono<Env>();
15
20
 
16
- const SortOrderSchema = z.enum(SORT_ORDERS);
17
-
18
- const CreateCollectionSchema = z.object({
19
- slug: z.string().min(1),
20
- title: z.string().min(1),
21
- description: z.string().optional(),
22
- icon: z.string().optional(),
23
- sortOrder: SortOrderSchema.optional(),
24
- position: z.number().int().min(0).optional(),
25
- showDivider: z.boolean().optional(),
26
- });
27
-
28
- const UpdateCollectionSchema = z.object({
29
- slug: z.string().min(1).optional(),
30
- title: z.string().min(1).optional(),
21
+ // API update schema extends shared schema with nullable fields for explicit clearing
22
+ const UpdateCollectionSchema = CreateCollectionSchema.partial().extend({
31
23
  description: z.string().nullable().optional(),
32
24
  icon: z.string().nullable().optional(),
33
25
  sortOrder: SortOrderSchema.optional(),
34
26
  position: z.number().int().min(0).optional(),
35
- showDivider: z.boolean().optional(),
36
27
  });
37
28
 
38
- const ReorderSchema = z.object({
39
- ids: z.array(z.number().int().positive()),
29
+ // Route-specific schemas (not shared domain schemas)
30
+ const CollectionReorderSchema = z.object({
31
+ ids: z.array(z.number().int().positive()).optional(),
32
+ items: z.array(z.string().regex(/^[cd]-\d+$/)).optional(),
33
+ });
34
+
35
+ const PostAssignSchema = z.object({
36
+ postId: z.number().int().positive(),
40
37
  });
41
38
 
42
39
  // List collections (includes post counts)
@@ -54,45 +51,30 @@ collectionsApiRoutes.get("/", async (c) => {
54
51
 
55
52
  // Get single collection
56
53
  collectionsApiRoutes.get("/:id", async (c) => {
57
- const id = parseInt(c.req.param("id"), 10);
58
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
59
-
60
- const collection = await c.var.services.collections.getById(id);
61
- if (!collection) return c.json({ error: "Not found" }, 404);
62
-
54
+ const id = parseIntParam(c.req.param("id"));
55
+ const collection = assertFound(
56
+ await c.var.services.collections.getById(id),
57
+ "Collection",
58
+ );
63
59
  return c.json(collection);
64
60
  });
65
61
 
66
62
  // Reorder collections (requires auth) — must be before /:id
67
63
  collectionsApiRoutes.put("/reorder", requireAuthApi(), async (c) => {
68
- const rawBody = await c.req.json();
69
-
70
- const parseResult = ReorderSchema.safeParse(rawBody);
71
- if (!parseResult.success) {
72
- return c.json(
73
- { error: "Validation failed", details: parseResult.error.flatten() },
74
- 400,
75
- );
76
- }
64
+ const body = parseValidated(CollectionReorderSchema, await c.req.json());
77
65
 
78
- await c.var.services.collections.reorder(parseResult.data.ids);
66
+ if (body.items) {
67
+ await c.var.services.collections.reorderAll(body.items);
68
+ } else if (body.ids) {
69
+ await c.var.services.collections.reorder(body.ids);
70
+ }
79
71
  const collections = await c.var.services.collections.list();
80
72
  return c.json({ collections });
81
73
  });
82
74
 
83
75
  // Create collection (requires auth)
84
76
  collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
85
- const rawBody = await c.req.json();
86
-
87
- const parseResult = CreateCollectionSchema.safeParse(rawBody);
88
- if (!parseResult.success) {
89
- return c.json(
90
- { error: "Validation failed", details: parseResult.error.flatten() },
91
- 400,
92
- );
93
- }
94
-
95
- const body = parseResult.data;
77
+ const body = parseValidated(CreateCollectionSchema, await c.req.json());
96
78
 
97
79
  const collection = await c.var.services.collections.create({
98
80
  slug: body.slug,
@@ -101,7 +83,6 @@ collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
101
83
  icon: body.icon,
102
84
  sortOrder: body.sortOrder as SortOrder | undefined,
103
85
  position: body.position,
104
- showDivider: body.showDivider,
105
86
  });
106
87
 
107
88
  return c.json(collection, 201);
@@ -109,35 +90,50 @@ collectionsApiRoutes.post("/", requireAuthApi(), async (c) => {
109
90
 
110
91
  // Update collection (requires auth)
111
92
  collectionsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
112
- const id = parseInt(c.req.param("id"), 10);
113
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
93
+ const id = parseIntParam(c.req.param("id"));
94
+ const body = parseValidated(UpdateCollectionSchema, await c.req.json());
114
95
 
115
- const rawBody = await c.req.json();
116
-
117
- const parseResult = UpdateCollectionSchema.safeParse(rawBody);
118
- if (!parseResult.success) {
119
- return c.json(
120
- { error: "Validation failed", details: parseResult.error.flatten() },
121
- 400,
122
- );
123
- }
124
-
125
- const collection = await c.var.services.collections.update(
126
- id,
127
- parseResult.data,
96
+ const collection = assertFound(
97
+ await c.var.services.collections.update(id, body),
98
+ "Collection",
128
99
  );
129
- if (!collection) return c.json({ error: "Not found" }, 404);
130
100
 
131
101
  return c.json(collection);
132
102
  });
133
103
 
134
104
  // Delete collection (requires auth)
135
105
  collectionsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
136
- const id = parseInt(c.req.param("id"), 10);
137
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
106
+ const id = parseIntParam(c.req.param("id"));
138
107
 
139
108
  const success = await c.var.services.collections.delete(id);
140
- if (!success) return c.json({ error: "Not found" }, 404);
109
+ if (!success) throw new NotFoundError("Collection");
141
110
 
142
111
  return c.json({ success: true });
143
112
  });
113
+
114
+ // Add a post to a collection (requires auth)
115
+ collectionsApiRoutes.post("/:id/posts", requireAuthApi(), async (c) => {
116
+ const id = parseIntParam(c.req.param("id"));
117
+ assertFound(await c.var.services.collections.getById(id), "Collection");
118
+
119
+ const body = parseValidated(PostAssignSchema, await c.req.json());
120
+ assertFound(await c.var.services.posts.getById(body.postId), "Post");
121
+
122
+ await c.var.services.collections.addPost(id, body.postId);
123
+
124
+ return c.json({ success: true }, 201);
125
+ });
126
+
127
+ // Remove a post from a collection (requires auth)
128
+ collectionsApiRoutes.delete(
129
+ "/:id/posts/:postId",
130
+ requireAuthApi(),
131
+ async (c) => {
132
+ const id = parseIntParam(c.req.param("id"));
133
+ const postId = parseIntParam(c.req.param("postId"));
134
+
135
+ await c.var.services.collections.removePost(id, postId);
136
+
137
+ return c.json({ success: true });
138
+ },
139
+ );
@@ -3,35 +3,24 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
+ import { z } from "zod";
6
7
  import type { Bindings, NavItemType } from "../../types.js";
7
- import type { AppVariables } from "../../app.js";
8
+ import type { AppVariables } from "../../types/app-context.js";
8
9
  import { requireAuthApi } from "../../middleware/auth.js";
9
- import { z } from "zod";
10
+ import {
11
+ CreateNavItemSchema,
12
+ ReorderSchema,
13
+ parseValidated,
14
+ } from "../../lib/schemas.js";
15
+ import { assertFound, parseIntParam, NotFoundError } from "../../lib/errors.js";
10
16
 
11
17
  type Env = { Bindings: Bindings; Variables: AppVariables };
12
18
 
13
19
  export const navItemsApiRoutes = new Hono<Env>();
14
20
 
15
- const NavItemTypeSchema = z.enum(["link", "page"]);
16
-
17
- const CreateNavItemSchema = z.object({
18
- type: NavItemTypeSchema,
19
- label: z.string().min(1),
20
- url: z.string().min(1),
21
- pageId: z.number().int().positive().optional(),
22
- position: z.number().int().min(0).optional(),
23
- });
24
-
25
- const UpdateNavItemSchema = z.object({
26
- type: NavItemTypeSchema.optional(),
27
- label: z.string().min(1).optional(),
28
- url: z.string().min(1).optional(),
21
+ // API update schema extends shared schema with nullable pageId for explicit clearing
22
+ const UpdateNavItemSchema = CreateNavItemSchema.partial().extend({
29
23
  pageId: z.number().int().positive().nullable().optional(),
30
- position: z.number().int().min(0).optional(),
31
- });
32
-
33
- const ReorderSchema = z.object({
34
- ids: z.array(z.number().int().positive()),
35
24
  });
36
25
 
37
26
  // List nav items
@@ -42,34 +31,16 @@ navItemsApiRoutes.get("/", async (c) => {
42
31
 
43
32
  // Reorder nav items (requires auth) — must be before /:id
44
33
  navItemsApiRoutes.put("/reorder", requireAuthApi(), async (c) => {
45
- const rawBody = await c.req.json();
46
-
47
- const parseResult = ReorderSchema.safeParse(rawBody);
48
- if (!parseResult.success) {
49
- return c.json(
50
- { error: "Validation failed", details: parseResult.error.flatten() },
51
- 400,
52
- );
53
- }
34
+ const body = parseValidated(ReorderSchema, await c.req.json());
54
35
 
55
- await c.var.services.navItems.reorder(parseResult.data.ids);
36
+ await c.var.services.navItems.reorder(body.ids);
56
37
  const items = await c.var.services.navItems.list();
57
38
  return c.json({ navItems: items });
58
39
  });
59
40
 
60
41
  // Create nav item (requires auth)
61
42
  navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
62
- const rawBody = await c.req.json();
63
-
64
- const parseResult = CreateNavItemSchema.safeParse(rawBody);
65
- if (!parseResult.success) {
66
- return c.json(
67
- { error: "Validation failed", details: parseResult.error.flatten() },
68
- 400,
69
- );
70
- }
71
-
72
- const body = parseResult.data;
43
+ const body = parseValidated(CreateNavItemSchema, await c.req.json());
73
44
 
74
45
  const item = await c.var.services.navItems.create({
75
46
  type: body.type as NavItemType,
@@ -84,32 +55,23 @@ navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
84
55
 
85
56
  // Update nav item (requires auth)
86
57
  navItemsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
87
- const id = parseInt(c.req.param("id"), 10);
88
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
89
-
90
- const rawBody = await c.req.json();
91
-
92
- const parseResult = UpdateNavItemSchema.safeParse(rawBody);
93
- if (!parseResult.success) {
94
- return c.json(
95
- { error: "Validation failed", details: parseResult.error.flatten() },
96
- 400,
97
- );
98
- }
58
+ const id = parseIntParam(c.req.param("id"));
59
+ const body = parseValidated(UpdateNavItemSchema, await c.req.json());
99
60
 
100
- const item = await c.var.services.navItems.update(id, parseResult.data);
101
- if (!item) return c.json({ error: "Not found" }, 404);
61
+ const item = assertFound(
62
+ await c.var.services.navItems.update(id, body),
63
+ "Nav item",
64
+ );
102
65
 
103
66
  return c.json(item);
104
67
  });
105
68
 
106
69
  // Delete nav item (requires auth)
107
70
  navItemsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
108
- const id = parseInt(c.req.param("id"), 10);
109
- if (isNaN(id)) return c.json({ error: "Invalid ID" }, 400);
71
+ const id = parseIntParam(c.req.param("id"));
110
72
 
111
73
  const success = await c.var.services.navItems.delete(id);
112
- if (!success) return c.json({ error: "Not found" }, 404);
74
+ if (!success) throw new NotFoundError("Nav item");
113
75
 
114
76
  return c.json({ success: true });
115
77
  });