@jant/core 0.3.21 → 0.3.23

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 (99) hide show
  1. package/dist/app.js +23 -4
  2. package/dist/index.js +11 -4
  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 +22 -18
  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 +0 -2
  19. package/dist/theme/index.js +10 -13
  20. package/dist/theme/layouts/index.js +0 -1
  21. package/dist/themes/minimal/MinimalSiteLayout.js +83 -0
  22. package/dist/themes/minimal/index.js +65 -0
  23. package/dist/themes/minimal/pages/ArchivePage.js +156 -0
  24. package/dist/themes/minimal/pages/CollectionPage.js +65 -0
  25. package/dist/themes/minimal/pages/HomePage.js +25 -0
  26. package/dist/themes/minimal/pages/PostPage.js +47 -0
  27. package/dist/themes/minimal/pages/SearchPage.js +121 -0
  28. package/dist/themes/minimal/pages/SinglePage.js +22 -0
  29. package/dist/themes/minimal/timeline/ArticleCard.js +36 -0
  30. package/dist/themes/minimal/timeline/ImageCard.js +67 -0
  31. package/dist/themes/minimal/timeline/LinkCard.js +47 -0
  32. package/dist/themes/minimal/timeline/NoteCard.js +34 -0
  33. package/dist/{theme/components → themes/minimal}/timeline/QuoteCard.js +9 -12
  34. package/dist/themes/minimal/timeline/ThreadPreview.js +46 -0
  35. package/dist/themes/minimal/timeline/TimelineFeed.js +48 -0
  36. package/dist/themes/minimal/timeline/TimelineItem.js +44 -0
  37. package/package.json +2 -1
  38. package/src/app.tsx +27 -4
  39. package/src/i18n/locales/en.po +53 -53
  40. package/src/i18n/locales/zh-Hans.po +53 -53
  41. package/src/i18n/locales/zh-Hant.po +53 -53
  42. package/src/index.ts +54 -6
  43. package/src/lib/__tests__/theme-components.test.ts +33 -14
  44. package/src/lib/__tests__/view.test.ts +377 -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 +34 -27
  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/styles/components.css +0 -54
  62. package/src/theme/components/MediaGallery.tsx +12 -12
  63. package/src/theme/components/index.ts +0 -12
  64. package/src/theme/index.ts +11 -13
  65. package/src/theme/layouts/index.ts +1 -1
  66. package/src/themes/minimal/MinimalSiteLayout.tsx +100 -0
  67. package/src/themes/minimal/index.ts +83 -0
  68. package/src/themes/minimal/pages/ArchivePage.tsx +157 -0
  69. package/src/themes/minimal/pages/CollectionPage.tsx +60 -0
  70. package/src/themes/minimal/pages/HomePage.tsx +41 -0
  71. package/src/themes/minimal/pages/PostPage.tsx +43 -0
  72. package/src/themes/minimal/pages/SearchPage.tsx +122 -0
  73. package/src/themes/minimal/pages/SinglePage.tsx +23 -0
  74. package/src/themes/minimal/timeline/ArticleCard.tsx +37 -0
  75. package/src/themes/minimal/timeline/ImageCard.tsx +63 -0
  76. package/src/themes/minimal/timeline/LinkCard.tsx +48 -0
  77. package/src/themes/minimal/timeline/NoteCard.tsx +35 -0
  78. package/src/{theme/components → themes/minimal}/timeline/QuoteCard.tsx +11 -17
  79. package/src/themes/minimal/timeline/ThreadPreview.tsx +47 -0
  80. package/src/{theme/components → themes/minimal}/timeline/TimelineFeed.tsx +20 -15
  81. package/src/themes/minimal/timeline/TimelineItem.tsx +75 -0
  82. package/src/types.ts +262 -38
  83. package/dist/theme/components/timeline/ArticleCard.js +0 -50
  84. package/dist/theme/components/timeline/ImageCard.js +0 -86
  85. package/dist/theme/components/timeline/LinkCard.js +0 -62
  86. package/dist/theme/components/timeline/NoteCard.js +0 -37
  87. package/dist/theme/components/timeline/ThreadPreview.js +0 -52
  88. package/dist/theme/components/timeline/TimelineFeed.js +0 -43
  89. package/dist/theme/components/timeline/TimelineItem.js +0 -25
  90. package/dist/theme/components/timeline/index.js +0 -8
  91. package/dist/theme/layouts/SiteLayout.js +0 -160
  92. package/src/theme/components/timeline/ArticleCard.tsx +0 -57
  93. package/src/theme/components/timeline/ImageCard.tsx +0 -80
  94. package/src/theme/components/timeline/LinkCard.tsx +0 -66
  95. package/src/theme/components/timeline/NoteCard.tsx +0 -41
  96. package/src/theme/components/timeline/ThreadPreview.tsx +0 -49
  97. package/src/theme/components/timeline/TimelineItem.tsx +0 -39
  98. package/src/theme/components/timeline/index.ts +0 -8
  99. package/src/theme/layouts/SiteLayout.tsx +0 -184
