Add syntax highlighting to markdown code blocks

This commit is contained in:
Sergii Kozak 2025-12-17 20:15:44 -08:00
parent 4ec2708bb3
commit f8e6d62db7
4 changed files with 313 additions and 11 deletions

View file

@ -44,6 +44,7 @@
"@mariozechner/pi-ai": "^0.23.3",
"@mariozechner/pi-tui": "^0.23.3",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
"diff": "^8.0.2",
"file-type": "^21.1.1",
"glob": "^11.0.3",

View file

@ -1,4 +1,5 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@mariozechner/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
@ -630,6 +631,50 @@ export function stopThemeWatcher(): void {
// TUI Helpers
// ============================================================================
type CliHighlightTheme = Record<string, (s: string) => string>;
let cachedHighlightThemeFor: Theme | undefined;
let cachedCliHighlightTheme: CliHighlightTheme | undefined;
function buildCliHighlightTheme(t: Theme): CliHighlightTheme {
return {
keyword: (s: string) => t.fg("syntaxKeyword", s),
built_in: (s: string) => t.fg("syntaxType", s),
literal: (s: string) => t.fg("syntaxNumber", s),
number: (s: string) => t.fg("syntaxNumber", s),
string: (s: string) => t.fg("syntaxString", s),
comment: (s: string) => t.fg("syntaxComment", s),
function: (s: string) => t.fg("syntaxFunction", s),
title: (s: string) => t.fg("syntaxFunction", s),
class: (s: string) => t.fg("syntaxType", s),
type: (s: string) => t.fg("syntaxType", s),
attr: (s: string) => t.fg("syntaxVariable", s),
variable: (s: string) => t.fg("syntaxVariable", s),
params: (s: string) => t.fg("syntaxVariable", s),
operator: (s: string) => t.fg("syntaxOperator", s),
punctuation: (s: string) => t.fg("syntaxPunctuation", s),
};
}
function getCliHighlightTheme(t: Theme): CliHighlightTheme {
if (cachedHighlightThemeFor !== t || !cachedCliHighlightTheme) {
cachedHighlightThemeFor = t;
cachedCliHighlightTheme = buildCliHighlightTheme(t);
}
return cachedCliHighlightTheme;
}
function requireCliHighlight(): { highlight: (code: string, opts?: any) => string } {
try {
const require = createRequire(import.meta.url);
return require("cli-highlight");
} catch {
return {
highlight: (code: string) => code,
};
}
}
export function getMarkdownTheme(): MarkdownTheme {
return {
heading: (text: string) => theme.fg("mdHeading", text),
@ -646,6 +691,19 @@ export function getMarkdownTheme(): MarkdownTheme {
italic: (text: string) => theme.italic(text),
underline: (text: string) => theme.underline(text),
strikethrough: (text: string) => chalk.strikethrough(text),
highlightCode: (code: string, lang?: string): string[] => {
const { highlight } = requireCliHighlight();
const opts: any = {
language: lang,
ignoreIllegals: true,
theme: getCliHighlightTheme(theme),
};
try {
return highlight(code, opts).split("\n");
} catch {
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
}
},
};
}

View file

@ -40,6 +40,7 @@ export interface MarkdownTheme {
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
highlightCode?: (code: string, lang?: string) => string[];
}
export class Markdown implements Component {
@ -263,10 +264,17 @@ export class Markdown implements Component {
case "code": {
lines.push(this.theme.codeBlockBorder("```" + (token.lang || "")));
// Split code by newlines and style each line
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(" " + this.theme.codeBlock(codeLine));
if (this.theme.highlightCode) {
const highlightedLines = this.theme.highlightCode(token.text, token.lang);
for (const hlLine of highlightedLines) {
lines.push(" " + hlLine);
}
} else {
// Split code by newlines and style each line
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(" " + this.theme.codeBlock(codeLine));
}
}
lines.push(this.theme.codeBlockBorder("```"));
if (nextTokenType !== "space") {
@ -471,9 +479,16 @@ export class Markdown implements Component {
} else if (token.type === "code") {
// Code block in list item
lines.push(this.theme.codeBlockBorder("```" + (token.lang || "")));
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(" " + this.theme.codeBlock(codeLine));
if (this.theme.highlightCode) {
const highlightedLines = this.theme.highlightCode(token.text, token.lang);
for (const hlLine of highlightedLines) {
lines.push(" " + hlLine);
}
} else {
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(" " + this.theme.codeBlock(codeLine));
}
}
lines.push(this.theme.codeBlockBorder("```"));
} else {