@jant/core 0.3.42 → 0.3.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/bin/commands/import-site.js +1 -1
  2. package/bin/commands/search-reindex.js +175 -0
  3. package/bin/lib/hugo-markdown.js +102 -0
  4. package/bin/lib/site-pull-media.js +1 -4
  5. package/dist/app-BI9bnCkO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-CtJDxZBb.js} +969 -373
  7. package/dist/client/.vite/manifest.json +2 -2
  8. package/dist/client/_assets/client-BQH7AQ24.css +2 -0
  9. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  10. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  11. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  12. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  13. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +5 -5
  16. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  17. package/package.json +1 -1
  18. package/src/__tests__/helpers/app.ts +15 -4
  19. package/src/app.tsx +8 -0
  20. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  21. package/src/client/tiptap/extensions.ts +3 -0
  22. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  23. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  24. package/src/db/migrations/0019_bored_magus.sql +2 -0
  25. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  26. package/src/db/migrations/meta/0019_snapshot.json +2238 -0
  27. package/src/db/migrations/meta/_journal.json +15 -1
  28. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  29. package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
  30. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  31. package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
  32. package/src/db/migrations/pg/meta/_journal.json +15 -1
  33. package/src/db/pg/schema.ts +22 -0
  34. package/src/db/schema.ts +27 -0
  35. package/src/index.ts +1 -2
  36. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  37. package/src/lib/__tests__/navigation.test.ts +4 -20
  38. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  39. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  40. package/src/lib/__tests__/summary.test.ts +140 -0
  41. package/src/lib/__tests__/view.test.ts +66 -0
  42. package/src/lib/feed.ts +70 -34
  43. package/src/lib/hosted-signin.ts +9 -3
  44. package/src/lib/icons.ts +37 -0
  45. package/src/lib/navigation.ts +11 -12
  46. package/src/lib/post-meta.ts +20 -2
  47. package/src/lib/rate-limit-d1.ts +99 -0
  48. package/src/lib/rate-limit-memory.ts +105 -0
  49. package/src/lib/rate-limit.ts +63 -0
  50. package/src/lib/render.tsx +9 -0
  51. package/src/lib/resolve-config.ts +9 -0
  52. package/src/lib/summary.ts +42 -7
  53. package/src/lib/url.ts +34 -0
  54. package/src/lib/view.ts +42 -8
  55. package/src/middleware/__tests__/auth.test.ts +44 -4
  56. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  57. package/src/middleware/__tests__/session.test.ts +85 -0
  58. package/src/middleware/auth.ts +62 -25
  59. package/src/middleware/rate-limit.ts +54 -0
  60. package/src/middleware/session.ts +36 -0
  61. package/src/routes/__tests__/compose.test.ts +1 -1
  62. package/src/routes/api/__tests__/search.test.ts +48 -0
  63. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  64. package/src/routes/api/internal/search-reindex.ts +40 -0
  65. package/src/routes/api/internal/sites.ts +1 -0
  66. package/src/routes/api/search.ts +13 -0
  67. package/src/routes/auth/dev.ts +1 -1
  68. package/src/routes/auth/signin.tsx +23 -5
  69. package/src/routes/dash/settings.tsx +3 -5
  70. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  71. package/src/routes/feed/sitemap.ts +208 -33
  72. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  73. package/src/routes/pages/home.tsx +24 -15
  74. package/src/routes/pages/page.tsx +34 -0
  75. package/src/routes/pages/partials.tsx +4 -15
  76. package/src/runtime/cloudflare.ts +4 -0
  77. package/src/runtime/node.ts +16 -0
  78. package/src/services/__tests__/post.test.ts +205 -0
  79. package/src/services/__tests__/search.test.ts +44 -0
  80. package/src/services/__tests__/site-admin.test.ts +85 -0
  81. package/src/services/export.ts +9 -2
  82. package/src/services/post.ts +200 -2
  83. package/src/services/site-admin.ts +66 -1
  84. package/src/styles/ui.css +12 -0
  85. package/src/types/app-context.ts +20 -0
  86. package/src/types/config.ts +8 -0
  87. package/src/types/props.ts +0 -7
  88. package/src/ui/feed/LinkCard.tsx +3 -20
  89. package/src/ui/feed/LinkPreview.tsx +5 -19
  90. package/src/ui/feed/PostStatusBadges.tsx +4 -38
  91. package/src/ui/layouts/BaseLayout.tsx +23 -29
  92. package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
  93. package/src/ui/shared/Icon.tsx +60 -0
  94. package/src/ui/shared/IconSprite.tsx +57 -0
  95. package/src/ui/shared/PostFooter.tsx +6 -62
  96. package/src/ui/shared/custom-icons.ts +132 -0
  97. package/src/ui/shared/icon-collector.ts +37 -0
  98. package/dist/app-DzCB4yOp.js +0 -5
  99. package/dist/client/_assets/client-C_kImWZj.css +0 -2
@@ -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 createSitemapTestApp(
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 = createSitemapTestApp();
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 = createSitemapTestApp({ NOINDEX: "true" });
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 { defaultSitemapRenderer } from "../../lib/feed.js";
9
- import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
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
- // XML Sitemap
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.siteUrl;
67
+ const { siteUrl, sitePathPrefix } = appConfig;
20
68
 
21
- const posts = await c.var.services.posts.list({
22
- status: "published",
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
- // Transform to View Models
29
- const mediaCtx = createMediaContext(appConfig);
30
- const aliasesMap = await c.var.services.paths.getPostAliases(
31
- posts.map((p) => p.id),
32
- );
33
- const aliasMap = new Map<string, string>();
34
- for (const [id, aliases] of aliasesMap) {
35
- if (aliases[0]) aliasMap.set(id, aliases[0]);
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
- const postViews = toPostViewsFromPosts(posts, mediaCtx, undefined, aliasMap);
38
-
39
- const xml = defaultSitemapRenderer({
40
- siteUrl,
41
- sitemapUrl: toAbsoluteSiteUrl(
42
- "/sitemap.xml",
43
- siteUrl,
44
- appConfig.sitePathPrefix,
45
- ),
46
- posts: postViews,
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
- return new Response(xml, {
50
- headers: {
51
- "Content-Type": "application/xml; charset=utf-8",
52
- "Cache-Control": "public, max-age=180",
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": "public, max-age=180",
252
+ "Cache-Control": CACHE_SHORT,
78
253
  },
79
254
  });
80
255
  });