blume 2.2.0 → 2.2.1

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 (124) hide show
  1. package/CHANGELOG.md +8 -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 +81 -23
  79. package/src/components/layout/RootLayout.astro +17 -1
  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/ratelimit/index.ts +2 -0
  87. package/src/ratelimit/memory.ts +1 -1
  88. package/src/ratelimit/runtime.ts +81 -0
  89. package/src/ratelimit/schema.ts +2 -0
  90. package/src/ratelimit/unkey.ts +61 -0
  91. package/src/registry/registry.ts +1 -1
  92. /package/dist/cli/{chunk-xgq4eytj.js.map → chunk-080mfch6.js.map} +0 -0
  93. /package/dist/cli/{chunk-br0n8n3e.js.map → chunk-1ch3ct3r.js.map} +0 -0
  94. /package/dist/cli/{chunk-49r370mb.js.map → chunk-2ssh60qh.js.map} +0 -0
  95. /package/dist/cli/{chunk-1cm0h819.js.map → chunk-34wap3ed.js.map} +0 -0
  96. /package/dist/cli/{chunk-fqy4n3q2.js.map → chunk-40vk8kgf.js.map} +0 -0
  97. /package/dist/cli/{chunk-96snx8yy.js.map → chunk-487b95qy.js.map} +0 -0
  98. /package/dist/cli/{chunk-kkrteb15.js.map → chunk-4zxh25hc.js.map} +0 -0
  99. /package/dist/cli/{chunk-cn3dy3hz.js.map → chunk-69rjf786.js.map} +0 -0
  100. /package/dist/cli/{chunk-t5xx74rd.js.map → chunk-9jqmq5hv.js.map} +0 -0
  101. /package/dist/cli/{chunk-8b489v1b.js.map → chunk-aafhwx74.js.map} +0 -0
  102. /package/dist/cli/{chunk-2nr29y95.js.map → chunk-b680sv30.js.map} +0 -0
  103. /package/dist/cli/{chunk-rkqae7kh.js.map → chunk-beb0z983.js.map} +0 -0
  104. /package/dist/cli/{chunk-th0pgaj5.js.map → chunk-c5bjxq8v.js.map} +0 -0
  105. /package/dist/cli/{chunk-wehv26ry.js.map → chunk-e1n41w9m.js.map} +0 -0
  106. /package/dist/cli/{chunk-frk2wdq1.js.map → chunk-etckvbrh.js.map} +0 -0
  107. /package/dist/cli/{chunk-ahc5gc9h.js.map → chunk-f260jdrn.js.map} +0 -0
  108. /package/dist/cli/{chunk-05f21r8h.js.map → chunk-fnxgtjeq.js.map} +0 -0
  109. /package/dist/cli/{chunk-vnbvhgmd.js.map → chunk-gxhcf2hs.js.map} +0 -0
  110. /package/dist/cli/{chunk-bjk964fp.js.map → chunk-h0qwdbem.js.map} +0 -0
  111. /package/dist/cli/{chunk-enk76qd5.js.map → chunk-hn3g2b89.js.map} +0 -0
  112. /package/dist/cli/{chunk-w6e455pz.js.map → chunk-j4nfgn2j.js.map} +0 -0
  113. /package/dist/cli/{chunk-p8zg3a2h.js.map → chunk-kmswjxj9.js.map} +0 -0
  114. /package/dist/cli/{chunk-xt0qvz1t.js.map → chunk-nqna8dzp.js.map} +0 -0
  115. /package/dist/cli/{chunk-dyk3r7jw.js.map → chunk-p834rkcn.js.map} +0 -0
  116. /package/dist/cli/{chunk-dpcahcrb.js.map → chunk-ppa6mg4d.js.map} +0 -0
  117. /package/dist/cli/{chunk-79bzzkx4.js.map → chunk-q1wwrxzw.js.map} +0 -0
  118. /package/dist/cli/{chunk-m9bp2hc1.js.map → chunk-qcyxkj44.js.map} +0 -0
  119. /package/dist/cli/{chunk-a3ydrqny.js.map → chunk-rtvgmg2j.js.map} +0 -0
  120. /package/dist/cli/{chunk-c69tw29j.js.map → chunk-t2h8pwy1.js.map} +0 -0
  121. /package/dist/cli/{chunk-vzy9rh6y.js.map → chunk-x60e15st.js.map} +0 -0
  122. /package/dist/cli/{chunk-mrvh4c88.js.map → chunk-x7bh6d9j.js.map} +0 -0
  123. /package/dist/cli/{chunk-7tekknev.js.map → chunk-ydqzkhjn.js.map} +0 -0
  124. /package/dist/cli/{chunk-gprwej7h.js.map → chunk-yg8ckc3g.js.map} +0 -0
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  // The site footer, below the content on every page: one row, as tall as the
3
- // header, with `footer.links` on one side and the social profile icons on the
4
- // other. The site's repository (`github`, unless `navigation.repo` hides it)
5
- // is the first icon; the footer is the only place it's linked. With `consent` configured the links end with the
3
+ // header, with `footer.links` on one side, the social profile icons on the
4
+ // other, and `footer.copyright` centered between them. The site's repository
5
+ // (`github`, unless `navigation.repo` hides it) is the first icon; the footer is the only place it's linked. With `consent` configured the links end with the
6
6
  // Cookie settings link, which reopens the reader's consent choices. Renders
