@stainless-api/playgrounds 0.0.1-beta.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/CHANGELOG.md +15 -0
- package/README.md +23 -0
- package/eslint.config.js +2 -0
- package/package.json +69 -0
- package/src/Logs.tsx +216 -0
- package/src/Panel.tsx +21 -0
- package/src/PlaygroundPanelWrapper.tsx +5 -0
- package/src/build-py-types.ts +152 -0
- package/src/build-ts-types.ts +70 -0
- package/src/build.ts +97 -0
- package/src/codemirror/comlink.ts +698 -0
- package/src/codemirror/curl/curlconverter.vendor.js +7959 -0
- package/src/codemirror/curl.ts +108 -0
- package/src/codemirror/deps.ts +12 -0
- package/src/codemirror/fix-lsp-markdown.ts +50 -0
- package/src/codemirror/lsp.ts +87 -0
- package/src/codemirror/python/anser.ts +398 -0
- package/src/codemirror/python/pyodide.ts +180 -0
- package/src/codemirror/python.ts +160 -0
- package/src/codemirror/react.tsx +615 -0
- package/src/codemirror/sanitize-html.ts +12 -0
- package/src/codemirror/shiki.ts +65 -0
- package/src/codemirror/typescript/cdn-typescript.d.ts +1 -0
- package/src/codemirror/typescript/cdn-typescript.js +1 -0
- package/src/codemirror/typescript/console.ts +590 -0
- package/src/codemirror/typescript/get-signature.ts +94 -0
- package/src/codemirror/typescript/prettier-plugin-external-typescript.vendor.js +4968 -0
- package/src/codemirror/typescript/runner.ts +396 -0
- package/src/codemirror/typescript/special-info.ts +171 -0
- package/src/codemirror/typescript/worker.ts +292 -0
- package/src/codemirror/typescript.tsx +198 -0
- package/src/create.tsx +44 -0
- package/src/icon.tsx +21 -0
- package/src/index.ts +6 -0
- package/src/logs-context.ts +5 -0
- package/src/playground.css +359 -0
- package/src/sandbox-worker/in-frame.js +179 -0
- package/src/sandbox-worker/index.ts +202 -0
- package/src/use-storage.ts +54 -0
- package/src/util.ts +29 -0
- package/src/virtual-module.d.ts +45 -0
- package/src/vite-env.d.ts +1 -0
- package/test/get-signature.test.ts +73 -0
- package/test/use-storage.test.ts +60 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import { useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type Ref } from 'react';
|
|
2
|
+
import { Braces, ChevronsUpDownIcon, CopyIcon, KeyRound, Play, Square } from 'lucide-react';
|
|
3
|
+
import type { EditorView } from '@codemirror/view';
|
|
4
|
+
import type * as lsp from 'vscode-languageserver-protocol';
|
|
5
|
+
import type { Diagnostic } from '@codemirror/lint';
|
|
6
|
+
import type { EditorState, Extension, Text, Transaction } from '@codemirror/state';
|
|
7
|
+
import type { Transport } from '@codemirror/lsp-client';
|
|
8
|
+
import type { PlaygroundLanguage } from '../create.tsx';
|
|
9
|
+
import style from '@stainless-api/docs-ui/style';
|
|
10
|
+
import { LogsContext } from '../logs-context.ts';
|
|
11
|
+
import { Logs, type Logger, type Part, type StatePart } from '../Logs.tsx';
|
|
12
|
+
import type { Completion } from '@codemirror/autocomplete';
|
|
13
|
+
import { Button, Dropdown } from '@stainless-api/ui-primitives';
|
|
14
|
+
import { PlaygroundIcon } from '../icon.tsx';
|
|
15
|
+
import { initDropdown } from '@stainless-api/ui-primitives/scripts';
|
|
16
|
+
// @ts-expect-error TODO: make independent from stl-starlight
|
|
17
|
+
import { navigate } from 'astro:transitions/client';
|
|
18
|
+
// @ts-expect-error TODO: make independent from stl-starlight
|
|
19
|
+
import { updateSelectedLanguage } from 'virtual:stl-playground/unstable-update-language';
|
|
20
|
+
// @ts-expect-error TODO: make independent from stl-starlight
|
|
21
|
+
import { BASE_PATH } from 'virtual:stl-starlight-virtual-module';
|
|
22
|
+
import authData from 'virtual:stl-playground/auth.json';
|
|
23
|
+
import { Panel } from '../Panel.tsx';
|
|
24
|
+
import { PlaygroundPanelWrapper } from '../PlaygroundPanelWrapper.tsx';
|
|
25
|
+
import { MaskedInput } from '@stainless-api/docs-ui/components/MaskedInput';
|
|
26
|
+
import useStorage from '../use-storage.ts';
|
|
27
|
+
|
|
28
|
+
let codeMirrorPromise: Promise<typeof import('./deps.ts')>;
|
|
29
|
+
function loadCodeMirror(): Promise<typeof import('./deps.ts')> {
|
|
30
|
+
return codeMirrorPromise || (codeMirrorPromise = import('./deps.ts'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fromPosition(doc: Text, pos: lsp.Position): number {
|
|
34
|
+
const line = doc.line(pos.line + 1);
|
|
35
|
+
return line.from + pos.character;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hydrateDetail(completion: Completion): Completion & { sortText?: string } {
|
|
39
|
+
if (completion.detail?.startsWith('\0{')) {
|
|
40
|
+
const extra = JSON.parse(completion.detail.slice(1));
|
|
41
|
+
completion.detail = undefined;
|
|
42
|
+
Object.assign(completion, extra);
|
|
43
|
+
}
|
|
44
|
+
return completion;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function initCodeMirror({
|
|
48
|
+
signal,
|
|
49
|
+
container,
|
|
50
|
+
extensions,
|
|
51
|
+
fileName,
|
|
52
|
+
doc,
|
|
53
|
+
transport,
|
|
54
|
+
}: {
|
|
55
|
+
signal: AbortSignal;
|
|
56
|
+
container: HTMLDivElement;
|
|
57
|
+
transport?: Transport;
|
|
58
|
+
fileName: string;
|
|
59
|
+
extensions: Extension[];
|
|
60
|
+
doc: string;
|
|
61
|
+
}): Promise<{ editor: EditorView; fixGutters(): void }> {
|
|
62
|
+
const {
|
|
63
|
+
view: {
|
|
64
|
+
ViewUpdate,
|
|
65
|
+
EditorView,
|
|
66
|
+
keymap,
|
|
67
|
+
highlightSpecialChars,
|
|
68
|
+
drawSelection,
|
|
69
|
+
dropCursor,
|
|
70
|
+
rectangularSelection,
|
|
71
|
+
crosshairCursor,
|
|
72
|
+
lineNumbers,
|
|
73
|
+
},
|
|
74
|
+
language: { indentOnInput, bracketMatching, foldGutter, indentUnit },
|
|
75
|
+
commands: { history },
|
|
76
|
+
search: { highlightSelectionMatches },
|
|
77
|
+
autocomplete: { autocompletion, closeBrackets },
|
|
78
|
+
lint: { setDiagnostics },
|
|
79
|
+
state: { EditorState, Compartment },
|
|
80
|
+
lsp: { LSPClient, languageServerSupport },
|
|
81
|
+
vscodeKeymap,
|
|
82
|
+
createLSPClient,
|
|
83
|
+
shikiHighlighter,
|
|
84
|
+
} = await loadCodeMirror();
|
|
85
|
+
|
|
86
|
+
if (signal.aborted) throw new Error('unmounted');
|
|
87
|
+
|
|
88
|
+
if (transport) {
|
|
89
|
+
const client = await createLSPClient(
|
|
90
|
+
LSPClient,
|
|
91
|
+
{
|
|
92
|
+
rootUri: 'file:///play/',
|
|
93
|
+
onDiagnostics: (lspDiagnostics) => {
|
|
94
|
+
const diagnostics = lspDiagnostics.map((lsp): Diagnostic => {
|
|
95
|
+
return {
|
|
96
|
+
from: fromPosition(editor.state.doc, lsp.range.start),
|
|
97
|
+
to: fromPosition(editor.state.doc, lsp.range.end),
|
|
98
|
+
message: lsp.message,
|
|
99
|
+
severity:
|
|
100
|
+
lsp.severity === 4
|
|
101
|
+
? 'hint'
|
|
102
|
+
: lsp.severity === 3
|
|
103
|
+
? 'info'
|
|
104
|
+
: lsp.severity === 2
|
|
105
|
+
? 'warning'
|
|
106
|
+
: 'error',
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
editor.dispatch(setDiagnostics(editor.state, diagnostics));
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
transport,
|
|
113
|
+
);
|
|
114
|
+
signal.addEventListener('abort', () => client.disconnect());
|
|
115
|
+
extensions.push(languageServerSupport(client, 'file:///play/' + fileName));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const editorTheme = new Compartment();
|
|
119
|
+
const getTheme = (): Extension[] => {
|
|
120
|
+
const isDark = document.documentElement.dataset.theme === 'dark';
|
|
121
|
+
return [EditorView.theme({}, { dark: isDark })];
|
|
122
|
+
};
|
|
123
|
+
const editor = new EditorView({
|
|
124
|
+
doc,
|
|
125
|
+
parent: container,
|
|
126
|
+
extensions: [
|
|
127
|
+
lineNumbers(),
|
|
128
|
+
foldGutter(),
|
|
129
|
+
highlightSpecialChars(),
|
|
130
|
+
history(),
|
|
131
|
+
drawSelection(),
|
|
132
|
+
dropCursor(),
|
|
133
|
+
EditorState.allowMultipleSelections.of(true),
|
|
134
|
+
indentOnInput(),
|
|
135
|
+
bracketMatching(),
|
|
136
|
+
closeBrackets(),
|
|
137
|
+
autocompletion({
|
|
138
|
+
compareCompletions(a, b) {
|
|
139
|
+
return (hydrateDetail(a).sortText ?? a.label).localeCompare(hydrateDetail(b).sortText ?? b.label);
|
|
140
|
+
},
|
|
141
|
+
optionClass(completion) {
|
|
142
|
+
hydrateDetail(completion);
|
|
143
|
+
return '';
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
rectangularSelection(),
|
|
147
|
+
crosshairCursor(),
|
|
148
|
+
highlightSelectionMatches(),
|
|
149
|
+
keymap.of(vscodeKeymap),
|
|
150
|
+
shikiHighlighter,
|
|
151
|
+
editorTheme.of(getTheme()),
|
|
152
|
+
indentUnit.of(fileName.endsWith('.py') ? ' ' : ' '),
|
|
153
|
+
...extensions,
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
const observer = new MutationObserver(() => {
|
|
157
|
+
editor.dispatch({
|
|
158
|
+
effects: editorTheme.reconfigure(getTheme()),
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
observer.observe(document.documentElement, {
|
|
162
|
+
attributeFilter: ['data-theme'],
|
|
163
|
+
});
|
|
164
|
+
signal.addEventListener('abort', () => {
|
|
165
|
+
editor.destroy();
|
|
166
|
+
observer.disconnect();
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
editor,
|
|
170
|
+
fixGutters() {
|
|
171
|
+
const update = (
|
|
172
|
+
ViewUpdate as unknown as {
|
|
173
|
+
create(view: EditorView, state: EditorState, _: []): Transaction & { flags: number };
|
|
174
|
+
}
|
|
175
|
+
).create(editor, editor.state, []);
|
|
176
|
+
update.flags = update.flags | 4; /* UpdateFlag.Viewport */
|
|
177
|
+
editor.dispatch(update);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
type State =
|
|
183
|
+
| { state: 'loading' }
|
|
184
|
+
| { state: 'error'; error: unknown }
|
|
185
|
+
| {
|
|
186
|
+
state: 'ready';
|
|
187
|
+
view: EditorView;
|
|
188
|
+
fixGutters(): void;
|
|
189
|
+
run: (signal: AbortSignal) => Promise<void>;
|
|
190
|
+
format(): Promise<void>;
|
|
191
|
+
setEnv(vars: Record<string, string>): Promise<void>;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export interface Language {
|
|
195
|
+
transport?: Transport;
|
|
196
|
+
fileName: string;
|
|
197
|
+
extensions: Extension[];
|
|
198
|
+
doc: string;
|
|
199
|
+
setLogger: (s: Logger) => void;
|
|
200
|
+
freeLogHandle(handle: string): void;
|
|
201
|
+
expandLogHandle(handle: string): Promise<Part[]>;
|
|
202
|
+
run: (code: string, signal: AbortSignal) => Promise<void>;
|
|
203
|
+
format: (code: string) => Promise<string | undefined>;
|
|
204
|
+
setEnv(vars: Record<string, string>): Promise<void>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const useCodeMirror = (
|
|
208
|
+
initLanguage: (arg: { signal: AbortSignal }) => Promise<Language>,
|
|
209
|
+
): {
|
|
210
|
+
containerRef: Ref<HTMLDivElement>;
|
|
211
|
+
} & State => {
|
|
212
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
213
|
+
const [state, setState] = useState<State>({ state: 'loading' });
|
|
214
|
+
const logsSignal = useContext(LogsContext)!;
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
const controller = new AbortController();
|
|
217
|
+
let run: Language['run'];
|
|
218
|
+
let format: Language['format'];
|
|
219
|
+
let setEnv: Language['setEnv'];
|
|
220
|
+
(async () => {
|
|
221
|
+
const {
|
|
222
|
+
transport,
|
|
223
|
+
fileName,
|
|
224
|
+
extensions,
|
|
225
|
+
doc,
|
|
226
|
+
run: run_,
|
|
227
|
+
format: format_,
|
|
228
|
+
setEnv: setEnv_,
|
|
229
|
+
setLogger,
|
|
230
|
+
freeLogHandle,
|
|
231
|
+
expandLogHandle,
|
|
232
|
+
} = await initLanguage({
|
|
233
|
+
signal: controller.signal,
|
|
234
|
+
});
|
|
235
|
+
setEnv = setEnv_;
|
|
236
|
+
run = run_;
|
|
237
|
+
format = format_;
|
|
238
|
+
if (controller.signal.aborted) throw new Error('unmounted');
|
|
239
|
+
setLogger((log) => {
|
|
240
|
+
if (log.type === 'clear') {
|
|
241
|
+
logsSignal.value = [];
|
|
242
|
+
} else {
|
|
243
|
+
const mapPart = (part: Part): StatePart => ({
|
|
244
|
+
css: part.css,
|
|
245
|
+
value: typeof part.value === 'string' ? part.value : part.value.map(mapPart),
|
|
246
|
+
handle:
|
|
247
|
+
part.expandHandle === undefined
|
|
248
|
+
? undefined
|
|
249
|
+
: {
|
|
250
|
+
async expand() {
|
|
251
|
+
const e = await expandLogHandle(part.expandHandle!);
|
|
252
|
+
return e.map(mapPart);
|
|
253
|
+
},
|
|
254
|
+
free() {
|
|
255
|
+
freeLogHandle(part.expandHandle!);
|
|
256
|
+
},
|
|
257
|
+
id: part.id,
|
|
258
|
+
},
|
|
259
|
+
lowPriority: part.lowPriority,
|
|
260
|
+
});
|
|
261
|
+
logsSignal.value = [
|
|
262
|
+
...logsSignal.value,
|
|
263
|
+
{ type: log.type, parts: log.parts.map((e) => (typeof e === 'string' ? e : mapPart(e))) },
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
return await initCodeMirror({
|
|
268
|
+
extensions,
|
|
269
|
+
transport,
|
|
270
|
+
fileName,
|
|
271
|
+
container: containerRef.current!,
|
|
272
|
+
signal: controller.signal,
|
|
273
|
+
doc,
|
|
274
|
+
});
|
|
275
|
+
})().then(
|
|
276
|
+
({ editor: view, fixGutters }) => {
|
|
277
|
+
setState({
|
|
278
|
+
state: 'ready',
|
|
279
|
+
view,
|
|
280
|
+
fixGutters,
|
|
281
|
+
run(signal) {
|
|
282
|
+
return run(view.state.doc.toString(), signal);
|
|
283
|
+
},
|
|
284
|
+
setEnv,
|
|
285
|
+
async format() {
|
|
286
|
+
const newDoc = await format(view.state.doc.toString());
|
|
287
|
+
if (newDoc !== undefined) {
|
|
288
|
+
view.dispatch({
|
|
289
|
+
changes: {
|
|
290
|
+
from: 0,
|
|
291
|
+
to: view.state.doc.length,
|
|
292
|
+
insert: newDoc,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
(error) => {
|
|
300
|
+
if (!controller.signal.aborted) {
|
|
301
|
+
console.error(error);
|
|
302
|
+
setState({ state: 'error', error });
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
);
|
|
306
|
+
return () => {
|
|
307
|
+
controller.abort();
|
|
308
|
+
};
|
|
309
|
+
}, []);
|
|
310
|
+
return { containerRef, ...state };
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const circleAlertIcon = `<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>`;
|
|
314
|
+
const checkIcon = `<path d="M20 6 9 17l-5-5"/>`;
|
|
315
|
+
function DOMNode({ node }: { node: Node }) {
|
|
316
|
+
const wrap = useRef<HTMLDivElement>(null);
|
|
317
|
+
|
|
318
|
+
useLayoutEffect(() => {
|
|
319
|
+
wrap.current!.replaceChildren(node);
|
|
320
|
+
}, [node]);
|
|
321
|
+
|
|
322
|
+
return <div style={{ display: 'contents' }} ref={wrap} />;
|
|
323
|
+
}
|
|
324
|
+
function prefixIds(root: Element | DocumentFragment, idPrefix: string) {
|
|
325
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null);
|
|
326
|
+
|
|
327
|
+
let node;
|
|
328
|
+
while ((node = walker.nextNode() as Element | null)) {
|
|
329
|
+
if (node.id) {
|
|
330
|
+
node.id = idPrefix + node.id;
|
|
331
|
+
}
|
|
332
|
+
if (node.hasAttribute('aria-labelledby')) {
|
|
333
|
+
node.setAttribute('aria-labelledby', idPrefix + node.getAttribute('aria-labelledby'));
|
|
334
|
+
}
|
|
335
|
+
for (const attr of node.getAttributeNames()) {
|
|
336
|
+
node.setAttribute(attr, node.getAttribute(attr)!.replaceAll('url(#', 'url(#' + idPrefix));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (node instanceof HTMLTemplateElement && node.content) {
|
|
340
|
+
prefixIds(node.content, idPrefix);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
export const Editor = ({
|
|
345
|
+
lang,
|
|
346
|
+
doc,
|
|
347
|
+
container,
|
|
348
|
+
onLoad,
|
|
349
|
+
unmount,
|
|
350
|
+
}: {
|
|
351
|
+
lang: PlaygroundLanguage;
|
|
352
|
+
doc: string;
|
|
353
|
+
container: HTMLElement;
|
|
354
|
+
onLoad: (onShow: () => void) => void;
|
|
355
|
+
unmount: () => void;
|
|
356
|
+
}) => {
|
|
357
|
+
const titleNode = useMemo(
|
|
358
|
+
() => container.querySelector('.stldocs-snippet-request-title-method')!.cloneNode(true),
|
|
359
|
+
[container],
|
|
360
|
+
);
|
|
361
|
+
const idPrefix = useId();
|
|
362
|
+
const menuNode = useMemo(() => {
|
|
363
|
+
const clone = container
|
|
364
|
+
.querySelector('.stldocs-snippet-request-title-content')!
|
|
365
|
+
.cloneNode(true) as HTMLElement;
|
|
366
|
+
prefixIds(clone, idPrefix);
|
|
367
|
+
initDropdown({
|
|
368
|
+
root: clone.querySelector('#' + idPrefix + 'stldocs-snippet-select')!,
|
|
369
|
+
onSelect: (value) => {
|
|
370
|
+
const originalLanguage = document.getElementById(idPrefix + 'stldocs-snippet-select')?.dataset
|
|
371
|
+
.currentValue;
|
|
372
|
+
const path: string = updateSelectedLanguage(BASE_PATH, originalLanguage, value);
|
|
373
|
+
navigate(path.replace(/(\?.+)?($|#)/, (_, str, end) => (str ? str + '&play' : '?play') + end));
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
return clone;
|
|
377
|
+
}, [container]);
|
|
378
|
+
const editor = useCodeMirror(async ({ signal }) => {
|
|
379
|
+
let _never: never;
|
|
380
|
+
void _never!;
|
|
381
|
+
switch (lang) {
|
|
382
|
+
case 'python': {
|
|
383
|
+
const { createPyright } = await import('./python');
|
|
384
|
+
if (signal.aborted) throw new Error('unmounted');
|
|
385
|
+
return await createPyright(signal, doc);
|
|
386
|
+
}
|
|
387
|
+
case 'typescript': {
|
|
388
|
+
const { createTypescript } = await import('./typescript');
|
|
389
|
+
if (signal.aborted) throw new Error('unmounted');
|
|
390
|
+
return await createTypescript(signal, doc);
|
|
391
|
+
}
|
|
392
|
+
case 'http': {
|
|
393
|
+
const { createCurl } = await import('./curl');
|
|
394
|
+
if (signal.aborted) throw new Error('unmounted');
|
|
395
|
+
return await createCurl(signal, doc);
|
|
396
|
+
}
|
|
397
|
+
default:
|
|
398
|
+
throw (_never = lang);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
const logs = useContext(LogsContext);
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (editor.state !== 'loading') {
|
|
404
|
+
onLoad(editor.state === 'ready' ? () => editor.fixGutters() : () => {});
|
|
405
|
+
}
|
|
406
|
+
}, [editor.state]);
|
|
407
|
+
const [controller, setController] = useState<AbortController | null>(null);
|
|
408
|
+
return (
|
|
409
|
+
<div className="stldocs-snippet stl-snippet-non-collapsible stldocs-snippet-playground">
|
|
410
|
+
<div className="stldocs-snippet-request">
|
|
411
|
+
<div className="stldocs-snippet-request-title">
|
|
412
|
+
<DOMNode node={titleNode} />
|
|
413
|
+
<DOMNode node={menuNode} />
|
|
414
|
+
<Button
|
|
415
|
+
variant="outline"
|
|
416
|
+
onClick={async (e) => {
|
|
417
|
+
if (editor.state !== 'ready') return;
|
|
418
|
+
const copyButton = e.currentTarget;
|
|
419
|
+
if (copyButton.dataset.__stldocsCopyTimeout) return;
|
|
420
|
+
const iconElement = copyButton.querySelector('.stldocs-icon') as SVGElement;
|
|
421
|
+
const originalIcon = iconElement.innerHTML;
|
|
422
|
+
try {
|
|
423
|
+
await navigator.clipboard.writeText(editor.view.state.doc.toString());
|
|
424
|
+
iconElement.innerHTML = checkIcon;
|
|
425
|
+
} catch {
|
|
426
|
+
iconElement.innerHTML = circleAlertIcon;
|
|
427
|
+
}
|
|
428
|
+
copyButton.dataset.__stldocsCopyTimeout =
|
|
429
|
+
setTimeout(() => {
|
|
430
|
+
copyButton.dataset.__stldocsCopyTimeout = '';
|
|
431
|
+
iconElement.innerHTML = originalIcon;
|
|
432
|
+
}, 1000) + '';
|
|
433
|
+
}}
|
|
434
|
+
title={'Copy Code'}
|
|
435
|
+
>
|
|
436
|
+
<CopyIcon size={16} className={style.Icon} />
|
|
437
|
+
</Button>
|
|
438
|
+
<Button
|
|
439
|
+
variant="accent-muted"
|
|
440
|
+
border
|
|
441
|
+
title="Back to snippet"
|
|
442
|
+
onClick={() => {
|
|
443
|
+
unmount();
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
<PlaygroundIcon />
|
|
447
|
+
</Button>
|
|
448
|
+
</div>
|
|
449
|
+
<div className="playground-editor-wrap">
|
|
450
|
+
{editor.state === 'error' && (
|
|
451
|
+
<pre className="playground-editor-load-error">Failed to load editor:{'\n' + editor.error}</pre>
|
|
452
|
+
)}
|
|
453
|
+
<div
|
|
454
|
+
className="playground-editor-container"
|
|
455
|
+
ref={editor.containerRef}
|
|
456
|
+
hidden={editor.state !== 'ready'}
|
|
457
|
+
/>
|
|
458
|
+
</div>
|
|
459
|
+
<div className="playground-footer">
|
|
460
|
+
{lang !== 'http' && (
|
|
461
|
+
<Button
|
|
462
|
+
variant="outline"
|
|
463
|
+
onClick={async (e) => {
|
|
464
|
+
if (editor.state !== 'ready') return;
|
|
465
|
+
const button = e.currentTarget;
|
|
466
|
+
if (button.dataset.__stldocsFormatTimeout) return;
|
|
467
|
+
if (button.getAttribute('aria-disabled') === 'true') return;
|
|
468
|
+
const iconElement = button.querySelector('.stldocs-icon') as SVGElement;
|
|
469
|
+
const originalIcon = iconElement.innerHTML;
|
|
470
|
+
// use aria-disabled, not disabled, to avoid losing focus
|
|
471
|
+
button.setAttribute('aria-disabled', 'true');
|
|
472
|
+
button.setAttribute('aria-label', 'Formatting...');
|
|
473
|
+
button.classList.add('stl-ui-button--loading');
|
|
474
|
+
try {
|
|
475
|
+
await editor.format();
|
|
476
|
+
iconElement.innerHTML = checkIcon;
|
|
477
|
+
} catch {
|
|
478
|
+
iconElement.innerHTML = circleAlertIcon;
|
|
479
|
+
}
|
|
480
|
+
button.dataset.__stldocsFormatTimeout =
|
|
481
|
+
setTimeout(() => {
|
|
482
|
+
button.dataset.__stldocsFormatTimeout = '';
|
|
483
|
+
iconElement.innerHTML = originalIcon;
|
|
484
|
+
}, 1000) + '';
|
|
485
|
+
button.removeAttribute('aria-disabled');
|
|
486
|
+
button.removeAttribute('aria-label');
|
|
487
|
+
button.classList.remove('stl-ui-button--loading');
|
|
488
|
+
}}
|
|
489
|
+
title={'Format Code'}
|
|
490
|
+
aria-label={'Format Code'}
|
|
491
|
+
>
|
|
492
|
+
<Braces size={16} className={style.Icon} />
|
|
493
|
+
</Button>
|
|
494
|
+
)}
|
|
495
|
+
<span className="playground-spacer" />
|
|
496
|
+
<Button
|
|
497
|
+
variant="accent"
|
|
498
|
+
onClick={async () => {
|
|
499
|
+
if (controller) {
|
|
500
|
+
controller.abort();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (editor.state !== 'ready') return;
|
|
504
|
+
try {
|
|
505
|
+
const controller = new AbortController();
|
|
506
|
+
setController(controller);
|
|
507
|
+
await editor.run(controller.signal);
|
|
508
|
+
} catch (e) {
|
|
509
|
+
logs!.value = [...logs!.value, { type: 'error', parts: ['Uncaught ' + e] }];
|
|
510
|
+
} finally {
|
|
511
|
+
setController(null);
|
|
512
|
+
}
|
|
513
|
+
}}
|
|
514
|
+
style={{ minWidth: '6em' }}
|
|
515
|
+
>
|
|
516
|
+
{controller ? (
|
|
517
|
+
<>
|
|
518
|
+
Stop <Square size={16} className={style.Icon} style={{ marginLeft: '0.5em' }} />
|
|
519
|
+
</>
|
|
520
|
+
) : (
|
|
521
|
+
<>
|
|
522
|
+
Run <Play size={16} className={style.Icon} style={{ marginLeft: '0.5em' }} />
|
|
523
|
+
</>
|
|
524
|
+
)}
|
|
525
|
+
</Button>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
<PlaygroundPanelWrapper>
|
|
529
|
+
<Auth setEnv={editor.state === 'ready' ? editor.setEnv : undefined} />
|
|
530
|
+
<Logs />
|
|
531
|
+
</PlaygroundPanelWrapper>
|
|
532
|
+
</div>
|
|
533
|
+
);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
function Auth(props: { setEnv?: (vars: Record<string, string>) => Promise<void> }) {
|
|
537
|
+
const [env, setEnv] = useStorage<Record<string, string>>(sessionStorage, 'stl-playground-env', {});
|
|
538
|
+
const id = useId();
|
|
539
|
+
const [selected, setSelected] = useState(authData![0]!.name);
|
|
540
|
+
const current = authData!.find((e) => e.name === selected)!;
|
|
541
|
+
useEffect(() => {
|
|
542
|
+
props.setEnv?.(env);
|
|
543
|
+
}, [props.setEnv, env, current]);
|
|
544
|
+
return (
|
|
545
|
+
<Panel title="Authentication" icon={KeyRound}>
|
|
546
|
+
<div className="playground-auth-panel playground-panel-content">
|
|
547
|
+
{authData!.length > 1 && (
|
|
548
|
+
<>
|
|
549
|
+
<label htmlFor={id + '-trigger'}>Method</label>
|
|
550
|
+
<div>
|
|
551
|
+
<Dropdown
|
|
552
|
+
id={id}
|
|
553
|
+
data-current-value={selected}
|
|
554
|
+
className={''}
|
|
555
|
+
ref={(root) => {
|
|
556
|
+
initDropdown({
|
|
557
|
+
root,
|
|
558
|
+
onSelect(value) {
|
|
559
|
+
setSelected(value);
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
}}
|
|
563
|
+
>
|
|
564
|
+
<Dropdown.Trigger
|
|
565
|
+
className="dropdown-toggle"
|
|
566
|
+
type="button"
|
|
567
|
+
id={id + '-trigger'}
|
|
568
|
+
aria-expanded="false"
|
|
569
|
+
>
|
|
570
|
+
<Dropdown.TriggerSelectedItem>
|
|
571
|
+
<span>{current.title}</span>
|
|
572
|
+
</Dropdown.TriggerSelectedItem>
|
|
573
|
+
<Dropdown.TriggerIcon>
|
|
574
|
+
<ChevronsUpDownIcon size={16} />
|
|
575
|
+
</Dropdown.TriggerIcon>
|
|
576
|
+
</Dropdown.Trigger>
|
|
577
|
+
<Dropdown.Menu aria-labelledby={id + '-trigger'}>
|
|
578
|
+
{authData!.map((item) => (
|
|
579
|
+
<Dropdown.MenuItem key={item.name} value={item.name} isSelected={item.name === selected}>
|
|
580
|
+
<div>{item.title}</div>
|
|
581
|
+
</Dropdown.MenuItem>
|
|
582
|
+
))}
|
|
583
|
+
</Dropdown.Menu>
|
|
584
|
+
</Dropdown>
|
|
585
|
+
</div>
|
|
586
|
+
</>
|
|
587
|
+
)}
|
|
588
|
+
{...current.opts
|
|
589
|
+
.filter((e) => e.read_env)
|
|
590
|
+
.map((e, i) => {
|
|
591
|
+
return (
|
|
592
|
+
<>
|
|
593
|
+
{(i !== 0 || authData!.length > 1) && <hr />}
|
|
594
|
+
<label htmlFor={id + '-var-' + i}>
|
|
595
|
+
<code>{e.read_env} =</code>
|
|
596
|
+
</label>
|
|
597
|
+
<MaskedInput
|
|
598
|
+
type="text"
|
|
599
|
+
key={e.name}
|
|
600
|
+
defaultValue={env[e.read_env!]}
|
|
601
|
+
onInput={(evt) => {
|
|
602
|
+
const input = (evt.target as HTMLElement).closest('input') as HTMLInputElement;
|
|
603
|
+
setEnv({
|
|
604
|
+
...env,
|
|
605
|
+
[e.read_env!]: input.value,
|
|
606
|
+
});
|
|
607
|
+
}}
|
|
608
|
+
/>
|
|
609
|
+
</>
|
|
610
|
+
);
|
|
611
|
+
})}
|
|
612
|
+
</div>
|
|
613
|
+
</Panel>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify';
|
|
2
|
+
|
|
3
|
+
export function sanitizeHTML(html: string) {
|
|
4
|
+
const dom = DOMPurify.sanitize(html, {
|
|
5
|
+
RETURN_DOM: true,
|
|
6
|
+
}) as HTMLBodyElement;
|
|
7
|
+
for (const link of dom.querySelectorAll('a, area')) {
|
|
8
|
+
link.setAttribute('target', '_blank');
|
|
9
|
+
link.setAttribute('rel', 'noopener');
|
|
10
|
+
}
|
|
11
|
+
return dom.innerHTML;
|
|
12
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { language } from '@codemirror/language';
|
|
2
|
+
import { Prec, RangeSetBuilder } from '@codemirror/state';
|
|
3
|
+
import {
|
|
4
|
+
Decoration,
|
|
5
|
+
type DecorationSet,
|
|
6
|
+
type EditorView,
|
|
7
|
+
ViewPlugin,
|
|
8
|
+
type ViewUpdate,
|
|
9
|
+
} from '@codemirror/view';
|
|
10
|
+
import { SupportedLanguageSyntaxes } from '@stainless-api/docs-ui/routing';
|
|
11
|
+
import { createHighlighter } from 'shiki';
|
|
12
|
+
// @ts-expect-error TODO: make independent from stl-starlight
|
|
13
|
+
import { HIGHLIGHT_THEMES } from 'virtual:stl-starlight-virtual-module';
|
|
14
|
+
export const highlighter = await createHighlighter({
|
|
15
|
+
themes: [HIGHLIGHT_THEMES?.dark ?? 'github-dark', HIGHLIGHT_THEMES?.light ?? 'github-light'],
|
|
16
|
+
langs: SupportedLanguageSyntaxes,
|
|
17
|
+
});
|
|
18
|
+
const span = document.createElement('span');
|
|
19
|
+
class TreeHighlighter {
|
|
20
|
+
decorations: DecorationSet;
|
|
21
|
+
|
|
22
|
+
constructor(view: EditorView) {
|
|
23
|
+
this.decorations = this.buildDeco(view);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
update(update: ViewUpdate) {
|
|
27
|
+
if (update.docChanged) {
|
|
28
|
+
this.decorations = this.buildDeco(update.view);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
buildDeco(view: EditorView) {
|
|
33
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
34
|
+
const { tokens } = highlighter.codeToTokens(view.state.doc.toString(), {
|
|
35
|
+
themes: {
|
|
36
|
+
dark: HIGHLIGHT_THEMES?.dark ?? 'github-dark',
|
|
37
|
+
light: HIGHLIGHT_THEMES?.light ?? 'github-light',
|
|
38
|
+
},
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
lang: view.state.facet(language)!.name as any,
|
|
41
|
+
});
|
|
42
|
+
for (const token of tokens.flat()) {
|
|
43
|
+
span.style.cssText = '';
|
|
44
|
+
for (const k in token.htmlStyle) {
|
|
45
|
+
span.style.setProperty(k, token.htmlStyle[k]!);
|
|
46
|
+
}
|
|
47
|
+
builder.add(
|
|
48
|
+
token.offset,
|
|
49
|
+
token.offset + token.content.length,
|
|
50
|
+
Decoration.mark({
|
|
51
|
+
attributes: {
|
|
52
|
+
style: span.style.cssText,
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return builder.finish();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const shikiHighlighter = Prec.high(
|
|
62
|
+
ViewPlugin.fromClass(TreeHighlighter, {
|
|
63
|
+
decorations: (v) => v.decorations,
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from 'typescript';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default (importScripts('https://cdn.jsdelivr.net/npm/typescript@5.9.3/lib/typescript.js'), ts);
|