@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
@@ -5,20 +5,13 @@
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
- import { useLingui } from "@lingui/react/macro";
9
- import type { FC } from "hono/jsx";
10
- import type {
11
- Bindings,
12
- PostWithMedia,
13
- TimelineItemData,
14
- TimelineFeedProps,
15
- } from "../../types.js";
8
+ import type { Bindings, TimelineItemView } from "../../types.js";
16
9
  import type { AppVariables } from "../../app.js";
17
- import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
18
10
  import { buildMediaMap } from "../../lib/media-helpers.js";
19
- import { resolveTimelineFeed } from "../../lib/theme-components.js";
20
- import { TimelineFeed as DefaultTimelineFeed } from "../../theme/components/timeline/TimelineFeed.js";
21
11
  import { getNavigationData } from "../../lib/navigation.js";
12
+ import { renderPublicPage } from "../../lib/render.js";
13
+ import { HomePage as DefaultHomePage } from "../../themes/minimal/pages/HomePage.js";
14
+ import { createMediaContext, toPostView, toPostViews } from "../../lib/view.js";
22
15
 
23
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
24
17
 
@@ -26,31 +19,6 @@ const PAGE_SIZE = 20;
26
19
 
27
20
  export const homeRoutes = new Hono<Env>();
28
21
 
