@jant/core 0.3.23 → 0.3.25

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 (248) hide show
  1. package/dist/app.js +50 -26
  2. package/dist/db/schema.js +72 -47
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +5 -11
  7. package/dist/lib/constants.js +2 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/nav-reorder.js +1 -1
  11. package/dist/lib/navigation.js +30 -6
  12. package/dist/lib/pagination.js +44 -0
  13. package/dist/lib/render.js +7 -11
  14. package/dist/lib/schemas.js +80 -38
  15. package/dist/lib/theme.js +4 -4
  16. package/dist/lib/time.js +56 -1
  17. package/dist/lib/timeline.js +95 -0
  18. package/dist/lib/view.js +61 -72
  19. package/dist/routes/api/collections.js +124 -0
  20. package/dist/routes/api/nav-items.js +104 -0
  21. package/dist/routes/api/pages.js +91 -0
  22. package/dist/routes/api/posts.js +27 -33
  23. package/dist/routes/api/search.js +4 -5
  24. package/dist/routes/api/settings.js +68 -0
  25. package/dist/routes/api/upload.js +13 -13
  26. package/dist/routes/compose.js +48 -0
  27. package/dist/routes/dash/collections.js +24 -42
  28. package/dist/routes/dash/index.js +3 -3
  29. package/dist/routes/dash/media.js +2 -2
  30. package/dist/routes/dash/pages.js +440 -106
  31. package/dist/routes/dash/posts.js +27 -37
  32. package/dist/routes/dash/redirects.js +2 -2
  33. package/dist/routes/dash/settings.js +79 -5
  34. package/dist/routes/feed/rss.js +4 -6
  35. package/dist/routes/feed/sitemap.js +11 -8
  36. package/dist/routes/pages/archive.js +13 -15
  37. package/dist/routes/pages/collection.js +12 -9
  38. package/dist/routes/pages/collections.js +28 -0
  39. package/dist/routes/pages/featured.js +32 -0
  40. package/dist/routes/pages/home.js +19 -68
  41. package/dist/routes/pages/page.js +57 -29
  42. package/dist/routes/pages/post.js +7 -17
  43. package/dist/routes/pages/search.js +5 -9
  44. package/dist/services/collection.js +52 -64
  45. package/dist/services/index.js +5 -3
  46. package/dist/services/navigation.js +29 -53
  47. package/dist/services/page.js +84 -0
  48. package/dist/services/post.js +102 -69
  49. package/dist/services/search.js +24 -18
  50. package/dist/types.js +24 -40
  51. package/dist/ui/compose/ComposeDialog.js +452 -0
  52. package/dist/ui/compose/ComposePrompt.js +55 -0
  53. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +3 -15
  54. package/dist/{theme/components → ui/dash}/PageForm.js +15 -15
  55. package/dist/{theme/components → ui/dash}/PostForm.js +117 -137
  56. package/dist/{theme/components → ui/dash}/PostList.js +18 -13
  57. package/dist/ui/dash/StatusBadge.js +46 -0
  58. package/dist/{theme/components → ui/dash}/index.js +3 -6
  59. package/dist/ui/feed/LinkCard.js +72 -0
  60. package/dist/ui/feed/NoteCard.js +58 -0
  61. package/dist/{themes/minimal/timeline → ui/feed}/QuoteCard.js +29 -14
  62. package/dist/{themes/minimal/timeline → ui/feed}/ThreadPreview.js +20 -18
  63. package/dist/ui/feed/TimelineFeed.js +41 -0
  64. package/dist/ui/feed/TimelineItem.js +27 -0
  65. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  66. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  67. package/dist/ui/layouts/SiteLayout.js +141 -0
  68. package/dist/{themes/minimal → ui}/pages/ArchivePage.js +37 -50
  69. package/dist/ui/pages/CollectionPage.js +70 -0
  70. package/dist/ui/pages/CollectionsPage.js +76 -0
  71. package/dist/ui/pages/FeaturedPage.js +24 -0
  72. package/dist/ui/pages/HomePage.js +24 -0
  73. package/dist/{themes/minimal → ui}/pages/PostPage.js +20 -12
  74. package/dist/{themes/minimal → ui}/pages/SearchPage.js +19 -18
  75. package/dist/{themes/minimal → ui}/pages/SinglePage.js +5 -4
  76. package/dist/ui/shared/MediaGallery.js +35 -0
  77. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  78. package/dist/{theme/components → ui/shared}/ThreadView.js +3 -3
  79. package/dist/ui/shared/index.js +5 -0
  80. package/package.json +2 -9
  81. package/src/__tests__/helpers/app.ts +4 -0
  82. package/src/__tests__/helpers/db.ts +53 -73
  83. package/src/app.tsx +56 -28
  84. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  85. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  86. package/src/db/migrations/meta/_journal.json +14 -0
  87. package/src/db/schema.ts +63 -46
  88. package/src/i18n/locales/en.po +443 -240
  89. package/src/i18n/locales/en.ts +1 -1
  90. package/src/i18n/locales/zh-Hans.po +443 -240
  91. package/src/i18n/locales/zh-Hans.ts +1 -1
  92. package/src/i18n/locales/zh-Hant.po +443 -240
  93. package/src/i18n/locales/zh-Hant.ts +1 -1
  94. package/src/index.ts +29 -42
  95. package/src/lib/__tests__/excerpt.test.ts +125 -0
  96. package/src/lib/__tests__/schemas.test.ts +201 -99
  97. package/src/lib/__tests__/time.test.ts +62 -0
  98. package/src/{routes/api → lib}/__tests__/timeline.test.ts +81 -75
  99. package/src/lib/__tests__/view.test.ts +204 -50
  100. package/src/lib/constants.ts +2 -4
  101. package/src/lib/excerpt.ts +87 -0
  102. package/src/lib/feed.ts +22 -7
  103. package/src/lib/nav-reorder.ts +1 -1
  104. package/src/lib/navigation.ts +45 -8
  105. package/src/lib/pagination.ts +50 -0
  106. package/src/lib/render.tsx +7 -14
  107. package/src/lib/schemas.ts +119 -51
  108. package/src/lib/theme.ts +5 -5
  109. package/src/lib/time.ts +64 -0
  110. package/src/lib/timeline.ts +141 -0
  111. package/src/lib/view.ts +80 -82
  112. package/src/preset.css +46 -0
  113. package/src/routes/__tests__/compose.test.ts +199 -0
  114. package/src/routes/api/__tests__/collections.test.ts +249 -0
  115. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  116. package/src/routes/api/__tests__/pages.test.ts +218 -0
  117. package/src/routes/api/__tests__/posts.test.ts +50 -108
  118. package/src/routes/api/__tests__/search.test.ts +2 -3
  119. package/src/routes/api/__tests__/settings.test.ts +132 -0
  120. package/src/routes/api/collections.ts +143 -0
  121. package/src/routes/api/nav-items.ts +115 -0
  122. package/src/routes/api/pages.ts +101 -0
  123. package/src/routes/api/posts.ts +28 -28
  124. package/src/routes/api/search.ts +3 -3
  125. package/src/routes/api/settings.ts +91 -0
  126. package/src/routes/api/upload.ts +16 -6
  127. package/src/routes/compose.ts +63 -0
  128. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  129. package/src/routes/dash/collections.tsx +20 -42
  130. package/src/routes/dash/index.tsx +3 -3
  131. package/src/routes/dash/media.tsx +2 -2
  132. package/src/routes/dash/pages.tsx +480 -122
  133. package/src/routes/dash/posts.tsx +42 -54
  134. package/src/routes/dash/redirects.tsx +2 -2
  135. package/src/routes/dash/settings.tsx +83 -5
  136. package/src/routes/feed/rss.ts +4 -3
  137. package/src/routes/feed/sitemap.ts +15 -5
  138. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  139. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  140. package/src/routes/pages/archive.tsx +15 -15
  141. package/src/routes/pages/collection.tsx +16 -9
  142. package/src/routes/pages/collections.tsx +36 -0
  143. package/src/routes/pages/featured.tsx +38 -0
  144. package/src/routes/pages/home.tsx +21 -92
  145. package/src/routes/pages/page.tsx +62 -27
  146. package/src/routes/pages/post.tsx +6 -18
  147. package/src/routes/pages/search.tsx +3 -7
  148. package/src/services/__tests__/collection.test.ts +257 -158
  149. package/src/services/__tests__/media.test.ts +18 -18
  150. package/src/services/__tests__/navigation.test.ts +161 -87
  151. package/src/services/__tests__/page.test.ts +106 -0
  152. package/src/services/__tests__/post-timeline.test.ts +92 -88
  153. package/src/services/__tests__/post.test.ts +432 -197
  154. package/src/services/__tests__/search.test.ts +19 -25
  155. package/src/services/collection.ts +71 -113
  156. package/src/services/index.ts +9 -8
  157. package/src/services/navigation.ts +38 -71
  158. package/src/services/page.ts +136 -0
  159. package/src/services/post.ts +141 -101
  160. package/src/services/search.ts +38 -27
  161. package/src/styles/tokens.css +47 -0
  162. package/src/styles/ui.css +491 -0
  163. package/src/types.ts +212 -198
  164. package/src/ui/compose/ComposeDialog.tsx +395 -0
  165. package/src/ui/compose/ComposePrompt.tsx +55 -0
  166. package/src/ui/dash/FormatBadge.tsx +28 -0
  167. package/src/{theme/components → ui/dash}/PageForm.tsx +21 -21
  168. package/src/{theme/components → ui/dash}/PostForm.tsx +110 -131
  169. package/src/ui/dash/PostList.tsx +101 -0
  170. package/src/ui/dash/StatusBadge.tsx +61 -0
  171. package/src/ui/dash/index.ts +10 -0
  172. package/src/ui/feed/LinkCard.tsx +72 -0
  173. package/src/ui/feed/NoteCard.tsx +63 -0
  174. package/src/ui/feed/QuoteCard.tsx +68 -0
  175. package/src/ui/feed/ThreadPreview.tsx +48 -0
  176. package/src/ui/feed/TimelineFeed.tsx +49 -0
  177. package/src/ui/feed/TimelineItem.tsx +45 -0
  178. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  179. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  180. package/src/ui/layouts/SiteLayout.tsx +150 -0
  181. package/src/ui/pages/ArchivePage.tsx +162 -0
  182. package/src/ui/pages/CollectionPage.tsx +70 -0
  183. package/src/ui/pages/CollectionsPage.tsx +73 -0
  184. package/src/ui/pages/FeaturedPage.tsx +31 -0
  185. package/src/ui/pages/HomePage.tsx +37 -0
  186. package/src/ui/pages/PostPage.tsx +56 -0
  187. package/src/{themes/minimal → ui}/pages/SearchPage.tsx +24 -20
  188. package/src/{themes/minimal → ui}/pages/SinglePage.tsx +5 -5
  189. package/src/ui/shared/MediaGallery.tsx +59 -0
  190. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  191. package/src/{theme/components → ui/shared}/ThreadView.tsx +6 -3
  192. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  193. package/src/ui/shared/index.ts +12 -0
  194. package/bin/jant.js +0 -185
  195. package/dist/lib/theme-components.js +0 -49
  196. package/dist/routes/api/timeline.js +0 -120
  197. package/dist/routes/dash/navigation.js +0 -288
  198. package/dist/theme/components/MediaGallery.js +0 -107
  199. package/dist/theme/components/VisibilityBadge.js +0 -37
  200. package/dist/theme/index.js +0 -18
  201. package/dist/theme/layouts/index.js +0 -2
  202. package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
  203. package/dist/themes/minimal/index.js +0 -65
  204. package/dist/themes/minimal/pages/CollectionPage.js +0 -65
  205. package/dist/themes/minimal/pages/HomePage.js +0 -25
  206. package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
  207. package/dist/themes/minimal/timeline/ImageCard.js +0 -67
  208. package/dist/themes/minimal/timeline/LinkCard.js +0 -47
  209. package/dist/themes/minimal/timeline/NoteCard.js +0 -34
  210. package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
  211. package/dist/themes/minimal/timeline/TimelineItem.js +0 -44
  212. package/src/lib/__tests__/theme-components.test.ts +0 -126
  213. package/src/lib/theme-components.ts +0 -68
  214. package/src/routes/api/timeline.tsx +0 -159
  215. package/src/routes/dash/navigation.tsx +0 -316
  216. package/src/theme/components/MediaGallery.tsx +0 -128
  217. package/src/theme/components/PostList.tsx +0 -92
  218. package/src/theme/components/TypeBadge.tsx +0 -37
  219. package/src/theme/components/VisibilityBadge.tsx +0 -45
  220. package/src/theme/components/index.ts +0 -23
  221. package/src/theme/index.ts +0 -22
  222. package/src/theme/layouts/index.ts +0 -7
  223. package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
  224. package/src/themes/minimal/index.ts +0 -83
  225. package/src/themes/minimal/pages/ArchivePage.tsx +0 -157
  226. package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
  227. package/src/themes/minimal/pages/HomePage.tsx +0 -41
  228. package/src/themes/minimal/pages/PostPage.tsx +0 -43
  229. package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
  230. package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
  231. package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
  232. package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
  233. package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
  234. package/src/themes/minimal/timeline/ThreadPreview.tsx +0 -47
  235. package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
  236. package/src/themes/minimal/timeline/TimelineItem.tsx +0 -75
  237. /package/dist/{theme → ui}/color-themes.js +0 -0
  238. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  239. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  240. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  241. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  242. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  243. /package/src/{theme → ui}/color-themes.ts +0 -0
  244. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  245. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  246. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  247. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  248. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
