chore(deps): replace cli-highlight (#4468)
This commit is contained in:
@@ -2,12 +2,12 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@earendil-works/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { highlight, supportsLanguage } from "cli-highlight";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { Compile } from "typebox/compile";
|
||||
import { getCustomThemesDir, getThemesDir } from "../../../config.js";
|
||||
import type { SourceInfo } from "../../../core/source-info.js";
|
||||
import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.js";
|
||||
import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types & Schema
|
||||
|
||||
19
packages/coding-agent/src/utils/highlight-js-lib-index.d.ts
vendored
Normal file
19
packages/coding-agent/src/utils/highlight-js-lib-index.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
declare module "highlight.js/lib/index.js" {
|
||||
interface HighlightResult {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HighlightOptions {
|
||||
language: string;
|
||||
ignoreIllegals?: boolean;
|
||||
}
|
||||
|
||||
interface HighlightJs {
|
||||
highlight(code: string, options: HighlightOptions): HighlightResult;
|
||||
highlightAuto(code: string, languageSubset?: string[]): HighlightResult;
|
||||
getLanguage(name: string): unknown;
|
||||
}
|
||||
|
||||
const hljs: HighlightJs;
|
||||
export default hljs;
|
||||
}
|
||||
51
packages/coding-agent/src/utils/html.ts
Normal file
51
packages/coding-agent/src/utils/html.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface DecodedHtmlEntity {
|
||||
text: string;
|
||||
length: number;
|
||||
}
|
||||
|
||||
function decodeCodePoint(codePoint: number): string | undefined {
|
||||
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
|
||||
return undefined;
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
|
||||
export function decodeHtmlEntity(entity: string): string | undefined {
|
||||
switch (entity) {
|
||||
case "amp":
|
||||
return "&";
|
||||
case "lt":
|
||||
return "<";
|
||||
case "gt":
|
||||
return ">";
|
||||
case "quot":
|
||||
return '"';
|
||||
case "apos":
|
||||
return "'";
|
||||
}
|
||||
|
||||
if (entity.startsWith("#x") || entity.startsWith("#X")) {
|
||||
return decodeCodePoint(Number.parseInt(entity.slice(2), 16));
|
||||
}
|
||||
|
||||
if (entity.startsWith("#")) {
|
||||
return decodeCodePoint(Number.parseInt(entity.slice(1), 10));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function decodeHtmlEntityAt(html: string, index: number): DecodedHtmlEntity | undefined {
|
||||
const semicolonIndex = html.indexOf(";", index + 1);
|
||||
if (semicolonIndex === -1 || semicolonIndex - index > 16) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entity = html.slice(index + 1, semicolonIndex);
|
||||
const decoded = decodeHtmlEntity(entity);
|
||||
if (decoded === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { text: decoded, length: semicolonIndex - index + 1 };
|
||||
}
|
||||
146
packages/coding-agent/src/utils/syntax-highlight.ts
Normal file
146
packages/coding-agent/src/utils/syntax-highlight.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import hljs from "highlight.js/lib/index.js";
|
||||
import { decodeHtmlEntityAt } from "./html.js";
|
||||
|
||||
export type HighlightFormatter = (text: string) => string;
|
||||
export type HighlightTheme = Partial<Record<string, HighlightFormatter>>;
|
||||
|
||||
export 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";
|
||||
}
|
||||
|
||||
export function renderHighlightedHtml(html: string, theme: HighlightTheme = {}): string {
|
||||
let output = "";
|
||||
let textBuffer = "";
|
||||
const scopes: Array<string | undefined> = [];
|
||||
|
||||
const flushText = () => {
|
||||
if (!textBuffer) {
|
||||
return;
|
||||
}
|
||||
const formatter = getActiveFormatter(scopes, theme);
|
||||
output += formatter ? formatter(textBuffer) : textBuffer;
|
||||
textBuffer = "";
|
||||
};
|
||||
|
||||
let index = 0;
|
||||
while (index < html.length) {
|
||||
if (isSpanOpenTagStart(html, index)) {
|
||||
const tagEndIndex = html.indexOf(">", index + 5);
|
||||
if (tagEndIndex !== -1) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (html[index] === "&") {
|
||||
const decoded = decodeHtmlEntityAt(html, index);
|
||||
if (decoded) {
|
||||
textBuffer += decoded.text;
|
||||
index += decoded.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
textBuffer += html[index];
|
||||
index++;
|
||||
}
|
||||
|
||||
flushText();
|
||||
return output;
|
||||
}
|
||||
|
||||
export function highlight(code: string, options: HighlightOptions = {}): string {
|
||||
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);
|
||||
}
|
||||
|
||||
export function supportsLanguage(name: string): boolean {
|
||||
return hljs.getLanguage(name) !== undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user