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/og/card.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { RenderOptions } from "takumi-js";
|
|
|
6
6
|
import { container, googleFonts, image, text } from "takumi-js/helpers";
|
|
7
7
|
import type { FontSubset, GoogleFontFamily, Node } from "takumi-js/helpers";
|
|
8
8
|
|
|
9
|
-
import { ACCENTS } from "../theme/palette.ts";
|
|
9
|
+
import { ACCENTS, isAccentPreset } from "../theme/palette.ts";
|
|
10
10
|
import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "./dimensions.ts";
|
|
11
11
|
|
|
12
12
|
/** A local font file registered with the OG card renderer, read at build. */
|
|
@@ -61,10 +61,10 @@ export interface OgFontFamilies {
|
|
|
61
61
|
// shows (a separate hand-synced hex palette used to drift: the card's "blue"
|
|
62
62
|
// was Tailwind's, not Blume's). Anything else is handed to Takumi as-is, and
|
|
63
63
|
// a genuinely malformed value fails the build with a parse error naming it.
|
|
64
|
-
// `
|
|
65
|
-
// prototype chain.
|
|
64
|
+
// `isAccentPreset` keeps a preset name like "constructor" from resolving up
|
|
65
|
+
// the prototype chain.
|
|
66
66
|
const resolveAccent = (accent: string): string =>
|
|
67
|
-
|
|
67
|
+
isAccentPreset(accent) ? ACCENTS[accent] : accent;
|
|
68
68
|
|
|
69
69
|
export interface OgCardPalette {
|
|
70
70
|
accent?: string;
|
|
@@ -153,6 +153,9 @@ const loadFonts = (
|
|
|
153
153
|
const key = JSON.stringify(fonts);
|
|
154
154
|
let pending = fontSubsetCache.get(key);
|
|
155
155
|
if (!pending) {
|
|
156
|
+
// SAFETY: OgFont's weight strings are documented as variable ranges like
|
|
157
|
+
// "100..900" (GoogleFontFamily's WeightRange); Takumi validates the value
|
|
158
|
+
// at fetch time and fails the build naming a malformed one.
|
|
156
159
|
pending = googleFonts(fonts as GoogleFontFamily[]);
|
|
157
160
|
fontSubsetCache.set(key, pending);
|
|
158
161
|
}
|
|
@@ -165,13 +168,29 @@ const loadFonts = (
|
|
|
165
168
|
* registers each file once across a build's per-page renders; a missing file
|
|
166
169
|
* rejects at first use, failing the build with the path in the cause.
|
|
167
170
|
*/
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
/** A lazily-read local font file, in the shape `render` accepts for `fonts`. */
|
|
172
|
+
interface LocalFontSource {
|
|
173
|
+
data: () => Promise<Buffer>;
|
|
174
|
+
key: string;
|
|
175
|
+
name: string;
|
|
176
|
+
weight?: number;
|
|
177
|
+
style?: "normal" | "italic";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const localFontLoader = (font: OgLocalFont): LocalFontSource => {
|
|
181
|
+
const loader: LocalFontSource = {
|
|
182
|
+
data: () => readFile(font.src),
|
|
183
|
+
key: font.src,
|
|
184
|
+
name: font.name,
|
|
185
|
+
};
|
|
186
|
+
if (font.weight !== undefined) {
|
|
187
|
+
loader.weight = font.weight;
|
|
188
|
+
}
|
|
189
|
+
if (font.style !== undefined) {
|
|
190
|
+
loader.style = font.style;
|
|
191
|
+
}
|
|
192
|
+
return loader;
|
|
193
|
+
};
|
|
175
194
|
|
|
176
195
|
// Light neutral scale mirrored from the docs homepage theme tokens:
|
|
177
196
|
// FOREGROUND = --foreground, MUTED = --muted-foreground, FAINT = that lighter,
|
package/src/og/derive.ts
CHANGED
|
@@ -13,7 +13,6 @@ import type {
|
|
|
13
13
|
FontsConfig,
|
|
14
14
|
FontValue,
|
|
15
15
|
LocalFontConfig,
|
|
16
|
-
RemoteFontConfig,
|
|
17
16
|
} from "../theme/fonts.ts";
|
|
18
17
|
import { GOOGLE_FONTS, isFontSlug } from "../theme/fonts.ts";
|
|
19
18
|
import type { OgFont, OgFontFamilies, OgLocalFont } from "./card.ts";
|
|
@@ -31,6 +30,23 @@ const CARD_WEIGHTS = [400, 600];
|
|
|
31
30
|
const absoluteSrc = (root: string, src: string): string =>
|
|
32
31
|
isAbsolute(src) ? src : join(root, src);
|
|
33
32
|
|
|
33
|
+
/** A concrete numeric face weight (as opposed to a variable-range string). */
|
|
34
|
+
const isNumericWeight = (
|
|
35
|
+
weight: number | string | undefined
|
|
36
|
+
): weight is number => typeof weight === "number";
|
|
37
|
+
|
|
38
|
+
/** A variable-range weight spec string, e.g. `"100..900"`. */
|
|
39
|
+
const isRangeWeight = (weight: number | string | undefined): weight is string =>
|
|
40
|
+
typeof weight === "string";
|
|
41
|
+
|
|
42
|
+
/** A theme role configured as a font slug / family-name string. */
|
|
43
|
+
const isFontName = (value: FontValue): value is string =>
|
|
44
|
+
typeof value === "string";
|
|
45
|
+
|
|
46
|
+
/** An OG font entry that reads a local file (as opposed to a Google family). */
|
|
47
|
+
const isLocalOgFont = (font: OgFont): font is OgLocalFont =>
|
|
48
|
+
typeof font !== "string" && "src" in font;
|
|
49
|
+
|
|
34
50
|
/**
|
|
35
51
|
* The weight spec to fetch for a derived Google family: the declared weights
|
|
36
52
|
* the card uses, the declared numeric weights otherwise, a lone variable
|
|
@@ -39,9 +55,7 @@ const absoluteSrc = (root: string, src: string): string =>
|
|
|
39
55
|
const googleWeights = (
|
|
40
56
|
weights: (number | string)[]
|
|
41
57
|
): number[] | string | undefined => {
|
|
42
|
-
const numbers = weights.filter(
|
|
43
|
-
(weight): weight is number => typeof weight === "number"
|
|
44
|
-
);
|
|
58
|
+
const numbers = weights.filter(isNumericWeight);
|
|
45
59
|
const used = numbers.filter((weight) => CARD_WEIGHTS.includes(weight));
|
|
46
60
|
if (used.length > 0) {
|
|
47
61
|
return used;
|
|
@@ -50,7 +64,7 @@ const googleWeights = (
|
|
|
50
64
|
return numbers;
|
|
51
65
|
}
|
|
52
66
|
const [first] = weights;
|
|
53
|
-
return weights.length === 1 &&
|
|
67
|
+
return weights.length === 1 && isRangeWeight(first) ? first : undefined;
|
|
54
68
|
};
|
|
55
69
|
|
|
56
70
|
const googleOgFont = (name: string, weights: (number | string)[]): OgFont => {
|
|
@@ -60,15 +74,19 @@ const googleOgFont = (name: string, weights: (number | string)[]): OgFont => {
|
|
|
60
74
|
|
|
61
75
|
/** Per-variant local entries for the renderer (paths made absolute). */
|
|
62
76
|
const localOgFonts = (font: LocalFontConfig, root: string): OgLocalFont[] =>
|
|
63
|
-
font.variants.map((variant) =>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
font.variants.map((variant) => {
|
|
78
|
+
const entry: OgLocalFont = {
|
|
79
|
+
name: font.name,
|
|
80
|
+
src: absoluteSrc(root, variant.src),
|
|
81
|
+
};
|
|
82
|
+
const withWeight: OgLocalFont = isNumericWeight(variant.weight)
|
|
83
|
+
? { ...entry, weight: variant.weight }
|
|
84
|
+
: entry;
|
|
67
85
|
// Takumi's per-face style is normal/italic; oblique falls back to the file.
|
|
68
|
-
|
|
69
|
-
? { style: variant.style }
|
|
70
|
-
:
|
|
71
|
-
})
|
|
86
|
+
return variant.style === "normal" || variant.style === "italic"
|
|
87
|
+
? { ...withWeight, style: variant.style }
|
|
88
|
+
: withWeight;
|
|
89
|
+
});
|
|
72
90
|
|
|
73
91
|
/**
|
|
74
92
|
* The card fonts for one theme role, or null when the role can't flow into
|
|
@@ -76,7 +94,7 @@ const localOgFonts = (font: LocalFontConfig, root: string): OgLocalFont[] =>
|
|
|
76
94
|
* `googleFonts` only speaks Google's css2 endpoint).
|
|
77
95
|
*/
|
|
78
96
|
const roleFonts = (value: FontValue, root: string): OgFont[] | null => {
|
|
79
|
-
if (
|
|
97
|
+
if (isFontName(value)) {
|
|
80
98
|
if (!isFontSlug(value)) {
|
|
81
99
|
return null;
|
|
82
100
|
}
|
|
@@ -86,16 +104,15 @@ const roleFonts = (value: FontValue, root: string): OgFont[] | null => {
|
|
|
86
104
|
if ("variants" in value) {
|
|
87
105
|
return localOgFonts(value, root);
|
|
88
106
|
}
|
|
89
|
-
|
|
90
|
-
if ((remote.provider ?? "google") !== "google") {
|
|
107
|
+
if ((value.provider ?? "google") !== "google") {
|
|
91
108
|
return null;
|
|
92
109
|
}
|
|
93
|
-
return [googleOgFont(
|
|
110
|
+
return [googleOgFont(value.name, value.weights ?? CARD_WEIGHTS)];
|
|
94
111
|
};
|
|
95
112
|
|
|
96
113
|
/** The family name a theme role registers under. */
|
|
97
114
|
const roleFamily = (value: FontValue): string | null => {
|
|
98
|
-
if (
|
|
115
|
+
if (isFontName(value)) {
|
|
99
116
|
return isFontSlug(value) ? GOOGLE_FONTS[value].family : null;
|
|
100
117
|
}
|
|
101
118
|
return value.name;
|
|
@@ -136,18 +153,17 @@ export const deriveOgFonts = (
|
|
|
136
153
|
}
|
|
137
154
|
}
|
|
138
155
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
156
|
+
const result: DerivedOgFonts = { fonts: derived };
|
|
157
|
+
if (families.title || families.body) {
|
|
158
|
+
result.families = families;
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
143
161
|
};
|
|
144
162
|
|
|
145
163
|
/** Explicit `seo.og.fonts` with local `src` paths resolved to absolute. */
|
|
146
164
|
export const resolveOgFontSources = (fonts: OgFont[], root: string): OgFont[] =>
|
|
147
165
|
fonts.map((font) =>
|
|
148
|
-
|
|
149
|
-
? { ...font, src: absoluteSrc(root, font.src) }
|
|
150
|
-
: font
|
|
166
|
+
isLocalOgFont(font) ? { ...font, src: absoluteSrc(root, font.src) } : font
|
|
151
167
|
);
|
|
152
168
|
|
|
153
169
|
/**
|
|
@@ -185,14 +201,14 @@ export const missingFontFiles = (
|
|
|
185
201
|
): string[] => {
|
|
186
202
|
const sources: string[] = [];
|
|
187
203
|
for (const value of Object.values(options.themeFonts ?? {})) {
|
|
188
|
-
if (
|
|
204
|
+
if (!isFontName(value) && "variants" in value) {
|
|
189
205
|
sources.push(
|
|
190
206
|
...value.variants.map((variant) => absoluteSrc(root, variant.src))
|
|
191
207
|
);
|
|
192
208
|
}
|
|
193
209
|
}
|
|
194
210
|
for (const font of options.ogFonts) {
|
|
195
|
-
if (
|
|
211
|
+
if (isLocalOgFont(font)) {
|
|
196
212
|
sources.push(absoluteSrc(root, font.src));
|
|
197
213
|
}
|
|
198
214
|
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type { ApiOperationRef, ApiTagRef } from "./model.ts";
|
|
2
|
+
import { operationCollector, operationKey } from "./model.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Blume's own AsyncAPI model — the second front-end of the API reference
|
|
6
|
+
* pipeline. Specs are normalized to AsyncAPI 3.x at parse time (see
|
|
7
|
+
* `parseAsyncApiSpec` in `parse.ts`), so this module only handles one shape:
|
|
8
|
+
* top-level `operations` with `action: send | receive` pointing at `channels`.
|
|
9
|
+
* Internal `$ref`s are deliberately left in place, mirroring `model.ts` — the
|
|
10
|
+
* components resolve them lazily, which keeps circular schemas serializable
|
|
11
|
+
* and lets type labels keep their `#/components/schemas/<name>` names.
|
|
12
|
+
* Browser-safe: no Node imports (the components import from here).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The two AsyncAPI 3.x operation actions, from the application's perspective. */
|
|
16
|
+
export const ASYNCAPI_ACTIONS = ["send", "receive"] as const;
|
|
17
|
+
|
|
18
|
+
export type AsyncApiAction = (typeof ASYNCAPI_ACTIONS)[number];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A value carried by a parsed spec document: JSON-compatible data whose exact
|
|
22
|
+
* position in the document is not modeled (extension fields, bindings,
|
|
23
|
+
* protocol-specific extras). `undefined` covers absent optional members.
|
|
24
|
+
*/
|
|
25
|
+
export type AsyncApiSpecValue =
|
|
26
|
+
| string
|
|
27
|
+
| number
|
|
28
|
+
| boolean
|
|
29
|
+
| null
|
|
30
|
+
| undefined
|
|
31
|
+
| AsyncApiSpecValue[]
|
|
32
|
+
| { [key: string]: AsyncApiSpecValue };
|
|
33
|
+
|
|
34
|
+
/** A permissive view of an AsyncAPI reference object. */
|
|
35
|
+
export interface AsyncApiRefLike {
|
|
36
|
+
$ref?: string;
|
|
37
|
+
[key: string]: AsyncApiSpecValue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A permissive view of an AsyncAPI 3.x channel — only the fields we render. */
|
|
41
|
+
export interface AsyncApiChannelObject {
|
|
42
|
+
address?: string | null;
|
|
43
|
+
title?: string;
|
|
44
|
+
summary?: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
messages?: Record<string, AsyncApiRefLike>;
|
|
47
|
+
parameters?: Record<string, AsyncApiRefLike>;
|
|
48
|
+
servers?: AsyncApiRefLike[];
|
|
49
|
+
bindings?: Record<string, Record<string, AsyncApiSpecValue>>;
|
|
50
|
+
[key: string]: AsyncApiSpecValue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A permissive view of an AsyncAPI 3.x operation — only the fields we render. */
|
|
54
|
+
export interface AsyncApiOperationObject {
|
|
55
|
+
action?: string;
|
|
56
|
+
channel?: AsyncApiRefLike;
|
|
57
|
+
title?: string;
|
|
58
|
+
summary?: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
deprecated?: boolean;
|
|
61
|
+
tags?: { name?: string; description?: string }[];
|
|
62
|
+
security?: AsyncApiRefLike[];
|
|
63
|
+
messages?: AsyncApiRefLike[];
|
|
64
|
+
bindings?: Record<string, Record<string, AsyncApiSpecValue>>;
|
|
65
|
+
[key: string]: AsyncApiSpecValue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A permissive view of an AsyncAPI 3.x server object. */
|
|
69
|
+
export interface AsyncApiServerObject {
|
|
70
|
+
host?: string;
|
|
71
|
+
protocol?: string;
|
|
72
|
+
pathname?: string;
|
|
73
|
+
description?: string;
|
|
74
|
+
security?: AsyncApiRefLike[];
|
|
75
|
+
[key: string]: AsyncApiSpecValue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A normalized AsyncAPI 3.x document, internal `$ref`s intact. */
|
|
79
|
+
export interface AsyncApiDocument {
|
|
80
|
+
asyncapi?: string;
|
|
81
|
+
info?: {
|
|
82
|
+
title?: string;
|
|
83
|
+
version?: string;
|
|
84
|
+
description?: string;
|
|
85
|
+
tags?: { name?: string; description?: string }[];
|
|
86
|
+
[key: string]: AsyncApiSpecValue;
|
|
87
|
+
};
|
|
88
|
+
defaultContentType?: string;
|
|
89
|
+
servers?: Record<string, AsyncApiServerObject>;
|
|
90
|
+
channels?: Record<string, AsyncApiChannelObject>;
|
|
91
|
+
operations?: Record<string, AsyncApiOperationObject>;
|
|
92
|
+
components?: Record<string, Record<string, AsyncApiSpecValue>>;
|
|
93
|
+
[key: string]: AsyncApiSpecValue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Decode a JSON-pointer token: `user~1signedup` -> `user/signedup`. */
|
|
97
|
+
const unescapePointer = (token: string): string =>
|
|
98
|
+
token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
99
|
+
|
|
100
|
+
const CHANNEL_REF = /^#\/channels\/(?<id>.+)$/u;
|
|
101
|
+
|
|
102
|
+
const isString = (value: AsyncApiSpecValue): value is string =>
|
|
103
|
+
typeof value === "string";
|
|
104
|
+
|
|
105
|
+
/** The channel id an operation's `channel.$ref` points at, if resolvable. */
|
|
106
|
+
export const channelIdOf = (channel?: AsyncApiRefLike): string | undefined => {
|
|
107
|
+
const id = CHANNEL_REF.exec(channel?.$ref ?? "")?.groups?.id;
|
|
108
|
+
return id === undefined ? undefined : unescapePointer(id);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A channel's display address. AsyncAPI 3.x allows `address: null` (unknown at
|
|
113
|
+
* design time) — fall back to the channel id so the operation still shows
|
|
114
|
+
* where it lives.
|
|
115
|
+
*/
|
|
116
|
+
export const channelAddress = (
|
|
117
|
+
channelId: string,
|
|
118
|
+
channel?: AsyncApiChannelObject
|
|
119
|
+
): string => {
|
|
120
|
+
const address = channel?.address;
|
|
121
|
+
return isString(address) && address !== "" ? address : channelId;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const isObject = (
|
|
125
|
+
value: AsyncApiSpecValue
|
|
126
|
+
): value is Record<string, AsyncApiSpecValue> =>
|
|
127
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
128
|
+
|
|
129
|
+
const COMPONENT_SECTION_REF =
|
|
130
|
+
/^#\/components\/(?<section>channels|operations)\/(?<name>[^/]+)$/u;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Inline top-level `channels`/`operations` entries declared as Reference
|
|
134
|
+
* Objects (`{ $ref: "#/components/channels/…" }` — the spec's reuse pattern)
|
|
135
|
+
* by replacing them with their components target. Runs before trait merging
|
|
136
|
+
* so an inlined operation's traits merge exactly like an inline one's.
|
|
137
|
+
* Unresolvable refs stay in place; the extractor reports them.
|
|
138
|
+
*/
|
|
139
|
+
const inlineComponentRefs = (document: AsyncApiDocument): void => {
|
|
140
|
+
const maps: [Record<string, AsyncApiSpecValue> | undefined, string][] = [
|
|
141
|
+
[document.channels, "channels"],
|
|
142
|
+
[document.operations, "operations"],
|
|
143
|
+
];
|
|
144
|
+
for (const [map, section] of maps) {
|
|
145
|
+
if (!isObject(map)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const table = document.components?.[section];
|
|
149
|
+
for (const [id, entry] of Object.entries(map)) {
|
|
150
|
+
if (!isObject(entry) || !isString(entry.$ref)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const groups = COMPONENT_SECTION_REF.exec(entry.$ref)?.groups;
|
|
154
|
+
if (groups?.section !== section) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const resolved = isObject(table)
|
|
158
|
+
? table[unescapePointer(groups.name ?? "")]
|
|
159
|
+
: undefined;
|
|
160
|
+
if (isObject(resolved) && !isString(resolved.$ref)) {
|
|
161
|
+
map[id] = resolved;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const TRAIT_REF =
|
|
168
|
+
/^#\/components\/(?<section>operationTraits|messageTraits)\/(?<name>[^/]+)$/u;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Merge `traits` into their operation/message objects, in declaration order,
|
|
172
|
+
* with the object's own properties taking precedence (the spec's merge rule;
|
|
173
|
+
* applied shallowly, which covers the fields traits carry in practice —
|
|
174
|
+
* bindings, security, tags, headers). Runs once at parse time so the
|
|
175
|
+
* serialized document and every downstream consumer are trait-free; the
|
|
176
|
+
* `traits` key itself is dropped. Unresolvable trait `$ref`s are skipped.
|
|
177
|
+
*/
|
|
178
|
+
export const applyAsyncApiTraits = (
|
|
179
|
+
document: AsyncApiDocument
|
|
180
|
+
): AsyncApiDocument => {
|
|
181
|
+
const resolveTrait = (
|
|
182
|
+
trait: AsyncApiSpecValue
|
|
183
|
+
): Record<string, AsyncApiSpecValue> | undefined => {
|
|
184
|
+
if (!isObject(trait)) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
if (!isString(trait.$ref)) {
|
|
188
|
+
return trait;
|
|
189
|
+
}
|
|
190
|
+
const groups = TRAIT_REF.exec(trait.$ref)?.groups;
|
|
191
|
+
const section = groups?.section
|
|
192
|
+
? document.components?.[groups.section]
|
|
193
|
+
: undefined;
|
|
194
|
+
const resolved = section?.[groups?.name ?? ""];
|
|
195
|
+
return isObject(resolved) ? resolved : undefined;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const mergeTraits = (node: Record<string, AsyncApiSpecValue>): void => {
|
|
199
|
+
const { traits } = node;
|
|
200
|
+
if (!Array.isArray(traits)) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
delete node.traits;
|
|
204
|
+
const merged: Record<string, AsyncApiSpecValue> = {};
|
|
205
|
+
for (const trait of traits) {
|
|
206
|
+
Object.assign(merged, resolveTrait(trait));
|
|
207
|
+
}
|
|
208
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
209
|
+
if (!(key in node)) {
|
|
210
|
+
node[key] = value;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
for (const operation of Object.values(document.operations ?? {})) {
|
|
216
|
+
if (isObject(operation)) {
|
|
217
|
+
mergeTraits(operation);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const channelMaps = [
|
|
221
|
+
...Object.values(document.channels ?? {}),
|
|
222
|
+
// Reusable channels under components carry messages too; their traits
|
|
223
|
+
// must merge the same way so a resolved component channel renders alike.
|
|
224
|
+
...Object.values(document.components?.channels ?? {}),
|
|
225
|
+
];
|
|
226
|
+
const messageMaps = [
|
|
227
|
+
...channelMaps.map((channel) =>
|
|
228
|
+
isObject(channel) ? channel.messages : undefined
|
|
229
|
+
),
|
|
230
|
+
document.components?.messages,
|
|
231
|
+
];
|
|
232
|
+
for (const messages of messageMaps) {
|
|
233
|
+
if (!isObject(messages)) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
for (const message of Object.values(messages)) {
|
|
237
|
+
// A channel message that is itself a `$ref` resolves to a components
|
|
238
|
+
// message, which this loop also visits — don't merge through the ref.
|
|
239
|
+
if (isObject(message) && !isString(message.$ref)) {
|
|
240
|
+
mergeTraits(message);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return document;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The parse-time normalization pass: component channel/operation refs inlined,
|
|
249
|
+
* then traits merged. The serialized document and every downstream consumer
|
|
250
|
+
* see plain channel/operation objects, trait-free.
|
|
251
|
+
*/
|
|
252
|
+
export const normalizeAsyncApiDocument = (
|
|
253
|
+
document: AsyncApiDocument
|
|
254
|
+
): AsyncApiDocument => {
|
|
255
|
+
inlineComponentRefs(document);
|
|
256
|
+
return applyAsyncApiTraits(document);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve where one operation renders from: its action and its declared
|
|
261
|
+
* channel. A string return is the warning explaining why the operation can't
|
|
262
|
+
* appear in the reference.
|
|
263
|
+
*/
|
|
264
|
+
const operationSite = (
|
|
265
|
+
id: string,
|
|
266
|
+
operation: AsyncApiOperationObject,
|
|
267
|
+
channels: Record<string, AsyncApiChannelObject>
|
|
268
|
+
): { action: AsyncApiAction; channelId: string; address: string } | string => {
|
|
269
|
+
const action = ASYNCAPI_ACTIONS.find((a) => a === operation.action);
|
|
270
|
+
if (!action) {
|
|
271
|
+
return `Operation "${id}" declares no send/receive action; it is missing from the reference.`;
|
|
272
|
+
}
|
|
273
|
+
const channelId = channelIdOf(operation.channel);
|
|
274
|
+
const channel = channelId === undefined ? undefined : channels[channelId];
|
|
275
|
+
// A channel entry that is still a bare `$ref` survived normalization — its
|
|
276
|
+
// components target doesn't exist — so it renders nothing useful either.
|
|
277
|
+
if (channelId === undefined || !isObject(channel) || isString(channel.$ref)) {
|
|
278
|
+
return `Operation "${id}" references a channel that isn't declared under "channels"; it is missing from the reference.`;
|
|
279
|
+
}
|
|
280
|
+
return { action, address: channelAddress(channelId, channel), channelId };
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** The route-mapped operations, ordered tags, and skip warnings of one document. */
|
|
284
|
+
export interface AsyncApiOperationCatalog {
|
|
285
|
+
operations: ApiOperationRef[];
|
|
286
|
+
tags: ApiTagRef[];
|
|
287
|
+
warnings: string[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Flatten a normalized AsyncAPI 3.x document into a route-mapped operation
|
|
292
|
+
* list and its ordered tags — the AsyncAPI counterpart of `extractOperations`
|
|
293
|
+
* in `model.ts`, built on the same `operationCollector`. Operations group by
|
|
294
|
+
* their first tag; untagged operations fall back to their channel address, so
|
|
295
|
+
* a spec with no tags still gets one sidebar group per channel. Keys come from
|
|
296
|
+
* the operation id (the `operations` map key), which the official 2.x
|
|
297
|
+
* converter synthesizes deterministically (`<channel>.publish` /
|
|
298
|
+
* `<channel>.subscribe`) — so a 2.x spec and its converter-upgraded 3.x form
|
|
299
|
+
* yield identical URLs. An id that slugifies to nothing falls back to
|
|
300
|
+
* action + address, the same scheme `operationKey` uses for method + path.
|
|
301
|
+
*/
|
|
302
|
+
export const extractAsyncApiOperations = (
|
|
303
|
+
document: AsyncApiDocument,
|
|
304
|
+
baseRoute: string
|
|
305
|
+
): AsyncApiOperationCatalog => {
|
|
306
|
+
const warnings: string[] = [];
|
|
307
|
+
const tagMeta = new Map(
|
|
308
|
+
(document.info?.tags ?? [])
|
|
309
|
+
.filter(
|
|
310
|
+
(tag): tag is { name: string; description?: string } =>
|
|
311
|
+
typeof tag?.name === "string"
|
|
312
|
+
)
|
|
313
|
+
.map((tag): [string, string] => [tag.name, tag.description ?? ""])
|
|
314
|
+
);
|
|
315
|
+
const collector = operationCollector(baseRoute, tagMeta);
|
|
316
|
+
const channels = document.channels ?? {};
|
|
317
|
+
|
|
318
|
+
for (const [id, operation] of Object.entries(document.operations ?? {})) {
|
|
319
|
+
if (!isObject(operation)) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
// An operation entry still carrying a `$ref` is one normalization could
|
|
323
|
+
// not inline; name the real problem instead of the missing-action one.
|
|
324
|
+
if (isString(operation.$ref)) {
|
|
325
|
+
warnings.push(
|
|
326
|
+
`Operation "${id}" is a reference that doesn't resolve to a components operation; it is missing from the reference.`
|
|
327
|
+
);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const site = operationSite(id, operation, channels);
|
|
331
|
+
if (isString(site)) {
|
|
332
|
+
warnings.push(site);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const { action, address, channelId } = site;
|
|
336
|
+
const tag = operation.tags?.find(
|
|
337
|
+
(candidate): candidate is { name: string; description?: string } =>
|
|
338
|
+
typeof candidate?.name === "string"
|
|
339
|
+
)?.name;
|
|
340
|
+
collector.add({
|
|
341
|
+
channelId,
|
|
342
|
+
deprecated: operation.deprecated === true,
|
|
343
|
+
description: operation.description ?? "",
|
|
344
|
+
key: operationKey(action, address, id),
|
|
345
|
+
method: action,
|
|
346
|
+
operationId: id,
|
|
347
|
+
path: address,
|
|
348
|
+
summary: operation.title ?? operation.summary ?? "",
|
|
349
|
+
tag: tag ?? address,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { ...collector.finish(), warnings };
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/** Resolve the operation object for a ref out of its (AsyncAPI) document. */
|
|
357
|
+
export const asyncApiOperationObject = (
|
|
358
|
+
document: AsyncApiDocument,
|
|
359
|
+
ref: ApiOperationRef
|
|
360
|
+
): AsyncApiOperationObject | undefined => {
|
|
361
|
+
const operation =
|
|
362
|
+
ref.operationId === undefined
|
|
363
|
+
? undefined
|
|
364
|
+
: document.operations?.[ref.operationId];
|
|
365
|
+
return isObject(operation) ? operation : undefined;
|
|
366
|
+
};
|