import Anser, { type AnserJsonEntry } from "anser"; import { escapeCarriageReturn } from "escape-carriage"; import fs from "node:fs"; import type { Plugin } from "vite"; const colorMap: Record = { "ansi-black": "color: var(--black)", "ansi-red": "color: var(--red)", "ansi-green": "color: var(--green)", "ansi-yellow": "color: var(--brown)", "ansi-blue": "color: var(--blue)", "ansi-magenta": "color: var(--magenta)", "ansi-cyan": "color: var(--cyan)", "ansi-white": "color: var(--light-gray)", "ansi-bright-black": "color: var(--dark-gray)", "ansi-bright-red": "color: var(--light-red)", "ansi-bright-green": "color: var(--light-green)", "ansi-bright-yellow": "color: var(--yellow)", "ansi-bright-blue": "color: var(--light-blue)", "ansi-bright-magenta": "color: var(--light-magenta)", "ansi-bright-cyan": "color: var(--light-cyan)", "ansi-bright-white": "color: var(--white)", }; const bgColorMap: Record = { "ansi-black": "background-color: transparent", "ansi-red": "background-color: var(--red)", "ansi-green": "background-color: var(--green)", "ansi-yellow": "background-color: var(--brown)", "ansi-blue": "background-color: var(--blue)", "ansi-magenta": "background-color: var(--magenta)", "ansi-cyan": "background-color: var(--cyan)", "ansi-white": "background-color: var(--light-gray)", "ansi-bright-black": "background-color: var(--dark-gray)", "ansi-bright-red": "background-color: var(--light-red)", "ansi-bright-green": "background-color: var(--light-green)", "ansi-bright-yellow": "background-color: var(--yellow)", "ansi-bright-blue": "background-color: var(--light-blue)", "ansi-bright-magenta": "background-color: var(--light-magenta)", "ansi-bright-cyan": "background-color: var(--light-cyan)", "ansi-bright-white": "background-color: var(--white)", }; const decorationMap: Record = { bold: "font-weight: 700", dim: "opacity: 0.5", italic: "font-style: italic", hidden: "visibility: hidden", strikethrough: "text-decoration: line-through", underline: "text-decoration: underline", blink: "animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", }; function fixBackspace(txt: string): string { let tmp = txt; do { txt = tmp; tmp = txt.replace(/[^\n]\x08/gm, ""); } while (tmp.length < txt.length); return txt; } function createStyle(bundle: AnserJsonEntry): string | null { const declarations: string[] = []; if (bundle.bg && bgColorMap[bundle.bg]) { declarations.push(bgColorMap[bundle.bg]!); } if (bundle.fg && colorMap[bundle.fg]) { declarations.push(colorMap[bundle.fg]!); } if (bundle.decoration && decorationMap[bundle.decoration]) { declarations.push(decorationMap[bundle.decoration]!); } return declarations.length ? declarations.join("; ") : null; } function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function renderAnsiToHtml(raw: string): string { const input = escapeCarriageReturn(fixBackspace(raw)); const bundles = Anser.ansiToJson(input, { json: true, remove_empty: true, use_classes: true, }); const spans = bundles .map((bundle) => { const style = createStyle(bundle); const content = escapeHtml(bundle.content); if (style) { return `${content}`; } return `${content}`; }) .join(""); return `
${spans}
`; } const ANSI_SUFFIX = "?ansi"; export default function ansiPlugin(): Plugin { return { name: "vite-plugin-ansi", enforce: "pre", async resolveId(source, importer, options) { if (!source.endsWith(ANSI_SUFFIX)) return; const bare = source.slice(0, -ANSI_SUFFIX.length); const resolved = await this.resolve(bare, importer, { ...options, skipSelf: true, }); if (resolved) { return resolved.id + ANSI_SUFFIX; } }, load(id) { if (!id.endsWith(ANSI_SUFFIX)) return; const filePath = id.slice(0, -ANSI_SUFFIX.length); const raw = fs.readFileSync(filePath, "utf-8"); const html = renderAnsiToHtml(raw); return `export default ${JSON.stringify(html)};`; }, }; }