blume 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/cli/index.js +1631 -940
  2. package/dist/cli/index.js.map +62 -50
  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 +442 -292
  6. package/dist/types/core/types.d.ts +7 -0
  7. package/dist/types/migrate/mintlify/assets.d.ts +8 -0
  8. package/docs/01-quickstart.mdx +5 -16
  9. package/docs/02-deployment.mdx +21 -54
  10. package/docs/advanced/api-reference.mdx +34 -51
  11. package/docs/advanced/blog.mdx +9 -25
  12. package/docs/advanced/bridge.mdx +74 -0
  13. package/docs/advanced/changelog.mdx +10 -33
  14. package/docs/advanced/custom-pages.mdx +21 -78
  15. package/docs/advanced/meta.ts +8 -1
  16. package/docs/advanced/migrate.mdx +119 -0
  17. package/docs/configuration/ai.mdx +42 -103
  18. package/docs/configuration/analytics.mdx +20 -38
  19. package/docs/configuration/customization.mdx +40 -73
  20. package/docs/configuration/export.mdx +9 -34
  21. package/docs/configuration/index.mdx +67 -87
  22. package/docs/configuration/search.mdx +17 -54
  23. package/docs/configuration/seo.mdx +17 -48
  24. package/docs/configuration/theming.mdx +20 -42
  25. package/docs/content/components.mdx +95 -101
  26. package/docs/content/i18n.mdx +21 -72
  27. package/docs/content/index.mdx +18 -48
  28. package/docs/content/islands.mdx +25 -52
  29. package/docs/content/meta.mdx +23 -50
  30. package/docs/content/navigation.mdx +23 -62
  31. package/docs/content/sources.mdx +20 -83
  32. package/docs/content/syntax.mdx +37 -105
  33. package/docs/index.mdx +12 -41
  34. package/docs/reference/cli.mdx +47 -30
  35. package/docs/reference/frontmatter.mdx +7 -5
  36. package/package.json +11 -1
  37. package/src/astro/generate.ts +18 -8
  38. package/src/astro/integration.ts +26 -3
  39. package/src/astro/islands.ts +6 -2
  40. package/src/astro/markdown-negotiation.ts +17 -3
  41. package/src/astro/pages.ts +6 -1
  42. package/src/astro/static-assets.ts +117 -0
  43. package/src/astro/templates.ts +76 -30
  44. package/src/cli/args.ts +23 -0
  45. package/src/cli/commands/build.ts +129 -62
  46. package/src/cli/commands/check.ts +20 -0
  47. package/src/cli/commands/dev.ts +11 -2
  48. package/src/cli/commands/doctor.ts +10 -1
  49. package/src/cli/commands/eject.ts +3 -1
  50. package/src/cli/commands/init.ts +21 -1
  51. package/src/cli/commands/preview.ts +2 -1
  52. package/src/cli/commands/validate.ts +12 -1
  53. package/src/cli/dev-lock.ts +92 -0
  54. package/src/cli/log.ts +11 -0
  55. package/src/cli/prepare.ts +3 -0
  56. package/src/components/BlumePage.astro +8 -0
  57. package/src/components/Icon.astro +13 -10
  58. package/src/components/content/ApiField.astro +75 -0
  59. package/src/components/content/ParamField.astro +39 -0
  60. package/src/components/content/RequestField.astro +23 -0
  61. package/src/components/content/ResponseField.astro +23 -0
  62. package/src/components/content/Step.astro +1 -1
  63. package/src/components/content/YouTube.astro +35 -0
  64. package/src/components/content/youtube.ts +46 -0
  65. package/src/components/islands/ask-ai.tsx +14 -14
  66. package/src/components/layout/Breadcrumbs.astro +7 -2
  67. package/src/components/layout/NavTree.astro +24 -8
  68. package/src/components/layout/RootLayout.astro +56 -34
  69. package/src/components/layout/Search.astro +1 -1
  70. package/src/components/openapi/ApiOverview.astro +84 -0
  71. package/src/components/openapi/MethodBadge.astro +28 -0
  72. package/src/components/openapi/Operation.astro +140 -0
  73. package/src/components/openapi/ParametersTable.astro +97 -0
  74. package/src/components/openapi/RequestBody.astro +58 -0
  75. package/src/components/openapi/RequestPanel.astro +169 -0
  76. package/src/components/openapi/Responses.astro +91 -0
  77. package/src/components/openapi/SchemaProperty.astro +118 -0
  78. package/src/components/openapi/SchemaTable.astro +86 -0
  79. package/src/components/openapi/helpers.ts +238 -0
  80. package/src/components/openapi/panel.ts +59 -0
  81. package/src/components/openapi/snippets.ts +201 -0
  82. package/src/components/props.ts +3 -0
  83. package/src/core/assets.ts +31 -0
  84. package/src/core/bridge.ts +10 -0
  85. package/src/core/builtin-tags.ts +6 -0
  86. package/src/core/data.ts +2 -0
  87. package/src/core/diagnostics.ts +6 -1
  88. package/src/core/gitignore.ts +30 -0
  89. package/src/core/links.ts +60 -19
  90. package/src/core/project-graph.ts +5 -1
  91. package/src/core/project.ts +25 -3
  92. package/src/core/schema.ts +54 -6
  93. package/src/core/sources/mdx-remote.ts +54 -8
  94. package/src/core/sources/mintlify.ts +1 -1
  95. package/src/core/sources/normalize.ts +6 -1
  96. package/src/core/sources/notion.ts +49 -5
  97. package/src/core/sources/resolve.ts +28 -6
  98. package/src/core/sources/sanity.ts +5 -1
  99. package/src/core/types.ts +7 -0
  100. package/src/deploy/rss.ts +1 -8
  101. package/src/deploy/sitemap.ts +20 -1
  102. package/src/deploy/xml.ts +8 -0
  103. package/src/markdown/directives.ts +15 -7
  104. package/src/markdown/package-commands.ts +26 -4
  105. package/src/migrate/fumadocs/content.ts +14 -1
  106. package/src/migrate/fumadocs/groups.ts +7 -0
  107. package/src/migrate/fumadocs/index.ts +5 -2
  108. package/src/migrate/mintlify/assets.ts +46 -0
  109. package/src/migrate/mintlify/config.ts +153 -1
  110. package/src/migrate/mintlify/content.ts +8 -2
  111. package/src/migrate/mintlify/index.ts +111 -46
  112. package/src/migrate/shared.ts +12 -27
  113. package/src/og/card.ts +14 -2
  114. package/src/openapi/model.ts +174 -0
  115. package/src/openapi/parse.ts +48 -0
  116. package/src/openapi/references.ts +164 -0
  117. package/src/openapi/render-mdx.ts +76 -0
  118. package/src/openapi/scalar.ts +15 -103
  119. package/src/openapi/source.ts +140 -0
  120. package/src/registry/eject.ts +28 -5
  121. package/src/registry/registry.ts +6 -0
  122. package/src/registry/rewrite-imports.ts +31 -19
  123. package/src/search/documents.ts +23 -5
  124. package/src/search/sync/algolia.ts +5 -1
  125. package/src/search/sync/typesense.ts +24 -16
  126. package/src/theme/chrome-icons.ts +22 -0
  127. package/src/theme/icons.ts +151 -161
  128. package/src/theme/palette.ts +26 -7
