@jant/core 0.3.20 → 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 (94) hide show
  1. package/dist/app.js +60 -17
  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/dash/collections.js +38 -10
  10. package/dist/routes/dash/navigation.js +22 -8
  11. package/dist/routes/dash/redirects.js +19 -5
  12. package/dist/routes/dash/settings.js +57 -15
  13. package/dist/routes/feed/rss.js +34 -78
  14. package/dist/routes/feed/sitemap.js +11 -26
  15. package/dist/routes/pages/archive.js +18 -195
  16. package/dist/routes/pages/collection.js +16 -70
  17. package/dist/routes/pages/home.js +25 -47
  18. package/dist/routes/pages/page.js +15 -27
  19. package/dist/routes/pages/post.js +25 -79
  20. package/dist/routes/pages/search.js +20 -130
  21. package/dist/theme/components/MediaGallery.js +10 -10
  22. package/dist/theme/components/PageForm.js +22 -8
  23. package/dist/theme/components/PostForm.js +22 -8
  24. package/dist/theme/components/index.js +1 -1
  25. package/dist/theme/components/timeline/ArticleCard.js +7 -11
  26. package/dist/theme/components/timeline/ImageCard.js +10 -13
  27. package/dist/theme/components/timeline/LinkCard.js +4 -7
  28. package/dist/theme/components/timeline/NoteCard.js +5 -8
  29. package/dist/theme/components/timeline/QuoteCard.js +3 -6
  30. package/dist/theme/components/timeline/ThreadPreview.js +9 -10
  31. package/dist/theme/components/timeline/TimelineFeed.js +8 -5
  32. package/dist/theme/components/timeline/TimelineItem.js +22 -2
  33. package/dist/theme/components/timeline/index.js +1 -1
  34. package/dist/theme/index.js +6 -3
  35. package/dist/theme/layouts/SiteLayout.js +10 -39
  36. package/dist/theme/pages/ArchivePage.js +157 -0
  37. package/dist/theme/pages/CollectionPage.js +63 -0
  38. package/dist/theme/pages/HomePage.js +26 -0
  39. package/dist/theme/pages/PostPage.js +48 -0
  40. package/dist/theme/pages/SearchPage.js +120 -0
  41. package/dist/theme/pages/SinglePage.js +23 -0
  42. package/dist/theme/pages/index.js +11 -0
  43. package/package.json +2 -1
  44. package/src/app.tsx +48 -17
  45. package/src/i18n/locales/en.po +171 -147
  46. package/src/i18n/locales/zh-Hans.po +171 -147
  47. package/src/i18n/locales/zh-Hant.po +171 -147
  48. package/src/index.ts +51 -2
  49. package/src/lib/__tests__/theme-components.test.ts +33 -14
  50. package/src/lib/__tests__/view.test.ts +375 -0
  51. package/src/lib/feed.ts +148 -0
  52. package/src/lib/navigation.ts +11 -11
  53. package/src/lib/render.tsx +67 -0
  54. package/src/lib/theme-components.ts +27 -35
  55. package/src/lib/view.ts +318 -0
  56. package/src/routes/api/__tests__/timeline.test.ts +3 -3
  57. package/src/routes/api/timeline.tsx +32 -25
  58. package/src/routes/dash/collections.tsx +30 -10
  59. package/src/routes/dash/navigation.tsx +20 -10
  60. package/src/routes/dash/redirects.tsx +15 -5
  61. package/src/routes/dash/settings.tsx +53 -15
  62. package/src/routes/feed/rss.ts +47 -94
  63. package/src/routes/feed/sitemap.ts +8 -30
  64. package/src/routes/pages/archive.tsx +24 -209
  65. package/src/routes/pages/collection.tsx +19 -75
  66. package/src/routes/pages/home.tsx +42 -76
  67. package/src/routes/pages/page.tsx +17 -28
  68. package/src/routes/pages/post.tsx +28 -86
  69. package/src/routes/pages/search.tsx +29 -151
  70. package/src/services/search.ts +2 -8
  71. package/src/theme/components/MediaGallery.tsx +12 -12
  72. package/src/theme/components/PageForm.tsx +20 -10
  73. package/src/theme/components/PostForm.tsx +20 -10
  74. package/src/theme/components/index.ts +1 -0
  75. package/src/theme/components/timeline/ArticleCard.tsx +7 -19
  76. package/src/theme/components/timeline/ImageCard.tsx +10 -20
  77. package/src/theme/components/timeline/LinkCard.tsx +4 -11
  78. package/src/theme/components/timeline/NoteCard.tsx +5 -12
  79. package/src/theme/components/timeline/QuoteCard.tsx +3 -10
  80. package/src/theme/components/timeline/ThreadPreview.tsx +5 -5
  81. package/src/theme/components/timeline/TimelineFeed.tsx +7 -3
  82. package/src/theme/components/timeline/TimelineItem.tsx +43 -4
  83. package/src/theme/components/timeline/index.ts +1 -1
  84. package/src/theme/index.ts +7 -3
  85. package/src/theme/layouts/SiteLayout.tsx +25 -77
  86. package/src/theme/layouts/index.ts +2 -1
  87. package/src/theme/pages/ArchivePage.tsx +160 -0
  88. package/src/theme/pages/CollectionPage.tsx +60 -0
  89. package/src/theme/pages/HomePage.tsx +42 -0
  90. package/src/theme/pages/PostPage.tsx +44 -0
  91. package/src/theme/pages/SearchPage.tsx +128 -0
  92. package/src/theme/pages/SinglePage.tsx +24 -0
  93. package/src/theme/pages/index.ts +13 -0
  94. 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: { PostCard: MyPostCard } },