29
- function HomeContent({
30
- FeedComponent,
31
- feedProps,
32
- }: {
33
- FeedComponent: FC<TimelineFeedProps>;
34
- feedProps: TimelineFeedProps;
35
- }) {
36
- const { t } = useLingui();
37
-
38
- return (
39
- <>
40
- {feedProps.items.length === 0 ? (
41
- <p class="text-muted-foreground">
42
- {t({
43
- message: "No posts yet.",
44
- comment: "@context: Empty state message on home page",
45
- })}
46
- </p>
47
- ) : (
48
- <FeedComponent {...feedProps} />
49
- )}
50
- </>
51
- );
52
- }
53
-
54
22
  homeRoutes.get("/", async (c) => {
55
23
  const navData = await getNavigationData(c);
56
24
 
@@ -68,14 +36,12 @@ homeRoutes.get("/", async (c) => {
68
36
  // Batch load media attachments
69
37
  const postIds = displayPosts.map((p) => p.id);
70
38
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
71
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
72
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
73
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
39
+ const mediaCtx = createMediaContext(c);
74
40
  const mediaMap = buildMediaMap(
75
41
  rawMediaMap,
76
- r2PublicUrl,
77
- imageTransformUrl,
78
- s3PublicUrl,
42
+ mediaCtx.r2PublicUrl,
43
+ mediaCtx.imageTransformUrl,
44
+ mediaCtx.s3PublicUrl,
79
45
  );
80
46
 
81
47
  // Get reply counts to identify thread roots
@@ -99,59 +65,59 @@ homeRoutes.get("/", async (c) => {
99
65
  previewReplyIds.length > 0
100
66
  ? buildMediaMap(
101
67
  await c.var.services.media.getByPostIds(previewReplyIds),
102
- r2PublicUrl,
103
- imageTransformUrl,
104
- s3PublicUrl,
68
+ mediaCtx.r2PublicUrl,
69
+ mediaCtx.imageTransformUrl,
70
+ mediaCtx.s3PublicUrl,
105
71
  )
106
72
  : new Map();
107
73
 
108
- // Assemble timeline items
109
- const items: TimelineItemData[] = displayPosts.map((post) => {
110
- const postWithMedia: PostWithMedia = {
111
- ...post,
112
- mediaAttachments: mediaMap.get(post.id) ?? [],
113
- };
74
+ // Assemble timeline items with View Models
75
+ const items: TimelineItemView[] = displayPosts.map((post) => {
76
+ const postView = toPostView(
77
+ { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
78
+ mediaCtx,
79
+ );
114
80
 
115
81
  const replyCount = replyCounts.get(post.id) ?? 0;
116
82
  const previewReplies = threadPreviews.get(post.id);
117
83
 
118
84
  if (replyCount > 0 && previewReplies) {
119
85
  return {
120
- post: postWithMedia,
86
+ post: postView,
121
87
  threadPreview: {
122
- replies: previewReplies.map((r) => ({
123
- ...r,
124
- mediaAttachments: previewMediaMap.get(r.id) ?? [],
125
- })),
88
+ replies: toPostViews(
89
+ previewReplies.map((r) => ({
90
+ ...r,
91
+ mediaAttachments: previewMediaMap.get(r.id) ?? [],
92
+ })),
93
+ mediaCtx,
94
+ ),
126
95
  totalReplyCount: replyCount,
127
96
  },
128
97
  };
129
98
  }
130
99
 
131
- return { post: postWithMedia };
100
+ return { post: postView };
132
101
  });
133
102
 
134
103
  // Determine next cursor
135
104
  const lastPost = displayPosts[displayPosts.length - 1];
136
105
  const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
137
106
 
138
- // Resolve theme components
139
- const Feed = resolveTimelineFeed(
140
- DefaultTimelineFeed,
141
- c.var.config.theme?.components,
142
- );
143
-
144
- const feedProps: TimelineFeedProps = {
145
- items,
146
- hasMore,
147
- nextCursor,
148
- };
149
-
150
- return c.html(
151
- <BaseLayout title={navData.siteName} c={c}>
152
- <SiteLayout {...navData}>
153
- <HomeContent FeedComponent={Feed} feedProps={feedProps} />
154
- </SiteLayout>
155
- </BaseLayout>,
156
- );
107
+ // Resolve page component
108
+ const components = c.var.config.theme?.components;
109
+ const Page = components?.HomePage ?? DefaultHomePage;
110
+
111
+ return renderPublicPage(c, {
112
+ title: navData.siteName,
113
+ navData,
114
+ content: (
115
+ <Page
116
+ items={items}
117
+ hasMore={hasMore}
118
+ nextCursor={nextCursor}
119
+ theme={components}
120
+ />
121
+ ),
122
+ });
157
123
  });
@@ -5,30 +5,17 @@
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
- import type { Bindings, Post } from "../../types.js";
8
+ import type { Bindings } from "../../types.js";
9
9
  import type { AppVariables } from "../../app.js";
10
- import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
10
+ import { SinglePage as DefaultSinglePage } from "../../themes/minimal/pages/SinglePage.js";
11
11
  import { getNavigationData } from "../../lib/navigation.js";
12
+ import { renderPublicPage } from "../../lib/render.js";
13
+ import { createMediaContext, toPostViewFromPost } from "../../lib/view.js";
12
14
 
13
15
  type Env = { Bindings: Bindings; Variables: AppVariables };
14
16
 
15
17
  export const pageRoutes = new Hono<Env>();
16
18
 
17
- function PageContent({ page }: { page: Post }) {
18
- return (
19
- <article class="h-entry">
20
- {page.title && (
21
- <h1 class="p-name text-3xl font-semibold mb-6">{page.title}</h1>
22
- )}
23
-
24
- <div
25
- class="e-content prose"
26
- dangerouslySetInnerHTML={{ __html: page.contentHtml || "" }}
27
- />
28
- </article>
29
- );
30
- }
31
-
32
19
  // Catch-all for custom page paths
33
20
  pageRoutes.get("/:path", async (c) => {
34
21
  const path = c.req.param("path");
@@ -48,15 +35,17 @@ pageRoutes.get("/:path", async (c) => {
48
35
 
49
36
  const navData = await getNavigationData(c);
50
37
 
51
- return c.html(
52
- <BaseLayout
53
- title={`${page.title} - ${navData.siteName}`}
54
- description={page.content?.slice(0, 160)}
55
- c={c}
56
- >
57
- <SiteLayout {...navData}>
58
- <PageContent page={page} />
59
- </SiteLayout>
60
- </BaseLayout>,
61
- );
38
+ // Transform to View Model
39
+ const mediaCtx = createMediaContext(c);
40
+ const pageView = toPostViewFromPost(page, mediaCtx);
41
+
42
+ const components = c.var.config.theme?.components;
43
+ const Page = components?.SinglePage ?? DefaultSinglePage;
44
+
45
+ return renderPublicPage(c, {
46
+ title: `${page.title} - ${navData.siteName}`,
47
+ description: page.content?.slice(0, 160),
48
+ navData,
49
+ content: <Page page={pageView} theme={components} />,
50
+ });
62
51
  });
@@ -3,66 +3,19 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import { useLingui } from "@lingui/react/macro";
7
- import type { Bindings, Post, MediaAttachment } from "../../types.js";
6
+ import type { Bindings } from "../../types.js";
8
7
  import type { AppVariables } from "../../app.js";
9
- import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
10
- import { MediaGallery } from "../../theme/components/index.js";
8
+ import { PostPage as DefaultPostPage } from "../../themes/minimal/pages/PostPage.js";
11
9
  import * as sqid from "../../lib/sqid.js";
12
- import * as time from "../../lib/time.js";
13
- import {
14
- getMediaUrl,
15
- getImageUrl,
16
- getPublicUrlForProvider,
17
- } from "../../lib/image.js";
18
10
  import { getNavigationData } from "../../lib/navigation.js";
11
+ import { renderPublicPage } from "../../lib/render.js";
12
+ import { buildMediaMap } from "../../lib/media-helpers.js";
13
+ import { createMediaContext, toPostView } from "../../lib/view.js";
19
14
 
20
15
  type Env = { Bindings: Bindings; Variables: AppVariables };
21
16
 
22
17
  export const postRoutes = new Hono<Env>();
23
18
 
24
- function PostContent({
25
- post,
26
- mediaAttachments,
27
- }: {
28
- post: Post;
29
- mediaAttachments: MediaAttachment[];
30
- }) {
31
- const { t } = useLingui();
32
-
33
- return (
34
- <article class="h-entry">
35
- {post.title && (
36
- <h1 class="p-name text-2xl font-semibold mb-4">{post.title}</h1>
37
- )}
38
-
39
- <div
40
- class="e-content prose"
41
- dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
42
- />
43
-
44
- {mediaAttachments.length > 0 && (
45
- <MediaGallery attachments={mediaAttachments} />
46
- )}
47
-
48
- <footer class="mt-6 pt-4 border-t text-sm text-muted-foreground">
49
- <time
50
- class="dt-published"
51
- datetime={time.toISOString(post.publishedAt)}
52
- >
53
- {time.formatDate(post.publishedAt)}
54
- </time>
55
- <a href={`/p/${sqid.encode(post.id)}`} class="u-url ml-4">
56
- {t({
57
- message: "Permalink",
58
- comment: "@context: Link to permanent URL of post",
59
- })}
60
- </a>
61
- </footer>
62
- </article>
63
- );
64
- }
65
-
66
19
  postRoutes.get("/:id", async (c) => {
67
20
  const paramId = c.req.param("id");
68
21
 
@@ -87,43 +40,32 @@ postRoutes.get("/:id", async (c) => {
87
40
  return c.notFound();
88
41
  }
89
42
 
90
- // Load media attachments
91
- const rawMedia = await c.var.services.media.getByPostId(post.id);
92
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
93
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
94
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
43
+ // Batch load media attachments
44
+ const rawMediaMap = await c.var.services.media.getByPostIds([post.id]);
45
+ const mediaCtx = createMediaContext(c);
46
+ const mediaMap = buildMediaMap(
47
+ rawMediaMap,
48
+ mediaCtx.r2PublicUrl,
49
+ mediaCtx.imageTransformUrl,
50
+ mediaCtx.s3PublicUrl,
51
+ );
95
52
 
96
- const mediaAttachments: MediaAttachment[] = rawMedia.map((m) => {
97
- const publicUrl = getPublicUrlForProvider(
98
- m.provider,
99
- r2PublicUrl,
100
- s3PublicUrl,
101
- );
102
- return {
103
- id: m.id,
104
- url: getMediaUrl(m.id, m.storageKey, publicUrl),
105
- previewUrl: getImageUrl(
106
- getMediaUrl(m.id, m.storageKey, publicUrl),
107
- imageTransformUrl,
108
- { width: 400, quality: 80, format: "auto", fit: "cover" },
109
- ),
110
- alt: m.alt,
111
- blurhash: m.blurhash,
112
- width: m.width,
113
- height: m.height,
114
- position: m.position,
115
- mimeType: m.mimeType,
116
- };
117
- });
53
+ // Transform to View Model
54
+ const postView = toPostView(
55
+ { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
56
+ mediaCtx,
57
+ );
118
58
 
119
59
  const navData = await getNavigationData(c);
120
60
  const title = post.title || navData.siteName;
121
61
 
122
- return c.html(
123
- <BaseLayout title={title} description={post.content?.slice(0, 160)} c={c}>
124
- <SiteLayout {...navData}>
125
- <PostContent post={post} mediaAttachments={mediaAttachments} />
126
- </SiteLayout>
127
- </BaseLayout>,
128
- );
62
+ const components = c.var.config.theme?.components;
63
+ const Page = components?.PostPage ?? DefaultPostPage;
64
+
65
+ return renderPublicPage(c, {
66
+ title,
67
+ description: post.content?.slice(0, 160),
68
+ navData,
69
+ content: <Page post={postView} theme={components} />,
70
+ });
129
71
  });
@@ -3,15 +3,12 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import { useLingui } from "@lingui/react/macro";
7
- import type { Bindings } from "../../types.js";
6
+ import type { Bindings, SearchResult } from "../../types.js";
8
7
  import type { AppVariables } from "../../app.js";
9
- import type { SearchResult } from "../../services/search.js";
10
- import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
11
- import { PagePagination } from "../../theme/components/index.js";
12
- import * as sqid from "../../lib/sqid.js";
13
- import * as time from "../../lib/time.js";
8
+ import { SearchPage as DefaultSearchPage } from "../../themes/minimal/pages/SearchPage.js";
14
9
  import { getNavigationData } from "../../lib/navigation.js";
10
+ import { renderPublicPage } from "../../lib/render.js";
11
+ import { createMediaContext, toSearchResultViews } from "../../lib/view.js";
15
12
 
16
13
  type Env = { Bindings: Bindings; Variables: AppVariables };
17
14
 
@@ -19,128 +16,6 @@ const PAGE_SIZE = 10;
19
16
 
20
17
  export const searchRoutes = new Hono<Env>();
21
18
 
22
- function SearchContent({
23
- query,
24
- results,
25
- error,
26
- hasMore,
27
- page,
28
- }: {
29
- query: string;
30
- results: SearchResult[];
31
- error: string | null;
32
- hasMore: boolean;
33
- page: number;
34
- }) {
35
- const { t } = useLingui();
36
- const searchTitle = t({
37
- message: "Search",
38
- comment: "@context: Search page title",
39
- });
40
-
41
- return (
42
- <div>
43
- <h1 class="text-2xl font-semibold mb-6">{searchTitle}</h1>
44
-
45
- {/* Search form */}
46
- <form method="get" action="/search" class="mb-8">
47
- <div class="flex gap-2">
48
- <input
49
- type="search"
50
- name="q"
51
- class="input flex-1"
52
- placeholder={t({
53
- message: "Search posts...",
54
- comment: "@context: Search input placeholder",
55
- })}
56
- value={query}
57
- autofocus
58
- />
59
- <button type="submit" class="btn">
60
- {t({
61
- message: "Search",
62
- comment: "@context: Search submit button",
63
- })}
64
- </button>
65
- </div>
66
- </form>
67
-
68
- {/* Error */}
69
- {error && (
70
- <div class="alert-destructive mb-6">
71
- <h2>{error}</h2>
72
- </div>
73
- )}
74
-
75
- {/* Results */}
76
- {query && !error && (
77
- <div>
78
- <p class="text-sm text-muted-foreground mb-4">
79
- {results.length === 0
80
- ? t({
81
- message: "No results found.",
82
- comment: "@context: Search empty results",
83
- })
84
- : results.length === 1
85
- ? t({
86
- message: "Found 1 result",
87
- comment: "@context: Search results count - single",
88
- })
89
- : t({
90
- message: "Found {count} results",
91
- comment: "@context: Search results count - multiple",
92
- values: { count: String(results.length) },
93
- })}
94
- </p>
95
-
96
- {results.length > 0 && (
97
- <>
98
- <div class="flex flex-col gap-4">
99
- {results.map((result) => (
100
- <article
101
- key={result.post.id}
102
- class="p-4 rounded-lg border hover:border-primary"
103
- >
104
- <a href={`/p/${sqid.encode(result.post.id)}`} class="block">
105
- <h2 class="font-medium hover:underline">
106
- {result.post.title ||
107
- result.post.content?.slice(0, 60) ||
108
- `Post #${result.post.id}`}
109
- </h2>
110
-
111
- {result.snippet && (
112
- <p
113
- class="text-sm text-muted-foreground mt-2 line-clamp-2"
114
- dangerouslySetInnerHTML={{ __html: result.snippet }}
115
- />
116
- )}
117
-
118
- <footer class="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
119
- <span class="badge-outline">{result.post.type}</span>
120
- <time
121
- datetime={time.toISOString(result.post.publishedAt)}
122
- >
123
- {time.formatDate(result.post.publishedAt)}
124
- </time>
125
- </footer>
126
- </a>
127
- </article>
128
- ))}
129
- </div>
130
-
131
- <PagePagination
132
- baseUrl={`/search?q=${encodeURIComponent(query)}`}
133
- currentPage={page}
134
- hasMore={hasMore}
135
- />
136
- </>
137
- )}
138
- </div>
139
- )}
140
- </div>
141
- );
142
- }
143
-
144
19
  searchRoutes.get("/", async (c) => {
145
20
  const query = c.req.query("q") || "";
146
21
  const pageParam = c.req.query("page");
@@ -149,8 +24,8 @@ searchRoutes.get("/", async (c) => {
149
24
  const navData = await getNavigationData(c);
150
25
 
151
26
  // Only search if there's a query
152
- let results: Awaited<ReturnType<typeof c.var.services.search.search>> = [];
153
- let error: string | null = null;
27
+ let results: SearchResult[] = [];
28
+ let error: string | undefined;
154
29
  let hasMore = false;
155
30
 
156
31
  if (query.trim()) {
@@ -173,24 +48,27 @@ searchRoutes.get("/", async (c) => {
173
48
  }
174
49
  }
175
50
 
176
- return c.html(
177
- <BaseLayout
178
- title={
179
- query
180
- ? `Search: ${query} - ${navData.siteName}`
181
- : `Search - ${navData.siteName}`
182
- }
183
- c={c}
184
- >
185
- <SiteLayout {...navData}>
186
- <SearchContent
187
- query={query}
188
- results={results}
189
- error={error}
190
- hasMore={hasMore}
191
- page={page}
192
- />
193
- </SiteLayout>
194
- </BaseLayout>,
195
- );
51
+ // Transform to View Models
52
+ const mediaCtx = createMediaContext(c);
53
+ const resultViews = toSearchResultViews(results, mediaCtx);
54
+
55
+ const components = c.var.config.theme?.components;
56
+ const Page = components?.SearchPage ?? DefaultSearchPage;
57
+
58
+ return renderPublicPage(c, {
59
+ title: query
60
+ ? `Search: ${query} - ${navData.siteName}`
61
+ : `Search - ${navData.siteName}`,
62
+ navData,
63
+ content: (
64
+ <Page
65
+ query={query}
66
+ results={resultViews}
67
+ error={error}
68
+ hasMore={hasMore}
69
+ page={page}
70
+ theme={components}
71
+ />
72
+ ),
73
+ });
196
74
  });
@@ -4,15 +4,9 @@
4
4
  * Full-text search using FTS5
5
5
  */
6
6
 
7
- import type { Post, Visibility } from "../types.js";
7
+ import type { Post, Visibility, SearchResult } from "../types.js";
8
8
 
9
- export interface SearchResult {
10
- post: Post;
11
- /** FTS5 rank score (lower is better) */
12
- rank: number;
13
- /** Highlighted snippet from content */
14
- snippet?: string;
15
- }
9
+ export type { SearchResult };
16
10
 
17
11
  export interface SearchOptions {
18
12
  /** Limit number of results */
@@ -124,60 +124,6 @@
124
124
  }
125
125
  }
126
126
 
127
- /* Timeline cards */
128
- @layer components {
129
- .timeline-card {
130
- @apply rounded-lg border p-4;
131
- border-color: var(--color-border);
132
- }
133
-
134
- .timeline-card-link {
135
- border-left-width: 4px;
136
- border-left-color: var(--color-primary);
137
- }
138
-
139
- .timeline-card-quote {
140
- border-left-width: 4px;
141
- border-left-color: var(--color-muted-foreground);
142
- background-color: var(--color-muted);
143
- }
144
-
145
- .timeline-card-image {
146
- @apply p-0 overflow-hidden;
147
- }
148
-
149
- .timeline-card-image-gallery {
150
- /* Remove default margin from MediaGallery inside image cards */
151
- > div {
152
- @apply mt-0;
153
- }
154
- }
155
-
156
- .timeline-card-compact {
157
- @apply p-3 text-sm;
158
- }
159
-
160
- .timeline-thread-replies {
161
- @apply relative ml-5;
162
- }
163
-
164
- .timeline-thread-reply {
165
- @apply relative pl-5 mt-3;
166
-
167
- &::after {
168
- content: "";
169
- @apply absolute left-0 top-0 bottom-0 w-px;
170
- background-color: var(--color-border);
171
- }
172
-
173
- &::before {
174
- content: "";
175
- @apply absolute left-0 top-4 h-px w-4;
176
- background-color: var(--color-border);
177
- }
178
- }
179
- }
180
-
181
127
  @keyframes toast-in {
182
128
  from {
183
129
  opacity: 0;