@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
@@ -0,0 +1,87 @@
1
+ /**
2
+ * HTML Excerpt Utilities
3
+ *
4
+ * Generates paragraph-aware excerpts from HTML content for article
5
+ * previews in timelines. Breaks only at paragraph boundaries.
6
+ */
7
+
8
+ /**
9
+ * Strips HTML tags from a string, returning plain text.
10
+ *
11
+ * @param html - HTML string to strip
12
+ * @returns Plain text without HTML tags
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * stripHtml("<p>Hello <strong>world</strong></p>") // "Hello world"
17
+ * ```
18
+ */
19
+ export function stripHtml(html: string): string {
20
+ return html.replace(/<[^>]*>/g, "");
21
+ }
22
+
23
+ /**
24
+ * Result of extracting an HTML excerpt.
25
+ */
26
+ export interface HtmlExcerpt {
27
+ /** HTML excerpt (complete paragraphs only) */
28
+ excerpt: string;
29
+ /** Whether the original content has more text beyond the excerpt */
30
+ hasMore: boolean;
31
+ }
32
+
33
+ /**
34
+ * Extracts a paragraph-aware HTML excerpt from body HTML.
35
+ *
36
+ * Uses a greedy algorithm: accumulates paragraphs until the total
37
+ * plain-text length exceeds 500 characters, then stops. At least
38
+ * one paragraph is always included.
39
+ *
40
+ * If the content contains a `<!--more-->` marker, the content before
41
+ * the marker is used as the excerpt instead.
42
+ *
43
+ * @param bodyHtml - Full HTML body content
44
+ * @returns Excerpt HTML and whether there is more content
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * // Short content — returned as-is with hasMore = false
49
+ * getHtmlExcerpt("<p>Short post.</p>")
50
+ * // { excerpt: "<p>Short post.</p>", hasMore: false }
51
+ *
52
+ * // Long content — truncated at paragraph boundary
53
+ * getHtmlExcerpt("<p>" + "A".repeat(300) + "</p><p>" + "B".repeat(300) + "</p>")
54
+ * // { excerpt: "<p>AAA...</p>", hasMore: true }
55
+ *
56
+ * // Manual break with <!--more-->
57
+ * getHtmlExcerpt("<p>Intro</p><!--more--><p>Rest</p>")
58
+ * // { excerpt: "<p>Intro</p>", hasMore: true }
59
+ * ```
60
+ */
61
+ export function getHtmlExcerpt(bodyHtml: string): HtmlExcerpt {
62
+ // Honor manual <!--more--> marker
63
+ if (bodyHtml.includes("<!--more-->")) {
64
+ const excerpt = bodyHtml.split("<!--more-->")[0]!;
65
+ return { excerpt, hasMore: true };
66
+ }
67
+
68
+ const paragraphs = bodyHtml.match(/<p>[\s\S]*?<\/p>/g) || [];
69
+
70
+ // No paragraphs found — return full content
71
+ if (paragraphs.length === 0) {
72
+ return { excerpt: bodyHtml, hasMore: false };
73
+ }
74
+
75
+ let excerpt = "";
76
+ let charCount = 0;
77
+
78
+ for (const p of paragraphs) {
79
+ const textLen = stripHtml(p).length;
80
+ if (charCount + textLen > 500 && excerpt) break;
81
+ excerpt += p;
82
+ charCount += textLen;
83
+ }
84
+
85
+ const hasMore = excerpt.length < bodyHtml.length;
86
+ return { excerpt, hasMore };
87
+ }
package/src/lib/feed.ts CHANGED
@@ -51,7 +51,7 @@ export function defaultRssRenderer(data: FeedData): string {
51
51
  <link>${link}</link>
52
52
  <guid isPermaLink="true">${link}</guid>
53
53
  <pubDate>${pubDate}</pubDate>
54
- <description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
54
+ <description><![CDATA[${post.bodyHtml || ""}]]></description>${enclosure}
55
55
  </item>`;
56
56
  })
57
57
  .join("");
@@ -90,7 +90,7 @@ export function defaultAtomRenderer(data: FeedData): string {
90
90
  <id>${link}</id>
91
91
  <published>${post.publishedAt}</published>
92
92
  <updated>${post.updatedAt}</updated>
93
- <content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
93
+ <content type="html"><![CDATA[${post.bodyHtml || ""}]]></content>
94
94
  </entry>`;
95
95
  })
