@jant/core 0.3.23 → 0.3.25

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 (248) hide show
  1. package/dist/app.js +50 -26
  2. package/dist/db/schema.js +72 -47
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +5 -11
  7. package/dist/lib/constants.js +2 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/nav-reorder.js +1 -1
  11. package/dist/lib/navigation.js +30 -6
  12. package/dist/lib/pagination.js +44 -0
  13. package/dist/lib/render.js +7 -11
  14. package/dist/lib/schemas.js +80 -38
  15. package/dist/lib/theme.js +4 -4
  16. package/dist/lib/time.js +56 -1
  17. package/dist/lib/timeline.js +95 -0
  18. package/dist/lib/view.js +61 -72
  19. package/dist/routes/api/collections.js +124 -0
  20. package/dist/routes/api/nav-items.js +104 -0
  21. package/dist/routes/api/pages.js +91 -0
  22. package/dist/routes/api/posts.js +27 -33
  23. package/dist/routes/api/search.js +4 -5
  24. package/dist/routes/api/settings.js +68 -0
  25. package/dist/routes/api/upload.js +13 -13
  26. package/dist/routes/compose.js +48 -0
  27. package/dist/routes/dash/collections.js +24 -42
  28. package/dist/routes/dash/index.js +3 -3
  29. package/dist/routes/dash/media.js +2 -2
  30. package/dist/routes/dash/pages.js +440 -106
  31. package/dist/routes/dash/posts.js +27 -37
  32. package/dist/routes/dash/redirects.js +2 -2
  33. package/dist/routes/dash/settings.js +79 -5
  34. package/dist/routes/feed/rss.js +4 -6
  35. package/dist/routes/feed/sitemap.js +11 -8
  36. package/dist/routes/pages/archive.js +13 -15
  37. package/dist/routes/pages/collection.js +12 -9
  38. package/dist/routes/pages/collections.js +28 -0
  39. package/dist/routes/pages/featured.js +32 -0
  40. package/dist/routes/pages/home.js +19 -68
  41. package/dist/routes/pages/page.js +57 -29
  42. package/dist/routes/pages/post.js +7 -17
  43. package/dist/routes/pages/search.js +5 -9
  44. package/dist/services/collection.js +52 -64
  45. package/dist/services/index.js +5 -3
  46. package/dist/services/navigation.js +29 -53
  47. package/dist/services/page.js +84 -0
  48. package/dist/services/post.js +102 -69
  49. package/dist/services/search.js +24 -18
  50. package/dist/types.js +24 -40
  51. package/dist/ui/compose/ComposeDialog.js +452 -0
  52. package/dist/ui/compose/ComposePrompt.js +55 -0
  53. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +3 -15
  54. package/dist/{theme/components → ui/dash}/PageForm.js +15 -15
  55. package/dist/{theme/components → ui/dash}/PostForm.js +117 -137
  56. package/dist/{theme/components → ui/dash}/PostList.js +18 -13
  57. package/dist/ui/dash/StatusBadge.js +46 -0
  58. package/dist/{theme/components → ui/dash}/index.js +3 -6
  59. package/dist/ui/feed/LinkCard.js +72 -0
  60. package/dist/ui/feed/NoteCard.js +58 -0
  61. package/dist/{themes/minimal/timeline → ui/feed}/QuoteCard.js +29 -14
  62. package/dist/{themes/minimal/timeline → ui/feed}/ThreadPreview.js +20 -18
  63. package/dist/ui/feed/TimelineFeed.js +41 -0
  64. package/dist/ui/feed/TimelineItem.js +27 -0
  65. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  66. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  67. package/dist/ui/layouts/SiteLayout.js +141 -0
  68. package/dist/{themes/minimal → ui}/pages/ArchivePage.js +37 -50
  69. package/dist/ui/pages/CollectionPage.js +70 -0
  70. package/dist/ui/pages/CollectionsPage.js +76 -0
  71. package/dist/ui/pages/FeaturedPage.js +24 -0
  72. package/dist/ui/pages/HomePage.js +24 -0
  73. package/dist/{themes/minimal → ui}/pages/PostPage.js +20 -12
  74. package/dist/{themes/minimal → ui}/pages/SearchPage.js +19 -18
  75. package/dist/{themes/minimal → ui}/pages/SinglePage.js +5 -4
  76. package/dist/ui/shared/MediaGallery.js +35 -0
  77. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  78. package/dist/{theme/components → ui/shared}/ThreadView.js +3 -3
  79. package/dist/ui/shared/index.js +5 -0
  80. package/package.json +2 -9
  81. package/src/__tests__/helpers/app.ts +4 -0
  82. package/src/__tests__/helpers/db.ts +53 -73
  83. package/src/app.tsx +56 -28
  84. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  85. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  86. package/src/db/migrations/meta/_journal.json +14 -0
  87. package/src/db/schema.ts +63 -46
  88. package/src/i18n/locales/en.po +443 -240
  89. package/src/i18n/locales/en.ts +1 -1
  90. package/src/i18n/locales/zh-Hans.po +443 -240
  91. package/src/i18n/locales/zh-Hans.ts +1 -1
  92. package/src/i18n/locales/zh-Hant.po +443 -240
  93. package/src/i18n/locales/zh-Hant.ts +1 -1
  94. package/src/index.ts +29 -42
  95. package/src/lib/__tests__/excerpt.test.ts +125 -0
  96. package/src/lib/__tests__/schemas.test.ts +201 -99
  97. package/src/lib/__tests__/time.test.ts +62 -0
  98. package/src/{routes/api → lib}/__tests__/timeline.test.ts +81 -75
  99. package/src/lib/__tests__/view.test.ts +204 -50
  100. package/src/lib/constants.ts +2 -4
  101. package/src/lib/excerpt.ts +87 -0
  102. package/src/lib/feed.ts +22 -7
  103. package/src/lib/nav-reorder.ts +1 -1
  104. package/src/lib/navigation.ts +45 -8
  105. package/src/lib/pagination.ts +50 -0
  106. package/src/lib/render.tsx +7 -14
  107. package/src/lib/schemas.ts +119 -51
  108. package/src/lib/theme.ts +5 -5
  109. package/src/lib/time.ts +64 -0
  110. package/src/lib/timeline.ts +141 -0
  111. package/src/lib/view.ts +80 -82
  112. package/src/preset.css +46 -0
  113. package/src/routes/__tests__/compose.test.ts +199 -0
  114. package/src/routes/api/__tests__/collections.test.ts +249 -0
  115. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  116. package/src/routes/api/__tests__/pages.test.ts +218 -0
  117. package/src/routes/api/__tests__/posts.test.ts +50 -108
  118. package/src/routes/api/__tests__/search.test.ts +2 -3
  119. package/src/routes/api/__tests__/settings.test.ts +132 -0
  120. package/src/routes/api/collections.ts +143 -0
  121. package/src/routes/api/nav-items.ts +115 -0
  122. package/src/routes/api/pages.ts +101 -0
  123. package/src/routes/api/posts.ts +28 -28
  124. package/src/routes/api/search.ts +3 -3
  125. package/src/routes/api/settings.ts +91 -0
  126. package/src/routes/api/upload.ts +16 -6
  127. package/src/routes/compose.ts +63 -0
  128. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  129. package/src/routes/dash/collections.tsx +20 -42
  130. package/src/routes/dash/index.tsx +3 -3
  131. package/src/routes/dash/media.tsx +2 -2
  132. package/src/routes/dash/pages.tsx +480 -122
  133. package/src/routes/dash/posts.tsx +42 -54
  134. package/src/routes/dash/redirects.tsx +2 -2
  135. package/src/routes/dash/settings.tsx +83 -5
  136. package/src/routes/feed/rss.ts +4 -3
  137. package/src/routes/feed/sitemap.ts +15 -5
  138. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  139. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  140. package/src/routes/pages/archive.tsx +15 -15
  141. package/src/routes/pages/collection.tsx +16 -9
  142. package/src/routes/pages/collections.tsx +36 -0
  143. package/src/routes/pages/featured.tsx +38 -0
  144. package/src/routes/pages/home.tsx +21 -92
  145. package/src/routes/pages/page.tsx +62 -27
  146. package/src/routes/pages/post.tsx +6 -18
  147. package/src/routes/pages/search.tsx +3 -7
  148. package/src/services/__tests__/collection.test.ts +257 -158
  149. package/src/services/__tests__/media.test.ts +18 -18
  150. package/src/services/__tests__/navigation.test.ts +161 -87
  151. package/src/services/__tests__/page.test.ts +106 -0
  152. package/src/services/__tests__/post-timeline.test.ts +92 -88
  153. package/src/services/__tests__/post.test.ts +432 -197
  154. package/src/services/__tests__/search.test.ts +19 -25
  155. package/src/services/collection.ts +71 -113
  156. package/src/services/index.ts +9 -8
  157. package/src/services/navigation.ts +38 -71
  158. package/src/services/page.ts +136 -0
  159. package/src/services/post.ts +141 -101
  160. package/src/services/search.ts +38 -27
  161. package/src/styles/tokens.css +47 -0
  162. package/src/styles/ui.css +491 -0
  163. package/src/types.ts +212 -198
  164. package/src/ui/compose/ComposeDialog.tsx +395 -0
  165. package/src/ui/compose/ComposePrompt.tsx +55 -0
  166. package/src/ui/dash/FormatBadge.tsx +28 -0
  167. package/src/{theme/components → ui/dash}/PageForm.tsx +21 -21
  168. package/src/{theme/components → ui/dash}/PostForm.tsx +110 -131
  169. package/src/ui/dash/PostList.tsx +101 -0
  170. package/src/ui/dash/StatusBadge.tsx +61 -0
  171. package/src/ui/dash/index.ts +10 -0
  172. package/src/ui/feed/LinkCard.tsx +72 -0
  173. package/src/ui/feed/NoteCard.tsx +63 -0
  174. package/src/ui/feed/QuoteCard.tsx +68 -0
  175. package/src/ui/feed/ThreadPreview.tsx +48 -0
  176. package/src/ui/feed/TimelineFeed.tsx +49 -0
  177. package/src/ui/feed/TimelineItem.tsx +45 -0
  178. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  179. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  180. package/src/ui/layouts/SiteLayout.tsx +150 -0
  181. package/src/ui/pages/ArchivePage.tsx +162 -0
  182. package/src/ui/pages/CollectionPage.tsx +70 -0
  183. package/src/ui/pages/CollectionsPage.tsx +73 -0
  184. package/src/ui/pages/FeaturedPage.tsx +31 -0
  185. package/src/ui/pages/HomePage.tsx +37 -0
  186. package/src/ui/pages/PostPage.tsx +56 -0
  187. package/src/{themes/minimal → ui}/pages/SearchPage.tsx +24 -20
  188. package/src/{themes/minimal → ui}/pages/SinglePage.tsx +5 -5
  189. package/src/ui/shared/MediaGallery.tsx +59 -0
  190. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  191. package/src/{theme/components → ui/shared}/ThreadView.tsx +6 -3
  192. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  193. package/src/ui/shared/index.ts +12 -0
  194. package/bin/jant.js +0 -185
  195. package/dist/lib/theme-components.js +0 -49
  196. package/dist/routes/api/timeline.js +0 -120
  197. package/dist/routes/dash/navigation.js +0 -288
  198. package/dist/theme/components/MediaGallery.js +0 -107
  199. package/dist/theme/components/VisibilityBadge.js +0 -37
  200. package/dist/theme/index.js +0 -18
  201. package/dist/theme/layouts/index.js +0 -2
  202. package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
  203. package/dist/themes/minimal/index.js +0 -65
  204. package/dist/themes/minimal/pages/CollectionPage.js +0 -65
  205. package/dist/themes/minimal/pages/HomePage.js +0 -25
  206. package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
  207. package/dist/themes/minimal/timeline/ImageCard.js +0 -67
  208. package/dist/themes/minimal/timeline/LinkCard.js +0 -47
  209. package/dist/themes/minimal/timeline/NoteCard.js +0 -34
  210. package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
  211. package/dist/themes/minimal/timeline/TimelineItem.js +0 -44
  212. package/src/lib/__tests__/theme-components.test.ts +0 -126
  213. package/src/lib/theme-components.ts +0 -68
  214. package/src/routes/api/timeline.tsx +0 -159
  215. package/src/routes/dash/navigation.tsx +0 -316
  216. package/src/theme/components/MediaGallery.tsx +0 -128
  217. package/src/theme/components/PostList.tsx +0 -92
  218. package/src/theme/components/TypeBadge.tsx +0 -37
  219. package/src/theme/components/VisibilityBadge.tsx +0 -45
  220. package/src/theme/components/index.ts +0 -23
  221. package/src/theme/index.ts +0 -22
  222. package/src/theme/layouts/index.ts +0 -7
  223. package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
  224. package/src/themes/minimal/index.ts +0 -83
  225. package/src/themes/minimal/pages/ArchivePage.tsx +0 -157
  226. package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
  227. package/src/themes/minimal/pages/HomePage.tsx +0 -41
  228. package/src/themes/minimal/pages/PostPage.tsx +0 -43
  229. package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
  230. package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
  231. package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
  232. package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
  233. package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
  234. package/src/themes/minimal/timeline/ThreadPreview.tsx +0 -47
  235. package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
  236. package/src/themes/minimal/timeline/TimelineItem.tsx +0 -75
  237. /package/dist/{theme → ui}/color-themes.js +0 -0
  238. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  239. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  240. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  241. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  242. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  243. /package/src/{theme → ui}/color-themes.ts +0 -0
  244. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  245. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  246. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  247. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  248. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -22,16 +22,15 @@ describe("Posts API Routes", () => {
22
22
  app.route("/api/posts", postsApiRoutes);
23
23
 
24
24
  await services.posts.create({
25
- type: "note",
26
- content: "Hello world",
27
- visibility: "featured",
25
+ format: "note",
26
+ body: "Hello world",
28
27
  });
29
28
 
30
29
  const res = await app.request("/api/posts");
31
30
  const body = await res.json();
32
31
 
33
32
  expect(body.posts).toHaveLength(1);
34
- expect(body.posts[0].content).toBe("Hello world");
33
+ expect(body.posts[0].body).toBe("Hello world");
35
34
  expect(body.posts[0].sqid).toBeTruthy();
36
35
  });
