diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a7ea30 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a8b6c69 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Server Port (e.g., 9000) +PORT=9000 + +# GitHub Personal Access Token for API requests (replace with your token) +GITHUB_TOKEN=your_github_token_here + +# Cache duration (in seconds) for generated SVG files (e.g., 14400 seconds = 4 hours) +CACHE_MAX_AGE=14400 + +# Default delay (in milliseconds) between GitHub API requests when retrying (e.g., 1000ms) +DEFAULT_GITHUB_RETRY_DELAY=1000 + +# Maximum number of retry attempts for GitHub API calls (e.g., 3) +DEFAULT_GITHUB_MAX_RETRY=3 + +# URL for retrieving Devicon assets (ensure it ends with a slash) +DEVICON_URL='https://raw.githubusercontent.com/devicons/devicon/master/icons/' + +# URL to fetch GitHub Linguist language definitions +LINGUIST_GITHUB='https://raw.githubusercontent.com/github/linguist/main/lib/linguist/languages.yml' + +# Base URL for language mappings file location +LANGUAGE_MAPPINGS_URL='https://raw.githubusercontent.com/teociaps/github-bubble-chart/main/' + +# Node Environment (set to 'dev' for local development and 'prod' for production) +NODE_ENV=dev diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c1dd1fc..0c27d6b 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,4 @@ # These are supported funding model platforms github: [teociaps] -custom: ["https://paypal.me/teociaps"] \ No newline at end of file +custom: ['https://paypal.me/teociaps'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c15ee5f..fb4bfa0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: 🐞 Bug Report description: Report an anomaly or unexpected behavior from this repository. -title: "[Bug]: " +title: '[Bug]: ' labels: ['needs: triage', 'bug'] body: @@ -64,4 +64,4 @@ body: description: Please provide any relevant logs or screenshots to help us understand the issue. placeholder: Attach logs or screenshots here. validations: - required: false \ No newline at end of file + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index a49eab2..0086358 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1 +1 @@ -blank_issues_enabled: true \ No newline at end of file +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 1845d6c..4e3a94b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: 💡 Feature Request description: Suggest a new feature/enhancement for this project. -title: "[Feature]: " +title: '[Feature]: ' labels: ['enhancement'] body: @@ -51,4 +51,4 @@ body: - Medium - Low validations: - required: true \ No newline at end of file + required: true diff --git a/.github/config/labels.yml b/.github/config/labels.yml new file mode 100644 index 0000000..6e87793 --- /dev/null +++ b/.github/config/labels.yml @@ -0,0 +1,21 @@ +documentation: + - changed-files: + - any-glob-to-any-file: + - README.md + - CONTRIBUTING.md + - CODE_OF_CONDUCT.md + - SECURITY.md + +dependencies: + - changed-files: + - any-glob-to-any-file: + - package.json + - yarn.lock + +configuration: + - changed-files: + - any-glob-to-any-file: + - .eslintrc* + - .prettierrc* + - .editorconfig + - .stylelintrc* diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bb1c99e..ac9405c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,32 +1,32 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "weekly" + interval: 'weekly' commit-message: - prefix: "npm deps update" - include: "scope" + prefix: 'npm deps update' + include: 'scope' open-pull-requests-limit: 10 assignees: - - "teociaps" + - 'teociaps' reviewers: - - "teociaps" + - 'teociaps' labels: - - "dependencies" - - "npm" - - package-ecosystem: "github-actions" - directory: "/" + - 'dependencies' + - 'npm' + - package-ecosystem: 'github-actions' + directory: '/' schedule: - interval: "monthly" + interval: 'monthly' commit-message: - prefix: "GitHub Actions update" - include: "scope" + prefix: 'GitHub Actions update' + include: 'scope' open-pull-requests-limit: 10 assignees: - - "teociaps" + - 'teociaps' reviewers: - - "teociaps" + - 'teociaps' labels: - - "dependencies" - - "github-actions" + - 'dependencies' + - 'github-actions' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c26bcf5..ddcb6b9 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,9 @@ # Pull Request Template ## **Checklist** + Before submitting your PR, confirm the following: + - [ ] Commit messages follow the [contribution guidelines](https://github.com/teociaps/github-bubble-chart/blob/main/CONTRIBUTING.md). - [ ] Tests are added or updated to cover all changes. - [ ] Documentation is updated (e.g., README, inline comments, external docs). @@ -10,32 +12,38 @@ Before submitting your PR, confirm the following: --- ## **Summary** + Provide a concise summary of this PR, including its purpose, the problem it solves, and the key changes made. --- ## **Change Type** + Select the type(s) of change included in this PR: + - [ ] Bug fix - [ ] New feature - [ ] Documentation update - [ ] Refactor (non-breaking changes) - [ ] Maintenance / Chores -- [ ] Breaking change +- [ ] Breaking change If this PR introduces a breaking change, describe the impact and required migration steps: --- ## **Linked Issues** + List any related issues or feature requests (e.g., Fixes #123). --- ## **Screenshots/Logs (Optional)** + Include relevant screenshots, logs, or other visuals, if applicable. --- ## **Additional Notes** + Provide any additional information or feedback requests for reviewers. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 555b5d0..772ee95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,16 +29,16 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' - - name: Install dependencies - run: yarn install --frozen-lockfile + - name: Install dependencies + run: yarn install --frozen-lockfile - - name: Build the app - run: yarn build + - name: Build the app + run: yarn build diff --git a/.github/workflows/close-stale-issues.yml b/.github/workflows/close-stale-issues.yml index 1fef103..c33f070 100644 --- a/.github/workflows/close-stale-issues.yml +++ b/.github/workflows/close-stale-issues.yml @@ -1,7 +1,7 @@ name: Close inactive issues on: schedule: - - cron: "0 0 * * *" + - cron: '0 0 * * *' jobs: close-issues: @@ -10,14 +10,13 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v9.0.0 + - uses: actions/stale@v9.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-issue-stale: 30 days-before-issue-close: 14 - stale-issue-label: "stale" - stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." - close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." + stale-issue-label: 'stale' + stale-issue-message: 'This issue is stale because it has been open for 30 days with no activity.' + close-issue-message: 'This issue was closed because it has been inactive for 14 days since being marked as stale.' days-before-pr-stale: -1 days-before-pr-close: -1 - diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..74024f5 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,66 @@ +name: Deploy to Vercel + +on: + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy to' + required: true + default: 'Preview' + type: choice + options: + - Production + - Preview + tag: + description: 'Tag for the release (Production only). This will be ignored for Preview.' + required: true + default: '1.0.0' + +permissions: + contents: write + actions: read + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Tag Release (Production Only) + if: ${{ inputs.environment == 'Production' }} + run: | + TAG=${{ inputs.tag }} + # Check if the tag already exists + if git tag -l "v$TAG" | grep -q "v$TAG"; then + echo "Error: Tag v$TAG already exists." + exit 1 + fi + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git tag v$TAG + git push origin --tags + + - name: Deploy to Vercel Production + if: ${{ inputs.environment == 'Production' }} + uses: amondnet/vercel-action@v25.2.0 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + vercel-version: 41 + vercel-args: '--prod --yes' + working-directory: . + github-deployment: true + + - name: Deploy to Vercel Preview + if: ${{ inputs.environment == 'Preview' }} + uses: amondnet/vercel-action@v25.2.0 + with: + vercel-token: ${{ secrets.VERCEL_TOKEN }} + vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} + vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} + vercel-version: 41 + vercel-args: '--yes' + working-directory: . + github-deployment: true diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..7762a8d --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,17 @@ +name: Add labels to issues and pull requests + +on: + pull_request: + types: [opened] + issues: + types: [opened] + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Add labels to issues and pull requests + uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/config/labels.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..de14b46 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,22 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '0 0 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - name: Mark stale issues and pull requests + uses: actions/stale@v9.1.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.' + stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.' + days-before-stale: 30 + days-before-close: 7 + stale-issue-label: 'stale' + stale-pr-label: 'stale' + exempt-issue-labels: 'enhancement,bug,help wanted' + exempt-pr-labels: 'wip' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90b82fe..924dada 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,26 +1,80 @@ -name: Test +name: Test and Lint on: workflow_run: - workflows: ["CI"] + workflows: ['CI'] types: - completed + pull_request: + branches: + - main + paths-ignore: + - '**/*.png' + - '**/*.jpg' + - '**/*.jpeg' + - '**/*.gif' + - '**/*.svg' jobs: test: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' - - name: Install dependencies - run: yarn install --frozen-lockfile + - name: Install dependencies + run: yarn install --frozen-lockfile - - name: Run tests - run: yarn test + - name: Run tests with coverage + run: yarn test:coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5.3.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ./coverage + files: '**/coverage-final.json' + flags: unittests + fail_ci_if_error: true + + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run linter + run: yarn lint + + prettier: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run Prettier check + run: yarn format:check diff --git a/.github/workflows/update-language-mappings.yml b/.github/workflows/update-language-mappings.yml index f7b234c..3d48419 100644 --- a/.github/workflows/update-language-mappings.yml +++ b/.github/workflows/update-language-mappings.yml @@ -17,6 +17,7 @@ jobs: env: DEVICON_URL: ${{ secrets.DEVICON_URL }} LINGUIST_GITHUB: ${{ secrets.LINGUIST_GITHUB }} + CHANGES: false steps: - name: Checkout repository @@ -39,14 +40,14 @@ jobs: echo "Checking for changes in src/languageMappings.json..." if [[ -n "$(git status --porcelain src/languageMappings.json)" ]]; then echo "Changes detected." - echo "changes=true" >> $GITHUB_ENV + echo "CHANGES=true" >> $GITHUB_ENV else echo "No changes detected." - echo "changes=false" >> $GITHUB_ENV + echo "CHANGES=false" >> $GITHUB_ENV fi - name: Commit and push changes - if: env.changes == 'true' + if: env.CHANGES == 'true' run: | git config user.name 'github-actions[bot]' git config user.email 'github-actions[bot]@users.noreply.github.com' @@ -56,7 +57,7 @@ jobs: git push origin HEAD:refs/heads/update-language-mappings --force - name: Create Pull Request - if: env.changes == 'true' + if: env.CHANGES == 'true' uses: peter-evans/create-pull-request@v7 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e69ab98..32fcf59 100644 --- a/.gitignore +++ b/.gitignore @@ -493,3 +493,4 @@ MigrationBackup/ # Vercel .vercel +.vercel/cache/ diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..3723623 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..77050f3 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,36 @@ +# Ignore node_modules +node_modules/ + +# Ignore build output directories +dist/ +out/ +.next/ +.nuxt/ +.cache/ +.parcel-cache/ +.svelte-kit/ +.webpack/ +.docusaurus/ +.serverless/ +.fusebox/ +.dynamodb/ +.vscode-test/ +.yarn/ +.pnp.* + +# Ignore coverage output +coverage/ + +# Ignore Vercel directories +.vercel/ +.vercel/cache/ + +# Ignore other unnecessary directories +AppPackages/ +BundleArtifacts/ +Generated_Code/ +ClientBin/ +.localhistory/ +.mfractor/ +.ionide/ +.husky/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..d909c93 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 2, + "useTabs": false +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..d7df89c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1832d1e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + // "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 9286c70..617dce6 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,4 +1,3 @@ - # Contributor Covenant Code of Conduct ## Our Pledge @@ -18,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or advances of +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf4982d..586d0a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,7 @@ Here are some ways you can contribute: ### Reporting Bugs If you find a bug, please open an issue and include as much detail as possible, including: + - Steps to reproduce the issue - Expected and actual behavior - Relevant screenshots, logs, or error messages @@ -83,4 +84,3 @@ If you have any questions or need assistance: - Reach out by opening an [issue](../../issues) with a question label. Thank you for contributing! - diff --git a/README.md b/README.md index 68ced6a..89e374e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # github-bubble-chart + Insert a bubble chart into your GitHub profile readme. Which makes for great [github profile readme](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-profile/managing-your-profile-readme). Example teociaps: ![teociaps](https://github-bubble-chart.vercel.app?username=teociaps&title-size=34&title-color=red&legend=true&legend-align=left&percentages=true) ----- +--- Example Test: diff --git a/api/default.ts b/api/default.ts index 5540345..f51fddd 100644 --- a/api/default.ts +++ b/api/default.ts @@ -1,11 +1,18 @@ +import { Request, Response } from 'express'; +import { + defaultHeaders, + fetchConfigFromRepo, + handleMissingUsername, + parseParams, + handleErrorResponse, +} from './utils.js'; import { CONSTANTS } from '../config/consts.js'; -import { defaultHeaders, fetchConfigFromRepo, handleMissingUsername, parseParams, handleErrorResponse } from './utils.js'; import { createBubbleChart } from '../src/chart/generator.js'; import { BubbleChartOptions } from '../src/chart/types/chartOptions.js'; import { getBubbleData } from '../src/chart/utils.js'; import { SVGGenerationError } from '../src/errors/custom-errors.js'; -export default async (req: any, res: any) => { +export default async (req: Request, res: Response): Promise => { const params = parseParams(req); const username = params.get('username'); const configBranch = params.get('config-branch') || undefined; @@ -22,7 +29,11 @@ export default async (req: any, res: any) => { let bubbleData; if (mode === 'custom-config' && configPath) { - const config = await fetchConfigFromRepo(username, configPath, configBranch); + const config = await fetchConfigFromRepo( + username, + configPath, + configBranch, + ); options = config.options; bubbleData = config.data; } else { @@ -30,7 +41,8 @@ export default async (req: any, res: any) => { width: params.getNumberValue('width', 600), height: params.getNumberValue('height', 400), titleOptions: params.parseTitleOptions(), - showPercentages: params.getBooleanValue('percentages', false), + displayValues: params.getValuesDisplayOption('display-values'), + usePercentages: true, legendOptions: params.parseLegendOptions(), theme: params.getTheme('theme', CONSTANTS.DEFAULT_THEME), }; @@ -42,7 +54,9 @@ export default async (req: any, res: any) => { const svg = await createBubbleChart(bubbleData, options); if (!svg) { - throw new SVGGenerationError('SVG generation failed: No data available or invalid configuration.'); + throw new SVGGenerationError( + 'SVG generation failed: No data available or invalid configuration.', + ); } res.setHeaders(defaultHeaders); diff --git a/api/index.ts b/api/index.ts index b18040b..d6d9f07 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,5 +1,6 @@ -import express, { Application } from 'express'; import dotenv from 'dotenv'; +import express, { Application } from 'express'; +import rateLimit from 'express-rate-limit'; import api from './default.js'; dotenv.config(); @@ -7,10 +8,17 @@ dotenv.config(); const PORT = process.env.PORT || 9000; const app: Application = express(); -app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); +const isDebugMode = process.env.NODE_ENV === 'development'; + +const rateLimiter = rateLimit({ + windowMs: 60 * 1000, + max: isDebugMode ? Number.MAX_SAFE_INTEGER : 60, + headers: false, + message: 'Too many requests from this IP, please try again after a minute', }); +app.set('trust proxy', 1); +app.get('/', rateLimiter, api); -app.get('/', api); +app.listen(PORT); -export default app; \ No newline at end of file +export default app; diff --git a/api/utils.ts b/api/utils.ts index 131a62b..cb18899 100644 --- a/api/utils.ts +++ b/api/utils.ts @@ -1,127 +1,125 @@ +import { Request, Response } from 'express'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { CONSTANTS } from '../config/consts.js'; import { ThemeBase, themeMap } from '../src/chart/themes.js'; import { BubbleData } from '../src/chart/types/bubbleData.js'; -import { TextAnchor, TitleOptions, LegendOptions, TextAlign, BubbleChartOptions } from '../src/chart/types/chartOptions.js'; +import { + TextAnchor, + TitleOptions, + LegendOptions, + TextAlign, + BubbleChartOptions, + DisplayMode, +} from '../src/chart/types/chartOptions.js'; import { CustomConfig, Mode } from '../src/chart/types/config.js'; -import { GitHubNotFoundError, GitHubRateLimitError } from '../src/errors/github-errors.js'; -import { ValidationError, FetchError, MissingUsernameError } from '../src/errors/custom-errors.js'; -import { isDevEnvironment, mapConfigToBubbleChartOptions } from '../src/common/utils.js'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import { + isDevEnvironment, + mapConfigToBubbleChartOptions, +} from '../src/common/utils.js'; import { BaseError } from '../src/errors/base-error.js'; +import { + ValidationError, + FetchError, + MissingUsernameError, +} from '../src/errors/custom-errors.js'; +import { + GitHubNotFoundError, + GitHubRateLimitError, +} from '../src/errors/github-errors.js'; +import logger from '../src/logger.js'; export class CustomURLSearchParams extends URLSearchParams { getStringValue(key: string, defaultValue: string): string { - try { - if (super.has(key)) { - const param = super.get(key); - if (param !== null) { - return param.toString(); - } + if (super.has(key)) { + const param = super.get(key); + if (param !== null) { + return param.toString(); } - return defaultValue.toString(); - } catch (error) { - throw new ValidationError('Invalid string parameter.', error instanceof Error ? error : undefined); } + return defaultValue.toString(); } getNumberValue(key: string, defaultValue: number): number { - try { - if (super.has(key)) { - const param = super.get(key); - if (param !== null) { - const parsedValue = parseInt(param); - if (isNaN(parsedValue)) { - return defaultValue; - } + if (super.has(key)) { + const param = super.get(key); + if (param !== null) { + const parsedValue = parseInt(param); + if (!isNaN(parsedValue)) { return parsedValue; } } - return defaultValue; - } catch (error) { - throw new ValidationError('Invalid number parameter.', error instanceof Error ? error : undefined); } + return defaultValue; } getBooleanValue(key: string, defaultValue: boolean): boolean { - try { - if (super.has(key)) { - const param = super.get(key); - return param !== null && param.toString() === 'true'; - } - return defaultValue; - } catch (error) { - throw new ValidationError('Invalid boolean parameter.', error instanceof Error ? error : undefined); + if (super.has(key)) { + const param = super.get(key); + return param !== null && param.toString() === 'true'; } + return defaultValue; } getTheme(key: string, defaultValue: ThemeBase): ThemeBase { - try { - if (super.has(key)) { - const param = super.get(key); - if (param !== null) { - return themeMap[param.toLowerCase()] || defaultValue; - } + if (super.has(key)) { + const param = super.get(key); + if (param !== null) { + return themeMap[param.toLowerCase()] || defaultValue; } - return defaultValue; - } catch (error) { - throw new ValidationError('Invalid theme parameter.', error instanceof Error ? error : undefined); } + return defaultValue; } getTextAnchorValue(key: string, defaultValue: TextAnchor): TextAnchor { - try { - const value = this.getStringValue(key, defaultValue); - switch (value) { - case 'left': - return 'start'; - case 'center': - return 'middle'; - case 'right': - return 'end'; - default: - return defaultValue; - } - } catch (error) { - throw new ValidationError('Invalid text anchor parameter.', error instanceof Error ? error : undefined); + const value = this.getStringValue(key, defaultValue); + switch (value) { + case 'left': + return 'start'; + case 'center': + return 'middle'; + case 'right': + return 'end'; + default: + return defaultValue; } } - getLanguagesCount(defaultValue: number) { - try { - const value = this.getNumberValue('langs-count', defaultValue); - if (value < 1) return 1; - if (value > 20) return 20; - return value; - } catch (error) { - throw new ValidationError('Invalid languages count parameter.', error instanceof Error ? error : undefined); + getLanguagesCount(defaultValue: number): number { + const value = this.getNumberValue('langs-count', defaultValue); + if (value < 1) return 1; + if (value > 20) return 20; + return value; + } + + getValuesDisplayOption(key: string): DisplayMode { + const defaultValue: DisplayMode = 'legend'; + const value = this.getStringValue(key, defaultValue); + if (['all', 'legend', 'bubbles', 'none'].includes(value)) { + return value as DisplayMode; } + return defaultValue; } parseTitleOptions(): TitleOptions { - try { - return { - text: this.getStringValue('title', 'Bubble Chart'), - fontSize: this.getNumberValue('title-size', 24) + 'px', - fontWeight: this.getStringValue('title-weight', 'bold'), - fill: this.getStringValue('title-color', this.getTheme('theme', CONSTANTS.DEFAULT_THEME).textColor), - textAnchor: this.getTextAnchorValue('title-align', 'middle') - }; - } catch (error) { - throw new ValidationError('Invalid title options.', error instanceof Error ? error : undefined); - } + return { + text: this.getStringValue('title', 'Bubble Chart'), + fontSize: this.getNumberValue('title-size', 24) + 'px', + fontWeight: this.getStringValue('title-weight', 'bold'), + fill: this.getStringValue( + 'title-color', + this.getTheme('theme', CONSTANTS.DEFAULT_THEME).textColor, + ), + textAnchor: this.getTextAnchorValue('title-align', 'middle'), + }; } parseLegendOptions(): LegendOptions { - try { - return { - show: this.getBooleanValue('legend', true), - align: this.getStringValue('legend-align', 'center') as TextAlign, - }; - } catch (error) { - throw new ValidationError('Invalid legend options.', error instanceof Error ? error : undefined); - } + return { + show: this.getBooleanValue('legend', true), + align: this.getStringValue('legend-align', 'center') as TextAlign, + }; } getMode(): Mode { @@ -145,8 +143,10 @@ export const defaultHeaders = new Headers({ 'Cache-Control': `public, max-age=${CONSTANTS.CACHE_MAX_AGE}`, }); -export async function handleMissingUsername(req: any, res: any) { - console.log('missing username'); +export async function handleMissingUsername( + req: Request, + res: Response, +): Promise { let protocol = req.protocol; if (!isDevEnvironment() && protocol === 'http') { protocol = 'https'; @@ -154,57 +154,88 @@ export async function handleMissingUsername(req: any, res: any) { const url = new URL(req.url, `${protocol}://${req.get('host')}`); const base = `${url.origin}${req.baseUrl}`; const error = new MissingUsernameError(base); - res.send(error.render()); + handleErrorResponse(error, res); } -export async function fetchConfigFromRepo(username: string, filePath: string, branch?: string): Promise<{ options: BubbleChartOptions, data: BubbleData[] }> { - try { - const processConfig = (customConfig: CustomConfig) => { - const options = mapConfigToBubbleChartOptions(customConfig.options); - customConfig.data.forEach(d => d.name = d.name); - return { options: options, data: customConfig.data }; - }; - - if (isDevEnvironment()) { - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); - const localPath = path.resolve(__dirname, '../example-config.json'); - if (fs.existsSync(localPath)) { - const customConfig = JSON.parse(fs.readFileSync(localPath, 'utf-8')) as CustomConfig; +export async function fetchConfigFromRepo( + username: string, + filePath: string, + branch?: string, +): Promise<{ options: BubbleChartOptions; data: BubbleData[] }> { + const processConfig = ( + customConfig: CustomConfig, + ): { options: BubbleChartOptions; data: BubbleData[] } => { + const options = mapConfigToBubbleChartOptions(customConfig.options); + customConfig.data = customConfig.data.filter( + (dataItem) => + typeof dataItem.value === 'number' && !isNaN(dataItem.value), + ); + return { options: options, data: customConfig.data }; + }; + + if (isDevEnvironment()) { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const localPath = path.resolve(__dirname, '../example-config.json'); + if (fs.existsSync(localPath)) { + try { + const customConfig = JSON.parse( + fs.readFileSync(localPath, 'utf-8'), + ) as CustomConfig; return processConfig(customConfig); - } else { - throw new FetchError(`Local config file not found at ${localPath}`); + } catch (error) { + throw new ValidationError( + 'Failed to parse local JSON configuration.', + error instanceof Error ? error : undefined, + ); } } else { - const url = `https://raw.githubusercontent.com/${username}/${username}/${branch || 'main'}/${filePath}`; - const response = await fetch(url, { - headers: { - Authorization: `token ${CONSTANTS.GITHUB_TOKEN}` - } - }); - if (!response.ok) { - if (response.status === 404) { - throw new GitHubNotFoundError(`The repository or file at ${filePath} was not found.`); - } else if (response.status === 403 && response.headers.get('X-RateLimit-Remaining') === '0') { - throw new GitHubRateLimitError('You have exceeded the GitHub API rate limit.'); - } else { - throw new FetchError(`Failed to fetch config from ${filePath} in ${username} repository`, new Error(`HTTP status ${response.status}`)); - } + throw new FetchError(`Local config file not found at ${localPath}`); + } + } else { + const url = `https://raw.githubusercontent.com/${username}/${username}/${branch || 'main'}/${filePath}`; + const response = await fetch(url, { + headers: { + Authorization: `token ${CONSTANTS.GITHUB_TOKEN}`, + }, + }); + if (!response.ok) { + if (response.status === 404) { + throw new GitHubNotFoundError( + `The repository or file at ${filePath} was not found.`, + ); + } else if ( + response.status === 403 && + response.headers.get('X-RateLimit-Remaining') === '0' + ) { + throw new GitHubRateLimitError(); + } else { + throw new FetchError( + `Failed to fetch config from ${filePath} in ${username} repository`, + new Error(`HTTP status ${response.status}`), + ); } + } - const customConfig = await response.json() as CustomConfig; + try { + const customConfig = (await response.json()) as CustomConfig; return processConfig(customConfig); + } catch (error) { + throw new ValidationError( + 'Failed to parse fetched JSON configuration.', + error instanceof Error ? error : undefined, + ); } - } catch (error) { - throw new FetchError('Failed to fetch configuration from repository', error instanceof Error ? error : undefined); } } -export function handleErrorResponse(error: Error | undefined, res: any) { - console.error(error); +export function handleErrorResponse( + error: Error | undefined, + res: Response, +): void { + logger.error(error); if (error instanceof BaseError) { res.status(error.status).send(error.render()); - // res.status(error.status).send({ error: error.message }); } else { res.status(500).send({ error: 'An unexpected error occurred' }); } diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..4632b00 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,22 @@ +codecov: + require_ci_to_pass: true + notify: + wait_for_ci: true + +coverage: + precision: 2 + round: down + range: 80...100 + status: + project: + default: + target: auto + threshold: 2% + patch: + default: + enabled: off + +comment: + require_changes: true + layout: 'condensed_header, condensed_files, condensed_footer' + hide_project_coverage: true diff --git a/config/consts.ts b/config/consts.ts index 208580b..bb551a2 100644 --- a/config/consts.ts +++ b/config/consts.ts @@ -7,7 +7,10 @@ const HOUR_IN_MILLISECONDS = 60 * 60 * 1000; const CONSTANTS = { GITHUB_TOKEN: process.env.GITHUB_TOKEN!, CACHE_MAX_AGE: parseInt(process.env.CACHE_MAX_AGE!, 10), - DEFAULT_GITHUB_RETRY_DELAY: parseInt(process.env.DEFAULT_GITHUB_RETRY_DELAY!, 10), + DEFAULT_GITHUB_RETRY_DELAY: parseInt( + process.env.DEFAULT_GITHUB_RETRY_DELAY!, + 10, + ), DEFAULT_GITHUB_MAX_RETRY: parseInt(process.env.DEFAULT_GITHUB_MAX_RETRY!, 10), REVALIDATE_TIME: HOUR_IN_MILLISECONDS, LANGS_OUTPUT_FILE: 'src/languageMappings.json', @@ -19,4 +22,4 @@ const CONSTANTS = { CONSTANTS.LANGUAGE_MAPPINGS_URL += CONSTANTS.LANGS_OUTPUT_FILE; -export { CONSTANTS }; \ No newline at end of file +export { CONSTANTS }; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..e88ee8b --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,80 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import prettier from 'eslint-config-prettier'; +import importPlugin from 'eslint-plugin-import'; +import prettierPlugin from 'eslint-plugin-prettier'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat(); + +export default [ + { + ignores: ['coverage/**', 'dist/**', '**/node_modules/**'], + }, + { + files: ['**/*.{ts,js}'], + languageOptions: { + parser: tsParser, + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname, + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + import: importPlugin, + prettier: prettierPlugin, + }, + rules: { + /* TypeScript Rules */ + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/explicit-function-return-type': [ + 'warn', + { allowExpressions: true }, + ], + '@typescript-eslint/no-inferrable-types': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], + + /* Best Practices */ + eqeqeq: ['error', 'always'], + curly: ['error', 'all'], + 'no-empty': ['error', { allowEmptyCatch: true }], + 'dot-notation': 'error', + 'no-console': 'warn', + 'prefer-const': 'error', + 'no-var': 'error', + + /* Style Rules */ + semi: ['error', 'always'], + quotes: ['error', 'double', { avoidEscape: true }], + indent: ['error', 2], + 'comma-dangle': ['error', 'never'], + 'arrow-parens': ['error', 'as-needed'], + 'max-len': ['warn', { code: 100 }], + 'object-curly-spacing': ['error', 'always'], + + /* Import Rules */ + 'import/order': [ + 'error', + { + groups: [['builtin', 'external'], 'internal'], + alphabetize: { order: 'asc' }, + }, + ], + + /* Prettier Rules */ + 'prettier/prettier': 'error', + ...prettier.rules, + }, + }, + ...compat.extends('prettier'), +]; diff --git a/example-config.json b/example-config.json index 09e8a22..2a18854 100644 --- a/example-config.json +++ b/example-config.json @@ -1,39 +1,47 @@ { "options": { - "width": 800, - "height": 600, - "showPercentages": true, + "width": 500, + "height": 300, + "displayValues": "all", "title": { "text": "Custom Bubble Chart", "fontSize": "30px", "fontWeight": "bold", - "color": "#fff", + "color": "#ddd", "align": "middle" }, "legend": { "show": true, "align": "left" }, - "theme": "dark" + "theme": "dark_dimmed" }, "data": [ - { "name": "JavaScript", "value": 40, "color": "green", "icon": "https://cdn.worldvectorlogo.com/logos/devops-2.svg" }, - { "name": "Python", "value": 30, "color": "#3572A5" }, - { "name": "Python", "value": 30, "color": "#3572A5" }, - { "name": "Python", "value": 30, "color": "#3572A5" }, - { "name": "Python", "value": 30, "color": "#3572A5" }, - { "name": "Python", "value": 30, "color": "#3572A5" }, - { "name": "Python", "value": 30, "color": "#3572A5" }, - { "name": "Python", "value": 30, "color": "#3572A5" }, - { "name": "Python", "value": 30, "color": "#3572A5" }, - { "name": "Python", "value": 30, "color": "#3572A5" }, - { "name": "Java", "value": 20, "color": "#b07219" }, - { "name": "Java", "value": 20, "color": "#b07219" }, - { "name": "Java", "value": 20, "color": "#b07219" }, - { "name": "Java", "value": 20, "color": "#b07219" }, - { "name": "Java", "value": 20, "color": "#b07219" }, - { "name": "Java", "value": 20, "color": "#b07219" }, - { "name": "C++", "value": 10, "color": "#f34b7d" }, - { "name": "C++", "value": 10, "color": "#f34b7d" } + { + "name": "JavaScript", + "value": 50, + "color": "yellow", + "icon": "https://icons.veryicon.com/png/o/business/vscode-program-item-icon/javascript-3.png" + }, + { + "name": "Python", + "value": 35, + "color": "#3572A5" + }, + { + "name": "Java", + "value": 25, + "color": "#b07219" + }, + { + "name": "C++", + "value": 10, + "color": "#f34b7d" + }, + { + "name": "C", + "value": 10, + "color": "orange" + } ] } diff --git a/jest.config.ts b/jest.config.ts deleted file mode 100644 index aedcc8f..0000000 --- a/jest.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { JestConfigWithTsJest } from "ts-jest"; - -const config: JestConfigWithTsJest = { - verbose: true, - transform: { - "^.+\\.ts?$": [ - "ts-jest", - { - useESM: true, - }, - ], - }, - extensionsToTreatAsEsm: [".ts"], - moduleNameMapper: { - "^(\\.{1,2}/.*)\\.js$": "$1", - } -}; - -// FIX: see https://github.com/jestjs/jest/issues/15312 - -export default config; \ No newline at end of file diff --git a/package.json b/package.json index 208d0be..5d3cb68 100644 --- a/package.json +++ b/package.json @@ -4,28 +4,70 @@ "main": "index.js", "type": "module", "scripts": { - "build": "npx tsc", - "test": "jest", - "start": "node dist/api/index.js", - "update-langs": "npx tsx scripts/fetchLanguageMappings.ts" + "build": "tsc", + "test": "vitest", + "test:watch": "vitest --watch", + "test:coverage": "vitest --coverage", + "start": "yarn build && node dist/api/index.js", + "start:vercel": "vercel dev", + "update-langs": "tsx scripts/fetchLanguageMappings.ts", + "lint": "eslint . --max-warnings=0 --no-warn-ignored", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "clean": "rimraf dist", + "clean:modules": "rimraf node_modules && yarn install", + "clean:coverage": "rimraf coverage", + "clean:eslint": "rimraf .eslintcache", + "prepare": "husky", + "prestart": "echo 'Open link http://localhost:9000'" }, "repository": "https://github.com/teociaps/github-bubble-chart.git", "author": "Matteo Ciapparelli", "license": "MIT", + "keywords": [ + "github", + "readme", + "chart", + "dynamic", + "github-profile", + "animated", + "bubble-chart", + "github-readme", + "github-readme-profile", + "profile-readme" + ], + "bugs": { + "url": "https://github.com/teociaps/github-bubble-chart/issues" + }, + "homepage": "https://github.com/teociaps/github-bubble-chart#readme", "devDependencies": { + "@eslint/eslintrc": "^3.2.0", "@types/d3": "^7.4.3", "@types/express": "^5.0.0", "@types/image-to-base64": "^2.1.2", - "@types/jest": "^29.5.12", "@types/jsdom": "^21.1.7", - "@types/node": "^22.10.7", + "@types/node": "^22.13.1", + "@types/supertest": "^6.0.2", "@types/text-to-svg": "^3.1.4", + "@typescript-eslint/eslint-plugin": "^8.24.0", + "@typescript-eslint/parser": "^8.24.0", + "@vitest/coverage-v8": "^3.0.5", + "eslint": "^9.20.1", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-prettier": "^5.2.3", + "husky": "^9.1.7", "image-to-base64": "^2.2.0", - "jest": "^29.7.0", - "ts-jest": "^29.2.5", + "lint-staged": "^15.4.3", + "pino-pretty": "^13.0.0", + "prettier": "^3.5.0", + "rimraf": "^6.0.1", + "supertest": "^7.0.0", "ts-node": "^10.9.2", "tsx": "^4.19.2", "typescript": "^5.7.3", + "vitest": "^3.0.5", "yaml": "^2.7.0" }, "dependencies": { @@ -33,7 +75,17 @@ "d3": "^7.9.0", "dotenv": "^16.4.5", "express": "^4.19.2", + "express-rate-limit": "^7.5.0", "node-emoji": "^2.2.0", + "pino": "^9.6.0", "text-to-svg": "^3.1.5" + }, + "lint-staged": { + "*.{js,ts}": "eslint --max-warnings=0 --no-warn-ignored", + "*.{js,ts,json,md,yml}": "prettier --write" + }, + "engines": { + "node": ">=18.0.0", + "yarn": ">=1.22.0" } } diff --git a/scripts/fetchLanguageMappings.ts b/scripts/fetchLanguageMappings.ts index ded1bf7..7b13438 100644 --- a/scripts/fetchLanguageMappings.ts +++ b/scripts/fetchLanguageMappings.ts @@ -1,27 +1,32 @@ +/* eslint-disable no-console */ import fs from 'fs'; +import imageToBase64 from 'image-to-base64'; import { parse as yamlParse } from 'yaml'; import { CONSTANTS } from '../config/consts'; -import imageToBase64 from 'image-to-base64'; // Known language name discrepancies map (GitHub vs Devicon) const languageDiscrepancies: Record = { 'c#': 'csharp', 'c++': 'cplusplus', 'objective-c': 'objectivec', - 'css': 'css3', - 'scss': 'sass', - 'html': 'html5', - 'jupyter_notebook': 'jupyter' + css: 'css3', + scss: 'sass', + html: 'html5', + jupyter_notebook: 'jupyter', // TODO: Add more discrepancies, see more here https://github.com/devicons/devicon/pull/2270 }; -async function fetchLanguageColors(): Promise> { +async function fetchLanguageColors(): Promise< + Record +> { try { console.log('Fetching language colors from GitHub...'); const response = await fetch(CONSTANTS.LINGUIST_GITHUB); if (!response.ok) { - throw new Error(`Failed to fetch language colors: ${response.statusText}`); + throw new Error( + `Failed to fetch language colors: ${response.statusText}`, + ); } const data = yamlParse(await response.text()); @@ -40,7 +45,7 @@ async function fetchLanguageColors(): Promise> } } -async function convertImageToBase64(url: string) { +async function convertImageToBase64(url: string): Promise { try { const base64 = await imageToBase64(url); return `data:image/svg+xml;base64,${base64}`; @@ -53,8 +58,8 @@ const svgVersions = [ '-original', '-plain', '-original-wordmark', - '-plain-wordmark' -] + '-plain-wordmark', +]; async function checkUrlExists(url: string): Promise { try { @@ -67,7 +72,7 @@ async function checkUrlExists(url: string): Promise { } async function mapIconsToLanguages( - languageColors: Record + languageColors: Record, ): Promise> { console.log('Fetching language icons...'); const languageMappings: Record = {}; @@ -115,7 +120,7 @@ function mergeMappings( return mergedMappings; } -async function main() { +async function main(): Promise { try { // Fetch updated language colors and icons const languageColors = await fetchLanguageColors(); @@ -124,12 +129,17 @@ async function main() { // Load existing mappings let oldMappings: Record = {}; if (fs.existsSync(CONSTANTS.LANGS_OUTPUT_FILE)) { - oldMappings = JSON.parse(fs.readFileSync(CONSTANTS.LANGS_OUTPUT_FILE, 'utf-8')); + oldMappings = JSON.parse( + fs.readFileSync(CONSTANTS.LANGS_OUTPUT_FILE, 'utf-8'), + ); } // Merge and save updated mappings const updatedMappings = mergeMappings(oldMappings, newMappings); - fs.writeFileSync(CONSTANTS.LANGS_OUTPUT_FILE, JSON.stringify(updatedMappings, null, 2)); + fs.writeFileSync( + CONSTANTS.LANGS_OUTPUT_FILE, + JSON.stringify(updatedMappings, null, 2), + ); console.log(`Updated mappings written to ${CONSTANTS.LANGS_OUTPUT_FILE}`); } catch (error) { console.error('An error occurred:', error); diff --git a/src/chart/defs.ts b/src/chart/defs.ts index a33ecbe..62ee029 100644 --- a/src/chart/defs.ts +++ b/src/chart/defs.ts @@ -3,7 +3,7 @@ export const createSVGDefs = (): string => { id: string, coordinates: { fx: string; fy: string }, stops: { offset: string; color: string; opacity?: number }[], - ) => { + ): string => { let gradient = ``; stops.forEach((stop) => { gradient += ` { return gradient; }; - const createMask = (id: string, gradientId: string, transform?: string) => { + const createMask = ( + id: string, + gradientId: string, + transform?: string, + ): string => { return ` + transform !== undefined ? `transform="${transform}"` : '' + }> `; }; @@ -45,7 +49,11 @@ export const createSVGDefs = (): string => { ]); svgDefs += createMask('mask', 'grad--bw'); - svgDefs += createMask('mask--light-top', 'grad--bw-light', 'rotate(180, .5, .5)'); + svgDefs += createMask( + 'mask--light-top', + 'grad--bw-light', + 'rotate(180, .5, .5)', + ); svgDefs += createMask('mask--light-bottom', 'grad--bw-light'); svgDefs += ` diff --git a/src/chart/generator.ts b/src/chart/generator.ts index 2bfbc7f..7571cd7 100644 --- a/src/chart/generator.ts +++ b/src/chart/generator.ts @@ -1,32 +1,60 @@ import { hierarchy, HierarchyCircularNode, max, pack } from 'd3'; import { createSVGDefs } from './defs.js'; +import { + getCommonStyles, + generateBubbleAnimationStyle, + getLegendItemAnimationStyle, +} from './styles.js'; import { BubbleData } from './types/bubbleData.js'; import { BubbleChartOptions, TitleOptions } from './types/chartOptions.js'; -import { getColor, getName, measureTextHeight, measureTextWidth, parseEmojis, toKebabCase, wrapText, getAlignmentPosition, escapeSpecialChars } from './utils.js'; -import { getCommonStyles, generateBubbleAnimationStyle, getLegendItemAnimationStyle } from './styles.js'; -import { GeneratorError } from '../errors/custom-errors.js'; +import { + getColor, + getName, + measureTextHeight, + measureTextWidth, + parseEmojis, + toKebabCase, + wrapText, + getAlignmentPosition, + escapeSpecialChars, +} from './utils.js'; import { truncateText } from '../common/utils.js'; +import { GeneratorError } from '../errors/custom-errors.js'; async function createTitleElement( titleOptions: TitleOptions, width: number, - titleHeight: number + titleHeight: number, ): Promise<{ svgTitle: string; titleLines: number }> { try { const style = Object.keys(titleOptions) - .filter((style) => style !== 'text' && style !== 'textAnchor' && titleOptions[style] != null) + .filter( + (style) => + style !== 'text' && + style !== 'textAnchor' && + titleOptions[style] !== null, + ) .map((style) => `${toKebabCase(style)}: ${titleOptions[style]};`) .join(' '); const titleAlign = getAlignmentPosition(titleOptions.textAnchor, width); titleOptions.text = escapeSpecialChars(parseEmojis(titleOptions.text)); - const textWidth = await measureTextWidth(titleOptions.text, titleOptions.fontSize, titleOptions.fontWeight); + const textWidth = await measureTextWidth( + titleOptions.text, + titleOptions.fontSize, + titleOptions.fontWeight, + ); let textElement = ''; let lines: string[] | null = null; if (textWidth > width) { - lines = await wrapText(titleOptions.text, width, titleOptions.fontSize, titleOptions.fontWeight); + lines = await wrapText( + titleOptions.text, + width, + titleOptions.fontSize, + titleOptions.fontWeight, + ); const linePadding = 0; // Padding between lines if (lines.length > 3) { @@ -56,7 +84,10 @@ async function createTitleElement( titleLines: lines?.length || 1, }; } catch (error) { - throw new GeneratorError('Failed to create title element.', error instanceof Error ? error : undefined); + throw new GeneratorError( + 'Failed to create title element.', + error instanceof Error ? error : undefined, + ); } } @@ -70,7 +101,9 @@ async function createBubbleElement( const radius = node.r; const iconUrl = node.data.icon as string; const language = getName(node.data); - const percentage = node.data.value + '%'; + const value = chartOptions.usePercentages + ? `${node.data.value}%` + : node.data.value; // Main group for the bubble let bubble = ``; @@ -93,7 +126,7 @@ async function createBubbleElement( } else { const fontSize = radius / 3 + 'px'; const textLines = await wrapText(language, radius * 2, fontSize); - + let displayedText = ''; if (textLines.length > 1) { const lineHeight = await measureTextHeight(language, fontSize); @@ -103,24 +136,29 @@ async function createBubbleElement( ${line} `; }); - } - else { + } else { displayedText = language; } bubble += `${displayedText}`; } - // Percentage text - if (chartOptions.showPercentages) { - bubble += `${percentage}`; + // Value text + if ( + chartOptions.displayValues === 'all' || + chartOptions.displayValues === 'bubbles' + ) { + bubble += `${value}`; } bubble += ''; // Close the bubble group - + return bubble; } catch (error) { - throw new GeneratorError('Failed to create bubble element.', error instanceof Error ? error : undefined); + throw new GeneratorError( + 'Failed to create bubble element.', + error instanceof Error ? error : undefined, + ); } } @@ -129,7 +167,7 @@ async function createLegend( svgWidth: number, svgMaxY: number, distanceFromBubbleChart: number, - chartOptions: BubbleChartOptions + chartOptions: BubbleChartOptions, ): Promise<{ svgLegend: string; legendHeight: number }> { try { const legendMarginTop = distanceFromBubbleChart; // Distance from the last bubble to the legend @@ -142,17 +180,23 @@ async function createLegend( // Prepare legend items with their measured widths const legendItems = data.map(async (item) => { - const percentage = item.value + '%'; - const text = `${item.name} (${percentage})`; + const value = + chartOptions.displayValues === 'all' || + chartOptions.displayValues === 'legend' + ? chartOptions.usePercentages + ? ` (${item.value}%)` + : ` (${item.value})` + : ''; + const text = `${item.name}${value}`; const textWidth = await measureTextWidth(text, '12px'); return { text, width: textWidth + legendXPadding, // Include circle and padding - color: item.color + color: item.color, }; }); - const rowItems: any[][] = [[]]; // Array of rows, each row contains legend items + const rowItems: { text: string; width: number; color: string }[][] = [[]]; // Array of rows, each row contains legend items let currentRowWidth = 0; let currentRowIndex = 0; @@ -169,7 +213,7 @@ async function createLegend( // Generate SVG for legend rows rowItems.forEach((row, rowIndex) => { - let rowWidth = row.reduce((sum, item) => sum + item.width, 0); + const rowWidth = row.reduce((sum, item) => sum + item.width, 0); let rowX = 0; if (chartOptions.legendOptions.align === 'center') { @@ -183,7 +227,7 @@ async function createLegend( svgLegend += ` - ${item.text} + ${item.text} `; rowX += item.width; // Next item @@ -198,7 +242,10 @@ async function createLegend( return { svgLegend: svgLegend, legendHeight }; } catch (error) { - throw new GeneratorError('Failed to create legend.', error instanceof Error ? error : undefined); + throw new GeneratorError( + 'Failed to create legend.', + error instanceof Error ? error : undefined, + ); } } @@ -207,12 +254,23 @@ async function createLegend( */ export async function createBubbleChart( data: BubbleData[], - chartOptions: BubbleChartOptions + chartOptions: BubbleChartOptions, ): Promise { - if (data.length === 0) return null; + if (data === undefined || data.length === 0) return null; + + if (isNaN(chartOptions.width) || isNaN(chartOptions.height)) { + throw new GeneratorError('Invalid width or hight.'); + } + + if ( + chartOptions.titleOptions === undefined || + chartOptions.legendOptions === undefined + ) { + throw new GeneratorError('Title or legend options are missing.'); + } // Escape special characters in data names so they can be shown correctly in the chart - data.forEach(item => { + data.forEach((item) => { item.name = escapeSpecialChars(item.name); }); @@ -220,21 +278,31 @@ export async function createBubbleChart( const height = chartOptions.height; const bubblesPack = pack().size([width, height]).padding(1.5); - const root = hierarchy({ children: data } as any).sum((d) => d.value); + const root = hierarchy({ + children: data, + } as unknown as BubbleData).sum((d) => d.value); const bubbleNodes = bubblesPack(root).leaves(); - + // Title let titleHeight = 0; - let { svgTitle, titleLines } = { svgTitle: '', titleLines: 0}; + let { svgTitle, titleLines } = { svgTitle: '', titleLines: 0 }; if (chartOptions.titleOptions.text) { - titleHeight = await measureTextHeight(chartOptions.titleOptions.text, chartOptions.titleOptions.fontSize, chartOptions.titleOptions.fontWeight); - const title = await createTitleElement(chartOptions.titleOptions, width, titleHeight); + titleHeight = await measureTextHeight( + chartOptions.titleOptions.text, + chartOptions.titleOptions.fontSize, + chartOptions.titleOptions.fontWeight, + ); + const title = await createTitleElement( + chartOptions.titleOptions, + width, + titleHeight, + ); svgTitle = title.svgTitle; titleLines = title.titleLines; } - // Calculate full height - const bubbleChartMargin = 20; // Space between bubbles and title/legend + // Calculate full height + const bubbleChartMargin = 20; // Space between bubbles and title/legend const maxY = max(bubbleNodes, (d) => d.y + d.r + bubbleChartMargin) || height; const distanceFromBubbleChart = titleHeight * titleLines + bubbleChartMargin; let fullHeight = maxY + distanceFromBubbleChart; @@ -244,8 +312,17 @@ export async function createBubbleChart( // Legend let svgLegend = ''; - if (chartOptions.legendOptions.show) { - const legendResult = await createLegend(data, width, maxY, distanceFromBubbleChart, chartOptions); + if ( + chartOptions.legendOptions !== undefined && + chartOptions.legendOptions.show + ) { + const legendResult = await createLegend( + data, + width, + maxY, + distanceFromBubbleChart, + chartOptions, + ); svgLegend = legendResult.svgLegend; fullHeight += legendResult.legendHeight; styles += getLegendItemAnimationStyle(); @@ -256,14 +333,14 @@ export async function createBubbleChart( svg += createSVGDefs(); svg += svgTitle; svg += ``; - + for await (const [index, element] of bubbleNodes.entries()) { svg += await createBubbleElement(element, index, chartOptions); styles += generateBubbleAnimationStyle(element, index); } svg += ''; // Close bubbles group - svg += svgLegend; + svg += svgLegend; svg += ``; svg += ''; diff --git a/src/chart/styles.ts b/src/chart/styles.ts index 88acaee..288aff1 100644 --- a/src/chart/styles.ts +++ b/src/chart/styles.ts @@ -1,9 +1,10 @@ -import { HierarchyCircularNode } from "d3"; -import { ThemeBase } from "./themes.js"; -import { BubbleData } from "./types/bubbleData.js"; -import { StyleError } from "../errors/custom-errors.js"; +import { HierarchyCircularNode } from 'd3'; +import { ThemeBase } from './themes.js'; +import { BubbleData } from './types/bubbleData.js'; +import { StyleError } from '../errors/custom-errors.js'; -export const defaultFontFamily = '-apple-system,BlinkMacSystemFont,\'Segoe UI\',\'Noto Sans\',Helvetica,Arial,sans-serif,\'Apple Color Emoji\',\'Segoe UI Emoji\''; +export const defaultFontFamily = + "-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans',Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'"; export function getCommonStyles(theme: ThemeBase): string { try { @@ -18,7 +19,7 @@ export function getCommonStyles(theme: ThemeBase): string { .b-text { text-anchor: middle; } - .b-percentage { + .b-value { text-shadow: 0 0 1px ${theme.textColor}; text-anchor: middle; } @@ -35,20 +36,26 @@ export function getCommonStyles(theme: ThemeBase): string { } `; } catch (error) { - throw new StyleError('Failed to get common styles.', error instanceof Error ? error : undefined); + throw new StyleError( + 'Failed to get common styles.', + error instanceof Error ? error : undefined, + ); } } -export function generateBubbleAnimationStyle(node: HierarchyCircularNode, index: number): string { +export function generateBubbleAnimationStyle( + node: HierarchyCircularNode, + index: number, +): string { try { const radius = node.r; const duration = (Math.random() * 5 + 8).toFixed(2); const delay = (Math.random() * 2).toFixed(2); const randomXOffset = Math.random() * 20 - 10; const randomYOffset = Math.random() * 20 - 10; - const plopDelay = radius * 0.010; + const plopDelay = radius * 0.01; - // TODO: make the animation more fluid/smooth + // TODO: make the animation more fluid/smooth return ` .bubble-${index} { scale: 0; @@ -74,7 +81,10 @@ export function generateBubbleAnimationStyle(node: HierarchyCircularNode { const response = await fetch(CONSTANTS.LANGUAGE_MAPPINGS_URL, { @@ -17,14 +17,17 @@ async function fetchLanguageMappings(): Promise { return response.json(); } -export const getColor = (d: BubbleData) => d.color; -export const getName = (d: BubbleData) => d.name; +export const getColor = (d: BubbleData): string => d.color; +export const getName = (d: BubbleData): string => d.name; export function toKebabCase(str: string): string { return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); } -export async function getBubbleData(username: string, langsCount: number) { +export async function getBubbleData( + username: string, + langsCount: number, +): Promise { const languagePercentages = await fetchTopLanguages(username!, langsCount); const languageMappings: LanguageMappings = await fetchLanguageMappings(); return languagePercentages.map((l) => ({ @@ -64,13 +67,13 @@ async function measureTextDimension( text: string, fontSize: string, fontWeight: string = 'normal', - dimension: 'width' | 'height' + dimension: 'width' | 'height', ): Promise { const textToSVG = await getTextToSVG(); // Convert the font size from a string to a number const size = parseFloat(fontSize); - + const sizeMultiplier = fontWeightMultipliers[fontWeight] || 1.0; const adjustedSize = size * sizeMultiplier; @@ -90,7 +93,7 @@ async function measureTextDimension( export async function measureTextWidth( text: string, fontSize: string, - fontWeight: string = 'normal' + fontWeight: string = 'normal', ): Promise { return measureTextDimension(text, fontSize, fontWeight, 'width'); } @@ -98,24 +101,25 @@ export async function measureTextWidth( export async function measureTextHeight( text: string, fontSize: string, - fontWeight: string = 'normal' + fontWeight: string = 'normal', ): Promise { return measureTextDimension(text, fontSize, fontWeight, 'height'); } export function escapeSpecialChars(text: string): string { - return text.replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(/\\/g, '\') - .replace(/`/g, '`') - .replace(/{/g, '{') - .replace(/}/g, '}'); + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\\/g, '\') + .replace(/`/g, '`') + .replace(/{/g, '{') + .replace(/}/g, '}'); } -export const parseEmojis = (str: string) => { +export const parseEmojis = (str: string): string => { if (!str) { throw new Error('[parseEmoji]: str argument not provided'); } @@ -134,7 +138,7 @@ export async function wrapText( fontWeight: string = 'normal', ): Promise { const words = escapeSpecialChars(text).split(' '); - let lines: string[] = []; + const lines: string[] = []; let currentLine = words[0]; const wordWidths: Record = {}; @@ -159,7 +163,10 @@ export async function wrapText( return lines; } -export function getAlignmentPosition(textAnchor: TextAnchor, width: number): number { +export function getAlignmentPosition( + textAnchor: TextAnchor, + width: number, +): number { switch (textAnchor) { case 'start': return 0; @@ -170,4 +177,4 @@ export function getAlignmentPosition(textAnchor: TextAnchor, width: number): num default: return width / 2; } -} \ No newline at end of file +} diff --git a/src/common/utils.ts b/src/common/utils.ts index 44b6d58..7dcffcb 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,6 +1,6 @@ -import { themeMap } from "../chart/themes.js"; -import { ConfigOptions } from "../chart/types/config.js"; -import { BubbleChartOptions } from "../chart/types/chartOptions.js"; +import { themeMap } from '../chart/themes.js'; +import { BubbleChartOptions } from '../chart/types/chartOptions.js'; +import { CustomConfigOptions } from '../chart/types/config.js'; export const isDevEnvironment = (): boolean => { return process.env.NODE_ENV === 'dev'; @@ -10,12 +10,18 @@ export const isProdEnvironment = (): boolean => { return process.env.NODE_ENV === 'prod'; }; -export function mapConfigToBubbleChartOptions(config: ConfigOptions): BubbleChartOptions { - const theme = typeof config.theme === 'string' ? themeMap[config.theme.toLowerCase()] : config.theme; +export function mapConfigToBubbleChartOptions( + config: CustomConfigOptions, +): BubbleChartOptions { + const theme = + typeof config.theme === 'string' + ? themeMap[config.theme.toLowerCase()] + : config.theme; return { width: config.width, height: config.height, - showPercentages: config.showPercentages, + displayValues: config.displayValues, + usePercentages: false, titleOptions: { text: config.title.text, fontSize: config.title.fontSize, @@ -36,4 +42,4 @@ export function truncateText(text: string, maxChars: number): string { return text.substring(0, maxChars - 1) + '…'; } return text; -} \ No newline at end of file +} diff --git a/src/errors/base-error.ts b/src/errors/base-error.ts index f9cdc5c..0c8c01e 100644 --- a/src/errors/base-error.ts +++ b/src/errors/base-error.ts @@ -3,7 +3,7 @@ export class BaseError extends Error { readonly status: number, readonly message: string, public originalError?: Error, - public content: string = 'An unexpected error occurred. Please try again later.' + public content: string = 'An unexpected error occurred. Please try again later.', ) { super(message); this.name = this.constructor.name; @@ -12,11 +12,11 @@ export class BaseError extends Error { } } - render() { + render(): string { return this.renderPage(); } - private renderPage() { + private renderPage(): string { return ` @@ -53,6 +53,9 @@ export class BaseError extends Error { #link-container a:hover { text-decoration: underline; } + main { + text-align: center; + } diff --git a/src/errors/custom-errors.ts b/src/errors/custom-errors.ts index 6f1999d..78d1066 100644 --- a/src/errors/custom-errors.ts +++ b/src/errors/custom-errors.ts @@ -1,44 +1,101 @@ import { BaseError } from './base-error.js'; export class BadRequestError extends BaseError { - constructor(content?: string, public originalError?: Error) { - super(400, 'Bad Request', originalError, content ?? 'The request could not be understood by the server due to malformed syntax.'); + constructor( + content?: string, + public originalError?: Error, + ) { + super( + 400, + 'Bad Request', + originalError, + content ?? + 'The request could not be understood by the server due to malformed syntax.', + ); } } export class NotFoundError extends BaseError { - constructor(content?: string, public originalError?: Error) { - super(404, 'Not Found', originalError, content ?? 'The requested resource could not be found.'); + constructor( + content?: string, + public originalError?: Error, + ) { + super( + 404, + 'Not Found', + originalError, + content ?? 'The requested resource could not be found.', + ); } } export class StyleError extends BaseError { - constructor(content?: string, public originalError?: Error) { - super(500, 'Style Error', originalError, content ?? 'An error occurred while applying styles.'); + constructor( + content?: string, + public originalError?: Error, + ) { + super( + 500, + 'Style Error', + originalError, + content ?? 'An error occurred while applying styles.', + ); } } export class GeneratorError extends BaseError { - constructor(content?: string, public originalError?: Error) { - super(500, 'Chart Generator Error', originalError, content ?? 'An error occurred while generating the chart.'); + constructor( + content?: string, + public originalError?: Error, + ) { + super( + 500, + 'Chart Generator Error', + originalError, + content ?? 'An error occurred while generating the chart.', + ); } } export class FetchError extends BaseError { - constructor(content?: string, public originalError?: Error) { - super(500, 'Fetch Error', originalError, content ?? 'An error occurred while fetching data.'); + constructor( + content?: string, + public originalError?: Error, + ) { + super( + 500, + 'Fetch Error', + originalError, + content ?? 'An error occurred while fetching data.', + ); } } export class ValidationError extends BaseError { - constructor(content?: string, public originalError?: Error) { - super(400, 'Validation Error', originalError, content ?? 'The provided data is invalid.'); + constructor( + content?: string, + public originalError?: Error, + ) { + super( + 400, + 'Validation Error', + originalError, + content ?? 'The provided data is invalid.', + ); } } export class SVGGenerationError extends BaseError { - constructor(content?: string, public originalError?: Error) { - super(500, 'SVG Generation Error', originalError, content ?? 'An error occurred while generating the SVG.'); + constructor( + content?: string, + public originalError?: Error, + ) { + super( + 500, + 'SVG Generation Error', + originalError, + content ?? 'An error occurred while generating the SVG.', + ); } } @@ -69,7 +126,7 @@ export class MissingUsernameError extends BaseError {

