diff --git a/.vscode/settings.json b/.vscode/settings.json index c6204df7d0..3601631402 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -44,5 +44,6 @@ "scss.scannerExclude": [ "**/.git", "**/bower_components" - ] + ], + "eslint.useFlatConfig": true } diff --git a/package.json b/package.json index ac5f177aff..459ee2e497 100644 --- a/package.json +++ b/package.json @@ -61,10 +61,12 @@ "date-fns-tz": "^2.0.1", "focus-trap": "^7.6.0", "focus-trap-vue": "^4.0.3", + "lodash-es": "^4.17.21", "nanoid": "^5.0.9", "sortablejs": "^1.15.3", "swrv": "^1.0.4", "v-calendar": "^3.1.2", + "virtua": "^0.39.3", "vue-draggable-next": "^2.2.1" }, "peerDependencies": { @@ -86,6 +88,7 @@ "@semantic-release/git": "^10.0.1", "@stylistic/stylelint-plugin": "^3.1.1", "@types/inquirer": "^9.0.7", + "@types/lodash-es": "^4.17.12", "@types/node": "^20.17.1", "@types/sortablejs": "^1.15.8", "@vitejs/plugin-vue": "^5.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f32e689e5..37ab623a0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: focus-trap-vue: specifier: ^4.0.3 version: 4.0.3(focus-trap@7.6.0)(vue@3.5.12(typescript@5.6.3)) + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 nanoid: specifier: ^5.0.9 version: 5.0.9 @@ -44,6 +47,9 @@ importers: v-calendar: specifier: ^3.1.2 version: 3.1.2(@popperjs/core@2.11.8)(vue@3.5.12(typescript@5.6.3)) + virtua: + specifier: ^0.39.3 + version: 0.39.3(vue@3.5.12(typescript@5.6.3)) vue-draggable-next: specifier: ^2.2.1 version: 2.2.1(sortablejs@1.15.3)(vue@3.5.12(typescript@5.6.3)) @@ -87,6 +93,9 @@ importers: '@types/inquirer': specifier: ^9.0.7 version: 9.0.7 + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 '@types/node': specifier: ^20.17.1 version: 20.17.6 @@ -971,7 +980,6 @@ packages: '@evilmartians/lefthook@1.8.2': resolution: {integrity: sha512-SZdQk3W9q7tcJwnSwEMUubQqVIK7SHxv52hEAnV7o3nPI+xKcmd+rN0hZIJg07wjBaJRAjzdvoQySKQQYPW5Qw==} - cpu: [x64, arm64, ia32] os: [darwin, linux, win32] hasBin: true @@ -1358,6 +1366,9 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + '@types/lodash@4.17.5': resolution: {integrity: sha512-MBIOHVZqVqgfro1euRDWX7OO0fBVUUMrN6Pwm8LQsz8cWhEpihlvR70ENj3f40j58TNxZaWv2ndSkInykNBBJw==} @@ -1403,51 +1414,51 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@8.19.0': - resolution: {integrity: sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==} + '@typescript-eslint/eslint-plugin@8.19.1': + resolution: {integrity: sha512-tJzcVyvvb9h/PB96g30MpxACd9IrunT7GF9wfA9/0TJ1LxGOJx1TdPzSbBBnNED7K9Ka8ybJsnEpiXPktolTLg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.8.0' - '@typescript-eslint/parser@8.19.0': - resolution: {integrity: sha512-6M8taKyOETY1TKHp0x8ndycipTVgmp4xtg5QpEZzXxDhNvvHOJi5rLRkLr8SK3jTgD5l4fTlvBiRdfsuWydxBw==} + '@typescript-eslint/parser@8.19.1': + resolution: {integrity: sha512-67gbfv8rAwawjYx3fYArwldTQKoYfezNUT4D5ioWetr/xCrxXxvleo3uuiFuKfejipvq+og7mjz3b0G2bVyUCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.8.0' - '@typescript-eslint/scope-manager@8.19.0': - resolution: {integrity: sha512-hkoJiKQS3GQ13TSMEiuNmSCvhz7ujyqD1x3ShbaETATHrck+9RaDdUbt+osXaUuns9OFwrDTTrjtwsU8gJyyRA==} + '@typescript-eslint/scope-manager@8.19.1': + resolution: {integrity: sha512-60L9KIuN/xgmsINzonOcMDSB8p82h95hoBfSBtXuO4jlR1R9L1xSkmVZKgCPVfavDlXihh4ARNjXhh1gGnLC7Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/type-utils@8.19.0': - resolution: {integrity: sha512-TZs0I0OSbd5Aza4qAMpp1cdCYVnER94IziudE3JU328YUHgWu9gwiwhag+fuLeJ2LkWLXI+F/182TbG+JaBdTg==} + '@typescript-eslint/type-utils@8.19.1': + resolution: {integrity: sha512-Rp7k9lhDKBMRJB/nM9Ksp1zs4796wVNyihG9/TU9R6KCJDNkQbc2EOKjrBtLYh3396ZdpXLtr/MkaSEmNMtykw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.8.0' - '@typescript-eslint/types@8.19.0': - resolution: {integrity: sha512-8XQ4Ss7G9WX8oaYvD4OOLCjIQYgRQxO+qCiR2V2s2GxI9AUpo7riNwo6jDhKtTcaJjT8PY54j2Yb33kWtSJsmA==} + '@typescript-eslint/types@8.19.1': + resolution: {integrity: sha512-JBVHMLj7B1K1v1051ZaMMgLW4Q/jre5qGK0Ew6UgXz1Rqh+/xPzV1aW581OM00X6iOfyr1be+QyW8LOUf19BbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.19.0': - resolution: {integrity: sha512-WW9PpDaLIFW9LCbucMSdYUuGeFUz1OkWYS/5fwZwTA+l2RwlWFdJvReQqMUMBw4yJWJOfqd7An9uwut2Oj8sLw==} + '@typescript-eslint/typescript-estree@8.19.1': + resolution: {integrity: sha512-jk/TZwSMJlxlNnqhy0Eod1PNEvCkpY6MXOXE/WLlblZ6ibb32i2We4uByoKPv1d0OD2xebDv4hbs3fm11SMw8Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.8.0' - '@typescript-eslint/utils@8.19.0': - resolution: {integrity: sha512-PTBG+0oEMPH9jCZlfg07LCB2nYI0I317yyvXGfxnvGvw4SHIOuRnQ3kadyyXY6tGdChusIHIbM5zfIbp4M6tCg==} + '@typescript-eslint/utils@8.19.1': + resolution: {integrity: sha512-IxG5gLO0Ne+KaUc8iW1A+XuKLd63o4wlbI1Zp692n1xojCl/THvgIKXJXBZixTh5dd5+yTJ/VXH7GJaaw21qXA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.8.0' - '@typescript-eslint/visitor-keys@8.19.0': - resolution: {integrity: sha512-mCFtBbFBJDCNCWUl5y6sZSCHXw1DEFEk3c/M3nRK2a4XUB8StGFtmcEMizdjKuBzB6e/smJAAWYug3VrdLMr1w==} + '@typescript-eslint/visitor-keys@8.19.1': + resolution: {integrity: sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.2.1': @@ -4837,11 +4848,11 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - ts-api-utils@1.3.0: - resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} - engines: {node: '>=16'} + ts-api-utils@2.0.0: + resolution: {integrity: sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==} + engines: {node: '>=18.12'} peerDependencies: - typescript: '>=4.2.0' + typescript: '>=4.8.4' tsc-alias@1.8.10: resolution: {integrity: sha512-Ibv4KAWfFkFdKJxnWfVtdOmB0Zi1RJVxcbPGiCDsFpCQSsmpWyuzHG3rQyI5YkobWwxFPEyQfu1hdo4qLG2zPw==} @@ -4900,8 +4911,8 @@ packages: resolution: {integrity: sha512-8WbVAQAUlENo1q3c3zZYuy5k9VzBQvp8AX9WOtbvyWlLM1v5JaSRmjubLjzHF4JFtptjH/5c/i95yaElvcjC0A==} engines: {node: '>= 0.4'} - typescript-eslint@8.19.0: - resolution: {integrity: sha512-Ni8sUkVWYK4KAcTtPjQ/UTiRk6jcsuDhPpxULapUDi8A/l8TSBk+t1GtJA1RsCzIJg0q6+J7bf35AwQigENWRQ==} + typescript-eslint@8.19.1: + resolution: {integrity: sha512-LKPUQpdEMVOeKluHi8md7rwLcoXHhwvWp3x+sJkMuq3gGm9yaYJtPo8sRZSblMFJ5pcOGCAak/scKf1mvZDlQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -5007,6 +5018,26 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + virtua@0.39.3: + resolution: {integrity: sha512-Ep3aiJXSGPm1UUniThr5mGDfG0upAleP7pqQs5mvvCgM1wPhII1ZKa7eNCWAJRLkC+InpXKokKozyaaj/aMYOQ==} + peerDependencies: + react: '>=16.14.0' + react-dom: '>=16.14.0' + solid-js: '>=1.0' + svelte: '>=5.0' + vue: '>=3.2' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + vite-hot-client@0.2.3: resolution: {integrity: sha512-rOGAV7rUlUHX89fP2p2v0A2WWvV3QMX2UYq0fRqsWSvFvev4atHWqjwGoKaZT1VTKyLGk533ecu3eyd0o59CAg==} peerDependencies: @@ -6168,7 +6199,7 @@ snapshots: eslint-plugin-vue: 9.32.0(eslint@9.17.0(jiti@1.21.6)) globals: 15.14.0 jsonc-eslint-parser: 2.4.0 - typescript-eslint: 8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) + typescript-eslint: 8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) vue-eslint-parser: 9.4.3(eslint@9.17.0(jiti@1.21.6)) transitivePeerDependencies: - '@eslint/json' @@ -6509,7 +6540,7 @@ snapshots: '@stylistic/eslint-plugin@2.12.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3)': dependencies: - '@typescript-eslint/utils': 8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) + '@typescript-eslint/utils': 8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) eslint: 9.17.0(jiti@1.21.6) eslint-visitor-keys: 4.2.0 espree: 10.3.0 @@ -6549,6 +6580,10 @@ snapshots: '@types/linkify-it@5.0.0': {} + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.5 + '@types/lodash@4.17.5': {} '@types/markdown-it@14.1.2': @@ -6591,81 +6626,81 @@ snapshots: '@types/node': 20.17.6 optional: true - '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3)': + '@typescript-eslint/eslint-plugin@8.19.1(@typescript-eslint/parser@8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) - '@typescript-eslint/scope-manager': 8.19.0 - '@typescript-eslint/type-utils': 8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) - '@typescript-eslint/utils': 8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.19.0 + '@typescript-eslint/parser': 8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) + '@typescript-eslint/scope-manager': 8.19.1 + '@typescript-eslint/type-utils': 8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) + '@typescript-eslint/utils': 8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.19.1 eslint: 9.17.0(jiti@1.21.6) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.6.3) + ts-api-utils: 2.0.0(typescript@5.6.3) typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3)': + '@typescript-eslint/parser@8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3)': dependencies: - '@typescript-eslint/scope-manager': 8.19.0 - '@typescript-eslint/types': 8.19.0 - '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.19.0 + '@typescript-eslint/scope-manager': 8.19.1 + '@typescript-eslint/types': 8.19.1 + '@typescript-eslint/typescript-estree': 8.19.1(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.19.1 debug: 4.3.6(supports-color@8.1.1) eslint: 9.17.0(jiti@1.21.6) typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.19.0': + '@typescript-eslint/scope-manager@8.19.1': dependencies: - '@typescript-eslint/types': 8.19.0 - '@typescript-eslint/visitor-keys': 8.19.0 + '@typescript-eslint/types': 8.19.1 + '@typescript-eslint/visitor-keys': 8.19.1 - '@typescript-eslint/type-utils@8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3)': + '@typescript-eslint/type-utils@8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3)': dependencies: - '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.6.3) - '@typescript-eslint/utils': 8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 8.19.1(typescript@5.6.3) + '@typescript-eslint/utils': 8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) debug: 4.3.6(supports-color@8.1.1) eslint: 9.17.0(jiti@1.21.6) - ts-api-utils: 1.3.0(typescript@5.6.3) + ts-api-utils: 2.0.0(typescript@5.6.3) typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.19.0': {} + '@typescript-eslint/types@8.19.1': {} - '@typescript-eslint/typescript-estree@8.19.0(typescript@5.6.3)': + '@typescript-eslint/typescript-estree@8.19.1(typescript@5.6.3)': dependencies: - '@typescript-eslint/types': 8.19.0 - '@typescript-eslint/visitor-keys': 8.19.0 + '@typescript-eslint/types': 8.19.1 + '@typescript-eslint/visitor-keys': 8.19.1 debug: 4.3.6(supports-color@8.1.1) fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.6.3) + ts-api-utils: 2.0.0(typescript@5.6.3) typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3)': + '@typescript-eslint/utils@8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3)': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@1.21.6)) - '@typescript-eslint/scope-manager': 8.19.0 - '@typescript-eslint/types': 8.19.0 - '@typescript-eslint/typescript-estree': 8.19.0(typescript@5.6.3) + '@typescript-eslint/scope-manager': 8.19.1 + '@typescript-eslint/types': 8.19.1 + '@typescript-eslint/typescript-estree': 8.19.1(typescript@5.6.3) eslint: 9.17.0(jiti@1.21.6) typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.19.0': + '@typescript-eslint/visitor-keys@8.19.1': dependencies: - '@typescript-eslint/types': 8.19.0 + '@typescript-eslint/types': 8.19.1 eslint-visitor-keys: 4.2.0 '@ungap/structured-clone@1.2.1': {} @@ -10263,7 +10298,7 @@ snapshots: trim-lines@3.0.1: {} - ts-api-utils@1.3.0(typescript@5.6.3): + ts-api-utils@2.0.0(typescript@5.6.3): dependencies: typescript: 5.6.3 @@ -10339,11 +10374,11 @@ snapshots: typed-array-buffer: 1.0.2 typed-array-byte-offset: 1.0.2 - typescript-eslint@8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3): + typescript-eslint@8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) - '@typescript-eslint/parser': 8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) - '@typescript-eslint/utils': 8.19.0(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) + '@typescript-eslint/eslint-plugin': 8.19.1(@typescript-eslint/parser@8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3))(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) + '@typescript-eslint/parser': 8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) + '@typescript-eslint/utils': 8.19.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.6.3) eslint: 9.17.0(jiti@1.21.6) typescript: 5.6.3 transitivePeerDependencies: @@ -10453,6 +10488,10 @@ snapshots: '@types/unist': 3.0.2 vfile-message: 4.0.2 + virtua@0.39.3(vue@3.5.12(typescript@5.6.3)): + optionalDependencies: + vue: 3.5.12(typescript@5.6.3) + vite-hot-client@0.2.3(vite@6.0.3(@types/node@20.17.6)(jiti@1.21.6)(sass-embedded@1.79.5)(sass@1.80.7)(yaml@2.4.5)): dependencies: vite: 6.0.3(@types/node@20.17.6)(jiti@1.21.6)(sass-embedded@1.79.5)(sass@1.80.7)(yaml@2.4.5) diff --git a/sandbox/pages/SandboxCodeBlock.vue b/sandbox/pages/SandboxCodeBlock.vue index c2aa143a9f..e0527706b2 100644 --- a/sandbox/pages/SandboxCodeBlock.vue +++ b/sandbox/pages/SandboxCodeBlock.vue @@ -70,7 +70,7 @@ -
+ +
+
- -
 directly, the  here acts as a workaround for
+        a potential bug of Vue itself. Because the  fails to render its scoped slots
+        when it's wrapped inside a 
 element.
+      -->
+      
+      
-        
-          
-            {{ line }}
-          
-        
+          {{ line }}
+        
+        
         
-      
+
-
+      
-        
-          
-            {{ line }}
-          
-        
+          {{ line }}
+        
+        
         
-      
- +
diff --git a/src/utilities/codeBlockHelpers.cy.ts b/src/utilities/codeBlockHelpers.cy.ts new file mode 100644 index 0000000000..18fda7a17d --- /dev/null +++ b/src/utilities/codeBlockHelpers.cy.ts @@ -0,0 +1,229 @@ +import { getMatchingLineNumbers, escapeInnerHTML, escapeHTMLIfNeeded, normalizeHighlightedLines, highlightMatchingChars, wrapMark } from './codeBlockHelpers' + +const code = `{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "node", + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + "jsx": "preserve" + }, + "include": [ + "./src", + "./types", + "./particularly-long-value-that-will-inadvertently-cause-scrolling-for-narrower-containers" + ], + "markup": "
Hello & Hi.
", +} +` + +describe('getMatchingLineNumbers', () => { + it('gets matched line numbers by exact match', () => { + expect(getMatchingLineNumbers(code, 'true', false)).eql([7, 8, 9, 10, 11, 12, 13]) + expect(getMatchingLineNumbers(code, ' ', false)).eql([2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]) + expect(getMatchingLineNumbers(code, '<', false)).eql([21]) + expect(getMatchingLineNumbers(code, 'kong', false)).eql([]) + }) + + it('gets matched line numbers by exact match with case sensitivity', () => { + expect(getMatchingLineNumbers(code, 'TRUE', false)).eql([]) + }) + + it('gets matched line numbers by regexp match', () => { + expect(getMatchingLineNumbers(code, 'tru.', true)).eql([7, 8, 9, 10, 11, 12, 13]) + expect(getMatchingLineNumbers(code, '[ ]', true)).eql([2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]) + expect(getMatchingLineNumbers(code, '.', true)).eql([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]) + expect(getMatchingLineNumbers(code, '<[^>]+>', true)).eql([21]) + expect(getMatchingLineNumbers(code, 'kong', true)).eql([]) + }) + + it('gets matched line numbers by regexp match with case sensitivity', () => { + expect(getMatchingLineNumbers(code, 'TRU.', true)).eql([]) + }) +}) + +describe('escapeInnerHTML', () => { + it('escapes only < and &', () => { + expect(escapeInnerHTML('')).eq('<script>alert("hi")</script>') + expect(escapeInnerHTML('
← & →
')).eq('<div>&larr; & &rarr;</div>') + expect(escapeInnerHTML('foo')).eq('foo') + expect(escapeInnerHTML('')).eq('') + }) +}) + +describe('escapeHTMLIfNeeded', () => { + it('escapes only < and &', () => { + const regex = /<&>/ + const escape = cy.spy((v: string) => v) + + expect(escapeHTMLIfNeeded('{ foo: "bar" }', regex, escape)).eq('{ foo: "bar" }') + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(escape).not.have.been.called + }) + + it('escapes only < and & by default', () => { + expect(escapeHTMLIfNeeded('')).eq('<script>alert("hi")</script>') + expect(escapeHTMLIfNeeded('
← & →
')).eq('<div>&larr; & &rarr;</div>') + expect(escapeHTMLIfNeeded('foo')).eq('foo') + expect(escapeHTMLIfNeeded('')).eq('') + }) +}) + +describe('highlightMatchingChars', () => { + it('wraps matched characters matched by exact match with a element', () => { + expect(highlightMatchingChars(code, 'true', false)).eq(`{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "node", + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": ${wrapMark('true')}, + "noFallthroughCasesInSwitch": ${wrapMark('true')}, + "noImplicitReturns": ${wrapMark('true')}, + "noUncheckedIndexedAccess": ${wrapMark('true')}, + "noUnusedLocals": ${wrapMark('true')}, + "noUnusedParameters": ${wrapMark('true')}, + "strict": ${wrapMark('true')}, + "jsx": "preserve" + }, + "include": [ + "./src", + "./types", + "./particularly-long-value-that-will-inadvertently-cause-scrolling-for-narrower-containers" + ], + "markup": "<div class="title">Hello & Hi.</div>", +} +`) + + expect(highlightMatchingChars(code, '.', false)).eq(`{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "node", + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + "jsx": "preserve" + }, + "include": [ + "${wrapMark('.')}/src", + "${wrapMark('.')}/types", + "${wrapMark('.')}/particularly-long-value-that-will-inadvertently-cause-scrolling-for-narrower-containers" + ], + "markup": "<div class="title">Hello & Hi${wrapMark('.')}</div>", +} +`) + }) + + it('wraps matched characters matched by exact match with a element', () => { + expect(highlightMatchingChars(code, '
', false)).eq(`{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "node", + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + "jsx": "preserve" + }, + "include": [ + "./src", + "./types", + "./particularly-long-value-that-will-inadvertently-cause-scrolling-for-narrower-containers" + ], + "markup": "${wrapMark('<div class="title">')}Hello & Hi.</div>", +} +`) + }) + + it('wraps matched characters matched by regexp with a element', () => { + expect(highlightMatchingChars(code, 'true', true)).eq(`{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "node", + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": ${wrapMark('true')}, + "noFallthroughCasesInSwitch": ${wrapMark('true')}, + "noImplicitReturns": ${wrapMark('true')}, + "noUncheckedIndexedAccess": ${wrapMark('true')}, + "noUnusedLocals": ${wrapMark('true')}, + "noUnusedParameters": ${wrapMark('true')}, + "strict": ${wrapMark('true')}, + "jsx": "preserve" + }, + "include": [ + "./src", + "./types", + "./particularly-long-value-that-will-inadvertently-cause-scrolling-for-narrower-containers" + ], + "markup": "<div class="title">Hello & Hi.</div>", +} +`) + + expect(highlightMatchingChars(code, '.', true)).eq(`${wrapMark(`{ + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "node", + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + "jsx": "preserve" + }, + "include": [ + "./src", + "./types", + "./particularly-long-value-that-will-inadvertently-cause-scrolling-for-narrower-containers" + ], + "markup": "<div class="title">Hello & Hi.</div>", +} +`)}`) + }) +}) + +describe('normalizeHighlightedLines', () => { + it('converts string expression to lines', () => { + expect(normalizeHighlightedLines('1,2,4-6', 10)).eql([1, 2, 4, 5, 6]) + expect(normalizeHighlightedLines('15', 10)).eql([]) + expect(normalizeHighlightedLines('7,5-9,10-9,12,0,3,1', 11)).eql([1, 3, 5, 6, 7, 8, 9, 10]) + expect(normalizeHighlightedLines('1,2,3', 0)).eql([]) + expect(normalizeHighlightedLines('1,1,3-3,5-6,5-6', 10)).eql([1, 3, 5, 6]) + }) + + it('normalizes ranges to lines', () => { + expect(normalizeHighlightedLines([1, 2, [4, 6]], 10)).eql([1, 2, 4, 5, 6]) + expect(normalizeHighlightedLines([15], 10)).eql([]) + expect(normalizeHighlightedLines([7, [5, 9], [10, 9], 12, 0, 3, 1], 11)).eql([1, 3, 5, 6, 7, 8, 9, 10]) + expect(normalizeHighlightedLines([1, 2, 3], 0)).eql([]) + expect(normalizeHighlightedLines([1, 1, [3, 3], [5, 6], [5, 6]], 10)).eql([1, 3, 5, 6]) + }) + + it('throws error for invalid expression', () => { + expect(() => normalizeHighlightedLines('', 10)).to.throw('Invalid line number expression.') + expect(() => normalizeHighlightedLines('foo', 10)).to.throw('Invalid line number expression.') + expect(() => normalizeHighlightedLines('1,2,4-6,-5', 10)).to.throw('Invalid line number expression.') + }) +}) diff --git a/src/utilities/codeBlockHelpers.ts b/src/utilities/codeBlockHelpers.ts new file mode 100644 index 0000000000..77ce5b3e09 --- /dev/null +++ b/src/utilities/codeBlockHelpers.ts @@ -0,0 +1,237 @@ +// ================================= +// Calculating matching line numbers +// ================================= + +import { escapeRegExp } from 'lodash-es' + +/** + * Build an array of character indices at which each line (0-based) starts. + * lineOffsets[i] = character index where line i starts in `code`. + */ +export function buildLineOffsets(code: string): number[] { + const lineOffsets = [0] + for (let i = 0; i < code.length; i++) { + if (code[i] === '\n') { + lineOffsets.push(i + 1) + } + } + return lineOffsets +} + +/** + * Binary search: + * Find which 1-based line number a particular character offset falls on. + * eg. findLineForOffset([0, 5, 10], 7) => 2 + */ +function findLineForOffset(lineOffsets: number[], offset: number): number { + let low = 0 + let high = lineOffsets.length - 1 + + // Perform a binary search to find the highest line offset <= the given offset + // and that will be the line number. + while (low < high) { + // Calculate the mid-point + const mid = Math.floor((low + high + 1) / 2) + if (lineOffsets[mid] <= offset) { + // narrow the search to the upper half + low = mid + } else { + // narrow the search to the lower half + high = mid - 1 + } + } + // Convert from 0-based line index to 1-based + return low + 1 +} + +/** + * For an **exact** substring, this returns an array of ALL line numbers + * (1-based) touched by each match. + * This assumes the query does NOT contain '\n' (no cross-line matches). + */ +function getAllMatchingLineNumbersByExactMatch(code: string, query: string, lineOffsets: number[]): number[] { + if (!code || !query) { + return [] + } + + const allMatchedLineNumbers: number[] = [] + + let startPos = 0 + while (true) { + const pos = code.indexOf(query, startPos) + if (pos === -1) break + + const lineNumber = findLineForOffset(lineOffsets, pos) + allMatchedLineNumbers.push(lineNumber) + + // lineNumber is 1-based, so it becomes the next line's start offset in the `lineOffsets` array + const nextLineOffset = lineNumber < lineOffsets.length ? lineOffsets[lineNumber] : code.length + + // Move to the next character after the match + startPos = nextLineOffset + } + + return allMatchedLineNumbers +} + +/** + * For a **regex** that may span multiple lines, this returns + * an array of ALL line numbers (1-based) touched by each match. + * + * Example: + * If a match starts on line 2 and ends on line 5, this adds + * [2, 3, 4, 5] to the result array for that match. + */ +function getAllMatchingLineNumbersByRegExp(code: string, query: string, lineOffsets: number[]): number[] { + if (!code || !query) { + return [] + } + + const regExp = new RegExp(query, 'sg') + + const allMatchedLineNumbers: number[] = [] + let match + + while ((match = regExp.exec(code)) !== null) { + // Start/end offsets of the matched text + const startOffset = match.index + const endOffset = match.index + match[0].length - 1 + + // Map offsets to line numbers + const startLine = findLineForOffset(lineOffsets, startOffset) + const endLine = findLineForOffset(lineOffsets, endOffset) + + // Collect each line from startLine through endLine + for (let line = startLine; line <= endLine; line++) { + // Avoid duplicates + if (allMatchedLineNumbers[allMatchedLineNumbers.length - 1] !== line) { + allMatchedLineNumbers.push(line) + } + } + } + + return allMatchedLineNumbers +} + +/** + * Get an array of line numbers (1-based) that contain matches for the given query. + * Both exact and regex matches are supported. + * Accepts a pre-built array of line offsets for better performance for repeated searches + * on the same code. + */ +export function getMatchingLineNumbers(code: string, query: string, isRegExpMode: boolean, lineOffsets = buildLineOffsets(code)): number[] { + if (isRegExpMode) { + return getAllMatchingLineNumbersByRegExp(code, query, lineOffsets) + } else { + return getAllMatchingLineNumbersByExactMatch(code, query, lineOffsets) + } +} + +// ================================ +// Highlighting matching characters +// ================================ +export const wrapMark = (match: string) => `${match}` + +// We only need to escape '<' and '&' characters as we can assure that the escaped output +// will only be directly set as innerHTML and will not be concatenated with other strings +// or used inside HTML attributes. +const ESCAPE_REGEX = /[<&]/ +export function escapeInnerHTML(raw: string): string { + return raw.replace(/[&<]/g, (char: string) => char === '&' ? '&' : '<') +} + +// Provide a fast path for escaping strings that don't contain special characters +// especially useful for large bodies of text where the overhead of escaping is non-trivial +// It also allows to only escape necessary characters. +export function escapeHTMLIfNeeded(raw: string, regExp = ESCAPE_REGEX, escape = escapeInnerHTML): string { + return regExp.test(raw) ? escape(raw) : raw +} + +export function highlightMatchingChars(code: string, query: string, isRegExpMode: boolean): string { + if (!code || !query) { + return '' + } + + let regExp: RegExp + try { + regExp = new RegExp(`(?:${isRegExpMode ? query : escapeRegExp(query)})+`, 'sgi') + } catch { + return code + } + + // We may take a fast path for the common case where there are no characters to escape + const skipEscape = !ESCAPE_REGEX.test(code) + + let result = '' + let lastIndex = 0 + let match: RegExpExecArray | null + + while ((match = regExp.exec(code)) !== null) { + const matchIndex = match.index + // escape last match end to current match start + result += skipEscape ? code.slice(lastIndex, matchIndex) : escapeInnerHTML(code.slice(lastIndex, matchIndex)) + // escape and wrap current match + result += wrapMark(skipEscape ? match[0] : escapeInnerHTML(match[0])) + lastIndex = matchIndex + match[0].length + } + // escape the rest of the string + result += skipEscape ? code.slice(lastIndex) : escapeInnerHTML(code.slice(lastIndex)) + + return result +} + +// ======================== +// Line highlighting syntax +// ======================== + +/** + * A regular expression to match a line number expression. + * Examples of valid expressions: + * - "1" (a single line number) + * - "1-5" (a range of line numbers) + * - "1,3,5" (a list of individual line numbers) + * - "1-3,5,7-10" (a mix of ranges and individual numbers) + */ +export const LINE_NUMBER_EXPRESSION_REGEX = /^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$/ + +// '1,2,4-6' -> [1, 2, 4, 5, 6] +function expressionToLines(expression: string, maxLines: number): number[] { + if (!LINE_NUMBER_EXPRESSION_REGEX.test(expression)) { + throw new Error('Invalid line number expression.') + } + + const ranges = expression.split(',').map((part) => { + const [start, end] = part.split('-').map(Number) + // If there's no end, it's a single line, otherwise it's a range + return end == null ? start : [start, end] as [number, number] + }) + + return rangesToLines(ranges, maxLines) +} + +// [1, 2, [4, 6]] -> [1, 2, 4, 5, 6] +function rangesToLines(ranges: (number | [number, number])[], maxLines: number): number[] { + const lines = ranges.flatMap((range) => { + if (typeof range === 'number') { + return range < 1 || range > maxLines ? [] : range + } + + // Ensure start is less than end + let [start, end] = range[0] < range[1] ? range : [range[1], range[0]] + + // Ensure start and end are within bounds + start = Math.max(1, start) + end = Math.min(maxLines, end) + + return Array.from({ length: end - start + 1 }, (_, i) => i + start) + }).sort((a, b) => a - b) + + // Ensure no duplicates + return Array.from(new Set(lines)) +} + +export function normalizeHighlightedLines(lines: string | (number | [number, number])[], maxLines: number): number[] { + return typeof lines === 'string' + ? expressionToLines(lines, maxLines) + : rangesToLines(lines, maxLines) +} diff --git a/tsconfig.json b/tsconfig.json index b6ca1173a0..2eae526c8e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@vue/tsconfig/tsconfig.json", "compilerOptions": { - "moduleResolution": "node", + "moduleResolution": "bundler", "strict": true, "jsx": "preserve", "importHelpers": true,