blume 1.3.1 → 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.
Files changed (139) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli/index.js +3512 -814
  3. package/dist/cli/index.js.map +99 -87
  4. package/dist/types/core/base-path.d.ts +5 -0
  5. package/dist/types/core/config-input.d.ts +82 -6
  6. package/dist/types/core/i18n-ui.d.ts +2 -0
  7. package/dist/types/core/schema.d.ts +19 -2
  8. package/dist/types/core/sources/types.d.ts +5 -0
  9. package/dist/types/core/types.d.ts +4 -3
  10. package/docs/02-deployment.mdx +1 -1
  11. package/docs/configuration/ai.mdx +15 -1
  12. package/docs/configuration/index.mdx +26 -0
  13. package/docs/configuration/search.mdx +1 -3
  14. package/docs/content/i18n.mdx +13 -1
  15. package/docs/content/navigation.mdx +11 -0
  16. package/docs/reference/cli.mdx +4 -0
  17. package/docs/reference/frontmatter.mdx +33 -0
  18. package/docs/reference/meta.ts +1 -1
  19. package/docs/reference/translate.mdx +80 -0
  20. package/package.json +22 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/component-markdown.ts +7 -6
  24. package/src/ai/mcp/data.ts +10 -4
  25. package/src/ai/mcp/server.ts +74 -3
  26. package/src/ai/mcp/tools.ts +2 -2
  27. package/src/astro/generate.ts +4 -13
  28. package/src/astro/integration.ts +3 -1
  29. package/src/astro/islands.ts +4 -1
  30. package/src/astro/markdown-negotiation.ts +5 -0
  31. package/src/astro/templates.ts +69 -22
  32. package/src/audit/checks/indexability.ts +3 -6
  33. package/src/audit/checks/robots.ts +18 -37
  34. package/src/audit/crawl.ts +49 -49
  35. package/src/audit/image-size.ts +13 -53
  36. package/src/audit/report.ts +22 -33
  37. package/src/audit/types.ts +6 -2
  38. package/src/audit/url.ts +5 -10
  39. package/src/cli/commands/build.ts +129 -24
  40. package/src/cli/commands/dev.ts +9 -21
  41. package/src/cli/commands/doctor.ts +9 -22
  42. package/src/cli/commands/translate.ts +300 -0
  43. package/src/cli/env.ts +6 -52
  44. package/src/cli/index.ts +2 -0
  45. package/src/cli/init/scaffold.ts +15 -28
  46. package/src/cli/internal-error.ts +11 -11
  47. package/src/components/Icon.astro +2 -7
  48. package/src/components/content/Step.astro +3 -8
  49. package/src/components/content/Tab.astro +20 -1
  50. package/src/components/islands/ask-ai.tsx +25 -100
  51. package/src/components/islands/hooks.ts +10 -3
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +4 -4
  54. package/src/components/layout/PageActions.astro +12 -7
  55. package/src/components/layout/RootLayout.astro +37 -109
  56. package/src/components/layout/Search.astro +18 -25
  57. package/src/components/layout/search/orama.ts +3 -1
  58. package/src/components/layout/search/types.ts +4 -16
  59. package/src/components/openapi/helpers.ts +21 -75
  60. package/src/core/base-path.ts +9 -0
  61. package/src/core/component-overrides.ts +0 -7
  62. package/src/core/config-input.ts +84 -6
  63. package/src/core/config.ts +3 -3
  64. package/src/core/diagnostics.ts +10 -20
  65. package/src/core/fs-atomic.ts +22 -0
  66. package/src/core/graph.ts +46 -2
  67. package/src/core/i18n-ui.ts +2 -0
  68. package/src/core/i18n.ts +31 -0
  69. package/src/core/nav-diagnostics.ts +13 -34
  70. package/src/core/project-graph.ts +13 -2
  71. package/src/core/schema.ts +174 -74
  72. package/src/core/sources/github-releases.ts +29 -26
  73. package/src/core/sources/mdx-remote.ts +10 -57
  74. package/src/core/sources/normalize.ts +25 -12
  75. package/src/core/sources/notion.ts +17 -23
  76. package/src/core/sources/types.ts +5 -0
  77. package/src/core/tsconfig-aliases.ts +39 -172
  78. package/src/core/types.ts +4 -3
  79. package/src/core/ui-packs/ar.ts +42 -1
  80. package/src/core/ui-packs/bg.ts +42 -1
  81. package/src/core/ui-packs/bn.ts +42 -1
  82. package/src/core/ui-packs/ca.ts +44 -1
  83. package/src/core/ui-packs/cs.ts +42 -1
  84. package/src/core/ui-packs/da.ts +42 -1
  85. package/src/core/ui-packs/de.ts +42 -1
  86. package/src/core/ui-packs/el.ts +44 -1
  87. package/src/core/ui-packs/es.ts +44 -1
  88. package/src/core/ui-packs/fa.ts +42 -1
  89. package/src/core/ui-packs/fi.ts +42 -1
  90. package/src/core/ui-packs/fr.ts +44 -1
  91. package/src/core/ui-packs/he.ts +42 -1
  92. package/src/core/ui-packs/hi.ts +42 -1
  93. package/src/core/ui-packs/hr.ts +42 -1
  94. package/src/core/ui-packs/hu.ts +42 -1
  95. package/src/core/ui-packs/id.ts +42 -1
  96. package/src/core/ui-packs/it.ts +44 -1
  97. package/src/core/ui-packs/ja.ts +44 -1
  98. package/src/core/ui-packs/ko.ts +44 -1
  99. package/src/core/ui-packs/nl.ts +42 -1
  100. package/src/core/ui-packs/no.ts +42 -1
  101. package/src/core/ui-packs/pl.ts +42 -1
  102. package/src/core/ui-packs/pt-br.ts +44 -1
  103. package/src/core/ui-packs/pt.ts +44 -1
  104. package/src/core/ui-packs/ro.ts +42 -1
  105. package/src/core/ui-packs/ru.ts +42 -1
  106. package/src/core/ui-packs/sk.ts +42 -1
  107. package/src/core/ui-packs/sr.ts +42 -1
  108. package/src/core/ui-packs/sv.ts +42 -1
  109. package/src/core/ui-packs/th.ts +44 -1
  110. package/src/core/ui-packs/tr.ts +42 -1
  111. package/src/core/ui-packs/uk.ts +42 -1
  112. package/src/core/ui-packs/vi.ts +44 -1
  113. package/src/core/ui-packs/zh-tw.ts +44 -1
  114. package/src/core/ui-packs/zh.ts +44 -1
  115. package/src/deploy/adapter-output.ts +44 -5
  116. package/src/deploy/cloudflare-negotiation.ts +527 -0
  117. package/src/deploy/redirects.ts +13 -0
  118. package/src/deploy/rss.ts +4 -1
  119. package/src/deploy/sitemap.ts +3 -1
  120. package/src/eval/agents.ts +1 -1
  121. package/src/eval/report.ts +20 -28
  122. package/src/markdown/directives.ts +6 -18
  123. package/src/markdown/index.ts +1 -6
  124. package/src/markdown/package-commands.ts +0 -4
  125. package/src/openapi/parse.ts +11 -9
  126. package/src/search/documents.ts +11 -0
  127. package/src/search/facets.ts +33 -0
  128. package/src/search/orama-index.ts +48 -6
  129. package/src/search/popular-icon.ts +33 -0
  130. package/src/theme/icon-kind.ts +20 -0
  131. package/src/translate/agents.ts +51 -0
  132. package/src/translate/ledger.ts +142 -0
  133. package/src/translate/meta.ts +149 -0
  134. package/src/translate/prompts.ts +95 -0
  135. package/src/translate/report.ts +354 -0
  136. package/src/translate/run.ts +357 -0
  137. package/src/translate/validate.ts +171 -0
  138. package/src/translate/work-list.ts +0 -0
  139. package/src/deploy/xml.ts +0 -8
