blume 0.4.0 → 0.5.1
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 +1170 -820
- package/dist/cli/index.js.map +32 -27
- 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 -41
- package/dist/types/core/types.d.ts +7 -6
- package/dist/types/migrate/mintlify/config.d.ts +14 -0
- package/docs/01-quickstart.mdx +6 -2
- package/docs/02-deployment.mdx +3 -1
- package/docs/advanced/api-reference.mdx +37 -23
- package/docs/advanced/bridge.mdx +76 -0
- package/docs/advanced/custom-pages.mdx +3 -1
- package/docs/advanced/meta.ts +8 -1
- package/docs/advanced/migrate.mdx +123 -0
- package/docs/configuration/ai.mdx +3 -1
- package/docs/configuration/analytics.mdx +3 -1
- package/docs/configuration/export.mdx +6 -2
- package/docs/configuration/index.mdx +1 -1
- package/docs/configuration/seo.mdx +3 -1
- package/docs/content/components.mdx +55 -2
- package/docs/content/i18n.mdx +6 -2
- package/docs/content/islands.mdx +6 -2
- package/docs/content/meta.mdx +3 -1
- package/docs/content/syntax.mdx +40 -14
- 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 -9
- 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/graph.ts +0 -3
- package/src/core/nav-diagnostics.ts +2 -12
- package/src/core/navigation.ts +0 -10
- package/src/core/project-graph.ts +5 -1
- package/src/core/project.ts +25 -3
- package/src/core/schema.ts +47 -14
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/resolve.ts +28 -6
- package/src/core/types.ts +7 -7
- package/src/migrate/mintlify/config.ts +190 -97
- package/src/migrate/mintlify/content.ts +24 -2
- package/src/migrate/mintlify/index.ts +76 -2
- package/src/migrate/mintlify/transform.ts +2 -0
- 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,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime helpers for the OpenAPI components. These operate on the parsed spec
|
|
3
|
+
* behind the `blume:openapi` alias — resolving `$ref`s (kept intact at parse
|
|
4
|
+
* time to avoid circular graphs), labelling types, and generating request
|
|
5
|
+
* examples and code samples. Pure and dependency-free so they run in the browser
|
|
6
|
+
* build with no server-only imports.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A permissive view of an OpenAPI 3.1 schema — only the fields we render. */
|
|
10
|
+
export interface SchemaLike {
|
|
11
|
+
$ref?: string;
|
|
12
|
+
type?: string | string[];
|
|
13
|
+
format?: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
properties?: Record<string, SchemaLike>;
|
|
17
|
+
required?: string[];
|
|
18
|
+
items?: SchemaLike;
|
|
19
|
+
enum?: unknown[];
|
|
20
|
+
const?: unknown;
|
|
21
|
+
default?: unknown;
|
|
22
|
+
example?: unknown;
|
|
23
|
+
examples?: unknown[];
|
|
24
|
+
allOf?: SchemaLike[];
|
|
25
|
+
oneOf?: SchemaLike[];
|
|
26
|
+
anyOf?: SchemaLike[];
|
|
27
|
+
additionalProperties?: boolean | SchemaLike;
|
|
28
|
+
nullable?: boolean;
|
|
29
|
+
deprecated?: boolean;
|
|
30
|
+
readOnly?: boolean;
|
|
31
|
+
writeOnly?: boolean;
|
|
32
|
+
minimum?: number;
|
|
33
|
+
maximum?: number;
|
|
34
|
+
minLength?: number;
|
|
35
|
+
maxLength?: number;
|
|
36
|
+
minItems?: number;
|
|
37
|
+
maxItems?: number;
|
|
38
|
+
pattern?: string;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REF_PATTERN = /#\/components\/schemas\/(?<name>[^/]+)$/u;
|
|
43
|
+
|
|
44
|
+
/** The display name of a `$ref`, e.g. `#/components/schemas/Pet` -> `Pet`. */
|
|
45
|
+
export const refName = (ref: string): string =>
|
|
46
|
+
REF_PATTERN.exec(ref)?.groups?.name ?? ref.split("/").at(-1) ?? ref;
|
|
47
|
+
|
|
48
|
+
/** Resolve one level of `$ref` against the document's component schemas. */
|
|
49
|
+
export const resolveSchema = (
|
|
50
|
+
schemas: Record<string, SchemaLike>,
|
|
51
|
+
schema?: SchemaLike
|
|
52
|
+
): SchemaLike => {
|
|
53
|
+
if (!schema) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
if (typeof schema.$ref === "string") {
|
|
57
|
+
const name = REF_PATTERN.exec(schema.$ref)?.groups?.name;
|
|
58
|
+
if (name && schemas[name]) {
|
|
59
|
+
return schemas[name];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return schema;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const nonNullTypes = (type: string | string[] | undefined): string[] => {
|
|
66
|
+
if (!type) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
return (Array.isArray(type) ? type : [type]).filter((t) => t !== "null");
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** A short, human-readable type label for a schema row. */
|
|
73
|
+
export const typeLabel = (
|
|
74
|
+
schema: SchemaLike,
|
|
75
|
+
schemas: Record<string, SchemaLike>
|
|
76
|
+
): string => {
|
|
77
|
+
if (typeof schema.$ref === "string") {
|
|
78
|
+
return refName(schema.$ref);
|
|
79
|
+
}
|
|
80
|
+
if (schema.oneOf || schema.anyOf) {
|
|
81
|
+
const branches = schema.oneOf ?? schema.anyOf ?? [];
|
|
82
|
+
const labels = branches.map((branch) => typeLabel(branch, schemas));
|
|
83
|
+
return [...new Set(labels)].join(" | ") || "any";
|
|
84
|
+
}
|
|
85
|
+
if (schema.allOf) {
|
|
86
|
+
return "object";
|
|
87
|
+
}
|
|
88
|
+
const types = nonNullTypes(schema.type);
|
|
89
|
+
if (types.includes("array")) {
|
|
90
|
+
const item = resolveSchema(schemas, schema.items);
|
|
91
|
+
return `${typeLabel(item, schemas)}[]`;
|
|
92
|
+
}
|
|
93
|
+
const base = types[0] ?? (schema.properties ? "object" : "any");
|
|
94
|
+
return schema.format ? `${base}<${schema.format}>` : base;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Whether this schema is nullable (3.0 `nullable` or a 3.1 `"null"` in `type`). */
|
|
98
|
+
export const isNullable = (schema: SchemaLike): boolean =>
|
|
99
|
+
schema.nullable === true ||
|
|
100
|
+
(Array.isArray(schema.type) && schema.type.includes("null"));
|
|
101
|
+
|
|
102
|
+
/** Human-readable validation constraints for a schema, in display order. */
|
|
103
|
+
export const constraints = (schema: SchemaLike): string[] => {
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
const numeric: [keyof SchemaLike, string][] = [
|
|
106
|
+
["minimum", "min"],
|
|
107
|
+
["maximum", "max"],
|
|
108
|
+
["minLength", "min length"],
|
|
109
|
+
["maxLength", "max length"],
|
|
110
|
+
["minItems", "min items"],
|
|
111
|
+
["maxItems", "max items"],
|
|
112
|
+
];
|
|
113
|
+
for (const [key, label] of numeric) {
|
|
114
|
+
const value = schema[key];
|
|
115
|
+
if (typeof value === "number") {
|
|
116
|
+
out.push(`${label} ${value}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (typeof schema.pattern === "string") {
|
|
120
|
+
out.push(`matches ${schema.pattern}`);
|
|
121
|
+
}
|
|
122
|
+
if (schema.default !== undefined) {
|
|
123
|
+
out.push(`default: ${JSON.stringify(schema.default)}`);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The object properties a schema exposes, merging `allOf` branches so an
|
|
130
|
+
* `allOf`-composed model still lists every field. Returns the properties plus
|
|
131
|
+
* the merged required set.
|
|
132
|
+
*/
|
|
133
|
+
export const objectProperties = (
|
|
134
|
+
schema: SchemaLike,
|
|
135
|
+
schemas: Record<string, SchemaLike>
|
|
136
|
+
): { properties: [string, SchemaLike][]; required: Set<string> } => {
|
|
137
|
+
const properties = new Map<string, SchemaLike>();
|
|
138
|
+
const required = new Set<string>();
|
|
139
|
+
|
|
140
|
+
const collect = (node: SchemaLike): void => {
|
|
141
|
+
const resolved = resolveSchema(schemas, node);
|
|
142
|
+
for (const name of resolved.required ?? []) {
|
|
143
|
+
required.add(name);
|
|
144
|
+
}
|
|
145
|
+
for (const [name, prop] of Object.entries(resolved.properties ?? {})) {
|
|
146
|
+
properties.set(name, prop);
|
|
147
|
+
}
|
|
148
|
+
for (const branch of resolved.allOf ?? []) {
|
|
149
|
+
collect(branch);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
collect(schema);
|
|
154
|
+
return { properties: [...properties.entries()], required };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Sentinel: no explicit example is declared on a schema. */
|
|
158
|
+
const NO_VALUE = Symbol("no-value");
|
|
159
|
+
|
|
160
|
+
/** The declared example/default/enum for a schema, or {@link NO_VALUE}. */
|
|
161
|
+
const explicitExample = (schema: SchemaLike): unknown => {
|
|
162
|
+
if (schema.example !== undefined) {
|
|
163
|
+
return schema.example;
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(schema.examples) && schema.examples.length > 0) {
|
|
166
|
+
return schema.examples[0];
|
|
167
|
+
}
|
|
168
|
+
if (schema.default !== undefined) {
|
|
169
|
+
return schema.default;
|
|
170
|
+
}
|
|
171
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
172
|
+
return schema.enum[0];
|
|
173
|
+
}
|
|
174
|
+
return NO_VALUE;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** A placeholder value for a primitive (leaf) schema. */
|
|
178
|
+
const primitiveExample = (
|
|
179
|
+
types: string[],
|
|
180
|
+
format: string | undefined
|
|
181
|
+
): unknown => {
|
|
182
|
+
if (types.includes("number") || types.includes("integer")) {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
if (types.includes("boolean")) {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
if (format === "date-time") {
|
|
189
|
+
return "2024-01-01T00:00:00Z";
|
|
190
|
+
}
|
|
191
|
+
return format ? `<${format}>` : "string";
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build a representative example value for a schema (honoring `example` /
|
|
196
|
+
* `default` / `enum` first). A `seen` set of `$ref`s guards against the circular
|
|
197
|
+
* schemas that keeping refs intact allows.
|
|
198
|
+
*/
|
|
199
|
+
export const exampleValue = (
|
|
200
|
+
schema: SchemaLike | undefined,
|
|
201
|
+
schemas: Record<string, SchemaLike>,
|
|
202
|
+
seen = new Set<string>()
|
|
203
|
+
): unknown => {
|
|
204
|
+
if (!schema) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
if (typeof schema.$ref === "string") {
|
|
208
|
+
if (seen.has(schema.$ref)) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
seen.add(schema.$ref);
|
|
212
|
+
return exampleValue(resolveSchema(schemas, schema), schemas, seen);
|
|
213
|
+
}
|
|
214
|
+
const explicit = explicitExample(schema);
|
|
215
|
+
if (explicit !== NO_VALUE) {
|
|
216
|
+
return explicit;
|
|
217
|
+
}
|
|
218
|
+
const branch = schema.oneOf?.[0] ?? schema.anyOf?.[0];
|
|
219
|
+
if (branch) {
|
|
220
|
+
return exampleValue(branch, schemas, seen);
|
|
221
|
+
}
|
|
222
|
+
const types = nonNullTypes(schema.type);
|
|
223
|
+
if (types.includes("array")) {
|
|
224
|
+
return [exampleValue(schema.items, schemas, seen)];
|
|
225
|
+
}
|
|
226
|
+
if (types.includes("object") || schema.properties || schema.allOf) {
|
|
227
|
+
const out: Record<string, unknown> = {};
|
|
228
|
+
for (const [name, prop] of objectProperties(schema, schemas).properties) {
|
|
229
|
+
out[name] = exampleValue(prop, schemas, new Set(seen));
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
return primitiveExample(types, schema.format);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Pretty-print a JSON value for an example/code block. */
|
|
237
|
+
export const toJson = (value: unknown): string =>
|
|
238
|
+
JSON.stringify(value, null, 2);
|
|
@@ -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;
|
package/src/core/graph.ts
CHANGED
|
@@ -96,7 +96,6 @@ export const buildContentGraph = (
|
|
|
96
96
|
selectors: options.navigation.selectors,
|
|
97
97
|
sharedFolderMeta: options.sharedFolderMeta,
|
|
98
98
|
sidebar: options.navigation.sidebar,
|
|
99
|
-
sidebarVariants: options.navigation.sidebarVariants,
|
|
100
99
|
tabs,
|
|
101
100
|
});
|
|
102
101
|
}
|
|
@@ -104,7 +103,6 @@ export const buildContentGraph = (
|
|
|
104
103
|
chromeVariants: [],
|
|
105
104
|
selectors: [],
|
|
106
105
|
sidebar: [],
|
|
107
|
-
sidebarVariants: [],
|
|
108
106
|
tabs: [],
|
|
109
107
|
};
|
|
110
108
|
} else {
|
|
@@ -114,7 +112,6 @@ export const buildContentGraph = (
|
|
|
114
112
|
selectors: options.navigation.selectors,
|
|
115
113
|
sharedFolderMeta: options.sharedFolderMeta,
|
|
116
114
|
sidebar: options.navigation.sidebar,
|
|
117
|
-
sidebarVariants: options.navigation.sidebarVariants,
|
|
118
115
|
tabs: options.navigation.tabs,
|
|
119
116
|
});
|
|
120
117
|
}
|
|
@@ -42,10 +42,7 @@ const collectIcons = (
|
|
|
42
42
|
push(item.icon, `selector "${item.label}"`);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
const sidebars = [
|
|
46
|
-
navigation.sidebar,
|
|
47
|
-
...navigation.sidebarVariants.map((variant) => variant.sidebar),
|
|
48
|
-
];
|
|
45
|
+
const sidebars = [navigation.sidebar];
|
|
49
46
|
for (const sidebar of sidebars) {
|
|
50
47
|
for (const node of flattenNodes(sidebar)) {
|
|
51
48
|
push(node.icon, `"${node.label}"`);
|
|
@@ -140,10 +137,6 @@ const duplicateLabelDiagnostics = (navigation: Navigation): Diagnostic[] => {
|
|
|
140
137
|
};
|
|
141
138
|
const sidebars: { nodes: NavNode[]; where: string }[] = [
|
|
142
139
|
{ nodes: navigation.sidebar, where: "at the top level" },
|
|
143
|
-
...navigation.sidebarVariants.map((variant) => ({
|
|
144
|
-
nodes: variant.sidebar,
|
|
145
|
-
where: `in the "${variant.path}" section`,
|
|
146
|
-
})),
|
|
147
140
|
];
|
|
148
141
|
for (const { nodes, where } of sidebars) {
|
|
149
142
|
checkLevel(nodes, where);
|
|
@@ -162,10 +155,7 @@ const hiddenInSidebarDiagnostics = (
|
|
|
162
155
|
if (hidden.size === 0) {
|
|
163
156
|
return [];
|
|
164
157
|
}
|
|
165
|
-
const sidebars = [
|
|
166
|
-
navigation.sidebar,
|
|
167
|
-
...navigation.sidebarVariants.map((variant) => variant.sidebar),
|
|
168
|
-
];
|
|
158
|
+
const sidebars = [navigation.sidebar];
|
|
169
159
|
const diagnostics: Diagnostic[] = [];
|
|
170
160
|
const seen = new Set<string>();
|
|
171
161
|
for (const sidebar of sidebars) {
|
package/src/core/navigation.ts
CHANGED
|
@@ -8,7 +8,6 @@ import type {
|
|
|
8
8
|
import type {
|
|
9
9
|
NavChromeVariant,
|
|
10
10
|
NavNode,
|
|
11
|
-
NavSidebarVariant,
|
|
12
11
|
Navigation,
|
|
13
12
|
NavSelector,
|
|
14
13
|
NavTab,
|
|
@@ -348,7 +347,6 @@ export const buildNavigation = (
|
|
|
348
347
|
selectors?: NavSelector[];
|
|
349
348
|
tabs?: NavTab[];
|
|
350
349
|
sidebar?: SidebarItemConfig[];
|
|
351
|
-
sidebarVariants?: { path: string; items: SidebarItemConfig[] }[];
|
|
352
350
|
/** Locale dir prefix for folder-meta lookup (`""` for the default locale). */
|
|
353
351
|
metaPrefix?: string;
|
|
354
352
|
/**
|
|
@@ -372,19 +370,12 @@ export const buildNavigation = (
|
|
|
372
370
|
page,
|
|
373
371
|
])
|
|
374
372
|
);
|
|
375
|
-
const sidebarVariants: NavSidebarVariant[] = (
|
|
376
|
-
options.sidebarVariants ?? []
|
|
377
|
-
).map((variant) => ({
|
|
378
|
-
path: variant.path,
|
|
379
|
-
sidebar: buildConfigSidebar(variant.items, byRoute),
|
|
380
|
-
}));
|
|
381
373
|
|
|
382
374
|
if (options.sidebar) {
|
|
383
375
|
return {
|
|
384
376
|
chromeVariants,
|
|
385
377
|
selectors,
|
|
386
378
|
sidebar: buildConfigSidebar(options.sidebar, byRoute),
|
|
387
|
-
sidebarVariants,
|
|
388
379
|
tabs,
|
|
389
380
|
};
|
|
390
381
|
}
|
|
@@ -398,7 +389,6 @@ export const buildNavigation = (
|
|
|
398
389
|
sharedFolderMeta,
|
|
399
390
|
metaPrefix
|
|
400
391
|
),
|
|
401
|
-
sidebarVariants,
|
|
402
392
|
tabs,
|
|
403
393
|
};
|
|
404
394
|
};
|
|
@@ -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),
|