blume 0.4.0 → 0.5.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/dist/cli/index.js +1137 -722
- package/dist/cli/index.js.map +28 -23
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/project.d.ts +12 -2
- package/dist/types/core/schema.d.ts +154 -15
- package/dist/types/core/types.d.ts +7 -0
- package/docs/advanced/api-reference.mdx +33 -23
- package/docs/advanced/bridge.mdx +74 -0
- package/docs/advanced/meta.ts +8 -1
- package/docs/advanced/migrate.mdx +119 -0
- package/docs/configuration/index.mdx +1 -1
- package/docs/content/components.mdx +55 -2
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/syntax.mdx +2 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +29 -1
- package/docs/reference/frontmatter.mdx +5 -0
- package/package.json +11 -1
- package/src/astro/generate.ts +18 -8
- package/src/astro/templates.ts +28 -4
- package/src/cli/commands/build.ts +107 -63
- package/src/cli/commands/check.ts +20 -0
- package/src/cli/dev-lock.ts +13 -5
- package/src/cli/prepare.ts +3 -0
- package/src/components/BlumePage.astro +6 -0
- package/src/components/Icon.astro +13 -10
- package/src/components/content/ApiField.astro +75 -0
- package/src/components/content/ParamField.astro +39 -0
- package/src/components/content/RequestField.astro +23 -0
- package/src/components/content/ResponseField.astro +23 -0
- package/src/components/content/Step.astro +1 -1
- package/src/components/layout/Breadcrumbs.astro +7 -2
- package/src/components/layout/NavTree.astro +24 -8
- package/src/components/layout/RootLayout.astro +56 -34
- package/src/components/layout/Search.astro +1 -1
- package/src/components/openapi/ApiOverview.astro +84 -0
- package/src/components/openapi/MethodBadge.astro +28 -0
- package/src/components/openapi/Operation.astro +140 -0
- package/src/components/openapi/ParametersTable.astro +97 -0
- package/src/components/openapi/RequestBody.astro +58 -0
- package/src/components/openapi/RequestPanel.astro +169 -0
- package/src/components/openapi/Responses.astro +91 -0
- package/src/components/openapi/SchemaProperty.astro +118 -0
- package/src/components/openapi/SchemaTable.astro +86 -0
- package/src/components/openapi/helpers.ts +238 -0
- package/src/components/openapi/panel.ts +59 -0
- package/src/components/openapi/snippets.ts +201 -0
- package/src/core/builtin-tags.ts +5 -0
- package/src/core/data.ts +2 -0
- package/src/core/project-graph.ts +5 -1
- package/src/core/project.ts +25 -3
- package/src/core/schema.ts +47 -6
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/resolve.ts +28 -6
- package/src/core/types.ts +7 -0
- package/src/migrate/mintlify/config.ts +153 -1
- package/src/migrate/mintlify/content.ts +8 -2
- package/src/migrate/mintlify/index.ts +58 -1
- package/src/openapi/model.ts +174 -0
- package/src/openapi/parse.ts +48 -0
- package/src/openapi/references.ts +164 -0
- package/src/openapi/render-mdx.ts +76 -0
- package/src/openapi/scalar.ts +15 -103
- package/src/openapi/source.ts +140 -0
- package/src/registry/eject.ts +15 -2
- package/src/theme/chrome-icons.ts +22 -0
- package/src/theme/icons.ts +151 -161
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client behaviour for the OpenAPI request/response panels. `<blume-panel-tabs>`
|
|
3
|
+
* switches the visible `[data-panel="key"]` region when a `[data-panel-tab="key"]`
|
|
4
|
+
* button is clicked, and an optional `[data-panel-copy]` button copies the active
|
|
5
|
+
* panel's text. Vanilla custom element — no framework, in keeping with the core
|
|
6
|
+
* theme.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
class BlumePanelTabs extends HTMLElement {
|
|
10
|
+
connectedCallback() {
|
|
11
|
+
const tabs = [
|
|
12
|
+
...this.querySelectorAll<HTMLButtonElement>("[data-panel-tab]"),
|
|
13
|
+
];
|
|
14
|
+
const panels = [...this.querySelectorAll<HTMLElement>("[data-panel]")];
|
|
15
|
+
const copy = this.querySelector<HTMLButtonElement>("[data-panel-copy]");
|
|
16
|
+
|
|
17
|
+
const activate = (key: string): void => {
|
|
18
|
+
for (const tab of tabs) {
|
|
19
|
+
tab.setAttribute(
|
|
20
|
+
"aria-selected",
|
|
21
|
+
tab.dataset.panelTab === key ? "true" : "false"
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
for (const panel of panels) {
|
|
25
|
+
panel.classList.toggle("hidden", panel.dataset.panel !== key);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
for (const tab of tabs) {
|
|
30
|
+
tab.addEventListener("click", () => {
|
|
31
|
+
const key = tab.dataset.panelTab;
|
|
32
|
+
if (key) {
|
|
33
|
+
activate(key);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (copy) {
|
|
39
|
+
copy.addEventListener("click", async () => {
|
|
40
|
+
const active = panels.find(
|
|
41
|
+
(panel) => !panel.classList.contains("hidden")
|
|
42
|
+
);
|
|
43
|
+
try {
|
|
44
|
+
await navigator.clipboard.writeText(active?.textContent ?? "");
|
|
45
|
+
copy.dataset.copied = "true";
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
delete copy.dataset.copied;
|
|
48
|
+
}, 1500);
|
|
49
|
+
} catch {
|
|
50
|
+
// Clipboard unavailable (insecure context); silently ignore.
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!customElements.get("blume-panel-tabs")) {
|
|
58
|
+
customElements.define("blume-panel-tabs", BlumePanelTabs);
|
|
59
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { exampleValue, toJson } from "./helpers.ts";
|
|
2
|
+
import type { SchemaLike } from "./helpers.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Request example + code-sample generation for an operation. Kept separate from
|
|
6
|
+
* `helpers.ts` so the schema renderers don't pull in the sample builders. Output
|
|
7
|
+
* is intentionally simple, copy-pasteable starter code — not an exhaustive SDK.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface ParamLike {
|
|
11
|
+
name?: string;
|
|
12
|
+
in?: string;
|
|
13
|
+
required?: boolean;
|
|
14
|
+
schema?: SchemaLike;
|
|
15
|
+
example?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MediaTypeLike {
|
|
19
|
+
schema?: SchemaLike;
|
|
20
|
+
example?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OperationLike {
|
|
24
|
+
parameters?: ParamLike[];
|
|
25
|
+
requestBody?: { content?: Record<string, MediaTypeLike> };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RequestSample {
|
|
29
|
+
method: string;
|
|
30
|
+
url: string;
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
/** JSON-stringified request body, when the operation takes one. */
|
|
33
|
+
body?: string;
|
|
34
|
+
bodyValue?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TRAILING_SLASH = /\/+$/u;
|
|
38
|
+
|
|
39
|
+
const jsonContentType = (
|
|
40
|
+
content: Record<string, MediaTypeLike> | undefined
|
|
41
|
+
): [string, MediaTypeLike] | undefined => {
|
|
42
|
+
const entries = Object.entries(content ?? {});
|
|
43
|
+
return entries.find(([type]) => type.includes("json")) ?? entries[0];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Assemble a representative request from an operation and the spec servers. */
|
|
47
|
+
export const buildRequestSample = (
|
|
48
|
+
operation: OperationLike,
|
|
49
|
+
method: string,
|
|
50
|
+
path: string,
|
|
51
|
+
servers: { url?: string }[],
|
|
52
|
+
schemas: Record<string, SchemaLike>
|
|
53
|
+
): RequestSample => {
|
|
54
|
+
const base = (servers[0]?.url ?? "").replace(TRAILING_SLASH, "");
|
|
55
|
+
const params = operation.parameters ?? [];
|
|
56
|
+
|
|
57
|
+
let resolvedPath = path;
|
|
58
|
+
for (const param of params) {
|
|
59
|
+
if (param.in === "path" && param.name) {
|
|
60
|
+
const value = param.example ?? exampleValue(param.schema, schemas);
|
|
61
|
+
resolvedPath = resolvedPath.replace(
|
|
62
|
+
`{${param.name}}`,
|
|
63
|
+
encodeURIComponent(String(value ?? param.name))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const query = params
|
|
69
|
+
.filter((param) => param.in === "query" && param.required && param.name)
|
|
70
|
+
.map((param) => {
|
|
71
|
+
const value = param.example ?? exampleValue(param.schema, schemas);
|
|
72
|
+
return `${encodeURIComponent(param.name ?? "")}=${encodeURIComponent(
|
|
73
|
+
String(value ?? "")
|
|
74
|
+
)}`;
|
|
75
|
+
});
|
|
76
|
+
const search = query.length > 0 ? `?${query.join("&")}` : "";
|
|
77
|
+
|
|
78
|
+
const headers: Record<string, string> = {};
|
|
79
|
+
for (const param of params) {
|
|
80
|
+
if (param.in === "header" && param.required && param.name) {
|
|
81
|
+
headers[param.name] = String(
|
|
82
|
+
param.example ?? exampleValue(param.schema, schemas) ?? ""
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const media = jsonContentType(operation.requestBody?.content);
|
|
88
|
+
let body: string | undefined;
|
|
89
|
+
let bodyValue: unknown;
|
|
90
|
+
if (media) {
|
|
91
|
+
const [type, mediaType] = media;
|
|
92
|
+
headers["Content-Type"] = type;
|
|
93
|
+
bodyValue = mediaType.example ?? exampleValue(mediaType.schema, schemas);
|
|
94
|
+
body = toJson(bodyValue);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
body,
|
|
99
|
+
bodyValue,
|
|
100
|
+
headers,
|
|
101
|
+
method: method.toUpperCase(),
|
|
102
|
+
url: `${base}${resolvedPath}${search}`,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const headerLines = (
|
|
107
|
+
headers: Record<string, string>,
|
|
108
|
+
format: (key: string, value: string) => string
|
|
109
|
+
): string[] =>
|
|
110
|
+
Object.entries(headers).map(([key, value]) => format(key, value));
|
|
111
|
+
|
|
112
|
+
const curlSnippet = (sample: RequestSample): string => {
|
|
113
|
+
const lines = [
|
|
114
|
+
`curl -X ${sample.method} "${sample.url}"`,
|
|
115
|
+
...headerLines(sample.headers, (key, value) => ` -H "${key}: ${value}"`),
|
|
116
|
+
];
|
|
117
|
+
if (sample.body) {
|
|
118
|
+
lines.push(` -d '${sample.body}'`);
|
|
119
|
+
}
|
|
120
|
+
return lines.join(" \\\n");
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const fetchSnippet = (sample: RequestSample): string => {
|
|
124
|
+
const options = [` method: "${sample.method}"`];
|
|
125
|
+
if (Object.keys(sample.headers).length > 0) {
|
|
126
|
+
const headers = headerLines(
|
|
127
|
+
sample.headers,
|
|
128
|
+
(key, value) => ` "${key}": "${value}"`
|
|
129
|
+
).join(",\n");
|
|
130
|
+
options.push(` headers: {\n${headers}\n }`);
|
|
131
|
+
}
|
|
132
|
+
if (sample.body) {
|
|
133
|
+
options.push(` body: JSON.stringify(${sample.body})`);
|
|
134
|
+
}
|
|
135
|
+
return `const response = await fetch("${sample.url}", {\n${options.join(
|
|
136
|
+
",\n"
|
|
137
|
+
)}\n});`;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Turn a JSON literal into an equivalent Python literal (`true` -> `True`). */
|
|
141
|
+
const toPython = (json: string): string =>
|
|
142
|
+
json
|
|
143
|
+
.replaceAll(/\btrue\b/gu, "True")
|
|
144
|
+
.replaceAll(/\bfalse\b/gu, "False")
|
|
145
|
+
.replaceAll(/\bnull\b/gu, "None");
|
|
146
|
+
|
|
147
|
+
const pythonSnippet = (sample: RequestSample): string => {
|
|
148
|
+
const args = [` "${sample.url}"`];
|
|
149
|
+
if (Object.keys(sample.headers).length > 0) {
|
|
150
|
+
const headers = headerLines(
|
|
151
|
+
sample.headers,
|
|
152
|
+
(key, value) => ` "${key}": "${value}"`
|
|
153
|
+
).join(",\n");
|
|
154
|
+
args.push(` headers={\n${headers}\n }`);
|
|
155
|
+
}
|
|
156
|
+
if (sample.body) {
|
|
157
|
+
args.push(` json=${toPython(sample.body)}`);
|
|
158
|
+
}
|
|
159
|
+
return `import requests\n\nresponse = requests.${sample.method.toLowerCase()}(\n${args.join(
|
|
160
|
+
",\n"
|
|
161
|
+
)},\n)`;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** A code-sample language: config id -> label, Shiki lang, and builder. */
|
|
165
|
+
export interface SampleLanguage {
|
|
166
|
+
id: string;
|
|
167
|
+
label: string;
|
|
168
|
+
lang: string;
|
|
169
|
+
build: (sample: RequestSample) => string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const LANGUAGES: SampleLanguage[] = [
|
|
173
|
+
{ build: curlSnippet, id: "curl", label: "cURL", lang: "bash" },
|
|
174
|
+
{ build: fetchSnippet, id: "js", label: "JavaScript", lang: "js" },
|
|
175
|
+
{ build: pythonSnippet, id: "python", label: "Python", lang: "python" },
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const ALIASES: Record<string, string> = {
|
|
179
|
+
bash: "curl",
|
|
180
|
+
javascript: "js",
|
|
181
|
+
node: "js",
|
|
182
|
+
py: "python",
|
|
183
|
+
shell: "curl",
|
|
184
|
+
typescript: "js",
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** The sample languages to render, resolved from config ids (unknown ids dropped). */
|
|
188
|
+
export const sampleLanguages = (ids: string[]): SampleLanguage[] => {
|
|
189
|
+
const wanted = ids.length > 0 ? ids : ["curl", "js", "python"];
|
|
190
|
+
const out: SampleLanguage[] = [];
|
|
191
|
+
const seen = new Set<string>();
|
|
192
|
+
for (const raw of wanted) {
|
|
193
|
+
const id = ALIASES[raw.toLowerCase()] ?? raw.toLowerCase();
|
|
194
|
+
const language = LANGUAGES.find((entry) => entry.id === id);
|
|
195
|
+
if (language && !seen.has(id)) {
|
|
196
|
+
seen.add(id);
|
|
197
|
+
out.push(language);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
};
|
package/src/core/builtin-tags.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
export const BUILTIN_MDX_TAGS = new Set<string>([
|
|
8
8
|
"Accordion",
|
|
9
9
|
"AccordionItem",
|
|
10
|
+
"ApiOverview",
|
|
10
11
|
"AutoTypeTable",
|
|
11
12
|
"Badge",
|
|
12
13
|
"Callout",
|
|
@@ -25,8 +26,12 @@ export const BUILTIN_MDX_TAGS = new Set<string>([
|
|
|
25
26
|
"GithubInfo",
|
|
26
27
|
"Icon",
|
|
27
28
|
"Math",
|
|
29
|
+
"Operation",
|
|
28
30
|
"Panel",
|
|
31
|
+
"ParamField",
|
|
29
32
|
"Prompt",
|
|
33
|
+
"RequestField",
|
|
34
|
+
"ResponseField",
|
|
30
35
|
"Step",
|
|
31
36
|
"Steps",
|
|
32
37
|
"Tab",
|
package/src/core/data.ts
CHANGED
|
@@ -93,6 +93,8 @@ export interface BlumeDataConfig {
|
|
|
93
93
|
favicon: BlumeFavicon;
|
|
94
94
|
feedback: boolean;
|
|
95
95
|
i18n: BlumeDataI18n | null;
|
|
96
|
+
/** Default icon library for bare `icon` names. */
|
|
97
|
+
icons: ResolvedConfig["icons"];
|
|
96
98
|
/** `markdown.imageZoom`: click-to-zoom content images. */
|
|
97
99
|
imageZoom: boolean;
|
|
98
100
|
logo: BlumeLogo | null;
|
|
@@ -88,6 +88,8 @@ export const scanProject = async (
|
|
|
88
88
|
refresh?: boolean;
|
|
89
89
|
/** CLI overrides applied over the loaded config (e.g. `--output`). */
|
|
90
90
|
overrides?: ConfigOverrides;
|
|
91
|
+
/** Relocate the generated runtime (e.g. `.blume-verify` for isolation). */
|
|
92
|
+
runtimeDir?: string;
|
|
91
93
|
} = {}
|
|
92
94
|
): Promise<BlumeProject> => {
|
|
93
95
|
const mode = options.mode ?? "dev";
|
|
@@ -97,7 +99,9 @@ export const scanProject = async (
|
|
|
97
99
|
});
|
|
98
100
|
const { bridge } = configResult;
|
|
99
101
|
const config = applyConfigOverrides(configResult.config, options.overrides);
|
|
100
|
-
const context = resolveProjectContext(root, config
|
|
102
|
+
const context = resolveProjectContext(root, config, {
|
|
103
|
+
runtimeDir: options.runtimeDir,
|
|
104
|
+
});
|
|
101
105
|
|
|
102
106
|
// Each source validates itself (e.g. the filesystem source checks its root
|
|
103
107
|
// exists), replacing the single hard `contentRoot` check.
|
package/src/core/project.ts
CHANGED
|
@@ -28,13 +28,27 @@ const firstExisting = (root: string, names: string[]): string | null => {
|
|
|
28
28
|
export const findConfigFile = (root: string): string | null =>
|
|
29
29
|
firstExisting(root, CONFIG_FILENAMES);
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the generated runtime directory for a project. Defaults to
|
|
33
|
+
* `<root>/.blume`; an override (e.g. `.blume-verify` for an isolated build that
|
|
34
|
+
* runs alongside a live `blume dev`) may be relative to the root or absolute.
|
|
35
|
+
*/
|
|
36
|
+
export const resolveRuntimeDir = (
|
|
37
|
+
root: string,
|
|
38
|
+
runtimeDir = ".blume"
|
|
39
|
+
): string =>
|
|
40
|
+
isAbsolute(runtimeDir) ? runtimeDir : join(resolve(root), runtimeDir);
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* Resolve every path Blume needs from a project root and its resolved config.
|
|
33
|
-
* Paths are absolute and normalized.
|
|
44
|
+
* Paths are absolute and normalized. `options.runtimeDir` relocates the whole
|
|
45
|
+
* generated runtime (and its build output) so a verify build/check can run
|
|
46
|
+
* without touching a live dev server's `.blume/` or the real `dist/`.
|
|
34
47
|
*/
|
|
35
48
|
export const resolveProjectContext = (
|
|
36
49
|
root: string,
|
|
37
|
-
config: ResolvedConfig
|
|
50
|
+
config: ResolvedConfig,
|
|
51
|
+
options?: { runtimeDir?: string }
|
|
38
52
|
): ProjectContext => {
|
|
39
53
|
const absoluteRoot = resolve(root);
|
|
40
54
|
const contentRoot = isAbsolute(config.content.root)
|
|
@@ -44,11 +58,19 @@ export const resolveProjectContext = (
|
|
|
44
58
|
const pagesPath = join(absoluteRoot, config.content.pages);
|
|
45
59
|
const pagesRoot = existsSync(pagesPath) ? pagesPath : null;
|
|
46
60
|
|
|
61
|
+
const outDir = resolveRuntimeDir(absoluteRoot, options?.runtimeDir);
|
|
62
|
+
// A relocated runtime keeps its build output self-contained under itself, so a
|
|
63
|
+
// verify build never empties the user's real `<root>/dist`.
|
|
64
|
+
const distDir = options?.runtimeDir
|
|
65
|
+
? join(outDir, "dist")
|
|
66
|
+
: join(absoluteRoot, "dist");
|
|
67
|
+
|
|
47
68
|
return {
|
|
48
69
|
componentsFile: firstExisting(absoluteRoot, COMPONENTS_FILENAMES),
|
|
49
70
|
configFile: findConfigFile(absoluteRoot),
|
|
50
71
|
contentRoot,
|
|
51
|
-
|
|
72
|
+
distDir,
|
|
73
|
+
outDir,
|
|
52
74
|
pagesRoot,
|
|
53
75
|
root: absoluteRoot,
|
|
54
76
|
themeFile: firstExisting(absoluteRoot, THEME_FILENAMES),
|
package/src/core/schema.ts
CHANGED
|
@@ -69,9 +69,30 @@ const changelogMetaSchema = z
|
|
|
69
69
|
})
|
|
70
70
|
.strict();
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A post author: a bare name/handle, or an object with a name plus optional
|
|
74
|
+
* avatar/URL. The object is passthrough so richer author metadata (social
|
|
75
|
+
* handles, roles) survives untouched — Blume doesn't render authors yet, so
|
|
76
|
+
* this exists to preserve the field (common on blog/changelog pages) rather
|
|
77
|
+
* than have a strict scan reject it.
|
|
78
|
+
*/
|
|
79
|
+
const authorSchema = z.union([
|
|
80
|
+
z.string(),
|
|
81
|
+
z
|
|
82
|
+
.object({
|
|
83
|
+
avatar: z.string().optional(),
|
|
84
|
+
image: z.string().optional(),
|
|
85
|
+
name: z.string(),
|
|
86
|
+
url: z.string().optional(),
|
|
87
|
+
})
|
|
88
|
+
.passthrough(),
|
|
89
|
+
]);
|
|
90
|
+
|
|
72
91
|
/** Frontmatter accepted on any content page. */
|
|
73
92
|
const pageMetaBaseSchema = z
|
|
74
93
|
.object({
|
|
94
|
+
/** Post author(s) for blog/changelog content; preserved, not yet rendered. */
|
|
95
|
+
authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
|
|
75
96
|
changelog: changelogMetaSchema.optional(),
|
|
76
97
|
/** Publish date for feed-backed content like blog/changelog. */
|
|
77
98
|
date: dateSchema.optional(),
|
|
@@ -903,8 +924,8 @@ const markdownConfigSchema = z
|
|
|
903
924
|
.strict();
|
|
904
925
|
|
|
905
926
|
/**
|
|
906
|
-
* A single spec rendered by the API reference
|
|
907
|
-
*
|
|
927
|
+
* A single spec rendered by the API reference. `spec` is a local path or an
|
|
928
|
+
* `http(s)` URL (OpenAPI for the Blume renderer; OpenAPI or AsyncAPI for Scalar).
|
|
908
929
|
*/
|
|
909
930
|
const openapiSourceSchema = z
|
|
910
931
|
.object({
|
|
@@ -920,20 +941,28 @@ const openapiSourceSchema = z
|
|
|
920
941
|
export type OpenApiSource = z.infer<typeof openapiSourceSchema>;
|
|
921
942
|
|
|
922
943
|
/**
|
|
923
|
-
* OpenAPI reference
|
|
924
|
-
*
|
|
925
|
-
*
|
|
944
|
+
* OpenAPI reference. By default (`renderer: "blume"`) Blume parses the spec with
|
|
945
|
+
* Scalar's parser and renders its own UI: one real page per operation, grouped
|
|
946
|
+
* by tag in the sidebar and included in site search, llms.txt, and OG. Set
|
|
947
|
+
* `renderer: "scalar"` to fall back to the embedded Scalar SPA (a single
|
|
948
|
+
* self-contained route that doesn't weave into the sidebar or search).
|
|
926
949
|
*/
|
|
927
950
|
const openapiConfigSchema = z
|
|
928
951
|
.object({
|
|
952
|
+
/** Code-sample languages shown per operation (Blume renderer). */
|
|
953
|
+
codeSamples: z.array(z.string()).default(["curl", "js", "python"]),
|
|
929
954
|
enabled: z.boolean().default(false),
|
|
955
|
+
/** Start nested schema rows expanded rather than collapsed (Blume renderer). */
|
|
956
|
+
expandSchemas: z.boolean().default(false),
|
|
957
|
+
/** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
|
|
958
|
+
renderer: z.enum(["blume", "scalar"]).default("blume"),
|
|
930
959
|
/** Where the reference mounts. */
|
|
931
960
|
route: z.string().default("/reference"),
|
|
932
961
|
/** One or more specs; each renders on its own route by default. */
|
|
933
962
|
sources: z.array(openapiSourceSchema).default([]),
|
|
934
963
|
/** Shorthand for a single source: `sources: [{ spec }]`. */
|
|
935
964
|
spec: z.string().optional(),
|
|
936
|
-
/** Scalar theme name
|
|
965
|
+
/** Scalar theme name (Scalar renderer only). */
|
|
937
966
|
theme: z.string().optional(),
|
|
938
967
|
})
|
|
939
968
|
.strict();
|
|
@@ -980,6 +1009,17 @@ const tocConfigSchema = z
|
|
|
980
1009
|
};
|
|
981
1010
|
});
|
|
982
1011
|
|
|
1012
|
+
/**
|
|
1013
|
+
* Which icon library bare `icon` names resolve against (mirrors Mintlify's
|
|
1014
|
+
* `icons.library`). Names can always opt into a specific set with an explicit
|
|
1015
|
+
* `prefix:name` (`lucide:rocket`, `fa6-brands:github`) regardless of this.
|
|
1016
|
+
*/
|
|
1017
|
+
const iconsConfigSchema = z
|
|
1018
|
+
.object({
|
|
1019
|
+
library: z.enum(["lucide", "fontawesome", "tabler"]).default("lucide"),
|
|
1020
|
+
})
|
|
1021
|
+
.strict();
|
|
1022
|
+
|
|
983
1023
|
export const blumeConfigSchema = z
|
|
984
1024
|
.object({
|
|
985
1025
|
ai: aiConfigSchema.default({}),
|
|
@@ -1008,6 +1048,7 @@ export const blumeConfigSchema = z
|
|
|
1008
1048
|
feedback: z.boolean().default(true),
|
|
1009
1049
|
github: githubConfigSchema.optional(),
|
|
1010
1050
|
i18n: i18nConfigSchema.optional(),
|
|
1051
|
+
icons: iconsConfigSchema.default({}),
|
|
1011
1052
|
lastModified: lastModifiedConfigSchema.default(false),
|
|
1012
1053
|
logo: logoConfigSchema.optional(),
|
|
1013
1054
|
markdown: markdownConfigSchema.default({}),
|
|
@@ -102,7 +102,7 @@ export const mintlifySource = (
|
|
|
102
102
|
? [
|
|
103
103
|
{
|
|
104
104
|
code: "BLUME_MINTLIFY_UNSUPPORTED",
|
|
105
|
-
message: `Mintlify components without a Blume equivalent were left as-is: ${[...unsupported].toSorted().join(", ")}.
|
|
105
|
+
message: `Mintlify components without a Blume equivalent were left as-is: ${[...unsupported].toSorted().join(", ")}. Replace them by hand or provide a matching component.`,
|
|
106
106
|
severity: "warning",
|
|
107
107
|
},
|
|
108
108
|
]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { join } from "pathe";
|
|
2
2
|
|
|
3
|
+
import { blumeReferences } from "../../openapi/references.ts";
|
|
4
|
+
import { openApiSource } from "../../openapi/source.ts";
|
|
3
5
|
import type { ContentSourceConfig, ResolvedConfig } from "../schema.ts";
|
|
4
6
|
import type { ProjectContext } from "../types.ts";
|
|
5
7
|
import { filesystemSource } from "./filesystem.ts";
|
|
@@ -144,12 +146,8 @@ const baseName = (def: ContentSourceConfig): string => {
|
|
|
144
146
|
return def.prefix ?? def.type;
|
|
145
147
|
};
|
|
146
148
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
* `content.sources` configured, the top-level `root`/`include`/`exclude` desugar
|
|
150
|
-
* to a single implicit filesystem source, so existing projects are untouched.
|
|
151
|
-
*/
|
|
152
|
-
export const resolveSources = (
|
|
149
|
+
/** The content sources declared by config (implicit filesystem when none). */
|
|
150
|
+
const contentSources = (
|
|
153
151
|
config: ResolvedConfig,
|
|
154
152
|
context: ProjectContext,
|
|
155
153
|
runtime: SourceRuntime
|
|
@@ -172,3 +170,27 @@ export const resolveSources = (
|
|
|
172
170
|
buildSource(def, nameFor(baseName(def)), context, runtime)
|
|
173
171
|
);
|
|
174
172
|
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build the ordered list of content sources for a project. With no
|
|
176
|
+
* `content.sources` configured, the top-level `root`/`include`/`exclude` desugar
|
|
177
|
+
* to a single implicit filesystem source, so existing projects are untouched.
|
|
178
|
+
* A Blume-rendered OpenAPI reference contributes an internal staged source that
|
|
179
|
+
* lowers each operation into a real content page (routing/nav/search/OG).
|
|
180
|
+
*/
|
|
181
|
+
export const resolveSources = (
|
|
182
|
+
config: ResolvedConfig,
|
|
183
|
+
context: ProjectContext,
|
|
184
|
+
runtime: SourceRuntime
|
|
185
|
+
): ContentSource[] => {
|
|
186
|
+
const sources = contentSources(config, context, runtime);
|
|
187
|
+
|
|
188
|
+
const references = blumeReferences(config);
|
|
189
|
+
if (references.length > 0) {
|
|
190
|
+
sources.push(
|
|
191
|
+
openApiSource(references, sourceContext(context, "openapi", runtime))
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return sources;
|
|
196
|
+
};
|
package/src/core/types.ts
CHANGED
|
@@ -54,6 +54,13 @@ export interface ProjectContext {
|
|
|
54
54
|
pagesRoot: string | null;
|
|
55
55
|
/** Absolute path to the generated runtime (`<root>/.blume`). */
|
|
56
56
|
outDir: string;
|
|
57
|
+
/**
|
|
58
|
+
* Absolute path to the Astro build output. `<root>/dist` normally; for a
|
|
59
|
+
* relocated runtime (isolated verify build) it lives under the runtime dir so
|
|
60
|
+
* it never empties the real `dist/`. Optional so hand-built test contexts and
|
|
61
|
+
* older callers still typecheck; `resolveProjectContext` always sets it.
|
|
62
|
+
*/
|
|
63
|
+
distDir?: string;
|
|
57
64
|
/** Absolute path to the user `theme.css`, if present. */
|
|
58
65
|
themeFile: string | null;
|
|
59
66
|
/** Absolute path to the user `components.ts`/`.tsx`, if present. */
|