Skip to content

Commit 16a7113

Browse files
authored
playground: Add ast typescript panel (#360)
1 parent 653d9bb commit 16a7113

File tree

3 files changed

+222
-18
lines changed

3 files changed

+222
-18
lines changed

website/rspress.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export default defineConfig({
7070
rspack(config) {
7171
config.ignoreWarnings = [
7272
{
73-
module: /editorSimpleWorker\.js/,
73+
module: /(editorSimpleWorker|typescript)\.js/,
7474
},
7575
];
7676
},

website/theme/components/Playground/ResultPanel.tsx

Lines changed: 146 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,18 @@ interface ResultPanelProps {
1919
diagnostics: Diagnostic[];
2020
ast?: string;
2121
astTree?: ASTNode;
22+
tsAstTree?: ASTNode;
2223
initialized?: boolean;
2324
error?: string;
2425
fixedCode?: string;
2526
typeInfo?: string;
2627
loading?: boolean;
2728
onAstNodeSelect?: (start: number, end: number) => void;
2829
selectedAstNodeRange?: { start: number; end: number };
30+
onRequestTsAst?: () => void;
2931
}
3032

31-
type TabType = 'lint' | 'fixed' | 'ast' | 'type';
33+
type TabType = 'lint' | 'fixed' | 'ast' | 'ast_ts' | 'type';
3234

3335
interface ASTNode {
3436
type: string;
@@ -44,13 +46,15 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
4446
diagnostics,
4547
ast,
4648
astTree,
49+
tsAstTree,
4750
error,
4851
initialized,
4952
fixedCode,
5053
typeInfo,
5154
loading,
5255
onAstNodeSelect,
5356
selectedAstNodeRange,
57+
onRequestTsAst,
5458
} = props;
5559
const [activeTab, setActiveTab] = useState<TabType>(() => {
5660
if (typeof window === 'undefined') return 'lint';
@@ -60,7 +64,13 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
6064
const hashParams = new URLSearchParams(window.location.hash.slice(1));
6165
tab = hashParams.get('tab');
6266
}
63-
if (tab === 'lint' || tab === 'ast' || tab === 'fixed' || tab === 'type') {
67+
if (
68+
tab === 'lint' ||
69+
tab === 'ast' ||
70+
tab === 'ast_ts' ||
71+
tab === 'fixed' ||
72+
tab === 'type'
73+
) {
6474
return tab as TabType;
6575
}
6676
return 'lint';
@@ -81,6 +91,13 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
8191
}
8292
}, [activeTab]);
8393

94+
// Notify parent when TS AST tab is opened (including initial state)
95+
useEffect(() => {
96+
if (activeTab === 'ast_ts') {
97+
onRequestTsAst?.();
98+
}
99+
}, [activeTab]);
100+
84101
// Respond to browser navigation updating the tab
85102
useEffect(() => {
86103
if (typeof window === 'undefined') return;
@@ -95,6 +112,7 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
95112
if (
96113
tab === 'lint' ||
97114
tab === 'ast' ||
115+
tab === 'ast_ts' ||
98116
tab === 'fixed' ||
99117
tab === 'type'
100118
) {
@@ -108,9 +126,12 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
108126
return () => window.removeEventListener('popstate', handler);
109127
}, []);
110128

111-
// AST tree view state
129+
// AST tree view state (tsgo)
112130
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
113131
const [selectedId, setSelectedId] = useState<string | null>(null);
132+
// AST tree view state (TypeScript)
133+
const [tsExpanded, setTsExpanded] = useState<Set<string>>(() => new Set());
134+
const [tsSelectedId, setTsSelectedId] = useState<string | null>(null);
114135

115136
function nodeId(n: ASTNode) {
116137
return `${n.type}:${n.start}-${n.end}`;
@@ -171,6 +192,54 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
171192
);
172193
};
173194

195+
// TypeScript AST rendering (separate selection/expansion state)
196+
function tsNodeId(n: ASTNode) {
197+
return `ts:${n.type}:${n.start}-${n.end}`;
198+
}
199+
function renderTsNode(n: ASTNode, depth = 0) {
200+
const id = tsNodeId(n);
201+
const open = tsExpanded.has(id);
202+
const hasKids = isExpandable(n);
203+
const preview = n.text ? n.text.replace(/\s+/g, ' ').slice(0, 40) : '';
204+
return (
205+
<div key={id} className="ast-node" style={{ paddingLeft: depth * 2 }}>
206+
<div
207+
className={`ast-node-row ${tsSelectedId === id ? 'selected' : ''}`}
208+
onClick={() => {
209+
setTsSelectedId(id);
210+
onAstNodeSelect?.(n.start, n.end);
211+
}}
212+
>
213+
{hasKids && (
214+
<button
215+
className={`twisty ${open ? 'open' : ''}`}
216+
onClick={e => {
217+
e.stopPropagation();
218+
setTsExpanded(prev => {
219+
const next = new Set(prev);
220+
if (next.has(id)) next.delete(id);
221+
else next.add(id);
222+
return next;
223+
});
224+
}}
225+
aria-label={open ? 'Collapse' : 'Expand'}
226+
/>
227+
)}
228+
<span className="node-type">{n.type}</span>
229+
<span className="node-range">
230+
[{n.start}, {n.end}]
231+
</span>
232+
{preview && <span className="node-preview">{preview}</span>}
233+
</div>
234+
{open && hasKids && (
235+
<div className="ast-children">
236+
{n.children!.map(child => renderTsNode(child, depth + 1))}
237+
</div>
238+
)}
239+
</div>
240+
);
241+
}
242+
174243
// When selection in editor changes, select smallest covering AST node and expand its ancestors
175244
useEffect(() => {
176245
if (!astTree || !selectedAstNodeRange) return;
@@ -200,17 +269,52 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
200269
}
201270
}, [selectedAstNodeRange, astTree]);
202271

203-
// Auto-expand root when a new tree arrives
272+
// Auto-expand roots when new trees arrive
204273
useEffect(() => {
205-
if (!astTree) return;
206-
const id = nodeId(astTree);
207-
setExpanded(prev => {
208-
if (prev.has(id)) return prev;
209-
const next = new Set(prev);
210-
next.add(id);
211-
return next;
212-
});
213-
}, [astTree]);
274+
if (astTree) {
275+
const id = nodeId(astTree);
276+
setExpanded(prev => {
277+
if (prev.has(id)) return prev;
278+
const next = new Set(prev);
279+
next.add(id);
280+
return next;
281+
});
282+
}
283+
if (tsAstTree) {
284+
const id = tsNodeId(tsAstTree);
285+
setTsExpanded(prev => {
286+
if (prev.has(id)) return prev;
287+
const next = new Set(prev);
288+
next.add(id);
289+
return next;
290+
});
291+
}
292+
}, [astTree, tsAstTree]);
293+
294+
// Selection sync for TS AST
295+
useEffect(() => {
296+
if (!tsAstTree || !selectedAstNodeRange) return;
297+
const { start, end } = selectedAstNodeRange;
298+
let best: { node: ASTNode; depth: number; path: ASTNode[] } | null = null;
299+
function visit(node: ASTNode, depth: number, path: ASTNode[]) {
300+
if (node.start <= start && node.end >= end) {
301+
if (!best || depth > best.depth)
302+
best = { node, depth, path: [...path, node] };
303+
if (node.children)
304+
for (const c of node.children) visit(c, depth + 1, [...path, node]);
305+
}
306+
}
307+
visit(tsAstTree, 0, []);
308+
if (best) {
309+
const id = tsNodeId(best.node);
310+
setTsSelectedId(id);
311+
setTsExpanded(prev => {
312+
const next = new Set(prev);
313+
for (const p of best!.path) next.add(tsNodeId(p));
314+
return next;
315+
});
316+
}
317+
}, [selectedAstNodeRange, tsAstTree]);
214318

