diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b502bc9a043e2..8a4b9abc85a5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Dependencies uses: ./.github/actions/install-dependencies @@ -59,7 +59,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Dependencies uses: ./.github/actions/install-dependencies @@ -67,8 +67,10 @@ jobs: - name: Build packages run: pnpm run build:packages + # Only install chromium since it is the only browser enabled in + # apps/frontend/playwright.config.ts. Update this if more projects are added. - name: Install Playwright Browsers - run: pnpm exec playwright install --with-deps + run: pnpm exec playwright install --with-deps chromium - name: Run Playwright tests run: pnpm --filter ./apps/frontend/ run test:e2e @@ -85,7 +87,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Dependencies uses: ./.github/actions/install-dependencies @@ -106,7 +108,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Dependencies uses: ./.github/actions/install-dependencies diff --git a/.github/workflows/generate-theme-doc.yml b/.github/workflows/generate-theme-doc.yml index 93789b8ba7893..28143a0918174 100644 --- a/.github/workflows/generate-theme-doc.yml +++ b/.github/workflows/generate-theme-doc.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Dependencies uses: ./.github/actions/install-dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8618ad7bf9000..f9b50b4e35fe8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Dependencies uses: ./.github/actions/install-dependencies diff --git a/.github/workflows/repeat-recent-requests.yml b/.github/workflows/repeat-recent-requests.yml index 40987e5b2e66d..1134f54d4d0dc 100644 --- a/.github/workflows/repeat-recent-requests.yml +++ b/.github/workflows/repeat-recent-requests.yml @@ -10,9 +10,11 @@ on: # │ │ │ │ │ # │ │ │ │ │ # * * * * * - - cron: "45 * * * *" + - cron: "15,45 * * * *" workflow_dispatch: +permissions: {} + jobs: triggerRepeatRecent: if: | @@ -24,7 +26,7 @@ jobs: steps: - name: Make Request id: myRequest - uses: fjogeleit/http-request-action@551353b829c3646756b2ec2b3694f819d7957495 # v2 + uses: fjogeleit/http-request-action@f98bb26551cd1af7d3eb2674578a80754ece88db # v2 with: url: "https://github-stats-extended.vercel.app/api/repeat-recent" method: "POST" diff --git a/.github/workflows/update-langs.yml b/.github/workflows/update-langs.yml index 0feeed86e8448..5076133b62fcb 100644 --- a/.github/workflows/update-langs.yml +++ b/.github/workflows/update-langs.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Dependencies uses: ./.github/actions/install-dependencies diff --git a/.github/workflows/update-tags.yml b/.github/workflows/update-tags.yml index ea81a2e180919..6b1750a584825 100644 --- a/.github/workflows/update-tags.yml +++ b/.github/workflows/update-tags.yml @@ -14,5 +14,5 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: haya14busa/action-update-semver@7d2c558640ea49e798d46539536190aff8c18715 # v1.5.1 diff --git a/.vscode/settings.json b/.vscode/settings.json index fe8961d381a79..904139152e151 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,8 +3,7 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "[javascript]": { - "editor.tabSize": 2, "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "cSpell.words": ["Wakatime"] + "cSpell.words": ["retryer", "Wakatime"] } diff --git a/apps/backend/_dot_vercel_copy/output/functions/api.prerender-config.json b/apps/backend/_dot_vercel_copy/output/functions/api.prerender-config.json index f848b3a003397..940b877a54244 100644 --- a/apps/backend/_dot_vercel_copy/output/functions/api.prerender-config.json +++ b/apps/backend/_dot_vercel_copy/output/functions/api.prerender-config.json @@ -1,5 +1,5 @@ { - "expiration": 39600, + "expiration": 255600, "bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n", "passQuery": true } diff --git a/apps/backend/_dot_vercel_copy/output/functions/api/gist.prerender-config.json b/apps/backend/_dot_vercel_copy/output/functions/api/gist.prerender-config.json index f848b3a003397..940b877a54244 100644 --- a/apps/backend/_dot_vercel_copy/output/functions/api/gist.prerender-config.json +++ b/apps/backend/_dot_vercel_copy/output/functions/api/gist.prerender-config.json @@ -1,5 +1,5 @@ { - "expiration": 39600, + "expiration": 255600, "bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n", "passQuery": true } diff --git a/apps/backend/_dot_vercel_copy/output/functions/api/pin.prerender-config.json b/apps/backend/_dot_vercel_copy/output/functions/api/pin.prerender-config.json index f848b3a003397..940b877a54244 100644 --- a/apps/backend/_dot_vercel_copy/output/functions/api/pin.prerender-config.json +++ b/apps/backend/_dot_vercel_copy/output/functions/api/pin.prerender-config.json @@ -1,5 +1,5 @@ { - "expiration": 39600, + "expiration": 255600, "bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n", "passQuery": true } diff --git a/apps/backend/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json b/apps/backend/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json index f848b3a003397..940b877a54244 100644 --- a/apps/backend/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json +++ b/apps/backend/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json @@ -1,5 +1,5 @@ { - "expiration": 39600, + "expiration": 255600, "bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n", "passQuery": true } diff --git a/apps/backend/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json b/apps/backend/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json index f848b3a003397..940b877a54244 100644 --- a/apps/backend/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json +++ b/apps/backend/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json @@ -1,5 +1,5 @@ { - "expiration": 39600, + "expiration": 255600, "bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n", "passQuery": true } diff --git a/apps/backend/package.json b/apps/backend/package.json index 299115e49ff57..a1530b4be811a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -22,8 +22,8 @@ "vitest": "catalog:default" }, "dependencies": { - "axios": "catalog:default", "@stats-organization/github-readme-stats-core": "workspace:^", - "pg": "^8.21.0" + "axios": "catalog:default", + "pg": "^8.22.0" } } diff --git a/apps/backend/src/repeatRequests.js b/apps/backend/src/repeatRequests.js index 29cddf318775e..582a135809136 100644 --- a/apps/backend/src/repeatRequests.js +++ b/apps/backend/src/repeatRequests.js @@ -66,6 +66,6 @@ export async function repeatRecentRequests() { if (urls.length === 0) { console.log("No recent requests found."); } else { - await makeRequests(urls, 5); + await makeRequests(urls, 3); } } diff --git a/apps/backend/tests/e2e/e2e.test.js b/apps/backend/tests/e2e/e2e.test.js index 3b13143f776f7..e440e58abea1e 100644 --- a/apps/backend/tests/e2e/e2e.test.js +++ b/apps/backend/tests/e2e/e2e.test.js @@ -29,7 +29,7 @@ const STATS_MOCK_RESPONSE = { followers: { totalCount: 0 }, repositories: { totalCount: 1, - nodes: [{ name: REPO, stargazers: { totalCount: 1 } }], + nodes: [{ name: REPO, stargazerCount: 1 }], pageInfo: { hasNextPage: false, endCursor: "cursor", @@ -110,9 +110,7 @@ const REPO_MOCK_RESPONSE = { isPrivate: false, isArchived: false, isTemplate: false, - stargazers: { - totalCount: 1, - }, + stargazerCount: 1, description: "Simple cra test repo.", primaryLanguage: { color: "#f1e05a", diff --git a/apps/backend/tests/public-instance/__snapshots__/api.test.js.snap b/apps/backend/tests/public-instance/__snapshots__/api.test.js.snap index 1edbcd227b6ed..c8123c5428061 100644 --- a/apps/backend/tests/public-instance/__snapshots__/api.test.js.snap +++ b/apps/backend/tests/public-instance/__snapshots__/api.test.js.snap @@ -443,7 +443,7 @@ exports[`Test /api contract > should match the public many-params response snaps ", - "graphqlRequest": "{"query":"\\n query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection (from: $startTime) {\\n totalCommitContributions,\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n \\n repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {\\n totalCount\\n nodes {\\n name\\n stargazers {\\n totalCount\\n }\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n\\n }\\n }\\n","variables":{"login":"anuraghazra","first":100,"after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}", + "graphqlRequest": "{"query":"\\n query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $startTime: DateTime = null, $ownerAffiliations: [RepositoryAffiliation]) {\\n user(login: $login) {\\n name\\n login\\n commits: contributionsCollection (from: $startTime) {\\n totalCommitContributions,\\n }\\n reviews: contributionsCollection {\\n totalPullRequestReviewContributions\\n }\\n repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) {\\n totalCount\\n }\\n pullRequests(first: 1) {\\n totalCount\\n }\\n mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) {\\n totalCount\\n }\\n openIssues: issues(states: OPEN) {\\n totalCount\\n }\\n closedIssues: issues(states: CLOSED) {\\n totalCount\\n }\\n followers {\\n totalCount\\n }\\n repositoryDiscussions @include(if: $includeDiscussions) {\\n totalCount\\n }\\n repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) {\\n totalCount\\n }\\n \\n repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) {\\n totalCount\\n nodes {\\n name\\n stargazerCount\\n }\\n pageInfo {\\n hasNextPage\\n endCursor\\n }\\n }\\n\\n }\\n }\\n","variables":{"login":"anuraghazra","first":100,"after":null,"includeMergedPullRequests":true,"includeDiscussions":true,"includeDiscussionsAnswers":true,"startTime":"2024-01-01T00:00:00Z","ownerAffiliations":["OWNER","COLLABORATOR"]}}", "headers": [ [ "Cache-Control", diff --git a/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap b/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap index 32426540c9ad5..9624970b54159 100644 --- a/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap +++ b/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap @@ -341,7 +341,7 @@ exports[`Test /api/pin contract > should match the public many-params response s ", - "graphqlRequest": "{"query":"\\n fragment RepoInfo on Repository {\\n name\\n nameWithOwner\\n isPrivate\\n isArchived\\n isTemplate\\n stargazers {\\n totalCount\\n }\\n description\\n primaryLanguage {\\n color\\n id\\n name\\n }\\n forkCount\\n }\\n query getRepo($login: String!, $repo: String!) {\\n user(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n organization(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","repo":"convoychat"}}", + "graphqlRequest": "{"query":"\\n fragment RepoInfo on Repository {\\n name\\n nameWithOwner\\n isPrivate\\n isArchived\\n isTemplate\\n stargazerCount\\n description\\n primaryLanguage {\\n color\\n id\\n name\\n }\\n forkCount\\n }\\n query getRepo($login: String!, $repo: String!) {\\n user(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n organization(login: $login) {\\n repository(name: $repo) {\\n ...RepoInfo\\n }\\n }\\n }\\n ","variables":{"login":"anuraghazra","repo":"convoychat"}}", "headers": [ [ "Cache-Control", diff --git a/apps/backend/tests/utils.js b/apps/backend/tests/utils.js index 8602f932370b1..739845ca5b807 100644 --- a/apps/backend/tests/utils.js +++ b/apps/backend/tests/utils.js @@ -26,9 +26,9 @@ export const data_stats = { repositories: { totalCount: 3, nodes: [ - { name: "repo-keep-1", stargazers: { totalCount: 1500 } }, - { name: "repo-exclude-me", stargazers: { totalCount: 9999 } }, - { name: "repo-keep-2", stargazers: { totalCount: 2600 } }, + { name: "repo-keep-1", stargazerCount: 1500 }, + { name: "repo-exclude-me", stargazerCount: 9999 }, + { name: "repo-keep-2", stargazerCount: 2600 }, ], pageInfo: { hasNextPage: false, @@ -73,9 +73,7 @@ export const data_user = { username: "anuraghazra", name: "convoychat", nameWithOwner: "anuraghazra/convoychat", - stargazers: { - totalCount: 38000, - }, + stargazerCount: 38000, description: "Help us take over the world with a deeply customizable React, TypeScript and GraphQL chat app that has enough text to wrap across multiple lines in the repository card.", primaryLanguage: { diff --git a/apps/frontend/e2e/app-trends-auth.spec.ts b/apps/frontend/e2e/app-trends-auth.spec.ts new file mode 100644 index 0000000000000..6976e1eb0fc33 --- /dev/null +++ b/apps/frontend/e2e/app-trends-auth.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +/** + * Puts the SPA into an authenticated state without contacting GitHub by + * stubbing the OAuth code exchange and the follow-up user-access lookup: + * `authenticate` returns a userId (which flips `isAuthenticated` to true), and + * `user-access` returns metadata so AppTrends does not immediately log back out. + * @param page - The Playwright page to install the route handlers on. + */ +async function mockAuthEndpoints(page: Page): Promise { + await page.route("**/api/authenticate**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ userId: "octocat", needDowngrade: false }), + }), + ); + await page.route("**/api/user-access**", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ token: "test-token", privateAccess: "false" }), + }), + ); +} + +test.describe("AppTrends auth-driven stage transition", () => { + test("auto-advances from Login to Select a Card once authenticated", async ({ + page, + }) => { + await mockAuthEndpoints(page); + + // Land on the app as GitHub does after the OAuth redirect (URL carries `code`). + await page.goto("?code=test-oauth-code"); + + // AppTrends starts unauthenticated on stage 0 ("Login"). When the code + // exchange flips `isAuthenticated` false -> true, the render-phase + // transition must advance the stepper to stage 1 ("Select a Card"). + await expect(page.getByRole("heading", { level: 1 })).toContainText( + "Select a Card", + ); + }); + + test("keeps a manually selected earlier step instead of re-forcing it", async ({ + page, + }) => { + await mockAuthEndpoints(page); + await page.goto("?code=test-oauth-code"); + + const heading = page.getByRole("heading", { level: 1 }); + await expect(heading).toContainText("Select a Card"); + + // Going back to "Login" while still authenticated must stick: the transition + // fires only on an auth *change*, so a later render must not snap the user + // forward again. A bug that dropped the previous-value guard would bounce + // the heading straight back to "Select a Card" here. + await page.getByRole("button", { name: "Login" }).click(); + await expect(heading).toContainText("Login"); + }); +}); diff --git a/apps/frontend/e2e/stats-rank.spec.ts b/apps/frontend/e2e/stats-rank.spec.ts new file mode 100644 index 0000000000000..4bbfe8b4375fe --- /dev/null +++ b/apps/frontend/e2e/stats-rank.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +test("selecting 'None' progress style hides the rank circle", async ({ + page, +}) => { + await page.goto(""); + + // Stage 0 -> 2: go to customization + await page.getByRole("button", { name: "Modify Parameters" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toContainText( + "Modify Card Parameters", + ); + + const preview = page.locator("#svgWrapper"); + const rankCircle = preview.locator('[data-testid="rank-circle"]'); + + // Default "Rank" progress style renders the rank circle. + await expect(preview).toBeAttached(); + await expect(rankCircle).toHaveCount(1); + + await page.getByRole("combobox").selectOption({ label: "None" }); + + await expect(rankCircle).toHaveCount(0); +}); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index ba6d49fa159a2..13bce35494c22 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -14,29 +14,29 @@ }, "dependencies": { "@reduxjs/toolkit": "^2.12.0", - "@tailwindcss/vite": "^4.3.0", - "axios": "catalog:default", - "axios-cache-interceptor": "^1", - "daisyui": "^5.5.23", "@stats-organization/github-readme-stats-backend": "workspace:^", "@stats-organization/github-readme-stats-core": "workspace:^", + "@tailwindcss/vite": "^4.3.3", + "axios": "catalog:default", + "axios-cache-interceptor": "^1.12.0", + "daisyui": "^5.6.18", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-icons": "^5.6.0", - "react-loading-skeleton": "^3.3.1", + "react-icons": "^5.7.0", + "react-loading-skeleton": "^3.5.0", "react-redux": "^9.3.0", "react-spinners": "^0.17.0", "react-toastify": "^11.1.0", "redux": "^5.0.1", "save-svg-as-png": "^1.4.17", - "uuid": "^14.0.0" + "uuid": "^14.0.1" }, "devDependencies": { "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "6.0.2", + "@vitejs/plugin-react": "6.0.3", "clsx": "2.1.1", - "tailwindcss": "4.3.0", + "tailwindcss": "4.3.3", "vite": "catalog:default", "vitest": "catalog:default" }, diff --git a/apps/frontend/src/components/Card/Card.tsx b/apps/frontend/src/components/Card/Card.tsx index b5f35c5c11d65..17f8fb3118d44 100644 --- a/apps/frontend/src/components/Card/Card.tsx +++ b/apps/frontend/src/components/Card/Card.tsx @@ -1,13 +1,15 @@ import { clsx } from "clsx"; import type { CSSProperties, JSX } from "react"; +import type { CardUrlBuilder } from "../../models/CardUrl"; + import { CardImage } from "./CardImage"; import { LIGHT_CARD_BG } from "./themeBackdrop"; interface CardProps { title: string; description: string; - imageSrc: string; + card: CardUrlBuilder; stage: number; selected?: boolean; compact?: boolean; @@ -21,7 +23,7 @@ interface CardProps { export const Card = ({ title, description, - imageSrc, + card, stage, selected = false, compact = false, @@ -68,7 +70,7 @@ export const Card = ({

