@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
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
toPostView,
|
|
8
8
|
toPostViews,
|
|
9
9
|
toMediaView,
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
toNavItemView,
|
|
11
|
+
toNavItemViews,
|
|
12
12
|
toSearchResultView,
|
|
13
13
|
toArchiveGroups,
|
|
14
14
|
} from "../view.js";
|
|
@@ -16,7 +16,7 @@ import type { MediaContext } from "../view.js";
|
|
|
16
16
|
import type {
|
|
17
17
|
PostWithMedia,
|
|
18
18
|
Media,
|
|
19
|
-
|
|
19
|
+
NavItem,
|
|
20
20
|
SearchResult,
|
|
21
21
|
Post,
|
|
22
22
|
} from "../../types.js";
|
|
@@ -30,15 +30,18 @@ const CTX_WITH_URLS: MediaContext = {
|
|
|
30
30
|
function makePost(overrides: Partial<Post> = {}): Post {
|
|
31
31
|
return {
|
|
32
32
|
id: 1,
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
format: "note",
|
|
34
|
+
status: "published",
|
|
35
|
+
featured: 0,
|
|
36
|
+
pinned: 0,
|
|
37
|
+
slug: null,
|
|
35
38
|
title: null,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
url: null,
|
|
40
|
+
body: "Hello world",
|
|
41
|
+
bodyHtml: "<p>Hello world</p>",
|
|
42
|
+
quoteText: null,
|
|
43
|
+
rating: null,
|
|
44
|
+
collectionId: null,
|
|
42
45
|
replyToId: null,
|
|
43
46
|
threadId: null,
|
|
44
47
|
deletedAt: null,
|
|
@@ -78,11 +81,13 @@ function makeMedia(overrides: Partial<Media> = {}): Media {
|
|
|
78
81
|
};
|
|
79
82
|
}
|
|
80
83
|
|
|
81
|
-
function
|
|
84
|
+
function makeNavItem(overrides: Partial<NavItem> = {}): NavItem {
|
|
82
85
|
return {
|
|
83
86
|
id: 1,
|
|
87
|
+
type: "link",
|
|
84
88
|
label: "Home",
|
|
85
89
|
url: "/",
|
|
90
|
+
pageId: null,
|
|
86
91
|
position: 0,
|
|
87
92
|
createdAt: 1706745600,
|
|
88
93
|
updatedAt: 1706745600,
|
|
@@ -95,13 +100,19 @@ function makeNavLink(overrides: Partial<NavigationLink> = {}): NavigationLink {
|
|
|
95
100
|
// =============================================================================
|
|
96
101
|
|
|
97
102
|
describe("toPostView", () => {
|
|
98
|
-
it("generates permalink from post id", () => {
|
|
99
|
-
const post = makePostWithMedia({ id: 123 });
|
|
103
|
+
it("generates permalink from post id when no slug", () => {
|
|
104
|
+
const post = makePostWithMedia({ id: 123, slug: null });
|
|
100
105
|
const view = toPostView(post, EMPTY_CTX);
|
|
101
106
|
expect(view.permalink).toMatch(/^\/p\/.+$/);
|
|
102
107
|
expect(view.permalink.length).toBeGreaterThan(3);
|
|
103
108
|
});
|
|
104
109
|
|
|
110
|
+
it("generates permalink from slug when slug is set", () => {
|
|
111
|
+
const post = makePostWithMedia({ id: 123, slug: "my-post" });
|
|
112
|
+
const view = toPostView(post, EMPTY_CTX);
|
|
113
|
+
expect(view.permalink).toBe("/my-post");
|
|
114
|
+
});
|
|
115
|
+
|
|
105
116
|
it("formats dates correctly", () => {
|
|
106
117
|
const post = makePostWithMedia({ publishedAt: 1706745600 });
|
|
107
118
|
const view = toPostView(post, EMPTY_CTX);
|
|
@@ -109,52 +120,156 @@ describe("toPostView", () => {
|
|
|
109
120
|
expect(view.publishedAtFormatted).toBe("Feb 1, 2024");
|
|
110
121
|
});
|
|
111
122
|
|
|
112
|
-
it("generates excerpt from
|
|
113
|
-
const
|
|
114
|
-
const
|
|
123
|
+
it("generates excerpt from body", () => {
|
|
124
|
+
const shortBody = "Short text";
|
|
125
|
+
const longBody = "A".repeat(200);
|
|
115
126
|
|
|
116
127
|
const shortView = toPostView(
|
|
117
|
-
makePostWithMedia({
|
|
128
|
+
makePostWithMedia({ body: shortBody }),
|
|
118
129
|
EMPTY_CTX,
|
|
119
130
|
);
|
|
120
131
|
expect(shortView.excerpt).toBe("Short text");
|
|
121
132
|
|
|
122
133
|
const longView = toPostView(
|
|
123
|
-
makePostWithMedia({
|
|
134
|
+
makePostWithMedia({ body: longBody }),
|
|
124
135
|
EMPTY_CTX,
|
|
125
136
|
);
|
|
126
137
|
expect(longView.excerpt).toBe("A".repeat(160) + "...");
|
|
127
138
|
});
|
|
128
139
|
|
|
129
|
-
it("
|
|
130
|
-
const view = toPostView(
|
|
140
|
+
it("computes summaryHtml for posts with title and bodyHtml", () => {
|
|
141
|
+
const view = toPostView(
|
|
142
|
+
makePostWithMedia({
|
|
143
|
+
title: "My Article",
|
|
144
|
+
body: "Short article body",
|
|
145
|
+
bodyHtml: "<p>Short article body</p>",
|
|
146
|
+
}),
|
|
147
|
+
EMPTY_CTX,
|
|
148
|
+
);
|
|
149
|
+
expect(view.summaryHtml).toBe("<p>Short article body</p>");
|
|
150
|
+
expect(view.summaryHasMore).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("truncates summaryHtml for long articles", () => {
|
|
154
|
+
const p1 = `<p>${"A".repeat(300)}</p>`;
|
|
155
|
+
const p2 = `<p>${"B".repeat(300)}</p>`;
|
|
156
|
+
const view = toPostView(
|
|
157
|
+
makePostWithMedia({
|
|
158
|
+
title: "Long Article",
|
|
159
|
+
body: "A".repeat(300) + "B".repeat(300),
|
|
160
|
+
bodyHtml: p1 + p2,
|
|
161
|
+
}),
|
|
162
|
+
EMPTY_CTX,
|
|
163
|
+
);
|
|
164
|
+
expect(view.summaryHtml).toBe(p1);
|
|
165
|
+
expect(view.summaryHasMore).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("does not compute summaryHtml for posts without title", () => {
|
|
169
|
+
const view = toPostView(
|
|
170
|
+
makePostWithMedia({
|
|
171
|
+
title: null,
|
|
172
|
+
bodyHtml: "<p>Just a note</p>",
|
|
173
|
+
}),
|
|
174
|
+
EMPTY_CTX,
|
|
175
|
+
);
|
|
176
|
+
expect(view.summaryHtml).toBeUndefined();
|
|
177
|
+
expect(view.summaryHasMore).toBeUndefined();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not compute summaryHtml for posts without bodyHtml", () => {
|
|
181
|
+
const view = toPostView(
|
|
182
|
+
makePostWithMedia({
|
|
183
|
+
title: "Title Only",
|
|
184
|
+
bodyHtml: null,
|
|
185
|
+
}),
|
|
186
|
+
EMPTY_CTX,
|
|
187
|
+
);
|
|
188
|
+
expect(view.summaryHtml).toBeUndefined();
|
|
189
|
+
expect(view.summaryHasMore).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("handles null body gracefully", () => {
|
|
193
|
+
const view = toPostView(
|
|
194
|
+
makePostWithMedia({ body: null, bodyHtml: null }),
|
|
195
|
+
EMPTY_CTX,
|
|
196
|
+
);
|
|
131
197
|
expect(view.excerpt).toBeUndefined();
|
|
132
|
-
expect(view.
|
|
198
|
+
expect(view.bodyHtml).toBeUndefined();
|
|
199
|
+
expect(view.body).toBeUndefined();
|
|
133
200
|
});
|
|
134
201
|
|
|
135
202
|
it("converts null fields to undefined", () => {
|
|
136
203
|
const view = toPostView(makePostWithMedia(), EMPTY_CTX);
|
|
137
204
|
expect(view.title).toBeUndefined();
|
|
138
|
-
expect(view.
|
|
139
|
-
expect(view.
|
|
140
|
-
expect(view.
|
|
141
|
-
expect(view.
|
|
205
|
+
expect(view.slug).toBeUndefined();
|
|
206
|
+
expect(view.url).toBeUndefined();
|
|
207
|
+
expect(view.quoteText).toBeUndefined();
|
|
208
|
+
expect(view.rating).toBeUndefined();
|
|
209
|
+
expect(view.collectionId).toBeUndefined();
|
|
142
210
|
expect(view.replyToId).toBeUndefined();
|
|
143
211
|
expect(view.threadRootId).toBeUndefined();
|
|
144
212
|
});
|
|
145
213
|
|
|
146
|
-
it("preserves non-null
|
|
214
|
+
it("preserves non-null url field", () => {
|
|
215
|
+
const view = toPostView(
|
|
216
|
+
makePostWithMedia({
|
|
217
|
+
url: "https://example.com",
|
|
218
|
+
}),
|
|
219
|
+
EMPTY_CTX,
|
|
220
|
+
);
|
|
221
|
+
expect(view.url).toBe("https://example.com");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("preserves non-null quoteText field", () => {
|
|
225
|
+
const view = toPostView(
|
|
226
|
+
makePostWithMedia({
|
|
227
|
+
format: "quote",
|
|
228
|
+
quoteText: "Something wise",
|
|
229
|
+
}),
|
|
230
|
+
EMPTY_CTX,
|
|
231
|
+
);
|
|
232
|
+
expect(view.quoteText).toBe("Something wise");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("maps format, status, featured, and pinned correctly", () => {
|
|
236
|
+
const view = toPostView(
|
|
237
|
+
makePostWithMedia({
|
|
238
|
+
format: "link",
|
|
239
|
+
status: "draft",
|
|
240
|
+
featured: 1,
|
|
241
|
+
pinned: 1,
|
|
242
|
+
}),
|
|
243
|
+
EMPTY_CTX,
|
|
244
|
+
);
|
|
245
|
+
expect(view.format).toBe("link");
|
|
246
|
+
expect(view.status).toBe("draft");
|
|
247
|
+
expect(view.featured).toBe(true);
|
|
248
|
+
expect(view.pinned).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("converts featured=0 and pinned=0 to false", () => {
|
|
147
252
|
const view = toPostView(
|
|
148
253
|
makePostWithMedia({
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
sourceDomain: "example.com",
|
|
254
|
+
featured: 0,
|
|
255
|
+
pinned: 0,
|
|
152
256
|
}),
|
|
153
257
|
EMPTY_CTX,
|
|
154
258
|
);
|
|
155
|
-
expect(view.
|
|
156
|
-
expect(view.
|
|
157
|
-
|
|
259
|
+
expect(view.featured).toBe(false);
|
|
260
|
+
expect(view.pinned).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("preserves rating and collectionId when set", () => {
|
|
264
|
+
const view = toPostView(
|
|
265
|
+
makePostWithMedia({
|
|
266
|
+
rating: 5,
|
|
267
|
+
collectionId: 42,
|
|
268
|
+
}),
|
|
269
|
+
EMPTY_CTX,
|
|
270
|
+
);
|
|
271
|
+
expect(view.rating).toBe(5);
|
|
272
|
+
expect(view.collectionId).toBe(42);
|
|
158
273
|
});
|
|
159
274
|
|
|
160
275
|
it("converts media attachments to MediaView", () => {
|
|
@@ -249,40 +364,40 @@ describe("toMediaView", () => {
|
|
|
249
364
|
});
|
|
250
365
|
|
|
251
366
|
// =============================================================================
|
|
252
|
-
//
|
|
367
|
+
// toNavItemView
|
|
253
368
|
// =============================================================================
|
|
254
369
|
|
|
255
|
-
describe("
|
|
370
|
+
describe("toNavItemView", () => {
|
|
256
371
|
it("marks home link active on exact / match", () => {
|
|
257
|
-
const view =
|
|
372
|
+
const view = toNavItemView(makeNavItem({ url: "/" }), "/");
|
|
258
373
|
expect(view.isActive).toBe(true);
|
|
259
374
|
expect(view.isExternal).toBe(false);
|
|
260
375
|
});
|
|
261
376
|
|
|
262
377
|
it("marks home link inactive on other paths", () => {
|
|
263
|
-
const view =
|
|
378
|
+
const view = toNavItemView(makeNavItem({ url: "/" }), "/archive");
|
|
264
379
|
expect(view.isActive).toBe(false);
|
|
265
380
|
});
|
|
266
381
|
|
|
267
382
|
it("matches prefix for non-root links", () => {
|
|
268
|
-
const view =
|
|
383
|
+
const view = toNavItemView(makeNavItem({ url: "/archive" }), "/archive");
|
|
269
384
|
expect(view.isActive).toBe(true);
|
|
270
385
|
|
|
271
|
-
const viewSub =
|
|
272
|
-
|
|
386
|
+
const viewSub = toNavItemView(
|
|
387
|
+
makeNavItem({ url: "/archive" }),
|
|
273
388
|
"/archive/2024",
|
|
274
389
|
);
|
|
275
390
|
expect(viewSub.isActive).toBe(true);
|
|
276
391
|
});
|
|
277
392
|
|
|
278
393
|
it("does not false-match similar prefixes", () => {
|
|
279
|
-
const view =
|
|
394
|
+
const view = toNavItemView(makeNavItem({ url: "/arch" }), "/archive");
|
|
280
395
|
expect(view.isActive).toBe(false);
|
|
281
396
|
});
|
|
282
397
|
|
|
283
398
|
it("marks external links as external and never active", () => {
|
|
284
|
-
const view =
|
|
285
|
-
|
|
399
|
+
const view = toNavItemView(
|
|
400
|
+
makeNavItem({ url: "https://example.com" }),
|
|
286
401
|
"/",
|
|
287
402
|
);
|
|
288
403
|
expect(view.isExternal).toBe(true);
|
|
@@ -290,20 +405,31 @@ describe("toNavLinkView", () => {
|
|
|
290
405
|
});
|
|
291
406
|
|
|
292
407
|
it("handles http:// links", () => {
|
|
293
|
-
const view =
|
|
408
|
+
const view = toNavItemView(makeNavItem({ url: "http://example.com" }), "/");
|
|
294
409
|
expect(view.isExternal).toBe(true);
|
|
295
410
|
expect(view.isActive).toBe(false);
|
|
296
411
|
});
|
|
412
|
+
|
|
413
|
+
it("includes type and pageId in view", () => {
|
|
414
|
+
const view = toNavItemView(makeNavItem({ type: "page", pageId: 5 }), "/");
|
|
415
|
+
expect(view.type).toBe("page");
|
|
416
|
+
expect(view.pageId).toBe(5);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("converts null pageId to undefined", () => {
|
|
420
|
+
const view = toNavItemView(makeNavItem({ pageId: null }), "/");
|
|
421
|
+
expect(view.pageId).toBeUndefined();
|
|
422
|
+
});
|
|
297
423
|
});
|
|
298
424
|
|
|
299
|
-
describe("
|
|
300
|
-
it("converts multiple
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
425
|
+
describe("toNavItemViews", () => {
|
|
426
|
+
it("converts multiple items", () => {
|
|
427
|
+
const items = [
|
|
428
|
+
makeNavItem({ id: 1, url: "/" }),
|
|
429
|
+
makeNavItem({ id: 2, url: "/archive" }),
|
|
430
|
+
makeNavItem({ id: 3, url: "https://github.com" }),
|
|
305
431
|
];
|
|
306
|
-
const views =
|
|
432
|
+
const views = toNavItemViews(items, "/archive");
|
|
307
433
|
expect(views).toHaveLength(3);
|
|
308
434
|
expect(views[0]).toHaveProperty("isActive", false);
|
|
309
435
|
expect(views[1]).toHaveProperty("isActive", true);
|
|
@@ -329,6 +455,28 @@ describe("toSearchResultView", () => {
|
|
|
329
455
|
expect(view.rank).toBe(1.5);
|
|
330
456
|
expect(view.snippet).toBe("...matching <b>text</b>...");
|
|
331
457
|
});
|
|
458
|
+
|
|
459
|
+
it("uses new post fields in search result view", () => {
|
|
460
|
+
const result: SearchResult = {
|
|
461
|
+
post: makePost({
|
|
462
|
+
id: 10,
|
|
463
|
+
format: "link",
|
|
464
|
+
status: "published",
|
|
465
|
+
featured: 1,
|
|
466
|
+
pinned: 0,
|
|
467
|
+
url: "https://example.com",
|
|
468
|
+
slug: "my-link",
|
|
469
|
+
}),
|
|
470
|
+
rank: 0.8,
|
|
471
|
+
};
|
|
472
|
+
const view = toSearchResultView(result, EMPTY_CTX);
|
|
473
|
+
expect(view.post.format).toBe("link");
|
|
474
|
+
expect(view.post.status).toBe("published");
|
|
475
|
+
expect(view.post.featured).toBe(true);
|
|
476
|
+
expect(view.post.pinned).toBe(false);
|
|
477
|
+
expect(view.post.url).toBe("https://example.com");
|
|
478
|
+
expect(view.post.permalink).toBe("/my-link");
|
|
479
|
+
});
|
|
332
480
|
});
|
|
333
481
|
|
|
334
482
|
// =============================================================================
|
package/src/lib/constants.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export const RESERVED_PATHS = [
|
|
9
9
|
"featured",
|
|
10
|
+
"collections",
|
|
10
11
|
"signin",
|
|
11
12
|
"signout",
|
|
12
13
|
"setup",
|
|
@@ -15,10 +16,6 @@ export const RESERVED_PATHS = [
|
|
|
15
16
|
"feed",
|
|
16
17
|
"search",
|
|
17
18
|
"archive",
|
|
18
|
-
"notes",
|
|
19
|
-
"articles",
|
|
20
|
-
"links",
|
|
21
|
-
"quotes",
|
|
22
19
|
"media",
|
|
23
20
|
"pages",
|
|
24
21
|
"reset",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Excerpt Utilities
|
|
3
|
+
*
|
|
4
|
+
* Generates paragraph-aware excerpts from HTML content for article
|
|
5
|
+
* previews in timelines. Breaks only at paragraph boundaries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Strips HTML tags from a string, returning plain text.
|
|
10
|
+
*
|
|
11
|
+
* @param html - HTML string to strip
|
|
12
|
+
* @returns Plain text without HTML tags
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* stripHtml("<p>Hello <strong>world</strong></p>") // "Hello world"
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function stripHtml(html: string): string {
|
|
20
|
+
return html.replace(/<[^>]*>/g, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Result of extracting an HTML excerpt.
|
|
25
|
+
*/
|
|
26
|
+
export interface HtmlExcerpt {
|
|
27
|
+
/** HTML excerpt (complete paragraphs only) */
|
|
28
|
+
excerpt: string;
|
|
29
|
+
/** Whether the original content has more text beyond the excerpt */
|
|
30
|
+
hasMore: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extracts a paragraph-aware HTML excerpt from body HTML.
|
|
35
|
+
*
|
|
36
|
+
* Uses a greedy algorithm: accumulates paragraphs until the total
|
|
37
|
+
* plain-text length exceeds 500 characters, then stops. At least
|
|
38
|
+
* one paragraph is always included.
|
|
39
|
+
*
|
|
40
|
+
* If the content contains a `<!--more-->` marker, the content before
|
|
41
|
+
* the marker is used as the excerpt instead.
|
|
42
|
+
*
|
|
43
|
+
* @param bodyHtml - Full HTML body content
|
|
44
|
+
* @returns Excerpt HTML and whether there is more content
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```typescript
|
|
48
|
+
* // Short content — returned as-is with hasMore = false
|
|
49
|
+
* getHtmlExcerpt("<p>Short post.</p>")
|
|
50
|
+
* // { excerpt: "<p>Short post.</p>", hasMore: false }
|
|
51
|
+
*
|
|
52
|
+
* // Long content — truncated at paragraph boundary
|
|
53
|
+
* getHtmlExcerpt("<p>" + "A".repeat(300) + "</p><p>" + "B".repeat(300) + "</p>")
|
|
54
|
+
* // { excerpt: "<p>AAA...</p>", hasMore: true }
|
|
55
|
+
*
|
|
56
|
+
* // Manual break with <!--more-->
|
|
57
|
+
* getHtmlExcerpt("<p>Intro</p><!--more--><p>Rest</p>")
|
|
58
|
+
* // { excerpt: "<p>Intro</p>", hasMore: true }
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function getHtmlExcerpt(bodyHtml: string): HtmlExcerpt {
|
|
62
|
+
// Honor manual <!--more--> marker
|
|
63
|
+
if (bodyHtml.includes("<!--more-->")) {
|
|
64
|
+
const excerpt = bodyHtml.split("<!--more-->")[0]!;
|
|
65
|
+
return { excerpt, hasMore: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const paragraphs = bodyHtml.match(/<p>[\s\S]*?<\/p>/g) || [];
|
|
69
|
+
|
|
70
|
+
// No paragraphs found — return full content
|
|
71
|
+
if (paragraphs.length === 0) {
|
|
72
|
+
return { excerpt: bodyHtml, hasMore: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let excerpt = "";
|
|
76
|
+
let charCount = 0;
|
|
77
|
+
|
|
78
|
+
for (const p of paragraphs) {
|
|
79
|
+
const textLen = stripHtml(p).length;
|
|
80
|
+
if (charCount + textLen > 500 && excerpt) break;
|
|
81
|
+
excerpt += p;
|
|
82
|
+
charCount += textLen;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hasMore = excerpt.length < bodyHtml.length;
|
|
86
|
+
return { excerpt, hasMore };
|
|
87
|
+
}
|
package/src/lib/feed.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function defaultRssRenderer(data: FeedData): string {
|
|
|
51
51
|
<link>${link}</link>
|
|
52
52
|
<guid isPermaLink="true">${link}</guid>
|
|
53
53
|
<pubDate>${pubDate}</pubDate>
|
|
54
|
-
<description><![CDATA[${post.
|
|
54
|
+
<description><![CDATA[${post.bodyHtml || ""}]]></description>${enclosure}
|
|
55
55
|
</item>`;
|
|
56
56
|
})
|
|
57
57
|
.join("");
|
|
@@ -90,7 +90,7 @@ export function defaultAtomRenderer(data: FeedData): string {
|
|
|
90
90
|
<id>${link}</id>
|
|
91
91
|
<published>${post.publishedAt}</published>
|
|
92
92
|
<updated>${post.updatedAt}</updated>
|
|
93
|
-
<content type="html"><![CDATA[${post.
|
|
93
|
+
<content type="html"><![CDATA[${post.bodyHtml || ""}]]></content>
|
|
94
94
|
</entry>`;
|
|
95
95
|
})
|
|
96
96
|
.join("");
|
|
@@ -112,17 +112,17 @@ export function defaultAtomRenderer(data: FeedData): string {
|
|
|
112
112
|
/**
|
|
113
113
|
* Default Sitemap renderer.
|
|
114
114
|
*
|
|
115
|
-
* @param data - Sitemap data with PostView[]
|
|
115
|
+
* @param data - Sitemap data with PostView[] and PageView[]
|
|
116
116
|
* @returns Sitemap XML string
|
|
117
117
|
*/
|
|
118
118
|
export function defaultSitemapRenderer(data: SitemapData): string {
|
|
119
|
-
const { siteUrl, posts } = data;
|
|
119
|
+
const { siteUrl, posts, pages } = data;
|
|
120
120
|
|
|
121
|
-
const
|
|
121
|
+
const postUrls = posts
|
|
122
122
|
.map((post) => {
|
|
123
123
|
const loc = `${siteUrl}${post.permalink}`;
|
|
124
124
|
const lastmod = post.updatedAt.split("T")[0];
|
|
125
|
-
const priority = post.
|
|
125
|
+
const priority = post.featured ? "0.8" : "0.6";
|
|
126
126
|
|
|
127
127
|
return `
|
|
128
128
|
<url>
|
|
@@ -133,6 +133,20 @@ export function defaultSitemapRenderer(data: SitemapData): string {
|
|
|
133
133
|
})
|
|
134
134
|
.join("");
|
|
135
135
|
|
|
136
|
+
const pageUrls = pages
|
|
137
|
+
.map((page) => {
|
|
138
|
+
const loc = `${siteUrl}/${page.slug}`;
|
|
139
|
+
const lastmod = page.updatedAt.split("T")[0];
|
|
140
|
+
|
|
141
|
+
return `
|
|
142
|
+
<url>
|
|
143
|
+
<loc>${loc}</loc>
|
|
144
|
+
<lastmod>${lastmod}</lastmod>
|
|
145
|
+
<priority>0.7</priority>
|
|
146
|
+
</url>`;
|
|
147
|
+
})
|
|
148
|
+
.join("");
|
|
149
|
+
|
|
136
150
|
const homepageUrl = `
|
|
137
151
|
<url>
|
|
138
152
|
<loc>${siteUrl}/</loc>
|
|
@@ -143,6 +157,7 @@ export function defaultSitemapRenderer(data: SitemapData): string {
|
|
|
143
157
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
144
158
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
145
159
|
${homepageUrl}
|
|
146
|
-
${
|
|
160
|
+
${postUrls}
|
|
161
|
+
${pageUrls}
|
|
147
162
|
</urlset>`;
|
|
148
163
|
}
|
package/src/lib/navigation.ts
CHANGED
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import type { Context } from "hono";
|
|
8
8
|
import { getSiteName } from "./config.js";
|
|
9
|
-
import type {
|
|
10
|
-
import {
|
|
9
|
+
import type { NavItemView } from "../types.js";
|
|
10
|
+
import { toNavItemViews } from "./view.js";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Navigation data needed by SiteLayout
|
|
14
14
|
*/
|
|
15
15
|
export interface NavigationData {
|
|
16
|
-
links:
|
|
16
|
+
links: NavItemView[];
|
|
17
17
|
currentPath: string;
|
|
18
18
|
siteName: string;
|
|
19
19
|
}
|
|
@@ -21,8 +21,7 @@ export interface NavigationData {
|
|
|
21
21
|
/**
|
|
22
22
|
* Fetch navigation data for public pages.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
* NavLinkView[] with pre-computed isActive/isExternal state.
|
|
24
|
+
* Returns NavItemView[] with pre-computed isActive/isExternal state.
|
|
26
25
|
*
|
|
27
26
|
* @param c - Hono context
|
|
28
27
|
* @returns Navigation data for SiteLayout
|
|
@@ -38,9 +37,9 @@ export interface NavigationData {
|
|
|
38
37
|
* ```
|
|
39
38
|
*/
|
|
40
39
|
export async function getNavigationData(c: Context): Promise<NavigationData> {
|
|
41
|
-
const
|
|
40
|
+
const items = await c.var.services.navItems.list();
|
|
42
41
|
const currentPath = new URL(c.req.url).pathname;
|
|
43
42
|
const siteName = await getSiteName(c);
|
|
44
|
-
const links =
|
|
43
|
+
const links = toNavItemViews(items, currentPath);
|
|
45
44
|
return { links, currentPath, siteName };
|
|
46
45
|
}
|
package/src/lib/render.tsx
CHANGED
|
@@ -12,7 +12,7 @@ import type { Context } from "hono";
|
|
|
12
12
|
import type { Child } from "hono/jsx";
|
|
13
13
|
import type { ThemeComponents, SiteLayoutProps } from "../types.js";
|
|
14
14
|
import { BaseLayout } from "../theme/layouts/BaseLayout.js";
|
|
15
|
-
import {
|
|
15
|
+
import { ThreadsSiteLayout as DefaultSiteLayout } from "../themes/threads/ThreadsSiteLayout.js";
|
|
16
16
|
import type { NavigationData } from "./navigation.js";
|
|
17
17
|
|
|
18
18
|
export interface RenderPublicPageOptions {
|