@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
@@ -53,7 +53,7 @@ function NewPostContent({ collections }: { collections: Collection[] }) {
53
53
  // List posts
54
54
  postsRoutes.get("/", async (c) => {
55
55
  const posts = await c.var.services.posts.list({
56
- visibility: ["featured", "quiet", "unlisted", "draft"],
56
+ excludeReplies: true,
57
57
  });
58
58
  const siteName = await getSiteName(c);
59
59
 
@@ -89,25 +89,32 @@ postsRoutes.get("/new", async (c) => {
89
89
  // Create post
90
90
  postsRoutes.post("/", async (c) => {
91
91
  const body = await c.req.json<{
92
- type: string;
92
+ format: string;
93
93
  title?: string;
94
- content: string;
95
- visibility: string;
96
- sourceUrl?: string;
97
- sourceName?: string;
98
- path?: string;
94
+ body: string;
95
+ status: string;
96
+ featured?: boolean;
97
+ pinned?: boolean;
98
+ slug?: string;
99
+ url?: string;
100
+ quoteText?: string;
101
+ rating?: number;
102
+ collectionId?: number;
99
103
  mediaIds?: string[];
100
- collectionIds?: number[];
101
104
  }>();
102
105
 
103
106
  const post = await c.var.services.posts.create({
104
- type: body.type as Post["type"],
107
+ format: body.format as Post["format"],
105
108
  title: body.title || undefined,
106
- content: body.content,
107
- visibility: body.visibility as Post["visibility"],
108
- sourceUrl: body.sourceUrl || undefined,
109
- sourceName: body.sourceName || undefined,
110
- path: body.path || undefined,
109
+ body: body.body,
110
+ status: body.status as Post["status"],
111
+ featured: body.featured,
112
+ pinned: body.pinned,
113
+ slug: body.slug || undefined,
114
+ url: body.url || undefined,
115
+ quoteText: body.quoteText || undefined,
116
+ rating: body.rating || undefined,
117
+ collectionId: body.collectionId || undefined,
111
118
  });
112
119
 
113
120
  // Attach media if provided
@@ -115,14 +122,6 @@ postsRoutes.post("/", async (c) => {
115
122
  await c.var.services.media.attachToPost(post.id, body.mediaIds);
116
123
  }
117
124
 
118
- // Sync collection associations
119
- if (body.collectionIds) {
120
- await c.var.services.collections.syncPostCollections(
121
- post.id,
122
- body.collectionIds,
123
- );
124
- }
125
-
126
125
  return dsRedirect(`/dash/posts/${sqid.encode(post.id)}`);
127
126
  });
128
127
 
@@ -132,6 +131,7 @@ function ViewPostContent({ post }: { post: Post }) {
132
131
  message: "Post",
133
132
  comment: "@context: Default post title",
134
133
  });
134
+ const permalink = post.slug ? `/${post.slug}` : `/p/${sqid.encode(post.id)}`;
135
135
 
136
136
  return (
137
137
  <>
@@ -143,7 +143,7 @@ function ViewPostContent({ post }: { post: Post }) {
143
143
  message: "Edit",
144
144
  comment: "@context: Button to edit post",
145
145
  })}
146
- viewHref={`/p/${sqid.encode(post.id)}`}
146
+ viewHref={permalink}
147
147
  viewLabel={t({
148
148
  message: "View",
149
149
  comment: "@context: Button to view post",
@@ -155,7 +155,7 @@ function ViewPostContent({ post }: { post: Post }) {
155
155
  <section>
156
156
  <div
157
157
  class="prose"
158
- dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
158
+ dangerouslySetInnerHTML={{ __html: post.bodyHtml || "" }}
159
159
  />
160
160
  </section>
161
161
  </div>
@@ -170,7 +170,6 @@ function EditPostContent({
170
170
  imageTransformUrl,
171
171
  s3PublicUrl,
172
172
  collections,
173
- postCollectionIds,
174
173
  }: {
175
174
  post: Post;
176
175
  mediaAttachments: Media[];
@@ -178,7 +177,6 @@ function EditPostContent({
178
177
  imageTransformUrl?: string;
179
178
  s3PublicUrl?: string;
180
179
  collections: Collection[];
181
- postCollectionIds: number[];
182
180
  }) {
183
181
  const { t } = useLingui();
184
182
  return (
@@ -194,7 +192,6 @@ function EditPostContent({
194
192
  imageTransformUrl={imageTransformUrl}
195
193
  s3PublicUrl={s3PublicUrl}
196
194
  collections={collections}
197
- postCollectionIds={postCollectionIds}
198
195
  />
199
196
  </>
200
197
  );
@@ -237,9 +234,6 @@ postsRoutes.get("/:id/edit", async (c) => {
237
234
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
238
235
  const s3PublicUrl = c.env.S3_PUBLIC_URL;
239
236
  const collections = await c.var.services.collections.list();
240
- const postCollections =
241
- await c.var.services.collections.getCollectionsForPost(post.id);
242
- const postCollectionIds = postCollections.map((col) => col.id);
243
237
 
244
238
  return c.html(
245
239
  <DashLayout
@@ -255,7 +249,6 @@ postsRoutes.get("/:id/edit", async (c) => {
255
249
  imageTransformUrl={imageTransformUrl}
256
250
  s3PublicUrl={s3PublicUrl}
257
251
  collections={collections}
258
- postCollectionIds={postCollectionIds}
259
252
  />
260
253
  </DashLayout>,
261
254
  );
@@ -267,25 +260,32 @@ postsRoutes.post("/:id", async (c) => {
267
260
  if (!id) return c.notFound();
268
261
 
269
262
  const body = await c.req.json<{
270
- type: string;
263
+ format: string;
271
264
  title?: string;
272
- content?: string;
273
- visibility: string;
274
- sourceUrl?: string;
275
- sourceName?: string;
276
- path?: string;
265
+ body?: string;
266
+ status: string;
267
+ featured?: boolean;
268
+ pinned?: boolean;
269
+ slug?: string;
270
+ url?: string;
271
+ quoteText?: string;
272
+ rating?: number;
273
+ collectionId?: number;
277
274
  mediaIds?: string[];
278
- collectionIds?: number[];
279
275
  }>();
280
276
 
281
277
  await c.var.services.posts.update(id, {
282
- type: body.type as Post["type"],
278
+ format: body.format as Post["format"],
283
279
  title: body.title || null,
284
- content: body.content || null,
285
- visibility: body.visibility as Post["visibility"],
286
- sourceUrl: body.sourceUrl || null,
287
- sourceName: body.sourceName || null,
288
- path: body.path || null,
280
+ body: body.body || null,
281
+ status: body.status as Post["status"],
282
+ featured: body.featured,
283
+ pinned: body.pinned,
284
+ slug: body.slug || null,
285
+ url: body.url || null,
286
+ quoteText: body.quoteText || null,
287
+ rating: body.rating || null,
288
+ collectionId: body.collectionId || null,
289
289
  });
290
290
 
291
291
  // Update media attachments if provided
@@ -293,14 +293,6 @@ postsRoutes.post("/:id", async (c) => {
293
293
  await c.var.services.media.attachToPost(id, body.mediaIds);
294
294
  }
295
295
 
296
- // Sync collection associations
297
- if (body.collectionIds !== undefined) {
298
- await c.var.services.collections.syncPostCollections(
299
- id,
300
- body.collectionIds,
301
- );
302
- }
303
-
304
296
  return dsRedirect(`/dash/posts/${sqid.encode(id)}`);
305
297
  });
306
298
 
@@ -26,7 +26,8 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
26
26
  const siteLanguage = await getSiteLanguage(c);
27
27
 
28
28
  const posts = await c.var.services.posts.list({
29
- visibility: ["featured", "quiet"],
29
+ status: "published",
30
+ excludeReplies: true,
30
31
  limit: 50,
31
32
  });
32
33
 
@@ -6,7 +6,11 @@ import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
8
  import { defaultSitemapRenderer } from "../../lib/feed.js";
9
- import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
9
+ import {
10
+ createMediaContext,
11
+ toPostViewsFromPosts,
12
+ toPageView,
13
+ } from "../../lib/view.js";
10
14
 
11
15
  type Env = { Bindings: Bindings; Variables: AppVariables };
12
16
 
@@ -17,16 +21,22 @@ sitemapRoutes.get("/sitemap.xml", async (c) => {
17
21
  const siteUrl = c.env.SITE_URL;
18
22
 
19
23
  const posts = await c.var.services.posts.list({
20
- visibility: ["featured", "quiet"],
24
+ status: "published",
25
+ excludeReplies: true,
21
26
  limit: 1000,
22
27
  });
23
28
 
24
- // Transform to PostView[]
29
+ // Fetch published pages
30
+ const allPages = await c.var.services.pages.list();
31
+ const publishedPages = allPages.filter((p) => p.status === "published");
32
+
33
+ // Transform to View Models
25
34
  const mediaCtx = createMediaContext(c);
26
35
  const postViews = toPostViewsFromPosts(posts, mediaCtx);
36
+ const pageViews = publishedPages.map(toPageView);
27
37
 
28
38
  const renderer = c.var.config.theme?.feed?.sitemap ?? defaultSitemapRenderer;
29
- const xml = renderer({ siteUrl, posts: postViews });
39
+ const xml = renderer({ siteUrl, posts: postViews, pages: pageViews });
30
40
 
31
41
  return new Response(xml, {
32
42
  headers: {
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Archive Page Route
3
3
  *
4
- * Shows all posts, optionally filtered by type
4
+ * Shows all posts, optionally filtered by format or featured status
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
- import type { Bindings, PostType } from "../../types.js";
8
+ import type { Bindings, Format } from "../../types.js";
9
9
  import type { AppVariables } from "../../app.js";
10
- import { POST_TYPES } from "../../types.js";
11
- import { ArchivePage as DefaultArchivePage } from "../../theme/pages/ArchivePage.js";
10
+ import { FORMATS } from "../../types.js";
11
+ import { ArchivePage as DefaultArchivePage } from "../../themes/threads/pages/ArchivePage.js";
12
12
  import { getNavigationData } from "../../lib/navigation.js";
13
13
  import { renderPublicPage } from "../../lib/render.js";
14
14
  import { createMediaContext, toArchiveGroups } from "../../lib/view.js";
@@ -21,9 +21,11 @@ export const archiveRoutes = new Hono<Env>();
21
21
 
22
22
  // Archive page - all posts
23
23
  archiveRoutes.get("/", async (c) => {
24
- const typeParam = c.req.query("type") as PostType | undefined;
25
- const type =
26
- typeParam && POST_TYPES.includes(typeParam) ? typeParam : undefined;
24
+ const formatParam = c.req.query("format") as Format | undefined;
25
+ const format =
26
+ formatParam && FORMATS.includes(formatParam) ? formatParam : undefined;
27
+ const featuredParam = c.req.query("featured");
28
+ const featured = featuredParam === "true" ? true : undefined;
27
29
 
28
30
  // Parse cursor
29
31
  const cursorParam = c.req.query("cursor");
@@ -33,8 +35,9 @@ archiveRoutes.get("/", async (c) => {
33
35
 
34
36
  // Fetch one extra to check for more
35
37
  const posts = await c.var.services.posts.list({
36
- type,
37
- visibility: ["featured", "quiet"],
38
+ format,
39
+ status: "published",
40
+ featured,
38
41
  excludeReplies: true,
39
42
  cursor,
40
43
  limit: PAGE_SIZE + 1,
@@ -77,7 +80,8 @@ archiveRoutes.get("/", async (c) => {
77
80
  groups={groups}
78
81
  hasMore={hasMore}
79
82
  nextCursor={nextCursor}
80
- type={type}
83
+ format={format}
84
+ featured={featured}
81
85
  theme={components}
82
86
  />
83
87
  ),
@@ -5,7 +5,7 @@
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
- import { CollectionPage as DefaultCollectionPage } from "../../theme/pages/CollectionPage.js";
8
+ import { CollectionPage as DefaultCollectionPage } from "../../themes/threads/pages/CollectionPage.js";
9
9
  import { getNavigationData } from "../../lib/navigation.js";
10
10
  import { renderPublicPage } from "../../lib/render.js";
11
11
  import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
@@ -14,13 +14,19 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
14
14
 
15
15
  export const collectionRoutes = new Hono<Env>();
16
16
 
17
- collectionRoutes.get("/:path", async (c) => {
18
- const path = c.req.param("path");
17
+ collectionRoutes.get("/:slug", async (c) => {
18
+ const slug = c.req.param("slug");
19
19
 
20
- const collection = await c.var.services.collections.getByPath(path);
20
+ const collection = await c.var.services.collections.getBySlug(slug);
21
21
  if (!collection) return c.notFound();
22
22
 
23
- const posts = await c.var.services.collections.getPosts(collection.id);
23
+ // Fetch posts in this collection
24
+ const posts = await c.var.services.posts.list({
25
+ collectionId: collection.id,
26
+ status: "published",
27
+ excludeReplies: true,
28
+ });
29
+
24
30
  const navData = await getNavigationData(c);
25
31
 
26
32
  // Transform to View Models
@@ -35,7 +41,12 @@ collectionRoutes.get("/:path", async (c) => {
35
41
  description: collection.description ?? undefined,
36
42
  navData,
37
43
  content: (
38
- <Page collection={collection} posts={postViews} theme={components} />
44
+ <Page
45
+ collection={collection}
46
+ posts={postViews}
47
+ hasMore={false}
48
+ theme={components}
49
+ />
39
50
  ),
40
51
  });
41
52
  });
@@ -2,109 +2,83 @@
2
2
  * Home Page Route
3
3
  *
4
4
  * Timeline feed with per-type card components and thread previews.
5
+ * Handles both full-page rendering and load-more SSE responses.
5
6
  */
6
7
 
7
8
  import { Hono } from "hono";
8
- import type { Bindings, TimelineItemView } from "../../types.js";
9
+ import type { Bindings } from "../../types.js";
9
10
  import type { AppVariables } from "../../app.js";
10
- import { buildMediaMap } from "../../lib/media-helpers.js";
11
11
  import { getNavigationData } from "../../lib/navigation.js";
12
12
  import { renderPublicPage } from "../../lib/render.js";
13
- import { HomePage as DefaultHomePage } from "../../theme/pages/HomePage.js";
14
- import { createMediaContext, toPostView, toPostViews } from "../../lib/view.js";
13
+ import { assembleTimeline } from "../../lib/timeline.js";
14
+ import { sse } from "../../lib/sse.js";
15
+ import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
16
+ import { HomePage as DefaultHomePage } from "../../themes/threads/pages/HomePage.js";
15
17
 
16
18
  type Env = { Bindings: Bindings; Variables: AppVariables };
17
19
 
18
- const PAGE_SIZE = 20;
19
-
20
20
  export const homeRoutes = new Hono<Env>();
21
21
 
22
22
  homeRoutes.get("/", async (c) => {
23
- const navData = await getNavigationData(c);
23
+ const cursorParam = c.req.query("cursor");
24
+ const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
25
+ const lastDate = c.req.query("lastDate");
24
26
 
25
- // Fetch one extra to determine if there are more
26
- const posts = await c.var.services.posts.list({
27
- visibility: ["featured", "quiet"],
28
- excludeReplies: true,
29
- excludeTypes: ["page"],
30
- limit: PAGE_SIZE + 1,
27
+ const { items, hasMore, nextCursor } = await assembleTimeline(c, {
28
+ cursor: cursor && !isNaN(cursor) ? cursor : undefined,
31
29
  });
32
30
 
33
- const hasMore = posts.length > PAGE_SIZE;
34
- const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
35
-
36
- // Batch load media attachments
37
- const postIds = displayPosts.map((p) => p.id);
38
- const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
39
- const mediaCtx = createMediaContext(c);
40
- const mediaMap = buildMediaMap(
41
- rawMediaMap,
42
- mediaCtx.r2PublicUrl,
43
- mediaCtx.imageTransformUrl,
44
- mediaCtx.s3PublicUrl,
45
- );
46
-
47
- // Get reply counts to identify thread roots
48
- const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
49
- const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
50
-
51
- // Batch load thread previews
52
- const threadPreviews = await c.var.services.posts.getThreadPreviews(
53
- threadRootIds,
54
- 3,
55
- );
56
-
57
- // Batch load media for preview replies
58
- const previewReplyIds: number[] = [];
59
- for (const replies of threadPreviews.values()) {
60
- for (const reply of replies) {
61
- previewReplyIds.push(reply.id);
31
+ // SSE load-more response
32
+ if (cursor && !isNaN(cursor)) {
33
+ if (items.length === 0) {
34
+ return sse(c, async (stream) => {
35
+ stream.remove("#load-more-container");
36
+ });
62
37
  }
63
- }
64
- const previewMediaMap =
65
- previewReplyIds.length > 0
66
- ? buildMediaMap(
67
- await c.var.services.media.getByPostIds(previewReplyIds),
68
- mediaCtx.r2PublicUrl,
69
- mediaCtx.imageTransformUrl,
70
- mediaCtx.s3PublicUrl,
71
- )
72
- : new Map();
73
38
 
74
- // Assemble timeline items with View Models
75
- const items: TimelineItemView[] = displayPosts.map((post) => {
76
- const postView = toPostView(
77
- { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
78
- mediaCtx,
79
- );
39
+ const themeConfig = c.var.config.theme;
40
+ const renderMore = themeConfig?.timelineMore;
41
+ if (!renderMore) {
42
+ // Should never happen default theme always provides timelineMore
43
+ return sse(c, async (stream) => {
44
+ stream.remove("#load-more-container");
45
+ });
46
+ }
80
47
 
81
- const replyCount = replyCounts.get(post.id) ?? 0;
82
- const previewReplies = threadPreviews.get(post.id);
48
+ const patches = renderMore({
49
+ items,
50
+ lastDate: lastDate ?? undefined,
51
+ hasMore,
52
+ nextCursor,
53
+ theme: themeConfig?.components,
54
+ });
55
+
56
+ return sse(c, async (stream) => {
57
+ for (const patch of patches) {
58
+ if (patch.mode === "remove") {
59
+ stream.remove(patch.selector);
60
+ } else {
61
+ stream.patchElements(patch.content, {
62
+ mode: patch.mode,
63
+ selector: patch.selector,
64
+ });
65
+ }
66
+ }
67
+ });
68
+ }
83
69
 
84
- if (replyCount > 0 && previewReplies) {
85
- return {
86
- post: postView,
87
- threadPreview: {
88
- replies: toPostViews(
89
- previewReplies.map((r) => ({
90
- ...r,
91
- mediaAttachments: previewMediaMap.get(r.id) ?? [],
92
- })),
93
- mediaCtx,
94
- ),
95
- totalReplyCount: replyCount,
96
- },
97
- };
98
- }
70
+ // Full page render
71
+ const navData = await getNavigationData(c);
99
72
 
100
- return { post: postView };
73
+ // Fetch pinned posts
74
+ const pinnedPosts = await c.var.services.posts.list({
75
+ pinned: true,
76
+ status: "published",
77
+ excludeReplies: true,
101
78
  });
79
+ const mediaCtx = createMediaContext(c);
80
+ const pinnedItems = toPostViewsFromPosts(pinnedPosts, mediaCtx);
102
81
 
103
- // Determine next cursor
104
- const lastPost = displayPosts[displayPosts.length - 1];
105
- const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
106
-
107
- // Resolve page component
108
82
  const components = c.var.config.theme?.components;
109
83
  const Page = components?.HomePage ?? DefaultHomePage;
110
84
 
@@ -114,6 +88,7 @@ homeRoutes.get("/", async (c) => {
114
88
  content: (
115
89
  <Page
116
90
  items={items}
91
+ pinnedItems={pinnedItems}
117
92
  hasMore={hasMore}
118
93
  nextCursor={nextCursor}
119
94
  theme={components}
@@ -1,51 +1,88 @@
1
1
  /**
2
2
  * Custom Page Route
3
3
  *
4
- * Catch-all route for custom pages accessible via their path field
4
+ * Serves pages from the pages table and posts with custom slugs.
5
+ * This is a catch-all route mounted at "/" - must be registered last.
5
6
  */
6
7
 
7
8
  import { Hono } from "hono";
8
9
  import type { Bindings } from "../../types.js";
9
10
  import type { AppVariables } from "../../app.js";
10
- import { SinglePage as DefaultSinglePage } from "../../theme/pages/SinglePage.js";
11
+ import { SinglePage as DefaultSinglePage } from "../../themes/threads/pages/SinglePage.js";
12
+ import { PostPage as DefaultPostPage } from "../../themes/threads/pages/PostPage.js";
11
13
  import { getNavigationData } from "../../lib/navigation.js";
12
14
  import { renderPublicPage } from "../../lib/render.js";
13
- import { createMediaContext, toPostViewFromPost } from "../../lib/view.js";
15
+ import { buildMediaMap } from "../../lib/media-helpers.js";
16
+ import { createMediaContext, toPageView, toPostView } from "../../lib/view.js";
14
17
 
15
18
  type Env = { Bindings: Bindings; Variables: AppVariables };
16
19
 
17
20
  export const pageRoutes = new Hono<Env>();
18
21
 
19
- // Catch-all for custom page paths
20
- pageRoutes.get("/:path", async (c) => {
21
- const path = c.req.param("path");
22
+ // Catch-all for custom page paths and post slugs
23
+ pageRoutes.get("/:slug", async (c) => {
24
+ const slug = c.req.param("slug");
22
25
 
23
- // Look up page by path
24
- const page = await c.var.services.posts.getByPath(path);
26
+ // First, try to find a page by slug
27
+ const page = await c.var.services.pages.getBySlug(slug);
25
28
 
26
- // Not found or not a page
27
- if (!page || page.type !== "page") {
28
- return c.notFound();
29
- }
29
+ if (page) {
30
+ // Don't show draft pages
31
+ if (page.status === "draft") {
32
+ return c.notFound();
33
+ }
34
+
35
+ const navData = await getNavigationData(c);
36
+ const pageView = toPageView(page);
30
37
 
31
- // Don't show drafts
32
- if (page.visibility === "draft") {
33
- return c.notFound();
38
+ const components = c.var.config.theme?.components;
39
+ const Page = components?.SinglePage ?? DefaultSinglePage;
40
+
41
+ return renderPublicPage(c, {
42
+ title: `${page.title || slug} - ${navData.siteName}`,
43
+ description: page.body?.slice(0, 160),
44
+ navData,
45
+ content: <Page page={pageView} theme={components} />,
46
+ });
34
47
  }
35
48
 
36
- const navData = await getNavigationData(c);
49
+ // Then, try to find a post by slug
50
+ const post = await c.var.services.posts.getBySlug(slug);
51
+
52
+ if (post) {
53
+ // Don't show draft posts
54
+ if (post.status === "draft") {
55
+ return c.notFound();
56
+ }
37
57
 
38
- // Transform to View Model
39
- const mediaCtx = createMediaContext(c);
40
- const pageView = toPostViewFromPost(page, mediaCtx);
58
+ // Load media attachments
59
+ const rawMediaMap = await c.var.services.media.getByPostIds([post.id]);
60
+ const mediaCtx = createMediaContext(c);
61
+ const mediaMap = buildMediaMap(
62
+ rawMediaMap,
63
+ mediaCtx.r2PublicUrl,
64
+ mediaCtx.imageTransformUrl,
65
+ mediaCtx.s3PublicUrl,
66
+ );
41
67
 
42
- const components = c.var.config.theme?.components;
43
- const Page = components?.SinglePage ?? DefaultSinglePage;
68
+ const postView = toPostView(
69
+ { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
70
+ mediaCtx,
71
+ );
72
+
73
+ const navData = await getNavigationData(c);
74
+ const title = post.title || navData.siteName;
75
+
76
+ const components = c.var.config.theme?.components;
77
+ const PostPage = components?.PostPage ?? DefaultPostPage;
78
+
79
+ return renderPublicPage(c, {
80
+ title,
81
+ description: post.body?.slice(0, 160),
82
+ navData,
83
+ content: <PostPage post={postView} theme={components} />,
84
+ });
85
+ }
44
86
 
45
- return renderPublicPage(c, {
46
- title: `${page.title} - ${navData.siteName}`,
47
- description: page.content?.slice(0, 160),
48
- navData,
49
- content: <Page page={pageView} theme={components} />,
50
- });
87
+ return c.notFound();
51
88
  });