@jant/core 0.3.20 → 0.3.22

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 (94) hide show
  1. package/dist/app.js +60 -17
  2. package/dist/index.js +8 -0
  3. package/dist/lib/feed.js +112 -0
  4. package/dist/lib/navigation.js +9 -9
  5. package/dist/lib/render.js +48 -0
  6. package/dist/lib/theme-components.js +18 -18
  7. package/dist/lib/view.js +228 -0
  8. package/dist/routes/api/timeline.js +20 -16
  9. package/dist/routes/dash/collections.js +38 -10
  10. package/dist/routes/dash/navigation.js +22 -8
  11. package/dist/routes/dash/redirects.js +19 -5
  12. package/dist/routes/dash/settings.js +57 -15
  13. package/dist/routes/feed/rss.js +34 -78
  14. package/dist/routes/feed/sitemap.js +11 -26
  15. package/dist/routes/pages/archive.js +18 -195
  16. package/dist/routes/pages/collection.js +16 -70
  17. package/dist/routes/pages/home.js +25 -47
  18. package/dist/routes/pages/page.js +15 -27
  19. package/dist/routes/pages/post.js +25 -79
  20. package/dist/routes/pages/search.js +20 -130
  21. package/dist/theme/components/MediaGallery.js +10 -10
  22. package/dist/theme/components/PageForm.js +22 -8
  23. package/dist/theme/components/PostForm.js +22 -8
  24. package/dist/theme/components/index.js +1 -1
  25. package/dist/theme/components/timeline/ArticleCard.js +7 -11
  26. package/dist/theme/components/timeline/ImageCard.js +10 -13
  27. package/dist/theme/components/timeline/LinkCard.js +4 -7
  28. package/dist/theme/components/timeline/NoteCard.js +5 -8
  29. package/dist/theme/components/timeline/QuoteCard.js +3 -6
  30. package/dist/theme/components/timeline/ThreadPreview.js +9 -10
  31. package/dist/theme/components/timeline/TimelineFeed.js +8 -5
  32. package/dist/theme/components/timeline/TimelineItem.js +22 -2
  33. package/dist/theme/components/timeline/index.js +1 -1
  34. package/dist/theme/index.js +6 -3
  35. package/dist/theme/layouts/SiteLayout.js +10 -39
  36. package/dist/theme/pages/ArchivePage.js +157 -0
  37. package/dist/theme/pages/CollectionPage.js +63 -0
  38. package/dist/theme/pages/HomePage.js +26 -0
  39. package/dist/theme/pages/PostPage.js +48 -0
  40. package/dist/theme/pages/SearchPage.js +120 -0
  41. package/dist/theme/pages/SinglePage.js +23 -0
  42. package/dist/theme/pages/index.js +11 -0
  43. package/package.json +2 -1
  44. package/src/app.tsx +48 -17
  45. package/src/i18n/locales/en.po +171 -147
  46. package/src/i18n/locales/zh-Hans.po +171 -147
  47. package/src/i18n/locales/zh-Hant.po +171 -147
  48. package/src/index.ts +51 -2
  49. package/src/lib/__tests__/theme-components.test.ts +33 -14
  50. package/src/lib/__tests__/view.test.ts +375 -0
  51. package/src/lib/feed.ts +148 -0
  52. package/src/lib/navigation.ts +11 -11
  53. package/src/lib/render.tsx +67 -0
  54. package/src/lib/theme-components.ts +27 -35
  55. package/src/lib/view.ts +318 -0
  56. package/src/routes/api/__tests__/timeline.test.ts +3 -3
  57. package/src/routes/api/timeline.tsx +32 -25
  58. package/src/routes/dash/collections.tsx +30 -10
  59. package/src/routes/dash/navigation.tsx +20 -10
  60. package/src/routes/dash/redirects.tsx +15 -5
  61. package/src/routes/dash/settings.tsx +53 -15
  62. package/src/routes/feed/rss.ts +47 -94
  63. package/src/routes/feed/sitemap.ts +8 -30
  64. package/src/routes/pages/archive.tsx +24 -209
  65. package/src/routes/pages/collection.tsx +19 -75
  66. package/src/routes/pages/home.tsx +42 -76
  67. package/src/routes/pages/page.tsx +17 -28
  68. package/src/routes/pages/post.tsx +28 -86
  69. package/src/routes/pages/search.tsx +29 -151
  70. package/src/services/search.ts +2 -8
  71. package/src/theme/components/MediaGallery.tsx +12 -12
  72. package/src/theme/components/PageForm.tsx +20 -10
  73. package/src/theme/components/PostForm.tsx +20 -10
  74. package/src/theme/components/index.ts +1 -0
  75. package/src/theme/components/timeline/ArticleCard.tsx +7 -19
  76. package/src/theme/components/timeline/ImageCard.tsx +10 -20
  77. package/src/theme/components/timeline/LinkCard.tsx +4 -11
  78. package/src/theme/components/timeline/NoteCard.tsx +5 -12
  79. package/src/theme/components/timeline/QuoteCard.tsx +3 -10
  80. package/src/theme/components/timeline/ThreadPreview.tsx +5 -5
  81. package/src/theme/components/timeline/TimelineFeed.tsx +7 -3
  82. package/src/theme/components/timeline/TimelineItem.tsx +43 -4
  83. package/src/theme/components/timeline/index.ts +1 -1
  84. package/src/theme/index.ts +7 -3
  85. package/src/theme/layouts/SiteLayout.tsx +25 -77
  86. package/src/theme/layouts/index.ts +2 -1
  87. package/src/theme/pages/ArchivePage.tsx +160 -0
  88. package/src/theme/pages/CollectionPage.tsx +60 -0
  89. package/src/theme/pages/HomePage.tsx +42 -0
  90. package/src/theme/pages/PostPage.tsx +44 -0
  91. package/src/theme/pages/SearchPage.tsx +128 -0
  92. package/src/theme/pages/SinglePage.tsx +24 -0
  93. package/src/theme/pages/index.ts +13 -0
  94. package/src/types.ts +262 -38
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import { createApp as _createApp } from "./app.js";
10
10
  export { createApp } from "./app.js";
