@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
|
@@ -19,6 +19,7 @@ import { createSiteMemberService } from "../../../services/site-member.js";
|
|
|
19
19
|
import { errorHandler } from "../../../middleware/error-handler.js";
|
|
20
20
|
import { createI18n } from "../../../i18n/i18n.js";
|
|
21
21
|
import { DEFAULT_APP_PORT } from "../../../lib/env.js";
|
|
22
|
+
import { createMemoryRateLimiter } from "../../../lib/rate-limit-memory.js";
|
|
22
23
|
import { resolveConfig } from "../../../lib/resolve-config.js";
|
|
23
24
|
import type { Database } from "../../../db/index.js";
|
|
24
25
|
import type { StorageDriver, UploadedPart } from "../../../lib/storage.js";
|
|
@@ -265,6 +266,7 @@ function createTestAppWithStorage(options: {
|
|
|
265
266
|
c.set("allSettings", allSettings);
|
|
266
267
|
c.set("appConfig", resolveConfig(c.env, allSettings));
|
|
267
268
|
c.set("storage", options.storage);
|
|
269
|
+
c.set("rateLimiter", createMemoryRateLimiter());
|
|
268
270
|
|
|
269
271
|
const i18n = createI18n("en");
|
|
270
272
|
c.set("lang", "en");
|
|
@@ -276,20 +278,25 @@ function createTestAppWithStorage(options: {
|
|
|
276
278
|
"test-user",
|
|
277
279
|
"owner",
|
|
278
280
|
);
|
|
281
|
+
const session = {
|
|
282
|
+
user: { id: "test-user", email: "test@test.com", name: "Test" },
|
|
283
|
+
session: { id: "test-session" },
|
|
284
|
+
} as unknown as AppVariables["session"];
|
|
279
285
|
c.set("auth", {
|
|
280
286
|
api: {
|
|
281
|
-
getSession: async () =>
|
|
282
|
-
user: { id: "test-user", email: "test@test.com", name: "Test" },
|
|
283
|
-
session: { id: "test-session" },
|
|
284
|
-
}),
|
|
287
|
+
getSession: async () => session,
|
|
285
288
|
},
|
|
286
289
|
} as AppVariables["auth"]);
|
|
290
|
+
c.set("session", session);
|
|
291
|
+
c.set("isAuthenticated", true);
|
|
287
292
|
} else {
|
|
288
293
|
c.set("auth", {
|
|
289
294
|
api: {
|
|
290
295
|
getSession: async () => null,
|
|
291
296
|
},
|
|
292
297
|
} as AppVariables["auth"]);
|
|
298
|
+
c.set("session", null);
|
|
299
|
+
c.set("isAuthenticated", false);
|
|
293
300
|
}
|
|
294
301
|
|
|
295
302
|
await next();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { requireInternalAdminApi } from "../../../middleware/auth.js";
|
|
4
|
+
import { parseValidated } from "../../../lib/schemas.js";
|
|
5
|
+
import type { Bindings } from "../../../types.js";
|
|
6
|
+
import type { AppVariables } from "../../../types/app-context.js";
|
|
7
|
+
|
|
8
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
9
|
+
|
|
10
|
+
const ReindexSchema = z.object({
|
|
11
|
+
limit: z.number().int().positive().max(500).optional(),
|
|
12
|
+
cursor: z.string().min(1).optional(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const internalSearchReindexRoutes = new Hono<Env>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Rebuild `post.body_text` for a batch of non-deleted posts. FTS indexes
|
|
19
|
+
* (SQLite trigger / Postgres generated column) refresh automatically when
|
|
20
|
+
* `body_text` changes.
|
|
21
|
+
*
|
|
22
|
+
* Idempotent. Callers loop with the returned `nextCursor` until `done: true`.
|
|
23
|
+
* Used by the `jant search-reindex` CLI to backfill search indexes for
|
|
24
|
+
* existing posts after changes to the text extraction logic (e.g. including
|
|
25
|
+
* link mark hrefs so inline URLs become searchable).
|
|
26
|
+
*/
|
|
27
|
+
internalSearchReindexRoutes.post("/", requireInternalAdminApi(), async (c) => {
|
|
28
|
+
const contentType = c.req.header("Content-Type") || "";
|
|
29
|
+
const rawBody = contentType.includes("application/json")
|
|
30
|
+
? await c.req.json().catch(() => ({}))
|
|
31
|
+
: {};
|
|
32
|
+
const body = parseValidated(ReindexSchema, rawBody);
|
|
33
|
+
|
|
34
|
+
const result = await c.var.services.posts.reindexBodyText({
|
|
35
|
+
limit: body.limit,
|
|
36
|
+
cursor: body.cursor,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return c.json(result);
|
|
40
|
+
});
|
package/src/routes/api/search.ts
CHANGED
|
@@ -7,11 +7,24 @@ import type { Bindings } from "../../types.js";
|
|
|
7
7
|
import type { AppVariables } from "../../types/app-context.js";
|
|
8
8
|
import { ValidationError, ExternalServiceError } from "../../lib/errors.js";
|
|
9
9
|
import { toSearchApiResult } from "../../lib/api-search.js";
|
|
10
|
+
import { rateLimit } from "../../middleware/rate-limit.js";
|
|
10
11
|
|
|
11
12
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
12
13
|
|
|
13
14
|
export const searchApiRoutes = new Hono<Env>();
|
|
14
15
|
|
|
16
|
+
// Per-IP rate limit. The request-time wrapper is needed because the
|
|
17
|
+
// per-minute cap is pulled from `appConfig` which is only available on
|
|
18
|
+
// `c.var`; constructing the middleware once at module load would capture
|
|
19
|
+
// an undefined value.
|
|
20
|
+
searchApiRoutes.use("*", async (c, next) =>
|
|
21
|
+
rateLimit({
|
|
22
|
+
name: "search",
|
|
23
|
+
limit: c.var.appConfig.rateLimit.searchPerMinute,
|
|
24
|
+
windowSec: 60,
|
|
25
|
+
})(c, next),
|
|
26
|
+
);
|
|
27
|
+
|
|
15
28
|
// Search posts
|
|
16
29
|
searchApiRoutes.get("/", async (c) => {
|
|
17
30
|
const query = c.req.query("q");
|
package/src/routes/auth/dev.ts
CHANGED
|
@@ -65,7 +65,7 @@ devAuthRoutes.get("/__dev/login", async (c) => {
|
|
|
65
65
|
});
|
|
66
66
|
} catch {
|
|
67
67
|
return c.text(
|
|
68
|
-
"Dev login failed. Finish /setup once or run `mise run db-
|
|
68
|
+
"Dev login failed. Finish /setup once or run `mise run db-wrangler-rebuild-demo`, then retry.",
|
|
69
69
|
500,
|
|
70
70
|
);
|
|
71
71
|
}
|
|
@@ -14,7 +14,7 @@ import { SigninSchema } from "../../lib/schemas.js";
|
|
|
14
14
|
import { buildPageTitle } from "../../lib/page-title.js";
|
|
15
15
|
import { getI18n } from "../../i18n/index.js";
|
|
16
16
|
import { getHostedControlPlaneSigninUrl } from "../../lib/hosted-signin.js";
|
|
17
|
-
import { toPublicPath } from "../../lib/url.js";
|
|
17
|
+
import { isSafeInternalRedirect, toPublicPath } from "../../lib/url.js";
|
|
18
18
|
|
|
19
19
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
20
20
|
|
|
@@ -22,11 +22,13 @@ const SigninContent: FC<{
|
|
|
22
22
|
demoEmail?: string;
|
|
23
23
|
demoPassword?: string;
|
|
24
24
|
sitePathPrefix?: string;
|
|
25
|
-
|
|
25
|
+
redirect?: string;
|
|
26
|
+
}> = ({ demoEmail, demoPassword, sitePathPrefix = "", redirect }) => {
|
|
26
27
|
const { i18n } = useLingui();
|
|
27
28
|
const signals = JSON.stringify({
|
|
28
29
|
email: demoEmail || "",
|
|
29
30
|
password: demoPassword || "",
|
|
31
|
+
...(redirect ? { redirect } : {}),
|
|
30
32
|
}).replace(/</g, "\\u003c");
|
|
31
33
|
|
|
32
34
|
return (
|
|
@@ -121,9 +123,15 @@ const SigninContent: FC<{
|
|
|
121
123
|
export const signinRoutes = new Hono<Env>();
|
|
122
124
|
|
|
123
125
|
signinRoutes.get("/signin", async (c) => {
|
|
126
|
+
const rawRedirect = c.req.query("redirect");
|
|
127
|
+
const redirect = isSafeInternalRedirect(rawRedirect)
|
|
128
|
+
? rawRedirect
|
|
129
|
+
: undefined;
|
|
130
|
+
|
|
124
131
|
const hostedSigninUrl = getHostedControlPlaneSigninUrl(
|
|
125
132
|
c.env,
|
|
126
133
|
c.var.publicRequestUrl,
|
|
134
|
+
redirect,
|
|
127
135
|
);
|
|
128
136
|
if (hostedSigninUrl) {
|
|
129
137
|
return c.redirect(hostedSigninUrl);
|
|
@@ -157,6 +165,7 @@ signinRoutes.get("/signin", async (c) => {
|
|
|
157
165
|
demoEmail={c.var.appConfig.demoEmail}
|
|
158
166
|
demoPassword={c.var.appConfig.demoPassword}
|
|
159
167
|
sitePathPrefix={c.var.appConfig.sitePathPrefix}
|
|
168
|
+
redirect={redirect}
|
|
160
169
|
/>
|
|
161
170
|
</BaseLayout>,
|
|
162
171
|
);
|
|
@@ -195,6 +204,14 @@ signinRoutes.post("/signin", async (c) => {
|
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
const { email, password } = parsed.data;
|
|
207
|
+
const rawRedirect =
|
|
208
|
+
body && typeof body === "object" && "redirect" in body
|
|
209
|
+
? (body as { redirect?: unknown }).redirect
|
|
210
|
+
: undefined;
|
|
211
|
+
const redirectTarget =
|
|
212
|
+
typeof rawRedirect === "string" && isSafeInternalRedirect(rawRedirect)
|
|
213
|
+
? rawRedirect
|
|
214
|
+
: "/";
|
|
198
215
|
|
|
199
216
|
try {
|
|
200
217
|
const { headers } = await c.var.auth.api.signInEmail({
|
|
@@ -203,9 +220,10 @@ signinRoutes.post("/signin", async (c) => {
|
|
|
203
220
|
headers: c.req.raw.headers,
|
|
204
221
|
});
|
|
205
222
|
|
|
206
|
-
return dsRedirect(
|
|
207
|
-
|
|
208
|
-
|
|
223
|
+
return dsRedirect(
|
|
224
|
+
toPublicPath(redirectTarget, c.var.appConfig.sitePathPrefix),
|
|
225
|
+
{ headers },
|
|
226
|
+
);
|
|
209
227
|
} catch {
|
|
210
228
|
return dsToast(
|
|
211
229
|
i18n._(
|
|
@@ -1016,11 +1016,9 @@ settingsRoutes.get("/account/sessions", async (c) => {
|
|
|
1016
1016
|
|
|
1017
1017
|
const navData = await getNavigationData(c);
|
|
1018
1018
|
|
|
1019
|
-
//
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
});
|
|
1023
|
-
const currentToken = currentSession?.session?.token ?? "";
|
|
1019
|
+
// Session was pre-fetched by `attachSession`; this route is behind
|
|
1020
|
+
// `requireAuth`, so it's guaranteed to be present here.
|
|
1021
|
+
const currentToken = c.var.session?.session?.token ?? "";
|
|
1024
1022
|
|
|
1025
1023
|
// List all active sessions
|
|
1026
1024
|
const rawSessions = await c.var.auth.api.listSessions({
|
|
@@ -4,15 +4,18 @@ import type { Bindings } from "../../../types.js";
|
|
|
4
4
|
import type { AppVariables } from "../../../types/app-context.js";
|
|
5
5
|
import { DEFAULT_APP_PORT } from "../../../lib/env.js";
|
|
6
6
|
import { resolveConfig } from "../../../lib/resolve-config.js";
|
|
7
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
7
8
|
import { sitemapRoutes } from "../sitemap.js";
|
|
9
|
+
import { SITEMAP_SHARD_SIZE } from "../../../lib/feed.js";
|
|
8
10
|
|
|
9
11
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
10
12
|
const TEST_SITE_ORIGIN = `http://localhost:${DEFAULT_APP_PORT}`;
|
|
11
13
|
|
|
12
|
-
function
|
|
14
|
+
function createRobotsTestApp(
|
|
13
15
|
allSettings: Record<string, string> = {},
|
|
14
16
|
envOverrides: Partial<Bindings> = {},
|
|
15
17
|
) {
|
|
18
|
+
// robots.txt doesn't need service wiring; keep a minimal harness for it.
|
|
16
19
|
const app = new Hono<Env>();
|
|
17
20
|
|
|
18
21
|
app.use("*", async (c, next) => {
|
|
@@ -27,14 +30,19 @@ function createSitemapTestApp(
|
|
|
27
30
|
});
|
|
28
31
|
|
|
29
32
|
app.route("/", sitemapRoutes);
|
|
30
|
-
|
|
31
33
|
return app;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
function createSitemapTestApp() {
|
|
37
|
+
const testApp = createTestApp();
|
|
38
|
+
testApp.app.route("/", sitemapRoutes);
|
|
39
|
+
return testApp;
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
describe("Sitemap Routes", () => {
|
|
35
43
|
describe("/robots.txt", () => {
|
|
36
44
|
it("disallows internal utility routes while allowing the public site", async () => {
|
|
37
|
-
const app =
|
|
45
|
+
const app = createRobotsTestApp();
|
|
38
46
|
|
|
39
47
|
const res = await app.request("/robots.txt");
|
|
40
48
|
|
|
@@ -49,7 +57,7 @@ describe("Sitemap Routes", () => {
|
|
|
49
57
|
});
|
|
50
58
|
|
|
51
59
|
it("disallows the entire site when global noindex is enabled", async () => {
|
|
52
|
-
const app =
|
|
60
|
+
const app = createRobotsTestApp({ NOINDEX: "true" });
|
|
53
61
|
|
|
54
62
|
const res = await app.request("/robots.txt");
|
|
55
63
|
|
|
@@ -61,4 +69,312 @@ describe("Sitemap Routes", () => {
|
|
|
61
69
|
expect(robots).not.toContain("Disallow: /_/");
|
|
62
70
|
});
|
|
63
71
|
});
|
|
72
|
+
|
|
73
|
+
describe("/sitemap.xml (index)", () => {
|
|
74
|
+
it("lists pages shard + one post shard when there are a few posts", async () => {
|
|
75
|
+
const { app, services } = createSitemapTestApp();
|
|
76
|
+
for (let i = 0; i < 3; i++) {
|
|
77
|
+
await services.posts.create({
|
|
78
|
+
format: "note",
|
|
79
|
+
bodyMarkdown: `post ${i}`,
|
|
80
|
+
status: "published",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const res = await app.request("/sitemap.xml");
|
|
85
|
+
expect(res.status).toBe(200);
|
|
86
|
+
expect(res.headers.get("Content-Type")).toContain("application/xml");
|
|
87
|
+
|
|
88
|
+
const xml = await res.text();
|
|
89
|
+
expect(xml).toContain("<sitemapindex");
|
|
90
|
+
expect(xml).toContain("/sitemap-pages.xml");
|
|
91
|
+
expect(xml).toContain("/sitemap-posts-1.xml");
|
|
92
|
+
expect(xml).not.toContain("/sitemap-posts-2.xml");
|
|
93
|
+
// No collections → no collections shard entry
|
|
94
|
+
expect(xml).not.toContain("/sitemap-collections.xml");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("lists multiple post shards when post count exceeds shard size", async () => {
|
|
98
|
+
const { app, services } = createSitemapTestApp();
|
|
99
|
+
// Create enough posts to span 2 shards. Use shard_size + 1 to minimize
|
|
100
|
+
// work while still forcing a second shard.
|
|
101
|
+
for (let i = 0; i < SITEMAP_SHARD_SIZE + 1; i++) {
|
|
102
|
+
await services.posts.create({
|
|
103
|
+
format: "note",
|
|
104
|
+
bodyMarkdown: `p${i}`,
|
|
105
|
+
status: "published",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const res = await app.request("/sitemap.xml");
|
|
110
|
+
const xml = await res.text();
|
|
111
|
+
expect(xml).toContain("/sitemap-posts-1.xml");
|
|
112
|
+
expect(xml).toContain("/sitemap-posts-2.xml");
|
|
113
|
+
expect(xml).not.toContain("/sitemap-posts-3.xml");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("includes the collections shard when collections exist", async () => {
|
|
117
|
+
const { app, services } = createSitemapTestApp();
|
|
118
|
+
await services.collections.create({
|
|
119
|
+
slug: "reading",
|
|
120
|
+
title: "Reading",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const res = await app.request("/sitemap.xml");
|
|
124
|
+
const xml = await res.text();
|
|
125
|
+
expect(xml).toContain("/sitemap-collections.xml");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("/sitemap-posts-N.xml", () => {
|
|
130
|
+
it("emits <url> entries for page 1 in ascending id order", async () => {
|
|
131
|
+
const { app, services } = createSitemapTestApp();
|
|
132
|
+
const created: string[] = [];
|
|
133
|
+
for (let i = 0; i < 3; i++) {
|
|
134
|
+
const post = await services.posts.create({
|
|
135
|
+
format: "note",
|
|
136
|
+
bodyMarkdown: `post ${i}`,
|
|
137
|
+
title: `Post ${i}`,
|
|
138
|
+
status: "published",
|
|
139
|
+
});
|
|
140
|
+
created.push(post.slug);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const res = await app.request("/sitemap-posts-1.xml");
|
|
144
|
+
expect(res.status).toBe(200);
|
|
145
|
+
|
|
146
|
+
const xml = await res.text();
|
|
147
|
+
expect(xml).toContain("<urlset");
|
|
148
|
+
for (const slug of created) {
|
|
149
|
+
expect(xml).toContain(`/${slug}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Slugs appear in id-ascending order (== creation order for TypeIDs).
|
|
153
|
+
const indices = created.map((slug) => xml.indexOf(`/${slug}`));
|
|
154
|
+
expect(indices).toEqual([...indices].sort((a, b) => a - b));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("excludes private posts, replies, and drafts", async () => {
|
|
158
|
+
const { app, services } = createSitemapTestApp();
|
|
159
|
+
const root = await services.posts.create({
|
|
160
|
+
format: "note",
|
|
161
|
+
bodyMarkdown: "root",
|
|
162
|
+
status: "published",
|
|
163
|
+
});
|
|
164
|
+
const reply = await services.posts.create({
|
|
165
|
+
format: "note",
|
|
166
|
+
bodyMarkdown: "reply",
|
|
167
|
+
replyToId: root.id,
|
|
168
|
+
status: "published",
|
|
169
|
+
});
|
|
170
|
+
const priv = await services.posts.create({
|
|
171
|
+
format: "note",
|
|
172
|
+
bodyMarkdown: "private",
|
|
173
|
+
visibility: "private",
|
|
174
|
+
status: "published",
|
|
175
|
+
});
|
|
176
|
+
const draft = await services.posts.create({
|
|
177
|
+
format: "note",
|
|
178
|
+
bodyMarkdown: "draft",
|
|
179
|
+
status: "draft",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const xml = await (await app.request("/sitemap-posts-1.xml")).text();
|
|
183
|
+
expect(xml).toContain(`/${root.slug}`);
|
|
184
|
+
expect(xml).not.toContain(`/${reply.slug}`);
|
|
185
|
+
expect(xml).not.toContain(`/${priv.slug}`);
|
|
186
|
+
expect(xml).not.toContain(`/${draft.slug}`);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("includes latest_hidden posts (they are public URLs)", async () => {
|
|
190
|
+
const { app, services } = createSitemapTestApp();
|
|
191
|
+
const hidden = await services.posts.create({
|
|
192
|
+
format: "note",
|
|
193
|
+
bodyMarkdown: "hidden",
|
|
194
|
+
visibility: "latest_hidden",
|
|
195
|
+
status: "published",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const xml = await (await app.request("/sitemap-posts-1.xml")).text();
|
|
199
|
+
expect(xml).toContain(`/${hidden.slug}`);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("splits content across shards at SITEMAP_SHARD_SIZE boundaries", async () => {
|
|
203
|
+
const { app, services } = createSitemapTestApp();
|
|
204
|
+
const created: string[] = [];
|
|
205
|
+
for (let i = 0; i < SITEMAP_SHARD_SIZE + 2; i++) {
|
|
206
|
+
const post = await services.posts.create({
|
|
207
|
+
format: "note",
|
|
208
|
+
bodyMarkdown: `p${i}`,
|
|
209
|
+
status: "published",
|
|
210
|
+
});
|
|
211
|
+
created.push(post.slug);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const page1 = await (await app.request("/sitemap-posts-1.xml")).text();
|
|
215
|
+
const page2 = await (await app.request("/sitemap-posts-2.xml")).text();
|
|
216
|
+
|
|
217
|
+
// First 500 slugs in page 1
|
|
218
|
+
expect(page1).toContain(`/${created[0]}`);
|
|
219
|
+
expect(page1).toContain(`/${created[SITEMAP_SHARD_SIZE - 1]}`);
|
|
220
|
+
expect(page1).not.toContain(`/${created[SITEMAP_SHARD_SIZE]}`);
|
|
221
|
+
|
|
222
|
+
// Remainder in page 2
|
|
223
|
+
expect(page2).toContain(`/${created[SITEMAP_SHARD_SIZE]}`);
|
|
224
|
+
expect(page2).toContain(`/${created[SITEMAP_SHARD_SIZE + 1]}`);
|
|
225
|
+
expect(page2).not.toContain(`/${created[0]}`);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("returns 404 for a shard beyond the last page", async () => {
|
|
229
|
+
const { app, services } = createSitemapTestApp();
|
|
230
|
+
await services.posts.create({
|
|
231
|
+
format: "note",
|
|
232
|
+
bodyMarkdown: "only",
|
|
233
|
+
status: "published",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const res = await app.request("/sitemap-posts-2.xml");
|
|
237
|
+
expect(res.status).toBe(404);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("uses long cache for a full shard and short cache for a partial shard", async () => {
|
|
241
|
+
const { app, services } = createSitemapTestApp();
|
|
242
|
+
// Exactly one full shard followed by one post in the next shard.
|
|
243
|
+
for (let i = 0; i < SITEMAP_SHARD_SIZE + 1; i++) {
|
|
244
|
+
await services.posts.create({
|
|
245
|
+
format: "note",
|
|
246
|
+
bodyMarkdown: `p${i}`,
|
|
247
|
+
status: "published",
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const full = await app.request("/sitemap-posts-1.xml");
|
|
252
|
+
expect(full.headers.get("Cache-Control")).toContain("86400");
|
|
253
|
+
|
|
254
|
+
const partial = await app.request("/sitemap-posts-2.xml");
|
|
255
|
+
expect(partial.headers.get("Cache-Control")).toContain("max-age=180");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("uses alias in <loc> when a post has one", async () => {
|
|
259
|
+
const { app, services } = createSitemapTestApp();
|
|
260
|
+
const post = await services.posts.create({
|
|
261
|
+
format: "note",
|
|
262
|
+
bodyMarkdown: "body",
|
|
263
|
+
status: "published",
|
|
264
|
+
});
|
|
265
|
+
await services.customUrls.create({
|
|
266
|
+
path: "my-alias",
|
|
267
|
+
targetType: "post",
|
|
268
|
+
targetId: post.id,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const xml = await (await app.request("/sitemap-posts-1.xml")).text();
|
|
272
|
+
expect(xml).toContain(`<loc>${TEST_SITE_ORIGIN}/my-alias</loc>`);
|
|
273
|
+
expect(xml).not.toContain(`/${post.slug}<`);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("emits absolute URLs anchored to the site origin for nested aliases", async () => {
|
|
277
|
+
// Regression: `getPostAliases` returns aliases with a leading "/", so
|
|
278
|
+
// blindly prepending another "/" produces "//blog/foo", which
|
|
279
|
+
// `new URL()` resolves as protocol-relative and hijacks the hostname
|
|
280
|
+
// (e.g. `https://blog/foo`).
|
|
281
|
+
const { app, services } = createSitemapTestApp();
|
|
282
|
+
const post = await services.posts.create({
|
|
283
|
+
format: "note",
|
|
284
|
+
bodyMarkdown: "body",
|
|
285
|
+
status: "published",
|
|
286
|
+
});
|
|
287
|
+
await services.customUrls.create({
|
|
288
|
+
path: "blog/about-notes",
|
|
289
|
+
targetType: "post",
|
|
290
|
+
targetId: post.id,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const xml = await (await app.request("/sitemap-posts-1.xml")).text();
|
|
294
|
+
expect(xml).toContain(`<loc>${TEST_SITE_ORIGIN}/blog/about-notes</loc>`);
|
|
295
|
+
expect(xml).not.toMatch(/<loc>https?:\/\/blog\//);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("/sitemap-collections.xml", () => {
|
|
300
|
+
it("lists public collections", async () => {
|
|
301
|
+
const { app, services } = createSitemapTestApp();
|
|
302
|
+
await services.collections.create({ slug: "reading", title: "Reading" });
|
|
303
|
+
await services.collections.create({ slug: "movies", title: "Movies" });
|
|
304
|
+
|
|
305
|
+
const xml = await (await app.request("/sitemap-collections.xml")).text();
|
|
306
|
+
expect(xml).toContain("<urlset");
|
|
307
|
+
expect(xml).toContain("/reading");
|
|
308
|
+
expect(xml).toContain("/movies");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("does not include the /collections directory page (lives in pages shard)", async () => {
|
|
312
|
+
const { app, services } = createSitemapTestApp();
|
|
313
|
+
await services.collections.create({ slug: "reading", title: "Reading" });
|
|
314
|
+
|
|
315
|
+
const xml = await (await app.request("/sitemap-collections.xml")).text();
|
|
316
|
+
// Only per-collection URLs should appear here; the directory landing
|
|
317
|
+
// is emitted by `/sitemap-pages.xml`.
|
|
318
|
+
expect(xml).not.toContain("<loc>http://localhost:8787/collections</loc>");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("returns an empty urlset when there are no collections", async () => {
|
|
322
|
+
const { app } = createSitemapTestApp();
|
|
323
|
+
const res = await app.request("/sitemap-collections.xml");
|
|
324
|
+
expect(res.status).toBe(200);
|
|
325
|
+
const xml = await res.text();
|
|
326
|
+
expect(xml).toContain("<urlset");
|
|
327
|
+
expect(xml).not.toContain("<url>");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("/sitemap-pages.xml", () => {
|
|
332
|
+
it("lists the homepage with priority 1.0", async () => {
|
|
333
|
+
const { app } = createSitemapTestApp();
|
|
334
|
+
const res = await app.request("/sitemap-pages.xml");
|
|
335
|
+
expect(res.status).toBe(200);
|
|
336
|
+
const xml = await res.text();
|
|
337
|
+
expect(xml).toContain("<urlset");
|
|
338
|
+
expect(xml).toContain("<priority>1.0</priority>");
|
|
339
|
+
expect(xml).toContain("<changefreq>daily</changefreq>");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("includes the archive aggregate page", async () => {
|
|
343
|
+
const { app } = createSitemapTestApp();
|
|
344
|
+
const xml = await (await app.request("/sitemap-pages.xml")).text();
|
|
345
|
+
expect(xml).toContain(`${TEST_SITE_ORIGIN}/archive`);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("includes /latest when the homepage default is 'featured'", async () => {
|
|
349
|
+
const { app, services } = createSitemapTestApp();
|
|
350
|
+
await services.settings.set("HOME_DEFAULT_VIEW", "featured");
|
|
351
|
+
|
|
352
|
+
const xml = await (await app.request("/sitemap-pages.xml")).text();
|
|
353
|
+
expect(xml).toContain(`${TEST_SITE_ORIGIN}/latest`);
|
|
354
|
+
expect(xml).not.toContain(`${TEST_SITE_ORIGIN}/featured`);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("includes /featured when the homepage default is 'latest'", async () => {
|
|
358
|
+
const { app, services } = createSitemapTestApp();
|
|
359
|
+
await services.settings.set("HOME_DEFAULT_VIEW", "latest");
|
|
360
|
+
|
|
361
|
+
const xml = await (await app.request("/sitemap-pages.xml")).text();
|
|
362
|
+
expect(xml).toContain(`${TEST_SITE_ORIGIN}/featured`);
|
|
363
|
+
expect(xml).not.toContain(`${TEST_SITE_ORIGIN}/latest`);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("includes /collections only when collections exist", async () => {
|
|
367
|
+
const { app, services } = createSitemapTestApp();
|
|
368
|
+
|
|
369
|
+
const emptyXml = await (await app.request("/sitemap-pages.xml")).text();
|
|
370
|
+
expect(emptyXml).not.toContain(`${TEST_SITE_ORIGIN}/collections`);
|
|
371
|
+
|
|
372
|
+
await services.collections.create({ slug: "reading", title: "Reading" });
|
|
373
|
+
|
|
374
|
+
const populatedXml = await (
|
|
375
|
+
await app.request("/sitemap-pages.xml")
|
|
376
|
+
).text();
|
|
377
|
+
expect(populatedXml).toContain(`${TEST_SITE_ORIGIN}/collections`);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
64
380
|
});
|