@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
@@ -3,24 +3,27 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import type { Bindings } from "../../types.js";
6
+ import type { Context } from "hono";
7
+ import type { Bindings, FeedData } from "../../types.js";
7
8
  import type { AppVariables } from "../../app.js";
8
- import * as sqid from "../../lib/sqid.js";
9
- import * as time from "../../lib/time.js";
10
- import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
9
+ import { defaultRssRenderer, defaultAtomRenderer } from "../../lib/feed.js";
10
+ import { getSiteLanguage } from "../../lib/config.js";
11
+ import { buildMediaMap } from "../../lib/media-helpers.js";
12
+ import { createMediaContext, toPostViews } from "../../lib/view.js";
11
13
 
12
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
13
15
 
14
16
  export const rssRoutes = new Hono<Env>();
15
17
 
16
- // RSS 2.0 Feed - main feed at /feed
17
- rssRoutes.get("/", async (c) => {
18
+ /**
19
+ * Build FeedData from the Hono context.
20
+ */
21
+ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
18
22
  const all = await c.var.services.settings.getAll();
19
23
  const siteName = all["SITE_NAME"] ?? "Jant";
20
24
  const siteDescription = all["SITE_DESCRIPTION"] ?? "";
21
25
  const siteUrl = c.env.SITE_URL;
22
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
23
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
26
+ const siteLanguage = await getSiteLanguage(c);
24
27
 
25
28
  const posts = await c.var.services.posts.list({
26
29
  visibility: ["featured", "quiet"],
@@ -29,45 +32,41 @@ rssRoutes.get("/", async (c) => {
29
32
 
30
33
  // Batch load media for enclosures
31
34
  const postIds = posts.map((p) => p.id);
32
- const mediaMap = await c.var.services.media.getByPostIds(postIds);
33
-
34
- const items = posts
35
- .map((post) => {
36
- const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
37
- const title = post.title || `Post #${post.id}`;
38
- const pubDate = new Date(post.publishedAt * 1000).toUTCString();
39
-
40
- // Add enclosure for first media attachment
41
- const postMedia = mediaMap.get(post.id);
42
- const firstMedia = postMedia?.[0];
43
- const enclosure = firstMedia
44
- ? `\n <enclosure url="${getMediaUrl(firstMedia.id, firstMedia.storageKey, getPublicUrlForProvider(firstMedia.provider, r2PublicUrl, s3PublicUrl))}" length="${firstMedia.size}" type="${firstMedia.mimeType}"/>`
45
- : "";
35
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
36
+ const mediaCtx = createMediaContext(c);
37
+ const mediaMap = buildMediaMap(
38
+ rawMediaMap,
39
+ mediaCtx.r2PublicUrl,
40
+ mediaCtx.imageTransformUrl,
41
+ mediaCtx.s3PublicUrl,
42
+ );
43
+
44
+ // Transform to PostView[] with media
45
+ const postViews = toPostViews(
46
+ posts.map((p) => ({
47
+ ...p,
48
+ mediaAttachments: mediaMap.get(p.id) ?? [],
49
+ })),
50
+ mediaCtx,
51
+ );
52
+
53
+ return {
54
+ siteName,
55
+ siteDescription,
56
+ siteUrl,
57
+ siteLanguage,
58
+ posts: postViews,
59
+ };
60
+ }
46
61
 
47
- return `
48
- <item>
49
- <title><![CDATA[${escapeXml(title)}]]></title>
50
- <link>${link}</link>
51
- <guid isPermaLink="true">${link}</guid>
52
- <pubDate>${pubDate}</pubDate>
53
- <description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
54
- </item>`;
55
- })
56
- .join("");
62
+ // RSS 2.0 Feed - main feed at /feed
63
+ rssRoutes.get("/", async (c) => {
64
+ const feedData = await buildFeedData(c);
57
65
 
58
- const rss = `<?xml version="1.0" encoding="UTF-8"?>
59
- <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
60
- <channel>
61
- <title>${escapeXml(siteName)}</title>
62
- <link>${siteUrl}</link>
63
- <description>${escapeXml(siteDescription)}</description>
64
- <language>en</language>
65
- <atom:link href="${siteUrl}/feed" rel="self" type="application/rss+xml"/>
66
- ${items}
67
- </channel>
68
- </rss>`;
66
+ const renderer = c.var.config.theme?.feed?.rss ?? defaultRssRenderer;
67
+ const xml = renderer(feedData);
69
68
 
70
- return new Response(rss, {
69
+ return new Response(xml, {
71
70
  headers: {
72
71
  "Content-Type": "application/rss+xml; charset=utf-8",
73
72
  },
@@ -76,60 +75,14 @@ rssRoutes.get("/", async (c) => {
76
75
 
77
76
  // Atom Feed
78
77
  rssRoutes.get("/atom.xml", async (c) => {
79
- const all = await c.var.services.settings.getAll();
80
- const siteName = all["SITE_NAME"] ?? "Jant";
81
- const siteDescription = all["SITE_DESCRIPTION"] ?? "";
82
- const siteUrl = c.env.SITE_URL;
83
-
84
- const posts = await c.var.services.posts.list({
85
- visibility: ["featured", "quiet"],
86
- limit: 50,
87
- });
78
+ const feedData = await buildFeedData(c);
88
79
 
89
- const entries = posts
90
- .map((post) => {
91
- const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
92
- const title = post.title || `Post #${post.id}`;
93
- const updated = time.toISOString(post.updatedAt);
94
- const published = time.toISOString(post.publishedAt);
80
+ const renderer = c.var.config.theme?.feed?.atom ?? defaultAtomRenderer;
81
+ const xml = renderer(feedData);
95
82
 
96
- return `
97
- <entry>
98
- <title>${escapeXml(title)}</title>
99
- <link href="${link}" rel="alternate"/>
100
- <id>${link}</id>
101
- <published>${published}</published>
102
- <updated>${updated}</updated>
103
- <content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
104
- </entry>`;
105
- })
106
- .join("");
107
-
108
- const now = time.toISOString(time.now());
109
-
110
- const atom = `<?xml version="1.0" encoding="UTF-8"?>
111
- <feed xmlns="http://www.w3.org/2005/Atom">
112
- <title>${escapeXml(siteName)}</title>
113
- <subtitle>${escapeXml(siteDescription)}</subtitle>
114
- <link href="${siteUrl}" rel="alternate"/>
115
- <link href="${siteUrl}/feed/atom.xml" rel="self"/>
116
- <id>${siteUrl}/</id>
117
- <updated>${now}</updated>
118
- ${entries}
119
- </feed>`;
120
-
121
- return new Response(atom, {
83
+ return new Response(xml, {
122
84
  headers: {
123
85
  "Content-Type": "application/atom+xml; charset=utf-8",
124
86
  },
125
87
  });
126
88
  });
127
-
128
- function escapeXml(str: string): string {
129
- return str
130
- .replace(/&/g, "&amp;")
131
- .replace(/</g, "&lt;")
132
- .replace(/>/g, "&gt;")
133
- .replace(/"/g, "&quot;")
134
- .replace(/'/g, "&apos;");
135
- }
@@ -5,8 +5,8 @@
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
- import * as sqid from "../../lib/sqid.js";
9
- import * as time from "../../lib/time.js";
8
+ import { defaultSitemapRenderer } from "../../lib/feed.js";
9
+ import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
10
10
 
11
11
  type Env = { Bindings: Bindings; Variables: AppVariables };
12
12
 
@@ -21,36 +21,14 @@ sitemapRoutes.get("/sitemap.xml", async (c) => {
21
21
  limit: 1000,
22
22
  });
23
23
 
24
- const urls = posts
25
- .map((post) => {
26
- const loc = `${siteUrl}/p/${sqid.encode(post.id)}`;
27
- const lastmod = time.toISOString(post.updatedAt).split("T")[0];
28
- const priority = post.visibility === "featured" ? "0.8" : "0.6";
24
+ // Transform to PostView[]
25
+ const mediaCtx = createMediaContext(c);
26
+ const postViews = toPostViewsFromPosts(posts, mediaCtx);
29
27
 
30
- return `
31
- <url>
32
- <loc>${loc}</loc>
33
- <lastmod>${lastmod}</lastmod>
34
- <priority>${priority}</priority>
35
- </url>`;
36
- })
37
- .join("");
28
+ const renderer = c.var.config.theme?.feed?.sitemap ?? defaultSitemapRenderer;
29
+ const xml = renderer({ siteUrl, posts: postViews });
38
30
 
39
- // Add homepage
40
- const homepageUrl = `
41
- <url>
42
- <loc>${siteUrl}/</loc>
43
- <priority>1.0</priority>
44
- <changefreq>daily</changefreq>
45
- </url>`;
46
-
47
- const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
48
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
49
- ${homepageUrl}
50
- ${urls}
51
- </urlset>`;
52
-
53
- return new Response(sitemap, {
31
+ return new Response(xml, {
54
32
  headers: {
55
33
  "Content-Type": "application/xml; charset=utf-8",
56
34
  },
@@ -5,15 +5,13 @@
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
- import { useLingui } from "@lingui/react/macro";
9
- import type { Bindings, Post, PostType } from "../../types.js";
8
+ import type { Bindings, PostType } from "../../types.js";
10
9
  import type { AppVariables } from "../../app.js";
11
- import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
12
- import { Pagination } from "../../theme/components/index.js";
13
10
  import { POST_TYPES } from "../../types.js";
14
- import * as sqid from "../../lib/sqid.js";
15
- import * as time from "../../lib/time.js";
11
+ import { ArchivePage as DefaultArchivePage } from "../../themes/minimal/pages/ArchivePage.js";
16
12
  import { getNavigationData } from "../../lib/navigation.js";
13
+ import { renderPublicPage } from "../../lib/render.js";
14
+ import { createMediaContext, toArchiveGroups } from "../../lib/view.js";
17
15
 
18
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
19
17
 
@@ -21,191 +19,6 @@ const PAGE_SIZE = 50;
21
19
 
22
20
  export const archiveRoutes = new Hono<Env>();
23
21
 
24
- function getTypeLabel(type: string): string {
25
- const { t } = useLingui();
26
- const labels: Record<string, string> = {
27
- note: t({ message: "Note", comment: "@context: Post type label - note" }),
28
- article: t({
29
- message: "Article",
30
- comment: "@context: Post type label - article",
31
- }),
32
- link: t({ message: "Link", comment: "@context: Post type label - link" }),
33
- quote: t({
34
- message: "Quote",
35
- comment: "@context: Post type label - quote",
36
- }),
37
- image: t({
38
- message: "Image",
39
- comment: "@context: Post type label - image",
40
- }),
41
- page: t({ message: "Page", comment: "@context: Post type label - page" }),
42
- };
43
- return labels[type] ?? type;
44
- }
45
-
46
- function getTypeLabelPlural(type: string): string {
47
- const { t } = useLingui();
48
- const labels: Record<string, string> = {
49
- note: t({
50
- message: "Notes",
51
- comment: "@context: Post type label plural - notes",
52
- }),
53
- article: t({
54
- message: "Articles",
55
- comment: "@context: Post type label plural - articles",
56
- }),
57
- link: t({
58
- message: "Links",
59
- comment: "@context: Post type label plural - links",
60
- }),
61
- quote: t({
62
- message: "Quotes",
63
- comment: "@context: Post type label plural - quotes",
64
- }),
65
- image: t({
66
- message: "Images",
67
- comment: "@context: Post type label plural - images",
68
- }),
69
- page: t({
70
- message: "Pages",
71
- comment: "@context: Post type label plural - pages",
72
- }),
73
- };
74
- return labels[type] ?? `${type}s`;
75
- }
76
-
77
- function formatYearMonth(yearMonth: string): string {
78
- const [year, month] = yearMonth.split("-");
79
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- yearMonth format YYYY-MM guarantees both year and month exist
80
- const date = new Date(parseInt(year!, 10), parseInt(month!, 10) - 1);
81
- return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
82
- }
83
-
84
- function ArchiveContent({
85
- displayPosts,
86
- hasMore,
87
- nextCursor,
88
- type,
89
- grouped,
90
- replyCounts,
91
- }: {
92
- displayPosts: Post[];
93
- hasMore: boolean;
94
- nextCursor?: number;
95
- type?: string;
96
- grouped: Map<string, Post[]>;
97
- replyCounts: Map<number, number>;
98
- }) {
99
- const { t } = useLingui();
100
- const title = type
101
- ? getTypeLabelPlural(type)
102
- : t({ message: "Archive", comment: "@context: Archive page title" });
103
-
104
- return (
105
- <div>
106
- <header class="mb-8">
107
- <h1 class="text-2xl font-semibold">{title}</h1>
108
-
109
- {/* Type filter */}
110
- <nav class="flex flex-wrap gap-2 mt-4">
111
- <a
112
- href="/archive"
113
- class={`badge ${!type ? "badge-primary" : "badge-outline"}`}
114
- >
115
- {t({
116
- message: "All",
117
- comment: "@context: Archive filter - all types",
118
- })}
119
- </a>
120
- {POST_TYPES.filter((t) => t !== "page").map((typeKey) => (
121
- <a
122
- key={typeKey}
123
- href={`/archive?type=${typeKey}`}
124
- class={`badge ${type === typeKey ? "badge-primary" : "badge-outline"}`}
125
- >
126
- {getTypeLabelPlural(typeKey)}
127
- </a>
128
- ))}
129
- </nav>
130
- </header>
131
-
132
- <main>
133
- {displayPosts.length === 0 ? (
134
- <p class="text-muted-foreground">
135
- {t({
136
- message: "No posts found.",
137
- comment: "@context: Archive empty state",
138
- })}
139
- </p>
140
- ) : (
141
- Array.from(grouped.entries()).map(([yearMonth, monthPosts]) => (
142
- <section key={yearMonth} class="mb-8">
143
- <h2 class="text-lg font-medium mb-4 text-muted-foreground">
144
- {formatYearMonth(yearMonth)}
145
- </h2>
146
- <div class="flex flex-col gap-3">
147
- {monthPosts.map((post) => {
148
- const replyCount = replyCounts.get(post.id);
149
- return (
150
- <article key={post.id} class="flex items-baseline gap-4">
151
- <time
152
- class="text-sm text-muted-foreground w-12 shrink-0"
153
- datetime={time.toISOString(post.publishedAt)}
154
- >
155
- {new Date(post.publishedAt * 1000).getDate()}
156
- </time>
157
- <div class="flex-1 min-w-0">
158
- <a
159
- href={`/p/${sqid.encode(post.id)}`}
160
- class="hover:underline"
161
- >
162
- {post.title ||
163
- post.content?.slice(0, 80) ||
164
- `Post #${post.id}`}
165
- </a>
166
- {!type && (
167
- <span class="ml-2 badge-outline text-xs">
168
- {getTypeLabel(post.type)}
169
- </span>
170
- )}
171
- {replyCount && replyCount > 0 && (
172
- <span class="ml-2 text-xs text-muted-foreground">
173
- (
174
- {replyCount === 1
175
- ? t({
176
- message: "1 reply",
177
- comment:
178
- "@context: Archive post reply indicator - single",
179
- })
180
- : t({
181
- message: "{count} replies",
182
- comment:
183
- "@context: Archive post reply indicator - plural",
184
- values: { count: String(replyCount) },
185
- })}
186
- )
187
- </span>
188
- )}
189
- </div>
190
- </article>
191
- );
192
- })}
193
- </div>
194
- </section>
195
- ))
196
- )}
197
- </main>
198
-
199
- {/* Pagination */}
200
- <Pagination
201
- baseUrl={type ? `/archive?type=${type}` : "/archive"}
202
- hasMore={hasMore}
203
- nextCursor={nextCursor}
204
- />
205
- </div>
206
- );
207
- }
208
-
209
22
  // Archive page - all posts
210
23
  archiveRoutes.get("/", async (c) => {
211
24
  const typeParam = c.req.query("type") as PostType | undefined;
@@ -230,10 +43,6 @@ archiveRoutes.get("/", async (c) => {
230
43
  const hasMore = posts.length > PAGE_SIZE;
231
44
  const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
232
45
 
233
- // Get reply counts for thread indicators
234
- const postIds = displayPosts.map((p) => p.id);
235
- const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
236
-
237
46
  // Get next cursor
238
47
  const nextCursor =
239
48
  hasMore && displayPosts.length > 0
@@ -253,18 +62,24 @@ archiveRoutes.get("/", async (c) => {
253
62
  grouped.get(key)!.push(post);
254
63
  }
255
64
 
256
- return c.html(
257
- <BaseLayout title={`Archive - ${navData.siteName}`} c={c}>
258
- <SiteLayout {...navData}>
259
- <ArchiveContent
260
- displayPosts={displayPosts}
261
- hasMore={hasMore}
262
- nextCursor={nextCursor}
263
- type={type}
264
- grouped={grouped}
265
- replyCounts={replyCounts}
266
- />
267
- </SiteLayout>
268
- </BaseLayout>,
269
- );
65
+ // Transform to View Models
66
+ const mediaCtx = createMediaContext(c);
67
+ const groups = toArchiveGroups(grouped, mediaCtx);
68
+
69
+ const components = c.var.config.theme?.components;
70
+ const Page = components?.ArchivePage ?? DefaultArchivePage;
71
+
72
+ return renderPublicPage(c, {
73
+ title: `Archive - ${navData.siteName}`,
74
+ navData,
75
+ content: (
76
+ <Page
77
+ groups={groups}
78
+ hasMore={hasMore}
79
+ nextCursor={nextCursor}
80
+ type={type}
81
+ theme={components}
82
+ />
83
+ ),
84
+ });
270
85
  });
@@ -3,77 +3,17 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import { useLingui } from "@lingui/react/macro";
7
- import type { Bindings, Collection, Post } 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 * as sqid from "../../lib/sqid.js";
11
- import * as time from "../../lib/time.js";
8
+ import { CollectionPage as DefaultCollectionPage } from "../../themes/minimal/pages/CollectionPage.js";
12
9
  import { getNavigationData } from "../../lib/navigation.js";
10
+ import { renderPublicPage } from "../../lib/render.js";
11
+ import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
13
12
 
14
13
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
14
 
16
15
  export const collectionRoutes = new Hono<Env>();
17
16
 
18
- function CollectionContent({
19
- collection,
20
- posts,
21
- }: {
22
- collection: Collection;
23
- posts: Post[];
24
- }) {
25
- const { t } = useLingui();
26
-
27
- return (
28
- <div>
29
- <header class="mb-8">
30
- <h1 class="text-2xl font-semibold">{collection.title}</h1>
31
- {collection.description && (
32
- <p class="text-muted-foreground mt-2">{collection.description}</p>
33
- )}
34
- </header>
35
-
36
- <main class="flex flex-col gap-6">
37
- {posts.length === 0 ? (
38
- <p class="text-muted-foreground">
39
- {t({
40
- message: "No posts in this collection.",
41
- comment: "@context: Empty state message",
42
- })}
43
- </p>
44
- ) : (
45
- posts.map((post) => (
46
- <article key={post.id} class="h-entry">
47
- {post.title && (
48
- <h2 class="p-name text-lg font-medium mb-2">
49
- <a
50
- href={`/p/${sqid.encode(post.id)}`}
51
- class="u-url hover:underline"
52
- >
53
- {post.title}
54
- </a>
55
- </h2>
56
- )}
57
- <div
58
- class="e-content prose prose-sm"
59
- dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
60
- />
61
- <footer class="mt-2 text-sm text-muted-foreground">
62
- <time
63
- class="dt-published"
64
- datetime={time.toISOString(post.publishedAt)}
65
- >
66
- {time.formatDate(post.publishedAt)}
67
- </time>
68
- </footer>
69
- </article>
70
- ))
71
- )}
72
- </main>
73
- </div>
74
- );
75
- }
76
-
77
17
  collectionRoutes.get("/:path", async (c) => {
78
18
  const path = c.req.param("path");
79
19
 
@@ -83,15 +23,19 @@ collectionRoutes.get("/:path", async (c) => {
83
23
  const posts = await c.var.services.collections.getPosts(collection.id);
84
24
  const navData = await getNavigationData(c);
85
25
 
86
- return c.html(
87
- <BaseLayout
88
- title={`${collection.title} - ${navData.siteName}`}
89
- description={collection.description ?? undefined}
90
- c={c}
91
- >
92
- <SiteLayout {...navData}>
93
- <CollectionContent collection={collection} posts={posts} />
94
- </SiteLayout>
95
- </BaseLayout>,
96
- );
26
+ // Transform to View Models
27
+ const mediaCtx = createMediaContext(c);
28
+ const postViews = toPostViewsFromPosts(posts, mediaCtx);
29
+
30
+ const components = c.var.config.theme?.components;
31
+ const Page = components?.CollectionPage ?? DefaultCollectionPage;
32
+
33
+ return renderPublicPage(c, {
34
+ title: `${collection.title} - ${navData.siteName}`,
35
+ description: collection.description ?? undefined,
36
+ navData,
37
+ content: (
38
+ <Page collection={collection} posts={postViews} theme={components} />
39
+ ),
40
+ });
97
41
  });