@writedocs/generator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +17 -0
- package/astro.config.mjs +419 -0
- package/bin/writedocs.js +73 -0
- package/package.json +79 -0
- package/src/assets/wd_watermark.png +0 -0
- package/src/assets/wd_watermark_dark.png +0 -0
- package/src/cli/build-auth.js +53 -0
- package/src/cli/build.js +40 -0
- package/src/cli/dev.js +12 -0
- package/src/cli/generate-api-pages.js +359 -0
- package/src/cli/init.js +81 -0
- package/src/cli/preflight.js +40 -0
- package/src/cli/run-astro.js +57 -0
- package/src/cli/run-pagefind.js +66 -0
- package/src/cli/write-redirects-file.js +80 -0
- package/src/components/Accordion.astro +164 -0
- package/src/components/AccordionGroup.astro +40 -0
- package/src/components/ApiLangSelect.astro +168 -0
- package/src/components/ApiPlayground.astro +281 -0
- package/src/components/ApiReferencePanel.astro +1754 -0
- package/src/components/ApiSchemaField.astro +54 -0
- package/src/components/AppIcon.astro +32 -0
- package/src/components/Badge.astro +128 -0
- package/src/components/Callout.astro +168 -0
- package/src/components/Card.astro +136 -0
- package/src/components/CardGroup.astro +20 -0
- package/src/components/CodeGroup.astro +184 -0
- package/src/components/CopyPageMenu.astro +246 -0
- package/src/components/Danger.astro +12 -0
- package/src/components/Expandable.astro +126 -0
- package/src/components/Frame.astro +102 -0
- package/src/components/Hint.astro +99 -0
- package/src/components/Icon.astro +70 -0
- package/src/components/Image.astro +147 -0
- package/src/components/Info.astro +12 -0
- package/src/components/Note.astro +12 -0
- package/src/components/Parameter.astro +119 -0
- package/src/components/RequestExample.astro +33 -0
- package/src/components/ResponseExample.astro +19 -0
- package/src/components/Searchbar.astro +117 -0
- package/src/components/Step.astro +10 -0
- package/src/components/Steps.astro +32 -0
- package/src/components/Tab.astro +9 -0
- package/src/components/Tabs.astro +52 -0
- package/src/components/Tip.astro +12 -0
- package/src/components/Video.astro +135 -0
- package/src/components/Warning.astro +12 -0
- package/src/components/index.ts +48 -0
- package/src/content.config.ts +223 -0
- package/src/layout/BaseLayout.astro +750 -0
- package/src/layout/components/AnalyticsScripts.astro +77 -0
- package/src/layout/components/AskAiWidget.astro +37 -0
- package/src/layout/components/Breadcrumbs.astro +97 -0
- package/src/layout/components/ImageZoom.astro +19 -0
- package/src/layout/components/MobileMenu.astro +200 -0
- package/src/layout/components/NavTree.astro +351 -0
- package/src/layout/components/SearchModal.astro +42 -0
- package/src/layout/components/Sidebar.astro +122 -0
- package/src/layout/components/SiteFooter.astro +85 -0
- package/src/layout/components/TableOfContents.astro +117 -0
- package/src/layout/components/TopBar.astro +311 -0
- package/src/layout/styles/banner.css +44 -0
- package/src/layout/styles/base.css +234 -0
- package/src/layout/styles/dropdown.css +133 -0
- package/src/layout/styles/footer.css +108 -0
- package/src/layout/styles/image-zoom.css +50 -0
- package/src/layout/styles/mobile-menu.css +258 -0
- package/src/layout/styles/search-modal.css +122 -0
- package/src/layout/styles/topbar.css +437 -0
- package/src/lib/config.ts +2131 -0
- package/src/lib/mdx-auto-hydrate.js +70 -0
- package/src/lib/mdx-inject-builtins.js +87 -0
- package/src/lib/mdx-substitute-variables.js +66 -0
- package/src/lib/mdx-title-anchor-ids.js +84 -0
- package/src/lib/mermaid-rehype.js +72 -0
- package/src/lib/openapi-render.ts +479 -0
- package/src/lib/shiki-code-block.js +102 -0
- package/src/lib/shiki-copy-button.js +45 -0
- package/src/lib/styles-asset-integration.js +210 -0
- package/src/lib/writedocs-temp-dir.js +93 -0
- package/src/pages/404.astro +62 -0
- package/src/pages/[...slug].astro +1270 -0
- package/src/pages/[...slug].md.ts +78 -0
- package/src/pages/llms-full.txt.ts +71 -0
- package/src/pages/llms.txt.ts +141 -0
- package/src/scripts/banner.ts +20 -0
- package/src/scripts/dropdowns.ts +61 -0
- package/src/scripts/image-zoom.ts +66 -0
- package/src/scripts/mobile-menu.ts +55 -0
- package/src/scripts/search.ts +155 -0
- package/src/scripts/sidebar-scroll.ts +65 -0
- package/src/scripts/theme-toggle.ts +35 -0
- package/src/scripts/topbar-offset.ts +141 -0
- package/src/styles/global.css +18 -0
|
@@ -0,0 +1,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">▸</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">▸</span></button>
|
|
237
|
+
<button type="button" class="wd-api-icon-btn" data-role="close-modal" aria-label="Close">×</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">▸</span>';
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
});
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
initApiPanel(document);
|
|
1753
|
+
document.addEventListener('astro:page-load', () => initApiPanel(document));
|
|
1754
|
+
</script>
|