Files
helder/src/renderer/src/highlight.ts
Jonathan van Rij e5f739ec24
Some checks failed
CI / check (push) Has been cancelled
fixes on the UI changes
2026-09-03 15:33:15 +02:00

156 lines
5.5 KiB
TypeScript

/* Syntax highlighting (Prism) + file-type icon metadata.
*
* The default `prismjs` bundle already registers markup, css, clike and
* javascript. We add the rest in dependency order. CRITICAL: prism-php requires
* prism-markup-templating to be loaded FIRST, or every Prism.highlight() call
* throws and silently falls back to plain text. */
import Prism from 'prismjs'
import { isKnownSymbol } from './symbols'
import 'prismjs/components/prism-markup-templating'
import 'prismjs/components/prism-php'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-typescript'
import 'prismjs/components/prism-jsx'
import 'prismjs/components/prism-tsx'
import 'prismjs/components/prism-json'
import 'prismjs/components/prism-bash'
import 'prismjs/components/prism-yaml'
import 'prismjs/components/prism-markdown'
// We drive highlighting manually; no DOM auto-scan.
Prism.manual = true
const EXT_LANG: Record<string, string> = {
php: 'php', js: 'javascript', mjs: 'javascript', cjs: 'javascript',
jsx: 'jsx', ts: 'typescript', tsx: 'tsx', py: 'python',
html: 'markup', xml: 'markup', svg: 'markup', vue: 'markup',
css: 'css', scss: 'css', json: 'json', md: 'markdown',
sh: 'bash', bash: 'bash', yml: 'yaml', yaml: 'yaml', env: 'bash',
}
function ext(path: string): string {
const base = path.split('/').pop() || ''
if (base === '.env' || base.startsWith('.env')) return 'env'
const i = base.lastIndexOf('.')
return i >= 0 ? base.slice(i + 1).toLowerCase() : ''
}
// Files the viewer renders as a picture (<img>) rather than as text/code.
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng', 'jfif'])
function isImage(path: string): boolean {
return IMAGE_EXT.has(ext(path))
}
function langFor(path: string): string | null {
return EXT_LANG[ext(path)] || null
}
function langLabel(path: string): string {
const e = ext(path)
const map: Record<string, string> = {
php: 'PHP', js: 'JavaScript', mjs: 'JavaScript', ts: 'TypeScript',
tsx: 'TypeScript', jsx: 'JavaScript', py: 'Python', html: 'HTML',
css: 'CSS', json: 'JSON', md: 'Markdown', sh: 'Shell', env: 'Dotenv',
yml: 'YAML', yaml: 'YAML',
}
return map[e] || (e ? e.toUpperCase() : 'Plain Text')
}
function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c] as string))
}
/* Mark the class tokens this project actually declares, so ⌘-click has
* something honest to underline. Prism already tells us which tokens are class
* names; the index tells us which of those we can open. A token whose text is
* not a bare identifier (a namespaced `App\Models\Agent`) carries nested spans
* and never matches, which is right — the name to click sits inside it. */
const TOKEN_RE = /<span class="token ([^"]*)">([A-Za-z_]\w*)<\/span>/g
function markKnown(html: string): string {
return html.replace(TOKEN_RE, (whole, cls: string, name: string) => (
/\b(class-name|package)\b/.test(cls) && isKnownSymbol(name)
? `<span class="token ${cls} sym">${name}</span>`
: whole
))
}
// highlight a single line independently (keeps line numbering robust)
function hlLine(line: string, lang: string | null): string {
if (line === '') return '&nbsp;'
try {
const grammar = lang ? Prism.languages[lang] : null
if (grammar) {
const html = Prism.highlight(line, grammar, lang as string)
return lang === 'php' ? markKnown(html) : html
}
} catch {
/* fall through */
}
return escapeHtml(line)
}
// highlight a whole multi-line block (for the editable buffer's display layer)
function hlText(text: string, lang: string | null): string {
try {
const grammar = lang ? Prism.languages[lang] : null
if (grammar) {
const html = Prism.highlight(text, grammar, lang as string)
return lang === 'php' ? markKnown(html) : html
}
} catch {
/* fall through */
}
return escapeHtml(text)
}
// ---- file-type icon: colored monogram chip --------------------------
interface IconMeta { c: string; t: string }
// Four fills only, taken from the syntax palette: purple, amber-soft, teal and
// the neutral grey. The text is the extension, in ink on the fill.
const PURPLE = '#C3A6CE'
const AMBER = '#F0B476'
const TEAL = '#8FBFB4'
const GREY = '#BAC0C0'
const ICONS: Record<string, IconMeta> = {
php: { c: PURPLE, t: 'php' },
blade: { c: PURPLE, t: 'php' },
ts: { c: PURPLE, t: 'ts' },
tsx: { c: PURPLE, t: 'tsx' },
vue: { c: PURPLE, t: 'vue' },
js: { c: AMBER, t: 'js' },
mjs: { c: AMBER, t: 'js' },
cjs: { c: AMBER, t: 'js' },
jsx: { c: AMBER, t: 'jsx' },
json: { c: AMBER, t: 'json' },
yml: { c: AMBER, t: 'yml' },
yaml: { c: AMBER, t: 'yml' },
css: { c: TEAL, t: 'css' },
scss: { c: TEAL, t: 'scss' },
pcss: { c: TEAL, t: 'css' },
html: { c: TEAL, t: 'html' },
sh: { c: TEAL, t: 'sh' },
env: { c: TEAL, t: 'env' },
py: { c: TEAL, t: 'py' },
sql: { c: TEAL, t: 'sql' },
md: { c: GREY, t: 'md' },
txt: { c: GREY, t: 'txt' },
lock: { c: GREY, t: 'lock' },
svg: { c: GREY, t: 'svg' },
}
const NAME_ICONS: Record<string, IconMeta> = {
'composer.json': { c: PURPLE, t: 'json' },
'package.json': { c: AMBER, t: 'json' },
'.env': { c: TEAL, t: 'env' },
}
function iconFor(path: string): IconMeta {
const base = path.split('/').pop() || ''
if (NAME_ICONS[base]) return NAME_ICONS[base]
return ICONS[ext(path)] || { c: GREY, t: ext(path).slice(0, 4) || '·' }
}
export const HL = { ext, langFor, langLabel, isImage, hlLine, hlText, iconFor, escapeHtml }