Skip to content

Commit da61772

Browse files
feat(cdk-explorer): add syntax highlighting, YAML template view, diagnostics, and app path
- Add PrismJS-based syntax highlighting for TypeScript, JSON, and YAML in the code viewer (~12KB bundle cost) - Add squiggly underlines for policy violations in the source pane, colored by severity (error/warning/info) - Create TemplateViewer component that displays templates as YAML by default with a JSON/YAML toggle - Add collapsible resource sections in the template view with Collapse All / Expand All controls - Show the CDK app directory path in the page header via GET /api/info - Add js-yaml and prismjs as devDependencies
1 parent 2988556 commit da61772

7 files changed

Lines changed: 1979 additions & 779 deletions

File tree

packages/@aws-cdk/cdk-explorer/frontend/App.tsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import SpaceBetween from '@cloudscape-design/components/space-between';
66
import Spinner from '@cloudscape-design/components/spinner';
77
import * as React from 'react';
88
import { api, type DirEntry, type LineRange, type TemplateResponse, type TreeResponse, type ViolationsResponse } from './api';
9+
import { CodeViewer, type Diagnostic } from './components/CodeViewer';
910
import { ConstructTree } from './components/ConstructTree';
10-
import { CodeViewer } from './components/CodeViewer';
11+
import { TemplateViewer } from './components/TemplateViewer';
1112
import { ViolationsPanel } from './components/ViolationsPanel';
1213
import type { NavigateHandler } from './nav-types';
1314
export type { NavigateHandler } from './nav-types';
@@ -228,11 +229,13 @@ export function App(): JSX.Element {
228229
) : sourceContent ? (
229230
<CodeViewer
230231
content={sourceContent}
232+
language={detectLanguage(sourceFile)}
231233
highlightStart={nav?.source?.startLine}
232234
highlightEnd={nav?.source?.endLine}
233235
highlightColor={nav?.color}
234236
navCounter={nav?.navCounter}
235237
scrollToLine={nav?.source?.startLine}
238+
diagnostics={buildDiagnostics(sourceFile, violations)}
236239
/>
237240
) : (
238241
<Box color="text-status-inactive">Double-click a construct to view its source.</Box>
@@ -251,8 +254,9 @@ export function App(): JSX.Element {
251254
{showFilePicker === 'template' ? (
252255
<FilePicker dir={pickerDir} entries={pickerEntries} onBrowse={browseDir} onPick={(p) => void pickFile(p, 'template')} />
253256
) : templateData ? (
254-
<CodeViewer
255-
content={templateData.content}
257+
<TemplateViewer
258+
jsonContent={templateData.content}
259+
resources={templateData.resources}
256260
highlightStart={nav?.template?.startLine}
257261
highlightEnd={nav?.template?.endLine}
258262
highlightColor={nav?.color}
@@ -527,3 +531,46 @@ const RESIZER_BUTTON_VERTICAL: React.CSSProperties = {
527531
height: '40px',
528532
borderRadius: '7px',
529533
};
534+
535+
function detectLanguage(file: string | undefined): 'typescript' | 'json' | 'yaml' {
536+
if (!file) return 'typescript';
537+
if (file.endsWith('.json')) return 'json';
538+
if (file.endsWith('.yaml') || file.endsWith('.yml')) return 'yaml';
539+
return 'typescript';
540+
}
541+
542+
function buildDiagnostics(
543+
sourceFile: string | undefined,
544+
violations: ViolationsResponse | undefined,
545+
): Diagnostic[] | undefined {
546+
if (!sourceFile || !violations || violations.status !== 'ok' || violations.violations.length === 0) {
547+
return undefined;
548+
}
549+
const diagnostics: Diagnostic[] = [];
550+
for (const violation of violations.violations) {
551+
const severity = violationSeverityToDiagnostic(violation.severity);
552+
for (const occ of violation.occurrences) {
553+
if (occ.sourceLocation?.file === sourceFile && occ.sourceLocation.line >= 1) {
554+
diagnostics.push({
555+
startLine: occ.sourceLocation.line,
556+
startCol: occ.sourceLocation.column >= 1 ? occ.sourceLocation.column : 1,
557+
severity,
558+
message: violation.description,
559+
});
560+
}
561+
}
562+
}
563+
return diagnostics.length > 0 ? diagnostics : undefined;
564+
}
565+
566+
function violationSeverityToDiagnostic(severity: string | undefined): 'error' | 'warning' | 'info' {
567+
switch (severity) {
568+
case 'fatal':
569+
case 'error':
570+
return 'error';
571+
case 'warning':
572+
return 'warning';
573+
default:
574+
return 'info';
575+
}
576+
}

packages/@aws-cdk/cdk-explorer/frontend/components/CodeViewer.tsx

Lines changed: 148 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,88 @@
11
import * as React from 'react';
2+
import { tokenizeLines, type Token } from '../syntax';
3+
4+
export interface Diagnostic {
5+
readonly startLine: number;
6+
readonly startCol: number;
7+
readonly endCol?: number;
8+
readonly severity: 'error' | 'warning' | 'info';
9+
readonly message?: string;
10+
}
211

312
export interface CodeViewerProps {
413
readonly content: string;
14+
readonly language: 'typescript' | 'json' | 'yaml';
515
readonly highlightStart?: number;
616
readonly highlightEnd?: number;
717
readonly highlightColor?: string;
818
readonly navCounter?: number;
919
readonly scrollToLine?: number;
1020
readonly onLineDoubleClick?: (line: number) => void;
21+
readonly diagnostics?: readonly Diagnostic[];
1122
}
1223

24+
const SQUIGGLE_COLORS: Record<string, string> = {
25+
error: '#d91515',
26+
warning: '#ff9900',
27+
info: '#0972d3',
28+
};
29+
30+
const TOKEN_COLORS: Record<string, string> = {
31+
keyword: '#0000ff',
32+
string: '#a31515',
33+
number: '#098658',
34+
boolean: '#0000ff',
35+
'attr-name': '#0451a5',
36+
property: '#0451a5',
37+
operator: '#393a34',
38+
punctuation: '#393a34',
39+
comment: '#008000',
40+
'class-name': '#267f99',
41+
builtin: '#267f99',
42+
function: '#795e26',
43+
tag: '#0451a5',
44+
selector: '#0451a5',
45+
key: '#0451a5',
46+
'atrule': '#0000ff',
47+
// YAML-specific
48+
important: '#098658',
49+
// null/undefined
50+
'null': '#0000ff',
51+
};
52+
1353
export function CodeViewer({
1454
content,
55+
language,
1556
highlightStart,
1657
highlightEnd,
1758
highlightColor,
1859
navCounter,
1960
scrollToLine,
2061
onLineDoubleClick,
62+
diagnostics,
2163
}: CodeViewerProps): JSX.Element {
22-
const lines = React.useMemo(() => content.split('\n'), [content]);
2364
const scrollTargetRef = React.useRef<HTMLDivElement>(null);
2465

66+
const tokenizedLines = React.useMemo(
67+
() => tokenizeLines(content, language),
68+
[content, language],
69+
);
70+
2571
const gutterWidth = React.useMemo(() => {
26-
const digits = String(lines.length).length;
72+
const digits = String(tokenizedLines.length).length;
2773
return `${digits * 8 + 8}px`;
28-
}, [lines.length]);
74+
}, [tokenizedLines.length]);
75+
76+
const diagnosticsByLine = React.useMemo(() => {
77+
if (!diagnostics?.length) return undefined;
78+
const map = new Map<number, Diagnostic[]>();
79+
for (const d of diagnostics) {
80+
const arr = map.get(d.startLine) ?? [];
81+
arr.push(d);
82+
map.set(d.startLine, arr);
83+
}
84+
return map;
85+
}, [diagnostics]);
2986

3087
React.useEffect(() => {
3188
if (scrollToLine && scrollTargetRef.current) {
@@ -35,13 +92,15 @@ export function CodeViewer({
3592

3693
return (
3794
<div style={CONTAINER_STYLE}>
38-
{lines.map((line, i) => {
95+
{tokenizedLines.map((lineTokens, i) => {
3996
const lineNum = i + 1;
4097
const isHighlighted = highlightStart !== undefined
4198
&& highlightEnd !== undefined
4299
&& lineNum >= highlightStart
43100
&& lineNum <= highlightEnd;
44101
const isScrollTarget = lineNum === scrollToLine;
102+
const lineDiagnostics = diagnosticsByLine?.get(lineNum);
103+
45104
return (
46105
<div
47106
key={`${lineNum}-${navCounter}`}
@@ -54,14 +113,98 @@ export function CodeViewer({
54113
onDoubleClick={onLineDoubleClick ? () => onLineDoubleClick(lineNum) : undefined}
55114
>
56115
<span style={{ ...LINE_NUM_STYLE, width: gutterWidth }}>{lineNum}</span>
57-
<span style={LINE_CONTENT_STYLE}>{line}</span>
116+
<span style={LINE_CONTENT_STYLE}>
117+
{lineDiagnostics
118+
? renderWithDiagnostics(lineTokens, lineDiagnostics)
119+
: renderTokens(lineTokens)
120+
}
121+
</span>
58122
</div>
59123
);
60124
})}
61125
</div>
62126
);
63127
}
64128

129+
function renderTokens(tokens: Token[]): React.ReactNode {
130+
return tokens.map((token, j) => {
131+
const color = token.type ? TOKEN_COLORS[token.type] : undefined;
132+
return color
133+
? <span key={j} style={{ color }}>{token.content}</span>
134+
: <span key={j}>{token.content}</span>;
135+
});
136+
}
137+
138+
function renderWithDiagnostics(tokens: Token[], diagnostics: Diagnostic[]): React.ReactNode {
139+
let col = 1;
140+
const elements: React.ReactNode[] = [];
141+
let key = 0;
142+
143+
for (const token of tokens) {
144+
const tokenStart = col;
145+
const tokenEnd = col + token.content.length;
146+
const color = token.type ? TOKEN_COLORS[token.type] : undefined;
147+
148+
let hasOverlap = false;
149+
for (const d of diagnostics) {
150+
const dEnd = d.endCol ?? 999;
151+
if (d.startCol < tokenEnd && dEnd > tokenStart) {
152+
hasOverlap = true;
153+
break;
154+
}
155+
}
156+
157+
if (!hasOverlap) {
158+
elements.push(
159+
color
160+
? <span key={key++} style={{ color }}>{token.content}</span>
161+
: <span key={key++}>{token.content}</span>,
162+
);
163+
} else {
164+
const chars = token.content;
165+
let segStart = 0;
166+
let currentSeverity = '';
167+
168+
for (let c = 0; c <= chars.length; c++) {
169+
const charCol = tokenStart + c;
170+
let severity = '';
171+
for (const d of diagnostics) {
172+
const dEnd = d.endCol ?? 999;
173+
if (charCol >= d.startCol && charCol < dEnd) {
174+
severity = d.severity;
175+
break;
176+
}
177+
}
178+
179+
if (c === chars.length || severity !== currentSeverity) {
180+
if (segStart < c) {
181+
const text = chars.slice(segStart, c);
182+
if (currentSeverity) {
183+
elements.push(
184+
<span key={key++} style={{ color, textDecoration: `underline wavy ${SQUIGGLE_COLORS[currentSeverity]}`, textUnderlineOffset: '3px' }}>
185+
{text}
186+
</span>,
187+
);
188+
} else {
189+
elements.push(
190+
color
191+
? <span key={key++} style={{ color }}>{text}</span>
192+
: <span key={key++}>{text}</span>,
193+
);
194+
}
195+
}
196+
segStart = c;
197+
currentSeverity = severity;
198+
}
199+
}
200+
}
201+
202+
col = tokenEnd;
203+
}
204+
205+
return elements;
206+
}
207+
65208
const CONTAINER_STYLE: React.CSSProperties = {
66209
margin: 0,
67210
maxHeight: '100%',

0 commit comments

Comments
 (0)