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.
- package/CHANGELOG.md +85 -0
- package/dist/cli/index.js +3639 -1377
- package/dist/cli/index.js.map +103 -91
- package/dist/types/ai/component-markdown.d.ts +79 -0
- package/dist/types/components/layout/nav-utils.d.ts +60 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +206 -4
- package/dist/types/core/config.d.ts +6 -4
- package/dist/types/core/data.d.ts +23 -1
- package/dist/types/core/github.d.ts +35 -0
- package/dist/types/core/i18n-ui.d.ts +8 -0
- package/dist/types/core/navigation.d.ts +69 -0
- package/dist/types/core/schema.d.ts +117 -1
- package/dist/types/core/sources/types.d.ts +31 -6
- package/dist/types/core/types.d.ts +23 -2
- package/dist/types/markdown/features.d.ts +21 -0
- package/dist/types/openapi/references.d.ts +21 -1
- package/dist/types/seo/jsonld.d.ts +105 -0
- package/dist/types/theme/fonts.d.ts +34 -4
- package/docs/_snippets/include-demo.mdx +7 -0
- package/docs/advanced/api-reference.mdx +3 -3
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/graphql.mdx +84 -0
- package/docs/advanced/meta.ts +8 -1
- package/docs/configuration/ai.mdx +21 -3
- package/docs/configuration/index.mdx +24 -0
- package/docs/configuration/search.mdx +13 -1
- package/docs/configuration/seo.mdx +27 -0
- package/docs/configuration/theming.mdx +17 -0
- package/docs/content/components.mdx +7 -0
- package/docs/content/includes.mdx +68 -0
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +25 -0
- package/docs/content/sources.mdx +42 -1
- package/docs/content/syntax.mdx +69 -1
- package/docs/content/versioning.mdx +15 -9
- package/docs/reference/cli.mdx +2 -1
- package/package.json +23 -14
- package/skills/blume-migrate/SKILL.md +16 -7
- package/skills/blume-migrate/references/docusaurus.md +5 -3
- package/skills/blume-migrate/references/fumadocs.md +10 -2
- package/skills/blume-migrate/references/mintlify.md +3 -2
- package/skills/blume-migrate/references/nextra.md +2 -2
- package/skills/blume-migrate/references/starlight.md +1 -1
- package/src/ai/agent-readability.ts +2 -1
- package/src/ai/ask-data.ts +2 -1
- package/src/ai/component-markdown.ts +199 -36
- package/src/ai/llms.ts +93 -6
- package/src/ai/markdown.ts +2 -2
- package/src/ai/mcp/discovery.ts +10 -2
- package/src/ai/mcp/server.ts +74 -2
- package/src/astro/generate.ts +183 -116
- package/src/astro/include-hmr.ts +81 -0
- package/src/astro/include-refresh.ts +0 -0
- package/src/astro/index.ts +3 -5
- package/src/astro/templates.ts +125 -76
- package/src/cli/commands/build.ts +84 -15
- package/src/cli/init/questions.ts +1 -0
- package/src/cli/init/scaffold.ts +27 -4
- package/src/components/colors.ts +142 -0
- package/src/components/content/Badge.astro +5 -12
- package/src/components/content/Callout.astro +19 -36
- package/src/components/content/Card.astro +15 -21
- package/src/components/content/Component.astro +10 -1
- package/src/components/content/GithubInfo.astro +28 -9
- package/src/components/content/Tabs.astro +27 -5
- package/src/components/content/github-info.ts +20 -5
- package/src/components/dropdown-dismiss.ts +122 -0
- package/src/components/layout/Fonts.astro +15 -8
- package/src/components/layout/Header.astro +44 -0
- package/src/components/layout/LanguageSwitcher.astro +9 -1
- package/src/components/layout/NavSelector.astro +12 -3
- package/src/components/layout/NavTree.astro +6 -18
- package/src/components/layout/PageActions.astro +29 -8
- package/src/components/layout/PageLayout.astro +10 -1
- package/src/components/layout/ReferenceLayout.astro +6 -1
- package/src/components/layout/RootLayout.astro +46 -15
- package/src/components/layout/Search.astro +36 -4
- package/src/components/layout/TableOfContents.astro +8 -2
- package/src/components/layout/head-scripts.ts +53 -1
- package/src/components/openapi/ApiOverview.astro +13 -3
- package/src/components/openapi/AsyncApiOperation.astro +7 -14
- package/src/components/openapi/GraphqlChip.astro +33 -0
- package/src/components/openapi/GraphqlFieldsTable.astro +111 -0
- package/src/components/openapi/GraphqlOperation.astro +186 -0
- package/src/components/openapi/GraphqlType.astro +154 -0
- package/src/components/openapi/MethodBadge.astro +3 -14
- package/src/components/openapi/Operation.astro +12 -5
- package/src/components/openapi/OperationPanel.astro +43 -0
- package/src/components/openapi/RequestPanel.astro +5 -10
- package/src/components/openapi/Responses.astro +1 -16
- package/src/components/openapi/graphql-helpers.ts +466 -0
- package/src/components/openapi/playground-client.ts +15 -0
- package/src/components/openapi/sample-panels.ts +45 -0
- package/src/components/openapi/snippets.ts +13 -35
- package/src/core/base-path.ts +11 -0
- package/src/core/config-input.ts +209 -2
- package/src/core/config.ts +6 -4
- package/src/core/content-assets.ts +15 -4
- package/src/core/data.ts +18 -2
- package/src/core/diagnostics.ts +8 -0
- package/src/core/frontmatter.ts +20 -8
- package/src/core/github.ts +71 -0
- package/src/core/graph.ts +22 -8
- package/src/core/heading-markers.ts +96 -0
- package/src/core/i18n-ui.ts +11 -0
- package/src/core/includes.ts +632 -0
- package/src/core/last-modified.ts +36 -11
- package/src/core/links.ts +79 -13
- package/src/core/meta.ts +2 -1
- package/src/core/nav-diagnostics.ts +11 -2
- package/src/core/navigation.ts +27 -6
- package/src/core/project-graph.ts +61 -9
- package/src/core/schema.ts +226 -35
- package/src/core/server-features.ts +5 -9
- package/src/core/sources/github-releases.ts +2 -2
- package/src/core/sources/normalize.ts +502 -115
- package/src/core/sources/notion.ts +43 -8
- package/src/core/sources/obsidian.ts +1038 -0
- package/src/core/sources/read.ts +36 -1
- package/src/core/sources/resolve.ts +34 -1
- package/src/core/sources/types.ts +28 -6
- package/src/core/sources/watch.ts +12 -8
- package/src/core/tsconfig-aliases.ts +48 -35
- package/src/core/types.ts +25 -2
- package/src/core/ui-packs/ar.ts +1 -0
- package/src/core/ui-packs/bg.ts +2 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +2 -0
- package/src/core/ui-packs/cs.ts +1 -0
- package/src/core/ui-packs/da.ts +1 -0
- package/src/core/ui-packs/de.ts +2 -0
- package/src/core/ui-packs/el.ts +2 -0
- package/src/core/ui-packs/es.ts +2 -0
- package/src/core/ui-packs/fa.ts +1 -0
- package/src/core/ui-packs/fi.ts +1 -0
- package/src/core/ui-packs/fr.ts +2 -0
- package/src/core/ui-packs/he.ts +1 -0
- package/src/core/ui-packs/hi.ts +1 -0
- package/src/core/ui-packs/hr.ts +2 -0
- package/src/core/ui-packs/hu.ts +2 -0
- package/src/core/ui-packs/id.ts +2 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +2 -0
- package/src/core/ui-packs/ko.ts +2 -0
- package/src/core/ui-packs/nl.ts +2 -0
- package/src/core/ui-packs/no.ts +2 -0
- package/src/core/ui-packs/pl.ts +2 -0
- package/src/core/ui-packs/pt-br.ts +2 -0
- package/src/core/ui-packs/pt.ts +2 -0
- package/src/core/ui-packs/ro.ts +2 -0
- package/src/core/ui-packs/ru.ts +2 -0
- package/src/core/ui-packs/sk.ts +1 -0
- package/src/core/ui-packs/sr.ts +1 -0
- package/src/core/ui-packs/sv.ts +2 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +2 -0
- package/src/core/ui-packs/uk.ts +2 -0
- package/src/core/ui-packs/vi.ts +1 -0
- package/src/core/ui-packs/zh-tw.ts +1 -0
- package/src/core/ui-packs/zh.ts +1 -0
- package/src/core/version-cut.ts +21 -3
- package/src/core/yaml.ts +26 -0
- package/src/deploy/function-bundle.ts +251 -0
- package/src/eval/schema.ts +3 -1
- package/src/markdown/code-title.ts +22 -16
- package/src/markdown/features.ts +21 -0
- package/src/markdown/fence-meta.ts +50 -0
- package/src/markdown/heading-anchors.ts +198 -37
- package/src/markdown/include.ts +247 -0
- package/src/markdown/index.ts +43 -34
- package/src/markdown/language-icon.ts +2 -2
- package/src/markdown/mdast.ts +7 -3
- package/src/markdown/ts2js.ts +264 -0
- package/src/openapi/asyncapi.ts +4 -1
- package/src/openapi/graphql-build.ts +293 -0
- package/src/openapi/graphql.ts +212 -0
- package/src/openapi/model.ts +38 -5
- package/src/openapi/parse.ts +34 -0
- package/src/openapi/proxy.ts +30 -5
- package/src/openapi/references.ts +89 -13
- package/src/openapi/render-mdx.ts +48 -8
- package/src/openapi/scalar.ts +5 -12
- package/src/openapi/source.ts +91 -23
- package/src/registry/eject.ts +11 -0
- package/src/search/documents.ts +229 -37
- package/src/search/orama-index.ts +9 -5
- package/src/seo/jsonld.ts +293 -51
- package/src/theme/code-block-padding.ts +16 -0
- package/src/theme/entry.ts +65 -11
- package/src/theme/fonts.ts +189 -16
- package/src/translate/prompts.ts +2 -0
- package/src/translate/run.ts +7 -0
- 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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* Mutually recursive with {@link collectSplices} (
|
|
441
|
-
*
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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,
|
|
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
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
:
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
535
|
-
const
|
|
536
|
-
|
|
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 {
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/src/ai/markdown.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
68
|
+
return await readExpandedEntryText(project, page);
|
|
69
69
|
}
|
|
70
70
|
return route.sourcePath ? await readFile(route.sourcePath, "utf-8") : "";
|
|
71
71
|
};
|
package/src/ai/mcp/discovery.ts
CHANGED
|
@@ -103,7 +103,10 @@ const HTTP_URL = /^https?:\/\//u;
|
|
|
103
103
|
*/
|
|
104
104
|
export interface McpServerCard {
|
|
105
105
|
$schema: string;
|
|
106
|
-
capabilities: {
|
|
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
|
-
|
|
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
|
),
|
package/src/ai/mcp/server.ts
CHANGED
|
@@ -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
|
|
336
|
-
: { capabilities
|
|
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
|
|