59
+ * theme: { components: { PostPage: MyPostPage } },
60
60
  * });
61
61
  * ```
62
62
  */ export function createApp(config = {}) {
@@ -75,9 +75,10 @@ import { createStorageDriver } from "./lib/storage.js";
75
75
  c.set("config", config);
76
76
  c.set("storage", createStorageDriver(c.env));
77
77
  if (c.env.AUTH_SECRET) {
78
+ const baseURL = c.env.SITE_URL || new URL(c.req.url).origin;
78
79
  const auth = createAuth(session, {
79
80
  secret: c.env.AUTH_SECRET,
80
- baseURL: c.env.SITE_URL
81
+ baseURL
81
82
  });
82
83
  c.set("auth", auth);
83
84
  }
@@ -164,6 +165,7 @@ import { createStorageDriver } from "./lib/storage.js";
164
165
  children: /*#__PURE__*/ _jsxs("form", {
165
166
  "data-signals": "{name: '', email: '', password: ''}",
166
167
  "data-on:submit__prevent": "@post('/setup')",
168
+ "data-indicator": "_loading",
167
169
  class: "flex flex-col gap-4",
168
170
  children: [
169
171
  /*#__PURE__*/ _jsxs("div", {
@@ -223,13 +225,26 @@ import { createStorageDriver } from "./lib/storage.js";
223
225
  })
224
226
  ]
225
227
  }),
226
- /*#__PURE__*/ _jsx("button", {
228
+ /*#__PURE__*/ _jsxs("button", {
227
229
  type: "submit",
228
230
  class: "btn",
229
- children: $__i18n._({
230
- id: "EGwzOK",
231
- message: "Complete Setup"
232
- })
231
+ "data-attr-disabled": "$_loading",
232
+ children: [
233
+ /*#__PURE__*/ _jsx("span", {
234
+ "data-show": "!$_loading",
235
+ children: $__i18n._({
236
+ id: "EGwzOK",
237
+ message: "Complete Setup"
238
+ })
239
+ }),
240
+ /*#__PURE__*/ _jsx("span", {
241
+ "data-show": "$_loading",
242
+ children: $__i18n._({
243
+ id: "k1ifdL",
244
+ message: "Processing..."
245
+ })
246
+ })
247
+ ]
233
248
  })
234
249
  ]
235
250
  })
@@ -313,6 +328,7 @@ import { createStorageDriver } from "./lib/storage.js";
313
328
  /*#__PURE__*/ _jsxs("form", {
314
329
  "data-signals": signals,
315
330
  "data-on:submit__prevent": "@post('/signin')",
331
+ "data-indicator": "_loading",
316
332
  class: "flex flex-col gap-4",
317
333
  children: [
318
334
  /*#__PURE__*/ _jsxs("div", {
@@ -351,13 +367,26 @@ import { createStorageDriver } from "./lib/storage.js";
351
367
  })
352
368
  ]
353
369
  }),
354
- /*#__PURE__*/ _jsx("button", {
370
+ /*#__PURE__*/ _jsxs("button", {
355
371
  type: "submit",
356
372
  class: "btn",
357
- children: $__i18n._({
358
- id: "n1ekoW",
359
- message: "Sign In"
360
- })
373
+ "data-attr-disabled": "$_loading",
374
+ children: [
375
+ /*#__PURE__*/ _jsx("span", {
376
+ "data-show": "!$_loading",
377
+ children: $__i18n._({
378
+ id: "n1ekoW",
379
+ message: "Sign In"
380
+ })
381
+ }),
382
+ /*#__PURE__*/ _jsx("span", {
383
+ "data-show": "$_loading",
384
+ children: $__i18n._({
385
+ id: "k1ifdL",
386
+ message: "Processing..."
387
+ })
388
+ })
389
+ ]
361
390
  })
362
391
  ]
363
392
  })
@@ -458,6 +487,7 @@ import { createStorageDriver } from "./lib/storage.js";
458
487
  children: /*#__PURE__*/ _jsxs("form", {
459
488
  "data-signals": signals,
460
489
  "data-on:submit__prevent": "@post('/reset')",
490
+ "data-indicator": "_loading",
461
491
  class: "flex flex-col gap-4",
462
492
  children: [
463
493
  /*#__PURE__*/ _jsxs("div", {
@@ -500,13 +530,26 @@ import { createStorageDriver } from "./lib/storage.js";
500
530
  })
501
531
  ]
502
532
  }),
503
- /*#__PURE__*/ _jsx("button", {
533
+ /*#__PURE__*/ _jsxs("button", {
504
534
  type: "submit",
505
535
  class: "btn",
506
- children: $__i18n._({
507
- id: "KbS2K9",
508
- message: "Reset Password"
509
- })
536
+ "data-attr-disabled": "$_loading",
537
+ children: [
538
+ /*#__PURE__*/ _jsx("span", {
539
+ "data-show": "!$_loading",
540
+ children: $__i18n._({
541
+ id: "KbS2K9",
542
+ message: "Reset Password"
543
+ })
544
+ }),
545
+ /*#__PURE__*/ _jsx("span", {
546
+ "data-show": "$_loading",
547
+ children: $__i18n._({
548
+ id: "k1ifdL",
549
+ message: "Processing..."
550
+ })
551
+ })
552
+ ]
510
553
  })
511
554
  ]
512
555
  })
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();
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Default Feed Renderers
3
+ *
4
+ * RSS 2.0, Atom, and Sitemap XML generators.
5
+ * Theme authors can import these to extend/wrap the defaults:
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { defaultRssRenderer } from "@jant/core/lib/feed";
10
+ * ```
11
+ */ /**
12
+ * Escape special XML characters.
13
+ */ function escapeXml(str) {
14
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
15
+ }
16
+ /**
17
+ * Default RSS 2.0 renderer.
18
+ *
19
+ * @param data - Feed data with PostView[] (pre-computed URLs)
20
+ * @returns RSS 2.0 XML string
21
+ */ export function defaultRssRenderer(data) {
22
+ const { siteName, siteDescription, siteUrl, siteLanguage, posts } = data;
23
+ const items = posts.map((post)=>{
24
+ const link = `${siteUrl}${post.permalink}`;
25
+ const title = post.title || `Post #${post.id}`;
26
+ const pubDate = new Date(post.publishedAt).toUTCString();
27
+ // Add enclosure for first media attachment
28
+ const firstMedia = post.media[0];
29
+ const enclosure = firstMedia ? `\n <enclosure url="${firstMedia.url}" type="${firstMedia.mimeType}"${firstMedia.size ? ` length="${firstMedia.size}"` : ""}/>` : "";
30
+ return `
31
+ <item>
32
+ <title><![CDATA[${escapeXml(title)}]]></title>
33
+ <link>${link}</link>
34
+ <guid isPermaLink="true">${link}</guid>
35
+ <pubDate>${pubDate}</pubDate>
36
+ <description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
37
+ </item>`;
38
+ }).join("");
39
+ return `<?xml version="1.0" encoding="UTF-8"?>
40
+ <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
41
+ <channel>
42
+ <title>${escapeXml(siteName)}</title>
43
+ <link>${siteUrl}</link>
44
+ <description>${escapeXml(siteDescription)}</description>
45
+ <language>${siteLanguage}</language>
46
+ <atom:link href="${siteUrl}/feed" rel="self" type="application/rss+xml"/>
47
+ ${items}
48
+ </channel>
49
+ </rss>`;
50
+ }
51
+ /**
52
+ * Default Atom renderer.
53
+ *
54
+ * @param data - Feed data with PostView[] (pre-computed URLs)
55
+ * @returns Atom XML string
56
+ */ export function defaultAtomRenderer(data) {
57
+ const { siteName, siteDescription, siteUrl, posts } = data;
58
+ const entries = posts.map((post)=>{
59
+ const link = `${siteUrl}${post.permalink}`;
60
+ const title = post.title || `Post #${post.id}`;
61
+ return `
62
+ <entry>
63
+ <title>${escapeXml(title)}</title>
64
+ <link href="${link}" rel="alternate"/>
65
+ <id>${link}</id>
66
+ <published>${post.publishedAt}</published>
67
+ <updated>${post.updatedAt}</updated>
68
+ <content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
69
+ </entry>`;
70
+ }).join("");
71
+ const now = new Date().toISOString();
72
+ return `<?xml version="1.0" encoding="UTF-8"?>
73
+ <feed xmlns="http://www.w3.org/2005/Atom">
74
+ <title>${escapeXml(siteName)}</title>
75
+ <subtitle>${escapeXml(siteDescription)}</subtitle>
76
+ <link href="${siteUrl}" rel="alternate"/>
77
+ <link href="${siteUrl}/feed/atom.xml" rel="self"/>
78
+ <id>${siteUrl}/</id>
79
+ <updated>${now}</updated>
80
+ ${entries}
81
+ </feed>`;
82
+ }
83
+ /**
84
+ * Default Sitemap renderer.
85
+ *
86
+ * @param data - Sitemap data with PostView[] (pre-computed URLs)
87
+ * @returns Sitemap XML string
88
+ */ export function defaultSitemapRenderer(data) {
89
+ const { siteUrl, posts } = data;
90
+ const urls = posts.map((post)=>{
91
+ const loc = `${siteUrl}${post.permalink}`;
92
+ const lastmod = post.updatedAt.split("T")[0];
93
+ const priority = post.visibility === "featured" ? "0.8" : "0.6";
94
+ return `
95
+ <url>
96
+ <loc>${loc}</loc>
97
+ <lastmod>${lastmod}</lastmod>
98
+ <priority>${priority}</priority>
99
+ </url>`;
100
+ }).join("");
101
+ const homepageUrl = `
102
+ <url>
103
+ <loc>${siteUrl}/</loc>
104
+ <priority>1.0</priority>
105
+ <changefreq>daily</changefreq>
106
+ </url>`;
107
+ return `<?xml version="1.0" encoding="UTF-8"?>
108
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
109
+ ${homepageUrl}
110
+ ${urls}
111
+ </urlset>`;
112
+ }
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * Provides shared data fetching for public page navigation.
5
5
  */ import { getSiteName } from "./config.js";