@@ -93,12 +93,15 @@ export const buildAgentReadability = (
93
93
 
94
94
  // Advertise `Accept: text/markdown` negotiation only where the deployed site
95
95
  // actually honors it — a Vercel server build, whose routing config gets the
96
- // rewrite rules (see `deploy/vercel-negotiation.ts`). Static builds and other
97
- // adapters serve prerendered pages from a static layer with no request-time
98
- // hook, so agents there should fetch the `.md` pattern directly.
96
+ // rewrite rules (see `deploy/vercel-negotiation.ts`), and a Cloudflare server
97
+ // build, whose deploy bundle gets a wrapper Worker (see
98
+ // `deploy/cloudflare-negotiation.ts`). Static builds and other adapters serve
99
+ // prerendered pages from a static layer with no request-time hook, so agents
100
+ // there should fetch the `.md` pattern directly.
99
101
  const negotiates =
100
102
  config.deployment.output === "server" &&
101
- config.deployment.adapter === "vercel";
103
+ (config.deployment.adapter === "vercel" ||
104
+ config.deployment.adapter === "cloudflare");
102
105
  const artifacts: Record<string, unknown> = {
103
106
  markdown: {
104
107
  ...(negotiates ? { contentNegotiation: "text/markdown" } : {}),
@@ -226,12 +226,9 @@ export const createAskContext = (
226
226
  ? byRoute.get(normalizeRoute(page.path))
227
227
  : undefined;
228
228
  const db = await index();
229
- const hits = await queryOramaIndex(
230
- db,
231
- query,
232
- MAX_RESULTS,
233
- current?.locale || undefined
234
- );
229
+ const hits = await queryOramaIndex(db, query, MAX_RESULTS, {
230
+ locale: current?.locale || undefined,
231
+ });
235
232
 
236
233
  const seen = new Set<string>();
237
234
  const sections: string[] = [];
@@ -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");
@@ -2,6 +2,7 @@ import { normalizeBasePath } from "../../core/base-path.ts";
2
2
  import type { BlumeProject } from "../../core/project-graph.ts";
3
3
  import type { Navigation } from "../../core/types.ts";
4
4
  import { buildSearchDocuments } from "../../search/documents.ts";
5
+ import { pageFacets } from "../../search/facets.ts";
5
6
  import type { OramaDoc } from "../../search/orama-index.ts";
6
7
  import { agentMarkdown, buildRawMarkdown } from "../markdown.ts";
7
8
 
@@ -9,6 +10,8 @@ import { agentMarkdown, buildRawMarkdown } from "../markdown.ts";
9
10
  export interface McpRoute {
10
11
  contentType: string;
11
12
  description?: string;
13
+ /** Declared facet values (`content.types.<type>.facets`), key → value. */
14
+ facets?: Record<string, string>;
12
15
  indexable: boolean;
13
16
  lastModified: string | null;
14
17
  route: string;
@@ -67,18 +70,19 @@ export const buildMcpData = async (project: BlumeProject): Promise<McpData> => {
67
70
  ])
68
71
  );
69
72
 
70
- const descriptionById = new Map(
71
- graph.pages.map((page) => [page.id, page.description])
72
- );
73
+ const pageById = new Map(graph.pages.map((page) => [page.id, page]));
73
74
 
74
75
  const routes: McpRoute[] = [];
75
76
  for (const route of manifest.routes) {
76
77
  if (route.hidden) {
77
78
  continue;
78
79
  }
80
+ const page = pageById.get(route.id);
81
+ const facets = page ? pageFacets(page, config) : undefined;
79
82
  routes.push({
80
83
  contentType: route.contentType,
81
- description: descriptionById.get(route.id),
84
+ description: page?.description,
85
+ ...(facets ? { facets } : {}),
82
86
  indexable: route.indexable,
83
87
  lastModified: route.lastModified ?? null,
84
88
  route: route.path,
@@ -91,7 +95,9 @@ export const buildMcpData = async (project: BlumeProject): Promise<McpData> => {
91
95
  defaultLocale: config.i18n?.defaultLocale,
92
96
  documents: documents.map((doc) => ({
93
97
  content: doc.content,
98
+ contentType: doc.contentType,
94
99
  description: doc.description,
100
+ ...(doc.facets ? { facets: doc.facets } : {}),
95
101
  route: doc.route,
96
102
  title: doc.title,
97
103
  })),
@@ -33,6 +33,22 @@ const CORS_HEADERS: Record<string, string> = {
33
33
  "Access-Control-Expose-Headers": "Mcp-Session-Id",
34
34
  };
35
35
 
36
+ /** The optional content-type filter `search_docs` and `list_pages` share. */
37
+ const CONTENT_TYPES_SCHEMA = {
38
+ description:
39
+ 'Only include pages of these content types (frontmatter `type`, e.g. `["doc", "rfc"]`). `list_pages` shows each page\'s type. Omit to include every type.',
40
+ items: { type: "string" },
41
+ type: "array",
42
+ } as const;
43
+
44
+ /** The optional facet filter `search_docs` and `list_pages` share. */
45
+ const FILTERS_SCHEMA = {
46
+ additionalProperties: { type: "string" },
47
+ description:
48
+ 'Only include pages matching every facet, key → required value (e.g. `{"status": "enforced"}`). Facets are metadata the site declares per content type; `list_pages` shows each page\'s facet values. Omit for no facet filtering.',
49
+ type: "object",
50
+ } as const;
51
+
36
52
  /** JSON Schema for each tool's input, keyed by tool name. */
37
53
  const INPUT_SCHEMAS: Record<string, Record<string, unknown>> = {
38
54
  get_navigation: { properties: {}, type: "object" },
@@ -46,9 +62,17 @@ const INPUT_SCHEMAS: Record<string, Record<string, unknown>> = {
46
62
  required: ["route"],
47
63
  type: "object",
48
64
  },
49
- list_pages: { properties: {}, type: "object" },
65
+ list_pages: {
66
+ properties: {
67
+ contentTypes: CONTENT_TYPES_SCHEMA,
68
+ filters: FILTERS_SCHEMA,
69
+ },
70
+ type: "object",
71
+ },
50
72
  search_docs: {
51
73
  properties: {
74
+ contentTypes: CONTENT_TYPES_SCHEMA,
75
+ filters: FILTERS_SCHEMA,
52
76
  limit: {
53
77
  description: `Maximum hits to return (default ${DEFAULT_SEARCH_LIMIT}).`,
54
78
  maximum: MAX_SEARCH_LIMIT,
@@ -74,6 +98,39 @@ const TOOL_DEFINITIONS = MCP_TOOLS.map((tool) => ({
74
98
  const asString = (value: unknown): string =>
75
99
  typeof value === "string" ? value : "";
76
100
 
101
+ /**
102
+ * The `contentTypes` filter as a string array, or `undefined` when absent or
103
+ * empty — an agent sending `[]` means "no filter", not "match nothing". A bare
104
+ * string is accepted as a one-element list.
105
+ */
106
+ const asContentTypes = (value: unknown): string[] | undefined => {
107
+ const list = Array.isArray(value)
108
+ ? value.filter((entry): entry is string => typeof entry === "string")
109
+ : [value].filter((entry): entry is string => typeof entry === "string");
110
+ return list.length > 0 ? list : undefined;
111
+ };
112
+
113
+ /**
114
+ * The `filters` facet map with only its string-valued entries, or `undefined`
115
+ * when nothing usable remains — an empty `{}` means "no filter".
116
+ */
117
+ const asFacetFilters = (value: unknown): Record<string, string> | undefined => {
118
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
119
+ return;
120
+ }
121
+ const entries = Object.entries(value).filter(
122
+ (entry): entry is [string, string] => typeof entry[1] === "string"
123
+ );
124
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
125
+ };
126
+
127
+ /** Whether a page's facet values satisfy every requested filter entry. */
128
+ const matchesFacets = (
129
+ facets: Record<string, string> | undefined,
130
+ filters: Record<string, string>
131
+ ): boolean =>
132
+ Object.entries(filters).every(([key, value]) => facets?.[key] === value);
133
+
77
134
  const asLimit = (value: unknown): number => {
78
135
  const num = typeof value === "number" ? value : Number(value);
79
136
  if (!Number.isFinite(num)) {
@@ -181,12 +238,18 @@ export const buildServer = (
181
238
  const hits = await queryOramaIndex(
182
239
  db,
183
240
  asString(args.query),
184
- asLimit(args.limit)
241
+ asLimit(args.limit),
242
+ {
243
+ contentTypes: asContentTypes(args.contentTypes),
244
+ facets: asFacetFilters(args.filters),
245
+ }
185
246
  );
186
247
  // `route` is the key `get_page` takes (the tool descriptions promise
187
248
  // it); `url` is where the page is served.
188
249
  const results = hits.map((doc: OramaDoc) => ({
250
+ contentType: doc.contentType,
189
251
  excerpt: excerptFor(doc),
252
+ facets: doc.facets,
190
253
  route: doc.route,
191
254
  title: doc.title,
192
255
  url: urlFor(doc.route, data),
@@ -207,11 +270,19 @@ export const buildServer = (
207
270
  }
208
271
 
209
272
  if (name === "list_pages") {
273
+ const contentTypes = asContentTypes(args.contentTypes);
274
+ const filters = asFacetFilters(args.filters);
275
+ const routes = data.routes.filter(
276
+ (route) =>
277
+ (!contentTypes || contentTypes.includes(route.contentType)) &&
278
+ (!filters || matchesFacets(route.facets, filters))
279
+ );
210
280
  return text(
211
281
  JSON.stringify(
212
- data.routes.map((route) => ({
282
+ routes.map((route) => ({
213
283
  contentType: route.contentType,
214
284
  description: route.description,
285
+ facets: route.facets,
215
286
  lastModified: route.lastModified,
216
287
  route: route.route,
217
288
  title: route.title,
@@ -19,7 +19,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
19
19
  {
20
20
  annotations: READ_ONLY,
21
21
  description:
22
- "Full-text search across the documentation. Returns matching pages with their title, route, and a short excerpt. Use this first to discover relevant pages, then `get_page` to read one in full.",
22
+ 'Full-text search across the documentation. Returns matching pages with their title, route, content type, and a short excerpt; pass `contentTypes` to search only pages of certain types (e.g. `rfc`, `changelog`), and `filters` to require facet values the site declares per type (e.g. `{"status": "enforced"}`). Use this first to discover relevant pages, then `get_page` to read one in full.',
23
23
  name: "search_docs",
24
24
  title: "Search documentation",
25
25
  },
@@ -33,7 +33,7 @@ export const MCP_TOOLS: McpToolMeta[] = [
33
33
  {
34
34
  annotations: READ_ONLY,
35
35
  description:
36
- "List every documentation page with its route, title, description, and content type. Useful for enumerating the docs or finding a page when search is too narrow.",
36
+ "List every documentation page with its route, title, description, content type, and any declared facet values; pass `contentTypes` and/or `filters` to narrow the list. Useful for enumerating the docs, discovering the types and facets in use, or finding a page when search is too narrow.",
37
37
  name: "list_pages",
38
38
  title: "List pages",
39
39
  },
@@ -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
 
@@ -114,7 +114,9 @@ const isHomeUrl = (rawUrl: string | undefined, base?: string): boolean => {
114
114
  * production the content pages are prerendered and served from the platform's
115
115
  * static layer, which this middleware never fronts. Vercel server builds get
116
116
  * the same negotiation from routing rules spliced into the Build Output config
117
- * (see `deploy/vercel-negotiation.ts`); every other build exposes the same
117
+ * (see `deploy/vercel-negotiation.ts`), Cloudflare server builds from a
118
+ * wrapper Worker routed to by `assets.run_worker_first` (see
119
+ * `deploy/cloudflare-negotiation.ts`); every other build exposes the same
118
120
  * content at the `.md` URL. Only routes with a Markdown variant are rewritten,
119
121
  * so user `.astro` pages keep serving HTML — except the homepage, whose
120
122
  * variant falls back to the synthesized llms.txt mirror when it's a landing
@@ -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,
@@ -3,6 +3,11 @@
3
3
  * endpoints already serve a page's source verbatim; these helpers let the dev
4
4
  * server honor `Accept: text/markdown` by transparently rewriting a page
5
5
  * request to its `.md` variant.
6
+ *
7
+ * The Cloudflare negotiation Worker embeds a plain-JavaScript copy of these
8
+ * helpers (`deploy/cloudflare-negotiation.ts` — its deploy bundle is uploaded
9
+ * unbundled, so it cannot import this module); when editing here, mirror the
10
+ * change there. Parity is enforced by `test/cloudflare-negotiation.test.ts`.
6
11
  */
7
12
 
8
13
  interface AcceptEntry {
@@ -370,6 +370,38 @@ const renderImageOption = (config: ResolvedConfig): string =>
370
370
  ? `\n image: ${JSON.stringify(config.image)},`
371
371
  : "";
372
372
 
373
+ /**
374
+ * Startup-scan entry points and forced includes for the dev dep optimizer:
375
+ * the Vite root is the generated runtime, so user pages, convention islands,
376
+ * and alias-reachable components all live outside it and are otherwise only
377
+ * crawled when first requested. The compiler runtime rides the include list
378
+ * because it is Babel-injected and no source scan can see it. See the
379
+ * optimizeDeps comment in the generated config for the failure this prevents.
380
+ */
381
+ const resolveOptimizeDeps = (options: {
382
+ aliases: Record<string, string> | undefined;
383
+ context: ProjectContext;
384
+ needsReact: boolean;
385
+ reactCompilerPath: string | null | undefined;
386
+ }): { optimizeDepsEntries: string[]; optimizeDepsInclude: string[] } => {
387
+ const { context } = options;
388
+ const optimizeDepsEntries = [
389
+ ...(context.pagesRoot ? [`${context.pagesRoot}/**/*.astro`] : []),
390
+ `${context.root}/islands/**/*.{jsx,svelte,tsx,vue}`,
391
+ ...[...new Set(Object.values(options.aliases ?? {}))]
392
+ .toSorted()
393
+ .map((dir) => `${dir}/**/*.{astro,jsx,svelte,tsx,vue}`),
394
+ ];
395
+ const optimizeDepsInclude = [
396
+ "blume > mermaid",
397
+ "blume > epub-gen-memory/bundle",
398
+ ...(options.needsReact && options.reactCompilerPath
399
+ ? ["react/compiler-runtime"]
400
+ : []),
401
+ ];
402
+ return { optimizeDepsEntries, optimizeDepsInclude };
403
+ };
404
+
373
405
  export const astroConfigTemplate = (options: {
374
406
  context: ProjectContext;
375
407
  config: ResolvedConfig;
@@ -424,6 +456,13 @@ export const astroConfigTemplate = (options: {
424
456
  // KaTeX fonts under a monorepo's root node_modules) stay servable in dev.
425
457
  const fsAllow = [...new Set([findWorkspaceRoot(context.root), context.root])];
426
458
 
459
+ const { optimizeDepsEntries, optimizeDepsInclude } = resolveOptimizeDeps({
460
+ aliases: options.aliases,
461
+ context,
462
+ needsReact,
463
+ reactCompilerPath: options.reactCompilerPath,
464
+ });
465
+
427
466
  const adapterImport =
428
467
  server && deployment.adapter
429
468
  ? `import adapter from "${ADAPTER_IMPORTS[deployment.adapter]}";\n`
@@ -642,24 +681,32 @@ ${userConfigSetup}export default defineConfig({
642
681
  devToolbar: { enabled: false },
643
682
  vite: {
644
683
  plugins: [tailwindcss(), prerenderDepsPlugin(), serverAppResolvePlugin()],
645
- // The lazy client-side imports both land on CJS/UMD files: mermaid (for
646
- // diagrams) statically imports dayjs as CJS (\`dayjs/dayjs.min.js\`), and
647
- // epub-gen-memory's browser bundle is a browserified UMD. In dev, an
648
- // un-pre-bundled dependency is served as raw ESM, where such a file
649
- // exposes no \`default\` export mermaid throws on load and diagrams
650
- // render blank, and the EPUB export throws \`epub is not a function\`
651
- // (the UMD finds no \`exports\`/\`define\` and strands its callable on
652
- // \`window.epubGen\` instead). Forcing them through the dep optimizer
653
- // restores the CJS interop. In a standalone install these dynamic imports
654
- // live inside \`node_modules/blume\`, which Vite's optimizer scan doesn't
655
- // crawl, so neither is discovered on its own — hence the explicit
656
- // includes. They resolve through the \`blume\` package (they aren't direct
657
- // deps of the generated project), so the nested \`blume > x\` form is
658
- // required, and epub-gen-memory must name the \`/bundle\` subpath that is
659
- // actually imported: optimizing the package root leaves that entry out.
660
- // Production (Rollup) already handles the interop, so this only affects dev.
684
+ // Everything hydration can reach must be part of the dev dep optimizer's
685
+ // FIRST run. The Vite root is the generated runtime, so user pages,
686
+ // islands, and aliased components live outside it and are only crawled
687
+ // when first requested and \`react/compiler-runtime\` is Babel-injected,
688
+ // so no source scan can ever see it. A dependency discovered after
689
+ // hydration begins triggers a mid-session re-optimization whose new
690
+ // generation imports React through new \`?v=\` URLs; the browser then
691
+ // evaluates a second React copy and every island tears down with
692
+ // "Invalid hook call" (#157). \`entries\` points the startup scanner at
693
+ // the user's files (the scanner follows their imports, so their deps land
694
+ // in the initial optimization); the compiler runtime rides the include
695
+ // list because only the transform pipeline knows it exists.
696
+ //
697
+ // The mermaid/epub includes fix CJS interop instead: both lazy client-side
698
+ // imports land on CJS/UMD files (mermaid statically imports dayjs as CJS,
699
+ // epub-gen-memory's browser bundle is a browserified UMD) that break when
700
+ // served as raw ESM — mermaid throws on load and the EPUB export throws
701
+ // \`epub is not a function\`. They resolve through the \`blume\` package
702
+ // (they aren't direct deps of the generated project), so the nested
703
+ // \`blume > x\` form is required, and epub-gen-memory must name the
704
+ // \`/bundle\` subpath that is actually imported: optimizing the package
705
+ // root leaves that entry out. Production (Rollup) already handles the
706
+ // interop, so all of this only affects dev.
661
707
  optimizeDeps: {
662
- include: ["blume > mermaid", "blume > epub-gen-memory/bundle"],
708
+ entries: ${JSON.stringify(optimizeDepsEntries)},
709
+ include: ${JSON.stringify(optimizeDepsInclude)},
663
710
  },
664
711
  // Blume's render-time deps are forced external on both build environments so
665
712
  // native bindings resolve at runtime and isolated linkers don't bundle
@@ -1080,10 +1127,9 @@ export const searchClientTemplate = (config: ResolvedConfig): string => {
1080
1127
  if (search.provider === "orama" || search.provider === "flexsearch") {
1081
1128
  // Only Orama derives a tokenizer from the locale; FlexSearch has no
1082
1129
  // equivalent hook, so its client keeps the bare index URL.
1083
- return staticSearchClient(
1084
- search.provider,
1085
- search.provider === "orama" ? config.i18n?.defaultLocale : undefined
1086
- );
1130
+ const locale =
1131
+ search.provider === "orama" ? config.i18n?.defaultLocale : undefined;
1132
+ return staticSearchClient(search.provider, locale);
1087
1133
  }
1088
1134
 
1089
1135
  const hosted = hostedSearchOptions(search);
@@ -1309,11 +1355,12 @@ export const mcpEndpointTemplate = (route: string): string => {
1309
1355
  return `// Generated by Blume. Do not edit.
1310
1356
  import type { APIRoute } from "astro";
1311
1357
  import { createMcpFetchHandler } from "blume/ai/mcp/server.ts";
1358
+ import type { McpData } from "blume/ai/mcp/data.ts";
1312
1359
  import data from "${up}generated/mcp-data.json";
1313
1360
 
1314
1361
  export const prerender = false;
1315
1362
 
1316
- const handler = createMcpFetchHandler(data);
1363
+ const handler = createMcpFetchHandler(data as McpData);
1317
1364
 
1318
1365
  export const ALL: APIRoute = ({ request }) => handler(request);
1319
1366
  `;
@@ -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
  }