@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.
- package/dist/app.js +1 -1
- package/dist/index.js +8 -0
- package/dist/lib/feed.js +112 -0
- package/dist/lib/navigation.js +9 -9
- package/dist/lib/render.js +48 -0
- package/dist/lib/theme-components.js +18 -18
- package/dist/lib/view.js +228 -0
- package/dist/routes/api/timeline.js +20 -16
- package/dist/routes/feed/rss.js +34 -78
- package/dist/routes/feed/sitemap.js +11 -26
- package/dist/routes/pages/archive.js +18 -195
- package/dist/routes/pages/collection.js +16 -70
- package/dist/routes/pages/home.js +25 -47
- package/dist/routes/pages/page.js +15 -27
- package/dist/routes/pages/post.js +25 -79
- package/dist/routes/pages/search.js +20 -130
- package/dist/theme/components/MediaGallery.js +10 -10
- package/dist/theme/components/index.js +1 -1
- package/dist/theme/components/timeline/ArticleCard.js +7 -11
- package/dist/theme/components/timeline/ImageCard.js +10 -13
- package/dist/theme/components/timeline/LinkCard.js +4 -7
- package/dist/theme/components/timeline/NoteCard.js +5 -8
- package/dist/theme/components/timeline/QuoteCard.js +3 -6
- package/dist/theme/components/timeline/ThreadPreview.js +9 -10
- package/dist/theme/components/timeline/TimelineFeed.js +8 -5
- package/dist/theme/components/timeline/TimelineItem.js +22 -2
- package/dist/theme/components/timeline/index.js +1 -1
- package/dist/theme/index.js +6 -3
- package/dist/theme/layouts/SiteLayout.js +10 -39
- package/dist/theme/pages/ArchivePage.js +157 -0
- package/dist/theme/pages/CollectionPage.js +63 -0
- package/dist/theme/pages/HomePage.js +26 -0
- package/dist/theme/pages/PostPage.js +48 -0
- package/dist/theme/pages/SearchPage.js +120 -0
- package/dist/theme/pages/SinglePage.js +23 -0
- package/dist/theme/pages/index.js +11 -0
- package/package.json +2 -1
- package/src/app.tsx +1 -1
- package/src/i18n/locales/en.po +31 -31
- package/src/i18n/locales/zh-Hans.po +31 -31
- package/src/i18n/locales/zh-Hant.po +31 -31
- package/src/index.ts +51 -2
- package/src/lib/__tests__/theme-components.test.ts +33 -14
- package/src/lib/__tests__/view.test.ts +375 -0
- package/src/lib/feed.ts +148 -0
- package/src/lib/navigation.ts +11 -11
- package/src/lib/render.tsx +67 -0
- package/src/lib/theme-components.ts +27 -35
- package/src/lib/view.ts +318 -0
- package/src/routes/api/__tests__/timeline.test.ts +3 -3
- package/src/routes/api/timeline.tsx +32 -25
- package/src/routes/feed/rss.ts +47 -94
- package/src/routes/feed/sitemap.ts +8 -30
- package/src/routes/pages/archive.tsx +24 -209
- package/src/routes/pages/collection.tsx +19 -75
- package/src/routes/pages/home.tsx +42 -76
- package/src/routes/pages/page.tsx +17 -28
- package/src/routes/pages/post.tsx +28 -86
- package/src/routes/pages/search.tsx +29 -151
- package/src/services/search.ts +2 -8
- package/src/theme/components/MediaGallery.tsx +12 -12
- package/src/theme/components/index.ts +1 -0
- package/src/theme/components/timeline/ArticleCard.tsx +7 -19
- package/src/theme/components/timeline/ImageCard.tsx +10 -20
- package/src/theme/components/timeline/LinkCard.tsx +4 -11
- package/src/theme/components/timeline/NoteCard.tsx +5 -12
- package/src/theme/components/timeline/QuoteCard.tsx +3 -10
- package/src/theme/components/timeline/ThreadPreview.tsx +5 -5
- package/src/theme/components/timeline/TimelineFeed.tsx +7 -3
- package/src/theme/components/timeline/TimelineItem.tsx +43 -4
- package/src/theme/components/timeline/index.ts +1 -1
- package/src/theme/index.ts +7 -3
- package/src/theme/layouts/SiteLayout.tsx +25 -77
- package/src/theme/layouts/index.ts +2 -1
- package/src/theme/pages/ArchivePage.tsx +160 -0
- package/src/theme/pages/CollectionPage.tsx +60 -0
- package/src/theme/pages/HomePage.tsx +42 -0
- package/src/theme/pages/PostPage.tsx +44 -0
- package/src/theme/pages/SearchPage.tsx +128 -0
- package/src/theme/pages/SinglePage.tsx +24 -0
- package/src/theme/pages/index.ts +13 -0
- 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
|
+
});
|
package/src/lib/feed.ts
ADDED
|
@@ -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, "&")
|
|
21
|
+
.replace(/</g, "<")
|
|
22
|
+
.replace(/>/g, ">")
|
|
23
|
+
.replace(/"/g, """)
|
|
24
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|
package/src/lib/navigation.ts
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import type { Context } from "hono";
|
|
8
8
|
import { getSiteName } from "./config.js";
|
|
9
|
-
import type {
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|