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
|
@@ -58,6 +58,61 @@ export interface RenderedPage {
|
|
|
58
58
|
body: string;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Meta descriptions. A page that sets none falls back to the site-wide default,
|
|
62
|
+
// so a spec's pages would otherwise all ship one identical description — what
|
|
63
|
+
// search engines treat as duplicate content. These go in `seo.description`, not
|
|
64
|
+
// `description`: the prose already renders in the body, and a `description`
|
|
65
|
+
// frontmatter field would print it a second time as the page subtitle.
|
|
66
|
+
const META_DESCRIPTION_MAX = 160;
|
|
67
|
+
const PARAGRAPH_BREAK = /\n\s*\n/u;
|
|
68
|
+
const MARKDOWN_LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
|
|
69
|
+
const MARKDOWN_MARKS = /[*_`#>]/gu;
|
|
70
|
+
const WHITESPACE = /\s+/gu;
|
|
71
|
+
const TRAILING_WORD = /\s+\S*$/u;
|
|
72
|
+
|
|
73
|
+
/** Flatten markdown prose to its first paragraph as single-line plain text. */
|
|
74
|
+
const plainProse = (markdown: string): string =>
|
|
75
|
+
(markdown.trim().split(PARAGRAPH_BREAK).at(0) ?? "")
|
|
76
|
+
.replace(MARKDOWN_LINK, "$<text>")
|
|
77
|
+
.replace(MARKDOWN_MARKS, "")
|
|
78
|
+
.replace(WHITESPACE, " ")
|
|
79
|
+
.trim();
|
|
80
|
+
|
|
81
|
+
/** Cap `text` at `max` characters, cutting on a word boundary. */
|
|
82
|
+
const clip = (text: string, max: number): string => {
|
|
83
|
+
if (max <= 0) {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
if (text.length <= max) {
|
|
87
|
+
return text;
|
|
88
|
+
}
|
|
89
|
+
const head = text.slice(0, max - 1);
|
|
90
|
+
const onWordBoundary = head.replace(TRAILING_WORD, "");
|
|
91
|
+
// One very long token — an endpoint path has no spaces — would be dropped
|
|
92
|
+
// whole, leaving a stub. Hard-cut it instead of losing it.
|
|
93
|
+
return `${onWordBoundary.length >= max / 2 ? onWordBoundary : head}…`;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const apiName = (spec: ApiSpecData): string => spec.title || spec.label;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The spec's own prose for the operation, followed by the endpoint it documents
|
|
100
|
+
* — so every operation page carries a distinct, self-describing meta
|
|
101
|
+
* description even when the spec's summaries are terse.
|
|
102
|
+
*/
|
|
103
|
+
const operationDescription = (
|
|
104
|
+
spec: ApiSpecData,
|
|
105
|
+
operation: ApiOperationRef
|
|
106
|
+
): string => {
|
|
107
|
+
const endpoint = `${operation.method.toUpperCase()} ${operation.path}`;
|
|
108
|
+
const suffix = `Reference for the ${endpoint} endpoint in the ${apiName(spec)} API.`;
|
|
109
|
+
const prose = clip(
|
|
110
|
+
plainProse(operation.description || operation.summary),
|
|
111
|
+
META_DESCRIPTION_MAX - suffix.length - 1
|
|
112
|
+
);
|
|
113
|
+
return clip([prose, suffix].filter(Boolean).join(" "), META_DESCRIPTION_MAX);
|
|
114
|
+
};
|
|
115
|
+
|
|
61
116
|
/** Prepend a markdown description (if any) above a component invocation. */
|
|
62
117
|
const withDescription = (description: string, component: string): string =>
|
|
63
118
|
description.trim()
|
|
@@ -84,6 +139,7 @@ export const operationMdx = (
|
|
|
84
139
|
data: {
|
|
85
140
|
...(operation.deprecated ? { deprecated: true } : {}),
|
|
86
141
|
search: { tags: [operation.tag, method] },
|
|
142
|
+
seo: { description: operationDescription(spec, operation) },
|
|
87
143
|
sidebar: { badge: method, label: operation.summary || operation.path },
|
|
88
144
|
title,
|
|
89
145
|
// Signals the two-column API layout (request panel instead of the TOC).
|
|
@@ -144,8 +200,13 @@ export const overviewMdx = (spec: ApiSpecData): RenderedPage => {
|
|
|
144
200
|
...tagSections,
|
|
145
201
|
].join("\n\n"),
|
|
146
202
|
data: {
|
|
203
|
+
seo: {
|
|
204
|
+
description:
|
|
205
|
+
clip(plainProse(spec.description), META_DESCRIPTION_MAX) ||
|
|
206
|
+
`${apiName(spec)} API reference.`,
|
|
207
|
+
},
|
|
147
208
|
sidebar: { label: "Overview" },
|
|
148
|
-
title: spec
|
|
209
|
+
title: apiName(spec),
|
|
149
210
|
},
|
|
150
211
|
};
|
|
151
212
|
};
|
package/src/openapi/source.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withBasePath } from "../core/base-path.ts";
|
|
1
2
|
import matter from "../core/frontmatter.ts";
|
|
2
3
|
import { hashText } from "../core/sources/cache.ts";
|
|
3
4
|
import type {
|
|
@@ -9,7 +10,7 @@ import type {
|
|
|
9
10
|
import type { Diagnostic } from "../core/types.ts";
|
|
10
11
|
import { extractOperations } from "./model.ts";
|
|
11
12
|
import type { ApiOperationRef, ApiSpecData, OpenApiData } from "./model.ts";
|
|
12
|
-
import { parseSpec } from "./parse.ts";
|
|
13
|
+
import { InvalidSpecError, parseSpec } from "./parse.ts";
|
|
13
14
|
import type { ReferenceSource } from "./references.ts";
|
|
14
15
|
import { operationMdx, overviewMdx } from "./render-mdx.ts";
|
|
15
16
|
import type { RenderedPage } from "./render-mdx.ts";
|
|
@@ -90,7 +91,11 @@ export const openApiSource = (
|
|
|
90
91
|
ctx.projectRoot,
|
|
91
92
|
{ cacheDir: ctx.cacheDir, refresh: ctx.refresh }
|
|
92
93
|
);
|
|
93
|
-
const {
|
|
94
|
+
const {
|
|
95
|
+
operations,
|
|
96
|
+
tags,
|
|
97
|
+
warnings: extractWarnings,
|
|
98
|
+
} = extractOperations(document, reference.route);
|
|
94
99
|
const info = document.info ?? { title: reference.label, version: "" };
|
|
95
100
|
const spec: ApiSpecData = {
|
|
96
101
|
codeSamples: reference.display.codeSamples,
|
|
@@ -98,8 +103,18 @@ export const openApiSource = (
|
|
|
98
103
|
document,
|
|
99
104
|
expandSchemas: reference.display.expandSchemas,
|
|
100
105
|
label: reference.label,
|
|
106
|
+
// Operation pages flow through the content pipeline, which mounts them
|
|
107
|
+
// under the site-wide `basePath` (staged entry refs below stay
|
|
108
|
+
// base-less); serialize the served route so components link to the
|
|
109
|
+
// pages' real URLs.
|
|
101
110
|
operations: Object.fromEntries(
|
|
102
|
-
operations.map((operation) => [
|
|
111
|
+
operations.map((operation) => [
|
|
112
|
+
operation.key,
|
|
113
|
+
{
|
|
114
|
+
...operation,
|
|
115
|
+
route: withBasePath(reference.basePath, operation.route),
|
|
116
|
+
},
|
|
117
|
+
])
|
|
103
118
|
),
|
|
104
119
|
route: reference.route,
|
|
105
120
|
slug: reference.slug,
|
|
@@ -108,11 +123,32 @@ export const openApiSource = (
|
|
|
108
123
|
version: info.version ?? "",
|
|
109
124
|
};
|
|
110
125
|
return {
|
|
111
|
-
diagnostics:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
diagnostics: [
|
|
127
|
+
...warnings.map((message) => ({
|
|
128
|
+
code: "BLUME_OPENAPI_STALE",
|
|
129
|
+
message,
|
|
130
|
+
severity: "warning" as const,
|
|
131
|
+
})),
|
|
132
|
+
...extractWarnings.map((message) => ({
|
|
133
|
+
code: "BLUME_OPENAPI_REF_PATH_ITEM",
|
|
134
|
+
message: `In OpenAPI spec "${reference.spec}": ${message}`,
|
|
135
|
+
severity: "warning" as const,
|
|
136
|
+
})),
|
|
137
|
+
// A document with no operations (say, a config file that happens to
|
|
138
|
+
// parse as YAML) would otherwise build a nav tab onto an empty
|
|
139
|
+
// reference with no hint why.
|
|
140
|
+
...(operations.length === 0
|
|
141
|
+
? [
|
|
142
|
+
{
|
|
143
|
+
code: "BLUME_OPENAPI_EMPTY",
|
|
144
|
+
message: `OpenAPI spec "${reference.spec}" for ${reference.route} declares no operations; its API reference is empty.`,
|
|
145
|
+
severity: "warning" as const,
|
|
146
|
+
suggestion:
|
|
147
|
+
"Check the spec points at an OpenAPI document with operations under `paths`.",
|
|
148
|
+
},
|
|
149
|
+
]
|
|
150
|
+
: []),
|
|
151
|
+
],
|
|
116
152
|
entries: specEntries(spec, operations),
|
|
117
153
|
slug: reference.slug,
|
|
118
154
|
spec,
|
|
@@ -125,8 +161,12 @@ export const openApiSource = (
|
|
|
125
161
|
// so fail loudly in build (blocks under --strict) while staying a warning
|
|
126
162
|
// in dev so offline work still runs.
|
|
127
163
|
severity: ctx.mode === "build" ? "error" : "warning",
|
|
164
|
+
// A readable-but-invalid file is a content problem, not a network one;
|
|
165
|
+
// only point at reachability for actual fetch/read failures.
|
|
128
166
|
suggestion:
|
|
129
|
-
|
|
167
|
+
error instanceof InvalidSpecError
|
|
168
|
+
? "Point the spec at an OpenAPI document (a YAML or JSON file with an object at the top level)."
|
|
169
|
+
: "Check the spec URL/path is reachable from the build environment; behind a proxy, set HTTP(S)_PROXY.",
|
|
130
170
|
};
|
|
131
171
|
}
|
|
132
172
|
};
|
|
@@ -134,7 +174,16 @@ export const openApiSource = (
|
|
|
134
174
|
const load = async (): Promise<SourceLoadResult> => {
|
|
135
175
|
const results = await Promise.all(references.map(loadReference));
|
|
136
176
|
const entries: SourceEntry[] = [];
|
|
137
|
-
|
|
177
|
+
// Route collisions recorded while deduping (see `blumeReferences`): a
|
|
178
|
+
// dropped source loses a whole spec's pages, so warn even when the kept
|
|
179
|
+
// spec loads cleanly.
|
|
180
|
+
const diagnostics: Diagnostic[] = references.flatMap((reference) =>
|
|
181
|
+
(reference.collisions ?? []).map((message) => ({
|
|
182
|
+
code: "BLUME_OPENAPI_ROUTE_COLLISION",
|
|
183
|
+
message,
|
|
184
|
+
severity: "warning" as const,
|
|
185
|
+
}))
|
|
186
|
+
);
|
|
138
187
|
const data: OpenApiData = {};
|
|
139
188
|
for (const result of results) {
|
|
140
189
|
if ("severity" in result) {
|
package/src/registry/eject.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { join, relative } from "pathe";
|
|
|
6
6
|
import { buildAskData } from "../ai/ask-data.ts";
|
|
7
7
|
import { resolveAskBackend } from "../ai/ask.ts";
|
|
8
8
|
import { buildRawMarkdown } from "../ai/markdown.ts";
|
|
9
|
+
import { buildMcpData } from "../ai/mcp/data.ts";
|
|
10
|
+
import { buildMcpDiscovery, buildMcpServerCard } from "../ai/mcp/discovery.ts";
|
|
9
11
|
import { planComponentSlots } from "../astro/component-slots.ts";
|
|
10
12
|
import { discoverExamples } from "../astro/examples.ts";
|
|
11
13
|
import {
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
askEndpointTemplate,
|
|
21
23
|
astroConfigTemplate,
|
|
22
24
|
catchAllPageTemplate,
|
|
25
|
+
changelogIndexTemplate,
|
|
23
26
|
contentConfigTemplate,
|
|
24
27
|
envTemplate,
|
|
25
28
|
exampleMapTemplate,
|
|
@@ -28,6 +31,8 @@ import {
|
|
|
28
31
|
exampleSlug,
|
|
29
32
|
islandMapTemplate,
|
|
30
33
|
islandWrapperTemplate,
|
|
34
|
+
mcpEndpointTemplate,
|
|
35
|
+
mcpPageFile,
|
|
31
36
|
mixedbreadSearchEndpointTemplate,
|
|
32
37
|
notFoundPageTemplate,
|
|
33
38
|
ogEndpointTemplate,
|
|
@@ -36,7 +41,9 @@ import {
|
|
|
36
41
|
runtimeTsconfigTemplate,
|
|
37
42
|
searchClientTemplate,
|
|
38
43
|
searchEndpointTemplate,
|
|
44
|
+
staticJsonEndpointTemplate,
|
|
39
45
|
} from "../astro/templates.ts";
|
|
46
|
+
import { packageRoot } from "../core/package-root.ts";
|
|
40
47
|
import { scanProject } from "../core/project-graph.ts";
|
|
41
48
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
42
49
|
import type { ProjectContext } from "../core/types.ts";
|
|
@@ -55,6 +62,39 @@ import { twoslashCss } from "../theme/twoslash.ts";
|
|
|
55
62
|
|
|
56
63
|
const toPosix = (path: string): string => path.split("\\").join("/");
|
|
57
64
|
|
|
65
|
+
/** The portable `@source` guess: blume in the project's own node_modules. */
|
|
66
|
+
const LOCAL_BLUME_SOURCE = "../../node_modules/blume/src/**/*.{astro,ts,tsx}";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The `@source` glob pointing Tailwind at Blume's own source, relative to the
|
|
70
|
+
* ejected `src/generated/app.css`. The project-local `node_modules/blume` is
|
|
71
|
+
* preferred (portable, and under pnpm the symlink survives version bumps), but
|
|
72
|
+
* hoisted installs (npm/yarn workspaces lift blume into the workspace root's
|
|
73
|
+
* node_modules) would make that guess match nothing and silently drop utility
|
|
74
|
+
* classes — so fall back to the package's real installed location, and when
|
|
75
|
+
* even that fails, warn instead of failing silently.
|
|
76
|
+
*
|
|
77
|
+
* Exported for testing.
|
|
78
|
+
*/
|
|
79
|
+
export const blumeSourceGlob = (
|
|
80
|
+
root: string,
|
|
81
|
+
genDir: string,
|
|
82
|
+
resolveBlumeRoot: () => string = packageRoot
|
|
83
|
+
): string => {
|
|
84
|
+
if (existsSync(join(root, "node_modules", "blume"))) {
|
|
85
|
+
return LOCAL_BLUME_SOURCE;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const src = join(resolveBlumeRoot(), "src");
|
|
89
|
+
return `${toPosix(relative(genDir, src))}/**/*.{astro,ts,tsx}`;
|
|
90
|
+
} catch {
|
|
91
|
+
console.warn(
|
|
92
|
+
'blume: could not locate the installed blume package; src/generated/app.css keeps its default `@source "../../node_modules/blume/..."` glob. If blume is hoisted elsewhere, point that glob at its install location or Blume\'s utility classes will be missing.'
|
|
93
|
+
);
|
|
94
|
+
return LOCAL_BLUME_SOURCE;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
58
98
|
/** The `blume:openapi` payload for the ejected app (`{}` when none). */
|
|
59
99
|
const ejectOpenApiData = (project: BlumeProject): unknown => {
|
|
60
100
|
const source = project.sources.find(isOpenApiSource);
|
|
@@ -90,6 +130,112 @@ const askFiles = async (
|
|
|
90
130
|
return files;
|
|
91
131
|
};
|
|
92
132
|
|
|
133
|
+
/** Whether the ejected app hosts the MCP server (enabled and route free). */
|
|
134
|
+
const hostsMcp = (
|
|
135
|
+
project: BlumeProject,
|
|
136
|
+
userPages: { pattern: string }[]
|
|
137
|
+
): boolean =>
|
|
138
|
+
project.config.mcp.enabled &&
|
|
139
|
+
!routeIsTaken(userPages, project.graph.pages, project.config.mcp.route);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The `.well-known` MCP discovery routes, injected as prerendered pages
|
|
143
|
+
* alongside the user's own so the ejected Astro config wires them in. Empty
|
|
144
|
+
* when the server is disabled or its route is already owned by a page.
|
|
145
|
+
*/
|
|
146
|
+
const mcpDiscoveryPages = (
|
|
147
|
+
project: BlumeProject,
|
|
148
|
+
userPages: { pattern: string }[]
|
|
149
|
+
): { entrypoint: string; pattern: string }[] =>
|
|
150
|
+
hostsMcp(project, userPages)
|
|
151
|
+
? [
|
|
152
|
+
{
|
|
153
|
+
entrypoint: "src/blume-mcp/discovery.ts",
|
|
154
|
+
pattern: "/.well-known/mcp.json",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
entrypoint: "src/blume-mcp/server-card.ts",
|
|
158
|
+
pattern: "/.well-known/mcp/server-card.json",
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
: [];
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The MCP data snapshot, server endpoint, and `.well-known` discovery
|
|
165
|
+
* documents, mirroring `writeMcpFiles` in generate.ts. Empty when the server
|
|
166
|
+
* is disabled or its route is already owned by a page.
|
|
167
|
+
*/
|
|
168
|
+
const mcpFiles = async (
|
|
169
|
+
project: BlumeProject,
|
|
170
|
+
userPages: { pattern: string }[],
|
|
171
|
+
srcDir: string,
|
|
172
|
+
genDir: string
|
|
173
|
+
): Promise<{ content: string; path: string }[]> => {
|
|
174
|
+
if (!hostsMcp(project, userPages)) {
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
const { route } = project.config.mcp;
|
|
178
|
+
const data = await buildMcpData(project);
|
|
179
|
+
const discoveryInput = {
|
|
180
|
+
base: data.base,
|
|
181
|
+
name: data.name,
|
|
182
|
+
route,
|
|
183
|
+
site: data.site,
|
|
184
|
+
version: data.version,
|
|
185
|
+
};
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
content: `${JSON.stringify(data)}\n`,
|
|
189
|
+
path: join(genDir, "mcp-data.json"),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
content: mcpEndpointTemplate(route),
|
|
193
|
+
path: join(srcDir, "pages", mcpPageFile(route)),
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
content: staticJsonEndpointTemplate(buildMcpDiscovery(discoveryInput)),
|
|
197
|
+
path: join(srcDir, "blume-mcp", "discovery.ts"),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
content: staticJsonEndpointTemplate(buildMcpServerCard(discoveryInput)),
|
|
201
|
+
path: join(srcDir, "blume-mcp", "server-card.ts"),
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The `/changelog` index page, mirroring `shouldGenerateChangelog` in
|
|
208
|
+
* generate.ts: emitted when `type: changelog` entries or a release-backed
|
|
209
|
+
* changelog source exist, unless a user page already owns the route.
|
|
210
|
+
*/
|
|
211
|
+
const changelogFiles = (
|
|
212
|
+
project: BlumeProject,
|
|
213
|
+
userPages: { pattern: string }[],
|
|
214
|
+
srcDir: string,
|
|
215
|
+
options: Parameters<typeof changelogIndexTemplate>[0]
|
|
216
|
+
): { content: string; path: string }[] => {
|
|
217
|
+
const hasChangelog = project.graph.pages.some(
|
|
218
|
+
(page) =>
|
|
219
|
+
page.contentType === "changelog" &&
|
|
220
|
+
!(page.meta.draft || page.meta.sidebar.hidden)
|
|
221
|
+
);
|
|
222
|
+
const hasChangelogSource = (project.config.content.sources ?? []).some(
|
|
223
|
+
(source) => source.type === "github-releases"
|
|
224
|
+
);
|
|
225
|
+
if (
|
|
226
|
+
!(hasChangelog || hasChangelogSource) ||
|
|
227
|
+
routeIsTaken(userPages, project.graph.pages, "/changelog")
|
|
228
|
+
) {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
return [
|
|
232
|
+
{
|
|
233
|
+
content: changelogIndexTemplate(options),
|
|
234
|
+
path: join(srcDir, "pages", "changelog.astro"),
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
};
|
|
238
|
+
|
|
93
239
|
/** Contents of the configured `examples.css`, or `""` when unset/absent. */
|
|
94
240
|
const readExamplesCss = (
|
|
95
241
|
root: string,
|
|
@@ -129,9 +275,13 @@ const examplesPreviewFiles = (
|
|
|
129
275
|
* eject the project has a normal `astro.config.mjs` and `src/`, the `blume` CLI
|
|
130
276
|
* is no longer required, and the `blume` package remains importable.
|
|
131
277
|
*
|
|
132
|
-
* Returns the
|
|
278
|
+
* Returns the written files plus non-fatal warnings, mirroring the generated
|
|
279
|
+
* runtime (e.g. a Scalar reference spec that wasn't found, or a reference
|
|
280
|
+
* route colliding with a content page).
|
|
133
281
|
*/
|
|
134
|
-
export const eject = async (
|
|
282
|
+
export const eject = async (
|
|
283
|
+
root: string
|
|
284
|
+
): Promise<{ files: string[]; warnings: string[] }> => {
|
|
135
285
|
const project = await scanProject(root, { mode: "build" });
|
|
136
286
|
const { context, config } = project;
|
|
137
287
|
|
|
@@ -183,10 +333,13 @@ export const eject = async (root: string): Promise<string[]> => {
|
|
|
183
333
|
const componentsImport = context.componentsFile
|
|
184
334
|
? `../../${toPosix(relative(root, context.componentsFile))}`
|
|
185
335
|
: null;
|
|
186
|
-
const relPages =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
336
|
+
const relPages = [
|
|
337
|
+
...pages.map((page) => ({
|
|
338
|
+
entrypoint: toPosix(relative(root, page.entrypoint)),
|
|
339
|
+
pattern: page.pattern,
|
|
340
|
+
})),
|
|
341
|
+
...mcpDiscoveryPages(project, pages),
|
|
342
|
+
];
|
|
190
343
|
|
|
191
344
|
// Non-filesystem sources eject their materialized MDX into `<root>/blume-staged`
|
|
192
345
|
// (a dedicated dir so it never clashes with a content root literally named
|
|
@@ -275,9 +428,11 @@ export const eject = async (root: string): Promise<string[]> => {
|
|
|
275
428
|
{
|
|
276
429
|
content: tailwindEntryTemplate({
|
|
277
430
|
configTokens: buildThemeCss(config.theme),
|
|
278
|
-
// Relative paths from src/generated/app.css keep the ejected app
|
|
431
|
+
// Relative paths from src/generated/app.css keep the ejected app
|
|
432
|
+
// portable; the blume glob resolves the real install location when
|
|
433
|
+
// the package is hoisted out of the project's own node_modules.
|
|
279
434
|
sources: [
|
|
280
|
-
|
|
435
|
+
blumeSourceGlob(root, genDir),
|
|
281
436
|
"../../**/*.{astro,mdx,ts,tsx}",
|
|
282
437
|
],
|
|
283
438
|
twoslashCss: twoslashCss(),
|
|
@@ -295,11 +450,11 @@ export const eject = async (root: string): Promise<string[]> => {
|
|
|
295
450
|
path: join(genDir, "raw-markdown.json"),
|
|
296
451
|
},
|
|
297
452
|
{
|
|
298
|
-
content: rawMarkdownEndpointTemplate(),
|
|
453
|
+
content: rawMarkdownEndpointTemplate("md"),
|
|
299
454
|
path: join(srcDir, "pages", "[...slug].md.ts"),
|
|
300
455
|
},
|
|
301
456
|
{
|
|
302
|
-
content: rawMarkdownEndpointTemplate(),
|
|
457
|
+
content: rawMarkdownEndpointTemplate("mdx"),
|
|
303
458
|
path: join(srcDir, "pages", "[...slug].mdx.ts"),
|
|
304
459
|
},
|
|
305
460
|
];
|
|
@@ -315,6 +470,19 @@ export const eject = async (root: string): Promise<string[]> => {
|
|
|
315
470
|
});
|
|
316
471
|
}
|
|
317
472
|
|
|
473
|
+
// The hosted MCP server and the `/changelog` index, mirrored from the
|
|
474
|
+
// generated runtime (each helper returns `[]` when its feature is off).
|
|
475
|
+
files.push(
|
|
476
|
+
...(await mcpFiles(project, pages, srcDir, genDir)),
|
|
477
|
+
...changelogFiles(project, pages, srcDir, {
|
|
478
|
+
askEnabled,
|
|
479
|
+
exportEpub,
|
|
480
|
+
exportPdf,
|
|
481
|
+
needsReact,
|
|
482
|
+
staged: hasStaged,
|
|
483
|
+
})
|
|
484
|
+
);
|
|
485
|
+
|
|
318
486
|
// Default 404 page, unless the project already owns `/404` (a custom
|
|
319
487
|
// `pages/404.astro` or a `404.md` content page). The ejected project owns the
|
|
320
488
|
// file afterwards and can edit or remove it.
|
|
@@ -372,13 +540,17 @@ export const eject = async (root: string): Promise<string[]> => {
|
|
|
372
540
|
}
|
|
373
541
|
|
|
374
542
|
// Scalar API/AsyncAPI reference pages, mirrored from the generated runtime so
|
|
375
|
-
// the ejected app keeps its reference routes
|
|
543
|
+
// the ejected app keeps its reference routes — including the warnings (a
|
|
544
|
+
// missing spec file, a route collision), which the caller surfaces exactly
|
|
545
|
+
// like the generated-runtime path does.
|
|
546
|
+
const warnings: string[] = [];
|
|
376
547
|
if (hasScalarReferences(config)) {
|
|
377
548
|
const references = await buildReferenceFiles({
|
|
378
549
|
config,
|
|
379
550
|
contentRoutes: new Set(project.graph.pages.map((page) => page.route)),
|
|
380
551
|
root,
|
|
381
552
|
});
|
|
553
|
+
warnings.push(...references.warnings);
|
|
382
554
|
for (const file of references.files) {
|
|
383
555
|
files.push({
|
|
384
556
|
content: file.content,
|
|
@@ -433,5 +605,5 @@ export const eject = async (root: string): Promise<string[]> => {
|
|
|
433
605
|
// The hidden runtime is no longer the source of truth.
|
|
434
606
|
await rm(context.outDir, { force: true, recursive: true });
|
|
435
607
|
|
|
436
|
-
return written.map((file) => file.path);
|
|
608
|
+
return { files: written.map((file) => file.path), warnings };
|
|
437
609
|
};
|
package/src/registry/registry.ts
CHANGED
|
@@ -23,9 +23,6 @@ export interface RegistryItem {
|
|
|
23
23
|
/** Absolute path to the blume package `src` directory (the copy source root). */
|
|
24
24
|
export const packageSrc = join(packageRoot(), "src");
|
|
25
25
|
|
|
26
|
-
/** Absolute path to the bundled registry item sources. */
|
|
27
|
-
export const itemsRoot = join(packageRoot(), "src", "registry", "items");
|
|
28
|
-
|
|
29
26
|
/**
|
|
30
27
|
* A built-in layout component offered as editable source. `blume add` rewrites
|
|
31
28
|
* its relative imports to `blume/*`, so it renders identically to the built-in
|
package/src/search/documents.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { applyAudienceVisibility } from "../ai/visibility.ts";
|
|
2
|
+
import type { VisibilityAudience } from "../ai/visibility.ts";
|
|
1
3
|
import matter from "../core/frontmatter.ts";
|
|
2
4
|
import { contentIndexable } from "../core/manifest.ts";
|
|
3
5
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
@@ -93,9 +95,21 @@ interface Crumbs {
|
|
|
93
95
|
*/
|
|
94
96
|
const buildCrumbIndex = (sidebar: NavNode[]): Map<string, Crumbs> => {
|
|
95
97
|
const index = new Map<string, Crumbs>();
|
|
98
|
+
// A config-sidebar section's landing page (the group's `root`) lives on the
|
|
99
|
+
// *group* node, not on any page leaf — record it under the group's own label
|
|
100
|
+
// so the section's landing page carries the same facet as its children. A
|
|
101
|
+
// real page leaf for the route (filesystem sidebars emit index pages as
|
|
102
|
+
// leaves) wins, so group routes are merged in only where no leaf claimed one.
|
|
103
|
+
const groupRoutes = new Map<string, Crumbs>();
|
|
96
104
|
const walk = (nodes: NavNode[], trail: string[]): void => {
|
|
97
105
|
for (const node of nodes) {
|
|
98
106
|
if (node.kind === "group") {
|
|
107
|
+
if (node.route && !groupRoutes.has(node.route)) {
|
|
108
|
+
groupRoutes.set(node.route, {
|
|
109
|
+
breadcrumb: [...trail, node.label],
|
|
110
|
+
section: node.label,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
99
113
|
walk(node.children, [...trail, node.label]);
|
|
100
114
|
} else if (node.route) {
|
|
101
115
|
index.set(node.route, {
|
|
@@ -106,6 +120,11 @@ const buildCrumbIndex = (sidebar: NavNode[]): Map<string, Crumbs> => {
|
|
|
106
120
|
}
|
|
107
121
|
};
|
|
108
122
|
walk(sidebar, []);
|
|
123
|
+
for (const [route, crumbs] of groupRoutes) {
|
|
124
|
+
if (!index.has(route)) {
|
|
125
|
+
index.set(route, crumbs);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
109
128
|
return index;
|
|
110
129
|
};
|
|
111
130
|
|
|
@@ -122,10 +141,19 @@ const buildCrumbIndex = (sidebar: NavNode[]): Map<string, Crumbs> => {
|
|
|
122
141
|
* searchable text; `"markdown"` keeps the body's Markdown — code blocks, lists,
|
|
123
142
|
* headings — for Ask AI grounding, where fenced examples are often the answer
|
|
124
143
|
* and stripping them makes the model unable to cite content the docs do contain.
|
|
144
|
+
*
|
|
145
|
+
* `audience` resolves `<Visibility>` blocks before extraction: `"web"`
|
|
146
|
+
* (default) keeps web-only content and drops agents-only blocks — the site
|
|
147
|
+
* search and hosted syncs must not surface content the page hides — while
|
|
148
|
+
* `"agents"` mirrors llms-full.txt/MCP `get_page` (web removed, agents kept).
|
|
125
149
|
*/
|
|
126
150
|
export const buildSearchDocuments = async (
|
|
127
151
|
project: BlumeProject,
|
|
128
|
-
options?: {
|
|
152
|
+
options?: {
|
|
153
|
+
includeWhenDisabled?: boolean;
|
|
154
|
+
content?: "markdown" | "plain";
|
|
155
|
+
audience?: VisibilityAudience;
|
|
156
|
+
}
|
|
129
157
|
): Promise<SearchDocument[]> => {
|
|
130
158
|
const pageById = new Map(project.graph.pages.map((page) => [page.id, page]));
|
|
131
159
|
|
|
@@ -157,8 +185,12 @@ export const buildSearchDocuments = async (
|
|
|
157
185
|
const page = pageById.get(route.id);
|
|
158
186
|
const raw = page ? await readEntryText(project, page) : "";
|
|
159
187
|
const source = raw ? matter(raw).content : "";
|
|
188
|
+
const visible = applyAudienceVisibility(
|
|
189
|
+
source,
|
|
190
|
+
options?.audience ?? "web"
|
|
191
|
+
);
|
|
160
192
|
const body =
|
|
161
|
-
options?.content === "markdown" ?
|
|
193
|
+
options?.content === "markdown" ? visible.trim() : toPlainText(visible);
|
|
162
194
|
const tags = page?.meta?.search?.tags;
|
|
163
195
|
const crumb = crumbs.get(route.path);
|
|
164
196
|
return {
|
package/src/seo/jsonld.ts
CHANGED
|
@@ -37,7 +37,13 @@ const trimSlash = (value: string): string => value.replace(/\/$/u, "");
|
|
|
37
37
|
const absolute = (base: string | null, path: string): string =>
|
|
38
38
|
base ? `${base}${path}` : path;
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Frontmatter date → ISO 8601, or undefined when absent/unparseable. Shared with
|
|
42
|
+
* the layout's `article:published_time`/`article:modified_time` so both date
|
|
43
|
+
* surfaces treat a malformed date the same way: omit it rather than emit
|
|
44
|
+
* "Invalid Date".
|
|
45
|
+
*/
|
|
46
|
+
export const toIso = (value: DateInput | undefined): string | undefined => {
|
|
41
47
|
if (!value) {
|
|
42
48
|
return;
|
|
43
49
|
}
|
|
@@ -98,20 +104,21 @@ export const buildStructuredData = (
|
|
|
98
104
|
}
|
|
99
105
|
graph.push(node);
|
|
100
106
|
|
|
101
|
-
|
|
107
|
+
// Google requires `item` on every ListItem except the last; sidebar groups
|
|
108
|
+
// without an index page produce route-less crumbs, so those are dropped
|
|
109
|
+
// (positions renumbered) rather than emitted as invalid link-less items.
|
|
110
|
+
const linked = input.breadcrumbs.filter(
|
|
111
|
+
(crumb): crumb is Required<Crumb> => typeof crumb.route === "string"
|
|
112
|
+
);
|
|
113
|
+
if (linked.length > 1) {
|
|
102
114
|
graph.push({
|
|
103
115
|
"@type": "BreadcrumbList",
|
|
104
|
-
itemListElement:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (crumb.route) {
|
|
111
|
-
item.item = absolute(base, withBasePath(deployBase, crumb.route));
|
|
112
|
-
}
|
|
113
|
-
return item;
|
|
114
|
-
}),
|
|
116
|
+
itemListElement: linked.map((crumb, index) => ({
|
|
117
|
+
"@type": "ListItem",
|
|
118
|
+
item: absolute(base, withBasePath(deployBase, crumb.route)),
|
|
119
|
+
name: crumb.label,
|
|
120
|
+
position: index + 1,
|
|
121
|
+
})),
|
|
115
122
|
});
|
|
116
123
|
}
|
|
117
124
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize an X account to the leading `@` that `twitter:site`/`twitter:creator`
|
|
3
|
+
* require, so `acme`, `@acme`, and ` @acme ` all land on `@acme`. Empty or
|
|
4
|
+
* blank input yields undefined, which renders no tag at all.
|
|
5
|
+
*
|
|
6
|
+
* The layouts call this on values that never passed through the config schema:
|
|
7
|
+
* Astro's collections carry no schema here, so a page's `seo.x.creator` reaches
|
|
8
|
+
* them as raw frontmatter, and the schema's own transform never runs on it.
|
|
9
|
+
* (Blume's page pipeline does reject a non-string `creator` before the page is
|
|
10
|
+
* built, so `unknown` is defense in depth rather than the expected path.)
|
|
11
|
+
*/
|
|
12
|
+
export const normalizeXHandle = (value: unknown): string | undefined => {
|
|
13
|
+
if (typeof value !== "string") {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const handle = value.trim().replace(/^@+/u, "");
|
|
17
|
+
return handle ? `@${handle}` : undefined;
|
|
18
|
+
};
|