6
+ import { toNavLinkViews } from "./view.js";
6
7
  /**
7
8
  * Fetch navigation data for public pages.
8
9
  *
9
10
  * Ensures default links exist (Home, Archive, RSS) and returns
10
- * the current path and site name alongside the links.
11
+ * NavLinkView[] with pre-computed isActive/isExternal state.
11
12
  *
12
13
  * @param c - Hono context
13
14
  * @returns Navigation data for SiteLayout
@@ -15,20 +16,19 @@
15
16
  * @example
16
17
  * ```typescript
17
18
  * const navData = await getNavigationData(c);
18
- * return c.html(
19
- * <BaseLayout c={c}>
20
- * <SiteLayout {...navData}>
21
- * <MyContent />
22
- * </SiteLayout>
23
- * </BaseLayout>
24
- * );
19
+ * return renderPublicPage(c, {
20
+ * title: "My Page",
21
+ * navData,
22
+ * content: <MyContent />,
23
+ * });
25
24
  * ```
26
25
  */ export async function getNavigationData(c) {
27
26
  const navigationLinks = await c.var.services.navigationLinks.ensureDefaults();
28
27
  const currentPath = new URL(c.req.url).pathname;
29
28
  const siteName = await getSiteName(c);
29
+ const links = toNavLinkViews(navigationLinks, currentPath);
30
30
  return {
31
- navigationLinks,
31
+ links,
32
32
  currentPath,
33
33
  siteName
34
34
  };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Public Page Rendering Helper
3
+ *
4
+ * Provides a single entry point for rendering public pages with the
5
+ * correct layout stack: BaseLayout > SiteLayout > content.
6
+ *
7
+ * BaseLayout is always the built-in implementation (handles Vite assets,
8
+ * I18nProvider, toast). SiteLayout is resolved from theme components.
9
+ */ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
10
+ import { BaseLayout } from "../theme/layouts/BaseLayout.js";
11
+ import { SiteLayout as DefaultSiteLayout } from "../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
- }
@@ -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
+ }