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
|
@@ -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
|
|
package/src/audit/crawl.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
|
|
3
3
|
import { XMLParser } from "fast-xml-parser";
|
|
4
|
+
import type { Nodes } from "mdast";
|
|
5
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
6
|
+
import pMap from "p-map";
|
|
4
7
|
import { join, relative } from "pathe";
|
|
5
8
|
import { glob } from "tinyglobby";
|
|
6
9
|
|
|
@@ -18,6 +21,13 @@ import type { LlmsDoc, PageSnapshot, RobotsDoc, SitemapDoc } from "./types.ts";
|
|
|
18
21
|
*/
|
|
19
22
|
const EXAMPLES_PREFIX = `${examplesRouteBase("")}/`;
|
|
20
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Ceiling on concurrent file reads/stats while crawling. Unbounded fan-out
|
|
26
|
+
* over a large `dist` risks EMFILE and holds every page's HTML in memory at
|
|
27
|
+
* once.
|
|
28
|
+
*/
|
|
29
|
+
const CRAWL_CONCURRENCY = 16;
|
|
30
|
+
|
|
21
31
|
/** Everything read off disk in one pass over the built site. */
|
|
22
32
|
export interface CrawlResult {
|
|
23
33
|
pages: PageSnapshot[];
|
|
@@ -45,12 +55,14 @@ export const fileToUrl = (staticDir: string, file: string): string => {
|
|
|
45
55
|
*/
|
|
46
56
|
const indexFiles = async (staticDir: string): Promise<Map<string, number>> => {
|
|
47
57
|
const found = await glob("**/*", { cwd: staticDir, dot: true });
|
|
48
|
-
const sized = await
|
|
49
|
-
found
|
|
58
|
+
const sized = await pMap(
|
|
59
|
+
found,
|
|
60
|
+
async (file) => {
|
|
50
61
|
const path = `/${file.replaceAll("\\", "/")}`;
|
|
51
62
|
const info = await stat(join(staticDir, file));
|
|
52
63
|
return [path, info.size] as const;
|
|
53
|
-
}
|
|
64
|
+
},
|
|
65
|
+
{ concurrency: CRAWL_CONCURRENCY }
|
|
54
66
|
);
|
|
55
67
|
return new Map(sized);
|
|
56
68
|
};
|
|
@@ -91,6 +103,35 @@ const sitemapParser = new XMLParser({
|
|
|
91
103
|
removeNSPrefix: true,
|
|
92
104
|
});
|
|
93
105
|
|
|
106
|
+
/**
|
|
107
|
+
* What fast-xml-parser produces for a parsed element: a string for text
|
|
108
|
+
* content (`parseTagValue: false`), an object of child elements, an array for
|
|
109
|
+
* a repeated element, or null for a self-closed one.
|
|
110
|
+
*/
|
|
111
|
+
type XmlValue =
|
|
112
|
+
| string
|
|
113
|
+
| number
|
|
114
|
+
| boolean
|
|
115
|
+
| null
|
|
116
|
+
| undefined
|
|
117
|
+
| XmlValue[]
|
|
118
|
+
| { [element: string]: XmlValue };
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* fast-xml-parser models an element with child elements as an object; a
|
|
122
|
+
* text-only or self-closed element parses to a string or null instead, which
|
|
123
|
+
* these guards reject the same way the crawler always has.
|
|
124
|
+
*/
|
|
125
|
+
const isUrlsetElement = (value: XmlValue): value is { url?: XmlValue } =>
|
|
126
|
+
typeof value === "object" && value !== null;
|
|
127
|
+
|
|
128
|
+
const isUrlEntry = (
|
|
129
|
+
value: XmlValue
|
|
130
|
+
): value is { lastmod?: XmlValue; loc?: XmlValue } =>
|
|
131
|
+
typeof value === "object" && value !== null;
|
|
132
|
+
|
|
133
|
+
const isText = (value: XmlValue): value is string => typeof value === "string";
|
|
134
|
+
|
|
94
135
|
/**
|
|
95
136
|
* Parse `sitemap.xml`. Deliberately shallow: the checks only need the `<loc>`
|
|
96
137
|
* list, each loc's `<lastmod>`, and whether the document is a urlset at all.
|
|
@@ -101,9 +142,9 @@ export const parseSitemap = (
|
|
|
101
142
|
bytes: number
|
|
102
143
|
): SitemapDoc => {
|
|
103
144
|
const doc: SitemapDoc = { bytes, file, lastmod: new Map(), urls: [] };
|
|
104
|
-
let parsed:
|
|
145
|
+
let parsed: { sitemapindex?: XmlValue; urlset?: XmlValue };
|
|
105
146
|
try {
|
|
106
|
-
parsed = sitemapParser.parse(xml)
|
|
147
|
+
parsed = sitemapParser.parse(xml);
|
|
107
148
|
} catch {
|
|
108
149
|
doc.error = "no <urlset> element";
|
|
109
150
|
return doc;
|
|
@@ -114,20 +155,19 @@ export const parseSitemap = (
|
|
|
114
155
|
: "no <urlset> element";
|
|
115
156
|
return doc;
|
|
116
157
|
}
|
|
117
|
-
const
|
|
118
|
-
const entries =
|
|
119
|
-
typeof urlset === "object" && urlset !== null ? [urlset.url].flat() : [];
|
|
158
|
+
const { urlset } = parsed;
|
|
159
|
+
const entries = isUrlsetElement(urlset) ? [urlset.url].flat() : [];
|
|
120
160
|
for (const entry of entries) {
|
|
121
|
-
if (
|
|
161
|
+
if (!isUrlEntry(entry)) {
|
|
122
162
|
continue;
|
|
123
163
|
}
|
|
124
|
-
const { loc, lastmod } = entry
|
|
125
|
-
const locText =
|
|
164
|
+
const { loc, lastmod } = entry;
|
|
165
|
+
const locText = isText(loc) ? loc.trim() : "";
|
|
126
166
|
if (!locText) {
|
|
127
167
|
continue;
|
|
128
168
|
}
|
|
129
169
|
doc.urls.push(locText);
|
|
130
|
-
if (
|
|
170
|
+
if (isText(lastmod) && lastmod.trim() !== "") {
|
|
131
171
|
doc.lastmod?.set(locText, lastmod.trim());
|
|
132
172
|
}
|
|
133
173
|
}
|
|
@@ -135,21 +175,32 @@ export const parseSitemap = (
|
|
|
135
175
|
};
|
|
136
176
|
|
|
137
177
|
/**
|
|
138
|
-
* Parse the `llms.txt` index into its Markdown link targets
|
|
139
|
-
*
|
|
140
|
-
*
|
|
178
|
+
* Parse the `llms.txt` index into its Markdown link targets, with the line
|
|
179
|
+
* each target sits on so findings can point at it. A real parse rather than a
|
|
180
|
+
* `](url)` regex: angle-bracket destinations, link titles, reference-style
|
|
181
|
+
* links (the definition line carries the URL), and autolinks all resolve, and
|
|
182
|
+
* a link-shaped string inside a fenced code block is no longer reported as a
|
|
183
|
+
* claim. Blume's own llms.txt only emits inline links, but the file is also
|
|
184
|
+
* hand-edited.
|
|
141
185
|
*/
|
|
142
186
|
export const parseLlms = (file: string, text: string): LlmsDoc => {
|
|
143
187
|
const entries: LlmsDoc["entries"] = [];
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
188
|
+
const collect = (node: Nodes): void => {
|
|
189
|
+
if (
|
|
190
|
+
(node.type === "link" ||
|
|
191
|
+
node.type === "image" ||
|
|
192
|
+
node.type === "definition") &&
|
|
193
|
+
node.url
|
|
194
|
+
) {
|
|
195
|
+
entries.push({ line: node.position?.start.line ?? 1, url: node.url });
|
|
196
|
+
}
|
|
197
|
+
if ("children" in node) {
|
|
198
|
+
for (const child of node.children) {
|
|
199
|
+
collect(child);
|
|
150
200
|
}
|
|
151
201
|
}
|
|
152
|
-
}
|
|
202
|
+
};
|
|
203
|
+
collect(fromMarkdown(text));
|
|
153
204
|
return { entries, file };
|
|
154
205
|
};
|
|
155
206
|
|
|
@@ -214,8 +265,9 @@ export const crawlStaticDir = async (options: {
|
|
|
214
265
|
const routes = routeIndex(manifest, basePath);
|
|
215
266
|
|
|
216
267
|
const htmlFiles = await glob("**/*.html", { absolute: true, cwd: staticDir });
|
|
217
|
-
const snapshots = await
|
|
218
|
-
htmlFiles.toSorted()
|
|
268
|
+
const snapshots = await pMap(
|
|
269
|
+
htmlFiles.toSorted(),
|
|
270
|
+
async (file) => {
|
|
219
271
|
const url = fileToUrl(staticDir, file);
|
|
220
272
|
if (stripBasePath(basePath, url).startsWith(EXAMPLES_PREFIX)) {
|
|
221
273
|
return null;
|
|
@@ -230,7 +282,8 @@ export const crawlStaticDir = async (options: {
|
|
|
230
282
|
route: routes.get(url) ?? routes.get(stripBasePath(basePath, url)),
|
|
231
283
|
url,
|
|
232
284
|
});
|
|
233
|
-
}
|
|
285
|
+
},
|
|
286
|
+
{ concurrency: CRAWL_CONCURRENCY }
|
|
234
287
|
);
|
|
235
288
|
const pages = snapshots.filter((page) => page !== null);
|
|
236
289
|
|
package/src/audit/report.ts
CHANGED
|
@@ -9,26 +9,26 @@ import type { CheckId } from "./catalog.ts";
|
|
|
9
9
|
import type { AuditResult } from "./run.ts";
|
|
10
10
|
import type { AuditCategory, AuditTier } from "./types.ts";
|
|
11
11
|
|
|
12
|
-
const SEVERITY_COLOR
|
|
12
|
+
const SEVERITY_COLOR = {
|
|
13
13
|
error: colors.red,
|
|
14
14
|
info: colors.blue,
|
|
15
15
|
warning: colors.yellow,
|
|
16
|
-
}
|
|
16
|
+
} satisfies Record<DiagnosticSeverity, ColorFunction>;
|
|
17
17
|
|
|
18
|
-
const GLYPH
|
|
18
|
+
const GLYPH = {
|
|
19
19
|
error: "✖",
|
|
20
20
|
info: "ℹ",
|
|
21
21
|
warning: "⚠",
|
|
22
|
-
}
|
|
22
|
+
} satisfies Record<DiagnosticSeverity, string>;
|
|
23
23
|
|
|
24
24
|
/** How many affected pages to list before collapsing the rest. */
|
|
25
25
|
const PREVIEW = 3;
|
|
26
26
|
|
|
27
|
-
/** The
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
/** The skippable tiers, each with the flag that runs it. */
|
|
28
|
+
const TIER_FLAGS: { tier: AuditTier; flag: string }[] = [
|
|
29
|
+
{ flag: "--external", tier: "external" },
|
|
30
|
+
{ flag: "--url <origin>", tier: "network" },
|
|
31
|
+
];
|
|
32
32
|
|
|
33
33
|
interface CheckRollup {
|
|
34
34
|
id: CheckId;
|
|
@@ -55,18 +55,21 @@ export const rollup = (diagnostics: Diagnostic[]): CheckRollup[] => {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
const order
|
|
58
|
+
const order = {
|
|
59
59
|
error: 0,
|
|
60
60
|
info: 2,
|
|
61
61
|
warning: 1,
|
|
62
|
-
}
|
|
62
|
+
} satisfies Record<DiagnosticSeverity, number>;
|
|
63
63
|
const checks = [...groups.entries()].map(([id, findings]) => {
|
|
64
|
-
|
|
64
|
+
// SAFETY: audit findings are only ever created through `finding()`, whose
|
|
65
|
+
// codes are the check catalog's ids.
|
|
66
|
+
const checkId = id as CheckId;
|
|
67
|
+
const { category, severity, title } = checkMeta(checkId);
|
|
65
68
|
return {
|
|
66
69
|
category,
|
|
67
70
|
count: findings.length,
|
|
68
71
|
findings,
|
|
69
|
-
id:
|
|
72
|
+
id: checkId,
|
|
70
73
|
severity,
|
|
71
74
|
title,
|
|
72
75
|
};
|
|
@@ -95,12 +98,10 @@ export const rollup = (diagnostics: Diagnostic[]): CheckRollup[] => {
|
|
|
95
98
|
|
|
96
99
|
/** Categories that had no findings but were never run, and the flag that runs them. */
|
|
97
100
|
const skippedTiers = (tiers: Record<AuditTier, boolean>): string[] =>
|
|
98
|
-
|
|
99
|
-
.filter((
|
|
100
|
-
.
|
|
101
|
-
|
|
102
|
-
return ` ${colors.dim(`⊘ ${tier.padEnd(12)} skipped — pass ${TIER_FLAG[tier]} (${label} checks)`)}`;
|
|
103
|
-
});
|
|
101
|
+
TIER_FLAGS.filter(({ tier }) => !tiers[tier]).map(({ flag, tier }) => {
|
|
102
|
+
const label = CHECKS.filter((check) => check.tier === tier).length;
|
|
103
|
+
return ` ${colors.dim(`⊘ ${tier.padEnd(12)} skipped — pass ${flag} (${label} checks)`)}`;
|
|
104
|
+
});
|
|
104
105
|
|
|
105
106
|
/** How many checks actually ran, i.e. those whose tier was enabled. */
|
|
106
107
|
const activeChecks = (tiers: Record<AuditTier, boolean>): number =>
|
package/src/audit/run.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
+
import pMap from "p-map";
|
|
4
|
+
|
|
3
5
|
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
4
6
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
5
7
|
import type { Diagnostic } from "../core/types.ts";
|
|
@@ -88,6 +90,9 @@ export class NoBuildError extends Error {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/** Ceiling on concurrent source reads; unbounded fan-out risks EMFILE. */
|
|
94
|
+
const READ_CONCURRENCY = 16;
|
|
95
|
+
|
|
91
96
|
/** Read every page's source file once, so findings can cite front matter lines. */
|
|
92
97
|
const readSources = async (
|
|
93
98
|
pages: PageSnapshot[]
|
|
@@ -95,8 +100,9 @@ const readSources = async (
|
|
|
95
100
|
const paths = [
|
|
96
101
|
...new Set(pages.flatMap((page) => (page.source ? [page.source] : []))),
|
|
97
102
|
];
|
|
98
|
-
const entries = await
|
|
99
|
-
paths
|
|
103
|
+
const entries = await pMap(
|
|
104
|
+
paths,
|
|
105
|
+
async (path) => {
|
|
100
106
|
try {
|
|
101
107
|
return [path, await readFile(path, "utf-8")] as const;
|
|
102
108
|
} catch {
|
|
@@ -104,7 +110,8 @@ const readSources = async (
|
|
|
104
110
|
// still names the URL; it just can't cite a line.
|
|
105
111
|
return null;
|
|
106
112
|
}
|
|
107
|
-
}
|
|
113
|
+
},
|
|
114
|
+
{ concurrency: READ_CONCURRENCY }
|
|
108
115
|
);
|
|
109
116
|
return new Map(entries.filter((entry) => entry !== null));
|
|
110
117
|
};
|
|
@@ -176,11 +183,11 @@ export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
|
|
|
176
183
|
thresholds: DEFAULT_THRESHOLDS,
|
|
177
184
|
};
|
|
178
185
|
|
|
179
|
-
const tiers
|
|
186
|
+
const tiers = {
|
|
180
187
|
external: Boolean(options.external),
|
|
181
188
|
network: origin !== null,
|
|
182
189
|
static: true,
|
|
183
|
-
}
|
|
190
|
+
} satisfies Record<AuditTier, boolean>;
|
|
184
191
|
|
|
185
192
|
const results = await Promise.all(
|
|
186
193
|
MODULES.filter((module) => tiers[module.tier]).map((module) =>
|
|
@@ -190,11 +197,14 @@ export const runAudit = async (options: AuditOptions): Promise<AuditResult> => {
|
|
|
190
197
|
|
|
191
198
|
let diagnostics = results.flat();
|
|
192
199
|
if (options.only?.length) {
|
|
200
|
+
// SAFETY: audit diagnostics are created through `finding()`, whose codes
|
|
201
|
+
// all come from the check catalog's `CheckId` set.
|
|
193
202
|
diagnostics = diagnostics.filter((d) =>
|
|
194
203
|
matches(d.code as CheckId, options.only ?? [])
|
|
195
204
|
);
|
|
196
205
|
}
|
|
197
206
|
if (options.skip?.length) {
|
|
207
|
+
// SAFETY: same invariant — every audit diagnostic code is a catalog `CheckId`.
|
|
198
208
|
diagnostics = diagnostics.filter(
|
|
199
209
|
(d) => !matches(d.code as CheckId, options.skip ?? [])
|
|
200
210
|
);
|
package/src/audit/snapshot.ts
CHANGED
|
@@ -8,6 +8,30 @@ import type { PageSnapshot, SnapshotAsset, SnapshotLink } from "./types.ts";
|
|
|
8
8
|
/** Site chrome: links here are navigation, not editorial. */
|
|
9
9
|
const CHROME = "nav, aside, header, footer";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Count prose words with `Intl.Segmenter`, seeded with the page's declared
|
|
13
|
+
* language. The whitespace split this replaces counted a fully written
|
|
14
|
+
* Japanese or Chinese page — scripts with no interword spaces — as a handful
|
|
15
|
+
* of "words", tripping BLUME_AUDIT_LOW_WORD_COUNT on every page of a CJK
|
|
16
|
+
* site. An unparseable `lang` attribute falls back to the default locale
|
|
17
|
+
* rather than failing the crawl.
|
|
18
|
+
*/
|
|
19
|
+
const countWords = (prose: string, lang: string | null): number => {
|
|
20
|
+
let segmenter: Intl.Segmenter;
|
|
21
|
+
try {
|
|
22
|
+
segmenter = new Intl.Segmenter(lang ?? undefined, { granularity: "word" });
|
|
23
|
+
} catch {
|
|
24
|
+
segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
25
|
+
}
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const segment of segmenter.segment(prose)) {
|
|
28
|
+
if (segment.isWordLike) {
|
|
29
|
+
count += 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return count;
|
|
33
|
+
};
|
|
34
|
+
|
|
11
35
|
/** Where a page's prose lives, in preference order. */
|
|
12
36
|
const CONTENT_ROOTS = ["main", "article", "body"];
|
|
13
37
|
|
|
@@ -60,9 +84,7 @@ const collectAssets = (
|
|
|
60
84
|
.filter((asset) => asset.src.length > 0);
|
|
61
85
|
|
|
62
86
|
/** Parse each JSON-LD block, keeping the parse failures rather than dropping them. */
|
|
63
|
-
const collectJsonLd = (
|
|
64
|
-
document: HtmlDocument
|
|
65
|
-
): { jsonld: unknown[]; jsonldErrors: string[] } => {
|
|
87
|
+
const collectJsonLd = (document: HtmlDocument) => {
|
|
66
88
|
const jsonld: unknown[] = [];
|
|
67
89
|
const jsonldErrors: string[] = [];
|
|
68
90
|
for (const script of document.querySelectorAll(
|
|
@@ -82,7 +104,7 @@ const prefixedMeta = (
|
|
|
82
104
|
document: HtmlDocument,
|
|
83
105
|
keyAttr: "property" | "name",
|
|
84
106
|
prefix: string
|
|
85
|
-
)
|
|
107
|
+
) => {
|
|
86
108
|
const found: Record<string, string> = {};
|
|
87
109
|
for (const element of document.querySelectorAll(
|
|
88
110
|
`meta[${keyAttr}^="${prefix}"]`
|
|
@@ -123,6 +145,7 @@ export const buildSnapshot = (options: {
|
|
|
123
145
|
const root = contentRoot(document);
|
|
124
146
|
const prose = root ? visibleText(root) : "";
|
|
125
147
|
const robots = document.querySelector('meta[name="robots"]');
|
|
148
|
+
const lang = attr(document.querySelector("html") ?? document, "lang");
|
|
126
149
|
const { jsonld, jsonldErrors } = collectJsonLd(document);
|
|
127
150
|
|
|
128
151
|
return {
|
|
@@ -160,7 +183,7 @@ export const buildSnapshot = (options: {
|
|
|
160
183
|
indexable: !robots?.getAttribute("content")?.includes("noindex"),
|
|
161
184
|
jsonld,
|
|
162
185
|
jsonldErrors,
|
|
163
|
-
lang
|
|
186
|
+
lang,
|
|
164
187
|
links,
|
|
165
188
|
metaRefresh:
|
|
166
189
|
document
|
|
@@ -184,6 +207,6 @@ export const buildSnapshot = (options: {
|
|
|
184
207
|
.querySelector('meta[name="viewport"]')
|
|
185
208
|
?.getAttribute("content")
|
|
186
209
|
?.trim() ?? null,
|
|
187
|
-
wordCount: prose
|
|
210
|
+
wordCount: countWords(prose, lang),
|
|
188
211
|
};
|
|
189
212
|
};
|
package/src/audit/types.ts
CHANGED
|
@@ -161,11 +161,32 @@ export interface LinkGraph {
|
|
|
161
161
|
/** Astro's reserved error routes. Never indexable, never crawlable — by design. */
|
|
162
162
|
export const ERROR_ROUTES: ReadonlySet<string> = new Set(["/404", "/500"]);
|
|
163
163
|
|
|
164
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Tunable limits. Not yet configurable — CLI-only until the ids settle.
|
|
166
|
+
*
|
|
167
|
+
* The title and description limits are measured in **display columns**, where
|
|
168
|
+
* a fullwidth or wide character counts 2 and everything else 1 — not in
|
|
169
|
+
* characters. What a search engine truncates is the space the text takes up,
|
|
170
|
+
* and a character count only stands in for that where every character is one
|
|
171
|
+
* column wide, which is true of Latin text and of nothing else. Counted in
|
|
172
|
+
* characters one range cannot serve both scripts: it is at once too strict for
|
|
173
|
+
* a Japanese description (which says in ~60 characters what English needs ~120
|
|
174
|
+
* for, and so reads as "too short") and too loose for a Japanese title (60
|
|
175
|
+
* characters render as wide as 120 Latin ones, and truncate). ASCII and
|
|
176
|
+
* precomposed (NFC) Latin text score identically either way; a decomposed
|
|
177
|
+
* (NFD) combining mark counts 0 where a character count saw 1. East-Asian
|
|
178
|
+
* "ambiguous"-width characters (★ ※ ①) count 1 per `string-width`'s default,
|
|
179
|
+
* though CJK fonts render them fullwidth — resolving that would need the
|
|
180
|
+
* page's language, which the audit does not thread through yet.
|
|
181
|
+
*/
|
|
165
182
|
export interface AuditThresholds {
|
|
183
|
+
/** Display columns, not characters. */
|
|
166
184
|
titleMin: number;
|
|
185
|
+
/** Display columns, not characters. */
|
|
167
186
|
titleMax: number;
|
|
187
|
+
/** Display columns, not characters. */
|
|
168
188
|
descriptionMin: number;
|
|
189
|
+
/** Display columns, not characters. */
|
|
169
190
|
descriptionMax: number;
|
|
170
191
|
minWordCount: number;
|
|
171
192
|
maxHtmlBytes: number;
|
|
@@ -174,8 +195,9 @@ export interface AuditThresholds {
|
|
|
174
195
|
}
|
|
175
196
|
|
|
176
197
|
export const DEFAULT_THRESHOLDS: AuditThresholds = {
|
|
177
|
-
// Ahrefs' guidance: 110–160
|
|
178
|
-
//
|
|
198
|
+
// Ahrefs' guidance: 110–160. Under ~110 wastes the snippet space search
|
|
199
|
+
// results give you; over ~160 gets truncated. Stated there in characters of
|
|
200
|
+
// English, which is the same number of columns.
|
|
179
201
|
descriptionMax: 160,
|
|
180
202
|
descriptionMin: 110,
|
|
181
203
|
maxAssetBytes: 500 * 1024,
|
package/src/blume-modules.d.ts
CHANGED
|
@@ -17,7 +17,10 @@ declare module "blume:search-client" {
|
|
|
17
17
|
|
|
18
18
|
declare module "blume:ask" {
|
|
19
19
|
/** The generated Ask trigger (see `askComponentTemplate`); empty when Ask is off. */
|
|
20
|
-
|
|
20
|
+
// Mirrors the generated `.blume/src/env.d.ts` declaration: the trigger's type
|
|
21
|
+
// is the AskAI island the enabled template re-exports.
|
|
22
|
+
// oxlint-disable-next-line typescript/consistent-type-imports
|
|
23
|
+
const Ask: typeof import("./components/islands/AskAI.astro").default;
|
|
21
24
|
export default Ask;
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -34,6 +37,7 @@ declare module "blume:data" {
|
|
|
34
37
|
// `tsc` (where the Astro TS plugin isn't active). Not shipped in `dist/types`, so
|
|
35
38
|
// consumers keep Astro's real `.astro` types and get the true prop shapes.
|
|
36
39
|
declare module "*.astro" {
|
|
40
|
+
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type, anti-slop/no-unknown-returns -- a wildcard shim spans every `.astro` component, whose prop shapes and render output only Astro's own TS plugin can type
|
|
37
41
|
const component: (props: Record<string, unknown>) => unknown;
|
|
38
42
|
export default component;
|
|
39
43
|
}
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
AGENTS,
|
|
5
5
|
fixPrompt,
|
|
6
6
|
launchAgent,
|
|
7
|
-
WINDOWS_COMMAND_NOT_FOUND,
|
|
8
7
|
writeAgentReport,
|
|
9
8
|
} from "../../audit/agent.ts";
|
|
10
9
|
import type { AgentKind } from "../../audit/agent.ts";
|
|
@@ -41,22 +40,29 @@ export const shouldFail = (
|
|
|
41
40
|
};
|
|
42
41
|
|
|
43
42
|
/**
|
|
44
|
-
* Launch the agent CLI,
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* Launch the agent CLI, turning a missing executable into the install hint.
|
|
44
|
+
* Only `ENOENT` means "not installed" — any other spawn failure (`EACCES`,
|
|
45
|
+
* `EMFILE`, …) must surface as itself, not be masked by an irrelevant
|
|
46
|
+
* install hint.
|
|
48
47
|
*/
|
|
49
48
|
const launchAgentCode = async (
|
|
50
|
-
|
|
49
|
+
agent: AgentKind,
|
|
51
50
|
prompt: string
|
|
52
51
|
): Promise<number> => {
|
|
52
|
+
const cli = AGENTS[agent];
|
|
53
53
|
try {
|
|
54
|
-
return await launchAgent(bin, prompt);
|
|
54
|
+
return await launchAgent(cli.bin, prompt);
|
|
55
55
|
} catch (error) {
|
|
56
|
+
// SAFETY: only the `code` tag is inspected; a spawn failure throws an
|
|
57
|
+
// ErrnoException, and any other thrown value fails the comparison and
|
|
58
|
+
// rethrows unchanged.
|
|
56
59
|
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
|
57
60
|
throw error;
|
|
58
61
|
}
|
|
59
|
-
|
|
62
|
+
logger.error(
|
|
63
|
+
`${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`
|
|
64
|
+
);
|
|
65
|
+
return process.exit(1);
|
|
60
66
|
}
|
|
61
67
|
};
|
|
62
68
|
|
|
@@ -120,14 +126,16 @@ export const auditCommand = defineCommand({
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
const root = process.cwd();
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
if (
|
|
129
|
+
const gateInput = args["fail-on"] ?? (args.strict ? "warning" : "error");
|
|
130
|
+
const gate = SEVERITIES.find((severity) => severity === gateInput);
|
|
131
|
+
if (gate === undefined) {
|
|
126
132
|
logger.error(
|
|
127
|
-
`Invalid --fail-on "${
|
|
133
|
+
`Invalid --fail-on "${gateInput}" (use ${SEVERITIES.join(" | ")}).`
|
|
128
134
|
);
|
|
129
135
|
process.exit(1);
|
|
130
136
|
}
|
|
137
|
+
// SAFETY: AGENTS is a closed record keyed by AgentKind, so its keys are
|
|
138
|
+
// exactly the agent kinds.
|
|
131
139
|
const agents = (Object.keys(AGENTS) as AgentKind[]).filter(
|
|
132
140
|
(kind) => args[kind]
|
|
133
141
|
);
|
|
@@ -183,15 +191,7 @@ export const auditCommand = defineCommand({
|
|
|
183
191
|
process.stderr.write(
|
|
184
192
|
` Handing ${count} finding${count === 1 ? "" : "s"} to ${cli.name}…\n\n`
|
|
185
193
|
);
|
|
186
|
-
const code = await launchAgentCode(
|
|
187
|
-
// A POSIX spawn rejects on a missing executable; the Windows shell
|
|
188
|
-
// launch reports it through cmd.exe's 9009 instead. Same diagnosis.
|
|
189
|
-
if (code === WINDOWS_COMMAND_NOT_FOUND) {
|
|
190
|
-
logger.error(
|
|
191
|
-
`${cli.name} (\`${cli.bin}\`) was not found on PATH. Install it with \`${cli.install}\`.`
|
|
192
|
-
);
|
|
193
|
-
process.exit(1);
|
|
194
|
-
}
|
|
194
|
+
const code = await launchAgentCode(agent, fixPrompt(report));
|
|
195
195
|
if (code !== 0) {
|
|
196
196
|
process.exit(code);
|
|
197
197
|
}
|