blume 1.4.2 → 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 +71 -0
- package/dist/cli/index.js +2260 -1100
- package/dist/cli/index.js.map +123 -117
- package/dist/types/ai/component-markdown.d.ts +14 -4
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +87 -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 +116 -15
- package/dist/types/core/sources/types.d.ts +11 -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 +14 -7
- package/dist/types/seo/x-handle.d.ts +3 -2
- package/docs/advanced/api-reference.mdx +8 -6
- package/docs/configuration/ai.mdx +26 -8
- 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/sources.mdx +1 -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 +13 -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 +40 -12
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +49 -12
- package/src/ai/ask.ts +10 -1
- package/src/ai/component-markdown.ts +80 -43
- package/src/ai/llms.ts +42 -17
- package/src/ai/mcp/data.ts +48 -12
- package/src/ai/mcp/discovery.ts +52 -16
- package/src/ai/mcp/server.ts +280 -125
- package/src/ai/mcp/tools.ts +3 -3
- package/src/ai/skills.ts +32 -9
- package/src/ai/tar.ts +29 -70
- package/src/ai/visibility.ts +2 -2
- package/src/astro/component-slots.ts +2 -0
- package/src/astro/examples.ts +13 -5
- package/src/astro/generate.ts +113 -63
- package/src/astro/integration.ts +13 -2
- package/src/astro/islands.ts +23 -12
- package/src/astro/templates.ts +185 -41
- package/src/audit/agent.ts +16 -31
- 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 +78 -25
- package/src/audit/report.ts +20 -19
- package/src/audit/run.ts +15 -5
- package/src/audit/snapshot.ts +29 -6
- package/src/audit/types.ts +25 -3
- package/src/blume-modules.d.ts +5 -1
- package/src/cli/commands/audit.ts +21 -21
- package/src/cli/commands/build.ts +30 -16
- package/src/cli/commands/dev.ts +15 -15
- package/src/cli/commands/doctor.ts +2 -0
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +24 -30
- 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/env.ts +13 -30
- package/src/cli/index.ts +2 -0
- package/src/cli/init/questions.ts +1 -1
- package/src/cli/init/scaffold.ts +41 -13
- package/src/cli/internal-error.ts +1 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- 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/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +111 -34
- 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/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +24 -48
- package/src/components/layout/Search.astro +133 -22
- package/src/components/layout/VersionBanner.astro +39 -0
- package/src/components/layout/analytics-client.ts +8 -5
- package/src/components/layout/drawer-inert.ts +31 -0
- 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 +8 -5
- package/src/components/layout/search/types.ts +45 -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/panel.ts +11 -8
- package/src/components/openapi/security.ts +102 -29
- package/src/components/openapi/snippets.ts +11 -11
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/component-overrides.ts +28 -23
- package/src/core/config-input.ts +96 -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 +74 -4
- 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/probe.ts +7 -19
- package/src/core/project-graph.ts +27 -4
- package/src/core/schema.ts +219 -67
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/assets.ts +2 -0
- package/src/core/sources/cache.ts +16 -8
- package/src/core/sources/github-releases.ts +39 -11
- package/src/core/sources/mdx-remote.ts +4 -0
- package/src/core/sources/normalize.ts +93 -22
- package/src/core/sources/notion.ts +76 -22
- package/src/core/sources/portable-text.ts +48 -12
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/sanity.ts +68 -14
- package/src/core/sources/types.ts +17 -1
- 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 +40 -11
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +89 -8
- package/src/deploy/vercel-negotiation.ts +11 -4
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +5 -18
- 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 +63 -58
- package/src/markdown/table-wrap.ts +4 -1
- package/src/markdown/twoslash.ts +2 -0
- package/src/og/card.ts +50 -33
- package/src/og/derive.ts +43 -27
- package/src/openapi/asyncapi.ts +366 -0
- package/src/openapi/model.ts +135 -66
- package/src/openapi/parse.ts +166 -33
- package/src/openapi/references.ts +47 -22
- package/src/openapi/render-mdx.ts +137 -59
- package/src/openapi/scalar.ts +8 -10
- package/src/openapi/source.ts +126 -29
- package/src/registry/eject.ts +7 -2
- package/src/search/documents.ts +103 -39
- package/src/search/facets.ts +7 -5
- package/src/search/orama-index.ts +117 -32
- 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 +27 -15
- package/src/translate/ledger.ts +4 -2
- package/src/translate/meta.ts +15 -6
- package/src/translate/report.ts +10 -19
- package/src/translate/run.ts +29 -38
- package/src/translate/validate.ts +52 -17
- package/src/translate/work-list.ts +0 -0
- package/src/cli/coalesce.ts +0 -43
package/src/openapi/parse.ts
CHANGED
|
@@ -1,10 +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";
|
|
7
|
+
import pRetry, { AbortError } from "p-retry";
|
|
5
8
|
import { isAbsolute, join } from "pathe";
|
|
6
9
|
|
|
7
10
|
import { hashText } from "../core/sources/cache.ts";
|
|
11
|
+
import type { AsyncApiDocument } from "./asyncapi.ts";
|
|
12
|
+
import { normalizeAsyncApiDocument } from "./asyncapi.ts";
|
|
8
13
|
import type { ApiDocument } from "./model.ts";
|
|
9
14
|
|
|
10
15
|
/**
|
|
@@ -95,13 +100,23 @@ const ensureProxyDispatcher = async (): Promise<void> => {
|
|
|
95
100
|
}
|
|
96
101
|
};
|
|
97
102
|
|
|
98
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* `Retry-After` in ms when the server sent a sane one, else undefined. RFC
|
|
105
|
+
* 9110 allows both forms: delta-seconds (`120`) and an HTTP-date (`Wed, 21
|
|
106
|
+
* Oct 2015 07:28:00 GMT`); the date form arrives from CDN rate limiters and
|
|
107
|
+
* was previously ignored.
|
|
108
|
+
*/
|
|
99
109
|
const retryAfterMs = (response: Response): number | undefined => {
|
|
100
110
|
const header = response.headers.get("retry-after");
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
if (!header) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const seconds = Number(header);
|
|
115
|
+
if (Number.isFinite(seconds)) {
|
|
116
|
+
return seconds > 0 ? seconds * SECOND_MS : undefined;
|
|
117
|
+
}
|
|
118
|
+
const delta = Date.parse(header) - Date.now();
|
|
119
|
+
return Number.isFinite(delta) && delta > 0 ? delta : undefined;
|
|
105
120
|
};
|
|
106
121
|
|
|
107
122
|
/** One fetch attempt, normalized: the body text, or a (maybe-retryable) error. */
|
|
@@ -137,33 +152,65 @@ const attemptFetch = async (spec: string): Promise<Attempt> => {
|
|
|
137
152
|
}
|
|
138
153
|
};
|
|
139
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A retryable failure, wrapped in a plain Error p-retry never special-cases:
|
|
157
|
+
* it refuses to retry a non-network `TypeError`, and the underlying error's
|
|
158
|
+
* type is the server's choice, not ours. The message is the underlying
|
|
159
|
+
* error's, so the exhaustion throw still reads `spec -> 503 Service
|
|
160
|
+
* Unavailable`.
|
|
161
|
+
*/
|
|
162
|
+
type RetryableFetchError = Error & { retryAfter?: number };
|
|
163
|
+
|
|
164
|
+
const retryableFetchError = (
|
|
165
|
+
error: Error,
|
|
166
|
+
retryAfter?: number
|
|
167
|
+
): RetryableFetchError => {
|
|
168
|
+
const wrapper: RetryableFetchError = new Error(error.message, {
|
|
169
|
+
cause: error,
|
|
170
|
+
});
|
|
171
|
+
wrapper.name = "RetryableFetchError";
|
|
172
|
+
wrapper.retryAfter = retryAfter;
|
|
173
|
+
return wrapper;
|
|
174
|
+
};
|
|
175
|
+
|
|
140
176
|
/** Fetch a remote spec's text, retrying transient failures with backoff. */
|
|
141
177
|
const fetchSpecText = async (spec: string): Promise<string> => {
|
|
142
178
|
await ensureProxyDispatcher();
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
179
|
+
return await pRetry(
|
|
180
|
+
async () => {
|
|
181
|
+
const attempt = await attemptFetch(spec);
|
|
182
|
+
if ("text" in attempt) {
|
|
183
|
+
return attempt.text;
|
|
184
|
+
}
|
|
185
|
+
if (!attempt.retryable) {
|
|
186
|
+
// AbortError stops retrying and rethrows the original untouched.
|
|
187
|
+
throw new AbortError(attempt.error);
|
|
188
|
+
}
|
|
189
|
+
throw retryableFetchError(attempt.error, attempt.retryAfter);
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
factor: 2,
|
|
193
|
+
maxTimeout: MAX_RETRY_WAIT_MS,
|
|
194
|
+
minTimeout: BASE_BACKOFF_MS,
|
|
195
|
+
// A sane `Retry-After` replaces the exponential backoff rather than
|
|
196
|
+
// stacking on it: p-retry's own (capped) delay still runs after this
|
|
197
|
+
// hook, so only the difference is slept here.
|
|
198
|
+
onFailedAttempt: async (context) => {
|
|
199
|
+
// SAFETY: every retryable throw above is a RetryableFetchError; any
|
|
200
|
+
// other error reaching this hook reads an absent retryAfter.
|
|
201
|
+
const { retryAfter } = context.error as RetryableFetchError;
|
|
202
|
+
if (retryAfter !== undefined && context.retriesLeft > 0) {
|
|
203
|
+
await sleep(
|
|
204
|
+
Math.max(
|
|
205
|
+
0,
|
|
206
|
+
Math.min(retryAfter, MAX_RETRY_WAIT_MS) - context.retryDelay
|
|
207
|
+
)
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
retries: MAX_ATTEMPTS - 1,
|
|
164
212
|
}
|
|
165
|
-
|
|
166
|
-
throw last.error;
|
|
213
|
+
);
|
|
167
214
|
};
|
|
168
215
|
|
|
169
216
|
const cacheFileFor = (cacheDir: string, spec: string): string =>
|
|
@@ -224,6 +271,8 @@ const readSpecText = async (
|
|
|
224
271
|
if (cacheFile) {
|
|
225
272
|
const cached = await readCache(cacheFile);
|
|
226
273
|
if (cached !== undefined) {
|
|
274
|
+
// SAFETY: fetchSpecText throws only Error instances — attemptFetch
|
|
275
|
+
// wraps every non-Error throw in an Error.
|
|
227
276
|
return {
|
|
228
277
|
text: cached,
|
|
229
278
|
warnings: [
|
|
@@ -242,6 +291,14 @@ const readSpecText = async (
|
|
|
242
291
|
* diagnostic (an error in build, a warning in dev) rather than a hard failure so
|
|
243
292
|
* a broken spec doesn't take down the whole build.
|
|
244
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
|
+
|
|
245
302
|
export const parseSpec = async (
|
|
246
303
|
spec: string,
|
|
247
304
|
root: string,
|
|
@@ -250,13 +307,89 @@ export const parseSpec = async (
|
|
|
250
307
|
const { text, warnings } = await readSpecText(spec, root, options);
|
|
251
308
|
const normalized = normalize(text);
|
|
252
309
|
const { specification } = upgrade(normalized);
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
// specification — reject it here so the renderer never sees a non-document.
|
|
256
|
-
if (specification === null || typeof specification !== "object") {
|
|
310
|
+
// Reject a non-mapping here so the renderer never sees a non-document.
|
|
311
|
+
if (!isApiDocument(specification)) {
|
|
257
312
|
throw new InvalidSpecError(
|
|
258
313
|
`${spec} is not a valid OpenAPI document (expected a YAML or JSON object).`
|
|
259
314
|
);
|
|
260
315
|
}
|
|
261
|
-
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 };
|
|
262
395
|
};
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { withBasePath } from "../core/base-path.ts";
|
|
1
|
+
import { normalizeRoute, withBasePath } from "../core/base-path.ts";
|
|
2
2
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
3
|
-
import { trimChar
|
|
3
|
+
import { trimChar } from "../core/trim.ts";
|
|
4
|
+
|
|
5
|
+
// Re-exported from its home next to the other path helpers; `core/schema.ts`
|
|
6
|
+
// and downstream consumers historically imported it from here.
|
|
7
|
+
export { normalizeRoute } from "../core/base-path.ts";
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Pure resolution of the configured API reference blocks into concrete routes,
|
|
@@ -50,9 +54,10 @@ export interface ReferenceSource {
|
|
|
50
54
|
theme?: string;
|
|
51
55
|
/**
|
|
52
56
|
* Arbitrary Scalar config forwarded to `<ScalarComponent>` (Scalar renderer
|
|
53
|
-
* 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.
|
|
54
59
|
*/
|
|
55
|
-
scalar?:
|
|
60
|
+
scalar?: ResolvedConfig["openapi"]["scalar"];
|
|
56
61
|
/** Display options carried through to the Blume renderer. */
|
|
57
62
|
display: ReferenceDisplay;
|
|
58
63
|
/**
|
|
@@ -62,18 +67,37 @@ export interface ReferenceSource {
|
|
|
62
67
|
collisions?: string[];
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
|
|
70
|
+
// Keep Unicode letters/marks/numbers so diacritics stay in the slug (ASCII-only
|
|
71
|
+
// stripping turned `Größe` into `gr-e`, which the nav humanizer rendered as
|
|
72
|
+
// `Gr E`); `\p{M}` keeps combining marks attached to their base letter, which
|
|
73
|
+
// NFC cannot always compose away (Devanagari vowel signs, Turkish `İ`'s
|
|
74
|
+
// lowercased combining dot).
|
|
75
|
+
const NON_SLUG = /[^\p{L}\p{M}\p{N}]+/gu;
|
|
76
|
+
// Format characters (ZWNJ, ZWJ, bidi controls) separate no words — hyphenating
|
|
77
|
+
// them would split Persian/Indic compounds the way ASCII stripping split
|
|
78
|
+
// `Größe` — so they are dropped, not replaced.
|
|
79
|
+
const FORMAT_CHARS = /\p{Cf}/gu;
|
|
80
|
+
// A combining mark at the start of the slug has no base letter to attach to
|
|
81
|
+
// (it would glue onto the preceding `/` in a URL), and a marks-only slug must
|
|
82
|
+
// come out empty so callers' fallbacks (`operations`, `reference`) fire.
|
|
83
|
+
const LEADING_MARKS = /^\p{M}+/u;
|
|
66
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Lowercase, hyphen-separated slug: `Add a Pet!` -> `add-a-pet`. Unicode
|
|
87
|
+
* letters are kept (`Größe` -> `größe`), NFC-normalized so canonically
|
|
88
|
+
* equivalent spellings (NFD input from macOS tooling) land on one slug.
|
|
89
|
+
* Non-ASCII slugs rely on the emitter percent-encoding the URL where a raw
|
|
90
|
+
* URI is required (sitemap, canonical).
|
|
91
|
+
*/
|
|
67
92
|
export const slugify = (text: string): string =>
|
|
68
|
-
trimChar(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
};
|
|
93
|
+
trimChar(
|
|
94
|
+
text
|
|
95
|
+
.normalize("NFC")
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(FORMAT_CHARS, "")
|
|
98
|
+
.replace(NON_SLUG, "-"),
|
|
99
|
+
"-"
|
|
100
|
+
).replace(LEADING_MARKS, "");
|
|
77
101
|
|
|
78
102
|
/** A stable per-reference token from its route: `/api/events` -> `api-events`. */
|
|
79
103
|
const routeSlug = (route: string): string =>
|
|
@@ -151,11 +175,9 @@ const referencesFor = (
|
|
|
151
175
|
});
|
|
152
176
|
};
|
|
153
177
|
|
|
154
|
-
const NO_DISPLAY: ReferenceDisplay = { codeSamples: [], expandSchemas: false };
|
|
155
|
-
|
|
156
178
|
/**
|
|
157
|
-
* Resolve every enabled reference.
|
|
158
|
-
* 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.
|
|
159
181
|
*/
|
|
160
182
|
export const resolveReferences = (
|
|
161
183
|
config: ResolvedConfig
|
|
@@ -175,8 +197,11 @@ export const resolveReferences = (
|
|
|
175
197
|
"asyncapi",
|
|
176
198
|
config.asyncapi,
|
|
177
199
|
"Events",
|
|
178
|
-
|
|
179
|
-
|
|
200
|
+
config.asyncapi.renderer,
|
|
201
|
+
{
|
|
202
|
+
codeSamples: config.asyncapi.codeSamples,
|
|
203
|
+
expandSchemas: config.asyncapi.expandSchemas,
|
|
204
|
+
},
|
|
180
205
|
config.basePath
|
|
181
206
|
),
|
|
182
207
|
];
|
|
@@ -209,7 +234,7 @@ const blumeReferenceOf = (
|
|
|
209
234
|
seen: Map<string, ReferenceSource>,
|
|
210
235
|
usedSlugs: Set<string>
|
|
211
236
|
): ReferenceSource | null => {
|
|
212
|
-
if (ref.
|
|
237
|
+
if (ref.renderer !== "blume") {
|
|
213
238
|
return null;
|
|
214
239
|
}
|
|
215
240
|
const kept = seen.get(ref.route);
|
|
@@ -237,7 +262,7 @@ const blumeReferenceOf = (
|
|
|
237
262
|
return accepted;
|
|
238
263
|
};
|
|
239
264
|
|
|
240
|
-
/** Blume-rendered
|
|
265
|
+
/** Blume-rendered references (both kinds), deduped by route (first wins). */
|
|
241
266
|
export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
|
|
242
267
|
const seen = new Map<string, ReferenceSource>();
|
|
243
268
|
const usedSlugs = new Set<string>();
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import type { Nodes } from "mdast";
|
|
2
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
3
|
+
import { toString as mdastToString } from "mdast-util-to-string";
|
|
4
|
+
import stringWidth from "string-width";
|
|
5
|
+
|
|
6
|
+
import { columnsPrefix } from "../core/text-width.ts";
|
|
1
7
|
import type { ApiOperationRef, ApiSpecData } from "./model.ts";
|
|
2
8
|
import type { ReferenceSource } from "./references.ts";
|
|
3
9
|
|
|
@@ -18,49 +24,95 @@ import type { ReferenceSource } from "./references.ts";
|
|
|
18
24
|
// deliberately not escaped: it isn't MDX-special on its own, and escaping it
|
|
19
25
|
// turns a `> Note:` blockquote into literal "> Note:" text.
|
|
20
26
|
const MDX_UNSAFE = /[<{}]/gu;
|
|
21
|
-
const ENTITIES
|
|
22
|
-
"<"
|
|
23
|
-
"{"
|
|
24
|
-
"}"
|
|
25
|
-
|
|
27
|
+
const ENTITIES = new Map([
|
|
28
|
+
["<", "<"],
|
|
29
|
+
["{", "{"],
|
|
30
|
+
["}", "}"],
|
|
31
|
+
]);
|
|
26
32
|
// MDX also parses lines starting with `import`/`export` as ESM ("import the
|
|
27
33
|
// SDK…" is common spec prose). Entity-escape the keyword's first letter so the
|
|
28
34
|
// construct can't match; it still renders as the literal word.
|
|
29
35
|
const MDX_ESM_KEYWORD = /^(?<keyword>import|export)\b/gmu;
|
|
30
|
-
// Backtick code — inline spans and fences alike — is already literal in MDX,
|
|
31
|
-
// and entities are NOT decoded inside it, so escaping there would render the
|
|
32
|
-
// entity text verbatim (`/pets/{petId}`). Matching any balanced
|
|
33
|
-
// backtick run covers `code`, ``code``, and ```fences``` in one shot. Both
|
|
34
|
-
// runs are pinned by the backtick lookarounds: CommonMark pairs a span only
|
|
35
|
-
// with an *equal-length* run, so without them a lone backtick would "close" on
|
|
36
|
-
// the first backtick of a longer fence run — leaving `{` in the real prose
|
|
37
|
-
// unescaped (a compile error) and escaping entities into the fence body.
|
|
38
|
-
const BACKTICK_CODE = /(?<!`)(?<bt>`+)(?!`)[\s\S]*?(?<!`)\k<bt>(?!`)/gu;
|
|
39
|
-
|
|
40
36
|
const escapeProse = (text: string): string =>
|
|
41
37
|
text
|
|
42
|
-
.replace(MDX_UNSAFE, (char) => ENTITIES
|
|
38
|
+
.replace(MDX_UNSAFE, (char) => ENTITIES.get(char) ?? char)
|
|
43
39
|
.replace(
|
|
44
40
|
MDX_ESM_KEYWORD,
|
|
45
41
|
(keyword) => `&#${keyword.codePointAt(0)};${keyword.slice(1)}`
|
|
46
42
|
);
|
|
47
43
|
|
|
48
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* The source offset ranges of code constructs — inline spans and fences —
|
|
46
|
+
* that MDX treats as literal (entities are NOT decoded inside them, so
|
|
47
|
+
* escaping there would render `/pets/{petId}` verbatim). The ranges
|
|
48
|
+
* come from a CommonMark parse rather than fence-emulating regexes: the
|
|
49
|
+
* parser is the authority on equal-length backtick pairing, longer tilde
|
|
50
|
+
* closers, unclosed fences running to EOF, and fences nested in blockquotes —
|
|
51
|
+
* each of which the replaced regexes had to re-derive (two with a recorded
|
|
52
|
+
* bug history in this file).
|
|
53
|
+
*
|
|
54
|
+
* One CommonMark construct is deliberately *not* masked: indented code. MDX
|
|
55
|
+
* disables indented code blocks, so a 4-space-indented sample is a paragraph
|
|
56
|
+
* whose braces genuinely need escaping; fence-or-backtick is told apart from
|
|
57
|
+
* indentation by the construct's first character.
|
|
58
|
+
*/
|
|
59
|
+
const codeSpans = (text: string): [number, number][] => {
|
|
60
|
+
const spans: [number, number][] = [];
|
|
61
|
+
const collect = (node: Nodes): void => {
|
|
62
|
+
if (node.type === "inlineCode" || node.type === "code") {
|
|
63
|
+
// fromMarkdown always stamps positions; -1 is an unreachable guard.
|
|
64
|
+
const start = node.position?.start.offset ?? -1;
|
|
65
|
+
const end = node.position?.end.offset ?? -1;
|
|
66
|
+
const head = text.slice(Math.max(start, 0), Math.max(end, 0)).trimStart();
|
|
67
|
+
if (
|
|
68
|
+
start >= 0 &&
|
|
69
|
+
(node.type === "inlineCode" ||
|
|
70
|
+
head.startsWith("`") ||
|
|
71
|
+
head.startsWith("~"))
|
|
72
|
+
) {
|
|
73
|
+
spans.push([start, end]);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if ("children" in node) {
|
|
78
|
+
for (const child of node.children) {
|
|
79
|
+
collect(child);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
collect(fromMarkdown(text));
|
|
84
|
+
return spans;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Escape MDX-special syntax in prose while leaving code verbatim. */
|
|
49
88
|
const mdxSafe = (text: string): string => {
|
|
50
89
|
let out = "";
|
|
51
90
|
let cursor = 0;
|
|
52
|
-
for (const
|
|
53
|
-
const start = match.index ?? 0;
|
|
91
|
+
for (const [start, end] of codeSpans(text)) {
|
|
54
92
|
out += escapeProse(text.slice(cursor, start));
|
|
55
|
-
out +=
|
|
56
|
-
cursor =
|
|
93
|
+
out += text.slice(start, end);
|
|
94
|
+
cursor = end;
|
|
57
95
|
}
|
|
58
96
|
return out + escapeProse(text.slice(cursor));
|
|
59
97
|
};
|
|
60
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
|
+
|
|
61
113
|
/** Frontmatter + body for one operation or overview page. */
|
|
62
114
|
export interface RenderedPage {
|
|
63
|
-
data:
|
|
115
|
+
data: RenderedPageData;
|
|
64
116
|
body: string;
|
|
65
117
|
}
|
|
66
118
|
|
|
@@ -70,33 +122,36 @@ export interface RenderedPage {
|
|
|
70
122
|
// `description`: the prose already renders in the body, and a `description`
|
|
71
123
|
// frontmatter field would print it a second time as the page subtitle.
|
|
72
124
|
const META_DESCRIPTION_MAX = 160;
|
|
73
|
-
const PARAGRAPH_BREAK = /\n\s*\n/u;
|
|
74
|
-
const MARKDOWN_LINK = /\[(?<text>[^\]]*)\]\([^)]*\)/gu;
|
|
75
|
-
const MARKDOWN_MARKS = /[*_`#>]/gu;
|
|
76
125
|
const WHITESPACE = /\s+/gu;
|
|
77
126
|
const TRAILING_WORD = /\s+\S*$/u;
|
|
78
127
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Flatten markdown prose to its first paragraph as single-line plain text,
|
|
130
|
+
* via a real parse (`mdast-util-to-string`). The regex strip this replaces
|
|
131
|
+
* was lossy on literal prose — `snake_case` → `snakecase`, `C#` → `C` — and
|
|
132
|
+
* these strings ship as `seo.description` meta tags. A description with no
|
|
133
|
+
* paragraph (say, only a heading or list) falls back to its first block.
|
|
134
|
+
*/
|
|
135
|
+
const plainProse = (markdown: string): string => {
|
|
136
|
+
const tree = fromMarkdown(markdown);
|
|
137
|
+
const first =
|
|
138
|
+
tree.children.find((node) => node.type === "paragraph") ?? tree.children[0];
|
|
139
|
+
return first ? mdastToString(first).replace(WHITESPACE, " ").trim() : "";
|
|
140
|
+
};
|
|
86
141
|
|
|
87
|
-
/** Cap `text` at `max`
|
|
142
|
+
/** Cap `text` at `max` display columns, cutting on a word boundary. */
|
|
88
143
|
const clip = (text: string, max: number): string => {
|
|
89
144
|
if (max <= 0) {
|
|
90
145
|
return "";
|
|
91
146
|
}
|
|
92
|
-
if (text
|
|
147
|
+
if (stringWidth(text) <= max) {
|
|
93
148
|
return text;
|
|
94
149
|
}
|
|
95
|
-
const head = text
|
|
150
|
+
const head = columnsPrefix(text, max - 1);
|
|
96
151
|
const onWordBoundary = head.replace(TRAILING_WORD, "");
|
|
97
152
|
// One very long token — an endpoint path has no spaces — would be dropped
|
|
98
153
|
// whole, leaving a stub. Hard-cut it instead of losing it.
|
|
99
|
-
return `${onWordBoundary
|
|
154
|
+
return `${stringWidth(onWordBoundary) >= max / 2 ? onWordBoundary : head}…`;
|
|
100
155
|
};
|
|
101
156
|
|
|
102
157
|
const apiName = (spec: ApiSpecData): string => spec.title || spec.label;
|
|
@@ -110,11 +165,14 @@ const operationDescription = (
|
|
|
110
165
|
spec: ApiSpecData,
|
|
111
166
|
operation: ApiOperationRef
|
|
112
167
|
): string => {
|
|
113
|
-
|
|
114
|
-
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.`;
|
|
115
173
|
const prose = clip(
|
|
116
174
|
plainProse(operation.description || operation.summary),
|
|
117
|
-
META_DESCRIPTION_MAX - suffix
|
|
175
|
+
META_DESCRIPTION_MAX - stringWidth(suffix) - 1
|
|
118
176
|
);
|
|
119
177
|
return clip([prose, suffix].filter(Boolean).join(" "), META_DESCRIPTION_MAX);
|
|
120
178
|
};
|
|
@@ -141,22 +199,35 @@ export const operationMdx = (
|
|
|
141
199
|
operation.description.trim() === operation.summary.trim()
|
|
142
200
|
? ""
|
|
143
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
|
+
}
|
|
144
222
|
return {
|
|
145
223
|
body: withDescription(
|
|
146
224
|
description,
|
|
147
225
|
`<Operation source="${spec.slug}" id="${operation.key}" />`
|
|
148
226
|
),
|
|
149
227
|
data: {
|
|
150
|
-
...
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
...(reference?.includeInSearch === false ? { exclude: true } : {}),
|
|
154
|
-
tags: [operation.tag, method],
|
|
155
|
-
},
|
|
156
|
-
seo: {
|
|
157
|
-
description: operationDescription(spec, operation),
|
|
158
|
-
...(reference?.noindex ? { noindex: true } : {}),
|
|
159
|
-
},
|
|
228
|
+
...flags,
|
|
229
|
+
search: { ...searchFlags, tags: [operation.tag, method] },
|
|
230
|
+
seo,
|
|
160
231
|
sidebar: { badge: method, label: operation.summary || operation.path },
|
|
161
232
|
title,
|
|
162
233
|
// Signals the two-column API layout (request panel instead of the TOC).
|
|
@@ -214,6 +285,21 @@ export const overviewMdx = (
|
|
|
214
285
|
].join("\n\n")
|
|
215
286
|
);
|
|
216
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
|
+
}
|
|
217
303
|
return {
|
|
218
304
|
body: [
|
|
219
305
|
withDescription(
|
|
@@ -223,16 +309,8 @@ export const overviewMdx = (
|
|
|
223
309
|
...tagSections,
|
|
224
310
|
].join("\n\n"),
|
|
225
311
|
data: {
|
|
226
|
-
...
|
|
227
|
-
|
|
228
|
-
? { search: { exclude: true } }
|
|
229
|
-
: {}),
|
|
230
|
-
seo: {
|
|
231
|
-
description:
|
|
232
|
-
clip(plainProse(spec.description), META_DESCRIPTION_MAX) ||
|
|
233
|
-
`${apiName(spec)} API reference.`,
|
|
234
|
-
...(reference?.noindex ? { noindex: true } : {}),
|
|
235
|
-
},
|
|
312
|
+
...flags,
|
|
313
|
+
seo,
|
|
236
314
|
sidebar: { label: "Overview" },
|
|
237
315
|
title: apiName(spec),
|
|
238
316
|
},
|