@jant/core 0.3.24 → 0.3.25

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 (206) hide show
  1. package/dist/app.js +50 -25
  2. package/dist/db/schema.js +1 -1
  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 +3 -9
  7. package/dist/lib/constants.js +1 -0
  8. package/dist/lib/nav-reorder.js +1 -1
  9. package/dist/lib/navigation.js +26 -1
  10. package/dist/lib/pagination.js +44 -0
  11. package/dist/lib/render.js +7 -11
  12. package/dist/lib/schemas.js +3 -3
  13. package/dist/lib/theme.js +4 -4
  14. package/dist/lib/timeline.js +24 -48
  15. package/dist/lib/view.js +2 -2
  16. package/dist/routes/api/collections.js +124 -0
  17. package/dist/routes/api/nav-items.js +104 -0
  18. package/dist/routes/api/pages.js +91 -0
  19. package/dist/routes/api/posts.js +2 -2
  20. package/dist/routes/api/search.js +2 -2
  21. package/dist/routes/api/settings.js +68 -0
  22. package/dist/routes/compose.js +48 -0
  23. package/dist/routes/dash/collections.js +2 -2
  24. package/dist/routes/dash/index.js +1 -1
  25. package/dist/routes/dash/media.js +2 -2
  26. package/dist/routes/dash/pages.js +411 -62
  27. package/dist/routes/dash/posts.js +3 -5
  28. package/dist/routes/dash/redirects.js +2 -2
  29. package/dist/routes/dash/settings.js +79 -5
  30. package/dist/routes/feed/rss.js +2 -2
  31. package/dist/routes/feed/sitemap.js +1 -1
  32. package/dist/routes/pages/archive.js +3 -6
  33. package/dist/routes/pages/collection.js +3 -6
  34. package/dist/routes/pages/collections.js +28 -0
  35. package/dist/routes/pages/featured.js +32 -0
  36. package/dist/routes/pages/home.js +9 -50
  37. package/dist/routes/pages/page.js +29 -32
  38. package/dist/routes/pages/post.js +3 -6
  39. package/dist/routes/pages/search.js +3 -6
  40. package/dist/services/page.js +5 -1
  41. package/dist/services/post.js +40 -6
  42. package/dist/services/search.js +1 -1
  43. package/dist/ui/compose/ComposeDialog.js +452 -0
  44. package/dist/ui/compose/ComposePrompt.js +55 -0
  45. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
  46. package/dist/{theme/components → ui/dash}/PostForm.js +0 -27
  47. package/dist/{theme/components → ui/dash}/PostList.js +6 -6
  48. package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
  49. package/dist/{theme/components → ui/dash}/index.js +3 -6
  50. package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
  51. package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
  52. package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
  53. package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
  54. package/dist/ui/feed/TimelineFeed.js +41 -0
  55. package/dist/ui/feed/TimelineItem.js +27 -0
  56. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  57. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  58. package/dist/ui/layouts/SiteLayout.js +141 -0
  59. package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
  60. package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
  61. package/dist/ui/pages/CollectionsPage.js +76 -0
  62. package/dist/ui/pages/FeaturedPage.js +24 -0
  63. package/dist/ui/pages/HomePage.js +24 -0
  64. package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
  65. package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
  66. package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
  67. package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
  68. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  69. package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
  70. package/dist/ui/shared/index.js +5 -0
  71. package/package.json +1 -9
  72. package/src/__tests__/helpers/db.ts +3 -0
  73. package/src/app.tsx +57 -27
  74. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  75. package/src/db/migrations/meta/_journal.json +7 -0
  76. package/src/db/schema.ts +1 -1
  77. package/src/i18n/locales/en.po +332 -181
  78. package/src/i18n/locales/en.ts +1 -1
  79. package/src/i18n/locales/zh-Hans.po +332 -181
  80. package/src/i18n/locales/zh-Hans.ts +1 -1
  81. package/src/i18n/locales/zh-Hant.po +332 -181
  82. package/src/i18n/locales/zh-Hant.ts +1 -1
  83. package/src/index.ts +7 -36
  84. package/src/lib/__tests__/schemas.test.ts +60 -19
  85. package/src/lib/__tests__/timeline.test.ts +45 -81
  86. package/src/lib/__tests__/view.test.ts +13 -7
  87. package/src/lib/constants.ts +1 -0
  88. package/src/lib/nav-reorder.ts +1 -1
  89. package/src/lib/navigation.ts +40 -2
  90. package/src/lib/pagination.ts +50 -0
  91. package/src/lib/render.tsx +7 -14
  92. package/src/lib/schemas.ts +8 -6
  93. package/src/lib/theme.ts +5 -5
  94. package/src/lib/timeline.ts +28 -57
  95. package/src/lib/view.ts +2 -2
  96. package/src/preset.css +2 -1
  97. package/src/routes/__tests__/compose.test.ts +199 -0
  98. package/src/routes/api/__tests__/collections.test.ts +249 -0
  99. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  100. package/src/routes/api/__tests__/pages.test.ts +218 -0
  101. package/src/routes/api/__tests__/settings.test.ts +132 -0
  102. package/src/routes/api/collections.ts +143 -0
  103. package/src/routes/api/nav-items.ts +115 -0
  104. package/src/routes/api/pages.ts +101 -0
  105. package/src/routes/api/posts.ts +2 -2
  106. package/src/routes/api/search.ts +2 -2
  107. package/src/routes/api/settings.ts +91 -0
  108. package/src/routes/compose.ts +63 -0
  109. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  110. package/src/routes/dash/collections.tsx +2 -2
  111. package/src/routes/dash/index.tsx +1 -1
  112. package/src/routes/dash/media.tsx +2 -2
  113. package/src/routes/dash/pages.tsx +443 -70
  114. package/src/routes/dash/posts.tsx +3 -7
  115. package/src/routes/dash/redirects.tsx +2 -2
  116. package/src/routes/dash/settings.tsx +83 -5
  117. package/src/routes/feed/rss.ts +2 -2
  118. package/src/routes/feed/sitemap.ts +1 -1
  119. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  120. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  121. package/src/routes/pages/archive.tsx +2 -6
  122. package/src/routes/pages/collection.tsx +2 -6
  123. package/src/routes/pages/collections.tsx +36 -0
  124. package/src/routes/pages/featured.tsx +38 -0
  125. package/src/routes/pages/home.tsx +9 -55
  126. package/src/routes/pages/page.tsx +28 -30
  127. package/src/routes/pages/post.tsx +2 -5
  128. package/src/routes/pages/search.tsx +2 -6
  129. package/src/services/__tests__/page.test.ts +106 -0
  130. package/src/services/__tests__/post.test.ts +114 -15
  131. package/src/services/page.ts +13 -1
  132. package/src/services/post.ts +57 -7
  133. package/src/services/search.ts +2 -2
  134. package/src/styles/tokens.css +47 -0
  135. package/src/styles/ui.css +491 -0
  136. package/src/types.ts +29 -159
  137. package/src/ui/compose/ComposeDialog.tsx +395 -0
  138. package/src/ui/compose/ComposePrompt.tsx +55 -0
  139. package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
  140. package/src/{theme/components → ui/dash}/PostForm.tsx +0 -25
  141. package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
  142. package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
  143. package/src/ui/dash/index.ts +10 -0
  144. package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
  145. package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
  146. package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
  147. package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
  148. package/src/ui/feed/TimelineFeed.tsx +49 -0
  149. package/src/ui/feed/TimelineItem.tsx +45 -0
  150. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  151. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  152. package/src/ui/layouts/SiteLayout.tsx +150 -0
  153. package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
  154. package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
  155. package/src/ui/pages/CollectionsPage.tsx +73 -0
  156. package/src/ui/pages/FeaturedPage.tsx +31 -0
  157. package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
  158. package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
  159. package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
  160. package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
  161. package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
  162. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  163. package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
  164. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  165. package/src/ui/shared/index.ts +12 -0
  166. package/bin/jant.js +0 -185
  167. package/dist/lib/theme-components.js +0 -46
  168. package/dist/routes/dash/navigation.js +0 -289
  169. package/dist/theme/index.js +0 -18
  170. package/dist/theme/layouts/index.js +0 -2
  171. package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
  172. package/dist/themes/threads/index.js +0 -81
  173. package/dist/themes/threads/pages/HomePage.js +0 -25
  174. package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
  175. package/dist/themes/threads/timeline/TimelineItem.js +0 -36
  176. package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
  177. package/dist/themes/threads/timeline/groupByDate.js +0 -22
  178. package/dist/themes/threads/timeline/timelineMore.js +0 -107
  179. package/src/lib/__tests__/theme-components.test.ts +0 -105
  180. package/src/lib/theme-components.ts +0 -65
  181. package/src/routes/dash/navigation.tsx +0 -317
  182. package/src/theme/components/index.ts +0 -23
  183. package/src/theme/index.ts +0 -22
  184. package/src/theme/layouts/index.ts +0 -7
  185. package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
  186. package/src/themes/threads/index.ts +0 -100
  187. package/src/themes/threads/style.css +0 -336
  188. package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
  189. package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
  190. package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
  191. package/src/themes/threads/timeline/groupByDate.ts +0 -30
  192. package/src/themes/threads/timeline/timelineMore.tsx +0 -130
  193. /package/dist/{theme → ui}/color-themes.js +0 -0
  194. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  195. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  196. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  197. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  198. /package/dist/{theme/components → ui/dash}/PageForm.js +0 -0
  199. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  200. /package/src/{theme → ui}/color-themes.ts +0 -0
  201. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  202. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  203. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  204. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  205. /package/src/{theme/components → ui/dash}/PageForm.tsx +0 -0
  206. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Custom Page Route
