blume 1.4.3 → 1.5.0
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 +17 -0
- package/dist/cli/index.js +1621 -576
- package/dist/cli/index.js.map +109 -104
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/config-input.d.ts +79 -27
- package/dist/types/core/config.d.ts +2 -1
- package/dist/types/core/data.d.ts +16 -1
- package/dist/types/core/diagnostics.d.ts +5 -1
- package/dist/types/core/i18n-ui.d.ts +12 -0
- package/dist/types/core/schema.d.ts +112 -15
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +7 -3
- package/dist/types/core/types.d.ts +43 -2
- package/dist/types/core/ui-packs/index.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +6 -5
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/meta.mdx +2 -1
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +35 -1
- package/docs/content/versioning.mdx +106 -0
- package/docs/reference/cli.mdx +1 -0
- package/docs/reference/frontmatter.mdx +3 -0
- package/package.json +3 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/fumadocs.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/agent-readability.ts +37 -10
- package/src/ai/ask-context.ts +5 -1
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +40 -16
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +28 -11
- package/src/ai/mcp/server.ts +183 -38
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +6 -2
- package/src/astro/generate.ts +54 -29
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +16 -9
- package/src/astro/templates.ts +152 -33
- package/src/audit/agent.ts +2 -2
- package/src/audit/checks/content.ts +26 -11
- package/src/audit/checks/dns-aid.ts +3 -0
- package/src/audit/checks/indexability.ts +24 -6
- package/src/audit/checks/llms.ts +9 -4
- package/src/audit/checks/network.ts +2 -0
- package/src/audit/checks/social.ts +18 -10
- package/src/audit/crawl.ts +37 -9
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +5 -2
- package/src/audit/snapshot.ts +2 -4
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +9 -4
- package/src/cli/commands/build.ts +15 -9
- package/src/cli/commands/dev.ts +2 -0
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eval.ts +7 -3
- package/src/cli/commands/init.ts +9 -9
- package/src/cli/commands/mcp-stdio.ts +3 -0
- package/src/cli/commands/translate.ts +14 -3
- package/src/cli/commands/version.ts +85 -0
- package/src/cli/dev-lock.ts +31 -10
- package/src/cli/eject-scripts.ts +17 -2
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +22 -15
- package/src/cli/internal-error.ts +1 -0
- package/src/components/content/auto-type-table.ts +3 -0
- package/src/components/content/diff.ts +9 -5
- package/src/components/content/github-info.ts +2 -0
- package/src/components/islands/ask-ai.tsx +33 -25
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/islands/webmcp.ts +49 -12
- package/src/components/layout/Header.astro +25 -1
- package/src/components/layout/NavSelector.astro +11 -2
- package/src/components/layout/NavTree.astro +4 -2
- package/src/components/layout/RootLayout.astro +18 -0
- package/src/components/layout/Search.astro +77 -13
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/hydration-hint.ts +1 -1
- package/src/components/layout/nav-utils.ts +1 -4
- package/src/components/layout/overrides.ts +25 -12
- package/src/components/layout/search/algolia.ts +18 -5
- package/src/components/layout/search/endpoint.ts +3 -0
- package/src/components/layout/search/flexsearch.ts +23 -7
- package/src/components/layout/search/orama-cloud.ts +1 -1
- package/src/components/layout/search/orama.ts +4 -1
- package/src/components/layout/search/pagefind.ts +2 -0
- package/src/components/layout/search/types.ts +13 -1
- package/src/components/layout/search/typesense.ts +19 -3
- package/src/components/openapi/ApiOverview.astro +32 -6
- package/src/components/openapi/AsyncApiOperation.astro +237 -0
- package/src/components/openapi/Bindings.astro +89 -0
- package/src/components/openapi/MethodBadge.astro +3 -0
- package/src/components/openapi/Operation.astro +7 -2
- package/src/components/openapi/PanelTabs.astro +131 -0
- package/src/components/openapi/ParametersTable.astro +2 -0
- package/src/components/openapi/RequestPanel.astro +12 -119
- package/src/components/openapi/async-snippets.ts +174 -0
- package/src/components/openapi/async.ts +348 -0
- package/src/components/openapi/helpers.ts +52 -20
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +88 -27
- package/src/core/config.ts +20 -7
- package/src/core/content.ts +3 -1
- package/src/core/data.ts +16 -1
- package/src/core/define-components.ts +5 -0
- package/src/core/diagnostics.ts +46 -38
- package/src/core/frontmatter.ts +33 -7
- package/src/core/graph.ts +137 -53
- package/src/core/i18n-ui.ts +15 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/load-module.ts +1 -0
- package/src/core/manifest.ts +92 -3
- package/src/core/meta.ts +44 -14
- package/src/core/nav-diagnostics.ts +3 -3
- package/src/core/navigation.ts +247 -67
- package/src/core/project-graph.ts +15 -3
- package/src/core/schema.ts +213 -67
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +6 -0
- package/src/core/sources/github-releases.ts +39 -31
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +67 -20
- package/src/core/sources/notion.ts +49 -17
- package/src/core/sources/portable-text.ts +32 -11
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +4 -0
- package/src/core/sources/watch.ts +1 -1
- package/src/core/standard-schema.ts +9 -3
- package/src/core/text-width.ts +26 -0
- package/src/core/tsconfig-aliases.ts +9 -5
- package/src/core/types.ts +45 -2
- package/src/core/ui-packs/index.ts +9 -1
- package/src/core/version-cut.ts +301 -0
- package/src/core/version.ts +2 -0
- package/src/core/versions.ts +170 -0
- package/src/deploy/adapter-output.ts +5 -2
- package/src/deploy/cloudflare-negotiation.ts +25 -10
- package/src/deploy/sitemap.ts +33 -1
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/report.ts +4 -4
- package/src/eval/run.ts +2 -2
- package/src/eval/schema.ts +1 -1
- package/src/markdown/base-links.ts +6 -6
- package/src/markdown/directives.ts +7 -1
- package/src/markdown/heading-anchors.ts +17 -6
- package/src/markdown/index.ts +73 -24
- package/src/markdown/inline-code.ts +14 -2
- package/src/markdown/language-icon.ts +6 -2
- package/src/markdown/mdast.ts +18 -4
- package/src/markdown/package-commands.ts +6 -8
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +30 -11
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +126 -57
- package/src/openapi/parse.ts +97 -5
- package/src/openapi/references.ts +12 -10
- package/src/openapi/render-mdx.ts +73 -34
- package/src/openapi/scalar.ts +6 -8
- package/src/openapi/source.ts +98 -28
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +25 -5
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +66 -20
- package/src/search/popular.ts +10 -5
- package/src/search/providers.ts +2 -2
- package/src/search/sync/index.ts +2 -0
- package/src/search/sync/typesense.ts +4 -2
- package/src/seo/jsonld.ts +24 -6
- package/src/seo/x-handle.ts +8 -3
- package/src/theme/chrome-icons.ts +7 -2
- package/src/theme/fonts.ts +8 -4
- package/src/theme/icons.ts +4 -2
- package/src/theme/palette.ts +22 -14
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +9 -5
- package/src/translate/run.ts +10 -4
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
package/src/cli/dev-lock.ts
CHANGED
|
@@ -29,15 +29,32 @@ export interface DevLockInfo {
|
|
|
29
29
|
|
|
30
30
|
const lockPath = (outDir: string): string => join(outDir, "dev.lock");
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/** What `JSON.parse` can yield for a lock file body. */
|
|
33
|
+
type LockFileValue =
|
|
34
|
+
| string
|
|
35
|
+
| number
|
|
36
|
+
| boolean
|
|
37
|
+
| null
|
|
38
|
+
| LockFileValue[]
|
|
39
|
+
| { [key: string]: LockFileValue };
|
|
40
|
+
|
|
41
|
+
const isValidPid = (pid: LockFileValue | undefined): pid is number =>
|
|
33
42
|
typeof pid === "number" && Number.isInteger(pid) && pid > 0;
|
|
34
43
|
|
|
44
|
+
const isPortNumber = (port: LockFileValue | undefined): port is number =>
|
|
45
|
+
typeof port === "number";
|
|
46
|
+
|
|
47
|
+
const isLockRecord = (
|
|
48
|
+
data: LockFileValue
|
|
49
|
+
): data is { pid?: LockFileValue; port?: LockFileValue } =>
|
|
50
|
+
typeof data === "object" && data !== null;
|
|
51
|
+
|
|
35
52
|
/**
|
|
36
53
|
* Parse a lock file body. Current locks are JSON (`{"pid":123,"port":3001}`);
|
|
37
54
|
* a bare integer (the pre-port format) still parses as a pid-only lock.
|
|
38
55
|
*/
|
|
39
56
|
const parseLock = (raw: string): DevLockInfo | null => {
|
|
40
|
-
let data:
|
|
57
|
+
let data: LockFileValue;
|
|
41
58
|
try {
|
|
42
59
|
data = JSON.parse(raw.trim());
|
|
43
60
|
} catch {
|
|
@@ -46,10 +63,10 @@ const parseLock = (raw: string): DevLockInfo | null => {
|
|
|
46
63
|
if (isValidPid(data)) {
|
|
47
64
|
return { pid: data };
|
|
48
65
|
}
|
|
49
|
-
if (
|
|
50
|
-
const { pid, port } = data
|
|
66
|
+
if (isLockRecord(data)) {
|
|
67
|
+
const { pid, port } = data;
|
|
51
68
|
if (isValidPid(pid)) {
|
|
52
|
-
return
|
|
69
|
+
return isPortNumber(port) ? { pid, port } : { pid };
|
|
53
70
|
}
|
|
54
71
|
}
|
|
55
72
|
return null;
|
|
@@ -63,6 +80,7 @@ const isProcessAlive = (pid: number): boolean => {
|
|
|
63
80
|
} catch (error) {
|
|
64
81
|
// EPERM means the process exists but belongs to another user — still
|
|
65
82
|
// live, so the lock must hold (only ESRCH proves it's gone).
|
|
83
|
+
// SAFETY: `process.kill` failures are errno exceptions.
|
|
66
84
|
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
67
85
|
}
|
|
68
86
|
};
|
|
@@ -84,11 +102,13 @@ export const readDevLock = (outDir: string): DevLockInfo | null => {
|
|
|
84
102
|
export const isDevLocked = (outDir: string): boolean =>
|
|
85
103
|
readDevLock(outDir) !== null;
|
|
86
104
|
|
|
87
|
-
const lockPayload = (port?: number): string =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
105
|
+
const lockPayload = (port?: number): string => {
|
|
106
|
+
const info: DevLockInfo = { pid: process.pid };
|
|
107
|
+
if (port !== undefined) {
|
|
108
|
+
info.port = port;
|
|
109
|
+
}
|
|
110
|
+
return JSON.stringify(info);
|
|
111
|
+
};
|
|
92
112
|
|
|
93
113
|
const writeLock = (outDir: string, port?: number): void => {
|
|
94
114
|
writeFileSync(lockPath(outDir), lockPayload(port));
|
|
@@ -128,6 +148,7 @@ const tryClaimLock = (outDir: string, port?: number): boolean => {
|
|
|
128
148
|
writeFileSync(lockPath(outDir), lockPayload(port), { flag: "wx" });
|
|
129
149
|
return true;
|
|
130
150
|
} catch (error) {
|
|
151
|
+
// SAFETY: `writeFileSync` failures are errno exceptions.
|
|
131
152
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
132
153
|
throw error;
|
|
133
154
|
}
|
package/src/cli/eject-scripts.ts
CHANGED
|
@@ -5,6 +5,21 @@ import { join } from "pathe";
|
|
|
5
5
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
6
6
|
import { searchProviderMeta } from "../search/providers.ts";
|
|
7
7
|
|
|
8
|
+
/** A JSON value, as `JSON.parse` of a manifest can return. */
|
|
9
|
+
type JsonValue =
|
|
10
|
+
| string
|
|
11
|
+
| number
|
|
12
|
+
| boolean
|
|
13
|
+
| null
|
|
14
|
+
| JsonValue[]
|
|
15
|
+
| { [key: string]: JsonValue };
|
|
16
|
+
|
|
17
|
+
/** The slice of package.json the rewrite touches; the rest rides along. */
|
|
18
|
+
interface PackageManifest {
|
|
19
|
+
[key: string]: JsonValue | undefined;
|
|
20
|
+
scripts?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
/**
|
|
9
24
|
* The `blume build`-only artifacts this project's config actually produces, as
|
|
10
25
|
* notice lines for the eject command. After an eject the build script runs
|
|
@@ -55,13 +70,13 @@ export const droppedArtifactNotices = (config: ResolvedConfig): string[] => {
|
|
|
55
70
|
*/
|
|
56
71
|
export const updatePackageScripts = async (root: string): Promise<void> => {
|
|
57
72
|
const pkgPath = join(root, "package.json");
|
|
58
|
-
let pkg:
|
|
73
|
+
let pkg: PackageManifest;
|
|
59
74
|
try {
|
|
60
75
|
pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
61
76
|
} catch {
|
|
62
77
|
return;
|
|
63
78
|
}
|
|
64
|
-
const scripts =
|
|
79
|
+
const scripts = pkg.scripts ?? {};
|
|
65
80
|
pkg.scripts = {
|
|
66
81
|
...scripts,
|
|
67
82
|
build: "astro build",
|
package/src/cli/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { previewCommand } from "./commands/preview.ts";
|
|
|
15
15
|
import { syncCommand } from "./commands/sync.ts";
|
|
16
16
|
import { translateCommand } from "./commands/translate.ts";
|
|
17
17
|
import { validateCommand } from "./commands/validate.ts";
|
|
18
|
+
import { versionCommand } from "./commands/version.ts";
|
|
18
19
|
import { loadEnvFiles } from "./env.ts";
|
|
19
20
|
import { reportInternalError } from "./internal-error.ts";
|
|
20
21
|
|
|
@@ -39,6 +40,7 @@ const main = defineCommand({
|
|
|
39
40
|
sync: syncCommand,
|
|
40
41
|
translate: translateCommand,
|
|
41
42
|
validate: validateCommand,
|
|
43
|
+
version: versionCommand,
|
|
42
44
|
},
|
|
43
45
|
});
|
|
44
46
|
|
package/src/cli/init/scaffold.ts
CHANGED
|
@@ -54,7 +54,7 @@ interface Starter {
|
|
|
54
54
|
const page = (title: string, description: string, body: string): string =>
|
|
55
55
|
`---\ntitle: ${title}\ndescription: ${description}\n---\n\n${body}\n`;
|
|
56
56
|
|
|
57
|
-
export const STARTERS
|
|
57
|
+
export const STARTERS = {
|
|
58
58
|
api: {
|
|
59
59
|
configExtra: `
|
|
60
60
|
openapi: {
|
|
@@ -135,16 +135,14 @@ export const STARTERS: Record<Template, Starter> = {
|
|
|
135
135
|
},
|
|
136
136
|
],
|
|
137
137
|
},
|
|
138
|
-
}
|
|
138
|
+
} satisfies Record<Template, Starter>;
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
141
|
* Install + dev commands to print for the chosen package manager, plus the
|
|
142
142
|
* prefix that runs a locally installed bin (`exec`, e.g. `npx blume eject`) —
|
|
143
143
|
* dependency bins aren't on PATH, so a bare `blume …` hint would not run.
|
|
144
144
|
*/
|
|
145
|
-
export const commandsFor = (
|
|
146
|
-
pm: PackageManager
|
|
147
|
-
): { build: string; dev: string; exec: string; install: string } => ({
|
|
145
|
+
export const commandsFor = (pm: PackageManager) => ({
|
|
148
146
|
// `bun build` invokes Bun's bundler, not the package.json `build` script —
|
|
149
147
|
// unlike `bun dev`, the script name is shadowed by a builtin subcommand.
|
|
150
148
|
build: pm === "npm" || pm === "bun" ? `${pm} run build` : `${pm} build`,
|
|
@@ -153,6 +151,9 @@ export const commandsFor = (
|
|
|
153
151
|
install: `${pm} install`,
|
|
154
152
|
});
|
|
155
153
|
|
|
154
|
+
const isPackageManager = (value: string): value is PackageManager =>
|
|
155
|
+
PACKAGE_MANAGERS.some((pm) => pm === value);
|
|
156
|
+
|
|
156
157
|
/**
|
|
157
158
|
* Derive the package manager from an npm user-agent string (the first
|
|
158
159
|
* `name/version` token of `npm_config_user_agent`), falling back to npm.
|
|
@@ -160,8 +161,8 @@ export const commandsFor = (
|
|
|
160
161
|
* runner is the only signal.
|
|
161
162
|
*/
|
|
162
163
|
export const detectPackageManager = (userAgent?: string): PackageManager => {
|
|
163
|
-
const name = userAgent?.split("/")[0]
|
|
164
|
-
return name !== undefined &&
|
|
164
|
+
const name = userAgent?.split("/")[0];
|
|
165
|
+
return name !== undefined && isPackageManager(name) ? name : "npm";
|
|
165
166
|
};
|
|
166
167
|
|
|
167
168
|
/**
|
|
@@ -176,8 +177,8 @@ export const detectProjectPackageManager = async (
|
|
|
176
177
|
root: string
|
|
177
178
|
): Promise<PackageManager> => {
|
|
178
179
|
const detected = await detect({ cwd: root });
|
|
179
|
-
const name = detected?.name
|
|
180
|
-
return name !== undefined &&
|
|
180
|
+
const name = detected?.name;
|
|
181
|
+
return name !== undefined && isPackageManager(name)
|
|
181
182
|
? name
|
|
182
183
|
: detectPackageManager(process.env.npm_config_user_agent);
|
|
183
184
|
};
|
|
@@ -216,7 +217,7 @@ const hasRemoteSource = (sources: SourceKind[]): boolean =>
|
|
|
216
217
|
* Config snippets for each remote source kind, with placeholder values to
|
|
217
218
|
* replace and comments naming the env var each source authenticates with.
|
|
218
219
|
*/
|
|
219
|
-
const SOURCE_SNIPPETS
|
|
220
|
+
const SOURCE_SNIPPETS = {
|
|
220
221
|
"github-releases": ` // Changelog entries from GitHub Releases. Private repos read
|
|
221
222
|
// GITHUB_TOKEN from the environment.
|
|
222
223
|
{
|
|
@@ -247,7 +248,7 @@ const SOURCE_SNIPPETS: Record<Exclude<SourceKind, "filesystem">, string> = {
|
|
|
247
248
|
query: \`*[_type == "doc"]\`,
|
|
248
249
|
prefix: "sanity",
|
|
249
250
|
},`,
|
|
250
|
-
}
|
|
251
|
+
} satisfies Record<Exclude<SourceKind, "filesystem">, string>;
|
|
251
252
|
|
|
252
253
|
/**
|
|
253
254
|
* The `content` block for the generated config, or an empty string when the
|
|
@@ -293,10 +294,16 @@ export default defineConfig({
|
|
|
293
294
|
`;
|
|
294
295
|
|
|
295
296
|
/** SDK dependencies required by the selected remote sources. */
|
|
296
|
-
const extraDepsFor = (sources: SourceKind[])
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
297
|
+
const extraDepsFor = (sources: SourceKind[]) => {
|
|
298
|
+
const deps: Record<string, string> = {};
|
|
299
|
+
if (sources.includes("notion")) {
|
|
300
|
+
deps["@notionhq/client"] = "^2.2.15";
|
|
301
|
+
}
|
|
302
|
+
if (sources.includes("sanity")) {
|
|
303
|
+
deps["@sanity/client"] = "^7.25.0";
|
|
304
|
+
}
|
|
305
|
+
return deps;
|
|
306
|
+
};
|
|
300
307
|
|
|
301
308
|
/** Every file `init` should write for the given answers, package.json first. */
|
|
302
309
|
export const buildPlan = (
|
|
@@ -33,6 +33,7 @@ export const remapBlumeStack = (stack: string): string =>
|
|
|
33
33
|
* message, a trimmed stack, and an environment dump for bug reports. Callers
|
|
34
34
|
* exit after this — it doesn't exit itself, so it's testable.
|
|
35
35
|
*/
|
|
36
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- last-resort handler for whatever a catch clause caught; anything narrower would force casts at every call site
|
|
36
37
|
export const reportInternalError = (error: unknown): void => {
|
|
37
38
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
38
39
|
const lines = [
|
|
@@ -75,6 +75,9 @@ export const extractTypeTable = async (
|
|
|
75
75
|
): Promise<TypeTableProperty[]> => {
|
|
76
76
|
const { name, path, root = process.cwd(), source } = options;
|
|
77
77
|
const tsModule = await import("typescript");
|
|
78
|
+
// SAFETY: CJS/ESM interop — the typescript package exposes its API namespace
|
|
79
|
+
// either directly or under `default` depending on the loader; both are the
|
|
80
|
+
// same object, which TypeScriptApi models structurally.
|
|
78
81
|
const ts = (tsModule.default ?? tsModule) as TypeScriptApi;
|
|
79
82
|
|
|
80
83
|
const compilerOptions: CompilerOptions = {
|
|
@@ -62,8 +62,11 @@ const registeredDiffThemeNames = new Set<string>();
|
|
|
62
62
|
* shared between both modes must not hand light mode the dark-typed
|
|
63
63
|
* registration.
|
|
64
64
|
*/
|
|
65
|
+
const isThemeName = (theme: CodeTheme): theme is string =>
|
|
66
|
+
typeof theme === "string";
|
|
67
|
+
|
|
65
68
|
const diffThemeName = (theme: CodeTheme, mode: "dark" | "light"): string => {
|
|
66
|
-
if (
|
|
69
|
+
if (isThemeName(theme)) {
|
|
67
70
|
return theme;
|
|
68
71
|
}
|
|
69
72
|
const type = theme.type ?? mode;
|
|
@@ -90,7 +93,7 @@ const diffThemeName = (theme: CodeTheme, mode: "dark" | "light"): string => {
|
|
|
90
93
|
return name;
|
|
91
94
|
};
|
|
92
95
|
|
|
93
|
-
const diffThemes = (themes: CodeThemes)
|
|
96
|
+
const diffThemes = (themes: CodeThemes) => ({
|
|
94
97
|
dark: diffThemeName(themes.dark, "dark"),
|
|
95
98
|
light: diffThemeName(themes.light, "light"),
|
|
96
99
|
});
|
|
@@ -113,11 +116,12 @@ export const renderDiff = async (options: DiffOptions): Promise<string> => {
|
|
|
113
116
|
theme = DEFAULT_CODE_THEMES,
|
|
114
117
|
} = options;
|
|
115
118
|
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
const patchText =
|
|
120
|
+
patch ?? (src === undefined ? undefined : await readText(src, root));
|
|
121
|
+
if (patchText !== undefined) {
|
|
118
122
|
const result = await preloadPatchDiff({
|
|
119
123
|
options: { theme: diffThemes(theme) },
|
|
120
|
-
patch:
|
|
124
|
+
patch: patchText,
|
|
121
125
|
});
|
|
122
126
|
return result.prerenderedHTML;
|
|
123
127
|
}
|
|
@@ -41,6 +41,8 @@ const load = async (
|
|
|
41
41
|
return null;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// SAFETY: GitHub's `GET /repos/{owner}/{repo}` contract carries these three
|
|
45
|
+
// fields on every 2xx response; a malformed body rejects into `loadSafe`.
|
|
44
46
|
const data = (await response.json()) as {
|
|
45
47
|
description: string | null;
|
|
46
48
|
forks_count: number;
|
|
@@ -100,12 +100,21 @@ const Glyph = ({ path, size = 16 }: { path: string; size?: number }) => (
|
|
|
100
100
|
const EMPTY_SUGGESTIONS: Suggestion[] = [];
|
|
101
101
|
|
|
102
102
|
// The toggle shortcut accepts both ⌘I and Ctrl+I; show the right modifier per
|
|
103
|
-
// platform (same detection Search.astro uses for its ⌘K hint). Guarded
|
|
104
|
-
// island still server-renders
|
|
105
|
-
// only renders client-side, inside the portaled panel.
|
|
103
|
+
// platform (same detection Search.astro uses for its ⌘K hint). Guarded via
|
|
104
|
+
// `globalThis` so the island still server-renders where `navigator` doesn't
|
|
105
|
+
// exist; the hint itself only renders client-side, inside the portaled panel.
|
|
106
106
|
const IS_APPLE =
|
|
107
|
-
|
|
108
|
-
/mac|iphone|ipad|ipod/iu.test(navigator.platform);
|
|
107
|
+
globalThis.navigator !== undefined &&
|
|
108
|
+
/mac|iphone|ipad|ipod/iu.test(globalThis.navigator.platform);
|
|
109
|
+
|
|
110
|
+
// Duck-typed (`closest` presence) rather than `instanceof Element`, which
|
|
111
|
+
// needs a DOM global the test environment doesn't provide.
|
|
112
|
+
const isElementLike = (target: EventTarget | null): target is Element => {
|
|
113
|
+
// SAFETY: the cast only names the probed surface; `closest` is verified to
|
|
114
|
+
// exist before the caller uses it.
|
|
115
|
+
const candidate = target as Partial<Element> | null;
|
|
116
|
+
return typeof candidate?.closest === "function";
|
|
117
|
+
};
|
|
109
118
|
|
|
110
119
|
// Ghost icon button, matching the header's theme toggle and repo link.
|
|
111
120
|
const TRIGGER_CLASS =
|
|
@@ -163,6 +172,8 @@ const AskAI = ({
|
|
|
163
172
|
// The search modal forwards its query so "Ask AI: <query>" carries straight in.
|
|
164
173
|
useEffect(() => {
|
|
165
174
|
const handler = (event: Event) => {
|
|
175
|
+
// SAFETY: `blume:open-ask-ai` is only ever dispatched as a CustomEvent
|
|
176
|
+
// whose optional detail carries the search query.
|
|
166
177
|
const query = (event as CustomEvent<{ query?: string }>).detail?.query;
|
|
167
178
|
if (query) {
|
|
168
179
|
setInput(query);
|
|
@@ -190,11 +201,8 @@ const AskAI = ({
|
|
|
190
201
|
// An Escape aimed at a modal surface stacked on top (the search
|
|
191
202
|
// dialog traps focus inside itself) dismisses that surface only —
|
|
192
203
|
// this window listener still fires for it, and closing the panel
|
|
193
|
-
// underneath too would eat the user's conversation view.
|
|
194
|
-
|
|
195
|
-
// a DOM global the test environment doesn't provide.
|
|
196
|
-
const target = event.target as Partial<Element> | null;
|
|
197
|
-
if (typeof target?.closest === "function" && target.closest("dialog")) {
|
|
204
|
+
// underneath too would eat the user's conversation view.
|
|
205
|
+
if (isElementLike(event.target) && event.target.closest("dialog")) {
|
|
198
206
|
return;
|
|
199
207
|
}
|
|
200
208
|
setOpen(false);
|
|
@@ -346,12 +354,12 @@ const AskAI = ({
|
|
|
346
354
|
// The closed panel is only translated off-screen; `inert` drops its
|
|
347
355
|
// buttons/textarea from the tab order and the accessibility tree.
|
|
348
356
|
inert={!open}
|
|
349
|
-
className={`fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-
|
|
357
|
+
className={`border-border bg-background fixed inset-y-0 end-0 z-[60] flex w-[var(--blume-ask-width)] flex-col border-s shadow-2xl transition-transform duration-200 ease-out ${
|
|
350
358
|
open ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"
|
|
351
359
|
}`}
|
|
352
360
|
>
|
|
353
|
-
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-
|
|
354
|
-
<span className="font-semibold
|
|
361
|
+
<header className="border-border flex h-16 shrink-0 items-center justify-between gap-2 border-b px-4">
|
|
362
|
+
<span className="text-foreground font-semibold">{t.title}</span>
|
|
355
363
|
<div className="flex items-center gap-0.5">
|
|
356
364
|
<button
|
|
357
365
|
aria-label={t.copy}
|
|
@@ -383,7 +391,7 @@ const AskAI = ({
|
|
|
383
391
|
</header>
|
|
384
392
|
|
|
385
393
|
<div
|
|
386
|
-
className="flex flex-1
|
|
394
|
+
className="scrollbar-thumb-border flex flex-1 scrollbar-thin scrollbar-track-transparent flex-col overflow-y-auto"
|
|
387
395
|
ref={scrollRef}
|
|
388
396
|
>
|
|
389
397
|
{hasMessages ? (
|
|
@@ -393,7 +401,7 @@ const AskAI = ({
|
|
|
393
401
|
{messages.map((message, index) =>
|
|
394
402
|
message.role === "user" ? (
|
|
395
403
|
<div
|
|
396
|
-
className="max-w-[85%] self-end
|
|
404
|
+
className="rounded-blume bg-muted text-foreground max-w-[85%] self-end px-3 py-2 text-sm whitespace-pre-wrap"
|
|
397
405
|
// oxlint-disable-next-line react/no-array-index-key -- append-only list, see above
|
|
398
406
|
key={index}
|
|
399
407
|
>
|
|
@@ -412,7 +420,7 @@ const AskAI = ({
|
|
|
412
420
|
}}
|
|
413
421
|
/>
|
|
414
422
|
) : (
|
|
415
|
-
<span className="
|
|
423
|
+
<span className="text-muted-foreground animate-pulse">
|
|
416
424
|
…
|
|
417
425
|
</span>
|
|
418
426
|
)}
|
|
@@ -423,18 +431,18 @@ const AskAI = ({
|
|
|
423
431
|
) : (
|
|
424
432
|
<div className="mt-auto flex flex-col gap-0.5 p-4">
|
|
425
433
|
{suggestions.length === 0 && (
|
|
426
|
-
<p className="
|
|
434
|
+
<p className="text-muted-foreground px-2 text-sm">{t.empty}</p>
|
|
427
435
|
)}
|
|
428
436
|
{suggestions.map((suggestion) => (
|
|
429
437
|
<button
|
|
430
|
-
className="flex cursor-pointer items-center gap-2.5
|
|
438
|
+
className="rounded-blume text-foreground hover:bg-muted flex cursor-pointer items-center gap-2.5 px-2 py-2 text-start text-sm transition-colors"
|
|
431
439
|
key={suggestion.label}
|
|
432
440
|
onClick={() => runQuestion(suggestion.label)}
|
|
433
441
|
type="button"
|
|
434
442
|
>
|
|
435
443
|
{suggestion.icon && (
|
|
436
444
|
<span
|
|
437
|
-
className="
|
|
445
|
+
className="text-muted-foreground shrink-0 [&_svg]:h-[18px] [&_svg]:w-[18px]"
|
|
438
446
|
// oxlint-disable-next-line react/no-danger -- trusted server-resolved inline SVG glyph
|
|
439
447
|
dangerouslySetInnerHTML={{ __html: suggestion.icon }}
|
|
440
448
|
/>
|
|
@@ -442,12 +450,12 @@ const AskAI = ({
|
|
|
442
450
|
<span>{suggestion.label}</span>
|
|
443
451
|
</button>
|
|
444
452
|
))}
|
|
445
|
-
<p className="mt-3 flex items-center gap-1.5 px-2 text-
|
|
453
|
+
<p className="text-muted-foreground mt-3 flex items-center gap-1.5 px-2 text-sm">
|
|
446
454
|
{t.tip}
|
|
447
|
-
<kbd className="
|
|
455
|
+
<kbd className="border-border bg-muted rounded border px-1.5 py-0.5 font-sans text-xs">
|
|
448
456
|
{IS_APPLE ? "⌘" : "Ctrl"}
|
|
449
457
|
</kbd>
|
|
450
|
-
<kbd className="
|
|
458
|
+
<kbd className="border-border bg-muted rounded border px-1.5 py-0.5 font-sans text-xs">
|
|
451
459
|
I
|
|
452
460
|
</kbd>
|
|
453
461
|
</p>
|
|
@@ -456,12 +464,12 @@ const AskAI = ({
|
|
|
456
464
|
</div>
|
|
457
465
|
|
|
458
466
|
<form
|
|
459
|
-
className="relative shrink-0 border-
|
|
467
|
+
className="border-border relative shrink-0 border-t"
|
|
460
468
|
onSubmit={onSubmit}
|
|
461
469
|
>
|
|
462
470
|
<textarea
|
|
463
471
|
aria-label={t.label}
|
|
464
|
-
className="max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-
|
|
472
|
+
className="text-foreground placeholder:text-muted-foreground max-h-48 min-h-[5rem] w-full resize-none bg-transparent px-4 py-3.5 pe-14 text-sm outline-none pointer-coarse:text-base"
|
|
465
473
|
onChange={(event) => setInput(event.target.value)}
|
|
466
474
|
onKeyDown={onInputKeyDown}
|
|
467
475
|
placeholder={t.placeholder}
|
|
@@ -471,7 +479,7 @@ const AskAI = ({
|
|
|
471
479
|
/>
|
|
472
480
|
<button
|
|
473
481
|
aria-label={t.send}
|
|
474
|
-
className="absolute end-3 bottom-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center
|
|
482
|
+
className="rounded-blume bg-foreground text-background absolute end-3 bottom-3 inline-flex h-8 w-8 cursor-pointer items-center justify-center transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
|
|
475
483
|
disabled={busy || input.trim().length === 0}
|
|
476
484
|
type="submit"
|
|
477
485
|
>
|
|
@@ -29,7 +29,9 @@ const readClientData = (): BlumeClientData | null => {
|
|
|
29
29
|
if (cachedData) {
|
|
30
30
|
return cachedData;
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
// `document` is undeclared on the server; probing `globalThis` avoids both
|
|
33
|
+
// the bare-reference ReferenceError and a `typeof` sniff.
|
|
34
|
+
if (!("document" in globalThis)) {
|
|
33
35
|
return null;
|
|
34
36
|
}
|
|
35
37
|
const element = document.querySelector("#blume-client-data");
|
|
@@ -37,6 +39,8 @@ const readClientData = (): BlumeClientData | null => {
|
|
|
37
39
|
return null;
|
|
38
40
|
}
|
|
39
41
|
try {
|
|
42
|
+
// SAFETY: the layout serialized this script tag's JSON from the same
|
|
43
|
+
// `BlumeClientData` snapshot this reads back.
|
|
40
44
|
cachedData = JSON.parse(element.textContent) as BlumeClientData;
|
|
41
45
|
return cachedData;
|
|
42
46
|
} catch {
|
|
@@ -16,11 +16,36 @@ interface WebMcpResult {
|
|
|
16
16
|
isError?: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** A JSON value as a WebMCP agent may supply it in a tool call. */
|
|
20
|
+
export type WebMcpJsonValue =
|
|
21
|
+
| boolean
|
|
22
|
+
| number
|
|
23
|
+
| string
|
|
24
|
+
| null
|
|
25
|
+
| WebMcpJsonValue[]
|
|
26
|
+
| { [key: string]: WebMcpJsonValue };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tool arguments exactly as the calling agent supplied them. WebMCP doesn't
|
|
30
|
+
* guarantee schema validation, so each tool checks its own fields at runtime.
|
|
31
|
+
*/
|
|
32
|
+
export interface WebMcpToolArgs {
|
|
33
|
+
query?: WebMcpJsonValue;
|
|
34
|
+
route?: WebMcpJsonValue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The JSON Schema subset these string-argument tools declare. */
|
|
38
|
+
export interface WebMcpInputSchema {
|
|
39
|
+
properties: Record<string, { description: string; type: "string" }>;
|
|
40
|
+
required?: string[];
|
|
41
|
+
type: "object";
|
|
42
|
+
}
|
|
43
|
+
|
|
19
44
|
export interface WebMcpTool {
|
|
20
45
|
annotations: { openWorldHint: boolean; readOnlyHint: boolean };
|
|
21
46
|
description: string;
|
|
22
|
-
execute: (input:
|
|
23
|
-
inputSchema:
|
|
47
|
+
execute: (input: WebMcpToolArgs) => Promise<WebMcpResult>;
|
|
48
|
+
inputSchema: WebMcpInputSchema;
|
|
24
49
|
name: string;
|
|
25
50
|
}
|
|
26
51
|
|
|
@@ -31,14 +56,26 @@ export interface WebMcpTool {
|
|
|
31
56
|
* individually via `registerTool`.
|
|
32
57
|
*/
|
|
33
58
|
export interface ModelContext {
|
|
34
|
-
provideContext?: (context: { tools: WebMcpTool[] }) =>
|
|
35
|
-
registerTool?: (tool: WebMcpTool) =>
|
|
59
|
+
provideContext?: (context: { tools: WebMcpTool[] }) => void;
|
|
60
|
+
registerTool?: (tool: WebMcpTool) => void;
|
|
36
61
|
}
|
|
37
62
|
|
|
38
|
-
const text = (value: string, isError = false): WebMcpResult =>
|
|
39
|
-
content: [{ text: value, type: "text" }]
|
|
40
|
-
|
|
41
|
-
|
|
63
|
+
const text = (value: string, isError = false): WebMcpResult => {
|
|
64
|
+
const result: WebMcpResult = { content: [{ text: value, type: "text" }] };
|
|
65
|
+
if (isError) {
|
|
66
|
+
result.isError = true;
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Validates an agent-supplied tool argument before it is used as a string. */
|
|
72
|
+
const isString = (value: WebMcpJsonValue | undefined): value is string =>
|
|
73
|
+
typeof value === "string";
|
|
74
|
+
|
|
75
|
+
/** Detects which registration surface a model context actually implements. */
|
|
76
|
+
const isCallable = <T extends (...args: never[]) => void>(
|
|
77
|
+
value: T | undefined
|
|
78
|
+
): value is T => typeof value === "function";
|
|
42
79
|
|
|
43
80
|
const TAG = /<[^>]*>?/gu;
|
|
44
81
|
|
|
@@ -89,7 +126,7 @@ export const buildWebMcpTools = (options: WebMcpToolOptions): WebMcpTool[] => {
|
|
|
89
126
|
description:
|
|
90
127
|
"Full-text search across this documentation site. Returns matching pages with their title, URL, and a short excerpt.",
|
|
91
128
|
async execute(input) {
|
|
92
|
-
const query =
|
|
129
|
+
const query = isString(input.query) ? input.query : "";
|
|
93
130
|
if (!query.trim()) {
|
|
94
131
|
return text("Provide a non-empty `query` string.", true);
|
|
95
132
|
}
|
|
@@ -128,7 +165,7 @@ export const buildWebMcpTools = (options: WebMcpToolOptions): WebMcpTool[] => {
|
|
|
128
165
|
description:
|
|
129
166
|
"Fetch a page of this site as plain Markdown. Pass the page's root-relative route, e.g. `/quickstart`.",
|
|
130
167
|
async execute(input) {
|
|
131
|
-
const route =
|
|
168
|
+
const route = isString(input.route) ? input.route : "";
|
|
132
169
|
if (!route.startsWith("/")) {
|
|
133
170
|
return text("Pass a root-relative route, e.g. `/quickstart`.", true);
|
|
134
171
|
}
|
|
@@ -189,11 +226,11 @@ export const registerWebMcpTools = (
|
|
|
189
226
|
if (!context) {
|
|
190
227
|
return false;
|
|
191
228
|
}
|
|
192
|
-
if (
|
|
229
|
+
if (isCallable(context.provideContext)) {
|
|
193
230
|
context.provideContext({ tools });
|
|
194
231
|
return true;
|
|
195
232
|
}
|
|
196
|
-
if (
|
|
233
|
+
if (isCallable(context.registerTool)) {
|
|
197
234
|
for (const tool of tools) {
|
|
198
235
|
context.registerTool(tool);
|
|
199
236
|
}
|
|
@@ -5,7 +5,11 @@ import { withBase } from "../islands/base-path.ts";
|
|
|
5
5
|
import type { ComponentOverride } from "../../core/define-components.ts";
|
|
6
6
|
import { EN_UI } from "../../core/i18n-ui.ts";
|
|
7
7
|
import type { UIStrings } from "../../core/i18n-ui.ts";
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
LocaleSwitchOption,
|
|
10
|
+
Navigation,
|
|
11
|
+
NavSelector as NavSelectorConfig,
|
|
12
|
+
} from "../../core/types.ts";
|
|
9
13
|
import { GITHUB_MARK } from "../github-mark.ts";
|
|
10
14
|
import Icon from "../Icon.astro";
|
|
11
15
|
import LanguageSwitcher from "./LanguageSwitcher.astro";
|
|
@@ -50,8 +54,16 @@ interface Props {
|
|
|
50
54
|
/** Localized chrome labels (nav toggle, sections, GitHub, theme toggle). */
|
|
51
55
|
navStrings?: UIStrings["nav"];
|
|
52
56
|
localeSwitch?: LocaleSwitchOption[];
|
|
57
|
+
/**
|
|
58
|
+
* Auto-populated version switcher, rendered ahead of the configured
|
|
59
|
+
* selectors. `null`/absent when versioning is off — or when the user
|
|
60
|
+
* declares their own `kind: "version"` selector, which then owns the UI.
|
|
61
|
+
*/
|
|
62
|
+
versionSelector?: NavSelectorConfig | null;
|
|
53
63
|
/** Active locale for per-language search filtering. */
|
|
54
64
|
searchLocale?: string;
|
|
65
|
+
/** Viewed docs version for search filtering (`""` = current; `null`/absent = off). */
|
|
66
|
+
searchVersion?: string | null;
|
|
55
67
|
/**
|
|
56
68
|
* Layout-slot overrides forwarded from the root layout. The header honors
|
|
57
69
|
* `Logo` and `Search` here so those pieces can be replaced without swapping
|
|
@@ -77,7 +89,9 @@ const {
|
|
|
77
89
|
switcherStrings,
|
|
78
90
|
navStrings,
|
|
79
91
|
localeSwitch,
|
|
92
|
+
versionSelector,
|
|
80
93
|
searchLocale,
|
|
94
|
+
searchVersion = null,
|
|
81
95
|
layout = {},
|
|
82
96
|
} = Astro.props;
|
|
83
97
|
|
|
@@ -167,6 +181,15 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
167
181
|
)
|
|
168
182
|
}
|
|
169
183
|
<div class="flex-1"></div>
|
|
184
|
+
{
|
|
185
|
+
/* Right-aligned next to the language switcher: the spacer absorbs its
|
|
186
|
+
width, so pages without a version selector (blog, generated references)
|
|
187
|
+
keep the logo, tabs, and the rest of this cluster in place — no layout
|
|
188
|
+
shift when crossing into the docs. */
|
|
189
|
+
versionSelector && (
|
|
190
|
+
<NavSelector align="end" route={route} selector={versionSelector} />
|
|
191
|
+
)
|
|
192
|
+
}
|
|
170
193
|
{
|
|
171
194
|
localeSwitch && localeSwitch.length > 1 && (
|
|
172
195
|
<LanguageSwitcher
|
|
@@ -184,6 +207,7 @@ const clickScript = `(()=>{const dr=()=>{const h=document.querySelector("[data-b
|
|
|
184
207
|
navigation={navigation}
|
|
185
208
|
popularPages={data.config.search.popular}
|
|
186
209
|
strings={searchStrings}
|
|
210
|
+
version={searchVersion}
|
|
187
211
|
/>
|
|
188
212
|
)
|
|
189
213
|
}
|