37
36
 
@@ -40,9 +39,8 @@ describe("Posts API Routes", () => {
40
39
  app.route("/api/posts", postsApiRoutes);
41
40
 
42
41
  const post = await services.posts.create({
43
- type: "note",
44
- content: "with media",
45
- visibility: "featured",
42
+ format: "note",
43
+ body: "with media",
46
44
  });
47
45
 
48
46
  const media = await services.media.create({
@@ -68,26 +66,25 @@ describe("Posts API Routes", () => {
68
66
  expect(body.posts[0].mediaAttachments[0].position).toBe(0);
69
67
  });
70
68
 
71
- it("filters by visibility", async () => {
69
+ it("filters by status", async () => {
72
70
  const { app, services } = createTestApp();
73
71
  app.route("/api/posts", postsApiRoutes);
74
72
 
75
73
  await services.posts.create({
76
- type: "note",
77
- content: "featured",
78
- visibility: "featured",
74
+ format: "note",
75
+ body: "published post",
79
76
  });
80
77
  await services.posts.create({
81
- type: "note",
82
- content: "draft",
83
- visibility: "draft",
78
+ format: "note",
79
+ body: "draft post",
80
+ status: "draft",
84
81
  });
85
82
 
86
- const res = await app.request("/api/posts?visibility=draft");
83
+ const res = await app.request("/api/posts?status=draft");
87
84
  const body = await res.json();
88
85
 
89
86
  expect(body.posts).toHaveLength(1);
90
- expect(body.posts[0].visibility).toBe("draft");
87
+ expect(body.posts[0].status).toBe("draft");
91
88
  });
92
89
 
93
90
  it("supports limit parameter", async () => {
@@ -96,9 +93,8 @@ describe("Posts API Routes", () => {
96
93
 
97
94
  for (let i = 0; i < 5; i++) {
98
95
  await services.posts.create({
99
- type: "note",
100
- content: `post ${i}`,
101
- visibility: "featured",
96
+ format: "note",
97
+ body: `post ${i}`,
102
98
  });
103
99
  }
104
100
 
@@ -116,9 +112,8 @@ describe("Posts API Routes", () => {
116
112
  app.route("/api/posts", postsApiRoutes);
117
113
 
118
114
  const post = await services.posts.create({
119
- type: "note",
120
- content: "test post",
121
- visibility: "featured",
115
+ format: "note",
116
+ body: "test post",
122
117
  });
123
118
  const id = sqid.encode(post.id);
124
119
 
@@ -126,7 +121,7 @@ describe("Posts API Routes", () => {
126
121
  expect(res.status).toBe(200);
127
122
 
128
123
  const body = await res.json();
129
- expect(body.content).toBe("test post");
124
+ expect(body.body).toBe("test post");
130
125
  expect(body.sqid).toBe(id);
131
126
  });
132
127
 
@@ -135,9 +130,8 @@ describe("Posts API Routes", () => {
135
130
  app.route("/api/posts", postsApiRoutes);
136
131
 
137
132
  const post = await services.posts.create({
138
- type: "note",
139
- content: "with media",
140
- visibility: "featured",
133
+ format: "note",
134
+ body: "with media",
141
135
  });
142
136
 
143
137
  const media = await services.media.create({
@@ -183,9 +177,8 @@ describe("Posts API Routes", () => {
183
177
  method: "POST",
184
178
  headers: { "Content-Type": "application/json" },
185
179
  body: JSON.stringify({
186
- type: "note",
187
- content: "test",
188
- visibility: "quiet",
180
+ format: "note",
181
+ body: "test",
189
182
  }),
190
183
  });
191
184
 
@@ -200,16 +193,15 @@ describe("Posts API Routes", () => {
200
193
  method: "POST",
201
194
  headers: { "Content-Type": "application/json" },
202
195
  body: JSON.stringify({
203
- type: "note",
204
- content: "Hello from API",
205
- visibility: "quiet",
196
+ format: "note",
197
+ body: "Hello from API",
206
198
  }),
207
199
  });
208
200
 
209
201
  expect(res.status).toBe(201);
210
202
 
211
203
  const body = await res.json();
212
- expect(body.content).toBe("Hello from API");
204
+ expect(body.body).toBe("Hello from API");
213
205
  expect(body.sqid).toBeTruthy();
214
206
  expect(body.mediaAttachments).toEqual([]);
215
207
  });
@@ -237,9 +229,8 @@ describe("Posts API Routes", () => {
237
229
  method: "POST",
238
230
  headers: { "Content-Type": "application/json" },
239
231
  body: JSON.stringify({
240
- type: "note",
241
- content: "with images",
242
- visibility: "quiet",
232
+ format: "note",
233
+ body: "with images",
243
234
  mediaIds: [m1.id, m2.id],
244
235
  }),
245
236
  });
@@ -253,54 +244,6 @@ describe("Posts API Routes", () => {
253
244
  expect(body.mediaAttachments[1].position).toBe(1);
254
245
  });
255
246
 
256
- it("returns 400 for image type without media", async () => {
257
- const { app } = createTestApp({ authenticated: true });
258
- app.route("/api/posts", postsApiRoutes);
259
-
260
- const res = await app.request("/api/posts", {
261
- method: "POST",
262
- headers: { "Content-Type": "application/json" },
263
- body: JSON.stringify({
264
- type: "image",
265
- content: "should fail",
266
- visibility: "quiet",
267
- mediaIds: [],
268
- }),
269
- });
270
-
271
- expect(res.status).toBe(400);
272
- const body = await res.json();
273
- expect(body.error).toContain("image posts require at least 1");
274
- });
275
-
276
- it("returns 400 for page type with media", async () => {
277
- const { app, services } = createTestApp({ authenticated: true });
278
- app.route("/api/posts", postsApiRoutes);
279
-
280
- const m1 = await services.media.create({
281
- filename: "a.jpg",
282
- originalName: "a.jpg",
283
- mimeType: "image/jpeg",
284
- size: 1024,
285
- storageKey: "media/2025/01/a.jpg",
286
- });
287
-
288
- const res = await app.request("/api/posts", {
289
- method: "POST",
290
- headers: { "Content-Type": "application/json" },
291
- body: JSON.stringify({
292
- type: "page",
293
- content: "test",
294
- visibility: "quiet",
295
- mediaIds: [m1.id],
296
- }),
297
- });
298
-
299
- expect(res.status).toBe(400);
300
- const body = await res.json();
301
- expect(body.error).toContain("page posts do not allow");
302
- });
303
-
304
247
  it("returns 400 for invalid media IDs", async () => {
305
248
  const { app } = createTestApp({ authenticated: true });
306
249
  app.route("/api/posts", postsApiRoutes);
@@ -309,9 +252,8 @@ describe("Posts API Routes", () => {
309
252
  method: "POST",
310
253
  headers: { "Content-Type": "application/json" },
311
254
  body: JSON.stringify({
312
- type: "note",
313
- content: "test",
314
- visibility: "quiet",
255
+ format: "note",
256
+ body: "test",
315
257
  mediaIds: ["nonexistent-id"],
316
258
  }),
317
259
  });
@@ -328,7 +270,7 @@ describe("Posts API Routes", () => {
328
270
  const res = await app.request("/api/posts", {
329
271
  method: "POST",
330
272
  headers: { "Content-Type": "application/json" },
331
- body: JSON.stringify({ type: "invalid-type" }),
273
+ body: JSON.stringify({ format: "invalid-type" }),
332
274
  });
333
275
 
334
276
  expect(res.status).toBe(400);
@@ -356,14 +298,14 @@ describe("Posts API Routes", () => {
356
298
  app.route("/api/posts", postsApiRoutes);
357
299
 
358
300
  const post = await services.posts.create({
359
- type: "note",
360
- content: "original",
301
+ format: "note",
302
+ body: "original",
361
303
  });
362
304
 
363
305
  const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
364
306
  method: "PUT",
365
307
  headers: { "Content-Type": "application/json" },
366
- body: JSON.stringify({ content: "updated" }),
308
+ body: JSON.stringify({ body: "updated" }),
367
309
  });
368
310
 
369
311
  expect(res.status).toBe(401);
@@ -374,19 +316,19 @@ describe("Posts API Routes", () => {
374
316
  app.route("/api/posts", postsApiRoutes);
375
317
 
376
318
  const post = await services.posts.create({
377
- type: "note",
378
- content: "original",
319
+ format: "note",
320
+ body: "original",
379
321
  });
380
322
 
381
323
  const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
382
324
  method: "PUT",
383
325
  headers: { "Content-Type": "application/json" },
384
- body: JSON.stringify({ content: "updated" }),
326
+ body: JSON.stringify({ body: "updated" }),
385
327
  });
386
328
 
387
329
  expect(res.status).toBe(200);
388
330
  const body = await res.json();
389
- expect(body.content).toBe("updated");
331
+ expect(body.body).toBe("updated");
390
332
  expect(body.mediaAttachments).toEqual([]);
391
333
  });
392
334
 
@@ -395,8 +337,8 @@ describe("Posts API Routes", () => {
395
337
  app.route("/api/posts", postsApiRoutes);
396
338
 
397
339
  const post = await services.posts.create({
398
- type: "note",
399
- content: "test",
340
+ format: "note",
341
+ body: "test",
400
342
  });
401
343
 
402
344
  const m1 = await services.media.create({
@@ -434,8 +376,8 @@ describe("Posts API Routes", () => {
434
376
  app.route("/api/posts", postsApiRoutes);
435
377
 
436
378
  const post = await services.posts.create({
437
- type: "note",
438
- content: "test",
379
+ format: "note",
380
+ body: "test",
439
381
  });
440
382
 
441
383
  const m1 = await services.media.create({
@@ -451,7 +393,7 @@ describe("Posts API Routes", () => {
451
393
  const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
452
394
  method: "PUT",
453
395
  headers: { "Content-Type": "application/json" },
454
- body: JSON.stringify({ content: "updated content" }),
396
+ body: JSON.stringify({ body: "updated content" }),
455
397
  });
456
398
 
457
399
  expect(res.status).toBe(200);
@@ -467,7 +409,7 @@ describe("Posts API Routes", () => {
467
409
  const res = await app.request(`/api/posts/${sqid.encode(9999)}`, {
468
410
  method: "PUT",
469
411
  headers: { "Content-Type": "application/json" },
470
- body: JSON.stringify({ content: "test" }),
412
+ body: JSON.stringify({ body: "test" }),
471
413
  });
472
414
 
473
415
  expect(res.status).toBe(404);
@@ -478,14 +420,14 @@ describe("Posts API Routes", () => {
478
420
  app.route("/api/posts", postsApiRoutes);
479
421
 
480
422
  const post = await services.posts.create({
481
- type: "note",
482
- content: "test",
423
+ format: "note",
424
+ body: "test",
483
425
  });
484
426
 
485
427
  const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
486
428
  method: "PUT",
487
429
  headers: { "Content-Type": "application/json" },
488
- body: JSON.stringify({ type: "invalid-type" }),
430
+ body: JSON.stringify({ format: "invalid-type" }),
489
431
  });
490
432
 
491
433
  expect(res.status).toBe(400);
@@ -498,8 +440,8 @@ describe("Posts API Routes", () => {
498
440
  app.route("/api/posts", postsApiRoutes);
499
441
 
500
442
  const post = await services.posts.create({
501
- type: "note",
502
- content: "test",
443
+ format: "note",
444
+ body: "test",
503
445
  });
504
446
 
505
447
  const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
@@ -514,8 +456,8 @@ describe("Posts API Routes", () => {
514
456
  app.route("/api/posts", postsApiRoutes);
515
457
 
516
458
  const post = await services.posts.create({
517
- type: "note",
518
- content: "to be deleted",
459
+ format: "note",
460
+ body: "to be deleted",
519
461
  });
520
462
 
521
463
  const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
@@ -39,9 +39,8 @@ describe("Search API Routes", () => {
39
39
  app.route("/api/search", searchApiRoutes);
40
40
 
41
41
  await services.posts.create({
42
- type: "note",
43
- content: "Testing search functionality in jant",
44
- visibility: "featured",
42
+ format: "note",
43
+ body: "Testing search functionality in jant",
45
44
  });
46
45
 
47
46
  const res = await app.request("/api/search?q=jant");
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { settingsApiRoutes } from "../settings.js";
4
+
5
+ describe("Settings API Routes", () => {
6
+ describe("GET /api/settings", () => {
7
+ it("returns 401 when not authenticated", async () => {
8
+ const { app } = createTestApp({ authenticated: false });
9
+ app.route("/api/settings", settingsApiRoutes);
10
+
11
+ const res = await app.request("/api/settings");
12
+ expect(res.status).toBe(401);
13
+ });
14
+
15
+ it("returns default settings when none are stored", async () => {
16
+ const { app } = createTestApp({ authenticated: true });
17
+ app.route("/api/settings", settingsApiRoutes);
18
+
19
+ const res = await app.request("/api/settings");
20
+ expect(res.status).toBe(200);
21
+
22
+ const body = await res.json();
23
+ expect(body.settings).toBeDefined();
24
+ expect(body.settings.SITE_NAME).toBe("Jant");
25
+ expect(body.settings.SITE_DESCRIPTION).toBe(
26
+ "A microblog powered by Jant",
27
+ );
28
+ expect(body.settings.SITE_LANGUAGE).toBe("en");
29
+ });
30
+
31
+ it("returns stored settings overriding defaults", async () => {
32
+ const { app, services } = createTestApp({ authenticated: true });
33
+ app.route("/api/settings", settingsApiRoutes);
34
+
35
+ await services.settings.set("SITE_NAME" as never, "My Blog");
36
+
37
+ const res = await app.request("/api/settings");
38
+ const body = await res.json();
39
+
40
+ expect(body.settings.SITE_NAME).toBe("My Blog");
41
+ });
42
+
43
+ it("does not include env-only settings", async () => {
44
+ const { app } = createTestApp({ authenticated: true });
45
+ app.route("/api/settings", settingsApiRoutes);
46
+
47
+ const res = await app.request("/api/settings");
48
+ const body = await res.json();
49
+
50
+ // Env-only keys should not be in the response
51
+ expect(body.settings.AUTH_SECRET).toBeUndefined();
52
+ expect(body.settings.SITE_URL).toBeUndefined();
53
+ });
54
+ });
55
+
56
+ describe("PUT /api/settings", () => {
57
+ it("returns 401 when not authenticated", async () => {
58
+ const { app } = createTestApp({ authenticated: false });
59
+ app.route("/api/settings", settingsApiRoutes);
60
+
61
+ const res = await app.request("/api/settings", {
62
+ method: "PUT",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ SITE_NAME: "New Name" }),
65
+ });
66
+
67
+ expect(res.status).toBe(401);
68
+ });
69
+
70
+ it("updates editable settings", async () => {
71
+ const { app } = createTestApp({ authenticated: true });
72
+ app.route("/api/settings", settingsApiRoutes);
73
+
74
+ const res = await app.request("/api/settings", {
75
+ method: "PUT",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify({ SITE_NAME: "Updated Blog" }),
78
+ });
79
+
80
+ expect(res.status).toBe(200);
81
+ const body = await res.json();
82
+ expect(body.settings.SITE_NAME).toBe("Updated Blog");
83
+ });
84
+
85
+ it("rejects env-only keys", async () => {
86
+ const { app } = createTestApp({ authenticated: true });
87
+ app.route("/api/settings", settingsApiRoutes);
88
+
89
+ const res = await app.request("/api/settings", {
90
+ method: "PUT",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ AUTH_SECRET: "should-not-work" }),
93
+ });
94
+
95
+ expect(res.status).toBe(400);
96
+ const body = await res.json();
97
+ expect(body.rejectedKeys).toContain("AUTH_SECRET");
98
+ });
99
+
100
+ it("partially applies when mixing editable and env-only keys", async () => {
101
+ const { app } = createTestApp({ authenticated: true });
102
+ app.route("/api/settings", settingsApiRoutes);
103
+
104
+ const res = await app.request("/api/settings", {
105
+ method: "PUT",
106
+ headers: { "Content-Type": "application/json" },
107
+ body: JSON.stringify({
108
+ SITE_NAME: "Mixed Update",
109
+ AUTH_SECRET: "ignored",
110
+ }),
111
+ });
112
+
113
+ expect(res.status).toBe(200);
114
+ const body = await res.json();
115
+ expect(body.settings.SITE_NAME).toBe("Mixed Update");
116
+ expect(body.rejectedKeys).toContain("AUTH_SECRET");
117
+ });
118
+
119
+ it("returns 400 for invalid body", async () => {
120
+ const { app } = createTestApp({ authenticated: true });
121
+ app.route("/api/settings", settingsApiRoutes);
122
+
123
+ const res = await app.request("/api/settings", {
124
+ method: "PUT",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify("not an object"),
127
+ });
128
+
129
+ expect(res.status).toBe(400);
130
+ });
131
+ });
132
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Collections API Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { Bindings, SortOrder } from "../../types.js";
7
+ import type { AppVariables } from "../../app.js";
8
+ import { requireAuthApi } from "../../middleware/auth.js";
9
+ import { z } from "zod";
10
+ import { SORT_ORDERS } from "../../types.js";
11
+
12
+ type Env = { Bindings: Bindings; Variables: AppVariables };
13
+
14
+ export const collectionsApiRoutes = new Hono<Env>();
15
+
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(),
31
+ description: z.string().nullable().optional(),
32
+ icon: z.string().nullable().optional(),
33
+ sortOrder: SortOrderSchema.optional(),
34
+ position: z.number().int().min(0).optional(),
35
+ showDivider: z.boolean().optional(),
36
+ });
37
+
38
+ const ReorderSchema = z.object({
39
+ ids: z.array(z.number().int().positive()),
40
+ });
41
+
42
+ // List collections (includes post counts)
43
+ collectionsApiRoutes.get("/", async (c) => {
44
+ const collections = await c.var.services.collections.list();
45
+ const postCounts = await c.var.services.collections.getPostCounts();
46
+
47
+ return c.json({
48
+ collections: collections.map((col) => ({
49
+ ...col,
50
+ postCount: postCounts.get(col.id) ?? 0,
51
+ })),
52
+ });
53
+ });
54
+
55
+ // Get single collection
56
+ 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
+
63
+ return c.json(collection);
64
+ });
65
+
66
+ // Reorder collections (requires auth) — must be before /:id
67
+ 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
+ }
77
+
78
+ await c.var.services.collections.reorder(parseResult.data.ids);
79
+ const collections = await c.var.services.collections.list();
80
+ return c.json({ collections });
81
+ });
82
+
83
+ // Create collection (requires auth)
84
+ 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;
96
+
97
+ const collection = await c.var.services.collections.create({
98
+ slug: body.slug,
99
+ title: body.title,
100
+ description: body.description,
101
+ icon: body.icon,
102
+ sortOrder: body.sortOrder as SortOrder | undefined,
103
+ position: body.position,
104
+ showDivider: body.showDivider,
105
+ });
106
+
107
+ return c.json(collection, 201);
108
+ });
109
+
110
+ // Update collection (requires auth)
111
+ 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);
114
+
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,
128
+ );
129
+ if (!collection) return c.json({ error: "Not found" }, 404);
130
+
131
+ return c.json(collection);
132
+ });
133
+
134
+ // Delete collection (requires auth)
135
+ 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);
138
+
139
+ const success = await c.var.services.collections.delete(id);
140
+ if (!success) return c.json({ error: "Not found" }, 404);
141
+
142
+ return c.json({ success: true });
143
+ });