blume 2.2.0 → 2.2.2

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 (125) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cli/{chunk-xgq4eytj.js → chunk-080mfch6.js} +15 -15
  3. package/dist/cli/{chunk-br0n8n3e.js → chunk-1ch3ct3r.js} +14 -14
  4. package/dist/cli/{chunk-49r370mb.js → chunk-2ssh60qh.js} +6 -6
  5. package/dist/cli/{chunk-1cm0h819.js → chunk-34wap3ed.js} +3 -3
  6. package/dist/cli/{chunk-fqy4n3q2.js → chunk-40vk8kgf.js} +7 -7
  7. package/dist/cli/{chunk-96snx8yy.js → chunk-487b95qy.js} +3 -3
  8. package/dist/cli/{chunk-kkrteb15.js → chunk-4zxh25hc.js} +2 -2
  9. package/dist/cli/{chunk-cn3dy3hz.js → chunk-69rjf786.js} +5 -5
  10. package/dist/cli/{chunk-dmx6cezx.js → chunk-9h8mh2tk.js} +3 -3
  11. package/dist/cli/{chunk-dmx6cezx.js.map → chunk-9h8mh2tk.js.map} +2 -2
  12. package/dist/cli/{chunk-t5xx74rd.js → chunk-9jqmq5hv.js} +6 -6
  13. package/dist/cli/{chunk-8b489v1b.js → chunk-aafhwx74.js} +1 -1
  14. package/dist/cli/{chunk-2nr29y95.js → chunk-b680sv30.js} +3 -3
  15. package/dist/cli/{chunk-rkqae7kh.js → chunk-beb0z983.js} +16 -16
  16. package/dist/cli/{chunk-th0pgaj5.js → chunk-c5bjxq8v.js} +2 -2
  17. package/dist/cli/{chunk-wehv26ry.js → chunk-e1n41w9m.js} +3 -3
  18. package/dist/cli/{chunk-frk2wdq1.js → chunk-etckvbrh.js} +14 -14
  19. package/dist/cli/{chunk-ahc5gc9h.js → chunk-f260jdrn.js} +5 -5
  20. package/dist/cli/{chunk-05f21r8h.js → chunk-fnxgtjeq.js} +1 -1
  21. package/dist/cli/{chunk-4rftmttv.js → chunk-gwy55cx3.js} +2 -2
  22. package/dist/cli/{chunk-4rftmttv.js.map → chunk-gwy55cx3.js.map} +2 -2
  23. package/dist/cli/{chunk-vnbvhgmd.js → chunk-gxhcf2hs.js} +2 -2
  24. package/dist/cli/{chunk-bjk964fp.js → chunk-h0qwdbem.js} +2 -2
  25. package/dist/cli/{chunk-1rkxzg3n.js → chunk-hktq4j5r.js} +15 -12
  26. package/dist/cli/{chunk-1rkxzg3n.js.map → chunk-hktq4j5r.js.map} +3 -3
  27. package/dist/cli/{chunk-enk76qd5.js → chunk-hn3g2b89.js} +9 -9
  28. package/dist/cli/{chunk-w6e455pz.js → chunk-j4nfgn2j.js} +1 -1
  29. package/dist/cli/{chunk-as09pafp.js → chunk-k45sq16q.js} +4 -4
  30. package/dist/cli/{chunk-as09pafp.js.map → chunk-k45sq16q.js.map} +3 -3
  31. package/dist/cli/{chunk-p8zg3a2h.js → chunk-kmswjxj9.js} +3 -3
  32. package/dist/cli/{chunk-xt0qvz1t.js → chunk-nqna8dzp.js} +17 -17
  33. package/dist/cli/{chunk-dyk3r7jw.js → chunk-p834rkcn.js} +5 -5
  34. package/dist/cli/{chunk-dpcahcrb.js → chunk-ppa6mg4d.js} +6 -6
  35. package/dist/cli/{chunk-79bzzkx4.js → chunk-q1wwrxzw.js} +2 -2
  36. package/dist/cli/{chunk-m9bp2hc1.js → chunk-qcyxkj44.js} +1 -1
  37. package/dist/cli/{chunk-dm4mg9bj.js → chunk-r5nm9w0c.js} +740 -685
  38. package/dist/cli/{chunk-dm4mg9bj.js.map → chunk-r5nm9w0c.js.map} +8 -6
  39. package/dist/cli/{chunk-a3ydrqny.js → chunk-rtvgmg2j.js} +2 -2
  40. package/dist/cli/{chunk-c69tw29j.js → chunk-t2h8pwy1.js} +4 -4
  41. package/dist/cli/{chunk-vzy9rh6y.js → chunk-x60e15st.js} +17 -17
  42. package/dist/cli/{chunk-mrvh4c88.js → chunk-x7bh6d9j.js} +4 -4
  43. package/dist/cli/{chunk-7tekknev.js → chunk-ydqzkhjn.js} +6 -6
  44. package/dist/cli/{chunk-gprwej7h.js → chunk-yg8ckc3g.js} +2 -2
  45. package/dist/cli/{chunk-8f8xw4x2.js → chunk-zxd5jrgx.js} +14 -4
  46. package/dist/cli/{chunk-8f8xw4x2.js.map → chunk-zxd5jrgx.js.map} +3 -3
  47. package/dist/cli/index.js +18 -18
  48. package/dist/types/core/config-input.d.ts +37 -5
  49. package/dist/types/core/data.d.ts +6 -2
  50. package/dist/types/core/mcp-clients.d.ts +47 -0
  51. package/dist/types/core/nav-diagnostics.d.ts +7 -0
  52. package/dist/types/core/schema.d.ts +55 -4
  53. package/dist/types/ratelimit/index.d.ts +2 -0
  54. package/dist/types/ratelimit/memory.d.ts +1 -1
  55. package/dist/types/ratelimit/schema.d.ts +40 -0
  56. package/dist/types/ratelimit/unkey.d.ts +48 -0
  57. package/docs/02-deployment.mdx +1 -0
  58. package/docs/configuration/index.mdx +5 -4
  59. package/docs/configuration/rate-limiting.mdx +20 -1
  60. package/docs/content/i18n.mdx +1 -1
  61. package/docs/discoverability/mcp.mdx +40 -8
  62. package/package.json +1 -1
  63. package/skills/blume-migrate/SKILL.md +2 -2
  64. package/skills/blume-migrate/references/docusaurus.md +2 -2
  65. package/skills/blume-migrate/references/gitbook.md +2 -2
  66. package/skills/blume-migrate/references/github-wiki.md +2 -2
  67. package/skills/blume-migrate/references/jekyll.md +2 -2
  68. package/skills/blume-migrate/references/mkdocs.md +2 -2
  69. package/skills/blume-migrate/references/nextra.md +3 -3
  70. package/skills/blume-migrate/references/readme.md +1 -1
  71. package/skills/blume-migrate/references/redocly.md +2 -2
  72. package/skills/blume-migrate/references/vitepress.md +2 -2
  73. package/skills/blume-migrate/references/vuepress.md +1 -1
  74. package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +1 -1
  75. package/src/astro/generate.ts +15 -5
  76. package/src/astro/templates.ts +4 -4
  77. package/src/cli/required-secrets.ts +0 -1
  78. package/src/components/layout/PageActions.astro +119 -34
  79. package/src/components/layout/RootLayout.astro +18 -2
  80. package/src/components/layout/SiteFooter.astro +31 -8
  81. package/src/core/config-input.ts +38 -4
  82. package/src/core/data.ts +10 -3
  83. package/src/core/mcp-clients.ts +87 -0
  84. package/src/core/nav-diagnostics.ts +25 -0
  85. package/src/core/schema.ts +93 -3
  86. package/src/openapi/proxy.ts +5 -1
  87. package/src/ratelimit/index.ts +2 -0
  88. package/src/ratelimit/memory.ts +1 -1
  89. package/src/ratelimit/runtime.ts +81 -0
  90. package/src/ratelimit/schema.ts +2 -0
  91. package/src/ratelimit/unkey.ts +61 -0
  92. package/src/registry/registry.ts +1 -1
  93. /package/dist/cli/{chunk-xgq4eytj.js.map → chunk-080mfch6.js.map} +0 -0
  94. /package/dist/cli/{chunk-br0n8n3e.js.map → chunk-1ch3ct3r.js.map} +0 -0
  95. /package/dist/cli/{chunk-49r370mb.js.map → chunk-2ssh60qh.js.map} +0 -0
  96. /package/dist/cli/{chunk-1cm0h819.js.map → chunk-34wap3ed.js.map} +0 -0
  97. /package/dist/cli/{chunk-fqy4n3q2.js.map → chunk-40vk8kgf.js.map} +0 -0
  98. /package/dist/cli/{chunk-96snx8yy.js.map → chunk-487b95qy.js.map} +0 -0
  99. /package/dist/cli/{chunk-kkrteb15.js.map → chunk-4zxh25hc.js.map} +0 -0
  100. /package/dist/cli/{chunk-cn3dy3hz.js.map → chunk-69rjf786.js.map} +0 -0
  101. /package/dist/cli/{chunk-t5xx74rd.js.map → chunk-9jqmq5hv.js.map} +0 -0
  102. /package/dist/cli/{chunk-8b489v1b.js.map → chunk-aafhwx74.js.map} +0 -0
  103. /package/dist/cli/{chunk-2nr29y95.js.map → chunk-b680sv30.js.map} +0 -0
  104. /package/dist/cli/{chunk-rkqae7kh.js.map → chunk-beb0z983.js.map} +0 -0
  105. /package/dist/cli/{chunk-th0pgaj5.js.map → chunk-c5bjxq8v.js.map} +0 -0
  106. /package/dist/cli/{chunk-wehv26ry.js.map → chunk-e1n41w9m.js.map} +0 -0
  107. /package/dist/cli/{chunk-frk2wdq1.js.map → chunk-etckvbrh.js.map} +0 -0
  108. /package/dist/cli/{chunk-ahc5gc9h.js.map → chunk-f260jdrn.js.map} +0 -0
  109. /package/dist/cli/{chunk-05f21r8h.js.map → chunk-fnxgtjeq.js.map} +0 -0
  110. /package/dist/cli/{chunk-vnbvhgmd.js.map → chunk-gxhcf2hs.js.map} +0 -0
  111. /package/dist/cli/{chunk-bjk964fp.js.map → chunk-h0qwdbem.js.map} +0 -0
  112. /package/dist/cli/{chunk-enk76qd5.js.map → chunk-hn3g2b89.js.map} +0 -0
  113. /package/dist/cli/{chunk-w6e455pz.js.map → chunk-j4nfgn2j.js.map} +0 -0
  114. /package/dist/cli/{chunk-p8zg3a2h.js.map → chunk-kmswjxj9.js.map} +0 -0
  115. /package/dist/cli/{chunk-xt0qvz1t.js.map → chunk-nqna8dzp.js.map} +0 -0
  116. /package/dist/cli/{chunk-dyk3r7jw.js.map → chunk-p834rkcn.js.map} +0 -0
  117. /package/dist/cli/{chunk-dpcahcrb.js.map → chunk-ppa6mg4d.js.map} +0 -0
  118. /package/dist/cli/{chunk-79bzzkx4.js.map → chunk-q1wwrxzw.js.map} +0 -0
  119. /package/dist/cli/{chunk-m9bp2hc1.js.map → chunk-qcyxkj44.js.map} +0 -0
  120. /package/dist/cli/{chunk-a3ydrqny.js.map → chunk-rtvgmg2j.js.map} +0 -0
  121. /package/dist/cli/{chunk-c69tw29j.js.map → chunk-t2h8pwy1.js.map} +0 -0
  122. /package/dist/cli/{chunk-vzy9rh6y.js.map → chunk-x60e15st.js.map} +0 -0
  123. /package/dist/cli/{chunk-mrvh4c88.js.map → chunk-x7bh6d9j.js.map} +0 -0
  124. /package/dist/cli/{chunk-7tekknev.js.map → chunk-ydqzkhjn.js.map} +0 -0
  125. /package/dist/cli/{chunk-gprwej7h.js.map → chunk-yg8ckc3g.js.map} +0 -0
