blume 1.5.2 → 1.6.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 (194) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/cli/index.js +3639 -1377
  3. package/dist/cli/index.js.map +103 -91
  4. package/dist/types/ai/component-markdown.d.ts +79 -0
  5. package/dist/types/components/layout/nav-utils.d.ts +60 -0
  6. package/dist/types/core/base-path.d.ts +9 -0
  7. package/dist/types/core/config-input.d.ts +206 -4
  8. package/dist/types/core/config.d.ts +6 -4
  9. package/dist/types/core/data.d.ts +23 -1
  10. package/dist/types/core/github.d.ts +35 -0
  11. package/dist/types/core/i18n-ui.d.ts +8 -0
  12. package/dist/types/core/navigation.d.ts +69 -0
  13. package/dist/types/core/schema.d.ts +117 -1
  14. package/dist/types/core/sources/types.d.ts +31 -6
  15. package/dist/types/core/types.d.ts +23 -2
  16. package/dist/types/markdown/features.d.ts +21 -0
  17. package/dist/types/openapi/references.d.ts +21 -1
  18. package/dist/types/seo/jsonld.d.ts +105 -0
  19. package/dist/types/theme/fonts.d.ts +34 -4
  20. package/docs/_snippets/include-demo.mdx +7 -0
  21. package/docs/advanced/api-reference.mdx +3 -3
  22. package/docs/advanced/custom-pages.mdx +1 -1
  23. package/docs/advanced/graphql.mdx +84 -0
  24. package/docs/advanced/meta.ts +8 -1
  25. package/docs/configuration/ai.mdx +21 -3
  26. package/docs/configuration/index.mdx +24 -0
  27. package/docs/configuration/search.mdx +13 -1
  28. package/docs/configuration/seo.mdx +27 -0
  29. package/docs/configuration/theming.mdx +17 -0
  30. package/docs/content/components.mdx +7 -0
  31. package/docs/content/includes.mdx +68 -0
  32. package/docs/content/meta.ts +1 -0
  33. package/docs/content/navigation.mdx +25 -0
  34. package/docs/content/sources.mdx +42 -1
  35. package/docs/content/syntax.mdx +69 -1
  36. package/docs/content/versioning.mdx +15 -9
  37. package/docs/reference/cli.mdx +2 -1
  38. package/package.json +23 -14
  39. package/skills/blume-migrate/SKILL.md +16 -7
  40. package/skills/blume-migrate/references/docusaurus.md +5 -3
  41. package/skills/blume-migrate/references/fumadocs.md +10 -2
  42. package/skills/blume-migrate/references/mintlify.md +3 -2
  43. package/skills/blume-migrate/references/nextra.md +2 -2
  44. package/skills/blume-migrate/references/starlight.md +1 -1
  45. package/src/ai/agent-readability.ts +2 -1
  46. package/src/ai/ask-data.ts +2 -1
  47. package/src/ai/component-markdown.ts +199 -36
  48. package/src/ai/llms.ts +93 -6
  49. package/src/ai/markdown.ts +2 -2
  50. package/src/ai/mcp/discovery.ts +10 -2
  51. package/src/ai/mcp/server.ts +74 -2
  52. package/src/astro/generate.ts +183 -116
  53. package/src/astro/include-hmr.ts +81 -0
  54. package/src/astro/include-refresh.ts +0 -0
  55. package/src/astro/index.ts +3 -5
  56. package/src/astro/templates.ts +125 -76
  57. package/src/cli/commands/build.ts +84 -15
  58. package/src/cli/init/questions.ts +1 -0
  59. package/src/cli/init/scaffold.ts +27 -4
  60. package/src/components/colors.ts +142 -0
  61. package/src/components/content/Badge.astro +5 -12
  62. package/src/components/content/Callout.astro +19 -36
  63. package/src/components/content/Card.astro +15 -21
  64. package/src/components/content/Component.astro +10 -1
  65. package/src/components/content/GithubInfo.astro +28 -9
  66. package/src/components/content/Tabs.astro +27 -5
  67. package/src/components/content/github-info.ts +20 -5
  68. package/src/components/dropdown-dismiss.ts +122 -0
  69. package/src/components/layout/Fonts.astro +15 -8
  70. package/src/components/layout/Header.astro +44 -0
  71. package/src/components/layout/LanguageSwitcher.astro +9 -1
  72. package/src/components/layout/NavSelector.astro +12 -3
  73. package/src/components/layout/NavTree.astro +6 -18
  74. package/src/components/layout/PageActions.astro +29 -8
  75. package/src/components/layout/PageLayout.astro +10 -1
  76. package/src/components/layout/ReferenceLayout.astro +6 -1
  77. package/src/components/layout/RootLayout.astro +46 -15
  78. package/src/components/layout/Search.astro +36 -4
  79. package/src/components/layout/TableOfContents.astro +8 -2
  80. package/src/components/layout/head-scripts.ts +53 -1
  81. package/src/components/openapi/ApiOverview.astro +13 -3
  82. package/src/components/openapi/AsyncApiOperation.astro +7 -14
  83. package/src/components/openapi/GraphqlChip.astro +33 -0
  84. package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
  85. package/src/components/openapi/GraphqlOperation.astro +186 -0
  86. package/src/components/openapi/GraphqlType.astro +154 -0
  87. package/src/components/openapi/MethodBadge.astro +3 -14
  88. package/src/components/openapi/Operation.astro +12 -5
  89. package/src/components/openapi/OperationPanel.astro +43 -0
  90. package/src/components/openapi/RequestPanel.astro +5 -10
  91. package/src/components/openapi/Responses.astro +1 -16
  92. package/src/components/openapi/graphql-helpers.ts +466 -0
  93. package/src/components/openapi/playground-client.ts +15 -0
  94. package/src/components/openapi/sample-panels.ts +45 -0
  95. package/src/components/openapi/snippets.ts +13 -35
  96. package/src/core/base-path.ts +11 -0
  97. package/src/core/config-input.ts +209 -2
  98. package/src/core/config.ts +6 -4
  99. package/src/core/content-assets.ts +15 -4
  100. package/src/core/data.ts +18 -2
  101. package/src/core/diagnostics.ts +8 -0
  102. package/src/core/frontmatter.ts +20 -8
  103. package/src/core/github.ts +71 -0
  104. package/src/core/graph.ts +22 -8
  105. package/src/core/heading-markers.ts +96 -0
  106. package/src/core/i18n-ui.ts +11 -0
  107. package/src/core/includes.ts +632 -0
  108. package/src/core/last-modified.ts +36 -11
  109. package/src/core/links.ts +79 -13
  110. package/src/core/meta.ts +2 -1
  111. package/src/core/nav-diagnostics.ts +11 -2
  112. package/src/core/navigation.ts +27 -6
  113. package/src/core/project-graph.ts +61 -9
  114. package/src/core/schema.ts +226 -35
  115. package/src/core/server-features.ts +5 -9
  116. package/src/core/sources/github-releases.ts +2 -2
  117. package/src/core/sources/normalize.ts +502 -115
  118. package/src/core/sources/notion.ts +43 -8
  119. package/src/core/sources/obsidian.ts +1038 -0
  120. package/src/core/sources/read.ts +36 -1
  121. package/src/core/sources/resolve.ts +34 -1
  122. package/src/core/sources/types.ts +28 -6
  123. package/src/core/sources/watch.ts +12 -8
  124. package/src/core/tsconfig-aliases.ts +48 -35
  125. package/src/core/types.ts +25 -2
  126. package/src/core/ui-packs/ar.ts +1 -0
  127. package/src/core/ui-packs/bg.ts +2 -0
  128. package/src/core/ui-packs/bn.ts +1 -0
  129. package/src/core/ui-packs/ca.ts +2 -0
  130. package/src/core/ui-packs/cs.ts +1 -0
  131. package/src/core/ui-packs/da.ts +1 -0
  132. package/src/core/ui-packs/de.ts +2 -0
  133. package/src/core/ui-packs/el.ts +2 -0
  134. package/src/core/ui-packs/es.ts +2 -0
  135. package/src/core/ui-packs/fa.ts +1 -0
  136. package/src/core/ui-packs/fi.ts +1 -0
  137. package/src/core/ui-packs/fr.ts +2 -0
  138. package/src/core/ui-packs/he.ts +1 -0
  139. package/src/core/ui-packs/hi.ts +1 -0
  140. package/src/core/ui-packs/hr.ts +2 -0
  141. package/src/core/ui-packs/hu.ts +2 -0
  142. package/src/core/ui-packs/id.ts +2 -0
  143. package/src/core/ui-packs/it.ts +1 -0
  144. package/src/core/ui-packs/ja.ts +2 -0
  145. package/src/core/ui-packs/ko.ts +2 -0
  146. package/src/core/ui-packs/nl.ts +2 -0
  147. package/src/core/ui-packs/no.ts +2 -0
  148. package/src/core/ui-packs/pl.ts +2 -0
  149. package/src/core/ui-packs/pt-br.ts +2 -0
  150. package/src/core/ui-packs/pt.ts +2 -0
  151. package/src/core/ui-packs/ro.ts +2 -0
  152. package/src/core/ui-packs/ru.ts +2 -0
  153. package/src/core/ui-packs/sk.ts +1 -0
  154. package/src/core/ui-packs/sr.ts +1 -0
  155. package/src/core/ui-packs/sv.ts +2 -0
  156. package/src/core/ui-packs/th.ts +1 -0
  157. package/src/core/ui-packs/tr.ts +2 -0
  158. package/src/core/ui-packs/uk.ts +2 -0
  159. package/src/core/ui-packs/vi.ts +1 -0
  160. package/src/core/ui-packs/zh-tw.ts +1 -0
  161. package/src/core/ui-packs/zh.ts +1 -0
  162. package/src/core/version-cut.ts +21 -3
  163. package/src/core/yaml.ts +26 -0
  164. package/src/deploy/function-bundle.ts +251 -0
  165. package/src/eval/schema.ts +3 -1
  166. package/src/markdown/code-title.ts +22 -16
  167. package/src/markdown/features.ts +21 -0
  168. package/src/markdown/fence-meta.ts +50 -0
  169. package/src/markdown/heading-anchors.ts +198 -37
  170. package/src/markdown/include.ts +247 -0
  171. package/src/markdown/index.ts +43 -34
  172. package/src/markdown/language-icon.ts +2 -2
  173. package/src/markdown/mdast.ts +7 -3
  174. package/src/markdown/ts2js.ts +264 -0
  175. package/src/openapi/asyncapi.ts +4 -1
  176. package/src/openapi/graphql-build.ts +293 -0
  177. package/src/openapi/graphql.ts +212 -0
  178. package/src/openapi/model.ts +38 -5
  179. package/src/openapi/parse.ts +34 -0
  180. package/src/openapi/proxy.ts +30 -5
  181. package/src/openapi/references.ts +89 -13
  182. package/src/openapi/render-mdx.ts +48 -8
  183. package/src/openapi/scalar.ts +5 -12
  184. package/src/openapi/source.ts +91 -23
  185. package/src/registry/eject.ts +11 -0
  186. package/src/search/documents.ts +229 -37
  187. package/src/search/orama-index.ts +9 -5
  188. package/src/seo/jsonld.ts +293 -51
  189. package/src/theme/code-block-padding.ts +16 -0
  190. package/src/theme/entry.ts +65 -11
  191. package/src/theme/fonts.ts +189 -16
  192. package/src/translate/prompts.ts +2 -0
  193. package/src/translate/run.ts +7 -0
  194. package/src/translate/work-list.ts +0 -0