package/dist/lib/feed.js CHANGED
@@ -33,7 +33,7 @@
33
33
  <link>${link}</link>
34
34
  <guid isPermaLink="true">${link}</guid>
35
35
  <pubDate>${pubDate}</pubDate>
36
- <description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
36
+ <description><![CDATA[${post.bodyHtml || ""}]]></description>${enclosure}
37
37
  </item>`;
38
38
  }).join("");
39
39
  return `<?xml version="1.0" encoding="UTF-8"?>
@@ -65,7 +65,7 @@
65
65
  <id>${link}</id>
66
66
  <published>${post.publishedAt}</published>
67
67
  <updated>${post.updatedAt}</updated>
68
- <content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
68
+ <content type="html"><![CDATA[${post.bodyHtml || ""}]]></content>
69
69
  </entry>`;
70
70
  }).join("");
71
71
  const now = new Date().toISOString();
@@ -83,19 +83,29 @@
83
83
  /**
84
84
  * Default Sitemap renderer.
85
85
  *
86
- * @param data - Sitemap data with PostView[] (pre-computed URLs)
86
+ * @param data - Sitemap data with PostView[] and PageView[]
87
87
  * @returns Sitemap XML string
88
88
  */ export function defaultSitemapRenderer(data) {
89
- const { siteUrl, posts } = data;
90
- const urls = posts.map((post)=>{
89
+ const { siteUrl, posts, pages } = data;
90
+ const postUrls = posts.map((post)=>{
91
91
  const loc = `${siteUrl}${post.permalink}`;
92
92
  const lastmod = post.updatedAt.split("T")[0];
93
- const priority = post.visibility === "featured" ? "0.8" : "0.6";
93
+ const priority = post.featured ? "0.8" : "0.6";
94
94
  return `
