@jant/core 0.3.22 → 0.3.24

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 (178) hide show
  1. package/dist/app.js +23 -5
  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 -6
  7. package/dist/lib/constants.js +1 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/navigation.js +4 -5
  11. package/dist/lib/render.js +1 -1
  12. package/dist/lib/schemas.js +80 -38
  13. package/dist/lib/theme-components.js +8 -11
  14. package/dist/lib/time.js +56 -1
  15. package/dist/lib/timeline.js +119 -0
  16. package/dist/lib/view.js +62 -73
  17. package/dist/routes/api/posts.js +29 -35
  18. package/dist/routes/api/search.js +5 -6
  19. package/dist/routes/api/upload.js +13 -13
  20. package/dist/routes/dash/collections.js +22 -40
  21. package/dist/routes/dash/index.js +2 -2
  22. package/dist/routes/dash/navigation.js +25 -24
  23. package/dist/routes/dash/pages.js +42 -57
  24. package/dist/routes/dash/posts.js +27 -35
  25. package/dist/routes/feed/rss.js +2 -4
  26. package/dist/routes/feed/sitemap.js +10 -7
  27. package/dist/routes/pages/archive.js +12 -11
  28. package/dist/routes/pages/collection.js +11 -5
  29. package/dist/routes/pages/home.js +53 -61
  30. package/dist/routes/pages/page.js +60 -29
  31. package/dist/routes/pages/post.js +5 -12
  32. package/dist/routes/pages/search.js +3 -4
  33. package/dist/services/collection.js +52 -64
  34. package/dist/services/index.js +5 -3
  35. package/dist/services/navigation.js +29 -53
  36. package/dist/services/page.js +80 -0
  37. package/dist/services/post.js +68 -69
  38. package/dist/services/search.js +24 -18
  39. package/dist/theme/components/MediaGallery.js +19 -91
  40. package/dist/theme/components/PageForm.js +15 -15
  41. package/dist/theme/components/PostForm.js +136 -129
  42. package/dist/theme/components/PostList.js +13 -8
  43. package/dist/theme/components/ThreadView.js +3 -3
  44. package/dist/theme/components/TypeBadge.js +3 -14
  45. package/dist/theme/components/VisibilityBadge.js +33 -23
  46. package/dist/theme/components/index.js +0 -2
  47. package/dist/theme/index.js +10 -16
  48. package/dist/theme/layouts/index.js +0 -1
  49. package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
  50. package/dist/themes/threads/index.js +81 -0
  51. package/dist/{theme → themes/threads}/pages/ArchivePage.js +31 -47
  52. package/dist/themes/threads/pages/CollectionPage.js +65 -0
  53. package/dist/{theme → themes/threads}/pages/HomePage.js +4 -5
  54. package/dist/{theme → themes/threads}/pages/PostPage.js +10 -8
  55. package/dist/{theme → themes/threads}/pages/SearchPage.js +8 -8
  56. package/dist/{theme → themes/threads}/pages/SinglePage.js +5 -6
  57. package/dist/{theme/components → themes/threads}/timeline/LinkCard.js +20 -11
  58. package/dist/themes/threads/timeline/NoteCard.js +53 -0
  59. package/dist/themes/threads/timeline/QuoteCard.js +59 -0
  60. package/dist/{theme/components → themes/threads}/timeline/ThreadPreview.js +5 -6
  61. package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
  62. package/dist/{theme/components → themes/threads}/timeline/TimelineItem.js +8 -17
  63. package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
  64. package/dist/themes/threads/timeline/groupByDate.js +22 -0
  65. package/dist/themes/threads/timeline/timelineMore.js +107 -0
  66. package/dist/types.js +24 -40
  67. package/package.json +2 -1
  68. package/src/__tests__/helpers/app.ts +4 -0
  69. package/src/__tests__/helpers/db.ts +51 -74
  70. package/src/app.tsx +27 -6
  71. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  72. package/src/db/migrations/meta/_journal.json +7 -0
  73. package/src/db/schema.ts +63 -46
  74. package/src/i18n/locales/en.po +216 -164
  75. package/src/i18n/locales/en.ts +1 -1
  76. package/src/i18n/locales/zh-Hans.po +216 -164
  77. package/src/i18n/locales/zh-Hans.ts +1 -1
  78. package/src/i18n/locales/zh-Hant.po +216 -164
  79. package/src/i18n/locales/zh-Hant.ts +1 -1
  80. package/src/index.ts +30 -15
  81. package/src/lib/__tests__/excerpt.test.ts +125 -0
  82. package/src/lib/__tests__/schemas.test.ts +166 -105
  83. package/src/lib/__tests__/theme-components.test.ts +4 -25
  84. package/src/lib/__tests__/time.test.ts +62 -0
  85. package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
  86. package/src/lib/__tests__/view.test.ts +217 -67
  87. package/src/lib/constants.ts +1 -4
  88. package/src/lib/excerpt.ts +87 -0
  89. package/src/lib/feed.ts +22 -7
  90. package/src/lib/navigation.ts +6 -7
  91. package/src/lib/render.tsx +1 -1
  92. package/src/lib/schemas.ts +118 -52
  93. package/src/lib/theme-components.ts +10 -13
  94. package/src/lib/time.ts +64 -0
  95. package/src/lib/timeline.ts +170 -0
  96. package/src/lib/view.ts +81 -83
  97. package/src/preset.css +45 -0
  98. package/src/routes/api/__tests__/posts.test.ts +50 -108
  99. package/src/routes/api/__tests__/search.test.ts +2 -3
  100. package/src/routes/api/posts.ts +30 -30
  101. package/src/routes/api/search.ts +4 -4
  102. package/src/routes/api/upload.ts +16 -6
  103. package/src/routes/dash/collections.tsx +18 -40
  104. package/src/routes/dash/index.tsx +2 -2
  105. package/src/routes/dash/navigation.tsx +27 -26
  106. package/src/routes/dash/pages.tsx +45 -60
  107. package/src/routes/dash/posts.tsx +44 -52
  108. package/src/routes/feed/rss.ts +2 -1
  109. package/src/routes/feed/sitemap.ts +14 -4
  110. package/src/routes/pages/archive.tsx +14 -10
  111. package/src/routes/pages/collection.tsx +17 -6
  112. package/src/routes/pages/home.tsx +56 -81
  113. package/src/routes/pages/page.tsx +64 -27
  114. package/src/routes/pages/post.tsx +5 -14
  115. package/src/routes/pages/search.tsx +2 -2
  116. package/src/services/__tests__/collection.test.ts +257 -158
  117. package/src/services/__tests__/media.test.ts +18 -18
  118. package/src/services/__tests__/navigation.test.ts +161 -87
  119. package/src/services/__tests__/post-timeline.test.ts +92 -88
  120. package/src/services/__tests__/post.test.ts +342 -206
  121. package/src/services/__tests__/search.test.ts +19 -25
  122. package/src/services/collection.ts +71 -113
  123. package/src/services/index.ts +9 -8
  124. package/src/services/navigation.ts +38 -71
  125. package/src/services/page.ts +124 -0
  126. package/src/services/post.ts +93 -103
  127. package/src/services/search.ts +38 -27
  128. package/src/styles/components.css +0 -54
  129. package/src/theme/components/MediaGallery.tsx +27 -96
  130. package/src/theme/components/PageForm.tsx +21 -21
  131. package/src/theme/components/PostForm.tsx +122 -118
  132. package/src/theme/components/PostList.tsx +58 -49
  133. package/src/theme/components/ThreadView.tsx +6 -3
  134. package/src/theme/components/TypeBadge.tsx +9 -17
  135. package/src/theme/components/VisibilityBadge.tsx +40 -23
  136. package/src/theme/components/index.ts +0 -13
  137. package/src/theme/index.ts +10 -16
  138. package/src/theme/layouts/index.ts +0 -1
  139. package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
  140. package/src/themes/threads/index.ts +100 -0
  141. package/src/{theme → themes/threads}/pages/ArchivePage.tsx +52 -55
  142. package/src/themes/threads/pages/CollectionPage.tsx +61 -0
  143. package/src/{theme → themes/threads}/pages/HomePage.tsx +5 -6
  144. package/src/{theme → themes/threads}/pages/PostPage.tsx +11 -8
  145. package/src/{theme → themes/threads}/pages/SearchPage.tsx +9 -13
  146. package/src/themes/threads/pages/SinglePage.tsx +23 -0
  147. package/src/themes/threads/style.css +336 -0
  148. package/src/{theme/components → themes/threads}/timeline/LinkCard.tsx +21 -13
  149. package/src/themes/threads/timeline/NoteCard.tsx +58 -0
  150. package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
  151. package/src/{theme/components → themes/threads}/timeline/ThreadPreview.tsx +6 -6
  152. package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
  153. package/src/{theme/components → themes/threads}/timeline/TimelineItem.tsx +9 -20
  154. package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
  155. package/src/themes/threads/timeline/groupByDate.ts +30 -0
  156. package/src/themes/threads/timeline/timelineMore.tsx +130 -0
  157. package/src/types.ts +242 -98
  158. package/dist/routes/api/timeline.js +0 -120
  159. package/dist/theme/components/timeline/ArticleCard.js +0 -46
  160. package/dist/theme/components/timeline/ImageCard.js +0 -83
  161. package/dist/theme/components/timeline/NoteCard.js +0 -34
  162. package/dist/theme/components/timeline/QuoteCard.js +0 -48
  163. package/dist/theme/components/timeline/TimelineFeed.js +0 -46
  164. package/dist/theme/components/timeline/index.js +0 -8
  165. package/dist/theme/layouts/SiteLayout.js +0 -131
  166. package/dist/theme/pages/CollectionPage.js +0 -63
  167. package/dist/theme/pages/index.js +0 -11
  168. package/src/routes/api/timeline.tsx +0 -159
  169. package/src/theme/components/timeline/ArticleCard.tsx +0 -45
  170. package/src/theme/components/timeline/ImageCard.tsx +0 -70
  171. package/src/theme/components/timeline/NoteCard.tsx +0 -34
  172. package/src/theme/components/timeline/QuoteCard.tsx +0 -48
  173. package/src/theme/components/timeline/TimelineFeed.tsx +0 -56
  174. package/src/theme/components/timeline/index.ts +0 -8
  175. package/src/theme/layouts/SiteLayout.tsx +0 -132
  176. package/src/theme/pages/CollectionPage.tsx +0 -60
  177. package/src/theme/pages/SinglePage.tsx +0 -24
  178. package/src/theme/pages/index.ts +0 -13
