@jant/core 0.3.24 → 0.3.26

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 (277) hide show
  1. package/dist/app.js +101 -571
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +1 -1
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/index.js +3 -9
  8. package/dist/lib/avatar-upload.js +134 -0
  9. package/dist/lib/config.js +39 -0
  10. package/dist/lib/constants.js +10 -9
  11. package/dist/lib/favicon.js +102 -0
  12. package/dist/lib/image.js +13 -17
  13. package/dist/lib/media-helpers.js +2 -2
  14. package/dist/lib/nav-reorder.js +1 -1
  15. package/dist/lib/navigation.js +48 -3
  16. package/dist/lib/pagination.js +44 -0
  17. package/dist/lib/render.js +16 -11
  18. package/dist/lib/schemas.js +34 -3
  19. package/dist/lib/theme.js +4 -4
  20. package/dist/lib/timeline.js +24 -48
  21. package/dist/lib/timezones.js +388 -0
  22. package/dist/lib/view.js +3 -3
  23. package/dist/routes/api/collections.js +124 -0
  24. package/dist/routes/api/nav-items.js +104 -0
  25. package/dist/routes/api/pages.js +91 -0
  26. package/dist/routes/api/posts.js +3 -3
  27. package/dist/routes/api/search.js +2 -2
  28. package/dist/routes/api/settings.js +68 -0
  29. package/dist/routes/api/upload.js +3 -3
  30. package/dist/routes/auth/reset.js +221 -0
  31. package/dist/routes/auth/setup.js +194 -0
  32. package/dist/routes/auth/signin.js +176 -0
  33. package/dist/routes/compose.js +48 -0
  34. package/dist/routes/dash/collections.js +24 -416
  35. package/dist/routes/dash/index.js +1 -1
  36. package/dist/routes/dash/media.js +13 -393
  37. package/dist/routes/dash/pages.js +112 -86
  38. package/dist/routes/dash/posts.js +3 -5
  39. package/dist/routes/dash/redirects.js +20 -14
  40. package/dist/routes/dash/settings.js +213 -518
  41. package/dist/routes/feed/rss.js +4 -3
  42. package/dist/routes/feed/sitemap.js +5 -3
  43. package/dist/routes/pages/archive.js +3 -6
  44. package/dist/routes/pages/collection.js +3 -6
  45. package/dist/routes/pages/collections.js +28 -0
  46. package/dist/routes/pages/featured.js +36 -0
  47. package/dist/routes/pages/home.js +33 -49
  48. package/dist/routes/pages/latest.js +45 -0
  49. package/dist/routes/pages/page.js +29 -32
  50. package/dist/routes/pages/post.js +3 -6
  51. package/dist/routes/pages/search.js +3 -6
  52. package/dist/services/page.js +5 -1
  53. package/dist/services/post.js +45 -31
  54. package/dist/services/search.js +1 -1
  55. package/dist/types/bindings.js +3 -0
  56. package/dist/types/config.js +147 -0
  57. package/dist/types/constants.js +27 -0
  58. package/dist/types/entities.js +3 -0
  59. package/dist/types/operations.js +3 -0
  60. package/dist/types/props.js +3 -0
  61. package/dist/types/views.js +5 -0
  62. package/dist/types.js +8 -111
  63. package/dist/{theme → ui}/color-themes.js +33 -33
  64. package/dist/ui/compose/ComposeDialog.js +467 -0
  65. package/dist/ui/compose/ComposePrompt.js +55 -0
  66. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
  67. package/dist/{theme/components → ui/dash}/PageForm.js +21 -15
  68. package/dist/{theme/components → ui/dash}/PostForm.js +22 -43
  69. package/dist/{theme/components → ui/dash}/PostList.js +6 -6
  70. package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
  71. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  72. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  73. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  74. package/dist/{theme/components → ui/dash}/index.js +3 -6
  75. package/dist/ui/dash/media/MediaListContent.js +166 -0
  76. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  77. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  78. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  79. package/dist/ui/dash/settings/AccountContent.js +209 -0
  80. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  81. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  82. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  83. package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
  84. package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
  85. package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
  86. package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
  87. package/dist/ui/feed/TimelineFeed.js +41 -0
  88. package/dist/ui/feed/TimelineItem.js +27 -0
  89. package/dist/ui/font-themes.js +36 -0
  90. package/dist/{theme → ui}/layouts/BaseLayout.js +34 -2
  91. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  92. package/dist/ui/layouts/SiteLayout.js +169 -0
  93. package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
  94. package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
  95. package/dist/ui/pages/CollectionsPage.js +76 -0
  96. package/dist/ui/pages/FeaturedPage.js +24 -0
  97. package/dist/ui/pages/HomePage.js +24 -0
  98. package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
  99. package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
  100. package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
  101. package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
  102. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  103. package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
  104. package/dist/ui/shared/index.js +5 -0
  105. package/package.json +1 -9
  106. package/src/__tests__/helpers/db.ts +3 -0
  107. package/src/app.tsx +131 -561
  108. package/src/client.ts +1 -0
  109. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  110. package/src/db/migrations/meta/_journal.json +7 -0
  111. package/src/db/schema.ts +1 -1
  112. package/src/i18n/locales/en.po +477 -261
  113. package/src/i18n/locales/en.ts +1 -1
  114. package/src/i18n/locales/zh-Hans.po +477 -261
  115. package/src/i18n/locales/zh-Hans.ts +1 -1
  116. package/src/i18n/locales/zh-Hant.po +477 -261
  117. package/src/i18n/locales/zh-Hant.ts +1 -1
  118. package/src/index.ts +7 -36
  119. package/src/lib/__tests__/config.test.ts +192 -0
  120. package/src/lib/__tests__/favicon.test.ts +151 -0
  121. package/src/lib/__tests__/image.test.ts +2 -6
  122. package/src/lib/__tests__/schemas.test.ts +60 -19
  123. package/src/lib/__tests__/timeline.test.ts +45 -81
  124. package/src/lib/__tests__/timezones.test.ts +61 -0
  125. package/src/lib/__tests__/view.test.ts +15 -9
  126. package/src/lib/avatar-upload.ts +165 -0
  127. package/src/lib/config.ts +47 -0
  128. package/src/lib/constants.ts +19 -10
  129. package/src/lib/favicon.ts +115 -0
  130. package/src/lib/image.ts +13 -21
  131. package/src/lib/media-helpers.ts +2 -2
  132. package/src/lib/nav-reorder.ts +1 -1
  133. package/src/lib/navigation.ts +73 -4
  134. package/src/lib/pagination.ts +50 -0
  135. package/src/lib/render.tsx +22 -15
  136. package/src/lib/schemas.ts +47 -6
  137. package/src/lib/theme.ts +5 -5
  138. package/src/lib/timeline.ts +28 -57
  139. package/src/lib/timezones.ts +325 -0
  140. package/src/lib/view.ts +3 -3
  141. package/src/preset.css +2 -1
  142. package/src/routes/__tests__/compose.test.ts +199 -0
  143. package/src/routes/api/__tests__/collections.test.ts +249 -0
  144. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  145. package/src/routes/api/__tests__/pages.test.ts +218 -0
  146. package/src/routes/api/__tests__/settings.test.ts +132 -0
  147. package/src/routes/api/collections.ts +143 -0
  148. package/src/routes/api/nav-items.ts +115 -0
  149. package/src/routes/api/pages.ts +101 -0
  150. package/src/routes/api/posts.ts +3 -3
  151. package/src/routes/api/search.ts +2 -2
  152. package/src/routes/api/settings.ts +91 -0
  153. package/src/routes/api/upload.ts +2 -3
  154. package/src/routes/auth/reset.tsx +239 -0
  155. package/src/routes/auth/setup.tsx +189 -0
  156. package/src/routes/auth/signin.tsx +163 -0
  157. package/src/routes/compose.ts +63 -0
  158. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  159. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  160. package/src/routes/dash/collections.tsx +18 -367
  161. package/src/routes/dash/index.tsx +1 -1
  162. package/src/routes/dash/media.tsx +13 -415
  163. package/src/routes/dash/pages.tsx +131 -98
  164. package/src/routes/dash/posts.tsx +3 -7
  165. package/src/routes/dash/redirects.tsx +22 -16
  166. package/src/routes/dash/settings.tsx +265 -478
  167. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  168. package/src/routes/feed/rss.ts +5 -3
  169. package/src/routes/feed/sitemap.ts +5 -3
  170. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  171. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  172. package/src/routes/pages/archive.tsx +2 -6
  173. package/src/routes/pages/collection.tsx +2 -6
  174. package/src/routes/pages/collections.tsx +36 -0
  175. package/src/routes/pages/featured.tsx +44 -0
  176. package/src/routes/pages/home.tsx +30 -53
  177. package/src/routes/pages/latest.tsx +59 -0
  178. package/src/routes/pages/page.tsx +28 -30
  179. package/src/routes/pages/post.tsx +2 -5
  180. package/src/routes/pages/search.tsx +2 -6
  181. package/src/services/__tests__/page.test.ts +106 -0
  182. package/src/services/__tests__/post.test.ts +114 -15
  183. package/src/services/page.ts +13 -1
  184. package/src/services/post.ts +58 -40
  185. package/src/services/search.ts +2 -2
  186. package/src/styles/components.css +0 -65
  187. package/src/styles/tokens.css +47 -0
  188. package/src/styles/ui.css +475 -0
  189. package/src/types/bindings.ts +30 -0
  190. package/src/types/config.ts +183 -0
  191. package/src/types/constants.ts +26 -0
  192. package/src/types/entities.ts +109 -0
  193. package/src/types/operations.ts +88 -0
  194. package/src/types/props.ts +115 -0
  195. package/src/types/views.ts +172 -0
  196. package/src/types.ts +8 -774
  197. package/src/ui/__tests__/font-themes.test.ts +34 -0
  198. package/src/{theme → ui}/color-themes.ts +34 -34
  199. package/src/ui/compose/ComposeDialog.tsx +414 -0
  200. package/src/ui/compose/ComposePrompt.tsx +55 -0
  201. package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
  202. package/src/{theme/components → ui/dash}/PageForm.tsx +25 -19
  203. package/src/{theme/components → ui/dash}/PostForm.tsx +26 -45
  204. package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
  205. package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
  206. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  207. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  208. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  209. package/src/ui/dash/index.ts +10 -0
  210. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  211. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  212. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  213. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  214. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  215. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  216. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  217. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  218. package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
  219. package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
  220. package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
  221. package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
  222. package/src/ui/feed/TimelineFeed.tsx +49 -0
  223. package/src/ui/feed/TimelineItem.tsx +45 -0
  224. package/src/ui/font-themes.ts +54 -0
  225. package/src/{theme → ui}/layouts/BaseLayout.tsx +28 -1
  226. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  227. package/src/ui/layouts/SiteLayout.tsx +164 -0
  228. package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
  229. package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
  230. package/src/ui/pages/CollectionsPage.tsx +73 -0
  231. package/src/ui/pages/FeaturedPage.tsx +31 -0
  232. package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
  233. package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
  234. package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
  235. package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
  236. package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
  237. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  238. package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
  239. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  240. package/src/ui/shared/index.ts +12 -0
  241. package/bin/jant.js +0 -185
  242. package/dist/lib/theme-components.js +0 -46
  243. package/dist/routes/dash/navigation.js +0 -289
  244. package/dist/theme/index.js +0 -18
  245. package/dist/theme/layouts/index.js +0 -2
  246. package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
  247. package/dist/themes/threads/index.js +0 -81
  248. package/dist/themes/threads/pages/HomePage.js +0 -25
  249. package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
  250. package/dist/themes/threads/timeline/TimelineItem.js +0 -36
  251. package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
  252. package/dist/themes/threads/timeline/groupByDate.js +0 -22
  253. package/dist/themes/threads/timeline/timelineMore.js +0 -107
  254. package/src/lib/__tests__/theme-components.test.ts +0 -105
  255. package/src/lib/theme-components.ts +0 -65
  256. package/src/routes/dash/navigation.tsx +0 -317
  257. package/src/theme/components/index.ts +0 -23
  258. package/src/theme/index.ts +0 -22
  259. package/src/theme/layouts/index.ts +0 -7
  260. package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
  261. package/src/themes/threads/index.ts +0 -100
  262. package/src/themes/threads/style.css +0 -336
  263. package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
  264. package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
  265. package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
  266. package/src/themes/threads/timeline/groupByDate.ts +0 -30
  267. package/src/themes/threads/timeline/timelineMore.tsx +0 -130
  268. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  269. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  270. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  271. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  272. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  273. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  274. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  275. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  276. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  277. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Hono } from "hono";
