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.
Files changed (77) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/index.js +1715 -539
  3. package/dist/cli/index.js.map +40 -31
  4. package/dist/types/core/config-input.d.ts +131 -11
  5. package/dist/types/core/config.d.ts +9 -1
  6. package/dist/types/core/data.d.ts +24 -5
  7. package/dist/types/core/i18n-ui.d.ts +58 -799
  8. package/dist/types/core/schema.d.ts +534 -3305
  9. package/dist/types/theme/fonts.d.ts +55 -11
  10. package/docs/02-deployment.mdx +2 -0
  11. package/docs/07-faq.mdx +14 -14
  12. package/docs/advanced/skills.mdx +2 -2
  13. package/docs/configuration/ai.mdx +126 -2
  14. package/docs/configuration/index.mdx +19 -1
  15. package/docs/configuration/search.mdx +17 -0
  16. package/docs/configuration/seo.mdx +26 -3
  17. package/docs/configuration/theming.mdx +44 -2
  18. package/docs/content/syntax.mdx +18 -2
  19. package/docs/reference/cli.mdx +3 -3
  20. package/package.json +9 -8
  21. package/skills/blume/SKILL.md +6 -4
  22. package/skills/blume-migrate/SKILL.md +5 -3
  23. package/skills/blume-migrate/references/mintlify.md +5 -5
  24. package/skills/blume-migrate/references/monorepo.md +2 -1
  25. package/src/ai/agent-readability.ts +31 -1
  26. package/src/ai/api-catalog.ts +81 -0
  27. package/src/ai/ask-context.ts +7 -1
  28. package/src/ai/ask-data.ts +1 -0
  29. package/src/ai/link-headers.ts +52 -0
  30. package/src/ai/llms.ts +12 -1
  31. package/src/ai/markdown.ts +15 -2
  32. package/src/ai/mcp/data.ts +7 -0
  33. package/src/ai/mcp/discovery.ts +70 -15
  34. package/src/ai/mcp/server.ts +14 -8
  35. package/src/ai/mcp/stdio.ts +4 -1
  36. package/src/ai/skills.ts +193 -0
  37. package/src/ai/tar.ts +104 -0
  38. package/src/ai/web-bot-auth.ts +30 -0
  39. package/src/astro/generate.ts +116 -6
  40. package/src/astro/integration.ts +52 -14
  41. package/src/astro/templates.ts +191 -37
  42. package/src/audit/catalog.ts +20 -0
  43. package/src/audit/checks/dns-aid.ts +190 -0
  44. package/src/audit/report.ts +5 -0
  45. package/src/audit/run.ts +2 -0
  46. package/src/cli/commands/build.ts +178 -9
  47. package/src/cli/init/scaffold.ts +1 -1
  48. package/src/components/islands/ask-ai.tsx +4 -1
  49. package/src/components/islands/webmcp.ts +203 -0
  50. package/src/components/layout/NavTree.astro +4 -4
  51. package/src/components/layout/PageLayout.astro +2 -0
  52. package/src/components/layout/ReferenceLayout.astro +2 -0
  53. package/src/components/layout/RootLayout.astro +63 -11
  54. package/src/components/layout/Search.astro +2 -2
  55. package/src/components/layout/WebMcp.astro +49 -0
  56. package/src/components/layout/search/orama.ts +5 -2
  57. package/src/core/config-input.ts +143 -11
  58. package/src/core/config.ts +17 -1
  59. package/src/core/content-assets.ts +199 -0
  60. package/src/core/data.ts +21 -5
  61. package/src/core/diagnostics.ts +6 -5
  62. package/src/core/i18n-ui.ts +19 -28
  63. package/src/core/project-graph.ts +6 -0
  64. package/src/core/schema.ts +224 -71
  65. package/src/core/sources/normalize.ts +5 -5
  66. package/src/deploy/headers.ts +45 -3
  67. package/src/deploy/vercel-negotiation.ts +233 -0
  68. package/src/markdown/mermaid.ts +7 -1
  69. package/src/markdown/table-wrap.ts +33 -1
  70. package/src/og/card.ts +91 -22
  71. package/src/og/derive.ts +200 -0
  72. package/src/og/index.ts +6 -1
  73. package/src/search/orama-index.ts +151 -7
  74. package/src/theme/entry.ts +34 -13
  75. package/src/theme/fonts.ts +183 -30
  76. package/dist/types/og/card.d.ts +0 -63
  77. package/dist/types/og/dimensions.d.ts +0 -12
@@ -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 Google Fonts via Astro's Fonts API, derived from theme.fonts.
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
- (font) =>
489
- `{ provider: fontProviders.google(), name: ${JSON.stringify(
490
- font.name
491
- )}, cssVariable: ${JSON.stringify(
492
- font.cssVariable
493
- )}, weights: ${JSON.stringify(
494
- font.weights
495
- )}, fallbacks: ${JSON.stringify(font.fallbacks)} }`
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({ base: deployment.base, contentRoutes, pages })})`
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 system =
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
- system,
925
+ instructions,
884
926
  messages,
885
927
  ${onError}
886
928
  });`
887
929
  : ` const result = streamText({
888
930
  model: ${modelExpr},
889
- system:
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
- /** A client that loads a static `blume-search.json` index (Orama, FlexSearch). */
980
- const staticSearchClient = (module: string): string =>
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
- return staticSearchClient(search.provider);
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
+ * (`![alt](./diagram.png)`), 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/_og/[...slug].png.ts`). */
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, derived once from the resolved config.
1240
- // The repo slug reuses the header link URL; the host comes from the site URL.
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
- fonts: data.config.og.fonts,
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: siteHost,
1418
+ site: data.config.og.site,
1265
1419
  title: props.title,
1266
1420
  });
1267
1421
  return new Response(new Uint8Array(png), {
@@ -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
+ };
@@ -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