blume 1.4.3 → 1.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.
- package/CHANGELOG.md +17 -0
- package/dist/cli/index.js +1621 -576
- package/dist/cli/index.js.map +109 -104
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/config-input.d.ts +79 -27
- package/dist/types/core/config.d.ts +2 -1
- package/dist/types/core/data.d.ts +16 -1
- package/dist/types/core/diagnostics.d.ts +5 -1
- package/dist/types/core/i18n-ui.d.ts +12 -0
- package/dist/types/core/schema.d.ts +112 -15
- package/dist/types/core/sources/types.d.ts +3 -1
- package/dist/types/core/standard-schema.d.ts +7 -3
- package/dist/types/core/types.d.ts +43 -2
- package/dist/types/core/ui-packs/index.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +6 -5
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/search.mdx +2 -0
- package/docs/configuration/seo.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/meta.mdx +2 -1
- package/docs/content/meta.ts +1 -0
- package/docs/content/navigation.mdx +35 -1
- package/docs/content/versioning.mdx +106 -0
- package/docs/reference/cli.mdx +1 -0
- package/docs/reference/frontmatter.mdx +3 -0
- package/package.json +3 -1
- package/skills/blume-migrate/SKILL.md +2 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/fumadocs.md +1 -1
- package/skills/blume-migrate/references/mintlify.md +1 -1
- package/src/ai/agent-readability.ts +37 -10
- package/src/ai/ask-context.ts +5 -1
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +40 -16
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +28 -11
- package/src/ai/mcp/server.ts +183 -38
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +6 -2
- package/src/astro/generate.ts +54 -29
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +16 -9
- package/src/astro/templates.ts +152 -33
- package/src/audit/agent.ts +2 -2
- package/src/audit/checks/content.ts +26 -11
- package/src/audit/checks/dns-aid.ts +3 -0
- package/src/audit/checks/indexability.ts +24 -6
- package/src/audit/checks/llms.ts +9 -4
- package/src/audit/checks/network.ts +2 -0
- package/src/audit/checks/social.ts +18 -10
- package/src/audit/crawl.ts +37 -9
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +5 -2
- package/src/audit/snapshot.ts +2 -4
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +9 -4
- package/src/cli/commands/build.ts +15 -9
- package/src/cli/commands/dev.ts +2 -0
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eval.ts +7 -3
- package/src/cli/commands/init.ts +9 -9
- package/src/cli/commands/mcp-stdio.ts +3 -0
- package/src/cli/commands/translate.ts +14 -3
- package/src/cli/commands/version.ts +85 -0
- package/src/cli/dev-lock.ts +31 -10
- package/src/cli/eject-scripts.ts +17 -2
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +22 -15
- package/src/cli/internal-error.ts +1 -0
- package/src/components/content/auto-type-table.ts +3 -0
- package/src/components/content/diff.ts +9 -5
- package/src/components/content/github-info.ts +2 -0
- package/src/components/islands/ask-ai.tsx +33 -25
- package/src/components/islands/hooks.ts +5 -1
- package/src/components/islands/webmcp.ts +49 -12
- package/src/components/layout/Header.astro +25 -1
- package/src/components/layout/NavSelector.astro +11 -2
- package/src/components/layout/NavTree.astro +4 -2
- package/src/components/layout/RootLayout.astro +18 -0
- package/src/components/layout/Search.astro +77 -13
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/hydration-hint.ts +1 -1
- package/src/components/layout/nav-utils.ts +1 -4
- package/src/components/layout/overrides.ts +25 -12
- package/src/components/layout/search/algolia.ts +18 -5
- package/src/components/layout/search/endpoint.ts +3 -0
- package/src/components/layout/search/flexsearch.ts +23 -7
- package/src/components/layout/search/orama-cloud.ts +1 -1
- package/src/components/layout/search/orama.ts +4 -1
- package/src/components/layout/search/pagefind.ts +2 -0
- package/src/components/layout/search/types.ts +13 -1
- package/src/components/layout/search/typesense.ts +19 -3
- package/src/components/openapi/ApiOverview.astro +32 -6
- package/src/components/openapi/AsyncApiOperation.astro +237 -0
- package/src/components/openapi/Bindings.astro +89 -0
- package/src/components/openapi/MethodBadge.astro +3 -0
- package/src/components/openapi/Operation.astro +7 -2
- package/src/components/openapi/PanelTabs.astro +131 -0
- package/src/components/openapi/ParametersTable.astro +2 -0
- package/src/components/openapi/RequestPanel.astro +12 -119
- package/src/components/openapi/async-snippets.ts +174 -0
- package/src/components/openapi/async.ts +348 -0
- package/src/components/openapi/helpers.ts +52 -20
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +88 -27
- package/src/core/config.ts +20 -7
- package/src/core/content.ts +3 -1
- package/src/core/data.ts +16 -1
- package/src/core/define-components.ts +5 -0
- package/src/core/diagnostics.ts +46 -38
- package/src/core/frontmatter.ts +33 -7
- package/src/core/graph.ts +137 -53
- package/src/core/i18n-ui.ts +15 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/load-module.ts +1 -0
- package/src/core/manifest.ts +92 -3
- package/src/core/meta.ts +44 -14
- package/src/core/nav-diagnostics.ts +3 -3
- package/src/core/navigation.ts +247 -67
- package/src/core/project-graph.ts +15 -3
- package/src/core/schema.ts +213 -67
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +6 -0
- package/src/core/sources/github-releases.ts +39 -31
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +67 -20
- package/src/core/sources/notion.ts +49 -17
- package/src/core/sources/portable-text.ts +32 -11
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +4 -0
- package/src/core/sources/watch.ts +1 -1
- package/src/core/standard-schema.ts +9 -3
- package/src/core/text-width.ts +26 -0
- package/src/core/tsconfig-aliases.ts +9 -5
- package/src/core/types.ts +45 -2
- package/src/core/ui-packs/index.ts +9 -1
- package/src/core/version-cut.ts +301 -0
- package/src/core/version.ts +2 -0
- package/src/core/versions.ts +170 -0
- package/src/deploy/adapter-output.ts +5 -2
- package/src/deploy/cloudflare-negotiation.ts +25 -10
- package/src/deploy/sitemap.ts +33 -1
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/report.ts +4 -4
- package/src/eval/run.ts +2 -2
- package/src/eval/schema.ts +1 -1
- package/src/markdown/base-links.ts +6 -6
- package/src/markdown/directives.ts +7 -1
- package/src/markdown/heading-anchors.ts +17 -6
- package/src/markdown/index.ts +73 -24
- package/src/markdown/inline-code.ts +14 -2
- package/src/markdown/language-icon.ts +6 -2
- package/src/markdown/mdast.ts +18 -4
- package/src/markdown/package-commands.ts +6 -8
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +30 -11
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +126 -57
- package/src/openapi/parse.ts +97 -5
- package/src/openapi/references.ts +12 -10
- package/src/openapi/render-mdx.ts +73 -34
- package/src/openapi/scalar.ts +6 -8
- package/src/openapi/source.ts +98 -28
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +25 -5
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +66 -20
- package/src/search/popular.ts +10 -5
- package/src/search/providers.ts +2 -2
- package/src/search/sync/index.ts +2 -0
- package/src/search/sync/typesense.ts +4 -2
- package/src/seo/jsonld.ts +24 -6
- package/src/seo/x-handle.ts +8 -3
- package/src/theme/chrome-icons.ts +7 -2
- package/src/theme/fonts.ts +8 -4
- package/src/theme/icons.ts +4 -2
- package/src/theme/palette.ts +22 -14
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +9 -5
- package/src/translate/run.ts +10 -4
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
package/src/openapi/model.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Document,
|
|
3
|
-
OperationObject,
|
|
4
|
-
PathItemObject,
|
|
5
|
-
} from "@scalar/openapi-types/3.1";
|
|
1
|
+
import type { Document, OperationObject } from "@scalar/openapi-types/3.1";
|
|
6
2
|
|
|
3
|
+
import type { AsyncApiAction, AsyncApiDocument } from "./asyncapi.ts";
|
|
4
|
+
import type { ReferenceKind } from "./references.ts";
|
|
7
5
|
import { slugify } from "./references.ts";
|
|
8
6
|
|
|
9
7
|
// The slug rules live with the reference resolver so operation routes and
|
|
@@ -12,8 +10,9 @@ import { slugify } from "./references.ts";
|
|
|
12
10
|
export { slugify } from "./references.ts";
|
|
13
11
|
|
|
14
12
|
/**
|
|
15
|
-
* Blume's own
|
|
16
|
-
*
|
|
13
|
+
* Blume's own API reference model, shared by both spec kinds. OpenAPI specs
|
|
14
|
+
* are parsed and upgraded to 3.1, AsyncAPI specs normalized to 3.x (see
|
|
15
|
+
* `parse.ts`), with internal `$ref`s left intact — the document stays
|
|
17
16
|
* JSON-serializable (a fully dereferenced graph can be circular), and the schema
|
|
18
17
|
* components resolve refs against `document.components.schemas` at render time.
|
|
19
18
|
* Each operation is flattened into an {@link ApiOperationRef} with a real,
|
|
@@ -50,22 +49,26 @@ export const operationKey = (
|
|
|
50
49
|
return fromId || slugify(`${method}-${path}`);
|
|
51
50
|
};
|
|
52
51
|
|
|
53
|
-
/** One operation, flattened out of
|
|
52
|
+
/** One operation, flattened out of its document and mapped to a route. */
|
|
54
53
|
export interface ApiOperationRef {
|
|
55
54
|
/** Stable key, unique within a spec; matches the MDX `<Operation id>`. */
|
|
56
55
|
key: string;
|
|
57
|
-
method
|
|
58
|
-
|
|
56
|
+
/** HTTP method (OpenAPI) or `send`/`receive` action (AsyncAPI). */
|
|
57
|
+
method: HttpMethod | AsyncApiAction;
|
|
58
|
+
/** Templated path, e.g. `/pets/{id}` — or the channel address (AsyncAPI). */
|
|
59
59
|
path: string;
|
|
60
60
|
/** Full site route for this operation's page, e.g. `/reference/pet/add-pet`. */
|
|
61
61
|
route: string;
|
|
62
|
-
/** Display tag name (first tag
|
|
62
|
+
/** Display tag name (first tag; `Operations` or the channel address when untagged). */
|
|
63
63
|
tag: string;
|
|
64
64
|
tagSlug: string;
|
|
65
65
|
summary: string;
|
|
66
66
|
description: string;
|
|
67
|
+
/** The `operationId` (OpenAPI) or the `operations` map key (AsyncAPI). */
|
|
67
68
|
operationId?: string;
|
|
68
69
|
deprecated: boolean;
|
|
70
|
+
/** The channel the operation acts on (AsyncAPI only). */
|
|
71
|
+
channelId?: string;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
/** A tag/section, in first-seen order. */
|
|
@@ -77,6 +80,8 @@ export interface ApiTagRef {
|
|
|
77
80
|
|
|
78
81
|
/** Everything the runtime needs for one spec, serialized into `blume:openapi`. */
|
|
79
82
|
export interface ApiSpecData {
|
|
83
|
+
/** Which front-end parsed the spec (and which components render it). */
|
|
84
|
+
kind: ReferenceKind;
|
|
80
85
|
/** Unique token used as the `<Operation source>` and the data-module key. */
|
|
81
86
|
slug: string;
|
|
82
87
|
/** Base route the spec's operations hang off, e.g. `/reference`. */
|
|
@@ -85,7 +90,7 @@ export interface ApiSpecData {
|
|
|
85
90
|
title: string;
|
|
86
91
|
version: string;
|
|
87
92
|
description: string;
|
|
88
|
-
document: ApiDocument;
|
|
93
|
+
document: ApiDocument | AsyncApiDocument;
|
|
89
94
|
/** Operations keyed by {@link ApiOperationRef.key}. */
|
|
90
95
|
operations: Record<string, ApiOperationRef>;
|
|
91
96
|
tags: ApiTagRef[];
|
|
@@ -98,17 +103,27 @@ export interface ApiSpecData {
|
|
|
98
103
|
/** The generated `blume:openapi` module: specs keyed by {@link ApiSpecData.slug}. */
|
|
99
104
|
export type OpenApiData = Record<string, ApiSpecData>;
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
// The runtime object check stands guard because the document was parsed from
|
|
107
|
+
// arbitrary YAML/JSON: a spec can put a scalar where the type promises an
|
|
108
|
+
// operation object.
|
|
109
|
+
const isOperation = (
|
|
110
|
+
value: OperationObject | undefined
|
|
111
|
+
): value is OperationObject => typeof value === "object" && value !== null;
|
|
112
|
+
|
|
113
|
+
/** A declared tag whose `name` really is a string at runtime, type aside. */
|
|
114
|
+
const hasTagName = (tag: SpecTag): tag is SpecTag =>
|
|
115
|
+
typeof tag.name === "string";
|
|
103
116
|
|
|
104
117
|
/**
|
|
105
118
|
* Assign each distinct tag name a unique slug. `slugify` can collapse
|
|
106
119
|
* different names onto one value — any two punctuation-only tags (`!!!`,
|
|
107
120
|
* `???`) both fall through to the `operations` fallback — and a shared slug
|
|
108
121
|
* silently merges the tags' routes, sidebar groups, and overview sections.
|
|
109
|
-
* Collisions gain `-2`, `-3`, … in first-seen order.
|
|
122
|
+
* Collisions gain `-2`, `-3`, … in first-seen order. Shared with the AsyncAPI
|
|
123
|
+
* extractor (`asyncapi.ts`), whose untagged fallback groups are channel
|
|
124
|
+
* addresses.
|
|
110
125
|
*/
|
|
111
|
-
const tagSlugger = (): ((name: string) => string) => {
|
|
126
|
+
export const tagSlugger = (): ((name: string) => string) => {
|
|
112
127
|
const assigned = new Map<string, string>();
|
|
113
128
|
const taken = new Set<string>();
|
|
114
129
|
return (name) => {
|
|
@@ -127,6 +142,79 @@ const tagSlugger = (): ((name: string) => string) => {
|
|
|
127
142
|
};
|
|
128
143
|
};
|
|
129
144
|
|
|
145
|
+
/** An operation before the collector assigns its unique key and route. */
|
|
146
|
+
type CollectedOperation = Omit<ApiOperationRef, "route" | "tagSlug">;
|
|
147
|
+
|
|
148
|
+
/** A document's declared tag entry (`tags[n]`). */
|
|
149
|
+
type SpecTag = NonNullable<ApiDocument["tags"]>[number];
|
|
150
|
+
|
|
151
|
+
/** The flattened output both extractors produce. */
|
|
152
|
+
export interface CollectedOperations {
|
|
153
|
+
operations: ApiOperationRef[];
|
|
154
|
+
tags: ApiTagRef[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The collector handle: feed operations in, read the flattened output out. */
|
|
158
|
+
export interface OperationCollector {
|
|
159
|
+
add: (entry: CollectedOperation) => void;
|
|
160
|
+
finish: () => CollectedOperations;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** {@link CollectedOperations} plus anything the extractor had to skip. */
|
|
164
|
+
export interface ExtractedOperations extends CollectedOperations {
|
|
165
|
+
warnings: string[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The collector behind both extractors (OpenAPI here, AsyncAPI in
|
|
170
|
+
* `asyncapi.ts`): first-seen tag ordering, key de-duplication (a repeated key
|
|
171
|
+
* gains its method/action as a suffix), and the shared route template — so
|
|
172
|
+
* URL shape and slug rules can never drift between the two spec kinds.
|
|
173
|
+
*/
|
|
174
|
+
export const operationCollector = (
|
|
175
|
+
baseRoute: string,
|
|
176
|
+
tagMeta: ReadonlyMap<string, string>
|
|
177
|
+
): OperationCollector => {
|
|
178
|
+
const operations: ApiOperationRef[] = [];
|
|
179
|
+
const tagOrder: string[] = [];
|
|
180
|
+
const tagsSeen = new Set<string>();
|
|
181
|
+
const seen = new Set<string>();
|
|
182
|
+
const slugForTag = tagSlugger();
|
|
183
|
+
|
|
184
|
+
const add = (entry: CollectedOperation): void => {
|
|
185
|
+
const tagSlug = slugForTag(entry.tag);
|
|
186
|
+
if (!tagsSeen.has(entry.tag)) {
|
|
187
|
+
tagsSeen.add(entry.tag);
|
|
188
|
+
tagOrder.push(entry.tag);
|
|
189
|
+
}
|
|
190
|
+
let { key } = entry;
|
|
191
|
+
while (seen.has(key)) {
|
|
192
|
+
key = `${key}-${entry.method}`;
|
|
193
|
+
}
|
|
194
|
+
seen.add(key);
|
|
195
|
+
operations.push({
|
|
196
|
+
...entry,
|
|
197
|
+
key,
|
|
198
|
+
// A root-mounted reference (`route: "/"`) must not emit `//tag/key`.
|
|
199
|
+
route: `${baseRoute === "/" ? "" : baseRoute}/${tagSlug}/${key}`,
|
|
200
|
+
tagSlug,
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const finish = (): CollectedOperations => ({
|
|
205
|
+
operations,
|
|
206
|
+
tags: tagOrder.map((name) => ({
|
|
207
|
+
description: tagMeta.get(name) ?? "",
|
|
208
|
+
name,
|
|
209
|
+
// The same slugger instance, so every tag resolves to the slug its
|
|
210
|
+
// operations were routed under.
|
|
211
|
+
slug: slugForTag(name),
|
|
212
|
+
})),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
return { add, finish };
|
|
216
|
+
};
|
|
217
|
+
|
|
130
218
|
/**
|
|
131
219
|
* Flatten a 3.1 document into a route-mapped operation list and its ordered
|
|
132
220
|
* tags. Operations inherit the first tag they declare; keys are de-duplicated so
|
|
@@ -136,19 +224,17 @@ const tagSlugger = (): ((name: string) => string) => {
|
|
|
136
224
|
export const extractOperations = (
|
|
137
225
|
document: ApiDocument,
|
|
138
226
|
baseRoute: string
|
|
139
|
-
):
|
|
140
|
-
const
|
|
141
|
-
const tagOrder: string[] = [];
|
|
142
|
-
const tagsSeen = new Set<string>();
|
|
227
|
+
): ExtractedOperations => {
|
|
228
|
+
const warnings: string[] = [];
|
|
143
229
|
const tagMeta = new Map(
|
|
144
|
-
(document.tags ?? [])
|
|
230
|
+
(document.tags ?? [])
|
|
231
|
+
.filter(hasTagName)
|
|
232
|
+
.map((tag) => [tag.name, tag.description ?? ""])
|
|
145
233
|
);
|
|
146
|
-
const
|
|
147
|
-
const warnings: string[] = [];
|
|
148
|
-
const slugForTag = tagSlugger();
|
|
234
|
+
const collector = operationCollector(baseRoute, tagMeta);
|
|
149
235
|
|
|
150
|
-
for (const [path,
|
|
151
|
-
|
|
236
|
+
for (const [path, item] of Object.entries(document.paths ?? {})) {
|
|
237
|
+
// A parsed spec can carry a null path item despite the type; skip it.
|
|
152
238
|
if (!item) {
|
|
153
239
|
continue;
|
|
154
240
|
}
|
|
@@ -163,52 +249,35 @@ export const extractOperations = (
|
|
|
163
249
|
if (!isOperation(operation)) {
|
|
164
250
|
continue;
|
|
165
251
|
}
|
|
166
|
-
|
|
167
|
-
const tagSlug = slugForTag(tag);
|
|
168
|
-
if (!tagsSeen.has(tag)) {
|
|
169
|
-
tagsSeen.add(tag);
|
|
170
|
-
tagOrder.push(tag);
|
|
171
|
-
}
|
|
172
|
-
let key = operationKey(method, path, operation.operationId);
|
|
173
|
-
while (seen.has(key)) {
|
|
174
|
-
key = `${key}-${method}`;
|
|
175
|
-
}
|
|
176
|
-
seen.add(key);
|
|
177
|
-
operations.push({
|
|
252
|
+
collector.add({
|
|
178
253
|
deprecated: operation.deprecated ?? false,
|
|
179
254
|
description: operation.description ?? "",
|
|
180
|
-
key,
|
|
255
|
+
key: operationKey(method, path, operation.operationId),
|
|
181
256
|
method,
|
|
182
257
|
operationId: operation.operationId,
|
|
183
258
|
path,
|
|
184
|
-
// A root-mounted reference (`route: "/"`) must not emit `//tag/key`.
|
|
185
|
-
route: `${baseRoute === "/" ? "" : baseRoute}/${tagSlug}/${key}`,
|
|
186
259
|
summary: operation.summary ?? "",
|
|
187
|
-
tag,
|
|
188
|
-
tagSlug,
|
|
260
|
+
tag: operation.tags?.[0] ?? UNTAGGED,
|
|
189
261
|
});
|
|
190
262
|
}
|
|
191
263
|
}
|
|
192
264
|
|
|
193
|
-
|
|
194
|
-
description: tagMeta.get(name) ?? "",
|
|
195
|
-
name,
|
|
196
|
-
// The same slugger instance, so every tag resolves to the slug its
|
|
197
|
-
// operations were routed under.
|
|
198
|
-
slug: slugForTag(name),
|
|
199
|
-
}));
|
|
200
|
-
|
|
201
|
-
return { operations, tags, warnings };
|
|
265
|
+
return { ...collector.finish(), warnings };
|
|
202
266
|
};
|
|
203
267
|
|
|
204
|
-
/** Resolve the operation object for a ref out of its document. */
|
|
268
|
+
/** Resolve the operation object for a ref out of its (OpenAPI) document. */
|
|
205
269
|
export const operationObject = (
|
|
206
270
|
spec: ApiSpecData,
|
|
207
271
|
ref: ApiOperationRef
|
|
208
272
|
): OperationObject | undefined => {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
273
|
+
// Only OpenAPI refs carry HTTP methods; the AsyncAPI counterpart is
|
|
274
|
+
// `asyncApiOperationObject` in `asyncapi.ts`.
|
|
275
|
+
const method = HTTP_METHODS.find((candidate) => candidate === ref.method);
|
|
276
|
+
// SAFETY: only OpenAPI specs route their refs through this resolver
|
|
277
|
+
// (AsyncAPI documents go to `asyncApiOperationObject`), so the spec's
|
|
278
|
+
// document is the OpenAPI shape.
|
|
279
|
+
const document = spec.document as ApiDocument;
|
|
280
|
+
const item = document.paths?.[ref.path];
|
|
281
|
+
const operation = method === undefined ? undefined : item?.[method];
|
|
213
282
|
return isOperation(operation) ? operation : undefined;
|
|
214
283
|
};
|
package/src/openapi/parse.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
3
|
|
|
4
|
+
import type { AsyncAPIDocument as ConverterDocument } from "@asyncapi/converter";
|
|
5
|
+
import { convert } from "@asyncapi/converter";
|
|
4
6
|
import { normalize, upgrade } from "@scalar/openapi-parser";
|
|
5
7
|
import pRetry, { AbortError } from "p-retry";
|
|
6
8
|
import { isAbsolute, join } from "pathe";
|
|
7
9
|
|
|
8
10
|
import { hashText } from "../core/sources/cache.ts";
|
|
11
|
+
import type { AsyncApiDocument } from "./asyncapi.ts";
|
|
12
|
+
import { normalizeAsyncApiDocument } from "./asyncapi.ts";
|
|
9
13
|
import type { ApiDocument } from "./model.ts";
|
|
10
14
|
|
|
11
15
|
/**
|
|
@@ -192,6 +196,8 @@ const fetchSpecText = async (spec: string): Promise<string> => {
|
|
|
192
196
|
// stacking on it: p-retry's own (capped) delay still runs after this
|
|
193
197
|
// hook, so only the difference is slept here.
|
|
194
198
|
onFailedAttempt: async (context) => {
|
|
199
|
+
// SAFETY: every retryable throw above is a RetryableFetchError; any
|
|
200
|
+
// other error reaching this hook reads an absent retryAfter.
|
|
195
201
|
const { retryAfter } = context.error as RetryableFetchError;
|
|
196
202
|
if (retryAfter !== undefined && context.retriesLeft > 0) {
|
|
197
203
|
await sleep(
|
|
@@ -265,6 +271,8 @@ const readSpecText = async (
|
|
|
265
271
|
if (cacheFile) {
|
|
266
272
|
const cached = await readCache(cacheFile);
|
|
267
273
|
if (cached !== undefined) {
|
|
274
|
+
// SAFETY: fetchSpecText throws only Error instances — attemptFetch
|
|
275
|
+
// wraps every non-Error throw in an Error.
|
|
268
276
|
return {
|
|
269
277
|
text: cached,
|
|
270
278
|
warnings: [
|
|
@@ -283,6 +291,14 @@ const readSpecText = async (
|
|
|
283
291
|
* diagnostic (an error in build, a warning in dev) rather than a hard failure so
|
|
284
292
|
* a broken spec doesn't take down the whole build.
|
|
285
293
|
*/
|
|
294
|
+
/**
|
|
295
|
+
* A parsed mapping is the only shape the renderer can treat as a document:
|
|
296
|
+
* `normalize` yields undefined for anything that isn't a YAML/JSON mapping
|
|
297
|
+
* (empty file, scalar, list) and `upgrade(undefined)` a null specification.
|
|
298
|
+
*/
|
|
299
|
+
const isApiDocument = <Value>(value: Value): value is Value & ApiDocument =>
|
|
300
|
+
typeof value === "object" && value !== null;
|
|
301
|
+
|
|
286
302
|
export const parseSpec = async (
|
|
287
303
|
spec: string,
|
|
288
304
|
root: string,
|
|
@@ -291,13 +307,89 @@ export const parseSpec = async (
|
|
|
291
307
|
const { text, warnings } = await readSpecText(spec, root, options);
|
|
292
308
|
const normalized = normalize(text);
|
|
293
309
|
const { specification } = upgrade(normalized);
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
// specification — reject it here so the renderer never sees a non-document.
|
|
297
|
-
if (specification === null || typeof specification !== "object") {
|
|
310
|
+
// Reject a non-mapping here so the renderer never sees a non-document.
|
|
311
|
+
if (!isApiDocument(specification)) {
|
|
298
312
|
throw new InvalidSpecError(
|
|
299
313
|
`${spec} is not a valid OpenAPI document (expected a YAML or JSON object).`
|
|
300
314
|
);
|
|
301
315
|
}
|
|
302
|
-
return { document: specification
|
|
316
|
+
return { document: specification, warnings };
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
export interface ParsedAsyncApiSpec {
|
|
320
|
+
document: AsyncApiDocument;
|
|
321
|
+
warnings: string[];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read and normalize a spec to an AsyncAPI 3.x document — the AsyncAPI mirror
|
|
326
|
+
* of {@link parseSpec}. 1.x/2.x documents are lifted to 3.0 with the official
|
|
327
|
+
* `@asyncapi/converter` (channels + operations with `send`/`receive` actions),
|
|
328
|
+
* so the extractor and components only ever handle one shape; `$ref`s stay
|
|
329
|
+
* intact, matching the OpenAPI path. Error semantics match `parseSpec`: an
|
|
330
|
+
* unreadable spec throws, a readable non-AsyncAPI document throws
|
|
331
|
+
* {@link InvalidSpecError}, and callers lower both into source diagnostics.
|
|
332
|
+
*/
|
|
333
|
+
/**
|
|
334
|
+
* An object carrying a non-empty `asyncapi` version string — the only input
|
|
335
|
+
* the converter and extractor can key on. `normalize` yields undefined for
|
|
336
|
+
* non-mapping input, which fails the object check here.
|
|
337
|
+
*/
|
|
338
|
+
const isAsyncApiDocument = <Value>(
|
|
339
|
+
value: Value
|
|
340
|
+
): value is Value & AsyncApiDocument & { asyncapi: string } =>
|
|
341
|
+
typeof value === "object" &&
|
|
342
|
+
value !== null &&
|
|
343
|
+
"asyncapi" in value &&
|
|
344
|
+
typeof value.asyncapi === "string" &&
|
|
345
|
+
value.asyncapi !== "";
|
|
346
|
+
|
|
347
|
+
export const parseAsyncApiSpec = async (
|
|
348
|
+
spec: string,
|
|
349
|
+
root: string,
|
|
350
|
+
options: SpecFetchOptions = {}
|
|
351
|
+
): Promise<ParsedAsyncApiSpec> => {
|
|
352
|
+
const { text, warnings } = await readSpecText(spec, root, options);
|
|
353
|
+
const normalized = normalize(text);
|
|
354
|
+
if (!isAsyncApiDocument(normalized)) {
|
|
355
|
+
throw new InvalidSpecError(
|
|
356
|
+
`${spec} is not a valid AsyncAPI document (expected a YAML or JSON object with an \`asyncapi\` version field).`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const version = normalized.asyncapi;
|
|
360
|
+
let document: AsyncApiDocument = normalized;
|
|
361
|
+
if (!version.startsWith("3.")) {
|
|
362
|
+
// The converter reports lossy conversions (e.g. a 2.x parameter schema
|
|
363
|
+
// that 3.0 can't express) through console.warn — capture those as spec
|
|
364
|
+
// warnings instead of letting them leak into CLI output.
|
|
365
|
+
const captured: string[] = [];
|
|
366
|
+
const originalWarn = console.warn;
|
|
367
|
+
console.warn = (...args: unknown[]) => {
|
|
368
|
+
captured.push(args.map(String).join(" "));
|
|
369
|
+
};
|
|
370
|
+
try {
|
|
371
|
+
// SAFETY: the converter accepts any pre-3.0 AsyncAPI object and returns
|
|
372
|
+
// the 3.0 shape the extractor consumes; the two packages just declare
|
|
373
|
+
// the document type differently.
|
|
374
|
+
document = convert(
|
|
375
|
+
document as ConverterDocument,
|
|
376
|
+
"3.0.0"
|
|
377
|
+
) as AsyncApiDocument;
|
|
378
|
+
} catch (error) {
|
|
379
|
+
// An unconvertible document (say, an unknown `asyncapi` version) is a
|
|
380
|
+
// content problem, not a network one — same class as a non-document.
|
|
381
|
+
// SAFETY: @asyncapi/converter throws Error instances for bad input.
|
|
382
|
+
throw new InvalidSpecError(
|
|
383
|
+
`${spec} could not be converted to AsyncAPI 3.0 (${(error as Error).message}).`
|
|
384
|
+
);
|
|
385
|
+
} finally {
|
|
386
|
+
console.warn = originalWarn;
|
|
387
|
+
}
|
|
388
|
+
warnings.push(
|
|
389
|
+
...captured.map(
|
|
390
|
+
(message) => `Converting ${spec} to AsyncAPI 3.0: ${message}`
|
|
391
|
+
)
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return { document: normalizeAsyncApiDocument(document), warnings };
|
|
303
395
|
};
|
|
@@ -54,9 +54,10 @@ export interface ReferenceSource {
|
|
|
54
54
|
theme?: string;
|
|
55
55
|
/**
|
|
56
56
|
* Arbitrary Scalar config forwarded to `<ScalarComponent>` (Scalar renderer
|
|
57
|
-
* only). Takes precedence over Blume's derived spec/theme config.
|
|
57
|
+
* only). Takes precedence over Blume's derived spec/theme config. Typed off
|
|
58
|
+
* the config schema so the two can never drift.
|
|
58
59
|
*/
|
|
59
|
-
scalar?:
|
|
60
|
+
scalar?: ResolvedConfig["openapi"]["scalar"];
|
|
60
61
|
/** Display options carried through to the Blume renderer. */
|
|
61
62
|
display: ReferenceDisplay;
|
|
62
63
|
/**
|
|
@@ -174,11 +175,9 @@ const referencesFor = (
|
|
|
174
175
|
});
|
|
175
176
|
};
|
|
176
177
|
|
|
177
|
-
const NO_DISPLAY: ReferenceDisplay = { codeSamples: [], expandSchemas: false };
|
|
178
|
-
|
|
179
178
|
/**
|
|
180
|
-
* Resolve every enabled reference.
|
|
181
|
-
* by default
|
|
179
|
+
* Resolve every enabled reference. Both blocks honor their `renderer` —
|
|
180
|
+
* Blume's own UI by default, with the embedded Scalar SPA as the opt-out.
|
|
182
181
|
*/
|
|
183
182
|
export const resolveReferences = (
|
|
184
183
|
config: ResolvedConfig
|
|
@@ -198,8 +197,11 @@ export const resolveReferences = (
|
|
|
198
197
|
"asyncapi",
|
|
199
198
|
config.asyncapi,
|
|
200
199
|
"Events",
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
config.asyncapi.renderer,
|
|
201
|
+
{
|
|
202
|
+
codeSamples: config.asyncapi.codeSamples,
|
|
203
|
+
expandSchemas: config.asyncapi.expandSchemas,
|
|
204
|
+
},
|
|
203
205
|
config.basePath
|
|
204
206
|
),
|
|
205
207
|
];
|
|
@@ -232,7 +234,7 @@ const blumeReferenceOf = (
|
|
|
232
234
|
seen: Map<string, ReferenceSource>,
|
|
233
235
|
usedSlugs: Set<string>
|
|
234
236
|
): ReferenceSource | null => {
|
|
235
|
-
if (ref.
|
|
237
|
+
if (ref.renderer !== "blume") {
|
|
236
238
|
return null;
|
|
237
239
|
}
|
|
238
240
|
const kept = seen.get(ref.route);
|
|
@@ -260,7 +262,7 @@ const blumeReferenceOf = (
|
|
|
260
262
|
return accepted;
|
|
261
263
|
};
|
|
262
264
|
|
|
263
|
-
/** Blume-rendered
|
|
265
|
+
/** Blume-rendered references (both kinds), deduped by route (first wins). */
|
|
264
266
|
export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
|
|
265
267
|
const seen = new Map<string, ReferenceSource>();
|
|
266
268
|
const usedSlugs = new Set<string>();
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Nodes } from "mdast";
|
|
2
2
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
3
3
|
import { toString as mdastToString } from "mdast-util-to-string";
|
|
4
|
+
import stringWidth from "string-width";
|
|
4
5
|
|
|
6
|
+
import { columnsPrefix } from "../core/text-width.ts";
|
|
5
7
|
import type { ApiOperationRef, ApiSpecData } from "./model.ts";
|
|
6
8
|
import type { ReferenceSource } from "./references.ts";
|
|
7
9
|
|
|
@@ -22,18 +24,18 @@ import type { ReferenceSource } from "./references.ts";
|
|
|
22
24
|
// deliberately not escaped: it isn't MDX-special on its own, and escaping it
|
|
23
25
|
// turns a `> Note:` blockquote into literal "> Note:" text.
|
|
24
26
|
const MDX_UNSAFE = /[<{}]/gu;
|
|
25
|
-
const ENTITIES
|
|
26
|
-
"<"
|
|
27
|
-
"{"
|
|
28
|
-
"}"
|
|
29
|
-
|
|
27
|
+
const ENTITIES = new Map([
|
|
28
|
+
["<", "<"],
|
|
29
|
+
["{", "{"],
|
|
30
|
+
["}", "}"],
|
|
31
|
+
]);
|
|
30
32
|
// MDX also parses lines starting with `import`/`export` as ESM ("import the
|
|
31
33
|
// SDK…" is common spec prose). Entity-escape the keyword's first letter so the
|
|
32
34
|
// construct can't match; it still renders as the literal word.
|
|
33
35
|
const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
|
|
34
36
|
const escapeProse = (text: string): string =>
|
|
35
37
|
text
|
|
36
|
-
.replace(MDX_UNSAFE, (char) => ENTITIES
|
|
38
|
+
.replace(MDX_UNSAFE, (char) => ENTITIES.get(char) ?? char)
|
|
37
39
|
.replace(
|
|
38
40
|
MDX_ESM_KEYWORD,
|
|
39
41
|
(keyword) => `&#${keyword.codePointAt(0)};${keyword.slice(1)}`
|
|
@@ -94,9 +96,23 @@ const mdxSafe = (text: string): string => {
|
|
|
94
96
|
return out + escapeProse(text.slice(cursor));
|
|
95
97
|
};
|
|
96
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Frontmatter emitted for one operation or overview page. Boolean flags are
|
|
101
|
+
* assigned only when set, so absent keys stay absent in the staged MDX.
|
|
102
|
+
*/
|
|
103
|
+
export interface RenderedPageData {
|
|
104
|
+
ai?: { exclude: boolean };
|
|
105
|
+
deprecated?: boolean;
|
|
106
|
+
search?: { exclude?: boolean; tags?: string[] };
|
|
107
|
+
seo: { description: string; noindex?: boolean };
|
|
108
|
+
sidebar: { badge?: string; label: string };
|
|
109
|
+
title: string;
|
|
110
|
+
type?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
97
113
|
/** Frontmatter + body for one operation or overview page. */
|
|
98
114
|
export interface RenderedPage {
|
|
99
|
-
data:
|
|
115
|
+
data: RenderedPageData;
|
|
100
116
|
body: string;
|
|
101
117
|
}
|
|
102
118
|
|
|
@@ -123,19 +139,19 @@ const plainProse = (markdown: string): string => {
|
|
|
123
139
|
return first ? mdastToString(first).replace(WHITESPACE, " ").trim() : "";
|
|
124
140
|
};
|
|
125
141
|
|
|
126
|
-
/** Cap `text` at `max`
|
|
142
|
+
/** Cap `text` at `max` display columns, cutting on a word boundary. */
|
|
127
143
|
const clip = (text: string, max: number): string => {
|
|
128
144
|
if (max <= 0) {
|
|
129
145
|
return "";
|
|
130
146
|
}
|
|
131
|
-
if (text
|
|
147
|
+
if (stringWidth(text) <= max) {
|
|
132
148
|
return text;
|
|
133
149
|
}
|
|
134
|
-
const head = text
|
|
150
|
+
const head = columnsPrefix(text, max - 1);
|
|
135
151
|
const onWordBoundary = head.replace(TRAILING_WORD, "");
|
|
136
152
|
// One very long token — an endpoint path has no spaces — would be dropped
|
|
137
153
|
// whole, leaving a stub. Hard-cut it instead of losing it.
|
|
138
|
-
return `${onWordBoundary
|
|
154
|
+
return `${stringWidth(onWordBoundary) >= max / 2 ? onWordBoundary : head}…`;
|
|
139
155
|
};
|
|
140
156
|
|
|
141
157
|
const apiName = (spec: ApiSpecData): string => spec.title || spec.label;
|
|
@@ -149,11 +165,14 @@ const operationDescription = (
|
|
|
149
165
|
spec: ApiSpecData,
|
|
150
166
|
operation: ApiOperationRef
|
|
151
167
|
): string => {
|
|
152
|
-
|
|
153
|
-
const suffix =
|
|
168
|
+
// AsyncAPI operations act on a channel, not an HTTP endpoint.
|
|
169
|
+
const suffix =
|
|
170
|
+
spec.kind === "asyncapi"
|
|
171
|
+
? `Reference for the ${operation.method} operation on ${operation.path} in the ${apiName(spec)} API.`
|
|
172
|
+
: `Reference for the ${operation.method.toUpperCase()} ${operation.path} endpoint in the ${apiName(spec)} API.`;
|
|
154
173
|
const prose = clip(
|
|
155
174
|
plainProse(operation.description || operation.summary),
|
|
156
|
-
META_DESCRIPTION_MAX - suffix
|
|
175
|
+
META_DESCRIPTION_MAX - stringWidth(suffix) - 1
|
|
157
176
|
);
|
|
158
177
|
return clip([prose, suffix].filter(Boolean).join(" "), META_DESCRIPTION_MAX);
|
|
159
178
|
};
|
|
@@ -180,22 +199,35 @@ export const operationMdx = (
|
|
|
180
199
|
operation.description.trim() === operation.summary.trim()
|
|
181
200
|
? ""
|
|
182
201
|
: operation.description;
|
|
202
|
+
const flags: Pick<RenderedPageData, "ai" | "deprecated"> = {};
|
|
203
|
+
if (reference?.includeInLlms === false) {
|
|
204
|
+
flags.ai = { exclude: true };
|
|
205
|
+
}
|
|
206
|
+
if (operation.deprecated) {
|
|
207
|
+
flags.deprecated = true;
|
|
208
|
+
}
|
|
209
|
+
const searchFlags: Pick<
|
|
210
|
+
NonNullable<RenderedPageData["search"]>,
|
|
211
|
+
"exclude"
|
|
212
|
+
> = {};
|
|
213
|
+
if (reference?.includeInSearch === false) {
|
|
214
|
+
searchFlags.exclude = true;
|
|
215
|
+
}
|
|
216
|
+
const seo: RenderedPageData["seo"] = {
|
|
217
|
+
description: operationDescription(spec, operation),
|
|
218
|
+
};
|
|
219
|
+
if (reference?.noindex) {
|
|
220
|
+
seo.noindex = true;
|
|
221
|
+
}
|
|
183
222
|
return {
|
|
184
223
|
body: withDescription(
|
|
185
224
|
description,
|
|
186
225
|
`<Operation source="${spec.slug}" id="${operation.key}" />`
|
|
187
226
|
),
|
|
188
227
|
data: {
|
|
189
|
-
...
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
...(reference?.includeInSearch === false ? { exclude: true } : {}),
|
|
193
|
-
tags: [operation.tag, method],
|
|
194
|
-
},
|
|
195
|
-
seo: {
|
|
196
|
-
description: operationDescription(spec, operation),
|
|
197
|
-
...(reference?.noindex ? { noindex: true } : {}),
|
|
198
|
-
},
|
|
228
|
+
...flags,
|
|
229
|
+
search: { ...searchFlags, tags: [operation.tag, method] },
|
|
230
|
+
seo,
|
|
199
231
|
sidebar: { badge: method, label: operation.summary || operation.path },
|
|
200
232
|
title,
|
|
201
233
|
// Signals the two-column API layout (request panel instead of the TOC).
|
|
@@ -253,6 +285,21 @@ export const overviewMdx = (
|
|
|
253
285
|
].join("\n\n")
|
|
254
286
|
);
|
|
255
287
|
}
|
|
288
|
+
const flags: Pick<RenderedPageData, "ai" | "search"> = {};
|
|
289
|
+
if (reference?.includeInLlms === false) {
|
|
290
|
+
flags.ai = { exclude: true };
|
|
291
|
+
}
|
|
292
|
+
if (reference?.includeInSearch === false) {
|
|
293
|
+
flags.search = { exclude: true };
|
|
294
|
+
}
|
|
295
|
+
const seo: RenderedPageData["seo"] = {
|
|
296
|
+
description:
|
|
297
|
+
clip(plainProse(spec.description), META_DESCRIPTION_MAX) ||
|
|
298
|
+
`${apiName(spec)} API reference.`,
|
|
299
|
+
};
|
|
300
|
+
if (reference?.noindex) {
|
|
301
|
+
seo.noindex = true;
|
|
302
|
+
}
|
|
256
303
|
return {
|
|
257
304
|
body: [
|
|
258
305
|
withDescription(
|
|
@@ -262,16 +309,8 @@ export const overviewMdx = (
|
|
|
262
309
|
...tagSections,
|
|
263
310
|
].join("\n\n"),
|
|
264
311
|
data: {
|
|
265
|
-
...
|
|
266
|
-
|
|
267
|
-
? { search: { exclude: true } }
|
|
268
|
-
: {}),
|
|
269
|
-
seo: {
|
|
270
|
-
description:
|
|
271
|
-
clip(plainProse(spec.description), META_DESCRIPTION_MAX) ||
|
|
272
|
-
`${apiName(spec)} API reference.`,
|
|
273
|
-
...(reference?.noindex ? { noindex: true } : {}),
|
|
274
|
-
},
|
|
312
|
+
...flags,
|
|
313
|
+
seo,
|
|
275
314
|
sidebar: { label: "Overview" },
|
|
276
315
|
title: apiName(spec),
|
|
277
316
|
},
|