3
+ import type { Bindings } from "../../../types.js";
4
+ import type { AppVariables } from "../../../app.js";
5
+ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
6
+ import { createPostService } from "../../../services/post.js";
7
+ import { createSettingsService } from "../../../services/settings.js";
8
+ import { createMediaService } from "../../../services/media.js";
9
+ import { rssRoutes } from "../rss.js";
10
+
11
+ type Env = { Bindings: Bindings; Variables: AppVariables };
12
+
13
+ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
14
+ const { db } = createTestDatabase();
15
+
16
+ const services = {
17
+ posts: createPostService(db as never),
18
+ settings: createSettingsService(db as never),
19
+ media: createMediaService(db as never),
20
+ };
21
+
22
+ const app = new Hono<Env>();
23
+
24
+ app.use("*", async (c, next) => {
25
+ c.env = {
26
+ SITE_URL: "http://localhost:9019",
27
+ ...envOverrides,
28
+ } as Bindings;
29
+
30
+ c.set("services", services as AppVariables["services"]);
31
+ c.set("config", {});
32
+ await next();
33
+ });
34
+
35
+ app.route("/feed", rssRoutes);
36
+
37
+ return { app, services };
38
+ }
39
+
40
+ describe("RSS Feed Routes", () => {
41
+ describe("RSS_FEED_LIMIT env var", () => {
42
+ it("defaults to 50 when RSS_FEED_LIMIT is not set", async () => {
43
+ const { app, services } = createFeedTestApp();
44
+
45
+ // Create 3 posts
46
+ for (let i = 0; i < 3; i++) {
47
+ await services.posts.create({
48
+ format: "note",
49
+ title: `Post ${i}`,
50
+ body: `Body ${i}`,
51
+ status: "published",
52
+ });
53
+ }
54
+
55
+ const res = await app.request("/feed");
56
+ expect(res.status).toBe(200);
57
+
58
+ const xml = await res.text();
59
+ // All 3 posts should appear (under default limit of 50)
60
+ expect(xml).toContain("Post 0");
61
+ expect(xml).toContain("Post 1");
62
+ expect(xml).toContain("Post 2");
63
+ });
64
+
65
+ it("respects RSS_FEED_LIMIT to limit the number of posts", async () => {
66
+ const { app, services } = createFeedTestApp({
67
+ RSS_FEED_LIMIT: "2",
68
+ });
69
+
70
+ // Create 5 posts
71
+ for (let i = 0; i < 5; i++) {
72
+ await services.posts.create({
73
+ format: "note",
74
+ title: `Post ${i}`,
75
+ body: `Body ${i}`,
76
+ status: "published",
77
+ });
78
+ }
79
+
80
+ const res = await app.request("/feed");
81
+ expect(res.status).toBe(200);
82
+
83
+ const xml = await res.text();
84
+ // Posts are ordered by publishedAt DESC, so the latest 2 should appear
85
+ // With same timestamp they fall back to id DESC, so Post 4 and Post 3
86
+ expect(xml).toContain("Post 4");
87
+ expect(xml).toContain("Post 3");
88
+ expect(xml).not.toContain("Post 2");
89
+ expect(xml).not.toContain("Post 1");
90
+ expect(xml).not.toContain("Post 0");
91
+ });
92
+
93
+ it("falls back to 50 for invalid RSS_FEED_LIMIT", async () => {
94
+ const { app, services } = createFeedTestApp({
95
+ RSS_FEED_LIMIT: "not-a-number",
96
+ });
97
+
98
+ // Create 2 posts
99
+ for (let i = 0; i < 2; i++) {
100
+ await services.posts.create({
101
+ format: "note",
102
+ title: `Post ${i}`,
103
+ body: `Body ${i}`,
104
+ status: "published",
105
+ });
106
+ }
107
+
108
+ const res = await app.request("/feed");
109
+ expect(res.status).toBe(200);
110
+
111
+ const xml = await res.text();
112
+ // Both posts should appear (fallback to 50)
113
+ expect(xml).toContain("Post 0");
114
+ expect(xml).toContain("Post 1");
115
+ });
116
+
117
+ it("also applies to atom feed", async () => {
118
+ const { app, services } = createFeedTestApp({
119
+ RSS_FEED_LIMIT: "1",
120
+ });
121
+
122
+ for (let i = 0; i < 3; i++) {
123
+ await services.posts.create({
124
+ format: "note",
125
+ title: `Post ${i}`,
126
+ body: `Body ${i}`,
127
+ status: "published",
128
+ });
129
+ }
130
+
131
+ const res = await app.request("/feed/atom.xml");
132
+ expect(res.status).toBe(200);
133
+
134
+ const xml = await res.text();
135
+ // Only the latest post should appear
136
+ expect(xml).toContain("Post 2");
137
+ expect(xml).not.toContain("Post 1");
138
+ expect(xml).not.toContain("Post 0");
139
+ });
140
+ });
141
+ });
@@ -25,10 +25,12 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
25
25
  const siteUrl = c.env.SITE_URL;