package/dist/app.js CHANGED
@@ -8,6 +8,7 @@ import { createAuth } from "./auth.js";
8
8
  import { i18nMiddleware } from "./i18n/index.js";
9
9
  import { useLingui as $_useLingui } from "@jant/core/i18n";
10
10
  import { SETTINGS_KEYS } from "./lib/constants.js";
11
+ import { theme as minimalTheme } from "./themes/minimal/index.js";
11
12
  import { hashPassword } from "better-auth/crypto";
12
13
  // Routes - Pages
13
14
  import { homeRoutes } from "./routes/pages/home.js";
@@ -56,10 +57,28 @@ import { createStorageDriver } from "./lib/storage.js";
56
57
  * import { createApp } from "@jant/core";
57
58
  *
58
59
  * export default createApp({
59
- * theme: { components: { PostCard: MyPostCard } },
60
+ * theme: { components: { PostPage: MyPostPage } },
60
61
  * });
61
62
  * ```
62
63
  */ export function createApp(config = {}) {
64
+ // Merge with default minimal theme
65
+ const defaultTheme = minimalTheme();
66
+ const resolvedConfig = {
67
+ ...config,
68
+ theme: {
69
+ name: config.theme?.name ?? defaultTheme.name,
70
+ components: {
71
+ ...defaultTheme.components,
72
+ ...config.theme?.components
73
+ },
74
+ cssVariables: {
75
+ ...defaultTheme.cssVariables,
76
+ ...config.theme?.cssVariables
77
+ },
78
+ colorThemes: config.theme?.colorThemes ?? defaultTheme.colorThemes,
79
+ feed: config.theme?.feed
80
+ }
81
+ };
63
82
  const app = new Hono();
64
83
  // Initialize services, auth, and config middleware
65
84
  app.use("*", async (c, next)=>{
@@ -72,7 +91,7 @@ import { createStorageDriver } from "./lib/storage.js";
72
91
  const db = createDatabase(session);
73
92
  const services = createServices(db, session);
74
93
  c.set("services", services);
75
- c.set("config", config);
94
+ c.set("config", resolvedConfig);
76
95
  c.set("storage", createStorageDriver(c.env));
77
96
  if (c.env.AUTH_SECRET) {
78
97
  const baseURL = c.env.SITE_URL || new URL(c.req.url).origin;
@@ -89,9 +108,9 @@ import { createStorageDriver } from "./lib/storage.js";
89
108
  // Theme middleware - resolve active color theme and build CSS
90
109
  app.use("*", async (c, next)=>{
91
110
  const themeId = await c.var.services.settings.get(SETTINGS_KEYS.THEME);
92
- const themes = getAvailableThemes(config);
111
+ const themes = getAvailableThemes(resolvedConfig);
93
112
  const activeTheme = themeId ? themes.find((t)=>t.id === themeId) : undefined;
94
- const themeStyle = buildThemeStyle(activeTheme, config.theme?.cssVariables);
113
+ const themeStyle = buildThemeStyle(activeTheme, resolvedConfig.theme?.cssVariables);
95
114
  c.set("themeStyle", themeStyle);
96
115
  await next();
97
116
  });
package/dist/index.js CHANGED
@@ -2,14 +2,21 @@
2
2
  * Jant - A microblog system
3
3
  *
4
4
  * @packageDocumentation
5
- */ import { createApp as _createApp } from "./app.js";
6
- // Main app factory
5
+ */ // Main app factory
7
6
  export { createApp } from "./app.js";
7
+ // Default theme
8
+ export { theme as minimalTheme } from "./themes/minimal/index.js";
8
9
  export { POST_TYPES, VISIBILITY_LEVELS, MAX_MEDIA_ATTACHMENTS, POST_TYPE_MEDIA_RULES } from "./types.js";
9
10
  // Utilities (for theme authors)
10
11
  export * as time from "./lib/time.js";
11
12
  export * as sqid from "./lib/sqid.js";
12
13
  export * as url from "./lib/url.js";
13
14
  export * as markdown from "./lib/markdown.js";
14
- // Default export for running core directly (e.g., for development)
15
- export default _createApp();
15
+ // View Model conversion utilities (for advanced theme use)
16
+ export { createMediaContext, toPostView, toPostViews, toMediaView, toNavLinkView, toNavLinkViews, toSearchResultView, toArchiveGroups } from "./lib/view.js";
17
+ // Render helper (for theme authors adding custom routes)
18
+ export { renderPublicPage } from "./lib/render.js";
19
+ // Navigation helper (for theme authors)
20
+ export { getNavigationData } from "./lib/navigation.js";
21
+ // Default feed renderers (for theme authors to extend)
22
+ export { defaultRssRenderer, defaultAtomRenderer, defaultSitemapRenderer } from "./lib/feed.js";
@@ -0,0 +1,112 @@
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
+ * Escape special XML characters.
13
+ */ function escapeXml(str) {
14
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
15
+ }
16
+ /**
17
+ * Default RSS 2.0 renderer.
18
+ *
19
+ * @param data - Feed data with PostView[] (pre-computed URLs)
20
+ * @returns RSS 2.0 XML string
21
+ */ export function defaultRssRenderer(data) {
22
+ const { siteName, siteDescription, siteUrl, siteLanguage, posts } = data;
23
+ const items = posts.map((post)=>{
24
+ const link = `${siteUrl}${post.permalink}`;
25
+ const title = post.title || `Post #${post.id}`;
26
+ const pubDate = new Date(post.publishedAt).toUTCString();
27
+ // Add enclosure for first media attachment
28
+ const firstMedia = post.media[0];
29
+ const enclosure = firstMedia ? `\n <enclosure url="${firstMedia.url}" type="${firstMedia.mimeType}"${firstMedia.size ? ` length="${firstMedia.size}"` : ""}/>` : "";
30
+ return `
31
+ <item>
32
+ <title><![CDATA[${escapeXml(title)}]]></title>
33
+ <link>${link}</link>
34
+ <guid isPermaLink="true">${link}</guid>
35
+ <pubDate>${pubDate}</pubDate>
36
+ <description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
37
+ </item>`;
38
+ }).join("");
39
+ return `<?xml version="1.0" encoding="UTF-8"?>
40
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
41
+ <channel>
42
+ <title>${escapeXml(siteName)}</title>
43
+ <link>${siteUrl}</link>
44
+ <description>${escapeXml(siteDescription)}</description>
45
+ <language>${siteLanguage}</language>
46
+ <atom:link href="${siteUrl}/feed" rel="self" type="application/rss+xml"/>
47
+ ${items}
48
+ </channel>
49
+ </rss>`;
50
+ }
51
+ /**
52
+ * Default Atom renderer.
53
+ *
54
+ * @param data - Feed data with PostView[] (pre-computed URLs)
55
+ * @returns Atom XML string
56
+ */ export function defaultAtomRenderer(data) {
57
+ const { siteName, siteDescription, siteUrl, posts } = data;
58
+ const entries = posts.map((post)=>{
59
+ const link = `${siteUrl}${post.permalink}`;
60
+ const title = post.title || `Post #${post.id}`;
61
+ return `
62
+ <entry>
63
+ <title>${escapeXml(title)}</title>
64
+ <link href="${link}" rel="alternate"/>
65
+ <id>${link}</id>
66
+ <published>${post.publishedAt}</published>
67
+ <updated>${post.updatedAt}</updated>
68
+ <content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
69
+ </entry>`;
70
+ }).join("");
71
+ const now = new Date().toISOString();
72
+ return `<?xml version="1.0" encoding="UTF-8"?>
73
+ <feed xmlns="http://www.w3.org/2005/Atom">
74
+ <title>${escapeXml(siteName)}</title>
75
+ <subtitle>${escapeXml(siteDescription)}</subtitle>
76
+ <link href="${siteUrl}" rel="alternate"/>
77
+ <link href="${siteUrl}/feed/atom.xml" rel="self"/>
78
+ <id>${siteUrl}/</id>
79
+ <updated>${now}</updated>
80
+ ${entries}
81
+ </feed>`;
82
+ }
83
+ /**
84
+ * Default Sitemap renderer.
85
+ *
86
+ * @param data - Sitemap data with PostView[] (pre-computed URLs)
87
+ * @returns Sitemap XML string
88
+ */ export function defaultSitemapRenderer(data) {
89
+ const { siteUrl, posts } = data;
90
+ const urls = posts.map((post)=>{
91
+ const loc = `${siteUrl}${post.permalink}`;
92
+ const lastmod = post.updatedAt.split("T")[0];
93
+ const priority = post.visibility === "featured" ? "0.8" : "0.6";
94
+ return `
95
+ <url>
96
+ <loc>${loc}</loc>
97
+ <lastmod>${lastmod}</lastmod>
98
+ <priority>${priority}</priority>
99
+ </url>`;
100
+ }).join("");
101
+ const homepageUrl = `
102
+ <url>
103
+ <loc>${siteUrl}/</loc>
104
+ <priority>1.0</priority>
105
+ <changefreq>daily</changefreq>
106
+ </url>`;
107
+ return `<?xml version="1.0" encoding="UTF-8"?>
108
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
109
+ ${homepageUrl}
110
+ ${urls}
111
+ </urlset>`;
112
+ }
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * Provides shared data fetching for public page navigation.
5
5
  */ import { getSiteName } from "./config.js";
