chore(deps): replace cli-highlight with a highlight.js ANSI renderer (D-5) (#2396)

* chore(deps): replace cli-highlight with a highlight.js ANSI renderer

* fix(highlight): key fallback highlight cache by Chalk level

Also correct the CONTRIBUTING highlighting note and move the qrcode
retention test and note out of this change.
This commit is contained in:
JATMN
2026-10-24 11:31:34 -07:00
committed by GitHub
parent de1bafcf6b
commit ff13baf587
11 changed files with 269 additions and 76 deletions
+8 -2
View File
@@ -230,9 +230,15 @@ color detection tests must import in a fresh process.
### Dependency pins and overrides
Terminal syntax highlighting uses the exact `highlight.js` pin directly, with
the shared Chalk instance for ANSI colors. Keep the lazy loader and terminal
palette in `src/utils/cliHighlight.ts` for terminal code and Markdown highlighting
only. Structured diff rendering in `src/native-ts/color-diff/index.ts` loads
`highlight.js` separately. Run the highlighting and Markdown regression tests
when changing this dependency or its ANSI renderer.
Keep all direct dependencies and devDependencies pinned to exact versions in the
root and web manifests. The existing `sharp` range is the temporary exception
while its separate upgrade is in progress. Regenerate the relevant Bun lockfile
root and web manifests. Regenerate the relevant Bun lockfile
when changing a pin.
The root `overrides` block also controls transitive resolutions:
+1 -49
View File
@@ -38,7 +38,6 @@
"chalk": "6.0.1",
"chokidar": "4.0.3",
"cli-boxes": "4.0.1",
"cli-highlight": "2.1.11",
"commander": "12.1.0",
"cross-spawn": "7.0.6",
"diff": "9.0.0",
@@ -54,6 +53,7 @@
"google-auth-library": "11.3.1",
"graphology": "0.26.0",
"graphology-metrics": "2.4.2",
"highlight.js": "10.7.3",
"https-proxy-agent": "9.1.0",
"ignore": "7.0.13",
"indent-string": "5.0.0",
@@ -613,8 +613,6 @@
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
@@ -663,8 +661,6 @@
"cli-boxes": ["cli-boxes@4.0.1", "", {}, "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw=="],
"cli-highlight": ["cli-highlight@2.1.11", "", { "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" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -1019,8 +1015,6 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"needle": ["needle@3.5.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" }, "bin": { "needle": "bin/needle" } }, "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w=="],
@@ -1067,10 +1061,6 @@
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="],
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
@@ -1199,10 +1189,6 @@
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -1503,10 +1489,6 @@
"bun-types/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cli-highlight/yargs": ["yargs@16.2.0", "", { "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" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1537,8 +1519,6 @@
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
@@ -1581,16 +1561,6 @@
"axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
"cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"cli-highlight/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="],
"cli-highlight/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cli-highlight/yargs/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
@@ -1633,18 +1603,6 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"cli-highlight/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cli-highlight/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"cli-highlight/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cli-highlight/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"cli-highlight/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"eslint/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"qrcode/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1661,12 +1619,6 @@
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"cli-highlight/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"cli-highlight/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"qrcode/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+1 -1
View File
@@ -118,7 +118,6 @@
"chalk": "6.0.1",
"chokidar": "4.0.3",
"cli-boxes": "4.0.1",
"cli-highlight": "2.1.11",
"commander": "12.1.0",
"cross-spawn": "7.0.6",
"diff": "9.0.0",
@@ -134,6 +133,7 @@
"google-auth-library": "11.3.1",
"graphology": "0.26.0",
"graphology-metrics": "2.4.2",
"highlight.js": "10.7.3",
"https-proxy-agent": "9.1.0",
"ignore": "7.0.13",
"indent-string": "5.0.0",
+1 -1
View File
@@ -141,7 +141,7 @@ export const INTENTIONALLY_BUNDLED: string[] = [
'bidi-js',
'chalk',
'cli-boxes',
'cli-highlight',
'highlight.js',
'commander',
'emoji-regex',
'env-paths',
@@ -0,0 +1,61 @@
import { expect, spyOn, test } from 'bun:test'
import chalk from 'chalk'
import { PassThrough } from 'node:stream'
import React from 'react'
import { Ansi, createRoot } from '../../ink.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../../test/sharedMutationLock.js'
import { getCliHighlightPromise } from '../../utils/cliHighlight.js'
import { HighlightedCodeFallback } from './Fallback.js'
test('remounting with colors disabled does not reuse colored highlights', async () => {
await acquireSharedMutationLock('HighlightedCode/Fallback.test.tsx')
const previousLevel = chalk.level
const stdout = new PassThrough()
const stdin = new PassThrough()
const code = 'const cachedColorLevel = 42;'
const renderAnsi = Ansi.type
// Inspect the highlighted string before Ink can strip ANSI colors itself.
const ansi = spyOn(Ansi, 'type').mockImplementation(props => renderAnsi(props))
const root = await createRoot({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
})
let timer: ReturnType<typeof setTimeout> | undefined
try {
expect(await getCliHighlightPromise()).not.toBeNull()
chalk.level = 1
const highlighted = Promise.withResolvers<void>()
ansi.mockImplementation(props => {
if (props.children.includes('\x1b')) highlighted.resolve()
return renderAnsi(props)
})
root.render(<HighlightedCodeFallback key="colored" code={code} filePath="cache.ts" />)
await Promise.race([
highlighted.promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('Highlighting did not complete')), 2500)
}),
])
expect(ansi.mock.calls.at(-1)![0].children).toContain('\x1b')
chalk.level = 0
ansi.mockClear()
root.render(<HighlightedCodeFallback key="plain" code={code} filePath="cache.ts" />)
const output = ansi.mock.calls.at(-1)![0].children
expect(output).toBe(code)
expect(output).not.toContain('\x1b')
} finally {
clearTimeout(timer)
root.unmount()
ansi.mockRestore()
stdout.end()
stdin.end()
chalk.level = previousLevel
releaseSharedMutationLock()
}
})
+3 -2
View File
@@ -1,4 +1,5 @@
import { c as _c } from "react-compiler-runtime";
import chalk from 'chalk';
import { extname } from 'path';
import React, { Suspense, use, useMemo } from 'react';
import { Ansi, Text } from '../../ink.js';
@@ -15,11 +16,11 @@ type Props = {
// Module-level highlight cache — hl.highlight() is the hot cost on virtual-
// scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash
// of code+language to avoid retaining full source strings (#24180 RSS fix).
// of code+language and Chalk level to avoid retaining full source strings (#24180 RSS fix).
const HL_CACHE_MAX = 500;
const hlCache = new Map<string, string>();
function cachedHighlight(hl: NonNullable<Awaited<ReturnType<typeof getCliHighlightPromise>>>, code: string, language: string): string {
const key = hashPair(language, code);
const key = `${chalk.level}:${hashPair(language, code)}`;
const hit = hlCache.get(key);
if (hit !== undefined) {
hlCache.delete(key);
+1 -1
View File
@@ -25,7 +25,7 @@ type SpanProps = {
* Component that parses ANSI escape codes and renders them using Text components.
*
* Use this as an escape hatch when you have pre-formatted ANSI strings from
* external tools (like cli-highlight) that need to be rendered in Ink.
* syntax highlighting that need to be rendered in Ink.
*
* Memoized to prevent re-renders when parent changes but children string is the same.
*/
+2 -2
View File
@@ -2,8 +2,8 @@
* Pure TypeScript port of vendor/color-diff-src.
*
* The Rust version uses syntect+bat for syntax highlighting and the similar
* crate for word diffing. This port uses highlight.js (already a dep via
* cli-highlight) and the diff npm package's diffArrays.
* crate for word diffing. This port uses highlight.js and the diff npm
* package's diffArrays.
*
* API matches vendor/color-diff-src/index.d.ts exactly so callers don't change.
*
+80
View File
@@ -0,0 +1,80 @@
[
{
"name": "javascript",
"language": "javascript",
"code": "const value = true;\nconsole.log(\"<&> \\\"quotes\\\"\", 42); // comment\nfunction sum(a) { return /ab+/g.test(a); }",
"output": "\u001b[34mconst\u001b[39m value = \u001b[34mtrue\u001b[39m;\n\u001b[36mconsole\u001b[39m.log(\u001b[31m\"<&> \\\"quotes\\\"\"\u001b[39m, \u001b[32m42\u001b[39m); \u001b[32m// comment\u001b[39m\n\u001b[33m\u001b[34mfunction\u001b[39m\u001b[33m sum(a) \u001b[39m{ \u001b[34mreturn\u001b[39m \u001b[31m/ab+/g\u001b[39m.test(a); }"
},
{
"name": "typescript",
"language": "ts",
"code": "interface Result { name: string; count: number }\nclass Example { value: Result | null = null; }",
"output": "\u001b[34minterface\u001b[39m Result { name: \u001b[36mstring\u001b[39m; count: \u001b[36mnumber\u001b[39m }\n\u001b[34m\u001b[34mclass\u001b[39m\u001b[34m Example \u001b[39m{ value: Result | \u001b[34mnull\u001b[39m = \u001b[34mnull\u001b[39m; }"
},
{
"name": "json",
"language": "json",
"code": "{\"key\": \"value\", \"count\": 42, \"ok\": true}",
"output": "{\u001b[36m\"key\"\u001b[39m: \u001b[31m\"value\"\u001b[39m, \u001b[36m\"count\"\u001b[39m: \u001b[32m42\u001b[39m, \u001b[36m\"ok\"\u001b[39m: \u001b[34mtrue\u001b[39m}"
},
{
"name": "markup",
"language": "xml",
"code": "<section title=\"<&>\">hello &amp; world</section>",
"output": "\u001b[90m<\u001b[34msection\u001b[39m\u001b[90m \u001b[36mtitle\u001b[39m\u001b[90m=\u001b[31m\"<&>\"\u001b[39m\u001b[90m>\u001b[39mhello &amp; world\u001b[90m</\u001b[34msection\u001b[39m\u001b[90m>\u001b[39m"
},
{
"name": "sublanguages",
"language": "xml",
"code": "<script>const n = 2;</script>\n<style>.item { color: red; }</style>",
"output": "\u001b[90m<\u001b[34mscript\u001b[39m\u001b[90m>\u001b[39m\u001b[34mconst\u001b[39m n = \u001b[32m2\u001b[39m;\u001b[90m</\u001b[34mscript\u001b[39m\u001b[90m>\u001b[39m\n\u001b[90m<\u001b[34mstyle\u001b[39m\u001b[90m>\u001b[39m.item { color: red; }\u001b[90m</\u001b[34mstyle\u001b[39m\u001b[90m>\u001b[39m"
},
{
"name": "templates",
"language": "javascript",
"code": "const value = `hello ${Math.max(1, 2)}`;",
"output": "\u001b[34mconst\u001b[39m value = \u001b[31m`hello ${\u001b[36mMath\u001b[39m\u001b[31m.max(\u001b[32m1\u001b[39m\u001b[31m, \u001b[32m2\u001b[39m\u001b[31m)}`\u001b[39m;"
},
{
"name": "markdown styles",
"language": "markdown",
"code": "# Heading\n**bold** *italic* [label](target)\n> quote\n- list\n`code`",
"output": "# Heading\n\u001b[1m**bold**\u001b[22m \u001b[3m*italic*\u001b[23m [\u001b[31mlabel\u001b[39m](\u001b[4mtarget\u001b[24m)\n> quote\n- list\n`code`"
},
{
"name": "diff colors",
"language": "diff",
"code": "@@ -1 +1 @@\n-removed\n+added",
"output": "@@ -1 +1 @@\n\u001b[31m-removed\u001b[39m\n\u001b[32m+added\u001b[39m"
},
{
"name": "meta",
"language": "cpp",
"code": "#include <vector>\n#define COUNT 12\nint main() { return 0; }",
"output": "\u001b[90m#\u001b[90minclude\u001b[39m\u001b[90m \u001b[90m<vector>\u001b[39m\u001b[90m\u001b[39m\n\u001b[90m#\u001b[90mdefine\u001b[39m\u001b[90m COUNT 12\u001b[39m\n\u001b[33m\u001b[34mint\u001b[39m\u001b[33m main() \u001b[39m{ \u001b[34mreturn\u001b[39m \u001b[32m0\u001b[39m; }"
},
{
"name": "type style",
"language": "haskell",
"code": "value :: Int\nvalue = 42",
"output": "value :: \u001b[36m\u001b[2mInt\u001b[22m\u001b[39m\nvalue = \u001b[32m42\u001b[39m"
},
{
"name": "plaintext escapes",
"language": "plaintext",
"code": "<span class=\"hljs-keyword\"> &amp; &#x27; > ' 😀\n",
"output": "<span class=\"hljs-keyword\"> &amp; &#x27; > ' 😀\n"
},
{
"name": "line endings",
"language": "plaintext",
"code": "first\r\nsecond\rthird\u0000last\n",
"output": "first\nsecond\nthirdlast\n"
},
{
"name": "empty",
"language": "plaintext",
"code": "",
"output": ""
}
]
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import chalk from 'chalk'
import { stripVTControlCharacters } from 'node:util'
import baseline from '../test/fixtures/cliHighlight.baseline.json'
import { getCliHighlightPromise, getLanguageName, type CliHighlight } from './cliHighlight.js'
import { applyMarkdown } from './markdown.js'
let previousLevel: typeof chalk.level
let highlighter: CliHighlight
beforeEach(async () => {
previousLevel = chalk.level
chalk.level = 1
const loaded = await getCliHighlightPromise()
expect(loaded).not.toBeNull()
highlighter = loaded!
})
afterEach(() => { chalk.level = previousLevel })
// Captured from the previous renderer at color level 1. Compare ANSI bytes,
// including nested color restoration, rather than only the visible text.
describe('terminal syntax output compatibility', () => {
for (const fixture of baseline) {
test(fixture.name, () => {
expect(highlighter.highlight(fixture.code, { language: fixture.language }))
.toBe(fixture.output)
})
}
test('disabled colors preserve visible output', () => {
chalk.level = 0
for (const fixture of baseline) {
expect(highlighter.highlight(fixture.code, { language: fixture.language }))
.toBe(stripVTControlCharacters(fixture.output))
}
})
test('aliases and telemetry use the shared language registry', async () => {
expect(getCliHighlightPromise()).toBe(getCliHighlightPromise())
expect(highlighter.supportsLanguage('ts')).toBe(true)
expect(highlighter.supportsLanguage('unsupported-language')).toBe(false)
expect(await getLanguageName('example.ts')).toBe('TypeScript')
expect(await getLanguageName('example.unsupported-language')).toBe('unknown')
expect(await getLanguageName('README')).toBe('unknown')
})
test('Markdown keeps the syntax palette in dark and light themes', () => {
const fixture = baseline.find(item => item.name === 'json')!
for (const theme of ['dark', 'light'] as const) {
expect(applyMarkdown('```json\n' + fixture.code + '\n```', theme, highlighter))
.toBe(fixture.output)
}
})
test('unknown and missing fenced languages fall back to plaintext', () => {
const code = 'const value = "<&>"; // keep plain'
for (const language of ['unsupported-language', '']) {
expect(applyMarkdown('```' + language + '\n' + code + '\n```', 'dark', highlighter))
.toBe(code)
}
})
})
+48 -18
View File
@@ -1,34 +1,64 @@
// highlight.js's type defs carry `/// <reference lib="dom" />`. SSETransport,
// mcp/client, ssh, dumpPrompts use DOM types (TextDecodeOptions, RequestInfo)
// that only typecheck because this file's `typeof import('highlight.js')` pulls
// lib.dom in. tsconfig has lib: ["ESNext"] only — fixing the actual DOM-type
// deps is a separate sweep; this ref preserves the status quo.
/// <reference lib="dom" />
import chalk from 'chalk'
import { extname } from 'path'
export type CliHighlight = {
highlight: typeof import('cli-highlight').highlight
supportsLanguage: typeof import('cli-highlight').supportsLanguage
highlight: (code: string, options: { language: string }) => string
supportsLanguage: (name: string) => boolean
}
// One promise shared by Fallback.tsx, markdown.ts, events.ts, getLanguageName.
// The highlight.js import piggybacks: cli-highlight has already pulled it into
// the module cache, so the second import() is a cache hit — no extra bytes
// faulted in.
// Preserve the terminal palette used by the code and Markdown renderers.
// Unlisted tokens, including titles, parameters and substitutions, are plain.
const tokenStyles = new Map<string, (text: string) => string>([
['keyword', chalk.blue], ['literal', chalk.blue], ['class', chalk.blue],
['built_in', chalk.cyan], ['type', chalk.cyan.dim], ['attr', chalk.cyan],
['number', chalk.green], ['comment', chalk.green], ['doctag', chalk.green],
['addition', chalk.green], ['regexp', chalk.red], ['string', chalk.red],
['deletion', chalk.red], ['function', chalk.yellow], ['meta', chalk.gray],
['tag', chalk.gray], ['name', chalk.blue], ['emphasis', chalk.italic],
['strong', chalk.bold], ['link', chalk.underline],
])
const entities: Record<string, string> = {
'&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#x27;': "'",
}
// Only consume highlight.js's generated spans and its five escaped entities.
// Source markup is escaped by highlight.js, so it cannot become a span here.
// A stack preserves nested styles and unprefixed sublanguage spans without a
// general HTML parser. Keep the previous renderer's CR/LF and NUL handling.
function terminalHighlight(html: string): string {
const stack: { token?: string; text: string }[] = [{ text: '' }]
for (const part of html.replace(/\r\n?/g, '\n').replace(/\0/g, '')
.split(/(<span class="[^"]*">|<\/span>)/g)) {
if (part.startsWith('<span class="')) {
stack.push({ token: /hljs-(\w+)/.exec(part)?.[1], text: '' })
} else if (part === '</span>') {
const frame = stack.pop()!
const style = frame.token ? tokenStyles.get(frame.token) : undefined
stack[stack.length - 1]!.text += style ? style(frame.text) : frame.text
} else {
stack[stack.length - 1]!.text += part.replace(
/&(?:amp|lt|gt|quot|#x27);/g, entity => entities[entity]!,
)
}
}
return stack[0]!.text
}
// Share one lazy registry load across code, Markdown and telemetry consumers.
let cliHighlightPromise: Promise<CliHighlight | null> | undefined
let loadedGetLanguage: typeof import('highlight.js').getLanguage | undefined
async function loadCliHighlight(): Promise<CliHighlight | null> {
try {
const cliHighlight = await import('cli-highlight')
// cache hit — cli-highlight already loaded highlight.js
const highlightJs = await import('highlight.js')
loadedGetLanguage = highlightJs.getLanguage
return {
highlight: cliHighlight.highlight,
supportsLanguage: cliHighlight.supportsLanguage,
highlight: (code, options) => terminalHighlight(
highlightJs.highlight(code, options).value,
),
supportsLanguage: name => Boolean(highlightJs.getLanguage(name)),
}
} catch {
return null
@@ -41,7 +71,7 @@ export function getCliHighlightPromise(): Promise<CliHighlight | null> {
}
/**
* eg. "foo/bar.ts" → "TypeScript". Awaits the shared cli-highlight load,
* Resolves a filename's extension to a language name after the shared load,
* then reads highlight.js's language registry. All callers are telemetry
* (OTel counter attributes, permission-dialog unary events) — none block
* on this, they fire-and-forget or the consumer already handles Promise<string>.