@@ -3,6 +3,7 @@ import { mdxToMdast } from "satteri";
3
3
 
4
4
  import { parseYouTubeId } from "../components/content/youtube.ts";
5
5
  import type { ExampleLookup } from "../core/types.ts";
6
+ import { MDX_FEATURES } from "../markdown/features.ts";
6
7
 
7
8
  /**
8
9
  * Downlevel Blume's MDX components to plain Markdown for agent-facing output
@@ -26,7 +27,8 @@ interface Offset {
26
27
  offset: number;
27
28
  }
28
29
 
29
- interface MdastNode {
30
+ /** The structural slice of an mdast node the downlevel walk reads. */
31
+ export interface MdastNode {
30
32
  attributes?: MdxAttribute[];
31
33
  children?: MdastNode[];
32
34
  name?: string;
@@ -80,8 +82,28 @@ export interface ComponentMarkdownChild extends EvaluatedProps {
80
82
  children: string;
81
83
  }
82
84
 
85
+ /** One direct child of a component — a child component or prose — as Markdown. */
86
+ export interface ComponentMarkdownBlock {
87
+ /**
88
+ * The child downleveled: a serializable component's rendering (through the
89
+ * registry, so a user override of that component applies), or — for prose,
90
+ * a component with no serializer, or one that declined — its source with
91
+ * any serializable descendants downleveled in place.
92
+ */
93
+ markdown: string;
94
+ /** The JSX name of a child component; `undefined` for prose. */
95
+ name?: string;
96
+ }
97
+
83
98
  /** What a serializer receives for one component usage. */
84
99
  export interface ComponentMarkdownContext extends EvaluatedProps {
100
+ /**
101
+ * Every direct child of the element in document order, components and
102
+ * prose alike, each as a block of Markdown. For a container that is nothing
103
+ * but its contents — `<CardGroup>` — joining these with blank lines is the
104
+ * whole serializer.
105
+ */
106
+ childBlocks: () => ComponentMarkdownBlock[];
85
107
  /** Direct child components of `name`, each with evaluated props and body. */
86
108
  childComponents: (name: string) => ComponentMarkdownChild[];
87
109
  /** The element's body, downleveled and dedented (empty if self-closing). */
@@ -160,9 +182,10 @@ const readProps = (
160
182
  return { lossy, props };
161
183
  };
162
184
 
163
- const hasOffsets = (
164
- node: MdastNode
165
- ): node is MdastNode & { position: { end: Offset; start: Offset } } =>
185
+ /** A node Satteri stamped with byte offsets. */
186
+ type Positioned = MdastNode & { position: { end: Offset; start: Offset } };
187
+
188
+ const hasOffsets = (node: MdastNode): node is Positioned =>
166
189
  typeof node.position?.start?.offset === "number" &&
167
190
  typeof node.position?.end?.offset === "number";
168
191
 
@@ -352,6 +375,80 @@ const tabs: ComponentMarkdown = ({ childComponents, children }) => {
352
375
  .join("\n\n");
353
376
  };
354
377
 
378
+ /** Escape the brackets that would end a link's text early. */
379
+ const linkText = (value: string): string =>
380
+ value.replaceAll(/[[\]]/gu, String.raw`\$&`);
381
+
382
+ /**
383
+ * A link destination. Whitespace ends one, and a `)` ends one unless it is
384
+ * part of a balanced pair — so an href carrying either goes in the angle
385
+ * bracket form, where only `<` and `>` are special.
386
+ */
387
+ const linkDestination = (href: string): string =>
388
+ /[\s()<>]/u.test(href)
389
+ ? `<${href.replaceAll(/[<>]/gu, String.raw`\$&`)}>`
390
+ : href;
391
+
392
+ /** A text prop: a string, or a number stringified — `title={2024}` is a title. */
393
+ const textProp = (value: EvaluatedValue): string => {
394
+ if (isString(value)) {
395
+ return value.trim();
396
+ }
397
+ return isNumber(value) ? String(value) : "";
398
+ };
399
+
400
+ /**
401
+ * A card is a link with a blurb, so that is what it becomes: the title as the
402
+ * link text, the body under it, and the call to action last — the order the
403
+ * card renders in. Shaped like {@link tabs}, a bold label over the body,
404
+ * rather than a heading: a card sits inside a page whose outline its author
405
+ * wrote, and a heading would add a level to it.
406
+ *
407
+ * The icon and image are presentation and drop out. A card with no title
408
+ * falls back to its `href`, and one with neither is just its body. `lossy`
409
+ * declines: a title or href recovered from an expression that would not
410
+ * evaluate is a link pointing somewhere wrong, which is worse than the
411
+ * visible JSX.
412
+ */
413
+ const card: ComponentMarkdown = ({ children, lossy, props }) => {
414
+ if (lossy) {
415
+ return null;
416
+ }
417
+ const title = textProp(props.title);
418
+ const href = isString(props.href) ? props.href.trim() : "";
419
+ const body = children.trim();
420
+ const cta = textProp(props.cta);
421
+ const label = title || href;
422
+ if (label === "") {
423
+ // Nothing to head the card with, so it is whatever text it carries.
424
+ const rest = [body, cta].filter(Boolean).join("\n\n");
425
+ // Nothing but presentation left — keep the JSX rather than delete a card.
426
+ return rest === "" ? null : rest;
427
+ }
428
+ const head =
429
+ href === ""
430
+ ? `**${label}**`
431
+ : `**[${linkText(label)}](${linkDestination(href)})**`;
432
+ return [head, body, cta].filter(Boolean).join("\n\n");
433
+ };
434
+
435
+ /**
436
+ * `CardGroup` is the grid its cards sit in and carries no meaning of its own,
437
+ * so it becomes its contents: every direct child in order, each a block, a
438
+ * blank line between them. Built on `childBlocks` rather than extracting the
439
+ * cards so nothing the group holds is lost — a `Card` renders through the
440
+ * registry (a user override of `Card` applies in here too), a nested group
441
+ * recurses, prose between the cards stays, and a card that declines keeps its
442
+ * JSX as a block of its own instead of vanishing beside a rendered sibling.
443
+ * Passing the body slice through instead would leave each card's source
444
+ * indentation in the output and run one card's title straight on from the
445
+ * previous card's body as the same paragraph.
446
+ */
447
+ const cardGroup: ComponentMarkdown = ({ childBlocks }) =>
448
+ childBlocks()
449
+ .map((block) => block.markdown)
450
+ .join("\n\n");
451
+
355
452
  const youtube: ComponentMarkdown = ({ props }) => {
356
453
  let input = "";
357
454
  if (isString(props.id)) {
@@ -407,6 +504,8 @@ export const exampleComponentSerializers = (examples: ExampleLookup) =>
407
504
  */
408
505
  const SERIALIZERS = {
409
506
  Callout: callout,
507
+ Card: card,
508
+ CardGroup: cardGroup,
410
509
  Steps: steps,
411
510
  Tabs: tabs,
412
511
  TypeTable: typeTable,
@@ -428,29 +527,37 @@ const componentHint = (registry: Record<string, ComponentMarkdown>): RegExp =>
428
527
  const BUILT_IN_HINT = componentHint(SERIALIZERS);
429
528
 
430
529
  /** One downlevel pass's inputs: the source, registry, and page metadata. */
431
- interface Walk {
530
+ /** What a downlevel walk needs: the serializers, the page's front matter, and the source its nodes were parsed from. */
531
+ export interface DownlevelWalk {
432
532
  frontmatter: Record<string, EvaluatedValue> | undefined;
433
533
  registry: Record<string, ComponentMarkdown>;
434
534
  source: string;
435
535
  }
536
+ type Walk = DownlevelWalk;
537
+
538
+ /** The serializer registry for a site: built-ins under a user's `ai.markdownComponents`. */
539
+ export const componentRegistry = (
540
+ components?: Record<string, ComponentMarkdown>
541
+ ): Record<string, ComponentMarkdown> =>
542
+ components && Object.keys(components).length > 0
543
+ ? { ...SERIALIZERS, ...components }
544
+ : SERIALIZERS;
436
545
 
437
546
  /**
438
- * The element's body as Markdown: the verbatim source slice covering its
439
- * children, with any serializable descendant components downleveled in place.
440
- * Mutually recursive with {@link collectSplices} (a container's children may
441
- * hold further serializable components), hence the forward reference.
547
+ * A source slice as Markdown: the verbatim `[start, end)` range with any
548
+ * serializable component under `nodes` downleveled in place, dedented and
549
+ * trimmed. Mutually recursive with {@link collectSplices} (the nodes may hold
550
+ * further serializable components), hence the forward reference.
442
551
  */
443
- const renderChildren = (walk: Walk, node: MdastNode): string => {
444
- const children = (node.children ?? []).filter(hasOffsets);
445
- const [first] = children;
446
- if (!first) {
447
- return "";
448
- }
449
- const start = first.position.start.offset;
450
- const end = children.at(-1)?.position.end.offset ?? start;
552
+ const renderSlice = (
553
+ walk: Walk,
554
+ start: number,
555
+ end: number,
556
+ nodes: MdastNode[]
557
+ ): string => {
451
558
  const splices: Splice[] = [];
452
559
  // oxlint-disable-next-line no-use-before-define
453
- collectSplices(walk, children, splices);
560
+ collectSplices(walk, nodes, splices);
454
561
  const spliced = applySplices(
455
562
  walk.source.slice(start, end),
456
563
  splices.map((splice) => ({
@@ -462,6 +569,45 @@ const renderChildren = (walk: Walk, node: MdastNode): string => {
462
569
  return dedent(spliced).trim();
463
570
  };
464
571
 
572
+ /** The element's body as Markdown: the slice covering all of its children. */
573
+ const renderChildren = (walk: Walk, node: MdastNode): string => {
574
+ const children = (node.children ?? []).filter(hasOffsets);
575
+ const [first] = children;
576
+ if (!first) {
577
+ return "";
578
+ }
579
+ const start = first.position.start.offset;
580
+ return renderSlice(
581
+ walk,
582
+ start,
583
+ children.at(-1)?.position.end.offset ?? start,
584
+ children
585
+ );
586
+ };
587
+
588
+ /**
589
+ * One direct child as a block of Markdown: a registered component's own
590
+ * rendering, else — prose, an unknown component, or one that declined — its
591
+ * source slice, the same choice {@link collectSplices} makes at the top level.
592
+ */
593
+ const renderBlock = (walk: Walk, node: Positioned): string => {
594
+ const serializer =
595
+ isJsxElement(node) && node.name ? walk.registry[node.name] : undefined;
596
+ const text = serializer
597
+ ? // oxlint-disable-next-line no-use-before-define
598
+ serializeElement(serializer, walk, node)
599
+ : null;
600
+ return (
601
+ text ??
602
+ renderSlice(
603
+ walk,
604
+ node.position.start.offset,
605
+ node.position.end.offset,
606
+ node.children ?? []
607
+ )
608
+ );
609
+ };
610
+
465
611
  /** Serialize one component usage, or `null` to keep its JSX verbatim. */
466
612
  const serializeElement = (
467
613
  serializer: ComponentMarkdown,
@@ -470,6 +616,11 @@ const serializeElement = (
470
616
  ): string | null =>
471
617
  serializer({
472
618
  ...readProps(node, walk.frontmatter),
619
+ childBlocks: () =>
620
+ (node.children ?? []).filter(hasOffsets).map((child) => ({
621
+ markdown: renderBlock(walk, child),
622
+ name: isJsxElement(child) ? child.name : undefined,
623
+ })),
473
624
  childComponents: (name) =>
474
625
  (node.children ?? [])
475
626
  .filter((child) => isJsxElement(child) && child.name === name)
@@ -487,26 +638,38 @@ const serializeElement = (
487
638
  * doesn't descend into it; when a serializer declines, the walk continues
488
639
  * inside so nested serializable components still convert.
489
640
  */
641
+ /**
642
+ * Downlevel one parsed component to the Markdown its serializer emits, or
643
+ * `null` when the node isn't a component, has no serializer, or its
644
+ * serializer declines. `walk.source` must be the text `node` was parsed from.
645
+ */
646
+ export const downlevelComponentNode = (
647
+ node: MdastNode,
648
+ walk: DownlevelWalk
649
+ ): string | null => {
650
+ const serializer =
651
+ node.type === "mdxJsxFlowElement" && node.name
652
+ ? walk.registry[node.name]
653
+ : undefined;
654
+ return serializer && hasOffsets(node)
655
+ ? serializeElement(serializer, walk, node)
656
+ : null;
657
+ };
658
+
490
659
  const collectSplices = (
491
660
  walk: Walk,
492
661
  nodes: MdastNode[],
493
662
  out: Splice[]
494
663
  ): void => {
495
664
  for (const node of nodes) {
496
- const serializer =
497
- node.type === "mdxJsxFlowElement" && node.name
498
- ? walk.registry[node.name]
499
- : undefined;
500
- if (serializer && hasOffsets(node)) {
501
- const text = serializeElement(serializer, walk, node);
502
- if (text !== null) {
503
- out.push({
504
- end: node.position.end.offset,
505
- start: node.position.start.offset,
506
- text,
507
- });
508
- continue;
509
- }
665
+ const text = hasOffsets(node) ? downlevelComponentNode(node, walk) : null;
666
+ if (text !== null && hasOffsets(node)) {
667
+ out.push({
668
+ end: node.position.end.offset,
669
+ start: node.position.start.offset,
670
+ text,
671
+ });
672
+ continue;
510
673
  }
511
674
  collectSplices(walk, node.children ?? [], out);
512
675
  }
@@ -531,9 +694,9 @@ export const downlevelComponents = (
531
694
  components?: Record<string, ComponentMarkdown>,
532
695
  frontmatter?: Record<string, EvaluatedValue>
533
696
  ): string => {
534
- const custom = components && Object.keys(components).length > 0;
535
- const registry = custom ? { ...SERIALIZERS, ...components } : SERIALIZERS;
536
- const hint = custom ? componentHint(registry) : BUILT_IN_HINT;
697
+ const registry = componentRegistry(components);
698
+ const hint =
699
+ registry === SERIALIZERS ? BUILT_IN_HINT : componentHint(registry);
537
700
  if (!hint.test(source)) {
538
701
  return source;
539
702
  }
@@ -541,7 +704,7 @@ export const downlevelComponents = (
541
704
  try {
542
705
  // SAFETY: MdastNode is a structural subset of Satteri's mdast output —
543
706
  // every node carries `type`, and the walk reads only optional fields.
544
- tree = mdxToMdast(source) as MdastNode;
707
+ tree = mdxToMdast(source, { features: MDX_FEATURES }) as MdastNode;
545
708
  } catch {
546
709
  return source;
547
710
  }
package/src/ai/llms.ts CHANGED
@@ -3,13 +3,16 @@ import { rewriteRelativeImages } from "../core/content-assets.ts";
3
3
  import matter from "../core/frontmatter.ts";
4
4
  import type { BlumeProject } from "../core/project-graph.ts";
5
5
  import { absoluteUrl } from "../core/site-url.ts";
6
- import { readEntryText } from "../core/sources/read.ts";
6
+ import { readExpandedEntryText } from "../core/sources/read.ts";
7
7
  import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
8
8
  import { buildRssFeeds } from "../deploy/rss.ts";
9
+ import { API_CATALOG_PATH, hasApiCatalog } from "./api-catalog.ts";
9
10
  import {
10
11
  downlevelComponents,
11
12
  exampleComponentSerializers,
12
13
  } from "./component-markdown.ts";
14
+ import { AGENT_SKILLS_DIR, AGENT_SKILLS_INDEX_PATH } from "./skills.ts";
15
+ import type { SkillArtifact } from "./skills.ts";
13
16
  import { applyAgentVisibility } from "./visibility.ts";
14
17
 
15
18
  // Routes carry `basePath`; a `deployment.base` subdirectory is layered on top —
@@ -21,6 +24,65 @@ const pageUrl = (route: string, site?: string, base = ""): string => {
21
24
  return encodeURI(site ? absoluteUrl(site, path) : path);
22
25
  };
23
26
 
27
+ /** Inputs only the build has: the published skills, collected once per build. */
28
+ export interface LlmsIndexOptions {
29
+ /**
30
+ * The Agent Skills the build publishes under `/.well-known/agent-skills/`,
31
+ * listed in their own section with each skill's description — which is
32
+ * where a skill says when to use it. Absent (empty) for the homepage
33
+ * Markdown mirror, which is synthesized before the skills are collected.
34
+ */
35
+ skills?: readonly SkillArtifact[];
36
+ }
37
+
38
+ /** A skill description on one line — SKILL.md frontmatter may wrap it. */
39
+ const oneLine = (text: string): string => text.replaceAll(/\s+/gu, " ").trim();
40
+
41
+ /**
42
+ * The "Agent resources" section: every machine-readable artifact the site
43
+ * publishes, so an agent that only reads llms.txt still finds the full
44
+ * Markdown dump, the per-page Markdown mirrors, the MCP server, the skills
45
+ * index, the API catalog, the readability manifest, and the sitemap. The
46
+ * same artifact set `agent-readability.json` indexes, in prose an agent can
47
+ * act on without a second fetch.
48
+ */
49
+ const agentResourceLines = (project: BlumeProject): string[] => {
50
+ const { config } = project;
51
+ const { site } = config.deployment;
52
+ const url = (path: string): string =>
53
+ pageUrl(path, site, normalizeBasePath(config.deployment.base));
54
+ const lines = [
55
+ `- [llms-full.txt](${url("/llms-full.txt")}): The full Markdown of every page in one file.`,
56
+ `- [Page Markdown](${url("/index.md")}): Append \`.md\` to any page URL to fetch that page as raw Markdown.`,
57
+ ];
58
+ if (config.ai.mcp.enabled) {
59
+ lines.push(
60
+ `- [MCP server](${url(config.ai.mcp.route)}): Streamable HTTP Model Context Protocol server with search_docs, get_page, list_pages, and get_navigation tools, plus every page as a resource. Discovery document: ${url("/.well-known/mcp.json")}`
61
+ );
62
+ }
63
+ if (config.ai.skills) {
64
+ lines.push(
65
+ `- [Agent skills](${url(AGENT_SKILLS_INDEX_PATH)}): Agent Skills discovery index of the skills this site publishes.`
66
+ );
67
+ }
68
+ if (hasApiCatalog(config)) {
69
+ lines.push(
70
+ `- [API catalog](${url(API_CATALOG_PATH)}): RFC 9727 linkset of the APIs documented here.`
71
+ );
72
+ }
73
+ if (config.seo.agentReadability) {
74
+ lines.push(
75
+ `- [agent-readability.json](${url("/agent-readability.json")}): Manifest of every agent-facing artifact on this site.`
76
+ );
77
+ }
78
+ if (site && config.seo.sitemap) {
79
+ lines.push(
80
+ `- [Sitemap](${url("/sitemap.xml")}): Every indexable page URL with its last-modified date.`
81
+ );
82
+ }
83
+ return lines;
84
+ };
85
+
24
86
  // Drafts, hidden, and ordinary `noindex` pages are excluded. Generated API
25
87
  // references keep crawler visibility (`noindex`) separate from LLM visibility
26
88
  // (`ai.exclude`), and are excluded wholesale when `ai.llmsTxt.openapi` is off.
@@ -88,7 +150,10 @@ const indexedNavigations = (
88
150
  * Also serves as the homepage's synthesized Markdown mirror when the home
89
151
  * route is a landing page (see `buildRawMarkdown`).
90
152
  */
91
- export const buildLlmsIndex = (project: BlumeProject): string => {
153
+ export const buildLlmsIndex = (
154
+ project: BlumeProject,
155
+ options: LlmsIndexOptions = {}
156
+ ): string => {
92
157
  const { config } = project;
93
158
  const { site } = config.deployment;
94
159
  const base = normalizeBasePath(config.deployment.base);
@@ -187,10 +252,31 @@ export const buildLlmsIndex = (project: BlumeProject): string => {
187
252
  );
188
253
  }
189
254
 
255
+ // The published skills, each with its description — the one place a skill
256
+ // states when an agent should reach for it.
257
+ const skills = options.skills ?? [];
258
+ if (skills.length > 0) {
259
+ blocks.push(
260
+ "## Agent skills",
261
+ skills
262
+ .map(
263
+ (skill) =>
264
+ `- [${skill.name}](${pageUrl(`${AGENT_SKILLS_DIR}/${skill.path}`, site, base)}): ${oneLine(skill.description)}`
265
+ )
266
+ .join("\n")
267
+ );
268
+ }
269
+
270
+ blocks.push("## Agent resources", agentResourceLines(project).join("\n"));
271
+
190
272
  const header = config.description
191
273
  ? `# ${config.title}\n\n> ${config.description}`
192
274
  : `# ${config.title}`;
193
- return `${[header, ...blocks].join("\n\n")}\n`;
275
+ // `details` is the llms.txt spec's free-form block between the summary and
276
+ // the file sections — "when to use this" guidance in the site's own words.
277
+ const { details } = config.ai.llmsTxt;
278
+ const lead = details ? `${header}\n\n${details}` : header;
279
+ return `${[lead, ...blocks].join("\n\n")}\n`;
194
280
  };
195
281
 
196
282
  /** Build `llms-full.txt`: the full Markdown body of every current-docs page. */
@@ -208,7 +294,7 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
208
294
 
209
295
  const sections = await Promise.all(
210
296
  pages.map(async (page) => {
211
- let raw = await readEntryText(project, page);
297
+ let raw = await readExpandedEntryText(project, page);
212
298
  // Colocated `./image.png` references resolve to nothing for a reader of
213
299
  // llms-full.txt; point them at the served originals instead.
214
300
  if (page.sourcePath) {
@@ -246,8 +332,9 @@ const buildFull = async (project: BlumeProject): Promise<string> => {
246
332
 
247
333
  /** Build both LLM text artifacts for a project. */
248
334
  export const buildLlmsFiles = async (
249
- project: BlumeProject
335
+ project: BlumeProject,
336
+ options: LlmsIndexOptions = {}
250
337
  ): Promise<{ index: string; full: string }> => ({
251
338
  full: await buildFull(project),
252
- index: buildLlmsIndex(project),
339
+ index: buildLlmsIndex(project, options),
253
340
  });
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises";
3
3
  import { rewriteRelativeImages } from "../core/content-assets.ts";
4
4
  import matter from "../core/frontmatter.ts";
5
5
  import type { BlumeProject } from "../core/project-graph.ts";
6
- import { readEntryText } from "../core/sources/read.ts";
6
+ import { readExpandedEntryText } from "../core/sources/read.ts";
7
7
  import type { RouteManifestEntry } from "../core/types.ts";
8
8
  import {
9
9
  downlevelComponents,
@@ -65,7 +65,7 @@ export const buildRawMarkdown = async (
65
65
  const readRoute = async (route: RouteManifestEntry): Promise<string> => {
66
66
  const page = pageById.get(route.id);
67
67
  if (page) {
68
- return await readEntryText(project, page);
68
+ return await readExpandedEntryText(project, page);
69
69
  }
70
70
  return route.sourcePath ? await readFile(route.sourcePath, "utf-8") : "";
71
71
  };
@@ -103,7 +103,10 @@ const HTTP_URL = /^https?:\/\//u;
103
103
  */
104
104
  export interface McpServerCard {
105
105
  $schema: string;
106
- capabilities: { tools: { listChanged: boolean } };
106
+ capabilities: {
107
+ resources: { listChanged: boolean; subscribe: boolean };
108
+ tools: { listChanged: boolean };
109
+ };
107
110
  description: string;
108
111
  name: string;
109
112
  /** Absolute endpoints only — present when a `site` is configured. */
@@ -122,7 +125,12 @@ export const buildMcpServerCard = (input: McpDiscoveryInput): McpServerCard => {
122
125
  const url = serverUrl(input);
123
126
  const card: McpServerCard = {
124
127
  $schema: SERVER_CARD_SCHEMA,
125
- capabilities: { tools: { listChanged: false } },
128
+ // Resources mirror the page list (one `text/markdown` resource per page);
129
+ // a rebuilt site replaces the server wholesale, so nothing changes live.
130
+ capabilities: {
131
+ resources: { listChanged: false, subscribe: false },
132
+ tools: { listChanged: false },
133
+ },
126
134
  description: truncate(
127
135
  `Model Context Protocol server for the ${input.name} documentation.`
128
136
  ),
@@ -3,7 +3,10 @@ import type { ServerOptions } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4
4
  import {
5
5
  CallToolRequestSchema,
6
+ ListResourcesRequestSchema,
6
7
  ListToolsRequestSchema,
8
+ McpError,
9
+ ReadResourceRequestSchema,
7
10
  } from "@modelcontextprotocol/sdk/types.js";
8
11
  import { z } from "zod";
9
12
 
@@ -33,6 +36,12 @@ const DEFAULT_SEARCH_LIMIT = 8;
33
36
  const MAX_SEARCH_LIMIT = 20;
34
37
  /** Excerpt length when a page has no description. */
35
38
  const EXCERPT_LENGTH = 200;
39
+ /** Every page resource is the page's agent Markdown. */
40
+ const RESOURCE_MIME_TYPE = "text/markdown";
41
+ /** The MCP spec's JSON-RPC code for an unknown resource URI. */
42
+ const RESOURCE_NOT_FOUND = -32_002;
43
+ /** URI scheme for page resources when no `deployment.site` is configured. */
44
+ const LOCAL_RESOURCE_SCHEME = "blume:";
36
45
 
37
46
  const CORS_HEADERS = {
38
47
  "Access-Control-Allow-Headers":
@@ -191,6 +200,15 @@ interface SearchHitPayload {
191
200
  version?: string;
192
201
  }
193
202
 
203
+ /** One `resources/list` entry: a page served as `text/markdown`. */
204
+ interface PageResource {
205
+ description?: string;
206
+ mimeType: string;
207
+ name: string;
208
+ title: string;
209
+ uri: string;
210
+ }
211
+
194
212
  /** One `list_pages` entry; `version` only appears on versioned sites. */
195
213
  interface PageListingPayload {
196
214
  contentType: string;
@@ -288,6 +306,26 @@ const urlFor = (route: string, data: McpData): string => {
288
306
  return data.site ? absoluteUrl(data.site, path) : path;
289
307
  };
290
308
 
309
+ /**
310
+ * A page's resource URI. Resource URIs must be absolute, so this is the page's
311
+ * served URL when a site is configured (the same URL `search_docs` and
312
+ * `list_pages` emit, so an agent can hand either back to `resources/read`),
313
+ * and a `blume:` URI carrying the based route otherwise.
314
+ */
315
+ const resourceUri = (route: string, data: McpData): string =>
316
+ data.site
317
+ ? urlFor(route, data)
318
+ : `${LOCAL_RESOURCE_SCHEME}${withBasePath(data.base, route)}`;
319
+
320
+ /** The `pages` key a resource URI (either form, or a bare route) names. */
321
+ const resourceRoute = (uri: string, data: McpData): string =>
322
+ normalizeRoute(
323
+ uri.startsWith(LOCAL_RESOURCE_SCHEME)
324
+ ? uri.slice(LOCAL_RESOURCE_SCHEME.length)
325
+ : uri,
326
+ data
327
+ );
328
+
291
329
  /** A hit's excerpt: its description, else the head of its content with an
292
330
  * ellipsis only when something was actually cut off. */
293
331
  const excerptFor = (doc: OramaDoc): string => {
@@ -331,9 +369,10 @@ export const buildServer = (
331
369
  data: McpData,
332
370
  index: OramaIndexProvider
333
371
  ): Server => {
372
+ const capabilities = { resources: {}, tools: {} };
334
373
  const serverOptions: ServerOptions = data.instructions
335
- ? { capabilities: { tools: {} }, instructions: data.instructions }
336
- : { capabilities: { tools: {} } };
374
+ ? { capabilities, instructions: data.instructions }
375
+ : { capabilities };
337
376
  const server = new Server(
338
377
  { name: data.name, version: data.version },
339
378
  serverOptions
@@ -343,6 +382,39 @@ export const buildServer = (
343
382
  tools: TOOL_DEFINITIONS,
344
383
  }));
345
384
 
385
+ // Every page doubles as a resource, so a client that attaches context by
386
+ // URI (rather than calling tools) can browse and read the docs too. The
387
+ // list is the same route set `list_pages` returns; reading one serves the
388
+ // same agent Markdown `get_page` does.
389
+ server.setRequestHandler(ListResourcesRequestSchema, () => ({
390
+ resources: data.routes.map((route) => {
391
+ const resource: PageResource = {
392
+ mimeType: RESOURCE_MIME_TYPE,
393
+ name: route.title,
394
+ title: route.title,
395
+ uri: resourceUri(route.route, data),
396
+ };
397
+ if (route.description) {
398
+ resource.description = route.description;
399
+ }
400
+ return resource;
401
+ }),
402
+ }));
403
+
404
+ server.setRequestHandler(ReadResourceRequestSchema, (request) => {
405
+ const { uri } = request.params;
406
+ const markdown = data.pages[resourceRoute(uri, data)];
407
+ if (markdown === undefined) {
408
+ throw new McpError(
409
+ RESOURCE_NOT_FOUND,
410
+ `No page found at "${uri}". Use resources/list or list_pages to find valid URIs.`
411
+ );
412
+ }
413
+ return {
414
+ contents: [{ mimeType: RESOURCE_MIME_TYPE, text: markdown, uri }],
415
+ };
416
+ });
417
+
346
418
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
347
419
  const { arguments: args = {}, name } = request.params;
348
420