@@ -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",
33
+ format: "note",
34
+ status: "published",
35
+ featured: 0,
36
+ pinned: 0,
37
+ slug: null,
35
38
  title: null,
36
- path: null,
37
- content: "Hello world",
38
- contentHtml: "<p>Hello world</p>",
39
- sourceUrl: null,
40
- sourceName: null,
41
- sourceDomain: 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,19 @@ 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 slug", () => {
104
+ const post = makePostWithMedia({ id: 123, slug: 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 slug when slug is set", () => {
111
+ const post = makePostWithMedia({ id: 123, slug: "my-post" });
112
+ const view = toPostView(post, EMPTY_CTX);
113
+ expect(view.permalink).toBe("/my-post");
114
+ });
115
+
105
116
  it("formats dates correctly", () => {
106
117
  const post = makePostWithMedia({ publishedAt: 1706745600 });
107
118
  const view = toPostView(post, EMPTY_CTX);
@@ -109,52 +120,156 @@ describe("toPostView", () => {
109
120
  expect(view.publishedAtFormatted).toBe("Feb 1, 2024");
110
121
  });
111
122
 
112
- it("generates excerpt from content", () => {
113
- const shortContent = "Short text";
114
- const longContent = "A".repeat(200);
123
+ it("generates excerpt from body", () => {
124
+ const shortBody = "Short text";
125
+ const longBody = "A".repeat(200);
115
126
 
116
127
  const shortView = toPostView(
117
- makePostWithMedia({ content: shortContent }),
128
+ makePostWithMedia({ body: shortBody }),
118
129
  EMPTY_CTX,
119
130
  );
120
131
  expect(shortView.excerpt).toBe("Short text");
121
132
 
122
133
  const longView = toPostView(
123
- makePostWithMedia({ content: longContent }),
134
+ makePostWithMedia({ body: longBody }),
124
135
  EMPTY_CTX,
125
136
  );
126
137
  expect(longView.excerpt).toBe("A".repeat(160) + "...");
127
138
  });
128
139
 
129
- it("handles null content gracefully", () => {
130
- const view = toPostView(makePostWithMedia({ content: null }), EMPTY_CTX);
140
+ it("computes summaryHtml for posts with title and bodyHtml", () => {
141
+ const view = toPostView(
142
+ makePostWithMedia({
143
+ title: "My Article",
144
+ body: "Short article body",
145
+ bodyHtml: "<p>Short article body</p>",
146
+ }),
147
+ EMPTY_CTX,
148
+ );
149
+ expect(view.summaryHtml).toBe("<p>Short article body</p>");
150
+ expect(view.summaryHasMore).toBe(false);
151
+ });
152
+
153
+ it("truncates summaryHtml for long articles", () => {
154
+ const p1 = `<p>${"A".repeat(300)}</p>`;
155
+ const p2 = `<p>${"B".repeat(300)}</p>`;
156
+ const view = toPostView(
157
+ makePostWithMedia({
158
+ title: "Long Article",
159
+ body: "A".repeat(300) + "B".repeat(300),
160
+ bodyHtml: p1 + p2,
161
+ }),
162
+ EMPTY_CTX,
163
+ );
164
+ expect(view.summaryHtml).toBe(p1);
165
+ expect(view.summaryHasMore).toBe(true);
166
+ });
167
+
168
+ it("does not compute summaryHtml for posts without title", () => {
169
+ const view = toPostView(
170
+ makePostWithMedia({
171
+ title: null,
172
+ bodyHtml: "<p>Just a note</p>",
173
+ }),
174
+ EMPTY_CTX,
175
+ );
176
+ expect(view.summaryHtml).toBeUndefined();
177
+ expect(view.summaryHasMore).toBeUndefined();
178
+ });
179
+
180
+ it("does not compute summaryHtml for posts without bodyHtml", () => {
181
+ const view = toPostView(
182
+ makePostWithMedia({
183
+ title: "Title Only",
184
+ bodyHtml: null,
185
+ }),
186
+ EMPTY_CTX,
187
+ );
188
+ expect(view.summaryHtml).toBeUndefined();
189
+ expect(view.summaryHasMore).toBeUndefined();
190
+ });
191
+
192
+ it("handles null body gracefully", () => {
193
+ const view = toPostView(
194
+ makePostWithMedia({ body: null, bodyHtml: null }),
195
+ EMPTY_CTX,
196
+ );
131
197
  expect(view.excerpt).toBeUndefined();
132
- expect(view.content).toBeUndefined();
198
+ expect(view.bodyHtml).toBeUndefined();
199
+ expect(view.body).toBeUndefined();
133
200
  });
134
201
 
135
202
  it("converts null fields to undefined", () => {
136
203
  const view = toPostView(makePostWithMedia(), EMPTY_CTX);
137
204
  expect(view.title).toBeUndefined();
138
- expect(view.path).toBeUndefined();
139
- expect(view.sourceUrl).toBeUndefined();
140
- expect(view.sourceName).toBeUndefined();
141
- expect(view.sourceDomain).toBeUndefined();
205
+ expect(view.slug).toBeUndefined();
206
+ expect(view.url).toBeUndefined();
207
+ expect(view.quoteText).toBeUndefined();
208
+ expect(view.rating).toBeUndefined();
209
+ expect(view.collectionId).toBeUndefined();
142
210
  expect(view.replyToId).toBeUndefined();
143
211
  expect(view.threadRootId).toBeUndefined();
144
212
  });
145
213
 
146
- it("preserves non-null source fields", () => {
214
+ it("preserves non-null url field", () => {
215
+ const view = toPostView(
216
+ makePostWithMedia({
217
+ url: "https://example.com",
218
+ }),
219
+ EMPTY_CTX,
220
+ );
221
+ expect(view.url).toBe("https://example.com");
222
+ });
223
+
224
+ it("preserves non-null quoteText field", () => {
225
+ const view = toPostView(
226
+ makePostWithMedia({
227
+ format: "quote",
228
+ quoteText: "Something wise",
229
+ }),
230
+ EMPTY_CTX,
231
+ );
232
+ expect(view.quoteText).toBe("Something wise");
233
+ });
234
+
235
+ it("maps format, status, featured, and pinned correctly", () => {
236
+ const view = toPostView(
237
+ makePostWithMedia({
238
+ format: "link",
239
+ status: "draft",
240
+ featured: 1,
241
+ pinned: 1,
242
+ }),
243
+ EMPTY_CTX,
244
+ );
245
+ expect(view.format).toBe("link");
246
+ expect(view.status).toBe("draft");
247
+ expect(view.featured).toBe(true);
248
+ expect(view.pinned).toBe(true);
249
+ });
250
+
251
+ it("converts featured=0 and pinned=0 to false", () => {
147
252
  const view = toPostView(
148
253
  makePostWithMedia({
149
- sourceUrl: "https://example.com",
150
- sourceName: "Example",
151
- sourceDomain: "example.com",
254
+ featured: 0,
255
+ pinned: 0,
152
256
  }),
153
257
  EMPTY_CTX,
154
258
  );
155
- expect(view.sourceUrl).toBe("https://example.com");
156
- expect(view.sourceName).toBe("Example");
157
- expect(view.sourceDomain).toBe("example.com");
259
+ expect(view.featured).toBe(false);
260
+ expect(view.pinned).toBe(false);
261
+ });
262
+
263
+ it("preserves rating and collectionId when set", () => {
264
+ const view = toPostView(
265
+ makePostWithMedia({
266
+ rating: 5,
267
+ collectionId: 42,
268
+ }),
269
+ EMPTY_CTX,
270
+ );
271
+ expect(view.rating).toBe(5);
272
+ expect(view.collectionId).toBe(42);
158
273
  });
159
274
 
160
275
  it("converts media attachments to MediaView", () => {
@@ -194,8 +309,8 @@ describe("toPostViews", () => {
194
309
  const posts = [makePostWithMedia({ id: 1 }), makePostWithMedia({ id: 2 })];
195
310
  const views = toPostViews(posts, EMPTY_CTX);
196
311
  expect(views).toHaveLength(2);
197
- expect(views[0]!.id).toBe(1);
198
- expect(views[1]!.id).toBe(2);
312
+ expect(views[0]).toHaveProperty("id", 1);
313
+ expect(views[1]).toHaveProperty("id", 2);
199
314
  });
200
315
  });
201
316
 
@@ -249,40 +364,40 @@ describe("toMediaView", () => {
249
364
  });
250
365
 
251
366
  // =============================================================================
252
- // toNavLinkView
367
+ // toNavItemView
253
368
  // =============================================================================
254
369
 
255
- describe("toNavLinkView", () => {
370
+ describe("toNavItemView", () => {
256
371
  it("marks home link active on exact / match", () => {
257
- const view = toNavLinkView(makeNavLink({ url: "/" }), "/");
372
+ const view = toNavItemView(makeNavItem({ url: "/" }), "/");
258
373
  expect(view.isActive).toBe(true);
259
374
  expect(view.isExternal).toBe(false);
260
375
  });
261
376
 
262
377
  it("marks home link inactive on other paths", () => {
263
- const view = toNavLinkView(makeNavLink({ url: "/" }), "/archive");
378
+ const view = toNavItemView(makeNavItem({ url: "/" }), "/archive");
264
379
  expect(view.isActive).toBe(false);
265
380
  });
266
381
 
267
382
  it("matches prefix for non-root links", () => {
268
- const view = toNavLinkView(makeNavLink({ url: "/archive" }), "/archive");
383
+ const view = toNavItemView(makeNavItem({ url: "/archive" }), "/archive");
269
384
  expect(view.isActive).toBe(true);
270
385
 
271
- const viewSub = toNavLinkView(
272
- makeNavLink({ url: "/archive" }),
386
+ const viewSub = toNavItemView(
387
+ makeNavItem({ url: "/archive" }),
273
388
  "/archive/2024",
274
389
  );
275
390
  expect(viewSub.isActive).toBe(true);
276
391
  });
277
392
 
278
393
  it("does not false-match similar prefixes", () => {
279
- const view = toNavLinkView(makeNavLink({ url: "/arch" }), "/archive");
394
+ const view = toNavItemView(makeNavItem({ url: "/arch" }), "/archive");
280
395
  expect(view.isActive).toBe(false);
281
396
  });
282
397
 
283
398
  it("marks external links as external and never active", () => {
284
- const view = toNavLinkView(
285
- makeNavLink({ url: "https://example.com" }),
399
+ const view = toNavItemView(
400
+ makeNavItem({ url: "https://example.com" }),
286
401
  "/",
287
402
  );
288
403
  expect(view.isExternal).toBe(true);
@@ -290,24 +405,35 @@ describe("toNavLinkView", () => {
290
405
  });
291
406
 
292
407
  it("handles http:// links", () => {
293
- const view = toNavLinkView(makeNavLink({ url: "http://example.com" }), "/");
408
+ const view = toNavItemView(makeNavItem({ url: "http://example.com" }), "/");
294
409
  expect(view.isExternal).toBe(true);
295
410
  expect(view.isActive).toBe(false);
296
411
  });
412
+
413
+ it("includes type and pageId in view", () => {
414
+ const view = toNavItemView(makeNavItem({ type: "page", pageId: 5 }), "/");
415
+ expect(view.type).toBe("page");
416
+ expect(view.pageId).toBe(5);
417
+ });
418
+
419
+ it("converts null pageId to undefined", () => {
420
+ const view = toNavItemView(makeNavItem({ pageId: null }), "/");
421
+ expect(view.pageId).toBeUndefined();
422
+ });
297
423
  });
298
424
 
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" }),
425
+ describe("toNavItemViews", () => {
426
+ it("converts multiple items", () => {
427
+ const items = [
428
+ makeNavItem({ id: 1, url: "/" }),
429
+ makeNavItem({ id: 2, url: "/archive" }),
430
+ makeNavItem({ id: 3, url: "https://github.com" }),
305
431
  ];
306
- const views = toNavLinkViews(links, "/archive");
432
+ const views = toNavItemViews(items, "/archive");
307
433
  expect(views).toHaveLength(3);
308
- expect(views[0]!.isActive).toBe(false);
309
- expect(views[1]!.isActive).toBe(true);
310
- expect(views[2]!.isExternal).toBe(true);
434
+ expect(views[0]).toHaveProperty("isActive", false);
435
+ expect(views[1]).toHaveProperty("isActive", true);
436
+ expect(views[2]).toHaveProperty("isExternal", true);
311
437
  });
312
438
  });
313
439
 
@@ -329,6 +455,28 @@ describe("toSearchResultView", () => {
329
455
  expect(view.rank).toBe(1.5);
330
456
  expect(view.snippet).toBe("...matching <b>text</b>...");
331
457
  });
458
+
459
+ it("uses new post fields in search result view", () => {
460
+ const result: SearchResult = {
461
+ post: makePost({
462
+ id: 10,
463
+ format: "link",
464
+ status: "published",
465
+ featured: 1,
466
+ pinned: 0,
467
+ url: "https://example.com",
468
+ slug: "my-link",
469
+ }),
470
+ rank: 0.8,
471
+ };
472
+ const view = toSearchResultView(result, EMPTY_CTX);
473
+ expect(view.post.format).toBe("link");
474
+ expect(view.post.status).toBe("published");
475
+ expect(view.post.featured).toBe(true);
476
+ expect(view.post.pinned).toBe(false);
477
+ expect(view.post.url).toBe("https://example.com");
478
+ expect(view.post.permalink).toBe("/my-link");
479
+ });
332
480
  });
333
481
 
334
482
  // =============================================================================
@@ -347,15 +495,16 @@ describe("toArchiveGroups", () => {
347
495
  const groups = toArchiveGroups(grouped, EMPTY_CTX);
348
496
  expect(groups).toHaveLength(2);
349
497
 
350
- expect(groups[0]!.year).toBe("2024");
351
- expect(groups[0]!.month).toBe("02");
352
- expect(groups[0]!.label).toBe("February 2024");
353
- expect(groups[0]!.posts).toHaveLength(2);
498
+ expect(groups[0]).toHaveProperty("year", "2024");
499
+ expect(groups[0]).toHaveProperty("month", "02");
500
+ expect(groups[0]).toHaveProperty("label", "February 2024");
501
+ expect(groups[0]).toHaveProperty("posts");
502
+ expect(groups[0]?.posts).toHaveLength(2);
354
503
 
355
- expect(groups[1]!.year).toBe("2024");
356
- expect(groups[1]!.month).toBe("01");
357
- expect(groups[1]!.label).toBe("January 2024");
358
- expect(groups[1]!.posts).toHaveLength(1);
504
+ expect(groups[1]).toHaveProperty("year", "2024");
505
+ expect(groups[1]).toHaveProperty("month", "01");
506
+ expect(groups[1]).toHaveProperty("label", "January 2024");
507
+ expect(groups[1]?.posts).toHaveLength(1);
359
508
  });
360
509
 
361
510
  it("converts posts to PostView within groups", () => {
@@ -363,9 +512,10 @@ describe("toArchiveGroups", () => {
363
512
  grouped.set("2024-02", [makePost({ id: 1 })]);
364
513
 
365
514
  const groups = toArchiveGroups(grouped, EMPTY_CTX);
366
- const post = groups[0]!.posts[0]!;
367
- expect(post.permalink).toBeDefined();
368
- expect(post.publishedAtFormatted).toBeDefined();
515
+ const post = groups[0]?.posts[0];
516
+ expect(post).toBeDefined();
517
+ expect(post?.permalink).toBeDefined();
518
+ expect(post?.publishedAtFormatted).toBeDefined();
369
519
  });
370
520
 
371
521
  it("handles empty map", () => {
@@ -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",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * HTML Excerpt Utilities
3
+ *
4
+ * Generates paragraph-aware excerpts from HTML content for article
5
+ * previews in timelines. Breaks only at paragraph boundaries.
6
+ */
7
+
8
+ /**
9
+ * Strips HTML tags from a string, returning plain text.
10
+ *
11
+ * @param html - HTML string to strip
12
+ * @returns Plain text without HTML tags
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * stripHtml("<p>Hello <strong>world</strong></p>") // "Hello world"
17
+ * ```
18
+ */
19
+ export function stripHtml(html: string): string {
20
+ return html.replace(/<[^>]*>/g, "");
21
+ }
22
+
23
+ /**
24
+ * Result of extracting an HTML excerpt.
25
+ */
26
+ export interface HtmlExcerpt {
27
+ /** HTML excerpt (complete paragraphs only) */
28
+ excerpt: string;
29
+ /** Whether the original content has more text beyond the excerpt */
30
+ hasMore: boolean;
31
+ }
32
+
33
+ /**
34
+ * Extracts a paragraph-aware HTML excerpt from body HTML.
35
+ *
36
+ * Uses a greedy algorithm: accumulates paragraphs until the total
37
+ * plain-text length exceeds 500 characters, then stops. At least
38
+ * one paragraph is always included.
39
+ *
40
+ * If the content contains a `<!--more-->` marker, the content before
41
+ * the marker is used as the excerpt instead.
42
+ *
43
+ * @param bodyHtml - Full HTML body content
44
+ * @returns Excerpt HTML and whether there is more content
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * // Short content — returned as-is with hasMore = false
49
+ * getHtmlExcerpt("<p>Short post.</p>")
50
+ * // { excerpt: "<p>Short post.</p>", hasMore: false }
51
+ *
52
+ * // Long content — truncated at paragraph boundary
53
+ * getHtmlExcerpt("<p>" + "A".repeat(300) + "</p><p>" + "B".repeat(300) + "</p>")
54
+ * // { excerpt: "<p>AAA...</p>", hasMore: true }
55
+ *
56
+ * // Manual break with <!--more-->
57
+ * getHtmlExcerpt("<p>Intro</p><!--more--><p>Rest</p>")
58
+ * // { excerpt: "<p>Intro</p>", hasMore: true }
59
+ * ```
60
+ */
61
+ export function getHtmlExcerpt(bodyHtml: string): HtmlExcerpt {
62
+ // Honor manual <!--more--> marker
63
+ if (bodyHtml.includes("<!--more-->")) {
64
+ const excerpt = bodyHtml.split("<!--more-->")[0]!;
65
+ return { excerpt, hasMore: true };
66
+ }
67
+
68
+ const paragraphs = bodyHtml.match(/<p>[\s\S]*?<\/p>/g) || [];
69
+
70
+ // No paragraphs found — return full content
71
+ if (paragraphs.length === 0) {
72
+ return { excerpt: bodyHtml, hasMore: false };
73
+ }
74
+
75
+ let excerpt = "";
76
+ let charCount = 0;
77
+
78
+ for (const p of paragraphs) {
79
+ const textLen = stripHtml(p).length;
80
+ if (charCount + textLen > 500 && excerpt) break;
81
+ excerpt += p;
82
+ charCount += textLen;
83
+ }
84
+
85
+ const hasMore = excerpt.length < bodyHtml.length;
86
+ return { excerpt, hasMore };
87
+ }
package/src/lib/feed.ts CHANGED
@@ -51,7 +51,7 @@ export function defaultRssRenderer(data: FeedData): string {
51
51
  <link>${link}</link>
52
52
  <guid isPermaLink="true">${link}</guid>
53
53
  <pubDate>${pubDate}</pubDate>
54
- <description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
54
+ <description><![CDATA[${post.bodyHtml || ""}]]></description>${enclosure}
55
55
  </item>`;
56
56
  })
57
57
  .join("");
@@ -90,7 +90,7 @@ export function defaultAtomRenderer(data: FeedData): string {
90
90
  <id>${link}</id>
91
91
  <published>${post.publishedAt}</published>
92
92
  <updated>${post.updatedAt}</updated>
93
- <content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
93
+ <content type="html"><![CDATA[${post.bodyHtml || ""}]]></content>
94
94
  </entry>`;
95
95
  })
96
96
  .join("");
@@ -112,17 +112,17 @@ export function defaultAtomRenderer(data: FeedData): string {
112
112
  /**
113
113
  * Default Sitemap renderer.
114
114
  *
115
- * @param data - Sitemap data with PostView[] (pre-computed URLs)
115
+ * @param data - Sitemap data with PostView[] and PageView[]
116
116
  * @returns Sitemap XML string
117
117
  */
118
118
  export function defaultSitemapRenderer(data: SitemapData): string {
119
- const { siteUrl, posts } = data;
119
+ const { siteUrl, posts, pages } = data;
120
120
 
121
- const urls = posts
121
+ const postUrls = posts
122
122
  .map((post) => {
123
123
  const loc = `${siteUrl}${post.permalink}`;
124
124
  const lastmod = post.updatedAt.split("T")[0];
125
- const priority = post.visibility === "featured" ? "0.8" : "0.6";
125
+ const priority = post.featured ? "0.8" : "0.6";
126
126
 
127
127
  return `
128
128
  <url>
@@ -133,6 +133,20 @@ export function defaultSitemapRenderer(data: SitemapData): string {
133
133
  })
134
134
  .join("");
135
135
 
136
+ const pageUrls = pages
137
+ .map((page) => {
138
+ const loc = `${siteUrl}/${page.slug}`;
139
+ const lastmod = page.updatedAt.split("T")[0];
140
+
141
+ return `
142
+ <url>
143
+ <loc>${loc}</loc>
144
+ <lastmod>${lastmod}</lastmod>
145
+ <priority>0.7</priority>
146
+ </url>`;
147
+ })
148
+ .join("");
149
+
136
150
  const homepageUrl = `
137
151
  <url>
138
152
  <loc>${siteUrl}/</loc>
@@ -143,6 +157,7 @@ export function defaultSitemapRenderer(data: SitemapData): string {
143
157
  return `<?xml version="1.0" encoding="UTF-8"?>
144
158
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
145
159
  ${homepageUrl}
146
- ${urls}
160
+ ${postUrls}
161
+ ${pageUrls}
147
162
  </urlset>`;
148
163
  }
@@ -6,14 +6,14 @@
6
6
 
7
7
  import type { Context } from "hono";
8
8
  import { getSiteName } from "./config.js";
9
- import type { NavLinkView } from "../types.js";
10
- import { toNavLinkViews } from "./view.js";
9
+ import type { NavItemView } from "../types.js";
10
+ import { toNavItemViews } from "./view.js";
11
11
 
12
12
  /**
13
13
  * Navigation data needed by SiteLayout
14
14
  */
15
15
  export interface NavigationData {
16
- links: NavLinkView[];
16
+ links: NavItemView[];
17
17
  currentPath: string;
18
18
  siteName: string;
19
19
  }
@@ -21,8 +21,7 @@ export interface NavigationData {
21
21
  /**
22
22
  * Fetch navigation data for public pages.
23
23
  *
24
- * Ensures default links exist (Home, Archive, RSS) and returns
25
- * NavLinkView[] with pre-computed isActive/isExternal state.
24
+ * Returns NavItemView[] with pre-computed isActive/isExternal state.
26
25
  *
27
26
  * @param c - Hono context
28
27
  * @returns Navigation data for SiteLayout
@@ -38,9 +37,9 @@ export interface NavigationData {
38
37
  * ```
39
38
  */
40
39
  export async function getNavigationData(c: Context): Promise<NavigationData> {
41
- const navigationLinks = await c.var.services.navigationLinks.ensureDefaults();
40
+ const items = await c.var.services.navItems.list();
42
41
  const currentPath = new URL(c.req.url).pathname;
43
42
  const siteName = await getSiteName(c);
44
- const links = toNavLinkViews(navigationLinks, currentPath);
43
+ const links = toNavItemViews(items, currentPath);
45
44
  return { links, currentPath, siteName };
46
45
  }
@@ -12,7 +12,7 @@ import type { Context } from "hono";
12
12
  import type { Child } from "hono/jsx";
13
13
  import type { ThemeComponents, SiteLayoutProps } from "../types.js";
14
14
  import { BaseLayout } from "../theme/layouts/BaseLayout.js";
15
- import { SiteLayout as DefaultSiteLayout } from "../theme/layouts/SiteLayout.js";
15
+ import { ThreadsSiteLayout as DefaultSiteLayout } from "../themes/threads/ThreadsSiteLayout.js";
16
16
  import type { NavigationData } from "./navigation.js";
17
17
 
18
18
  export interface RenderPublicPageOptions {