95
95
  <url>
96
96
  <loc>${loc}</loc>
97
97
  <lastmod>${lastmod}</lastmod>
98
98
  <priority>${priority}</priority>
99
+ </url>`;
100
+ }).join("");
101
+ const pageUrls = pages.map((page)=>{
102
+ const loc = `${siteUrl}/${page.slug}`;
103
+ const lastmod = page.updatedAt.split("T")[0];
104
+ return `
105
+ <url>
106
+ <loc>${loc}</loc>
107
+ <lastmod>${lastmod}</lastmod>
108
+ <priority>0.7</priority>
99
109
  </url>`;
100
110
  }).join("");
101
111
  const homepageUrl = `
@@ -107,6 +117,7 @@
107
117
  return `<?xml version="1.0" encoding="UTF-8"?>
108
118
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
109
119
  ${homepageUrl}
110
- ${urls}
120
+ ${postUrls}
121
+ ${pageUrls}
111
122
  </urlset>`;
112
123
  }
@@ -13,7 +13,7 @@ if (list) {
13
13
  const ids = [
14
14
  ...list.querySelectorAll("[data-id]")
15
15
  ].map((el)=>Number(el.dataset.id));
16
- fetch("/dash/navigation/reorder", {
16
+ fetch("/dash/pages/reorder", {
17
17
  method: "POST",
18
18
  headers: {
19
19
  "Content-Type": "application/json"
@@ -3,12 +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
+ import { toNavItemViews } from "./view.js";
7
7
  /**
8
8
  * Fetch navigation data for public pages.
9
9
  *
10
- * Ensures default links exist (Home, Archive, RSS) and returns
11
- * NavLinkView[] with pre-computed isActive/isExternal state.
10
+ * Returns NavItemView[] with pre-computed isActive/isExternal state.
11
+ * Also checks authentication status and loads collections for authenticated users.
12
12
  *
13
13
  * @param c - Hono context
14
14
  * @returns Navigation data for SiteLayout
@@ -23,13 +23,37 @@ import { toNavLinkViews } from "./view.js";
23
23
  * });
24
24
  * ```
25
25
  */ export async function getNavigationData(c) {
26
- const navigationLinks = await c.var.services.navigationLinks.ensureDefaults();
26
+ const items = await c.var.services.navItems.list();
27
27
  const currentPath = new URL(c.req.url).pathname;
28
28
  const siteName = await getSiteName(c);
29
- const links = toNavLinkViews(navigationLinks, currentPath);
29
+ // Only include description if explicitly set (DB or env), not the default
30
+ const dbDescription = await c.var.services.settings.get("SITE_DESCRIPTION");
31
+ const envDescription = c.env.SITE_DESCRIPTION;
32
+ const siteDescription = dbDescription || (typeof envDescription === "string" ? envDescription : "");
33
+ const links = toNavItemViews(items, currentPath);
34
+ // Check auth status for compose button
35
+ let isAuthenticated = false;
36
+ let collections = [];
37
+ if (c.var.auth) {
38
+ try {
39
+ const session = await c.var.auth.api.getSession({
40
+ headers: c.req.raw.headers
41
+ });
42
+ isAuthenticated = !!session?.user;
43
+ } catch {
44
+ // Not authenticated
45
+ }
46
+ }
47
+ // Only load collections when authenticated (for compose dialog)
48
+ if (isAuthenticated) {
49
+ collections = await c.var.services.collections.list();
50
+ }
30
51
  return {
31
52
  links,
32
53
  currentPath,
33
- siteName
54
+ siteName,
55
+ siteDescription,
56
+ isAuthenticated,
57
+ collections
34
58
  };
35
59
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pagination Utilities
3
+ *
4
+ * Pure utility functions for page-based pagination.
5
+ */ /**
6
+ * Computes which page numbers to display in a numbered pagination control.
7
+ * Always includes: first page, last page, current page, and 1 page on each side of current.
8
+ * Gaps between non-consecutive pages are represented by 0 (ellipsis marker).
9
+ *
10
+ * @param currentPage - The current active page (1-indexed)
11
+ * @param totalPages - Total number of pages
12
+ * @returns Array of page numbers, with 0 representing ellipsis gaps
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * getPageNumbers(1, 5) // [1, 2, 3, 4, 5]
17
+ * getPageNumbers(1, 20) // [1, 2, 0, 20]
18
+ * getPageNumbers(10, 20) // [1, 0, 9, 10, 11, 0, 20]
19
+ * ```
20
+ */ export function getPageNumbers(currentPage, totalPages) {
21
+ if (totalPages <= 7) {
22
+ return Array.from({
23
+ length: totalPages
24
+ }, (_, i)=>i + 1);
25
+ }
26
+ const pages = new Set();
27
+ pages.add(1);
28
+ pages.add(totalPages);
29
+ pages.add(currentPage);
30
+ if (currentPage > 1) pages.add(currentPage - 1);
31
+ if (currentPage < totalPages) pages.add(currentPage + 1);
32
+ const sorted = [
33
+ ...pages
34
+ ].sort((a, b)=>a - b);
35
+ // Insert 0 for gaps
36
+ const result = [];
37
+ for(let i = 0; i < sorted.length; i++){
38
+ if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
39
+ result.push(0); // ellipsis marker
40
+ }
41
+ result.push(sorted[i]);
42
+ }
43
+ return result;
44
+ }
@@ -3,17 +3,12 @@
3
3
  *
4
4
  * Provides a single entry point for rendering public pages with the
5
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
6
  */ import { jsx as _jsx } from "hono/jsx/jsx-runtime";
10
- import { BaseLayout } from "../theme/layouts/BaseLayout.js";
11
- import { SiteLayout as DefaultSiteLayout } from "../themes/minimal/MinimalSiteLayout.js";
7
+ import { BaseLayout } from "../ui/layouts/BaseLayout.js";
8
+ import { SiteLayout } from "../ui/layouts/SiteLayout.js";
12
9
  /**
13
10
  * Render a public page with the standard layout stack.
14
11
  *
15
- * Always uses the built-in BaseLayout, resolves SiteLayout from theme config.
16
- *
17
12
  * @param c - Hono context
18
13
  * @param options - Page rendering options
19
14
  * @returns Hono HTML response
@@ -29,18 +24,19 @@ import { SiteLayout as DefaultSiteLayout } from "../themes/minimal/MinimalSiteLa
29
24
  * ```
30
25
  */ export function renderPublicPage(c, options) {
31
26
  const { title, description, navData, content } = options;
32
- const components = c.var.config?.theme?.components;
33
- const Layout = components?.SiteLayout ?? DefaultSiteLayout;
34
27
  const layoutProps = {
35
28
  siteName: navData.siteName,
29
+ siteDescription: navData.siteDescription,
36
30
  links: navData.links,
37
- currentPath: navData.currentPath
31
+ currentPath: navData.currentPath,
32
+ isAuthenticated: navData.isAuthenticated,
33
+ collections: navData.collections
38
34
  };
39
35
  return c.html(/*#__PURE__*/ _jsx(BaseLayout, {
40
36
  title: title,
41
37
  description: description,
42
38
  c: c,
43
- children: /*#__PURE__*/ _jsx(Layout, {
39
+ children: /*#__PURE__*/ _jsx(SiteLayout, {
44
40
  ...layoutProps,
45
41
  children: content
46
42
  })
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared Zod schemas for validation
2
+ * Shared Zod schemas for validation (v2)
3
3
  *
4
4
  * These schemas ensure type-safe validation of user input
5
5
  * from forms, API requests, and other external sources.
@@ -7,15 +7,21 @@
7
7
  * IMPORTANT: Types are defined in types.ts as the single source of truth.
8
8
  * This file only defines Zod validation schemas based on those types.
9
9
  */ import { z } from "zod";
10
- import { POST_TYPES, VISIBILITY_LEVELS, MAX_MEDIA_ATTACHMENTS, POST_TYPE_MEDIA_RULES } from "../types.js";
10
+ import { FORMATS, STATUSES, SORT_ORDERS, NAV_ITEM_TYPES, MAX_MEDIA_ATTACHMENTS } from "../types.js";
11
11
  /**
12
- * Post type enum schema
13
- * Based on POST_TYPES from types.ts
14
- */ export const PostTypeSchema = z.enum(POST_TYPES);
12
+ * Post format enum schema
13
+ * Based on FORMATS from types.ts
14
+ */ export const FormatSchema = z.enum(FORMATS);
15
15
  /**
16
- * Visibility enum schema
17
- * Based on VISIBILITY_LEVELS from types.ts
18
- */ export const VisibilitySchema = z.enum(VISIBILITY_LEVELS);
16
+ * Post status enum schema
17
+ * Based on STATUSES from types.ts
18
+ */ export const StatusSchema = z.enum(STATUSES);
19
+ /**
20
+ * Collection sort order enum schema
21
+ */ export const SortOrderSchema = z.enum(SORT_ORDERS);
22
+ /**
23
+ * Navigation item type enum schema
24
+ */ export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
19
25
  /**
20
26
  * Redirect type enum schema
21
27
  * Form input validation for redirect type (stored as number in DB)
@@ -23,16 +29,29 @@ import { POST_TYPES, VISIBILITY_LEVELS, MAX_MEDIA_ATTACHMENTS, POST_TYPE_MEDIA_R
23
29
  "301",
24
30
  "302"
25
31
  ]);
32
+ /**
33
+ * Rating schema (1-5 integer)
34
+ */ export const RatingSchema = z.coerce.number().int().min(0).max(5).optional().or(z.literal("").transform(()=>undefined)).transform((v)=>v === 0 ? undefined : v);
26
35
  /**
27
36
  * API request body schema for creating a post
28
37
  */ export const CreatePostSchema = z.object({
29
- type: PostTypeSchema,
38
+ format: FormatSchema,
39
+ path: z.string().regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\/[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/).optional().or(z.literal("").transform(()=>undefined)),
30
40
  title: z.string().optional(),
31
- content: z.string(),
32
- visibility: VisibilitySchema,
33
- sourceUrl: z.url().optional().or(z.literal("")),
34
- sourceName: z.string().optional(),
35
- path: z.string().regex(/^[a-z0-9-]*$/).optional().or(z.literal("")),
41
+ body: z.string().optional(),
42
+ status: StatusSchema.optional(),
43
+ featured: z.union([
44
+ z.boolean(),
45
+ z.literal("on").transform(()=>true)
46
+ ]).optional(),
47
+ pinned: z.union([
48
+ z.boolean(),
49
+ z.literal("on").transform(()=>true)
50
+ ]).optional(),
51
+ url: z.url().optional().or(z.literal("")),
52
+ quoteText: z.string().optional(),
53
+ rating: RatingSchema,
54
+ collectionId: z.coerce.number().int().min(0).optional().or(z.literal("").transform(()=>undefined)).transform((v)=>v === 0 ? undefined : v),
36
55
  replyToId: z.string().optional(),
37
56
  publishedAt: z.number().int().positive().optional(),
38
57
  mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional()
@@ -40,13 +59,53 @@ import { POST_TYPES, VISIBILITY_LEVELS, MAX_MEDIA_ATTACHMENTS, POST_TYPE_MEDIA_R
40
59
  /**
41
60
  * API request body schema for updating a post
42
61
  */ export const UpdatePostSchema = CreatePostSchema.partial();
62
+ /**
63
+ * API request body schema for creating a page
64
+ */ export const CreatePageSchema = z.object({
65
+ slug: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
66
+ title: z.string().optional(),
67
+ body: z.string().optional(),
68
+ status: StatusSchema.optional()
69
+ });
70
+ /**
71
+ * API request body schema for updating a page
72
+ */ export const UpdatePageSchema = CreatePageSchema.partial();
73
+ /**
74
+ * API request body schema for creating a navigation item
75
+ */ export const CreateNavItemSchema = z.object({
76
+ type: NavItemTypeSchema,
77
+ label: z.string().min(1),
78
+ url: z.string().min(1),
79
+ pageId: z.coerce.number().int().positive().optional(),
80
+ position: z.coerce.number().int().min(0).optional()
81
+ });
82
+ /**
83
+ * API request body schema for updating a navigation item
84
+ */ export const UpdateNavItemSchema = CreateNavItemSchema.partial();
85
+ /**
86
+ * API request body schema for creating a collection
87
+ */ export const CreateCollectionSchema = z.object({
88
+ slug: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
89
+ title: z.string().min(1),
90
+ description: z.string().optional(),
91
+ icon: z.string().optional(),
92
+ sortOrder: SortOrderSchema.optional(),
93
+ position: z.coerce.number().int().min(0).optional(),
94
+ showDivider: z.union([
95
+ z.boolean(),
96
+ z.literal("on").transform(()=>true)
97
+ ]).optional()
98
+ });
99
+ /**
100
+ * API request body schema for updating a collection
101
+ */ export const UpdateCollectionSchema = CreateCollectionSchema.partial();
43
102
  /**
44
103
  * Form data helper: safely parse a FormData value with a schema
45
104
  *
46
105
  * @example
47
106
  * ```ts
48
- * const type = parseFormData(formData, "type", PostTypeSchema);
49
- * // type is PostType, throws if invalid
107
+ * const format = parseFormData(formData, "format", FormatSchema);
108
+ * // format is Format, throws if invalid
50
109
  * ```
51
110
  */ export function parseFormData(formData, key, schema) {
52
111
  const value = formData.get(key);
@@ -71,31 +130,14 @@ import { POST_TYPES, VISIBILITY_LEVELS, MAX_MEDIA_ATTACHMENTS, POST_TYPE_MEDIA_R
71
130
  return schema.parse(value);
72
131
  }
73
132
  /**
74
- * Validates media attachment count against post type rules.
133
+ * Validates media attachment count for a post.
134
+ * All formats allow 0-20 media attachments.
75
135
  *
76
- * @param type - The post type to validate against
77
136
  * @param mediaIds - Array of media IDs to attach
78
137
  * @returns null if valid, error string if invalid
79
- *
80
- * @example
81
- * ```ts
82
- * const error = validateMediaForPostType("image", []);
83
- * // Returns: "image posts require at least 1 media attachment"
84
- * ```
85
- */ export function validateMediaForPostType(type, mediaIds) {
86
- const rules = POST_TYPE_MEDIA_RULES[type];
87
- if (rules === null) {
88
- if (mediaIds.length > 0) {
89
- return `${type} posts do not allow media attachments`;
90
- }
91
- return null;
92
- }
93
- const [min, max] = rules;
94
- if (mediaIds.length < min) {
95
- return `${type} posts require at least ${min} media attachment${min !== 1 ? "s" : ""}`;
96
- }
97
- if (mediaIds.length > max) {
98
- return `${type} posts allow at most ${max} media attachment${max !== 1 ? "s" : ""}`;
138
+ */ export function validateMediaCount(mediaIds) {
139
+ if (mediaIds.length > MAX_MEDIA_ATTACHMENTS) {
140
+ return `Posts allow at most ${MAX_MEDIA_ATTACHMENTS} media attachments`;
99
141
  }
100
142
  return null;
101
143
  }
package/dist/lib/theme.js CHANGED
@@ -2,11 +2,11 @@
2
2
  * Theme Resolution Helpers
3
3
  *
4
4
  * Resolves the active color theme and builds CSS for injection into `<head>`.
5
- */ import { BUILTIN_COLOR_THEMES } from "../theme/color-themes.js";
5
+ */ import { BUILTIN_COLOR_THEMES } from "../ui/color-themes.js";
6
6
  /**
7
7
  * Get the list of available color themes.
8
8
  *
9
- * Returns `config.theme.colorThemes` if provided, otherwise the built-in list.
9
+ * Returns `config.colorThemes` if provided, otherwise the built-in list.
10
10
  *
11
11
  * @param config - The Jant configuration
12
12
  * @returns Array of available color themes
@@ -16,7 +16,7 @@
16
16
  * const themes = getAvailableThemes(c.var.config);
17
17
  * ```
18
18
  */ export function getAvailableThemes(config) {
19
- return config.theme?.colorThemes ?? BUILTIN_COLOR_THEMES;
19
+ return config.colorThemes ?? BUILTIN_COLOR_THEMES;
20
20
  }
21
21
  /**
22
22
  * Build a `<style>` CSS string from a color theme and optional cssVariables overlay.
@@ -25,7 +25,7 @@
25
25
  * BaseCoat defaults → selected theme → cssVariables
26
26
  *
27
27
  * @param theme - The active color theme (undefined = no theme overrides)
28
- * @param cssVariables - Extra CSS variable overrides from `createApp({ theme: { cssVariables } })`
28
+ * @param cssVariables - Extra CSS variable overrides from `createApp({ cssVariables })`
29
29
  * @returns CSS string to inject in `<head>`, or empty string if nothing to inject
30
30
  *
31
31
  * Uses `:root:root` and `:root.dark` selectors for higher specificity than
package/dist/lib/time.js CHANGED
@@ -96,7 +96,62 @@
96
96
  * const yearMonth = formatYearMonth(1706745600);
97
97
  * // Returns: "2024-02"
98
98
  * ```
99
- */ export function formatYearMonth(timestamp) {
99
+ */ /**
100
+ * Formats a Unix timestamp as a 24-hour time string (HH:MM).
101
+ *
102
+ * Converts a Unix timestamp (in seconds) to a zero-padded time string in
103
+ * 24-hour format. Always uses UTC timezone for consistency.
104
+ *
105
+ * @param timestamp - Unix timestamp in seconds to format
106
+ * @returns Formatted time string in "HH:MM" format
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * const time = formatTime(1706745600);
111
+ * // Returns: "00:00"
112
+ * ```
113
+ */ export function formatTime(timestamp) {
114
+ const date = new Date(timestamp * 1000);
115
+ const hours = String(date.getUTCHours()).padStart(2, "0");
116
+ const minutes = String(date.getUTCMinutes()).padStart(2, "0");
117
+ return `${hours}:${minutes}`;
118
+ }
119
+ /**
120
+ * Formats a Unix timestamp as a short relative time string.
121
+ *
122
+ * Returns compact labels like "1m", "5h", "3d" for recent timestamps,
123
+ * and falls back to "MMM D" (e.g. "Feb 1") for anything older than 7 days.
124
+ *
125
+ * @param timestamp - Unix timestamp in seconds
126
+ * @returns Short relative time string
127
+ *
128
+ * @example
129
+ * ```ts
130
+ * // Assuming current time is Feb 16, 2026
131
+ * formatRelativeTime(now() - 30); // "1m" (30 seconds → rounds up)
132
+ * formatRelativeTime(now() - 3600); // "1h"
133
+ * formatRelativeTime(now() - 86400); // "1d"
134
+ * formatRelativeTime(now() - 604800); // "7d"
135
+ * formatRelativeTime(now() - 864000); // "Feb 6"
136
+ * ```
137
+ */ export function formatRelativeTime(timestamp) {
138
+ const seconds = now() - timestamp;
139
+ if (seconds < 60) return "1m";
140
+ const minutes = Math.floor(seconds / 60);
141
+ if (minutes < 60) return `${minutes}m`;
142
+ const hours = Math.floor(seconds / 3600);
143
+ if (hours < 24) return `${hours}h`;
144
+ const days = Math.floor(seconds / 86400);
145
+ if (days <= 7) return `${days}d`;
146
+ // Older than 7 days: show "MMM D" (e.g. "Feb 1")
147
+ const date = new Date(timestamp * 1000);
148
+ return date.toLocaleDateString("en-US", {
149
+ month: "short",
150
+ day: "numeric",
151
+ timeZone: "UTC"
152
+ });
153
+ }
154
+ export function formatYearMonth(timestamp) {
100
155
  const date = new Date(timestamp * 1000);
101
156
  const year = date.getUTCFullYear();
102
157
  const month = String(date.getUTCMonth() + 1).padStart(2, "0");
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Timeline Data Assembly
3
+ *
4
+ * Shared helper for assembling timeline items with media and thread previews.
5
+ * Used by page rendering with page-based pagination.
6
+ */ import { buildMediaMap } from "./media-helpers.js";
7
+ import { createMediaContext, toPostView, toPostViews } from "./view.js";
8
+ const DEFAULT_PAGE_SIZE = 20;
9
+ /**
10
+ * Assembles a page of timeline items with media attachments and thread previews.
11
+ *
12
+ * Fetches posts using offset-based pagination, batch-loads media, identifies
13
+ * threads, and returns render-ready `TimelineItemView[]` with page info.
14
+ *
15
+ * @param c - Hono context (provides services + env)
16
+ * @param options - Optional page number (1-indexed, defaults to 1)
17
+ * @returns Assembled timeline items with pagination info
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const { items, currentPage, totalPages } = await assembleTimeline(c);
22
+ * const { items, currentPage, totalPages } = await assembleTimeline(c, { page: 2 });
23
+ * ```
24
+ */ export async function assembleTimeline(c, options) {
25
+ const pageSize = parseInt(c.env.PAGE_SIZE ?? String(DEFAULT_PAGE_SIZE), 10) || DEFAULT_PAGE_SIZE;
26
+ const page = Math.max(1, options?.page ?? 1);
27
+ const offset = (page - 1) * pageSize;
28
+ // Get total count for pagination
29
+ const totalCount = await c.var.services.posts.count({
30
+ status: "published",
31
+ excludeReplies: true
32
+ });
33
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
34
+ // Fetch posts for the current page
35
+ const posts = await c.var.services.posts.list({
36
+ status: "published",
37
+ excludeReplies: true,
38
+ limit: pageSize,
39
+ offset
40
+ });
41
+ if (posts.length === 0) {
42
+ return {
43
+ items: [],
44
+ currentPage: page,
45
+ totalPages
46
+ };
47
+ }
48
+ // Batch load media attachments
49
+ const postIds = posts.map((p)=>p.id);
50
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
51
+ const mediaCtx = createMediaContext(c);
52
+ const mediaMap = buildMediaMap(rawMediaMap, mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl);
53
+ // Get reply counts to identify thread roots
54
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
55
+ const threadRootIds = postIds.filter((id)=>(replyCounts.get(id) ?? 0) > 0);
56
+ // Batch load thread previews
57
+ const threadPreviews = await c.var.services.posts.getThreadPreviews(threadRootIds, 3);
58
+ // Batch load media for preview replies
59
+ const previewReplyIds = [];
60
+ for (const replies of threadPreviews.values()){
61
+ for (const reply of replies){
62
+ previewReplyIds.push(reply.id);
63
+ }
64
+ }
65
+ const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl) : new Map();
66
+ // Assemble timeline items with View Models
67
+ const items = posts.map((post)=>{
68
+ const postView = toPostView({
69
+ ...post,
70
+ mediaAttachments: mediaMap.get(post.id) ?? []
71
+ }, mediaCtx);
72
+ const replyCount = replyCounts.get(post.id) ?? 0;
73
+ const previewReplies = threadPreviews.get(post.id);
74
+ if (replyCount > 0 && previewReplies) {
75
+ return {
76
+ post: postView,
77
+ threadPreview: {
78
+ replies: toPostViews(previewReplies.map((r)=>({
79
+ ...r,
80
+ mediaAttachments: previewMediaMap.get(r.id) ?? []
81
+ })), mediaCtx),
82
+ totalReplyCount: replyCount
83
+ }
84
+ };
85
+ }
86
+ return {
87
+ post: postView
88
+ };
89
+ });
90
+ return {
91
+ items,
92
+ currentPage: page,
93
+ totalPages
94
+ };
95
+ }