@jant/core 0.3.23 → 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 (169) hide show
  1. package/dist/app.js +4 -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 +3 -3
  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 +61 -72
  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/themes/threads/ThreadsSiteLayout.js +172 -0
  47. package/dist/themes/threads/index.js +81 -0
  48. package/dist/themes/{minimal → threads}/pages/ArchivePage.js +32 -47
  49. package/dist/themes/threads/pages/CollectionPage.js +65 -0
  50. package/dist/themes/{minimal → threads}/pages/HomePage.js +3 -3
  51. package/dist/themes/{minimal → threads}/pages/PostPage.js +12 -9
  52. package/dist/themes/{minimal → threads}/pages/SearchPage.js +13 -14
  53. package/dist/themes/{minimal → threads}/pages/SinglePage.js +4 -4
  54. package/dist/themes/threads/timeline/LinkCard.js +68 -0
  55. package/dist/themes/threads/timeline/NoteCard.js +53 -0
  56. package/dist/themes/threads/timeline/QuoteCard.js +59 -0
  57. package/dist/themes/{minimal → threads}/timeline/ThreadPreview.js +17 -13
  58. package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
  59. package/dist/themes/{minimal → threads}/timeline/TimelineItem.js +8 -16
  60. package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
  61. package/dist/themes/threads/timeline/groupByDate.js +22 -0
  62. package/dist/themes/threads/timeline/timelineMore.js +107 -0
  63. package/dist/types.js +24 -40
  64. package/package.json +2 -1
  65. package/src/__tests__/helpers/app.ts +4 -0
  66. package/src/__tests__/helpers/db.ts +51 -74
  67. package/src/app.tsx +4 -6
  68. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  69. package/src/db/migrations/meta/_journal.json +7 -0
  70. package/src/db/schema.ts +63 -46
  71. package/src/i18n/locales/en.po +216 -164
  72. package/src/i18n/locales/en.ts +1 -1
  73. package/src/i18n/locales/zh-Hans.po +216 -164
  74. package/src/i18n/locales/zh-Hans.ts +1 -1
  75. package/src/i18n/locales/zh-Hant.po +216 -164
  76. package/src/i18n/locales/zh-Hant.ts +1 -1
  77. package/src/index.ts +28 -12
  78. package/src/lib/__tests__/excerpt.test.ts +125 -0
  79. package/src/lib/__tests__/schemas.test.ts +166 -105
  80. package/src/lib/__tests__/theme-components.test.ts +4 -25
  81. package/src/lib/__tests__/time.test.ts +62 -0
  82. package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
  83. package/src/lib/__tests__/view.test.ts +199 -51
  84. package/src/lib/constants.ts +1 -4
  85. package/src/lib/excerpt.ts +87 -0
  86. package/src/lib/feed.ts +22 -7
  87. package/src/lib/navigation.ts +6 -7
  88. package/src/lib/render.tsx +1 -1
  89. package/src/lib/schemas.ts +118 -52
  90. package/src/lib/theme-components.ts +10 -13
  91. package/src/lib/time.ts +64 -0
  92. package/src/lib/timeline.ts +170 -0
  93. package/src/lib/view.ts +80 -82
  94. package/src/preset.css +45 -0
  95. package/src/routes/api/__tests__/posts.test.ts +50 -108
  96. package/src/routes/api/__tests__/search.test.ts +2 -3
  97. package/src/routes/api/posts.ts +30 -30
  98. package/src/routes/api/search.ts +4 -4
  99. package/src/routes/api/upload.ts +16 -6
  100. package/src/routes/dash/collections.tsx +18 -40
  101. package/src/routes/dash/index.tsx +2 -2
  102. package/src/routes/dash/navigation.tsx +27 -26
  103. package/src/routes/dash/pages.tsx +45 -60
  104. package/src/routes/dash/posts.tsx +44 -52
  105. package/src/routes/feed/rss.ts +2 -1
  106. package/src/routes/feed/sitemap.ts +14 -4
  107. package/src/routes/pages/archive.tsx +14 -10
  108. package/src/routes/pages/collection.tsx +17 -6
  109. package/src/routes/pages/home.tsx +56 -81
  110. package/src/routes/pages/page.tsx +64 -27
  111. package/src/routes/pages/post.tsx +5 -14
  112. package/src/routes/pages/search.tsx +2 -2
  113. package/src/services/__tests__/collection.test.ts +257 -158
  114. package/src/services/__tests__/media.test.ts +18 -18
  115. package/src/services/__tests__/navigation.test.ts +161 -87
  116. package/src/services/__tests__/post-timeline.test.ts +92 -88
  117. package/src/services/__tests__/post.test.ts +342 -206
  118. package/src/services/__tests__/search.test.ts +19 -25
  119. package/src/services/collection.ts +71 -113
  120. package/src/services/index.ts +9 -8
  121. package/src/services/navigation.ts +38 -71
  122. package/src/services/page.ts +124 -0
  123. package/src/services/post.ts +93 -103
  124. package/src/services/search.ts +38 -27
  125. package/src/theme/components/MediaGallery.tsx +27 -96
  126. package/src/theme/components/PageForm.tsx +21 -21
  127. package/src/theme/components/PostForm.tsx +122 -118
  128. package/src/theme/components/PostList.tsx +58 -49
  129. package/src/theme/components/ThreadView.tsx +6 -3
  130. package/src/theme/components/TypeBadge.tsx +9 -17
  131. package/src/theme/components/VisibilityBadge.tsx +40 -23
  132. package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
  133. package/src/themes/{minimal → threads}/index.ts +30 -13
  134. package/src/themes/{minimal → threads}/pages/ArchivePage.tsx +53 -53
  135. package/src/themes/threads/pages/CollectionPage.tsx +61 -0
  136. package/src/themes/{minimal → threads}/pages/HomePage.tsx +3 -3
  137. package/src/themes/{minimal → threads}/pages/PostPage.tsx +12 -8
  138. package/src/themes/{minimal → threads}/pages/SearchPage.tsx +15 -13
  139. package/src/themes/{minimal → threads}/pages/SinglePage.tsx +4 -4
  140. package/src/themes/threads/style.css +336 -0
  141. package/src/themes/threads/timeline/LinkCard.tsx +67 -0
  142. package/src/themes/threads/timeline/NoteCard.tsx +58 -0
  143. package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
  144. package/src/themes/{minimal → threads}/timeline/ThreadPreview.tsx +15 -13
  145. package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
  146. package/src/themes/{minimal → threads}/timeline/TimelineItem.tsx +9 -17
  147. package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
  148. package/src/themes/threads/timeline/groupByDate.ts +30 -0
  149. package/src/themes/threads/timeline/timelineMore.tsx +130 -0
  150. package/src/types.ts +242 -98
  151. package/dist/routes/api/timeline.js +0 -120
  152. package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
  153. package/dist/themes/minimal/index.js +0 -65
  154. package/dist/themes/minimal/pages/CollectionPage.js +0 -65
  155. package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
  156. package/dist/themes/minimal/timeline/ImageCard.js +0 -67
  157. package/dist/themes/minimal/timeline/LinkCard.js +0 -47
  158. package/dist/themes/minimal/timeline/NoteCard.js +0 -34
  159. package/dist/themes/minimal/timeline/QuoteCard.js +0 -48
  160. package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
  161. package/src/routes/api/timeline.tsx +0 -159
  162. package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
  163. package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
  164. package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
  165. package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
  166. package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
  167. package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
  168. package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
  169. package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
@@ -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", () => {
@@ -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,20 +405,31 @@ 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
434
  expect(views[0]).toHaveProperty("isActive", false);
309
435
  expect(views[1]).toHaveProperty("isActive", true);
@@ -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
  // =============================================================================
@@ -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 "../themes/minimal/MinimalSiteLayout.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 {