@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.
- package/dist/app.js +60 -17
- 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/dash/collections.js +38 -10
- package/dist/routes/dash/navigation.js +22 -8
- package/dist/routes/dash/redirects.js +19 -5
- package/dist/routes/dash/settings.js +57 -15
- 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/PageForm.js +22 -8
- package/dist/theme/components/PostForm.js +22 -8
- 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 +48 -17
- package/src/i18n/locales/en.po +171 -147
- package/src/i18n/locales/zh-Hans.po +171 -147
- package/src/i18n/locales/zh-Hant.po +171 -147
- 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/dash/collections.tsx +30 -10
- package/src/routes/dash/navigation.tsx +20 -10
- package/src/routes/dash/redirects.tsx +15 -5
- package/src/routes/dash/settings.tsx +53 -15
- 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/PageForm.tsx +20 -10
- package/src/theme/components/PostForm.tsx +20 -10
- 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
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
|
|
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("
|
|
80
|
+
describe("resolveComponent", () => {
|
|
83
81
|
it("returns default ThreadPreview when no override", () => {
|
|
84
|
-
expect(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
});
|
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
|
+
}
|