Add syntax highlighting to HTML export using highlight.js

This commit is contained in:
Mario Zechner 2025-12-24 12:13:04 +01:00
parent 914898c58f
commit 9851ee3bdb
3 changed files with 954 additions and 61 deletions

743
out.html Normal file

File diff suppressed because one or more lines are too long

View file

@ -6359,23 +6359,6 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-70b-instruct": {
id: "meta-llama/llama-3.1-70b-instruct",
name: "Meta: Llama 3.1 70B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.39999999999999997,
output: 0.39999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-8b-instruct": {
id: "meta-llama/llama-3.1-8b-instruct",
name: "Meta: Llama 3.1 8B Instruct",
@ -6410,6 +6393,23 @@ export const MODELS = {
contextWindow: 10000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-70b-instruct": {
id: "meta-llama/llama-3.1-70b-instruct",
name: "Meta: Llama 3.1 70B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.39999999999999997,
output: 0.39999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/mistral-nemo": {
id: "mistralai/mistral-nemo",
name: "Mistral: Mistral Nemo",
@ -6546,6 +6546,23 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4o-2024-05-13": {
id: "openai/gpt-4o-2024-05-13",
name: "OpenAI: GPT-4o (2024-05-13)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 5,
output: 15,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4o": {
id: "openai/gpt-4o",
name: "OpenAI: GPT-4o",
@ -6580,23 +6597,6 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 64000,
} satisfies Model<"openai-completions">,
"openai/gpt-4o-2024-05-13": {
id: "openai/gpt-4o-2024-05-13",
name: "OpenAI: GPT-4o (2024-05-13)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 5,
output: 15,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3-70b-instruct": {
id: "meta-llama/llama-3-70b-instruct",
name: "Meta: Llama 3 70B Instruct",
@ -6716,23 +6716,6 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4-turbo-preview": {
id: "openai/gpt-4-turbo-preview",
name: "OpenAI: GPT-4 Turbo Preview",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 10,
output: 30,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-3.5-turbo-0613": {
id: "openai/gpt-3.5-turbo-0613",
name: "OpenAI: GPT-3.5 Turbo (older v0613)",
@ -6750,6 +6733,23 @@ export const MODELS = {
contextWindow: 4095,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4-turbo-preview": {
id: "openai/gpt-4-turbo-preview",
name: "OpenAI: GPT-4 Turbo Preview",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 10,
output: 30,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/mistral-tiny": {
id: "mistralai/mistral-tiny",
name: "Mistral Tiny",

View file

@ -1,6 +1,7 @@
import type { AgentState } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ImageContent, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { existsSync, readFileSync, writeFileSync } from "fs";
import hljs from "highlight.js";
import { marked } from "marked";
import { homedir } from "os";
import * as path from "path";
@ -322,17 +323,123 @@ function formatTimestamp(timestamp: number | string | undefined): string {
return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
/** Render markdown to HTML server-side with TUI-style code block formatting. */
/** Highlight code using highlight.js. Returns HTML with syntax highlighting spans. */
function highlightCode(code: string, lang?: string): string {
if (!lang) {
return escapeHtml(code);
}
try {
// Check if language is supported
if (hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
}
// Try common aliases
const aliases: Record<string, string> = {
ts: "typescript",
js: "javascript",
py: "python",
rb: "ruby",
sh: "bash",
yml: "yaml",
md: "markdown",
};
const aliasedLang = aliases[lang];
if (aliasedLang && hljs.getLanguage(aliasedLang)) {
return hljs.highlight(code, { language: aliasedLang, ignoreIllegals: true }).value;
}
} catch {
// Fall through to escaped output
}
return escapeHtml(code);
}
/** Get language from file path extension. */
function getLanguageFromPath(filePath: string): string | undefined {
const ext = filePath.split(".").pop()?.toLowerCase();
if (!ext) return undefined;
const extToLang: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
py: "python",
rb: "ruby",
rs: "rust",
go: "go",
java: "java",
kt: "kotlin",
swift: "swift",
c: "c",
h: "c",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
cs: "csharp",
php: "php",
sh: "bash",
bash: "bash",
zsh: "bash",
fish: "bash",
ps1: "powershell",
sql: "sql",
html: "html",
htm: "html",
xml: "xml",
css: "css",
scss: "scss",
sass: "scss",
less: "less",
json: "json",
yaml: "yaml",
yml: "yaml",
toml: "toml",
ini: "ini",
md: "markdown",
markdown: "markdown",
dockerfile: "dockerfile",
makefile: "makefile",
cmake: "cmake",
lua: "lua",
r: "r",
scala: "scala",
clj: "clojure",
cljs: "clojure",
ex: "elixir",
exs: "elixir",
erl: "erlang",
hrl: "erlang",
hs: "haskell",
ml: "ocaml",
mli: "ocaml",
fs: "fsharp",
fsx: "fsharp",
vue: "vue",
svelte: "xml",
tf: "hcl",
hcl: "hcl",
proto: "protobuf",
graphql: "graphql",
gql: "graphql",
};
return extToLang[ext];
}
/** Render markdown to HTML server-side with TUI-style code block formatting and syntax highlighting. */
function renderMarkdown(text: string): string {
// Custom renderer for code blocks to match TUI style
const renderer = new marked.Renderer();
renderer.code = ({ text: code, lang }: { text: string; lang?: string }) => {
const language = lang || "";
const escaped = escapeHtml(code);
const highlighted = highlightCode(code, lang);
return (
'<div class="code-block-wrapper">' +
`<div class="code-block-header">\`\`\`${language}</div>` +
`<pre><code>${escaped}</code></pre>` +
`<pre><code class="hljs">${highlighted}</code></pre>` +
'<div class="code-block-footer">```</div>' +
"</div>"
);
@ -348,10 +455,32 @@ function renderMarkdown(text: string): string {
return marked.parse(text, { renderer }) as string;
}
function formatExpandableOutput(lines: string[], maxLines: number): string {
function formatExpandableOutput(lines: string[], maxLines: number, lang?: string): string {
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
// If language is provided, highlight the entire code block
if (lang) {
const code = lines.join("\n");
const highlighted = highlightCode(code, lang);
if (remaining > 0) {
// For expandable, we need preview and full versions
const previewCode = displayLines.join("\n");
const previewHighlighted = highlightCode(previewCode, lang);
let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
out += `<div class="output-preview"><pre><code class="hljs">${previewHighlighted}</code></pre>`;
out += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
out += "</div>";
out += `<div class="output-full"><pre><code class="hljs">${highlighted}</code></pre></div></div>`;
return out;
}
return `<div class="tool-output"><pre><code class="hljs">${highlighted}</code></pre></div>`;
}
// No language - plain text output
if (remaining > 0) {
let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
out += '<div class="output-preview">';
@ -601,12 +730,14 @@ function formatToolExecution(
}
case "read": {
const filePath = shortenPath((args?.file_path as string) || (args?.path as string) || "");
const filePath = (args?.file_path as string) || (args?.path as string) || "";
const shortenedPath = shortenPath(filePath);
const offset = args?.offset as number | undefined;
const limit = args?.limit as number | undefined;
const lang = getLanguageFromPath(filePath);
// Build path display with offset/limit suffix
let pathHtml = escapeHtml(filePath || "...");
let pathHtml = escapeHtml(shortenedPath || "...");
if (offset !== undefined || limit !== undefined) {
const startLine = offset ?? 1;
const endLine = limit !== undefined ? startLine + limit - 1 : "";
@ -617,25 +748,27 @@ function formatToolExecution(
if (result) {
const output = getTextOutput();
if (output) {
html += formatExpandableOutput(output.split("\n"), 10);
html += formatExpandableOutput(output.split("\n"), 10, lang);
}
}
break;
}
case "write": {
const path = shortenPath((args?.file_path as string) || (args?.path as string) || "");
const filePath = (args?.file_path as string) || (args?.path as string) || "";
const shortenedPath = shortenPath(filePath);
const fileContent = (args?.content as string) || "";
const lines = fileContent ? fileContent.split("\n") : [];
const lang = getLanguageFromPath(filePath);
html = `<div class="tool-header"><span class="tool-name">write</span> <span class="tool-path">${escapeHtml(path || "...")}</span>`;
html = `<div class="tool-header"><span class="tool-name">write</span> <span class="tool-path">${escapeHtml(shortenedPath || "...")}</span>`;
if (lines.length > 10) {
html += ` <span class="line-count">(${lines.length} lines)</span>`;
}
html += "</div>";
if (fileContent) {
html += formatExpandableOutput(lines, 10);
html += formatExpandableOutput(lines, 10, lang);
}
if (result) {
const output = getTextOutput().trim();
@ -1108,6 +1241,23 @@ function generateHtml(data: ParsedSessionData, filename: string, colors: ThemeCo
border-radius: 4px;
}
/* Syntax highlighting (highlight.js) */
.hljs { background: transparent; }
.hljs-comment, .hljs-quote { color: ${colors.syntaxComment}; }
.hljs-keyword, .hljs-selector-tag, .hljs-addition { color: ${colors.syntaxKeyword}; }
.hljs-number, .hljs-literal, .hljs-symbol, .hljs-bullet { color: ${colors.syntaxNumber}; }
.hljs-string, .hljs-doctag, .hljs-regexp { color: ${colors.syntaxString}; }
.hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: ${colors.syntaxFunction}; }
.hljs-type, .hljs-class, .hljs-built_in { color: ${colors.syntaxType}; }
.hljs-attr, .hljs-variable, .hljs-template-variable, .hljs-params { color: ${colors.syntaxVariable}; }
.hljs-attribute { color: ${colors.syntaxVariable}; }
.hljs-meta { color: ${colors.syntaxKeyword}; }
.hljs-formula { background: rgba(${isLight ? "0, 0, 0" : "255, 255, 255"}, 0.05); }
.hljs-deletion { color: ${colors.toolDiffRemoved}; }
.hljs-emphasis { font-style: italic; }
.hljs-strong { font-weight: bold; }
.hljs-link { color: ${colors.mdLink}; text-decoration: underline; }
@media print { body { background: white; color: black; } .tool-execution { border: 1px solid #ddd; } }
</style>
</head>