3
3
  *
4
- * Serves pages from the pages table and posts with custom slugs.
4
+ * Serves pages from the pages table and posts with custom paths.
5
5
  * This is a catch-all route mounted at "/" - must be registered last.
6
+ * Supports multi-level paths (e.g. /2024/my-post) for posts.
6
7
  */
7
8
 
8
9
  import { Hono } from "hono";
9
10
  import type { Bindings } from "../../types.js";
10
11
  import type { AppVariables } from "../../app.js";
11
- import { SinglePage as DefaultSinglePage } from "../../themes/threads/pages/SinglePage.js";
12
- import { PostPage as DefaultPostPage } from "../../themes/threads/pages/PostPage.js";
12
+ import { SinglePage } from "../../ui/pages/SinglePage.js";
13
+ import { PostPage } from "../../ui/pages/PostPage.js";
13
14
  import { getNavigationData } from "../../lib/navigation.js";
14
15
  import { renderPublicPage } from "../../lib/render.js";
15
16
  import { buildMediaMap } from "../../lib/media-helpers.js";
@@ -19,38 +20,38 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
19
20
 
20
21
  export const pageRoutes = new Hono<Env>();
21
22
 
22
- // Catch-all for custom page paths and post slugs
23
- pageRoutes.get("/:slug", async (c) => {
24
- const slug = c.req.param("slug");
23
+ // Catch-all for custom page slugs and post paths (including multi-level)
24
+ pageRoutes.get("/*", async (c) => {
25
+ const fullPath = c.req.path.slice(1); // Remove leading /
26
+ if (!fullPath) return c.notFound();
25
27
 
26
- // First, try to find a page by slug
27
- const page = await c.var.services.pages.getBySlug(slug);
28
+ const isMultiSegment = fullPath.includes("/");
28
29
 
29
- if (page) {
30
- // Don't show draft pages
31
- if (page.status === "draft") {
32
- return c.notFound();
33
- }
30
+ // Pages only have single-level slugs; skip page lookup for multi-segment paths
31
+ if (!isMultiSegment) {
32
+ const page = await c.var.services.pages.getBySlug(fullPath);
34
33
 
35
- const navData = await getNavigationData(c);
36
- const pageView = toPageView(page);
34
+ if (page) {
35
+ if (page.status === "draft") {
36
+ return c.notFound();
37
+ }
37
38
 
38
- const components = c.var.config.theme?.components;
39
- const Page = components?.SinglePage ?? DefaultSinglePage;
39
+ const navData = await getNavigationData(c);
40
+ const pageView = toPageView(page);
40
41
 
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
- });
42
+ return renderPublicPage(c, {
43
+ title: `${page.title || fullPath} - ${navData.siteName}`,
44
+ description: page.body?.slice(0, 160),
45
+ navData,
46
+ content: <SinglePage page={pageView} />,
47
+ });
48
+ }
47
49
  }
48
50
 
49
- // Then, try to find a post by slug
50
- const post = await c.var.services.posts.getBySlug(slug);
51
+ // Posts support multi-level paths
52
+ const post = await c.var.services.posts.getByPath(fullPath);
51
53
 
52
54
  if (post) {
53
- // Don't show draft posts
54
55
  if (post.status === "draft") {
55
56
  return c.notFound();
56
57
  }
@@ -73,14 +74,11 @@ pageRoutes.get("/:slug", async (c) => {
73
74
  const navData = await getNavigationData(c);
74
75
  const title = post.title || navData.siteName;
75
76
 
76
- const components = c.var.config.theme?.components;
77
- const PostPage = components?.PostPage ?? DefaultPostPage;
78
-
79
77
  return renderPublicPage(c, {
80
78
  title,
81
79
  description: post.body?.slice(0, 160),
82
80
  navData,
83
- content: <PostPage post={postView} theme={components} />,
81
+ content: <PostPage post={postView} />,
84
82
  });
85
83
  }
86
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 { PostPage as DefaultPostPage } from "../../themes/threads/pages/PostPage.js";
8
+ import { PostPage } from "../../ui/pages/PostPage.js";
9
9
  import * as sqid from "../../lib/sqid.js";
10
10
  import { getNavigationData } from "../../lib/navigation.js";
11
11
  import { renderPublicPage } from "../../lib/render.js";
@@ -50,13 +50,10 @@ postRoutes.get("/:id", async (c) => {
50
50
  const navData = await getNavigationData(c);
51
51
  const title = post.title || navData.siteName;
52
52
 
53
- const components = c.var.config.theme?.components;
54
- const Page = components?.PostPage ?? DefaultPostPage;
55
-
56
53
  return renderPublicPage(c, {
57
54
  title,
58
55
  description: post.body?.slice(0, 160),
59
56
  navData,
60
- content: <Page post={postView} theme={components} />,
57
+ content: <PostPage post={postView} />,
61
58
  });
62
59
  });
@@ -5,7 +5,7 @@
5
5
  import { Hono } from "hono";
6
6
  import type { Bindings, SearchResult } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
- import { SearchPage as DefaultSearchPage } from "../../themes/threads/pages/SearchPage.js";
8
+ import { SearchPage } from "../../ui/pages/SearchPage.js";
9
9
  import { getNavigationData } from "../../lib/navigation.js";
10
10
  import { renderPublicPage } from "../../lib/render.js";
11
11
  import { createMediaContext, toSearchResultViews } from "../../lib/view.js";
@@ -52,22 +52,18 @@ searchRoutes.get("/", async (c) => {
52
52
  const mediaCtx = createMediaContext(c);
53
53
  const resultViews = toSearchResultViews(results, mediaCtx);
54
54
 
55
- const components = c.var.config.theme?.components;
56
- const Page = components?.SearchPage ?? DefaultSearchPage;
57
-
58
55
  return renderPublicPage(c, {
59
56
  title: query
60
57
  ? `Search: ${query} - ${navData.siteName}`
61
58
  : `Search - ${navData.siteName}`,
62
59
  navData,
63
60
  content: (
64
- <Page
61
+ <SearchPage
65
62
  query={query}
66
63
  results={resultViews}
67
64
  error={error}
68
65
  hasMore={hasMore}
69
66
  page={page}
70
- theme={components}
71
67
  />
72
68
  ),
73
69
  });
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { createPageService } from "../page.js";
4
+ import { createNavItemService } from "../navigation.js";
5
+ import type { Database } from "../../db/index.js";
6
+
7
+ describe("PageService", () => {
8
+ let db: Database;
9
+ let pageService: ReturnType<typeof createPageService>;
10
+ let navItemService: ReturnType<typeof createNavItemService>;
11
+
12
+ beforeEach(() => {
13
+ const testDb = createTestDatabase();
14
+ db = testDb.db as unknown as Database;
15
+ pageService = createPageService(db);
16
+ navItemService = createNavItemService(db);
17
+ });
18
+
19
+ describe("listNotInNav", () => {
20
+ it("returns all pages when none are in navigation", async () => {
21
+ await pageService.create({ slug: "about", title: "About" });
22
+ await pageService.create({ slug: "contact", title: "Contact" });
23
+
24
+ const pages = await pageService.listNotInNav();
25
+ expect(pages).toHaveLength(2);
26
+ });
27
+
28
+ it("excludes pages that have a nav item", async () => {
29
+ const aboutPage = await pageService.create({
30
+ slug: "about",
31
+ title: "About",
32
+ });
33
+ await pageService.create({ slug: "contact", title: "Contact" });
34
+
35
+ // Add "About" to navigation
36
+ await navItemService.create({
37
+ type: "page",
38
+ label: "About",
39
+ url: "/about",
40
+ pageId: aboutPage.id,
41
+ });
42
+
43
+ const pages = await pageService.listNotInNav();
44
+ expect(pages).toHaveLength(1);
45
+ expect(pages[0]?.slug).toBe("contact");
46
+ });
47
+
48
+ it("returns empty array when all pages are in navigation", async () => {
49
+ const aboutPage = await pageService.create({
50
+ slug: "about",
51
+ title: "About",
52
+ });
53
+
54
+ await navItemService.create({
55
+ type: "page",
56
+ label: "About",
57
+ url: "/about",
58
+ pageId: aboutPage.id,
59
+ });
60
+
61
+ const pages = await pageService.listNotInNav();
62
+ expect(pages).toHaveLength(0);
63
+ });
64
+
65
+ it("returns empty array when no pages exist", async () => {
66
+ const pages = await pageService.listNotInNav();
67
+ expect(pages).toHaveLength(0);
68
+ });
69
+
70
+ it("is not affected by link-type nav items (no pageId)", async () => {
71
+ await pageService.create({ slug: "about", title: "About" });
72
+
73
+ // Link-type nav items have no pageId
74
+ await navItemService.create({
75
+ type: "link",
76
+ label: "External",
77
+ url: "https://example.com",
78
+ });
79
+
80
+ const pages = await pageService.listNotInNav();
81
+ expect(pages).toHaveLength(1);
82
+ });
83
+
84
+ it("returns multiple pages correctly", async () => {
85
+ await pageService.create({ slug: "first", title: "First" });
86
+ await pageService.create({ slug: "second", title: "Second" });
87
+ await pageService.create({ slug: "third", title: "Third" });
88
+
89
+ // Add one to nav
90
+ const pages = await pageService.list();
91
+ await navItemService.create({
92
+ type: "page",
93
+ label: "Second",
94
+ url: "/second",
95
+ pageId: pages.find((p) => p.slug === "second")!.id,
96
+ });
97
+
98
+ const notInNav = await pageService.listNotInNav();
99
+ expect(notInNav).toHaveLength(2);
100
+ const slugs = notInNav.map((p) => p.slug);
101
+ expect(slugs).toContain("first");
102
+ expect(slugs).toContain("third");
103
+ expect(slugs).not.toContain("second");
104
+ });
105
+ });
106
+ });
@@ -38,7 +38,7 @@ describe("PostService", () => {
38
38
  status: "published",
39
39
  featured: true,
40
40
  pinned: true,
41
- slug: "my-link",
41
+ path: "my-link",
42
42
  url: "https://example.com/source",
43
43
  quoteText: "A notable quote",
44
44
  rating: 5,
@@ -49,7 +49,7 @@ describe("PostService", () => {
49
49
  expect(post.status).toBe("published");
50
50
  expect(post.featured).toBe(1);
51
51
  expect(post.pinned).toBe(1);
52
- expect(post.slug).toBe("my-link");
52
+ expect(post.path).toBe("my-link");
53
53
  expect(post.url).toBe("https://example.com/source");
54
54
  expect(post.quoteText).toBe("A notable quote");
55
55
  expect(post.rating).toBe(5);
@@ -154,21 +154,21 @@ describe("PostService", () => {
154
154
  });
155
155
  });
156
156
 
157
- describe("getBySlug", () => {
158
- it("returns a post by slug", async () => {
157
+ describe("getByPath", () => {
158
+ it("returns a post by path", async () => {
159
159
  await postService.create({
160
160
  format: "note",
161
161
  body: "About page",
162
- slug: "about",
162
+ path: "about",
163
163
  });
164
164
 
165
- const found = await postService.getBySlug("about");
165
+ const found = await postService.getByPath("about");
166
166
  expect(found).not.toBeNull();
167
- expect(found?.slug).toBe("about");
167
+ expect(found?.path).toBe("about");
168
168
  });
169
169
 
170
- it("returns null for non-existent slug", async () => {
171
- const found = await postService.getBySlug("nonexistent");
170
+ it("returns null for non-existent path", async () => {
171
+ const found = await postService.getByPath("nonexistent");
172
172
  expect(found).toBeNull();
173
173
  });
174
174
 
@@ -176,13 +176,25 @@ describe("PostService", () => {
176
176
  const post = await postService.create({
177
177
  format: "note",
178
178
  body: "test",
179
- slug: "test-page",
179
+ path: "test-page",
180
180
  });
181
181
  await postService.delete(post.id);
182
182
 
183
- const found = await postService.getBySlug("test-page");
183
+ const found = await postService.getByPath("test-page");
184
184
  expect(found).toBeNull();
185
185
  });
186
+
187
+ it("finds a post with a multi-level path", async () => {
188
+ await postService.create({
189
+ format: "note",
190
+ body: "Blog migration",
191
+ path: "2024/01/my-post",
192
+ });
193
+
194
+ const found = await postService.getByPath("2024/01/my-post");
195
+ expect(found).not.toBeNull();
196
+ expect(found?.path).toBe("2024/01/my-post");
197
+ });
186
198
  });
187
199
 
188
200
  describe("list", () => {
@@ -358,6 +370,93 @@ describe("PostService", () => {
358
370
  expect(posts).toHaveLength(1);
359
371
  expect(posts[0]?.body).toBe("root post");
360
372
  });
373
+
374
+ it("supports offset pagination", async () => {
375
+ for (let i = 0; i < 5; i++) {
376
+ await postService.create({
377
+ format: "note",
378
+ body: `post ${i}`,
379
+ publishedAt: 1000 + i,
380
+ });
381
+ }
382
+
383
+ // Skip the first 2 posts (newest), get 2 more
384
+ const posts = await postService.list({ limit: 2, offset: 2 });
385
+ expect(posts).toHaveLength(2);
386
+ expect(posts[0]?.body).toBe("post 2");
387
+ expect(posts[1]?.body).toBe("post 1");
388
+ });
389
+ });
390
+
391
+ describe("count", () => {
392
+ it("returns 0 when no posts exist", async () => {
393
+ const count = await postService.count();
394
+ expect(count).toBe(0);
395
+ });
396
+
397
+ it("counts all non-deleted posts", async () => {
398
+ await postService.create({ format: "note", body: "first" });
399
+ await postService.create({ format: "note", body: "second" });
400
+ await postService.create({ format: "note", body: "third" });
401
+
402
+ const count = await postService.count();
403
+ expect(count).toBe(3);
404
+ });
405
+
406
+ it("filters by status", async () => {
407
+ await postService.create({
408
+ format: "note",
409
+ body: "published",
410
+ status: "published",
411
+ });
412
+ await postService.create({
413
+ format: "note",
414
+ body: "draft",
415
+ status: "draft",
416
+ });
417
+
418
+ const count = await postService.count({ status: "published" });
419
+ expect(count).toBe(1);
420
+ });
421
+
422
+ it("filters by featured", async () => {
423
+ await postService.create({
424
+ format: "note",
425
+ body: "featured",
426
+ featured: true,
427
+ });
428
+ await postService.create({ format: "note", body: "normal" });
429
+
430
+ const count = await postService.count({ featured: true });
431
+ expect(count).toBe(1);
432
+ });
433
+
434
+ it("excludes deleted posts by default", async () => {
435
+ const post = await postService.create({
436
+ format: "note",
437
+ body: "to delete",
438
+ });
439
+ await postService.create({ format: "note", body: "keep" });
440
+ await postService.delete(post.id);
441
+
442
+ const count = await postService.count();
443
+ expect(count).toBe(1);
444
+ });
445
+
446
+ it("excludes replies when requested", async () => {
447
+ const root = await postService.create({
448
+ format: "note",
449
+ body: "root",
450
+ });
451
+ await postService.create({
452
+ format: "note",
453
+ body: "reply",
454
+ replyToId: root.id,
455
+ });
456
+
457
+ const count = await postService.count({ excludeReplies: true });
458
+ expect(count).toBe(1);
459
+ });
361
460
  });
362
461
 
363
462
  describe("update", () => {
@@ -471,18 +570,18 @@ describe("PostService", () => {
471
570
  expect(updated?.pinned).toBe(1);
472
571
  });
473
572
 
474
- it("updates slug", async () => {
573
+ it("updates path", async () => {
475
574
  const post = await postService.create({
476
575
  format: "note",
477
576
  body: "test",
478
- slug: "old-slug",
577
+ path: "old-path",
479
578
  });
480
579
 
481
580
  const updated = await postService.update(post.id, {
482
- slug: "new-slug",
581
+ path: "new-path",
483
582
  });
484
583
 
485
- expect(updated?.slug).toBe("new-slug");
584
+ expect(updated?.path).toBe("new-path");
486
585
  });
487
586
 
488
587
  it("updates quoteText and rating", async () => {
@@ -4,7 +4,7 @@
4
4
  * CRUD operations for standalone pages (about, now, etc.)
5
5
  */
6
6
 
7
- import { eq, desc } from "drizzle-orm";
7
+ import { eq, desc, sql } from "drizzle-orm";
8
8
  import type { Database } from "../db/index.js";
9
9
  import { pages, navItems } from "../db/schema.js";
10
10
  import { now } from "../lib/time.js";
@@ -15,6 +15,7 @@ export interface PageService {
15
15
  getById(id: number): Promise<Page | null>;
16
16
  getBySlug(slug: string): Promise<Page | null>;
17
17
  list(): Promise<Page[]>;
18
+ listNotInNav(): Promise<Page[]>;
18
19
  create(data: CreatePage): Promise<Page>;
19
20
  update(id: number, data: UpdatePage): Promise<Page | null>;
20
21
  delete(id: number): Promise<boolean>;
@@ -58,6 +59,17 @@ export function createPageService(db: Database): PageService {
58
59
  return rows.map(toPage);
59
60
  },
60
61
 
62
+ async listNotInNav() {
63
+ const rows = await db
64
+ .select()
65
+ .from(pages)
66
+ .where(
67
+ sql`${pages.id} NOT IN (SELECT ${navItems.pageId} FROM ${navItems} WHERE ${navItems.pageId} IS NOT NULL)`,
68
+ )
69
+ .orderBy(desc(pages.createdAt));
70
+ return rows.map(toPage);
71
+ },
72
+
61
73
  async create(data) {
62
74
  const timestamp = now();
63
75
 
@@ -25,12 +25,15 @@ export interface PostFilters {
25
25
  threadId?: number;
26
26
  limit?: number;
27
27
  cursor?: number; // post id for cursor pagination
28
+ offset?: number; // offset for page-based pagination
28
29
  }
29
30
 
30
31
  export interface PostService {
31
32
  getById(id: number): Promise<Post | null>;
32
- getBySlug(slug: string): Promise<Post | null>;
33
+ getByPath(path: string): Promise<Post | null>;
33
34
  list(filters?: PostFilters): Promise<Post[]>;
35
+ /** Count posts matching filters (ignores cursor, offset, limit) */
36
+ count(filters?: PostFilters): Promise<number>;
34
37
  create(data: CreatePost): Promise<Post>;
35
38
  update(id: number, data: UpdatePost): Promise<Post | null>;
36
39
  delete(id: number): Promise<boolean>;
@@ -57,7 +60,7 @@ export function createPostService(db: Database): PostService {
57
60
  status: row.status as Status,
58
61
  featured: row.featured,
59
62
  pinned: row.pinned,
60
- slug: row.slug,
63
+ path: row.path,
61
64
  title: row.title,
62
65
  url: row.url,
63
66
  body: row.body,
@@ -84,11 +87,11 @@ export function createPostService(db: Database): PostService {
84
87
  return result[0] ? toPost(result[0]) : null;
85
88
  },
86
89
 
87
- async getBySlug(slug) {
90
+ async getByPath(path) {
88
91
  const result = await db
89
92
  .select()
90
93
  .from(posts)
91
- .where(and(eq(posts.slug, slug), isNull(posts.deletedAt)))
94
+ .where(and(eq(posts.path, path), isNull(posts.deletedAt)))
92
95
  .limit(1);
93
96
  return result[0] ? toPost(result[0]) : null;
94
97
  },
@@ -132,17 +135,64 @@ export function createPostService(db: Database): PostService {
132
135
  conditions.push(sql`${posts.id} < ${filters.cursor}`);
133
136
  }
134
137
 
135
- const query = db
138
+ let query = db
136
139
  .select()
137
140
  .from(posts)
138
141
  .where(conditions.length > 0 ? and(...conditions) : undefined)
139
142
  .orderBy(desc(posts.publishedAt), desc(posts.id))
140
143
  .limit(filters.limit ?? 100);
141
144
 
145
+ if (filters.offset !== undefined) {
146
+ query = query.offset(filters.offset) as typeof query;
147
+ }
148
+
142
149
  const rows = await query;
143
150
  return rows.map(toPost);
144
151
  },
145
152
 
153
+ async count(filters = {}) {
154
+ const conditions = [];
155
+
156
+ if (filters.status) {
157
+ conditions.push(eq(posts.status, filters.status));
158
+ }
159
+
160
+ if (filters.featured !== undefined) {
161
+ conditions.push(eq(posts.featured, filters.featured ? 1 : 0));
162
+ }
163
+
164
+ if (filters.pinned !== undefined) {
165
+ conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
166
+ }
167
+
168
+ if (filters.format) {
169
+ conditions.push(eq(posts.format, filters.format));
170
+ }
171
+
172
+ if (filters.collectionId !== undefined) {
173
+ conditions.push(eq(posts.collectionId, filters.collectionId));
174
+ }
175
+
176
+ if (filters.threadId) {
177
+ conditions.push(eq(posts.threadId, filters.threadId));
178
+ }
179
+
180
+ if (filters.excludeReplies) {
181
+ conditions.push(isNull(posts.threadId));
182
+ }
183
+
184
+ if (!filters.includeDeleted) {
185
+ conditions.push(isNull(posts.deletedAt));
186
+ }
187
+
188
+ const result = await db
189
+ .select({ count: sql<number>`count(*)`.as("count") })
190
+ .from(posts)
191
+ .where(conditions.length > 0 ? and(...conditions) : undefined);
192
+
193
+ return result[0]?.count ?? 0;
194
+ },
195
+
146
196
  async create(data) {
147
197
  const timestamp = now();
148
198
 
@@ -175,7 +225,7 @@ export function createPostService(db: Database): PostService {
175
225
  status,
176
226
  featured: featured ? 1 : 0,
177
227
  pinned: data.pinned ? 1 : 0,
178
- slug: data.slug ?? null,
228
+ path: data.path ?? null,
179
229
  title: data.title ?? null,
180
230
  url: data.url ?? null,
181
231
  body: data.body ?? null,
@@ -205,7 +255,7 @@ export function createPostService(db: Database): PostService {
205
255
  };
206
256
 
207
257
  if (data.format !== undefined) updates.format = data.format;
208
- if (data.slug !== undefined) updates.slug = data.slug;
258
+ if (data.path !== undefined) updates.path = data.path;
209
259
  if (data.title !== undefined) updates.title = data.title;
210
260
  if (data.url !== undefined) updates.url = data.url;
211
261
  if (data.quoteText !== undefined) updates.quoteText = data.quoteText;
@@ -29,7 +29,7 @@ interface RawSearchRow {
29
29
  status: string;
30
30
  featured: number;
31
31
  pinned: number;
32
- slug: string | null;
32
+ path: string | null;
33
33
  title: string | null;
34
34
  url: string | null;
35
35
  body: string | null;
@@ -99,7 +99,7 @@ export function createSearchService(d1: D1Database): SearchService {
99
99
  status: row.status as Post["status"],
100
100
  featured: row.featured,
101
101
  pinned: row.pinned,
102
- slug: row.slug,
102
+ path: row.path,
103
103
  title: row.title,
104
104
  url: row.url,
105
105
  body: row.body,
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Design Tokens
3
+ *
4
+ * CSS custom properties for all visual aspects of the UI.
5
+ * These are the stable customization API — override in custom CSS
6
+ * to change typography, layout, surfaces, and element sizing.
7
+ */
8
+
9
+ :root {
10
+ /* Typography */
11
+ --font-body: system-ui, sans-serif;
12
+ --font-heading: var(--font-body);
13
+ --font-mono: ui-monospace, monospace;
14
+ --text-sm: 0.8125rem;
15
+ --text-base: 0.9375rem;
16
+ --text-lg: 1.0625rem;
17
+ --leading: 1.5;
18
+
19
+ /* Layout */
20
+ --site-width: 640px;
21
+ --site-padding: 1.5rem;
22
+ --content-gap: 1rem;
23
+ --space-xl: 2rem;
24
+
25
+ /* Surfaces */
26
+ --card-bg: var(--card);
27
+ --card-radius: 0;
28
+ --card-padding: 1rem;
29
+ --card-border-width: 0;
30
+ --card-shadow: none;
31
+
32
+ /* Elements */
33
+ --avatar-size: 36px;
34
+ --avatar-radius: 50%;
35
+ --media-radius: 0.5rem;
36
+
37
+ /* Derived color tokens (from BaseCoat variables) */
38
+ --site-column-outline: var(--border);
39
+ --site-threadline: var(--border);
40
+ --site-page-bg: var(--background);
41
+ --site-elevated-bg: var(--background);
42
+ --site-nav-hover-bg: var(--accent);
43
+ --site-text-primary: var(--foreground);
44
+ --site-text-secondary: var(--muted-foreground);
45
+ --site-media-outline: var(--border);
46
+ --site-divider: var(--border);
47
+ }