blume 1.2.0 → 1.3.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 +45 -0
- package/dist/cli/index.js +1715 -539
- package/dist/cli/index.js.map +40 -31
- package/dist/types/core/config-input.d.ts +131 -11
- package/dist/types/core/config.d.ts +9 -1
- package/dist/types/core/data.d.ts +24 -5
- package/dist/types/core/i18n-ui.d.ts +58 -799
- package/dist/types/core/schema.d.ts +534 -3305
- package/dist/types/theme/fonts.d.ts +55 -11
- package/docs/02-deployment.mdx +2 -0
- package/docs/07-faq.mdx +14 -14
- package/docs/advanced/skills.mdx +2 -2
- package/docs/configuration/ai.mdx +126 -2
- package/docs/configuration/index.mdx +19 -1
- package/docs/configuration/search.mdx +17 -0
- package/docs/configuration/seo.mdx +26 -3
- package/docs/configuration/theming.mdx +44 -2
- package/docs/content/syntax.mdx +18 -2
- package/docs/reference/cli.mdx +3 -3
- package/package.json +9 -8
- package/skills/blume/SKILL.md +6 -4
- package/skills/blume-migrate/SKILL.md +5 -3
- package/skills/blume-migrate/references/mintlify.md +5 -5
- package/skills/blume-migrate/references/monorepo.md +2 -1
- package/src/ai/agent-readability.ts +31 -1
- package/src/ai/api-catalog.ts +81 -0
- package/src/ai/ask-context.ts +7 -1
- package/src/ai/ask-data.ts +1 -0
- package/src/ai/link-headers.ts +52 -0
- package/src/ai/llms.ts +12 -1
- package/src/ai/markdown.ts +15 -2
- package/src/ai/mcp/data.ts +7 -0
- package/src/ai/mcp/discovery.ts +70 -15
- package/src/ai/mcp/server.ts +14 -8
- package/src/ai/mcp/stdio.ts +4 -1
- package/src/ai/skills.ts +193 -0
- package/src/ai/tar.ts +104 -0
- package/src/ai/web-bot-auth.ts +30 -0
- package/src/astro/generate.ts +116 -6
- package/src/astro/integration.ts +52 -14
- package/src/astro/templates.ts +191 -37
- package/src/audit/catalog.ts +20 -0
- package/src/audit/checks/dns-aid.ts +190 -0
- package/src/audit/report.ts +5 -0
- package/src/audit/run.ts +2 -0
- package/src/cli/commands/build.ts +178 -9
- package/src/cli/init/scaffold.ts +1 -1
- package/src/components/islands/ask-ai.tsx +4 -1
- package/src/components/islands/webmcp.ts +203 -0
- package/src/components/layout/NavTree.astro +4 -4
- package/src/components/layout/PageLayout.astro +2 -0
- package/src/components/layout/ReferenceLayout.astro +2 -0
- package/src/components/layout/RootLayout.astro +63 -11
- package/src/components/layout/Search.astro +2 -2
- package/src/components/layout/WebMcp.astro +49 -0
- package/src/components/layout/search/orama.ts +5 -2
- package/src/core/config-input.ts +143 -11
- package/src/core/config.ts +17 -1
- package/src/core/content-assets.ts +199 -0
- package/src/core/data.ts +21 -5
- package/src/core/diagnostics.ts +6 -5
- package/src/core/i18n-ui.ts +19 -28
- package/src/core/project-graph.ts +6 -0
- package/src/core/schema.ts +224 -71
- package/src/core/sources/normalize.ts +5 -5
- package/src/deploy/headers.ts +45 -3
- package/src/deploy/vercel-negotiation.ts +233 -0
- package/src/markdown/mermaid.ts +7 -1
- package/src/markdown/table-wrap.ts +33 -1
- package/src/og/card.ts +91 -22
- package/src/og/derive.ts +200 -0
- package/src/og/index.ts +6 -1
- package/src/search/orama-index.ts +151 -7
- package/src/theme/entry.ts +34 -13
- package/src/theme/fonts.ts +183 -30
- package/dist/types/og/card.d.ts +0 -63
- package/dist/types/og/dimensions.d.ts +0 -12
package/src/astro/templates.ts
CHANGED
|
@@ -5,12 +5,14 @@ import { dirname, isAbsolute, join, relative } from "pathe";
|
|
|
5
5
|
|
|
6
6
|
import { askBackendRuntimeDep } from "../ai/ask.ts";
|
|
7
7
|
import type { AskBackend } from "../ai/ask.ts";
|
|
8
|
+
import { buildHomeLinkHeader } from "../ai/link-headers.ts";
|
|
8
9
|
import { normalizeBasePath } from "../core/base-path.ts";
|
|
9
10
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
10
11
|
import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
|
|
11
12
|
import { trimChar } from "../core/trim.ts";
|
|
12
13
|
import type { ProjectContext } from "../core/types.ts";
|
|
13
14
|
import { applyBaseToAstroRedirects } from "../deploy/redirects.ts";
|
|
15
|
+
import type { OgFont, OgFontFamilies } from "../og/card.ts";
|
|
14
16
|
import { hasScalarReferences } from "../openapi/references.ts";
|
|
15
17
|
import { searchProviderMeta } from "../search/providers.ts";
|
|
16
18
|
import { buildFontEntries } from "../theme/fonts.ts";
|
|
@@ -358,6 +360,16 @@ const renderIntegrationBridge = (
|
|
|
358
360
|
};
|
|
359
361
|
};
|
|
360
362
|
|
|
363
|
+
/**
|
|
364
|
+
* The generated config's `image` block. Remote images are only optimized when
|
|
365
|
+
* their host is authorized; local (relative-path) images need no
|
|
366
|
+
* configuration. Emitted only when set, so the generated config stays minimal.
|
|
367
|
+
*/
|
|
368
|
+
const renderImageOption = (config: ResolvedConfig): string =>
|
|
369
|
+
config.image.domains.length > 0 || config.image.remotePatterns.length > 0
|
|
370
|
+
? `\n image: ${JSON.stringify(config.image)},`
|
|
371
|
+
: "";
|
|
372
|
+
|
|
361
373
|
export const astroConfigTemplate = (options: {
|
|
362
374
|
context: ProjectContext;
|
|
363
375
|
config: ResolvedConfig;
|
|
@@ -445,6 +457,7 @@ export const astroConfigTemplate = (options: {
|
|
|
445
457
|
const baseOption = deployment.base
|
|
446
458
|
? `\n base: ${JSON.stringify(deployment.base)},`
|
|
447
459
|
: "";
|
|
460
|
+
const imageOption = renderImageOption(config);
|
|
448
461
|
|
|
449
462
|
// Astro's native i18n gives locale-aware helpers + `<html lang>` correctness.
|
|
450
463
|
// Blume owns getStaticPaths and materializes fallback routes in the manifest,
|
|
@@ -479,20 +492,43 @@ export const astroConfigTemplate = (options: {
|
|
|
479
492
|
)},`
|
|
480
493
|
: "";
|
|
481
494
|
|
|
482
|
-
// Self-hosted
|
|
495
|
+
// Self-hosted fonts via Astro's Fonts API, derived from theme.fonts.
|
|
483
496
|
// `fontProviders` is only imported when at least one font is configured.
|
|
497
|
+
// Local variant sources are emitted as absolute paths (the Astro root is
|
|
498
|
+
// `.blume/`, not the user's project, so root-relative paths would miss).
|
|
484
499
|
const fontEntries = buildFontEntries(config.theme.fonts);
|
|
485
500
|
const fontsOption = fontEntries.length
|
|
486
501
|
? `\n fonts: [${fontEntries
|
|
487
|
-
.map(
|
|
488
|
-
|
|
489
|
-
`{ provider: fontProviders.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
502
|
+
.map((font) =>
|
|
503
|
+
font.kind === "local"
|
|
504
|
+
? `{ provider: fontProviders.local(), name: ${JSON.stringify(
|
|
505
|
+
font.name
|
|
506
|
+
)}, cssVariable: ${JSON.stringify(
|
|
507
|
+
font.cssVariable
|
|
508
|
+
)}, fallbacks: ${JSON.stringify(
|
|
509
|
+
font.fallbacks
|
|
510
|
+
)}, options: { variants: ${JSON.stringify(
|
|
511
|
+
font.variants.map((variant) => ({
|
|
512
|
+
...(variant.weight === undefined
|
|
513
|
+
? {}
|
|
514
|
+
: { weight: variant.weight }),
|
|
515
|
+
...(variant.style === undefined
|
|
516
|
+
? {}
|
|
517
|
+
: { style: variant.style }),
|
|
518
|
+
src: [
|
|
519
|
+
isAbsolute(variant.src)
|
|
520
|
+
? variant.src
|
|
521
|
+
: join(context.root, variant.src),
|
|
522
|
+
],
|
|
523
|
+
}))
|
|
524
|
+
)} } }`
|
|
525
|
+
: `{ provider: fontProviders.${font.provider}(), name: ${JSON.stringify(
|
|
526
|
+
font.name
|
|
527
|
+
)}, cssVariable: ${JSON.stringify(
|
|
528
|
+
font.cssVariable
|
|
529
|
+
)}, weights: ${JSON.stringify(
|
|
530
|
+
font.weights
|
|
531
|
+
)}, fallbacks: ${JSON.stringify(font.fallbacks)} }`
|
|
496
532
|
)
|
|
497
533
|
.join(", ")}],`
|
|
498
534
|
: "";
|
|
@@ -550,9 +586,15 @@ export const astroConfigTemplate = (options: {
|
|
|
550
586
|
integrations.push("svelte()");
|
|
551
587
|
}
|
|
552
588
|
// Always mounted: injects user pages (a no-op when there are none) and wires
|
|
553
|
-
// up dev-server `Accept: text/markdown` negotiation over the content routes
|
|
589
|
+
// up dev-server `Accept: text/markdown` negotiation over the content routes,
|
|
590
|
+
// plus the homepage agent-discovery `Link` header.
|
|
554
591
|
integrations.push(
|
|
555
|
-
`blumeIntegration(${JSON.stringify({
|
|
592
|
+
`blumeIntegration(${JSON.stringify({
|
|
593
|
+
base: deployment.base,
|
|
594
|
+
contentRoutes,
|
|
595
|
+
homeLinkHeader: buildHomeLinkHeader(config, contentRoutes) ?? undefined,
|
|
596
|
+
pages,
|
|
597
|
+
})})`
|
|
556
598
|
);
|
|
557
599
|
|
|
558
600
|
const watchOption = devWatchOption(
|
|
@@ -577,7 +619,7 @@ ${userConfigSetup}export default defineConfig({
|
|
|
577
619
|
srcDir: ${JSON.stringify(`${context.outDir}/src`)},
|
|
578
620
|
outDir: ${JSON.stringify(astroOutDir(context))},
|
|
579
621
|
publicDir: ${JSON.stringify(`${context.root}/public`)},
|
|
580
|
-
output: ${JSON.stringify(deployment.output)},${adapterOption}${sessionOption}${siteOption}${baseOption}${redirectsOption}${i18nOption}${fontsOption}
|
|
622
|
+
output: ${JSON.stringify(deployment.output)},${adapterOption}${sessionOption}${siteOption}${baseOption}${imageOption}${redirectsOption}${i18nOption}${fontsOption}
|
|
581
623
|
integrations: [${integrations.join(", ")}${userIntegrationSpread}],
|
|
582
624
|
markdown: {
|
|
583
625
|
processor: blumeMarkdownProcessor(${JSON.stringify({
|
|
@@ -875,18 +917,18 @@ export const askEndpointTemplate = (
|
|
|
875
917
|
console.error("Ask AI provider error:", error);
|
|
876
918
|
},`;
|
|
877
919
|
const stream = grounded
|
|
878
|
-
? ` const
|
|
920
|
+
? ` const instructions =
|
|
879
921
|
(await ground(messages, body.page)) ??
|
|
880
922
|
"You are a helpful documentation assistant. Answer using the project's documentation.";
|
|
881
923
|
const result = streamText({
|
|
882
924
|
model: ${modelExpr},
|
|
883
|
-
|
|
925
|
+
instructions,
|
|
884
926
|
messages,
|
|
885
927
|
${onError}
|
|
886
928
|
});`
|
|
887
929
|
: ` const result = streamText({
|
|
888
930
|
model: ${modelExpr},
|
|
889
|
-
|
|
931
|
+
instructions:
|
|
890
932
|
"You are a helpful documentation assistant. Answer using the project's documentation.",
|
|
891
933
|
messages,
|
|
892
934
|
${onError}
|
|
@@ -976,12 +1018,18 @@ const searchClientImport = (module: string): string =>
|
|
|
976
1018
|
const SEARCH_BASE_IMPORT =
|
|
977
1019
|
'import { joinBase } from "blume/components/islands/base-path.ts";\n';
|
|
978
1020
|
|
|
979
|
-
/**
|
|
980
|
-
|
|
1021
|
+
/**
|
|
1022
|
+
* A client that loads a static `blume-search.json` index (Orama, FlexSearch).
|
|
1023
|
+
* `locale` (Orama only) is the site's `i18n.defaultLocale`, which selects a
|
|
1024
|
+
* word-segmenting tokenizer for languages written without spaces.
|
|
1025
|
+
*/
|
|
1026
|
+
const staticSearchClient = (module: string, locale?: string): string =>
|
|
981
1027
|
`${SEARCH_CLIENT_HEADER}${searchClientImport(module)}${SEARCH_BASE_IMPORT}
|
|
982
1028
|
const indexUrl = joinBase(import.meta.env.BASE_URL, "blume-search.json");
|
|
983
1029
|
|
|
984
|
-
export const createSearch = () => create({ indexUrl
|
|
1030
|
+
export const createSearch = () => create({ indexUrl${
|
|
1031
|
+
locale ? `, locale: ${JSON.stringify(locale)}` : ""
|
|
1032
|
+
} });
|
|
985
1033
|
`;
|
|
986
1034
|
|
|
987
1035
|
/** A client that passes public credentials straight to the provider SDK. */
|
|
@@ -1030,7 +1078,12 @@ export const searchClientTemplate = (config: ResolvedConfig): string => {
|
|
|
1030
1078
|
const { search } = config;
|
|
1031
1079
|
|
|
1032
1080
|
if (search.provider === "orama" || search.provider === "flexsearch") {
|
|
1033
|
-
|
|
1081
|
+
// Only Orama derives a tokenizer from the locale; FlexSearch has no
|
|
1082
|
+
// equivalent hook, so its client keeps the bare index URL.
|
|
1083
|
+
return staticSearchClient(
|
|
1084
|
+
search.provider,
|
|
1085
|
+
search.provider === "orama" ? config.i18n?.defaultLocale : undefined
|
|
1086
|
+
);
|
|
1034
1087
|
}
|
|
1035
1088
|
|
|
1036
1089
|
const hosted = hostedSearchOptions(search);
|
|
@@ -1136,6 +1189,105 @@ export function GET({ props }: { props: { route: string } }) {
|
|
|
1136
1189
|
}
|
|
1137
1190
|
`;
|
|
1138
1191
|
|
|
1192
|
+
/**
|
|
1193
|
+
* Generate the `/blume-assets/[...asset]` endpoint. It serves two families of
|
|
1194
|
+
* files that exist on disk but outside the public dir:
|
|
1195
|
+
* - `content/<project-relative path>` — colocated content images
|
|
1196
|
+
* (``), served as originals for the agent-facing
|
|
1197
|
+
* Markdown endpoints (the HTML render uses the `astro:assets`-optimized
|
|
1198
|
+
* copies instead). The mapping comes from `generated/content-assets.json`.
|
|
1199
|
+
* - `<source>/<hash>.<ext>` — remote-source images the scan pipeline
|
|
1200
|
+
* materializes under `.blume/public/blume-assets` (see
|
|
1201
|
+
* `core/sources/assets.ts`). That directory is NOT Astro's `publicDir`
|
|
1202
|
+
* (which points at the user project's `public/`), so without this endpoint
|
|
1203
|
+
* those references 404.
|
|
1204
|
+
* Prerendered: every asset becomes a static file in the build output; the dev
|
|
1205
|
+
* server renders on demand, so new images appear without a restart.
|
|
1206
|
+
*/
|
|
1207
|
+
export const contentAssetsEndpointTemplate = (
|
|
1208
|
+
stagedAssetsDir: string
|
|
1209
|
+
): string =>
|
|
1210
|
+
`// Generated by Blume. Do not edit.
|
|
1211
|
+
import { existsSync } from "node:fs";
|
|
1212
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
1213
|
+
import { join, relative, resolve } from "node:path";
|
|
1214
|
+
import type { APIRoute } from "astro";
|
|
1215
|
+
import assets from "../../generated/content-assets.json";
|
|
1216
|
+
|
|
1217
|
+
export const prerender = true;
|
|
1218
|
+
|
|
1219
|
+
const files = assets as Record<string, string>;
|
|
1220
|
+
const STAGED_DIR = ${JSON.stringify(stagedAssetsDir)};
|
|
1221
|
+
|
|
1222
|
+
const TYPES: Record<string, string> = {
|
|
1223
|
+
".apng": "image/apng",
|
|
1224
|
+
".avif": "image/avif",
|
|
1225
|
+
".bmp": "image/bmp",
|
|
1226
|
+
".gif": "image/gif",
|
|
1227
|
+
".ico": "image/x-icon",
|
|
1228
|
+
".jpeg": "image/jpeg",
|
|
1229
|
+
".jpg": "image/jpeg",
|
|
1230
|
+
".png": "image/png",
|
|
1231
|
+
".svg": "image/svg+xml",
|
|
1232
|
+
".tiff": "image/tiff",
|
|
1233
|
+
".webp": "image/webp",
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
const contentType = (path: string): string =>
|
|
1237
|
+
TYPES[path.slice(path.lastIndexOf(".")).toLowerCase()] ??
|
|
1238
|
+
"application/octet-stream";
|
|
1239
|
+
|
|
1240
|
+
const stagedParams = async (): Promise<string[]> => {
|
|
1241
|
+
if (!existsSync(STAGED_DIR)) {
|
|
1242
|
+
return [];
|
|
1243
|
+
}
|
|
1244
|
+
const entries = await readdir(STAGED_DIR, {
|
|
1245
|
+
recursive: true,
|
|
1246
|
+
withFileTypes: true,
|
|
1247
|
+
});
|
|
1248
|
+
return entries
|
|
1249
|
+
.filter((entry) => entry.isFile())
|
|
1250
|
+
.map((entry) =>
|
|
1251
|
+
relative(STAGED_DIR, join(entry.parentPath, entry.name)).replaceAll(
|
|
1252
|
+
"\\\\",
|
|
1253
|
+
"/"
|
|
1254
|
+
)
|
|
1255
|
+
);
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
export async function getStaticPaths() {
|
|
1259
|
+
const params = [
|
|
1260
|
+
...Object.keys(files).map((key) => "content/" + key),
|
|
1261
|
+
...(await stagedParams()),
|
|
1262
|
+
];
|
|
1263
|
+
return params.map((asset) => ({ params: { asset } }));
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const resolveAsset = (asset: string): string | null => {
|
|
1267
|
+
if (asset.startsWith("content/")) {
|
|
1268
|
+
return files[asset.slice("content/".length)] ?? null;
|
|
1269
|
+
}
|
|
1270
|
+
const abs = resolve(STAGED_DIR, asset);
|
|
1271
|
+
// Traversal guard: the dev server renders on demand, so the param is
|
|
1272
|
+
// attacker-controlled there — never step outside the staged directory.
|
|
1273
|
+
if (!abs.startsWith(STAGED_DIR + "/")) {
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
return existsSync(abs) ? abs : null;
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
export const GET: APIRoute = async ({ params }) => {
|
|
1280
|
+
const path = resolveAsset(String(params.asset ?? ""));
|
|
1281
|
+
if (!path) {
|
|
1282
|
+
return new Response(null, { status: 404 });
|
|
1283
|
+
}
|
|
1284
|
+
const body = await readFile(path);
|
|
1285
|
+
return new Response(new Uint8Array(body), {
|
|
1286
|
+
headers: { "Content-Type": contentType(path) },
|
|
1287
|
+
});
|
|
1288
|
+
};
|
|
1289
|
+
`;
|
|
1290
|
+
|
|
1139
1291
|
/** The `src/pages` file that serves a route, e.g. `/mcp` -> `mcp.ts`. */
|
|
1140
1292
|
export const mcpPageFile = (route: string): string =>
|
|
1141
1293
|
`${trimChar(route, "/")}.ts`;
|
|
@@ -1201,12 +1353,14 @@ export function GET({ props }: { props: { section: string } }) {
|
|
|
1201
1353
|
}
|
|
1202
1354
|
`;
|
|
1203
1355
|
|
|
1204
|
-
/** Generate the OG image endpoint (`.blume/src/pages/
|
|
1356
|
+
/** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
|
|
1205
1357
|
export const ogEndpointTemplate = (
|
|
1206
|
-
customRoutes: OgCustomRoute[] = []
|
|
1358
|
+
customRoutes: OgCustomRoute[] = [],
|
|
1359
|
+
og: { families?: OgFontFamilies; fonts?: OgFont[] } = {}
|
|
1207
1360
|
): string =>
|
|
1208
1361
|
`// Generated by Blume. Do not edit.
|
|
1209
1362
|
import { renderOgImage } from "blume/og";
|
|
1363
|
+
import type { OgFont, OgFontFamilies } from "blume/og";
|
|
1210
1364
|
import data from "blume:data";
|
|
1211
1365
|
|
|
1212
1366
|
export const prerender = true;
|
|
@@ -1216,6 +1370,15 @@ export const prerender = true;
|
|
|
1216
1370
|
// (ts(7034)) under a strict tsconfig.
|
|
1217
1371
|
const customRoutes: { slug: string; title: string }[] = ${JSON.stringify(customRoutes)};
|
|
1218
1372
|
|
|
1373
|
+
// Card fonts, resolved at generation time (explicit seo.og.fonts, or derived
|
|
1374
|
+
// from theme.fonts). Local entries carry absolute build-machine paths, which
|
|
1375
|
+
// is why they are baked into this build-only endpoint instead of the runtime
|
|
1376
|
+
// data that pages serialize into HTML.
|
|
1377
|
+
const fonts: OgFont[] = ${JSON.stringify(og.fonts ?? [])};
|
|
1378
|
+
const families: OgFontFamilies | undefined = ${
|
|
1379
|
+
og.families ? JSON.stringify(og.families) : "undefined"
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1219
1382
|
export function getStaticPaths() {
|
|
1220
1383
|
const seen = new Set<string>();
|
|
1221
1384
|
const paths: { params: { slug: string }; props: { title: string } }[] = [];
|
|
@@ -1236,32 +1399,23 @@ export function getStaticPaths() {
|
|
|
1236
1399
|
return paths;
|
|
1237
1400
|
}
|
|
1238
1401
|
|
|
1239
|
-
// Footer branding shared by every card
|
|
1240
|
-
//
|
|
1402
|
+
// Footer branding shared by every card. The repo slug reuses the header link
|
|
1403
|
+
// URL; the site text (host plus deployment base) is resolved at generate time.
|
|
1241
1404
|
const repoSlug = data.config.repoUrl
|
|
1242
1405
|
? data.config.repoUrl.split("github.com/")[1]
|
|
1243
1406
|
: undefined;
|
|
1244
|
-
const siteHost = (() => {
|
|
1245
|
-
if (!data.config.site) {
|
|
1246
|
-
return undefined;
|
|
1247
|
-
}
|
|
1248
|
-
try {
|
|
1249
|
-
return new URL(data.config.site).host;
|
|
1250
|
-
} catch {
|
|
1251
|
-
return undefined;
|
|
1252
|
-
}
|
|
1253
|
-
})();
|
|
1254
1407
|
|
|
1255
1408
|
export async function GET({ props }: { props: { title: string } }) {
|
|
1256
1409
|
const png = await renderOgImage({
|
|
1257
1410
|
accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
|
|
1258
1411
|
brand: data.config.title,
|
|
1259
|
-
description: data.config.description,
|
|
1260
|
-
|
|
1412
|
+
description: data.config.og.description,
|
|
1413
|
+
families,
|
|
1414
|
+
fonts,
|
|
1261
1415
|
logo: data.config.og.logo,
|
|
1262
1416
|
palette: data.config.og.palette,
|
|
1263
1417
|
repo: repoSlug,
|
|
1264
|
-
site:
|
|
1418
|
+
site: data.config.og.site,
|
|
1265
1419
|
title: props.title,
|
|
1266
1420
|
});
|
|
1267
1421
|
return new Response(new Uint8Array(png), {
|
package/src/audit/catalog.ts
CHANGED
|
@@ -690,6 +690,26 @@ export const CHECKS = [
|
|
|
690
690
|
tier: "static",
|
|
691
691
|
title: "Indexable page missing from llms.txt",
|
|
692
692
|
},
|
|
693
|
+
// DNS-AID (draft-mozleywilliams-dnsop-dnsaid) lives in the DNS zone, not the
|
|
694
|
+
// build, so unlike the llms.txt checks these can only observe and advise —
|
|
695
|
+
// hence `info`: publishing the records is a DNS-provider action Blume can't
|
|
696
|
+
// take for you, and the draft is young enough that absence is the norm.
|
|
697
|
+
{
|
|
698
|
+
category: "ai",
|
|
699
|
+
fix: "Publish a ServiceMode SVCB or HTTPS record at `_index._agents.<host>` with your DNS provider so agents can discover the site via DNS (DNS-AID).",
|
|
700
|
+
id: "BLUME_AUDIT_DNS_AID_MISSING",
|
|
701
|
+
severity: "info",
|
|
702
|
+
tier: "network",
|
|
703
|
+
title: "No DNS-AID agent-discovery records",
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
category: "ai",
|
|
707
|
+
fix: "Enable DNSSEC for the zone so validating resolvers return authenticated DNS-AID answers; if your DNS provider doesn't support DNSSEC, the records still work unsigned.",
|
|
708
|
+
id: "BLUME_AUDIT_DNS_AID_UNSIGNED",
|
|
709
|
+
severity: "info",
|
|
710
|
+
tier: "network",
|
|
711
|
+
title: "DNS-AID records are not DNSSEC-authenticated",
|
|
712
|
+
},
|
|
693
713
|
|
|
694
714
|
// Structured data. Note we validate only what Blume itself emits — we do not
|
|
695
715
|
// claim Google-rich-results or full schema.org validation (the former is an
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { Diagnostic } from "../../core/types.ts";
|
|
2
|
+
import { finding } from "../catalog.ts";
|
|
3
|
+
import type { CheckModule } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DNS-AID (draft-mozleywilliams-dnsop-dnsaid): agents discover a site's AI
|
|
7
|
+
* surface by querying ServiceMode SVCB/HTTPS records under the `_agents` DNS
|
|
8
|
+
* namespace — the well-known entrypoint is `_index._agents.<host>`. The
|
|
9
|
+
* records live in the DNS zone, which no build artifact can publish, so this
|
|
10
|
+
* check probes the live DNS over DoH and reports what to add. It queries the
|
|
11
|
+
* `deployment.site` host (the domain agents actually probe), not the `--url`
|
|
12
|
+
* origin, which is often a localhost preview.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** SVCB and its HTTPS specialization (RFC 9460). */
|
|
16
|
+
const RR_SVCB = 64;
|
|
17
|
+
const RR_HTTPS = 65;
|
|
18
|
+
|
|
19
|
+
const DOH_TIMEOUT_MS = 5000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* DoH JSON resolvers, tried in order until one answers. Both accept the same
|
|
23
|
+
* `?name=&type=` query and the `application/dns-json` accept header (Google
|
|
24
|
+
* ignores it, Cloudflare requires it). `BLUME_DOH_URL` overrides the list for
|
|
25
|
+
* networks that block the public resolvers (and for tests, which must never
|
|
26
|
+
* touch the real DNS).
|
|
27
|
+
*/
|
|
28
|
+
const DOH_RESOLVERS = [
|
|
29
|
+
"https://dns.google/resolve",
|
|
30
|
+
"https://cloudflare-dns.com/dns-query",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const resolvers = (): string[] => {
|
|
34
|
+
const override = process.env.BLUME_DOH_URL;
|
|
35
|
+
return override ? [override] : DOH_RESOLVERS;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The subset of the DoH JSON response this check reads (RFC 8427-shaped). */
|
|
39
|
+
interface DohResponse {
|
|
40
|
+
/** DNSSEC-validated by the resolver (the AD flag). */
|
|
41
|
+
AD?: boolean;
|
|
42
|
+
Answer?: { data?: string; type?: number }[];
|
|
43
|
+
Status?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DnsAidResult {
|
|
47
|
+
/** Whether every record set found came back DNSSEC-authenticated. */
|
|
48
|
+
authenticated: boolean;
|
|
49
|
+
/** Whether any SVCB/HTTPS record exists at the well-known entrypoint. */
|
|
50
|
+
found: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One resolver's answer, or null when it's unreachable or not DoH-JSON. */
|
|
54
|
+
const fetchDohJson = async (
|
|
55
|
+
base: string,
|
|
56
|
+
name: string,
|
|
57
|
+
type: number,
|
|
58
|
+
fetchFn: typeof fetch
|
|
59
|
+
): Promise<DohResponse | null> => {
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetchFn(
|
|
62
|
+
`${base}?name=${encodeURIComponent(name)}&type=${type}`,
|
|
63
|
+
{
|
|
64
|
+
headers: { accept: "application/dns-json" },
|
|
65
|
+
signal: AbortSignal.timeout(DOH_TIMEOUT_MS),
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return (await response.json()) as DohResponse;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const queryDoh = async (
|
|
78
|
+
name: string,
|
|
79
|
+
type: number,
|
|
80
|
+
fetchFn: typeof fetch
|
|
81
|
+
): Promise<DohResponse | null> => {
|
|
82
|
+
for (const base of resolvers()) {
|
|
83
|
+
// oxlint-disable-next-line no-await-in-loop -- fallback chain: the next resolver is only tried when this one fails
|
|
84
|
+
const response = await fetchDohJson(base, name, type, fetchFn);
|
|
85
|
+
if (response) {
|
|
86
|
+
return response;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Query the DNS-AID entrypoint for a host over DoH: both SVCB and HTTPS
|
|
94
|
+
* rrtypes at `_index._agents.<host>` (the checker-facing convention accepts
|
|
95
|
+
* either). Returns null when no resolver answered at all — "couldn't check"
|
|
96
|
+
* must stay distinct from "checked and absent", or a flaky network would
|
|
97
|
+
* report every site as undiscoverable.
|
|
98
|
+
*/
|
|
99
|
+
export const lookupDnsAid = async (
|
|
100
|
+
host: string,
|
|
101
|
+
fetchFn: typeof fetch = fetch
|
|
102
|
+
): Promise<DnsAidResult | null> => {
|
|
103
|
+
const name = `_index._agents.${host}`;
|
|
104
|
+
let answered = false;
|
|
105
|
+
let found = false;
|
|
106
|
+
let authenticated = true;
|
|
107
|
+
const responses = await Promise.all(
|
|
108
|
+
[RR_HTTPS, RR_SVCB].map((type) => queryDoh(name, type, fetchFn))
|
|
109
|
+
);
|
|
110
|
+
for (const response of responses) {
|
|
111
|
+
if (!response) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
answered = true;
|
|
115
|
+
const records = (response.Answer ?? []).filter(
|
|
116
|
+
(answer) => answer.type === RR_SVCB || answer.type === RR_HTTPS
|
|
117
|
+
);
|
|
118
|
+
if (records.length > 0) {
|
|
119
|
+
found = true;
|
|
120
|
+
authenticated &&= response.AD === true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return answered ? { authenticated, found } : null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** The findings for a lookup result — pure, so it's testable without DoH. */
|
|
127
|
+
export const dnsAidFindings = (
|
|
128
|
+
host: string,
|
|
129
|
+
result: DnsAidResult
|
|
130
|
+
): Diagnostic[] => {
|
|
131
|
+
if (!result.found) {
|
|
132
|
+
return [
|
|
133
|
+
finding(
|
|
134
|
+
"BLUME_AUDIT_DNS_AID_MISSING",
|
|
135
|
+
{ url: "/" },
|
|
136
|
+
`No SVCB or HTTPS records at _index._agents.${host} — agents probing DNS for AI Discovery (DNS-AID) find nothing.`,
|
|
137
|
+
`Publish a ServiceMode record with your DNS provider, e.g. \`_index._agents.${host}. 3600 IN HTTPS 1 ${host}. alpn=h2\`, and sign the zone with DNSSEC if the provider supports it.`
|
|
138
|
+
),
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
if (!result.authenticated) {
|
|
142
|
+
return [
|
|
143
|
+
finding(
|
|
144
|
+
"BLUME_AUDIT_DNS_AID_UNSIGNED",
|
|
145
|
+
{ url: "/" },
|
|
146
|
+
`The DNS-AID records at _index._agents.${host} exist but are not DNSSEC-authenticated, so validating resolvers can't prove they're genuine.`
|
|
147
|
+
),
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
return [];
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** The `deployment.site` hostname, or null when unset/unusable for DNS. */
|
|
154
|
+
export const dnsAidHost = (site?: string): string | null => {
|
|
155
|
+
if (!site) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
let hostname: string;
|
|
159
|
+
try {
|
|
160
|
+
({ hostname } = new URL(site));
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
// A local or dotless host has no public zone to query.
|
|
165
|
+
if (
|
|
166
|
+
hostname === "localhost" ||
|
|
167
|
+
hostname.endsWith(".local") ||
|
|
168
|
+
!hostname.includes(".")
|
|
169
|
+
) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return hostname;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const dnsAidChecks: CheckModule = {
|
|
176
|
+
category: "ai",
|
|
177
|
+
async run(context) {
|
|
178
|
+
if (!context.origin) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
const host = dnsAidHost(context.project.config.deployment.site);
|
|
182
|
+
if (!host) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const result = await lookupDnsAid(host);
|
|
186
|
+
// No resolver answered: report nothing rather than guess.
|
|
187
|
+
return result ? dnsAidFindings(host, result) : [];
|
|
188
|
+
},
|
|
189
|
+
tier: "network",
|
|
190
|
+
};
|
package/src/audit/report.ts
CHANGED
|
@@ -192,6 +192,11 @@ export const formatReport = (
|
|
|
192
192
|
: group.findings.slice(0, PREVIEW);
|
|
193
193
|
for (const diagnostic of shown) {
|
|
194
194
|
lines.push(findingLine(diagnostic, root));
|
|
195
|
+
// The message names the specifics the rolled-up line can't — which target
|
|
196
|
+
// is broken, what the duplicate is — so --verbose prints it per finding.
|
|
197
|
+
if (options.verbose) {
|
|
198
|
+
lines.push(` ${COLORS.dim}${diagnostic.message}${COLORS.reset}`);
|
|
199
|
+
}
|
|
195
200
|
}
|
|
196
201
|
const hidden = group.count - shown.length;
|
|
197
202
|
if (hidden > 0) {
|
package/src/audit/run.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { CHECKS } from "./catalog.ts";
|
|
|
8
8
|
import type { CheckId } from "./catalog.ts";
|
|
9
9
|
import { assetChecks } from "./checks/assets.ts";
|
|
10
10
|
import { contentChecks } from "./checks/content.ts";
|
|
11
|
+
import { dnsAidChecks } from "./checks/dns-aid.ts";
|
|
11
12
|
import { duplicateChecks } from "./checks/duplicates.ts";
|
|
12
13
|
import { i18nChecks } from "./checks/i18n.ts";
|
|
13
14
|
import { indexabilityChecks } from "./checks/indexability.ts";
|
|
@@ -51,6 +52,7 @@ const MODULES: CheckModule[] = [
|
|
|
51
52
|
structuredDataChecks,
|
|
52
53
|
urlChecks,
|
|
53
54
|
networkChecks,
|
|
55
|
+
dnsAidChecks,
|
|
54
56
|
externalChecks,
|
|
55
57
|
];
|
|
56
58
|
|