blume 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/cli/index.js +328 -644
  3. package/dist/cli/index.js.map +35 -35
  4. package/dist/types/core/data.d.ts +10 -0
  5. package/docs/configuration/ai.mdx +15 -1
  6. package/package.json +28 -7
  7. package/src/ai/component-markdown.ts +7 -6
  8. package/src/ai/link-headers.ts +7 -2
  9. package/src/astro/generate.ts +8 -13
  10. package/src/astro/islands.ts +4 -1
  11. package/src/astro/templates.ts +5 -4
  12. package/src/audit/checks/indexability.ts +3 -6
  13. package/src/audit/checks/robots.ts +18 -37
  14. package/src/audit/crawl.ts +49 -49
  15. package/src/audit/image-size.ts +13 -53
  16. package/src/audit/report.ts +22 -33
  17. package/src/audit/types.ts +6 -2
  18. package/src/cli/commands/dev.ts +9 -21
  19. package/src/cli/commands/doctor.ts +9 -22
  20. package/src/cli/env.ts +6 -52
  21. package/src/cli/init/scaffold.ts +15 -28
  22. package/src/cli/internal-error.ts +11 -11
  23. package/src/components/islands/ask-ai.tsx +25 -100
  24. package/src/components/islands/hooks.ts +10 -3
  25. package/src/components/layout/RootLayout.astro +78 -109
  26. package/src/components/layout/Search.astro +3 -5
  27. package/src/components/layout/search/types.ts +4 -16
  28. package/src/components/openapi/helpers.ts +21 -75
  29. package/src/core/component-overrides.ts +0 -7
  30. package/src/core/config.ts +3 -3
  31. package/src/core/data.ts +7 -0
  32. package/src/core/diagnostics.ts +10 -20
  33. package/src/core/fs-atomic.ts +22 -0
  34. package/src/core/sources/github-releases.ts +29 -26
  35. package/src/core/sources/mdx-remote.ts +10 -57
  36. package/src/core/sources/notion.ts +17 -23
  37. package/src/core/tsconfig-aliases.ts +39 -172
  38. package/src/deploy/rss.ts +4 -1
  39. package/src/deploy/sitemap.ts +3 -1
  40. package/src/eval/report.ts +20 -28
  41. package/src/markdown/directives.ts +6 -18
  42. package/src/markdown/index.ts +1 -6
  43. package/src/markdown/package-commands.ts +0 -4
  44. package/src/openapi/parse.ts +11 -9
  45. package/src/search/popular-icon.ts +3 -3
  46. package/src/translate/ledger.ts +5 -11
  47. package/src/translate/report.ts +22 -28
  48. package/src/translate/run.ts +5 -24
  49. package/src/translate/work-list.ts +0 -0
  50. package/src/deploy/xml.ts +0 -8
