blume 1.4.3 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +16 -12
- package/dist/cli/index.js +1784 -633
- package/dist/cli/index.js.map +111 -106
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/config-input.d.ts +80 -28
- package/dist/types/core/config.d.ts +2 -1
- package/dist/types/core/data.d.ts +19 -3
- package/dist/types/core/diagnostics.d.ts +5 -1
- package/dist/types/core/i18n-ui.d.ts +12 -0
- package/dist/types/core/schema.d.ts +112 -15
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +7 -3
- package/dist/types/core/types.d.ts +43 -2
- package/dist/types/core/ui-packs/index.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +6 -5
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/dist/types/theme/fonts.d.ts +11 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/advanced/custom-pages.mdx +5 -1
- package/docs/configuration/index.mdx +1 -1
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +1 -1
- package/docs/configuration/theming.mdx +4 -2
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/meta.mdx +2 -1
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +35 -1
- package/docs/content/versioning.mdx +106 -0
- package/docs/reference/cli.mdx +2 -1
- package/docs/reference/frontmatter.mdx +3 -0
- package/package.json +3 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/fumadocs.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/agent-readability.ts +37 -10
- package/src/ai/ask-context.ts +5 -1
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +40 -16
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +28 -11
- package/src/ai/mcp/server.ts +183 -38
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +6 -2
- package/src/astro/generate.ts +64 -34
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +16 -9
- package/src/astro/templates.ts +181 -40
- package/src/audit/agent.ts +2 -2
- package/src/audit/checks/content.ts +26 -11
- package/src/audit/checks/dns-aid.ts +3 -0
- package/src/audit/checks/indexability.ts +24 -6
- package/src/audit/checks/llms.ts +9 -4
- package/src/audit/checks/network.ts +2 -0
- package/src/audit/checks/social.ts +18 -10
- package/src/audit/crawl.ts +37 -9
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +5 -2
- package/src/audit/snapshot.ts +2 -4
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +9 -4
- package/src/cli/commands/build.ts +15 -9
- package/src/cli/commands/dev.ts +2 -0
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eval.ts +7 -3
- package/src/cli/commands/init.ts +9 -9
- package/src/cli/commands/mcp-stdio.ts +3 -0
- package/src/cli/commands/translate.ts +14 -3
- package/src/cli/commands/version.ts +85 -0
- package/src/cli/dev-lock.ts +31 -10
- package/src/cli/eject-scripts.ts +17 -2
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +22 -15
- package/src/cli/internal-error.ts +1 -0
- package/src/components/content/auto-type-table.ts +3 -0
- package/src/components/content/diff.ts +9 -5
- package/src/components/content/github-info.ts +2 -0
- package/src/components/islands/ask-ai.tsx +33 -25
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/islands/webmcp.ts +49 -12
- package/src/components/layout/Fonts.astro +23 -3
- 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/PageLayout.astro +72 -3
- package/src/components/layout/ReferenceLayout.astro +2 -1
- package/src/components/layout/RootLayout.astro +20 -1
- package/src/components/layout/Search.astro +77 -13
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/hydration-hint.ts +1 -1
- package/src/components/layout/nav-utils.ts +1 -4
- package/src/components/layout/overrides.ts +25 -12
- package/src/components/layout/search/algolia.ts +18 -5
- package/src/components/layout/search/endpoint.ts +3 -0
- package/src/components/layout/search/flexsearch.ts +23 -7
- package/src/components/layout/search/orama-cloud.ts +1 -1
- package/src/components/layout/search/orama.ts +4 -1
- package/src/components/layout/search/pagefind.ts +2 -0
- package/src/components/layout/search/types.ts +13 -1
- package/src/components/layout/search/typesense.ts +19 -3
- package/src/components/openapi/ApiOverview.astro +32 -6
- package/src/components/openapi/AsyncApiOperation.astro +237 -0
- package/src/components/openapi/Bindings.astro +89 -0
- package/src/components/openapi/MethodBadge.astro +3 -0
- package/src/components/openapi/Operation.astro +7 -2
- package/src/components/openapi/PanelTabs.astro +131 -0
- package/src/components/openapi/ParametersTable.astro +2 -0
- package/src/components/openapi/RequestPanel.astro +12 -119
- package/src/components/openapi/async-snippets.ts +174 -0
- package/src/components/openapi/async.ts +348 -0
- package/src/components/openapi/helpers.ts +52 -20
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +89 -28
- package/src/core/config.ts +20 -7
- package/src/core/content.ts +3 -1
- package/src/core/data.ts +19 -3
- package/src/core/define-components.ts +5 -0
- package/src/core/diagnostics.ts +46 -38
- package/src/core/frontmatter.ts +33 -7
- package/src/core/graph.ts +137 -53
- package/src/core/i18n-ui.ts +15 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/last-modified.ts +49 -0
- package/src/core/load-module.ts +1 -0
- package/src/core/manifest.ts +92 -3
- package/src/core/meta.ts +44 -14
- package/src/core/nav-diagnostics.ts +3 -3
- package/src/core/navigation.ts +247 -67
- package/src/core/project-graph.ts +26 -3
- package/src/core/schema.ts +214 -68
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +6 -0
- package/src/core/sources/github-releases.ts +39 -31
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +67 -20
- package/src/core/sources/notion.ts +49 -17
- package/src/core/sources/portable-text.ts +32 -11
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +4 -0
- package/src/core/sources/watch.ts +1 -1
- package/src/core/standard-schema.ts +9 -3
- package/src/core/text-width.ts +26 -0
- package/src/core/tsconfig-aliases.ts +9 -5
- package/src/core/types.ts +45 -2
- package/src/core/ui-packs/index.ts +9 -1
- package/src/core/version-cut.ts +301 -0
- package/src/core/version.ts +2 -0
- package/src/core/versions.ts +170 -0
- package/src/deploy/adapter-output.ts +5 -2
- package/src/deploy/cloudflare-negotiation.ts +25 -10
- package/src/deploy/sitemap.ts +33 -1
- package/src/deploy/vercel-negotiation.ts +45 -18
- package/src/eval/report.ts +4 -4
- package/src/eval/run.ts +2 -2
- package/src/eval/schema.ts +1 -1
- package/src/markdown/base-links.ts +6 -6
- package/src/markdown/directives.ts +7 -1
- package/src/markdown/heading-anchors.ts +17 -6
- package/src/markdown/index.ts +73 -24
- package/src/markdown/inline-code.ts +14 -2
- package/src/markdown/language-icon.ts +6 -2
- package/src/markdown/mdast.ts +18 -4
- package/src/markdown/package-commands.ts +6 -8
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +33 -12
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +126 -57
- package/src/openapi/parse.ts +97 -5
- package/src/openapi/references.ts +12 -10
- package/src/openapi/render-mdx.ts +73 -34
- package/src/openapi/scalar.ts +6 -8
- package/src/openapi/source.ts +98 -28
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +25 -5
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +66 -20
- package/src/search/popular.ts +10 -5
- package/src/search/providers.ts +2 -2
- package/src/search/sync/index.ts +2 -0
- package/src/search/sync/typesense.ts +4 -2
- package/src/seo/jsonld.ts +24 -6
- package/src/seo/x-handle.ts +8 -3
- package/src/theme/chrome-icons.ts +7 -2
- package/src/theme/entry.ts +24 -2
- package/src/theme/fonts.ts +83 -7
- package/src/theme/icons.ts +4 -2
- package/src/theme/palette.ts +22 -14
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +9 -5
- package/src/translate/run.ts +10 -4
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
package/src/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
|
|
@@ -679,6 +694,11 @@ ${userConfigSetup}export default defineConfig({
|
|
|
679
694
|
},
|
|
680
695
|
},
|
|
681
696
|
devToolbar: { enabled: false },
|
|
697
|
+
// Navigations are full document loads (no client router), so the next page's
|
|
698
|
+
// HTML is fetched on hover/viewport to hide the request latency behind the
|
|
699
|
+
// user's intent. Pairs with the cross-document view-transition rule in the
|
|
700
|
+
// theme sheet, which smooths the swap itself.
|
|
701
|
+
prefetch: { prefetchAll: true },
|
|
682
702
|
vite: {
|
|
683
703
|
plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
|
|
684
704
|
// Everything hydration can reach must be part of the dev dep optimizer's
|
|
@@ -1095,10 +1115,17 @@ export const createSearch = () => create({ indexUrl${
|
|
|
1095
1115
|
} });
|
|
1096
1116
|
`;
|
|
1097
1117
|
|
|
1118
|
+
/**
|
|
1119
|
+
* Public credential fields baked into a hosted provider's generated client
|
|
1120
|
+
* (Algolia/Orama Cloud/Typesense config values from `search.*`, all plain
|
|
1121
|
+
* strings or numbers; `JSON.stringify` drops the absent ones).
|
|
1122
|
+
*/
|
|
1123
|
+
type HostedSearchCredentials = Record<string, string | number | undefined>;
|
|
1124
|
+
|
|
1098
1125
|
/** A client that passes public credentials straight to the provider SDK. */
|
|
1099
1126
|
const hostedSearchClient = (
|
|
1100
1127
|
module: string,
|
|
1101
|
-
options:
|
|
1128
|
+
options: HostedSearchCredentials
|
|
1102
1129
|
): string =>
|
|
1103
1130
|
`${SEARCH_CLIENT_HEADER}${searchClientImport(module)}
|
|
1104
1131
|
export const createSearch = () => create(${JSON.stringify(options)});
|
|
@@ -1107,7 +1134,7 @@ export const createSearch = () => create(${JSON.stringify(options)});
|
|
|
1107
1134
|
/** Build the per-provider config object the hosted client is created with. */
|
|
1108
1135
|
const hostedSearchOptions = (
|
|
1109
1136
|
search: ResolvedConfig["search"]
|
|
1110
|
-
): { module: string; options:
|
|
1137
|
+
): { module: string; options: HostedSearchCredentials } | null => {
|
|
1111
1138
|
switch (search.provider) {
|
|
1112
1139
|
case "algolia": {
|
|
1113
1140
|
return { module: "algolia", options: { ...search.algolia } };
|
|
@@ -1389,7 +1416,9 @@ export const ALL: APIRoute = ({ request }) => handler(request);
|
|
|
1389
1416
|
};
|
|
1390
1417
|
|
|
1391
1418
|
/** Generate a prerendered endpoint that serves a fixed JSON payload. */
|
|
1392
|
-
export const staticJsonEndpointTemplate =
|
|
1419
|
+
export const staticJsonEndpointTemplate = <Payload extends object>(
|
|
1420
|
+
payload: Payload
|
|
1421
|
+
): string =>
|
|
1393
1422
|
`// Generated by Blume. Do not edit.
|
|
1394
1423
|
export const prerender = true;
|
|
1395
1424
|
|
|
@@ -1430,7 +1459,8 @@ export function GET({ props }: { props: { section: string } }) {
|
|
|
1430
1459
|
/** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
|
|
1431
1460
|
export const ogEndpointTemplate = (
|
|
1432
1461
|
customRoutes: OgCustomRoute[] = [],
|
|
1433
|
-
og: { families?: OgFontFamilies; fonts?: OgFont[] } = {}
|
|
1462
|
+
og: { families?: OgFontFamilies; fonts?: OgFont[] } = {},
|
|
1463
|
+
includeChangelog = false
|
|
1434
1464
|
): string =>
|
|
1435
1465
|
`// Generated by Blume. Do not edit.
|
|
1436
1466
|
import { renderOgImage } from "blume/og";
|
|
@@ -1469,6 +1499,13 @@ export function getStaticPaths() {
|
|
|
1469
1499
|
}
|
|
1470
1500
|
for (const route of data.routes) {
|
|
1471
1501
|
add(route.path === "/" ? "index" : route.path.slice(1), route.title);
|
|
1502
|
+
}${
|
|
1503
|
+
includeChangelog
|
|
1504
|
+
? `
|
|
1505
|
+
// The generated changelog index is not a content route, so it needs its own
|
|
1506
|
+
// card. Added last: a custom page or content route owning /changelog wins.
|
|
1507
|
+
add("changelog", data.ui.changelog?.title ?? "Changelog");`
|
|
1508
|
+
: ""
|
|
1472
1509
|
}
|
|
1473
1510
|
return paths;
|
|
1474
1511
|
}
|
|
@@ -1510,8 +1547,9 @@ export async function GET({ props }: { props: { title: string } }) {
|
|
|
1510
1547
|
* live inside our shell. `dataImport` is the route-depth-aware relative path to
|
|
1511
1548
|
* the generated data module the layout reads.
|
|
1512
1549
|
*/
|
|
1513
|
-
export const scalarReferenceTemplate = (options: {
|
|
1514
|
-
|
|
1550
|
+
export const scalarReferenceTemplate = <Configuration extends object>(options: {
|
|
1551
|
+
/** Scalar options forwarded verbatim (spec/theme config plus the author's `scalar` escape hatch). */
|
|
1552
|
+
configuration: Configuration;
|
|
1515
1553
|
dataImport: string;
|
|
1516
1554
|
noindex?: boolean;
|
|
1517
1555
|
route: string;
|
|
@@ -1687,11 +1725,13 @@ export function getStaticPaths() {
|
|
|
1687
1725
|
locale: route.locale,
|
|
1688
1726
|
route: route.path,
|
|
1689
1727
|
title: route.title,
|
|
1728
|
+
version: route.version,
|
|
1729
|
+
versionAlternates: route.versionAlternates,
|
|
1690
1730
|
},
|
|
1691
1731
|
}));
|
|
1692
1732
|
}
|
|
1693
1733
|
|
|
1694
|
-
const { entryId, collection, route, title, indexable, editUrl, lastModified, locale, alternates, fallback } = Astro.props;
|
|
1734
|
+
const { entryId, collection, route, title, indexable, editUrl, lastModified, locale, alternates, fallback, version, versionAlternates } = Astro.props;
|
|
1695
1735
|
const entry = await getEntry(collection as CollectionKey, entryId);
|
|
1696
1736
|
if (!entry) {
|
|
1697
1737
|
return new Response(null, { status: 404 });
|
|
@@ -1723,9 +1763,6 @@ const ogGenerated = !seo.image && Boolean(ogPath);
|
|
|
1723
1763
|
const x = { ...data.config.x, ...(seo.x?.creator ? { creator: seo.x.creator } : {}) };
|
|
1724
1764
|
|
|
1725
1765
|
const basedRoute = withBase(route);
|
|
1726
|
-
const canonical =
|
|
1727
|
-
seo.canonical ??
|
|
1728
|
-
(base ? \`\${base}\${basedRoute === "/" ? "" : encodeURI(basedRoute)}\` : null);
|
|
1729
1766
|
|
|
1730
1767
|
// Locale resolution. With i18n on, pick the active locale's nav + dictionary,
|
|
1731
1768
|
// build hreflang alternates, and derive the language-switcher targets.
|
|
@@ -1746,7 +1783,20 @@ const stripLocale = (path: string, codeArg: string) => {
|
|
|
1746
1783
|
return prefix && path.startsWith(prefix) ? path.slice(prefix.length) || "/" : path;
|
|
1747
1784
|
};
|
|
1748
1785
|
|
|
1749
|
-
|
|
1786
|
+
// Version resolution. An archived page renders its snapshot's navigation tree,
|
|
1787
|
+
// points its canonical at the latest equivalent (unless configured otherwise),
|
|
1788
|
+
// and shows the old-version notice.
|
|
1789
|
+
const versionsConfig = data.config.versions;
|
|
1790
|
+
const archived = versionsConfig && version
|
|
1791
|
+
? (versionsConfig.archived.find((v) => v.id === version) ?? null)
|
|
1792
|
+
: null;
|
|
1793
|
+
const latestVersionAlt = (versionAlternates ?? []).find((alt) => alt.version === "");
|
|
1794
|
+
|
|
1795
|
+
const navigation = version
|
|
1796
|
+
? (data.navigationByVersion[version]?.[i18n ? locale : ""] ?? data.navigation)
|
|
1797
|
+
: i18n
|
|
1798
|
+
? (data.navigationByLocale[locale] ?? data.navigation)
|
|
1799
|
+
: data.navigation;
|
|
1750
1800
|
const ui = i18n ? (data.uiByLocale[locale] ?? data.ui) : data.ui;
|
|
1751
1801
|
const localeMeta = i18n ? i18n.locales.find((l) => l.code === locale) : null;
|
|
1752
1802
|
const dir = localeMeta?.dir ?? "ltr";
|
|
@@ -1758,10 +1808,22 @@ const contentLocale =
|
|
|
1758
1808
|
const contentDir = i18n
|
|
1759
1809
|
? (i18n.locales.find((l) => l.code === contentLocale)?.dir ?? "ltr")
|
|
1760
1810
|
: "ltr";
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1811
|
+
// The root route keeps its trailing slash (\`https://site/\`) so canonical and
|
|
1812
|
+
// hreflang URLs byte-match the sitemap's <loc> for the home page.
|
|
1813
|
+
const absolute = (path: string) => base + withBase(path);
|
|
1814
|
+
|
|
1815
|
+
// An archived page defaults its canonical to the same page in the latest docs
|
|
1816
|
+
// when that page still exists — search engines treat the live page as
|
|
1817
|
+
// authoritative without deindexing version-only content. A page's own
|
|
1818
|
+
// \`seo.canonical\` always wins, and \`canonical: "self"\` keeps the default.
|
|
1819
|
+
const canonical =
|
|
1820
|
+
seo.canonical ??
|
|
1821
|
+
(archived && archived.canonical === "latest" && latestVersionAlt && base
|
|
1822
|
+
? absolute(latestVersionAlt.path)
|
|
1823
|
+
: base
|
|
1824
|
+
? \`\${base}\${basedRoute === "/" ? "/" : encodeURI(basedRoute)}\`
|
|
1825
|
+
: null);
|
|
1826
|
+
const effectiveNoindex = Boolean(seo.noindex) || (archived?.noindex ?? false);
|
|
1765
1827
|
|
|
1766
1828
|
const localeAlternates =
|
|
1767
1829
|
i18n && base
|
|
@@ -1785,6 +1847,72 @@ const localeSwitch = i18n
|
|
|
1785
1847
|
})
|
|
1786
1848
|
: [];
|
|
1787
1849
|
|
|
1850
|
+
// Version switcher + old-version notice. The switcher auto-populates from the
|
|
1851
|
+
// versions config as a \`kind: "version"\` selector; a user-declared version
|
|
1852
|
+
// selector in \`navigation.selectors\` suppresses it (theirs renders instead).
|
|
1853
|
+
// Fallback version roots compose like real routes — \`{basePath}/{locale?}/{id}\`
|
|
1854
|
+
// (manifest \`versionAlternates\` paths arrive with the base already applied).
|
|
1855
|
+
const versionRootFor = (id: string) => {
|
|
1856
|
+
const logical = id ? \`/\${id}\` : "/";
|
|
1857
|
+
const localized = i18n ? localizeRoute(logical, locale) : logical;
|
|
1858
|
+
const mount = data.config.basePath;
|
|
1859
|
+
if (!mount) {
|
|
1860
|
+
return localized;
|
|
1861
|
+
}
|
|
1862
|
+
return localized === "/" ? mount : \`\${mount}\${localized}\`;
|
|
1863
|
+
};
|
|
1864
|
+
const samePageSwitch = versionsConfig
|
|
1865
|
+
? versionsConfig.switcher.redirect === "same-page"
|
|
1866
|
+
: true;
|
|
1867
|
+
const userHasVersionSelector = navigation.selectors.some(
|
|
1868
|
+
(selector) => selector.kind === "version"
|
|
1869
|
+
);
|
|
1870
|
+
const versionSelector =
|
|
1871
|
+
versionsConfig && !userHasVersionSelector
|
|
1872
|
+
? {
|
|
1873
|
+
items: [
|
|
1874
|
+
{
|
|
1875
|
+
id: "",
|
|
1876
|
+
label: versionsConfig.current.label,
|
|
1877
|
+
tag: versionsConfig.current.badge,
|
|
1878
|
+
},
|
|
1879
|
+
...versionsConfig.archived.map((v) => ({
|
|
1880
|
+
id: v.id,
|
|
1881
|
+
label: v.label ?? v.id,
|
|
1882
|
+
tag: undefined,
|
|
1883
|
+
})),
|
|
1884
|
+
].map((entry) => {
|
|
1885
|
+
const alt = (versionAlternates ?? []).find(
|
|
1886
|
+
(a) => a.version === entry.id
|
|
1887
|
+
);
|
|
1888
|
+
return {
|
|
1889
|
+
label: entry.label,
|
|
1890
|
+
path: samePageSwitch && alt ? alt.path : versionRootFor(entry.id),
|
|
1891
|
+
...(entry.tag ? { tag: entry.tag } : {}),
|
|
1892
|
+
};
|
|
1893
|
+
}),
|
|
1894
|
+
kind: "version" as const,
|
|
1895
|
+
label: ui.versions.switcher,
|
|
1896
|
+
}
|
|
1897
|
+
: null;
|
|
1898
|
+
|
|
1899
|
+
const versionNotice =
|
|
1900
|
+
archived && archived.banner !== false
|
|
1901
|
+
? {
|
|
1902
|
+
latestHref: latestVersionAlt
|
|
1903
|
+
? latestVersionAlt.path
|
|
1904
|
+
: versionRootFor(""),
|
|
1905
|
+
latestLabel: ui.versions.latest,
|
|
1906
|
+
message:
|
|
1907
|
+
typeof archived.banner === "string"
|
|
1908
|
+
? archived.banner
|
|
1909
|
+
: ui.versions.notice.replace(
|
|
1910
|
+
"{version}",
|
|
1911
|
+
archived.label ?? archived.id
|
|
1912
|
+
),
|
|
1913
|
+
}
|
|
1914
|
+
: null;
|
|
1915
|
+
|
|
1788
1916
|
// The whole page shell is overridable via \`layout.Layout\`; it receives the same
|
|
1789
1917
|
// props as the built-in RootLayout, plus the \`layout\` map for its inner slots.
|
|
1790
1918
|
const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
@@ -1809,6 +1937,9 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1809
1937
|
localeAlternates={localeAlternates}
|
|
1810
1938
|
xDefault={xDefault}
|
|
1811
1939
|
localeSwitch={localeSwitch}
|
|
1940
|
+
versionSelector={versionSelector}
|
|
1941
|
+
versionNotice={versionNotice}
|
|
1942
|
+
searchVersion={versionsConfig ? version : null}
|
|
1812
1943
|
page={{ title: seo.title ?? title, description: seo.description ?? frontmatter.description, route }}
|
|
1813
1944
|
headings={headings}
|
|
1814
1945
|
toc={data.config.toc}
|
|
@@ -1831,7 +1962,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
1831
1962
|
pageType={frontmatter.type}
|
|
1832
1963
|
published={frontmatter.date ?? frontmatter.changelog?.date ?? null}
|
|
1833
1964
|
lastModified={lastModified}
|
|
1834
|
-
noindex={
|
|
1965
|
+
noindex={effectiveNoindex}
|
|
1835
1966
|
structuredDataEnabled={data.config.structuredData}
|
|
1836
1967
|
>
|
|
1837
1968
|
<h1>{title}</h1>
|
|
@@ -2003,6 +2134,12 @@ const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
|
|
|
2003
2134
|
const basedRoute = withBase("/changelog");
|
|
2004
2135
|
const canonical = base ? base + basedRoute : null;
|
|
2005
2136
|
|
|
2137
|
+
// The generated OG card for this route (the /og endpoint emits it alongside
|
|
2138
|
+
// the content-route cards), absolutized like the catch-all's so crawlers get
|
|
2139
|
+
// a full URL when the site is known.
|
|
2140
|
+
const ogPath = data.config.og.enabled ? withBase("/og/changelog.png") : null;
|
|
2141
|
+
const ogImage = ogPath && base ? base + ogPath : ogPath;
|
|
2142
|
+
|
|
2006
2143
|
// The page chrome (h1, title, description) comes from the same translatable
|
|
2007
2144
|
// \`changelog\` group as the reveal button; optional chaining tolerates a
|
|
2008
2145
|
// not-yet-regenerated data snapshot from before these keys existed.
|
|
@@ -2010,7 +2147,10 @@ const changelogTitle = data.ui.changelog?.title ?? "Changelog";
|
|
|
2010
2147
|
const changelogDescription =
|
|
2011
2148
|
data.ui.changelog?.description ??
|
|
2012
2149
|
"Product updates, new features, and fixes from every release.";
|
|
2013
|
-
|
|
2150
|
+
// The layout suffixes "- {site title}" itself, so the page title is just the
|
|
2151
|
+
// changelog's own name — prefixing the site title too would double it
|
|
2152
|
+
// ("Acme Changelog - Acme").
|
|
2153
|
+
const pageTitle = changelogTitle;
|
|
2014
2154
|
|
|
2015
2155
|
const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
2016
2156
|
---
|
|
@@ -2042,7 +2182,8 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
2042
2182
|
fontCssVars={data.fontCssVars}
|
|
2043
2183
|
searchEnabled={data.config.search.enabled}
|
|
2044
2184
|
indexable={true}
|
|
2045
|
-
ogImage={
|
|
2185
|
+
ogImage={ogImage}
|
|
2186
|
+
ogGenerated={Boolean(ogImage)}
|
|
2046
2187
|
x={data.config.x}
|
|
2047
2188
|
canonical={canonical}
|
|
2048
2189
|
exportPdf={${options.exportPdf}}
|
package/src/audit/agent.ts
CHANGED
|
@@ -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
|
|
@@ -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);
|
|
@@ -89,6 +89,18 @@ export const socialChecks: CheckModule = {
|
|
|
89
89
|
tier: "static",
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
/** The JSON-LD keywords the audit reads off a parsed block. */
|
|
93
|
+
interface JsonLdNode {
|
|
94
|
+
"@context"?: unknown;
|
|
95
|
+
"@graph"?: unknown;
|
|
96
|
+
"@type"?: unknown;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether one parsed JSON-LD value is an object the keyword checks can read. */
|
|
100
|
+
const isJsonLdNode = (
|
|
101
|
+
value: PageSnapshot["jsonld"][number]
|
|
102
|
+
): value is JsonLdNode => typeof value === "object" && value !== null;
|
|
103
|
+
|
|
92
104
|
/**
|
|
93
105
|
* What's missing from one JSON-LD block.
|
|
94
106
|
*
|
|
@@ -98,28 +110,24 @@ export const socialChecks: CheckModule = {
|
|
|
98
110
|
* and each entry carries its own `@type` — so demanding `@type` on the root, or
|
|
99
111
|
* `@context` on each entry, would flag perfectly valid structured data.
|
|
100
112
|
*/
|
|
101
|
-
const jsonLdProblems = (node:
|
|
102
|
-
if (
|
|
113
|
+
const jsonLdProblems = (node: PageSnapshot["jsonld"][number]): string[] => {
|
|
114
|
+
if (!isJsonLdNode(node)) {
|
|
103
115
|
return ["it is not an object"];
|
|
104
116
|
}
|
|
105
|
-
const record = node as Record<string, unknown>;
|
|
106
117
|
const problems: string[] = [];
|
|
107
|
-
if (!
|
|
118
|
+
if (!node["@context"]) {
|
|
108
119
|
problems.push("@context");
|
|
109
120
|
}
|
|
110
121
|
|
|
111
|
-
const graph =
|
|
122
|
+
const graph = node["@graph"];
|
|
112
123
|
if (Array.isArray(graph)) {
|
|
113
124
|
const untyped = graph.filter(
|
|
114
|
-
(entry) =>
|
|
115
|
-
typeof entry !== "object" ||
|
|
116
|
-
entry === null ||
|
|
117
|
-
!(entry as Record<string, unknown>)["@type"]
|
|
125
|
+
(entry) => !isJsonLdNode(entry) || !entry["@type"]
|
|
118
126
|
).length;
|
|
119
127
|
if (untyped > 0) {
|
|
120
128
|
problems.push(`@type on ${untyped} of its ${graph.length} @graph nodes`);
|
|
121
129
|
}
|
|
122
|
-
} else if (!
|
|
130
|
+
} else if (!node["@type"]) {
|
|
123
131
|
problems.push("@type");
|
|
124
132
|
}
|
|
125
133
|
|