blume 0.7.0 → 1.0.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 +666 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +1852 -380
- package/dist/cli/index.js.map +98 -91
- package/dist/types/ai/component-markdown.d.ts +34 -0
- package/dist/types/components/content/youtube.d.ts +18 -0
- package/dist/types/core/base-path.d.ts +9 -0
- package/dist/types/core/config-input.d.ts +47 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +7 -0
- package/dist/types/core/i18n-ui.d.ts +526 -132
- package/dist/types/core/schema.d.ts +293 -146
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/dist/types/seo/x-handle.d.ts +12 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +8 -8
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/api-reference.mdx +1 -1
- package/docs/advanced/changelog.mdx +1 -1
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +49 -10
- package/docs/configuration/customization.mdx +11 -0
- package/docs/configuration/export.mdx +1 -1
- package/docs/configuration/index.mdx +27 -3
- package/docs/configuration/seo.mdx +35 -5
- package/docs/content/components.mdx +2 -2
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +6 -4
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +9 -7
- package/docs/reference/frontmatter.mdx +1 -1
- package/package.json +22 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +9 -8
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +135 -26
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +25 -4
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +21 -7
- package/src/ai/mcp/tools.ts +1 -1
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +77 -45
- package/src/astro/integration.ts +1 -1
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +81 -19
- package/src/astro/templates.ts +150 -19
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +120 -23
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +26 -5
- package/src/cli/commands/eject.ts +47 -19
- package/src/cli/commands/init.ts +120 -180
- package/src/cli/commands/preview.ts +4 -1
- package/src/cli/commands/validate.ts +43 -2
- package/src/cli/dev-lock.ts +8 -4
- package/src/cli/eject-scripts.ts +72 -0
- package/src/cli/env.ts +15 -5
- package/src/cli/init/questions.ts +158 -0
- package/src/cli/init/scaffold.ts +380 -0
- package/src/cli/internal-error.ts +9 -4
- package/src/cli/prepare.ts +3 -2
- package/src/components/Icon.astro +2 -1
- package/src/components/content/AccordionItem.astro +23 -4
- package/src/components/content/Badge.astro +3 -1
- package/src/components/content/Card.astro +4 -2
- package/src/components/content/Step.astro +10 -1
- package/src/components/content/Tabs.astro +15 -3
- package/src/components/content/Tile.astro +2 -1
- package/src/components/content/Tooltip.astro +3 -1
- package/src/components/content/Update.astro +9 -2
- package/src/components/content/auto-type-table.ts +7 -1
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/mermaid-element.ts +7 -2
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +86 -11
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Breadcrumbs.astro +11 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +19 -5
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +85 -9
- package/src/components/layout/Pagination.astro +10 -4
- package/src/components/layout/ReferenceLayout.astro +20 -2
- package/src/components/layout/RootLayout.astro +142 -12
- package/src/components/layout/Search.astro +117 -27
- package/src/components/layout/search/algolia.ts +11 -2
- package/src/components/layout/search/endpoint.ts +11 -5
- package/src/components/layout/search/orama-cloud.ts +8 -2
- package/src/components/layout/search/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +1 -1
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +1 -1
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/core/base-path.ts +24 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/config-input.ts +48 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +4 -0
- package/src/core/frontmatter.ts +7 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +54 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/last-modified.ts +13 -6
- package/src/core/links.ts +32 -8
- package/src/core/navigation.ts +29 -4
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +15 -6
- package/src/core/schema.ts +71 -2
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/mdx-remote.ts +23 -14
- package/src/core/sources/normalize.ts +152 -50
- package/src/core/sources/notion.ts +8 -8
- package/src/core/ui-packs/ar.ts +8 -0
- package/src/core/ui-packs/bg.ts +8 -0
- package/src/core/ui-packs/bn.ts +8 -0
- package/src/core/ui-packs/ca.ts +8 -0
- package/src/core/ui-packs/cs.ts +8 -0
- package/src/core/ui-packs/da.ts +8 -0
- package/src/core/ui-packs/de.ts +8 -0
- package/src/core/ui-packs/el.ts +8 -0
- package/src/core/ui-packs/es.ts +8 -0
- package/src/core/ui-packs/fa.ts +8 -0
- package/src/core/ui-packs/fi.ts +8 -0
- package/src/core/ui-packs/fr.ts +9 -1
- package/src/core/ui-packs/he.ts +8 -0
- package/src/core/ui-packs/hi.ts +8 -0
- package/src/core/ui-packs/hr.ts +8 -0
- package/src/core/ui-packs/hu.ts +8 -0
- package/src/core/ui-packs/id.ts +8 -0
- package/src/core/ui-packs/it.ts +8 -0
- package/src/core/ui-packs/ja.ts +8 -0
- package/src/core/ui-packs/ko.ts +8 -0
- package/src/core/ui-packs/nl.ts +8 -0
- package/src/core/ui-packs/no.ts +8 -0
- package/src/core/ui-packs/pl.ts +8 -0
- package/src/core/ui-packs/pt-br.ts +8 -0
- package/src/core/ui-packs/pt.ts +8 -0
- package/src/core/ui-packs/ro.ts +8 -0
- package/src/core/ui-packs/ru.ts +8 -0
- package/src/core/ui-packs/sk.ts +8 -0
- package/src/core/ui-packs/sr.ts +8 -0
- package/src/core/ui-packs/sv.ts +8 -0
- package/src/core/ui-packs/th.ts +8 -0
- package/src/core/ui-packs/tr.ts +8 -0
- package/src/core/ui-packs/uk.ts +8 -0
- package/src/core/ui-packs/vi.ts +8 -0
- package/src/core/ui-packs/zh-tw.ts +8 -0
- package/src/core/ui-packs/zh.ts +8 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +7 -2
- package/src/deploy/sitemap.ts +53 -11
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +10 -8
- package/src/markdown/index.ts +15 -3
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/package-commands.ts +10 -4
- package/src/og/card.ts +4 -2
- package/src/og/dimensions.ts +12 -0
- package/src/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- package/src/openapi/render-mdx.ts +62 -1
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +184 -12
- package/src/registry/registry.ts +0 -3
- package/src/search/documents.ts +34 -2
- package/src/seo/jsonld.ts +20 -13
- package/src/seo/x-handle.ts +18 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { mdxToMdast } from "satteri";
|
|
2
|
+
|
|
3
|
+
import { parseYouTubeId } from "../components/content/youtube.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Downlevel Blume's MDX components to plain Markdown for agent-facing output
|
|
7
|
+
* (the `/<route>.md` mirror, llms-full.txt, MCP `get_page`). Each supported
|
|
8
|
+
* component has a serializer — its "toString" — that renders the same
|
|
9
|
+
* information as the Astro component, but as portable Markdown: `<TypeTable>`
|
|
10
|
+
* becomes a GFM table, `<Callout>` a blockquote, `<Steps>` an ordered list,
|
|
11
|
+
* `<Tabs>` labeled sections, `<YouTube>` a link.
|
|
12
|
+
*
|
|
13
|
+
* The transform is a position splice, not a re-stringify: the MDX source is
|
|
14
|
+
* parsed to MDAST (via Satteri, which stamps byte offsets on every node) and
|
|
15
|
+
* only the matched component ranges are replaced, so all surrounding Markdown
|
|
16
|
+
* stays byte-identical to what the author wrote. Anything the serializers
|
|
17
|
+
* can't faithfully convert — an unknown component, a prop bound to an import —
|
|
18
|
+
* is left as JSX, and fenced code that merely *shows* component markup is
|
|
19
|
+
* naturally untouched because it parses as a `code` node.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Minimal structural MDAST types — we model only what this module reads. */
|
|
23
|
+
interface Offset {
|
|
24
|
+
offset: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface MdastNode {
|
|
28
|
+
attributes?: MdxAttribute[];
|
|
29
|
+
children?: MdastNode[];
|
|
30
|
+
name?: string;
|
|
31
|
+
position?: { end: Offset; start: Offset };
|
|
32
|
+
type: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface MdxAttribute {
|
|
36
|
+
name?: string;
|
|
37
|
+
type: string;
|
|
38
|
+
value?: string | { type: string; value: string } | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A single source replacement: `[start, end)` byte range → `text`. */
|
|
42
|
+
interface Splice {
|
|
43
|
+
end: number;
|
|
44
|
+
start: number;
|
|
45
|
+
text: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Evaluated props plus whether any attribute resisted static evaluation. */
|
|
49
|
+
interface EvaluatedProps {
|
|
50
|
+
lossy: boolean;
|
|
51
|
+
props: Record<string, unknown>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A child component extracted by name (e.g. each `<Step>` under `<Steps>`). */
|
|
55
|
+
export interface ComponentMarkdownChild extends EvaluatedProps {
|
|
56
|
+
/** The child's body, downleveled and dedented. */
|
|
57
|
+
children: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What a serializer receives for one component usage. */
|
|
61
|
+
export interface ComponentMarkdownContext extends EvaluatedProps {
|
|
62
|
+
/** Direct child components of `name`, each with evaluated props and body. */
|
|
63
|
+
childComponents: (name: string) => ComponentMarkdownChild[];
|
|
64
|
+
/** The element's body, downleveled and dedented (empty if self-closing). */
|
|
65
|
+
children: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A component's Markdown serializer. Return the replacement Markdown, or
|
|
70
|
+
* `null` to leave the component's JSX in the output verbatim (the safe
|
|
71
|
+
* fallback when the props can't be recovered statically).
|
|
72
|
+
*/
|
|
73
|
+
export type ComponentMarkdown = (
|
|
74
|
+
context: ComponentMarkdownContext
|
|
75
|
+
) => string | null;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Statically evaluate an MDX attribute expression (`prop={...}`). Component
|
|
79
|
+
* data props are object/array/number literals in practice; evaluation runs at
|
|
80
|
+
* build time over the author's own content — the same trust level as the MDX
|
|
81
|
+
* itself, which Astro compiles and executes. Expressions that reference
|
|
82
|
+
* imports or scope throw and report as not evaluable.
|
|
83
|
+
*/
|
|
84
|
+
const evaluateExpression = (raw: string): { ok: boolean; value: unknown } => {
|
|
85
|
+
try {
|
|
86
|
+
// Build-time eval of the author's own attribute literals; a throw falls
|
|
87
|
+
// back to leaving the JSX verbatim.
|
|
88
|
+
// oxlint-disable-next-line no-new-func
|
|
89
|
+
const value = new Function(`"use strict"; return (${raw});`)();
|
|
90
|
+
return { ok: true, value };
|
|
91
|
+
} catch {
|
|
92
|
+
return { ok: false, value: undefined };
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Evaluate an element's attributes into a plain props object. */
|
|
97
|
+
const readProps = (node: MdastNode): EvaluatedProps => {
|
|
98
|
+
const props: Record<string, unknown> = {};
|
|
99
|
+
let lossy = false;
|
|
100
|
+
for (const attribute of node.attributes ?? []) {
|
|
101
|
+
// A spread ({...props}) can't be recovered statically.
|
|
102
|
+
if (attribute.type !== "mdxJsxAttribute" || !attribute.name) {
|
|
103
|
+
lossy = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (attribute.value === null || attribute.value === undefined) {
|
|
107
|
+
// Boolean shorthand: `<Steps compact>`.
|
|
108
|
+
props[attribute.name] = true;
|
|
109
|
+
} else if (typeof attribute.value === "string") {
|
|
110
|
+
props[attribute.name] = attribute.value;
|
|
111
|
+
} else {
|
|
112
|
+
const result = evaluateExpression(attribute.value.value);
|
|
113
|
+
if (result.ok) {
|
|
114
|
+
props[attribute.name] = result.value;
|
|
115
|
+
} else {
|
|
116
|
+
lossy = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { lossy, props };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const hasOffsets = (
|
|
124
|
+
node: MdastNode
|
|
125
|
+
): node is MdastNode & { position: { end: Offset; start: Offset } } =>
|
|
126
|
+
typeof node.position?.start?.offset === "number" &&
|
|
127
|
+
typeof node.position?.end?.offset === "number";
|
|
128
|
+
|
|
129
|
+
/** Apply non-overlapping splices to `text` (offsets relative to `text`). */
|
|
130
|
+
const applySplices = (text: string, splices: Splice[]): string => {
|
|
131
|
+
let result = text;
|
|
132
|
+
for (const splice of [...splices].toSorted((a, b) => b.start - a.start)) {
|
|
133
|
+
// An element indented under a parent starts mid-line after whitespace;
|
|
134
|
+
// repeat that indent on the replacement's continuation lines so the block
|
|
135
|
+
// stays uniformly indented (and dedents cleanly with its siblings).
|
|
136
|
+
const lineStart = result.lastIndexOf("\n", splice.start - 1) + 1;
|
|
137
|
+
const prefix = result.slice(lineStart, splice.start);
|
|
138
|
+
const indent = /^[\t ]+$/u.test(prefix) ? prefix : "";
|
|
139
|
+
const replacement = indent
|
|
140
|
+
? splice.text
|
|
141
|
+
.split("\n")
|
|
142
|
+
.map((line, index) =>
|
|
143
|
+
index === 0 || line === "" ? line : `${indent}${line}`
|
|
144
|
+
)
|
|
145
|
+
.join("\n")
|
|
146
|
+
: splice.text;
|
|
147
|
+
result =
|
|
148
|
+
result.slice(0, splice.start) + replacement + result.slice(splice.end);
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Strip the common indentation JSX children carry in source (`<Step>` bodies
|
|
155
|
+
* are typically indented two spaces under their tag). The first line starts
|
|
156
|
+
* mid-line at the slice boundary, so the common prefix is measured on the
|
|
157
|
+
* following lines only.
|
|
158
|
+
*/
|
|
159
|
+
const dedent = (text: string): string => {
|
|
160
|
+
const lines = text.split("\n");
|
|
161
|
+
const rest = lines.slice(1).filter((line) => line.trim() !== "");
|
|
162
|
+
if (rest.length === 0) {
|
|
163
|
+
return text;
|
|
164
|
+
}
|
|
165
|
+
const indent = Math.min(
|
|
166
|
+
...rest.map((line) => line.length - line.trimStart().length)
|
|
167
|
+
);
|
|
168
|
+
if (indent === 0) {
|
|
169
|
+
return text;
|
|
170
|
+
}
|
|
171
|
+
return [
|
|
172
|
+
lines[0],
|
|
173
|
+
...lines
|
|
174
|
+
.slice(1)
|
|
175
|
+
.map((line) => (line.trim() === "" ? "" : line.slice(indent))),
|
|
176
|
+
].join("\n");
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const isJsxElement = (node: MdastNode): boolean =>
|
|
180
|
+
node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement";
|
|
181
|
+
|
|
182
|
+
/** Flatten a value to a single Markdown table cell (pipes escaped). */
|
|
183
|
+
const cellText = (value: unknown): string =>
|
|
184
|
+
String(value ?? "")
|
|
185
|
+
.replaceAll(/\s*\n\s*/gu, " ")
|
|
186
|
+
.replaceAll("|", "\\|")
|
|
187
|
+
.trim();
|
|
188
|
+
|
|
189
|
+
/** A cell rendered as inline code, unless the value itself uses backticks. */
|
|
190
|
+
const cellCode = (value: unknown): string => {
|
|
191
|
+
const text = cellText(value);
|
|
192
|
+
return text && !text.includes("`") ? `\`${text}\`` : text;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** One `<TypeTable type={{...}}>` entry, matching the component's shape. */
|
|
196
|
+
interface TypeEntry {
|
|
197
|
+
default?: unknown;
|
|
198
|
+
description?: unknown;
|
|
199
|
+
required?: unknown;
|
|
200
|
+
type?: unknown;
|
|
201
|
+
typeDescription?: unknown;
|
|
202
|
+
typeDescriptionLink?: unknown;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const typeTable: ComponentMarkdown = ({ children, props }) => {
|
|
206
|
+
const { type } = props;
|
|
207
|
+
if (type === null || typeof type !== "object") {
|
|
208
|
+
// The data prop is missing or wasn't statically evaluable.
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const entries = Object.entries(type as Record<string, TypeEntry>);
|
|
212
|
+
const rows = entries.map(([name, info]) => {
|
|
213
|
+
const prop = cellCode(`${name}${info.required ? "" : "?"}`);
|
|
214
|
+
const typeCell = info.typeDescriptionLink
|
|
215
|
+
? `[${cellCode(info.type)}](${cellText(info.typeDescriptionLink)})`
|
|
216
|
+
: cellCode(info.type);
|
|
217
|
+
const defaultCell =
|
|
218
|
+
info.default === undefined ? "-" : cellCode(info.default);
|
|
219
|
+
const description = cellText(
|
|
220
|
+
[info.description, info.typeDescription]
|
|
221
|
+
.filter((part) => typeof part === "string" && part !== "")
|
|
222
|
+
.join(" ")
|
|
223
|
+
);
|
|
224
|
+
return `| ${prop} | ${typeCell} | ${defaultCell} | ${description} |`;
|
|
225
|
+
});
|
|
226
|
+
const table =
|
|
227
|
+
rows.length > 0
|
|
228
|
+
? [
|
|
229
|
+
"| Prop | Type | Default | Description |",
|
|
230
|
+
"| --- | --- | --- | --- |",
|
|
231
|
+
...rows,
|
|
232
|
+
].join("\n")
|
|
233
|
+
: "";
|
|
234
|
+
// The component renders its slot after the table.
|
|
235
|
+
return [table, children].filter(Boolean).join("\n\n");
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const callout: ComponentMarkdown = ({ children, props }) => {
|
|
239
|
+
const type = typeof props.type === "string" ? props.type : "info";
|
|
240
|
+
const label =
|
|
241
|
+
typeof props.title === "string" && props.title !== ""
|
|
242
|
+
? props.title
|
|
243
|
+
: type.charAt(0).toUpperCase() + type.slice(1);
|
|
244
|
+
if (!children) {
|
|
245
|
+
return `> **${label}**`;
|
|
246
|
+
}
|
|
247
|
+
const body = children
|
|
248
|
+
.split("\n")
|
|
249
|
+
.map((line) => (line.trim() === "" ? ">" : `> ${line}`))
|
|
250
|
+
.join("\n");
|
|
251
|
+
return `> **${label}**\n>\n${body}`;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** Format `content` as one ordered-list item, continuation lines indented. */
|
|
255
|
+
const listItem = (index: number, content: string): string => {
|
|
256
|
+
const marker = `${index}. `;
|
|
257
|
+
return content
|
|
258
|
+
.split("\n")
|
|
259
|
+
.map((line, lineIndex) => {
|
|
260
|
+
if (lineIndex === 0) {
|
|
261
|
+
return `${marker}${line}`;
|
|
262
|
+
}
|
|
263
|
+
return line === "" ? "" : ` ${line}`;
|
|
264
|
+
})
|
|
265
|
+
.join("\n");
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const steps: ComponentMarkdown = ({ childComponents, children }) => {
|
|
269
|
+
const items = childComponents("Step");
|
|
270
|
+
if (items.length === 0) {
|
|
271
|
+
return children;
|
|
272
|
+
}
|
|
273
|
+
return items
|
|
274
|
+
.map((step, index) => {
|
|
275
|
+
const title =
|
|
276
|
+
typeof step.props.title === "string" && step.props.title !== ""
|
|
277
|
+
? `**${step.props.title}**`
|
|
278
|
+
: "";
|
|
279
|
+
const content = [title, step.children].filter(Boolean).join("\n\n");
|
|
280
|
+
return listItem(index + 1, content);
|
|
281
|
+
})
|
|
282
|
+
.join("\n\n");
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const tabs: ComponentMarkdown = ({ childComponents, children }) => {
|
|
286
|
+
const items = childComponents("Tab");
|
|
287
|
+
if (items.length === 0) {
|
|
288
|
+
return children;
|
|
289
|
+
}
|
|
290
|
+
return items
|
|
291
|
+
.map((tab, index) => {
|
|
292
|
+
const title =
|
|
293
|
+
typeof tab.props.title === "string" && tab.props.title !== ""
|
|
294
|
+
? tab.props.title
|
|
295
|
+
: `Tab ${index + 1}`;
|
|
296
|
+
return tab.children ? `**${title}**\n\n${tab.children}` : `**${title}**`;
|
|
297
|
+
})
|
|
298
|
+
.join("\n\n");
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const youtube: ComponentMarkdown = ({ props }) => {
|
|
302
|
+
let input = "";
|
|
303
|
+
if (typeof props.id === "string") {
|
|
304
|
+
input = props.id;
|
|
305
|
+
} else if (typeof props.url === "string") {
|
|
306
|
+
input = props.url;
|
|
307
|
+
}
|
|
308
|
+
const videoId = parseYouTubeId(input);
|
|
309
|
+
if (!videoId) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
const start =
|
|
313
|
+
typeof props.start === "number" && props.start > 0
|
|
314
|
+
? `&t=${Math.floor(props.start)}s`
|
|
315
|
+
: "";
|
|
316
|
+
const title =
|
|
317
|
+
typeof props.title === "string" && props.title !== ""
|
|
318
|
+
? props.title
|
|
319
|
+
: "Watch on YouTube";
|
|
320
|
+
return `[${title}](https://www.youtube.com/watch?v=${videoId}${start})`;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The built-in serializer registry, keyed by JSX name. `Step` and `Tab` are
|
|
325
|
+
* intentionally absent: they only carry meaning inside their containers,
|
|
326
|
+
* which extract them via `childComponents`; a stray one stays verbatim.
|
|
327
|
+
*/
|
|
328
|
+
const SERIALIZERS: Record<string, ComponentMarkdown> = {
|
|
329
|
+
Callout: callout,
|
|
330
|
+
Steps: steps,
|
|
331
|
+
Tabs: tabs,
|
|
332
|
+
TypeTable: typeTable,
|
|
333
|
+
YouTube: youtube,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const escapeRegExp = (value: string): string =>
|
|
337
|
+
value.replaceAll(/[$()*+.?[\\\]^{|}]/gu, String.raw`\$&`);
|
|
338
|
+
|
|
339
|
+
// Skip the MDX parse when no serializable component name appears at all —
|
|
340
|
+
// the common case for prose pages, and it keeps plain-Markdown sources (where
|
|
341
|
+
// `<`/`{` may be literal text MDX would reject) out of the parser entirely.
|
|
342
|
+
const componentHint = (registry: Record<string, ComponentMarkdown>): RegExp =>
|
|
343
|
+
new RegExp(
|
|
344
|
+
`<(?:${Object.keys(registry).map(escapeRegExp).join("|")})[\\s/>]`,
|
|
345
|
+
"u"
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const BUILT_IN_HINT = componentHint(SERIALIZERS);
|
|
349
|
+
|
|
350
|
+
/** One downlevel pass's inputs: the full source and the active registry. */
|
|
351
|
+
interface Walk {
|
|
352
|
+
registry: Record<string, ComponentMarkdown>;
|
|
353
|
+
source: string;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The element's body as Markdown: the verbatim source slice covering its
|
|
358
|
+
* children, with any serializable descendant components downleveled in place.
|
|
359
|
+
* Mutually recursive with {@link collectSplices} (a container's children may
|
|
360
|
+
* hold further serializable components), hence the forward reference.
|
|
361
|
+
*/
|
|
362
|
+
const renderChildren = (walk: Walk, node: MdastNode): string => {
|
|
363
|
+
const children = (node.children ?? []).filter(hasOffsets);
|
|
364
|
+
const [first] = children;
|
|
365
|
+
if (!first) {
|
|
366
|
+
return "";
|
|
367
|
+
}
|
|
368
|
+
const start = first.position.start.offset;
|
|
369
|
+
const end = children.at(-1)?.position.end.offset ?? start;
|
|
370
|
+
const splices: Splice[] = [];
|
|
371
|
+
// oxlint-disable-next-line no-use-before-define
|
|
372
|
+
collectSplices(walk, children, splices);
|
|
373
|
+
const spliced = applySplices(
|
|
374
|
+
walk.source.slice(start, end),
|
|
375
|
+
splices.map((splice) => ({
|
|
376
|
+
...splice,
|
|
377
|
+
end: splice.end - start,
|
|
378
|
+
start: splice.start - start,
|
|
379
|
+
}))
|
|
380
|
+
);
|
|
381
|
+
return dedent(spliced).trim();
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/** Serialize one component usage, or `null` to keep its JSX verbatim. */
|
|
385
|
+
const serializeElement = (
|
|
386
|
+
serializer: ComponentMarkdown,
|
|
387
|
+
walk: Walk,
|
|
388
|
+
node: MdastNode
|
|
389
|
+
): string | null =>
|
|
390
|
+
serializer({
|
|
391
|
+
...readProps(node),
|
|
392
|
+
childComponents: (name) =>
|
|
393
|
+
(node.children ?? [])
|
|
394
|
+
.filter((child) => isJsxElement(child) && child.name === name)
|
|
395
|
+
.map((child) => ({
|
|
396
|
+
...readProps(child),
|
|
397
|
+
children: renderChildren(walk, child),
|
|
398
|
+
})),
|
|
399
|
+
children: renderChildren(walk, node),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Walk the tree collecting replacements. A replaced element's subtree is
|
|
404
|
+
* owned by its serializer (which downlevels its own children), so the walk
|
|
405
|
+
* doesn't descend into it; when a serializer declines, the walk continues
|
|
406
|
+
* inside so nested serializable components still convert.
|
|
407
|
+
*/
|
|
408
|
+
const collectSplices = (
|
|
409
|
+
walk: Walk,
|
|
410
|
+
nodes: MdastNode[],
|
|
411
|
+
out: Splice[]
|
|
412
|
+
): void => {
|
|
413
|
+
for (const node of nodes) {
|
|
414
|
+
const serializer =
|
|
415
|
+
node.type === "mdxJsxFlowElement" && node.name
|
|
416
|
+
? walk.registry[node.name]
|
|
417
|
+
: undefined;
|
|
418
|
+
if (serializer && hasOffsets(node)) {
|
|
419
|
+
const text = serializeElement(serializer, walk, node);
|
|
420
|
+
if (text !== null) {
|
|
421
|
+
out.push({
|
|
422
|
+
end: node.position.end.offset,
|
|
423
|
+
start: node.position.start.offset,
|
|
424
|
+
text,
|
|
425
|
+
});
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
collectSplices(walk, node.children ?? [], out);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Downlevel supported components in an MDX source to plain Markdown. Sources
|
|
435
|
+
* with no supported components — and sources Satteri can't parse as MDX, e.g.
|
|
436
|
+
* plain `.md` with literal `<`/`{` — are returned byte-identical.
|
|
437
|
+
*
|
|
438
|
+
* `components` adds user serializers from `ai.markdownComponents`, layered
|
|
439
|
+
* over the built-ins: a same-name entry replaces the built-in serializer, and
|
|
440
|
+
* one that always returns `null` effectively opts that component out.
|
|
441
|
+
*/
|
|
442
|
+
export const downlevelComponents = (
|
|
443
|
+
source: string,
|
|
444
|
+
components?: Record<string, ComponentMarkdown>
|
|
445
|
+
): string => {
|
|
446
|
+
const custom = components && Object.keys(components).length > 0;
|
|
447
|
+
const registry = custom ? { ...SERIALIZERS, ...components } : SERIALIZERS;
|
|
448
|
+
const hint = custom ? componentHint(registry) : BUILT_IN_HINT;
|
|
449
|
+
if (!hint.test(source)) {
|
|
450
|
+
return source;
|
|
451
|
+
}
|
|
452
|
+
let tree: MdastNode;
|
|
453
|
+
try {
|
|
454
|
+
tree = mdxToMdast(source) as MdastNode;
|
|
455
|
+
} catch {
|
|
456
|
+
return source;
|
|
457
|
+
}
|
|
458
|
+
const splices: Splice[] = [];
|
|
459
|
+
collectSplices({ registry, source }, tree.children ?? [], splices);
|
|
460
|
+
return splices.length > 0 ? applySplices(source, splices) : source;
|
|
461
|
+
};
|
package/src/ai/llms.ts
CHANGED
|
@@ -2,54 +2,163 @@ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
|
2
2
|
import matter from "../core/frontmatter.ts";
|
|
3
3
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
4
4
|
import { readEntryText } from "../core/sources/read.ts";
|
|
5
|
-
import type { PageRecord } from "../core/types.ts";
|
|
5
|
+
import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
|
|
6
|
+
import { downlevelComponents } from "./component-markdown.ts";
|
|
7
|
+
import { applyAgentVisibility } from "./visibility.ts";
|
|
6
8
|
|
|
7
|
-
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top
|
|
8
|
-
// the
|
|
9
|
+
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top —
|
|
10
|
+
// with or without a `site` (the mcp.json convention) — so the emitted URL
|
|
11
|
+
// matches where the page is served. Encoded like the sitemap: a route with
|
|
12
|
+
// spaces or non-ASCII must still yield a valid Markdown link.
|
|
9
13
|
const pageUrl = (route: string, site?: string, base = ""): string => {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
return `${site.replace(/\/$/u, "")}${withBasePath(base, route)}`;
|
|
14
|
+
const path = withBasePath(base, route);
|
|
15
|
+
return encodeURI(site ? `${site.replace(/\/$/u, "")}${path}` : path);
|
|
14
16
|
};
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Drafts, hidden, and `noindex` pages are excluded, matching the sitemap.
|
|
19
|
+
// Generated API reference pages are excluded when `ai.llmsTxt.openapi` is off
|
|
20
|
+
// (they arrive through the internal staged "openapi" source).
|
|
21
|
+
const eligiblePages = (project: BlumeProject): PageRecord[] =>
|
|
22
|
+
project.graph.pages.filter(
|
|
23
|
+
(page) =>
|
|
24
|
+
!(page.meta.draft || page.meta.sidebar.hidden || page.meta.seo.noindex) &&
|
|
25
|
+
(project.config.ai.llmsTxt.openapi || page.source.name !== "openapi")
|
|
26
|
+
);
|
|
20
27
|
|
|
21
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The navigation trees the index mirrors: the site tree, or one per locale
|
|
30
|
+
* under i18n (each labeled with the locale except the default, so sections
|
|
31
|
+
* don't repeat ambiguously).
|
|
32
|
+
*/
|
|
33
|
+
const indexedNavigations = (
|
|
34
|
+
project: BlumeProject
|
|
35
|
+
): { label?: string; nav: Navigation }[] => {
|
|
36
|
+
const { i18n } = project.config;
|
|
37
|
+
if (i18n) {
|
|
38
|
+
return i18n.locales.flatMap(({ code, label }) => {
|
|
39
|
+
const nav = project.graph.navigationByLocale[code];
|
|
40
|
+
if (!nav) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
return [{ label: code === i18n.defaultLocale ? undefined : label, nav }];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return [{ nav: project.graph.navigation }];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the compact `llms.txt` index: title and summary, then the sidebar tree
|
|
51
|
+
* rendered as sections — group labels become headings, pages become link lists —
|
|
52
|
+
* so the file mirrors how the docs are organized rather than one flat blob.
|
|
53
|
+
*/
|
|
22
54
|
const buildIndex = (project: BlumeProject): string => {
|
|
23
55
|
const { config } = project;
|
|
24
56
|
const { site } = config.deployment;
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
const base = normalizeBasePath(config.deployment.base);
|
|
58
|
+
const eligible = eligiblePages(project);
|
|
59
|
+
const byRoute = new Map(eligible.map((page) => [page.route, page]));
|
|
60
|
+
const seen = new Set<string>();
|
|
61
|
+
|
|
62
|
+
const line = (page: PageRecord): string => {
|
|
63
|
+
seen.add(page.route);
|
|
64
|
+
const summary = page.description ? `: ${page.description}` : "";
|
|
65
|
+
return `- [${page.title}](${pageUrl(page.route, site, base)})${summary}`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// One nav level -> Markdown blocks: the level's loose pages as a link list,
|
|
69
|
+
// then each group as a heading (depth-capped at h6) followed by its own
|
|
70
|
+
// blocks. Nav entries whose route has no eligible page — external links,
|
|
71
|
+
// padded i18n fallbacks, `noindex` pages, excluded API references — are
|
|
72
|
+
// skipped, and a group left with nothing emits no heading at all.
|
|
73
|
+
const renderLevel = (nodes: NavNode[], depth: number): string[] => {
|
|
74
|
+
const list: string[] = [];
|
|
75
|
+
const groupBlocks: string[] = [];
|
|
76
|
+
for (const node of nodes) {
|
|
77
|
+
if (node.kind === "page") {
|
|
78
|
+
const page = byRoute.get(node.route);
|
|
79
|
+
if (page && !seen.has(page.route)) {
|
|
80
|
+
list.push(line(page));
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// An explicit-config group may link its index page on the group itself
|
|
85
|
+
// (`root`) rather than as a child; keep it at the top of the section.
|
|
86
|
+
const rootPage = node.route ? byRoute.get(node.route) : undefined;
|
|
87
|
+
const blocks = renderLevel(node.children, depth + 1);
|
|
88
|
+
if (rootPage && !seen.has(rootPage.route)) {
|
|
89
|
+
blocks.unshift(line(rootPage));
|
|
90
|
+
}
|
|
91
|
+
if (blocks.length > 0) {
|
|
92
|
+
groupBlocks.push(
|
|
93
|
+
`${"#".repeat(Math.min(depth, 6))} ${node.label}`,
|
|
94
|
+
...blocks
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return list.length > 0 ? [list.join("\n"), ...groupBlocks] : groupBlocks;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Loose pages at a tree's root get a "Docs" section of their own, so every
|
|
102
|
+
// link sits under an h2 as llms.txt consumers expect.
|
|
103
|
+
const renderNav = (nav: Navigation, depth: number): string[] => {
|
|
104
|
+
const loose = nav.sidebar.filter((node) => node.kind === "page");
|
|
105
|
+
const groups = nav.sidebar.filter((node) => node.kind === "group");
|
|
106
|
+
const looseBlocks = renderLevel(loose, depth + 1);
|
|
107
|
+
return [
|
|
108
|
+
...(looseBlocks.length > 0
|
|
109
|
+
? [`${"#".repeat(depth)} Docs`, ...looseBlocks]
|
|
110
|
+
: []),
|
|
111
|
+
...renderLevel(groups, depth),
|
|
112
|
+
];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const blocks: string[] = [];
|
|
116
|
+
for (const { label, nav } of indexedNavigations(project)) {
|
|
117
|
+
if (label) {
|
|
118
|
+
const localized = renderNav(nav, 3);
|
|
119
|
+
if (localized.length > 0) {
|
|
120
|
+
blocks.push(`## ${label}`, ...localized);
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
blocks.push(...renderNav(nav, 2));
|
|
28
125
|
}
|
|
29
|
-
lines.push("", "## Docs", "");
|
|
30
126
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
127
|
+
// Pages the navigation doesn't reach (an explicit sidebar that omits them,
|
|
128
|
+
// or a hand-rolled tree) still belong in the index.
|
|
129
|
+
const leftover = eligible
|
|
130
|
+
.filter((page) => !seen.has(page.route))
|
|
131
|
+
.toSorted((a, b) => a.route.localeCompare(b.route));
|
|
132
|
+
if (leftover.length > 0) {
|
|
133
|
+
blocks.push(
|
|
134
|
+
blocks.length > 0 ? "## Other" : "## Docs",
|
|
135
|
+
leftover.map(line).join("\n")
|
|
36
136
|
);
|
|
37
|
-
const summary = page.description ? `: ${page.description}` : "";
|
|
38
|
-
lines.push(`- [${page.title}](${url})${summary}`);
|
|
39
137
|
}
|
|
40
138
|
|
|
41
|
-
|
|
139
|
+
const header = config.description
|
|
140
|
+
? `# ${config.title}\n\n> ${config.description}`
|
|
141
|
+
: `# ${config.title}`;
|
|
142
|
+
return `${[header, ...blocks].join("\n\n")}\n`;
|
|
42
143
|
};
|
|
43
144
|
|
|
44
145
|
/** Build `llms-full.txt`: the full Markdown body of every page. */
|
|
45
146
|
const buildFull = async (project: BlumeProject): Promise<string> => {
|
|
46
147
|
const { config } = project;
|
|
47
|
-
const pages =
|
|
148
|
+
const pages = eligiblePages(project).toSorted((a, b) =>
|
|
149
|
+
a.route.localeCompare(b.route)
|
|
150
|
+
);
|
|
48
151
|
|
|
49
152
|
const sections = await Promise.all(
|
|
50
153
|
pages.map(async (page) => {
|
|
51
154
|
const raw = await readEntryText(project, page);
|
|
52
|
-
|
|
155
|
+
// Resolve `<Visibility>` audiences (web-only content omitted from the
|
|
156
|
+
// agent-facing output, agents-only unwrapped), then downlevel supported
|
|
157
|
+
// components to plain Markdown.
|
|
158
|
+
const body = downlevelComponents(
|
|
159
|
+
applyAgentVisibility(matter(raw).content),
|
|
160
|
+
config.ai.markdownComponents
|
|
161
|
+
).trim();
|
|
53
162
|
const url = pageUrl(
|
|
54
163
|
page.route,
|
|
55
164
|
config.deployment.site,
|