blume 1.6.0 → 1.6.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 (97) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cli/index.js +1318 -270
  3. package/dist/cli/index.js.map +68 -61
  4. package/dist/types/core/config-input.d.ts +9 -0
  5. package/dist/types/core/data.d.ts +12 -1
  6. package/dist/types/core/i18n-ui.d.ts +4 -0
  7. package/dist/types/core/schema.d.ts +7 -0
  8. package/dist/types/core/types.d.ts +6 -0
  9. package/dist/types/openapi/references.d.ts +5 -0
  10. package/docs/07-faq.mdx +9 -9
  11. package/docs/advanced/api-reference.mdx +10 -1
  12. package/docs/advanced/custom-pages.mdx +3 -1
  13. package/docs/advanced/graphql.mdx +1 -1
  14. package/docs/configuration/ai.mdx +76 -7
  15. package/docs/configuration/seo.mdx +3 -3
  16. package/docs/configuration/theming.mdx +6 -0
  17. package/docs/content/components.mdx +8 -1
  18. package/docs/index.mdx +2 -2
  19. package/package.json +53 -53
  20. package/skills/blume/SKILL.md +2 -2
  21. package/src/ai/agent-readability.ts +60 -17
  22. package/src/ai/api/handlers.ts +273 -0
  23. package/src/ai/api/paths.ts +14 -0
  24. package/src/ai/api/problem.ts +63 -0
  25. package/src/ai/api/spec.ts +681 -0
  26. package/src/ai/api-catalog.ts +11 -1
  27. package/src/ai/link-headers.ts +12 -3
  28. package/src/ai/llms.ts +9 -2
  29. package/src/ai/mcp/query.ts +390 -0
  30. package/src/ai/mcp/server.ts +32 -352
  31. package/src/astro/examples.ts +29 -2
  32. package/src/astro/generate.ts +256 -64
  33. package/src/astro/index.ts +7 -0
  34. package/src/astro/markdown-negotiation.ts +1 -1
  35. package/src/astro/runtime-modules.ts +196 -0
  36. package/src/astro/templates.ts +398 -38
  37. package/src/cli/commands/build.ts +9 -1
  38. package/src/cli/commands/dev.ts +6 -3
  39. package/src/cli/host-args.ts +18 -0
  40. package/src/cli/index.ts +2 -1
  41. package/src/components/copy-feedback.ts +93 -9
  42. package/src/components/islands/ask-ai.tsx +4 -1
  43. package/src/components/islands/hooks.ts +3 -1
  44. package/src/components/layout/PageActions.astro +25 -14
  45. package/src/core/config-input.ts +9 -0
  46. package/src/core/data.ts +17 -2
  47. package/src/core/define-components.ts +2 -0
  48. package/src/core/i18n-ui.ts +3 -0
  49. package/src/core/includes.ts +2 -1
  50. package/src/core/manifest.ts +10 -0
  51. package/src/core/schema.ts +20 -5
  52. package/src/core/types.ts +6 -0
  53. package/src/core/ui-packs/ar.ts +1 -0
  54. package/src/core/ui-packs/bg.ts +1 -0
  55. package/src/core/ui-packs/bn.ts +1 -0
  56. package/src/core/ui-packs/ca.ts +1 -0
  57. package/src/core/ui-packs/cs.ts +1 -0
  58. package/src/core/ui-packs/da.ts +1 -0
  59. package/src/core/ui-packs/de.ts +1 -0
  60. package/src/core/ui-packs/el.ts +1 -0
  61. package/src/core/ui-packs/es.ts +1 -0
  62. package/src/core/ui-packs/fa.ts +1 -0
  63. package/src/core/ui-packs/fi.ts +1 -0
  64. package/src/core/ui-packs/fr.ts +1 -0
  65. package/src/core/ui-packs/he.ts +1 -0
  66. package/src/core/ui-packs/hi.ts +1 -0
  67. package/src/core/ui-packs/hr.ts +1 -0
  68. package/src/core/ui-packs/hu.ts +1 -0
  69. package/src/core/ui-packs/id.ts +1 -0
  70. package/src/core/ui-packs/it.ts +1 -0
  71. package/src/core/ui-packs/ja.ts +1 -0
  72. package/src/core/ui-packs/ko.ts +1 -0
  73. package/src/core/ui-packs/nl.ts +1 -0
  74. package/src/core/ui-packs/no.ts +1 -0
  75. package/src/core/ui-packs/pl.ts +1 -0
  76. package/src/core/ui-packs/pt-br.ts +1 -0
  77. package/src/core/ui-packs/pt.ts +1 -0
  78. package/src/core/ui-packs/ro.ts +1 -0
  79. package/src/core/ui-packs/ru.ts +1 -0
  80. package/src/core/ui-packs/sk.ts +1 -0
  81. package/src/core/ui-packs/sr.ts +1 -0
  82. package/src/core/ui-packs/sv.ts +1 -0
  83. package/src/core/ui-packs/th.ts +1 -0
  84. package/src/core/ui-packs/tr.ts +1 -0
  85. package/src/core/ui-packs/uk.ts +1 -0
  86. package/src/core/ui-packs/vi.ts +1 -0
  87. package/src/core/ui-packs/zh-tw.ts +1 -0
  88. package/src/core/ui-packs/zh.ts +1 -0
  89. package/src/core/version-cut.ts +5 -3
  90. package/src/deploy/vercel-negotiation.ts +97 -6
  91. package/src/og/card.ts +1 -1
  92. package/src/openapi/references.ts +8 -0
  93. package/src/openapi/render-mdx.ts +18 -4
  94. package/src/openapi/scalar.ts +0 -4
  95. package/src/registry/eject.ts +36 -17
  96. package/src/theme/entry.ts +2 -2
  97. package/src/theme/sources.ts +49 -0
