From 6a9178a3ac21f248388265f043d07226cb200364 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 11:08:06 -0500 Subject: [PATCH 01/15] =?UTF-8?q?=F0=9F=A4=96=20Migrate=20to=20Shiki=20min?= =?UTF-8?q?-dark=20theme=20with=20decorations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Shiki theme:** - Changed from 'dark-plus' to 'min-dark' throughout - Centralized theme constant in shikiHighlighter.ts **Search highlighting with decorations:** - Replaced DOM-based HTML post-processing with Shiki decorations - Decorations compute character positions at highlight time - Removed DOMParser, CRC32 caching for HTML manipulation - Added highlightSearchInText() for non-code text (file paths) - Removed highlightSearchMatches() complexity **Markdown integration:** - Integrated @shikijs/rehype plugin for code block highlighting - Removed react-syntax-highlighter dependency - Removed syntaxHighlighting.ts style definitions - Simplified MarkdownComponents (Shiki handles syntax now) - Enabled lazy language loading for better performance **Performance safeguards:** - Added MAX_DIFF_SIZE_BYTES (4kb) limit for diff highlighting - Large diffs fall back to plain text automatically - Enforced in one place (highlightDiffChunk) _Generated with `cmux`_ --- bun.lock | 89 ++------ package.json | 3 +- .../Messages/MarkdownComponents.tsx | 45 +---- src/components/Messages/MarkdownCore.tsx | 10 + .../RightSidebar/CodeReview/HunkViewer.tsx | 5 +- src/components/shared/DiffRenderer.tsx | 34 +--- src/styles/syntaxHighlighting.ts | 21 -- src/utils/highlighting/highlightDiffChunk.ts | 22 +- .../highlighting/highlightSearchTerms.ts | 191 +++++++++--------- src/utils/highlighting/shikiHighlighter.ts | 9 +- 10 files changed, 168 insertions(+), 261 deletions(-) delete mode 100644 src/styles/syntaxHighlighting.ts diff --git a/bun.lock b/bun.lock index b34a8f2ae..5588241ee 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@ai-sdk/openai": "^2.0.52", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@types/react-syntax-highlighter": "^15.5.13", + "@shikijs/rehype": "^3.13.0", "ai": "^5.0.72", "ai-tokenizer": "^1.0.3", "chalk": "^5.6.2", @@ -29,7 +29,6 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", @@ -456,6 +455,8 @@ "@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="], + "@shikijs/rehype": ["@shikijs/rehype@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "3.13.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" } }, "sha512-dxvB5gXEpiTI3beGwOPEwxFxQNmUWM4cwOWbvUmL6DnQJGl18/+cCjVHZK2OnasmU0v7SvM39Zh3iliWdwfBDA=="], + "@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="], "@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], @@ -738,8 +739,6 @@ "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], - "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], - "@types/resolve": ["@types/resolve@1.20.6", "", {}, "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ=="], "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], @@ -1020,13 +1019,13 @@ "char-regex": ["char-regex@2.0.2", "", {}, "sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg=="], - "character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], - "character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="], + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="], + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], @@ -1388,8 +1387,6 @@ "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], - "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], @@ -1426,8 +1423,6 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], - "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], - "fromentries": ["fromentries@1.3.2", "", {}, "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg=="], "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], @@ -1522,7 +1517,7 @@ "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - "hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="], + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], @@ -1534,15 +1529,13 @@ "hast-util-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="], + "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], - "hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="], - - "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], - - "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], @@ -1598,9 +1591,9 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - "is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="], + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], @@ -1626,7 +1619,7 @@ "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], - "is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -1642,7 +1635,7 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], @@ -1868,8 +1861,6 @@ "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], - "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -2100,7 +2091,7 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], @@ -2166,8 +2157,6 @@ "pretty-format": ["pretty-format@30.2.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA=="], - "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], @@ -2228,8 +2217,6 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="], - "read-config-file": ["read-config-file@6.3.2", "", { "dependencies": { "config-file-ts": "^0.2.4", "dotenv": "^9.0.2", "dotenv-expand": "^5.1.0", "js-yaml": "^4.1.0", "json5": "^2.2.0", "lazy-val": "^1.0.4" } }, "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q=="], "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -2246,8 +2233,6 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - "refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="], - "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -2638,8 +2623,6 @@ "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yaku": ["yaku@0.16.7", "", {}, "sha512-Syu3IB3rZvKvYk7yTiyl1bo/jiEFaaStrgv1V2TIJTqYPStSMQVO8EQjg/z+DRzLq/4LIIharNT3iH1hylEIRw=="], @@ -2850,8 +2833,6 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - "default-require-extensions/strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], "dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -2916,20 +2897,8 @@ "hasha/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="], - "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - - "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], - "hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], - - "hastscript/comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="], - - "hastscript/property-information": ["property-information@5.6.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="], - - "hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="], - "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -3062,8 +3031,6 @@ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - "mermaid/stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], "mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], @@ -3082,6 +3049,8 @@ "nyc/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=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -3106,8 +3075,6 @@ "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], - "refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="], - "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -3128,8 +3095,6 @@ "string-length/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -3394,12 +3359,6 @@ "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - - "hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - - "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "istanbul-lib-report/make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "jest-changed-files/jest-util/@jest/types": ["@jest/types@30.2.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg=="], @@ -3556,18 +3515,6 @@ "jest/@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "mdast-util-mdx-jsx/parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - - "mdast-util-mdx-jsx/parse-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - - "mdast-util-mdx-jsx/parse-entities/character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - - "mdast-util-mdx-jsx/parse-entities/is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - - "mdast-util-mdx-jsx/parse-entities/is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - - "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "nyc/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -3808,8 +3755,6 @@ "jest/@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - "nyc/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "nyc/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], diff --git a/package.json b/package.json index eea0b9840..839094680 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@ai-sdk/openai": "^2.0.52", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@types/react-syntax-highlighter": "^15.5.13", + "@shikijs/rehype": "^3.13.0", "ai": "^5.0.72", "ai-tokenizer": "^1.0.3", "chalk": "^5.6.2", @@ -58,7 +58,6 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.6", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", diff --git a/src/components/Messages/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx index af5079bd2..b1f22530e 100644 --- a/src/components/Messages/MarkdownComponents.tsx +++ b/src/components/Messages/MarkdownComponents.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from "react"; import React from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { syntaxStyleNoBackgrounds } from "@/styles/syntaxHighlighting"; import { Mermaid } from "./Mermaid"; interface CodeProps { @@ -58,7 +56,8 @@ export const markdownComponents = { ), - // Custom code block renderer with syntax highlighting + // Custom code block renderer + // Shiki rehype handles syntax highlighting for code blocks code: ({ inline, className, children, node, ...props }: CodeProps) => { const match = /language-(\w+)/.exec(className ?? ""); const language = match ? match[1] : ""; @@ -69,46 +68,14 @@ export const markdownComponents = { const hasMultipleLines = childString.includes("\n"); const isInline = inline ?? !hasMultipleLines; - if (!isInline && language) { - // Extract text content from children (react-markdown passes string or array of strings) + if (!isInline && language === "mermaid") { + // Handle mermaid diagrams specially const code = typeof children === "string" ? children : Array.isArray(children) ? children.join("") : ""; - - // Handle mermaid diagrams - if (language === "mermaid") { - return ; - } - - // Code block with language - use syntax highlighter - return ( - - {code.replace(/\n$/, "")} - - ); - } - - if (!isInline) { - // Code block without language - plain pre/code - return ( -
-          
-            {children}
-          
-        
- ); + return ; } + // For all other code blocks and inline code, let Shiki/default rendering handle it // Inline code (filter out node prop to avoid [object Object]) return ( diff --git a/src/components/Messages/MarkdownCore.tsx b/src/components/Messages/MarkdownCore.tsx index 93f6d176c..7f5e1d994 100644 --- a/src/components/Messages/MarkdownCore.tsx +++ b/src/components/Messages/MarkdownCore.tsx @@ -6,6 +6,8 @@ import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import rehypeShiki from "@shikijs/rehype"; +import { SHIKI_THEME } from "@/utils/highlighting/shikiHighlighter"; import "katex/dist/katex.min.css"; import { normalizeMarkdown } from "./MarkdownStyles"; import { markdownComponents } from "./MarkdownComponents"; @@ -34,6 +36,14 @@ const REHYPE_PLUGINS: PluggableList = [ rehypeRaw, // Parse HTML elements [rehypeSanitize, SANITIZE_SCHEMA], // Sanitize to whitelist only rehypeKatex, // Render math (must be after sanitization) + [ + rehypeShiki, + { + theme: SHIKI_THEME, + // Load languages on-demand for better performance + lazy: true, + }, + ], ]; /** diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index 307caa982..c27cb50de 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -8,9 +8,8 @@ import type { DiffHunk } from "@/types/review"; import { SelectableDiffRenderer } from "../../shared/DiffRenderer"; import { type SearchHighlightConfig, - highlightSearchMatches, + highlightSearchInText, } from "@/utils/highlighting/highlightSearchTerms"; -import { escapeHtml } from "@/utils/highlighting/highlightDiffChunk"; import { Tooltip, TooltipWrapper } from "../../Tooltip"; import { usePersistedState } from "@/hooks/usePersistedState"; import { getReviewExpandStateKey } from "@/constants/storage"; @@ -212,7 +211,7 @@ export const HunkViewer = React.memo( if (!searchConfig) { return hunk.filePath; } - return highlightSearchMatches(escapeHtml(hunk.filePath), searchConfig); + return highlightSearchInText(hunk.filePath, searchConfig); }, [hunk.filePath, searchConfig]); // Persist manual expand/collapse state across remounts per workspace diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index d99c61f47..448fb8751 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -10,10 +10,7 @@ import { getLanguageFromPath } from "@/utils/git/languageDetector"; import { Tooltip, TooltipWrapper } from "../Tooltip"; import { groupDiffLines } from "@/utils/highlighting/diffChunking"; import { highlightDiffChunk, type HighlightedChunk } from "@/utils/highlighting/highlightDiffChunk"; -import { - highlightSearchMatches, - type SearchHighlightConfig, -} from "@/utils/highlighting/highlightSearchTerms"; +import { type SearchHighlightConfig } from "@/utils/highlighting/highlightSearchTerms"; // Shared type for diff line types export type DiffLineType = "add" | "remove" | "context" | "header"; @@ -156,13 +153,14 @@ interface DiffRendererProps { /** * Hook to pre-process and highlight diff content in chunks - * Runs once when content/language changes + * Runs once when content/language/search changes */ function useHighlightedDiff( content: string, language: string, oldStart: number, - newStart: number + newStart: number, + searchConfig?: SearchHighlightConfig ): HighlightedChunk[] | null { const [chunks, setChunks] = useState(null); @@ -176,9 +174,9 @@ function useHighlightedDiff( // Group into chunks const diffChunks = groupDiffLines(lines, oldStart, newStart); - // Highlight each chunk + // Highlight each chunk with search decorations if provided const highlighted = await Promise.all( - diffChunks.map((chunk) => highlightDiffChunk(chunk, language)) + diffChunks.map((chunk) => highlightDiffChunk(chunk, language, searchConfig)) ); if (!cancelled) { @@ -191,7 +189,7 @@ function useHighlightedDiff( return () => { cancelled = true; }; - }, [content, language, oldStart, newStart]); + }, [content, language, oldStart, newStart, searchConfig]); return chunks; } @@ -473,10 +471,11 @@ export const SelectableDiffRenderer = React.memo( [filePath] ); - const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart); + const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart, searchConfig); // Build lineData from highlighted chunks (memoized to prevent repeated parsing) // Note: content field is NOT included - must be extracted from lines array when needed + // Search highlighting is now done via Shiki decorations at highlight time const lineData = React.useMemo(() => { if (!highlightedChunks) return []; @@ -501,17 +500,6 @@ export const SelectableDiffRenderer = React.memo( return data; }, [highlightedChunks]); - // Memoize highlighted line data to avoid re-parsing HTML on every render - // Only recalculate when lineData or searchConfig changes - const highlightedLineData = React.useMemo(() => { - if (!searchConfig) return lineData; - - return lineData.map((line) => ({ - ...line, - html: highlightSearchMatches(line.html, searchConfig), - })); - }, [lineData, searchConfig]); - const handleCommentButtonClick = (lineIndex: number, shiftKey: boolean) => { // Notify parent that this hunk should become active onLineClick?.(); @@ -555,7 +543,7 @@ export const SelectableDiffRenderer = React.memo( }; // Show loading state while highlighting - if (!highlightedChunks || highlightedLineData.length === 0) { + if (!highlightedChunks || lineData.length === 0) { return (
Processing...
@@ -568,7 +556,7 @@ export const SelectableDiffRenderer = React.memo( return ( - {highlightedLineData.map((lineInfo, displayIndex) => { + {lineData.map((lineInfo, displayIndex) => { const isSelected = isLineSelected(displayIndex); const indicator = lineInfo.type === "add" ? "+" : lineInfo.type === "remove" ? "-" : " "; diff --git a/src/styles/syntaxHighlighting.ts b/src/styles/syntaxHighlighting.ts deleted file mode 100644 index d2302b93b..000000000 --- a/src/styles/syntaxHighlighting.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Shared syntax highlighting styles for code blocks and diffs - * Based on VS Code's Dark+ theme, with backgrounds removed for flexibility - */ - -import type { CSSProperties } from "react"; -import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; - -/** - * Syntax style with colors only (backgrounds removed) - * This allows us to apply syntax highlighting on top of diff backgrounds - */ -export const syntaxStyleNoBackgrounds: Record = {}; - -// Strip background colors from the theme while preserving syntax colors -for (const [key, value] of Object.entries(vscDarkPlus as Record)) { - if (typeof value === "object" && value !== null) { - const { background, backgroundColor, ...rest } = value as Record; - syntaxStyleNoBackgrounds[key] = rest as CSSProperties; - } -} diff --git a/src/utils/highlighting/highlightDiffChunk.ts b/src/utils/highlighting/highlightDiffChunk.ts index 9d5292692..49d46fedf 100644 --- a/src/utils/highlighting/highlightDiffChunk.ts +++ b/src/utils/highlighting/highlightDiffChunk.ts @@ -1,5 +1,6 @@ -import { getShikiHighlighter, mapToShikiLang } from "./shikiHighlighter"; +import { getShikiHighlighter, mapToShikiLang, SHIKI_THEME, MAX_DIFF_SIZE_BYTES } from "./shikiHighlighter"; import type { DiffChunk } from "./diffChunking"; +import { computeSearchDecorations, type SearchHighlightConfig } from "./highlightSearchTerms"; /** * Chunk-based diff highlighting with Shiki @@ -32,7 +33,8 @@ export interface HighlightedChunk { */ export async function highlightDiffChunk( chunk: DiffChunk, - language: string + language: string, + searchConfig?: SearchHighlightConfig ): Promise { // Fast path: no highlighting for text files if (language === "text" || language === "plaintext") { @@ -47,6 +49,14 @@ export async function highlightDiffChunk( }; } + // Enforce size limit for performance + // Calculate size in bytes (rough estimate using string length) + const code = chunk.lines.join("\n"); + const sizeBytes = new TextEncoder().encode(code).length; + if (sizeBytes > MAX_DIFF_SIZE_BYTES) { + return createFallbackChunk(chunk); + } + try { const highlighter = await getShikiHighlighter(); const shikiLang = mapToShikiLang(language); @@ -66,11 +76,13 @@ export async function highlightDiffChunk( } } - // Highlight entire chunk as one block - const code = chunk.lines.join("\n"); + // Compute decorations for search matches if search is active + const decorations = searchConfig ? computeSearchDecorations(code, searchConfig) : []; + const html = highlighter.codeToHtml(code, { lang: shikiLang, - theme: "dark-plus", + theme: SHIKI_THEME, + decorations, }); // Parse HTML to extract line contents diff --git a/src/utils/highlighting/highlightSearchTerms.ts b/src/utils/highlighting/highlightSearchTerms.ts index 32eff907d..3fc9ff2d0 100644 --- a/src/utils/highlighting/highlightSearchTerms.ts +++ b/src/utils/highlighting/highlightSearchTerms.ts @@ -1,10 +1,9 @@ /** * Search term highlighting for diff content - * Post-processes Shiki-highlighted HTML to add search match highlights + * Computes Shiki decorations for search matches */ import { LRUCache } from "lru-cache"; -import CRC32 from "crc-32"; export interface SearchHighlightConfig { searchTerm: string; @@ -12,13 +11,11 @@ export interface SearchHighlightConfig { matchCase: boolean; } -// Module-level caches for performance -// Lazy-loaded to avoid DOMParser instantiation in non-browser environments (e.g., tests) -let parserInstance: DOMParser | null = null; -const getParser = (): DOMParser => { - parserInstance ??= new DOMParser(); - return parserInstance; -}; +export interface SearchDecoration { + start: number; + end: number; + properties: { class: string }; +} // LRU cache for compiled regex patterns // Key: search config string, Value: compiled RegExp @@ -26,16 +23,6 @@ const regexCache = new LRUCache({ max: 100, // Max 100 unique search patterns (plenty for typical usage) }); -// LRU cache for parsed DOM documents -// Key: CRC32 checksum of html, Value: parsed Document -// Caching the parsed DOM is more efficient than caching the final highlighted HTML -// because the parsing step is identical regardless of search config -const domCache = new LRUCache({ - max: 2000, // Max number of cached parsed documents - maxSize: 8 * 1024 * 1024, // 8MB total cache size (DOM objects are larger than strings) - sizeCalculation: () => 4096, // Rough estimate: ~4KB per parsed document -}); - /** * Escape special regex characters for literal string matching */ @@ -44,50 +31,92 @@ function escapeRegex(str: string): string { } /** - * Walk all text nodes in a DOM tree and apply a callback + * Highlight search matches in plain text by wrapping in tags + * Useful for highlighting non-code text like file paths + * + * @param text - Plain text to highlight + * @param config - Search configuration + * @returns HTML string with matches wrapped in */ -function walkTextNodes(node: Node, callback: (textNode: Text) => void): void { - if (node.nodeType === Node.TEXT_NODE) { - callback(node as Text); - } else { - const children = Array.from(node.childNodes); - for (const child of children) { - walkTextNodes(child, callback); +export function highlightSearchInText(text: string, config: SearchHighlightConfig): string { + const { searchTerm, useRegex, matchCase } = config; + + // No highlighting if search term is empty + if (!searchTerm.trim()) { + return text; + } + + try { + // Build regex pattern (with caching) + const regexCacheKey = `${searchTerm}:${useRegex}:${matchCase}`; + let pattern = regexCache.get(regexCacheKey); + + if (!pattern) { + try { + pattern = useRegex + ? new RegExp(searchTerm, matchCase ? "g" : "gi") + : new RegExp(escapeRegex(searchTerm), matchCase ? "g" : "gi"); + regexCache.set(regexCacheKey, pattern); + } catch { + // Invalid regex pattern - return original text + return text; + } + } + + let result = ""; + let lastIndex = 0; + pattern.lastIndex = 0; + + let match; + while ((match = pattern.exec(text)) !== null) { + // Add text before match + if (match.index > lastIndex) { + result += text.slice(lastIndex, match.index); + } + + // Add highlighted match + result += `${match[0]}`; + + lastIndex = match.index + match[0].length; + + // Prevent infinite loop on zero-length matches + if (match[0].length === 0) { + pattern.lastIndex++; + } + } + + // Add remaining text after last match + if (lastIndex < text.length) { + result += text.slice(lastIndex); } + + return result; + } catch (error) { + console.warn("Failed to highlight search in text:", error); + return text; } } /** - * Wrap search matches in HTML with tags - * Preserves existing HTML structure (e.g., Shiki syntax highlighting) + * Compute decorations for search matches in text + * Returns character positions for highlighting * - * @param html - HTML content to process (e.g., from Shiki) + * @param text - Plain text content to search * @param config - Search configuration - * @returns HTML with search matches wrapped in + * @returns Array of decorations marking search matches */ -export function highlightSearchMatches(html: string, config: SearchHighlightConfig): string { +export function computeSearchDecorations( + text: string, + config: SearchHighlightConfig +): SearchDecoration[] { const { searchTerm, useRegex, matchCase } = config; - // No highlighting if search term is empty + // No decorations if search term is empty if (!searchTerm.trim()) { - return html; + return []; } try { - // Check cache for parsed DOM (keyed only by html, not search config) - const htmlChecksum = CRC32.str(html); - let doc = domCache.get(htmlChecksum); - - if (!doc) { - // Parse HTML into DOM for safe manipulation - doc = getParser().parseFromString(html, "text/html"); - domCache.set(htmlChecksum, doc); - } - - // Clone the cached DOM so we don't mutate the cached version - // This is cheaper than re-parsing and allows cache reuse across different searches - const workingDoc = doc.cloneNode(true) as Document; - // Build regex pattern (with caching) const regexCacheKey = `${searchTerm}:${useRegex}:${matchCase}`; let pattern = regexCache.get(regexCacheKey); @@ -99,60 +128,32 @@ export function highlightSearchMatches(html: string, config: SearchHighlightConf : new RegExp(escapeRegex(searchTerm), matchCase ? "g" : "gi"); regexCache.set(regexCacheKey, pattern); } catch { - // Invalid regex pattern - return original HTML - return html; + // Invalid regex pattern - return no decorations + return []; } } - // Walk all text nodes and wrap matches in the working copy - walkTextNodes(workingDoc.body, (textNode) => { - const text = textNode.textContent || ""; + const decorations: SearchDecoration[] = []; + pattern.lastIndex = 0; // Reset regex state - // Quick check: does this text node contain any matches? - pattern.lastIndex = 0; // Reset regex state - if (!pattern.test(text)) { - return; - } - - // Build replacement fragment with wrapped matches - const fragment = workingDoc.createDocumentFragment(); - let lastIndex = 0; - pattern.lastIndex = 0; // Reset again for actual iteration - - let match; - while ((match = pattern.exec(text)) !== null) { - // Add text before match - if (match.index > lastIndex) { - fragment.appendChild(workingDoc.createTextNode(text.slice(lastIndex, match.index))); - } - - // Add highlighted match - const mark = workingDoc.createElement("mark"); - mark.className = "search-highlight"; - mark.textContent = match[0]; - fragment.appendChild(mark); - - lastIndex = match.index + match[0].length; - - // Prevent infinite loop on zero-length matches - if (match[0].length === 0) { - pattern.lastIndex++; - } - } + let match; + while ((match = pattern.exec(text)) !== null) { + decorations.push({ + start: match.index, + end: match.index + match[0].length, + properties: { class: "search-highlight" }, + }); - // Add remaining text after last match - if (lastIndex < text.length) { - fragment.appendChild(workingDoc.createTextNode(text.slice(lastIndex))); + // Prevent infinite loop on zero-length matches + if (match[0].length === 0) { + pattern.lastIndex++; } + } - // Replace text node with fragment - textNode.parentNode?.replaceChild(fragment, textNode); - }); - - return workingDoc.body.innerHTML; + return decorations; } catch (error) { - // Failed to parse/process - return original HTML - console.warn("Failed to highlight search matches:", error); - return html; + // Failed to process - return no decorations + console.warn("Failed to compute search decorations:", error); + return []; } } diff --git a/src/utils/highlighting/shikiHighlighter.ts b/src/utils/highlighting/shikiHighlighter.ts index d4465e795..ff8e7466e 100644 --- a/src/utils/highlighting/shikiHighlighter.ts +++ b/src/utils/highlighting/shikiHighlighter.ts @@ -1,5 +1,12 @@ import { createHighlighter, type Highlighter } from "shiki"; +// Shiki theme used throughout the application +export const SHIKI_THEME = "min-dark"; + +// Maximum diff size to highlight (in bytes) +// Diffs larger than this will fall back to plain text for performance +export const MAX_DIFF_SIZE_BYTES = 4096; // 4kb + // Singleton promise (cached to prevent race conditions) // Multiple concurrent calls will await the same Promise let highlighterPromise: Promise | null = null; @@ -14,7 +21,7 @@ export async function getShikiHighlighter(): Promise { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (!highlighterPromise) { highlighterPromise = createHighlighter({ - themes: ["dark-plus"], + themes: [SHIKI_THEME], langs: [], // Load languages on-demand via highlightDiffChunk }); } From c07af64a1fe5e8e213f39999168a2142c22f3d13 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 11:09:06 -0500 Subject: [PATCH 02/15] =?UTF-8?q?=F0=9F=A4=96=20Fix=20rehype-shiki=20lazy?= =?UTF-8?q?=20loading=20(must=20be=20false=20for=20sync=20pipeline)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Messages/MarkdownCore.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/Messages/MarkdownCore.tsx b/src/components/Messages/MarkdownCore.tsx index 7f5e1d994..84039d1b5 100644 --- a/src/components/Messages/MarkdownCore.tsx +++ b/src/components/Messages/MarkdownCore.tsx @@ -40,8 +40,9 @@ const REHYPE_PLUGINS: PluggableList = [ rehypeShiki, { theme: SHIKI_THEME, - // Load languages on-demand for better performance - lazy: true, + // Note: lazy must be false because ReactMarkdown uses runSync (not async) + // Languages are loaded on-demand by the shared highlighter instance + lazy: false, }, ], ]; From f935448d01b8cd8b171087e2e54f070a3fc9287e Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 11:12:39 -0500 Subject: [PATCH 03/15] =?UTF-8?q?=F0=9F=A4=96=20Fix=20Shiki=20markdown=20i?= =?UTF-8?q?ntegration=20with=20async=20CodeBlock=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem:** - @shikijs/rehype requires async processing - react-markdown uses runSync() (synchronous only) - Caused "runSync finished async" error **Solution:** - Removed @shikijs/rehype from rehype plugin pipeline - Created custom CodeBlock component with async Shiki highlighting - Reuses shared getShikiHighlighter() instance - Progressive enhancement: plain code → highlighted **Implementation:** - CodeBlock uses useState/useEffect pattern (matches DiffRenderer) - On-demand language loading with graceful fallback - Shows plain code during loading or on error - Renders highlighted HTML once ready **Benefits:** - Works with react-markdown's sync pipeline - Consistent with existing diff highlighting approach - Reuses highlighter instance (no duplication) - Better error handling and fallback behavior _Generated with `cmux`_ --- bun.lock | 5 - package.json | 1 - .../Messages/MarkdownComponents.tsx | 120 ++++++++++++++++-- src/components/Messages/MarkdownCore.tsx | 11 -- 4 files changed, 111 insertions(+), 26 deletions(-) diff --git a/bun.lock b/bun.lock index 5588241ee..39c8fcc07 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,6 @@ "@ai-sdk/openai": "^2.0.52", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@shikijs/rehype": "^3.13.0", "ai": "^5.0.72", "ai-tokenizer": "^1.0.3", "chalk": "^5.6.2", @@ -455,8 +454,6 @@ "@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="], - "@shikijs/rehype": ["@shikijs/rehype@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "3.13.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" } }, "sha512-dxvB5gXEpiTI3beGwOPEwxFxQNmUWM4cwOWbvUmL6DnQJGl18/+cCjVHZK2OnasmU0v7SvM39Zh3iliWdwfBDA=="], - "@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="], "@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], @@ -1529,8 +1526,6 @@ "hast-util-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="], - "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], - "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], diff --git a/package.json b/package.json index 839094680..ad8c3b20c 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@ai-sdk/openai": "^2.0.52", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@shikijs/rehype": "^3.13.0", "ai": "^5.0.72", "ai-tokenizer": "^1.0.3", "chalk": "^5.6.2", diff --git a/src/components/Messages/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx index b1f22530e..616240d02 100644 --- a/src/components/Messages/MarkdownComponents.tsx +++ b/src/components/Messages/MarkdownComponents.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; -import React from "react"; +import React, { useState, useEffect } from "react"; import { Mermaid } from "./Mermaid"; +import { getShikiHighlighter, mapToShikiLang, SHIKI_THEME } from "@/utils/highlighting/shikiHighlighter"; interface CodeProps { node?: unknown; @@ -22,6 +23,86 @@ interface SummaryProps { children?: ReactNode; } +interface CodeBlockProps { + code: string; + language: string; +} + +/** + * CodeBlock component with async Shiki highlighting + * Reuses shared highlighter instance from diff rendering + */ +const CodeBlock: React.FC = ({ code, language }) => { + const [html, setHtml] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function highlight() { + try { + const highlighter = await getShikiHighlighter(); + const shikiLang = mapToShikiLang(language); + + // Load language on-demand if needed + const loadedLangs = highlighter.getLoadedLanguages(); + if (!loadedLangs.includes(shikiLang)) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + await highlighter.loadLanguage(shikiLang as any); + } catch { + // Language not available - fall back to plain code + if (!cancelled) { + setHtml(null); + } + return; + } + } + + const result = highlighter.codeToHtml(code, { + lang: shikiLang, + theme: SHIKI_THEME, + }); + + if (!cancelled) { + setHtml(result); + } + } catch (error) { + console.warn(`Failed to highlight code block (${language}):`, error); + if (!cancelled) { + setHtml(null); + } + } + } + + void highlight(); + + return () => { + cancelled = true; + }; + }, [code, language]); + + // Show loading state or fall back to plain code + if (html === null) { + return ( +
+        {code}
+      
+ ); + } + + // Render highlighted HTML + return
; +}; + // Custom components for markdown rendering export const markdownComponents = { // Pass through pre element - let code component handle the wrapping @@ -56,26 +137,47 @@ export const markdownComponents = { ), - // Custom code block renderer - // Shiki rehype handles syntax highlighting for code blocks + // Custom code block renderer with async Shiki highlighting code: ({ inline, className, children, node, ...props }: CodeProps) => { const match = /language-(\w+)/.exec(className ?? ""); const language = match ? match[1] : ""; - // Better inline detection: check for multiline content + // Extract text content const childString = typeof children === "string" ? children : Array.isArray(children) ? children.join("") : ""; const hasMultipleLines = childString.includes("\n"); const isInline = inline ?? !hasMultipleLines; + // Handle mermaid diagrams specially if (!isInline && language === "mermaid") { - // Handle mermaid diagrams specially - const code = - typeof children === "string" ? children : Array.isArray(children) ? children.join("") : ""; - return ; + return ; + } + + // Code blocks with language - use async Shiki highlighting + if (!isInline && language) { + return ; + } + + // Code blocks without language + if (!isInline) { + return ( +
+          
+            {children}
+          
+        
+ ); } - // For all other code blocks and inline code, let Shiki/default rendering handle it // Inline code (filter out node prop to avoid [object Object]) return ( diff --git a/src/components/Messages/MarkdownCore.tsx b/src/components/Messages/MarkdownCore.tsx index 84039d1b5..93f6d176c 100644 --- a/src/components/Messages/MarkdownCore.tsx +++ b/src/components/Messages/MarkdownCore.tsx @@ -6,8 +6,6 @@ import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; -import rehypeShiki from "@shikijs/rehype"; -import { SHIKI_THEME } from "@/utils/highlighting/shikiHighlighter"; import "katex/dist/katex.min.css"; import { normalizeMarkdown } from "./MarkdownStyles"; import { markdownComponents } from "./MarkdownComponents"; @@ -36,15 +34,6 @@ const REHYPE_PLUGINS: PluggableList = [ rehypeRaw, // Parse HTML elements [rehypeSanitize, SANITIZE_SCHEMA], // Sanitize to whitelist only rehypeKatex, // Render math (must be after sanitization) - [ - rehypeShiki, - { - theme: SHIKI_THEME, - // Note: lazy must be false because ReactMarkdown uses runSync (not async) - // Languages are loaded on-demand by the shared highlighter instance - lazy: false, - }, - ], ]; /** From b75580e5eb84f5025346bde74fcb58ff75e8998c Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 11:15:08 -0500 Subject: [PATCH 04/15] =?UTF-8?q?=F0=9F=A4=96=20Optimize=20size=20check=20?= =?UTF-8?q?and=20fix=20search=20highlighting=20in=20Shiki?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use reduce on line lengths instead of TextEncoder for better performance - Add span.search-highlight CSS selector (Shiki uses span, not mark) - Decorations now properly styled with yellow highlight --- src/App.tsx | 4 +++- src/utils/highlighting/highlightDiffChunk.ts | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index fb035ae66..0743dce15 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -101,7 +101,9 @@ const globalStyles = css` } /* Search term highlighting - global for consistent styling across components */ - mark.search-highlight { + /* Applied to for plain text and for Shiki-highlighted code */ + mark.search-highlight, + span.search-highlight { background: rgba(255, 215, 0, 0.3); color: inherit; padding: 0; diff --git a/src/utils/highlighting/highlightDiffChunk.ts b/src/utils/highlighting/highlightDiffChunk.ts index 49d46fedf..6d51d3257 100644 --- a/src/utils/highlighting/highlightDiffChunk.ts +++ b/src/utils/highlighting/highlightDiffChunk.ts @@ -50,13 +50,14 @@ export async function highlightDiffChunk( } // Enforce size limit for performance - // Calculate size in bytes (rough estimate using string length) - const code = chunk.lines.join("\n"); - const sizeBytes = new TextEncoder().encode(code).length; + // Calculate size by summing line lengths + newlines (more performant than TextEncoder) + const sizeBytes = chunk.lines.reduce((total, line) => total + line.length, 0) + chunk.lines.length - 1; if (sizeBytes > MAX_DIFF_SIZE_BYTES) { return createFallbackChunk(chunk); } + const code = chunk.lines.join("\n"); + try { const highlighter = await getShikiHighlighter(); const shikiLang = mapToShikiLang(language); From a00d5b0606db536a2e11a500edf1f2a44fa12a93 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 11:17:10 -0500 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=A4=96=20Fix=20search=20highlightin?= =?UTF-8?q?g=20by=20excluding=20from=20transparent=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed 'span' selector to 'span:not(.search-highlight)' - Prevents transparent !important from overriding search highlight background - Search terms now properly highlighted in yellow in diff code --- src/components/shared/DiffRenderer.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index 448fb8751..b82378999 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -99,7 +99,8 @@ export const LineContent = styled.span<{ type: DiffLineType }>` }}; /* Ensure Shiki spans don't interfere with diff backgrounds */ - span { + /* Exclude search-highlight to allow search marking to show */ + span:not(.search-highlight) { background: transparent !important; } `; From aed999551e43237da6475e07115c5d1329d25cba Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 11:19:22 -0500 Subject: [PATCH 06/15] =?UTF-8?q?=F0=9F=A4=96=20Use=20global=20--color-cod?= =?UTF-8?q?e-bg=20for=20markdown=20code=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded rgba(0, 0, 0, 0.3) with var(--color-code-bg) - Consistent styling with rest of application --- src/components/Messages/MarkdownComponents.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Messages/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx index 616240d02..f36c4fb9a 100644 --- a/src/components/Messages/MarkdownComponents.tsx +++ b/src/components/Messages/MarkdownComponents.tsx @@ -86,7 +86,7 @@ const CodeBlock: React.FC = ({ code, language }) => { return (
Date: Mon, 20 Oct 2025 11:21:53 -0500
Subject: [PATCH 07/15] =?UTF-8?q?=F0=9F=A4=96=20Override=20Shiki=20theme?=
 =?UTF-8?q?=20background=20with=20global=20color?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Shiki's min-dark theme uses #1f1f1f inline
- Override with var(--color-code-bg) for consistency
---
 src/App.tsx | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/src/App.tsx b/src/App.tsx
index 0743dce15..e804ed682 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -109,6 +109,12 @@ const globalStyles = css`
     padding: 0;
     border-radius: 2px;
   }
+
+  /* Override Shiki theme background to use our global color */
+  .shiki,
+  .shiki pre {
+    background: var(--color-code-bg) !important;
+  }
 `;
 
 // Styled Components

From 4f3e2f5fca23d2816ffa1e35c3764e3d443ac443 Mon Sep 17 00:00:00 2001
From: Ammar 
Date: Mon, 20 Oct 2025 11:23:27 -0500
Subject: [PATCH 08/15] =?UTF-8?q?=F0=9F=A4=96=20Simplify=20CodeBlock=20-?=
 =?UTF-8?q?=20codeToHtml=20lazy-loads=20languages?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Removed manual language loading check
- codeToHtml already handles lazy-loading internally
- Cleaner code with same functionality
---
 src/components/Messages/MarkdownComponents.tsx | 16 +---------------
 1 file changed, 1 insertion(+), 15 deletions(-)

diff --git a/src/components/Messages/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx
index f36c4fb9a..4d7432b07 100644
--- a/src/components/Messages/MarkdownComponents.tsx
+++ b/src/components/Messages/MarkdownComponents.tsx
@@ -43,21 +43,7 @@ const CodeBlock: React.FC = ({ code, language }) => {
         const highlighter = await getShikiHighlighter();
         const shikiLang = mapToShikiLang(language);
 
-        // Load language on-demand if needed
-        const loadedLangs = highlighter.getLoadedLanguages();
-        if (!loadedLangs.includes(shikiLang)) {
-          try {
-            // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
-            await highlighter.loadLanguage(shikiLang as any);
-          } catch {
-            // Language not available - fall back to plain code
-            if (!cancelled) {
-              setHtml(null);
-            }
-            return;
-          }
-        }
-
+        // codeToHtml lazy-loads languages automatically
         const result = highlighter.codeToHtml(code, {
           lang: shikiLang,
           theme: SHIKI_THEME,

From baf783c0d0060c85a616f26555c72ee27dcbbcbc Mon Sep 17 00:00:00 2001
From: Ammar 
Date: Mon, 20 Oct 2025 11:25:21 -0500
Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=A4=96=20Move=20code=20block=20styl?=
 =?UTF-8?q?ing=20to=20global=20CSS=20and=20deduplicate?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Added global pre and pre > code styling in App.tsx
- Removed duplicate inline styles from MarkdownComponents
- Single source of truth for code block appearance
- Net -20 LoC
---
 src/App.tsx                                   | 15 ++++++++++++
 .../Messages/MarkdownComponents.tsx           | 24 +++----------------
 2 files changed, 18 insertions(+), 21 deletions(-)

diff --git a/src/App.tsx b/src/App.tsx
index e804ed682..5ef362dc2 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -115,6 +115,21 @@ const globalStyles = css`
   .shiki pre {
     background: var(--color-code-bg) !important;
   }
+
+  /* Global styling for markdown code blocks */
+  pre {
+    background: var(--color-code-bg);
+    margin: 1em 0;
+    border-radius: 4px;
+    font-size: 12px;
+    padding: 12px;
+    overflow: auto;
+  }
+
+  pre > code {
+    background: transparent;
+    padding: 0;
+  }
 `;
 
 // Styled Components
diff --git a/src/components/Messages/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx
index 4d7432b07..f55f20237 100644
--- a/src/components/Messages/MarkdownComponents.tsx
+++ b/src/components/Messages/MarkdownComponents.tsx
@@ -70,16 +70,7 @@ const CodeBlock: React.FC = ({ code, language }) => {
   // Show loading state or fall back to plain code
   if (html === null) {
     return (
-      
+      
         {code}
       
); @@ -144,19 +135,10 @@ export const markdownComponents = { return ; } - // Code blocks without language + // Code blocks without language (global CSS provides styling) if (!isInline) { return ( -
+        
           
             {children}
           

From d10db20ca8d8dc0831c4c6800a81bc488be1d669 Mon Sep 17 00:00:00 2001
From: Ammar 
Date: Mon, 20 Oct 2025 11:26:53 -0500
Subject: [PATCH 10/15] =?UTF-8?q?=F0=9F=A4=96=20Scope=20code=20block=20sty?=
 =?UTF-8?q?ling=20to=20'pre=20code'=20selector?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Changed from 'pre' to 'pre code' to avoid styling non-code pre elements
- Consolidated into single rule with display: block
---
 src/App.tsx | 8 ++------
 1 file changed, 2 insertions(+), 6 deletions(-)

diff --git a/src/App.tsx b/src/App.tsx
index 5ef362dc2..45c382397 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -117,7 +117,8 @@ const globalStyles = css`
   }
 
   /* Global styling for markdown code blocks */
-  pre {
+  pre code {
+    display: block;
     background: var(--color-code-bg);
     margin: 1em 0;
     border-radius: 4px;
@@ -125,11 +126,6 @@ const globalStyles = css`
     padding: 12px;
     overflow: auto;
   }
-
-  pre > code {
-    background: transparent;
-    padding: 0;
-  }
 `;
 
 // Styled Components

From d7a6c3359604782d7c88213fcce600301897bc42 Mon Sep 17 00:00:00 2001
From: Ammar 
Date: Mon, 20 Oct 2025 11:36:52 -0500
Subject: [PATCH 11/15] =?UTF-8?q?=F0=9F=A4=96=20Restore=20DOM-based=20sear?=
 =?UTF-8?q?ch=20highlighting=20for=20performance?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Reverts to the performant DOM-based approach that was in main before
this branch. The Shiki decoration-based approach was slower due to:
- Computing decorations synchronously blocks initial render
- Decorations applied during tokenization couples search with highlighting

Restored approach (Option A from plan):
- Highlight code WITHOUT search decorations (fast, immediate render)
- Post-process HTML to wrap matches using DOM manipulation
- Uses LRU caches: parsed DOMs (CRC32-keyed) + regex patterns

Architecture changes:
- highlightSearchTerms.ts: Restored highlightSearchMatches() and highlightSearchInText()
- highlightDiffChunk.ts: Removed searchConfig parameter and decoration logic
- DiffRenderer.tsx: Added highlightedLineData useMemo that applies search post-process

Performance benefits:
- Initial syntax highlighting renders immediately
- Search highlighting non-blocking (computed in useMemo)
- DOM cache reused across different searches (same HTML, different terms)

All 708 tests passing, typecheck clean.
---
 src/components/shared/DiffRenderer.tsx        |  36 +++--
 src/utils/highlighting/highlightDiffChunk.ts  |   8 +-
 .../highlighting/highlightSearchTerms.ts      | 138 +++++++++++++-----
 3 files changed, 126 insertions(+), 56 deletions(-)

diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx
index b82378999..7f6afe31d 100644
--- a/src/components/shared/DiffRenderer.tsx
+++ b/src/components/shared/DiffRenderer.tsx
@@ -10,7 +10,10 @@ import { getLanguageFromPath } from "@/utils/git/languageDetector";
 import { Tooltip, TooltipWrapper } from "../Tooltip";
 import { groupDiffLines } from "@/utils/highlighting/diffChunking";
 import { highlightDiffChunk, type HighlightedChunk } from "@/utils/highlighting/highlightDiffChunk";
-import { type SearchHighlightConfig } from "@/utils/highlighting/highlightSearchTerms";
+import {
+  highlightSearchMatches,
+  type SearchHighlightConfig,
+} from "@/utils/highlighting/highlightSearchTerms";
 
 // Shared type for diff line types
 export type DiffLineType = "add" | "remove" | "context" | "header";
@@ -154,14 +157,13 @@ interface DiffRendererProps {
 
 /**
  * Hook to pre-process and highlight diff content in chunks
- * Runs once when content/language/search changes
+ * Runs once when content/language changes (NOT search - that's applied post-process)
  */
 function useHighlightedDiff(
   content: string,
   language: string,
   oldStart: number,
-  newStart: number,
-  searchConfig?: SearchHighlightConfig
+  newStart: number
 ): HighlightedChunk[] | null {
   const [chunks, setChunks] = useState(null);
 
@@ -175,10 +177,8 @@ function useHighlightedDiff(
       // Group into chunks
       const diffChunks = groupDiffLines(lines, oldStart, newStart);
 
-      // Highlight each chunk with search decorations if provided
-      const highlighted = await Promise.all(
-        diffChunks.map((chunk) => highlightDiffChunk(chunk, language, searchConfig))
-      );
+      // Highlight each chunk (without search decorations - those are applied later)
+      const highlighted = await Promise.all(diffChunks.map((chunk) => highlightDiffChunk(chunk, language)));
 
       if (!cancelled) {
         setChunks(highlighted);
@@ -190,7 +190,7 @@ function useHighlightedDiff(
     return () => {
       cancelled = true;
     };
-  }, [content, language, oldStart, newStart, searchConfig]);
+  }, [content, language, oldStart, newStart]);
 
   return chunks;
 }
@@ -472,11 +472,10 @@ export const SelectableDiffRenderer = React.memo(
       [filePath]
     );
 
-    const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart, searchConfig);
+    const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart);
 
     // Build lineData from highlighted chunks (memoized to prevent repeated parsing)
     // Note: content field is NOT included - must be extracted from lines array when needed
-    // Search highlighting is now done via Shiki decorations at highlight time
     const lineData = React.useMemo(() => {
       if (!highlightedChunks) return [];
 
@@ -501,6 +500,17 @@ export const SelectableDiffRenderer = React.memo(
       return data;
     }, [highlightedChunks]);
 
+    // Memoize highlighted line data to avoid re-parsing HTML on every render
+    // Only recalculate when lineData or searchConfig changes
+    const highlightedLineData = React.useMemo(() => {
+      if (!searchConfig) return lineData;
+
+      return lineData.map((line) => ({
+        ...line,
+        html: highlightSearchMatches(line.html, searchConfig),
+      }));
+    }, [lineData, searchConfig]);
+
     const handleCommentButtonClick = (lineIndex: number, shiftKey: boolean) => {
       // Notify parent that this hunk should become active
       onLineClick?.();
@@ -544,7 +554,7 @@ export const SelectableDiffRenderer = React.memo(
     };
 
     // Show loading state while highlighting
-    if (!highlightedChunks || lineData.length === 0) {
+    if (!highlightedChunks || highlightedLineData.length === 0) {
       return (
         
           
Processing...
@@ -557,7 +567,7 @@ export const SelectableDiffRenderer = React.memo( return ( - {lineData.map((lineInfo, displayIndex) => { + {highlightedLineData.map((lineInfo, displayIndex) => { const isSelected = isLineSelected(displayIndex); const indicator = lineInfo.type === "add" ? "+" : lineInfo.type === "remove" ? "-" : " "; diff --git a/src/utils/highlighting/highlightDiffChunk.ts b/src/utils/highlighting/highlightDiffChunk.ts index 6d51d3257..6b6ce5e02 100644 --- a/src/utils/highlighting/highlightDiffChunk.ts +++ b/src/utils/highlighting/highlightDiffChunk.ts @@ -1,6 +1,5 @@ import { getShikiHighlighter, mapToShikiLang, SHIKI_THEME, MAX_DIFF_SIZE_BYTES } from "./shikiHighlighter"; import type { DiffChunk } from "./diffChunking"; -import { computeSearchDecorations, type SearchHighlightConfig } from "./highlightSearchTerms"; /** * Chunk-based diff highlighting with Shiki @@ -33,8 +32,7 @@ export interface HighlightedChunk { */ export async function highlightDiffChunk( chunk: DiffChunk, - language: string, - searchConfig?: SearchHighlightConfig + language: string ): Promise { // Fast path: no highlighting for text files if (language === "text" || language === "plaintext") { @@ -77,13 +75,9 @@ export async function highlightDiffChunk( } } - // Compute decorations for search matches if search is active - const decorations = searchConfig ? computeSearchDecorations(code, searchConfig) : []; - const html = highlighter.codeToHtml(code, { lang: shikiLang, theme: SHIKI_THEME, - decorations, }); // Parse HTML to extract line contents diff --git a/src/utils/highlighting/highlightSearchTerms.ts b/src/utils/highlighting/highlightSearchTerms.ts index 3fc9ff2d0..460ba36af 100644 --- a/src/utils/highlighting/highlightSearchTerms.ts +++ b/src/utils/highlighting/highlightSearchTerms.ts @@ -1,9 +1,10 @@ /** * Search term highlighting for diff content - * Computes Shiki decorations for search matches + * Post-processes Shiki-highlighted HTML to add search match highlights */ import { LRUCache } from "lru-cache"; +import CRC32 from "crc-32"; export interface SearchHighlightConfig { searchTerm: string; @@ -11,11 +12,13 @@ export interface SearchHighlightConfig { matchCase: boolean; } -export interface SearchDecoration { - start: number; - end: number; - properties: { class: string }; -} +// Module-level caches for performance +// Lazy-loaded to avoid DOMParser instantiation in non-browser environments (e.g., tests) +let parserInstance: DOMParser | null = null; +const getParser = (): DOMParser => { + parserInstance ??= new DOMParser(); + return parserInstance; +}; // LRU cache for compiled regex patterns // Key: search config string, Value: compiled RegExp @@ -23,6 +26,16 @@ const regexCache = new LRUCache({ max: 100, // Max 100 unique search patterns (plenty for typical usage) }); +// LRU cache for parsed DOM documents +// Key: CRC32 checksum of html, Value: parsed Document +// Caching the parsed DOM is more efficient than caching the final highlighted HTML +// because the parsing step is identical regardless of search config +const domCache = new LRUCache({ + max: 2000, // Max number of cached parsed documents + maxSize: 8 * 1024 * 1024, // 8MB total cache size (DOM objects are larger than strings) + sizeCalculation: () => 4096, // Rough estimate: ~4KB per parsed document +}); + /** * Escape special regex characters for literal string matching */ @@ -30,9 +43,23 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +/** + * Walk all text nodes in a DOM tree and apply a callback + */ +function walkTextNodes(node: Node, callback: (textNode: Text) => void): void { + if (node.nodeType === Node.TEXT_NODE) { + callback(node as Text); + } else { + const children = Array.from(node.childNodes); + for (const child of children) { + walkTextNodes(child, callback); + } + } +} + /** * Highlight search matches in plain text by wrapping in tags - * Useful for highlighting non-code text like file paths + * For use with non-HTML text like file paths * * @param text - Plain text to highlight * @param config - Search configuration @@ -98,25 +125,36 @@ export function highlightSearchInText(text: string, config: SearchHighlightConfi } /** - * Compute decorations for search matches in text - * Returns character positions for highlighting + * Wrap search matches in HTML with tags + * Preserves existing HTML structure (e.g., Shiki syntax highlighting) * - * @param text - Plain text content to search + * @param html - HTML content to process (e.g., from Shiki) * @param config - Search configuration - * @returns Array of decorations marking search matches + * @returns HTML with search matches wrapped in */ -export function computeSearchDecorations( - text: string, - config: SearchHighlightConfig -): SearchDecoration[] { +export function highlightSearchMatches(html: string, config: SearchHighlightConfig): string { const { searchTerm, useRegex, matchCase } = config; - // No decorations if search term is empty + // No highlighting if search term is empty if (!searchTerm.trim()) { - return []; + return html; } try { + // Check cache for parsed DOM (keyed only by html, not search config) + const htmlChecksum = CRC32.str(html); + let doc = domCache.get(htmlChecksum); + + if (!doc) { + // Parse HTML into DOM for safe manipulation + doc = getParser().parseFromString(html, "text/html"); + domCache.set(htmlChecksum, doc); + } + + // Clone the cached DOM so we don't mutate the cached version + // This is cheaper than re-parsing and allows cache reuse across different searches + const workingDoc = doc.cloneNode(true) as Document; + // Build regex pattern (with caching) const regexCacheKey = `${searchTerm}:${useRegex}:${matchCase}`; let pattern = regexCache.get(regexCacheKey); @@ -128,32 +166,60 @@ export function computeSearchDecorations( : new RegExp(escapeRegex(searchTerm), matchCase ? "g" : "gi"); regexCache.set(regexCacheKey, pattern); } catch { - // Invalid regex pattern - return no decorations - return []; + // Invalid regex pattern - return original HTML + return html; } } - const decorations: SearchDecoration[] = []; - pattern.lastIndex = 0; // Reset regex state + // Walk all text nodes and wrap matches in the working copy + walkTextNodes(workingDoc.body, (textNode) => { + const text = textNode.textContent || ""; - let match; - while ((match = pattern.exec(text)) !== null) { - decorations.push({ - start: match.index, - end: match.index + match[0].length, - properties: { class: "search-highlight" }, - }); + // Quick check: does this text node contain any matches? + pattern.lastIndex = 0; // Reset regex state + if (!pattern.test(text)) { + return; + } - // Prevent infinite loop on zero-length matches - if (match[0].length === 0) { - pattern.lastIndex++; + // Build replacement fragment with wrapped matches + const fragment = workingDoc.createDocumentFragment(); + let lastIndex = 0; + pattern.lastIndex = 0; // Reset again for actual iteration + + let match; + while ((match = pattern.exec(text)) !== null) { + // Add text before match + if (match.index > lastIndex) { + fragment.appendChild(workingDoc.createTextNode(text.slice(lastIndex, match.index))); + } + + // Add highlighted match + const mark = workingDoc.createElement("mark"); + mark.className = "search-highlight"; + mark.textContent = match[0]; + fragment.appendChild(mark); + + lastIndex = match.index + match[0].length; + + // Prevent infinite loop on zero-length matches + if (match[0].length === 0) { + pattern.lastIndex++; + } } - } - return decorations; + // Add remaining text after last match + if (lastIndex < text.length) { + fragment.appendChild(workingDoc.createTextNode(text.slice(lastIndex))); + } + + // Replace text node with fragment + textNode.parentNode?.replaceChild(fragment, textNode); + }); + + return workingDoc.body.innerHTML; } catch (error) { - // Failed to process - return no decorations - console.warn("Failed to compute search decorations:", error); - return []; + // Failed to parse/process - return original HTML + console.warn("Failed to highlight search matches:", error); + return html; } } From 0d4f3c9338518f3246b94a6ddef0fa59b7ca1a2b Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 11:43:38 -0500 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=A4=96=20Remove=20dead=20code:=20Pr?= =?UTF-8?q?ism=20CSS=20generation=20script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that we've fully migrated to Shiki and removed react-syntax-highlighter, these files are no longer needed: - scripts/generate_prism_css.ts - Generated CSS for react-syntax-highlighter - src/styles/prism-syntax.css - Output of the generator Both were used for the old Prism-based syntax highlighting approach. --- scripts/generate_prism_css.ts | 99 ---------- src/styles/prism-syntax.css | 357 ---------------------------------- 2 files changed, 456 deletions(-) delete mode 100755 scripts/generate_prism_css.ts delete mode 100644 src/styles/prism-syntax.css diff --git a/scripts/generate_prism_css.ts b/scripts/generate_prism_css.ts deleted file mode 100755 index 9ac077d33..000000000 --- a/scripts/generate_prism_css.ts +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bun - -/** - * Generates Prism CSS stylesheet from vscDarkPlus theme - * Used for syntax highlighting when react-syntax-highlighter has useInlineStyles={false} - * - * Strips backgrounds to preserve diff backgrounds in Review tab - * Omits font-family and font-size to inherit from parent components - */ - -import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; -import type { CSSProperties } from "react"; - -const OUTPUT_PATH = "src/styles/prism-syntax.css"; - -// Strip backgrounds like we do in syntaxHighlighting.ts -const syntaxStyleNoBackgrounds: Record = {}; -for (const [key, value] of Object.entries(vscDarkPlus as Record)) { - if (typeof value === "object" && value !== null) { - const { background, backgroundColor, ...rest } = value as Record; - if (Object.keys(rest).length > 0) { - syntaxStyleNoBackgrounds[key] = rest as CSSProperties; - } - } -} - -// Convert CSS properties object to CSS string -function cssPropertiesToString(props: CSSProperties, selector: string): string { - const entries = Object.entries(props) - .filter(([key]) => { - // Skip font-family and font-size - we want to inherit these - return key !== "fontFamily" && key !== "fontSize"; - }) - .map(([key, value]) => { - // Convert camelCase to kebab-case - const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase(); - return ` ${cssKey}: ${value};`; - }); - - // Add background: transparent to pre/code elements to prevent double backgrounds - if (selector.startsWith("pre") || selector.startsWith("code")) { - entries.push(" background: transparent;"); - } - - return entries.join("\n"); -} - -// Generate CSS content -function generateCSS(): string { - const lines: string[] = [ - "/**", - " * Auto-generated Prism syntax highlighting styles", - " * Based on VS Code Dark+ theme with backgrounds removed", - " * Used when react-syntax-highlighter has useInlineStyles={false}", - " *", - " * Font family and size are intentionally omitted to inherit from parent.", - " * ", - " * To regenerate: bun run scripts/generate_prism_css.ts", - " */", - "", - ]; - - for (const [selector, props] of Object.entries(syntaxStyleNoBackgrounds)) { - const cssRules = cssPropertiesToString(props, selector); - if (cssRules.trim().length > 0) { - // Handle selectors that need .token prefix - let cssSelector = selector; - - // Add .token prefix for single-word selectors (token types) - if (!/[ >[\]:.]/.test(selector) && !selector.startsWith("pre") && !selector.startsWith("code")) { - cssSelector = `.token.${selector}`; - } - - lines.push(`${cssSelector} {`); - lines.push(cssRules); - lines.push("}"); - lines.push(""); - } - } - - return lines.join("\n"); -} - -async function main() { - console.log("Generating Prism CSS stylesheet..."); - - const css = generateCSS(); - - console.log(`Writing CSS to ${OUTPUT_PATH}...`); - await Bun.write(OUTPUT_PATH, css); - - console.log("✓ Prism CSS generated successfully"); -} - -main().catch((error) => { - console.error("Error generating Prism CSS:", error); - process.exit(1); -}); - diff --git a/src/styles/prism-syntax.css b/src/styles/prism-syntax.css deleted file mode 100644 index a0ebbf57d..000000000 --- a/src/styles/prism-syntax.css +++ /dev/null @@ -1,357 +0,0 @@ -/** - * Auto-generated Prism syntax highlighting styles - * Based on VS Code Dark+ theme with backgrounds removed - * Used when react-syntax-highlighter has useInlineStyles={false} - * - * Font family and size are intentionally omitted to inherit from parent. - * - * To regenerate: bun run scripts/generate_prism_css.ts - */ - -pre[class*="language-"] { - color: #d4d4d4; - text-shadow: none; - direction: ltr; - text-align: left; - white-space: pre; - word-spacing: normal; - word-break: normal; - line-height: 1.5; - -moz-tab-size: 4; - -o-tab-size: 4; - tab-size: 4; - -webkit-hyphens: none; - -moz-hyphens: none; - ms-hyphens: none; - hyphens: none; - padding: 1em; - margin: 0.5em 0; - overflow: auto; - background: transparent; -} - -code[class*="language-"] { - color: #d4d4d4; - text-shadow: none; - direction: ltr; - text-align: left; - white-space: pre; - word-spacing: normal; - word-break: normal; - line-height: 1.5; - -moz-tab-size: 4; - -o-tab-size: 4; - tab-size: 4; - -webkit-hyphens: none; - -moz-hyphens: none; - ms-hyphens: none; - hyphens: none; -} - -pre[class*="language-"]::selection { - text-shadow: none; -} - -code[class*="language-"]::selection { - text-shadow: none; -} - -pre[class*="language-"] *::selection { - text-shadow: none; -} - -code[class*="language-"] *::selection { - text-shadow: none; -} - -:not(pre) > code[class*="language-"] { - padding: 0.1em 0.3em; - border-radius: 0.3em; - color: #db4c69; -} - -.namespace { - -opacity: 0.7; -} - -doctype.doctype-tag { - color: #569cd6; -} - -doctype.name { - color: #9cdcfe; -} - -.token.comment { - color: #6a9955; -} - -.token.prolog { - color: #6a9955; -} - -.token.punctuation { - color: #d4d4d4; -} - -.language-html .language-css .token.punctuation { - color: #d4d4d4; -} - -.language-html .language-javascript .token.punctuation { - color: #d4d4d4; -} - -.token.property { - color: #9cdcfe; -} - -.token.tag { - color: #569cd6; -} - -.token.boolean { - color: #569cd6; -} - -.token.number { - color: #b5cea8; -} - -.token.constant { - color: #9cdcfe; -} - -.token.symbol { - color: #b5cea8; -} - -.token.inserted { - color: #b5cea8; -} - -.token.unit { - color: #b5cea8; -} - -.token.selector { - color: #d7ba7d; -} - -.token.attr-name { - color: #9cdcfe; -} - -.token.string { - color: #ce9178; -} - -.token.char { - color: #ce9178; -} - -.token.builtin { - color: #ce9178; -} - -.token.deleted { - color: #ce9178; -} - -.language-css .token.string.url { - text-decoration: underline; -} - -.token.operator { - color: #d4d4d4; -} - -.token.entity { - color: #569cd6; -} - -operator.arrow { - color: #569cd6; -} - -.token.atrule { - color: #ce9178; -} - -atrule.rule { - color: #c586c0; -} - -atrule.url { - color: #9cdcfe; -} - -atrule.url.function { - color: #dcdcaa; -} - -atrule.url.punctuation { - color: #d4d4d4; -} - -.token.keyword { - color: #569cd6; -} - -keyword.module { - color: #c586c0; -} - -keyword.control-flow { - color: #c586c0; -} - -.token.function { - color: #dcdcaa; -} - -function.maybe-class-name { - color: #dcdcaa; -} - -.token.regex { - color: #d16969; -} - -.token.important { - color: #569cd6; -} - -.token.italic { - font-style: italic; -} - -.token.class-name { - color: #4ec9b0; -} - -.token.maybe-class-name { - color: #4ec9b0; -} - -.token.console { - color: #9cdcfe; -} - -.token.parameter { - color: #9cdcfe; -} - -.token.interpolation { - color: #9cdcfe; -} - -punctuation.interpolation-punctuation { - color: #569cd6; -} - -.token.variable { - color: #9cdcfe; -} - -imports.maybe-class-name { - color: #9cdcfe; -} - -exports.maybe-class-name { - color: #9cdcfe; -} - -.token.escape { - color: #d7ba7d; -} - -tag.punctuation { - color: #808080; -} - -.token.cdata { - color: #808080; -} - -.token.attr-value { - color: #ce9178; -} - -attr-value.punctuation { - color: #ce9178; -} - -attr-value.punctuation.attr-equals { - color: #d4d4d4; -} - -.token.namespace { - color: #4ec9b0; -} - -pre[class*="language-javascript"] { - color: #9cdcfe; -} - -code[class*="language-javascript"] { - color: #9cdcfe; -} - -pre[class*="language-jsx"] { - color: #9cdcfe; -} - -code[class*="language-jsx"] { - color: #9cdcfe; -} - -pre[class*="language-typescript"] { - color: #9cdcfe; -} - -code[class*="language-typescript"] { - color: #9cdcfe; -} - -pre[class*="language-tsx"] { - color: #9cdcfe; -} - -code[class*="language-tsx"] { - color: #9cdcfe; -} - -pre[class*="language-css"] { - color: #ce9178; -} - -code[class*="language-css"] { - color: #ce9178; -} - -pre[class*="language-html"] { - color: #d4d4d4; -} - -code[class*="language-html"] { - color: #d4d4d4; -} - -.language-regex .token.anchor { - color: #dcdcaa; -} - -.language-html .token.punctuation { - color: #808080; -} - -pre[class*="language-"] > code[class*="language-"] { - position: relative; - z-index: 1; -} - -.line-highlight.line-highlight { - box-shadow: inset 5px 0 0 #f7d87c; - z-index: 0; -} From addca8b942b535d886c9c7c6c190b20433f1b918 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 11:45:42 -0500 Subject: [PATCH 13/15] =?UTF-8?q?=F0=9F=A4=96=20Fix=20XSS=20in=20highlight?= =?UTF-8?q?SearchInText=20by=20escaping=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The highlightSearchInText() function was building HTML strings without escaping text content, allowing potential XSS via malicious file paths or search terms containing HTML/script tags. Fix: Added escapeHtml() helper and escape all text content before concatenating into HTML string: - Text before matches - The matched substring - Text after matches This prevents script injection while maintaining search highlighting functionality. --- .../highlighting/highlightSearchTerms.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/utils/highlighting/highlightSearchTerms.ts b/src/utils/highlighting/highlightSearchTerms.ts index 460ba36af..fcc20e385 100644 --- a/src/utils/highlighting/highlightSearchTerms.ts +++ b/src/utils/highlighting/highlightSearchTerms.ts @@ -43,6 +43,18 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +/** + * Escape HTML entities for safe injection + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + /** * Walk all text nodes in a DOM tree and apply a callback */ @@ -96,13 +108,13 @@ export function highlightSearchInText(text: string, config: SearchHighlightConfi let match; while ((match = pattern.exec(text)) !== null) { - // Add text before match + // Add text before match (escaped) if (match.index > lastIndex) { - result += text.slice(lastIndex, match.index); + result += escapeHtml(text.slice(lastIndex, match.index)); } - // Add highlighted match - result += `${match[0]}`; + // Add highlighted match (escaped) + result += `${escapeHtml(match[0])}`; lastIndex = match.index + match[0].length; @@ -112,9 +124,9 @@ export function highlightSearchInText(text: string, config: SearchHighlightConfi } } - // Add remaining text after last match + // Add remaining text after last match (escaped) if (lastIndex < text.length) { - result += text.slice(lastIndex); + result += escapeHtml(text.slice(lastIndex)); } return result; From 1438b722b47790385db79150a28b46bdb5a3be79 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 11:46:30 -0500 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=A4=96=20Replace=20custom=20escapeH?= =?UTF-8?q?tml=20with=20escape-html=20library?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced custom HTML escape implementation with the well-tested escape-html package from the Express.js team. This provides better security confidence through: - Industry-standard implementation - Extensive testing and auditing - Active maintenance - Small bundle size (~2KB) Added dependencies: - escape-html - @types/escape-html (dev) --- bun.lock | 6 ++++++ package.json | 2 ++ src/utils/highlighting/highlightSearchTerms.ts | 13 +------------ 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 39c8fcc07..5bac150a7 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "diff": "^8.0.2", "disposablestack": "^1.1.7", "electron-updater": "^6.6.2", + "escape-html": "^1.0.3", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.2", "markdown-it": "^14.1.0", @@ -54,6 +55,7 @@ "@testing-library/react": "^16.3.0", "@types/bun": "^1.2.23", "@types/diff": "^8.0.0", + "@types/escape-html": "^1.0.4", "@types/jest": "^30.0.0", "@types/katex": "^0.16.7", "@types/markdown-it": "^14.1.2", @@ -682,6 +684,8 @@ "@types/doctrine": ["@types/doctrine@0.0.9", "", {}, "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA=="], + "@types/escape-html": ["@types/escape-html@1.0.4", "", {}, "sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], @@ -1324,6 +1328,8 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.4.0", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="], diff --git a/package.json b/package.json index ad8c3b20c..bf82e642c 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "diff": "^8.0.2", "disposablestack": "^1.1.7", "electron-updater": "^6.6.2", + "escape-html": "^1.0.3", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.2", "markdown-it": "^14.1.0", @@ -83,6 +84,7 @@ "@testing-library/react": "^16.3.0", "@types/bun": "^1.2.23", "@types/diff": "^8.0.0", + "@types/escape-html": "^1.0.4", "@types/jest": "^30.0.0", "@types/katex": "^0.16.7", "@types/markdown-it": "^14.1.2", diff --git a/src/utils/highlighting/highlightSearchTerms.ts b/src/utils/highlighting/highlightSearchTerms.ts index fcc20e385..74ca1c77c 100644 --- a/src/utils/highlighting/highlightSearchTerms.ts +++ b/src/utils/highlighting/highlightSearchTerms.ts @@ -5,6 +5,7 @@ import { LRUCache } from "lru-cache"; import CRC32 from "crc-32"; +import escapeHtml from "escape-html"; export interface SearchHighlightConfig { searchTerm: string; @@ -43,18 +44,6 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -/** - * Escape HTML entities for safe injection - */ -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - /** * Walk all text nodes in a DOM tree and apply a callback */ From fccb20e30df873092ed1344c7efc92ba2004dab5 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 20 Oct 2025 11:49:27 -0500 Subject: [PATCH 15/15] =?UTF-8?q?=F0=9F=A4=96=20Run=20prettier=20formattin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Messages/MarkdownComponents.tsx | 6 +++++- src/components/shared/DiffRenderer.tsx | 4 +++- src/utils/highlighting/highlightDiffChunk.ts | 10 ++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/Messages/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx index f55f20237..621ef309a 100644 --- a/src/components/Messages/MarkdownComponents.tsx +++ b/src/components/Messages/MarkdownComponents.tsx @@ -1,7 +1,11 @@ import type { ReactNode } from "react"; import React, { useState, useEffect } from "react"; import { Mermaid } from "./Mermaid"; -import { getShikiHighlighter, mapToShikiLang, SHIKI_THEME } from "@/utils/highlighting/shikiHighlighter"; +import { + getShikiHighlighter, + mapToShikiLang, + SHIKI_THEME, +} from "@/utils/highlighting/shikiHighlighter"; interface CodeProps { node?: unknown; diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index 7f6afe31d..864f710f8 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -178,7 +178,9 @@ function useHighlightedDiff( const diffChunks = groupDiffLines(lines, oldStart, newStart); // Highlight each chunk (without search decorations - those are applied later) - const highlighted = await Promise.all(diffChunks.map((chunk) => highlightDiffChunk(chunk, language))); + const highlighted = await Promise.all( + diffChunks.map((chunk) => highlightDiffChunk(chunk, language)) + ); if (!cancelled) { setChunks(highlighted); diff --git a/src/utils/highlighting/highlightDiffChunk.ts b/src/utils/highlighting/highlightDiffChunk.ts index 6b6ce5e02..cf1635331 100644 --- a/src/utils/highlighting/highlightDiffChunk.ts +++ b/src/utils/highlighting/highlightDiffChunk.ts @@ -1,4 +1,9 @@ -import { getShikiHighlighter, mapToShikiLang, SHIKI_THEME, MAX_DIFF_SIZE_BYTES } from "./shikiHighlighter"; +import { + getShikiHighlighter, + mapToShikiLang, + SHIKI_THEME, + MAX_DIFF_SIZE_BYTES, +} from "./shikiHighlighter"; import type { DiffChunk } from "./diffChunking"; /** @@ -49,7 +54,8 @@ export async function highlightDiffChunk( // Enforce size limit for performance // Calculate size by summing line lengths + newlines (more performant than TextEncoder) - const sizeBytes = chunk.lines.reduce((total, line) => total + line.length, 0) + chunk.lines.length - 1; + const sizeBytes = + chunk.lines.reduce((total, line) => total + line.length, 0) + chunk.lines.length - 1; if (sizeBytes > MAX_DIFF_SIZE_BYTES) { return createFallbackChunk(chunk); }