@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 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.78",
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.11",
62
- "@stainless-api/docs-ui": "0.1.0-beta.58",
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.2"
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 type {
2
- SnippetCodeProps,
3
- SnippetContainerProps,
4
- SnippetRequestContainerProps,
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 SnippetRequestContainer({ children, signature }: SnippetRequestContainerProps) {
155
+ export function SnippetContainer({ children, signature, method }: SnippetContainerProps) {
153
156
  const isCollapsible = useIsCollapsible({ signature });
154
-
155
- return (
156
- <div className="stl-snippet-request-container">
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
- </div>
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
- <div
222
- className={clsx(style.SnippetCode, isCollapsible && 'stl-snippet-code-is-collapsed')}
223
- data-snippet-expanded-offset={offset}
224
- data-stldocs-copy-content
225
- dangerouslySetInnerHTML={{ __html: highlighted }}
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,
@@ -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