@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.
- package/dist/app.js +1 -1
- package/dist/index.js +8 -0
- package/dist/lib/feed.js +112 -0
- package/dist/lib/navigation.js +9 -9
- package/dist/lib/render.js +48 -0
- package/dist/lib/theme-components.js +18 -18
- package/dist/lib/view.js +228 -0
- package/dist/routes/api/timeline.js +20 -16
- package/dist/routes/feed/rss.js +34 -78
- package/dist/routes/feed/sitemap.js +11 -26
- package/dist/routes/pages/archive.js +18 -195
- package/dist/routes/pages/collection.js +16 -70
- package/dist/routes/pages/home.js +25 -47
- package/dist/routes/pages/page.js +15 -27
- package/dist/routes/pages/post.js +25 -79
- package/dist/routes/pages/search.js +20 -130
- package/dist/theme/components/MediaGallery.js +10 -10
- package/dist/theme/components/index.js +1 -1
- package/dist/theme/components/timeline/ArticleCard.js +7 -11
- package/dist/theme/components/timeline/ImageCard.js +10 -13
- package/dist/theme/components/timeline/LinkCard.js +4 -7
- package/dist/theme/components/timeline/NoteCard.js +5 -8
- package/dist/theme/components/timeline/QuoteCard.js +3 -6
- package/dist/theme/components/timeline/ThreadPreview.js +9 -10
- package/dist/theme/components/timeline/TimelineFeed.js +8 -5
- package/dist/theme/components/timeline/TimelineItem.js +22 -2
- package/dist/theme/components/timeline/index.js +1 -1
- package/dist/theme/index.js +6 -3
- package/dist/theme/layouts/SiteLayout.js +10 -39
- package/dist/theme/pages/ArchivePage.js +157 -0
- package/dist/theme/pages/CollectionPage.js +63 -0
- package/dist/theme/pages/HomePage.js +26 -0
- package/dist/theme/pages/PostPage.js +48 -0
- package/dist/theme/pages/SearchPage.js +120 -0
- package/dist/theme/pages/SinglePage.js +23 -0
- package/dist/theme/pages/index.js +11 -0
- package/package.json +2 -1
- package/src/app.tsx +1 -1
- package/src/i18n/locales/en.po +31 -31
- package/src/i18n/locales/zh-Hans.po +31 -31
- package/src/i18n/locales/zh-Hant.po +31 -31
- package/src/index.ts +51 -2
- package/src/lib/__tests__/theme-components.test.ts +33 -14
- package/src/lib/__tests__/view.test.ts +375 -0
- package/src/lib/feed.ts +148 -0
- package/src/lib/navigation.ts +11 -11
- package/src/lib/render.tsx +67 -0
- package/src/lib/theme-components.ts +27 -35
- package/src/lib/view.ts +318 -0
- package/src/routes/api/__tests__/timeline.test.ts +3 -3
- package/src/routes/api/timeline.tsx +32 -25
- package/src/routes/feed/rss.ts +47 -94
- package/src/routes/feed/sitemap.ts +8 -30
- package/src/routes/pages/archive.tsx +24 -209
- package/src/routes/pages/collection.tsx +19 -75
- package/src/routes/pages/home.tsx +42 -76
- package/src/routes/pages/page.tsx +17 -28
- package/src/routes/pages/post.tsx +28 -86
- package/src/routes/pages/search.tsx +29 -151
- package/src/services/search.ts +2 -8
- package/src/theme/components/MediaGallery.tsx +12 -12
- package/src/theme/components/index.ts +1 -0
- package/src/theme/components/timeline/ArticleCard.tsx +7 -19
- package/src/theme/components/timeline/ImageCard.tsx +10 -20
- package/src/theme/components/timeline/LinkCard.tsx +4 -11
- package/src/theme/components/timeline/NoteCard.tsx +5 -12
- package/src/theme/components/timeline/QuoteCard.tsx +3 -10
- package/src/theme/components/timeline/ThreadPreview.tsx +5 -5
- package/src/theme/components/timeline/TimelineFeed.tsx +7 -3
- package/src/theme/components/timeline/TimelineItem.tsx +43 -4
- package/src/theme/components/timeline/index.ts +1 -1
- package/src/theme/index.ts +7 -3
- package/src/theme/layouts/SiteLayout.tsx +25 -77
- package/src/theme/layouts/index.ts +2 -1
- package/src/theme/pages/ArchivePage.tsx +160 -0
- package/src/theme/pages/CollectionPage.tsx +60 -0
- package/src/theme/pages/HomePage.tsx +42 -0
- package/src/theme/pages/PostPage.tsx +44 -0
- package/src/theme/pages/SearchPage.tsx +128 -0
- package/src/theme/pages/SinglePage.tsx +24 -0
- package/src/theme/pages/index.ts +13 -0
- package/src/types.ts +262 -38
package/dist/app.js
CHANGED
|
@@ -56,7 +56,7 @@ import { createStorageDriver } from "./lib/storage.js";
|
|
|
56
56
|
* import { createApp } from "@jant/core";
|
|
57
57
|
*
|
|
58
58
|
* export default createApp({
|
|
59
|
-
* theme: { components: {
|
|
59
|
+
* theme: { components: { PostPage: MyPostPage } },
|
|
60
60
|
* });
|
|
61
61
|
* ```
|
|
62
62
|
*/ export function createApp(config = {}) {
|
package/dist/index.js
CHANGED
|
@@ -11,5 +11,13 @@ export * as time from "./lib/time.js";
|
|
|
11
11
|
export * as sqid from "./lib/sqid.js";
|
|
12
12
|
export * as url from "./lib/url.js";
|
|
13
13
|
export * as markdown from "./lib/markdown.js";
|
|
14
|
+
// View Model conversion utilities (for advanced theme use)
|
|
15
|
+
export { createMediaContext, toPostView, toPostViews, toMediaView, toNavLinkView, toNavLinkViews, toSearchResultView, toArchiveGroups } from "./lib/view.js";
|
|
16
|
+
// Render helper (for theme authors adding custom routes)
|
|
17
|
+
export { renderPublicPage } from "./lib/render.js";
|
|
18
|
+
// Navigation helper (for theme authors)
|
|
19
|
+
export { getNavigationData } from "./lib/navigation.js";
|
|
20
|
+
// Default feed renderers (for theme authors to extend)
|
|
21
|
+
export { defaultRssRenderer, defaultAtomRenderer, defaultSitemapRenderer } from "./lib/feed.js";
|
|
14
22
|
// Default export for running core directly (e.g., for development)
|
|
15
23
|
export default _createApp();
|
package/dist/lib/feed.js
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
+
}
|
package/dist/lib/navigation.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
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 "../theme/layouts/SiteLayout.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
|
-
}
|
package/dist/lib/view.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -7,7 +7,8 @@ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
|
|
|
7
7
|
import { sse } from "../../lib/sse.js";
|
|
8
8
|
import { buildMediaMap } from "../../lib/media-helpers.js";
|
|
9
9
|
import { TimelineItem } from "../../theme/components/timeline/TimelineItem.js";
|
|
10
|
-
import { ThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
|
|
10
|
+
import { ThreadPreview as DefaultThreadPreview } from "../../theme/components/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
|
|
45
|
-
const
|
|
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,42 +56,47 @@ 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
|
|
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:
|
|
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:
|
|
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(
|
|
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
|