/**
* Lightweight syntax highlighter for JSX, HTML, and CSS.
* Produces HTML strings with color spans.
*/
interface TokenRule {
pattern: RegExp;
className: string;
}
const JSX_RULES: TokenRule[] = [
// Multi-line comments
{ pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' },
// Single-line comments
{ pattern: /\/\/.*/g, className: 'syn-comment' },
// JSX self-closing tags:
{ pattern: /<\/?[A-Za-z][A-Za-z0-9.]*/g, className: 'syn-tag' },
// Closing bracket
{ pattern: /\/?>/g, className: 'syn-tag' },
// Strings (double and single quoted)
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
{ pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' },
// Template literals
{ pattern: /`(?:[^`\\]|\\.)*`/g, className: 'syn-string' },
// Keywords
{
pattern:
/\b(export|function|return|const|let|var|import|from|default|if|else|for|while|switch|case|break|continue|new|this|class|extends|typeof|instanceof|void|null|undefined|true|false)\b/g,
className: 'syn-keyword',
},
// Attribute names (word followed by =)
{ pattern: /\b[a-zA-Z-]+(?==)/g, className: 'syn-attr' },
// Numbers
{ pattern: /\b\d+\.?\d*\b/g, className: 'syn-number' },
// Curly braces in JSX
{ pattern: /[{}]/g, className: 'syn-bracket' },
];
const HTML_RULES: TokenRule[] = [
// Comments
{ pattern: //g, className: 'syn-comment' },
// Tags
{ pattern: /<\/?[a-zA-Z][a-zA-Z0-9-]*/g, className: 'syn-tag' },
{ pattern: /\/?>/g, className: 'syn-tag' },
// Attribute values
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
{ pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' },
// Attribute names
{ pattern: /\b[a-zA-Z-]+(?==)/g, className: 'syn-attr' },
];
const CSS_RULES: TokenRule[] = [
// Comments
{ pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' },
// Selectors (class/id/element)
{ pattern: /[.#]?[a-zA-Z_-][a-zA-Z0-9_-]*(?=\s*\{)/g, className: 'syn-tag' },
// Property names
{ pattern: /[a-zA-Z-]+(?=\s*:)/g, className: 'syn-attr' },
// Strings
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
{ pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' },
// Numbers with units
{ pattern: /\b\d+\.?\d*(px|em|rem|%|deg|vh|vw|s|ms)?\b/g, className: 'syn-number' },
// Colors
{ pattern: /#[0-9a-fA-F]{3,8}\b/g, className: 'syn-string' },
// Braces
{ pattern: /[{}]/g, className: 'syn-bracket' },
// Keywords
{
pattern:
/\b(important|inherit|initial|unset|none|auto|solid|dashed|flex|grid|block|inline|absolute|relative|fixed|sticky)\b/g,
className: 'syn-keyword',
},
];
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
interface Token {
start: number;
end: number;
className: string;
}
function tokenize(code: string, rules: TokenRule[]): Token[] {
const tokens: Token[] = [];
for (const rule of rules) {
const regex = new RegExp(rule.pattern.source, rule.pattern.flags);
let match: RegExpExecArray | null;
while ((match = regex.exec(code)) !== null) {
tokens.push({
start: match.index,
end: match.index + match[0].length,
className: rule.className,
});
}
}
// Sort by start position; earlier rules win ties (priority order)
tokens.sort((a, b) => a.start - b.start);
// Remove overlapping tokens (first match wins)
const filtered: Token[] = [];
let lastEnd = 0;
for (const token of tokens) {
if (token.start >= lastEnd) {
filtered.push(token);
lastEnd = token.end;
}
}
return filtered;
}
function renderTokens(code: string, tokens: Token[]): string {
let result = '';
let pos = 0;
for (const token of tokens) {
if (token.start > pos) {
result += escapeHtml(code.slice(pos, token.start));
}
result += `${escapeHtml(code.slice(token.start, token.end))}`;
pos = token.end;
}
if (pos < code.length) {
result += escapeHtml(code.slice(pos));
}
return result;
}
const SWIFT_RULES: TokenRule[] = [
{ pattern: /\/\/.*/g, className: 'syn-comment' },
{ pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' },
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
{
pattern:
/\b(import|struct|var|let|func|return|if|else|for|in|while|switch|case|break|some|self|true|false|nil|class|enum|protocol|extension|guard|throw|try|catch|async|await|private|public|internal|static|mutating|typealias|associatedtype|where)\b/g,
className: 'syn-keyword',
},
{
pattern:
/\b(View|Text|VStack|HStack|ZStack|Color|Font|Image|Rectangle|RoundedRectangle|Circle|Ellipse|Capsule|Path|Spacer|Divider|GeometryReader|ScrollView|Button|AsyncImage|LinearGradient|RadialGradient|Shape|some)\b/g,
className: 'syn-tag',
},
{ pattern: /\.[a-zA-Z]+(?=\()/g, className: 'syn-attr' },
{ pattern: /\b\d+\.?\d*\b/g, className: 'syn-number' },
{ pattern: /[{}()]/g, className: 'syn-bracket' },
];
const KOTLIN_RULES: TokenRule[] = [
{ pattern: /\/\/.*/g, className: 'syn-comment' },
{ pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' },
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
{
pattern:
/\b(package|import|fun|val|var|return|if|else|for|in|while|when|is|class|object|interface|override|private|public|internal|companion|data|sealed|abstract|open|suspend|inline|annotation|true|false|null)\b/g,
className: 'syn-keyword',
},
{
pattern:
/\b(Composable|Modifier|Column|Row|Box|Text|Image|Icon|Canvas|Divider|Spacer|Surface|Card|Scaffold|LazyColumn|LazyRow|Color|FontWeight|FontStyle|TextAlign|TextDecoration|Arrangement|Alignment|RoundedCornerShape|CircleShape|Dp|ContentScale)\b/g,
className: 'syn-tag',
},
{ pattern: /\.[a-zA-Z]+(?=\()/g, className: 'syn-attr' },
{ pattern: /\b\d+\.?\d*(f|dp|sp)?\b/g, className: 'syn-number' },
{ pattern: /@[A-Za-z]+/g, className: 'syn-attr' },
{ pattern: /[{}()]/g, className: 'syn-bracket' },
];
const DART_RULES: TokenRule[] = [
{ pattern: /\/\/.*/g, className: 'syn-comment' },
{ pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' },
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
{ pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' },
{
pattern:
/\b(import|class|extends|implements|with|mixin|abstract|final|const|var|void|return|if|else|for|in|while|switch|case|break|continue|new|this|super|true|false|null|async|await|static|late|required|override|enum|typedef|try|catch|throw|dynamic)\b/g,
className: 'syn-keyword',
},
{
pattern:
/\b(Widget|StatelessWidget|StatefulWidget|State|BuildContext|Container|Column|Row|Stack|Positioned|Text|Image|SizedBox|Padding|Center|Expanded|Flexible|Scaffold|AppBar|Material|BoxDecoration|BorderRadius|Border|EdgeInsets|Color|TextStyle|FontWeight|FontStyle|TextAlign|BoxFit|MainAxisAlignment|CrossAxisAlignment|CustomPaint|ClipPath|Opacity|Transform|LinearGradient|RadialGradient)\b/g,
className: 'syn-tag',
},
{ pattern: /\.[a-zA-Z]+(?=\()/g, className: 'syn-attr' },
{ pattern: /@[a-zA-Z]+/g, className: 'syn-attr' },
{ pattern: /\b\d+\.?\d*\b/g, className: 'syn-number' },
{ pattern: /[{}()]/g, className: 'syn-bracket' },
];
export type SyntaxLanguage = 'jsx' | 'html' | 'css' | 'swift' | 'kotlin' | 'dart';
export function highlightCode(code: string, language: SyntaxLanguage): string {
const ruleMap: Record = {
jsx: JSX_RULES,
html: HTML_RULES,
css: CSS_RULES,
swift: SWIFT_RULES,
kotlin: KOTLIN_RULES,
dart: DART_RULES,
};
const rules = ruleMap[language];
const tokens = tokenize(code, rules);
return renderTokens(code, tokens);
}