@@ -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
+ });
@@ -4,32 +4,19 @@ import { isAbsolute, join } from "pathe";
4
4
 
5
5
  import { scalarReferenceTemplate } from "../astro/templates.ts";
6
6
  import type { ResolvedConfig } from "../core/schema.ts";
7
- import type { NavTab } from "../core/types.ts";
8
7
  import { resolveAccent, resolveRadius } from "../theme/palette.ts";
8
+ import { resolveReferences } from "./references.ts";
9
+ import type { ReferenceSource } from "./references.ts";
9
10
 
10
11
  /**
11
- * API reference support, delegated wholesale to Scalar (`@scalar/astro`). Blume
12
- * resolves the configured spec sources into routes, generates one self-contained
13
- * Scalar page per source, and adds a nav tab. OpenAPI and AsyncAPI share this
14
- * exact path Scalar auto-detects the document type so the only difference
15
- * between the two config blocks is their default route and nav label.
12
+ * The Scalar renderer: an escape hatch (`openapi.renderer: "scalar"`) and the
13
+ * path AsyncAPI still uses. Each Scalar-rendered spec becomes one self-contained
14
+ * `@scalar/astro` page loaded client-side from Scalar's CDN. Blume's own OpenAPI
15
+ * renderer (the default) lives in `source.ts` / the `components/openapi` set and
16
+ * does not pass through here.
16
17
  */
17
18
 