215319
// Share button state and handler
216320
const [shareCopied, setShareCopied] = useState(false);
@@ -245,7 +349,19 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
245349
onClick={() => setActiveTab('ast')}
246350
aria-pressed={activeTab === 'ast'}
247351
>
248-
AST
352+
AST (tsgo)
353+
</Button>
354+
<Button
355+
type="button"
356+
variant={activeTab === 'ast_ts' ? 'default' : 'outline'}
357+
size="sm"
358+
onClick={() => {
359+
setActiveTab('ast_ts');
360+
onRequestTsAst?.();
361+
}}
362+
aria-pressed={activeTab === 'ast_ts'}
363+
>
364+
AST (TypeScript)
249365
</Button>
250366
</div>
251367
<div className="result-actions">
@@ -320,6 +436,22 @@ export const ResultPanel: React.FC<ResultPanelProps> = props => {
320436
)}
321437
</div>
322438
)}
439+
440+
{!error && activeTab === 'ast_ts' && (
441+
<div className="ast-view">
442+
{tsAstTree ? (
443+
<div className="ast-tree" role="tree">
444+
{renderTsNode(tsAstTree)}
445+
</div>
446+
) : (
447+
<div className="empty-state">
448+
<div className="empty-text">
449+
TypeScript AST will be displayed here
450+
</div>
451+
</div>
452+
)}
453+
</div>
454+
)}
323455
</div>
324456
) : (
325457
<div className="result-content">

website/theme/components/Playground/index.tsx

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const Playground: React.FC = () => {
2424
const [error, setError] = useState<string | undefined>();
2525
const [ast, setAst] = useState<string | undefined>();
2626
const [astTree, setAstTree] = useState<any | undefined>();
27+
const [tsAstTree, setTsAstTree] = useState<any | undefined>();
28+
const tsModuleRef = useRef<null | typeof import('typescript')>(null);
29+
const tsAstActiveRef = useRef<boolean>(false);
30+
const lastSourceTextRef = useRef<string>('');
2731
const [loading, setLoading] = useState(true);
2832
const lintTimer = useRef<number | null>(null);
2933
const [selectedAstRange, setSelectedAstRange] = useState<
@@ -75,11 +79,16 @@ const Playground: React.FC = () => {
7579
text?: string;
7680
}
7781

78-
// Generate AST
82+
// Generate AST (tsgo)
83+
let sourceTextForTs: string | undefined;
7984
try {
8085
const astBuffer = result.encodedSourceFiles!['index.ts'];
8186
const buffer = Uint8Array.from(atob(astBuffer), c => c.charCodeAt(0));
8287
const source = new RemoteSourceFile(buffer, new TextDecoder());
88+
// capture the exact source text from encoded source file
89+
try {
90+
sourceTextForTs = (source as any).text as string | undefined;
91+
} catch {}
8392
// Convert a RemoteNode (from tsgo/rslint-api) to a minimal ESTree node
8493

8594
function RemoteNodeToEstree(node: Node): ASTNode {
@@ -98,13 +107,19 @@ const Playground: React.FC = () => {
98107
}
99108

100109
const tree = RemoteNodeToEstree(source);
101-
const astData = JSON.stringify(tree, null, 2);
102110
setAstTree(tree);
103-
setAst(astData);
111+
setAst(undefined);
112+
// persist source text for TS parsing alignment
113+
lastSourceTextRef.current = sourceTextForTs ?? code;
104114
} catch (astError) {
105115
console.warn('AST generation failed:', astError);
106116
setAst(undefined);
107117
setAstTree(undefined);
118+
lastSourceTextRef.current = code;
119+
}
120+
// If TS AST tab has been opened and TS module loaded, refresh TS AST too
121+
if (tsModuleRef.current && tsAstActiveRef.current) {
122+
await buildTypeScriptAst(lastSourceTextRef.current);
108123
}
109124
} catch (err) {
110125
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -136,6 +151,47 @@ const Playground: React.FC = () => {
136151
}, []);
137152
// Initial lint is triggered by Editor's initial onChange
138153

154+
async function buildTypeScriptAst(text: string) {
155+
const ts = tsModuleRef.current!;
156+
try {
157+
const sf = ts.createSourceFile(
158+
'index.ts',
159+
text,
160+
ts.ScriptTarget.Latest,
161+
/*setParentNodes*/ true,
162+
ts.ScriptKind.TS,
163+
);
164+
165+
interface TSAstNode {
166+
type: string;
167+
start: number;
168+
end: number;
169+
text?: string;
170+
children?: TSAstNode[];
171+
}
172+
173+
function tsNodeToTree(node: any): TSAstNode {
174+
const current: TSAstNode = {
175+
type: (ts as any).Debug.formatSyntaxKind(node.kind),
176+
start: node.pos,
177+
end: node.end,
178+
text: node.text,
179+
};
180+
const children: TSAstNode[] = [];
181+
ts.forEachChild(node, (child: any) => {
182+
children.push(tsNodeToTree(child));
183+
});
184+
if (children.length) current.children = children;
185+
return current;
186+
}
187+
188+
setTsAstTree(tsNodeToTree(sf));
189+
} catch (e) {
190+
console.warn('TypeScript AST generation failed:', e);
191+
setTsAstTree(undefined);
192+
}
193+
}
194+
139195
return (
140196
<div className="playground-container">
141197
<div className="editor-panel">
@@ -152,12 +208,28 @@ const Playground: React.FC = () => {
152208
diagnostics={diagnostics}
153209
ast={ast}
154210
astTree={astTree}
211+
tsAstTree={tsAstTree}
155212
error={error}
156213
loading={loading}
157214
onAstNodeSelect={(start, end) =>
158215
editorRef.current?.revealRangeByOffset(start, end)
159216
}
160217
selectedAstNodeRange={selectedAstRange}
218+
onRequestTsAst={async () => {
219+
tsAstActiveRef.current = true;
220+
if (!tsModuleRef.current) {
221+
try {
222+
const mod = await import('typescript');
223+
tsModuleRef.current = mod as any;
224+
} catch (e) {
225+
console.warn('Failed to load TypeScript module:', e);
226+
return;
227+
}
228+
}
229+
await buildTypeScriptAst(
230+
lastSourceTextRef.current || editorRef.current?.getValue() || '',
231+
);
232+
}}
161233
/>
162234
</div>
163235
);

0 commit comments

Comments
 (0)