@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.
- 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-BI9bnCkO.js +5 -0
- package/dist/{app-Cu3lveYI.js → app-CtJDxZBb.js} +969 -373
- package/dist/client/.vite/manifest.json +2 -2
- package/dist/client/_assets/client-BQH7AQ24.css +2 -0
- 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/0019_bored_magus.sql +2 -0
- package/src/db/migrations/meta/0018_snapshot.json +2225 -0
- package/src/db/migrations/meta/0019_snapshot.json +2238 -0
- package/src/db/migrations/meta/_journal.json +15 -1
- package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
- package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
- package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
- package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
- package/src/db/migrations/pg/meta/_journal.json +15 -1
- package/src/db/pg/schema.ts +22 -0
- package/src/db/schema.ts +27 -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/icons.ts +37 -0
- 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/internal/sites.ts +1 -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/__tests__/site-admin.test.ts +85 -0
- package/src/services/export.ts +9 -2
- package/src/services/post.ts +200 -2
- package/src/services/site-admin.ts +66 -1
- package/src/styles/ui.css +12 -0
- 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/feed/LinkCard.tsx +3 -20
- package/src/ui/feed/LinkPreview.tsx +5 -19
- package/src/ui/feed/PostStatusBadges.tsx +4 -38
- package/src/ui/layouts/BaseLayout.tsx +23 -29
- package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
- package/src/ui/shared/Icon.tsx +60 -0
- package/src/ui/shared/IconSprite.tsx +57 -0
- package/src/ui/shared/PostFooter.tsx +6 -62
- package/src/ui/shared/custom-icons.ts +132 -0
- package/src/ui/shared/icon-collector.ts +37 -0
- package/dist/app-DzCB4yOp.js +0 -5
- package/dist/client/_assets/client-C_kImWZj.css +0 -2
|
@@ -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
|
});
|
|
@@ -1,60 +1,235 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sitemap Routes
|
|
3
|
+
*
|
|
4
|
+
* Sitemap is sharded to keep each shard small, cache-friendly, and stable:
|
|
5
|
+
*
|
|
6
|
+
* /sitemap.xml → sitemap index listing all shards
|
|
7
|
+
* /sitemap-posts-N.xml → one shard of published non-reply posts
|
|
8
|
+
* /sitemap-collections.xml → public collection pages
|
|
9
|
+
* /sitemap-pages.xml → homepage + static aggregate pages
|
|
10
|
+
*
|
|
11
|
+
* Post shards are keyset-paginated by post `id` (TypeIDs embed a
|
|
12
|
+
* creation-ordered UUIDv7 timestamp), so once a shard fills up its membership
|
|
13
|
+
* never changes: new posts always land in the last shard, never rewriting an
|
|
14
|
+
* older one. This lets old shards be cached at the edge for a long time.
|
|
3
15
|
*/
|
|
4
16
|
|
|
5
17
|
import { Hono } from "hono";
|
|
6
18
|
import type { Bindings } from "../../types.js";
|
|
7
19
|
import type { AppVariables } from "../../types/app-context.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
20
|
+
import {
|
|
21
|
+
renderSitemapIndex,
|
|
22
|
+
renderSitemapUrlSet,
|
|
23
|
+
SITEMAP_SHARD_SIZE,
|
|
24
|
+
type SitemapIndexEntry,
|
|
25
|
+
type SitemapUrlEntry,
|
|
26
|
+
} from "../../lib/feed.js";
|
|
10
27
|
import { toAbsoluteSiteUrl } from "../../lib/url.js";
|
|
11
28
|
|
|
12
29
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
13
30
|
|
|
14
31
|
export const sitemapRoutes = new Hono<Env>();
|
|
15
32
|
|
|
16
|
-
|
|
33
|
+
const CACHE_SHORT = "public, max-age=180";
|
|
34
|
+
const CACHE_FULL_SHARD = "public, max-age=86400, s-maxage=86400";
|
|
35
|
+
|
|
36
|
+
function xmlResponse(xml: string, cacheControl: string): Response {
|
|
37
|
+
return new Response(xml, {
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
40
|
+
"Cache-Control": cacheControl,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a public URL entry (absolute URL) from an internal path.
|
|
47
|
+
*/
|
|
48
|
+
function absoluteUrl(
|
|
49
|
+
internalPath: string,
|
|
50
|
+
siteUrl: string,
|
|
51
|
+
sitePathPrefix: string,
|
|
52
|
+
): string {
|
|
53
|
+
return toAbsoluteSiteUrl(internalPath, siteUrl, sitePathPrefix);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Convert a unix-seconds timestamp into a `YYYY-MM-DD` string. */
|
|
57
|
+
function toIsoDate(unixSeconds: number): string {
|
|
58
|
+
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// =============================================================================
|
|
62
|
+
// Sitemap Index
|
|
63
|
+
// =============================================================================
|
|
64
|
+
|
|
17
65
|
sitemapRoutes.get("/sitemap.xml", async (c) => {
|
|
18
66
|
const { appConfig } = c.var;
|
|
19
|
-
const siteUrl = appConfig
|
|
67
|
+
const { siteUrl, sitePathPrefix } = appConfig;
|
|
20
68
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
excludeReplies: true,
|
|
24
|
-
excludePrivate: true,
|
|
25
|
-
limit: 1000,
|
|
26
|
-
});
|
|
69
|
+
const postCount = await c.var.services.posts.countForSitemap();
|
|
70
|
+
const postShardCount = Math.max(1, Math.ceil(postCount / SITEMAP_SHARD_SIZE));
|
|
27
71
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
72
|
+
const entries: SitemapIndexEntry[] = [
|
|
73
|
+
{ loc: absoluteUrl("/sitemap-pages.xml", siteUrl, sitePathPrefix) },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Only include post shards if there's at least one post. When postCount is
|
|
77
|
+
// 0 we still list `sitemap-posts-1.xml` so the site always has a posts
|
|
78
|
+
// shard — the renderer emits an empty <urlset>, which is valid.
|
|
79
|
+
for (let page = 1; page <= postShardCount; page++) {
|
|
80
|
+
entries.push({
|
|
81
|
+
loc: absoluteUrl(`/sitemap-posts-${page}.xml`, siteUrl, sitePathPrefix),
|
|
82
|
+
});
|
|
36
83
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
84
|
+
|
|
85
|
+
const collections = await c.var.services.collections.list();
|
|
86
|
+
if (collections.length > 0) {
|
|
87
|
+
entries.push({
|
|
88
|
+
loc: absoluteUrl("/sitemap-collections.xml", siteUrl, sitePathPrefix),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return xmlResponse(renderSitemapIndex(entries), CACHE_SHORT);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// =============================================================================
|
|
96
|
+
// Post Shards
|
|
97
|
+
// =============================================================================
|
|
98
|
+
|
|
99
|
+
// Hono's path parser does not allow a param alongside a literal prefix in
|
|
100
|
+
// the same segment (e.g. `/sitemap-posts-:page.xml` does not match). The
|
|
101
|
+
// param must own the whole segment, so we match the full filename with a
|
|
102
|
+
// regex and parse the page number out inside the handler.
|
|
103
|
+
sitemapRoutes.get("/:file{sitemap-posts-[0-9]+\\.xml}", async (c) => {
|
|
104
|
+
const { appConfig } = c.var;
|
|
105
|
+
const { siteUrl, sitePathPrefix } = appConfig;
|
|
106
|
+
const file = c.req.param("file");
|
|
107
|
+
const match = /^sitemap-posts-([0-9]+)\.xml$/.exec(file);
|
|
108
|
+
if (!match) return c.notFound();
|
|
109
|
+
const page = Number(match[1]);
|
|
110
|
+
if (!Number.isFinite(page) || page < 1) return c.notFound();
|
|
111
|
+
|
|
112
|
+
// Keyset cursor: for page N (>1) we want the id just before the shard's
|
|
113
|
+
// first row, so `listForSitemap({ afterId })` returns the shard. For page
|
|
114
|
+
// 1 there is no cursor.
|
|
115
|
+
let afterId: string | undefined;
|
|
116
|
+
if (page > 1) {
|
|
117
|
+
const cursorOffset = (page - 1) * SITEMAP_SHARD_SIZE - 1;
|
|
118
|
+
const cursor = await c.var.services.posts.getSitemapIdAt(cursorOffset);
|
|
119
|
+
if (cursor === null) return c.notFound();
|
|
120
|
+
afterId = cursor;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const shardEntries = await c.var.services.posts.listForSitemap({
|
|
124
|
+
afterId,
|
|
125
|
+
limit: SITEMAP_SHARD_SIZE,
|
|
47
126
|
});
|
|
48
127
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
128
|
+
const urls: SitemapUrlEntry[] = shardEntries.map((entry) => {
|
|
129
|
+
// `entry.alias` already includes a leading "/" (see
|
|
130
|
+
// `paths.getPostAliases`); slugs are stored raw. Prepending "/" to an
|
|
131
|
+
// alias would create "//path" which `new URL()` interprets as
|
|
132
|
+
// protocol-relative and hijacks the hostname.
|
|
133
|
+
const path = entry.alias ?? `/${entry.slug}`;
|
|
134
|
+
return {
|
|
135
|
+
loc: absoluteUrl(path, siteUrl, sitePathPrefix),
|
|
136
|
+
lastmod: toIsoDate(entry.updatedAt),
|
|
137
|
+
priority: entry.featuredAt ? "0.8" : "0.6",
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// The last (not-yet-filled) shard needs short caching because new posts
|
|
142
|
+
// will append to it. Full shards are immutable in membership and can be
|
|
143
|
+
// cached aggressively — only a post edit inside them moves `<lastmod>`,
|
|
144
|
+
// which is acceptable sitemap staleness.
|
|
145
|
+
const isFullShard = shardEntries.length === SITEMAP_SHARD_SIZE;
|
|
146
|
+
const cacheControl = isFullShard ? CACHE_FULL_SHARD : CACHE_SHORT;
|
|
147
|
+
|
|
148
|
+
return xmlResponse(renderSitemapUrlSet(urls), cacheControl);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// Collections Shard
|
|
153
|
+
// =============================================================================
|
|
154
|
+
|
|
155
|
+
sitemapRoutes.get("/sitemap-collections.xml", async (c) => {
|
|
156
|
+
const { appConfig } = c.var;
|
|
157
|
+
const { siteUrl, sitePathPrefix } = appConfig;
|
|
158
|
+
|
|
159
|
+
const collections = await c.var.services.collections.list();
|
|
160
|
+
|
|
161
|
+
// Resolve each collection's canonical URL (alias if one exists, else slug).
|
|
162
|
+
// The `/collections` directory itself lives in `/sitemap-pages.xml`, since
|
|
163
|
+
// it's a static aggregate page rather than per-collection content.
|
|
164
|
+
const urls: SitemapUrlEntry[] = await Promise.all(
|
|
165
|
+
collections.map(async (collection) => {
|
|
166
|
+
const alias = await c.var.services.customUrls.getByTarget(
|
|
167
|
+
"collection",
|
|
168
|
+
collection.id,
|
|
169
|
+
);
|
|
170
|
+
const path = alias ? `/${alias.path}` : `/${collection.slug}`;
|
|
171
|
+
return {
|
|
172
|
+
loc: absoluteUrl(path, siteUrl, sitePathPrefix),
|
|
173
|
+
lastmod: toIsoDate(collection.updatedAt),
|
|
174
|
+
priority: "0.7",
|
|
175
|
+
};
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return xmlResponse(renderSitemapUrlSet(urls), CACHE_SHORT);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// =============================================================================
|
|
183
|
+
// Static Pages Shard (homepage)
|
|
184
|
+
// =============================================================================
|
|
185
|
+
|
|
186
|
+
sitemapRoutes.get("/sitemap-pages.xml", async (c) => {
|
|
187
|
+
const { appConfig } = c.var;
|
|
188
|
+
const { siteUrl, sitePathPrefix, homeDefaultView } = appConfig;
|
|
189
|
+
|
|
190
|
+
const urls: SitemapUrlEntry[] = [
|
|
191
|
+
{
|
|
192
|
+
loc: absoluteUrl("/", siteUrl, sitePathPrefix),
|
|
193
|
+
priority: "1.0",
|
|
194
|
+
changefreq: "daily",
|
|
53
195
|
},
|
|
196
|
+
{
|
|
197
|
+
loc: absoluteUrl("/archive", siteUrl, sitePathPrefix),
|
|
198
|
+
priority: "0.5",
|
|
199
|
+
changefreq: "weekly",
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
// Whichever of /latest and /featured is NOT the homepage default is a
|
|
204
|
+
// standalone URL worth indexing; the other 302-redirects to `/`.
|
|
205
|
+
const secondaryAggregate =
|
|
206
|
+
homeDefaultView === "featured" ? "/latest" : "/featured";
|
|
207
|
+
urls.push({
|
|
208
|
+
loc: absoluteUrl(secondaryAggregate, siteUrl, sitePathPrefix),
|
|
209
|
+
priority: "0.6",
|
|
210
|
+
changefreq: "daily",
|
|
54
211
|
});
|
|
212
|
+
|
|
213
|
+
// Include the collections directory landing page when at least one
|
|
214
|
+
// collection exists. When there are no collections, `/collections` still
|
|
215
|
+
// renders (as an empty directory), but indexing an empty aggregate page
|
|
216
|
+
// adds no value.
|
|
217
|
+
const collections = await c.var.services.collections.list();
|
|
218
|
+
if (collections.length > 0) {
|
|
219
|
+
urls.push({
|
|
220
|
+
loc: absoluteUrl("/collections", siteUrl, sitePathPrefix),
|
|
221
|
+
priority: "0.5",
|
|
222
|
+
changefreq: "weekly",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return xmlResponse(renderSitemapUrlSet(urls), CACHE_SHORT);
|
|
55
227
|
});
|
|
56
228
|
|
|
229
|
+
// =============================================================================
|
|
57
230
|
// robots.txt
|
|
231
|
+
// =============================================================================
|
|
232
|
+
|
|
58
233
|
sitemapRoutes.get("/robots.txt", async (c) => {
|
|
59
234
|
const { appConfig } = c.var;
|
|
60
235
|
const siteUrl = appConfig.siteUrl;
|
|
@@ -74,7 +249,7 @@ sitemapRoutes.get("/robots.txt", async (c) => {
|
|
|
74
249
|
return new Response(robots, {
|
|
75
250
|
headers: {
|
|
76
251
|
"Content-Type": "text/plain; charset=utf-8",
|
|
77
|
-
"Cache-Control":
|
|
252
|
+
"Cache-Control": CACHE_SHORT,
|
|
78
253
|
},
|
|
79
254
|
});
|
|
80
255
|
});
|