diff --git a/.changeset/rc12-runtime-parity.md b/.changeset/rc12-runtime-parity.md index 909dc90..e83d4fe 100644 --- a/.changeset/rc12-runtime-parity.md +++ b/.changeset/rc12-runtime-parity.md @@ -2,9 +2,9 @@ '@solidjs/start-devtools': patch --- -Build and read the Solid 2.0.0-rc.12 runtime. +Build and read the Solid 2.0.0-rc.13 runtime. The toolbar is compiled with `@solidjs/compiler`, the compiler that ships with the runtime, so delegated event handlers use the key the runtime reads (`_$$click`). Every click in the reactivity and ownership panels was dead against rc.9 and later. -The `solid-js` and `@solidjs/web` peers move to `^2.0.0-rc.12`. +The `solid-js` and `@solidjs/web` peers move to `^2.0.0-rc.13`. The reactivity graph reads a node's error off its extension (`_x._error`), recognises store slot nodes by the store they belong to, and hides the memo the built-in hot reload wrapper creates, which the runtime now marks as plumbing instead of naming. The ownership tree walks through that memo without a row. A server render that fails sets the 500 status and logs, without writing toolbar state. diff --git a/.changeset/twinkleplop-highlighting.md b/.changeset/twinkleplop-highlighting.md new file mode 100644 index 0000000..dc93a27 --- /dev/null +++ b/.changeset/twinkleplop-highlighting.md @@ -0,0 +1,9 @@ +--- +'@solidjs/start-devtools': patch +--- + +Highlight error sources with twinkleplop instead of shiki. + +The code view now highlights synchronously, so a stack frame's source appears with the panel rather than a moment later, and the toolbar no longer ships a WebAssembly grammar engine. The built package drops from 8.1 MB to 3.6 MB, and the error viewer chunk from 1.1 MB to 273 kB. +The panel highlights the whole file and keeps twenty-five lines on each side of the frame, which is about as far as the view scrolls. Cutting the file before highlighting it left the highlighter reading a block comment or template literal it never saw open, and the colours fell apart from there. The view opens on the frame's line. +The frame's line is marked with the `focus` directive and the word it points at with `err`, both written as comments above the file. The code itself is never edited to carry them, and marker lines are dropped from the output. diff --git a/package.json b/package.json index cb1ef68..9b84c96 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,10 @@ "@solidjs/compiler": "2.0.0-rc.13", "@solidjs/vite-plugin": "3.0.0-next.46", "@solidjs/web": "^2.0.0-rc.13", + "@twinkleplop/annotation": "^0.1.5", + "@twinkleplop/theme-github": "^0.2.2", + "@twinkleplop/tsx": "^0.1.5", + "@twinkleplop/typescript": "^0.1.5", "@types/node": "^24.0.0", "error-stack-parser-es": "^2.0.1", "html-to-image": "^1.11.13", @@ -71,7 +75,6 @@ "rolldown": "^1.2.4", "rolldown-plugin-dts": "^0.28.2", "seroval": "^1.6.0", - "shiki": "^4.3.1", "solid-js": "^2.0.0-rc.13", "terracotta": "2.0.0-next.9", "typescript": "^7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2a2383..b9cb2fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,18 @@ importers: '@solidjs/web': specifier: ^2.0.0-rc.13 version: 2.0.0-rc.13(solid-js@2.0.0-rc.13) + '@twinkleplop/annotation': + specifier: ^0.1.5 + version: 0.1.5 + '@twinkleplop/theme-github': + specifier: ^0.2.2 + version: 0.2.2 + '@twinkleplop/tsx': + specifier: ^0.1.5 + version: 0.1.5 + '@twinkleplop/typescript': + specifier: ^0.1.5 + version: 0.1.5 '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -53,9 +65,6 @@ importers: seroval: specifier: ^1.6.0 version: 1.6.2 - shiki: - specifier: ^4.3.1 - version: 4.4.3 solid-js: specifier: ^2.0.0-rc.13 version: 2.0.0-rc.13 @@ -526,37 +535,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@shikijs/core@4.4.3': - resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} - engines: {node: '>=20'} - - '@shikijs/engine-javascript@4.4.3': - resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} - engines: {node: '>=20'} - - '@shikijs/engine-oniguruma@4.4.3': - resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} - engines: {node: '>=20'} - - '@shikijs/langs@4.4.3': - resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} - engines: {node: '>=20'} - - '@shikijs/primitive@4.4.3': - resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} - engines: {node: '>=20'} - - '@shikijs/themes@4.4.3': - resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} - engines: {node: '>=20'} - - '@shikijs/types@4.4.3': - resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} - engines: {node: '>=20'} - - '@shikijs/vscode-textmate@10.0.2': - resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -666,6 +644,30 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@twinkleplop/annotation@0.1.5': + resolution: {integrity: sha512-UyM3PG2MEAS2nco+D7ujVB7U95wVZb+/FXE8fvrD5zIdhRe5xtzjrYag1IueGaaqhKsnhKrIwlN9wPAIcj+lHg==} + + '@twinkleplop/core@0.2.2': + resolution: {integrity: sha512-xJpsL0QdgZ0gKDkTW+S1FAzVzyTns+jKPXER52vc/69qCe6gXCpsoclhTjERnFUbkZgTBEA/iVJxaufhY8Oqrw==} + + '@twinkleplop/css@0.1.5': + resolution: {integrity: sha512-dL3uPkpsqujMYepE2uzv7jsluvhYiJrV8fhYZsEl0fMm8F0ZimO2uY4r1FsppfmzrUUsYIztQVRfe+qBh9gFPg==} + + '@twinkleplop/html@0.1.5': + resolution: {integrity: sha512-Yqg0G4N5cLwD+FS7DJlB1g+z5qGdb+ww3sxxo5ihzeg0LV065guDckans8YPXGxUT3juj/lEhv5m5ts1ljfNHw==} + + '@twinkleplop/javascript@0.1.5': + resolution: {integrity: sha512-0gynjT75JRAuR9EkD/ljRCSJ+qdWjTNXLYUptmwncsfiyHEulfHbSr0k7uo5GmxkvyOx08bqTxZdFlrBzmJN0w==} + + '@twinkleplop/theme-github@0.2.2': + resolution: {integrity: sha512-dQdR+BdV2WjjoGjZNDg0pq5+DKhuEp0QbOKgvK/q25Qw0/14XIOHi7sHBiIGwqvYiWWgGTODc2lS4pIoP7Q7NQ==} + + '@twinkleplop/tsx@0.1.5': + resolution: {integrity: sha512-0rfEU3PZ5+SZAT6dtXxIN/pxgtXk9/sB8nRI50rWcPzG1vuhdbaFfGkTUJuuxnsSWGBeXF5d48Dh5v+C0u6riA==} + + '@twinkleplop/typescript@0.1.5': + resolution: {integrity: sha512-OFrw5unMFNQOOy/TvXU7X2kMmzWn/Nn3F9HdB+kkfFyYgmMaYoyXbRIBZQjVebQqp0hy0Jzfv4DtJi3FrBefDg==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -690,21 +692,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/hast@3.0.5': - resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} - - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -825,9 +818,6 @@ packages: cpu: [x64] os: [win32] - '@ungap/structured-clone@1.3.3': - resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} - '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} @@ -1050,9 +1040,6 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1069,12 +1056,6 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} - character-entities-html4@2.1.0: - resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - - character-entities-legacy@3.0.0: - resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -1100,9 +1081,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -1126,10 +1104,6 @@ packages: supports-color: optional: true - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -1138,9 +1112,6 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1272,12 +1243,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - hast-util-to-html@9.0.5: - resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} - - hast-util-whitespace@3.0.0: - resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -1287,9 +1252,6 @@ packages: html-to-image@1.11.13: resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} - html-void-elements@3.0.0: - resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - human-id@4.2.0: resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} hasBin: true @@ -1459,9 +1421,6 @@ packages: engines: {node: '>= 16'} hasBin: true - mdast-util-to-hast@13.2.1: - resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - merge-anything@5.1.7: resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} engines: {node: '>=12.13'} @@ -1470,21 +1429,6 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - - micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - - micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - - micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1520,12 +1464,6 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} - oniguruma-parser@0.12.2: - resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} - - oniguruma-to-es@4.3.6: - resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} - outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -1629,9 +1567,6 @@ packages: engines: {node: '>=10.13.0'} hasBin: true - property-information@7.2.0: - resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} - publint@0.3.23: resolution: {integrity: sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ==} engines: {node: '>=18'} @@ -1647,15 +1582,6 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} - regex-recursion@6.0.2: - resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} - - regex-utilities@2.3.0: - resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} - - regex@6.1.0: - resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1736,10 +1662,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@4.4.3: - resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} - engines: {node: '>=20'} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1769,9 +1691,6 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} @@ -1788,9 +1707,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1848,9 +1764,6 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1871,21 +1784,6 @@ packages: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} - unist-util-is@6.0.1: - resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - - unist-util-position@5.0.0: - resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - - unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - - unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - - unist-util-visit@5.1.0: - resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} - universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -1900,12 +1798,6 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - vfile-message@4.0.3: - resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} - - vfile@6.0.3: - resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@8.2.1: resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2036,9 +1928,6 @@ packages: yuku-parser@0.8.7: resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - snapshots: '@ampproject/remapping@2.3.0': @@ -2522,46 +2411,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@shikijs/core@4.4.3': - dependencies: - '@shikijs/primitive': 4.4.3 - '@shikijs/types': 4.4.3 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - hast-util-to-html: 9.0.5 - - '@shikijs/engine-javascript@4.4.3': - dependencies: - '@shikijs/types': 4.4.3 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.6 - - '@shikijs/engine-oniguruma@4.4.3': - dependencies: - '@shikijs/types': 4.4.3 - '@shikijs/vscode-textmate': 10.0.2 - - '@shikijs/langs@4.4.3': - dependencies: - '@shikijs/types': 4.4.3 - - '@shikijs/primitive@4.4.3': - dependencies: - '@shikijs/types': 4.4.3 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - - '@shikijs/themes@4.4.3': - dependencies: - '@shikijs/types': 4.4.3 - - '@shikijs/types@4.4.3': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - - '@shikijs/vscode-textmate@10.0.2': {} - '@sindresorhus/is@4.6.0': {} '@solidjs/babel-plugin@2.0.0-rc.11(@babel/core@7.29.7(supports-color@7.2.0))': @@ -2661,6 +2510,43 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@twinkleplop/annotation@0.1.5': + dependencies: + '@twinkleplop/core': 0.2.2 + + '@twinkleplop/core@0.2.2': {} + + '@twinkleplop/css@0.1.5': + dependencies: + '@twinkleplop/core': 0.2.2 + + '@twinkleplop/html@0.1.5': + dependencies: + '@twinkleplop/core': 0.2.2 + '@twinkleplop/css': 0.1.5 + '@twinkleplop/javascript': 0.1.5 + + '@twinkleplop/javascript@0.1.5': + dependencies: + '@twinkleplop/core': 0.2.2 + '@twinkleplop/css': 0.1.5 + '@twinkleplop/html': 0.1.5 + + '@twinkleplop/theme-github@0.2.2': + dependencies: + '@twinkleplop/core': 0.2.2 + + '@twinkleplop/tsx@0.1.5': + dependencies: + '@twinkleplop/core': 0.2.2 + '@twinkleplop/javascript': 0.1.5 + '@twinkleplop/typescript': 0.1.5 + + '@twinkleplop/typescript@0.1.5': + dependencies: + '@twinkleplop/core': 0.2.2 + '@twinkleplop/javascript': 0.1.5 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -2696,22 +2582,12 @@ snapshots: '@types/estree@1.0.9': {} - '@types/hast@3.0.5': - dependencies: - '@types/unist': 3.0.3 - - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 3.0.3 - '@types/node@12.20.55': {} '@types/node@24.13.3': dependencies: undici-types: 7.18.2 - '@types/unist@3.0.3': {} - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -2772,8 +2648,6 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@ungap/structured-clone@1.3.3': {} - '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 @@ -2935,8 +2809,6 @@ snapshots: caniuse-lite@1.0.30001810: {} - ccount@2.0.1: {} - chai@6.2.2: {} chalk@4.1.2: @@ -2948,10 +2820,6 @@ snapshots: char-regex@1.0.2: {} - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - chardet@2.2.0: {} cjs-module-lexer@1.4.3: {} @@ -2983,8 +2851,6 @@ snapshots: color-name@1.1.4: {} - comma-separated-tokens@2.0.3: {} - commander@10.0.1: {} convert-source-map@2.0.0: {} @@ -3003,16 +2869,10 @@ snapshots: optionalDependencies: supports-color: 7.2.0 - dequal@2.0.3: {} - detect-indent@6.1.0: {} detect-libc@2.1.2: {} - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -3120,32 +2980,12 @@ snapshots: has-flag@4.0.0: {} - hast-util-to-html@9.0.5: - dependencies: - '@types/hast': 3.0.5 - '@types/unist': 3.0.3 - ccount: 2.0.1 - comma-separated-tokens: 2.0.3 - hast-util-whitespace: 3.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.1 - property-information: 7.2.0 - space-separated-tokens: 2.0.2 - stringify-entities: 4.0.4 - zwitch: 2.0.4 - - hast-util-whitespace@3.0.0: - dependencies: - '@types/hast': 3.0.5 - highlight.js@10.7.3: {} html-entities@2.3.3: {} html-to-image@1.11.13: {} - html-void-elements@3.0.0: {} - human-id@4.2.0: {} iconv-lite@0.7.3: @@ -3271,41 +3111,12 @@ snapshots: marked@9.1.6: {} - mdast-util-to-hast@13.2.1: - dependencies: - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.3 - devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.1 - trim-lines: 3.0.1 - unist-util-position: 5.0.0 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - merge-anything@5.1.7: dependencies: is-what: 4.1.16 merge2@1.4.1: {} - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-encode@2.0.1: {} - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.2: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -3336,14 +3147,6 @@ snapshots: obug@2.1.4: {} - oniguruma-parser@0.12.2: {} - - oniguruma-to-es@4.3.6: - dependencies: - oniguruma-parser: 0.12.2 - regex: 6.1.0 - regex-recursion: 6.0.2 - outdent@0.5.0: {} oxfmt@0.64.0: @@ -3436,8 +3239,6 @@ snapshots: prettier@2.8.8: {} - property-information@7.2.0: {} - publint@0.3.23: dependencies: '@publint/pack': 0.1.6 @@ -3456,16 +3257,6 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 - regex-recursion@6.0.2: - dependencies: - regex-utilities: 2.3.0 - - regex-utilities@2.3.0: {} - - regex@6.1.0: - dependencies: - regex-utilities: 2.3.0 - require-directory@2.1.1: {} resolve-from@5.0.0: {} @@ -3536,17 +3327,6 @@ snapshots: shebang-regex@3.0.0: {} - shiki@4.4.3: - dependencies: - '@shikijs/core': 4.4.3 - '@shikijs/engine-javascript': 4.4.3 - '@shikijs/engine-oniguruma': 4.4.3 - '@shikijs/langs': 4.4.3 - '@shikijs/themes': 4.4.3 - '@shikijs/types': 4.4.3 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.5 - siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -3570,8 +3350,6 @@ snapshots: source-map-js@1.2.1: {} - space-separated-tokens@2.0.2: {} - spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -3589,11 +3367,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - stringify-entities@4.0.4: - dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -3642,8 +3415,6 @@ snapshots: dependencies: is-number: 7.0.0 - trim-lines@3.0.1: {} - tslib@2.8.1: optional: true @@ -3676,29 +3447,6 @@ snapshots: unicode-emoji-modifier-base@1.0.0: {} - unist-util-is@6.0.1: - dependencies: - '@types/unist': 3.0.3 - - unist-util-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-visit@5.1.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - universalify@0.1.2: {} update-browserslist-db@1.3.2(browserslist@4.28.9): @@ -3709,16 +3457,6 @@ snapshots: validate-npm-package-name@5.0.1: {} - vfile-message@4.0.3: - dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 - - vfile@6.0.3: - dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.3 - vite@8.2.1(@types/node@24.13.3): dependencies: lightningcss: 1.33.0 @@ -3830,5 +3568,3 @@ snapshots: '@yuku-parser/binding-linux-x64-musl': 0.8.7 '@yuku-parser/binding-win32-arm64': 0.8.7 '@yuku-parser/binding-win32-x64': 0.8.7 - - zwitch@2.0.4: {} diff --git a/rolldown.config.js b/rolldown.config.js index ae3e7f4..9f834af 100644 --- a/rolldown.config.js +++ b/rolldown.config.js @@ -56,28 +56,6 @@ function css(server = false) { }; } -function assetUrl() { - return { - name: 'devtools-asset-url', - async resolveId(source, importer) { - if (!source.endsWith('?url')) return null; - const resolved = await this.resolve(source.slice(0, -4), importer, { - skipSelf: true, - }); - return resolved ? `${resolved.id}?url` : null; - }, - load(id) { - if (!id.endsWith('?url')) return null; - const referenceId = this.emitFile({ - type: 'asset', - name: 'onig.wasm', - source: readFileSync(id.slice(0, -4)), - }); - return `export default import.meta.ROLLDOWN_FILE_URL_${referenceId};`; - }, - }; -} - function packageVersion() { const version = JSON.parse(readFileSync(new URL('./package.json', import.meta.url))).version; return { @@ -112,7 +90,7 @@ function config({ input, entryFileNames, chunkFileNames, generate, server = fals sourcemap: true, }, external, - plugins: [css(server), assetUrl(), packageVersion(), solid(generate)], + plugins: [css(server), packageVersion(), solid(generate)], }; } diff --git a/src/dev-toolbar/error-viewer/CodeView.tsx b/src/dev-toolbar/error-viewer/CodeView.tsx index f2f6a1d..9093615 100644 --- a/src/dev-toolbar/error-viewer/CodeView.tsx +++ b/src/dev-toolbar/error-viewer/CodeView.tsx @@ -1,97 +1,49 @@ // @refresh skip -import { getSingletonHighlighterCore, type HighlighterCore } from 'shiki/core'; -import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; -import langJS from 'shiki/langs/javascript.mjs'; -import langJSX from 'shiki/langs/jsx.mjs'; -import langTSX from 'shiki/langs/tsx.mjs'; -import langTS from 'shiki/langs/typescript.mjs'; -import url from 'shiki/onig.wasm?url'; -import darkPlus from 'shiki/themes/dark-plus.mjs'; -import { createEffect, createMemo } from 'solid-js'; +import { createEffect, createMemo, createSignal } from 'solid-js'; import type { JSX } from '@solidjs/web'; - -let HIGHLIGHTER: HighlighterCore; - -async function loadHighlighter() { - if (!HIGHLIGHTER) { - HIGHLIGHTER = await getSingletonHighlighterCore({ - engine: createOnigurumaEngine(fetch(url)), - themes: [darkPlus], - langs: [langJS, langJSX, langTS, langTSX], - }); - } - return HIGHLIGHTER; -} +import { codeToHtml } from './highlight.js'; +import '@twinkleplop/theme-github/dark'; export interface CodeViewProps { fileName: string; content: string; line: number; + /** Column of the error, so the marker can point at one word instead of the line. */ + column?: number; } -const RANGE = 15; +/** + * Lines kept on each side of the frame. + * + * The view scrolls about this far, so the snippet holds what the reader can + * scroll to and nothing beyond it. + */ +const RANGE = 25; export function CodeView(props: CodeViewProps): JSX.Element | null { - const lines = () => - props.content.split('\n').map((item, index) => ({ - index: index + 1, - line: item, - })); - - const minLine = () => Math.max(props.line - (1 + RANGE), 0); - const maxLine = () => Math.min(props.line + RANGE, lines().length - 1); - - let ref: HTMLDivElement | undefined; - - const data = createMemo(async () => { - const value = lines() - .slice(minLine(), maxLine()) - .map((item) => item.line) - .join('\n'); - const highlighter = await loadHighlighter(); - const fileExtension = props.fileName.split(/[#?]/)[0]!.split('.').pop()?.trim(); - // Only these grammars are loaded — anything else would make shiki - // throw. Fall back to plain JS highlighting for unknown sources. - let lang: 'js' | 'jsx' | 'ts' | 'tsx' = 'js'; - if ( - fileExtension === 'jsx' || - fileExtension === 'ts' || - fileExtension === 'tsx' || - fileExtension === 'js' - ) { - lang = fileExtension; - } - return highlighter.codeToHtml(value, { - theme: 'dark-plus', - lang, - }); - }); + const [element, setElement] = createSignal(); + + const html = createMemo(() => + codeToHtml({ + fileName: props.fileName, + content: props.content, + line: props.line, + column: props.column, + range: RANGE, + }), + ); + // The frame can sit anywhere in the file, so the view opens on it. Scrolling + // the box itself leaves the page where it is. createEffect( - () => data(), - (result) => { - if (ref && result) { - ref.innerHTML = result; - - const lines = ref.querySelectorAll('span[class="line"]'); - - for (let i = 0, len = lines.length; i < len; i++) { - const el = lines[i] as HTMLElement; - if (props.line - minLine() - 1 === i) { - el.dataset.solidErrorViewerErrorLine = ''; - } - } - } + () => ({ view: element(), code: html() }), + ({ view }) => { + if (!view) return; + const focused = view.querySelector('.focus') as HTMLElement | null; + if (!focused) return; + view.scrollTop = Math.max(focused.offsetTop - view.clientHeight / 2, 0); }, ); - return ( -
- ); + return
; } diff --git a/src/dev-toolbar/error-viewer/env.d.ts b/src/dev-toolbar/error-viewer/env.d.ts deleted file mode 100644 index 7636b5d..0000000 --- a/src/dev-toolbar/error-viewer/env.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.wasm?url' { - const url: string; - export default url; -} diff --git a/src/dev-toolbar/error-viewer/highlight.ts b/src/dev-toolbar/error-viewer/highlight.ts new file mode 100644 index 0000000..dc62e2d --- /dev/null +++ b/src/dev-toolbar/error-viewer/highlight.ts @@ -0,0 +1,68 @@ +import { err, focus } from '@twinkleplop/annotation'; +import { language as tsxLanguage } from '@twinkleplop/tsx'; +import { language as typescriptLanguage } from '@twinkleplop/typescript'; +import { buildSnippet, type SnippetInput } from './marker.js'; + +const options = { + annotation: { + plugins: [focus, err], + // The markers are written by the panel, not by the reader, so a bad one is + // a bug here rather than something the app's console should carry. + on_error: () => {}, + }, +}; + +const highlighters = { + ts: typescriptLanguage(options), + tsx: tsxLanguage(options), +}; + +/** The grammar for a file. The TSX grammar also covers plain JavaScript and JSX. */ +function highlighterFor(fileName: string) { + const extension = fileName.split(/[#?]/)[0]!.split('.').pop()?.trim(); + return extension === 'ts' || extension === 'mts' || extension === 'cts' + ? highlighters.ts + : highlighters.tsx; +} + +const OUTSIDE = 'data-outside'; + +/** + * Removes the lines the window leaves out. + * + * The highlighter writes one row per line and joins them with newlines, and a + * row never holds a newline of its own, so the rows split cleanly. The wrapper + * around them is kept whatever the first shown line is. + */ +function dropOutsideRows(html: string): string { + const open = html.indexOf(''); + const close = html.lastIndexOf(''); + if (open < 0 || close < 0) return html; + + const start = open + ''.length; + const rows = html + .slice(start, close) + .split('\n') + .filter((row) => !row.includes(OUTSIDE)); + + return html.slice(0, start) + rows.join('\n') + html.slice(close); +} + +export interface CodeHtmlInput extends SnippetInput { + fileName: string; +} + +/** The window around a frame, highlighted as part of the whole file. */ +export function codeToHtml(input: CodeHtmlInput): string { + const { source, offset, first, last } = buildSnippet(input); + + const html = highlighterFor(input.fileName)(source, { + line_numbers: { start: 1 }, + line: (_rendered: number, sourceLine: number) => { + const line = sourceLine - offset; + return line < first || line > last ? { attrs: { [OUTSIDE]: '' } } : undefined; + }, + }); + + return dropOutsideRows(html); +} diff --git a/src/dev-toolbar/error-viewer/index.tsx b/src/dev-toolbar/error-viewer/index.tsx index 966950c..98f7d7e 100644 --- a/src/dev-toolbar/error-viewer/index.tsx +++ b/src/dev-toolbar/error-viewer/index.tsx @@ -172,6 +172,7 @@ function StackFramesContent(props: StackFramesContentProps) {
diff --git a/src/dev-toolbar/error-viewer/marker.test.ts b/src/dev-toolbar/error-viewer/marker.test.ts new file mode 100644 index 0000000..30285d2 --- /dev/null +++ b/src/dev-toolbar/error-viewer/marker.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { codeToHtml } from './highlight.js'; +import { buildSnippet, wordAt } from './marker.js'; + +describe('wordAt', () => { + const line = ' throw new Error("boom");'; + + it('reads a 1-based column', () => { + expect(wordAt(line, 3)).toBe('throw'); + }); + + it('reads a 0-based column', () => { + expect(wordAt(line, 8)).toBe('new'); + }); + + it('keeps the whole word from any position inside it', () => { + expect(wordAt(line, 13)).toBe('Error'); + }); + + it('has no word for punctuation', () => { + expect(wordAt(line, 26)).toBeUndefined(); + }); + + // A digit is part of a number, and a number reads worse marked than its line. + it('has no word for a number', () => { + expect(wordAt('const total = 42;', 15)).toBeUndefined(); + }); + + it('has no word without a column', () => { + expect(wordAt(line, undefined)).toBeUndefined(); + }); +}); + +const FILE = [ + '/**', + ' * A block comment the window would cut in half.', + ' * More of it.', + ' */', + 'export function boom() {', + ' throw new Error("bang");', + '}', + '', + 'const tail = 1;', +].join('\n'); + +describe('buildSnippet', () => { + // Both markers are dropped from the output, and `:N` counts dropped lines. + it('marks the line and the word the column points at', () => { + const { source } = buildSnippet({ content: FILE, line: 6, column: 3, range: 1 }); + expect(source.split('\n').slice(0, 2)).toEqual(['// [!focus :8]', '// [!err =throw :8]']); + }); + + it('marks the line alone without a column', () => { + const { source } = buildSnippet({ content: FILE, line: 6, range: 1 }); + expect(source.split('\n')[0]).toBe('// [!focus :7]'); + }); + + it('keeps the whole file and names the window around the frame', () => { + const { source, first, last, offset } = buildSnippet({ + content: FILE, + line: 6, + column: 3, + range: 1, + }); + + expect(source).toContain('A block comment the window would cut in half.'); + expect({ first, last, offset }).toEqual({ first: 5, last: 7, offset: 2 }); + }); + + it('stops the window at the ends of the file', () => { + const { first, last } = buildSnippet({ content: FILE, line: 5, range: 50 }); + expect({ first, last }).toEqual({ first: 1, last: 9 }); + }); +}); + +describe('codeToHtml', () => { + // Cutting the file at the window would leave the highlighter inside a block + // comment it never saw open, and every line after it would read as comment. + it('highlights a window under an unclosed-looking comment', () => { + const html = codeToHtml({ + fileName: 'boom.ts', + content: FILE, + line: 6, + column: 3, + range: 1, + }); + + expect(html).toContain('throw'); + expect(html).not.toContain('A block comment'); + }); + + // The numbers are the file's own, and only the window's lines are kept. + it('keeps the window and numbers it by the file', () => { + const html = codeToHtml({ fileName: 'boom.ts', content: FILE, line: 6, column: 3, range: 1 }); + const numbers = [...html.matchAll(/(\d+)<\/span>/g)].map((match) => + Number(match[1]), + ); + + expect(numbers).toEqual([5, 6, 7]); + }); + + it('puts the frame in focus and marks its word', () => { + const html = codeToHtml({ fileName: 'boom.ts', content: FILE, line: 6, column: 3, range: 1 }); + + expect(html).toContain('class="l focus"'); + expect(html).toContain(''); + }); +}); diff --git a/src/dev-toolbar/error-viewer/marker.ts b/src/dev-toolbar/error-viewer/marker.ts new file mode 100644 index 0000000..2672b59 --- /dev/null +++ b/src/dev-toolbar/error-viewer/marker.ts @@ -0,0 +1,82 @@ +/** + * Prepares the source the code view highlights. + * + * The panel shows a window around a stack frame, but it highlights the whole + * file and drops the lines outside the window afterwards. A window is a blunt + * cut: starting it inside a block comment or a template literal leaves the + * highlighter reading an opening it never saw, and the colours fall apart from + * there. + * + * Twinkleplop reads directives out of comments in the source it highlights and + * drops a line that holds nothing else, so the markers sit on their own lines + * above the file and the code is never edited to carry them. + */ +const WORD = /[\p{L}\p{N}_$]/u; + +/** + * The word at a column. + * + * A column is 1-based in a stack frame and 0-based in a source map, and a + * frame arrives from either, so both readings are tried. + */ +export function wordAt(line: string | undefined, column: number | undefined): string | undefined { + if (!line || column === undefined) return undefined; + for (const candidate of [column - 1, column]) { + if (candidate < 0 || candidate >= line.length) continue; + if (!WORD.test(line[candidate]!)) continue; + let start = candidate; + while (start > 0 && WORD.test(line[start - 1]!)) start--; + let end = candidate; + while (end < line.length && WORD.test(line[end]!)) end++; + const word = line.slice(start, end); + // A word that starts with a digit is part of a number, which reads worse + // marked than the whole line does. + if (!/^\p{N}/u.test(word)) return word; + } + return undefined; +} + +export interface SnippetInput { + content: string; + /** 1-based line of the frame inside the file. */ + line: number; + /** Column of the frame, when it carries one. */ + column?: number; + /** How many lines to keep on each side of the frame. */ + range: number; +} + +export interface Snippet { + /** The whole file, with the markers above it. */ + source: string; + /** Lines the markers take up, which sit above the file's first line. */ + offset: number; + /** First line of the file to show. */ + first: number; + /** Last line of the file to show. */ + last: number; +} + +export function buildSnippet(input: SnippetInput): Snippet { + const { content, line, column, range } = input; + const lines = content.split('\n'); + + const markers: string[] = []; + if (line >= 1 && line <= lines.length) { + // A line reference counts the lines that are dropped, so the frame's line + // sits below the markers by however many there are. + const word = wordAt(lines[line - 1], column); + const target = line + (word ? 2 : 1); + markers.push(`// [!focus :${target}]`); + // The frame points at one word, which the error mark sits under while the + // line around it stays in focus. + if (word) markers.push(`// [!err =${word} :${target}]`); + } + + return { + source: [...markers, content].join('\n'), + offset: markers.length, + first: Math.max(line - range, 1), + last: Math.min(line + range, lines.length), + }; +} diff --git a/src/dev-toolbar/error-viewer/styles.css b/src/dev-toolbar/error-viewer/styles.css index 09f44e1..394cd88 100644 --- a/src/dev-toolbar/error-viewer/styles.css +++ b/src/dev-toolbar/error-viewer/styles.css @@ -246,27 +246,41 @@ height: 100%; } -[data-solid-error-viewer-code-view] > .shiki { +[data-solid-error-viewer-code-view] > .twinkleplop { float: left; min-width: 100%; overflow-x: auto; + margin: 0; font-size: 0.75rem; line-height: 1rem; - counter-reset: step; - counter-increment: step calc(var(--error-viewer-code-view-start, 1) - 1); - background-color: transparent !important; + background-color: transparent; } -[data-solid-error-viewer-code-view] > .shiki .line::before { - content: counter(step); - counter-increment: step; - width: 1rem; - margin-right: 1.5rem; +/* The highlighter numbers the lines, so the panel only has to place them. */ +[data-solid-error-viewer-code-view] .ln { display: inline-block; + width: 2.5rem; + margin-right: 1rem; text-align: right; color: rgba(115, 138, 148, 0.4); } -[data-solid-error-viewer-error-line] { +/* The `focus` directive marks the line the frame points at. */ +[data-solid-error-viewer-code-view] .l.focus { + display: inline-block; + min-width: 100%; background-color: oklch(0.63 0.21 25 / 0.25); } + +/* The highlighter tags the block when a line is in focus, so the lines around + it can step back without the panel tracking which line that is. */ +[data-solid-error-viewer-code-view] .has-focus .l:not(.focus) { + opacity: 0.65; +} + +/* The `err` directive marks the word inside it, when the frame has a column. */ +[data-solid-error-viewer-code-view] .tok.error { + border-radius: 0.125rem; + background-color: oklch(0.63 0.21 25 / 0.35); + box-shadow: 0 1px 0 0 oklch(0.63 0.21 25 / 0.9); +} diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index cf69381..1ddf696 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -41,6 +41,32 @@ test('captures client errors', async ({ page }) => { // Frames come from the parsed stack, so an empty list means the parser broke. const frames = page.locator('[data-solid-error-viewer-stack-frame]'); await expect(frames.first()).toContainText('app.tsx'); + + // The source is highlighted, and the frame's line and word are marked by the + // directives the panel writes above the snippet. + const code = page.locator('[data-solid-error-viewer-code-view]').first(); + await expect(code.locator('.twinkleplop')).toBeVisible(); + await expect(code.locator('.l.focus')).toHaveCount(1); + await expect(code.locator('.l.focus')).toContainText('client boom'); + await expect(code.locator('.tok.error')).toHaveCount(1); + + // The window around the frame is what the view scrolls through: lines near + // it are there, lines far from it are not. + await expect(code).toContainText('emitServerFunctionResponse'); + await expect(code).not.toContainText('__disposeToolbar'); + + // It opens on the frame instead of the top of the file. + expect( + await code.evaluate((view) => { + const focused = view.querySelector('.focus')!.getBoundingClientRect(); + const box = view.getBoundingClientRect(); + return focused.top >= box.top && focused.bottom <= box.bottom; + }), + ).toBe(true); + + // The markers themselves never reach the reader. + await expect(code).not.toContainText('[!focus'); + await expect(code).not.toContainText('[!err'); }); test('shows server-function calls', async ({ page }) => {