@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,1754 @@
1
+ ---
2
+ import fs from 'node:fs';
3
+ import { Code } from 'astro:components';
4
+ import {
5
+ exampleFromSchema,
6
+ emptyValueFromSchema,
7
+ primaryContentType,
8
+ buildCurlSnippet,
9
+ buildFetchSnippet,
10
+ buildPythonSnippet,
11
+ buildPhpSnippet,
12
+ buildGoSnippet,
13
+ buildJavaSnippet,
14
+ buildRubySnippet,
15
+ findOperationFile,
16
+ type OpenApiOperation,
17
+ } from '../lib/openapi-render';
18
+ import { loadDocsConfig, resolveCodeblockTheme } from '../lib/config';
19
+ import ApiLangSelect from './ApiLangSelect.astro';
20
+
21
+ interface Props {
22
+ operation: string;
23
+ contentDir: string;
24
+ }
25
+ const { operation, contentDir } = Astro.props as Props;
26
+
27
+ // Shiki language id for each request-sample tab - <Code/> falls back to
28
+ // "plaintext" for anything it doesn't recognize, so this only needs to
29
+ // cover the ids actually used by `samples` below.
30
+ const SNIPPET_LANG: Record<string, string> = {
31
+ curl: 'bash',
32
+ js: 'javascript',
33
+ py: 'python',
34
+ php: 'php',
35
+ go: 'go',
36
+ java: 'java',
37
+ ruby: 'ruby',
38
+ };
39
+ // Iconify id for each request-sample tab's language, shown in both of
40
+ // this panel's language dropdowns (sidebar + Try-it modal) - see
41
+ // ApiLangSelect.astro. mdi's language-* set (not simple-icons' brand
42
+ // marks) since those render as plain currentColor monochrome glyphs,
43
+ // matching every other inline icon on this page instead of introducing
44
+ // a second, full-color icon style just for this one control.
45
+ const LANG_ICON: Record<string, string> = {
46
+ curl: 'mdi:console',
47
+ js: 'mdi:language-javascript',
48
+ py: 'mdi:language-python',
49
+ php: 'mdi:language-php',
50
+ go: 'mdi:language-go',
51
+ java: 'mdi:language-java',
52
+ ruby: 'mdi:language-ruby',
53
+ };
54
+ // Same dual light/dark themes as the sitewide MDX code-fence config in
55
+ // astro.config.mjs (writedocs.json's styles.codeblocks, via the shared
56
+ // resolveCodeblockTheme() helper - <Code/> doesn't inherit
57
+ // markdown.shikiConfig at all, so this needs its own copy of the same
58
+ // resolved value), so the response/request panels here follow the
59
+ // [data-theme] toggle exactly like article content does - see the
60
+ // [data-theme='dark'] :global(.astro-code) override in BaseLayout.astro.
61
+ const docsConfig = loadDocsConfig(contentDir);
62
+ const SNIPPET_THEMES = resolveCodeblockTheme(docsConfig);
63
+ // Whether the Send button in the Try-it modal routes its request through
64
+ // writedocs' CORS proxy - see PROXY_BASE_URL in the <script> below.
65
+ const proxyEnabled = docsConfig.api.proxy;
66
+
67
+ const [method = '', urlPath = ''] = operation.trim().split(/\s+/, 2);
68
+ const opFile = findOperationFile(contentDir, method, urlPath);
69
+ const op: OpenApiOperation | null = opFile ? JSON.parse(fs.readFileSync(opFile, 'utf-8')) : null;
70
+
71
+ // ApiPlayground.astro (rendered in the article column) already shows a
72
+ // clear error when no matching operation is found - this panel just
73
+ // quietly renders nothing in that case rather than duplicating it.
74
+ const servers = op?.servers ?? [];
75
+ const baseUrl = servers[0]?.url ?? '';
76
+ const sampleBaseUrl = baseUrl || 'https://api.example.com';
77
+ const pathParams = op?.parameters.filter((p) => p.in === 'path') ?? [];
78
+ const queryParams = op?.parameters.filter((p) => p.in === 'query') ?? [];
79
+ const headerParams = op?.parameters.filter((p) => p.in === 'header') ?? [];
80
+
81
+ const requestContentType = op ? primaryContentType(op.requestBody?.content) : null;
82
+ const requestSchema = requestContentType ? op?.requestBody?.content?.[requestContentType]?.schema : undefined;
83
+ const requestExample = requestSchema ? JSON.stringify(exampleFromSchema(requestSchema), null, 2) : null;
84
+ // What the Body textarea resets to when the reader picks "Default" in
85
+ // the examples select (see requestExampleEntries below) - the same
86
+ // field shape as requestExample above, but blank rather than filled
87
+ // with synthesized placeholder values, so picking it clears prior
88
+ // input without losing sight of what the body actually needs.
89
+ const blankRequestExample = requestSchema ? JSON.stringify(emptyValueFromSchema(requestSchema), null, 2) : null;
90
+ // OpenAPI's plural, *named* `examples` map on the request body's media
91
+ // type object (distinct from the singular `example`/schema-derived
92
+ // default above) - when a spec author provides one or more of these,
93
+ // the modal gets a selector for them (see the "Example" <select> in the
94
+ // modal head below) that swaps the Body textarea to that exact value on
95
+ // selection. Scoped deliberately to the request body only, not
96
+ // parameters - OpenAPI parameters can carry their own `examples` map
97
+ // too, but there's no standard link between "this body example" and
98
+ // "these parameter values" the way there sometimes conceptually is in a
99
+ // hand-written doc, so auto-populating anything beyond the body from a
100
+ // body example would be guessing at an association the spec itself
101
+ // doesn't actually make.
102
+ const requestExamples = requestContentType ? (op?.requestBody?.content?.[requestContentType]?.examples ?? {}) : {};
103
+ const requestExampleEntries = Object.entries(requestExamples);
104
+ // The modal's own header title - "the endpoint title, not a dropdown"
105
+ // (an earlier version showed the raw path here instead, which reads
106
+ // more like plumbing than a title a reader would recognize). Same
107
+ // summary-with-a-fallback op.summary ?? "METHOD /path" that
108
+ // generate-api-pages.js already uses for a generated stub page's own
109
+ // <title>, so the modal's heading matches whatever the reader already
110
+ // clicked "Try it" from.
111
+ const modalTitle = op ? (op.summary ?? `${op.method} ${op.path}`) : '';
112
+
113
+ const samples = op
114
+ ? [
115
+ { id: 'curl', label: 'cURL', code: buildCurlSnippet(op, sampleBaseUrl) },
116
+ { id: 'js', label: 'JavaScript', code: buildFetchSnippet(op, sampleBaseUrl) },
117
+ { id: 'py', label: 'Python', code: buildPythonSnippet(op, sampleBaseUrl) },
118
+ { id: 'php', label: 'PHP', code: buildPhpSnippet(op, sampleBaseUrl) },
119
+ { id: 'go', label: 'Go', code: buildGoSnippet(op, sampleBaseUrl) },
120
+ { id: 'java', label: 'Java', code: buildJavaSnippet(op, sampleBaseUrl) },
121
+ { id: 'ruby', label: 'Ruby', code: buildRubySnippet(op, sampleBaseUrl) },
122
+ ]
123
+ : [];
124
+ // Same {id, label} shape samples already carries, plus the icon each
125
+ // dropdown item shows - kept as its own array (not folded into samples
126
+ // itself) since ApiLangSelect.astro only needs id/label/icon, not the
127
+ // snippet code samples also carries.
128
+ const langOptions = samples.map((s) => ({ id: s.id, label: s.label, icon: LANG_ICON[s.id] ?? 'mdi:code-tags' }));
129
+
130
+ // Classifies a response status into the same ok/error tone the Try-it
131
+ // modal's own live response status already uses (wd-api-status-ok/
132
+ // -error, see the <script> below) - reused here for the status select's
133
+ // leading color dot, so "green means success, red means an error status"
134
+ // reads consistently whether it's a live request result or just a
135
+ // static example being browsed. Anything outside 2xx/4xx/5xx (a 3xx
136
+ // redirect, mainly) gets a neutral gray dot rather than being force-fit
137
+ // into ok-or-error.
138
+ function statusToneClass(status: string): string {
139
+ const code = parseInt(status, 10);
140
+ if (code >= 200 && code < 300) return 'wd-api-status-dot-ok';
141
+ if (code >= 400) return 'wd-api-status-dot-error';
142
+ return 'wd-api-status-dot-neutral';
143
+ }
144
+
145
+ const responseExamples = op
146
+ ? Object.entries(op.responses).map(([status, resp]) => {
147
+ const contentType = primaryContentType(resp.content);
148
+ const schema = contentType ? resp.content?.[contentType]?.schema : undefined;
149
+ return {
150
+ status,
151
+ tone: statusToneClass(status),
152
+ code: schema ? JSON.stringify(exampleFromSchema(schema), null, 2) : '(no body)',
153
+ };
154
+ })
155
+ : [];
156
+
157
+ const paramsJson = JSON.stringify(op?.parameters ?? []);
158
+ const securityJson = JSON.stringify(op?.security ?? []);
159
+ ---
160
+
161
+ {op && (
162
+ <div class="wd-api-panel" data-api-panel data-method={op.method} data-path={op.path}>
163
+ <div class="wd-api-card">
164
+ <div class="wd-api-card-head">
165
+ <span class="wd-api-card-label">Request</span>
166
+ <div class="wd-api-card-actions">
167
+ <ApiLangSelect options={langOptions} rolePrefix="lang" ariaLabel="Language" />
168
+ <button type="button" class="wd-api-try-inline" data-role="open-modal">
169
+ Try <span class="wd-api-try-icon" aria-hidden="true">&#9656;</span>
170
+ </button>
171
+ </div>
172
+ </div>
173
+ <div class="wd-api-card-body">
174
+ {samples.map((s, i) => (
175
+ <Code
176
+ code={s.code}
177
+ lang={SNIPPET_LANG[s.id] ?? 'plaintext'}
178
+ themes={SNIPPET_THEMES}
179
+ class="wd-api-pre wd-api-pre-lines"
180
+ data-lang-panel={s.id}
181
+ style={`display: ${i === 0 ? 'block' : 'none'}`}
182
+ />
183
+ ))}
184
+ <button type="button" class="wd-api-code-copy-btn" data-role="copy-request" aria-label="Copy">⧉</button>
185
+ </div>
186
+ </div>
187
+
188
+ {responseExamples.length > 0 && (
189
+ <div class="wd-api-card">
190
+ <div class="wd-api-card-head">
191
+ <span class="wd-api-card-label">Response</span>
192
+ <div class="wd-api-card-actions">
193
+ <span class="wd-api-select-wrap wd-api-status-select-wrap">
194
+ <span class={`wd-api-status-dot ${responseExamples[0].tone}`} data-role="status-dot"></span>
195
+ <select class="wd-api-select" data-role="status-select" aria-label="Status">
196
+ {responseExamples.map((r) => <option value={r.status} data-tone={r.tone}>{r.status}</option>)}
197
+ </select>
198
+ </span>
199
+ </div>
200
+ </div>
201
+ <div class="wd-api-card-body">
202
+ {responseExamples.map((r, i) => (
203
+ <Code
204
+ code={r.code}
205
+ lang="json"
206
+ themes={SNIPPET_THEMES}
207
+ class="wd-api-pre wd-api-pre-lines"
208
+ data-status-panel={r.status}
209
+ style={`display: ${i === 0 ? 'block' : 'none'}`}
210
+ />
211
+ ))}
212
+ <button type="button" class="wd-api-code-copy-btn" data-role="copy-response" aria-label="Copy">⧉</button>
213
+ </div>
214
+ </div>
215
+ )}
216
+ </div>
217
+
218
+ <div class="wd-api-modal-overlay" data-role="modal-overlay" hidden>
219
+ <div class="wd-api-modal" role="dialog" aria-modal="true" aria-label="Try it">
220
+ <div class="wd-api-modal-head">
221
+ <div class="wd-api-modal-title">
222
+ <span class={`wd-api-method wd-api-method-${op.method.toLowerCase()}`}>{op.method}</span>
223
+ <span class="wd-api-modal-title-text">{modalTitle}</span>
224
+ </div>
225
+ {requestExampleEntries.length > 0 && (
226
+ <span class="wd-api-select-wrap wd-api-modal-example-wrap">
227
+ <select class="wd-api-select" data-role="example-select" aria-label="Example">
228
+ <option value="">Default</option>
229
+ {requestExampleEntries.map(([name, ex]) => (
230
+ <option value={name}>{ex.summary ?? name}</option>
231
+ ))}
232
+ </select>
233
+ </span>
234
+ )}
235
+ <div class="wd-api-modal-actions">
236
+ <button type="button" class="wd-api-send" data-role="send">Send <span aria-hidden="true">&#9656;</span></button>
237
+ <button type="button" class="wd-api-icon-btn" data-role="close-modal" aria-label="Close">&times;</button>
238
+ </div>
239
+ </div>
240
+ <div
241
+ class="wd-api-modal-body"
242
+ data-api-tryit
243
+ data-params={paramsJson}
244
+ data-security={securityJson}
245
+ data-examples={JSON.stringify(requestExamples)}
246
+ data-blank-body={blankRequestExample}
247
+ data-method={op.method}
248
+ data-path={op.path}
249
+ data-themes={JSON.stringify(SNIPPET_THEMES)}
250
+ data-proxy={proxyEnabled}
251
+ >
252
+ <div class="wd-api-modal-form">
253
+ <div class="wd-api-modal-url-bar">
254
+ <span class={`wd-api-method wd-api-method-${op.method.toLowerCase()}`}>{op.method}</span>
255
+ {servers.length > 1 ? (
256
+ // Multiple servers in the spec - a real <select>, not free
257
+ // text, since these are the only base URLs the operation
258
+ // actually supports. Each option's own label is the full
259
+ // concatenated URL (server + path), not just the server,
260
+ // so the control always reads as one complete request URL
261
+ // whether it's open or closed - never a bare host with the
262
+ // path implied elsewhere.
263
+ <select class="wd-api-url-select" data-role="base-url" aria-label="Server">
264
+ {servers.map((s) => (
265
+ <option value={s.url}>{s.url}{op.path}</option>
266
+ ))}
267
+ </select>
268
+ ) : (
269
+ // Single server (the common case) - nothing to choose
270
+ // between, so this is plain non-editable text rather than
271
+ // an input the reader might mistake for something they
272
+ // need to fill in. data-value carries the base URL alone
273
+ // (the part collectLiveRequest() below needs) since the
274
+ // element's own text content is the base+path combined.
275
+ <span class="wd-api-url-full" data-role="base-url" data-value={baseUrl}>{baseUrl}{op.path}</span>
276
+ )}
277
+ </div>
278
+
279
+ {headerParams.length > 0 && (
280
+ <div class="wd-api-field-group">
281
+ <span class="wd-api-field-group-label">Header</span>
282
+ {headerParams.map((p) => (
283
+ <label class="wd-api-field">
284
+ <span>{p.name}{p.required && <span class="wd-api-pill wd-api-pill-required">required</span>}</span>
285
+ <input type="text" class="wd-api-input" data-role="param" data-param-name={p.name} data-param-in="header" />
286
+ </label>
287
+ ))}
288
+ </div>
289
+ )}
290
+
291
+ {pathParams.length > 0 && (
292
+ <div class="wd-api-field-group">
293
+ <span class="wd-api-field-group-label">Path</span>
294
+ {pathParams.map((p) => (
295
+ <label class="wd-api-field">
296
+ <span>{p.name}{p.required && <span class="wd-api-pill wd-api-pill-required">required</span>}</span>
297
+ <input
298
+ type="text"
299
+ class="wd-api-input"
300
+ data-role="param"
301
+ data-param-name={p.name}
302
+ data-param-in="path"
303
+ value={String(p.example ?? p.schema?.example ?? p.schema?.default ?? '')}
304
+ />
305
+ </label>
306
+ ))}
307
+ </div>
308
+ )}
309
+
310
+ {queryParams.length > 0 && (
311
+ <div class="wd-api-field-group">
312
+ <span class="wd-api-field-group-label">Query</span>
313
+ {queryParams.map((p) => (
314
+ <label class="wd-api-field">
315
+ <span>{p.name}{p.required && <span class="wd-api-pill wd-api-pill-required">required</span>}</span>
316
+ <input
317
+ type="text"
318
+ class="wd-api-input"
319
+ data-role="param"
320
+ data-param-name={p.name}
321
+ data-param-in="query"
322
+ value={String(p.example ?? p.schema?.example ?? p.schema?.default ?? '')}
323
+ />
324
+ </label>
325
+ ))}
326
+ </div>
327
+ )}
328
+
329
+ {requestSchema && (
330
+ <div class="wd-api-field-group">
331
+ <span class="wd-api-field-group-label">Body</span>
332
+ <textarea class="wd-api-textarea" data-role="body" rows="8">{requestExample}</textarea>
333
+ </div>
334
+ )}
335
+ </div>
336
+
337
+ <div class="wd-api-modal-preview">
338
+ {op.security.length > 0 && (
339
+ <div class="wd-api-card wd-api-auth-card">
340
+ <div class="wd-api-card-head">
341
+ <span class="wd-api-card-label">Authentication</span>
342
+ </div>
343
+ <div class="wd-api-card-body wd-api-auth-card-body">
344
+ {op.security.map((s) => {
345
+ const label =
346
+ s.type === 'http' && s.scheme === 'bearer' ? 'Bearer token' :
347
+ s.type === 'http' && s.scheme === 'basic' ? 'Basic auth (user:pass)' :
348
+ s.name;
349
+ return (
350
+ <label class="wd-api-auth-field">
351
+ <input
352
+ type="text"
353
+ class="wd-api-input wd-api-auth-input"
354
+ data-role="auth"
355
+ data-auth-name={s.name}
356
+ data-auth-type={s.type}
357
+ data-auth-scheme={s.scheme ?? ''}
358
+ data-auth-in={s.in ?? ''}
359
+ placeholder={`<${label}>`}
360
+ />
361
+ <span class="wd-api-auth-icons">
362
+ <span class="wd-api-auth-icon" title="Sent with your request, kept only in this browser">
363
+ <svg width="11" height="11" viewBox="0 0 12 12" aria-hidden="true">
364
+ <rect x="2.5" y="5.5" width="7" height="5" rx="1" stroke="currentColor" stroke-width="1.1" fill="none" />
365
+ <path d="M4 5.5V4a2 2 0 0 1 4 0v1.5" stroke="currentColor" stroke-width="1.1" fill="none" stroke-linecap="round" />
366
+ </svg>
367
+ </span>
368
+ <span class="wd-api-auth-icon" title={label}>
369
+ <svg width="11" height="11" viewBox="0 0 12 12" aria-hidden="true">
370
+ <circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.1" fill="none" />
371
+ <text x="6" y="8.3" text-anchor="middle" font-size="6.5" fill="currentColor" stroke="none" font-family="sans-serif">?</text>
372
+ </svg>
373
+ </span>
374
+ </span>
375
+ </label>
376
+ );
377
+ })}
378
+ </div>
379
+ </div>
380
+ )}
381
+ <div class="wd-api-card">
382
+ <div class="wd-api-card-head">
383
+ <span class="wd-api-card-label">Request</span>
384
+ <ApiLangSelect options={langOptions} rolePrefix="modal-lang" ariaLabel="Language" />
385
+ </div>
386
+ <div class="wd-api-card-body">
387
+ {samples.map((s, i) => (
388
+ <pre
389
+ class="wd-api-pre"
390
+ data-modal-lang-panel={s.id}
391
+ style={`display: ${i === 0 ? 'block' : 'none'}`}
392
+ ><code>{s.code}</code></pre>
393
+ ))}
394
+ </div>
395
+ </div>
396
+ <div class="wd-api-card">
397
+ <div class="wd-api-card-head">
398
+ <span class="wd-api-card-label">Response</span>
399
+ </div>
400
+ <div class="wd-api-card-body">
401
+ <div class="wd-api-response-status" data-role="response-status"></div>
402
+ <pre class="wd-api-pre" data-role="response-body"><code></code></pre>
403
+ </div>
404
+ </div>
405
+ </div>
406
+ </div>
407
+ </div>
408
+ </div>
409
+ )}
410
+
411
+ <style is:global>
412
+ /* Widens the shared .wd-toc-col (defined in BaseLayout.astro) only on
413
+ pages that actually render this panel into it, rather than adding a
414
+ prop BaseLayout has to thread through just for this - :has() lets
415
+ the override live entirely in this component instead. */
416
+ .wd-toc-col:has(.wd-api-panel) {
417
+ width: 380px;
418
+ }
419
+ /* top/max-height match Sidebar.astro/TableOfContents.astro's own
420
+ sticky columns - see Sidebar.astro's comment on --wd-topbar-offset
421
+ (src/scripts/topbar-offset.ts), the topbar's real measured height. */
422
+ .wd-api-panel {
423
+ position: sticky;
424
+ top: var(--wd-topbar-offset, 5rem);
425
+ padding: 1.5rem 1.5rem 1.5rem 1rem;
426
+ max-height: calc(100vh - var(--wd-topbar-offset, 5rem));
427
+ overflow-y: auto;
428
+ display: flex;
429
+ flex-direction: column;
430
+ gap: 1rem;
431
+ }
432
+ .wd-api-card {
433
+ background: var(--wd-surface);
434
+ border: 1px solid var(--wd-border);
435
+ border-radius: 0.75rem;
436
+ /* Soft elevation - same scale TopBar/MobileMenu's own dropdown panel
437
+ (dropdown.css's .wd-dropdown-menu) already uses for a resting,
438
+ non-modal floating panel - what turns this from a flat bordered
439
+ box into a card that visually lifts off the page background,
440
+ matching the reference design's own drop shadow around both
441
+ cards. */
442
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
443
+ }
444
+ /* Sidebar-only (the Try-it modal reuses .wd-api-card too, inside
445
+ .wd-api-modal-preview - a plain scrolling column with no height cap
446
+ of its own, so it never needs any of this). .wd-api-panel is a flex
447
+ column with its own bounded max-height + overflow-y: auto (see that
448
+ rule's comment) - by default a flex item won't shrink below its own
449
+ content's natural height, so without help here, two cards taller
450
+ than the panel just push the *panel* into needing to scroll as a
451
+ whole (previously "fixed" by giving cards flex-shrink: 0, forcing
452
+ every code block to keep its own fixed max-height regardless of how
453
+ little room was actually left - the panel scrolling, not the code
454
+ blocks resizing to fit it, which is backwards from what a reader
455
+ actually wants: both Request and Response staying visible at once,
456
+ each shrinking to fit, only truly overflowing (this rule's own
457
+ overflow-y: auto, still very much intentional as a last resort) once
458
+ even a sensible minimum won't fit both.
459
+ flex: 0 1 auto (grow: 0, shrink: 1, basis: auto) is what makes a
460
+ card sit at its own natural size when there's room, and only shrink
461
+ - never stretch - when there isn't; min-height: 0 lifts the default
462
+ "never shrink below natural content height" floor so that shrink
463
+ can actually happen. The genuine floor lives one level down, on
464
+ .wd-api-card-head (never shrinks at all - the RESPONSE label,
465
+ language dropdown etc. always stay fully visible) and
466
+ .wd-api-card-body (shrinks, but never below a legible minimum - see
467
+ that rule's own comment).
468
+
469
+ Two things had to be true at once here, and getting only one right
470
+ broke the other (both tried and measured against a real headless
471
+ render before landing on this):
472
+
473
+ 1) min-height: 0 is required for the card to shrink at all below its
474
+ natural content height - flex items default to min-height: auto,
475
+ which without this override refuses to shrink a flex *container*
476
+ item below its own children's natural (max-content) size. Tried
477
+ leaving this at the default `auto` instead, on the theory that
478
+ auto would resolve to the "real" content-based minimum (head's
479
+ natural height + body's own 8rem floor, ~11rem) - measured
480
+ against a real build instead of assumed, and it doesn't: Chromium
481
+ resolves a flex container's automatic minimum size to something
482
+ much closer to its full natural (unshrunk) content height here,
483
+ not the smaller nested-floor value - so `auto` just pinned every
484
+ card at full height regardless of available space, the opposite
485
+ of what this whole redesign is for.
486
+
487
+ 2) But min-height: 0 alone throws away the card's *only* protection
488
+ against being shrunk smaller than its own children can actually
489
+ render at - .wd-api-panel's own flex-shrink distributes the
490
+ deficit across both cards using each card's min-height as its
491
+ floor, with no visibility into what's two levels further down
492
+ (card-head's flex-shrink: 0, card-body's own min-height: 8rem);
493
+ min-height: 0 tells that outer algorithm "I have no floor," so it
494
+ shrinks the card's own box smaller than head+body's genuine
495
+ minimum, and - since a shrunk-too-small flex item doesn't clip
496
+ its own overflowing children by default - the code block renders
497
+ at its real 8rem floor anyway, just spilling past the card's own
498
+ (too-small) box into the space belowe reserved for the next
499
+ sibling card. Measured this directly: at a short enough viewport,
500
+ card height read smaller than head-height + body-height combined.
501
+
502
+ The explicit calc() below is what actually fixes both at once: an
503
+ honest floor (safely above the ~2.95rem this card-head design
504
+ measures at across themes, rounded up to 3.5rem for headroom) plus
505
+ card-body's own real 8rem floor, so .wd-api-panel's shrink algorithm
506
+ now sees the true minimum instead of either extreme. */
507
+ .wd-api-panel .wd-api-card {
508
+ display: flex;
509
+ flex-direction: column;
510
+ flex: 0 1 auto;
511
+ min-height: calc(3.5rem + 8rem);
512
+ }
513
+ .wd-api-panel .wd-api-card-head {
514
+ flex-shrink: 0;
515
+ }
516
+ .wd-api-card-head {
517
+ display: flex;
518
+ align-items: center;
519
+ justify-content: space-between;
520
+ gap: 0.5rem;
521
+ padding: 0.65rem 0.85rem;
522
+ border-bottom: 1px solid var(--wd-border);
523
+ }
524
+ .wd-api-card-label {
525
+ font-size: 0.75rem;
526
+ font-weight: 700;
527
+ text-transform: uppercase;
528
+ letter-spacing: 0.05em;
529
+ color: var(--wd-text-muted);
530
+ }
531
+ .wd-api-card-actions {
532
+ display: flex;
533
+ align-items: center;
534
+ gap: 0.5rem;
535
+ }
536
+ /* Distinct tinted card so Authentication reads as its own concern
537
+ sitting above Request/Response, not a third copy of the same
538
+ neutral-surface card - color-mix over --wd-primary matches the
539
+ tinted-background convention used elsewhere (NavTree.astro's hover
540
+ state, the status dot colors below). */
541
+ .wd-api-auth-card {
542
+ background: color-mix(in srgb, var(--wd-primary) 6%, var(--wd-surface));
543
+ border-color: color-mix(in srgb, var(--wd-primary) 18%, var(--wd-border));
544
+ }
545
+ .wd-api-auth-card-body {
546
+ display: flex;
547
+ flex-direction: column;
548
+ gap: 0.6rem;
549
+ padding: 0.85rem;
550
+ }
551
+ .wd-api-auth-field {
552
+ display: flex;
553
+ align-items: center;
554
+ gap: 0.5rem;
555
+ }
556
+ .wd-api-auth-input {
557
+ flex: 1;
558
+ min-width: 0;
559
+ }
560
+ .wd-api-auth-icons {
561
+ display: flex;
562
+ align-items: center;
563
+ gap: 0.35rem;
564
+ flex-shrink: 0;
565
+ color: var(--wd-text-muted);
566
+ }
567
+ .wd-api-auth-icon {
568
+ display: inline-flex;
569
+ }
570
+ /* Wraps the remaining plain <select> controls on this panel - the
571
+ status select-with-dot (Response), the Try-it modal's Example picker,
572
+ and its multi-server base-URL select - in the same rounded-pill
573
+ chrome as ApiLangSelect.astro's own trigger button, so every control
574
+ in this family still reads as one kind of thing even though the
575
+ language pickers themselves moved to that separate icon+label+
576
+ checkmark dropdown rather than a native <select>. The wrapper owns
577
+ border/radius/background; the <select> inside is stripped down to
578
+ borderless/transparent (below) so it doesn't paint a second,
579
+ conflicting box on top of its own wrapper's. */
580
+ .wd-api-select-wrap {
581
+ display: inline-flex;
582
+ align-items: center;
583
+ gap: 0.35rem;
584
+ padding: 0.2rem 0.55rem;
585
+ border-radius: 999px;
586
+ border: 1px solid var(--wd-border);
587
+ background: var(--wd-background);
588
+ }
589
+ .wd-api-select {
590
+ border: none;
591
+ background: none;
592
+ padding: 0;
593
+ margin: 0;
594
+ font-size: 0.75rem;
595
+ font-family: inherit;
596
+ color: var(--wd-text);
597
+ cursor: pointer;
598
+ /* appearance: none + this file's own chevron svg (data-uri, same
599
+ stroke/curve as Expandable.astro's/NavTree.astro's own chevrons -
600
+ one visual "this is a dropdown" affordance across the whole site,
601
+ not a second one improvised just for this control) replaces each
602
+ browser's own inconsistently-styled native arrow, so the pill
603
+ reads the same in Chrome/Firefox/Safari alike. */
604
+ appearance: none;
605
+ -webkit-appearance: none;
606
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath d='M2 3.5L5 6.5L8 3.5' stroke='%23888' stroke-width='1.4' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
607
+ background-repeat: no-repeat;
608
+ background-position: right center;
609
+ padding-right: 1rem;
610
+ }
611
+ /* The dropdown list a native <select> opens is a separate,
612
+ browser-rendered popup that the rules above (all scoped to the
613
+ closed box) can't reach - left alone it renders with the OS/browser
614
+ default light popup (white background, near-black text) regardless
615
+ of the site's dark theme, which is exactly the jarring flash of
616
+ light seen against everything else here. Chrome/Firefox/Edge do
617
+ respect background-color/color set directly on <option>, so this
618
+ is the most that can be done without replacing the native <select>
619
+ with a fully custom listbox - Safari has weaker support and falls
620
+ back to its own default popup, but degrades no worse than before. */
621
+ .wd-api-select option,
622
+ .wd-api-url-select option {
623
+ background-color: var(--wd-surface);
624
+ color: var(--wd-text);
625
+ }
626
+ .wd-api-status-select-wrap {
627
+ padding-left: 0.45rem;
628
+ }
629
+ .wd-api-status-dot {
630
+ flex-shrink: 0;
631
+ width: 0.5rem;
632
+ height: 0.5rem;
633
+ border-radius: 999px;
634
+ background: var(--wd-text-muted);
635
+ }
636
+ .wd-api-status-dot-ok {
637
+ background: #16a34a;
638
+ }
639
+ .wd-api-status-dot-error {
640
+ background: #dc2626;
641
+ }
642
+ .wd-api-status-dot-neutral {
643
+ background: var(--wd-text-muted);
644
+ }
645
+ /* The "Try it" button used to be a separate full-width block below the
646
+ card; moved inline into the Request card's own header, next to the
647
+ language select, matching the reference design - a compact pill
648
+ button rather than a full-width bar. */
649
+ .wd-api-try-inline {
650
+ display: inline-flex;
651
+ align-items: center;
652
+ gap: 0.3rem;
653
+ padding: 0.3rem 0.75rem;
654
+ border-radius: 0.5rem;
655
+ border: none;
656
+ background: var(--wd-primary);
657
+ color: #fff;
658
+ font-size: 0.75rem;
659
+ font-weight: 600;
660
+ cursor: pointer;
661
+ }
662
+ .wd-api-try-icon {
663
+ font-size: 0.65rem;
664
+ }
665
+ .wd-api-icon-btn {
666
+ display: inline-flex;
667
+ align-items: center;
668
+ justify-content: center;
669
+ width: 1.8rem;
670
+ height: 1.8rem;
671
+ border-radius: 0.4rem;
672
+ border: none;
673
+ background: none;
674
+ color: var(--wd-text-muted);
675
+ cursor: pointer;
676
+ font-size: 0.95rem;
677
+ }
678
+ .wd-api-icon-btn:hover {
679
+ background: var(--wd-background);
680
+ color: var(--wd-text);
681
+ }
682
+ /* Clips its own content (a code block's baked-in Shiki background,
683
+ mainly) to the card's bottom corners - moved here from .wd-api-card
684
+ itself so that clip only ever applies to the body, not the head
685
+ above it, where ApiLangSelect.astro's dropdown menu now lives.
686
+ overflow: hidden directly on .wd-api-card clipped that menu too
687
+ whenever it opened past the card's own edge (visible on any
688
+ narrower/shorter card, e.g. a short request sample) - the head
689
+ never had a distinct background box of its own needing this same
690
+ clip, so it doesn't need overflow: hidden at all. */
691
+ .wd-api-card-body {
692
+ position: relative;
693
+ padding: 0.75rem;
694
+ overflow: hidden;
695
+ border-radius: 0 0 0.75rem 0.75rem;
696
+ }
697
+ /* Floating overlay on top of the code block itself, not a header
698
+ button - matches the reference design, where the copy affordance
699
+ sits at the top-right corner of the code area rather than beside
700
+ the language/status select. Always visible here (unlike
701
+ .wd-code-copy-btn's hover-reveal in [...slug].astro, the same idea
702
+ applied to prose code fences) - this is a persistent reference
703
+ panel a reader is actively using, not flowing article content where
704
+ a visible-on-hover control is less visually noisy. */
705
+ .wd-api-code-copy-btn {
706
+ position: absolute;
707
+ top: 0.65rem;
708
+ right: 0.65rem;
709
+ z-index: 1;
710
+ display: inline-flex;
711
+ align-items: center;
712
+ justify-content: center;
713
+ width: 1.7rem;
714
+ height: 1.7rem;
715
+ padding: 0;
716
+ border-radius: 0.4rem;
717
+ border: 1px solid var(--wd-border);
718
+ background: var(--wd-surface);
719
+ color: var(--wd-text-muted);
720
+ font-size: 0.85rem;
721
+ line-height: 1;
722
+ cursor: pointer;
723
+ }
724
+ .wd-api-code-copy-btn:hover {
725
+ color: var(--wd-text);
726
+ background: var(--wd-background);
727
+ }
728
+ /* Line-number gutter - the exact same CSS-counter approach
729
+ .wd-code-lines uses for a fenced (```) content code block
730
+ ([...slug].astro's own comment on that rule explains the mechanism)
731
+ - Astro's <Code/> (used for these sidebar samples) produces the
732
+ identical per-line `.line` span structure Shiki's markdown pipeline
733
+ does, so the same counter trick applies unchanged. Unconditional
734
+ here (no opt-in class/meta-flag needed, unlike article content) -
735
+ every request/response sample in this panel always shows line
736
+ numbers, matching the reference design. */
737
+ .wd-api-pre-lines code {
738
+ counter-reset: wd-api-code-line;
739
+ }
740
+ .wd-api-pre-lines .line {
741
+ counter-increment: wd-api-code-line;
742
+ }
743
+ .wd-api-pre-lines .line::before {
744
+ content: counter(wd-api-code-line);
745
+ display: inline-block;
746
+ width: 1.6em;
747
+ margin-right: 1em;
748
+ text-align: right;
749
+ color: var(--wd-text-muted);
750
+ user-select: none;
751
+ }
752
+ .wd-api-card-body .wd-api-pre {
753
+ border: none;
754
+ background: none;
755
+ padding: 0;
756
+ }
757
+ /* max-height + overflow-y (on top of the pre-existing overflow-x, for
758
+ a long single line like a curl command's own URL) is what makes a
759
+ long code block scroll *internally* instead of just growing the
760
+ card to fit it - without this, a request/response body long enough
761
+ to need it (the kitchen-sink POST .../variants example this was
762
+ caught against, comparing its 201/400/404 response bodies of very
763
+ different lengths) just kept growing the Request card taller every
764
+ time more of it needed to render, which - since both cards share
765
+ one sticky, fixed-max-height panel (.wd-api-panel, see its own
766
+ comment) - pushed the Response card further down and further out of
767
+ the panel's own bounded height each time, looking like it was
768
+ "getting more hidden" the more content the Request card grew to
769
+ fit. Bounding every .wd-api-pre's own height here fixes both at
770
+ once: the block itself gets a real scrollbar for content past this
771
+ height, and neither card can grow tall enough to squeeze its sibling
772
+ out of view anymore. Applies to every .wd-api-pre in this file
773
+ uniformly - the sidebar's Request/Response cards and the Try-it
774
+ modal's own preview panels (request samples + the live response
775
+ body) all share this one class, so one rule covers all of them. */
776
+ .wd-api-pre {
777
+ overflow-x: auto;
778
+ overflow-y: auto;
779
+ max-height: 20rem;
780
+ font-size: 0.8rem;
781
+ margin: 0;
782
+ }
783
+ .wd-api-pre code {
784
+ background: none;
785
+ padding: 0;
786
+ white-space: pre;
787
+ }
788
+ /* The sidebar request/response samples are rendered via astro:components'
789
+ <Code/> (see the frontmatter) so they get real Shiki highlighting
790
+ that follows the site's [data-theme] toggle. Earlier this forced
791
+ the baked-in background transparent to blend into .wd-api-card's
792
+ own surface color - but that strips a theme's background away
793
+ while keeping its foreground, which only looks right for themes
794
+ whose foreground happens to already have enough contrast against
795
+ the card's own (unrelated) surface color. A regular MDX fenced
796
+ code block never does this (see .wd-article pre.astro-code in
797
+ [...slug].astro) - it always keeps the theme's own background+
798
+ foreground pairing intact, which is what makes *any* Shiki theme
799
+ name safe to use on either side of writedocs.json's styles.codeblocks.
800
+ Matching that here instead: the card-body's own padding moves onto
801
+ the codeblock itself, so the theme's real background fills the
802
+ card edge-to-edge (clipped to its rounded corners by .wd-api-card's
803
+ own overflow: hidden) exactly like a regular content codeblock,
804
+ rather than floating disconnected from it. */
805
+ .wd-api-panel .wd-api-card-body {
806
+ padding: 0;
807
+ /* The genuine floor .wd-api-panel .wd-api-card's own comment refers
808
+ to: this can shrink (flex: 1 1 auto, min-height overridden down
809
+ from its own multi-rem default below) as the panel runs out of
810
+ room, but never past ~8rem - enough for a handful of code lines to
811
+ stay readable. display: flex here (not the .astro-code child
812
+ directly) is what lets that child claim 100% of whatever height
813
+ this ends up with via its own flex: 1 below, in place of the fixed
814
+ max-height: 20rem every other .wd-api-pre in this file still uses
815
+ (Try-it modal's own preview panels, .wd-api-pre's own comment) -
816
+ those sit in .wd-api-modal-preview, a plain scrolling column with
817
+ no height cap of its own, so they never hit this squeeze at all. */
818
+ display: flex;
819
+ flex-direction: column;
820
+ flex: 1 1 auto;
821
+ min-height: 8rem;
822
+ }
823
+ .wd-api-panel .wd-api-card-body .astro-code {
824
+ margin: 0;
825
+ padding: 0.75rem 0.9rem;
826
+ border-radius: 0;
827
+ /* Overrides .wd-api-pre's own max-height: 20rem (that rule's own
828
+ comment) - here the code block fills whatever height its
829
+ flex-column parent above actually has, shrinking or growing with
830
+ it, rather than sitting at a fixed height regardless of how much
831
+ (or little) room is actually available. min-height: 0 lets it
832
+ shrink freely - the real floor is the parent's own min-height:
833
+ 8rem above, not this element's. */
834
+ flex: 1 1 auto;
835
+ min-height: 0;
836
+ max-height: none;
837
+ }
838
+
839
+ .wd-api-card .wd-api-card-body .shiki {
840
+ border-radius: 0;
841
+ }
842
+ /* The modal's request preview starts as a plain <pre> (see the
843
+ frontmatter) and only gains the .astro-code class once
844
+ initApiPanel()'s renderModalPanel() swaps in real Shiki output -
845
+ lazily, the first time the modal is opened (see loadHighlighter()
846
+ in the <script> below). :has() lets the same edge-to-edge treatment
847
+ as the sidebar above apply the instant that happens, without any
848
+ extra JS to toggle a class on the card-body itself. Before that,
849
+ the plain-text panel keeps the ordinary blended-into-the-card look
850
+ via .wd-api-card-body .wd-api-pre above. */
851
+ .wd-api-modal-preview .wd-api-card-body:has(.astro-code) {
852
+ padding: 0;
853
+ }
854
+ .wd-api-modal-preview .wd-api-card-body .astro-code {
855
+ margin: 0;
856
+ padding: 0.75rem 0.9rem;
857
+ }
858
+
859
+ .wd-api-modal-overlay {
860
+ position: fixed;
861
+ inset: 0;
862
+ z-index: 100;
863
+ display: flex;
864
+ align-items: center;
865
+ justify-content: center;
866
+ padding: 2rem;
867
+ background: rgba(15, 23, 42, 0.5);
868
+ }
869
+ .wd-api-modal-overlay[hidden] { display: none; }
870
+ .wd-api-modal {
871
+ width: 100%;
872
+ max-width: min(96vw, 1440px);
873
+ max-height: 85vh;
874
+ display: flex;
875
+ flex-direction: column;
876
+ background: var(--wd-background);
877
+ border: 1px solid var(--wd-border);
878
+ border-radius: 0.75rem;
879
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
880
+ overflow: hidden;
881
+ }
882
+ .wd-api-modal-head {
883
+ display: flex;
884
+ align-items: center;
885
+ /* Not space-between: the title stays flush left and everything
886
+ else (examples select, then Send/Close) is pushed flush right by
887
+ .wd-api-modal-example-wrap's margin-left: auto below - so a spec
888
+ with no examples still ends up with title-left/actions-right,
889
+ the same layout space-between gave before this component had a
890
+ middle item to place. */
891
+ justify-content: flex-start;
892
+ gap: 1rem;
893
+ padding: 1rem 1.25rem;
894
+ border-bottom: 1px solid var(--wd-border);
895
+ flex-shrink: 0;
896
+ }
897
+ .wd-api-modal-title {
898
+ display: flex;
899
+ align-items: center;
900
+ gap: 0.6rem;
901
+ font-size: 0.95rem;
902
+ }
903
+ .wd-api-modal-title-text {
904
+ font-weight: 600;
905
+ color: var(--wd-text);
906
+ }
907
+ /* Only rendered when the operation's request body actually carries a
908
+ named `examples` map (see requestExampleEntries above) - sits
909
+ between the title and the Send/Close actions when present. */
910
+ .wd-api-modal-example-wrap {
911
+ flex-shrink: 0;
912
+ }
913
+ .wd-api-modal-actions {
914
+ display: flex;
915
+ align-items: center;
916
+ gap: 0.5rem;
917
+ /* Pushes just this element flush to the right edge, leaving the
918
+ title (and the examples select right after it, when present)
919
+ packed on the left per the mockup - works whether or not the
920
+ examples select is rendered, so a spec without any `examples`
921
+ still lands title-left/actions-right same as before this select
922
+ existed. */
923
+ margin-left: auto;
924
+ }
925
+ .wd-api-send {
926
+ padding: 0.5rem 1rem;
927
+ border-radius: 0.4rem;
928
+ border: none;
929
+ background: var(--wd-primary);
930
+ color: #fff;
931
+ font-size: 0.85rem;
932
+ font-weight: 600;
933
+ cursor: pointer;
934
+ }
935
+ .wd-api-send:disabled {
936
+ opacity: 0.6;
937
+ cursor: default;
938
+ }
939
+ .wd-api-modal-body {
940
+ display: grid;
941
+ /* Form (params/body inputs) gets roughly twice the preview
942
+ column's width - the form has more to lay out (header/path/
943
+ query groups, the body textarea, potentially long field lists),
944
+ while the preview is just two-three fixed-height cards. */
945
+ grid-template-columns: 2fr 1fr;
946
+ gap: 0;
947
+ overflow-y: auto;
948
+ }
949
+ .wd-api-modal-form {
950
+ padding: 1.25rem;
951
+ border-right: 1px solid var(--wd-border);
952
+ overflow-y: auto;
953
+ }
954
+ .wd-api-modal-preview {
955
+ padding: 1.25rem;
956
+ display: flex;
957
+ flex-direction: column;
958
+ gap: 1rem;
959
+ overflow-y: auto;
960
+ /* No background of its own - inherits .wd-api-modal's background,
961
+ same as .wd-api-modal-form on the other side of the divider, so
962
+ the two columns read as one surface. The Authentication/Request/
963
+ Response cards inside still pop off it via their own
964
+ background: var(--wd-surface) (.wd-api-card below). */
965
+ }
966
+ @media (max-width: 760px) {
967
+ .wd-api-modal-body {
968
+ grid-template-columns: 1fr;
969
+ }
970
+ .wd-api-modal-form {
971
+ border-right: none;
972
+ border-bottom: 1px solid var(--wd-border);
973
+ }
974
+ }
975
+ .wd-api-field {
976
+ display: flex;
977
+ flex-direction: column;
978
+ gap: 0.3rem;
979
+ margin-bottom: 0.75rem;
980
+ font-size: 0.85rem;
981
+ }
982
+ .wd-api-field > span:first-child {
983
+ color: var(--wd-text-muted);
984
+ font-weight: 500;
985
+ display: flex;
986
+ align-items: center;
987
+ gap: 0.4rem;
988
+ }
989
+ .wd-api-field-group {
990
+ margin-bottom: 1.1rem;
991
+ }
992
+ /* Replaces the old standalone "Base URL" labeled field - a single
993
+ method+URL bar where the URL itself is always one element showing
994
+ the complete request URL (server + path together), never an
995
+ editable text input split from a separately-boxed path. With one
996
+ server in the spec it's plain non-editable text (.wd-api-url-full);
997
+ with more than one, a real <select> (.wd-api-url-select) whose
998
+ every option's own label is already the full concatenated URL, so
999
+ the control still reads as one complete URL whether it's open or
1000
+ closed - see the markup above for which one renders. */
1001
+ .wd-api-modal-url-bar {
1002
+ display: flex;
1003
+ align-items: center;
1004
+ gap: 0.5rem;
1005
+ padding: 0.4rem 0.5rem;
1006
+ margin-bottom: 1.1rem;
1007
+ border: 1px solid var(--wd-border);
1008
+ border-radius: 0.5rem;
1009
+ background: var(--wd-background);
1010
+ }
1011
+ .wd-api-url-full {
1012
+ flex: 1;
1013
+ min-width: 0;
1014
+ overflow-wrap: anywhere;
1015
+ color: var(--wd-text);
1016
+ font-size: 0.85rem;
1017
+ font-family: monospace;
1018
+ }
1019
+ .wd-api-url-select {
1020
+ flex: 1;
1021
+ min-width: 0;
1022
+ border: none;
1023
+ background: none;
1024
+ padding: 0.2rem 1rem 0.2rem 0;
1025
+ color: var(--wd-text);
1026
+ font-size: 0.85rem;
1027
+ font-family: monospace;
1028
+ /* Same custom-chevron treatment as .wd-api-select below - unlike
1029
+ that one this isn't wrapped in a pill, since it needs to read as
1030
+ plain inline text flowing right after the method badge, not a
1031
+ separate control floating in the bar. */
1032
+ appearance: none;
1033
+ -webkit-appearance: none;
1034
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath d='M2 3.5L5 6.5L8 3.5' stroke='%23888' stroke-width='1.4' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
1035
+ background-repeat: no-repeat;
1036
+ background-position: right center;
1037
+ cursor: pointer;
1038
+ }
1039
+ .wd-api-url-select:focus {
1040
+ outline: none;
1041
+ }
1042
+ .wd-api-field-group-label {
1043
+ display: block;
1044
+ font-size: 1rem;
1045
+ font-weight: 600;
1046
+ color: var(--wd-text);
1047
+ margin-bottom: 0.6rem;
1048
+ padding-bottom: 0.4rem;
1049
+ border-bottom: 1px solid var(--wd-border);
1050
+ }
1051
+ .wd-api-input,
1052
+ .wd-api-textarea {
1053
+ width: 100%;
1054
+ padding: 0.5rem 0.65rem;
1055
+ border: 1px solid var(--wd-border);
1056
+ border-radius: 0.4rem;
1057
+ background: var(--wd-background);
1058
+ color: var(--wd-text);
1059
+ font-size: 0.85rem;
1060
+ font-family: inherit;
1061
+ }
1062
+ .wd-api-textarea {
1063
+ font-family: monospace;
1064
+ resize: vertical;
1065
+ }
1066
+ .wd-api-response-status {
1067
+ font-size: 0.8rem;
1068
+ font-weight: 600;
1069
+ margin-bottom: 0.5rem;
1070
+ color: var(--wd-text-muted);
1071
+ }
1072
+ .wd-api-response-status.wd-api-status-ok { color: #16a34a; }
1073
+ .wd-api-response-status.wd-api-status-error { color: #dc2626; }
1074
+ @media (max-width: 1150px) {
1075
+ .wd-api-panel { display: none; }
1076
+ }
1077
+ </style>
1078
+
1079
+ <script>
1080
+ // Shiki's WASM engine, exactly the 3 grammars the Try-it modal needs,
1081
+ // and the 2 writedocs.json styles.codeblocks themes are fetched lazily, only
1082
+ // once the modal is actually opened - not on every operation page load
1083
+ // - so the live-updating preview can use *real* Shiki highlighting
1084
+ // (matching every other codeblock on the site, see ApiReferencePanel's
1085
+ // own CSS comment on this) without adding that weight to pages nobody
1086
+ // ever opens Try-it on. Declared at true module scope (outside
1087
+ // initApiPanel) so it's fetched once and reused across astro:page-load
1088
+ // navigations between operation pages - they all share the same
1089
+ // writedocs.json styles.codeblocks, so one cached highlighter is always
1090
+ // correct regardless of which operation is currently active.
1091
+ //
1092
+ // Deliberately the fine-grained core API (shiki/core +
1093
+ // shiki/engine/oniguruma + the individual @shikijs/langs/* packages)
1094
+ // rather than shiki/bundle/web's own createHighlighter(): that
1095
+ // convenience wrapper's own docs note "importing this function will
1096
+ // bundle all languages and themes" - true even though each is only
1097
+ // *fetched* on demand, Vite still has to code-split and ship every
1098
+ // bundled language (100+, several hundred KB each for ts/tsx/jsx/...)
1099
+ // as its own chunk, just in case. Themes still come from shiki/themes'
1100
+ // full by-name lookup table (unavoidable - which theme is needed isn't
1101
+ // known until writedocs.json is read, so it can't be a static import like
1102
+ // the 3 languages below can), but that alone is a much smaller tax
1103
+ // than the language side would have been.
1104
+ interface ShikiHighlighter {
1105
+ codeToHtml(code: string, options: { lang: string; themes: { light: string; dark: string } }): string;
1106
+ }
1107
+ let wdHighlighter: ShikiHighlighter | null = null;
1108
+ let wdHighlighterPromise: Promise<ShikiHighlighter> | null = null;
1109
+ const MODAL_LANG_TO_SHIKI: Record<string, string> = {
1110
+ curl: 'bash',
1111
+ js: 'javascript',
1112
+ py: 'python',
1113
+ php: 'php',
1114
+ go: 'go',
1115
+ java: 'java',
1116
+ ruby: 'ruby',
1117
+ };
1118
+ // cors-anywhere-style proxy: forwards GET/POST/etc to the URL appended
1119
+ // after the base (protocol required), preserving method/headers/body,
1120
+ // and returns CORS headers so the browser fetch() below succeeds even
1121
+ // when the target API itself doesn't send Access-Control-Allow-Origin.
1122
+ // Toggle via writedocs.json's `api.proxy` (see data-proxy on the tryit
1123
+ // section below); the displayed curl/fetch/python snippets deliberately
1124
+ // keep showing the real, non-proxied URL - only this in-browser request
1125
+ // actually routes through the proxy.
1126
+ const PROXY_BASE_URL = 'https://proxy.writechoice.io/';
1127
+ function loadHighlighter(themes: { light: string; dark: string }): Promise<ShikiHighlighter> {
1128
+ if (!wdHighlighterPromise) {
1129
+ wdHighlighterPromise = Promise.all([
1130
+ import('shiki/core'),
1131
+ import('shiki/engine/oniguruma'),
1132
+ import('shiki/themes'),
1133
+ import('@shikijs/langs/bash'),
1134
+ import('@shikijs/langs/javascript'),
1135
+ import('@shikijs/langs/python'),
1136
+ import('@shikijs/langs/php'),
1137
+ import('@shikijs/langs/go'),
1138
+ import('@shikijs/langs/java'),
1139
+ import('@shikijs/langs/ruby'),
1140
+ ]).then(([core, engine, themeTable, bash, javascript, python, php, go, java, ruby]) =>
1141
+ core.createHighlighterCore({
1142
+ langs: [bash.default, javascript.default, python.default, php.default, go.default, java.default, ruby.default],
1143
+ themes: [themeTable.bundledThemes[themes.light](), themeTable.bundledThemes[themes.dark]()],
1144
+ engine: engine.createOnigurumaEngine(import('shiki/wasm')),
1145
+ })
1146
+ ) as Promise<ShikiHighlighter>;
1147
+ }
1148
+ return wdHighlighterPromise;
1149
+ }
1150
+ // Same github-light/github-dark fallback as resolveCodeblockTheme()
1151
+ // (src/lib/config.ts) - kept as a literal default here rather than
1152
+ // imported, since this <script> runs in the browser and can't reach
1153
+ // back into server-only modules; the data-themes JSON below is
1154
+ // already resolveCodeblockTheme()'s own output, so this only matters
1155
+ // if that attribute is ever missing or malformed.
1156
+ function parseThemes(raw: string | undefined): { light: string; dark: string } {
1157
+ try {
1158
+ const parsed = raw ? JSON.parse(raw) : {};
1159
+ return { light: parsed.light ?? 'github-light', dark: parsed.dark ?? 'github-dark' };
1160
+ } catch {
1161
+ return { light: 'github-light', dark: 'github-dark' };
1162
+ }
1163
+ }
1164
+
1165
+ // Drives the hidden <select data-role="{rolePrefix}-select"> rendered
1166
+ // by ApiLangSelect.astro from a click on its custom icon+label+
1167
+ // checkmark dropdown - open/close/outside-click/Escape all come free
1168
+ // from the site's shared initDropdowns() (already wired globally
1169
+ // against `document` by TopBar.astro/MobileMenu.astro), so this only
1170
+ // needs to: swap the trigger's icon/label to match the pick (by
1171
+ // cloning the clicked item's own already-rendered <svg> rather than
1172
+ // this script knowing any Iconify id itself), move which item shows
1173
+ // .active, and set+dispatch 'change' on the hidden select so every
1174
+ // listener already written against lang-select/modal-lang-select
1175
+ // below (panel-toggle, live-preview refresh) keeps working unchanged.
1176
+ function initLangDropdown(scope: ParentNode, rolePrefix: string) {
1177
+ const nativeSelect = scope.querySelector<HTMLSelectElement>(`[data-role="${rolePrefix}-select"]`);
1178
+ const trigger = scope.querySelector<HTMLElement>(`[data-role="${rolePrefix}-trigger"]`);
1179
+ // A distinct dataset key from plain wdInit - the trigger is also a
1180
+ // .wd-dropdown-trigger, and initDropdowns() (dropdowns.ts, wired
1181
+ // globally by TopBar.astro/MobileMenu.astro) already claims
1182
+ // trigger.dataset.wdInit as its own "have I wired open/close yet"
1183
+ // guard on this exact element. Sharing that key meant whichever of
1184
+ // the two ran first silently blocked the other from ever attaching -
1185
+ // in practice initDropdowns() always won the race, so the item click
1186
+ // listeners below were never being attached at all.
1187
+ if (!nativeSelect || !trigger || trigger.dataset.wdLangInit) return;
1188
+ trigger.dataset.wdLangInit = 'true';
1189
+ const items = Array.from(scope.querySelectorAll<HTMLButtonElement>(`[data-role="${rolePrefix}-item"]`));
1190
+ items.forEach((item) => {
1191
+ item.addEventListener('click', () => {
1192
+ items.forEach((it) => it.classList.toggle('active', it === item));
1193
+ const icon = item.querySelector('.wd-api-lang-icon');
1194
+ const triggerIcon = trigger.querySelector('.wd-api-lang-icon');
1195
+ if (icon && triggerIcon) triggerIcon.replaceWith(icon.cloneNode(true));
1196
+ const label = item.querySelector('.wd-api-lang-label')?.textContent ?? '';
1197
+ const triggerLabel = trigger.querySelector('.wd-api-lang-label');
1198
+ if (triggerLabel) triggerLabel.textContent = label;
1199
+ nativeSelect.value = item.dataset.value ?? '';
1200
+ nativeSelect.dispatchEvent(new Event('change'));
1201
+ });
1202
+ });
1203
+ }
1204
+
1205
+ function initApiPanel(root: ParentNode) {
1206
+ // Set once the tryit section below wires it up - there's exactly one
1207
+ // Try-it modal per operation page, so a single shared reference (like
1208
+ // the existing `document.querySelector('[data-role="send"]')` a few
1209
+ // lines down, which makes the same single-instance assumption) is all
1210
+ // that's needed for the open-modal handler in the loop below to
1211
+ // trigger a re-render once Shiki finishes loading.
1212
+ let refreshModalPreview: (() => void) | null = null;
1213
+
1214
+ root.querySelectorAll<HTMLElement>('[data-api-panel]').forEach((panel) => {
1215
+ if (panel.dataset.wdInit) return;
1216
+ panel.dataset.wdInit = 'true';
1217
+
1218
+ const langSelect = panel.querySelector<HTMLSelectElement>('[data-role="lang-select"]');
1219
+ const langPanels = Array.from(panel.querySelectorAll<HTMLElement>('[data-lang-panel]'));
1220
+ langSelect?.addEventListener('change', () => {
1221
+ langPanels.forEach((p) => {
1222
+ p.style.display = p.dataset.langPanel === langSelect.value ? 'block' : 'none';
1223
+ });
1224
+ });
1225
+ initLangDropdown(panel, 'lang');
1226
+
1227
+ const statusSelect = panel.querySelector<HTMLSelectElement>('[data-role="status-select"]');
1228
+ const statusPanels = Array.from(panel.querySelectorAll<HTMLElement>('[data-status-panel]'));
1229
+ const statusDot = panel.querySelector<HTMLElement>('[data-role="status-dot"]');
1230
+ statusSelect?.addEventListener('change', () => {
1231
+ statusPanels.forEach((p) => {
1232
+ p.style.display = p.dataset.statusPanel === statusSelect.value ? 'block' : 'none';
1233
+ });
1234
+ // Keeps the leading color dot (green/red/gray, set server-side
1235
+ // from statusToneClass() in the frontmatter - see this file's
1236
+ // own comment there) in sync with whichever status the reader
1237
+ // has switched to - each <option> carries its own tone as a
1238
+ // data attribute for exactly this, since the dot itself isn't
1239
+ // part of the native <select> box the browser renders.
1240
+ const tone = statusSelect.selectedOptions[0]?.dataset.tone;
1241
+ if (statusDot && tone) statusDot.className = `wd-api-status-dot ${tone}`;
1242
+ });
1243
+
1244
+ panel.querySelector('[data-role="copy-request"]')?.addEventListener('click', () => {
1245
+ const visible = langPanels.find((p) => p.style.display !== 'none');
1246
+ if (visible) navigator.clipboard?.writeText(visible.textContent ?? '');
1247
+ });
1248
+ panel.querySelector('[data-role="copy-response"]')?.addEventListener('click', () => {
1249
+ const visible = statusPanels.find((p) => p.style.display !== 'none');
1250
+ if (visible) navigator.clipboard?.writeText(visible.textContent ?? '');
1251
+ });
1252
+
1253
+ const overlay = document.querySelector<HTMLElement>('[data-role="modal-overlay"]');
1254
+ panel.querySelector('[data-role="open-modal"]')?.addEventListener('click', () => {
1255
+ if (overlay) {
1256
+ overlay.hidden = false;
1257
+ document.body.style.overflow = 'hidden';
1258
+ }
1259
+ if (!wdHighlighter) {
1260
+ const tryitSection = document.querySelector<HTMLElement>('[data-api-tryit]');
1261
+ const themes = parseThemes(tryitSection?.dataset.themes);
1262
+ loadHighlighter(themes).then((h) => {
1263
+ wdHighlighter = h;
1264
+ refreshModalPreview?.();
1265
+ });
1266
+ }
1267
+ });
1268
+ });
1269
+
1270
+ const overlay = root.querySelector<HTMLElement>('[data-role="modal-overlay"]');
1271
+ if (overlay && !overlay.dataset.wdInit) {
1272
+ overlay.dataset.wdInit = 'true';
1273
+ const close = () => {
1274
+ overlay.hidden = true;
1275
+ document.body.style.overflow = '';
1276
+ };
1277
+ overlay.addEventListener('click', (e) => {
1278
+ if (e.target === overlay) close();
1279
+ });
1280
+ overlay.querySelector('[data-role="close-modal"]')?.addEventListener('click', close);
1281
+ document.addEventListener('keydown', (e) => {
1282
+ if (e.key === 'Escape' && !overlay.hidden) close();
1283
+ });
1284
+
1285
+ const modalLangSelect = overlay.querySelector<HTMLSelectElement>('[data-role="modal-lang-select"]');
1286
+ const modalLangPanels = Array.from(overlay.querySelectorAll<HTMLElement>('[data-modal-lang-panel]'));
1287
+ modalLangSelect?.addEventListener('change', () => {
1288
+ modalLangPanels.forEach((p) => {
1289
+ p.style.display = p.dataset.modalLangPanel === modalLangSelect.value ? 'block' : 'none';
1290
+ });
1291
+ });
1292
+ initLangDropdown(overlay, 'modal-lang');
1293
+ }
1294
+
1295
+ root.querySelectorAll<HTMLElement>('[data-api-tryit]').forEach((section) => {
1296
+ if (section.dataset.wdInit) return;
1297
+ section.dataset.wdInit = 'true';
1298
+
1299
+ const method = section.dataset.method ?? 'GET';
1300
+ const opPath = section.dataset.path ?? '';
1301
+ const themes = parseThemes(section.dataset.themes);
1302
+ const proxyEnabled = section.dataset.proxy === 'true';
1303
+ // Either a plain <span data-value="..."> (single-server spec) or a
1304
+ // <select> (multiple servers) - see the markup comment above.
1305
+ // getBaseUrl() normalizes the two into one string, whichever
1306
+ // element actually rendered.
1307
+ const baseUrlEl = section.querySelector<HTMLElement>('[data-role="base-url"]');
1308
+ function getBaseUrl(): string {
1309
+ if (!baseUrlEl) return '';
1310
+ if (baseUrlEl instanceof HTMLSelectElement) return baseUrlEl.value;
1311
+ return baseUrlEl.dataset.value ?? '';
1312
+ }
1313
+ const authInputs = Array.from(section.querySelectorAll<HTMLInputElement>('[data-role="auth"]'));
1314
+ const paramInputs = Array.from(section.querySelectorAll<HTMLInputElement>('[data-role="param"]'));
1315
+ const bodyInput = section.querySelector<HTMLTextAreaElement>('[data-role="body"]');
1316
+ const sendBtn = document.querySelector<HTMLButtonElement>('[data-role="send"]');
1317
+ const responseStatusEl = section.querySelector<HTMLElement>('[data-role="response-status"]');
1318
+ const responseBodyEl = section.querySelector<HTMLElement>('[data-role="response-body"] code');
1319
+ const modalLangPanels = Array.from(section.querySelectorAll<HTMLElement>('[data-modal-lang-panel]'));
1320
+
1321
+ authInputs.forEach((input) => {
1322
+ const name = input.dataset.authName ?? '';
1323
+ try {
1324
+ const saved = localStorage.getItem(`wd-api-auth:${name}`);
1325
+ if (saved) input.value = saved;
1326
+ } catch {
1327
+ // localStorage unavailable - auth just won't persist across reloads
1328
+ }
1329
+ input.addEventListener('input', () => {
1330
+ try {
1331
+ localStorage.setItem(`wd-api-auth:${name}`, input.value);
1332
+ } catch {
1333
+ // ignore
1334
+ }
1335
+ });
1336
+ });
1337
+
1338
+ // Keeps the modal's request-sample preview (plain <pre><code> - see
1339
+ // the frontmatter) in sync with whatever the user has typed into
1340
+ // the form so far, substituting a bracketed placeholder (<name>,
1341
+ // <token>, ...) for anything still empty so the preview always
1342
+ // renders something readable instead of looking broken. This is
1343
+ // deliberately its own code path rather than reusing the Send
1344
+ // handler below: Send needs strict validation with user-facing
1345
+ // error messages, while this needs lenient, always-renders-
1346
+ // something behavior on every keystroke. It loosely mirrors the
1347
+ // structure of buildCurlSnippet/buildFetchSnippet/buildPythonSnippet
1348
+ // (src/lib/openapi-render.ts) - same per-language shape, live values
1349
+ // instead of a schema-derived example.
1350
+ function collectLiveRequest() {
1351
+ const baseUrl = (getBaseUrl().trim() || 'https://api.example.com').replace(/\/$/, '');
1352
+ let path = opPath;
1353
+ const queryParams: { name: string; value: string }[] = [];
1354
+ const headerParams: { name: string; value: string }[] = [];
1355
+ for (const input of paramInputs) {
1356
+ const name = input.dataset.paramName ?? '';
1357
+ const paramIn = input.dataset.paramIn;
1358
+ const value = input.value.trim() || `<${name}>`;
1359
+ if (paramIn === 'path') {
1360
+ path = path.replace(`{${name}}`, value);
1361
+ } else if (paramIn === 'query') {
1362
+ queryParams.push({ name, value });
1363
+ } else if (paramIn === 'header') {
1364
+ headerParams.push({ name, value });
1365
+ }
1366
+ }
1367
+
1368
+ const securityHeaders: { name: string; value: string }[] = [];
1369
+ const securityQuery: { name: string; value: string }[] = [];
1370
+ let basicAuth: string | null = null;
1371
+ for (const input of authInputs) {
1372
+ const name = input.dataset.authName ?? '';
1373
+ const type = input.dataset.authType;
1374
+ const scheme = input.dataset.authScheme;
1375
+ const authIn = input.dataset.authIn;
1376
+ const value = input.value.trim();
1377
+ if (type === 'apiKey' && authIn === 'header') {
1378
+ securityHeaders.push({ name, value: value || `<${name}>` });
1379
+ } else if (type === 'apiKey' && authIn === 'query') {
1380
+ securityQuery.push({ name, value: value || `<${name}>` });
1381
+ } else if (type === 'http' && scheme === 'bearer') {
1382
+ securityHeaders.push({ name: 'Authorization', value: `Bearer ${value || '<token>'}` });
1383
+ } else if (type === 'http' && scheme === 'basic') {
1384
+ basicAuth = value || '<username>:<password>';
1385
+ }
1386
+ }
1387
+
1388
+ const hasBodyField = !!bodyInput;
1389
+ const bodyRaw = bodyInput?.value.trim() ?? '';
1390
+
1391
+ return { method, baseUrl, path, queryParams, headerParams, securityHeaders, securityQuery, basicAuth, hasBodyField, bodyRaw };
1392
+ }
1393
+ type LiveRequestState = ReturnType<typeof collectLiveRequest>;
1394
+
1395
+ function liveCurlSnippet(state: LiveRequestState): string {
1396
+ const query = [...state.queryParams, ...state.securityQuery];
1397
+ const url =
1398
+ state.baseUrl + state.path + (query.length ? '?' + query.map((q) => `${q.name}=${q.value}`).join('&') : '');
1399
+ const lines = [`curl -X ${state.method} "${url}" \\`];
1400
+ if (state.hasBodyField) lines.push(` -H "Content-Type: application/json" \\`);
1401
+ for (const h of state.headerParams) lines.push(` -H "${h.name}: ${h.value}" \\`);
1402
+ for (const h of state.securityHeaders) lines.push(` -H "${h.name}: ${h.value}" \\`);
1403
+ if (state.basicAuth) lines.push(` -u "${state.basicAuth}" \\`);
1404
+ if (state.bodyRaw) lines.push(` -d '${state.bodyRaw}'`);
1405
+ const last = lines[lines.length - 1];
1406
+ lines[lines.length - 1] = last.endsWith('\\') ? last.slice(0, -2) : last;
1407
+ return lines.join('\n');
1408
+ }
1409
+
1410
+ function liveFetchSnippet(state: LiveRequestState): string {
1411
+ const url = state.baseUrl + state.path;
1412
+ const headers = [
1413
+ ...(state.hasBodyField ? [`'Content-Type': 'application/json'`] : []),
1414
+ ...state.securityHeaders.map((h) => `'${h.name}': '${h.value}'`),
1415
+ ];
1416
+ const optsLines = [
1417
+ `method: '${state.method}'`,
1418
+ headers.length ? `headers: { ${headers.join(', ')} }` : null,
1419
+ state.hasBodyField && state.bodyRaw ? `body: JSON.stringify(${state.bodyRaw})` : null,
1420
+ ].filter(Boolean);
1421
+ return `const response = await fetch(\`${url}\`, {\n ${optsLines.join(',\n ')}\n});\nconst data = await response.json();`;
1422
+ }
1423
+
1424
+ function livePythonSnippet(state: LiveRequestState): string {
1425
+ const url = state.baseUrl + state.path;
1426
+ const headers = state.securityHeaders.map((h) => `"${h.name}": "${h.value}"`);
1427
+ const lines = ['import requests', '', `response = requests.request(`, ` "${state.method}",`, ` "${url}",`];
1428
+ if (headers.length) lines.push(` headers={ ${headers.join(', ')} },`);
1429
+ if (state.hasBodyField && state.bodyRaw) {
1430
+ lines.push(
1431
+ ` json=${state.bodyRaw.replace(/\btrue\b/g, 'True').replace(/\bfalse\b/g, 'False').replace(/\bnull\b/g, 'None')},`
1432
+ );
1433
+ }
1434
+ lines.push(')', 'data = response.json()');
1435
+ return lines.join('\n');
1436
+ }
1437
+
1438
+ // php/go/java/ruby mirror liveFetchSnippet/livePythonSnippet's own
1439
+ // subset of liveCurlSnippet's behavior (no query-param URL
1440
+ // building, no basic-auth) - same precedent buildPhpSnippet/
1441
+ // buildGoSnippet/buildJavaSnippet/buildRubySnippet in
1442
+ // src/lib/openapi-render.ts follow for the static versions of
1443
+ // these same four, for the same reason (see that file's comment).
1444
+ function livePhpSnippet(state: LiveRequestState): string {
1445
+ const url = state.baseUrl + state.path;
1446
+ const headers = [
1447
+ ...(state.hasBodyField ? [`"Content-Type: application/json"`] : []),
1448
+ ...state.headerParams.map((h) => `"${h.name}: ${h.value}"`),
1449
+ ...state.securityHeaders.map((h) => `"${h.name}: ${h.value}"`),
1450
+ ];
1451
+ const lines = [
1452
+ '$ch = curl_init();',
1453
+ `curl_setopt($ch, CURLOPT_URL, "${url}");`,
1454
+ 'curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);',
1455
+ `curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "${state.method}");`,
1456
+ ];
1457
+ if (headers.length) lines.push(`curl_setopt($ch, CURLOPT_HTTPHEADER, [${headers.join(', ')}]);`);
1458
+ if (state.hasBodyField && state.bodyRaw) lines.push(`curl_setopt($ch, CURLOPT_POSTFIELDS, '${state.bodyRaw}');`);
1459
+ lines.push('$response = curl_exec($ch);', 'curl_close($ch);');
1460
+ return `<?php\n${lines.join('\n')}`;
1461
+ }
1462
+
1463
+ function liveGoSnippet(state: LiveRequestState): string {
1464
+ const url = state.baseUrl + state.path;
1465
+ const headers = [
1466
+ ...(state.hasBodyField ? [`req.Header.Set("Content-Type", "application/json")`] : []),
1467
+ ...state.headerParams.map((h) => `req.Header.Set("${h.name}", "${h.value}")`),
1468
+ ...state.securityHeaders.map((h) => `req.Header.Set("${h.name}", "${h.value}")`),
1469
+ ];
1470
+ const hasBody = state.hasBodyField && state.bodyRaw;
1471
+ const bodyExpr = hasBody ? `strings.NewReader(\`${state.bodyRaw}\`)` : 'nil';
1472
+ const lines = [
1473
+ 'package main',
1474
+ '',
1475
+ 'import (',
1476
+ '\t"fmt"',
1477
+ '\t"io"',
1478
+ '\t"net/http"',
1479
+ ...(hasBody ? ['\t"strings"'] : []),
1480
+ ')',
1481
+ '',
1482
+ 'func main() {',
1483
+ `\treq, _ := http.NewRequest("${state.method}", "${url}", ${bodyExpr})`,
1484
+ ...headers.map((h) => `\t${h}`),
1485
+ '',
1486
+ '\tresp, err := http.DefaultClient.Do(req)',
1487
+ '\tif err != nil {',
1488
+ '\t\tpanic(err)',
1489
+ '\t}',
1490
+ '\tdefer resp.Body.Close()',
1491
+ '',
1492
+ '\tdata, _ := io.ReadAll(resp.Body)',
1493
+ '\tfmt.Println(string(data))',
1494
+ '}',
1495
+ ];
1496
+ return lines.join('\n');
1497
+ }
1498
+
1499
+ function liveJavaSnippet(state: LiveRequestState): string {
1500
+ const url = state.baseUrl + state.path;
1501
+ const headers = [
1502
+ ...(state.hasBodyField ? [`.header("Content-Type", "application/json")`] : []),
1503
+ ...state.headerParams.map((h) => `.header("${h.name}", "${h.value}")`),
1504
+ ...state.securityHeaders.map((h) => `.header("${h.name}", "${h.value}")`),
1505
+ ];
1506
+ const hasBody = state.hasBodyField && state.bodyRaw;
1507
+ const bodyPublisher = hasBody
1508
+ ? `HttpRequest.BodyPublishers.ofString(${JSON.stringify(state.bodyRaw)})`
1509
+ : 'HttpRequest.BodyPublishers.noBody()';
1510
+ const lines = [
1511
+ 'HttpClient client = HttpClient.newHttpClient();',
1512
+ 'HttpRequest request = HttpRequest.newBuilder()',
1513
+ ` .uri(URI.create("${url}"))`,
1514
+ ...headers.map((h) => ` ${h}`),
1515
+ ` .method("${state.method}", ${bodyPublisher})`,
1516
+ ' .build();',
1517
+ '',
1518
+ 'HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());',
1519
+ 'System.out.println(response.body());',
1520
+ ];
1521
+ return lines.join('\n');
1522
+ }
1523
+
1524
+ function liveRubySnippet(state: LiveRequestState): string {
1525
+ const url = state.baseUrl + state.path;
1526
+ const methodClass = state.method.charAt(0).toUpperCase() + state.method.slice(1).toLowerCase();
1527
+ const headers = [
1528
+ ...(state.hasBodyField ? [`request["Content-Type"] = "application/json"`] : []),
1529
+ ...state.headerParams.map((h) => `request["${h.name}"] = "${h.value}"`),
1530
+ ...state.securityHeaders.map((h) => `request["${h.name}"] = "${h.value}"`),
1531
+ ];
1532
+ const hasBody = state.hasBodyField && state.bodyRaw;
1533
+ const lines = [
1534
+ 'require "net/http"',
1535
+ 'require "uri"',
1536
+ '',
1537
+ `uri = URI("${url}")`,
1538
+ 'http = Net::HTTP.new(uri.host, uri.port)',
1539
+ 'http.use_ssl = uri.scheme == "https"',
1540
+ '',
1541
+ `request = Net::HTTP::${methodClass}.new(uri)`,
1542
+ ...headers,
1543
+ ...(hasBody ? [`request.body = ${JSON.stringify(state.bodyRaw)}`] : []),
1544
+ '',
1545
+ 'response = http.request(request)',
1546
+ 'puts response.body',
1547
+ ];
1548
+ return lines.join('\n');
1549
+ }
1550
+
1551
+ const LIVE_SNIPPET_BUILDERS: Record<string, (state: LiveRequestState) => string> = {
1552
+ curl: liveCurlSnippet,
1553
+ js: liveFetchSnippet,
1554
+ py: livePythonSnippet,
1555
+ php: livePhpSnippet,
1556
+ go: liveGoSnippet,
1557
+ java: liveJavaSnippet,
1558
+ ruby: liveRubySnippet,
1559
+ };
1560
+
1561
+ // Renders one modal panel's code - through real Shiki once
1562
+ // wdHighlighter has loaded (see the open-modal click handler above),
1563
+ // so the live preview ends up looking identical to every other
1564
+ // codeblock on the site; until then, plain text keeps it accurate
1565
+ // without blocking on the network. codeToHtml() on an already-built
1566
+ // highlighter instance is synchronous (all langs/themes are already
1567
+ // loaded into it), so this is cheap enough to call on every
1568
+ // keystroke with no debouncing needed.
1569
+ function renderModalPanel(panel: HTMLElement, code: string) {
1570
+ if (!wdHighlighter) {
1571
+ const codeEl = panel.querySelector('code');
1572
+ if (codeEl) codeEl.textContent = code;
1573
+ return;
1574
+ }
1575
+ const shikiLang = MODAL_LANG_TO_SHIKI[panel.dataset.modalLangPanel ?? ''] ?? 'plaintext';
1576
+ const html = wdHighlighter.codeToHtml(code, { lang: shikiLang, themes });
1577
+ const template = document.createElement('template');
1578
+ template.innerHTML = html.trim();
1579
+ const rendered = template.content.firstElementChild as HTMLElement | null;
1580
+ if (!rendered) return;
1581
+ // Shiki's own output owns class/style (background, --shiki-dark-*
1582
+ // vars, etc. - same as the sidebar's <Code/> panels, see
1583
+ // ApiReferencePanel's CSS) but display: block/none is this
1584
+ // panel's own toggle state (driven by modalLangSelect above), not
1585
+ // something Shiki knows about - preserved across the swap rather
1586
+ // than clobbered by it.
1587
+ const currentDisplay = panel.style.display;
1588
+ panel.setAttribute('style', rendered.getAttribute('style') ?? '');
1589
+ panel.style.display = currentDisplay;
1590
+ panel.className = `${rendered.className} wd-api-pre astro-code`.trim();
1591
+ panel.innerHTML = rendered.innerHTML;
1592
+ }
1593
+
1594
+ function updateModalPreview() {
1595
+ if (modalLangPanels.length === 0) return;
1596
+ const state = collectLiveRequest();
1597
+ modalLangPanels.forEach((panel) => {
1598
+ const build = LIVE_SNIPPET_BUILDERS[panel.dataset.modalLangPanel ?? ''];
1599
+ if (build) renderModalPanel(panel, build(state));
1600
+ });
1601
+ }
1602
+ refreshModalPreview = updateModalPreview;
1603
+
1604
+ // Only a <select> fires a meaningful change here - the static
1605
+ // <span> case has nothing for the reader to alter, so there's no
1606
+ // listener to attach for it (getBaseUrl() above still reads its
1607
+ // fixed data-value on every other input's own 'input' event).
1608
+ if (baseUrlEl instanceof HTMLSelectElement) {
1609
+ baseUrlEl.addEventListener('change', updateModalPreview);
1610
+ }
1611
+ paramInputs.forEach((input) => input.addEventListener('input', updateModalPreview));
1612
+ authInputs.forEach((input) => input.addEventListener('input', updateModalPreview));
1613
+ bodyInput?.addEventListener('input', updateModalPreview);
1614
+ updateModalPreview();
1615
+
1616
+ // Examples select (only rendered when the spec's request body
1617
+ // carries a named `examples` map - see requestExampleEntries in
1618
+ // the frontmatter). Selecting one only ever touches the Body
1619
+ // field: OpenAPI's `examples` keyword lives on the request body's
1620
+ // media-type object, with no standard link to path/query/header
1621
+ // parameter values, so there's nothing else to populate from it.
1622
+ // "Default" (value="") is its own real option, not a no-op
1623
+ // placeholder - picking it resets the Body to blankRequestExample
1624
+ // (see the frontmatter's emptyValueFromSchema call), the same
1625
+ // field shape as a named example but with every value left empty,
1626
+ // so the reader still sees what the body needs instead of losing
1627
+ // the shape to a blank textarea.
1628
+ const exampleSelect = section.parentElement?.querySelector<HTMLSelectElement>('[data-role="example-select"]');
1629
+ const blankBody = section.dataset.blankBody ?? '';
1630
+ let examples: Record<string, { summary?: string; description?: string; value?: unknown }> = {};
1631
+ try {
1632
+ examples = JSON.parse(section.dataset.examples ?? '{}');
1633
+ } catch {
1634
+ // malformed/absent - examples select just won't populate anything
1635
+ }
1636
+ exampleSelect?.addEventListener('change', () => {
1637
+ if (!bodyInput) return;
1638
+ const chosen = examples[exampleSelect.value];
1639
+ bodyInput.value = chosen ? JSON.stringify(chosen.value, null, 2) : blankBody;
1640
+ updateModalPreview();
1641
+ });
1642
+ // Once the reader edits the body by hand, it no longer matches
1643
+ // whichever example (if any) is still shown selected - reset the
1644
+ // select back to "Default" so it doesn't keep claiming a match
1645
+ // that's now stale. Only fires on direct typing into the
1646
+ // textarea, not on the programmatic set above (that dispatches no
1647
+ // 'input' event), so picking an example doesn't immediately
1648
+ // un-pick itself.
1649
+ bodyInput?.addEventListener('input', () => {
1650
+ if (exampleSelect) exampleSelect.value = '';
1651
+ });
1652
+
1653
+ sendBtn?.addEventListener('click', async () => {
1654
+ if (!responseStatusEl || !responseBodyEl) return;
1655
+ const baseUrl = getBaseUrl().replace(/\/$/, '');
1656
+ if (!baseUrl) {
1657
+ responseStatusEl.textContent = 'No base URL is configured for this operation.';
1658
+ responseStatusEl.className = 'wd-api-response-status wd-api-status-error';
1659
+ responseBodyEl.textContent = '';
1660
+ return;
1661
+ }
1662
+
1663
+ let requestPath = opPath;
1664
+ const query = new URLSearchParams();
1665
+ for (const input of paramInputs) {
1666
+ const name = input.dataset.paramName ?? '';
1667
+ const paramIn = input.dataset.paramIn;
1668
+ const value = input.value;
1669
+ if (paramIn === 'path') {
1670
+ requestPath = requestPath.replace(`{${name}}`, encodeURIComponent(value));
1671
+ } else if (paramIn === 'query' && value) {
1672
+ query.set(name, value);
1673
+ }
1674
+ }
1675
+
1676
+ const headers: Record<string, string> = {};
1677
+ for (const input of paramInputs) {
1678
+ if (input.dataset.paramIn === 'header' && input.value) {
1679
+ headers[input.dataset.paramName ?? ''] = input.value;
1680
+ }
1681
+ }
1682
+ for (const input of authInputs) {
1683
+ const type = input.dataset.authType;
1684
+ const scheme = input.dataset.authScheme;
1685
+ const authIn = input.dataset.authIn;
1686
+ const value = input.value;
1687
+ if (!value) continue;
1688
+ if (type === 'apiKey' && authIn === 'header') {
1689
+ headers[input.dataset.authName ?? ''] = value;
1690
+ } else if (type === 'apiKey' && authIn === 'query') {
1691
+ query.set(input.dataset.authName ?? '', value);
1692
+ } else if (type === 'http' && scheme === 'bearer') {
1693
+ headers['Authorization'] = `Bearer ${value}`;
1694
+ } else if (type === 'http' && scheme === 'basic') {
1695
+ headers['Authorization'] = `Basic ${btoa(value)}`;
1696
+ }
1697
+ }
1698
+
1699
+ let body: string | undefined;
1700
+ if (bodyInput && bodyInput.value.trim()) {
1701
+ try {
1702
+ body = JSON.stringify(JSON.parse(bodyInput.value));
1703
+ headers['Content-Type'] = headers['Content-Type'] ?? 'application/json';
1704
+ } catch {
1705
+ responseStatusEl.textContent = 'Request body is not valid JSON.';
1706
+ responseStatusEl.className = 'wd-api-response-status wd-api-status-error';
1707
+ responseBodyEl.textContent = '';
1708
+ return;
1709
+ }
1710
+ }
1711
+
1712
+ const targetUrl = baseUrl + requestPath + (query.toString() ? `?${query.toString()}` : '');
1713
+ // Route through writedocs' CORS proxy unless disabled via writedocs.json's
1714
+ // `api.proxy: false` - see PROXY_BASE_URL above for the contract.
1715
+ const url = proxyEnabled ? `${PROXY_BASE_URL}${targetUrl}` : targetUrl;
1716
+
1717
+ if (sendBtn) {
1718
+ sendBtn.disabled = true;
1719
+ sendBtn.textContent = 'Sending...';
1720
+ }
1721
+ responseStatusEl.textContent = 'Sending request...';
1722
+ responseStatusEl.className = 'wd-api-response-status';
1723
+ responseBodyEl.textContent = '';
1724
+
1725
+ const startedAt = performance.now();
1726
+ try {
1727
+ const res = await fetch(url, { method, headers, body });
1728
+ const elapsed = Math.round(performance.now() - startedAt);
1729
+ const text = await res.text();
1730
+ let pretty = text;
1731
+ try {
1732
+ pretty = JSON.stringify(JSON.parse(text), null, 2);
1733
+ } catch {
1734
+ // not JSON - show raw text as-is
1735
+ }
1736
+ responseStatusEl.textContent = `${res.status} ${res.statusText} - ${elapsed}ms`;
1737
+ responseStatusEl.className = `wd-api-response-status ${res.ok ? 'wd-api-status-ok' : 'wd-api-status-error'}`;
1738
+ responseBodyEl.textContent = pretty;
1739
+ } catch (err) {
1740
+ responseStatusEl.textContent = 'Request failed - likely blocked by CORS, or the base URL is unreachable from your browser.';
1741
+ responseStatusEl.className = 'wd-api-response-status wd-api-status-error';
1742
+ responseBodyEl.textContent = err instanceof Error ? err.message : String(err);
1743
+ } finally {
1744
+ if (sendBtn) {
1745
+ sendBtn.disabled = false;
1746
+ sendBtn.innerHTML = 'Send <span aria-hidden="true">&#9656;</span>';
1747
+ }
1748
+ }
1749
+ });
1750
+ });
1751
+ }
1752
+ initApiPanel(document);
1753
+ document.addEventListener('astro:page-load', () => initApiPanel(document));
1754
+ </script>