@jant/core 0.3.21 → 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 (82) hide show
  1. package/dist/app.js +1 -1
  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/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 +1 -1
  19. package/dist/theme/components/timeline/ArticleCard.js +7 -11
  20. package/dist/theme/components/timeline/ImageCard.js +10 -13
  21. package/dist/theme/components/timeline/LinkCard.js +4 -7
  22. package/dist/theme/components/timeline/NoteCard.js +5 -8
  23. package/dist/theme/components/timeline/QuoteCard.js +3 -6
  24. package/dist/theme/components/timeline/ThreadPreview.js +9 -10
  25. package/dist/theme/components/timeline/TimelineFeed.js +8 -5
  26. package/dist/theme/components/timeline/TimelineItem.js +22 -2
  27. package/dist/theme/components/timeline/index.js +1 -1
  28. package/dist/theme/index.js +6 -3
  29. package/dist/theme/layouts/SiteLayout.js +10 -39
  30. package/dist/theme/pages/ArchivePage.js +157 -0
  31. package/dist/theme/pages/CollectionPage.js +63 -0
  32. package/dist/theme/pages/HomePage.js +26 -0
  33. package/dist/theme/pages/PostPage.js +48 -0
  34. package/dist/theme/pages/SearchPage.js +120 -0
  35. package/dist/theme/pages/SinglePage.js +23 -0
  36. package/dist/theme/pages/index.js +11 -0
  37. package/package.json +2 -1
  38. package/src/app.tsx +1 -1
  39. package/src/i18n/locales/en.po +31 -31
  40. package/src/i18n/locales/zh-Hans.po +31 -31
  41. package/src/i18n/locales/zh-Hant.po +31 -31
  42. package/src/index.ts +51 -2
  43. package/src/lib/__tests__/theme-components.test.ts +33 -14
  44. package/src/lib/__tests__/view.test.ts +375 -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 +32 -25
  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/theme/components/MediaGallery.tsx +12 -12
  62. package/src/theme/components/index.ts +1 -0
  63. package/src/theme/components/timeline/ArticleCard.tsx +7 -19
  64. package/src/theme/components/timeline/ImageCard.tsx +10 -20
  65. package/src/theme/components/timeline/LinkCard.tsx +4 -11
  66. package/src/theme/components/timeline/NoteCard.tsx +5 -12
  67. package/src/theme/components/timeline/QuoteCard.tsx +3 -10
  68. package/src/theme/components/timeline/ThreadPreview.tsx +5 -5
  69. package/src/theme/components/timeline/TimelineFeed.tsx +7 -3
  70. package/src/theme/components/timeline/TimelineItem.tsx +43 -4
  71. package/src/theme/components/timeline/index.ts +1 -1
  72. package/src/theme/index.ts +7 -3
  73. package/src/theme/layouts/SiteLayout.tsx +25 -77
  74. package/src/theme/layouts/index.ts +2 -1
  75. package/src/theme/pages/ArchivePage.tsx +160 -0
  76. package/src/theme/pages/CollectionPage.tsx +60 -0
  77. package/src/theme/pages/HomePage.tsx +42 -0
  78. package/src/theme/pages/PostPage.tsx +44 -0
  79. package/src/theme/pages/SearchPage.tsx +128 -0
  80. package/src/theme/pages/SinglePage.tsx +24 -0
  81. package/src/theme/pages/index.ts +13 -0
  82. package/src/types.ts +262 -38
@@ -1,18 +1,19 @@
1
1
  /**
2
2
  * RSS Feed Routes
3
3
  */ import { Hono } from "hono";
4
- import * as sqid from "../../lib/sqid.js";
5
- import * as time from "../../lib/time.js";
6
- import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
4
+ import { defaultRssRenderer, defaultAtomRenderer } from "../../lib/feed.js";
5
+ import { getSiteLanguage } from "../../lib/config.js";
6
+ import { buildMediaMap } from "../../lib/media-helpers.js";
7
+ import { createMediaContext, toPostViews } from "../../lib/view.js";
7
8
  export const rssRoutes = new Hono();
