@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
@@ -1,45 +1,41 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
- PostTypeSchema,
4
- VisibilitySchema,
3
+ FormatSchema,
4
+ StatusSchema,
5
5
  RedirectTypeSchema,
6
6
  CreatePostSchema,
7
7
  UpdatePostSchema,
8
8
  parseFormData,
9
9
  parseFormDataOptional,
10
- validateMediaForPostType,
10
+ validateMediaCount,
11
11
  } from "../schemas.js";
12
12
  import { z } from "zod";
13
- import {
14
- POST_TYPES,
15
- VISIBILITY_LEVELS,
16
- MAX_MEDIA_ATTACHMENTS,
17
- } from "../../types.js";
18
-
19
- describe("PostTypeSchema", () => {
20
- it("accepts all valid post types", () => {
21
- for (const type of POST_TYPES) {
22
- expect(PostTypeSchema.parse(type)).toBe(type);
13
+ import { FORMATS, STATUSES, MAX_MEDIA_ATTACHMENTS } from "../../types.js";
14
+
15
+ describe("FormatSchema", () => {
16
+ it("accepts all valid formats", () => {
17
+ for (const format of FORMATS) {
18
+ expect(FormatSchema.parse(format)).toBe(format);
23
19
  }
24
20
  });
25
21
 
26
- it("rejects invalid post types", () => {
27
- expect(() => PostTypeSchema.parse("invalid")).toThrow();
28
- expect(() => PostTypeSchema.parse("")).toThrow();
29
- expect(() => PostTypeSchema.parse(123)).toThrow();
22
+ it("rejects invalid formats", () => {
23
+ expect(() => FormatSchema.parse("invalid")).toThrow();
24
+ expect(() => FormatSchema.parse("")).toThrow();
25
+ expect(() => FormatSchema.parse(123)).toThrow();
30
26
  });
31
27
  });
32
28
 
33
- describe("VisibilitySchema", () => {
34
- it("accepts all valid visibility levels", () => {
35
- for (const level of VISIBILITY_LEVELS) {
36
- expect(VisibilitySchema.parse(level)).toBe(level);
29
+ describe("StatusSchema", () => {
30
+ it("accepts all valid statuses", () => {
31
+ for (const status of STATUSES) {
32
+ expect(StatusSchema.parse(status)).toBe(status);
37
33
  }
38
34
  });
39
35
 
40
- it("rejects invalid visibility levels", () => {
41
- expect(() => VisibilitySchema.parse("public")).toThrow();
42
- expect(() => VisibilitySchema.parse("private")).toThrow();
36
+ it("rejects invalid statuses", () => {
37
+ expect(() => StatusSchema.parse("public")).toThrow();
38
+ expect(() => StatusSchema.parse("private")).toThrow();
43
39
  });
44
40
  });
45
41
 
@@ -58,22 +54,22 @@ describe("RedirectTypeSchema", () => {
58
54
 
59
55
  describe("CreatePostSchema", () => {
60
56
  const validPost = {
61
- type: "note",
62
- content: "Hello world",
63
- visibility: "quiet",
57
+ format: "note",
58
+ body: "Hello world",
59
+ status: "published",
64
60
  };
65
61
 
66
62
  it("accepts a valid post with required fields", () => {
67
63
  const result = CreatePostSchema.parse(validPost);
68
- expect(result.type).toBe("note");
69
- expect(result.content).toBe("Hello world");
70
- expect(result.visibility).toBe("quiet");
64
+ expect(result.format).toBe("note");
65
+ expect(result.body).toBe("Hello world");
66
+ expect(result.status).toBe("published");
71
67
  });
72
68
 
73
- it("accepts all post types", () => {
74
- for (const type of POST_TYPES) {
69
+ it("accepts all formats", () => {
70
+ for (const format of FORMATS) {
75
71
  expect(() =>
76
- CreatePostSchema.parse({ ...validPost, type }),
72
+ CreatePostSchema.parse({ ...validPost, format }),
77
73
  ).not.toThrow();
78
74
  }
79
75
  });
@@ -89,14 +85,38 @@ describe("CreatePostSchema", () => {
89
85
  it("accepts valid path format", () => {
90
86
  const result = CreatePostSchema.parse({
91
87
  ...validPost,
92
- path: "my-post-slug",
88
+ path: "my-post-path",
89
+ });
90
+ expect(result.path).toBe("my-post-path");
91
+ });
92
+
93
+ it("accepts single-character path", () => {
94
+ const result = CreatePostSchema.parse({
95
+ ...validPost,
96
+ path: "a",
93
97
  });
94
- expect(result.path).toBe("my-post-slug");
98
+ expect(result.path).toBe("a");
95
99
  });
96
100
 
97
- it("accepts empty path", () => {
101
+ it("accepts empty path (transforms to undefined)", () => {
98
102
  const result = CreatePostSchema.parse({ ...validPost, path: "" });
99
- expect(result.path).toBe("");
103
+ expect(result.path).toBeUndefined();
104
+ });
105
+
106
+ it("accepts multi-level path", () => {
107
+ const result = CreatePostSchema.parse({
108
+ ...validPost,
109
+ path: "2024/my-post",
110
+ });
111
+ expect(result.path).toBe("2024/my-post");
112
+ });
113
+
114
+ it("accepts deeply nested path", () => {
115
+ const result = CreatePostSchema.parse({
116
+ ...validPost,
117
+ path: "2024/01/my-post",
118
+ });
119
+ expect(result.path).toBe("2024/01/my-post");
100
120
  });
101
121
 
102
122
  it("rejects invalid path format (uppercase)", () => {
@@ -111,22 +131,52 @@ describe("CreatePostSchema", () => {
111
131
  ).toThrow();
112
132
  });
113
133
 
114
- it("accepts valid source URL", () => {
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();
156
+ });
157
+
158
+ it("rejects path with consecutive slashes", () => {
159
+ expect(() =>
160
+ CreatePostSchema.parse({ ...validPost, path: "2024//my-post" }),
161
+ ).toThrow();
162
+ });
163
+
164
+ it("accepts valid url", () => {
115
165
  const result = CreatePostSchema.parse({
116
166
  ...validPost,
117
- sourceUrl: "https://example.com",
167
+ url: "https://example.com",
118
168
  });
119
- expect(result.sourceUrl).toBe("https://example.com");
169
+ expect(result.url).toBe("https://example.com");
120
170
  });
121
171
 
122
- it("accepts empty source URL", () => {
123
- const result = CreatePostSchema.parse({ ...validPost, sourceUrl: "" });
124
- expect(result.sourceUrl).toBe("");
172
+ it("accepts empty url", () => {
173
+ const result = CreatePostSchema.parse({ ...validPost, url: "" });
174
+ expect(result.url).toBe("");
125
175
  });
126
176
 
127
- it("rejects invalid source URL", () => {
177
+ it("rejects invalid url", () => {
128
178
  expect(() =>
129
- CreatePostSchema.parse({ ...validPost, sourceUrl: "not-a-url" }),
179
+ CreatePostSchema.parse({ ...validPost, url: "not-a-url" }),
130
180
  ).toThrow();
131
181
  });
132
182
 
@@ -181,10 +231,84 @@ describe("CreatePostSchema", () => {
181
231
  ).toThrow();
182
232
  });
183
233
 
184
- it("rejects missing required fields", () => {
234
+ it("accepts featured as boolean", () => {
235
+ const result = CreatePostSchema.parse({ ...validPost, featured: true });
236
+ expect(result.featured).toBe(true);
237
+ });
238
+
239
+ it("accepts featured as 'on' (transforms to true)", () => {
240
+ const result = CreatePostSchema.parse({ ...validPost, featured: "on" });
241
+ expect(result.featured).toBe(true);
242
+ });
243
+
244
+ it("accepts pinned as boolean", () => {
245
+ const result = CreatePostSchema.parse({ ...validPost, pinned: true });
246
+ expect(result.pinned).toBe(true);
247
+ });
248
+
249
+ it("accepts pinned as 'on' (transforms to true)", () => {
250
+ const result = CreatePostSchema.parse({ ...validPost, pinned: "on" });
251
+ expect(result.pinned).toBe(true);
252
+ });
253
+
254
+ it("accepts optional quoteText", () => {
255
+ const result = CreatePostSchema.parse({
256
+ ...validPost,
257
+ quoteText: "A wise person once said...",
258
+ });
259
+ expect(result.quoteText).toBe("A wise person once said...");
260
+ });
261
+
262
+ it("accepts optional rating (1-5)", () => {
263
+ for (const rating of [1, 2, 3, 4, 5]) {
264
+ const result = CreatePostSchema.parse({ ...validPost, rating });
265
+ expect(result.rating).toBe(rating);
266
+ }
267
+ });
268
+
269
+ it("rejects rating outside 0-5 range", () => {
270
+ expect(() =>
271
+ CreatePostSchema.parse({ ...validPost, rating: -1 }),
272
+ ).toThrow();
273
+ expect(() => CreatePostSchema.parse({ ...validPost, rating: 6 })).toThrow();
274
+ });
275
+
276
+ it("accepts rating 0 (transforms to undefined)", () => {
277
+ const result = CreatePostSchema.parse({ ...validPost, rating: 0 });
278
+ expect(result.rating).toBeUndefined();
279
+ });
280
+
281
+ it("accepts empty string rating (transforms to undefined)", () => {
282
+ const result = CreatePostSchema.parse({ ...validPost, rating: "" });
283
+ expect(result.rating).toBeUndefined();
284
+ });
285
+
286
+ it("accepts optional collectionId as positive integer", () => {
287
+ const result = CreatePostSchema.parse({ ...validPost, collectionId: 42 });
288
+ expect(result.collectionId).toBe(42);
289
+ });
290
+
291
+ it("accepts empty string collectionId (transforms to undefined)", () => {
292
+ const result = CreatePostSchema.parse({ ...validPost, collectionId: "" });
293
+ expect(result.collectionId).toBeUndefined();
294
+ });
295
+
296
+ it("accepts optional replyToId", () => {
297
+ const result = CreatePostSchema.parse({
298
+ ...validPost,
299
+ replyToId: "abc123",
300
+ });
301
+ expect(result.replyToId).toBe("abc123");
302
+ });
303
+
304
+ it("only requires format field", () => {
305
+ const result = CreatePostSchema.parse({ format: "note" });
306
+ expect(result.format).toBe("note");
307
+ });
308
+
309
+ it("rejects missing format", () => {
185
310
  expect(() => CreatePostSchema.parse({})).toThrow();
186
- expect(() => CreatePostSchema.parse({ type: "note" })).toThrow();
187
- expect(() => CreatePostSchema.parse({ content: "hello" })).toThrow();
311
+ expect(() => CreatePostSchema.parse({ body: "hello" })).toThrow();
188
312
  });
189
313
  });
190
314
 
@@ -199,13 +323,13 @@ describe("UpdatePostSchema", () => {
199
323
  expect(result.title).toBe("New Title");
200
324
  });
201
325
 
202
- it("accepts only type", () => {
203
- const result = UpdatePostSchema.parse({ type: "article" });
204
- expect(result.type).toBe("article");
326
+ it("accepts only format", () => {
327
+ const result = UpdatePostSchema.parse({ format: "link" });
328
+ expect(result.format).toBe("link");
205
329
  });
206
330
 
207
331
  it("still validates field types", () => {
208
- expect(() => UpdatePostSchema.parse({ type: "invalid" })).toThrow();
332
+ expect(() => UpdatePostSchema.parse({ format: "invalid" })).toThrow();
209
333
  });
210
334
  });
211
335
 
@@ -225,8 +349,8 @@ describe("parseFormData", () => {
225
349
 
226
350
  it("throws for invalid value", () => {
227
351
  const form = new FormData();
228
- form.set("type", "invalid-type");
229
- expect(() => parseFormData(form, "type", PostTypeSchema)).toThrow();
352
+ form.set("format", "invalid-format");
353
+ expect(() => parseFormData(form, "format", FormatSchema)).toThrow();
230
354
  });
231
355
  });
232
356
 
@@ -250,59 +374,37 @@ describe("parseFormDataOptional", () => {
250
374
 
251
375
  it("throws for invalid value", () => {
252
376
  const form = new FormData();
253
- form.set("type", "invalid");
254
- expect(() => parseFormDataOptional(form, "type", PostTypeSchema)).toThrow();
377
+ form.set("format", "invalid");
378
+ expect(() => parseFormDataOptional(form, "format", FormatSchema)).toThrow();
255
379
  });
256
380
  });
257
381
 
258
- describe("validateMediaForPostType", () => {
259
- it("returns null for note with no media", () => {
260
- expect(validateMediaForPostType("note", [])).toBeNull();
261
- });
262
-
263
- it("returns null for note with media", () => {
264
- expect(validateMediaForPostType("note", ["id-1", "id-2"])).toBeNull();
265
- });
266
-
267
- it("returns null for article with media", () => {
268
- expect(validateMediaForPostType("article", ["id-1"])).toBeNull();
269
- });
270
-
271
- it("returns null for image with at least 1 media", () => {
272
- expect(validateMediaForPostType("image", ["id-1"])).toBeNull();
273
- });
274
-
275
- it("returns error for image with no media", () => {
276
- const error = validateMediaForPostType("image", []);
277
- expect(error).toBe("image posts require at least 1 media attachment");
278
- });
279
-
280
- it("returns null for link with 0 or 1 media", () => {
281
- expect(validateMediaForPostType("link", [])).toBeNull();
282
- expect(validateMediaForPostType("link", ["id-1"])).toBeNull();
382
+ describe("validateMediaCount", () => {
383
+ it("returns null for empty media array", () => {
384
+ expect(validateMediaCount([])).toBeNull();
283
385
  });
284
386
 
285
- it("returns error for link with more than 1 media", () => {
286
- const error = validateMediaForPostType("link", ["id-1", "id-2"]);
287
- expect(error).toBe("link posts allow at most 1 media attachment");
387
+ it("returns null for media within limit", () => {
388
+ const media = Array.from({ length: 5 }, (_, i) => `id-${i}`);
389
+ expect(validateMediaCount(media)).toBeNull();
288
390
  });
289
391
 
290
- it("returns error for page with any media", () => {
291
- const error = validateMediaForPostType("page", ["id-1"]);
292
- expect(error).toBe("page posts do not allow media attachments");
293
- });
294
-
295
- it("returns null for page with no media", () => {
296
- expect(validateMediaForPostType("page", [])).toBeNull();
297
- });
298
-
299
- it("returns null for quote with media", () => {
300
- expect(validateMediaForPostType("quote", ["id-1", "id-2"])).toBeNull();
392
+ it("returns null for exactly MAX_MEDIA_ATTACHMENTS", () => {
393
+ const media = Array.from(
394
+ { length: MAX_MEDIA_ATTACHMENTS },
395
+ (_, i) => `id-${i}`,
396
+ );
397
+ expect(validateMediaCount(media)).toBeNull();
301
398
  });
302
399
 
303
- it("returns error when exceeding max for note", () => {
304
- const tooMany = Array.from({ length: 21 }, (_, i) => `id-${i}`);
305
- const error = validateMediaForPostType("note", tooMany);
306
- expect(error).toBe("note posts allow at most 20 media attachments");
400
+ it("returns error when exceeding MAX_MEDIA_ATTACHMENTS", () => {
401
+ const tooMany = Array.from(
402
+ { length: MAX_MEDIA_ATTACHMENTS + 1 },
403
+ (_, i) => `id-${i}`,
404
+ );
405
+ const error = validateMediaCount(tooMany);
406
+ expect(error).toBe(
407
+ `Posts allow at most ${MAX_MEDIA_ATTACHMENTS} media attachments`,
408
+ );
307
409
  });
308
410
  });
@@ -4,6 +4,8 @@ import {
4
4
  isWithinMonth,
5
5
  toISOString,
6
6
  formatDate,
7
+ formatTime,
8
+ formatRelativeTime,
7
9
  formatYearMonth,
8
10
  } from "../time.js";
9
11
 
@@ -91,6 +93,66 @@ describe("formatDate", () => {
91
93
  });
92
94
  });
93
95
 
96
+ describe("formatTime", () => {
97
+ it("formats as HH:MM in 24-hour format", () => {
98
+ // 2024-01-15T12:30:00Z = 1705321800
99
+ expect(formatTime(1705321800)).toBe("12:30");
100
+ });
101
+
102
+ it("zero-pads hours and minutes", () => {
103
+ // 2024-02-01T00:00:00Z = 1706745600
104
+ expect(formatTime(1706745600)).toBe("00:00");
105
+ });
106
+
107
+ it("formats evening time correctly", () => {
108
+ // 2024-02-01T23:05:00Z = 1706828700
109
+ expect(formatTime(1706828700)).toBe("23:05");
110
+ });
111
+
112
+ it("formats single-digit hour with padding", () => {
113
+ // 2024-02-01T09:07:00Z = 1706778420
114
+ expect(formatTime(1706778420)).toBe("09:07");
115
+ });
116
+ });
117
+
118
+ describe("formatRelativeTime", () => {
119
+ afterEach(() => {
120
+ vi.restoreAllMocks();
121
+ });
122
+
123
+ it("returns '1m' for timestamps less than 60 seconds ago", () => {
124
+ expect(formatRelativeTime(now() - 10)).toBe("1m");
125
+ expect(formatRelativeTime(now() - 59)).toBe("1m");
126
+ });
127
+
128
+ it("returns minutes for timestamps under 1 hour", () => {
129
+ expect(formatRelativeTime(now() - 60)).toBe("1m");
130
+ expect(formatRelativeTime(now() - 300)).toBe("5m");
131
+ expect(formatRelativeTime(now() - 3540)).toBe("59m");
132
+ });
133
+
134
+ it("returns hours for timestamps under 24 hours", () => {
135
+ expect(formatRelativeTime(now() - 3600)).toBe("1h");
136
+ expect(formatRelativeTime(now() - 7200)).toBe("2h");
137
+ expect(formatRelativeTime(now() - 82800)).toBe("23h");
138
+ });
139
+
140
+ it("returns days for timestamps up to 7 days", () => {
141
+ expect(formatRelativeTime(now() - 86400)).toBe("1d");
142
+ expect(formatRelativeTime(now() - 3 * 86400)).toBe("3d");
143
+ expect(formatRelativeTime(now() - 7 * 86400)).toBe("7d");
144
+ });
145
+
146
+ it("returns 'MMM D' for timestamps older than 7 days", () => {
147
+ // Use a fixed timestamp to get a predictable date
148
+ // Feb 1, 2024 00:00:00 UTC
149
+ const feb1 = 1706745600;
150
+ // Mock now() to return Feb 16, 2024
151
+ vi.spyOn(Date, "now").mockReturnValue((feb1 + 15 * 86400) * 1000);
152
+ expect(formatRelativeTime(feb1)).toBe("Feb 1");
153
+ });
154
+ });
155
+
94
156
  describe("formatYearMonth", () => {
95
157
  it("formats as YYYY-MM", () => {
96
158
  expect(formatYearMonth(1706745600)).toBe("2024-02");