blume 1.4.0 → 1.4.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 +22 -0
- package/dist/cli/index.js +322 -644
- package/dist/cli/index.js.map +34 -34
- package/package.json +22 -1
- package/src/ai/component-markdown.ts +7 -6
- package/src/astro/generate.ts +4 -13
- package/src/astro/islands.ts +4 -1
- package/src/astro/templates.ts +3 -4
- package/src/audit/checks/indexability.ts +3 -6
- package/src/audit/checks/robots.ts +18 -37
- package/src/audit/crawl.ts +49 -49
- package/src/audit/image-size.ts +13 -53
- package/src/audit/report.ts +22 -33
- package/src/audit/types.ts +6 -2
- package/src/cli/commands/dev.ts +9 -21
- package/src/cli/commands/doctor.ts +9 -22
- package/src/cli/env.ts +6 -52
- package/src/cli/init/scaffold.ts +15 -28
- package/src/cli/internal-error.ts +11 -11
- package/src/components/islands/ask-ai.tsx +25 -100
- package/src/components/islands/hooks.ts +10 -3
- package/src/components/layout/RootLayout.astro +37 -109
- package/src/components/layout/Search.astro +3 -5
- package/src/components/layout/search/types.ts +4 -16
- package/src/components/openapi/helpers.ts +21 -75
- package/src/core/component-overrides.ts +0 -7
- package/src/core/config.ts +3 -3
- package/src/core/diagnostics.ts +10 -20
- package/src/core/fs-atomic.ts +22 -0
- package/src/core/sources/github-releases.ts +29 -26
- package/src/core/sources/mdx-remote.ts +10 -57
- package/src/core/sources/notion.ts +17 -23
- package/src/core/tsconfig-aliases.ts +39 -172
- package/src/deploy/rss.ts +4 -1
- package/src/deploy/sitemap.ts +3 -1
- package/src/eval/report.ts +20 -28
- package/src/markdown/directives.ts +6 -18
- package/src/markdown/index.ts +1 -6
- package/src/markdown/package-commands.ts +0 -4
- package/src/openapi/parse.ts +11 -9
- package/src/search/popular-icon.ts +3 -3
- package/src/translate/ledger.ts +5 -11
- package/src/translate/report.ts +22 -28
- package/src/translate/run.ts +5 -24
- package/src/translate/work-list.ts +0 -0
- package/src/deploy/xml.ts +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blume",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Documentation that's fast, AI-ready, and zero-config.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -90,23 +90,39 @@
|
|
|
90
90
|
"ai": "^7.0.42",
|
|
91
91
|
"astro": "^7.0.2",
|
|
92
92
|
"babel-plugin-react-compiler": "^1.0.0",
|
|
93
|
+
"chokidar": "^5.0.0",
|
|
93
94
|
"citty": "^0.1.6",
|
|
94
95
|
"consola": "^3.4.0",
|
|
95
96
|
"dompurify": "^3.4.11",
|
|
97
|
+
"dotenv": "^17.4.2",
|
|
96
98
|
"epub-gen-memory": "^1.1.2",
|
|
99
|
+
"fast-xml-parser": "^5.10.1",
|
|
100
|
+
"get-tsconfig": "^4.14.1",
|
|
97
101
|
"github-slugger": "^2.0.0",
|
|
98
102
|
"gray-matter": "^4.0.3",
|
|
103
|
+
"html-escaper": "^3.0.3",
|
|
104
|
+
"image-size": "^2.0.2",
|
|
99
105
|
"jiti": "^2.4.0",
|
|
100
106
|
"js-yaml": "^4.1.0",
|
|
101
107
|
"katex": "^0.18.1",
|
|
108
|
+
"markdown-table": "^3.0.4",
|
|
102
109
|
"marked": "^18.0.5",
|
|
110
|
+
"mdast-util-from-markdown": "^2.0.3",
|
|
111
|
+
"mdast-util-gfm": "^3.1.0",
|
|
112
|
+
"mdast-util-to-string": "^4.0.0",
|
|
113
|
+
"medium-zoom": "^1.1.0",
|
|
103
114
|
"mermaid": "^11.15.0",
|
|
115
|
+
"micromark-extension-gfm": "^3.0.0",
|
|
104
116
|
"node-html-parser": "^9.0.0",
|
|
117
|
+
"openapi-sampler": "^1.7.4",
|
|
105
118
|
"pagefind": "^1.3.0",
|
|
106
119
|
"pathe": "^2.0.0",
|
|
120
|
+
"picomatch": "^4.0.5",
|
|
107
121
|
"react": "^19.0.0",
|
|
108
122
|
"react-dom": "^19.0.0",
|
|
123
|
+
"robots-parser": "^3.0.1",
|
|
109
124
|
"satteri": "^0.9.5",
|
|
125
|
+
"semver": "^7.8.5",
|
|
110
126
|
"sharp": "^0.35.3",
|
|
111
127
|
"shiki": "^4.2.0",
|
|
112
128
|
"simple-icons": "^13.0.0",
|
|
@@ -116,13 +132,18 @@
|
|
|
116
132
|
"twoslash": "^0.3.9",
|
|
117
133
|
"typescript": "^6.0.3",
|
|
118
134
|
"undici": "^8.6.0",
|
|
135
|
+
"write-file-atomic": "^8.0.0",
|
|
119
136
|
"zod": "^4.3.6"
|
|
120
137
|
},
|
|
121
138
|
"devDependencies": {
|
|
139
|
+
"@types/html-escaper": "^3.0.4",
|
|
122
140
|
"@types/js-yaml": "^4.0.9",
|
|
123
141
|
"@types/node": "^22.10.0",
|
|
142
|
+
"@types/picomatch": "^4.0.3",
|
|
124
143
|
"@types/react": "^19.0.0",
|
|
125
144
|
"@types/react-dom": "^19.0.0",
|
|
145
|
+
"@types/semver": "^7.8.0",
|
|
146
|
+
"@types/write-file-atomic": "^4.0.3",
|
|
126
147
|
"@typescript/native-preview": "^7.0.0-dev.20260626.1",
|
|
127
148
|
"bun-types": "^1.3.14"
|
|
128
149
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { markdownTable } from "markdown-table";
|
|
1
2
|
import { mdxToMdast } from "satteri";
|
|
2
3
|
|
|
3
4
|
import { parseYouTubeId } from "../components/content/youtube.ts";
|
|
@@ -238,15 +239,15 @@ const typeTable: ComponentMarkdown = ({ children, props }) => {
|
|
|
238
239
|
.filter((part) => typeof part === "string" && part !== "")
|
|
239
240
|
.join(" ")
|
|
240
241
|
);
|
|
241
|
-
return
|
|
242
|
+
return [prop, typeCell, defaultCell, description];
|
|
242
243
|
});
|
|
243
244
|
const table =
|
|
244
245
|
rows.length > 0
|
|
245
|
-
? [
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
246
|
+
? markdownTable([["Prop", "Type", "Default", "Description"], ...rows], {
|
|
247
|
+
// Unpadded columns: cells hold prose and the output is for model
|
|
248
|
+
// consumption, so aligned delimiter rows are wasted tokens.
|
|
249
|
+
alignDelimiters: false,
|
|
250
|
+
})
|
|
250
251
|
: "";
|
|
251
252
|
// The component renders its slot after the table.
|
|
252
253
|
return [table, children].filter(Boolean).join("\n\n");
|
package/src/astro/generate.ts
CHANGED
|
@@ -6,10 +6,8 @@ import {
|
|
|
6
6
|
readFile,
|
|
7
7
|
readlink,
|
|
8
8
|
realpath,
|
|
9
|
-
rename,
|
|
10
9
|
rm,
|
|
11
10
|
symlink,
|
|
12
|
-
writeFile,
|
|
13
11
|
} from "node:fs/promises";
|
|
14
12
|
import { createRequire } from "node:module";
|
|
15
13
|
import { pathToFileURL } from "node:url";
|
|
@@ -33,6 +31,7 @@ import type {
|
|
|
33
31
|
BlumeLogo,
|
|
34
32
|
} from "../core/data.ts";
|
|
35
33
|
import { BlumeError } from "../core/diagnostics.ts";
|
|
34
|
+
import { writeTextAtomic } from "../core/fs-atomic.ts";
|
|
36
35
|
import { EN_UI, resolveUIStrings } from "../core/i18n-ui.ts";
|
|
37
36
|
import { resolveFallbackLocale } from "../core/i18n.ts";
|
|
38
37
|
import {
|
|
@@ -762,17 +761,9 @@ const writeIfChanged = async (
|
|
|
762
761
|
if (existing === content) {
|
|
763
762
|
return false;
|
|
764
763
|
}
|
|
765
|
-
|
|
766
|
-
//
|
|
767
|
-
|
|
768
|
-
const tmp = `${path}.${process.pid}.tmp`;
|
|
769
|
-
await writeFile(tmp, content, "utf-8");
|
|
770
|
-
try {
|
|
771
|
-
await rename(tmp, path);
|
|
772
|
-
} catch (error) {
|
|
773
|
-
await rm(tmp, { force: true });
|
|
774
|
-
throw error;
|
|
775
|
-
}
|
|
764
|
+
// Atomic temp-write + rename, so a watching dev server never observes a
|
|
765
|
+
// missing or half-written file mid-regeneration.
|
|
766
|
+
await writeTextAtomic(path, content);
|
|
776
767
|
return true;
|
|
777
768
|
};
|
|
778
769
|
|
package/src/astro/islands.ts
CHANGED
|
@@ -83,7 +83,10 @@ export const discoverIslands = async (
|
|
|
83
83
|
root: string
|
|
84
84
|
): Promise<IslandDiscovery> => {
|
|
85
85
|
const dir = join(root, "islands");
|
|
86
|
-
|
|
86
|
+
// Enumerate everything and let ISLAND_FILE do the filtering below, so
|
|
87
|
+
// unknown extensions take the skip guard instead of relying on a glob
|
|
88
|
+
// pattern staying in sync with the regex and framework map.
|
|
89
|
+
const matches = await glob(["**/*"], {
|
|
87
90
|
absolute: true,
|
|
88
91
|
cwd: dir,
|
|
89
92
|
onlyFiles: true,
|
package/src/astro/templates.ts
CHANGED
|
@@ -1127,10 +1127,9 @@ export const searchClientTemplate = (config: ResolvedConfig): string => {
|
|
|
1127
1127
|
if (search.provider === "orama" || search.provider === "flexsearch") {
|
|
1128
1128
|
// Only Orama derives a tokenizer from the locale; FlexSearch has no
|
|
1129
1129
|
// equivalent hook, so its client keeps the bare index URL.
|
|
1130
|
-
|
|
1131
|
-
search.provider
|
|
1132
|
-
|
|
1133
|
-
);
|
|
1130
|
+
const locale =
|
|
1131
|
+
search.provider === "orama" ? config.i18n?.defaultLocale : undefined;
|
|
1132
|
+
return staticSearchClient(search.provider, locale);
|
|
1134
1133
|
}
|
|
1135
1134
|
|
|
1136
1135
|
const hosted = hostedSearchOptions(search);
|
|
@@ -8,12 +8,9 @@ import type { AuditContext, CheckModule, PageSnapshot } from "../types.ts";
|
|
|
8
8
|
import { decodePath, normalizePath, siteOrigin } from "../url.ts";
|
|
9
9
|
|
|
10
10
|
/** The canonical URL parsed, or null when it isn't a usable absolute URL. */
|
|
11
|
-
const parseCanonical = (
|
|
12
|
-
if (!page.canonical) {
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
11
|
+
const parseCanonical = (canonical: string): URL | null => {
|
|
15
12
|
try {
|
|
16
|
-
return new URL(
|
|
13
|
+
return new URL(canonical);
|
|
17
14
|
} catch {
|
|
18
15
|
return null;
|
|
19
16
|
}
|
|
@@ -48,7 +45,7 @@ const canonicalChecks = (
|
|
|
48
45
|
: [];
|
|
49
46
|
}
|
|
50
47
|
|
|
51
|
-
const canonical = parseCanonical(page);
|
|
48
|
+
const canonical = parseCanonical(page.canonical);
|
|
52
49
|
if (!canonical) {
|
|
53
50
|
return [
|
|
54
51
|
finding(
|
|
@@ -1,40 +1,16 @@
|
|
|
1
|
+
import robotsParser from "robots-parser";
|
|
2
|
+
|
|
1
3
|
import type { Diagnostic } from "../../core/types.ts";
|
|
2
4
|
import { finding } from "../catalog.ts";
|
|
3
5
|
import type { CheckModule } from "../types.ts";
|
|
4
6
|
import { normalizePath } from "../url.ts";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* robots-parser needs full URLs on a single origin; the origin itself is
|
|
10
|
+
* irrelevant to path matching, so a fixed placeholder keeps the check
|
|
11
|
+
* independent of whether the project configured `deployment.site`.
|
|
9
12
|
*/
|
|
10
|
-
|
|
11
|
-
const anchored = rule.endsWith("$");
|
|
12
|
-
const pattern = anchored ? rule.slice(0, -1) : rule;
|
|
13
|
-
const parts = pattern.split("*");
|
|
14
|
-
|
|
15
|
-
let cursor = 0;
|
|
16
|
-
for (const [index, part] of parts.entries()) {
|
|
17
|
-
if (part === "") {
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
// The first segment is anchored to the start of the path (robots.txt rules
|
|
21
|
-
// are prefix matches); every later segment may appear anywhere after the
|
|
22
|
-
// previous one, which is what makes `*` a wildcard.
|
|
23
|
-
let at: number;
|
|
24
|
-
if (index === 0) {
|
|
25
|
-
at = path.startsWith(part) ? 0 : -1;
|
|
26
|
-
} else {
|
|
27
|
-
at = path.indexOf(part, cursor);
|
|
28
|
-
}
|
|
29
|
-
if (at === -1) {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
cursor = at + part.length;
|
|
33
|
-
}
|
|
34
|
-
// A wildcard just before `$` (`/docs*$`) absorbs the rest of the path, so
|
|
35
|
-
// the anchor is already satisfied by any prefix match.
|
|
36
|
-
return anchored && !pattern.endsWith("*") ? cursor === path.length : true;
|
|
37
|
-
};
|
|
13
|
+
const MATCH_ORIGIN = "https://robots-audit.invalid";
|
|
38
14
|
|
|
39
15
|
/**
|
|
40
16
|
* robots.txt: is it there, is it well-formed, does it point at the sitemap, and
|
|
@@ -82,8 +58,13 @@ export const robotsChecks: CheckModule = {
|
|
|
82
58
|
}
|
|
83
59
|
|
|
84
60
|
// A page can't be both blocked from crawling and advertised for indexing.
|
|
85
|
-
// Checking the
|
|
86
|
-
//
|
|
61
|
+
// Checking the rules against the sitemap (rather than against every built
|
|
62
|
+
// file) keeps this to the pages the site actually wants indexed.
|
|
63
|
+
// robots-parser resolves Allow/Disallow by longest match, so the common
|
|
64
|
+
// `Disallow: /` + `Allow: /docs/` pattern doesn't flag every page, and
|
|
65
|
+
// consecutive User-agent lines form one group as the spec requires.
|
|
66
|
+
const parser = robotsParser(`${MATCH_ORIGIN}/robots.txt`, robots.raw);
|
|
67
|
+
const lines = robots.raw.split(/\r?\n/u);
|
|
87
68
|
for (const loc of context.sitemap?.urls ?? []) {
|
|
88
69
|
let pathname: string;
|
|
89
70
|
try {
|
|
@@ -94,15 +75,15 @@ export const robotsChecks: CheckModule = {
|
|
|
94
75
|
// Match the pathname as served: robots.txt rules are literal prefixes,
|
|
95
76
|
// so `Disallow: /page/` must see the trailing slash to match.
|
|
96
77
|
const path = normalizePath(pathname);
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
78
|
+
const url = `${MATCH_ORIGIN}${pathname}`;
|
|
79
|
+
if (parser.isDisallowed(url, "*")) {
|
|
80
|
+
const line = parser.getMatchingLineNumber(url, "*");
|
|
81
|
+
const rule = line > 0 ? lines[line - 1]?.trim() : undefined;
|
|
101
82
|
found.push(
|
|
102
83
|
finding(
|
|
103
84
|
"BLUME_AUDIT_ROBOTS_DISALLOWS_INDEXABLE",
|
|
104
85
|
{ file: robots.file, url: path },
|
|
105
|
-
`robots.txt "
|
|
86
|
+
`robots.txt "${rule ?? "Disallow"}" blocks ${path}, which sitemap.xml advertises.`
|
|
106
87
|
)
|
|
107
88
|
);
|
|
108
89
|
}
|
package/src/audit/crawl.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
+
import { XMLParser } from "fast-xml-parser";
|
|
3
4
|
import { join, relative } from "pathe";
|
|
4
5
|
import { glob } from "tinyglobby";
|
|
5
6
|
|
|
@@ -74,27 +75,25 @@ const routeIndex = (
|
|
|
74
75
|
return index;
|
|
75
76
|
};
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
);
|
|
78
|
+
/**
|
|
79
|
+
* Sitemaps arrive from arbitrary generators (the audit also fetches remote
|
|
80
|
+
* ones), so parsing is fast-xml-parser's job: CDATA sections, numeric
|
|
81
|
+
* entities, and namespace-prefixed elements are all legal there and all
|
|
82
|
+
* invisible to a regex scan. Values stay strings (`parseTagValue: false`) so
|
|
83
|
+
* a numeric-looking `<lastmod>` isn't coerced.
|
|
84
|
+
*/
|
|
85
|
+
const sitemapParser = new XMLParser({
|
|
86
|
+
// htmlEntities adds numeric character references (&) on top of the
|
|
87
|
+
// default XML five; a sitemap loc legitimately carries either form.
|
|
88
|
+
htmlEntities: true,
|
|
89
|
+
ignoreAttributes: true,
|
|
90
|
+
parseTagValue: false,
|
|
91
|
+
removeNSPrefix: true,
|
|
92
|
+
});
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
* Parse `sitemap.xml`. Deliberately shallow:
|
|
96
|
-
* whether the document is a
|
|
97
|
-
* learn that would be a dependency for one regex.
|
|
95
|
+
* Parse `sitemap.xml`. Deliberately shallow: the checks only need the `<loc>`
|
|
96
|
+
* list, each loc's `<lastmod>`, and whether the document is a urlset at all.
|
|
98
97
|
*/
|
|
99
98
|
export const parseSitemap = (
|
|
100
99
|
file: string,
|
|
@@ -102,31 +101,34 @@ export const parseSitemap = (
|
|
|
102
101
|
bytes: number
|
|
103
102
|
): SitemapDoc => {
|
|
104
103
|
const doc: SitemapDoc = { bytes, file, lastmod: new Map(), urls: [] };
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
let parsed: Record<string, unknown>;
|
|
105
|
+
try {
|
|
106
|
+
parsed = sitemapParser.parse(xml) as Record<string, unknown>;
|
|
107
|
+
} catch {
|
|
108
|
+
doc.error = "no <urlset> element";
|
|
109
|
+
return doc;
|
|
110
|
+
}
|
|
111
|
+
if (!Object.hasOwn(parsed, "urlset")) {
|
|
112
|
+
doc.error = Object.hasOwn(parsed, "sitemapindex")
|
|
107
113
|
? "sitemap is an index, not a urlset"
|
|
108
114
|
: "no <urlset> element";
|
|
109
115
|
return doc;
|
|
110
116
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
const urlset = parsed.urlset as { url?: unknown } | string | null;
|
|
118
|
+
const entries =
|
|
119
|
+
typeof urlset === "object" && urlset !== null ? [urlset.url].flat() : [];
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (typeof entry !== "object" || entry === null) {
|
|
122
|
+
continue;
|
|
115
123
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
(
|
|
124
|
-
new RegExp(SITEMAP_LOC.source, "u").exec(block)?.groups?.loc ?? ""
|
|
125
|
-
).trim()
|
|
126
|
-
);
|
|
127
|
-
const lastmod = SITEMAP_LASTMOD.exec(block)?.groups?.date?.trim();
|
|
128
|
-
if (loc && lastmod) {
|
|
129
|
-
doc.lastmod?.set(loc, lastmod);
|
|
124
|
+
const { loc, lastmod } = entry as { loc?: unknown; lastmod?: unknown };
|
|
125
|
+
const locText = typeof loc === "string" ? loc.trim() : "";
|
|
126
|
+
if (!locText) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
doc.urls.push(locText);
|
|
130
|
+
if (typeof lastmod === "string" && lastmod.trim() !== "") {
|
|
131
|
+
doc.lastmod?.set(locText, lastmod.trim());
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
134
|
return doc;
|
|
@@ -153,12 +155,14 @@ export const parseLlms = (file: string, text: string): LlmsDoc => {
|
|
|
153
155
|
|
|
154
156
|
const ROBOTS_DIRECTIVE = /^(?<field>[a-z-]+)\s*:\s*(?<value>.*)$/iu;
|
|
155
157
|
|
|
156
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Parse `robots.txt` into the pieces the audit cares about. Sitemap
|
|
160
|
+
* declarations and a not-a-directive lint come from a line scan; rule
|
|
161
|
+
* *matching* is robots-parser's job at check time (see `checks/robots.ts`),
|
|
162
|
+
* so the raw text rides along instead of a pre-extracted rule list.
|
|
163
|
+
*/
|
|
157
164
|
export const parseRobots = (file: string, text: string): RobotsDoc => {
|
|
158
|
-
const doc: RobotsDoc = {
|
|
159
|
-
// Only `User-agent: *` rules bind the crawlers we're auditing for; a block
|
|
160
|
-
// scoped to some other agent isn't a finding about our indexable pages.
|
|
161
|
-
let appliesToAll = false;
|
|
165
|
+
const doc: RobotsDoc = { file, invalid: [], raw: text, sitemaps: [] };
|
|
162
166
|
for (const [index, raw] of text.split(/\r?\n/u).entries()) {
|
|
163
167
|
const line = raw.trim();
|
|
164
168
|
if (line === "" || line.startsWith("#")) {
|
|
@@ -171,11 +175,7 @@ export const parseRobots = (file: string, text: string): RobotsDoc => {
|
|
|
171
175
|
}
|
|
172
176
|
const field = (match.groups?.field ?? "").toLowerCase();
|
|
173
177
|
const value = (match.groups?.value ?? "").trim();
|
|
174
|
-
if (field === "
|
|
175
|
-
appliesToAll = value === "*";
|
|
176
|
-
} else if (field === "disallow" && appliesToAll && value) {
|
|
177
|
-
doc.disallow.push(value);
|
|
178
|
-
} else if (field === "sitemap" && value) {
|
|
178
|
+
if (field === "sitemap" && value) {
|
|
179
179
|
doc.sitemaps.push(value);
|
|
180
180
|
}
|
|
181
181
|
}
|
package/src/audit/image-size.ts
CHANGED
|
@@ -1,63 +1,23 @@
|
|
|
1
|
+
import { imageSize as measureImage } from "image-size";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* Pixel dimensions read
|
|
3
|
-
*
|
|
4
|
-
*
|
|
4
|
+
* Pixel dimensions read from an image header via the image-size package,
|
|
5
|
+
* which covers the formats a modern pipeline actually emits — WebP and AVIF
|
|
6
|
+
* included, where the previous hand parser (PNG/JPEG/GIF only) went silent
|
|
7
|
+
* and the dimension checks never ran. An unrecognized or truncated buffer
|
|
8
|
+
* yields null and its checks simply don't run.
|
|
5
9
|
*/
|
|
6
10
|
export interface ImageSize {
|
|
7
11
|
width: number;
|
|
8
12
|
height: number;
|
|
9
13
|
}
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
return { height: bytes.readUInt32BE(20), width: bytes.readUInt32BE(16) };
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
/** JPEG start-of-frame markers (C0–CF minus DHT C4, JPG C8, DAC CC). */
|
|
23
|
-
const isSof = (marker: number): boolean =>
|
|
24
|
-
marker >= 0xc0 &&
|
|
25
|
-
marker <= 0xcf &&
|
|
26
|
-
marker !== 0xc4 &&
|
|
27
|
-
marker !== 0xc8 &&
|
|
28
|
-
marker !== 0xcc;
|
|
29
|
-
|
|
30
|
-
const jpegSize = (bytes: Buffer): ImageSize | null => {
|
|
31
|
-
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
// Walk the segment list: each is FF <marker> <u16 length> <payload>. The
|
|
35
|
-
// dimensions live in the first start-of-frame segment's payload, as
|
|
36
|
-
// big-endian u16s after a one-byte precision field.
|
|
37
|
-
let offset = 2;
|
|
38
|
-
while (offset + 9 < bytes.length) {
|
|
39
|
-
if (bytes[offset] !== 0xff) {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
const marker = bytes[offset + 1] ?? 0;
|
|
43
|
-
if (isSof(marker)) {
|
|
44
|
-
return {
|
|
45
|
-
height: bytes.readUInt16BE(offset + 5),
|
|
46
|
-
width: bytes.readUInt16BE(offset + 7),
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
offset += 2 + bytes.readUInt16BE(offset + 2);
|
|
50
|
-
}
|
|
51
|
-
return null;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const gifSize = (bytes: Buffer): ImageSize | null => {
|
|
55
|
-
if (bytes.length < 10 || bytes.subarray(0, 4).toString("latin1") !== "GIF8") {
|
|
15
|
+
/** The image's pixel dimensions, or null when the format isn't recognized. */
|
|
16
|
+
export const imageSize = (bytes: Buffer): ImageSize | null => {
|
|
17
|
+
try {
|
|
18
|
+
const { width, height } = measureImage(bytes);
|
|
19
|
+
return width > 0 && height > 0 ? { height, width } : null;
|
|
20
|
+
} catch {
|
|
56
21
|
return null;
|
|
57
22
|
}
|
|
58
|
-
return { height: bytes.readUInt16LE(8), width: bytes.readUInt16LE(6) };
|
|
59
23
|
};
|
|
60
|
-
|
|
61
|
-
/** The image's pixel dimensions, or null when the format isn't recognized. */
|
|
62
|
-
export const imageSize = (bytes: Buffer): ImageSize | null =>
|
|
63
|
-
pngSize(bytes) ?? jpegSize(bytes) ?? gifSize(bytes);
|
package/src/audit/report.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { colors } from "consola/utils";
|
|
2
|
+
import type { ColorFunction } from "consola/utils";
|
|
1
3
|
import { relative } from "pathe";
|
|
2
4
|
|
|
3
5
|
import { countBySeverity } from "../core/diagnostics.ts";
|
|
@@ -7,21 +9,10 @@ import type { CheckId } from "./catalog.ts";
|
|
|
7
9
|
import type { AuditResult } from "./run.ts";
|
|
8
10
|
import type { AuditCategory, AuditTier } from "./types.ts";
|
|
9
11
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
dim: `${ESC}[2m`,
|
|
15
|
-
green: `${ESC}[32m`,
|
|
16
|
-
red: `${ESC}[31m`,
|
|
17
|
-
reset: `${ESC}[0m`,
|
|
18
|
-
yellow: `${ESC}[33m`,
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const SEVERITY_COLOR: Record<DiagnosticSeverity, string> = {
|
|
22
|
-
error: COLORS.red,
|
|
23
|
-
info: `${ESC}[34m`,
|
|
24
|
-
warning: COLORS.yellow,
|
|
12
|
+
const SEVERITY_COLOR: Record<DiagnosticSeverity, ColorFunction> = {
|
|
13
|
+
error: colors.red,
|
|
14
|
+
info: colors.blue,
|
|
15
|
+
warning: colors.yellow,
|
|
25
16
|
};
|
|
26
17
|
|
|
27
18
|
const GLYPH: Record<DiagnosticSeverity, string> = {
|
|
@@ -108,7 +99,7 @@ const skippedTiers = (tiers: Record<AuditTier, boolean>): string[] =>
|
|
|
108
99
|
.filter((tier) => !tiers[tier])
|
|
109
100
|
.map((tier) => {
|
|
110
101
|
const label = CHECKS.filter((check) => check.tier === tier).length;
|
|
111
|
-
return ` ${
|
|
102
|
+
return ` ${colors.dim(`⊘ ${tier.padEnd(12)} skipped — pass ${TIER_FLAG[tier]} (${label} checks)`)}`;
|
|
112
103
|
});
|
|
113
104
|
|
|
114
105
|
/** How many checks actually ran, i.e. those whose tier was enabled. */
|
|
@@ -138,9 +129,11 @@ const summaryLine = (
|
|
|
138
129
|
const findingLine = (diagnostic: Diagnostic, root: string): string => {
|
|
139
130
|
const url = diagnostic.url ?? "";
|
|
140
131
|
const source = diagnostic.file
|
|
141
|
-
?
|
|
142
|
-
|
|
143
|
-
|
|
132
|
+
? colors.dim(
|
|
133
|
+
`${relative(root, diagnostic.file)}${
|
|
134
|
+
diagnostic.line === undefined ? "" : `:${diagnostic.line}`
|
|
135
|
+
}`
|
|
136
|
+
)
|
|
144
137
|
: "";
|
|
145
138
|
// padEnd alone yields no gap once the URL reaches the column width.
|
|
146
139
|
return ` ${url.padEnd(34)} ${source}`.trimEnd();
|
|
@@ -164,13 +157,13 @@ export const formatReport = (
|
|
|
164
157
|
: `${relative(root, result.staticDir) || "dist"} · offline`;
|
|
165
158
|
lines.push(
|
|
166
159
|
"",
|
|
167
|
-
` ${
|
|
160
|
+
` ${colors.bold("blume audit")} ${colors.dim(`${result.pages} pages · ${where}`)}`,
|
|
168
161
|
` ${summaryLine(counts, auditCount(result))}`,
|
|
169
162
|
""
|
|
170
163
|
);
|
|
171
164
|
|
|
172
165
|
if (groups.length === 0) {
|
|
173
|
-
lines.push(` ${
|
|
166
|
+
lines.push(` ${colors.green("✔ No issues found.")}`, "");
|
|
174
167
|
}
|
|
175
168
|
|
|
176
169
|
let category: AuditCategory | null = null;
|
|
@@ -178,13 +171,13 @@ export const formatReport = (
|
|
|
178
171
|
const { category: next } = group;
|
|
179
172
|
if (next !== category) {
|
|
180
173
|
category = next;
|
|
181
|
-
lines.push(` ${
|
|
174
|
+
lines.push(` ${colors.bold(category)}`, "");
|
|
182
175
|
}
|
|
183
176
|
|
|
184
177
|
const color = SEVERITY_COLOR[group.severity];
|
|
185
178
|
const pages = `${group.count} page${group.count === 1 ? "" : "s"}`;
|
|
186
179
|
lines.push(
|
|
187
|
-
` ${color
|
|
180
|
+
` ${color(`${GLYPH[group.severity]} ${group.title}`)} ${colors.dim(pages)}`
|
|
188
181
|
);
|
|
189
182
|
|
|
190
183
|
const shown = options.verbose
|
|
@@ -195,14 +188,12 @@ export const formatReport = (
|
|
|
195
188
|
// The message names the specifics the rolled-up line can't — which target
|
|
196
189
|
// is broken, what the duplicate is — so --verbose prints it per finding.
|
|
197
190
|
if (options.verbose) {
|
|
198
|
-
lines.push(` ${
|
|
191
|
+
lines.push(` ${colors.dim(diagnostic.message)}`);
|
|
199
192
|
}
|
|
200
193
|
}
|
|
201
194
|
const hidden = group.count - shown.length;
|
|
202
195
|
if (hidden > 0) {
|
|
203
|
-
lines.push(
|
|
204
|
-
` ${COLORS.dim}… and ${hidden} more (--verbose)${COLORS.reset}`
|
|
205
|
-
);
|
|
196
|
+
lines.push(` ${colors.dim(`… and ${hidden} more (--verbose)`)}`);
|
|
206
197
|
}
|
|
207
198
|
|
|
208
199
|
// Every finding in a group shares the catalog's fix unless it overrode it,
|
|
@@ -210,7 +201,7 @@ export const formatReport = (
|
|
|
210
201
|
const [first] = group.findings;
|
|
211
202
|
const fix = first?.suggestion;
|
|
212
203
|
if (fix) {
|
|
213
|
-
lines.push(` ${
|
|
204
|
+
lines.push(` ${colors.cyan(`fix: ${fix}`)}`);
|
|
214
205
|
}
|
|
215
206
|
lines.push("");
|
|
216
207
|
}
|
|
@@ -268,14 +259,12 @@ export const formatCatalog = (): string => {
|
|
|
268
259
|
const { category: next } = check;
|
|
269
260
|
if (next !== category) {
|
|
270
261
|
category = next;
|
|
271
|
-
lines.push(` ${
|
|
262
|
+
lines.push(` ${colors.bold(category)}`);
|
|
272
263
|
}
|
|
273
264
|
const tier =
|
|
274
|
-
check.tier === "static"
|
|
275
|
-
? ""
|
|
276
|
-
: ` ${COLORS.dim}[${check.tier}]${COLORS.reset}`;
|
|
265
|
+
check.tier === "static" ? "" : ` ${colors.dim(`[${check.tier}]`)}`;
|
|
277
266
|
lines.push(
|
|
278
|
-
` ${SEVERITY_COLOR[check.severity]
|
|
267
|
+
` ${SEVERITY_COLOR[check.severity](GLYPH[check.severity])} ${check.id.replace("BLUME_AUDIT_", "").toLowerCase().padEnd(34)} ${colors.dim(check.title)}${tier}`
|
|
279
268
|
);
|
|
280
269
|
}
|
|
281
270
|
lines.push("", ` ${CHECKS.length} checks.`, "");
|
package/src/audit/types.ts
CHANGED
|
@@ -135,8 +135,12 @@ export interface LlmsDoc {
|
|
|
135
135
|
/** A parsed `robots.txt`. */
|
|
136
136
|
export interface RobotsDoc {
|
|
137
137
|
file: string;
|
|
138
|
-
/**
|
|
139
|
-
|
|
138
|
+
/**
|
|
139
|
+
* The file's full text. Rule matching runs through robots-parser (which
|
|
140
|
+
* owns longest-match Allow/Disallow semantics), so the raw text is the
|
|
141
|
+
* source of truth rather than a pre-extracted rule list.
|
|
142
|
+
*/
|
|
143
|
+
raw: string;
|
|
140
144
|
/** `Sitemap:` declarations. */
|
|
141
145
|
sitemaps: string[];
|
|
142
146
|
/** Lines that aren't a recognized directive, with their 1-based line number. */
|