clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / highlight.ts
highlight.ts 5.5K
1// Syntax highlighting at build time with highlight.js. It runs synchronously2// inside the render filters and emits class names only; the colors are CSS3// variables in style.css. The language comes from the file name, or the4// shebang for extensionless scripts. Unknown languages and very large files5// stay plain rather than paying for auto-detection.6import fs from 'node:fs';7import path from 'node:path';8import { createRequire } from 'node:module';9import type { HLJSApi } from 'highlight.js';1011const require = createRequire(import.meta.url);12let api: HLJSApi | null = null;13// Loading every grammar costs ~35ms, so builds that render no code skip it.14const hljs = (): HLJSApi => (api ??= require('highlight.js') as HLJSApi);1516// Larger inputs render plain: highlighting runs at a few MB/s.17export const MAX_HIGHLIGHT = 256 << 10;1819// Whole file names, and extensions highlight.js's aliases miss or get wrong.20const NAMES: Record<string, string> = {21 makefile: 'makefile', gnumakefile: 'makefile', dockerfile: 'dockerfile', containerfile: 'dockerfile',22 'cmakelists.txt': 'cmake', gemfile: 'ruby', rakefile: 'ruby', vagrantfile: 'ruby', podfile: 'ruby',23 '.bashrc': 'bash', '.bash_profile': 'bash', '.zshrc': 'bash', '.zprofile': 'bash', '.profile': 'bash', '.envrc': 'bash',24 '.gitconfig': 'ini', '.gitmodules': 'ini', '.editorconfig': 'ini', 'flake.lock': 'json', 'go.mod': 'go', 'go.work': 'go',25};26const EXTS: Record<string, string | null> = {27 m: 'objectivec', ml: 'ocaml', mli: 'ocaml', jl: 'julia', el: 'lisp', s: 'x86asm', asm: 'x86asm',28 njk: 'django', jinja: 'django', j2: 'django', vue: 'xml', svelte: 'xml', cfg: 'ini', service: 'ini',29 txt: null, text: null,30};31const SHEBANG: [RegExp, string][] = [32 [/\b(sh|bash|zsh|ksh|dash|ash)$/, 'bash'], [/\b(node|deno|bun)$/, 'javascript'], [/\bpython[\d.]*$/, 'python'],33 [/\bruby$/, 'ruby'], [/\bperl$/, 'perl'], [/\bphp$/, 'php'], [/\blua$/, 'lua'], [/\b[gnm]?awk$/, 'awk'], [/\btclsh$/, 'tcl'],34];3536// A highlight.js language for a fence info string or an extension, or null.37export function resolve(name: string | null | undefined): string | null {38 const n = (name ?? '').trim().toLowerCase();39 if (!n) return null;40 const l = n in EXTS ? EXTS[n] : n;41 return l && hljs().getLanguage(l) && l !== 'plaintext' ? l : null;42}4344// The language of a file, from its name or, without an extension, its first line.45export function language(file: string, firstLine = ''): string | null {46 const base = file.slice(file.lastIndexOf('/') + 1).toLowerCase();47 if (NAMES[base]) return NAMES[base];48 const dot = base.lastIndexOf('.');49 if (dot > 0) return resolve(base.slice(dot + 1));50 const m = /^#!\s*(\S+)(?:\s+(?:-\S+\s+)*(\S+))?/.exec(firstLine);51 if (!m) return null;52 // "#!/usr/bin/env node" names the interpreter second.53 const prog = m[1].endsWith('/env') && m[2] ? m[2] : m[1];54 return SHEBANG.find(([re]) => re.test(prog))?.[1] ?? null;55}5657// Highlighted HTML for code, or null when it should stay plain.58export function highlight(code: string, lang: string | null): string | null {59 if (!lang || code.length > MAX_HIGHLIGHT) return null;60 try {61 return hljs().highlight(code, { language: lang, ignoreIllegals: true }).value;62 } catch {63 return null;64 }65}6667// Split highlighted HTML into lines. Spans open at a line break are closed at68// the end of the line and reopened at the start of the next, so each line is69// markup of its own and tokens never span rows. highlight.js escapes text, so70// every "<" is one of its tags.71export function splitLines(html: string): string[] {72 const out: string[] = [];73 const open: string[] = [];74 let line = '';75 let at = 0;76 for (const m of html.matchAll(/<span[^>]*>|<\/span>|\n/g)) {77 line += html.slice(at, m.index);78 at = m.index + m[0].length;79 if (m[0] === '\n') {80 out.push(line + '</span>'.repeat(open.length));81 line = open.join('');82 continue;83 }84 if (m[0] === '</span>') open.pop();85 else open.push(m[0]);86 line += m[0];87 }88 out.push(line + html.slice(at));89 return out;90}9192// Highlighted lines of `code` (as many as code.split('\n') gives), or null.93export function highlightLines(code: string, lang: string | null): string[] | null {94 const h = highlight(code, lang);95 return h == null ? null : splitLines(h);96}9798// A new branch renders every file page again, though most of its blobs are99// unchanged, and highlighting is most of that work. So file highlights are100// kept on disk, one per blob id and language, and sweep() drops those for101// blobs no branch has any more.102const VERSION = (require('highlight.js/package.json') as { version: string }).version;103104export class HighlightCache {105 dir: string;106107 constructor(dir: string) {108 this.dir = dir;109 }110111 highlight(oid: string, code: string, lang: string | null): string | null {112 if (!lang || code.length > MAX_HIGHLIGHT) return null;113 const file = path.join(this.dir, `${oid}.${lang}.${VERSION}`);114 try {115 return fs.readFileSync(file, 'utf8');116 } catch {}117 const h = highlight(code, lang);118 if (h != null) {119 fs.mkdirSync(this.dir, { recursive: true });120 fs.writeFileSync(`${file}.${process.pid}`, h);121 fs.renameSync(`${file}.${process.pid}`, file);122 }123 return h;124 }125126 sweep(oids: Set<string>): void {127 let names: string[] = [];128 try {129 names = fs.readdirSync(this.dir);130 } catch {}131 for (const n of names) {132 const [oid, , ...version] = n.split('.');133 if (!oids.has(oid) || version.join('.') !== VERSION) fs.rmSync(path.join(this.dir, n), { force: true });134 }135 }136}