diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a950248..56efb46 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -44,7 +44,7 @@ jobs: CLIENT_ID: ${{ secrets.CLIENT_ID }} - name: Run tests - run: npm test + run: npm run test:ci - name: Package artifact run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4cc4865..c0b882c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,5 +42,5 @@ jobs: CLIENT_ID: ${{ secrets.CLIENT_ID }} - name: Run tests - run: npm test + run: npm run test:ci diff --git a/.vscode/settings.json b/.vscode/settings.json index d722df4..9affadd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,6 @@ }, "[json]": { "editor.defaultFormatter": "biomejs.biome" - } + }, + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/biome.json b/biome.json index 06b3bb0..b7ae8e1 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,8 @@ "style": { "noNonNullAssertion": "off", "useConst": "error", - "useTemplate": "error" + "useTemplate": "error", + "useBlockStatements": "error" }, "suspicious": { "noExplicitAny": "warn", diff --git a/package.json b/package.json index 16de36f..c66397c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Web Dev & Web Design discord bot", "type": "module", "scripts": { - "build:ci": "tsup && node scripts/copy-assets.js", + "build:ci": "npm run build:ts && npm run build:copy", "build:dev": "pnpm run build:ts && pnpm run build:copy", "build:ts": "tsup", "build:copy": "node scripts/copy-assets.js", @@ -17,7 +17,8 @@ "check": "biome check .", "check:fix": "biome check --write .", "typecheck": "tsc --noEmit", - "test": "node --test", + "test": "pnpm run build:dev && node --test dist/**/*.test.js", + "test:ci": "node --test dist/**/*.test.js", "prepare": "husky", "pre-commit": "lint-staged" }, @@ -27,7 +28,8 @@ "packageManager": "pnpm@10.17.1", "dependencies": { "@discordjs/core": "^2.2.2", - "discord.js": "^14.22.1" + "discord.js": "^14.22.1", + "web-features": "^3.3.0" }, "devDependencies": { "@biomejs/biome": "2.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e0aa3e..f1d5162 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,13 +14,16 @@ importers: discord.js: specifier: ^14.22.1 version: 14.22.1 + web-features: + specifier: ^3.3.0 + version: 3.3.0 devDependencies: '@biomejs/biome': specifier: 2.2.4 version: 2.2.4 '@types/node': specifier: ^24.5.2 - version: 24.5.2 + version: 24.7.0 husky: specifier: ^9.1.7 version: 9.1.7 @@ -29,7 +32,7 @@ importers: version: 16.2.3 tsup: specifier: ^8.5.0 - version: 8.5.0(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) + version: 8.5.0(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.20.6 version: 4.20.6 @@ -305,113 +308,113 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@rollup/rollup-android-arm-eabi@4.52.3': - resolution: {integrity: sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==} + '@rollup/rollup-android-arm-eabi@4.52.4': + resolution: {integrity: sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.52.3': - resolution: {integrity: sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==} + '@rollup/rollup-android-arm64@4.52.4': + resolution: {integrity: sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.52.3': - resolution: {integrity: sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==} + '@rollup/rollup-darwin-arm64@4.52.4': + resolution: {integrity: sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.52.3': - resolution: {integrity: sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==} + '@rollup/rollup-darwin-x64@4.52.4': + resolution: {integrity: sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.52.3': - resolution: {integrity: sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==} + '@rollup/rollup-freebsd-arm64@4.52.4': + resolution: {integrity: sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.52.3': - resolution: {integrity: sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==} + '@rollup/rollup-freebsd-x64@4.52.4': + resolution: {integrity: sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.52.3': - resolution: {integrity: sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==} + '@rollup/rollup-linux-arm-gnueabihf@4.52.4': + resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.52.3': - resolution: {integrity: sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==} + '@rollup/rollup-linux-arm-musleabihf@4.52.4': + resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.52.3': - resolution: {integrity: sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==} + '@rollup/rollup-linux-arm64-gnu@4.52.4': + resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.52.3': - resolution: {integrity: sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==} + '@rollup/rollup-linux-arm64-musl@4.52.4': + resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.52.3': - resolution: {integrity: sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==} + '@rollup/rollup-linux-loong64-gnu@4.52.4': + resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.52.3': - resolution: {integrity: sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==} + '@rollup/rollup-linux-ppc64-gnu@4.52.4': + resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.52.3': - resolution: {integrity: sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==} + '@rollup/rollup-linux-riscv64-gnu@4.52.4': + resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.52.3': - resolution: {integrity: sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==} + '@rollup/rollup-linux-riscv64-musl@4.52.4': + resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.52.3': - resolution: {integrity: sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==} + '@rollup/rollup-linux-s390x-gnu@4.52.4': + resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.52.3': - resolution: {integrity: sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==} + '@rollup/rollup-linux-x64-gnu@4.52.4': + resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.52.3': - resolution: {integrity: sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==} + '@rollup/rollup-linux-x64-musl@4.52.4': + resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} cpu: [x64] os: [linux] - '@rollup/rollup-openharmony-arm64@4.52.3': - resolution: {integrity: sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==} + '@rollup/rollup-openharmony-arm64@4.52.4': + resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.52.3': - resolution: {integrity: sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==} + '@rollup/rollup-win32-arm64-msvc@4.52.4': + resolution: {integrity: sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.52.3': - resolution: {integrity: sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==} + '@rollup/rollup-win32-ia32-msvc@4.52.4': + resolution: {integrity: sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.52.3': - resolution: {integrity: sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==} + '@rollup/rollup-win32-x64-gnu@4.52.4': + resolution: {integrity: sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.52.3': - resolution: {integrity: sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==} + '@rollup/rollup-win32-x64-msvc@4.52.4': + resolution: {integrity: sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==} cpu: [x64] os: [win32] @@ -434,14 +437,14 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/node@24.5.2': - resolution: {integrity: sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==} + '@types/node@24.7.0': + resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@vladfrangu/async_event_emitter@2.4.6': - resolution: {integrity: sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==} + '@vladfrangu/async_event_emitter@2.4.7': + resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} acorn@8.15.0: @@ -542,8 +545,8 @@ packages: supports-color: optional: true - discord-api-types@0.38.26: - resolution: {integrity: sha512-xpmPviHjIJ6dFu1eNwNDIGQ3N6qmPUUYFVAx/YZ64h7ZgPkTcKjnciD8bZe8Vbeji7yS5uYljyciunpq0J5NSw==} + discord-api-types@0.38.28: + resolution: {integrity: sha512-QwgoJb+83O8Cx0bhHdI/Y9cQIHRvzy8lKXzSQOmzHEf8InuJMEWrzYk94f+OncHk3qWOqBdr9i0DjtXp4i+NHg==} discord.js@14.22.1: resolution: {integrity: sha512-3k+Kisd/v570Jr68A1kNs7qVhNehDwDJAPe4DZ2Syt+/zobf9zEcuYFvsfIaAOgCa0BiHMfOOKQY4eYINl0z7w==} @@ -795,8 +798,8 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.52.3: - resolution: {integrity: sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==} + rollup@4.52.4: + resolution: {integrity: sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -912,21 +915,24 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} - undici-types@7.12.0: - resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==} + undici-types@7.14.0: + resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} undici@6.21.3: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} engines: {node: '>=18.17'} + web-features@3.3.0: + resolution: {integrity: sha512-hg7CKhLUTKEi4zRrFXs1sEmu0kFK+hGCZHalw+nkyBN5X+lgNzs22q7WlNqZfe2avqB+AVuEhONytMgaq/SgxA==} + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -1009,7 +1015,7 @@ snapshots: '@discordjs/formatters': 0.6.1 '@discordjs/util': 1.1.1 '@sapphire/shapeshift': 4.0.0 - discord-api-types: 0.38.26 + discord-api-types: 0.38.28 fast-deep-equal: 3.1.3 ts-mixer: 6.0.4 tslib: 2.8.1 @@ -1024,15 +1030,15 @@ snapshots: '@discordjs/util': 1.1.1 '@discordjs/ws': 2.0.3 '@sapphire/snowflake': 3.5.5 - '@vladfrangu/async_event_emitter': 2.4.6 - discord-api-types: 0.38.26 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.28 transitivePeerDependencies: - bufferutil - utf-8-validate '@discordjs/formatters@0.6.1': dependencies: - discord-api-types: 0.38.26 + discord-api-types: 0.38.28 '@discordjs/rest@2.6.0': dependencies: @@ -1040,8 +1046,8 @@ snapshots: '@discordjs/util': 1.1.1 '@sapphire/async-queue': 1.5.5 '@sapphire/snowflake': 3.5.5 - '@vladfrangu/async_event_emitter': 2.4.6 - discord-api-types: 0.38.26 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.28 magic-bytes.js: 1.12.1 tslib: 2.8.1 undici: 6.21.3 @@ -1055,8 +1061,8 @@ snapshots: '@discordjs/util': 1.1.1 '@sapphire/async-queue': 1.5.5 '@types/ws': 8.18.1 - '@vladfrangu/async_event_emitter': 2.4.6 - discord-api-types: 0.38.26 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.28 tslib: 2.8.1 ws: 8.18.3 transitivePeerDependencies: @@ -1070,8 +1076,8 @@ snapshots: '@discordjs/util': 1.1.1 '@sapphire/async-queue': 1.5.5 '@types/ws': 8.18.1 - '@vladfrangu/async_event_emitter': 2.4.6 - discord-api-types: 0.38.26 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.28 tslib: 2.8.1 ws: 8.18.3 transitivePeerDependencies: @@ -1182,70 +1188,70 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@rollup/rollup-android-arm-eabi@4.52.3': + '@rollup/rollup-android-arm-eabi@4.52.4': optional: true - '@rollup/rollup-android-arm64@4.52.3': + '@rollup/rollup-android-arm64@4.52.4': optional: true - '@rollup/rollup-darwin-arm64@4.52.3': + '@rollup/rollup-darwin-arm64@4.52.4': optional: true - '@rollup/rollup-darwin-x64@4.52.3': + '@rollup/rollup-darwin-x64@4.52.4': optional: true - '@rollup/rollup-freebsd-arm64@4.52.3': + '@rollup/rollup-freebsd-arm64@4.52.4': optional: true - '@rollup/rollup-freebsd-x64@4.52.3': + '@rollup/rollup-freebsd-x64@4.52.4': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.52.3': + '@rollup/rollup-linux-arm-gnueabihf@4.52.4': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.52.3': + '@rollup/rollup-linux-arm-musleabihf@4.52.4': optional: true - '@rollup/rollup-linux-arm64-gnu@4.52.3': + '@rollup/rollup-linux-arm64-gnu@4.52.4': optional: true - '@rollup/rollup-linux-arm64-musl@4.52.3': + '@rollup/rollup-linux-arm64-musl@4.52.4': optional: true - '@rollup/rollup-linux-loong64-gnu@4.52.3': + '@rollup/rollup-linux-loong64-gnu@4.52.4': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.52.3': + '@rollup/rollup-linux-ppc64-gnu@4.52.4': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.52.3': + '@rollup/rollup-linux-riscv64-gnu@4.52.4': optional: true - '@rollup/rollup-linux-riscv64-musl@4.52.3': + '@rollup/rollup-linux-riscv64-musl@4.52.4': optional: true - '@rollup/rollup-linux-s390x-gnu@4.52.3': + '@rollup/rollup-linux-s390x-gnu@4.52.4': optional: true - '@rollup/rollup-linux-x64-gnu@4.52.3': + '@rollup/rollup-linux-x64-gnu@4.52.4': optional: true - '@rollup/rollup-linux-x64-musl@4.52.3': + '@rollup/rollup-linux-x64-musl@4.52.4': optional: true - '@rollup/rollup-openharmony-arm64@4.52.3': + '@rollup/rollup-openharmony-arm64@4.52.4': optional: true - '@rollup/rollup-win32-arm64-msvc@4.52.3': + '@rollup/rollup-win32-arm64-msvc@4.52.4': optional: true - '@rollup/rollup-win32-ia32-msvc@4.52.3': + '@rollup/rollup-win32-ia32-msvc@4.52.4': optional: true - '@rollup/rollup-win32-x64-gnu@4.52.3': + '@rollup/rollup-win32-x64-gnu@4.52.4': optional: true - '@rollup/rollup-win32-x64-msvc@4.52.3': + '@rollup/rollup-win32-x64-msvc@4.52.4': optional: true '@sapphire/async-queue@1.5.5': {} @@ -1261,15 +1267,15 @@ snapshots: '@types/estree@1.0.8': {} - '@types/node@24.5.2': + '@types/node@24.7.0': dependencies: - undici-types: 7.12.0 + undici-types: 7.14.0 '@types/ws@8.18.1': dependencies: - '@types/node': 24.5.2 + '@types/node': 24.7.0 - '@vladfrangu/async_event_emitter@2.4.6': {} + '@vladfrangu/async_event_emitter@2.4.7': {} acorn@8.15.0: {} @@ -1345,7 +1351,7 @@ snapshots: dependencies: ms: 2.1.3 - discord-api-types@0.38.26: {} + discord-api-types@0.38.28: {} discord.js@14.22.1: dependencies: @@ -1356,7 +1362,7 @@ snapshots: '@discordjs/util': 1.1.1 '@discordjs/ws': 1.2.3 '@sapphire/snowflake': 3.5.3 - discord-api-types: 0.38.26 + discord-api-types: 0.38.28 fast-deep-equal: 3.1.3 lodash.snakecase: 4.1.1 magic-bytes.js: 1.12.1 @@ -1421,7 +1427,7 @@ snapshots: dependencies: magic-string: 0.30.19 mlly: 1.8.0 - rollup: 4.52.3 + rollup: 4.52.4 foreground-child@3.3.1: dependencies: @@ -1598,32 +1604,32 @@ snapshots: rfdc@1.4.1: {} - rollup@4.52.3: + rollup@4.52.4: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.52.3 - '@rollup/rollup-android-arm64': 4.52.3 - '@rollup/rollup-darwin-arm64': 4.52.3 - '@rollup/rollup-darwin-x64': 4.52.3 - '@rollup/rollup-freebsd-arm64': 4.52.3 - '@rollup/rollup-freebsd-x64': 4.52.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.52.3 - '@rollup/rollup-linux-arm-musleabihf': 4.52.3 - '@rollup/rollup-linux-arm64-gnu': 4.52.3 - '@rollup/rollup-linux-arm64-musl': 4.52.3 - '@rollup/rollup-linux-loong64-gnu': 4.52.3 - '@rollup/rollup-linux-ppc64-gnu': 4.52.3 - '@rollup/rollup-linux-riscv64-gnu': 4.52.3 - '@rollup/rollup-linux-riscv64-musl': 4.52.3 - '@rollup/rollup-linux-s390x-gnu': 4.52.3 - '@rollup/rollup-linux-x64-gnu': 4.52.3 - '@rollup/rollup-linux-x64-musl': 4.52.3 - '@rollup/rollup-openharmony-arm64': 4.52.3 - '@rollup/rollup-win32-arm64-msvc': 4.52.3 - '@rollup/rollup-win32-ia32-msvc': 4.52.3 - '@rollup/rollup-win32-x64-gnu': 4.52.3 - '@rollup/rollup-win32-x64-msvc': 4.52.3 + '@rollup/rollup-android-arm-eabi': 4.52.4 + '@rollup/rollup-android-arm64': 4.52.4 + '@rollup/rollup-darwin-arm64': 4.52.4 + '@rollup/rollup-darwin-x64': 4.52.4 + '@rollup/rollup-freebsd-arm64': 4.52.4 + '@rollup/rollup-freebsd-x64': 4.52.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.52.4 + '@rollup/rollup-linux-arm-musleabihf': 4.52.4 + '@rollup/rollup-linux-arm64-gnu': 4.52.4 + '@rollup/rollup-linux-arm64-musl': 4.52.4 + '@rollup/rollup-linux-loong64-gnu': 4.52.4 + '@rollup/rollup-linux-ppc64-gnu': 4.52.4 + '@rollup/rollup-linux-riscv64-gnu': 4.52.4 + '@rollup/rollup-linux-riscv64-musl': 4.52.4 + '@rollup/rollup-linux-s390x-gnu': 4.52.4 + '@rollup/rollup-linux-x64-gnu': 4.52.4 + '@rollup/rollup-linux-x64-musl': 4.52.4 + '@rollup/rollup-openharmony-arm64': 4.52.4 + '@rollup/rollup-win32-arm64-msvc': 4.52.4 + '@rollup/rollup-win32-ia32-msvc': 4.52.4 + '@rollup/rollup-win32-x64-gnu': 4.52.4 + '@rollup/rollup-win32-x64-msvc': 4.52.4 fsevents: 2.3.3 shebang-command@2.0.0: @@ -1717,7 +1723,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.0(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1): + tsup@8.5.0(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.10) cac: 6.7.14 @@ -1730,14 +1736,14 @@ snapshots: picocolors: 1.1.1 postcss-load-config: 6.0.1(tsx@4.20.6)(yaml@2.8.1) resolve-from: 5.0.0 - rollup: 4.52.3 + rollup: 4.52.4 source-map: 0.8.0-beta.0 sucrase: 3.35.0 tinyexec: 0.3.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: - typescript: 5.9.2 + typescript: 5.9.3 transitivePeerDependencies: - jiti - supports-color @@ -1751,14 +1757,16 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - typescript@5.9.2: {} + typescript@5.9.3: {} ufo@1.6.1: {} - undici-types@7.12.0: {} + undici-types@7.14.0: {} undici@6.21.3: {} + web-features@3.3.0: {} + webidl-conversions@4.0.2: {} whatwg-url@7.1.0: diff --git a/src/commands/docs/baseline.ts b/src/commands/docs/baseline.ts new file mode 100644 index 0000000..79d6542 --- /dev/null +++ b/src/commands/docs/baseline.ts @@ -0,0 +1,127 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + Collection, + EmbedBuilder, + type MessageActionRowComponentBuilder, + StringSelectMenuBuilder, +} from 'discord.js'; +import { features as data } from 'web-features'; +import { fuzzySearch } from '../../util/fuzzy-search.js'; +import type { ProviderConfig } from './types.js'; +import { createBaseConfig, getBaselineFeatures } from './utils.js'; + +export type FeatureData = { + name: string; + kind: 'feature'; + description: string; + status: { + baseline: 'high' | 'low' | false; + support: Record; + }; +}; +type FeatureItem = FeatureData & { key: string }; + +// Prepare baseline features by excluding non-feature entries and converting to array +const features: FeatureItem[] = Object.entries(getBaselineFeatures(data)).map(([key, feature]) => ({ + ...feature, + key, +})); + +const baselines = { + high: { + image: + 'https://web-platform-dx.github.io/web-features/assets/img/baseline-widely-word-dark.png', + description: 'Widely supported', + }, + low: { + image: 'https://web-platform-dx.github.io/web-features/assets/img/baseline-newly-word-dark.png', + description: 'Newly supported', + }, + none: { + image: + 'https://web-platform-dx.github.io/web-features/assets/img/baseline-limited-word-dark.png', + description: 'Not supported', + }, +}; + +const baseConfig = createBaseConfig({ + color: 0x4e_8c_2f, + icon: '', + commandDescription: 'Get baseline support information for web platform features', +}); + +export const baselineProvider: ProviderConfig = { + ...baseConfig, + getFilteredData: (query: string) => { + return fuzzySearch({ + items: features, + query, + findIn: [(feature) => feature.name], + limit: 20, + }); + }, + createCollection: (items) => new Collection(items.map((item) => [item.key, item])), + createActionBuilders: (data) => { + const selectRow = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('baseline-select') + .setPlaceholder('Select one feature') + .setMinValues(1) + .setMaxValues(1) + .addOptions( + ...data.map((feature) => ({ + label: feature.name.length > 100 ? `${feature.name.slice(0, 97)}...` : feature.name, + description: + feature.description.length > 100 + ? `${feature.description.slice(0, 97)}...` + : feature.description, + value: feature.key, + })) + ) + ); + + const buttonRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('Cancel') + .setStyle(ButtonStyle.Danger) + .setCustomId('baseline-cancel') + ); + + return { selectRow, buttonRow }; + }, + createResultEmbeds: (selectedItems) => + selectedItems.map((feature) => { + const support = Object.entries(feature.status.support) + .map(([browser, data]) => `${browser}: **${data}**`) + .join('\n'); + return new EmbedBuilder() + .setTitle(feature.name) + .setColor(baseConfig.color) + .setDescription(` + ${feature.description}\n + ${support} + `) + .setImage( + typeof feature.status.baseline === 'string' + ? baselines[feature.status.baseline].image + : baselines.none.image + ) + .addFields({ + name: 'Baseline status', + value: + typeof feature.status.baseline === 'string' + ? baselines[feature.status.baseline].description + : baselines.none.description, + }) + .setFooter({ + text: 'Powered by web-features (web-platform-dx.github.io/web-features)', + }) + .setTimestamp(); + }), + getDisplayMessage: (selectedTitles) => + `Displaying Result for **${new Intl.ListFormat('en-US').format(selectedTitles)}**:`, + getDisplayTitle: (feature) => feature.name, + getSelectionMessage: (query) => `Select a feature for **${query}**:`, +}; diff --git a/src/commands/docs/index.ts b/src/commands/docs/index.ts index f120800..20b3da7 100644 --- a/src/commands/docs/index.ts +++ b/src/commands/docs/index.ts @@ -1,6 +1,16 @@ import { ApplicationCommandOptionType } from 'discord.js'; import { createCommands } from '../../util/commands.js'; -import { type DocProvider, docProviders, executeDocCommand } from './providers.js'; +import { baselineProvider } from './baseline.js'; +import { mdnProvider } from './mdn.js'; +import { npmProvider } from './npm.js'; +import type { ProviderConfig } from './types.js'; +import { executeDocCommand } from './utils.js'; + +const docProviders = { + mdn: mdnProvider, + npm: npmProvider, + baseline: baselineProvider, +}; export const docsCommands = createCommands( Object.entries(docProviders).map(([providerKey, providerConfig]) => ({ @@ -22,6 +32,7 @@ export const docsCommands = createCommands( }, ], }, - execute: async (interaction) => executeDocCommand(providerKey as DocProvider, interaction), + execute: async (interaction) => + executeDocCommand(providerConfig as ProviderConfig, interaction), })) ); diff --git a/src/commands/docs/mdn.ts b/src/commands/docs/mdn.ts new file mode 100644 index 0000000..a759d3c --- /dev/null +++ b/src/commands/docs/mdn.ts @@ -0,0 +1,87 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + Collection, + EmbedBuilder, + type MessageActionRowComponentBuilder, + StringSelectMenuBuilder, +} from 'discord.js'; +import { CHAIN_EMOJI } from '../../constants/emoji.js'; +import { clampText } from '../../util/text.js'; +import type { ProviderConfig } from './types.js'; +import { createBaseConfig, getSearchUrl, SEARCH_TERM, TERM } from './utils.js'; + +type SearchItem = { + mdn_url: string; + title: string; + slug: string; + summary: string; +}; +type SearchResult = { + documents: SearchItem[]; +}; + +const baseConfig = createBaseConfig({ + color: 0x83_d0_f2, + icon: 'https://avatars0.githubusercontent.com/u/7565578', + directUrl: `https://developer.mozilla.org${TERM}`, + commandDescription: 'Search MDN for documentation on web development topics', +}); + +export const mdnProvider: ProviderConfig = { + ...baseConfig, + getFilteredData: async (query: string) => { + const response = await fetch( + getSearchUrl( + `https://developer.mozilla.org/api/v1/search?q=${SEARCH_TERM}&locale=en-US`, + query + ) + ); + + if (!response.ok) { + throw new Error(`Error fetching MDN data: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as SearchResult; + return data.documents; + }, + createCollection: (items) => new Collection(items.map((item) => [item.slug, item])), + createActionBuilders: (data) => { + const selectRow = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('mdn-select') + .setPlaceholder('Select 1 to 5 results') + .setMinValues(1) + .setMaxValues(Math.min(5, data.size)) + .addOptions( + ...data.map((doc) => ({ + label: doc.title.length > 100 ? `${doc.title.slice(0, 97)}...` : doc.title, + description: doc.summary.length > 100 ? `${doc.summary.slice(0, 97)}...` : doc.summary, + value: doc.slug, + })) + ) + ); + + const buttonRow = new ActionRowBuilder().addComponents( + new ButtonBuilder().setLabel('Cancel').setStyle(ButtonStyle.Danger).setCustomId('mdn-cancel') + ); + + return { selectRow, buttonRow }; + }, + createResultEmbeds: (selectedItems) => + selectedItems.map((doc) => + new EmbedBuilder() + .setTitle(`${CHAIN_EMOJI} ${doc.title}`) + .setURL(baseConfig.directUrl!.replace(TERM, doc.mdn_url)) + .setDescription(clampText(doc.summary, 200)) + .setColor(baseConfig.color) + .setThumbnail(baseConfig.icon) + .setFooter({ text: 'Powered by MDN' }) + .setTimestamp() + ), + getDisplayTitle: (item) => item.title, + getSelectionMessage: (query) => `Select 1 to 5 results for **${query}**:`, + getDisplayMessage: (selectedTitles) => + `Displaying Result for **${new Intl.ListFormat('en-US').format(selectedTitles)}**:`, +}; diff --git a/src/commands/docs/npm.ts b/src/commands/docs/npm.ts new file mode 100644 index 0000000..1461b9c --- /dev/null +++ b/src/commands/docs/npm.ts @@ -0,0 +1,100 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + Collection, + EmbedBuilder, + type MessageActionRowComponentBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, +} from 'discord.js'; +import { CHAIN_EMOJI } from '../../constants/emoji.js'; +import { clampText } from '../../util/text.js'; +import type { ProviderConfig } from './types.js'; +import { createBaseConfig, getSearchUrl, SEARCH_TERM, TERM } from './utils.js'; + +type SearchItem = { + name: string; + version: string; + description: string; + license: string; + links: { + npm: string; + homepage?: string; + repository?: string; + }; +}; + +type SearchResult = { + objects: Array<{ + package: SearchItem; + }>; +}; + +const baseConfig = createBaseConfig({ + color: 0xfb_3e_44, + icon: 'https://avatars0.githubusercontent.com/u/6078720', + directUrl: `https://www.npmjs.com/package/${TERM}`, + commandDescription: 'Search NPM for JavaScript packages', +}); + +export const npmProvider: ProviderConfig = { + ...baseConfig, + getFilteredData: async (query: string) => { + const response = await fetch( + getSearchUrl(`https://registry.npmjs.org/-/v1/search?text=${SEARCH_TERM}&size=10`, query) + ); + + if (!response.ok) { + throw new Error(`Error fetching NPM data: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as SearchResult; + return data.objects.map((obj) => obj.package); + }, + createCollection: (items) => new Collection(items.map((item) => [item.name, item])), + createActionBuilders: (data) => { + const selectRow = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('npm-select') + .setPlaceholder('Select a package') + .setMinValues(1) + .setMaxValues(1) + .addOptions( + ...data.map((pkg) => + new StringSelectMenuOptionBuilder() + .setLabel(pkg.name) + .setDescription(pkg.description) + .setValue(pkg.name) + ) + ) + ); + const buttonRow = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('npm-cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger) + ); + return { selectRow, buttonRow }; + }, + createResultEmbeds: (selectedItems) => + selectedItems.map((pkg) => + new EmbedBuilder() + .setTitle(`${CHAIN_EMOJI} ${pkg.name}`) + .setThumbnail(baseConfig.icon) + .setURL(pkg.links.npm) + .setColor(baseConfig.color) + .setDescription(clampText(pkg.description, 200)) + .setFields( + Object.entries(pkg.links) + .filter(([key, value]) => key !== 'npm' && value !== undefined) + .map(([key, value]) => ({ + name: key, + value, + })) + ) + .setFooter({ text: `Version ${pkg.version} | License: ${pkg.license ?? 'N/A'}` }) + .setTimestamp() + ), + getDisplayTitle: (item) => item.name, + getSelectionMessage: (query) => `Select a package for **${query}**:`, + getDisplayMessage: (selectedTitles) => + `Displaying Result for **${new Intl.ListFormat('en-US').format(selectedTitles)}**:`, +}; diff --git a/src/commands/docs/providers.ts b/src/commands/docs/providers.ts deleted file mode 100644 index 3f022b3..0000000 --- a/src/commands/docs/providers.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - Collection, - type CommandInteraction, - EmbedBuilder, - type MessageActionRowComponentBuilder, - MessageFlags, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, -} from 'discord.js'; -import { logToChannel } from '../../util/channel-logging.js'; - -const SEARCH_TERM = '%SEARCH%'; -const TERM = '%TERM%'; - -export type NPMSearchResult = { - objects: Array<{ - package: { - name: string; - version: string; - description: string; - license: string; - links: { - npm: string; - homepage?: string; - repository?: string; - }; - }; - }>; -}; - -export type MDNSearchResult = { - documents: Array<{ - mdn_url: string; - title: string; - slug: string; - summary: string; - }>; -}; - -export type ActionBuilders = { - selectRow: ActionRowBuilder; - buttonRow: ActionRowBuilder; -}; - -export type DocProvider = 'mdn' | 'npm'; - -export type ProviderData = T extends 'mdn' - ? MDNSearchResult - : T extends 'npm' - ? NPMSearchResult - : never; - -export type ProviderItem = T extends 'mdn' - ? MDNSearchResult['documents'][number] - : T extends 'npm' - ? NPMSearchResult['objects'][number]['package'] - : never; - -export type ProviderKey = T extends 'mdn' - ? MDNSearchResult['documents'][number]['slug'] - : T extends 'npm' - ? NPMSearchResult['objects'][number]['package']['name'] - : never; - -export type DocProviderConfig = { - searchUrl: string; - directUrl?: string; - icon: string; - color: number; - commandDescription: string; - - // Transform raw API response to array of items - transformResponse: (data: ProviderData) => Array>; - - // Create collection from items - createCollection: (items: Array>) => Collection, ProviderItem>; - - // Create action builders (select menu and buttons) - createActionBuilders: (data: Collection, ProviderItem>) => ActionBuilders; - - // Create result embeds to show after selection - createResultEmbeds: ( - data: Collection, ProviderItem> - ) => EmbedBuilder | EmbedBuilder[]; - - // Get display title for an item - getDisplayTitle: (item: ProviderItem) => string; - - // Get selection content message - getSelectionMessage: (query: string) => string; - - // Get display message after selection - getDisplayMessage: (selectedTitles: string[]) => string; -}; - -export type DocProviders = { - [K in DocProvider]: DocProviderConfig; -}; - -export const docProviders: DocProviders = { - mdn: { - color: 0x83_d0_f2, - icon: 'https://avatars0.githubusercontent.com/u/7565578', - searchUrl: `https://developer.mozilla.org/api/v1/search?q=${SEARCH_TERM}&locale=en-US`, - directUrl: `https://developer.mozilla.org${TERM}`, - commandDescription: 'Search MDN for documentation on web development topics', - transformResponse: (data) => data.documents, - createCollection: (items) => new Collection(items.map((item) => [item.slug, item])), - createActionBuilders: (data) => { - const selectRow = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId('mdn-select') - .setPlaceholder('Select 1 to 5 results') - .setMinValues(1) - .setMaxValues(Math.min(5, data.size)) - .addOptions( - ...data.map((doc) => ({ - label: doc.title.length > 100 ? `${doc.title.slice(0, 97)}...` : doc.title, - description: - doc.summary.length > 100 ? `${doc.summary.slice(0, 97)}...` : doc.summary, - value: doc.slug, - })) - ) - ); - - const buttonRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setLabel('Cancel') - .setStyle(ButtonStyle.Danger) - .setCustomId('mdn-cancel') - ); - - return { selectRow, buttonRow }; - }, - createResultEmbeds: (selectedItems) => - selectedItems.map((doc) => - new EmbedBuilder() - .setTitle(doc.title) - .setURL(getDirectUrl('mdn', doc.mdn_url) ?? '') - .setDescription(doc.summary) - .setColor(getColor('mdn')) - .setFooter({ text: 'Powered by MDN' }) - .setTimestamp() - ), - getDisplayTitle: (item) => item.title, - getSelectionMessage: (query) => `Select 1 to 5 results for **${query}**:`, - getDisplayMessage: (selectedTitles) => - `Displaying Result for **${new Intl.ListFormat('en-US').format(selectedTitles)}**:`, - }, - npm: { - color: 0xfb_3e_44, - icon: 'https://avatars0.githubusercontent.com/u/6078720', - searchUrl: `https://registry.npmjs.org/-/v1/search?text=${SEARCH_TERM}&size=10`, - commandDescription: 'Search NPM for JavaScript packages', - transformResponse: (data) => data.objects.map((obj) => obj.package), - createCollection: (items) => new Collection(items.map((item) => [item.name, item])), - createActionBuilders: (data) => { - const selectRow = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId('npm-select') - .setPlaceholder('Select a package') - .setMinValues(1) - .setMaxValues(1) - .addOptions( - ...data.map((pkg) => - new StringSelectMenuOptionBuilder() - .setLabel(pkg.name) - .setDescription(pkg.description) - .setValue(pkg.name) - ) - ) - ); - const buttonRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('npm-cancel') - .setLabel('Cancel') - .setStyle(ButtonStyle.Danger) - ); - return { selectRow, buttonRow }; - }, - createResultEmbeds: (selectedItems) => - selectedItems.map((pkg) => - new EmbedBuilder() - .setTitle(pkg.name) - .setThumbnail(getIconUrl('npm')) - .setURL(pkg.links.npm) - .setColor(getColor('npm')) - .setDescription(pkg.description) - .setFields( - Object.entries(pkg.links) - .filter(([key, value]) => key !== 'npm' && value !== undefined) - .map(([key, value]) => ({ - name: key, - value, - })) - ) - .setFooter({ text: `Version ${pkg.version} | License: ${pkg.license ?? 'N/A'}` }) - .setTimestamp() - ), - getDisplayTitle: (item) => item.name, - getSelectionMessage: (query) => `Select a package for **${query}**:`, - getDisplayMessage: (selectedTitles) => - `Displaying Result for **${new Intl.ListFormat('en-US').format(selectedTitles)}**:`, - }, -}; - -// Utility functions -export const getSearchUrl = (provider: DocProvider, search: string) => - docProviders[provider].searchUrl.replace(SEARCH_TERM, encodeURIComponent(search)); - -export const getDirectUrl = ( - provider: T, - term: string -): DocProviders[T]['directUrl'] => { - const direct = docProviders[provider].directUrl; - if (!direct) return undefined; - return direct.replace(TERM, term); -}; - -export const getIconUrl = (provider: DocProvider): string => docProviders[provider].icon; - -export const getColor = (provider: DocProvider): number => docProviders[provider].color; - -export const executeDocCommand = async ( - provider: T, - interaction: CommandInteraction -): Promise => { - if (!interaction.isChatInputCommand()) return; - - const query = interaction.options.getString('query', true).trim(); - const config = docProviders[provider]; - - try { - const url = getSearchUrl(provider, query); - const response = await fetch(url); - if (!response.ok) { - await interaction.reply({ - content: `Error: ${response.status} ${response.statusText}`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - const data = (await response.json()) as ProviderData; - const items = config.transformResponse(data); - - if (items.length === 0) { - await interaction.reply({ - content: `No results found for "${query}"`, - flags: MessageFlags.Ephemeral, - }); - return; - } - - const collection = config.createCollection(items); - const { selectRow, buttonRow } = config.createActionBuilders(collection); - - const choiceInteraction = await interaction.reply({ - content: config.getSelectionMessage(query), - components: [selectRow, buttonRow], - flags: MessageFlags.Ephemeral, - }); - - const collector = choiceInteraction.createMessageComponentCollector({ - filter: (i) => i.user.id === interaction.user.id, - }); - - collector.once('collect', async (i) => { - if (i.isStringSelectMenu()) { - const selectedSet = new Set(i.values); - const selectedItems = collection.filter((_, key) => selectedSet.has(key)); - const selectedTitles = selectedItems.map(config.getDisplayTitle); - - await interaction.editReply({ - content: config.getDisplayMessage(selectedTitles), - components: [], - }); - - const embeds = config.createResultEmbeds(selectedItems); - - logToChannel({ - channel: interaction.channel, - content: { - type: 'embed', - embed: embeds, - content: interaction.options.getUser('user') - ? `<@${interaction.options.getUser('user')?.id}>` - : undefined, - }, - }); - } else if (i.isButton()) { - await choiceInteraction.delete(); - } - }); - } catch (error) { - console.error('Error executing doc command:', error); - await interaction.reply({ - content: `Error: ${error}`, - flags: MessageFlags.Ephemeral, - }); - } -}; diff --git a/src/commands/docs/types.ts b/src/commands/docs/types.ts new file mode 100644 index 0000000..bccbb24 --- /dev/null +++ b/src/commands/docs/types.ts @@ -0,0 +1,36 @@ +import type { + ActionRowBuilder, + Collection, + EmbedBuilder, + MessageActionRowComponentBuilder, +} from 'discord.js'; + +export type ActionBuilders = { + selectRow: ActionRowBuilder; + buttonRow: ActionRowBuilder; +}; + +export type ProviderConfig = { + color: number; + icon: string; + commandDescription: string; + directUrl?: string; + + getFilteredData: (query: string) => Promise | Item[]; + + createCollection: (items: Array) => Collection; + + createActionBuilders: (data: Collection) => ActionBuilders; + + // Create result embeds to show after selection + createResultEmbeds: (data: Collection) => EmbedBuilder | EmbedBuilder[]; + + // Get display title for an item + getDisplayTitle: (item: Item) => string; + + // Get selection content message + getSelectionMessage: (query: string) => string; + + // Get display message after selection + getDisplayMessage: (selectedTitles: string[]) => string; +}; diff --git a/src/commands/docs/utils.test.ts b/src/commands/docs/utils.test.ts new file mode 100644 index 0000000..b10f6f3 --- /dev/null +++ b/src/commands/docs/utils.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { features } from 'web-features'; +import { getBaselineFeatures, NON_BASELINE_FEATURES } from './utils.js'; + +describe('getBaselineFeatures', () => { + it('should return the correct baseline features when provided with non-features key array', () => { + const originalFeatures = { + 'feature-1': { + name: 'Feature 1', + description: 'Description 1', + status: { support: {}, baseline: 'full' }, + }, + 'feature-2': { + name: 'Feature 2', + description: 'Description 2', + status: { support: {}, baseline: 'partial' }, + }, + 'numeric-seperators': { + name: 'Numeric Separators', + description: 'Description NS', + status: { support: {}, baseline: 'none' }, + }, + 'single-color-gradient': { + name: 'Single Color Gradient', + description: 'Description SCG', + status: { support: {}, baseline: 'none' }, + }, + }; + + const expectedFeatures = { + 'feature-1': { + name: 'Feature 1', + description: 'Description 1', + status: { support: {}, baseline: 'full' }, + }, + 'feature-2': { + name: 'Feature 2', + description: 'Description 2', + status: { support: {}, baseline: 'partial' }, + }, + }; + + const result = getBaselineFeatures(originalFeatures, [ + 'numeric-seperators', + 'single-color-gradient', + ]); + + assert.deepStrictEqual(result, expectedFeatures); + }); + + it('NON_BASELINE_FEATURES should contain the correct features to exclude', () => { + const expectedNonBaselineFeatures = Object.entries(features) + .filter(([, feature]) => feature.kind !== 'feature') + .map(([key]) => key); + + assert.deepStrictEqual(NON_BASELINE_FEATURES, expectedNonBaselineFeatures); + }); +}); diff --git a/src/commands/docs/utils.ts b/src/commands/docs/utils.ts new file mode 100644 index 0000000..1d55053 --- /dev/null +++ b/src/commands/docs/utils.ts @@ -0,0 +1,105 @@ +import { type CommandInteraction, MessageFlags } from 'discord.js'; +import { logToChannel } from '../../util/channel-logging.js'; +import type { FeatureData } from './baseline.js'; +import type { ProviderConfig } from './types.js'; + +export const SEARCH_TERM = '%SEARCH%'; +export const TERM = '%TERM%'; + +// Utility functions +export const getSearchUrl = (url: string, search: string) => { + return url.replace(SEARCH_TERM, encodeURIComponent(search)); +}; + +export const createBaseConfig = (options: { + color: number; + icon: string; + commandDescription: string; + directUrl?: string; +}): Pick => options; + +export const executeDocCommand = async ( + config: ProviderConfig, + interaction: CommandInteraction +): Promise => { + if (interaction.replied || interaction.deferred) { + return; + } + + try { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + } catch (error) { + console.error(`deferReply FAILED:`, error); + return; + } + + if (!interaction.isChatInputCommand()) { + return; + } + + const query = interaction.options.getString('query', true).trim(); + + try { + const items = await config.getFilteredData(query); + + if (items.length === 0) { + await interaction.editReply({ content: `No results found for "${query}"` }); + return; + } + + const collection = config.createCollection(items); + const { selectRow, buttonRow } = config.createActionBuilders(collection); + + const choiceInteraction = await interaction.editReply({ + content: config.getSelectionMessage(query), + components: [selectRow, buttonRow], + }); + + const collector = choiceInteraction.createMessageComponentCollector({ + filter: (componentInteraction) => componentInteraction.user.id === interaction.user.id, + }); + + collector.once('collect', async (componentInteraction) => { + if (componentInteraction.isStringSelectMenu()) { + const selectedSet = new Set(componentInteraction.values); + const selectedItems = collection.filter((_, key) => selectedSet.has(key)); + const selectedTitles = selectedItems.map(config.getDisplayTitle); + + await interaction.editReply({ + content: config.getDisplayMessage(selectedTitles), + components: [], + }); + + const embeds = config.createResultEmbeds(selectedItems); + + logToChannel({ + channel: interaction.channel, + content: { + type: 'embed', + embed: embeds, + content: interaction.options.getUser('user') + ? `<@${interaction.options.getUser('user')?.id}>` + : undefined, + }, + }); + } else if (componentInteraction.isButton()) { + await choiceInteraction.delete(); + } + }); + } catch (error) { + console.error(`executeDocCommand FAILED:`, error); + await interaction.editReply({ content: `Error: ${error}` }); + } +}; + +export const NON_BASELINE_FEATURES = ['numeric-seperators', 'single-color-gradients']; +export const getBaselineFeatures = ( + originalFeatures: Record, + nonFeatureKeys: string[] = NON_BASELINE_FEATURES +): Record => { + const features = { ...originalFeatures }; + for (const nonFeature of nonFeatureKeys) { + delete features[nonFeature]; + } + return features as Record; +}; diff --git a/src/commands/guides/index.ts b/src/commands/guides/index.ts index a02ca39..24e2e67 100644 --- a/src/commands/guides/index.ts +++ b/src/commands/guides/index.ts @@ -4,6 +4,7 @@ import { createCommand } from '../../util/commands.js'; import { loadMarkdownOptions } from '../../util/markdown.js'; const subjectsDir = new URL('./subjects/', import.meta.url); + const subjectChoices = new Map(); const loadChoices = async (): Promise => { @@ -18,8 +19,8 @@ const loadChoices = async (): Promise => { await loadChoices(); -export const guidesCommand = createCommand( - { +export const guidesCommand = createCommand({ + data: { name: 'guides', description: 'Get a guide on a specific subject', type: ApplicationCommandType.ChatInput, @@ -42,8 +43,10 @@ export const guidesCommand = createCommand( }, ], }, - async (interaction) => { - if (!interaction.isChatInputCommand()) return; + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) { + return; + } const subject = interaction.options.getString('subject', true); const user = interaction.options.getUser('user'); if (!subjectChoices.has(subject)) { @@ -69,5 +72,5 @@ export const guidesCommand = createCommand( await interaction.reply({ content: 'Guide sent!', flags: MessageFlags.Ephemeral }); return; - } -); + }, +}); diff --git a/src/commands/ping.ts b/src/commands/ping.ts index d0657d3..4151c72 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -1,12 +1,12 @@ import { createCommand } from '../util/commands.js'; -export const pingCommand = createCommand( - { +export const pingCommand = createCommand({ + data: { name: 'ping', description: 'Replies with Pong!', }, - async (interaction) => { + execute: async (interaction) => { const user = interaction.user; await interaction.reply(`<@${user.id}> Pong!`); - } -); + }, +}); diff --git a/src/commands/tips/index.ts b/src/commands/tips/index.ts index 0970fd1..cb86175 100644 --- a/src/commands/tips/index.ts +++ b/src/commands/tips/index.ts @@ -4,6 +4,7 @@ import { createCommand } from '../../util/commands.js'; import { loadMarkdownOptions } from '../../util/markdown.js'; const subjectsDir = new URL('./subjects/', import.meta.url); + const subjectChoices = new Map(); const loadChoices = async (): Promise => { @@ -18,8 +19,8 @@ const loadChoices = async (): Promise => { await loadChoices(); -const slashCommand = createCommand( - { +const slashCommand = createCommand({ + data: { name: 'tips', description: 'Provide a helpful tip on a given subject', options: [ @@ -40,8 +41,10 @@ const slashCommand = createCommand( }, ], }, - async (interaction) => { - if (!interaction.isChatInputCommand()) return; + execute: async (interaction) => { + if (!interaction.isChatInputCommand()) { + return; + } const subject = interaction.options.getString('subject', true); const user = interaction.options.getUser('user'); @@ -66,17 +69,19 @@ const slashCommand = createCommand( }); await interaction.reply({ content: 'Tip sent!', ephemeral: true }); - } -); + }, +}); const contextMenuCommands = Array.from(subjectChoices).map(([key, value]) => - createCommand( - { + createCommand({ + data: { type: ApplicationCommandType.Message, name: `Tip: ${key}`, }, - async (interaction) => { - if (!interaction.isMessageContextMenuCommand()) return; + execute: async (interaction) => { + if (!interaction.isMessageContextMenuCommand()) { + return; + } const message = interaction.targetMessage; await interaction.reply({ content: 'Fetching tip...', flags: MessageFlags.Ephemeral }); @@ -88,8 +93,8 @@ const contextMenuCommands = Array.from(subjectChoices).map(([key, value]) => await interaction.editReply({ content: 'Tip sent!' }); return; - } - ) + }, + }) ); export const tipsCommands = [slashCommand, ...contextMenuCommands]; diff --git a/src/constants/emoji.ts b/src/constants/emoji.ts new file mode 100644 index 0000000..3a9a6c6 --- /dev/null +++ b/src/constants/emoji.ts @@ -0,0 +1 @@ +export const CHAIN_EMOJI = '🔗'; diff --git a/src/events/has-var.ts b/src/events/has-var.ts index 951ee1a..64447d8 100644 --- a/src/events/has-var.ts +++ b/src/events/has-var.ts @@ -43,9 +43,9 @@ export const hasVarEvent = createEvent( once: false, }, async (message) => { - if (message.author.bot) return; - - if (!canRun()) return; + if (message.author.bot || !canRun()) { + return; + } const codeBlocks = Array.from(message.content.match(codeBlockRegex) || []); diff --git a/src/events/just-ask.ts b/src/events/just-ask.ts index 8029c52..331859d 100644 --- a/src/events/just-ask.ts +++ b/src/events/just-ask.ts @@ -38,10 +38,14 @@ export const justAskEvent = createEvent( name: Events.MessageCreate, }, async (message) => { - if (!canRun()) return; - if (message.author.bot) return; + if (!canRun() || message.author.bot) { + return; + } - if (message.content.split(' ').length > 10) return; + // Ignore long messages, likely user provided more context + if (message.content.split(' ').length > 10) { + return; + } if (isAskingToAsk(message.content)) { await message.reply({ diff --git a/src/util/commands.ts b/src/util/commands.ts index 219f806..2985b40 100644 --- a/src/util/commands.ts +++ b/src/util/commands.ts @@ -1,24 +1,12 @@ -import type { - Client, - CommandInteraction, - RESTPostAPIApplicationCommandsJSONBody, -} from 'discord.js'; +import type { Client } from 'discord.js'; import type { Command } from '../commands/types.js'; -export const createCommand = ( - data: RESTPostAPIApplicationCommandsJSONBody, - execute: (interaction: CommandInteraction) => Promise | void -): Command => { - return { data, execute } satisfies Command; +export const createCommand = (command: Command): Command => { + return command; }; -export const createCommands = ( - commands: Array<{ - data: RESTPostAPIApplicationCommandsJSONBody; - execute: (interaction: CommandInteraction) => Promise | void; - }> -): Command[] => { - return commands.map(({ data, execute }) => createCommand(data, execute)); +export const createCommands = (commands: Array): Command[] => { + return commands.map(createCommand); }; export const registerCommands = async ( diff --git a/src/util/events.ts b/src/util/events.ts index 3636182..74af4ce 100644 --- a/src/util/events.ts +++ b/src/util/events.ts @@ -8,7 +8,7 @@ export const createEvent = ( }, execute: (...args: ClientEvents[T]) => Promise | void ): DiscordEvent => { - return { ...data, execute } satisfies DiscordEvent; + return { ...data, execute }; }; export const createEvents = ( diff --git a/src/util/fuzzy-search.ts b/src/util/fuzzy-search.ts new file mode 100644 index 0000000..1ecffb1 --- /dev/null +++ b/src/util/fuzzy-search.ts @@ -0,0 +1,115 @@ +export const levenshtein = (a: string, b: string) => { + const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0)); + + for (let i = 0; i <= a.length; i++) { + dp[i][0] = i; + } + for (let j = 0; j <= b.length; j++) { + dp[0][j] = j; + } + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + dp[i][j] = Math.min( + dp[i - 1][j] + 1, // deletion + dp[i][j - 1] + 1, // insertion + dp[i - 1][j - 1] + cost // substitution + ); + } + } + + return dp[a.length][b.length]; +}; + +const bestSubstringDistance = (query: string, text: string): number => { + const queryLen = query.length; + const textLen = text.length; + let minDistance = Infinity; + + // The range of substring lengths to check (e.g., query length +/- 3 characters) + const range = 3; + + // Iterate over all possible starting points in the text + for (let i = 0; i < textLen; i++) { + // Iterate over possible ending points, limiting the substring length + for ( + let j = Math.max(i + queryLen - range, i + 1); + j <= Math.min(i + queryLen + range, textLen); + j++ + ) { + const sub = text.substring(i, j); + const distance = levenshtein(query, sub); + minDistance = Math.min(minDistance, distance); + } + } + + return minDistance; +}; + +export function fuzzySearch({ + query, + items, + findIn, + limit = 10, +}: { + query?: string; + items: T[]; + findIn: Array<(item: T) => string>; + limit?: number; +}) { + if (!query || query.trim() === '') { + return items.slice(0, limit); + } + + const threshold = 0.3; // minimum score to consider a match + const EXACT_MATCH_BOOST = 100; // Large boost for perfect match + const PREFIX_BOOST = 10; // Smaller boost for prefix match + + query = query.trim().toLowerCase(); + const queryLen = query.length; + + const scored = items.map((item) => { + let maxFuzzyScore = 0; + let titleMatchScore = 0; + + // 1. Calculate Fuzzy Score (Max Levenshtein Score from all keys) + const scores = findIn.map((fn) => { + const text = fn(item).toLowerCase(); + + // Apply a large, non-normalized boost for exact or prefix matches + if (text === query) { + titleMatchScore = EXACT_MATCH_BOOST; // Query "array" matches title "Array" + } else if (titleMatchScore < EXACT_MATCH_BOOST && text.startsWith(query)) { + // If not a perfect match, check for prefix match + titleMatchScore = Math.max(titleMatchScore, PREFIX_BOOST); + } else if (titleMatchScore === 0 && text.includes(` ${query} `)) { + // A minor boost if it's a whole word in the middle (optional) + titleMatchScore = Math.max(titleMatchScore, 1); + } + + // Fuzzy match using normalized Levenshtein distance + const distance = bestSubstringDistance(query, text); + const fuzzyScore = 1 - distance / queryLen; // normalized to 0–1 + return fuzzyScore; + }); + + maxFuzzyScore = Math.max(...scores); + + // Combine Scores + // The final score is the max fuzzy score plus the title match boost. + // The high boost (100) ensures it ranks above any normalized fuzzy score (max 1.0). + const finalScore = maxFuzzyScore + titleMatchScore; + + return { item, score: finalScore }; + }); + + return ( + scored + // Filter by the original fuzzy score threshold (optional, you could use a lower threshold) + .filter((s) => s.score >= threshold) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((s) => s.item) + ); +} diff --git a/src/util/text.ts b/src/util/text.ts new file mode 100644 index 0000000..04360f5 --- /dev/null +++ b/src/util/text.ts @@ -0,0 +1,6 @@ +export const clampText = (text: string, maxLength: number): string => { + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength - 3)}...`; +};