@jant/core 0.3.42 → 0.3.43

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 (79) 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-Ctl0T0zO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-GbfwoeDJ.js} +630 -123
  7. package/dist/client/.vite/manifest.json +1 -1
  8. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  9. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  10. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  11. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  12. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  13. package/dist/index.js +5 -5
  14. package/dist/node.js +5 -5
  15. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  16. package/package.json +1 -1
  17. package/src/__tests__/helpers/app.ts +15 -4
  18. package/src/app.tsx +8 -0
  19. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  20. package/src/client/tiptap/extensions.ts +3 -0
  21. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  22. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  23. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  24. package/src/db/migrations/meta/_journal.json +8 -1
  25. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  26. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  27. package/src/db/migrations/pg/meta/_journal.json +8 -1
  28. package/src/db/pg/schema.ts +18 -0
  29. package/src/db/schema.ts +23 -0
  30. package/src/index.ts +1 -2
  31. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  32. package/src/lib/__tests__/navigation.test.ts +4 -20
  33. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  34. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  35. package/src/lib/__tests__/summary.test.ts +140 -0
  36. package/src/lib/__tests__/view.test.ts +66 -0
  37. package/src/lib/feed.ts +70 -34
  38. package/src/lib/hosted-signin.ts +9 -3
  39. package/src/lib/navigation.ts +11 -12
  40. package/src/lib/post-meta.ts +20 -2
  41. package/src/lib/rate-limit-d1.ts +99 -0
  42. package/src/lib/rate-limit-memory.ts +105 -0
  43. package/src/lib/rate-limit.ts +63 -0
  44. package/src/lib/render.tsx +9 -0
  45. package/src/lib/resolve-config.ts +9 -0
  46. package/src/lib/summary.ts +42 -7
  47. package/src/lib/url.ts +34 -0
  48. package/src/lib/view.ts +42 -8
  49. package/src/middleware/__tests__/auth.test.ts +44 -4
  50. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  51. package/src/middleware/__tests__/session.test.ts +85 -0
  52. package/src/middleware/auth.ts +62 -25
  53. package/src/middleware/rate-limit.ts +54 -0
  54. package/src/middleware/session.ts +36 -0
  55. package/src/routes/__tests__/compose.test.ts +1 -1
  56. package/src/routes/api/__tests__/search.test.ts +48 -0
  57. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  58. package/src/routes/api/internal/search-reindex.ts +40 -0
  59. package/src/routes/api/search.ts +13 -0
  60. package/src/routes/auth/dev.ts +1 -1
  61. package/src/routes/auth/signin.tsx +23 -5
  62. package/src/routes/dash/settings.tsx +3 -5
  63. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  64. package/src/routes/feed/sitemap.ts +208 -33
  65. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  66. package/src/routes/pages/home.tsx +24 -15
  67. package/src/routes/pages/page.tsx +34 -0
  68. package/src/routes/pages/partials.tsx +4 -15
  69. package/src/runtime/cloudflare.ts +4 -0
  70. package/src/runtime/node.ts +16 -0
  71. package/src/services/__tests__/post.test.ts +205 -0
  72. package/src/services/__tests__/search.test.ts +44 -0
  73. package/src/services/export.ts +9 -2
  74. package/src/services/post.ts +200 -2
  75. package/src/types/app-context.ts +20 -0
  76. package/src/types/config.ts +8 -0
  77. package/src/types/props.ts +0 -7
  78. package/src/ui/layouts/BaseLayout.tsx +9 -0
  79. package/dist/app-DzCB4yOp.js +0 -5
@@ -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",
@@ -28,6 +28,7 @@ import {
28
28
  getDefaultJantFaviconIcoBytes,
29
29
  } from "../lib/jant-branding.js";
30
30
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
31
+ import { extractBodyText } from "../lib/summary.js";
31
32
  import { getMediaUrl, getPublicUrlForProvider } from "../lib/image.js";
32
33
  import { render as renderMarkdown } from "../lib/markdown.js";
33
34
  import { formatRelativeAge, toISOString } from "../lib/time.js";