6
+ import { toNavLinkViews } from "./view.js";
6
7
  /**
7
8
  * Fetch navigation data for public pages.
8
9
  *
9
10
  * Ensures default links exist (Home, Archive, RSS) and returns
10
- * the current path and site name alongside the links.
11
+ * NavLinkView[] with pre-computed isActive/isExternal state.
11
12
  *
12
13
  * @param c - Hono context
13
14
  * @returns Navigation data for SiteLayout
@@ -15,20 +16,19 @@
15
16
  * @example
16
17
  * ```typescript
17
18
  * const navData = await getNavigationData(c);
18
- * return c.html(
19
- * <BaseLayout c={c}>
20
- * <SiteLayout {...navData}>
21
- * <MyContent />
22
- * </SiteLayout>
23
- * </BaseLayout>
24
- * );
19
+ * return renderPublicPage(c, {
20
+ * title: "My Page",
21
+ * navData,
22
+ * content: <MyContent />,
23
+ * });
25
24
  * ```
26
25
  */ export async function getNavigationData(c) {
27
26
  const navigationLinks = await c.var.services.navigationLinks.ensureDefaults();
28
27
  const currentPath = new URL(c.req.url).pathname;
29
28
  const siteName = await getSiteName(c);
29
+ const links = toNavLinkViews(navigationLinks, currentPath);
30
30
  return {
31
- navigationLinks,
31
+ links,
32
32
  currentPath,
33
33
  siteName
34
34
  };
@@ -0,0 +1,48 @@
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
+ */ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
10
+ import { BaseLayout } from "../theme/layouts/BaseLayout.js";
11
+ import { SiteLayout as DefaultSiteLayout } from "../themes/minimal/MinimalSiteLayout.js";
12
+ /**
13
+ * Render a public page with the standard layout stack.
14
+ *
15
+ * Always uses the built-in BaseLayout, resolves SiteLayout from theme config.
16
+ *
17
+ * @param c - Hono context
18
+ * @param options - Page rendering options
19
+ * @returns Hono HTML response
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * const navData = await getNavigationData(c);
24
+ * return renderPublicPage(c, {
25
+ * title: "My Page",
26
+ * navData,
27
+ * content: <MyPageComponent />,
28
+ * });
29
+ * ```
30
+ */ export function renderPublicPage(c, options) {
31
+ const { title, description, navData, content } = options;
32
+ const components = c.var.config?.theme?.components;
33
+ const Layout = components?.SiteLayout ?? DefaultSiteLayout;
34
+ const layoutProps = {
35
+ siteName: navData.siteName,
36
+ links: navData.links,
37
+ currentPath: navData.currentPath
38
+ };
39
+ return c.html(/*#__PURE__*/ _jsx(BaseLayout, {
40
+ title: title,
41
+ description: description,
42
+ c: c,
43
+ children: /*#__PURE__*/ _jsx(Layout, {
44
+ ...layoutProps,
45
+ children: content
46
+ })
47
+ }));
48
+ }
@@ -10,6 +10,24 @@
10
10
  image: "ImageCard",
11
11
  page: "NoteCard"
12
12
  };
