@jant/core 0.3.42 → 0.3.44

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 (99) hide show
  1. package/bin/commands/import-site.js +1 -1
  2. package/bin/commands/search-reindex.js +175 -0
  3. package/bin/lib/hugo-markdown.js +102 -0
  4. package/bin/lib/site-pull-media.js +1 -4
  5. package/dist/app-BI9bnCkO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-CtJDxZBb.js} +969 -373
  7. package/dist/client/.vite/manifest.json +2 -2
  8. package/dist/client/_assets/client-BQH7AQ24.css +2 -0
  9. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  10. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  11. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  12. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  13. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +5 -5
  16. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  17. package/package.json +1 -1
  18. package/src/__tests__/helpers/app.ts +15 -4
  19. package/src/app.tsx +8 -0
  20. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  21. package/src/client/tiptap/extensions.ts +3 -0
  22. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  23. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  24. package/src/db/migrations/0019_bored_magus.sql +2 -0
  25. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  26. package/src/db/migrations/meta/0019_snapshot.json +2238 -0
  27. package/src/db/migrations/meta/_journal.json +15 -1
  28. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  29. package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
  30. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  31. package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
  32. package/src/db/migrations/pg/meta/_journal.json +15 -1
  33. package/src/db/pg/schema.ts +22 -0
  34. package/src/db/schema.ts +27 -0
  35. package/src/index.ts +1 -2
  36. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  37. package/src/lib/__tests__/navigation.test.ts +4 -20
  38. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  39. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  40. package/src/lib/__tests__/summary.test.ts +140 -0
  41. package/src/lib/__tests__/view.test.ts +66 -0
  42. package/src/lib/feed.ts +70 -34
  43. package/src/lib/hosted-signin.ts +9 -3
  44. package/src/lib/icons.ts +37 -0
  45. package/src/lib/navigation.ts +11 -12
  46. package/src/lib/post-meta.ts +20 -2
  47. package/src/lib/rate-limit-d1.ts +99 -0
  48. package/src/lib/rate-limit-memory.ts +105 -0
  49. package/src/lib/rate-limit.ts +63 -0
  50. package/src/lib/render.tsx +9 -0
  51. package/src/lib/resolve-config.ts +9 -0
  52. package/src/lib/summary.ts +42 -7
  53. package/src/lib/url.ts +34 -0
  54. package/src/lib/view.ts +42 -8
  55. package/src/middleware/__tests__/auth.test.ts +44 -4
  56. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  57. package/src/middleware/__tests__/session.test.ts +85 -0
  58. package/src/middleware/auth.ts +62 -25
  59. package/src/middleware/rate-limit.ts +54 -0
  60. package/src/middleware/session.ts +36 -0
  61. package/src/routes/__tests__/compose.test.ts +1 -1
  62. package/src/routes/api/__tests__/search.test.ts +48 -0
  63. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  64. package/src/routes/api/internal/search-reindex.ts +40 -0
  65. package/src/routes/api/internal/sites.ts +1 -0
  66. package/src/routes/api/search.ts +13 -0
  67. package/src/routes/auth/dev.ts +1 -1
  68. package/src/routes/auth/signin.tsx +23 -5
  69. package/src/routes/dash/settings.tsx +3 -5
  70. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  71. package/src/routes/feed/sitemap.ts +208 -33
  72. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  73. package/src/routes/pages/home.tsx +24 -15
  74. package/src/routes/pages/page.tsx +34 -0
  75. package/src/routes/pages/partials.tsx +4 -15
  76. package/src/runtime/cloudflare.ts +4 -0
  77. package/src/runtime/node.ts +16 -0
  78. package/src/services/__tests__/post.test.ts +205 -0
  79. package/src/services/__tests__/search.test.ts +44 -0
  80. package/src/services/__tests__/site-admin.test.ts +85 -0
  81. package/src/services/export.ts +9 -2
  82. package/src/services/post.ts +200 -2
  83. package/src/services/site-admin.ts +66 -1
  84. package/src/styles/ui.css +12 -0
  85. package/src/types/app-context.ts +20 -0
  86. package/src/types/config.ts +8 -0
  87. package/src/types/props.ts +0 -7
  88. package/src/ui/feed/LinkCard.tsx +3 -20
  89. package/src/ui/feed/LinkPreview.tsx +5 -19
  90. package/src/ui/feed/PostStatusBadges.tsx +4 -38
  91. package/src/ui/layouts/BaseLayout.tsx +23 -29
  92. package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
  93. package/src/ui/shared/Icon.tsx +60 -0
  94. package/src/ui/shared/IconSprite.tsx +57 -0
  95. package/src/ui/shared/PostFooter.tsx +6 -62
  96. package/src/ui/shared/custom-icons.ts +132 -0
  97. package/src/ui/shared/icon-collector.ts +37 -0
  98. package/dist/app-DzCB4yOp.js +0 -5
  99. package/dist/client/_assets/client-C_kImWZj.css +0 -2
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Tests for the `<link rel="canonical">` tag on post pages.
3
+ *
4
+ * Reply URLs render the full thread, so each reply URL would otherwise look
5
+ * like duplicate content to crawlers. The canonical tag points every page in
6
+ * the thread back to the thread root.
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
11
+ import { pageRoutes } from "../page.js";
12
+
13
+ function createPageTestApp() {
14
+ const testApp = createTestApp();
15
+ const { app } = testApp;
16
+
17
+ app.use("*", async (c, next) => {
18
+ c.set("publicPath", c.req.path);
19
+ c.set("publicRequestUrl", c.req.url);
20
+ await next();
21
+ });
22
+
23
+ app.route("/", pageRoutes);
24
+
25
+ return testApp;
26
+ }
27
+
28
+ function extractCanonicalHref(html: string): string | null {
29
+ const match = html.match(/<link\s+rel="canonical"\s+href="([^"]+)"\s*\/?>/i);
30
+ return match?.[1] ?? null;
31
+ }
32
+
33
+ describe("Post page canonical link", () => {
34
+ it("root post canonical points at its own permalink", async () => {
35
+ const { app, services } = createPageTestApp();
36
+
37
+ const root = await services.posts.create({
38
+ format: "note",
39
+ title: "Root post",
40
+ bodyMarkdown: "Root body",
41
+ status: "published",
42
+ });
43
+
44
+ const res = await app.request(`/${root.slug}`);
45
+ expect(res.status).toBe(200);
46
+
47
+ const html = await res.text();
48
+ const canonical = extractCanonicalHref(html);
49
+ expect(canonical).not.toBeNull();
50
+ expect(canonical).toMatch(new RegExp(`/${root.slug}$`));
51
+ });
52
+
53
+ it("reply canonical points back to the thread root", async () => {
54
+ const { app, services } = createPageTestApp();
55
+
56
+ const root = await services.posts.create({
57
+ format: "note",
58
+ title: "Thread root",
59
+ bodyMarkdown: "Root body",
60
+ status: "published",
61
+ });
62
+ const reply = await services.posts.create({
63
+ format: "note",
64
+ bodyMarkdown: "Reply body",
65
+ replyToId: root.id,
66
+ status: "published",
67
+ });
68
+
69
+ // Visiting the reply URL should canonicalize to the root URL.
70
+ const replyRes = await app.request(`/${reply.slug}`);
71
+ expect(replyRes.status).toBe(200);
72
+
73
+ const replyHtml = await replyRes.text();
74
+ const replyCanonical = extractCanonicalHref(replyHtml);
75
+ expect(replyCanonical).not.toBeNull();
76
+ expect(replyCanonical).toMatch(new RegExp(`/${root.slug}$`));
77
+ expect(replyCanonical).not.toMatch(new RegExp(`/${reply.slug}$`));
78
+
79
+ // And the root URL should canonicalize to itself.
80
+ const rootRes = await app.request(`/${root.slug}`);
81
+ const rootCanonical = extractCanonicalHref(await rootRes.text());
82
+ expect(rootCanonical).toMatch(new RegExp(`/${root.slug}$`));
83
+ });
84
+
85
+ it("canonical is absolute when siteUrl is configured", async () => {
86
+ const { app, services } = createPageTestApp();
87
+
88
+ const post = await services.posts.create({
89
+ format: "note",
90
+ title: "Absolute test",
91
+ bodyMarkdown: "Body",
92
+ status: "published",
93
+ });
94
+
95
+ const res = await app.request(`/${post.slug}`);
96
+ const canonical = extractCanonicalHref(await res.text());
97
+ // SITE_ORIGIN in the test harness is http://localhost:<port>
98
+ expect(canonical).toMatch(/^https?:\/\//);
99
+ expect(canonical).toContain(`/${post.slug}`);
100
+ });
101
+ });
@@ -12,7 +12,10 @@ import { Hono } from "hono";
12
12
  import { msg } from "@lingui/core/macro";
13
13
  import type { Bindings } from "../../types.js";
14
14
  import type { AppVariables } from "../../types/app-context.js";
15
- import { getNavigationData } from "../../lib/navigation.js";
15
+ import {
16
+ getHomeDefaultViewFromNavItems,
17
+ getNavigationData,
18
+ } from "../../lib/navigation.js";
16
19
  import { getI18n } from "../../i18n/index.js";
17
20
  import { formatPageLabel, parsePageNumber } from "../../lib/pagination.js";
18
21
  import { buildPageTitle } from "../../lib/page-title.js";
@@ -30,25 +33,36 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
30
33
  export const homeRoutes = new Hono<Env>();
31
34
 
32
35
  homeRoutes.get("/", async (c) => {
33
- const navData = await getNavigationData(c);
34
36
  const i18n = getI18n(c);
35
37
  const page = parsePageNumber(c.req.query("page"));
36
38
  const paginatedPageTitle = formatPageLabel(page);
39
+ const isAuthenticated = c.var.isAuthenticated;
40
+
41
+ // Fetch nav items once — we need `homeDefaultView` to decide which timeline
42
+ // to assemble, but `getNavigationData` also consumes them. Passing them
43
+ // through avoids a duplicate DB query and unlocks the Promise.all below.
44
+ const navItems = await c.var.services.navItems.list();
45
+ const homeDefaultView = getHomeDefaultViewFromNavItems(navItems);
46
+
47
+ const timelinePromise =
48
+ homeDefaultView === "featured"
49
+ ? assembleFeaturedTimeline(c, { page, isAuthenticated })
50
+ : assembleTimeline(c, { page, isAuthenticated });
51
+
52
+ const [navData, timeline] = await Promise.all([
53
+ getNavigationData(c, { preloadedItems: navItems }),
54
+ timelinePromise,
55
+ ]);
37
56
 
38
- if (navData.homeDefaultView === "featured") {
57
+ const { items, currentPage, totalPages } = timeline;
58
+
59
+ if (homeDefaultView === "featured") {
39
60
  const featuredTitle = i18n._(
40
61
  msg({
41
62
  message: "Featured",
42
63
  comment: "@context: Browser page title for the featured feed",
43
64
  }),
44
65
  );
45
- const { items, currentPage, totalPages } = await assembleFeaturedTimeline(
46
- c,
47
- {
48
- page,
49
- isAuthenticated: navData.isAuthenticated,
50
- },
51
- );
52
66
 
53
67
  return renderPublicPage(c, {
54
68
  title:
@@ -76,11 +90,6 @@ homeRoutes.get("/", async (c) => {
76
90
  }),
77
91
  );
78
92
 
79
- const { items, currentPage, totalPages } = await assembleTimeline(c, {
80
- page,
81
- isAuthenticated: navData.isAuthenticated,
82
- });
83
-
84
93
  return renderPublicPage(c, {
85
94
  title:
86
95
  page > 1
@@ -33,6 +33,28 @@ interface TextPreviewAutoOpen {
33
33
  mediaId: string;
34
34
  }
35
35
 
36
+ /**
37
+ * Build the canonical absolute URL for a post page.
38
+ *
39
+ * Reply URLs render the full thread, so search engines see overlapping
40
+ * content at every reply URL. Point the canonical to the thread root so
41
+ * crawlers consolidate ranking on one URL.
42
+ *
43
+ * The root post is always at index 0 of `threadPostViews` (getThread orders
44
+ * by createdAt ASC, and the DB check constraint guarantees root has the
45
+ * smallest createdAt in its thread). When `threadPostViews` is undefined the
46
+ * post is not part of a multi-post thread, so the post itself is the root.
47
+ */
48
+ function buildPostCanonicalHref(
49
+ postView: { permalink: string },
50
+ threadPostViews: Array<{ permalink: string }> | undefined,
51
+ siteUrl: string,
52
+ ): string {
53
+ const rootPermalink = threadPostViews?.[0]?.permalink ?? postView.permalink;
54
+ if (!siteUrl) return rootPermalink;
55
+ return new URL(rootPermalink, siteUrl).toString();
56
+ }
57
+
36
58
  async function renderPostWithTextPreview(
37
59
  c: Context<Env>,
38
60
  post: Post,
@@ -48,6 +70,11 @@ async function renderPostWithTextPreview(
48
70
 
49
71
  const navData = await navDataPromise;
50
72
  const meta = buildPostMeta(post, navData.siteName);
73
+ const canonicalHref = buildPostCanonicalHref(
74
+ display.postView,
75
+ display.threadPostViews,
76
+ c.var.appConfig.siteUrl,
77
+ );
51
78
 
52
79
  // Use the attachment summary as the page title (for OG/link previews),
53
80
  // and pass the post title in the payload so the client can restore it
@@ -68,6 +95,7 @@ async function renderPostWithTextPreview(
68
95
  return renderPublicPage(c, {
69
96
  title: pageTitle,
70
97
  description: meta.description,
98
+ canonicalHref,
71
99
  navData,
72
100
  content: (
73
101
  <>
@@ -127,10 +155,16 @@ async function renderPost(c: Context<Env>, post: Post) {
127
155
 
128
156
  const navData = await navDataPromise;
129
157
  const meta = buildPostMeta(post, navData.siteName);
158
+ const canonicalHref = buildPostCanonicalHref(
159
+ display.postView,
160
+ display.threadPostViews,
161
+ c.var.appConfig.siteUrl,
162
+ );
130
163
 
131
164
  return renderPublicPage(c, {
132
165
  title: meta.title,
133
166
  description: meta.description,
167
+ canonicalHref,
134
168
  navData,
135
169
  content: (
136
170
  <PostPage post={display.postView} threadPosts={display.threadPostViews} />
@@ -1,4 +1,4 @@
1
- import { Hono, type Context } from "hono";
1
+ import { Hono } from "hono";
2
2
  import { I18nProvider } from "../../i18n/index.js";
3
3
  import { parseIdParam } from "../../lib/errors.js";
4
4
  import { ID_PREFIX } from "../../lib/ids.js";
@@ -18,17 +18,6 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
18
18
 
19
19
  export const partialPageRoutes = new Hono<Env>();
20
20
 
21
- async function getIsAuthenticated(c: Context<Env>): Promise<boolean> {
22
- try {
23
- const session = await c.var.auth.api.getSession({
24
- headers: c.req.raw.headers,
25
- });
26
- return !!session?.user;
27
- } catch {
28
- return false;
29
- }
30
- }
31
-
32
21
  partialPageRoutes.get("/_/version", (c) => {
33
22
  return c.json({ version: CORE_VERSION });
34
23
  });
@@ -39,7 +28,7 @@ partialPageRoutes.get("/_/timeline-item/:threadRootId", async (c) => {
39
28
  ID_PREFIX.post,
40
29
  );
41
30
  const item = await assembleTimelineItem(c, threadRootId, {
42
- isAuthenticated: await getIsAuthenticated(c),
31
+ isAuthenticated: c.var.isAuthenticated,
43
32
  });
44
33
 
45
34
  if (!item) {
@@ -56,7 +45,7 @@ partialPageRoutes.get("/_/timeline-item/:threadRootId", async (c) => {
56
45
  partialPageRoutes.get("/_/post-card/:postId", async (c) => {
57
46
  const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
58
47
  const postView = await assemblePostCardView(c, postId, {
59
- isAuthenticated: await getIsAuthenticated(c),
48
+ isAuthenticated: c.var.isAuthenticated,
60
49
  });
61
50
 
62
51
  if (!postView) {
@@ -73,7 +62,7 @@ partialPageRoutes.get("/_/post-card/:postId", async (c) => {
73
62
  partialPageRoutes.get("/_/post-view/:postId", async (c) => {
74
63
  const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
75
64
  const display = await assemblePostPageDisplay(c, postId, {
76
- isAuthenticated: await getIsAuthenticated(c),
65
+ isAuthenticated: c.var.isAuthenticated,
77
66
  });
78
67
 
79
68
  if (!display) {
@@ -10,6 +10,8 @@ import {
10
10
  shouldUseSecureCookies,
11
11
  } from "../lib/env.js";
12
12
  import { createHostedControlPlaneClient } from "../lib/hosted-control-plane.js";
13
+ import { createD1RateLimiter } from "../lib/rate-limit-d1.js";
14
+ import type { RateLimiter } from "../lib/rate-limit.js";
13
15
  import { createStorageDriver, type StorageDriver } from "../lib/storage.js";
14
16
  import {
15
17
  createHostedHandoffService,
@@ -30,6 +32,7 @@ export interface CloudflareRequestRuntime {
30
32
  currentSiteDomain: SiteDomain | null;
31
33
  db: Database;
32
34
  hostedHandoff: HostedHandoffService;
35
+ rateLimiter: RateLimiter;
33
36
  services: Services;
34
37
  storage: StorageDriver | null;
35
38
  }
@@ -88,6 +91,7 @@ export async function createCloudflareRequestRuntime(
88
91
  schema: sqliteSchemaBundle,
89
92
  secret: hostedControlPlaneSsoSecret,
90
93
  }),
94
+ rateLimiter: createD1RateLimiter(db, sqliteSchemaBundle),
91
95
  services: createServices(db, session, siteLookup.site.id, {
92
96
  databaseDialect: "sqlite",
93
97
  bootstrapSite: getSingleSiteBootstrapOptions(env),
@@ -12,6 +12,8 @@ import {
12
12
  shouldUseSecureCookies,
13
13
  } from "../lib/env.js";
14
14
  import { createHostedControlPlaneClient } from "../lib/hosted-control-plane.js";
15
+ import { createMemoryRateLimiter } from "../lib/rate-limit-memory.js";
16
+ import type { RateLimiter } from "../lib/rate-limit.js";
15
17
  import { createStorageDriver, type StorageDriver } from "../lib/storage.js";
16
18
  import {
17
19
  createHostedHandoffService,
@@ -33,10 +35,23 @@ export interface NodeRequestRuntime {
33
35
  currentSiteDomain: SiteDomain | null;
34
36
  db: Database;
35
37
  hostedHandoff: HostedHandoffService;
38
+ rateLimiter: RateLimiter;
36
39
  services: Services;
37
40
  storage: StorageDriver | null;
38
41
  }
39
42
 
43
+ /**
44
+ * Single process-wide rate limiter for the Node runtime. Node serves all
45
+ * requests out of one persistent process, so in-memory counters are
46
+ * reliable and avoid per-request D1 round-trips. Constructed lazily on
47
+ * first use so tests that never build a request runtime don't pay for it.
48
+ */
49
+ let sharedNodeRateLimiter: RateLimiter | null = null;
50
+ function getNodeRateLimiter(): RateLimiter {
51
+ sharedNodeRateLimiter ??= createMemoryRateLimiter();
52
+ return sharedNodeRateLimiter;
53
+ }
54
+
40
55
  export interface NodeCliRuntime {
41
56
  currentSite: Site;
42
57
  currentSiteDomain: SiteDomain | null;
@@ -131,6 +146,7 @@ export async function createNodeRequestRuntime(
131
146
  schema: databaseSchema,
132
147
  secret: hostedControlPlaneSsoSecret,
133
148
  }),
149
+ rateLimiter: getNodeRateLimiter(),
134
150
  services: createServices(db, rawQuery, siteLookup.site.id, {
135
151
  databaseDialect,
136
152
  bootstrapSite: getSingleSiteBootstrapOptions(env),
@@ -2073,4 +2073,209 @@ describe("PostService", () => {
2073
2073
  expect(updatedRoot?.lastActivityAt).toBe(1000);
2074
2074
  });
2075
2075
  });
2076
+
2077
+ describe("reindexBodyText", () => {
2078
+ function bodyWithLink(text: string, href: string): string {
2079
+ return JSON.stringify({
2080
+ type: "doc",
2081
+ content: [
2082
+ {
2083
+ type: "paragraph",
2084
+ content: [
2085
+ {
2086
+ type: "text",
2087
+ text,
2088
+ marks: [{ type: "link", attrs: { href } }],
2089
+ },
2090
+ ],
2091
+ },
2092
+ ],
2093
+ });
2094
+ }
2095
+
2096
+ it("recomputes body_text and updates only rows that differ", async () => {
2097
+ const post = await postService.create({
2098
+ format: "note",
2099
+ body: bodyWithLink("docs", "https://rebuild.example/page"),
2100
+ });
2101
+
2102
+ // Simulate the pre-fix state by stripping URLs from body_text directly.
2103
+ await db
2104
+ .update(posts)
2105
+ .set({ bodyText: "docs" })
2106
+ .where(eq(posts.id, post.id));
2107
+
2108
+ const firstPass = await postService.reindexBodyText();
2109
+ expect(firstPass.processed).toBe(1);
2110
+ expect(firstPass.updated).toBe(1);
2111
+ expect(firstPass.skipped).toBe(0);
2112
+ expect(firstPass.done).toBe(true);
2113
+ expect(firstPass.nextCursor).toBeNull();
2114
+
2115
+ const reindexed = await postService.getById(post.id);
2116
+ expect(reindexed?.bodyText).toContain("rebuild.example");
2117
+
2118
+ // Idempotent: re-running immediately should be a no-op.
2119
+ const secondPass = await postService.reindexBodyText();
2120
+ expect(secondPass.updated).toBe(0);
2121
+ expect(secondPass.skipped).toBe(1);
2122
+ expect(secondPass.done).toBe(true);
2123
+ });
2124
+
2125
+ it("skips soft-deleted posts", async () => {
2126
+ const live = await postService.create({
2127
+ format: "note",
2128
+ body: bodyWithLink("a", "https://live.example"),
2129
+ });
2130
+ const gone = await postService.create({
2131
+ format: "note",
2132
+ body: bodyWithLink("b", "https://gone.example"),
2133
+ });
2134
+
2135
+ // Strip body_text on both to force an update on the next pass.
2136
+ await db
2137
+ .update(posts)
2138
+ .set({ bodyText: "a" })
2139
+ .where(eq(posts.id, live.id));
2140
+ await db
2141
+ .update(posts)
2142
+ .set({ bodyText: "b" })
2143
+ .where(eq(posts.id, gone.id));
2144
+ await postService.delete(gone.id);
2145
+
2146
+ const result = await postService.reindexBodyText();
2147
+ expect(result.processed).toBe(1);
2148
+ expect(result.updated).toBe(1);
2149
+ expect(result.done).toBe(true);
2150
+ });
2151
+
2152
+ it("paginates with cursor when more posts remain", async () => {
2153
+ for (let i = 0; i < 3; i++) {
2154
+ await postService.create({
2155
+ format: "note",
2156
+ body: bodyWithLink(`p${i}`, `https://p${i}.example`),
2157
+ });
2158
+ }
2159
+
2160
+ const first = await postService.reindexBodyText({ limit: 2 });
2161
+ expect(first.processed).toBe(2);
2162
+ expect(first.done).toBe(false);
2163
+ expect(first.nextCursor).not.toBeNull();
2164
+
2165
+ const second = await postService.reindexBodyText({
2166
+ limit: 2,
2167
+ cursor: first.nextCursor ?? undefined,
2168
+ });
2169
+ expect(second.processed).toBe(1);
2170
+ expect(second.done).toBe(true);
2171
+ expect(second.nextCursor).toBeNull();
2172
+ });
2173
+ });
2174
+
2175
+ describe("listForSitemap", () => {
2176
+ it("returns published non-reply non-private non-deleted posts", async () => {
2177
+ const root = await postService.create({
2178
+ format: "note",
2179
+ bodyMarkdown: "root",
2180
+ status: "published",
2181
+ });
2182
+ await postService.create({
2183
+ format: "note",
2184
+ bodyMarkdown: "reply",
2185
+ replyToId: root.id,
2186
+ status: "published",
2187
+ });
2188
+ await postService.create({
2189
+ format: "note",
2190
+ bodyMarkdown: "private",
2191
+ visibility: "private",
2192
+ status: "published",
2193
+ });
2194
+ await postService.create({
2195
+ format: "note",
2196
+ bodyMarkdown: "draft",
2197
+ status: "draft",
2198
+ });
2199
+ await postService.create({
2200
+ format: "note",
2201
+ bodyMarkdown: "latest hidden",
2202
+ visibility: "latest_hidden",
2203
+ status: "published",
2204
+ });
2205
+
2206
+ const entries = await postService.listForSitemap({ limit: 100 });
2207
+ const ids = entries.map((e) => e.id);
2208
+ // Root post and latest_hidden post should be included; reply/private/draft excluded.
2209
+ expect(ids).toHaveLength(2);
2210
+ expect(ids).toContain(root.id);
2211
+ });
2212
+
2213
+ it("returns entries in ascending id order", async () => {
2214
+ const created: string[] = [];
2215
+ for (let i = 0; i < 5; i++) {
2216
+ const post = await postService.create({
2217
+ format: "note",
2218
+ bodyMarkdown: `post ${i}`,
2219
+ status: "published",
2220
+ });
2221
+ created.push(post.id);
2222
+ }
2223
+
2224
+ const entries = await postService.listForSitemap({ limit: 100 });
2225
+ const ids = entries.map((e) => e.id);
2226
+ // TypeIDs embed a UUIDv7 timestamp, so creation order == ascending id.
2227
+ expect(ids).toEqual([...ids].sort());
2228
+ expect(ids).toEqual(created);
2229
+ });
2230
+
2231
+ it("respects afterId as an exclusive cursor", async () => {
2232
+ const posts = [];
2233
+ for (let i = 0; i < 5; i++) {
2234
+ posts.push(
2235
+ await postService.create({
2236
+ format: "note",
2237
+ bodyMarkdown: `post ${i}`,
2238
+ status: "published",
2239
+ }),
2240
+ );
2241
+ }
2242
+
2243
+ const firstPage = await postService.listForSitemap({ limit: 2 });
2244
+ expect(firstPage).toHaveLength(2);
2245
+
2246
+ const cursor = firstPage[firstPage.length - 1]?.id;
2247
+ const secondPage = await postService.listForSitemap({
2248
+ afterId: cursor,
2249
+ limit: 2,
2250
+ });
2251
+ expect(secondPage.map((e) => e.id)).toEqual([posts[2]?.id, posts[3]?.id]);
2252
+
2253
+ const thirdPage = await postService.listForSitemap({
2254
+ afterId: secondPage[secondPage.length - 1]?.id,
2255
+ limit: 2,
2256
+ });
2257
+ expect(thirdPage.map((e) => e.id)).toEqual([posts[4]?.id]);
2258
+ });
2259
+
2260
+ it("countForSitemap matches listForSitemap without a cursor", async () => {
2261
+ for (let i = 0; i < 3; i++) {
2262
+ await postService.create({
2263
+ format: "note",
2264
+ bodyMarkdown: `p${i}`,
2265
+ status: "published",
2266
+ });
2267
+ }
2268
+ await postService.create({
2269
+ format: "note",
2270
+ bodyMarkdown: "private",
2271
+ visibility: "private",
2272
+ status: "published",
2273
+ });
2274
+
2275
+ const count = await postService.countForSitemap();
2276
+ const entries = await postService.listForSitemap({ limit: 100 });
2277
+ expect(count).toBe(entries.length);
2278
+ expect(count).toBe(3);
2279
+ });
2280
+ });
2076
2281
  });
@@ -204,6 +204,50 @@ describe("SearchService", () => {
204
204
  expect(results[0]?.post.url).toContain("example.com");
205
205
  });
206
206
 
207
+ it("finds posts by URL embedded in inline markdown links", async () => {
208
+ // TipTap stores markdown links as marks on text nodes. Their href
209
+ // must reach body_text so users can search for the URL, not just
210
+ // the visible link text.
211
+ const bodyWithLink = JSON.stringify({
212
+ type: "doc",
213
+ content: [
214
+ {
215
+ type: "paragraph",
216
+ content: [
217
+ { type: "text", text: "See " },
218
+ {
219
+ type: "text",
220
+ text: "this page",
221
+ marks: [
222
+ {
223
+ type: "link",
224
+ attrs: { href: "https://inline-link.example/article" },
225
+ },
226
+ ],
227
+ },
228
+ { type: "text", text: " for details." },
229
+ ],
230
+ },
231
+ ],
232
+ });
233
+
234
+ await postService.create({
235
+ format: "note",
236
+ body: bodyWithLink,
237
+ });
238
+
239
+ const d1 = createMockD1(sqlite);
240
+ const searchService = createSearchService(d1, DEFAULT_TEST_SITE_ID);
241
+
242
+ // Searching by the link's URL host should match.
243
+ const byUrl = await searchService.search("inline-link.example");
244
+ expect(byUrl.length).toBeGreaterThanOrEqual(1);
245
+
246
+ // Regression guard: the visible link text still matches too.
247
+ const byText = await searchService.search("this page");
248
+ expect(byText.length).toBeGreaterThanOrEqual(1);
249
+ });
250
+
207
251
  it("finds posts with short queries (< 3 chars) via LIKE fallback", async () => {
208
252
  await postService.create({
209
253
  format: "note",