@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
@@ -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
+ }
@@ -5,13 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { FC } from "hono/jsx";
8
- import type {
9
- PostType,
10
- ThemeComponents,
11
- TimelineCardProps,
12
- ThreadPreviewProps,
13
- TimelineFeedProps,
14
- } from "../types.js";
8
+ import type { PostType, ThemeComponents, TimelineCardProps } from "../types.js";
15
9
 
16
10
  const THEME_KEY_MAP: Record<PostType, keyof ThemeComponents> = {
17
11
  note: "NoteCard",
@@ -22,6 +16,32 @@ const THEME_KEY_MAP: Record<PostType, keyof ThemeComponents> = {
22
16
  page: "NoteCard",
23
17
  };
24
18
 
19
+ /**
20
+ * Generic component resolver.
21
+ *
22
+ * Looks up a component by key in `ThemeComponents` and falls back to the
23
+ * provided default component.
24
+ *
25
+ * @param key - ThemeComponents key to look up
26
+ * @param defaultComponent - Fallback component
27
+ * @param themeComponents - Optional theme component overrides
28
+ * @returns The resolved component
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * const Gallery = resolveComponent("MediaGallery", DefaultMediaGallery, theme);
33
+ * ```
34
+ */
35
+ export function resolveComponent<K extends keyof ThemeComponents>(
36
+ key: K,
37
+ defaultComponent: NonNullable<ThemeComponents[K]>,
38
+ themeComponents?: ThemeComponents,
39
+ ): NonNullable<ThemeComponents[K]> {
40
+ return (themeComponents?.[key] ?? defaultComponent) as NonNullable<
41
+ ThemeComponents[K]
42
+ >;
43
+ }
44
+
25
45
  /**
26
46
  * Resolves the card component for a given post type.
27
47
  *
@@ -46,31 +66,3 @@ export function resolveCardComponent(
46
66
  const override = themeComponents?.[key] as FC<TimelineCardProps> | undefined;
47
67
  return override ?? defaults[type];
48
68
  }
49
-
50
- /**
51
- * Resolves the ThreadPreview component.
52
- *
53
- * @param defaultComponent - The default ThreadPreview component
54
- * @param themeComponents - Optional theme component overrides
55
- * @returns The resolved ThreadPreview component
56
- */
57
- export function resolveThreadPreview(
58
- defaultComponent: FC<ThreadPreviewProps>,
59
- themeComponents?: ThemeComponents,
60
- ): FC<ThreadPreviewProps> {
61
- return themeComponents?.ThreadPreview ?? defaultComponent;
62
- }
63
-
64
- /**
65
- * Resolves the TimelineFeed component.
66
- *
67
- * @param defaultComponent - The default TimelineFeed component
68
- * @param themeComponents - Optional theme component overrides
69
- * @returns The resolved TimelineFeed component
70
- */
71
- export function resolveTimelineFeed(
72
- defaultComponent: FC<TimelineFeedProps>,
73
- themeComponents?: ThemeComponents,
74
- ): FC<TimelineFeedProps> {
75
- return themeComponents?.TimelineFeed ?? defaultComponent;
76
- }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * View Model Conversions
3
+ *
4
+ * Transforms raw database models into render-ready View types.
5
+ * Theme components receive only View types — no lib/ imports needed.
6
+ */
7
+
8
+ import type { Context } from "hono";
9
+ import type {
10
+ Post,
11
+ PostWithMedia,
12
+ Media,
13
+ MediaView,
14
+ PostView,
15
+ NavLinkView,
16
+ NavigationLink,
17
+ SearchResult,
18
+ SearchResultView,
19
+ ArchiveGroup,
20
+ } from "../types.js";
21
+ import { encode } from "./sqid.js";
22
+ import { toISOString, formatDate } from "./time.js";
23
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
24
+
25
+ // =============================================================================
26
+ // Media Context
27
+ // =============================================================================
28
+
29
+ /**
30
+ * Central media config — extracted once per request from env.
31
+ */
32
+ export interface MediaContext {
33
+ r2PublicUrl?: string;
34
+ imageTransformUrl?: string;
35
+ s3PublicUrl?: string;
36
+ }
37
+
38
+ /**
39
+ * Creates a MediaContext from Hono context environment variables.
40
+ *
41
+ * @param c - Hono context
42
+ * @returns MediaContext with env values
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const mediaCtx = createMediaContext(c);
47
+ * const postView = toPostView(post, mediaCtx);
48
+ * ```
49
+ */
50
+ export function createMediaContext(c: Context): MediaContext {
51
+ return {
52
+ r2PublicUrl: c.env.R2_PUBLIC_URL,
53
+ imageTransformUrl: c.env.IMAGE_TRANSFORM_URL,
54
+ s3PublicUrl: c.env.S3_PUBLIC_URL,
55
+ };
56
+ }
57
+
58
+ // =============================================================================
59
+ // Media Conversions
60
+ // =============================================================================
61
+
62
+ /**
63
+ * Converts a raw Media record to a render-ready MediaView.
64
+ *
65
+ * @param media - Raw media record from database
66
+ * @param ctx - Media context with URL configuration
67
+ * @returns Render-ready MediaView with pre-computed URLs
68
+ */
69
+ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
70
+ const publicUrl = getPublicUrlForProvider(
71
+ media.provider,
72
+ ctx.r2PublicUrl,
73
+ ctx.s3PublicUrl,
74
+ );
75
+ const url = getMediaUrl(media.id, media.storageKey, publicUrl);
76
+ const thumbnailUrl = getImageUrl(url, ctx.imageTransformUrl, {
77
+ width: 400,
78
+ quality: 80,
79
+ format: "auto",
80
+ fit: "cover",
81
+ });
82
+
83
+ return {
84
+ id: media.id,
85
+ url,
86
+ thumbnailUrl,
87
+ mimeType: media.mimeType,
88
+ altText: media.alt ?? undefined,
89
+ width: media.width ?? undefined,
90
+ height: media.height ?? undefined,
91
+ size: media.size,
92
+ };
93
+ }
94
+
95
+ // =============================================================================
96
+ // Post Conversions
97
+ // =============================================================================
98
+
99
+ /**
100
+ * Converts a PostWithMedia to a render-ready PostView.
101
+ *
102
+ * @param post - Post with media attachments from database
103
+ * @param ctx - Media context with URL configuration
104
+ * @returns Render-ready PostView with pre-computed fields
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * const mediaCtx = createMediaContext(c);
109
+ * const postView = toPostView({ ...post, mediaAttachments: [...] }, mediaCtx);
110
+ * ```
111
+ */
112
+ export function toPostView(post: PostWithMedia, ctx: MediaContext): PostView {
113
+ const permalink = `/p/${encode(post.id)}`;
114
+
115
+ // Pre-compute excerpt from raw content
116
+ let excerpt: string | undefined;
117
+ if (post.content) {
118
+ excerpt =
119
+ post.content.length > 160
120
+ ? post.content.slice(0, 160) + "..."
121
+ : post.content;
122
+ }
123
+
124
+ // Convert media attachments
125
+ const media: MediaView[] = post.mediaAttachments.map((m) => ({
126
+ id: m.id,
127
+ url: m.url,
128
+ thumbnailUrl: m.previewUrl,
129
+ mimeType: m.mimeType,
130
+ altText: m.alt ?? undefined,
131
+ width: m.width ?? undefined,
132
+ height: m.height ?? undefined,
133
+ }));
134
+
135
+ return {
136
+ id: post.id,
137
+ permalink,
138
+ title: post.title ?? undefined,
139
+ contentHtml: post.contentHtml ?? undefined,
140
+ excerpt,
141
+ type: post.type,
142
+ visibility: post.visibility,
143
+ path: post.path ?? undefined,
144
+ publishedAt: toISOString(post.publishedAt),
145
+ publishedAtFormatted: formatDate(post.publishedAt),
146
+ updatedAt: toISOString(post.updatedAt),
147
+ sourceUrl: post.sourceUrl ?? undefined,
148
+ sourceName: post.sourceName ?? undefined,
149
+ sourceDomain: post.sourceDomain ?? undefined,
150
+ media,
151
+ replyToId: post.replyToId ?? undefined,
152
+ threadRootId: post.threadId ?? undefined,
153
+ content: post.content ?? undefined,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Batch converts PostWithMedia[] to PostView[].
159
+ *
160
+ * @param posts - Array of posts with media
161
+ * @param ctx - Media context
162
+ * @returns Array of PostView
163
+ */
164
+ export function toPostViews(
165
+ posts: PostWithMedia[],
166
+ ctx: MediaContext,
167
+ ): PostView[] {
168
+ return posts.map((p) => toPostView(p, ctx));
169
+ }
170
+
171
+ /**
172
+ * Converts a bare Post (no media) to a PostView with empty media array.
173
+ *
174
+ * @param post - Post without media
175
+ * @param ctx - Media context (unused but kept for consistency)
176
+ * @returns PostView with empty media
177
+ */
178
+ export function toPostViewFromPost(post: Post, ctx: MediaContext): PostView {
179
+ return toPostView({ ...post, mediaAttachments: [] }, ctx);
180
+ }
181
+
182
+ /**
183
+ * Batch converts Post[] (no media) to PostView[].
184
+ *
185
+ * @param posts - Array of posts without media
186
+ * @param ctx - Media context
187
+ * @returns Array of PostView
188
+ */
189
+ export function toPostViewsFromPosts(
190
+ posts: Post[],
191
+ ctx: MediaContext,
192
+ ): PostView[] {
193
+ return posts.map((p) => toPostViewFromPost(p, ctx));
194
+ }
195
+
196
+ // =============================================================================
197
+ // Navigation Conversions
198
+ // =============================================================================
199
+
200
+ /**
201
+ * Converts a NavigationLink to a NavLinkView with pre-computed state.
202
+ *
203
+ * @param link - Raw navigation link from database
204
+ * @param currentPath - Current page path for active state computation
205
+ * @returns NavLinkView with isActive and isExternal pre-computed
206
+ */
207
+ export function toNavLinkView(
208
+ link: NavigationLink,
209
+ currentPath: string,
210
+ ): NavLinkView {
211
+ const isExternal =
212
+ link.url.startsWith("http://") || link.url.startsWith("https://");
213
+
214
+ let isActive = false;
215
+ if (!isExternal) {
216
+ if (link.url === "/") {
217
+ isActive = currentPath === "/";
218
+ } else {
219
+ isActive =
220
+ currentPath === link.url || currentPath.startsWith(link.url + "/");
221
+ }
222
+ }
223
+
224
+ return {
225
+ id: link.id,
226
+ label: link.label,
227
+ url: link.url,
228
+ isActive,
229
+ isExternal,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Batch converts NavigationLink[] to NavLinkView[].
235
+ *
236
+ * @param links - Raw navigation links
237
+ * @param currentPath - Current page path
238
+ * @returns Array of NavLinkView
239
+ */
240
+ export function toNavLinkViews(
241
+ links: NavigationLink[],
242
+ currentPath: string,
243
+ ): NavLinkView[] {
244
+ return links.map((l) => toNavLinkView(l, currentPath));
245
+ }
246
+
247
+ // =============================================================================
248
+ // Search Result Conversions
249
+ // =============================================================================
250
+
251
+ /**
252
+ * Converts a SearchResult to a SearchResultView with PostView.
253
+ *
254
+ * @param result - Raw search result
255
+ * @param ctx - Media context
256
+ * @returns SearchResultView with PostView
257
+ */
258
+ export function toSearchResultView(
259
+ result: SearchResult,
260
+ ctx: MediaContext,
261
+ ): SearchResultView {
262
+ return {
263
+ post: toPostViewFromPost(result.post, ctx),
264
+ rank: result.rank,
265
+ snippet: result.snippet,
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Batch converts SearchResult[] to SearchResultView[].
271
+ *
272
+ * @param results - Raw search results
273
+ * @param ctx - Media context
274
+ * @returns Array of SearchResultView
275
+ */
276
+ export function toSearchResultViews(
277
+ results: SearchResult[],
278
+ ctx: MediaContext,
279
+ ): SearchResultView[] {
280
+ return results.map((r) => toSearchResultView(r, ctx));
281
+ }
282
+
283
+ // =============================================================================
284
+ // Archive Group Conversions
285
+ // =============================================================================
286
+
287
+ /**
288
+ * Converts a grouped post map to typed ArchiveGroup[].
289
+ *
290
+ * @param grouped - Map of "YYYY-MM" keys to Post arrays
291
+ * @param ctx - Media context
292
+ * @returns Array of ArchiveGroup with pre-formatted labels
293
+ */
294
+ export function toArchiveGroups(
295
+ grouped: Map<string, Post[]>,
296
+ ctx: MediaContext,
297
+ ): ArchiveGroup[] {
298
+ const groups: ArchiveGroup[] = [];
299
+ for (const [yearMonth, posts] of grouped) {
300
+ const [year, month] = yearMonth.split("-");
301
+ if (!year || !month) continue;
302
+
303
+ // Format label like "February 2024"
304
+ const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
305
+ const label = date.toLocaleDateString("en-US", {
306
+ year: "numeric",
307
+ month: "long",
308
+ });
309
+
310
+ groups.push({
311
+ year,
312
+ month,
313
+ label,
314
+ posts: toPostViewsFromPosts(posts, ctx),
315
+ });
316
+ }
317
+ return groups;
318
+ }
@@ -13,7 +13,7 @@ import { createPostService } from "../../../services/post.js";
13
13
  import { createMediaService } from "../../../services/media.js";
14
14
  import { buildMediaMap } from "../../../lib/media-helpers.js";
15
15
  import type { Database } from "../../../db/index.js";
16
- import type { PostWithMedia, TimelineItemData } from "../../../types.js";
16
+ import type { PostWithMedia } from "../../../types.js";
17
17
 
18
18
  describe("Timeline data assembly", () => {
19
19
  let db: Database;
@@ -50,7 +50,7 @@ describe("Timeline data assembly", () => {
50
50
  const mediaMap = buildMediaMap(rawMediaMap);
51
51
 
52
52
  // Assemble items
53
- const items: TimelineItemData[] = posts.map((p) => ({
53
+ const items = posts.map((p) => ({
54
54
  post: { ...p, mediaAttachments: mediaMap.get(p.id) ?? [] },
55
55
  }));
56
56
 
@@ -102,7 +102,7 @@ describe("Timeline data assembly", () => {
102
102
  const rawMediaMap = await mediaService.getByPostIds(postIds);
103
103
  const mediaMap = buildMediaMap(rawMediaMap);
104
104
 
105
- const items: TimelineItemData[] = posts.map((post) => {
105
+ const items = posts.map((post) => {
106
106
  const postWithMedia: PostWithMedia = {
107
107
  ...post,
108
108
  mediaAttachments: mediaMap.get(post.id) ?? [],
@@ -5,12 +5,13 @@
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
- import type { Bindings, PostWithMedia, TimelineItemData } from "../../types.js";
8
+ import type { Bindings, TimelineItemView } from "../../types.js";
9
9
  import type { AppVariables } from "../../app.js";
10
10
  import { sse } from "../../lib/sse.js";
11
11
  import { buildMediaMap } from "../../lib/media-helpers.js";
12
12
  import { TimelineItem } from "../../theme/components/timeline/TimelineItem.js";
13
- import { ThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
13
+ import { ThreadPreview as DefaultThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
14
+ import { createMediaContext, toPostView, toPostViews } from "../../lib/view.js";
14
15
 
15
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
16
17
 
@@ -47,14 +48,12 @@ timelineApiRoutes.get("/", async (c) => {
47
48
  // Build media map
48
49
  const postIds = displayPosts.map((p) => p.id);
49
50
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
50
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
51
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
52
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
51
+ const mediaCtx = createMediaContext(c);
53
52
  const mediaMap = buildMediaMap(
54
53
  rawMediaMap,
55
- r2PublicUrl,
56
- imageTransformUrl,
57
- s3PublicUrl,
54
+ mediaCtx.r2PublicUrl,
55
+ mediaCtx.imageTransformUrl,
56
+ mediaCtx.s3PublicUrl,
58
57
  );
59
58
 
60
59
  // Get reply counts to identify thread roots
@@ -78,51 +77,59 @@ timelineApiRoutes.get("/", async (c) => {
78
77
  previewReplyIds.length > 0
79
78
  ? buildMediaMap(
80
79
  await c.var.services.media.getByPostIds(previewReplyIds),
81
- r2PublicUrl,
82
- imageTransformUrl,
83
- s3PublicUrl,
80
+ mediaCtx.r2PublicUrl,
81
+ mediaCtx.imageTransformUrl,
82
+ mediaCtx.s3PublicUrl,
84
83
  )
85
84
  : new Map();
86
85
 
87
- // Assemble timeline items
88
- const items: TimelineItemData[] = displayPosts.map((post) => {
89
- const postWithMedia: PostWithMedia = {
90
- ...post,
91
- mediaAttachments: mediaMap.get(post.id) ?? [],
92
- };
86
+ // Assemble timeline items with View Models
87
+ const items: TimelineItemView[] = displayPosts.map((post) => {
88
+ const postView = toPostView(
89
+ { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
90
+ mediaCtx,
91
+ );
93
92
 
94
93
  const replyCount = replyCounts.get(post.id) ?? 0;
95
94
  const previewReplies = threadPreviews.get(post.id);
96
95
 
97
96
  if (replyCount > 0 && previewReplies) {
98
97
  return {
99
- post: postWithMedia,
98
+ post: postView,
100
99
  threadPreview: {
101
- replies: previewReplies.map((r) => ({
102
- ...r,
103
- mediaAttachments: previewMediaMap.get(r.id) ?? [],
104
- })),
100
+ replies: toPostViews(
101
+ previewReplies.map((r) => ({
102
+ ...r,
103
+ mediaAttachments: previewMediaMap.get(r.id) ?? [],
104
+ })),
105
+ mediaCtx,
106
+ ),
105
107
  totalReplyCount: replyCount,
106
108
  },
107
109
  };
108
110
  }
109
111
 
110
- return { post: postWithMedia };
112
+ return { post: postView };
111
113
  });
112
114
 
115
+ // Resolve theme components for card rendering
116
+ const theme = c.var.config.theme?.components;
117
+ const ResolvedThreadPreview = theme?.ThreadPreview ?? DefaultThreadPreview;
118
+
113
119
  // Render items to HTML
114
120
  const itemsHtml = items
115
121
  .map((item) => {
116
122
  if (item.threadPreview) {
117
123
  return (
118
- <ThreadPreview
124
+ <ResolvedThreadPreview
119
125
  rootPost={item.post}
120
126
  previewReplies={item.threadPreview.replies}
121
127
  totalReplyCount={item.threadPreview.totalReplyCount}
128
+ theme={theme}
122
129
  />
123
130
  );
124
131
  }
125
- return <TimelineItem item={item} />;
132
+ return <TimelineItem item={item} theme={theme} />;
126
133
  })
127
134
  .map((jsx) => jsx.toString())
128
135
  .join("");