package/dist/cli/index.js CHANGED
@@ -725,24 +725,24 @@ var main = defineCommand({
725
725
  version: getBlumeVersion()
726
726
  },
727
727
  subCommands: {
728
- add: lazyCommand(commandMeta.add, () => import("./chunk-th0pgaj5.js"), "addCommand"),
729
- audit: lazyCommand(commandMeta.audit, () => import("./chunk-49r370mb.js"), "auditCommand"),
730
- build: lazyCommand(commandMeta.build, () => import("./chunk-xt0qvz1t.js"), "buildCommand"),
731
- check: lazyCommand(commandMeta.check, () => import("./chunk-rkqae7kh.js"), "checkCommand"),
732
- dev: lazyCommand(commandMeta.dev, () => import("./chunk-vzy9rh6y.js"), "devCommand"),
733
- doctor: lazyCommand(commandMeta.doctor, () => import("./chunk-enk76qd5.js"), "doctorCommand"),
734
- eject: lazyCommand(commandMeta.eject, () => import("./chunk-br0n8n3e.js"), "ejectCommand"),
735
- eval: lazyCommand(commandMeta.eval, () => import("./chunk-t5xx74rd.js"), "evalCommand"),
736
- init: lazyCommand(commandMeta.init, () => import("./chunk-frk2wdq1.js"), "initCommand"),
737
- "mcp-stdio": lazyCommand(commandMeta["mcp-stdio"], () => import("./chunk-m9bp2hc1.js"), "mcpStdioCommand"),
738
- migrate: lazyCommand(commandMeta.migrate, () => import("./chunk-gprwej7h.js"), "migrateCommand"),
739
- preview: lazyCommand(commandMeta.preview, () => import("./chunk-cn3dy3hz.js"), "previewCommand"),
740
- skill: lazyCommand(commandMeta.skill, () => import("./chunk-dyk3r7jw.js"), "skillCommand"),
741
- sync: lazyCommand(commandMeta.sync, () => import("./chunk-xgq4eytj.js"), "syncCommand"),
742
- translate: lazyCommand(commandMeta.translate, () => import("./chunk-ahc5gc9h.js"), "translateCommand"),
743
- upgrade: lazyCommand(commandMeta.upgrade, () => import("./chunk-7tekknev.js"), "upgradeCommand"),
744
- validate: lazyCommand(commandMeta.validate, () => import("./chunk-dpcahcrb.js"), "validateCommand"),
745
- version: lazyCommand(commandMeta.version, () => import("./chunk-mrvh4c88.js"), "versionCommand")
728
+ add: lazyCommand(commandMeta.add, () => import("./chunk-c5bjxq8v.js"), "addCommand"),
729
+ audit: lazyCommand(commandMeta.audit, () => import("./chunk-2ssh60qh.js"), "auditCommand"),
730
+ build: lazyCommand(commandMeta.build, () => import("./chunk-nqna8dzp.js"), "buildCommand"),
731
+ check: lazyCommand(commandMeta.check, () => import("./chunk-beb0z983.js"), "checkCommand"),
732
+ dev: lazyCommand(commandMeta.dev, () => import("./chunk-x60e15st.js"), "devCommand"),
733
+ doctor: lazyCommand(commandMeta.doctor, () => import("./chunk-hn3g2b89.js"), "doctorCommand"),
734
+ eject: lazyCommand(commandMeta.eject, () => import("./chunk-1ch3ct3r.js"), "ejectCommand"),
735
+ eval: lazyCommand(commandMeta.eval, () => import("./chunk-9jqmq5hv.js"), "evalCommand"),
736
+ init: lazyCommand(commandMeta.init, () => import("./chunk-etckvbrh.js"), "initCommand"),
737
+ "mcp-stdio": lazyCommand(commandMeta["mcp-stdio"], () => import("./chunk-qcyxkj44.js"), "mcpStdioCommand"),
738
+ migrate: lazyCommand(commandMeta.migrate, () => import("./chunk-yg8ckc3g.js"), "migrateCommand"),
739
+ preview: lazyCommand(commandMeta.preview, () => import("./chunk-69rjf786.js"), "previewCommand"),
740
+ skill: lazyCommand(commandMeta.skill, () => import("./chunk-p834rkcn.js"), "skillCommand"),
741
+ sync: lazyCommand(commandMeta.sync, () => import("./chunk-080mfch6.js"), "syncCommand"),
742
+ translate: lazyCommand(commandMeta.translate, () => import("./chunk-f260jdrn.js"), "translateCommand"),
743
+ upgrade: lazyCommand(commandMeta.upgrade, () => import("./chunk-ydqzkhjn.js"), "upgradeCommand"),
744
+ validate: lazyCommand(commandMeta.validate, () => import("./chunk-ppa6mg4d.js"), "validateCommand"),
745
+ version: lazyCommand(commandMeta.version, () => import("./chunk-x7bh6d9j.js"), "versionCommand")
746
746
  }
747
747
  });
