diff --git a/.vscode/extensions.json b/.vscode/extensions.json index b01e17ddbeee6..eae93fd7d1ad5 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,7 @@ { "recommendations": [ "yzhang.markdown-all-in-one", - "prettier.prettier-vscode", + "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "github.vscode-github-actions" ] diff --git a/apps/frontend/e2e/app.spec.ts b/apps/frontend/e2e/app.spec.ts index e8def4ccb7702..04f41e5e8c1b8 100644 --- a/apps/frontend/e2e/app.spec.ts +++ b/apps/frontend/e2e/app.spec.ts @@ -12,8 +12,8 @@ test("load initial page correctly", async ({ page }) => { // Logo exists await expect(page.locator('img[alt="logo"]')).toBeVisible(); - // Star on GitHub button - const starButton = page.getByRole("button", { name: /star on/i }); + // Star on GitHub link + const starButton = page.getByRole("link", { name: /star on/i }); await expect(starButton).toBeVisible(); const githubLink = page.locator( @@ -39,6 +39,45 @@ test("load initial page correctly", async ({ page }) => { await expect(guestBtn).toBeEnabled(); }); +test("theme picker switches and persists the theme", async ({ page }) => { + await page.goto(""); + + const html = page.locator("html"); + const trigger = page.getByRole("button", { name: /choose theme/i }); + const options = page.getByRole("menuitemradio"); + + // The menu is closed initially. + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveAttribute("aria-expanded", "false"); + await expect(options).toHaveCount(0); + + // Opening it reveals the options (portaled to body, above the stepper). + await trigger.click(); + await expect(trigger).toHaveAttribute("aria-expanded", "true"); + + // Read the themes off the options instead of hardcoding their names. + const firstTheme = await options.first().getAttribute("data-theme-value"); + const lastTheme = await options.last().getAttribute("data-theme-value"); + + // Selecting an option applies the theme, persists it, and closes the menu. + await options.last().click(); + await expect(html).toHaveAttribute("data-theme", lastTheme ?? ""); + await expect(options).toHaveCount(0); + await expect( + page.evaluate(() => localStorage.getItem("theme")), + ).resolves.toBe(lastTheme); + + // The choice survives a reload and is marked as selected on reopen. + await page.reload(); + await expect(html).toHaveAttribute("data-theme", lastTheme ?? ""); + await trigger.click(); + await expect(options.last()).toHaveAttribute("aria-checked", "true"); + + // Switching to another option works too. + await options.first().click(); + await expect(html).toHaveAttribute("data-theme", firstTheme ?? ""); +}); + test("navigates between steps", async ({ page }) => { await page.goto(""); diff --git a/apps/frontend/index.html b/apps/frontend/index.html index b09638c309844..ff41b6247fa2d 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -37,6 +37,24 @@ GitHub Stats Extended +
diff --git a/apps/frontend/src/components/Card/Card.tsx b/apps/frontend/src/components/Card/Card.tsx index ec70d0d2dd0a8..b5f35c5c11d65 100644 --- a/apps/frontend/src/components/Card/Card.tsx +++ b/apps/frontend/src/components/Card/Card.tsx @@ -1,7 +1,8 @@ import { clsx } from "clsx"; -import type { JSX } from "react"; +import type { CSSProperties, JSX } from "react"; import { CardImage } from "./CardImage"; +import { LIGHT_CARD_BG } from "./themeBackdrop"; interface CardProps { title: string; @@ -11,6 +12,10 @@ interface CardProps { selected?: boolean; compact?: boolean; fixedSize?: boolean; + /** CSS background for the card surface (e.g. a theme's own background). */ + backgroundColor?: string; + /** Title color, used when the card sits on a custom background. */ + titleColor?: string; } export const Card = ({ @@ -21,16 +26,46 @@ export const Card = ({ selected = false, compact = false, fixedSize = false, + backgroundColor, + titleColor, }: CardProps): JSX.Element => { + const customBackground = backgroundColor + ? selected + ? `color-mix(in oklab, var(--color-primary) ${backgroundColor === LIGHT_CARD_BG ? "10%" : "25%"}, ${backgroundColor})` + : backgroundColor + : undefined; + + const cardStyle = customBackground + ? ({ "--card-bg": customBackground } as CSSProperties) + : undefined; + return (
-

{title}

+

+ {title} +

{description}

char + char) + .join(""); + } + return hex; +} + +/** Whether a hex color (3-, 6- or 8-digit) is dark by perceived luminance. */ +function isDarkHex(hex: string): boolean { + const normalized = expandHex(hex); + if (normalized.length < 6) { + return false; + } + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance < 0.5; +} + +/** Whether a theme's `bg_color` (hex or `angle,c1,c2,…` gradient) is dark. */ +function isDarkBg(bgColor: string): boolean { + const parts = bgColor.split(","); + // For gradients use the first color stop (index 0 is the angle). + return isDarkHex((parts.length > 1 ? parts[1] : parts[0]) ?? ""); +} + +/** A theme whose backdrop should follow the app mode rather than its own bg. */ +function isAdaptiveTheme(themeName: string): boolean { + return ADAPTIVE_THEMES.includes(themeName); +} + +/** + * Whether the backdrop a card actually gets is dark, for the given app mode. + * Adaptive themes follow the app mode; the rest follow their own background. + */ +function isCardBackdropDark(themeName: string, appIsDark: boolean): boolean { + const theme = themes[themeName as keyof typeof themes]; + if (isAdaptiveTheme(themeName)) { + return appIsDark; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!theme) { + return appIsDark; + } + return isDarkBg(theme.bg_color); +} + +/** + * Sort rank for the theme grid: light themes first (0), adaptive themes in the + * middle (1), dark themes last (2). Adaptive cards sit between the groups so + * they blend with whichever side matches their backdrop in the current mode. + */ +export function getThemeSortRank(themeName: string): number { + if (isAdaptiveTheme(themeName)) { + return 1; + } + const theme = themes[themeName as keyof typeof themes]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!theme) { + return 0; + } + return isDarkBg(theme.bg_color) ? 2 : 0; +} + +/** The backdrop color a card rendered with the given theme should sit on. */ +export function getCardThemeBackdrop( + themeName: string, + appIsDark: boolean, +): string { + return isCardBackdropDark(themeName, appIsDark) + ? DARK_CARD_BG + : LIGHT_CARD_BG; +} diff --git a/apps/frontend/src/components/Generic/Button.tsx b/apps/frontend/src/components/Generic/Button.tsx index 4417da017589f..b0ae1582cad67 100644 --- a/apps/frontend/src/components/Generic/Button.tsx +++ b/apps/frontend/src/components/Generic/Button.tsx @@ -1,18 +1,32 @@ import { clsx } from "clsx"; import type { HTMLProps, JSX, ReactNode } from "react"; -interface ButtonProps extends HTMLProps { +type ButtonVariant = "primary" | "soft" | "error"; +type ButtonSize = "sm" | "md" | "lg"; + +interface ButtonProps extends Omit, "size"> { children: ReactNode; + /** DaisyUI color variant. Omit for the default neutral button. */ + variant?: ButtonVariant | undefined; + size?: ButtonSize; } export function Button(props: ButtonProps): JSX.Element { - const { className, children, ...rest } = props; + const { className, children, variant, size = "md", ...rest } = props; + return ( ); } @@ -59,16 +61,23 @@ export function ProgressBar({ const rightDisabled = currItemIndex === items.length - 1; return ( -
- +
{items.map((item, index) => { return ( @@ -85,15 +94,22 @@ export function ProgressBar({ ); })}
- { onItemClick(Math.min(currItemIndex + 1, items.length - 1)); }} - /> + > + +
); } diff --git a/apps/frontend/src/components/Home/Section.tsx b/apps/frontend/src/components/Home/Section.tsx index c2d9c4ef8d2ae..c7d35a1810d6b 100644 --- a/apps/frontend/src/components/Home/Section.tsx +++ b/apps/frontend/src/components/Home/Section.tsx @@ -10,14 +10,14 @@ export function Section({ title, children }: SectionProps): JSX.Element { return (
-
+
-
+
-

+

{title}

{children} diff --git a/apps/frontend/src/components/Home/TextSection.tsx b/apps/frontend/src/components/Home/TextSection.tsx index 11aaf749b6974..f762f7ae0e1a6 100644 --- a/apps/frontend/src/components/Home/TextSection.tsx +++ b/apps/frontend/src/components/Home/TextSection.tsx @@ -55,8 +55,8 @@ export function TextSection({ { diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 5b1c5072739c5..1af8e11ccf3cb 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -1,14 +1,115 @@ -/* ./src/index.css */ @import "tailwindcss"; -@plugin "daisyui"; +@plugin "daisyui" { + themes: + light --default, + dark --prefersdark; +} + +/* +Override primary color with default blue from github-stats-extended. +Override background color in dark mode with GitHub's dark mode background color. +*/ +@plugin "daisyui/theme" { + name: "light"; + --color-error: var(--color-red-700); +} +@plugin "daisyui/theme" { + name: "dark"; + --color-error: var(--color-red-900); + --color-base-100: #0d1117; +} + +:root { + --color-primary: #2f80ed; + + /* Snappy duration for interactive elements (buttons, hovers). */ + --transition-fast: 100ms; + /* Slower, deliberate fade for large surfaces during a theme switch. */ + --transition-surface: 300ms; +} body { margin: 0; font-family: "Segoe UI", Ubuntu, Sans-Serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + background-color: var(--color-base-100); + color: var(--color-base-content); } button { cursor: pointer; } + +/* Smoothly animate color changes when switching themes / on hover. */ +*, +*::before, +*::after { + transition-property: background-color, border-color, color, fill, stroke; + /* + * An ease-out curve: animations start at full speed so quick interactions + * like button hovers feel responsive instead of laggy. + */ + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: var(--transition-fast); +} + +/* + * Large surface backgrounds fade more slowly so a theme switch reads as a + * deliberate fade rather than an instant flip. Durations reference the custom + * properties above so the reduced-motion override below zeroes them out + * regardless of selector specificity. + */ +body, +.bg-base-100, +.bg-base-200, +.bg-base-300 { + transition-duration: var(--transition-surface); +} + +/* + * DaisyUI's `.btn` ships its own 200ms transition which felt slow. The global + * rule above is unlayered, so it would otherwise clobber the button's property + * list and timing function. Restore both here (so fill, text and border animate + * together on outline buttons) and only speed up the duration. + */ +.btn { + transition-property: color, background-color, border-color, box-shadow; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + transition-duration: var(--transition-fast); +} + +/* + * DaisyUI's default hover is very subtle; make it noticeable. Primary always darkens, + * while soft lightens in dark mode and darkens in light mode, so the change shows. + */ +.btn-primary:hover { + background-color: color-mix(in oklab, var(--color-primary), #000 30%); +} +.btn-soft { + background-color: color-mix(in oklab, var(--color-base-100), #888 35%); +} +.btn-soft:hover { + background-color: color-mix(in oklab, var(--color-base-100), #888 60%); +} +.btn-error { + color: var(--color-neutral-content); +} +.btn-error:hover { + color: var(--color-neutral-content); + background-color: color-mix(in oklab, var(--color-error), #f00 40%); +} + +input:focus, +select:focus { + outline: 0; + border: var(--color-primary) solid 1px; +} + +/* Respect users who prefer reduced motion. */ +@media (prefers-reduced-motion: reduce) { + :root { + --transition-fast: 0ms; + --transition-surface: 0ms; + } +} diff --git a/apps/frontend/src/pages/App/AppTrends.tsx b/apps/frontend/src/pages/App/AppTrends.tsx index 25a483b9edf7f..54f8356e46e35 100644 --- a/apps/frontend/src/pages/App/AppTrends.tsx +++ b/apps/frontend/src/pages/App/AppTrends.tsx @@ -5,6 +5,7 @@ import { ToastContainer, toast } from "react-toastify"; import { getUserMetadata } from "../../api/user"; import { clearAxiosCache } from "../../axios-override"; import type { StageIndex } from "../../models/Stage"; +import { useTheme } from "../../redux/selectors/themeSelectors"; import { useIsAuthenticated, useUserKey, @@ -56,6 +57,7 @@ export function AppTrends() { const userKey = useUserKey(); const userToken = useUserToken(); const isAuthenticated = useIsAuthenticated(); + const { isDark } = useTheme(); const [stage, setStage] = useState(isAuthenticated ? 1 : 0); @@ -116,9 +118,9 @@ export function AppTrends() { return (
-
+
- +
); diff --git a/apps/frontend/src/pages/App/Header.tsx b/apps/frontend/src/pages/App/Header.tsx index d99b9b63b4130..7701ef1c1109d 100644 --- a/apps/frontend/src/pages/App/Header.tsx +++ b/apps/frontend/src/pages/App/Header.tsx @@ -2,6 +2,7 @@ import type { JSX } from "react"; import { FaGithub as GithubIcon } from "react-icons/fa"; import appIcon from "../../assets/appLogo64.png"; +import { ThemePicker } from "../../components/Generic/ThemePicker"; import { ProgressBar } from "../../components/Home/Progress"; import { STAGE_LABELS } from "../../models/Stage"; import type { StageIndex } from "../../models/Stage"; @@ -19,31 +20,34 @@ export function Header({ }: HeaderProps): JSX.Element { return ( <> -
+
{/* Logo */} logo GitHub Stats Extended - {/* Star on GitHub */} -
+ {/* Star on GitHub + theme toggle */} +
diff --git a/apps/frontend/src/pages/Home/Home.tsx b/apps/frontend/src/pages/Home/Home.tsx index 62a92bbbe9d0b..acea4a437279b 100644 --- a/apps/frontend/src/pages/Home/Home.tsx +++ b/apps/frontend/src/pages/Home/Home.tsx @@ -19,6 +19,7 @@ import { import { CardType } from "../../models/CardType"; import { STAGE_LABELS } from "../../models/Stage"; import type { StageIndex } from "../../models/Stage"; +import { useTheme } from "../../redux/selectors/themeSelectors"; import { useIsAuthenticated, usePrivateAccess, @@ -83,7 +84,9 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { const [enableAnimations, setEnableAnimations] = useState(true); const [usePercent, setUsePercent] = useState(false); - const [theme, setTheme] = useState("default"); + const { isDark } = useTheme(); + const themeParam = isDark ? "&theme=github_dark" : ""; + const [theme, setTheme] = useState(isDark ? "dark" : "default"); const handleCardTypeChange = (cardType: CardType) => { if (cardType === CardType.TOP_LANGS) { @@ -230,11 +233,14 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { } return ( -
+
-

+

{STAGE_LABELS[stage].title}

@@ -245,7 +251,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { {userId} @@ -328,7 +334,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { setWakatimeUser={setWakatimeUser} usePercent={usePercent} setUsePercent={setUsePercent} - fullSuffix={fullSuffix} + fullSuffix={fullSuffix + themeParam} setStage={setStage} /> )} @@ -382,6 +388,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { return ""; } })()} + theme={theme} themeSuffix={themeSuffix} guestHint={ isAuthenticated diff --git a/apps/frontend/src/pages/Home/stages/Customize.tsx b/apps/frontend/src/pages/Home/stages/Customize.tsx index 9443c9f0ed650..20efee355bd42 100644 --- a/apps/frontend/src/pages/Home/stages/Customize.tsx +++ b/apps/frontend/src/pages/Home/stages/Customize.tsx @@ -109,7 +109,7 @@ export function CustomizeStage({ return (
-
+
{(cardType === CardType.STATS || cardType === CardType.TOP_LANGS) && ( log in {" "} @@ -170,7 +170,7 @@ export function CustomizeStage({ e.preventDefault(); setStage(0); }} - className="underline text-blue-900" + className="underline text-primary" > log in {" "} @@ -214,7 +214,7 @@ export function CustomizeStage({ e.preventDefault(); setStage(0); }} - className="underline text-blue-900" + className="underline text-primary" > log in {" "} @@ -251,7 +251,7 @@ export function CustomizeStage({ WakaTime {" "} @@ -411,7 +411,7 @@ export function CustomizeStage({ customization documentation {" "} diff --git a/apps/frontend/src/pages/Home/stages/Display.tsx b/apps/frontend/src/pages/Home/stages/Display.tsx index 7946c8882d0f1..fcd9fc85241d8 100644 --- a/apps/frontend/src/pages/Home/stages/Display.tsx +++ b/apps/frontend/src/pages/Home/stages/Display.tsx @@ -1,15 +1,17 @@ -import { clsx } from "clsx"; import type { JSX } from "react"; import { toast } from "react-toastify"; import { saveSvgAsPng } from "save-svg-as-png"; import { CardImage } from "../../../components/Card/CardImage"; +import { getCardThemeBackdrop } from "../../../components/Card/themeBackdrop"; import { Button } from "../../../components/Generic/Button"; import { HOST } from "../../../constants"; +import { useTheme } from "../../../redux/selectors/themeSelectors"; interface DisplayStageProps { filename: string; link: string; + theme: string; themeSuffix: string; guestHint: string | null; } @@ -17,9 +19,12 @@ interface DisplayStageProps { export function DisplayStage({ filename, link, + theme, themeSuffix, guestHint, }: DisplayStageProps): JSX.Element { + const { isDark } = useTheme(); + const downloadPNG = () => { saveSvgAsPng( document.getElementById("svgWrapper")?.shadowRoot?.firstElementChild @@ -61,7 +66,7 @@ export function DisplayStage({ return (
-
+
{[ { @@ -82,10 +87,8 @@ export function DisplayStage({ ].map((item) => (
-
+
diff --git a/apps/frontend/src/pages/Home/stages/Login/LoginAccountDeleteModal.tsx b/apps/frontend/src/pages/Home/stages/Login/LoginAccountDeleteModal.tsx index 3bd65172dff29..905205d210ca3 100644 --- a/apps/frontend/src/pages/Home/stages/Login/LoginAccountDeleteModal.tsx +++ b/apps/frontend/src/pages/Home/stages/Login/LoginAccountDeleteModal.tsx @@ -45,7 +45,7 @@ export function LoginAccountDeleteModal(

Delete Account

@@ -57,16 +57,10 @@ export function LoginAccountDeleteModal(


- -
diff --git a/apps/frontend/src/pages/Home/stages/Login/LoginAccountManagement.tsx b/apps/frontend/src/pages/Home/stages/Login/LoginAccountManagement.tsx index f0c36921821dc..f3f6e7a900df6 100644 --- a/apps/frontend/src/pages/Home/stages/Login/LoginAccountManagement.tsx +++ b/apps/frontend/src/pages/Home/stages/Login/LoginAccountManagement.tsx @@ -56,14 +56,17 @@ export function LoginAccountManagement(): JSX.Element { - -

+

Switch to public access if you prefer not to share private contributions.

@@ -71,14 +74,17 @@ export function LoginAccountManagement(): JSX.Element { ) : (
- -

+

Upgrade to include contributions in private repositories for more complete and accurate stats.

@@ -89,12 +95,15 @@ export function LoginAccountManagement(): JSX.Element { {/* Delete Account Button */}
-

+

This will delete your GitHub-Stats-Extended account and then redirect you to a GitHub screen where you can revoke your access token.

@@ -103,12 +112,13 @@ export function LoginAccountManagement(): JSX.Element { {/* Logout Button */}
-

+

Log out from GitHub-Stats-Extended.

diff --git a/apps/frontend/src/pages/Home/stages/Login/LoginBox.tsx b/apps/frontend/src/pages/Home/stages/Login/LoginBox.tsx index d603563a23bca..c69bfb82a6c5b 100644 --- a/apps/frontend/src/pages/Home/stages/Login/LoginBox.tsx +++ b/apps/frontend/src/pages/Home/stages/Login/LoginBox.tsx @@ -16,7 +16,7 @@ export function LoginBox(props: LoginBoxProps): JSX.Element {
-
+
{children}
diff --git a/apps/frontend/src/pages/Home/stages/Login/LoginBoxDemoCards.tsx b/apps/frontend/src/pages/Home/stages/Login/LoginBoxDemoCards.tsx index 99834c3818f1f..a262ef11364f4 100644 --- a/apps/frontend/src/pages/Home/stages/Login/LoginBoxDemoCards.tsx +++ b/apps/frontend/src/pages/Home/stages/Login/LoginBoxDemoCards.tsx @@ -7,6 +7,7 @@ import { DEMO_USER, DEMO_WAKATIME_USER, } from "../../../../constants"; +import { useTheme } from "../../../../redux/selectors/themeSelectors"; const cards: Array<{ demoImageSrc: string }> = [ { @@ -39,6 +40,10 @@ 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 (
@@ -52,7 +57,11 @@ export function LoginBoxDemoCards(): JSX.Element { marginBottom: "1%", }} > - +
))}
diff --git a/apps/frontend/src/pages/Home/stages/Login/LoginOptions.tsx b/apps/frontend/src/pages/Home/stages/Login/LoginOptions.tsx index 4e763bad7c4a4..6ed1a7a639492 100644 --- a/apps/frontend/src/pages/Home/stages/Login/LoginOptions.tsx +++ b/apps/frontend/src/pages/Home/stages/Login/LoginOptions.tsx @@ -20,24 +20,30 @@ export function LoginOptions(props: LoginOptionsProps): JSX.Element {
- -

+

Generate stats based on your contributions in public repositories.

- -

+

Include contributions from private repositories for more complete and accurate stats.

@@ -45,12 +51,13 @@ export function LoginOptions(props: LoginOptionsProps): JSX.Element {
-

+

Explore options using sample data. Insert your own username in the last step.

diff --git a/apps/frontend/src/pages/Home/stages/SelectCard.tsx b/apps/frontend/src/pages/Home/stages/SelectCard.tsx index ed59b09e4477d..bc2afb3723257 100644 --- a/apps/frontend/src/pages/Home/stages/SelectCard.tsx +++ b/apps/frontend/src/pages/Home/stages/SelectCard.tsx @@ -9,6 +9,7 @@ import { DEMO_WAKATIME_USER, } from "../../../constants"; import { CardType } from "../../../models/CardType"; +import { useTheme } from "../../../redux/selectors/themeSelectors"; import { useUserId } from "../../../redux/selectors/userSelectors"; interface SelectCardStageProps { @@ -21,6 +22,9 @@ export function SelectCardStage({ onCardTypeChange, }: 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<{ @@ -34,37 +38,37 @@ export function SelectCardStage({ { title: "GitHub Stats Card", description: "your overall GitHub statistics", - demoImageSrc: `?username=${userId}&include_all_commits=true`, + demoImageSrc: `?username=${userId}&include_all_commits=true${themeParam}`, cardType: CardType.STATS, }, { title: "Top Languages Card", description: "your most frequently used languages", - demoImageSrc: `/top-langs?username=${userId}&langs_count=4`, + demoImageSrc: `/top-langs?username=${userId}&langs_count=4${themeParam}`, 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}`, + demoImageSrc: `/pin?repo=${DEMO_REPO}${themeParam}`, cardType: CardType.PIN, }, { title: "GitHub Gist Pin", description: "pin gists in your GitHub profile using a GitHub profile readme", - demoImageSrc: `/gist?id=${DEMO_GIST}`, + demoImageSrc: `/gist?id=${DEMO_GIST}${themeParam}`, 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`, + demoImageSrc: `/wakatime?username=${DEMO_WAKATIME_USER}&langs_count=6&card_width=450${themeParam}`, cardType: CardType.WAKATIME, }, ], - [userId], + [userId, themeParam], ); return ( diff --git a/apps/frontend/src/pages/Home/stages/Theme.tsx b/apps/frontend/src/pages/Home/stages/Theme.tsx index 23797cf3f1cfb..fc5c1668b5bfb 100644 --- a/apps/frontend/src/pages/Home/stages/Theme.tsx +++ b/apps/frontend/src/pages/Home/stages/Theme.tsx @@ -2,6 +2,11 @@ import { themes } from "@stats-organization/github-readme-stats-core"; import type { JSX } from "react"; import { Card } from "../../../components/Card/Card"; +import { + getCardThemeBackdrop, + getThemeSortRank, +} from "../../../components/Card/themeBackdrop"; +import { useTheme } from "../../../redux/selectors/themeSelectors"; const excludedThemes = [ "merko", @@ -12,9 +17,10 @@ const excludedThemes = [ "holi", ]; -const themeList = Object.keys(themes).filter( - (myTheme) => !excludedThemes.includes(myTheme), -); +// Light themes first, adaptive themes in the middle, dark themes last. +const themeList = Object.keys(themes) + .filter((myTheme) => !excludedThemes.includes(myTheme)) + .sort((a, b) => getThemeSortRank(a) - getThemeSortRank(b)); interface ThemeStageProps { fullSuffix: string; @@ -27,38 +33,49 @@ export function ThemeStage({ fullSuffix, onThemeChange, }: ThemeStageProps): JSX.Element { + const { isDark } = useTheme(); + return ( <>
- {themeList.map((myTheme) => ( - - ))} + {themeList.map((myTheme) => { + const themeColors = themes[myTheme as keyof typeof themes]; + return ( + + ); + })}
- For more theme options check the{" "} + {"For more theme options check the "} customization documentation - {" "} - after you copied your card URL in step 5. + + {" after you copied your card URL in step 5."}
); diff --git a/apps/frontend/src/redux/selectors/themeSelectors.ts b/apps/frontend/src/redux/selectors/themeSelectors.ts new file mode 100644 index 0000000000000..ec18dd59e36e1 --- /dev/null +++ b/apps/frontend/src/redux/selectors/themeSelectors.ts @@ -0,0 +1,27 @@ +import { useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { isDarkTheme, setTheme as setThemeAction } from "../slices/theme"; +import type { StoreState } from "../store"; + +/** + * Reads the active DaisyUI theme from the Redux store and exposes a setter + * that persists the choice and updates the `data-theme` attribute. + */ +export function useTheme(): { + theme: string; + isDark: boolean; + setTheme: (theme: string) => void; +} { + const dispatch = useDispatch(); + const theme = useSelector((state: StoreState) => state.theme.theme); + + const setTheme = useCallback( + (next: string) => { + dispatch(setThemeAction(next)); + }, + [dispatch], + ); + + return { theme, isDark: isDarkTheme(theme), setTheme }; +} diff --git a/apps/frontend/src/redux/slices/theme.ts b/apps/frontend/src/redux/slices/theme.ts new file mode 100644 index 0000000000000..4f78d7a93ee1e --- /dev/null +++ b/apps/frontend/src/redux/slices/theme.ts @@ -0,0 +1,69 @@ +import { createSlice } from "@reduxjs/toolkit"; +import type { PayloadAction } from "@reduxjs/toolkit"; + +interface ThemeOption { + /** DaisyUI theme name, used as the `data-theme` value. */ + name: string; + label: string; + /** Whether the theme has a dark background (drives toast styling, etc.). */ + isDark: boolean; +} + +/** + * Themes exposed in the theme picker. + * Keep in sync with the `themes:` list in `index.css`. + */ +export const THEMES: ReadonlyArray = [ + { name: "light", label: "Light", isDark: false }, + { name: "dark", label: "Dark", isDark: true }, +]; + +const DEFAULT_LIGHT_THEME = "light"; +const DEFAULT_DARK_THEME = "dark"; + +function isValidTheme(name: string | null): name is string { + return !!name && THEMES.some((theme) => theme.name === name); +} + +export function isDarkTheme(name: string): boolean { + return THEMES.find((theme) => theme.name === name)?.isDark ?? false; +} + +/** + * @public + * Exported so the inferred store type can name it (see the note in `user.ts`). + */ +export interface ThemeState { + theme: string; +} + +function getInitialTheme(): string { + const stored = localStorage.getItem("theme"); + if (isValidTheme(stored)) { + return stored; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? DEFAULT_DARK_THEME + : DEFAULT_LIGHT_THEME; +} + +const initialState: ThemeState = { + theme: getInitialTheme(), +}; + +const themeSlice = createSlice({ + name: "theme", + initialState, + reducers: { + setTheme: (state, action: PayloadAction) => { + const theme = action.payload; + localStorage.setItem("theme", theme); + document.documentElement.setAttribute("data-theme", theme); + state.theme = theme; + }, + }, +}); + +export const { setTheme } = themeSlice.actions; + +export default themeSlice.reducer; diff --git a/apps/frontend/src/redux/store.ts b/apps/frontend/src/redux/store.ts index 73870b8e2c9d3..97fc03a06cd7b 100644 --- a/apps/frontend/src/redux/store.ts +++ b/apps/frontend/src/redux/store.ts @@ -3,11 +3,13 @@ import { configureStore } from "@reduxjs/toolkit"; import { USE_LOGGER } from "../constants"; import { loggerMiddleware } from "./logger"; +import theme from "./slices/theme"; import user from "./slices/user"; const store = configureStore({ reducer: { user, + theme, }, middleware: (getDefaultMiddleware) => { const middleware = getDefaultMiddleware(); diff --git a/eslint.config.js b/eslint.config.js index 270d6cbb80d67..c1b759dbeb113 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -150,6 +150,10 @@ export default defineConfig( }, ], + // Keep `@param props.x` doc names in sync with the destructured + // parameters so they cannot silently drift on rename. + "jsdoc/check-param-names": "error", + // We don't need this we have typescript "jsdoc/require-returns": "off", "jsdoc/require-returns-description": "off", diff --git a/packages/core/src/api/gist.js b/packages/core/src/api/gist.js index d03071248dcde..0cb9610262ca8 100644 --- a/packages/core/src/api/gist.js +++ b/packages/core/src/api/gist.js @@ -1,5 +1,3 @@ -// @ts-check - import { renderGistCard } from "../cards/gist.js"; import { MissingParamError, diff --git a/packages/core/src/api/index.js b/packages/core/src/api/index.js index 5709e30a08930..4751560082eab 100644 --- a/packages/core/src/api/index.js +++ b/packages/core/src/api/index.js @@ -1,5 +1,3 @@ -// @ts-check - import { renderStatsCard } from "../cards/stats.js"; import { MissingParamError, diff --git a/packages/core/src/api/pin.js b/packages/core/src/api/pin.js index 7d6ab9504221e..118aa70cc2204 100644 --- a/packages/core/src/api/pin.js +++ b/packages/core/src/api/pin.js @@ -1,5 +1,3 @@ -// @ts-check - import { renderRepoCard } from "../cards/repo.js"; import { MissingParamError, diff --git a/packages/core/src/api/top-langs.js b/packages/core/src/api/top-langs.js index 9d5c9670e0164..46f6633c013e6 100644 --- a/packages/core/src/api/top-langs.js +++ b/packages/core/src/api/top-langs.js @@ -1,5 +1,3 @@ -// @ts-check - import { renderTopLanguages } from "../cards/top-languages.js"; import { MissingParamError, diff --git a/packages/core/src/api/wakatime.js b/packages/core/src/api/wakatime.js index eaf02daf5c38a..19307f104aaf8 100644 --- a/packages/core/src/api/wakatime.js +++ b/packages/core/src/api/wakatime.js @@ -1,5 +1,3 @@ -// @ts-check - import { renderWakatimeCard } from "../cards/wakatime.js"; import { MissingParamError, diff --git a/packages/core/src/cards/gist.js b/packages/core/src/cards/gist.js index 81d5c2446f455..fd16f4ad0783c 100644 --- a/packages/core/src/cards/gist.js +++ b/packages/core/src/cards/gist.js @@ -1,5 +1,3 @@ -// @ts-check - import { default as Card } from "../common/Card.js"; import { getCardColors } from "../common/color.js"; import { kFormatter, wrapTextMultiline } from "../common/fmt.js"; diff --git a/packages/core/src/cards/repo.js b/packages/core/src/cards/repo.js index 38b7bd6192b0a..a352f357c1ea5 100644 --- a/packages/core/src/cards/repo.js +++ b/packages/core/src/cards/repo.js @@ -1,5 +1,3 @@ -// @ts-check - import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.js"; import { getCardColors } from "../common/color.js"; diff --git a/packages/core/src/cards/stats.js b/packages/core/src/cards/stats.js index 940271a381dc1..cbaf2b61f3e43 100644 --- a/packages/core/src/cards/stats.js +++ b/packages/core/src/cards/stats.js @@ -1,5 +1,3 @@ -// @ts-check - import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.js"; import { getCardColors } from "../common/color.js"; diff --git a/packages/core/src/cards/top-languages.js b/packages/core/src/cards/top-languages.js index 8c2d2f1841e09..ad28510074008 100644 --- a/packages/core/src/cards/top-languages.js +++ b/packages/core/src/cards/top-languages.js @@ -1,5 +1,3 @@ -// @ts-check - import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.js"; import { fallbackColor, getCardColors } from "../common/color.js"; diff --git a/packages/core/src/cards/wakatime.js b/packages/core/src/cards/wakatime.js index f7eeeee395cc2..a717e753587fc 100644 --- a/packages/core/src/cards/wakatime.js +++ b/packages/core/src/cards/wakatime.js @@ -1,5 +1,3 @@ -// @ts-check - import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.js"; import { getCardColors } from "../common/color.js"; diff --git a/packages/core/src/common/Card.js b/packages/core/src/common/Card.ts similarity index 59% rename from packages/core/src/common/Card.js rename to packages/core/src/common/Card.ts index 6345b662c639b..7ae629f83c8cf 100644 --- a/packages/core/src/common/Card.js +++ b/packages/core/src/common/Card.ts @@ -1,25 +1,46 @@ -// @ts-check - import { encodeHTML } from "./html.js"; import { flexLayout } from "./render.js"; +interface CardColors { + /** Card title color. */ + titleColor?: string; + /** Card text color. */ + textColor?: string; + /** Card icon color. */ + iconColor?: string; + /** Card background color. */ + bgColor?: string | Array; + /** Card border color. */ + borderColor?: string; +} + class Card { + width: number; + height: number; + hideBorder: boolean; + hideTitle: boolean; + border_radius: number; + colors: CardColors; + title: string; + css: string; + paddingX: number; + paddingY: number; + titlePrefixIcon: string | undefined; + animations: boolean; + a11yTitle: string; + a11yDesc: string; + /** * Creates a new card instance. * - * @param {object} args Card arguments. - * @param {number=} args.width Card width. - * @param {number=} args.height Card height. - * @param {number=} args.border_radius Card border radius. - * @param {string=} args.customTitle Card custom title. - * @param {string=} args.defaultTitle Card default title. - * @param {string=} args.titlePrefixIcon Card title prefix icon. - * @param {object} [args.colors={}] Card colors arguments. - * @param {string=} args.colors.titleColor Card title color. - * @param {string=} args.colors.textColor Card text color. - * @param {string=} args.colors.iconColor Card icon color. - * @param {string|string[]=} args.colors.bgColor Card background color. - * @param {string=} args.colors.borderColor Card border color. + * @param props Card arguments. + * @param props.width Card width. + * @param props.height Card height. + * @param props.border_radius Card border radius. + * @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. */ constructor({ width = 100, @@ -29,6 +50,14 @@ class Card { customTitle, defaultTitle = "", titlePrefixIcon, + }: { + width?: number; + height?: number; + border_radius?: number; + colors?: CardColors; + customTitle?: string; + defaultTitle?: string; + titlePrefixIcon?: string; }) { this.width = width; this.height = height; @@ -40,10 +69,9 @@ class Card { // returns theme based colors with proper overrides and defaults this.colors = colors; - this.title = - customTitle === undefined - ? encodeHTML(defaultTitle) - : encodeHTML(customTitle); + this.title = encodeHTML( + customTitle === undefined ? defaultTitle : customTitle, + ); this.css = ""; @@ -55,45 +83,44 @@ class Card { this.a11yDesc = ""; } - /** - * @returns {void} - */ - disableAnimations() { + disableAnimations(): void { this.animations = false; } /** - * @param {Object} props The props object. - * @param {string} props.title Accessibility title. - * @param {string} props.desc Accessibility description. - * @returns {void} + * @param props The props object. + * @param props.title Accessibility title. + * @param props.desc Accessibility description. */ - setAccessibilityLabel({ title, desc }) { + setAccessibilityLabel({ + title, + desc, + }: { + title: string; + desc: string; + }): void { this.a11yTitle = title; this.a11yDesc = desc; } /** - * @param {string} value The CSS to add to the card. - * @returns {void} + * @param value The CSS to add to the card. */ - setCSS(value) { + setCSS(value: string): void { this.css = value; } /** - * @param {boolean} value Whether to hide the border or not. - * @returns {void} + * @param value Whether to hide the border or not. */ - setHideBorder(value) { + setHideBorder(value: boolean): void { this.hideBorder = value; } /** - * @param {boolean} value Whether to hide the title or not. - * @returns {void} + * @param value Whether to hide the title or not. */ - setHideTitle(value) { + setHideTitle(value: boolean): void { this.hideTitle = value; if (value) { this.height -= 30; @@ -101,17 +128,16 @@ class Card { } /** - * @param {string} text The title to set. - * @returns {void} + * @param text The title to set. */ - setTitle(text) { + setTitle(text: string): void { this.title = text; } /** - * @returns {string} The rendered card title. + * @returns The rendered card title. */ - renderTitle() { + renderTitle(): string { const titleText = ` - ${this.titlePrefixIcon} + ${String(this.titlePrefixIcon)} `; return ` @@ -148,9 +174,9 @@ class Card { } /** - * @returns {string} The rendered card gradient. + * @returns The rendered card gradient. */ - renderGradient() { + renderGradient(): string { if (typeof this.colors.bgColor !== "object") { return ""; } @@ -161,13 +187,15 @@ class Card { - ${gradients.map((grad, index) => { - let offset = (index * 100) / (gradients.length - 1); - return ``; - })} + ${gradients + .map((grad, index) => { + const offset = (index * 100) / (gradients.length - 1); + return ``; + }) + .join(",")} ` @@ -177,9 +205,9 @@ class Card { /** * Retrieves css animations for a card. * - * @returns {string} Animation css. + * @returns Animation css. */ - getAnimations = () => { + getAnimations = (): string => { return ` /* Animations */ @keyframes scaleInAnimation { @@ -202,10 +230,10 @@ class Card { }; /** - * @param {string} body The inner body of the card. - * @returns {string} The rendered card. + * @param body The inner body of the card. + * @returns The rendered card. */ - render(body) { + render(body: string): string { return ` .header { font: 600 18px 'Segoe UI', Ubuntu, Sans-Serif; - fill: ${this.colors.titleColor}; + fill: ${String(this.colors.titleColor)}; animation: fadeInAnimation 0.8s ease-in-out forwards; } @supports(-moz-appearance: auto) { @@ -232,9 +260,9 @@ class Card { ${this.getAnimations()} ${ - this.animations === false - ? `* { animation-duration: 0s !important; animation-delay: 0s !important; }` - : "" + this.animations + ? "" + : `* { animation-duration: 0s !important; animation-delay: 0s !important; }` } @@ -246,12 +274,12 @@ class Card { y="0.5" rx="${this.border_radius}" height="99%" - stroke="${this.colors.borderColor}" + stroke="${String(this.colors.borderColor)}" width="${this.width - 1}" fill="${ typeof this.colors.bgColor === "object" ? "url(#gradient)" - : this.colors.bgColor + : String(this.colors.bgColor) }" stroke-opacity="${this.hideBorder ? 0 : 1}" /> diff --git a/packages/core/src/common/I18n.js b/packages/core/src/common/I18n.js deleted file mode 100644 index 8cf1f445b4573..0000000000000 --- a/packages/core/src/common/I18n.js +++ /dev/null @@ -1,42 +0,0 @@ -// @ts-check - -const FALLBACK_LOCALE = "en"; - -/** - * I18n translation class. - */ -class I18n { - /** - * Constructor. - * - * @param {Object} options Options. - * @param {string=} options.locale Locale. - * @param {any} options.translations Translations. - */ - constructor({ locale, translations }) { - this.locale = locale || FALLBACK_LOCALE; - this.translations = translations; - } - - /** - * Get translation. - * - * @param {string} str String to translate. - * @returns {string} Translated string. - */ - t(str) { - if (!this.translations[str]) { - throw new Error(`${str} Translation string not found`); - } - - if (!this.translations[str][this.locale]) { - throw new Error( - `'${str}' translation not found for locale '${this.locale}'`, - ); - } - - return this.translations[str][this.locale]; - } -} - -export { I18n }; diff --git a/packages/core/src/common/I18n.ts b/packages/core/src/common/I18n.ts new file mode 100644 index 0000000000000..27c9acf307620 --- /dev/null +++ b/packages/core/src/common/I18n.ts @@ -0,0 +1,49 @@ +const FALLBACK_LOCALE = "en"; + +/** + * I18n translation class. + */ +class I18n>> { + locale: string; + translations: Translations; + + /** + * @param props Constructor arguments. + * @param props.locale Locale. + * @param props.translations Translations. + */ + constructor({ + locale, + translations, + }: { + locale?: string; + translations: Translations; + }) { + this.locale = locale || FALLBACK_LOCALE; + this.translations = translations; + } + + /** + * Get translation. + * + * @param str String to translate. + * @returns Translated string. + */ + t(str: keyof Translations & string): string { + const translation = this.translations[str]; + if (!translation) { + throw new Error(`${str} Translation string not found`); + } + + const localized = translation[this.locale]; + if (!localized) { + throw new Error( + `'${str}' translation not found for locale '${this.locale}'`, + ); + } + + return localized; + } +} + +export { I18n }; diff --git a/packages/core/src/common/color.js b/packages/core/src/common/color.ts similarity index 54% rename from packages/core/src/common/color.js rename to packages/core/src/common/color.ts index bc1395e8f737f..2e9de30545e01 100644 --- a/packages/core/src/common/color.js +++ b/packages/core/src/common/color.ts @@ -1,14 +1,12 @@ -// @ts-check - import { themes } from "../themes/index.js"; /** * Checks if a string is a valid hex color. * - * @param {string} hexColor String to check. - * @returns {boolean} True if the given string is a valid hex color. + * @param hexColor String to check. + * @returns True if the given string is a valid hex color. */ -const isValidHexColor = (hexColor) => { +const isValidHexColor = (hexColor: string): boolean => { return new RegExp( /^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/, ).test(hexColor); @@ -17,10 +15,10 @@ const isValidHexColor = (hexColor) => { /** * Check if the given string is a valid gradient. * - * @param {string[]} colors Array of colors. - * @returns {boolean} True if the given string is a valid gradient. + * @param colors Array of colors. + * @returns True if the given string is a valid gradient. */ -const isValidGradient = (colors) => { +const isValidGradient = (colors: Array): boolean => { return ( colors.length > 2 && colors.slice(1).every((color) => isValidHexColor(color)) @@ -30,48 +28,50 @@ const isValidGradient = (colors) => { /** * Retrieves a gradient if color has more than one valid hex codes else a single color. * - * @param {string} color The color to parse. - * @param {string | string[]} fallbackColor The fallback color. - * @returns {string | string[]} The gradient or color. + * @param color The color to parse. + * @param fallbackColor The fallback color. + * @returns The gradient or color. */ -const fallbackColor = (color, fallbackColor) => { - let gradient = null; - - let colors = color ? color.split(",") : []; +const fallbackColor = ( + color: string | undefined, + fallbackColor: string | Array, +): string | Array => { + const colors = color ? color.split(",") : []; if (colors.length > 1 && isValidGradient(colors)) { - gradient = colors; + return colors; } - return ( - (gradient ? gradient : isValidHexColor(color) && `#${color}`) || - fallbackColor - ); + if (color !== undefined && isValidHexColor(color)) { + return `#${color}`; + } + + return fallbackColor; }; /** * Object containing card colors. - * @typedef {{ - * titleColor: string; - * iconColor: string; - * textColor: string; - * bgColor: string | string[]; - * borderColor: string; - * ringColor: string; - * }} CardColors */ +interface CardColors { + titleColor: string; + iconColor: string; + textColor: string; + bgColor: string | Array; + borderColor: string; + ringColor: string; +} /** * Returns theme based colors with proper overrides and defaults. * - * @param {Object} args Function arguments. - * @param {string=} args.title_color Card title color. - * @param {string=} args.text_color Card text color. - * @param {string=} args.icon_color Card icon color. - * @param {string=} args.bg_color Card background color. - * @param {string=} args.border_color Card border color. - * @param {string=} args.ring_color Card ring color. - * @param {string=} args.theme Card theme. - * @returns {CardColors} Card colors. + * @param props Function arguments. + * @param props.title_color Card title color. + * @param props.text_color Card text color. + * @param props.icon_color Card icon color. + * @param props.bg_color Card background color. + * @param props.border_color Card border color. + * @param props.ring_color Card ring color. + * @param props.theme Card theme. + * @returns Card colors. */ const getCardColors = ({ title_color, @@ -81,19 +81,26 @@ const getCardColors = ({ border_color, ring_color, theme, -}) => { - const defaultTheme = themes["default"]; - const isThemeProvided = - theme !== null && theme !== undefined && theme in themes; - - // @ts-ignore - const selectedTheme = isThemeProvided ? themes[theme] : defaultTheme; +}: { + title_color?: string; + text_color?: string; + icon_color?: string; + bg_color?: string; + border_color?: string; + ring_color?: string; + theme?: string; +}): CardColors => { + const defaultTheme = themes.default; + const isThemeProvided = theme !== undefined && theme in themes; + + const selectedTheme = isThemeProvided + ? themes[theme as keyof typeof themes] + : defaultTheme; const defaultBorderColor = "border_color" in selectedTheme ? selectedTheme.border_color - : // @ts-ignore - defaultTheme.border_color; + : defaultTheme.border_color; // get the color provided by the user else the theme color // finally if both colors are invalid fallback to default theme @@ -104,11 +111,8 @@ const getCardColors = ({ // get the color provided by the user else the theme color // finally if both colors are invalid we use the titleColor - const ringColor = fallbackColor( - // @ts-ignore - ring_color || selectedTheme.ring_color, - titleColor, - ); + // NOTE: no built-in theme defines `ring_color`, so it falls back to the title color. + const ringColor = fallbackColor(ring_color, titleColor); const iconColor = fallbackColor( icon_color || selectedTheme.icon_color, "#" + defaultTheme.icon_color, diff --git a/packages/core/src/common/config.js b/packages/core/src/common/config.js deleted file mode 100644 index 32456ce8ae3d1..0000000000000 --- a/packages/core/src/common/config.js +++ /dev/null @@ -1,78 +0,0 @@ -// @ts-check - -/** - * @param {string | undefined} value Comma-separated string. - * @returns {string[] | undefined} Parsed string values. - */ -const parseCsv = (value) => { - if (!value) { - return undefined; - } - return value.split(","); -}; - -/** - * @param {Record} env Environment variables to inspect. - * @returns {{name: string, value: string}[]} Personal access tokens found in the environment. - */ -const parsePATsFromEnv = (env) => { - return Object.keys(env) - .filter((key) => /PAT_\d*$/.exec(key)) - .map((name) => ({ - name, - value: env[name], - })); -}; - -/** - * @returns {Record} `process.env` if available, otherwise `{}`. - */ -const getDefaultEnv = () => { - if (typeof process !== "undefined" && process?.env) { - return process.env; - } - return {}; -}; - -/** - * @param {Partial>} config (Partial) config values to normalize. - * @returns {{ - * whitelist: string[] | undefined, - * gistWhitelist: string[] | undefined, - * excludeRepositories: string[], - * fetchMultiPageStars: string | undefined, - * pats: {name: string, value: string}[], - * }} Normalized config object with defaults applied. - */ -const normalizeConfig = (config = {}) => { - return { - whitelist: config.whitelist, - gistWhitelist: config.gistWhitelist, - excludeRepositories: config.excludeRepositories || [], - fetchMultiPageStars: config.fetchMultiPageStars, - pats: config.pats || [], - }; -}; - -let currentConfig; - -/** - * @param {Record} env Environment variables used to build the runtime config. - */ -export const loadConfigFromEnv = (env = getDefaultEnv()) => { - const whitelist = parseCsv(env.WHITELIST); - const gistWhitelist = parseCsv(env.GIST_WHITELIST); - const excludeRepositories = parseCsv(env.EXCLUDE_REPO) || []; - - currentConfig = normalizeConfig({ - whitelist, - gistWhitelist, - excludeRepositories, - fetchMultiPageStars: env.FETCH_MULTI_PAGE_STARS, - pats: parsePATsFromEnv(env), - }); -}; - -loadConfigFromEnv(); - -export const getConfig = () => currentConfig; diff --git a/packages/core/src/common/config.ts b/packages/core/src/common/config.ts new file mode 100644 index 0000000000000..5dfc2d86487a7 --- /dev/null +++ b/packages/core/src/common/config.ts @@ -0,0 +1,83 @@ +type Env = Record; + +interface PersonalAccessToken { + name: string; + value: string; +} + +interface Config { + whitelist: Array | undefined; + gistWhitelist: Array | undefined; + excludeRepositories: Array; + fetchMultiPageStars: string | undefined; + pats: Array; +} + +/** + * @param value Comma-separated string. + * @returns Parsed string values. + */ +const parseCsv = (value: string | undefined): Array | undefined => { + if (!value) { + return undefined; + } + return value.split(","); +}; + +/** + * @param env Environment variables to inspect. + * @returns Personal access tokens found in the environment. + */ +const parsePATsFromEnv = (env: Env): Array => { + return Object.keys(env) + .filter((key) => /PAT_\d*$/.exec(key)) + .map((name) => ({ + name, + value: env[name] ?? "", + })); +}; + +/** + * @returns `process.env` if available, otherwise `{}`. + */ +const getDefaultEnv = (): Env => { + const processEnv = (globalThis as { process?: { env?: Env } }).process?.env; + return processEnv ?? {}; +}; + +/** + * @param config (Partial) config values to normalize. + * @returns Normalized config object with defaults applied. + */ +const normalizeConfig = (config: Partial = {}): Config => { + return { + whitelist: config.whitelist, + gistWhitelist: config.gistWhitelist, + excludeRepositories: config.excludeRepositories ?? [], + fetchMultiPageStars: config.fetchMultiPageStars, + pats: config.pats ?? [], + }; +}; + +let currentConfig: Config; + +/** + * @param env Environment variables used to build the runtime config. + */ +export const loadConfigFromEnv = (env: Env = getDefaultEnv()): void => { + const whitelist = parseCsv(env["WHITELIST"]); + const gistWhitelist = parseCsv(env["GIST_WHITELIST"]); + const excludeRepositories = parseCsv(env["EXCLUDE_REPO"]) ?? []; + + currentConfig = normalizeConfig({ + whitelist, + gistWhitelist, + excludeRepositories, + fetchMultiPageStars: env["FETCH_MULTI_PAGE_STARS"], + pats: parsePATsFromEnv(env), + }); +}; + +loadConfigFromEnv(); + +export const getConfig = (): Config => currentConfig; diff --git a/packages/core/src/common/constants.js b/packages/core/src/common/constants.js deleted file mode 100644 index 483a57b5590b7..0000000000000 --- a/packages/core/src/common/constants.js +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-check - -/** - * Valid owner affiliations for GitHub API queries. - * @type {Array} - */ -const OWNER_AFFILIATIONS = ["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"]; - -export { OWNER_AFFILIATIONS }; diff --git a/packages/core/src/common/constants.ts b/packages/core/src/common/constants.ts new file mode 100644 index 0000000000000..9b40de281b76b --- /dev/null +++ b/packages/core/src/common/constants.ts @@ -0,0 +1,10 @@ +/** + * Valid owner affiliations for GitHub API queries. + */ +const OWNER_AFFILIATIONS: Array = [ + "OWNER", + "COLLABORATOR", + "ORGANIZATION_MEMBER", +]; + +export { OWNER_AFFILIATIONS }; diff --git a/packages/core/src/common/error.js b/packages/core/src/common/error.ts similarity index 68% rename from packages/core/src/common/error.js rename to packages/core/src/common/error.ts index b838f36d137c1..0cb3b0f8c5877 100644 --- a/packages/core/src/common/error.js +++ b/packages/core/src/common/error.ts @@ -1,14 +1,12 @@ -// @ts-check - import { OWNER_AFFILIATIONS } from "./constants.js"; /** - * @type {string} A general message to ask user to try again later. + * A general message to ask user to try again later. */ const TRY_AGAIN_LATER = "Please try again later"; /** - * @type {Object} A map of error types to secondary error messages. + * A map of error types to secondary error messages. */ const SECONDARY_ERROR_MESSAGES = { MAX_RETRY: @@ -28,16 +26,21 @@ const SECONDARY_ERROR_MESSAGES = { * Custom error class to handle custom GRS errors. */ class CustomError extends Error { + type: string; + secondaryMessage: string; + /** * Custom error constructor. * - * @param {string} message Error message. - * @param {string} type Error type. + * @param message Error message. + * @param type Error type. */ - constructor(message, type) { + constructor(message: string, type: string) { super(message); this.type = type; - this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type; + type PartialRecord = Partial>; + this.secondaryMessage = + (SECONDARY_ERROR_MESSAGES as PartialRecord)[type] ?? type; } static MAX_RETRY = "MAX_RETRY"; @@ -53,13 +56,16 @@ class CustomError extends Error { * Missing query parameter class. */ class MissingParamError extends Error { + missedParams: Array; + secondaryMessage: string | undefined; + /** * Missing query parameter error constructor. * - * @param {string[]} missedParams An array of missing parameters names. - * @param {string=} secondaryMessage Optional secondary message to display. + * @param missedParams An array of missing parameters names. + * @param secondaryMessage Optional secondary message to display. */ - constructor(missedParams, secondaryMessage) { + constructor(missedParams: Array, secondaryMessage?: string) { const msg = `Missing params ${missedParams .map((p) => `"${p}"`) .join(", ")} make sure you pass the parameters in URL`; @@ -72,10 +78,10 @@ class MissingParamError extends Error { /** * Retrieve secondary message from an error object. * - * @param {Error} err The error object. - * @returns {string|undefined} The secondary message if available, otherwise undefined. + * @param err The error object. + * @returns The secondary message if available, otherwise undefined. */ -const retrieveSecondaryMessage = (err) => { +const retrieveSecondaryMessage = (err: Error): string | undefined => { return "secondaryMessage" in err && typeof err.secondaryMessage === "string" ? err.secondaryMessage : undefined; diff --git a/packages/core/src/common/fmt.js b/packages/core/src/common/fmt.ts similarity index 59% rename from packages/core/src/common/fmt.js rename to packages/core/src/common/fmt.ts index 2a46e79fc99c9..071030579c151 100644 --- a/packages/core/src/common/fmt.js +++ b/packages/core/src/common/fmt.ts @@ -1,16 +1,14 @@ -// @ts-check - import { encodeHTML } from "./html.js"; import { splitWrappedText } from "./render.js"; /** * Retrieves num with suffix k(thousands) precise to given decimal places. * - * @param {number} num The number to format. - * @param {number=} precision The number of decimal places to include. - * @returns {string|number} The formatted number. + * @param num The number to format. + * @param precision The number of decimal places to include. + * @returns The formatted number. */ -const kFormatter = (num, precision) => { +const kFormatter = (num: number, precision?: number): string | number => { const abs = Math.abs(num); const sign = Math.sign(num); @@ -22,17 +20,17 @@ const kFormatter = (num, precision) => { return sign * abs; } - return sign * parseFloat((abs / 1000).toFixed(1)) + "k"; + return `${sign * parseFloat((abs / 1000).toFixed(1))}k`; }; /** * Convert bytes to a human-readable string representation. * - * @param {number} bytes The number of bytes to convert. - * @returns {string} The human-readable representation of bytes. + * @param bytes The number of bytes to convert. + * @returns The human-readable representation of bytes. * @throws {Error} If bytes is negative or too large. */ -const formatBytes = (bytes) => { +const formatBytes = (bytes: number): string => { if (bytes < 0) { throw new Error("Bytes must be a non-negative number"); } @@ -45,23 +43,29 @@ const formatBytes = (bytes) => { const base = 1024; const i = Math.floor(Math.log(bytes) / Math.log(base)); - if (i >= sizes.length) { + const unit = sizes[i]; + if (unit === undefined) { throw new Error("Bytes is too large to convert to a human-readable string"); } - return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`; + return `${(bytes / Math.pow(base, i)).toFixed(1)} ${unit}`; }; /** * Split text over multiple lines based on the card width. * - * @param {string} text Text to split. - * @param {number} width Available wrap width in px. - * @param {number} fontSize Font size in px. - * @param {number} maxLines Maximum number of lines. - * @returns {string[]} Array of lines. + * @param text Text to split. + * @param width Available wrap width in px. + * @param fontSize Font size in px. + * @param maxLines Maximum number of lines. + * @returns Array of lines. */ -const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => { +const wrapTextMultiline = ( + text: string, + width: number, + fontSize: number, + maxLines = 3, +): Array => { const wrapped = splitWrappedText(text, fontSize, width); const lines = wrapped .map((line) => encodeHTML(line.trim())) @@ -69,7 +73,11 @@ const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => { // Add "..." to the last line if the text exceeds maxLines if (wrapped.length > maxLines) { - lines[maxLines - 1] += "..."; + const lastIndex = maxLines - 1; + const lastLine = lines[lastIndex]; + if (lastLine !== undefined) { + lines[lastIndex] = `${lastLine}...`; + } } // Remove empty lines if text fits in less than maxLines lines diff --git a/packages/core/src/common/html.js b/packages/core/src/common/html.ts similarity index 51% rename from packages/core/src/common/html.js rename to packages/core/src/common/html.ts index 3c5d44af8e741..a02b8d3d0d34a 100644 --- a/packages/core/src/common/html.js +++ b/packages/core/src/common/html.ts @@ -1,18 +1,13 @@ -// @ts-check - /** * Encode string as HTML. * * @see https://stackoverflow.com/a/48073476/10629172 - * - * @param {string} str String to encode. - * @returns {string} Encoded string. */ -const encodeHTML = (str) => { +const encodeHTML = (str: string): string => { return ( str - .replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => { - return "&#" + i.charCodeAt(0) + ";"; + .replace(/[\u00A0-\u9999<>&](?!#)/gim, (i: string) => { + return `&#${i.charCodeAt(0)};`; }) // eslint-disable-next-line no-control-regex .replace(/\u0008/gim, "") diff --git a/packages/core/src/common/http.js b/packages/core/src/common/http.js index ef6258b0b85eb..388d105289906 100644 --- a/packages/core/src/common/http.js +++ b/packages/core/src/common/http.js @@ -1,5 +1,3 @@ -// @ts-check - import axios from "axios"; /** diff --git a/packages/core/src/common/icons.js b/packages/core/src/common/icons.ts similarity index 96% rename from packages/core/src/common/icons.js rename to packages/core/src/common/icons.ts index 9b0c33ac949bf..64bc3b523f310 100644 --- a/packages/core/src/common/icons.js +++ b/packages/core/src/common/icons.ts @@ -1,5 +1,3 @@ -// @ts-check - /* The icons in this file are based on https://github.com/primer/octicons which is released under the MIT license: @@ -46,12 +44,16 @@ const icons = { /** * Get rank icon * - * @param {string} rankIcon - The rank icon type. - * @param {string} rankLevel - The rank level. - * @param {number} percentile - The rank percentile. - * @returns {string} - The SVG code of the rank icon + * @param rankIcon - The rank icon type. + * @param rankLevel - The rank level. + * @param percentile - The rank percentile. + * @returns The SVG code of the rank icon */ -const rankIcon = (rankIcon, rankLevel, percentile) => { +const rankIcon = ( + rankIcon: string, + rankLevel: string, + percentile: number, +): string => { switch (rankIcon) { case "github": return ` diff --git a/packages/core/src/common/ops.js b/packages/core/src/common/ops.js index 2aad261e7d57a..04df76eed4092 100644 --- a/packages/core/src/common/ops.js +++ b/packages/core/src/common/ops.js @@ -1,5 +1,3 @@ -// @ts-check - import toEmoji from "emoji-name-map"; import { OWNER_AFFILIATIONS } from "./constants.js"; diff --git a/packages/core/src/common/render.js b/packages/core/src/common/render.js index 55781a8dd23db..24dd8c624e2aa 100644 --- a/packages/core/src/common/render.js +++ b/packages/core/src/common/render.js @@ -1,5 +1,3 @@ -// @ts-check - import { getCardColors } from "./color.js"; import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js"; import { encodeHTML } from "./html.js"; diff --git a/packages/core/src/common/retryer.js b/packages/core/src/common/retryer.ts similarity index 54% rename from packages/core/src/common/retryer.js rename to packages/core/src/common/retryer.ts index 79d294dc00abb..b5fed0915dc3a 100644 --- a/packages/core/src/common/retryer.js +++ b/packages/core/src/common/retryer.ts @@ -1,43 +1,56 @@ -// @ts-check +import type { AxiosResponse } from "axios"; import { getConfig } from "./config.js"; import { CustomError } from "./error.js"; import { logger } from "./log.js"; +/** + * Subset of the response payload the retryer inspects to detect + * rate-limiting and credential failures. + */ +interface ResponseData { + errors?: Array<{ type?: string; message?: string }>; + message?: string; +} + /** * Returns a random integer from 0 (inclusive) to `max` (exclusive). * * The value is generated using `Math.random()` and uniformly distributed * across the range. * - * @param {number} max The upper bound (exclusive). Must be a positive number. + * @param max The upper bound (exclusive). Must be a positive number. * - * @returns {number} A random integer `n` such that `0 <= n < max`. + * @returns A random integer `n` such that `0 <= n < max`. */ -function getRandomInt(max) { +function getRandomInt(max: number): number { return Math.floor(Math.random() * max); } -/** - * @typedef {import("axios").AxiosResponse} AxiosResponse Axios response. - * @typedef {(variables: any, token: string, retriesForTests?: number) => Promise} FetcherFunction Fetcher function. - */ +type FetcherResponse = AxiosResponse; + +type FetcherFunction = ( + variables: Record, + token: string, + retriesForTests?: number, +) => Promise; /** * Try to execute the fetcher function until it succeeds or the max number of retries is reached. * - * @param {FetcherFunction} fetcher The fetcher function. - * @param {any} variables Object with arguments to pass to the fetcher function. - * @param {string | null} pat Optional PAT override. - * @returns {Promise} The response from the fetcher function. + * @param fetcher The fetcher function. + * @param variables Object with arguments to pass to the fetcher function. + * @param pat Optional PAT override. + * @returns The response from the fetcher function. */ -const retryer = async (fetcher, variables, pat = null) => { - let PATs; - if (pat) { - PATs = [{ name: "user PAT from database", value: pat }]; - } else { - PATs = getConfig().pats; - } +const retryer = async ( + fetcher: FetcherFunction, + variables: Record, + pat: string | null = null, +): Promise => { + const PATs = pat + ? [{ name: "user PAT from database", value: pat }] + : getConfig().pats; if (!PATs.length) { throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS); @@ -46,11 +59,13 @@ const retryer = async (fetcher, variables, pat = null) => { for (let retries = 0; retries < PATs.length; retries++) { const currentPAT = PATs[(startPAT + retries) % PATs.length]; + if (!currentPAT) { + continue; + } try { - let response = await fetcher( + const response = await fetcher( variables, - // @ts-ignore currentPAT.value, // used in tests for faking rate limit retries, @@ -58,11 +73,11 @@ const retryer = async (fetcher, variables, pat = null) => { // react on both type and message-based rate-limit signals. // https://github.com/anuraghazra/github-readme-stats/issues/4425 - const errors = response?.data?.errors; + const errors = response.data.errors; const errorType = errors?.[0]?.type; - const errorMsg = errors?.[0]?.message || ""; + const errorMsg = errors?.[0]?.message ?? ""; const isRateLimited = - (errors && errorType === "RATE_LIMITED") || + (!!errors && errorType === "RATE_LIMITED") || /rate limit/i.test(errorMsg); if (isRateLimited) { @@ -71,20 +86,18 @@ const retryer = async (fetcher, variables, pat = null) => { return response; } } catch (err) { - /** @type {any} */ - const e = err; + const e = err as { response?: FetcherResponse }; // network/unexpected error → let caller treat as failure - if (!e?.response) { - throw e; + if (!e.response) { + throw err; } - // prettier-ignore // also checking for bad credentials if any tokens gets invalidated - const isBadCredential = - e?.response?.data?.message === "Bad credentials"; + const message = e.response.data.message; + const isBadCredential = message === "Bad credentials"; const isAccountSuspended = - e?.response?.data?.message === "Sorry. Your account was suspended."; + message === "Sorry. Your account was suspended."; if (isBadCredential || isAccountSuspended) { logger.log(`${currentPAT.name} Failed due to bad credentials`); diff --git a/packages/core/src/fetchers/gist.js b/packages/core/src/fetchers/gist.js index 450b17119d476..6bf4a682f05c8 100644 --- a/packages/core/src/fetchers/gist.js +++ b/packages/core/src/fetchers/gist.js @@ -1,5 +1,3 @@ -// @ts-check - import { MissingParamError } from "../common/error.js"; import { request } from "../common/http.js"; import { retryer } from "../common/retryer.js"; diff --git a/packages/core/src/fetchers/repo.js b/packages/core/src/fetchers/repo.js index 96d727f6b050b..2458f244100ea 100644 --- a/packages/core/src/fetchers/repo.js +++ b/packages/core/src/fetchers/repo.js @@ -1,5 +1,3 @@ -// @ts-check - import { MissingParamError } from "../common/error.js"; import { request } from "../common/http.js"; import { retryer } from "../common/retryer.js"; diff --git a/packages/core/src/fetchers/stats.js b/packages/core/src/fetchers/stats.js index 602101bc61a7e..307d6e4ac2301 100644 --- a/packages/core/src/fetchers/stats.js +++ b/packages/core/src/fetchers/stats.js @@ -1,5 +1,3 @@ -// @ts-check - import axios from "axios"; import githubUsernameRegex from "github-username-regex"; diff --git a/packages/core/src/fetchers/top-languages.js b/packages/core/src/fetchers/top-languages.js index 8f782493e6588..fb2dcc3b161c8 100644 --- a/packages/core/src/fetchers/top-languages.js +++ b/packages/core/src/fetchers/top-languages.js @@ -1,5 +1,3 @@ -// @ts-check - import { getConfig } from "../common/config.js"; import { CustomError, MissingParamError } from "../common/error.js"; import { wrapTextMultiline } from "../common/fmt.js"; diff --git a/packages/core/src/fetchers/wakatime.js b/packages/core/src/fetchers/wakatime.js index 916d84a8bf364..1ecbc77334583 100644 --- a/packages/core/src/fetchers/wakatime.js +++ b/packages/core/src/fetchers/wakatime.js @@ -1,5 +1,3 @@ -// @ts-check - import axios from "axios"; import { CustomError, MissingParamError } from "../common/error.js"; diff --git a/packages/core/src/translations.js b/packages/core/src/translations.ts similarity index 99% rename from packages/core/src/translations.js rename to packages/core/src/translations.ts index e34d0d4dffaf1..385d4759f0d27 100644 --- a/packages/core/src/translations.js +++ b/packages/core/src/translations.ts @@ -1,18 +1,22 @@ -// @ts-check - import { encodeHTML } from "./common/html.js"; /** * Retrieves stat card labels in the available locales. * - * @param {object} props Function arguments. - * @param {string} props.name The name of the locale. - * @param {string} props.apostrophe Whether to use apostrophe or not. - * @returns {object} The locales object. + * @param props Function arguments. + * @param props.name The name of the locale. + * @param props.apostrophe Whether to use apostrophe or not. + * @returns The locales object. * * @see https://www.andiamo.co.uk/resources/iso-language-codes/ for language codes. */ -const statCardLocales = ({ name, apostrophe }) => { +const statCardLocales = ({ + name, + apostrophe, +}: { + name: string; + apostrophe: string; +}) => { const encodedName = encodeHTML(name); return { "statcard.title": { @@ -1140,10 +1144,10 @@ const availableLocales = Object.keys(repoCardLocales["repocard.archived"]); /** * Checks whether the locale is available or not. * - * @param {string} locale The locale to check. - * @returns {boolean} Boolean specifying whether the locale is available or not. + * @param locale The locale to check. + * @returns Boolean specifying whether the locale is available or not. */ -const isLocaleAvailable = (locale) => { +const isLocaleAvailable = (locale: string): boolean => { return availableLocales.includes(locale.toLowerCase()); }; diff --git a/packages/core/tests/_css-to-object.d.ts b/packages/core/tests/_css-to-object.d.ts new file mode 100644 index 0000000000000..4f15005a3a568 --- /dev/null +++ b/packages/core/tests/_css-to-object.d.ts @@ -0,0 +1,12 @@ +/** + * `@uppercod/css-to-object` ships type definitions but does not expose them + * through its package.json `exports` map, so they cannot be resolved under + * `nodenext`. Declare the minimal surface used in tests here. + */ +declare module "@uppercod/css-to-object" { + export type CssObject = Record< + string, + Record> + >; + export function cssToObject(css: string): CssObject; +} diff --git a/packages/core/tests/_setup.js b/packages/core/tests/_setup.js deleted file mode 100644 index 3956661798162..0000000000000 --- a/packages/core/tests/_setup.js +++ /dev/null @@ -1,7 +0,0 @@ -import * as matchers from "@testing-library/jest-dom/matchers"; -import { expect } from "vitest"; - -expect.extend(matchers); - -process.env.PAT_1 = "dummyPAT1"; -process.env.PAT_2 = "dummyPAT2"; diff --git a/packages/core/tests/_setup.ts b/packages/core/tests/_setup.ts new file mode 100644 index 0000000000000..ce0fc87720550 --- /dev/null +++ b/packages/core/tests/_setup.ts @@ -0,0 +1,4 @@ +import "@testing-library/jest-dom/vitest"; + +process.env["PAT_1"] = "dummyPAT1"; +process.env["PAT_2"] = "dummyPAT2"; diff --git a/packages/core/tests/card.test.js b/packages/core/tests/card.test.ts similarity index 91% rename from packages/core/tests/card.test.js rename to packages/core/tests/card.test.ts index c9d289d4f8249..cfafacaa23977 100644 --- a/packages/core/tests/card.test.js +++ b/packages/core/tests/card.test.ts @@ -1,12 +1,16 @@ import { queryByTestId } from "@testing-library/dom"; import { cssToObject } from "@uppercod/css-to-object"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { Card } from "../src/common/Card.js"; import { getCardColors } from "../src/common/color.js"; import { icons } from "../src/common/icons.js"; describe("Card", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + it("should hide border", () => { const card = new Card({}); card.setHideBorder(true); @@ -68,21 +72,21 @@ describe("Card", () => { }); it("title should have prefix icon", () => { - const card = new Card({ title: "ok", titlePrefixIcon: icons.contribs }); + const card = new Card({ titlePrefixIcon: icons.contribs }); document.body.innerHTML = card.render(``); expect(document.getElementsByClassName("icon")[0]).toBeInTheDocument(); }); it("title should not have prefix icon", () => { - const card = new Card({ title: "ok" }); + const card = new Card({}); document.body.innerHTML = card.render(``); expect(document.getElementsByClassName("icon")[0]).toBeUndefined(); }); it("should have proper height, width", () => { - const card = new Card({ height: 200, width: 200, title: "ok" }); + const card = new Card({ height: 200, width: 200 }); document.body.innerHTML = card.render(``); expect(document.getElementsByTagName("svg")[0]).toHaveAttribute( "height", @@ -95,7 +99,7 @@ describe("Card", () => { }); it("should have less height after title is hidden", () => { - const card = new Card({ height: 200, title: "ok" }); + const card = new Card({ height: 200 }); card.setHideTitle(true); document.body.innerHTML = card.render(``); @@ -147,10 +151,10 @@ describe("Card", () => { document.body.innerHTML = card.render(``); const styleTag = document.querySelector("style"); - const stylesObject = cssToObject(styleTag.innerHTML); - const headerClassStyles = stylesObject[":host"][".header "]; + const stylesObject = cssToObject(styleTag?.innerHTML ?? ""); + const headerClassStyles = stylesObject[":host"]?.[".header "]; - expect(headerClassStyles["fill"].trim()).toBe("#f00"); + expect(headerClassStyles?.["fill"]?.trim()).toBe("#f00"); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", "#fff", diff --git a/packages/core/tests/color.test.js b/packages/core/tests/color.test.ts similarity index 90% rename from packages/core/tests/color.test.js rename to packages/core/tests/color.test.ts index c1a000b1e5e4e..7f9fc557d6665 100644 --- a/packages/core/tests/color.test.js +++ b/packages/core/tests/color.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { getCardColors } from "../src/common/color"; +import { getCardColors } from "../src/common/color.js"; describe("Test color.js", () => { it("getCardColors: should return expected values", () => { - let colors = getCardColors({ + const colors = getCardColors({ title_color: "f00", text_color: "0f0", ring_color: "0000ff", @@ -24,7 +24,7 @@ describe("Test color.js", () => { }); it("getCardColors: should fallback to default colors if color is invalid", () => { - let colors = getCardColors({ + const colors = getCardColors({ title_color: "invalidcolor", text_color: "0f0", icon_color: "00f", @@ -43,7 +43,7 @@ describe("Test color.js", () => { }); it("getCardColors: should fallback to specified theme colors if is not defined", () => { - let colors = getCardColors({ + const colors = getCardColors({ theme: "dark", }); expect(colors).toStrictEqual({ @@ -57,7 +57,7 @@ describe("Test color.js", () => { }); it("getCardColors: should return ring color equal to title color if not ring color is defined", () => { - let colors = getCardColors({ + const colors = getCardColors({ title_color: "f00", text_color: "0f0", icon_color: "00f", @@ -76,7 +76,7 @@ describe("Test color.js", () => { }); it("getCardColors: should fallback to default theme if theme is invalid", () => { - let colors = getCardColors({ + const colors = getCardColors({ theme: "invalidTheme", }); expect(colors).toStrictEqual({ diff --git a/packages/core/tests/fmt.test.js b/packages/core/tests/fmt.test.ts similarity index 92% rename from packages/core/tests/fmt.test.js rename to packages/core/tests/fmt.test.ts index 1c0bcdf96def0..1a0087cc5eaec 100644 --- a/packages/core/tests/fmt.test.js +++ b/packages/core/tests/fmt.test.ts @@ -72,7 +72,7 @@ describe("Test fmt.js", () => { it("wrapTextMultiline: should not wrap small texts", () => { { - let multiLineText = wrapTextMultiline( + const multiLineText = wrapTextMultiline( "Small text should not wrap", 130, 11, @@ -83,7 +83,7 @@ describe("Test fmt.js", () => { }); it("wrapTextMultiline: should wrap large texts", () => { - let multiLineText = wrapTextMultiline( + const multiLineText = wrapTextMultiline( "Hello world long long long text", 130, 11, @@ -93,7 +93,7 @@ describe("Test fmt.js", () => { }); it("wrapTextMultiline: should wrap large texts and limit max lines", () => { - let multiLineText = wrapTextMultiline( + const multiLineText = wrapTextMultiline( "Hello world long long long text", 53, 11, @@ -103,13 +103,13 @@ describe("Test fmt.js", () => { }); it("wrapTextMultiline: should handle chinese characters", () => { - let multiLineText = wrapTextMultiline( + const multiLineText = wrapTextMultiline( "专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!", 130, 11, 3, ); - expect(multiLineText.length).toEqual(3); - expect(multiLineText[0].length).toEqual(11 * 8); // &#xxxxx; x 8 + expect(multiLineText).toHaveLength(3); + expect(multiLineText[0]).toHaveLength(11 * 8); // &#xxxxx; x 8 }); }); diff --git a/packages/core/tests/html.test.js b/packages/core/tests/html.test.ts similarity index 100% rename from packages/core/tests/html.test.js rename to packages/core/tests/html.test.ts diff --git a/packages/core/tests/i18n.test.js b/packages/core/tests/i18n.test.ts similarity index 83% rename from packages/core/tests/i18n.test.js rename to packages/core/tests/i18n.test.ts index 0071f77c930e1..d9034261f15a8 100644 --- a/packages/core/tests/i18n.test.js +++ b/packages/core/tests/i18n.test.ts @@ -17,9 +17,10 @@ describe("I18n", () => { locale: "en", translations: statCardLocales({ name: "Anurag Hazra", apostrophe: "s" }), }); - expect(() => i18n.t("statcard.title1")).toThrow( - "statcard.title1 Translation string not found", - ); + expect( + // @ts-expect-error using a non-existing key should be reported by ts + () => i18n.t("statcard.title1"), + ).toThrow("statcard.title1 Translation string not found"); }); it("should throw error if translation not found for locale", () => { diff --git a/packages/core/tests/render.test.js b/packages/core/tests/render.test.js index 843d047e6b4c7..67a6d0a5c4d87 100644 --- a/packages/core/tests/render.test.js +++ b/packages/core/tests/render.test.js @@ -1,5 +1,3 @@ -// @ts-check - import { queryByTestId } from "@testing-library/dom"; import { describe, expect, it } from "vitest"; diff --git a/packages/core/tests/retryer.test.js b/packages/core/tests/retryer.test.ts similarity index 85% rename from packages/core/tests/retryer.test.js rename to packages/core/tests/retryer.test.ts index d869296b354c7..99061efe98f11 100644 --- a/packages/core/tests/retryer.test.js +++ b/packages/core/tests/retryer.test.ts @@ -1,9 +1,9 @@ -// @ts-check - import { describe, expect, it, vi } from "vitest"; import { retryer } from "../src/common/retryer.js"; +type Fetcher = Parameters[0]; + vi.mock(import("../src/common/log.js"), async () => { const { createLoggerMock } = await import("./utils.js"); return createLoggerMock(); @@ -13,14 +13,14 @@ const fetcher = vi.fn().mockResolvedValue({ data: "ok" }); const fetcherFail = vi.fn().mockResolvedValue({ data: { errors: [{ type: "RATE_LIMITED" }] }, -}); +}) as unknown as Fetcher; const fetcherFailOnSecondTry = vi.fn((_vars, _token, retries) => { if (retries < 1) { return Promise.resolve({ data: { errors: [{ type: "RATE_LIMITED" }] } }); } return Promise.resolve({ data: "ok" }); -}); +}) as unknown as Fetcher; const fetcherFailWithMessageBasedRateLimitErr = vi.fn( (_vars, _token, retries) => { @@ -38,29 +38,29 @@ const fetcherFailWithMessageBasedRateLimitErr = vi.fn( } return Promise.resolve({ data: "ok" }); }, -); +) as unknown as Fetcher; -const customFetcher = vi.fn((variables, token) => { +const customFetcher = vi.fn((_variables: unknown, token: string) => { return Promise.resolve({ data: { token } }); -}); +}) as unknown as Fetcher; describe("Test Retryer", () => { it("retryer should return value and have zero retries on first try", async () => { - let res = await retryer(fetcher, {}); + const res = await retryer(fetcher, {}); expect(fetcher).toHaveBeenCalledTimes(1); expect(res).toStrictEqual({ data: "ok" }); }); it("retryer should return value and have 2 retries", async () => { - let res = await retryer(fetcherFailOnSecondTry, {}); + const res = await retryer(fetcherFailOnSecondTry, {}); expect(fetcherFailOnSecondTry).toHaveBeenCalledTimes(2); expect(res).toStrictEqual({ data: "ok" }); }); it("retryer should return value and have 2 retries with message based rate limit error", async () => { - let res = await retryer(fetcherFailWithMessageBasedRateLimitErr, {}); + const res = await retryer(fetcherFailWithMessageBasedRateLimitErr, {}); expect(fetcherFailWithMessageBasedRateLimitErr).toHaveBeenCalledTimes(2); expect(res).toStrictEqual({ data: "ok" }); diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts index 1a4c5205ceb00..d4f3af6528060 100644 --- a/packages/core/tests/utils.ts +++ b/packages/core/tests/utils.ts @@ -16,8 +16,7 @@ import type * as loggerModule from "../src/common/log.js"; * For example, with `precision = 3`, values must be within `0.001`. * * @param expected The expected numeric value to compare against. - * - * @param + * @param precision * The number of decimal places of tolerance. Higher values mean stricter * comparison. Internally converted to epsilon = 10^-precision. * diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json index 0167680b525ab..c81310019f532 100644 --- a/packages/core/tsconfig.build.json +++ b/packages/core/tsconfig.build.json @@ -5,6 +5,9 @@ "rootDir": "./src", "declaration": true, "declarationMap": true, - "customConditions": [] + "customConditions": [], + + // Typecheck is performed in a separate task + "noCheck": true } } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index c1b9c120b8b6d..84750492a86d8 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,17 +2,7 @@ "extends": ["../../tsconfig.base.json"], "include": ["src", "tests", "vitest.config.ts"], "compilerOptions": { - /** - * This workspace contains a lot of type errors. - * We will add typecheck later: - * @see https://github.com/stats-organization/github-stats-extended/issues/140 - * - * At the moment we just need to output `js` and `.d.ts` files - */ - "noCheck": true, - "allowJs": true, - "checkJs": true, "module": "nodenext", "moduleResolution": "nodenext", diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 4e11ea5320975..fc05dfd1008c0 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -4,6 +4,6 @@ export default defineProject({ test: { environment: "jsdom", include: ["./tests/*.test.{ts,js}"], - setupFiles: ["./tests/_setup.js"], + setupFiles: ["./tests/_setup.ts"], }, });