From ff13baf587522f97b61d844768e8a40e2f0bc254 Mon Sep 17 00:00:00 2001 From: JATMN Date: Sat, 24 Oct 2026 11:31:34 -0700 Subject: [PATCH] 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. --- CONTRIBUTING.md | 10 ++- bun.lock | 50 +----------- package.json | 2 +- scripts/externals.ts | 2 +- .../HighlightedCode/Fallback.test.tsx | 61 ++++++++++++++ src/components/HighlightedCode/Fallback.tsx | 5 +- src/ink/Ansi.tsx | 2 +- src/native-ts/color-diff/index.ts | 4 +- src/test/fixtures/cliHighlight.baseline.json | 80 +++++++++++++++++++ src/utils/cliHighlight.test.ts | 63 +++++++++++++++ src/utils/cliHighlight.ts | 66 ++++++++++----- 11 files changed, 269 insertions(+), 76 deletions(-) create mode 100644 src/components/HighlightedCode/Fallback.test.tsx create mode 100644 src/test/fixtures/cliHighlight.baseline.json create mode 100644 src/utils/cliHighlight.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e79c4effb..b7afebe38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: diff --git a/bun.lock b/bun.lock index 0fe3c8953..715fbe455 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index ba0cfac10..b322b7975 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/externals.ts b/scripts/externals.ts index 75309749d..bd398d279 100644 --- a/scripts/externals.ts +++ b/scripts/externals.ts @@ -141,7 +141,7 @@ export const INTENTIONALLY_BUNDLED: string[] = [ 'bidi-js', 'chalk', 'cli-boxes', - 'cli-highlight', + 'highlight.js', 'commander', 'emoji-regex', 'env-paths', diff --git a/src/components/HighlightedCode/Fallback.test.tsx b/src/components/HighlightedCode/Fallback.test.tsx new file mode 100644 index 000000000..5a2523a18 --- /dev/null +++ b/src/components/HighlightedCode/Fallback.test.tsx @@ -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 | undefined + + try { + expect(await getCliHighlightPromise()).not.toBeNull() + chalk.level = 1 + const highlighted = Promise.withResolvers() + ansi.mockImplementation(props => { + if (props.children.includes('\x1b')) highlighted.resolve() + return renderAnsi(props) + }) + root.render() + await Promise.race([ + highlighted.promise, + new Promise((_, 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() + 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() + } +}) diff --git a/src/components/HighlightedCode/Fallback.tsx b/src/components/HighlightedCode/Fallback.tsx index 425807734..1457ca7c8 100644 --- a/src/components/HighlightedCode/Fallback.tsx +++ b/src/components/HighlightedCode/Fallback.tsx @@ -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(); function cachedHighlight(hl: NonNullable>>, 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); diff --git a/src/ink/Ansi.tsx b/src/ink/Ansi.tsx index c397b058f..46c41ab54 100644 --- a/src/ink/Ansi.tsx +++ b/src/ink/Ansi.tsx @@ -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. */ diff --git a/src/native-ts/color-diff/index.ts b/src/native-ts/color-diff/index.ts index 2d81f2738..e582e5449 100644 --- a/src/native-ts/color-diff/index.ts +++ b/src/native-ts/color-diff/index.ts @@ -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. * diff --git a/src/test/fixtures/cliHighlight.baseline.json b/src/test/fixtures/cliHighlight.baseline.json new file mode 100644 index 000000000..50d9d5548 --- /dev/null +++ b/src/test/fixtures/cliHighlight.baseline.json @@ -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": "
\">hello & world
", + "output": "\u001b[90m<\u001b[34msection\u001b[39m\u001b[90m \u001b[36mtitle\u001b[39m\u001b[90m=\u001b[31m\"<&>\"\u001b[39m\u001b[90m>\u001b[39mhello & world\u001b[90m\u001b[39m" + }, + { + "name": "sublanguages", + "language": "xml", + "code": "\n", + "output": "\u001b[90m<\u001b[34mscript\u001b[39m\u001b[90m>\u001b[39m\u001b[34mconst\u001b[39m n = \u001b[32m2\u001b[39m;\u001b[90m\u001b[39m\n\u001b[90m<\u001b[34mstyle\u001b[39m\u001b[90m>\u001b[39m.item { color: red; }\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 \n#define COUNT 12\nint main() { return 0; }", + "output": "\u001b[90m#\u001b[90minclude\u001b[39m\u001b[90m \u001b[90m\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": " & ' > ' 😀\n", + "output": " & ' > ' 😀\n" + }, + { + "name": "line endings", + "language": "plaintext", + "code": "first\r\nsecond\rthird\u0000last\n", + "output": "first\nsecond\nthirdlast\n" + }, + { + "name": "empty", + "language": "plaintext", + "code": "", + "output": "" + } +] diff --git a/src/utils/cliHighlight.test.ts b/src/utils/cliHighlight.test.ts new file mode 100644 index 000000000..a49b50e32 --- /dev/null +++ b/src/utils/cliHighlight.test.ts @@ -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) + } + }) +}) diff --git a/src/utils/cliHighlight.ts b/src/utils/cliHighlight.ts index 70504fa04..086782aa8 100644 --- a/src/utils/cliHighlight.ts +++ b/src/utils/cliHighlight.ts @@ -1,34 +1,64 @@ -// highlight.js's type defs carry `/// `. 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. -/// - +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>([ + ['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 = { + '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", +} + +// 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>)/g)) { + if (part.startsWith('