@@ -107,6 +107,16 @@ export interface BlumeDataConfig {
107
107
  /** `dateFormat`: `Intl.DateTimeFormat` options for the date stamps. */
108
108
  dateFormat: ResolvedConfig["dateFormat"];
109
109
  description: string | undefined;
110
+ /**
111
+ * Which agent-discovery resources exist for the layout to advertise in every
112
+ * page's `<head>` (`seo.agentReadability`, `ai.llmsTxt.enabled`) — the HTML
113
+ * counterpart of the homepage-only HTTP `Link` header, for agents that enter
114
+ * on a deep page (see `ai/link-headers.ts`).
115
+ */
116
+ discovery: {
117
+ agentReadability: boolean;
118
+ llmsTxt: boolean;
119
+ };
110
120
  favicon: BlumeFavicon;
111
121
  feedback: boolean;
112
122
  i18n: BlumeDataI18n | null;
@@ -338,7 +338,21 @@ Link: </agent-readability.json>; rel="describedby"; type="application/json",
338
338
  </index.md>; rel="alternate"; type="text/markdown"
339
339
  ```
340
340
 
341
- Each entry appears only when its feature is on. The `alternate` link points at the homepage's Markdown mirror — the page's own [raw Markdown](#raw-markdown) when the home route is a content page, or the synthesized `llms.txt` fallback when it's a landing page. Sites that publish APIs also get a `rel="api-catalog"` entry pointing at the [generated API catalog](#api-catalog). The header rides on every surface Blume controls: the dev server (check it with `curl -I localhost:4321`), static builds via the emitted `_headers` file (Netlify and Cloudflare), and Vercel server builds via the deploy's routing rules. Hosts that ignore `_headers` on static output (GitHub Pages, S3) can't send custom response headers at all — there, agents still find everything through `llms.txt` and `agent-readability.json` at the site root.
341
+ Each entry appears only when its feature is on. The `alternate` link points at the homepage's Markdown mirror — the page's own [raw Markdown](#raw-markdown) when the home route is a content page, or the synthesized `llms.txt` fallback when it's a landing page. Sites that publish APIs also get a `rel="api-catalog"` entry pointing at the [generated API catalog](#api-catalog). The header rides on every surface Blume controls: the dev server (check it with `curl -I localhost:4321`), static builds via the emitted `_headers` file (Netlify and Cloudflare), and Vercel server builds via the deploy's routing rules.
342
+
343
+ Not every agent enters through the root, though — one following a search result or a shared link lands on a deep page and never sees the homepage header. So every rendered page also carries the same discovery links in its HTML `<head>`, using the same IANA-registered relations:
344
+
345
+ ```html
346
+ <link
347
+ rel="describedby"
348
+ href="/agent-readability.json"
349
+ type="application/json"
350
+ />
351
+ <link rel="describedby" href="/llms.txt" type="text/plain" />
352
+ <link rel="alternate" href="/docs/example.md" type="text/markdown" />
353
+ ```
354
+
355
+ Here the `alternate` link points at _that page's own_ [raw-Markdown mirror](#raw-markdown), so an agent can jump straight from the HTML it landed on to the token-efficient version. Because the head links travel with the prerendered HTML, they also work on hosts that ignore `_headers` and can't send custom response headers at all (GitHub Pages, S3) — no matter which page the agent enters on.
342
356
 
343
357
  ### API catalog
344
358
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blume",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Documentation that's fast, AI-ready, and zero-config.",
5
5
  "keywords": [
6
6
  "astro",
@@ -71,7 +71,7 @@
71
71
  "@astrojs/mdx": "^7.0.0",
72
72
  "@astrojs/node": "^11.0.0",
73
73
  "@astrojs/react": "^6.0.0",
74
- "@astrojs/vercel": "^11.0.0",
74
+ "@astrojs/vercel": "^11.0.3",
75
75
  "@clack/prompts": "^1.7.0",
76
76
  "@iconify-json/lucide": "^1.2.115",
77
77
  "@iconify/types": "^2.0.0",
@@ -88,25 +88,41 @@
88
88
  "@tailwindcss/vite": "^4",
89
89
  "@vercel/analytics": "^2.0.1",
90
90
  "ai": "^7.0.42",
91
- "astro": "^7.0.2",
91
+ "astro": "^7.1.0",
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
- "dompurify": "^3.4.11",
96
+ "dompurify": "^3.4.13",
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
- "js-yaml": "^4.1.0",
106
+ "js-yaml": "^4.3.1",
101
107
  "katex": "^0.18.1",
108
+ "markdown-table": "^3.0.4",
102
109
  "marked": "^18.0.5",
103
- "mermaid": "^11.15.0",
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",
114
+ "mermaid": "^11.16.1",
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",
@@ -115,14 +131,19 @@
115
131
  "tinyglobby": "^0.2.10",
116
132
  "twoslash": "^0.3.9",
117
133
  "typescript": "^6.0.3",
118
- "undici": "^8.6.0",
134
+ "undici": "^8.9.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 `| ${prop} | ${typeCell} | ${defaultCell} | ${description} |`;
242
+ return [prop, typeCell, defaultCell, description];
242
243
  });
243
244
  const table =
244
245
  rows.length > 0
245
- ? [
246
- "| Prop | Type | Default | Description |",
247
- "| --- | --- | --- | --- |",
248
- ...rows,
249
- ].join("\n")
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");
@@ -14,8 +14,13 @@ import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
14
14
  * The header is homepage-only by design: the root response is what agents
15
15
  * probe, and `agent-readability.json` indexes the rest of the surface (the
16
16
  * per-route Markdown pattern, MCP, feeds) far better than per-page headers
17
- * could. Targets are root-relative under `deployment.base` RFC 8288 resolves
18
- * them against the request URL. Returns null when nothing is advertisable.
17
+ * could. An agent that enters on a deep page (a search result, a shared link)
18
+ * never sees this header at all that path is covered in the HTML instead:
19
+ * every page's `<head>` carries the same `describedby` links plus its own
20
+ * Markdown mirror as an `alternate` (see `RootLayout.astro`), which also
21
+ * reaches hosts where Blume can't set response headers. Targets are
22
+ * root-relative under `deployment.base` — RFC 8288 resolves them against the
23
+ * request URL. Returns null when nothing is advertisable.
19
24
  */
20
25
  export const buildHomeLinkHeader = (
21
26
  config: ResolvedConfig,
@@ -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
- await mkdir(dirname(path), { recursive: true });
766
- // Write to a temp file then atomically rename into place, so a watching dev
767
- // server never observes a missing or half-written file mid-regeneration.
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
 
@@ -1169,6 +1160,10 @@ export const buildRuntimeData = (project: BlumeProject): string => {
1169
1160
  codeWrap: config.markdown.code.wrap,
1170
1161
  dateFormat: config.dateFormat,
1171
1162
  description: config.description,
1163
+ discovery: {
1164
+ agentReadability: config.seo.agentReadability,
1165
+ llmsTxt: config.ai.llmsTxt.enabled,
1166
+ },
1172
1167
  favicon: resolveFavicon(project),
1173
1168
  feedback: config.feedback,
1174
1169
  i18n: i18n
@@ -83,7 +83,10 @@ export const discoverIslands = async (
83
83
  root: string
84
84
  ): Promise<IslandDiscovery> => {
85
85
  const dir = join(root, "islands");
86
- const matches = await glob(["**/*.{jsx,svelte,tsx,vue}"], {
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,
@@ -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
- return staticSearchClient(
1131
- search.provider,
1132
- search.provider === "orama" ? config.i18n?.defaultLocale : undefined
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);
@@ -1802,6 +1801,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
1802
1801
  exportPdf={${options.exportPdf}}
1803
1802
  exportEpub={${options.exportEpub}}
1804
1803
  feeds={data.feeds}
1804
+ discovery={data.config.discovery}
1805
1805
  siteUrl={data.config.site}
1806
1806
  pageType={frontmatter.type}
1807
1807
  published={frontmatter.date ?? frontmatter.changelog?.date ?? null}
@@ -2023,6 +2023,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
2023
2023
  exportPdf={${options.exportPdf}}
2024
2024
  exportEpub={${options.exportEpub}}
2025
2025
  feeds={data.feeds}
2026
+ discovery={data.config.discovery}
2026
2027
  siteUrl={data.config.site}
2027
2028
  noindex={false}
2028
2029
  structuredDataEnabled={data.config.structuredData}
@@ -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 = (page: PageSnapshot): URL | null => {
12
- if (!page.canonical) {
13
- return null;
14
- }
11
+ const parseCanonical = (canonical: string): URL | null => {
15
12
  try {
16
- return new URL(page.canonical);
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
- * Whether a robots.txt `Disallow` value covers a path. robots.txt matching is
8
- * prefix-based, with `*` as a wildcard and `$` anchoring the end.
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
- export const disallowMatches = (rule: string, path: string): boolean => {
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 disallow rules against the sitemap (rather than against every
86
- // built file) keeps this to the pages the site actually wants indexed.
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 rule = robots.disallow.find((entry) =>
98
- disallowMatches(entry, pathname)
99
- );
100
- if (rule) {
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 "Disallow: ${rule}" blocks ${path}, which sitemap.xml advertises.`
86
+ `robots.txt "${rule ?? "Disallow"}" blocks ${path}, which sitemap.xml advertises.`
106
87
  )
