156 lines
5.5 KiB
TypeScript
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) => ({ '&': '&', '<': '<', '>': '>' }[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 ' '
|
|
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 }
|