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.
- package/CHANGELOG.md +8 -0
- package/dist/cli/{chunk-xgq4eytj.js → chunk-080mfch6.js} +15 -15
- package/dist/cli/{chunk-br0n8n3e.js → chunk-1ch3ct3r.js} +14 -14
- package/dist/cli/{chunk-49r370mb.js → chunk-2ssh60qh.js} +6 -6
- package/dist/cli/{chunk-1cm0h819.js → chunk-34wap3ed.js} +3 -3
- package/dist/cli/{chunk-fqy4n3q2.js → chunk-40vk8kgf.js} +7 -7
- package/dist/cli/{chunk-96snx8yy.js → chunk-487b95qy.js} +3 -3
- package/dist/cli/{chunk-kkrteb15.js → chunk-4zxh25hc.js} +2 -2
- package/dist/cli/{chunk-cn3dy3hz.js → chunk-69rjf786.js} +5 -5
- package/dist/cli/{chunk-dmx6cezx.js → chunk-9h8mh2tk.js} +3 -3
- package/dist/cli/{chunk-dmx6cezx.js.map → chunk-9h8mh2tk.js.map} +2 -2
- package/dist/cli/{chunk-t5xx74rd.js → chunk-9jqmq5hv.js} +6 -6
- package/dist/cli/{chunk-8b489v1b.js → chunk-aafhwx74.js} +1 -1
- package/dist/cli/{chunk-2nr29y95.js → chunk-b680sv30.js} +3 -3
- package/dist/cli/{chunk-rkqae7kh.js → chunk-beb0z983.js} +16 -16
- package/dist/cli/{chunk-th0pgaj5.js → chunk-c5bjxq8v.js} +2 -2
- package/dist/cli/{chunk-wehv26ry.js → chunk-e1n41w9m.js} +3 -3
- package/dist/cli/{chunk-frk2wdq1.js → chunk-etckvbrh.js} +14 -14
- package/dist/cli/{chunk-ahc5gc9h.js → chunk-f260jdrn.js} +5 -5
- package/dist/cli/{chunk-05f21r8h.js → chunk-fnxgtjeq.js} +1 -1
- package/dist/cli/{chunk-4rftmttv.js → chunk-gwy55cx3.js} +2 -2
- package/dist/cli/{chunk-4rftmttv.js.map → chunk-gwy55cx3.js.map} +2 -2
- package/dist/cli/{chunk-vnbvhgmd.js → chunk-gxhcf2hs.js} +2 -2
- package/dist/cli/{chunk-bjk964fp.js → chunk-h0qwdbem.js} +2 -2
- package/dist/cli/{chunk-1rkxzg3n.js → chunk-hktq4j5r.js} +15 -12
- package/dist/cli/{chunk-1rkxzg3n.js.map → chunk-hktq4j5r.js.map} +3 -3
- package/dist/cli/{chunk-enk76qd5.js → chunk-hn3g2b89.js} +9 -9
- package/dist/cli/{chunk-w6e455pz.js → chunk-j4nfgn2j.js} +1 -1
- package/dist/cli/{chunk-as09pafp.js → chunk-k45sq16q.js} +4 -4
- package/dist/cli/{chunk-as09pafp.js.map → chunk-k45sq16q.js.map} +3 -3
- package/dist/cli/{chunk-p8zg3a2h.js → chunk-kmswjxj9.js} +3 -3
- package/dist/cli/{chunk-xt0qvz1t.js → chunk-nqna8dzp.js} +17 -17
- package/dist/cli/{chunk-dyk3r7jw.js → chunk-p834rkcn.js} +5 -5
- package/dist/cli/{chunk-dpcahcrb.js → chunk-ppa6mg4d.js} +6 -6
- package/dist/cli/{chunk-79bzzkx4.js → chunk-q1wwrxzw.js} +2 -2
- package/dist/cli/{chunk-m9bp2hc1.js → chunk-qcyxkj44.js} +1 -1
- package/dist/cli/{chunk-dm4mg9bj.js → chunk-r5nm9w0c.js} +740 -685
- package/dist/cli/{chunk-dm4mg9bj.js.map → chunk-r5nm9w0c.js.map} +8 -6
- package/dist/cli/{chunk-a3ydrqny.js → chunk-rtvgmg2j.js} +2 -2
- package/dist/cli/{chunk-c69tw29j.js → chunk-t2h8pwy1.js} +4 -4
- package/dist/cli/{chunk-vzy9rh6y.js → chunk-x60e15st.js} +17 -17
- package/dist/cli/{chunk-mrvh4c88.js → chunk-x7bh6d9j.js} +4 -4
- package/dist/cli/{chunk-7tekknev.js → chunk-ydqzkhjn.js} +6 -6
- package/dist/cli/{chunk-gprwej7h.js → chunk-yg8ckc3g.js} +2 -2
- package/dist/cli/{chunk-8f8xw4x2.js → chunk-zxd5jrgx.js} +14 -4
- package/dist/cli/{chunk-8f8xw4x2.js.map → chunk-zxd5jrgx.js.map} +3 -3
- package/dist/cli/index.js +18 -18
- package/dist/types/core/config-input.d.ts +37 -5
- package/dist/types/core/data.d.ts +6 -2
- package/dist/types/core/mcp-clients.d.ts +47 -0
- package/dist/types/core/nav-diagnostics.d.ts +7 -0
- package/dist/types/core/schema.d.ts +55 -4
- package/dist/types/ratelimit/index.d.ts +2 -0
- package/dist/types/ratelimit/memory.d.ts +1 -1
- package/dist/types/ratelimit/schema.d.ts +40 -0
- package/dist/types/ratelimit/unkey.d.ts +48 -0
- package/docs/02-deployment.mdx +1 -0
- package/docs/configuration/index.mdx +5 -4
- package/docs/configuration/rate-limiting.mdx +20 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/discoverability/mcp.mdx +40 -8
- package/package.json +1 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +2 -2
- package/skills/blume-migrate/references/gitbook.md +2 -2
- package/skills/blume-migrate/references/github-wiki.md +2 -2
- package/skills/blume-migrate/references/jekyll.md +2 -2
- package/skills/blume-migrate/references/mkdocs.md +2 -2
- package/skills/blume-migrate/references/nextra.md +3 -3
- package/skills/blume-migrate/references/readme.md +1 -1
- package/skills/blume-migrate/references/redocly.md +2 -2
- package/skills/blume-migrate/references/vitepress.md +2 -2
- package/skills/blume-migrate/references/vuepress.md +1 -1
- package/skills/blume-migrate/scripts/github-wiki-codemod.mjs +1 -1
- package/src/astro/generate.ts +15 -5
- package/src/astro/templates.ts +4 -4
- package/src/cli/required-secrets.ts +0 -1
- package/src/components/layout/PageActions.astro +81 -23
- package/src/components/layout/RootLayout.astro +17 -1
- package/src/components/layout/SiteFooter.astro +31 -8
- package/src/core/config-input.ts +38 -4
- package/src/core/data.ts +10 -3
- package/src/core/mcp-clients.ts +87 -0
- package/src/core/nav-diagnostics.ts +25 -0
- package/src/core/schema.ts +93 -3
- package/src/ratelimit/index.ts +2 -0
- package/src/ratelimit/memory.ts +1 -1
- package/src/ratelimit/runtime.ts +81 -0
- package/src/ratelimit/schema.ts +2 -0
- package/src/ratelimit/unkey.ts +61 -0
- package/src/registry/registry.ts +1 -1
- /package/dist/cli/{chunk-xgq4eytj.js.map → chunk-080mfch6.js.map} +0 -0
- /package/dist/cli/{chunk-br0n8n3e.js.map → chunk-1ch3ct3r.js.map} +0 -0
- /package/dist/cli/{chunk-49r370mb.js.map → chunk-2ssh60qh.js.map} +0 -0
- /package/dist/cli/{chunk-1cm0h819.js.map → chunk-34wap3ed.js.map} +0 -0
- /package/dist/cli/{chunk-fqy4n3q2.js.map → chunk-40vk8kgf.js.map} +0 -0
- /package/dist/cli/{chunk-96snx8yy.js.map → chunk-487b95qy.js.map} +0 -0
- /package/dist/cli/{chunk-kkrteb15.js.map → chunk-4zxh25hc.js.map} +0 -0
- /package/dist/cli/{chunk-cn3dy3hz.js.map → chunk-69rjf786.js.map} +0 -0
- /package/dist/cli/{chunk-t5xx74rd.js.map → chunk-9jqmq5hv.js.map} +0 -0
- /package/dist/cli/{chunk-8b489v1b.js.map → chunk-aafhwx74.js.map} +0 -0
- /package/dist/cli/{chunk-2nr29y95.js.map → chunk-b680sv30.js.map} +0 -0
- /package/dist/cli/{chunk-rkqae7kh.js.map → chunk-beb0z983.js.map} +0 -0
- /package/dist/cli/{chunk-th0pgaj5.js.map → chunk-c5bjxq8v.js.map} +0 -0
- /package/dist/cli/{chunk-wehv26ry.js.map → chunk-e1n41w9m.js.map} +0 -0
- /package/dist/cli/{chunk-frk2wdq1.js.map → chunk-etckvbrh.js.map} +0 -0
- /package/dist/cli/{chunk-ahc5gc9h.js.map → chunk-f260jdrn.js.map} +0 -0
- /package/dist/cli/{chunk-05f21r8h.js.map → chunk-fnxgtjeq.js.map} +0 -0
- /package/dist/cli/{chunk-vnbvhgmd.js.map → chunk-gxhcf2hs.js.map} +0 -0
- /package/dist/cli/{chunk-bjk964fp.js.map → chunk-h0qwdbem.js.map} +0 -0
- /package/dist/cli/{chunk-enk76qd5.js.map → chunk-hn3g2b89.js.map} +0 -0
- /package/dist/cli/{chunk-w6e455pz.js.map → chunk-j4nfgn2j.js.map} +0 -0
- /package/dist/cli/{chunk-p8zg3a2h.js.map → chunk-kmswjxj9.js.map} +0 -0
- /package/dist/cli/{chunk-xt0qvz1t.js.map → chunk-nqna8dzp.js.map} +0 -0
- /package/dist/cli/{chunk-dyk3r7jw.js.map → chunk-p834rkcn.js.map} +0 -0
- /package/dist/cli/{chunk-dpcahcrb.js.map → chunk-ppa6mg4d.js.map} +0 -0
- /package/dist/cli/{chunk-79bzzkx4.js.map → chunk-q1wwrxzw.js.map} +0 -0
- /package/dist/cli/{chunk-m9bp2hc1.js.map → chunk-qcyxkj44.js.map} +0 -0
- /package/dist/cli/{chunk-a3ydrqny.js.map → chunk-rtvgmg2j.js.map} +0 -0
- /package/dist/cli/{chunk-c69tw29j.js.map → chunk-t2h8pwy1.js.map} +0 -0
- /package/dist/cli/{chunk-vzy9rh6y.js.map → chunk-x60e15st.js.map} +0 -0
- /package/dist/cli/{chunk-mrvh4c88.js.map → chunk-x7bh6d9j.js.map} +0 -0
- /package/dist/cli/{chunk-7tekknev.js.map → chunk-ydqzkhjn.js.map} +0 -0
- /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
|
|
4
|
-
// other. The site's repository
|
|
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
|
|
25
|
-
* entry, and internal links move into it (see
|
|
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
|
-
|
|
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
|
package/src/core/config-input.ts
CHANGED
|
@@ -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,
|
|
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:
|
|
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 `
|
|
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`:
|
|
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
|
-
/**
|
|
210
|
-
|
|
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}/`));
|
package/src/core/schema.ts
CHANGED
|
@@ -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
|
|
491
|
-
* has no footer (a `components.ts` `Footer` still
|
|
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
|
}
|
package/src/ratelimit/index.ts
CHANGED
|
@@ -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";
|
package/src/ratelimit/memory.ts
CHANGED
|
@@ -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 `
|
|
38
|
+
* with `upstash()`, `cloudflare()`, or `unkey()`.
|
|
39
39
|
*/
|
|
40
40
|
export const memory = (options: RateLimitOptions = {}): MemoryAdapter => ({
|
|
41
41
|
kind: "memory",
|
package/src/ratelimit/runtime.ts
CHANGED
|
@@ -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);
|
package/src/ratelimit/schema.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/registry/registry.ts
CHANGED
|
@@ -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,
|
|
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",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|