blume 1.3.0 → 1.4.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/cli/index.js +3290 -254
  3. package/dist/cli/index.js.map +79 -67
  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 +16 -2
  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 +1 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/link-headers.ts +4 -3
  24. package/src/ai/llms.ts +4 -2
  25. package/src/ai/markdown.ts +34 -1
  26. package/src/ai/mcp/data.ts +10 -4
  27. package/src/ai/mcp/server.ts +74 -3
  28. package/src/ai/mcp/tools.ts +2 -2
  29. package/src/astro/generate.ts +2 -2
  30. package/src/astro/integration.ts +6 -2
  31. package/src/astro/markdown-negotiation.ts +5 -0
  32. package/src/astro/templates.ts +74 -21
  33. package/src/audit/url.ts +5 -10
  34. package/src/cli/commands/build.ts +145 -34
  35. package/src/cli/commands/translate.ts +300 -0
  36. package/src/cli/index.ts +2 -0
  37. package/src/components/Icon.astro +2 -7
  38. package/src/components/content/Step.astro +3 -8
  39. package/src/components/content/Tab.astro +20 -1
  40. package/src/components/layout/LanguageSwitcher.astro +2 -1
  41. package/src/components/layout/Logo.astro +4 -4
  42. package/src/components/layout/PageActions.astro +12 -7
  43. package/src/components/layout/Search.astro +15 -20
  44. package/src/components/layout/search/orama.ts +3 -1
  45. package/src/core/base-path.ts +9 -0
  46. package/src/core/config-input.ts +84 -6
  47. package/src/core/graph.ts +46 -2
  48. package/src/core/i18n-ui.ts +2 -0
  49. package/src/core/i18n.ts +31 -0
  50. package/src/core/nav-diagnostics.ts +13 -34
  51. package/src/core/project-graph.ts +13 -2
  52. package/src/core/schema.ts +174 -74
  53. package/src/core/sources/normalize.ts +25 -12
  54. package/src/core/sources/types.ts +5 -0
  55. package/src/core/types.ts +4 -3
  56. package/src/core/ui-packs/ar.ts +42 -1
  57. package/src/core/ui-packs/bg.ts +42 -1
  58. package/src/core/ui-packs/bn.ts +42 -1
  59. package/src/core/ui-packs/ca.ts +44 -1
  60. package/src/core/ui-packs/cs.ts +42 -1
  61. package/src/core/ui-packs/da.ts +42 -1
  62. package/src/core/ui-packs/de.ts +42 -1
  63. package/src/core/ui-packs/el.ts +44 -1
  64. package/src/core/ui-packs/es.ts +44 -1
  65. package/src/core/ui-packs/fa.ts +42 -1
  66. package/src/core/ui-packs/fi.ts +42 -1
  67. package/src/core/ui-packs/fr.ts +44 -1
  68. package/src/core/ui-packs/he.ts +42 -1
  69. package/src/core/ui-packs/hi.ts +42 -1
  70. package/src/core/ui-packs/hr.ts +42 -1
  71. package/src/core/ui-packs/hu.ts +42 -1
  72. package/src/core/ui-packs/id.ts +42 -1
  73. package/src/core/ui-packs/it.ts +44 -1
  74. package/src/core/ui-packs/ja.ts +44 -1
  75. package/src/core/ui-packs/ko.ts +44 -1
  76. package/src/core/ui-packs/nl.ts +42 -1
  77. package/src/core/ui-packs/no.ts +42 -1
  78. package/src/core/ui-packs/pl.ts +42 -1
  79. package/src/core/ui-packs/pt-br.ts +44 -1
  80. package/src/core/ui-packs/pt.ts +44 -1
  81. package/src/core/ui-packs/ro.ts +42 -1
  82. package/src/core/ui-packs/ru.ts +42 -1
  83. package/src/core/ui-packs/sk.ts +42 -1
  84. package/src/core/ui-packs/sr.ts +42 -1
  85. package/src/core/ui-packs/sv.ts +42 -1
  86. package/src/core/ui-packs/th.ts +44 -1
  87. package/src/core/ui-packs/tr.ts +42 -1
  88. package/src/core/ui-packs/uk.ts +42 -1
  89. package/src/core/ui-packs/vi.ts +44 -1
  90. package/src/core/ui-packs/zh-tw.ts +44 -1
  91. package/src/core/ui-packs/zh.ts +44 -1
  92. package/src/deploy/adapter-output.ts +44 -5
  93. package/src/deploy/cloudflare-negotiation.ts +527 -0
  94. package/src/deploy/redirects.ts +13 -0
  95. package/src/deploy/vercel-negotiation.ts +30 -13
  96. package/src/eval/agents.ts +1 -1
  97. package/src/search/documents.ts +11 -0
  98. package/src/search/facets.ts +33 -0
  99. package/src/search/orama-index.ts +48 -6
  100. package/src/search/popular-icon.ts +33 -0
  101. package/src/theme/fonts.ts +3 -1
  102. package/src/theme/icon-kind.ts +20 -0
  103. package/src/translate/agents.ts +51 -0
  104. package/src/translate/ledger.ts +148 -0
  105. package/src/translate/meta.ts +149 -0
  106. package/src/translate/prompts.ts +95 -0
  107. package/src/translate/report.ts +360 -0
  108. package/src/translate/run.ts +376 -0
  109. package/src/translate/validate.ts +171 -0
  110. package/src/translate/work-list.ts +0 -0