8
- // RSS 2.0 Feed - main feed at /feed
9
- rssRoutes.get("/", async (c)=>{
9
+ /**
10
+ * Build FeedData from the Hono context.
11
+ */ async function buildFeedData(c) {
10
12
  const all = await c.var.services.settings.getAll();
11
13
  const siteName = all["SITE_NAME"] ?? "Jant";
12
14
  const siteDescription = all["SITE_DESCRIPTION"] ?? "";
13
15
  const siteUrl = c.env.SITE_URL;
14
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
15
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
16
+ const siteLanguage = await getSiteLanguage(c);
16
17
  const posts = await c.var.services.posts.list({
17
18
  visibility: [
18
19
  "featured",
@@ -22,36 +23,28 @@ rssRoutes.get("/", async (c)=>{
22
23
  });
23
24
  // Batch load media for enclosures
24
25
  const postIds = posts.map((p)=>p.id);
25
- const mediaMap = await c.var.services.media.getByPostIds(postIds);
26
- const items = posts.map((post)=>{
27
- const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
28
- const title = post.title || `Post #${post.id}`;
29
- const pubDate = new Date(post.publishedAt * 1000).toUTCString();
30
- // Add enclosure for first media attachment
31
- const postMedia = mediaMap.get(post.id);
32
- const firstMedia = postMedia?.[0];
33
- const enclosure = firstMedia ? `\n <enclosure url="${getMediaUrl(firstMedia.id, firstMedia.storageKey, getPublicUrlForProvider(firstMedia.provider, r2PublicUrl, s3PublicUrl))}" length="${firstMedia.size}" type="${firstMedia.mimeType}"/>` : "";
34
- return `
35
- <item>
36
- <title><![CDATA[${escapeXml(title)}]]></title>
37
- <link>${link}</link>
38
- <guid isPermaLink="true">${link}</guid>
39
- <pubDate>${pubDate}</pubDate>
40
- <description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
41
- </item>`;
42
- }).join("");
43
- const rss = `<?xml version="1.0" encoding="UTF-8"?>
44
- <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
45
- <channel>
46
- <title>${escapeXml(siteName)}</title>
47
- <link>${siteUrl}</link>
48
- <description>${escapeXml(siteDescription)}</description>
49
- <language>en</language>
50
- <atom:link href="${siteUrl}/feed" rel="self" type="application/rss+xml"/>
51
- ${items}
52
- </channel>
53
- </rss>`;
54
- return new Response(rss, {
26
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
27
+ const mediaCtx = createMediaContext(c);
28
+ const mediaMap = buildMediaMap(rawMediaMap, mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl);
29
+ // Transform to PostView[] with media
30
+ const postViews = toPostViews(posts.map((p)=>({
31
+ ...p,
32
+ mediaAttachments: mediaMap.get(p.id) ?? []
33
+ })), mediaCtx);
34
+ return {
35
+ siteName,
36
+ siteDescription,
37
+ siteUrl,
38
+ siteLanguage,
39
+ posts: postViews
40
+ };
41
+ }
42
+ // RSS 2.0 Feed - main feed at /feed
43
+ rssRoutes.get("/", async (c)=>{
44
+ const feedData = await buildFeedData(c);
45
+ const renderer = c.var.config.theme?.feed?.rss ?? defaultRssRenderer;
46
+ const xml = renderer(feedData);
47
+ return new Response(xml, {
55
48
  headers: {
56
49
  "Content-Type": "application/rss+xml; charset=utf-8"
57
50
  }
@@ -59,49 +52,12 @@ rssRoutes.get("/", async (c)=>{
59
52
  });
60
53
  // Atom Feed
61
54
  rssRoutes.get("/atom.xml", async (c)=>{
62
- const all = await c.var.services.settings.getAll();
63
- const siteName = all["SITE_NAME"] ?? "Jant";
64
- const siteDescription = all["SITE_DESCRIPTION"] ?? "";
65
- const siteUrl = c.env.SITE_URL;
66
- const posts = await c.var.services.posts.list({
67
- visibility: [
68
- "featured",
69
- "quiet"
70
- ],
71
- limit: 50
72
- });
73
- const entries = posts.map((post)=>{
74
- const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
75
- const title = post.title || `Post #${post.id}`;
76
- const updated = time.toISOString(post.updatedAt);
77
- const published = time.toISOString(post.publishedAt);
78
- return `
79
- <entry>
80
- <title>${escapeXml(title)}</title>
81
- <link href="${link}" rel="alternate"/>
82
- <id>${link}</id>
83
- <published>${published}</published>
84
- <updated>${updated}</updated>
85
- <content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
86
- </entry>`;
87
- }).join("");
88
- const now = time.toISOString(time.now());
89
- const atom = `<?xml version="1.0" encoding="UTF-8"?>
90
- <feed xmlns="http://www.w3.org/2005/Atom">
91
- <title>${escapeXml(siteName)}</title>
92
- <subtitle>${escapeXml(siteDescription)}</subtitle>
93
- <link href="${siteUrl}" rel="alternate"/>
94
- <link href="${siteUrl}/feed/atom.xml" rel="self"/>
95
- <id>${siteUrl}/</id>
96
- <updated>${now}</updated>
97
- ${entries}
98
- </feed>`;
99
- return new Response(atom, {
55
+ const feedData = await buildFeedData(c);
56
+ const renderer = c.var.config.theme?.feed?.atom ?? defaultAtomRenderer;
57
+ const xml = renderer(feedData);
58
+ return new Response(xml, {
100
59
  headers: {
101
60
  "Content-Type": "application/atom+xml; charset=utf-8"
102
61
  }
103
62
  });
104
63
  });
105
- function escapeXml(str) {
106
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
107
- }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Sitemap Routes
3
3
  */ import { Hono } from "hono";
4
- import * as sqid from "../../lib/sqid.js";
5
- import * as time from "../../lib/time.js";
4
+ import { defaultSitemapRenderer } from "../../lib/feed.js";
5
+ import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
6
6
  export const sitemapRoutes = new Hono();
7
7
  // XML Sitemap
8
8
  sitemapRoutes.get("/sitemap.xml", async (c)=>{
@@ -14,30 +14,15 @@ sitemapRoutes.get("/sitemap.xml", async (c)=>{
14
14
  ],
15
15
  limit: 1000
16
16
  });
17
- const urls = posts.map((post)=>{
18
- const loc = `${siteUrl}/p/${sqid.encode(post.id)}`;
19
- const lastmod = time.toISOString(post.updatedAt).split("T")[0];
20
- const priority = post.visibility === "featured" ? "0.8" : "0.6";
21
- return `
22
- <url>
23
- <loc>${loc}</loc>
24
- <lastmod>${lastmod}</lastmod>
25
- <priority>${priority}</priority>
26
- </url>`;
27
- }).join("");
28
- // Add homepage
29
- const homepageUrl = `
30
- <url>
31
- <loc>${siteUrl}/</loc>
32
- <priority>1.0</priority>
33
- <changefreq>daily</changefreq>
34
- </url>`;
35
- const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
36
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
37
- ${homepageUrl}
38
- ${urls}
39
- </urlset>`;
40
- return new Response(sitemap, {
17
+ // Transform to PostView[]
18
+ const mediaCtx = createMediaContext(c);
19
+ const postViews = toPostViewsFromPosts(posts, mediaCtx);
20
+ const renderer = c.var.config.theme?.feed?.sitemap ?? defaultSitemapRenderer;
21
+ const xml = renderer({
22
+ siteUrl,
23
+ posts: postViews
24
+ });
25
+ return new Response(xml, {
41
26
  headers: {
42
27
  "Content-Type": "application/xml; charset=utf-8"
43
28
  }
@@ -1,191 +1,16 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
1
+ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
2
2
  /**
3
3
  * Archive Page Route
4
4
  *
5
5
  * Shows all posts, optionally filtered by type
6
6
  */ import { Hono } from "hono";
7
- import { useLingui as $_useLingui } from "@jant/core/i18n";
8
- import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
9
- import { Pagination } from "../../theme/components/index.js";
10
7
  import { POST_TYPES } from "../../types.js";
11
- import * as sqid from "../../lib/sqid.js";
12
- import * as time from "../../lib/time.js";
8
+ import { ArchivePage as DefaultArchivePage } from "../../theme/pages/ArchivePage.js";
13
9
  import { getNavigationData } from "../../lib/navigation.js";
10
+ import { renderPublicPage } from "../../lib/render.js";
11
+ import { createMediaContext, toArchiveGroups } from "../../lib/view.js";
14
12
  const PAGE_SIZE = 50;
15
13
  export const archiveRoutes = new Hono();
16
- function getTypeLabel(type) {
17
- const { i18n: $__i18n, _: $__ } = $_useLingui();
18
- const labels = {
19
- note: $__i18n._({
20
- id: "KiJn9B",
21
- message: "Note"
22
- }),
23
- article: $__i18n._({
24
- id: "f6e0Ry",
25
- message: "Article"
26
- }),
27
- link: $__i18n._({
28
- id: "yzF66j",
29
- message: "Link"
30
- }),
31
- quote: $__i18n._({
32
- id: "ZhhOwV",
33
- message: "Quote"
34
- }),
35
- image: $__i18n._({
36
- id: "hG89Ed",
37
- message: "Image"
38
- }),
39
- page: $__i18n._({
40
- id: "6WdDG7",
41
- message: "Page"
42
- })
43
- };
44
- return labels[type] ?? type;
45
- }
46
- function getTypeLabelPlural(type) {
47
- const { i18n: $__i18n, _: $__ } = $_useLingui();
48
- const labels = {
49
- note: $__i18n._({
50
- id: "1DBGsz",
51
- message: "Notes"
52
- }),
53
- article: $__i18n._({
54
- id: "Tt5T6+",
55
- message: "Articles"
56
- }),
57
- link: $__i18n._({
58
- id: "Rj01Fz",
59
- message: "Links"
60
- }),
61
- quote: $__i18n._({
62
- id: "eWLklq",
63
- message: "Quotes"
64
- }),
65
- image: $__i18n._({
66
- id: "an5hVd",
67
- message: "Images"
68
- }),
69
- page: $__i18n._({
70
- id: "wRR604",
71
- message: "Pages"
72
- })
73
- };
74
- return labels[type] ?? `${type}s`;
75
- }
76
- function formatYearMonth(yearMonth) {
77
- const [year, month] = yearMonth.split("-");
78
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- yearMonth format YYYY-MM guarantees both year and month exist
79
- const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
80
- return date.toLocaleDateString("en-US", {
81
- year: "numeric",
82
- month: "long"
83
- });
84
- }
85
- function ArchiveContent({ displayPosts, hasMore, nextCursor, type, grouped, replyCounts }) {
86
- const { i18n: $__i18n, _: $__ } = $_useLingui();
87
- const title = type ? getTypeLabelPlural(type) : $__i18n._({
88
- id: "B495Gs",
89
- message: "Archive"
90
- });
91
- return /*#__PURE__*/ _jsxs("div", {
92
- children: [
93
- /*#__PURE__*/ _jsxs("header", {
94
- class: "mb-8",
95
- children: [
96
- /*#__PURE__*/ _jsx("h1", {
97
- class: "text-2xl font-semibold",
98
- children: title
99
- }),
100
- /*#__PURE__*/ _jsxs("nav", {
101
- class: "flex flex-wrap gap-2 mt-4",
102
- children: [
103
- /*#__PURE__*/ _jsx("a", {
104
- href: "/archive",
105
- class: `badge ${!type ? "badge-primary" : "badge-outline"}`,
106
- children: $__i18n._({
107
- id: "N40H+G",
108
- message: "All"
109
- })
110
- }),
111
- POST_TYPES.filter((t)=>t !== "page").map((typeKey)=>/*#__PURE__*/ _jsx("a", {
112
- href: `/archive?type=${typeKey}`,
113
- class: `badge ${type === typeKey ? "badge-primary" : "badge-outline"}`,
114
- children: getTypeLabelPlural(typeKey)
115
- }, typeKey))
116
- ]
117
- })
118
- ]
119
- }),
120
- /*#__PURE__*/ _jsx("main", {
121
- children: displayPosts.length === 0 ? /*#__PURE__*/ _jsx("p", {
122
- class: "text-muted-foreground",
123
- children: $__i18n._({
124
- id: "Hzi9AA",
125
- message: "No posts found."
126
- })
127
- }) : Array.from(grouped.entries()).map(([yearMonth, monthPosts])=>/*#__PURE__*/ _jsxs("section", {
128
- class: "mb-8",
129
- children: [
130
- /*#__PURE__*/ _jsx("h2", {
131
- class: "text-lg font-medium mb-4 text-muted-foreground",
132
- children: formatYearMonth(yearMonth)
133
- }),
134
- /*#__PURE__*/ _jsx("div", {
135
- class: "flex flex-col gap-3",
136
- children: monthPosts.map((post)=>{
137
- const replyCount = replyCounts.get(post.id);
138
- return /*#__PURE__*/ _jsxs("article", {
139
- class: "flex items-baseline gap-4",
140
- children: [
141
- /*#__PURE__*/ _jsx("time", {
142
- class: "text-sm text-muted-foreground w-12 shrink-0",
143
- datetime: time.toISOString(post.publishedAt),
144
- children: new Date(post.publishedAt * 1000).getDate()
145
- }),
146
- /*#__PURE__*/ _jsxs("div", {
147
- class: "flex-1 min-w-0",
148
- children: [
149
- /*#__PURE__*/ _jsx("a", {
150
- href: `/p/${sqid.encode(post.id)}`,
151
- class: "hover:underline",
152
- children: post.title || post.content?.slice(0, 80) || `Post #${post.id}`
153
- }),
154
- !type && /*#__PURE__*/ _jsx("span", {
155
- class: "ml-2 badge-outline text-xs",
156
- children: getTypeLabel(post.type)
157
- }),
158
- replyCount && replyCount > 0 && /*#__PURE__*/ _jsxs("span", {
159
- class: "ml-2 text-xs text-muted-foreground",
160
- children: [
161
- "(",
162
- replyCount === 1 ? $__i18n._({
163
- id: "TxE+Mj",
164
- message: "1 reply"
165
- }) : $__i18n._({
166
- id: "90Luob",
167
- message: "{count} replies"
168
- }),
169
- ")"
170
- ]
171
- })
172
- ]
173
- })
174
- ]
175
- }, post.id);
176
- })
177
- })
178
- ]
179
- }, yearMonth))
180
- }),
181
- /*#__PURE__*/ _jsx(Pagination, {
182
- baseUrl: type ? `/archive?type=${type}` : "/archive",
183
- hasMore: hasMore,
184
- nextCursor: nextCursor
185
- })
186
- ]
187
- });
188
- }
189
14
  // Archive page - all posts