@@ -10,32 +10,32 @@ import {
10
10
  } from "@modelcontextprotocol/sdk/types.js";
11
11
  import { z } from "zod";
12
12
 
13
- import {
14
- normalizeRoute as normalizePageRoute,
15
- stripBasePath,
16
- withBasePath,
17
- } from "../../core/base-path.ts";
18
- import { absoluteUrl } from "../../core/site-url.ts";
19
- import { trimEnd } from "../../core/trim.ts";
20
- import { buildOramaIndex, queryOramaIndex } from "../../search/orama-index.ts";
21
- import type { OramaDoc } from "../../search/orama-index.ts";
13
+ import { withBasePath } from "../../core/base-path.ts";
22
14
  import type { McpData } from "./data.ts";
15
+ import {
16
+ createIndexProvider,
17
+ getNavigation,
18
+ getPageMarkdown,
19
+ listPages,
20
+ normalizeRoute,
21
+ searchDocs,
22
+ TOOL_INPUTS,
23
+ urlFor,
24
+ } from "./query.ts";
25
+ import type { OramaIndexProvider } from "./query.ts";
23
26
  import { MCP_TOOLS } from "./tools.ts";
24
27
 
28
+ export { createIndexProvider } from "./query.ts";
29
+ export type { OramaIndexProvider } from "./query.ts";
30
+
25
31
  /**
26
32
  * The low-level SDK `Server` is used (rather than the high-level `McpServer`)
27
33
  * because the latter's `registerTool` is generic over the caller's Zod instance;
28
34
  * Blume's zod and the SDK's may resolve to different copies, whose types don't
29
- * unify. Each tool's input is defined once in Blume's own zod: the runtime
30
- * parse and the JSON Schema advertised by `tools/list` (via `z.toJSONSchema`)
31
- * derive from the same definition, so they cannot drift.
35
+ * unify. The operations themselves live in `query.ts`, shared with the JSON
36
+ * docs API; this module is the MCP transport over them.
32
37
  */
33
38
 
34
- /** Default and maximum number of hits returned by `search_docs`. */
35
- const DEFAULT_SEARCH_LIMIT = 8;
36
- const MAX_SEARCH_LIMIT = 20;
37
- /** Excerpt length when a page has no description. */
38
- const EXCERPT_LENGTH = 200;
39
39
  /** Every page resource is the page's agent Markdown. */
