feat: add PowerShellHighlighter syntax highlighter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
97
frontend/src/components/scripts/PowerShellHighlighter.tsx
Normal file
97
frontend/src/components/scripts/PowerShellHighlighter.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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-[#22d3ee]',
|
||||
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
|
||||
}
|
||||
|
||||
export function PowerShellHighlighter({ script }: Props) {
|
||||
const parts: React.ReactNode[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
TOKEN_REGEX.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = TOKEN_REGEX.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="font-label text-sm bg-card rounded-xl p-4 overflow-x-auto">
|
||||
<code>{parts}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user