@writedocs/generator 0.1.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/LICENSE +15 -0
- package/README.md +17 -0
- package/astro.config.mjs +419 -0
- package/bin/writedocs.js +73 -0
- package/package.json +79 -0
- package/src/assets/wd_watermark.png +0 -0
- package/src/assets/wd_watermark_dark.png +0 -0
- package/src/cli/build-auth.js +53 -0
- package/src/cli/build.js +40 -0
- package/src/cli/dev.js +12 -0
- package/src/cli/generate-api-pages.js +359 -0
- package/src/cli/init.js +81 -0
- package/src/cli/preflight.js +40 -0
- package/src/cli/run-astro.js +57 -0
- package/src/cli/run-pagefind.js +66 -0
- package/src/cli/write-redirects-file.js +80 -0
- package/src/components/Accordion.astro +164 -0
- package/src/components/AccordionGroup.astro +40 -0
- package/src/components/ApiLangSelect.astro +168 -0
- package/src/components/ApiPlayground.astro +281 -0
- package/src/components/ApiReferencePanel.astro +1754 -0
- package/src/components/ApiSchemaField.astro +54 -0
- package/src/components/AppIcon.astro +32 -0
- package/src/components/Badge.astro +128 -0
- package/src/components/Callout.astro +168 -0
- package/src/components/Card.astro +136 -0
- package/src/components/CardGroup.astro +20 -0
- package/src/components/CodeGroup.astro +184 -0
- package/src/components/CopyPageMenu.astro +246 -0
- package/src/components/Danger.astro +12 -0
- package/src/components/Expandable.astro +126 -0
- package/src/components/Frame.astro +102 -0
- package/src/components/Hint.astro +99 -0
- package/src/components/Icon.astro +70 -0
- package/src/components/Image.astro +147 -0
- package/src/components/Info.astro +12 -0
- package/src/components/Note.astro +12 -0
- package/src/components/Parameter.astro +119 -0
- package/src/components/RequestExample.astro +33 -0
- package/src/components/ResponseExample.astro +19 -0
- package/src/components/Searchbar.astro +117 -0
- package/src/components/Step.astro +10 -0
- package/src/components/Steps.astro +32 -0
- package/src/components/Tab.astro +9 -0
- package/src/components/Tabs.astro +52 -0
- package/src/components/Tip.astro +12 -0
- package/src/components/Video.astro +135 -0
- package/src/components/Warning.astro +12 -0
- package/src/components/index.ts +48 -0
- package/src/content.config.ts +223 -0
- package/src/layout/BaseLayout.astro +750 -0
- package/src/layout/components/AnalyticsScripts.astro +77 -0
- package/src/layout/components/AskAiWidget.astro +37 -0
- package/src/layout/components/Breadcrumbs.astro +97 -0
- package/src/layout/components/ImageZoom.astro +19 -0
- package/src/layout/components/MobileMenu.astro +200 -0
- package/src/layout/components/NavTree.astro +351 -0
- package/src/layout/components/SearchModal.astro +42 -0
- package/src/layout/components/Sidebar.astro +122 -0
- package/src/layout/components/SiteFooter.astro +85 -0
- package/src/layout/components/TableOfContents.astro +117 -0
- package/src/layout/components/TopBar.astro +311 -0
- package/src/layout/styles/banner.css +44 -0
- package/src/layout/styles/base.css +234 -0
- package/src/layout/styles/dropdown.css +133 -0
- package/src/layout/styles/footer.css +108 -0
- package/src/layout/styles/image-zoom.css +50 -0
- package/src/layout/styles/mobile-menu.css +258 -0
- package/src/layout/styles/search-modal.css +122 -0
- package/src/layout/styles/topbar.css +437 -0
- package/src/lib/config.ts +2131 -0
- package/src/lib/mdx-auto-hydrate.js +70 -0
- package/src/lib/mdx-inject-builtins.js +87 -0
- package/src/lib/mdx-substitute-variables.js +66 -0
- package/src/lib/mdx-title-anchor-ids.js +84 -0
- package/src/lib/mermaid-rehype.js +72 -0
- package/src/lib/openapi-render.ts +479 -0
- package/src/lib/shiki-code-block.js +102 -0
- package/src/lib/shiki-copy-button.js +45 -0
- package/src/lib/styles-asset-integration.js +210 -0
- package/src/lib/writedocs-temp-dir.js +93 -0
- package/src/pages/404.astro +62 -0
- package/src/pages/[...slug].astro +1270 -0
- package/src/pages/[...slug].md.ts +78 -0
- package/src/pages/llms-full.txt.ts +71 -0
- package/src/pages/llms.txt.ts +141 -0
- package/src/scripts/banner.ts +20 -0
- package/src/scripts/dropdowns.ts +61 -0
- package/src/scripts/image-zoom.ts +66 -0
- package/src/scripts/mobile-menu.ts +55 -0
- package/src/scripts/search.ts +155 -0
- package/src/scripts/sidebar-scroll.ts +65 -0
- package/src/scripts/theme-toggle.ts +35 -0
- package/src/scripts/topbar-offset.ts +141 -0
- package/src/styles/global.css +18 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
// Rendering helpers for ApiPlayground.astro - mostly pure functions over
|
|
2
|
+
// an already-dereferenced OpenAPI operation (as written by
|
|
3
|
+
// src/cli/generate-api-pages.js to writedocsTempDir()'s
|
|
4
|
+
// openapi/<path>/operations/*.json), kept separate from the
|
|
5
|
+
// .astro component so the schema-walking logic (which has nothing to do
|
|
6
|
+
// with markup) is easy to read and test on its own. findOperationFile()
|
|
7
|
+
// below is the one exception (it touches the filesystem) - kept here
|
|
8
|
+
// anyway since it's used by exactly the same two consumers as
|
|
9
|
+
// operationFileName(), right next to it.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { writedocsTempDir } from './writedocs-temp-dir.js';
|
|
14
|
+
|
|
15
|
+
export interface OpenApiSchema {
|
|
16
|
+
type?: string;
|
|
17
|
+
properties?: Record<string, OpenApiSchema>;
|
|
18
|
+
items?: OpenApiSchema;
|
|
19
|
+
required?: string[];
|
|
20
|
+
allOf?: OpenApiSchema[];
|
|
21
|
+
oneOf?: OpenApiSchema[];
|
|
22
|
+
anyOf?: OpenApiSchema[];
|
|
23
|
+
enum?: unknown[];
|
|
24
|
+
example?: unknown;
|
|
25
|
+
default?: unknown;
|
|
26
|
+
description?: string;
|
|
27
|
+
format?: string;
|
|
28
|
+
nullable?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OpenApiParameter {
|
|
32
|
+
name: string;
|
|
33
|
+
in: 'path' | 'query' | 'header' | 'cookie';
|
|
34
|
+
required?: boolean;
|
|
35
|
+
description?: string;
|
|
36
|
+
schema?: OpenApiSchema;
|
|
37
|
+
example?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Mirrors operationFileName() in src/cli/generate-api-pages.js exactly -
|
|
41
|
+
* the pre-step wrote each operation's resolved JSON under
|
|
42
|
+
* writedocsTempDir()'s openapi/<path>/operations/ directory using this same naming scheme,
|
|
43
|
+
* so both ApiPlayground.astro and ApiReferencePanel.astro (which
|
|
44
|
+
* independently read that file rather than sharing one parsed value
|
|
45
|
+
* passed through props) need to compute the identical filename. */
|
|
46
|
+
export function operationFileName(method: string, urlPath: string): string {
|
|
47
|
+
return `${method.toLowerCase()}_${urlPath.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')}.json`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Locates a single operation's resolved JSON under writedocsTempDir()'s
|
|
51
|
+
* openapi/<path>/operations/ directory - one such directory exists per
|
|
52
|
+
* openapi group in writedocs.json (see generate-api-pages.js), each
|
|
53
|
+
* namespaced under that group's own `path`, so a page's `openapi:
|
|
54
|
+
* "METHOD /path"` frontmatter alone doesn't say which one to look in.
|
|
55
|
+
* Rather than threading a spec identifier through every page's
|
|
56
|
+
* frontmatter just for this lookup, this walks the (small - one
|
|
57
|
+
* directory per spec) openapi/ tree once per call, checking
|
|
58
|
+
* every "operations" directory it finds for the target filename. Returns
|
|
59
|
+
* null if no group's spec defines this operation (a stale/typo'd
|
|
60
|
+
* `openapi:` frontmatter value). */
|
|
61
|
+
export function findOperationFile(contentDir: string, method: string, urlPath: string): string | null {
|
|
62
|
+
const openapiRoot = path.join(writedocsTempDir(contentDir), 'openapi');
|
|
63
|
+
const filename = operationFileName(method, urlPath);
|
|
64
|
+
|
|
65
|
+
function walk(dir: string): string | null {
|
|
66
|
+
if (!fs.existsSync(dir)) return null;
|
|
67
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
68
|
+
const full = path.join(dir, entry.name);
|
|
69
|
+
if (!entry.isDirectory()) continue;
|
|
70
|
+
if (entry.name === 'operations') {
|
|
71
|
+
const candidate = path.join(full, filename);
|
|
72
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
73
|
+
continue; // an "operations" dir has no further subdirectories worth descending into
|
|
74
|
+
}
|
|
75
|
+
const found = walk(full);
|
|
76
|
+
if (found) return found;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return walk(openapiRoot);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A single named entry in a media-type object's `examples` map (OpenAPI's
|
|
85
|
+
// plural, named form - distinct from the singular `example` a schema or
|
|
86
|
+
// media-type object can also carry, already handled separately via
|
|
87
|
+
// exampleFromSchema()). Each key is the example's own name (not
|
|
88
|
+
// necessarily human-readable - `summary` is what a UI should actually
|
|
89
|
+
// display, falling back to the key itself when absent).
|
|
90
|
+
export interface OpenApiExample {
|
|
91
|
+
summary?: string;
|
|
92
|
+
description?: string;
|
|
93
|
+
value?: unknown;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface OpenApiOperation {
|
|
97
|
+
method: string;
|
|
98
|
+
path: string;
|
|
99
|
+
operationId: string | null;
|
|
100
|
+
summary: string | null;
|
|
101
|
+
description: string | null;
|
|
102
|
+
tags: string[];
|
|
103
|
+
parameters: OpenApiParameter[];
|
|
104
|
+
requestBody: {
|
|
105
|
+
required?: boolean;
|
|
106
|
+
content?: Record<string, { schema?: OpenApiSchema; examples?: Record<string, OpenApiExample> }>;
|
|
107
|
+
} | null;
|
|
108
|
+
responses: Record<string, { description?: string; content?: Record<string, { schema?: OpenApiSchema }> }>;
|
|
109
|
+
servers: { url: string }[];
|
|
110
|
+
security: { name: string; type: string; in?: string; scheme?: string }[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Merges `allOf` members into one flat {type, properties, required}
|
|
114
|
+
* shape - dereferencing (done once, up front, by generate-api-pages.js)
|
|
115
|
+
* resolves $ref pointers but doesn't collapse schema *composition*, so
|
|
116
|
+
* this still needs to happen at render time. `oneOf`/`anyOf` aren't
|
|
117
|
+
* merged (that would be misleading - they're alternatives, not a
|
|
118
|
+
* combination) - the first variant is shown, which covers the common
|
|
119
|
+
* case of "one of several near-identical response shapes" well enough
|
|
120
|
+
* for a reference view without building a full variant switcher. */
|
|
121
|
+
export function resolveSchema(schema: OpenApiSchema | undefined): OpenApiSchema {
|
|
122
|
+
if (!schema) return {};
|
|
123
|
+
if (schema.allOf) {
|
|
124
|
+
return schema.allOf.reduce<OpenApiSchema>(
|
|
125
|
+
(acc, member) => {
|
|
126
|
+
const resolved = resolveSchema(member);
|
|
127
|
+
return {
|
|
128
|
+
...acc,
|
|
129
|
+
...resolved,
|
|
130
|
+
properties: { ...acc.properties, ...resolved.properties },
|
|
131
|
+
required: [...(acc.required ?? []), ...(resolved.required ?? [])],
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
{ type: 'object', properties: {}, required: [] }
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (schema.oneOf?.[0]) return resolveSchema(schema.oneOf[0]);
|
|
138
|
+
if (schema.anyOf?.[0]) return resolveSchema(schema.anyOf[0]);
|
|
139
|
+
return schema;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function typeLabel(schema: OpenApiSchema): string {
|
|
143
|
+
const resolved = resolveSchema(schema);
|
|
144
|
+
if (resolved.type === 'array') return `${typeLabel(resolved.items ?? {})}[]`;
|
|
145
|
+
if (resolved.enum) return resolved.enum.map((v) => JSON.stringify(v)).join(' | ');
|
|
146
|
+
return resolved.format ? `${resolved.type}<${resolved.format}>` : (resolved.type ?? 'any');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface SchemaRow {
|
|
150
|
+
name: string;
|
|
151
|
+
type: string;
|
|
152
|
+
required: boolean;
|
|
153
|
+
description: string | null;
|
|
154
|
+
// A nested object's (or array-of-objects') own properties, one level
|
|
155
|
+
// down - empty for anything that bottoms out at a plain value. Used by
|
|
156
|
+
// ApiSchemaField.astro to decide whether a row needs an <Expandable>
|
|
157
|
+
// wrapping a recursive render of itself, rather than a flat
|
|
158
|
+
// depth-indented row (an earlier version of this returned one flat,
|
|
159
|
+
// pre-flattened array with a `depth` number instead - replaced once
|
|
160
|
+
// the reference design called for a real collapsible "Show/Hide
|
|
161
|
+
// properties" control per nested object, the same one already used for
|
|
162
|
+
// hand-written Parameter/Expandable content elsewhere on API pages,
|
|
163
|
+
// rather than everything permanently expanded and indented). */
|
|
164
|
+
children: SchemaRow[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Builds a *tree* of an object schema's properties, one SchemaRow per
|
|
168
|
+
* property with its own nested object/array-of-objects properties (if
|
|
169
|
+
* any) attached as `children` rather than flattened alongside it -
|
|
170
|
+
* ApiSchemaField.astro walks this recursively, rendering each level of
|
|
171
|
+
* `children` inside its own <Expandable>. Arrays of objects recurse into
|
|
172
|
+
* the array's own item schema (same as a plain nested object - the
|
|
173
|
+
* distinction between "one nested object" and "an array of them" isn't
|
|
174
|
+
* meaningful for *which properties* exist, only for `type`'s own label,
|
|
175
|
+
* see typeLabel() above); everything else bottoms out with an empty
|
|
176
|
+
* `children` array. */
|
|
177
|
+
export function schemaRows(schema: OpenApiSchema | undefined): SchemaRow[] {
|
|
178
|
+
const resolved = resolveSchema(schema);
|
|
179
|
+
const target = resolved.type === 'array' ? resolveSchema(resolved.items) : resolved;
|
|
180
|
+
if (!target.properties) return [];
|
|
181
|
+
const required = new Set(target.required ?? []);
|
|
182
|
+
return Object.entries(target.properties).map(([name, propSchema]) => {
|
|
183
|
+
const resolvedProp = resolveSchema(propSchema);
|
|
184
|
+
const children =
|
|
185
|
+
resolvedProp.type === 'object' || resolvedProp.type === 'array' ? schemaRows(propSchema) : [];
|
|
186
|
+
return {
|
|
187
|
+
name,
|
|
188
|
+
type: typeLabel(propSchema),
|
|
189
|
+
required: required.has(name),
|
|
190
|
+
description: propSchema.description ?? null,
|
|
191
|
+
children,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Builds a plausible example value for a schema - `example`/`default`
|
|
197
|
+
* win outright when present (most specs that care about this provide
|
|
198
|
+
* one), otherwise a type-appropriate placeholder is synthesized so the
|
|
199
|
+
* "Try it" body textarea and the static example block are never just
|
|
200
|
+
* empty for a schema that didn't bother to include examples. */
|
|
201
|
+
export function exampleFromSchema(schema: OpenApiSchema | undefined, depth = 0): unknown {
|
|
202
|
+
if (!schema) return null;
|
|
203
|
+
if (schema.example !== undefined) return schema.example;
|
|
204
|
+
if (schema.default !== undefined) return schema.default;
|
|
205
|
+
if (schema.enum?.[0] !== undefined) return schema.enum[0];
|
|
206
|
+
if (depth > 6) return null; // guard against a pathological/self-referential schema
|
|
207
|
+
|
|
208
|
+
const resolved = resolveSchema(schema);
|
|
209
|
+
if (resolved.type === 'object' || resolved.properties) {
|
|
210
|
+
const out: Record<string, unknown> = {};
|
|
211
|
+
for (const [name, propSchema] of Object.entries(resolved.properties ?? {})) {
|
|
212
|
+
out[name] = exampleFromSchema(propSchema, depth + 1);
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
if (resolved.type === 'array') return [exampleFromSchema(resolved.items, depth + 1)];
|
|
217
|
+
if (resolved.type === 'integer' || resolved.type === 'number') return 0;
|
|
218
|
+
if (resolved.type === 'boolean') return true;
|
|
219
|
+
if (resolved.type === 'string') {
|
|
220
|
+
if (resolved.format === 'date-time') return new Date(0).toISOString();
|
|
221
|
+
if (resolved.format === 'date') return '2024-01-01';
|
|
222
|
+
if (resolved.format === 'email') return 'user@example.com';
|
|
223
|
+
if (resolved.format === 'uuid') return '00000000-0000-0000-0000-000000000000';
|
|
224
|
+
return 'string';
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Same object/array shape-walking as exampleFromSchema, but every leaf
|
|
230
|
+
* is left blank instead of filled with a synthesized placeholder - the
|
|
231
|
+
* Try-it modal's "Default" example option uses this (rather than an
|
|
232
|
+
* empty textarea) so the reader still sees exactly which fields the
|
|
233
|
+
* request body needs and can fill each one in, instead of losing the
|
|
234
|
+
* shape entirely. Strings are '' (their own native "nothing yet"),
|
|
235
|
+
* arrays are [] (no item to show without a real example), and
|
|
236
|
+
* numbers/booleans are null - 0/false would themselves look like
|
|
237
|
+
* real, deliberately-chosen values rather than an unfilled field. */
|
|
238
|
+
export function emptyValueFromSchema(schema: OpenApiSchema | undefined, depth = 0): unknown {
|
|
239
|
+
if (!schema) return null;
|
|
240
|
+
if (depth > 6) return null; // guard against a pathological/self-referential schema
|
|
241
|
+
|
|
242
|
+
const resolved = resolveSchema(schema);
|
|
243
|
+
if (resolved.type === 'object' || resolved.properties) {
|
|
244
|
+
const out: Record<string, unknown> = {};
|
|
245
|
+
for (const [name, propSchema] of Object.entries(resolved.properties ?? {})) {
|
|
246
|
+
out[name] = emptyValueFromSchema(propSchema, depth + 1);
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
if (resolved.type === 'array') return [];
|
|
251
|
+
if (resolved.type === 'string') return '';
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The single content-type this operation's request body is rendered
|
|
256
|
+
* for - application/json when available (by far the common case for
|
|
257
|
+
* anything with a playground worth building), else whatever the spec
|
|
258
|
+
* happens to list first. */
|
|
259
|
+
export function primaryContentType(
|
|
260
|
+
content: Record<string, { schema?: OpenApiSchema }> | undefined
|
|
261
|
+
): string | null {
|
|
262
|
+
if (!content) return null;
|
|
263
|
+
if ('application/json' in content) return 'application/json';
|
|
264
|
+
return Object.keys(content)[0] ?? null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const METHOD_COLORS: Record<string, string> = {
|
|
268
|
+
GET: 'get',
|
|
269
|
+
POST: 'post',
|
|
270
|
+
PUT: 'put',
|
|
271
|
+
PATCH: 'patch',
|
|
272
|
+
DELETE: 'delete',
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export function methodClass(method: string): string {
|
|
276
|
+
return METHOD_COLORS[method.toUpperCase()] ?? 'other';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Splits "/pets/{petId}" into alternating literal/param segments, so the
|
|
280
|
+
* path can be rendered with `{param}` portions visually distinguished
|
|
281
|
+
* from the literal path structure around them. */
|
|
282
|
+
export function pathSegments(urlPath: string): { text: string; isParam: boolean }[] {
|
|
283
|
+
const parts = urlPath.split(/(\{[^}]+\})/g).filter((p) => p !== '');
|
|
284
|
+
return parts.map((text) => ({ text, isParam: /^\{.*\}$/.test(text) }));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function jsonBody(schema: OpenApiSchema | undefined): string | null {
|
|
288
|
+
if (!schema) return null;
|
|
289
|
+
return JSON.stringify(exampleFromSchema(schema), null, 2);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function buildCurlSnippet(op: OpenApiOperation, baseUrl: string): string {
|
|
293
|
+
const contentType = primaryContentType(op.requestBody?.content);
|
|
294
|
+
const bodySchema = contentType ? op.requestBody?.content?.[contentType]?.schema : undefined;
|
|
295
|
+
const body = jsonBody(bodySchema);
|
|
296
|
+
const headerParams = op.parameters.filter((p) => p.in === 'header');
|
|
297
|
+
const queryParams = op.parameters.filter((p) => p.in === 'query');
|
|
298
|
+
const url =
|
|
299
|
+
baseUrl.replace(/\/$/, '') +
|
|
300
|
+
op.path.replace(/\{(\w+)\}/g, (_, name) => `<${name}>`) +
|
|
301
|
+
(queryParams.length > 0 ? '?' + queryParams.map((p) => `${p.name}=<${p.name}>`).join('&') : '');
|
|
302
|
+
|
|
303
|
+
const lines = [`curl -X ${op.method} "${url}" \\`];
|
|
304
|
+
if (contentType) lines.push(` -H "Content-Type: ${contentType}" \\`);
|
|
305
|
+
for (const h of headerParams) lines.push(` -H "${h.name}: <${h.name}>" \\`);
|
|
306
|
+
for (const s of op.security) {
|
|
307
|
+
if (s.type === 'apiKey' && s.in === 'header') lines.push(` -H "${s.name}: <${s.name}>" \\`);
|
|
308
|
+
if (s.type === 'http' && s.scheme === 'bearer') lines.push(` -H "Authorization: Bearer <token>" \\`);
|
|
309
|
+
if (s.type === 'http' && s.scheme === 'basic') lines.push(` -u "<username>:<password>" \\`);
|
|
310
|
+
}
|
|
311
|
+
if (body) lines.push(` -d '${body}'`);
|
|
312
|
+
const last = lines[lines.length - 1];
|
|
313
|
+
lines[lines.length - 1] = last.endsWith('\\') ? last.slice(0, -2) : last;
|
|
314
|
+
return lines.join('\n');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function buildFetchSnippet(op: OpenApiOperation, baseUrl: string): string {
|
|
318
|
+
const contentType = primaryContentType(op.requestBody?.content);
|
|
319
|
+
const bodySchema = contentType ? op.requestBody?.content?.[contentType]?.schema : undefined;
|
|
320
|
+
const body = jsonBody(bodySchema);
|
|
321
|
+
const url = baseUrl.replace(/\/$/, '') + op.path.replace(/\{(\w+)\}/g, (_, name) => `\${${name}}`);
|
|
322
|
+
const headers: string[] = [];
|
|
323
|
+
if (contentType) headers.push(`'Content-Type': '${contentType}'`);
|
|
324
|
+
for (const s of op.security) {
|
|
325
|
+
if (s.type === 'apiKey' && s.in === 'header') headers.push(`'${s.name}': '<${s.name}>'`);
|
|
326
|
+
if (s.type === 'http' && s.scheme === 'bearer') headers.push(`'Authorization': 'Bearer <token>'`);
|
|
327
|
+
}
|
|
328
|
+
const optsLines = [`method: '${op.method}'`, headers.length ? `headers: { ${headers.join(', ')} }` : null, body ? `body: JSON.stringify(${body})` : null].filter(Boolean);
|
|
329
|
+
return `const response = await fetch(\`${url}\`, {\n ${optsLines.join(',\n ')}\n});\nconst data = await response.json();`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function buildPythonSnippet(op: OpenApiOperation, baseUrl: string): string {
|
|
333
|
+
const contentType = primaryContentType(op.requestBody?.content);
|
|
334
|
+
const bodySchema = contentType ? op.requestBody?.content?.[contentType]?.schema : undefined;
|
|
335
|
+
const body = jsonBody(bodySchema);
|
|
336
|
+
const url = baseUrl.replace(/\/$/, '') + op.path.replace(/\{(\w+)\}/g, (_, name) => `{${name}}`);
|
|
337
|
+
const headers: string[] = [];
|
|
338
|
+
for (const s of op.security) {
|
|
339
|
+
if (s.type === 'apiKey' && s.in === 'header') headers.push(`"${s.name}": "<${s.name}>"`);
|
|
340
|
+
if (s.type === 'http' && s.scheme === 'bearer') headers.push(`"Authorization": "Bearer <token>"`);
|
|
341
|
+
}
|
|
342
|
+
const lines = ['import requests', '', `response = requests.request(`, ` "${op.method}",`, ` "${url}",`];
|
|
343
|
+
if (headers.length) lines.push(` headers={ ${headers.join(', ')} },`);
|
|
344
|
+
if (body) lines.push(` json=${body.replace(/\btrue\b/g, 'True').replace(/\bfalse\b/g, 'False').replace(/\bnull\b/g, 'None')},`);
|
|
345
|
+
lines.push(')', 'data = response.json()');
|
|
346
|
+
return lines.join('\n');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The four snippet builders below follow buildFetchSnippet/
|
|
350
|
+
// buildPythonSnippet's own subset of buildCurlSnippet's behavior, not
|
|
351
|
+
// buildCurlSnippet's full one - only apiKey-in-header and bearer
|
|
352
|
+
// security get a placeholder header, no query params get appended to
|
|
353
|
+
// the URL, and no basic-auth handling - since that's the precedent
|
|
354
|
+
// already set by every other non-curl snippet on this page, matching it
|
|
355
|
+
// keeps all seven tabs behaviorally consistent with each other rather
|
|
356
|
+
// than curl alone being the odd one out with extra capability the rest
|
|
357
|
+
// quietly lack.
|
|
358
|
+
export function buildPhpSnippet(op: OpenApiOperation, baseUrl: string): string {
|
|
359
|
+
const contentType = primaryContentType(op.requestBody?.content);
|
|
360
|
+
const bodySchema = contentType ? op.requestBody?.content?.[contentType]?.schema : undefined;
|
|
361
|
+
const body = jsonBody(bodySchema);
|
|
362
|
+
const url = baseUrl.replace(/\/$/, '') + op.path.replace(/\{(\w+)\}/g, (_, name) => `<${name}>`);
|
|
363
|
+
const headers: string[] = [];
|
|
364
|
+
if (contentType) headers.push(`"Content-Type: ${contentType}"`);
|
|
365
|
+
for (const s of op.security) {
|
|
366
|
+
if (s.type === 'apiKey' && s.in === 'header') headers.push(`"${s.name}: <${s.name}>"`);
|
|
367
|
+
if (s.type === 'http' && s.scheme === 'bearer') headers.push(`"Authorization: Bearer <token>"`);
|
|
368
|
+
}
|
|
369
|
+
const lines = [
|
|
370
|
+
'$ch = curl_init();',
|
|
371
|
+
`curl_setopt($ch, CURLOPT_URL, "${url}");`,
|
|
372
|
+
'curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);',
|
|
373
|
+
`curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "${op.method}");`,
|
|
374
|
+
];
|
|
375
|
+
if (headers.length) lines.push(`curl_setopt($ch, CURLOPT_HTTPHEADER, [${headers.join(', ')}]);`);
|
|
376
|
+
if (body) lines.push(`curl_setopt($ch, CURLOPT_POSTFIELDS, '${body}');`);
|
|
377
|
+
lines.push('$response = curl_exec($ch);', 'curl_close($ch);');
|
|
378
|
+
return `<?php\n${lines.join('\n')}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function buildGoSnippet(op: OpenApiOperation, baseUrl: string): string {
|
|
382
|
+
const contentType = primaryContentType(op.requestBody?.content);
|
|
383
|
+
const bodySchema = contentType ? op.requestBody?.content?.[contentType]?.schema : undefined;
|
|
384
|
+
const body = jsonBody(bodySchema);
|
|
385
|
+
const url = baseUrl.replace(/\/$/, '') + op.path.replace(/\{(\w+)\}/g, (_, name) => `<${name}>`);
|
|
386
|
+
const headers: string[] = [];
|
|
387
|
+
if (contentType) headers.push(`req.Header.Set("Content-Type", "${contentType}")`);
|
|
388
|
+
for (const s of op.security) {
|
|
389
|
+
if (s.type === 'apiKey' && s.in === 'header') headers.push(`req.Header.Set("${s.name}", "<${s.name}>")`);
|
|
390
|
+
if (s.type === 'http' && s.scheme === 'bearer') headers.push(`req.Header.Set("Authorization", "Bearer <token>")`);
|
|
391
|
+
}
|
|
392
|
+
const bodyExpr = body ? `strings.NewReader(\`${body}\`)` : 'nil';
|
|
393
|
+
const lines = [
|
|
394
|
+
'package main',
|
|
395
|
+
'',
|
|
396
|
+
'import (',
|
|
397
|
+
'\t"fmt"',
|
|
398
|
+
'\t"io"',
|
|
399
|
+
'\t"net/http"',
|
|
400
|
+
...(body ? ['\t"strings"'] : []),
|
|
401
|
+
')',
|
|
402
|
+
'',
|
|
403
|
+
'func main() {',
|
|
404
|
+
`\treq, _ := http.NewRequest("${op.method}", "${url}", ${bodyExpr})`,
|
|
405
|
+
...headers.map((h) => `\t${h}`),
|
|
406
|
+
'',
|
|
407
|
+
'\tresp, err := http.DefaultClient.Do(req)',
|
|
408
|
+
'\tif err != nil {',
|
|
409
|
+
'\t\tpanic(err)',
|
|
410
|
+
'\t}',
|
|
411
|
+
'\tdefer resp.Body.Close()',
|
|
412
|
+
'',
|
|
413
|
+
'\tdata, _ := io.ReadAll(resp.Body)',
|
|
414
|
+
'\tfmt.Println(string(data))',
|
|
415
|
+
'}',
|
|
416
|
+
];
|
|
417
|
+
return lines.join('\n');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function buildJavaSnippet(op: OpenApiOperation, baseUrl: string): string {
|
|
421
|
+
const contentType = primaryContentType(op.requestBody?.content);
|
|
422
|
+
const bodySchema = contentType ? op.requestBody?.content?.[contentType]?.schema : undefined;
|
|
423
|
+
const body = jsonBody(bodySchema);
|
|
424
|
+
const url = baseUrl.replace(/\/$/, '') + op.path.replace(/\{(\w+)\}/g, (_, name) => `<${name}>`);
|
|
425
|
+
const headers: string[] = [];
|
|
426
|
+
if (contentType) headers.push(`.header("Content-Type", "${contentType}")`);
|
|
427
|
+
for (const s of op.security) {
|
|
428
|
+
if (s.type === 'apiKey' && s.in === 'header') headers.push(`.header("${s.name}", "<${s.name}>")`);
|
|
429
|
+
if (s.type === 'http' && s.scheme === 'bearer') headers.push(`.header("Authorization", "Bearer <token>")`);
|
|
430
|
+
}
|
|
431
|
+
// JSON.stringify's escaping (quotes, backslashes, newlines) happens to
|
|
432
|
+
// double as a valid Java string-literal escape too, so this reuses it
|
|
433
|
+
// rather than hand-rolling a second escaper just for this language.
|
|
434
|
+
const bodyPublisher = body
|
|
435
|
+
? `HttpRequest.BodyPublishers.ofString(${JSON.stringify(body)})`
|
|
436
|
+
: 'HttpRequest.BodyPublishers.noBody()';
|
|
437
|
+
const lines = [
|
|
438
|
+
'HttpClient client = HttpClient.newHttpClient();',
|
|
439
|
+
'HttpRequest request = HttpRequest.newBuilder()',
|
|
440
|
+
` .uri(URI.create("${url}"))`,
|
|
441
|
+
...headers.map((h) => ` ${h}`),
|
|
442
|
+
` .method("${op.method}", ${bodyPublisher})`,
|
|
443
|
+
' .build();',
|
|
444
|
+
'',
|
|
445
|
+
'HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());',
|
|
446
|
+
'System.out.println(response.body());',
|
|
447
|
+
];
|
|
448
|
+
return lines.join('\n');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function buildRubySnippet(op: OpenApiOperation, baseUrl: string): string {
|
|
452
|
+
const contentType = primaryContentType(op.requestBody?.content);
|
|
453
|
+
const bodySchema = contentType ? op.requestBody?.content?.[contentType]?.schema : undefined;
|
|
454
|
+
const body = jsonBody(bodySchema);
|
|
455
|
+
const url = baseUrl.replace(/\/$/, '') + op.path.replace(/\{(\w+)\}/g, (_, name) => `<${name}>`);
|
|
456
|
+
const methodClass = op.method.charAt(0).toUpperCase() + op.method.slice(1).toLowerCase();
|
|
457
|
+
const headers: string[] = [];
|
|
458
|
+
if (contentType) headers.push(`request["Content-Type"] = "${contentType}"`);
|
|
459
|
+
for (const s of op.security) {
|
|
460
|
+
if (s.type === 'apiKey' && s.in === 'header') headers.push(`request["${s.name}"] = "<${s.name}>"`);
|
|
461
|
+
if (s.type === 'http' && s.scheme === 'bearer') headers.push(`request["Authorization"] = "Bearer <token>"`);
|
|
462
|
+
}
|
|
463
|
+
const lines = [
|
|
464
|
+
'require "net/http"',
|
|
465
|
+
'require "uri"',
|
|
466
|
+
'',
|
|
467
|
+
`uri = URI("${url}")`,
|
|
468
|
+
'http = Net::HTTP.new(uri.host, uri.port)',
|
|
469
|
+
'http.use_ssl = uri.scheme == "https"',
|
|
470
|
+
'',
|
|
471
|
+
`request = Net::HTTP::${methodClass}.new(uri)`,
|
|
472
|
+
...headers,
|
|
473
|
+
...(body ? [`request.body = ${JSON.stringify(body)}`] : []),
|
|
474
|
+
'',
|
|
475
|
+
'response = http.request(request)',
|
|
476
|
+
'puts response.body',
|
|
477
|
+
];
|
|
478
|
+
return lines.join('\n');
|
|
479
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// A single combined Shiki transformer covering everything about a
|
|
2
|
+
// fenced (```) code block's outer chrome that isn't syntax coloring
|
|
3
|
+
// itself: wrapping every block in a positioned `.wd-code-block`
|
|
4
|
+
// container, injecting the copy-to-clipboard button, and reading a
|
|
5
|
+
// handful of writedocs-specific meta flags off the fence's info string
|
|
6
|
+
// (the part after the language, e.g. ```js title="foo.js" wrap lines
|
|
7
|
+
// expandable) to add a title bar and toggle wrap/line-numbers/
|
|
8
|
+
// expandable behavior. Registered in astro.config.mjs's
|
|
9
|
+
// markdown.shikiConfig.transformers, alongside the official
|
|
10
|
+
// @shikijs/transformers notation/meta-highlight transformers that
|
|
11
|
+
// handle the actual line/word annotation classes.
|
|
12
|
+
//
|
|
13
|
+
// Everything here only produces static HAST/HTML at build time - the
|
|
14
|
+
// click behavior for the copy and expand/collapse buttons lives in
|
|
15
|
+
// [...slug].astro's client script instead, and the CSS for all of this
|
|
16
|
+
// (including the classes the @shikijs/transformers transformers add)
|
|
17
|
+
// lives there too.
|
|
18
|
+
//
|
|
19
|
+
// Deliberately NOT used by ApiPlayground/ApiReferencePanel's <Code/>
|
|
20
|
+
// usages - per Astro's docs, <Code/> doesn't inherit markdown.shikiConfig
|
|
21
|
+
// at all, so this only ever touches genuine ```-fenced MDX content and
|
|
22
|
+
// never collides with the API playground's own bespoke copy buttons.
|
|
23
|
+
|
|
24
|
+
const META_TITLE_RE = /title\s*=\s*(["'])((?:(?!\1).)*)\1/;
|
|
25
|
+
|
|
26
|
+
function parseMeta(raw) {
|
|
27
|
+
return {
|
|
28
|
+
title: raw.match(META_TITLE_RE)?.[2] ?? null,
|
|
29
|
+
wrap: /\bwrap\b/.test(raw),
|
|
30
|
+
lines: /\b(?:lines|showLineNumbers)\b/.test(raw),
|
|
31
|
+
expandable: /\bexpandable\b/.test(raw),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function codeBlockTransformer() {
|
|
36
|
+
return {
|
|
37
|
+
name: 'writedocs:code-block',
|
|
38
|
+
// Runs once per block, before `root` - stashes the parsed meta
|
|
39
|
+
// flags on `this` (the shared per-block transformer context; see
|
|
40
|
+
// @shikijs/core's tokensToHast, which calls both `pre` and `root`
|
|
41
|
+
// with the same `context` object for a given block, but a *fresh*
|
|
42
|
+
// one for each block) so `root` below doesn't have to re-parse it.
|
|
43
|
+
pre(node) {
|
|
44
|
+
this.wdMeta = parseMeta(this.options.meta?.__raw ?? '');
|
|
45
|
+
return node;
|
|
46
|
+
},
|
|
47
|
+
root(hast) {
|
|
48
|
+
const pre = hast.children.find((child) => child.type === 'element' && child.tagName === 'pre');
|
|
49
|
+
if (!pre) return hast;
|
|
50
|
+
const meta = this.wdMeta ?? parseMeta(this.options.meta?.__raw ?? '');
|
|
51
|
+
|
|
52
|
+
const wrapperClass = ['wd-code-block'];
|
|
53
|
+
if (meta.wrap) wrapperClass.push('wd-code-wrap');
|
|
54
|
+
if (meta.lines) wrapperClass.push('wd-code-lines');
|
|
55
|
+
if (meta.expandable) wrapperClass.push('wd-code-expandable');
|
|
56
|
+
|
|
57
|
+
const children = [];
|
|
58
|
+
if (meta.title) {
|
|
59
|
+
children.push({
|
|
60
|
+
type: 'element',
|
|
61
|
+
tagName: 'div',
|
|
62
|
+
properties: { class: 'wd-code-title' },
|
|
63
|
+
children: [{ type: 'text', value: meta.title }],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
children.push(pre);
|
|
67
|
+
children.push({
|
|
68
|
+
type: 'element',
|
|
69
|
+
tagName: 'button',
|
|
70
|
+
properties: {
|
|
71
|
+
type: 'button',
|
|
72
|
+
class: 'wd-code-copy-btn',
|
|
73
|
+
'data-role': 'copy-code',
|
|
74
|
+
'aria-label': 'Copy code',
|
|
75
|
+
},
|
|
76
|
+
children: [{ type: 'text', value: '⧉' }],
|
|
77
|
+
});
|
|
78
|
+
if (meta.expandable) {
|
|
79
|
+
children.push({
|
|
80
|
+
type: 'element',
|
|
81
|
+
tagName: 'button',
|
|
82
|
+
properties: {
|
|
83
|
+
type: 'button',
|
|
84
|
+
class: 'wd-code-expand-btn',
|
|
85
|
+
'data-role': 'toggle-expand',
|
|
86
|
+
},
|
|
87
|
+
children: [{ type: 'text', value: 'Show more' }],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
hast.children = [
|
|
92
|
+
{
|
|
93
|
+
type: 'element',
|
|
94
|
+
tagName: 'div',
|
|
95
|
+
properties: { class: wrapperClass.join(' ') },
|
|
96
|
+
children,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
return hast;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// A Shiki transformer that wraps every fenced (```) code block's <pre>
|
|
2
|
+
// in a positioned container and injects a copy-to-clipboard button next
|
|
3
|
+
// to it - registered in astro.config.mjs's markdown.shikiConfig so it
|
|
4
|
+
// runs for every MDX code fence sitewide. The click handling (clipboard
|
|
5
|
+
// write, "copied" feedback) lives in [...slug].astro's own client
|
|
6
|
+
// script instead of here, since a Shiki transformer only ever produces
|
|
7
|
+
// static HAST/HTML at build time and has no way to attach behavior.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately NOT used by ApiPlayground/ApiReferencePanel's <Code/>
|
|
10
|
+
// usages - per Astro's docs, <Code/> doesn't inherit markdown.shikiConfig
|
|
11
|
+
// at all, so this only ever touches genuine ```-fenced MDX content and
|
|
12
|
+
// never collides with the API playground's own bespoke copy buttons
|
|
13
|
+
// (which copy whichever language tab is currently visible, not "the
|
|
14
|
+
// whole block").
|
|
15
|
+
export function copyButtonTransformer() {
|
|
16
|
+
return {
|
|
17
|
+
name: 'writedocs:copy-button',
|
|
18
|
+
root(hast) {
|
|
19
|
+
const pre = hast.children.find((child) => child.type === 'element' && child.tagName === 'pre');
|
|
20
|
+
if (!pre) return hast;
|
|
21
|
+
hast.children = [
|
|
22
|
+
{
|
|
23
|
+
type: 'element',
|
|
24
|
+
tagName: 'div',
|
|
25
|
+
properties: { class: 'wd-code-block' },
|
|
26
|
+
children: [
|
|
27
|
+
pre,
|
|
28
|
+
{
|
|
29
|
+
type: 'element',
|
|
30
|
+
tagName: 'button',
|
|
31
|
+
properties: {
|
|
32
|
+
type: 'button',
|
|
33
|
+
class: 'wd-code-copy-btn',
|
|
34
|
+
'data-role': 'copy-code',
|
|
35
|
+
'aria-label': 'Copy code',
|
|
36
|
+
},
|
|
37
|
+
children: [{ type: 'text', value: '⧉' }],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
return hast;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|