import * as fs from "node:fs"; import * as path from "node:path"; import { createRequire } from "node:module"; import { getLanguageFromPath as piGetLanguageFromPath, highlightCode as piHighlightCode } from "@earendil-works/pi-coding-agent"; import type { DebugFn, HljsApi, PeekSettings } from "./types.js"; import { expandTabs, getPeekRuntimeRoot } from "./helpers.js"; import { renderMarkdown } from "./markdown.js"; const requireFromHere = createRequire(import.meta.url); function classToThemeColor(className: string): string | undefined { if (/comment|quote|doctag/.test(className)) return "syntaxComment"; if (/keyword|selector-tag|literal|meta/.test(className)) return "syntaxKeyword"; if (/built_in|builtin-name|type|class|title\.class/.test(className)) return "syntaxType"; if (/string|regexp|template-variable/.test(className)) return "syntaxString"; if (/number/.test(className)) return "syntaxNumber"; if (/title|function/.test(className)) return "syntaxFunction"; if (/attr|variable|property|params/.test(className)) return "syntaxVariable"; if (/operator|punctuation|tag/.test(className)) return "syntaxPunctuation"; return undefined; } function decodeEntities(text: string): string { return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") .replace(/"/g, '"').replace(/'/g, "'"); } function htmlToAnsi(html: string, theme: any): string { const stack: ((s: string) => string)[] = [(s: string) => s]; let out = ""; const regex = /|<\/span>|([^<]+)/g; let match: RegExpExecArray | null; while ((match = regex.exec(html)) !== null) { if (match[1]) { const color = classToThemeColor(match[1]!); stack.push(color ? ((s: string) => theme.fg(color, s)) : ((s: string) => s)); } else if (match[0] === "") { if (stack.length > 1) stack.pop(); } else if (match[2]) { out += stack[stack.length - 1]!(decodeEntities(match[2])); } } return out; } function normalizeRenderedLines(lines: string[]): string[] { return lines.map((line) => line.replace(/\r/g, "")); } export class HighlightService { private hljs: HljsApi | undefined; private extraLanguageNames = new Set(); constructor(private readonly pushDebug: DebugFn, private readonly getSettings: () => PeekSettings) {} private getExtraHighlightBuildDir(): string { return path.join(getPeekRuntimeRoot(), "extra", "build", "lib"); } private loadExtraHljs(): HljsApi | undefined { if (this.hljs) return this.hljs; try { const libDir = this.getExtraHighlightBuildDir(); const core = requireFromHere(path.join(libDir, "core.js")) as HljsApi; const languagesDir = path.join(libDir, "languages"); for (const fileName of fs.existsSync(languagesDir) ? fs.readdirSync(languagesDir) : []) { if (!fileName.endsWith(".js")) continue; const languageName = path.basename(fileName, ".js"); try { const mod = requireFromHere(path.join(languagesDir, fileName)); core.registerLanguage(languageName, mod.default ?? mod); this.extraLanguageNames.add(languageName.toLowerCase()); } catch (error) { this.pushDebug(`extra language load failed file=${fileName}: ${error instanceof Error ? error.message : String(error)}`); } } this.hljs = core; this.pushDebug(`extra hljs loaded from ${libDir}`); return core; } catch (error) { this.pushDebug(`extra hljs load failed: ${error instanceof Error ? error.message : String(error)}`); return undefined; } } private resolveExtraLanguage(ext: string, engine: HljsApi | undefined): string | undefined { if (!engine || !ext) return undefined; const mapped = this.getSettings().extraLanguages[ext]; if (mapped && engine.getLanguage(mapped)) return mapped; const normalized = ext.replace(/^\./, "").toLowerCase(); return engine.getLanguage(normalized) ? normalized : undefined; } private detectTarget(filePath: string | undefined): { language: string; source: "pi" | "extra" } | undefined { const ext = filePath ? path.extname(filePath).toLowerCase() : ""; if (ext === ".mkd" || ext === ".markdown") return { language: "markdown", source: "pi" }; if (!filePath) return undefined; const language = piGetLanguageFromPath(filePath); if (language) return { language, source: "pi" }; const extraLanguage = this.resolveExtraLanguage(ext, this.loadExtraHljs()); return extraLanguage ? { language: extraLanguage, source: "extra" } : undefined; } render(content: string, filePath: string | undefined, theme: any): { lines: string[]; highlighted: boolean } { const expanded = expandTabs(content); const ext = filePath ? path.extname(filePath).toLowerCase() : ""; if (ext === ".md" || ext === ".markdown" || ext === ".mkd") { return { lines: renderMarkdown(expanded), highlighted: true }; } const target = this.detectTarget(filePath); this.pushDebug(`renderHighlightedContent file=${filePath ?? ""} language=${target?.language ?? "none"} source=${target?.source ?? "plain"}`); if (target?.source === "pi") { try { return { lines: normalizeRenderedLines(piHighlightCode(expanded, target.language)), highlighted: true }; } catch (error) { this.pushDebug(`pi highlight failed language=${target.language}: ${error instanceof Error ? error.message : String(error)}`); } } if (target?.source === "extra") { const engine = this.loadExtraHljs(); if (engine && engine.getLanguage(target.language)) { try { const html = engine.highlight(expanded, { language: target.language, ignoreIllegals: true }).value; return { lines: normalizeRenderedLines(htmlToAnsi(html, theme).replace(/\r\n/g, "\n").split("\n")), highlighted: true }; } catch (error) { this.pushDebug(`extra highlight failed language=${target.language}: ${error instanceof Error ? error.message : String(error)}`); } } } return { lines: expanded.replace(/\r\n/g, "\n").split("\n"), highlighted: false }; } }