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
@@ -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 (use the OpenAPI reference instead): ${[...unsupported].join(", ")}.`
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
+ "<": "&lt;",
20
+ ">": "&gt;",
21
+ "{": "&#123;",
22
+ "}": "&#125;",
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
+ });