blume 1.4.2 → 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 +71 -0
- package/dist/cli/index.js +2260 -1100
- package/dist/cli/index.js.map +123 -117
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +87 -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 +116 -15
- package/dist/types/core/sources/types.d.ts +11 -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 +14 -7
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/ai.mdx +26 -8
- 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/sources.mdx +1 -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 +13 -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 +40 -12
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +49 -12
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +42 -17
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +52 -16
- package/src/ai/mcp/server.ts +280 -125
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/tar.ts +29 -70
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +13 -5
- package/src/astro/generate.ts +113 -63
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +23 -12
- package/src/astro/templates.ts +185 -41
- package/src/audit/agent.ts +16 -31
- 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 +78 -25
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +15 -5
- package/src/audit/snapshot.ts +29 -6
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +21 -21
- package/src/cli/commands/build.ts +30 -16
- package/src/cli/commands/dev.ts +15 -15
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +24 -30
- 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/env.ts +13 -30
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +41 -13
- package/src/cli/internal-error.ts +1 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- 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/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +111 -34
- 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/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +24 -48
- package/src/components/layout/Search.astro +133 -22
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/drawer-inert.ts +31 -0
- 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 +8 -5
- package/src/components/layout/search/types.ts +45 -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/panel.ts +11 -8
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +96 -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 +74 -4
- 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/probe.ts +7 -19
- package/src/core/project-graph.ts +27 -4
- package/src/core/schema.ts +219 -67
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +16 -8
- package/src/core/sources/github-releases.ts +39 -11
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +93 -22
- package/src/core/sources/notion.ts +76 -22
- package/src/core/sources/portable-text.ts +48 -12
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +17 -1
- 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 +40 -11
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +89 -8
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +5 -18
- 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 +63 -58
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +50 -33
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +135 -66
- package/src/openapi/parse.ts +166 -33
- package/src/openapi/references.ts +47 -22
- package/src/openapi/render-mdx.ts +137 -59
- package/src/openapi/scalar.ts +8 -10
- package/src/openapi/source.ts +126 -29
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +103 -39
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +117 -32
- 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 +27 -15
- package/src/translate/ledger.ts +4 -2
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +10 -19
- package/src/translate/run.ts +29 -38
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
- package/src/cli/coalesce.ts +0 -43
package/src/astro/templates.ts
CHANGED
|
@@ -37,6 +37,8 @@ const hasWorkspacesField = (pkgPath: string): boolean => {
|
|
|
37
37
|
return false;
|
|
38
38
|
}
|
|
39
39
|
try {
|
|
40
|
+
// SAFETY: parsed from the user's own package.json; only the presence of a
|
|
41
|
+
// `workspaces` field is read, so this loose shape is all the cast claims.
|
|
40
42
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
|
|
41
43
|
workspaces?: unknown;
|
|
42
44
|
};
|
|
@@ -71,16 +73,19 @@ const findWorkspaceRoot = (start: string): string => {
|
|
|
71
73
|
}
|
|
72
74
|
};
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
type DeploymentAdapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
|
|
77
|
+
|
|
78
|
+
const ADAPTER_IMPORTS = {
|
|
75
79
|
cloudflare: "@astrojs/cloudflare",
|
|
76
80
|
netlify: "@astrojs/netlify",
|
|
77
81
|
node: "@astrojs/node",
|
|
78
82
|
vercel: "@astrojs/vercel",
|
|
79
|
-
}
|
|
83
|
+
} satisfies Record<DeploymentAdapter, string>;
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
85
|
+
/** Adapter constructor arguments, for the adapters that need any. */
|
|
86
|
+
const ADAPTER_OPTIONS = new Map<DeploymentAdapter, string>([
|
|
87
|
+
["node", '{ mode: "standalone" }'],
|
|
88
|
+
]);
|
|
84
89
|
|
|
85
90
|
const WRANGLER_CONFIG_FILES = [
|
|
86
91
|
"wrangler.jsonc",
|
|
@@ -169,8 +174,8 @@ export const runtimeDependencies = (options: {
|
|
|
169
174
|
deps.push("@astrojs/svelte");
|
|
170
175
|
}
|
|
171
176
|
// The Scalar integration is only declared for a Scalar-rendered reference
|
|
172
|
-
// (the `renderer: "scalar"`
|
|
173
|
-
//
|
|
177
|
+
// (the `renderer: "scalar"` opt-out on either block). Blume-rendered
|
|
178
|
+
// references parse at generate time and need no runtime Scalar dependency.
|
|
174
179
|
if (hasScalarReferences(config)) {
|
|
175
180
|
deps.push("@scalar/astro");
|
|
176
181
|
}
|
|
@@ -370,6 +375,12 @@ const renderImageOption = (config: ResolvedConfig): string =>
|
|
|
370
375
|
? `\n image: ${JSON.stringify(config.image)},`
|
|
371
376
|
: "";
|
|
372
377
|
|
|
378
|
+
/** What `resolveOptimizeDeps` feeds the generated `optimizeDeps` block. */
|
|
379
|
+
interface OptimizeDepsConfig {
|
|
380
|
+
optimizeDepsEntries: string[];
|
|
381
|
+
optimizeDepsInclude: string[];
|
|
382
|
+
}
|
|
383
|
+
|
|
373
384
|
/**
|
|
374
385
|
* Startup-scan entry points and forced includes for the dev dep optimizer:
|
|
375
386
|
* the Vite root is the generated runtime, so user pages, convention islands,
|
|
@@ -383,7 +394,7 @@ const resolveOptimizeDeps = (options: {
|
|
|
383
394
|
context: ProjectContext;
|
|
384
395
|
needsReact: boolean;
|
|
385
396
|
reactCompilerPath: string | null | undefined;
|
|
386
|
-
}):
|
|
397
|
+
}): OptimizeDepsConfig => {
|
|
387
398
|
const { context } = options;
|
|
388
399
|
const optimizeDepsEntries = [
|
|
389
400
|
...(context.pagesRoot ? [`${context.pagesRoot}/**/*.astro`] : []),
|
|
@@ -474,7 +485,7 @@ export const astroConfigTemplate = (options: {
|
|
|
474
485
|
if (deployment.adapter === "cloudflare") {
|
|
475
486
|
return resolveCloudflareAdapterArgs(context);
|
|
476
487
|
}
|
|
477
|
-
return ADAPTER_OPTIONS
|
|
488
|
+
return ADAPTER_OPTIONS.get(deployment.adapter) ?? "";
|
|
478
489
|
})();
|
|
479
490
|
// Vercel resolves its Build Output tree and its `@vercel/nft` dependency
|
|
480
491
|
// trace against the Astro root, which for Blume is the hidden `.blume`
|
|
@@ -547,19 +558,23 @@ export const astroConfigTemplate = (options: {
|
|
|
547
558
|
)}, fallbacks: ${JSON.stringify(
|
|
548
559
|
font.fallbacks
|
|
549
560
|
)}, options: { variants: ${JSON.stringify(
|
|
550
|
-
font.variants.map((variant) =>
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
561
|
+
font.variants.map((variant) => {
|
|
562
|
+
const face: Pick<typeof variant, "style" | "weight"> = {};
|
|
563
|
+
if (variant.weight !== undefined) {
|
|
564
|
+
face.weight = variant.weight;
|
|
565
|
+
}
|
|
566
|
+
if (variant.style !== undefined) {
|
|
567
|
+
face.style = variant.style;
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
...face,
|
|
571
|
+
src: [
|
|
572
|
+
isAbsolute(variant.src)
|
|
573
|
+
? variant.src
|
|
574
|
+
: join(context.root, variant.src),
|
|
575
|
+
],
|
|
576
|
+
};
|
|
577
|
+
})
|
|
563
578
|
)} } }`
|
|
564
579
|
: `{ provider: fontProviders.${font.provider}(), name: ${JSON.stringify(
|
|
565
580
|
font.name
|
|
@@ -868,11 +883,24 @@ export const collections = { docs${options.staged ? ", staged" : ""} };
|
|
|
868
883
|
};
|
|
869
884
|
|
|
870
885
|
/** Generate `.blume/src/pages/[...slug].astro`, the docs catch-all route. */
|
|
871
|
-
/**
|
|
886
|
+
/** The plain prompt used when there is no grounding context to inject. */
|
|
887
|
+
const ASK_FALLBACK_PROMPT =
|
|
888
|
+
"You are a helpful documentation assistant. Answer using the project's documentation.";
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Generate the Ask AI server endpoint (`.blume/src/pages/api/ask.ts`).
|
|
892
|
+
* `instructions` (the `ai.ask.instructions` config) is appended to the
|
|
893
|
+
* built-in prompt on every path: the grounded prompt via `createAskContext`,
|
|
894
|
+
* and the plain fallback here.
|
|
895
|
+
*/
|
|
872
896
|
export const askEndpointTemplate = (
|
|
873
897
|
backend: AskBackend,
|
|
874
|
-
grounded: boolean
|
|
898
|
+
grounded: boolean,
|
|
899
|
+
instructions?: string
|
|
875
900
|
): string => {
|
|
901
|
+
const fallbackPrompt = instructions
|
|
902
|
+
? `${ASK_FALLBACK_PROMPT}\n\n${instructions}`
|
|
903
|
+
: ASK_FALLBACK_PROMPT;
|
|
876
904
|
const imports = [
|
|
877
905
|
'import type { APIRoute } from "astro";',
|
|
878
906
|
'import { streamText } from "ai";',
|
|
@@ -905,7 +933,10 @@ export const askEndpointTemplate = (
|
|
|
905
933
|
'import { createAskContext } from "blume/ai/ask-context.ts";',
|
|
906
934
|
'import askData from "../../generated/ask-data.json";'
|
|
907
935
|
);
|
|
908
|
-
|
|
936
|
+
const groundOptions = instructions
|
|
937
|
+
? `, { instructions: ${JSON.stringify(instructions)} }`
|
|
938
|
+
: "";
|
|
939
|
+
setup += `\nconst ground = createAskContext(askData${groundOptions});\n`;
|
|
909
940
|
}
|
|
910
941
|
// Validate the client-supplied body and cap its size. The endpoint is
|
|
911
942
|
// unauthenticated, so bounding message count/length limits how much a caller
|
|
@@ -966,7 +997,7 @@ export const askEndpointTemplate = (
|
|
|
966
997
|
const stream = grounded
|
|
967
998
|
? ` const instructions =
|
|
968
999
|
(await ground(messages, body.page)) ??
|
|
969
|
-
|
|
1000
|
+
${JSON.stringify(fallbackPrompt)};
|
|
970
1001
|
const result = streamText({
|
|
971
1002
|
model: ${modelExpr},
|
|
972
1003
|
instructions,
|
|
@@ -976,7 +1007,7 @@ ${onError}
|
|
|
976
1007
|
: ` const result = streamText({
|
|
977
1008
|
model: ${modelExpr},
|
|
978
1009
|
instructions:
|
|
979
|
-
|
|
1010
|
+
${JSON.stringify(fallbackPrompt)},
|
|
980
1011
|
messages,
|
|
981
1012
|
${onError}
|
|
982
1013
|
});`;
|
|
@@ -1079,10 +1110,17 @@ export const createSearch = () => create({ indexUrl${
|
|
|
1079
1110
|
} });
|
|
1080
1111
|
`;
|
|
1081
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Public credential fields baked into a hosted provider's generated client
|
|
1115
|
+
* (Algolia/Orama Cloud/Typesense config values from `search.*`, all plain
|
|
1116
|
+
* strings or numbers; `JSON.stringify` drops the absent ones).
|
|
1117
|
+
*/
|
|
1118
|
+
type HostedSearchCredentials = Record<string, string | number | undefined>;
|
|
1119
|
+
|
|
1082
1120
|
/** A client that passes public credentials straight to the provider SDK. */
|
|
1083
1121
|
const hostedSearchClient = (
|
|
1084
1122
|
module: string,
|
|
1085
|
-
options:
|
|
1123
|
+
options: HostedSearchCredentials
|
|
1086
1124
|
): string =>
|
|
1087
1125
|
`${SEARCH_CLIENT_HEADER}${searchClientImport(module)}
|
|
1088
1126
|
export const createSearch = () => create(${JSON.stringify(options)});
|
|
@@ -1091,7 +1129,7 @@ export const createSearch = () => create(${JSON.stringify(options)});
|
|
|
1091
1129
|
/** Build the per-provider config object the hosted client is created with. */
|
|
1092
1130
|
const hostedSearchOptions = (
|
|
1093
1131
|
search: ResolvedConfig["search"]
|
|
1094
|
-
): { module: string; options:
|
|
1132
|
+
): { module: string; options: HostedSearchCredentials } | null => {
|
|
1095
1133
|
switch (search.provider) {
|
|
1096
1134
|
case "algolia": {
|
|
1097
1135
|
return { module: "algolia", options: { ...search.algolia } };
|
|
@@ -1261,7 +1299,7 @@ export const contentAssetsEndpointTemplate = (
|
|
|
1261
1299
|
`// Generated by Blume. Do not edit.
|
|
1262
1300
|
import { existsSync } from "node:fs";
|
|
1263
1301
|
import { readdir, readFile } from "node:fs/promises";
|
|
1264
|
-
import { join, relative, resolve } from "node:path";
|
|
1302
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
1265
1303
|
import type { APIRoute } from "astro";
|
|
1266
1304
|
import assets from "../../generated/content-assets.json";
|
|
1267
1305
|
|
|
@@ -1321,7 +1359,13 @@ const resolveAsset = (asset: string): string | null => {
|
|
|
1321
1359
|
const abs = resolve(STAGED_DIR, asset);
|
|
1322
1360
|
// Traversal guard: the dev server renders on demand, so the param is
|
|
1323
1361
|
// attacker-controlled there — never step outside the staged directory.
|
|
1324
|
-
|
|
1362
|
+
// path.relative rather than a string-prefix test: STAGED_DIR is baked in
|
|
1363
|
+
// with forward slashes while resolve() answers in the platform's
|
|
1364
|
+
// separators, so on Windows the prefix test 404'd every legitimate staged
|
|
1365
|
+
// asset — and a bare prefix also admits a sibling directory that merely
|
|
1366
|
+
// shares the name.
|
|
1367
|
+
const rel = relative(STAGED_DIR, abs);
|
|
1368
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
|
1325
1369
|
return null;
|
|
1326
1370
|
}
|
|
1327
1371
|
return existsSync(abs) ? abs : null;
|
|
@@ -1367,7 +1411,9 @@ export const ALL: APIRoute = ({ request }) => handler(request);
|
|
|
1367
1411
|
};
|
|
1368
1412
|
|
|
1369
1413
|
/** Generate a prerendered endpoint that serves a fixed JSON payload. */
|
|
1370
|
-
export const staticJsonEndpointTemplate =
|
|
1414
|
+
export const staticJsonEndpointTemplate = <Payload extends object>(
|
|
1415
|
+
payload: Payload
|
|
1416
|
+
): string =>
|
|
1371
1417
|
`// Generated by Blume. Do not edit.
|
|
1372
1418
|
export const prerender = true;
|
|
1373
1419
|
|
|
@@ -1488,8 +1534,9 @@ export async function GET({ props }: { props: { title: string } }) {
|
|
|
1488
1534
|
* live inside our shell. `dataImport` is the route-depth-aware relative path to
|
|
1489
1535
|
* the generated data module the layout reads.
|
|
1490
1536
|
*/
|
|
1491
|
-
export const scalarReferenceTemplate = (options: {
|
|
1492
|
-
|
|
1537
|
+
export const scalarReferenceTemplate = <Configuration extends object>(options: {
|
|
1538
|
+
/** Scalar options forwarded verbatim (spec/theme config plus the author's `scalar` escape hatch). */
|
|
1539
|
+
configuration: Configuration;
|
|
1493
1540
|
dataImport: string;
|
|
1494
1541
|
noindex?: boolean;
|
|
1495
1542
|
route: string;
|
|
@@ -1665,11 +1712,13 @@ export function getStaticPaths() {
|
|
|
1665
1712
|
locale: route.locale,
|
|
1666
1713
|
route: route.path,
|
|
1667
1714
|
title: route.title,
|
|
1715
|
+
version: route.version,
|
|
1716
|
+
versionAlternates: route.versionAlternates,
|
|
1668
1717
|
},
|
|
1669
1718
|
}));
|
|
1670
1719
|
}
|
|
1671
1720
|
|
|
1672
|
-
const { entryId, collection, route, title, indexable, editUrl, lastModified, locale, alternates, fallback } = Astro.props;
|
|
1721
|
+
const { entryId, collection, route, title, indexable, editUrl, lastModified, locale, alternates, fallback, version, versionAlternates } = Astro.props;
|
|
1673
1722
|
const entry = await getEntry(collection as CollectionKey, entryId);
|
|
1674
1723
|
if (!entry) {
|
|
1675
1724
|
return new Response(null, { status: 404 });
|
|
@@ -1680,8 +1729,11 @@ const frontmatter = entry.data ?? {};
|
|
|
1680
1729
|
const seo = frontmatter.seo ?? {};
|
|
1681
1730
|
const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
|
|
1682
1731
|
|
|
1732
|
+
// Percent-encode the route-derived path (the sitemap convention): a Unicode
|
|
1733
|
+
// slug (\`/api/größe\`) is not legal in a raw URI, and crawlers compare
|
|
1734
|
+
// canonical against the sitemap's encoded <loc> byte-for-byte.
|
|
1683
1735
|
const ogPath = data.config.og.enabled
|
|
1684
|
-
? \`/og/\${route === "/" ? "index" : route.slice(1)}.png\`
|
|
1736
|
+
? encodeURI(\`/og/\${route === "/" ? "index" : route.slice(1)}.png\`)
|
|
1685
1737
|
: null;
|
|
1686
1738
|
const ogRel = seo.image ?? ogPath;
|
|
1687
1739
|
// Absolute URLs also carry the deployment base (the page is served under it):
|
|
@@ -1698,9 +1750,6 @@ const ogGenerated = !seo.image && Boolean(ogPath);
|
|
|
1698
1750
|
const x = { ...data.config.x, ...(seo.x?.creator ? { creator: seo.x.creator } : {}) };
|
|
1699
1751
|
|
|
1700
1752
|
const basedRoute = withBase(route);
|
|
1701
|
-
const canonical =
|
|
1702
|
-
seo.canonical ??
|
|
1703
|
-
(base ? \`\${base}\${basedRoute === "/" ? "" : basedRoute}\` : null);
|
|
1704
1753
|
|
|
1705
1754
|
// Locale resolution. With i18n on, pick the active locale's nav + dictionary,
|
|
1706
1755
|
// build hreflang alternates, and derive the language-switcher targets.
|
|
@@ -1721,7 +1770,20 @@ const stripLocale = (path: string, codeArg: string) => {
|
|
|
1721
1770
|
return prefix && path.startsWith(prefix) ? path.slice(prefix.length) || "/" : path;
|
|
1722
1771
|
};
|
|
1723
1772
|
|
|
1724
|
-
|
|
1773
|
+
// Version resolution. An archived page renders its snapshot's navigation tree,
|
|
1774
|
+
// points its canonical at the latest equivalent (unless configured otherwise),
|
|
1775
|
+
// and shows the old-version notice.
|
|
1776
|
+
const versionsConfig = data.config.versions;
|
|
1777
|
+
const archived = versionsConfig && version
|
|
1778
|
+
? (versionsConfig.archived.find((v) => v.id === version) ?? null)
|
|
1779
|
+
: null;
|
|
1780
|
+
const latestVersionAlt = (versionAlternates ?? []).find((alt) => alt.version === "");
|
|
1781
|
+
|
|
1782
|
+
const navigation = version
|
|
1783
|
+
? (data.navigationByVersion[version]?.[i18n ? locale : ""] ?? data.navigation)
|
|
1784
|
+
: i18n
|
|
1785
|
+
? (data.navigationByLocale[locale] ?? data.navigation)
|
|
1786
|
+
: data.navigation;
|
|
1725
1787
|
const ui = i18n ? (data.uiByLocale[locale] ?? data.ui) : data.ui;
|
|
1726
1788
|
const localeMeta = i18n ? i18n.locales.find((l) => l.code === locale) : null;
|
|
1727
1789
|
const dir = localeMeta?.dir ?? "ltr";
|
|
@@ -1738,6 +1800,19 @@ const absolute = (path: string) => {
|
|
|
1738
1800
|
return base + (p === "/" ? "" : p);
|
|
1739
1801
|
};
|
|
1740
1802
|
|
|
1803
|
+
// An archived page defaults its canonical to the same page in the latest docs
|
|
1804
|
+
// when that page still exists — search engines treat the live page as
|
|
1805
|
+
// authoritative without deindexing version-only content. A page's own
|
|
1806
|
+
// \`seo.canonical\` always wins, and \`canonical: "self"\` keeps the default.
|
|
1807
|
+
const canonical =
|
|
1808
|
+
seo.canonical ??
|
|
1809
|
+
(archived && archived.canonical === "latest" && latestVersionAlt && base
|
|
1810
|
+
? absolute(latestVersionAlt.path)
|
|
1811
|
+
: base
|
|
1812
|
+
? \`\${base}\${basedRoute === "/" ? "" : encodeURI(basedRoute)}\`
|
|
1813
|
+
: null);
|
|
1814
|
+
const effectiveNoindex = Boolean(seo.noindex) || (archived?.noindex ?? false);
|
|
1815
|
+
|
|
1741
1816
|
const localeAlternates =
|
|
1742
1817
|
i18n && base
|
|
1743
1818
|
? (alternates ?? []).map((alt) => ({ hreflang: alt.locale, href: absolute(alt.path) }))
|
|
@@ -1760,6 +1835,72 @@ const localeSwitch = i18n
|
|
|
1760
1835
|
})
|
|
1761
1836
|
: [];
|
|
1762
1837
|
|
|
1838
|
+
// Version switcher + old-version notice. The switcher auto-populates from the
|
|
1839
|
+
// versions config as a \`kind: "version"\` selector; a user-declared version
|
|
1840
|
+
// selector in \`navigation.selectors\` suppresses it (theirs renders instead).
|
|
1841
|
+
// Fallback version roots compose like real routes — \`{basePath}/{locale?}/{id}\`
|
|
1842
|
+
// (manifest \`versionAlternates\` paths arrive with the base already applied).
|
|
1843
|
+
const versionRootFor = (id: string) => {
|
|
1844
|
+
const logical = id ? \`/\${id}\` : "/";
|
|
1845
|
+
const localized = i18n ? localizeRoute(logical, locale) : logical;
|
|
1846
|
+
const mount = data.config.basePath;
|
|
1847
|
+
if (!mount) {
|
|
1848
|
+
return localized;
|
|
1849
|
+
}
|
|
1850
|
+
return localized === "/" ? mount : \`\${mount}\${localized}\`;
|
|
1851
|
+
};
|
|
1852
|
+
const samePageSwitch = versionsConfig
|
|
1853
|
+
? versionsConfig.switcher.redirect === "same-page"
|
|
1854
|
+
: true;
|
|
1855
|
+
const userHasVersionSelector = navigation.selectors.some(
|
|
1856
|
+
(selector) => selector.kind === "version"
|
|
1857
|
+
);
|
|
1858
|
+
const versionSelector =
|
|
1859
|
+
versionsConfig && !userHasVersionSelector
|
|
1860
|
+
? {
|
|
1861
|
+
items: [
|
|
1862
|
+
{
|
|
1863
|
+
id: "",
|
|
1864
|
+
label: versionsConfig.current.label,
|
|
1865
|
+
tag: versionsConfig.current.badge,
|
|
1866
|
+
},
|
|
1867
|
+
...versionsConfig.archived.map((v) => ({
|
|
1868
|
+
id: v.id,
|
|
1869
|
+
label: v.label ?? v.id,
|
|
1870
|
+
tag: undefined,
|
|
1871
|
+
})),
|
|
1872
|
+
].map((entry) => {
|
|
1873
|
+
const alt = (versionAlternates ?? []).find(
|
|
1874
|
+
(a) => a.version === entry.id
|
|
1875
|
+
);
|
|
1876
|
+
return {
|
|
1877
|
+
label: entry.label,
|
|
1878
|
+
path: samePageSwitch && alt ? alt.path : versionRootFor(entry.id),
|
|
1879
|
+
...(entry.tag ? { tag: entry.tag } : {}),
|
|
1880
|
+
};
|
|
1881
|
+
}),
|
|
1882
|
+
kind: "version" as const,
|
|
1883
|
+
label: ui.versions.switcher,
|
|
1884
|
+
}
|
|
1885
|
+
: null;
|
|
1886
|
+
|
|
1887
|
+
const versionNotice =
|
|
1888
|
+
archived && archived.banner !== false
|
|
1889
|
+
? {
|
|
1890
|
+
latestHref: latestVersionAlt
|
|
1891
|
+
? latestVersionAlt.path
|
|
1892
|
+
: versionRootFor(""),
|
|
1893
|
+
latestLabel: ui.versions.latest,
|
|
1894
|
+
message:
|
|
1895
|
+
typeof archived.banner === "string"
|
|
1896
|
+
? archived.banner
|
|
1897
|
+
: ui.versions.notice.replace(
|
|
1898
|
+
"{version}",
|
|
1899
|
+
archived.label ?? archived.id
|
|
1900
|
+
),
|
|
1901
|
+
}
|
|
1902
|
+
: null;
|
|
1903
|
+
|
|
1763
1904
|
// The whole page shell is overridable via \`layout.Layout\`; it receives the same
|
|
1764
1905
|
// props as the built-in RootLayout, plus the \`layout\` map for its inner slots.
|
|
1765
1906
|
const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
@@ -1784,6 +1925,9 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1784
1925
|
localeAlternates={localeAlternates}
|
|
1785
1926
|
xDefault={xDefault}
|
|
1786
1927
|
localeSwitch={localeSwitch}
|
|
1928
|
+
versionSelector={versionSelector}
|
|
1929
|
+
versionNotice={versionNotice}
|
|
1930
|
+
searchVersion={versionsConfig ? version : null}
|
|
1787
1931
|
page={{ title: seo.title ?? title, description: seo.description ?? frontmatter.description, route }}
|
|
1788
1932
|
headings={headings}
|
|
1789
1933
|
toc={data.config.toc}
|
|
@@ -1806,7 +1950,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1806
1950
|
pageType={frontmatter.type}
|
|
1807
1951
|
published={frontmatter.date ?? frontmatter.changelog?.date ?? null}
|
|
1808
1952
|
lastModified={lastModified}
|
|
1809
|
-
noindex={
|
|
1953
|
+
noindex={effectiveNoindex}
|
|
1810
1954
|
structuredDataEnabled={data.config.structuredData}
|
|
1811
1955
|
>
|
|
1812
1956
|
<h1>{title}</h1>
|
package/src/audit/agent.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
3
2
|
import { tmpdir } from "node:os";
|
|
4
3
|
|
|
4
|
+
import spawn from "cross-spawn";
|
|
5
5
|
import { join } from "pathe";
|
|
6
6
|
|
|
7
7
|
import { reportJson } from "./report.ts";
|
|
@@ -19,7 +19,7 @@ export interface AgentCli {
|
|
|
19
19
|
|
|
20
20
|
export type AgentKind = "claude" | "codex";
|
|
21
21
|
|
|
22
|
-
export const AGENTS
|
|
22
|
+
export const AGENTS = {
|
|
23
23
|
claude: {
|
|
24
24
|
bin: "claude",
|
|
25
25
|
install: "npm install -g @anthropic-ai/claude-code",
|
|
@@ -30,7 +30,7 @@ export const AGENTS: Record<AgentKind, AgentCli> = {
|
|
|
30
30
|
install: "npm install -g @openai/codex",
|
|
31
31
|
name: "Codex",
|
|
32
32
|
},
|
|
33
|
-
}
|
|
33
|
+
} satisfies Record<AgentKind, AgentCli>;
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Write the full JSON report where the agent can read it. A file rather than
|
|
@@ -61,37 +61,26 @@ Work through every finding:
|
|
|
61
61
|
|
|
62
62
|
When you are done, run \`blume build\` and then \`blume audit\` to verify, and repeat until the audit reports no issues.`;
|
|
63
63
|
|
|
64
|
-
const spawnAgent = (
|
|
65
|
-
command: string,
|
|
66
|
-
args: string[],
|
|
67
|
-
shell: boolean
|
|
68
|
-
): Promise<number> =>
|
|
64
|
+
const spawnAgent = (bin: string, args: string[]): Promise<number> =>
|
|
69
65
|
// oxlint-disable-next-line promise/avoid-new -- adapt spawn's event callbacks
|
|
70
66
|
new Promise((resolve, reject) => {
|
|
71
|
-
const child = spawn(
|
|
67
|
+
const child = spawn(bin, args, { stdio: "inherit" });
|
|
72
68
|
child.once("error", reject);
|
|
73
69
|
child.once("close", (code) => resolve(code ?? 1));
|
|
74
70
|
});
|
|
75
71
|
|
|
76
|
-
/**
|
|
77
|
-
* cmd.exe reports a missing executable through this exit code instead of a
|
|
78
|
-
* spawn error, so a shell launch can't rely on the `error` event for the
|
|
79
|
-
* "not installed" diagnosis.
|
|
80
|
-
*/
|
|
81
|
-
export const WINDOWS_COMMAND_NOT_FOUND = 9009;
|
|
82
|
-
|
|
83
72
|
/**
|
|
84
73
|
* Run the agent CLI interactively with the handoff prompt, inheriting the
|
|
85
74
|
* terminal so the user watches and steers the fixes rather than granting a
|
|
86
75
|
* headless process blanket write access. Resolves with the agent's exit code;
|
|
87
|
-
* rejects when the executable isn't on PATH
|
|
76
|
+
* rejects when the executable isn't on PATH — on every platform, since
|
|
77
|
+
* cross-spawn resolves npm's `.cmd` shims itself and escapes their arguments
|
|
78
|
+
* (the old `shell: true` launch left quoting to a hand-built command line and
|
|
79
|
+
* reported a missing executable as cmd.exe's exit code 9009).
|
|
88
80
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* file next to the report and handed over via a one-line pointer that survives
|
|
93
|
-
* cmd.exe quoting; a missing executable surfaces as
|
|
94
|
-
* {@link WINDOWS_COMMAND_NOT_FOUND} rather than a rejection.
|
|
81
|
+
* cmd.exe still cannot carry the multi-line prompt as an argument (a newline
|
|
82
|
+
* ends the command), so on Windows the prompt is written to a file next to
|
|
83
|
+
* the report and handed over via a one-line pointer.
|
|
95
84
|
*/
|
|
96
85
|
export const launchAgent = async (
|
|
97
86
|
bin: string,
|
|
@@ -99,16 +88,12 @@ export const launchAgent = async (
|
|
|
99
88
|
platform: NodeJS.Platform = process.platform
|
|
100
89
|
): Promise<number> => {
|
|
101
90
|
if (platform !== "win32") {
|
|
102
|
-
return await spawnAgent(bin, [prompt]
|
|
91
|
+
return await spawnAgent(bin, [prompt]);
|
|
103
92
|
}
|
|
104
93
|
const dir = await mkdtemp(join(tmpdir(), "blume-audit-"));
|
|
105
94
|
const promptPath = join(dir, "prompt.md");
|
|
106
95
|
await writeFile(promptPath, prompt);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
`"${bin}" "Read ${promptPath} and follow its instructions exactly."`,
|
|
111
|
-
[],
|
|
112
|
-
true
|
|
113
|
-
);
|
|
96
|
+
return await spawnAgent(bin, [
|
|
97
|
+
`Read ${promptPath} and follow its instructions exactly.`,
|
|
98
|
+
]);
|
|
114
99
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
|
|
1
3
|
import matter from "../../core/frontmatter.ts";
|
|
2
4
|
import type { Diagnostic } from "../../core/types.ts";
|
|
3
5
|
import { finding } from "../catalog.ts";
|
|
@@ -5,6 +7,17 @@ import { pageSite } from "../locate.ts";
|
|
|
5
7
|
import { ERROR_ROUTES } from "../types.ts";
|
|
6
8
|
import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
|
|
7
9
|
|
|
10
|
+
// Extraction trims only the ends; interior runs of whitespace (a <title>
|
|
11
|
+
// authored across indented source lines) survive. A search engine collapses
|
|
12
|
+
// them to single spaces before rendering, so the width is measured on the
|
|
13
|
+
// collapsed text — source formatting must not move a page across a threshold.
|
|
14
|
+
const WHITESPACE = /\s+/gu;
|
|
15
|
+
|
|
16
|
+
/** Whether front matter `date` arrived in one of YAML's two date spellings. */
|
|
17
|
+
const isDateValue = (
|
|
18
|
+
value: string | Date | undefined
|
|
19
|
+
): value is string | Date => typeof value === "string" || value instanceof Date;
|
|
20
|
+
|
|
8
21
|
/**
|
|
9
22
|
* The `date` a source file's front matter declares, when it parses to a real
|
|
10
23
|
* date. YAML hands back a `Date` for an unquoted `2026-01-01` and a string for
|
|
@@ -14,8 +27,8 @@ import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
|
|
|
14
27
|
*/
|
|
15
28
|
const frontmatterDate = (source: string): Date | null => {
|
|
16
29
|
try {
|
|
17
|
-
const { date } = matter(source).data
|
|
18
|
-
if (
|
|
30
|
+
const { date } = matter(source).data;
|
|
31
|
+
if (!isDateValue(date)) {
|
|
19
32
|
return null;
|
|
20
33
|
}
|
|
21
34
|
const parsed = date instanceof Date ? date : new Date(date);
|
|
@@ -52,13 +65,16 @@ const titleChecks = (
|
|
|
52
65
|
)
|
|
53
66
|
);
|
|
54
67
|
}
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
// Columns, not characters: a search engine truncates by the space the text
|
|
69
|
+
// takes up. See {@link AuditThresholds}.
|
|
70
|
+
const width = stringWidth(title.replaceAll(WHITESPACE, " "));
|
|
71
|
+
if (width > titleMax || width < titleMin) {
|
|
72
|
+
const direction = width > titleMax ? "long" : "short";
|
|
57
73
|
found.push(
|
|
58
74
|
finding(
|
|
59
75
|
"BLUME_AUDIT_TITLE_LENGTH",
|
|
60
76
|
pageSite(context, page, ["title"]),
|
|
61
|
-
`Title
|
|
77
|
+
`Title renders ${width} columns wide — too ${direction} (aim for ${titleMin}–${titleMax}).`
|
|
62
78
|
)
|
|
63
79
|
);
|
|
64
80
|
}
|
|
@@ -99,16 +115,15 @@ const descriptionChecks = (
|
|
|
99
115
|
)
|
|
100
116
|
);
|
|
101
117
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const direction = description.length > descriptionMax ? "long" : "short";
|
|
118
|
+
// Columns, not characters. See {@link AuditThresholds}.
|
|
119
|
+
const width = stringWidth(description.replaceAll(WHITESPACE, " "));
|
|
120
|
+
if (width > descriptionMax || width < descriptionMin) {
|
|
121
|
+
const direction = width > descriptionMax ? "long" : "short";
|
|
107
122
|
found.push(
|
|
108
123
|
finding(
|
|
109
124
|
"BLUME_AUDIT_DESCRIPTION_LENGTH",
|
|
110
125
|
pageSite(context, page, ["description"]),
|
|
111
|
-
`Meta description
|
|
126
|
+
`Meta description renders ${width} columns wide — too ${direction} (aim for ${descriptionMin}–${descriptionMax}).`
|
|
112
127
|
)
|
|
113
128
|
);
|
|
114
129
|
}
|
|
@@ -68,6 +68,9 @@ const fetchDohJson = async (
|
|
|
68
68
|
if (!response.ok) {
|
|
69
69
|
return null;
|
|
70
70
|
}
|
|
71
|
+
// SAFETY: the resolver answered a dns-json query, whose response schema
|
|
72
|
+
// DohResponse models with every field optional; a malformed body that
|
|
73
|
+
// fails to parse rejects into the catch below.
|
|
71
74
|
return (await response.json()) as DohResponse;
|
|
72
75
|
} catch {
|
|
73
76
|
return null;
|
|
@@ -101,13 +101,31 @@ const canonicalChecks = (
|
|
|
101
101
|
)
|
|
102
102
|
);
|
|
103
103
|
} else if (context.byUrl.has(target)) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
// An archived-version page pointing at its latest equivalent is Blume's
|
|
105
|
+
// own default (versions.archived[].canonical: "latest"), not a defect —
|
|
106
|
+
// the live page is the authoritative one by design.
|
|
107
|
+
const version = page.route?.version;
|
|
108
|
+
const archived = version
|
|
109
|
+
? context.project.config.versions?.archived.find(
|
|
110
|
+
(entry) => entry.id === version
|
|
111
|
+
)
|
|
112
|
+
: undefined;
|
|
113
|
+
const latestAlternate = page.route?.versionAlternates.find(
|
|
114
|
+
(alternate) => alternate.version === ""
|
|
110
115
|
);
|
|
116
|
+
const intentional =
|
|
117
|
+
archived?.canonical === "latest" &&
|
|
118
|
+
latestAlternate !== undefined &&
|
|
119
|
+
normalizePath(latestAlternate.path) === target;
|
|
120
|
+
if (!intentional) {
|
|
121
|
+
found.push(
|
|
122
|
+
finding(
|
|
123
|
+
"BLUME_AUDIT_CANONICAL_NOT_SELF",
|
|
124
|
+
pageSite(context, page, ["seo", "canonical"]),
|
|
125
|
+
`Page declares ${target} as its canonical, so it will not be indexed itself.`
|
|
126
|
+
)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
111
129
|
} else {
|
|
112
130
|
found.push(
|
|
113
131
|
finding(
|
package/src/audit/checks/llms.ts
CHANGED
|
@@ -6,12 +6,17 @@ import { ERROR_ROUTES } from "../types.ts";
|
|
|
6
6
|
import type { AuditContext, CheckModule } from "../types.ts";
|
|
7
7
|
import { normalizePath, siteOrigin } from "../url.ts";
|
|
8
8
|
|
|
9
|
+
/** The object form of `ai.llmsTxt`. The schema always emits it, but hand-built
|
|
10
|
+
* audit contexts (tests, partial configs) may still carry the raw boolean. */
|
|
11
|
+
const isLlmsToggleObject = (
|
|
12
|
+
value: boolean | { enabled: boolean; openapi: boolean } | undefined
|
|
13
|
+
): value is { enabled: boolean; openapi: boolean } =>
|
|
14
|
+
typeof value === "object" && value !== null;
|
|
15
|
+
|
|
9
16
|
/** The `ai.llmsTxt` config normalized to what the checks need. */
|
|
10
|
-
const llmsConfig = (
|
|
11
|
-
context: AuditContext
|
|
12
|
-
): { enabled: boolean; openapi: boolean } => {
|
|
17
|
+
const llmsConfig = (context: AuditContext) => {
|
|
13
18
|
const value = context.project.config.ai?.llmsTxt;
|
|
14
|
-
if (
|
|
19
|
+
if (isLlmsToggleObject(value)) {
|
|
15
20
|
return { enabled: value.enabled, openapi: value.openapi };
|
|
16
21
|
}
|
|
17
22
|
return { enabled: value !== false, openapi: true };
|
|
@@ -252,6 +252,8 @@ export const externalChecks: CheckModule = {
|
|
|
252
252
|
if (!result) {
|
|
253
253
|
continue;
|
|
254
254
|
}
|
|
255
|
+
// SAFETY: every `linkers` entry is created with its linking page and only
|
|
256
|
+
// ever appended to, so the array is never empty.
|
|
255
257
|
const site = pageSite(context, pages[0] as PageSnapshot);
|
|
256
258
|
|
|
257
259
|
const grade = gradeExternal(result);
|