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.
Files changed (81) hide show
  1. package/dist/cli/index.js +1170 -820
  2. package/dist/cli/index.js.map +32 -27
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +154 -41
  6. package/dist/types/core/types.d.ts +7 -6
  7. package/dist/types/migrate/mintlify/config.d.ts +14 -0
  8. package/docs/01-quickstart.mdx +6 -2
  9. package/docs/02-deployment.mdx +3 -1
  10. package/docs/advanced/api-reference.mdx +37 -23
  11. package/docs/advanced/bridge.mdx +76 -0
  12. package/docs/advanced/custom-pages.mdx +3 -1
  13. package/docs/advanced/meta.ts +8 -1
  14. package/docs/advanced/migrate.mdx +123 -0
  15. package/docs/configuration/ai.mdx +3 -1
  16. package/docs/configuration/analytics.mdx +3 -1
  17. package/docs/configuration/export.mdx +6 -2
  18. package/docs/configuration/index.mdx +1 -1
  19. package/docs/configuration/seo.mdx +3 -1
  20. package/docs/content/components.mdx +55 -2
  21. package/docs/content/i18n.mdx +6 -2
  22. package/docs/content/islands.mdx +6 -2
  23. package/docs/content/meta.mdx +3 -1
  24. package/docs/content/syntax.mdx +40 -14
  25. package/docs/index.mdx +2 -2
  26. package/docs/reference/cli.mdx +29 -1
  27. package/docs/reference/frontmatter.mdx +5 -0
  28. package/package.json +11 -1
  29. package/src/astro/generate.ts +18 -9
  30. package/src/astro/templates.ts +28 -4
  31. package/src/cli/commands/build.ts +107 -63
  32. package/src/cli/commands/check.ts +20 -0
  33. package/src/cli/dev-lock.ts +13 -5
  34. package/src/cli/prepare.ts +3 -0
  35. package/src/components/BlumePage.astro +6 -0
  36. package/src/components/Icon.astro +13 -10
  37. package/src/components/content/ApiField.astro +75 -0
  38. package/src/components/content/ParamField.astro +39 -0
  39. package/src/components/content/RequestField.astro +23 -0
  40. package/src/components/content/ResponseField.astro +23 -0
  41. package/src/components/content/Step.astro +1 -1
  42. package/src/components/layout/Breadcrumbs.astro +7 -2
  43. package/src/components/layout/NavTree.astro +24 -8
  44. package/src/components/layout/RootLayout.astro +56 -34
  45. package/src/components/layout/Search.astro +1 -1
  46. package/src/components/openapi/ApiOverview.astro +84 -0
  47. package/src/components/openapi/MethodBadge.astro +28 -0
  48. package/src/components/openapi/Operation.astro +140 -0
  49. package/src/components/openapi/ParametersTable.astro +97 -0
  50. package/src/components/openapi/RequestBody.astro +58 -0
  51. package/src/components/openapi/RequestPanel.astro +169 -0
  52. package/src/components/openapi/Responses.astro +91 -0
  53. package/src/components/openapi/SchemaProperty.astro +118 -0
  54. package/src/components/openapi/SchemaTable.astro +86 -0
  55. package/src/components/openapi/helpers.ts +238 -0
  56. package/src/components/openapi/panel.ts +59 -0
  57. package/src/components/openapi/snippets.ts +201 -0
  58. package/src/core/builtin-tags.ts +5 -0
  59. package/src/core/data.ts +2 -0
  60. package/src/core/graph.ts +0 -3
  61. package/src/core/nav-diagnostics.ts +2 -12
  62. package/src/core/navigation.ts +0 -10
  63. package/src/core/project-graph.ts +5 -1
  64. package/src/core/project.ts +25 -3
  65. package/src/core/schema.ts +47 -14
  66. package/src/core/sources/mintlify.ts +1 -1
  67. package/src/core/sources/resolve.ts +28 -6
  68. package/src/core/types.ts +7 -7
  69. package/src/migrate/mintlify/config.ts +190 -97
  70. package/src/migrate/mintlify/content.ts +24 -2
  71. package/src/migrate/mintlify/index.ts +76 -2
  72. package/src/migrate/mintlify/transform.ts +2 -0
  73. package/src/openapi/model.ts +174 -0
  74. package/src/openapi/parse.ts +48 -0
  75. package/src/openapi/references.ts +164 -0
  76. package/src/openapi/render-mdx.ts +76 -0
  77. package/src/openapi/scalar.ts +15 -103
  78. package/src/openapi/source.ts +140 -0
  79. package/src/registry/eject.ts +15 -2
  80. package/src/theme/chrome-icons.ts +22 -0
  81. 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
+ };
@@ -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) {
@@ -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.
@@ -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
- outDir: join(absoluteRoot, ".blume"),
72
+ distDir,
73
+ outDir,
52
74
  pagesRoot,
53
75
  root: absoluteRoot,
54
76
  themeFile: firstExisting(absoluteRoot, THEME_FILENAMES),