748
748
  loadEnvFiles(process.cwd());
@@ -13,7 +13,7 @@ import type { AnySearchAdapter } from "../search/adapters/registry.ts";
13
13
  import type { SourceAdapterInput } from "../sources/registry.ts";
14
14
  import type { FontSlug } from "../theme/fonts.ts";
15
15
  import type { FooterSocial } from "./footer.ts";
16
- import type { OpenInChatProvider, SidebarDisplay, SidebarItemConfig } from "./schema.ts";
16
+ import type { McpClient, McpCustomClient, OpenInChatProvider, SidebarDisplay, SidebarItemConfig } from "./schema.ts";
17
17
  import type { StandardSchema } from "./standard-schema.ts";
18
18
  /**
19
19
  * The public, hand-documented authoring type for `blume.config.ts`.
@@ -115,9 +115,15 @@ export interface FooterLink {
115
115
  }
116
116
  /**
117
117
  * The site footer, below the content on every page: a row of links on one
118
- * side, and social profile icons on the other.
118
+ * side, social profile icons on the other, and a copyright line centered
119
+ * between them.
119
120
  */
120
121
  export interface FooterConfig {
122
+ /**
123
+ * A line of plain text centered between the links and the icons, like
124
+ * `© 2026 Acme, Inc.`, or a map of locale code to text.
125
+ */
126
+ copyright?: LocalizableLabel;
121
127
  /** Links in one row, left to right. */
122
128
  links?: FooterLink[];
123
129
  /**
@@ -638,6 +644,32 @@ export interface LlmsTxtConfig {
638
644
  }
639
645
  /** Expose the docs as an MCP server for connecting agents. */
640
646
  export interface McpConfig {
647
+ /**
648
+ * The clients the "Connect to MCP" page action installs the server into,
649
+ * beside its "Copy server URL" row. Defaults to `true` (every built-in
650
+ * client). Set `false` to list none, or list built-in keys and your own
651
+ * clients, in order. A custom client's `command` is copied with `{name}`
652
+ * (the server name as an id) and `{url}` (the server's URL) filled in; its
653
+ * `label` localizes like a header link's, and `icon` defaults to `terminal`.
654
+ *
655
+ * ```ts
656
+ * agents: {
657
+ * mcp: {
658
+ * enabled: true,
659
+ * clients: [
660
+ * "claude-code",
661
+ * {
662
+ * label: "Copy Copilot CLI command",
663
+ * command: "copilot mcp add --transport http {name} {url}",
664
+ * icon: "terminal",
665
+ * },
666
+ * "vscode",
667
+ * ],
668
+ * },
669
+ * }
670
+ * ```
671
+ */
672
+ clients?: boolean | (McpClient | McpCustomClient)[];
641
673
  /** Turn the MCP server on. Defaults to `false`. */
642
674
  enabled?: boolean;
643
675
  /** Optional system hint passed to connecting agents. */
@@ -1408,8 +1440,8 @@ export interface BlumeConfig {
1408
1440
  */
1409
1441
  feedback?: boolean | FeedbackConfig;
1410
1442
  /**
1411
- * The site footer: social profile icons and up to four link columns. Unset,
1412
- * the site has no footer.
1443
+ * The site footer: a copyright line, a row of links, and social profile
1444
+ * icons. Unset, the site has no footer.
1413
1445
  */
1414
1446
  footer?: FooterConfig;
1415
1447
  /** Opt-in custom frontmatter keys, validated by schemas you supply. */
@@ -1438,7 +1470,7 @@ export interface BlumeConfig {
1438
1470
  * Limit how often one reader (by IP address) can call the server routes —
1439
1471
  * the assistant, the API playground proxy, server-side search — with an
1440
1472
  * adapter from `blume/ratelimit`: `memory()` (the default, 30 requests per
1441
- * 10 minutes), `upstash()`, or `cloudflare()`. `false` turns it off.
1473
+ * 10 minutes), `upstash()`, `cloudflare()`, or `unkey()`. `false` turns it off.
1442
1474
  */
1443
1475
  rateLimit?: false | RateLimitAdapter;
1444
1476
  /** React island behavior (compiler auto-memoization). */
@@ -193,7 +193,7 @@ export interface BlumeDataConfig {
193
193
  feedback: boolean;
194
194
  /** Whether the rating asks for a written comment after it (`feedback.comments`). */
195
195
  feedbackComments: boolean;
196
- /** `footer`: social profile icons and link columns, or `null` when unset. */
196
+ /** `footer`: the copyright line, links, and social profile icons, or `null` when unset. */
197
197
  footer: ResolvedConfig["footer"] | null;
198
198
  /**
199
199
  * Repo coordinates for content components that address the API or build
@@ -217,8 +217,12 @@ export interface BlumeDataConfig {
217
217
  /** `markdown.imageZoom`: click-to-zoom content images. */
218
218
  imageZoom: boolean;
219
219
  logo: BlumeLogo | null;
220
- /** Hosted MCP server, or `null` when MCP is off. */
220
+ /**
221
+ * Hosted MCP server, or `null` when MCP is off, with the clients its
222
+ * "Connect to MCP" page action lists (`agents.mcp.clients`), in order.
223
+ */
221
224
  mcp: {
225
+ clients: ResolvedConfig["agents"]["mcp"]["clients"];
222
226
  name: string;
223
227
  route: string;
224
228
  } | null;
@@ -0,0 +1,47 @@
1
+ import type { LocalizableLabel } from "./schema.ts";
2
+ /**
3
+ * Clients the "Connect to MCP" page action knows how to install the server
4
+ * into, in display order. Shared by the config schema (`agents.mcp.clients`
5
+ * entries validate against this list) and the PageActions menu (which renders
6
+ * the full list when the config is `true`). Lives outside `schema.ts` so the
7
+ * component can import the list without pulling the whole config schema into
8
+ * the layout module graph.
9
+ */
10
+ export declare const mcpClients: readonly ["claude-code", "codex", "cursor", "vscode"];
11
+ export type McpClient = (typeof mcpClients)[number];
12
+ /** A client Blume doesn't ship: a menu row that copies its `command`. */
13
+ export interface McpCustomClient {
14
+ /**
15
+ * Command the row copies, with `{name}` (the server name as an id, like
16
+ * `acme-docs`) and `{url}` (the server's absolute URL) filled in.
17
+ */
18
+ command: string;
19
+ /** Icon beside the label: a Lucide name, inline SVG, or image. Defaults to `terminal`. */
20
+ icon?: string;
21
+ /** Row label, optionally per locale (`{ en: "…", de: "…" }`). */
22
+ label: LocalizableLabel;
23
+ }
24
+ export type McpClientEntry = McpClient | McpCustomClient;
25
+ /** A menu entry as a page renders it: a custom label resolved to its locale. */
26
+ export type McpMenuEntry = McpClient | (Omit<McpCustomClient, "label"> & {
27
+ label: string;
28
+ });
29
+ /** Whether a `clients` entry names a built-in client rather than a custom one. */
30
+ export declare const isBuiltInMcpClient: (client: McpClientEntry | McpMenuEntry) => client is McpClient;
31
+ /** Resolve the custom clients' labels for one page's locale. */
32
+ export declare const mcpMenuEntries: (clients: readonly McpClientEntry[], locale?: string, defaultLocale?: string) => McpMenuEntry[];
33
+ /**
34
+ * The menu's rows after "Copy server URL", each with whether a rule goes
35
+ * above it: wherever the list switches between commands to copy and install
36
+ * links ("Copy server URL" copies too), so the default menu keeps its one
37
+ * rule before Cursor.
38
+ */
39
+ export declare const mcpMenuRows: (entries: readonly McpMenuEntry[]) => {
40
+ client: McpMenuEntry;
41
+ rule: boolean;
42
+ }[];
43
+ /** Fill a custom client's command for the server it installs. */
44
+ export declare const mcpClientCommand: (command: string, server: {
45
+ name: string;
46
+ url: string;
47
+ }) => string;
@@ -1,3 +1,4 @@
1
+ import type { McpClientEntry } from "./mcp-clients.ts";
1
2
  import type { SidebarItemConfig } from "./schema.ts";
2
3
  import type { Diagnostic, Navigation, PageRecord } from "./types.ts";
3
4
  /** Warn about icon names that aren't in Blume's set (skipping image/SVG icons). */
@@ -10,6 +11,12 @@ export declare const validateSearchPopularIcons: (popular: {
10
11
  icon?: string;
11
12
  label: string;
12
13
  }[]) => Diagnostic[];
14
+ /**
15
+ * Warn about unknown icons on custom "Connect to MCP" clients
16
+ * (`agents.mcp.clients`), which live outside the navigation model too. A
17
+ * per-locale label is named in the default locale.
18
+ */
19
+ export declare const validateMcpClientIcons: (clients: readonly McpClientEntry[], defaultLocale?: string) => Diagnostic[];
13
20
  /**
14
21
  * Warn when a config-linked tab/selector target has no matching page. `routes`
15
22
  * must be the full set of servable routes — content, custom `.astro` pages, and
@@ -994,19 +994,21 @@ declare const aiConfigSchema: z.ZodObject<{
994
994
  tools: z.ZodOptional<z.ZodBoolean>;
995
995
  }, z.core.$strict>>;
996
996
  openInChat: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodArray<z.ZodEnum<{
997
+ cursor: "cursor";
997
998
  v0: "v0";
998
999
  chatgpt: "chatgpt";
999
1000
  claude: "claude";
1000
1001
  t3: "t3";
1001
1002
  scira: "scira";
1002
- cursor: "cursor";
1003
- }>>]>>, z.ZodTransform<("v0" | "chatgpt" | "claude" | "t3" | "scira" | "cursor")[], boolean | ("v0" | "chatgpt" | "claude" | "t3" | "scira" | "cursor")[]>>;
1003
+ }>>]>>, z.ZodTransform<("cursor" | "v0" | "chatgpt" | "claude" | "t3" | "scira")[], boolean | ("cursor" | "v0" | "chatgpt" | "claude" | "t3" | "scira")[]>>;
1004
1004
  }, z.core.$strict>;
1005
1005
  export { assistantReasoningLevels } from "../ai/ask.ts";
1006
1006
  export type { AssistantReasoning } from "../ai/ask.ts";
1007
1007
  export type AssistantConfig = NonNullable<z.infer<typeof aiConfigSchema>["assistant"]>;
1008
1008
  export { openInChatProviders } from "./open-in-chat.ts";
1009
1009
  export type { OpenInChatProvider } from "./open-in-chat.ts";
1010
+ export { mcpClients } from "./mcp-clients.ts";
1011
+ export type { McpClient, McpCustomClient } from "./mcp-clients.ts";
1010
1012
  /** A configured locale: ISO-ish code plus display metadata for the switcher. */
1011
1013
  declare const localeSchema: z.ZodObject<{
1012
1014
  code: z.ZodString;
@@ -1206,6 +1208,24 @@ export declare const blumeConfigSchema: z.ZodObject<{
1206
1208
  }>>;
1207
1209
  markdownComponents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodCustom<ComponentMarkdown, ComponentMarkdown>>>;
1208
1210
  mcp: z.ZodPrefault<z.ZodObject<{
1211
+ clients: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodArray<z.ZodUnion<readonly [z.ZodEnum<{
1212
+ "claude-code": "claude-code";
1213
+ codex: "codex";
1214
+ cursor: "cursor";
1215
+ vscode: "vscode";
1216
+ }>, z.ZodObject<{
1217
+ command: z.ZodString;
1218
+ icon: z.ZodOptional<z.ZodString>;
1219
+ label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
1220
+ }, z.core.$strict>]>>]>>, z.ZodTransform<("claude-code" | "codex" | "cursor" | "vscode" | {
1221
+ command: string;
1222
+ label: string | Record<string, string>;
1223
+ icon?: string | undefined;
1224
+ })[], boolean | ("claude-code" | "codex" | "cursor" | "vscode" | {
1225
+ command: string;
1226
+ label: string | Record<string, string>;
1227
+ icon?: string | undefined;
1228
+ })[]>>;
1209
1229
  enabled: z.ZodDefault<z.ZodBoolean>;
1210
1230
  instructions: z.ZodOptional<z.ZodString>;
1211
1231
  name: z.ZodOptional<z.ZodString>;
@@ -1393,13 +1413,13 @@ export declare const blumeConfigSchema: z.ZodObject<{
1393
1413
  tools: z.ZodOptional<z.ZodBoolean>;
1394
1414
  }, z.core.$strict>>;
1395
1415
  openInChat: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodArray<z.ZodEnum<{
1416
+ cursor: "cursor";
1396
1417
  v0: "v0";
1397
1418
  chatgpt: "chatgpt";
1398
1419
  claude: "claude";
1399
1420
  t3: "t3";
1400
1421
  scira: "scira";
1401
- cursor: "cursor";
1402
- }>>]>>, z.ZodTransform<("v0" | "chatgpt" | "claude" | "t3" | "scira" | "cursor")[], boolean | ("v0" | "chatgpt" | "claude" | "t3" | "scira" | "cursor")[]>>;
1422
+ }>>]>>, z.ZodTransform<("cursor" | "v0" | "chatgpt" | "claude" | "t3" | "scira")[], boolean | ("cursor" | "v0" | "chatgpt" | "claude" | "t3" | "scira")[]>>;
1403
1423
  }, z.core.$strict>>;
1404
1424
  analytics: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
1405
1425
  kind: z.ZodLiteral<"adobe">;
@@ -3038,6 +3058,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
3038
3058
  enabled: boolean;
3039
3059
  }>>>;