40
40
  const RESOURCE_MIME_TYPE = "text/markdown";
41
41
  /** The MCP spec's JSON-RPC code for an unknown resource URI. */
@@ -51,116 +51,6 @@ const CORS_HEADERS = {
51
51
  "Access-Control-Expose-Headers": "Mcp-Session-Id",
52
52
  };
53
53
 
54
- // Each field is a preprocess pipe: the input side accepts the sloppy shapes
55
- // LLM callers actually send (a bare string for an array field, `[]`/`{}`
56
- // meaning "no filter", out-of-range limits clamped rather than rejected), and
57
- // the pipe's *output* side is the clean shape — which is exactly what
58
- // `z.toJSONSchema` emits for `tools/list`. No coercion can ever fail, so a
59
- // tool call is never rejected on argument shape, matching the previous
60
- // hand-rolled coercions.
61
-
62
- /**
63
- * The optional content-type filter `search_docs` and `list_pages` share.
64
- * `[]` or no usable strings mean "no filter", not "match nothing"; a bare
65
- * string is accepted as a one-element list.
66
- */
67
- const contentTypesField = z.preprocess((value) => {
68
- const list = (Array.isArray(value) ? value : [value]).filter(
69
- (entry): entry is string => typeof entry === "string"
70
- );
71
- return list.length > 0 ? list : undefined;
72
- }, z.array(z.string()).optional().describe('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.'));
73
-
74
- /**
75
- * The optional facet filter `search_docs` and `list_pages` share. Only
76
- * string-valued entries survive; an empty `{}` means "no filter".
77
- */
78
- /** Accepts any plain object, so the string-valued entries can be sifted out. */
79
- const looseFacetObject = z.record(z.string(), z.unknown());
80
-
81
- const filtersField = z.preprocess((value) => {
82
- const candidate = looseFacetObject.safeParse(value);
83
- if (!candidate.success) {
84
- return;
85
- }
86
- const entries = Object.entries(candidate.data).filter(
87
- (entry): entry is [string, string] => typeof entry[1] === "string"
88
- );
89
- return entries.length > 0 ? Object.fromEntries(entries) : undefined;
90
- }, z.record(z.string(), z.string()).optional().describe('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.'));
91
-
92
- /** Clamped into range rather than rejected; non-numeric means the default. */
93
- const limitField = z.preprocess(
94
- (value) => {
95
- // `Number` is the identity on numbers, so one conversion covers both the
96
- // well-typed call and a numeric string.
97
- const num = Number(value);
98
- return Number.isFinite(num)
99
- ? Math.min(Math.max(Math.trunc(num), 1), MAX_SEARCH_LIMIT)
100
- : undefined;
101
- },
102
- z
103
- .int()
104
- .min(1)
105
- .max(MAX_SEARCH_LIMIT)
106
- .optional()
107
- .describe(`Maximum hits to return (default ${DEFAULT_SEARCH_LIMIT}).`)
108
- );
109
-
110
- /** A required text field; a missing or non-string value coerces to "". */
111
- const textField = (description: string) =>
112
- z.preprocess((value) => {
113
- const parsed = z.string().safeParse(value);
114
- return parsed.success ? parsed.data : "";
115
- }, z.string().describe(description));
116
-
117
- /** An optional trimmed text field; blank or non-string means "absent". */
118
- const optionalTextField = (description: string) =>
119
- z.preprocess((value) => {
120
- const parsed = z.string().safeParse(value);
121
- const trimmed = parsed.success ? parsed.data.trim() : "";
122
- return trimmed || undefined;
123
- }, z.string().optional().describe(description));
124
-
125
- /** The optional locale filter `search_docs` and `list_pages` share. */
126
- const localeField = optionalTextField(
127
- "Only include pages in this locale (e.g. `fr`). Omit for every language."
128
- );
129
-
130
- /** The optional docs-version scope `search_docs` and `list_pages` share. */
131
- const versionField = optionalTextField(
132
- 'Docs version to scope to on a versioned site: `"latest"` (the default — current docs only), `"all"` (every version), or an archived version id (e.g. `"v1.0"`). Ignored when the site is unversioned.'
133
- );
134
-
135
- /** Every tool's input schema — the runtime parse and tools/list source. */
136
- const TOOL_INPUTS = {
137
- get_navigation: z.object({
138
- locale: optionalTextField(
139
- "Locale whose navigation tree to return (defaults to the default locale)."
140
- ),
141
- version: optionalTextField(
142
- "Archived version id whose tree to return (defaults to the current docs)."
143
- ),
144
- }),
145
- get_page: z.object({
146
- route: textField("The page route, e.g. `/guides/install`."),
147
- }),
148
- list_pages: z.object({
149
- contentTypes: contentTypesField,
150
- filters: filtersField,
151
- locale: localeField,
152
- version: versionField,
153
- }),
154
- search_docs: z.object({
155
- contentTypes: contentTypesField,
156
- filters: filtersField,
157
- limit: limitField,
158
- locale: localeField,
159
- query: textField("The search query."),
160
- version: versionField,
161
- }),
162
- };
163
-
164
54
  /**
165
55
  * A tool's advertised JSON Schema. The dialect key is dropped (noise in a
166
56
  * tools/list payload), as is the root `additionalProperties: false` — the
@@ -189,17 +79,6 @@ const TOOL_DEFINITIONS = MCP_TOOLS.map((tool) => ({
189
79
  title: tool.title,
190
80
  }));
191
81
 
192
- /** One `search_docs` result entry; `version` only appears on versioned sites. */
193
- interface SearchHitPayload {
194
- contentType: string | undefined;
195
- excerpt: string;
196
- facets: Record<string, string> | undefined;
197
- route: string;
198
- title: string;
199
- url: string;
200
- version?: string;
201
- }
202
-
203
82
  /** One `resources/list` entry: a page served as `text/markdown`. */
