blume 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +1137 -722
- package/dist/cli/index.js.map +28 -23
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/project.d.ts +12 -2
- package/dist/types/core/schema.d.ts +154 -15
- package/dist/types/core/types.d.ts +7 -0
- package/docs/advanced/api-reference.mdx +33 -23
- package/docs/advanced/bridge.mdx +74 -0
- package/docs/advanced/meta.ts +8 -1
- package/docs/advanced/migrate.mdx +119 -0
- package/docs/configuration/index.mdx +1 -1
- package/docs/content/components.mdx +55 -2
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/syntax.mdx +2 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +29 -1
- package/docs/reference/frontmatter.mdx +5 -0
- package/package.json +11 -1
- package/src/astro/generate.ts +18 -8
- package/src/astro/templates.ts +28 -4
- package/src/cli/commands/build.ts +107 -63
- package/src/cli/commands/check.ts +20 -0
- package/src/cli/dev-lock.ts +13 -5
- package/src/cli/prepare.ts +3 -0
- package/src/components/BlumePage.astro +6 -0
- package/src/components/Icon.astro +13 -10
- package/src/components/content/ApiField.astro +75 -0
- package/src/components/content/ParamField.astro +39 -0
- package/src/components/content/RequestField.astro +23 -0
- package/src/components/content/ResponseField.astro +23 -0
- package/src/components/content/Step.astro +1 -1
- package/src/components/layout/Breadcrumbs.astro +7 -2
- package/src/components/layout/NavTree.astro +24 -8
- package/src/components/layout/RootLayout.astro +56 -34
- package/src/components/layout/Search.astro +1 -1
- package/src/components/openapi/ApiOverview.astro +84 -0
- package/src/components/openapi/MethodBadge.astro +28 -0
- package/src/components/openapi/Operation.astro +140 -0
- package/src/components/openapi/ParametersTable.astro +97 -0
- package/src/components/openapi/RequestBody.astro +58 -0
- package/src/components/openapi/RequestPanel.astro +169 -0
- package/src/components/openapi/Responses.astro +91 -0
- package/src/components/openapi/SchemaProperty.astro +118 -0
- package/src/components/openapi/SchemaTable.astro +86 -0
- package/src/components/openapi/helpers.ts +238 -0
- package/src/components/openapi/panel.ts +59 -0
- package/src/components/openapi/snippets.ts +201 -0
- package/src/core/builtin-tags.ts +5 -0
- package/src/core/data.ts +2 -0
- package/src/core/project-graph.ts +5 -1
- package/src/core/project.ts +25 -3
- package/src/core/schema.ts +47 -6
- package/src/core/sources/mintlify.ts +1 -1
- package/src/core/sources/resolve.ts +28 -6
- package/src/core/types.ts +7 -0
- package/src/migrate/mintlify/config.ts +153 -1
- package/src/migrate/mintlify/content.ts +8 -2
- package/src/migrate/mintlify/index.ts +58 -1
- package/src/openapi/model.ts +174 -0
- package/src/openapi/parse.ts +48 -0
- package/src/openapi/references.ts +164 -0
- package/src/openapi/render-mdx.ts +76 -0
- package/src/openapi/scalar.ts +15 -103
- package/src/openapi/source.ts +140 -0
- package/src/registry/eject.ts +15 -2
- package/src/theme/chrome-icons.ts +22 -0
- package/src/theme/icons.ts +151 -161
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { highlightCode } from "../../markdown/index.ts";
|
|
3
|
+
import { exampleValue, type SchemaLike, toJson } from "./helpers.ts";
|
|
4
|
+
import type { RequestSample, SampleLanguage } from "./snippets.ts";
|
|
5
|
+
|
|
6
|
+
interface MediaTypeLike {
|
|
7
|
+
schema?: SchemaLike;
|
|
8
|
+
example?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ResponseLike {
|
|
12
|
+
description?: string;
|
|
13
|
+
content?: Record<string, MediaTypeLike>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
sample: RequestSample;
|
|
18
|
+
languages: SampleLanguage[];
|
|
19
|
+
responses: Record<string, ResponseLike>;
|
|
20
|
+
schemas: Record<string, SchemaLike>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { sample, languages, responses, schemas } = Astro.props;
|
|
24
|
+
|
|
25
|
+
// A `.prose` wrapper gives Shiki its scoped token colours; the global style at
|
|
26
|
+
// the foot of this file strips the standalone code block's own box (border,
|
|
27
|
+
// injected copy button, language label) so the code sits flush inside the one
|
|
28
|
+
// panel border.
|
|
29
|
+
const CODE_WRAP = "prose max-w-none text-xs";
|
|
30
|
+
const TAB_CLASS =
|
|
31
|
+
"-mb-px cursor-pointer border-transparent border-b-2 bg-transparent py-2 font-medium text-muted-foreground text-xs transition-colors hover:text-foreground aria-[selected=true]:border-accent aria-[selected=true]:text-foreground";
|
|
32
|
+
const HEADING = "mb-2 font-semibold text-foreground text-sm";
|
|
33
|
+
|
|
34
|
+
const requestSamples = await Promise.all(
|
|
35
|
+
languages.map(async (language) => ({
|
|
36
|
+
html: await highlightCode(language.build(sample), language.lang, {
|
|
37
|
+
icons: false,
|
|
38
|
+
}),
|
|
39
|
+
id: language.id,
|
|
40
|
+
label: language.label,
|
|
41
|
+
}))
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const responseEntries = await Promise.all(
|
|
45
|
+
Object.entries(responses).map(async ([status, response]) => {
|
|
46
|
+
const media =
|
|
47
|
+
Object.entries(response.content ?? {}).find(([type]) =>
|
|
48
|
+
type.includes("json")
|
|
49
|
+
)?.[1] ?? Object.values(response.content ?? {})[0];
|
|
50
|
+
const example = media
|
|
51
|
+
? (media.example ?? exampleValue(media.schema, schemas))
|
|
52
|
+
: undefined;
|
|
53
|
+
const html =
|
|
54
|
+
example === undefined || example === null
|
|
55
|
+
? null
|
|
56
|
+
: await highlightCode(toJson(example), "json", { icons: false });
|
|
57
|
+
return { description: response.description ?? "", html, status };
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
<div class="not-prose flex flex-col gap-6">
|
|
63
|
+
{
|
|
64
|
+
requestSamples.length > 0 && (
|
|
65
|
+
<div>
|
|
66
|
+
<div aria-level="3" class={HEADING} role="heading">
|
|
67
|
+
Request
|
|
68
|
+
</div>
|
|
69
|
+
<blume-panel-tabs class="block overflow-hidden rounded-blume border border-border bg-background">
|
|
70
|
+
<div class="flex items-center justify-between gap-2 border-border border-b px-3">
|
|
71
|
+
<div class="flex gap-4" role="tablist">
|
|
72
|
+
{requestSamples.map((entry, index) => (
|
|
73
|
+
<button
|
|
74
|
+
aria-selected={index === 0 ? "true" : "false"}
|
|
75
|
+
class={TAB_CLASS}
|
|
76
|
+
data-panel-tab={entry.id}
|
|
77
|
+
role="tab"
|
|
78
|
+
type="button"
|
|
79
|
+
>
|
|
80
|
+
{entry.label}
|
|
81
|
+
</button>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
<button
|
|
85
|
+
aria-label="Copy request"
|
|
86
|
+
class="group shrink-0 cursor-pointer rounded px-1.5 py-1 text-muted-foreground text-xs hover:text-foreground"
|
|
87
|
+
data-panel-copy
|
|
88
|
+
type="button"
|
|
89
|
+
>
|
|
90
|
+
<span class="group-data-[copied]:hidden">Copy</span>
|
|
91
|
+
<span class="hidden group-data-[copied]:inline">Copied</span>
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
{requestSamples.map((entry, index) => (
|
|
95
|
+
<div
|
|
96
|
+
class:list={[index === 0 ? "" : "hidden", CODE_WRAP]}
|
|
97
|
+
data-panel={entry.id}
|
|
98
|
+
>
|
|
99
|
+
<Fragment set:html={entry.html} />
|
|
100
|
+
</div>
|
|
101
|
+
))}
|
|
102
|
+
</blume-panel-tabs>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
{
|
|
107
|
+
responseEntries.length > 0 && (
|
|
108
|
+
<div>
|
|
109
|
+
<div aria-level="3" class={HEADING} role="heading">
|
|
110
|
+
Response
|
|
111
|
+
</div>
|
|
112
|
+
<blume-panel-tabs class="block overflow-hidden rounded-blume border border-border bg-background">
|
|
113
|
+
<div class="flex flex-wrap gap-4 border-border border-b px-3" role="tablist">
|
|
114
|
+
{responseEntries.map((entry, index) => (
|
|
115
|
+
<button
|
|
116
|
+
aria-selected={index === 0 ? "true" : "false"}
|
|
117
|
+
class={`${TAB_CLASS} font-mono`}
|
|
118
|
+
data-panel-tab={entry.status}
|
|
119
|
+
role="tab"
|
|
120
|
+
type="button"
|
|
121
|
+
>
|
|
122
|
+
{entry.status}
|
|
123
|
+
</button>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
{responseEntries.map((entry, index) => (
|
|
127
|
+
<div
|
|
128
|
+
class:list={[index === 0 ? "" : "hidden"]}
|
|
129
|
+
data-panel={entry.status}
|
|
130
|
+
>
|
|
131
|
+
{entry.html ? (
|
|
132
|
+
<div class={CODE_WRAP}>
|
|
133
|
+
<Fragment set:html={entry.html} />
|
|
134
|
+
</div>
|
|
135
|
+
) : (
|
|
136
|
+
<div class="px-3 py-4 text-muted-foreground text-xs">
|
|
137
|
+
{entry.description || "No example response."}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
))}
|
|
142
|
+
</blume-panel-tabs>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<script>
|
|
149
|
+
import "./panel.ts";
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<style is:global>
|
|
153
|
+
/* Strip the standalone code block's own chrome inside a panel: the border,
|
|
154
|
+
margin, radius, and the copy button + language label the prose code theme
|
|
155
|
+
adds — the panel supplies a single border and its own copy button. */
|
|
156
|
+
blume-panel-tabs pre.astro-code {
|
|
157
|
+
margin: 0 !important;
|
|
158
|
+
border: 0 !important;
|
|
159
|
+
border-radius: 0 !important;
|
|
160
|
+
background: transparent !important;
|
|
161
|
+
padding: 0.75rem 1rem !important;
|
|
162
|
+
}
|
|
163
|
+
blume-panel-tabs pre.astro-code::before {
|
|
164
|
+
content: none !important;
|
|
165
|
+
}
|
|
166
|
+
blume-panel-tabs [data-blume-copy] {
|
|
167
|
+
display: none !important;
|
|
168
|
+
}
|
|
169
|
+
</style>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { SchemaLike } from "./helpers.ts";
|
|
3
|
+
import SchemaTable from "./SchemaTable.astro";
|
|
4
|
+
|
|
5
|
+
interface MediaTypeLike {
|
|
6
|
+
schema?: SchemaLike;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ResponseLike {
|
|
10
|
+
description?: string;
|
|
11
|
+
content?: Record<string, MediaTypeLike>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
responses: Record<string, ResponseLike>;
|
|
16
|
+
schemas: Record<string, SchemaLike>;
|
|
17
|
+
expandAll?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { responses, schemas, expandAll = false } = Astro.props;
|
|
21
|
+
|
|
22
|
+
const statusColor = (status: string): string => {
|
|
23
|
+
if (status.startsWith("2")) {
|
|
24
|
+
return "bg-green-500/15 text-green-700 dark:text-green-300";
|
|
25
|
+
}
|
|
26
|
+
if (status.startsWith("3")) {
|
|
27
|
+
return "bg-blue-500/15 text-blue-700 dark:text-blue-300";
|
|
28
|
+
}
|
|
29
|
+
if (status.startsWith("4")) {
|
|
30
|
+
return "bg-orange-500/15 text-orange-700 dark:text-orange-300";
|
|
31
|
+
}
|
|
32
|
+
if (status.startsWith("5")) {
|
|
33
|
+
return "bg-red-500/15 text-red-700 dark:text-red-300";
|
|
34
|
+
}
|
|
35
|
+
return "bg-muted text-muted-foreground";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const items = Object.entries(responses);
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
items.length > 0 && (
|
|
43
|
+
<section class="mt-8">
|
|
44
|
+
<div
|
|
45
|
+
aria-level="2"
|
|
46
|
+
class="mb-3 font-semibold text-foreground text-lg"
|
|
47
|
+
role="heading"
|
|
48
|
+
>
|
|
49
|
+
Responses
|
|
50
|
+
</div>
|
|
51
|
+
<div class="flex flex-col gap-4">
|
|
52
|
+
{items.map(([status, response]) => {
|
|
53
|
+
const schema = (
|
|
54
|
+
Object.entries(response.content ?? {}).find(([type]) =>
|
|
55
|
+
type.includes("json")
|
|
56
|
+
)?.[1] ?? Object.values(response.content ?? {})[0]
|
|
57
|
+
)?.schema;
|
|
58
|
+
return (
|
|
59
|
+
<div class="rounded-blume border border-border p-4">
|
|
60
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
61
|
+
<span
|
|
62
|
+
class:list={[
|
|
63
|
+
"not-prose inline-flex items-center rounded-md px-2 py-0.5 font-mono font-semibold text-xs",
|
|
64
|
+
statusColor(status),
|
|
65
|
+
]}
|
|
66
|
+
>
|
|
67
|
+
{status}
|
|
68
|
+
</span>
|
|
69
|
+
{response.description && (
|
|
70
|
+
<span
|
|
71
|
+
class="text-muted-foreground text-sm"
|
|
72
|
+
set:text={response.description}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
{schema && (
|
|
77
|
+
<div class="mt-3">
|
|
78
|
+
<SchemaTable
|
|
79
|
+
expandAll={expandAll}
|
|
80
|
+
schema={schema}
|
|
81
|
+
schemas={schemas}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
})}
|
|
88
|
+
</div>
|
|
89
|
+
</section>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
import {
|
|
3
|
+
constraints,
|
|
4
|
+
isNullable,
|
|
5
|
+
refName,
|
|
6
|
+
resolveSchema,
|
|
7
|
+
type SchemaLike,
|
|
8
|
+
typeLabel,
|
|
9
|
+
} from "./helpers.ts";
|
|
10
|
+
import SchemaTable from "./SchemaTable.astro";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
name: string;
|
|
14
|
+
schema: SchemaLike;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
schemas: Record<string, SchemaLike>;
|
|
17
|
+
seen?: string[];
|
|
18
|
+
expandAll?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
name,
|
|
23
|
+
schema,
|
|
24
|
+
required = false,
|
|
25
|
+
schemas,
|
|
26
|
+
seen = [],
|
|
27
|
+
expandAll = false,
|
|
28
|
+
} = Astro.props;
|
|
29
|
+
|
|
30
|
+
const refLabel = typeof schema.$ref === "string" ? refName(schema.$ref) : null;
|
|
31
|
+
const circular = refLabel !== null && seen.includes(refLabel);
|
|
32
|
+
const resolved = resolveSchema(schemas, schema);
|
|
33
|
+
|
|
34
|
+
const type = typeLabel(schema, schemas);
|
|
35
|
+
const description = resolved.description ?? schema.description ?? "";
|
|
36
|
+
const deprecated = resolved.deprecated === true;
|
|
37
|
+
const nullable = isNullable(resolved);
|
|
38
|
+
const enumValues = Array.isArray(resolved.enum) ? resolved.enum : null;
|
|
39
|
+
const limits = constraints(resolved);
|
|
40
|
+
|
|
41
|
+
const types = Array.isArray(resolved.type) ? resolved.type : [resolved.type];
|
|
42
|
+
const isArray = types.includes("array");
|
|
43
|
+
const items = isArray ? resolveSchema(schemas, resolved.items) : null;
|
|
44
|
+
|
|
45
|
+
const hasObjectShape = (candidate: SchemaLike): boolean =>
|
|
46
|
+
Boolean(
|
|
47
|
+
candidate.properties ||
|
|
48
|
+
candidate.allOf ||
|
|
49
|
+
candidate.oneOf ||
|
|
50
|
+
candidate.anyOf ||
|
|
51
|
+
typeof candidate.$ref === "string"
|
|
52
|
+
);
|
|
53
|
+
const expandable =
|
|
54
|
+
!circular && (hasObjectShape(resolved) || Boolean(items && hasObjectShape(items)));
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
<div class="border-border border-t py-3 first:border-t-0 last:pb-0">
|
|
58
|
+
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
59
|
+
<code class="font-mono text-foreground text-sm">{name}</code>
|
|
60
|
+
<span class="text-muted-foreground text-xs"
|
|
61
|
+
>{type}{nullable ? " | null" : ""}</span
|
|
62
|
+
>
|
|
63
|
+
{
|
|
64
|
+
required && (
|
|
65
|
+
<span class="font-medium text-[0.625rem] text-red-600 uppercase tracking-wide dark:text-red-400">
|
|
66
|
+
required
|
|
67
|
+
</span>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
{
|
|
71
|
+
deprecated && (
|
|
72
|
+
<span class="font-medium text-[0.625rem] text-muted-foreground uppercase tracking-wide line-through">
|
|
73
|
+
deprecated
|
|
74
|
+
</span>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
</div>
|
|
78
|
+
{
|
|
79
|
+
description && (
|
|
80
|
+
<div class="mt-1 text-muted-foreground text-sm" set:text={description} />
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
{
|
|
84
|
+
limits.length > 0 && (
|
|
85
|
+
<div class="mt-1 text-muted-foreground text-xs">{limits.join(" · ")}</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
{
|
|
89
|
+
enumValues && (
|
|
90
|
+
<div class="mt-1 flex flex-wrap items-center gap-1 text-xs">
|
|
91
|
+
<span class="text-muted-foreground">Allowed:</span>
|
|
92
|
+
{enumValues.map((value) => (
|
|
93
|
+
<code class="rounded bg-muted px-1 py-0.5 text-foreground">
|
|
94
|
+
{String(value)}
|
|
95
|
+
</code>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
{
|
|
101
|
+
expandable && (
|
|
102
|
+
<details class="group mt-2" open={expandAll}>
|
|
103
|
+
<summary class="cursor-pointer select-none text-accent text-xs hover:underline">
|
|
104
|
+
<span class="group-open:hidden">Show properties</span>
|
|
105
|
+
<span class="hidden group-open:inline">Hide properties</span>
|
|
106
|
+
</summary>
|
|
107
|
+
<div class="mt-2 border-border border-l pl-4">
|
|
108
|
+
<SchemaTable
|
|
109
|
+
schema={schema}
|
|
110
|
+
schemas={schemas}
|
|
111
|
+
seen={seen}
|
|
112
|
+
expandAll={expandAll}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
</details>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
</div>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
import {
|
|
3
|
+
objectProperties,
|
|
4
|
+
refName,
|
|
5
|
+
resolveSchema,
|
|
6
|
+
type SchemaLike,
|
|
7
|
+
typeLabel,
|
|
8
|
+
} from "./helpers.ts";
|
|
9
|
+
import SchemaProperty from "./SchemaProperty.astro";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
schema: SchemaLike;
|
|
13
|
+
schemas: Record<string, SchemaLike>;
|
|
14
|
+
/** `$ref` names already on the current branch, to break circular schemas. */
|
|
15
|
+
seen?: string[];
|
|
16
|
+
expandAll?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { schema, schemas, seen = [], expandAll = false } = Astro.props;
|
|
20
|
+
|
|
21
|
+
// Track the ref so a self-referential model stops instead of recursing forever.
|
|
22
|
+
const refLabel =
|
|
23
|
+
typeof schema.$ref === "string" ? refName(schema.$ref) : null;
|
|
24
|
+
const circular = refLabel !== null && seen.includes(refLabel);
|
|
25
|
+
const nextSeen = refLabel ? [...seen, refLabel] : seen;
|
|
26
|
+
const resolved = circular ? schema : resolveSchema(schemas, schema);
|
|
27
|
+
|
|
28
|
+
const types = Array.isArray(resolved.type) ? resolved.type : [resolved.type];
|
|
29
|
+
const isArray = types.includes("array");
|
|
30
|
+
// Keep array items unresolved so a self-referential item `$ref` stays trackable
|
|
31
|
+
// via `seen` — resolving here would drop the name and loop forever.
|
|
32
|
+
const items = isArray ? (resolved.items ?? null) : null;
|
|
33
|
+
const branches = resolved.oneOf ?? resolved.anyOf ?? null;
|
|
34
|
+
|
|
35
|
+
const { properties, required } = circular
|
|
36
|
+
? { properties: [] as [string, SchemaLike][], required: new Set<string>() }
|
|
37
|
+
: objectProperties(resolved, schemas);
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
circular ? (
|
|
42
|
+
<div class="text-muted-foreground text-sm">
|
|
43
|
+
Circular reference to <code class="text-foreground">{refLabel}</code>.
|
|
44
|
+
</div>
|
|
45
|
+
) : isArray ? (
|
|
46
|
+
<div>
|
|
47
|
+
<div class="mb-2 text-muted-foreground text-xs">
|
|
48
|
+
Array of <code class="text-foreground">{typeLabel(items ?? {}, schemas)}</code>
|
|
49
|
+
</div>
|
|
50
|
+
{items && (
|
|
51
|
+
<Astro.self schema={items} schemas={schemas} seen={nextSeen} expandAll={expandAll} />
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
) : branches ? (
|
|
55
|
+
<div class="flex flex-col gap-3">
|
|
56
|
+
<div class="text-muted-foreground text-xs">
|
|
57
|
+
{resolved.oneOf ? "One of" : "Any of"}:
|
|
58
|
+
</div>
|
|
59
|
+
{branches.map((branch, index) => (
|
|
60
|
+
<div class="rounded-blume border border-border p-3">
|
|
61
|
+
<div class="mb-2 font-medium text-foreground text-xs">
|
|
62
|
+
{typeLabel(branch, schemas) || `Option ${index + 1}`}
|
|
63
|
+
</div>
|
|
64
|
+
<Astro.self schema={branch} schemas={schemas} seen={nextSeen} expandAll={expandAll} />
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
) : properties.length > 0 ? (
|
|
69
|
+
<div class="not-prose">
|
|
70
|
+
{properties.map(([name, prop]) => (
|
|
71
|
+
<SchemaProperty
|
|
72
|
+
name={name}
|
|
73
|
+
schema={prop}
|
|
74
|
+
required={required.has(name)}
|
|
75
|
+
schemas={schemas}
|
|
76
|
+
seen={nextSeen}
|
|
77
|
+
expandAll={expandAll}
|
|
78
|
+
/>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
) : (
|
|
82
|
+
<div class="text-muted-foreground text-sm">
|
|
83
|
+
<code class="text-foreground">{typeLabel(resolved, schemas)}</code>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime helpers for the OpenAPI components. These operate on the parsed spec
|
|
3
|
+
* behind the `blume:openapi` alias — resolving `$ref`s (kept intact at parse
|
|
4
|
+
* time to avoid circular graphs), labelling types, and generating request
|
|
5
|
+
* examples and code samples. Pure and dependency-free so they run in the browser
|
|
6
|
+
* build with no server-only imports.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A permissive view of an OpenAPI 3.1 schema — only the fields we render. */
|
|
10
|
+
export interface SchemaLike {
|
|
11
|
+
$ref?: string;
|
|
12
|
+
type?: string | string[];
|
|
13
|
+
format?: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
properties?: Record<string, SchemaLike>;
|
|
17
|
+
required?: string[];
|
|
18
|
+
items?: SchemaLike;
|
|
19
|
+
enum?: unknown[];
|
|
20
|
+
const?: unknown;
|
|
21
|
+
default?: unknown;
|
|
22
|
+
example?: unknown;
|
|
23
|
+
examples?: unknown[];
|
|
24
|
+
allOf?: SchemaLike[];
|
|
25
|
+
oneOf?: SchemaLike[];
|
|
26
|
+
anyOf?: SchemaLike[];
|
|
27
|
+
additionalProperties?: boolean | SchemaLike;
|
|
28
|
+
nullable?: boolean;
|
|
29
|
+
deprecated?: boolean;
|
|
30
|
+
readOnly?: boolean;
|
|
31
|
+
writeOnly?: boolean;
|
|
32
|
+
minimum?: number;
|
|
33
|
+
maximum?: number;
|
|
34
|
+
minLength?: number;
|
|
35
|
+
maxLength?: number;
|
|
36
|
+
minItems?: number;
|
|
37
|
+
maxItems?: number;
|
|
38
|
+
pattern?: string;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REF_PATTERN = /#\/components\/schemas\/(?<name>[^/]+)$/u;
|
|
43
|
+
|
|
44
|
+
/** The display name of a `$ref`, e.g. `#/components/schemas/Pet` -> `Pet`. */
|
|
45
|
+
export const refName = (ref: string): string =>
|
|
46
|
+
REF_PATTERN.exec(ref)?.groups?.name ?? ref.split("/").at(-1) ?? ref;
|
|
47
|
+
|
|
48
|
+
/** Resolve one level of `$ref` against the document's component schemas. */
|
|
49
|
+
export const resolveSchema = (
|
|
50
|
+
schemas: Record<string, SchemaLike>,
|
|
51
|
+
schema?: SchemaLike
|
|
52
|
+
): SchemaLike => {
|
|
53
|
+
if (!schema) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
if (typeof schema.$ref === "string") {
|
|
57
|
+
const name = REF_PATTERN.exec(schema.$ref)?.groups?.name;
|
|
58
|
+
if (name && schemas[name]) {
|
|
59
|
+
return schemas[name];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return schema;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const nonNullTypes = (type: string | string[] | undefined): string[] => {
|
|
66
|
+
if (!type) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
return (Array.isArray(type) ? type : [type]).filter((t) => t !== "null");
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** A short, human-readable type label for a schema row. */
|
|
73
|
+
export const typeLabel = (
|
|
74
|
+
schema: SchemaLike,
|
|
75
|
+
schemas: Record<string, SchemaLike>
|
|
76
|
+
): string => {
|
|
77
|
+
if (typeof schema.$ref === "string") {
|
|
78
|
+
return refName(schema.$ref);
|
|
79
|
+
}
|
|
80
|
+
if (schema.oneOf || schema.anyOf) {
|
|
81
|
+
const branches = schema.oneOf ?? schema.anyOf ?? [];
|
|
82
|
+
const labels = branches.map((branch) => typeLabel(branch, schemas));
|
|
83
|
+
return [...new Set(labels)].join(" | ") || "any";
|
|
84
|
+
}
|
|
85
|
+
if (schema.allOf) {
|
|
86
|
+
return "object";
|
|
87
|
+
}
|
|
88
|
+
const types = nonNullTypes(schema.type);
|
|
89
|
+
if (types.includes("array")) {
|
|
90
|
+
const item = resolveSchema(schemas, schema.items);
|
|
91
|
+
return `${typeLabel(item, schemas)}[]`;
|
|
92
|
+
}
|
|
93
|
+
const base = types[0] ?? (schema.properties ? "object" : "any");
|
|
94
|
+
return schema.format ? `${base}<${schema.format}>` : base;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Whether this schema is nullable (3.0 `nullable` or a 3.1 `"null"` in `type`). */
|
|
98
|
+
export const isNullable = (schema: SchemaLike): boolean =>
|
|
99
|
+
schema.nullable === true ||
|
|
100
|
+
(Array.isArray(schema.type) && schema.type.includes("null"));
|
|
101
|
+
|
|
102
|
+
/** Human-readable validation constraints for a schema, in display order. */
|
|
103
|
+
export const constraints = (schema: SchemaLike): string[] => {
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
const numeric: [keyof SchemaLike, string][] = [
|
|
106
|
+
["minimum", "min"],
|
|
107
|
+
["maximum", "max"],
|
|
108
|
+
["minLength", "min length"],
|
|
109
|
+
["maxLength", "max length"],
|
|
110
|
+
["minItems", "min items"],
|
|
111
|
+
["maxItems", "max items"],
|
|
112
|
+
];
|
|
113
|
+
for (const [key, label] of numeric) {
|
|
114
|
+
const value = schema[key];
|
|
115
|
+
if (typeof value === "number") {
|
|
116
|
+
out.push(`${label} ${value}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (typeof schema.pattern === "string") {
|
|
120
|
+
out.push(`matches ${schema.pattern}`);
|
|
121
|
+
}
|
|
122
|
+
if (schema.default !== undefined) {
|
|
123
|
+
out.push(`default: ${JSON.stringify(schema.default)}`);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The object properties a schema exposes, merging `allOf` branches so an
|
|
130
|
+
* `allOf`-composed model still lists every field. Returns the properties plus
|
|
131
|
+
* the merged required set.
|
|
132
|
+
*/
|
|
133
|
+
export const objectProperties = (
|
|
134
|
+
schema: SchemaLike,
|
|
135
|
+
schemas: Record<string, SchemaLike>
|
|
136
|
+
): { properties: [string, SchemaLike][]; required: Set<string> } => {
|
|
137
|
+
const properties = new Map<string, SchemaLike>();
|
|
138
|
+
const required = new Set<string>();
|
|
139
|
+
|
|
140
|
+
const collect = (node: SchemaLike): void => {
|
|
141
|
+
const resolved = resolveSchema(schemas, node);
|
|
142
|
+
for (const name of resolved.required ?? []) {
|
|
143
|
+
required.add(name);
|
|
144
|
+
}
|
|
145
|
+
for (const [name, prop] of Object.entries(resolved.properties ?? {})) {
|
|
146
|
+
properties.set(name, prop);
|
|
147
|
+
}
|
|
148
|
+
for (const branch of resolved.allOf ?? []) {
|
|
149
|
+
collect(branch);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
collect(schema);
|
|
154
|
+
return { properties: [...properties.entries()], required };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Sentinel: no explicit example is declared on a schema. */
|
|
158
|
+
const NO_VALUE = Symbol("no-value");
|
|
159
|
+
|
|
160
|
+
/** The declared example/default/enum for a schema, or {@link NO_VALUE}. */
|
|
161
|
+
const explicitExample = (schema: SchemaLike): unknown => {
|
|
162
|
+
if (schema.example !== undefined) {
|
|
163
|
+
return schema.example;
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(schema.examples) && schema.examples.length > 0) {
|
|
166
|
+
return schema.examples[0];
|
|
167
|
+
}
|
|
168
|
+
if (schema.default !== undefined) {
|
|
169
|
+
return schema.default;
|
|
170
|
+
}
|
|
171
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
172
|
+
return schema.enum[0];
|
|
173
|
+
}
|
|
174
|
+
return NO_VALUE;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** A placeholder value for a primitive (leaf) schema. */
|
|
178
|
+
const primitiveExample = (
|
|
179
|
+
types: string[],
|
|
180
|
+
format: string | undefined
|
|
181
|
+
): unknown => {
|
|
182
|
+
if (types.includes("number") || types.includes("integer")) {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
if (types.includes("boolean")) {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
if (format === "date-time") {
|
|
189
|
+
return "2024-01-01T00:00:00Z";
|
|
190
|
+
}
|
|
191
|
+
return format ? `<${format}>` : "string";
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build a representative example value for a schema (honoring `example` /
|
|
196
|
+
* `default` / `enum` first). A `seen` set of `$ref`s guards against the circular
|
|
197
|
+
* schemas that keeping refs intact allows.
|
|
198
|
+
*/
|
|
199
|
+
export const exampleValue = (
|
|
200
|
+
schema: SchemaLike | undefined,
|
|
201
|
+
schemas: Record<string, SchemaLike>,
|
|
202
|
+
seen = new Set<string>()
|
|
203
|
+
): unknown => {
|
|
204
|
+
if (!schema) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
if (typeof schema.$ref === "string") {
|
|
208
|
+
if (seen.has(schema.$ref)) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
seen.add(schema.$ref);
|
|
212
|
+
return exampleValue(resolveSchema(schemas, schema), schemas, seen);
|
|
213
|
+
}
|
|
214
|
+
const explicit = explicitExample(schema);
|
|
215
|
+
if (explicit !== NO_VALUE) {
|
|
216
|
+
return explicit;
|
|
217
|
+
}
|
|
218
|
+
const branch = schema.oneOf?.[0] ?? schema.anyOf?.[0];
|
|
219
|
+
if (branch) {
|
|
220
|
+
return exampleValue(branch, schemas, seen);
|
|
221
|
+
}
|
|
222
|
+
const types = nonNullTypes(schema.type);
|
|
223
|
+
if (types.includes("array")) {
|
|
224
|
+
return [exampleValue(schema.items, schemas, seen)];
|
|
225
|
+
}
|
|
226
|
+
if (types.includes("object") || schema.properties || schema.allOf) {
|
|
227
|
+
const out: Record<string, unknown> = {};
|
|
228
|
+
for (const [name, prop] of objectProperties(schema, schemas).properties) {
|
|
229
|
+
out[name] = exampleValue(prop, schemas, new Set(seen));
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
return primitiveExample(types, schema.format);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Pretty-print a JSON value for an example/code block. */
|
|
237
|
+
export const toJson = (value: unknown): string =>
|
|
238
|
+
JSON.stringify(value, null, 2);
|