11
11
  export type { App, AppVariables } from "./app.js";
12
12
 
13
- // Types (excluding component props to avoid conflicts with theme exports)
13
+ // Types
14
14
  export type {
15
15
  PostType,
16
16
  Visibility,
@@ -29,10 +29,31 @@ export type {
29
29
  JantConfig,
30
30
  JantTheme,
31
31
  ThemeComponents,
32
+ // View Model types (for theme authors)
33
+ PostView,
34
+ MediaView,
35
+ NavLinkView,
36
+ SearchResultView,
37
+ TimelineItemView,
38
+ ArchiveGroup,
39
+ // Timeline types
32
40
  TimelineCardProps,
33
41
  ThreadPreviewProps,
34
- TimelineItemData,
35
42
  TimelineFeedProps,
43
+ // Site layout
44
+ SiteLayoutProps,
45
+ // Page-level props (for theme authors)
46
+ HomePageProps,
47
+ PostPageProps,
48
+ SinglePageProps,
49
+ ArchivePageProps,
50
+ SearchPageProps,
51
+ CollectionPageProps,
52
+ // Feed types (for theme authors)
53
+ FeedData,
54
+ SitemapData,
55
+ // Search
56
+ SearchResult,
36
57
  } from "./types.js";
37
58
 
38
59
  export {
@@ -48,5 +69,33 @@ export * as sqid from "./lib/sqid.js";
48
69
  export * as url from "./lib/url.js";
49
70
  export * as markdown from "./lib/markdown.js";
50
71
 
72
+ // View Model conversion utilities (for advanced theme use)
73
+ export {
74
+ createMediaContext,
75
+ toPostView,
76
+ toPostViews,
77
+ toMediaView,
78
+ toNavLinkView,
79
+ toNavLinkViews,
80
+ toSearchResultView,
81
+ toArchiveGroups,
82
+ } from "./lib/view.js";
83
+ export type { MediaContext } from "./lib/view.js";
84
+
85
+ // Render helper (for theme authors adding custom routes)
86
+ export { renderPublicPage } from "./lib/render.js";
87
+ export type { RenderPublicPageOptions } from "./lib/render.js";
88
+
89
+ // Navigation helper (for theme authors)
90
+ export { getNavigationData } from "./lib/navigation.js";
91
+ export type { NavigationData } from "./lib/navigation.js";
92
+
93
+ // Default feed renderers (for theme authors to extend)
94
+ export {
95
+ defaultRssRenderer,
96
+ defaultAtomRenderer,
97
+ defaultSitemapRenderer,
98
+ } from "./lib/feed.js";
99
+
51
100
  // Default export for running core directly (e.g., for development)
52
101
  export default _createApp();
@@ -1,15 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import {
3
- resolveCardComponent,
4
- resolveThreadPreview,
5
- resolveTimelineFeed,
6
- } from "../theme-components.js";
2
+ import { resolveCardComponent, resolveComponent } from "../theme-components.js";
7
3
  import type {
8
4
  ThemeComponents,
9
5
  TimelineCardProps,
10
6
  ThreadPreviewProps,
11
7
  TimelineFeedProps,
12
8
  PostType,
9
+ HomePageProps,
13
10
  } from "../../types.js";
14
11
  import type { FC } from "hono/jsx";
15
12
 
@@ -21,6 +18,7 @@ const MockQuoteCard: FC<TimelineCardProps> = () => null;
21
18
  const MockImageCard: FC<TimelineCardProps> = () => null;
22
19
  const MockThreadPreview: FC<ThreadPreviewProps> = () => null;
23
20
  const MockTimelineFeed: FC<TimelineFeedProps> = () => null;
21
+ const MockHomePage: FC<HomePageProps> = () => null;
24
22
 
25
23
  const DEFAULT_CARD_MAP: Record<PostType, FC<TimelineCardProps>> = {
26
24
  note: MockNoteCard,
@@ -79,29 +77,50 @@ describe("theme-components", () => {
79
77
  });
80
78
  });
81
79
 
82
- describe("resolveThreadPreview", () => {
80
+ describe("resolveComponent", () => {
83
81
  it("returns default ThreadPreview when no override", () => {
84
- expect(resolveThreadPreview(MockThreadPreview)).toBe(MockThreadPreview);
82
+ expect(resolveComponent("ThreadPreview", MockThreadPreview)).toBe(
83
+ MockThreadPreview,
84
+ );
85
85
  });
86
86
 
87
- it("returns theme override when provided", () => {
87
+ it("returns theme override for ThreadPreview when provided", () => {
88
88
  const Custom: FC<ThreadPreviewProps> = () => null;
89
89
  expect(
90
- resolveThreadPreview(MockThreadPreview, { ThreadPreview: Custom }),
90
+ resolveComponent("ThreadPreview", MockThreadPreview, {
91
+ ThreadPreview: Custom,
92
+ }),
91
93
  ).toBe(Custom);
92
94
  });
93
- });
94
95
 
95
- describe("resolveTimelineFeed", () => {
96
96
  it("returns default TimelineFeed when no override", () => {
97
- expect(resolveTimelineFeed(MockTimelineFeed)).toBe(MockTimelineFeed);
97
+ expect(resolveComponent("TimelineFeed", MockTimelineFeed)).toBe(
98
+ MockTimelineFeed,
99
+ );
98
100
  });
99
101
 
100
- it("returns theme override when provided", () => {
102
+ it("returns theme override for TimelineFeed when provided", () => {
101
103
  const Custom: FC<TimelineFeedProps> = () => null;
102
104
  expect(
103
- resolveTimelineFeed(MockTimelineFeed, { TimelineFeed: Custom }),
105
+ resolveComponent("TimelineFeed", MockTimelineFeed, {
106
+ TimelineFeed: Custom,
107
+ }),
104
108
  ).toBe(Custom);
105
109
  });
110
+
111
+ it("returns default HomePage when no override", () => {
112
+ expect(resolveComponent("HomePage", MockHomePage)).toBe(MockHomePage);
113
+ });
114
+
115
+ it("returns theme override for HomePage when provided", () => {
116
+ const Custom: FC<HomePageProps> = () => null;
117
+ expect(
118
+ resolveComponent("HomePage", MockHomePage, { HomePage: Custom }),
119
+ ).toBe(Custom);
120
+ });
121
+
122
+ it("returns default when theme has empty overrides", () => {
123
+ expect(resolveComponent("HomePage", MockHomePage, {})).toBe(MockHomePage);
124
+ });
106
125
  });
107
126
  });
@@ -0,0 +1,375 @@
1
+ /**
2
+ * View Model Conversion Tests
3
+ */
4
+
5
+ import { describe, it, expect } from "vitest";
6
+ import {
7
+ toPostView,
8
+ toPostViews,
9
+ toMediaView,
10
+ toNavLinkView,
11
+ toNavLinkViews,
12
+ toSearchResultView,
13
+ toArchiveGroups,
14
+ } from "../view.js";
15
+ import type { MediaContext } from "../view.js";
16
+ import type {
17
+ PostWithMedia,
18
+ Media,
19
+ NavigationLink,
20
+ SearchResult,
21
+ Post,
22
+ } from "../../types.js";
23
+
24
+ const EMPTY_CTX: MediaContext = {};
25
+ const CTX_WITH_URLS: MediaContext = {
26
+ r2PublicUrl: "https://cdn.example.com",
27
+ imageTransformUrl: "https://example.com/cdn-cgi/image",
28
+ };
29
+
30
+ function makePost(overrides: Partial<Post> = {}): Post {
31
+ return {
32
+ id: 1,
33
+ type: "note",
34
+ visibility: "featured",
35
+ title: null,
36
+ path: null,
37
+ content: "Hello world",
38
+ contentHtml: "<p>Hello world</p>",
39
+ sourceUrl: null,
40
+ sourceName: null,
41
+ sourceDomain: null,
42
+ replyToId: null,
43
+ threadId: null,
44
+ deletedAt: null,
45
+ publishedAt: 1706745600, // 2024-02-01T00:00:00Z
46
+ createdAt: 1706745600,
47
+ updatedAt: 1706745600,
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ function makePostWithMedia(
53
+ overrides: Partial<PostWithMedia> = {},
54
+ ): PostWithMedia {
55
+ return {
56
+ ...makePost(overrides),
57
+ mediaAttachments: overrides.mediaAttachments ?? [],
58
+ };
59
+ }
60
+
61
+ function makeMedia(overrides: Partial<Media> = {}): Media {
62
+ return {
63
+ id: "01902a9f-1a2b-7c3d",
64
+ postId: 1,
65
+ filename: "image.webp",
66
+ originalName: "photo.jpg",
67
+ mimeType: "image/webp",
68
+ size: 12345,
69
+ storageKey: "media/2025/01/01902a9f-1a2b-7c3d.webp",
70
+ provider: "r2",
71
+ width: 1920,
72
+ height: 1080,
73
+ alt: "A photo",
74
+ position: 0,
75
+ blurhash: null,
76
+ createdAt: 1706745600,
77
+ ...overrides,
78
+ };
79
+ }
80
+
81
+ function makeNavLink(overrides: Partial<NavigationLink> = {}): NavigationLink {
82
+ return {
83
+ id: 1,
84
+ label: "Home",
85
+ url: "/",
86
+ position: 0,
87
+ createdAt: 1706745600,
88
+ updatedAt: 1706745600,
89
+ ...overrides,
90
+ };
91
+ }
92
+
93
+ // =============================================================================
94
+ // toPostView
95
+ // =============================================================================
96
+
97
+ describe("toPostView", () => {
98
+ it("generates permalink from post id", () => {
99
+ const post = makePostWithMedia({ id: 123 });
100
+ const view = toPostView(post, EMPTY_CTX);
101
+ expect(view.permalink).toMatch(/^\/p\/.+$/);
102
+ expect(view.permalink.length).toBeGreaterThan(3);
103
+ });
104
+
105
+ it("formats dates correctly", () => {
106
+ const post = makePostWithMedia({ publishedAt: 1706745600 });
107
+ const view = toPostView(post, EMPTY_CTX);
108
+ expect(view.publishedAt).toBe("2024-02-01T00:00:00.000Z");
109
+ expect(view.publishedAtFormatted).toBe("Feb 1, 2024");
110
+ });
111
+
112
+ it("generates excerpt from content", () => {
113
+ const shortContent = "Short text";
114
+ const longContent = "A".repeat(200);
115
+
116
+ const shortView = toPostView(
117
+ makePostWithMedia({ content: shortContent }),
118
+ EMPTY_CTX,
119
+ );
120
+ expect(shortView.excerpt).toBe("Short text");
121
+
122
+ const longView = toPostView(
123
+ makePostWithMedia({ content: longContent }),
124
+ EMPTY_CTX,
125
+ );
126
+ expect(longView.excerpt).toBe("A".repeat(160) + "...");
127
+ });
128
+
129
+ it("handles null content gracefully", () => {
130
+ const view = toPostView(makePostWithMedia({ content: null }), EMPTY_CTX);
131
+ expect(view.excerpt).toBeUndefined();
132
+ expect(view.content).toBeUndefined();
133
+ });
134
+
135
+ it("converts null fields to undefined", () => {
136
+ const view = toPostView(makePostWithMedia(), EMPTY_CTX);
137
+ expect(view.title).toBeUndefined();
138
+ expect(view.path).toBeUndefined();
139
+ expect(view.sourceUrl).toBeUndefined();
140
+ expect(view.sourceName).toBeUndefined();
141
+ expect(view.sourceDomain).toBeUndefined();
142
+ expect(view.replyToId).toBeUndefined();
143
+ expect(view.threadRootId).toBeUndefined();
144
+ });
145
+
146
+ it("preserves non-null source fields", () => {
147
+ const view = toPostView(
148
+ makePostWithMedia({
149
+ sourceUrl: "https://example.com",
150
+ sourceName: "Example",
151
+ sourceDomain: "example.com",
152
+ }),
153
+ EMPTY_CTX,
154
+ );
155
+ expect(view.sourceUrl).toBe("https://example.com");
156
+ expect(view.sourceName).toBe("Example");
157
+ expect(view.sourceDomain).toBe("example.com");
158
+ });
159
+
160
+ it("converts media attachments to MediaView", () => {
161
+ const view = toPostView(
162
+ makePostWithMedia({
163
+ mediaAttachments: [
164
+ {
165
+ id: "abc",
166
+ url: "/media/abc.webp",
167
+ previewUrl: "/media/abc-thumb.webp",
168
+ alt: "Photo",
169
+ blurhash: null,
170
+ width: 800,
171
+ height: 600,
172
+ position: 0,
173
+ mimeType: "image/webp",
174
+ },
175
+ ],
176
+ }),
177
+ EMPTY_CTX,
178
+ );
179
+ expect(view.media).toHaveLength(1);
180
+ expect(view.media[0]).toEqual({
181
+ id: "abc",
182
+ url: "/media/abc.webp",
183
+ thumbnailUrl: "/media/abc-thumb.webp",
184
+ mimeType: "image/webp",
185
+ altText: "Photo",
186
+ width: 800,
187
+ height: 600,
188
+ });
189
+ });
190
+ });
191
+
192
+ describe("toPostViews", () => {
193
+ it("converts multiple posts", () => {
194
+ const posts = [makePostWithMedia({ id: 1 }), makePostWithMedia({ id: 2 })];
195
+ const views = toPostViews(posts, EMPTY_CTX);
196
+ expect(views).toHaveLength(2);
197
+ expect(views[0]!.id).toBe(1);
198
+ expect(views[1]!.id).toBe(2);
199
+ });
200
+ });
201
+
202
+ // =============================================================================
203
+ // toMediaView
204
+ // =============================================================================
205
+
206
+ describe("toMediaView", () => {
207
+ it("generates local proxy URL without public URL", () => {
208
+ const media = makeMedia();
209
+ const view = toMediaView(media, EMPTY_CTX);
210
+ expect(view.url).toBe("/media/01902a9f-1a2b-7c3d.webp");
211
+ expect(view.thumbnailUrl).toBe("/media/01902a9f-1a2b-7c3d.webp");
212
+ });
213
+
214
+ it("generates CDN URL with public URL", () => {
215
+ const media = makeMedia();
216
+ const view = toMediaView(media, CTX_WITH_URLS);
217
+ expect(view.url).toBe(
218
+ "https://cdn.example.com/media/2025/01/01902a9f-1a2b-7c3d.webp",
219
+ );
220
+ expect(view.thumbnailUrl).toContain("cdn-cgi/image");
221
+ });
222
+
223
+ it("uses S3 URL for s3 provider", () => {
224
+ const media = makeMedia({ provider: "s3" });
225
+ const ctx: MediaContext = {
226
+ r2PublicUrl: "https://r2.example.com",
227
+ s3PublicUrl: "https://s3.example.com",
228
+ };
229
+ const view = toMediaView(media, ctx);
230
+ expect(view.url).toContain("s3.example.com");
231
+ });
232
+
233
+ it("maps alt text and dimensions", () => {
234
+ const view = toMediaView(makeMedia(), EMPTY_CTX);
235
+ expect(view.altText).toBe("A photo");
236
+ expect(view.width).toBe(1920);
237
+ expect(view.height).toBe(1080);
238
+ expect(view.mimeType).toBe("image/webp");
239
+ expect(view.size).toBe(12345);
240
+ });
241
+
242
+ it("handles null alt and dimensions", () => {
243
+ const media = makeMedia({ alt: null, width: null, height: null });
244
+ const view = toMediaView(media, EMPTY_CTX);
245
+ expect(view.altText).toBeUndefined();
246
+ expect(view.width).toBeUndefined();
247
+ expect(view.height).toBeUndefined();
248
+ });
249
+ });
250
+
251
+ // =============================================================================
252
+ // toNavLinkView
253
+ // =============================================================================
254
+
255
+ describe("toNavLinkView", () => {
256
+ it("marks home link active on exact / match", () => {
257
+ const view = toNavLinkView(makeNavLink({ url: "/" }), "/");
258
+ expect(view.isActive).toBe(true);
259
+ expect(view.isExternal).toBe(false);
260
+ });
261
+
262
+ it("marks home link inactive on other paths", () => {
263
+ const view = toNavLinkView(makeNavLink({ url: "/" }), "/archive");
264
+ expect(view.isActive).toBe(false);
265
+ });
266
+
267
+ it("matches prefix for non-root links", () => {
268
+ const view = toNavLinkView(makeNavLink({ url: "/archive" }), "/archive");
269
+ expect(view.isActive).toBe(true);
270
+
271
+ const viewSub = toNavLinkView(
272
+ makeNavLink({ url: "/archive" }),
273
+ "/archive/2024",
274
+ );
275
+ expect(viewSub.isActive).toBe(true);
276
+ });
277
+
278
+ it("does not false-match similar prefixes", () => {
279
+ const view = toNavLinkView(makeNavLink({ url: "/arch" }), "/archive");
280
+ expect(view.isActive).toBe(false);
281
+ });
282
+
283
+ it("marks external links as external and never active", () => {
284
+ const view = toNavLinkView(
285
+ makeNavLink({ url: "https://example.com" }),
286
+ "/",
287
+ );
288
+ expect(view.isExternal).toBe(true);
289
+ expect(view.isActive).toBe(false);
290
+ });
291
+
292
+ it("handles http:// links", () => {
293
+ const view = toNavLinkView(makeNavLink({ url: "http://example.com" }), "/");
294
+ expect(view.isExternal).toBe(true);
295
+ expect(view.isActive).toBe(false);
296
+ });
297
+ });
298
+
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" }),
305
+ ];
306
+ const views = toNavLinkViews(links, "/archive");
307
+ 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);
311
+ });
312
+ });
313
+
314
+ // =============================================================================
315
+ // toSearchResultView
316
+ // =============================================================================
317
+
318
+ describe("toSearchResultView", () => {
319
+ it("wraps post in PostView", () => {
320
+ const result: SearchResult = {
321
+ post: makePost({ id: 42, title: "Test" }),
322
+ rank: 1.5,
323
+ snippet: "...matching <b>text</b>...",
324
+ };
325
+ const view = toSearchResultView(result, EMPTY_CTX);
326
+ expect(view.post.id).toBe(42);
327
+ expect(view.post.title).toBe("Test");
328
+ expect(view.post.permalink).toBeDefined();
329
+ expect(view.rank).toBe(1.5);
330
+ expect(view.snippet).toBe("...matching <b>text</b>...");
331
+ });
332
+ });
333
+
334
+ // =============================================================================
335
+ // toArchiveGroups
336
+ // =============================================================================
337
+
338
+ describe("toArchiveGroups", () => {
339
+ it("converts grouped map to ArchiveGroup array", () => {
340
+ const grouped = new Map<string, Post[]>();
341
+ grouped.set("2024-02", [
342
+ makePost({ id: 1, publishedAt: 1706745600 }),
343
+ makePost({ id: 2, publishedAt: 1706832000 }),
344
+ ]);
345
+ grouped.set("2024-01", [makePost({ id: 3, publishedAt: 1704067200 })]);
346
+
347
+ const groups = toArchiveGroups(grouped, EMPTY_CTX);
348
+ expect(groups).toHaveLength(2);
349
+
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);
354
+
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);
359
+ });
360
+
361
+ it("converts posts to PostView within groups", () => {
362
+ const grouped = new Map<string, Post[]>();
363
+ grouped.set("2024-02", [makePost({ id: 1 })]);
364
+
365
+ const groups = toArchiveGroups(grouped, EMPTY_CTX);
366
+ const post = groups[0]!.posts[0]!;
367
+ expect(post.permalink).toBeDefined();
368
+ expect(post.publishedAtFormatted).toBeDefined();
369
+ });
370
+
371
+ it("handles empty map", () => {
372
+ const groups = toArchiveGroups(new Map(), EMPTY_CTX);
373
+ expect(groups).toHaveLength(0);
374
+ });
375
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Default Feed Renderers
3
+ *
4
+ * RSS 2.0, Atom, and Sitemap XML generators.
5
+ * Theme authors can import these to extend/wrap the defaults:
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { defaultRssRenderer } from "@jant/core/lib/feed";
10
+ * ```
11
+ */
12
+
13
+ import type { FeedData, SitemapData } from "../types.js";
14
+
15
+ /**
16
+ * Escape special XML characters.
17
+ */
18
+ function escapeXml(str: string): string {
19
+ return str
20
+ .replace(/&/g, "&amp;")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;")
24
+ .replace(/'/g, "&apos;");
25
+ }
26
+
27
+ /**
28
+ * Default RSS 2.0 renderer.
29
+ *
30
+ * @param data - Feed data with PostView[] (pre-computed URLs)
31
+ * @returns RSS 2.0 XML string
32
+ */
33
+ export function defaultRssRenderer(data: FeedData): string {
34
+ const { siteName, siteDescription, siteUrl, siteLanguage, posts } = data;
35
+
36
+ const items = posts
37
+ .map((post) => {
38
+ const link = `${siteUrl}${post.permalink}`;
39
+ const title = post.title || `Post #${post.id}`;
40
+ const pubDate = new Date(post.publishedAt).toUTCString();
41
+
42
+ // Add enclosure for first media attachment
43
+ const firstMedia = post.media[0];
44
+ const enclosure = firstMedia
45
+ ? `\n <enclosure url="${firstMedia.url}" type="${firstMedia.mimeType}"${firstMedia.size ? ` length="${firstMedia.size}"` : ""}/>`
46
+ : "";
47
+
48
+ return `
49
+ <item>
50
+ <title><![CDATA[${escapeXml(title)}]]></title>
51
+ <link>${link}</link>
52
+ <guid isPermaLink="true">${link}</guid>
53
+ <pubDate>${pubDate}</pubDate>
54
+ <description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
55
+ </item>`;
56
+ })
57
+ .join("");
58
+
59
+ return `<?xml version="1.0" encoding="UTF-8"?>
60
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
61
+ <channel>
62
+ <title>${escapeXml(siteName)}</title>
63
+ <link>${siteUrl}</link>
64
+ <description>${escapeXml(siteDescription)}</description>
65
+ <language>${siteLanguage}</language>
66
+ <atom:link href="${siteUrl}/feed" rel="self" type="application/rss+xml"/>
67
+ ${items}
68
+ </channel>
69
+ </rss>`;
70
+ }
71
+
72
+ /**
73
+ * Default Atom renderer.
74
+ *
75
+ * @param data - Feed data with PostView[] (pre-computed URLs)
76
+ * @returns Atom XML string
77
+ */
78
+ export function defaultAtomRenderer(data: FeedData): string {
79
+ const { siteName, siteDescription, siteUrl, posts } = data;
80
+
81
+ const entries = posts
82
+ .map((post) => {
83
+ const link = `${siteUrl}${post.permalink}`;
84
+ const title = post.title || `Post #${post.id}`;
85
+
86
+ return `
87
+ <entry>
88
+ <title>${escapeXml(title)}</title>
89
+ <link href="${link}" rel="alternate"/>
90
+ <id>${link}</id>
91
+ <published>${post.publishedAt}</published>
92
+ <updated>${post.updatedAt}</updated>
93
+ <content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
94
+ </entry>`;
95
+ })
96
+ .join("");
97
+
98
+ const now = new Date().toISOString();
99
+
100
+ return `<?xml version="1.0" encoding="UTF-8"?>
101
+ <feed xmlns="http://www.w3.org/2005/Atom">
102
+ <title>${escapeXml(siteName)}</title>
103
+ <subtitle>${escapeXml(siteDescription)}</subtitle>
104
+ <link href="${siteUrl}" rel="alternate"/>
105
+ <link href="${siteUrl}/feed/atom.xml" rel="self"/>
106
+ <id>${siteUrl}/</id>
107
+ <updated>${now}</updated>
108
+ ${entries}
109
+ </feed>`;
110
+ }
111
+
112
+ /**
113
+ * Default Sitemap renderer.
114
+ *
115
+ * @param data - Sitemap data with PostView[] (pre-computed URLs)
116
+ * @returns Sitemap XML string
117
+ */
118
+ export function defaultSitemapRenderer(data: SitemapData): string {
119
+ const { siteUrl, posts } = data;
120
+
121
+ const urls = posts
122
+ .map((post) => {
123
+ const loc = `${siteUrl}${post.permalink}`;
124
+ const lastmod = post.updatedAt.split("T")[0];
125
+ const priority = post.visibility === "featured" ? "0.8" : "0.6";
126
+
127
+ return `
128
+ <url>
129
+ <loc>${loc}</loc>
130
+ <lastmod>${lastmod}</lastmod>
131
+ <priority>${priority}</priority>
132
+ </url>`;
133
+ })
134
+ .join("");
135
+
136
+ const homepageUrl = `
137
+ <url>
138
+ <loc>${siteUrl}/</loc>
139
+ <priority>1.0</priority>
140
+ <changefreq>daily</changefreq>
141
+ </url>`;
142
+
143
+ return `<?xml version="1.0" encoding="UTF-8"?>
144
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
145
+ ${homepageUrl}
146
+ ${urls}
147
+ </urlset>`;
148
+ }