- Home sidebar icon: always cyan, bg-accent-dim only when route is "/" - Mobile TopBar: add left padding so hamburger isn't hidden behind logo - Landing page: bump card border color (#1e2130 → #2a2f3d) for better contrast - Replace all font-label references (40 occurrences, 19 files) with font-mono or font-sans - Remove deprecated --font-label CSS variable from index.css - Convert hardcoded hex in layout inline styles to CSS variables (light-mode ready) - Add @types/react-syntax-highlighter for script builder types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
99 lines
3.2 KiB
TypeScript
99 lines
3.2 KiB
TypeScript
/**
|
|
* Single-pass PowerShell syntax highlighter.
|
|
*
|
|
* Uses a combined alternation regex so tokens matched earlier in the list
|
|
* cannot be re-coloured by later rules (e.g. a variable inside a string
|
|
* is captured by the string rule and won't be re-matched by the variable rule).
|
|
*
|
|
* Priority order:
|
|
* 1. Comments /#[^\r\n]star/
|
|
* 2. String literals /"[^"]*"|'[^']*'/
|
|
* 3. Unfilled placeholders /\{\{[^}]+\}\}/
|
|
* 4. Variables /\$\w+/
|
|
* 5. Cmdlets /[A-Z][a-z]+-[A-Z][a-zA-Z]+/
|
|
* 6. Parameters /-[A-Za-z]+/
|
|
* 7. Keywords /\b(if|else|...)\b/
|
|
*
|
|
* Note: variables (priority 4) consume $foreach before keywords (priority 7)
|
|
* can match — this is intentional PowerShell behaviour.
|
|
*/
|
|
|
|
import React from 'react'
|
|
|
|
const TOKEN_REGEX = new RegExp(
|
|
[
|
|
/#[^\r\n]*/, // 1. comments
|
|
/"[^"]*"|'[^']*'/, // 2. string literals
|
|
/\{\{[^}]+\}\}/, // 3. unfilled placeholders
|
|
/\$\w+/, // 4. variables
|
|
/[A-Z][a-z]+-[A-Z][a-zA-Z]+/, // 5. cmdlets (Verb-Noun)
|
|
/-[A-Za-z]+/, // 6. parameters
|
|
/\b(?:if|else|elseif|foreach|for|while|function|return|try|catch|finally|param|switch)\b/, // 7. keywords
|
|
]
|
|
.map(r => r.source)
|
|
.join('|'),
|
|
'g'
|
|
)
|
|
|
|
const TOKEN_CLASSES: Record<string, string> = {
|
|
comment: 'text-[#8b949e]',
|
|
string: 'text-[#a5d6ff]',
|
|
placeholder: 'text-amber-400 underline decoration-dashed',
|
|
variable: 'text-[#79c0ff]',
|
|
cmdlet: 'text-primary',
|
|
parameter: 'text-[#d2a8ff]',
|
|
keyword: 'text-[#ff7b72]',
|
|
}
|
|
|
|
const KEYWORDS = new Set([
|
|
'if', 'else', 'elseif', 'foreach', 'for', 'while',
|
|
'function', 'return', 'try', 'catch', 'finally', 'param', 'switch',
|
|
])
|
|
|
|
function classify(token: string): string {
|
|
if (token.startsWith('#')) return 'comment'
|
|
if (token.startsWith('"') || token.startsWith("'")) return 'string'
|
|
if (token.startsWith('{{')) return 'placeholder'
|
|
if (token.startsWith('$')) return 'variable'
|
|
if (/^-[A-Za-z]+$/.test(token)) return 'parameter'
|
|
if (KEYWORDS.has(token)) return 'keyword'
|
|
return 'cmdlet'
|
|
}
|
|
|
|
interface Props {
|
|
script: string
|
|
className?: string
|
|
}
|
|
|
|
export function PowerShellHighlighter({ script, className }: Props) {
|
|
const parts: React.ReactNode[] = []
|
|
let lastIndex = 0
|
|
const tokenRegex = new RegExp(TOKEN_REGEX.source, TOKEN_REGEX.flags)
|
|
|
|
let match: RegExpExecArray | null
|
|
|
|
while ((match = tokenRegex.exec(script)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
parts.push(script.slice(lastIndex, match.index))
|
|
}
|
|
const token = match[0]
|
|
const kind = classify(token)
|
|
parts.push(
|
|
<span key={match.index} className={TOKEN_CLASSES[kind]}>
|
|
{token}
|
|
</span>
|
|
)
|
|
lastIndex = match.index + token.length
|
|
}
|
|
|
|
if (lastIndex < script.length) {
|
|
parts.push(script.slice(lastIndex))
|
|
}
|
|
|
|
return (
|
|
<pre className={className ?? "font-mono text-sm bg-card rounded-xl p-4 overflow-x-auto"}>
|
|
<code>{parts}</code>
|
|
</pre>
|
|
)
|
|
}
|