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.
Files changed (67) hide show
  1. package/dist/cli/index.js +1137 -722
  2. package/dist/cli/index.js.map +28 -23
  3. package/dist/types/core/data.d.ts +2 -0
  4. package/dist/types/core/project.d.ts +12 -2
  5. package/dist/types/core/schema.d.ts +154 -15
  6. package/dist/types/core/types.d.ts +7 -0
  7. package/docs/advanced/api-reference.mdx +33 -23
  8. package/docs/advanced/bridge.mdx +74 -0
  9. package/docs/advanced/meta.ts +8 -1
  10. package/docs/advanced/migrate.mdx +119 -0
  11. package/docs/configuration/index.mdx +1 -1
  12. package/docs/content/components.mdx +55 -2
  13. package/docs/content/i18n.mdx +1 -1
  14. package/docs/content/syntax.mdx +2 -2
  15. package/docs/index.mdx +2 -2
  16. package/docs/reference/cli.mdx +29 -1
  17. package/docs/reference/frontmatter.mdx +5 -0
  18. package/package.json +11 -1
  19. package/src/astro/generate.ts +18 -8
  20. package/src/astro/templates.ts +28 -4
  21. package/src/cli/commands/build.ts +107 -63
  22. package/src/cli/commands/check.ts +20 -0
  23. package/src/cli/dev-lock.ts +13 -5
  24. package/src/cli/prepare.ts +3 -0
  25. package/src/components/BlumePage.astro +6 -0
  26. package/src/components/Icon.astro +13 -10
  27. package/src/components/content/ApiField.astro +75 -0
  28. package/src/components/content/ParamField.astro +39 -0
  29. package/src/components/content/RequestField.astro +23 -0
  30. package/src/components/content/ResponseField.astro +23 -0
  31. package/src/components/content/Step.astro +1 -1
  32. package/src/components/layout/Breadcrumbs.astro +7 -2
  33. package/src/components/layout/NavTree.astro +24 -8
  34. package/src/components/layout/RootLayout.astro +56 -34
  35. package/src/components/layout/Search.astro +1 -1
  36. package/src/components/openapi/ApiOverview.astro +84 -0
  37. package/src/components/openapi/MethodBadge.astro +28 -0
  38. package/src/components/openapi/Operation.astro +140 -0
  39. package/src/components/openapi/ParametersTable.astro +97 -0
  40. package/src/components/openapi/RequestBody.astro +58 -0
  41. package/src/components/openapi/RequestPanel.astro +169 -0
  42. package/src/components/openapi/Responses.astro +91 -0
  43. package/src/components/openapi/SchemaProperty.astro +118 -0
  44. package/src/components/openapi/SchemaTable.astro +86 -0
  45. package/src/components/openapi/helpers.ts +238 -0
  46. package/src/components/openapi/panel.ts +59 -0
  47. package/src/components/openapi/snippets.ts +201 -0
  48. package/src/core/builtin-tags.ts +5 -0
  49. package/src/core/data.ts +2 -0
  50. package/src/core/project-graph.ts +5 -1
  51. package/src/core/project.ts +25 -3
  52. package/src/core/schema.ts +47 -6
  53. package/src/core/sources/mintlify.ts +1 -1
  54. package/src/core/sources/resolve.ts +28 -6
  55. package/src/core/types.ts +7 -0
  56. package/src/migrate/mintlify/config.ts +153 -1
  57. package/src/migrate/mintlify/content.ts +8 -2
  58. package/src/migrate/mintlify/index.ts +58 -1
  59. package/src/openapi/model.ts +174 -0
  60. package/src/openapi/parse.ts +48 -0
  61. package/src/openapi/references.ts +164 -0
  62. package/src/openapi/render-mdx.ts +76 -0
  63. package/src/openapi/scalar.ts +15 -103
  64. package/src/openapi/source.ts +140 -0
  65. package/src/registry/eject.ts +15 -2
  66. package/src/theme/chrome-icons.ts +22 -0
  67. 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);