13
+ /**
14
+ * Generic component resolver.
15
+ *
16
+ * Looks up a component by key in `ThemeComponents` and falls back to the
17
+ * provided default component.
18
+ *
19
+ * @param key - ThemeComponents key to look up
20
+ * @param defaultComponent - Fallback component
21
+ * @param themeComponents - Optional theme component overrides
22
+ * @returns The resolved component
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const Gallery = resolveComponent("MediaGallery", DefaultMediaGallery, theme);
27
+ * ```
28
+ */ export function resolveComponent(key, defaultComponent, themeComponents) {
29
+ return themeComponents?.[key] ?? defaultComponent;
30
+ }
13
31
  /**
14
32
  * Resolves the card component for a given post type.
15
33
  *
@@ -29,21 +47,3 @@
29
47
  const override = themeComponents?.[key];
30
48
  return override ?? defaults[type];
31
49
  }
32
- /**
33
- * Resolves the ThreadPreview component.
34
- *
35
- * @param defaultComponent - The default ThreadPreview component
36
- * @param themeComponents - Optional theme component overrides
37
- * @returns The resolved ThreadPreview component
38
- */ export function resolveThreadPreview(defaultComponent, themeComponents) {
39
- return themeComponents?.ThreadPreview ?? defaultComponent;
40
- }
41
- /**
42
- * Resolves the TimelineFeed component.
43
- *
44
- * @param defaultComponent - The default TimelineFeed component
45
- * @param themeComponents - Optional theme component overrides
46
- * @returns The resolved TimelineFeed component
47
- */ export function resolveTimelineFeed(defaultComponent, themeComponents) {
48
- return themeComponents?.TimelineFeed ?? defaultComponent;
49
- }
@@ -0,0 +1,228 @@
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
+ */ import { encode } from "./sqid.js";
7
+ import { toISOString, formatDate } from "./time.js";
8
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
9
+ /**
10
+ * Creates a MediaContext from Hono context environment variables.
11
+ *
12
+ * @param c - Hono context
13
+ * @returns MediaContext with env values
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * const mediaCtx = createMediaContext(c);
18
+ * const postView = toPostView(post, mediaCtx);
19
+ * ```
20
+ */ export function createMediaContext(c) {
21
+ return {
22
+ r2PublicUrl: c.env.R2_PUBLIC_URL,
23
+ imageTransformUrl: c.env.IMAGE_TRANSFORM_URL,
24
+ s3PublicUrl: c.env.S3_PUBLIC_URL
25
+ };
26
+ }
27
+ // =============================================================================
28
+ // Media Conversions
29
+ // =============================================================================
30
+ /**
31
+ * Converts a raw Media record to a render-ready MediaView.
32
+ *
33
+ * @param media - Raw media record from database
34
+ * @param ctx - Media context with URL configuration
35
+ * @returns Render-ready MediaView with pre-computed URLs
36
+ */ export function toMediaView(media, ctx) {
37
+ const publicUrl = getPublicUrlForProvider(media.provider, ctx.r2PublicUrl, ctx.s3PublicUrl);
38
+ const url = getMediaUrl(media.id, media.storageKey, publicUrl);
39
+ const thumbnailUrl = getImageUrl(url, ctx.imageTransformUrl, {
40
+ width: 400,
41
+ quality: 80,
42
+ format: "auto",
43
+ fit: "cover"
44
+ });
45
+ return {
46
+ id: media.id,
47
+ url,
48
+ thumbnailUrl,
49
+ mimeType: media.mimeType,
50
+ altText: media.alt ?? undefined,
51
+ width: media.width ?? undefined,
52
+ height: media.height ?? undefined,
53
+ size: media.size
54
+ };
55
+ }
56
+ // =============================================================================
57
+ // Post Conversions
58
+ // =============================================================================
59
+ /**
60
+ * Converts a PostWithMedia to a render-ready PostView.
61
+ *
62
+ * @param post - Post with media attachments from database
63
+ * @param ctx - Media context with URL configuration
64
+ * @returns Render-ready PostView with pre-computed fields
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * const mediaCtx = createMediaContext(c);
69
+ * const postView = toPostView({ ...post, mediaAttachments: [...] }, mediaCtx);
70
+ * ```
71
+ */ export function toPostView(post, _ctx) {
72
+ const permalink = `/p/${encode(post.id)}`;
73
+ // Pre-compute excerpt from raw content
74
+ let excerpt;
75
+ if (post.content) {
76
+ excerpt = post.content.length > 160 ? post.content.slice(0, 160) + "..." : post.content;
77
+ }
78
+ // Convert media attachments
79
+ const media = post.mediaAttachments.map((m)=>({
80
+ id: m.id,
81
+ url: m.url,
82
+ thumbnailUrl: m.previewUrl,
83
+ mimeType: m.mimeType,
84
+ altText: m.alt ?? undefined,
85
+ width: m.width ?? undefined,
86
+ height: m.height ?? undefined
87
+ }));
88
+ return {
89
+ id: post.id,
90
+ permalink,
91
+ title: post.title ?? undefined,
92
+ contentHtml: post.contentHtml ?? undefined,
93
+ excerpt,
94
+ type: post.type,
95
+ visibility: post.visibility,
96
+ path: post.path ?? undefined,
97
+ publishedAt: toISOString(post.publishedAt),
98
+ publishedAtFormatted: formatDate(post.publishedAt),
99
+ updatedAt: toISOString(post.updatedAt),
100
+ sourceUrl: post.sourceUrl ?? undefined,
101
+ sourceName: post.sourceName ?? undefined,
102
+ sourceDomain: post.sourceDomain ?? undefined,
103
+ media,
104
+ replyToId: post.replyToId ?? undefined,
105
+ threadRootId: post.threadId ?? undefined,
106
+ content: post.content ?? undefined
107
+ };
108
+ }
109
+ /**
110
+ * Batch converts PostWithMedia[] to PostView[].
111
+ *
112
+ * @param posts - Array of posts with media
113
+ * @param ctx - Media context
114
+ * @returns Array of PostView
115
+ */ export function toPostViews(posts, ctx) {
116
+ return posts.map((p)=>toPostView(p, ctx));
117
+ }
118
+ /**
119
+ * Converts a bare Post (no media) to a PostView with empty media array.
120
+ *
121
+ * @param post - Post without media
122
+ * @param ctx - Media context (unused but kept for consistency)
123
+ * @returns PostView with empty media
124
+ */ export function toPostViewFromPost(post, ctx) {
125
+ return toPostView({
126
+ ...post,
127
+ mediaAttachments: []
128
+ }, ctx);
129
+ }
130
+ /**
131
+ * Batch converts Post[] (no media) to PostView[].
132
+ *
133
+ * @param posts - Array of posts without media
134
+ * @param ctx - Media context
135
+ * @returns Array of PostView
136
+ */ export function toPostViewsFromPosts(posts, ctx) {
137
+ return posts.map((p)=>toPostViewFromPost(p, ctx));
138
+ }
139
+ // =============================================================================
140
+ // Navigation Conversions
141
+ // =============================================================================
142
+ /**
143
+ * Converts a NavigationLink to a NavLinkView with pre-computed state.
144
+ *
145
+ * @param link - Raw navigation link from database
146
+ * @param currentPath - Current page path for active state computation
147
+ * @returns NavLinkView with isActive and isExternal pre-computed
148
+ */ export function toNavLinkView(link, currentPath) {
149
+ const isExternal = link.url.startsWith("http://") || link.url.startsWith("https://");
150
+ let isActive = false;
151
+ if (!isExternal) {
152
+ if (link.url === "/") {
153
+ isActive = currentPath === "/";
154
+ } else {
155
+ isActive = currentPath === link.url || currentPath.startsWith(link.url + "/");
156
+ }
157
+ }
158
+ return {
159
+ id: link.id,
160
+ label: link.label,
161
+ url: link.url,
162
+ isActive,
163
+ isExternal
164
+ };
165
+ }
166
+ /**
167
+ * Batch converts NavigationLink[] to NavLinkView[].
168
+ *
169
+ * @param links - Raw navigation links
170
+ * @param currentPath - Current page path
171
+ * @returns Array of NavLinkView
172
+ */ export function toNavLinkViews(links, currentPath) {
173
+ return links.map((l)=>toNavLinkView(l, currentPath));
174
+ }
175
+ // =============================================================================
176
+ // Search Result Conversions
177
+ // =============================================================================
178
+ /**
179
+ * Converts a SearchResult to a SearchResultView with PostView.
180
+ *
181
+ * @param result - Raw search result
182
+ * @param ctx - Media context
183
+ * @returns SearchResultView with PostView
184
+ */ export function toSearchResultView(result, ctx) {
185
+ return {
186
+ post: toPostViewFromPost(result.post, ctx),
187
+ rank: result.rank,
188
+ snippet: result.snippet
189
+ };
190
+ }
191
+ /**
192
+ * Batch converts SearchResult[] to SearchResultView[].
193
+ *
194
+ * @param results - Raw search results
195
+ * @param ctx - Media context
196
+ * @returns Array of SearchResultView
197
+ */ export function toSearchResultViews(results, ctx) {
198
+ return results.map((r)=>toSearchResultView(r, ctx));
199
+ }
200
+ // =============================================================================
201
+ // Archive Group Conversions
202
+ // =============================================================================
203
+ /**
204
+ * Converts a grouped post map to typed ArchiveGroup[].
205
+ *
206
+ * @param grouped - Map of "YYYY-MM" keys to Post arrays
207
+ * @param ctx - Media context
208
+ * @returns Array of ArchiveGroup with pre-formatted labels
209
+ */ export function toArchiveGroups(grouped, ctx) {
210
+ const groups = [];
211
+ for (const [yearMonth, posts] of grouped){
212
+ const [year, month] = yearMonth.split("-");
213
+ if (!year || !month) continue;
214
+ // Format label like "February 2024"
215
+ const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
216
+ const label = date.toLocaleDateString("en-US", {
217
+ year: "numeric",
218
+ month: "long"
219
+ });
220
+ groups.push({
221
+ year,
222
+ month,
223
+ label,
224
+ posts: toPostViewsFromPosts(posts, ctx)
225
+ });
226
+ }
227
+ return groups;
228
+ }
@@ -6,8 +6,9 @@ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
6
6
  */ import { Hono } from "hono";