96
96
  .join("");
@@ -112,17 +112,17 @@ export function defaultAtomRenderer(data: FeedData): string {
112
112
  /**
113
113
  * Default Sitemap renderer.
114
114
  *
115
- * @param data - Sitemap data with PostView[] (pre-computed URLs)
115
+ * @param data - Sitemap data with PostView[] and PageView[]
116
116
  * @returns Sitemap XML string
117
117
  */
118
118
  export function defaultSitemapRenderer(data: SitemapData): string {
119
- const { siteUrl, posts } = data;
119
+ const { siteUrl, posts, pages } = data;
120
120
 
121
- const urls = posts
121
+ const postUrls = posts
122
122
  .map((post) => {
123
123
  const loc = `${siteUrl}${post.permalink}`;
124
124
  const lastmod = post.updatedAt.split("T")[0];
125
- const priority = post.visibility === "featured" ? "0.8" : "0.6";
125
+ const priority = post.featured ? "0.8" : "0.6";
126
126
 
127
127
  return `
128
128
  <url>
@@ -133,6 +133,20 @@ export function defaultSitemapRenderer(data: SitemapData): string {
133
133
  })
134
134
  .join("");
135
135
 
136
+ const pageUrls = pages
137
+ .map((page) => {
138
+ const loc = `${siteUrl}/${page.slug}`;
139
+ const lastmod = page.updatedAt.split("T")[0];
140
+
141
+ return `
142
+ <url>
143
+ <loc>${loc}</loc>
144
+ <lastmod>${lastmod}</lastmod>
145
+ <priority>0.7</priority>
146
+ </url>`;
147
+ })
148
+ .join("");
149
+
136
150
  const homepageUrl = `
137
151
  <url>
138
152
  <loc>${siteUrl}/</loc>
@@ -143,6 +157,7 @@ export function defaultSitemapRenderer(data: SitemapData): string {
143
157
  return `<?xml version="1.0" encoding="UTF-8"?>
144
158
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
145
159
  ${homepageUrl}
146
- ${urls}
160
+ ${postUrls}
161
+ ${pageUrls}
147
162
  </urlset>`;
148
163
  }
@@ -16,7 +16,7 @@ if (list) {
16
16
  const ids = [...list.querySelectorAll<HTMLElement>("[data-id]")].map(
17
17
  (el) => Number(el.dataset.id),
18
18
  );
19
- fetch("/dash/navigation/reorder", {
19
+ fetch("/dash/pages/reorder", {
20
20
  method: "POST",
21
21
  headers: { "Content-Type": "application/json" },
22
22
  body: JSON.stringify({ ids }),
@@ -6,23 +6,26 @@
6
6
 
7
7
  import type { Context } from "hono";
8
8
  import { getSiteName } from "./config.js";
9
- import type { NavLinkView } from "../types.js";
10
- import { toNavLinkViews } from "./view.js";
9
+ import type { Collection, NavItemView } from "../types.js";
10
+ import { toNavItemViews } from "./view.js";
11
11
 
12
12
  /**
13
13
  * Navigation data needed by SiteLayout
14
14
  */
15
15
  export interface NavigationData {
16
- links: NavLinkView[];
16
+ links: NavItemView[];
17
17
  currentPath: string;
18
18
  siteName: string;
19
+ siteDescription: string;
20
+ isAuthenticated: boolean;
21
+ collections: Collection[];
19
22
  }
20
23
 
21
24
  /**
22
25
  * Fetch navigation data for public pages.
23
26
  *
24
- * Ensures default links exist (Home, Archive, RSS) and returns
25
- * NavLinkView[] with pre-computed isActive/isExternal state.
27
+ * Returns NavItemView[] with pre-computed isActive/isExternal state.
28
+ * Also checks authentication status and loads collections for authenticated users.
26
29
  *
27
30
  * @param c - Hono context
28
31
  * @returns Navigation data for SiteLayout
@@ -38,9 +41,43 @@ export interface NavigationData {
38
41
  * ```
39
42
  */
40
43
  export async function getNavigationData(c: Context): Promise<NavigationData> {
41
- const navigationLinks = await c.var.services.navigationLinks.ensureDefaults();
44
+ const items = await c.var.services.navItems.list();
42
45
  const currentPath = new URL(c.req.url).pathname;
43
46
  const siteName = await getSiteName(c);
44
- const links = toNavLinkViews(navigationLinks, currentPath);
45
- return { links, currentPath, siteName };
47
+
48
+ // Only include description if explicitly set (DB or env), not the default
49
+ const dbDescription = await c.var.services.settings.get("SITE_DESCRIPTION");
50
+ const envDescription = c.env.SITE_DESCRIPTION;
51
+ const siteDescription =
52
+ dbDescription || (typeof envDescription === "string" ? envDescription : "");
53
+
54
+ const links = toNavItemViews(items, currentPath);
55
+
56
+ // Check auth status for compose button
57
+ let isAuthenticated = false;
58
+ let collections: Collection[] = [];
59
+ if (c.var.auth) {
60
+ try {
61
+ const session = await c.var.auth.api.getSession({
62
+ headers: c.req.raw.headers,
63
+ });
64
+ isAuthenticated = !!session?.user;
65
+ } catch {
66
+ // Not authenticated
67
+ }
68
+ }
69
+
70
+ // Only load collections when authenticated (for compose dialog)
71
+ if (isAuthenticated) {
72
+ collections = await c.var.services.collections.list();
73
+ }
74
+
75
+ return {
76
+ links,
77
+ currentPath,
78
+ siteName,
79
+ siteDescription,
80
+ isAuthenticated,
81
+ collections,
82
+ };
46
83
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pagination Utilities
3
+ *
4
+ * Pure utility functions for page-based pagination.
5
+ */
6
+
7
+ /**
8
+ * Computes which page numbers to display in a numbered pagination control.
9
+ * Always includes: first page, last page, current page, and 1 page on each side of current.
10
+ * Gaps between non-consecutive pages are represented by 0 (ellipsis marker).
11
+ *
12
+ * @param currentPage - The current active page (1-indexed)
13
+ * @param totalPages - Total number of pages
14
+ * @returns Array of page numbers, with 0 representing ellipsis gaps
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * getPageNumbers(1, 5) // [1, 2, 3, 4, 5]
19
+ * getPageNumbers(1, 20) // [1, 2, 0, 20]
20
+ * getPageNumbers(10, 20) // [1, 0, 9, 10, 11, 0, 20]
21
+ * ```
22
+ */
23
+ export function getPageNumbers(
24
+ currentPage: number,
25
+ totalPages: number,
26
+ ): number[] {
27
+ if (totalPages <= 7) {
28
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
29
+ }
30
+
31
+ const pages = new Set<number>();
32
+ pages.add(1);
33
+ pages.add(totalPages);
34
+ pages.add(currentPage);
35
+ if (currentPage > 1) pages.add(currentPage - 1);
36
+ if (currentPage < totalPages) pages.add(currentPage + 1);
37
+
38
+ const sorted = [...pages].sort((a, b) => a - b);
39
+
40
+ // Insert 0 for gaps
41
+ const result: number[] = [];
42
+ for (let i = 0; i < sorted.length; i++) {
43
+ if (i > 0 && sorted[i]! - sorted[i - 1]! > 1) {
44
+ result.push(0); // ellipsis marker
45
+ }
46
+ result.push(sorted[i]!);
47
+ }
48
+
49
+ return result;
50
+ }
@@ -3,16 +3,13 @@
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
  */
10
7
 
11
8
  import type { Context } from "hono";
12
9
  import type { Child } from "hono/jsx";
13
- import type { ThemeComponents, SiteLayoutProps } from "../types.js";
14
- import { BaseLayout } from "../theme/layouts/BaseLayout.js";
15
- import { SiteLayout as DefaultSiteLayout } from "../themes/minimal/MinimalSiteLayout.js";
10
+ import type { SiteLayoutProps } from "../types.js";
11
+ import { BaseLayout } from "../ui/layouts/BaseLayout.js";
12
+ import { SiteLayout } from "../ui/layouts/SiteLayout.js";
16
13
  import type { NavigationData } from "./navigation.js";
17
14
 
18
15
  export interface RenderPublicPageOptions {
@@ -29,8 +26,6 @@ export interface RenderPublicPageOptions {
29
26
  /**
30
27
  * Render a public page with the standard layout stack.
31
28
  *
32
- * Always uses the built-in BaseLayout, resolves SiteLayout from theme config.
33
- *
34
29
  * @param c - Hono context
35
30
  * @param options - Page rendering options
36
31
  * @returns Hono HTML response
@@ -48,20 +43,18 @@ export interface RenderPublicPageOptions {
48
43
  export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
49
44
  const { title, description, navData, content } = options;
50
45
 
51
- const components = c.var.config?.theme?.components as
52
- | ThemeComponents
53
- | undefined;
54
- const Layout = components?.SiteLayout ?? DefaultSiteLayout;
55
-
56
46
  const layoutProps: SiteLayoutProps = {
57
47
  siteName: navData.siteName,
48
+ siteDescription: navData.siteDescription,
58
49
  links: navData.links,
59
50
  currentPath: navData.currentPath,
51
+ isAuthenticated: navData.isAuthenticated,
52
+ collections: navData.collections,
60
53
  };
61
54
 
62
55
  return c.html(
63
56
  <BaseLayout title={title} description={description} c={c}>
64
- <Layout {...layoutProps}>{content}</Layout>
57
+ <SiteLayout {...layoutProps}>{content}</SiteLayout>
65
58
  </BaseLayout>,
66
59
  );
67
60
  }
@@ -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.
@@ -10,24 +10,34 @@
10
10
 
11
11
  import { z } from "zod";
12
12
  import {
13
- POST_TYPES,
14
- VISIBILITY_LEVELS,
13
+ FORMATS,
14
+ STATUSES,
15
+ SORT_ORDERS,
16
+ NAV_ITEM_TYPES,
15
17
  MAX_MEDIA_ATTACHMENTS,
16
- POST_TYPE_MEDIA_RULES,
17
18
  } from "../types.js";
18
- import type { PostType } from "../types.js";
19
19
 
20
20
  /**
21
- * Post type enum schema
22
- * Based on POST_TYPES from types.ts
21
+ * Post format enum schema
22
+ * Based on FORMATS from types.ts
23
23
  */
24
- export const PostTypeSchema = z.enum(POST_TYPES);
24
+ export const FormatSchema = z.enum(FORMATS);
25
25
 
26
26
  /**
27
- * Visibility enum schema
28
- * Based on VISIBILITY_LEVELS from types.ts
27
+ * Post status enum schema
28
+ * Based on STATUSES from types.ts
29
29
  */
30
- export const VisibilitySchema = z.enum(VISIBILITY_LEVELS);
30
+ export const StatusSchema = z.enum(STATUSES);
31
+
32
+ /**
33
+ * Collection sort order enum schema
34
+ */
35
+ export const SortOrderSchema = z.enum(SORT_ORDERS);
36
+
37
+ /**
38
+ * Navigation item type enum schema
39
+ */
40
+ export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
31
41
 
32
42
  /**
33
43
  * Redirect type enum schema
@@ -35,21 +45,47 @@ export const VisibilitySchema = z.enum(VISIBILITY_LEVELS);
35
45
  */
36
46
  export const RedirectTypeSchema = z.enum(["301", "302"]);
37
47
 
48
+ /**
49
+ * Rating schema (1-5 integer)
50
+ */
51
+ export const RatingSchema = z.coerce
52
+ .number()
53
+ .int()
54
+ .min(0)
55
+ .max(5)
56
+ .optional()
57
+ .or(z.literal("").transform(() => undefined))
58
+ .transform((v) => (v === 0 ? undefined : v));
59
+
38
60
  /**
39
61
  * API request body schema for creating a post
40
62
  */
41
63
  export const CreatePostSchema = z.object({
42
- type: PostTypeSchema,
43
- title: z.string().optional(),
44
- content: z.string(),
45
- visibility: VisibilitySchema,
46
- sourceUrl: z.url().optional().or(z.literal("")),
47
- sourceName: z.string().optional(),
64
+ format: FormatSchema,
48
65
  path: z
49
66
  .string()
50
- .regex(/^[a-z0-9-]*$/)
67
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\/[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/)
68
+ .optional()
69
+ .or(z.literal("").transform(() => undefined)),
70
+ title: z.string().optional(),
71
+ body: z.string().optional(),
72
+ status: StatusSchema.optional(),
73
+ featured: z
74
+ .union([z.boolean(), z.literal("on").transform(() => true)])
75
+ .optional(),
76
+ pinned: z
77
+ .union([z.boolean(), z.literal("on").transform(() => true)])
78
+ .optional(),
79
+ url: z.url().optional().or(z.literal("")),
80
+ quoteText: z.string().optional(),
81
+ rating: RatingSchema,
82
+ collectionId: z.coerce
83
+ .number()
84
+ .int()
85
+ .min(0)
51
86
  .optional()
52
- .or(z.literal("")),
87
+ .or(z.literal("").transform(() => undefined))
88
+ .transform((v) => (v === 0 ? undefined : v)),
53
89
  replyToId: z.string().optional(), // Sqid format
54
90
  publishedAt: z.number().int().positive().optional(),
55
91
  mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
@@ -60,13 +96,70 @@ export const CreatePostSchema = z.object({
60
96
  */
61
97
  export const UpdatePostSchema = CreatePostSchema.partial();
62
98
 
99
+ /**
100
+ * API request body schema for creating a page
101
+ */
102
+ export const CreatePageSchema = z.object({
103
+ slug: z
104
+ .string()
105
+ .min(1)
106
+ .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
107
+ title: z.string().optional(),
108
+ body: z.string().optional(),
109
+ status: StatusSchema.optional(),
110
+ });
111
+
112
+ /**
113
+ * API request body schema for updating a page
114
+ */
115
+ export const UpdatePageSchema = CreatePageSchema.partial();
116
+
117
+ /**
118
+ * API request body schema for creating a navigation item
119
+ */
120
+ export const CreateNavItemSchema = z.object({
121
+ type: NavItemTypeSchema,
122
+ label: z.string().min(1),
123
+ url: z.string().min(1),
124
+ pageId: z.coerce.number().int().positive().optional(),
125
+ position: z.coerce.number().int().min(0).optional(),
126
+ });
127
+
128
+ /**
129
+ * API request body schema for updating a navigation item
130
+ */
131
+ export const UpdateNavItemSchema = CreateNavItemSchema.partial();
132
+
133
+ /**
134
+ * API request body schema for creating a collection
135
+ */
136
+ export const CreateCollectionSchema = z.object({
137
+ slug: z
138
+ .string()
139
+ .min(1)
140
+ .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
141
+ title: z.string().min(1),
142
+ description: z.string().optional(),
143
+ icon: z.string().optional(),
144
+ sortOrder: SortOrderSchema.optional(),
145
+ position: z.coerce.number().int().min(0).optional(),
146
+ showDivider: z
147
+ .union([z.boolean(), z.literal("on").transform(() => true)])
148
+ .optional(),
149
+ });
150
+
151
+ /**
152
+ * API request body schema for updating a collection
153
+ */
154
+ export const UpdateCollectionSchema = CreateCollectionSchema.partial();
155
+
63
156
  /**
64
157
  * Form data helper: safely parse a FormData value with a schema
65
158
  *
66
159
  * @example
67
160
  * ```ts
68
- * const type = parseFormData(formData, "type", PostTypeSchema);
69
- * // type is PostType, throws if invalid
161
+ * const format = parseFormData(formData, "format", FormatSchema);
162
+ * // format is Format, throws if invalid
70
163
  * ```
71
164
  */
72
165
  export function parseFormData<T>(
@@ -103,40 +196,15 @@ export function parseFormDataOptional<T>(
103
196
  }
104
197
 
105
198
  /**
106
- * Validates media attachment count against post type rules.
199
+ * Validates media attachment count for a post.
200
+ * All formats allow 0-20 media attachments.
107
201
  *
108
- * @param type - The post type to validate against
109
202
  * @param mediaIds - Array of media IDs to attach
110
203
  * @returns null if valid, error string if invalid
111
- *
112
- * @example
113
- * ```ts
114
- * const error = validateMediaForPostType("image", []);
115
- * // Returns: "image posts require at least 1 media attachment"
116
- * ```
117
204
  */
118
- export function validateMediaForPostType(
119
- type: PostType,
120
- mediaIds: string[],
121
- ): string | null {
122
- const rules = POST_TYPE_MEDIA_RULES[type];
123
-
124
- if (rules === null) {
125
- if (mediaIds.length > 0) {
126
- return `${type} posts do not allow media attachments`;
127
- }
128
- return null;
205
+ export function validateMediaCount(mediaIds: string[]): string | null {
206
+ if (mediaIds.length > MAX_MEDIA_ATTACHMENTS) {
207
+ return `Posts allow at most ${MAX_MEDIA_ATTACHMENTS} media attachments`;
129
208
  }
130
-
131
- const [min, max] = rules;
132
-
133
- if (mediaIds.length < min) {
134
- return `${type} posts require at least ${min} media attachment${min !== 1 ? "s" : ""}`;
135
- }
136
-
137
- if (mediaIds.length > max) {
138
- return `${type} posts allow at most ${max} media attachment${max !== 1 ? "s" : ""}`;
139
- }
140
-
141
209
  return null;
142
210
  }
package/src/lib/theme.ts CHANGED
@@ -4,14 +4,14 @@
4
4
  * Resolves the active color theme and builds CSS for injection into `<head>`.
5
5
  */
6
6
 
7
- import type { ColorTheme } from "../theme/color-themes.js";
8
- import { BUILTIN_COLOR_THEMES } from "../theme/color-themes.js";
7
+ import type { ColorTheme } from "../ui/color-themes.js";
8
+ import { BUILTIN_COLOR_THEMES } from "../ui/color-themes.js";
9
9
  import type { JantConfig } from "../types.js";
10
10
 
11
11
  /**
12
12
  * Get the list of available color themes.
13
13
  *
14
- * Returns `config.theme.colorThemes` if provided, otherwise the built-in list.
14
+ * Returns `config.colorThemes` if provided, otherwise the built-in list.
15
15
  *
16
16
  * @param config - The Jant configuration
17
17
  * @returns Array of available color themes
@@ -22,7 +22,7 @@ import type { JantConfig } from "../types.js";
22
22
  * ```
23
23
  */
24
24
  export function getAvailableThemes(config: JantConfig): ColorTheme[] {
25
- return config.theme?.colorThemes ?? BUILTIN_COLOR_THEMES;
25
+ return config.colorThemes ?? BUILTIN_COLOR_THEMES;
26
26
  }
27
27
 
28
28
  /**
@@ -32,7 +32,7 @@ export function getAvailableThemes(config: JantConfig): ColorTheme[] {
32
32
  * BaseCoat defaults → selected theme → cssVariables
33
33
  *
34
34
  * @param theme - The active color theme (undefined = no theme overrides)
35
- * @param cssVariables - Extra CSS variable overrides from `createApp({ theme: { cssVariables } })`
35
+ * @param cssVariables - Extra CSS variable overrides from `createApp({ cssVariables })`
36
36
  * @returns CSS string to inject in `<head>`, or empty string if nothing to inject
37
37
  *
38
38
  * Uses `:root:root` and `:root.dark` selectors for higher specificity than
package/src/lib/time.ts CHANGED
@@ -109,6 +109,70 @@ export function formatDate(timestamp: number): string {
109
109
  * // Returns: "2024-02"
110
110
  * ```
111
111
  */
112
+ /**
113
+ * Formats a Unix timestamp as a 24-hour time string (HH:MM).
114
+ *
115
+ * Converts a Unix timestamp (in seconds) to a zero-padded time string in
116
+ * 24-hour format. Always uses UTC timezone for consistency.
117
+ *
118
+ * @param timestamp - Unix timestamp in seconds to format
119
+ * @returns Formatted time string in "HH:MM" format
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * const time = formatTime(1706745600);
124
+ * // Returns: "00:00"
125
+ * ```
126
+ */
127
+ export function formatTime(timestamp: number): string {
128
+ const date = new Date(timestamp * 1000);
129
+ const hours = String(date.getUTCHours()).padStart(2, "0");
130
+ const minutes = String(date.getUTCMinutes()).padStart(2, "0");
131
+ return `${hours}:${minutes}`;
132
+ }
133
+
134
+ /**
135
+ * Formats a Unix timestamp as a short relative time string.
136
+ *
137
+ * Returns compact labels like "1m", "5h", "3d" for recent timestamps,
138
+ * and falls back to "MMM D" (e.g. "Feb 1") for anything older than 7 days.
139
+ *
140
+ * @param timestamp - Unix timestamp in seconds
141
+ * @returns Short relative time string
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * // Assuming current time is Feb 16, 2026
146
+ * formatRelativeTime(now() - 30); // "1m" (30 seconds → rounds up)
147
+ * formatRelativeTime(now() - 3600); // "1h"
148
+ * formatRelativeTime(now() - 86400); // "1d"
149
+ * formatRelativeTime(now() - 604800); // "7d"
150
+ * formatRelativeTime(now() - 864000); // "Feb 6"
151
+ * ```
152
+ */
153
+ export function formatRelativeTime(timestamp: number): string {
154
+ const seconds = now() - timestamp;
155
+
156
+ if (seconds < 60) return "1m";
157
+
158
+ const minutes = Math.floor(seconds / 60);
159
+ if (minutes < 60) return `${minutes}m`;
160
+
161
+ const hours = Math.floor(seconds / 3600);
162
+ if (hours < 24) return `${hours}h`;
163
+
164
+ const days = Math.floor(seconds / 86400);
165
+ if (days <= 7) return `${days}d`;
166
+
167
+ // Older than 7 days: show "MMM D" (e.g. "Feb 1")
168
+ const date = new Date(timestamp * 1000);
169
+ return date.toLocaleDateString("en-US", {
170
+ month: "short",
171
+ day: "numeric",
172
+ timeZone: "UTC",
173
+ });
174
+ }
175
+
112
176
  export function formatYearMonth(timestamp: number): string {
113
177
  const date = new Date(timestamp * 1000);
114
178
  const year = date.getUTCFullYear();