26
26
  const siteLanguage = await getSiteLanguage(c);
27
27
 
28
+ const feedLimit = parseInt(c.env.RSS_FEED_LIMIT ?? "50", 10) || 50;
29
+
28
30
  const posts = await c.var.services.posts.list({
29
31
  status: "published",
30
32
  excludeReplies: true,
31
- limit: 50,
33
+ limit: feedLimit,
32
34
  });
33
35
 
34
36
  // Batch load media for enclosures
@@ -64,7 +66,7 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
64
66
  rssRoutes.get("/", async (c) => {
65
67
  const feedData = await buildFeedData(c);
66
68
 
67
- const renderer = c.var.config.theme?.feed?.rss ?? defaultRssRenderer;
69
+ const renderer = c.var.config.feed?.rss ?? defaultRssRenderer;
68
70
  const xml = renderer(feedData);
69
71
 
70
72
  return new Response(xml, {
@@ -78,7 +80,7 @@ rssRoutes.get("/", async (c) => {
78
80
  rssRoutes.get("/atom.xml", async (c) => {
79
81
  const feedData = await buildFeedData(c);
80
82
 
81
- const renderer = c.var.config.theme?.feed?.atom ?? defaultAtomRenderer;
83
+ const renderer = c.var.config.feed?.atom ?? defaultAtomRenderer;
82
84
  const xml = renderer(feedData);
83
85
 
84
86
  return new Response(xml, {
@@ -35,7 +35,7 @@ sitemapRoutes.get("/sitemap.xml", async (c) => {
35
35
  const postViews = toPostViewsFromPosts(posts, mediaCtx);
36
36
  const pageViews = publishedPages.map(toPageView);
37
37
 
38
- const renderer = c.var.config.theme?.feed?.sitemap ?? defaultSitemapRenderer;
38
+ const renderer = c.var.config.feed?.sitemap ?? defaultSitemapRenderer;
39
39
  const xml = renderer({ siteUrl, posts: postViews, pages: pageViews });
40
40
 
41
41
  return new Response(xml, {
@@ -46,11 +46,13 @@ sitemapRoutes.get("/sitemap.xml", async (c) => {
46
46
  });
47
47
 
48
48
  // robots.txt
49
- sitemapRoutes.get("/robots.txt", (c) => {
49
+ sitemapRoutes.get("/robots.txt", async (c) => {
50
50
  const siteUrl = c.env.SITE_URL;
51
+ const noindex = (await c.var.services.settings.get("NOINDEX")) === "true";
51
52
 
53
+ const directive = noindex ? "Disallow: /" : "Allow: /";
52
54
  const robots = `User-agent: *
53
- Allow: /
55
+ ${directive}
54
56
 
55
57
  Sitemap: ${siteUrl}/sitemap.xml
56
58
  `;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for the collections listing page data logic.
3
+ *
4
+ * Note: Route handler tests that import JSX components with @lingui/react/macro
5
+ * cannot run in vitest (requires SWC plugin). These tests verify the service
6
+ * layer operations that the collections route orchestrates.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from "vitest";
10
+ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
11
+ import { createCollectionService } from "../../../services/collection.js";
12
+ import { createPostService } from "../../../services/post.js";
13
+ import type { Database } from "../../../db/index.js";
14
+
15
+ describe("Collections Listing Page - Data Logic", () => {
16
+ let db: Database;
17
+ let collectionService: ReturnType<typeof createCollectionService>;
18
+ let postService: ReturnType<typeof createPostService>;
19
+
20
+ beforeEach(() => {
21
+ const testDb = createTestDatabase();
22
+ db = testDb.db as unknown as Database;
23
+ collectionService = createCollectionService(db);
24
+ postService = createPostService(db);
25
+ });
26
+
27
+ it("returns collections with post counts", async () => {
28
+ const recipes = await collectionService.create({
29
+ slug: "recipes",
30
+ title: "Recipes",
31
+ });
32
+ await collectionService.create({
33
+ slug: "travel",
34
+ title: "Travel",
35
+ });
36
+
37
+ // Add posts to recipes collection
38
+ await postService.create({
39
+ format: "note",
40
+ body: "Recipe 1",
41
+ collectionId: recipes.id,
42
+ });
43
+ await postService.create({
44
+ format: "note",
45
+ body: "Recipe 2",
46
+ collectionId: recipes.id,
47
+ });
48
+
49
+ // Simulate route handler logic
50
+ const [allCollections, postCounts] = await Promise.all([
51
+ collectionService.list(),
52
+ collectionService.getPostCounts(),
53
+ ]);
54
+
55
+ const collections = allCollections.map((col) => ({
56
+ ...col,
57
+ postCount: postCounts.get(col.id) ?? 0,
58
+ }));
59
+
60
+ expect(collections).toHaveLength(2);
61
+ const recipesResult = collections.find((c) => c.slug === "recipes");
62
+ const travelResult = collections.find((c) => c.slug === "travel");
63
+ expect(recipesResult?.postCount).toBe(2);
64
+ expect(travelResult?.postCount).toBe(0);
65
+ });
66
+
67
+ it("returns empty list when no collections exist", async () => {
68
+ const allCollections = await collectionService.list();
69
+ expect(allCollections).toHaveLength(0);
70
+ });
71
+
72
+ it("does not count soft-deleted posts", async () => {
73
+ const col = await collectionService.create({
74
+ slug: "test",
75
+ title: "Test",
76
+ });
77
+
78
+ const post = await postService.create({
79
+ format: "note",
80
+ body: "Will be deleted",
81
+ collectionId: col.id,
82
+ });
83
+ await postService.create({
84
+ format: "note",
85
+ body: "Will remain",
86
+ collectionId: col.id,
87
+ });
88
+
89
+ await postService.delete(post.id);
90
+
91
+ const postCounts = await collectionService.getPostCounts();
92
+ expect(postCounts.get(col.id)).toBe(1);
93
+ });
94
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for the featured page data logic.
3
+ *
4
+ * Note: Route handler tests that import JSX components with @lingui/react/macro
5
+ * cannot run in vitest (requires SWC plugin). These tests verify the service
6
+ * layer operations that the featured route orchestrates.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from "vitest";
10
+ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
11
+ import { createPostService } from "../../../services/post.js";
12
+ import type { Database } from "../../../db/index.js";
13
+
14
+ describe("Featured Page - Data Logic", () => {
15
+ let db: Database;
16
+ let postService: ReturnType<typeof createPostService>;
17
+
18
+ beforeEach(() => {
19
+ const testDb = createTestDatabase();
20
+ db = testDb.db as unknown as Database;
21
+ postService = createPostService(db);
22
+ });
23
+
24
+ it("returns only featured published posts", async () => {
25
+ await postService.create({
26
+ format: "note",
27
+ body: "Featured post",
28
+ featured: true,
29
+ status: "published",
30
+ });
31
+ await postService.create({
32
+ format: "note",
33
+ body: "Normal post",
34
+ featured: false,
35
+ status: "published",
36
+ });
37
+ await postService.create({
38
+ format: "note",
39
+ body: "Draft featured",
40
+ featured: true,
41
+ status: "draft",
42
+ });
43
+
44
+ const posts = await postService.list({
45
+ featured: true,
46
+ status: "published",
47
+ excludeReplies: true,
48
+ });
49
+
50
+ expect(posts).toHaveLength(1);
51
+ expect(posts[0]?.body).toBe("Featured post");
52
+ });
53
+
54
+ it("returns empty list when no featured posts exist", async () => {
55
+ await postService.create({
56
+ format: "note",
57
+ body: "Normal post",
58
+ status: "published",
59
+ });
60
+
61
+ const posts = await postService.list({
62
+ featured: true,
63
+ status: "published",
64
+ excludeReplies: true,
65
+ });
66
+
67
+ expect(posts).toHaveLength(0);
68
+ });
69
+
70
+ it("excludes replies from featured posts", async () => {
71
+ const root = await postService.create({
72
+ format: "note",
73
+ body: "Featured root",
74
+ featured: true,
75
+ status: "published",
76
+ });
77
+
78
+ // Reply inherits featured from root
79
+ await postService.create({
80
+ format: "note",
81
+ body: "Reply to featured",
82
+ replyToId: root.id,
83
+ });
84
+
85
+ const posts = await postService.list({
86
+ featured: true,
87
+ status: "published",
88
+ excludeReplies: true,
89
+ });
90
+
91
+ expect(posts).toHaveLength(1);
92
+ expect(posts[0]?.body).toBe("Featured root");
93
+ });
94
+ });
@@ -8,7 +8,7 @@ import { Hono } from "hono";
8
8
  import type { Bindings, Format } from "../../types.js";
9
9
  import type { AppVariables } from "../../app.js";
10
10
  import { FORMATS } from "../../types.js";
11
- import { ArchivePage as DefaultArchivePage } from "../../themes/threads/pages/ArchivePage.js";
11
+ import { ArchivePage } from "../../ui/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";
@@ -69,20 +69,16 @@ archiveRoutes.get("/", async (c) => {
69
69
  const mediaCtx = createMediaContext(c);
70
70
  const groups = toArchiveGroups(grouped, mediaCtx);
71
71
 
72
- const components = c.var.config.theme?.components;
73
- const Page = components?.ArchivePage ?? DefaultArchivePage;
74
-
75
72
  return renderPublicPage(c, {
76
73
  title: `Archive - ${navData.siteName}`,
77
74
  navData,
78
75
  content: (
79
- <Page
76
+ <ArchivePage
80
77
  groups={groups}
81
78
  hasMore={hasMore}
82
79
  nextCursor={nextCursor}
83
80
  format={format}
84
81
  featured={featured}
85
- theme={components}
86
82
  />
87
83
  ),
88
84
  });
@@ -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 "../../themes/threads/pages/CollectionPage.js";
8
+ import { CollectionPage } from "../../ui/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";
@@ -33,19 +33,15 @@ collectionRoutes.get("/:slug", async (c) => {
33
33
  const mediaCtx = createMediaContext(c);
34
34
  const postViews = toPostViewsFromPosts(posts, mediaCtx);
35
35
 
36
- const components = c.var.config.theme?.components;
37
- const Page = components?.CollectionPage ?? DefaultCollectionPage;
38
-
39
36
  return renderPublicPage(c, {
40
37
  title: `${collection.title} - ${navData.siteName}`,
41
38
  description: collection.description ?? undefined,
42
39
  navData,
43
40
  content: (
44
- <Page
41
+ <CollectionPage
45
42
  collection={collection}
46
43
  posts={postViews}
47
44
  hasMore={false}
48
- theme={components}
49
45
  />
50
46
  ),
51
47
  });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Collections Listing Page Route
3
+ *
4
+ * Lists all collections with their post counts.
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import type { Bindings } from "../../types.js";
9
+ import type { AppVariables } from "../../app.js";
10
+ import { getNavigationData } from "../../lib/navigation.js";
11
+ import { renderPublicPage } from "../../lib/render.js";
12
+ import { CollectionsPage } from "../../ui/pages/CollectionsPage.js";
13
+
14
+ type Env = { Bindings: Bindings; Variables: AppVariables };
15
+
16
+ export const collectionsPageRoutes = new Hono<Env>();
17
+
18
+ collectionsPageRoutes.get("/", async (c) => {
19
+ const [allCollections, postCounts] = await Promise.all([
20
+ c.var.services.collections.list(),
21
+ c.var.services.collections.getPostCounts(),
22
+ ]);
23
+
24
+ const collections = allCollections.map((col) => ({
25
+ ...col,
26
+ postCount: postCounts.get(col.id) ?? 0,
27
+ }));
28
+
29
+ const navData = await getNavigationData(c);
30
+
31
+ return renderPublicPage(c, {
32
+ title: `Collections - ${navData.siteName}`,
33
+ navData,
34
+ content: <CollectionsPage collections={collections} />,
35
+ });
36
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Featured Page Route
3
+ *
4
+ * Shows featured posts as a timeline feed.
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import type { Bindings } from "../../types.js";
9
+ import type { AppVariables } from "../../app.js";
10
+ import { getNavigationData } from "../../lib/navigation.js";
11
+ import { renderPublicPage } from "../../lib/render.js";
12
+ import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
13
+ import { FeaturedPage } from "../../ui/pages/FeaturedPage.js";
14
+
15
+ type Env = { Bindings: Bindings; Variables: AppVariables };
16
+
17
+ export const featuredRoutes = new Hono<Env>();
18
+
19
+ featuredRoutes.get("/", async (c) => {
20
+ const navData = await getNavigationData(c);
21
+
22
+ // When homepage already shows featured, redirect to avoid duplicate content
23
+ if (navData.homeDefaultView === "featured") {
24
+ return c.redirect("/", 302);
25
+ }
26
+
27
+ const posts = await c.var.services.posts.list({
28
+ featured: true,
29
+ status: "published",
30
+ excludeReplies: true,
31
+ });
32
+
33
+ const mediaCtx = createMediaContext(c);
34
+ const postViews = toPostViewsFromPosts(posts, mediaCtx);
35
+
36
+ // Convert to timeline items (simple — no thread previews)
37
+ const items = postViews.map((post) => ({ post }));
38
+
39
+ return renderPublicPage(c, {
40
+ title: `Featured - ${navData.siteName}`,
41
+ navData,
42
+ content: <FeaturedPage items={items} />,
43
+ });
44
+ });
@@ -2,7 +2,10 @@
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
+ * Uses page-based pagination.
6
+ *
7
+ * When HOME_DEFAULT_VIEW is "featured", the homepage shows featured posts
8
+ * instead of latest. The /latest route always shows latest posts explicitly.
6
9
  */
7
10
 
8
11
  import { Hono } from "hono";
@@ -11,64 +14,42 @@ import type { AppVariables } from "../../app.js";
11
14
  import { getNavigationData } from "../../lib/navigation.js";
12
15
  import { renderPublicPage } from "../../lib/render.js";
13
16
  import { assembleTimeline } from "../../lib/timeline.js";
14
- import { sse } from "../../lib/sse.js";
15
17
  import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
16
- import { HomePage as DefaultHomePage } from "../../themes/threads/pages/HomePage.js";
18
+ import { HomePage } from "../../ui/pages/HomePage.js";
19
+ import { FeaturedPage } from "../../ui/pages/FeaturedPage.js";
17
20
 
18
21
  type Env = { Bindings: Bindings; Variables: AppVariables };
19
22
 
20
23
  export const homeRoutes = new Hono<Env>();
21
24
 
22
25
  homeRoutes.get("/", async (c) => {
23
- const cursorParam = c.req.query("cursor");
24
- const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
25
- const lastDate = c.req.query("lastDate");
26
-
27
- const { items, hasMore, nextCursor } = await assembleTimeline(c, {
28
- cursor: cursor && !isNaN(cursor) ? cursor : undefined,
29
- });
30
-
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
- });
37
- }
38
-
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
- }
26
+ const navData = await getNavigationData(c);
47
27
 
48
- const patches = renderMore({
49
- items,
50
- lastDate: lastDate ?? undefined,
51
- hasMore,
52
- nextCursor,
53
- theme: themeConfig?.components,
28
+ if (navData.homeDefaultView === "featured") {
29
+ // Show featured posts on homepage
30
+ const posts = await c.var.services.posts.list({
31
+ featured: true,
32
+ status: "published",
33
+ excludeReplies: true,
54
34
  });
35
+ const mediaCtx = createMediaContext(c);
36
+ const postViews = toPostViewsFromPosts(posts, mediaCtx);
37
+ const items = postViews.map((post) => ({ post }));
55
38
 
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
- }
39
+ return renderPublicPage(c, {
40
+ title: navData.siteName,
41
+ navData,
42
+ content: <FeaturedPage items={items} />,
67
43
  });
68
44
  }
69
45
 
70
- // Full page render
71
- const navData = await getNavigationData(c);
46
+ // Default: show latest posts
47
+ const pageParam = c.req.query("page");
48
+ const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
49
+
50
+ const { items, currentPage, totalPages } = await assembleTimeline(c, {
51
+ page,
52
+ });
72
53
 
73
54
  // Fetch pinned posts
74
55
  const pinnedPosts = await c.var.services.posts.list({
@@ -79,19 +60,15 @@ homeRoutes.get("/", async (c) => {
79
60
  const mediaCtx = createMediaContext(c);
80
61
  const pinnedItems = toPostViewsFromPosts(pinnedPosts, mediaCtx);
81
62
 
82
- const components = c.var.config.theme?.components;
83
- const Page = components?.HomePage ?? DefaultHomePage;
84
-
85
63
  return renderPublicPage(c, {
86
64
  title: navData.siteName,
87
65
  navData,
88
66
  content: (
89
- <Page
67
+ <HomePage
90
68
  items={items}
91
69
  pinnedItems={pinnedItems}
92
- hasMore={hasMore}
93
- nextCursor={nextCursor}
94
- theme={components}
70
+ currentPage={currentPage}
71
+ totalPages={totalPages}
95
72
  />
96
73
  ),
97
74
  });