openclaw / src /agents /utils /syntax-highlight.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw History Blame Contribute Delete
6.28 kB
/**
* Syntax highlighting renderer for terminal-friendly formatted output.
*
* Highlight.js emits HTML spans; this module walks that small HTML subset and
* maps active scopes to caller-provided text formatters.
*/
import { createRequire } from "node:module";
import { decodeHtmlEntities } from "../../shared/html-entities.js";
import { getWorkerDeployHighlightJs } from "../../worker/worker-deploy-runtime-registry.js";
type HighlightJs = {
getLanguage(name: string): unknown;
highlight(
code: string,
options: { language: string; ignoreIllegals?: boolean },
): { value: string };
highlightAuto(code: string, languageSubset?: string[]): { value: string };
};
let highlightJsRuntime: HighlightJs | undefined;
declare const WORKER_DEPLOY_BUILD: boolean;
function isHighlightJs(value: unknown): value is HighlightJs {
return (
typeof value === "object" &&
value !== null &&
"getLanguage" in value &&
typeof value.getLanguage === "function" &&
"highlight" in value &&
typeof value.highlight === "function" &&
"highlightAuto" in value &&
typeof value.highlightAuto === "function"
);
}
function setHighlightJsRuntime(runtime: unknown): HighlightJs {
if (!isHighlightJs(runtime)) {
throw new TypeError("highlight.js did not expose the expected Node API");
}
highlightJsRuntime = runtime;
return runtime;
}
function loadHighlightJsRuntime(): HighlightJs {
if (highlightJsRuntime) {
return highlightJsRuntime;
}
const injected = getWorkerDeployHighlightJs();
if (injected !== undefined) {
return setHighlightJsRuntime(injected);
}
if (typeof WORKER_DEPLOY_BUILD === "boolean" && WORKER_DEPLOY_BUILD) {
throw new Error("worker highlight.js runtime was not registered before use");
}
// highlight.js ships `/// <reference lib="dom" />` in its d.ts, which would
// silently re-inject DOM globals into the DOM-free core program. Load it
// untyped and validate the narrow API we use instead of importing its types.
return setHighlightJsRuntime(createRequire(import.meta.url)("highlight.js"));
}
/** Formatter applied to highlighted text segments. */
type HighlightFormatter = (text: string) => string;
/** Mapping from highlight.js scope names to text formatters. */
type HighlightTheme = Partial<Record<string, HighlightFormatter>>;
/** Options used when highlighting code and rendering themed text. */
interface HighlightOptions {
language?: string;
ignoreIllegals?: boolean;
languageSubset?: string[];
theme?: HighlightTheme;
}
const SPAN_CLOSE = "</span>";
const HIGHLIGHT_CLASS_PREFIX = "hljs-";
function getScopeFromSpanTag(tag: string): string | undefined {
const match = /\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(tag);
const classValue = match?.[1] ?? match?.[2];
if (!classValue) {
return undefined;
}
for (const className of classValue.split(/\s+/)) {
if (className.startsWith(HIGHLIGHT_CLASS_PREFIX)) {
return className.slice(HIGHLIGHT_CLASS_PREFIX.length);
}
}
return undefined;
}
function getScopeFormatter(scope: string, theme: HighlightTheme): HighlightFormatter | undefined {
const exact = theme[scope];
if (exact) {
return exact;
}
const dotIndex = scope.indexOf(".");
if (dotIndex !== -1) {
const prefixFormatter = theme[scope.slice(0, dotIndex)];
if (prefixFormatter) {
return prefixFormatter;
}
}
const dashIndex = scope.indexOf("-");
if (dashIndex !== -1) {
const prefixFormatter = theme[scope.slice(0, dashIndex)];
if (prefixFormatter) {
return prefixFormatter;
}
}
return undefined;
}
function getActiveFormatter(
scopes: Array<string | undefined>,
theme: HighlightTheme,
): HighlightFormatter | undefined {
for (let i = scopes.length - 1; i >= 0; i--) {
const scope = scopes[i];
if (!scope) {
continue;
}
const formatter = getScopeFormatter(scope, theme);
if (formatter) {
return formatter;
}
}
return theme.default;
}
function isSpanOpenTagStart(html: string, index: number): boolean {
if (!html.startsWith("<span", index)) {
return false;
}
const nextChar = html[index + "<span".length];
return (
nextChar === ">" ||
nextChar === " " ||
nextChar === "\t" ||
nextChar === "\n" ||
nextChar === "\r"
);
}
/** Renders highlight.js span HTML into themed plain text. */
function renderHighlightedHtml(html: string, theme: HighlightTheme = {}): string {
let output = "";
let textBuffer = "";
const scopes: Array<string | undefined> = [];
const flushText = () => {
if (!textBuffer) {
return;
}
const decodedText = decodeHtmlEntities(textBuffer);
const formatter = getActiveFormatter(scopes, theme);
output += formatter ? formatter(decodedText) : decodedText;
textBuffer = "";
};
let index = 0;
while (index < html.length) {
if (isSpanOpenTagStart(html, index)) {
const tagEndIndex = html.indexOf(">", index + 5);
if (tagEndIndex !== -1) {
// Scope stack mirrors nested highlight.js spans so inner scopes override outer ones.
flushText();
const tag = html.slice(index, tagEndIndex + 1);
const scope = getScopeFromSpanTag(tag);
scopes.push(scope);
index = tagEndIndex + 1;
continue;
}
}
if (html.startsWith(SPAN_CLOSE, index)) {
flushText();
if (scopes.length > 0) {
scopes.pop();
}
index += SPAN_CLOSE.length;
continue;
}
textBuffer += html[index];
index++;
}
flushText();
return output;
}
/** Highlights code using an explicit language or highlight.js auto-detection. */
export function highlight(code: string, options: HighlightOptions = {}): string {
const hljs = loadHighlightJsRuntime();
const html = options.language
? hljs.highlight(code, {
language: options.language,
ignoreIllegals: options.ignoreIllegals,
}).value
: hljs.highlightAuto(code, options.languageSubset).value;
return renderHighlightedHtml(html, options.theme);
}
/** Returns whether highlight.js has a registered language by this name. */
export function supportsLanguage(name: string): boolean {
return loadHighlightJsRuntime().getLanguage(name) !== undefined;
}