For more options, visit - this page. + this page.

@@ -97,8 +154,9 @@ export class MissingUsernameError extends BaseError { width: 80%; margin: 0 auto; padding: 20px; + text-align: left; } - + button { padding: 10px 20px; color: #fff; @@ -106,7 +164,7 @@ export class MissingUsernameError extends BaseError { border-radius: inherit; cursor: pointer; } - + .container { padding: 20px; margin-bottom: 20px; @@ -115,12 +173,12 @@ export class MissingUsernameError extends BaseError { border-radius: 5px; box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); } - + .url-container { background-color: #f9f9f9; padding: 10px; border-radius: 5px; - border: 1px solid #ededed; + border: 1px solid #ededed; } #baseurl-show { font-family: monospace; diff --git a/src/errors/github-errors.ts b/src/errors/github-errors.ts index aedbfed..49e8340 100644 --- a/src/errors/github-errors.ts +++ b/src/errors/github-errors.ts @@ -1,37 +1,61 @@ import { BaseError } from './base-error.js'; export class GitHubError extends BaseError { - constructor(readonly status: number, readonly message: string, content?: string) { + constructor( + readonly status: number, + readonly message: string, + content?: string, + ) { super(status, message, undefined, content); } } export class GitHubNotFoundError extends GitHubError { constructor(content?: string) { - super(404, 'GitHub Repository Not Found', content ?? 'The requested GitHub repository could not be found.'); + super( + 404, + 'GitHub Repository Not Found', + content ?? 'The requested GitHub repository could not be found.', + ); } } export class GitHubRateLimitError extends GitHubError { constructor(content?: string) { - super(403, 'GitHub API Rate Limit Exceeded', content ?? 'You have exceeded the GitHub API rate limit.'); + super( + 403, + 'GitHub API Rate Limit Exceeded', + content ?? 'You have exceeded the GitHub API rate limit.', + ); } } export class GitHubBadCredentialsError extends GitHubError { constructor(content?: string) { - super(401, 'GitHub Bad Credentials', content ?? 'The provided GitHub credentials are invalid.'); + super( + 401, + 'GitHub Bad Credentials', + content ?? 'The provided GitHub credentials are invalid.', + ); } } export class GitHubAccountSuspendedError extends GitHubError { constructor(content?: string) { - super(403, 'GitHub Account Suspended', content ?? 'The GitHub account has been suspended.'); + super( + 403, + 'GitHub Account Suspended', + content ?? 'The GitHub account has been suspended.', + ); } } export class GitHubUsernameNotFoundError extends GitHubError { constructor(content?: string) { - super(404, 'GitHub Username Not Found', content ?? 'The requested GitHub username could not be found.'); + super( + 404, + 'GitHub Username Not Found', + content ?? 'The requested GitHub username could not be found.', + ); } } diff --git a/src/languageMappings.json b/src/languageMappings.json index 7cea74d..00a30e7 100644 --- a/src/languageMappings.json +++ b/src/languageMappings.json @@ -1941,4 +1941,4 @@ "Jai": { "color": "#ab8b4b" } -} \ No newline at end of file +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..5d03eb5 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,18 @@ +import { pino } from 'pino'; +import { isDevEnvironment } from './common/utils.js'; + +const isDev = isDevEnvironment(); + +const logger = pino({ + level: process.env.LOG_LEVEL || 'info', + ...(isDev && { + transport: { + target: 'pino-pretty', + options: { + colorize: true, + }, + }, + }), +}); + +export default logger; diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 59c5cc8..10ab04b 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -1,8 +1,20 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { graphql } from '@octokit/graphql'; import { CONSTANTS } from '../../config/consts.js'; -import { GitHubError, GitHubRateLimitError, GitHubNotFoundError, GitHubBadCredentialsError, GitHubAccountSuspendedError, GitHubUsernameNotFoundError } from '../errors/github-errors.js'; +import { + GitHubError, + GitHubRateLimitError, + GitHubNotFoundError, + GitHubBadCredentialsError, + GitHubAccountSuspendedError, + GitHubUsernameNotFoundError, +} from '../errors/github-errors.js'; +import logger from '../logger.js'; -export const fetchTopLanguages = async (username: string, langsCount: number) => { +export const fetchTopLanguages = async ( + username: string, + langsCount: number, +): Promise<{ language: string; percentage: string }[]> => { try { const query = ` query UserLanguages($username: String!, $first: Int!, $after: String) { @@ -29,7 +41,7 @@ export const fetchTopLanguages = async (username: string, langsCount: number) => `; let hasNextPage = true; - let after: any = null; + let after: string | null = null; const languageMap: Record = {}; while (hasNextPage) { @@ -63,7 +75,10 @@ export const fetchTopLanguages = async (username: string, langsCount: number) => .filter(([, size]) => size > 0) .slice(0, langsCount); - const limitedTotalSize = sortedLanguages.reduce((sum, [, size]) => sum + size, 0); + const limitedTotalSize = sortedLanguages.reduce( + (sum, [, size]) => sum + size, + 0, + ); const languagePercentages = sortedLanguages.map(([language, size]) => ({ language, percentage: ((size / limitedTotalSize) * 100).toFixed(2), @@ -71,10 +86,15 @@ export const fetchTopLanguages = async (username: string, langsCount: number) => return languagePercentages; } catch (error) { - if (error instanceof Error) { - handleGitHubError(error); + if (error instanceof GitHubError) { + throw error; } - throw new GitHubError(400, 'GitHub API Error', 'Failed to fetch top languages from GitHub'); + logger.error(error); + throw new GitHubError( + 400, + 'GitHub API Error', + 'Failed to fetch top languages from GitHub', + ); } }; @@ -93,7 +113,11 @@ const retry = async ( await new Promise((res) => setTimeout(res, delay)); return retry(fn, retries - 1, delay); } else { - throw new GitHubError(400, 'GitHub API Error', 'Exceeded maximum retries for GitHub API. Try again later.'); + throw new GitHubError( + 400, + 'GitHub API Error', + 'Exceeded maximum retries for GitHub API. Try again later.', + ); } } }; @@ -104,11 +128,14 @@ const graphqlWithAuth = graphql.defaults({ }, }); -const handleGitHubError = (error: Error) => { - console.error('GitHub API Error:', error.message); +const handleGitHubError = (error: Error): void => { + logger.error(`GitHub API Error: ${error.message}`); if (error.message.includes('rate limit')) { throw new GitHubRateLimitError(); } + if (error.message.includes('Could not resolve to a User with the login of')) { + throw new GitHubUsernameNotFoundError(); + } if (error.message.includes('Not Found')) { throw new GitHubNotFoundError(); } @@ -118,8 +145,5 @@ const handleGitHubError = (error: Error) => { if (error.message.includes('Your account was suspended')) { throw new GitHubAccountSuspendedError(); } - if (error.message.includes('Could not resolve to a User with the login of')) { - throw new GitHubUsernameNotFoundError(); - } throw error; -}; \ No newline at end of file +}; diff --git a/tests/api-utils.test.ts b/tests/api-utils.test.ts deleted file mode 100644 index 12bca35..0000000 --- a/tests/api-utils.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { CustomURLSearchParams, parseParams } from '../src/api-utils'; -import { LightTheme } from '../src/chart/themes'; - -describe('api-utils', () => { - describe('CustomURLSearchParams', () => { - it('should return default string value if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getStringValue('key', 'default')).toBe('default'); - }); - - it('should return parsed number value if key is present', () => { - const params = new CustomURLSearchParams('key=42'); - expect(params.getNumberValue('key', 0)).toBe(42); - }); - - it('should return default boolean value if key is not present', () => { - const params = new CustomURLSearchParams(''); - expect(params.getBooleanValue('key', true)).toBe(true); - }); - - it('should return parsed theme if key is present', () => { - const params = new CustomURLSearchParams('theme=light'); - expect(params.getTheme('theme', new LightTheme())).toBeInstanceOf(LightTheme); - }); - }); - - describe('parseParams', () => { - it('should parse URL parameters', () => { - const req = { url: 'http://example.com?key=value' }; - const params = parseParams(req as any); - expect(params.get('key')).toBe('value'); - }); - }); -}); diff --git a/tests/api/default.test.ts b/tests/api/default.test.ts new file mode 100644 index 0000000..450a3dc --- /dev/null +++ b/tests/api/default.test.ts @@ -0,0 +1,128 @@ +import { Request, Response } from 'express'; +import { describe, it, expect, vi, Mock } from 'vitest'; +import handler from '../../api/default'; +import { defaultHeaders } from '../../api/utils'; +import { createBubbleChart } from '../../src/chart/generator'; +import { getBubbleData } from '../../src/chart/utils'; + +vi.mock('../../src/chart/utils'); +vi.mock('../../src/chart/generator'); + +// Add mock for the API utils to stub fetchConfigFromRepo and related helpers. +vi.mock('../../api/utils', () => ({ + defaultHeaders: { 'Content-Type': 'image/svg+xml' }, + fetchConfigFromRepo: vi.fn().mockResolvedValue({ + options: { custom: true }, + data: [{ name: 'TestLang', value: 42, color: 'red' }], + }), + handleMissingUsername: vi.fn((error, res) => { + res.status(400).send('Missing Required Parameter'); + }), + parseParams: (req: Request) => { + const url = new URL(req.url); + return { + get: (key: string) => url.searchParams.get(key), + getMode: () => url.searchParams.get('mode') || 'default', + getNumberValue: (key: string, defaultVal: number) => + Number(url.searchParams.get(key)) || defaultVal, + parseTitleOptions: () => ({}), + getValuesDisplayOption: () => url.searchParams.get('display-values'), + parseLegendOptions: () => ({}), + getTheme: (key: string, defaultVal: string) => + url.searchParams.get(key) || defaultVal, + getLanguagesCount: (count: number) => count, + }; + }, + handleErrorResponse: vi.fn((error, res) => { + res.status(500).send({ error: 'An unexpected error occurred' }); + }), +})); + +describe('API handler', () => { + it('should handle missing username', async () => { + const req = { + url: 'http://example.com', + get: vi.fn().mockReturnValue('example.com'), + protocol: 'http', + } as unknown as Request; + const res = { + status: vi.fn().mockReturnThis(), + send: vi.fn(), + } as unknown as Response; + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith( + expect.stringContaining(`Missing Required Parameter`), + ); + }); + + it('should generate bubble chart SVG', async () => { + const req = { + url: 'http://example.com?username=testuser', + } as unknown as Request; + const res = { setHeaders: vi.fn(), send: vi.fn() } as unknown as Response; + (getBubbleData as Mock).mockResolvedValue([ + { name: 'JavaScript', value: 70, color: 'yellow' }, + ]); + (createBubbleChart as Mock).mockReturnValue(''); + + await handler(req, res); + expect(res.setHeaders).toHaveBeenCalledWith(defaultHeaders); + expect(res.send).toHaveBeenCalledWith(''); + }); + + it('should handle errors', async () => { + const req = { + url: 'http://example.com?username=testuser', + } as unknown as Request; + const res = { + status: vi.fn().mockReturnThis(), + send: vi.fn(), + } as unknown as Response; + (getBubbleData as Mock).mockRejectedValue( + new Error('Generic failed to fetch'), + ); + + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.send).toHaveBeenCalledWith({ + error: 'An unexpected error occurred', + }); + }); + + it('should handle custom-config mode', async () => { + // Prepare a request with custom-config parameters. + const req = { + url: 'http://example.com?username=testuser&config-path=somePath&mode=custom-config&config-branch=dev', + get: vi.fn().mockReturnValue('example.com'), + protocol: 'http', + } as unknown as Request; + + // Stub createBubbleChart to return valid SVG. + (createBubbleChart as Mock).mockResolvedValue('custom'); + const res = { + setHeaders: vi.fn(), + send: vi.fn(), + status: vi.fn().mockReturnThis(), + } as unknown as Response; + + await handler(req, res); + + // Expect fetchConfigFromRepo to have been called. + const { fetchConfigFromRepo } = await import('../../api/utils'); + expect(fetchConfigFromRepo).toHaveBeenCalledWith( + 'testuser', + 'somePath', + 'dev', + ); + // Expect createBubbleChart to use the custom data and options. + expect(createBubbleChart).toHaveBeenCalledWith( + [{ name: 'TestLang', value: 42, color: 'red' }], + { custom: true }, + ); + expect(res.setHeaders).toHaveBeenCalledWith({ + 'Content-Type': 'image/svg+xml', + }); + expect(res.send).toHaveBeenCalledWith('custom'); + }); +}); diff --git a/tests/api/index.test.ts b/tests/api/index.test.ts index d2938f3..6f4ebe7 100644 --- a/tests/api/index.test.ts +++ b/tests/api/index.test.ts @@ -1,36 +1,37 @@ -import handler from '../../api/index'; -import { getBubbleData } from '../../src/chart/utils'; -import { createBubbleChart } from '../../src/chart/generator'; +import dotenv from 'dotenv'; +import { Server } from 'http'; +import { AddressInfo } from 'net'; +import request from 'supertest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import app from '../../api/index'; -jest.mock('../../src/chart/utils'); -jest.mock('../../src/chart/generator'); +dotenv.config(); -describe('API handler', () => { - it('should handle missing username', async () => { - const req = { url: 'http://example.com' }; - const res = { send: jest.fn() }; - await handler(req, res); - expect(res.send).toHaveBeenCalledWith(expect.stringContaining('"username" is a required query parameter')); - }); +describe('Express App', () => { + let server: Server; + let dynamicPort: number; - it('should generate bubble chart SVG', async () => { - const req = { url: 'http://example.com?username=testuser' }; - const res = { send: jest.fn(), setHeaders: jest.fn() }; - (getBubbleData as jest.Mock).mockResolvedValue([{ name: 'JavaScript', value: 70, color: 'yellow' }]); - (createBubbleChart as jest.Mock).mockReturnValue(''); + beforeAll(async () => { + server = await new Promise((resolve, _) => { + const s = app.listen(0, () => { + dynamicPort = (s.address() as AddressInfo).port; + resolve(s); + }); + }); + }); - await handler(req, res); - expect(res.setHeaders).toHaveBeenCalled(); - expect(res.send).toHaveBeenCalledWith(''); + afterAll(() => { + if (server) { + server.close(); + } }); - it('should handle errors', async () => { - const req = { url: 'http://example.com?username=testuser' }; - const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; - (getBubbleData as jest.Mock).mockRejectedValue(new Error('Failed to fetch')); + it('should start the server on a dynamic port', () => { + expect(dynamicPort).toBeGreaterThan(0); + }); - await handler(req, res); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: 'Failed to fetch languages for specified user' }); + it('should respond to GET / with the API response', async () => { + const response = await request(app).get('/'); + expect(response.status).toBe(400); }); }); diff --git a/tests/api/utils.test.ts b/tests/api/utils.test.ts new file mode 100644 index 0000000..95d018f --- /dev/null +++ b/tests/api/utils.test.ts @@ -0,0 +1,358 @@ +import { Request } from 'express'; +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + CustomURLSearchParams, + parseParams, + fetchConfigFromRepo, +} from '../../api/utils'; +import { LightTheme } from '../../src/chart/themes'; +import { CustomConfig } from '../../src/chart/types/config'; +import { + isDevEnvironment, + mapConfigToBubbleChartOptions, +} from '../../src/common/utils'; +import { FetchError, ValidationError } from '../../src/errors/custom-errors'; +import { + GitHubNotFoundError, + GitHubRateLimitError, +} from '../../src/errors/github-errors'; + +describe('API Utils', () => { + describe('CustomURLSearchParams', () => { + it('should return default string value if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getStringValue('key', 'default')).toBe('default'); + }); + + it('should return string value if key is present', () => { + const params = new CustomURLSearchParams('key=value'); + expect(params.getStringValue('key', 'default')).toBe('value'); + }); + + it('should return default number value if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getNumberValue('key', 0)).toBe(0); + }); + + it('should return parsed number value if key is present', () => { + const params = new CustomURLSearchParams('key=42'); + expect(params.getNumberValue('key', 0)).toBe(42); + }); + + it('should return default boolean value if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getBooleanValue('key', true)).toBe(true); + }); + + it('should return parsed boolean value if key is present', () => { + const params = new CustomURLSearchParams('key=true'); + expect(params.getBooleanValue('key', false)).toBe(true); + }); + + it('should return default theme if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getTheme('theme', new LightTheme())).toBeInstanceOf( + LightTheme, + ); + }); + + it('should return parsed theme if key is present', () => { + const params = new CustomURLSearchParams('theme=light'); + expect(params.getTheme('theme', new LightTheme())).toBeInstanceOf( + LightTheme, + ); + }); + + it('should return default text anchor value if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getTextAnchorValue('key', 'middle')).toBe('middle'); + }); + + it('should return parsed text anchor value if key is present', () => { + const params = new CustomURLSearchParams('key=center'); + expect(params.getTextAnchorValue('key', 'middle')).toBe('middle'); + }); + + it('should return default languages count if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getLanguagesCount(5)).toBe(5); + }); + + it('should return parsed languages count if key is present', () => { + const params = new CustomURLSearchParams('langs-count=10'); + expect(params.getLanguagesCount(5)).toBe(10); + }); + + it('should return minimum languages count if parsed value is less than 1', () => { + const params = new CustomURLSearchParams('langs-count=0'); + expect(params.getLanguagesCount(5)).toBe(1); + }); + + it('should return maximum languages count if parsed value is greater than 20', () => { + const params = new CustomURLSearchParams('langs-count=21'); + expect(params.getLanguagesCount(5)).toBe(20); + }); + + it('should parse title options correctly', () => { + const params = new CustomURLSearchParams( + 'title=MyChart&title-size=30&title-weight=normal&title-color=#000000&title-align=center', + ); + const titleOptions = params.parseTitleOptions(); + expect(titleOptions).toEqual({ + text: 'MyChart', + fontSize: '30px', + fontWeight: 'normal', + fill: '#000000', + textAnchor: 'middle', + }); + }); + + it('should parse legend options correctly', () => { + const params = new CustomURLSearchParams( + 'legend=false&legend-align=right', + ); + const legendOptions = params.parseLegendOptions(); + expect(legendOptions).toEqual({ + show: false, + align: 'right', + }); + }); + + it('should return default mode if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getMode()).toBe('top-langs'); + }); + + it('should return parsed mode if key is present', () => { + const params = new CustomURLSearchParams('mode=custom-config'); + expect(params.getMode()).toBe('custom-config'); + }); + + it('should return default mode if parsed mode is invalid', () => { + const params = new CustomURLSearchParams('mode=invalid-mode'); + expect(params.getMode()).toBe('top-langs'); + }); + + it('should return default values display option if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getValuesDisplayOption('display-values')).toBe('legend'); + }); + + it('should return parsed values display option if key is present', () => { + const params = new CustomURLSearchParams('display-values=all'); + expect(params.getValuesDisplayOption('display-values')).toBe('all'); + }); + + it('should return default values display option if parsed value is invalid', () => { + const params = new CustomURLSearchParams('display-values=invalid'); + expect(params.getValuesDisplayOption('display-values')).toBe('legend'); + }); + + it('should return default title if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getStringValue('title', 'Bubble Chart')).toBe( + 'Bubble Chart', + ); + }); + + it('should return parsed title if key is present', () => { + const params = new CustomURLSearchParams('title=MyChart'); + expect(params.getStringValue('title', 'Bubble Chart')).toBe('MyChart'); + }); + + it('should return default legend alignment if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getStringValue('legend-align', 'center')).toBe('center'); + }); + + it('should return parsed legend alignment if key is present', () => { + const params = new CustomURLSearchParams('legend-align=right'); + expect(params.getStringValue('legend-align', 'center')).toBe('right'); + }); + + it('should return default title size if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getNumberValue('title-size', 24)).toBe(24); + }); + + it('should return parsed title size if key is present', () => { + const params = new CustomURLSearchParams('title-size=30'); + expect(params.getNumberValue('title-size', 24)).toBe(30); + }); + + it('should return default title weight if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getStringValue('title-weight', 'bold')).toBe('bold'); + }); + + it('should return parsed title weight if key is present', () => { + const params = new CustomURLSearchParams('title-weight=normal'); + expect(params.getStringValue('title-weight', 'bold')).toBe('normal'); + }); + + it('should return default title color if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getStringValue('title-color', '#000000')).toBe('#000000'); + }); + + it('should return parsed title color if key is present', () => { + const params = new CustomURLSearchParams('title-color=#ffffff'); + expect(params.getStringValue('title-color', '#000000')).toBe('#ffffff'); + }); + + it('should return default title alignment if key is not present', () => { + const params = new CustomURLSearchParams(''); + expect(params.getTextAnchorValue('title-align', 'middle')).toBe('middle'); + }); + + it('should return parsed title alignment if key is present', () => { + const params = new CustomURLSearchParams('title-align=center'); + expect(params.getTextAnchorValue('title-align', 'middle')).toBe('middle'); + }); + }); + + describe('parseParams', () => { + it('should parse URL parameters', () => { + const req = { url: 'http://example.com?key=value' }; + const params = parseParams(req as Request); + expect(params.get('key')).toBe('value'); + }); + + it('should return empty params if no query string is present', () => { + const req = { url: 'http://example.com' }; + const params = parseParams(req as Request); + expect(params.get('key')).toBeNull(); + }); + }); + + vi.mock('fs'); + vi.mock('path'); + vi.mock('../../src/common/utils', () => ({ + isDevEnvironment: vi.fn(), + mapConfigToBubbleChartOptions: vi.fn().mockReturnValue({ + titleOptions: { text: 'Test Chart' }, + } as unknown as CustomConfig), + })); + vi.stubGlobal('fetch', vi.fn()); + + const mockConfig: CustomConfig = { + options: { + titleOptions: { text: 'Test Chart' }, + } as unknown as CustomConfig['options'], + data: [{ name: 'Node.js', value: 50, color: '#68A063' }] as { + name: string; + value: number; + color: string; + }[], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('fetchConfigFromRepo', () => { + it('fetches configuration from local file in development environment', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(true); + const localPath = '/example-config.json'; + vi.mocked(path.resolve).mockReturnValue(localPath); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockConfig)); + vi.mocked(mapConfigToBubbleChartOptions); + + const result = await fetchConfigFromRepo('username', 'filePath'); + + expect(result).toEqual({ + options: { titleOptions: { text: 'Test Chart' } }, + data: [{ name: 'Node.js', value: 50, color: '#68A063' }], + }); + expect(fs.existsSync).toHaveBeenCalledWith(localPath); + expect(fs.readFileSync).toHaveBeenCalledWith(localPath, 'utf-8'); + expect(mapConfigToBubbleChartOptions).toHaveBeenCalledWith( + mockConfig.options, + ); + }); + + it('throws an error if local config file is missing in development environment', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(true); + vi.mocked(path.resolve).mockReturnValue('/example-config.json'); + vi.mocked(fs.existsSync).mockReturnValue(false); + + await expect(fetchConfigFromRepo('username', 'filePath')).rejects.toThrow( + FetchError, + ); + }); + + it('fetches configuration from GitHub in non-development environment', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(false); + const mockResponse = { + ok: true, + json: async () => mockConfig, + } as Response; + vi.mocked(fetch).mockResolvedValue(mockResponse); + + const result = await fetchConfigFromRepo('username', 'filePath'); + + expect(result).toEqual({ + options: { titleOptions: { text: 'Test Chart' } }, + data: [{ name: 'Node.js', value: 50, color: '#68A063' }], + }); + expect(fetch).toHaveBeenCalledWith( + 'https://raw.githubusercontent.com/username/username/main/filePath', + expect.objectContaining({ + headers: { Authorization: expect.any(String) }, + }), + ); + }); + + it('throws GitHubNotFoundError if the file is not found on GitHub', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(false); + const mockResponse = { ok: false, status: 404 } as Response; + vi.mocked(fetch).mockResolvedValue(mockResponse); + + await expect(fetchConfigFromRepo('username', 'filePath')).rejects.toThrow( + GitHubNotFoundError, + ); + }); + + it('throws GitHubRateLimitError if the rate limit is exceeded', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(false); + const mockResponse = { + ok: false, + status: 403, + headers: { get: vi.fn(() => '0') }, + } as unknown as Response; + vi.mocked(fetch).mockResolvedValue(mockResponse); + + await expect(fetchConfigFromRepo('username', 'filePath')).rejects.toThrow( + GitHubRateLimitError, + ); + }); + + it('throws FetchError for other HTTP errors', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(false); + const mockResponse = { ok: false, status: 500 } as Response; + vi.mocked(fetch).mockResolvedValue(mockResponse); + + await expect(fetchConfigFromRepo('username', 'filePath')).rejects.toThrow( + FetchError, + ); + }); + + it('throws ValidationError if JSON parsing fails', async () => { + vi.mocked(isDevEnvironment).mockReturnValue(false); + const mockResponse = { + ok: true, + json: async () => { + throw new Error('Invalid JSON'); + }, + } as unknown as Response; + vi.mocked(fetch).mockResolvedValue(mockResponse); + + await expect(fetchConfigFromRepo('username', 'filePath')).rejects.toThrow( + ValidationError, + ); + }); + }); +}); diff --git a/tests/chart/generator.test.ts b/tests/chart/generator.test.ts index a86b3ac..6bcf381 100644 --- a/tests/chart/generator.test.ts +++ b/tests/chart/generator.test.ts @@ -1,22 +1,35 @@ +import { describe, it, expect } from 'vitest'; import { createBubbleChart } from '../../src/chart/generator'; -import { BubbleData, BubbleChartOptions } from '../../src/chart/types'; -import { LightTheme } from '../../src/chart/themes'; +import { + getCommonStyles, + getLegendItemAnimationStyle, +} from '../../src/chart/styles'; +import { LightTheme, ThemeBase } from '../../src/chart/themes'; +import { BubbleData } from '../../src/chart/types/bubbleData'; +import { + BubbleChartOptions, + LegendOptions, + TitleOptions, +} from '../../src/chart/types/chartOptions'; +import { GeneratorError, StyleError } from '../../src/errors/custom-errors'; -describe('generator', () => { +describe('Generator', () => { describe('createBubbleChart', () => { - it('should return null if no data is provided', () => { + it('should return null if no data is provided', async () => { const options: BubbleChartOptions = { width: 600, height: 400, - titleOptions: { text: 'Test Chart' }, - showPercentages: false, + titleOptions: { text: 'Test Chart' } as TitleOptions, + displayValues: 'none', + usePercentages: false, legendOptions: { show: false, align: 'left' }, theme: new LightTheme(), }; - expect(createBubbleChart([], options)).toBeNull(); + const result = await createBubbleChart([], options); + expect(result).toBeNull(); }); - it('should generate SVG string for bubble chart', () => { + it('should generate SVG string for bubble chart', async () => { const data: BubbleData[] = [ { name: 'JavaScript', value: 70, color: 'yellow' }, { name: 'TypeScript', value: 30, color: 'blue' }, @@ -24,15 +37,464 @@ describe('generator', () => { const options: BubbleChartOptions = { width: 600, height: 400, - titleOptions: { text: 'Test Chart' }, - showPercentages: true, + titleOptions: { text: 'Test Chart' } as TitleOptions, + displayValues: 'all', + usePercentages: true, legendOptions: { show: true, align: 'center' }, theme: new LightTheme(), }; - const svg = createBubbleChart(data, options); + const svg = await createBubbleChart(data, options); expect(svg).toContain(' { + expect(svg).toContain(`.bubble-${index}`); + expect(svg).toContain(`@keyframes float-${index}`); + }); + }); + + it('should throw GeneratorError if bubble creation fails', async () => { + const data: BubbleData[] = [ + { name: 'JavaScript', value: 70, color: 'yellow' }, + ]; + const options: BubbleChartOptions = { + width: NaN, + height: 400, + titleOptions: { text: 'Test Chart' } as TitleOptions, + displayValues: 'all', + usePercentages: true, + legendOptions: { show: true, align: 'center' }, + theme: new LightTheme(), + }; + await expect(createBubbleChart(data, options)).rejects.toThrow( + GeneratorError, + ); + }); + + it('should escape special characters in data names', async () => { + const data: BubbleData[] = [ + { name: 'JavaScript & TypeScript', value: 70, color: 'yellow' }, + ]; + const options: BubbleChartOptions = { + width: 600, + height: 400, + titleOptions: { text: 'Test Chart' } as TitleOptions, + displayValues: 'all', + usePercentages: false, + legendOptions: { show: true, align: 'center' }, + theme: new LightTheme(), + }; + const svg = await createBubbleChart(data, options); + expect(svg).toContain('JavaScript & TypeScript'); + }); + + it('should handle invalid width or height', async () => { + const data: BubbleData[] = [ + { name: 'JavaScript', value: 70, color: 'yellow' }, + ]; + const options: BubbleChartOptions = { + width: NaN, + height: 400, + titleOptions: { text: 'Test Chart' } as TitleOptions, + displayValues: 'all', + usePercentages: false, + legendOptions: { show: true, align: 'center' }, + theme: new LightTheme(), + }; + await expect(createBubbleChart(data, options)).rejects.toThrow( + GeneratorError, + ); + }); + + it('should create title element if no bubble image is provided', async () => { + const data: BubbleData[] = [ + { name: 'JavaScript', value: 70, color: 'yellow' }, + ]; + const options: BubbleChartOptions = { + width: 600, + height: 400, + titleOptions: { + text: 'Test Chart', + fontSize: '16px', + fontWeight: 'bold', + } as TitleOptions, + displayValues: 'all', + usePercentages: false, + legendOptions: { show: true, align: 'center' }, + theme: new LightTheme(), + }; + const svg = await createBubbleChart(data, options); + expect(svg).toContain(' { + const data: BubbleData[] = [ + { name: 'JavaScript', value: 70, color: 'yellow' }, + ]; + const options: BubbleChartOptions = { + width: 600, + height: 400, + titleOptions: { + text: 'Test Chart', + fontSize: '16px', + fontWeight: 'bold', + } as TitleOptions, + displayValues: 'all', + usePercentages: false, + legendOptions: { show: true, align: 'center' }, + theme: new LightTheme(), + }; + const svg = await createBubbleChart(data, options); + expect(svg).toContain('height="'); + }); + + it('should include common styles in the SVG', async () => { + const data: BubbleData[] = [ + { name: 'JavaScript', value: 70, color: 'yellow' }, + ]; + const options: BubbleChartOptions = { + width: 600, + height: 400, + titleOptions: { text: 'Test Chart' } as TitleOptions, + displayValues: 'all', + usePercentages: false, + legendOptions: { show: true, align: 'center' }, + theme: new LightTheme(), + }; + const svg = await createBubbleChart(data, options); + expect(svg).toContain('