@@ -1074,10 +1075,16 @@ function getArchiveSummaryText(post: Post): string | null {
1074
1075
  // serialized as `link_url` and rendered as a domain badge, so using
1075
1076
  // it as `summary_text` would duplicate that information.
1076
1077
  // - Note: body only. If the body is empty, there's nothing to describe.
1078
+ //
1079
+ // Note: we re-derive the body text from `post.body` (TipTap JSON) rather
1080
+ // than reusing `post.bodyText`, because `bodyText` is written with
1081
+ // `includeLinkHrefs: true` for FTS search indexing — that pollutes the
1082
+ // stored text with trailing link URLs. Here we need clean prose.
1083
+ const cleanBodyText = post.body ? extractBodyText(post.body) : null;
1077
1084
  const candidates =
1078
1085
  post.format === "quote"
1079
- ? [post.summary, post.bodyText, post.quoteText]
1080
- : [post.summary, post.bodyText];
1086
+ ? [post.summary, cleanBodyText, post.quoteText]
1087
+ : [post.summary, cleanBodyText];
1081
1088
 
1082
1089
  for (const candidate of candidates) {
1083
1090
  const normalized = normalizeArchiveText(candidate);
@@ -19,6 +19,7 @@ import {
19
19
  isNotNull,
20
20
  asc,
21
21
  lte,
22
+ gt,
22
23
  } from "drizzle-orm";
23
24
  import {
24
25
  type Database,
@@ -156,6 +157,17 @@ export interface PostBodyContent {
156
157
  chars: number;
157
158
  }
158
159
 
160
+ /** Minimal projection used by the sitemap renderer. */
161
+ export interface SitemapPostEntry {
162
+ id: string;
163
+ /** Canonical slug from `path_registry` */
164
+ slug: string;
165
+ /** Primary alias, if the post has one; used in preference to `slug` for URLs */
166
+ alias: string | null;
167
+ updatedAt: number;
168
+ featuredAt: number | null;
169
+ }
170
+
159
171
  export interface PostService {
160
172
  getById(id: string): Promise<Post | null>;
161
173
  getBodyContent(id: string): Promise<PostBodyContent | null>;
@@ -167,6 +179,35 @@ export interface PostService {
167
179
  }): Promise<string>;
168
180
  checkSlugAvailability(slug: string, excludePostId?: string): Promise<boolean>;
169
181
  list(filters?: PostFilters): Promise<Post[]>;
182
+ /**
183
+ * List minimal fields needed to render sitemap entries, paginated by `id`
184
+ * (ascending). Excludes replies, private posts, deleted posts, and drafts.
185
+ *
186
+ * Uses keyset pagination on the primary key so old sitemap shards are cheap
187
+ * to serve and stable across shard boundaries: a newly created post always
188
+ * gets a larger TypeID than any previously-committed post, so it lands in
189
+ * the last shard and never rewrites older ones.
190
+ *
191
+ * @param options.afterId Exclusive lower bound on `id`. Omit for the first
192
+ * shard.
193
+ * @param options.limit Maximum rows to return.
194
+ */
195
+ listForSitemap(options: {
196
+ afterId?: string;
197
+ limit: number;
198
+ }): Promise<SitemapPostEntry[]>;
199
+ /** Count posts that qualify for the sitemap (same filters as `listForSitemap`) */
200
+ countForSitemap(): Promise<number>;
201
+ /**
202
+ * Return the id at the given 0-based offset in the sitemap ordering.
203
+ * Used to compute keyset cursors for sharded sitemap endpoints.
204
+ *
205
+ * Returns `null` when the offset is beyond the available rows.
206
+ *
207
+ * Walks the primary-key index with `ORDER BY id ASC LIMIT 1 OFFSET ?` —
208
+ * SQLite/D1 scan only the index for this, not the row data.
209
+ */
210
+ getSitemapIdAt(offset: number): Promise<string | null>;
170
211
  /** Count posts matching filters (ignores cursor, offset, limit) */
171
212
  count(filters?: PostFilters): Promise<number>;
172
213
  /** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
@@ -292,6 +333,28 @@ export interface PostService {
292
333
  getDistinctYears(filters?: PostFilters): Promise<number[]>;
293
334
  /** For each thread ID, return the ID of the last published, non-deleted post */
294
335
  getLastPostIdsByThread(threadIds: string[]): Promise<Map<string, string>>;
336
+ /**
337
+ * Rebuild `post.body_text` for a batch of non-deleted posts, cursor-paginated
338
+ * by post id. For each row, recomputes the plain-text extraction via
339
+ * `extractBodyText(body)` and writes it back only when it differs from the
340
+ * stored value. FTS indexes (SQLite trigger / Postgres generated column)
341
+ * refresh automatically on the UPDATE.
342
+ *
343
+ * Idempotent: re-running after a no-op pass returns `updated: 0`.
344
+ *
345
+ * @param options.limit Batch size (1..500, default 50)
346
+ * @param options.cursor Exclusive lower bound on post id; pass the previous
347
+ * response's `nextCursor` to continue
348
+ * @returns processed/updated/skipped counts, the next cursor, and a `done`
349
+ * flag the caller uses to terminate the loop
350
+ */
351
+ reindexBodyText(options?: { limit?: number; cursor?: string }): Promise<{
352
+ processed: number;
353
+ updated: number;
354
+ skipped: number;
355
+ nextCursor: string | null;
356
+ done: boolean;
357
+ }>;
295
358
  }
296
359
 
297
360
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
@@ -1300,6 +1363,83 @@ export function createPostService(
1300
1363
  return hydratePosts(rows);
1301
1364
  },
1302
1365
 
1366
+ async listForSitemap({ afterId, limit }) {
1367
+ // Share the filter conditions with `list()` so visibility/reply/deleted
1368
+ // semantics stay consistent if they ever change.
1369
+ const conditions = buildFilterConditions({
1370
+ status: "published",
1371
+ excludePrivate: true,
1372
+ excludeReplies: true,
1373
+ });
1374
+ if (afterId !== undefined) {
1375
+ conditions.push(sql`${posts.id} > ${afterId}`);
1376
+ }
1377
+
1378
+ const rows = await db
1379
+ .select({
1380
+ id: posts.id,
1381
+ updatedAt: posts.updatedAt,
1382
+ featuredAt: posts.featuredAt,
1383
+ })
1384
+ .from(posts)
1385
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
1386
+ .orderBy(asc(posts.id))
1387
+ .limit(limit);
1388
+
1389
+ if (rows.length === 0) return [];
1390
+
1391
+ const ids = rows.map((row) => row.id);
1392
+ const [slugMap, aliasesMap] = await Promise.all([
1393
+ resolvedPaths.getPostSlugMap(ids),
1394
+ resolvedPaths.getPostAliases(ids),
1395
+ ]);
1396
+
1397
+ return rows
1398
+ .map((row): SitemapPostEntry | null => {
1399
+ const slug = slugMap.get(row.id);
1400
+ if (!slug) return null;
1401
+ const alias = aliasesMap.get(row.id)?.[0] ?? null;
1402
+ return {
1403
+ id: row.id,
1404
+ slug,
1405
+ alias,
1406
+ updatedAt: row.updatedAt,
1407
+ featuredAt: row.featuredAt,
1408
+ };
1409
+ })
1410
+ .filter((entry): entry is SitemapPostEntry => entry !== null);
1411
+ },
1412
+
1413
+ async countForSitemap() {
1414
+ const conditions = buildFilterConditions({
1415
+ status: "published",
1416
+ excludePrivate: true,
1417
+ excludeReplies: true,
1418
+ });
1419
+ const result = await db
1420
+ .select({ count: sql<number>`CAST(count(*) AS INTEGER)`.as("count") })
1421
+ .from(posts)
1422
+ .where(conditions.length > 0 ? and(...conditions) : undefined);
1423
+ return result[0]?.count ?? 0;
1424
+ },
1425
+
1426
+ async getSitemapIdAt(offset) {
1427
+ if (offset < 0) return null;
1428
+ const conditions = buildFilterConditions({
1429
+ status: "published",
1430
+ excludePrivate: true,
1431
+ excludeReplies: true,
1432
+ });
1433
+ const rows = await db
1434
+ .select({ id: posts.id })
1435
+ .from(posts)
1436
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
1437
+ .orderBy(asc(posts.id))
1438
+ .limit(1)
1439
+ .offset(offset);
1440
+ return rows[0]?.id ?? null;
1441
+ },
1442
+
1303
1443
  async count(filters = {}) {
1304
1444
  const conditions = buildFilterConditions(filters);
1305
1445
 
@@ -1377,7 +1517,9 @@ export function createPostService(
1377
1517
  });
1378
1518
 
1379
1519
  const bodyHtml = body ? renderTiptapJson(body) : null;
1380
- const bodyText = body ? extractBodyText(body) : null;
1520
+ const bodyText = body
1521
+ ? extractBodyText(body, { includeLinkHrefs: true })
1522
+ : null;
1381
1523
 
1382
1524
  // Generate summary for titled notes with body content
1383
1525
  let summary: string | null = null;
@@ -1879,7 +2021,7 @@ export function createPostService(
1879
2021
  ? renderTiptapJson(normalizedBody)
1880
2022
  : null;
1881
2023
  updates.bodyText = normalizedBody
1882
- ? extractBodyText(normalizedBody)
2024
+ ? extractBodyText(normalizedBody, { includeLinkHrefs: true })
1883
2025
  : null;
1884
2026
  }
1885
2027
 
@@ -2967,5 +3109,61 @@ export function createPostService(
2967
3109
 
2968
3110
  return rows.map((r) => parseInt(r.year, 10));
2969
3111
  },
3112
+
3113
+ async reindexBodyText(options = {}) {
3114
+ const requested = options.limit ?? 50;
3115
+ const limit = Math.min(Math.max(Math.trunc(requested), 1), 500);
3116
+ const cursor = options.cursor;
3117
+
3118
+ const whereConditions = [
3119
+ eq(posts.siteId, siteId),
3120
+ isNull(posts.deletedAt),
3121
+ ];
3122
+ if (cursor) whereConditions.push(gt(posts.id, cursor));
3123
+
3124
+ // Fetch one extra row to detect end-of-data without a separate COUNT.
3125
+ const rows = await db
3126
+ .select({
3127
+ id: posts.id,
3128
+ body: posts.body,
3129
+ bodyText: posts.bodyText,
3130
+ })
3131
+ .from(posts)
3132
+ .where(and(...whereConditions))
3133
+ .orderBy(asc(posts.id))
3134
+ .limit(limit + 1);
3135
+
3136
+ const hasMore = rows.length > limit;
3137
+ const batch = hasMore ? rows.slice(0, limit) : rows;
3138
+
3139
+ let updated = 0;
3140
+ let skipped = 0;
3141
+
3142
+ for (const row of batch) {
3143
+ const nextBodyText = row.body
3144
+ ? extractBodyText(row.body, { includeLinkHrefs: true })
3145
+ : null;
3146
+ if (nextBodyText === row.bodyText) {
3147
+ skipped++;
3148
+ continue;
3149
+ }
3150
+ await db
3151
+ .update(posts)
3152
+ .set({ bodyText: nextBodyText })
3153
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, row.id)));
3154
+ updated++;
3155
+ }
3156
+
3157
+ const lastRow = batch.at(-1);
3158
+ const lastId = lastRow ? lastRow.id : null;
3159
+
3160
+ return {
3161
+ processed: batch.length,
3162
+ updated,
3163
+ skipped,
3164
+ nextCursor: hasMore ? lastId : null,
3165
+ done: !hasMore,
3166
+ };
3167
+ },
2970
3168
  };
