diff --git a/docs/deployment/self-hosting.md b/docs/deployment/self-hosting.md index 2522bad6..3b0f2129 100644 --- a/docs/deployment/self-hosting.md +++ b/docs/deployment/self-hosting.md @@ -14,6 +14,8 @@ byteflow.tools is a static Next.js export for privacy-first browser-local develo - Container wrapper: use a minimal static server only for files; do not add an API that processes tool payloads. - Local automation prototype: `scripts/prototypes/byteflow-local-cli.mjs` can be evaluated on a workstation or CI runner for stdin/stdout transformations. Keep it local to the runner; do not expose it as a hosted payload-processing endpoint. +The export uses extensionless public routes backed by `.html` files. Configure the host's clean-URL or HTML fallback support so `/en/json-formatter` resolves to `/en/json-formatter.html`. `npm start` provides a production-like local static server with that behavior after `npm run build`; a file server that only performs literal path lookup is not compatible. + ## Privacy-Safe Analytics Analytics should be disabled unless the deployment owner has reviewed the allowlisted taxonomy. If enabled, only aggregate event names and safe identifiers such as tool ID, category, language, size bucket, or result count are allowed. Raw search text, tool input, output, file names, file contents, tokens, logs, full URLs, request bodies, and response bodies are forbidden. diff --git a/docs/security/next-postcss-advisory-runbook.md b/docs/security/next-postcss-advisory-runbook.md index 511c27dd..87565286 100644 --- a/docs/security/next-postcss-advisory-runbook.md +++ b/docs/security/next-postcss-advisory-runbook.md @@ -1,19 +1,22 @@ # Next Bundled PostCSS Advisory Runbook -This runbook records the remediation for issue #7: GHSA-qx2v-qp2m-jg93 through -Next.js bundled `postcss`. +This runbook records the remediation for issue #7 and the later PostCSS source-map +advisories GHSA-6g55-p6wh-862q and GHSA-r28c-9q8g-f849 through Next.js bundled +`postcss`. ## Stable Remediation -Checked on 2026-07-16: +Checked on 2026-07-26: -- `npm view next version`: `16.2.10` -- `npm view @next/bundle-analyzer version`: `16.2.10` +- `npm view next version`: `16.2.12` +- `npm view @next/bundle-analyzer version`: `16.2.12` - Stable Next still declares vulnerable `postcss@8.4.31`. - The project keeps Next, `@next/bundle-analyzer`, and `eslint-config-next` aligned at - stable `16.2.10`. + stable `16.2.12`. - A controlled npm override replaces only Next's internal PostCSS copy with - `postcss@8.5.10`, the first patched version already used by Next 16.3 canaries. + `postcss@8.5.23`, which is newer than the fixed `8.5.18` floor for the current advisories. +- A second controlled override raises Next's optional Sharp dependency to `0.35.3`, + above the `0.35.0` floor for the inherited libvips advisories. This avoids the unsafe framework downgrade proposed by `npm audit fix --force`, does not suppress the advisory, and keeps the application on a stable Next release. @@ -22,20 +25,24 @@ After `npm ci`, verify the effective dependency and production audit: ```bash node -p "require('./node_modules/next/package.json').version" -node -p "require('./node_modules/next/node_modules/postcss/package.json').version" +node -p "require('./node_modules/postcss/package.json').version" +node -p "require('./node_modules/sharp/package.json').version" npm audit --omit=dev --json ``` Expected results: -- Next reports `16.2.10`. -- Next's effective PostCSS reports `8.5.10`, satisfying `postcss >= 8.5.10`. +- Next reports `16.2.12`. +- Next's effective PostCSS reports `8.5.23`, satisfying `postcss >= 8.5.18`. +- Sharp reports `0.35.3`. - The production audit reports 0 moderate/high/critical vulnerabilities and no `next -> postcss` advisory path. ## Upstream Evidence - Advisory: https://github.com/advisories/GHSA-qx2v-qp2m-jg93 +- Advisory: https://github.com/advisories/GHSA-6g55-p6wh-862q +- Advisory: https://github.com/advisories/GHSA-r28c-9q8g-f849 - Next PostCSS 8.5.10 bump: https://github.com/vercel/next.js/pull/93288 - Next applicability discussion: https://github.com/vercel/next.js/issues/93234 @@ -63,7 +70,7 @@ npm run test:e2e:pwa - Do not merge Next canary into `main` without an explicit project decision. - Do not suppress the advisory without upstream evidence. - Do not loosen production audit gates. -- Do not remove the override until stable Next resolves PostCSS to `8.5.10` or newer +- Do not remove the override until stable Next resolves PostCSS to `8.5.18` or newer without it. ## Closure Comment Template diff --git a/package-lock.json b/package-lock.json index 38b9a6e2..a7620015 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,15 +18,15 @@ "cronstrue": "^3.12.0", "crypto-js": "^4.2.0", "csso": "^5.0.5", - "dompurify": "3.4.11", + "dompurify": "3.4.12", "jq-wasm": "^1.1.0-jq-1.8.1", - "js-beautify": "^1.15.4", + "js-beautify": "2.0.3", "jsbarcode": "^3.12.3", "jsqr": "^1.4.0", "jwt-decode": "^4.0.0", "lorem-ipsum": "^2.0.8", "lucide-react": "^0.575.0", - "next": "16.2.10", + "next": "16.2.12", "pdf-lib": "^1.17.1", "qrcode": "^1.5.4", "radix-ui": "^1.4.3", @@ -50,7 +50,7 @@ "yaml": "^2.8.2" }, "devDependencies": { - "@next/bundle-analyzer": "16.2.10", + "@next/bundle-analyzer": "16.2.12", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -69,7 +69,7 @@ "@vitest/coverage-v8": "^4.1.8", "axe-core": "^4.11.1", "eslint": "^9", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.12", "jsdom": "^28.1.0", "playwright": "^1.58.2", "serve-handler": "^6.1.7", @@ -199,6 +199,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -571,6 +572,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -619,6 +621,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -646,9 +649,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -1361,9 +1364,9 @@ } }, "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "optional": true, "engines": { @@ -1371,9 +1374,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -1383,19 +1386,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -1405,19 +1408,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -1431,9 +1453,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -1447,9 +1469,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -1463,9 +1485,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -1479,9 +1501,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -1495,9 +1517,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -1511,9 +1533,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -1527,9 +1549,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -1543,9 +1565,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -1559,9 +1581,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -1575,9 +1597,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -1587,19 +1609,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -1609,19 +1631,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -1631,19 +1653,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -1653,19 +1675,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -1675,19 +1697,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -1697,19 +1719,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -1719,19 +1741,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -1741,38 +1763,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", "cpu": [ "wasm32" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@img/sharp-wasm32": "0.35.3" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -1782,16 +1820,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -1801,16 +1839,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -1820,75 +1858,12 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1988,9 +1963,9 @@ } }, "node_modules/@next/bundle-analyzer": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.10.tgz", - "integrity": "sha512-KcepWhb3IVniZgm00GSSCQDEUQqZXuXtuXRh8J6e3Un342TcQ77iK4DedeEkct+fcx7yFEDL2J6z4Jeho5JDAw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.12.tgz", + "integrity": "sha512-0dYhmCYsTMFYUFaoEUx+VKw/oYP6b3XiGgq47EujXTE2UGgwVWAaur2tbko2pxfUka3WRZ9YWxa3PlSv3luWNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1998,15 +1973,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", - "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz", + "integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz", - "integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.12.tgz", + "integrity": "sha512-uF2z/qAK2q7B5/6CpnFcBRX6jOq5iCO+Uqh1UkJhXljX1JwLarLYhhoJadO6dPb6moTprOKewMXheBcbIoSbug==", "dev": true, "license": "MIT", "dependencies": { @@ -2014,9 +1989,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz", - "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz", + "integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==", "cpu": [ "arm64" ], @@ -2030,9 +2005,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz", - "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz", + "integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==", "cpu": [ "x64" ], @@ -2046,9 +2021,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz", - "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz", + "integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==", "cpu": [ "arm64" ], @@ -2062,9 +2037,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz", - "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz", + "integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==", "cpu": [ "arm64" ], @@ -2078,9 +2053,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz", - "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz", + "integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==", "cpu": [ "x64" ], @@ -2094,9 +2069,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz", - "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz", + "integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==", "cpu": [ "x64" ], @@ -2110,9 +2085,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz", - "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz", + "integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==", "cpu": [ "arm64" ], @@ -2126,9 +2101,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz", - "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz", + "integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==", "cpu": [ "x64" ], @@ -2213,16 +2188,6 @@ "pako": "^1.0.10" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -4474,7 +4439,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4495,7 +4459,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -4571,8 +4534,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4772,6 +4734,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4782,6 +4745,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4865,6 +4829,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5019,16 +4984,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -5417,6 +5382,7 @@ "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", @@ -5555,20 +5521,12 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5626,18 +5584,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -5937,6 +5883,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -5962,9 +5909,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -6005,6 +5952,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6363,6 +6311,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -6736,13 +6685,12 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -6770,12 +6718,6 @@ "dev": true, "license": "MIT" }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, "node_modules/editorconfig": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", @@ -6804,15 +6746,15 @@ } }, "node_modules/editorconfig/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/editorconfig/node_modules/minimatch": { @@ -6853,6 +6795,7 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, "node_modules/enhanced-resolve": { @@ -7136,6 +7079,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7191,13 +7135,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz", - "integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.12.tgz", + "integrity": "sha512-iaaf4vvKo5h2LBdGt0JuRv7t0Ysqr9FMCiFxbptDg8LqOE//mIKR80DdpOnSVM7qjLH3jT8P0aFiwXxBEGZRXw==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.10", + "@next/eslint-plugin-next": "16.2.12", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -7321,6 +7265,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7737,22 +7682,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -7918,21 +7847,17 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7951,25 +7876,37 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -9009,6 +8946,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -9068,21 +9006,6 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -9100,16 +9023,16 @@ "license": "MIT" }, "node_modules/js-beautify": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", - "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-2.0.3.tgz", + "integrity": "sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==", "license": "MIT", "dependencies": { "config-chain": "^1.1.13", - "editorconfig": "^1.0.4", - "glob": "^10.4.2", - "js-cookie": "^3.0.5", - "nopt": "^7.2.1" + "editorconfig": "^3.0.2", + "glob": "^13.0.6", + "js-cookie": "^3.0.8", + "nopt": "^10.0.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", @@ -9120,6 +9043,30 @@ "node": ">=14" } }, + "node_modules/js-beautify/node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/js-beautify/node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/js-cookie": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", @@ -9700,7 +9647,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -9771,7 +9717,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -10741,9 +10686,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -10810,12 +10755,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz", - "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==", + "version": "16.2.12", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz", + "integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==", "license": "MIT", "dependencies": { - "@next/env": "16.2.10", + "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -10829,14 +10774,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.10", - "@next/swc-darwin-x64": "16.2.10", - "@next/swc-linux-arm64-gnu": "16.2.10", - "@next/swc-linux-arm64-musl": "16.2.10", - "@next/swc-linux-x64-gnu": "16.2.10", - "@next/swc-linux-x64-musl": "16.2.10", - "@next/swc-win32-arm64-msvc": "16.2.10", - "@next/swc-win32-x64-msvc": "16.2.10", + "@next/swc-darwin-arm64": "16.2.12", + "@next/swc-darwin-x64": "16.2.12", + "@next/swc-linux-arm64-gnu": "16.2.12", + "@next/swc-linux-arm64-musl": "16.2.12", + "@next/swc-linux-x64-gnu": "16.2.12", + "@next/swc-linux-x64-musl": "16.2.12", + "@next/swc-win32-arm64-msvc": "16.2.12", + "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "peerDependencies": { @@ -10862,34 +10807,6 @@ } } }, - "node_modules/next/node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -10916,21 +10833,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nopt": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", - "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11152,12 +11054,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -11234,6 +11130,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11247,26 +11144,29 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/pathe": { "version": "2.0.3", @@ -11379,10 +11279,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -11399,7 +11298,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -11423,7 +11322,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -11439,7 +11337,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -11450,7 +11347,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -11463,8 +11359,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prop-types": { "version": "15.8.1", @@ -11788,6 +11683,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11797,6 +11693,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12458,54 +12355,59 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "optional": true, "bin": { @@ -12519,6 +12421,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -12531,6 +12434,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12619,18 +12523,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", @@ -12760,48 +12652,6 @@ "node": ">= 0.4" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -12929,43 +12779,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -13090,7 +12903,8 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -13197,6 +13011,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13464,6 +13279,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13879,6 +13695,7 @@ "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -13972,6 +13789,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13985,6 +13803,7 @@ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", @@ -14182,6 +14001,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -14329,65 +14149,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -14501,6 +14262,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -14530,6 +14292,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 7a7bdb72..eddf1ee8 100644 --- a/package.json +++ b/package.json @@ -33,32 +33,32 @@ "npm": ">=10 <11" }, "overrides": { - "dompurify": "3.4.11", + "dompurify": "3.4.12", "editorconfig": { ".": "3.0.2", "minimatch": "10.2.4" }, - "minimatch": "3.1.5", + "js-beautify": { + "nopt": "9.0.0" + }, "@typescript-eslint/typescript-estree": { "minimatch": "10.2.4" }, - "glob": { - "minimatch": "9.0.9" - }, + "sharp": "0.35.3", "undici": "7.28.0", "ws": "7.5.11", "next": { - "postcss": "8.5.10" + "postcss": "8.5.23" } }, "scripts": { "dev": "next dev", - "analyze": "ANALYZE=true next build --webpack", + "analyze": "node scripts/build/run-bundle-analyzer.js", "validate": "npm run check:sw-version && npm run check:sitemap-lastmod && npm run check:security-headers && npm run check:pwa-manifests && npm run check:runtime-scripts && npm run check:llms-txt && npm run check:bundle-boundaries && npm run check:performance-budget && npm run check:og-tool-images && npm run check:ia-stability && npm run check:route-width-inventory && npm run check:analytics-taxonomy && npm run check:registry-manifests && npm run check:tool-index && npm run check:legacy-routes && npm run check:client-tool-lookup && npm run check:privacy-copy && npm run check:i18n && npm run check:i18n-qa && npm run check:types", "build:app": "next build", "build:post": "npm run check:canonical && npm run check:hreflang && npm run check:legacy-routes && npm run check:metadata-localization && npm run check:jsonld-structured-data && npm run check:performance-budget:report && npm run check:rendered-i18n-copy && npm run check:related-tools && npm run check:content-template:legacy-paths && npm run check:content-template && npm run check:content-template:quality && npm run check:content-template:uniqueness && npm run check:faq-schema && npm run check:content-template:zh-cn && npm run check:content-template:quality:zh-cn && npm run check:faq-schema:zh-cn && npm run check:content-template:zh-tw && npm run check:content-template:quality:zh-tw && npm run check:faq-schema:zh-tw && npm run check:content-template:ja && npm run check:content-template:quality:ja && npm run check:faq-schema:ja && npm run check:content-template:ko && npm run check:content-template:quality:ko && npm run check:faq-schema:ko && npm run check:content-template:de && npm run check:content-template:quality:de && npm run check:faq-schema:de && npm run check:content-template:fr && npm run check:content-template:quality:fr && npm run check:faq-schema:fr && npm run postprocess:export-html-lang && npm run check:export-html-lang && npm run postprocess:export-robots-meta && npm run check:export-robots-meta && npm run check:sitemap-export && npm run build:sw", "build": "npm run validate && npm run build:app && npm run build:post", - "start": "next start", + "start": "node scripts/deployment/serve-export.js", "lint": "eslint", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -152,15 +152,15 @@ "cronstrue": "^3.12.0", "crypto-js": "^4.2.0", "csso": "^5.0.5", - "dompurify": "3.4.11", + "dompurify": "3.4.12", "jq-wasm": "^1.1.0-jq-1.8.1", - "js-beautify": "^1.15.4", + "js-beautify": "2.0.3", "jsbarcode": "^3.12.3", "jsqr": "^1.4.0", "jwt-decode": "^4.0.0", "lorem-ipsum": "^2.0.8", "lucide-react": "^0.575.0", - "next": "16.2.10", + "next": "16.2.12", "pdf-lib": "^1.17.1", "qrcode": "^1.5.4", "radix-ui": "^1.4.3", @@ -184,7 +184,7 @@ "yaml": "^2.8.2" }, "devDependencies": { - "@next/bundle-analyzer": "16.2.10", + "@next/bundle-analyzer": "16.2.12", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -203,7 +203,7 @@ "@vitest/coverage-v8": "^4.1.8", "axe-core": "^4.11.1", "eslint": "^9", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.12", "jsdom": "^28.1.0", "playwright": "^1.58.2", "serve-handler": "^6.1.7", diff --git a/public/sw.js b/public/sw.js index 3049ee3f..4362a4aa 100644 --- a/public/sw.js +++ b/public/sw.js @@ -22,7 +22,7 @@ const ACTIVE_CACHE_NAMES = [ CACHE_META_NAME, ]; const OFFLINE_FALLBACK_URL = '/offline.html'; -const OFFLINE_FALLBACK_CANDIDATES = [OFFLINE_FALLBACK_URL, '/offline']; +const OFFLINE_FALLBACK_CANDIDATES = [OFFLINE_FALLBACK_URL]; const MAX_RUNTIME_CACHE_ENTRIES = 120; const MAX_RUNTIME_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; const CACHE_WRITE_PAUSE_AFTER_CLEAR_MS = 5000; @@ -30,7 +30,13 @@ const CACHE_META_URL_PREFIX = '/__byteflow-cache-meta__?url='; let cacheWritesPausedUntil = 0; const APP_SHELL_ASSETS = [ - ...OFFLINE_FALLBACK_CANDIDATES, + '/en', + '/zh-CN', + '/zh-TW', + '/ja', + '/ko', + '/de', + '/fr', ]; const MANIFEST_ICON_ASSETS = [ @@ -77,6 +83,7 @@ const SENSITIVE_QUERY_PARAMS = [ 'signature', 'token', ]; +const CACHEABLE_QUERY_PARAMS = new Set(['source', 'utm_source']); function hasSensitiveQuery(url) { const sensitiveParams = new Set(SENSITIVE_QUERY_PARAMS); @@ -86,9 +93,22 @@ function hasSensitiveQuery(url) { return false; } +function hasUncacheableQuery(url) { + for (const param of url.searchParams.keys()) { + if (!CACHEABLE_QUERY_PARAMS.has(param.toLowerCase())) return true; + } + return false; +} + +function cacheRequest(request) { + const url = new URL(typeof request === 'string' ? request : request.url, self.location.origin); + url.search = ''; + url.hash = ''; + return new Request(url.toString(), { method: 'GET' }); +} + function matchOfflineFallback() { return caches.match(OFFLINE_FALLBACK_URL) - .then((cached) => cached || caches.match('/offline')) .then((cached) => cached || new Response( 'Offline | byteflow.tools

