@jant/core 0.3.22 → 0.3.24

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 (178) hide show
  1. package/dist/app.js +23 -5
  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 -6
  7. package/dist/lib/constants.js +1 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/navigation.js +4 -5
  11. package/dist/lib/render.js +1 -1
  12. package/dist/lib/schemas.js +80 -38
  13. package/dist/lib/theme-components.js +8 -11
  14. package/dist/lib/time.js +56 -1
  15. package/dist/lib/timeline.js +119 -0
  16. package/dist/lib/view.js +62 -73
  17. package/dist/routes/api/posts.js +29 -35
  18. package/dist/routes/api/search.js +5 -6
  19. package/dist/routes/api/upload.js +13 -13
  20. package/dist/routes/dash/collections.js +22 -40
  21. package/dist/routes/dash/index.js +2 -2
  22. package/dist/routes/dash/navigation.js +25 -24
  23. package/dist/routes/dash/pages.js +42 -57
  24. package/dist/routes/dash/posts.js +27 -35
  25. package/dist/routes/feed/rss.js +2 -4
  26. package/dist/routes/feed/sitemap.js +10 -7
  27. package/dist/routes/pages/archive.js +12 -11
  28. package/dist/routes/pages/collection.js +11 -5
  29. package/dist/routes/pages/home.js +53 -61
  30. package/dist/routes/pages/page.js +60 -29
  31. package/dist/routes/pages/post.js +5 -12
  32. package/dist/routes/pages/search.js +3 -4
  33. package/dist/services/collection.js +52 -64
  34. package/dist/services/index.js +5 -3
  35. package/dist/services/navigation.js +29 -53
  36. package/dist/services/page.js +80 -0
  37. package/dist/services/post.js +68 -69
  38. package/dist/services/search.js +24 -18
  39. package/dist/theme/components/MediaGallery.js +19 -91
  40. package/dist/theme/components/PageForm.js +15 -15
  41. package/dist/theme/components/PostForm.js +136 -129
  42. package/dist/theme/components/PostList.js +13 -8
  43. package/dist/theme/components/ThreadView.js +3 -3
  44. package/dist/theme/components/TypeBadge.js +3 -14
  45. package/dist/theme/components/VisibilityBadge.js +33 -23
  46. package/dist/theme/components/index.js +0 -2
  47. package/dist/theme/index.js +10 -16
  48. package/dist/theme/layouts/index.js +0 -1
  49. package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
  50. package/dist/themes/threads/index.js +81 -0
  51. package/dist/{theme → themes/threads}/pages/ArchivePage.js +31 -47
  52. package/dist/themes/threads/pages/CollectionPage.js +65 -0
  53. package/dist/{theme → themes/threads}/pages/HomePage.js +4 -5
  54. package/dist/{theme → themes/threads}/pages/PostPage.js +10 -8
  55. package/dist/{theme → themes/threads}/pages/SearchPage.js +8 -8
  56. package/dist/{theme → themes/threads}/pages/SinglePage.js +5 -6
  57. package/dist/{theme/components → themes/threads}/timeline/LinkCard.js +20 -11
  58. package/dist/themes/threads/timeline/NoteCard.js +53 -0
  59. package/dist/themes/threads/timeline/QuoteCard.js +59 -0
  60. package/dist/{theme/components → themes/threads}/timeline/ThreadPreview.js +5 -6
  61. package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
  62. package/dist/{theme/components → themes/threads}/timeline/TimelineItem.js +8 -17
  63. package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
  64. package/dist/themes/threads/timeline/groupByDate.js +22 -0
  65. package/dist/themes/threads/timeline/timelineMore.js +107 -0
  66. package/dist/types.js +24 -40
  67. package/package.json +2 -1
  68. package/src/__tests__/helpers/app.ts +4 -0
  69. package/src/__tests__/helpers/db.ts +51 -74
  70. package/src/app.tsx +27 -6
  71. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  72. package/src/db/migrations/meta/_journal.json +7 -0
  73. package/src/db/schema.ts +63 -46
  74. package/src/i18n/locales/en.po +216 -164
  75. package/src/i18n/locales/en.ts +1 -1
  76. package/src/i18n/locales/zh-Hans.po +216 -164
  77. package/src/i18n/locales/zh-Hans.ts +1 -1
  78. package/src/i18n/locales/zh-Hant.po +216 -164
  79. package/src/i18n/locales/zh-Hant.ts +1 -1
  80. package/src/index.ts +30 -15
  81. package/src/lib/__tests__/excerpt.test.ts +125 -0
  82. package/src/lib/__tests__/schemas.test.ts +166 -105
  83. package/src/lib/__tests__/theme-components.test.ts +4 -25
  84. package/src/lib/__tests__/time.test.ts +62 -0
  85. package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
  86. package/src/lib/__tests__/view.test.ts +217 -67
  87. package/src/lib/constants.ts +1 -4
  88. package/src/lib/excerpt.ts +87 -0
  89. package/src/lib/feed.ts +22 -7
  90. package/src/lib/navigation.ts +6 -7
  91. package/src/lib/render.tsx +1 -1
  92. package/src/lib/schemas.ts +118 -52
  93. package/src/lib/theme-components.ts +10 -13
  94. package/src/lib/time.ts +64 -0
  95. package/src/lib/timeline.ts +170 -0
  96. package/src/lib/view.ts +81 -83
  97. package/src/preset.css +45 -0
  98. package/src/routes/api/__tests__/posts.test.ts +50 -108
  99. package/src/routes/api/__tests__/search.test.ts +2 -3
  100. package/src/routes/api/posts.ts +30 -30
  101. package/src/routes/api/search.ts +4 -4
  102. package/src/routes/api/upload.ts +16 -6
  103. package/src/routes/dash/collections.tsx +18 -40
  104. package/src/routes/dash/index.tsx +2 -2
  105. package/src/routes/dash/navigation.tsx +27 -26
  106. package/src/routes/dash/pages.tsx +45 -60
  107. package/src/routes/dash/posts.tsx +44 -52
  108. package/src/routes/feed/rss.ts +2 -1
  109. package/src/routes/feed/sitemap.ts +14 -4
  110. package/src/routes/pages/archive.tsx +14 -10
  111. package/src/routes/pages/collection.tsx +17 -6
  112. package/src/routes/pages/home.tsx +56 -81
  113. package/src/routes/pages/page.tsx +64 -27
  114. package/src/routes/pages/post.tsx +5 -14
  115. package/src/routes/pages/search.tsx +2 -2
  116. package/src/services/__tests__/collection.test.ts +257 -158
  117. package/src/services/__tests__/media.test.ts +18 -18
  118. package/src/services/__tests__/navigation.test.ts +161 -87
  119. package/src/services/__tests__/post-timeline.test.ts +92 -88
  120. package/src/services/__tests__/post.test.ts +342 -206
  121. package/src/services/__tests__/search.test.ts +19 -25
  122. package/src/services/collection.ts +71 -113
  123. package/src/services/index.ts +9 -8
  124. package/src/services/navigation.ts +38 -71
  125. package/src/services/page.ts +124 -0
  126. package/src/services/post.ts +93 -103
  127. package/src/services/search.ts +38 -27
  128. package/src/styles/components.css +0 -54
  129. package/src/theme/components/MediaGallery.tsx +27 -96
  130. package/src/theme/components/PageForm.tsx +21 -21
  131. package/src/theme/components/PostForm.tsx +122 -118
  132. package/src/theme/components/PostList.tsx +58 -49
  133. package/src/theme/components/ThreadView.tsx +6 -3
  134. package/src/theme/components/TypeBadge.tsx +9 -17
  135. package/src/theme/components/VisibilityBadge.tsx +40 -23
  136. package/src/theme/components/index.ts +0 -13
  137. package/src/theme/index.ts +10 -16
  138. package/src/theme/layouts/index.ts +0 -1
  139. package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
  140. package/src/themes/threads/index.ts +100 -0
  141. package/src/{theme → themes/threads}/pages/ArchivePage.tsx +52 -55
  142. package/src/themes/threads/pages/CollectionPage.tsx +61 -0
  143. package/src/{theme → themes/threads}/pages/HomePage.tsx +5 -6
  144. package/src/{theme → themes/threads}/pages/PostPage.tsx +11 -8
  145. package/src/{theme → themes/threads}/pages/SearchPage.tsx +9 -13
  146. package/src/themes/threads/pages/SinglePage.tsx +23 -0
  147. package/src/themes/threads/style.css +336 -0
  148. package/src/{theme/components → themes/threads}/timeline/LinkCard.tsx +21 -13
  149. package/src/themes/threads/timeline/NoteCard.tsx +58 -0
  150. package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
  151. package/src/{theme/components → themes/threads}/timeline/ThreadPreview.tsx +6 -6
  152. package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
  153. package/src/{theme/components → themes/threads}/timeline/TimelineItem.tsx +9 -20
  154. package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
  155. package/src/themes/threads/timeline/groupByDate.ts +30 -0
  156. package/src/themes/threads/timeline/timelineMore.tsx +130 -0
  157. package/src/types.ts +242 -98
  158. package/dist/routes/api/timeline.js +0 -120
  159. package/dist/theme/components/timeline/ArticleCard.js +0 -46
  160. package/dist/theme/components/timeline/ImageCard.js +0 -83
  161. package/dist/theme/components/timeline/NoteCard.js +0 -34
  162. package/dist/theme/components/timeline/QuoteCard.js +0 -48
  163. package/dist/theme/components/timeline/TimelineFeed.js +0 -46
  164. package/dist/theme/components/timeline/index.js +0 -8
  165. package/dist/theme/layouts/SiteLayout.js +0 -131
  166. package/dist/theme/pages/CollectionPage.js +0 -63
  167. package/dist/theme/pages/index.js +0 -11
  168. package/src/routes/api/timeline.tsx +0 -159
  169. package/src/theme/components/timeline/ArticleCard.tsx +0 -45
  170. package/src/theme/components/timeline/ImageCard.tsx +0 -70
  171. package/src/theme/components/timeline/NoteCard.tsx +0 -34
  172. package/src/theme/components/timeline/QuoteCard.tsx +0 -48
  173. package/src/theme/components/timeline/TimelineFeed.tsx +0 -56
  174. package/src/theme/components/timeline/index.ts +0 -8
  175. package/src/theme/layouts/SiteLayout.tsx +0 -132
  176. package/src/theme/pages/CollectionPage.tsx +0 -60
  177. package/src/theme/pages/SinglePage.tsx +0 -24
  178. package/src/theme/pages/index.ts +0 -13