7
7
  // nothing when there's none of these; a `components.ts` `Footer` replaces it.
8
8
  import data from "blume:data";
@@ -12,6 +12,7 @@ import type { Navigation } from "../../core/types.ts";
12
12
  import { EN_UI } from "../../core/i18n-ui.ts";
13
13
  import type { UIStrings } from "../../core/i18n-ui.ts";
14
14
  import { routeSetFor } from "../../core/locale-links.ts";
15
+ import { resolveLocalizable } from "../../core/localizable.ts";
15
16
  import { newTabAttrs } from "../../core/new-tab.ts";
16
17
  import Icon from "../Icon.astro";
17
18
  import { withBase } from "../islands/base-path.ts";
@@ -21,8 +22,9 @@ import { footerSocialLinks } from "./footer-socials.ts";
21
22
  interface Props {
22
23
  footer?: BlumeDataConfig["footer"];
23
24
  /**
24
- * Active locale of the page: labels written as per-locale maps show its
25
- * entry, and internal links move into it (see `footer-links.ts`).
25
+ * Active locale of the page: the copyright and labels written as
26
+ * per-locale maps show its entry, and internal links move into it (see
27
+ * `footer-links.ts`).
26
28
  */
27
29
  locale?: string;
28
30
  /** The page's navigation: its `repoUrl` is the repository link. */
@@ -40,6 +42,14 @@ const socials = footerSocialLinks(
40
42
  ? { href: repoUrl, label: { ...EN_UI.nav, ...ui?.nav }.githubRepository }
41
43
  : null
42
44
  );
45
+ const copyright =
46
+ footer?.copyright === undefined
47
+ ? null
48
+ : resolveLocalizable(
49
+ footer.copyright,
50
+ locale,
51
+ data.config.i18n?.defaultLocale
52
+ );
43
53
  const links = footerLinks(footer?.links ?? [], {
44
54
  basePath: data.config.basePath,
45
55
  i18n: data.config.i18n,
@@ -58,9 +68,17 @@ const hasLinks = links.length > 0 || consentSettings !== null;
58
68
  ---
59
69
 
60
70
  {
61
- (hasLinks || socials.length > 0) && (
71
+ (copyright !== null || hasLinks || socials.length > 0) && (
62
72
  <footer class="border-border border-t px-4 md:px-6" data-blume-footer>
63
- <div class="flex min-h-16 flex-wrap items-center justify-between gap-x-6 gap-y-3 py-3">
73
+ {/* With a copyright, three columns from `md`, the outer two sharing
74
+ what it leaves so it sits at the true center; without one, the
75
+ links keep the row's full width. Narrower, the row wraps. */}
76
+ <div
77
+ class:list={[
78
+ "flex min-h-16 flex-wrap items-center gap-x-6 gap-y-3 py-3",
79
+ copyright !== null && "md:grid md:grid-cols-[1fr_auto_1fr]",
80
+ ]}
81
+ >
64
82
  {hasLinks && (
65
83
  <ul class="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
66
84
  {links.map((link) => (
@@ -87,10 +105,15 @@ const hasLinks = links.length > 0 || consentSettings !== null;
87
105
  )}
88
106
  </ul>
89
107
  )}
108
+ {copyright !== null && (
109
+ <p class="text-muted-foreground text-sm md:col-start-2">
110
+ {copyright}
111
+ </p>
112
+ )}
90
113
  {/* Pulled out by the buttons' padding, so the glyphs line up with the
91
114
  footer's edge, and pushed to the far side even with no links. */}
92
115
  {socials.length > 0 && (
93
- <ul class="-me-[9px] -my-[9px] ms-auto flex flex-wrap items-center gap-1">
116
+ <ul class="-me-[9px] -my-[9px] ms-auto flex flex-wrap items-center gap-1 md:col-start-3">
94
117
  {socials.map((social) => (
95
118
  <li>
96
119
  <a
@@ -22,6 +22,8 @@ import type { FontSlug } from "../theme/fonts.ts";
22
22
  import type { FooterSocial } from "./footer.ts";
23
23
  import type {
24
24
  blumeConfigSchema,
25
+ McpClient,
26
+ McpCustomClient,
25
27
  OpenInChatProvider,
26
28
  SidebarDisplay,
27
29
  SidebarItemConfig,
@@ -147,9 +149,15 @@ export interface FooterLink {
147
149
 
148
150
  /**
149
151
  * The site footer, below the content on every page: a row of links on one
150
- * side, and social profile icons on the other.
152
+ * side, social profile icons on the other, and a copyright line centered
153
+ * between them.
151
154
  */
152
155
  export interface FooterConfig {
156
+ /**
157
+ * A line of plain text centered between the links and the icons, like
158
+ * `© 2026 Acme, Inc.`, or a map of locale code to text.
159
+ */
160
+ copyright?: LocalizableLabel;
153
161
  /** Links in one row, left to right. */
154
162
  links?: FooterLink[];
155
163
  /**
@@ -719,6 +727,32 @@ export interface LlmsTxtConfig {
719
727
 
720
728
  /** Expose the docs as an MCP server for connecting agents. */
721
729
  export interface McpConfig {
730
+ /**
731
+ * The clients the "Connect to MCP" page action installs the server into,
732
+ * beside its "Copy server URL" row. Defaults to `true` (every built-in
733
+ * client). Set `false` to list none, or list built-in keys and your own
734
+ * clients, in order. A custom client's `command` is copied with `{name}`
735
+ * (the server name as an id) and `{url}` (the server's URL) filled in; its
736
+ * `label` localizes like a header link's, and `icon` defaults to `terminal`.
737
+ *
738
+ * ```ts
739
+ * agents: {
740
+ * mcp: {
741
+ * enabled: true,
742
+ * clients: [
743
+ * "claude-code",
744
+ * {
745
+ * label: "Copy Copilot CLI command",
746
+ * command: "copilot mcp add --transport http {name} {url}",
747
+ * icon: "terminal",
748
+ * },
749
+ * "vscode",
750
+ * ],
751
+ * },
752
+ * }
753
+ * ```
754
+ */
755
+ clients?: boolean | (McpClient | McpCustomClient)[];
722
756
  /** Turn the MCP server on. Defaults to `false`. */
723
757
  enabled?: boolean;
724
758
  /** Optional system hint passed to connecting agents. */
@@ -1567,8 +1601,8 @@ export interface BlumeConfig {
1567
1601
  */
1568
1602
  feedback?: boolean | FeedbackConfig;
1569
1603
  /**
1570
- * The site footer: social profile icons and up to four link columns. Unset,
1571
- * the site has no footer.
1604
+ * The site footer: a copyright line, a row of links, and social profile
1605
+ * icons. Unset, the site has no footer.
1572
1606
  */
1573
1607
  footer?: FooterConfig;
1574
1608
  /** Opt-in custom frontmatter keys, validated by schemas you supply. */
@@ -1597,7 +1631,7 @@ export interface BlumeConfig {
1597
1631
  * Limit how often one reader (by IP address) can call the server routes —
1598
1632
  * the assistant, the API playground proxy, server-side search — with an
1599
1633
  * adapter from `blume/ratelimit`: `memory()` (the default, 30 requests per
1600
- * 10 minutes), `upstash()`, or `cloudflare()`. `false` turns it off.
1634
+ * 10 minutes), `upstash()`, `cloudflare()`, or `unkey()`. `false` turns it off.
1601
1635
  */
1602
1636
  rateLimit?: false | RateLimitAdapter;
1603
1637
  /** React island behavior (compiler auto-memoization). */
package/src/core/data.ts CHANGED
@@ -187,7 +187,7 @@ export interface BlumeDataConfig {
187
187
  feedback: boolean;
188
188
  /** Whether the rating asks for a written comment after it (`feedback.comments`). */
189
189
  feedbackComments: boolean;
190
- /** `footer`: social profile icons and link columns, or `null` when unset. */
190
+ /** `footer`: the copyright line, links, and social profile icons, or `null` when unset. */
191
191
  footer: ResolvedConfig["footer"] | null;
192
192
  /**
193
193
  * Repo coordinates for content components that address the API or build
@@ -206,8 +206,15 @@ export interface BlumeDataConfig {
206
206
  /** `markdown.imageZoom`: click-to-zoom content images. */
207
207
  imageZoom: boolean;
208
208
  logo: BlumeLogo | null;
209
- /** Hosted MCP server, or `null` when MCP is off. */
210
- mcp: { name: string; route: string } | null;
209
+ /**
210
+ * Hosted MCP server, or `null` when MCP is off, with the clients its
211
+ * "Connect to MCP" page action lists (`agents.mcp.clients`), in order.
212
+ */
213
+ mcp: {
214
+ clients: ResolvedConfig["agents"]["mcp"]["clients"];
215
+ name: string;
216
+ route: string;
217
+ } | null;
211
218
  /** `seo.metatags`: the site's own tags for every page's head, or `null`. */
212
219
  metatags: Record<string, string> | null;
213
220
  /**
@@ -0,0 +1,87 @@
1
+ import { resolveLocalizable } from "./localizable.ts";
2
+ import type { LocalizableLabel } from "./schema.ts";
3
+
4
+ /**
5
+ * Clients the "Connect to MCP" page action knows how to install the server
6
+ * into, in display order. Shared by the config schema (`agents.mcp.clients`
7
+ * entries validate against this list) and the PageActions menu (which renders
8
+ * the full list when the config is `true`). Lives outside `schema.ts` so the
9
+ * component can import the list without pulling the whole config schema into
10
+ * the layout module graph.
11
+ */
12
+ export const mcpClients = ["claude-code", "codex", "cursor", "vscode"] as const;
13
+
14
+ export type McpClient = (typeof mcpClients)[number];
15
+
16
+ /** A client Blume doesn't ship: a menu row that copies its `command`. */
17
+ export interface McpCustomClient {
18
+ /**
19
+ * Command the row copies, with `{name}` (the server name as an id, like
20
+ * `acme-docs`) and `{url}` (the server's absolute URL) filled in.
21
+ */
22
+ command: string;
23
+ /** Icon beside the label: a Lucide name, inline SVG, or image. Defaults to `terminal`. */
24
+ icon?: string;
25
+ /** Row label, optionally per locale (`{ en: "…", de: "…" }`). */
26
+ label: LocalizableLabel;
27
+ }
28
+
29
+ export type McpClientEntry = McpClient | McpCustomClient;
30
+
31
+ /** A menu entry as a page renders it: a custom label resolved to its locale. */
32
+ export type McpMenuEntry =
33
+ | McpClient
34
+ | (Omit<McpCustomClient, "label"> & { label: string });
35
+
36
+ /** Whether a `clients` entry names a built-in client rather than a custom one. */
37
+ export const isBuiltInMcpClient = (
38
+ client: McpClientEntry | McpMenuEntry
39
+ ): client is McpClient => typeof client === "string";
40
+
41
+ /** Resolve the custom clients' labels for one page's locale. */
42
+ export const mcpMenuEntries = (
43
+ clients: readonly McpClientEntry[],
44
+ locale?: string,
45
+ defaultLocale?: string
46
+ ): McpMenuEntry[] =>
47
+ clients.map((client) =>
48
+ isBuiltInMcpClient(client)
49
+ ? client
50
+ : {
51
+ ...client,
52
+ label: resolveLocalizable(client.label, locale, defaultLocale),
53
+ }
54
+ );
55
+
56
+ /** Whether a row opens an install link rather than copying a command. */
57
+ const opensLink = (client: McpMenuEntry): boolean =>
58
+ client === "cursor" || client === "vscode";
59
+
60
+ /**
61
+ * The menu's rows after "Copy server URL", each with whether a rule goes
62
+ * above it: wherever the list switches between commands to copy and install
63
+ * links ("Copy server URL" copies too), so the default menu keeps its one
64
+ * rule before Cursor.
65
+ */
66
+ export const mcpMenuRows = (
67
+ entries: readonly McpMenuEntry[]
68
+ ): { client: McpMenuEntry; rule: boolean }[] =>
69
+ entries.map((client, index) => {
70
+ const previous = entries[index - 1];
71
+ return {
72
+ client,
73
+ rule:
74
+ opensLink(client) !== (previous !== undefined && opensLink(previous)),
75
+ };
76
+ });
77
+
78
+ /** Fill a custom client's command for the server it installs. */
79
+ export const mcpClientCommand = (
80
+ command: string,
81
+ server: { name: string; url: string }
82
+ ): string =>
83
+ // Replacer functions, not strings: a URL may contain `$`, which a string
84
+ // replacement would interpret as a substitution pattern.
85
+ command
86
+ .replaceAll("{name}", () => server.name)
87
+ .replaceAll("{url}", () => server.url);
@@ -1,6 +1,9 @@
1
1
  import { isAssetIcon } from "../theme/icon-kind.ts";
2
2
  import { hasIcon } from "../theme/icons.ts";
3
3
  import { isInternalPath } from "./base-path.ts";
4
+ import { resolveLocalizable } from "./localizable.ts";
5
+ import { isBuiltInMcpClient } from "./mcp-clients.ts";
6
+ import type { McpClientEntry } from "./mcp-clients.ts";
4
7
  import { isPageRef, normalizeRef } from "./navigation.ts";
5
8
  import type { SidebarItemConfig } from "./schema.ts";
6
9
  import type { Diagnostic, NavNode, Navigation, PageRecord } from "./types.ts";
@@ -100,6 +103,28 @@ export const validateSearchPopularIcons = (
100
103
  return unknownIconDiagnostics(icons, ICON_FORMAT_HINT);
101
104
  };
102
105
 
106
+ /**
107
+ * Warn about unknown icons on custom "Connect to MCP" clients
108
+ * (`agents.mcp.clients`), which live outside the navigation model too. A
109
+ * per-locale label is named in the default locale.
110
+ */
111
+ export const validateMcpClientIcons = (
112
+ clients: readonly McpClientEntry[],
113
+ defaultLocale?: string
114
+ ): Diagnostic[] => {
115
+ const icons = clients.flatMap((client) =>
116
+ !isBuiltInMcpClient(client) && client.icon
117
+ ? [
118
+ {
119
+ icon: client.icon,
120
+ where: `MCP client "${resolveLocalizable(client.label, defaultLocale, defaultLocale)}"`,
121
+ },
122
+ ]
123
+ : []
124
+ );
125
+ return unknownIconDiagnostics(icons, ICON_FORMAT_HINT);
126
+ };
127
+
103
128
  /** Whether an internal path resolves to a page or a section that has pages. */
104
129
  const resolvesToPages = (routes: Set<string>, path: string): boolean =>
105
130
  routes.has(path) || [...routes].some((route) => route.startsWith(`${path}/`));
@@ -43,6 +43,7 @@ import { isExternalUrl, normalizeBasePath } from "./base-path.ts";
43
43
  import { FOOTER_SOCIALS } from "./footer.ts";
44
44
  import { PUBLIC_HOST_URL } from "./github.ts";
45
45
  import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
46
+ import { isBuiltInMcpClient, mcpClients } from "./mcp-clients.ts";
46
47
  import { openInChatProviders } from "./open-in-chat.ts";
47
48
  import { PAGE_MODES } from "./page-modes.ts";
48
49
  import { redirectPatternError } from "./redirect-patterns.ts";
@@ -487,10 +488,13 @@ const apiConfigSchema = z.strictObject({
487
488
  });
488
489
 
489
490
  /**
490
- * The site footer: a row of links, and social profile icons. Unset, the site
491
- * has no footer (a `components.ts` `Footer` still renders).
491
+ * The site footer: a copyright line, a row of links, and social profile
492
+ * icons. Unset, the site has no footer (a `components.ts` `Footer` still
493
+ * renders).
492
494
  */
493
495
  const footerConfigSchema = z.strictObject({
496
+ /** A line of plain text between the links and the icons (`© 2026 Acme, Inc.`). */
497
+ copyright: localizableLabelSchema.optional(),
494
498
  /** Links in one row, in the order written. */
495
499
  links: z
496
500
  .array(z.strictObject({ href: z.string(), label: localizableLabelSchema }))
@@ -891,7 +895,91 @@ const publicJwkSchema = z
891
895
  }
892
896
  });
893
897
 
898
+ // The label is the row button's only accessible name (its icon is
899
+ // decorative), so unlike a header link's it can't be blank in any locale.
900
+ const mcpClientLabelSchema = z.union(
901
+ [
902
+ z.string().trim().min(1),
903
+ z
904
+ .record(z.string(), z.string().trim().min(1))
905
+ .refine((value) => Object.keys(value).length > 0, {
906
+ message: "Provide at least one locale's label.",
907
+ }),
908
+ ],
909
+ { error: "Expected a label, or a map of locale code to label" }
910
+ );
911
+
912
+ // A "Connect to MCP" row for a client Blume doesn't ship: its `command` is
913
+ // copied with `{name}` and `{url}` filled in.
914
+ const mcpCustomClientSchema = z.strictObject({
915
+ command: z.string().trim().min(1),
916
+ icon: iconName.optional(),
917
+ label: mcpClientLabelSchema,
918
+ });
919
+
920
+ const ACCEPTED_MCP_CLIENTS = `${mcpClients.map((key) => `"${key}"`).join(", ")}, or a custom client { label, command, icon? }`;
921
+
922
+ /** A union's issue: every member failed, each with issues of its own. */
923
+ const isUnionIssue = (
924
+ issue: z.core.$ZodIssue
925
+ ): issue is z.core.$ZodIssueInvalidUnion => issue.code === "invalid_union";
926
+
927
+ // A bad entry fails the list member of `clients`, which zod reports as a bare
928
+ // "Invalid input" on the whole list; that union issue is all this map sees.
929
+ // The entries' issues ride along in `errors`. The culprit is an entry that
930
+ // matches neither a built-in key nor a custom client, which fails as a union
931
+ // at its index (a blank field or an unknown key elsewhere doesn't fail the
932
+ // list on its own). Name that entry, and for a custom client its bad field.
933
+ const mcpClientsError = (issue: z.core.$ZodRawIssue): string => {
934
+ const entryIssue = (issue.code === "invalid_union" ? issue.errors : [])
935
+ .flat()
936
+ .filter(isUnionIssue)
937
+ .find((nested) => Number.isInteger(nested.path[0]));
938
+ if (!entryIssue) {
939
+ return `agents.mcp.clients takes true, false, or a list whose entries are each ${ACCEPTED_MCP_CLIENTS}.`;
940
+ }
941
+ const index = Number(entryIssue.path[0]);
942
+ const at = `agents.mcp.clients.${String(index)}`;
943
+ const entry = Array.isArray(issue.input) ? issue.input[index] : null;
944
+ if (isString(entry)) {
945
+ return `${at} is "${entry}", which isn't a built-in client. Use ${ACCEPTED_MCP_CLIENTS}.`;
946
+ }
947
+ const fieldIssue = entryIssue.errors
948
+ .flat()
949
+ .find((nested) => nested.path.length > 0);
950
+ return fieldIssue
951
+ ? `${at}.${fieldIssue.path.map(String).join(".")}: ${fieldIssue.message}. A custom client is { label, command, icon? }.`
952
+ : `${at} isn't ${ACCEPTED_MCP_CLIENTS}.`;
953
+ };
954
+
894
955
  const mcpConfigSchema = z.strictObject({
956
+ /**
957
+ * The "Connect to MCP" page action's clients. `true` (the default) lists
958
+ * every built-in client, `false` none (the menu keeps "Copy server URL"),
959
+ * and an array picks built-in clients by key and adds custom ones, in the
960
+ * given order. Normalized to the list so consumers read a plain array.
961
+ */
962
+ clients: z
963
+ .union(
964
+ [
965
+ z.boolean(),
966
+ z.array(z.union([z.enum(mcpClients), mcpCustomClientSchema])).refine(
967
+ (value) => {
968
+ const keys = value.filter(isBuiltInMcpClient);
969
+ return new Set(keys).size === keys.length;
970
+ },
971
+ { message: "agents.mcp.clients must not repeat a built-in client." }
972
+ ),
973
+ ],
974
+ { error: mcpClientsError }
975
+ )
976
+ .default(true)
977
+ .transform((value) => {
978
+ if (isBoolean(value)) {
979
+ return value ? [...mcpClients] : [];
980
+ }
981
+ return value;
982
+ }),
895
983
  enabled: z.boolean().default(false),
896
984
  /** Optional system hint passed to connecting agents. */
897
985
  instructions: z.string().optional(),
@@ -1182,6 +1270,8 @@ export type AssistantConfig = NonNullable<
1182
1270
  >;
1183
1271
  export { openInChatProviders } from "./open-in-chat.ts";
1184
1272
  export type { OpenInChatProvider } from "./open-in-chat.ts";
1273
+ export { mcpClients } from "./mcp-clients.ts";
1274
+ export type { McpClient, McpCustomClient } from "./mcp-clients.ts";
1185
1275
 
1186
1276
  // Reader-facing "Export" page action (PDF via print, EPUB via client-side
1187
1277
  // generation). Off by default. Accepts a shorthand boolean to toggle both
@@ -2271,7 +2361,7 @@ export const blumeConfigSchema = z
2271
2361
  ctx.addIssue({
2272
2362
  code: z.ZodIssueCode.custom,
2273
2363
  message:
2274
- 'rateLimit: cloudflare() counts with a Workers binding, so it needs `deployment: cloudflare()` from "blume/deploy". On another host, use upstash() or memory() from "blume/ratelimit".',
2364
+ 'rateLimit: cloudflare() counts with a Workers binding, so it needs `deployment: cloudflare()` from "blume/deploy". On another host, use upstash(), unkey(), or memory() from "blume/ratelimit".',
2275
2365
  path: ["rateLimit"],
2276
2366
  });
2277
2367
  }
@@ -28,3 +28,5 @@ export type { MemoryAdapter, RateLimitOptions } from "./memory.ts";
28
28
  export type { RateLimitAdapter } from "./schema.ts";
29
29
  export { upstash } from "./upstash.ts";
30
30
  export type { UpstashAdapter, UpstashOptions } from "./upstash.ts";
31
+ export { unkey } from "./unkey.ts";
32
+ export type { UnkeyAdapter, UnkeyOptions } from "./unkey.ts";
@@ -35,7 +35,7 @@ export const memoryAdapterSchema = adapterDescriptorSchema(
35
35
  * platform that runs many short-lived instances (Vercel, Netlify,
36
36
  * Cloudflare) each instance keeps its own, so it stops a burst from one
37
37
  * reader rather than enforcing the limit exactly. For that, share the count
38
- * with `upstash()` or `cloudflare()`.
38
+ * with `upstash()`, `cloudflare()`, or `unkey()`.
39
39
  */
40
40
  export const memory = (options: RateLimitOptions = {}): MemoryAdapter => ({
41
41
  kind: "memory",
@@ -11,11 +11,14 @@
11
11
  * the shared store fails to count (logged, not blocked). A shared store
12
12
  * without its secrets or binding falls back to counting in memory.
13
13
  */
14
+ import { z } from "zod";
15
+
14
16
  import { clientAddressOf } from "../core/client-address.ts";
15
17
  import type { ClientContext } from "../core/client-address.ts";
16
18
  import { RATE_LIMIT_BINDING } from "./cloudflare.ts";
17
19
  import { DEFAULT_REQUESTS, DEFAULT_WINDOW } from "./memory.ts";
18
20
  import type { RateLimitAdapter } from "./schema.ts";
21
+ import { UNKEY_MAX_WINDOW, UNKEY_NAMESPACE, unkeySecrets } from "./unkey.ts";
19
22
  import { upstashSecrets } from "./upstash.ts";
20
23
 
21
24
  /** What a limiter says about one request. */
@@ -152,6 +155,68 @@ export const upstashLimiter = (
152
155
  };
153
156
  };
154
157
 
158
+ /** Unkey's rate limit endpoint. */
159
+ const UNKEY_LIMIT_URL = "https://api.unkey.com/v2/ratelimit.limit";
160
+
161
+ /**
162
+ * How long Unkey gets to count a request, in milliseconds, before the
163
+ * request is let through without it.
164
+ */
165
+ export const UNKEY_TIMEOUT = 2000;
166
+
167
+ /** The part of Unkey's reply the limiter reads. */
168
+ const unkeyReplySchema = z.object({
169
+ data: z.object({
170
+ overrideId: z.string().optional(),
171
+ reset: z.number(),
172
+ success: z.boolean(),
173
+ }),
174
+ });
175
+
176
+ /**
177
+ * Count with Unkey's rate limit API. Unkey takes identifiers of letters,
178
+ * digits, and `_.:/-`, so anything else in the key (an IPv6 host's brackets,
179
+ * an address's `%` zone) becomes `_`. Its `reset` is a timestamp on Unkey's
180
+ * clock, which this server's may not match, so `retryAfter` is kept between
181
+ * one second and the window: the configured one, or Unkey's longest when an
182
+ * override set in Unkey replaced it.
183
+ */
184
+ export const unkeyLimiter =
185
+ (
186
+ requests: number,
187
+ window: number,
188
+ namespace: string,
189
+ rootKey: string,
190
+ { fetch: fetchImpl = fetch, now = Date.now }: LimiterRuntime = {}
191
+ ): Limiter =>
192
+ async (key) => {
193
+ const response = await fetchImpl(UNKEY_LIMIT_URL, {
194
+ body: JSON.stringify({
195
+ duration: window * 1000,
196
+ identifier: key.replaceAll(/[^\w.:/-]/gu, "_"),
197
+ limit: requests,
198
+ namespace,
199
+ }),
200
+ headers: {
201
+ authorization: `Bearer ${rootKey}`,
202
+ "content-type": "application/json",
203
+ },
204
+ method: "POST",
205
+ signal: AbortSignal.timeout(UNKEY_TIMEOUT),
206
+ });
207
+ if (!response.ok) {
208
+ await response.body?.cancel();
209
+ throw new Error(`Unkey answered ${response.status}.`);
210
+ }
211
+ const { data } = unkeyReplySchema.parse(await response.json());
212
+ const seconds = Math.ceil((data.reset - now()) / 1000);
213
+ const longest = data.overrideId ? UNKEY_MAX_WINDOW : window;
214
+ return {
215
+ allowed: data.success,
216
+ retryAfter: Math.min(longest, Math.max(1, seconds)),
217
+ };
218
+ };
219
+
155
220
  /** Count with a Workers rate limiting binding. */
156
221
  export const bindingLimiter =
157
222
  (binding: RateLimitBinding, window: number): Limiter =>
@@ -185,6 +250,22 @@ export const createLimiter = (
185
250
  return null;
186
251
  }
187
252
  const { requests, window } = limitOf(adapter);
253
+ if (adapter.kind === "unkey") {
254
+ const [secret] = unkeySecrets(adapter.options);
255
+ const rootKey = runtime.secret?.(secret);
256
+ if (rootKey) {
257
+ return unkeyLimiter(
258
+ requests,
259
+ window,
260
+ adapter.options.namespace ?? UNKEY_NAMESPACE,
261
+ rootKey,
262
+ runtime
263
+ );
264
+ }
265
+ console.warn(
266
+ `Rate limiting counts in memory: set ${secret} to share the count through Unkey.`
267
+ );
268
+ }
188
269
  if (adapter.kind === "cloudflare") {
189
270
  if (runtime.binding) {
190
271
  return bindingLimiter(runtime.binding, window);
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
 
3
3
  import { cloudflareRateLimitAdapterSchema } from "./cloudflare.ts";
4
4
  import { memory, memoryAdapterSchema } from "./memory.ts";
5
+ import { unkeyAdapterSchema } from "./unkey.ts";
5
6
  import { upstashAdapterSchema } from "./upstash.ts";
6
7
 
7
8
  const ADAPTER_HINT =
@@ -12,6 +13,7 @@ export const rateLimitAdapterSchema = z.discriminatedUnion("kind", [
12
13
  cloudflareRateLimitAdapterSchema,
13
14
  memoryAdapterSchema,
14
15
  upstashAdapterSchema,
16
+ unkeyAdapterSchema,
15
17
  ]);
16
18
 
17
19
  export type RateLimitAdapter = z.output<typeof rateLimitAdapterSchema>;
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+
3
+ import type { AdapterDescriptor } from "../core/adapter.ts";
4
+ import { adapterDescriptorSchema } from "../core/adapter.ts";
5
+ import { rateLimitOptionsSchema } from "./memory.ts";
6
+ import type { RateLimitOptions } from "./memory.ts";
7
+
8
+ /** The env var holding the Unkey root key, unless set. */
9
+ const UNKEY_ROOT_KEY_ENV = "UNKEY_ROOT_KEY";
10
+
11
+ /** The Unkey rate limit namespace the count is kept in, unless set. */
12
+ export const UNKEY_NAMESPACE = "docs";
13
+
14
+ /** The longest window Unkey takes, in seconds (30 days). */
15
+ export const UNKEY_MAX_WINDOW = 2_592_000;
16
+
17
+ /** Options for {@link unkey}. */
18
+ export interface UnkeyOptions extends RateLimitOptions {
19
+ /**
20
+ * The Unkey rate limit namespace, up to 512 characters. Defaults to `docs`;
21
+ * Unkey creates it on the first request.
22
+ */
23
+ namespace?: string;
24
+ /** Name of the env var holding the root key. Defaults to `UNKEY_ROOT_KEY`. */
25
+ rootKeyEnv?: string;
26
+ /** The window, in seconds: Unkey takes up to 30 days. */
27
+ window?: number;
28
+ }
29
+
30
+ export const unkeyOptionsSchema = rateLimitOptionsSchema.extend({
31
+ namespace: z.string().min(1).max(512).optional(),
32
+ rootKeyEnv: z.string().min(1).optional(),
33
+ window: z.number().int().positive().max(UNKEY_MAX_WINDOW).optional(),
34
+ });
35
+
36
+ export type UnkeyAdapter = AdapterDescriptor<"unkey", UnkeyOptions>;
37
+
38
+ export const unkeyAdapterSchema = adapterDescriptorSchema(
39
+ "unkey",
40
+ unkeyOptionsSchema
41
+ );
42
+
43
+ /** The env var the root key is read from. */
44
+ export const unkeySecrets = (options: UnkeyOptions): [string] => [
45
+ options.rootKeyEnv ?? UNKEY_ROOT_KEY_ENV,
46
+ ];
47
+
48
+ /**
49
+ * Counts with Unkey's rate limiting, which every instance on every host
50
+ * shares. Unkey counts in each region and shares the counts between them
51
+ * within moments, so a short burst split across regions can pass more than
52
+ * the limit. It talks to Unkey's REST API, so there is nothing to install;
53
+ * the root key comes from `UNKEY_ROOT_KEY`, or the env var `rootKeyEnv`
54
+ * names.
55
+ */
56
+ export const unkey = (options: UnkeyOptions = {}): UnkeyAdapter => ({
57
+ kind: "unkey",
58
+ options,
59
+ requiredSecrets: unkeySecrets(options),
60
+ runtimeDeps: [],
61
+ });
@@ -264,7 +264,7 @@ export const registry: RegistryItem[] = [
264
264
  }),
265
265
  layoutComponent({
266
266
  description:
267
- "The site footer: a row of links, social icons, the theme toggle, and the Cookie settings link.",
267
+ "The site footer: a copyright line, a row of links, social icons, and the Cookie settings link.",
268
268
  file: "SiteFooter.astro",
269
269
  name: "footer",
270
270
  slot: "Footer",