From 63bd6999e80f2cfcf202b85b2a7fa2856d40d133 Mon Sep 17 00:00:00 2001 From: Ryota Ikezawa Date: Fri, 2 Jan 2026 15:25:02 +0900 Subject: [PATCH 1/3] chore: Add Biome linter/formatter with pre-commit hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Biome for linting, formatting, and import organization - Configure a11y rules, unused import detection, and type imports - Add husky + lint-staged for pre-commit validation - Add npm scripts: lint, lint:fix, format - Auto-fix 43 files with formatting and import organization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .husky/pre-commit | 1 + biome.json | 57 +++ package.json | 14 +- pnpm-lock.yaml | 354 +++++++++++++++++- src/api/cache-wrapper.ts | 19 +- src/api/index.ts | 17 +- src/cards/base.ts | 2 +- src/cards/languages.ts | 175 ++++++--- src/cards/repo.ts | 100 +++-- src/cards/stats.ts | 107 ++++-- src/client/App.tsx | 4 +- .../components/generators/CodeOutput.tsx | 30 +- .../components/generators/LocaleSelect.tsx | 2 +- .../components/generators/PreviewPanel.tsx | 25 +- .../components/generators/ThemeSelect.tsx | 2 +- src/client/components/layout/Footer.tsx | 2 +- src/client/components/layout/Header.tsx | 22 +- src/client/components/layout/Layout.tsx | 2 +- src/client/components/ui/button.tsx | 17 +- src/client/components/ui/card.tsx | 106 ++---- src/client/components/ui/checkbox.tsx | 6 +- src/client/components/ui/label.tsx | 11 +- src/client/components/ui/select.tsx | 15 +- src/client/components/ui/skeleton.tsx | 12 +- src/client/components/ui/tabs.tsx | 2 +- src/client/components/ui/tooltip.tsx | 2 +- src/client/contexts/i18n.tsx | 26 +- src/client/contexts/username.tsx | 2 +- src/client/i18n/index.ts | 32 +- src/client/pages/Home.tsx | 28 +- src/client/pages/LanguagesGenerator.tsx | 74 ++-- src/client/pages/PinGenerator.tsx | 68 +--- src/client/pages/StatsGenerator.tsx | 68 +--- src/fetchers/github.ts | 29 +- src/index.ts | 6 +- src/middleware/compression.ts | 8 +- src/middleware/rate-limit.ts | 39 +- src/themes/index.ts | 2 +- src/types.ts | 2 +- src/utils/cache.ts | 43 ++- src/utils/fonts.ts | 6 +- src/utils/index.ts | 40 +- src/utils/locale.ts | 2 +- src/utils/monitoring.ts | 12 +- tests/e2e.spec.ts | 2 +- 45 files changed, 967 insertions(+), 628 deletions(-) create mode 100644 .husky/pre-commit create mode 100644 biome.json diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..cb2c84d --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm lint-staged diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..3b34dd1 --- /dev/null +++ b/biome.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": [ + "**/src/**/*.ts", + "**/src/**/*.tsx", + "**/tests/**/*.ts", + "!**/node_modules", + "!**/dist", + "!**/.wrangler" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "recommended": true + }, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "warn" + }, + "style": { + "noNonNullAssertion": "warn", + "useImportType": "error" + }, + "suspicious": { + "noExplicitAny": "warn", + "noArrayIndexKey": "warn", + "noConfusingVoidType": "off" + }, + "complexity": { + "noStaticOnlyClass": "warn" + } + } + }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "es5", + "semicolons": "always" + } + } +} diff --git a/package.json b/package.json index 0792621..46468b3 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,16 @@ "preview": "vite preview", "deploy": "vite build && wrangler deploy", "typecheck": "tsc --noEmit", - "typecheck:client": "tsc --noEmit -p tsconfig.client.json" + "typecheck:client": "tsc --noEmit -p tsconfig.client.json", + "lint": "biome check src tests", + "lint:fix": "biome check --write src tests", + "format": "biome format --write src tests", + "prepare": "husky" + }, + "lint-staged": { + "*.{ts,tsx}": [ + "biome check --write --no-errors-on-unmatched" + ] }, "keywords": [ "github", @@ -41,6 +50,7 @@ "tailwind-merge": "^2.6.0" }, "devDependencies": { + "@biomejs/biome": "2.3.10", "@cloudflare/workers-types": "^4.0.0", "@playwright/test": "1.57.0", "@types/node": "^20.0.0", @@ -49,6 +59,8 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "concurrently": "^9.1.0", + "husky": "9.1.7", + "lint-staged": "16.2.7", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b59b2c..df8ff47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: specifier: ^2.6.0 version: 2.6.0 devDependencies: + '@biomejs/biome': + specifier: 2.3.10 + version: 2.3.10 '@cloudflare/workers-types': specifier: ^4.0.0 version: 4.20260101.0 @@ -77,28 +80,34 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.1(@types/node@20.19.27)(jiti@1.21.7)) + version: 4.7.0(vite@6.4.1(@types/node@20.19.27)(jiti@1.21.7)(yaml@2.8.2)) autoprefixer: specifier: ^10.4.20 version: 10.4.23(postcss@8.5.6) concurrently: specifier: ^9.1.0 version: 9.2.1 + husky: + specifier: 9.1.7 + version: 9.1.7 + lint-staged: + specifier: 16.2.7 + version: 16.2.7 postcss: specifier: ^8.4.49 version: 8.5.6 tailwindcss: specifier: ^3.4.17 - version: 3.4.19 + version: 3.4.19(yaml@2.8.2) tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.19) + version: 1.0.7(tailwindcss@3.4.19(yaml@2.8.2)) typescript: specifier: ^5.0.0 version: 5.9.3 vite: specifier: ^6.0.0 - version: 6.4.1(@types/node@20.19.27)(jiti@1.21.7) + version: 6.4.1(@types/node@20.19.27)(jiti@1.21.7)(yaml@2.8.2) wrangler: specifier: ^4.54.0 version: 4.54.0(@cloudflare/workers-types@4.20260101.0) @@ -196,6 +205,59 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.3.10': + resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.10': + resolution: {integrity: sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.10': + resolution: {integrity: sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.10': + resolution: {integrity: sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.10': + resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.10': + resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.10': + resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.10': + resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.10': + resolution: {integrity: sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@cloudflare/kv-asset-handler@0.4.1': resolution: {integrity: sha512-Nu8ahitGFFJztxUml9oD/DLb7Z28C8cd8F46IVQ7y5Btz575pvMY8AqZsXkX7Gds29eCKdMgIHjIvzskHgPSFg==} engines: {node: '>=18.0.0'} @@ -1252,14 +1314,26 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ansi-escapes@7.2.0: + resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1319,6 +1393,14 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} + engines: {node: '>=20'} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1341,6 +1423,13 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1390,9 +1479,16 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -1410,6 +1506,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + exit-hook@2.2.1: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} @@ -1458,6 +1557,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -1488,6 +1591,11 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + i18next@25.7.3: resolution: {integrity: sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==} peerDependencies: @@ -1515,6 +1623,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1551,6 +1663,19 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lint-staged@16.2.7: + resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1572,6 +1697,10 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + miniflare@4.20251210.0: resolution: {integrity: sha512-k6kIoXwGVqlPZb0hcn+X7BmnK+8BjIIkusQPY22kCo2RaQJ/LzAjtxHQdGXerlHSnJyQivDQsL6BJHMpQfUFyw==} engines: {node: '>=18.0.0'} @@ -1583,6 +1712,10 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nano-spawn@2.0.0: + resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} + engines: {node: '>=20.17'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1603,6 +1736,10 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -1623,6 +1760,11 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -1783,10 +1925,17 @@ packages: engines: {node: '>= 0.4'} hasBin: true + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.54.0: resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1821,9 +1970,17 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1832,14 +1989,30 @@ packages: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -2011,6 +2184,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -2030,6 +2207,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -2165,6 +2347,41 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/biome@2.3.10': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.10 + '@biomejs/cli-darwin-x64': 2.3.10 + '@biomejs/cli-linux-arm64': 2.3.10 + '@biomejs/cli-linux-arm64-musl': 2.3.10 + '@biomejs/cli-linux-x64': 2.3.10 + '@biomejs/cli-linux-x64-musl': 2.3.10 + '@biomejs/cli-win32-arm64': 2.3.10 + '@biomejs/cli-win32-x64': 2.3.10 + + '@biomejs/cli-darwin-arm64@2.3.10': + optional: true + + '@biomejs/cli-darwin-x64@2.3.10': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.10': + optional: true + + '@biomejs/cli-linux-arm64@2.3.10': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.10': + optional: true + + '@biomejs/cli-linux-x64@2.3.10': + optional: true + + '@biomejs/cli-win32-arm64@2.3.10': + optional: true + + '@biomejs/cli-win32-x64@2.3.10': + optional: true + '@cloudflare/kv-asset-handler@0.4.1': dependencies: mime: 3.0.0 @@ -2946,7 +3163,7 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.27)(jiti@1.21.7))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@20.19.27)(jiti@1.21.7)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -2954,7 +3171,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@20.19.27)(jiti@1.21.7) + vite: 6.4.1(@types/node@20.19.27)(jiti@1.21.7)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -2962,12 +3179,20 @@ snapshots: acorn@8.14.0: {} + ansi-escapes@7.2.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -3033,6 +3258,15 @@ snapshots: dependencies: clsx: 2.1.1 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-truncate@5.1.1: + dependencies: + slice-ansi: 7.1.2 + string-width: 8.1.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -3057,6 +3291,10 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + colorette@2.0.20: {} + + commander@14.0.2: {} + commander@4.1.1: {} concurrently@9.2.1: @@ -3090,8 +3328,12 @@ snapshots: electron-to-chromium@1.5.267: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + environment@1.1.0: {} + error-stack-parser-es@1.0.5: {} esbuild@0.25.12: @@ -3154,6 +3396,8 @@ snapshots: escalade@3.2.0: {} + eventemitter3@5.0.1: {} + exit-hook@2.2.1: {} fast-glob@3.3.3: @@ -3190,6 +3434,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.4.0: {} + get-nonce@1.0.1: {} glob-parent@5.1.2: @@ -3214,6 +3460,8 @@ snapshots: dependencies: void-elements: 3.1.0 + husky@9.1.7: {} + i18next@25.7.3(typescript@5.9.3): dependencies: '@babel/runtime': 7.28.4 @@ -3234,6 +3482,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.4.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3254,6 +3506,33 @@ snapshots: lines-and-columns@1.2.4: {} + lint-staged@16.2.7: + dependencies: + commander: 14.0.2 + listr2: 9.0.5 + micromatch: 4.0.8 + nano-spawn: 2.0.0 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.8.2 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.1.1 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.2.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -3271,6 +3550,8 @@ snapshots: mime@3.0.0: {} + mimic-function@5.0.1: {} + miniflare@4.20251210.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -3297,6 +3578,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nano-spawn@2.0.0: {} + nanoid@3.3.11: {} node-releases@2.0.27: {} @@ -3307,6 +3590,10 @@ snapshots: object-hash@3.0.0: {} + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + path-parse@1.0.7: {} path-to-regexp@6.3.0: {} @@ -3319,6 +3606,8 @@ snapshots: picomatch@4.0.3: {} + pidtree@0.6.0: {} + pify@2.3.0: {} pirates@4.0.7: {} @@ -3343,12 +3632,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 + yaml: 2.8.2 postcss-nested@6.2.0(postcss@8.5.6): dependencies: @@ -3447,8 +3737,15 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + reusify@1.1.0: {} + rfdc@1.4.1: {} + rollup@4.54.0: dependencies: '@types/estree': 1.0.8 @@ -3521,24 +3818,48 @@ snapshots: shell-quote@1.8.3: {} + signal-exit@4.1.0: {} + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} stoppable@1.1.0: {} + string-argv@0.3.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -3563,11 +3884,11 @@ snapshots: tailwind-merge@2.6.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.19): + tailwindcss-animate@1.0.7(tailwindcss@3.4.19(yaml@2.8.2)): dependencies: - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(yaml@2.8.2) - tailwindcss@3.4.19: + tailwindcss@3.4.19(yaml@2.8.2): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -3586,7 +3907,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(yaml@2.8.2) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -3655,7 +3976,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@6.4.1(@types/node@20.19.27)(jiti@1.21.7): + vite@6.4.1(@types/node@20.19.27)(jiti@1.21.7)(yaml@2.8.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -3667,6 +3988,7 @@ snapshots: '@types/node': 20.19.27 fsevents: 2.3.3 jiti: 1.21.7 + yaml: 2.8.2 void-elements@3.1.0: {} @@ -3701,12 +4023,20 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + ws@8.18.0: {} y18n@5.0.8: {} yallist@3.1.1: {} + yaml@2.8.2: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/src/api/cache-wrapper.ts b/src/api/cache-wrapper.ts index 497994c..8a4cef2 100644 --- a/src/api/cache-wrapper.ts +++ b/src/api/cache-wrapper.ts @@ -1,6 +1,6 @@ import type { Context } from 'hono'; import type { Env } from '../types'; -import { CacheManager, getCacheHeaders, CACHE_TTL_EXPORT } from '../utils/cache'; +import { CACHE_TTL_EXPORT, CacheManager, getCacheHeaders } from '../utils/cache'; export interface CacheWrapperOptions { type: 'stats' | 'languages' | 'repo'; @@ -15,7 +15,7 @@ export async function withCache( const cache = new CacheManager(c.env); const query = c.req.query(); const cacheKey = CacheManager.generateKey(options.type, query); - + // Try to get from cache const cached = await cache.get(cacheKey); if (cached) { @@ -25,14 +25,15 @@ export async function withCache( 'X-Cache-Status': 'HIT', }); } - + try { const svg = await handler(); - + // Cache the result - const ttl = options.ttl || CACHE_TTL_EXPORT[options.type.toUpperCase() as keyof typeof CACHE_TTL_EXPORT]; + const ttl = + options.ttl || CACHE_TTL_EXPORT[options.type.toUpperCase() as keyof typeof CACHE_TTL_EXPORT]; await cache.set(cacheKey, svg, { ttl }); - + return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', ...Object.fromEntries(getCacheHeaders(ttl)), @@ -40,11 +41,11 @@ export async function withCache( }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - + // Cache error responses to prevent hammering const errorKey = `${cacheKey}:error`; await cache.set(errorKey, message, { ttl: CACHE_TTL_EXPORT.ERROR }); - + return c.text(`Error: ${message}`, 500); } -} \ No newline at end of file +} diff --git a/src/api/index.ts b/src/api/index.ts index 46796f4..45949cb 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,10 +1,10 @@ import { Hono } from 'hono'; -import type { Env, StatsCardOptions, LanguagesCardOptions, RepoCardOptions } from '../types'; -import { fetchUserStats, fetchTopLanguages, fetchRepo } from '../fetchers/github'; -import { createStatsCard } from '../cards/stats'; import { createLanguagesCard } from '../cards/languages'; import { createRepoCard } from '../cards/repo'; -import { CacheManager, getCacheHeaders, CACHE_TTL_EXPORT } from '../utils/cache'; +import { createStatsCard } from '../cards/stats'; +import { fetchRepo, fetchTopLanguages, fetchUserStats } from '../fetchers/github'; +import type { Env, LanguagesCardOptions, RepoCardOptions, StatsCardOptions } from '../types'; +import { CACHE_TTL_EXPORT, CacheManager, getCacheHeaders } from '../utils/cache'; const api = new Hono<{ Bindings: Env }>(); @@ -16,13 +16,16 @@ const parseBoolean = (value: string | undefined): boolean | undefined => { const parseArray = (value: string | undefined): string[] => { if (!value) return []; - return value.split(',').map(s => s.trim()).filter(Boolean); + return value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); }; const parseNumber = (value: string | undefined): number | undefined => { if (!value) return undefined; const num = parseInt(value, 10); - return isNaN(num) ? undefined : num; + return Number.isNaN(num) ? undefined : num; }; const parseCommonOptions = (query: Record) => ({ @@ -165,4 +168,4 @@ api.get('/pin', async (c) => { } }); -export { api }; \ No newline at end of file +export { api }; diff --git a/src/cards/base.ts b/src/cards/base.ts index 8e95e26..d4f4419 100644 --- a/src/cards/base.ts +++ b/src/cards/base.ts @@ -1,2 +1,2 @@ // This file is deprecated - each card now generates its own SVG directly -export {}; \ No newline at end of file +export {}; diff --git a/src/cards/languages.ts b/src/cards/languages.ts index ca34aad..aa97181 100644 --- a/src/cards/languages.ts +++ b/src/cards/languages.ts @@ -1,5 +1,16 @@ import type { LanguageStats, LanguagesCardOptions } from '../types'; -import { getColors, escapeXml, getLanguageColor, t, type Locale, getFontFamily, getFontSizes } from '../utils'; +import { + escapeXml, + getColors, + getFontFamily, + getFontSizes, + getLanguageColor, + type Locale, + t, +} from '../utils'; + +// Standard card height for consistent alignment +const CARD_HEIGHT = 195; export const createLanguagesCard = ( languages: LanguageStats, @@ -13,18 +24,47 @@ export const createLanguagesCard = ( const fontFamily = getFontFamily(locale); const fontSize = getFontSizes(locale); const title = escapeXml(customTitle ?? trans.languages.title); - - const langArray = Object.values(languages).slice(0, options.langsCount ?? 8); - + + // Limit languages to fit in fixed height + const maxLangs = layout === 'compact' ? 8 : 5; + const langArray = Object.values(languages).slice( + 0, + Math.min(options.langsCount ?? maxLangs, maxLangs) + ); + if (langArray.length === 0) { - return createEmptyCard(title, colors, options.cardWidth ?? 300, locale, trans, fontFamily, fontSize); + return createEmptyCard( + title, + colors, + options.cardWidth ?? 300, + locale, + trans, + fontFamily, + fontSize + ); } switch (layout) { case 'compact': - return createCompactLayout(langArray, title, colors, options.cardWidth ?? 300, locale, fontFamily, fontSize); + return createCompactLayout( + langArray, + title, + colors, + options.cardWidth ?? 300, + locale, + fontFamily, + fontSize + ); default: - return createNormalLayout(langArray, title, colors, options.cardWidth ?? 300, locale, fontFamily, fontSize); + return createNormalLayout( + langArray, + title, + colors, + options.cardWidth ?? 300, + locale, + fontFamily, + fontSize + ); } }; @@ -33,40 +73,43 @@ const createNormalLayout = ( title: string, colors: any, width: number, - locale: Locale, + _locale: Locale, fontFamily: string, fontSize: any ): string => { const padding = 20; - const titleHeight = 50; - const itemHeight = 35; - const height = titleHeight + langs.length * itemHeight + padding * 2; - + const height = CARD_HEIGHT; + + // Vertical layout: top 20px, title, 15px gap, content, bottom 20px + const titleY = 35; + const contentStart = 55; + const contentEnd = height - 20; + const itemHeight = Math.floor((contentEnd - contentStart) / Math.max(langs.length, 1)); + const barWidth = width - padding * 2; + let content = ''; langs.forEach((lang, index) => { - const y = titleHeight + index * itemHeight; - const barY = 18; - const barWidth = width - padding * 2; + const y = contentStart + index * itemHeight; const progressWidth = (lang.percentage / 100) * barWidth; - + content += ` - - ${escapeXml(lang.name)} - ${lang.percentage.toFixed(1)}% - - - + + ${escapeXml(lang.name)} + ${lang.percentage.toFixed(1)}% + + + `; }); - + return ` - - ${title} - + + ${title} + ${content} `; }; @@ -76,80 +119,88 @@ const createCompactLayout = ( title: string, colors: any, width: number, - locale: Locale, + _locale: Locale, fontFamily: string, fontSize: any ): string => { const padding = 20; + const height = CARD_HEIGHT; const barHeight = 8; const barWidth = width - padding * 2; - - // Calculate height based on legend rows - const cols = 2; - const legendRows = Math.ceil(langs.length / cols); - const legendHeight = legendRows * 20; - const height = 80 + legendHeight + padding; - + + // Vertical layout: top 20px, title, 15px gap, content, bottom 20px + const titleY = 35; + // Create stacked bar let stackedBar = ''; let currentX = 0; - langs.forEach((lang, i) => { + langs.forEach((lang) => { const segmentWidth = (lang.percentage / 100) * barWidth; stackedBar += ``; currentX += segmentWidth; }); - - // Create legend + + // Create legend (2 columns, max 4 rows = 8 items) let legend = ''; - const legendY = 25; + const cols = 2; const colWidth = barWidth / cols; - + const legendStartY = 72; + const legendRowHeight = 24; + langs.forEach((lang, i) => { const col = i % cols; const row = Math.floor(i / cols); const x = col * colWidth; - const y = legendY + row * 20; - + const y = legendStartY + row * legendRowHeight; + legend += ` - - - ${escapeXml(lang.name)} ${lang.percentage.toFixed(1)}% + + + ${escapeXml(lang.name)} ${lang.percentage.toFixed(1)}% `; }); - + return ` - - ${title} - - - + + ${title} + + + ${stackedBar} - - ${legend} - - + + ${legend} + - + `; }; -const createEmptyCard = (title: string, colors: any, width: number, locale: Locale, trans: any, fontFamily: string, fontSize: any): string => { - const height = 120; - +const createEmptyCard = ( + title: string, + colors: any, + width: number, + _locale: Locale, + trans: any, + fontFamily: string, + fontSize: any +): string => { + const height = CARD_HEIGHT; + const padding = 15; + return ` - - ${title} - ${trans.languages.noData} + + ${title} + ${trans.languages.noData} `; -}; \ No newline at end of file +}; diff --git a/src/cards/repo.ts b/src/cards/repo.ts index 2b70efc..1c3d1a8 100644 --- a/src/cards/repo.ts +++ b/src/cards/repo.ts @@ -1,41 +1,54 @@ import type { GitHubRepo, RepoCardOptions } from '../types'; -import { getColors, formatNumber, escapeXml, wrapText, getLanguageColor, t, formatNumberLocale, type Locale, getFontFamily, getFontSizes } from '../utils'; +import { + escapeXml, + formatNumberLocale, + getColors, + getFontFamily, + getFontSizes, + getLanguageColor, + type Locale, + t, + wrapText, +} from '../utils'; -export const createRepoCard = ( - repo: GitHubRepo, - options: RepoCardOptions = {} -): string => { +// Standard card height for consistent alignment +const CARD_HEIGHT = 195; + +// SVG icon paths +const repoIcon = + 'M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z'; +const starIcon = + 'M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z'; +const forkIcon = + 'M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h1.5v2.128a2.251 2.251 0 1 0 1.5 0V8.5h1.5a2.25 2.25 0 0 0 2.25-2.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75h-4.5a.75.75 0 0 1-.75-.75v-.878Zm6.5-.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Zm-3 8.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z'; + +export const createRepoCard = (repo: GitHubRepo, options: RepoCardOptions = {}): string => { const colors = getColors(options); const showOwner = options.showOwner ?? false; - const descriptionLinesCount = options.descriptionLinesCount ?? 2; const locale = (options.locale as Locale) || 'en'; const trans = t(locale); const fontFamily = getFontFamily(locale); const fontSize = getFontSizes(locale); - + const width = options.cardWidth ?? 400; - const padding = 20; - const titleHeight = 50; - const lineHeight = 18; - + const height = CARD_HEIGHT; + const padding = 15; + const titleText = showOwner ? repo.nameWithOwner : repo.name; const description = repo.description || trans.repo.noDescription; - const descLines = wrapText(description, width - padding * 2, 13).slice(0, descriptionLinesCount); - - const descriptionHeight = descLines.length * lineHeight + 10; - const statsHeight = 30; - const height = titleHeight + descriptionHeight + statsHeight + padding * 2; - - // Description lines + // Fixed max lines to fit in 195px height + const descLines = wrapText(description, width - padding * 2 - 20, 12).slice(0, 4); + + // Description section let descContent = ''; descLines.forEach((line, i) => { - descContent += `${escapeXml(line)}\n`; + descContent += `${escapeXml(line)}\n`; }); - - // Stats section - const statsY = titleHeight + descriptionHeight + 15; + + // Stats section at fixed position + const statsY = height - 25; let statsX = padding; - + // Language dot let langContent = ''; if (repo.primaryLanguage) { @@ -45,38 +58,47 @@ export const createRepoCard = ( `; statsX += 100; } - - // Stars + + // Stars with icon const starContent = ` - ⭐ ${formatNumberLocale(repo.stargazerCount, locale)} + + + ${formatNumberLocale(repo.stargazerCount, locale)} + `; - statsX += 60; - - // Forks + statsX += 55; + + // Forks with icon const forkContent = ` - 🍴 ${formatNumberLocale(repo.forkCount, locale)} + + + ${formatNumberLocale(repo.forkCount, locale)} + `; - + // Archived badge let archivedBadge = ''; if (repo.isArchived) { archivedBadge = ` - - ${trans.repo.archived} + + ${trans.repo.archived} `; } - + return ` - - 📦 ${escapeXml(titleText)} + + + + ${escapeXml(titleText)} + ${archivedBadge} - + ${descContent} - + ${langContent} ${starContent} ${forkContent} `; -}; \ No newline at end of file +}; diff --git a/src/cards/stats.ts b/src/cards/stats.ts index d1568e6..d4d81cb 100644 --- a/src/cards/stats.ts +++ b/src/cards/stats.ts @@ -1,10 +1,37 @@ -import type { UserStats, StatsCardOptions } from '../types'; -import { getColors, formatNumber, escapeXml, t, formatNumberLocale, type Locale, getFontFamily, getFontSizes } from '../utils'; +import type { StatsCardOptions, UserStats } from '../types'; +import { + escapeXml, + formatNumberLocale, + getColors, + getFontFamily, + getFontSizes, + type Locale, + t, +} from '../utils'; -export const createStatsCard = ( - stats: UserStats, - options: StatsCardOptions = {} -): string => { +// SVG icon paths (16x16 viewBox) +const icons: Record = { + stars: + 'M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.75.75 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z', + commits: + 'M1.643 3.143.427 1.927A.25.25 0 0 1 .604 1.5h2.792a.25.25 0 0 1 .177.427l-1.216 1.216a.25.25 0 0 1-.354 0Zm6.354 1.854a3.5 3.5 0 1 0 0 6.006v1.078a.75.75 0 0 0 1.5 0v-1.078c1.503-.273 2.648-1.577 2.648-3.003 0-1.426-1.145-2.73-2.648-3.003V3.919a.75.75 0 0 0-1.5 0v1.078ZM8 6.5a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z', + prs: 'M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z', + issues: + 'M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z', + contribs: + 'M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z', +}; + +const createIcon = (iconKey: string, color: string, x: number, y: number): string => { + const path = icons[iconKey]; + if (!path) return ''; + return ``; +}; + +// Standard card height for consistent alignment +const CARD_HEIGHT = 195; + +export const createStatsCard = (stats: UserStats, options: StatsCardOptions = {}): string => { const colors = getColors(options); const showIcons = options.showIcons ?? false; const hideRank = options.hideRank ?? false; @@ -14,13 +41,19 @@ export const createStatsCard = ( const trans = t(locale); const fontFamily = getFontFamily(locale); const fontSize = getFontSizes(locale); - - // Card dimensions - const width = options.cardWidth ?? 450; + + // Card dimensions - fixed height for consistency + const width = options.cardWidth ?? 495; + const height = CARD_HEIGHT; const padding = 20; - const titleHeight = 50; - const lineHeight = 30; - + const iconOffset = showIcons ? 22 : 0; + + // Vertical layout: top 20px, title, 15px gap, content, bottom 20px + const titleY = 35; + const contentStart = 55; + const contentEnd = height - 20; + const contentHeight = contentEnd - contentStart; + // Stats to display const statItems = [ { key: 'stars', label: trans.stats.totalStars, value: stats.totalStars }, @@ -28,51 +61,55 @@ export const createStatsCard = ( { key: 'prs', label: trans.stats.totalPRs, value: stats.totalPRs }, { key: 'issues', label: trans.stats.totalIssues, value: stats.totalIssues }, { key: 'contribs', label: trans.stats.contributedTo, value: stats.contributedTo }, - ].filter(stat => !hide.includes(stat.key)); - - const contentHeight = statItems.length * lineHeight; - const rankHeight = hideRank ? 0 : 120; - const height = titleHeight + contentHeight + rankHeight + padding * 2; - + ].filter((stat) => !hide.includes(stat.key)); + const title = escapeXml(customTitle ?? trans.stats.title(stats.name)); - + + // Stats area width (leave room for rank circle on right if shown) + const statsAreaWidth = hideRank ? width - padding * 2 : width - 115; + const lineHeight = Math.floor(contentHeight / Math.max(statItems.length, 1)); + // Build stats rows let statsContent = ''; statItems.forEach((stat, index) => { - const y = titleHeight + (index * lineHeight) + 5; + const y = contentStart + index * lineHeight + 12; + const textX = padding + iconOffset; + const iconContent = showIcons ? createIcon(stat.key, colors.iconColor, padding, y) : ''; statsContent += ` - ${stat.label} - ${formatNumberLocale(stat.value, locale)} + ${iconContent} + ${stat.label} + ${formatNumberLocale(stat.value, locale)} `; }); - - // Rank circle (if not hidden) + + // Rank circle (positioned on right side if not hidden) let rankContent = ''; if (!hideRank) { - const rankY = titleHeight + contentHeight + 60; - const circleRadius = 40; + const circleX = width - 55; + const circleY = height / 2 + 5; + const circleRadius = 35; const circumference = 2 * Math.PI * circleRadius; const progress = ((100 - stats.rank.percentile) / 100) * circumference; - + rankContent = ` - + - ${stats.rank.level} - ${trans.stats.top} ${stats.rank.percentile}% + ${stats.rank.level} + ${trans.stats.top} ${stats.rank.percentile}% `; } - + return ` - - ${title} - + + ${title} + ${statsContent} ${rankContent} `; -}; \ No newline at end of file +}; diff --git a/src/client/App.tsx b/src/client/App.tsx index 765f3c5..1368c15 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,5 +1,5 @@ -import { Suspense, lazy } from 'react'; -import { Routes, Route } from 'react-router-dom'; +import { lazy, Suspense } from 'react'; +import { Route, Routes } from 'react-router-dom'; import { Layout } from './components/layout/Layout'; import { PageLoader } from './components/layout/PageLoader'; diff --git a/src/client/components/generators/CodeOutput.tsx b/src/client/components/generators/CodeOutput.tsx index 3ba8168..50f28da 100644 --- a/src/client/components/generators/CodeOutput.tsx +++ b/src/client/components/generators/CodeOutput.tsx @@ -1,8 +1,8 @@ -import { useState, useCallback, memo, useMemo } from 'react'; +import { Check, Copy } from 'lucide-react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Copy, Check } from 'lucide-react'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; interface CodeOutputProps { url: string; @@ -23,11 +23,14 @@ export const CodeOutput = memo(function CodeOutput({ url, alt }: CodeOutputProps }, [url]); // Memoize all code formats - const codes = useMemo(() => ({ - markdown: `![${alt}](${fullUrl})`, - html: `${alt}`, - url: fullUrl, - }), [alt, fullUrl]); + const codes = useMemo( + () => ({ + markdown: `![${alt}](${fullUrl})`, + html: `${alt}`, + url: fullUrl, + }), + [alt, fullUrl] + ); const handleCopy = useCallback(async () => { await navigator.clipboard.writeText(codes[activeTab as keyof typeof codes]); @@ -50,12 +53,7 @@ export const CodeOutput = memo(function CodeOutput({ url, alt }: CodeOutputProps {t('code.url')} - diff --git a/src/client/components/layout/Layout.tsx b/src/client/components/layout/Layout.tsx index e37402e..cef384b 100644 --- a/src/client/components/layout/Layout.tsx +++ b/src/client/components/layout/Layout.tsx @@ -1,6 +1,6 @@ import { Outlet } from 'react-router-dom'; -import { Header } from './Header'; import { Footer } from './Footer'; +import { Header } from './Header'; export function Layout() { return ( diff --git a/src/client/components/ui/button.tsx b/src/client/components/ui/button.tsx index d26a958..0cefcd6 100644 --- a/src/client/components/ui/button.tsx +++ b/src/client/components/ui/button.tsx @@ -1,6 +1,6 @@ -import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; import { cn } from '@/lib/utils'; const buttonVariants = cva( @@ -8,14 +8,11 @@ const buttonVariants = cva( { variants: { variant: { - default: - 'bg-primary text-primary-foreground shadow hover:bg-primary/90', - destructive: - 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', - secondary: - 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', }, @@ -43,11 +40,7 @@ const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; return ( - + ); } ); diff --git a/src/client/components/ui/card.tsx b/src/client/components/ui/card.tsx index d7b6b88..b3bae8e 100644 --- a/src/client/components/ui/card.tsx +++ b/src/client/components/ui/card.tsx @@ -1,82 +1,54 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; -const Card = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); Card.displayName = 'Card'; -const CardHeader = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); CardHeader.displayName = 'CardHeader'; -const CardTitle = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); CardTitle.displayName = 'CardTitle'; -const CardDescription = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); CardDescription.displayName = 'CardDescription'; -const CardContent = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); CardContent.displayName = 'CardContent'; -const CardFooter = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); CardFooter.displayName = 'CardFooter'; -export { - Card, - CardHeader, - CardFooter, - CardTitle, - CardDescription, - CardContent, -}; +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/src/client/components/ui/checkbox.tsx b/src/client/components/ui/checkbox.tsx index 11f2d73..c7ffabb 100644 --- a/src/client/components/ui/checkbox.tsx +++ b/src/client/components/ui/checkbox.tsx @@ -1,6 +1,6 @@ -import * as React from 'react'; import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import { Check } from 'lucide-react'; +import * as React from 'react'; import { cn } from '@/lib/utils'; const Checkbox = React.forwardRef< @@ -15,9 +15,7 @@ const Checkbox = React.forwardRef< )} {...props} > - + diff --git a/src/client/components/ui/label.tsx b/src/client/components/ui/label.tsx index b4b4d82..e76c680 100644 --- a/src/client/components/ui/label.tsx +++ b/src/client/components/ui/label.tsx @@ -1,6 +1,6 @@ -import * as React from 'react'; import * as LabelPrimitive from '@radix-ui/react-label'; import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; import { cn } from '@/lib/utils'; const labelVariants = cva( @@ -9,14 +9,9 @@ const labelVariants = cva( const Label = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps + React.ComponentPropsWithoutRef & VariantProps >(({ className, ...props }, ref) => ( - + )); Label.displayName = LabelPrimitive.Root.displayName; diff --git a/src/client/components/ui/select.tsx b/src/client/components/ui/select.tsx index 7a01ecd..1e22e03 100644 --- a/src/client/components/ui/select.tsx +++ b/src/client/components/ui/select.tsx @@ -1,6 +1,6 @@ -import * as React from 'react'; import * as SelectPrimitive from '@radix-ui/react-select'; import { Check, ChevronDown, ChevronUp } from 'lucide-react'; +import * as React from 'react'; import { cn } from '@/lib/utils'; const Select = SelectPrimitive.Root; @@ -33,10 +33,7 @@ const SelectScrollUpButton = React.forwardRef< >(({ className, ...props }, ref) => ( @@ -50,17 +47,13 @@ const SelectScrollDownButton = React.forwardRef< >(({ className, ...props }, ref) => ( )); -SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName; +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; const SelectContent = React.forwardRef< React.ElementRef, diff --git a/src/client/components/ui/skeleton.tsx b/src/client/components/ui/skeleton.tsx index a626d9b..f0a2781 100644 --- a/src/client/components/ui/skeleton.tsx +++ b/src/client/components/ui/skeleton.tsx @@ -1,15 +1,7 @@ import { cn } from '@/lib/utils'; -function Skeleton({ - className, - ...props -}: React.HTMLAttributes) { - return ( -
- ); +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
; } export { Skeleton }; diff --git a/src/client/components/ui/tabs.tsx b/src/client/components/ui/tabs.tsx index a312d0e..ac875c9 100644 --- a/src/client/components/ui/tabs.tsx +++ b/src/client/components/ui/tabs.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; import * as TabsPrimitive from '@radix-ui/react-tabs'; +import * as React from 'react'; import { cn } from '@/lib/utils'; const Tabs = TabsPrimitive.Root; diff --git a/src/client/components/ui/tooltip.tsx b/src/client/components/ui/tooltip.tsx index 9843924..7731e6f 100644 --- a/src/client/components/ui/tooltip.tsx +++ b/src/client/components/ui/tooltip.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import * as React from 'react'; import { cn } from '@/lib/utils'; const TooltipProvider = TooltipPrimitive.Provider; diff --git a/src/client/contexts/i18n.tsx b/src/client/contexts/i18n.tsx index fd4d3c0..3a4dd1e 100644 --- a/src/client/contexts/i18n.tsx +++ b/src/client/contexts/i18n.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, type ReactNode, useContext, useState } from 'react'; export type Locale = 'en' | 'ja'; @@ -11,7 +11,8 @@ const translations = { // Home 'home.title': 'Beautiful GitHub Stats for Your README', - 'home.subtitle': 'Minimal, modern, and customizable GitHub statistics cards. Showcase your contributions with style.', + 'home.subtitle': + 'Minimal, modern, and customizable GitHub statistics cards. Showcase your contributions with style.', 'home.placeholder': 'GitHub username', 'home.generate': 'Generate', 'home.preview.empty': 'Enter a GitHub username to preview', @@ -25,13 +26,16 @@ const translations = { 'features.title': 'Features', 'features.subtitle': 'Everything you need to showcase your GitHub profile', 'features.themes.title': 'Beautiful Themes', - 'features.themes.desc': 'Choose from multiple themes or customize colors to match your README aesthetic.', + 'features.themes.desc': + 'Choose from multiple themes or customize colors to match your README aesthetic.', 'features.i18n.title': 'i18n Support', 'features.i18n.desc': 'Display your stats in English or Japanese. More languages coming soon.', 'features.fast.title': 'Lightning Fast', - 'features.fast.desc': 'Powered by Cloudflare Workers with intelligent caching for instant loading.', + 'features.fast.desc': + 'Powered by Cloudflare Workers with intelligent caching for instant loading.', 'features.cards.title': 'Multiple Card Types', - 'features.cards.desc': 'Stats card, language breakdown, and repository pins - all in one service.', + 'features.cards.desc': + 'Stats card, language breakdown, and repository pins - all in one service.', 'features.design.title': 'Minimal Design', 'features.design.desc': 'Clean, modern SVG cards that look great in any README.', 'features.privacy.title': 'Privacy First', @@ -69,7 +73,8 @@ const translations = { // Languages page 'languages.title': 'Top Languages Generator', - 'languages.subtitle': 'Display the programming languages you use most across your repositories.', + 'languages.subtitle': + 'Display the programming languages you use most across your repositories.', 'languages.layout': 'Layout', 'languages.langsCount': 'Languages Count', 'languages.hideBorder': 'Hide Border', @@ -110,7 +115,8 @@ const translations = { // Home 'home.title': 'README用の美しいGitHub統計', - 'home.subtitle': 'ミニマル、モダン、カスタマイズ可能なGitHub統計カード。あなたの貢献をスタイリッシュに紹介。', + 'home.subtitle': + 'ミニマル、モダン、カスタマイズ可能なGitHub統計カード。あなたの貢献をスタイリッシュに紹介。', 'home.placeholder': 'GitHubユーザー名', 'home.generate': '生成', 'home.preview.empty': 'プレビューするにはGitHubユーザー名を入力', @@ -234,11 +240,7 @@ export function I18nProvider({ children }: { children: ReactNode }) { return translations[locale][key] || key; }; - return ( - - {children} - - ); + return {children}; } export function useI18n() { diff --git a/src/client/contexts/username.tsx b/src/client/contexts/username.tsx index af430c6..9d09f8c 100644 --- a/src/client/contexts/username.tsx +++ b/src/client/contexts/username.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, type ReactNode, useContext, useState } from 'react'; interface UsernameContextType { username: string; diff --git a/src/client/i18n/index.ts b/src/client/i18n/index.ts index 551fd03..56b2562 100644 --- a/src/client/i18n/index.ts +++ b/src/client/i18n/index.ts @@ -3,27 +3,21 @@ import { initReactI18next } from 'react-i18next'; import en from './locales/en.json'; import ja from './locales/ja.json'; -const savedLocale = typeof window !== 'undefined' - ? localStorage.getItem('devcard-locale') - : null; +const savedLocale = typeof window !== 'undefined' ? localStorage.getItem('devcard-locale') : null; -const browserLocale = typeof navigator !== 'undefined' - ? navigator.language.split('-')[0] - : 'en'; +const browserLocale = typeof navigator !== 'undefined' ? navigator.language.split('-')[0] : 'en'; -i18n - .use(initReactI18next) - .init({ - resources: { - en: { translation: en }, - ja: { translation: ja }, - }, - lng: savedLocale || (browserLocale === 'ja' ? 'ja' : 'en'), - fallbackLng: 'en', - interpolation: { - escapeValue: false, - }, - }); +i18n.use(initReactI18next).init({ + resources: { + en: { translation: en }, + ja: { translation: ja }, + }, + lng: savedLocale || (browserLocale === 'ja' ? 'ja' : 'en'), + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, +}); i18n.on('languageChanged', (lng) => { localStorage.setItem('devcard-locale', lng); diff --git a/src/client/pages/Home.tsx b/src/client/pages/Home.tsx index a6d8f74..d9a3be9 100644 --- a/src/client/pages/Home.tsx +++ b/src/client/pages/Home.tsx @@ -1,19 +1,19 @@ -import { useState, memo } from 'react'; -import { Link } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; import { - Palette, - Globe, - Zap, - Code2, - Sparkles, - ShieldCheck, ArrowRight, + Code2, + Globe, type LucideIcon, + Palette, + ShieldCheck, + Sparkles, + Zap, } from 'lucide-react'; +import { memo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Card, CardContent } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { useUsername } from '@/contexts/username'; @@ -35,9 +35,7 @@ const FeatureCard = memo(function FeatureCard({

{title}

-

- {description} -

+

{description}

{/* Subtle gradient overlay on hover */}
@@ -151,9 +149,7 @@ export function Home() {
)} {previewUrl && error && ( -

- {t('home.preview.error')} -

+

{t('home.preview.error')}

)} {previewUrl && ( (null); const [advancedOpen, setAdvancedOpen] = useState(false); - const updateForm = useCallback( - (key: K, value: FormState[K]) => { - setForm((prev) => ({ ...prev, [key]: value })); - }, - [] - ); + const updateForm = useCallback((key: K, value: FormState[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + }, []); const buildUrl = useCallback(() => { if (!username.trim()) return null; @@ -104,13 +97,9 @@ export function LanguagesGenerator() {
-

- {t('languages.title')} -

+

{t('languages.title')}

-

- {t('languages.subtitle')} -

+

{t('languages.subtitle')}

@@ -131,26 +120,17 @@ export function LanguagesGenerator() { />
- updateForm('theme', v)} - /> + updateForm('theme', v)} />
- updateForm('layout', v)}> {layouts.map((layout) => ( - + {layout.label} ))} @@ -170,10 +150,7 @@ export function LanguagesGenerator() { />
- updateForm('locale', v)} - /> + updateForm('locale', v)} />
@@ -188,9 +165,7 @@ export function LanguagesGenerator() { - updateForm(key, checked === true) - } + onCheckedChange={(checked) => updateForm(key, checked === true)} />
@@ -261,10 +232,7 @@ export function LanguagesGenerator() { - +
); diff --git a/src/client/pages/PinGenerator.tsx b/src/client/pages/PinGenerator.tsx index 88044ba..f67b236 100644 --- a/src/client/pages/PinGenerator.tsx +++ b/src/client/pages/PinGenerator.tsx @@ -1,19 +1,15 @@ -import { useState, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; import { ChevronDown, Pin } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LocaleSelect } from '@/components/generators/LocaleSelect'; +import { PreviewPanel } from '@/components/generators/PreviewPanel'; +import { ThemeSelect } from '@/components/generators/ThemeSelect'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; -import { ThemeSelect } from '@/components/generators/ThemeSelect'; -import { LocaleSelect } from '@/components/generators/LocaleSelect'; -import { PreviewPanel } from '@/components/generators/PreviewPanel'; import { useUsername } from '@/contexts/username'; interface FormState { @@ -41,12 +37,9 @@ export function PinGenerator() { const [previewUrl, setPreviewUrl] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); - const updateForm = useCallback( - (key: K, value: FormState[K]) => { - setForm((prev) => ({ ...prev, [key]: value })); - }, - [] - ); + const updateForm = useCallback((key: K, value: FormState[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + }, []); const buildUrl = useCallback(() => { if (!username.trim() || !form.repo.trim()) return null; @@ -78,13 +71,9 @@ export function PinGenerator() {
-

- {t('pin.title')} -

+

{t('pin.title')}

-

- {t('pin.subtitle')} -

+

{t('pin.subtitle')}

@@ -116,15 +105,9 @@ export function PinGenerator() { />
- updateForm('theme', v)} - /> + updateForm('theme', v)} /> - updateForm('locale', v)} - /> + updateForm('locale', v)} />
@@ -138,9 +121,7 @@ export function PinGenerator() { - updateForm(key, checked === true) - } + onCheckedChange={(checked) => updateForm(key, checked === true)} />
@@ -193,10 +168,7 @@ export function PinGenerator() { - +
); diff --git a/src/client/pages/StatsGenerator.tsx b/src/client/pages/StatsGenerator.tsx index 2ddb698..2ce19d4 100644 --- a/src/client/pages/StatsGenerator.tsx +++ b/src/client/pages/StatsGenerator.tsx @@ -1,19 +1,15 @@ -import { useState, useCallback } from 'react'; +import { BarChart2, ChevronDown } from 'lucide-react'; +import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ChevronDown, BarChart2 } from 'lucide-react'; +import { LocaleSelect } from '@/components/generators/LocaleSelect'; +import { PreviewPanel } from '@/components/generators/PreviewPanel'; +import { ThemeSelect } from '@/components/generators/ThemeSelect'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; -import { ThemeSelect } from '@/components/generators/ThemeSelect'; -import { LocaleSelect } from '@/components/generators/LocaleSelect'; -import { PreviewPanel } from '@/components/generators/PreviewPanel'; import { useUsername } from '@/contexts/username'; interface FormState { @@ -51,12 +47,9 @@ export function StatsGenerator() { const [previewUrl, setPreviewUrl] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); - const updateForm = useCallback( - (key: K, value: FormState[K]) => { - setForm((prev) => ({ ...prev, [key]: value })); - }, - [] - ); + const updateForm = useCallback((key: K, value: FormState[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + }, []); const buildUrl = useCallback(() => { if (!username.trim()) return null; @@ -92,13 +85,9 @@ export function StatsGenerator() {
-

- {t('stats.title')} -

+

{t('stats.title')}

-

- {t('stats.subtitle')} -

+

{t('stats.subtitle')}

@@ -119,15 +108,9 @@ export function StatsGenerator() { />
- updateForm('theme', v)} - /> + updateForm('theme', v)} /> - updateForm('locale', v)} - /> + updateForm('locale', v)} />
@@ -144,9 +127,7 @@ export function StatsGenerator() { - updateForm(key, checked === true) - } + onCheckedChange={(checked) => updateForm(key, checked === true)} />
@@ -203,9 +180,7 @@ export function StatsGenerator() { value={form.show} onChange={(e) => updateForm('show', e.target.value)} /> -

- {t('stats.showHint')} -

+

{t('stats.showHint')}

@@ -217,10 +192,7 @@ export function StatsGenerator() { - +
); diff --git a/src/fetchers/github.ts b/src/fetchers/github.ts index c0d39ea..6970856 100644 --- a/src/fetchers/github.ts +++ b/src/fetchers/github.ts @@ -1,4 +1,4 @@ -import type { GitHubUser, GitHubRepo, LanguageEdge, UserStats, LanguageStats, Env } from '../types'; +import type { Env, GitHubRepo, GitHubUser, LanguageEdge, LanguageStats, UserStats } from '../types'; import { calculateRank } from '../utils'; const GITHUB_API = 'https://api.github.com/graphql'; @@ -101,8 +101,8 @@ const fetchGitHub = async ( throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } - const data = await response.json() as { data: T; errors?: Array<{ message: string }> }; - + const data = (await response.json()) as { data: T; errors?: Array<{ message: string }> }; + if (data.errors) { throw new Error(`GraphQL error: ${data.errors[0].message}`); } @@ -128,9 +128,10 @@ export const fetchUserStats = async ( const totalStars = user.repositories.nodes.reduce((acc, repo) => acc + repo.stargazerCount, 0); const totalForks = user.repositories.nodes.reduce((acc, repo) => acc + repo.forkCount, 0); - + const totalCommits = includeAllCommits - ? user.contributionsCollection.totalCommitContributions + user.contributionsCollection.restrictedContributionsCount + ? user.contributionsCollection.totalCommitContributions + + user.contributionsCollection.restrictedContributionsCount : user.contributionsCollection.totalCommitContributions; const stats = { @@ -155,7 +156,7 @@ export const fetchUserStats = async ( export const fetchTopLanguages = async ( username: string, env: Env, - excludeRepos: string[] = [], + _excludeRepos: string[] = [], langsCount: number = 5 ): Promise => { const data = await fetchGitHub<{ @@ -168,11 +169,7 @@ export const fetchTopLanguages = async ( }>; }; }; - }>( - TOP_LANGUAGES_QUERY, - { login: username, first: 100 }, - env.GITHUB_TOKEN - ); + }>(TOP_LANGUAGES_QUERY, { login: username, first: 100 }, env.GITHUB_TOKEN); if (!data.user) { throw new Error(`User "${username}" not found`); @@ -199,7 +196,7 @@ export const fetchTopLanguages = async ( .slice(0, langsCount); const totalSize = sorted.reduce((acc, lang) => acc + lang.size, 0); - + const result: LanguageStats = {}; for (const lang of sorted) { result[lang.name] = { @@ -211,11 +208,7 @@ export const fetchTopLanguages = async ( return result; }; -export const fetchRepo = async ( - owner: string, - name: string, - env: Env -): Promise => { +export const fetchRepo = async (owner: string, name: string, env: Env): Promise => { const data = await fetchGitHub<{ repository: GitHubRepo }>( REPO_QUERY, { owner, name }, @@ -227,4 +220,4 @@ export const fetchRepo = async ( } return data.repository; -}; \ No newline at end of file +}; diff --git a/src/index.ts b/src/index.ts index 4d77c20..998769a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ import { Hono } from 'hono'; import { api } from './api'; -import type { Env } from './types'; -import { rateLimitMiddleware } from './middleware/rate-limit'; import { compressionMiddleware } from './middleware/compression'; import { loggerMiddleware } from './middleware/logger'; +import { rateLimitMiddleware } from './middleware/rate-limit'; +import type { Env } from './types'; const app = new Hono<{ Bindings: Env }>(); @@ -63,4 +63,4 @@ app.get('/*', async (c) => { } }); -export default app; \ No newline at end of file +export default app; diff --git a/src/middleware/compression.ts b/src/middleware/compression.ts index 73ea40d..2535b0a 100644 --- a/src/middleware/compression.ts +++ b/src/middleware/compression.ts @@ -3,21 +3,21 @@ import type { Context, Next } from 'hono'; // SVGs compress very well with gzip export async function compressionMiddleware(c: Context, next: Next) { await next(); - + // Check if response is SVG const contentType = c.res.headers.get('Content-Type'); if (!contentType || !contentType.includes('image/svg+xml')) { return; } - + // Check if client accepts gzip const acceptEncoding = c.req.header('Accept-Encoding') || ''; if (!acceptEncoding.includes('gzip')) { return; } - + // For Cloudflare Workers, compression is automatically handled // But we can add hints for better caching c.header('Vary', 'Accept-Encoding'); c.header('Content-Encoding', 'gzip'); -} \ No newline at end of file +} diff --git a/src/middleware/rate-limit.ts b/src/middleware/rate-limit.ts index 9c47fb9..11ab9c7 100644 --- a/src/middleware/rate-limit.ts +++ b/src/middleware/rate-limit.ts @@ -10,7 +10,7 @@ const RATE_LIMITS = { // Per IP limits IP_PER_MINUTE: 30, IP_PER_HOUR: 100, - + // Per username limits (to prevent abuse of specific users) USER_PER_HOUR: 50, }; @@ -20,19 +20,19 @@ export async function rateLimitMiddleware(c: Context<{ Bindings: Env }>, next: N // If no KV storage, skip rate limiting return next(); } - + const ip = c.req.header('CF-Connecting-IP') || c.req.header('X-Forwarded-For') || 'unknown'; const username = c.req.query('username'); - + // Check IP rate limit const ipKey = `ratelimit:ip:${ip}:${Math.floor(Date.now() / 60000)}`; // Per minute const ipInfo = await c.env.CACHE.get(ipKey, { type: 'json' }); - + if (ipInfo && ipInfo.count >= RATE_LIMITS.IP_PER_MINUTE) { return c.json( - { - error: 'Rate limit exceeded', - retryAfter: Math.ceil((ipInfo.resetAt - Date.now()) / 1000) + { + error: 'Rate limit exceeded', + retryAfter: Math.ceil((ipInfo.resetAt - Date.now()) / 1000), }, 429, { @@ -43,7 +43,7 @@ export async function rateLimitMiddleware(c: Context<{ Bindings: Env }>, next: N } ); } - + // Update IP rate limit await c.env.CACHE.put( ipKey, @@ -53,18 +53,18 @@ export async function rateLimitMiddleware(c: Context<{ Bindings: Env }>, next: N }), { expirationTtl: 60 } // Expire after 1 minute ); - + // Check username rate limit if username is provided if (username) { const userKey = `ratelimit:user:${username}:${Math.floor(Date.now() / 3600000)}`; // Per hour const userInfo = await c.env.CACHE.get(userKey, { type: 'json' }); - + if (userInfo && userInfo.count >= RATE_LIMITS.USER_PER_HOUR) { return c.json( - { - error: 'Rate limit exceeded for this user', + { + error: 'Rate limit exceeded for this user', message: 'Too many requests for this GitHub user. Please try again later.', - retryAfter: Math.ceil((userInfo.resetAt - Date.now()) / 1000) + retryAfter: Math.ceil((userInfo.resetAt - Date.now()) / 1000), }, 429, { @@ -75,7 +75,7 @@ export async function rateLimitMiddleware(c: Context<{ Bindings: Env }>, next: N } ); } - + // Update username rate limit await c.env.CACHE.put( userKey, @@ -86,10 +86,13 @@ export async function rateLimitMiddleware(c: Context<{ Bindings: Env }>, next: N { expirationTtl: 3600 } // Expire after 1 hour ); } - + // Add rate limit headers to response c.header('X-RateLimit-Limit', RATE_LIMITS.IP_PER_MINUTE.toString()); - c.header('X-RateLimit-Remaining', (RATE_LIMITS.IP_PER_MINUTE - ((ipInfo?.count || 0) + 1)).toString()); - + c.header( + 'X-RateLimit-Remaining', + (RATE_LIMITS.IP_PER_MINUTE - ((ipInfo?.count || 0) + 1)).toString() + ); + return next(); -} \ No newline at end of file +} diff --git a/src/themes/index.ts b/src/themes/index.ts index 6964ef8..ab34e62 100644 --- a/src/themes/index.ts +++ b/src/themes/index.ts @@ -139,4 +139,4 @@ export const themes: Record = { export const getTheme = (themeName?: string): Theme => { if (!themeName) return themes.default; return themes[themeName] ?? themes.default; -}; \ No newline at end of file +}; diff --git a/src/types.ts b/src/types.ts index f35a8bc..a66f6cc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -148,4 +148,4 @@ export interface Env { GITHUB_TOKEN?: string; CACHE?: KVNamespace; ASSETS: Fetcher; -} \ No newline at end of file +} diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 2c17231..a9a5919 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -15,47 +15,47 @@ export interface CacheOptions { export class CacheManager { private kv: KVNamespace | undefined; - + constructor(env: Env) { this.kv = env.CACHE; } - + async get(key: string): Promise { if (!this.kv) return null; - + try { const cached = await this.kv.get(key, { type: 'json' }); if (!cached) return null; - + const data = cached as { value: T; timestamp: number; ttl: number }; const now = Date.now(); - + // Check if cache is expired if (now - data.timestamp > data.ttl * 1000) { // Optionally delete expired cache await this.kv.delete(key); return null; } - + return data.value; } catch (error) { console.error('Cache get error:', error); return null; } } - + async set(key: string, value: T, options: CacheOptions = {}): Promise { if (!this.kv) return; - + const ttl = options.ttl || CACHE_TTL.STATS; - + try { const data = { value, timestamp: Date.now(), ttl, }; - + // KV stores data with expiration await this.kv.put(key, JSON.stringify(data), { expirationTtl: ttl, @@ -64,15 +64,15 @@ export class CacheManager { console.error('Cache set error:', error); } } - + // Generate cache key based on request parameters static generateKey(type: string, params: Record): string { const sortedParams = Object.keys(params) .sort() - .filter(key => params[key] !== undefined) - .map(key => `${key}:${params[key]}`) + .filter((key) => params[key] !== undefined) + .map((key) => `${key}:${params[key]}`) .join(','); - + return `devcard:${type}:${sortedParams}`; } } @@ -80,17 +80,20 @@ export class CacheManager { // HTTP Cache headers export const getCacheHeaders = (maxAge: number = 3600): Headers => { const headers = new Headers(); - + // Browser cache - headers.set('Cache-Control', `public, max-age=${maxAge}, s-maxage=${maxAge}, stale-while-revalidate=86400`); - + headers.set( + 'Cache-Control', + `public, max-age=${maxAge}, s-maxage=${maxAge}, stale-while-revalidate=86400` + ); + // CDN cache hint headers.set('CDN-Cache-Control', `max-age=${maxAge}`); - + // Cloudflare specific headers.set('CF-Cache-Status', 'HIT'); - + return headers; }; -export const CACHE_TTL_EXPORT = CACHE_TTL; \ No newline at end of file +export const CACHE_TTL_EXPORT = CACHE_TTL; diff --git a/src/utils/fonts.ts b/src/utils/fonts.ts index 94d1e4a..95f8c7c 100644 --- a/src/utils/fonts.ts +++ b/src/utils/fonts.ts @@ -6,7 +6,7 @@ export const getFontFamily = (locale: Locale = 'en'): string => { // Japanese font stack similar to GitHub's return 'Hiragino Kaku Gothic Pro, Yu Gothic UI, Meiryo UI, Meiryo, Noto Sans JP, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif'; } - + // Default font stack for English and other languages return '-apple-system, BlinkMacSystemFont, Segoe UI, Noto Sans, Helvetica, Arial, sans-serif'; }; @@ -22,7 +22,7 @@ export const getFontSizes = (locale: Locale = 'en') => { normal: 13, }; } - + return { title: 18, label: 14, @@ -30,4 +30,4 @@ export const getFontSizes = (locale: Locale = 'en') => { small: 12, normal: 14, }; -}; \ No newline at end of file +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 24696b2..9abd506 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,17 +1,17 @@ -import type { RankInfo, CardOptions } from '../types'; import { getTheme } from '../themes'; +import type { CardOptions, RankInfo } from '../types'; -export * from './locale'; -export * from './fonts'; export * from './cache'; +export * from './fonts'; +export * from './locale'; // Number formatting export const formatNumber = (num: number, precision: number = 1): string => { if (num >= 1000000) { - return (num / 1000000).toFixed(precision).replace(/\.0+$/, '') + 'M'; + return `${(num / 1000000).toFixed(precision).replace(/\.0+$/, '')}M`; } if (num >= 1000) { - return (num / 1000).toFixed(precision).replace(/\.0+$/, '') + 'k'; + return `${(num / 1000).toFixed(precision).replace(/\.0+$/, '')}k`; } return num.toString(); }; @@ -77,7 +77,7 @@ export const parseColor = (color: string | undefined, defaultColor: string): str // Get colors from options with theme support export const getColors = (options: CardOptions) => { const theme = getTheme(options.theme); - + return { titleColor: parseColor(options.titleColor, theme.titleColor), textColor: parseColor(options.textColor, theme.textColor), @@ -97,21 +97,23 @@ export const parseGradient = (bgColor: string): string => { export const createGradientDef = (bgColor: string): string => { const parts = bgColor.split(','); if (parts.length < 3) return ''; - - const angle = parseInt(parts[0]) || 0; + + const angle = parseInt(parts[0], 10) || 0; const colors = parts.slice(1); - + const rad = (angle * Math.PI) / 180; const x1 = 50 - Math.cos(rad) * 50; const y1 = 50 + Math.sin(rad) * 50; const x2 = 50 + Math.cos(rad) * 50; const y2 = 50 - Math.sin(rad) * 50; - - const stops = colors.map((color, i) => { - const percent = (i / (colors.length - 1)) * 100; - return ``; - }).join('\n '); - + + const stops = colors + .map((color, i) => { + const percent = (i / (colors.length - 1)) * 100; + return ``; + }) + .join('\n '); + return ` ${stops} @@ -129,7 +131,7 @@ export const wrapText = (text: string, maxWidth: number, fontSize: number = 12): const words = text.split(' '); const lines: string[] = []; let currentLine = ''; - + for (const word of words) { const testLine = currentLine ? `${currentLine} ${word}` : word; if (measureText(testLine, fontSize) > maxWidth) { @@ -137,18 +139,18 @@ export const wrapText = (text: string, maxWidth: number, fontSize: number = 12): lines.push(currentLine); currentLine = word; } else { - lines.push(word.slice(0, Math.floor(maxWidth / (fontSize * 0.6)) - 3) + '...'); + lines.push(`${word.slice(0, Math.floor(maxWidth / (fontSize * 0.6)) - 3)}...`); currentLine = ''; } } else { currentLine = testLine; } } - + if (currentLine) { lines.push(currentLine); } - + return lines; }; diff --git a/src/utils/locale.ts b/src/utils/locale.ts index 8cddd56..18269ce 100644 --- a/src/utils/locale.ts +++ b/src/utils/locale.ts @@ -67,4 +67,4 @@ export const formatNumberLocale = (num: number, locale: Locale = 'en'): string = return locale === 'ja' ? `${formatted}千` : `${formatted}k`; } return num.toLocaleString(locale === 'ja' ? 'ja-JP' : 'en-US'); -}; \ No newline at end of file +}; diff --git a/src/utils/monitoring.ts b/src/utils/monitoring.ts index ef9adf3..a1fa5cd 100644 --- a/src/utils/monitoring.ts +++ b/src/utils/monitoring.ts @@ -25,12 +25,14 @@ export class Monitor { country: c.req.header('CF-IPCountry') || 'unknown', }); } - + // Optionally store aggregated metrics in KV for analysis if (c.env.CACHE) { const key = `metrics:${Math.floor(Date.now() / 3600000)}`; // Hourly aggregation try { - const existing = await c.env.CACHE.get<{ count: number; errors: number }>(key, { type: 'json' }) || { count: 0, errors: 0 }; + const existing = (await c.env.CACHE.get<{ count: number; errors: number }>(key, { + type: 'json', + })) || { count: 0, errors: 0 }; await c.env.CACHE.put( key, JSON.stringify({ @@ -44,11 +46,11 @@ export class Monitor { } } } - - static trackTiming(name: string): { end: () => number } { + + static trackTiming(_name: string): { end: () => number } { const start = Date.now(); return { end: () => Date.now() - start, }; } -} \ No newline at end of file +} diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index ab310e7..2bf1cd9 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test'; +import { expect, test } from '@playwright/test'; test('DevCard Landing Page', async ({ page }) => { await page.goto('/'); From 80fba914699b67a7cec8ee41ca9e311e06210e43 Mon Sep 17 00:00:00 2001 From: Ryota Ikezawa Date: Fri, 2 Jan 2026 15:27:04 +0900 Subject: [PATCH 2/3] ci: Add GitHub Actions workflow for lint, typecheck, and build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8ac6cc8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Type check + run: pnpm typecheck + + - name: Build + run: pnpm build From ea074ecbc4000aef532c9bf4c87c41a0c9b3019b Mon Sep 17 00:00:00 2001 From: Ryota Ikezawa Date: Fri, 2 Jan 2026 15:27:36 +0900 Subject: [PATCH 3/3] fix: Unify SVG card spacing for consistent top/bottom padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardize vertical spacing across all card types: - Top padding: 20px - Title baseline: y=35 - Content start: y=55 - Bottom padding: 20px This ensures cards align properly when displayed side by side in READMEs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/cards/languages.ts | 5 +++-- src/cards/repo.ts | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/cards/languages.ts b/src/cards/languages.ts index aa97181..42e1f2f 100644 --- a/src/cards/languages.ts +++ b/src/cards/languages.ts @@ -194,13 +194,14 @@ const createEmptyCard = ( fontSize: any ): string => { const height = CARD_HEIGHT; - const padding = 15; + const padding = 20; + const titleY = 35; return ` - ${title} + ${title} ${trans.languages.noData} `; }; diff --git a/src/cards/repo.ts b/src/cards/repo.ts index 1c3d1a8..bcad8d8 100644 --- a/src/cards/repo.ts +++ b/src/cards/repo.ts @@ -32,21 +32,25 @@ export const createRepoCard = (repo: GitHubRepo, options: RepoCardOptions = {}): const width = options.cardWidth ?? 400; const height = CARD_HEIGHT; - const padding = 15; + const padding = 20; + + // Vertical layout: top 20px, title, 15px gap, content, bottom 20px + const titleY = 35; + const contentStart = 55; + const statsY = height - 20; const titleText = showOwner ? repo.nameWithOwner : repo.name; const description = repo.description || trans.repo.noDescription; // Fixed max lines to fit in 195px height - const descLines = wrapText(description, width - padding * 2 - 20, 12).slice(0, 4); + const descLines = wrapText(description, width - padding * 2, 12).slice(0, 4); // Description section let descContent = ''; descLines.forEach((line, i) => { - descContent += `${escapeXml(line)}\n`; + descContent += `${escapeXml(line)}\n`; }); - // Stats section at fixed position - const statsY = height - 25; + // Stats section at fixed position (bottom - 20px) let statsX = padding; // Language dot @@ -80,8 +84,8 @@ export const createRepoCard = (repo: GitHubRepo, options: RepoCardOptions = {}): let archivedBadge = ''; if (repo.isArchived) { archivedBadge = ` - - ${trans.repo.archived} + + ${trans.repo.archived} `; } @@ -89,7 +93,7 @@ export const createRepoCard = (repo: GitHubRepo, options: RepoCardOptions = {}): - + ${escapeXml(titleText)}