diff --git a/package.json b/package.json index 1e8a9757..d67b6b9d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "preview": "vite preview", "release": "bumpp", "generate:template-snapshots": "tsx scripts/generate-template-snapshots.ts", - "install:playwright": "playwright install chromium" + "install:playwright": "playwright install chromium", + "check:i18n": "tsx scripts/check-i18n-keys.ts", + "test": "vitest run" }, "dependencies": { "@google/generative-ai": "^0.24.1", @@ -85,6 +87,9 @@ "zustand": "^4.5.4" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/lodash": "^4.17.13", "@types/node": "^20", "@types/react": "^18", @@ -94,6 +99,7 @@ "bumpp": "^10.4.1", "changelogen": "^0.6.2", "eslint": "^8", + "jsdom": "^29.1.1", "playwright": "^1.58.2", "postcss": "^8", "postcss-normalize": "^13.0.1", @@ -103,6 +109,7 @@ "typescript": "^5", "vite": "^7.3.1", "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.8", "wrangler": "^4.19.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d29667ba..bfdc3a07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,6 +216,15 @@ importers: specifier: ^4.5.4 version: 4.5.6(@types/react@18.3.18)(react@18.3.1) devDependencies: + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/lodash': specifier: ^4.17.13 version: 4.17.15 @@ -243,6 +252,9 @@ importers: eslint: specifier: ^8 version: 8.57.1 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 playwright: specifier: ^1.58.2 version: 1.58.2 @@ -270,16 +282,37 @@ importers: vite-tsconfig-paths: specifier: ^6.1.1 version: 6.1.1(typescript@5.7.3)(vite@7.3.1(@types/node@20.17.17)(jiti@2.6.1)(sass@1.83.4)(tsx@4.21.0)(yaml@2.8.2)) + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@20.17.17)(jsdom@29.1.1)(vite@7.3.1(@types/node@20.17.17)(jiti@2.6.1)(sass@1.83.4)(tsx@4.21.0)(yaml@2.8.2)) wrangler: specifier: ^4.19.1 version: 4.19.1 packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -391,6 +424,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@cloudflare/kv-asset-handler@0.4.0': resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} engines: {node: '>=18.0.0'} @@ -438,6 +475,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5': + resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@csstools/normalize.css@12.1.1': resolution: {integrity: sha512-YAYeJ+Xqh7fUou1d1j9XHl44BmsuThiTr4iNrgCQ3J27IbhXsxXDGZ1cXv8Qvs99d4rBbLiSKy3+WZiet32PcQ==} @@ -768,6 +841,15 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} @@ -1407,79 +1489,67 @@ packages: resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} @@ -1538,6 +1608,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -1579,35 +1652,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.95': resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.95': resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.95': resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.95': resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.95': resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==} @@ -1645,28 +1713,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@14.2.3': resolution: {integrity: sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@14.2.3': resolution: {integrity: sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@14.2.3': resolution: {integrity: sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@14.2.3': resolution: {integrity: sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==} @@ -1743,42 +1807,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -3097,79 +3155,66 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -3205,6 +3250,9 @@ packages: resolution: {integrity: sha512-VfmHkQmb/Px0zjwdSQwjRMwFw63Qfj+g4Giumz4jiAGEpXHgIZk6xEwlUz6yiUHVDveK2TEgMR2MR6I3okOE7w==} engines: {node: '>= 16'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -3321,6 +3369,35 @@ packages: resolution: {integrity: sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg==} engines: {node: '>=12'} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.21.0': resolution: {integrity: sha512-IfnQiuEeabDSPr1C/zHFTbnvlTf5z0DE/d/xz4C6bkL4ZBDJ3rr99h2qsaV0l8F+kbNswZMlQdM8rxNlMy95fQ==} peerDependencies: @@ -3505,6 +3582,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3517,9 +3597,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -3614,6 +3700,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3661,6 +3776,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -3692,9 +3811,20 @@ packages: resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.13.4: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} @@ -3756,6 +3886,9 @@ packages: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -3838,6 +3971,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3992,10 +4129,17 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -4011,6 +4155,10 @@ packages: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} @@ -4029,6 +4177,9 @@ packages: decimal.js@10.5.0: resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -4101,6 +4252,12 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -4151,6 +4308,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4158,6 +4319,9 @@ packages: error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es6-promise@4.2.8: resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} @@ -4226,6 +4390,9 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -4234,6 +4401,10 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.5: resolution: {integrity: sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==} @@ -4452,6 +4623,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -4498,6 +4673,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -4581,6 +4760,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -4617,6 +4799,15 @@ packages: jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4692,6 +4883,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4704,6 +4899,13 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} @@ -4770,6 +4972,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -4870,6 +5075,10 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + miniflare@4.20250525.1: resolution: {integrity: sha512-4PJlT5WA+hfclFU5Q7xnpG1G1VGYTXaf/3iu6iKQ8IsbSi9QvPTA2bSZ5goCFxmJXDjV4cxttVxB0Wl1CLuQ0w==} engines: {node: '>=18.0.0'} @@ -4935,6 +5144,7 @@ packages: next@14.2.3: resolution: {integrity: sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==} engines: {node: '>=18.17.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -4982,6 +5192,10 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + obug@2.1.2: + resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} + engines: {node: '>=12.20.0'} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -5044,6 +5258,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5186,6 +5403,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} @@ -5307,6 +5528,9 @@ packages: peerDependencies: react: ^18.3.1 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} @@ -5386,6 +5610,10 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} @@ -5420,6 +5648,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5474,6 +5706,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -5519,6 +5755,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -5572,6 +5811,9 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackblur-canvas@2.7.0: resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} engines: {node: '>=0.1.14'} @@ -5582,6 +5824,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stoppable@1.1.0: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} @@ -5618,6 +5863,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -5658,6 +5907,9 @@ packages: resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} engines: {node: '>=12.0.0'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} @@ -5715,6 +5967,9 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -5723,10 +5978,29 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.2: + resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} + + tldts@7.4.2: + resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5807,6 +6081,10 @@ packages: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} + undici@7.27.2: + resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.17: resolution: {integrity: sha512-B06u0wXkEd+o5gOCMl/ZHl5cfpYbDZKAT+HWTL+Hws6jWu7dCiqBBXXXzMFcFVJb8D4ytAnYmxJA83uwOQRSsg==} @@ -5979,12 +6257,61 @@ packages: vite: optional: true + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -5996,11 +6323,24 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -6047,10 +6387,17 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xmlbuilder2@4.0.3: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6119,8 +6466,30 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -6261,6 +6630,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@cloudflare/kv-asset-handler@0.4.0': dependencies: mime: 3.0.0 @@ -6290,6 +6663,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@csstools/normalize.css@12.1.1': {} '@emnapi/runtime@1.3.1': @@ -6473,6 +6870,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@exodus/bytes@1.15.1': {} + '@fastify/busboy@2.1.1': {} '@floating-ui/core@1.6.9': @@ -7689,6 +8088,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -9665,6 +10066,8 @@ snapshots: - bare-buffer - debug + '@standard-schema/spec@1.1.0': {} + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.5': @@ -9861,6 +10264,40 @@ snapshots: '@tanstack/virtual-file-routes@1.154.7': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.26.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.26.7 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tiptap/core@3.21.0(@tiptap/pm@3.21.0)': dependencies: '@tiptap/pm': 3.21.0 @@ -10068,6 +10505,8 @@ snapshots: '@tsconfig/node16@1.0.4': optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -10089,10 +10528,17 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -10170,6 +10616,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@7.3.1(@types/node@20.17.17)(jiti@2.6.1)(sass@1.83.4)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.17.17)(jiti@2.6.1)(sass@1.83.4)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.14.0): dependencies: acorn: 8.14.0 @@ -10205,6 +10692,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} ansis@4.2.0: {} @@ -10229,10 +10718,18 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + as-table@1.0.55: dependencies: printable-characters: 1.0.42 + assertion-error@2.0.1: {} + ast-types@0.13.4: dependencies: tslib: 2.8.1 @@ -10291,6 +10788,10 @@ snapshots: basic-ftp@5.0.5: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} blake3-wasm@2.1.5: {} @@ -10391,6 +10892,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -10580,8 +11083,15 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-what@6.2.2: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -10590,6 +11100,13 @@ snapshots: data-uri-to-buffer@6.0.2: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + date-fns@3.6.0: {} dayjs@1.11.13: {} @@ -10600,6 +11117,8 @@ snapshots: decimal.js@10.5.0: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -10655,6 +11174,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -10701,12 +11224,16 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 + es-module-lexer@2.1.0: {} + es6-promise@4.2.8: {} esbuild@0.25.4: @@ -10850,10 +11377,16 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} exit-hook@2.2.1: {} + expect-type@1.3.0: {} + exsolve@1.0.5: {} exsolve@1.0.8: {} @@ -11114,6 +11647,12 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -11167,6 +11706,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -11236,6 +11777,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -11266,6 +11809,32 @@ snapshots: jsbn@1.1.0: {} + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.27.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -11329,6 +11898,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -11339,6 +11910,12 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + make-error@1.3.6: optional: true @@ -11518,6 +12095,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + mdurl@2.0.0: {} merge2@1.4.1: {} @@ -11720,6 +12299,8 @@ snapshots: mime@3.0.0: {} + min-indent@1.0.1: {} + miniflare@4.20250525.1: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -11834,6 +12415,8 @@ snapshots: object-hash@3.0.0: {} + obug@2.1.2: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -11928,6 +12511,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -12049,6 +12636,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + printable-characters@1.0.42: {} progress@2.0.3: {} @@ -12239,6 +12832,8 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-is@17.0.2: {} + react-refresh@0.18.0: {} react-remove-scroll-bar@2.3.8(@types/react@18.3.18)(react@18.3.1): @@ -12317,6 +12912,11 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + regenerator-runtime@0.13.11: optional: true @@ -12375,6 +12975,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -12447,6 +13049,10 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -12501,6 +13107,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-swizzle@0.2.2: @@ -12541,6 +13149,8 @@ snapshots: srvx@0.11.5: {} + stackback@0.0.2: {} + stackblur-canvas@2.7.0: optional: true @@ -12551,6 +13161,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.1.0: {} + stoppable@1.1.0: {} streamdown@2.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -12610,6 +13222,10 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} style-to-js@1.1.21: @@ -12647,6 +13263,8 @@ snapshots: svg-pathdata@6.0.3: optional: true + symbol-tree@3.2.4: {} + tailwind-merge@2.6.0: {} tailwind-merge@3.4.0: {} @@ -12728,6 +13346,8 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -12735,10 +13355,26 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + + tldts-core@7.4.2: {} + + tldts@7.4.2: + dependencies: + tldts-core: 7.4.2 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.2 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -12808,6 +13444,8 @@ snapshots: undici@7.22.0: {} + undici@7.27.2: {} + unenv@2.0.0-rc.17: dependencies: defu: 6.1.4 @@ -12973,10 +13611,44 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@20.17.17)(jiti@2.6.1)(sass@1.83.4)(tsx@4.21.0)(yaml@2.8.2) + vitest@4.1.8(@types/node@20.17.17)(jsdom@29.1.1)(vite@7.3.1(@types/node@20.17.17)(jiti@2.6.1)(sass@1.83.4)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@7.3.1(@types/node@20.17.17)(jiti@2.6.1)(sass@1.83.4)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@20.17.17)(jiti@2.6.1)(sass@1.83.4)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.17.17 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@3.1.1: @@ -12985,10 +13657,25 @@ snapshots: whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} workerd@1.20250525.0: @@ -13035,6 +13722,8 @@ snapshots: dependencies: is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} + xmlbuilder2@4.0.3: dependencies: '@oozcitak/dom': 2.0.2 @@ -13042,6 +13731,8 @@ snapshots: '@oozcitak/util': 10.0.0 js-yaml: 4.1.1 + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/public/sitemap.xml b/public/sitemap.xml index 615d8166..5b4809c0 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -7,6 +7,7 @@ 1.0 + @@ -15,6 +16,16 @@ 1.0 + + + + + https://magicv.art/ru + daily + 1.0 + + + diff --git a/public/template-snapshots/en/classic.png b/public/template-snapshots/en/classic.png index 492ae649..2e76612b 100644 Binary files a/public/template-snapshots/en/classic.png and b/public/template-snapshots/en/classic.png differ diff --git a/public/template-snapshots/en/creative.png b/public/template-snapshots/en/creative.png index 6aeaa6fc..4a9dca8c 100644 Binary files a/public/template-snapshots/en/creative.png and b/public/template-snapshots/en/creative.png differ diff --git a/public/template-snapshots/en/editorial.png b/public/template-snapshots/en/editorial.png index fe603116..ce1f26f6 100644 Binary files a/public/template-snapshots/en/editorial.png and b/public/template-snapshots/en/editorial.png differ diff --git a/public/template-snapshots/en/elegant.png b/public/template-snapshots/en/elegant.png index 876253c5..d8bf9060 100644 Binary files a/public/template-snapshots/en/elegant.png and b/public/template-snapshots/en/elegant.png differ diff --git a/public/template-snapshots/en/left-right.png b/public/template-snapshots/en/left-right.png index 3ed8c057..6edeb47e 100644 Binary files a/public/template-snapshots/en/left-right.png and b/public/template-snapshots/en/left-right.png differ diff --git a/public/template-snapshots/en/minimalist.png b/public/template-snapshots/en/minimalist.png index 183914f6..068193e4 100644 Binary files a/public/template-snapshots/en/minimalist.png and b/public/template-snapshots/en/minimalist.png differ diff --git a/public/template-snapshots/en/modern.png b/public/template-snapshots/en/modern.png index 8aff0f7e..a596df06 100644 Binary files a/public/template-snapshots/en/modern.png and b/public/template-snapshots/en/modern.png differ diff --git a/public/template-snapshots/en/swiss.png b/public/template-snapshots/en/swiss.png index 82233036..fc03f39a 100644 Binary files a/public/template-snapshots/en/swiss.png and b/public/template-snapshots/en/swiss.png differ diff --git a/public/template-snapshots/en/timeline.png b/public/template-snapshots/en/timeline.png index 11c4fbf1..e9b30ba9 100644 Binary files a/public/template-snapshots/en/timeline.png and b/public/template-snapshots/en/timeline.png differ diff --git a/public/template-snapshots/ru/classic.png b/public/template-snapshots/ru/classic.png new file mode 100644 index 00000000..a7b817c8 Binary files /dev/null and b/public/template-snapshots/ru/classic.png differ diff --git a/public/template-snapshots/ru/creative.png b/public/template-snapshots/ru/creative.png new file mode 100644 index 00000000..bf18fc5b Binary files /dev/null and b/public/template-snapshots/ru/creative.png differ diff --git a/public/template-snapshots/ru/editorial.png b/public/template-snapshots/ru/editorial.png new file mode 100644 index 00000000..1d044117 Binary files /dev/null and b/public/template-snapshots/ru/editorial.png differ diff --git a/public/template-snapshots/ru/elegant.png b/public/template-snapshots/ru/elegant.png new file mode 100644 index 00000000..f9593466 Binary files /dev/null and b/public/template-snapshots/ru/elegant.png differ diff --git a/public/template-snapshots/ru/left-right.png b/public/template-snapshots/ru/left-right.png new file mode 100644 index 00000000..aca7dbe6 Binary files /dev/null and b/public/template-snapshots/ru/left-right.png differ diff --git a/public/template-snapshots/ru/minimalist.png b/public/template-snapshots/ru/minimalist.png new file mode 100644 index 00000000..69d866c4 Binary files /dev/null and b/public/template-snapshots/ru/minimalist.png differ diff --git a/public/template-snapshots/ru/modern.png b/public/template-snapshots/ru/modern.png new file mode 100644 index 00000000..5c2d7296 Binary files /dev/null and b/public/template-snapshots/ru/modern.png differ diff --git a/public/template-snapshots/ru/swiss.png b/public/template-snapshots/ru/swiss.png new file mode 100644 index 00000000..fd3ce603 Binary files /dev/null and b/public/template-snapshots/ru/swiss.png differ diff --git a/public/template-snapshots/ru/timeline.png b/public/template-snapshots/ru/timeline.png new file mode 100644 index 00000000..c8cf4786 Binary files /dev/null and b/public/template-snapshots/ru/timeline.png differ diff --git a/public/template-snapshots/zh/classic.png b/public/template-snapshots/zh/classic.png index e5a658c0..c1ac9c70 100644 Binary files a/public/template-snapshots/zh/classic.png and b/public/template-snapshots/zh/classic.png differ diff --git a/public/template-snapshots/zh/creative.png b/public/template-snapshots/zh/creative.png index dce46bbe..d7ce9960 100644 Binary files a/public/template-snapshots/zh/creative.png and b/public/template-snapshots/zh/creative.png differ diff --git a/public/template-snapshots/zh/editorial.png b/public/template-snapshots/zh/editorial.png index f6dc8864..5597cf16 100644 Binary files a/public/template-snapshots/zh/editorial.png and b/public/template-snapshots/zh/editorial.png differ diff --git a/public/template-snapshots/zh/elegant.png b/public/template-snapshots/zh/elegant.png index 761c3165..9ae95a94 100644 Binary files a/public/template-snapshots/zh/elegant.png and b/public/template-snapshots/zh/elegant.png differ diff --git a/public/template-snapshots/zh/left-right.png b/public/template-snapshots/zh/left-right.png index 8c253f05..d0667956 100644 Binary files a/public/template-snapshots/zh/left-right.png and b/public/template-snapshots/zh/left-right.png differ diff --git a/public/template-snapshots/zh/minimalist.png b/public/template-snapshots/zh/minimalist.png index b2c7c702..7c17b311 100644 Binary files a/public/template-snapshots/zh/minimalist.png and b/public/template-snapshots/zh/minimalist.png differ diff --git a/public/template-snapshots/zh/modern.png b/public/template-snapshots/zh/modern.png index 6c5eda49..40be1d0d 100644 Binary files a/public/template-snapshots/zh/modern.png and b/public/template-snapshots/zh/modern.png differ diff --git a/public/template-snapshots/zh/swiss.png b/public/template-snapshots/zh/swiss.png index fa8e9971..332d6643 100644 Binary files a/public/template-snapshots/zh/swiss.png and b/public/template-snapshots/zh/swiss.png differ diff --git a/public/template-snapshots/zh/timeline.png b/public/template-snapshots/zh/timeline.png index d7b1a7a3..a36bb03a 100644 Binary files a/public/template-snapshots/zh/timeline.png and b/public/template-snapshots/zh/timeline.png differ diff --git a/scripts/check-i18n-keys.ts b/scripts/check-i18n-keys.ts new file mode 100644 index 00000000..867fa54f --- /dev/null +++ b/scripts/check-i18n-keys.ts @@ -0,0 +1,63 @@ +import zh from "../src/i18n/locales/zh.json"; +import en from "../src/i18n/locales/en.json"; +import ru from "../src/i18n/locales/ru.json"; + +function flattenKeys( + value: Record, + prefix = "" +): string[] { + const keys: string[] = []; + + for (const [key, nested] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key; + + if (nested && typeof nested === "object" && !Array.isArray(nested)) { + keys.push(...flattenKeys(nested as Record, path)); + } else { + keys.push(path); + } + } + + return keys.sort(); +} + +const localeFiles = { + zh, + en, + ru, +} as const; + +const keySets = Object.fromEntries( + Object.entries(localeFiles).map(([locale, messages]) => [ + locale, + new Set(flattenKeys(messages as Record)), + ]) +) as Record>; + +const allKeys = new Set(); +for (const keys of Object.values(keySets)) { + for (const key of keys) { + allKeys.add(key); + } +} + +let hasMismatch = false; + +for (const key of [...allKeys].sort()) { + const missingIn = Object.entries(keySets) + .filter(([, keys]) => !keys.has(key)) + .map(([locale]) => locale); + + if (missingIn.length > 0) { + hasMismatch = true; + console.error(`Missing key "${key}" in: ${missingIn.join(", ")}`); + } +} + +if (hasMismatch) { + process.exit(1); +} + +console.log( + `i18n key parity OK (${keySets.zh.size} keys across zh, en, ru)` +); diff --git a/src/app/(public)/[locale]/layout.tsx b/src/app/(public)/[locale]/layout.tsx index 52d21182..0e5eb7a2 100644 --- a/src/app/(public)/[locale]/layout.tsx +++ b/src/app/(public)/[locale]/layout.tsx @@ -8,7 +8,7 @@ import { setRequestLocale } from "@/i18n/compat/server"; import Document from "@/components/Document"; -import { locales } from "@/i18n/config"; +import { localeTags, locales, type Locale } from "@/i18n/config"; import { Providers } from "@/app/providers"; type Props = { @@ -35,8 +35,8 @@ export async function generateMetadata({ openGraph: { title: t("title"), description: t("description"), - locale: locale, - alternateLocale: locale === "en" ? ["zh"] : ["en"] + locale: localeTags[locale as Locale], + alternateLocale: locales.filter((loc) => loc !== locale).map((loc) => localeTags[loc]) } }; } diff --git a/src/app/app/dashboard/client.tsx b/src/app/app/dashboard/client.tsx index 251b8d41..4d7b8189 100644 --- a/src/app/app/dashboard/client.tsx +++ b/src/app/app/dashboard/client.tsx @@ -21,6 +21,7 @@ import { TooltipTrigger } from "@/components/ui/tooltip"; import Logo from "@/components/shared/Logo"; +import LanguageSwitch from "@/components/shared/LanguageSwitch"; import { useLocale, useTranslations } from "@/i18n/compat/client"; interface MenuItem { @@ -166,7 +167,9 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => { - + + +
diff --git a/src/app/app/dashboard/resumes/CreateResumeModal.tsx b/src/app/app/dashboard/resumes/CreateResumeModal.tsx index eae72826..9779baef 100644 --- a/src/app/app/dashboard/resumes/CreateResumeModal.tsx +++ b/src/app/app/dashboard/resumes/CreateResumeModal.tsx @@ -12,6 +12,7 @@ import { DEFAULT_TEMPLATES } from "@/config"; import { initialResumeState } from "@/config/initialResumeData"; import ResumeTemplateComponent from "@/components/templates"; import { useTemplateSnapshots } from "@/hooks/useTemplateSnapshots"; +import { getTemplateKey } from "@/lib/templates"; import type { Translator } from "@/i18n/compat/utils"; import type { ResumeData } from "@/types/resume"; import type { ResumeTemplate } from "@/types/template"; @@ -36,14 +37,11 @@ type BlankTemplate = { type NormalTemplate = ResumeTemplate & { isBlank: false; nameKey: string }; type TemplateOption = NormalTemplate | BlankTemplate; -const toTemplateNameKey = (templateId: string) => - templateId === "left-right" ? "leftRight" : templateId; - const BLANK_TEMPLATE: BlankTemplate = { id: null, isBlank: true, nameKey: "blankTitle" }; const NORMAL_TEMPLATES: NormalTemplate[] = DEFAULT_TEMPLATES.map((template) => ({ ...template, isBlank: false, - nameKey: toTemplateNameKey(template.id), + nameKey: getTemplateKey(template.id), })); const BlankTemplateThumbnail = ({ t }: { t: Translator }) => ( diff --git a/src/app/app/dashboard/templates/page.tsx b/src/app/app/dashboard/templates/page.tsx index f375fd35..26597093 100644 --- a/src/app/app/dashboard/templates/page.tsx +++ b/src/app/app/dashboard/templates/page.tsx @@ -10,7 +10,8 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import ResumeTemplateComponent from "@/components/templates"; -import { initialResumeState, initialResumeStateEn } from "@/config/initialResumeData"; +import { getInitialResumeStateForLocale } from "@/config/localeResumeData"; +import { getTemplateKey } from "@/lib/templates"; import type { ResumeTemplate } from "@/types/template"; import { normalizeFontFamily } from "@/utils/fonts"; @@ -28,12 +29,7 @@ const PRESET_COLORS = [ { name: "black", value: "#000000" }, ]; -const getTemplateKey = (templateId: string) => - templateId === "left-right" ? "leftRight" : templateId; - -type TemplatePreviewBaseData = - | typeof initialResumeState - | typeof initialResumeStateEn; +type TemplatePreviewBaseData = ReturnType; const buildTemplatePreviewData = ( baseData: TemplatePreviewBaseData, @@ -235,7 +231,7 @@ const TemplatesPage = () => { } }; - const baseData = locale === "en" ? initialResumeStateEn : initialResumeState; + const baseData = getInitialResumeStateForLocale(locale); const activePreviewTemplate = DEFAULT_TEMPLATES.find((template) => template.id === previewTemplate) ?? null; diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 2c6258ff..2ea08461 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -2,6 +2,7 @@ import { ThemeProvider } from "next-themes"; import { HeroUIProvider } from "@heroui/react"; import { useLocale } from "@/i18n/compat/client"; +import { heroUiLocales } from "@/i18n/config"; import { useResumeDirectorySync } from "@/hooks/useResumeDirectorySync"; export function Providers({ children }: { children: React.ReactNode }) { @@ -9,7 +10,7 @@ export function Providers({ children }: { children: React.ReactNode }) { useResumeDirectorySync(); return ( - + ({ - url: `${baseUrl}${route}`, - lastModified: new Date(), - changeFrequency: "daily", - priority: 1.0 - })); - - return sitemap; -} diff --git a/src/components/editor/EditPanel.tsx b/src/components/editor/EditPanel.tsx index 36efed4c..ed5598ea 100644 --- a/src/components/editor/EditPanel.tsx +++ b/src/components/editor/EditPanel.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useTranslations } from "@/i18n/compat/client"; import { Pencil } from "lucide-react"; import { motion } from "framer-motion"; import { useResumeStore } from "@/store/useResumeStore"; @@ -19,6 +20,7 @@ import { } from "../ui/tooltip"; export function EditPanel() { + const t = useTranslations("workbench.editPanel"); const { activeResume, updateMenuSections } = useResumeStore(); if (!activeResume) return; const { activeSection = "", menuSections = [] } = activeResume || {}; @@ -104,7 +106,7 @@ export function EditPanel() { -

点击文字部分即可聚焦编辑

+

{t("focusHint")}

diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index a538e453..399813c4 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -6,6 +6,7 @@ import { useRouter } from "@/lib/navigation"; import { Input } from "@/components/ui/input"; import PdfExport from "../shared/PdfExport"; import ThemeToggle from "../shared/ThemeToggle"; +import LanguageSwitch from "../shared/LanguageSwitch"; import { useResumeStore } from "@/store/useResumeStore"; import { getThemeConfig } from "@/theme/themeConfig"; import { useGrammarCheck } from "@/hooks/useGrammarCheck"; @@ -36,6 +37,8 @@ export function EditorHeader({ isMobile }: EditorHeaderProps) { const { errors, selectError } = useGrammarCheck(); const router = useRouter(); const t = useTranslations(); + const tHeader = useTranslations("workbench.editorHeader"); + const tFallbacks = useTranslations("workbench.fallbacks"); const visibleSections = menuSections ?.filter((section) => section.enabled) .sort((a, b) => a.order - b.order); @@ -83,10 +86,10 @@ export function EditorHeader({ isMobile }: EditorHeaderProps) { key={activeResume?.id || "resume-title"} defaultValue={activeResume?.title || ""} onBlur={(e) => { - updateResumeTitle(e.target.value || "未命名简历"); + updateResumeTitle(e.target.value || tFallbacks("unnamedResume")); }} className="w-56 text-sm h-8 bg-muted/30 border-transparent hover:bg-muted/60 focus:bg-background transition-colors px-2.5 py-1 pr-8 shadow-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-border rounded-md font-medium text-foreground/90 hover:text-foreground" - placeholder="简历名称" + placeholder={tHeader("placeholder")} />
@@ -162,6 +165,7 @@ export function EditorHeader({ isMobile }: EditorHeaderProps) { )} +
diff --git a/src/components/editor/SidePanel.tsx b/src/components/editor/SidePanel.tsx index b36e6f32..9a00e775 100644 --- a/src/components/editor/SidePanel.tsx +++ b/src/components/editor/SidePanel.tsx @@ -30,12 +30,6 @@ import { STANDARD_MODULES } from "@/config/modules"; import { DEFAULT_TEMPLATES } from "@/config"; import { getFontOptions, normalizeFontFamily } from "@/utils/fonts"; -const lineHeightOptions = [ - { value: "normal", label: "默认" }, - { value: "relaxed", label: "适中" }, - { value: "loose", label: "宽松" }, -]; - function SettingCard({ icon: Icon, title, @@ -511,7 +505,7 @@ export function SidePanel({ } }} > - 增加 + {t("accessibility.increase")} - 减少 + {t("accessibility.decrease")} - 增加 + {t("accessibility.increase")} - 减少 + {t("accessibility.decrease")} - 增加 + {t("accessibility.increase")} - 减少 + {t("accessibility.decrease")} = ({ value, onChange }) => { + const t = useTranslations("workbench.basicPanel"); const layouts = [ { value: "left", @@ -24,7 +26,7 @@ const AlignSelector: React.FC = ({ value, onChange }) => { ), - tooltip: "左对齐", + tooltip: t("align.left"), }, { value: "center", @@ -48,7 +50,7 @@ const AlignSelector: React.FC = ({ value, onChange }) => { ), - tooltip: "居中", + tooltip: t("align.center"), }, { value: "right", @@ -66,7 +68,7 @@ const AlignSelector: React.FC = ({ value, onChange }) => { ), - tooltip: "右对齐", + tooltip: t("align.right"), }, ]; diff --git a/src/components/editor/basic/BasicPanel.tsx b/src/components/editor/basic/BasicPanel.tsx index 27abc05c..a736163c 100644 --- a/src/components/editor/basic/BasicPanel.tsx +++ b/src/components/editor/basic/BasicPanel.tsx @@ -334,7 +334,9 @@ const BasicPanel: React.FC = () => { [field.key]: value, }) } - placeholder={`请输入${field.label}`} + placeholder={t("placeholders.field", { + label: t(`basicFields.${field.key}`), + })} type={field.type} />
@@ -490,7 +492,7 @@ const BasicPanel: React.FC = () => {
Access Token
@@ -505,7 +507,7 @@ const BasicPanel: React.FC = () => {
UseName
updateBasicInfo({ diff --git a/src/components/editor/custom/CustomItem.tsx b/src/components/editor/custom/CustomItem.tsx index 4d8d7bc5..668a0925 100644 --- a/src/components/editor/custom/CustomItem.tsx +++ b/src/components/editor/custom/CustomItem.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useResumeStore } from "@/store/useResumeStore"; import { GripVertical, Eye, EyeOff, ChevronDown, Trash2 } from "lucide-react"; +import { useTranslations } from "@/i18n/compat/client"; import Field from "../Field"; import { CustomItem as CustomItemType } from "@/types/resume"; @@ -20,6 +21,7 @@ const CustomItemEditor = ({ item: CustomItemType; onSave: (item: CustomItemType) => void; }) => { + const t = useTranslations("workbench.customItem"); const handleChange = (field: keyof CustomItemType, value: string) => { onSave({ ...item, [field]: value }); }; @@ -29,33 +31,33 @@ const CustomItemEditor = ({
handleChange("title", value)} - placeholder="标题" + placeholder={t("titlePlaceholder")} /> handleChange("subtitle", value)} - placeholder="副标题" + placeholder={t("subtitlePlaceholder")} />
handleChange("dateRange", value)} type="date-range" - placeholder="例如: 2023.01 - 2024.01" + placeholder={t("dateRangePlaceholder")} /> handleChange("description", value)} type="editor" - placeholder="请输入详细描述..." + placeholder={t("descriptionPlaceholder")} />
@@ -69,6 +71,7 @@ const CustomItem = ({ item: CustomItemType; sectionId: string; }) => { + const t = useTranslations("workbench.customItem"); const { updateCustomItem, removeCustomItem } = useResumeStore(); const dragControls = useDragControls(); const [expandedId, setExpandedId] = useState(null); @@ -136,7 +139,7 @@ const CustomItem = ({ "font-medium truncate text-foreground" )} > - {item.title || "未命名模块"} + {item.title || t("unnamedModule")} {item.subtitle && (

{ + const t = useTranslations("workbench.customPanel"); const { addCustomItem, updateCustomData, activeResume } = useResumeStore(); const { customData } = activeResume || {}; const items = customData?.[sectionId] || []; @@ -36,7 +38,7 @@ const CustomPanel = memo(({ sectionId }: { sectionId: string }) => { diff --git a/src/components/editor/education/EducationItem.tsx b/src/components/editor/education/EducationItem.tsx index 7b05c716..81840f55 100644 --- a/src/components/editor/education/EducationItem.tsx +++ b/src/components/editor/education/EducationItem.tsx @@ -26,6 +26,7 @@ const EducationEditor: React.FC = ({ onSave, }) => { const t = useTranslations("workbench.educationItem"); + const tFallbacks = useTranslations("workbench.fallbacks"); const handleChange = (field: keyof Education, value: string) => { onSave({ ...education, @@ -182,7 +183,7 @@ const EducationItem = ({ education }: { education: Education }) => { "text-foreground" )} > - {education.school || "未填写学校"} + {education.school || tFallbacks("emptySchool")} {(education.major || education.degree) && (

= ({ onSave, }) => { const t = useTranslations("workbench.experienceItem"); + const tFallbacks = useTranslations("workbench.fallbacks"); const handleChange = (field: keyof Experience, value: string) => { onSave({ @@ -153,7 +154,7 @@ const ExperienceItem = ({ experience }: { experience: Experience }) => { "text-foreground" )} > - {experience.company || "家里蹲公司"} + {experience.company || tFallbacks("unnamedCompany")}

diff --git a/src/components/editor/grammar/GrammarCheckDrawer.tsx b/src/components/editor/grammar/GrammarCheckDrawer.tsx index 8def5dfe..08f3012e 100644 --- a/src/components/editor/grammar/GrammarCheckDrawer.tsx +++ b/src/components/editor/grammar/GrammarCheckDrawer.tsx @@ -174,12 +174,6 @@ export function GrammarCheckDrawer() { > {error.type === "spelling" ? t("spelling") : t("punctuation")} - {/* 只有当 reason 与 Badge 内容不同时才显示 */} - {error.reason && error.reason !== "错别字" && error.reason !== "标点符号" && ( - - {error.reason} - - )}
diff --git a/src/components/editor/project/ProjectItem.tsx b/src/components/editor/project/ProjectItem.tsx index 95dfb0af..484609bf 100644 --- a/src/components/editor/project/ProjectItem.tsx +++ b/src/components/editor/project/ProjectItem.tsx @@ -24,6 +24,7 @@ interface ProjectEditorProps { const ProjectEditor: React.FC = ({ project, onSave }) => { const t = useTranslations("workbench.projectItem"); + const tFallbacks = useTranslations("workbench.fallbacks"); const handleChange = (field: keyof Project, value: string) => { onSave({ ...project, @@ -195,7 +196,7 @@ const ProjectItem = ({ project }: { project: Project }) => { "text-gray-700 dark:text-neutral-200" )} > - {project.name || "未命名项目"} + {project.name || tFallbacks("unnamedProject")}
diff --git a/src/components/editor/self-evaluation/SelfEvaluationPanel.tsx b/src/components/editor/self-evaluation/SelfEvaluationPanel.tsx index e9d0e9ee..726ff230 100644 --- a/src/components/editor/self-evaluation/SelfEvaluationPanel.tsx +++ b/src/components/editor/self-evaluation/SelfEvaluationPanel.tsx @@ -1,8 +1,10 @@ +import { useTranslations } from "@/i18n/compat/client"; import { useResumeStore } from "@/store/useResumeStore"; import { cn } from "@/lib/utils"; import Field from "../Field"; const SelfEvaluationPanel = () => { + const t = useTranslations("workbench.selfEvaluationPanel"); const { activeResume, updateSelfEvaluationContent } = useResumeStore(); const selfEvaluationContent = activeResume?.selfEvaluationContent ?? ""; const handleChange = (value: string) => { @@ -21,7 +23,7 @@ const SelfEvaluationPanel = () => { value={selfEvaluationContent} onChange={handleChange} type="editor" - placeholder="描述你的自我评价..." + placeholder={t("placeholder")} />
); diff --git a/src/components/editor/skills/SkillPanel.tsx b/src/components/editor/skills/SkillPanel.tsx index da07d0b3..7209bdf2 100644 --- a/src/components/editor/skills/SkillPanel.tsx +++ b/src/components/editor/skills/SkillPanel.tsx @@ -1,8 +1,10 @@ +import { useTranslations } from "@/i18n/compat/client"; import { useResumeStore } from "@/store/useResumeStore"; import { cn } from "@/lib/utils"; import Field from "../Field"; const SkillPanel = () => { + const t = useTranslations("workbench.skillsPanel"); const { activeResume, updateSkillContent } = useResumeStore(); const { skillContent } = activeResume || {}; const handleChange = (value: string) => { @@ -21,7 +23,7 @@ const SkillPanel = () => { value={skillContent} onChange={handleChange} type="editor" - placeholder="描述你的技能、专长等..." + placeholder={t("placeholder")} /> ); diff --git a/src/components/preview/PreviewDock.tsx b/src/components/preview/PreviewDock.tsx index 1b1b9627..745b617c 100644 --- a/src/components/preview/PreviewDock.tsx +++ b/src/components/preview/PreviewDock.tsx @@ -16,7 +16,7 @@ import { import { RiMarkdownLine } from "@remixicon/react"; import { toast } from "sonner"; import { motion } from "framer-motion"; -import { useTranslations } from "@/i18n/compat/client"; +import { useTranslations, useLocale } from "@/i18n/compat/client"; import { useRouter } from "@/lib/navigation"; import { Dock, DockIcon } from "@/components/magicui/dock"; import { Button } from "@/components/ui/button"; @@ -91,6 +91,7 @@ const PreviewDock = ({ }: PreviewDockProps) => { const router = useRouter(); const t = useTranslations("previewDock"); + const locale = useLocale(); const { checkGrammar, isChecking } = useGrammarCheck(); const { @@ -130,11 +131,11 @@ const PreviewDock = ({ return; } - await checkGrammar(text); + await checkGrammar(text, locale); } catch (error) { toast.error(t("grammarCheck.errorToast")); } - }, [resumeContentRef, checkConfiguration, checkGrammar, t]); + }, [resumeContentRef, checkConfiguration, checkGrammar, locale, t]); const handleGoGitHub = () => { window.open(GITHUB_REPO_URL, "_blank"); diff --git a/src/components/shared/GithubContribution.tsx b/src/components/shared/GithubContribution.tsx index 745b8b84..38ea33fb 100644 --- a/src/components/shared/GithubContribution.tsx +++ b/src/components/shared/GithubContribution.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useState } from "react"; +import { useLocale } from "@/i18n/compat/client"; +import { heroUiLocales } from "@/i18n/config"; import { cn } from "@/lib/utils"; interface ContributionDay { @@ -30,9 +32,9 @@ const getColorLevel = (count: number): string => { return colorLevels[4]; }; -const formatDate = (dateString: string): string => { +const formatDate = (dateString: string, locale: string): string => { const date = new Date(dateString); - return date.toLocaleDateString("zh-CN", { + return date.toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric", @@ -125,6 +127,8 @@ const GithubContributions: React.FC = ({ className, year = new Date().getFullYear(), }) => { + const appLocale = useLocale(); + const dateLocale = heroUiLocales[appLocale]; const [weeks, setWeeks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -223,7 +227,7 @@ const GithubContributions: React.FC = ({ transitionDuration: "150ms", }} > - {formatDate(day.date)}: {day.count} contributions + {formatDate(day.date, dateLocale)}: {day.count} contributions
)} diff --git a/src/components/shared/LanguageSwitch.tsx b/src/components/shared/LanguageSwitch.tsx index a9f47729..c1451d2f 100644 --- a/src/components/shared/LanguageSwitch.tsx +++ b/src/components/shared/LanguageSwitch.tsx @@ -1,5 +1,4 @@ import { useLocale } from "@/i18n/compat/client"; -import { useLocation, useNavigate } from "@tanstack/react-router"; import { Languages } from "lucide-react"; import { DropdownMenu, @@ -9,23 +8,11 @@ import { } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { locales, localeNames } from "@/i18n/config"; -import { getLocaleFromPathname, replacePathLocale } from "@/i18n/runtime"; +import { useSetAppLocale } from "@/i18n/locale-context"; export default function LanguageSwitch() { const locale = useLocale(); - const navigate = useNavigate(); - const pathname = useLocation({ - select: (location) => location.pathname - }); - - const handleSwitchLocale = (nextLocale: (typeof locales)[number]) => { - document.cookie = `NEXT_LOCALE=${nextLocale}; path=/; max-age=31536000`; - - const currentPathLocale = getLocaleFromPathname(pathname); - if (currentPathLocale) { - navigate({ to: replacePathLocale(pathname, nextLocale) }); - } - }; + const setLocale = useSetAppLocale(); return ( @@ -44,7 +31,7 @@ export default function LanguageSwitch() { handleSwitchLocale(loc)} + onClick={() => setLocale(loc)} > {localeNames[loc]} diff --git a/src/components/shared/TemplateSheet.tsx b/src/components/shared/TemplateSheet.tsx index c63bb832..6d3be181 100644 --- a/src/components/shared/TemplateSheet.tsx +++ b/src/components/shared/TemplateSheet.tsx @@ -10,6 +10,7 @@ import { SheetTrigger, } from "@/components/ui/sheet-no-overlay"; import { cn } from "@/lib/utils"; +import { getTemplateKey } from "@/lib/templates"; import { DEFAULT_TEMPLATES } from "@/config"; import { useResumeStore } from "@/store/useResumeStore"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -19,6 +20,7 @@ type TemplateItem = (typeof DEFAULT_TEMPLATES)[number]; interface TemplatePreviewProps { template: TemplateItem; + templateName: string; isActive: boolean; snapshotSrc: string | null; onSelect: (templateId: string) => void; @@ -26,6 +28,7 @@ interface TemplatePreviewProps { const TemplatePreview = ({ template, + templateName, isActive, snapshotSrc, onSelect, @@ -44,7 +47,7 @@ const TemplatePreview = ({ {snapshotSrc ? ( {template.name} - {template.name} + {templateName} )} @@ -72,6 +75,7 @@ const TemplatePreview = ({ const TemplateSheet = () => { const t = useTranslations("templates"); + const tTemplates = useTranslations("dashboard.templates"); const locale = useLocale(); const { activeResume, setTemplate } = useResumeStore(); const { snapshotMap } = useTemplateSnapshots(locale); @@ -98,6 +102,7 @@ const TemplateSheet = () => { = { + zh: DEFAULT_FIELD_ORDER, + en: DEFAULT_FIELD_ORDER_EN, + ru: DEFAULT_FIELD_ORDER_RU, +}; + +export function getDefaultFieldOrder(locale: Locale): BasicFieldType[] { + return FIELD_ORDER_BY_LOCALE[locale] ?? DEFAULT_FIELD_ORDER; +} + export const GITHUB_REPO_URL = "https://github.com/JOYCEQL/magic-resume"; export const PDF_EXPORT_CONFIG = { diff --git a/src/config/initialResumeData.ts b/src/config/initialResumeData.ts index 160e28eb..603357dc 100644 --- a/src/config/initialResumeData.ts +++ b/src/config/initialResumeData.ts @@ -1,4 +1,8 @@ -import { DEFAULT_FIELD_ORDER } from "./constants"; +import { + DEFAULT_FIELD_ORDER, + DEFAULT_FIELD_ORDER_EN, + DEFAULT_FIELD_ORDER_RU, +} from "./constants"; import { GlobalSettings, DEFAULT_CONFIG, ResumeData } from "../types/resume"; const initialGlobalSettings: GlobalSettings = { baseFontSize: 16, @@ -180,7 +184,7 @@ export const initialResumeStateEn = { phone: "555-123-4567", location: "San Francisco, CA", birthDate: "", - fieldOrder: DEFAULT_FIELD_ORDER, + fieldOrder: DEFAULT_FIELD_ORDER_EN, icons: { email: "Mail", phone: "Phone", @@ -378,3 +382,188 @@ export const blankResumeStateEn = { certificates: [], menuSections: [initialResumeStateEn.menuSections[0]], }; + +export const initialResumeStateRu = { + title: "Новое резюме", + basic: { + name: "Анна Иванова", + title: "Старший фронтенд-инженер", + employementStatus: "В поиске работы", + email: "anna.ivanova@example.com", + phone: "+7 (999) 123-45-67", + location: "Москва, Россия", + birthDate: "", + fieldOrder: DEFAULT_FIELD_ORDER_RU, + icons: { + email: "Mail", + phone: "Phone", + birthDate: "CalendarRange", + employementStatus: "Briefcase", + location: "MapPin", + }, + photoConfig: DEFAULT_CONFIG, + customFields: [], + photo: "/avatar.png", + githubKey: "", + githubUseName: "", + githubContributionsVisible: false, + }, + education: [ + { + id: "1", + school: "МГУ им. М.В. Ломоносова", + major: "Прикладная математика и информатика", + degree: "", + startDate: "2013-09", + endDate: "2017-06", + visible: true, + gpa: "", + description: `
    +
  • Основные курсы: структуры данных, алгоритмы, операционные системы, компьютерные сети, веб-разработка
  • +
  • Топ 5% потока, три года подряд получала стипендию за успеваемость
  • +
  • Руководила техническим отделом студенческого IT-клуба, организовывала митапы
  • +
  • Участвовала в open-source проектах, сертификат GitHub Campus Expert
  • +
`, + }, + ], + skillContent: `
+
    +
  • Фронтенд-фреймворки: React, Vue.js, Next.js, Nuxt.js и другие SSR-фреймворки
  • +
  • Языки: TypeScript, JavaScript(ES6+), HTML5, CSS3
  • +
  • UI/стили: TailwindCSS, Sass/Less, CSS Modules, Styled-components
  • +
  • Управление состоянием: Redux, Vuex, Zustand, Jotai, React Query
  • +
  • Сборка: Webpack, Vite, Rollup, Babel, ESLint
  • +
  • Тестирование: Jest, React Testing Library, Cypress
  • +
  • Производительность: принципы рендеринга браузера, мониторинг метрик, code splitting, lazy loading
  • +
  • Контроль версий: Git, SVN
  • +
  • Техническое лидерство: опыт управления командой, выбор технологий и архитектура крупных проектов
  • +
+
`, + selfEvaluationContent: "", + experience: [ + { + id: "1", + company: "Яндекс", + position: "Старший фронтенд-инженер", + date: "2021.07 - 2024.12", + visible: true, + details: `
    +
  • Разработка и поддержка платформы для создателей контента, проектирование архитектуры ключевых функций
  • +
  • Оптимизация сборки: время сборки сокращено с 8 до 2 минут
  • +
  • Разработка библиотеки компонентов, повышение переиспользования кода на 70%
  • +
  • Руководство проектом по оптимизации производительности, сокращение времени загрузки на 50%
  • +
  • Наставничество junior-разработчиков, организация технических митапов
  • +
`, + }, + ], + draggingProjectId: null, + projects: [ + { + id: "p1", + name: "Платформа для создателей контента", + role: "Ведущий фронтенд-разработчик", + date: "2022.06 - 2023.12", + description: `
    +
  • React-платформа аналитики и управления контентом для миллионов создателей
  • +
  • Подсистемы аналитики, управления контентом и монетизации
  • +
  • Redux для управления состоянием и сложными потоками данных
  • +
  • Ant Design для единообразия интерфейса
  • +
  • Code splitting и lazy loading для оптимизации загрузки
  • +
`, + visible: true, + }, + { + id: "p2", + name: "Инструменты разработки мини-приложений", + role: "Ключевой разработчик", + date: "2020.03 - 2021.06", + description: `
    +
  • Комплексное решение для разработки, отладки и публикации мини-приложений
  • +
  • Кроссплатформенное десктопное приложение на Electron
  • +
  • Поддержка Windows, macOS и Linux
  • +
  • Логирование ошибок и анализ производительности в реальном времени
  • +
  • Интеграция сторонних плагинов и SDK
  • +
`, + visible: true, + }, + { + id: "p3", + name: "Платформа мониторинга фронтенда", + role: "Технический руководитель", + date: "2021.09 - 2022.05", + description: `
    +
  • Мониторинг ошибок, производительности и поведения пользователей
  • +
  • Vue и Element UI, визуализация данных в реальном времени
  • +
  • Метрики ошибок, производительности и поведения пользователей
  • +
  • Инструменты анализа для поиска и устранения проблем
  • +
  • Интеграция сторонних плагинов и SDK
  • +
`, + visible: true, + }, + ], + menuSections: [ + { + id: "basic", + title: "Профиль", + icon: "👤", + enabled: true, + order: 0, + }, + { + id: "skills", + title: "Навыки", + icon: "⚡", + enabled: true, + order: 1, + }, + { + id: "experience", + title: "Опыт", + icon: "💼", + enabled: true, + order: 2, + }, + { + id: "projects", + title: "Проекты", + icon: "🚀", + enabled: true, + order: 3, + }, + { + id: "education", + title: "Образование", + icon: "🎓", + enabled: true, + order: 4, + }, + ], + certificates: [], + customData: {}, + activeSection: "basic", + globalSettings: initialGlobalSettings, +}; + +export const blankResumeStateRu = { + ...initialResumeStateRu, + title: "Новое резюме", + basic: { + ...initialResumeStateRu.basic, + name: "", + title: "", + email: "", + phone: "", + location: "", + birthDate: "", + employementStatus: "", + photo: "", + customFields: [], + }, + education: [], + skillContent: "", + selfEvaluationContent: "", + experience: [], + projects: [], + certificates: [], + menuSections: [initialResumeStateRu.menuSections[0]], +}; diff --git a/src/config/localeResumeData.test.ts b/src/config/localeResumeData.test.ts new file mode 100644 index 00000000..707d7531 --- /dev/null +++ b/src/config/localeResumeData.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getInitialResumeStateForLocale } from "./localeResumeData"; +import { initialResumeStateRu } from "./initialResumeData"; + +describe("getInitialResumeStateForLocale", () => { + it("returns Russian demo resume for ru locale", () => { + const resume = getInitialResumeStateForLocale("ru"); + + expect(resume.basic.name).toBe(initialResumeStateRu.basic.name); + expect(resume.basic.name).not.toBe( + getInitialResumeStateForLocale("zh").basic.name + ); + }); +}); diff --git a/src/config/localeResumeData.ts b/src/config/localeResumeData.ts new file mode 100644 index 00000000..28f89f62 --- /dev/null +++ b/src/config/localeResumeData.ts @@ -0,0 +1,49 @@ +import type { Locale } from "@/i18n/config"; +import { defaultLocale } from "@/i18n/config"; +import { getMessagesForLocale } from "@/i18n/messages"; +import { getCookieLocale as readCookieLocale } from "@/i18n/runtime"; +import { + blankResumeState, + blankResumeStateEn, + blankResumeStateRu, + initialResumeState, + initialResumeStateEn, + initialResumeStateRu, +} from "./initialResumeData"; + +type ResumeTemplateState = typeof initialResumeState; + +const INITIAL_RESUME_BY_LOCALE: Record = { + zh: initialResumeState, + en: initialResumeStateEn, + ru: initialResumeStateRu, +}; + +const BLANK_RESUME_BY_LOCALE: Record = { + zh: blankResumeState, + en: blankResumeStateEn, + ru: blankResumeStateRu, +}; + +export function getCookieLocale(): Locale { + return readCookieLocale(); +} + +export function getInitialResumeStateForLocale(locale: Locale): ResumeTemplateState { + return INITIAL_RESUME_BY_LOCALE[locale] ?? INITIAL_RESUME_BY_LOCALE[defaultLocale]; +} + +export function getBlankResumeStateForLocale(locale: Locale): ResumeTemplateState { + return BLANK_RESUME_BY_LOCALE[locale] ?? BLANK_RESUME_BY_LOCALE[defaultLocale]; +} + +export function getLocalizedCommonLabel( + locale: Locale, + key: "newResume" | "copy" +): string { + const messages = getMessagesForLocale(locale) as { + common: Record; + }; + + return messages.common[key] ?? key; +} diff --git a/src/generated/templateSnapshotManifest.ts b/src/generated/templateSnapshotManifest.ts index a7a1f1cd..f73a72f6 100644 --- a/src/generated/templateSnapshotManifest.ts +++ b/src/generated/templateSnapshotManifest.ts @@ -1,28 +1,39 @@ export const TEMPLATE_SNAPSHOT_MANIFEST = { "version": 1, - "generatedAt": "2026-06-01T05:05:39.212Z", + "generatedAt": "2026-06-09T15:38:50.087Z", "locales": { "zh": { - "classic": "/template-snapshots/zh/classic.png?v=2026-06-01T05%3A05%3A39.212Z", - "modern": "/template-snapshots/zh/modern.png?v=2026-06-01T05%3A05%3A39.212Z", - "left-right": "/template-snapshots/zh/left-right.png?v=2026-06-01T05%3A05%3A39.212Z", - "timeline": "/template-snapshots/zh/timeline.png?v=2026-06-01T05%3A05%3A39.212Z", - "minimalist": "/template-snapshots/zh/minimalist.png?v=2026-06-01T05%3A05%3A39.212Z", - "elegant": "/template-snapshots/zh/elegant.png?v=2026-06-01T05%3A05%3A39.212Z", - "creative": "/template-snapshots/zh/creative.png?v=2026-06-01T05%3A05%3A39.212Z", - "editorial": "/template-snapshots/zh/editorial.png?v=2026-06-01T05%3A05%3A39.212Z", - "swiss": "/template-snapshots/zh/swiss.png?v=2026-06-01T05%3A05%3A39.212Z" + "classic": "/template-snapshots/zh/classic.png?v=2026-06-09T15%3A38%3A50.087Z", + "modern": "/template-snapshots/zh/modern.png?v=2026-06-09T15%3A38%3A50.087Z", + "left-right": "/template-snapshots/zh/left-right.png?v=2026-06-09T15%3A38%3A50.087Z", + "timeline": "/template-snapshots/zh/timeline.png?v=2026-06-09T15%3A38%3A50.087Z", + "minimalist": "/template-snapshots/zh/minimalist.png?v=2026-06-09T15%3A38%3A50.087Z", + "elegant": "/template-snapshots/zh/elegant.png?v=2026-06-09T15%3A38%3A50.087Z", + "creative": "/template-snapshots/zh/creative.png?v=2026-06-09T15%3A38%3A50.087Z", + "editorial": "/template-snapshots/zh/editorial.png?v=2026-06-09T15%3A38%3A50.087Z", + "swiss": "/template-snapshots/zh/swiss.png?v=2026-06-09T15%3A38%3A50.087Z" }, "en": { - "classic": "/template-snapshots/en/classic.png?v=2026-06-01T05%3A05%3A39.212Z", - "modern": "/template-snapshots/en/modern.png?v=2026-06-01T05%3A05%3A39.212Z", - "left-right": "/template-snapshots/en/left-right.png?v=2026-06-01T05%3A05%3A39.212Z", - "timeline": "/template-snapshots/en/timeline.png?v=2026-06-01T05%3A05%3A39.212Z", - "minimalist": "/template-snapshots/en/minimalist.png?v=2026-06-01T05%3A05%3A39.212Z", - "elegant": "/template-snapshots/en/elegant.png?v=2026-06-01T05%3A05%3A39.212Z", - "creative": "/template-snapshots/en/creative.png?v=2026-06-01T05%3A05%3A39.212Z", - "editorial": "/template-snapshots/en/editorial.png?v=2026-06-01T05%3A05%3A39.212Z", - "swiss": "/template-snapshots/en/swiss.png?v=2026-06-01T05%3A05%3A39.212Z" + "classic": "/template-snapshots/en/classic.png?v=2026-06-09T15%3A38%3A50.087Z", + "modern": "/template-snapshots/en/modern.png?v=2026-06-09T15%3A38%3A50.087Z", + "left-right": "/template-snapshots/en/left-right.png?v=2026-06-09T15%3A38%3A50.087Z", + "timeline": "/template-snapshots/en/timeline.png?v=2026-06-09T15%3A38%3A50.087Z", + "minimalist": "/template-snapshots/en/minimalist.png?v=2026-06-09T15%3A38%3A50.087Z", + "elegant": "/template-snapshots/en/elegant.png?v=2026-06-09T15%3A38%3A50.087Z", + "creative": "/template-snapshots/en/creative.png?v=2026-06-09T15%3A38%3A50.087Z", + "editorial": "/template-snapshots/en/editorial.png?v=2026-06-09T15%3A38%3A50.087Z", + "swiss": "/template-snapshots/en/swiss.png?v=2026-06-09T15%3A38%3A50.087Z" + }, + "ru": { + "classic": "/template-snapshots/ru/classic.png?v=2026-06-09T15%3A38%3A50.087Z", + "modern": "/template-snapshots/ru/modern.png?v=2026-06-09T15%3A38%3A50.087Z", + "left-right": "/template-snapshots/ru/left-right.png?v=2026-06-09T15%3A38%3A50.087Z", + "timeline": "/template-snapshots/ru/timeline.png?v=2026-06-09T15%3A38%3A50.087Z", + "minimalist": "/template-snapshots/ru/minimalist.png?v=2026-06-09T15%3A38%3A50.087Z", + "elegant": "/template-snapshots/ru/elegant.png?v=2026-06-09T15%3A38%3A50.087Z", + "creative": "/template-snapshots/ru/creative.png?v=2026-06-09T15%3A38%3A50.087Z", + "editorial": "/template-snapshots/ru/editorial.png?v=2026-06-09T15%3A38%3A50.087Z", + "swiss": "/template-snapshots/ru/swiss.png?v=2026-06-09T15%3A38%3A50.087Z" } } } as const; diff --git a/src/i18n/compat/server.ts b/src/i18n/compat/server.ts index 4c0e5732..ef6938ac 100644 --- a/src/i18n/compat/server.ts +++ b/src/i18n/compat/server.ts @@ -1,15 +1,9 @@ import { defaultLocale, Locale } from "@/i18n/config"; -import zhMessages from "@/i18n/locales/zh.json"; -import enMessages from "@/i18n/locales/en.json"; +import { getMessagesForLocale } from "@/i18n/messages"; import { createTranslator } from "./utils"; type Messages = Record; -const MESSAGES: Record = { - zh: zhMessages as Messages, - en: enMessages as Messages -}; - let requestLocale: Locale = defaultLocale; export function setRequestLocale(locale: Locale) { @@ -21,7 +15,7 @@ export async function getLocale() { } export async function getMessages({ locale }: { locale?: Locale } = {}) { - return MESSAGES[locale ?? requestLocale] ?? MESSAGES[defaultLocale]; + return getMessagesForLocale(locale ?? requestLocale); } export async function getTranslations({ diff --git a/src/i18n/config.ts b/src/i18n/config.ts index fae98859..a834a129 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -1,4 +1,4 @@ -export const locales = ["zh", "en"] as const; +export const locales = ["zh", "en", "ru"] as const; export type Locale = (typeof locales)[number]; export const defaultLocale: Locale = "zh"; @@ -6,4 +6,23 @@ export const defaultLocale: Locale = "zh"; export const localeNames: Record = { zh: "中文", en: "English", + ru: "Русский", +}; + +export const localeTags: Record = { + zh: "zh_CN", + en: "en_US", + ru: "ru_RU", +}; + +export const heroUiLocales: Record = { + zh: "zh-CN", + en: "en-US", + ru: "ru-RU", +}; + +export const importLanguages: Record = { + zh: "Chinese", + en: "English", + ru: "Russian", }; diff --git a/src/i18n/locale-context.test.tsx b/src/i18n/locale-context.test.tsx new file mode 100644 index 00000000..66e194ac --- /dev/null +++ b/src/i18n/locale-context.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LocaleProvider, useAppLocale, useSetAppLocale } from "./locale-context"; +import { LOCALE_COOKIE_NAME } from "./runtime"; + +const navigate = vi.fn(); + +vi.mock("@tanstack/react-router", () => ({ + useLocation: (opts?: { select?: (loc: { pathname: string }) => string }) => { + const location = { pathname: "/app/dashboard" }; + return opts?.select ? opts.select(location) : location; + }, + useNavigate: () => navigate, +})); + +function LocaleConsumer() { + const locale = useAppLocale(); + const setLocale = useSetAppLocale(); + + return ( +
+ {locale} + +
+ ); +} + +describe("LocaleProvider", () => { + it("updates locale on app routes without navigation", async () => { + document.cookie = `${LOCALE_COOKIE_NAME}=en; path=/`; + const user = userEvent.setup(); + + render( + + + + ); + + expect(screen.getByTestId("locale")).toHaveTextContent("en"); + + await user.click(screen.getByRole("button", { name: "Switch to Russian" })); + + expect(screen.getByTestId("locale")).toHaveTextContent("ru"); + expect(document.cookie).toContain(`${LOCALE_COOKIE_NAME}=ru`); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/i18n/locale-context.tsx b/src/i18n/locale-context.tsx new file mode 100644 index 00000000..0418c9de --- /dev/null +++ b/src/i18n/locale-context.tsx @@ -0,0 +1,73 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; +import type { Locale } from "./config"; +import { + getLocaleFromPathname, + getPreferredLocale, + replacePathLocale, + setCookieLocale, +} from "./runtime"; + +type LocaleContextValue = { + locale: Locale; + setLocale: (nextLocale: Locale) => void; +}; + +const LocaleContext = createContext(null); + +export function LocaleProvider({ children }: { children: ReactNode }) { + const pathname = useLocation({ + select: (location) => location.pathname, + }); + const navigate = useNavigate(); + const [locale, setLocaleState] = useState(() => + getPreferredLocale(pathname) + ); + + useEffect(() => { + setLocaleState(getPreferredLocale(pathname)); + }, [pathname]); + + const setLocale = useCallback( + (nextLocale: Locale) => { + setCookieLocale(nextLocale); + setLocaleState(nextLocale); + + const currentPathLocale = getLocaleFromPathname(pathname); + if (currentPathLocale) { + navigate({ to: replacePathLocale(pathname, nextLocale) }); + } + }, + [navigate, pathname] + ); + + const value = useMemo(() => ({ locale, setLocale }), [locale, setLocale]); + + return ( + {children} + ); +} + +export function useAppLocale(): Locale { + const context = useContext(LocaleContext); + if (!context) { + throw new Error("useAppLocale must be used within LocaleProvider"); + } + return context.locale; +} + +export function useSetAppLocale(): (nextLocale: Locale) => void { + const context = useContext(LocaleContext); + if (!context) { + throw new Error("useSetAppLocale must be used within LocaleProvider"); + } + return context.setLocale; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 27d5afd8..264cbabc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -13,7 +13,8 @@ "deleteSuccess": "Deleted successfully", "deleteModuleConfirm": "Are you sure you want to delete this module? This action cannot be undone.", "configured": "Configured", - "notConfigured": "Not configured" + "notConfigured": "Not configured", + "notFound": "Page not found" }, "home": { "header": { @@ -58,6 +59,7 @@ "preview": { "badge": "Real-time Preview", "title": "What You See Is What You Get", + "description": "Edit and preview in real time with professional templates. Export to PDF anytime and apply with confidence.", "item1": "Real-time preview of editing effects", "item2": "Multiple export format support" } @@ -401,6 +403,10 @@ "flexibleHeaderLayout": { "title": "Long Title" } + }, + "accessibility": { + "increase": "Increase", + "decrease": "Decrease" } }, "basicPanel": { @@ -409,6 +415,9 @@ "basicField": "Basic", "customField": "Custom", "layout": "Align", + "layoutLeft": "Align left", + "layoutCenter": "Align center", + "layoutRight": "Align right", "customFields": { "placeholders": { "label": "Label", @@ -431,7 +440,17 @@ "show": "Show", "hide": "Hide" }, - "githubContributions": "GitHub Contributions" + "githubContributions": "GitHub Contributions", + "placeholders": { + "field": "Enter {label}", + "githubToken": "Enter GitHub access token", + "githubUsername": "Enter GitHub username" + }, + "align": { + "left": "Align left", + "center": "Center", + "right": "Align right" + } }, "experiencePanel": { "title": "Work Experience", @@ -480,12 +499,14 @@ "name": "Personal Project", "description": "Project Description", "role": "Responsibilities", + "technologies": "Tech Stack", "date": "2023.01 - 2023.06" }, "placeholders": { "name": "Project Name", "description": "Briefly describe project background and goals", "role": "Your role and responsibilities in the project", + "technologies": "Technologies and tools used", "date": "Project time range", "link": "Project link " } @@ -578,6 +599,38 @@ "width": "Width (%)", "delete": "Delete", "empty": "No images yet. Please upload or paste images." + }, + "skillsPanel": { + "placeholder": "Describe your skills, expertise, etc..." + }, + "selfEvaluationPanel": { + "placeholder": "Describe your self-evaluation..." + }, + "customPanel": { + "add": "Add" + }, + "customItem": { + "title": "Title", + "titlePlaceholder": "Title", + "subtitle": "Subtitle", + "subtitlePlaceholder": "Subtitle", + "dateRange": "Date Range", + "dateRangePlaceholder": "e.g. 2023.01 - 2024.01", + "description": "Description", + "descriptionPlaceholder": "Enter detailed description...", + "unnamedModule": "Unnamed Module" + }, + "fallbacks": { + "unnamedProject": "Unnamed Project", + "unnamedCompany": "Company Name", + "emptySchool": "School not filled", + "unnamedResume": "Untitled Resume" + }, + "editorHeader": { + "placeholder": "Resume name" + }, + "editPanel": { + "focusHint": "Click the text to start editing" } }, "field": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json new file mode 100644 index 00000000..b83a8a08 --- /dev/null +++ b/src/i18n/locales/ru.json @@ -0,0 +1,875 @@ +{ + "common": { + "title": "Magic Resume", + "subtitle": "Редактор резюме с ИИ", + "description": "Magic Resume — бесплатный редактор резюме с открытым исходным кодом, ориентированный на конфиденциальность. Регистрация не требуется, все данные хранятся локально, поддерживается резервное копирование и экспорт.", + "dashboard": "Панель управления", + "edit": "Редактировать", + "delete": "Удалить", + "newResume": "Новое резюме", + "copy": "Копировать", + "cancel": "Отмена", + "confirm": "Подтвердить", + "deleteSuccess": "Успешно удалено", + "deleteModuleConfirm": "Вы уверены, что хотите удалить этот модуль? Это действие нельзя отменить.", + "configured": "Настроено", + "notConfigured": "Не настроено", + "notFound": "Страница не найдена" + }, + "home": { + "header": { + "title": "Magic Resume", + "startButton": "Начать", + "features": "Возможности", + "pricing": "Цены", + "about": "О проекте", + "login": "Войти", + "register": "Регистрация", + "dashboard": "Панель управления" + }, + "hero": { + "badge": "Умное создание резюме", + "title": "Сделайте создание резюме простым и умным", + "subtitle": "Magic Resume использует технологии ИИ, чтобы помочь вам быстро создать профессиональное резюме. Без регистрации, бесплатно, с безопасным хранением данных.", + "cta": "Начать", + "secondary": "Просмотреть шаблоны" + }, + "features": { + "title": "Почему Magic Resume?", + "subtitle": "Мы предлагаем комплексное решение для резюме, чтобы сделать поиск работы проще", + "ai": { + "badge": "ИИ-проверка", + "title": "Умное обнаружение, профессиональные советы", + "description": "Встроенная проверка грамматики автоматически выявляет неудачные формулировки и предлагает профессиональные правки, делая ваше резюме заметнее.", + "item1": "Интеллектуальное улучшение", + "item1_description": "ИИ автоматически оптимизирует формулировки, делая резюме более профессиональным", + "item2": "Проверка грамматики", + "item2_description": "Автоматически обнаруживает и исправляет грамматические и орфографические ошибки" + }, + "storage": { + "badge": "Локальное хранение", + "title": "Безопасность данных, конфиденциальность прежде всего", + "description": "Все данные резюме хранятся локально — не нужно беспокоиться об утечках. Поддерживается экспорт для резервного копирования.", + "item1": "Локальное хранение файлов", + "item1_description": "Данные резюме надёжно хранятся на жёстком диске вашего компьютера", + "item2": "Несколько форматов экспорта", + "item2_description": "Поддержка экспорта в PDF и JSON", + "item3": "Резервное копирование данных" + }, + "preview": { + "badge": "Предпросмотр в реальном времени", + "title": "Что видите — то и получаете", + "description": "Редактируйте и сразу смотрите результат с профессиональными шаблонами. Экспорт в PDF в любой момент.", + "item1": "Предпросмотр изменений в реальном времени", + "item2": "Поддержка нескольких форматов экспорта" + } + }, + "news": { + "label": "Новости", + "content": "Запущена новая функция улучшения резюме с ИИ" + }, + "footer": { + "copyright": " 2025 Magic Resume. Все права защищены." + }, + "changelog": "История изменений", + "cta": { + "title": "Начните новую главу карьеры", + "description": "Начните использовать Magic Resume прямо сейчас, чтобы создать впечатляющее резюме", + "button": "Начать бесплатно" + }, + "faq": { + "title": "Часто задаваемые вопросы", + "items": [ + { + "question": "Magic Resume бесплатен?", + "answer": "Magic Resume сейчас бесплатен и покрывает базовые потребности в создании резюме. Функции open-source версии останутся без изменений." + }, + { + "question": "Мои данные резюме в безопасности?", + "answer": "Да, полностью. Magic Resume использует локальное хранение — все данные хранятся на вашем устройстве, без облака, что обеспечивает полную защиту конфиденциальности." + }, + { + "question": "Какие форматы экспорта поддерживаются?", + "answer": "Сейчас поддерживается экспорт в PDF с сохранением форматирования на любом устройстве. В будущем планируется поддержка других форматов." + }, + { + "question": "Как синхронизировать между устройствами?", + "answer": "Мы предоставляем экспорт JSON-конфигурации, позволяющий сохранить настройки резюме и открыть их на любом устройстве." + }, + { + "question": "Насколько гибкая настройка?", + "answer": "Мы предлагаем широкие возможности настройки: цвета, макеты и многое другое — под ваши предпочтения и требования отрасли." + } + ] + } + }, + "dashboard": { + "sidebar": { + "appName": "Magic Resume", + "resumes": "Резюме", + "settings": "Настройки", + "templates": "Шаблоны", + "ai": "Настройки ИИ" + }, + "resumes": { + "created": "Создано", + "synced": "Синхронизированные файлы", + "view": "Просмотр", + "myResume": "Мои резюме", + "create": "Создать резюме", + "newResume": "Новое резюме", + "newResumeDescription": "Создайте новое резюме, чтобы начать.", + "import": "Импорт резюме", + "untitled": "Резюме без названия", + "importSuccess": "Конфигурация успешно импортирована", + "importError": "Ошибка импорта, проверьте формат файла", + "importDialog": { + "title": "Импорт резюме", + "jsonTitle": "Импорт JSON", + "jsonDescription": "Импорт экспортированного файла конфигурации резюме (.json)", + "pdfTitle": "Импорт PDF", + "pdfDescription": "Использовать Gemini для преобразования в структурированные данные резюме", + "importing": "Импорт, пожалуйста подождите...", + "geminiConfigRequired": "Сначала настройте Gemini API Key и Model ID в настройках ИИ", + "pdfSuccess": "PDF успешно импортирован", + "pdfError": "Ошибка импорта PDF. Проверьте содержимое PDF или настройки Gemini" + }, + "notice": { + "title": "Внимание", + "description": "Рекомендуем настроить папку резервного копирования резюме в настройках, чтобы данные не потерялись при очистке кэша браузера", + "goToSettings": "Перейти в настройки" + }, + "deleteConfirmTitle": "Подтвердить удаление резюме?", + "deleteConfirmDescription": "Это действие нельзя отменить. Резюме будет безвозвратно удалено с вашего устройства.", + "createDialog": { + "title": "Создать новое резюме", + "description": "Начните с пустого шаблона", + "tabs": { + "fromTemplate": "Из шаблона", + "uploadFile": "Загрузить файл" + }, + "namePlaceholder": "Название", + "switchTemplate": "Сменить шаблон", + "cancel": "Отмена", + "create": "Создать", + "blankTitle": "Пустое резюме", + "startFromBlank": "Начать с пустого", + "startFromTemplate": "Начать с шаблона", + "blankCardDescription": "Используйте встроенный макет по умолчанию и начните с нуля.", + "blankThumbnailDescription": "Начните с чистой страницы и создайте резюме с полным контролем.", + "createNow": "Создать сейчас", + "backToGrid": "Вернуться к сетке шаблонов", + "blankPreviewDescription": "Выберите пустую страницу и создайте всё с нуля без ограничений.", + "useThisTemplate": "Использовать этот шаблон", + "sample": { + "company": "Google", + "position": "Старший инженер-программист", + "present": "Настоящее время", + "workDescription": "Руководил фронтенд-командой и улучшил производительность страниц на 40%." + } + } + }, + "settings": { + "title": "Настройки", + "syncDirectory": { + "title": "Папка синхронизации", + "description": "Выберите папку для синхронизации и резервного копирования резюме.", + "currentSyncFolder": "Текущая папка синхронизации", + "noFolderConfigured": "Папка не настроена", + "changeFolder": "Сменить папку", + "selectFolder": "Выбрать папку" + }, + "sync": { + "title": "Папка синхронизации", + "description": "Выберите папку для синхронизации и резервного копирования резюме.", + "select": "Выбрать папку" + }, + "ai": { + "title": "Настройки ИИ", + "currentModel": "Текущая модель", + "selectModel": "Выбрать модель", + "getApiKey": "Получить API-ключ", + "doubao": { + "title": "Doubao", + "description": "Получите API-ключ на Volcengine", + "apiKey": "Doubao API Key", + "modelId": "ID модели" + }, + "deepseek": { + "title": "DeepSeek", + "description": "Получите API-ключ на платформе DeepSeek", + "apiKey": "DeepSeek API Key" + }, + "openai": { + "title": "OpenAI", + "description": "Получите API-ключ на OpenAI или совместимой платформе", + "apiKey": "OpenAI API Key", + "modelId": "ID модели", + "apiEndpoint": "API Endpoint, например: https://openai.example.org/v1" + }, + "gemini": { + "title": "Gemini", + "description": "Поддерживает улучшение, проверку грамматики и импорт PDF. Рекомендуемая модель: gemini-flash-latest", + "apiKey": "Gemini API Key", + "modelId": "Gemini Model ID" + } + } + }, + "templates": { + "title": "Шаблоны", + "useTemplate": "Использовать шаблон", + "preview": "Предпросмотр", + "switchTemplate": "Сменить шаблон", + "classic": { + "name": "Классический", + "description": "Традиционный минималистичный макет, подходит для большинства вакансий" + }, + "modern": { + "name": "Две колонки", + "description": "Классический двухколоночный макет, подчёркивающий личные качества" + }, + "leftRight": { + "name": "Заголовки с фоном", + "description": "Выразительные заголовки разделов с цветным фоном" + }, + "timeline": { + "name": "Временная шкала", + "description": "Стиль временной шкалы, акцент на хронологии опыта" + }, + "minimalist": { + "name": "Минимализм", + "description": "Много свободного пространства, чистый и лаконичный стиль" + }, + "elegant": { + "name": "Элегантный", + "description": "Одноколоночный дизайн с центрированным заголовком и ноткой элегантности" + }, + "creative": { + "name": "Креативный", + "description": "Визуальный контраст, яркий и индивидуальный дизайн" + }, + "editorial": { + "name": "Редакционный", + "description": "Роскошное сочетание жирного шрифта с засечками и утончённого sans-serif" + }, + "swiss": { + "name": "Швейцарский стиль", + "description": "Художественный макет в стиле Баухаус с сильной типографической иерархией и геометрическими акцентами" + } + } + }, + "pdfExport": { + "modal": { + "title": "Экспорт резюме", + "subtitle": "Выберите формат экспорта резюме", + "pdfDesc": "Высокоточный рендеринг с 100% точностью форматирования. Рекомендуется для откликов на вакансии.", + "printDesc": "Сохранение через системный диалог печати. Запасной вариант или для настройки полей.", + "jsonDesc": "Экспорт данных резюме в JSON для резервного копирования или переноса между устройствами.", + "markdownDesc": "Конвертация в Markdown для удобного обмена с ИИ-моделями.", + "privacyNotice": "Magic Resume никогда не собирает, не загружает и не хранит ваши данные резюме. Ваша конфиденциальность полностью защищена." + }, + "button": { + "export": "Экспорт", + "exportPdf": "Экспорт PDF (сервер)", + "exportJson": "Экспорт JSON", + "exportMarkdown": "Экспорт Markdown", + "exporting": "Экспорт...", + "exportingJson": "Экспорт...", + "exportingMarkdown": "Экспорт...", + "print": "Печать в браузере" + }, + "toast": { + "success": "PDF успешно экспортирован", + "error": "Ошибка экспорта PDF", + "jsonSuccess": "Конфигурация успешно экспортирована", + "jsonError": "Ошибка экспорта конфигурации", + "markdownSuccess": "Markdown успешно экспортирован", + "markdownError": "Ошибка экспорта Markdown" + } + }, + "previewDock": { + "switchTemplate": "Сменить шаблон", + "grammarCheck": { + "idle": "ИИ-проверка грамматики", + "checking": "Проверка...", + "configurePrompt": "Настройте модель ИИ", + "configureButton": "Настроить", + "errorToast": "Ошибка проверки грамматики, попробуйте снова" + }, + "sidePanel": { + "expand": "Развернуть боковую панель", + "collapse": "Свернуть боковую панель" + }, + "editPanel": { + "expand": "Развернуть панель редактирования", + "collapse": "Свернуть панель редактирования" + }, + "previewPanel": { + "expand": "Развернуть панель предпросмотра", + "collapse": "Свернуть панель предпросмотра" + }, + "github": "GitHub", + "backToDashboard": "Вернуться на панель", + "copyResume": { + "tooltip": "Копировать резюме", + "success": "Резюме успешно скопировано", + "error": "Не удалось скопировать резюме" + }, + "export": { + "tooltip": "Экспорт резюме", + "pdf": "Экспорт PDF", + "json": "Экспорт JSON", + "markdown": "Экспорт Markdown", + "print": "Печать" + }, + "autoOnePage": { + "tooltip": "Автоподгонка на одну страницу", + "enabled": "Режим одной страницы включён", + "disabled": "Режим одной страницы выключен", + "cannotFit": "Слишком много контента. Оптимизировано по максимуму, но не помещается на одну страницу. Попробуйте упростить содержание или настроить поля и размер шрифта в боковой панели." + }, + "backup": { + "configured": "Резервная копия настроена", + "notConfigured": "Резервная копия не настроена", + "clickToConfigure": "Нажмите для настройки" + } + }, + "workbench": { + "sidePanel": { + "layout": { + "title": "Макет", + "addCustomSection": "Добавить модуль", + "addCustomSectionOption": "Добавить пользовательский раздел", + "standardSections": { + "skills": "Навыки", + "experience": "Опыт", + "projects": "Проекты", + "education": "Образование", + "selfEvaluation": "Самооценка", + "certificates": "Сертификаты" + } + }, + "theme": { + "title": "Цвет темы", + "custom": "Свой" + }, + "typography": { + "title": "Типографика", + "font": { + "title": "Шрифт", + "alibaba": "Alibaba PuHuiTi", + "misans": "MiSans", + "notosanssc": "Noto Sans SC", + "sourcehanserifsc": "Source Han Serif SC", + "note": "MiSans используется по бесплатной коммерческой лицензии. Сохраняйте атрибуцию MiSans при распространении проекта." + }, + "lineHeight": { + "title": "Межстрочный интервал", + "normal": "По умолчанию", + "relaxed": "Свободный", + "loose": "Широкий" + }, + "baseFontSize": { + "title": "Базовый размер шрифта" + }, + "headerSize": { + "title": "Размер заголовка раздела" + }, + "subheaderSize": { + "title": "Размер подзаголовка" + } + }, + "spacing": { + "title": "Отступы", + "pagePadding": { + "title": "Поля страницы" + }, + "sectionSpacing": { + "title": "Отступ между разделами" + }, + "paragraphSpacing": { + "title": "Отступ между абзацами" + } + }, + "mode": { + "title": "Режим", + "useIconMode": { + "title": "Режим иконок" + }, + "centerSubtitle": { + "title": "Центрировать подзаголовок" + }, + "flexibleHeaderLayout": { + "title": "Длинный заголовок" + } + }, + "accessibility": { + "increase": "Увеличить", + "decrease": "Уменьшить" + } + }, + "basicPanel": { + "title": "Профиль", + "avatar": "Аватар", + "basicField": "Основное", + "customField": "Дополнительно", + "layout": "Выравнивание", + "layoutLeft": "По левому краю", + "layoutCenter": "По центру", + "layoutRight": "По правому краю", + "customFields": { + "placeholders": { + "label": "Метка", + "value": "Значение" + }, + "displayLabel": "Показывать метку", + "addButton": "Добавить поле" + }, + "basicFields": { + "name": "Имя", + "title": "Должность", + "email": "Email", + "phone": "Телефон", + "website": "Сайт", + "location": "Местоположение", + "birthDate": "Дата рождения", + "employementStatus": "Занятость" + }, + "fieldVisibility": { + "show": "Показать", + "hide": "Скрыть" + }, + "githubContributions": "Вклад на GitHub", + "placeholders": { + "field": "Введите {label}", + "githubToken": "Введите GitHub access token", + "githubUsername": "Введите имя пользователя GitHub" + }, + "align": { + "left": "По левому краю", + "center": "По центру", + "right": "По правому краю" + } + }, + "experiencePanel": { + "title": "Опыт работы", + "addButton": "Добавить опыт работы", + "defaultProject": { + "company": "ООО Технологии", + "position": "Старший фронтенд-инженер", + "date": "2020 — настоящее время", + "details": "Отвечал за разработку основных продуктов..." + }, + "placeholders": { + "company": "название компании", + "position": "должность", + "date": "период работы", + "details": "обязанности и достижения" + } + }, + "experienceItem": { + "labels": { + "company": "Название компании", + "position": "Должность", + "date": "Период работы", + "details": "Обязанности" + }, + "placeholders": { + "company": "Введите название компании", + "position": "например, фронтенд-инженер", + "date": "например, 2020 — настоящее время", + "details": "Опишите обязанности и достижения на этой должности" + }, + "buttons": { + "edit": "Редактировать", + "save": "Сохранить", + "cancel": "Отмена", + "delete": "Удалить" + }, + "visibility": { + "show": "Показать", + "hide": "Скрыть" + } + }, + "projectPanel": { + "title": "Проекты", + "addButton": "Добавить проект", + "defaultProject": { + "name": "Личный проект", + "description": "Описание проекта", + "role": "Обязанности", + "technologies": "Технологии", + "date": "2023.01 — 2023.06" + }, + "placeholders": { + "name": "Название проекта", + "description": "Кратко опишите контекст и цели проекта", + "role": "Ваша роль и обязанности в проекте", + "technologies": "Используемые технологии и инструменты", + "date": "Период проекта", + "link": "Ссылка на проект" + } + }, + "projectItem": { + "labels": { + "name": "Название проекта", + "role": "Роль в проекте", + "date": "Период проекта", + "description": "Описание проекта", + "technologies": "Технологии", + "link": "Ссылка на проект", + "linkLabel": "Текст ссылки" + }, + "placeholders": { + "name": "Введите название проекта", + "role": "Ваша роль в проекте", + "date": "Период проекта", + "description": "Кратко опишите контекст и цели проекта", + "technologies": "Используемые технологии и инструменты", + "link": "Ссылка на проект", + "linkLabel": "Текст ссылки" + }, + "hints": { + "linkLabel": "Если текст ссылки пуст, автоматически отобразится домен или полный URL. Поддерживаются только ссылки http:// и https://." + }, + "buttons": { + "edit": "Редактировать", + "save": "Сохранить", + "cancel": "Отмена", + "delete": "Удалить" + }, + "visibility": { + "show": "Показать", + "hide": "Скрыть" + } + }, + "educationPanel": { + "title": "Образование", + "addButton": "Добавить образование", + "defaultProject": { + "school": "Название учебного заведения", + "degree": "Степень", + "major": "Специальность", + "date": "2020.09 — 2024.06" + }, + "placeholders": { + "school": "Введите название учебного заведения", + "degree": "Выберите степень", + "major": "Введите специальность", + "date": "Введите период обучения" + } + }, + "educationItem": { + "labels": { + "school": "Учебное заведение", + "degree": "Степень", + "major": "Специальность", + "date": "Период обучения", + "description": "Описание", + "gpa": "Средний балл", + "location": "Местоположение", + "startDate": "Дата начала", + "endDate": "Дата окончания" + }, + "placeholders": { + "school": "Введите название учебного заведения", + "degree": "Выберите степень", + "major": "Введите специальность", + "date": "Введите период обучения", + "description": "Введите описание", + "gpa": "Введите средний балл", + "location": "Введите местоположение" + }, + "buttons": { + "edit": "Редактировать", + "save": "Сохранить", + "cancel": "Отмена", + "delete": "Удалить" + }, + "visibility": { + "show": "Показать", + "hide": "Скрыть" + } + }, + "certificatesPanel": { + "title": "Сертификаты", + "addButton": "Добавить сертификат", + "tips": "Загрузите или вставьте (Cmd/Ctrl + V) изображения. Настройте ширину для горизонтального расположения.", + "width": "Ширина (%)", + "delete": "Удалить", + "empty": "Изображений пока нет. Загрузите или вставьте изображения." + }, + "skillsPanel": { + "placeholder": "Опишите ваши навыки, специализацию и т.д..." + }, + "selfEvaluationPanel": { + "placeholder": "Опишите вашу самооценку..." + }, + "customPanel": { + "add": "Добавить" + }, + "customItem": { + "title": "Заголовок", + "titlePlaceholder": "Заголовок", + "subtitle": "Подзаголовок", + "subtitlePlaceholder": "Подзаголовок", + "dateRange": "Период", + "dateRangePlaceholder": "например: 2023.01 - 2024.01", + "description": "Описание", + "descriptionPlaceholder": "Введите подробное описание...", + "unnamedModule": "Безымянный модуль" + }, + "fallbacks": { + "unnamedProject": "Безымянный проект", + "unnamedCompany": "Название компании", + "emptySchool": "Учебное заведение не указано", + "unnamedResume": "Резюме без названия" + }, + "editorHeader": { + "placeholder": "Название резюме" + }, + "editPanel": { + "focusHint": "Нажмите на текст, чтобы начать редактирование" + } + }, + "field": { + "selectDate": "Выбрать дату", + "enterYear": "Введите год", + "toPresent": "По настоящее время" + }, + "richEditor": { + "bold": "Жирный", + "italic": "Курсив", + "underline": "Подчёркнутый", + "link": "Ссылка", + "linkPlaceholder": "Введите URL, например https://example.com", + "linkApply": "Применить", + "linkRemove": "Удалить", + "linkInvalid": "Введите корректный URL", + "textColor": "Цвет текста", + "backgroundColor": "Цвет фона", + "alignLeft": "По левому краю", + "alignCenter": "По центру", + "alignRight": "По правому краю", + "alignJustify": "По ширине", + "bulletList": "Маркированный список", + "orderedList": "Нумерованный список", + "undo": "Отменить", + "redo": "Повторить", + "aiPolish": "ИИ-улучшение", + "paragraph": "Абзац", + "heading1": "Заголовок 1", + "heading2": "Заголовок 2", + "heading3": "Заголовок 3", + "colors": { + "black": "Чёрный", + "darkGray": "Тёмно-серый", + "gray": "Серый", + "red": "Красный", + "orange": "Оранжевый", + "orangeYellow": "Оранжево-жёлтый", + "yellow": "Жёлтый", + "yellowGreen": "Жёлто-зелёный", + "green": "Зелёный", + "cyan": "Голубой", + "lightBlue": "Светло-синий", + "blue": "Синий", + "purple": "Фиолетовый", + "magenta": "Пурпурный", + "pink": "Розовый" + } + }, + "iconSelector": { + "all": "Все", + "searchPlaceholder": "Поиск иконок...", + "noMatchingIcons": "Иконки не найдены", + "tryOtherKeywords": "Попробуйте другие ключевые слова", + "selectOtherCategory": "Выберите другую категорию", + "categories": { + "personal": "Личная информация", + "education": "Образование", + "experience": "Опыт работы", + "skills": "Навыки", + "languages": "Языки", + "projects": "Проекты", + "achievements": "Достижения", + "hobbies": "Хобби", + "social": "Социальные сети", + "others": "Другое" + }, + "icons": { + "user": "Пользователь", + "email": "Email", + "phone": "Телефон", + "address": "Адрес", + "website": "Сайт", + "mobile": "Мобильный", + "education": "Образование", + "school": "Учебное заведение", + "major": "Специальность", + "library": "Библиотека", + "scholarship": "Стипендия", + "work": "Работа", + "company": "Компания", + "office": "Офис", + "dateRange": "Период", + "workTime": "Время работы", + "programming": "Программирование", + "system": "Система", + "database": "База данных", + "terminal": "Терминал", + "techStack": "Технологический стек", + "language": "Язык", + "speaking": "Разговорная речь", + "communication": "Коммуникация", + "project": "Проект", + "branch": "Ветка", + "release": "Релиз", + "target": "Цель", + "trophy": "Трофей", + "medal": "Медаль", + "star": "Звезда", + "interest": "Интерес", + "music": "Музыка", + "art": "Искусство", + "photography": "Фотография", + "linkedin": "LinkedIn", + "twitter": "Twitter", + "facebook": "Facebook", + "instagram": "Instagram", + "profile": "Профиль", + "review": "Обзор", + "filter": "Фильтр", + "link": "Ссылка", + "salary": "Зарплата", + "idea": "Идея", + "send": "Отправить", + "share": "Поделиться", + "settings": "Настройки", + "search": "Поиск", + "flag": "Флаг", + "bookmark": "Закладка", + "thumbsUp": "Нравится", + "skill": "Навык" + } + }, + "aiPolishDialog": { + "title": "ИИ-улучшение", + "description": { + "ready": "Добавьте инструкции (необязательно) и нажмите «Начать улучшение»", + "polishing": "Улучшаем ваш текст...", + "finished": "Текст оптимизирован, проверьте результат" + }, + "error": { + "configRequired": "Сначала настройте модель ИИ", + "polishFailed": "Ошибка улучшения", + "applied": "Улучшенный текст применён" + }, + "content": { + "original": "Исходный текст", + "polished": "Улучшенный текст" + }, + "customInstructions": "Дополнительные инструкции", + "customInstructionsPlaceholder": "например: используйте более технические термины, выделите метрики, сохраните формальный тон… (необязательно)", + "button": { + "start": "Начать улучшение", + "regenerate": "Перегенерировать", + "generating": "Генерация...", + "apply": "Применить текст" + } + }, + "photoConfig": { + "title": "Настройки фото", + "description": "Настройте фото для резюме", + "upload": { + "title": "Ссылка", + "dragHint": "Перетащите или нажмите для загрузки", + "sizeLimit": "Размер изображения не должен превышать 2 МБ", + "typeLimit": "Загрузите файл изображения", + "urlPlaceholder": "Введите ссылку на изображение", + "invalidUrl": "Некорректная ссылка или недоступное изображение", + "timeout": "Превышено время загрузки", + "loadError": "Не удалось загрузить изображение" + }, + "config": { + "aspectRatio": "Соотношение сторон", + "size": "Размер", + "width": "Ширина", + "height": "Высота", + "border-radius": "Скругление углов", + "widthPlaceholder": "Ширина", + "heightPlaceholder": "Высота", + "ratios": { + "1:1": "1:1 Квадрат", + "4:3": "4:3 Альбомная", + "3:4": "3:4 Портретная", + "16:9": "16:9 Широкоэкранная", + "custom": "Свой" + }, + "borderRadius": { + "none": "Нет", + "medium": "Среднее", + "full": "Круг", + "custom": "Свой", + "customPlaceholder": "Своё скругление" + } + }, + "actions": { + "reset": "Сбросить", + "close": "Закрыть", + "cancel": "Отмена", + "removePhoto": "Удалить фото" + } + }, + "templates": { + "switchTemplate": "Сменить шаблон" + }, + "themeModal": { + "delete": { + "title": "Подтвердить удаление", + "description": "Вы уверены, что хотите удалить {title}?", + "confirmText": "Удалить", + "cancelText": "Отмена" + } + }, + "grammarCheck": { + "title": "ИИ-проверка грамматики", + "description": "Найдено предложений: {count}", + "exit": "Выход", + "spelling": "Орфография", + "punctuation": "Пунктуация", + "original": "Исходный текст", + "error_point": "Ошибка", + "suggestion": "Предложение", + "reason": "Причина", + "accept": "Применить", + "ignore": "Игнорировать", + "found_issues": "Найдено проблем: {count}", + "applied_success": "Изменения применены", + "apply_error": "Текст не найден, автоматическое применение невозможно", + "no_errors_title": "Отлично!", + "no_errors_desc": "Грамматических и пунктуационных ошибок не найдено." + }, + "faqDialog": { + "title": "Часто задаваемые вопросы (FAQ)", + "description": "Советы и решения для рабочей области Magic Resume.", + "items": { + "browser-compatibility": { + "question": "Какой браузер рекомендуется?", + "answer": "Рекомендуем последнюю версию Chrome или Edge для лучшего макета и экспорта. Устаревшие браузеры могут вызывать проблемы со стилями." + }, + "export-failure": { + "question": "Что делать, если экспорт не работает или стили сломаны?", + "answer": "1. Рекомендуем Google Chrome (https://www.google.com/chrome/) для лучшей совместимости.\n2. Если не помогает, попробуйте режим инкогнито, чтобы исключить влияние расширений.\n3. Или нажмите «PDF (резерв)» в меню экспорта и выберите «Сохранить как PDF» в диалоге печати." + }, + "export-methods": { + "question": "В чём разница между двумя способами экспорта?", + "answer": "• Экспорт PDF: использует серверный рендеринг с высокой точностью, 100% воспроизведение макета. Рекомендуется для отправки HR.\n\n• Печать в браузере: вызывает встроенную функцию печати браузера (Сохранить как PDF). Подходит, когда серверный экспорт недоступен или нужна тонкая настройка полей." + }, + "drag-and-drop": { + "question": "Как перетаскивать модули для изменения порядка?", + "answer": "В настройках «Макет» левой панели редактирования наведите курсор на «ручку перетаскивания» (иконка с шестью точками) слева от карточки модуля. Зажмите левую кнопку мыши и перетащите модуль вверх или вниз для изменения порядка разделов резюме." + } + } + } +} diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 282ac713..ec570ec9 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -13,7 +13,8 @@ "deleteSuccess": "删除成功", "deleteModuleConfirm": "确定要删除此模块吗?此操作无法撤销。", "configured": "已配置", - "notConfigured": "未配置" + "notConfigured": "未配置", + "notFound": "页面不存在" }, "home": { "header": { @@ -355,6 +356,10 @@ "flexibleHeaderLayout": { "title": "长标题模式" } + }, + "accessibility": { + "increase": "增加", + "decrease": "减少" } }, "basicPanel": { @@ -388,6 +393,16 @@ "fieldVisibility": { "show": "显示", "hide": "隐藏" + }, + "placeholders": { + "field": "请输入{label}", + "githubToken": "请输入 GitHub access token", + "githubUsername": "请输入 GitHub 用户名" + }, + "align": { + "left": "居左", + "center": "居中", + "right": "居右" } }, "experiencePanel": { @@ -456,7 +471,8 @@ "date": "项目时间", "description": "项目描述", "link": "项目链接", - "linkLabel": "显示文字" + "linkLabel": "显示文字", + "technologies": "技术栈" }, "placeholders": { "name": "请输入项目名称", @@ -464,7 +480,8 @@ "date": "项目时间范围", "description": "简要描述项目背景和目标", "link": "项目链接", - "linkLabel": "显示文字" + "linkLabel": "显示文字", + "technologies": "使用的技术和工具" }, "hints": { "linkLabel": "显示文字留空时,将自动显示域名或完整链接。链接仅支持 http:// 或 https:// 链接。" @@ -504,6 +521,7 @@ "date": "就读时间", "description": "学校简介", "gpa": "GPA", + "location": "地址", "startDate": "开始时间", "endDate": "结束时间" }, @@ -513,7 +531,8 @@ "major": "请输入专业名称", "date": "请输入就读时间范围", "description": "请输入学校简介", - "gpa": "请输入GPA" + "gpa": "请输入GPA", + "location": "请输入地址" }, "buttons": { "edit": "编辑", @@ -533,6 +552,38 @@ "width": "宽度 (横向拼接占比)", "delete": "删除", "empty": "暂无图片,请上传或粘贴图片" + }, + "skillsPanel": { + "placeholder": "描述你的技能、专长等..." + }, + "selfEvaluationPanel": { + "placeholder": "描述你的自我评价..." + }, + "customPanel": { + "add": "添加" + }, + "customItem": { + "title": "标题", + "titlePlaceholder": "标题", + "subtitle": "副标题", + "subtitlePlaceholder": "副标题", + "dateRange": "时间范围", + "dateRangePlaceholder": "例如: 2023.01 - 2024.01", + "description": "详细描述", + "descriptionPlaceholder": "请输入详细描述...", + "unnamedModule": "未命名模块" + }, + "fallbacks": { + "unnamedProject": "未命名项目", + "unnamedCompany": "家里蹲公司", + "emptySchool": "未填写学校", + "unnamedResume": "未命名简历" + }, + "editorHeader": { + "placeholder": "简历名称" + }, + "editPanel": { + "focusHint": "点击文字部分即可聚焦编辑" } }, "field": { diff --git a/src/i18n/messages.test.ts b/src/i18n/messages.test.ts new file mode 100644 index 00000000..0c9750f1 --- /dev/null +++ b/src/i18n/messages.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { getMessagesForLocale } from "./messages"; + +describe("getMessagesForLocale", () => { + it("returns Russian messages for ru locale", () => { + const messages = getMessagesForLocale("ru") as { + common: { title: string }; + }; + + expect(messages.common.title).toBeTruthy(); + expect(messages.common.title).not.toBe( + (getMessagesForLocale("zh") as { common: { title: string } }).common.title + ); + }); + + it("falls back to default locale for unknown locale", () => { + const fallback = getMessagesForLocale("zh"); + const unknown = getMessagesForLocale("xx" as "zh"); + + expect(unknown).toBe(fallback); + }); +}); diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts new file mode 100644 index 00000000..93da9e72 --- /dev/null +++ b/src/i18n/messages.ts @@ -0,0 +1,17 @@ +// Static imports for all locales. TODO: lazy-load per locale when locale count grows. +import { defaultLocale, type Locale } from "./config"; +import zhMessages from "./locales/zh.json"; +import enMessages from "./locales/en.json"; +import ruMessages from "./locales/ru.json"; + +type Messages = Record; + +export const messagesByLocale: Record = { + zh: zhMessages as Messages, + en: enMessages as Messages, + ru: ruMessages as Messages, +}; + +export function getMessagesForLocale(locale: Locale): Messages { + return messagesByLocale[locale] ?? messagesByLocale[defaultLocale]; +} diff --git a/src/i18n/runtime.test.ts b/src/i18n/runtime.test.ts new file mode 100644 index 00000000..27bc683d --- /dev/null +++ b/src/i18n/runtime.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + getCookieLocale, + getPreferredLocale, + isSupportedLocale, + LOCALE_COOKIE_NAME, + parseCookieLocale, + setCookieLocale, +} from "./runtime"; + +describe("isSupportedLocale", () => { + it("accepts supported locales", () => { + expect(isSupportedLocale("zh")).toBe(true); + expect(isSupportedLocale("en")).toBe(true); + expect(isSupportedLocale("ru")).toBe(true); + }); + + it("rejects unknown locales", () => { + expect(isSupportedLocale("de")).toBe(false); + expect(isSupportedLocale("")).toBe(false); + }); +}); + +describe("cookie locale helpers", () => { + beforeEach(() => { + document.cookie = `${LOCALE_COOKIE_NAME}=; path=/; max-age=0`; + }); + + it("reads valid cookie locale", () => { + document.cookie = `${LOCALE_COOKIE_NAME}=ru; path=/`; + + expect(parseCookieLocale(document.cookie)).toBe("ru"); + expect(getCookieLocale()).toBe("ru"); + }); + + it("ignores invalid cookie locale", () => { + document.cookie = `${LOCALE_COOKIE_NAME}=invalid; path=/`; + + expect(parseCookieLocale(document.cookie)).toBeNull(); + expect(getCookieLocale()).toBe("zh"); + }); + + it("writes cookie locale", () => { + setCookieLocale("en"); + + expect(getCookieLocale()).toBe("en"); + }); +}); + +describe("getPreferredLocale", () => { + beforeEach(() => { + document.cookie = `${LOCALE_COOKIE_NAME}=; path=/; max-age=0`; + }); + + it("prefers locale from pathname", () => { + expect(getPreferredLocale("/ru")).toBe("ru"); + expect(getPreferredLocale("/en/about")).toBe("en"); + }); + + it("uses cookie locale on app routes", () => { + document.cookie = `${LOCALE_COOKIE_NAME}=ru; path=/`; + + expect(getPreferredLocale("/app/dashboard")).toBe("ru"); + }); +}); diff --git a/src/i18n/runtime.ts b/src/i18n/runtime.ts index 7251f2df..1bdf4f0c 100644 --- a/src/i18n/runtime.ts +++ b/src/i18n/runtime.ts @@ -2,6 +2,9 @@ import { defaultLocale, locales, Locale } from "./config"; const localeSet = new Set(locales); +export const LOCALE_COOKIE_NAME = "NEXT_LOCALE"; +const LOCALE_COOKIE_MAX_AGE = 31536000; + export function isSupportedLocale(value: string): value is Locale { return localeSet.has(value as Locale); } @@ -14,6 +17,35 @@ export function getLocaleFromPathname(pathname: string): Locale | null { return isSupportedLocale(firstSegment) ? firstSegment : null; } +export function parseCookieLocale(cookieHeader?: string): Locale | null { + if (!cookieHeader) { + return null; + } + + const cookieLocale = cookieHeader + .split("; ") + .find((row) => row.startsWith(`${LOCALE_COOKIE_NAME}=`)) + ?.split("=")[1]; + + return cookieLocale && isSupportedLocale(cookieLocale) ? cookieLocale : null; +} + +export function getCookieLocale(): Locale { + if (typeof document === "undefined") { + return defaultLocale; + } + + return parseCookieLocale(document.cookie) ?? defaultLocale; +} + +export function setCookieLocale(locale: Locale): void { + if (typeof document === "undefined") { + return; + } + + document.cookie = `${LOCALE_COOKIE_NAME}=${locale}; path=/; max-age=${LOCALE_COOKIE_MAX_AGE}`; +} + export function getPreferredLocale(pathname: string): Locale { const localeFromPath = getLocaleFromPathname(pathname); if (localeFromPath) { @@ -21,12 +53,8 @@ export function getPreferredLocale(pathname: string): Locale { } if (typeof document !== "undefined") { - const cookieLocale = document.cookie - .split("; ") - .find((row) => row.startsWith("NEXT_LOCALE=")) - ?.split("=")[1]; - - if (cookieLocale && isSupportedLocale(cookieLocale)) { + const cookieLocale = parseCookieLocale(document.cookie); + if (cookieLocale) { return cookieLocale; } } diff --git a/src/lib/server/ai-prompts.ts b/src/lib/server/ai-prompts.ts new file mode 100644 index 00000000..aef23e6c --- /dev/null +++ b/src/lib/server/ai-prompts.ts @@ -0,0 +1,245 @@ +import { + defaultLocale, + importLanguages, + type Locale, +} from "@/i18n/config"; + +const RESUME_JSON_SCHEMA = `{ + "title": "Resume title", + "basic": { + "name": "", + "title": "", + "email": "", + "phone": "", + "location": "", + "employementStatus": "", + "birthDate": "" + }, + "education": [ + { + "school": "", + "major": "", + "degree": "", + "startDate": "", + "endDate": "", + "gpa": "", + "description": ["", ""] + } + ], + "experience": [ + { + "company": "", + "position": "", + "date": "", + "details": ["", ""] + } + ], + "projects": [ + { + "name": "", + "role": "", + "date": "", + "description": ["", ""], + "link": "", + "linkLabel": "" + } + ], + "skills": ["", ""] +}`; + +export function resolveApiLocale(locale?: string): Locale { + if (locale && locale in importLanguages) { + return locale as Locale; + } + return defaultLocale; +} + +export function buildResumeImportPrompt(locale: Locale): string { + const language = importLanguages[locale]; + + return `You are a professional resume structuring assistant. Extract information from the user's resume content and output exactly one valid JSON object. + +Output constraints: +1. Output JSON only. No Markdown, no explanations. +2. If a field is uncertain, use an empty string or empty array. +3. Write all text content in ${language}. +4. description/details fields must be string arrays; each item is one readable sentence. + +JSON schema: +${RESUME_JSON_SCHEMA}`; +} + +export function buildResumeImportUserPrompt( + locale: Locale, + hasContent: boolean +): string { + if (hasContent) { + return ""; + } + + const prompts: Record = { + zh: "请识别以下简历页面图片中的信息,并严格按 JSON 结构输出。", + en: "Extract information from the resume page images below and output strictly according to the JSON schema.", + ru: "Извлеките информацию со страниц резюме ниже и выведите строго в соответствии со схемой JSON.", + }; + + return prompts[locale]; +} + +const GRAMMAR_PROMPTS: Record = { + zh: `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。 + +**严格禁止**: +1. ❌ **禁止**提供任何风格、语气、润色或改写建议。如果句子在语法上是正确的(即使读起来不够优美),也**绝对不要**报错。 +2. ❌ **禁止**报告“无明显错误”或类似的信息。如果没有发现错别字或标点错误,"errors" 数组必须为空。 +3. ❌ **禁止**对专业术语进行过度纠正,除非通过上下文非常确定是打字错误。 + +**仅检查以下两类错误**: +1. ✅ **错别字**:例如将“作为”写成“做为”,将“经理”写成“经里”。 +2. ✅ **严重标点错误**:仅报告重复标点(如“,,”)或完全错误的符号位置。 + +**重要例外(绝不报错)**: +- ❌ **忽略中英文标点混用**:在技术简历中,中文内容使用英文标点是可接受的。**绝对不要**报告此类“错误”。 +- ❌ **忽略空格使用**:不要报告中英文之间的空格遗漏或多余。 + +返回格式(JSON): +{ + "errors": [ + { + "context": "包含错误的完整句子(必须是原文)", + "text": "具体的错误部分(必须是原文中实际存在的字符串)", + "suggestion": "仅包含修正后的词汇或片段", + "type": "spelling" + } + ] +} + +"type" must be either "spelling" or "punctuation". Do not include a free-form "reason" field.`, + + en: `You are a professional English resume proofreading assistant. Find **only** spelling mistakes and serious punctuation errors. + +**Strictly forbidden**: +1. Do not suggest style, tone, polish, or rewrites. If a sentence is grammatically correct, do not report it. +2. If no spelling or punctuation errors are found, return an empty "errors" array. +3. Do not over-correct technical terms unless clearly a typo. + +**Check only**: +1. Spelling mistakes (typos). +2. Serious punctuation errors (duplicate punctuation, clearly wrong symbol placement). + +**Ignore**: +- Mixed punctuation styles common in tech resumes. +- Optional spacing around punctuation. + +Return JSON: +{ + "errors": [ + { + "context": "Full sentence containing the error (exact original text)", + "text": "The erroneous substring (must exist in the original)", + "suggestion": "Corrected word or fragment only", + "type": "spelling" + } + ] +} + +"type" must be either "spelling" or "punctuation". Do not include a free-form "reason" field.`, + + ru: `Вы — профессиональный корректор русскоязычных резюме. Находите **только** орфографические ошибки и серьёзные пунктуационные ошибки. + +**Строго запрещено**: +1. Не предлагайте стилистические правки, перефразирование или улучшение формулировок. +2. Если ошибок нет, верните пустой массив "errors". +3. Не исправляйте профессиональную терминологию без явной опечатки. + +**Проверяйте только**: +1. Орфографические ошибки (опечатки). +2. Серьёзные пунктуационные ошибки (дублирование знаков, явно неверное расположение). + +**Игнорируйте**: +- Смешанную пунктуацию, типичную для IT-резюме. +- Пробелы вокруг знаков препинания. + +Формат ответа (JSON): +{ + "errors": [ + { + "context": "Полное предложение с ошибкой (точный оригинальный текст)", + "text": "Ошибочный фрагмент (должен существовать в оригинале)", + "suggestion": "Только исправленное слово или фрагмент", + "type": "spelling" + } + ] +} + +"type" must be either "spelling" or "punctuation". Do not include a free-form "reason" field.`, +}; + +export function buildGrammarPrompt(locale: Locale): string { + return GRAMMAR_PROMPTS[locale]; +} + +const POLISH_PROMPTS: Record = { + zh: `你是一个专业的简历优化助手。请帮助优化以下 Markdown 格式的文本,使其更加专业和有吸引力。 + +优化原则: +1. 使用更专业的词汇和表达方式 +2. 突出关键成就和技能 +3. 保持简洁清晰 +4. 使用主动语气 +5. 保持原有信息的完整性 +6. 严格保留原有的 Markdown 格式结构 + +输出强约束(必须遵守): +1. 只能输出“润色后的正文内容”本身。 +2. 禁止输出任何前言、说明、总结、附加建议。 +3. 禁止使用 Markdown 代码块包裹结果。`, + + en: `You are a professional resume optimization assistant. Polish the following Markdown text to make it more professional and compelling. + +Principles: +1. Use professional vocabulary and phrasing +2. Highlight key achievements and skills +3. Keep it concise and clear +4. Use active voice +5. Preserve all original information +6. Strictly preserve the original Markdown structure + +Output constraints: +1. Output only the polished body text. +2. No preface, explanation, summary, or extra suggestions. +3. Do not wrap the result in Markdown code blocks.`, + + ru: `Вы — профессиональный ассистент по оптимизации резюме. Отполируйте следующий Markdown-текст, сделав его более профессиональным и убедительным. + +Принципы: +1. Используйте профессиональную лексику и формулировки +2. Выделяйте ключевые достижения и навыки +3. Будьте лаконичны и ясны +4. Используйте активный залог +5. Сохраняйте всю исходную информацию +6. Строго сохраняйте исходную структуру Markdown + +Ограничения вывода: +1. Выводите только отполированный текст. +2. Без вступлений, пояснений, резюме и дополнительных советов. +3. Не оборачивайте результат в блоки Markdown-кода.`, +}; + +export function buildPolishPrompt( + locale: Locale, + customInstructions?: string +): string { + let prompt = POLISH_PROMPTS[locale]; + + if (customInstructions?.trim()) { + const labels: Record = { + zh: "用户额外要求", + en: "Additional user instructions", + ru: "Дополнительные требования пользователя", + }; + prompt += `\n\n${labels[locale]}:\n${customInstructions.trim()}`; + } + + return prompt; +} diff --git a/src/lib/templatePreview.ts b/src/lib/templatePreview.ts index d41bf92f..b37e2dc3 100644 --- a/src/lib/templatePreview.ts +++ b/src/lib/templatePreview.ts @@ -1,8 +1,6 @@ import { DEFAULT_TEMPLATES } from "@/config"; -import { - initialResumeState, - initialResumeStateEn, -} from "@/config/initialResumeData"; +import { getInitialResumeStateForLocale } from "@/config/localeResumeData"; +import { locales, type Locale } from "@/i18n/config"; import type { ResumeData } from "@/types/resume"; import type { ResumeTemplate } from "@/types/template"; @@ -12,9 +10,9 @@ export const TEMPLATE_SNAPSHOT_VERSION = 1; export const TEMPLATE_SNAPSHOT_ROOT_ATTRIBUTE = "data-template-snapshot-root"; export const TEMPLATE_SNAPSHOT_ROOT_SELECTOR = `[${TEMPLATE_SNAPSHOT_ROOT_ATTRIBUTE}]`; export const TEMPLATE_SNAPSHOT_PUBLIC_DIR = "template-snapshots"; -export const TEMPLATE_PREVIEW_LOCALES = ["zh", "en"] as const; +export const TEMPLATE_PREVIEW_LOCALES = locales; -export type TemplatePreviewLocale = (typeof TEMPLATE_PREVIEW_LOCALES)[number]; +export type TemplatePreviewLocale = Locale; export interface TemplateSnapshotManifest { version: number; @@ -29,20 +27,23 @@ export const createEmptyTemplateSnapshotManifest = locales: { zh: {}, en: {}, + ru: {}, }, }); export const isTemplatePreviewLocale = ( value: string | null | undefined ): value is TemplatePreviewLocale => - value === "zh" || value === "en"; + value !== null && + value !== undefined && + (locales as readonly string[]).includes(value); export const getTemplateById = (templateId: string | undefined): ResumeTemplate => DEFAULT_TEMPLATES.find((template) => template.id === templateId) ?? DEFAULT_TEMPLATES[0]; export const getTemplatePreviewBaseData = (locale: TemplatePreviewLocale) => - locale === "en" ? initialResumeStateEn : initialResumeState; + getInitialResumeStateForLocale(locale); export const createTemplatePreviewData = ( template: ResumeTemplate, diff --git a/src/lib/templates.ts b/src/lib/templates.ts new file mode 100644 index 00000000..f043778f --- /dev/null +++ b/src/lib/templates.ts @@ -0,0 +1,3 @@ +export function getTemplateKey(templateId: string): string { + return templateId === "left-right" ? "leftRight" : templateId; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 58041e54..9b8690ff 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,7 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { defaultLocale, heroUiLocales, type Locale } from "@/i18n/config"; +import { isSupportedLocale } from "@/i18n/runtime"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -35,7 +37,7 @@ function parseToDate(dateStr: string): Date | null { return null; } -export function formatDateString(dateStr: string | undefined, locale: string = "zh"): string { +export function formatDateString(dateStr: string | undefined, locale: string = defaultLocale): string { if (!dateStr) return ""; if (dateStr.includes(DATE_RANGE_SEPARATOR)) { @@ -50,7 +52,13 @@ export function formatDateString(dateStr: string | undefined, locale: string = " if (locale === "zh" || locale === "zh-CN") { return `${date.getUTCFullYear()}/${String(date.getUTCMonth() + 1).padStart(2, "0")}`; } - const formatter = new Intl.DateTimeFormat(locale, { + const intlLocale = + locale in heroUiLocales + ? heroUiLocales[locale as Locale] + : isSupportedLocale(locale) + ? heroUiLocales[locale] + : locale; + const formatter = new Intl.DateTimeFormat(intlLocale, { year: 'numeric', month: '2-digit', timeZone: 'UTC' @@ -64,7 +72,7 @@ export function formatDateString(dateStr: string | undefined, locale: string = " export function formatDateRange( startDate: string | undefined, endDate: string | undefined, - locale: string = "zh" + locale: string = defaultLocale ): string { const start = formatDateString(startDate, locale).trim(); const end = formatDateString(endDate, locale).trim(); diff --git a/src/routes/$locale.tsx b/src/routes/$locale.tsx index 5697d10d..d810daf3 100644 --- a/src/routes/$locale.tsx +++ b/src/routes/$locale.tsx @@ -1,8 +1,7 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; import LandingPage from "@/app/(public)/[locale]/page"; -import { defaultLocale, locales, type Locale } from "@/i18n/config"; -import zhMessages from "@/i18n/locales/zh.json"; -import enMessages from "@/i18n/locales/en.json"; +import { defaultLocale, localeTags, locales, type Locale } from "@/i18n/config"; +import { getMessagesForLocale } from "@/i18n/messages"; const SEO_BASE_URL = "https://magicv.art"; @@ -14,19 +13,21 @@ function resolveLocale(rawLocale: string): Locale { } function getLocaleSeo(locale: Locale) { - const messages = locale === "en" ? enMessages : zhMessages; + const messages = getMessagesForLocale(locale) as { + common: { title: string; subtitle: string; description: string }; + }; const title = `${messages.common.title} - ${messages.common.subtitle}`; const description = messages.common.description; - const localeTag = locale === "en" ? "en_US" : "zh_CN"; + const localeTag = localeTags[locale]; const canonical = `${SEO_BASE_URL}/${locale}`; - const alternateLocale = locale === "en" ? "zh" : "en"; + const alternateLocales = locales.filter((loc) => loc !== locale); return { title, description, localeTag, canonical, - alternateLocale + alternateLocales }; } @@ -45,6 +46,10 @@ export const Route = createFileRoute("/$locale")({ { property: "og:title", content: seo.title }, { property: "og:description", content: seo.description }, { property: "og:locale", content: seo.localeTag }, + ...seo.alternateLocales.map((loc) => ({ + property: "og:locale:alternate", + content: localeTags[loc], + })), { property: "og:url", content: seo.canonical }, { property: "og:image", content: `${SEO_BASE_URL}/web-shot.png` }, { name: "twitter:card", content: "summary_large_image" }, @@ -54,12 +59,11 @@ export const Route = createFileRoute("/$locale")({ ], links: [ { rel: "canonical", href: seo.canonical }, - { rel: "alternate", hrefLang: locale, href: seo.canonical }, - { - rel: "alternate", - hrefLang: seo.alternateLocale, - href: `${SEO_BASE_URL}/${seo.alternateLocale}` - }, + ...locales.map((loc) => ({ + rel: "alternate" as const, + hrefLang: loc, + href: `${SEO_BASE_URL}/${loc}` + })), { rel: "alternate", hrefLang: "x-default", href: `${SEO_BASE_URL}/zh` } ] }; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 84a53b49..408c3481 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -3,18 +3,17 @@ import { HeadContent, Outlet, Scripts, - useLocation } from "@tanstack/react-router"; import appCss from "../app/globals.css?url"; import appFontCss from "../app/font.css?url"; import tiptapCss from "../styles/tiptap.scss?url"; import { NextIntlClientProvider } from "@/i18n/compat/client"; -import { useEffect } from "react"; -import zhMessages from "@/i18n/locales/zh.json"; -import enMessages from "@/i18n/locales/en.json"; +import { getMessagesForLocale } from "@/i18n/messages"; import { Providers } from "@/app/providers"; import { Toaster } from "@/components/ui/sonner"; -import { getPreferredLocale } from "@/i18n/runtime"; +import { heroUiLocales } from "@/i18n/config"; +import { LocaleProvider, useAppLocale } from "@/i18n/locale-context"; +import { useTranslations } from "@/i18n/compat/client"; export const Route = createRootRoute({ head: () => ({ @@ -22,42 +21,35 @@ export const Route = createRootRoute({ { charSet: "utf-8" }, { name: "viewport", - content: "width=device-width, initial-scale=1" + content: "width=device-width, initial-scale=1", }, - { title: "Magic Resume" } + { title: "Magic Resume" }, ], links: [ { rel: "stylesheet", - href: appCss + href: appCss, }, { rel: "stylesheet", - href: appFontCss + href: appFontCss, }, { rel: "stylesheet", - href: tiptapCss - } - ] + href: tiptapCss, + }, + ], }), component: RootComponent, - notFoundComponent: RootNotFound + notFoundComponent: RootNotFound, }); -function RootComponent() { - const pathname = useLocation({ - select: (location) => location.pathname - }); - const locale = getPreferredLocale(pathname); - const messages = locale === "en" ? enMessages : zhMessages; - - useEffect(() => { - document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000`; - }, [locale]); +function AppShell({ children }: { children: React.ReactNode }) { + const locale = useAppLocale(); + const messages = getMessagesForLocale(locale); return ( - + @@ -70,7 +62,7 @@ function RootComponent() { timeZone="Asia/Shanghai" > - + {children} @@ -80,10 +72,32 @@ function RootComponent() { ); } -function RootNotFound() { +function RootComponent() { + return ( + + + + + + ); +} + +function NotFoundContent() { + const t = useTranslations("common"); + return (
-

页面不存在

+

{t("notFound")}

); } + +function RootNotFound() { + return ( + + + + + + ); +} diff --git a/src/routes/api/grammar.ts b/src/routes/api/grammar.ts index 7dd968d1..2372977a 100644 --- a/src/routes/api/grammar.ts +++ b/src/routes/api/grammar.ts @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai"; import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini"; +import { buildGrammarPrompt, resolveApiLocale } from "@/lib/server/ai-prompts"; const parseUpstreamError = (raw: string, fallback: string) => { if (!raw) return { message: fallback }; @@ -24,12 +25,13 @@ export const Route = createFileRoute("/api/grammar")({ POST: async ({ request }) => { try { const body = await request.json(); - const { apiKey, model, content, modelType, apiEndpoint } = body as { + const { apiKey, model, content, modelType, apiEndpoint, locale } = body as { apiKey: string; model: string; content: string; modelType: AIModelType; apiEndpoint?: string; + locale?: string; }; const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType]; @@ -37,35 +39,8 @@ export const Route = createFileRoute("/api/grammar")({ throw new Error("Invalid model type"); } - const systemPrompt = `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。 - - **严格禁止**: - 1. ❌ **禁止**提供任何风格、语气、润色或改写建议。如果句子在语法上是正确的(即使读起来不够优美),也**绝对不要**报错。 - 2. ❌ **禁止**报告“无明显错误”或类似的信息。如果没有发现错别字或标点错误,"errors" 数组必须为空。 - 3. ❌ **禁止**对专业术语进行过度纠正,除非通过上下文非常确定是打字错误。 - - **仅检查以下两类错误**: - 1. ✅ **错别字**:例如将“作为”写成“做为”,将“经理”写成“经里”。 - 2. ✅ **严重标点错误**:仅报告重复标点(如“,,”)或完全错误的符号位置。 - - **重要例外(绝不报错)**: - - ❌ **忽略中英文标点混用**:在技术简历中,中文内容使用英文标点(如使用英文逗号, 代替中文逗号,或使用英文句点. 代替中文句号)是**完全接受**的风格。**绝对不要**报告此类“错误”。 - - ❌ **忽略空格使用**:不要报告中英文之间的空格遗漏或多余。 - - 返回格式示例(JSON): - { - "errors": [ - { - "context": "包含错误的完整句子(必须是原文)", - "text": "具体的错误部分(必须是原文中实际存在的字符串)", - "suggestion": "仅包含修正后的词汇或片段(**不要**返回整句,除非整句都是错误的)", - "reason": "错别字 / 标点错误", - "type": "spelling" - } - ] - } - - 再次强调:**只找错别字和标点错误,不要做任何润色!**`; + const resolvedLocale = resolveApiLocale(locale); + const systemPrompt = buildGrammarPrompt(resolvedLocale); if (modelType === "gemini") { const geminiModel = model || "gemini-flash-latest"; diff --git a/src/routes/api/polish.ts b/src/routes/api/polish.ts index e25b98cf..25a4c0aa 100644 --- a/src/routes/api/polish.ts +++ b/src/routes/api/polish.ts @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai"; import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini"; +import { buildPolishPrompt, resolveApiLocale } from "@/lib/server/ai-prompts"; const parseUpstreamError = (raw: string, fallback: string) => { if (!raw) return { message: fallback }; @@ -24,13 +25,14 @@ export const Route = createFileRoute("/api/polish")({ POST: async ({ request }) => { try { const body = await request.json(); - const { apiKey, model, content, modelType, apiEndpoint, customInstructions } = body as { + const { apiKey, model, content, modelType, apiEndpoint, customInstructions, locale } = body as { apiKey: string; model: string; content: string; modelType: AIModelType; apiEndpoint?: string; customInstructions?: string; + locale?: string; }; const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType]; @@ -38,27 +40,8 @@ export const Route = createFileRoute("/api/polish")({ throw new Error("Invalid model type"); } - let systemPrompt = `你是一个专业的简历优化助手。请帮助优化以下 Markdown 格式的文本,使其更加专业和有吸引力。 - - 优化原则: - 1. 使用更专业的词汇和表达方式 - 2. 突出关键成就和技能 - 3. 保持简洁清晰 - 4. 使用主动语气 - 5. 保持原有信息的完整性 - 6. 严格保留原有的 Markdown 格式结构(列表项保持为列表项,加粗保持加粗等) - - 输出强约束(必须遵守): - 1. 只能输出“润色后的正文内容”本身。 - 2. 禁止输出任何前言、说明、总结、附加建议。 - 3. 禁止出现这类引导语:如“以下是...”“根据您提供...”“这是...”“特点:”“说明:”“总结:”等。 - 4. 禁止新增与原文无关的章节标题或收尾段落。 - 5. 不要使用 Markdown 代码块(\`\`\`)包裹结果。 - 6. 若你产生了解释性内容,必须在输出前自检并删除,只保留最终正文。`; - - if (customInstructions?.trim()) { - systemPrompt += `\n\n用户额外要求:\n${customInstructions.trim()}`; - } + const resolvedLocale = resolveApiLocale(locale); + const systemPrompt = buildPolishPrompt(resolvedLocale, customInstructions); if (modelType === "gemini") { const geminiModel = model || "gemini-flash-latest"; diff --git a/src/routes/api/resume-import.ts b/src/routes/api/resume-import.ts index 343863d0..e9e99494 100644 --- a/src/routes/api/resume-import.ts +++ b/src/routes/api/resume-import.ts @@ -1,5 +1,12 @@ import { createFileRoute } from "@tanstack/react-router"; +import type { Locale } from "@/i18n/config"; +import { defaultLocale } from "@/i18n/config"; import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini"; +import { + buildResumeImportPrompt, + buildResumeImportUserPrompt, + resolveApiLocale, +} from "@/lib/server/ai-prompts"; const parseJsonPayload = (content: string) => { const text = content.trim(); @@ -60,7 +67,7 @@ export const Route = createFileRoute("/api/resume-import")({ ); } - const language = locale === "en" ? "English" : "Chinese"; + const resolvedLocale = resolveApiLocale(locale); const geminiModel = model || "gemini-flash-latest"; const imageParts = Array.isArray(images) ? images.map((image) => { @@ -76,68 +83,20 @@ export const Route = createFileRoute("/api/resume-import")({ const modelInstance = getGeminiModelInstance({ apiKey, model: geminiModel, - systemInstruction: `你是一个专业的简历结构化助手。根据用户提供的简历内容,提取信息并只输出一个合法 JSON 对象。 - -输出约束: -1. 只允许输出 JSON,不要输出 Markdown,不要输出解释。 -2. 如果某个字段不确定,使用空字符串或空数组。 -3. 请使用 ${language} 输出内容文本。 -4. description/details 字段输出字符串数组,每一项为一句可读内容。 - -JSON 结构: -{ - "title": "简历标题", - "basic": { - "name": "", - "title": "", - "email": "", - "phone": "", - "location": "", - "employementStatus": "", - "birthDate": "" - }, - "education": [ - { - "school": "", - "major": "", - "degree": "", - "startDate": "", - "endDate": "", - "gpa": "", - "description": ["", ""] - } - ], - "experience": [ - { - "company": "", - "position": "", - "date": "", - "details": ["", ""] - } - ], - "projects": [ - { - "name": "", - "role": "", - "date": "", - "description": ["", ""], - "link": "", - "linkLabel": "" - } - ], - "skills": ["", ""] -}`, + systemInstruction: buildResumeImportPrompt(resolvedLocale), generationConfig: { temperature: 0.2, responseMimeType: "application/json", }, }); + const userPrompt = + content || + buildResumeImportUserPrompt(resolvedLocale, Boolean(content)); + const inputParts = [ { - text: - content || - "请识别以下简历页面图片中的信息,并严格按 JSON 结构输出。", + text: userPrompt, }, ...imageParts, ]; diff --git a/src/store/useGrammarStore.ts b/src/store/useGrammarStore.ts index 39ce6150..28d61f0b 100644 --- a/src/store/useGrammarStore.ts +++ b/src/store/useGrammarStore.ts @@ -4,13 +4,13 @@ import Mark from "mark.js"; import { useAIConfigStore } from "@/store/useAIConfigStore"; import { AI_MODEL_CONFIGS } from "@/config/ai"; import { cn } from "@/lib/utils"; +import { defaultLocale, type Locale } from "@/i18n/config"; export interface GrammarError { context: string; text: string; suggestion: string; - reason: string; - type: "spelling" | "grammar"; + type: "spelling" | "punctuation"; } interface GrammarStore { @@ -22,12 +22,35 @@ interface GrammarStore { setIsChecking: (isChecking: boolean) => void; setSelectedErrorIndex: (index: number | null) => void; incrementHighlightKey: () => void; - checkGrammar: (text: string) => Promise; + checkGrammar: (text: string, locale?: Locale) => Promise; clearErrors: () => void; selectError: (index: number) => void; dismissError: (index: number) => void; } +const normalizeGrammarError = (error: { + context?: string; + text?: string; + suggestion?: string; + type?: string; + reason?: string; +}): GrammarError | null => { + if (!error.text?.trim() || !error.context?.trim()) { + return null; + } + + const rawType = error.type?.toLowerCase() ?? ""; + const type: GrammarError["type"] = + rawType === "punctuation" || rawType === "grammar" ? "punctuation" : "spelling"; + + return { + context: error.context, + text: error.text, + suggestion: error.suggestion ?? "", + type, + }; +}; + const markSingleError = ( marker: Mark, error: GrammarError, @@ -89,7 +112,7 @@ export const useGrammarStore = create((set, get) => ({ incrementHighlightKey: () => set((state) => ({ highlightKey: state.highlightKey + 1 })), - checkGrammar: async (text: string) => { + checkGrammar: async (text: string, locale: Locale = defaultLocale) => { const { selectedModel, doubaoApiKey, @@ -135,6 +158,7 @@ export const useGrammarStore = create((set, get) => ({ model: config.requiresModelId ? modelId : config.defaultModel, modelType: selectedModel, apiEndpoint: selectedModel === "openai" ? openaiApiEndpoint : undefined, + locale, }), }); @@ -158,19 +182,22 @@ export const useGrammarStore = create((set, get) => ({ try { const grammarErrors = JSON.parse(aiResponse); - if (grammarErrors.errors.length === 0) { + const normalizedErrors = (grammarErrors.errors ?? []) + .map(normalizeGrammarError) + .filter(Boolean) as GrammarError[]; + + if (normalizedErrors.length === 0) { set({ errors: [] }); toast.success("无语法错误"); return; } - set({ errors: grammarErrors.errors }); + set({ errors: normalizedErrors }); const preview = document.getElementById("resume-preview"); if (preview) { const marker = new Mark(preview); marker.unmark(); - grammarErrors.errors.forEach((error: GrammarError) => { - // 仅标注错误片段,避免整句/全局模糊匹配造成误高亮 + normalizedErrors.forEach((error: GrammarError) => { markSingleError(marker, error); }); } diff --git a/src/store/useResumeStore.ts b/src/store/useResumeStore.ts index 999ffd07..f1e43faf 100644 --- a/src/store/useResumeStore.ts +++ b/src/store/useResumeStore.ts @@ -14,11 +14,12 @@ import { } from "../types/resume"; import { DEFAULT_TEMPLATES } from "@/config"; import { - initialResumeState, - initialResumeStateEn, - blankResumeState, - blankResumeStateEn, -} from "@/config/initialResumeData"; + getBlankResumeStateForLocale, + getCookieLocale, + getInitialResumeStateForLocale, + getLocalizedCommonLabel, +} from "@/config/localeResumeData"; +import type { Locale } from "@/i18n/config"; import { generateUUID } from "@/utils/uuid"; interface ResumeStore { resumes: Record; @@ -208,22 +209,11 @@ export const useResumeStore = create( activeResume: null, createResume: (templateId = null, isBlank = false) => { - const locale = - typeof document !== "undefined" - ? document.cookie - .split("; ") - .find((row) => row.startsWith("NEXT_LOCALE=")) - ?.split("=")[1] || "zh" - : "zh"; - - let initialResumeData: any; - if (isBlank) { - initialResumeData = - locale === "en" ? blankResumeStateEn : blankResumeState; - } else { - initialResumeData = - locale === "en" ? initialResumeStateEn : initialResumeState; - } + const locale = getCookieLocale(); + + const initialResumeData = isBlank + ? getBlankResumeStateForLocale(locale) + : getInitialResumeStateForLocale(locale); const id = generateUUID(); const template = templateId @@ -236,7 +226,7 @@ export const useResumeStore = create( createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), templateId: template?.id, - title: `${locale === "en" ? "New Resume" : "新建简历"} ${id.slice( + title: `${getLocalizedCommonLabel(locale, "newResume")} ${id.slice( 0, 6 )}`, @@ -348,21 +338,15 @@ export const useResumeStore = create( return ""; } - // 获取当前语言环境 - const locale = - typeof document !== "undefined" - ? document.cookie - .split("; ") - .find((row) => row.startsWith("NEXT_LOCALE=")) - ?.split("=")[1] || "zh" - : "zh"; + const locale = getCookieLocale(); const duplicatedResume = { ...structuredClone(originalResume), id: newId, - title: `${originalResume.title} (${ - locale === "en" ? "Copy" : "复制" - })`, + title: `${originalResume.title} (${getLocalizedCommonLabel( + locale as Locale, + "copy" + )})`, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 00000000..f149f27a --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/vite.config.ts b/vite.config.ts index 2aae8add..27504fa9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from "vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; @@ -22,5 +23,10 @@ export default defineConfig({ } }), viteReact() - ] + ], + test: { + environment: "jsdom", + include: ["src/**/*.test.{ts,tsx}"], + setupFiles: ["src/test/setup.ts"], + }, });