@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
@@ -113,6 +113,20 @@
113
113
  "when": 1776405301273,
114
114
  "tag": "0015_daffy_mikhail_rasputin",
115
115
  "breakpoints": true
116
+ },
117
+ {
118
+ "idx": 16,
119
+ "version": "7",
120
+ "when": 1776685653392,
121
+ "tag": "0016_familiar_lionheart",
122
+ "breakpoints": true
123
+ },
124
+ {
125
+ "idx": 17,
126
+ "version": "7",
127
+ "when": 1776712494794,
128
+ "tag": "0017_bright_beyonder",
129
+ "breakpoints": true
116
130
  }
117
131
  ]
118
- }
132
+ }
@@ -70,11 +70,15 @@ export const sites = pgTable(
70
70
  })
71
71
  .notNull()
72
72
  .default("active"),
73
+ provisioningIdempotencyKey: text("provisioning_idempotency_key"),
73
74
  createdAt: integer("created_at").notNull(),
74
75
  updatedAt: integer("updated_at").notNull(),
75
76
  },
76
77
  (table) => [
77
78
  uniqueIndex("uq_site_key").on(table.key),
79
+ uniqueIndex("uq_site_provisioning_idempotency_key")
80
+ .on(table.provisioningIdempotencyKey)
81
+ .where(sql`${table.provisioningIdempotencyKey} IS NOT NULL`),
78
82
  check(
79
83
  "chk_site_status",
80
84
  sql`${table.status} IN (${sqlTextEnum(SITE_STATUSES)})`,
@@ -855,3 +859,21 @@ export const githubAppInstallation = pgTable(
855
859
  ),
856
860
  ],
857
861
  );
862
+
863
+ // ---------------------------------------------------------------------------
864
+ // Rate Limit
865
+ // ---------------------------------------------------------------------------
866
+
867
+ /**
868
+ * Per-key sliding-window rate-limit counters. Mirrors the SQLite `rate_limit`
869
+ * table — kept in lockstep because both dialects are production targets.
870
+ */
871
+ export const rateLimit = pgTable(
872
+ "rate_limit",
873
+ {
874
+ key: text("key").notNull(),
875
+ windowStart: integer("window_start").notNull(),
876
+ count: integer("count").notNull().default(0),
877
+ },
878
+ (table) => [primaryKey({ columns: [table.key, table.windowStart] })],
879
+ );
package/src/db/schema.ts CHANGED
@@ -60,11 +60,15 @@ export const sites = sqliteTable(
60
60
  })
61
61
  .notNull()
62
62
  .default("active"),
63
+ provisioningIdempotencyKey: text("provisioning_idempotency_key"),
63
64
  createdAt: integer("created_at").notNull(),
64
65
  updatedAt: integer("updated_at").notNull(),
65
66
  },
66
67
  (table) => [
67
68
  uniqueIndex("uq_site_key").on(table.key),
69
+ uniqueIndex("uq_site_provisioning_idempotency_key")
70
+ .on(table.provisioningIdempotencyKey)
71
+ .where(sql`${table.provisioningIdempotencyKey} IS NOT NULL`),
68
72
  check(
69
73
  "chk_site_status",
70
74
  sql`${table.status} IN (${sqlTextEnum(SITE_STATUSES)})`,
@@ -813,3 +817,26 @@ export const githubAppInstallation = sqliteTable(
813
817
  ),
814
818
  ],
815
819
  );
820
+
821
+ // =============================================================================
822
+ // Rate Limit
823
+ // =============================================================================
824
+
825
+ /**
826
+ * Per-key sliding-window rate-limit counters. Used by the Cloudflare Workers
827
+ * runtime; Node deployments keep the state in memory instead.
828
+ *
829
+ * `key` is typically a scope prefix plus client IP (e.g. "search:1.2.3.4").
830
+ * `window_start` is the aligned start of the window in Unix seconds — we
831
+ * store two adjacent windows per key to support the sliding-window-counter
832
+ * algorithm.
833
+ */
834
+ export const rateLimit = sqliteTable(
835
+ "rate_limit",
836
+ {
837
+ key: text("key").notNull(),
838
+ windowStart: integer("window_start").notNull(),
839
+ count: integer("count").notNull().default(0),
840
+ },
841
+ (table) => [primaryKey({ columns: [table.key, table.windowStart] })],
842
+ );
package/src/index.ts CHANGED
@@ -44,7 +44,6 @@ export type {
44
44
  ArchiveFilters,
45
45
  // Feed types
46
46
  FeedData,
47
- SitemapData,
48
47
  // Search
49
48
  SearchResult,
50
49
  } from "./types.js";
