diff --git a/package-lock.json b/package-lock.json index a9f659dc..7e944bb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,6 @@ "packages": { "": { "name": "plugins", - "version": "1.0.0", "license": "MIT", "workspaces": [ "plugins/*" @@ -54,6 +53,374 @@ "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz", + "integrity": "sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.25.7", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.8.tgz", + "integrity": "sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.8.tgz", + "integrity": "sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.25.7", + "@babel/generator": "^7.25.7", + "@babel/helper-compilation-targets": "^7.25.7", + "@babel/helper-module-transforms": "^7.25.7", + "@babel/helpers": "^7.25.7", + "@babel/parser": "^7.25.8", + "@babel/template": "^7.25.7", + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.8", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.7.tgz", + "integrity": "sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.7", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.7.tgz", + "integrity": "sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.25.7", + "@babel/helper-validator-option": "^7.25.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.7.tgz", + "integrity": "sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.7.tgz", + "integrity": "sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.7", + "@babel/helper-simple-access": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", + "@babel/traverse": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.7.tgz", + "integrity": "sha512-eaPZai0PiqCi09pPs3pAFfl/zYgGaE6IdXtYvmf0qlcDTd3WCtO7JWCcRd64e0EQrcYgiHibEZnOGsSY4QSgaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.7.tgz", + "integrity": "sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.7.tgz", + "integrity": "sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz", + "integrity": "sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.7.tgz", + "integrity": "sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.7.tgz", + "integrity": "sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz", + "integrity": "sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.7", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.8.tgz", + "integrity": "sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.7.tgz", + "integrity": "sha512-JD9MUnLbPL0WdVK8AWC7F7tTG2OS6u/AKKnsK+NdRhUiVdnzyR1S3kKQCaRLOiaULvUiqK6Z4JQE635VgtCFeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.7.tgz", + "integrity": "sha512-S/JXG/KrbIY06iyJPKfxr0qRxnhNOdkNXYBl/rmwgDd72cQLH9tEGkDm/yJPGvcSIUoikzfjMios9i+xT/uv9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/runtime": { "version": "7.25.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", @@ -65,6 +432,65 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", + "integrity": "sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.7.tgz", + "integrity": "sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.7", + "@babel/generator": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/template": "^7.25.7", + "@babel/types": "^7.25.7", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.8.tgz", + "integrity": "sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -457,6 +883,55 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", @@ -511,6 +986,29 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.1.tgz", + "integrity": "sha512-HFZ4Mp26nbWk9d/BpvP0YNL6W4UoZF0VFcTw/aPPA8RpOxeFQgK+ClABGgAUXs9Y/RGX/l1vOmrqz1MQt9MNuw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", @@ -545,6 +1043,30 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" }, + "node_modules/@humanfs/core": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", + "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.5.tgz", + "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.0", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -602,6 +1124,20 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@icons/material": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", @@ -2716,6 +3252,51 @@ "integrity": "sha512-BV9NplVgLmSi4mwKzD8BD/NQ8erOY/nUE/GpgWe2ckx+wIQF5RyRirn/QsSSCPeulVpc3RA/iJt6DpfTIZps0Q==", "dev": true }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -3474,6 +4055,26 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.3.tgz", + "integrity": "sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, "node_modules/@vitejs/plugin-react-swc": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.0.tgz", @@ -3686,6 +4287,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "browserslist": "^4.23.3", "caniuse-lite": "^1.0.30001646", @@ -3903,9 +4505,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", - "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", + "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", "dev": true, "funding": [ { @@ -3921,9 +4523,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001646", - "electron-to-chromium": "^1.5.4", + "caniuse-lite": "^1.0.30001663", + "electron-to-chromium": "^1.5.28", "node-releases": "^2.0.18", "update-browserslist-db": "^1.1.0" }, @@ -4033,9 +4636,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001660", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001660.tgz", - "integrity": "sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==", + "version": "1.0.30001669", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz", + "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==", "dev": true, "funding": [ { @@ -4050,7 +4653,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2", @@ -4304,6 +4908,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4750,10 +5361,11 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.5.24", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.24.tgz", - "integrity": "sha512-0x0wLCmpdKFCi9ulhvYZebgcPmHTkFVUfU2wzDykadkslKwT4oAmDTHEKLnlrDsMGZe4B+ksn8quZfZjYsBetA==", - "dev": true + "version": "1.5.41", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.41.tgz", + "integrity": "sha512-dfdv/2xNjX0P8Vzme4cfzHqnPm5xsZXwsolTYr0eyW18IUmNyG08vL+fttvinTfhKfIKdRoqkDIC9e9iWQCNYQ==", + "dev": true, + "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -5452,6 +6064,15 @@ } } }, + "node_modules/framer-plugin": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", + "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5487,6 +6108,16 @@ "node": ">=10" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -5975,6 +6606,19 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5998,6 +6642,19 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7134,6 +7791,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", @@ -7511,6 +8169,16 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-remove-scroll": { "version": "2.5.7", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", @@ -8078,6 +8746,10 @@ "loose-envify": "^1.1.0" } }, + "node_modules/semrush": { + "resolved": "plugins/semrush", + "link": true + }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -8748,10 +9420,11 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.12.tgz", - "integrity": "sha512-Htf/gHj2+soPb9UayUNci/Ja3d8pTmu9ONTfh4QY8r3MATTZOzmv6UYWF7ZwikEIC8okpfqmGqrmDehua8mF8w==", + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", "dev": true, + "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8829,6 +9502,16 @@ "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9050,6 +9733,220 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.10.0.tgz", + "integrity": "sha512-YIu230PeN7z9zpu/EtqCIuRVHPs4iSlqW6TEvjbyDAE3MZsSl2RXBo+5ag+lbABCG8sFM1WVKEXhlQ8Ml8A3Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.10.0", + "@typescript-eslint/parser": "8.10.0", + "@typescript-eslint/utils": "8.10.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.10.0.tgz", + "integrity": "sha512-phuB3hoP7FFKbRXxjl+DRlQDuJqhpOnm5MmtROXyWi3uS/Xg2ZXqiQfcG2BJHiN4QKyzdOJi3NEn/qTnjUlkmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/type-utils": "8.10.0", + "@typescript-eslint/utils": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.10.0.tgz", + "integrity": "sha512-E24l90SxuJhytWJ0pTQydFT46Nk0Z+bsLKo/L8rtQSL93rQ6byd1V/QbDpHUTdLPOMsBCcYXZweADNCfOCmOAg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/typescript-estree": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.10.0.tgz", + "integrity": "sha512-AgCaEjhfql9MDKjMUxWvH7HjLeBqMCBfIaBbzzIcBbQPZE7CPh1m6FF+L75NUMJFMLYhCywJXIDEMa3//1A0dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.10.0.tgz", + "integrity": "sha512-PCpUOpyQSpxBn230yIcK+LeCQaXuxrgCm2Zk1S+PTIRJsEfU6nJ0TtwyH8pIwPK/vJoA+7TZtzyAJSGBz+s/dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.10.0", + "@typescript-eslint/utils": "8.10.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz", + "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.10.0.tgz", + "integrity": "sha512-3OE0nlcOHaMvQ8Xu5gAfME3/tWVDpb/HxtpUZ1WeOAksZ/h/gwrBzCklaGzwZT97/lBbbxJ16dMA98JMEngW4w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.10.0.tgz", + "integrity": "sha512-Oq4uZ7JFr9d1ZunE/QKy5egcDRXT/FrS2z/nlxzPua2VHFtmMvFNDvpq1m/hq0ra+T52aUezfcjGRIB7vNJF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/typescript-estree": "8.10.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz", + "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.10.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -9350,6 +10247,7 @@ "version": "3.3.5", "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.3.5.tgz", "integrity": "sha512-bx3fLQAMn+EhYbBdY3W1gw9ZfO/uchudxYMwOIBzF3HVgqNEEIT199vEoh7FLTC0Vz5+rpMO6NdFsOkGX1QQCw==", + "license": "Unlicense", "dependencies": { "mitt": "^3.0.1", "regexparam": "^3.0.0", @@ -9585,15 +10483,6 @@ "@rollup/rollup-linux-x64-gnu": "4.9.5" } }, - "plugins/ascii/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/cms-export": { "version": "0.0.0", "dependencies": { @@ -9793,15 +10682,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "plugins/cms-export/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/color-extract": { "version": "0.0.0", "dependencies": { @@ -9826,15 +10706,6 @@ "vite-plugin-framer": "^1.0.1" } }, - "plugins/color-extract/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/concentric": { "version": "0.0.0", "dependencies": { @@ -9861,15 +10732,6 @@ "vite-plugin-framer": "^1.0.1" } }, - "plugins/concentric/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/dither": { "version": "0.0.0", "dependencies": { @@ -9903,15 +10765,6 @@ "@rollup/rollup-linux-x64-gnu": "4.9.5" } }, - "plugins/dither/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/doodles": { "version": "1.0.0", "dependencies": { @@ -9942,15 +10795,6 @@ "vite-plugin-framer": "^1.0.1" } }, - "plugins/doodles/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/flip-image": { "version": "0.0.0", "dependencies": { @@ -9974,15 +10818,6 @@ "vite-plugin-framer": "^1.0.1" } }, - "plugins/flip-image/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/google-search-console": { "version": "0.0.0", "dependencies": { @@ -10208,15 +11043,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "plugins/google-search-console/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/google-sheets": { "version": "0.0.0", "dependencies": { @@ -10423,15 +11249,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "plugins/google-sheets/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/hubspot": { "version": "0.0.0", "dependencies": { @@ -10643,15 +11460,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "plugins/hubspot/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/layout-insert": { "version": "0.0.0", "dependencies": { @@ -10843,21 +11651,12 @@ "@typescript-eslint/types": "7.11.0", "eslint-visitor-keys": "^3.4.3" }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "plugins/layout-insert/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, "plugins/notion": { @@ -11071,15 +11870,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "plugins/notion/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/phosphor": { "version": "0.0.0", "dependencies": { @@ -11105,15 +11895,6 @@ "vite-plugin-framer": "^1.0.1" } }, - "plugins/phosphor/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/photobooth": { "version": "0.0.0", "dependencies": { @@ -11139,15 +11920,6 @@ "vite-plugin-framer": "^1.0.1" } }, - "plugins/photobooth/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/pong": { "version": "0.0.0", "dependencies": { @@ -11170,15 +11942,6 @@ "vite-plugin-framer": "^1.0.1" } }, - "plugins/pong/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/renamer": { "version": "0.0.0", "dependencies": { @@ -11198,15 +11961,6 @@ "vite-plugin-mkcert": "^1.17.5" } }, - "plugins/renamer/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/rss-feeds": { "version": "0.0.0", "dependencies": { @@ -11412,13 +12166,277 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "plugins/rss-feeds/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", + "plugins/semrush": { + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.49.2", + "classnames": "^2.5.1", + "framer-motion": "^11.2.9", + "framer-plugin": "^1.1.0", + "react": "^18", + "react-dom": "^18", + "react-error-boundary": "^4.0.13", + "usehooks-ts": "^3.1.0", + "vite-plugin-mkcert": "^1", + "wouter": "^3.3.5" + }, + "devDependencies": { + "@eslint/js": "^9", + "@types/react": "^18", + "@types/react-dom": "^18", + "@vitejs/plugin-react": "^4.3.1", + "@vitejs/plugin-react-swc": "^3", + "autoprefixer": "^10.4.20", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.3", + "typescript-eslint": "^8.0.1", + "vite": "^5", + "vite-plugin-framer": "^1" + } + }, + "plugins/semrush/node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "plugins/semrush/node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "plugins/semrush/node_modules/@eslint/js": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "plugins/semrush/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "plugins/semrush/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "plugins/semrush/node_modules/eslint": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", + "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.13.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.5", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.3.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.1.0", + "eslint-visitor-keys": "^4.1.0", + "espree": "^10.2.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "plugins/semrush/node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0-rc-fb9a90fa48-20240614", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0-rc-fb9a90fa48-20240614.tgz", + "integrity": "sha512-xsiRwaDNF5wWNC4ZHLut+x/YcAxksUd9Rizt7LaEn3bV8VyYRpXnRJQlLOfYaVy9esk4DFP4zPPnoNVjq5Gc0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "plugins/semrush/node_modules/eslint-scope": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "plugins/semrush/node_modules/eslint-visitor-keys": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "plugins/semrush/node_modules/espree": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "plugins/semrush/node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "plugins/semrush/node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "plugins/semrush/node_modules/globals": { + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", + "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "plugins/semrush/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, "plugins/threshold": { @@ -11447,15 +12465,6 @@ "vite-plugin-framer": "^1.0.1" } }, - "plugins/threshold/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/tidyup": { "version": "0.0.0", "dependencies": { @@ -11660,15 +12669,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "plugins/tidyup/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/unsplash": { "version": "0.0.0", "dependencies": { @@ -12289,15 +13289,6 @@ "@esbuild/win32-x64": "0.20.2" } }, - "plugins/unsplash/node_modules/framer-plugin": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/framer-plugin/-/framer-plugin-1.1.0.tgz", - "integrity": "sha512-ewn6xC5HOE2BB43pCfoC+gDjbZlQpjgYY30UlqZWiF40njiEljh7vvJip9hJireAVsFVZPRtOP45Gqzk3GgSyQ==", - "peerDependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - } - }, "plugins/unsplash/node_modules/vite": { "version": "5.2.9", "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.9.tgz", diff --git a/plugins/semrush/.gitignore b/plugins/semrush/.gitignore new file mode 100644 index 00000000..25519452 --- /dev/null +++ b/plugins/semrush/.gitignore @@ -0,0 +1,33 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies + +/node_modules +/.pnp +.pnp.js +.yarn + +# misc + +.DS_Store +\*.pem + +# files + +my-plugin +dev-plugin +dist + +# debug + +npm-debug.log* +yarn-debug.log* +yarn-error.log\* + +# local env files + +.env\*.local + +# Packed plugin + +plugin.zip diff --git a/plugins/semrush/README.md b/plugins/semrush/README.md new file mode 100644 index 00000000..8c659b80 --- /dev/null +++ b/plugins/semrush/README.md @@ -0,0 +1,19 @@ +# Framer Plugin Template + +This is a template for using the Framer Plugin API in a TypeScript project. + +## Quickstart + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Learn more: https://www.framer.com/developers/plugins/introduction diff --git a/plugins/semrush/eslint.config.js b/plugins/semrush/eslint.config.js new file mode 100644 index 00000000..6e64b68b --- /dev/null +++ b/plugins/semrush/eslint.config.js @@ -0,0 +1,25 @@ +import js from "@eslint/js" +import globals from "globals" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" +import tseslint from "typescript-eslint" + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + }, + } +) diff --git a/plugins/semrush/framer.json b/plugins/semrush/framer.json new file mode 100644 index 00000000..7116ba5e --- /dev/null +++ b/plugins/semrush/framer.json @@ -0,0 +1,6 @@ +{ + "id": "036742", + "name": "Semrush", + "modes": ["canvas"], + "icon": "/semrush.svg" +} diff --git a/plugins/semrush/index.html b/plugins/semrush/index.html new file mode 100644 index 00000000..ac78f151 --- /dev/null +++ b/plugins/semrush/index.html @@ -0,0 +1,14 @@ + + + + + + + Semrush + + +
+ + + + diff --git a/plugins/semrush/package.json b/plugins/semrush/package.json new file mode 100644 index 00000000..5fcea70b --- /dev/null +++ b/plugins/semrush/package.json @@ -0,0 +1,43 @@ +{ + "name": "semrush", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview", + "pack": "npx framer-plugin-tools@latest pack" + }, + "dependencies": { + "framer-plugin": "^1.1.0", + "@tanstack/react-query": "^5.49.2", + "classnames": "^2.5.1", + "framer-motion": "^11.2.9", + "react": "^18", + "react-dom": "^18", + "react-error-boundary": "^4.0.13", + "usehooks-ts": "^3.1.0", + "vite-plugin-mkcert": "^1", + "wouter": "^3.3.5" + }, + "devDependencies": { + "@eslint/js": "^9", + "@types/react": "^18", + "@types/react-dom": "^18", + "@vitejs/plugin-react": "^4.3.1", + "@vitejs/plugin-react-swc": "^3", + "autoprefixer": "^10.4.20", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.3", + "typescript-eslint": "^8.0.1", + "vite": "^5", + "vite-plugin-framer": "^1" + } +} diff --git a/plugins/semrush/postcss.config.js b/plugins/semrush/postcss.config.js new file mode 100644 index 00000000..d41ad635 --- /dev/null +++ b/plugins/semrush/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/plugins/semrush/public/semrush.svg b/plugins/semrush/public/semrush.svg new file mode 100644 index 00000000..f640bcee --- /dev/null +++ b/plugins/semrush/public/semrush.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/semrush/src/App.css b/plugins/semrush/src/App.css new file mode 100644 index 00000000..62e732f3 --- /dev/null +++ b/plugins/semrush/src/App.css @@ -0,0 +1,10 @@ +/* Your Plugin CSS */ + +main { + display: flex; + flex-direction: column; + align-items: start; + padding: 0 15px 15px 15px; + height: 100%; + gap: 15px; +} diff --git a/plugins/semrush/src/App.tsx b/plugins/semrush/src/App.tsx new file mode 100644 index 00000000..0c0e8368 --- /dev/null +++ b/plugins/semrush/src/App.tsx @@ -0,0 +1,43 @@ +import { framer } from "framer-plugin" +import { useEffect, useRef, useState } from "react" +import { useDebounceCallback, useResizeObserver } from "usehooks-ts" +import { Router } from "./router" +import { semrush } from "./api" + +interface Size { + width?: number + height?: number +} + +const usePluginResizeObserver = (ref: React.RefObject) => { + const [{ width, height }, setSize] = useState({ + width: 260, + // Menu Page : Auth Page heights + height: semrush.auth.isAuthenticated() ? 545 : 344, + }) + + const onResize = useDebounceCallback(setSize, 0) + + useResizeObserver({ + ref, + onResize, + }) + + useEffect(() => { + framer.showUI({ + width, + height, + }) + }, [width, height]) +} + +export function App() { + const ref = useRef(null) + usePluginResizeObserver(ref) + + return ( +
+ +
+ ) +} diff --git a/plugins/semrush/src/api.ts b/plugins/semrush/src/api.ts new file mode 100644 index 00000000..b66302fb --- /dev/null +++ b/plugins/semrush/src/api.ts @@ -0,0 +1,234 @@ +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import SemrushClient, { Issue, Tokens } from "./semrush" +import { useEffect, useState } from "react" +import { AUDIT_ISSUES } from "./constants" +import { framer } from "framer-plugin" +import { formatNumWithMetricPrefix, timeAgo } from "./utils" + +const semrushClientInstance = new SemrushClient({ + defaultAuditSettings: { + // Issues a Framer user cannot resolve e.g. uncached JS + excludedChecks: [4, 9, 10, 16, 17, 18, 27, 28, 43, 27, 29, 41, 131, 132, 133, 134, 145, 127], + }, +}) + +const handler: ProxyHandler = { + set(target, prop, value) { + // Save to local storage on change + if (prop === "apiKey") { + window.localStorage.setItem("semrushApiKey", JSON.stringify(value)) + } + + if (prop === "tokens") { + const tokens: Tokens = value + const storedTokens = { + createdAt: Date.now(), + expiredIn: tokens.expires_in, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + } + + window.localStorage.setItem("tokens", JSON.stringify(storedTokens)) + } + + return Reflect.set(target, prop, value) + }, +} + +export const semrush = new Proxy(semrushClientInstance, handler) + +async function getStagingInfo() { + const publishInfo = await framer.getPublishInfo() + const stagingInfo = publishInfo.staging + + if (!stagingInfo) { + throw new Error("This project must be published to staging.") + } + + return { + ...stagingInfo, + hostname: new URL(stagingInfo.url).hostname, + } +} + +// Removes unrelated issues and annotates the rest +// with their type and description +function annotateIssues(issues: Issue[], type: "error" | "warning" | "notice") { + const applicableAuditIssues = issues.filter(issue => issue.count > 0) + return applicableAuditIssues.map(issue => ({ + ...issue, + type, + description: AUDIT_ISSUES[issue.id].description ?? String(issue.id), + })) +} + +const transformKeywordRow = (cell: Awaited>[0]) => ({ + keyword: cell.Keyword, + searchVolume: formatNumWithMetricPrefix(Number(cell["Search Volume"])), + trends: cell.Trends.split(","), + cpc: cell.CPC, + difficulty: cell["Keyword Difficulty Index"], + totalResults: formatNumWithMetricPrefix(Number(cell["Number of Results"])), + // Intent is the only possible empty cell + intentCodes: cell.Intent === "" ? null : cell.Intent.split(","), +}) + +export function useValidateApiKeyMutation({ onSuccess, onError }: { onSuccess?: () => void; onError?: () => void }) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (apiKey: string) => semrush.setApiKey(apiKey), + onSuccess: () => { + queryClient.prefetchQuery({ + queryKey: ["project"], + queryFn: async () => { + const { hostname } = await getStagingInfo() + return semrush.getOrCreateProject(hostname) + }, + }) + + onSuccess?.() + }, + onError, + }) +} + +export function useProjectQuery() { + return useQuery({ + queryKey: ["project"], + queryFn: async () => { + const { hostname } = await getStagingInfo() + return semrush.getOrCreateProject(hostname) + }, + throwOnError: true, + }) +} + +export function useDeleteProjectMutation({ onSuccess }: { onSuccess: () => void }) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => semrush.projects.delete(), + onSuccess: () => { + queryClient.clear() + onSuccess() + }, + }) +} + +export function useInfiniteKeywordSearchQuery(args: Omit[0], "offset">) { + const { keyword, database, limit, type, sort } = args + + return useInfiniteQuery({ + queryKey: ["keywords", { keyword, database, type, sort }], + enabled: !!keyword, + initialPageParam: 0, + placeholderData: keepPreviousData, + queryFn: ({ pageParam }) => { + const offset = pageParam * limit + return semrush.getKeyphrases({ + ...args, + offset, + limit: offset + limit, + }) + }, + getNextPageParam: (lastPage, _allPages, lastPageParam) => { + return lastPage.length === 0 ? undefined : lastPageParam + 1 + }, + getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => { + return firstPageParam <= 1 ? undefined : firstPageParam - 1 + }, + select: data => { + return data.pages.flatMap(page => page.map(row => transformKeywordRow(row))) + }, + }) +} + +export function useAuditQuery({ formatSnapshotData }: { formatSnapshotData: boolean }) { + const [refetchInterval, setRefetchInterval] = useState(4500) + const { data, ...rest } = useQuery({ + throwOnError: true, + queryKey: ["audit"], + queryFn: () => semrush.audit.get(), + select: data => { + // Don't format if no previous audit data exists or if not requested + if (data.current_snapshot === null || !formatSnapshotData) { + return { + ...data, + timeAgo: undefined, + annotatedIssues: { + errors: [], + warnings: [], + notices: [], + }, + } + } + + const { errors, warnings, notices } = data.current_snapshot + + return { + ...data, + timeAgo: timeAgo(data.current_snapshot.finish_date), + annotatedIssues: { + errors: annotateIssues(errors, "error"), + warnings: annotateIssues(warnings, "warning"), + notices: annotateIssues(notices, "notice"), + }, + } + }, + refetchInterval, + }) + const isAuditFinished = data?.status === "FINISHED" + + // Poll for audit status + useEffect(() => { + if (isAuditFinished || !data) { + setRefetchInterval(0) + } else { + setRefetchInterval(4500) + } + }, [isAuditFinished, data]) + + return { data, ...rest } +} + +export function useRunAuditMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => semrush.audit.run(), + onSuccess: () => queryClient.refetchQueries({ queryKey: ["audit"] }), + }) +} + +export function useEditAuditMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (audit: Parameters[0]) => { + return semrush.audit.update(audit) + }, + onSuccess: () => { + framer.notify("Audit settings saved.", { variant: "success" }) + return queryClient.refetchQueries({ queryKey: ["audit"] }) + }, + }) +} + +export const usePrefetchAuditQuery = () => { + const queryClient = useQueryClient() + + return () => { + queryClient.prefetchQuery({ + queryKey: ["audit"], + queryFn: () => semrush.audit.get(), + }) + } +} + +export const useIssueReportQuery = (snapshotId: string, issueId: number) => { + return useQuery({ + queryKey: ["issue", snapshotId, issueId], + queryFn: () => semrush.audit.getIssueReport(snapshotId, issueId), + }) +} diff --git a/plugins/semrush/src/assets/icon.svg b/plugins/semrush/src/assets/icon.svg new file mode 100644 index 00000000..f640bcee --- /dev/null +++ b/plugins/semrush/src/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/semrush/src/components/Button.tsx b/plugins/semrush/src/components/Button.tsx new file mode 100644 index 00000000..313ef979 --- /dev/null +++ b/plugins/semrush/src/components/Button.tsx @@ -0,0 +1,13 @@ +import cx from "classnames" +import { Spinner } from "./Spinner" + +interface Props extends React.ButtonHTMLAttributes { + variant?: "primary" | "secondary" | "destructive" + isLoading?: boolean +} + +export const Button = ({ variant = "primary", children, className, isLoading = false, disabled, ...rest }: Props) => ( + +) diff --git a/plugins/semrush/src/components/CenteredSpinner.tsx b/plugins/semrush/src/components/CenteredSpinner.tsx new file mode 100644 index 00000000..2b876652 --- /dev/null +++ b/plugins/semrush/src/components/CenteredSpinner.tsx @@ -0,0 +1,7 @@ +import { Spinner } from "./Spinner" + +export const CenteredSpinner = () => ( +
+ +
+) diff --git a/plugins/semrush/src/components/Icons.tsx b/plugins/semrush/src/components/Icons.tsx new file mode 100644 index 00000000..6d2c60b1 --- /dev/null +++ b/plugins/semrush/src/components/Icons.tsx @@ -0,0 +1,56 @@ +export const CaretLeftIcon = () => ( + + + +) + +export const ChartIcon = () => ( + + + +) + +export const DatabaseIcon = () => ( + + + +) + +export const SearchIcon = () => ( + + + + +) + +export const DownArrowIcon = () => ( + + + +) diff --git a/plugins/semrush/src/components/Indicator.tsx b/plugins/semrush/src/components/Indicator.tsx new file mode 100644 index 00000000..3b1ab6d8 --- /dev/null +++ b/plugins/semrush/src/components/Indicator.tsx @@ -0,0 +1,15 @@ +import cx from "classnames" + +export const Indicator = ({ type }: { type: "error" | "warning" | "notice" }) => { + const color = { + "bg-framer-red": type === "error", + "bg-framer-yellow": type === "warning", + "bg-framer-blue": type === "notice", + } + + return ( +
+
+
+ ) +} diff --git a/plugins/semrush/src/components/MenuOption.tsx b/plugins/semrush/src/components/MenuOption.tsx new file mode 100644 index 00000000..2b9eea7f --- /dev/null +++ b/plugins/semrush/src/components/MenuOption.tsx @@ -0,0 +1,28 @@ +import { useLocation } from "wouter" + +export const MenuOption = ({ + children, + title, + to, + onClick, +}: { + children: React.ReactNode + title: string + to: string + onClick?: () => void +}) => { + const [, setLocation] = useLocation() + + return ( +
{ + setLocation(to) + onClick?.() + }} + > + {children} +

{title}

+
+ ) +} diff --git a/plugins/semrush/src/components/PageErrorBoundaryFallback.tsx b/plugins/semrush/src/components/PageErrorBoundaryFallback.tsx new file mode 100644 index 00000000..ccb9233a --- /dev/null +++ b/plugins/semrush/src/components/PageErrorBoundaryFallback.tsx @@ -0,0 +1,23 @@ +import { PropsWithChildren } from "react" +import { ErrorBoundary } from "react-error-boundary" +import { QueryErrorResetBoundary } from "@tanstack/react-query" + +export const PageErrorBoundaryFallback = ({ children }: PropsWithChildren) => ( + + {({ reset }) => ( + ( + <> +

{error.message}

+ + + )} + > + {children} +
+ )} +
+) diff --git a/plugins/semrush/src/components/PluginPage.tsx b/plugins/semrush/src/components/PluginPage.tsx new file mode 100644 index 00000000..ba7c632b --- /dev/null +++ b/plugins/semrush/src/components/PluginPage.tsx @@ -0,0 +1,52 @@ +import React from "react" +import { motion } from "framer-motion" +import cx from "classnames" +import { CaretLeftIcon } from "./Icons" + +const PageDivider = () => ( +
+
+
+) + +const Title = ({ title, animateForward }: { title: string; animateForward?: boolean }) => ( + + +
+
+ +
+ +
{title}
+
+
+
+) + +interface Props { + children: React.ReactNode + animateForward?: boolean + title?: string + className?: string +} + +export const PluginPage = ({ children, title, className, animateForward }: Props) => ( +
+ {title && } + <PageDivider /> + <div className="col-lg p-15">{children}</div> + </div> +) diff --git a/plugins/semrush/src/components/Spinner.tsx b/plugins/semrush/src/components/Spinner.tsx new file mode 100644 index 00000000..594ad9d9 --- /dev/null +++ b/plugins/semrush/src/components/Spinner.tsx @@ -0,0 +1,40 @@ +import cx from "classnames" +import styles from "./spinner.module.css" + +export interface SpinnerProps { + /** Size of the spinner */ + size?: "normal" | "medium" | "large" + /** Set the spinner to have a static position inline with other content */ + inline?: boolean + className?: string + inheritColor?: boolean +} + +function styleForSize(size: SpinnerProps["size"]) { + switch (size) { + case "normal": + return styles.normalStyle + case "medium": + return styles.mediumStyle + case "large": + return styles.largeStyle + } +} + +function spinnerClassNames(size: SpinnerProps["size"] = "normal") { + return cx(styles.spin, styles.baseStyle, styleForSize(size)) +} + +export const Spinner = ({ size, inline = false, inheritColor, className, ...rest }: SpinnerProps) => { + return ( + <div + className={cx( + className, + spinnerClassNames(size), + inheritColor && styles.buttonWithDepthSpinner, + !inline && styles.centeredStyle + )} + {...rest} + /> + ) +} diff --git a/plugins/semrush/src/components/Table.tsx b/plugins/semrush/src/components/Table.tsx new file mode 100644 index 00000000..d2678a2d --- /dev/null +++ b/plugins/semrush/src/components/Table.tsx @@ -0,0 +1,42 @@ +import cx from "classnames" + +interface Props extends React.HTMLAttributes<HTMLDivElement> { + children: React.ReactNode + className?: string +} + +export const TableContainer = ({ children, className, ...rest }: Props) => ( + <div className={cx("col-lg w-full h-fit", className)} {...rest}> + {children} + </div> +) + +export const TableHead = ({ children, className, ...rest }: Props) => ( + <div className={cx("flex gap-5 border-b border-divider min-h-30 w-full text-secondary", className)} {...rest}> + {children} + </div> +) + +export const TableRow = ({ children, className, ...rest }: Props) => ( + <div className={cx("flex gap-5 min-h-30", className)} {...rest}> + {children} + </div> +) + +export const TableCell = ({ children, className, ...rest }: Props) => ( + <div className={cx("flex items-center gap-[5px] min-w-[65px]", className)} {...rest}> + {children} + </div> +) + +export const TableBody = ({ children, className, ...rest }: Props) => ( + <div + className={cx( + "flex flex-col gap-15 w-full max-h-[500px] overflow-y-auto overflow-x-hidden text-primary", + className + )} + {...rest} + > + {children} + </div> +) diff --git a/plugins/semrush/src/components/spinner.module.css b/plugins/semrush/src/components/spinner.module.css new file mode 100644 index 00000000..a0d1a7ac --- /dev/null +++ b/plugins/semrush/src/components/spinner.module.css @@ -0,0 +1,60 @@ +.baseStyle { + --spinner-translate: 0; + background-color: #fff; +} + +.buttonWithDepthSpinner { + background-color: currentColor; +} + +.normalStyle { + width: 12px; + height: 12px; + -webkit-mask: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAApNJREFUSA2tlUtLlFEYgEe7mtFFQ8NLFBG1SJAwahtiLVy5ceVSEPQH1LZf0Lp9FO1bdYNyI7gyBFcS2kAzilYq3sfxeYZ55Zv6FGfohWfOd97vnPd23u9MXeZoaeb1Q7gPHXAFzsAS5GACxiEPqVKXqs1kmtAPQi+cAtedToz1PJ8ExxPwDl7CAlRImoN7rBiF86ABHTiGAzMIh8n3O+ifwWc4EL0n5TGTYQijvjNSxbUadozInUeGZ3nuh1WYgpIkM+hGMwYajAgdrfUn+AaLoLTCAxiAOxCZxL4RdB+gZMjxEjwHo4hS8Jh5DR9hz0mKaNizegoNEI7Wee6FXJTISG6AUgDr+QImoAiHie+mwZJow/PRps4uwnsnRj8Epqc4voVJJ8eULOtW4BFYYjPpgjc+3IWI2sh/wFeoVl6xYQ48dDGLfjOwc/ygQqz595hUMVouS9QHBm4l6k3HWpnBLqicgVrlCxujSbR1Swd+UDpQYRS/oVaZZ2MWwlZBB9Zdxf+QTYw8SRgq6sAvz7vH6JULEB9USVHFjzZskgPRQR4ayxoXXIdaHdg9HrB2rErByWxZERnYtrWUzD0esDbtTmXPhzXwHrKLxEUbYGbVyDkWWwkdadfqbPmwDXZSCyh2VAf8BM/nOOJH5Y0QopMt2I5U/KO4DZZJB47XwEX+ex0mGroMV8sLwp56gyuGwiyW4SZ4c8bt2cZzO7jBNeqtsRHbeZ1g9krsMbhfYPv/c5gecA+4yAVuitFszE5Hjva872OMdXagF19JIoOYWyozMTI3J0tmA6gLvXPfO1cc/XOqOLe/HbjwD8yCXRD3VNJROEgatiRZMJsKsbZHia3nAdphtq8fkiWyXJbBe8uM1aXKPouNnz2Bm1kwAAAAAElFTkSuQmCC"); + mask: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAApNJREFUSA2tlUtLlFEYgEe7mtFFQ8NLFBG1SJAwahtiLVy5ceVSEPQH1LZf0Lp9FO1bdYNyI7gyBFcS2kAzilYq3sfxeYZ55Zv6FGfohWfOd97vnPd23u9MXeZoaeb1Q7gPHXAFzsAS5GACxiEPqVKXqs1kmtAPQi+cAtedToz1PJ8ExxPwDl7CAlRImoN7rBiF86ABHTiGAzMIh8n3O+ifwWc4EL0n5TGTYQijvjNSxbUadozInUeGZ3nuh1WYgpIkM+hGMwYajAgdrfUn+AaLoLTCAxiAOxCZxL4RdB+gZMjxEjwHo4hS8Jh5DR9hz0mKaNizegoNEI7Wee6FXJTISG6AUgDr+QImoAiHie+mwZJow/PRps4uwnsnRj8Epqc4voVJJ8eULOtW4BFYYjPpgjc+3IWI2sh/wFeoVl6xYQ48dDGLfjOwc/ygQqz595hUMVouS9QHBm4l6k3HWpnBLqicgVrlCxujSbR1Swd+UDpQYRS/oVaZZ2MWwlZBB9Zdxf+QTYw8SRgq6sAvz7vH6JULEB9USVHFjzZskgPRQR4ayxoXXIdaHdg9HrB2rErByWxZERnYtrWUzD0esDbtTmXPhzXwHrKLxEUbYGbVyDkWWwkdadfqbPmwDXZSCyh2VAf8BM/nOOJH5Y0QopMt2I5U/KO4DZZJB47XwEX+ex0mGroMV8sLwp56gyuGwiyW4SZ4c8bt2cZzO7jBNeqtsRHbeZ1g9krsMbhfYPv/c5gecA+4yAVuitFszE5Hjva872OMdXagF19JIoOYWyozMTI3J0tmA6gLvXPfO1cc/XOqOLe/HbjwD8yCXRD3VNJROEgatiRZMJsKsbZHia3nAdphtq8fkiWyXJbBe8uM1aXKPouNnz2Bm1kwAAAAAElFTkSuQmCC"); + -webkit-mask-size: 12px; + mask-size: 12px; +} + +.mediumStyle { + width: 24px; + height: 24px; + -webkit-mask: url("data:image/png;base64,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"); + mask: url("data:image/png;base64,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"); + -webkit-mask-size: 24px; + mask-size: 24px; +} + +.largeStyle { + width: 30px; + height: 30px; + -webkit-mask: url("data:image/png;base64,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"); + mask: url("data:image/png;base64,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"); + -webkit-mask-size: 30px; + mask-size: 30px; +} + +.centeredStyle { + --spinner-translate: -50%; + position: absolute; + top: 50%; + left: 50%; + transform: translate(var(--spinner-translate), var(--spinner-translate)); +} + +.spin { + animation-duration: 800ms; + animation-iteration-count: infinite; + animation-name: spin; + animation-timing-function: linear; +} + +@keyframes spin { + 0% { + transform: translate(var(--spinner-translate), var(--spinner-translate)) rotate(0deg); + } + + 100% { + transform: translate(var(--spinner-translate), var(--spinner-translate)) rotate(360deg); + } +} diff --git a/plugins/semrush/src/constants.ts b/plugins/semrush/src/constants.ts new file mode 100644 index 00000000..13343c43 --- /dev/null +++ b/plugins/semrush/src/constants.ts @@ -0,0 +1,806 @@ +interface AuditIssue { + title: string + description: string + solution: string + type: "error" | "warning" | "notice" +} + +export const SEMRUSH_API_KEY = "semrushApiKey" + +export const AUDIT_ISSUES: Record<number, AuditIssue> = { + 1: { + title: "Pages returning 5XX status code", + type: "error", + description: + "5xx errors refer to problems with a server being unable to perform the request from a user or a crawler. They prevent users and search engine robots from accessing your webpages, and can negatively affect user experience and search engines' crawlability. This will in turn lead to a drop in traffic driven to your website.", + solution: "Investigate the causes of these errors and fix them.", + }, + 3: { + title: "Pages don't have title tags", + type: "error", + description: + "A <title> tag is a key on-page SEO element. It appears in browsers and search results and helps both search engines and users understand what your page is about.<br>\nIf a page is missing a title, or a <title> tag is empty, Google may consider it low quality. In case you promote this page in search results, you will miss chances to rank high and gain a higher click-through rate.", + solution: + 'Ensure that every page on your website has a unique and concise title containing your most important keywords. For information on how to create effective titles, please <a href="https://support.google.com/webmasters/answer/35624" rel="nofollow" target="_blank">see this Google article</a>. You can also view the On-Page SEO Basics: <a href="https://www.semrush.com/blog/on-page-seo-basics-meta-descriptions/" rel="nofollow" target="_blank">Meta Descriptions article</a>.', + }, + 6: { + title: "Issues with duplicate title tags", + description: + "Our crawler reports pages that have duplicate title tags only if they are exact matches. Duplicate <title> tags make it difficult for search engines to determine which of a website's pages is relevant for a specific search query, and which one should be prioritized in search results. Pages with duplicate titles have a lower chance of ranking well and are at risk of being banned. Moreover, identical <title> tags confuse users as to which webpage they should follow.", + solution: + 'Provide a unique and concise title for each of your pages that contains your most important keywords. For information on how to create effective titles, please <a href="https://support.google.com/webmasters/answer/35624" rel="nofollow" target="_blank">see this Google article</a>. You can also view the On-Page SEO Basics: <a href="https://www.semrush.com/blog/on-page-seo-basics-meta-descriptions/" rel="nofollow" target="_blank">Meta Descriptions article</a>.', + type: "error", + }, + 7: { + title: "Pages with duplicate content issues", + type: "error", + description: + "Webpages are considered duplicates if their content is 85% identical. Having duplicate content may significantly affect your SEO performance. First of all, Google will typically show only one duplicate page, filtering other instances out of its index and search results, and this page may not be the one you want to rank.<br>\nIn some cases, search engines may consider duplicate pages as an attempt to manipulate search engine rankings, and, as a result, your website may be downgraded or even banned from search results. Moreover, duplicate pages may dilute your link profile.", + solution: + 'Here are a few ways to fix duplicate content issues:<br>\n- Add a rel="canonical" link to one of your duplicate pages to inform search engines which page to show in search results<br>\n- Use a 301 redirect from a duplicate page to the original one<br>\n- Use a rel="next" and a rel="prev" link attribute to fix pagination duplicates<br>\n- Instruct GoogleBot to handle URL parameters differently using Google Search Console<br>\n- Provide some unique content on the webpage<br>\nFor more information, please read these Google articles: <a href="https://support.google.com/webmasters/answer/66359?hl=en" rel="nofollow" target="_blank">"Duplicate content"</a> and <a href="https://support.google.com/webmasters/answer/139066?hl=en" rel="nofollow" target="_blank">"Consolidate duplicate URLs"</a>.', + }, + 8: { + title: "Broken internal links", + type: "error", + description: + "Broken internal links lead users from one website to another and bring them to non-existent webpages. Multiple broken links negatively affect user experience and may worsen your search engine rankings because crawlers may think that your website is poorly maintained or coded.<br>\nPlease note that our crawler may detect a working link as broken. Generally, this happens if the server hosting the website you're referring to blocks our crawler from accessing this website.", + solution: + "Please follow all links reported as broken. If a target webpage returns an error, remove the link leading to the error page or replace it with another resource.<br>\nIf the links reported as broken do work when accessed with a browser, you should contact the website's owner and inform them about the issue.", + }, + 10: { + title: "Pages couldn't be crawled (DNS resolution issues)", + type: "error", + description: + "A DNS resolution error is reported when our crawler can't resolve the hostname when trying to access your webpage.", + solution: "Please contact your web hosting technical support and ask them to investigate and fix the issue.", + }, + 9: { + title: "Pages couldn't be crawled (incorrect URL formats)", + type: "error", + description: + "This issue indicates that our crawler couldn't access the webpage. There are two possible reasons:<br>\n- Your site's server response time is more than 5 seconds<br>\n- Your server refused access to your webpages", + solution: + "Make sure your page's URL conforms to a standard scheme and doesn't have any unnecessary characters or typos.", + }, + 13: { + title: "Broken internal images", + type: "error", + description: + "An internal broken image is an image that can't be displayed because it no longer exists, its URL is misspelled, or because the file path is not valid. Broken images may jeopardize your search rankings because they provide a poor user experience and signal to search engines that your page is low quality.", + solution: + "To fix a broken internal image, perform one of the following:<br>\n- If an image is no longer located in the same location, change its URL<br>\n- If an image was deleted or damaged, replace it with a new one<br>\n- If an image is no longer needed, simply remove it from your page's code", + }, + 15: { + title: "Pages with duplicate meta descriptions", + type: "error", + description: + "Our crawler reports pages that have duplicate meta descriptions only if they are exact matches.<br>\nA <meta description> tag is a short summary of a webpage's content that helps search engines understand what the page is about and can be shown to users in search results.<br>\nDuplicate meta descriptions on different pages mean a lost opportunity to use more relevant keywords. Also, duplicate meta descriptions make it difficult for search engines and users to differentiate between different webpages. It is better to have no meta description at all than to have a duplicate one.", + solution: + 'Provide a unique, relevant meta description for each of your webpages.<br>\nFor information on how to create effective meta descriptions, please see <a href="https://support.google.com/webmasters/answer/35624" rel="nofollow" target="_blank">this Google article</a>.<br>\nYou can also view the On-Page SEO Basics: <a href="https://www.semrush.com/blog/on-page-seo-basics-meta-descriptions/" rel="nofollow" target="_blank">Meta Descriptions article</a>.', + }, + 16: { + title: "Format errors in Robots.txt file", + type: "error", + description: + "If your robots.txt file is poorly configured, it can cause you a lot of problems.<br>\nWebpages that you want to be promoted in search results may not be indexed by search engines, while some of your private content may be exposed to users.<br>\nSo, one configuration mistake can damage your search rankings, ruining all your search engine optimization efforts.", + solution: + 'Review your robots.txt file and fix all errors, if there are any.<br>\nYou can check your file using <a href="https://www.google.com/webmasters/tools/robots-testing-tool" rel="nofollow" target="_blank">Google\'s robots.txt Tester</a>.<br>\nFor information on how to configure your robots.txt, please see <a href="https://developers.google.com/search/reference/robots_txt" rel="nofollow" target="_blank">this article</a>.', + }, + 17: { + title: "Format errors in sitemap.xml files", + type: "error", + description: + "If your sitemap.xml file has any errors, search engines will not be able to process the data it contains, and they will ignore it.", + solution: + 'Review your sitemap.xml file and fix all errors.<br>\nYou can check your file using the Sitemaps report in <a href="https://search.google.com/search-console/not-verified?original_url=/search-console/sitemaps&original_resource_id" rel="nofollow" target="_blank">Google Search Console</a>.<br>\nFor information on how to configure your sitemap.xml, please see<a href="https://www.sitemaps.org/protocol.html" rel="nofollow" target="_blank"> this article</a>.', + }, + 18: { + title: "Incorrect pages found in sitemap.xml", + type: "error", + description: + "A sitemap.xml file makes it easier for crawlers to discover the pages on your website. Only good pages intended for your visitors should be included in your sitemap.xml file.<br>\nThis error is triggered if your sitemap.xml contains URLs that:<br>\n- lead to webpages with the same content<br>\n- redirect to a different webpage<br>\n- return non-200 status code<br>\nPopulating your file with such URLs will confuse search engines, cause unnecessary crawling or may even result in your sitemap being rejected.", + solution: + "Review your sitemap.xml for any redirected, non-canonical or non-200 URLs. Provide the final destination URLs that are canonical and return a 200 status code.", + }, + 19: { + title: "Pages with a WWW resolve issue", + type: "error", + description: + "Normally, a webpage can be accessed with or without adding www to its domain name. If you haven’t specified which version should be prioritized, search engines will crawl both versions, and the link juice will be split between them. Therefore, none of your page versions will get high positions in search results.", + solution: + 'Specify which version of your webpage you want to be the main one. Use Google Search Console data to define pages that are indexed. We recommend that you redirect an alternate version of your page to the preferred version via a 301 redirect. For more information, please see the <a href="https://developers.google.com/search/docs/advanced/crawling/consolidate-duplicate-urls?visit_id=637691140006114086-295375402&rd=1" rel="nofollow" target="_blank">Consolidate duplicate URLs </a>article.', + }, + 20: { + title: "Pages with no viewport tag", + type: "error", + description: + 'The viewport meta tag is an HTML tag that allows you to control a page\'s viewport size and scale on mobile devices. This tag is indispensable if you want to make your website accessible and optimized for mobile devices.<br>\nFor more information about the viewport meta tag, please see <a href="https://developers.google.com/web/fundamentals/design-and-ux/responsive/" rel="nofollow" target="_blank">the Responsive Web Design Basics article</a>.', + solution: + "Set the viewport meta tag for each page, and then test your website on a mobile device to make sure everything works fine.", + }, + 21: { + title: "Size of HTML on a page is too large", + type: "error", + description: + "A webpage’s HTML size is the size of all HTML code contained in it. A page size that is too large (i.e., exceeding 2 MB) leads to a slower page load time, resulting in a poor user experience and a lower search engine ranking.", + solution: + "Review your page’s HTML code and consider optimizing its structure and/or removing inline scripts and styles.", + }, + 22: { + title: "AMP pages with no canonical tag", + type: "error", + description: + 'This issue is triggered if your AMP page has no canonical tag.<br>\nWhen creating AMP pages, several requirements should be met:<br>\n- If you have both an AMP and a non-AMP version of the same page, you should place canonical tags on both versions to prevent duplicate content issues<br>\n- If you have only an AMP version of your webpage, it must have a self-referential canonical tag<br>\nFor more information, please see these articles: <a href="https://support.google.com/webmasters/answer/6340290?hl=en#discovery" rel="nofollow" target="_blank">AMP on Google Search guidelines</a> and <a href="https://www.semrush.com/blog/fixing-amp-validation-errors/" rel="nofollow" target="_blank">ABC of Fixing AMP Validation Errors With Semrush</a>', + solution: 'Add a rel="canonical" tag in the <head> section of each AMP page.', + }, + 2: { + title: "Pages returning 4XX status code", + type: "error", + description: + "A 4xx error means that a webpage cannot be accessed. This is usually the result of broken links. These errors prevent users and search engine robots from accessing your webpages, and can negatively affect both user experience and search engine crawlability. This will in turn lead to a drop in traffic driven to your website. Please be aware that the crawler may detect a working link as broken if your website blocks our crawler from accessing it. This usually happens due to the following reasons:<br>\n- DDoS protection system<br>\n- Overloaded or misconfigured server", + solution: + 'If a webpage returns an error, remove all links leading to the error page or replace it with another resource.<br>\nTo identify all pages on your website that contain links to a 4xx page, click "View broken links" next to the error page.<br>\nIf the links reported as 4xx do work when accessed with a browser, you can try either of the following:<br>\n- Contact your web hosting support team<br>\n- Instruct search engine robots not to crawl your website too frequently by specifying the "crawl-delay" directive in your robots.txt', + }, + 26: { + title: "Non-secure pages", + type: "error", + description: + 'This issue is triggered if our crawler detects an HTTP page with a <input type="password"> field.<br>\nUsing a <input type="password"> field on your HTTP page is harmful to user security, as there is a high risk that user login credentials can be stolen. To protect users\' sensitive information from being compromised, Google Chrome will start informing users about the dangers of submitting their passwords on HTTP pages by labeling such pages as "non-secure" starting January 2017. This could have a negative impact on your bounce rate, as users will most likely feel uncomfortable and leave your page as quickly as possible.', + solution: + 'Move your HTTP webpages that contain a password field to HTTPS. Please follow these <a href="https://support.google.com/webmasters/answer/6033049" rel="nofollow" target="_blank">Google guidelines</a>.', + }, + 27: { + title: "Issues with expiring or expired certificate", + type: "error", + description: + "This issue is triggered if your certificate has expired or will expire soon.<br>\nIf you allow your certificate to expire, users accessing your website will be presented with a warning message, which usually stops them from going further and may lead to a drop in your organic search traffic.", + solution: + "Ask your website administrator to renew the certificate and run periodic checks to avoid any future issues.", + }, + 28: { + title: "Issues with old security protocol", + type: "error", + description: + "Running SSL or old TLS protocol (version 1.0) is a security risk, which is why it is strongly recommended that you implement the newest protocol versions.", + solution: "Update your security protocol to the latest version.", + }, + 29: { + title: "Issues with incorrect certificate name", + type: "error", + description: + "If the domain or subdomain name to which your SSL certificate is registered doesn't match the name displayed in the address bar, web browsers will block users from visiting your website by showing them a name mismatch error, and this will in turn negatively affect your organic search traffic.", + solution: + "Contact your website administrator and ask them to install the correct certificate.<br>\nSince subdomains also require their own certificates, you can use a wildcard or multi-domain SSL certificate that allows you to secure multiple subdomains.", + }, + 30: { + title: "Issues with mixed content", + type: "error", + description: + "If your website contains any elements that are not secured with HTTPS, this may lead to security issues. Moreover, browsers will warn users about loading unsecure content, and this may negatively affect user experience and reduce their confidence in your website.", + solution: + "Only embed HTTPS content on HTTPS pages.<br>\nReplace all HTTP links with the new HTTPS versions. If there are any external links leading to a page that has no HTTPS version, remove those links.", + }, + 32: { + title: "No redirect or canonical to HTTPS homepage from HTTP version", + type: "error", + description: + "If you're running both HTTP and HTTPS versions of your homepage, it is very important to make sure that their coexistence doesn't impede your SEO. Search engines are not able to figure out which page to index and which one to prioritize in search results. As a result, you may experience a lot of problems, including pages competing with each other, traffic loss, and poor placement in search results. To avoid these issues, you must instruct search engines to only index the HTTPS version.", + solution: + 'Do either of the following:<br>\n- Redirect your HTTP page to the HTTPS version via a 301 redirect<br>\n- Mark up your HTTPS version as the preferred one by adding a rel="canonical" to your HTTP pages', + }, + 33: { + title: "Redirect chains and loops", + type: "error", + description: + "Redirecting one URL to another is appropriate in many situations. However, if redirects are done incorrectly, it can lead to disastrous results. Two common examples of improper redirect usage are redirect chains and loops.Long redirect chains and infinite loops lead to a number of problems that can damage your SEO efforts. They make it difficult for search engines to crawl your site, which affects your crawl budget usage and how well your webpages are indexed, slows down your site's load speed, and, as a result, may have a negative impact on your rankings and user experience.Please note that if you can’t spot a redirect chain with your browser, but it is reported in your Site Audit report, your website probably responds to crawlers’ and browsers’ requests differently, and you still need to fix the issue.", + solution: + "The best way to avoid any issues is to follow one general rule: do not use more than three redirects in a chain.If you are already experiencing issues with long redirect chains or loops, we recommend that you redirect each URL in the chain to your final destination page.We do not recommend that you simply remove redirects for intermediate pages as there can be other links pointing to your removed URLs, and, as a result, you may end up with 404 errors.", + }, + 34: { + title: "AMP HTML issues", + type: "error", + description: + "In order for AMP pages to be served properly to mobile users, they must be compliant with AMP guidelines.<br>\nIf your HTML doesn't adhere to AMP standards, your AMP page will not work correctly, and may not be indexed by search engines, and, as a result, may not appear in mobile search results.", + solution: + 'Since there are multiple reasons why your page\'s HTML may not comply with AMP standards, we provide specific how-to-fix tips for each invalid AMP page. These tips are provided in the \'Issue Description\' column on the page that lists all pages with HTML issues.<br>\nYou can also check out the <a href="https://www.semrush.com/blog/fixing-amp-validation-errors/" rel="nofollow" target="_blank">ABC of Fixing AMP Validation Errors With Semrush</a> article to get more information.', + }, + 35: { + title: "AMP style and layout issues", + type: "error", + description: + "In order for AMP pages to be served properly to mobile users, they must be compliant with AMP guidelines.<br>\nIf the style and layout of your AMP page do not adhere to AMP standards, the page will not work correctly, and may not be indexed by search engines, and, as a result, may not appear in mobile search results.", + solution: + 'Since there are multiple reasons why your page\'s style and layout may not comply with AMP standards, we provide specific how-to-fix tips for each invalid AMP page. These tips are provided in the \'Issue Description\' column on the page that lists all pages with style and layout issues.<br>\nYou can also check out the <a href="https://www.semrush.com/blog/fixing-amp-validation-errors/" rel="nofollow" target="_blank">ABC of Fixing AMP Validation Errors With Semrush</a> article to get more information.', + }, + 36: { + title: "AMP templating issues", + type: "error", + description: + "In order for AMP pages to be served properly to mobile users, they must be compliant with AMP guidelines.<br>\nIf your AMP page includes templating syntax, it will not work correctly and may not be indexed by search engines, and, as a result, may not appear in mobile search results.", + solution: + 'Since there are different types of templating issues that your AMP page can have, we provide specific how-to-fix tips for each invalid AMP page. These tips are provided in the \'Issue Description\' column on the page that lists all pages with templating issues.<br>\nYou can also check out the <a href="https://www.semrush.com/blog/fixing-amp-validation-errors/" rel="nofollow" target="_blank">ABC of Fixing AMP Validation Errors With Semrush</a> article to get more information.', + }, + 38: { + title: "Pages with a broken canonical link", + type: "error", + description: + 'By setting a rel="canonical" element on your page, you can inform search engines of which version of a page you want to show up in search results. When using canonical tags, it is important to make sure that the URL you include in your rel="canonical" element leads to a page that actually exists. Canonical links that lead to non-existent webpages complicate the process of crawling and indexing your content and, as a result, decrease crawling efficiency and lead to unnecessary crawl budget waste.', + solution: + "Review all broken canonical links. If a canonical URL applies to a non-existent webpage, remove it or replace it with another resource.", + }, + 39: { + title: "Pages with multiple canonical URLs", + type: "error", + description: + "Multiple rel=”canonical” tags with different URLs specified for the same page confuse search engines and make it almost impossible for them to identify which URL is the actual canonical page. As a result, search engines will likely ignore all the canonical elements or pick the wrong one. That’s why it is recommended that you specify no more than one rel=”canonical” for a page.", + solution: "Remove all canonical URLs except the one that you’d like to serve as the actual canonical page.", + }, + 40: { + title: "Pages with a meta refresh tag", + type: "error", + description: + "A meta refresh tag instructs a web browser to redirect a user to a different page after a given interval. Generally, it is recommended that you avoid using a meta refresh tag as it is considered a poor, slow, and outdated technique that may lead to SEO and usability issues.", + solution: + "Review all pages with a meta refresh tag. If this tag is used to redirect an old page to a new one, replace it with a 301 redirect.", + }, + 41: { + title: "Issues with broken internal JavaScript and CSS files", + type: "error", + description: + "A broken JavaScript or CSS file is an issue that should be watched out for on your website. Any script that has stopped running on your website may jeopardize your rankings, since search engines will not be able to properly render and index your webpages. Moreover, broken JS and CSS files may cause website errors, and this will certainly spoil your user experience.", + solution: "Review all broken JavaScript and CSS files hosted on your website and fix any issues.", + }, + 42: { + title: "Subdomains don’t support secure encryption algorithms", + type: "error", + description: + "This issue is triggered when we connect to your web server and detect that it uses old or deprecated encryption algorithms. Using outdated encryption algorithms is a security risk that can have a negative impact on your user experience and search traffic. Some web browsers may warn users accessing your website about loading insecure content. This usually negatively affects their confidence in your website, thereby stopping them from going further, and as a result, you may experience a drop in your organic search traffic.", + solution: "Contact your website administrator and ask them to update encryption algorithms.", + }, + 43: { + title: "Sitemap.xml files are too large", + type: "error", + description: + "This issue is triggered if the size of your sitemap.xml file (uncompressed) exceeds 50 MB or it contains more than 50,000 URLs. Sitemap files that are too large will put your site at risk of being ineffectively crawled or even ignored by search engines.", + solution: + 'Break up your sitemap into smaller files. You will also need to create a sitemap index file to list all your sitemaps and submit it to Google.<br>\nDon\'t forget to specify the location of your new sitemap.xml files in your robots.txt.<br>\nFor more details, see <a href="https://support.google.com/webmasters/answer/183668?hl=en" rel="nofollow" target="_blank">this Google article</a>.', + }, + 111: { + title: "Pages with slow load speed", + type: "error", + description: + 'Page (HTML) load speed is one of the most important ranking factors. The quicker your page loads, the higher the rankings it can receive. Moreover, fast-loading pages positively affect user experience and may increase your conversion rates.<br>\nPlease note that "page load speed" usually refers to the amount of time it takes for a webpage to be fully rendered by a browser. However, the crawler only measures the time it takes to load a webpage’s HTML code - load times for images, JavaScript, and CSS are not factored in.', + solution: + "The main factors that negatively affect your HTML page generation time are your server’s performance and the density of your webpage’s HTML code.<br>\nSo, try to clean up your webpage’s HTML code. If the problem is with your web server, you should think about moving to a better hosting service with more resources.", + }, + 45: { + title: "Invalid structured data items", + type: "error", + description: + 'This issue is triggered if structured data items contain fields that do not meet <a href="https://developers.google.com/search/docs/data-types/article" rel="nofollow" target="_blank">Google\'s guidelines</a>.Implementing and maintaining your structured data correctly is important if you want to get an edge over your competitors in search results.If your website markup has errors, crawlers will not be able to properly understand it, and you may run the risk of losing the chance of gaining rich snippets and getting more favorable rankings.For more information on the structured data requirements, see <a href="https://schema.org/" rel="nofollow" target="_blank">schema.org</a>, <a href="https://developers.google.com/search/docs/data-types/article" rel="nofollow" target="_blank">Google documentation</a>, or <a href="https://www.semrush.com/kb/1084-structured-data-items-site-audit" target="_blank">our article</a>.', + solution: + 'Check structured data on your webpages with a validation tool. Please note that different markup testing tools may show different results.We recommend that you use the <a href="https://search.google.com/test/rich-results" rel="nofollow" target="_blank">Rich Results Test</a> tool to review and validate your pages’ structured data against their rich snippet requirements.', + }, + 44: { + title: "Malformed links", + type: "error", + description: + "This issue is reported when SemrushBot fails to crawl a link because of an invalid link's URL.Common mistakes include the following:- Invalid URL syntax (e.g., no or an invalid protocol is specified, backslashes (\\) are used)- Spelling mistakes- Unnecessary additional characters", + solution: + "Make sure the link's URL conforms to a standard scheme and doesn't have any unnecessary characters or typos.", + }, + 46: { + title: "Missing the viewport width value", + type: "error", + description: + "This issue is triggered if the viewport meta tag used on your page is missing the width or initial scale value. <br/>The viewport meta tag is an HTML tag that allows you to control a page’s viewport size and scale on mobile devices. <br/>This tag is indispensable if you want to make your website accessible and optimized for mobile devices. <br/>For more information about the viewport meta tag, please see the Responsive web design basics article.", + solution: + 'Specify the width and initial-scale values. We recommend you contact your developers for assistance. Once this is done, <a href="https://search.google.com/test/mobile-friendly" rel="nofollow" target="_blank">check your page</a> for mobile-friendliness or re-audit your site.', + }, + 102: { + title: "Pages with too much text within the title tags", + type: "warning", + description: + 'Most search engines truncate titles containing more than 70 characters. Incomplete and shortened titles look unappealing to users and won\'t entice them to click on your page.<br>\nFor more information, please see <a href="https://support.google.com/webmasters/answer/35624" rel="nofollow" target="_blank">this Google article</a>.', + solution: "Try to rewrite your page titles to be 70 characters or less.", + }, + 101: { + title: "Pages without enough text within the title tags", + type: "warning", + description: + 'Generally, using short titles on webpages is a recommended practice. However, keep in mind that titles containing 10 characters or less do not provide enough information about what your webpage is about and limit your page\'s potential to show up in search results for different keywords.<br>\nFor more information, please see <a href="https://support.google.com/webmasters/answer/35624" rel="nofollow" target="_blank">this Google article</a>.', + solution: "Add more descriptive text inside your page's <title> tag.", + }, + 112: { + title: "Pages with low text-HTML ratio", + type: "warning", + description: + "Your text-to-HTML ratio indicates the amount of actual text you have on your webpage compared to the amount of code. This issue is triggered when your text to HTML is 10% or less.<br>\nSearch engines have begun focusing on pages that contain more content. That's why a higher text-to-HTML ratio means your page has a better chance of getting a good position in search results.<br>\nLess code increases your page's load speed and also helps your rankings. It also helps search engine robots crawl your website faster.", + solution: + "Split your webpage's text content and code into separate files and compare their size. If the size of your code file exceeds the size of the text file, review your page's HTML code and consider optimizing its structure and removing embedded scripts and styles.", + }, + 106: { + title: "Pages without meta descriptions", + type: "warning", + description: + 'Though meta descriptions don\'t have a direct influence on rankings, they are used by search engines to display your page\'s description in search results. A good description helps users know what your page is about and encourages them to click on it. If your page\'s meta description tag is missing, search engines will usually display its first sentence, which may be irrelevant and unappealing to users.<br>\nFor more information, please see these articles: <a href="https://support.google.com/webmasters/answer/35624" rel="nofollow" target="_blank">Create good titles and snippets in Search Results</a> and <a href="https://www.semrush.com/blog/on-page-seo-basics-meta-descriptions/" rel="nofollow" target="_blank">On-Page SEO Basics: Meta Descriptions</a>.', + solution: + "In order to gain a higher click-through rate, you should ensure that all of your webpages have meta descriptions that contain relevant keywords.", + }, + 105: { + title: "Pages with duplicate H1 and title tags", + type: "warning", + description: + 'It is a bad idea to duplicate your title tag content in your first-level header. If your page\'s <title> and <h1> tags match, the latter may appear over-optimized to search engines.<br>\nAlso, using the same content in titles and headers means a lost opportunity to incorporate other relevant keywords for your page.<br>\nFor more information, please see <a href="https://support.google.com/webmasters/answer/35624" rel="nofollow" target="_blank">this Google article</a>.', + solution: "Try to create different content for your <title> and <h1> tags.", + }, + 103: { + title: "Pages without an h1 heading", + type: "warning", + description: + "While less important than <title> tags, h1 headings still help define your page’s topic for search engines and users. If an <h1> tag is empty or missing, search engines may place your page lower than they would otherwise. Besides, a lack of an <h1> tag breaks your page’s heading hierarchy, which is not SEO-friendly.", + solution: "Provide a concise, relevant h1 heading for each of your pages.", + }, + 122: { + title: "Pages with an underscore in the URL", + type: "warning", + description: + 'When it comes to URL structure, using underscores as word separators is not recommended because search engines may not interpret them correctly and may consider them to be a part of a word. Using hyphens instead of underscores makes it easier for search engines to understand what your page is about.<br>\nAlthough using underscores doesn\'t have a huge impact on webpage visibility, it decreases your page\'s chances of appearing in search results, as opposed to when hyphens are used.<br>\nFor more information, please see <a href="https://support.google.com/webmasters/answer/76329?hl=en" rel="nofollow" target="_blank">this Google article</a>.', + solution: + "Replace underscores with hyphens. However, if your page ranks well, we do not recommend that you do this.", + }, + 124: { + title: "Sitemap.xml not indicated in robots.txt", + type: "warning", + description: + "If you have both a sitemap.xml and a robots.txt file on your website, it is a good practice to place a link to your sitemap.xml in your robots.txt, which will allow search engines to better understand what content they should crawl.", + solution: + 'Specify the location of your sitemap.xml in your robots.txt. To check if Googlebot can index your sitemap.xml file, use the <a href="https://search.google.com/search-console/not-verified?original_url=/search-console/sitemaps&original_resource_id" rel="nofollow" target="_blank">Sitemaps report in Google Search Console</a>.', + }, + 117: { + title: "Pages with a low word count", + type: "warning", + description: + 'This issue is triggered if the number of words on your webpage is less than 200.<br>\nThe amount of text placed on your webpage is a quality signal to search engines.<br>\nSearch engines prefer to provide as much information to users as possible, so pages with longer content tend to be placed higher in search results, as opposed to those with lower word counts.<br>\nFor more information, please view <a href="https://www.youtube.com/watch?v=w3-obcXkyA4" rel="nofollow" target="_blank">this video</a>.', + solution: "Improve your on-page content and be sure to include more than 200 meaningful words.", + }, + 109: { + title: "Pages with temporary redirects", + type: "warning", + description: + "Temporary redirects (i.e., a 302 and a 307 redirect) mean that a page has been temporarily moved to a new location. Search engines will continue to index the redirected page, and no link juice or traffic is passed to the new page, which is why temporary redirects can damage your search rankings if used by mistake.", + solution: + "Review all URLs to make sure the use of 302 and 307 redirects is justified. If so, don’t forget to remove them when they are no longer needed. However, if you permanently move any page, replace a 302/307 redirect with a 301/308 one.", + }, + 110: { + title: "Images without alt attributes", + type: "warning", + description: + 'Alt attributes within <img> tags are used by search engines to understand the contents of your images. If you neglect alt attributes, you may miss the chance to get a better placement in search results because alt attributes allow you to rank in image search results.<br>\nNot using alt attributes also negatively affects the experience of visually impaired users and those who have disabled images in their browsers.<br>\nFor more information, please see these articles: <a href="https://webmasters.googleblog.com/2007/12/using-alt-attributes-smartly.html" rel="nofollow" target="_blank">Using ALT attributes smartly</a> and <a href="https://support.google.com/webmasters/answer/114016?hl=en" rel="nofollow" target="_blank">Google Image Publishing Guidelines</a>.', + solution: + 'Specify a relevant alternative attribute inside an <img> tag for each image on your website, e.g., "<img src="mylogo.png" alt="This is my company logo">".', + }, + 14: { + title: "Broken external images", + type: "warning", + description: + "A broken external image is an image that can't be displayed because it no longer exists or because its URL is misspelled. Having too many broken external images negatively affects user experience and may be a signal to search engines that your website is poorly coded or maintained.", + solution: + "To fix a broken external image, perform one of the following:<br>\n- If an image was deleted or damaged, replace it with a new one<br>\n- If an image is no longer needed, simply remove it from your page's code<br>\n- If an image moved to a different location and you know its new address, change its URL", + }, + 113: { + title: "Pages with too many parameters in their URLs", + type: "warning", + description: + "Using too many URL parameters is not an SEO-friendly approach. Multiple parameters make URLs less enticing for users to click and may cause search engines to fail to index some of your most important pages.", + solution: "Try to use no more than four parameters in your URLs.", + }, + 114: { + title: "Pages with no hreflang and lang attributes", + type: "warning", + description: + "This issue is reported if your page has neither lang nor hreflang attribute.<br>\nWhen running a multilingual website, you should make sure that you’re doing it correctly.<br>\nFirst, you should use a hreflang attribute to indicate to Google which pages should be shown to visitors based on their location. That way, you can rest assured that your users will always land on the correct language version of your website.<br>\nYou should also declare a language for your webpage’s content (i.e., lang attribute). Otherwise, your web text might not be recognized by search engines. It also may not appear in search results or may be displayed incorrectly.", + solution: + 'Perform the following:<br>\n- Add a lang attribute to the <html> tag, e.g., "<html lang="en">"<br>\n- Add a hreflang attribute to your page\'s <head> tag, e.g., <link rel="alternate" href="http://example.com/" hreflang="en"/>', + }, + 115: { + title: "Pages without character encoding declared", + type: "warning", + description: + "Providing a character encoding tells web browsers which set of characters must be used to display a webpage’s content. If a character encoding is not specified, browsers may not render the page content properly, which may result in a negative user experience. Moreover, search engines may consider pages without a character encoding to be of little help to users and, therefore, place them lower in search results than those with a specified encoding.", + solution: + 'Declare a character encoding either by specifying one in the charset parameter of the HTTP Content-Type header (Content-Type: text/html; charset=utf-8) or by using a meta charset attribute in your webpage HTML (<meta charset="utf-8"/>). For more details, please see these articles: <a href="https://www.w3.org/International/questions/qa-headers-charset" rel="nofollow" target="_blank">Character Encoding - HTTP header</a> and <a href="https://www.w3.org/International/tutorials/tutorial-char-enc" rel="nofollow" target="_blank">Character Encoding - HTML</a>', + }, + 116: { + title: "Pages without doctype declared", + type: "warning", + description: + "A webpage’s doctype instructs web browsers which version of HTML or XHTML is being used. Declaring a doctype is extremely important in order for a page’s content to load properly. If no doctype is specified, this may lead to various problems, such as messed up page content or slow page load speed, and, as a result, negatively affect user experience.", + solution: + 'Specify a doctype for each of your pages by adding a <!Doctype> element (e.g., "<!Doctype HTML5>") to the very top of every webpage source, right before the <html> tag.', + }, + 120: { + title: "Incompatible plugin content", + type: "warning", + description: + "This issue is triggered if your page has content based on Flash, JavaApplet, or Silverlight plugins. These types of plugins do not work properly on mobile devices, which frustrates users. Moreover, they cannot be crawled and indexed properly, negatively impacting your website’s mobile rankings.", + solution: + 'Convert unsupported plugin content into HTML5. If you’re using Flash videos on your website, please see <a href="https://developer.mozilla.org/en-US/docs/Plugins/Flash_to_HTML5/Video" rel="nofollow" target="_blank">this article</a>.', + }, + 121: { + title: "Pages containing frames", + type: "warning", + description: + "<frame> tags are considered to be one of the most significant search engine optimization issues. Not only is it difficult for search engines to index and crawl content within <frame> tags, which may in turn lead to your page being excluded from search results, using these tags also negatively affects user experience.", + solution: "Try to avoid using <frame> tags whenever possible.", + }, + 12: { + title: "Broken external links", + type: "warning", + description: + "Broken external links lead users from one website to another and bring them to non-existent webpages. Multiple broken links negatively affect user experience and may worsen your search engine rankings because crawlers may think that your website is poorly maintained or coded.<br>\nPlease note that our crawler may detect a working link as broken. Generally, this happens if the server hosting the website you're referring to blocks our crawler from accessing this website.", + solution: + "Please follow all links reported as broken. If a target webpage returns an error, remove the link leading to the error page or replace it with another resource.<br>\nIf the links reported as broken do work when accessed with a browser, you should contact the website's owner and inform them about the issue.", + }, + 123: { + title: "Internal links containing nofollow attribute", + type: "warning", + description: + 'The rel="nofollow" attribute is an element in an <a> tag that tells crawlers not to follow the link (e.g., "<a href="http://example.com/link" rel="nofollow">Nofollow link example</a>")."Nofollow" links don’t pass any link juice to referred webpages. That’s why it is not recommended that you use nofollow attributes in internal links. You should let link juice flow freely throughout your website. Moreover, unintentional use of nofollow attributes may result in your webpage being ignored by search engine crawlers even if it contains valuable content.', + solution: "Make sure not to use nofollow attributes by mistake. Remove them from <a> tags, if necessary.", + }, + 108: { + title: "Pages with too many on-page links", + type: "warning", + description: + "This issue is triggered if a webpage contains more than 3,000 links. As a rule, search engines process as many on-page links as they consider necessary for a particular website. However, placing more than 3,000 links on a webpage can make your page look low-quality and even spammy to search engines, which may cause your page to drop in rankings or not show up in search results at all. Having too many on-page links is also bad for user experience.", + solution: + "Review all pages that contain more than 3,000 links and delete unnecessary links.<br>\nYou can also use the Internal Linking report to check your internal linking.", + }, + 125: { + title: "Sitemap.xml not found", + type: "warning", + description: + "A sitemap.xml file is used to list all URLs available for crawling. It can also include additional data about each URL.<br>\nUsing a sitemap.xml file is quite beneficial. Not only does it provide easier navigation and better visibility to search engines, it also quickly informs search engines about any new or updated content on your website. Therefore, your website will be crawled faster and more intelligently.", + solution: + 'Consider generating a sitemap.xml file if you don\'t already have one.<br>\nThen you should specify the location of your sitemap.xml files in your robots.txt, and check if Googlebot can index your sitemap.xml file with the <a href="https://search.google.com/search-console/not-verified?original_url=/search-console/sitemaps&original_resource_id" rel="nofollow" target="_blank">Sitemaps report in Google Search Console</a>', + }, + 127: { + title: "Subdomains don't support SNI", + type: "warning", + description: + "One of the common issues you may face when using HTTPS is when your web server doesn't support Server Name Indication (SNI). Using SNI allows you to support multiple servers and host multiple certificates at the same IP address, which may improve security and trust.", + solution: + "Make sure that your web server supports SNI. Keep in mind that SNI is not supported by some older browsers, which is why you need to ensure that your audience uses browsers supporting SNI.", + }, + 126: { + title: "Homepage does not use HTTPS encryption", + type: "warning", + description: + 'Google considers a website\'s security as a ranking factor. Websites that do not support HTTPS connections may be less prominent in Google\'s search results, while HTTPS-protected sites will rank higher with its search algorithms.<br>\nFor more information, see <a href="https://webmasters.googleblog.com/2014/08/https-as-ranking-signal.html" rel="nofollow" target="_blank">this Google article</a>.', + solution: + 'Switch your site to HTTPS. For more details, see <a href="https://support.google.com/webmasters/answer/6073543" rel="nofollow" target="_blank">Secure your site with HTTPS</a>.', + }, + 128: { + title: "HTTP URLs in sitemap.xml for HTTPS site", + type: "warning", + description: + "Your sitemap.xml should include the links that you want search engines to find and index. Using different URL versions in your sitemap could be misleading to search engines and may result in an incomplete crawling of your website.", + solution: "Replace all HTTP URLs in your sitemap.xml with HTTPS URLs.", + }, + 31: { + title: "Links on HTTPS pages leading to HTTP page", + type: "warning", + description: + "If any link on the website points to the old HTTP version of the website, search engines can become confused as to which version of the page they should rank.", + solution: "Replace all HTTP links with the new HTTPS versions.", + }, + 129: { + title: "Uncompressed pages", + type: "warning", + description: + "This issue is triggered if the Content-Encoding entity is not present in the response header. Page compression is essential to the process of optimizing your website. Using uncompressed pages leads to a slower page load time, resulting in a poor user experience and a lower search engine ranking.", + solution: "Enable compression on your webpages for faster load time.", + }, + 130: { + title: "Issues with blocked internal resources in robots.txt", + type: "warning", + description: + 'Blocked resources are resources (e.g., CSS, JavaScript, image files, etc.) that are blocked from crawling by a "Disallow" directive in your robots.txt file. By disallowing these files, you\'re preventing search engines from accessing them and, as a result, properly rendering and indexing your webpages. This, in return, may lead to lower rankings. For more information, please see <a href="https://developers.google.com/search/docs/crawling-indexing/robots/intro?sjid=4559026812217101995-EU#what-is-a-robots.txt-file-used-for" rel="nofollow" target="_blank">this article</a>.', + solution: "To unblock a resource, simply update your robots.txt file.", + }, + 131: { + title: "Issues with uncompressed JavaScript and CSS files", + type: "warning", + description: + 'This issue is triggered if compression is not enabled in the HTTP response.<br>\nCompressing JavaScript and CSS files significantly reduces their size as well as the overall size of your webpage, thus improving your page load time.<br>\nUncompressed JavaScript and CSS files make your page load slower, which negatively affects user experience and may worsen your search engine rankings.<br>\nIf your webpage uses uncompressed CSS and JS files that are hosted on an external site, you should make sure they do not affect your page\'s load time.<br>\nFor more information, please see <a href="https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/" rel="nofollow" target="_blank">this Google article</a>.', + solution: + "Enable compression for your JavaScript and CSS files on your server.<br>\nIf your webpage uses uncompressed CSS and JS files that are hosted on an external site, contact the website owner and ask them to enable compression on their server.<br>\nIf this issue doesn't affect your page load time, simply ignore it.", + }, + 132: { + title: "Issues with uncached JavaScript and CSS files", + type: "warning", + description: + 'This issue is triggered if browser caching is not specified in the response header.<br>\nEnabling browser caching for JavaScript and CSS files allows browsers to store and reuse these resources without having to download them again when requesting your page. That way the browser will download less data, which will decrease your page load time. And the less time it takes to load your page, the happier your visitors are.<br>\nFor more information, please see <a href="https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/" rel="nofollow" target="_blank">this Google article</a>.', + solution: + "If JavaScript and CSS files are hosted on your website, enable browser caching for them.<br>\nIf JavaScript and CSS files are hosted on a website that you don't own, contact the website owner and ask them to enable browser caching for them.<br>\nIf this issue doesn't affect your page load time, simply ignore it.", + }, + 133: { + title: "Pages have a JavaScript and CSS total size that is too large", + type: "warning", + description: + 'This issue is triggered if the total transfer size of the JavaScript and CSS files used on your page exceeds 2 MB.<br>\nThe size of the JavaScript and CSS files used on a webpage is one of the important factors for a page\'s load time. Having lots of clunky JavaScript and CSS files makes your webpage "heavier" in weight, thus increasing its load time. This in turn leads to a poor user experience and lower search engine rankings.<br>\nFor more information, please see <a href="https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/" rel="nofollow" target="_blank">this Google article</a>.', + solution: + "Review your pages to make sure that they only contain necessary JavaScript and CSS files. If all resources are important for your page, consider reducing their transfer size.", + }, + 134: { + title: "Pages use too many JavaScript and CSS files", + type: "warning", + description: + 'This issue is triggered if a webpage uses more than a hundred JavaScript and CSS files.<br>\nEach time a visitor navigates to a webpage, their browser first starts loading supportive files, such as JavaScript and CSS. For each file used by your webpage, a browser will send a separate HTTP request. Each request increases your page load time and affects its rendering, which has a direct impact on user experience, bounce rate, and, ultimately, search engine rankings.<br>\nFor more information, please see <a href="https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/" rel="nofollow" target="_blank">this Google article</a>.', + solution: + "Review your pages to make sure that they only contain necessary JavaScript and CSS files.<br>\nIf all resources are important for your page, we recommend that you combine them.", + }, + 135: { + title: "Issues with unminified JavaScript and CSS files", + type: "warning", + description: + 'Minification is the process of removing unnecessary lines, white space, and comments from the source code.<br>\nMinifying JavaScript and CSS files makes their size smaller, thereby decreasing your page load time, providing a better user experience, and improving your search engine rankings.<br>\nFor more information, please see <a href="https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/" rel="nofollow" target="_blank">this Google article</a>.', + solution: + "Minify your JavaScript and CSS files.<br>\nIf your webpage uses CSS and JS files that are hosted on an external site, contact the website owner and ask them to minify their files.<br>\nIf this issue doesn't affect your page load time, simply ignore it.", + }, + 136: { + title: "Too long link URLs", + type: "warning", + description: + "This issue is triggered if your link URL is longer than 2,000 characters. Although theoretically there is no character limit for your URLs, it is still recommended that you keep their length under 2,000 characters. This is important because some browsers cannot handle URLs exceeding this limit. Moreover, keeping URLs at a reasonable length will make their crawling much easier, while extremely long URLs may be ignored by search engines.", + solution: + 'Try to keep your link URLs shorter than 2,000 characters. For more information, please see <a href="https://support.google.com/webmasters/answer/76329?hl=en&ref_topic=4617741" rel="nofollow" target="_blank">this article</a>.', + }, + 213: { + type: "notice", + title: "Pages with only one incoming internal linksource formatted as page link", + description: + "Having very few incoming internal links means very few visits, or even none, and fewer chances of placing in search results. It is a good practice to add more incoming internal links to pages with useful content. That way, you can rest assured that users and search engines will never miss them.", + solution: "Add more incoming internal links to pages with important content.", + }, + 218: { + type: "notice", + title: "Links to external pages or resources returned a 403 HTTP status code", + description: + "This issue is triggered if a crawler gets a 403 code when trying to access an external webpage or resource via a link on your site. A 403 HTTP status code is returned if a user is not allowed to access the resource for some reason. In the case of crawlers, this usually means that a crawler is being blocked from accessing content at the server level.", + solution: + "Check that the page is available to browsers and search engines. To do this, follow a link in your browser and check the Google Search Console data.- If a page or resource is not available, contact the owner of the external website to restore deleted content or change the link on your page- If a page is available but our bot is blocked from accessing it, you can ask the external website owner to unblock the page, so we can check all resources correctly. You can also hide this issue from your list.", + }, + 217: { + type: "notice", + title: "Links with non-descriptive anchor text", + description: + 'This issue is triggered if a non-descriptive anchor text is used for a link (either internal or external). An anchor is considered to be non-descriptive if it doesn’t give any idea of what the linked-to page is about, for example, “click here”, “right here”, etc. This type of anchor provides little value to users and search engines as it doesn\'t provide any information about the target page. Also, such anchors will offer little in terms of the target page’s ability to be indexed by search engines, and as a result, rank for relevant search requests. For more information on the criteria used to trigger this check, refer to <a href="https://www.semrush.com/kb/1060-unoptimized-anchors-site-audit" target="_blank">“What are unoptimized anchors and how does Site Audit identify them?”</a>.', + solution: + 'To let users and search engines understand the meaning of the linked-to page, use a succinct anchor text that describes the page’s content. For best practices on how to optimize your anchor text, refer to the “Write good link text” section in <a href="https://support.google.com/webmasters/answer/7451184?hl=en&ref_topic=9460495&authuser=0" rel="nofollow" target="_blank">Google’s Search Engine Optimization (SEO) Starter Guide</a>.', + }, + 212: { + type: "notice", + title: "Pages that need more than 3 clicks to be reached", + description: + "A page's crawl depth is the number of clicks required for users and search engine crawlers to reach it via its corresponding homepage. From an SEO perspective, an excessive crawl depth may pose a great threat to your optimization efforts, as both crawlers and users are less likely to reach deep pages.<br>\nFor this reason, pages that contain important content should be no more than 3 clicks away from your homepage.", + solution: + "Make sure that pages with important content can be reached within a few clicks.<br>\nIf any of them are buried too deep in your site, consider changing your internal link architecture.", + }, + 202: { + type: "notice", + title: "Outgoing external links containing no follow attributes", + description: + 'A nofollow attribute is an element in an <a> tag that tells crawlers not to follow the link. "Nofollow" links don’t pass any link juice or anchor texts to referred webpages. The unintentional use of nofollow attributes may have a negative impact on the crawling process and your rankings.', + solution: + "Make sure you haven’t used nofollow attributes by mistake. Remove them from <a> tags, if needed. ", + }, + 205: { + type: "notice", + title: "Subdomains don't support HSTS", + description: + "HTTP Strict Transport Security (HSTS) informs web browsers that they can communicate with servers only through HTTPS connections. So, to ensure that you don't serve unsecured content to your audience, we recommend that you implement HSTS support.", + solution: "Use a server that supports HSTS.", + }, + 201: { + type: "notice", + title: "URLs longer than 200 characters", + description: + "According to Google, long URLs are not SEO-friendly. Excessive URL length intimidates users and discourages them from clicking or sharing it, thus hurting your page's click-through rate and usability.", + solution: "Keep your URLs at a reasonable length.", + }, + 104: { + type: "notice", + title: "Pages with more than one H1 tag", + description: + "Although multiple <h1> tags are allowed in HTML5, we still do not recommend that you use more than one <h1> tag per page. Including multiple <h1> tags may confuse users.", + solution: "Use multiple <h2>-<h6> tags instead of an <h1>.", + }, + 203: { + type: "notice", + title: "Robots.txt not found", + description: + 'A robots.txt file has an important impact on your overall SEO website\'s performance. This file helps search engines determine what content on your website they should crawl.<br>\nUtilizing a robots.txt file can cut the time search engine robots spend crawling and indexing your website.<br>\nFor more information, please see <a href="https://support.google.com/webmasters/answer/6062608" rel="nofollow" target="_blank">this Google article</a>.', + solution: + 'If you don\'t want specific content on your website to be crawled, creating a robots.txt file is recommended. To check your robots.txt file, use Google\'s robots.txt Tester in <a href="https://www.google.com/webmasters/tools/robots-testing-tool" rel="nofollow" target="_blank">Google Search Console</a>.', + }, + 4: { + type: "notice", + title: "Pages that were blocked from crawling", + description: + "If a page cannot be accessed by search engines, it will never appear in search results. A page can be blocked from crawling either by a robots.txt file or a noindex meta tag.", + solution: "Make sure that pages with valuable content are not blocked from crawling by mistake.", + }, + 206: { + type: "notice", + title: "Orphaned pages (from Google Analytics)", + description: + "A webpage that is not linked internally is called an orphaned page. It is very important to check your website for such pages. If a page has valuable content but is not linked to another page on your website, it can miss out on the opportunity to receive enough link juice. Orphaned pages that no longer serve their purpose confuse your users and, as a result, negatively affect their experience. We identify orphaned pages on your website by comparing the number of pages we crawled to the number of pages in your Google Analytics account. That's why to check your website for any orphaned pages, you need to connect your Google Analytics account.", + solution: + "Review all orphaned pages on your website and do either of the following:<br>\n- If a page is no longer needed, remove it<br>\n- If a page has valuable content and brings traffic to your website, link to it from another page on your website<br>\n- If a page serves a specific need and requires no internal linking, leave it as is.", + }, + 207: { + type: "notice", + title: "Orphaned pages (in sitemap)", + description: + "An orphaned page is a webpage that is not linked internally. Including orphaned pages in your sitemap.xml files is considered to be a bad practice, as these pages will be crawled by search engines. Crawling outdated orphaned pages will waste your crawl budget. If an orphaned page in your sitemap.xml file has valuable content, we recommend that you link to it internally.", + solution: + "Review all orphaned pages in your sitemap.xml files and do either of the following:<br>\n - If a page is no longer needed, remove it<br>\n - If a page has valuable content and brings traffic to your website, link to it from another page on your website<br>\n - If a page serves a specific need and requires no internal linking, leave it as is.", + }, + 209: { + type: "notice", + title: "Pages blocked by X-Robots-Tag: noindex HTTP header", + description: + "The x-robots-tag is an HTTP header that can be used to instruct search engines whether or not they can index or crawl a webpage. This tag supports the same directives as a regular meta robots tag and is typically used to control the crawling of non-HTML files. If a page is blocked from crawling with x-robots-tag, it will never appear in search results.", + solution: "Make sure that pages with valuable content are not blocked from crawling by mistake.", + }, + 211: { + type: "notice", + title: "Issues with broken external JavaScript and CSS files", + description: + "If your website uses JavaScript or CSS files that are hosted on an external site, you should be sure that they work properly. Any script that has stopped running on your website may jeopardize your rankings since search engines will not be able to properly render and index your webpages. Moreover, broken JavaScript and CSS files may cause website errors, and this will certainly spoil your user experience.", + solution: "Contact the website owner and ask them to fix a broken file.", + }, + 214: { + type: "notice", + title: "URLs with a permanent redirect", + description: + "Although using permanent redirects (a 301 or 308 redirect) is appropriate in many situations (for example, when you move a website to a new domain, redirect users from a deleted page to a new one, or handle duplicate content issues), we recommend that you keep them to a reasonable minimum. Every time you redirect one of your website's pages, it decreases your crawl budget, which may run out before search engines can crawl the page you want to be indexed. Moreover, too many permanent redirects can be confusing to users.", + solution: + "Review all URLs with a permanent redirect. Change permanent redirects to a target page URL where possible.", + }, + 216: { + type: "notice", + title: "Links with no anchor text", + description: + "This issue is triggered if a link (either external or internal) on your website has an empty or naked anchor (i.e., anchor that uses a raw URL), or anchor text only contains symbols. Although a missing anchor doesn't prevent users and crawlers from following a link, it makes it difficult to understand what the page you're linking to is about. Also, Google considers anchor text when indexing a page. So, a missing anchor represents a lost opportunity to optimize the performance of the linked-to page in search results.", + solution: + 'Use anchor text for your links where it is necessary. The link text must give users and search engines at least a basic idea of what the target page is about. Also, use short but descriptive text. For more information, please see the "Use link wisely" section in <a href="https://support.google.com/webmasters/answer/7451184?hl=en&ref_topic=9460495&authuser=0" rel="nofollow" target="_blank">Google\'s SEO Starter Guide</a>.', + }, + 215: { + type: "notice", + title: "Resources formatted as page link", + description: + "We detected that some links to resources are formatted with <a href> HTML element. An <a> tag with a href attribute is used to link to other webpages and must only contain a page URL. Search engines will crawl your site from page to page by following these HTML page links. When following a page link that contains a resource, for example, an image, the returned page will not contain anything except an image. This may confuse search engines and will indicate that your site has poor architecture.", + solution: + "Review your links. Replace <a href> links with tags necessary for specific resources. For example, if you’d like to add an image, use an <img> tag with an alt attribute describing the contents of your image.", + }, +} + +export const SEMRUSH_DATABASES = [ + { title: "United States", value: "us" }, + { title: "United Kingdom", value: "uk" }, + { title: "Canada", value: "ca" }, + { title: "Russia", value: "ru" }, + { title: "Germany", value: "de" }, + { title: "France", value: "fr" }, + { title: "Spain", value: "es" }, + { title: "Italy", value: "it" }, + { title: "Brazil", value: "br" }, + { title: "Australia", value: "au" }, + { title: "Argentina", value: "ar" }, + { title: "Belgium", value: "be" }, + { title: "Switzerland", value: "ch" }, + { title: "Denmark", value: "dk" }, + { title: "Finland", value: "fi" }, + { title: "Hong Kong", value: "hk" }, + { title: "Ireland", value: "ie" }, + { title: "Mexico", value: "mx" }, + { title: "Netherlands", value: "nl" }, + { title: "Norway", value: "no" }, + { title: "Poland", value: "pl" }, + { title: "Sweden", value: "se" }, + { title: "Singapore", value: "sg" }, + { title: "Turkey", value: "tr" }, + { title: "Japan", value: "jp" }, + { title: "India", value: "in" }, + { title: "Hungary", value: "hu" }, + { title: "Afghanistan", value: "af" }, + { title: "Albania", value: "al" }, + { title: "Algeria", value: "dz" }, + { title: "Angola", value: "ao" }, + { title: "Armenia", value: "am" }, + { title: "Austria", value: "at" }, + { title: "Azerbaijan", value: "az" }, + { title: "Bahrain", value: "bh" }, + { title: "Bangladesh", value: "bd" }, + { title: "Belarus", value: "by" }, + { title: "Belize", value: "bz" }, + { title: "Bolivia", value: "bo" }, + { title: "Bosnia and Herzegovina", value: "ba" }, + { title: "Botswana", value: "bw" }, + { title: "Brunei", value: "bn" }, + { title: "Bulgaria", value: "bg" }, + { title: "Cabo Verde", value: "cv" }, + { title: "Cambodia", value: "kh" }, + { title: "Cameroon", value: "cm" }, + { title: "Chile", value: "cl" }, + { title: "Colombia", value: "co" }, + { title: "Costa Rica", value: "cr" }, + { title: "Croatia", value: "hr" }, + { title: "Cyprus", value: "cy" }, + { title: "Czech Republic", value: "cz" }, + { title: "Congo", value: "cd" }, + { title: "Dominican Republic", value: "do" }, + { title: "Ecuador", value: "ec" }, + { title: "Egypt", value: "eg" }, + { title: "El Salvador", value: "sv" }, + { title: "Estonia", value: "ee" }, + { title: "Ethiopia", value: "et" }, + { title: "Georgia", value: "ge" }, + { title: "Ghana", value: "gh" }, + { title: "Greece", value: "gr" }, + { title: "Guatemala", value: "gt" }, + { title: "Guyana", value: "gy" }, + { title: "Haiti", value: "ht" }, + { title: "Honduras", value: "hn" }, + { title: "Iceland", value: "is" }, + { title: "Indonesia", value: "id" }, + { title: "Jamaica", value: "jm" }, + { title: "Jordan", value: "jo" }, + { title: "Kazakhstan", value: "kz" }, + { title: "Kuwait", value: "kw" }, + { title: "Latvia", value: "lv" }, + { title: "Lebanon", value: "lb" }, + { title: "Lithuania", value: "lt" }, + { title: "Luxembourg", value: "lu" }, + { title: "Madagascar", value: "mg" }, + { title: "Malaysia", value: "my" }, + { title: "Malta", value: "mt" }, + { title: "Mauritius", value: "mu" }, + { title: "Moldova", value: "md" }, + { title: "Mongolia", value: "mn" }, + { title: "Montenegro", value: "me" }, + { title: "Morocco", value: "ma" }, + { title: "Mozambique", value: "mz" }, + { title: "Namibia", value: "na" }, + { title: "Nepal", value: "np" }, + { title: "New Zealand", value: "nz" }, + { title: "Nicaragua", value: "ni" }, + { title: "Nigeria", value: "ng" }, + { title: "Oman", value: "om" }, + { title: "Panama", value: "pa" }, + { title: "Pakistan", value: "pk" }, + { title: "Taiwan", value: "tw" }, + { title: "Qatar", value: "qa" }, + { title: "Senegal", value: "sn" }, + { title: "Serbia", value: "rs" }, + { title: "Slovakia", value: "sk" }, + { title: "Slovenia", value: "si" }, + { title: "South Africa", value: "za" }, + { title: "South Korea", value: "kr" }, + { title: "Sri Lanka", value: "lk" }, + { title: "Thailand", value: "th" }, + { title: "Bahamas", value: "bs" }, + { title: "Trinidad and Tobago", value: "tt" }, + { title: "Tunisia", value: "tn" }, + { title: "Ukraine", value: "ua" }, + { title: "United Arab Emirates", value: "ae" }, + { title: "Uruguay", value: "uy" }, + { title: "Venezuela", value: "ve" }, + { title: "Vietnam", value: "vn" }, + { title: "Zambia", value: "zm" }, + { title: "Zimbabwe", value: "zw" }, + { title: "Libya", value: "ly" }, +] diff --git a/plugins/semrush/src/globals.css b/plugins/semrush/src/globals.css new file mode 100644 index 00000000..3fce3084 --- /dev/null +++ b/plugins/semrush/src/globals.css @@ -0,0 +1,64 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + h6 { + @apply font-semibold text-primary leading-[1.2]; + } +} + +@layer components { + .row { + display: flex; + flex-direction: row; + gap: 10px; + } + + .col { + display: flex; + flex-direction: column; + gap: 10px; + } + + .row-lg { + display: flex; + flex-direction: row; + gap: 15px; + } + + .col-lg { + display: flex; + flex-direction: column; + gap: 15px; + } + + .framer-button-destructive { + @apply bg-framer-red text-white; + } + + .framer-button-destructive:hover, + .framer-button-destructive:focus { + background-color: #e15; + } + + .input-container { + @apply row items-center justify-between pl-[15px] text-tertiary; + } + + .input-container > p { + @apply max-w-[134px] truncate text-primary; + } + + .tile { + @apply bg-tertiaryDimmedLight dark:bg-tertiaryDimmedDark hover:bg-tertiary; + } +} + +body, +html, +#root { + height: fit-content; + width: fit-content; + overflow: hidden; +} diff --git a/plugins/semrush/src/main.tsx b/plugins/semrush/src/main.tsx new file mode 100644 index 00000000..87500905 --- /dev/null +++ b/plugins/semrush/src/main.tsx @@ -0,0 +1,29 @@ +import "./globals.css" +import "framer-plugin/framer.css" + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import React from "react" +import ReactDOM from "react-dom/client" +import { App } from "./App.tsx" + +const root = document.getElementById("root") +if (!root) throw new Error("Root element not found") + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, + throwOnError: true, + }, + }, +}) + +ReactDOM.createRoot(root).render( + <React.StrictMode> + <QueryClientProvider client={queryClient}> + <App /> + </QueryClientProvider> + </React.StrictMode> +) diff --git a/plugins/semrush/src/pages/KeywordsSearchPage.tsx b/plugins/semrush/src/pages/KeywordsSearchPage.tsx new file mode 100644 index 00000000..e00d89c6 --- /dev/null +++ b/plugins/semrush/src/pages/KeywordsSearchPage.tsx @@ -0,0 +1,261 @@ +import { useEffect, useState, useCallback } from "react" +import { useInView } from "react-intersection-observer" +import { Area, AreaChart } from "recharts" +import { useDarkMode } from "usehooks-ts" +import { SEMRUSH_DATABASES } from "@/constants" +import { useInfiniteKeywordSearchQuery } from "@/api" +import { Columns, KeywordSearchSort, KeyphraseSearchType as SearchType } from "@/semrush" +import { Spinner } from "../components/Spinner" +import cx from "classnames" +import { TableBody, TableCell, TableContainer, TableHead, TableRow } from "@/components/Table" +import { DownArrowIcon } from "@/components/Icons" + +type KeywordSearchArgs = Parameters<typeof useInfiniteKeywordSearchQuery>[0] + +export function KeywordsSearchPage() { + const { ref: scrollRef, inView } = useInView() + const [keyword, setKeyword] = useState("") + const debouncedKeyword = useDebounce(keyword, 500) + const [searchOptions, setSearchOptions] = useState<KeywordSearchArgs>({ + keyword: debouncedKeyword, + database: "us", + type: "phrase_related", + sort: { column: Columns.searchVolume, order: "desc" }, + limit: 16, + }) + const { fetchNextPage, hasNextPage, isFetchingNextPage, data, isLoading, isPlaceholderData, isFetching } = + useInfiniteKeywordSearchQuery({ + ...searchOptions, + keyword: debouncedKeyword, + limit: 16, + }) + + useEffect(() => { + if (inView) { + fetchNextPage() + } + }, [inView, fetchNextPage]) + + const handleSort = useCallback( + (newColumn: Columns) => { + const currentSort = searchOptions.sort + let newOrder: KeywordSearchSort["order"] = "desc" + + // Flip sort order if column already sorted + if (currentSort.column === newColumn) { + newOrder = currentSort.order === "asc" ? "desc" : "asc" + } + + setSearchOptions(prevOptions => ({ + ...prevOptions, + sort: { column: newColumn, order: newOrder }, + })) + }, + [searchOptions.sort] + ) + + return ( + <div className="col-lg w-[700px]"> + <div className="row justify-between"> + <div className="row items-center"> + <input + type="text" + placeholder="e.g. pizza place, movies" + className="w-[200px]" + onChange={e => setKeyword(e.target.value)} + value={keyword} + /> + <p>in:</p> + <select + name="database" + onChange={e => + setSearchOptions(prevOptions => ({ + ...prevOptions, + database: e.target.value, + })) + } + > + {SEMRUSH_DATABASES.map((db, i) => ( + <option value={db.value} key={i}> + {db.title} + </option> + ))} + </select> + </div> + <select + name="searchType" + onChange={e => + setSearchOptions(prevOptions => ({ + ...prevOptions, + type: e.target.value as SearchType, + })) + } + > + <option value="phrase_related">Related</option> + <option value="phrase_fullsearch">Full Search</option> + <option value="phrase_questions">Questions</option> + </select> + </div> + <div className="flex items-center justify-center h-[545px] relative"> + {isLoading && <Spinner size="large" inheritColor />} + {isPlaceholderData && isFetching && ( + <div className="w-full h-full flex items-center justify-center absolute top-8 right-3"> + <div className="w-full h-full absolute backdrop-blur-[2px] z-10"></div> + <Spinner size="large" inheritColor className="z-20 ml-3" /> + </div> + )} + {!isLoading && (data === undefined || keyword === "") && ( + <p className="text-tertiary text-center max-w-[300px]"> + Find new ranking opportunities in just a few clicks with a database of 25 billion keywords. + </p> + )} + {data?.length === 0 && keyword && <p className="text-tertiary">Nothing returned.</p>} + {data && data.length > 0 && keyword && ( + <TableContainer className="self-start"> + <TableHead className="pr-4"> + {columnHeadings.map(({ sortColumn, className, name }, i) => { + const { column, order } = searchOptions.sort + + return ( + <TableCell + key={i} + className={cx(className, { + "cursor-pointer": sortColumn, + })} + onClick={sortColumn ? () => handleSort(sortColumn) : undefined} + > + <p>{name}</p> + {column === sortColumn ? ( + order === "asc" ? ( + <DownArrowIcon /> + ) : ( + <div className="rotate-180"> + <DownArrowIcon /> + </div> + ) + ) : null} + </TableCell> + ) + })} + </TableHead> + <TableBody> + {data.map((row, i) => ( + <KeywordRow key={i} row={row} database={searchOptions.database} /> + ))} + <div ref={scrollRef}></div> + {!hasNextPage && ( + <p className="text-tertiary w-full text-center mb-15"> + You have reached the end of the list. + </p> + )} + {isFetchingNextPage && ( + <div className="flex justify-center items-center relative mb-15"> + <Spinner inheritColor={true} /> + </div> + )} + </TableBody> + </TableContainer> + )} + </div> + </div> + ) +} + +type KeywordRow = NonNullable<ReturnType<typeof useInfiniteKeywordSearchQuery>["data"]>[0] + +const KeywordRow = ({ row, database }: { row: KeywordRow; database: string }) => { + const { isDarkMode } = useDarkMode() + const { keyword, intentCodes, trends, cpc, totalResults, searchVolume, difficulty: keywordDifficulty } = row + + const difficulty = parseInt(keywordDifficulty) + + return ( + <TableRow className="min-h-[40px]"> + <TableCell className="grow"> + <a + target="_blank" + href={`https://semrush.com/analytics/keywordoverview/?q=${keyword}&db=${database}`} + className="line-clamp-2" + > + {keyword} + </a> + </TableCell> + <TableCell> + {!intentCodes + ? "-" + : intentCodes.map((code, i) => ( + <span + key={i} + className={cx( + "font-semibold font-sans w-4 h-4 rounded-sm flex items-center justify-center", + { + "bg-[#FCE081] text-[#A75800]": code === "0", + "bg-[#C4E5FE] text-[#006DCA]": code === "1", + "bg-[#EDD9FF] text-[#8649E1]": code === "2", + "bg-[#9EF2C9] text-[#007C65]": code === "3", + } + )} + > + {["C", "I", "N", "T"][Number(code)]} + </span> + ))} + </TableCell> + <TableCell>{searchVolume}</TableCell> + <TableCell> + <AreaChart + width={51} + height={15} + data={trends.map(point => ({ + value: parseFloat(point), + }))} + margin={{ top: 1, right: 0, left: 0, bottom: 2 }} + > + <Area + type="monotone" + dataKey="value" + stroke={"#0099FF"} + fill={isDarkMode ? "transparent" : "#EAF4FF"} + /> + </AreaChart> + </TableCell> + <TableCell>${cpc}</TableCell> + <TableCell>{totalResults}</TableCell> + <TableCell + className={cx({ + "text-framer-green": difficulty < 50, + "text-framer-yellow": difficulty >= 50 && difficulty < 70, + "text-framer-red": difficulty >= 70, + })} + > + {difficulty}% + </TableCell> + </TableRow> + ) +} + +const columnHeadings = [ + { name: "Keyword", className: "grow" }, + { name: "Intent" }, + { + name: "Volume", + sortColumn: Columns.searchVolume, + }, + { name: "Trend" }, + { name: "CPC", sortColumn: Columns.cpc }, + { name: "Results", sortColumn: Columns.totalResults }, + { name: "Difficulty", sortColumn: Columns.difficulty }, +] + +function useDebounce<T>(value: T, delay: number) { + const [debouncedValue, setDebouncedValue] = useState<T>(value) + + useEffect(() => { + const debounce = setTimeout(() => setDebouncedValue(value), delay) + + return () => { + clearTimeout(debounce) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/plugins/semrush/src/pages/Menu.tsx b/plugins/semrush/src/pages/Menu.tsx new file mode 100644 index 00000000..5fedc5b4 --- /dev/null +++ b/plugins/semrush/src/pages/Menu.tsx @@ -0,0 +1,28 @@ +import { MenuOption } from "@/components/MenuOption" +import { ChartIcon, DatabaseIcon, SearchIcon } from "@/components/Icons" +import { usePrefetchAuditQuery } from "@/api" + +export function MenuPage() { + const prefetchAudit = usePrefetchAuditQuery() + + return ( + <div className="col-lg"> + <p className="text-tertiary">Welcome! Research keywords, audit your site and improve your SEO.</p> + <div className="col-lg items-start"> + <div className="row w-full"> + <MenuOption to="/keywords" title="Search"> + <SearchIcon /> + </MenuOption> + <MenuOption to="/audit" title="Audit" onClick={() => prefetchAudit()}> + <ChartIcon /> + </MenuOption> + </div> + <div className="row w-full"> + <MenuOption to="/project" title="Project"> + <DatabaseIcon /> + </MenuOption> + </div> + </div> + </div> + ) +} diff --git a/plugins/semrush/src/pages/Project.tsx b/plugins/semrush/src/pages/Project.tsx new file mode 100644 index 00000000..2f62ab42 --- /dev/null +++ b/plugins/semrush/src/pages/Project.tsx @@ -0,0 +1,50 @@ +import { framer } from "framer-plugin" +import { useDeleteProjectMutation, useProjectQuery } from "@/api" +import { Button } from "../components/Button" +import { CenteredSpinner } from "../components/CenteredSpinner" + +export function ProjectPage() { + const { data: project, isLoading } = useProjectQuery() + const deleteProjectMutation = useDeleteProjectMutation({ + onSuccess: () => { + framer.closePlugin("Semrush project deleted.") + }, + }) + + if (isLoading) return <CenteredSpinner /> + + if (!project) { + return <div className="flex-1 flex items-center justify-center text-tertiary">No project found.</div> + } + + return ( + <div className="col-lg w-[340px]"> + <div className="flex h-[30px] justify-between"> + <p className="w-20 text-tertiary">Name</p> + <p className="text-primary truncate">{project.project_name}</p> + </div> + <div className="flex h-[30px] justify-between"> + <p className="w-20 text-tertiary">URL</p> + <p className="text-primary truncate">{project.url}</p> + </div> + <div className="flex h-[30px] justify-between"> + <p className="w-20 text-tertiary">ID</p> + <p className="text-primary">{project.project_id}</p> + </div> + <div className="flex justify-between items-center"> + <div className="col"> + <p>Danger Zone</p> + <p className="text-tertiary">Delete project and campaigns.</p> + </div> + <Button + variant="destructive" + className="w-[74px]" + onClick={() => deleteProjectMutation.mutate()} + isPending={deleteProjectMutation.isPending} + > + Delete + </Button> + </div> + </div> + ) +} diff --git a/plugins/semrush/src/pages/audit/Settings.tsx b/plugins/semrush/src/pages/audit/Settings.tsx new file mode 100644 index 00000000..34f4de6b --- /dev/null +++ b/plugins/semrush/src/pages/audit/Settings.tsx @@ -0,0 +1,151 @@ +import { useState } from "react" +import { useAuditQuery, useEditAuditMutation } from "@/api" +import { Button } from "@/components/Button" + +export function AuditSettingsPage() { + const { data: settings, isLoading } = useAuditQuery({ + formatSnapshotData: false, + }) + const editAuditMutation = useEditAuditMutation() + const [formData, setFormData] = useState({ + scheduleDay: 1, + allow: [""], + disallow: [""], + pageLimit: 100, + userAgentType: 2, + respectCrawlDelay: true, + }) + + if (isLoading) { + return <p>Loading audit....</p> + } + + if (!settings) { + return ( + <div className="flex-1 flex items-center justify-center text-tertiary"> + Failed to load audit settings. Ensure you have site audit enabled. + </div> + ) + } + + return ( + <section className="col"> + <div className="col pb-15"> + <h6>Basic</h6> + <div className="input-container"> + <p>Limit</p> + <select + name="pageLimit" + onChange={e => + setFormData(prevFormData => ({ + ...prevFormData, + pageLimit: parseInt(e.target.value), + })) + } + defaultValue={settings.pages_limit} + > + <option>100</option> + <option>200</option> + <option>500</option> + <option>1000</option> + <option>5000</option> + <option>10000</option> + <option>15000</option> + <option>20000</option> + <option>100000</option> + </select> + </div> + <div className="input-container"> + <p>Frequency</p> + <select + name="scheduleDelay" + defaultValue={settings.scheduleDay} + onChange={e => + setFormData(prevData => ({ + ...prevData, + scheduleDay: Number(e.target.value), + })) + } + > + <option value={1}>Weekly, Every Monday</option> + <option value={2}>Weekly, Every Tuesday</option> + <option value={3}>Weekly, Every Wednesday</option> + <option value={4}>Weekly, Every Thursday</option> + <option value={5}>Weekly, Every Friday</option> + <option value={6}>Weekly, Every Saturday</option> + <option value={7}>Weekly, Every Sunday</option> + <option value={0}>Once</option> + </select> + </div> + <hr /> + <h6>Crawler</h6> + <div className="input-container"> + <p>Agent</p> + <select + name="userAgentType" + defaultValue={settings.user_agent_type} + onChange={e => + setFormData(prevData => ({ + ...prevData, + userAgentType: Number(e.target.value), + })) + } + > + <option value={2}>GoogleBot Desktop</option> + <option value={3}>GoogleBot Mobile</option> + </select> + </div> + <div className="input-container"> + <p>Delay</p> + <select + name="respectCrawlDelay" + defaultValue={settings.respectCrawlDelay ? "true" : "false"} + onChange={e => + setFormData(prevData => ({ + ...prevData, + respectCrawlDelay: e.target.value === "true" ? true : false, + })) + } + > + <option value="true">1 URL per 2s</option> + <option value="false">Follow robots.txt</option> + </select> + </div> + <hr /> + <h6>URL</h6> + <div className="input-container"> + <p>Allow</p> + <input + placeholder="/pizza, /order, /about" + name="allow" + onChange={e => + setFormData(prevData => ({ + ...prevData, + allow: e.target.value.split(",").map(path => path.trim()), + })) + } + defaultValue={settings.mask_allow.join(", ")} + /> + </div> + <div className="input-container"> + <p>Disallow</p> + <input + placeholder="/pizza, /order, /about" + name="disallow" + onChange={e => + setFormData(prevData => ({ + ...prevData, + disallow: e.target.value.split(",").map(path => path.trim()), + })) + } + defaultValue={settings.mask_disallow.join(", ")} + /> + </div> + </div> + <hr /> + <Button onClick={() => editAuditMutation.mutate(formData)} isPending={editAuditMutation.isPending}> + Save + </Button> + </section> + ) +} diff --git a/plugins/semrush/src/pages/audit/index.tsx b/plugins/semrush/src/pages/audit/index.tsx new file mode 100644 index 00000000..735d0604 --- /dev/null +++ b/plugins/semrush/src/pages/audit/index.tsx @@ -0,0 +1,17 @@ +import { MenuOption } from "@/components/MenuOption" +import { ChartIcon, DatabaseIcon } from "@/components/Icons" + +export function AuditMenuPage() { + return ( + <div className="col-lg items-start"> + <div className="row w-full"> + <MenuOption to="/audit/settings" title="Settings"> + <DatabaseIcon /> + </MenuOption> + <MenuOption to="/audit/report" title="Reports"> + <ChartIcon /> + </MenuOption> + </div> + </div> + ) +} diff --git a/plugins/semrush/src/pages/audit/report/index.tsx b/plugins/semrush/src/pages/audit/report/index.tsx new file mode 100644 index 00000000..a7c09d2a --- /dev/null +++ b/plugins/semrush/src/pages/audit/report/index.tsx @@ -0,0 +1,112 @@ +import { useRunAuditMutation, useAuditQuery } from "@/api" +import { TableContainer, TableHead, TableRow, TableBody, TableCell } from "@/components/Table" +import { Button } from "@/components/Button" +import { Indicator } from "@/components/Indicator" +import { Link } from "wouter" +import { CenteredSpinner } from "@/components/CenteredSpinner" + +export function AuditReportPage() { + const { data: audit, isLoading: isLoadingAudit } = useAuditQuery({ + formatSnapshotData: true, + }) + const runAuditMutation = useRunAuditMutation() + + if (isLoadingAudit) return <CenteredSpinner /> + + if (!audit) { + return <div className="flex-1 flex items-center justify-center text-tertiary">No audit found.</div> + } + + if (audit?.status !== "FINISHED") { + return <p>Loading audit...</p> + } + + if (audit.current_snapshot === null) { + return ( + <div className="col-lg"> + <p>No snapshots found. You must run at least one audit.</p> + <hr /> + <Button + onClick={() => runAuditMutation.mutate()} + isLoading={runAuditMutation.isPending} + className="w-full" + > + Run + </Button> + </div> + ) + } + + const { + current_snapshot: { quality, snapshotId }, + timeAgo, + annotatedIssues: { errors, warnings, notices }, + id, + } = audit + const issues = [...errors, ...warnings, ...notices] + + return ( + <section className="col-lg px-15 w-[650px]"> + <div className="flex justify-between pb-15"> + <div className="col"> + <p>Last updated: {timeAgo}</p> + <a target="_blank" href={`https://semrush.com/siteaudit/campaign/${id}/review/#overview`}> + View in Semrush + </a> + </div> + <Button + className="w-[74px]" + onClick={() => runAuditMutation.mutate()} + isLoading={runAuditMutation.isPending} + > + Rerun + </Button> + </div> + <hr /> + <div className="flex gap-8"> + <div className="flex flex-col gap-8"> + <p>Score</p> + <h1 className="text-2xl font-bold">{quality.value}%</h1> + </div> + <div className="flex flex-col gap-8"> + <p>Errors</p> + <h1 className="text-2xl font-bold text-framer-red">{errors.length}</h1> + </div> + <div className="flex flex-col gap-8"> + <p>Warnings</p> + <h1 className="text-2xl font-bold text-framer-yellow">{warnings.length}</h1> + </div> + <div className="flex flex-col gap-8"> + <p>Notices</p> + <h1 className="text-2xl font-bold text-framer-blue">{notices.length}</h1> + </div> + </div> + {issues.length > 0 && ( + <TableContainer> + <TableHead className={issues.length > 15 ? "pr-4" : ""}> + <TableCell className="grow">Issue</TableCell> + <TableCell>Count</TableCell> + <TableCell>Checks</TableCell> + </TableHead> + <TableBody> + {issues.map((issue, i) => ( + <TableRow key={i}> + <TableCell className="grow flex items-center gap-[5px]"> + <Indicator type={issue.type} /> + <Link + className="line-clamp-2" + href={`/issues/${issue.id}&snapshotId=${snapshotId}`} + > + {issue.description} + </Link> + </TableCell> + <TableCell>{issue.checks}</TableCell> + <TableCell>{issue.count}</TableCell> + </TableRow> + ))} + </TableBody> + </TableContainer> + )} + </section> + ) +} diff --git a/plugins/semrush/src/pages/audit/report/issues/index.tsx b/plugins/semrush/src/pages/audit/report/issues/index.tsx new file mode 100644 index 00000000..c9138ed3 --- /dev/null +++ b/plugins/semrush/src/pages/audit/report/issues/index.tsx @@ -0,0 +1,62 @@ +import { useParams, useSearch } from "wouter" +import { Indicator } from "@/components/Indicator" +import { TableContainer, TableHead, TableCell, TableBody, TableRow } from "@/components/Table" +import { AUDIT_ISSUES } from "@/constants" +import { useIssueReportQuery } from "@/api" +import { CenteredSpinner } from "@/components/CenteredSpinner" + +export function AuditReportIssuesPage() { + const searchString = useSearch() + const params = useParams() + + const snapshotId = new URLSearchParams(searchString).get("snapshotId") + const issueId = params.issueId + + if (!snapshotId || !issueId) { + throw new Error("Missing snapshotId or issueId") + } + + const issue = AUDIT_ISSUES[Number(issueId)] + const { data: issueReport, isLoading } = useIssueReportQuery(snapshotId, Number(issueId)) + + if (isLoading) return <CenteredSpinner /> + + if (!issueReport) { + return ( + <div className="flex-1 flex items-center justify-center text-tertiary"> + {`No issue report for issueId '${issueId}' with snapshot id '${snapshotId}'`} + </div> + ) + } + + return ( + <div className="col-lg w-[650px]"> + <div className="flex gap-[5px] items-center"> + <Indicator type={issue.type} /> + <h6>{issue.title}</h6> + </div> + <div className="col"> + <p className="text-primary">About the issue</p> + <p dangerouslySetInnerHTML={{ __html: issue.description }} /> + </div> + <div className="col"> + <p className="text-primary">How to fix it</p> + <p dangerouslySetInnerHTML={{ __html: issue.solution }} /> + </div> + <TableContainer> + <TableHead> + <TableCell className="grow">Page</TableCell> + </TableHead> + <TableBody> + {issueReport.data.map((issue, i) => ( + <TableRow key={i}> + <TableCell className="grow"> + <a>{issue.source_url}</a> + </TableCell> + </TableRow> + ))} + </TableBody> + </TableContainer> + </div> + ) +} diff --git a/plugins/semrush/src/pages/index.tsx b/plugins/semrush/src/pages/index.tsx new file mode 100644 index 00000000..bb1dfe39 --- /dev/null +++ b/plugins/semrush/src/pages/index.tsx @@ -0,0 +1,62 @@ +import { useState } from "react" +import { Redirect, useLocation } from "wouter" +import { useValidateApiKeyMutation } from "@/api" +import { Button } from "@/components/Button" +import SemrushIcon from "@/assets/icon.svg" +import { semrush } from "../api" +import { framer } from "framer-plugin" + +export function SetupPage() { + const [, navigate] = useLocation() + const [apiKey, setApiKey] = useState("") + const authMutation = useValidateApiKeyMutation({ + onSuccess: () => { + navigate("/menu") + }, + onError: () => framer.notify("Invalid API key", { variant: "error" }), + }) + + if (semrush.auth.isAuthenticated()) { + return <Redirect to="/menu" /> + } + + const handleApiKeyValidation = () => { + if (apiKey === "") return + authMutation.mutate(apiKey) + } + + return ( + <div className="col-lg"> + <section className="col-lg items-center"> + <img src={SemrushIcon} width={30} height={30} /> + <div className="col items-center"> + <h6>Connect to Semrush</h6> + <p className="text-tertiary max-w-[190px] text-center"> + Please enter your{" "} + <a href="https://www.semrush.com/accounts/subscription-info/api-units/" target="_blank"> + API key + </a>{" "} + to continue. + </p> + </div> + </section> + <section className="col pt-15 grow"> + <input + type="text" + placeholder="API Key" + className="w-full" + onChange={e => setApiKey(e.target.value)} + disabled={authMutation.isPending} + onKeyDown={e => { + if (e.key === "Enter") { + handleApiKeyValidation() + } + }} + /> + <Button onClick={() => handleApiKeyValidation()} isPending={authMutation.isPending}> + Connect + </Button> + </section> + </div> + ) +} diff --git a/plugins/semrush/src/router.tsx b/plugins/semrush/src/router.tsx new file mode 100644 index 00000000..41bf87cd --- /dev/null +++ b/plugins/semrush/src/router.tsx @@ -0,0 +1,164 @@ +import React, { cloneElement, useEffect, useState } from "react" +import { useLocation, useRoute, RouteComponentProps } from "wouter" +import { AnimatePresence, MotionProps, motion } from "framer-motion" +import { AuditMenuPage } from "./pages/audit" +import { AuditReportPage } from "./pages/audit/report" +import { AuditSettingsPage } from "./pages/audit/Settings" +import { KeywordsSearchPage } from "./pages/KeywordsSearchPage" +import { ProjectPage } from "./pages/Project" +import { SetupPage } from "./pages" +import { PluginPage } from "./components/PluginPage" +import { MenuPage } from "./pages/Menu" +import { AuditReportIssuesPage } from "./pages/audit/report/issues" +import { PageErrorBoundaryFallback } from "./components/PageErrorBoundaryFallback" + +interface PluginRoute { + path: string + component: React.ComponentType<RouteComponentProps> + title?: string + children?: PluginRoute[] +} + +interface Match { + match: ReturnType<typeof useRoute> + route: PluginRoute +} + +const pluginRoutes: PluginRoute[] = [ + { + path: "/", + component: SetupPage, + }, + { + path: "/menu", + component: MenuPage, + }, + { + path: "/keywords", + component: KeywordsSearchPage, + title: "Keywords", + }, + { + path: "/project", + component: ProjectPage, + title: "Project", + }, + { + path: "/audit", + component: AuditMenuPage, + title: "Audit", + children: [ + { + path: "/settings", + component: AuditSettingsPage, + }, + { + path: "/report", + component: AuditReportPage, + children: [ + { + path: "/issues/*", + component: AuditReportIssuesPage, + title: "Issues", + }, + ], + }, + ], + }, +] + +function useRoutes(routes: PluginRoute[]) { + const [location] = useLocation() + const [animationDirection, setAnimationDirection] = useState(1) + const [isFirstPage, setIsFirstPage] = useState(true) + // Save the length of the `routes` array that we receive on the first render + const [routesLen] = useState(() => routes.length) + + // because we call `useRoute` inside a loop the number of routes can't be changed + if (routesLen !== routes.length) { + throw new Error("The length of `routes` array provided to `useRoutes` must be constant") + } + + useEffect(() => { + setIsFirstPage(false) + }, []) + + useEffect(() => { + const originalHistoryBack = history.back + + history.back = () => { + setAnimationDirection(-1) + originalHistoryBack.call(history) + } + + return () => { + history.back = originalHistoryBack + } + }, []) + + useEffect(() => { + setAnimationDirection(1) + }, [location]) + + const matches: Match[] = [] + + const addToMatch = (route: PluginRoute, parentPath = "") => { + const fullPath = parentPath + route.path + + // eslint-disable-next-line react-hooks/rules-of-hooks + const match = useRoute(fullPath) + matches.push({ match, route: { ...route, path: fullPath } }) + + if (route.children) { + for (const child of route.children) { + addToMatch(child, fullPath) + } + } + } + + for (const route of routes) { + addToMatch(route) + } + + for (const { match, route } of matches) { + const [isMatch, params] = match + const { title, component: Component } = route + + if (!isMatch) continue + + const animationProps = isFirstPage + ? {} + : { + initial: { x: `${animationDirection * 100}vw`, opacity: 0, position: "absolute" }, + animate: { x: 0, opacity: 1, position: "relative" }, + exit: { x: `${animationDirection * -100}vw`, opacity: 0, position: "absolute" }, + transition: { ease: "easeInOut", duration: 0.28 }, + } + + return ( + <motion.div {...(animationProps as MotionProps)}> + <PluginPage title={title} animateForward={animationDirection === 1}> + <PageErrorBoundaryFallback> + <Component params={params} /> + </PageErrorBoundaryFallback> + </PluginPage> + </motion.div> + ) + } +} + +export function Router() { + const page = useRoutes(pluginRoutes) + + return ( + <AnimatePresence> + {page ? ( + cloneElement(page, { key: location.pathname }) + ) : ( + <PluginPage title="404"> + <p className="text-tertiary">Yikes! Looks like we lost that page.</p> + </PluginPage> + )} + </AnimatePresence> + ) +} diff --git a/plugins/semrush/src/semrush.ts b/plugins/semrush/src/semrush.ts new file mode 100644 index 00000000..1ac45272 --- /dev/null +++ b/plugins/semrush/src/semrush.ts @@ -0,0 +1,536 @@ +import * as v from "valibot" + +const apiErrorSchema = v.looseObject({ + message: v.string(), +}) + +const userAgentTypeSchema = v.pipe(v.number(), v.toMinValue(2), v.toMaxValue(7)) + +const projectSchema = v.object({ + project_id: v.number(), + project_name: v.string(), + url: v.string(), + domain_unicode: v.string(), + tools: v.array( + v.object({ + tool: v.string(), + }) + ), + owner_id: v.number(), + permission: v.array(v.string()), +}) + +const auditEnabledSchema = v.looseObject({ + status: v.string(), +}) + +const keywordsTableSchema = v.array( + v.object({ + Keyword: v.string(), + Intent: v.string(), + CPC: v.string(), + Trends: v.string(), + "Search Volume": v.string(), + "Number of Results": v.string(), + "Keyword Difficulty Index": v.string(), + }) +) + +// eslint-disable-next-line +const auditSettingsSchema = v.object({ + domain: v.string(), + scheduleDay: v.optional(v.pipe(v.number(), v.toMinValue(0), v.toMaxValue(7))), + notify: v.optional(v.boolean()), + allow: v.optional(v.array(v.string())), + disallow: v.optional(v.array(v.string())), + pageLimit: v.optional(v.pipe(v.number(), v.toMinValue(1), v.toMaxValue(1000))), + userAgentType: v.optional(userAgentTypeSchema), + respectCrawlDelay: v.optional(v.boolean()), + excludedChecks: v.optional(v.array(v.number())), +}) + +const savedAuditSchema = v.object({ + mask_allow: v.array(v.string()), + mask_disallow: v.array(v.string()), + user_agent_type: v.number(), + status: v.string(), + scheduleDay: v.number(), + respectCrawlDelay: v.boolean(), + pages_limit: v.number(), +}) + +const auditRunSchema = v.object({ + snapshot_id: v.string(), +}) + +const issueSchema = v.object({ + id: v.number(), + count: v.number(), + delta: v.number(), + checks: v.number(), +}) + +const issuesSchema = v.array(issueSchema) + +const scoreSchema = v.object({ + value: v.number(), + delta: v.number(), +}) + +const auditSchemaReport = v.looseObject({ + id: v.number(), + pages_limit: v.number(), + mask_allow: v.array(v.string()), + mask_disallow: v.array(v.string()), + user_agent_type: v.optional(userAgentTypeSchema), + respectCrawlDelay: v.boolean(), + scheduleDay: v.fallback(v.number(), 0), + status: v.picklist(["RUNNING", "FINISHED", "CHECKING", "SAVING"]), + current_snapshot: v.nullable( + v.looseObject({ + thematicScores: v.object({ + https: scoreSchema, + intSeo: scoreSchema, + crawlability: scoreSchema, + performance: scoreSchema, + linking: scoreSchema, + markups: scoreSchema, + }), + errors: issuesSchema, + warnings: issuesSchema, + notices: issuesSchema, + snapshot_id: v.string(), + pages_crawled: v.number(), + finish_date: v.number(), + quality: scoreSchema, + }) + ), +}) + +const issueReportSchema = v.object({ + limit: v.number(), + page: v.number(), + total: v.number(), + data: v.array( + v.object({ + target_url: v.string(), + page_id: v.string(), + source_url: v.string(), + }) + ), + issue_id: v.number(), +}) + +interface SemrushConfigOptions { + defaultAuditSettings: Partial<AuditSettings> +} + +interface RequestParamters<TSchema extends v.GenericSchema> { + path: string + method: "get" | "post" | "delete" + query?: Record<string, string> + body?: Record<string, unknown> + schema: TSchema +} + +export interface Authorize { + url: string + writeKey: string + readKey: string +} + +export interface Tokens { + access_token: string + refresh_token: string + expires_in: number + scope: string + token_type: "Bearer" +} + +export interface StoredTokens { + createdAt: number + expiredIn: number + accessToken: string + refreshToken: string +} + +export enum Columns { + totalResults = "nr", + keyword = "ph", + searchVolume = "nq", + cpc = "cp", + difficulty = "kd", +} + +export interface KeywordSearchSort { + column: Columns + order: "asc" | "desc" +} + +export type KeyphraseSearchType = "phrase_related" | "phrase_fullsearch" | "phrase_questions" + +export interface KeywordSearchOptions { + keyword: string + database: string + offset: number + limit: number + type: KeyphraseSearchType + sort: KeywordSearchSort +} + +export type AuditSettings = v.InferOutput<typeof auditSettingsSchema> + +export type Issue = v.InferOutput<typeof issueSchema> + +export type Project = v.InferOutput<typeof projectSchema> + +export default class Semrush { + static readonly proxyUrl = "https://framer-semrush-proxy.sakibulislam25800.workers.dev/?" + static readonly apiBaseUrl = `${Semrush.proxyUrl}https://api.semrush.com` + static readonly baseUrl = `${Semrush.proxyUrl}https://semrush.com` + private static readonly API_KEY_STORAGE_KEY = "semrushApiKey" + + /** + * The project ID of the Semrush project assigned to + * this Framer project via the staging domain + */ + private projectId!: string + private apiKey: string | null = null + + /** + * Default audit settings for enabling and updating audits + */ + defaultAuditSettings: Partial<AuditSettings> + + constructor({ defaultAuditSettings }: SemrushConfigOptions) { + this.defaultAuditSettings = defaultAuditSettings + } + + /** + * Sends a request to the Semrush v3 Projects API + */ + private async request<TSchema extends v.GenericSchema>({ + path, + method, + query, + body, + schema, + }: RequestParamters<TSchema>): Promise<v.InferOutput<TSchema>> { + // If the body is empty, don't send the body in the HTTP request + const bodyAsJsonString = !body || Object.entries(body).length === 0 ? undefined : JSON.stringify(body) + + const apiKey = this.auth.getApiKeyOrThrow() + const url = new URL(`${Semrush.apiBaseUrl}${path}?key=${apiKey}`) + + if (query) { + Object.keys(query).forEach(key => url.searchParams.append(key, query[key])) + } + + const headers: Record<string, string> = {} + + if (bodyAsJsonString) { + headers["content-type"] = "application/json" + } + + try { + const res = await fetch(url, { + method: method.toUpperCase(), + body: bodyAsJsonString, + headers, + }) + + if (res.ok && method === "delete") return + + const contentType = res.headers.get("content-type") + + if (contentType && !contentType.includes("application/json")) { + const textRes = await res.text() + throw new Error(textRes) + } + + const jsonRes = await res.json() + + const successResult = v.safeParse(schema, jsonRes) + if (successResult.success) return successResult.output + + const errorResult = v.safeParse(apiErrorSchema, jsonRes) + if (errorResult.success) throw new Error(errorResult.output.message) + + throw new Error("Failed to parse Semrush API response") + } catch (e) { + const err = e instanceof Error ? e : new Error("Something went wrong") + throw err + } + } + + async setApiKey(apiKey: string) { + try { + const res = await fetch(`${Semrush.proxyUrl}https://semrush.com/users/countapiunits.html?key=${apiKey}`) + + const contentType = res.headers.get("content-type") + + if (contentType && contentType.includes("application/json")) { + throw new Error("Invalid API key") + } + + const textRes = await res.text() + const apiUnitCount = Number(textRes) + + if (apiUnitCount === 0) { + throw new Error("Insufficient API units.") + } + + this.apiKey = apiKey + + return true + } catch (e) { + const err = e instanceof Error ? e : new Error("Something went wrong") + throw err + } + } + + async getOrCreateProject(domain: string) { + const projects = await this.projects.getAll() + let connectedProject = projects.find(p => p.url === domain) + let siteAuditEnabled = false + + if (!connectedProject) { + // Default the project name to their domain + connectedProject = await this.projects.create(domain, domain) + } else { + // Check if the site audit tool is enabled for the existing project + siteAuditEnabled = connectedProject.tools.some(tool => tool.tool === "siteaudit") + } + + this.projectId = String(connectedProject.project_id) + + if (!siteAuditEnabled) { + // Set an arbitrary page limit + await this.audit.enable({ pageLimit: 100, domain }) + } + + return connectedProject + } + + /** + * Get keyphrases from Semrush + */ + public async getKeyphrases({ keyword, database, offset, limit, type, sort }: KeywordSearchOptions) { + try { + const apiKey = this.auth.getApiKeyOrThrow() + const params: Record<string, string> = { + database, + phrase: keyword.trim().replace(/ /g, "+"), + display_limit: String(limit), + display_offset: String(offset), + export_columns: "Nr,Ph,In,Nq,Cp,Td,Kd", + display_sort: `${sort.column}_${sort.order}`, + key: apiKey, + } + + const query = Object.keys(params) + .map(key => `${key}=${params[key]}`) + .join("&") + + const res = await fetch(`${Semrush.apiBaseUrl}/${type}?${query}`) + const textRes = await res.text() + + if (textRes.includes("ERROR")) { + if (textRes.includes("ERROR 50")) { + return [] + } + + throw new Error(textRes) + } + + const keywordsJSON = this.utils.convertCSVToJSON(textRes) + const result = v.safeParse(keywordsTableSchema, keywordsJSON) + + if (!result.success) { + throw new Error("Failed to parse keyword search data.") + } + + return result.output + } catch (e) { + throw e instanceof Error ? e : new Error("Something went wrong") + } + } + + public readonly auth = { + setApiKey: async (apiKey: string): Promise<boolean> => { + try { + const res = await fetch(`${Semrush.proxyUrl}https://semrush.com/users/countapiunits.html?key=${apiKey}`) + + const contentType = res.headers.get("content-type") + if (contentType && contentType.includes("application/json")) { + throw new Error("Invalid API key") + } + + const textRes = await res.text() + const apiUnitCount = Number(textRes) + + if (apiUnitCount === 0) { + throw new Error("Insufficient API units.") + } + + this.apiKey = apiKey + localStorage.setItem(Semrush.API_KEY_STORAGE_KEY, apiKey) + + return true + } catch (e) { + const err = e instanceof Error ? e : new Error("Something went wrong") + throw err + } + }, + + getApiKey: (): string | null => { + if (this.apiKey) return this.apiKey + + const storedApiKey = localStorage.getItem(Semrush.API_KEY_STORAGE_KEY) + if (storedApiKey) { + this.apiKey = storedApiKey + return storedApiKey + } + + return null + }, + + getApiKeyOrThrow: (): string => { + const apiKey = this.auth.getApiKey() + if (!apiKey) throw new Error("Semrush API key missing") + return apiKey + }, + + clearApiKey: () => { + this.apiKey = null + localStorage.removeItem(Semrush.API_KEY_STORAGE_KEY) + }, + + isAuthenticated: (): boolean => { + return !!this.auth.getApiKey() + }, + } + + public readonly projects = { + /** + * Create a new project + */ + create: (projectName: string, hostname: string) => { + return this.request({ + path: "/management/v1/projects", + method: "post", + body: { + project_name: projectName, + url: hostname, + }, + schema: projectSchema, + }) + }, + + /** + * Get all projects for the associated account + */ + getAll: () => { + return this.request({ + path: "/management/v1/projects", + method: "get", + schema: v.array(projectSchema), + }) + }, + + /** + * Delete the corresponding Semrush project + */ + delete: () => { + return this.request({ + path: `/management/v1/projects/${this.projectId}`, + method: "delete", + schema: v.null_(), + }) + }, + } + + public readonly audit = { + /** + * Get the project's latest audit report and settings + */ + get: () => { + return this.request({ + path: `/reports/v1/projects/${this.projectId}/siteaudit/info`, + method: "get", + schema: auditSchemaReport, + }) + }, + + /** + * Enable the project's site audit tool feature + */ + enable: async (settings: AuditSettings) => { + return this.request({ + path: `/management/v1/projects/${this.projectId}/siteaudit/enable`, + method: "post", + schema: auditEnabledSchema, + body: { + ...this.defaultAuditSettings, + ...settings, + }, + }) + }, + + /** + * Update the project's site audit settings + */ + update: async (settings: AuditSettings) => { + return this.request({ + path: `/management/v1/projects/${this.projectId}/siteaudit/save`, + method: "post", + schema: savedAuditSchema, + body: { + ...this.defaultAuditSettings, + ...settings, + }, + }) + }, + + /** + * Run a site audit on the current Framer project + */ + run: () => { + return this.request({ + path: `/reports/v1/projects/${this.projectId}/siteaudit/launch`, + method: "post", + schema: auditRunSchema, + }) + }, + + /** + * Get a detailed report of an issue + */ + getIssueReport: (snapshotId: string, issueId: number) => { + return this.request({ + path: `/reports/v1/projects/{ID}/siteaudit/snapshot/${snapshotId}/issue/${issueId}`, + method: "get", + schema: issueReportSchema, + }) + }, + } + + private utils = { + convertCSVToJSON: (data: string) => { + const rows = data.split("\n") + const headers = rows[0].split(";") + rows.shift() + + return rows.map(row => { + const rowData = row.split(";") + const obj: Record<string, string> = {} + + headers.forEach((header, index) => { + obj[header.trim()] = rowData[index].trim() + }) + + return obj + }) + }, + } +} diff --git a/plugins/semrush/src/utils.ts b/plugins/semrush/src/utils.ts new file mode 100644 index 00000000..66d2f42d --- /dev/null +++ b/plugins/semrush/src/utils.ts @@ -0,0 +1,30 @@ +// Example: 2500 -> '2.5k' +export function formatNumWithMetricPrefix(n: number) { + return Intl.NumberFormat("en-US", { + maximumFractionDigits: 1, + notation: "compact", + }).format(n) +} + +// Example: '5 days ago' +export function timeAgo(isoDate: number) { + const date = new Date(isoDate) + const formatter = new Intl.RelativeTimeFormat("en") + const ranges = [ + ["years", 3600 * 24 * 365], + ["months", 3600 * 24 * 30], + ["weeks", 3600 * 24 * 7], + ["days", 3600 * 24], + ["hours", 3600], + ["minutes", 60], + ["seconds", 1], + ] as const + const secondsElapsed = (date.getTime() - Date.now()) / 1000 + + for (const [rangeType, rangeVal] of ranges) { + if (rangeVal < Math.abs(secondsElapsed)) { + const delta = secondsElapsed / rangeVal + return formatter.format(Math.round(delta), rangeType) + } + } +} diff --git a/plugins/semrush/src/vite-env.d.ts b/plugins/semrush/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/plugins/semrush/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/plugins/semrush/tailwind.config.js b/plugins/semrush/tailwind.config.js new file mode 100644 index 00000000..ed108fc1 --- /dev/null +++ b/plugins/semrush/tailwind.config.js @@ -0,0 +1,41 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class", "[data-framer-theme='dark']"], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + backgroundColor: { + primary: "var(--framer-color-bg)", + secondary: "var(--framer-color-bg-secondary)", + tertiary: "var(--framer-color-bg-tertiary)", + tertiaryDimmedLight: "rgba(243, 243, 243, 0.75)", + tertiaryDimmedDark: "rgba(43, 43, 43, 0.75)", + divider: "var(--framer-color-divider)", + tint: "var(--framer-color-tint)", + tintDimmed: "var(--framer-color-tint-dimmed)", + tintDark: "var(--framer-color-tint-dark)", + blackDimmed: "rgba(0, 0, 0, 0.5)", + "hs-orange": "#FF5C35", + "framer-red": "#FF3366", + "framer-blue": "#0099FF", + }, + colors: { + primary: "var(--framer-color-text)", + secondary: "var(--framer-color-text-secondary)", + tertiary: "var(--framer-color-text-tertiary)", + inverted: "var(--framer-color-text-inverted)", + tint: "var(--framer-color-tint)", + "framer-red": "#FF3366", + }, + borderColor: { + divider: "var(--framer-color-divider)", + }, + fontSize: { + "2xs": "10px", + }, + padding: { + 15: "15px", + }, + }, + }, +} diff --git a/plugins/semrush/tsconfig.json b/plugins/semrush/tsconfig.json new file mode 100644 index 00000000..90770116 --- /dev/null +++ b/plugins/semrush/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ES2022", + + /* Paths */ + "baseUrl": "./src", + "paths": { + "@/*": ["./*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/plugins/semrush/vite.config.ts b/plugins/semrush/vite.config.ts new file mode 100644 index 00000000..a71f79bb --- /dev/null +++ b/plugins/semrush/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite" +import react from "@vitejs/plugin-react-swc" +import mkcert from "vite-plugin-mkcert" +import framer from "vite-plugin-framer" +import path from "path" + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react(), mkcert(), framer()], + build: { + target: "ES2022", + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +})