@jant/core 0.3.21 → 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 (82) hide show
  1. package/dist/app.js +1 -1
  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/feed/rss.js +34 -78
  10. package/dist/routes/feed/sitemap.js +11 -26
  11. package/dist/routes/pages/archive.js +18 -195
  12. package/dist/routes/pages/collection.js +16 -70
  13. package/dist/routes/pages/home.js +25 -47
  14. package/dist/routes/pages/page.js +15 -27
  15. package/dist/routes/pages/post.js +25 -79
  16. package/dist/routes/pages/search.js +20 -130
  17. package/dist/theme/components/MediaGallery.js +10 -10
  18. package/dist/theme/components/index.js +1 -1
  19. package/dist/theme/components/timeline/ArticleCard.js +7 -11
  20. package/dist/theme/components/timeline/ImageCard.js +10 -13
  21. package/dist/theme/components/timeline/LinkCard.js +4 -7
  22. package/dist/theme/components/timeline/NoteCard.js +5 -8
  23. package/dist/theme/components/timeline/QuoteCard.js +3 -6
  24. package/dist/theme/components/timeline/ThreadPreview.js +9 -10
  25. package/dist/theme/components/timeline/TimelineFeed.js +8 -5
  26. package/dist/theme/components/timeline/TimelineItem.js +22 -2
  27. package/dist/theme/components/timeline/index.js +1 -1
  28. package/dist/theme/index.js +6 -3
  29. package/dist/theme/layouts/SiteLayout.js +10 -39
  30. package/dist/theme/pages/ArchivePage.js +157 -0
  31. package/dist/theme/pages/CollectionPage.js +63 -0
  32. package/dist/theme/pages/HomePage.js +26 -0
  33. package/dist/theme/pages/PostPage.js +48 -0
  34. package/dist/theme/pages/SearchPage.js +120 -0
  35. package/dist/theme/pages/SinglePage.js +23 -0
  36. package/dist/theme/pages/index.js +11 -0
  37. package/package.json +2 -1
  38. package/src/app.tsx +1 -1
  39. package/src/i18n/locales/en.po +31 -31
  40. package/src/i18n/locales/zh-Hans.po +31 -31
  41. package/src/i18n/locales/zh-Hant.po +31 -31
  42. package/src/index.ts +51 -2
  43. package/src/lib/__tests__/theme-components.test.ts +33 -14
  44. package/src/lib/__tests__/view.test.ts +375 -0
  45. package/src/lib/feed.ts +148 -0
  46. package/src/lib/navigation.ts +11 -11
  47. package/src/lib/render.tsx +67 -0
  48. package/src/lib/theme-components.ts +27 -35
  49. package/src/lib/view.ts +318 -0
  50. package/src/routes/api/__tests__/timeline.test.ts +3 -3
  51. package/src/routes/api/timeline.tsx +32 -25
  52. package/src/routes/feed/rss.ts +47 -94
  53. package/src/routes/feed/sitemap.ts +8 -30
  54. package/src/routes/pages/archive.tsx +24 -209
  55. package/src/routes/pages/collection.tsx +19 -75
  56. package/src/routes/pages/home.tsx +42 -76
  57. package/src/routes/pages/page.tsx +17 -28
  58. package/src/routes/pages/post.tsx +28 -86
  59. package/src/routes/pages/search.tsx +29 -151
  60. package/src/services/search.ts +2 -8
  61. package/src/theme/components/MediaGallery.tsx +12 -12
  62. package/src/theme/components/index.ts +1 -0
  63. package/src/theme/components/timeline/ArticleCard.tsx +7 -19
  64. package/src/theme/components/timeline/ImageCard.tsx +10 -20
  65. package/src/theme/components/timeline/LinkCard.tsx +4 -11
  66. package/src/theme/components/timeline/NoteCard.tsx +5 -12
  67. package/src/theme/components/timeline/QuoteCard.tsx +3 -10
  68. package/src/theme/components/timeline/ThreadPreview.tsx +5 -5
  69. package/src/theme/components/timeline/TimelineFeed.tsx +7 -3
  70. package/src/theme/components/timeline/TimelineItem.tsx +43 -4
  71. package/src/theme/components/timeline/index.ts +1 -1
  72. package/src/theme/index.ts +7 -3
  73. package/src/theme/layouts/SiteLayout.tsx +25 -77
  74. package/src/theme/layouts/index.ts +2 -1
  75. package/src/theme/pages/ArchivePage.tsx +160 -0
  76. package/src/theme/pages/CollectionPage.tsx +60 -0
  77. package/src/theme/pages/HomePage.tsx +42 -0
  78. package/src/theme/pages/PostPage.tsx +44 -0
  79. package/src/theme/pages/SearchPage.tsx +128 -0
  80. package/src/theme/pages/SinglePage.tsx +24 -0
  81. package/src/theme/pages/index.ts +13 -0
  82. package/src/types.ts +262 -38