package/src/ai/llms.ts CHANGED
@@ -60,8 +60,10 @@ const indexedNavigations = (
60
60
  * Build the compact `llms.txt` index: title and summary, then the sidebar tree
61
61
  * rendered as sections — group labels become headings, pages become link lists —
62
62
  * so the file mirrors how the docs are organized rather than one flat blob.
63
+ * Also serves as the homepage's synthesized Markdown mirror when the home
64
+ * route is a landing page (see `buildRawMarkdown`).
63
65
  */
64
- const buildIndex = (project: BlumeProject): string => {
66
+ export const buildLlmsIndex = (project: BlumeProject): string => {
65
67
  const { config } = project;
66
68
  const { site } = config.deployment;
67
69
  const base = normalizeBasePath(config.deployment.base);
@@ -222,5 +224,5 @@ export const buildLlmsFiles = async (
222
224
  project: BlumeProject
223
225
  ): Promise<{ index: string; full: string }> => ({
224
226
  full: await buildFull(project),
225
- index: buildIndex(project),
227
+ index: buildLlmsIndex(project),
226
228
  });
@@ -9,6 +9,7 @@ import {
9
9
  downlevelComponents,
10
10
  exampleComponentSerializers,
11
11
  } from "./component-markdown.ts";
12
+ import { buildLlmsIndex } from "./llms.ts";
12
13
  import { applyAgentVisibility } from "./visibility.ts";
13
14
 
14
15
  /** One route's raw-Markdown variants. */
@@ -28,6 +29,16 @@ export interface RawMarkdownEntry {
28
29
  export const agentMarkdown = (entry: RawMarkdownEntry): string =>
29
30
  entry.md ?? entry.mdx;
30
31
 
32
+ /**
33
+ * Estimated token count of a Markdown document, for the `x-markdown-tokens`
34
+ * response header (the convention Cloudflare's Markdown for Agents ships; the
35
+ * tokenizer is unspecified there too, so this is the common ~4-characters-
36
+ * per-token estimate). Kept in sync with the inline computation in
37
+ * `rawMarkdownEndpointTemplate`, which can't import build-time code.
38
+ */
39
+ export const markdownTokenCount = (text: string): number =>
40
+ Math.ceil(text.length / 4);
41
+
31
42
  /**
32
43
  * Map every route to its raw source Markdown. Powers the `<route>.md` and
33
44
  * `<route>.mdx` endpoints: `.mdx` serves the original source so tools can see
@@ -79,5 +90,27 @@ export const buildRawMarkdown = async (
79
90
  return [route.path, entry] as const;
80
91
  })
81
92
  );
82
- return Object.fromEntries(entries);
93
+ const map = Object.fromEntries(entries);
94
+ // A landing-page homepage (user `.astro` page, or no home route at all) has
95
+ // no Markdown source, but agents negotiating `Accept: text/markdown` on `/`
96
+ // still expect a Markdown answer. The llms.txt index — the machine-readable
97
+ // representation of the site a landing page fronts — becomes its mirror, so
98
+ // `/index.md` always exists (see `markdownRoutePaths`).
99
+ if (!map["/"]) {
100
+ map["/"] = { mdx: buildLlmsIndex(project) };
101
+ }
102
+ return map;
103
+ };
104
+
105
+ /**
106
+ * Every route path with a raw-Markdown mirror: the manifest routes, plus the
107
+ * homepage when its mirror is the synthesized llms.txt fallback (see
108
+ * `buildRawMarkdown`). This is the route list the negotiation surfaces (dev
109
+ * middleware, Vercel routing config) and the homepage `Link` header build
110
+ * from, so `Accept: text/markdown` on `/` resolves even when the homepage is
111
+ * a landing page.
112
+ */
113
+ export const markdownRoutePaths = (project: BlumeProject): string[] => {
114
+ const paths = project.manifest.routes.map((route) => route.path);
115
+ return paths.includes("/") ? paths : [...paths, "/"];
83
116
  };
@@ -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
  },
@@ -19,7 +19,7 @@ import { glob } from "tinyglobby";
19
19
 
20
20
  import { buildAskData } from "../ai/ask-data.ts";
21
21
  import { resolveAskBackend } from "../ai/ask.ts";
22
- import { buildRawMarkdown } from "../ai/markdown.ts";
22
+ import { buildRawMarkdown, markdownRoutePaths } from "../ai/markdown.ts";
23
23
  import { buildMcpData } from "../ai/mcp/data.ts";
24
24
  import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
25
25
  import { normalizeBasePath } from "../core/base-path.ts";
@@ -1608,7 +1608,7 @@ export const generateRuntime = async (
1608
1608
  aliases: resolveTsconfigAliases(context.root),
1609
1609
  askPath,
1610
1610
  config,
1611
- contentRoutes: project.manifest.routes.map((route) => route.path),
1611
+ contentRoutes: markdownRoutePaths(project),
1612
1612
  contentWatchesRuntimeDir: contentWatchesRuntimeDir(
1613
1613
  hasFilesystemSource,
1614
1614
  docsCollection.base,
@@ -114,9 +114,13 @@ 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
- * so landing pages and user `.astro` pages keep serving HTML. The same
121
+ * so user `.astro` pages keep serving HTML except the homepage, whose
122
+ * variant falls back to the synthesized llms.txt mirror when it's a landing
123
+ * page (see `markdownRoutePaths`). The same
120
124
  * middleware also stamps the homepage agent-discovery `Link` header, mirroring
121
125
  * what the deployed site sends via `_headers` / the Vercel routing config.
122
126
  */
@@ -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
@@ -1181,10 +1228,15 @@ export function getStaticPaths() {
1181
1228
  export function GET({ props }: { props: { route: string } }) {
1182
1229
  const entries = raw as Record<string, { md?: string; mdx?: string }>;
1183
1230
  const entry = entries[props.route];
1184
- return new Response(entry ? ${
1231
+ const body = entry ? ${
1185
1232
  kind === "md" ? '(entry.md ?? entry.mdx ?? "")' : '(entry.mdx ?? "")'
1186
- } : "", {
1187
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
1233
+ } : "";
1234
+ return new Response(body, {
1235
+ headers: {
1236
+ "Content-Type": "text/markdown; charset=utf-8",
1237
+ // ~4 characters per token; keep in sync with markdownTokenCount.
1238
+ "x-markdown-tokens": String(Math.ceil(body.length / 4)),
1239
+ },
1188
1240
  });
1189
1241
  }
1190
1242
  `;
@@ -1304,11 +1356,12 @@ export const mcpEndpointTemplate = (route: string): string => {
1304
1356
  return `// Generated by Blume. Do not edit.
1305
1357
  import type { APIRoute } from "astro";
1306
1358
  import { createMcpFetchHandler } from "blume/ai/mcp/server.ts";
1359
+ import type { McpData } from "blume/ai/mcp/data.ts";
1307
1360
  import data from "${up}generated/mcp-data.json";
1308
1361
 
1309
1362
  export const prerender = false;
1310
1363
 
1311
- const handler = createMcpFetchHandler(data);
1364
+ const handler = createMcpFetchHandler(data as McpData);
1312
1365
 
1313
1366
  export const ALL: APIRoute = ({ request }) => handler(request);
1314
1367
  `;
package/src/audit/url.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { stripBasePath } from "../core/base-path.ts";
1
+ import { normalizePath, stripBasePath } from "../core/base-path.ts";
2
+
3
+ // Re-exported from its home next to the other path helpers; the audit checks
4
+ // (and their tests) import it from here.
5
+ export { normalizePath } from "../core/base-path.ts";
2
6
 
3
7
  /** What an `href` in built HTML turned out to point at. */
4
8
  export type ResolvedHref =
@@ -13,15 +17,6 @@ export type ResolvedHref =
13
17
 
14
18
  const NON_HTTP_SCHEME = /^(?!https?:)[a-z][a-z0-9+.-]*:/iu;
15
19
 
16
- /**
17
- * Normalize a site path for comparison: drop the trailing slash (Astro serves
18
- * `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
19
- */
20
- export const normalizePath = (path: string): string => {
21
- const trimmed = path.replace(/\/+$/u, "");
22
- return trimmed === "" ? "/" : trimmed;
23
- };
24
-
25
20
  /**
26
21
  * Percent-decode a pathname for comparison against the built file tree. Page
27
22
  * URLs and file-index keys come from raw on-disk names, while `URL#pathname`