From 1549a6347ae7645caff5e6da7682b31f53eacb86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:01:39 +0000 Subject: [PATCH 1/6] Initial plan From b873d47a51a5bf9c800b795cf9c10a9014d27a46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:03:39 +0000 Subject: [PATCH 2/6] Add knip dependency for code analysis Co-authored-by: piotrl <290300+piotrl@users.noreply.github.com> --- package-lock.json | 512 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 9 +- 2 files changed, 501 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8d75179..dc6715f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "eslint-plugin-prettier": "5.5.1", "jest": "30.0.4", "jest-environment-jsdom": "30.0.4", + "knip": "^5.82.1", "prettier": "3.6.2", "sass": "1.25.0", "ts-jest": "29.4.0", @@ -709,21 +710,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz", - "integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.3", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", - "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, @@ -732,9 +733,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz", - "integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, "license": "MIT", "optional": true, @@ -1966,6 +1967,306 @@ "node": ">= 8" } }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.16.4.tgz", + "integrity": "sha512-6XUHilmj8D6Ggus+sTBp64x/DUQ7LgC/dvTDdUOt4iMQnDdSep6N1mnvVLIiG+qM5tRnNHravNzBJnUlYwRQoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.16.4.tgz", + "integrity": "sha512-5ODwd1F5mdkm6JIg1CNny9yxIrCzrkKpxmqas7Alw23vE0Ot8D4ykqNBW5Z/nIZkXVEo5VDmnm0sMBBIANcpeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.16.4.tgz", + "integrity": "sha512-egwvDK9DMU4Q8F4BG74/n4E22pQ0lT5ukOVB6VXkTj0iG2fnyoStHoFaBnmDseLNRA4r61Mxxz8k940CIaJMDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.16.4.tgz", + "integrity": "sha512-HMkODYrAG4HaFNCpaYzSQFkxeiz2wzl+smXwxeORIQVEo1WAgUrWbvYT/0RNJg/A8z2aGMGK5KWTUr2nX5GiMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.16.4.tgz", + "integrity": "sha512-mkcKhIdSlUqnndD928WAVVFMEr1D5EwHOBGHadypW0PkM0h4pn89ZacQvU7Qs/Z2qquzvbyw8m4Mq3jOYI+4Dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.16.4.tgz", + "integrity": "sha512-ZJvzbmXI/cILQVcJL9S2Fp7GLAIY4Yr6mpGb+k6LKLUSEq85yhG+rJ9eWCqgULVIf2BFps/NlmPTa7B7oj8jhQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.16.4.tgz", + "integrity": "sha512-iZUB0W52uB10gBUDAi79eTnzqp1ralikCAjfq7CdokItwZUVJXclNYANnzXmtc0Xr0ox+YsDsG2jGcj875SatA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.16.4.tgz", + "integrity": "sha512-qNQk0H6q1CnwS9cnvyjk9a+JN8BTbxK7K15Bb5hYfJcKTG1hfloQf6egndKauYOO0wu9ldCMPBrEP1FNIQEhaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.16.4.tgz", + "integrity": "sha512-wEXSaEaYxGGoVSbw0i2etjDDWcqErKr8xSkTdwATP798efsZmodUAcLYJhN0Nd4W35Oq6qAvFGHpKwFrrhpTrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.16.4.tgz", + "integrity": "sha512-CUFOlpb07DVOFLoYiaTfbSBRPIhNgwc/MtlYeg3p6GJJw+kEm/vzc9lohPSjzF2MLPB5hzsJdk+L/GjrTT3UPw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.16.4.tgz", + "integrity": "sha512-d8It4AH8cN9ReK1hW6ZO4x3rMT0hB2LYH0RNidGogV9xtnjLRU+Y3MrCeClLyOSGCibmweJJAjnwB7AQ31GEhg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.16.4.tgz", + "integrity": "sha512-d09dOww9iKyEHSxuOQ/Iu2aYswl0j7ExBcyy14D6lJ5ijQSP9FXcJYJsJ3yvzboO/PDEFjvRuF41f8O1skiPVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.16.4.tgz", + "integrity": "sha512-lhjyGmUzTWHduZF3MkdUSEPMRIdExnhsqv8u1upX3A15epVn6YVwv4msFQPJl1x1wszkACPeDHGOtzHsITXGdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.16.4.tgz", + "integrity": "sha512-ZtqqiI5rzlrYBm/IMMDIg3zvvVj4WO/90Dg/zX+iA8lWaLN7K5nroXb17MQ4WhI5RqlEAgrnYDXW+hok1D9Kaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.16.4.tgz", + "integrity": "sha512-LM424h7aaKcMlqHnQWgTzO+GRNLyjcNnMpqm8SygEtFRVW693XS+XGXYvjORlmJtsyjo84ej1FMb3U2HE5eyjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.16.4.tgz", + "integrity": "sha512-8w8U6A5DDWTBv3OUxSD9fNk37liZuEC5jnAc9wQRv9DeYKAXvuUtBfT09aIZ58swaci0q1WS48/CoMVEO6jdCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.16.4.tgz", + "integrity": "sha512-hnjb0mDVQOon6NdfNJ1EmNquonJUjoYkp7UyasjxVa4iiMcApziHP4czzzme6WZbp+vzakhVv2Yi5ACTon3Zlw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.16.4.tgz", + "integrity": "sha512-+i0XtNfSP7cfnh1T8FMrMm4HxTeh0jxKP/VQCLWbjdUxaAQ4damho4gN9lF5dl0tZahtdszXLUboBFNloSJNOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.16.4.tgz", + "integrity": "sha512-ePW1islJrv3lPnef/iWwrjrSpRH8kLlftdKf2auQNWvYLx6F0xvcnv9d+r/upnVuttoQY9amLnWJf+JnCRksTw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.16.4.tgz", + "integrity": "sha512-qnjQhjHI4TDL3hkidZyEmQRK43w2NHl6TP5Rnt/0XxYuLdEgx/1yzShhYidyqWzdnhGhSPTM/WVP2mK66XLegA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -2364,9 +2665,9 @@ "peer": true }, "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, @@ -4440,6 +4741,16 @@ "bser": "2.1.1" } }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4554,6 +4865,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5759,6 +6086,16 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5767,9 +6104,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -5883,6 +6220,74 @@ "json-buffer": "3.0.1" } }, + "node_modules/knip": { + "version": "5.82.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.82.1.tgz", + "integrity": "sha512-1nQk+5AcnkqL40kGQXfouzAEXkTR+eSrgo/8m1d0BMei4eAzFwghoXC4gOKbACgBiCof7hE8wkBVDsEvznf85w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "@nodelib/fs.walk": "^1.2.3", + "fast-glob": "^3.3.3", + "formatly": "^0.3.0", + "jiti": "^2.6.0", + "js-yaml": "^4.1.1", + "minimist": "^1.2.8", + "oxc-resolver": "^11.15.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.1", + "smol-toml": "^1.5.2", + "strip-json-comments": "5.0.3", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": ">=18.18.0" + }, + "peerDependencies": { + "@types/node": ">=18", + "typescript": ">=5.0.4 <7" + } + }, + "node_modules/knip/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -6054,6 +6459,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -6190,6 +6605,38 @@ "node": ">= 0.8.0" } }, + "node_modules/oxc-resolver": { + "version": "11.16.4", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.16.4.tgz", + "integrity": "sha512-nvJr3orFz1wNaBA4neRw7CAn0SsjgVaEw1UHpgO/lzVW12w+nsFnvU/S6vVX3kYyFaZdxZheTExi/fa8R8PrZA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.16.4", + "@oxc-resolver/binding-android-arm64": "11.16.4", + "@oxc-resolver/binding-darwin-arm64": "11.16.4", + "@oxc-resolver/binding-darwin-x64": "11.16.4", + "@oxc-resolver/binding-freebsd-x64": "11.16.4", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.16.4", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.16.4", + "@oxc-resolver/binding-linux-arm64-gnu": "11.16.4", + "@oxc-resolver/binding-linux-arm64-musl": "11.16.4", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.16.4", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.16.4", + "@oxc-resolver/binding-linux-riscv64-musl": "11.16.4", + "@oxc-resolver/binding-linux-s390x-gnu": "11.16.4", + "@oxc-resolver/binding-linux-x64-gnu": "11.16.4", + "@oxc-resolver/binding-linux-x64-musl": "11.16.4", + "@oxc-resolver/binding-openharmony-arm64": "11.16.4", + "@oxc-resolver/binding-wasm32-wasi": "11.16.4", + "@oxc-resolver/binding-win32-arm64-msvc": "11.16.4", + "@oxc-resolver/binding-win32-ia32-msvc": "11.16.4", + "@oxc-resolver/binding-win32-x64-msvc": "11.16.4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7218,6 +7665,19 @@ "node": ">=8" } }, + "node_modules/smol-toml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7976,6 +8436,16 @@ "node": ">=18" } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -8329,6 +8799,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index a40aee6..f6b3043 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,20 @@ "devDependencies": { "@eslint/js": "9.31.0", "@jest/globals": "30.0.4", + "esbuild": "0.25.1", + "esbuild-sass-plugin": "3.3.1", "eslint": "9.31.0", - "eslint-plugin-jest": "28.8.3", "eslint-config-prettier": "10.1.5", + "eslint-plugin-jest": "28.8.3", "eslint-plugin-prettier": "5.5.1", "jest": "30.0.4", "jest-environment-jsdom": "30.0.4", + "knip": "^5.82.1", "prettier": "3.6.2", "sass": "1.25.0", "ts-jest": "29.4.0", "typescript": "5.8.3", - "typescript-eslint": "8.36.0", - "esbuild": "0.25.1", - "esbuild-sass-plugin": "3.3.1" + "typescript-eslint": "8.36.0" }, "scripts": { "build": "node build.js", From 118f51bb3219381fd95814fb5c5f6b15da0bb581 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:05:47 +0000 Subject: [PATCH 3/6] Add knip configuration and remove unused exports Co-authored-by: piotrl <290300+piotrl@users.noreply.github.com> --- dist/gh-profile-card.min.js | 369 +++++++++++++++++++++----------- knip.json | 7 + package.json | 1 + src/testing/cache-mock.ts | 29 --- src/testing/fetch-mock.ts | 9 - src/testing/mock-github-data.ts | 24 --- src/testing/test-utils.ts | 27 --- 7 files changed, 256 insertions(+), 210 deletions(-) create mode 100644 knip.json diff --git a/dist/gh-profile-card.min.js b/dist/gh-profile-card.min.js index 755b314..5aa812f 100644 --- a/dist/gh-profile-card.min.js +++ b/dist/gh-profile-card.min.js @@ -1,6 +1,6 @@ /** * github-profile-card - 3.2.0 | MIT -* (c) 2014 - 2025 Piotr Lewandowski | https://piotrl.github.io/github-profile-card/ +* (c) 2014 - 2026 Piotr Lewandowski | https://piotrl.github.io/github-profile-card/ */ (() => { @@ -9,111 +9,181 @@ constructor(storage) { this.storage = storage; this.cacheName = "github-request-cache"; - this.requestCache = this.getCache() || {}; + this.requestCache = this.initializeCache(); } get(key) { return this.requestCache[key]; } add(url, entry) { this.requestCache[url] = entry; - this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); + this.saveCache(); } - getCache() { - return JSON.parse(this.storage.getItem(this.cacheName)); + initializeCache() { + try { + const cacheData = this.storage.getItem(this.cacheName); + if (!cacheData) { + return {}; + } + const cache = JSON.parse(cacheData); + return cache && typeof cache === "object" ? cache : {}; + } catch (error) { + console.error("Failed to parse cache:", error); + try { + this.storage.removeItem(this.cacheName); + } catch (cleanupError) { + console.error("Failed to clear corrupted cache:", cleanupError); + } + return {}; + } + } + saveCache() { + try { + this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); + } catch (error) { + console.error("Failed to save cache:", error); + this.clearExpiredEntries(/* @__PURE__ */ new Date()); + try { + this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); + } catch (retryError) { + console.error("Failed to save cache after cleanup:", retryError); + } + } + } + clearExpiredEntries(currentDate) { + let hasChanges = false; + for (const [url, entry] of Object.entries(this.requestCache)) { + if (entry.lastModified) { + const entryDate = new Date(entry.lastModified); + if (isNaN(entryDate.getTime()) || entryDate < currentDate) { + delete this.requestCache[url]; + hasChanges = true; + } + } + } + if (hasChanges) { + this.saveCache(); + } } }; // src/gh-data-loader.ts + var API_HOST = "https://api.github.com"; var GitHubApiLoader = class { constructor() { - this.apiBase = "https://api.github.com"; this.cache = new CacheStorage(window.localStorage); } - loadUserData(username, callback) { - const request = this.apiGet(`${this.apiBase}/users/${username}`); - request.success((profile) => { - this.apiGet(profile.repos_url).success((repositories) => { - callback({ profile, repositories }, null); - }); - }); - request.error((result, request2) => { - const error = this.identifyError(result, request2); - callback(null, error); - }); + async loadUserData(username) { + if (typeof username !== "string") { + throw new Error("Invalid username provided"); + } + const sanitizedUsername = username.trim(); + if (!sanitizedUsername) { + throw new Error("Username cannot be empty"); + } + const profile = await this.fetch( + `${API_HOST}/users/${sanitizedUsername}` + ); + const repositories = await this.fetch(profile.repos_url); + return { profile, repositories }; } loadRepositoriesLanguages(repositories, callback) { + if (!repositories || repositories.length === 0) { + callback([]); + return; + } const languagesUrls = this.extractLangURLs(repositories); const langStats = []; - let requestsAmount = languagesUrls.length; + const requestsAmount = languagesUrls.length; + let completedRequests = 0; + if (requestsAmount === 0) { + callback([]); + return; + } + const handleCompletion = () => { + completedRequests++; + if (completedRequests === requestsAmount) { + callback(langStats); + } + }; languagesUrls.forEach((repoLangUrl) => { - const request = this.apiGet(repoLangUrl); - request.error(() => requestsAmount--); - request.success((repoLangs) => { - langStats.push(repoLangs); - if (langStats.length === requestsAmount) { - callback(langStats); - } + this.fetch(repoLangUrl).then((repoLangs) => { + langStats.push(repoLangs || {}); + handleCompletion(); + }).catch((error) => { + console.warn("Failed to load languages for repository:", error); + langStats.push({}); + handleCompletion(); }); }); } - identifyError(result, request) { + async identifyError(response) { + let result; + try { + result = await response.json(); + } catch { + result = { message: "Failed to parse error response" }; + } const error = { - message: result.message + message: result.message || `HTTP ${response.status}: ${response.statusText}` }; - if (request.status === 404) { + if (response.status === 404) { error.isWrongUser = true; } - const limitRequests = request.getResponseHeader("X-RateLimit-Remaining"); + const limitRequests = response.headers.get("X-RateLimit-Remaining"); if (Number(limitRequests) === 0) { - const resetTime = request.getResponseHeader("X-RateLimit-Reset"); - error.resetDate = new Date(Number(resetTime) * 1e3); - error.message = error.message.split("(")[0]; + const resetTime = response.headers.get("X-RateLimit-Reset"); + if (resetTime) { + error.resetDate = new Date(Number(resetTime) * 1e3); + error.message = error.message.split("(")[0]; + } } return error; } extractLangURLs(profileRepositories) { - return profileRepositories.map((repository) => repository.languages_url); + return profileRepositories.filter((repo) => repo && repo.languages_url).map((repository) => repository.languages_url); } - apiGet(url) { - const request = this.buildRequest(url); - return { - success: (callback) => { - request.addEventListener("load", () => { - if (request.status === 304) { - callback(this.cache.get(url).data, request); - } - if (request.status === 200) { - const response = JSON.parse(request.responseText); - this.cache.add(url, { - lastModified: request.getResponseHeader("Last-Modified"), - data: response - }); - callback(response, request); - } - }); - }, - error: (callback) => { - request.addEventListener("load", () => { - if (request.status !== 200 && request.status !== 304) { - callback(JSON.parse(request.responseText), request); - } - }); - } - }; + async fetch(url) { + if (typeof url !== "string") { + throw new Error("Invalid URL provided for fetch"); + } + const cache = this.cache.get(url); + let response; + try { + response = await fetch(url, { + headers: this.buildHeaders(cache) + }); + } catch (networkError) { + throw new Error(`Network error: ${networkError.message}`); + } + if (response.status === 304 && cache) { + return cache.data; + } + if (response.status !== 200) { + throw await this.identifyError(response); + } + let jsonResponse; + try { + jsonResponse = await response.json(); + } catch { + throw new Error("Failed to parse API response as JSON"); + } + const lastModified = response.headers.get("Last-Modified"); + if (lastModified) { + this.cache.add(url, { + lastModified, + data: jsonResponse + }); + } + return jsonResponse; } - buildRequest(url) { - const request = new XMLHttpRequest(); - request.open("GET", url); - this.buildApiHeaders(request, url); - request.send(); - return request; - } - buildApiHeaders(request, url) { - request.setRequestHeader("Accept", "application/vnd.github.v3+json"); - const urlCache = this.cache.get(url); - if (urlCache) { - request.setRequestHeader("If-Modified-Since", urlCache.lastModified); + buildHeaders(cache) { + const headers = { + Accept: "application/vnd.github.v3+json" + }; + if (cache?.lastModified) { + headers["If-Modified-Since"] = cache.lastModified; } + return headers; } }; @@ -131,26 +201,27 @@ const $name = document.createElement("a"); $name.href = profileUrl; $name.className = "name"; - $name.appendChild(document.createTextNode(name)); + $name.appendChild(document.createTextNode(name || "")); return $name; } function createAvatar(avatarUrl) { const $avatar = document.createElement("img"); $avatar.src = avatarUrl; $avatar.className = "avatar"; + $avatar.alt = "GitHub avatar"; return $avatar; } function createFollowButton(username, followUrl) { const $followButton = document.createElement("a"); $followButton.href = followUrl; $followButton.className = "follow-button"; - $followButton.innerHTML = "Follow @" + username; + $followButton.textContent = `Follow @${username}`; return $followButton; } function createFollowers(followersAmount) { const $followers = document.createElement("span"); $followers.className = "followers"; - $followers.innerHTML = "" + followersAmount; + $followers.textContent = String(followersAmount); return $followers; } function createFollowContainer(children) { @@ -163,21 +234,27 @@ // src/gh-dom-operator.ts var DOMOperator = class { static clearChildren($parent) { - while ($parent.hasChildNodes()) { - $parent.removeChild($parent.firstChild); - } + $parent.textContent = ""; } static createError(error, username) { const $error = document.createElement("div"); $error.className = "error"; - $error.innerHTML = `${error.message}`; + const $message = document.createElement("span"); + $message.textContent = error.message; + $error.appendChild($message); if (error.isWrongUser) { - $error.innerHTML = `Not found user: ${username}`; + $message.textContent = `Not found user: ${username}`; } if (error.resetDate) { - let remainingTime = error.resetDate.getMinutes() - (/* @__PURE__ */ new Date()).getMinutes(); - remainingTime = remainingTime < 0 ? 60 + remainingTime : remainingTime; - $error.innerHTML += `Come back after ${remainingTime} minutes`; + const currentTime = (/* @__PURE__ */ new Date()).getTime(); + const resetTime = error.resetDate.getTime(); + const remainingMinutes = Math.ceil( + (resetTime - currentTime) / (1e3 * 60) + ); + const $remainingTime = document.createElement("span"); + $remainingTime.className = "remain"; + $remainingTime.textContent = `Come back after ${remainingMinutes} minutes`; + $error.appendChild($remainingTime); } return $error; } @@ -195,10 +272,19 @@ return $langsList; } static createTopLanguagesList(langs) { - return Object.keys(langs).map((language) => ({ + const sortedLanguages = Object.keys(langs).map((language) => ({ name: language, stat: langs[language] - })).sort((a, b) => b.stat - a.stat).slice(0, 3).map((lang) => `
  • ${lang.name}
  • `).reduce((list, nextElement) => list + nextElement); + })).sort((a, b) => b.stat - a.stat).slice(0, 3); + return sortedLanguages.map((lang) => { + const escapedName = this.escapeHtml(lang.name); + return `
  • ${escapedName}
  • `; + }).join(""); + } + static escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; } static createRepositoriesHeader(headerText) { const $repositoriesHeader = document.createElement("span"); @@ -216,12 +302,19 @@ const updated = new Date(repository.updated_at); const $repoLink = document.createElement("a"); $repoLink.href = repository.html_url; - $repoLink.title = repository.description; - $repoLink.innerHTML = ` - ${repository.name} - Updated: ${updated.toLocaleDateString()} - ${repository.stargazers_count} - `; + $repoLink.title = repository.description || ""; + const $repoName = document.createElement("span"); + $repoName.className = "repo-name"; + $repoName.textContent = repository.name; + const $updated = document.createElement("span"); + $updated.className = "updated"; + $updated.textContent = `Updated: ${updated.toLocaleDateString()}`; + const $star = document.createElement("span"); + $star.className = "star"; + $star.textContent = String(repository.stargazers_count); + $repoLink.appendChild($repoName); + $repoLink.appendChild($updated); + $repoLink.appendChild($star); return $repoLink; } }; @@ -230,15 +323,18 @@ var GitHubCardWidget = class { constructor(options = {}) { this.apiLoader = new GitHubApiLoader(); + this.userData = null; this.$template = this.findTemplate(options.template); this.extractHtmlConfig(options, this.$template); this.options = this.completeConfiguration(options); } - init() { - this.apiLoader.loadUserData(this.options.username, (data, err) => { - this.userData = data; + async init() { + try { + this.userData = await this.apiLoader.loadUserData(this.options.username); + this.render(this.options); + } catch (err) { this.render(this.options, err); - }); + } } refresh(options) { this.options = this.completeConfiguration(options); @@ -246,7 +342,7 @@ } completeConfiguration(options) { const defaultConfig = { - username: null, + username: "", template: "#github-card", sortBy: "stars", // possible: 'stars', 'updateTime' @@ -254,53 +350,62 @@ maxRepos: 5, hideTopLanguages: false }; - for (const key in defaultConfig) { - defaultConfig[key] = options[key] || defaultConfig[key]; - } - return defaultConfig; + return { + ...defaultConfig, + ...options + }; } findTemplate(templateCssSelector = "#github-card") { const $template = document.querySelector( templateCssSelector ); if (!$template) { - throw `No template found for selector: ${templateCssSelector}`; + throw new Error(`No template found for selector: ${templateCssSelector}`); } $template.className = "gh-profile-card"; return $template; } extractHtmlConfig(widgetConfig, $template) { - widgetConfig.username = widgetConfig.username || $template.dataset["username"]; - widgetConfig.sortBy = widgetConfig.sortBy || $template.dataset["sortBy"]; - widgetConfig.headerText = widgetConfig.headerText || $template.dataset["headerText"]; - widgetConfig.maxRepos = widgetConfig.maxRepos || parseInt($template.dataset["maxRepos"], 10); - widgetConfig.hideTopLanguages = widgetConfig.hideTopLanguages || $template.dataset["hideTopLanguages"] === "true"; + const dataset = $template.dataset; + widgetConfig.username = widgetConfig.username || dataset.username; + widgetConfig.sortBy = widgetConfig.sortBy || dataset.sortBy; + widgetConfig.headerText = widgetConfig.headerText || dataset.headerText; + if (dataset.maxRepos) { + const parsedMaxRepos = parseInt(dataset.maxRepos, 10); + if (!isNaN(parsedMaxRepos)) { + widgetConfig.maxRepos = widgetConfig.maxRepos || parsedMaxRepos; + } + } + widgetConfig.hideTopLanguages = widgetConfig.hideTopLanguages || dataset.hideTopLanguages === "true"; if (!widgetConfig.username) { - throw "Not provided username"; + throw new Error("Username is required but not provided"); } } render(options, error) { const $root = this.$template; DOMOperator.clearChildren($root); if (error) { - const $errorSection = DOMOperator.createError(error, options.username); + const $errorSection = DOMOperator.createError( + error, + options.username || "" + ); $root.appendChild($errorSection); return; } const repositories = this.userData.repositories; - this.sortRepositories(repositories, options.sortBy); + this.sortRepositories(repositories, options.sortBy || "stars"); const $profile = DOMOperator.createProfile(this.userData.profile); if (!options.hideTopLanguages) { $profile.appendChild(this.createTopLanguagesSection(repositories)); } $root.appendChild($profile); - if (options.maxRepos > 0) { + if ((options.maxRepos || 0) > 0) { const $reposHeader = DOMOperator.createRepositoriesHeader( - options.headerText + options.headerText || "Repositories" ); const $reposList = DOMOperator.createRepositoriesList( repositories, - options.maxRepos + options.maxRepos || 5 ); $reposList.insertBefore($reposHeader, $reposList.firstChild); $root.appendChild($reposList); @@ -308,11 +413,16 @@ } createTopLanguagesSection(repositories) { const $topLanguages = DOMOperator.createTopLanguagesSection(); + if (!repositories || repositories.length === 0) { + return $topLanguages; + } this.apiLoader.loadRepositoriesLanguages( repositories.slice(0, 10), (langStats) => { - const languagesRank = this.groupLanguagesUsage(langStats); - $topLanguages.innerHTML = DOMOperator.createTopLanguagesList(languagesRank); + if (langStats.length > 0) { + const languagesRank = this.groupLanguagesUsage(langStats); + $topLanguages.innerHTML = DOMOperator.createTopLanguagesList(languagesRank); + } } ); return $topLanguages; @@ -320,16 +430,22 @@ groupLanguagesUsage(langStats) { const languagesRank = {}; langStats.forEach((repoLangs) => { - for (const language in repoLangs) { - languagesRank[language] = languagesRank[language] || 0; - languagesRank[language] += repoLangs[language]; + if (repoLangs && typeof repoLangs === "object") { + Object.entries(repoLangs).forEach(([language, bytes]) => { + if (typeof bytes === "number" && bytes > 0) { + languagesRank[language] = (languagesRank[language] || 0) + bytes; + } + }); } }); return languagesRank; } - sortRepositories(repos, sortyBy) { + sortRepositories(repos, sortBy) { + if (!repos || repos.length === 0) { + return; + } repos.sort((firstRepo, secondRepo) => { - if (sortyBy === "stars") { + if (sortBy === "stars") { const starDifference = secondRepo.stargazers_count - firstRepo.stargazers_count; if (starDifference !== 0) { return starDifference; @@ -339,7 +455,12 @@ }); } dateDifference(first, second) { - return new Date(first).getTime() - new Date(second).getTime(); + const firstDate = new Date(first); + const secondDate = new Date(second); + if (isNaN(firstDate.getTime()) || isNaN(secondDate.getTime())) { + return 0; + } + return firstDate.getTime() - secondDate.getTime(); } }; @@ -549,8 +670,14 @@ document.addEventListener("DOMContentLoaded", () => { const $defaultTemplate = document.querySelector("#github-card"); if ($defaultTemplate) { - const widget = new GitHubCardWidget(); - widget.init(); + try { + const widget = new GitHubCardWidget(); + widget.init().catch((error) => { + console.error("Failed to initialize GitHub Card widget:", error); + }); + } catch (error) { + console.error("Failed to construct GitHub Card widget:", error); + } } }); })(); diff --git a/knip.json b/knip.json new file mode 100644 index 0000000..14ee69f --- /dev/null +++ b/knip.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://unpkg.com/knip@latest/schema.json", + "entry": ["src/gh-widget-init.ts"], + "project": ["src/**/*.ts"], + "ignore": ["demo/**", "dist/**", "src/**/*.spec.ts"], + "ignoreDependencies": ["sass"] +} diff --git a/package.json b/package.json index f6b3043..619d4bd 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "build": "node build.js", "build:check": "npm run build && npm run lint && npm run format:check && npm run test", "lint": "eslint .", + "lint:knip": "knip", "format": "prettier --write {*,src/*,demo/*}", "format:check": "prettier --check {*,src/*,demo/*}", "test": "jest", diff --git a/src/testing/cache-mock.ts b/src/testing/cache-mock.ts index f1507bf..7cd86bd 100644 --- a/src/testing/cache-mock.ts +++ b/src/testing/cache-mock.ts @@ -20,32 +20,3 @@ export function createCacheMock(): jest.Mocked { export function setupEmptyCache(mockCache: jest.Mocked): void { mockCache.get.mockReturnValue(undefined); } - -/** - * Sets up cache mock with cached data - */ -export function setupCacheWithData( - mockCache: jest.Mocked, - url: string, - data: any, - lastModified: string = 'Mon, 18 Mar 2019 20:40:35 GMT', -): void { - mockCache.get.mockImplementation((requestedUrl: string) => { - if (requestedUrl === url) { - return { - lastModified, - data, - }; - } - return undefined; - }); -} - -/** - * Resets all cache mock calls - */ -export function resetCacheMock(mockCache: jest.Mocked): void { - mockCache.get.mockClear(); - mockCache.add.mockClear(); - mockCache.clearExpiredEntries.mockClear(); -} diff --git a/src/testing/fetch-mock.ts b/src/testing/fetch-mock.ts index 7e57cd1..37931c7 100644 --- a/src/testing/fetch-mock.ts +++ b/src/testing/fetch-mock.ts @@ -54,15 +54,6 @@ export function createErrorResponse( }; } -/** - * Creates a 304 Not Modified response mock - */ -export function createNotModifiedResponse(): MockResponse { - return { - status: 304, - }; -} - /** * Creates a network error for fetch */ diff --git a/src/testing/mock-github-data.ts b/src/testing/mock-github-data.ts index b27e2ff..4c94e8f 100644 --- a/src/testing/mock-github-data.ts +++ b/src/testing/mock-github-data.ts @@ -180,27 +180,3 @@ export const mockLanguageStats = [ { TypeScript: 1000, JavaScript: 500 }, { Python: 800, TypeScript: 200 }, ]; - -/** - * Creates a mock repository with custom properties - */ -export function createMockRepository( - overrides: Partial = {}, -): ApiRepository { - return { - ...mockRepositories[0], - ...overrides, - }; -} - -/** - * Creates a mock profile with custom properties - */ -export function createMockProfile( - overrides: Partial = {}, -): ApiProfile { - return { - ...mockProfile, - ...overrides, - }; -} diff --git a/src/testing/test-utils.ts b/src/testing/test-utils.ts index fc8886c..1e5324f 100644 --- a/src/testing/test-utils.ts +++ b/src/testing/test-utils.ts @@ -25,30 +25,3 @@ export function cleanupTestEnvironment(): void { jest.clearAllMocks(); resetFetchMock(); } - -export function setupTestDOM(html: string): void { - document.body.innerHTML = html; -} - -export function setupCommonTestHooks(): { - getLoader: () => GitHubApiLoader; - getMockCache: () => jest.Mocked; -} { - let loader: GitHubApiLoader; - let mockCache: jest.Mocked; - - beforeEach(() => { - const env = setupTestEnvironment(); - loader = env.loader; - mockCache = env.mockCache; - }); - - afterEach(() => { - cleanupTestEnvironment(); - }); - - return { - getLoader: () => loader, - getMockCache: () => mockCache, - }; -} From f39a613f2799462226c509dd663bba70dbf61bf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:09:16 +0000 Subject: [PATCH 4/6] Pin knip to exact version and revert unrelated dist changes Co-authored-by: piotrl <290300+piotrl@users.noreply.github.com> --- dist/gh-profile-card.min.js | 369 ++++++++++++------------------------ package-lock.json | 2 +- package.json | 2 +- 3 files changed, 123 insertions(+), 250 deletions(-) diff --git a/dist/gh-profile-card.min.js b/dist/gh-profile-card.min.js index 5aa812f..755b314 100644 --- a/dist/gh-profile-card.min.js +++ b/dist/gh-profile-card.min.js @@ -1,6 +1,6 @@ /** * github-profile-card - 3.2.0 | MIT -* (c) 2014 - 2026 Piotr Lewandowski | https://piotrl.github.io/github-profile-card/ +* (c) 2014 - 2025 Piotr Lewandowski | https://piotrl.github.io/github-profile-card/ */ (() => { @@ -9,181 +9,111 @@ constructor(storage) { this.storage = storage; this.cacheName = "github-request-cache"; - this.requestCache = this.initializeCache(); + this.requestCache = this.getCache() || {}; } get(key) { return this.requestCache[key]; } add(url, entry) { this.requestCache[url] = entry; - this.saveCache(); + this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); } - initializeCache() { - try { - const cacheData = this.storage.getItem(this.cacheName); - if (!cacheData) { - return {}; - } - const cache = JSON.parse(cacheData); - return cache && typeof cache === "object" ? cache : {}; - } catch (error) { - console.error("Failed to parse cache:", error); - try { - this.storage.removeItem(this.cacheName); - } catch (cleanupError) { - console.error("Failed to clear corrupted cache:", cleanupError); - } - return {}; - } - } - saveCache() { - try { - this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); - } catch (error) { - console.error("Failed to save cache:", error); - this.clearExpiredEntries(/* @__PURE__ */ new Date()); - try { - this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); - } catch (retryError) { - console.error("Failed to save cache after cleanup:", retryError); - } - } - } - clearExpiredEntries(currentDate) { - let hasChanges = false; - for (const [url, entry] of Object.entries(this.requestCache)) { - if (entry.lastModified) { - const entryDate = new Date(entry.lastModified); - if (isNaN(entryDate.getTime()) || entryDate < currentDate) { - delete this.requestCache[url]; - hasChanges = true; - } - } - } - if (hasChanges) { - this.saveCache(); - } + getCache() { + return JSON.parse(this.storage.getItem(this.cacheName)); } }; // src/gh-data-loader.ts - var API_HOST = "https://api.github.com"; var GitHubApiLoader = class { constructor() { + this.apiBase = "https://api.github.com"; this.cache = new CacheStorage(window.localStorage); } - async loadUserData(username) { - if (typeof username !== "string") { - throw new Error("Invalid username provided"); - } - const sanitizedUsername = username.trim(); - if (!sanitizedUsername) { - throw new Error("Username cannot be empty"); - } - const profile = await this.fetch( - `${API_HOST}/users/${sanitizedUsername}` - ); - const repositories = await this.fetch(profile.repos_url); - return { profile, repositories }; + loadUserData(username, callback) { + const request = this.apiGet(`${this.apiBase}/users/${username}`); + request.success((profile) => { + this.apiGet(profile.repos_url).success((repositories) => { + callback({ profile, repositories }, null); + }); + }); + request.error((result, request2) => { + const error = this.identifyError(result, request2); + callback(null, error); + }); } loadRepositoriesLanguages(repositories, callback) { - if (!repositories || repositories.length === 0) { - callback([]); - return; - } const languagesUrls = this.extractLangURLs(repositories); const langStats = []; - const requestsAmount = languagesUrls.length; - let completedRequests = 0; - if (requestsAmount === 0) { - callback([]); - return; - } - const handleCompletion = () => { - completedRequests++; - if (completedRequests === requestsAmount) { - callback(langStats); - } - }; + let requestsAmount = languagesUrls.length; languagesUrls.forEach((repoLangUrl) => { - this.fetch(repoLangUrl).then((repoLangs) => { - langStats.push(repoLangs || {}); - handleCompletion(); - }).catch((error) => { - console.warn("Failed to load languages for repository:", error); - langStats.push({}); - handleCompletion(); + const request = this.apiGet(repoLangUrl); + request.error(() => requestsAmount--); + request.success((repoLangs) => { + langStats.push(repoLangs); + if (langStats.length === requestsAmount) { + callback(langStats); + } }); }); } - async identifyError(response) { - let result; - try { - result = await response.json(); - } catch { - result = { message: "Failed to parse error response" }; - } + identifyError(result, request) { const error = { - message: result.message || `HTTP ${response.status}: ${response.statusText}` + message: result.message }; - if (response.status === 404) { + if (request.status === 404) { error.isWrongUser = true; } - const limitRequests = response.headers.get("X-RateLimit-Remaining"); + const limitRequests = request.getResponseHeader("X-RateLimit-Remaining"); if (Number(limitRequests) === 0) { - const resetTime = response.headers.get("X-RateLimit-Reset"); - if (resetTime) { - error.resetDate = new Date(Number(resetTime) * 1e3); - error.message = error.message.split("(")[0]; - } + const resetTime = request.getResponseHeader("X-RateLimit-Reset"); + error.resetDate = new Date(Number(resetTime) * 1e3); + error.message = error.message.split("(")[0]; } return error; } extractLangURLs(profileRepositories) { - return profileRepositories.filter((repo) => repo && repo.languages_url).map((repository) => repository.languages_url); + return profileRepositories.map((repository) => repository.languages_url); } - async fetch(url) { - if (typeof url !== "string") { - throw new Error("Invalid URL provided for fetch"); - } - const cache = this.cache.get(url); - let response; - try { - response = await fetch(url, { - headers: this.buildHeaders(cache) - }); - } catch (networkError) { - throw new Error(`Network error: ${networkError.message}`); - } - if (response.status === 304 && cache) { - return cache.data; - } - if (response.status !== 200) { - throw await this.identifyError(response); - } - let jsonResponse; - try { - jsonResponse = await response.json(); - } catch { - throw new Error("Failed to parse API response as JSON"); - } - const lastModified = response.headers.get("Last-Modified"); - if (lastModified) { - this.cache.add(url, { - lastModified, - data: jsonResponse - }); - } - return jsonResponse; - } - buildHeaders(cache) { - const headers = { - Accept: "application/vnd.github.v3+json" + apiGet(url) { + const request = this.buildRequest(url); + return { + success: (callback) => { + request.addEventListener("load", () => { + if (request.status === 304) { + callback(this.cache.get(url).data, request); + } + if (request.status === 200) { + const response = JSON.parse(request.responseText); + this.cache.add(url, { + lastModified: request.getResponseHeader("Last-Modified"), + data: response + }); + callback(response, request); + } + }); + }, + error: (callback) => { + request.addEventListener("load", () => { + if (request.status !== 200 && request.status !== 304) { + callback(JSON.parse(request.responseText), request); + } + }); + } }; - if (cache?.lastModified) { - headers["If-Modified-Since"] = cache.lastModified; + } + buildRequest(url) { + const request = new XMLHttpRequest(); + request.open("GET", url); + this.buildApiHeaders(request, url); + request.send(); + return request; + } + buildApiHeaders(request, url) { + request.setRequestHeader("Accept", "application/vnd.github.v3+json"); + const urlCache = this.cache.get(url); + if (urlCache) { + request.setRequestHeader("If-Modified-Since", urlCache.lastModified); } - return headers; } }; @@ -201,27 +131,26 @@ const $name = document.createElement("a"); $name.href = profileUrl; $name.className = "name"; - $name.appendChild(document.createTextNode(name || "")); + $name.appendChild(document.createTextNode(name)); return $name; } function createAvatar(avatarUrl) { const $avatar = document.createElement("img"); $avatar.src = avatarUrl; $avatar.className = "avatar"; - $avatar.alt = "GitHub avatar"; return $avatar; } function createFollowButton(username, followUrl) { const $followButton = document.createElement("a"); $followButton.href = followUrl; $followButton.className = "follow-button"; - $followButton.textContent = `Follow @${username}`; + $followButton.innerHTML = "Follow @" + username; return $followButton; } function createFollowers(followersAmount) { const $followers = document.createElement("span"); $followers.className = "followers"; - $followers.textContent = String(followersAmount); + $followers.innerHTML = "" + followersAmount; return $followers; } function createFollowContainer(children) { @@ -234,27 +163,21 @@ // src/gh-dom-operator.ts var DOMOperator = class { static clearChildren($parent) { - $parent.textContent = ""; + while ($parent.hasChildNodes()) { + $parent.removeChild($parent.firstChild); + } } static createError(error, username) { const $error = document.createElement("div"); $error.className = "error"; - const $message = document.createElement("span"); - $message.textContent = error.message; - $error.appendChild($message); + $error.innerHTML = `${error.message}`; if (error.isWrongUser) { - $message.textContent = `Not found user: ${username}`; + $error.innerHTML = `Not found user: ${username}`; } if (error.resetDate) { - const currentTime = (/* @__PURE__ */ new Date()).getTime(); - const resetTime = error.resetDate.getTime(); - const remainingMinutes = Math.ceil( - (resetTime - currentTime) / (1e3 * 60) - ); - const $remainingTime = document.createElement("span"); - $remainingTime.className = "remain"; - $remainingTime.textContent = `Come back after ${remainingMinutes} minutes`; - $error.appendChild($remainingTime); + let remainingTime = error.resetDate.getMinutes() - (/* @__PURE__ */ new Date()).getMinutes(); + remainingTime = remainingTime < 0 ? 60 + remainingTime : remainingTime; + $error.innerHTML += `Come back after ${remainingTime} minutes`; } return $error; } @@ -272,19 +195,10 @@ return $langsList; } static createTopLanguagesList(langs) { - const sortedLanguages = Object.keys(langs).map((language) => ({ + return Object.keys(langs).map((language) => ({ name: language, stat: langs[language] - })).sort((a, b) => b.stat - a.stat).slice(0, 3); - return sortedLanguages.map((lang) => { - const escapedName = this.escapeHtml(lang.name); - return `
  • ${escapedName}
  • `; - }).join(""); - } - static escapeHtml(text) { - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; + })).sort((a, b) => b.stat - a.stat).slice(0, 3).map((lang) => `
  • ${lang.name}
  • `).reduce((list, nextElement) => list + nextElement); } static createRepositoriesHeader(headerText) { const $repositoriesHeader = document.createElement("span"); @@ -302,19 +216,12 @@ const updated = new Date(repository.updated_at); const $repoLink = document.createElement("a"); $repoLink.href = repository.html_url; - $repoLink.title = repository.description || ""; - const $repoName = document.createElement("span"); - $repoName.className = "repo-name"; - $repoName.textContent = repository.name; - const $updated = document.createElement("span"); - $updated.className = "updated"; - $updated.textContent = `Updated: ${updated.toLocaleDateString()}`; - const $star = document.createElement("span"); - $star.className = "star"; - $star.textContent = String(repository.stargazers_count); - $repoLink.appendChild($repoName); - $repoLink.appendChild($updated); - $repoLink.appendChild($star); + $repoLink.title = repository.description; + $repoLink.innerHTML = ` + ${repository.name} + Updated: ${updated.toLocaleDateString()} + ${repository.stargazers_count} + `; return $repoLink; } }; @@ -323,18 +230,15 @@ var GitHubCardWidget = class { constructor(options = {}) { this.apiLoader = new GitHubApiLoader(); - this.userData = null; this.$template = this.findTemplate(options.template); this.extractHtmlConfig(options, this.$template); this.options = this.completeConfiguration(options); } - async init() { - try { - this.userData = await this.apiLoader.loadUserData(this.options.username); - this.render(this.options); - } catch (err) { + init() { + this.apiLoader.loadUserData(this.options.username, (data, err) => { + this.userData = data; this.render(this.options, err); - } + }); } refresh(options) { this.options = this.completeConfiguration(options); @@ -342,7 +246,7 @@ } completeConfiguration(options) { const defaultConfig = { - username: "", + username: null, template: "#github-card", sortBy: "stars", // possible: 'stars', 'updateTime' @@ -350,62 +254,53 @@ maxRepos: 5, hideTopLanguages: false }; - return { - ...defaultConfig, - ...options - }; + for (const key in defaultConfig) { + defaultConfig[key] = options[key] || defaultConfig[key]; + } + return defaultConfig; } findTemplate(templateCssSelector = "#github-card") { const $template = document.querySelector( templateCssSelector ); if (!$template) { - throw new Error(`No template found for selector: ${templateCssSelector}`); + throw `No template found for selector: ${templateCssSelector}`; } $template.className = "gh-profile-card"; return $template; } extractHtmlConfig(widgetConfig, $template) { - const dataset = $template.dataset; - widgetConfig.username = widgetConfig.username || dataset.username; - widgetConfig.sortBy = widgetConfig.sortBy || dataset.sortBy; - widgetConfig.headerText = widgetConfig.headerText || dataset.headerText; - if (dataset.maxRepos) { - const parsedMaxRepos = parseInt(dataset.maxRepos, 10); - if (!isNaN(parsedMaxRepos)) { - widgetConfig.maxRepos = widgetConfig.maxRepos || parsedMaxRepos; - } - } - widgetConfig.hideTopLanguages = widgetConfig.hideTopLanguages || dataset.hideTopLanguages === "true"; + widgetConfig.username = widgetConfig.username || $template.dataset["username"]; + widgetConfig.sortBy = widgetConfig.sortBy || $template.dataset["sortBy"]; + widgetConfig.headerText = widgetConfig.headerText || $template.dataset["headerText"]; + widgetConfig.maxRepos = widgetConfig.maxRepos || parseInt($template.dataset["maxRepos"], 10); + widgetConfig.hideTopLanguages = widgetConfig.hideTopLanguages || $template.dataset["hideTopLanguages"] === "true"; if (!widgetConfig.username) { - throw new Error("Username is required but not provided"); + throw "Not provided username"; } } render(options, error) { const $root = this.$template; DOMOperator.clearChildren($root); if (error) { - const $errorSection = DOMOperator.createError( - error, - options.username || "" - ); + const $errorSection = DOMOperator.createError(error, options.username); $root.appendChild($errorSection); return; } const repositories = this.userData.repositories; - this.sortRepositories(repositories, options.sortBy || "stars"); + this.sortRepositories(repositories, options.sortBy); const $profile = DOMOperator.createProfile(this.userData.profile); if (!options.hideTopLanguages) { $profile.appendChild(this.createTopLanguagesSection(repositories)); } $root.appendChild($profile); - if ((options.maxRepos || 0) > 0) { + if (options.maxRepos > 0) { const $reposHeader = DOMOperator.createRepositoriesHeader( - options.headerText || "Repositories" + options.headerText ); const $reposList = DOMOperator.createRepositoriesList( repositories, - options.maxRepos || 5 + options.maxRepos ); $reposList.insertBefore($reposHeader, $reposList.firstChild); $root.appendChild($reposList); @@ -413,16 +308,11 @@ } createTopLanguagesSection(repositories) { const $topLanguages = DOMOperator.createTopLanguagesSection(); - if (!repositories || repositories.length === 0) { - return $topLanguages; - } this.apiLoader.loadRepositoriesLanguages( repositories.slice(0, 10), (langStats) => { - if (langStats.length > 0) { - const languagesRank = this.groupLanguagesUsage(langStats); - $topLanguages.innerHTML = DOMOperator.createTopLanguagesList(languagesRank); - } + const languagesRank = this.groupLanguagesUsage(langStats); + $topLanguages.innerHTML = DOMOperator.createTopLanguagesList(languagesRank); } ); return $topLanguages; @@ -430,22 +320,16 @@ groupLanguagesUsage(langStats) { const languagesRank = {}; langStats.forEach((repoLangs) => { - if (repoLangs && typeof repoLangs === "object") { - Object.entries(repoLangs).forEach(([language, bytes]) => { - if (typeof bytes === "number" && bytes > 0) { - languagesRank[language] = (languagesRank[language] || 0) + bytes; - } - }); + for (const language in repoLangs) { + languagesRank[language] = languagesRank[language] || 0; + languagesRank[language] += repoLangs[language]; } }); return languagesRank; } - sortRepositories(repos, sortBy) { - if (!repos || repos.length === 0) { - return; - } + sortRepositories(repos, sortyBy) { repos.sort((firstRepo, secondRepo) => { - if (sortBy === "stars") { + if (sortyBy === "stars") { const starDifference = secondRepo.stargazers_count - firstRepo.stargazers_count; if (starDifference !== 0) { return starDifference; @@ -455,12 +339,7 @@ }); } dateDifference(first, second) { - const firstDate = new Date(first); - const secondDate = new Date(second); - if (isNaN(firstDate.getTime()) || isNaN(secondDate.getTime())) { - return 0; - } - return firstDate.getTime() - secondDate.getTime(); + return new Date(first).getTime() - new Date(second).getTime(); } }; @@ -670,14 +549,8 @@ document.addEventListener("DOMContentLoaded", () => { const $defaultTemplate = document.querySelector("#github-card"); if ($defaultTemplate) { - try { - const widget = new GitHubCardWidget(); - widget.init().catch((error) => { - console.error("Failed to initialize GitHub Card widget:", error); - }); - } catch (error) { - console.error("Failed to construct GitHub Card widget:", error); - } + const widget = new GitHubCardWidget(); + widget.init(); } }); })(); diff --git a/package-lock.json b/package-lock.json index dc6715f..4fef88e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "eslint-plugin-prettier": "5.5.1", "jest": "30.0.4", "jest-environment-jsdom": "30.0.4", - "knip": "^5.82.1", + "knip": "5.82.1", "prettier": "3.6.2", "sass": "1.25.0", "ts-jest": "29.4.0", diff --git a/package.json b/package.json index 619d4bd..f4d5b79 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "eslint-plugin-prettier": "5.5.1", "jest": "30.0.4", "jest-environment-jsdom": "30.0.4", - "knip": "^5.82.1", + "knip": "5.82.1", "prettier": "3.6.2", "sass": "1.25.0", "ts-jest": "29.4.0", From f09f7a67d1e15774ba2b2f75f8c9d78254460b35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:10:49 +0000 Subject: [PATCH 5/6] Pin knip schema version to match dependency version Co-authored-by: piotrl <290300+piotrl@users.noreply.github.com> --- knip.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knip.json b/knip.json index 14ee69f..365c613 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/knip@latest/schema.json", + "$schema": "https://unpkg.com/knip@5.82.1/schema.json", "entry": ["src/gh-widget-init.ts"], "project": ["src/**/*.ts"], "ignore": ["demo/**", "dist/**", "src/**/*.spec.ts"], From 3cb878f41e2e20018d2aad8c2f25492ad60a889e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:12:09 +0000 Subject: [PATCH 6/6] Add knip for dead code detection, remove unused test utilities Co-authored-by: piotrl <290300+piotrl@users.noreply.github.com> --- dist/gh-profile-card.min.js | 369 ++++++++++++++++++++++++------------ 1 file changed, 248 insertions(+), 121 deletions(-) diff --git a/dist/gh-profile-card.min.js b/dist/gh-profile-card.min.js index 755b314..5aa812f 100644 --- a/dist/gh-profile-card.min.js +++ b/dist/gh-profile-card.min.js @@ -1,6 +1,6 @@ /** * github-profile-card - 3.2.0 | MIT -* (c) 2014 - 2025 Piotr Lewandowski | https://piotrl.github.io/github-profile-card/ +* (c) 2014 - 2026 Piotr Lewandowski | https://piotrl.github.io/github-profile-card/ */ (() => { @@ -9,111 +9,181 @@ constructor(storage) { this.storage = storage; this.cacheName = "github-request-cache"; - this.requestCache = this.getCache() || {}; + this.requestCache = this.initializeCache(); } get(key) { return this.requestCache[key]; } add(url, entry) { this.requestCache[url] = entry; - this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); + this.saveCache(); } - getCache() { - return JSON.parse(this.storage.getItem(this.cacheName)); + initializeCache() { + try { + const cacheData = this.storage.getItem(this.cacheName); + if (!cacheData) { + return {}; + } + const cache = JSON.parse(cacheData); + return cache && typeof cache === "object" ? cache : {}; + } catch (error) { + console.error("Failed to parse cache:", error); + try { + this.storage.removeItem(this.cacheName); + } catch (cleanupError) { + console.error("Failed to clear corrupted cache:", cleanupError); + } + return {}; + } + } + saveCache() { + try { + this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); + } catch (error) { + console.error("Failed to save cache:", error); + this.clearExpiredEntries(/* @__PURE__ */ new Date()); + try { + this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); + } catch (retryError) { + console.error("Failed to save cache after cleanup:", retryError); + } + } + } + clearExpiredEntries(currentDate) { + let hasChanges = false; + for (const [url, entry] of Object.entries(this.requestCache)) { + if (entry.lastModified) { + const entryDate = new Date(entry.lastModified); + if (isNaN(entryDate.getTime()) || entryDate < currentDate) { + delete this.requestCache[url]; + hasChanges = true; + } + } + } + if (hasChanges) { + this.saveCache(); + } } }; // src/gh-data-loader.ts + var API_HOST = "https://api.github.com"; var GitHubApiLoader = class { constructor() { - this.apiBase = "https://api.github.com"; this.cache = new CacheStorage(window.localStorage); } - loadUserData(username, callback) { - const request = this.apiGet(`${this.apiBase}/users/${username}`); - request.success((profile) => { - this.apiGet(profile.repos_url).success((repositories) => { - callback({ profile, repositories }, null); - }); - }); - request.error((result, request2) => { - const error = this.identifyError(result, request2); - callback(null, error); - }); + async loadUserData(username) { + if (typeof username !== "string") { + throw new Error("Invalid username provided"); + } + const sanitizedUsername = username.trim(); + if (!sanitizedUsername) { + throw new Error("Username cannot be empty"); + } + const profile = await this.fetch( + `${API_HOST}/users/${sanitizedUsername}` + ); + const repositories = await this.fetch(profile.repos_url); + return { profile, repositories }; } loadRepositoriesLanguages(repositories, callback) { + if (!repositories || repositories.length === 0) { + callback([]); + return; + } const languagesUrls = this.extractLangURLs(repositories); const langStats = []; - let requestsAmount = languagesUrls.length; + const requestsAmount = languagesUrls.length; + let completedRequests = 0; + if (requestsAmount === 0) { + callback([]); + return; + } + const handleCompletion = () => { + completedRequests++; + if (completedRequests === requestsAmount) { + callback(langStats); + } + }; languagesUrls.forEach((repoLangUrl) => { - const request = this.apiGet(repoLangUrl); - request.error(() => requestsAmount--); - request.success((repoLangs) => { - langStats.push(repoLangs); - if (langStats.length === requestsAmount) { - callback(langStats); - } + this.fetch(repoLangUrl).then((repoLangs) => { + langStats.push(repoLangs || {}); + handleCompletion(); + }).catch((error) => { + console.warn("Failed to load languages for repository:", error); + langStats.push({}); + handleCompletion(); }); }); } - identifyError(result, request) { + async identifyError(response) { + let result; + try { + result = await response.json(); + } catch { + result = { message: "Failed to parse error response" }; + } const error = { - message: result.message + message: result.message || `HTTP ${response.status}: ${response.statusText}` }; - if (request.status === 404) { + if (response.status === 404) { error.isWrongUser = true; } - const limitRequests = request.getResponseHeader("X-RateLimit-Remaining"); + const limitRequests = response.headers.get("X-RateLimit-Remaining"); if (Number(limitRequests) === 0) { - const resetTime = request.getResponseHeader("X-RateLimit-Reset"); - error.resetDate = new Date(Number(resetTime) * 1e3); - error.message = error.message.split("(")[0]; + const resetTime = response.headers.get("X-RateLimit-Reset"); + if (resetTime) { + error.resetDate = new Date(Number(resetTime) * 1e3); + error.message = error.message.split("(")[0]; + } } return error; } extractLangURLs(profileRepositories) { - return profileRepositories.map((repository) => repository.languages_url); + return profileRepositories.filter((repo) => repo && repo.languages_url).map((repository) => repository.languages_url); } - apiGet(url) { - const request = this.buildRequest(url); - return { - success: (callback) => { - request.addEventListener("load", () => { - if (request.status === 304) { - callback(this.cache.get(url).data, request); - } - if (request.status === 200) { - const response = JSON.parse(request.responseText); - this.cache.add(url, { - lastModified: request.getResponseHeader("Last-Modified"), - data: response - }); - callback(response, request); - } - }); - }, - error: (callback) => { - request.addEventListener("load", () => { - if (request.status !== 200 && request.status !== 304) { - callback(JSON.parse(request.responseText), request); - } - }); - } - }; + async fetch(url) { + if (typeof url !== "string") { + throw new Error("Invalid URL provided for fetch"); + } + const cache = this.cache.get(url); + let response; + try { + response = await fetch(url, { + headers: this.buildHeaders(cache) + }); + } catch (networkError) { + throw new Error(`Network error: ${networkError.message}`); + } + if (response.status === 304 && cache) { + return cache.data; + } + if (response.status !== 200) { + throw await this.identifyError(response); + } + let jsonResponse; + try { + jsonResponse = await response.json(); + } catch { + throw new Error("Failed to parse API response as JSON"); + } + const lastModified = response.headers.get("Last-Modified"); + if (lastModified) { + this.cache.add(url, { + lastModified, + data: jsonResponse + }); + } + return jsonResponse; } - buildRequest(url) { - const request = new XMLHttpRequest(); - request.open("GET", url); - this.buildApiHeaders(request, url); - request.send(); - return request; - } - buildApiHeaders(request, url) { - request.setRequestHeader("Accept", "application/vnd.github.v3+json"); - const urlCache = this.cache.get(url); - if (urlCache) { - request.setRequestHeader("If-Modified-Since", urlCache.lastModified); + buildHeaders(cache) { + const headers = { + Accept: "application/vnd.github.v3+json" + }; + if (cache?.lastModified) { + headers["If-Modified-Since"] = cache.lastModified; } + return headers; } }; @@ -131,26 +201,27 @@ const $name = document.createElement("a"); $name.href = profileUrl; $name.className = "name"; - $name.appendChild(document.createTextNode(name)); + $name.appendChild(document.createTextNode(name || "")); return $name; } function createAvatar(avatarUrl) { const $avatar = document.createElement("img"); $avatar.src = avatarUrl; $avatar.className = "avatar"; + $avatar.alt = "GitHub avatar"; return $avatar; } function createFollowButton(username, followUrl) { const $followButton = document.createElement("a"); $followButton.href = followUrl; $followButton.className = "follow-button"; - $followButton.innerHTML = "Follow @" + username; + $followButton.textContent = `Follow @${username}`; return $followButton; } function createFollowers(followersAmount) { const $followers = document.createElement("span"); $followers.className = "followers"; - $followers.innerHTML = "" + followersAmount; + $followers.textContent = String(followersAmount); return $followers; } function createFollowContainer(children) { @@ -163,21 +234,27 @@ // src/gh-dom-operator.ts var DOMOperator = class { static clearChildren($parent) { - while ($parent.hasChildNodes()) { - $parent.removeChild($parent.firstChild); - } + $parent.textContent = ""; } static createError(error, username) { const $error = document.createElement("div"); $error.className = "error"; - $error.innerHTML = `${error.message}`; + const $message = document.createElement("span"); + $message.textContent = error.message; + $error.appendChild($message); if (error.isWrongUser) { - $error.innerHTML = `Not found user: ${username}`; + $message.textContent = `Not found user: ${username}`; } if (error.resetDate) { - let remainingTime = error.resetDate.getMinutes() - (/* @__PURE__ */ new Date()).getMinutes(); - remainingTime = remainingTime < 0 ? 60 + remainingTime : remainingTime; - $error.innerHTML += `Come back after ${remainingTime} minutes`; + const currentTime = (/* @__PURE__ */ new Date()).getTime(); + const resetTime = error.resetDate.getTime(); + const remainingMinutes = Math.ceil( + (resetTime - currentTime) / (1e3 * 60) + ); + const $remainingTime = document.createElement("span"); + $remainingTime.className = "remain"; + $remainingTime.textContent = `Come back after ${remainingMinutes} minutes`; + $error.appendChild($remainingTime); } return $error; } @@ -195,10 +272,19 @@ return $langsList; } static createTopLanguagesList(langs) { - return Object.keys(langs).map((language) => ({ + const sortedLanguages = Object.keys(langs).map((language) => ({ name: language, stat: langs[language] - })).sort((a, b) => b.stat - a.stat).slice(0, 3).map((lang) => `
  • ${lang.name}
  • `).reduce((list, nextElement) => list + nextElement); + })).sort((a, b) => b.stat - a.stat).slice(0, 3); + return sortedLanguages.map((lang) => { + const escapedName = this.escapeHtml(lang.name); + return `
  • ${escapedName}
  • `; + }).join(""); + } + static escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; } static createRepositoriesHeader(headerText) { const $repositoriesHeader = document.createElement("span"); @@ -216,12 +302,19 @@ const updated = new Date(repository.updated_at); const $repoLink = document.createElement("a"); $repoLink.href = repository.html_url; - $repoLink.title = repository.description; - $repoLink.innerHTML = ` - ${repository.name} - Updated: ${updated.toLocaleDateString()} - ${repository.stargazers_count} - `; + $repoLink.title = repository.description || ""; + const $repoName = document.createElement("span"); + $repoName.className = "repo-name"; + $repoName.textContent = repository.name; + const $updated = document.createElement("span"); + $updated.className = "updated"; + $updated.textContent = `Updated: ${updated.toLocaleDateString()}`; + const $star = document.createElement("span"); + $star.className = "star"; + $star.textContent = String(repository.stargazers_count); + $repoLink.appendChild($repoName); + $repoLink.appendChild($updated); + $repoLink.appendChild($star); return $repoLink; } }; @@ -230,15 +323,18 @@ var GitHubCardWidget = class { constructor(options = {}) { this.apiLoader = new GitHubApiLoader(); + this.userData = null; this.$template = this.findTemplate(options.template); this.extractHtmlConfig(options, this.$template); this.options = this.completeConfiguration(options); } - init() { - this.apiLoader.loadUserData(this.options.username, (data, err) => { - this.userData = data; + async init() { + try { + this.userData = await this.apiLoader.loadUserData(this.options.username); + this.render(this.options); + } catch (err) { this.render(this.options, err); - }); + } } refresh(options) { this.options = this.completeConfiguration(options); @@ -246,7 +342,7 @@ } completeConfiguration(options) { const defaultConfig = { - username: null, + username: "", template: "#github-card", sortBy: "stars", // possible: 'stars', 'updateTime' @@ -254,53 +350,62 @@ maxRepos: 5, hideTopLanguages: false }; - for (const key in defaultConfig) { - defaultConfig[key] = options[key] || defaultConfig[key]; - } - return defaultConfig; + return { + ...defaultConfig, + ...options + }; } findTemplate(templateCssSelector = "#github-card") { const $template = document.querySelector( templateCssSelector ); if (!$template) { - throw `No template found for selector: ${templateCssSelector}`; + throw new Error(`No template found for selector: ${templateCssSelector}`); } $template.className = "gh-profile-card"; return $template; } extractHtmlConfig(widgetConfig, $template) { - widgetConfig.username = widgetConfig.username || $template.dataset["username"]; - widgetConfig.sortBy = widgetConfig.sortBy || $template.dataset["sortBy"]; - widgetConfig.headerText = widgetConfig.headerText || $template.dataset["headerText"]; - widgetConfig.maxRepos = widgetConfig.maxRepos || parseInt($template.dataset["maxRepos"], 10); - widgetConfig.hideTopLanguages = widgetConfig.hideTopLanguages || $template.dataset["hideTopLanguages"] === "true"; + const dataset = $template.dataset; + widgetConfig.username = widgetConfig.username || dataset.username; + widgetConfig.sortBy = widgetConfig.sortBy || dataset.sortBy; + widgetConfig.headerText = widgetConfig.headerText || dataset.headerText; + if (dataset.maxRepos) { + const parsedMaxRepos = parseInt(dataset.maxRepos, 10); + if (!isNaN(parsedMaxRepos)) { + widgetConfig.maxRepos = widgetConfig.maxRepos || parsedMaxRepos; + } + } + widgetConfig.hideTopLanguages = widgetConfig.hideTopLanguages || dataset.hideTopLanguages === "true"; if (!widgetConfig.username) { - throw "Not provided username"; + throw new Error("Username is required but not provided"); } } render(options, error) { const $root = this.$template; DOMOperator.clearChildren($root); if (error) { - const $errorSection = DOMOperator.createError(error, options.username); + const $errorSection = DOMOperator.createError( + error, + options.username || "" + ); $root.appendChild($errorSection); return; } const repositories = this.userData.repositories; - this.sortRepositories(repositories, options.sortBy); + this.sortRepositories(repositories, options.sortBy || "stars"); const $profile = DOMOperator.createProfile(this.userData.profile); if (!options.hideTopLanguages) { $profile.appendChild(this.createTopLanguagesSection(repositories)); } $root.appendChild($profile); - if (options.maxRepos > 0) { + if ((options.maxRepos || 0) > 0) { const $reposHeader = DOMOperator.createRepositoriesHeader( - options.headerText + options.headerText || "Repositories" ); const $reposList = DOMOperator.createRepositoriesList( repositories, - options.maxRepos + options.maxRepos || 5 ); $reposList.insertBefore($reposHeader, $reposList.firstChild); $root.appendChild($reposList); @@ -308,11 +413,16 @@ } createTopLanguagesSection(repositories) { const $topLanguages = DOMOperator.createTopLanguagesSection(); + if (!repositories || repositories.length === 0) { + return $topLanguages; + } this.apiLoader.loadRepositoriesLanguages( repositories.slice(0, 10), (langStats) => { - const languagesRank = this.groupLanguagesUsage(langStats); - $topLanguages.innerHTML = DOMOperator.createTopLanguagesList(languagesRank); + if (langStats.length > 0) { + const languagesRank = this.groupLanguagesUsage(langStats); + $topLanguages.innerHTML = DOMOperator.createTopLanguagesList(languagesRank); + } } ); return $topLanguages; @@ -320,16 +430,22 @@ groupLanguagesUsage(langStats) { const languagesRank = {}; langStats.forEach((repoLangs) => { - for (const language in repoLangs) { - languagesRank[language] = languagesRank[language] || 0; - languagesRank[language] += repoLangs[language]; + if (repoLangs && typeof repoLangs === "object") { + Object.entries(repoLangs).forEach(([language, bytes]) => { + if (typeof bytes === "number" && bytes > 0) { + languagesRank[language] = (languagesRank[language] || 0) + bytes; + } + }); } }); return languagesRank; } - sortRepositories(repos, sortyBy) { + sortRepositories(repos, sortBy) { + if (!repos || repos.length === 0) { + return; + } repos.sort((firstRepo, secondRepo) => { - if (sortyBy === "stars") { + if (sortBy === "stars") { const starDifference = secondRepo.stargazers_count - firstRepo.stargazers_count; if (starDifference !== 0) { return starDifference; @@ -339,7 +455,12 @@ }); } dateDifference(first, second) { - return new Date(first).getTime() - new Date(second).getTime(); + const firstDate = new Date(first); + const secondDate = new Date(second); + if (isNaN(firstDate.getTime()) || isNaN(secondDate.getTime())) { + return 0; + } + return firstDate.getTime() - secondDate.getTime(); } }; @@ -549,8 +670,14 @@ document.addEventListener("DOMContentLoaded", () => { const $defaultTemplate = document.querySelector("#github-card"); if ($defaultTemplate) { - const widget = new GitHubCardWidget(); - widget.init(); + try { + const widget = new GitHubCardWidget(); + widget.init().catch((error) => { + console.error("Failed to initialize GitHub Card widget:", error); + }); + } catch (error) { + console.error("Failed to construct GitHub Card widget:", error); + } } }); })();