204
83
  interface PageResource {
205
84
  description?: string;
@@ -209,103 +88,6 @@ interface PageResource {
209
88
  uri: string;
210
89
  }
211
90
 
212
- /** One `list_pages` entry; `version` only appears on versioned sites. */
213
- interface PageListingPayload {
214
- contentType: string;
215
- description: string | undefined;
216
- facets: Record<string, string> | undefined;
217
- lastModified: string | null;
218
- route: string;
219
- title: string;
220
- url: string;
221
- version?: string;
222
- }
223
-
224
- /** Whether a page's facet values satisfy every requested filter entry. */
225
- const matchesFacets = (
226
- facets: Record<string, string> | undefined,
227
- filters: Record<string, string>
228
- ): boolean =>
229
- Object.entries(filters).every(([key, value]) => facets?.[key] === value);
230
-
231
- /**
232
- * Resolve the `version` scope on a versioned site: `undefined` disables the
233
- * filter (`"all"`), `""` is the current docs (the default — agents almost
234
- * always want the live documentation), and anything else is an archived id
235
- * (an unknown id simply matches nothing). On an unversioned site the input is
236
- * ignored entirely. The input arrives pre-trimmed (blank coerced to absent)
237
- * from the tool's input schema.
238
- */
239
- const asVersionScope = (
240
- value: string | undefined,
241
- data: McpData
242
- ): string | undefined => {
243
- if (!data.archivedVersions) {
244
- return;
245
- }
246
- if (value === "all") {
247
- return;
248
- }
249
- if (value === undefined || value === "latest" || value === "current") {
250
- return "";
251
- }
252
- return value;
253
- };
254
-
255
- /**
256
- * Error message for a `get_navigation` version id that isn't a configured
257
- * archived version, or `null` when the id is valid (or the site is
258
- * unversioned, where the id is ignored like the other tools' scopes). Unlike
259
- * `asVersionScope`'s match-nothing filters, a bad id here would otherwise
260
- * silently return the *current* tree posing as the requested snapshot.
261
- */
262
- const unknownVersionError = (
263
- versionId: string | undefined,
264
- data: McpData
265
- ): string | null =>
266
- versionId &&
267
- data.archivedVersions &&
268
- !data.archivedVersions.includes(versionId)
269
- ? `Unknown version "${versionId}". Archived versions: ${data.archivedVersions.join(", ")}.`
270
- : null;
271
-
272
- /**
273
- * Normalize a user-supplied route to a `pages` key (`/`, `/a/b`, no suffix).
274
- * Accepts a full URL too — `search_docs` hits and llms.txt entries carry
275
- * `site` + `deployment.base`, and an agent following "pass a route from
276
- * `search_docs`" will hand one straight back — reducing it to its base-less,
277
- * percent-decoded path.
278
- */
279
- const normalizeRoute = (input: string, data: McpData): string => {
280
- let value = input.trim();
281
- if (/^https?:\/\//iu.test(value)) {
282
- try {
283
- value = new URL(value).pathname;
284
- } catch {
285
- // Not parseable as a URL after all; treat it as a path.
286
- }
287
- }
288
- try {
289
- value = decodeURI(value);
290
- } catch {
291
- // Malformed percent sequence — compare it as written.
292
- }
293
- // Trailing slashes come off before the suffix so `/a/b.md/` still loses its
294
- // `.md`; normalizePageRoute then settles the leading slash.
295
- const noSuffix = trimEnd(value, "/").replace(/\.mdx?$/u, "");
296
- return stripBasePath(data.base, normalizePageRoute(noSuffix));
297
- };
298
-
299
- /** Build the absolute (or root-relative) URL for a route. */
300
- const urlFor = (route: string, data: McpData): string => {
301
- // Routes are base-less manifest paths; layer `deployment.base` on top so the
302
- // URL matches where the page is served (the sitemap/llms.txt convention).
303
- const path = withBasePath(data.base, route);
304
- // Concatenate rather than `new URL(path, site)` — a root-absolute path
305
- // would drop the base path of a subpath deployment (`acme.com/docs`).
306
- return data.site ? absoluteUrl(data.site, path) : path;
307
- };
308
-
309
91
  /**
310
92
  * A page's resource URI. Resource URIs must be absolute, so this is the page's
311
93
  * served URL when a site is configured (the same URL `search_docs` and
@@ -326,44 +108,12 @@ const resourceRoute = (uri: string, data: McpData): string =>
326
108
  data
327
109
  );
328
110
 
329
- /** A hit's excerpt: its description, else the head of its content with an
330
- * ellipsis only when something was actually cut off. */
331
- const excerptFor = (doc: OramaDoc): string => {
332
- if (doc.description) {
333
- return doc.description;
334
- }
335
- const head = doc.content.slice(0, EXCERPT_LENGTH).trim();
336
- return doc.content.length > EXCERPT_LENGTH ? `${head}…` : head;
337
- };
338
-
339
111
  /** A tool call's text result, marked as an error when `isError` is set. */
340
112
  const text = (value: string, isError = false) => {
341
113
  const content = [{ text: value, type: "text" as const }];
342
114
  return isError ? { content, isError: true } : { content };
343
115
  };
344
116
 
345
- /** Lazily builds the Orama index over a snapshot's documents, once. */
346
- export type OramaIndexProvider = () => Promise<
347
- Awaited<ReturnType<typeof buildOramaIndex>>
348
- >;
349
-
350
- /**
351
- * Memoize the search index so every server built from a snapshot shares it.
352
- * `locale` is the snapshot's `defaultLocale`, forwarded so non-Latin scripts
353
- * (Japanese and Chinese, but equally Cyrillic, Greek, Hebrew, Devanagari…)
354
- * get a word-segmenting tokenizer.
355
- */
356
- export const createIndexProvider = (
357
- documents: OramaDoc[],
358
- locale?: string
359
- ): OramaIndexProvider => {
360
- let dbPromise: ReturnType<OramaIndexProvider> | null = null;
361
- return function provideIndex() {
362
- dbPromise ??= buildOramaIndex(documents, locale);
363
- return dbPromise;
364
- };
365
- };
366
-
367
117
  /** Construct a fresh MCP server with Blume's read-only docs tools registered. */
368
118
  export const buildServer = (
369
119
  data: McpData,
@@ -403,7 +153,7 @@ export const buildServer = (
403
153
 
404
154
  server.setRequestHandler(ReadResourceRequestSchema, (request) => {
405
155
  const { uri } = request.params;
406
- const markdown = data.pages[resourceRoute(uri, data)];
156
+ const markdown = getPageMarkdown(data, resourceRoute(uri, data));
407
157
  if (markdown === undefined) {
408
158
  throw new McpError(
409
159
  RESOURCE_NOT_FOUND,
@@ -419,42 +169,18 @@ export const buildServer = (
419
169
  const { arguments: args = {}, name } = request.params;
420
170
 
421
171
  if (name === "search_docs") {
422
- const input = TOOL_INPUTS.search_docs.parse(args);
423
- const db = await index();
424
- const hits = await queryOramaIndex(
425
- db,
426
- input.query,
427
- input.limit ?? DEFAULT_SEARCH_LIMIT,
428
- {
429
- contentTypes: input.contentTypes,
430
- facets: input.filters,
431
- locale: input.locale,
432
- version: asVersionScope(input.version, data),
433
- }
172
+ const results = await searchDocs(
173
+ data,
174
+ index,
175
+ TOOL_INPUTS.search_docs.parse(args)
434
176
  );
435
- // `route` is the key `get_page` takes (the tool descriptions promise
436
- // it); `url` is where the page is served.
437
- const results = hits.map((doc: OramaDoc) => {
438
- const hit: SearchHitPayload = {
439
- contentType: doc.contentType,
440
- excerpt: excerptFor(doc),
441
- facets: doc.facets,
442
- route: doc.route,
443
- title: doc.title,
444
- url: urlFor(doc.route, data),
445
- };
446
- if (data.archivedVersions) {
447
- hit.version = doc.version ?? "";
448
- }
449
- return hit;
450
- });
451
177
  return text(JSON.stringify(results, null, 2));
452
178
  }
453
179
 
454
180
  if (name === "get_page") {
455
181
  const input = TOOL_INPUTS.get_page.parse(args);
456
182
  const key = normalizeRoute(input.route, data);
457
- const markdown = data.pages[key];
183
+ const markdown = getPageMarkdown(data, key);
458
184
  if (markdown === undefined) {
459
185
  return text(
460
186
  `No page found at "${key}". Use list_pages or search_docs to find valid routes.`,
@@ -465,65 +191,19 @@ export const buildServer = (
465
191
  }
466
192
 
467
193
  if (name === "list_pages") {
468
- const input = TOOL_INPUTS.list_pages.parse(args);
469
- const { contentTypes, filters, locale } = input;
470
- const versionScope = asVersionScope(input.version, data);
471
- const routes = data.routes.filter(
472
- (route) =>
473
- (!contentTypes || contentTypes.includes(route.contentType)) &&
474
- (!filters || matchesFacets(route.facets, filters)) &&
475
- (!locale || route.locale === locale) &&
476
- (versionScope === undefined || route.version === versionScope)
477
- );
478
- return text(
479
- JSON.stringify(
480
- routes.map((route) => {
481
- const listing: PageListingPayload = {
482
- contentType: route.contentType,
483
- description: route.description,
484
- facets: route.facets,
485
- lastModified: route.lastModified,
486
- route: route.route,
487
- title: route.title,
488
- url: urlFor(route.route, data),
489
- };
490
- if (data.archivedVersions) {
491
- listing.version = route.version;
492
- }
493
- return listing;
494
- }),
495
- null,
496
- 2
497
- )
498
- );
194
+ const listing = listPages(data, TOOL_INPUTS.list_pages.parse(args));
195
+ return text(JSON.stringify(listing, null, 2));
499
196
  }
500
197
 
501
198
  if (name === "get_navigation") {
502
- // A version id selects the snapshot's tree; a locale selects its
503
- // language (falling back through the default locale to any tree the
504
- // snapshot has). Without a version, a locale selects the current docs'
505
- // localized tree. An unknown id on a versioned site is an error — the
506
- // current tree would silently masquerade as the requested snapshot.
507
- const { locale, version: versionId } =
508
- TOOL_INPUTS.get_navigation.parse(args);
509
- const unknownVersion = unknownVersionError(versionId, data);
510
- if (unknownVersion) {
511
- return text(unknownVersion, true);
512
- }
513
- let { navigation } = data;
514
- const byLocale = versionId
515
- ? data.navigationByVersion?.[versionId]
516
- : undefined;
517
- if (byLocale) {
518
- navigation =
519
- (locale ? byLocale[locale] : undefined) ??
520
- byLocale[data.defaultLocale ?? ""] ??
521
- Object.values(byLocale)[0] ??
522
- navigation;
523
- } else if (locale && data.navigationByLocale?.[locale]) {
524
- navigation = data.navigationByLocale[locale];
199
+ const result = getNavigation(
200
+ data,
201
+ TOOL_INPUTS.get_navigation.parse(args)
202
+ );
203
+ if ("error" in result) {
204
+ return text(result.error, true);
525
205
  }
526
- return text(JSON.stringify(navigation, null, 2));
206
+ return text(JSON.stringify(result.navigation, null, 2));
527
207
  }
528
208
 
529
209
  return text(`Unknown tool: ${name}`, true);
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
3
  import pMap from "p-map";
4
- import { join, relative } from "pathe";
4
+ import { isAbsolute, join, relative } from "pathe";
5
5
  import { glob } from "tinyglobby";
6
6
 
7
7
  import type { ExampleLookup } from "../core/types.ts";
@@ -28,6 +28,11 @@ export interface ExampleSpec {
28
28
  }
29
29
 
30
30
  export interface ExampleDiscovery {
31
+ /**
32
+ * Absolute directory the examples were discovered under: the configured
33
+ * `examples.source` (or a glob's static prefix) resolved against the root.
34
+ */
35
+ dir: string;
31
36
  examples: ExampleSpec[];
32
37
  warnings: string[];
33
38
  }
@@ -56,6 +61,28 @@ const DEFAULT_EXAMPLE_GLOB = "**/*.{astro,jsx,svelte,tsx,vue}";
56
61
  /** Ceiling on concurrent example-file reads; unbounded fan-out risks EMFILE. */
57
62
  const READ_CONCURRENCY = 16;
58
63
 
64
+ /**
65
+ * Files the preview-frame Tailwind entry scans for utility classes, appended
66
+ * to each directory from `exampleScanRoots`.
67
+ */
68
+ export const EXAMPLE_SCAN_GLOB = "**/*.{astro,jsx,svelte,ts,tsx,vue}";
69
+
70
+ /**
71
+ * Directories the `<Component />` preview sheet scans for utility classes:
72
+ * the project root, plus the examples directory when it lives outside the
73
+ * root (a sibling workspace package, say). Tailwind's `@source` is a file
74
+ * glob, not an import graph, so an out-of-root examples directory would
75
+ * otherwise contribute no utilities and previews would render half-styled.
76
+ */
77
+ export const exampleScanRoots = (
78
+ root: string,
79
+ examplesDir: string
80
+ ): string[] => {
81
+ const path = relative(root, examplesDir);
82
+ const outside = path.startsWith("..") || isAbsolute(path);
83
+ return outside ? [root, examplesDir] : [root];
84
+ };
85
+
59
86
  // Glob magic that turns `examples` from a plain directory into a pattern. `()`,
60
87
  // `@`, and `+` are excluded so literal path segments (npm scopes, parens) keep
61
88
  // resolving as directories; the extglob leads `*?!` still trigger here.
@@ -153,7 +180,7 @@ export const discoverExamples = async (
153
180
  collectExample(file, sources[index] ?? "");
154
181
  }
155
182
 
156
- return { examples, warnings };
183
+ return { dir, examples, warnings };
157
184
  };
158
185
 
159
186
  /**