190
15
  archiveRoutes.get("/", async (c)=>{
191
16
  const typeParam = c.req.query("type");
@@ -207,9 +32,6 @@ archiveRoutes.get("/", async (c)=>{
207
32
  });
208
33
  const hasMore = posts.length > PAGE_SIZE;
209
34
  const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
210
- // Get reply counts for thread indicators
211
- const postIds = displayPosts.map((p)=>p.id);
212
- const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
213
35
  // Get next cursor
214
36
  const nextCursor = hasMore && displayPosts.length > 0 ? displayPosts[displayPosts.length - 1].id : undefined;
215
37
  // Group posts by year-month
@@ -223,19 +45,20 @@ archiveRoutes.get("/", async (c)=>{
223
45
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Map.set() above guarantees key exists
224
46
  grouped.get(key).push(post);
225
47
  }
226
- return c.html(/*#__PURE__*/ _jsx(BaseLayout, {
48
+ // Transform to View Models
49
+ const mediaCtx = createMediaContext(c);
50
+ const groups = toArchiveGroups(grouped, mediaCtx);
51
+ const components = c.var.config.theme?.components;
52
+ const Page = components?.ArchivePage ?? DefaultArchivePage;
53
+ return renderPublicPage(c, {
227
54
  title: `Archive - ${navData.siteName}`,
228
- c: c,
229
- children: /*#__PURE__*/ _jsx(SiteLayout, {
230
- ...navData,
231
- children: /*#__PURE__*/ _jsx(ArchiveContent, {
232
- displayPosts: displayPosts,
233
- hasMore: hasMore,
234
- nextCursor: nextCursor,
235
- type: type,
236
- grouped: grouped,
237
- replyCounts: replyCounts
238
- })
55
+ navData,
56
+ content: /*#__PURE__*/ _jsx(Page, {
57
+ groups: groups,
58
+ hasMore: hasMore,
59
+ nextCursor: nextCursor,
60
+ type: type,
61
+ theme: components
239
62
  })
240
- }));
63
+ });
241
64
  });
@@ -1,85 +1,31 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "hono/jsx/jsx-runtime";
1
+ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
2
2
  /**
3
3
  * Collection Page Route
4
4
  */ import { Hono } from "hono";
5
- import { useLingui as $_useLingui } from "@jant/core/i18n";
6
- import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
7
- import * as sqid from "../../lib/sqid.js";
8
- import * as time from "../../lib/time.js";
5
+ import { CollectionPage as DefaultCollectionPage } from "../../theme/pages/CollectionPage.js";
9
6
  import { getNavigationData } from "../../lib/navigation.js";
7
+ import { renderPublicPage } from "../../lib/render.js";
8
+ import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
10
9
  export const collectionRoutes = new Hono();
11
- function CollectionContent({ collection, posts }) {
12
- const { i18n: $__i18n, _: $__ } = $_useLingui();
13
- return /*#__PURE__*/ _jsxs("div", {
14
- children: [
15
- /*#__PURE__*/ _jsxs("header", {
16
- class: "mb-8",
17
- children: [
18
- /*#__PURE__*/ _jsx("h1", {
19
- class: "text-2xl font-semibold",
20
- children: collection.title
21
- }),
22
- collection.description && /*#__PURE__*/ _jsx("p", {
23
- class: "text-muted-foreground mt-2",
24
- children: collection.description
25
- })
26
- ]
27
- }),
28
- /*#__PURE__*/ _jsx("main", {
29
- class: "flex flex-col gap-6",
30
- children: posts.length === 0 ? /*#__PURE__*/ _jsx("p", {
31
- class: "text-muted-foreground",
32
- children: $__i18n._({
33
- id: "J4FNfC",
34
- message: "No posts in this collection."
35
- })
36
- }) : posts.map((post)=>/*#__PURE__*/ _jsxs("article", {
37
- class: "h-entry",
38
- children: [
39
- post.title && /*#__PURE__*/ _jsx("h2", {
40
- class: "p-name text-lg font-medium mb-2",
41
- children: /*#__PURE__*/ _jsx("a", {
42
- href: `/p/${sqid.encode(post.id)}`,
43
- class: "u-url hover:underline",
44
- children: post.title
45
- })
46
- }),
47
- /*#__PURE__*/ _jsx("div", {
48
- class: "e-content prose prose-sm",
49
- dangerouslySetInnerHTML: {
50
- __html: post.contentHtml || ""
51
- }
52
- }),
53
- /*#__PURE__*/ _jsx("footer", {
54
- class: "mt-2 text-sm text-muted-foreground",
55
- children: /*#__PURE__*/ _jsx("time", {
56
- class: "dt-published",
57
- datetime: time.toISOString(post.publishedAt),
58
- children: time.formatDate(post.publishedAt)
59
- })
60
- })
61
- ]
62
- }, post.id))
63
- })
64
- ]
65
- });
66
- }
67
10
  collectionRoutes.get("/:path", async (c)=>{
68
11
  const path = c.req.param("path");
69
12
  const collection = await c.var.services.collections.getByPath(path);
70
13
  if (!collection) return c.notFound();
71
14
  const posts = await c.var.services.collections.getPosts(collection.id);
72
15
  const navData = await getNavigationData(c);
73
- return c.html(/*#__PURE__*/ _jsx(BaseLayout, {
16
+ // Transform to View Models
17
+ const mediaCtx = createMediaContext(c);
18
+ const postViews = toPostViewsFromPosts(posts, mediaCtx);
19
+ const components = c.var.config.theme?.components;
20
+ const Page = components?.CollectionPage ?? DefaultCollectionPage;
21
+ return renderPublicPage(c, {
74
22
  title: `${collection.title} - ${navData.siteName}`,
75
23
  description: collection.description ?? undefined,
76
- c: c,
77
- children: /*#__PURE__*/ _jsx(SiteLayout, {
78
- ...navData,
79
- children: /*#__PURE__*/ _jsx(CollectionContent, {
80
- collection: collection,
81
- posts: posts
82
- })
24
+ navData,
25
+ content: /*#__PURE__*/ _jsx(Page, {
26
+ collection: collection,
27
+ posts: postViews,
28
+ theme: components
83
29
  })
84
- }));
30
+ });
85
31
  });
@@ -1,31 +1,16 @@
1
- import { jsx as _jsx, Fragment as _Fragment } from "hono/jsx/jsx-runtime";
1
+ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
2
2
  /**
3
3
  * Home Page Route
4
4
  *
5
5
  * Timeline feed with per-type card components and thread previews.
6
6
  */ import { Hono } from "hono";
7
- import { useLingui as $_useLingui } from "@jant/core/i18n";
8
- import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
9
7
  import { buildMediaMap } from "../../lib/media-helpers.js";
10
- import { resolveTimelineFeed } from "../../lib/theme-components.js";
11
- import { TimelineFeed as DefaultTimelineFeed } from "../../theme/components/timeline/TimelineFeed.js";
12
8
  import { getNavigationData } from "../../lib/navigation.js";
9
+ import { renderPublicPage } from "../../lib/render.js";
10
+ import { HomePage as DefaultHomePage } from "../../theme/pages/HomePage.js";
11
+ import { createMediaContext, toPostView, toPostViews } from "../../lib/view.js";
13
12
  const PAGE_SIZE = 20;
14
13
  export const homeRoutes = new Hono();
15
- function HomeContent({ FeedComponent, feedProps }) {
16
- const { i18n: $__i18n, _: $__ } = $_useLingui();
17
- return /*#__PURE__*/ _jsx(_Fragment, {
18
- children: feedProps.items.length === 0 ? /*#__PURE__*/ _jsx("p", {
19
- class: "text-muted-foreground",
20
- children: $__i18n._({
21
- id: "ODiSoW",
22
- message: "No posts yet."
23
- })
24
- }) : /*#__PURE__*/ _jsx(FeedComponent, {
25
- ...feedProps
26
- })
27
- });
28
- }
29
14
  homeRoutes.get("/", async (c)=>{
30
15
  const navData = await getNavigationData(c);
31
16
  // Fetch one extra to determine if there are more
@@ -45,10 +30,8 @@ homeRoutes.get("/", async (c)=>{
45
30
  // Batch load media attachments
46
31
  const postIds = displayPosts.map((p)=>p.id);
47
32
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
48
- const r2PublicUrl = c.env.R2_PUBLIC_URL;
49
- const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
50
- const s3PublicUrl = c.env.S3_PUBLIC_URL;
51
- const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl, s3PublicUrl);
33
+ const mediaCtx = createMediaContext(c);
34
+ const mediaMap = buildMediaMap(rawMediaMap, mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl);
52
35
  // Get reply counts to identify thread roots
53
36
  const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
54
37
  const threadRootIds = postIds.filter((id)=>(replyCounts.get(id) ?? 0) > 0);
@@ -61,50 +44,45 @@ homeRoutes.get("/", async (c)=>{
61
44
  previewReplyIds.push(reply.id);
62
45
  }
63
46
  }
64
- const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), r2PublicUrl, imageTransformUrl, s3PublicUrl) : new Map();
65
- // Assemble timeline items
47
+ const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl) : new Map();
48
+ // Assemble timeline items with View Models
66
49
  const items = displayPosts.map((post)=>{
67
- const postWithMedia = {
50
+ const postView = toPostView({
68
51
  ...post,
69
52
  mediaAttachments: mediaMap.get(post.id) ?? []
70
- };
53
+ }, mediaCtx);
71
54
  const replyCount = replyCounts.get(post.id) ?? 0;
72
55
  const previewReplies = threadPreviews.get(post.id);
73
56
  if (replyCount > 0 && previewReplies) {
74
57
  return {
75
- post: postWithMedia,
58
+ post: postView,
76
59
  threadPreview: {
77
- replies: previewReplies.map((r)=>({
60
+ replies: toPostViews(previewReplies.map((r)=>({
78
61
  ...r,
79
62
  mediaAttachments: previewMediaMap.get(r.id) ?? []
80
- })),
63
+ })), mediaCtx),
81
64
  totalReplyCount: replyCount
82
65
  }
83
66
  };
84
67
  }
85
68
  return {
86
- post: postWithMedia
69
+ post: postView
87
70
  };
88
71
  });
89
72
  // Determine next cursor
90
73
  const lastPost = displayPosts[displayPosts.length - 1];
91
74
  const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
92
- // Resolve theme components
93
- const Feed = resolveTimelineFeed(DefaultTimelineFeed, c.var.config.theme?.components);
94
- const feedProps = {
95
- items,
96
- hasMore,
97
- nextCursor
98
- };
99
- return c.html(/*#__PURE__*/ _jsx(BaseLayout, {
75
+ // Resolve page component
76
+ const components = c.var.config.theme?.components;
77
+ const Page = components?.HomePage ?? DefaultHomePage;
78
+ return renderPublicPage(c, {
100
79
  title: navData.siteName,
101
- c: c,
102
- children: /*#__PURE__*/ _jsx(SiteLayout, {
103
- ...navData,
104
- children: /*#__PURE__*/ _jsx(HomeContent, {
105
- FeedComponent: Feed,
106
- feedProps: feedProps
107
- })
80
+ navData,
81
+ content: /*#__PURE__*/ _jsx(Page, {
82
+ items: items,
83
+ hasMore: hasMore,
84
+ nextCursor: nextCursor,
85
+ theme: components
108
86
  })
109
- }));
87
+ });
110
88
  });