diff --git a/.prettierrc b/.prettierrc index 1a88ab1..1f4c4bb 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,6 +1,6 @@ { "semi": true, - "singleQuote": false, + "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", "printWidth": 100 diff --git a/api/languages.ts b/api/languages.ts index 207f637..457fb7a 100644 --- a/api/languages.ts +++ b/api/languages.ts @@ -30,12 +30,12 @@ export default async (req: VercelRequest, res: VercelResponse): Promise => res.setHeader("Content-Type", "image/svg+xml"); res.setHeader("Cache-Control", `public, max-age=${CACHE_DURATION_SECONDS}`); if (pie) { - res.send(renderLangPie(data, color ?? "")); + res.send(await renderLangPie(data, color ?? "")); return; } - res.send(renderLangPercent(data, color ?? "")); + res.send(await renderLangPercent(data, color ?? "")); } catch { res.setHeader("Content-Type", "image/svg+xml"); - res.status(500).send(renderErrorCard("Could not fetch data")); + res.status(500).send(await renderErrorCard("Could not fetch data")); } }; diff --git a/api/stats.ts b/api/stats.ts index fdacf53..c8d0258 100644 --- a/api/stats.ts +++ b/api/stats.ts @@ -28,9 +28,9 @@ export default async (req: VercelRequest, res: VercelResponse): Promise => const data = await fetchUserStats(username); res.setHeader("Content-Type", "image/svg+xml"); res.setHeader("Cache-Control", `public, max-age=${CACHE_DURATION_SECONDS}`); - res.send(renderStatCard(data, color ?? "", peng)); + res.send(await renderStatCard(data, color ?? "", peng)); } catch { res.setHeader("Content-Type", "image/svg+xml"); - res.status(500).send(renderErrorCard("Could not fetch data")); + res.status(500).send(await renderErrorCard("Could not fetch data")); } }; diff --git a/jest.config.js b/jest.config.js index 9e4ca38..1ad2840 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,6 @@ module.exports = { preset: "ts-jest", testEnvironment: "node", - testMatch: ["**/*.test.ts"], - moduleFileExtensions: ["ts", "js", "json"], + testMatch: ["**/*.test.ts", "**/*.test.tsx"], + moduleFileExtensions: ["ts", "tsx", "js", "json"], }; diff --git a/package-lock.json b/package-lock.json index c3bcfa7..2180ce4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,17 @@ "license": "ISC", "dependencies": { "dotenv": "^16.0.0", - "express": "^4.17.3" + "express": "^4.17.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "satori": "^0.25.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "jest": "^30.3.0", @@ -1296,6 +1301,21 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "dependencies": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -1486,6 +1506,24 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -2371,6 +2409,14 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", @@ -2498,6 +2544,14 @@ "node": ">=6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -2667,8 +2721,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/concat-map": { "version": "0.0.1", @@ -2728,6 +2781,48 @@ "node": ">= 8" } }, + "node_modules/css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==" + }, + "node_modules/css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-gradient-parser": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz", + "integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==", + "engines": { + "node": ">=16" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2830,6 +2925,14 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/emoji-regex-xs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", + "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -3318,6 +3421,11 @@ } } }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3551,6 +3659,17 @@ "node": ">=8" } }, + "node_modules/hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4519,6 +4638,15 @@ "node": ">= 0.8.0" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4881,6 +5009,11 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4893,6 +5026,15 @@ "node": ">=6" } }, + "node_modules/parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "dependencies": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -5012,6 +5154,11 @@ "node": ">=8" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5132,6 +5279,25 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -5192,6 +5358,32 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/satori": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.25.0.tgz", + "integrity": "sha512-utINfLxrYrmSnLvxFT4ZwgwWa8KOjrz7ans32V5wItgHVmzESl/9i33nE38uG0miycab8hUqQtDlOpqrIpB/iw==", + "dependencies": { + "@shuding/opentype.js": "1.4.0-beta.0", + "css-background-parser": "^0.1.0", + "css-box-shadow": "1.0.0-3", + "css-gradient-parser": "^0.0.17", + "css-to-react-native": "^3.0.0", + "emoji-regex-xs": "^2.0.1", + "escape-html": "^1.0.3", + "linebreak": "^1.1.0", + "parse-css-color": "^0.2.1", + "postcss-value-parser": "^4.2.0", + "yoga-layout": "^3.2.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5428,6 +5620,11 @@ "node": ">=8" } }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==" + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -5579,6 +5776,11 @@ "node": "*" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5804,6 +6006,15 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -6155,6 +6366,11 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==" } }, "dependencies": { @@ -7112,6 +7328,15 @@ "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "dev": true }, + "@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "requires": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + } + }, "@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -7302,6 +7527,22 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "dev": true }, + "@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "requires": { + "csstype": "^3.2.2" + } + }, + "@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "requires": {} + }, "@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -7871,6 +8112,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==" + }, "baseline-browser-mapping": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", @@ -7957,6 +8203,11 @@ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true }, + "camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==" + }, "caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -8071,8 +8322,7 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "concat-map": { "version": "0.0.1", @@ -8120,6 +8370,42 @@ "which": "^2.0.1" } }, + "css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==" + }, + "css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==" + }, + "css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==" + }, + "css-gradient-parser": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz", + "integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==" + }, + "css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "requires": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -8197,6 +8483,11 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "emoji-regex-xs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz", + "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==" + }, "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -8545,6 +8836,11 @@ "dev": true, "requires": {} }, + "fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==" + }, "file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8705,6 +9001,11 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==" + }, "html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -9435,6 +9736,15 @@ "type-check": "~0.4.0" } }, + "linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "requires": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, "lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -9705,6 +10015,11 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true }, + "pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9714,6 +10029,15 @@ "callsites": "^3.0.0" } }, + "parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "requires": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, "parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -9799,6 +10123,11 @@ "find-up": "^4.0.0" } }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9872,6 +10201,19 @@ "unpipe": "1.0.0" } }, + "react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" + }, + "react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "requires": { + "scheduler": "^0.27.0" + } + }, "react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -9909,6 +10251,29 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "satori": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.25.0.tgz", + "integrity": "sha512-utINfLxrYrmSnLvxFT4ZwgwWa8KOjrz7ans32V5wItgHVmzESl/9i33nE38uG0miycab8hUqQtDlOpqrIpB/iw==", + "requires": { + "@shuding/opentype.js": "1.4.0-beta.0", + "css-background-parser": "^0.1.0", + "css-box-shadow": "1.0.0-3", + "css-gradient-parser": "^0.0.17", + "css-to-react-native": "^3.0.0", + "emoji-regex-xs": "^2.0.1", + "escape-html": "^1.0.3", + "linebreak": "^1.1.0", + "parse-css-color": "^0.2.1", + "postcss-value-parser": "^4.2.0", + "yoga-layout": "^3.2.1" + } + }, + "scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -10093,6 +10458,11 @@ } } }, + "string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==" + }, "strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -10201,6 +10571,11 @@ } } }, + "tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, "tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -10328,6 +10703,15 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true }, + "unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "requires": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -10583,6 +10967,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==" } } } diff --git a/package.json b/package.json index a6b2b38..1f5583c 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,17 @@ "main": "app.js", "dependencies": { "dotenv": "^16.0.0", - "express": "^4.17.3" + "express": "^4.17.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "satori": "^0.25.0" }, "devDependencies": { "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "jest": "^30.3.0", diff --git a/scripts/assets/fonts/inter-400.woff b/scripts/assets/fonts/inter-400.woff new file mode 100644 index 0000000..2f21ed4 Binary files /dev/null and b/scripts/assets/fonts/inter-400.woff differ diff --git a/scripts/assets/fonts/inter-400.woff2 b/scripts/assets/fonts/inter-400.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/scripts/assets/fonts/inter-400.woff2 differ diff --git a/scripts/assets/fonts/inter-600.woff b/scripts/assets/fonts/inter-600.woff new file mode 100644 index 0000000..323fa67 Binary files /dev/null and b/scripts/assets/fonts/inter-600.woff differ diff --git a/scripts/assets/fonts/inter-700.woff2 b/scripts/assets/fonts/inter-700.woff2 new file mode 100644 index 0000000..a68fb10 Binary files /dev/null and b/scripts/assets/fonts/inter-700.woff2 differ diff --git a/scripts/assets/svgs/standalone/github_cat.svg b/scripts/assets/svgs/standalone/github_cat.svg new file mode 100644 index 0000000..067ebf3 --- /dev/null +++ b/scripts/assets/svgs/standalone/github_cat.svg @@ -0,0 +1,3 @@ + + + diff --git a/scripts/assets/svgs/standalone/github_cat_white.svg b/scripts/assets/svgs/standalone/github_cat_white.svg new file mode 100644 index 0000000..d5d7846 --- /dev/null +++ b/scripts/assets/svgs/standalone/github_cat_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/scripts/assets/svgs/standalone/icon_followers.svg b/scripts/assets/svgs/standalone/icon_followers.svg new file mode 100644 index 0000000..6627d06 --- /dev/null +++ b/scripts/assets/svgs/standalone/icon_followers.svg @@ -0,0 +1,3 @@ + + + diff --git a/scripts/assets/svgs/standalone/icon_forks.svg b/scripts/assets/svgs/standalone/icon_forks.svg new file mode 100644 index 0000000..5283fbc --- /dev/null +++ b/scripts/assets/svgs/standalone/icon_forks.svg @@ -0,0 +1,3 @@ + + + diff --git a/scripts/assets/svgs/standalone/icon_repositories.svg b/scripts/assets/svgs/standalone/icon_repositories.svg new file mode 100644 index 0000000..7dfd458 --- /dev/null +++ b/scripts/assets/svgs/standalone/icon_repositories.svg @@ -0,0 +1,3 @@ + + + diff --git a/scripts/assets/svgs/standalone/icon_stars.svg b/scripts/assets/svgs/standalone/icon_stars.svg new file mode 100644 index 0000000..77a8f47 --- /dev/null +++ b/scripts/assets/svgs/standalone/icon_stars.svg @@ -0,0 +1,3 @@ + + + diff --git a/scripts/assets/svgs/standalone/no_penguin.svg b/scripts/assets/svgs/standalone/no_penguin.svg new file mode 100644 index 0000000..4af3afd --- /dev/null +++ b/scripts/assets/svgs/standalone/no_penguin.svg @@ -0,0 +1,3 @@ + + + diff --git a/scripts/assets/svgs/standalone/no_penguin_white.svg b/scripts/assets/svgs/standalone/no_penguin_white.svg new file mode 100644 index 0000000..db037c4 --- /dev/null +++ b/scripts/assets/svgs/standalone/no_penguin_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/scripts/assets/svgs/standalone/penguin.svg b/scripts/assets/svgs/standalone/penguin.svg new file mode 100644 index 0000000..5da1210 --- /dev/null +++ b/scripts/assets/svgs/standalone/penguin.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/github/__mock__/mockGithubApiResponse.ts b/scripts/github/__mock__/mockGithubApiResponse.ts index fb1bbb0..e30a698 100644 --- a/scripts/github/__mock__/mockGithubApiResponse.ts +++ b/scripts/github/__mock__/mockGithubApiResponse.ts @@ -1,4 +1,4 @@ -import { GitHubApiResponse } from "../../../types"; +import { GitHubApiResponse } from '../../../types'; const MOCK_RESPONSE: GitHubApiResponse = { data: { @@ -12,8 +12,9 @@ const MOCK_RESPONSE: GitHubApiResponse = { forkCount: 3, languages: { edges: [ - { node: { name: "TypeScript", color: "#3178c6" } }, - { node: { name: "JavaScript", color: "#f1e05a" } }, + { node: { name: 'TypeScript', color: '#3178c6' } }, + { node: { name: 'JavaScript', color: '#f1e05a' } }, + { node: { name: 'SCSS', color: '#f15add' } }, ], }, }, @@ -24,8 +25,8 @@ const MOCK_RESPONSE: GitHubApiResponse = { forkCount: 1, languages: { edges: [ - { node: { name: "TypeScript", color: "#3178c6" } }, - { node: { name: "CSS", color: "#563d7c" } }, + { node: { name: 'TypeScript', color: '#3178c6' } }, + { node: { name: 'CSS', color: '#563d7c' } }, ], }, }, @@ -35,7 +36,7 @@ const MOCK_RESPONSE: GitHubApiResponse = { stargazerCount: 5, forkCount: 0, languages: { - edges: [{ node: { name: "Python", color: "#3572A5" } }], + edges: [{ node: { name: 'Python', color: '#3572A5' } }], }, }, }, diff --git a/scripts/renderers/ErrorCard.tsx b/scripts/renderers/ErrorCard.tsx new file mode 100644 index 0000000..78af10f --- /dev/null +++ b/scripts/renderers/ErrorCard.tsx @@ -0,0 +1,72 @@ +import { CARD_WIDTH, CARD_HEIGHT, ERROR_DIVIDER_Y, COLOR_DARK, COLOR_SUBTLE } from '../utils/constants'; + +const DIVIDER_WIDTH = CARD_WIDTH - 2 * Math.round(CARD_WIDTH / 10); + +interface ErrorCardProps { + message: string; +} + +const ErrorCard = ({ message }: ErrorCardProps) => ( +
+
+ + Error + +
+
+
+ + {message} + +
+
+); + +export { ErrorCard }; diff --git a/scripts/renderers/LanguageCard.tsx b/scripts/renderers/LanguageCard.tsx new file mode 100644 index 0000000..4d470a2 --- /dev/null +++ b/scripts/renderers/LanguageCard.tsx @@ -0,0 +1,247 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { UserLanguageStats } from '../../types'; +import { LanguageDataWithAccum } from './types'; +import { + CARD_WIDTH, + CARD_HEIGHT, + DIVIDER_Y, + COLOR_SUBTLE, + COLOR_LIGHT, + COLOR_DARK, +} from '../utils/constants'; +import { calcPercentagesPie, calcPercentagesBar } from './calcPercentages'; + +const standaloneDir = join(__dirname, '../assets/svgs/standalone'); +const DIVIDER_WIDTH = CARD_WIDTH - 2 * Math.round(CARD_WIDTH / 10); + +function toDataUri(filePath: string): string { + return `data:image/svg+xml;base64,${readFileSync(filePath).toString('base64')}`; +} + +const GITHUB_CAT = toDataUri(join(standaloneDir, 'github_cat.svg')); +const GITHUB_CAT_WHITE = toDataUri(join(standaloneDir, 'github_cat_white.svg')); + +// Satori 0.25 does not support conic-gradient. The pie chart is pre-rendered as an +// SVG string (using the stroke-dasharray layer technique) and embedded as a data URI +// in an tag, which satori handles via its image renderer. +const CIRCUMFERENCE = 31.42; // 2π × r=5 + +function buildPieSvgUri(languageStats: LanguageDataWithAccum[], bgColor: string): string { + // Render largest slice first (background), smallest last (foreground). + const circles = [...languageStats] + .reverse() + .map((lang) => { + const dash = ((lang.count + lang.accum) / 100) * CIRCUMFERENCE; + return ( + `` + ); + }) + .join(''); + + const svg = + `` + + `` + + circles + + `` + + ``; + + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; +} + +interface LanguageCardProps { + userData: UserLanguageStats; + color: string; + chartType: 'pie' | 'bar'; +} + +const LanguageCard = ({ userData, color, chartType }: LanguageCardProps) => { + const isWhite = color === 'white'; + const cardBackground = isWhite ? COLOR_LIGHT : COLOR_DARK; + const labelColor = isWhite ? COLOR_DARK : COLOR_SUBTLE; + const titleColor = isWhite ? COLOR_DARK : COLOR_LIGHT; + const githubCatSrc = isWhite ? GITHUB_CAT : GITHUB_CAT_WHITE; + + const renderPieContent = () => { + const languageStats = calcPercentagesPie(userData.languages); + const languageStatsDesc = [...languageStats].sort((a, b) => b.count - a.count); + const pieSrc = buildPieSvgUri(languageStats, cardBackground); + + return ( +
+ {/* Language list */} +
+ {languageStatsDesc.map((lang, i) => ( +
+
+ + {lang.name} + +
+ ))} +
+ + {/* Pie chart rendered as a pre-built SVG data URI */} + +
+ ); + }; + + const renderBarContent = () => { + const languageStats = calcPercentagesBar(userData.languages).sort((a, b) => b.count - a.count); + const trackColor = isWhite ? '#E0E0E0' : '#2A3A4A'; + + return ( +
+ {languageStats.map((lang, i) => ( +
+
+ + {lang.name} + + {/* Track */} +
+ {/* Fill */} +
+
+
+ ))} +
+ ); + }; + + return ( +
+ {/* Title */} +
+ + Most used languages + + +
+ + {/* Divider */} +
+ + {/* Content */} + {chartType === 'pie' ? renderPieContent() : renderBarContent()} +
+ ); +}; + +export { LanguageCard }; diff --git a/scripts/renderers/StatCard.tsx b/scripts/renderers/StatCard.tsx new file mode 100644 index 0000000..e0bfd1d --- /dev/null +++ b/scripts/renderers/StatCard.tsx @@ -0,0 +1,191 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { UserStats } from '../../types'; +import { + CARD_WIDTH, + CARD_HEIGHT, + DIVIDER_Y, + COLOR_SUBTLE, + COLOR_LIGHT, + COLOR_DARK, +} from '../utils/constants'; + +const standaloneDir = join(__dirname, '../assets/svgs/standalone'); +const DIVIDER_WIDTH = CARD_WIDTH - 2 * Math.round(CARD_WIDTH / 10); + +function toDataUri(filePath: string): string { + return `data:image/svg+xml;base64,${readFileSync(filePath).toString('base64')}`; +} + +const ICONS = [ + toDataUri(join(standaloneDir, 'icon_followers.svg')), + toDataUri(join(standaloneDir, 'icon_repositories.svg')), + toDataUri(join(standaloneDir, 'icon_stars.svg')), + toDataUri(join(standaloneDir, 'icon_forks.svg')), +]; + +const GITHUB_CAT = toDataUri(join(standaloneDir, 'github_cat.svg')); +const GITHUB_CAT_WHITE = toDataUri(join(standaloneDir, 'github_cat_white.svg')); +const PENGUIN = toDataUri(join(standaloneDir, 'penguin.svg')); +const NO_PENGUIN = toDataUri(join(standaloneDir, 'no_penguin.svg')); +const NO_PENGUIN_WHITE = toDataUri(join(standaloneDir, 'no_penguin_white.svg')); + +const STAT_ROWS: { label: string }[] = [ + { label: 'Followers: ' }, + { label: 'Repositories: ' }, + { label: 'Stars: ' }, + { label: 'Forks: ' }, + { label: 'Total Contributions: ' }, +]; + +interface StatCardProps { + userData: UserStats; + color: string; + peng: boolean; +} + +type DecorImage = 'penguin' | 'no_penguin' | 'no_penguin_white'; + +const getDecordImage = (image: DecorImage) => { + switch (image) { + case 'penguin': + return ; + case 'no_penguin': + return ; + case 'no_penguin_white': + return ; + } +}; + +const StatCard = ({ userData, color, peng }: StatCardProps) => { + const isWhite = color === 'white'; + const background = isWhite ? COLOR_LIGHT : COLOR_DARK; + const labelColor = COLOR_SUBTLE; + const valueColor = isWhite ? COLOR_DARK : COLOR_LIGHT; + const githubCatSrc = isWhite ? GITHUB_CAT : GITHUB_CAT_WHITE; + const decor: DecorImage = + !peng || isWhite ? (isWhite ? 'no_penguin' : 'no_penguin_white') : 'penguin'; + + const values = [ + userData.amountFollowers, + userData.amountRepos, + userData.amountStars, + userData.amountForks, + userData.totalContributions, + ]; + + return ( +
+ {/* Title */} +
+ + @{userData.user}'s GitHub + + +
+ + {/* Divider */} +
+ + {/* Stats */} +
+ {STAT_ROWS.map((row, i) => ( +
+ {i < ICONS.length ? ( + + ) : ( +
+ )} + + {row.label} + + + {values[i]} + +
+ ))} +
+ + {/* Decorative corner image */} +
+ {getDecordImage(decor)} +
+
+ ); +}; + +export { StatCard }; diff --git a/scripts/renderers/renderErrorCard.test.ts b/scripts/renderers/renderErrorCard.test.ts index 466e80d..647f538 100644 --- a/scripts/renderers/renderErrorCard.test.ts +++ b/scripts/renderers/renderErrorCard.test.ts @@ -2,21 +2,21 @@ import { renderErrorCard } from './renderErrorCard'; import { CARD_WIDTH, CARD_HEIGHT } from '../utils/constants'; describe('renderErrorCard', () => { - test('returns an SVG string', () => { - const result = renderErrorCard('Test error'); + test('returns an SVG string', async () => { + const result = await renderErrorCard('Test error'); expect(result).toContain(''); }); - test('includes the error message', () => { - const message = 'Could not fetch data'; - const result = renderErrorCard(message); - expect(result).toContain(message); + test('renders different output for different messages', async () => { + const result1 = await renderErrorCard('Could not fetch data'); + const result2 = await renderErrorCard('Invalid username'); + expect(result1).not.toBe(result2); }); - test('uses the correct card dimensions', () => { - const result = renderErrorCard('Test'); - expect(result).toContain(`width="${ CARD_WIDTH }"`); - expect(result).toContain(`height="${ CARD_HEIGHT }"`); + test('uses the correct card dimensions', async () => { + const result = await renderErrorCard('Test'); + expect(result).toContain(`width="${CARD_WIDTH}"`); + expect(result).toContain(`height="${CARD_HEIGHT}"`); }); }); diff --git a/scripts/renderers/renderErrorCard.ts b/scripts/renderers/renderErrorCard.ts index ba4e627..afebae3 100644 --- a/scripts/renderers/renderErrorCard.ts +++ b/scripts/renderers/renderErrorCard.ts @@ -1,19 +1,32 @@ -import { CARD_WIDTH, CARD_HEIGHT, ERROR_DIVIDER_Y } from "../utils/constants"; +import satori from 'satori'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import React from 'react'; +import { ErrorCard } from './ErrorCard'; +import { CARD_WIDTH, CARD_HEIGHT } from '../utils/constants'; -const renderErrorCard = (message: string): string => { - return ` - - Error - - ${message} - `; +const fontRegular = readFileSync(join(__dirname, '../assets/fonts/inter-400.woff')); +const fontBold = readFileSync(join(__dirname, '../assets/fonts/inter-600.woff')); + +const renderErrorCard = async (message: string): Promise => { + return satori(React.createElement(ErrorCard, { message }), { + width: CARD_WIDTH, + height: CARD_HEIGHT, + fonts: [ + { + name: 'Inter', + data: fontRegular, + weight: 400, + style: 'normal', + }, + { + name: 'Inter', + data: fontBold, + weight: 600, + style: 'normal', + }, + ], + }); }; export { renderErrorCard }; diff --git a/scripts/renderers/renderLangCard.test.ts b/scripts/renderers/renderLangCard.test.ts new file mode 100644 index 0000000..1f58182 --- /dev/null +++ b/scripts/renderers/renderLangCard.test.ts @@ -0,0 +1,70 @@ +import { renderLanguageCard as renderLangPie } from './renderLangPie'; +import { renderLanguageCard as renderLangPercent } from './renderLangPercent'; +import { CARD_WIDTH, CARD_HEIGHT } from '../utils/constants'; +import { UserLanguageStats } from '../../types'; + +const testUser: UserLanguageStats = { + user: 'testuser', + languages: [ + { name: 'TypeScript', color: '#3178C6', count: 50000 }, + { name: 'JavaScript', color: '#F7DF1E', count: 30000 }, + { name: 'Python', color: '#3776AB', count: 10000 }, + { name: 'Go', color: '#00ADD8', count: 6000 }, + { name: 'Rust', color: '#DEA584', count: 4000 }, + ], +}; + +describe('renderLangPie', () => { + test('returns an SVG string', async () => { + const result = await renderLangPie(testUser, ''); + expect(result).toContain(''); + }); + + test('uses the correct card dimensions', async () => { + const result = await renderLangPie(testUser, ''); + expect(result).toContain(`width="${CARD_WIDTH}"`); + expect(result).toContain(`height="${CARD_HEIGHT}"`); + }); + + test('renders different output for dark and white color variants', async () => { + const dark = await renderLangPie(testUser, ''); + const white = await renderLangPie(testUser, 'white'); + expect(dark).not.toBe(white); + }); + + test('renders different output for different language data', async () => { + const user1 = await renderLangPie(testUser, ''); + const user2 = await renderLangPie({ + ...testUser, + languages: [{ name: 'Rust', color: '#DEA584', count: 10000 }], + }, ''); + expect(user1).not.toBe(user2); + }); +}); + +describe('renderLangPercent', () => { + test('returns an SVG string', async () => { + const result = await renderLangPercent(testUser, ''); + expect(result).toContain(''); + }); + + test('uses the correct card dimensions', async () => { + const result = await renderLangPercent(testUser, ''); + expect(result).toContain(`width="${CARD_WIDTH}"`); + expect(result).toContain(`height="${CARD_HEIGHT}"`); + }); + + test('renders different output for dark and white color variants', async () => { + const dark = await renderLangPercent(testUser, ''); + const white = await renderLangPercent(testUser, 'white'); + expect(dark).not.toBe(white); + }); + + test('renders different output compared to pie chart', async () => { + const pie = await renderLangPie(testUser, ''); + const percent = await renderLangPercent(testUser, ''); + expect(pie).not.toBe(percent); + }); +}); diff --git a/scripts/renderers/renderLangPercent.ts b/scripts/renderers/renderLangPercent.ts index b470331..ba553b6 100644 --- a/scripts/renderers/renderLangPercent.ts +++ b/scripts/renderers/renderLangPercent.ts @@ -1,100 +1,34 @@ -import * as svgs from "../utils/svgs"; -import { CARD_WIDTH, CARD_HEIGHT, LANG_ITEM_COUNT, DIVIDER_Y, COLOR_SUBTLE, COLOR_LIGHT, COLOR_DARK } from "../utils/constants"; -import { LanguageData, UserLanguageStats } from "../../types"; -import { TextAttr, CardAttr } from "./types"; -import { calcPercentagesBar as calcPercentages } from "./calcPercentages"; - -const renderLanguageCard = (userData: UserLanguageStats, color: string): string => { - let lightFontColor = COLOR_SUBTLE; - let normalFontColor = COLOR_LIGHT; - if (color === "white") { - lightFontColor = COLOR_DARK; - normalFontColor = COLOR_DARK; - } - - const createText = (text: string, textAttr: TextAttr): string => { - const element = ` - - ${ text } - - ${ (textAttr.title) ? `` : "" } - ` - return element; - } - - const createBar = (language: LanguageData, line: number): string => { - const icon = ` - - ` - return icon; - } - - const textAttr: TextAttr = { - weight: 400, - index: 0, - color: lightFontColor, - fontSize: 14, - dir: "left", - title: false - } - - const languageStats = calcPercentages(userData.languages).sort((a, b) => b.count - a.count); - - const cardAttr: CardAttr = { +import satori from 'satori'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import React from 'react'; +import { LanguageCard } from './LanguageCard'; +import { UserLanguageStats } from '../../types'; +import { CARD_WIDTH, CARD_HEIGHT } from '../utils/constants'; + +const fontRegular = readFileSync(join(__dirname, '../assets/fonts/inter-400.woff')); +const fontBold = readFileSync(join(__dirname, '../assets/fonts/inter-600.woff')); + +const renderLanguageCard = async (userData: UserLanguageStats, color: string): Promise => { + return satori(React.createElement(LanguageCard, { userData, color, chartType: 'bar' }), { width: CARD_WIDTH, height: CARD_HEIGHT, - background: `${ (color === "white") ? "white" : COLOR_DARK}`, - style: "border-radius: 10px;", - children: languageStats.reduce((acc: string[], item) => [...acc, item.name], ["Most used languages"]) - } - - const mountText = (): void => { - for (let i = 0; i < cardAttr.children.length; i++) { - if (i === 0) { - cardAttr.children[i] = createText(cardAttr.children[i], { ...textAttr, index: ++textAttr.index, dir: "right", title: true, color: normalFontColor }); - continue; - } - cardAttr.children[i] = createText(cardAttr.children[i], { ...textAttr, index: ++textAttr.index }); - } - } - - mountText(); - - return ` - - - ${ cardAttr.children.map(child => child).join('') } - ${ languageStats.map((child, index) => createBar(child, index + 1)) } - - ${ color === "white" ? svgs.githubCat : svgs.githubCatW } - - `; -} + fonts: [ + { + name: 'Inter', + data: fontRegular, + weight: 400, + style: 'normal', + }, + { + name: 'Inter', + data: fontBold, + weight: 600, + style: 'normal', + }, + ], + }); +}; export { renderLanguageCard }; -export { calcPercentagesBar as calcPercentages } from "./calcPercentages"; +export { calcPercentagesBar as calcPercentages } from './calcPercentages'; diff --git a/scripts/renderers/renderLangPie.ts b/scripts/renderers/renderLangPie.ts index e2918c4..51b6584 100644 --- a/scripts/renderers/renderLangPie.ts +++ b/scripts/renderers/renderLangPie.ts @@ -1,115 +1,34 @@ -import * as svgs from "../utils/svgs"; -import { CARD_WIDTH, CARD_HEIGHT, LANG_ITEM_COUNT, DIVIDER_Y, COLOR_SUBTLE, COLOR_LIGHT, COLOR_DARK } from "../utils/constants"; -import { UserLanguageStats } from "../../types"; -import { TextAttr, CardAttr, LanguageDataWithAccum } from "./types"; -import { calcPercentagesPie as calcPercentages } from "./calcPercentages"; - -const renderLanguageCard = (userData: UserLanguageStats, color: string): string => { - let lightFontColor = COLOR_SUBTLE; - let normalFontColor = COLOR_LIGHT; - if (color === "white") { - lightFontColor = COLOR_DARK; - normalFontColor = COLOR_DARK; - } - - const createText = (text: string, textAttr: TextAttr): string => { - const element = ` - - ${ text } - - ${ (textAttr.title) ? `` : "" } - ` - return element; - } - - const createIcon = (language: LanguageDataWithAccum, line: number): string => { - const icon = ` - ` - return icon; - } - - const textAttr: TextAttr = { - weight: 400, - index: 0, - color: lightFontColor, - fontSize: 14, - dir: "left", - title: false - } - - const languageStats = calcPercentages(userData.languages); - const languageStatsDesc = [...languageStats].sort((a, b) => b.count - a.count); - - const createCircles = (): string[] => { - return languageStats.map(lang => - `` - ).reverse(); - } - - const cardAttr: CardAttr = { +import satori from 'satori'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import React from 'react'; +import { LanguageCard } from './LanguageCard'; +import { UserLanguageStats } from '../../types'; +import { CARD_WIDTH, CARD_HEIGHT } from '../utils/constants'; + +const fontRegular = readFileSync(join(__dirname, '../assets/fonts/inter-400.woff')); +const fontBold = readFileSync(join(__dirname, '../assets/fonts/inter-600.woff')); + +const renderLanguageCard = async (userData: UserLanguageStats, color: string): Promise => { + return satori(React.createElement(LanguageCard, { userData, color, chartType: 'pie' }), { width: CARD_WIDTH, height: CARD_HEIGHT, - background: `${ (color === "white") ? "white" : COLOR_DARK}`, - style: "border-radius: 10px;", - children: languageStatsDesc.reduce((acc: string[], item) => [...acc, item.name], ["Most used languages"]) - } - - const mountText = (): void => { - for (let i = 0; i < cardAttr.children.length; i++) { - if (i === 0) { - cardAttr.children[i] = createText(cardAttr.children[i], { ...textAttr, index: ++textAttr.index, dir: "right", title: true, color: normalFontColor }); - continue; - } - cardAttr.children[i] = createText(cardAttr.children[i], { ...textAttr, index: ++textAttr.index }); - } - } - - mountText(); - return ` - - - ${ cardAttr.children.map(child => child).join('') } - ${ languageStatsDesc.map((child, index) => createIcon(child, index + 1)) } - - ${ color === "white" ? svgs.githubCat : svgs.githubCatW } - - - - ${ createCircles() } - - `; - -} + fonts: [ + { + name: 'Inter', + data: fontRegular, + weight: 400, + style: 'normal', + }, + { + name: 'Inter', + data: fontBold, + weight: 600, + style: 'normal', + }, + ], + }); +}; export { renderLanguageCard }; -export { calcPercentagesPie as calcPercentages } from "./calcPercentages"; +export { calcPercentagesPie as calcPercentages } from './calcPercentages'; diff --git a/scripts/renderers/renderStatCard.test.ts b/scripts/renderers/renderStatCard.test.ts new file mode 100644 index 0000000..c6b2ad5 --- /dev/null +++ b/scripts/renderers/renderStatCard.test.ts @@ -0,0 +1,44 @@ +import { renderStatCard } from './renderStatCard'; +import { CARD_WIDTH, CARD_HEIGHT } from '../utils/constants'; +import { UserStats } from '../../types'; + +const testUser: UserStats = { + user: 'testuser', + amountFollowers: 100, + amountRepos: 50, + amountStars: 200, + amountForks: 30, + totalContributions: 1000, +}; + +describe('renderStatCard', () => { + test('returns an SVG string', async () => { + const result = await renderStatCard(testUser, '', false); + expect(result).toContain(''); + }); + + test('uses the correct card dimensions', async () => { + const result = await renderStatCard(testUser, '', false); + expect(result).toContain(`width="${CARD_WIDTH}"`); + expect(result).toContain(`height="${CARD_HEIGHT}"`); + }); + + test('renders different output for dark and white color variants', async () => { + const dark = await renderStatCard(testUser, '', false); + const white = await renderStatCard(testUser, 'white', false); + expect(dark).not.toBe(white); + }); + + test('renders different output when peng is toggled', async () => { + const withPeng = await renderStatCard(testUser, '', true); + const withoutPeng = await renderStatCard(testUser, '', false); + expect(withPeng).not.toBe(withoutPeng); + }); + + test('renders different output for different users', async () => { + const user1 = await renderStatCard(testUser, '', false); + const user2 = await renderStatCard({ ...testUser, user: 'otheruser' }, '', false); + expect(user1).not.toBe(user2); + }); +}); diff --git a/scripts/renderers/renderStatCard.ts b/scripts/renderers/renderStatCard.ts index 6643f71..5e5f8ec 100644 --- a/scripts/renderers/renderStatCard.ts +++ b/scripts/renderers/renderStatCard.ts @@ -1,116 +1,33 @@ -import * as svgs from "../utils/svgs"; -import { CARD_WIDTH, CARD_HEIGHT, DIVIDER_Y, COLOR_SUBTLE, COLOR_LIGHT, COLOR_DARK } from "../utils/constants"; -import { UserStats } from "../../types"; -import { TextAttr, CardAttr } from "./types"; - -const renderStatCard = (userData: UserStats, color: string, peng: boolean): string => { - let lightFontColor = COLOR_SUBTLE; - let normalFontColor = COLOR_LIGHT; - const icons = [...svgs.icons]; - - if (color === "white") { - lightFontColor = COLOR_DARK; - normalFontColor = COLOR_DARK; - } - - const createText = (text: string, textAttr: TextAttr): string => { - const element = ` - - ${ text } - - ${ (textAttr.title) ? `` : "" } - ` - return element; - } - - const createIcon = (svg: string, line: number): string => { - const icon = ` - ${ svg } - - ` - return icon; - } - - const textAttr: TextAttr = { - weight: 400, - index: 0, - color: lightFontColor, - fontSize: 14, - dir: "left", - title: false - } - - const cardAttr: CardAttr = { +import satori from 'satori'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import React from 'react'; +import { StatCard } from './StatCard'; +import { UserStats } from '../../types'; +import { CARD_WIDTH, CARD_HEIGHT } from '../utils/constants'; + +const fontRegular = readFileSync(join(__dirname, '../assets/fonts/inter-400.woff')); +const fontBold = readFileSync(join(__dirname, '../assets/fonts/inter-600.woff')); + +const renderStatCard = async (userData: UserStats, color: string, peng: boolean): Promise => { + return satori(React.createElement(StatCard, { userData, color, peng }), { width: CARD_WIDTH, height: CARD_HEIGHT, - background: `${ (color === "white") ? "white" : COLOR_DARK}`, - style: "border-radius: 10px;", - children: [ - `@${ userData.user }'s GitHub`, - "Followers: ", - "Repositories: ", - "Stars: ", - "Forks: ", - "Total Contributions: ", - ] - } - - const mountText = (): void => { - for (let i = 0; i < cardAttr.children.length; i++) { - if (i === 0) { - cardAttr.children[i] = createText(cardAttr.children[i], { ...textAttr, index: ++textAttr.index, dir: "right", title: true, color: normalFontColor }); - continue; - } - cardAttr.children[i] += `${ Object.values(userData)[i] }`; - cardAttr.children[i] = createText(cardAttr.children[i], { ...textAttr, index: ++textAttr.index }); - - } - } - - const mountIcons = (): void => { - for (let i = 0; i < icons.length; i++) { - icons[i] = createIcon(icons[i], i + 1); - } - } - - mountText(); - mountIcons(); - - return ` - - - ${ cardAttr.children.map(child => child).join('') } - ${ icons.map(icon => icon).join('') } - - ${ color === "white" ? svgs.githubCat : svgs.githubCatW } - - - ${ (peng === false || color === "white") ? ((color !== "white") ? svgs.nopengW :svgs.nopeng ) : svgs.peng } - - `; -} + fonts: [ + { + name: 'Inter', + data: fontRegular, + weight: 400, + style: 'normal', + }, + { + name: 'Inter', + data: fontBold, + weight: 600, + style: 'normal', + }, + ], + }); +}; export { renderStatCard }; diff --git a/scripts/renderers/types.ts b/scripts/renderers/types.ts index 69a2518..728ce39 100644 --- a/scripts/renderers/types.ts +++ b/scripts/renderers/types.ts @@ -4,20 +4,3 @@ import { LanguageData } from "../../types"; export interface LanguageDataWithAccum extends LanguageData { accum: number; } - -export interface TextAttr { - weight: number; - index: number; - color: string; - fontSize: number; - dir: string; - title: boolean; -} - -export interface CardAttr { - width: number; - height: number; - background: string; - style: string; - children: string[]; -} diff --git a/tsconfig.json b/tsconfig.json index 0ecb1a2..9899aa0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "outDir": "dist", "rootDir": ".", "resolveJsonModule": true, - "allowJs": true + "allowJs": true, + "jsx": "react-jsx" }, "include": ["api/**/*", "scripts/**/*", "types/**/*"], "exclude": ["node_modules", "dist"]