From e0b5d27af2fb4dc36301212b9eed35caed6826f3 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 13 May 2026 12:19:45 +0200 Subject: [PATCH] chore(deps): replace cli-highlight (#4468) --- package-lock.json | 158 ++---------------- packages/coding-agent/package.json | 2 +- .../src/modes/interactive/theme/theme.ts | 2 +- .../src/utils/highlight-js-lib-index.d.ts | 19 +++ packages/coding-agent/src/utils/html.ts | 51 ++++++ .../src/utils/syntax-highlight.ts | 146 ++++++++++++++++ .../test/syntax-highlight.test.ts | 48 ++++++ packages/coding-agent/tsconfig.build.json | 4 +- 8 files changed, 281 insertions(+), 149 deletions(-) create mode 100644 packages/coding-agent/src/utils/highlight-js-lib-index.d.ts create mode 100644 packages/coding-agent/src/utils/html.ts create mode 100644 packages/coding-agent/src/utils/syntax-highlight.ts create mode 100644 packages/coding-agent/test/syntax-highlight.test.ts diff --git a/package-lock.json b/package-lock.json index 06d8c051..1fba0423 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3967,10 +3967,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "license": "MIT" - }, "node_modules/assertion-error": { "version": "2.0.1", "dev": true, @@ -4159,75 +4155,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/cli-highlight": { - "version": "2.1.11", - "license": "ISC", - "dependencies": { - "chalk": "^4.0.0", - "highlight.js": "^10.7.1", - "mz": "^2.4.0", - "parse5": "^5.1.1", - "parse5-htmlparser2-tree-adapter": "^6.0.0", - "yargs": "^16.0.0" - }, - "bin": { - "highlight": "bin/highlight" - }, - "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" - } - }, - "node_modules/cli-highlight/node_modules/chalk": { - "version": "4.1.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cli-highlight/node_modules/supports-color": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui": { - "version": "7.0.4", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/clsx": { "version": "2.1.1", "license": "MIT", @@ -4485,6 +4412,7 @@ }, "node_modules/emoji-regex": { "version": "8.0.0", + "dev": true, "license": "MIT" }, "node_modules/end-of-stream": { @@ -4562,6 +4490,7 @@ }, "node_modules/escalade": { "version": "3.2.0", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4818,6 +4747,7 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5073,6 +5003,7 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5640,15 +5571,6 @@ "version": "2.1.3", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.12", "dev": true, @@ -5743,13 +5665,6 @@ "node": ">=4" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ollama": { "version": "0.6.3", "license": "MIT", @@ -5839,21 +5754,6 @@ "version": "1.0.11", "license": "(MIT AND Zlib)" }, - "node_modules/parse5": { - "version": "5.1.1", - "license": "MIT" - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "6.0.1", - "license": "MIT", - "dependencies": { - "parse5": "^6.0.1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { - "version": "6.0.1", - "license": "MIT" - }, "node_modules/partial-json": { "version": "0.1.7", "license": "MIT" @@ -6146,6 +6046,7 @@ }, "node_modules/require-directory": { "version": "2.1.1", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6488,6 +6389,7 @@ }, "node_modules/string-width": { "version": "4.2.3", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6500,6 +6402,7 @@ }, "node_modules/string-width/node_modules/ansi-regex": { "version": "5.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6507,6 +6410,7 @@ }, "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6644,23 +6548,6 @@ "node": ">=6" } }, - "node_modules/thenify": { - "version": "3.3.1", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/tinybench": { "version": "2.9.0", "dev": true, @@ -7073,6 +6960,7 @@ }, "node_modules/wrap-ansi": { "version": "7.0.0", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7088,6 +6976,7 @@ }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "5.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7095,6 +6984,7 @@ }, "node_modules/wrap-ansi/node_modules/strip-ansi": { "version": "6.0.1", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7141,6 +7031,7 @@ }, "node_modules/y18n": { "version": "5.0.8", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -7159,29 +7050,6 @@ "url": "https://github.com/sponsors/eemeli" } }, - "node_modules/yargs": { - "version": "16.2.0", - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/zod": { "version": "4.4.3", "license": "MIT", @@ -7278,9 +7146,9 @@ "@earendil-works/pi-tui": "^0.74.0", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", - "cli-highlight": "^2.1.11", "diff": "^8.0.2", "glob": "^13.0.1", + "highlight.js": "^10.7.3", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "jiti": "^2.7.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 5f4ab9e5..394eb948 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -43,9 +43,9 @@ "@earendil-works/pi-tui": "^0.74.0", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", - "cli-highlight": "^2.1.11", "diff": "^8.0.2", "glob": "^13.0.1", + "highlight.js": "^10.7.3", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "jiti": "^2.7.0", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 30e4a233..1ff111b2 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -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 diff --git a/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts b/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts new file mode 100644 index 00000000..75e31da2 --- /dev/null +++ b/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts @@ -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; +} diff --git a/packages/coding-agent/src/utils/html.ts b/packages/coding-agent/src/utils/html.ts new file mode 100644 index 00000000..a13ad46f --- /dev/null +++ b/packages/coding-agent/src/utils/html.ts @@ -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 }; +} diff --git a/packages/coding-agent/src/utils/syntax-highlight.ts b/packages/coding-agent/src/utils/syntax-highlight.ts new file mode 100644 index 00000000..fc8a0281 --- /dev/null +++ b/packages/coding-agent/src/utils/syntax-highlight.ts @@ -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>; + +export interface HighlightOptions { + language?: string; + ignoreIllegals?: boolean; + languageSubset?: string[]; + theme?: HighlightTheme; +} + +const SPAN_CLOSE = ""; +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, 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("" || nextChar === " " || nextChar === "\t" || nextChar === "\n" || nextChar === "\r"; +} + +export function renderHighlightedHtml(html: string, theme: HighlightTheme = {}): string { + let output = ""; + let textBuffer = ""; + const scopes: Array = []; + + 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; +} diff --git a/packages/coding-agent/test/syntax-highlight.test.ts b/packages/coding-agent/test/syntax-highlight.test.ts new file mode 100644 index 00000000..c5cc7ab1 --- /dev/null +++ b/packages/coding-agent/test/syntax-highlight.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { highlight, renderHighlightedHtml, supportsLanguage } from "../src/utils/syntax-highlight.js"; + +describe("syntax highlight renderer", () => { + it("renders highlighted spans with the provided theme", () => { + const rendered = renderHighlightedHtml('const value', { + keyword: (text) => `[keyword:${text}]`, + }); + expect(rendered).toBe("[keyword:const] value"); + }); + + it("decodes HTML entities emitted by highlight.js", () => { + const rendered = renderHighlightedHtml("<tag attr="value">&#x41;A</tag>"); + expect(rendered).toBe('AA'); + }); + + it("inherits parent formatting for unmapped nested scopes", () => { + const interpolation = "$" + "{x}"; + const rendered = renderHighlightedHtml( + `a${interpolation}b`, + { + string: (text) => `[string:${text}]`, + }, + ); + expect(rendered).toBe(`[string:a][string:${interpolation}][string:b]`); + }); + + it("keeps parent formatting across unscoped nested spans", () => { + const rendered = renderHighlightedHtml('abc', { + string: (text) => `[string:${text}]`, + }); + expect(rendered).toBe("[string:a][string:b][string:c]"); + }); + + it("highlights code through highlight.js", () => { + expect(supportsLanguage("typescript")).toBe(true); + const rendered = highlight("const value = 1", { + language: "typescript", + ignoreIllegals: true, + theme: { + keyword: (text) => `[keyword:${text}]`, + number: (text) => `[number:${text}]`, + }, + }); + expect(rendered).toContain("[keyword:const]"); + expect(rendered).toContain("[number:1]"); + }); +}); diff --git a/packages/coding-agent/tsconfig.build.json b/packages/coding-agent/tsconfig.build.json index 695dd9ad..3437fd70 100644 --- a/packages/coding-agent/tsconfig.build.json +++ b/packages/coding-agent/tsconfig.build.json @@ -4,6 +4,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["node_modules", "dist"] }