@jant/core 0.3.23 → 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.
- package/dist/app.js +4 -5
- package/dist/db/schema.js +72 -47
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/index.js +3 -3
- package/dist/lib/constants.js +1 -4
- package/dist/lib/excerpt.js +76 -0
- package/dist/lib/feed.js +18 -7
- package/dist/lib/navigation.js +4 -5
- package/dist/lib/render.js +1 -1
- package/dist/lib/schemas.js +80 -38
- package/dist/lib/theme-components.js +8 -11
- package/dist/lib/time.js +56 -1
- package/dist/lib/timeline.js +119 -0
- package/dist/lib/view.js +61 -72
- package/dist/routes/api/posts.js +29 -35
- package/dist/routes/api/search.js +5 -6
- package/dist/routes/api/upload.js +13 -13
- package/dist/routes/dash/collections.js +22 -40
- package/dist/routes/dash/index.js +2 -2
- package/dist/routes/dash/navigation.js +25 -24
- package/dist/routes/dash/pages.js +42 -57
- package/dist/routes/dash/posts.js +27 -35
- package/dist/routes/feed/rss.js +2 -4
- package/dist/routes/feed/sitemap.js +10 -7
- package/dist/routes/pages/archive.js +12 -11
- package/dist/routes/pages/collection.js +11 -5
- package/dist/routes/pages/home.js +53 -61
- package/dist/routes/pages/page.js +60 -29
- package/dist/routes/pages/post.js +5 -12
- package/dist/routes/pages/search.js +3 -4
- package/dist/services/collection.js +52 -64
- package/dist/services/index.js +5 -3
- package/dist/services/navigation.js +29 -53
- package/dist/services/page.js +80 -0
- package/dist/services/post.js +68 -69
- package/dist/services/search.js +24 -18
- package/dist/theme/components/MediaGallery.js +19 -91
- package/dist/theme/components/PageForm.js +15 -15
- package/dist/theme/components/PostForm.js +136 -129
- package/dist/theme/components/PostList.js +13 -8
- package/dist/theme/components/ThreadView.js +3 -3
- package/dist/theme/components/TypeBadge.js +3 -14
- package/dist/theme/components/VisibilityBadge.js +33 -23
- package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
- package/dist/themes/threads/index.js +81 -0
- package/dist/themes/{minimal → threads}/pages/ArchivePage.js +32 -47
- package/dist/themes/threads/pages/CollectionPage.js +65 -0
- package/dist/themes/{minimal → threads}/pages/HomePage.js +3 -3
- package/dist/themes/{minimal → threads}/pages/PostPage.js +12 -9
- package/dist/themes/{minimal → threads}/pages/SearchPage.js +13 -14
- package/dist/themes/{minimal → threads}/pages/SinglePage.js +4 -4
- package/dist/themes/threads/timeline/LinkCard.js +68 -0
- package/dist/themes/threads/timeline/NoteCard.js +53 -0
- package/dist/themes/threads/timeline/QuoteCard.js +59 -0
- package/dist/themes/{minimal → threads}/timeline/ThreadPreview.js +17 -13
- package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
- package/dist/themes/{minimal → threads}/timeline/TimelineItem.js +8 -16
- package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
- package/dist/themes/threads/timeline/groupByDate.js +22 -0
- package/dist/themes/threads/timeline/timelineMore.js +107 -0
- package/dist/types.js +24 -40
- package/package.json +2 -1
- package/src/__tests__/helpers/app.ts +4 -0
- package/src/__tests__/helpers/db.ts +51 -74
- package/src/app.tsx +4 -6
- package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/schema.ts +63 -46
- package/src/i18n/locales/en.po +216 -164
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +216 -164
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +216 -164
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +28 -12
- package/src/lib/__tests__/excerpt.test.ts +125 -0
- package/src/lib/__tests__/schemas.test.ts +166 -105
- package/src/lib/__tests__/theme-components.test.ts +4 -25
- package/src/lib/__tests__/time.test.ts +62 -0
- package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
- package/src/lib/__tests__/view.test.ts +199 -51
- package/src/lib/constants.ts +1 -4
- package/src/lib/excerpt.ts +87 -0
- package/src/lib/feed.ts +22 -7
- package/src/lib/navigation.ts +6 -7
- package/src/lib/render.tsx +1 -1
- package/src/lib/schemas.ts +118 -52
- package/src/lib/theme-components.ts +10 -13
- package/src/lib/time.ts +64 -0
- package/src/lib/timeline.ts +170 -0
- package/src/lib/view.ts +80 -82
- package/src/preset.css +45 -0
- package/src/routes/api/__tests__/posts.test.ts +50 -108
- package/src/routes/api/__tests__/search.test.ts +2 -3
- package/src/routes/api/posts.ts +30 -30
- package/src/routes/api/search.ts +4 -4
- package/src/routes/api/upload.ts +16 -6
- package/src/routes/dash/collections.tsx +18 -40
- package/src/routes/dash/index.tsx +2 -2
- package/src/routes/dash/navigation.tsx +27 -26
- package/src/routes/dash/pages.tsx +45 -60
- package/src/routes/dash/posts.tsx +44 -52
- package/src/routes/feed/rss.ts +2 -1
- package/src/routes/feed/sitemap.ts +14 -4
- package/src/routes/pages/archive.tsx +14 -10
- package/src/routes/pages/collection.tsx +17 -6
- package/src/routes/pages/home.tsx +56 -81
- package/src/routes/pages/page.tsx +64 -27
- package/src/routes/pages/post.tsx +5 -14
- package/src/routes/pages/search.tsx +2 -2
- package/src/services/__tests__/collection.test.ts +257 -158
- package/src/services/__tests__/media.test.ts +18 -18
- package/src/services/__tests__/navigation.test.ts +161 -87
- package/src/services/__tests__/post-timeline.test.ts +92 -88
- package/src/services/__tests__/post.test.ts +342 -206
- package/src/services/__tests__/search.test.ts +19 -25
- package/src/services/collection.ts +71 -113
- package/src/services/index.ts +9 -8
- package/src/services/navigation.ts +38 -71
- package/src/services/page.ts +124 -0
- package/src/services/post.ts +93 -103
- package/src/services/search.ts +38 -27
- package/src/theme/components/MediaGallery.tsx +27 -96
- package/src/theme/components/PageForm.tsx +21 -21
- package/src/theme/components/PostForm.tsx +122 -118
- package/src/theme/components/PostList.tsx +58 -49
- package/src/theme/components/ThreadView.tsx +6 -3
- package/src/theme/components/TypeBadge.tsx +9 -17
- package/src/theme/components/VisibilityBadge.tsx +40 -23
- package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
- package/src/themes/{minimal → threads}/index.ts +30 -13
- package/src/themes/{minimal → threads}/pages/ArchivePage.tsx +53 -53
- package/src/themes/threads/pages/CollectionPage.tsx +61 -0
- package/src/themes/{minimal → threads}/pages/HomePage.tsx +3 -3
- package/src/themes/{minimal → threads}/pages/PostPage.tsx +12 -8
- package/src/themes/{minimal → threads}/pages/SearchPage.tsx +15 -13
- package/src/themes/{minimal → threads}/pages/SinglePage.tsx +4 -4
- package/src/themes/threads/style.css +336 -0
- package/src/themes/threads/timeline/LinkCard.tsx +67 -0
- package/src/themes/threads/timeline/NoteCard.tsx +58 -0
- package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
- package/src/themes/{minimal → threads}/timeline/ThreadPreview.tsx +15 -13
- package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
- package/src/themes/{minimal → threads}/timeline/TimelineItem.tsx +9 -17
- package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
- package/src/themes/threads/timeline/groupByDate.ts +30 -0
- package/src/themes/threads/timeline/timelineMore.tsx +130 -0
- package/src/types.ts +242 -98
- package/dist/routes/api/timeline.js +0 -120
- package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
- package/dist/themes/minimal/index.js +0 -65
- package/dist/themes/minimal/pages/CollectionPage.js +0 -65
- package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
- package/dist/themes/minimal/timeline/ImageCard.js +0 -67
- package/dist/themes/minimal/timeline/LinkCard.js +0 -47
- package/dist/themes/minimal/timeline/NoteCard.js +0 -34
- package/dist/themes/minimal/timeline/QuoteCard.js +0 -48
- package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
- package/src/routes/api/timeline.tsx +0 -159
- package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
- package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
- package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
- package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
- package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
- package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
- package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
- package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
package/dist/lib/schemas.js
CHANGED
|
@@ -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 {
|
|
10
|
+
import { FORMATS, STATUSES, SORT_ORDERS, NAV_ITEM_TYPES, MAX_MEDIA_ATTACHMENTS } from "../types.js";
|
|
11
11
|
/**
|
|
12
|
-
* Post
|
|
13
|
-
* Based on
|
|
14
|
-
*/ export const
|
|
12
|
+
* Post format enum schema
|
|
13
|
+
* Based on FORMATS from types.ts
|
|
14
|
+
*/ export const FormatSchema = z.enum(FORMATS);
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
* Based on
|
|
18
|
-
*/ export const
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
49
|
-
* //
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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
|
|
37
|
-
* @param defaults - Map of
|
|
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("
|
|
40
|
+
* const Card = resolveCardComponent("note", DEFAULT_CARD_MAP, c.var.config.theme?.components);
|
|
44
41
|
* ```
|
|
45
|
-
*/ export function resolveCardComponent(
|
|
46
|
-
const key = THEME_KEY_MAP[
|
|
42
|
+
*/ export function resolveCardComponent(format, defaults, themeComponents) {
|
|
43
|
+
const key = THEME_KEY_MAP[format];
|
|
47
44
|
const override = themeComponents?.[key];
|
|
48
|
-
return override ?? defaults[
|
|
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
|
-
*/
|
|
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
|
|
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
|
|
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
60
|
*/ export function toPostView(post, _ctx) {
|
|
72
|
-
const permalink = `/p/${encode(post.id)}`;
|
|
73
|
-
// Pre-compute excerpt from raw
|
|
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.
|
|
76
|
-
excerpt = post.
|
|
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
|
-
|
|
90
|
+
bodyHtml: post.bodyHtml ?? undefined,
|
|
93
91
|
excerpt,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
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 (
|
|
156
|
+
if (item.url === "/") {
|
|
153
157
|
isActive = currentPath === "/";
|
|
154
158
|
} else {
|
|
155
|
-
isActive = currentPath ===
|
|
159
|
+
isActive = currentPath === item.url || currentPath.startsWith(item.url + "/");
|
|
156
160
|
}
|
|
157
161
|
}
|
|
158
162
|
return {
|
|
159
|
-
id:
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
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",
|