@stainless-api/docs 0.1.0-beta.78 → 0.1.0-beta.79
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/CHANGELOG.md +17 -0
- package/package.json +4 -4
- package/plugin/assets/languages/cli.svg +14 -0
- package/plugin/components/RequestBuilder/ParamEditor.tsx +55 -0
- package/plugin/components/RequestBuilder/SnippetStainlessIsland.tsx +107 -0
- package/plugin/components/RequestBuilder/index.tsx +31 -0
- package/plugin/components/RequestBuilder/props.ts +9 -0
- package/plugin/components/RequestBuilder/spec-helpers.ts +50 -0
- package/plugin/components/RequestBuilder/styles.css +67 -0
- package/plugin/components/SnippetCode.tsx +64 -40
- package/plugin/components/StainlessIslands.tsx +126 -0
- package/plugin/index.ts +1 -0
- package/plugin/languages.ts +2 -0
- package/plugin/loadPluginConfig.ts +4 -0
- package/plugin/react/Routing.tsx +5 -3
- package/plugin/routes/Docs.astro +9 -1
- package/plugin/vendor/preview.worker.docs.js +8292 -7260
- package/stl-docs/index.ts +3 -0
- package/styles/code.css +11 -0
- package/virtual-module.d.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @stainless-api/docs
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.79
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 32b2c88: add simple request fields to request builder
|
|
8
|
+
- 6fd3625: Introduce experimentalRequestBuilder to hydrate within code snippets
|
|
9
|
+
- 98166fd: Adds CLI docs
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 7a79858: more aggressively suppress dev-server waterfalls through ai-chat
|
|
14
|
+
- Updated dependencies [32b2c88]
|
|
15
|
+
- Updated dependencies [6fd3625]
|
|
16
|
+
- Updated dependencies [98166fd]
|
|
17
|
+
- @stainless-api/docs-ui@0.1.0-beta.59
|
|
18
|
+
- @stainless-api/docs-search@0.1.0-beta.12
|
|
19
|
+
|
|
3
20
|
## 0.1.0-beta.78
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stainless-api/docs",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.79",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"vite-plugin-prebundle-workers": "^0.2.0",
|
|
59
59
|
"web-worker": "^1.5.0",
|
|
60
60
|
"yaml": "^2.8.2",
|
|
61
|
-
"@stainless-api/docs-search": "0.1.0-beta.
|
|
62
|
-
"@stainless-api/docs-ui": "0.1.0-beta.
|
|
61
|
+
"@stainless-api/docs-search": "0.1.0-beta.12",
|
|
62
|
+
"@stainless-api/docs-ui": "0.1.0-beta.59",
|
|
63
63
|
"@stainless-api/ui-primitives": "0.1.0-beta.43"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"vite": "^6.4.1",
|
|
76
76
|
"zod": "^4.3.5",
|
|
77
77
|
"@stainless/eslint-config": "0.1.0-beta.1",
|
|
78
|
-
"@stainless/sdk-json": "^0.1.0-beta.
|
|
78
|
+
"@stainless/sdk-json": "^0.1.0-beta.3"
|
|
79
79
|
},
|
|
80
80
|
"scripts": {
|
|
81
81
|
"vendor-deps": "tsx scripts/vendor_deps.ts",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<svg
|
|
2
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
3
|
+
viewBox="0 0 20 20"
|
|
4
|
+
width="24"
|
|
5
|
+
height="24"
|
|
6
|
+
fill="nopne"
|
|
7
|
+
>
|
|
8
|
+
<path
|
|
9
|
+
fill-rule="evenodd"
|
|
10
|
+
d="M3.25 3A2.25 2.25 0 0 0 1 5.25v9.5A2.25 2.25 0 0 0 3.25 17h13.5A2.25 2.25 0 0 0 19 14.75v-9.5A2.25 2.25 0 0 0 16.75 3zm.943 8.752a.75.75 0 0 1 .055-1.06L6.128 9l-1.88-1.693a.75.75 0 1 1 1.004-1.114l2.5 2.25a.75.75 0 0 1 0 1.114l-2.5 2.25a.75.75 0 0 1-1.06-.055M9.75 10.25a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5z"
|
|
11
|
+
clip-rule="evenodd"
|
|
12
|
+
fill="#a2a1a1"
|
|
13
|
+
/>
|
|
14
|
+
</svg>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { Button } from '@stainless-api/ui-primitives';
|
|
3
|
+
import type { Param } from './spec-helpers';
|
|
4
|
+
import { InfoIcon } from 'lucide-react';
|
|
5
|
+
import { Input } from '@stainless-api/docs-ui/components';
|
|
6
|
+
|
|
7
|
+
function setHighlight(stainlessPath: string, highlighted: boolean) {
|
|
8
|
+
const ele = document.getElementById(stainlessPath);
|
|
9
|
+
if (!ele) return;
|
|
10
|
+
ele.classList.toggle('stldocs-property-highlighted', highlighted);
|
|
11
|
+
if (highlighted) {
|
|
12
|
+
if (location.hash) {
|
|
13
|
+
const prevScroll = document.documentElement.scrollTop;
|
|
14
|
+
location.hash = '';
|
|
15
|
+
document.documentElement.scrollTop = prevScroll;
|
|
16
|
+
}
|
|
17
|
+
if (document.body.clientWidth >= 1280) {
|
|
18
|
+
ele.scrollIntoView({
|
|
19
|
+
behavior: 'smooth',
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function htmlToText(html: string) {
|
|
26
|
+
const template = document.createElement('template');
|
|
27
|
+
template.innerHTML = html;
|
|
28
|
+
return template.content.textContent;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ParamEditor({ param }: { param: Param }) {
|
|
32
|
+
const type = useMemo(() => htmlToText(param.type), [param.type]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<label className="request-builder-param">
|
|
36
|
+
<Button
|
|
37
|
+
className="request-builder-param-info-button"
|
|
38
|
+
variant="ghost"
|
|
39
|
+
href={`#${encodeURIComponent(param.stainlessPath)}`}
|
|
40
|
+
>
|
|
41
|
+
<Button.Icon icon={InfoIcon} size={16} />
|
|
42
|
+
</Button>
|
|
43
|
+
|
|
44
|
+
<span className="request-builder-param-label">{param.key}</span>
|
|
45
|
+
<span className="request-builder-param-colon">:</span>
|
|
46
|
+
|
|
47
|
+
<Input
|
|
48
|
+
className="request-builder-param-value"
|
|
49
|
+
onFocus={() => setHighlight(param.stainlessPath, true)}
|
|
50
|
+
onBlur={() => setHighlight(param.stainlessPath, false)}
|
|
51
|
+
placeholder={type}
|
|
52
|
+
/>
|
|
53
|
+
</label>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useState, useMemo, useEffect, useId, useSyncExternalStore, Activity, Fragment } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { Button } from '@stainless-api/ui-primitives';
|
|
4
|
+
import { PlayIcon, RotateCcw } from 'lucide-react';
|
|
5
|
+
import { SnippetStainlessIslandPropsSchema } from './props';
|
|
6
|
+
import { ParamEditor } from './ParamEditor';
|
|
7
|
+
import './styles.css';
|
|
8
|
+
|
|
9
|
+
function useRequiredChild<T extends Element = Element>(
|
|
10
|
+
parent: Element | null,
|
|
11
|
+
selector: string,
|
|
12
|
+
): React.RefObject<T> {
|
|
13
|
+
const elementRef = useMemo(() => {
|
|
14
|
+
const el = parent?.querySelector<T>(selector);
|
|
15
|
+
return el ? { current: el } : null;
|
|
16
|
+
}, [parent, selector]);
|
|
17
|
+
if (!elementRef) throw new Error(`Required child not found: ${selector}`);
|
|
18
|
+
return elementRef;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function useSetVisibility(elementRef: React.RefObject<HTMLElement>, visible: boolean) {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
elementRef.current.style.display = visible ? '' : 'none';
|
|
24
|
+
}, [elementRef, visible]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function SnippetStainlessIsland({ parent }: { parent: HTMLElement }) {
|
|
28
|
+
const [expanded, setExpanded] = useState(false);
|
|
29
|
+
|
|
30
|
+
const trigger = useRequiredChild<HTMLButtonElement>(parent, '.try-it-footer .try-it-button');
|
|
31
|
+
const codeContainer = useRequiredChild<HTMLElement>(parent, '.stldocs-snippet-code');
|
|
32
|
+
const exampleContainer = useRequiredChild<HTMLElement>(parent, '.stldocs-snippet-multi-response');
|
|
33
|
+
useSetVisibility(codeContainer, !expanded);
|
|
34
|
+
useSetVisibility(exampleContainer, !expanded);
|
|
35
|
+
useSetVisibility(trigger, !expanded);
|
|
36
|
+
// Attach click handler to trigger
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!trigger.current) return;
|
|
39
|
+
const ac = new AbortController();
|
|
40
|
+
trigger.current.addEventListener('click', () => setExpanded(true), { signal: ac.signal });
|
|
41
|
+
return () => ac.abort();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const requestBuilderContainer = useRequiredChild<HTMLElement>(parent, '.request-builder-container');
|
|
45
|
+
const requestBuilderFooter = useRequiredChild<HTMLElement>(parent, '.request-builder-footer');
|
|
46
|
+
const requestBuilderResponse = useRequiredChild<HTMLElement>(parent, '.request-builder-response');
|
|
47
|
+
const requestBuilderProps = useRequiredChild<HTMLTemplateElement>(parent, '.request-builder-props').current;
|
|
48
|
+
const serializedProps = useSyncExternalStore(
|
|
49
|
+
(cb) => {
|
|
50
|
+
const mutationObserver = new MutationObserver(() => cb());
|
|
51
|
+
mutationObserver.observe(requestBuilderProps, { childList: true });
|
|
52
|
+
return () => mutationObserver.disconnect();
|
|
53
|
+
},
|
|
54
|
+
() => requestBuilderProps.content.textContent,
|
|
55
|
+
);
|
|
56
|
+
const deserializedProps = SnippetStainlessIslandPropsSchema.parse(JSON.parse(serializedProps));
|
|
57
|
+
const groupedParams = Map.groupBy(deserializedProps.params, (p) => p.location);
|
|
58
|
+
|
|
59
|
+
const formId = `request-builder-form-${useId()}`;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
{createPortal(
|
|
64
|
+
<Activity mode={expanded ? 'visible' : 'hidden'}>
|
|
65
|
+
<form
|
|
66
|
+
onSubmit={(e) => {
|
|
67
|
+
alert('TODO: Submit button clicked');
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
}}
|
|
70
|
+
id={formId}
|
|
71
|
+
>
|
|
72
|
+
{[...groupedParams.entries()].map(([location, params]) => (
|
|
73
|
+
<Fragment key={location}>
|
|
74
|
+
<h4>{location} parameters</h4>
|
|
75
|
+
{params.map((e) => (
|
|
76
|
+
<ParamEditor param={e} key={e.location + e.key} />
|
|
77
|
+
))}
|
|
78
|
+
</Fragment>
|
|
79
|
+
))}
|
|
80
|
+
</form>
|
|
81
|
+
</Activity>,
|
|
82
|
+
requestBuilderContainer.current,
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{createPortal(
|
|
86
|
+
<Activity mode={expanded ? 'visible' : 'hidden'}>
|
|
87
|
+
<Button variant="ghost" border={true} onClick={() => setExpanded(false)}>
|
|
88
|
+
<Button.Icon icon={RotateCcw} />
|
|
89
|
+
</Button>
|
|
90
|
+
|
|
91
|
+
<Button variant="success" className="send-button" type="submit" form={formId}>
|
|
92
|
+
<Button.Label>Send</Button.Label>
|
|
93
|
+
<Button.Icon icon={PlayIcon} />
|
|
94
|
+
</Button>
|
|
95
|
+
</Activity>,
|
|
96
|
+
requestBuilderFooter.current,
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
{createPortal(
|
|
100
|
+
<Activity mode={expanded ? 'visible' : 'hidden'}>
|
|
101
|
+
<div>{/* TODO */}</div>
|
|
102
|
+
</Activity>,
|
|
103
|
+
requestBuilderResponse.current,
|
|
104
|
+
)}
|
|
105
|
+
</>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import * as SDKJSON from '@stainless/sdk-json';
|
|
3
|
+
import { useSpec } from '@stainless-api/docs-ui/contexts';
|
|
4
|
+
import { extractParams } from './spec-helpers';
|
|
5
|
+
import type { SnippetStainlessIslandProps } from './props';
|
|
6
|
+
|
|
7
|
+
/** Load and process the spec on the server side to avoid inflating client bundle */
|
|
8
|
+
export function RequestBuilder({
|
|
9
|
+
className,
|
|
10
|
+
children,
|
|
11
|
+
method,
|
|
12
|
+
}: {
|
|
13
|
+
className: string;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
method: SDKJSON.Method;
|
|
16
|
+
}) {
|
|
17
|
+
const spec = useSpec();
|
|
18
|
+
if (!spec) throw new Error('Spec is required for RequestBuilder');
|
|
19
|
+
const params = spec && extractParams(spec, method);
|
|
20
|
+
const [httpMethod, path] = method.endpoint.split(' ') as [string, string];
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<stl-island component="SnippetStainlessIsland" className={className}>
|
|
24
|
+
{/* Pass state down to the client component. TODO: we need a better solution for this */}
|
|
25
|
+
<template className="request-builder-props">
|
|
26
|
+
{JSON.stringify({ method: httpMethod, path, params } satisfies SnippetStainlessIslandProps)}
|
|
27
|
+
</template>
|
|
28
|
+
{children}
|
|
29
|
+
</stl-island>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import { ParamSchema } from './spec-helpers';
|
|
3
|
+
|
|
4
|
+
export const SnippetStainlessIslandPropsSchema = z.object({
|
|
5
|
+
method: z.string(),
|
|
6
|
+
path: z.string(),
|
|
7
|
+
params: z.array(ParamSchema),
|
|
8
|
+
});
|
|
9
|
+
export type SnippetStainlessIslandProps = z.infer<typeof SnippetStainlessIslandPropsSchema>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type * as SDKJSON from '@stainless/sdk-json';
|
|
2
|
+
import { printer } from '@stainless-api/docs-ui/markdown';
|
|
3
|
+
import z from 'zod';
|
|
4
|
+
|
|
5
|
+
export const ParamSchema = z.object({
|
|
6
|
+
stainlessPath: z.string(),
|
|
7
|
+
location: z.string(),
|
|
8
|
+
key: z.string(),
|
|
9
|
+
type: z.string(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export type Param = z.infer<typeof ParamSchema>;
|
|
13
|
+
|
|
14
|
+
export function extractParams(spec: SDKJSON.Spec | undefined, method: SDKJSON.Method): Param[] {
|
|
15
|
+
const httpDecls = spec?.decls?.http;
|
|
16
|
+
if (!httpDecls) throw new Error('expected http language to be present in SDKJSON');
|
|
17
|
+
const decl = httpDecls?.[method.stainlessPath];
|
|
18
|
+
if (decl?.kind !== 'HttpDeclFunction') {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'expected HttpDeclFunction at stainlessPath "' + method.stainlessPath + '", got ' + decl?.kind,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const bodyTypes = Object.keys(decl.bodyParamsChildren ?? {});
|
|
24
|
+
if (bodyTypes.length > 0 && !bodyTypes.includes('application/json')) {
|
|
25
|
+
throw new Error('TODO: support non-json body params');
|
|
26
|
+
}
|
|
27
|
+
const bodyParams = decl.bodyParamsChildren?.['application/json'];
|
|
28
|
+
const params = [
|
|
29
|
+
...Object.entries(decl.paramsChildren ?? {}).map(([location, children]) => ({ location, children })),
|
|
30
|
+
...(bodyParams ? [{ location: 'body', children: bodyParams }] : []),
|
|
31
|
+
]
|
|
32
|
+
.filter((e) => e.children.length)
|
|
33
|
+
.flatMap(({ location, children }) =>
|
|
34
|
+
children.map((child) => {
|
|
35
|
+
const resolved = httpDecls[child];
|
|
36
|
+
if (resolved?.kind !== 'HttpDeclProperty') {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'expected HttpDeclProperty at stainlessPath "' + child + '", got ' + resolved?.kind,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
stainlessPath: resolved.stainlessPath,
|
|
43
|
+
location,
|
|
44
|
+
key: resolved.key,
|
|
45
|
+
type: (resolved.optional ? 'optional ' : '') + printer.type('http', resolved.type),
|
|
46
|
+
};
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
return params;
|
|
50
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
.request-builder-container form {
|
|
2
|
+
display: grid;
|
|
3
|
+
|
|
4
|
+
/* prettier-ignore */
|
|
5
|
+
grid-template-columns: /*info button*/max-content /*label*/max-content /*colon*/max-content /*input*/1fr;
|
|
6
|
+
font-family: var(--stl-typography-font);
|
|
7
|
+
font-size: var(--stl-typography-text-body-sm);
|
|
8
|
+
color: var(--stl-color-foreground);
|
|
9
|
+
|
|
10
|
+
& > * {
|
|
11
|
+
padding-inline-start: 12px;
|
|
12
|
+
padding-inline-end: 8px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
& > h4 {
|
|
16
|
+
grid-column: 1 / -1;
|
|
17
|
+
text-transform: capitalize;
|
|
18
|
+
font-size: var(--stl-typography-scale-sm);
|
|
19
|
+
margin-top: 0;
|
|
20
|
+
margin-bottom: 0.25em;
|
|
21
|
+
color: var(--stl-color-foreground-muted);
|
|
22
|
+
padding-top: 0.75rem;
|
|
23
|
+
|
|
24
|
+
&:not(:first-child) {
|
|
25
|
+
border-top: 1px solid var(--stl-color-border);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.request-builder-param {
|
|
30
|
+
grid-column: 1 / -1;
|
|
31
|
+
display: grid;
|
|
32
|
+
grid-template-columns: subgrid;
|
|
33
|
+
align-items: center;
|
|
34
|
+
column-gap: 0.5rem;
|
|
35
|
+
padding-block: 0.35rem;
|
|
36
|
+
|
|
37
|
+
.request-builder-param-info-button {
|
|
38
|
+
color: var(--stl-color-foreground-muted);
|
|
39
|
+
margin-inline: -4px;
|
|
40
|
+
padding: 0;
|
|
41
|
+
}
|
|
42
|
+
.request-builder-param-label {
|
|
43
|
+
font-family: var(--stl-typography-font-mono);
|
|
44
|
+
}
|
|
45
|
+
.request-builder-param-colon {
|
|
46
|
+
font-family: var(--stl-typography-font-mono);
|
|
47
|
+
color: var(--stl-color-foreground-muted);
|
|
48
|
+
}
|
|
49
|
+
/* TODO: new input component that is better stylable */
|
|
50
|
+
.request-builder-param-value {
|
|
51
|
+
input {
|
|
52
|
+
margin: 6px 8px;
|
|
53
|
+
font-size: inherit;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
&:has(+ h4),
|
|
58
|
+
&:last-child {
|
|
59
|
+
padding-bottom: 0.75rem;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.request-builder-footer .stl-ui-button--ghost .stl-ui-button__icon {
|
|
65
|
+
color: var(--stl-color-foreground);
|
|
66
|
+
opacity: var(--stl-opacity-level-040);
|
|
67
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
SnippetCodeProps,
|
|
3
|
-
SnippetContainerProps,
|
|
4
|
-
|
|
1
|
+
import {
|
|
2
|
+
type SnippetCodeProps,
|
|
3
|
+
type SnippetContainerProps,
|
|
4
|
+
SnippetResponse as DocsUiSnippetResponse,
|
|
5
5
|
} from '@stainless-api/docs-ui/components';
|
|
6
6
|
import { useHighlight, useLanguage } from '@stainless-api/docs-ui/contexts';
|
|
7
7
|
import style from '@stainless-api/docs-ui/style';
|
|
@@ -9,10 +9,13 @@ import * as cheerio from 'cheerio/slim';
|
|
|
9
9
|
import {
|
|
10
10
|
EXPERIMENTAL_COLLAPSIBLE_SNIPPETS,
|
|
11
11
|
EXPERIMENTAL_PLAYGROUNDS,
|
|
12
|
+
EXPERIMENTAL_REQUEST_BUILDER,
|
|
12
13
|
} from 'virtual:stl-starlight-virtual-module';
|
|
13
14
|
import clsx from 'clsx';
|
|
14
15
|
import { Button } from '@stainless-api/ui-primitives';
|
|
15
|
-
import { CopyIcon } from 'lucide-react';
|
|
16
|
+
import { CopyIcon, PlayIcon } from 'lucide-react';
|
|
17
|
+
import React from 'react';
|
|
18
|
+
import { RequestBuilder } from './RequestBuilder';
|
|
16
19
|
|
|
17
20
|
function PlaygroundIcon() {
|
|
18
21
|
return (
|
|
@@ -149,38 +152,18 @@ function useIsCollapsible({ signature }: { signature?: string }): boolean {
|
|
|
149
152
|
return Boolean(EXPERIMENTAL_COLLAPSIBLE_SNIPPETS && signature && language);
|
|
150
153
|
}
|
|
151
154
|
|
|
152
|
-
export function
|
|
155
|
+
export function SnippetContainer({ children, signature, method }: SnippetContainerProps) {
|
|
153
156
|
const isCollapsible = useIsCollapsible({ signature });
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{children}
|
|
158
|
-
{signature && isCollapsible && (
|
|
159
|
-
<Button
|
|
160
|
-
className={'stl-snippet-expand-button'}
|
|
161
|
-
id="stl-snippet-expand-button"
|
|
162
|
-
size="sm"
|
|
163
|
-
variant="outline"
|
|
164
|
-
>
|
|
165
|
-
Show more
|
|
166
|
-
</Button>
|
|
167
|
-
)}
|
|
168
|
-
</div>
|
|
157
|
+
const className = clsx(
|
|
158
|
+
style.Snippet,
|
|
159
|
+
isCollapsible ? 'stl-snippet-collapsible' : 'stl-snippet-non-collapsible',
|
|
169
160
|
);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
export function SnippetContainer({ children, signature }: SnippetContainerProps) {
|
|
173
|
-
const isCollapsible = useIsCollapsible({ signature });
|
|
174
|
-
|
|
175
|
-
return (
|
|
176
|
-
<div
|
|
177
|
-
className={clsx(
|
|
178
|
-
style.Snippet,
|
|
179
|
-
isCollapsible ? 'stl-snippet-collapsible' : 'stl-snippet-non-collapsible',
|
|
180
|
-
)}
|
|
181
|
-
>
|
|
161
|
+
return EXPERIMENTAL_REQUEST_BUILDER ? (
|
|
162
|
+
<RequestBuilder className={className} method={method}>
|
|
182
163
|
{children}
|
|
183
|
-
</
|
|
164
|
+
</RequestBuilder>
|
|
165
|
+
) : (
|
|
166
|
+
<div className={className}>{children}</div>
|
|
184
167
|
);
|
|
185
168
|
}
|
|
186
169
|
|
|
@@ -218,11 +201,52 @@ export function SnippetCode({ content, signature, language: forcedLanguage }: Sn
|
|
|
218
201
|
}
|
|
219
202
|
|
|
220
203
|
return (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
204
|
+
<>
|
|
205
|
+
<div
|
|
206
|
+
className={clsx(style.SnippetCode, isCollapsible && 'stl-snippet-code-is-collapsed')}
|
|
207
|
+
data-snippet-expanded-offset={offset}
|
|
208
|
+
data-stldocs-copy-content
|
|
209
|
+
dangerouslySetInnerHTML={{ __html: highlighted }}
|
|
210
|
+
/>
|
|
211
|
+
{signature && isCollapsible && (
|
|
212
|
+
<Button
|
|
213
|
+
className={'stl-snippet-expand-button'}
|
|
214
|
+
id="stl-snippet-expand-button"
|
|
215
|
+
size="sm"
|
|
216
|
+
variant="outline"
|
|
217
|
+
>
|
|
218
|
+
Show more
|
|
219
|
+
</Button>
|
|
220
|
+
)}
|
|
221
|
+
{EXPERIMENTAL_REQUEST_BUILDER && (
|
|
222
|
+
<div className="request-builder-container" style={{ display: 'contents' }}></div>
|
|
223
|
+
)}
|
|
224
|
+
</>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function SnippetFooter() {
|
|
229
|
+
if (!EXPERIMENTAL_REQUEST_BUILDER) return null;
|
|
230
|
+
return (
|
|
231
|
+
<div className={clsx(style.SnippetFooter, 'try-it-footer')}>
|
|
232
|
+
{EXPERIMENTAL_REQUEST_BUILDER && (
|
|
233
|
+
<div className="request-builder-footer" style={{ display: 'contents' }}></div>
|
|
234
|
+
)}
|
|
235
|
+
<Button variant="accent" className="try-it-button">
|
|
236
|
+
<Button.Label>Try it</Button.Label>
|
|
237
|
+
<Button.Icon icon={PlayIcon} />
|
|
238
|
+
</Button>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function SnippetResponse({ ...props }: React.ComponentProps<typeof DocsUiSnippetResponse>) {
|
|
244
|
+
return (
|
|
245
|
+
<>
|
|
246
|
+
<DocsUiSnippetResponse {...props} />
|
|
247
|
+
{EXPERIMENTAL_REQUEST_BUILDER && (
|
|
248
|
+
<div className="request-builder-response" style={{ display: 'contents' }} />
|
|
249
|
+
)}
|
|
250
|
+
</>
|
|
227
251
|
);
|
|
228
252
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Technical overview:
|
|
3
|
+
* - `StainlessIslands.ts` is an Astro Client Island wherein we register a HTML custom element called `stl-island`
|
|
4
|
+
* - When a `<stl-island component="MyComponent">` is added to the DOM, we:
|
|
5
|
+
* 1. receive a callback from the browser
|
|
6
|
+
* 2. dynamic-import `MyComponent`
|
|
7
|
+
* 3. create an instance of `MyComponent`, to which we pass the **`stl-island` DOM node** as a prop named `parent`
|
|
8
|
+
* 4. register the (`stlIslandDomNode` → `ReactNode`) pair in a global map named `roots`
|
|
9
|
+
* - The actual Astro client island is a simple React component that renders all of the ReactNodes from the global `roots` map
|
|
10
|
+
* - it uses a `useSyncExternalStore` to be notified of changes to the global `roots` map by registering a callback
|
|
11
|
+
* - Each “stainless island” (instantiated by the custom element handler and rendered by the Astro client island) is responsible for using the `parent` prop it receives to identify one or more **portal targets** into which it can render its contents.
|
|
12
|
+
* - the react parent of all Stainless Islands is the global `<StainlessIslands client:load />` singleton, but the _DOM parent_ is the various portal targets of all of the stainless islands
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useSyncExternalStore, ReactNode } from 'react';
|
|
16
|
+
|
|
17
|
+
type StlIslandComponent = ({ parent }: { parent: HTMLElement }) => ReactNode;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register new Stainless Islands in this map.
|
|
21
|
+
* The component should be the default export and should be able to be dynamic-imported.
|
|
22
|
+
* The component should accept a single prop: `parent: HTMLElement`, which is the DOM node of the `<stl-island>` element.
|
|
23
|
+
* The component can create portals to render into the DOM subtree of the `parent`.
|
|
24
|
+
*/
|
|
25
|
+
const componentsMap: Record<string, () => Promise<{ default: StlIslandComponent }>> = {
|
|
26
|
+
SnippetStainlessIsland: () => import('./RequestBuilder/SnippetStainlessIsland'),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface State {
|
|
30
|
+
roots: Map<HTMLElement, ReactNode>;
|
|
31
|
+
onRootsChange?: (() => void) | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// keep state in import.meta.hot.data so that our record of our react roots is not lost across HMR
|
|
35
|
+
const state: State = import.meta.hot?.data?.state ?? {
|
|
36
|
+
roots: new Map(),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (import.meta.hot?.data) {
|
|
40
|
+
import.meta.hot.data.state = state;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let key = 0;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Custom element mounts and unmounts components and gives them a reference to the parent
|
|
47
|
+
* so they can render in portals.
|
|
48
|
+
*/
|
|
49
|
+
class StlIsland extends (globalThis?.HTMLElement ?? Object) {
|
|
50
|
+
connectedCallback(this: StlIsland) {
|
|
51
|
+
const componentName = this.getAttribute('component');
|
|
52
|
+
if (!componentName) {
|
|
53
|
+
console.error('[stl-island] missing required attribute "component"');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!componentsMap[componentName]) {
|
|
58
|
+
console.error(`[stl-island] unknown component "${componentName}"`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
componentsMap[componentName]().then(
|
|
63
|
+
({ default: Component }) => {
|
|
64
|
+
state.roots = new Map(state.roots).set(this, <Component parent={this} key={key++} />);
|
|
65
|
+
state.onRootsChange?.();
|
|
66
|
+
},
|
|
67
|
+
(e) => {
|
|
68
|
+
console.error(`[stl-island] failed to load component "${componentName}":`, e);
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
connectedMoveCallback() {
|
|
73
|
+
// empty so we don't get disconnected/reconnected if the dom element gets moved
|
|
74
|
+
}
|
|
75
|
+
disconnectedCallback() {
|
|
76
|
+
state.roots = new Map(state.roots);
|
|
77
|
+
state.roots.delete(this);
|
|
78
|
+
state.onRootsChange?.();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof customElements !== 'undefined' && !customElements.get('stl-island')) {
|
|
83
|
+
customElements.define('stl-island', StlIsland);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
declare global {
|
|
87
|
+
interface HTMLElementTagNameMap {
|
|
88
|
+
'stl-island': StlIsland;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
declare module 'react' {
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
93
|
+
namespace JSX {
|
|
94
|
+
interface IntrinsicElements {
|
|
95
|
+
// client-loaded
|
|
96
|
+
'stl-island': React.DetailedHTMLProps<React.HTMLAttributes<StlIsland>, StlIsland> & {
|
|
97
|
+
component: keyof typeof componentsMap;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Renders all StainlessIslands that have been registered in the global `state.roots` map */
|
|
104
|
+
function StainlessIslandsInner() {
|
|
105
|
+
const roots = useSyncExternalStore<Map<HTMLElement, ReactNode> | null>(
|
|
106
|
+
(onChange) => {
|
|
107
|
+
state.onRootsChange = onChange;
|
|
108
|
+
return () => {
|
|
109
|
+
state.onRootsChange = undefined;
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
() => {
|
|
113
|
+
return state.roots;
|
|
114
|
+
},
|
|
115
|
+
() => null,
|
|
116
|
+
);
|
|
117
|
+
if (!roots) return null;
|
|
118
|
+
return [...roots.values()];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function StainlessIslands() {
|
|
122
|
+
// Astro tries to call this function outside of react to detect if it's
|
|
123
|
+
// an Astro JSX or React JSX component, which causes warnings if we use hooks,
|
|
124
|
+
// so we use a wrapper component to avoid the hook calls.
|
|
125
|
+
return <StainlessIslandsInner />;
|
|
126
|
+
}
|
package/plugin/index.ts
CHANGED
|
@@ -313,6 +313,7 @@ async function stlStarlightAstroIntegration(
|
|
|
313
313
|
PROPERTY_SETTINGS: pluginConfig.propertySettings,
|
|
314
314
|
ENABLE_CONTEXT_MENU: pluginConfig.contextMenu,
|
|
315
315
|
EXPERIMENTAL_PLAYGROUNDS: !!pluginConfig.experimentalPlaygrounds,
|
|
316
|
+
EXPERIMENTAL_REQUEST_BUILDER: pluginConfig.experimentalRequestBuilder,
|
|
316
317
|
STAINLESS_PROJECT: version.stainlessProject,
|
|
317
318
|
} satisfies Omit<typeof StlStarlightVirtualModule, 'MIDDLEWARE'>),
|
|
318
319
|
vmMiddlewareExport,
|
package/plugin/languages.ts
CHANGED
|
@@ -8,6 +8,7 @@ import JavaIcon from './assets/languages/java.svg';
|
|
|
8
8
|
import GoIcon from './assets/languages/go.svg';
|
|
9
9
|
import CurlIcon from './assets/languages/curl.svg';
|
|
10
10
|
import CSharpIcon from './assets/languages/csharp.svg';
|
|
11
|
+
import CLIIcon from './assets/languages/cli.svg';
|
|
11
12
|
|
|
12
13
|
export const Languages: Record<
|
|
13
14
|
DocsLanguage,
|
|
@@ -31,6 +32,7 @@ export const Languages: Record<
|
|
|
31
32
|
terraform: { name: 'Terraform', icon: TerraformIcon, alt: 'Terraform logo' },
|
|
32
33
|
ruby: { name: 'Ruby', icon: RubyIcon, alt: 'Ruby logo' },
|
|
33
34
|
csharp: { name: 'C#', icon: CSharpIcon, alt: 'C# logo' },
|
|
35
|
+
cli: { name: 'CLI Tool', icon: CLIIcon, alt: 'CLI logo' },
|
|
34
36
|
php: { name: 'PHP', icon: CSharpIcon, alt: 'PHP logo' }, // TODO update PHP icon
|
|
35
37
|
};
|
|
36
38
|
|