3040
3060
  footer: z.ZodOptional<z.ZodObject<{
3061
+ copyright: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
3041
3062
  links: z.ZodDefault<z.ZodArray<z.ZodObject<{
3042
3063
  href: z.ZodString;
3043
3064
  label: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>;
@@ -3350,6 +3371,16 @@ export declare const blumeConfigSchema: z.ZodObject<{
3350
3371
  }, z.core.$strict>;
3351
3372
  requiredSecrets: z.ZodArray<z.ZodString>;
3352
3373
  runtimeDeps: z.ZodArray<z.ZodString>;
3374
+ }, z.core.$strict>, z.ZodObject<{
3375
+ kind: z.ZodLiteral<"unkey">;
3376
+ options: z.ZodObject<{
3377
+ requests: z.ZodOptional<z.ZodNumber>;
3378
+ namespace: z.ZodOptional<z.ZodString>;
3379
+ rootKeyEnv: z.ZodOptional<z.ZodString>;
3380
+ window: z.ZodOptional<z.ZodNumber>;
3381
+ }, z.core.$strict>;
3382
+ requiredSecrets: z.ZodArray<z.ZodString>;
3383
+ runtimeDeps: z.ZodArray<z.ZodString>;
3353
3384
  }, z.core.$strict>], "kind">]>>, z.ZodTransform<{
3354
3385
  kind: "cloudflare";
3355
3386
  options: {
@@ -3377,6 +3408,16 @@ export declare const blumeConfigSchema: z.ZodObject<{
3377
3408
  };
3378
3409
  requiredSecrets: string[];
3379
3410
  runtimeDeps: string[];
3411
+ } | {
3412
+ kind: "unkey";
3413
+ options: {
3414
+ requests?: number | undefined;
3415
+ namespace?: string | undefined;
3416
+ rootKeyEnv?: string | undefined;
3417
+ window?: number | undefined;
3418
+ };
3419
+ requiredSecrets: string[];
3420
+ runtimeDeps: string[];
3380
3421
  } | null, false | {
3381
3422
  kind: "cloudflare";
3382
3423
  options: {
@@ -3404,6 +3445,16 @@ export declare const blumeConfigSchema: z.ZodObject<{
3404
3445
  };
3405
3446
  requiredSecrets: string[];
3406
3447
  runtimeDeps: string[];
3448
+ } | {
3449
+ kind: "unkey";
3450
+ options: {
3451
+ requests?: number | undefined;
3452
+ namespace?: string | undefined;
3453
+ rootKeyEnv?: string | undefined;
3454
+ window?: number | undefined;
3455
+ };
3456
+ requiredSecrets: string[];
3457
+ runtimeDeps: string[];
3407
3458
  } | undefined>>;
3408
3459
  react: z.ZodPrefault<z.ZodObject<{
3409
3460
  compiler: z.ZodDefault<z.ZodBoolean>;
@@ -25,3 +25,5 @@ export type { MemoryAdapter, RateLimitOptions } from "./memory.ts";
25
25
  export type { RateLimitAdapter } from "./schema.ts";
26
26
  export { upstash } from "./upstash.ts";
27
27
  export type { UpstashAdapter, UpstashOptions } from "./upstash.ts";
28
+ export { unkey } from "./unkey.ts";
29
+ export type { UnkeyAdapter, UnkeyOptions } from "./unkey.ts";
@@ -31,6 +31,6 @@ export declare const memoryAdapterSchema: z.ZodObject<{
31
31
  * platform that runs many short-lived instances (Vercel, Netlify,
32
32
  * Cloudflare) each instance keeps its own, so it stops a burst from one
33
33
  * reader rather than enforcing the limit exactly. For that, share the count
34
- * with `upstash()` or `cloudflare()`.
34
+ * with `upstash()`, `cloudflare()`, or `unkey()`.
35
35
  */
36
36
  export declare const memory: (options?: RateLimitOptions) => MemoryAdapter;
@@ -27,6 +27,16 @@ export declare const rateLimitAdapterSchema: z.ZodDiscriminatedUnion<[z.ZodObjec
27
27
  }, z.core.$strict>;
28
28
  requiredSecrets: z.ZodArray<z.ZodString>;
29
29
  runtimeDeps: z.ZodArray<z.ZodString>;
30
+ }, z.core.$strict>, z.ZodObject<{
31
+ kind: z.ZodLiteral<"unkey">;
32
+ options: z.ZodObject<{
33
+ requests: z.ZodOptional<z.ZodNumber>;
34
+ namespace: z.ZodOptional<z.ZodString>;
35
+ rootKeyEnv: z.ZodOptional<z.ZodString>;
36
+ window: z.ZodOptional<z.ZodNumber>;
37
+ }, z.core.$strict>;
38
+ requiredSecrets: z.ZodArray<z.ZodString>;
39
+ runtimeDeps: z.ZodArray<z.ZodString>;
30
40
  }, z.core.$strict>], "kind">;
31
41
  export type RateLimitAdapter = z.output<typeof rateLimitAdapterSchema>;
32
42
  /**
@@ -62,6 +72,16 @@ export declare const rateLimitConfigSchema: z.ZodPipe<z.ZodOptional<z.ZodUnion<r
62
72
  }, z.core.$strict>;
63
73
  requiredSecrets: z.ZodArray<z.ZodString>;
64
74
  runtimeDeps: z.ZodArray<z.ZodString>;
75
+ }, z.core.$strict>, z.ZodObject<{
76
+ kind: z.ZodLiteral<"unkey">;
77
+ options: z.ZodObject<{
78
+ requests: z.ZodOptional<z.ZodNumber>;
79
+ namespace: z.ZodOptional<z.ZodString>;
80
+ rootKeyEnv: z.ZodOptional<z.ZodString>;
81
+ window: z.ZodOptional<z.ZodNumber>;
82
+ }, z.core.$strict>;
83
+ requiredSecrets: z.ZodArray<z.ZodString>;
84
+ runtimeDeps: z.ZodArray<z.ZodString>;
65
85
  }, z.core.$strict>], "kind">]>>, z.ZodTransform<{
66
86
  kind: "cloudflare";
67
87
  options: {
@@ -89,6 +109,16 @@ export declare const rateLimitConfigSchema: z.ZodPipe<z.ZodOptional<z.ZodUnion<r
89
109
  };
90
110
  requiredSecrets: string[];
91
111
  runtimeDeps: string[];
112
+ } | {
113
+ kind: "unkey";
114
+ options: {
115
+ requests?: number | undefined;
116
+ namespace?: string | undefined;
117
+ rootKeyEnv?: string | undefined;
118
+ window?: number | undefined;
119
+ };
120
+ requiredSecrets: string[];
121
+ runtimeDeps: string[];
92
122
  } | null, false | {
93
123
  kind: "cloudflare";
94
124
  options: {
@@ -116,4 +146,14 @@ export declare const rateLimitConfigSchema: z.ZodPipe<z.ZodOptional<z.ZodUnion<r
116
146
  };
117
147
  requiredSecrets: string[];
118
148
  runtimeDeps: string[];
149
+ } | {
150
+ kind: "unkey";
151
+ options: {
152
+ requests?: number | undefined;
153
+ namespace?: string | undefined;
154
+ rootKeyEnv?: string | undefined;
155
+ window?: number | undefined;
156
+ };
157
+ requiredSecrets: string[];
158
+ runtimeDeps: string[];
119
159
  } | undefined>>;
@@ -0,0 +1,48 @@
1
+ import { z } from "zod";
2
+ import type { AdapterDescriptor } from "../core/adapter.ts";
3
+ import type { RateLimitOptions } from "./memory.ts";
4
+ /** The Unkey rate limit namespace the count is kept in, unless set. */
5
+ export declare const UNKEY_NAMESPACE = "docs";
6
+ /** The longest window Unkey takes, in seconds (30 days). */
7
+ export declare const UNKEY_MAX_WINDOW = 2592000;
8
+ /** Options for {@link unkey}. */
9
+ export interface UnkeyOptions extends RateLimitOptions {
10
+ /**
11
+ * The Unkey rate limit namespace, up to 512 characters. Defaults to `docs`;
12
+ * Unkey creates it on the first request.
13
+ */
14
+ namespace?: string;
15
+ /** Name of the env var holding the root key. Defaults to `UNKEY_ROOT_KEY`. */
16
+ rootKeyEnv?: string;
17
+ /** The window, in seconds: Unkey takes up to 30 days. */
18
+ window?: number;
19
+ }
20
+ export declare const unkeyOptionsSchema: z.ZodObject<{
21
+ requests: z.ZodOptional<z.ZodNumber>;
22
+ namespace: z.ZodOptional<z.ZodString>;
23
+ rootKeyEnv: z.ZodOptional<z.ZodString>;
24
+ window: z.ZodOptional<z.ZodNumber>;
25
+ }, z.core.$strict>;
26
+ export type UnkeyAdapter = AdapterDescriptor<"unkey", UnkeyOptions>;
27
+ export declare const unkeyAdapterSchema: z.ZodObject<{
28
+ kind: z.ZodLiteral<"unkey">;
29
+ options: z.ZodObject<{
30
+ requests: z.ZodOptional<z.ZodNumber>;
31
+ namespace: z.ZodOptional<z.ZodString>;
32
+ rootKeyEnv: z.ZodOptional<z.ZodString>;
33
+ window: z.ZodOptional<z.ZodNumber>;
34
+ }, z.core.$strict>;
35
+ requiredSecrets: z.ZodArray<z.ZodString>;
36
+ runtimeDeps: z.ZodArray<z.ZodString>;
37
+ }, z.core.$strict>;
38
+ /** The env var the root key is read from. */
39
+ export declare const unkeySecrets: (options: UnkeyOptions) => [string];
40
+ /**
41
+ * Counts with Unkey's rate limiting, which every instance on every host
42
+ * shares. Unkey counts in each region and shares the counts between them
43
+ * within moments, so a short burst split across regions can pass more than
44
+ * the limit. It talks to Unkey's REST API, so there is nothing to install;
45
+ * the root key comes from `UNKEY_ROOT_KEY`, or the env var `rootKeyEnv`
46
+ * names.
47
+ */
48
+ export declare const unkey: (options?: UnkeyOptions) => UnkeyAdapter;
@@ -267,6 +267,7 @@ When a feature needs a secret, Blume warns at `blume dev`/`build` if it's missin
267
267
  | Assistant (other adapters) | the adapter's default key env var (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `XAI_API_KEY`, `OPENROUTER_API_KEY`, `LLMGATEWAY_API_KEY`, `INKEEP_API_KEY`), or the `apiKeyEnv` you passed it |
268
268
  | [Assistant bot protection](/docs/configuration/assistant#bot-protection) | `TURNSTILE_SECRET_KEY` (`turnstile()`), `HCAPTCHA_SECRET_KEY` (`hcaptcha()`) |
269
269
  | [Rate limiting](/docs/configuration/rate-limiting) (`upstash()`) | `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` |
270
+ | [Rate limiting](/docs/configuration/rate-limiting) (`unkey()`) | `UNKEY_ROOT_KEY`, or the `rootKeyEnv` you passed it |
270
271
  | Mixedbread search | `MIXEDBREAD_API_KEY` |
271
272
  | [Narration](/docs/configuration/narration) (generated voices) | `AI_GATEWAY_API_KEY` (or Vercel OIDC) for `gateway()`, `OPENAI_API_KEY` for `openai()` (none with a `baseUrl`, unless you name one), or the `apiKeyEnv` you passed to either |
272
273
  | [Content sources](/docs/content/sources) | `NOTION_TOKEN` (`notion()`), `SANITY_TOKEN` (`sanity()`), `CONTENTFUL_ACCESS_TOKEN` (`contentful()`, plus `CONTENTFUL_PREVIEW_TOKEN` for `--preview`, which fails without it), `PAYLOAD_API_KEY` (`payload()`), `STRAPI_API_TOKEN` (`strapi()`), `GITHUB_TOKEN` (`githubReleases()`, and `mdxRemote()` with `github`) |
@@ -101,7 +101,7 @@ export default defineConfig({
101
101
  | `description` | — | Default meta description, used for SEO and OG. |
102
102
  | `logo` | — | Brand mark and/or wordmark shown in the header. |
103
103
  | `banner` | — | Site-wide announcement bar above the header. |
104
- | `footer` | — | A row of links and social profile icons below the content. |
104
+ | `footer` | — | A copyright line, a row of links, and social profile icons below the content. |
105
105
 
106
106
  ### Logo
107
107
 
@@ -204,10 +204,11 @@ A translated banner keys dismissal on the default locale's text, so one dismissa
204
204
 
205
205
  ### Footer
206
206
 
207
- Every page ends with a footer: one row, as tall as the header, with your links on one side and social profile icons on the other. The footer is the only place Blume links your social profiles and repository, so the [`github`](#github) repository comes first among the icons whenever it's set. `footer` adds the rest:
207
+ Every page ends with a footer: one row, as tall as the header, with your links on one side, social profile icons on the other, and a copyright line centered between them. The footer is the only place Blume links your social profiles and repository, so the [`github`](#github) repository comes first among the icons whenever it's set. `footer` adds the rest:
208
208
 
209
209
  ```ts blume.config.ts lineNumbers
210
210
  footer: {
211
+ copyright: "© 2026 Acme, Inc.",
211
212
  links: [
212
213
  { label: "Pricing", href: "https://acme.dev/pricing" },
213
214
  { label: "Changelog", href: "/changelog" },
@@ -220,9 +221,9 @@ footer: {
220
221
  },
221
222
  ```
222
223
 
223
- `links` appear in the order you write them. Write internal links as if mounted at root; an external link opens in a new tab. On a [multilingual](/docs/content/i18n#per-locale-navigation) site, a `label` can be a map of locale code to text, and an internal link moves into the reader's locale whenever that locale serves the page. `socials` maps a platform to its profile URL, and the icons appear in the order you write them. The platforms are `bluesky`, `discord`, `facebook`, `github`, `hacker-news`, `instagram`, `linkedin`, `medium`, `podcast`, `reddit`, `slack`, `telegram`, `threads`, `website`, `x`, and `youtube`. A `github` social replaces the repository link; hide that link with [`navigation.repo`](/docs/content/navigation#repository-link).
224
+ `copyright` is plain text, so write `©` itself rather than `&copy;`. For a year that keeps itself current, use a template literal: `` `© ${new Date().getFullYear()} Acme, Inc.` ``. `links` appear in the order you write them. Write internal links as if mounted at root; an external link opens in a new tab. On a [multilingual](/docs/content/i18n#per-locale-navigation) site, `copyright` and a `label` can each be a map of locale code to text, and an internal link moves into the reader's locale whenever that locale serves the page. `socials` maps a platform to its profile URL, and the icons appear in the order you write them. The platforms are `bluesky`, `discord`, `facebook`, `github`, `hacker-news`, `instagram`, `linkedin`, `medium`, `podcast`, `reddit`, `slack`, `telegram`, `threads`, `website`, `x`, and `youtube`. A `github` social replaces the repository link; hide that link with [`navigation.repo`](/docs/content/navigation#repository-link).
224
225
 
225
- A site with no links, profiles, or repository has no footer. Custom pages built on `PageLayout` show it too, unless they fill the layout's own `footer` slot. To replace it entirely, override the [`Footer` layout slot](/docs/configuration/customization#layout-slots).
226
+ A site with no copyright, links, profiles, or repository has no footer. Custom pages built on `PageLayout` show it too, unless they fill the layout's own `footer` slot. To replace it entirely, override the [`Footer` layout slot](/docs/configuration/customization#layout-slots).
226
227
 
227
228
  ## Content
228
229
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Rate limiting
3
- description: Limit how often one reader can call the assistant, the API playground proxy, and server-side search, on by default, with the count kept in memory, Upstash, or Cloudflare.
3
+ description: Limit how often one reader can call the assistant, the API playground proxy, and server-side search, on by default, with the count kept in memory, Upstash, Cloudflare, or Unkey.
4
4
  ---
5
5
 
6
6
  The server routes a reader can call cost you something each time: the [assistant](/docs/configuration/assistant) spends model tokens, the [API playground](/docs/references/openapi#cors-and-the-proxy) proxy relays requests to your API, and [Mixedbread search](/docs/configuration/search#mixedbread) runs a paid query. Blume limits how often one reader, identified by IP address, can call each of them. A reader over the limit gets `429 Too Many Requests` with a `Retry-After` header, and the assistant tells them to try again in a few minutes.
@@ -25,6 +25,7 @@ Each route keeps its own count, so a busy playground session doesn't use up the
25
25
  | `memory()` | The server's memory (the default) | Exactly on one server; per instance on serverless hosts |
26
26
  | `upstash()` | [Upstash Redis](https://upstash.com) | Exactly, on every host |
27
27
  | `cloudflare()` | Cloudflare's Workers rate limiting | Per Cloudflare location |
28
+ | `unkey()` | [Unkey](https://unkey.com) | On every host; regions catch up within moments |
28
29
 
29
30
  ### Memory
30
31
 
@@ -68,6 +69,24 @@ export default defineConfig({
68
69
 
69
70
  The binding's namespace comes from the Worker's name, so two sites on one account don't share a count. Set `namespaceId` to choose it yourself.
70
71
 
72
+ ### Unkey
73
+
74
+ `unkey()` keeps the count in [Unkey's rate limiting](https://www.unkey.com/docs/api-management/ratelimiting/overview), which every instance shares. Create a root key with the `ratelimit.*.limit` permission, plus `ratelimit.*.create_namespace` so Unkey can create the namespace on the first request, then set it as `UNKEY_ROOT_KEY`. Blume talks to Unkey's REST API, so there's nothing to install. Until the key is set, the routes count in memory and `blume build` warns.
75
+
76
+ ```ts blume.config.ts lineNumbers
77
+ import { unkey } from "blume/ratelimit";
78
+
79
+ rateLimit: unkey(),
80
+ ```
81
+
82
+ The namespace is `docs` unless you name another with `namespace`. Name the variable that holds the key with `rootKeyEnv`:
83
+
84
+ ```ts blume.config.ts lineNumbers
85
+ rateLimit: unkey({ namespace: "acme-docs", rootKeyEnv: "DOCS_UNKEY_ROOT_KEY" }),
86
+ ```
87
+
88
+ Unkey counts in each region and shares the counts between regions within moments, so a short burst split across regions can get a few more requests through than the limit. Every count fails with a key Unkey refuses, like one missing those permissions, and a request a shared store fails to count is let through, so if the server logs `Rate limiting failed`, check the key.
89
+
71
90
  ## Behind a reverse proxy
72
91
 
73
92
  Vercel, Netlify, and Cloudflare tell Blume each reader's address. A `node()` server behind a reverse proxy, like nginx or Caddy, sees every request come from the proxy, so every reader shares one count and a busy site hits the limit for everyone. List the host the proxy answers for in `allowedDomains`:
@@ -85,7 +85,7 @@ i18n: {
85
85
 
86
86
  Each language gets its own sidebar, built from that locale's files — so translations can diverge in structure, ordering, or labels. Folder [`meta.ts`](/docs/content/meta) files resolve per locale, too: under the default `dir` parser, put a `meta.ts` under `fr/guides/` to order the French group independently. Until a locale has one (or a shared `meta.$.ts`), its group mirrors the [fallback](#fallbacks) locale's — that folder's `meta.ts` and the `sidebar.display` its index page sets — so pages that fall back keep the same titles, order, and collapsible groups. Under the `dot` parser translations sit next to the originals, so a folder's `meta.ts` applies to every locale. Everything else about [navigation](/docs/content/navigation) works the same, per language.
87
87
 
88
- Header tabs, header links, and the footer are configured, not derived from content, so their labels localize in `blume.config.ts`. Every configured label accepts a per-locale map (`{ en: "Docs", fr: "Documentation" }`) alongside the plain-string form, falling back to the default locale's entry for locales you haven't filled in: tab and dropdown labels ([Tabs](/docs/content/navigation#tabs)), [featured links](/docs/content/navigation#featured-links), [header actions](/docs/content/navigation#header-actions) and the call to action, [footer](/docs/configuration#footer) links, and the [banner](/docs/configuration#banner)'s text and link text.
88
+ Header tabs, header links, and the footer are configured, not derived from content, so their labels localize in `blume.config.ts`. Every configured label accepts a per-locale map (`{ en: "Docs", fr: "Documentation" }`) alongside the plain-string form, falling back to the default locale's entry for locales you haven't filled in: tab and dropdown labels ([Tabs](/docs/content/navigation#tabs)), [featured links](/docs/content/navigation#featured-links), [header actions](/docs/content/navigation#header-actions) and the call to action, [footer](/docs/configuration#footer) links, the [banner](/docs/configuration#banner)'s text and link text, and custom clients in the [Connect to MCP](/docs/discoverability/mcp#connect-to-mcp-menu) menu.
89
89
 
90
90
  ```ts blume.config.ts lineNumbers
91
91
  navigation: {