@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.
- package/bin/commands/import-site.js +1 -1
- package/bin/commands/search-reindex.js +175 -0
- package/bin/lib/hugo-markdown.js +102 -0
- package/bin/lib/site-pull-media.js +1 -4
- package/dist/app-Ctl0T0zO.js +5 -0
- package/dist/{app-Cu3lveYI.js → app-GbfwoeDJ.js} +630 -123
- package/dist/client/.vite/manifest.json +1 -1
- package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
- package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
- package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
- package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
- package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
- package/dist/index.js +5 -5
- package/dist/node.js +5 -5
- package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
- package/package.json +1 -1
- package/src/__tests__/helpers/app.ts +15 -4
- package/src/app.tsx +8 -0
- package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
- package/src/client/tiptap/extensions.ts +3 -0
- package/src/client/tiptap/insert-paragraph-around.ts +79 -0
- package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
- package/src/db/migrations/meta/0018_snapshot.json +2225 -0
- package/src/db/migrations/meta/_journal.json +8 -1
- package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
- package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
- package/src/db/migrations/pg/meta/_journal.json +8 -1
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +23 -0
- package/src/index.ts +1 -2
- package/src/lib/__tests__/hosted-signin.test.ts +30 -0
- package/src/lib/__tests__/navigation.test.ts +4 -20
- package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
- package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
- package/src/lib/__tests__/summary.test.ts +140 -0
- package/src/lib/__tests__/view.test.ts +66 -0
- package/src/lib/feed.ts +70 -34
- package/src/lib/hosted-signin.ts +9 -3
- package/src/lib/navigation.ts +11 -12
- package/src/lib/post-meta.ts +20 -2
- package/src/lib/rate-limit-d1.ts +99 -0
- package/src/lib/rate-limit-memory.ts +105 -0
- package/src/lib/rate-limit.ts +63 -0
- package/src/lib/render.tsx +9 -0
- package/src/lib/resolve-config.ts +9 -0
- package/src/lib/summary.ts +42 -7
- package/src/lib/url.ts +34 -0
- package/src/lib/view.ts +42 -8
- package/src/middleware/__tests__/auth.test.ts +44 -4
- package/src/middleware/__tests__/rate-limit.test.ts +113 -0
- package/src/middleware/__tests__/session.test.ts +85 -0
- package/src/middleware/auth.ts +62 -25
- package/src/middleware/rate-limit.ts +54 -0
- package/src/middleware/session.ts +36 -0
- package/src/routes/__tests__/compose.test.ts +1 -1
- package/src/routes/api/__tests__/search.test.ts +48 -0
- package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
- package/src/routes/api/internal/search-reindex.ts +40 -0
- package/src/routes/api/search.ts +13 -0
- package/src/routes/auth/dev.ts +1 -1
- package/src/routes/auth/signin.tsx +23 -5
- package/src/routes/dash/settings.tsx +3 -5
- package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
- package/src/routes/feed/sitemap.ts +208 -33
- package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
- package/src/routes/pages/home.tsx +24 -15
- package/src/routes/pages/page.tsx +34 -0
- package/src/routes/pages/partials.tsx +4 -15
- package/src/runtime/cloudflare.ts +4 -0
- package/src/runtime/node.ts +16 -0
- package/src/services/__tests__/post.test.ts +205 -0
- package/src/services/__tests__/search.test.ts +44 -0
- package/src/services/export.ts +9 -2
- package/src/services/post.ts +200 -2
- package/src/types/app-context.ts +20 -0
- package/src/types/config.ts +8 -0
- package/src/types/props.ts +0 -7
- package/src/ui/layouts/BaseLayout.tsx +9 -0
- 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
|
+
}
|
package/src/db/pg/schema.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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>...<<span id="continue"></span>/h2>`.
|
|
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("<");
|
|
241
|
+
expect(view.bodyHtml).not.toContain(">");
|
|
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
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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
|
|
251
|
-
const { siteUrl, sitemapUrl, posts } = data;
|
|
244
|
+
export const SITEMAP_SHARD_SIZE = 500;
|
|
252
245
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
+
}
|