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
@@ -1,6 +1,12 @@
1
1
  ---
2
2
  import { EN_UI } from "../../core/i18n-ui.ts";
3
3
  import type { UIStrings } from "../../core/i18n-ui.ts";
4
+ import {
5
+ isBuiltInMcpClient,
6
+ mcpClients,
7
+ mcpMenuRows,
8
+ } from "../../core/mcp-clients.ts";
9
+ import type { McpMenuEntry } from "../../core/mcp-clients.ts";
4
10
  import { NEW_TAB_ATTRS } from "../../core/new-tab.ts";
5
11
  import { openInChatProviders } from "../../core/open-in-chat.ts";
6
12
  import type { OpenInChatProvider } from "../../core/open-in-chat.ts";
@@ -18,6 +24,11 @@ interface Props {
18
24
  mcpUrl?: string | null;
19
25
  /** Display name of the MCP server (defaults to the site title). */
20
26
  mcpName?: string | null;
27
+ /**
28
+ * "Connect to MCP" clients to list after "Copy server URL", in order, with
29
+ * custom labels resolved; omitting the prop lists every built-in client.
30
+ */
31
+ mcpMenu?: readonly McpMenuEntry[];
21
32
  /**
22
33
  * "Open in chat" providers to list, in order; an empty list hides the
23
34
  * action, and omitting the prop shows every provider.
@@ -40,6 +51,7 @@ const {
40
51
  exportEpub,
41
52
  mcpUrl,
42
53
  mcpName,
54
+ mcpMenu,
43
55
  openInChat,
44
56
  strings,
45
57
  divider = true,
@@ -89,6 +101,10 @@ const providers = (openInChat ?? openInChatProviders).map((key) => ({
89
101
  // keep that separator only while a configured list preserves v0 first.
90
102
  const chatSeparator = providers.length > 1 && providers[0]?.key === "v0";
91
103
 
104
+ // `agents.mcp.clients` picks and orders the clients after "Copy server URL",
105
+ // with a rule between the commands to copy and the install links.
106
+ const mcpRows = mcpMenuRows(mcpMenu ?? mcpClients);
107
+
92
108
  // The block renders once, for the right rail. Below `xl` the script moves it
93
109
  // into the mobile "On this page" dropdown, and the global styles below the
94
110
  // markup restyle it there from that container.
@@ -99,11 +115,17 @@ const rowClass =
99
115
  "flex w-full items-center gap-2.5 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:text-foreground";
100
116
 
101
117
  // Items inside the floating popovers keep menu affordances (padding + hover
102
- // background).
118
+ // background). Their icons sit in a box one line tall, so a label that wraps
119
+ // keeps them beside its first line.
103
120
  const menuRowClass =
104
- "flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground";
121
+ "flex w-full items-start gap-2.5 rounded-md px-2 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground";
122
+ const menuIconClass = "flex h-lh shrink-0 items-center";
123
+ // At most out to the rail's edge, past its padding (`--blume-rail-inset`, set
124
+ // by the layout), less 2px so the border never lands on the edge: the rail
125
+ // scrolls, so it clips anything wider. Longer labels wrap instead. The minimum
126
+ // width gives way to that cap in a narrower rail.
105
127
  const panelClass =
106
- "absolute top-full right-2 z-50 mt-1 w-max min-w-[14rem] max-w-[22rem] rounded-blume border border-border bg-background p-1 shadow-xl";
128
+ "absolute top-full end-0 z-50 mt-1 w-max [--blume-menu-room:calc(100%+var(--blume-rail-inset,1rem)-2px)] min-w-[min(14rem,var(--blume-menu-room))] max-w-(--blume-menu-room) wrap-anywhere rounded-blume border border-border bg-background p-1 shadow-xl";
107
129
  ---
108
130
 
109
131
  <div
@@ -156,13 +178,17 @@ const panelClass =
156
178
  <div class={panelClass} data-blume-menu>
157
179
  {exportPdf && (
158
180
  <button class={menuRowClass} data-blume-export-pdf type="button">
159
- <Icon name="file" size={16} />
181
+ <span class={menuIconClass}>
182
+ <Icon name="file" size={16} />
183
+ </span>
160
184
  <span class="flex-1">{a.exportPdf}</span>
161
185
  </button>
162
186
  )}
163
187
  {exportEpub && (
164
188
  <button class={menuRowClass} data-blume-export-epub type="button">
165
- <Icon name="book-open" size={16} />
189
+ <span class={menuIconClass}>
190
+ <Icon name="book-open" size={16} />
191
+ </span>
166
192
  <span class="flex-1" data-blume-epub-label>
167
193
  {a.exportEpub}
168
194
  </span>
@@ -198,9 +224,11 @@ const panelClass =
198
224
  data-open-in={provider.key}
199
225
  {...NEW_TAB_ATTRS}
200
226
  >
201
- <span set:html={LOGOS[provider.key]} />
227
+ <span class={menuIconClass} set:html={LOGOS[provider.key]} />
202
228
  <span class="flex-1">{provider.label}</span>
203
- <Icon name="external-link" size={13} />
229
+ <span class={menuIconClass}>
230
+ <Icon name="external-link" size={13} />
231
+ </span>
204
232
  </a>
205
233
  </>
206
234
  ))}
@@ -225,34 +253,72 @@ const panelClass =
225
253
  </summary>
226
254
  <div class={panelClass} data-blume-menu>
227
255
  <button class={menuRowClass} data-mcp-copy-url type="button">
228
- <Icon name="link" size={16} />
256
+ <span class={menuIconClass}>
257
+ <Icon name="link" size={16} />
258
+ </span>
229
259
  <span class="flex-1" data-mcp-url-label>
230
260
  {a.copyServerUrl}
231
261
  </span>
232
262
  </button>
233
- <button class={menuRowClass} data-mcp-copy-claude type="button">
234
- <span set:html={LOGOS.claude} />
235
- <span class="flex-1" data-mcp-claude-label>
236
- {a.copyClaudeCode}
237
- </span>
238
- </button>
239
- <button class={menuRowClass} data-mcp-copy-codex type="button">
240
- <span set:html={LOGOS.codex} />
241
- <span class="flex-1" data-mcp-codex-label>
242
- {a.copyCodex}
243
- </span>
244
- </button>
245
- <hr class="my-1 border-border border-t" />
246
- <a class={menuRowClass} data-mcp-cursor rel="noreferrer">
247
- <span set:html={LOGOS.cursor} />
248
- <span class="flex-1">{a.addToCursor}</span>
249
- <Icon name="external-link" size={13} />
250
- </a>
251
- <a class={menuRowClass} data-mcp-vscode rel="noreferrer">
252
- <Icon name="code" size={16} />
253
- <span class="flex-1">{a.addToVscode}</span>
254
- <Icon name="external-link" size={13} />
255
- </a>
263
+ {mcpRows.map(({ client, rule }) => (
264
+ <>
265
+ {rule && <hr class="my-1 border-border border-t" />}
266
+ {client === "claude-code" && (
267
+ <button
268
+ class={menuRowClass}
269
+ data-mcp-copy-claude
270
+ type="button"
271
+ >
272
+ <span class={menuIconClass} set:html={LOGOS.claude} />
273
+ <span class="flex-1" data-mcp-claude-label>
274
+ {a.copyClaudeCode}
275
+ </span>
276
+ </button>
277
+ )}
278
+ {client === "codex" && (
279
+ <button class={menuRowClass} data-mcp-copy-codex type="button">
280
+ <span class={menuIconClass} set:html={LOGOS.codex} />
281
+ <span class="flex-1" data-mcp-codex-label>
282
+ {a.copyCodex}
283
+ </span>
284
+ </button>
285
+ )}
286
+ {client === "cursor" && (
287
+ <a class={menuRowClass} data-mcp-cursor rel="noreferrer">
288
+ <span class={menuIconClass} set:html={LOGOS.cursor} />
289
+ <span class="flex-1">{a.addToCursor}</span>
290
+ <span class={menuIconClass}>
291
+ <Icon name="external-link" size={13} />
292
+ </span>
293
+ </a>
294
+ )}
295
+ {client === "vscode" && (
296
+ <a class={menuRowClass} data-mcp-vscode rel="noreferrer">
297
+ <span class={menuIconClass}>
298
+ <Icon name="code" size={16} />
299
+ </span>
300
+ <span class="flex-1">{a.addToVscode}</span>
301
+ <span class={menuIconClass}>
302
+ <Icon name="external-link" size={13} />
303
+ </span>
304
+ </a>
305
+ )}
306
+ {!isBuiltInMcpClient(client) && (
307
+ <button
308
+ class={menuRowClass}
309
+ data-mcp-copy-command={client.command}
310
+ type="button"
311
+ >
312
+ <span class={menuIconClass}>
313
+ <Icon name={client.icon ?? "terminal"} size={16} />
314
+ </span>
315
+ <span class="flex-1" data-mcp-command-label>
316
+ {client.label}
317
+ </span>
318
+ </button>
319
+ )}
320
+ </>
321
+ ))}
256
322
  </div>
257
323
  </details>
258
324
  )
@@ -299,6 +365,7 @@ const panelClass =
299
365
  </style>
300
366
 
301
367
  <script>
368
+ import { mcpClientCommand } from "../../core/mcp-clients.ts";
302
369
  import { copyDeferredText, copyText, flashLabel } from "../copy-feedback.ts";
303
370
  import { installDropdownDismiss } from "../dropdown-dismiss.ts";
304
371
  import { mountBase } from "../islands/base-path.ts";
@@ -397,10 +464,15 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
397
464
  return;
398
465
  }
399
466
  const rect = summary.getBoundingClientRect();
467
+ // The rail scrolls, so it clips the menu at its own edges: its top sits
468
+ // under the sticky header, below the viewport's.
469
+ const room = details
470
+ .closest("[data-blume-toc]")
471
+ ?.getBoundingClientRect() ?? { bottom: window.innerHeight, top: 0 };
400
472
  const margin = 8;
401
473
  const flipUp =
402
- rect.bottom + menu.offsetHeight + margin > window.innerHeight &&
403
- rect.top - menu.offsetHeight - margin > 0;
474
+ rect.bottom + menu.offsetHeight + margin > room.bottom &&
475
+ rect.top - menu.offsetHeight - margin > room.top;
404
476
  menu.classList.toggle("top-full", !flipUp);
405
477
  menu.classList.toggle("mt-1", !flipUp);
406
478
  menu.classList.toggle("bottom-full", flipUp);
@@ -468,7 +540,7 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
468
540
  }
469
541
  // The action dropdowns are native <details>. Make them behave like a proper
470
542
  // menu: only one open at a time, and flip above the trigger when opening
471
- // downward would run past the viewport bottom.
543
+ // downward would run past the rail's bottom and there's room above.
472
544
  const dropdowns = [...root.querySelectorAll<HTMLDetailsElement>("details")];
473
545
  for (const details of dropdowns) {
474
546
  details.addEventListener("toggle", () => {
@@ -572,6 +644,19 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
572
644
  root.querySelector("[data-mcp-codex-label]")
573
645
  )
574
646
  );
647
+ for (const button of root.querySelectorAll<HTMLElement>(
648
+ "[data-mcp-copy-command]"
649
+ )) {
650
+ button.addEventListener("click", () =>
651
+ copy(
652
+ mcpClientCommand(button.dataset.mcpCopyCommand ?? "", {
653
+ name: id,
654
+ url: mcpUrl,
655
+ }),
656
+ button.querySelector("[data-mcp-command-label]")
657
+ )
658
+ );
659
+ }
575
660
  }
576
661
 
577
662
  const label = root.querySelector("[data-blume-copy-label]");
@@ -9,6 +9,8 @@ import type { UIStrings } from "../../core/i18n-ui.ts";
9
9
  import { resolveDateFormatOptions } from "../../core/date-format.ts";
10
10
  import type { ResolvedDateFormat } from "../../core/schema.ts";
11
11
  import type { OpenInChatProvider } from "../../core/open-in-chat.ts";
12
+ import { mcpMenuEntries } from "../../core/mcp-clients.ts";
13
+ import type { McpClientEntry } from "../../core/mcp-clients.ts";
12
14
  import type {
13
15
  BlumeBanner,
14
16
  BlumeClientData,
@@ -114,7 +116,15 @@ interface Props {
114
116
  banner?: BlumeBanner | null;
115
117
  analytics?: BlumeDataConfig["analytics"] | null;
116
118
  navigation: Navigation;
117
- mcp?: { name: string; route: string } | null;
119
+ /**
120
+ * The hosted MCP server. `clients` lists the "Connect to MCP" menu's
121
+ * clients in order, and defaults to every built-in one.
122
+ */
123
+ mcp?: {
124
+ clients?: readonly McpClientEntry[];
125
+ name: string;
126
+ route: string;
127
+ } | null;
118
128
  page: { title: string; description?: string; route: string };
119
129
  headings: Heading[];
120
130
  imageZoom?: boolean;
@@ -471,6 +481,11 @@ const markdownMirror = isBare
471
481
  // Needs a configured site to be useful, so the menu is hidden without one.
472
482
  const mcpUrl =
473
483
  mcp && siteUrl ? new URL(withMountedBase(mcp.route), siteUrl).href : null;
484
+ // Custom clients' labels resolve to the page's locale. Without a list,
485
+ // PageActions shows every built-in client.
486
+ const mcpMenu = mcp?.clients
487
+ ? mcpMenuEntries(mcp.clients, locale, data.config.i18n?.defaultLocale)
488
+ : undefined;
474
489
 
475
490
  // Scope the sidebar (and the breadcrumbs/pagination derived from it) to the
476
491
  // active tab's section, so a multi-section site drills each tab into its own
@@ -953,7 +968,7 @@ const localeRedirect = localeTargets
953
968
  showToc && (
954
969
  <aside
955
970
  aria-label={strings.toc.title}
956
- class="sticky top-16 hidden h-[calc(100dvh-4rem)] scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto px-4 pt-6 pb-10 text-sm xl:block"
971
+ class="sticky top-16 hidden h-[calc(100dvh-4rem)] scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent overflow-y-auto [--blume-rail-inset:1rem] px-(--blume-rail-inset) pt-6 pb-10 text-sm xl:block"
957
972
  data-blume-toc
958
973
  >
959
974
  {hasToc && (
@@ -968,6 +983,7 @@ const localeRedirect = localeTargets
968
983
  editUrl={editUrl}
969
984
  exportEpub={exportEpub}
970
985
  exportPdf={exportPdf}
986
+ mcpMenu={mcpMenu}
971
987
  mcpName={mcp?.name}
972
988
  mcpUrl={mcpUrl}
973
989
  openInChat={openInChat}
@@ -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}/`));