@@ -82,7 +81,7 @@ export {
82
81
  export type { MediaContext } from "./lib/view.js";
83
82
 
84
83
  // Default feed renderers (for custom feed implementations)
85
- export { defaultFeedRenderer, defaultSitemapRenderer } from "./lib/feed.js";
84
+ export { defaultFeedRenderer } from "./lib/feed.js";
86
85
 
87
86
  // GitHub Sync queue handler (for Cloudflare Workers queue consumer)
88
87
  export { handleQueueBatch as handleGitHubSyncQueueBatch } from "./lib/github-sync-queue-handler.js";
@@ -26,6 +26,36 @@ describe("getHostedControlPlaneSigninUrl", () => {
26
26
  );
27
27
  });
28
28
 
29
+ it("forwards a safe post-signin redirect through the handoff URL", () => {
30
+ const url = getHostedControlPlaneSigninUrl(
31
+ {
32
+ HOSTED_CONTROL_PLANE_BASE_URL: "https://cloud-jant.localtest.me",
33
+ SITE_RESOLUTION_MODE: "host-based",
34
+ },
35
+ "https://site7.localtest.me/signin",
36
+ "/settings/general",
37
+ );
38
+
39
+ expect(url).toBe(
40
+ "https://cloud-jant.localtest.me/auth/handoff/start?host=site7.localtest.me&redirect=%2Fsettings%2Fgeneral",
41
+ );
42
+ });
43
+
44
+ it("falls back to / when the supplied redirect is unsafe", () => {
45
+ const url = getHostedControlPlaneSigninUrl(
46
+ {
47
+ HOSTED_CONTROL_PLANE_BASE_URL: "https://cloud-jant.localtest.me",
48
+ SITE_RESOLUTION_MODE: "host-based",
49
+ },
50
+ "https://site7.localtest.me/signin",
51
+ "//evil.example/steal",
52
+ );
53
+
54
+ expect(url).toBe(
55
+ "https://cloud-jant.localtest.me/auth/handoff/start?host=site7.localtest.me&redirect=%2F",
56
+ );
57
+ });
58
+
29
59
  it("returns null outside host-based mode", () => {
30
60
  const url = getHostedControlPlaneSigninUrl(
31
61
  {
@@ -56,16 +56,8 @@ describe("getNavigationData", () => {
56
56
  listByRecentActivity: async () => [],
57
57
  },
58
58
  },
59
- auth: {
60
- api: {
61
- getSession: async () => null,
62
- },
63
- },
64
- },
65
- req: {
66
- raw: {
67
- headers: new Headers(),
68
- },
59
+ isAuthenticated: false,
60
+ session: null,
69
61
  },
70
62
  } as unknown as Context;
71
63
 
@@ -126,16 +118,8 @@ describe("getNavigationData", () => {
126
118
  listByRecentActivity: async () => [],
127
119
  },
128
120
  },
129
- auth: {
130
- api: {
131
- getSession: async () => null,
132
- },
133
- },
134
- },
135
- req: {
136
- raw: {
137
- headers: new Headers(),
138
- },
121
+ isAuthenticated: false,
122
+ session: null,
139
123
  },
140
124
  } as unknown as Context;
