diff --git a/package-lock.json b/package-lock.json index beba6d027..a30dd0d70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -189,18 +189,18 @@ "license": "MIT" }, "node_modules/@astrojs/internal-helpers": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.5.tgz", - "integrity": "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA==", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.6.tgz", + "integrity": "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==", "license": "MIT" }, "node_modules/@astrojs/markdown-remark": { - "version": "6.3.10", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.10.tgz", - "integrity": "sha512-kk4HeYR6AcnzC4QV8iSlOfh+N8TZ3MEStxPyenyCtemqn8IpEATBFMTJcfrNW32dgpt6MY3oCkMM/Tv3/I4G3A==", + "version": "6.3.11", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.11.tgz", + "integrity": "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.7.5", + "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", @@ -214,8 +214,8 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", - "shiki": "^3.19.0", - "smol-toml": "^1.5.2", + "shiki": "^3.21.0", + "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", @@ -224,12 +224,12 @@ } }, "node_modules/@astrojs/mdx": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-4.3.13.tgz", - "integrity": "sha512-IHDHVKz0JfKBy3//52JSiyWv089b7GVSChIXLrlUOoTLWowG3wr2/8hkaEgEyd/vysvNQvGk+QhysXpJW5ve6Q==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-4.3.14.tgz", + "integrity": "sha512-FBrqJQORVm+rkRa2TS5CjU9PBA6hkhrwLVBSS9A77gN2+iehvjq1w6yya/d0YKC7osiVorKkr3Qd9wNbl0ZkGA==", "license": "MIT", "dependencies": { - "@astrojs/markdown-remark": "6.3.10", + "@astrojs/markdown-remark": "6.3.11", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.15.0", "es-module-lexer": "^1.7.0", @@ -749,9 +749,9 @@ } }, "node_modules/@astrojs/react/node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -843,9 +843,9 @@ } }, "node_modules/@astrojs/starlight": { - "version": "0.37.6", - "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.37.6.tgz", - "integrity": "sha512-wQrKwH431q+8FsLBnNQeG+R36TMtEGxTQ2AuiVpcx9APcazvL3n7wVW8mMmYyxX0POjTnxlcWPkdMGR3Yj1L+w==", + "version": "0.37.7", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.37.7.tgz", + "integrity": "sha512-KyBnou8aKIlPJUSNx6a1SN7XyH22oj/VAvTGC+Edld4Bnei1A//pmCRTBvSrSeoGrdUjK0ErFUfaEhhO1bPfDg==", "license": "MIT", "dependencies": { "@astrojs/markdown-remark": "^6.3.1", @@ -953,12 +953,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -967,29 +967,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -1021,13 +1021,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1050,13 +1050,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -1161,9 +1161,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1184,27 +1184,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1227,9 +1227,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1286,27 +1286,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1328,25 +1328,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1955,16 +1955,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", - "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.5" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2685,31 +2685,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -2717,13 +2717,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2834,9 +2834,9 @@ } }, "node_modules/@chakra-ui/cli": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/@chakra-ui/cli/-/cli-3.35.0.tgz", - "integrity": "sha512-QZzMuLPKZJMdI6wgtTT3muDtTYXcqyT+pmB8fkS6pHSzRZ5bxiq+vI6F9qvAW2VpjvbhcsRXGW2VLgaAI6BfDw==", + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/@chakra-ui/cli/-/cli-3.36.0.tgz", + "integrity": "sha512-I41JaWdjaJFJGO5XIdQbol30YFcaWfjz/TTEECYSvyew7KN0aqw9niTBvO1DdBvXn+3g1ycWJ9Sjg3NzDW8S9A==", "dev": true, "license": "MIT", "dependencies": { @@ -2844,7 +2844,7 @@ "@babel/parser": "^7.26.0", "@babel/plugin-transform-typescript": "^7.26.0", "@clack/prompts": "1.0.1", - "@pandacss/is-valid-prop": "1.10.0", + "@pandacss/is-valid-prop": "1.11.1", "@types/babel__core": "^7.20.5", "@types/cli-table": "^0.3.4", "@types/debug": "^4.1.12", @@ -4096,25 +4096,21 @@ } }, "node_modules/@electron-forge/cli": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.11.1.tgz", - "integrity": "sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.11.2.tgz", + "integrity": "sha512-c+C4ndLfHbxwZuCn9G8iT9wD/woLdaVkoSVjAIbj+0nJhi8UmiVsz/+Gxlj4cvhMRTzBMBxudstLU7RocMikfg==", "dev": true, "funding": [ { - "type": "individual", - "url": "https://github.com/sponsors/malept" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/subscription/pkg/npm-.electron-forge-cli?utm_medium=referral&utm_source=npm_fund" + "type": "opencollective", + "url": "https://opencollective.com/electron" } ], "license": "MIT", "dependencies": { - "@electron-forge/core": "7.11.1", - "@electron-forge/core-utils": "7.11.1", - "@electron-forge/shared-types": "7.11.1", + "@electron-forge/core": "7.11.2", + "@electron-forge/core-utils": "7.11.2", + "@electron-forge/shared-types": "7.11.2", "@electron/get": "^3.0.0", "@inquirer/prompts": "^6.0.1", "@listr2/prompt-adapter-inquirer": "^2.0.22", @@ -4205,33 +4201,29 @@ } }, "node_modules/@electron-forge/core": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/core/-/core-7.11.1.tgz", - "integrity": "sha512-YtuPLzggPKPabFAD2rOZFE0s7f4KaUTpGRduhSMbZUqpqD1TIPyfoDBpYiZvao3Ht8pyZeOJjbzcC0LpFs9gIQ==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/core/-/core-7.11.2.tgz", + "integrity": "sha512-RbOvlCahSlYBkY1XFgD5QuoifZltEY3ezYGqJYnV1z6RiUK1DfUXwdidmclBLI9d6u8NNr9xWPv79LHVc9ZA3Q==", "dev": true, "funding": [ { - "type": "individual", - "url": "https://github.com/sponsors/malept" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/subscription/pkg/npm-.electron-forge-core?utm_medium=referral&utm_source=npm_fund" + "type": "opencollective", + "url": "https://opencollective.com/electron" } ], "license": "MIT", "dependencies": { - "@electron-forge/core-utils": "7.11.1", - "@electron-forge/maker-base": "7.11.1", - "@electron-forge/plugin-base": "7.11.1", - "@electron-forge/publisher-base": "7.11.1", - "@electron-forge/shared-types": "7.11.1", - "@electron-forge/template-base": "7.11.1", - "@electron-forge/template-vite": "7.11.1", - "@electron-forge/template-vite-typescript": "7.11.1", - "@electron-forge/template-webpack": "7.11.1", - "@electron-forge/template-webpack-typescript": "7.11.1", - "@electron-forge/tracer": "7.11.1", + "@electron-forge/core-utils": "7.11.2", + "@electron-forge/maker-base": "7.11.2", + "@electron-forge/plugin-base": "7.11.2", + "@electron-forge/publisher-base": "7.11.2", + "@electron-forge/shared-types": "7.11.2", + "@electron-forge/template-base": "7.11.2", + "@electron-forge/template-vite": "7.11.2", + "@electron-forge/template-vite-typescript": "7.11.2", + "@electron-forge/template-webpack": "7.11.2", + "@electron-forge/template-webpack-typescript": "7.11.2", + "@electron-forge/tracer": "7.11.2", "@electron/get": "^3.0.0", "@electron/packager": "^18.3.5", "@electron/rebuild": "^3.7.0", @@ -4239,6 +4231,7 @@ "@vscode/sudo-prompt": "^9.3.1", "chalk": "^4.0.0", "debug": "^4.3.1", + "eta": "^3.5.0", "fast-glob": "^3.2.7", "filenamify": "^4.1.0", "find-up": "^5.0.0", @@ -4248,7 +4241,6 @@ "interpret": "^3.1.1", "jiti": "^2.4.2", "listr2": "^7.0.2", - "lodash": "^4.17.20", "log-symbols": "^4.0.0", "node-fetch": "^2.6.7", "rechoir": "^0.8.0", @@ -4261,13 +4253,13 @@ } }, "node_modules/@electron-forge/core-utils": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/core-utils/-/core-utils-7.11.1.tgz", - "integrity": "sha512-9UxRWVsfcziBsbAA2MS0Oz4yYovQCO2BhnGIfsbKNTBtMc/RcVSxAS0NMyymce44i43p1ZC/FqWhnt1XqYw3bQ==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/core-utils/-/core-utils-7.11.2.tgz", + "integrity": "sha512-/Fpwo44an6ulUdq94co5OOcbRCohgYNci/E6eoZZuTO9f72X+PqJkMkghqkMX3iQ8Aq2QRLkGKFwrKWJNTjL7Q==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.11.1", + "@electron-forge/shared-types": "7.11.2", "@electron/rebuild": "^3.7.0", "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", @@ -4577,13 +4569,13 @@ } }, "node_modules/@electron-forge/maker-base": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-7.11.1.tgz", - "integrity": "sha512-yhZrCGoN6bDeiB5DHFaueZ1h84AReElEj+f0hl2Ph4UbZnO0cnLpbx+Bs+XfMLAiA+beC8muB5UDK5ysfuT9BQ==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-base/-/maker-base-7.11.2.tgz", + "integrity": "sha512-9934zYu9WVdgCYQXvtS+eL1oyLagsY8JlWhZmoK8yWTYftSAydH7jb3seVpfy6n85SYmY/yjcAy2lvOTy5dUwA==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.11.1", + "@electron-forge/shared-types": "7.11.2", "fs-extra": "^10.0.0", "which": "^2.0.2" }, @@ -4592,14 +4584,14 @@ } }, "node_modules/@electron-forge/maker-deb": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-deb/-/maker-deb-7.11.1.tgz", - "integrity": "sha512-QTYiryQLYPDkq6pIfBmx0GQ6D8QatUkowH7rTlW5MnCUa0uumX0Xu7yGIjesuwW37fxT3Lv4xi+FSXMCm2eC1w==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-deb/-/maker-deb-7.11.2.tgz", + "integrity": "sha512-MYSdCTsqzKNmsmaq7CIFh2kJdBWUZ4njxnVGrIRClzueVITk5Kots3+eQo+e5QQLvXTVn2XTNDc2nYjvtBh+Mw==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/maker-base": "7.11.1", - "@electron-forge/shared-types": "7.11.1" + "@electron-forge/maker-base": "7.11.2", + "@electron-forge/shared-types": "7.11.2" }, "engines": { "node": ">= 16.4.0" @@ -4609,14 +4601,14 @@ } }, "node_modules/@electron-forge/maker-dmg": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-dmg/-/maker-dmg-7.11.1.tgz", - "integrity": "sha512-7zs5/Ewz1PcOl4N1102stFgBiFGWxU18+UPFUSd/fgf9MErBl4HBWuVNMIHyeJ/56rdfkcmTxTqE+9TBEYrZcg==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-dmg/-/maker-dmg-7.11.2.tgz", + "integrity": "sha512-vh8/mnu3xyMbRcz3S0TV1rB7ZyGhMK60Yz2f2R+Zmj1EbAivLEq5UAvzHdB+kLttr0Dpjp2FIX4TcZ9neD4Y9g==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/maker-base": "7.11.1", - "@electron-forge/shared-types": "7.11.1", + "@electron-forge/maker-base": "7.11.2", + "@electron-forge/shared-types": "7.11.2", "fs-extra": "^10.0.0" }, "engines": { @@ -4627,15 +4619,15 @@ } }, "node_modules/@electron-forge/maker-wix": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-wix/-/maker-wix-7.11.1.tgz", - "integrity": "sha512-yKsnfPQVBrjyjWndmSB0rqQSXAiIukwz5fuVyoCTnYpfJQojrEEMUN/eXSHA08d+rOPbB/hkMAR+QJcjJ8vO2Q==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-wix/-/maker-wix-7.11.2.tgz", + "integrity": "sha512-uf+wmWCqTA23m0KabkQT7m+lyEtdB/Hbtn9Rb8KVSK4FPLrcSC/glYLlHKJhg6DikQraZVfD8JcUPKyZ1YTX2A==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/core-utils": "7.11.1", - "@electron-forge/maker-base": "7.11.1", - "@electron-forge/shared-types": "7.11.1", + "@electron-forge/core-utils": "7.11.2", + "@electron-forge/maker-base": "7.11.2", + "@electron-forge/shared-types": "7.11.2", "chalk": "^4.0.0", "electron-wix-msi": "^5.1.3", "log-symbols": "^4.0.0", @@ -4716,14 +4708,14 @@ } }, "node_modules/@electron-forge/maker-zip": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/maker-zip/-/maker-zip-7.11.1.tgz", - "integrity": "sha512-30rcp0AbJLfkFBX2hmO14LKXx7z9V61LffTVbTCFMh5vUB2kZvcA5xAhsBk2oUJWfGVxe1DuSEU0rDR9bUMHUg==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/maker-zip/-/maker-zip-7.11.2.tgz", + "integrity": "sha512-FWnOm2MORX/nt8psnEtID3Vnt8Blby1NkzjU3KjXBPF9kave71C3lI8KbBbCeKKyTQ/S00i2FiglKdRWQ1WNTw==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/maker-base": "7.11.1", - "@electron-forge/shared-types": "7.11.1", + "@electron-forge/maker-base": "7.11.2", + "@electron-forge/shared-types": "7.11.2", "cross-zip": "^4.0.0", "fs-extra": "^10.0.0", "got": "^11.8.5" @@ -4733,53 +4725,53 @@ } }, "node_modules/@electron-forge/plugin-auto-unpack-natives": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/plugin-auto-unpack-natives/-/plugin-auto-unpack-natives-7.11.1.tgz", - "integrity": "sha512-5uRM3WNv7jIeDt8pLP3V4U2puWHPGJ/3qRuSE47RKgTp5qxpZidWHSYcEJJxjoqOL/7KFwSqKSQ/a36GoZV4Fg==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-auto-unpack-natives/-/plugin-auto-unpack-natives-7.11.2.tgz", + "integrity": "sha512-ktMQxO80vGjqVXVSFha+kWJscBmJaWud4Kqu7rKkbrCphr1zqaG+8EDNbkpGzV/+mhKOLuA5qII1hJ69gFNpdA==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/plugin-base": "7.11.1", - "@electron-forge/shared-types": "7.11.1" + "@electron-forge/plugin-base": "7.11.2", + "@electron-forge/shared-types": "7.11.2" }, "engines": { "node": ">= 16.4.0" } }, "node_modules/@electron-forge/plugin-base": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-7.11.1.tgz", - "integrity": "sha512-lKpSOV1GA3FoYiD9k05i6v4KaQVmojnRgCr7d6VL1bFp13QOtXSaAWhFI9mtSY7rGElOacX6Zt7P7rPoB8T9eQ==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/plugin-base/-/plugin-base-7.11.2.tgz", + "integrity": "sha512-tIFzEE2+D9NnCAn/rLwSkh8H59IqN+G973JNl7xmCzquO6qa7/veitZOQFGO79Zmmgkc8R/fmiCbh7LIdLS9Tg==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.11.1" + "@electron-forge/shared-types": "7.11.2" }, "engines": { "node": ">= 16.4.0" } }, "node_modules/@electron-forge/publisher-base": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/publisher-base/-/publisher-base-7.11.1.tgz", - "integrity": "sha512-rXE9oMFGMtdQrixnumWYH5TTGsp99iPHZb3jI74YWq518ctCh6DlIgWlhf6ok2X0+lhWovcIb45KJucUFAQ13w==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/publisher-base/-/publisher-base-7.11.2.tgz", + "integrity": "sha512-YwK4ZF3+uW7PBEV/ho59NVTriP3fCahskORrztUaFIdG0QP3hqMsfmo01euv98FDsBEW9UXo7/EW8t5jpmYZ0Q==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.11.1" + "@electron-forge/shared-types": "7.11.2" }, "engines": { "node": ">= 16.4.0" } }, "node_modules/@electron-forge/shared-types": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.11.1.tgz", - "integrity": "sha512-vvBWdAEh53UJlDGUevpaJk1+sqDMQibfrbHR+0IPA4MPyQex7/Uhv3vYH9oGHujBVAChQahjAuJt0fG6IJBLZg==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/shared-types/-/shared-types-7.11.2.tgz", + "integrity": "sha512-Tcles7y74xy3jN5dEC+Pt1duJYk4c7W2xu98tjWW8RewmfKD2uHkie6I1I3yifPFZXZ/QfTlaFOOoKIQ9ENZjg==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/tracer": "7.11.1", + "@electron-forge/tracer": "7.11.2", "@electron/packager": "^18.3.5", "@electron/rebuild": "^3.7.0", "listr2": "^7.0.2" @@ -4789,14 +4781,14 @@ } }, "node_modules/@electron-forge/template-base": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/template-base/-/template-base-7.11.1.tgz", - "integrity": "sha512-XpTaEf+EfQw+0BlSAtSpZKYIKYvKu4raNzSGHZZoSYHp+HDC7R+MlpFQmSJiGdYQzQ14C+uxO42tVjgM0DMbpw==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/template-base/-/template-base-7.11.2.tgz", + "integrity": "sha512-l10I+XZRbbxFGiDLMnuXmlOppmLYmimKj6FWjEGUvft4VJFXW2BIDrLIugIGdM1nbrl/0aYjen2xRg0nZlcWzg==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/core-utils": "7.11.1", - "@electron-forge/shared-types": "7.11.1", + "@electron-forge/core-utils": "7.11.2", + "@electron-forge/shared-types": "7.11.2", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "fs-extra": "^10.0.0", @@ -4808,14 +4800,14 @@ } }, "node_modules/@electron-forge/template-vite": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/template-vite/-/template-vite-7.11.1.tgz", - "integrity": "sha512-Or8Lxf4awoeUZoMTKJEw5KQDIhqOFs24WhVka3yZXxc6VgVWN79KmYKYM6uM/YMQttmafhsBhY2t1Lxo1WR/ug==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/template-vite/-/template-vite-7.11.2.tgz", + "integrity": "sha512-yFSDSu3IdyNpgLXzrwODSUyaWniHRSZI82gwcXdnJLx7D7DIDLtbx6KzEoy7QBmWZRULO3F7rLsYG+Ur7orvyA==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.11.1", - "@electron-forge/template-base": "7.11.1", + "@electron-forge/shared-types": "7.11.2", + "@electron-forge/template-base": "7.11.2", "fs-extra": "^10.0.0" }, "engines": { @@ -4823,14 +4815,14 @@ } }, "node_modules/@electron-forge/template-vite-typescript": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/template-vite-typescript/-/template-vite-typescript-7.11.1.tgz", - "integrity": "sha512-Us4AHXFb+4z+gXgZImSqMBS63oKnsQWLOhqRg321xiDzu2UcQPlwgWNb4rAEKNVC1e7LXrUNDHuBiTrQkvWXbg==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/template-vite-typescript/-/template-vite-typescript-7.11.2.tgz", + "integrity": "sha512-QvvdmO9Gdv+3aISI9+bBLKPBTyKaucs6HhXxz+IDALcdykIL9wVN0/BrWuwwgbwuw4BiJTyXGSPNXuJ+EWnP6g==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.11.1", - "@electron-forge/template-base": "7.11.1", + "@electron-forge/shared-types": "7.11.2", + "@electron-forge/template-base": "7.11.2", "fs-extra": "^10.0.0" }, "engines": { @@ -4838,14 +4830,14 @@ } }, "node_modules/@electron-forge/template-webpack": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack/-/template-webpack-7.11.1.tgz", - "integrity": "sha512-15lbXxi+er461MPk6sbwAOyjofAHwmQjTvxNCiNpaU2naEwbj3t0SlLq/BMr5HxnVOaMmA7+lKV9afkIom+d4Q==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack/-/template-webpack-7.11.2.tgz", + "integrity": "sha512-JjG8XIZctrSZvTlii7Hqvt/pHDKigRk4PoLTQCs1TiT05ZWsn40itBm8cbja3L7bfm0ccDd3JTWWOl2G7PhlmA==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.11.1", - "@electron-forge/template-base": "7.11.1", + "@electron-forge/shared-types": "7.11.2", + "@electron-forge/template-base": "7.11.2", "fs-extra": "^10.0.0" }, "engines": { @@ -4853,14 +4845,14 @@ } }, "node_modules/@electron-forge/template-webpack-typescript": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack-typescript/-/template-webpack-typescript-7.11.1.tgz", - "integrity": "sha512-6ExfFnFkHBz8rvRFTFg5HVGTC12uJpbVk4q8DVg0R8rhhxhqiVNh8lF2UPtZ2yT2UtGWjXNVlyP3Y3T6q6E3GQ==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/template-webpack-typescript/-/template-webpack-typescript-7.11.2.tgz", + "integrity": "sha512-2lwK+OrCeZgYM8WqsUXJzk94rdF0z/kA7WnAf79U3COEmAAMcFIwJtwF8c/n+52UecP3yrEE70LIGmM1sjGZJQ==", "dev": true, "license": "MIT", "dependencies": { - "@electron-forge/shared-types": "7.11.1", - "@electron-forge/template-base": "7.11.1", + "@electron-forge/shared-types": "7.11.2", + "@electron-forge/template-base": "7.11.2", "fs-extra": "^10.0.0", "typescript": "~5.4.5", "webpack": "^5.69.1" @@ -4884,9 +4876,9 @@ } }, "node_modules/@electron-forge/tracer": { - "version": "7.11.1", - "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.11.1.tgz", - "integrity": "sha512-tiB6cglVQFcSw9N8GRwVwZUeB9u0DOx2Mj7aFXBUsFLUYQapvVGv51tUSy/UAW5lvmubGscYIILuVko+II3+NA==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@electron-forge/tracer/-/tracer-7.11.2.tgz", + "integrity": "sha512-U8j5Hyj2Zt7I5PciJvPJfmEv69Gb/Da9v+k655z3Jj1cuY0UnToEJ61IhXrzlTYqo+jUKC+fgAjDJ6vltJTS0A==", "dev": true, "license": "MIT", "dependencies": { @@ -5017,9 +5009,9 @@ } }, "node_modules/@electron/node-gyp/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -5285,9 +5277,9 @@ } }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -5524,9 +5516,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -5540,9 +5532,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -5556,9 +5548,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -5572,9 +5564,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -5588,9 +5580,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -5604,9 +5596,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -5620,9 +5612,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -5636,9 +5628,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -5652,9 +5644,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -5668,9 +5660,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -5684,9 +5676,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -5700,9 +5692,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -5716,9 +5708,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -5732,9 +5724,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -5748,9 +5740,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -5764,9 +5756,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -5780,9 +5772,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -5796,9 +5788,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -5812,9 +5804,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -5828,9 +5820,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -5844,9 +5836,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -5860,9 +5852,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -5876,9 +5868,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -5892,9 +5884,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -5908,9 +5900,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -5924,9 +5916,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -8305,9 +8297,9 @@ ] }, "node_modules/@pandacss/is-valid-prop": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.10.0.tgz", - "integrity": "sha512-OmEHkDj3BRkHYLxHwNFYJkcwF84c7PuHsWHmmmNLJnmk8z0RVlDQei4GxWjfW1bnvCihCwakJ8Xq6GDRlmcDiQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.11.1.tgz", + "integrity": "sha512-tvfThJmSw88Lgk5qVYzVckZ2FY8T376mGvP1cWUC5SR58AYm82ZKEFciDbGOQKcyN70rF9qqJBB5JVWgJo3JmA==", "license": "MIT" }, "node_modules/@peculiar/asn1-cms": { @@ -10100,25 +10092,15 @@ } }, "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -10998,9 +10980,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -11745,9 +11727,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "dev": true, "license": "MIT", "engines": { @@ -13045,9 +13027,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -13445,14 +13427,14 @@ } }, "node_modules/astro": { - "version": "5.17.3", - "resolved": "https://registry.npmjs.org/astro/-/astro-5.17.3.tgz", - "integrity": "sha512-69dcfPe8LsHzklwj+hl+vunWUbpMB6pmg35mACjetxbJeUNNys90JaBM8ZiwsPK689SAj/4Zqb1ayaANls9/MA==", + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/astro/-/astro-5.18.2.tgz", + "integrity": "sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ==", "license": "MIT", "dependencies": { "@astrojs/compiler": "^2.13.0", - "@astrojs/internal-helpers": "0.7.5", - "@astrojs/markdown-remark": "6.3.10", + "@astrojs/internal-helpers": "0.7.6", + "@astrojs/markdown-remark": "6.3.11", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", @@ -14051,9 +14033,9 @@ } }, "node_modules/astro/node_modules/svgo": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", - "integrity": "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", "license": "MIT", "dependencies": { "commander": "^11.1.0", @@ -14062,7 +14044,7 @@ "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", - "sax": "^1.4.1" + "sax": "^1.5.0" }, "bin": { "svgo": "bin/svgo.js" @@ -14076,9 +14058,9 @@ } }, "node_modules/astro/node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -14330,9 +14312,9 @@ } }, "node_modules/babel-plugin-macros/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -14793,9 +14775,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -14940,9 +14922,9 @@ } }, "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -15878,9 +15860,9 @@ } }, "node_modules/cookie-es": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", - "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", "license": "MIT" }, "node_modules/cookie-signature": { @@ -16772,9 +16754,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, "node_modules/depd": { @@ -16848,9 +16830,9 @@ } }, "node_modules/devalue": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", - "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, "node_modules/devlop": { @@ -17226,9 +17208,9 @@ } }, "node_modules/electron": { - "version": "39.8.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.2.tgz", - "integrity": "sha512-uwNJHeqm8pzQEZf/KX4XM1fJctZpHcA0Za/MlP9mOg0FAWHbKo6yRC33QbdfLX7PeNjYZC3I3nnVhE5L2CLqxw==", + "version": "39.8.10", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.10.tgz", + "integrity": "sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -18080,9 +18062,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -18092,32 +18074,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { @@ -18924,6 +18906,19 @@ "node": ">=0.10.0" } }, + "node_modules/eta": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-3.5.0.tgz", + "integrity": "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -19288,9 +19283,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "dev": true, "funding": [ { @@ -19584,9 +19579,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -20665,14 +20660,14 @@ "license": "ISC" }, "node_modules/h3": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz", - "integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", "license": "MIT", "dependencies": { - "cookie-es": "^1.2.2", + "cookie-es": "^1.2.3", "crossws": "^0.3.5", - "defu": "^6.1.4", + "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", @@ -21598,9 +21593,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -21756,15 +21751,15 @@ } }, "node_modules/i18next-fs-backend": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.6.3.tgz", - "integrity": "sha512-/5aW996nbolGh/qVU9urjAJ2QiXpiyGuhG8x/ZvvZQ3bMlrK+ouUdD6whS4e+sU9CCdUIzl7x/heEjsBh/3xtw==", + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/i18next-fs-backend/-/i18next-fs-backend-2.6.6.tgz", + "integrity": "sha512-mYGu6Nt8RIp3X/U8Y+Gej1wo5xmYWmGKLqBGMCC2OCAou5rW5epeHgHmVcw20mJs9Z9+DAPHIxQPNCgFyPRMeg==", "license": "MIT" }, "node_modules/i18next-http-backend": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.4.tgz", - "integrity": "sha512-udwrBIE6cNpqn1gRAqRULq3+7MzIIuaiKRWrz++dVz5SqWW2VwXmPJtAgkI0JtMLFaADC9qNmnZAxWAhsxXx2g==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.6.tgz", + "integrity": "sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==", "license": "MIT", "dependencies": { "cross-fetch": "4.1.0" @@ -22050,9 +22045,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "engines": { @@ -22937,9 +22932,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -23261,14 +23266,14 @@ "license": "MIT" }, "node_modules/launch-editor": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", - "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "dev": true, "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.8.4" } }, "node_modules/leviathan-crypto": { @@ -23793,9 +23798,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.camelcase": { @@ -25650,9 +25655,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -26023,9 +26028,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -27493,9 +27498,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "funding": [ { "type": "opencollective", @@ -27512,7 +27517,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -29269,9 +29274,9 @@ } }, "node_modules/remark-parse-frontmatter/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -30410,9 +30415,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { @@ -30630,9 +30635,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -31568,19 +31573,19 @@ "dev": true }, "node_modules/svgo": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", + "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", "dev": true, "license": "MIT", "dependencies": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", - "picocolors": "^1.0.0" + "picocolors": "^1.0.0", + "sax": "^1.5.0" }, "bin": { "svgo": "bin/svgo" @@ -31741,9 +31746,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.1", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.1.tgz", - "integrity": "sha512-6pRwxoGeV/roJYpsfcP6tN9mep6pPeCtXbUOCdVa0nme05Brwcwdge/fVNhIZn2wuUitAKZm4IYa7QjnRIa9zA==", + "version": "5.31.11", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.11.tgz", + "integrity": "sha512-I6O7iaUj23AXRgCPDDnvi3xHvdOLp4+1YMbF+X194lJwY1NeWojgHJPhslVKcmTtrLTguRk3QJK+xEdTiI3P0w==", "license": "MIT", "os": [ "darwin", @@ -32163,9 +32168,9 @@ } }, "node_modules/tmp-promise/node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "optional": true, @@ -32961,9 +32966,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "optional": true, @@ -33707,9 +33712,9 @@ } }, "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -33790,13 +33795,13 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "license": "MIT", "peer": true, "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -34240,9 +34245,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz", - "integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==", + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.5.tgz", + "integrity": "sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==", "dev": true, "license": "MIT", "dependencies": { @@ -34790,9 +34795,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -34960,9 +34965,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/packages/app/src/features/NoteEditor/RichEditor/RichEditor.tsx b/packages/app/src/features/NoteEditor/RichEditor/RichEditor.tsx index 6bc2d027c..13b1ecc94 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/RichEditor.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/RichEditor.tsx @@ -10,7 +10,6 @@ import { HeadingNode, QuoteNode } from '@lexical/rich-text'; import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'; import { ImageNode } from './plugins/Image/ImageNode'; -import { FormattingNode } from './plugins/Markdown/nodes/FormattingNode'; import { RawNode } from './plugins/Markdown/nodes/RawNode'; import { RichEditorContent, RichEditorContentProps } from './RichEditorContent'; import { theme } from './theme/RichEditor'; @@ -29,7 +28,6 @@ export const RichEditor = memo((props: RichEditorContentProps) => { nodes: [ // App specific nodes RawNode, - FormattingNode, ImageNode, // Plugins diff --git a/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx b/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx index 26b296725..2a58248d4 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/RichEditorContent.tsx @@ -22,9 +22,9 @@ import { ContextMenuPlugin } from './plugins/ContextMenu/ContextMenuPlugin'; import { EditorPanelPlugin } from './plugins/EditorPanelPlugin/EditorPanelPlugin'; import { DropFilesPlugin } from './plugins/Files/DropFilesPlugin'; import { FilesPlugin } from './plugins/Files/FilesPlugin'; -import { FormattingPlugin } from './plugins/Formatting/FormattingPlugin'; import { HighlightingPlugin } from './plugins/HighlightingPlugin/HighlightingPlugin'; import ImagesPlugin from './plugins/Image/ImagesPlugin'; +import { KeyboardControlsPlugin } from './plugins/KeyboardControlsPlugin/KeyboardControlsPlugin'; import { LinkClickHandlerPlugin } from './plugins/LinkClickHandlerPlugin'; import { MarkdownSerializePlugin, @@ -113,7 +113,7 @@ export const RichEditorContent = ({ - + diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/interactions/readonly.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/interactions/readonly.dom.test.ts new file mode 100644 index 000000000..5401bdead --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/interactions/readonly.dom.test.ts @@ -0,0 +1,47 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderRichEditor } from '../utils/renderRichEditor'; + +test('Editor is not editable in readonly mode', async () => { + const user = userEvent.setup(); + await renderRichEditor({ + value: '# Hello', + isReadOnly: true, + }); + + expect(screen.getByRole('textbox')).toHaveAttribute('contenteditable', 'false'); + + // Cannot enter text + await user.click(screen.getByRole('heading')); + await user.keyboard('Some text'); + + expect(screen.getByRole('textbox')).toHaveTextContent('Hello'); + expect(screen.getByRole('textbox')).not.toHaveTextContent('Some text'); +}); + +test('ReadOnly editor is not editable while the other editor remains editable', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: 'Editable text' }); + await renderRichEditor({ value: 'ReadOnly text', isReadOnly: true }); + + const textBoxes = screen.getAllByRole('textbox'); + expect(textBoxes).toHaveLength(2); + + expect(textBoxes[0]).toHaveAttribute('contenteditable', 'true'); + expect(textBoxes[1]).toHaveAttribute('contenteditable', 'false'); + + // editorA is editable — editing works + await user.click(within(textBoxes[0]).getByText('Editable text')); + await user.keyboard('New text '); + + expect(textBoxes[0]).toHaveTextContent('New text Editable text'); + + // editorB is readOnly — editing must be ignored + await user.click(within(textBoxes[1]).getByText('ReadOnly text')); + await user.keyboard('Some text'); + + expect(textBoxes[1]).toHaveTextContent('ReadOnly text'); + expect(textBoxes[1]).not.toHaveTextContent('Some text'); + expect(textBoxes[1]).toHaveAttribute('contenteditable', 'false'); +}); diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/isolation.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/isolation.dom.test.ts new file mode 100644 index 000000000..e221472d7 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/isolation.dom.test.ts @@ -0,0 +1,43 @@ +import { screen, within } from '@testing-library/react'; + +import { renderRichEditor } from './utils/renderRichEditor'; +import { textFormatClasses } from './utils/richEditorFixtures'; +import { selectContent } from './utils/utils'; + +test('Formatting text in one editor does not affect the other editor', async () => { + const editorA = await renderRichEditor({ value: 'Big text' }); + const editorB = await renderRichEditor({ value: 'Small text' }); + + const editorContainers = screen.getAllByRole('textbox'); + expect(editorContainers).toHaveLength(2); + + // initial state + expect(editorContainers[0]).toHaveTextContent('Big text'); + expect(editorContainers[1]).toHaveTextContent('Small text'); + + // apply italic in editorA + selectContent(editorContainers[0], 'Big text'); + await editorA.format('italic'); + expect(within(editorContainers[0]).getByRole('emphasis')).toHaveTextContent( + 'Big text', + ); + expect(within(editorContainers[1]).queryByRole('emphasis')).not.toBeInTheDocument(); + + // apply strikethrough in editorB + selectContent(editorContainers[1], 'Small text'); + await editorB.format('strikethrough'); + expect(within(editorContainers[1]).getByText('Small text')).toHaveClass( + textFormatClasses.strikethrough, + ); + expect(within(editorContainers[1]).getByText('Small text')).not.toHaveClass( + textFormatClasses.italic, + ); + + // No changes in editorA + expect(within(editorContainers[0]).getByText('Big text')).toHaveClass( + textFormatClasses.italic, + ); + expect(within(editorContainers[0]).getByText('Big text')).not.toHaveClass( + textFormatClasses.strikethrough, + ); +}); diff --git a/packages/app/src/features/NoteEditor/__tests__/render.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/render.dom.test.ts similarity index 59% rename from packages/app/src/features/NoteEditor/__tests__/render.dom.test.ts rename to packages/app/src/features/NoteEditor/RichEditor/__tests__/render.dom.test.ts index d9b693250..98bb66980 100644 --- a/packages/app/src/features/NoteEditor/__tests__/render.dom.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/render.dom.test.ts @@ -3,6 +3,23 @@ import path from 'path'; import { screen, within } from '@testing-library/react'; import { renderRichEditor } from './utils/renderRichEditor'; +import { textFormatClasses } from './utils/richEditorFixtures'; + +test('Editor updates when value changes', async () => { + const editor = await renderRichEditor({ value: `# Big text` }); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Big text'); + + // Run component rerender with new value + await editor.rerender({ value: `### Not so big text` }); + + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent( + 'Not so big text', + ); + + // The old header was removed + expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument(); +}); test('Renders markdown correctly', async () => { const markdown = readFileSync( @@ -18,10 +35,13 @@ test('Renders markdown correctly', async () => { expect(paragraph).toHaveTextContent( 'This is a regular paragraph with bold text, italic text, strikethrough text.', ); - expect(paragraph.querySelector('b')).toHaveTextContent('bold text'); - expect(within(paragraph).getByRole('emphasis')).toHaveTextContent('italic text'); - expect(within(paragraph).getByRole('deletion')).toHaveTextContent( - 'strikethrough text', + + expect(within(paragraph).getByText('bold text')).toHaveClass(textFormatClasses.bold); + expect(within(paragraph).getByText('italic text')).toHaveClass( + textFormatClasses.italic, + ); + expect(within(paragraph).getByText('strikethrough text')).toHaveClass( + textFormatClasses.strikethrough, ); // Horizontal rule @@ -102,63 +122,3 @@ test('Renders markdown correctly', async () => { expect(within(rows[1]).getByRole('cell', { name: 'Alice' })).toBeInTheDocument(); expect(within(rows[1]).getByRole('cell', { name: 'Admin' })).toBeInTheDocument(); }); - -test('Renders a checklist with checked and unchecked items', async () => { - await renderRichEditor({ - value: `- [x] First item - - [ ] Nested item - - [x] Deep nested item -- [ ] Second item`, - }); - - const checkboxes = within(screen.getByRole('textbox')).getAllByRole('checkbox'); - const [firstItem, nestedItem, deepNestedItem, secondItem] = checkboxes; - expect(checkboxes).toHaveLength(4); - - // First item contains nested list - expect(firstItem).toHaveTextContent('First item'); - expect(firstItem).toBeChecked(); - - const [firstItemNestedList] = within(firstItem).getAllByRole('list'); - expect(firstItemNestedList).toContainElement(nestedItem); - - // Nested item contains deep nested list - expect(nestedItem).toHaveTextContent('Nested item'); - expect(nestedItem).not.toBeChecked(); - - const nestedItemList = within(nestedItem).getByRole('list'); - expect(nestedItemList).toContainElement(deepNestedItem); - - // Deep nested item - expect(deepNestedItem).toHaveTextContent('Deep nested item'); - expect(deepNestedItem).toBeChecked(); - - // Second item - expect(secondItem).toHaveTextContent('Second item'); - expect(secondItem).not.toBeChecked(); -}); - -test('Renders a mixed list with regular and checkbox items correctly', async () => { - await renderRichEditor({ - value: `- [x] First item - - Nested simple item -- [ ] Second item`, - }); - - const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes).toHaveLength(2); - const [first, second] = checkboxes; - - expect(first).toHaveTextContent('First item'); - expect(first).toBeChecked(); - - expect(second).toHaveTextContent('Second item'); - expect(second).not.toBeChecked(); - - // Nested item - regular, not checkbox - const nestedList = within(first).getByRole('list'); - const nestedItem = within(nestedList).getByRole('listitem'); - - expect(nestedItem).toHaveTextContent('Nested simple item'); - expect(within(nestedItem).queryByRole('checkbox')).toBeNull(); -}); diff --git a/packages/app/src/features/NoteEditor/__tests__/resources/example.md b/packages/app/src/features/NoteEditor/RichEditor/__tests__/resources/example.md similarity index 100% rename from packages/app/src/features/NoteEditor/__tests__/resources/example.md rename to packages/app/src/features/NoteEditor/RichEditor/__tests__/resources/example.md diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/formatting.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/formatting.dom.test.ts new file mode 100644 index 000000000..6e321b218 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/formatting.dom.test.ts @@ -0,0 +1,124 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderRichEditor } from '../utils/renderRichEditor'; +import { textFormatClasses } from '../utils/richEditorFixtures'; +import { selectContent, selectText } from '../utils/utils'; + +test('Text formatting can be toggled', async () => { + const content = 'Hello, my dear friends!'; + const richEditor = await renderRichEditor({ value: content }); + + const editor = screen.getByRole('textbox'); + + // Apply strikethrough + selectContent(editor, content); + await richEditor.format('strikethrough'); + expect(within(editor).getByText(content)).toHaveClass( + textFormatClasses.strikethrough, + ); + + // Remove strikethrough + selectContent(editor, content); + await richEditor.format('strikethrough'); + expect(within(editor).getByText(content)).not.toHaveClass( + textFormatClasses.strikethrough, + ); + + // Apply italic + selectContent(editor, content); + await richEditor.format('italic'); + expect(within(editor).getByText(content)).toHaveClass(textFormatClasses.italic); + + // Remove italic + selectContent(editor, content); + await richEditor.format('italic'); + expect(within(editor).getByText(content)).not.toHaveClass(textFormatClasses.italic); +}); + +test('One text node can have different formatting at once', async () => { + const content = 'Hello, my dear friends!'; + const richEditor = await renderRichEditor({ value: content }); + + const editor = screen.getByRole('textbox'); + + selectContent(editor, content); + await richEditor.format('italic'); + await richEditor.format('bold'); + await richEditor.format('strikethrough'); + + expect(within(editor).getByText(content)).toHaveClass(textFormatClasses.bold); + expect(within(editor).getByText(content)).toHaveClass(textFormatClasses.italic); + expect(within(editor).getByText(content)).toHaveClass( + textFormatClasses.strikethrough, + ); + + // Removes bold without breaking others formatting + selectContent(editor, content); + await richEditor.format('bold'); + + expect(within(editor).getByText(content)).not.toHaveClass(textFormatClasses.bold); + expect(within(editor).getByText(content)).toHaveClass(textFormatClasses.italic); + expect(within(editor).getByText(content)).toHaveClass( + textFormatClasses.strikethrough, + ); +}); + +test('Formatting can be applied to a part of text', async () => { + const user = userEvent.setup(); + const richEditor = await renderRichEditor({ value: 'Hello, my dear friends!' }); + + const editor = screen.getByRole('textbox'); + await user.click(editor); + + // Apply formatting + selectText(editor, 'friends'); + await richEditor.format('italic'); + + expect(within(editor).getByText('friends')).toHaveClass(textFormatClasses.italic); +}); + +describe('Formatting via keyboard shortcuts', () => { + const cases: { + title: string; + shortcut: string; + formatClass: RegExp; + }[] = [ + { + title: 'Ctrl+I must toggle Italic format', + shortcut: '{Control>}i{/Control}', + formatClass: textFormatClasses.italic, + }, + { + title: 'Ctrl+B must toggle Bold format', + shortcut: '{Control>}b{/Control}', + formatClass: textFormatClasses.bold, + }, + ]; + + cases.forEach(({ title, shortcut, formatClass }) => + test(title, async () => { + const user = userEvent.setup(); + await renderRichEditor({ + value: 'The quick brown fox jumps over the lazy dog', + }); + + const editor = screen.getByRole('textbox'); + await user.click(editor); + + const selectionText = 'quick brown fox'; + + // Apply formatting + selectText(editor, selectionText); + await user.keyboard(shortcut); + expect(within(editor).getByText(selectionText)).toHaveClass(formatClass); + + // Remove formatting + selectText(editor, selectionText); + await user.keyboard(shortcut); + expect( + within(editor).getByText(selectionText, { exact: false }), + ).not.toHaveClass(formatClass); + }), + ); +}); diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/heading.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/heading.dom.test.ts new file mode 100644 index 000000000..55255c662 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/heading.dom.test.ts @@ -0,0 +1,34 @@ +import { screen, within } from '@testing-library/react'; + +import { renderRichEditor } from '../utils/renderRichEditor'; +import { selectContent } from '../utils/utils'; + +test('Updates heading level correctly', async () => { + const content = 'Hello, my dear friends!'; + const richEditor = await renderRichEditor({ value: content }); + + const editor = screen.getByRole('textbox'); + selectContent(editor, content); + + // Plain text becomes heading + await richEditor.insert({ type: 'heading', data: { level: 1 } }); + expect(within(editor).getByRole('heading', { level: 1 })).toHaveTextContent(content); + + // Heading level is updated when different level applied + await richEditor.insert({ type: 'heading', data: { level: 3 } }); + + expect(within(editor).getByRole('heading', { level: 3 })).toHaveTextContent(content); + expect(within(editor).queryByRole('heading', { level: 1 })).not.toBeInTheDocument(); + + // Heading reverts to paragraph when same level applied again + await richEditor.insert({ type: 'heading', data: { level: 3 } }); + + expect(within(editor).queryByRole('heading')).not.toBeInTheDocument(); + expect(within(editor).getByText(content)).toBeInTheDocument(); + + // After editing the screen should contain one paragraph with a text + const editorChildren = editor.children; + expect(editorChildren).toHaveLength(1); + expect(editorChildren[0]).toHaveRole('paragraph'); + expect(editorChildren[0]).toHaveTextContent(content); +}); diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/image.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/image.dom.test.ts new file mode 100644 index 000000000..a2466bd22 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/image.dom.test.ts @@ -0,0 +1,78 @@ +import { screen, within } from '@testing-library/react'; + +import { renderRichEditor } from '../utils/renderRichEditor'; +import { setCursorPosition } from '../utils/utils'; + +test(`Inserts image between text nodes`, async () => { + const richEditor = await renderRichEditor({ + value: `My favorite image\n\n I love cat`, + }); + + const editor = screen.getByRole('textbox'); + setCursorPosition(editor, 'My favorite image'.length); + + // Simulate inserting an image via the editor panel action + await richEditor.insert({ + type: 'image', + data: { url: 'http://example.com/cat.png', altText: 'My cat' }, + }); + + // Image nodes inserting asynchronously, so use findByRole to wait for the img to appear + const img = await within(editor).findByRole('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'http://example.com/cat.png'); + expect(img).toHaveAttribute('alt', 'My cat'); + + // Image between two texts + const firstText = within(editor).getByText('My favorite image'); + const secondText = within(editor).getByText('I love cat'); + expect(img).toAppearAfter(firstText); + expect(img).toAppearBefore(secondText); + + // Editor contains only expected nodes + const editorChildren = editor.children; + expect(editorChildren).toHaveLength(3); + Array.from(editor.children).forEach((child) => { + expect(child).toHaveRole('paragraph'); + }); + + // First paragraph contains text and image + expect(editorChildren[0]).toContainElement(img); + expect(editorChildren[0]).toHaveTextContent('My favorite image'); + expect(editorChildren[1]).toHaveTextContent(''); + expect(editorChildren[2]).toHaveTextContent('I love cat'); +}); + +test('Inserts image after block node', async () => { + const richEditor = await renderRichEditor({ + value: '```js\nconst a = 1;\n```', + }); + + // Place cursor position inside the code node + const editor = screen.getByRole('textbox'); + setCursorPosition(within(editor).getByRole('code'), 10); + + await richEditor.insert({ + type: 'image', + data: { url: 'http://example.com/cat.png', altText: 'My cat' }, + }); + + // Wait before image to appear + const img = await within(editor).findByRole('img'); + expect(img).toBeInTheDocument(); + + const codeNode = within(editor).getByRole('code'); + expect(codeNode).toBeInTheDocument(); + + // Image is inserted as next sibling of the code block + expect(img).toAppearAfter(codeNode); + expect(codeNode.nextElementSibling).toContainElement(img); + + const editorChildren = editor.children; + expect(editorChildren).toHaveLength(2); + expect(editorChildren[0]).toHaveRole('code'); + expect(editorChildren[1]).toHaveRole('paragraph'); + + // Image is inside the paragraph + expect(editorChildren[1]).toContainElement(img); +}); diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts new file mode 100644 index 000000000..5ef632627 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/list.dom.test.ts @@ -0,0 +1,165 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderRichEditor } from '../utils/renderRichEditor'; +import { selectContent, setCursorPosition } from '../utils/utils'; + +test('Converts an unordered list to an ordered list', async () => { + const richEditor = await renderRichEditor({ + value: `- First item + - Nested item +- Second item`, + }); + const editor = screen.getByRole('textbox'); + + expect(editor.children[0].tagName).toBe('UL'); + expect(editor.children[0].children).toHaveLength(2); + + // Select text and convert to ordered list + selectContent(editor, 'First item'); + await richEditor.insert({ type: 'list', data: { type: 'ordered' } }); + + expect(editor.children[0].tagName).toBe('OL'); + + const listItems = editor.children[0].children; + expect(listItems).toHaveLength(2); + + // First item has text and nested list + const firstItemChildren = listItems[0].children; + expect(firstItemChildren).toHaveLength(2); + expect(listItems[0]).toHaveTextContent('First item'); + + const nestedListChildren = firstItemChildren[1].children; + expect(nestedListChildren).toHaveLength(1); + expect(nestedListChildren[0]).toHaveTextContent('Nested item'); + + expect(listItems[1].children).toHaveLength(1); + expect(listItems[1]).toHaveTextContent('Second item'); +}); + +test('Pressing Enter adds a new item to the list', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: '- First item' }); + const editor = screen.getByRole('textbox'); + + const initialChildren = editor.children; + expect(initialChildren).toHaveLength(1); + expect(initialChildren[0]).toHaveRole('list'); + expect(initialChildren[0].children).toHaveLength(1); + + const [firstItem] = within(editor).getAllByRole('listitem'); + expect(firstItem).toHaveTextContent('First item'); + + // Place cursor and press Enter + await user.click(firstItem); + setCursorPosition(firstItem, 'First item'.length); + await user.keyboard('{Enter}'); + + // Editor contains only one list with two items + const editorChildren = editor.children; + expect(editorChildren).toHaveLength(1); + expect(editorChildren[0]).toHaveRole('list'); + + const items = editorChildren[0].children; + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('First item'); + expect(items[1]).toHaveTextContent(''); +}); + +test('Pressing Enter on an empty last list item exits the list', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: '- First item' }); + const editor = screen.getByRole('textbox'); + + const initialChildren = editor.children; + expect(initialChildren).toHaveLength(1); + expect(initialChildren[0]).toHaveRole('list'); + expect(initialChildren[0].children).toHaveLength(1); + + expect(within(editor).getAllByRole('listitem')).toHaveLength(1); + expect(within(editor).queryByRole('paragraph')).not.toBeInTheDocument(); + + // Set cursor and press Enter + const firstItem = within(editor).getByRole('listitem'); + await user.click(firstItem); + setCursorPosition(firstItem, 'First item'.length); + await user.keyboard('{Enter}'); + + expect(within(editor).getAllByRole('listitem')).toHaveLength(2); + expect(within(editor).queryByRole('paragraph')).not.toBeInTheDocument(); + + await user.keyboard('{Enter}'); + + // A new empty paragraph is created + expect(within(editor).getByRole('paragraph')).toHaveTextContent(''); + expect(within(editor).getAllByRole('listitem')).toHaveLength(1); + + // Editor contains only list with one items and one paragraph + const editorChildren = editor.children; + expect(editorChildren).toHaveLength(2); + expect(editorChildren[0]).toHaveRole('list'); + expect(editorChildren[0].children).toHaveLength(1); + expect(editorChildren[1]).toHaveRole('paragraph'); +}); + +describe('Rendering', () => { + test('Renders a checklist with checked and unchecked items', async () => { + await renderRichEditor({ + value: `- [x] First item + - [ ] Nested item + - [x] Deep nested item +- [ ] Second item`, + }); + + const checkboxes = within(screen.getByRole('textbox')).getAllByRole('checkbox'); + const [firstItem, nestedItem, deepNestedItem, secondItem] = checkboxes; + expect(checkboxes).toHaveLength(4); + + // First item contains nested list + expect(firstItem).toHaveTextContent('First item'); + expect(firstItem).toBeChecked(); + + const [firstItemNestedList] = within(firstItem).getAllByRole('list'); + expect(firstItemNestedList).toContainElement(nestedItem); + + // Nested item contains deep nested list + expect(nestedItem).toHaveTextContent('Nested item'); + expect(nestedItem).not.toBeChecked(); + + const nestedItemList = within(nestedItem).getByRole('list'); + expect(nestedItemList).toContainElement(deepNestedItem); + + // Deep nested item + expect(deepNestedItem).toHaveTextContent('Deep nested item'); + expect(deepNestedItem).toBeChecked(); + + // Second item + expect(secondItem).toHaveTextContent('Second item'); + expect(secondItem).not.toBeChecked(); + }); + + test('Renders a mixed list with regular and checkbox items correctly', async () => { + await renderRichEditor({ + value: `- [x] First item + - Nested simple item +- [ ] Second item`, + }); + + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(2); + const [first, second] = checkboxes; + + expect(first).toHaveTextContent('First item'); + expect(first).toBeChecked(); + + expect(second).toHaveTextContent('Second item'); + expect(second).not.toBeChecked(); + + // Nested item - regular, not checkbox + const nestedList = within(first).getByRole('list'); + const nestedItem = within(nestedList).getByRole('listitem'); + + expect(nestedItem).toHaveTextContent('Nested simple item'); + expect(within(nestedItem).queryByRole('checkbox')).toBeNull(); + }); +}); diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/paragraph.dom.test.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/paragraph.dom.test.ts new file mode 100644 index 000000000..6891ce474 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/spec/paragraph.dom.test.ts @@ -0,0 +1,67 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { renderRichEditor } from '../utils/renderRichEditor'; +import { setCursorPosition } from '../utils/utils'; + +test('Pressing Enter inside a paragraph splits it into two paragraphs', async () => { + const user = userEvent.setup(); + await renderRichEditor({ value: 'My favorite dish is cake' }); + + // Initial state + const editor = screen.getByRole('textbox'); + expect(editor.children).toHaveLength(1); + const paragraph = editor.children[0]; + expect(paragraph).toHaveRole('paragraph'); + expect(paragraph).toHaveTextContent('My favorite dish is cake'); + + // Place cursor and press Enter + await user.click(paragraph); + setCursorPosition(paragraph, 'My favorite dis'.length); + await user.keyboard('{Enter}'); + + // Editor should now have two paragraphs split at the cursor + const editorChildren = editor.children; + expect(editorChildren).toHaveLength(2); + + expect(editorChildren[0]).toHaveRole('paragraph'); + expect(editorChildren[0]).toHaveTextContent('My favorite dis'); + + expect(editorChildren[1]).toHaveRole('paragraph'); + expect(editorChildren[1]).toHaveTextContent('h is cake'); +}); + +test('Ctrl+Enter exits a block node and creates a new empty paragraph', async () => { + const user = userEvent.setup(); + const content = 'This is a blockquote'; + const richEditor = await renderRichEditor({ value: `> ${content}` }); + + // One blockquote with one paragraph inside + const editor = screen.getByRole('textbox'); + const initialChildren = editor.children; + expect(initialChildren).toHaveLength(1); + expect(initialChildren[0]).toHaveRole('blockquote'); + expect(initialChildren[0]).toHaveTextContent(content); + + expect(within(editor).getAllByRole('paragraph')).toHaveLength(1); + + // Press Ctrl+Enter to exit the blockquote + await user.click(within(editor).getByRole('blockquote')); + await user.keyboard('{Control>}{Enter}{/Control}'); + + // New empty paragraph is added + const editorChildren = editor.children; + expect(editorChildren).toHaveLength(2); + expect(editorChildren[0]).toHaveRole('blockquote'); + expect(editorChildren[1]).toHaveRole('paragraph'); + + const paragraphs = within(editor).getAllByRole('paragraph'); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]).toHaveTextContent(content); + expect(paragraphs[1]).toHaveTextContent(''); + + // Cursor lands in the new paragraph - inserted content inside new paragraph + await richEditor.insert({ type: 'date', data: { date: '01.01.2025' } }); + expect(within(editor).getByText('01.01.2025')).toBeInTheDocument(); + expect(within(editor).getByRole('blockquote')).not.toHaveTextContent('01.01.2025'); +}); diff --git a/packages/app/src/features/NoteEditor/__tests__/utils/renderRichEditor.tsx b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderRichEditor.tsx similarity index 100% rename from packages/app/src/features/NoteEditor/__tests__/utils/renderRichEditor.tsx rename to packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/renderRichEditor.tsx diff --git a/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/richEditorFixtures.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/richEditorFixtures.ts new file mode 100644 index 000000000..0d2384963 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/richEditorFixtures.ts @@ -0,0 +1,5 @@ +export const textFormatClasses = { + bold: /__textBold/, + italic: /__textItalic/, + strikethrough: /__textStrikethrough/, +}; diff --git a/packages/app/src/features/NoteEditor/__tests__/utils/utils.ts b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/utils.ts similarity index 67% rename from packages/app/src/features/NoteEditor/__tests__/utils/utils.ts rename to packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/utils.ts index 941bb261f..303d7ba74 100644 --- a/packages/app/src/features/NoteEditor/__tests__/utils/utils.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/__tests__/utils/utils.ts @@ -50,23 +50,56 @@ export const selectContent = ( /** * Selects the given text within a provided element */ -export const selectText = (element: HTMLElement, text: string) => { - const textNode = getFirstTextNode(element); - if (!textNode) throw new Error('No text node found in the provided element'); +export function selectText(container: Node, text: string): Range { + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); - const startPosition = textNode.textContent.indexOf(text); - if (startPosition === -1) - throw new Error(`Text "${text}" not found in the provided element`); + const nodes: Text[] = []; + let full = ''; + + while (walker.nextNode()) { + const n = walker.currentNode as Text; + nodes.push(n); + full += n.data; + } + + const start = full.indexOf(text); + if (start === -1) throw new Error('Not found'); + + const end = start + text.length; + + let pos = 0; + let startNode: Text | undefined; + let endNode: Text | undefined; + let startOffset = 0; + let endOffset = 0; + + for (const n of nodes) { + const next = pos + n.data.length; + + if (!startNode && start < next) { + startNode = n; + startOffset = start - pos; + } + + if (!endNode && end <= next) { + endNode = n; + endOffset = end - pos; + break; + } + + pos = next; + } const range = document.createRange(); - range.setStart(textNode, startPosition); - range.setEnd(textNode, startPosition + text.length); + range.setStart(startNode!, startOffset); + range.setEnd(endNode!, endOffset); - window.getSelection()?.removeAllRanges(); - window.getSelection()?.addRange(range); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); - fireEvent(document, new Event('selectionchange')); -}; + return range; +} /** * Simulates placing the cursor at a given position within a node diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx index e16a77533..0dfd87249 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/EditorPanelPlugin.tsx @@ -3,14 +3,14 @@ import { $createParagraphNode, $createTextNode, $getRoot, - $getSelection, $isBlockElementNode, $isParagraphNode, $isRootNode, $isTextNode, CONTROLLED_TEXT_INSERTION_COMMAND, + FORMAT_TEXT_COMMAND, } from 'lexical'; -import { $createCodeNode, $isCodeNode } from '@lexical/code'; +import { $createCodeNode } from '@lexical/code'; import { TOGGLE_LINK_COMMAND } from '@lexical/link'; import { INSERT_CHECK_LIST_COMMAND, @@ -20,20 +20,13 @@ import { import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'; import { $createHeadingNode, $createQuoteNode, $isHeadingNode } from '@lexical/rich-text'; -import { $dfs } from '@lexical/utils'; import { InsertingPayloadMap, useEditorPanelContext } from '../../../EditorPanel'; import { $getCursorNode } from '../../utils/selection'; import { INSERT_FILES_COMMAND } from '../Files/FilesPlugin'; import { $createImageNode } from '../Image/ImageNode'; -import { $toggleFormatNode } from './utils/format'; -import { - $canInsertElementsToNode, - $findCommonAncestor, - $getNearestSibling, - $wrapNodes, -} from './utils/tree'; +import { $canInsertElementsToNode, $getNearestSibling, $wrapNodes } from './utils/tree'; /** * Plugin to handle editor panel actions about formatting and nodes insertion @@ -45,21 +38,7 @@ export const EditorPanelPlugin = () => { useEffect(() => { const cleanupFormatting = onFormatting.watch((format) => { - // TODO: support formatting selected text slices - editor.update(() => { - const selection = $getSelection(); - if (!selection) return; - - const nodes = selection.getNodes(); - if (nodes.length === 0) return; - - const commonAncestor = $findCommonAncestor(nodes[0], nodes, (node) => - $dfs(node).some(({ node }) => $isCodeNode(node)), - ); - if (!commonAncestor) return; - - $toggleFormatNode(commonAncestor, format); - }); + editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); }); const cleanupInserting = onInserting.watch((evt) => { diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/format.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/format.tsx deleted file mode 100644 index ebe7f2a22..000000000 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/EditorPanelPlugin/utils/format.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { $isBlockElementNode, LexicalNode } from 'lexical'; - -import { TextFormat } from '../../../../EditorPanel'; -import { $insertAfter } from '../../../utils/selection'; -import { - $createFormattingNode, - $isFormattingNode, - FormattingNode, -} from '../../Markdown/nodes/FormattingNode'; - -export const $getFormatNodes = (node: LexicalNode): FormattingNode[] => { - return node.getParents().filter((node) => $isFormattingNode(node)); -}; - -const formatMap = { - bold: 'b', - italic: 'em', - strikethrough: 'del', -} satisfies Record; - -export const $getFormatNode = (node: LexicalNode, format: TextFormat) => { - const isMatch = (formatNode: FormattingNode) => - formatNode.getTagName() === formatMap[format]; - - if ($isFormattingNode(node) && isMatch(node)) { - return node; - } - - return $getFormatNodes(node).find(isMatch) ?? null; -}; - -export const $setFormatNode = (node: LexicalNode, format: TextFormat) => { - const parentFormat = $getFormatNode(node, format); - if (parentFormat) return; - - const formattingNode = $createFormattingNode({ tag: formatMap[format] }); - if ($isBlockElementNode(node)) { - formattingNode.append(...node.getChildren()); - node.append(formattingNode); - return formattingNode; - } - - node.replace(formattingNode); - formattingNode.append(node); - return formattingNode; -}; - -export const $removeFormatNode = (node: LexicalNode, format: TextFormat) => { - const parentFormat = $getFormatNode(node, format); - if (!parentFormat) return; - - $insertAfter(parentFormat, parentFormat.getChildren()); -}; - -export const $toggleFormatNode = (node: LexicalNode, format: TextFormat) => { - const parentFormat = $getFormatNode(node, format); - - if (!parentFormat) { - $setFormatNode(node, format); - } else { - $removeFormatNode(node, format); - } -}; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Formatting/utils.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Formatting/utils.ts deleted file mode 100644 index 66ba45bb9..000000000 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Formatting/utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IS_BOLD, IS_ITALIC, IS_STRIKETHROUGH, TextFormatType, TextNode } from 'lexical'; -import { $findMatchingParent } from '@lexical/utils'; - -import { $createFormattingNode, FormattingNode } from '../Markdown/nodes/FormattingNode'; - -const formatsMap = { - bold: { - tag: 'b', - code: IS_BOLD, - }, - italic: { - tag: 'em', - code: IS_ITALIC, - }, - strikethrough: { - tag: 'del', - code: IS_STRIKETHROUGH, - }, -} as const; - -export const $convertTextNodeFormatting = (node: TextNode) => { - const format = node.getFormat(); - if (format === 0) return; - - for (const [format, meta] of Object.entries(formatsMap)) { - if (!node.hasFormat(format as TextFormatType)) continue; - - // Looking for parent format node with current format - const parentFormatNode = $findMatchingParent(node, (node) => { - if (!(node instanceof FormattingNode)) return false; - - return node.getTagName().toLowerCase() === meta.tag.toLowerCase(); - }); - if (parentFormatNode) continue; - - // Remove format - node.toggleFormat(format as TextFormatType); - - // Wrap to formatting node - const newNode = $createFormattingNode({ tag: meta.tag }); - node.replace(newNode); - newNode.append(node); - node.select(); - } -}; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Formatting/FormattingPlugin.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts similarity index 58% rename from packages/app/src/features/NoteEditor/RichEditor/plugins/Formatting/FormattingPlugin.ts rename to packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts index 25343657d..b01406f95 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Formatting/FormattingPlugin.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/KeyboardControlsPlugin/KeyboardControlsPlugin.ts @@ -1,7 +1,6 @@ import { useEffect } from 'react'; import { $createParagraphNode, - $createTextNode, $getSelection, $isParagraphNode, $isTextNode, @@ -10,17 +9,12 @@ import { createCommand, ElementNode, KEY_ENTER_COMMAND, - KEY_SPACE_COMMAND, - TextNode, } from 'lexical'; import { $isCodeNode } from '@lexical/code'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $isQuoteNode } from '@lexical/rich-text'; import { mergeRegister } from '@lexical/utils'; -import { $isFormattingNode, FormattingNode } from '../Markdown/nodes/FormattingNode'; -import { $convertTextNodeFormatting } from './utils'; - const OUT_OF_BLOCK_NODE_COMMAND = createCommand(); /** @@ -60,20 +54,15 @@ const $getParentOfTextOnEnd = (selection: BaseSelection | null) => { return null; }; -export const FormattingPlugin = () => { +/** + * Plugin for nodes management via keyboard + */ +export const KeyboardControlsPlugin = () => { const [editor] = useLexicalComposerContext(); useEffect( () => mergeRegister( - editor.registerNodeTransform(TextNode, $convertTextNodeFormatting), - editor.registerNodeTransform(FormattingNode, (node: FormattingNode) => { - // Remove empty formatting nodes - const textContent = node.getTextContent(); - if (textContent.length > 0) return; - - node.remove(); - }), editor.registerCommand( KEY_ENTER_COMMAND, (event) => { @@ -105,51 +94,6 @@ export const FormattingPlugin = () => { }, COMMAND_PRIORITY_LOW, ), - editor.registerCommand( - KEY_SPACE_COMMAND, - (event) => { - // Out of inline nodes by Shift+Space - if (!event.shiftKey) return false; - - const selection = $getSelection(); - if (!selection) return false; - - const points = selection.getStartEndPoints(); - if (!points) return false; - - const [start, end] = points; - - if ( - start.getNode() !== end.getNode() || - start.offset !== end.offset - ) - return false; - - const focusedNode = end.getNode(); - if (!$isTextNode(focusedNode)) return false; - - const parent = focusedNode.getParent(); - if ( - !parent || - !$isFormattingNode(parent) || - parent.getLastChild() !== focusedNode - ) - return false; - - if (end.offset !== focusedNode.getTextContentSize()) return false; - - const textNode = $createTextNode(' '); - parent.insertAfter(textNode); - textNode.select(); - - event.stopImmediatePropagation(); - event.stopPropagation(); - event.preventDefault(); - - return true; - }, - COMMAND_PRIORITY_LOW, - ), ), [editor], ); diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap index aff132457..9b9dd5f91 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/__snapshots__/markdown-roundtrips-consistency.test.ts.snap @@ -1,5 +1,10 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Bold node inside bold node 1`] = ` +"**foo bar baz** +" +`; + exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Check list with all checked items 1`] = ` "- [x] foo - [x] bar @@ -38,6 +43,11 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; +exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Header with formatting 1`] = ` +"### *All header is italic, **something bold**, ~~strikethrough~~, \`inline code\`* +" +`; + exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Image 1`] = ` "![Alt text](https://url) " @@ -48,6 +58,16 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; +exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Inline code 1`] = ` +"Text with \`code\` +" +`; + +exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Inline code with formatting 1`] = ` +"Text with ***~~\`formatted code\`~~*** +" +`; + exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Links 1`] = ` "Some [link](https://url "Title") and [another link](proto://url) " @@ -65,6 +85,15 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; +exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > List with formatting 1`] = ` +"- *All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** | plain text and ~~deleted~~ +- Plain text +- *Italic* +- **Bold** +- ~~Delete~~ +" +`; + exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Mixed list 1`] = ` "- level 1-1 - [x] level 2-1 @@ -106,6 +135,20 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; +exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Quote with formatting 1`] = ` +"> *All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** | plain text and ~~deleted~~ +> Plain text +> *Italic* +> **Bold** +> ~~Delete~~ +" +`; + +exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Rich formatting 1`] = ` +"*All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** +" +`; + exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Simple code 1`] = ` "\`\`\`js const x = 42; @@ -114,6 +157,11 @@ console.log("Your number is", x ** x); " `; +exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Simple formatting 1`] = ` +"Text may be **bold**, *italic*, ~~strikethrough~~. +" +`; + exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Simple post with headers 1`] = ` "# The title @@ -153,6 +201,14 @@ exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > " `; +exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Table with formatting 1`] = ` +"| foo | bar | +| ---------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| *All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** | plain text and ~~deleted~~ | +| **bold** | *italic* | +" +`; + exports[`Markdown-Lexical-Markdown round-trips must be consistent on AST level > Text with few empty lines 1`] = ` "foo diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts index bba1a6b17..7105ea31f 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-roundtrips-consistency.test.ts @@ -6,15 +6,21 @@ import { $convertFromMarkdownString, $convertToMarkdownString, $serializeAsMarkdownAST, + markdownProcessor, parseMarkdownToAST, } from '../markdownParser'; import { detailsWithSummary, formattedLine, + formattingInList, + formattingInQuote, + formattingInTable, mixedList, nestedQuote, postWithHeaders, + richFormatting, simpleCode, + simpleFormatting, simpleQuote, simpleTable, unsupportedFeatures, @@ -25,12 +31,59 @@ import { updateEditorState, } from './utils'; +test('Markdown parser round-trips', () => { + const ast = parseMarkdownToAST(richFormatting); + const out = markdownProcessor.stringify(ast); + + expect(normalizeMarkdownTree(parseMarkdownToAST(out))).toEqual( + normalizeMarkdownTree(ast), + ); +}); + describe('Markdown-Lexical-Markdown round-trips must be consistent on AST level', () => { const cases = [ + { + title: 'Bold node inside bold node', + markdown: '**foo __bar__ baz**', + inconsistentAST: true, + }, + { + title: 'Inline code', + markdown: 'Text with `code`', + }, + { + title: 'Inline code with formatting', + markdown: 'Text with ***~~`formatted code`~~***', + }, + { + title: 'Rich formatting', + markdown: richFormatting, + }, { title: 'Plain list', markdown: '- foo\n - bar\n - baz', }, + { + title: 'Simple formatting', + markdown: simpleFormatting, + }, + { + title: 'Header with formatting', + markdown: + '### *All header is italic, **something bold**, ~~strikethrough~~, `inline code`*', + }, + { + title: 'List with formatting', + markdown: formattingInList, + }, + { + title: 'Quote with formatting', + markdown: formattingInQuote, + }, + { + title: 'Table with formatting', + markdown: formattingInTable, + }, { title: 'List item with inline elements', markdown: '- foo [bar](...) baz', @@ -111,7 +164,7 @@ describe('Markdown-Lexical-Markdown round-trips must be consistent on AST level' }, ]; - cases.forEach(({ title, markdown: sourceText }) => + cases.forEach(({ title, markdown: sourceText, inconsistentAST }) => test(title, async () => { const { editor, destroy } = createLexicalEditorInstance(); onTestFinished(destroy); @@ -120,11 +173,23 @@ describe('Markdown-Lexical-Markdown round-trips must be consistent on AST level' $convertFromMarkdownString(sourceText); }); - expect( - editor.read(() => normalizeMarkdownTree($serializeAsMarkdownAST())), - ).toMatchObject(normalizeMarkdownTree(parseMarkdownToAST(sourceText))); + // Skip AST equality check for cases where AST may be optimized + // Even in that cases the source and output must be equal visually + if (!inconsistentAST) { + expect( + editor.read(() => normalizeMarkdownTree($serializeAsMarkdownAST())), + ).toMatchObject(normalizeMarkdownTree(parseMarkdownToAST(sourceText))); + } + + const out = editor.read(() => $convertToMarkdownString()); + expect(out).toMatchSnapshot(); + + // Parse markdown + await updateEditorState(editor, () => { + $convertFromMarkdownString(out); + }); - expect(editor.read(() => $convertToMarkdownString())).toMatchSnapshot(); + expect(editor.read(() => $convertToMarkdownString())).toBe(out); }), ); }); diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-samples/index.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-samples/index.ts index 039915c86..9084c67c5 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-samples/index.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/markdown-samples/index.ts @@ -1,3 +1,34 @@ +export const simpleFormatting = ` +Text may be **bold**, *italic*, ~~strikethrough~~. +`.trim(); + +export const richFormatting = ` +*All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** +`.trim(); + +export const formattingInList = ` +- *All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** | plain text and ~~deleted~~ +- Plain text +- *Italic* +- **Bold** +- ~~Delete~~ +`.trim(); + +export const formattingInQuote = ` +> *All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** | plain text and ~~deleted~~ +> Plain text +> *Italic* +> **Bold** +> ~~Delete~~ +`.trim(); + +export const formattingInTable = ` +| foo | bar | +| ---- | ----- | +| *All text can be italic, something additionally can be **bold**, ~~strikethrough~~, or **~~bold AND strikethrough~~*** | plain text and ~~deleted~~ | +| **bold** | *italic* | +`.trim(); + export const mixedList = ` - level 1-1 - [x] level 2-1 diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/utils.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/utils.ts index 5010a7824..01e2a1aa1 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/utils.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/__tests__/utils.ts @@ -11,7 +11,6 @@ import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'; import { ImageNode } from '../../Image/ImageNode'; -import { FormattingNode } from '../nodes/FormattingNode'; import { RawNode } from '../nodes/RawNode'; /** @@ -47,7 +46,6 @@ export const createLexicalEditorInstance = () => { nodes: [ // App specific nodes RawNode, - FormattingNode, ImageNode, // Plugins diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/convertLexicalNodeToMarkdownNode.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/convertLexicalNodeToMarkdownNode.ts index 0f464f480..d6e5fcae5 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/convertLexicalNodeToMarkdownNode.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/convertLexicalNodeToMarkdownNode.ts @@ -1,11 +1,15 @@ -import { $isLineBreakNode, $isParagraphNode, $isTextNode, LexicalNode } from 'lexical'; +import { + $isLineBreakNode, + $isParagraphNode, + $isTextNode, + LexicalNode, + TextFormatType, +} from 'lexical'; import { Blockquote, Break, Code, Content, - Delete, - Emphasis, Heading, HTML, Image, @@ -16,7 +20,6 @@ import { Paragraph, PhrasingContent, RootContent, - Strong, Table, TableCell, TableRow, @@ -32,7 +35,6 @@ import { $isHeadingNode, $isQuoteNode } from '@lexical/rich-text'; import { $isTableCellNode, $isTableNode, $isTableRowNode } from '@lexical/table'; import { $isImageNode } from '../Image/ImageNode'; -import { $isFormattingNode } from './nodes/FormattingNode'; const inlineTypes = new Set([ 'text', @@ -98,13 +100,30 @@ export const convertLexicalNodeToMarkdownNode = (node: LexicalNode): Content => } if ($isTextNode(node)) { - if (node.hasFormat('code')) { - return u('inlineCode', { - value: node.getTextContent(), - }) satisfies InlineCode; - } - - return u('text', { value: node.getTextContent() }) satisfies Text; + let wrappedNode: PhrasingContent = node.hasFormat('code') + ? (u('inlineCode', { + value: node.getTextContent(), + }) satisfies InlineCode) + : (u('text', { + value: node.getTextContent(), + }) satisfies Text); + + // TODO: support all formats like super/sub, etc + const formatsOrder = ( + ['bold', 'italic', 'strikethrough'] satisfies TextFormatType[] + ).reverse(); + const nodesMap = { + italic: 'emphasis', + bold: 'strong', + strikethrough: 'delete', + } satisfies Partial>; + + formatsOrder.forEach((format) => { + if (!node.hasFormat(format)) return; + wrappedNode = u(nodesMap[format], { children: [wrappedNode] }); + }); + + return wrappedNode; } if ($isCodeNode(node)) { @@ -115,33 +134,6 @@ export const convertLexicalNodeToMarkdownNode = (node: LexicalNode): Content => }) satisfies Code; } - if ($isFormattingNode(node)) { - const tagName = node.getTagName(); - switch (tagName) { - case 'em': { - return u('emphasis', { - children: node - .getChildren() - .map(convertLexicalNodeToMarkdownNode) as Emphasis['children'], - }) satisfies Emphasis; - } - case 'del': { - return u('delete', { - children: node - .getChildren() - .map(convertLexicalNodeToMarkdownNode) as Delete['children'], - }) satisfies Delete; - } - case 'b': { - return u('strong', { - children: node - .getChildren() - .map(convertLexicalNodeToMarkdownNode) as Strong['children'], - }) satisfies Strong; - } - } - } - if ($isHorizontalRuleNode(node)) { return u('thematicBreak') satisfies ThematicBreak; } diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/createSyncContext.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/createSyncContext.test.ts new file mode 100644 index 000000000..f1dd79715 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/createSyncContext.test.ts @@ -0,0 +1,64 @@ +import { wait } from '@utils/time'; + +import { createSyncContext } from './createSyncContext'; + +test('Default value can be used anywhere', () => { + const ctx = createSyncContext(1); + expect(ctx.get()).toBe(1); +}); + +test('Overridden value can be accessed inside context', () => { + const ctx = createSyncContext(1); + + expect.assertions(1); + ctx.use(2, () => { + expect(ctx.get()).toBe(2); + }); +}); + +test('The nearest value must be accessible inside context', () => { + expect.assertions(3); + + const ctx = createSyncContext(1); + ctx.use(2, () => { + ctx.use(3, () => { + expect(ctx.get()).toBe(3); + + ctx.use(4, () => { + ctx.use(5, () => { + expect(ctx.get()).toBe(5); + }); + }); + + expect(ctx.get()).toBe(3); + }); + }); +}); + +test('Context use can return value', () => { + const ctx = createSyncContext(1); + expect( + ctx.use(2, () => 'test'), + 'Callback may return any value', + ).toBe('test'); + + expect( + ctx.use(2, () => ctx.use(3, () => ctx.use(4, () => ctx.get()))), + 'Callback may return its context value', + ).toBe(4); +}); + +test('Async code may access context via closure', async () => { + const ctx = createSyncContext(1); + + const getValue = () => { + const value = ctx.get(); + + return (async () => { + await wait(10); + return value; + })(); + }; + + await expect(ctx.use(2, () => getValue())).resolves.toBe(2); +}); diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/createSyncContext.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/createSyncContext.ts new file mode 100644 index 000000000..526350976 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/createSyncContext.ts @@ -0,0 +1,18 @@ +export const createSyncContext = (defaultValue: T) => { + let context: { current: T } | null = null; + + const get = () => (context ? context.current : defaultValue); + const use = (value: T, callback: () => R) => { + // Set context + const currentContext = context; + context = { current: value }; + try { + return callback(); + } finally { + // Restore previous context + context = currentContext; + } + }; + + return { get, use }; +}; diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts index b418bf011..16bd97908 100644 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/markdownParser.ts @@ -5,16 +5,17 @@ import { $createTextNode, $getRoot, $isTextNode, - IS_CODE, LexicalNode, + TextFormatType, + TextNode, } from 'lexical'; -import { Content, Root, RootContent } from 'mdast'; +import { Content, type Root } from 'mdast'; import remarkGfm from 'remark-gfm'; import remarkParse from 'remark-parse'; import remarkStringify from 'remark-stringify'; -import { Plugin, unified } from 'unified'; +import { unified } from 'unified'; import { u } from 'unist-builder'; -import { CONTINUE, SKIP, visit } from 'unist-util-visit'; +import { TextFormat } from '@features/NoteEditor/EditorPanel'; import { $createCodeNode } from '@lexical/code'; import { $createLinkNode } from '@lexical/link'; import { $createListItemNode, $createListNode, ListType } from '@lexical/list'; @@ -29,67 +30,10 @@ import { import { $createImageNode } from '../Image/ImageNode'; import { convertLexicalNodeToMarkdownNode } from './convertLexicalNodeToMarkdownNode'; -import { $createFormattingNode } from './nodes/FormattingNode'; +import { createSyncContext } from './createSyncContext'; import { $createRawNode } from './nodes/RawNode'; - -const remarkPreserveBlankLines: Plugin<[], Root> = () => { - const ignoredNodeTypes = new Set([ - 'table', - 'tableCell', - 'tableRow', - ] satisfies RootContent['type'][]); - - return (tree: Root) => { - const skipNodes = new Set(); - - visit(tree, (node) => { - // Skip nodes with no nested elements - if (!('children' in node)) return SKIP; - - // Skip ignored node types - if (ignoredNodeTypes.has(node.type)) return SKIP; - - // Skip already handled nodes - if (skipNodes.has(node)) return SKIP; - - const newChildren: RootContent[] = []; - for (let i = 0; i < node.children.length; i++) { - const current = node.children[i]; - const next = node.children[i + 1]; - - // Collect its own children - newChildren.push(current); - - // Add empty lines to preserve - if (next && current.position && next.position) { - const lineGap = next.position.start.line - current.position.end.line; - // lineGap === 2 means exactly one blank line, 3 means two, etc. - const blankLineCount = lineGap - 1; - - for (let b = 0; b < blankLineCount; b++) { - const line = current.position.end.line + 1 + b; - const emptyLine = { - type: 'paragraph', - children: [], - // TODO: add tests to verify position are correct in complex cases - position: { - start: { line, column: 1, offset: 0 }, - end: { line, column: 1, offset: 0 }, - }, - } as RootContent; - - newChildren.push(emptyLine); - skipNodes.add(emptyLine); - } - } - } - - node.children = newChildren; - - return CONTINUE; - }); - }; -}; +import { liftFormattingNodes } from './remark/remarkLiftFormatting'; +import { remarkPreserveBlankLines } from './remark/remarkPreserveBlankLines'; export const markdownProcessor = unified() .use(remarkParse) @@ -131,31 +75,51 @@ export const $wrapWithParagraph = (children: LexicalNode[]) => { return p; }; +const $setTextNodeFormat = (node: TextNode, formats: Iterable) => { + for (const format of formats) { + node.toggleFormat(format); + } +}; + +/** + * MDAST node type to Lexical text format + */ +const textFormattingMap = { + emphasis: 'italic', + strong: 'bold', + delete: 'strikethrough', +} satisfies Record; + export const $convertFromMarkdownString = (rawMarkdown: string) => { const mdTree = parseMarkdownToAST(rawMarkdown); - function convertToMarkdownNode(node: Content): LexicalNode { + const textFormatContext = createSyncContext>(new Set()); + function convertToMarkdownNode(node: Content): LexicalNode[] { switch (node.type) { case 'text': { - return $createTextNode(node.value); + const t = $createTextNode(node.value); + $setTextNodeFormat(t, textFormatContext.get()); + return [t]; } case 'paragraph': { const paragraph = $createParagraphNode(); paragraph.append(...convertToMarkdownNodes(node.children)); - return paragraph; + return [paragraph]; } case 'image': { - return $createImageNode({ - src: node.url, - altText: node.alt || '', - }); + return [ + $createImageNode({ + src: node.url, + altText: node.alt || '', + }), + ]; } case 'heading': { const heading = $createHeadingNode(`h${node.depth}`); heading.append(...convertToMarkdownNodes(node.children)); - return heading; + return [heading]; } case 'list': { let listType: ListType = 'bullet'; @@ -172,7 +136,7 @@ export const $convertFromMarkdownString = (rawMarkdown: string) => { const list = $createListNode(listType); list.append(...convertToMarkdownNodes(node.children)); - return list; + return [list]; } case 'listItem': { const listItem = $createListItemNode(node.checked ?? undefined); @@ -180,29 +144,29 @@ export const $convertFromMarkdownString = (rawMarkdown: string) => { $wrapWithParagraph(convertToMarkdownNodes(node.children)), ); - return listItem; + return [listItem]; } case 'link': { const link = $createLinkNode(node.url, { title: node.title }); link.append(...convertToMarkdownNodes(node.children)); - return link; + return [link]; } case 'blockquote': { const quote = $createQuoteNode(); quote.append(...convertToMarkdownNodes(node.children)); - return quote; + return [quote]; } case 'table': { const table = $createTableNode(); table.append(...convertToMarkdownNodes(node.children)); - return table; + return [table]; } case 'tableRow': { const tableRow = $createTableRowNode(); tableRow.append(...convertToMarkdownNodes(node.children)); - return tableRow; + return [tableRow]; } case 'tableCell': { const tableCell = $createTableCellNode(TableCellHeaderStates.NO_STATUS); @@ -211,55 +175,47 @@ export const $convertFromMarkdownString = (rawMarkdown: string) => { p.append(...convertToMarkdownNodes(node.children)); tableCell.append(p); - return tableCell; + return [tableCell]; } case 'code': { const code = $createCodeNode(node.lang); code.append($createTextNode(node.value)); - return code; + return [code]; } case 'inlineCode': { const text = $createTextNode(node.value); - text.setFormat(IS_CODE); - return text; - } - case 'emphasis': { - const format = $createFormattingNode({ tag: 'em' }); - format.append(...convertToMarkdownNodes(node.children)); - - return format; - } - case 'strong': { - const format = $createFormattingNode({ tag: 'b' }); - format.append(...convertToMarkdownNodes(node.children)); - - return format; + $setTextNodeFormat(text, [...textFormatContext.get(), 'code']); + return [text]; } + // TODO: handle sub/super/etc + case 'emphasis': + case 'strong': case 'delete': { - const format = $createFormattingNode({ tag: 'del' }); - format.append(...convertToMarkdownNodes(node.children)); - - return format; + return textFormatContext.use( + textFormatContext + .get() + .union(new Set([textFormattingMap[node.type]])), + () => convertToMarkdownNodes(node.children), + ); } case 'break': { - return $createLineBreakNode(); + return [$createLineBreakNode()]; } case 'thematicBreak': { - const format = $createHorizontalRuleNode(); - return format; + return [$createHorizontalRuleNode()]; } } const rawNode = $createRawNode(); rawNode.append($createTextNode(dumpMarkdownNode(node))); - return rawNode; + return [rawNode]; } function convertToMarkdownNodes(mdTree: Content[]): LexicalNode[] { const lexicalTree: LexicalNode[] = []; for (const mdNode of mdTree) { - lexicalTree.push(convertToMarkdownNode(mdNode)); + lexicalTree.push(...convertToMarkdownNode(mdNode)); } return lexicalTree; @@ -281,9 +237,14 @@ export const $convertFromMarkdownString = (rawMarkdown: string) => { export const $serializeAsMarkdownAST = () => { const rootNode = $getRoot(); const children = rootNode.getChildren(); - return u('root', { + + const tree = u('root', { children: children.map(convertLexicalNodeToMarkdownNode), }) satisfies Root; + + liftFormattingNodes(tree); + + return tree; }; export const $convertToMarkdownString = () => { diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/nodes/FormattingNode.tsx b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/nodes/FormattingNode.tsx deleted file mode 100644 index 0a86fa223..000000000 --- a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/nodes/FormattingNode.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/* eslint-disable @typescript-eslint/no-use-before-define */ - -import { - $applyNodeReplacement, - DOMExportOutput, - ElementNode, - LexicalNode, - NodeKey, - SerializedElementNode, - Spread, -} from 'lexical'; - -export interface FormattingNodePayload { - tag: string; - key?: NodeKey; -} - -export type SerializedFormattingNode = Spread< - { - tag: string; - }, - SerializedElementNode ->; - -export class FormattingNode extends ElementNode { - __tagName: string; - - static getType(): string { - return 'formatting'; - } - - static clone(node: FormattingNode): FormattingNode { - return new FormattingNode(node.__tagName, node.__key); - } - - static importJSON(serializedNode: SerializedFormattingNode): FormattingNode { - const { tag } = serializedNode; - return $createFormattingNode({ tag }); - } - - exportDOM(): DOMExportOutput { - const element = document.createElement(this.__tagName); - return { element }; - } - - constructor(tagName: string, key?: NodeKey) { - super(key); - this.__tagName = tagName; - } - - isInline(): boolean { - return true; - } - - exportJSON(): SerializedFormattingNode { - return { - ...super.exportJSON(), - type: this.getType(), - tag: this.getTagName(), - }; - } - - getTagName() { - return this.__tagName; - } - - // View - - createDOM(): HTMLElement { - const element = document.createElement(this.__tagName); - return element; - } - - updateDOM(): false { - return false; - } -} - -export function $createFormattingNode({ - tag, - key, -}: FormattingNodePayload): FormattingNode { - return $applyNodeReplacement(new FormattingNode(tag, key)); -} - -export function $isFormattingNode( - node: LexicalNode | null | undefined, -): node is FormattingNode { - return node instanceof FormattingNode; -} diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/liftFormattingNodes.test.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/liftFormattingNodes.test.ts new file mode 100644 index 000000000..32549c60b --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/liftFormattingNodes.test.ts @@ -0,0 +1,368 @@ +import { Root } from 'mdast'; +import { u } from 'unist-builder'; + +import { liftFormattingNodes } from './remarkLiftFormatting'; + +describe('Normalize Lexical tree', () => { + test('Complex formatting', () => { + expect( + liftFormattingNodes({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'emphasis', + children: [ + { + type: 'text', + value: 'All text can be italic, something additionally can be ', + }, + ], + }, + { + type: 'emphasis', + children: [ + { + type: 'strong', + children: [{ type: 'text', value: 'bold' }], + }, + ], + }, + { + type: 'emphasis', + children: [{ type: 'text', value: ', ' }], + }, + { + type: 'delete', + children: [ + { + type: 'emphasis', + children: [ + { type: 'text', value: 'strikethrough' }, + ], + }, + ], + }, + { + type: 'emphasis', + children: [{ type: 'text', value: ', or ' }], + }, + { + type: 'delete', + children: [ + { + type: 'emphasis', + children: [ + { + type: 'strong', + children: [ + { + type: 'text', + value: 'bold AND strikethrough', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + } satisfies Root), + ).toEqual({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'emphasis', + children: [ + { + type: 'text', + value: 'All text can be italic, something additionally can be ', + }, + { + type: 'strong', + children: [{ type: 'text', value: 'bold' }], + }, + { type: 'text', value: ', ' }, + { + type: 'delete', + children: [{ type: 'text', value: 'strikethrough' }], + }, + { type: 'text', value: ', or ' }, + { + type: 'strong', + children: [ + { + type: 'delete', + children: [ + { + type: 'text', + value: 'bold AND strikethrough', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + } satisfies Root); + }); + + test('Grouping siblings', () => { + expect( + liftFormattingNodes({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'emphasis', + children: [ + { + type: 'text', + value: 'All text can be italic, something additionally can be ', + }, + ], + }, + { + type: 'emphasis', + children: [ + { + type: 'strong', + children: [{ type: 'text', value: 'bold' }], + }, + ], + }, + { + type: 'emphasis', + children: [{ type: 'text', value: ', ' }], + }, + { + type: 'delete', + children: [ + { + type: 'emphasis', + children: [ + { type: 'text', value: 'strikethrough' }, + ], + }, + ], + }, + { + type: 'delete', + children: [ + { + type: 'emphasis', + children: [ + { + type: 'strong', + children: [ + { + type: 'text', + value: 'bold AND strikethrough', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + } satisfies Root), + ).toEqual({ + type: 'root', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'emphasis', + children: [ + { + type: 'text', + value: 'All text can be italic, something additionally can be ', + }, + { + type: 'strong', + children: [{ type: 'text', value: 'bold' }], + }, + { type: 'text', value: ', ' }, + { + type: 'delete', + children: [ + { type: 'text', value: 'strikethrough' }, + { + type: 'strong', + children: [ + { + type: 'text', + value: 'bold AND strikethrough', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + } satisfies Root); + }); +}); + +test('Sibling nodes with exact format must be grouped', () => { + expect( + liftFormattingNodes( + u('root', { + children: [ + u('paragraph', { + children: [ + u('emphasis', { + children: [ + u('text', { value: 'Italic text' }), + u('strong', { + children: [ + u('delete', { + children: [ + u('text', { + value: 'Strong & delete text #1', + }), + ], + }), + ], + }), + u('strong', { + children: [ + u('delete', { + children: [ + u('text', { + value: 'Strong & delete text #2', + }), + ], + }), + ], + }), + ], + }), + u('text', { value: 'Text with no formatting' }), + ], + }), + ], + }) satisfies Root, + ), + ).toEqual( + u('root', { + children: [ + u('paragraph', { + children: [ + u('emphasis', { + children: [ + u('text', { value: 'Italic text' }), + u('strong', { + children: [ + u('delete', { + children: [ + u('text', { + value: 'Strong & delete text #1', + }), + u('text', { + value: 'Strong & delete text #2', + }), + ], + }), + ], + }), + ], + }), + u('text', { value: 'Text with no formatting' }), + ], + }), + ], + }) satisfies Root, + ); +}); + +test('Sibling nodes with equal format must be grouped', () => { + expect( + liftFormattingNodes( + u('root', { + children: [ + u('paragraph', { + children: [ + u('emphasis', { + children: [ + u('text', { value: 'Italic text' }), + u('delete', { + children: [ + u('strong', { + children: [ + u('text', { + value: 'Strong & delete text #1', + }), + ], + }), + ], + }), + u('strong', { + children: [ + u('delete', { + children: [ + u('text', { + value: 'Strong & delete text #2', + }), + ], + }), + ], + }), + ], + }), + u('text', { value: 'Text with no formatting' }), + ], + }), + ], + }) satisfies Root, + ), + ).toEqual( + u('root', { + children: [ + u('paragraph', { + children: [ + u('emphasis', { + children: [ + u('text', { value: 'Italic text' }), + u('strong', { + children: [ + u('delete', { + children: [ + u('text', { + value: 'Strong & delete text #1', + }), + u('text', { + value: 'Strong & delete text #2', + }), + ], + }), + ], + }), + ], + }), + u('text', { value: 'Text with no formatting' }), + ], + }), + ], + }) satisfies Root, + ); +}); diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkLiftFormatting.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkLiftFormatting.ts new file mode 100644 index 000000000..dafdc7764 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkLiftFormatting.ts @@ -0,0 +1,137 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import type { Delete, Emphasis, PhrasingContent, Root, RootContent, Strong } from 'mdast'; +import { SKIP, visit } from 'unist-util-visit'; + +/** + * Formatting node types treated as pure "marks": they wrap exactly one + * thing and carry no data of their own (unlike e.g. `link`/`image`), so + * they can be freely merged, split and re-nested. + * + * The ORDER here is significant: it's the canonical outer -> inner nesting + * order that lifted formatting gets rebuilt in, regardless of whatever + * (possibly inconsistent) order the input used. Add more types here to + * support lifting them too - no other code needs to change. + */ +const MARK_ORDER = ['emphasis', 'strong', 'delete'] satisfies ( + | Emphasis['type'] + | Strong['type'] + | Delete['type'] +)[]; + +type Mark = (typeof MARK_ORDER)[number]; +type MarkNode = Emphasis | Strong | Delete; + +const MARK_TYPES = new Set(MARK_ORDER); + +function isMarkNode(node: PhrasingContent): node is MarkNode { + return MARK_TYPES.has(node.type); +} + +function hasChildren( + node: PhrasingContent, +): node is PhrasingContent & { children: PhrasingContent[] } { + return ( + 'children' in node && + Array.isArray((node as { children?: unknown }).children) && + (node as { children: unknown[] }).children.length > 0 + ); +} + +/** Flattened leaf: some content plus the set of marks wrapping it. */ +interface Leaf { + content: PhrasingContent; + marks: Set; +} + +/** + * Descends through a chain of single-child mark wrappers, collecting every + * mark found along the way, stopping at the first node that either isn't a + * mark node or doesn't have exactly one child. That stopping node's own + * children (if any) are recursively re-lifted, so formatting nested inside + * e.g. links, or inside already-multi-child mark nodes, is still + * normalized without being lifted past a boundary it shouldn't cross. + */ +function flattenToLeaf(node: PhrasingContent, marks: Set): Leaf { + if (isMarkNode(node) && node.children.length === 1) { + marks.add(node.type); + return flattenToLeaf(node.children[0], marks); + } + + if (hasChildren(node)) + return { + content: { + ...node, + children: liftChildren(node.children), + } as PhrasingContent, + marks, + }; + + return { content: node, marks }; +} + +/** + * Rebuilds a flat list of leaves into a tree. Marks already consumed are + * removed from each leaf's `Set`, so scanning `MARK_ORDER` from the start + * every time is both safe (can't re-select a consumed mark) and necessary: + * different leaves inside the same run may carry different subsets of + * marks, so the "next" priority mark to lift can legitimately be one that + * comes *before* a mark that was just grouped one level up. + */ +function rebuildFromLeaves(leaves: Leaf[]): PhrasingContent[] { + const result: PhrasingContent[] = []; + let i = 0; + + while (i < leaves.length) { + let markIndex = 0; + while ( + markIndex < MARK_ORDER.length && + !leaves[i].marks.has(MARK_ORDER[markIndex]) + ) + markIndex++; + + if (markIndex === MARK_ORDER.length) { + result.push(leaves[i].content); + i++; + continue; + } + + const mark = MARK_ORDER[markIndex]; + const group: Leaf[] = []; + + while (i < leaves.length && leaves[i].marks.has(mark)) { + leaves[i].marks.delete(mark); + group.push(leaves[i]); + i++; + } + + result.push({ + type: mark, + children: rebuildFromLeaves(group), + } as PhrasingContent); + } + + return result; +} + +function liftChildren(children: PhrasingContent[]): PhrasingContent[] { + return rebuildFromLeaves(children.map((child) => flattenToLeaf(child, new Set()))); +} + +export const liftFormattingNodes = (tree: Root) => { + visit( + tree, + ['heading', 'paragraph', 'tableCell'] satisfies RootContent['type'][], + (node) => { + if ('children' in node) { + node.children = liftChildren(node.children); + } + return SKIP; + }, + ); + + return tree; +}; + +export default function remarkLiftFormatting() { + return liftFormattingNodes; +} diff --git a/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts new file mode 100644 index 000000000..450800060 --- /dev/null +++ b/packages/app/src/features/NoteEditor/RichEditor/plugins/Markdown/remark/remarkPreserveBlankLines.ts @@ -0,0 +1,62 @@ +import { type Root, RootContent } from 'mdast'; +import { Plugin } from 'unified'; +import { CONTINUE, SKIP, visit } from 'unist-util-visit'; + +export const remarkPreserveBlankLines: Plugin<[], Root> = () => { + const ignoredNodeTypes = new Set([ + 'table', + 'tableCell', + 'tableRow', + ] satisfies RootContent['type'][]); + + return (tree: Root) => { + const skipNodes = new Set(); + + visit(tree, (node) => { + // Skip nodes with no nested elements + if (!('children' in node)) return SKIP; + + // Skip ignored node types + if (ignoredNodeTypes.has(node.type)) return SKIP; + + // Skip already handled nodes + if (skipNodes.has(node)) return SKIP; + + const newChildren: RootContent[] = []; + for (let i = 0; i < node.children.length; i++) { + const current = node.children[i]; + const next = node.children[i + 1]; + + // Collect its own children + newChildren.push(current); + + // Add empty lines to preserve + if (next && current.position && next.position) { + const lineGap = next.position.start.line - current.position.end.line; + // lineGap === 2 means exactly one blank line, 3 means two, etc. + const blankLineCount = lineGap - 1; + + for (let b = 0; b < blankLineCount; b++) { + const line = current.position.end.line + 1 + b; + const emptyLine = { + type: 'paragraph', + children: [], + // TODO: add tests to verify position are correct in complex cases + position: { + start: { line, column: 1, offset: 0 }, + end: { line, column: 1, offset: 0 }, + }, + } as RootContent; + + newChildren.push(emptyLine); + skipNodes.add(emptyLine); + } + } + } + + node.children = newChildren; + + return CONTINUE; + }); + }; +}; diff --git a/packages/app/src/features/NoteEditor/__tests__/behavior.dom.test.ts b/packages/app/src/features/NoteEditor/__tests__/behavior.dom.test.ts deleted file mode 100644 index d252a6ca5..000000000 --- a/packages/app/src/features/NoteEditor/__tests__/behavior.dom.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { renderRichEditor } from './utils/renderRichEditor'; -import { selectContent } from './utils/utils'; - -test('Editor updates when value changes', async () => { - const editor = await renderRichEditor({ value: `# Big text` }); - - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Big text'); - - // Run component rerender with new value - await editor.rerender({ value: `### Not so big text` }); - - expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent( - 'Not so big text', - ); - - // The old header was removed - expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument(); -}); - -test('Editor is not editable in readonly mode', async () => { - const user = userEvent.setup(); - await renderRichEditor({ - value: '# Hello', - isReadOnly: true, - }); - - expect(screen.getByRole('textbox')).toHaveAttribute('contenteditable', 'false'); - - // Cannot enter text - await user.click(screen.getByRole('heading')); - await user.keyboard('Some text'); - - expect(screen.getByRole('textbox')).toHaveTextContent('Hello'); - expect(screen.getByRole('textbox')).not.toHaveTextContent('Some text'); -}); - -test('Formatting text in one editor does not affect the other editor', async () => { - const editorA = await renderRichEditor({ value: 'Big text' }); - const editorB = await renderRichEditor({ value: 'Small text' }); - - const textBoxes = screen.getAllByRole('textbox'); - expect(textBoxes).toHaveLength(2); - - // initial state - expect(within(textBoxes[0]).queryByRole('paragraph')).toHaveTextContent('Big text'); - expect(within(textBoxes[1]).queryByRole('paragraph')).toHaveTextContent('Small text'); - - // apply italic in editorA - selectContent(textBoxes[0], 'Big text'); - await editorA.format('italic'); - - expect(within(textBoxes[0]).getByRole('emphasis')).toHaveTextContent('Big text'); - expect(within(textBoxes[1]).queryByRole('emphasis')).not.toBeInTheDocument(); - - // apply strikethrough in editorB - selectContent(textBoxes[1], 'Small text'); - await editorB.format('strikethrough'); - - expect(within(textBoxes[1]).getByRole('deletion')).toHaveTextContent('Small text'); - expect(within(textBoxes[1]).queryByRole('emphasis')).not.toBeInTheDocument(); - - expect(within(textBoxes[0]).getByRole('emphasis')).toHaveTextContent('Big text'); - expect(within(textBoxes[0]).queryByRole('deletion')).not.toBeInTheDocument(); -}); - -test('ReadOnly editor is not editable while the other editor remains editable', async () => { - const user = userEvent.setup(); - await renderRichEditor({ value: 'Editable text' }); - await renderRichEditor({ value: 'ReadOnly text', isReadOnly: true }); - - const textBoxes = screen.getAllByRole('textbox'); - expect(textBoxes).toHaveLength(2); - - expect(textBoxes[0]).toHaveAttribute('contenteditable', 'true'); - expect(textBoxes[1]).toHaveAttribute('contenteditable', 'false'); - - // editorA is editable — editing works - await user.click(within(textBoxes[0]).getByText('Editable text')); - await user.keyboard('New text '); - - expect(textBoxes[0]).toHaveTextContent('New text Editable text'); - - // editorB is readOnly — editing must be ignored - await user.click(within(textBoxes[1]).getByText('ReadOnly text')); - await user.keyboard('Some text'); - - expect(textBoxes[1]).toHaveTextContent('ReadOnly text'); - expect(textBoxes[1]).not.toHaveTextContent('Some text'); - expect(textBoxes[1]).toHaveAttribute('contenteditable', 'false'); -}); diff --git a/packages/app/src/features/NoteEditor/__tests__/interactive.dom.test.ts b/packages/app/src/features/NoteEditor/__tests__/interactive.dom.test.ts deleted file mode 100644 index a1c0d7a18..000000000 --- a/packages/app/src/features/NoteEditor/__tests__/interactive.dom.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -import { renderRichEditor } from './utils/renderRichEditor'; -import { selectContent, selectText, setCursorPosition } from './utils/utils'; - -test('Pressing Enter inside a paragraph splits it into two paragraphs', async () => { - const user = userEvent.setup(); - await renderRichEditor({ value: 'My favorite dish is cake' }); - - // Initial state - const editor = screen.getByRole('textbox'); - expect(editor.children).toHaveLength(1); - const paragraph = editor.children[0]; - expect(paragraph).toHaveRole('paragraph'); - expect(paragraph).toHaveTextContent('My favorite dish is cake'); - - // Place cursor and press Enter - await user.click(paragraph); - setCursorPosition(paragraph, 'My favorite dis'.length); - await user.keyboard('{Enter}'); - - // Editor should now have two paragraphs split at the cursor - const editorChildren = editor.children; - expect(editorChildren).toHaveLength(2); - - expect(editorChildren[0]).toHaveRole('paragraph'); - expect(editorChildren[0]).toHaveTextContent('My favorite dis'); - - expect(editorChildren[1]).toHaveRole('paragraph'); - expect(editorChildren[1]).toHaveTextContent('h is cake'); -}); - -test('Ctrl+Enter exits a block node and creates a new empty paragraph', async () => { - const user = userEvent.setup(); - const content = 'This is a blockquote'; - const richEditor = await renderRichEditor({ value: `> ${content}` }); - - // One blockquote with one paragraph inside - const editor = screen.getByRole('textbox'); - const initialChildren = editor.children; - expect(initialChildren).toHaveLength(1); - expect(initialChildren[0]).toHaveRole('blockquote'); - expect(initialChildren[0]).toHaveTextContent(content); - - expect(within(editor).getAllByRole('paragraph')).toHaveLength(1); - - // Press Ctrl+Enter to exit the blockquote - await user.click(within(editor).getByRole('blockquote')); - await user.keyboard('{Control>}{Enter}{/Control}'); - - // New empty paragraph is added - const editorChildren = editor.children; - expect(editorChildren).toHaveLength(2); - expect(editorChildren[0]).toHaveRole('blockquote'); - expect(editorChildren[1]).toHaveRole('paragraph'); - - const paragraphs = within(editor).getAllByRole('paragraph'); - expect(paragraphs).toHaveLength(2); - expect(paragraphs[0]).toHaveTextContent(content); - expect(paragraphs[1]).toHaveTextContent(''); - - // Cursor lands in the new paragraph - inserted content inside new paragraph - await richEditor.insert({ type: 'date', data: { date: '01.01.2025' } }); - expect(within(editor).getByText('01.01.2025')).toBeInTheDocument(); - expect(within(editor).getByRole('blockquote')).not.toHaveTextContent('01.01.2025'); -}); - -test(`Inserts image between text nodes`, async () => { - const richEditor = await renderRichEditor({ - value: `My favorite image\n\n I love cat`, - }); - - const editor = screen.getByRole('textbox'); - setCursorPosition(editor, 'My favorite image'.length); - - // Simulate inserting an image via the editor panel action - await richEditor.insert({ - type: 'image', - data: { url: 'http://example.com/cat.png', altText: 'My cat' }, - }); - - // Image nodes inserting asynchronously, so use findByRole to wait for the img to appear - const img = await within(editor).findByRole('img'); - expect(img).toBeInTheDocument(); - expect(img).toHaveAttribute('src', 'http://example.com/cat.png'); - expect(img).toHaveAttribute('alt', 'My cat'); - - // Image between two texts - const firstText = within(editor).getByText('My favorite image'); - const secondText = within(editor).getByText('I love cat'); - expect(img).toAppearAfter(firstText); - expect(img).toAppearBefore(secondText); - - // Editor contains only expected nodes - const editorChildren = editor.children; - expect(editorChildren).toHaveLength(3); - Array.from(editor.children).forEach((child) => { - expect(child).toHaveRole('paragraph'); - }); - - // First paragraph contains text and image - expect(editorChildren[0]).toContainElement(img); - expect(editorChildren[0]).toHaveTextContent('My favorite image'); - expect(editorChildren[1]).toHaveTextContent(''); - expect(editorChildren[2]).toHaveTextContent('I love cat'); -}); - -test('Inserts image after block node', async () => { - const richEditor = await renderRichEditor({ - value: '```js\nconst a = 1;\n```', - }); - - // Place cursor position inside the code node - const editor = screen.getByRole('textbox'); - setCursorPosition(within(editor).getByRole('code'), 10); - - await richEditor.insert({ - type: 'image', - data: { url: 'http://example.com/cat.png', altText: 'My cat' }, - }); - - // Wait before image to appear - const img = await within(editor).findByRole('img'); - expect(img).toBeInTheDocument(); - - const codeNode = within(editor).getByRole('code'); - expect(codeNode).toBeInTheDocument(); - - // Image is inserted as next sibling of the code block - expect(img).toAppearAfter(codeNode); - expect(codeNode.nextElementSibling).toContainElement(img); - - const editorChildren = editor.children; - expect(editorChildren).toHaveLength(2); - expect(editorChildren[0]).toHaveRole('code'); - expect(editorChildren[1]).toHaveRole('paragraph'); - - // Image is inside the paragraph - expect(editorChildren[1]).toContainElement(img); -}); - -test('Updates heading level correctly', async () => { - const content = 'Hello, my dear friends!'; - const richEditor = await renderRichEditor({ value: content }); - - const editor = screen.getByRole('textbox'); - selectContent(editor, content); - - // Plain text becomes heading - await richEditor.insert({ type: 'heading', data: { level: 1 } }); - expect(within(editor).getByRole('heading', { level: 1 })).toHaveTextContent(content); - - // Heading level is updated when different level applied - await richEditor.insert({ type: 'heading', data: { level: 3 } }); - - expect(within(editor).getByRole('heading', { level: 3 })).toHaveTextContent(content); - expect(within(editor).queryByRole('heading', { level: 1 })).not.toBeInTheDocument(); - - // Heading reverts to paragraph when same level applied again - await richEditor.insert({ type: 'heading', data: { level: 3 } }); - - expect(within(editor).queryByRole('heading')).not.toBeInTheDocument(); - expect(within(editor).getByText(content)).toBeInTheDocument(); - - // After editing the screen should contain one paragraph with a text - const editorChildren = editor.children; - expect(editorChildren).toHaveLength(1); - expect(editorChildren[0]).toHaveRole('paragraph'); - expect(editorChildren[0]).toHaveTextContent(content); -}); - -test('Converts an unordered list to an ordered list', async () => { - const richEditor = await renderRichEditor({ - value: `- First item - - Nested item -- Second item`, - }); - const editor = screen.getByRole('textbox'); - - expect(editor.children[0].tagName).toBe('UL'); - expect(editor.children[0].children).toHaveLength(2); - - // Select text and convert to ordered list - selectContent(editor, 'First item'); - await richEditor.insert({ type: 'list', data: { type: 'ordered' } }); - - expect(editor.children[0].tagName).toBe('OL'); - - const listItems = editor.children[0].children; - expect(listItems).toHaveLength(2); - - // First item has text and nested list - const firstItemChildren = listItems[0].children; - expect(firstItemChildren).toHaveLength(2); - expect(listItems[0]).toHaveTextContent('First item'); - - const nestedListChildren = firstItemChildren[1].children; - expect(nestedListChildren).toHaveLength(1); - expect(nestedListChildren[0]).toHaveTextContent('Nested item'); - - expect(listItems[1].children).toHaveLength(1); - expect(listItems[1]).toHaveTextContent('Second item'); -}); - -test('Pressing Enter adds a new item to the list', async () => { - const user = userEvent.setup(); - await renderRichEditor({ value: '- First item' }); - const editor = screen.getByRole('textbox'); - - const initialChildren = editor.children; - expect(initialChildren).toHaveLength(1); - expect(initialChildren[0]).toHaveRole('list'); - expect(initialChildren[0].children).toHaveLength(1); - - const [firstItem] = within(editor).getAllByRole('listitem'); - expect(firstItem).toHaveTextContent('First item'); - - // Place cursor and press Enter - await user.click(firstItem); - setCursorPosition(firstItem, 'First item'.length); - await user.keyboard('{Enter}'); - - // Editor contains only one list with two items - const editorChildren = editor.children; - expect(editorChildren).toHaveLength(1); - expect(editorChildren[0]).toHaveRole('list'); - - const items = editorChildren[0].children; - expect(items).toHaveLength(2); - expect(items[0]).toHaveTextContent('First item'); - expect(items[1]).toHaveTextContent(''); -}); - -test('Pressing Enter on an empty last list item exits the list', async () => { - const user = userEvent.setup(); - await renderRichEditor({ value: '- First item' }); - const editor = screen.getByRole('textbox'); - - const initialChildren = editor.children; - expect(initialChildren).toHaveLength(1); - expect(initialChildren[0]).toHaveRole('list'); - expect(initialChildren[0].children).toHaveLength(1); - - expect(within(editor).getAllByRole('listitem')).toHaveLength(1); - expect(within(editor).queryByRole('paragraph')).not.toBeInTheDocument(); - - // Set cursor and press Enter - const firstItem = within(editor).getByRole('listitem'); - await user.click(firstItem); - setCursorPosition(firstItem, 'First item'.length); - await user.keyboard('{Enter}'); - - expect(within(editor).getAllByRole('listitem')).toHaveLength(2); - expect(within(editor).queryByRole('paragraph')).not.toBeInTheDocument(); - - await user.keyboard('{Enter}'); - - // A new empty paragraph is created - expect(within(editor).getByRole('paragraph')).toHaveTextContent(''); - expect(within(editor).getAllByRole('listitem')).toHaveLength(1); - - // Editor contains only list with one items and one paragraph - const editorChildren = editor.children; - expect(editorChildren).toHaveLength(2); - expect(editorChildren[0]).toHaveRole('list'); - expect(editorChildren[0].children).toHaveLength(1); - expect(editorChildren[1]).toHaveRole('paragraph'); -}); - -test('Toggles text formatting', async () => { - const content = 'Hello, my dear friends!'; - const richEditor = await renderRichEditor({ value: content }); - - const editor = screen.getByRole('textbox'); - selectContent(editor, content); - - // Apply strikethrough - await richEditor.format('strikethrough'); - expect(within(editor).getByRole('deletion')).toHaveTextContent(content); - - // Remove strikethrough - await richEditor.format('strikethrough'); - expect(editor.children).toHaveLength(1); - expect(editor.children[0]).toHaveTextContent(content); - expect(within(editor).queryByRole('deletion')).not.toBeInTheDocument(); - - // Apply italic - await richEditor.format('italic'); - expect(within(editor).getByRole('emphasis')).toHaveTextContent(content); - - // Remove italic - await richEditor.format('italic'); - expect(editor.children).toHaveLength(1); - expect(editor.children[0]).toHaveTextContent(content); - expect(within(editor).queryByRole('emphasis')).not.toBeInTheDocument(); -}); - -test('Combines multiple text formatting', async () => { - const content = 'Hello, my dear friends!'; - const richEditor = await renderRichEditor({ value: content }); - - const editor = screen.getByRole('textbox'); - selectContent(editor, content); - - await richEditor.format('italic'); - await richEditor.format('bold'); - await richEditor.format('strikethrough'); - - // Bold formatting is implemented using the tag which has no ARIA role - expect(within(editor).getByText(content).closest('b')).toBeInTheDocument(); - expect(within(editor).getByRole('emphasis')).toHaveTextContent(content); - expect(within(editor).getByRole('deletion')).toHaveTextContent(content); - - // Removes bold without breaking others formatting - await richEditor.format('bold'); - - expect(within(editor).getByText(content).closest('b')).not.toBeInTheDocument(); - expect(within(editor).getByRole('emphasis')).toHaveTextContent(content); - expect(within(editor).getByRole('deletion')).toHaveTextContent(content); -}); - -// TODO: Remove `.fails` after fixing the formatting implementation -// The assertions below describe the expected behavior and should remain unchanged -test.fails('Applies formatting to a selected part of a text node', async () => { - const richEditor = await renderRichEditor({ value: 'Hello, my dear friends!' }); - - const editor = screen.getByRole('textbox'); - selectText(editor, 'friends'); - - // Apply formatting - await richEditor.format('italic'); - - // Editor contains one paragraph - const editorChildren = editor.children; - expect(editorChildren).toHaveLength(1); - expect(editorChildren[0]).toHaveRole('paragraph'); - - const paragraph = editorChildren[0]; - expect(paragraph).toHaveTextContent('Hello, my dear friends!'); - expect(paragraph.children).toHaveLength(3); - - // Only "friends" is wrapped in emphasis - expect(paragraph.children[0]).toHaveTextContent('Hello, my dear '); - expect(paragraph.children[1]).toHaveRole('emphasis'); - expect(paragraph.children[1]).toHaveTextContent('friends'); - expect(paragraph.children[2]).toHaveTextContent('!'); -}); - -test.fails('Applies formatting across multiple text blocks', async () => { - const richEditor = await renderRichEditor({ - value: 'Hello, my dear friends! \n\n Nice to see you. \n\n How are you ?', - }); - - const editor = screen.getByRole('textbox'); - selectContent(editor, 'Hello, my dear friends!', 'How are you ?'); - - // Apply formatting - await richEditor.format('italic'); - - // Each line should be wrapped in emphasis - const formattingNodes = within(editor).getAllByRole('emphasis'); - expect(formattingNodes).toHaveLength(3); - expect(formattingNodes[0]).toHaveTextContent('Hello, my dear friends!'); - expect(formattingNodes[1]).toHaveTextContent('Nice to see you'); - expect(formattingNodes[2]).toHaveTextContent('How are you ?'); -});