@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.
Files changed (95) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +17 -0
  3. package/astro.config.mjs +419 -0
  4. package/bin/writedocs.js +73 -0
  5. package/package.json +79 -0
  6. package/src/assets/wd_watermark.png +0 -0
  7. package/src/assets/wd_watermark_dark.png +0 -0
  8. package/src/cli/build-auth.js +53 -0
  9. package/src/cli/build.js +40 -0
  10. package/src/cli/dev.js +12 -0
  11. package/src/cli/generate-api-pages.js +359 -0
  12. package/src/cli/init.js +81 -0
  13. package/src/cli/preflight.js +40 -0
  14. package/src/cli/run-astro.js +57 -0
  15. package/src/cli/run-pagefind.js +66 -0
  16. package/src/cli/write-redirects-file.js +80 -0
  17. package/src/components/Accordion.astro +164 -0
  18. package/src/components/AccordionGroup.astro +40 -0
  19. package/src/components/ApiLangSelect.astro +168 -0
  20. package/src/components/ApiPlayground.astro +281 -0
  21. package/src/components/ApiReferencePanel.astro +1754 -0
  22. package/src/components/ApiSchemaField.astro +54 -0
  23. package/src/components/AppIcon.astro +32 -0
  24. package/src/components/Badge.astro +128 -0
  25. package/src/components/Callout.astro +168 -0
  26. package/src/components/Card.astro +136 -0
  27. package/src/components/CardGroup.astro +20 -0
  28. package/src/components/CodeGroup.astro +184 -0
  29. package/src/components/CopyPageMenu.astro +246 -0
  30. package/src/components/Danger.astro +12 -0
  31. package/src/components/Expandable.astro +126 -0
  32. package/src/components/Frame.astro +102 -0
  33. package/src/components/Hint.astro +99 -0
  34. package/src/components/Icon.astro +70 -0
  35. package/src/components/Image.astro +147 -0
  36. package/src/components/Info.astro +12 -0
  37. package/src/components/Note.astro +12 -0
  38. package/src/components/Parameter.astro +119 -0
  39. package/src/components/RequestExample.astro +33 -0
  40. package/src/components/ResponseExample.astro +19 -0
  41. package/src/components/Searchbar.astro +117 -0
  42. package/src/components/Step.astro +10 -0
  43. package/src/components/Steps.astro +32 -0
  44. package/src/components/Tab.astro +9 -0
  45. package/src/components/Tabs.astro +52 -0
  46. package/src/components/Tip.astro +12 -0
  47. package/src/components/Video.astro +135 -0
  48. package/src/components/Warning.astro +12 -0
  49. package/src/components/index.ts +48 -0
  50. package/src/content.config.ts +223 -0
  51. package/src/layout/BaseLayout.astro +750 -0
  52. package/src/layout/components/AnalyticsScripts.astro +77 -0
  53. package/src/layout/components/AskAiWidget.astro +37 -0
  54. package/src/layout/components/Breadcrumbs.astro +97 -0
  55. package/src/layout/components/ImageZoom.astro +19 -0
  56. package/src/layout/components/MobileMenu.astro +200 -0
  57. package/src/layout/components/NavTree.astro +351 -0
  58. package/src/layout/components/SearchModal.astro +42 -0
  59. package/src/layout/components/Sidebar.astro +122 -0
  60. package/src/layout/components/SiteFooter.astro +85 -0
  61. package/src/layout/components/TableOfContents.astro +117 -0
  62. package/src/layout/components/TopBar.astro +311 -0
  63. package/src/layout/styles/banner.css +44 -0
  64. package/src/layout/styles/base.css +234 -0
  65. package/src/layout/styles/dropdown.css +133 -0
  66. package/src/layout/styles/footer.css +108 -0
  67. package/src/layout/styles/image-zoom.css +50 -0
  68. package/src/layout/styles/mobile-menu.css +258 -0
  69. package/src/layout/styles/search-modal.css +122 -0
  70. package/src/layout/styles/topbar.css +437 -0
  71. package/src/lib/config.ts +2131 -0
  72. package/src/lib/mdx-auto-hydrate.js +70 -0
  73. package/src/lib/mdx-inject-builtins.js +87 -0
  74. package/src/lib/mdx-substitute-variables.js +66 -0
  75. package/src/lib/mdx-title-anchor-ids.js +84 -0
  76. package/src/lib/mermaid-rehype.js +72 -0
  77. package/src/lib/openapi-render.ts +479 -0
  78. package/src/lib/shiki-code-block.js +102 -0
  79. package/src/lib/shiki-copy-button.js +45 -0
  80. package/src/lib/styles-asset-integration.js +210 -0
  81. package/src/lib/writedocs-temp-dir.js +93 -0
  82. package/src/pages/404.astro +62 -0
  83. package/src/pages/[...slug].astro +1270 -0
  84. package/src/pages/[...slug].md.ts +78 -0
  85. package/src/pages/llms-full.txt.ts +71 -0
  86. package/src/pages/llms.txt.ts +141 -0
  87. package/src/scripts/banner.ts +20 -0
  88. package/src/scripts/dropdowns.ts +61 -0
  89. package/src/scripts/image-zoom.ts +66 -0
  90. package/src/scripts/mobile-menu.ts +55 -0
  91. package/src/scripts/search.ts +155 -0
  92. package/src/scripts/sidebar-scroll.ts +65 -0
  93. package/src/scripts/theme-toggle.ts +35 -0
  94. package/src/scripts/topbar-offset.ts +141 -0
  95. 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
+ }