@@ -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(1).max(5).optional().or(z.literal("").transform(()=>undefined));
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
+ slug: z.string().regex(/^[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().positive().optional().or(z.literal("").transform(()=>undefined)),
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
  }
@@ -4,11 +4,8 @@
4
4
  * Resolves theme-overridable components, falling back to defaults.
5
5
  */ const THEME_KEY_MAP = {
6
6
  note: "NoteCard",
7
- article: "ArticleCard",
8
7
  link: "LinkCard",
9
- quote: "QuoteCard",
10
- image: "ImageCard",
11
- page: "NoteCard"
8
+ quote: "QuoteCard"
12
9
  };
13
10
  /**
14
11
  * Generic component resolver.
@@ -29,21 +26,21 @@
29
26
  return themeComponents?.[key] ?? defaultComponent;
30
27
  }
31
28
  /**
32
- * Resolves the card component for a given post type.
29
+ * Resolves the card component for a given post format.
33
30
  *
34
31
  * Checks theme overrides first, then falls back to the provided default card component.
35
32
  *
36
- * @param type - The post type to resolve a card for
37
- * @param defaults - Map of post type to default card component
33
+ * @param format - The post format to resolve a card for
34
+ * @param defaults - Map of format to default card component
38
35
  * @param themeComponents - Optional theme component overrides
39
36
  * @returns The resolved card component
40
37
  *
41
38
  * @example
42
39
  * ```ts
43
- * const Card = resolveCardComponent("article", DEFAULT_CARD_MAP, c.var.config.theme?.components);
40
+ * const Card = resolveCardComponent("note", DEFAULT_CARD_MAP, c.var.config.theme?.components);
44
41
  * ```
45
- */ export function resolveCardComponent(type, defaults, themeComponents) {
46
- const key = THEME_KEY_MAP[type];
42
+ */ export function resolveCardComponent(format, defaults, themeComponents) {
43
+ const key = THEME_KEY_MAP[format];
47
44
  const override = themeComponents?.[key];
48
- return override ?? defaults[type];
45
+ return override ?? defaults[format];
49
46
  }
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,119 @@
1
+ /**
2
+ * Timeline Data Assembly
3
+ *
4
+ * Shared helper for assembling timeline items with media and thread previews.
5
+ * Used by both full-page rendering and load-more SSE responses.
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, batch-loads media, identifies threads, and returns
13
+ * render-ready `TimelineItemView[]` with pagination info.
14
+ *
15
+ * @param c - Hono context (provides services + env)
16
+ * @param options - Optional cursor for pagination
17
+ * @returns Assembled timeline items with pagination info
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const { items, hasMore, nextCursor } = await assembleTimeline(c);
22
+ * const { items, hasMore, nextCursor } = await assembleTimeline(c, { cursor: 42 });
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
+ // Fetch one extra to determine if there are more
27
+ const posts = await c.var.services.posts.list({
28
+ status: "published",
29
+ excludeReplies: true,
30
+ limit: pageSize + 1,
31
+ cursor: options?.cursor
32
+ });
33
+ const hasMore = posts.length > pageSize;
34
+ const displayPosts = hasMore ? posts.slice(0, pageSize) : posts;
35
+ if (displayPosts.length === 0) {
36
+ return {
37
+ items: [],
38
+ hasMore: false
39
+ };
40
+ }
41
+ // Batch load media attachments
42
+ const postIds = displayPosts.map((p)=>p.id);
43
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
44
+ const mediaCtx = createMediaContext(c);
45
+ const mediaMap = buildMediaMap(rawMediaMap, mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl);
46
+ // Get reply counts to identify thread roots
47
+ const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
48
+ const threadRootIds = postIds.filter((id)=>(replyCounts.get(id) ?? 0) > 0);
49
+ // Batch load thread previews
50
+ const threadPreviews = await c.var.services.posts.getThreadPreviews(threadRootIds, 3);
51
+ // Batch load media for preview replies
52
+ const previewReplyIds = [];
53
+ for (const replies of threadPreviews.values()){
54
+ for (const reply of replies){
55
+ previewReplyIds.push(reply.id);
56
+ }
57
+ }
58
+ const previewMediaMap = previewReplyIds.length > 0 ? buildMediaMap(await c.var.services.media.getByPostIds(previewReplyIds), mediaCtx.r2PublicUrl, mediaCtx.imageTransformUrl, mediaCtx.s3PublicUrl) : new Map();
59
+ // Assemble timeline items with View Models
60
+ const items = displayPosts.map((post)=>{
61
+ const postView = toPostView({
62
+ ...post,
63
+ mediaAttachments: mediaMap.get(post.id) ?? []
64
+ }, mediaCtx);
65
+ const replyCount = replyCounts.get(post.id) ?? 0;
66
+ const previewReplies = threadPreviews.get(post.id);
67
+ if (replyCount > 0 && previewReplies) {
68
+ return {
69
+ post: postView,
70
+ threadPreview: {
71
+ replies: toPostViews(previewReplies.map((r)=>({
72
+ ...r,
73
+ mediaAttachments: previewMediaMap.get(r.id) ?? []
74
+ })), mediaCtx),
75
+ totalReplyCount: replyCount
76
+ }
77
+ };
78
+ }
79
+ return {
80
+ post: postView
81
+ };
82
+ });
83
+ // Determine next cursor
84
+ const lastPost = displayPosts[displayPosts.length - 1];
85
+ const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
86
+ return {
87
+ items,
88
+ hasMore,
89
+ nextCursor
90
+ };
91
+ }
92
+ /**
93
+ * Groups timeline items by their publication date (YYYY-MM-DD).
94
+ *
95
+ * @param items - Timeline items to group
96
+ * @returns Array of date groups, each containing items published on the same day
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * const groups = groupByDate(items);
101
+ * // [{ dateKey: "2024-02-01", label: "Feb 1, 2024", items: [...] }, ...]
102
+ * ```
103
+ */ export function groupByDate(items) {
104
+ const groups = [];
105
+ let current = null;
106
+ for (const item of items){
107
+ const dateKey = item.post.publishedAt.slice(0, 10);
108
+ if (!current || current.dateKey !== dateKey) {
109
+ current = {
110
+ dateKey,
111
+ label: item.post.publishedAtFormatted,
112
+ items: []
113
+ };
114
+ groups.push(current);
115
+ }
116
+ current.items.push(item);
117
+ }
118
+ return groups;
119
+ }
package/dist/lib/view.js CHANGED
@@ -1,22 +1,17 @@
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
  */ import { encode } from "./sqid.js";
7
- import { toISOString, formatDate } from "./time.js";
7
+ import { toISOString, formatDate, formatTime, formatRelativeTime } from "./time.js";
8
8
  import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
9
+ import { getHtmlExcerpt } from "./excerpt.js";
9
10
  /**
10
11
  * Creates a MediaContext from Hono context environment variables.
11
12
  *
12
13
  * @param c - Hono context
13
14
  * @returns MediaContext with env values
14
- *
15
- * @example
16
- * ```ts
17
- * const mediaCtx = createMediaContext(c);
18
- * const postView = toPostView(post, mediaCtx);
19
- * ```
20
15
  */ export function createMediaContext(c) {
21
16
  return {
22
17
  r2PublicUrl: c.env.R2_PUBLIC_URL,
@@ -60,20 +55,22 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
60
55
  * Converts a PostWithMedia to a render-ready PostView.
61
56
  *
62
57
  * @param post - Post with media attachments from database
63
- * @param ctx - Media context with URL configuration
58
+ * @param _ctx - Media context with URL configuration
64
59
  * @returns Render-ready PostView with pre-computed fields
65
- *
66
- * @example
67
- * ```ts
68
- * const mediaCtx = createMediaContext(c);
69
- * const postView = toPostView({ ...post, mediaAttachments: [...] }, mediaCtx);
70
- * ```
71
- */ export function toPostView(post, ctx) {
72
- const permalink = `/p/${encode(post.id)}`;
73
- // Pre-compute excerpt from raw content
60
+ */ export function toPostView(post, _ctx) {
61
+ const permalink = post.slug ? `/${post.slug}` : `/p/${encode(post.id)}`;
62
+ // Pre-compute excerpt from raw body
74
63
  let excerpt;
75
- if (post.content) {
76
- excerpt = post.content.length > 160 ? post.content.slice(0, 160) + "..." : post.content;
64
+ if (post.body) {
65
+ excerpt = post.body.length > 160 ? post.body.slice(0, 160) + "..." : post.body;
66
+ }
67
+ // Pre-compute HTML summary for article-style posts (with title)
68
+ let summaryHtml;
69
+ let summaryHasMore;
70
+ if (post.title && post.bodyHtml) {
71
+ const result = getHtmlExcerpt(post.bodyHtml);
72
+ summaryHtml = result.excerpt;
73
+ summaryHasMore = result.hasMore;
77
74
  }
78
75
  // Convert media attachments
79
76
  const media = post.mediaAttachments.map((m)=>({
@@ -88,39 +85,38 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
88
85
  return {
89
86
  id: post.id,
90
87
  permalink,
88
+ slug: post.slug ?? undefined,
91
89
  title: post.title ?? undefined,
92
- contentHtml: post.contentHtml ?? undefined,
90
+ bodyHtml: post.bodyHtml ?? undefined,
93
91
  excerpt,
94
- type: post.type,
95
- visibility: post.visibility,
96
- path: post.path ?? undefined,
92
+ summaryHtml,
93
+ summaryHasMore,
94
+ url: post.url ?? undefined,
95
+ quoteText: post.quoteText ?? undefined,
96
+ format: post.format,
97
+ status: post.status,
98
+ featured: post.featured === 1,
99
+ pinned: post.pinned === 1,
100
+ rating: post.rating ?? undefined,
101
+ collectionId: post.collectionId ?? undefined,
97
102
  publishedAt: toISOString(post.publishedAt),
98
103
  publishedAtFormatted: formatDate(post.publishedAt),
104
+ publishedAtTime: formatTime(post.publishedAt),
105
+ publishedAtRelative: formatRelativeTime(post.publishedAt),
99
106
  updatedAt: toISOString(post.updatedAt),
100
- sourceUrl: post.sourceUrl ?? undefined,
101
- sourceName: post.sourceName ?? undefined,
102
- sourceDomain: post.sourceDomain ?? undefined,
103
107
  media,
104
108
  replyToId: post.replyToId ?? undefined,
105
109
  threadRootId: post.threadId ?? undefined,
106
- content: post.content ?? undefined
110
+ body: post.body ?? undefined
107
111
  };
108
112
  }
109
113
  /**
110
114
  * Batch converts PostWithMedia[] to PostView[].
111
- *
112
- * @param posts - Array of posts with media
113
- * @param ctx - Media context
114
- * @returns Array of PostView
115
115
  */ export function toPostViews(posts, ctx) {
116
116
  return posts.map((p)=>toPostView(p, ctx));
117
117
  }
118
118
  /**
119
119
  * Converts a bare Post (no media) to a PostView with empty media array.
120
- *
121
- * @param post - Post without media
122
- * @param ctx - Media context (unused but kept for consistency)
123
- * @returns PostView with empty media
124
120
  */ export function toPostViewFromPost(post, ctx) {
125
121
  return toPostView({
126
122
  ...post,
@@ -129,58 +125,60 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
129
125
  }
130
126
  /**
131
127
  * Batch converts Post[] (no media) to PostView[].
132
- *
133
- * @param posts - Array of posts without media
134
- * @param ctx - Media context
135
- * @returns Array of PostView
136
128
  */ export function toPostViewsFromPosts(posts, ctx) {
137
129
  return posts.map((p)=>toPostViewFromPost(p, ctx));
138
130
  }
139
131
  // =============================================================================
132
+ // Page Conversions
133
+ // =============================================================================
134
+ /**
135
+ * Converts a Page to a render-ready PageView.
136
+ */ export function toPageView(page) {
137
+ return {
138
+ id: page.id,
139
+ slug: page.slug,
140
+ title: page.title ?? undefined,
141
+ bodyHtml: page.bodyHtml ?? undefined,
142
+ status: page.status,
143
+ createdAt: toISOString(page.createdAt),
144
+ updatedAt: toISOString(page.updatedAt)
145
+ };
146
+ }
147
+ // =============================================================================
140
148
  // Navigation Conversions
141
149
  // =============================================================================
142
150
  /**
143
- * Converts a NavigationLink to a NavLinkView with pre-computed state.
144
- *
145
- * @param link - Raw navigation link from database
146
- * @param currentPath - Current page path for active state computation
147
- * @returns NavLinkView with isActive and isExternal pre-computed
148
- */ export function toNavLinkView(link, currentPath) {
149
- const isExternal = link.url.startsWith("http://") || link.url.startsWith("https://");
151
+ * Converts a NavItem to a NavItemView with pre-computed state.
152
+ */ export function toNavItemView(item, currentPath) {
153
+ const isExternal = item.url.startsWith("http://") || item.url.startsWith("https://");
150
154
  let isActive = false;
151
155
  if (!isExternal) {
152
- if (link.url === "/") {
156
+ if (item.url === "/") {
153
157
  isActive = currentPath === "/";
154
158
  } else {
155
- isActive = currentPath === link.url || currentPath.startsWith(link.url + "/");
159
+ isActive = currentPath === item.url || currentPath.startsWith(item.url + "/");
156
160
  }
157
161
  }
158
162
  return {
159
- id: link.id,
160
- label: link.label,
161
- url: link.url,
163
+ id: item.id,
164
+ type: item.type,
165
+ label: item.label,
166
+ url: item.url,
167
+ pageId: item.pageId ?? undefined,
162
168
  isActive,
163
169
  isExternal
164
170
  };
165
171
  }
166
172
  /**
167
- * Batch converts NavigationLink[] to NavLinkView[].
168
- *
169
- * @param links - Raw navigation links
170
- * @param currentPath - Current page path
171
- * @returns Array of NavLinkView
172
- */ export function toNavLinkViews(links, currentPath) {
173
- return links.map((l)=>toNavLinkView(l, currentPath));
173
+ * Batch converts NavItem[] to NavItemView[].
174
+ */ export function toNavItemViews(items, currentPath) {
175
+ return items.map((item)=>toNavItemView(item, currentPath));
174
176
  }
175
177
  // =============================================================================
176
178
  // Search Result Conversions
177
179
  // =============================================================================
178
180
  /**
179
181
  * Converts a SearchResult to a SearchResultView with PostView.
180
- *
181
- * @param result - Raw search result
182
- * @param ctx - Media context
183
- * @returns SearchResultView with PostView
184
182
  */ export function toSearchResultView(result, ctx) {
185
183
  return {
186
184
  post: toPostViewFromPost(result.post, ctx),
@@ -190,10 +188,6 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
190
188
  }
191
189
  /**
192
190
  * Batch converts SearchResult[] to SearchResultView[].
193
- *
194
- * @param results - Raw search results
195
- * @param ctx - Media context
196
- * @returns Array of SearchResultView
197
191
  */ export function toSearchResultViews(results, ctx) {
198
192
  return results.map((r)=>toSearchResultView(r, ctx));
199
193
  }
@@ -202,16 +196,11 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
202
196
  // =============================================================================
203
197
  /**
204
198
  * Converts a grouped post map to typed ArchiveGroup[].
205
- *
206
- * @param grouped - Map of "YYYY-MM" keys to Post arrays
207
- * @param ctx - Media context
208
- * @returns Array of ArchiveGroup with pre-formatted labels
209
199
  */ export function toArchiveGroups(grouped, ctx) {
210
200
  const groups = [];
211
201
  for (const [yearMonth, posts] of grouped){
212
202
  const [year, month] = yearMonth.split("-");
213
203
  if (!year || !month) continue;
214
- // Format label like "February 2024"
215
204
  const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1);
216
205
  const label = date.toLocaleDateString("en-US", {
217
206
  year: "numeric",