107
88
  );
108
89
  }
@@ -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
- const SITEMAP_URL = /<url>(?<block>[\s\S]*?)<\/url>/gu;
78
- const SITEMAP_LOC = /<loc>(?<loc>[\s\S]*?)<\/loc>/gu;
79
- const SITEMAP_LASTMOD = /<lastmod>(?<date>[\s\S]*?)<\/lastmod>/u;
80
- const XML_ENTITIES: Record<string, string> = {
81
- "&amp;": "&",
82
- "&apos;": "'",
83
- "&gt;": ">",
84
- "&lt;": "<",
85
- "&quot;": '"',
86
- };
87
-
88
- const unescapeXml = (value: string): string =>
89
- value.replaceAll(
90
- /&(?:amp|apos|gt|lt|quot);/gu,
91
- (entity) => XML_ENTITIES[entity] ?? entity
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 (&#38;) 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: we only need the `<loc>` list and
96
- * whether the document is a well-formed urlset, and pulling in an XML parser to
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
- if (!xml.includes("<urlset")) {
106
- doc.error = xml.includes("<sitemapindex")
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
- for (const match of xml.matchAll(SITEMAP_LOC)) {
112
- const loc = unescapeXml((match.groups?.loc ?? "").trim());
113
- if (loc) {
114
- doc.urls.push(loc);
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
- // `<lastmod>` is scoped per `<url>` block so it stays attached to its `<loc>`
118
- // the flat loc scan above deliberately isn't, so a sitemap with stray text
119
- // between blocks still yields its URL list.
120
- for (const match of xml.matchAll(SITEMAP_URL)) {
121
- const block = match.groups?.block ?? "";
122
- const loc = unescapeXml(
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
- /** Parse `robots.txt` into the directives the audit cares about. */
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 = { disallow: [], file, invalid: [], sitemaps: [] };
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 === "user-agent") {
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
  }
@@ -1,63 +1,23 @@
1
+ import { imageSize as measureImage } from "image-size";
2
+
1
3
  /**
2
- * Pixel dimensions read straight from a PNG, JPEG, or GIF header. A dedicated
3
- * image library would be a dependency for three well-documented byte layouts;
4
- * anything else (SVG, WebP, AVIF) yields null and its checks simply don't run.
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
- const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
12
-
13
- const pngSize = (bytes: Buffer): ImageSize | null => {
14
- // Signature, then the IHDR chunk is required to come first: width and height
15
- // are big-endian u32s at fixed offsets 16 and 20.
16
- if (bytes.length < 24 || !bytes.subarray(0, 4).equals(PNG_SIGNATURE)) {
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);