141
125
 
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
+ import { sqliteSchemaBundle } from "../../db/schema-bundle.js";
4
+ import type { Database } from "../../db/index.js";
5
+ import { createD1RateLimiter } from "../rate-limit-d1.js";
6
+
7
+ function createLimiter(now: () => number) {
8
+ const testDb = createTestDatabase();
9
+ const db = testDb.db as unknown as Database;
10
+ return {
11
+ limiter: createD1RateLimiter(db, sqliteSchemaBundle, now),
12
+ db,
13
+ sqlite: testDb.sqlite,
14
+ };
15
+ }
16
+
17
+ describe("createD1RateLimiter", () => {
18
+ it("allows requests under the limit", async () => {
19
+ const { limiter } = createLimiter(() => 1_000);
20
+ for (let i = 0; i < 3; i++) {
21
+ const result = await limiter.check("k", { limit: 3, windowSec: 60 });
22
+ expect(result.ok).toBe(true);
23
+ }
24
+ });
25
+
26
+ it("rejects requests at or over the limit", async () => {
27
+ const { limiter } = createLimiter(() => 1_000);
28
+ await limiter.check("k", { limit: 2, windowSec: 60 });
29
+ await limiter.check("k", { limit: 2, windowSec: 60 });
30
+ const blocked = await limiter.check("k", { limit: 2, windowSec: 60 });
31
+ expect(blocked.ok).toBe(false);
32
+ expect(blocked.retryAfterSec).toBeGreaterThan(0);
33
+ });
34
+
35
+ it("does not persist a row when a request is rejected", async () => {
36
+ const { limiter, sqlite } = createLimiter(() => 1_000);
37
+ await limiter.check("k", { limit: 1, windowSec: 60 });
38
+ await limiter.check("k", { limit: 1, windowSec: 60 }); // rejected
39
+
40
+ // Only one write should have happened — the first (allowed) one.
41
+ const row = sqlite
42
+ .prepare("SELECT count FROM rate_limit WHERE key = 'k'")
43
+ .get() as { count: number };
44
+ expect(row.count).toBe(1);
45
+ });
46
+
47
+ it("keeps keys independent", async () => {
48
+ const { limiter } = createLimiter(() => 1_000);
49
+ await limiter.check("a", { limit: 1, windowSec: 60 });
50
+ const b = await limiter.check("b", { limit: 1, windowSec: 60 });
51
+ expect(b.ok).toBe(true);
52
+ });
53
+
54
+ it("releases capacity after two full windows", async () => {
55
+ let now = 960;
56
+ const { limiter } = createLimiter(() => now);
57
+ await limiter.check("k", { limit: 1, windowSec: 60 });
58
+ const blocked = await limiter.check("k", { limit: 1, windowSec: 60 });
59
+ expect(blocked.ok).toBe(false);
60
+
61
+ now += 60 * 2;
62
+ const released = await limiter.check("k", { limit: 1, windowSec: 60 });
63
+ expect(released.ok).toBe(true);
64
+ });
65
+
66
+ it("applies previous-window weighting across a boundary", async () => {
67
+ let now = 960;
68
+ const { limiter } = createLimiter(() => now);
69
+ for (let i = 0; i < 10; i++) {
70
+ const r = await limiter.check("k", { limit: 10, windowSec: 60 });
71
+ expect(r.ok).toBe(true);
72
+ }
73
+
74
+ now = 960 + 60; // t=0 into next window, prev weight = 1.0
75
+ const justAfter = await limiter.check("k", { limit: 10, windowSec: 60 });
76
+ expect(justAfter.ok).toBe(false);
77
+
78
+ now = 960 + 60 + 59; // prev weight ≈ 1/60, estimate ≈ 0.167
79
+ const released = await limiter.check("k", { limit: 10, windowSec: 60 });
80
+ expect(released.ok).toBe(true);
81
+ });
82
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createMemoryRateLimiter } from "../rate-limit-memory.js";
3
+
4
+ describe("createMemoryRateLimiter", () => {
5
+ it("allows requests under the limit", async () => {
6
+ const limiter = createMemoryRateLimiter(() => 1_000);
7
+ for (let i = 0; i < 3; i++) {
8
+ const result = await limiter.check("k", { limit: 3, windowSec: 60 });
9
+ expect(result.ok).toBe(true);
10
+ }
11
+ });
12
+
13
+ it("rejects requests at or over the limit", async () => {
14
+ const limiter = createMemoryRateLimiter(() => 1_000);
15
+ await limiter.check("k", { limit: 2, windowSec: 60 });
16
+ await limiter.check("k", { limit: 2, windowSec: 60 });
17
+ const blocked = await limiter.check("k", { limit: 2, windowSec: 60 });
18
+ expect(blocked.ok).toBe(false);
19
+ expect(blocked.retryAfterSec).toBeGreaterThan(0);
20
+ });
21
+
22
+ it("keeps keys independent", async () => {
23
+ const limiter = createMemoryRateLimiter(() => 1_000);
24
+ await limiter.check("a", { limit: 1, windowSec: 60 });
25
+ // "a" is now at limit, but "b" should still pass.
26
+ const b = await limiter.check("b", { limit: 1, windowSec: 60 });
27
+ expect(b.ok).toBe(true);
28
+ });
29
+
30
+ it("releases capacity after the window rolls over", async () => {
31
+ let now = 1_000;
32
+ const limiter = createMemoryRateLimiter(() => now);
33
+
34
+ // Fill the current window.
35
+ await limiter.check("k", { limit: 1, windowSec: 60 });
36
+ const blocked = await limiter.check("k", { limit: 1, windowSec: 60 });
37
+ expect(blocked.ok).toBe(false);
38
+
39
+ // Jump two full windows into the future — previous window carries no
40
+ // weight, so capacity fully resets.
41
+ now += 60 * 2;
42
+ const released = await limiter.check("k", { limit: 1, windowSec: 60 });
43
+ expect(released.ok).toBe(true);
44
+ });
45
+
46
+ it("applies previous-window weighting across a boundary", async () => {
47
+ // Start aligned to a window boundary so window math is exact.
48
+ // limit=10, windowSec=60. Fill the first window to the limit, then
49
+ // step to the start of the next window. At t=0 into the new window,
50
+ // the prev-window weight is 1.0, so the sliding estimate equals the
51
+ // prev count (10) and the next request should still be rejected.
52
+ let now = 960;
53
+ const limiter = createMemoryRateLimiter(() => now);
54
+ for (let i = 0; i < 10; i++) {
55
+ const r = await limiter.check("k", { limit: 10, windowSec: 60 });
56
+ expect(r.ok).toBe(true);
57
+ }
58
+
59
+ now = 960 + 60; // exact start of the next window
60
+ const justAfter = await limiter.check("k", { limit: 10, windowSec: 60 });
61
+ expect(justAfter.ok).toBe(false);
62
+
63
+ // Well into the next window the prev-window weight decays; capacity
64
+ // should become available again.
65
+ now = 960 + 60 + 59; // 59s into the new window, weight ≈ 1/60
66
+ const released = await limiter.check("k", { limit: 10, windowSec: 60 });
67
+ expect(released.ok).toBe(true);
68
+ });
69
+ });
@@ -246,6 +246,115 @@ describe("extractBodyText", () => {
246
246
  expect(result).toContain("Below rule");
247
247
  });
248
248
 
249
+ it("includes link mark hrefs when includeLinkHrefs is true", () => {
250
+ const doc = JSON.stringify({
251
+ type: "doc",
252
+ content: [
253
+ {
254
+ type: "paragraph",
255
+ content: [
256
+ { type: "text", text: "See " },
257
+ {
258
+ type: "text",
259
+ text: "this page",
260
+ marks: [
261
+ {
262
+ type: "link",
263
+ attrs: { href: "https://example.com/foo" },
264
+ },
265
+ ],
266
+ },
267
+ { type: "text", text: " for details." },
268
+ ],
269
+ },
270
+ ],
271
+ });
272
+
273
+ const result = extractBodyText(doc, { includeLinkHrefs: true });
274
+ expect(result).toContain("this page");
275
+ expect(result).toContain("https://example.com/foo");
276
+ expect(result).toContain("See");
277
+ expect(result).toContain("for details.");
278
+ });
279
+
280
+ it("omits link mark hrefs by default (plain-text contract)", () => {
281
+ const doc = JSON.stringify({
282
+ type: "doc",
283
+ content: [
284
+ {
285
+ type: "paragraph",
286
+ content: [
287
+ { type: "text", text: "See " },
288
+ {
289
+ type: "text",
290
+ text: "this page",
291
+ marks: [
292
+ {
293
+ type: "link",
294
+ attrs: { href: "https://example.com/foo" },
295
+ },
296
+ ],
297
+ },
298
+ { type: "text", text: "." },
299
+ ],
300
+ },
301
+ ],
302
+ });
303
+
304
+ const result = extractBodyText(doc);
305
+ expect(result).toContain("this page");
306
+ expect(result).not.toContain("example.com");
307
+ expect(result).not.toContain("https://");
308
+ });
309
+
310
+ it("ignores non-link marks (bold, italic, code, etc.)", () => {
311
+ const doc = JSON.stringify({
312
+ type: "doc",
313
+ content: [
314
+ {
315
+ type: "paragraph",
316
+ content: [
317
+ {
318
+ type: "text",
319
+ text: "emphasized",
320
+ marks: [{ type: "bold" }, { type: "italic" }],
321
+ },
322
+ ],
323
+ },
324
+ ],
325
+ });
326
+
327
+ expect(extractBodyText(doc, { includeLinkHrefs: true })).toBe("emphasized");
328
+ });
329
+
330
+ it("skips link marks with empty or whitespace-only hrefs", () => {
331
+ const doc = JSON.stringify({
332
+ type: "doc",
333
+ content: [
334
+ {
335
+ type: "paragraph",
336
+ content: [
337
+ {
338
+ type: "text",
339
+ text: "broken",
340
+ marks: [{ type: "link", attrs: { href: " " } }],
341
+ },
342
+ {
343
+ type: "text",
344
+ text: "also-broken",
345
+ marks: [{ type: "link", attrs: {} }],
346
+ },
347
+ ],
348
+ },
349
+ ],
350
+ });
351
+
352
+ const result = extractBodyText(doc, { includeLinkHrefs: true });
353
+ expect(result).toContain("broken");
354
+ expect(result).toContain("also-broken");
355
+ expect(result).not.toMatch(/https?:\/\//);
356
+ });
357
+
249
358
  it("returns null for invalid JSON", () => {
250
359
  expect(extractBodyText("not json")).toBeNull();
251
360
  expect(extractBodyText("{invalid")).toBeNull();
@@ -454,6 +563,37 @@ describe("extractSummary", () => {
454
563
  expect(extractSummary(doc, 5, 500)).toBe("After decorations");
455
564
  });
456
565
 
566
+ it("does not leak link mark URLs into the plaintext summary", () => {
567
+ // Regression guard: extractBodyText (for search) includes link hrefs,
568
+ // but extractSummary (for feeds/meta descriptions) must not.
569
+ const doc = JSON.stringify({
570
+ type: "doc",
571
+ content: [
572
+ {
573
+ type: "paragraph",
574
+ content: [
575
+ { type: "text", text: "See " },
576
+ {
577
+ type: "text",
578
+ text: "this link",
579
+ marks: [
580
+ {
581
+ type: "link",
582
+ attrs: { href: "https://example.com/foo" },
583
+ },
584
+ ],
585
+ },
586
+ { type: "text", text: "." },
587
+ ],
588
+ },
589
+ ],
590
+ });
591
+
592
+ const result = extractSummary(doc, 5, 500);
593
+ expect(result).toBe("See this link.");
594
+ expect(result).not.toContain("example.com");
595
+ });
596
+
457
597
  it("returns null for invalid JSON", () => {
458
598
  expect(extractSummary("not json", 5, 500)).toBeNull();
459
599
  });
@@ -206,6 +206,72 @@ describe("toPostView", () => {
206
206
  expect(view.summaryHasMore).toBe(true);
207
207
  });
208
208
 
209
+ it("injects #continue anchor at the block boundary when body has a leading <hr>", () => {
210
+ // Regression: structural nodes like `horizontalRule` appear in bodyHtml
211
+ // but are excluded from the summary. Slicing bodyHtml by summary.length
212
+ // landed mid-tag (e.g. inside </h2>), producing corrupted markup like
213
+ // `<h2>...&lt;<span id="continue"></span>/h2&gt;`.
214
+ const textA = "A".repeat(300);
215
+ const textB = "B".repeat(300);
216
+ const body = JSON.stringify({
217
+ type: "doc",
218
+ content: [
219
+ { type: "horizontalRule" },
220
+ {
221
+ type: "paragraph",
222
+ content: [{ type: "text", text: textA }],
223
+ },
224
+ {
225
+ type: "paragraph",
226
+ content: [{ type: "text", text: textB }],
227
+ },
228
+ ],
229
+ });
230
+ const p1 = `<p>${textA}</p>`;
231
+ const p2 = `<p>${textB}</p>`;
232
+ const bodyHtml = `<hr>${p1}${p2}`;
233
+ const view = toPostView(
234
+ makePostWithMedia({ title: "Article", body, bodyHtml }),
235
+ EMPTY_CTX,
236
+ );
237
+ expect(view.summaryHasMore).toBe(true);
238
+ expect(view.bodyHtml).toBe(`<hr>${p1}<span id="continue"></span>${p2}`);
239
+ // Must not corrupt tags by splitting them mid-character.
240
+ expect(view.bodyHtml).not.toContain("&lt;");
241
+ expect(view.bodyHtml).not.toContain("&gt;");
242
+ });
243
+
244
+ it("places the #continue anchor at the moreBreak boundary", () => {
245
+ const body = JSON.stringify({
246
+ type: "doc",
247
+ content: [
248
+ {
249
+ type: "paragraph",
250
+ content: [{ type: "text", text: "Lead" }],
251
+ },
252
+ { type: "moreBreak" },
253
+ {
254
+ type: "paragraph",
255
+ content: [{ type: "text", text: "Rest" }],
256
+ },
257
+ ],
258
+ });
259
+ const view = toPostView(
260
+ makePostWithMedia({
261
+ title: "Article",
262
+ body,
263
+ bodyHtml: "<p>Lead</p><!--more--><p>Rest</p>",
264
+ }),
265
+ EMPTY_CTX,
266
+ );
267
+ expect(view.summaryHasMore).toBe(true);
268
+ // The anchor sits at the summary boundary. The `<!--more-->` marker is an
269
+ // inert HTML comment, so it's fine to keep in the post-anchor body.
270
+ expect(view.bodyHtml).toBe(
271
+ '<p>Lead</p><span id="continue"></span><!--more--><p>Rest</p>',
272
+ );
273
+ });
274
+
209
275
  it("does not compute summaryHtml for posts without title", () => {
210
276
  const view = toPostView(
211
277
  makePostWithMedia({