7
7
  import { sse } from "../../lib/sse.js";
8
8
  import { buildMediaMap } from "../../lib/media-helpers.js";
9
- import { TimelineItem } from "../../theme/components/timeline/TimelineItem.js";
10
- import { ThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
9
+ import { TimelineItem } from "../../themes/minimal/timeline/TimelineItem.js";
10
+ import { ThreadPreview as DefaultThreadPreview } from "../../themes/minimal/timeline/ThreadPreview.js";
11
+ import { createMediaContext, toPostView, toPostViews } from "../../lib/view.js";
11
12
  const PAGE_SIZE = 20;
12
13
  export const timelineApiRoutes = new Hono();
13
14
  timelineApiRoutes.get("/", async (c)=>{
@@ -41,10 +42,8 @@ timelineApiRoutes.get("/", async (c)=>{
41
42
  // Build media map
42
43
  const postIds = displayPosts.map((p)=>p.id);
43
44
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
44
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
45
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
46
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
47
- const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl, s3PublicUrl);
45
+ const mediaCtx = createMediaContext(c);
46
+ const mediaMap = buildMediaMap(rawMediaMap, mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl);
48
47
  // Get reply counts to identify thread roots
49
48
  const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
50
49
  const threadRootIds = postIds.filter((id)=>(replyCounts.get(id) ?? 0) > 0);
@@ -57,49 +56,54 @@ timelineApiRoutes.get("/", async (c)=>{
57
56
  previewReplyIds.push(reply.id);
58
57
  }
59
58
  }
60
- const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), r2PublicUrl, imageTransformUrl, s3PublicUrl) : new Map();
61
- // Assemble timeline items
59
+ const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl) : new Map();
60
+ // Assemble timeline items with View Models
62
61
  const items = displayPosts.map((post)=>{
63
- const postWithMedia = {
62
+ const postView = toPostView({
64
63
  ...post,
65
64
  mediaAttachments: mediaMap.get(post.id) ?? []
66
- };
65
+ }, mediaCtx);
67
66
  const replyCount = replyCounts.get(post.id) ?? 0;
68
67
  const previewReplies = threadPreviews.get(post.id);
69
68
  if (replyCount > 0 && previewReplies) {
70
69
  return {
71
- post: postWithMedia,
70
+ post: postView,
72
71
  threadPreview: {
73
- replies: previewReplies.map((r)=>({
72
+ replies: toPostViews(previewReplies.map((r)=>({
74
73
  ...r,
75
74
  mediaAttachments: previewMediaMap.get(r.id) ?? []
76
- })),
75
+ })), mediaCtx),
77
76
  totalReplyCount: replyCount
78
77
  }
79
78
  };
80
79
  }
81
80
  return {
82
- post: postWithMedia
81
+ post: postView
83
82
  };
84
83
  });
84
+ // Resolve theme components for card rendering
85
+ const theme = c.var.config.theme?.components;
86
+ const ResolvedThreadPreview = theme?.ThreadPreview ?? DefaultThreadPreview;
85
87
  // Render items to HTML
86
88
  const itemsHtml = items.map((item)=>{
87
89
  if (item.threadPreview) {
88
- return /*#__PURE__*/ _jsx(ThreadPreview, {
90
+ return /*#__PURE__*/ _jsx(ResolvedThreadPreview, {
89
91
  rootPost: item.post,
90
92
  previewReplies: item.threadPreview.replies,
91
- totalReplyCount: item.threadPreview.totalReplyCount
93
+ totalReplyCount: item.threadPreview.totalReplyCount,
94
+ theme: theme
92
95
  });
93
96
  }
94
97
  return /*#__PURE__*/ _jsx(TimelineItem, {
95
- item: item
98
+ item: item,
99
+ theme: theme
96
100
  });
97
101
  }).map((jsx)=>jsx.toString()).join("");
98
102
  // Determine next cursor
99
103
  const lastPost = displayPosts[displayPosts.length - 1];
100
104
  const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
101
105
  // Build load-more button HTML
102
- const loadMoreHtml = nextCursor ? `<div id="load-more-container" class="mt-6 text-center"><button class="btn btn-outline" data-on:click="@get('/api/timeline?cursor=${nextCursor}')">Load more</button></div>` : "";
106
+ const loadMoreHtml = nextCursor ? `<div id="load-more-container" class="mt-8 text-center"><button class="text-sm text-muted-foreground hover:text-foreground hover:underline" data-on:click="@get('/api/timeline?cursor=${nextCursor}')">Load more</button></div>` : "";
103
107
  return sse(c, async (stream)=>{
104
108
  // Append new items to the feed
105
109
  stream.patchElements(itemsHtml, {