18
- type ReferenceKind = "openapi" | "asyncapi";
19
-
20
- /** A spec source resolved to a concrete route and nav label. */
21
- export interface ReferenceSource {
22
- kind: ReferenceKind;
23
- /** Normalized route the reference mounts at, e.g. `/reference`. */
24
- route: string;
25
- label: string;
26
- /** Local path or `http(s)` URL, verbatim from config. */
27
- spec: string;
28
- /** Per-block Scalar theme name override, if any. */
29
- theme?: string;
30
- }
31
-
32
- /** A generated reference page, ready to write under `src/pages`. */
19
+ /** A generated Scalar reference page, ready to write under `src/pages`. */
33
20
  export interface ReferenceFile {
34
21
  /** Path relative to `src/pages`, e.g. `reference.astro`, `api/events.astro`. */
35
22
  pagePath: string;
@@ -37,21 +24,7 @@ export interface ReferenceFile {
37
24
  }
38
25
 
39
26
  const URL_SPEC = /^https?:\/\//u;
40
- const NON_SLUG = /[^a-z0-9]+/gu;
41
- const SLUG_EDGES = /^-+|-+$/gu;
42
27
  const ROUTE_EDGES = /^\/+|\/+$/gu;
43
- const TRAILING_SLASH = /\/+$/u;
44
-
45
- const slugify = (text: string): string =>
46
- text.toLowerCase().replace(NON_SLUG, "-").replace(SLUG_EDGES, "");
47
-
48
- /** Normalize a configured route to a single leading slash, no trailing slash. */
49
- const normalizeRoute = (route: string): string => {
50
- const trimmed = route.trim();
51
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
52
- const noTrailing = withSlash.replace(TRAILING_SLASH, "");
53
- return noTrailing === "" ? "/" : noTrailing;
54
- };
55
28
 
56
29
  /** The `src/pages`-relative file path for a reference route. */
57
30
  const referencePagePath = (route: string): string => {
@@ -59,71 +32,6 @@ const referencePagePath = (route: string): string => {
59
32
  return `${segments === "" ? "index" : segments}.astro`;
60
33
  };
61
34
 
62
- /** A spec is a single source (`spec` shorthand prepended to any `sources`). */
63
- type Block = ResolvedConfig["openapi"] | ResolvedConfig["asyncapi"];
64
-
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
- ): ReferenceSource[] => {
80
- if (!block.enabled) {
81
- return [];
82
- }
83
- const sources = sourcesOf(block);
84
- const base = normalizeRoute(block.route);
85
-
86
- return sources.map((source, index) => {
87
- const label =
88
- source.label ??
89
- (sources.length > 1 ? `${defaultLabel} ${index + 1}` : defaultLabel);
90
-
91
- let route: string;
92
- if (source.route) {
93
- route = normalizeRoute(source.route);
94
- } else if (sources.length === 1) {
95
- route = base;
96
- } else {
97
- const suffix = source.label ? slugify(source.label) : "";
98
- route = normalizeRoute(`${base}/${suffix || index + 1}`);
99
- }
100
-
101
- return { kind, label, route, spec: source.spec, theme: block.theme };
102
- });
103
- };
104
-
105
- /**
106
- * Resolve every enabled reference source into its route and label. Pure (no file
107
- * IO), so the nav and the page generator stay in sync from one source of truth.
108
- */
109
- export const resolveReferences = (
110
- config: ResolvedConfig
111
- ): ReferenceSource[] => [
112
- ...referencesFor("openapi", config.openapi, "API Reference"),
113
- ...referencesFor("asyncapi", config.asyncapi, "Events"),
114
- ];
115
-
116
- /** Nav tabs (header links) for the configured references. */
117
- export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
118
- resolveReferences(config).map((ref) => ({
119
- label: ref.label,
120
- path: ref.route,
121
- }));
122
-
123
- /** Whether any reference block is enabled (gates dependency + page wiring). */
124
- export const hasReferences = (config: ResolvedConfig): boolean =>
125
- config.openapi.enabled || config.asyncapi.enabled;
126
-
127
35
  const darkModeConfig = (
128
36
  mode: ResolvedConfig["theme"]["mode"]
129
37
  ): Record<string, boolean> => {
@@ -180,9 +88,10 @@ const specConfiguration = async (
180
88
  };
181
89
 
182
90
  /**
183
- * Build the Scalar reference page(s) for the project. Reads local specs, maps
184
- * the theme, and skips routes that collide with a content page or another
185
- * source. Returns the files to write under `src/pages` plus any warnings.
91
+ * Build the Scalar reference page(s) for the project. Only Scalar-rendered
92
+ * references are emitted here (Blume-rendered OpenAPI is staged content). Reads
93
+ * local specs, maps the theme, and skips routes that collide with a content page
94
+ * or another source. Returns the files to write under `src/pages` plus warnings.
186
95
  */
187
96
  export const buildReferenceFiles = async (options: {
188
97
  config: ResolvedConfig;
@@ -196,6 +105,9 @@ export const buildReferenceFiles = async (options: {
196
105
  const seen = new Set<string>();
197
106
  const accepted: ReferenceSource[] = [];
198
107
  for (const ref of resolveReferences(config)) {
108
+ if (ref.renderer !== "scalar") {
109
+ continue;
110
+ }
199
111
  if (seen.has(ref.route)) {
200
112
  warnings.push(
201
113
  `Two API reference sources resolve to ${ref.route}; keeping the first.`