@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
@@ -113,6 +113,13 @@
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
116
123
  }
117
124
  ]
118
- }
125
+ }
@@ -855,3 +855,21 @@ export const githubAppInstallation = pgTable(
855
855
  ),
856
856
  ],
857
857
  );
858
+
859
+ // ---------------------------------------------------------------------------
860
+ // Rate Limit
861
+ // ---------------------------------------------------------------------------
862
+
863
+ /**
864
+ * Per-key sliding-window rate-limit counters. Mirrors the SQLite `rate_limit`
865
+ * table — kept in lockstep because both dialects are production targets.
866
+ */
867
+ export const rateLimit = pgTable(
868
+ "rate_limit",
869
+ {
870
+ key: text("key").notNull(),
871
+ windowStart: integer("window_start").notNull(),
872
+ count: integer("count").notNull().default(0),
873
+ },
874
+ (table) => [primaryKey({ columns: [table.key, table.windowStart] })],
875
+ );
package/src/db/schema.ts CHANGED
@@ -813,3 +813,26 @@ export const githubAppInstallation = sqliteTable(
813
813
  ),
814
814
  ],
815
815
  );
816
+
817
+ // =============================================================================
818
+ // Rate Limit
819
+ // =============================================================================
820
+
821
+ /**
822
+ * Per-key sliding-window rate-limit counters. Used by the Cloudflare Workers
823
+ * runtime; Node deployments keep the state in memory instead.
824
+ *
825
+ * `key` is typically a scope prefix plus client IP (e.g. "search:1.2.3.4").
826
+ * `window_start` is the aligned start of the window in Unix seconds — we
827
+ * store two adjacent windows per key to support the sliding-window-counter
828
+ * algorithm.
829
+ */
830
+ export const rateLimit = sqliteTable(
831
+ "rate_limit",
832
+ {
833
+ key: text("key").notNull(),
834
+ windowStart: integer("window_start").notNull(),
835
+ count: integer("count").notNull().default(0),
836
+ },
837
+ (table) => [primaryKey({ columns: [table.key, table.windowStart] })],
838
+ );
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({
package/src/lib/feed.ts CHANGED
@@ -10,12 +10,7 @@
10
10
  * ```
11
11
  */
12
12
 
13
- import type {
14
- FeedData,
15
- FeedPostView,
16
- PostView,
17
- SitemapData,
18
- } from "../types.js";
13
+ import type { FeedData, FeedPostView, PostView } from "../types.js";
19
14
  import { extractDisplayDomain } from "./url.js";
20
15
 
21
16
  /**
@@ -242,40 +237,81 @@ export function defaultFeedRenderer(data: FeedData): string {
242
237
  }
243
238
 
244
239
  /**
245
- * Default Sitemap renderer.
246
- *
247
- * @param data - Sitemap data with PostView[]
248
- * @returns Sitemap XML string
240
+ * Maximum URLs per sitemap shard. The sitemap.xml spec allows up to 50,000
241
+ * per file; 500 keeps individual shards cheap to generate on D1 and makes old
242
+ * (already-filled) shards small enough to cache aggressively at the edge.
249
243
  */
250
- export function defaultSitemapRenderer(data: SitemapData): string {
251
- const { siteUrl, sitemapUrl, posts } = data;
244
+ export const SITEMAP_SHARD_SIZE = 500;
252
245
 
253
- const postUrls = posts
254
- .map((post) => {
255
- const loc = escapeXml(new URL(post.permalink, siteUrl).toString());
256
- const lastmod = post.updatedAt.split("T")[0];
257
- const priority = post.featured ? "0.8" : "0.6";
246
+ /** One `<url>` entry inside a sitemap `<urlset>`. */
247
+ export interface SitemapUrlEntry {
248
+ loc: string;
249
+ /** ISO date (YYYY-MM-DD) or full ISO datetime */
250
+ lastmod?: string;
251
+ changefreq?:
252
+ | "always"
253
+ | "hourly"
254
+ | "daily"
255
+ | "weekly"
256
+ | "monthly"
257
+ | "yearly"
258
+ | "never";
259
+ /** "0.0" – "1.0" */
260
+ priority?: string;
261
+ }
258
262
 
259
- return `
260
- <url>
261
- <loc>${loc}</loc>
262
- <lastmod>${lastmod}</lastmod>
263
- <priority>${priority}</priority>
264
- </url>`;
265
- })
266
- .join("");
263
+ /** One `<sitemap>` entry inside a `<sitemapindex>`. */
264
+ export interface SitemapIndexEntry {
265
+ loc: string;
266
+ lastmod?: string;
267
+ }
267
268
 
268
- const homepageUrl = `
269
- <url>
270
- <loc>${escapeXml(siteUrl)}</loc>
271
- <priority>1.0</priority>
272
- <changefreq>daily</changefreq>
273
- </url>`;
269
+ /**
270
+ * Render a sitemap `<urlset>` XML document from a list of URL entries.
271
+ *
272
+ * Used by the sharded sitemap endpoints in `routes/feed/sitemap.ts`.
273
+ */
274
+ export function renderSitemapUrlSet(entries: SitemapUrlEntry[]): string {
275
+ const urls = entries
276
+ .map((entry) => {
277
+ const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
278
+ if (entry.lastmod) {
279
+ parts.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
280
+ }
281
+ if (entry.changefreq) {
282
+ parts.push(
283
+ ` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`,
284
+ );
285
+ }
286
+ if (entry.priority) {
287
+ parts.push(` <priority>${escapeXml(entry.priority)}</priority>`);
288
+ }
289
+ return ` <url>\n${parts.join("\n")}\n </url>`;
290
+ })
291
+ .join("\n");
274
292
 
275
293
  return `<?xml version="1.0" encoding="UTF-8"?>
276
294
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
277
- <!-- Generated from ${escapeXml(sitemapUrl)} -->
278
- ${homepageUrl}
279
- ${postUrls}
295
+ ${urls}
280
296
  </urlset>`;
281
297
  }
298
+
299
+ /**
300
+ * Render a `<sitemapindex>` XML document listing shard sitemap URLs.
301
+ */
302
+ export function renderSitemapIndex(entries: SitemapIndexEntry[]): string {
303
+ const items = entries
304
+ .map((entry) => {
305
+ const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
306
+ if (entry.lastmod) {
307
+ parts.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
308
+ }
309
+ return ` <sitemap>\n${parts.join("\n")}\n </sitemap>`;
310
+ })
311
+ .join("\n");
312
+
313
+ return `<?xml version="1.0" encoding="UTF-8"?>
314
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
315
+ ${items}
316
+ </sitemapindex>`;
317
+ }