2971
3169
  }
@@ -11,10 +11,17 @@ import type { Services } from "../services/index.js";
11
11
  import type { HostedHandoffService } from "../services/hosted-handoff.js";
12
12
  import type { Auth } from "../auth.js";
13
13
  import type { AppConfig } from "./config.js";
14
+ import type { RateLimiter } from "../lib/rate-limit.js";
14
15
  import type { StorageDriver } from "../lib/storage.js";
15
16
  import type { Bindings } from "./bindings.js";
16
17
  import type { Site, SiteDomain } from "./entities.js";
17
18
 
19
+ /**
20
+ * Session payload as returned by better-auth's `getSession`.
21
+ * Populated once per request by the `attachSession` middleware.
22
+ */
23
+ export type AppSession = Awaited<ReturnType<Auth["api"]["getSession"]>>;
24
+
18
25
  export interface AppVariables {
19
26
  services: Services;
20
27
  hostedHandoff: HostedHandoffService;
@@ -27,6 +34,19 @@ export interface AppVariables {
27
34
  storage: StorageDriver | null;
28
35
  publicRequestUrl: string;
29
36
  publicPath: string;
37
+ /**
38
+ * Cached session for the current request. `null` when unauthenticated or
39
+ * when the session lookup errored. Populated by `attachSession` middleware.
40
+ */
41
+ session: AppSession;
42
+ /** True when `session?.user` is set. Shortcut for the common read. */
43
+ isAuthenticated: boolean;
44
+ /**
45
+ * Runtime-appropriate rate limiter. Populated per-request from the
46
+ * runtime (D1 on Workers, in-memory on Node). Middleware calls
47
+ * `c.var.rateLimiter.check(...)` instead of caring which impl is used.
48
+ */
49
+ rateLimiter: RateLimiter;
30
50
  }
31
51
 
32
52
  export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
@@ -476,6 +476,14 @@ export interface AppConfig {
476
476
  siteAvatarUrl: string;
477
477
  faviconVersion: string;
478
478
 
479
+ // Rate limiting (ENV only)
480
+ rateLimit: {
481
+ /** When true, all rate-limit middleware becomes a no-op. */
482
+ disabled: boolean;
483
+ /** Per-IP cap for `/api/search` requests per 60-second window. */
484
+ searchPerMinute: number;
485
+ };
486
+
479
487
  // Settings form placeholders (ENV > Default, without DB)
480
488
  fallbacks: {
481
489
  siteName: string;
@@ -138,13 +138,6 @@ export interface FeedData {
138
138
  posts: FeedPostView[];
139
139
  }
140
140
 
141
- /** Data passed to sitemap renderers */
142
- export interface SitemapData {
143
- siteUrl: string;
144
- sitemapUrl: string;
145
- posts: PostView[];
146
- }
147
-
148
141
  // =============================================================================
149
142
  // Timeline Types
150
143
  // =============================================================================
@@ -49,6 +49,13 @@ export interface BaseLayoutProps {
49
49
  faviconUrl?: string;
50
50
  faviconVersion?: string;
51
51
  socialImageUrl?: string;
52
+ /**
53
+ * Absolute canonical URL for the current page. Rendered as
54
+ * `<link rel="canonical">` when set. Use on pages whose primary content is
55
+ * also reachable via another URL (e.g. reply posts, which render the full
56
+ * thread at both the reply URL and the thread-root URL).
57
+ */
58
+ canonicalHref?: string;
52
59
  noindex?: boolean;
53
60
  isAuthenticated?: boolean;
54
61
  clientBundle?: "public" | "full";
@@ -65,6 +72,7 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
65
72
  faviconUrl,
66
73
  faviconVersion,
67
74
  socialImageUrl,
75
+ canonicalHref,
68
76
  noindex,
69
77
  isAuthenticated = false,
70
78
  clientBundle,
@@ -265,6 +273,7 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
265
273
  {resolvedNoindex && (
266
274
  <meta name="robots" content="noindex, nofollow" />
267
275
  )}
276
+ {canonicalHref && <link rel="canonical" href={canonicalHref} />}
268
277
  <link rel="icon" href={resolvedFaviconHref} sizes="16x16 32x32" />
269
278
  <link rel="apple-touch-icon" href={resolvedAppleTouchHref} />
270
279
  <link
@@ -1,5 +0,0 @@
1
- import "./url-FvvgARU9.js";
2
- import { t as createApp } from "./app-Cu3lveYI.js";
3
- import "./github-sync-zohnA9qv.js";
4
- import "./env-wCpMcNXs.js";
5
- export { createApp };