@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,19 +1,19 @@
1
1
  /**
2
- * Timeline API Tests
2
+ * Timeline Data Assembly Tests
3
3
  *
4
4
  * Tests the timeline data assembly logic via the service layer.
5
5
  * The actual route handler renders JSX components which require the Lingui SWC
6
6
  * plugin (not available in vitest). We test the underlying service operations
7
- * that power the timeline API instead.
7
+ * that power the timeline instead.
8
8
  */
9
9
 
10
10
  import { describe, it, expect, beforeEach } from "vitest";
11
- import { createTestDatabase } from "../../../__tests__/helpers/db.js";
12
- import { createPostService } from "../../../services/post.js";
13
- import { createMediaService } from "../../../services/media.js";
14
- import { buildMediaMap } from "../../../lib/media-helpers.js";
15
- import type { Database } from "../../../db/index.js";
16
- import type { PostWithMedia } from "../../../types.js";
11
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
12
+ import { createPostService } from "../../services/post.js";
13
+ import { createMediaService } from "../../services/media.js";
14
+ import { buildMediaMap } from "../media-helpers.js";
15
+ import type { Database } from "../../db/index.js";
16
+ import type { PostWithMedia } from "../../types.js";
17
17
 
18
18
  describe("Timeline data assembly", () => {
19
19
  let db: Database;
@@ -29,15 +29,13 @@ describe("Timeline data assembly", () => {
29
29
 
30
30
  it("assembles timeline items with media attachments", async () => {
31
31
  const post = await postService.create({
32
- type: "note",
33
- content: "Hello",
34
- visibility: "featured",
32
+ format: "note",
33
+ body: "Hello",
35
34
  });
36
35
 
37
36
  const posts = await postService.list({
38
- visibility: ["featured", "quiet"],
37
+ status: "published",
39
38
  excludeReplies: true,
40
- excludeTypes: ["page"],
41
39
  limit: 21,
42
40
  });
43
41
 
@@ -60,25 +58,23 @@ describe("Timeline data assembly", () => {
60
58
 
61
59
  it("identifies thread roots and builds thread previews", async () => {
62
60
  const root = await postService.create({
63
- type: "note",
64
- content: "Thread root",
65
- visibility: "featured",
61
+ format: "note",
62
+ body: "Thread root",
66
63
  });
67
64
  await postService.create({
68
- type: "note",
69
- content: "Reply 1",
65
+ format: "note",
66
+ body: "Reply 1",
70
67
  replyToId: root.id,
71
68
  });
72
69
  await postService.create({
73
- type: "note",
74
- content: "Reply 2",
70
+ format: "note",
71
+ body: "Reply 2",
75
72
  replyToId: root.id,
76
73
  });
77
74
 
78
75
  const posts = await postService.list({
79
- visibility: ["featured", "quiet"],
76
+ status: "published",
80
77
  excludeReplies: true,
81
- excludeTypes: ["page"],
82
78
  limit: 21,
83
79
  });
84
80
 
@@ -96,7 +92,7 @@ describe("Timeline data assembly", () => {
96
92
  const threadPreviews = await postService.getThreadPreviews(threadRootIds);
97
93
  const replies = threadPreviews.get(root.id);
98
94
  expect(replies).toHaveLength(2);
99
- expect(replies?.[0]?.content).toBe("Reply 1");
95
+ expect(replies?.[0]?.body).toBe("Reply 1");
100
96
 
101
97
  // Assemble items
102
98
  const rawMediaMap = await mediaService.getByPostIds(postIds);
@@ -133,50 +129,25 @@ describe("Timeline data assembly", () => {
133
129
  expect(items[0]?.threadPreview?.totalReplyCount).toBe(2);
134
130
  });
135
131
 
136
- it("excludes pages from timeline", async () => {
137
- await postService.create({
138
- type: "note",
139
- content: "A note",
140
- visibility: "quiet",
141
- });
142
- await postService.create({
143
- type: "page",
144
- content: "A page",
145
- visibility: "quiet",
146
- });
147
-
148
- const posts = await postService.list({
149
- visibility: ["featured", "quiet"],
150
- excludeReplies: true,
151
- excludeTypes: ["page"],
152
- limit: 21,
153
- });
154
-
155
- expect(posts).toHaveLength(1);
156
- expect(posts[0]?.type).toBe("note");
157
- });
158
-
159
132
  it("excludes replies from top-level list", async () => {
160
133
  const root = await postService.create({
161
- type: "note",
162
- content: "Root",
163
- visibility: "quiet",
134
+ format: "note",
135
+ body: "Root",
164
136
  });
165
137
  await postService.create({
166
- type: "note",
167
- content: "Reply",
138
+ format: "note",
139
+ body: "Reply",
168
140
  replyToId: root.id,
169
141
  });
170
142
 
171
143
  const posts = await postService.list({
172
- visibility: ["featured", "quiet"],
144
+ status: "published",
173
145
  excludeReplies: true,
174
- excludeTypes: ["page"],
175
146
  limit: 21,
176
147
  });
177
148
 
178
149
  expect(posts).toHaveLength(1);
179
- expect(posts[0]?.content).toBe("Root");
150
+ expect(posts[0]?.body).toBe("Root");
180
151
  });
181
152
 
182
153
  it("supports cursor pagination for load more", async () => {
@@ -184,9 +155,8 @@ describe("Timeline data assembly", () => {
184
155
  for (let i = 0; i < 5; i++) {
185
156
  posts.push(
186
157
  await postService.create({
187
- type: "note",
188
- content: `Post ${i}`,
189
- visibility: "quiet",
158
+ format: "note",
159
+ body: `Post ${i}`,
190
160
  publishedAt: 1000 + i,
191
161
  }),
192
162
  );
@@ -194,9 +164,8 @@ describe("Timeline data assembly", () => {
194
164
 
195
165
  // First page
196
166
  const page1 = await postService.list({
197
- visibility: ["featured", "quiet"],
167
+ status: "published",
198
168
  excludeReplies: true,
199
- excludeTypes: ["page"],
200
169
  limit: 3,
201
170
  });
202
171
  expect(page1).toHaveLength(3);
@@ -205,9 +174,8 @@ describe("Timeline data assembly", () => {
205
174
  const lastPost = page1[page1.length - 1];
206
175
  expect(lastPost).toBeDefined();
207
176
  const page2 = await postService.list({
208
- visibility: ["featured", "quiet"],
177
+ status: "published",
209
178
  excludeReplies: true,
210
- excludeTypes: ["page"],
211
179
  limit: 3,
212
180
  cursor: lastPost?.id,
213
181
  });
@@ -215,28 +183,66 @@ describe("Timeline data assembly", () => {
215
183
  expect(page2.every((p) => p.id < (lastPost?.id ?? 0))).toBe(true);
216
184
  });
217
185
 
218
- it("correctly determines hasMore flag", async () => {
219
- for (let i = 0; i < 3; i++) {
186
+ it("supports offset-based pagination for page navigation", async () => {
187
+ for (let i = 0; i < 5; i++) {
220
188
  await postService.create({
221
- type: "note",
222
- content: `Post ${i}`,
223
- visibility: "quiet",
189
+ format: "note",
190
+ body: `Post ${i}`,
191
+ publishedAt: 1000 + i,
224
192
  });
225
193
  }
226
194
 
227
- // Request limit + 1 to check for more
228
195
  const pageSize = 2;
229
- const posts = await postService.list({
230
- visibility: ["featured", "quiet"],
196
+
197
+ // Page 1
198
+ const page1 = await postService.list({
199
+ status: "published",
231
200
  excludeReplies: true,
232
- excludeTypes: ["page"],
233
- limit: pageSize + 1,
201
+ limit: pageSize,
202
+ offset: 0,
234
203
  });
204
+ expect(page1).toHaveLength(2);
205
+ expect(page1[0]?.body).toBe("Post 4");
206
+ expect(page1[1]?.body).toBe("Post 3");
235
207
 
236
- const hasMore = posts.length > pageSize;
237
- expect(hasMore).toBe(true);
208
+ // Page 2
209
+ const page2 = await postService.list({
210
+ status: "published",
211
+ excludeReplies: true,
212
+ limit: pageSize,
213
+ offset: 2,
214
+ });
215
+ expect(page2).toHaveLength(2);
216
+ expect(page2[0]?.body).toBe("Post 2");
217
+ expect(page2[1]?.body).toBe("Post 1");
218
+
219
+ // Page 3 (partial)
220
+ const page3 = await postService.list({
221
+ status: "published",
222
+ excludeReplies: true,
223
+ limit: pageSize,
224
+ offset: 4,
225
+ });
226
+ expect(page3).toHaveLength(1);
227
+ expect(page3[0]?.body).toBe("Post 0");
228
+ });
229
+
230
+ it("computes total pages from count", async () => {
231
+ for (let i = 0; i < 5; i++) {
232
+ await postService.create({
233
+ format: "note",
234
+ body: `Post ${i}`,
235
+ });
236
+ }
237
+
238
+ const pageSize = 2;
239
+ const totalCount = await postService.count({
240
+ status: "published",
241
+ excludeReplies: true,
242
+ });
238
243
 
239
- const displayPosts = posts.slice(0, pageSize);
240
- expect(displayPosts).toHaveLength(2);
244
+ expect(totalCount).toBe(5);
245
+ const totalPages = Math.ceil(totalCount / pageSize);
246
+ expect(totalPages).toBe(3);
241
247
  });
242
248
  });
@@ -7,8 +7,8 @@ import {
7
7
  toPostView,
8
8
  toPostViews,
9
9
  toMediaView,
10
- toNavLinkView,
11
- toNavLinkViews,
10
+ toNavItemView,
11
+ toNavItemViews,
12
12
  toSearchResultView,
13
13
  toArchiveGroups,
14
14
  } from "../view.js";
@@ -16,7 +16,7 @@ import type { MediaContext } from "../view.js";
16
16
  import type {
17
17
  PostWithMedia,
18
18
  Media,
19
- NavigationLink,
19
+ NavItem,
20
20
  SearchResult,
21
21
  Post,
22
22
  } from "../../types.js";
@@ -30,15 +30,18 @@ const CTX_WITH_URLS: MediaContext = {
30
30
  function makePost(overrides: Partial<Post> = {}): Post {
31
31
  return {
32
32
  id: 1,
33
- type: "note",
34
- visibility: "featured",
35
- title: null,
33
+ format: "note",
34
+ status: "published",
35
+ featured: 0,
36
+ pinned: 0,
36
37
  path: null,
37
- content: "Hello world",
38
- contentHtml: "<p>Hello world</p>",
39
- sourceUrl: null,
40
- sourceName: null,
41
- sourceDomain: null,
38
+ title: null,
39
+ url: null,
40
+ body: "Hello world",
41
+ bodyHtml: "<p>Hello world</p>",
42
+ quoteText: null,
43
+ rating: null,
44
+ collectionId: null,
42
45
  replyToId: null,
43
46
  threadId: null,
44
47
  deletedAt: null,
@@ -78,11 +81,13 @@ function makeMedia(overrides: Partial<Media> = {}): Media {
78
81
  };
79
82
  }
80
83
 
81
- function makeNavLink(overrides: Partial<NavigationLink> = {}): NavigationLink {
84
+ function makeNavItem(overrides: Partial<NavItem> = {}): NavItem {
82
85
  return {
83
86
  id: 1,
87
+ type: "link",
84
88
  label: "Home",
85
89
  url: "/",
90
+ pageId: null,
86
91
  position: 0,
87
92
  createdAt: 1706745600,
88
93
  updatedAt: 1706745600,
@@ -95,13 +100,25 @@ function makeNavLink(overrides: Partial<NavigationLink> = {}): NavigationLink {
95
100
  // =============================================================================
96
101
 
97
102
  describe("toPostView", () => {
98
- it("generates permalink from post id", () => {
99
- const post = makePostWithMedia({ id: 123 });
103
+ it("generates permalink from post id when no path", () => {
104
+ const post = makePostWithMedia({ id: 123, path: null });
100
105
  const view = toPostView(post, EMPTY_CTX);
101
106
  expect(view.permalink).toMatch(/^\/p\/.+$/);
102
107
  expect(view.permalink.length).toBeGreaterThan(3);
103
108
  });
104
109
 
110
+ it("generates permalink from path when path is set", () => {
111
+ const post = makePostWithMedia({ id: 123, path: "my-post" });
112
+ const view = toPostView(post, EMPTY_CTX);
113
+ expect(view.permalink).toBe("/my-post");
114
+ });
115
+
116
+ it("generates permalink from multi-level path", () => {
117
+ const post = makePostWithMedia({ id: 123, path: "2024/01/my-post" });
118
+ const view = toPostView(post, EMPTY_CTX);
119
+ expect(view.permalink).toBe("/2024/01/my-post");
120
+ });
121
+
105
122
  it("formats dates correctly", () => {
106
123
  const post = makePostWithMedia({ publishedAt: 1706745600 });
107
124
  const view = toPostView(post, EMPTY_CTX);
@@ -109,52 +126,156 @@ describe("toPostView", () => {
109
126
  expect(view.publishedAtFormatted).toBe("Feb 1, 2024");
110
127
  });
111
128
 
112
- it("generates excerpt from content", () => {
113
- const shortContent = "Short text";
114
- const longContent = "A".repeat(200);
129
+ it("generates excerpt from body", () => {
130
+ const shortBody = "Short text";
131
+ const longBody = "A".repeat(200);
115
132
 
116
133
  const shortView = toPostView(
117
- makePostWithMedia({ content: shortContent }),
134
+ makePostWithMedia({ body: shortBody }),
118
135
  EMPTY_CTX,
119
136
  );
120
137
  expect(shortView.excerpt).toBe("Short text");
121
138
 
122
139
  const longView = toPostView(
123
- makePostWithMedia({ content: longContent }),
140
+ makePostWithMedia({ body: longBody }),
124
141
  EMPTY_CTX,
125
142
  );
126
143
  expect(longView.excerpt).toBe("A".repeat(160) + "...");
127
144
  });
128
145
 
129
- it("handles null content gracefully", () => {
130
- const view = toPostView(makePostWithMedia({ content: null }), EMPTY_CTX);
146
+ it("computes summaryHtml for posts with title and bodyHtml", () => {
147
+ const view = toPostView(
148
+ makePostWithMedia({
149
+ title: "My Article",
150
+ body: "Short article body",
151
+ bodyHtml: "<p>Short article body</p>",
152
+ }),
153
+ EMPTY_CTX,
154
+ );
155
+ expect(view.summaryHtml).toBe("<p>Short article body</p>");
156
+ expect(view.summaryHasMore).toBe(false);
157
+ });
158
+
159
+ it("truncates summaryHtml for long articles", () => {
160
+ const p1 = `<p>${"A".repeat(300)}</p>`;
161
+ const p2 = `<p>${"B".repeat(300)}</p>`;
162
+ const view = toPostView(
163
+ makePostWithMedia({
164
+ title: "Long Article",
165
+ body: "A".repeat(300) + "B".repeat(300),
166
+ bodyHtml: p1 + p2,
167
+ }),
168
+ EMPTY_CTX,
169
+ );
170
+ expect(view.summaryHtml).toBe(p1);
171
+ expect(view.summaryHasMore).toBe(true);
172
+ });
173
+
174
+ it("does not compute summaryHtml for posts without title", () => {
175
+ const view = toPostView(
176
+ makePostWithMedia({
177
+ title: null,
178
+ bodyHtml: "<p>Just a note</p>",
179
+ }),
180
+ EMPTY_CTX,
181
+ );
182
+ expect(view.summaryHtml).toBeUndefined();
183
+ expect(view.summaryHasMore).toBeUndefined();
184
+ });
185
+
186
+ it("does not compute summaryHtml for posts without bodyHtml", () => {
187
+ const view = toPostView(
188
+ makePostWithMedia({
189
+ title: "Title Only",
190
+ bodyHtml: null,
191
+ }),
192
+ EMPTY_CTX,
193
+ );
194
+ expect(view.summaryHtml).toBeUndefined();
195
+ expect(view.summaryHasMore).toBeUndefined();
196
+ });
197
+
198
+ it("handles null body gracefully", () => {
199
+ const view = toPostView(
200
+ makePostWithMedia({ body: null, bodyHtml: null }),
201
+ EMPTY_CTX,
202
+ );
131
203
  expect(view.excerpt).toBeUndefined();
132
- expect(view.content).toBeUndefined();
204
+ expect(view.bodyHtml).toBeUndefined();
205
+ expect(view.body).toBeUndefined();
133
206
  });
134
207
 
135
208
  it("converts null fields to undefined", () => {
136
209
  const view = toPostView(makePostWithMedia(), EMPTY_CTX);
137
210
  expect(view.title).toBeUndefined();
138
211
  expect(view.path).toBeUndefined();
139
- expect(view.sourceUrl).toBeUndefined();
140
- expect(view.sourceName).toBeUndefined();
141
- expect(view.sourceDomain).toBeUndefined();
212
+ expect(view.url).toBeUndefined();
213
+ expect(view.quoteText).toBeUndefined();
214
+ expect(view.rating).toBeUndefined();
215
+ expect(view.collectionId).toBeUndefined();
142
216
  expect(view.replyToId).toBeUndefined();
143
217
  expect(view.threadRootId).toBeUndefined();
144
218
  });
145
219
 
146
- it("preserves non-null source fields", () => {
220
+ it("preserves non-null url field", () => {
147
221
  const view = toPostView(
148
222
  makePostWithMedia({
149
- sourceUrl: "https://example.com",
150
- sourceName: "Example",
151
- sourceDomain: "example.com",
223
+ url: "https://example.com",
152
224
  }),
153
225
  EMPTY_CTX,
154
226
  );
155
- expect(view.sourceUrl).toBe("https://example.com");
156
- expect(view.sourceName).toBe("Example");
157
- expect(view.sourceDomain).toBe("example.com");
227
+ expect(view.url).toBe("https://example.com");
228
+ });
229
+
230
+ it("preserves non-null quoteText field", () => {
231
+ const view = toPostView(
232
+ makePostWithMedia({
233
+ format: "quote",
234
+ quoteText: "Something wise",
235
+ }),
236
+ EMPTY_CTX,
237
+ );
238
+ expect(view.quoteText).toBe("Something wise");
239
+ });
240
+
241
+ it("maps format, status, featured, and pinned correctly", () => {
242
+ const view = toPostView(
243
+ makePostWithMedia({
244
+ format: "link",
245
+ status: "draft",
246
+ featured: 1,
247
+ pinned: 1,
248
+ }),
249
+ EMPTY_CTX,
250
+ );
251
+ expect(view.format).toBe("link");
252
+ expect(view.status).toBe("draft");
253
+ expect(view.featured).toBe(true);
254
+ expect(view.pinned).toBe(true);
255
+ });
256
+
257
+ it("converts featured=0 and pinned=0 to false", () => {
258
+ const view = toPostView(
259
+ makePostWithMedia({
260
+ featured: 0,
261
+ pinned: 0,
262
+ }),
263
+ EMPTY_CTX,
264
+ );
265
+ expect(view.featured).toBe(false);
266
+ expect(view.pinned).toBe(false);
267
+ });
268
+
269
+ it("preserves rating and collectionId when set", () => {
270
+ const view = toPostView(
271
+ makePostWithMedia({
272
+ rating: 5,
273
+ collectionId: 42,
274
+ }),
275
+ EMPTY_CTX,
276
+ );
277
+ expect(view.rating).toBe(5);
278
+ expect(view.collectionId).toBe(42);
158
279
  });
159
280
 
160
281
  it("converts media attachments to MediaView", () => {
@@ -249,40 +370,40 @@ describe("toMediaView", () => {
249
370
  });
250
371
 
251
372
  // =============================================================================
252
- // toNavLinkView
373
+ // toNavItemView
253
374
  // =============================================================================
254
375
 
255
- describe("toNavLinkView", () => {
376
+ describe("toNavItemView", () => {
256
377
  it("marks home link active on exact / match", () => {
257
- const view = toNavLinkView(makeNavLink({ url: "/" }), "/");
378
+ const view = toNavItemView(makeNavItem({ url: "/" }), "/");
258
379
  expect(view.isActive).toBe(true);
259
380
  expect(view.isExternal).toBe(false);
260
381
  });
261
382
 
262
383
  it("marks home link inactive on other paths", () => {
263
- const view = toNavLinkView(makeNavLink({ url: "/" }), "/archive");
384
+ const view = toNavItemView(makeNavItem({ url: "/" }), "/archive");
264
385
  expect(view.isActive).toBe(false);
265
386
  });
266
387
 
267
388
  it("matches prefix for non-root links", () => {
268
- const view = toNavLinkView(makeNavLink({ url: "/archive" }), "/archive");
389
+ const view = toNavItemView(makeNavItem({ url: "/archive" }), "/archive");
269
390
  expect(view.isActive).toBe(true);
270
391
 
271
- const viewSub = toNavLinkView(
272
- makeNavLink({ url: "/archive" }),
392
+ const viewSub = toNavItemView(
393
+ makeNavItem({ url: "/archive" }),
273
394
  "/archive/2024",
274
395
  );
275
396
  expect(viewSub.isActive).toBe(true);
276
397
  });
277
398
 
278
399
  it("does not false-match similar prefixes", () => {
279
- const view = toNavLinkView(makeNavLink({ url: "/arch" }), "/archive");
400
+ const view = toNavItemView(makeNavItem({ url: "/arch" }), "/archive");
280
401
  expect(view.isActive).toBe(false);
281
402
  });
282
403
 
283
404
  it("marks external links as external and never active", () => {
284
- const view = toNavLinkView(
285
- makeNavLink({ url: "https://example.com" }),
405
+ const view = toNavItemView(
406
+ makeNavItem({ url: "https://example.com" }),
286
407
  "/",
287
408
  );
288
409
  expect(view.isExternal).toBe(true);
@@ -290,20 +411,31 @@ describe("toNavLinkView", () => {
290
411
  });
291
412
 
292
413
  it("handles http:// links", () => {
293
- const view = toNavLinkView(makeNavLink({ url: "http://example.com" }), "/");
414
+ const view = toNavItemView(makeNavItem({ url: "http://example.com" }), "/");
294
415
  expect(view.isExternal).toBe(true);
295
416
  expect(view.isActive).toBe(false);
296
417
  });
418
+
419
+ it("includes type and pageId in view", () => {
420
+ const view = toNavItemView(makeNavItem({ type: "page", pageId: 5 }), "/");
421
+ expect(view.type).toBe("page");
422
+ expect(view.pageId).toBe(5);
423
+ });
424
+
425
+ it("converts null pageId to undefined", () => {
426
+ const view = toNavItemView(makeNavItem({ pageId: null }), "/");
427
+ expect(view.pageId).toBeUndefined();
428
+ });
297
429
  });
298
430
 
299
- describe("toNavLinkViews", () => {
300
- it("converts multiple links", () => {
301
- const links = [
302
- makeNavLink({ id: 1, url: "/" }),
303
- makeNavLink({ id: 2, url: "/archive" }),
304
- makeNavLink({ id: 3, url: "https://github.com" }),
431
+ describe("toNavItemViews", () => {
432
+ it("converts multiple items", () => {
433
+ const items = [
434
+ makeNavItem({ id: 1, url: "/" }),
435
+ makeNavItem({ id: 2, url: "/archive" }),
436
+ makeNavItem({ id: 3, url: "https://github.com" }),
305
437
  ];
306
- const views = toNavLinkViews(links, "/archive");
438
+ const views = toNavItemViews(items, "/archive");
307
439
  expect(views).toHaveLength(3);
308
440
  expect(views[0]).toHaveProperty("isActive", false);
309
441
  expect(views[1]).toHaveProperty("isActive", true);
@@ -329,6 +461,28 @@ describe("toSearchResultView", () => {
329
461
  expect(view.rank).toBe(1.5);
330
462
  expect(view.snippet).toBe("...matching <b>text</b>...");
331
463
  });
464
+
465
+ it("uses new post fields in search result view", () => {
466
+ const result: SearchResult = {
467
+ post: makePost({
468
+ id: 10,
469
+ format: "link",
470
+ status: "published",
471
+ featured: 1,
472
+ pinned: 0,
473
+ url: "https://example.com",
474
+ path: "my-link",
475
+ }),
476
+ rank: 0.8,
477
+ };
478
+ const view = toSearchResultView(result, EMPTY_CTX);
479
+ expect(view.post.format).toBe("link");
480
+ expect(view.post.status).toBe("published");
481
+ expect(view.post.featured).toBe(true);
482
+ expect(view.post.pinned).toBe(false);
483
+ expect(view.post.url).toBe("https://example.com");
484
+ expect(view.post.permalink).toBe("/my-link");
485
+ });
332
486
  });
333
487
 
334
488
  // =============================================================================
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export const RESERVED_PATHS = [
9
9
  "featured",
10
+ "collections",
10
11
  "signin",
11
12
  "signout",
12
13
  "setup",
@@ -15,10 +16,6 @@ export const RESERVED_PATHS = [
15
16
  "feed",
16
17
  "search",
17
18
  "archive",
18
- "notes",
19
- "articles",
20
- "links",
21
- "quotes",
22
19
  "media",
23
20
  "pages",
24
21
  "reset",
@@ -53,6 +50,7 @@ export const SETTINGS_KEYS = {
53
50
  SITE_DESCRIPTION: "SITE_DESCRIPTION",
54
51
  SITE_LANGUAGE: "SITE_LANGUAGE",
55
52
  THEME: "THEME",
53
+ CUSTOM_CSS: "CUSTOM_CSS",
56
54
  PASSWORD_RESET_TOKEN: "PASSWORD_RESET_TOKEN",
57
55
  } as const;
58
56