@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,141 @@
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
+ */
7
+
8
+ import type { Context } from "hono";
9
+ import type { Bindings, TimelineItemView } from "../types.js";
10
+ import type { AppVariables } from "../app.js";
11
+ import { buildMediaMap } from "./media-helpers.js";
12
+ import { createMediaContext, toPostView, toPostViews } from "./view.js";
13
+
14
+ type Env = { Bindings: Bindings; Variables: AppVariables };
15
+
16
+ const DEFAULT_PAGE_SIZE = 20;
17
+
18
+ /**
19
+ * Result from assembling a timeline page.
20
+ */
21
+ export interface TimelineResult {
22
+ items: TimelineItemView[];
23
+ currentPage: number;
24
+ totalPages: number;
25
+ }
26
+
27
+ /**
28
+ * Assembles a page of timeline items with media attachments and thread previews.
29
+ *
30
+ * Fetches posts using offset-based pagination, batch-loads media, identifies
31
+ * threads, and returns render-ready `TimelineItemView[]` with page info.
32
+ *
33
+ * @param c - Hono context (provides services + env)
34
+ * @param options - Optional page number (1-indexed, defaults to 1)
35
+ * @returns Assembled timeline items with pagination info
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const { items, currentPage, totalPages } = await assembleTimeline(c);
40
+ * const { items, currentPage, totalPages } = await assembleTimeline(c, { page: 2 });
41
+ * ```
42
+ */
43
+ export async function assembleTimeline(
44
+ c: Context<Env>,
45
+ options?: { page?: number },
46
+ ): Promise<TimelineResult> {
47
+ const pageSize =
48
+ parseInt(c.env.PAGE_SIZE ?? String(DEFAULT_PAGE_SIZE), 10) ||
49
+ DEFAULT_PAGE_SIZE;
50
+
51
+ const page = Math.max(1, options?.page ?? 1);
52
+ const offset = (page - 1) * pageSize;
53
+
54
+ // Get total count for pagination
55
+ const totalCount = await c.var.services.posts.count({
56
+ status: "published",
57
+ excludeReplies: true,
58
+ });
59
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
60
+
61
+ // Fetch posts for the current page
62
+ const posts = await c.var.services.posts.list({
63
+ status: "published",
64
+ excludeReplies: true,
65
+ limit: pageSize,
66
+ offset,
67
+ });
68
+
69
+ if (posts.length === 0) {
70
+ return { items: [], currentPage: page, totalPages };
71
+ }
72
+
73
+ // Batch load media attachments
74
+ const postIds = posts.map((p) => p.id);
75
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
76
+ const mediaCtx = createMediaContext(c);
77
+ const mediaMap = buildMediaMap(
78
+ rawMediaMap,
79
+ mediaCtx.r2PublicUrl,
80
+ mediaCtx.imageTransformUrl,
81
+ mediaCtx.s3PublicUrl,
82
+ );
83
+
84
+ // Get reply counts to identify thread roots
85
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
86
+ const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
87
+
88
+ // Batch load thread previews
89
+ const threadPreviews = await c.var.services.posts.getThreadPreviews(
90
+ threadRootIds,
91
+ 3,
92
+ );
93
+
94
+ // Batch load media for preview replies
95
+ const previewReplyIds: number[] = [];
96
+ for (const replies of threadPreviews.values()) {
97
+ for (const reply of replies) {
98
+ previewReplyIds.push(reply.id);
99
+ }
100
+ }
101
+ const previewMediaMap =
102
+ previewReplyIds.length > 0
103
+ ? buildMediaMap(
104
+ await c.var.services.media.getByPostIds(previewReplyIds),
105
+ mediaCtx.r2PublicUrl,
106
+ mediaCtx.imageTransformUrl,
107
+ mediaCtx.s3PublicUrl,
108
+ )
109
+ : new Map();
110
+
111
+ // Assemble timeline items with View Models
112
+ const items: TimelineItemView[] = posts.map((post) => {
113
+ const postView = toPostView(
114
+ { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
115
+ mediaCtx,
116
+ );
117
+
118
+ const replyCount = replyCounts.get(post.id) ?? 0;
119
+ const previewReplies = threadPreviews.get(post.id);
120
+
121
+ if (replyCount > 0 && previewReplies) {
122
+ return {
123
+ post: postView,
124
+ threadPreview: {
125
+ replies: toPostViews(
126
+ previewReplies.map((r) => ({
127
+ ...r,
128
+ mediaAttachments: previewMediaMap.get(r.id) ?? [],
129
+ })),
130
+ mediaCtx,
131
+ ),
132
+ totalReplyCount: replyCount,
133
+ },
134
+ };
135
+ }
136
+
137
+ return { post: postView };
138
+ });
139
+
140
+ return { items, currentPage: page, totalPages };
141
+ }
package/src/lib/view.ts CHANGED
@@ -1,33 +1,44 @@
1
1
  /**
2
- * View Model Conversions
2
+ * View Model Conversions (v2)
3
3
  *
4
4
  * Transforms raw database models into render-ready View types.
5
- * Theme components receive only View types no lib/ imports needed.
5
+ * Theme components receive only View types -- no lib/ imports needed.
6
6
  */
7
7
 
8
8
  import type { Context } from "hono";
9
9
  import type {
10
10
  Post,
11
11
  PostWithMedia,
12
+ Page,
12
13
  Media,
13
14
  MediaView,
14
15
  PostView,
15
- NavLinkView,
16
- NavigationLink,
16
+ PageView,
17
+ NavItemView,
18
+ NavItem,
17
19
  SearchResult,
18
20
  SearchResultView,
19
21
  ArchiveGroup,
22
+ Format,
23
+ Status,
24
+ NavItemType,
20
25
  } from "../types.js";
21
26
  import { encode } from "./sqid.js";
22
- import { toISOString, formatDate } from "./time.js";
27
+ import {
28
+ toISOString,
29
+ formatDate,
30
+ formatTime,
31
+ formatRelativeTime,
32
+ } from "./time.js";
23
33
  import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
34
+ import { getHtmlExcerpt } from "./excerpt.js";
24
35
 
25
36
  // =============================================================================
26
37
  // Media Context
27
38
  // =============================================================================
28
39
 
29
40
  /**
30
- * Central media config extracted once per request from env.
41
+ * Central media config -- extracted once per request from env.
31
42
  */
32
43
  export interface MediaContext {
33
44
  r2PublicUrl?: string;
@@ -40,12 +51,6 @@ export interface MediaContext {
40
51
  *
41
52
  * @param c - Hono context
42
53
  * @returns MediaContext with env values
43
- *
44
- * @example
45
- * ```ts
46
- * const mediaCtx = createMediaContext(c);
47
- * const postView = toPostView(post, mediaCtx);
48
- * ```
49
54
  */
50
55
  export function createMediaContext(c: Context): MediaContext {
51
56
  return {
@@ -100,25 +105,26 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
100
105
  * Converts a PostWithMedia to a render-ready PostView.
101
106
  *
102
107
  * @param post - Post with media attachments from database
103
- * @param ctx - Media context with URL configuration
108
+ * @param _ctx - Media context with URL configuration
104
109
  * @returns Render-ready PostView with pre-computed fields
105
- *
106
- * @example
107
- * ```ts
108
- * const mediaCtx = createMediaContext(c);
109
- * const postView = toPostView({ ...post, mediaAttachments: [...] }, mediaCtx);
110
- * ```
111
110
  */
112
111
  export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
113
- const permalink = `/p/${encode(post.id)}`;
112
+ const permalink = post.path ? `/${post.path}` : `/p/${encode(post.id)}`;
114
113
 
115
- // Pre-compute excerpt from raw content
114
+ // Pre-compute excerpt from raw body
116
115
  let excerpt: string | undefined;
117
- if (post.content) {
116
+ if (post.body) {
118
117
  excerpt =
119
- post.content.length > 160
120
- ? post.content.slice(0, 160) + "..."
121
- : post.content;
118
+ post.body.length > 160 ? post.body.slice(0, 160) + "..." : post.body;
119
+ }
120
+
121
+ // Pre-compute HTML summary for article-style posts (with title)
122
+ let summaryHtml: string | undefined;
123
+ let summaryHasMore: boolean | undefined;
124
+ if (post.title && post.bodyHtml) {
125
+ const result = getHtmlExcerpt(post.bodyHtml);
126
+ summaryHtml = result.excerpt;
127
+ summaryHasMore = result.hasMore;
122
128
  }
123
129
 
124
130
  // Convert media attachments
@@ -135,31 +141,34 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
135
141
  return {
136
142
  id: post.id,
137
143
  permalink,
144
+ path: post.path ?? undefined,
138
145
  title: post.title ?? undefined,
139
- contentHtml: post.contentHtml ?? undefined,
146
+ bodyHtml: post.bodyHtml ?? undefined,
140
147
  excerpt,
141
- type: post.type,
142
- visibility: post.visibility,
143
- path: post.path ?? undefined,
148
+ summaryHtml,
149
+ summaryHasMore,
150
+ url: post.url ?? undefined,
151
+ quoteText: post.quoteText ?? undefined,
152
+ format: post.format as Format,
153
+ status: post.status as Status,
154
+ featured: post.featured === 1,
155
+ pinned: post.pinned === 1,
156
+ rating: post.rating ?? undefined,
157
+ collectionId: post.collectionId ?? undefined,
144
158
  publishedAt: toISOString(post.publishedAt),
145
159
  publishedAtFormatted: formatDate(post.publishedAt),
160
+ publishedAtTime: formatTime(post.publishedAt),
161
+ publishedAtRelative: formatRelativeTime(post.publishedAt),
146
162
  updatedAt: toISOString(post.updatedAt),
147
- sourceUrl: post.sourceUrl ?? undefined,
148
- sourceName: post.sourceName ?? undefined,
149
- sourceDomain: post.sourceDomain ?? undefined,
150
163
  media,
151
164
  replyToId: post.replyToId ?? undefined,
152
165
  threadRootId: post.threadId ?? undefined,
153
- content: post.content ?? undefined,
166
+ body: post.body ?? undefined,
154
167
  };
155
168
  }
156
169
 
157
170
  /**
158
171
  * Batch converts PostWithMedia[] to PostView[].
159
- *
160
- * @param posts - Array of posts with media
161
- * @param ctx - Media context
162
- * @returns Array of PostView
163
172
  */
164
173
  export function toPostViews(
165
174
  posts: PostWithMedia[],
@@ -170,10 +179,6 @@ export function toPostViews(
170
179
 
171
180
  /**
172
181
  * Converts a bare Post (no media) to a PostView with empty media array.
173
- *
174
- * @param post - Post without media
175
- * @param ctx - Media context (unused but kept for consistency)
176
- * @returns PostView with empty media
177
182
  */
178
183
  export function toPostViewFromPost(post: Post, ctx: MediaContext): PostView {
179
184
  return toPostView({ ...post, mediaAttachments: [] }, ctx);
@@ -181,10 +186,6 @@ export function toPostViewFromPost(post: Post, ctx: MediaContext): PostView {
181
186
 
182
187
  /**
183
188
  * Batch converts Post[] (no media) to PostView[].
184
- *
185
- * @param posts - Array of posts without media
186
- * @param ctx - Media context
187
- * @returns Array of PostView
188
189
  */
189
190
  export function toPostViewsFromPosts(
190
191
  posts: Post[],
@@ -193,55 +194,65 @@ export function toPostViewsFromPosts(
193
194
  return posts.map((p) => toPostViewFromPost(p, ctx));
194
195
  }
195
196
 
197
+ // =============================================================================
198
+ // Page Conversions
199
+ // =============================================================================
200
+
201
+ /**
202
+ * Converts a Page to a render-ready PageView.
203
+ */
204
+ export function toPageView(page: Page): PageView {
205
+ return {
206
+ id: page.id,
207
+ slug: page.slug,
208
+ title: page.title ?? undefined,
209
+ bodyHtml: page.bodyHtml ?? undefined,
210
+ status: page.status as Status,
211
+ createdAt: toISOString(page.createdAt),
212
+ updatedAt: toISOString(page.updatedAt),
213
+ };
214
+ }
215
+
196
216
  // =============================================================================
197
217
  // Navigation Conversions
198
218
  // =============================================================================
199
219
 
200
220
  /**
201
- * Converts a NavigationLink to a NavLinkView with pre-computed state.
202
- *
203
- * @param link - Raw navigation link from database
204
- * @param currentPath - Current page path for active state computation
205
- * @returns NavLinkView with isActive and isExternal pre-computed
221
+ * Converts a NavItem to a NavItemView with pre-computed state.
206
222
  */
207
- export function toNavLinkView(
208
- link: NavigationLink,
209
- currentPath: string,
210
- ): NavLinkView {
223
+ export function toNavItemView(item: NavItem, currentPath: string): NavItemView {
211
224
  const isExternal =
212
- link.url.startsWith("http://") || link.url.startsWith("https://");
225
+ item.url.startsWith("http://") || item.url.startsWith("https://");
213
226
 
214
227
  let isActive = false;
215
228
  if (!isExternal) {
216
- if (link.url === "/") {
229
+ if (item.url === "/") {
217
230
  isActive = currentPath === "/";
218
231
  } else {
219
232
  isActive =
220
- currentPath === link.url || currentPath.startsWith(link.url + "/");
233
+ currentPath === item.url || currentPath.startsWith(item.url + "/");
221
234
  }
222
235
  }
223
236
 
224
237
  return {
225
- id: link.id,
226
- label: link.label,
227
- url: link.url,
238
+ id: item.id,
239
+ type: item.type as NavItemType,
240
+ label: item.label,
241
+ url: item.url,
242
+ pageId: item.pageId ?? undefined,
228
243
  isActive,
229
244
  isExternal,
230
245
  };
231
246
  }
232
247
 
233
248
  /**
234
- * Batch converts NavigationLink[] to NavLinkView[].
235
- *
236
- * @param links - Raw navigation links
237
- * @param currentPath - Current page path
238
- * @returns Array of NavLinkView
249
+ * Batch converts NavItem[] to NavItemView[].
239
250
  */
240
- export function toNavLinkViews(
241
- links: NavigationLink[],
251
+ export function toNavItemViews(
252
+ items: NavItem[],
242
253
  currentPath: string,
243
- ): NavLinkView[] {
244
- return links.map((l) => toNavLinkView(l, currentPath));
254
+ ): NavItemView[] {
255
+ return items.map((item) => toNavItemView(item, currentPath));
245
256
  }
246
257
 
247
258
  // =============================================================================
@@ -250,10 +261,6 @@ export function toNavLinkViews(
250
261
 
251
262
  /**
252
263
  * Converts a SearchResult to a SearchResultView with PostView.
253
- *
254
- * @param result - Raw search result
255
- * @param ctx - Media context
256
- * @returns SearchResultView with PostView
257
264
  */
258
265
  export function toSearchResultView(
259
266
  result: SearchResult,
@@ -268,10 +275,6 @@ export function toSearchResultView(
268
275
 
269
276
  /**
270
277
  * Batch converts SearchResult[] to SearchResultView[].
271
- *
272
- * @param results - Raw search results
273
- * @param ctx - Media context
274
- * @returns Array of SearchResultView
275
278
  */
276
279
  export function toSearchResultViews(
277
280
  results: SearchResult[],
@@ -286,10 +289,6 @@ export function toSearchResultViews(
286
289
 
287
290
  /**
288
291
  * Converts a grouped post map to typed ArchiveGroup[].
289
- *
290
- * @param grouped - Map of "YYYY-MM" keys to Post arrays
291
- * @param ctx - Media context
292
- * @returns Array of ArchiveGroup with pre-formatted labels
293
292
  */
294
293
  export function toArchiveGroups(
295
294
  grouped: Map<string, Post[]>,
@@ -300,7 +299,6 @@ export function toArchiveGroups(
300
299
  const [year, month] = yearMonth.split("-");
301
300
  if (!year || !month) continue;
302
301
 
303
- // Format label like "February 2024"
304
302
  const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
305
303
  const label = date.toLocaleDateString("en-US", {
306
304
  year: "numeric",
package/src/preset.css CHANGED
@@ -8,7 +8,10 @@
8
8
  @source "./";
9
9
 
10
10
  @import "basecoat-css";
11
+ @plugin "@tailwindcss/typography";
11
12
  @import "./styles/components.css";
13
+ @import "./styles/tokens.css";
14
+ @import "./styles/ui.css";
12
15
 
13
16
  @theme {
14
17
  --radius-default: 0.5rem;
@@ -22,3 +25,46 @@
22
25
  .dark {
23
26
  --success: oklch(0.627 0.194 149.214);
24
27
  }
28
+
29
+ /**
30
+ * Typography (prose) — integrate with BaseCoat theme colors.
31
+ *
32
+ * The @tailwindcss/typography plugin ships with hardcoded slate values.
33
+ * We override them to use BaseCoat's CSS variables so that:
34
+ * - Light/dark mode switches automatically
35
+ * - Prose text matches the rest of the theme
36
+ * - Links are understated (same color as text, underline only)
37
+ *
38
+ * Design reference: Medium, Substack, NYT — body links inherit text
39
+ * color and rely on underline as the sole affordance.
40
+ */
41
+ .prose {
42
+ --tw-prose-body: inherit;
43
+ --tw-prose-headings: var(--color-foreground);
44
+ --tw-prose-lead: var(--color-muted-foreground);
45
+ --tw-prose-links: inherit;
46
+ --tw-prose-bold: inherit;
47
+ --tw-prose-counters: var(--color-muted-foreground);
48
+ --tw-prose-bullets: var(--color-muted-foreground);
49
+ --tw-prose-hr: var(--color-border);
50
+ --tw-prose-quotes: var(--color-foreground);
51
+ --tw-prose-quote-borders: var(--color-border);
52
+ --tw-prose-captions: var(--color-muted-foreground);
53
+ --tw-prose-kbd: var(--color-foreground);
54
+ --tw-prose-code: var(--color-foreground);
55
+ --tw-prose-pre-code: var(--color-muted-foreground);
56
+ --tw-prose-pre-bg: var(--color-muted);
57
+ --tw-prose-th-borders: var(--color-border);
58
+ --tw-prose-td-borders: var(--color-border);
59
+
60
+ /* Links: same color as surrounding text, underline only, no bold */
61
+ a {
62
+ font-weight: inherit;
63
+ text-underline-offset: 2px;
64
+ text-decoration-color: var(--color-border);
65
+ }
66
+
67
+ a:hover {
68
+ text-decoration-color: currentColor;
69
+ }
70
+ }
@@ -0,0 +1,199 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestApp } from "../../__tests__/helpers/app.js";
3
+ import { composeRoutes } from "../compose.js";
4
+
5
+ describe("Compose Routes", () => {
6
+ describe("POST /compose", () => {
7
+ it("redirects to signin when not authenticated", async () => {
8
+ const { app } = createTestApp({ authenticated: false });
9
+ app.route("/compose", composeRoutes);
10
+
11
+ const res = await app.request("/compose", {
12
+ method: "POST",
13
+ headers: { "Content-Type": "application/json" },
14
+ body: JSON.stringify({ format: "note", body: "Hello" }),
15
+ });
16
+
17
+ expect(res.status).toBe(302);
18
+ expect(res.headers.get("Location")).toBe("/signin");
19
+ });
20
+
21
+ it("creates a note post and returns redirect", async () => {
22
+ const { app, services } = createTestApp({ authenticated: true });
23
+ app.route("/compose", composeRoutes);
24
+
25
+ const res = await app.request("/compose", {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify({ format: "note", body: "Hello world" }),
29
+ });
30
+
31
+ expect(res.status).toBe(200);
32
+ expect(res.headers.get("Content-Type")).toBe("text/html");
33
+
34
+ // Verify post was created
35
+ const posts = await services.posts.list();
36
+ expect(posts).toHaveLength(1);
37
+ expect(posts[0].format).toBe("note");
38
+ expect(posts[0].body).toBe("Hello world");
39
+ expect(posts[0].status).toBe("published");
40
+ });
41
+
42
+ it("creates a link post", async () => {
43
+ const { app, services } = createTestApp({ authenticated: true });
44
+ app.route("/compose", composeRoutes);
45
+
46
+ const res = await app.request("/compose", {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({
50
+ format: "link",
51
+ body: "Check this out",
52
+ url: "https://example.com",
53
+ }),
54
+ });
55
+
56
+ expect(res.status).toBe(200);
57
+
58
+ const posts = await services.posts.list();
59
+ expect(posts).toHaveLength(1);
60
+ expect(posts[0].format).toBe("link");
61
+ expect(posts[0].url).toBe("https://example.com");
62
+ });
63
+
64
+ it("creates a quote post", async () => {
65
+ const { app, services } = createTestApp({ authenticated: true });
66
+ app.route("/compose", composeRoutes);
67
+
68
+ const res = await app.request("/compose", {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({
72
+ format: "quote",
73
+ body: "Great insight",
74
+ quoteText: "The original quote",
75
+ url: "https://example.com/source",
76
+ }),
77
+ });
78
+
79
+ expect(res.status).toBe(200);
80
+
81
+ const posts = await services.posts.list();
82
+ expect(posts).toHaveLength(1);
83
+ expect(posts[0].format).toBe("quote");
84
+ expect(posts[0].quoteText).toBe("The original quote");
85
+ });
86
+
87
+ it("creates a draft when status is draft", async () => {
88
+ const { app, services } = createTestApp({ authenticated: true });
89
+ app.route("/compose", composeRoutes);
90
+
91
+ const res = await app.request("/compose", {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify({
95
+ format: "note",
96
+ body: "Draft content",
97
+ status: "draft",
98
+ }),
99
+ });
100
+
101
+ expect(res.status).toBe(200);
102
+
103
+ const posts = await services.posts.list({ includeDrafts: true });
104
+ expect(posts).toHaveLength(1);
105
+ expect(posts[0].status).toBe("draft");
106
+ });
107
+
108
+ it("returns error for invalid format", async () => {
109
+ const { app } = createTestApp({ authenticated: true });
110
+ app.route("/compose", composeRoutes);
111
+
112
+ const res = await app.request("/compose", {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ format: "invalid", body: "Hello" }),
116
+ });
117
+
118
+ expect(res.status).toBe(200);
119
+ expect(res.headers.get("Content-Type")).toBe("text/html");
120
+ // Returns a toast error (text/html with error message)
121
+ const text = await res.text();
122
+ expect(text).toContain("toast-error");
123
+ });
124
+
125
+ it("attaches media IDs when provided", async () => {
126
+ const { app, services } = createTestApp({ authenticated: true });
127
+ app.route("/compose", composeRoutes);
128
+
129
+ // Create media first
130
+ const media = await services.media.create({
131
+ filename: "test.jpg",
132
+ originalName: "test.jpg",
133
+ mimeType: "image/jpeg",
134
+ size: 1024,
135
+ storageKey: "media/2025/01/test.jpg",
136
+ width: 800,
137
+ height: 600,
138
+ });
139
+
140
+ const res = await app.request("/compose", {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json" },
143
+ body: JSON.stringify({
144
+ format: "note",
145
+ body: "Post with media",
146
+ mediaIds: [media.id],
147
+ }),
148
+ });
149
+
150
+ expect(res.status).toBe(200);
151
+
152
+ const posts = await services.posts.list();
153
+ expect(posts).toHaveLength(1);
154
+
155
+ // Verify media is attached
156
+ const attachments = await services.media.getByPostId(posts[0].id);
157
+ expect(attachments).toHaveLength(1);
158
+ expect(attachments[0].id).toBe(media.id);
159
+ });
160
+
161
+ it("sets featured and pinned flags", async () => {
162
+ const { app, services } = createTestApp({ authenticated: true });
163
+ app.route("/compose", composeRoutes);
164
+
165
+ const res = await app.request("/compose", {
166
+ method: "POST",
167
+ headers: { "Content-Type": "application/json" },
168
+ body: JSON.stringify({
169
+ format: "note",
170
+ body: "Featured and pinned",
171
+ featured: true,
172
+ pinned: true,
173
+ }),
174
+ });
175
+
176
+ expect(res.status).toBe(200);
177
+
178
+ const posts = await services.posts.list();
179
+ expect(posts).toHaveLength(1);
180
+ expect(posts[0].featured).toBe(1);
181
+ expect(posts[0].pinned).toBe(1);
182
+ });
183
+
184
+ it("returns error when format is missing", async () => {
185
+ const { app } = createTestApp({ authenticated: true });
186
+ app.route("/compose", composeRoutes);
187
+
188
+ const res = await app.request("/compose", {
189
+ method: "POST",
190
+ headers: { "Content-Type": "application/json" },
191
+ body: JSON.stringify({ body: "No format" }),
192
+ });
193
+
194
+ expect(res.status).toBe(200);
195
+ const text = await res.text();
196
+ expect(text).toContain("toast-error");
197
+ });
198
+ });
199
+ });