@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
@@ -16,62 +16,59 @@ describe("PostService", () => {
16
16
  describe("create", () => {
17
17
  it("creates a note post with required fields", async () => {
18
18
  const post = await postService.create({
19
- type: "note",
20
- content: "Hello world",
19
+ format: "note",
20
+ body: "Hello world",
21
21
  });
22
22
 
23
23
  expect(post.id).toBe(1);
24
- expect(post.type).toBe("note");
25
- expect(post.content).toBe("Hello world");
26
- expect(post.visibility).toBe("quiet"); // default
27
- expect(post.contentHtml).toContain("<p>Hello world</p>");
24
+ expect(post.format).toBe("note");
25
+ expect(post.body).toBe("Hello world");
26
+ expect(post.status).toBe("published"); // default
27
+ expect(post.featured).toBe(0);
28
+ expect(post.pinned).toBe(0);
29
+ expect(post.bodyHtml).toContain("<p>Hello world</p>");
28
30
  expect(post.deletedAt).toBeNull();
29
31
  });
30
32
 
31
33
  it("creates a post with all fields", async () => {
32
34
  const post = await postService.create({
33
- type: "article",
34
- title: "My Article",
35
- content: "# Introduction\n\nSome content.",
36
- visibility: "featured",
37
- path: "my-article",
38
- sourceUrl: "https://example.com/source",
39
- sourceName: "Example",
40
- });
41
-
42
- expect(post.type).toBe("article");
43
- expect(post.title).toBe("My Article");
44
- expect(post.visibility).toBe("featured");
45
- expect(post.path).toBe("my-article");
46
- expect(post.sourceUrl).toBe("https://example.com/source");
47
- expect(post.sourceName).toBe("Example");
48
- expect(post.sourceDomain).toBe("example.com");
49
- expect(post.contentHtml).toContain("<h1>");
50
- });
51
-
52
- it("renders markdown content to HTML", async () => {
35
+ format: "link",
36
+ title: "My Link",
37
+ body: "# Introduction\n\nSome content.",
38
+ status: "published",
39
+ featured: true,
40
+ pinned: true,
41
+ path: "my-link",
42
+ url: "https://example.com/source",
43
+ quoteText: "A notable quote",
44
+ rating: 5,
45
+ });
46
+
47
+ expect(post.format).toBe("link");
48
+ expect(post.title).toBe("My Link");
49
+ expect(post.status).toBe("published");
50
+ expect(post.featured).toBe(1);
51
+ expect(post.pinned).toBe(1);
52
+ expect(post.path).toBe("my-link");
53
+ expect(post.url).toBe("https://example.com/source");
54
+ expect(post.quoteText).toBe("A notable quote");
55
+ expect(post.rating).toBe(5);
56
+ expect(post.bodyHtml).toContain("<h1>");
57
+ });
58
+
59
+ it("renders markdown body to HTML", async () => {
53
60
  const post = await postService.create({
54
- type: "note",
55
- content: "This is **bold** text",
61
+ format: "note",
62
+ body: "This is **bold** text",
56
63
  });
57
64
 
58
- expect(post.contentHtml).toContain("<strong>bold</strong>");
59
- });
60
-
61
- it("extracts domain from source URL", async () => {
62
- const post = await postService.create({
63
- type: "link",
64
- content: "Check this out",
65
- sourceUrl: "https://blog.example.org/article",
66
- });
67
-
68
- expect(post.sourceDomain).toBe("blog.example.org");
65
+ expect(post.bodyHtml).toContain("<strong>bold</strong>");
69
66
  });
70
67
 
71
68
  it("sets publishedAt and timestamps", async () => {
72
69
  const post = await postService.create({
73
- type: "note",
74
- content: "test",
70
+ format: "note",
71
+ body: "test",
75
72
  });
76
73
 
77
74
  expect(post.publishedAt).toBeGreaterThan(0);
@@ -82,8 +79,8 @@ describe("PostService", () => {
82
79
  it("allows custom publishedAt", async () => {
83
80
  const customTime = 1706745600;
84
81
  const post = await postService.create({
85
- type: "note",
86
- content: "test",
82
+ format: "note",
83
+ body: "test",
87
84
  publishedAt: customTime,
88
85
  });
89
86
 
@@ -92,29 +89,52 @@ describe("PostService", () => {
92
89
 
93
90
  it("creates incrementing IDs", async () => {
94
91
  const post1 = await postService.create({
95
- type: "note",
96
- content: "first",
92
+ format: "note",
93
+ body: "first",
97
94
  });
98
95
  const post2 = await postService.create({
99
- type: "note",
100
- content: "second",
96
+ format: "note",
97
+ body: "second",
101
98
  });
102
99
 
103
100
  expect(post2.id).toBeGreaterThan(post1.id);
104
101
  });
102
+
103
+ it("creates a quote post", async () => {
104
+ const post = await postService.create({
105
+ format: "quote",
106
+ quoteText: "To be or not to be",
107
+ body: "Shakespeare's famous line",
108
+ url: "https://example.com/hamlet",
109
+ });
110
+
111
+ expect(post.format).toBe("quote");
112
+ expect(post.quoteText).toBe("To be or not to be");
113
+ expect(post.url).toBe("https://example.com/hamlet");
114
+ });
115
+
116
+ it("creates a draft post", async () => {
117
+ const post = await postService.create({
118
+ format: "note",
119
+ body: "draft content",
120
+ status: "draft",
121
+ });
122
+
123
+ expect(post.status).toBe("draft");
124
+ });
105
125
  });
106
126
 
107
127
  describe("getById", () => {
108
128
  it("returns a post by ID", async () => {
109
129
  const created = await postService.create({
110
- type: "note",
111
- content: "test",
130
+ format: "note",
131
+ body: "test",
112
132
  });
113
133
 
114
134
  const found = await postService.getById(created.id);
115
135
  expect(found).not.toBeNull();
116
136
  expect(found?.id).toBe(created.id);
117
- expect(found?.content).toBe("test");
137
+ expect(found?.body).toBe("test");
118
138
  });
119
139
 
120
140
  it("returns null for non-existent ID", async () => {
@@ -124,8 +144,8 @@ describe("PostService", () => {
124
144
 
125
145
  it("excludes soft-deleted posts", async () => {
126
146
  const post = await postService.create({
127
- type: "note",
128
- content: "test",
147
+ format: "note",
148
+ body: "test",
129
149
  });
130
150
  await postService.delete(post.id);
131
151
 
@@ -137,8 +157,8 @@ describe("PostService", () => {
137
157
  describe("getByPath", () => {
138
158
  it("returns a post by path", async () => {
139
159
  await postService.create({
140
- type: "page",
141
- content: "About page",
160
+ format: "note",
161
+ body: "About page",
142
162
  path: "about",
143
163
  });
144
164
 
@@ -154,8 +174,8 @@ describe("PostService", () => {
154
174
 
155
175
  it("excludes soft-deleted posts", async () => {
156
176
  const post = await postService.create({
157
- type: "page",
158
- content: "test",
177
+ format: "note",
178
+ body: "test",
159
179
  path: "test-page",
160
180
  });
161
181
  await postService.delete(post.id);
@@ -163,6 +183,18 @@ describe("PostService", () => {
163
183
  const found = await postService.getByPath("test-page");
164
184
  expect(found).toBeNull();
165
185
  });
186
+
187
+ it("finds a post with a multi-level path", async () => {
188
+ await postService.create({
189
+ format: "note",
190
+ body: "Blog migration",
191
+ path: "2024/01/my-post",
192
+ });
193
+
194
+ const found = await postService.getByPath("2024/01/my-post");
195
+ expect(found).not.toBeNull();
196
+ expect(found?.path).toBe("2024/01/my-post");
197
+ });
166
198
  });
167
199
 
168
200
  describe("list", () => {
@@ -172,9 +204,9 @@ describe("PostService", () => {
172
204
  });
173
205
 
174
206
  it("returns all non-deleted posts", async () => {
175
- await postService.create({ type: "note", content: "first" });
176
- await postService.create({ type: "note", content: "second" });
177
- await postService.create({ type: "note", content: "third" });
207
+ await postService.create({ format: "note", body: "first" });
208
+ await postService.create({ format: "note", body: "second" });
209
+ await postService.create({ format: "note", body: "third" });
178
210
 
179
211
  const posts = await postService.list();
180
212
  expect(posts).toHaveLength(3);
@@ -182,91 +214,113 @@ describe("PostService", () => {
182
214
 
183
215
  it("orders by publishedAt descending", async () => {
184
216
  await postService.create({
185
- type: "note",
186
- content: "old",
217
+ format: "note",
218
+ body: "old",
187
219
  publishedAt: 1000,
188
220
  });
189
221
  await postService.create({
190
- type: "note",
191
- content: "new",
222
+ format: "note",
223
+ body: "new",
192
224
  publishedAt: 2000,
193
225
  });
194
226
 
195
227
  const posts = await postService.list();
196
- expect(posts[0]?.content).toBe("new");
197
- expect(posts[1]?.content).toBe("old");
228
+ expect(posts[0]?.body).toBe("new");
229
+ expect(posts[1]?.body).toBe("old");
198
230
  });
199
231
 
200
- it("filters by type", async () => {
201
- await postService.create({ type: "note", content: "a note" });
232
+ it("filters by format", async () => {
233
+ await postService.create({ format: "note", body: "a note" });
202
234
  await postService.create({
203
- type: "article",
204
- content: "an article",
205
- title: "Article",
235
+ format: "link",
236
+ body: "a link",
237
+ title: "Link",
238
+ url: "https://example.com",
206
239
  });
207
240
 
208
- const notes = await postService.list({ type: "note" });
241
+ const notes = await postService.list({ format: "note" });
209
242
  expect(notes).toHaveLength(1);
210
- expect(notes[0]?.type).toBe("note");
243
+ expect(notes[0]?.format).toBe("note");
211
244
  });
212
245
 
213
- it("filters by single visibility", async () => {
246
+ it("filters by status", async () => {
214
247
  await postService.create({
215
- type: "note",
216
- content: "featured",
217
- visibility: "featured",
248
+ format: "note",
249
+ body: "published post",
250
+ status: "published",
218
251
  });
219
252
  await postService.create({
220
- type: "note",
221
- content: "draft",
222
- visibility: "draft",
253
+ format: "note",
254
+ body: "draft post",
255
+ status: "draft",
223
256
  });
224
257
 
225
- const featured = await postService.list({ visibility: "featured" });
226
- expect(featured).toHaveLength(1);
227
- expect(featured[0]?.visibility).toBe("featured");
258
+ const published = await postService.list({ status: "published" });
259
+ expect(published).toHaveLength(1);
260
+ expect(published[0]?.status).toBe("published");
228
261
  });
229
262
 
230
- it("filters by multiple visibility levels", async () => {
263
+ it("filters by featured", async () => {
264
+ await postService.create({
265
+ format: "note",
266
+ body: "featured post",
267
+ featured: true,
268
+ });
231
269
  await postService.create({
232
- type: "note",
233
- content: "featured",
234
- visibility: "featured",
270
+ format: "note",
271
+ body: "normal post",
235
272
  });
273
+
274
+ const featured = await postService.list({ featured: true });
275
+ expect(featured).toHaveLength(1);
276
+ expect(featured[0]?.featured).toBe(1);
277
+ expect(featured[0]?.body).toBe("featured post");
278
+
279
+ const notFeatured = await postService.list({ featured: false });
280
+ expect(notFeatured).toHaveLength(1);
281
+ expect(notFeatured[0]?.featured).toBe(0);
282
+ expect(notFeatured[0]?.body).toBe("normal post");
283
+ });
284
+
285
+ it("filters by pinned", async () => {
236
286
  await postService.create({
237
- type: "note",
238
- content: "quiet",
239
- visibility: "quiet",
287
+ format: "note",
288
+ body: "pinned post",
289
+ pinned: true,
240
290
  });
241
291
  await postService.create({
242
- type: "note",
243
- content: "draft",
244
- visibility: "draft",
292
+ format: "note",
293
+ body: "normal post",
245
294
  });
246
295
 
247
- const publicPosts = await postService.list({
248
- visibility: ["featured", "quiet"],
249
- });
250
- expect(publicPosts).toHaveLength(2);
296
+ const pinned = await postService.list({ pinned: true });
297
+ expect(pinned).toHaveLength(1);
298
+ expect(pinned[0]?.pinned).toBe(1);
299
+ expect(pinned[0]?.body).toBe("pinned post");
300
+
301
+ const notPinned = await postService.list({ pinned: false });
302
+ expect(notPinned).toHaveLength(1);
303
+ expect(notPinned[0]?.pinned).toBe(0);
304
+ expect(notPinned[0]?.body).toBe("normal post");
251
305
  });
252
306
 
253
307
  it("excludes deleted posts by default", async () => {
254
308
  const post = await postService.create({
255
- type: "note",
256
- content: "test",
309
+ format: "note",
310
+ body: "test",
257
311
  });
258
- await postService.create({ type: "note", content: "kept" });
312
+ await postService.create({ format: "note", body: "kept" });
259
313
  await postService.delete(post.id);
260
314
 
261
315
  const posts = await postService.list();
262
316
  expect(posts).toHaveLength(1);
263
- expect(posts[0]?.content).toBe("kept");
317
+ expect(posts[0]?.body).toBe("kept");
264
318
  });
265
319
 
266
320
  it("includes deleted posts when requested", async () => {
267
321
  const post = await postService.create({
268
- type: "note",
269
- content: "test",
322
+ format: "note",
323
+ body: "test",
270
324
  });
271
325
  await postService.delete(post.id);
272
326
 
@@ -276,7 +330,7 @@ describe("PostService", () => {
276
330
 
277
331
  it("supports limit", async () => {
278
332
  for (let i = 0; i < 5; i++) {
279
- await postService.create({ type: "note", content: `post ${i}` });
333
+ await postService.create({ format: "note", body: `post ${i}` });
280
334
  }
281
335
 
282
336
  const posts = await postService.list({ limit: 2 });
@@ -288,8 +342,8 @@ describe("PostService", () => {
288
342
  for (let i = 0; i < 5; i++) {
289
343
  created.push(
290
344
  await postService.create({
291
- type: "note",
292
- content: `post ${i}`,
345
+ format: "note",
346
+ body: `post ${i}`,
293
347
  publishedAt: 1000 + i,
294
348
  }),
295
349
  );
@@ -303,42 +357,130 @@ describe("PostService", () => {
303
357
 
304
358
  it("excludes replies when requested", async () => {
305
359
  const root = await postService.create({
306
- type: "note",
307
- content: "root post",
360
+ format: "note",
361
+ body: "root post",
308
362
  });
309
363
  await postService.create({
310
- type: "note",
311
- content: "reply",
364
+ format: "note",
365
+ body: "reply",
312
366
  replyToId: root.id,
313
367
  });
314
368
 
315
369
  const posts = await postService.list({ excludeReplies: true });
316
370
  expect(posts).toHaveLength(1);
317
- expect(posts[0]?.content).toBe("root post");
371
+ expect(posts[0]?.body).toBe("root post");
372
+ });
373
+
374
+ it("supports offset pagination", async () => {
375
+ for (let i = 0; i < 5; i++) {
376
+ await postService.create({
377
+ format: "note",
378
+ body: `post ${i}`,
379
+ publishedAt: 1000 + i,
380
+ });
381
+ }
382
+
383
+ // Skip the first 2 posts (newest), get 2 more
384
+ const posts = await postService.list({ limit: 2, offset: 2 });
385
+ expect(posts).toHaveLength(2);
386
+ expect(posts[0]?.body).toBe("post 2");
387
+ expect(posts[1]?.body).toBe("post 1");
388
+ });
389
+ });
390
+
391
+ describe("count", () => {
392
+ it("returns 0 when no posts exist", async () => {
393
+ const count = await postService.count();
394
+ expect(count).toBe(0);
395
+ });
396
+
397
+ it("counts all non-deleted posts", async () => {
398
+ await postService.create({ format: "note", body: "first" });
399
+ await postService.create({ format: "note", body: "second" });
400
+ await postService.create({ format: "note", body: "third" });
401
+
402
+ const count = await postService.count();
403
+ expect(count).toBe(3);
404
+ });
405
+
406
+ it("filters by status", async () => {
407
+ await postService.create({
408
+ format: "note",
409
+ body: "published",
410
+ status: "published",
411
+ });
412
+ await postService.create({
413
+ format: "note",
414
+ body: "draft",
415
+ status: "draft",
416
+ });
417
+
418
+ const count = await postService.count({ status: "published" });
419
+ expect(count).toBe(1);
420
+ });
421
+
422
+ it("filters by featured", async () => {
423
+ await postService.create({
424
+ format: "note",
425
+ body: "featured",
426
+ featured: true,
427
+ });
428
+ await postService.create({ format: "note", body: "normal" });
429
+
430
+ const count = await postService.count({ featured: true });
431
+ expect(count).toBe(1);
432
+ });
433
+
434
+ it("excludes deleted posts by default", async () => {
435
+ const post = await postService.create({
436
+ format: "note",
437
+ body: "to delete",
438
+ });
439
+ await postService.create({ format: "note", body: "keep" });
440
+ await postService.delete(post.id);
441
+
442
+ const count = await postService.count();
443
+ expect(count).toBe(1);
444
+ });
445
+
446
+ it("excludes replies when requested", async () => {
447
+ const root = await postService.create({
448
+ format: "note",
449
+ body: "root",
450
+ });
451
+ await postService.create({
452
+ format: "note",
453
+ body: "reply",
454
+ replyToId: root.id,
455
+ });
456
+
457
+ const count = await postService.count({ excludeReplies: true });
458
+ expect(count).toBe(1);
318
459
  });
319
460
  });
320
461
 
321
462
  describe("update", () => {
322
- it("updates post content", async () => {
463
+ it("updates post body", async () => {
323
464
  const post = await postService.create({
324
- type: "note",
325
- content: "original",
465
+ format: "note",
466
+ body: "original",
326
467
  });
327
468
 
328
469
  const updated = await postService.update(post.id, {
329
- content: "updated content",
470
+ body: "updated content",
330
471
  });
331
472
 
332
473
  expect(updated).not.toBeNull();
333
- expect(updated?.content).toBe("updated content");
334
- expect(updated?.contentHtml).toContain("updated content");
474
+ expect(updated?.body).toBe("updated content");
475
+ expect(updated?.bodyHtml).toContain("updated content");
335
476
  });
336
477
 
337
478
  it("updates post title", async () => {
338
479
  const post = await postService.create({
339
- type: "article",
340
- content: "body",
480
+ format: "link",
481
+ body: "body",
341
482
  title: "Original Title",
483
+ url: "https://example.com",
342
484
  });
343
485
 
344
486
  const updated = await postService.update(post.id, {
@@ -348,44 +490,43 @@ describe("PostService", () => {
348
490
  expect(updated?.title).toBe("New Title");
349
491
  });
350
492
 
351
- it("updates source URL and extracts domain", async () => {
493
+ it("updates post url", async () => {
352
494
  const post = await postService.create({
353
- type: "link",
354
- content: "link post",
495
+ format: "link",
496
+ body: "link post",
497
+ url: "https://old.com",
355
498
  });
356
499
 
357
500
  const updated = await postService.update(post.id, {
358
- sourceUrl: "https://new-source.com/path",
501
+ url: "https://new-source.com/path",
359
502
  });
360
503
 
361
- expect(updated?.sourceUrl).toBe("https://new-source.com/path");
362
- expect(updated?.sourceDomain).toBe("new-source.com");
504
+ expect(updated?.url).toBe("https://new-source.com/path");
363
505
  });
364
506
 
365
- it("clears source domain when URL cleared", async () => {
507
+ it("clears url when set to null", async () => {
366
508
  const post = await postService.create({
367
- type: "link",
368
- content: "test",
369
- sourceUrl: "https://example.com",
509
+ format: "link",
510
+ body: "test",
511
+ url: "https://example.com",
370
512
  });
371
513
 
372
514
  const updated = await postService.update(post.id, {
373
- sourceUrl: null,
515
+ url: null,
374
516
  });
375
517
 
376
- expect(updated?.sourceUrl).toBeNull();
377
- expect(updated?.sourceDomain).toBeNull();
518
+ expect(updated?.url).toBeNull();
378
519
  });
379
520
 
380
521
  it("returns null for non-existent post", async () => {
381
- const result = await postService.update(9999, { content: "test" });
522
+ const result = await postService.update(9999, { body: "test" });
382
523
  expect(result).toBeNull();
383
524
  });
384
525
 
385
526
  it("updates updatedAt timestamp", async () => {
386
527
  const post = await postService.create({
387
- type: "note",
388
- content: "test",
528
+ format: "note",
529
+ body: "test",
389
530
  });
390
531
  const originalUpdatedAt = post.updatedAt;
391
532
 
@@ -393,18 +534,78 @@ describe("PostService", () => {
393
534
  await new Promise((r) => setTimeout(r, 1100));
394
535
 
395
536
  const updated = await postService.update(post.id, {
396
- content: "modified",
537
+ body: "modified",
397
538
  });
398
539
 
399
540
  expect(updated?.updatedAt).toBeGreaterThanOrEqual(originalUpdatedAt);
400
541
  });
542
+
543
+ it("updates featured flag", async () => {
544
+ const post = await postService.create({
545
+ format: "note",
546
+ body: "test",
547
+ });
548
+
549
+ expect(post.featured).toBe(0);
550
+
551
+ const updated = await postService.update(post.id, {
552
+ featured: true,
553
+ });
554
+
555
+ expect(updated?.featured).toBe(1);
556
+ });
557
+
558
+ it("updates pinned flag", async () => {
559
+ const post = await postService.create({
560
+ format: "note",
561
+ body: "test",
562
+ });
563
+
564
+ expect(post.pinned).toBe(0);
565
+
566
+ const updated = await postService.update(post.id, {
567
+ pinned: true,
568
+ });
569
+
570
+ expect(updated?.pinned).toBe(1);
571
+ });
572
+
573
+ it("updates path", async () => {
574
+ const post = await postService.create({
575
+ format: "note",
576
+ body: "test",
577
+ path: "old-path",
578
+ });
579
+
580
+ const updated = await postService.update(post.id, {
581
+ path: "new-path",
582
+ });
583
+
584
+ expect(updated?.path).toBe("new-path");
585
+ });
586
+
587
+ it("updates quoteText and rating", async () => {
588
+ const post = await postService.create({
589
+ format: "quote",
590
+ quoteText: "Original quote",
591
+ rating: 3,
592
+ });
593
+
594
+ const updated = await postService.update(post.id, {
595
+ quoteText: "Updated quote",
596
+ rating: 5,
597
+ });
598
+
599
+ expect(updated?.quoteText).toBe("Updated quote");
600
+ expect(updated?.rating).toBe(5);
601
+ });
401
602
  });
402
603
 
403
604
  describe("delete (soft delete)", () => {
404
605
  it("soft-deletes a post", async () => {
405
606
  const post = await postService.create({
406
- type: "note",
407
- content: "test",
607
+ format: "note",
608
+ body: "test",
408
609
  });
409
610
 
410
611
  const result = await postService.delete(post.id);
@@ -422,12 +623,12 @@ describe("PostService", () => {
422
623
 
423
624
  it("cascade deletes thread when deleting root post", async () => {
424
625
  const root = await postService.create({
425
- type: "note",
426
- content: "root",
626
+ format: "note",
627
+ body: "root",
427
628
  });
428
629
  const reply = await postService.create({
429
- type: "note",
430
- content: "reply",
630
+ format: "note",
631
+ body: "reply",
431
632
  replyToId: root.id,
432
633
  });
433
634
 
@@ -440,17 +641,17 @@ describe("PostService", () => {
440
641
 
441
642
  it("only deletes single post when deleting a reply", async () => {
442
643
  const root = await postService.create({
443
- type: "note",
444
- content: "root",
644
+ format: "note",
645
+ body: "root",
445
646
  });
446
647
  const reply1 = await postService.create({
447
- type: "note",
448
- content: "reply1",
648
+ format: "note",
649
+ body: "reply1",
449
650
  replyToId: root.id,
450
651
  });
451
652
  await postService.create({
452
- type: "note",
453
- content: "reply2",
653
+ format: "note",
654
+ body: "reply2",
454
655
  replyToId: root.id,
455
656
  });
456
657
 
@@ -466,12 +667,12 @@ describe("PostService", () => {
466
667
  describe("threads", () => {
467
668
  it("sets threadId on reply to a root post", async () => {
468
669
  const root = await postService.create({
469
- type: "note",
470
- content: "root",
670
+ format: "note",
671
+ body: "root",
471
672
  });
472
673
  const reply = await postService.create({
473
- type: "note",
474
- content: "reply",
674
+ format: "note",
675
+ body: "reply",
475
676
  replyToId: root.id,
476
677
  });
477
678
 
@@ -481,17 +682,17 @@ describe("PostService", () => {
481
682
 
482
683
  it("inherits threadId from parent in nested replies", async () => {
483
684
  const root = await postService.create({
484
- type: "note",
485
- content: "root",
685
+ format: "note",
686
+ body: "root",
486
687
  });
487
688
  const reply1 = await postService.create({
488
- type: "note",
489
- content: "reply1",
689
+ format: "note",
690
+ body: "reply1",
490
691
  replyToId: root.id,
491
692
  });
492
693
  const reply2 = await postService.create({
493
- type: "note",
494
- content: "reply2",
694
+ format: "note",
695
+ body: "reply2",
495
696
  replyToId: reply1.id,
496
697
  });
497
698
 
@@ -500,51 +701,66 @@ describe("PostService", () => {
500
701
  expect(reply2.threadId).toBe(root.id);
501
702
  });
502
703
 
503
- it("inherits visibility from root post", async () => {
704
+ it("inherits status from root post", async () => {
504
705
  const root = await postService.create({
505
- type: "note",
506
- content: "root",
507
- visibility: "featured",
706
+ format: "note",
707
+ body: "root",
708
+ status: "draft",
508
709
  });
509
710
  const reply = await postService.create({
510
- type: "note",
511
- content: "reply",
711
+ format: "note",
712
+ body: "reply",
512
713
  replyToId: root.id,
513
714
  });
514
715
 
515
- expect(reply.visibility).toBe("featured");
716
+ expect(reply.status).toBe("draft");
717
+ });
718
+
719
+ it("inherits featured from root post", async () => {
720
+ const root = await postService.create({
721
+ format: "note",
722
+ body: "root",
723
+ featured: true,
724
+ });
725
+ const reply = await postService.create({
726
+ format: "note",
727
+ body: "reply",
728
+ replyToId: root.id,
729
+ });
730
+
731
+ expect(reply.featured).toBe(1);
516
732
  });
517
733
 
518
734
  it("getThread returns all posts in a thread", async () => {
519
735
  const root = await postService.create({
520
- type: "note",
521
- content: "root",
736
+ format: "note",
737
+ body: "root",
522
738
  });
523
739
  await postService.create({
524
- type: "note",
525
- content: "reply1",
740
+ format: "note",
741
+ body: "reply1",
526
742
  replyToId: root.id,
527
743
  });
528
744
  await postService.create({
529
- type: "note",
530
- content: "reply2",
745
+ format: "note",
746
+ body: "reply2",
531
747
  replyToId: root.id,
532
748
  });
533
749
 
534
750
  const thread = await postService.getThread(root.id);
535
751
  expect(thread).toHaveLength(3);
536
752
  // Ordered by createdAt
537
- expect(thread[0]?.content).toBe("root");
753
+ expect(thread[0]?.body).toBe("root");
538
754
  });
539
755
 
540
756
  it("getThread excludes deleted posts", async () => {
541
757
  const root = await postService.create({
542
- type: "note",
543
- content: "root",
758
+ format: "note",
759
+ body: "root",
544
760
  });
545
761
  const reply = await postService.create({
546
- type: "note",
547
- content: "reply",
762
+ format: "note",
763
+ body: "reply",
548
764
  replyToId: root.id,
549
765
  });
550
766
 
@@ -554,23 +770,42 @@ describe("PostService", () => {
554
770
  expect(thread).toHaveLength(1);
555
771
  });
556
772
 
557
- it("cascades visibility changes from root to thread", async () => {
773
+ it("cascades status changes from root to thread", async () => {
774
+ const root = await postService.create({
775
+ format: "note",
776
+ body: "root",
777
+ status: "published",
778
+ });
779
+ await postService.create({
780
+ format: "note",
781
+ body: "reply",
782
+ replyToId: root.id,
783
+ });
784
+
785
+ await postService.update(root.id, { status: "draft" });
786
+
787
+ const thread = await postService.getThread(root.id);
788
+ for (const post of thread) {
789
+ expect(post.status).toBe("draft");
790
+ }
791
+ });
792
+
793
+ it("cascades featured changes from root to thread", async () => {
558
794
  const root = await postService.create({
559
- type: "note",
560
- content: "root",
561
- visibility: "quiet",
795
+ format: "note",
796
+ body: "root",
562
797
  });
563
798
  await postService.create({
564
- type: "note",
565
- content: "reply",
799
+ format: "note",
800
+ body: "reply",
566
801
  replyToId: root.id,
567
802
  });
568
803
 
569
- await postService.update(root.id, { visibility: "featured" });
804
+ await postService.update(root.id, { featured: true });
570
805
 
571
806
  const thread = await postService.getThread(root.id);
572
807
  for (const post of thread) {
573
- expect(post.visibility).toBe("featured");
808
+ expect(post.featured).toBe(1);
574
809
  }
575
810
  });
576
811
  });
@@ -583,17 +818,17 @@ describe("PostService", () => {
583
818
 
584
819
  it("returns reply counts for posts", async () => {
585
820
  const root = await postService.create({
586
- type: "note",
587
- content: "root",
821
+ format: "note",
822
+ body: "root",
588
823
  });
589
824
  await postService.create({
590
- type: "note",
591
- content: "reply1",
825
+ format: "note",
826
+ body: "reply1",
592
827
  replyToId: root.id,
593
828
  });
594
829
  await postService.create({
595
- type: "note",
596
- content: "reply2",
830
+ format: "note",
831
+ body: "reply2",
597
832
  replyToId: root.id,
598
833
  });
599
834
 
@@ -603,8 +838,8 @@ describe("PostService", () => {
603
838
 
604
839
  it("returns 0 (missing) for posts without replies", async () => {
605
840
  const post = await postService.create({
606
- type: "note",
607
- content: "no replies",
841
+ format: "note",
842
+ body: "no replies",
608
843
  });
609
844
 
610
845
  const counts = await postService.getReplyCounts([post.id]);
@@ -613,17 +848,17 @@ describe("PostService", () => {
613
848
 
614
849
  it("excludes deleted replies from count", async () => {
615
850
  const root = await postService.create({
616
- type: "note",
617
- content: "root",
851
+ format: "note",
852
+ body: "root",
618
853
  });
619
854
  const reply = await postService.create({
620
- type: "note",
621
- content: "reply",
855
+ format: "note",
856
+ body: "reply",
622
857
  replyToId: root.id,
623
858
  });
624
859
  await postService.create({
625
- type: "note",
626
- content: "reply2",
860
+ format: "note",
861
+ body: "reply2",
627
862
  replyToId: root.id,
628
863
  });
629
864