{description}

{ - const fullImageSrc = `https://${HOST}/api${imageSrc}&client=wizard`; + // `client=wizard` marks requests coming from the wizard preview. + const fullImageSrc = card.client("wizard").toApiUrl(HOST); return (
diff --git a/apps/frontend/src/components/Generic/ThemePicker.tsx b/apps/frontend/src/components/Generic/ThemePicker.tsx index 4fa7fe9f0bf39..a4397497f62d0 100644 --- a/apps/frontend/src/components/Generic/ThemePicker.tsx +++ b/apps/frontend/src/components/Generic/ThemePicker.tsx @@ -84,7 +84,7 @@ export function ThemePicker(): JSX.Element {
) : ( - [ - "", - "You will be able to customize your card in future steps.", - "", - "", - "Display the finished card on GitHub, Twitter/X, LinkedIn, or anywhere else!", - ][stage] + STAGE_LABELS[stage].description )} @@ -298,49 +242,15 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { {stage === 2 && ( )} {stage === 3 && ( { setTheme(theme); @@ -350,50 +260,14 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { )} {stage === 4 && ( { - switch (selectedCard) { - case CardType.STATS: - case CardType.TOP_LANGS: - return `${selectedUserId}_card`; - case CardType.PIN: - return `${repo}_card`; - case CardType.GIST: - return `gist_card`; - case CardType.WAKATIME: - return `${wakatimeUser}_card`; - default: - selectedCard satisfies never; - return ""; - } - })()} - link={(() => { - switch (selectedCard) { - case CardType.STATS: - case CardType.TOP_LANGS: - return `https://${HOST}/api${themeSuffix}`; - - case CardType.PIN: { - let myRepo = repo; - if (!myRepo.includes("/")) { - myRepo = `${userId}/${myRepo}`; - } - return `https://github.com/${myRepo}`; - } - case CardType.GIST: - return gistUrl; - case CardType.WAKATIME: - return `https://wakatime.com/@${wakatimeUser}`; - default: - selectedCard satisfies never; - return ""; - } - })()} + filename={cardBuilder.filename()} + link={cardDescriptor.link} theme={theme} - themeSuffix={themeSuffix} + card={themeBuilder} guestHint={ isAuthenticated ? null - : `Replace the sample ${guestHint} with your own after copying your Markdown or URL!` + : `Replace the sample ${cardDescriptor.guestHint} with your own after copying your Markdown or URL!` } /> )} diff --git a/apps/frontend/src/pages/Home/getFullSuffix.test.ts b/apps/frontend/src/pages/Home/buildCardUrl.test.ts similarity index 62% rename from apps/frontend/src/pages/Home/getFullSuffix.test.ts rename to apps/frontend/src/pages/Home/buildCardUrl.test.ts index 5351cb9fb858c..350eecd12c848 100644 --- a/apps/frontend/src/pages/Home/getFullSuffix.test.ts +++ b/apps/frontend/src/pages/Home/buildCardUrl.test.ts @@ -5,12 +5,15 @@ import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/Stat import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection"; import { CardType } from "../../models/CardType"; -import { getFullSuffix } from "./getFullSuffix"; +import { buildCardUrl } from "./buildCardUrl"; +import type { CardOptions } from "./cardOptions"; -const baseOptions = { - selectedCard: CardType.STATS, +const USER_ID = "john-github"; + +// Built inline instead of via `getDefaultCardOptions`, which imports +// `constants.ts` and needs a `window` global these node-based tests lack. +const baseOptions: CardOptions = { selectedUserId: "john", - userId: "john-github", repo: "repo1", gist: "gist1", wakatimeUser: "wakaUser", @@ -30,15 +33,15 @@ const baseOptions = { usePercent: false, }; -describe("getFullSuffix", () => { +describe("buildCardUrl", () => { it("builds stats suffix with defaults", () => { - const result = getFullSuffix(baseOptions); + const result = buildCardUrl(USER_ID, CardType.STATS, baseOptions); - expect(result).toBe("?username=john"); + expect(result.toString()).toBe("?username=john"); }); it("adds stats options", () => { - const result = getFullSuffix({ + const result = buildCardUrl(USER_ID, CardType.STATS, { ...baseOptions, showIcons: true, includeAllCommits: true, @@ -46,73 +49,69 @@ describe("getFullSuffix", () => { showTitle: false, }); - expect(result).toBe( + expect(result.toString()).toBe( "?username=john" + "&hide_title=true" + - "&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented" + + // commas are percent-encoded (%2C) now that params go through URLSearchParams + "&show=reviews%2Cdiscussions_started%2Cdiscussions_answered%2Cprs_merged%2Cprs_merged_percentage%2Cprs_commented%2Cprs_reviewed%2Cissues_commented" + "&show_icons=true" + "&include_all_commits=true", ); }); it("builds top-langs suffix", () => { - const result = getFullSuffix({ + const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, { ...baseOptions, - selectedCard: CardType.TOP_LANGS, langsCount: 5, showTitle: false, }); - expect(result).toBe( + expect(result.toString()).toBe( "/top-langs?username=john&hide_title=true&langs_count=5", ); }); it("builds pin suffix using userId not selectedUserId", () => { - const result = getFullSuffix({ + const result = buildCardUrl(USER_ID, CardType.PIN, { ...baseOptions, - selectedCard: CardType.PIN, showOwner: true, descriptionLines: 3, }); - expect(result).toBe( + expect(result.toString()).toBe( "/pin?username=john-github&repo=repo1&show_owner=true&description_lines_count=3", ); }); it("builds gist suffix", () => { - const result = getFullSuffix({ + const result = buildCardUrl(USER_ID, CardType.GIST, { ...baseOptions, - selectedCard: CardType.GIST, showOwner: true, }); - expect(result).toBe("/gist?id=gist1&show_owner=true"); + expect(result.toString()).toBe("/gist?id=gist1&show_owner=true"); }); it("builds wakatime suffix with percent and custom title", () => { - const result = getFullSuffix({ + const result = buildCardUrl(USER_ID, CardType.WAKATIME, { ...baseOptions, - selectedCard: CardType.WAKATIME, wakatimeUser: "waka", usePercent: true, customTitle: "My Stats", showTitle: false, }); - expect(result).toBe( + expect(result.toString()).toBe( "/wakatime?username=waka&hide_title=true&custom_title=My%20Stats&display_format=percent", ); }); it("adds non-default layouts", () => { - const result = getFullSuffix({ + const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, { ...baseOptions, - selectedCard: CardType.TOP_LANGS, selectedLanguagesLayout: { id: 2, value: "compact", label: "Compact" }, }); - expect(result).toBe("/top-langs?username=john&layout=compact"); + expect(result.toString()).toBe("/top-langs?username=john&layout=compact"); }); }); diff --git a/apps/frontend/src/pages/Home/buildCardUrl.ts b/apps/frontend/src/pages/Home/buildCardUrl.ts new file mode 100644 index 0000000000000..70743b823612c --- /dev/null +++ b/apps/frontend/src/pages/Home/buildCardUrl.ts @@ -0,0 +1,175 @@ +import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection"; +import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection"; +import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection"; +import { CardType } from "../../models/CardType"; +import { cardUrl } from "../../models/CardUrl"; +import type { CardUrlBuilder } from "../../models/CardUrl"; + +import type { CardOptions } from "./cardOptions"; + +export function buildCardUrl( + userId: string, + selectedCard: CardType, + options: CardOptions, +): CardUrlBuilder { + const { + selectedUserId, + repo, + gist, + wakatimeUser, + selectedStatsRank, + selectedLanguagesLayout, + selectedWakatimeLayout, + showTitle, + showOwner, + descriptionLines, + customTitle, + langsCount, + hideValues, + showAllStats, + showIcons, + includeAllCommits, + enableAnimations, + usePercent, + } = options; + + switch (selectedCard) { + case CardType.STATS: { + let url = cardUrl(CardType.STATS); + if (selectedUserId) { + url = url.username(selectedUserId); + } + if (selectedStatsRank !== STATS_DEFAULT_RANK) { + if (selectedStatsRank.value === "hide_rank") { + url = url.hideRank(); + } else { + url = url.rankIcon(selectedStatsRank.value); + } + } + if (!showTitle) { + url = url.hideTitle(); + } + if (customTitle) { + url = url.customTitle(customTitle); + } + if (showAllStats) { + url = url.show( + "reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented", + ); + } + if (showIcons) { + url = url.showIcons(); + } + if (includeAllCommits) { + url = url.includeAllCommits(); + } + if (!enableAnimations) { + url = url.disableAnimations(); + } + return url; + } + + case CardType.TOP_LANGS: { + let url = cardUrl(CardType.TOP_LANGS); + if (selectedUserId) { + url = url.username(selectedUserId); + } + if (selectedLanguagesLayout !== LANGUAGES_DEFAULT_LAYOUT) { + if (selectedLanguagesLayout.value === "hide_progress") { + url = url.hideProgress(); + } else { + url = url.layout(selectedLanguagesLayout.value); + } + } + if (!showTitle) { + url = url.hideTitle(); + } + if (langsCount) { + url = url.langsCount(langsCount); + } + if (hideValues) { + url = url.hideValues(); + } + if (!enableAnimations) { + url = url.disableAnimations(); + } + return url; + } + + case CardType.PIN: { + /** + * We should use the name of the logged-in user, not the value entered in the + * username field in step 3. + * + * This input is not shown when the PIN card type is selected, + * but it may still contain a different value if the user previously chose another card type. + * + * For example, + * 1. the user could select the STATS card + * 2. enter a username + * 3. then go back and switch to the PIN card, leaving the old value behind. + * + * @see https://github.com/stats-organization/github-stats-extended/pull/73#discussion_r2792177515 + */ + let url = cardUrl(CardType.PIN); + if (userId) { + url = url.username(userId); + } + if (repo) { + url = url.repo(repo); + } + if (showOwner) { + url = url.showOwner(); + } + if (descriptionLines) { + url = url.descriptionLines(descriptionLines); + } + return url; + } + + case CardType.GIST: { + let url = cardUrl(CardType.GIST); + if (gist) { + url = url.gistId(gist); + } + if (showOwner) { + url = url.showOwner(); + } + return url; + } + + case CardType.WAKATIME: { + let url = cardUrl(CardType.WAKATIME); + if (wakatimeUser) { + url = url.username(wakatimeUser); + } + if (selectedWakatimeLayout !== WAKATIME_DEFAULT_LAYOUT) { + if (selectedWakatimeLayout.value === "hide_progress") { + url = url.hideProgress().cardWidth(315); + } else { + url = url.layout(selectedWakatimeLayout.value); + } + } + if (!showTitle) { + url = url.hideTitle(); + } + if (customTitle) { + url = url.customTitle(customTitle); + } + if (langsCount) { + url = url.langsCount(langsCount); + } + if (!enableAnimations) { + url = url.disableAnimations(); + } + if (usePercent) { + url = url.displayFormat("percent"); + } + return url; + } + + default: + selectedCard satisfies never; + throw new Error(`unknown card type: ${selectedCard as string}`); + } +} diff --git a/apps/frontend/src/pages/Home/cardOptions.ts b/apps/frontend/src/pages/Home/cardOptions.ts new file mode 100644 index 0000000000000..7e9a3650a60e1 --- /dev/null +++ b/apps/frontend/src/pages/Home/cardOptions.ts @@ -0,0 +1,53 @@ +import type { SelectOption } from "../../components/Generic/Select"; +import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection"; +import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection"; +import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection"; +import { DEMO_GIST, DEMO_REPO, DEMO_WAKATIME_USER } from "../../constants"; + +/** + * All user-tunable card parameters collected during the customize stage. + * Held as a single state object in `HomeScreen` and updated one key at a time. + */ +export interface CardOptions { + selectedUserId: string; + repo: string; + gist: string; + wakatimeUser: string; + selectedStatsRank: SelectOption; + selectedLanguagesLayout: SelectOption; + selectedWakatimeLayout: SelectOption; + showTitle: boolean; + showOwner: boolean; + descriptionLines: number | undefined; + customTitle: string; + langsCount: number | undefined; + hideValues: boolean; + showAllStats: boolean; + showIcons: boolean; + includeAllCommits: boolean; + enableAnimations: boolean; + usePercent: boolean; +} + +export function getDefaultCardOptions(userId: string): CardOptions { + return { + selectedUserId: userId, + repo: DEMO_REPO, + gist: DEMO_GIST, + wakatimeUser: DEMO_WAKATIME_USER, + selectedStatsRank: STATS_DEFAULT_RANK, + selectedLanguagesLayout: LANGUAGES_DEFAULT_LAYOUT, + selectedWakatimeLayout: WAKATIME_DEFAULT_LAYOUT, + showTitle: true, + showOwner: false, + descriptionLines: undefined, + customTitle: "", + langsCount: undefined, + hideValues: false, + showAllStats: false, + showIcons: false, + includeAllCommits: true, + enableAnimations: true, + usePercent: false, + }; +} diff --git a/apps/frontend/src/pages/Home/getFullSuffix.ts b/apps/frontend/src/pages/Home/getFullSuffix.ts deleted file mode 100644 index e6dc776d7f199..0000000000000 --- a/apps/frontend/src/pages/Home/getFullSuffix.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { SelectOption } from "../../components/Generic/Select"; -import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection"; -import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection"; -import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection"; -import { CardType } from "../../models/CardType"; - -interface Options { - userId: string; - selectedUserId: string; - selectedCard: CardType; - repo: string; - gist: string; - wakatimeUser: string; - selectedStatsRank: SelectOption; - selectedLanguagesLayout: SelectOption; - selectedWakatimeLayout: SelectOption; - showTitle: boolean; - showOwner: boolean; - descriptionLines: number | undefined; - customTitle: string; - langsCount: number | undefined; - hideValues: boolean; - showAllStats: boolean; - showIcons: boolean; - includeAllCommits: boolean; - enableAnimations: boolean; - usePercent: boolean; -} - -export function getFullSuffix({ - userId, - selectedCard, - selectedUserId, - repo, - gist, - wakatimeUser, - selectedStatsRank, - selectedLanguagesLayout, - selectedWakatimeLayout, - showTitle, - showOwner, - descriptionLines, - customTitle, - langsCount, - hideValues, - showAllStats, - showIcons, - includeAllCommits, - enableAnimations, - usePercent, -}: Options): string { - let fullSuffix = `${selectedCard === CardType.STATS ? "" : "/" + selectedCard}?`; - - switch (selectedCard) { - case CardType.STATS: - case CardType.TOP_LANGS: - fullSuffix += `username=${selectedUserId}`; - break; - case CardType.PIN: - /** - * We should use the name of the logged-in user, not the value entered in the - * username field in step 3. - * - * This input is not shown when the PIN card type is selected, - * but it may still contain a different value if the user previously chose another card type. - * - * For example, - * 1. the user could select the STATS card - * 2. enter a username - * 3. then go back and switch to the PIN card, leaving the old value behind. - * - * @see https://github.com/stats-organization/github-stats-extended/pull/73#discussion_r2792177515 - */ - fullSuffix += `username=${userId}&repo=${repo}`; - break; - case CardType.GIST: - fullSuffix += `id=${gist}`; - break; - case CardType.WAKATIME: - fullSuffix += `username=${wakatimeUser}`; - break; - - default: - selectedCard satisfies never; - } - - if ( - selectedStatsRank !== STATS_DEFAULT_RANK && - selectedCard === CardType.STATS - ) { - fullSuffix += `&rank_icon=${selectedStatsRank.value}`; - } - - if ( - selectedLanguagesLayout !== LANGUAGES_DEFAULT_LAYOUT && - selectedCard === CardType.TOP_LANGS - ) { - fullSuffix += `&layout=${selectedLanguagesLayout.value}`; - } - - if ( - selectedWakatimeLayout !== WAKATIME_DEFAULT_LAYOUT && - selectedCard === CardType.WAKATIME - ) { - fullSuffix += `&layout=${selectedWakatimeLayout.value}`; - } - - if ( - !showTitle && - (selectedCard === CardType.STATS || - selectedCard === CardType.TOP_LANGS || - selectedCard === CardType.WAKATIME) - ) { - fullSuffix += "&hide_title=true"; - } - - if ( - showOwner && - (selectedCard === CardType.PIN || selectedCard === CardType.GIST) - ) { - fullSuffix += "&show_owner=true"; - } - - if (descriptionLines && selectedCard === CardType.PIN) { - fullSuffix += `&description_lines_count=${descriptionLines}`; - } - - if ( - customTitle && - (selectedCard === CardType.STATS || selectedCard === CardType.WAKATIME) - ) { - const encodedTitle = encodeURIComponent(customTitle); - fullSuffix += `&custom_title=${encodedTitle}`; - } - - if ( - langsCount && - (selectedCard === CardType.TOP_LANGS || selectedCard === CardType.WAKATIME) - ) { - fullSuffix += `&langs_count=${langsCount}`; - } - - if (hideValues && selectedCard === CardType.TOP_LANGS) { - fullSuffix += `&hide_values=true`; - } - - if (showAllStats && selectedCard === CardType.STATS) { - fullSuffix += `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented`; - } - - if (showIcons && selectedCard === CardType.STATS) { - fullSuffix += `&show_icons=true`; - } - - if (includeAllCommits && selectedCard === CardType.STATS) { - fullSuffix += `&include_all_commits=true`; - } - - if ( - !enableAnimations && - (selectedCard === CardType.STATS || - selectedCard === CardType.TOP_LANGS || - selectedCard === CardType.WAKATIME) - ) { - fullSuffix += `&disable_animations=${!enableAnimations}`; - } - - if (usePercent && selectedCard === CardType.WAKATIME) { - fullSuffix += `&display_format=percent`; - } - - return fullSuffix; -} diff --git a/apps/frontend/src/pages/Home/stages/Customize.tsx b/apps/frontend/src/pages/Home/stages/Customize.tsx index 20efee355bd42..50c828ec55d04 100644 --- a/apps/frontend/src/pages/Home/stages/Customize.tsx +++ b/apps/frontend/src/pages/Home/stages/Customize.tsx @@ -1,7 +1,6 @@ -import type { JSX, default as React } from "react"; +import type { JSX } from "react"; import { CardImage } from "../../../components/Card/CardImage"; -import type { SelectOption } from "../../../components/Generic/Select"; import { CheckboxSection } from "../../../components/Home/CheckboxSection"; import { LanguagesLayoutSection } from "../../../components/Home/LanguagesLayoutSection"; import { NumericSection } from "../../../components/Home/NumericSection"; @@ -15,98 +14,69 @@ import { DEMO_WAKATIME_USER, } from "../../../constants"; import { CardType } from "../../../models/CardType"; +import type { CardUrlBuilder } from "../../../models/CardUrl"; import type { StageIndex } from "../../../models/Stage"; import { useIsAuthenticated } from "../../../redux/selectors/userSelectors"; +import type { CardOptions } from "../cardOptions"; -type Updater = React.Dispatch>; +/** + * Extract the trailing path segments from pasted text, so pasting a full + * GitHub URL yields just the username (1 segment), owner/repo (2), or Gist id (1). + */ +function pastedPathTail(text: string, segments: number): string { + let value = text; + if (value.endsWith("/")) { + value = value.slice(0, -1); + } + const parts = value.split("/"); + if (parts.length > segments) { + value = parts.slice(-segments).join("/"); + } + return value; +} -/** @todo todo consider using React context API to avoid prop drilling */ interface CustomizeStageProps { selectedCard: CardType; - selectedStatsRank: SelectOption; - setSelectedStatsRank: Updater; - selectedLanguagesLayout: SelectOption; - setSelectedLanguagesLayout: Updater; - selectedWakatimeLayout: SelectOption; - setSelectedWakatimeLayout: Updater; - selectedUserId: string; - setSelectedUserId: Updater; - repo: string; - setRepo: Updater; - gist: string; - setGist: Updater; - wakatimeUser: string; - setWakatimeUser: Updater; - showTitle: boolean; - setShowTitle: Updater; - descriptionLines: number | undefined; - setDescriptionLines: Updater; - showOwner: boolean; - setShowOwner: Updater; - customTitle: string; - setCustomTitle: Updater; - langsCount: number | undefined; - setLangsCount: Updater; - hideValues: boolean; - setHideValues: Updater; - showIcons: boolean; - setShowIcons: Updater; - showAllStats: boolean; - setShowAllStats: Updater; - includeAllCommits: boolean; - setIncludeAllCommits: Updater; - enableAnimations: boolean; - setEnableAnimations: Updater; - usePercent: boolean; - setUsePercent: Updater; - fullSuffix: string; + options: CardOptions; + onOptionChange: ( + key: K, + value: CardOptions[K], + ) => void; + card: CardUrlBuilder; setStage: (stageIndex: StageIndex) => void; } export function CustomizeStage({ selectedCard, - selectedStatsRank, - setSelectedStatsRank, - selectedLanguagesLayout, - setSelectedLanguagesLayout, - selectedWakatimeLayout, - setSelectedWakatimeLayout, - selectedUserId, - setSelectedUserId, - repo, - setRepo, - gist, - setGist, - wakatimeUser, - setWakatimeUser, - showTitle, - setShowTitle, - showOwner, - setShowOwner, - descriptionLines, - setDescriptionLines, - customTitle, - setCustomTitle, - langsCount, - setLangsCount, - hideValues, - setHideValues, - showIcons, - setShowIcons, - showAllStats, - setShowAllStats, - includeAllCommits, - setIncludeAllCommits, - enableAnimations, - setEnableAnimations, - usePercent, - setUsePercent, - fullSuffix, + options, + onOptionChange, + card, setStage, }: CustomizeStageProps): JSX.Element { const cardType = selectedCard; const isAuthenticated = useIsAuthenticated(); + const { + selectedUserId, + repo, + gist, + wakatimeUser, + selectedStatsRank, + selectedLanguagesLayout, + selectedWakatimeLayout, + showTitle, + showOwner, + descriptionLines, + customTitle, + langsCount, + hideValues, + showAllStats, + showIcons, + includeAllCommits, + enableAnimations, + usePercent, + } = options; + return (
@@ -137,19 +107,16 @@ export function CustomizeStage({ } placeholder={`e.g. "${DEMO_USER}"`} value={selectedUserId} - onValueChange={setSelectedUserId} + onValueChange={(value) => { + onOptionChange("selectedUserId", value); + }} onPaste={(e) => { e.preventDefault(); - let newValue = e.clipboardData.getData("text"); // if the user pasted a full GitHub URL, extract username - if (newValue.endsWith("/")) { - newValue = newValue.slice(0, -1); - } - const parts = newValue.split("/"); - if (parts.length > 1) { - newValue = parts.slice(-1).join("/"); - } - setSelectedUserId(newValue); + onOptionChange( + "selectedUserId", + pastedPathTail(e.clipboardData.getData("text"), 1), + ); }} disabled={!isAuthenticated} /> @@ -181,19 +148,16 @@ export function CustomizeStage({ } placeholder={`e.g. "${DEMO_REPO}"`} value={repo} - onValueChange={setRepo} + onValueChange={(value) => { + onOptionChange("repo", value); + }} onPaste={(e) => { e.preventDefault(); - let newValue = e.clipboardData.getData("text"); // if the user pasted a full GitHub URL, extract owner/repo - if (newValue.endsWith("/")) { - newValue = newValue.slice(0, -1); - } - const parts = newValue.split("/"); - if (parts.length > 2) { - newValue = parts.slice(-2).join("/"); - } - setRepo(newValue); + onOptionChange( + "repo", + pastedPathTail(e.clipboardData.getData("text"), 2), + ); }} disabled={!isAuthenticated} /> @@ -225,19 +189,16 @@ export function CustomizeStage({ } placeholder={`e.g. "${DEMO_GIST}"`} value={gist} - onValueChange={setGist} + onValueChange={(value) => { + onOptionChange("gist", value); + }} onPaste={(e) => { e.preventDefault(); - let newValue = e.clipboardData.getData("text"); // if the user pasted a full GitHub URL, extract Gist ID - if (newValue.endsWith("/")) { - newValue = newValue.slice(0, -1); - } - const parts = newValue.split("/"); - if (parts.length > 1) { - newValue = parts.slice(-1).join("/"); - } - setGist(newValue); + onOptionChange( + "gist", + pastedPathTail(e.clipboardData.getData("text"), 1), + ); }} disabled={!isAuthenticated} /> @@ -260,7 +221,9 @@ export function CustomizeStage({ } placeholder={`e.g. "${DEMO_WAKATIME_USER}"`} value={wakatimeUser} - onValueChange={setWakatimeUser} + onValueChange={(value) => { + onOptionChange("wakatimeUser", value); + }} /> )} {cardType === CardType.STATS && ( @@ -269,13 +232,17 @@ export function CustomizeStage({ text="Show all available statistics." question="Show all stats?" checked={showAllStats} - onCheckedChange={setShowAllStats} + onCheckedChange={(checked) => { + onOptionChange("showAllStats", checked); + }} /> )} {cardType === CardType.STATS && ( { + onOptionChange("selectedStatsRank", option); + }} /> )} {cardType === CardType.STATS && ( @@ -284,7 +251,9 @@ export function CustomizeStage({ text="Show icons next to all stats." question="Show icons?" checked={showIcons} - onCheckedChange={setShowIcons} + onCheckedChange={(checked) => { + onOptionChange("showIcons", checked); + }} /> )} {cardType === CardType.STATS && ( @@ -293,19 +262,25 @@ export function CustomizeStage({ text="Count total commits or just commits of the last 365 days." question="Include all commits?" checked={includeAllCommits} - onCheckedChange={setIncludeAllCommits} + onCheckedChange={(checked) => { + onOptionChange("includeAllCommits", checked); + }} /> )} {cardType === CardType.TOP_LANGS && ( { + onOptionChange("selectedLanguagesLayout", option); + }} /> )} {cardType === CardType.WAKATIME && ( { + onOptionChange("selectedWakatimeLayout", option); + }} /> )} {(cardType === CardType.TOP_LANGS || @@ -320,7 +295,9 @@ export function CustomizeStage({ } value={langsCount} - onValueChange={setLangsCount} + onValueChange={(value) => { + onOptionChange("langsCount", value); + }} min={1} max={20} /> @@ -331,7 +308,9 @@ export function CustomizeStage({ text="Hide language percentages or bytes while keeping the selected layout visible." question="Hide values?" checked={hideValues} - onCheckedChange={setHideValues} + onCheckedChange={(checked) => { + onOptionChange("hideValues", checked); + }} /> )} {cardType === CardType.WAKATIME && ( @@ -340,7 +319,9 @@ export function CustomizeStage({ text="Show time spent in hours or percentages." question="Show percentages?" checked={usePercent} - onCheckedChange={setUsePercent} + onCheckedChange={(checked) => { + onOptionChange("usePercent", checked); + }} /> )} {(cardType === CardType.STATS || @@ -351,7 +332,9 @@ export function CustomizeStage({ text="Shows a title at the top of the card." question="Show title?" checked={showTitle} - onCheckedChange={setShowTitle} + onCheckedChange={(checked) => { + onOptionChange("showTitle", checked); + }} /> )} {(cardType === CardType.STATS || cardType === CardType.WAKATIME) && ( @@ -366,7 +349,9 @@ export function CustomizeStage({ } placeholder='e.g. "My GitHub Stats"' value={customTitle} - onValueChange={setCustomTitle} + onValueChange={(value) => { + onOptionChange("customTitle", value); + }} /> )} {(cardType === CardType.STATS || @@ -377,7 +362,9 @@ export function CustomizeStage({ // text="Enable Animations." question="enable animations?" checked={enableAnimations} - onCheckedChange={setEnableAnimations} + onCheckedChange={(checked) => { + onOptionChange("enableAnimations", checked); + }} /> )} {(cardType === CardType.PIN || cardType === CardType.GIST) && ( @@ -386,7 +373,9 @@ export function CustomizeStage({ text="Shows the repo owner's name next to the repo name." question="Show owner?" checked={showOwner} - onCheckedChange={setShowOwner} + onCheckedChange={(checked) => { + onOptionChange("showOwner", checked); + }} /> )} {cardType === CardType.PIN && ( @@ -401,7 +390,9 @@ export function CustomizeStage({ } value={descriptionLines} - onValueChange={setDescriptionLines} + onValueChange={(value) => { + onOptionChange("descriptionLines", value); + }} min={1} max={3} /> @@ -420,7 +411,7 @@ export function CustomizeStage({
- +
diff --git a/apps/frontend/src/pages/Home/stages/Display.tsx b/apps/frontend/src/pages/Home/stages/Display.tsx index fcd9fc85241d8..a04e1f7ad8ad7 100644 --- a/apps/frontend/src/pages/Home/stages/Display.tsx +++ b/apps/frontend/src/pages/Home/stages/Display.tsx @@ -6,13 +6,14 @@ import { CardImage } from "../../../components/Card/CardImage"; import { getCardThemeBackdrop } from "../../../components/Card/themeBackdrop"; import { Button } from "../../../components/Generic/Button"; import { HOST } from "../../../constants"; +import type { CardUrlBuilder } from "../../../models/CardUrl"; import { useTheme } from "../../../redux/selectors/themeSelectors"; interface DisplayStageProps { filename: string; link: string; theme: string; - themeSuffix: string; + card: CardUrlBuilder; guestHint: string | null; } @@ -20,7 +21,7 @@ export function DisplayStage({ filename, link, theme, - themeSuffix, + card, guestHint, }: DisplayStageProps): JSX.Element { const { isDark } = useTheme(); @@ -39,7 +40,7 @@ export function DisplayStage({ const copyMarkdown = () => { void navigator.clipboard.writeText( - `[![GitHub Stats](https://${HOST}/api${themeSuffix})](${link})`, + `[![GitHub Stats](${card.toApiUrl(HOST)})](${link})`, ); toast.info("Copied to Clipboard!", { position: "bottom-right", @@ -52,7 +53,7 @@ export function DisplayStage({ }; const copyUrl = () => { - void navigator.clipboard.writeText(`https://${HOST}/api${themeSuffix}`); + void navigator.clipboard.writeText(card.toApiUrl(HOST)); toast.info("Copied to Clipboard!", { position: "bottom-right", autoClose: 1500, @@ -104,7 +105,7 @@ export function DisplayStage({ style={{ background: getCardThemeBackdrop(theme, isDark) }} > diff --git a/apps/frontend/src/pages/Home/stages/Login/LoginBoxDemoCards.tsx b/apps/frontend/src/pages/Home/stages/Login/LoginBoxDemoCards.tsx index a262ef11364f4..d8b92ea622a41 100644 --- a/apps/frontend/src/pages/Home/stages/Login/LoginBoxDemoCards.tsx +++ b/apps/frontend/src/pages/Home/stages/Login/LoginBoxDemoCards.tsx @@ -7,24 +7,26 @@ import { DEMO_USER, DEMO_WAKATIME_USER, } from "../../../../constants"; +import { CardType } from "../../../../models/CardType"; +import { cardUrl } from "../../../../models/CardUrl"; import { useTheme } from "../../../../redux/selectors/themeSelectors"; -const cards: Array<{ demoImageSrc: string }> = [ - { - demoImageSrc: `/pin?repo=${DEMO_REPO}&disable_animations=true`, - }, - { - demoImageSrc: `/top-langs?username=${DEMO_USER}&langs_count=4&disable_animations=true`, - }, - { - demoImageSrc: `?username=${DEMO_USER}&include_all_commits=true&disable_animations=true`, - }, - { - demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450&disable_animations=true`, - }, - { - demoImageSrc: `/gist?id=${DEMO_GIST}&disable_animations=true`, - }, +const cards = [ + cardUrl(CardType.PIN).repo(DEMO_REPO).disableAnimations(), + cardUrl(CardType.TOP_LANGS) + .username(DEMO_USER) + .langsCount(4) + .disableAnimations(), + cardUrl(CardType.STATS) + .username(DEMO_USER) + .includeAllCommits() + .disableAnimations(), + cardUrl(CardType.WAKATIME) + .username(DEMO_WAKATIME_USER) + .langsCount(6) + .cardWidth(450) + .disableAnimations(), + cardUrl(CardType.GIST).gistId(DEMO_GIST).disableAnimations(), ]; function getCardXPosition(cardIndex: number): number { @@ -41,29 +43,27 @@ function getCardXPosition(cardIndex: number): number { export function LoginBoxDemoCards(): JSX.Element { const { isDark } = useTheme(); - // Show dark-themed demo cards in dark mode so they fit the surroundings. - const themeParam = isDark ? "&theme=github_dark" : ""; return (
- {cards.map((card, index) => ( -
- -
- ))} + {cards.map((card, index) => { + // Show dark-themed demo cards in dark mode so they fit the surroundings. + const demoCard = isDark ? card.theme("github_dark") : card; + return ( +
+ +
+ ); + })}
); diff --git a/apps/frontend/src/pages/Home/stages/SelectCard.tsx b/apps/frontend/src/pages/Home/stages/SelectCard.tsx index bc2afb3723257..b961253916e05 100644 --- a/apps/frontend/src/pages/Home/stages/SelectCard.tsx +++ b/apps/frontend/src/pages/Home/stages/SelectCard.tsx @@ -9,6 +9,8 @@ import { DEMO_WAKATIME_USER, } from "../../../constants"; import { CardType } from "../../../models/CardType"; +import { cardUrl } from "../../../models/CardUrl"; +import type { CardUrlBuilder } from "../../../models/CardUrl"; import { useTheme } from "../../../redux/selectors/themeSelectors"; import { useUserId } from "../../../redux/selectors/userSelectors"; @@ -23,14 +25,12 @@ export function SelectCardStage({ }: SelectCardStageProps): JSX.Element { const userId = useUserId(DEMO_USER); const { isDark } = useTheme(); - // Show dark-themed demo cards in dark mode so they fit the surroundings. - const themeParam = isDark ? "&theme=github_dark" : ""; const options = useMemo< Array<{ title: string; description: string; - demoImageSrc: string; + demoCard: CardUrlBuilder; cardType: CardType; }> >( @@ -38,60 +38,69 @@ export function SelectCardStage({ { title: "GitHub Stats Card", description: "your overall GitHub statistics", - demoImageSrc: `?username=${userId}&include_all_commits=true${themeParam}`, + demoCard: cardUrl(CardType.STATS).username(userId).includeAllCommits(), cardType: CardType.STATS, }, { title: "Top Languages Card", description: "your most frequently used languages", - demoImageSrc: `/top-langs?username=${userId}&langs_count=4${themeParam}`, + demoCard: cardUrl(CardType.TOP_LANGS).username(userId).langsCount(4), cardType: CardType.TOP_LANGS, }, { title: "GitHub Extra Pin", description: "pin more than 6 repositories in your profile using a GitHub profile readme", - demoImageSrc: `/pin?repo=${DEMO_REPO}${themeParam}`, + demoCard: cardUrl(CardType.PIN).repo(DEMO_REPO), cardType: CardType.PIN, }, { title: "GitHub Gist Pin", description: "pin gists in your GitHub profile using a GitHub profile readme", - demoImageSrc: `/gist?id=${DEMO_GIST}${themeParam}`, + demoCard: cardUrl(CardType.GIST).gistId(DEMO_GIST), cardType: CardType.GIST, }, { title: "WakaTime Stats Card", description: "your coding activity from WakaTime", - demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450${themeParam}`, + demoCard: cardUrl(CardType.WAKATIME) + .username(DEMO_WAKATIME_USER) + .langsCount(6) + .cardWidth(450), cardType: CardType.WAKATIME, }, ], - [userId, themeParam], + [userId], ); return (
- {options.map((card) => ( - - ))} + {options.map((card) => { + // Show dark-themed demo cards in dark mode so they fit the surroundings. + const demoCard = isDark + ? card.demoCard.theme("github_dark") + : card.demoCard; + return ( + + ); + })}
); } diff --git a/apps/frontend/src/pages/Home/stages/Theme.tsx b/apps/frontend/src/pages/Home/stages/Theme.tsx index fc5c1668b5bfb..7d386b812cb91 100644 --- a/apps/frontend/src/pages/Home/stages/Theme.tsx +++ b/apps/frontend/src/pages/Home/stages/Theme.tsx @@ -6,6 +6,7 @@ import { getCardThemeBackdrop, getThemeSortRank, } from "../../../components/Card/themeBackdrop"; +import type { CardUrlBuilder } from "../../../models/CardUrl"; import { useTheme } from "../../../redux/selectors/themeSelectors"; const excludedThemes = [ @@ -23,14 +24,14 @@ const themeList = Object.keys(themes) .sort((a, b) => getThemeSortRank(a) - getThemeSortRank(b)); interface ThemeStageProps { - fullSuffix: string; + card: CardUrlBuilder; theme: string; onThemeChange: (theme: string) => void; } export function ThemeStage({ theme, - fullSuffix, + card, onThemeChange, }: ThemeStageProps): JSX.Element { const { isDark } = useTheme(); @@ -52,7 +53,7 @@ export function ThemeStage({ { + async function fetchGistURL() { + try { + const result = await axios.get<{ html_url: string }>( + `https://api.github.com/gists/${gist}`, + ); + setGistUrl(result.data.html_url); + } catch (error) { + console.error(error); + } + } + void fetchGistURL(); + }, [gist]); + + return useMemo(() => { + const pinRepo = repo.includes("/") ? repo : `${userId}/${repo}`; + const statsCardLink = themeBuilder.toApiUrl(HOST); + + const descriptors: Record = { + [CardType.STATS]: { + guestHint: `username "${DEMO_USER}"`, + link: statsCardLink, + }, + [CardType.TOP_LANGS]: { + guestHint: `username "${DEMO_USER}"`, + link: statsCardLink, + }, + [CardType.PIN]: { + guestHint: `repo "${DEMO_REPO}"`, + link: `https://github.com/${pinRepo}`, + }, + [CardType.GIST]: { + guestHint: `Gist ID "${DEMO_GIST}"`, + link: gistUrl, + }, + [CardType.WAKATIME]: { + guestHint: `WakaTime username "${DEMO_WAKATIME_USER}"`, + link: `https://wakatime.com/@${wakatimeUser}`, + }, + }; + + return descriptors[selectedCard]; + }, [selectedCard, themeBuilder, repo, userId, gistUrl, wakatimeUser]); +} diff --git a/docs/advanced_documentation.md b/docs/advanced_documentation.md index a08f34cc4650e..7b3da430cb7bc 100644 --- a/docs/advanced_documentation.md +++ b/docs/advanced_documentation.md @@ -15,7 +15,7 @@ > By default, the stats card only shows statistics like stars, commits, and pull requests from public repositories. To show private statistics on the stats card, [allow GitHub-Stats-Extended to access your private contributions](fork.md#private-contributions-support) or [deploy your own instance](deploy.md). > [!NOTE] -> Available ranks are S (top 1%), A+ (12.5%), A (25%), A- (37.5%), B+ (50%), B (62.5%), B- (75%), C+ (87.5%) and C (everyone). This ranking scheme is based on the [Japanese academic grading](https://wikipedia.org/wiki/Academic_grading_in_Japan) system. The global percentile is calculated as a weighted sum of percentiles for each statistic (number of commits, pull requests, reviews, issues, stars, and followers), based on the cumulative distribution function of the [exponential](https://wikipedia.org/wiki/exponential_distribution) and the [log-normal](https://wikipedia.org/wiki/Log-normal_distribution) distributions. The implementation can be investigated at [src/calculateRank.js](https://github.com/stats-organization/github-stats-extended/blob/master/backend/src/calculateRank.js). The circle around the rank shows 100 minus the global percentile. +> Available ranks are S (top 1%), A+ (12.5%), A (25%), A- (37.5%), B+ (50%), B (62.5%), B- (75%), C+ (87.5%) and C (everyone). This ranking scheme is based on the [Japanese academic grading](https://wikipedia.org/wiki/Academic_grading_in_Japan) system. The global percentile is calculated as a weighted sum of percentiles for each statistic (number of commits, pull requests, reviews, issues, stars, and followers), based on the cumulative distribution function of the [exponential](https://wikipedia.org/wiki/exponential_distribution) and the [log-normal](https://wikipedia.org/wiki/Log-normal_distribution) distributions. The implementation can be investigated at [calculateRank.ts](https://github.com/stats-organization/github-stats-extended/blob/master/packages/core/src/calculateRank.ts). The circle around the rank shows 100 minus the global percentile. ### Hiding individual stats @@ -76,7 +76,7 @@ GitHub Stats Extended comes with several built-in themes (e.g. `dark`, `radical` GitHub Stats Extended Themes -You can look at a preview for [all available themes](../packages/core/src/themes/README.md) or checkout the [theme config file](../packages/core/src/themes/index.js). Please note that we paused the addition of new themes to decrease maintenance efforts; all pull requests related to new themes will be closed. +You can look at a preview for [all available themes](../packages/core/src/themes/README.md) or checkout the [theme config file](../packages/core/src/themes/index.ts). Please note that we paused the addition of new themes to decrease maintenance efforts; all pull requests related to new themes will be closed. #### Responsive Card Theme diff --git a/docs/deploy.md b/docs/deploy.md index 4a1f16a091084..4aa24cd3b5ae4 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -94,7 +94,7 @@ Selecting the right scopes for your token is important in case you want to displ Click on the deploy button to get started! -[![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/import/project?template=https://github.com/stats-organization/github-stats-extended) +[![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/stats-organization/github-stats-extended&root-directory=apps/backend) Recommended: Step-by-step guide on setting up your own Vercel instance diff --git a/eslint.config.js b/eslint.config.js index c1b759dbeb113..909e6e84bc50f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,12 +2,14 @@ import { fileURLToPath } from "node:url"; import { includeIgnoreFile } from "@eslint/compat"; import js from "@eslint/js"; +import eslintReact from "@eslint-react/eslint-plugin"; import { defineConfig } from "eslint/config"; -import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript"; +import { + createTypeScriptImportResolver, + defaultConditionNames, +} from "eslint-import-resolver-typescript"; import { importX } from "eslint-plugin-import-x"; import { default as jsdoc } from "eslint-plugin-jsdoc"; -import react from "eslint-plugin-react"; -import reactHooks from "eslint-plugin-react-hooks"; import globals from "globals"; import { default as tseslint } from "typescript-eslint"; @@ -26,14 +28,7 @@ export default defineConfig( /** Keep in sync with `tsconfig.base.json#customConditions` */ "@stats/source", - "types", - "import", - - "require", - "node", - "node-addons", - "browser", - "default", + ...defaultConditionNames, ], }), ], @@ -176,23 +171,6 @@ export default defineConfig( }, { files: ["apps/frontend/**/*.{js,jsx,ts,tsx}"], - plugins: { - react, - "react-hooks": reactHooks, - }, - languageOptions: { - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, - }, - rules: { - "react/jsx-no-undef": "error", - "react/jsx-uses-vars": "error", - "react/no-array-index-key": "warn", - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", - }, + ...eslintReact.configs["recommended-typescript"], }, ); diff --git a/package.json b/package.json index ba3912a571c72..b3bbf8d8d5185 100644 --- a/package.json +++ b/package.json @@ -2,27 +2,26 @@ "name": "@stats-organization/root", "private": true, "type": "module", - "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8", + "packageManager": "pnpm@10.34.1+sha512.b58fbde6dca66a929538021581f648b4570b6ca19b18e7cbd7f2c07a7b24454155388dacdf08f2af3678e88a6d1fe04f9d609df24bf51735a060ea041b374ab7", "devDependencies": { + "@eslint-react/eslint-plugin": "5.17.1", "@eslint/compat": "2.1.0", "@eslint/js": "10.0.1", - "@playwright/test": "1.60.0", - "@types/node": "24.13.2", + "@playwright/test": "1.61.1", + "@types/node": "24.13.3", "@vitest/coverage-v8": "catalog:default", - "eslint": "10.4.1", + "eslint": "10.7.0", "eslint-import-resolver-typescript": "4.4.5", - "eslint-plugin-import-x": "4.16.2", - "eslint-plugin-jsdoc": "63.0.2", - "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "7.1.1", - "globals": "17.6.0", + "eslint-plugin-import-x": "4.17.1", + "eslint-plugin-jsdoc": "63.1.0", + "globals": "17.7.0", "husky": "9.1.7", - "knip": "6.16.1", - "lint-staged": "17.0.7", - "prettier": "3.8.4", - "turbo": "2.9.18", + "knip": "6.27.0", + "lint-staged": "17.0.8", + "prettier": "3.9.5", + "turbo": "2.10.5", "typescript": "6.0.3", - "typescript-eslint": "8.61.0", + "typescript-eslint": "8.64.0", "vitest": "catalog:default" }, "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index ad9d5ff2c42c8..3459fa3a67e92 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@stats-organization/github-readme-stats-core", - "version": "2.1.3", + "version": "2.1.5", "type": "module", "homepage": "https://github-stats-extended.vercel.app/frontend", "bugs": { @@ -83,7 +83,7 @@ "@testing-library/jest-dom": "6.9.1", "@uppercod/css-to-object": "1.1.1", "axios-mock-adapter": "2.1.0", - "js-yaml": "4.2.0", + "js-yaml": "5.2.1", "jsdom": "catalog:default", "vitest": "catalog:default" }, diff --git a/packages/core/src/_emoji-name-map.d.ts b/packages/core/src/_emoji-name-map.d.ts new file mode 100644 index 0000000000000..a22d709a0c074 --- /dev/null +++ b/packages/core/src/_emoji-name-map.d.ts @@ -0,0 +1,10 @@ +/** + * `emoji-name-map` ships no type definitions and exposes no `exports` map, so it + * cannot be resolved under `nodenext`. Declare the minimal surface used here. + */ +declare module "emoji-name-map" { + const emojiNameMap: { + get(name: string): string | undefined; + }; + export default emojiNameMap; +} diff --git a/packages/core/src/api/gist.js b/packages/core/src/api/gist.js index 0cb9610262ca8..67505392b76f7 100644 --- a/packages/core/src/api/gist.js +++ b/packages/core/src/api/gist.js @@ -1,4 +1,5 @@ import { renderGistCard } from "../cards/gist.js"; +import { findInvalidColor } from "../common/color.js"; import { MissingParamError, retrieveSecondaryMessage, @@ -26,6 +27,23 @@ export default async ( }, pat = null, ) => { + const invalidColorInput = findInvalidColor({ + title_color, + icon_color, + text_color, + bg_color, + border_color, + }); + if (invalidColorInput) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`, + }), + }; + } + if (locale && !isLocaleAvailable(locale)) { return { status: "error - permanent", @@ -43,6 +61,24 @@ export default async ( }; } + const safePattern = /^[-\w/.,]+$/; + if (id && !safePattern.test(id)) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: "Gist ID contains unsafe characters", + renderOptions: { + title_color, + text_color, + bg_color, + border_color, + theme, + }, + }), + }; + } + try { const gistData = await fetchGist(id, pat); diff --git a/packages/core/src/api/index.js b/packages/core/src/api/index.js index 4751560082eab..87f6bfd9056cf 100644 --- a/packages/core/src/api/index.js +++ b/packages/core/src/api/index.js @@ -1,4 +1,5 @@ import { renderStatsCard } from "../cards/stats.js"; +import { findInvalidColor } from "../common/color.js"; import { MissingParamError, retrieveSecondaryMessage, @@ -44,6 +45,24 @@ export default async ( }, pat = null, ) => { + const invalidColorInput = findInvalidColor({ + title_color, + ring_color, + icon_color, + text_color, + bg_color, + border_color, + }); + if (invalidColorInput) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`, + }), + }; + } + if (locale && !isLocaleAvailable(locale)) { return { status: "error - permanent", diff --git a/packages/core/src/api/pin.js b/packages/core/src/api/pin.js index 118aa70cc2204..fe59500dfe228 100644 --- a/packages/core/src/api/pin.js +++ b/packages/core/src/api/pin.js @@ -1,4 +1,5 @@ import { renderRepoCard } from "../cards/repo.js"; +import { findInvalidColor } from "../common/color.js"; import { MissingParamError, retrieveSecondaryMessage, @@ -34,6 +35,23 @@ export default async ( }, pat = null, ) => { + const invalidColorInput = findInvalidColor({ + title_color, + icon_color, + text_color, + bg_color, + border_color, + }); + if (invalidColorInput) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`, + }), + }; + } + if (locale && !isLocaleAvailable(locale)) { return { status: "error - permanent", diff --git a/packages/core/src/api/top-langs.js b/packages/core/src/api/top-langs.js index 46f6633c013e6..46e106eeb6cac 100644 --- a/packages/core/src/api/top-langs.js +++ b/packages/core/src/api/top-langs.js @@ -1,4 +1,5 @@ import { renderTopLanguages } from "../cards/top-languages.js"; +import { findInvalidColor } from "../common/color.js"; import { MissingParamError, retrieveSecondaryMessage, @@ -38,6 +39,23 @@ export default async ( }, pat = null, ) => { + const invalidColorInput = findInvalidColor({ + title_color, + text_color, + bg_color, + prog_bar_bg_color, + border_color, + }); + if (invalidColorInput) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`, + }), + }; + } + if (locale && !isLocaleAvailable(locale)) { return { status: "error - permanent", @@ -55,10 +73,27 @@ export default async ( }; } + const safePattern = /^[-\w/.,]+$/; + if (username && !safePattern.test(username)) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: "Username contains unsafe characters", + renderOptions: { + title_color, + text_color, + bg_color, + border_color, + theme, + }, + }), + }; + } + if ( layout !== undefined && - (typeof layout !== "string" || - !["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout)) + !["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout) ) { return { status: "error - permanent", @@ -78,8 +113,7 @@ export default async ( if ( stats_format !== undefined && - (typeof stats_format !== "string" || - !["bytes", "percentages"].includes(stats_format)) + !["bytes", "percentages"].includes(stats_format) ) { return { status: "error - permanent", diff --git a/packages/core/src/api/wakatime.js b/packages/core/src/api/wakatime.js index 19307f104aaf8..7d03c58e49813 100644 --- a/packages/core/src/api/wakatime.js +++ b/packages/core/src/api/wakatime.js @@ -1,4 +1,5 @@ import { renderWakatimeCard } from "../cards/wakatime.js"; +import { findInvalidColor } from "../common/color.js"; import { MissingParamError, retrieveSecondaryMessage, @@ -32,6 +33,23 @@ export default async ({ display_format, disable_animations, }) => { + const invalidColorInput = findInvalidColor({ + title_color, + icon_color, + text_color, + bg_color, + border_color, + }); + if (invalidColorInput) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`, + }), + }; + } + if (locale && !isLocaleAvailable(locale)) { return { status: "error - permanent", @@ -49,6 +67,24 @@ export default async ({ }; } + const safePattern = /^[-\w/.,]+$/; + if (username && !safePattern.test(username)) { + return { + status: "error - permanent", + content: renderError({ + message: "Something went wrong", + secondaryMessage: "Username contains unsafe characters", + renderOptions: { + title_color, + text_color, + bg_color, + border_color, + theme, + }, + }), + }; + } + try { const stats = await fetchWakatimeStats({ username, api_domain }); diff --git a/packages/core/src/calculateRank.js b/packages/core/src/calculateRank.ts similarity index 58% rename from packages/core/src/calculateRank.js rename to packages/core/src/calculateRank.ts index 377b3fbe11597..586752c314737 100644 --- a/packages/core/src/calculateRank.js +++ b/packages/core/src/calculateRank.ts @@ -1,20 +1,20 @@ /** * Calculates the exponential cdf. * - * @param {number} x The value. - * @returns {number} The exponential cdf. + * @param x The value. + * @returns The exponential cdf. */ -function exponential_cdf(x) { +function exponential_cdf(x: number): number { return 1 - 2 ** -x; } /** * Calculates the log normal cdf. * - * @param {number} x The value. - * @returns {number} The log normal cdf. + * @param x The value. + * @returns The log normal cdf. */ -function log_normal_cdf(x) { +function log_normal_cdf(x: number): number { // approximation return x / (1 + x); } @@ -22,16 +22,16 @@ function log_normal_cdf(x) { /** * Calculates the users rank. * - * @param {object} params Parameters on which the user's rank depends. - * @param {boolean} params.all_commits Whether `include_all_commits` was used. - * @param {number} params.commits Number of commits. - * @param {number} params.prs The number of pull requests. - * @param {number} params.issues The number of issues. - * @param {number} params.reviews The number of reviews. - * @param {number} params.repos Total number of repos. - * @param {number} params.stars The number of stars. - * @param {number} params.followers The number of followers. - * @returns {{ level: string, percentile: number }} The users rank. + * @param params Parameters on which the user's rank depends. + * @param params.all_commits Whether `include_all_commits` was used. + * @param params.commits Number of commits. + * @param params.prs The number of pull requests. + * @param params.issues The number of issues. + * @param params.reviews The number of reviews. + * @param params.repos Total number of repos (accepted for compatibility, unused in the calculation). + * @param params.stars The number of stars. + * @param params.followers The number of followers. + * @returns The users rank. */ function calculateRank({ all_commits, @@ -39,11 +39,18 @@ function calculateRank({ prs, issues, reviews, - // eslint-disable-next-line no-unused-vars - repos, // unused stars, followers, -}) { +}: { + all_commits: boolean; + commits: number; + prs: number; + issues: number; + reviews: number; + repos: number; + stars: number; + followers: number; +}): { level: string; percentile: number } { const COMMITS_MEDIAN = all_commits ? 1000 : 250, COMMITS_WEIGHT = 2; const PRS_MEDIAN = 50, @@ -79,6 +86,9 @@ function calculateRank({ TOTAL_WEIGHT; const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)]; + if (level === undefined) { + throw new Error("Unable to determine rank level"); + } return { level, percentile: rank * 100 }; } diff --git a/packages/core/src/cards/repo.js b/packages/core/src/cards/repo.js index a352f357c1ea5..4150967d0284a 100644 --- a/packages/core/src/cards/repo.js +++ b/packages/core/src/cards/repo.js @@ -1,6 +1,6 @@ import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.js"; -import { getCardColors } from "../common/color.js"; +import { getCardColors, isPrefixedHexColor } from "../common/color.js"; import { kFormatter, wrapTextMultiline } from "../common/fmt.js"; import { encodeHTML } from "../common/html.js"; import { icons } from "../common/icons.js"; @@ -32,20 +32,29 @@ const DESCRIPTION_MAX_LINES = 3; * @param {string} textColor The color of the text. * @returns {string} Wrapped repo description SVG object. */ -const getBadgeSVG = (label, textColor, xOffset = 0) => ` - - - - ${label} - - -`; +const getBadgeSVG = (label, textColor, xOffset = 0) => { + if (!isPrefixedHexColor(textColor)) { + throw new Error(`Invalid text color: "${textColor}"`); + } + if (!Number.isFinite(xOffset)) { + throw new Error(`Invalid xOffset: "${xOffset}"`); + } + + return ` + + + + ${encodeHTML(label)} + + + `; +}; /** * @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data. @@ -110,6 +119,7 @@ const renderRepoCard = (repo, options = {}) => { }); let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], [])); + const encodedUsername = encodeURIComponent(username); const STATS = {}; if (show.includes("prs_authored")) { STATS.prs_authored = { @@ -117,7 +127,7 @@ const renderRepoCard = (repo, options = {}) => { label: i18n.t("repocard.prs-authored"), value: totalPRsAuthored, id: "prs_authored", - link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`, + link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&type=pullrequests`, }; } if (show.includes("prs_commented")) { @@ -126,7 +136,7 @@ const renderRepoCard = (repo, options = {}) => { label: i18n.t("repocard.prs-commented"), value: totalPRsCommented, id: "prs_commented", - link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`, + link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&type=pullrequests`, }; } if (show.includes("prs_reviewed")) { @@ -135,7 +145,7 @@ const renderRepoCard = (repo, options = {}) => { label: i18n.t("repocard.prs-reviewed"), value: totalPRsReviewed, id: "prs_reviewed", - link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`, + link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${encodedUsername}+-author%3A${encodedUsername}&type=pullrequests`, }; } if (show.includes("issues_authored")) { @@ -144,7 +154,7 @@ const renderRepoCard = (repo, options = {}) => { label: i18n.t("repocard.issues-authored"), value: totalIssuesAuthored, id: "issues_authored", - link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`, + link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&type=issues`, }; } if (show.includes("issues_commented")) { @@ -153,7 +163,7 @@ const renderRepoCard = (repo, options = {}) => { label: i18n.t("repocard.issues-commented"), value: totalIssuesCommented, id: "issues_commented", - link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`, + link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&type=issues`, }; } diff --git a/packages/core/src/cards/stats.js b/packages/core/src/cards/stats.js index cbaf2b61f3e43..cd65180eed967 100644 --- a/packages/core/src/cards/stats.js +++ b/packages/core/src/cards/stats.js @@ -3,6 +3,7 @@ import { I18n } from "../common/I18n.js"; import { getCardColors } from "../common/color.js"; import { CustomError } from "../common/error.js"; import { kFormatter } from "../common/fmt.js"; +import { encodeHTML } from "../common/html.js"; import { icons, rankIcon } from "../common/icons.js"; import { buildSearchFilter, clampValue } from "../common/ops.js"; import { flexLayout, measureText } from "../common/render.js"; @@ -52,8 +53,10 @@ const LONG_LOCALES = [ /** * Create a stats card text item. * + * The caller must ensure that the passed `icon` and `link` are properly sanitized! + * * @param {object} params Object that contains the createTextNode parameters. - * @param {string} params.icon The icon to display. + * @param {string} params.icon The sanitized icon to display. * @param {string} params.label The label to display. * @param {number} params.value The value to display. * @param {string} params.id The id of the stat. @@ -64,7 +67,7 @@ const LONG_LOCALES = [ * @param {boolean} params.bold Whether to bold the label. * @param {string} params.numberFormat The format of numbers on card. * @param {number=} params.numberPrecision The precision of numbers on card. - * @param {string} params.link Url to link to. + * @param {string} params.link Sanitized url to link to. * @param {number} params.labelXOffset horizontal offset for label. * @returns {string} The stats card text item SVG object. */ @@ -83,6 +86,16 @@ const createTextNode = ({ link, labelXOffset = 25, }) => { + if (!Number.isFinite(labelXOffset)) { + throw new Error(`Invalid labelXOffset: "${labelXOffset}"`); + } + if (!Number.isFinite(shiftValuePos)) { + throw new Error(`Invalid shiftValuePos: "${shiftValuePos}"`); + } + if (!Number.isFinite(index)) { + throw new Error(`Invalid index: "${index}"`); + } + const precision = typeof numberPrecision === "number" && !isNaN(numberPrecision) ? clampValue(numberPrecision, 0, 2) @@ -109,7 +122,7 @@ const createTextNode = ({ ${iconSvg} ${label}: + }" ${labelOffset} y="12.5">${encodeHTML(label)}: - ${name} - ${hideValues ? "" : `${displayValue}`} + ${encodeHTML(name)} + ${hideValues ? "" : `${encodeHTML(displayValue)}`} ${createProgressNode({ x: 0, y: 25, @@ -285,11 +290,15 @@ const createCompactLangNode = ({ const staggerDelay = (index + 3) * 150; const color = lang.color || "#858585"; + if (!isPrefixedHexColor(color)) { + throw new Error(`Invalid language color: "${color}"`); + } + return ` - ${lang.name} ${hideProgress || hideValues ? "" : displayValue} + ${encodeHTML(lang.name)} ${hideProgress || hideValues ? "" : encodeHTML(displayValue)} `; @@ -439,6 +448,11 @@ const renderCompactLayout = ( let progressOffset = 0; const compactProgressBar = langs .map((lang) => { + const langColor = lang.color || DEFAULT_LANG_COLOR; + if (!isPrefixedHexColor(langColor)) { + throw new Error(`Invalid language color: "${langColor}"`); + } + const percentage = parseFloat( ((lang.size / totalLanguageSize) * offsetWidth).toFixed(2), ); @@ -453,7 +467,7 @@ const renderCompactLayout = ( y="0" width="${progress}" height="8" - fill="${lang.color || "#858585"}" + fill="${langColor}" /> `; progressOffset += percentage; @@ -514,18 +528,23 @@ const renderDonutVerticalLayout = ( // Generate each donut vertical chart part for (const lang of langs) { + const langColor = lang.color || DEFAULT_LANG_COLOR; + if (!isPrefixedHexColor(langColor)) { + throw new Error(`Invalid language color: "${langColor}"`); + } + const percentage = (lang.size / totalLanguageSize) * 100; const circleLength = totalCircleLength * (percentage / 100); const delay = startDelayCoefficient * 100; circles.push(` - { // Generate each pie chart part for (const lang of langs) { + const langColor = lang.color || DEFAULT_LANG_COLOR; + if (!isPrefixedHexColor(langColor)) { + throw new Error(`Invalid language color: "${langColor}"`); + } + if (langs.length === 1) { paths.push(` { cy="${centerY}" r="${radius}" stroke="none" - fill="${lang.color}" + fill="${langColor}" data-testid="lang-pie" size="100" /> @@ -629,7 +653,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => { data-testid="lang-pie" size="${percentage}" d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z" - fill="${lang.color}" + fill="${langColor}" /> `); @@ -716,12 +740,22 @@ const renderDonutLayout = ( statsFormat, hideValues, ) => { + if (!Number.isFinite(width)) { + throw new Error(`Invalid width: "${width}"`); + } + const centerX = width / 3; const centerY = width / 3; const radius = centerX - 60; const strokeWidth = 12; - const colors = langs.map((lang) => lang.color); + const colors = langs.map((lang) => { + const langColor = lang.color || DEFAULT_LANG_COLOR; + if (!isPrefixedHexColor(langColor)) { + throw new Error(`Invalid language color: "${langColor}"`); + } + return langColor; + }); const langsPercents = langs.map((lang) => parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)), ); @@ -783,10 +817,14 @@ const renderDonutLayout = ( * @returns {string} No languages data SVG node string. */ const noLanguagesDataNode = ({ color, text, layout }) => { + if (!isPrefixedHexColor(color)) { + throw new Error(`Invalid text color: "${color}"`); + } + return ` ${text} + }" y="11" class="stat bold" fill="${color}">${encodeHTML(text)} `; }; diff --git a/packages/core/src/cards/wakatime.js b/packages/core/src/cards/wakatime.js index a717e753587fc..ea87c648b9482 100644 --- a/packages/core/src/cards/wakatime.js +++ b/packages/core/src/cards/wakatime.js @@ -1,6 +1,7 @@ import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.js"; -import { getCardColors } from "../common/color.js"; +import { getCardColors, isPrefixedHexColor } from "../common/color.js"; +import { encodeHTML } from "../common/html.js"; import languageColors from "../common/languageColors.json" with { type: "json" }; import { clampValue, lowercaseTrim } from "../common/ops.js"; import { createProgressNode, flexLayout } from "../common/render.js"; @@ -24,8 +25,12 @@ const TOTAL_TEXT_WIDTH = 275; * @returns {string} No coding activity SVG node string. */ const noCodingActivityNode = ({ color, text }) => { + if (!isPrefixedHexColor(color)) { + throw new Error(`Invalid text color: "${color}"`); + } + return ` - ${text} + ${encodeHTML(text)} `; }; @@ -58,6 +63,13 @@ const formatLanguageValue = ({ display_format, lang }) => { * @returns {string} The compact layout language SVG node. */ const createCompactLangNode = ({ lang, x, y, display_format }) => { + if (!Number.isFinite(x)) { + throw new Error(`Invalid x: "${x}"`); + } + if (!Number.isFinite(y)) { + throw new Error(`Invalid y: "${y}"`); + } + // @ts-ignore const color = languageColors[lang.name] || "#858585"; const value = formatLanguageValue({ display_format, lang }); @@ -66,7 +78,7 @@ const createCompactLangNode = ({ lang, x, y, display_format }) => { - ${lang.name} - ${value} + ${encodeHTML(lang.name)} - ${encodeHTML(value)} `; @@ -125,6 +137,13 @@ const createTextNode = ({ progressBarBackgroundColor, progressBarWidth, }) => { + if (!Number.isFinite(index)) { + throw new Error(`Invalid index: "${index}"`); + } + if (!Number.isFinite(progressBarWidth)) { + throw new Error(`Invalid progressBarWidth: "${progressBarWidth}"`); + } + const staggerDelay = (index + 3) * 150; const cardProgress = hideProgress ? null @@ -142,12 +161,12 @@ const createTextNode = ({ return ` - ${label}: + ${encodeHTML(label)}: ${value} + >${encodeHTML(value)} ${cardProgress} `; @@ -179,11 +198,15 @@ const recalculatePercentages = (languages) => { * @param {string} colors.textColor The text color. * @returns {string} Card CSS styles. */ -const getStyles = ({ +const getStyles = function ({ // eslint-disable-next-line no-unused-vars titleColor, textColor, -}) => { +}) { + if (!isPrefixedHexColor(textColor)) { + throw new Error(`Invalid text color: "${textColor}"`); + } + return ` .stat { font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; @@ -377,7 +400,9 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => { progressBarColor: titleColor, // @ts-ignore progressBarBackgroundColor: - textColor === titleColor ? bgColor : textColor, + textColor === titleColor + ? "#fff0" // transparent + : textColor, hideProgress: hide_progress, progressBarWidth: normalizedWidth - TOTAL_TEXT_WIDTH, }); diff --git a/packages/core/src/common/Card.ts b/packages/core/src/common/Card.ts index 7ae629f83c8cf..00aac90dfc8fa 100644 --- a/packages/core/src/common/Card.ts +++ b/packages/core/src/common/Card.ts @@ -1,3 +1,4 @@ +import { isPrefixedHexColor, isValidGradient } from "./color.js"; import { encodeHTML } from "./html.js"; import { flexLayout } from "./render.js"; @@ -33,6 +34,8 @@ class Card { /** * Creates a new card instance. * + * The caller must ensure that the passed `titlePrefixIcon` is properly sanitized! + * * @param props Card arguments. * @param props.width Card width. * @param props.height Card height. @@ -40,7 +43,7 @@ class Card { * @param props.colors Card colors arguments. * @param props.customTitle Card custom title. * @param props.defaultTitle Card default title. - * @param props.titlePrefixIcon Card title prefix icon. + * @param props.titlePrefixIcon Sanitized card title prefix icon. */ constructor({ width = 100, @@ -65,13 +68,11 @@ class Card { this.hideBorder = false; this.hideTitle = false; - this.border_radius = border_radius; + this.border_radius = parseFloat(String(border_radius)); // returns theme based colors with proper overrides and defaults this.colors = colors; - this.title = encodeHTML( - customTitle === undefined ? defaultTitle : customTitle, - ); + this.title = customTitle === undefined ? defaultTitle : customTitle; this.css = ""; @@ -104,7 +105,9 @@ class Card { } /** - * @param value The CSS to add to the card. + * The caller must ensure that the passed `css` string is properly sanitized! + * + * @param value The sanitized CSS to add to the card. */ setCSS(value: string): void { this.css = value; @@ -121,10 +124,13 @@ class Card { * @param value Whether to hide the title or not. */ setHideTitle(value: boolean): void { - this.hideTitle = value; - if (value) { + if (value && !this.hideTitle) { this.height -= 30; } + if (!value && this.hideTitle) { + this.height += 30; + } + this.hideTitle = value; } /** @@ -144,7 +150,7 @@ class Card { y="0" class="header" data-testid="header" - >${this.title} + >${encodeHTML(this.title)} `; const prefixIcon = ` @@ -180,10 +186,12 @@ class Card { if (typeof this.colors.bgColor !== "object") { return ""; } + if (!isValidGradient(this.colors.bgColor)) { + throw new Error(`Invalid gradient: ${this.colors.bgColor.join(",")}`); + } const gradients = this.colors.bgColor.slice(1); - return typeof this.colors.bgColor === "object" - ? ` + return ` - ` - : ""; + `; } /** @@ -230,10 +237,38 @@ class Card { }; /** - * @param body The inner body of the card. + * The caller must ensure that the passed `body` string is properly sanitized! + * + * @param body The sanitized inner body of the card. * @returns The rendered card. */ render(body: string): string { + if (!Number.isFinite(this.border_radius)) { + throw new Error(`Invalid border radius: "${this.border_radius}"`); + } + if ( + this.colors.titleColor !== undefined && + !isPrefixedHexColor(this.colors.titleColor) + ) { + throw new Error(`Invalid title color: "${this.colors.titleColor}"`); + } + if ( + this.colors.borderColor !== undefined && + !isPrefixedHexColor(this.colors.borderColor) + ) { + throw new Error(`Invalid border color: "${this.colors.borderColor}"`); + } + if ( + this.colors.bgColor !== undefined && + !(typeof this.colors.bgColor === "object" + ? isValidGradient(this.colors.bgColor) + : isPrefixedHexColor(this.colors.bgColor)) + ) { + throw new Error( + `Invalid background color: ${String(this.colors.bgColor)}`, + ); + } + return ` - ${this.a11yTitle} - ${this.a11yDesc} + ${encodeHTML(this.a11yTitle)} + ${encodeHTML(this.a11yDesc)}