You are offline

Reconnect and refresh, or open a page you have visited before.

', { headers: { 'Content-Type': 'text/html; charset=utf-8' } }, @@ -96,7 +116,10 @@ function matchOfflineFallback() { } function cacheMetaRequest(cacheName, request) { - return new Request(`${CACHE_META_URL_PREFIX}${encodeURIComponent(`${cacheName}:${request.url}`)}`); + const canonicalRequest = cacheRequest(request); + const metaUrl = new URL(CACHE_META_URL_PREFIX, self.location.origin); + metaUrl.searchParams.set('url', `${cacheName}:${canonicalRequest.url}`); + return new Request(metaUrl.toString()); } function rememberCachedRequest(cacheName, request) { @@ -153,12 +176,13 @@ function discardRuntimeCacheWrite(cacheName) { function putRuntimeCache(request, response, cacheName) { if (isCacheWritePaused()) return Promise.resolve(); const clone = response.clone(); + const canonicalRequest = cacheRequest(request); return caches.open(cacheName) .then((cache) => { if (isCacheWritePaused()) return discardRuntimeCacheWrite(cacheName); - return cache.put(request, clone).then(() => { + return cache.put(canonicalRequest, clone).then(() => { if (isCacheWritePaused()) return discardRuntimeCacheWrite(cacheName); - return rememberCachedRequest(cacheName, request); + return rememberCachedRequest(cacheName, canonicalRequest); }); }) .then(() => { @@ -171,12 +195,59 @@ function isNetworkOnlyRequest(request, url) { return ( url.origin !== self.location.origin || hasSensitiveQuery(url) || + hasUncacheableQuery(url) || request.headers.get('x-byteflow-cache-mode') === 'network-only' || request.headers.get('x-byteflow-external-request') === '1' || url.pathname.startsWith('/api/') ); } +function extractAppShellAssets(html) { + const assets = new Set(); + const attributePattern = /(?:src|href)=["']([^"']+)["']/g; + let match; + while ((match = attributePattern.exec(html)) !== null) { + const url = new URL(match[1], self.location.origin); + if (url.origin === self.location.origin && url.pathname.startsWith('/_next/static/')) { + assets.add(url.toString()); + } + } + return [...assets]; +} + +async function precacheAppShellAsset(assetUrl) { + const request = cacheRequest(assetUrl); + const response = await fetch(request); + if (!response.ok) throw new Error(`Unable to precache app shell asset: ${request.url}`); + const url = new URL(request.url); + const cacheName = isToolChunkRequest(url) ? TOOL_CHUNK_CACHE_NAME : STATIC_ASSET_CACHE_NAME; + const cache = await caches.open(cacheName); + await cache.put(request, response); +} + +async function precacheAppShellRoute(cache, route) { + const candidates = [route, `${route}.html`]; + let response; + for (const candidate of candidates) { + const fetched = await fetch(candidate); + if (fetched.ok) { + response = fetched; + break; + } + } + if (!response) throw new Error(`Unable to precache app shell route: ${route}`); + + await cache.put(cacheRequest(route), response.clone()); + const assets = extractAppShellAssets(await response.text()); + await Promise.all(assets.map(precacheAppShellAsset)); +} + +async function precacheAppShell() { + const cache = await caches.open(APP_SHELL_CACHE_NAME); + await cache.addAll(OFFLINE_FALLBACK_CANDIDATES); + await Promise.all(APP_SHELL_ASSETS.map((route) => precacheAppShellRoute(cache, route))); +} + function isHtmlRequest(request, url) { return url.pathname.endsWith('.html') || request.headers.get('accept')?.includes('text/html'); } @@ -212,7 +283,7 @@ function deleteByteflowCaches() { self.addEventListener('install', (event) => { event.waitUntil( Promise.all([ - caches.open(APP_SHELL_CACHE_NAME).then((cache) => cache.addAll(APP_SHELL_ASSETS)), + precacheAppShell(), caches.open(MANIFEST_ICON_CACHE_NAME).then((cache) => cache.addAll(MANIFEST_ICON_ASSETS)), ]) ); @@ -277,7 +348,7 @@ self.addEventListener('fetch', (event) => { return response; }) .catch(() => - caches.match(event.request).then((cached) => cached || matchOfflineFallback()) + caches.match(cacheRequest(event.request)).then((cached) => cached || matchOfflineFallback()) ) ); return; @@ -286,7 +357,7 @@ self.addEventListener('fetch', (event) => { // Cache-first for manifest, icons, fonts, images, and other static assets. if (isManifestOrIconRequest(url) || CACHEABLE_STATIC_ASSET_PATTERN.test(url.pathname)) { event.respondWith( - caches.match(event.request).then((cached) => { + caches.match(cacheRequest(event.request)).then((cached) => { if (cached) return cached; return fetch(event.request).then((response) => { if (response.ok) { @@ -309,7 +380,7 @@ self.addEventListener('fetch', (event) => { return response; }) .catch(() => - caches.match(event.request).then((cached) => cached || matchOfflineFallback()) + caches.match(cacheRequest(event.request)).then((cached) => cached || matchOfflineFallback()) ) ); }); diff --git a/scripts/build/run-bundle-analyzer.js b/scripts/build/run-bundle-analyzer.js new file mode 100644 index 00000000..adb75572 --- /dev/null +++ b/scripts/build/run-bundle-analyzer.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import path from "node:path"; + +const nextCli = path.resolve(process.cwd(), "node_modules/next/dist/bin/next"); +const child = spawn(process.execPath, [nextCli, "build", "--webpack"], { + env: { ...process.env, ANALYZE: "true" }, + stdio: "inherit", +}); + +child.on("error", (error) => { + console.error(`[analyze] Unable to start Next.js: ${error.message}`); + process.exitCode = 1; +}); + +child.on("exit", (code, signal) => { + if (signal) { + console.error(`[analyze] Next.js exited after signal ${signal}.`); + process.exitCode = 1; + return; + } + process.exitCode = code ?? 1; +}); diff --git a/scripts/deployment/serve-export.js b/scripts/deployment/serve-export.js new file mode 100644 index 00000000..e2c8c43f --- /dev/null +++ b/scripts/deployment/serve-export.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { createServer } from "node:http"; +import path from "node:path"; +import serveHandler from "serve-handler"; + +function readOption(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +const outDir = path.resolve(process.cwd(), "out"); +if (!existsSync(outDir)) { + console.error(`[start] Missing static export at ${outDir}. Run npm run build first.`); + process.exit(1); +} + +const port = Number(readOption("--port") ?? process.env.PORT ?? 3000); +if (!Number.isInteger(port) || port < 1 || port > 65535) { + console.error(`[start] Invalid port: ${String(port)}`); + process.exit(1); +} +const host = readOption("--host") ?? process.env.HOST ?? "0.0.0.0"; + +const server = createServer((request, response) => serveHandler(request, response, { + public: outDir, + cleanUrls: true, + etag: true, +})); + +server.listen(port, host, () => { + console.log(`[start] Serving ${outDir} at http://${host}:${port}`); +}); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js index fa04d8ae..fff27ad0 100644 --- a/scripts/e2e/run-playwright-smoke.js +++ b/scripts/e2e/run-playwright-smoke.js @@ -2106,6 +2106,32 @@ async function assertPwaShellJourney(browser, baseUrl) { throw new Error("Service worker is active but did not control the PWA smoke page."); } + const installedAppShell = await page.evaluate(async () => { + const appShellName = (await caches.keys()).find((name) => name.startsWith("byteflow-app-shell-v")); + if (!appShellName) return { routes: [], staticAssets: 0 }; + const appShell = await caches.open(appShellName); + const routes = (await appShell.keys()).map((request) => new URL(request.url).pathname); + const staticAssetNames = (await caches.keys()).filter((name) => + name.startsWith("byteflow-static-assets-v") || name.startsWith("byteflow-tool-chunks-v"), + ); + const staticAssets = (await Promise.all(staticAssetNames.map(async (name) => (await caches.open(name)).keys()))) + .flat().length; + return { routes, staticAssets }; + }); + if (!installedAppShell.routes.includes("/en") || installedAppShell.staticAssets === 0) { + throw new Error(`PWA install did not precache the manifest start shell. Routes: ${installedAppShell.routes.join(", ")}; static assets: ${installedAppShell.staticAssets}`); + } + + await context.setOffline(true); + contextOffline = true; + await page.goto(`${baseUrl}/en?utm_source=pwa`, { waitUntil: "domcontentloaded" }); + await page.waitForSelector("main", { timeout: 15_000 }); + if ((await page.title()).startsWith("Offline |")) { + throw new Error("Fresh PWA offline launch returned the fallback instead of the installed app shell."); + } + await context.setOffline(false); + contextOffline = false; + await page.goto(`${baseUrl}/en/json-formatter`, { waitUntil: "domcontentloaded" }); await page.waitForSelector("main", { timeout: 15_000 }); isControlled = await waitForController(); diff --git a/scripts/gates/performance-budgets.json b/scripts/gates/performance-budgets.json index f62656b3..605ec420 100644 --- a/scripts/gates/performance-budgets.json +++ b/scripts/gates/performance-budgets.json @@ -40,6 +40,16 @@ "maxCssRawBytes": 220000, "maxHtmlBytes": 335000 }, + { + "route": "/ja/json-formatter", + "label": "JSON Formatter (Japanese)", + "maxInitialJsGzipBytes": 350000, + "maxInitialJsRawBytes": 1220000, + "maxInitialScriptFiles": 27, + "maxCssGzipBytes": 35000, + "maxCssRawBytes": 220000, + "maxHtmlBytes": 335000 + }, { "route": "/en/markdown-preview", "label": "Markdown Preview", diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index fc7b6616..e929f26f 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { LOCALES, isValidLocale } from "@/core/i18n/i18n"; +import { LOCALES, isValidLocale, type Locale } from "@/core/i18n/i18n"; import { AppLayout } from "@/components/layout/app-layout"; import { ServerFooter } from "@/components/layout/server-footer"; import { ServerNavbar } from "@/components/layout/server-navbar"; @@ -9,10 +9,13 @@ import { SiteJsonLd } from "@/core/seo/components/site-json-ld"; import { buildDefaultOgImageUrl, buildSiteKeywords, getOgLocale } from "@/core/seo/seo"; import { buildCanonicalUrl, buildLocalizedAlternates } from "@/core/seo/urls"; import { getEnglishToolSearchAliases, getTranslation } from "@/core/i18n/translations/catalog"; +import { encodeTranslationPayload } from "@/core/i18n/translation-payload"; import { ToolPrivacyFooterSlot } from "@/features/tool-shell/tool-privacy-footer-slot"; export const dynamicParams = false; +const encodedLanguagePayloads = new Map(); + export function generateStaticParams() { return LOCALES.map((lang) => ({ lang })); } @@ -70,12 +73,16 @@ export default async function LangLayout({ const locale = lang; const translations = getTranslation(locale); const englishToolSearchAliases = locale === "en" ? undefined : getEnglishToolSearchAliases(); + let encodedPayload = encodedLanguagePayloads.get(locale); + if (!encodedPayload) { + encodedPayload = encodeTranslationPayload({ translations, englishToolSearchAliases }); + encodedLanguagePayloads.set(locale, encodedPayload); + } return ( + } diff --git a/src/core/i18n/lang-provider.tsx b/src/core/i18n/lang-provider.tsx index c7d280f0..8f34dc07 100644 --- a/src/core/i18n/lang-provider.tsx +++ b/src/core/i18n/lang-provider.tsx @@ -3,6 +3,7 @@ import * as React from "react" import type { Locale } from "@/core/i18n/i18n" import type { ToolSearchAliases, TranslationSchema } from "@/core/i18n/translations/catalog" +import { decodeTranslationPayload } from "@/core/i18n/translation-payload" export type TranslationType = Omit & { tools: Record; @@ -25,11 +26,13 @@ export function LangProvider({ lang, translations, englishToolSearchAliases, + encodedPayload, }: { children?: React.ReactNode lang: Locale - translations: TranslationSchema + translations?: TranslationSchema englishToolSearchAliases?: ToolSearchAliases + encodedPayload?: string }) { React.useEffect(() => { if (typeof document !== 'undefined') { @@ -41,10 +44,20 @@ export function LangProvider({ } }, [lang]) - const t = translations as TranslationType + const decoded = React.useMemo(() => encodedPayload + ? decodeTranslationPayload<{ + translations: TranslationSchema + englishToolSearchAliases?: ToolSearchAliases + }>(encodedPayload) + : undefined, [encodedPayload]) + const resolvedTranslations = translations ?? decoded?.translations + if (!resolvedTranslations) throw new Error("[i18n] LangProvider requires translations or an encoded payload") + + const t = resolvedTranslations as TranslationType + const resolvedEnglishAliases = englishToolSearchAliases ?? decoded?.englishToolSearchAliases const value = React.useMemo( - () => ({ lang, t, englishToolSearchAliases }), - [englishToolSearchAliases, lang, t], + () => ({ lang, t, englishToolSearchAliases: resolvedEnglishAliases }), + [lang, resolvedEnglishAliases, t], ) return {children} diff --git a/src/core/i18n/translation-payload.test.ts b/src/core/i18n/translation-payload.test.ts new file mode 100644 index 00000000..3c21fa76 --- /dev/null +++ b/src/core/i18n/translation-payload.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest" +import { getEnglishToolSearchAliases, getTranslation } from "@/core/i18n/translations/catalog" +import { decodeTranslationPayload, encodeTranslationPayload } from "./translation-payload" + +describe("translation payload", () => { + it("round-trips localized dictionaries and search aliases", () => { + const value = { + translations: getTranslation("ja"), + englishToolSearchAliases: getEnglishToolSearchAliases(), + } + const payload = encodeTranslationPayload(value) + + expect(decodeTranslationPayload(payload)).toEqual(value) + expect(payload.length).toBeLessThan(JSON.stringify(value).length) + }) +}) diff --git a/src/core/i18n/translation-payload.ts b/src/core/i18n/translation-payload.ts new file mode 100644 index 00000000..2a732067 --- /dev/null +++ b/src/core/i18n/translation-payload.ts @@ -0,0 +1,77 @@ +function bytesToBase64(bytes: Uint8Array): string { + let binary = "" + const chunkSize = 0x8000 + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)) + } + return btoa(binary) +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes +} + +function compressBytes(input: Uint8Array): Uint16Array { + if (input.length === 0) return new Uint16Array() + const dictionary = new Map() + const codes: number[] = [] + let nextCode = 256 + let phrase = String.fromCharCode(input[0]) + + for (let index = 1; index < input.length; index += 1) { + const character = String.fromCharCode(input[index]) + const combined = phrase + character + if (dictionary.has(combined)) { + phrase = combined + continue + } + codes.push(phrase.length === 1 ? phrase.charCodeAt(0) : dictionary.get(phrase)!) + if (nextCode <= 0xffff) dictionary.set(combined, nextCode++) + phrase = character + } + codes.push(phrase.length === 1 ? phrase.charCodeAt(0) : dictionary.get(phrase)!) + return Uint16Array.from(codes) +} + +function decompressBytes(codes: Uint16Array): Uint8Array { + if (codes.length === 0) return new Uint8Array() + const dictionary: string[] = [] + let nextCode = 256 + let phrase = String.fromCharCode(codes[0]) + const chunks = [phrase] + + for (let index = 1; index < codes.length; index += 1) { + const code = codes[index] + let entry = code < 256 ? String.fromCharCode(code) : dictionary[code] + if (entry === undefined && code === nextCode) entry = phrase + phrase[0] + if (entry === undefined) throw new Error("Invalid translation payload") + chunks.push(entry) + if (nextCode <= 0xffff) dictionary[nextCode++] = phrase + entry[0] + phrase = entry + } + + const binary = chunks.join("") + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes +} + +export function encodeTranslationPayload(value: unknown): string { + const codes = compressBytes(new TextEncoder().encode(JSON.stringify(value))) + const packed = new Uint8Array(codes.length * 2) + const view = new DataView(packed.buffer) + codes.forEach((code, index) => view.setUint16(index * 2, code, false)) + return bytesToBase64(packed) +} + +export function decodeTranslationPayload(payload: string): T { + const bytes = base64ToBytes(payload) + if (bytes.byteLength % 2 !== 0) throw new Error("Invalid translation payload") + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + const codes = new Uint16Array(bytes.byteLength / 2) + codes.forEach((_, index) => { codes[index] = view.getUint16(index * 2, false) }) + return JSON.parse(new TextDecoder().decode(decompressBytes(codes))) as T +} diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index c68ca4ce..ce25465a 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -1412,7 +1412,7 @@ "parse_error": "Zertifikat konnte nicht geparst werden. Bitte ein gueltiges PEM-kodiertes X.509-Zertifikat pruefen.", "validity_valid": "Zertifikat ist aktuell gueltig", "validity_invalid": "Zertifikat ist abgelaufen oder noch nicht gueltig", - "self_signed": "Selbstsigniert", + "self_issued": "Selbstausgestellt (Signatur nicht geprüft)", "certificate_details": "Zertifikatsdetails", "subject_label": "Subjekt", "issuer_label": "Aussteller", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index 2219b44a..d7bec509 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -1440,7 +1440,7 @@ "parse_error": "Failed to parse certificate. Ensure it's a valid PEM-encoded X.509 certificate.", "validity_valid": "Certificate is currently valid", "validity_invalid": "Certificate has expired or is not yet valid", - "self_signed": "Self-Signed", + "self_issued": "Self-issued (signature not verified)", "certificate_details": "Certificate Details", "subject_label": "Subject", "issuer_label": "Issuer", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 86a2b0f4..d8f71e4d 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -1412,7 +1412,7 @@ "parse_error": "Impossible d'analyser le certificat. Vérifiez que le certificat X.509 encodé en PEM est valide.", "validity_valid": "Le certificat est actuellement valide", "validity_invalid": "Le certificat a expiré ou n'est pas encore valide", - "self_signed": "Auto-signé", + "self_issued": "Auto-émis (signature non vérifiée)", "certificate_details": "Détails du certificat", "subject_label": "Sujet", "issuer_label": "Émetteur", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 257df54a..aa12a2f2 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -1412,7 +1412,7 @@ "parse_error": "証明書を解析できませんでした。有効な PEM 形式の X.509 証明書か確認してください。", "validity_valid": "証明書は現在有効です", "validity_invalid": "証明書は期限切れ、またはまだ有効ではありません", - "self_signed": "自己署名", + "self_issued": "自己発行(署名未検証)", "certificate_details": "証明書の詳細", "subject_label": "サブジェクト", "issuer_label": "発行者", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index eb2d279c..e07cdf0a 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -1412,7 +1412,7 @@ "parse_error": "인증서를 파싱할 수 없습니다. 유효한 PEM 형식 X.509 인증서인지 확인하세요.", "validity_valid": "인증서가 현재 유효합니다", "validity_invalid": "인증서가 만료되었거나 아직 유효하지 않습니다", - "self_signed": "자체 서명", + "self_issued": "자체 발급(서명 미검증)", "certificate_details": "인증서 상세 정보", "subject_label": "주체", "issuer_label": "발급자", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index a1c45b02..c91eea77 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -1412,7 +1412,7 @@ "parse_error": "证书解析失败。请确认这是有效的 PEM 编码 X.509 证书。", "validity_valid": "证书当前有效", "validity_invalid": "证书已过期或尚未生效", - "self_signed": "自签名", + "self_issued": "自颁发(未验证签名)", "certificate_details": "证书详情", "subject_label": "主体", "issuer_label": "签发者", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 01609af7..39418501 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -1412,7 +1412,7 @@ "parse_error": "憑證解析失敗。請確認這是有效的 PEM 編碼 X.509 憑證。", "validity_valid": "憑證目前有效", "validity_invalid": "憑證已過期或尚未生效", - "self_signed": "自簽", + "self_issued": "自頒發(未驗證簽章)", "certificate_details": "憑證詳細資訊", "subject_label": "主體", "issuer_label": "簽發者", diff --git a/src/features/tools/certificate-decoder/logic.ts b/src/features/tools/certificate-decoder/logic.ts new file mode 100644 index 00000000..e9035646 --- /dev/null +++ b/src/features/tools/certificate-decoder/logic.ts @@ -0,0 +1,6 @@ +export function calculateIntegerBitLength(bytes: Uint8Array): number { + const firstNonZero = bytes.findIndex((byte) => byte !== 0) + if (firstNonZero < 0) return 0 + const firstByte = bytes[firstNonZero] + return (bytes.length - firstNonZero - 1) * 8 + Math.floor(Math.log2(firstByte)) + 1 +} diff --git a/src/features/tools/certificate-decoder/page.test.ts b/src/features/tools/certificate-decoder/page.test.ts new file mode 100644 index 00000000..2b42fbe6 --- /dev/null +++ b/src/features/tools/certificate-decoder/page.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest" +import { calculateIntegerBitLength } from "./logic" + +describe("certificate key size calculation", () => { + it("calculates the modulus bit length without counting DER sign padding", () => { + const modulus = new Uint8Array(257) + modulus[1] = 0x80 + + expect(calculateIntegerBitLength(modulus)).toBe(2048) + }) + + it("supports non-byte-aligned integer sizes", () => { + expect(calculateIntegerBitLength(Uint8Array.from([0x01, 0xff]))).toBe(9) + expect(calculateIntegerBitLength(Uint8Array.from([0x00, 0x00]))).toBe(0) + }) +}) diff --git a/src/features/tools/certificate-decoder/page.tsx b/src/features/tools/certificate-decoder/page.tsx index 178c2d04..b42e2985 100644 --- a/src/features/tools/certificate-decoder/page.tsx +++ b/src/features/tools/certificate-decoder/page.tsx @@ -8,6 +8,7 @@ import { RelatedTools } from "@/core/seo/components/related-tools" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" import { SensitiveInputWarning } from "@/features/tool-shell/sensitive-input-warning" import { ToolPageContainer } from "@/components/layout/page-container" +import { calculateIntegerBitLength } from "./logic" // ─── ASN.1 / PEM Parser (Pure JS, no dependencies) ────────────────────────── @@ -155,7 +156,42 @@ interface CertInfo { publicKeyBits: number extensions: { name: string; value: string; critical: boolean }[] isValid: boolean - isSelfSigned: boolean + isSelfIssued: boolean +} + +function calculatePublicKeyBits(spki: ASN1Node, algorithmOID: string): number { + const bitString = spki.children[1]?.value + if (!bitString || bitString.length < 2) return 0 + const keyBytes = bitString.slice(1) + + if (algorithmOID === "1.2.840.113549.1.1.1") { + try { + const rsaKey = parseASN1(keyBytes) + const modulus = rsaKey.children[0]?.value + if (modulus?.length) return calculateIntegerBitLength(modulus) + } catch { + // Fall through to the encoded key size for malformed or uncommon inputs. + } + } + + if (algorithmOID === "1.2.840.10045.2.1") { + const curveNode = spki.children[0]?.children[1] + const curveOID = curveNode?.tag === 6 ? decodeOID(curveNode.value) : "" + const curveBits: Record = { + "1.2.840.10045.3.1.7": 256, + "1.3.132.0.34": 384, + "1.3.132.0.35": 521, + } + if (curveBits[curveOID]) return curveBits[curveOID] + } + + const fixedAlgorithmBits: Record = { + "1.3.101.110": 255, + "1.3.101.111": 448, + "1.3.101.112": 256, + "1.3.101.113": 448, + } + return fixedAlgorithmBits[algorithmOID] ?? Math.max(0, keyBytes.length * 8 - bitString[0]) } function parseDN(node: ASN1Node): string { @@ -208,7 +244,7 @@ function parseCertificate(pem: string): CertInfo | null { const spki = tbsCert.children[idx++] const pkAlgOID = decodeOID(spki.children[0].children[0].value) const publicKeyAlgorithm = OID_NAMES[pkAlgOID] || pkAlgOID - const publicKeyBits = (spki.children[1].value.length - 1) * 8 + const publicKeyBits = calculatePublicKeyBits(spki, pkAlgOID) // Extensions (optional, context tag 3) const extensions: { name: string; value: string; critical: boolean }[] = [] @@ -239,7 +275,7 @@ function parseCertificate(pem: string): CertInfo | null { return { subject, issuer, serialNumber, notBefore, notAfter, signatureAlgorithm, publicKeyAlgorithm, publicKeyBits, - extensions, isValid, isSelfSigned: subject === issuer, + extensions, isValid, isSelfIssued: subject === issuer, } } catch { return null @@ -366,9 +402,9 @@ export function CertificateDecoderPage() { ? text("validity_valid") : text("validity_invalid")} - {certInfo.isSelfSigned && ( + {certInfo.isSelfIssued && ( - {text("self_signed")} + {text("self_issued")} )} diff --git a/src/features/tools/cron-visualizer/logic.ts b/src/features/tools/cron-visualizer/logic.ts new file mode 100644 index 00000000..b4bf9331 --- /dev/null +++ b/src/features/tools/cron-visualizer/logic.ts @@ -0,0 +1,84 @@ +import { validateCronExpression } from "@/features/tools/crontab-generator/logic" + +export type VisualizerCron = { + minute: string + hour: string + dom: string + month: string + dow: string +} + +export function parseVisualizerCron(expression: string): VisualizerCron | null { + const validation = validateCronExpression(expression, "Invalid cron expression.") + if (!validation.ok || validation.hasSeconds) return null + + const [minute, hour, dom, month, dow] = validation.parts + return { minute, hour, dom, month, dow } +} + +export function expandCronField(field: string, min: number, max: number): number[] { + const results = new Set() + for (const part of field.split(",")) { + const stepMatch = /^(\*|\d+|\d+-\d+)\/(\d+)$/.exec(part) + const step = stepMatch ? Number(stepMatch[2]) : 1 + const range = stepMatch ? stepMatch[1] : part + if (!Number.isSafeInteger(step) || step <= 0) return [] + + if (range === "*") { + for (let value = min; value <= max; value += step) results.add(value) + continue + } + + const rangeMatch = /^(\d+)-(\d+)$/.exec(range) + if (rangeMatch) { + const start = Number(rangeMatch[1]) + const end = Number(rangeMatch[2]) + if (start < min || end > max || start > end) return [] + for (let value = start; value <= end; value += step) results.add(value) + continue + } + + if (!/^\d+$/.test(range)) return [] + const value = Number(range) + if (!Number.isSafeInteger(value) || value < min || value > max) return [] + results.add(value) + } + return [...results].sort((a, b) => a - b) +} + +export function getNextExecutions(expression: string, count: number): Date[] { + const cron = parseVisualizerCron(expression) + if (!cron) return [] + + const minutes = expandCronField(cron.minute, 0, 59) + const hours = expandCronField(cron.hour, 0, 23) + const doms = cron.dom === "*" ? null : expandCronField(cron.dom, 1, 31) + const months = cron.month === "*" ? null : expandCronField(cron.month, 1, 12) + const dows = cron.dow === "*" ? null : expandCronField(cron.dow, 0, 6) + if (minutes.length === 0 || hours.length === 0 || doms?.length === 0 || months?.length === 0 || dows?.length === 0) return [] + + const results: Date[] = [] + const cursor = new Date() + cursor.setSeconds(0, 0) + cursor.setMinutes(cursor.getMinutes() + 1) + + let iterations = 0 + while (results.length < count && iterations < 525960) { + iterations++ + const month = cursor.getMonth() + 1 + const day = cursor.getDate() + const dayOfWeek = cursor.getDay() + const hour = cursor.getHours() + const minute = cursor.getMinutes() + + if (months && !months.includes(month)) { cursor.setMinutes(cursor.getMinutes() + 1); continue } + if (doms && !doms.includes(day)) { cursor.setMinutes(cursor.getMinutes() + 1); continue } + if (dows && !dows.includes(dayOfWeek)) { cursor.setMinutes(cursor.getMinutes() + 1); continue } + if (!hours.includes(hour)) { cursor.setMinutes(cursor.getMinutes() + 1); continue } + if (!minutes.includes(minute)) { cursor.setMinutes(cursor.getMinutes() + 1); continue } + + results.push(new Date(cursor)) + cursor.setMinutes(cursor.getMinutes() + 1) + } + return results +} diff --git a/src/features/tools/cron-visualizer/page.tsx b/src/features/tools/cron-visualizer/page.tsx index 17f4a9f3..45f9165b 100644 --- a/src/features/tools/cron-visualizer/page.tsx +++ b/src/features/tools/cron-visualizer/page.tsx @@ -8,71 +8,7 @@ import { useLang } from "@/core/i18n/lang-provider" import { Input } from "@/components/ui/input" import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { ToolPageContainer } from "@/components/layout/page-container" - -function parseCron(expr: string): { minute: string; hour: string; dom: string; month: string; dow: string } | null { - const parts = expr.trim().split(/\s+/) - if (parts.length !== 5) return null - return { minute: parts[0], hour: parts[1], dom: parts[2], month: parts[3], dow: parts[4] } -} - -function expandField(field: string, min: number, max: number): number[] { - const results = new Set() - for (const part of field.split(",")) { - const stepMatch = part.match(/^(.+)\/(\d+)$/) - const step = stepMatch ? parseInt(stepMatch[2]) : 1 - const range = stepMatch ? stepMatch[1] : part - - if (range === "*") { - for (let i = min; i <= max; i += step) results.add(i) - } else if (range.includes("-")) { - const [a, b] = range.split("-").map(Number) - for (let i = a; i <= b; i += step) results.add(i) - } else { - results.add(parseInt(range)) - } - } - return [...results].filter(n => n >= min && n <= max).sort((a, b) => a - b) -} - -function getNextExecutions(expr: string, count: number): Date[] { - const cron = parseCron(expr) - if (!cron) return [] - try { - const minutes = expandField(cron.minute, 0, 59) - const hours = expandField(cron.hour, 0, 23) - const doms = cron.dom === "*" ? null : expandField(cron.dom, 1, 31) - const months = cron.month === "*" ? null : expandField(cron.month, 1, 12) - const dows = cron.dow === "*" ? null : expandField(cron.dow, 0, 6) - - const results: Date[] = [] - const now = new Date() - const cursor = new Date(now) - cursor.setSeconds(0, 0) - cursor.setMinutes(cursor.getMinutes() + 1) - - let iterations = 0 - while (results.length < count && iterations < 525960) { - iterations++ - const m = cursor.getMonth() + 1 - const d = cursor.getDate() - const dow = cursor.getDay() - const h = cursor.getHours() - const min = cursor.getMinutes() - - if (months && !months.includes(m)) { cursor.setMinutes(cursor.getMinutes() + 1); continue } - if (doms && !doms.includes(d)) { cursor.setMinutes(cursor.getMinutes() + 1); continue } - if (dows && !dows.includes(dow)) { cursor.setMinutes(cursor.getMinutes() + 1); continue } - if (!hours.includes(h)) { cursor.setMinutes(cursor.getMinutes() + 1); continue } - if (!minutes.includes(min)) { cursor.setMinutes(cursor.getMinutes() + 1); continue } - - results.push(new Date(cursor)) - cursor.setMinutes(cursor.getMinutes() + 1) - } - return results - } catch { - return [] - } -} +import { getNextExecutions, parseVisualizerCron } from "./logic" export function CronVisualizerPage() { const { t, lang } = useLang() @@ -96,7 +32,7 @@ export function CronVisualizerPage() { React.useEffect(() => { if (!expr.trim()) { setExecutions([]); setError(null); return } - const cron = parseCron(expr) + const cron = parseVisualizerCron(expr) if (!cron) { setError(toolT.invalid_expression); setExecutions([]); return } const next = getNextExecutions(expr, 20) setExecutions(next) @@ -116,7 +52,7 @@ export function CronVisualizerPage() { toast.success(t.common.copied) } - const cron = parseCron(expr) + const cron = parseVisualizerCron(expr) const fieldLabels = [ toolT.minute, toolT.hour, diff --git a/src/features/tools/crontab-generator/logic.ts b/src/features/tools/crontab-generator/logic.ts index c82b3e7a..f3aaf3e5 100644 --- a/src/features/tools/crontab-generator/logic.ts +++ b/src/features/tools/crontab-generator/logic.ts @@ -40,13 +40,46 @@ function getFieldErrorLabel(field: CronFieldDefinition): string { return field.key.replace("_", " ") } -function validateNumericTokens(field: string, definition: CronFieldDefinition): string | null { - const numericTokens = field.match(/\d+/g) ?? [] - for (const token of numericTokens) { - const value = Number(token) - if (!Number.isInteger(value) || value < definition.min || value > definition.max) { - return `Invalid cron ${getFieldErrorLabel(definition)} field. Expected ${definition.min}-${definition.max}, got ${token}.` +function getOutOfRangeError(definition: CronFieldDefinition, token: string): string { + return `Invalid cron ${getFieldErrorLabel(definition)} field. Expected ${definition.min}-${definition.max}, got ${token}.` +} + +function validateFieldValue(token: string, definition: CronFieldDefinition): string | null { + const value = Number(token) + if (!Number.isSafeInteger(value) || value < definition.min || value > definition.max) { + return getOutOfRangeError(definition, token) + } + return null +} + +function validateCronField(field: string, definition: CronFieldDefinition, genericError: string): string | null { + for (const segment of field.split(",")) { + const match = /^(\*|\d+|\d+-\d+)(?:\/(\d+))?$/.exec(segment) + if (!match) return genericError + + const [, base, stepToken] = match + if (stepToken !== undefined) { + const step = Number(stepToken) + if (!Number.isSafeInteger(step) || step <= 0) { + return `Invalid cron ${getFieldErrorLabel(definition)} field. Step must be a positive integer.` + } } + + if (base === "*") continue + + const rangeMatch = /^(\d+)-(\d+)$/.exec(base) + if (!rangeMatch) { + const valueError = validateFieldValue(base, definition) + if (valueError) return valueError + continue + } + + const [, startToken, endToken] = rangeMatch + const startError = validateFieldValue(startToken, definition) + if (startError) return startError + const endError = validateFieldValue(endToken, definition) + if (endError) return endError + if (Number(startToken) > Number(endToken)) return genericError } return null } @@ -59,7 +92,7 @@ export function validateCronExpression(expression: string, genericError: string) const definitions = getCronFieldDefinitions(parts.length) for (let index = 0; index < definitions.length; index += 1) { - const fieldError = validateNumericTokens(parts[index] ?? "", definitions[index]) + const fieldError = validateCronField(parts[index] ?? "", definitions[index], genericError) if (fieldError) return { ok: false, error: fieldError } } diff --git a/src/features/tools/env-parser/utils.ts b/src/features/tools/env-parser/utils.ts index f2d8f374..fa132b13 100644 --- a/src/features/tools/env-parser/utils.ts +++ b/src/features/tools/env-parser/utils.ts @@ -9,6 +9,24 @@ export type EnvVar = { export type EnvExportFormat = "json" | "yaml" | "docker-args" +const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +export function isValidEnvKey(key: string): boolean { + return ENV_KEY_PATTERN.test(key) +} + +function getExportableEnvVars(vars: EnvVar[]): EnvVar[] { + return vars.filter((variable) => + !variable.isComment && + !variable.isEmpty && + isValidEnvKey(variable.key), + ) +} + +function quotePosixShellValue(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'` +} + export function parseEnvFile(content: string): EnvVar[] { return content.split("\n").map((raw, i) => { const trimmed = raw.trim() @@ -29,21 +47,21 @@ export function parseEnvFile(content: string): EnvVar[] { export function envToJson(vars: EnvVar[]): string { const obj: Record = {} - vars.filter((variable) => !variable.isComment && !variable.isEmpty && variable.key).forEach((variable) => { + getExportableEnvVars(vars).forEach((variable) => { obj[variable.key] = variable.value }) return JSON.stringify(obj, null, 2) } export function envToYaml(vars: EnvVar[]): string { - return vars.filter((variable) => !variable.isComment && !variable.isEmpty && variable.key) - .map((variable) => `${variable.key}: "${variable.value.replace(/"/g, "\\\"")}"`) + return getExportableEnvVars(vars) + .map((variable) => `${variable.key}: ${JSON.stringify(variable.value)}`) .join("\n") } export function envToDockerArgs(vars: EnvVar[]): string { - return vars.filter((variable) => !variable.isComment && !variable.isEmpty && variable.key) - .map((variable) => `-e ${variable.key}="${variable.value}"`) + return getExportableEnvVars(vars) + .map((variable) => `-e ${variable.key}=${quotePosixShellValue(variable.value)}`) .join(" \\\n ") } diff --git a/src/features/tools/hash-generator/hash-task.ts b/src/features/tools/hash-generator/hash-task.ts index 21b23a8d..12468f70 100644 --- a/src/features/tools/hash-generator/hash-task.ts +++ b/src/features/tools/hash-generator/hash-task.ts @@ -11,14 +11,18 @@ export async function runHashTask(input: HashTaskInput, options: HashTaskOptions return runHashTaskSync(input) } + const workerInput: HashTaskInput = input.mode === "file" + ? { mode: "file", bytes: input.bytes.slice() } + : input + try { return await runWorkerTask( () => new Worker(new URL("./hash-worker.ts", import.meta.url), { type: "module" }), - input, + workerInput, { signal: options.signal, timeoutMs: options.timeoutMs ?? 20_000, - transfer: input.mode === "file" ? [input.bytes.buffer] : undefined, + transfer: workerInput.mode === "file" ? [workerInput.bytes.buffer] : undefined, }, ) } catch (error) { diff --git a/src/features/tools/json-to-typescript/utils.test.ts b/src/features/tools/json-to-typescript/utils.test.ts new file mode 100644 index 00000000..3585f809 --- /dev/null +++ b/src/features/tools/json-to-typescript/utils.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest" +import { jsonToTs } from "./utils" + +const options = { readonly: false, optional: false } + +describe("jsonToTs", () => { + it("merges every array element and marks missing fields optional", () => { + const output = jsonToTs([ + { id: 1, active: true }, + { id: "second", email: "user@example.com" }, + ], "Root", options) + + expect(output).toContain("id: number | string;") + expect(output).toContain("active?: boolean;") + expect(output).toContain("email?: string;") + }) + + it("does not reuse interfaces that only share field names", () => { + const output = jsonToTs({ a: { x: 1 }, b: { x: "text" } }, "Root", options) + + expect(output).toContain("export interface RootA") + expect(output).toContain("x: number;") + expect(output).toContain("export interface RootB") + expect(output).toContain("x: string;") + expect(output).toContain("a: RootA;") + expect(output).toContain("b: RootB;") + }) + + it("infers unions for heterogeneous primitive arrays", () => { + expect(jsonToTs([1, "two", null], "Value", options)).toContain("export type Value = (number | string | null)[];") + }) + + it("quotes property names using valid TypeScript string literals", () => { + const output = jsonToTs({ 'quote"line\n': true }, "Root", options) + expect(output).toContain('"quote\\"line\\n": boolean;') + }) +}) diff --git a/src/features/tools/json-to-typescript/utils.ts b/src/features/tools/json-to-typescript/utils.ts index 8bab3f42..7b0aa571 100644 --- a/src/features/tools/json-to-typescript/utils.ts +++ b/src/features/tools/json-to-typescript/utils.ts @@ -1,67 +1,172 @@ -export function jsonToTs(json: unknown, name: string, options: { readonly: boolean; optional: boolean }): string { - const lines: string[] = [] - const seen = new Map() +type PrimitiveType = { + kind: "primitive" + name: "string" | "number" | "boolean" | "null" | "unknown" +} - function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1) - } +type ArrayType = { + kind: "array" + item: InferredType +} + +type ObjectProperty = { + name: string + type: InferredType + optional: boolean +} + +type ObjectType = { + kind: "object" + properties: ObjectProperty[] +} - function sanitize(key: string): string { - return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `"${key}"` +type UnionType = { + kind: "union" + types: InferredType[] +} + +type InferredType = PrimitiveType | ArrayType | ObjectType | UnionType + +const primitive = (name: PrimitiveType["name"]): PrimitiveType => ({ kind: "primitive", name }) + +function typeSignature(type: InferredType): string { + switch (type.kind) { + case "primitive": + return type.name + case "array": + return `array:${typeSignature(type.item)}` + case "object": + return `object:${type.properties + .map((property) => `${JSON.stringify(property.name)}${property.optional ? "?" : ""}:${typeSignature(property.type)}`) + .sort() + .join(",")}` + case "union": + return `union:${type.types.map(typeSignature).sort().join("|")}` } +} - function inferType(value: unknown, typeName: string): string { - if (value === null) return "null" - if (Array.isArray(value)) { - if (value.length === 0) return "unknown[]" - const itemType = inferType(value[0], typeName + "Item") - return `${itemType}[]` +function mergeObjectTypes(left: ObjectType, right: ObjectType): ObjectType { + const rightByName = new Map(right.properties.map((property) => [property.name, property])) + const leftNames = new Set(left.properties.map((property) => property.name)) + const properties = left.properties.map((property) => { + const other = rightByName.get(property.name) + if (!other) return { ...property, optional: true } + return { + name: property.name, + type: mergeTypes(property.type, other.type), + optional: property.optional || other.optional, } - switch (typeof value) { - case "string": return "string" - case "number": return "number" - case "boolean": return "boolean" - default: - if (typeof value === "object" && value !== null) { - const existing = seen.get(JSON.stringify(Object.keys(value).sort())) - if (existing) return existing - generateInterface(value as Record, typeName) - return typeName - } - return "unknown" + }) + + for (const property of right.properties) { + if (!leftNames.has(property.name)) properties.push({ ...property, optional: true }) + } + return { kind: "object", properties } +} + +function mergeTypes(left: InferredType, right: InferredType): InferredType { + if (typeSignature(left) === typeSignature(right)) return left + if (left.kind === "object" && right.kind === "object") return mergeObjectTypes(left, right) + + const candidates = [ + ...(left.kind === "union" ? left.types : [left]), + ...(right.kind === "union" ? right.types : [right]), + ] + const unique = new Map() + candidates.forEach((candidate) => unique.set(typeSignature(candidate), candidate)) + return { kind: "union", types: [...unique.values()] } +} + +function inferType(value: unknown): InferredType { + if (value === null) return primitive("null") + if (Array.isArray(value)) { + const item = value.reduce( + (current, entry) => current ? mergeTypes(current, inferType(entry)) : inferType(entry), + null, + ) + return { kind: "array", item: item ?? primitive("unknown") } + } + if (typeof value === "object") { + return { + kind: "object", + properties: Object.entries(value as Record).map(([name, entry]) => ({ + name, + type: inferType(entry), + optional: false, + })), } } + if (typeof value === "string") return primitive("string") + if (typeof value === "number") return primitive("number") + if (typeof value === "boolean") return primitive("boolean") + return primitive("unknown") +} - function generateInterface(obj: Record, interfaceName: string) { - const key = JSON.stringify(Object.keys(obj).sort()) - if (seen.has(key)) return - seen.set(key, interfaceName) +function sanitizePropertyName(name: string): string { + return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name) +} - const prefix = options.readonly ? "readonly " : "" - const suffix = options.optional ? "?" : "" +function sanitizeTypeName(name: string): string { + const parts = name.trim().split(/[^\p{L}\p{N}_$]+/u).filter(Boolean) + const candidate = parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("") || "Root" + return /^\p{N}/u.test(candidate) ? `T${candidate}` : candidate +} + +function childTypeName(parent: string, property: string): string { + return `${parent}${sanitizeTypeName(property)}` +} - const members = Object.entries(obj).map(([k, v]) => { - const childTypeName = capitalize(interfaceName) + capitalize(k) - const type = inferType(v, childTypeName) - return ` ${prefix}${sanitize(k)}${suffix}: ${type};` - }) +export function jsonToTs(json: unknown, name: string, options: { readonly: boolean; optional: boolean }): string { + const declarations: string[] = [] + const usedNames = new Set() + const renderedObjects = new Map() - lines.push(`export interface ${interfaceName} {\n${members.join("\n")}\n}\n`) + function reserveName(preferredName: string): string { + const base = sanitizeTypeName(preferredName) + let candidate = base + let suffix = 2 + while (usedNames.has(candidate)) candidate = `${base}${suffix++}` + usedNames.add(candidate) + return candidate } - if (Array.isArray(json)) { - if (json.length > 0 && typeof json[0] === "object" && json[0] !== null) { - generateInterface(json[0] as Record, name) - lines.push(`export type ${name}List = ${name}[];\n`) - } else { - const itemType = json.length > 0 ? inferType(json[0], name + "Item") : "unknown" - lines.push(`export type ${name} = ${itemType}[];\n`) + function renderType(type: InferredType, preferredName: string): string { + switch (type.kind) { + case "primitive": + return type.name + case "array": { + const item = renderType(type.item, `${preferredName}Item`) + return type.item.kind === "union" ? `(${item})[]` : `${item}[]` + } + case "union": + return type.types.map((entry, index) => renderType(entry, `${preferredName}${index + 1}`)).join(" | ") + case "object": { + const existing = renderedObjects.get(type) + if (existing) return existing + + const interfaceName = reserveName(preferredName) + renderedObjects.set(type, interfaceName) + const prefix = options.readonly ? "readonly " : "" + const members = type.properties.map((property) => { + const propertyType = renderType(property.type, childTypeName(interfaceName, property.name)) + const suffix = options.optional || property.optional ? "?" : "" + return ` ${prefix}${sanitizePropertyName(property.name)}${suffix}: ${propertyType};` + }) + declarations.push(`export interface ${interfaceName} {\n${members.join("\n")}\n}`) + return interfaceName + } } - } else if (typeof json === "object" && json !== null) { - generateInterface(json as Record, name) + } + + const rootName = sanitizeTypeName(name) + const inferred = inferType(json) + if (inferred.kind === "object") { + renderType(inferred, rootName) + } else if (inferred.kind === "array" && inferred.item.kind === "object") { + const itemType = renderType(inferred.item, rootName) + declarations.push(`export type ${rootName}List = ${itemType}[];`) } else { - lines.push(`export type ${name} = ${typeof json};\n`) + declarations.push(`export type ${rootName} = ${renderType(inferred, `${rootName}Item`)};`) } - return lines.join("\n") + return `${declarations.join("\n\n")}\n` } diff --git a/src/features/tools/messagepack-inspector/logic.test.ts b/src/features/tools/messagepack-inspector/logic.test.ts index 0aa45bcf..5ed95c67 100644 --- a/src/features/tools/messagepack-inspector/logic.test.ts +++ b/src/features/tools/messagepack-inspector/logic.test.ts @@ -8,6 +8,29 @@ describe("messagepack-inspector logic", () => { expect(report.summary).toContain("map") }) + it("preserves non-string map keys without collisions", () => { + const report = decodeMessagePack("8201a161a131a162", "hex") + + expect(report.value).toEqual({ + type: "map", + entries: [ + { key: 1, value: "a" }, + { key: "1", value: "b" }, + ], + }) + expect(report.json).toContain('"key": 1') + expect(report.json).toContain('"key": "1"') + }) + + it("treats __proto__ as data instead of mutating the result prototype", () => { + const report = decodeMessagePack("81a95f5f70726f746f5f5f81a17801", "hex") + + expect(Object.getPrototypeOf(report.value)).toBeNull() + expect(Object.prototype.hasOwnProperty.call(report.value, "__proto__")).toBe(true) + expect((report.value as Record)["__proto__"]).toEqual({ x: 1 }) + expect(report.json).toContain('"__proto__"') + }) + it("decodes arrays and booleans", () => { expect(decodeMessagePack("93c3c2c0", "hex").value).toEqual([true, false, null]) }) diff --git a/src/features/tools/messagepack-inspector/logic.ts b/src/features/tools/messagepack-inspector/logic.ts index 685e700e..bce1e9a0 100644 --- a/src/features/tools/messagepack-inspector/logic.ts +++ b/src/features/tools/messagepack-inspector/logic.ts @@ -99,6 +99,11 @@ type MessagePackTimestampValue = { hex: string } +type MessagePackMapValue = { + type: "map" + entries: Array<{ key: unknown; value: unknown }> +} + function readUInt32FromBytes(bytes: Uint8Array, offset = 0): number { return bytes[offset] * 2 ** 24 + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3] } @@ -347,24 +352,24 @@ function decodeValue(reader: Reader): unknown { } } -function decodeMap(reader: Reader, length: number): Record { +function decodeMap(reader: Reader, length: number): Record | MessagePackMapValue { if (length > Math.floor(reader.remaining() / 2)) throw new Error(`MessagePack map declares ${length} pair(s), but only ${reader.remaining()} byte(s) remain.`) - const output: Record = {} + const entries: MessagePackMapValue["entries"] = [] for (let index = 0; index < length; index += 1) { const key = decodeValue(reader) - output[mapKeyToString(key)] = decodeValue(reader) + entries.push({ key, value: decodeValue(reader) }) } - return output -} -function mapKeyToString(key: unknown): string { - if (typeof key === "string") return key - if (key === null || typeof key !== "object") return String(key) - try { - return JSON.stringify(key) ?? String(key) - } catch { - return String(key) - } + const stringKeys = entries.map(({ key }) => typeof key === "string" ? key : null) + const canUseObject = stringKeys.every((key): key is string => key !== null) + && new Set(stringKeys).size === stringKeys.length + if (!canUseObject) return { type: "map", entries } + + const output: Record = Object.create(null) as Record + entries.forEach(({ value }, index) => { + output[stringKeys[index]] = value + }) + return output } function isRecord(value: unknown): value is Record { @@ -374,6 +379,7 @@ function isRecord(value: unknown): value is Record { function summarize(value: unknown): string { if (Array.isArray(value)) return `Decoded MessagePack array with ${value.length} item(s).` if (isRecord(value)) { + if (value.type === "map" && Array.isArray(value.entries)) return `Decoded MessagePack map with ${value.entries.length} key(s).` if (value.type === "binary" && typeof value.length === "number") return `Decoded MessagePack binary with ${value.length} byte(s).` if (value.type === "extension" && typeof value.extensionType === "number" && typeof value.length === "number") { return `Decoded MessagePack extension type ${value.extensionType} with ${value.length} byte(s).` diff --git a/src/features/tools/open-graph-meta-generator/manifest.ts b/src/features/tools/open-graph-meta-generator/manifest.ts index e77d27a1..7624adb3 100644 --- a/src/features/tools/open-graph-meta-generator/manifest.ts +++ b/src/features/tools/open-graph-meta-generator/manifest.ts @@ -6,12 +6,17 @@ export const toolManifest = { category: "generators", relatedTools: ["tweet_generator", "tweet_to_image_converter", "instagram_post_generator", "instagram_story_generator"], privacy: { - executionMode: "browser-local", - offlineCapable: true, + executionMode: "external-request", + offlineCapable: false, sensitiveInput: false, externalRequest: { - required: false, - endpointType: "none", + required: true, + endpointType: "user_provided_url", + domains: ["instagram.com", "i.ytimg.com", "vumbnail.com"], + purposeKey: "thumbnail_preview", + userDataSent: "user_provided_url", + disclosure: "Loads a supported public image URL you provide only after you confirm and choose Preview.", + consentRequired: true, }, }, compliance: { diff --git a/src/features/tools/open-graph-meta-generator/page.tsx b/src/features/tools/open-graph-meta-generator/page.tsx index dfb5fd2f..5fac6026 100644 --- a/src/features/tools/open-graph-meta-generator/page.tsx +++ b/src/features/tools/open-graph-meta-generator/page.tsx @@ -1,22 +1,32 @@ "use client" import * as React from "react" -import { Copy, Download, Eraser, Globe2, TestTube2 } from "lucide-react" +import { Copy, Download, Eraser, Globe2, ImageDown, TestTube2 } from "lucide-react" import { useLang } from "@/core/i18n/lang-provider" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { ToolActionBar, type ToolAction } from "@/features/tool-shell/tool-action-bar" -import { copyTextWithToolFeedback, downloadedFileFeedback } from "@/features/tool-shell/tool-action-feedback" +import { + copyTextWithToolFeedback, + downloadedFileFeedback, + notifyToolActionFailure, + notifyToolActionSuccess, +} from "@/features/tool-shell/tool-action-feedback" +import { ExternalRequestConfirmation } from "@/features/tool-shell/external-request-confirmation" +import { isBrowserOffline, useBrowserOnlineStatus } from "@/features/tool-shell/external-request-offline" import { TextOutputPanel } from "@/features/tool-shell/text-output-panel" import { ToolPreviewArea } from "@/features/tool-shell/tool-preview-area" import { buildOpenGraphMetaTags, buildOpenGraphSnippetDocument, normalizeAbsoluteHttpUrl, + normalizeSupportedOpenGraphPreviewUrl, type OpenGraphInput, } from "@/features/tools/open-graph-meta-generator/utils" import { ToolPageContainer } from "@/components/layout/page-container" +const EXTERNAL_IMAGE_HOSTS = ["instagram.com", "i.ytimg.com", "vumbnail.com"] as const + export function OpenGraphMetaGeneratorPage() { const { t, lang } = useLang() const toolT = t.tools["open_graph_meta_generator"] as Record @@ -33,22 +43,35 @@ export function OpenGraphMetaGeneratorPage() { }), [lang, toolT.sample_description, toolT.sample_title]) const [form, setForm] = React.useState(sample) + const [approvedPreviewImage, setApprovedPreviewImage] = React.useState(null) + const [externalRequestConfirmed, setExternalRequestConfirmed] = React.useState(false) + const isOnline = useBrowserOnlineStatus() React.useEffect(() => { setForm(sample) + setApprovedPreviewImage(null) + setExternalRequestConfirmed(false) }, [sample]) const tags = React.useMemo(() => buildOpenGraphMetaTags(form), [form]) const output = React.useMemo(() => buildOpenGraphSnippetDocument(tags), [tags]) const previewUrl = React.useMemo(() => normalizeAbsoluteHttpUrl(form.url), [form.url]) - const previewImage = React.useMemo(() => normalizeAbsoluteHttpUrl(form.image), [form.image]) + const candidatePreviewImage = React.useMemo(() => normalizeSupportedOpenGraphPreviewUrl(form.image), [form.image]) const setField = (key: K, value: OpenGraphInput[K]) => { + if (key === "image") { + setApprovedPreviewImage(null) + setExternalRequestConfirmed(false) + } setForm((prev) => ({ ...prev, [key]: value })) } - const handleSample = () => setForm(sample) - const handleReset = () => + const handleSample = () => { + setForm(sample) + setApprovedPreviewImage(null) + setExternalRequestConfirmed(false) + } + const handleReset = () => { setForm({ title: "", description: "", @@ -59,6 +82,34 @@ export function OpenGraphMetaGeneratorPage() { twitterCard: "summary", twitterSite: "", }) + setApprovedPreviewImage(null) + setExternalRequestConfirmed(false) + } + + const handlePreview = () => { + if (!candidatePreviewImage) return + if (!externalRequestConfirmed) { + return notifyToolActionFailure(t, { + kind: "share", + label: t.common.preview, + title: t.common.external_network_notice.confirm_required, + }) + } + if (isBrowserOffline()) { + return notifyToolActionFailure(t, { + kind: "share", + label: t.common.preview, + title: t.common.external_network_notice.offline_required, + }) + } + + setApprovedPreviewImage(candidatePreviewImage) + return notifyToolActionSuccess(t, { + kind: "share", + label: t.common.preview, + title: t.common.action_status_success.replace("{action}", t.common.preview), + }) + } const handleCopy = async () => { return copyTextWithToolFeedback(t, output, toolT.preview_meta_tags_label || t.common.output) @@ -79,6 +130,18 @@ export function OpenGraphMetaGeneratorPage() { const actions: ToolAction[] = [ { id: "sample", label: t.common.sample, icon: TestTube2, onClick: handleSample }, { id: "reset", label: t.common.reset, icon: Eraser, onClick: handleReset }, + { + id: "preview", + label: t.common.preview, + icon: ImageDown, + onClick: handlePreview, + disabled: !candidatePreviewImage || !externalRequestConfirmed || !isOnline, + disabledReason: !candidatePreviewImage + ? t.common.action_disabled_input_required + : !externalRequestConfirmed + ? t.common.external_network_notice.confirm_required + : t.common.external_network_notice.offline_required, + }, { id: "copy", label: t.common.copy, icon: Copy, onClick: handleCopy }, { id: "download", label: t.common.download, icon: Download, onClick: handleDownload }, ] @@ -136,6 +199,16 @@ export function OpenGraphMetaGeneratorPage() { + { + setExternalRequestConfirmed(confirmed) + if (!confirmed) setApprovedPreviewImage(null) + }} + />
@@ -151,11 +224,12 @@ export function OpenGraphMetaGeneratorPage() { allowBackgroundToggle={false} allowFullscreen={true} > - {previewImage ? ( + {approvedPreviewImage ? ( // eslint-disable-next-line @next/next/no-img-element {toolT.image_preview_alt} ) : ( diff --git a/src/features/tools/open-graph-meta-generator/utils.ts b/src/features/tools/open-graph-meta-generator/utils.ts index 340dc5a7..7454163c 100644 --- a/src/features/tools/open-graph-meta-generator/utils.ts +++ b/src/features/tools/open-graph-meta-generator/utils.ts @@ -9,6 +9,8 @@ export type OpenGraphInput = { twitterSite: string } +const SUPPORTED_PREVIEW_HOSTS = new Set(["i.ytimg.com", "vumbnail.com"]) + function collapseWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim() } @@ -34,14 +36,27 @@ export function normalizeAbsoluteHttpUrl(raw: string): string | null { } } +export function normalizeSupportedOpenGraphPreviewUrl(raw: string): string | null { + const normalized = normalizeAbsoluteHttpUrl(raw) + if (!normalized) return null + + const url = new URL(normalized) + if (url.protocol !== "https:" || url.username || url.password || url.port) return null + + const hostname = url.hostname.toLowerCase() + const isInstagramHost = hostname === "instagram.com" || hostname.endsWith(".instagram.com") + return isInstagramHost || SUPPORTED_PREVIEW_HOSTS.has(hostname) ? url.toString() : null +} + export function buildOpenGraphMetaTags(input: OpenGraphInput): string { const title = escapeHtml(collapseWhitespace(input.title)) const description = escapeHtml(collapseWhitespace(input.description)) const siteName = escapeHtml(collapseWhitespace(input.siteName)) const url = normalizeAbsoluteHttpUrl(input.url) || "" const image = normalizeAbsoluteHttpUrl(input.image) || "" - const type = collapseWhitespace(input.type || "website") + const type = escapeHtml(collapseWhitespace(input.type || "website")) const twitterSite = collapseWhitespace(input.twitterSite) + const twitterCard = input.twitterCard === "summary_large_image" ? "summary_large_image" : "summary" const lines = [ ``, @@ -50,7 +65,7 @@ export function buildOpenGraphMetaTags(input: OpenGraphInput): string { url ? `` : "", image ? `` : "", siteName ? `` : "", - ``, + ``, ``, ``, image ? `` : "", diff --git a/src/features/tools/pipeline-builder/page.tsx b/src/features/tools/pipeline-builder/page.tsx index 9b75eb84..832007cd 100644 --- a/src/features/tools/pipeline-builder/page.tsx +++ b/src/features/tools/pipeline-builder/page.tsx @@ -67,7 +67,7 @@ export function PipelineBuilderPage() { const [importError, setImportError] = React.useState(null) const [actionAnnouncement, setActionAnnouncement] = React.useState("") const [onboardingDismissed, setOnboardingDismissed] = React.useState(false) - + const runRequestRef = React.useRef(0) const selectedStep = recipe.steps.find((step) => step.id === selectedStepId) ?? recipe.steps[0] ?? null const validation = React.useMemo(() => validateRecipe(recipe), [recipe]) const compatibilityHints = React.useMemo(() => getStepCompatibilityHints(recipe.steps), [recipe.steps]) @@ -105,11 +105,16 @@ export function PipelineBuilderPage() { void refreshSavedRecipes() }, [refreshSavedRecipes, storageAvailable]) + const invalidateExecution = React.useCallback(() => { + runRequestRef.current += 1 + setResult(null) + setIsRunning(false) + }, []) const updateRecipe = React.useCallback((updater: (current: RecipeDocument) => RecipeDocument) => { setRecipe((current) => updateRecipeTimestamp(updater(current))) - setResult(null) + invalidateExecution() setImportError(null) - }, []) + }, [invalidateExecution]) const addStep = React.useCallback(() => { const step = createStep(pendingToolKey) @@ -186,10 +191,10 @@ export function PipelineBuilderPage() { setRecipe(generated.recipe) setInitialInput(generated.initialInput) setSelectedStepId(generated.recipe.steps[0]?.id ?? null) - setResult(null) + invalidateExecution() setImportError(null) toast.success(text("template_loaded")) - }, [lang, text]) + }, [invalidateExecution, lang, text]) React.useEffect(() => { const params = new URLSearchParams(window.location.search) @@ -229,9 +234,11 @@ export function PipelineBuilderPage() { }, [recipe.steps, selectedStepId]) const runCurrentRecipe = React.useCallback(async () => { + const requestId = ++runRequestRef.current setIsRunning(true) try { const execution = await runRecipe(recipe, initialInput) + if (requestId !== runRequestRef.current) return setResult(execution) if (execution.ok) { toast.success(text("run_complete")) @@ -239,7 +246,7 @@ export function PipelineBuilderPage() { toast.error(text("run_failed")) } } finally { - setIsRunning(false) + if (requestId === runRequestRef.current) setIsRunning(false) } }, [initialInput, recipe, text]) @@ -273,9 +280,9 @@ export function PipelineBuilderPage() { } setRecipe(loadResult.value.recipe) setSelectedStepId(loadResult.value.recipe.steps[0]?.id ?? null) - setResult(null) + invalidateExecution() toast.success(text("recipe_loaded")) - }, [selectedSavedId, storageAvailable, text]) + }, [invalidateExecution, selectedSavedId, storageAvailable, text]) const deleteRecipe = React.useCallback(async () => { if (!storageAvailable || !selectedSavedId) return @@ -302,10 +309,10 @@ export function PipelineBuilderPage() { } setRecipe(imported.recipe) setSelectedStepId(imported.recipe.steps[0]?.id ?? null) - setResult(null) + invalidateExecution() setImportError(null) setActionAnnouncement(imported.announcement) - }, [text]) + }, [invalidateExecution, text]) const performShareRecipe = React.useCallback(async () => { return sharePipelineRecipe(recipe, lang, t, text, setActionAnnouncement) @@ -488,7 +495,7 @@ export function PipelineBuilderPage() { value={initialInput} onChange={(event) => { setInitialInput(event.target.value) - setResult(null) + invalidateExecution() }} placeholder={text("initial_input_placeholder")} className="font-mono text-sm" diff --git a/src/features/tools/qr-code-generator/browser-actions.ts b/src/features/tools/qr-code-generator/browser-actions.ts index 13696552..31cacb8a 100644 --- a/src/features/tools/qr-code-generator/browser-actions.ts +++ b/src/features/tools/qr-code-generator/browser-actions.ts @@ -17,6 +17,16 @@ export async function loadToast() { return toastPromise } +export function createQrRenderCanvas(): HTMLCanvasElement { + return document.createElement("canvas") +} + +export function commitQrRenderCanvas(source: HTMLCanvasElement, target: HTMLCanvasElement): void { + target.width = source.width + target.height = source.height + target.getContext("2d")?.drawImage(source, 0, 0) +} + export function drawRoundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) { ctx.beginPath() ctx.moveTo(x + radius, y) diff --git a/src/features/tools/qr-code-generator/page.tsx b/src/features/tools/qr-code-generator/page.tsx index d92d23df..e4b8c976 100644 --- a/src/features/tools/qr-code-generator/page.tsx +++ b/src/features/tools/qr-code-generator/page.tsx @@ -11,6 +11,8 @@ import { FILE_INPUT_POLICIES, validateFileAgainstPolicy } from "@/core/files/fil import { parseSafeExternalUrl } from "@/core/security/external-url" import { buildQrSvg, + commitQrRenderCanvas, + createQrRenderCanvas, decodeQrImageFile, downloadCanvasPng, downloadSvg as downloadSvgFile, @@ -74,24 +76,27 @@ export function QrCodeGeneratorPage() { const logoInputRef = React.useRef(null) const decodeInputRef = React.useRef(null) const decodeRequestRef = React.useRef(0) + const renderRequestRef = React.useRef(0) const decodedUrl = React.useMemo(() => { const parsed = parseSafeExternalUrl(decodedPayload, { requireHttps: false }) return parsed.ok ? parsed.url : null }, [decodedPayload]) - const renderToCanvas = React.useCallback(async () => { + const renderToCanvas = React.useCallback(async (requestId: number) => { if (!text.trim()) { - setDataUrl("") + if (requestId === renderRequestRef.current) setDataUrl("") return } - const canvas = canvasRef.current - if (!canvas) return + const visibleCanvas = canvasRef.current + if (!visibleCanvas) return + const renderCanvas = createQrRenderCanvas() const qrCode = await loadQRCode() + if (requestId !== renderRequestRef.current) return - await qrCode.toCanvas(canvas, text, { + await qrCode.toCanvas(renderCanvas, text, { width: size, margin, errorCorrectionLevel, @@ -100,11 +105,13 @@ export function QrCodeGeneratorPage() { light: bgColor, }, }) + if (requestId !== renderRequestRef.current) return if (logoEnabled && logoDataUrl) { - const ctx = canvas.getContext("2d") + const ctx = renderCanvas.getContext("2d") if (ctx) { const img = await loadImage(logoDataUrl) + if (requestId !== renderRequestRef.current) return const logoSize = Math.floor((size * logoScale) / 100) const x = (size - logoSize) / 2 const y = (size - logoSize) / 2 @@ -120,22 +127,24 @@ export function QrCodeGeneratorPage() { } } - setDataUrl(canvas.toDataURL("image/png")) + if (requestId !== renderRequestRef.current) return + commitQrRenderCanvas(renderCanvas, visibleCanvas) + setDataUrl(renderCanvas.toDataURL("image/png")) }, [bgColor, errorCorrectionLevel, fgColor, logoDataUrl, logoEnabled, logoScale, margin, size, text]) React.useEffect(() => { - let cancelled = false + const requestId = ++renderRequestRef.current void (async () => { try { - await renderToCanvas() + await renderToCanvas(requestId) } catch { - if (!cancelled) { + if (requestId === renderRequestRef.current) { setDataUrl("") } } })() return () => { - cancelled = true + if (requestId === renderRequestRef.current) renderRequestRef.current += 1 } }, [renderToCanvas]) diff --git a/src/features/tools/svg-blob-generator/utils.ts b/src/features/tools/svg-blob-generator/utils.ts index c472494d..454275b8 100644 --- a/src/features/tools/svg-blob-generator/utils.ts +++ b/src/features/tools/svg-blob-generator/utils.ts @@ -5,6 +5,18 @@ export type BlobConfig = { seed: number } +const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i +const DEFAULT_FILL = "#22d3ee" +const DEFAULT_STROKE = "#0f172a" + +export function normalizeSvgColor(value: string, fallback: string): string { + const normalized = value.trim() + if (HEX_COLOR_PATTERN.test(normalized)) return normalized + + const normalizedFallback = fallback.trim() + return HEX_COLOR_PATTERN.test(normalizedFallback) ? normalizedFallback : "#000000" +} + function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min return Math.max(min, Math.min(max, value)) @@ -54,9 +66,11 @@ export function buildBlobPath(config: BlobConfig): string { export function buildBlobSvg(path: string, size: number, fill: string, stroke: string): string { const safeSize = Math.round(clamp(size, 64, 1024)) + const safeFill = normalizeSvgColor(fill, DEFAULT_FILL) + const safeStroke = normalizeSvgColor(stroke, DEFAULT_STROKE) return [ ``, - ` `, + ` `, "", ].join("\n") } diff --git a/src/features/tools/svg-pattern-generator/utils.ts b/src/features/tools/svg-pattern-generator/utils.ts index 79be6104..9aa00f21 100644 --- a/src/features/tools/svg-pattern-generator/utils.ts +++ b/src/features/tools/svg-pattern-generator/utils.ts @@ -11,6 +11,18 @@ export type PatternConfig = { height: number } +const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i +const DEFAULT_FOREGROUND = "#22d3ee" +const DEFAULT_BACKGROUND = "#020617" + +export function normalizeSvgColor(value: string, fallback: string): string { + const normalized = value.trim() + if (HEX_COLOR_PATTERN.test(normalized)) return normalized + + const normalizedFallback = fallback.trim() + return HEX_COLOR_PATTERN.test(normalizedFallback) ? normalizedFallback : "#000000" +} + function clamp(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min return Math.max(min, Math.min(max, value)) @@ -22,20 +34,21 @@ export function buildPatternShape(config: PatternConfig): string { const strokeWidth = clamp(config.strokeWidth, 0.5, 24) const halfGap = gap / 2 const inner = Math.max(1, size - gap) + const foreground = normalizeSvgColor(config.foreground, DEFAULT_FOREGROUND) if (config.kind === "grid") { return [ - ``, - ``, + ``, + ``, ].join("\n") } if (config.kind === "diagonal") { - return `` + return `` } const radius = clamp(inner * 0.2, 1, size * 0.45) - return `` + return `` } export function buildPatternSvg(config: PatternConfig): string { @@ -43,12 +56,13 @@ export function buildPatternSvg(config: PatternConfig): string { const height = clamp(Math.round(config.height), 64, 2048) const size = clamp(Math.round(config.tileSize), 8, 256) const shape = buildPatternShape(config) + const background = normalizeSvgColor(config.background, DEFAULT_BACKGROUND) return [ ``, " ", ` `, - ` `, + ` `, shape.split("\n").map((line) => ` ${line}`).join("\n"), " ", " ", diff --git a/src/features/tools/url-parser/page.test.ts b/src/features/tools/url-parser/page.test.ts new file mode 100644 index 00000000..72bf62fe --- /dev/null +++ b/src/features/tools/url-parser/page.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest" +import { buildUrl } from "./page" + +const params = [{ id: "one", key: "q", value: "value" }] + +describe("URL parser reconstruction", () => { + it("keeps ports that are non-default for the selected protocol", () => { + expect(buildUrl("https", "example.com", "80", "/path", params, "", "", "")) + .toBe("https://example.com:80/path?q=value") + expect(buildUrl("http", "example.com", "443", "/path", [], "", "", "")) + .toBe("http://example.com:443/path") + }) + + it("omits only the protocol's default port", () => { + expect(buildUrl("http", "example.com", "80", "/", [], "", "", "")) + .toBe("http://example.com/") + expect(buildUrl("https:", "example.com", "443", "/", [], "", "", "")) + .toBe("https://example.com/") + }) +}) diff --git a/src/features/tools/url-parser/page.tsx b/src/features/tools/url-parser/page.tsx index aa3d5a16..7b2632f4 100644 --- a/src/features/tools/url-parser/page.tsx +++ b/src/features/tools/url-parser/page.tsx @@ -53,7 +53,7 @@ function extractParams(raw: string): QueryParam[] { } } -function buildUrl( +export function buildUrl( protocol: string, host: string, port: string, @@ -64,12 +64,13 @@ function buildUrl( password: string, ): string { try { - const scheme = protocol || "https" + const scheme = (protocol || "https").replace(/:$/, "").toLowerCase() let userinfo = "" if (username) { userinfo = password ? `${username}:${password}@` : `${username}@` } - const portPart = port && !["80", "443", ""].includes(port) ? `:${port}` : "" + const isDefaultPort = (scheme === "http" && port === "80") || (scheme === "https" && port === "443") + const portPart = port && !isDefaultPort ? `:${port}` : "" const path = pathname.startsWith("/") ? pathname : `/${pathname}` const qs = params.filter((p) => p.key).map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value)}`).join("&") const hashPart = hash ? `#${hash}` : "" diff --git a/src/generated/client-tool-lookup.ts b/src/generated/client-tool-lookup.ts index 79a9c719..0693a5b5 100644 --- a/src/generated/client-tool-lookup.ts +++ b/src/generated/client-tool-lookup.ts @@ -2851,28 +2851,40 @@ const CLIENT_TOOL_LOOKUP: Record = { "affiliationDisclaimer": "Open Graph, X (Twitter), Instagram, YouTube, and Vimeo names are used descriptively only." }, "privacy": { - "executionMode": "browser-local", - "offlineCapable": true, + "executionMode": "external-request", + "offlineCapable": false, "sensitiveInput": false, "externalRequest": { - "required": false, - "endpointType": "none" + "required": true, + "endpointType": "user_provided_url", + "domains": [ + "instagram.com", + "i.ytimg.com", + "vumbnail.com" + ], + "purposeKey": "thumbnail_preview", + "userDataSent": "user_provided_url", + "disclosure": "Loads a supported public image URL you provide only after you confirm and choose Preview.", + "consentRequired": true } }, - "networkAccess": "none", - "networkHosts": [], - "networkPurposeKey": null, - "allowUserProvidedUrl": null, - "requiresExplicitUserAction": null, - "externalDataSent": null, + "networkAccess": "user_requested", + "networkHosts": [ + "instagram.com", + "i.ytimg.com", + "vumbnail.com" + ], + "networkPurposeKey": "thumbnail_preview", + "allowUserProvidedUrl": true, + "requiresExplicitUserAction": true, + "externalDataSent": "user_provided_url", "persistInput": null, "family": "social-metadata", "tags": [ "social-metadata" ], "capabilities": [ - "browser-local", - "offline-capable", + "external-request", "visual-output" ] }, diff --git a/src/generated/command-search-index.ts b/src/generated/command-search-index.ts index e8c9213a..1f72b113 100644 --- a/src/generated/command-search-index.ts +++ b/src/generated/command-search-index.ts @@ -1303,8 +1303,7 @@ const COMMAND_SEARCH_INDEX: Record = { "social-metadata" ], "capabilities": [ - "browser-local", - "offline-capable", + "external-request", "visual-output" ] }, diff --git a/src/generated/discovery-tool-index.ts b/src/generated/discovery-tool-index.ts index 6f87eb61..44c11e6b 100644 --- a/src/generated/discovery-tool-index.ts +++ b/src/generated/discovery-tool-index.ts @@ -1481,8 +1481,7 @@ const DISCOVERY_TOOL_INDEX: Record = { "social-metadata" ], "capabilities": [ - "browser-local", - "offline-capable", + "external-request", "visual-output" ] }, diff --git a/src/generated/route-tool-lookup.ts b/src/generated/route-tool-lookup.ts index f1495484..cc279987 100644 --- a/src/generated/route-tool-lookup.ts +++ b/src/generated/route-tool-lookup.ts @@ -1031,19 +1031,32 @@ const ROUTE_TOOL_LOOKUP: Record = { "key": "open_graph_meta_generator", "slug": "open-graph-meta-generator", "privacy": { - "executionMode": "browser-local", - "offlineCapable": true, + "executionMode": "external-request", + "offlineCapable": false, "sensitiveInput": false, "externalRequest": { - "required": false, - "endpointType": "none" + "required": true, + "endpointType": "user_provided_url", + "domains": [ + "instagram.com", + "i.ytimg.com", + "vumbnail.com" + ], + "purposeKey": "thumbnail_preview", + "userDataSent": "user_provided_url", + "disclosure": "Loads a supported public image URL you provide only after you confirm and choose Preview.", + "consentRequired": true } }, - "networkAccess": "none", - "networkHosts": [], - "networkPurposeKey": null, - "requiresExplicitUserAction": null, - "externalDataSent": null, + "networkAccess": "user_requested", + "networkHosts": [ + "instagram.com", + "i.ytimg.com", + "vumbnail.com" + ], + "networkPurposeKey": "thumbnail_preview", + "requiresExplicitUserAction": true, + "externalDataSent": "user_provided_url", "compliance": { "platformName": "Open Graph and social cards", "rightsGuidance": "Generate metadata for pages, images, and descriptions you control or have rights to publish.", diff --git a/src/generated/route-width-inventory.json b/src/generated/route-width-inventory.json index 4013758c..65caacfd 100644 --- a/src/generated/route-width-inventory.json +++ b/src/generated/route-width-inventory.json @@ -70,7 +70,7 @@ ] }, "summary": { - "scannedFiles": 946, + "scannedFiles": 951, "pageRoots": 369, "toolSurfaces": 131, "standardTools": 82, @@ -2496,7 +2496,7 @@ "file": "src/app/page.tsx", "component": "RootPage", "element": "LangProvider", - "line": 60 + "line": 62 }, { "file": "src/core/seo/components/growth-content-pages.tsx", @@ -2580,7 +2580,7 @@ "file": "src/features/tools/certificate-decoder/page.tsx", "component": "CertificateDecoderPage", "element": "ToolPageContainer", - "line": 322 + "line": 358 }, { "file": "src/features/tools/chmod-calculator/page.tsx", @@ -2622,7 +2622,7 @@ "file": "src/features/tools/cron-visualizer/page.tsx", "component": "CronVisualizerPage", "element": "ToolPageContainer", - "line": 130 + "line": 66 }, { "file": "src/features/tools/crontab-generator/page.tsx", @@ -3066,7 +3066,7 @@ "file": "src/features/tools/open-graph-meta-generator/page.tsx", "component": "OpenGraphMetaGeneratorPage", "element": "ToolPageContainer", - "line": 87 + "line": 150 }, { "file": "src/features/tools/openapi-diff/page.tsx", @@ -3102,7 +3102,7 @@ "file": "src/features/tools/pipeline-builder/page.tsx", "component": "PipelineBuilderPage", "element": "WideToolPageContainer", - "line": 357 + "line": 364 }, { "file": "src/features/tools/public-key-jwk-helper/page.tsx", @@ -3114,7 +3114,7 @@ "file": "src/features/tools/qr-code-generator/page.tsx", "component": "QrCodeGeneratorPage", "element": "ToolPageContainer", - "line": 368 + "line": 377 }, { "file": "src/features/tools/react-native-shadow-generator/page.tsx", @@ -3270,7 +3270,7 @@ "file": "src/features/tools/url-parser/page.tsx", "component": "UrlParserPage", "element": "ToolPageContainer", - "line": 197 + "line": 198 }, { "file": "src/features/tools/user-agent-parser/page.tsx", diff --git a/src/generated/tool-index.json b/src/generated/tool-index.json index 32c54753..bd58fbcf 100644 --- a/src/generated/tool-index.json +++ b/src/generated/tool-index.json @@ -2299,20 +2299,33 @@ "affiliationDisclaimer": "Open Graph, X (Twitter), Instagram, YouTube, and Vimeo names are used descriptively only." }, "privacy": { - "executionMode": "browser-local", - "offlineCapable": true, + "executionMode": "external-request", + "offlineCapable": false, "sensitiveInput": false, "externalRequest": { - "required": false, - "endpointType": "none" + "required": true, + "endpointType": "user_provided_url", + "domains": [ + "instagram.com", + "i.ytimg.com", + "vumbnail.com" + ], + "purposeKey": "thumbnail_preview", + "userDataSent": "user_provided_url", + "disclosure": "Loads a supported public image URL you provide only after you confirm and choose Preview.", + "consentRequired": true } }, - "networkAccess": "none", - "networkHosts": [], - "networkPurposeKey": null, - "allowUserProvidedUrl": null, - "requiresExplicitUserAction": null, - "externalDataSent": null, + "networkAccess": "user_requested", + "networkHosts": [ + "instagram.com", + "i.ytimg.com", + "vumbnail.com" + ], + "networkPurposeKey": "thumbnail_preview", + "allowUserProvidedUrl": true, + "requiresExplicitUserAction": true, + "externalDataSent": "user_provided_url", "persistInput": null, "updatedAt": null, "sourceFile": "src/features/tools/open-graph-meta-generator/manifest.ts" diff --git a/src/lib/sitemap-lastmod.json b/src/lib/sitemap-lastmod.json index 2179b7c3..042d9e4d 100644 --- a/src/lib/sitemap-lastmod.json +++ b/src/lib/sitemap-lastmod.json @@ -1239,13 +1239,13 @@ "fr": "2026-06-24T00:00:00.000Z" }, "open-graph-meta-generator": { - "en": "2026-06-24T00:00:00.000Z", - "zh-CN": "2026-06-24T00:00:00.000Z", - "zh-TW": "2026-06-24T00:00:00.000Z", - "ja": "2026-06-24T00:00:00.000Z", - "ko": "2026-06-24T00:00:00.000Z", - "de": "2026-06-24T00:00:00.000Z", - "fr": "2026-06-24T00:00:00.000Z" + "en": "2026-07-26T00:00:00.000Z", + "zh-CN": "2026-07-26T00:00:00.000Z", + "zh-TW": "2026-07-26T00:00:00.000Z", + "ja": "2026-07-26T00:00:00.000Z", + "ko": "2026-07-26T00:00:00.000Z", + "de": "2026-07-26T00:00:00.000Z", + "fr": "2026-07-26T00:00:00.000Z" }, "tweet-generator": { "en": "2026-06-24T00:00:00.000Z", diff --git a/tests/component/external-request-media-tools.test.tsx b/tests/component/external-request-media-tools.test.tsx index a2d0b35b..51b28c02 100644 --- a/tests/component/external-request-media-tools.test.tsx +++ b/tests/component/external-request-media-tools.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { LangProvider } from "@/core/i18n/lang-provider" import { getTranslation } from "@/core/i18n/translations/catalog" import { InstagramPhotoDownloaderPage } from "@/features/tools/instagram-photo-downloader/page" +import { OpenGraphMetaGeneratorPage } from "@/features/tools/open-graph-meta-generator/page" import { VimeoThumbnailGrabberPage } from "@/features/tools/vimeo-thumbnail-grabber/page" import { YouTubeThumbnailGrabberPage } from "@/features/tools/youtube-thumbnail-grabber/page" @@ -128,6 +129,33 @@ describe("external request media tools", () => { expect(globalThis.fetch).not.toHaveBeenCalled() }) + it("loads an Open Graph image only after confirmation and an explicit preview action", () => { + renderEnglish() + + expect(screen.queryByAltText("Image preview")).not.toBeInTheDocument() + + const imageUrlInput = screen.getByRole("textbox", { name: "Image URL" }) + fireEvent.change(imageUrlInput, { target: { value: "https://i.ytimg.com/vi/id/image.jpg" } }) + + expect(screen.queryByAltText("Image preview")).not.toBeInTheDocument() + expect(screen.getByRole("button", { + name: "Preview", + description: "Confirm the external request before previewing or downloading.", + })).toBeDisabled() + + fireEvent.click(screen.getByLabelText("I understand this action may request the disclosed external asset from my browser.")) + fireEvent.click(screen.getByRole("button", { name: "Preview" })) + + expect(screen.getByAltText("Image preview")).toHaveAttribute("src", "https://i.ytimg.com/vi/id/image.jpg") + expect(screen.getByAltText("Image preview")).toHaveAttribute("referrerpolicy", "no-referrer") + expect(globalThis.fetch).not.toHaveBeenCalled() + + fireEvent.change(imageUrlInput, { target: { value: "https://vumbnail.com/123.jpg" } }) + + expect(screen.queryByAltText("Image preview")).not.toBeInTheDocument() + expect(screen.getByLabelText("I understand this action may request the disclosed external asset from my browser.")).not.toBeChecked() + }) + it("shows a graceful offline error before Vimeo download opens a remote asset", async () => { const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined) renderEnglish() diff --git a/tests/component/pipeline-builder-stale-run.test.tsx b/tests/component/pipeline-builder-stale-run.test.tsx new file mode 100644 index 00000000..bc478ba1 --- /dev/null +++ b/tests/component/pipeline-builder-stale-run.test.tsx @@ -0,0 +1,57 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { LangProvider } from "@/core/i18n/lang-provider" +import { getTranslation } from "@/core/i18n/translations/catalog" +import PipelineBuilderPage from "@/app/[lang]/pipeline-builder/page" +import type { PipelineExecutionResult } from "@/features/pipeline/recipe-types" + +const runRecipeMock = vi.hoisted(() => vi.fn()) +const toastSuccessMock = vi.hoisted(() => vi.fn()) + +vi.mock("next/navigation", () => ({ + usePathname: () => "/en/pipeline-builder", +})) + +vi.mock("@/core/analytics/analytics", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, trackPipelineTemplateOpened: vi.fn() } +}) + +vi.mock("@/features/pipeline/executor", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, runRecipe: runRecipeMock } +}) + +vi.mock("sonner", () => ({ + toast: { error: vi.fn(), info: vi.fn(), success: toastSuccessMock }, +})) + +describe("pipeline builder async runs", () => { + beforeEach(() => { + vi.clearAllMocks() + window.history.replaceState(null, "", "/en/pipeline-builder") + }) + + it("discards a completed run after its input changes", async () => { + let finishRun: ((result: PipelineExecutionResult) => void) | undefined + runRecipeMock.mockReturnValue(new Promise((resolve) => { + finishRun = resolve + })) + render( + + + , + ) + fireEvent.click(screen.getAllByRole("button", { name: "Use" })[0]) + fireEvent.click(screen.getByRole("button", { name: /Run Recipe/i })) + await waitFor(() => expect(runRecipeMock).toHaveBeenCalledOnce()) + + fireEvent.change(screen.getByLabelText("Initial input"), { target: { value: "new input" } }) + await act(async () => { + finishRun?.({ ok: true, finalOutput: "stale output", steps: [], errors: [], warnings: [] }) + }) + + expect(screen.getByPlaceholderText("Run the recipe to generate output...")).toHaveValue("") + expect(toastSuccessMock).not.toHaveBeenCalledWith("Recipe completed") + }) +}) diff --git a/tests/component/privacy-page.test.tsx b/tests/component/privacy-page.test.tsx index 59a5dfff..ae01bd3e 100644 --- a/tests/component/privacy-page.test.tsx +++ b/tests/component/privacy-page.test.tsx @@ -65,7 +65,7 @@ describe("PrivacyPage", () => { expect(screen.getAllByText("Generate and preview public thumbnail image URLs derived from the video link you enter.").length).toBeGreaterThan(0) expect(screen.getByText("Download media from a URL you provide after you confirm you are allowed to use it.")).toBeInTheDocument() expect(screen.getAllByText("A derived public asset URL may be requested by your browser.").length).toBeGreaterThan(0) - expect(screen.getByText("The URL you provide may be requested by your browser.")).toBeInTheDocument() + expect(screen.getAllByText("The URL you provide may be requested by your browser.").length).toBeGreaterThan(0) expect(screen.getAllByText("Network access starts only after you choose the external-request action.").length).toBeGreaterThan(0) }) diff --git a/tests/component/qr-code-generator-logo-upload.test.tsx b/tests/component/qr-code-generator-logo-upload.test.tsx index fd14ab4b..dcf4146f 100644 --- a/tests/component/qr-code-generator-logo-upload.test.tsx +++ b/tests/component/qr-code-generator-logo-upload.test.tsx @@ -31,6 +31,10 @@ vi.mock("next/navigation", () => ({ vi.mock("@/features/tools/qr-code-generator/browser-actions", () => ({ buildQrSvg: vi.fn(), + commitQrRenderCanvas: (source: HTMLCanvasElement, target: HTMLCanvasElement) => { + target.dataset.payload = source.dataset.payload + }, + createQrRenderCanvas: () => document.createElement("canvas"), decodeQrImageFile: (file: File) => decodeQrImageFileMock(file), downloadCanvasPng: vi.fn(), downloadDataUrl: vi.fn(), @@ -165,6 +169,39 @@ describe("QR code generator logo uploads", () => { expect(drainQueuedToastFeedback()).toEqual([]) }) + it("does not let an older async render overwrite the latest QR", async () => { + let finishOldRender: (() => void) | undefined + const oldRender = new Promise((resolve) => { + finishOldRender = resolve + }) + toCanvasMock.mockImplementation(async (canvas: HTMLCanvasElement, value: string) => { + if (value === "old payload") await oldRender + canvas.dataset.payload = value + }) + HTMLCanvasElement.prototype.toDataURL = vi.fn(function (this: HTMLCanvasElement) { + return `data:image/png;base64,${this.dataset.payload}` + }) + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }) + const { container } = renderPage() + const content = container.querySelector("#qr-content") + + fireEvent.change(content!, { target: { value: "old payload" } }) + await waitFor(() => expect(toCanvasMock).toHaveBeenCalledWith(expect.any(HTMLCanvasElement), "old payload", expect.any(Object))) + fireEvent.change(content!, { target: { value: "new payload" } }) + + const copyButton = screen.getByRole("button", { name: "Copy" }) + await waitFor(() => expect(copyButton).toBeEnabled()) + finishOldRender?.() + await waitFor(() => expect(toCanvasMock).toHaveBeenCalledTimes(2)) + fireEvent.click(copyButton) + + await waitFor(() => expect(writeText).toHaveBeenLastCalledWith("data:image/png;base64,new payload")) + }) + it("decodes an uploaded QR image without opening URL payloads automatically", async () => { const open = vi.spyOn(window, "open") decodeQrImageFileMock.mockResolvedValueOnce({ diff --git a/tests/component/trust-center-page.test.tsx b/tests/component/trust-center-page.test.tsx index 7d3658dd..3ba2f401 100644 --- a/tests/component/trust-center-page.test.tsx +++ b/tests/component/trust-center-page.test.tsx @@ -39,9 +39,12 @@ describe("TrustCenterPage", () => { for (const { tool, hosts } of externalTools) { const title = toolCopy[tool.key]?.title ?? tool.slug - expect(within(table).getByRole("link", { name: title })).toHaveAttribute("href", `/en/${tool.slug}`) + const toolLink = within(table).getByRole("link", { name: title }) + expect(toolLink).toHaveAttribute("href", `/en/${tool.slug}`) + const row = toolLink.closest("tr") + expect(row).not.toBeNull() for (const domain of hosts) { - expect(within(table).getByText(new RegExp(domain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))).toBeInTheDocument() + expect(within(row!).getByText(new RegExp(domain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))).toBeInTheDocument() } } }) diff --git a/tests/guards/external-request-confirmation-guard.test.ts b/tests/guards/external-request-confirmation-guard.test.ts index 3ca1d991..bafb3119 100644 --- a/tests/guards/external-request-confirmation-guard.test.ts +++ b/tests/guards/external-request-confirmation-guard.test.ts @@ -12,6 +12,7 @@ describe("external request confirmation guard", () => { "src/features/tools/youtube-thumbnail-grabber/page.tsx", "src/features/tools/vimeo-thumbnail-grabber/page.tsx", "src/features/tools/instagram-photo-downloader/page.tsx", + "src/features/tools/open-graph-meta-generator/page.tsx", ] expect(component).toContain("confirm_title") diff --git a/tests/guards/external-request-taxonomy.test.ts b/tests/guards/external-request-taxonomy.test.ts index 48ab595b..3ca6659b 100644 --- a/tests/guards/external-request-taxonomy.test.ts +++ b/tests/guards/external-request-taxonomy.test.ts @@ -4,6 +4,7 @@ import { getClientToolByKey } from "@/generated/client-tool-lookup" const EXPECTED_EXTERNAL_REQUEST_TOOLS = [ "instagram-photo-downloader", + "open-graph-meta-generator", "vimeo-thumbnail-grabber", "youtube-thumbnail-grabber", ] diff --git a/tests/guards/hash-worker-guard.test.ts b/tests/guards/hash-worker-guard.test.ts index 8df0f1bb..02031065 100644 --- a/tests/guards/hash-worker-guard.test.ts +++ b/tests/guards/hash-worker-guard.test.ts @@ -39,7 +39,8 @@ describe("hash worker guard", () => { expect(hookSource).toContain("controller.abort()") expect(taskSource).toContain("new Worker(new URL(\"./hash-worker.ts\", import.meta.url)") expect(taskSource).toContain("signal: options.signal") - expect(taskSource).toContain("transfer: input.mode === \"file\" ? [input.bytes.buffer] : undefined") + expect(taskSource).toContain('{ mode: "file", bytes: input.bytes.slice() }') + expect(taskSource).toContain("transfer: workerInput.mode === \"file\" ? [workerInput.bytes.buffer] : undefined") expect(taskSource).toContain("runHashTaskSync(input)") expect(workerSource).toContain("runHashTaskSync(event.data)") }) diff --git a/tests/guards/next-config-bundle-analyzer.test.ts b/tests/guards/next-config-bundle-analyzer.test.ts index 4f46341e..3b59ef96 100644 --- a/tests/guards/next-config-bundle-analyzer.test.ts +++ b/tests/guards/next-config-bundle-analyzer.test.ts @@ -16,7 +16,9 @@ describe("next config bundle analyzer wiring", () => { const packageJsonPath = path.join(process.cwd(), "package.json") const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) - expect(packageJson.scripts?.analyze).toBe("ANALYZE=true next build --webpack") + expect(packageJson.scripts?.analyze).toBe("node scripts/build/run-bundle-analyzer.js") + expect(fs.readFileSync(path.join(process.cwd(), "scripts/build/run-bundle-analyzer.js"), "utf8")) + .toContain('ANALYZE: "true"') expect(packageJson.scripts?.["check:bundle-boundaries"]).toBe("node scripts/gates/check-bundle-boundaries.js") expect(packageJson.scripts?.validate).toContain("npm run check:bundle-boundaries") expect(packageJson.devDependencies?.["@next/bundle-analyzer"]).toBeTruthy() diff --git a/tests/guards/next-postcss-advisory-runbook.test.ts b/tests/guards/next-postcss-advisory-runbook.test.ts index be5706b1..a005054b 100644 --- a/tests/guards/next-postcss-advisory-runbook.test.ts +++ b/tests/guards/next-postcss-advisory-runbook.test.ts @@ -21,28 +21,30 @@ describe("issue #7 Next bundled PostCSS advisory runbook", () => { expect(doc).toContain("issue #7") expect(doc).toContain("GHSA-qx2v-qp2m-jg93") - expect(doc).toContain("postcss >= 8.5.10") - expect(doc).toContain("Checked on 2026-07-16") - expect(doc).toContain("16.2.10") + expect(doc).toContain("postcss >= 8.5.18") + expect(doc).toContain("Checked on 2026-07-26") + expect(doc).toContain("16.2.12") expect(doc).toContain("8.4.31") + expect(doc).toContain("0.35.3") expect(doc).toContain("controlled npm override") expect(doc).toContain("0 moderate/high/critical") expect(doc).toContain("npm run check:audit:prod") expect(doc).toContain("npm run check:audit:prod-high") expect(doc).toContain("npm run test:e2e:pwa") expect(doc).toContain("Do not run `npm audit fix --force`") - expect(packageJson.dependencies.next).toBe("16.2.10") - expect(packageJson.devDependencies["@next/bundle-analyzer"]).toBe("16.2.10") - expect(packageJson.devDependencies["eslint-config-next"]).toBe("16.2.10") + expect(packageJson.dependencies.next).toBe("16.2.12") + expect(packageJson.devDependencies["@next/bundle-analyzer"]).toBe("16.2.12") + expect(packageJson.devDependencies["eslint-config-next"]).toBe("16.2.12") expect(packageJson.overrides).toMatchObject({ - next: { postcss: "8.5.10" }, + next: { postcss: "8.5.23" }, + sharp: "0.35.3", }) expect(packageJson.scripts["check:audit:prod"]).toBe( "npm audit --omit=dev --audit-level=moderate", ) expect(ci).toMatch(/^\s*run: npm run check:audit:prod\s*$/m) - expect(packageLock.packages["node_modules/next/node_modules/postcss"]?.version).toBe( - "8.5.10", + expect(packageLock.packages["node_modules/postcss"]?.version).toBe( + "8.5.23", ) }) }) diff --git a/tests/guards/offline-fallback-page.test.ts b/tests/guards/offline-fallback-page.test.ts index 6f0d7934..fbb340c4 100644 --- a/tests/guards/offline-fallback-page.test.ts +++ b/tests/guards/offline-fallback-page.test.ts @@ -15,7 +15,7 @@ describe("offline fallback page", () => { it("keeps service worker offline fallback coverage for uncached routes", () => { const source = fs.readFileSync(path.join(process.cwd(), "public", "sw.js"), "utf8") - expect(source).toContain("const OFFLINE_FALLBACK_CANDIDATES = [OFFLINE_FALLBACK_URL, '/offline']") + expect(source).toContain("const OFFLINE_FALLBACK_CANDIDATES = [OFFLINE_FALLBACK_URL]") expect(source).toContain("function matchOfflineFallback()") expect(source).toContain("cached || matchOfflineFallback()") expect(source).toContain("new Response(") @@ -36,7 +36,12 @@ describe("offline fallback page", () => { expect(source).toContain("'signature'") expect(source).toContain("param.toLowerCase()") expect(source).toContain("function hasSensitiveQuery(url)") + expect(source).toContain("function hasUncacheableQuery(url)") expect(source).toContain("hasSensitiveQuery(url) ||") + expect(source).toContain("hasUncacheableQuery(url) ||") + expect(source).toContain("function cacheRequest(request)") + expect(source).toContain("url.search = ''") + expect(source).toContain("cache.put(canonicalRequest, clone)") expect(source).toContain("if (isNetworkOnlyRequest(event.request, url)) return;") }) @@ -51,6 +56,8 @@ describe("offline fallback page", () => { expect(source).toContain("const RUNTIME_PAGE_CACHE_NAME = `byteflow-runtime-pages-v${APP_VERSION}`") expect(source).toContain("const ACTIVE_CACHE_NAMES = [") expect(source).toContain("const APP_SHELL_ASSETS = [") + expect(source).toContain("async function precacheAppShell()") + expect(source).toContain("precacheAppShell(),") expect(source).toContain("const MANIFEST_ICON_ASSETS = [") expect(source).toContain("function selectCacheName(request, url)") expect(source).toContain("if (isToolChunkRequest(url)) return TOOL_CHUNK_CACHE_NAME;") diff --git a/tests/guards/sensitive-storage-audit.test.ts b/tests/guards/sensitive-storage-audit.test.ts index 366703a1..2340231b 100644 --- a/tests/guards/sensitive-storage-audit.test.ts +++ b/tests/guards/sensitive-storage-audit.test.ts @@ -127,6 +127,9 @@ describe("sensitive storage audit", () => { expect(serviceWorker).toContain("function isNetworkOnlyRequest(request, url)") expect(serviceWorker).toContain("url.origin !== self.location.origin") expect(serviceWorker).toContain("hasSensitiveQuery(url) ||") + expect(serviceWorker).toContain("hasUncacheableQuery(url) ||") + expect(serviceWorker).toContain("url.search = ''") + expect(serviceWorker).toContain("cache.put(canonicalRequest, clone)") expect(serviceWorker).toContain("request.headers.get('x-byteflow-cache-mode') === 'network-only'") expect(serviceWorker).toContain("request.headers.get('x-byteflow-external-request') === '1'") expect(serviceWorker).toContain("if (isNetworkOnlyRequest(event.request, url)) return;") diff --git a/tests/guards/tool-network-access-metadata.test.ts b/tests/guards/tool-network-access-metadata.test.ts index 932b39dc..bcb18a09 100644 --- a/tests/guards/tool-network-access-metadata.test.ts +++ b/tests/guards/tool-network-access-metadata.test.ts @@ -12,6 +12,8 @@ const SOURCE_EXTENSIONS = new Set([".ts", ".tsx"]) const EXECUTABLE_NETWORK_CALLS = new Set(["fetch", "openExternalUrl"]) const EXECUTABLE_NETWORK_CONSTRUCTORS = new Set(["EventSource", "WebSocket", "XMLHttpRequest"]) const EXECUTABLE_NETWORK_PROPERTY_CALLS = new Set(["window.open", "navigator.sendBeacon"]) +const EXTERNAL_RESOURCE_ELEMENTS = new Set(["audio", "img", "source", "video"]) +const RESERVED_DOCUMENTATION_HOSTS = new Set(["example.com", "example.net", "example.org"]) const EXTERNAL_TARGET_PATTERNS = [ /\btarget\s*=\s*["']_blank["']/, /\.\s*target\s*=\s*["']_blank["']/, @@ -65,9 +67,35 @@ function hasExecutableNetworkCall(source: string): boolean { return found } +function hasConfirmedExternalResource(source: string): boolean { + if (!source.includes("ExternalRequestConfirmation")) return false + + const sourceFile = ts.createSourceFile("tool-source.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + let found = false + + function visit(node: ts.Node) { + if (found) return + const element = ts.isJsxSelfClosingElement(node) + ? node + : ts.isJsxOpeningElement(node) + ? node + : null + if (element && EXTERNAL_RESOURCE_ELEMENTS.has(element.tagName.getText(sourceFile))) { + found = element.attributes.properties.some((attribute) => + ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === "src", + ) + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return found +} + function hasNetworkBehavior(source: string): boolean { return ( hasExecutableNetworkCall(source) || + hasConfirmedExternalResource(source) || EXTERNAL_TARGET_PATTERNS.some((pattern) => pattern.test(source)) ) } @@ -75,7 +103,8 @@ function hasNetworkBehavior(source: string): boolean { function collectStaticExternalHosts(source: string): string[] { const hosts = new Set() for (const match of source.matchAll(STATIC_EXTERNAL_URL_PATTERN)) { - hosts.add(match[1].toLowerCase()) + const host = match[1].toLowerCase() + if (!RESERVED_DOCUMENTATION_HOSTS.has(host)) hosts.add(host) } return [...hosts].sort() } diff --git a/tests/unit/cron-visualizer-logic.test.ts b/tests/unit/cron-visualizer-logic.test.ts new file mode 100644 index 00000000..b84b05fd --- /dev/null +++ b/tests/unit/cron-visualizer-logic.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest" +import { expandCronField, getNextExecutions, parseVisualizerCron } from "@/features/tools/cron-visualizer/logic" + +describe("cron visualizer logic", () => { + it("rejects invalid five-field expressions before expansion", () => { + expect(parseVisualizerCron("*/0 * * * *")).toBeNull() + expect(parseVisualizerCron("banana * * * *")).toBeNull() + expect(parseVisualizerCron("1-2-3 * * * *")).toBeNull() + expect(getNextExecutions("*/0 * * * *", 20)).toEqual([]) + }) + + it("expands valid lists, ranges, and steps without accepting unsafe steps", () => { + expect(expandCronField("*/15", 0, 59)).toEqual([0, 15, 30, 45]) + expect(expandCronField("1-5/2,9", 0, 59)).toEqual([1, 3, 5, 9]) + expect(expandCronField("*/0", 0, 59)).toEqual([]) + }) + + it("keeps valid five-field expressions and rejects six-field input", () => { + expect(parseVisualizerCron("0 9 * * 1")).toEqual({ + minute: "0", + hour: "9", + dom: "*", + month: "*", + dow: "1", + }) + expect(parseVisualizerCron("30 0 9 * * 1")).toBeNull() + }) +}) diff --git a/tests/unit/crontab-generator-logic.test.ts b/tests/unit/crontab-generator-logic.test.ts index e8605522..5f2bc812 100644 --- a/tests/unit/crontab-generator-logic.test.ts +++ b/tests/unit/crontab-generator-logic.test.ts @@ -27,4 +27,21 @@ describe("crontab generator logic", () => { error: "Invalid cron hour field. Expected 0-23, got 25.", }) }) + + it("rejects zero steps, arbitrary text, and malformed ranges", () => { + expect(validateCronExpression("*/0 * * * *", "invalid")).toEqual({ + ok: false, + error: "Invalid cron minute field. Step must be a positive integer.", + }) + expect(validateCronExpression("banana * * * *", "invalid")).toEqual({ ok: false, error: "invalid" }) + expect(validateCronExpression("1-2-3 * * * *", "invalid")).toEqual({ ok: false, error: "invalid" }) + expect(validateCronExpression("5-1 * * * *", "invalid")).toEqual({ ok: false, error: "invalid" }) + }) + + it("accepts lists, ranges, and positive steps", () => { + expect(validateCronExpression("*/15 0,12 1-15/2 * 1-5", "invalid")).toMatchObject({ + ok: true, + normalized: "*/15 0,12 1-15/2 * 1-5", + }) + }) }) diff --git a/tests/unit/env-parser-utils.test.ts b/tests/unit/env-parser-utils.test.ts index 58ac15da..be302d1f 100644 --- a/tests/unit/env-parser-utils.test.ts +++ b/tests/unit/env-parser-utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" -import { envToDockerArgs, envToJson, envToYaml, parseEnvFile } from "@/features/tools/env-parser/utils" +import { parse as parseYaml } from "yaml" +import { envToDockerArgs, envToJson, envToYaml, isValidEnvKey, parseEnvFile } from "@/features/tools/env-parser/utils" describe("env parser utils", () => { it("parses comments, blanks, and quoted values", () => { @@ -19,7 +20,7 @@ describe("env parser utils", () => { expect(envToJson(parsed)).toBe("{\n \"PORT\": \"3000\",\n \"SECRET\": \"quoted value\"\n}") expect(envToYaml(parsed)).toBe("PORT: \"3000\"\nSECRET: \"quoted value\"") - expect(envToDockerArgs(parsed)).toBe("-e PORT=\"3000\" \\\n -e SECRET=\"quoted value\"") + expect(envToDockerArgs(parsed)).toBe("-e PORT='3000' \\\n -e SECRET='quoted value'") }) it("handles empty, malformed, and large env files without dropping secrets from expected exports", () => { @@ -36,7 +37,33 @@ describe("env parser utils", () => { expect(parsed).toHaveLength(500) expect(exported).toContain('"SECRET_499": "value-499"') - expect(envToDockerArgs(parsed)).toContain('-e SECRET_499="value-499"') + expect(envToDockerArgs(parsed)).toContain("-e SECRET_499='value-499'") expect(envToYaml(parsed)).toContain('SECRET_499: "value-499"') }) + + it("round-trips control characters through YAML double-quoted scalars", () => { + const parsed = parseEnvFile("VALUE=placeholder") + parsed[0].value = "quotes: \"yes\"\\path\nnext\rline\ttab" + + const yaml = envToYaml(parsed) + + expect(parseYaml(yaml)).toEqual({ VALUE: parsed[0].value }) + }) + + it("quotes docker values without allowing shell expansion or command injection", () => { + const parsed = parseEnvFile("VALUE=placeholder") + parsed[0].value = "it's $HOME `whoami` \"quoted\"; echo unsafe\nnext" + + expect(envToDockerArgs(parsed)).toBe("-e VALUE='it'\"'\"'s $HOME `whoami` \"quoted\"; echo unsafe\nnext'") + }) + + it("filters invalid environment names from every export format", () => { + const parsed = parseEnvFile("SAFE_KEY=ok\nBAD-KEY=no\nEVIL: value=bad\n1START=no") + + expect(isValidEnvKey("SAFE_KEY")).toBe(true) + expect(isValidEnvKey("BAD-KEY")).toBe(false) + expect(JSON.parse(envToJson(parsed))).toEqual({ SAFE_KEY: "ok" }) + expect(parseYaml(envToYaml(parsed))).toEqual({ SAFE_KEY: "ok" }) + expect(envToDockerArgs(parsed)).toBe("-e SAFE_KEY='ok'") + }) }) diff --git a/tests/unit/hash-task.test.ts b/tests/unit/hash-task.test.ts index 3cc2db36..498b2af7 100644 --- a/tests/unit/hash-task.test.ts +++ b/tests/unit/hash-task.test.ts @@ -13,6 +13,9 @@ class MockHashWorker { postMessage(input: unknown, transfer?: Transferable[]) { MockHashWorker.lastTransfer = transfer + const deliveredInput = transfer?.length + ? structuredClone(input, { transfer }) + : input if (MockHashWorker.mode === "idle") return queueMicrotask(() => { if (this.terminated) return @@ -23,7 +26,7 @@ class MockHashWorker { this.onmessage?.({ data: { ok: true, - value: runHashTaskSync(input as Parameters[0]), + value: runHashTaskSync(deliveredInput as Parameters[0]), }, } as MessageEvent) }) @@ -116,7 +119,9 @@ describe("hash task", () => { await runHashTask({ mode: "file", bytes }) - expect(MockHashWorker.lastTransfer).toEqual([bytes.buffer]) + expect(MockHashWorker.lastTransfer).toHaveLength(1) + expect(MockHashWorker.lastTransfer?.[0]).not.toBe(bytes.buffer) + expect(bytes.byteLength).toBe(3) }) it("falls back to sync computation on non-timeout worker failures", async () => { @@ -130,6 +135,17 @@ describe("hash task", () => { }) }) + it("hashes the original file bytes when the transferred worker payload fails", async () => { + MockHashWorker.mode = "error" + vi.stubGlobal("Worker", MockHashWorker) + const bytes = Uint8Array.from([1, 2, 3]) + + const result = await runHashTask({ mode: "file", bytes }) + + expect(bytes.byteLength).toBe(3) + expect(result.standardHashes.sha256).toBe(createHash("sha256").update(bytes).digest("hex")) + }) + it("does not fall back to sync computation when aborted", async () => { MockHashWorker.mode = "idle" vi.stubGlobal("Worker", MockHashWorker) diff --git a/tests/unit/open-graph-utils.test.ts b/tests/unit/open-graph-utils.test.ts index 12b90012..0849e79a 100644 --- a/tests/unit/open-graph-utils.test.ts +++ b/tests/unit/open-graph-utils.test.ts @@ -3,6 +3,7 @@ import { buildOpenGraphMetaTags, buildOpenGraphSnippetDocument, normalizeAbsoluteHttpUrl, + normalizeSupportedOpenGraphPreviewUrl, type OpenGraphInput, } from "@/features/tools/open-graph-meta-generator/utils" @@ -23,6 +24,18 @@ describe("open-graph-utils", () => { expect(normalizeAbsoluteHttpUrl("ftp://example.com")).toBeNull() }) + it("allows live previews only from CSP-supported HTTPS image hosts", () => { + expect(normalizeSupportedOpenGraphPreviewUrl("https://i.ytimg.com/vi/id/image.jpg")).toBe("https://i.ytimg.com/vi/id/image.jpg") + expect(normalizeSupportedOpenGraphPreviewUrl("https://vumbnail.com/123.jpg")).toBe("https://vumbnail.com/123.jpg") + expect(normalizeSupportedOpenGraphPreviewUrl("https://cdn.instagram.com/image.jpg")).toBe("https://cdn.instagram.com/image.jpg") + expect(normalizeSupportedOpenGraphPreviewUrl("https://instagram.com/image.jpg")).toBe("https://instagram.com/image.jpg") + expect(normalizeSupportedOpenGraphPreviewUrl("http://i.ytimg.com/image.jpg")).toBeNull() + expect(normalizeSupportedOpenGraphPreviewUrl("https://user@i.ytimg.com/image.jpg")).toBeNull() + expect(normalizeSupportedOpenGraphPreviewUrl("https://i.ytimg.com:8443/image.jpg")).toBeNull() + expect(normalizeSupportedOpenGraphPreviewUrl("https://evilinstagram.com/image.jpg")).toBeNull() + expect(normalizeSupportedOpenGraphPreviewUrl("https://example.com/image.jpg")).toBeNull() + }) + it("builds og + twitter meta tags", () => { const tags = buildOpenGraphMetaTags(sample) expect(tags).toContain('property="og:title"') @@ -31,12 +44,20 @@ describe("open-graph-utils", () => { }) it("escapes dangerous characters", () => { + const payload = '">', + title: payload, + description: payload, + type: payload, + siteName: payload, + twitterSite: payload, + twitterCard: payload as OpenGraphInput["twitterCard"], }) expect(tags).toContain("<script>") expect(tags).not.toContain(" { expect(css).toContain("background-image") expect(css).toContain("data:image/svg+xml") }) + + it("rejects colors that could inject SVG markup", () => { + const payload = '">