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
|
@@ -6,7 +6,7 @@ import { glob } from "tinyglobby";
|
|
|
6
6
|
|
|
7
7
|
import type { BlumeConfig } from "../../core/schema.ts";
|
|
8
8
|
import { assetSegments } from "./assets.ts";
|
|
9
|
-
import { loadMintlifyConfig } from "./config.ts";
|
|
9
|
+
import { loadMintlifyConfig, partitionMintlifyRedirects } from "./config.ts";
|
|
10
10
|
import { mintlifyI18n } from "./i18n.ts";
|
|
11
11
|
import { transformMintlifyContent } from "./transform.ts";
|
|
12
12
|
|
|
@@ -15,6 +15,72 @@ export interface MintlifyMigrationResult {
|
|
|
15
15
|
warnings: string[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
19
|
+
value && typeof value === "object" && !Array.isArray(value)
|
|
20
|
+
? (value as Record<string, unknown>)
|
|
21
|
+
: undefined;
|
|
22
|
+
|
|
23
|
+
const hasFontFamily = (value: unknown): boolean => {
|
|
24
|
+
const object = asRecord(value);
|
|
25
|
+
if (!object) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const named = (child: unknown): boolean =>
|
|
29
|
+
typeof asRecord(child)?.family === "string";
|
|
30
|
+
return (
|
|
31
|
+
typeof object.family === "string" ||
|
|
32
|
+
named(object.heading) ||
|
|
33
|
+
named(object.body)
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Warn about Mintlify site chrome that Blume's config doesn't model, so it isn't
|
|
39
|
+
* dropped silently: header links (`navbar.links`/`navbar.primary`), footer
|
|
40
|
+
* socials (`footer.socials`), and fonts outside Blume's curated Google set. The
|
|
41
|
+
* contextual page menu and last-updated timestamp are covered by Blume defaults
|
|
42
|
+
* (page actions, git-derived dates), so they need no warning.
|
|
43
|
+
*/
|
|
44
|
+
const droppedChromeWarnings = (
|
|
45
|
+
spec: Record<string, unknown>,
|
|
46
|
+
config: BlumeConfig
|
|
47
|
+
): string[] => {
|
|
48
|
+
const warnings: string[] = [];
|
|
49
|
+
const navbar = asRecord(spec.navbar);
|
|
50
|
+
if (navbar && (navbar.links || navbar.primary)) {
|
|
51
|
+
warnings.push(
|
|
52
|
+
"Header links (navbar.links/navbar.primary) have no blume.config equivalent and were dropped; re-add them with navigation.tabs or a Header layout override."
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (asRecord(spec.footer)?.socials) {
|
|
56
|
+
warnings.push(
|
|
57
|
+
"Footer social links (footer.socials) have no blume.config equivalent and were dropped; add them with a Footer layout override."
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (hasFontFamily(spec.fonts ?? spec.font) && !config.theme?.fonts) {
|
|
61
|
+
warnings.push(
|
|
62
|
+
"docs.json font family isn't in Blume's curated Google Fonts set; set theme.fonts to a supported slug or add @font-face rules in theme.css."
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return warnings;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Warn about dynamic (wildcard/param) redirects the migrator dropped. Blume
|
|
70
|
+
* redirects are static path-to-path, so a `:slug*`/`:id` source becomes an
|
|
71
|
+
* unroutable Astro destination — kept ones crash the build. Point the user at
|
|
72
|
+
* host-level rules that do support wildcards.
|
|
73
|
+
*/
|
|
74
|
+
const droppedRedirectWarnings = (spec: Record<string, unknown>): string[] => {
|
|
75
|
+
const { dropped } = partitionMintlifyRedirects(spec);
|
|
76
|
+
if (dropped.length === 0) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
return [
|
|
80
|
+
`Dropped ${dropped.length} dynamic redirect(s) Blume can't model as static path-to-path (${dropped.join(", ")}); re-add them as host-level rules (e.g. _redirects or vercel.json).`,
|
|
81
|
+
];
|
|
82
|
+
};
|
|
83
|
+
|
|
18
84
|
/** Recursively drop `undefined`, empty arrays, and empty objects. */
|
|
19
85
|
const prune = (value: unknown): unknown => {
|
|
20
86
|
if (Array.isArray(value)) {
|
|
@@ -190,6 +256,14 @@ export const migrateMintlifyProject = async (
|
|
|
190
256
|
`Mapped ${i18n.locales.length} languages to i18n.locales (default: ${i18n.defaultLocale}); review the locale labels.`
|
|
191
257
|
);
|
|
192
258
|
}
|
|
259
|
+
const openapiSources = config.openapi?.sources ?? [];
|
|
260
|
+
if (openapiSources.length > 0) {
|
|
261
|
+
warnings.push(
|
|
262
|
+
`Mapped ${openapiSources.length} OpenAPI spec source(s) to openapi.sources (native reference renderer); verify each spec path or URL resolves.`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
warnings.push(...droppedChromeWarnings(spec, config));
|
|
266
|
+
warnings.push(...droppedRedirectWarnings(spec));
|
|
193
267
|
} else {
|
|
194
268
|
warnings.push("No docs.json or mint.json found; writing a default config.");
|
|
195
269
|
config = { content: { root: "." }, title: "Documentation" };
|
|
@@ -263,7 +337,7 @@ export const migrateMintlifyProject = async (
|
|
|
263
337
|
}
|
|
264
338
|
if (unsupported.size > 0) {
|
|
265
339
|
warnings.push(
|
|
266
|
-
`Components without a Blume equivalent need manual review
|
|
340
|
+
`Components without a Blume equivalent need manual review: ${[...unsupported].join(", ")}.`
|
|
267
341
|
);
|
|
268
342
|
}
|
|
269
343
|
warnings.push(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import matter from "../../core/frontmatter.ts";
|
|
2
2
|
import { stripUnknownPageMeta } from "../shared.ts";
|
|
3
3
|
import {
|
|
4
|
+
rewriteMintlifyAccordions,
|
|
4
5
|
rewriteMintlifyCallouts,
|
|
5
6
|
rewriteMintlifyExampleBlocks,
|
|
6
7
|
rewriteSnippetImports,
|
|
@@ -59,6 +60,7 @@ export const transformMintlifyContent = async (
|
|
|
59
60
|
text = snippetImports.source;
|
|
60
61
|
text = rewriteMintlifySvgIconProps(text);
|
|
61
62
|
text = rewriteMintlifyExampleBlocks(text);
|
|
63
|
+
text = rewriteMintlifyAccordions(text);
|
|
62
64
|
text = rewriteMintlifyCallouts(text);
|
|
63
65
|
|
|
64
66
|
const unsupported = unsupportedMintlifyComponents(text);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Document,
|
|
3
|
+
OperationObject,
|
|
4
|
+
PathItemObject,
|
|
5
|
+
} from "@scalar/openapi-types/3.1";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Blume's own OpenAPI model. Specs are parsed and upgraded to 3.1 (see
|
|
9
|
+
* `parse.ts`) with internal `$ref`s left intact — the document stays
|
|
10
|
+
* JSON-serializable (a fully dereferenced graph can be circular), and the schema
|
|
11
|
+
* components resolve refs against `document.components.schemas` at render time.
|
|
12
|
+
* Each operation is flattened into an {@link ApiOperationRef} with a real,
|
|
13
|
+
* per-operation route so it becomes a first-class Blume page.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A normalized OpenAPI 3.1 document, internal `$ref`s intact. */
|
|
17
|
+
export type ApiDocument = Document;
|
|
18
|
+
|
|
19
|
+
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
20
|
+
const SLUG_EDGES = /^-+|-+$/gu;
|
|
21
|
+
|
|
22
|
+
/** Lowercase, URL-safe slug: `Add a Pet!` -> `add-a-pet`. */
|
|
23
|
+
export const slugify = (text: string): string =>
|
|
24
|
+
text.toLowerCase().replace(NON_SLUG, "-").replace(SLUG_EDGES, "");
|
|
25
|
+
|
|
26
|
+
/** The HTTP methods an OpenAPI path item may declare, in spec order. */
|
|
27
|
+
export const HTTP_METHODS = [
|
|
28
|
+
"get",
|
|
29
|
+
"put",
|
|
30
|
+
"post",
|
|
31
|
+
"delete",
|
|
32
|
+
"options",
|
|
33
|
+
"head",
|
|
34
|
+
"patch",
|
|
35
|
+
"trace",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export type HttpMethod = (typeof HTTP_METHODS)[number];
|
|
39
|
+
|
|
40
|
+
/** Group used for operations that declare no tag. */
|
|
41
|
+
const UNTAGGED = "Operations";
|
|
42
|
+
|
|
43
|
+
/** A stable, URL-safe key for an operation: its `operationId`, else method+path. */
|
|
44
|
+
export const operationKey = (
|
|
45
|
+
method: string,
|
|
46
|
+
path: string,
|
|
47
|
+
operationId?: string
|
|
48
|
+
): string => {
|
|
49
|
+
const fromId = operationId ? slugify(operationId) : "";
|
|
50
|
+
return fromId || slugify(`${method}-${path}`);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** One operation, flattened out of the paths object and mapped to a route. */
|
|
54
|
+
export interface ApiOperationRef {
|
|
55
|
+
/** Stable key, unique within a spec; matches the MDX `<Operation id>`. */
|
|
56
|
+
key: string;
|
|
57
|
+
method: HttpMethod;
|
|
58
|
+
/** Templated path, e.g. `/pets/{id}`. */
|
|
59
|
+
path: string;
|
|
60
|
+
/** Full site route for this operation's page, e.g. `/reference/pet/add-pet`. */
|
|
61
|
+
route: string;
|
|
62
|
+
/** Display tag name (first tag, or `Operations` when untagged). */
|
|
63
|
+
tag: string;
|
|
64
|
+
tagSlug: string;
|
|
65
|
+
summary: string;
|
|
66
|
+
description: string;
|
|
67
|
+
operationId?: string;
|
|
68
|
+
deprecated: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A tag/section, in first-seen order. */
|
|
72
|
+
export interface ApiTagRef {
|
|
73
|
+
slug: string;
|
|
74
|
+
name: string;
|
|
75
|
+
description: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Everything the runtime needs for one spec, serialized into `blume:openapi`. */
|
|
79
|
+
export interface ApiSpecData {
|
|
80
|
+
/** Unique token used as the `<Operation source>` and the data-module key. */
|
|
81
|
+
slug: string;
|
|
82
|
+
/** Base route the spec's operations hang off, e.g. `/reference`. */
|
|
83
|
+
route: string;
|
|
84
|
+
label: string;
|
|
85
|
+
title: string;
|
|
86
|
+
version: string;
|
|
87
|
+
description: string;
|
|
88
|
+
document: ApiDocument;
|
|
89
|
+
/** Operations keyed by {@link ApiOperationRef.key}. */
|
|
90
|
+
operations: Record<string, ApiOperationRef>;
|
|
91
|
+
tags: ApiTagRef[];
|
|
92
|
+
/** Code-sample languages to render per operation. */
|
|
93
|
+
codeSamples: string[];
|
|
94
|
+
/** Whether nested schema rows start expanded. */
|
|
95
|
+
expandSchemas: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The generated `blume:openapi` module: specs keyed by {@link ApiSpecData.slug}. */
|
|
99
|
+
export type OpenApiData = Record<string, ApiSpecData>;
|
|
100
|
+
|
|
101
|
+
const isOperation = (value: unknown): value is OperationObject =>
|
|
102
|
+
typeof value === "object" && value !== null;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Flatten a 3.1 document into a route-mapped operation list and its ordered
|
|
106
|
+
* tags. Operations inherit the first tag they declare; keys are de-duplicated so
|
|
107
|
+
* a repeated `operationId` still yields distinct routes.
|
|
108
|
+
*/
|
|
109
|
+
export const extractOperations = (
|
|
110
|
+
document: ApiDocument,
|
|
111
|
+
baseRoute: string
|
|
112
|
+
): { operations: ApiOperationRef[]; tags: ApiTagRef[] } => {
|
|
113
|
+
const operations: ApiOperationRef[] = [];
|
|
114
|
+
const tagOrder: string[] = [];
|
|
115
|
+
const tagMeta = new Map(
|
|
116
|
+
(document.tags ?? []).map((tag) => [tag.name, tag.description ?? ""])
|
|
117
|
+
);
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
|
|
120
|
+
for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
|
|
121
|
+
const item = rawItem as PathItemObject | undefined;
|
|
122
|
+
if (!item || "$ref" in item) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const method of HTTP_METHODS) {
|
|
126
|
+
const operation = item[method];
|
|
127
|
+
if (!isOperation(operation)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const tag = operation.tags?.[0] ?? UNTAGGED;
|
|
131
|
+
const tagSlug = slugify(tag) || "operations";
|
|
132
|
+
if (!tagOrder.includes(tag)) {
|
|
133
|
+
tagOrder.push(tag);
|
|
134
|
+
}
|
|
135
|
+
let key = operationKey(method, path, operation.operationId);
|
|
136
|
+
while (seen.has(key)) {
|
|
137
|
+
key = `${key}-${method}`;
|
|
138
|
+
}
|
|
139
|
+
seen.add(key);
|
|
140
|
+
operations.push({
|
|
141
|
+
deprecated: operation.deprecated ?? false,
|
|
142
|
+
description: operation.description ?? "",
|
|
143
|
+
key,
|
|
144
|
+
method,
|
|
145
|
+
operationId: operation.operationId,
|
|
146
|
+
path,
|
|
147
|
+
route: `${baseRoute}/${tagSlug}/${key}`,
|
|
148
|
+
summary: operation.summary ?? "",
|
|
149
|
+
tag,
|
|
150
|
+
tagSlug,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const tags: ApiTagRef[] = tagOrder.map((name) => ({
|
|
156
|
+
description: tagMeta.get(name) ?? "",
|
|
157
|
+
name,
|
|
158
|
+
slug: slugify(name) || "operations",
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
return { operations, tags };
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Resolve the operation object for a ref out of its document. */
|
|
165
|
+
export const operationObject = (
|
|
166
|
+
spec: ApiSpecData,
|
|
167
|
+
ref: ApiOperationRef
|
|
168
|
+
): OperationObject | undefined => {
|
|
169
|
+
const item = (spec.document.paths?.[ref.path] ?? undefined) as
|
|
170
|
+
| PathItemObject
|
|
171
|
+
| undefined;
|
|
172
|
+
const operation = item?.[ref.method];
|
|
173
|
+
return isOperation(operation) ? operation : undefined;
|
|
174
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import { normalize, upgrade } from "@scalar/openapi-parser";
|
|
4
|
+
import { isAbsolute, join } from "pathe";
|
|
5
|
+
|
|
6
|
+
import type { ApiDocument } from "./model.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Spec loading and normalization. Blume reuses Scalar's parser
|
|
10
|
+
* (`@scalar/openapi-parser`) to read a spec (YAML or JSON), then upgrade Swagger
|
|
11
|
+
* 2.0 / OpenAPI 3.0 documents to 3.1 so the renderer only handles one shape.
|
|
12
|
+
* Internal `$ref`s are deliberately left in place (see `model.ts`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const URL_SPEC = /^https?:\/\//u;
|
|
16
|
+
|
|
17
|
+
export interface ParsedSpec {
|
|
18
|
+
document: ApiDocument;
|
|
19
|
+
warnings: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read a spec's raw text from an `http(s)` URL or a local (project-relative) path. */
|
|
23
|
+
const readSpecText = async (spec: string, root: string): Promise<string> => {
|
|
24
|
+
if (URL_SPEC.test(spec)) {
|
|
25
|
+
const response = await fetch(spec);
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(`${spec} -> ${response.status} ${response.statusText}`);
|
|
28
|
+
}
|
|
29
|
+
return await response.text();
|
|
30
|
+
}
|
|
31
|
+
const absolute = isAbsolute(spec) ? spec : join(root, spec);
|
|
32
|
+
return await readFile(absolute, "utf-8");
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read, normalize, and upgrade a spec to an OpenAPI 3.1 document. Throws when the
|
|
37
|
+
* spec can't be read; callers turn that into a source diagnostic rather than a
|
|
38
|
+
* hard failure so a broken spec doesn't take down the whole build.
|
|
39
|
+
*/
|
|
40
|
+
export const parseSpec = async (
|
|
41
|
+
spec: string,
|
|
42
|
+
root: string
|
|
43
|
+
): Promise<ParsedSpec> => {
|
|
44
|
+
const text = await readSpecText(spec, root);
|
|
45
|
+
const normalized = normalize(text);
|
|
46
|
+
const { specification } = upgrade(normalized);
|
|
47
|
+
return { document: specification as ApiDocument, warnings: [] };
|
|
48
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../core/schema.ts";
|
|
2
|
+
import type { NavTab } from "../core/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure resolution of the configured API reference blocks into concrete routes,
|
|
6
|
+
* labels, and a renderer choice — no file IO, so the content source, the nav
|
|
7
|
+
* tabs, the Scalar page generator, and the `blume:openapi` data module all share
|
|
8
|
+
* one source of truth. Kept free of any Astro/template imports so `core` can
|
|
9
|
+
* depend on it without a cycle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type ReferenceKind = "openapi" | "asyncapi";
|
|
13
|
+
|
|
14
|
+
/** Who renders a reference: Blume's own UI, or the embedded Scalar SPA. */
|
|
15
|
+
export type ReferenceRenderer = "blume" | "scalar";
|
|
16
|
+
|
|
17
|
+
/** Per-block display options for the Blume renderer. */
|
|
18
|
+
export interface ReferenceDisplay {
|
|
19
|
+
/** Code-sample languages shown per operation. */
|
|
20
|
+
codeSamples: string[];
|
|
21
|
+
/** Whether nested schema rows start expanded. */
|
|
22
|
+
expandSchemas: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A spec source resolved to a concrete route, label, and renderer. */
|
|
26
|
+
export interface ReferenceSource {
|
|
27
|
+
kind: ReferenceKind;
|
|
28
|
+
renderer: ReferenceRenderer;
|
|
29
|
+
/** Unique token derived from the route; the `<Operation source>` / data key. */
|
|
30
|
+
slug: string;
|
|
31
|
+
/** Normalized route the reference mounts at, e.g. `/reference`. */
|
|
32
|
+
route: string;
|
|
33
|
+
label: string;
|
|
34
|
+
/** Local path or `http(s)` URL, verbatim from config. */
|
|
35
|
+
spec: string;
|
|
36
|
+
/** Per-block Scalar theme name override, if any (Scalar renderer only). */
|
|
37
|
+
theme?: string;
|
|
38
|
+
/** Display options carried through to the Blume renderer. */
|
|
39
|
+
display: ReferenceDisplay;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
43
|
+
const SLUG_EDGES = /^-+|-+$/gu;
|
|
44
|
+
const ROUTE_EDGES = /^\/+|\/+$/gu;
|
|
45
|
+
const TRAILING_SLASH = /\/+$/u;
|
|
46
|
+
|
|
47
|
+
export const slugify = (text: string): string =>
|
|
48
|
+
text.toLowerCase().replace(NON_SLUG, "-").replace(SLUG_EDGES, "");
|
|
49
|
+
|
|
50
|
+
/** Normalize a configured route to a single leading slash, no trailing slash. */
|
|
51
|
+
export const normalizeRoute = (route: string): string => {
|
|
52
|
+
const trimmed = route.trim();
|
|
53
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
54
|
+
const noTrailing = withSlash.replace(TRAILING_SLASH, "");
|
|
55
|
+
return noTrailing === "" ? "/" : noTrailing;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A stable per-reference token from its route: `/api/events` -> `api-events`. */
|
|
59
|
+
const routeSlug = (route: string): string =>
|
|
60
|
+
slugify(route.replace(ROUTE_EDGES, "")) || "reference";
|
|
61
|
+
|
|
62
|
+
type Block = ResolvedConfig["openapi"] | ResolvedConfig["asyncapi"];
|
|
63
|
+
|
|
64
|
+
/** A spec is a single source (`spec` shorthand prepended to any `sources`). */
|
|
65
|
+
const sourcesOf = (
|
|
66
|
+
block: Block
|
|
67
|
+
): { label?: string; route?: string; spec: string }[] => {
|
|
68
|
+
const sources = [...block.sources];
|
|
69
|
+
if (block.spec) {
|
|
70
|
+
sources.unshift({ spec: block.spec });
|
|
71
|
+
}
|
|
72
|
+
return sources;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const referencesFor = (
|
|
76
|
+
kind: ReferenceKind,
|
|
77
|
+
block: Block,
|
|
78
|
+
defaultLabel: string,
|
|
79
|
+
renderer: ReferenceRenderer,
|
|
80
|
+
display: ReferenceDisplay
|
|
81
|
+
): ReferenceSource[] => {
|
|
82
|
+
if (!block.enabled) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const sources = sourcesOf(block);
|
|
86
|
+
const base = normalizeRoute(block.route);
|
|
87
|
+
|
|
88
|
+
return sources.map((source, index) => {
|
|
89
|
+
const label =
|
|
90
|
+
source.label ??
|
|
91
|
+
(sources.length > 1 ? `${defaultLabel} ${index + 1}` : defaultLabel);
|
|
92
|
+
|
|
93
|
+
let route: string;
|
|
94
|
+
if (source.route) {
|
|
95
|
+
route = normalizeRoute(source.route);
|
|
96
|
+
} else if (sources.length === 1) {
|
|
97
|
+
route = base;
|
|
98
|
+
} else {
|
|
99
|
+
const suffix = source.label ? slugify(source.label) : "";
|
|
100
|
+
route = normalizeRoute(`${base}/${suffix || index + 1}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
display,
|
|
105
|
+
kind,
|
|
106
|
+
label,
|
|
107
|
+
renderer,
|
|
108
|
+
route,
|
|
109
|
+
slug: routeSlug(route),
|
|
110
|
+
spec: source.spec,
|
|
111
|
+
theme: block.theme,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const NO_DISPLAY: ReferenceDisplay = { codeSamples: [], expandSchemas: false };
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve every enabled reference. OpenAPI honors its `renderer` (Blume's own UI
|
|
120
|
+
* by default); AsyncAPI is always rendered by Scalar for now.
|
|
121
|
+
*/
|
|
122
|
+
export const resolveReferences = (
|
|
123
|
+
config: ResolvedConfig
|
|
124
|
+
): ReferenceSource[] => [
|
|
125
|
+
...referencesFor(
|
|
126
|
+
"openapi",
|
|
127
|
+
config.openapi,
|
|
128
|
+
"API Reference",
|
|
129
|
+
config.openapi.renderer,
|
|
130
|
+
{
|
|
131
|
+
codeSamples: config.openapi.codeSamples,
|
|
132
|
+
expandSchemas: config.openapi.expandSchemas,
|
|
133
|
+
}
|
|
134
|
+
),
|
|
135
|
+
...referencesFor("asyncapi", config.asyncapi, "Events", "scalar", NO_DISPLAY),
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
/** Nav tabs (header links) for every reference, regardless of renderer. */
|
|
139
|
+
export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
|
|
140
|
+
resolveReferences(config).map((ref) => ({
|
|
141
|
+
label: ref.label,
|
|
142
|
+
path: ref.route,
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
/** Blume-rendered OpenAPI references, deduped by route (first wins). */
|
|
146
|
+
export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
|
|
147
|
+
const seen = new Set<string>();
|
|
148
|
+
const result: ReferenceSource[] = [];
|
|
149
|
+
for (const ref of resolveReferences(config)) {
|
|
150
|
+
if (ref.kind !== "openapi" || ref.renderer !== "blume") {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (seen.has(ref.route)) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
seen.add(ref.route);
|
|
157
|
+
result.push(ref);
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Whether any reference is Scalar-rendered (gates the `@scalar/astro` dep + pages). */
|
|
163
|
+
export const hasScalarReferences = (config: ResolvedConfig): boolean =>
|
|
164
|
+
resolveReferences(config).some((ref) => ref.renderer === "scalar");
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ApiOperationRef, ApiSpecData } from "./model.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lower a parsed spec into MDX for the staged content source. Each operation and
|
|
5
|
+
* the spec overview become a thin MDX page: the frontmatter carries the
|
|
6
|
+
* searchable `title` (so operations flow into Blume's search, OG, and llms.txt),
|
|
7
|
+
* the operation/overview **description is emitted as markdown in the body** so it
|
|
8
|
+
* renders parsed (links, formatting) and is indexed, and the structured UI is
|
|
9
|
+
* deferred to a Blume-owned component (`<Operation>` / `<ApiOverview>`). The
|
|
10
|
+
* catch-all renders the frontmatter title as the page `<h1>`, so the components
|
|
11
|
+
* omit their own top heading.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Neutralize the few characters MDX treats specially (`{` expressions, `<` JSX)
|
|
15
|
+
// so an arbitrary spec description can be embedded in the body verbatim without
|
|
16
|
+
// breaking compilation. They render as their literal selves.
|
|
17
|
+
const MDX_UNSAFE = /[<>{}]/gu;
|
|
18
|
+
const ENTITIES: Record<string, string> = {
|
|
19
|
+
"<": "<",
|
|
20
|
+
">": ">",
|
|
21
|
+
"{": "{",
|
|
22
|
+
"}": "}",
|
|
23
|
+
};
|
|
24
|
+
const mdxSafe = (text: string): string =>
|
|
25
|
+
text.replace(MDX_UNSAFE, (char) => ENTITIES[char] ?? char);
|
|
26
|
+
|
|
27
|
+
/** Frontmatter + body for one operation or overview page. */
|
|
28
|
+
export interface RenderedPage {
|
|
29
|
+
data: Record<string, unknown>;
|
|
30
|
+
body: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Prepend a markdown description (if any) above a component invocation. */
|
|
34
|
+
const withDescription = (description: string, component: string): string =>
|
|
35
|
+
description.trim()
|
|
36
|
+
? `${mdxSafe(description.trim())}\n\n${component}`
|
|
37
|
+
: component;
|
|
38
|
+
|
|
39
|
+
export const operationMdx = (
|
|
40
|
+
spec: ApiSpecData,
|
|
41
|
+
operation: ApiOperationRef
|
|
42
|
+
): RenderedPage => {
|
|
43
|
+
const method = operation.method.toUpperCase();
|
|
44
|
+
const title = operation.summary || `${method} ${operation.path}`;
|
|
45
|
+
// Skip the body description when it only repeats the summary (the `<h1>`) —
|
|
46
|
+
// common in specs that set summary and description to the same string.
|
|
47
|
+
const description =
|
|
48
|
+
operation.description.trim() === operation.summary.trim()
|
|
49
|
+
? ""
|
|
50
|
+
: operation.description;
|
|
51
|
+
return {
|
|
52
|
+
body: withDescription(
|
|
53
|
+
description,
|
|
54
|
+
`<Operation source="${spec.slug}" id="${operation.key}" />`
|
|
55
|
+
),
|
|
56
|
+
data: {
|
|
57
|
+
...(operation.deprecated ? { deprecated: true } : {}),
|
|
58
|
+
search: { tags: [operation.tag, method] },
|
|
59
|
+
sidebar: { badge: method, label: operation.summary || operation.path },
|
|
60
|
+
title,
|
|
61
|
+
// Signals the two-column API layout (request panel instead of the TOC).
|
|
62
|
+
type: "openapi-operation",
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const overviewMdx = (spec: ApiSpecData): RenderedPage => ({
|
|
68
|
+
body: withDescription(
|
|
69
|
+
spec.description,
|
|
70
|
+
`<ApiOverview source="${spec.slug}" />`
|
|
71
|
+
),
|
|
72
|
+
data: {
|
|
73
|
+
sidebar: { label: "Overview" },
|
|
74
|
+
title: spec.title || spec.label,
|
|
75
|
+
},
|
|
76
|
+
});
|