@@ -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
+ }
@@ -6,13 +6,14 @@
6
6
 
7
7
  import type { Context } from "hono";
8
8
  import { getSiteName } from "./config.js";
9
- import type { NavigationLink } from "../types.js";
9
+ import type { NavLinkView } from "../types.js";
10
+ import { toNavLinkViews } from "./view.js";
10
11
 
11
12
  /**
12
13
  * Navigation data needed by SiteLayout
13
14
  */
14
15
  export interface NavigationData {
15
- navigationLinks: NavigationLink[];
16
+ links: NavLinkView[];
16
17
  currentPath: string;
17
18
  siteName: string;
18
19
  }
@@ -21,7 +22,7 @@ export interface NavigationData {
21
22
  * Fetch navigation data for public pages.
22
23
  *
23
24
  * Ensures default links exist (Home, Archive, RSS) and returns
24
- * the current path and site name alongside the links.
25
+ * NavLinkView[] with pre-computed isActive/isExternal state.
25
26
  *
26
27
  * @param c - Hono context
27
28
  * @returns Navigation data for SiteLayout
@@ -29,18 +30,17 @@ export interface NavigationData {
29
30
  * @example
30
31
  * ```typescript
31
32
  * const navData = await getNavigationData(c);
32
- * return c.html(
33
- * <BaseLayout c={c}>
34
- * <SiteLayout {...navData}>
35
- * <MyContent />
36
- * </SiteLayout>
37
- * </BaseLayout>
38
- * );
33
+ * return renderPublicPage(c, {
34
+ * title: "My Page",
35
+ * navData,
36
+ * content: <MyContent />,
37
+ * });
39
38
  * ```
40
39
  */
41
40
  export async function getNavigationData(c: Context): Promise<NavigationData> {
42
41
  const navigationLinks = await c.var.services.navigationLinks.ensureDefaults();
43
42
  const currentPath = new URL(c.req.url).pathname;
44
43
  const siteName = await getSiteName(c);
45
- return { navigationLinks, currentPath, siteName };
44
+ const links = toNavLinkViews(navigationLinks, currentPath);
45
+ return { links, currentPath, siteName };
46
46
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Public Page Rendering Helper
3
+ *
4
+ * Provides a single entry point for rendering public pages with the
5
+ * correct layout stack: BaseLayout > SiteLayout > content.
6
+ *
7
+ * BaseLayout is always the built-in implementation (handles Vite assets,
8
+ * I18nProvider, toast). SiteLayout is resolved from theme components.
9
+ */
10
+
11
+ import type { Context } from "hono";
12
+ import type { Child } from "hono/jsx";
13
+ import type { ThemeComponents, SiteLayoutProps } from "../types.js";
14
+ import { BaseLayout } from "../theme/layouts/BaseLayout.js";
15
+ import { SiteLayout as DefaultSiteLayout } from "../theme/layouts/SiteLayout.js";
16
+ import type { NavigationData } from "./navigation.js";
17
+
18
+ export interface RenderPublicPageOptions {
19
+ /** Page title for <title> tag */
20
+ title: string;
21
+ /** Page description for meta tag */
22
+ description?: string;
23
+ /** Navigation data (from getNavigationData) */
24
+ navData: NavigationData;
25
+ /** Page content JSX to render inside SiteLayout */
26
+ content: Child;
27
+ }
28
+
29
+ /**
30
+ * Render a public page with the standard layout stack.
31
+ *
32
+ * Always uses the built-in BaseLayout, resolves SiteLayout from theme config.
33
+ *
34
+ * @param c - Hono context
35
+ * @param options - Page rendering options
36
+ * @returns Hono HTML response
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * const navData = await getNavigationData(c);
41
+ * return renderPublicPage(c, {
42
+ * title: "My Page",
43
+ * navData,
44
+ * content: <MyPageComponent />,
45
+ * });
46
+ * ```
47
+ */
48
+ export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
49
+ const { title, description, navData, content } = options;
50
+
51
+ const components = c.var.config?.theme?.components as
52
+ | ThemeComponents
53
+ | undefined;
54
+ const Layout = components?.SiteLayout ?? DefaultSiteLayout;
55
+
56
+ const layoutProps: SiteLayoutProps = {
57
+ siteName: navData.siteName,
58
+ links: navData.links,
59
+ currentPath: navData.currentPath,
60
+ };
61
+
62
+ return c.html(
63
+ <BaseLayout title={title} description={description} c={c}>
64
+ <Layout {...layoutProps}>{content}</Layout>
65
+ </BaseLayout>,
66
+ );
67
+ }