From ae3e94a8613199daaa3135d338405c9ad42e80a5 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti Date: Sat, 11 Jul 2026 13:46:57 +0200 Subject: [PATCH 1/3] fix(top-languages): default color when a language has no color --- packages/core/src/cards/top-languages.js | 30 +++++++++++-------- .../core/tests/renderTopLanguagesCard.test.js | 22 ++++++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/core/src/cards/top-languages.js b/packages/core/src/cards/top-languages.js index ed6f1b00eef28..86c89de3fc14a 100644 --- a/packages/core/src/cards/top-languages.js +++ b/packages/core/src/cards/top-languages.js @@ -448,8 +448,9 @@ const renderCompactLayout = ( let progressOffset = 0; const compactProgressBar = langs .map((lang) => { - if (!isValidHexColor(lang.color, true)) { - throw new Error(`Invalid language color: "${lang.color}"`); + const langColor = lang.color || DEFAULT_LANG_COLOR; + if (!isValidHexColor(langColor, true)) { + throw new Error(`Invalid language color: "${langColor}"`); } const percentage = parseFloat( @@ -466,7 +467,7 @@ const renderCompactLayout = ( y="0" width="${progress}" height="8" - fill="${lang.color || "#858585"}" + fill="${langColor}" /> `; progressOffset += percentage; @@ -527,8 +528,9 @@ const renderDonutVerticalLayout = ( // Generate each donut vertical chart part for (const lang of langs) { - if (!isValidHexColor(lang.color, true)) { - throw new Error(`Invalid language color: "${lang.color}"`); + const langColor = lang.color || DEFAULT_LANG_COLOR; + if (!isValidHexColor(langColor, true)) { + throw new Error(`Invalid language color: "${langColor}"`); } const percentage = (lang.size / totalLanguageSize) * 100; @@ -542,7 +544,7 @@ const renderDonutVerticalLayout = ( cy="100" r="${radius}" fill="transparent" - stroke="${lang.color}" + stroke="${langColor}" stroke-width="25" stroke-dasharray="${totalCircleLength}" stroke-dashoffset="${indent}" @@ -606,8 +608,9 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => { // Generate each pie chart part for (const lang of langs) { - if (!isValidHexColor(lang.color, true)) { - throw new Error(`Invalid language color: "${lang.color}"`); + const langColor = lang.color || DEFAULT_LANG_COLOR; + if (!isValidHexColor(langColor, true)) { + throw new Error(`Invalid language color: "${langColor}"`); } if (langs.length === 1) { @@ -617,7 +620,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => { cy="${centerY}" r="${radius}" stroke="none" - fill="${lang.color}" + fill="${langColor}" data-testid="lang-pie" size="100" /> @@ -650,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}" /> `); @@ -747,10 +750,11 @@ const renderDonutLayout = ( const strokeWidth = 12; const colors = langs.map((lang) => { - if (!isValidHexColor(lang.color, true)) { - throw new Error(`Invalid language color: "${lang.color}"`); + const langColor = lang.color || DEFAULT_LANG_COLOR; + if (!isValidHexColor(langColor, true)) { + throw new Error(`Invalid language color: "${langColor}"`); } - return lang.color; + return langColor; }); const langsPercents = langs.map((lang) => parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)), diff --git a/packages/core/tests/renderTopLanguagesCard.test.js b/packages/core/tests/renderTopLanguagesCard.test.js index 020f0a8ae4e0b..d453a0ff08c5f 100644 --- a/packages/core/tests/renderTopLanguagesCard.test.js +++ b/packages/core/tests/renderTopLanguagesCard.test.js @@ -1010,3 +1010,25 @@ describe("test top-langs API", () => { ); }); }); + +describe("test renderTopLanguages with languages missing a color", () => { + // GitHub's GraphQL `Language.color` is nullable, so a language can reach the + // card with `color: null`. It must fall back to the default color instead of + // throwing. + const langsWithNullColor = { + HTML: { color: "#0f0", name: "HTML", size: 200 }, + Text: { color: null, name: "Text", size: 100 }, + }; + + it.each(["normal", "compact", "donut", "donut-vertical", "pie"])( + "should render the %s layout using the default color", + (layout) => { + expect(() => + renderTopLanguages(langsWithNullColor, { layout }), + ).not.toThrow(); + + const card = renderTopLanguages(langsWithNullColor, { layout }); + expect(card).toContain("#858585"); + }, + ); +}); From 795201a099e44b9460bd283330dbad14216adfe4 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti Date: Sat, 11 Jul 2026 14:07:30 +0200 Subject: [PATCH 2/3] refactor(core): restructure color validation --- .github/workflows/ci.yml | 1 + packages/core/src/cards/repo.js | 4 +- packages/core/src/cards/top-languages.js | 14 ++--- packages/core/src/cards/wakatime.js | 6 +- packages/core/src/common/Card.ts | 8 +-- packages/core/src/common/color.ts | 71 ++++++++++++++---------- packages/core/src/common/render.ts | 10 ++-- packages/core/tests/color.test.ts | 44 ++++++++++----- 8 files changed, 95 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db9a95007c650..5d65fd3858928 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: pull_request: branches: - master + - fix-warnings concurrency: group: "${{ github.workflow }}-${{ github.head_ref }}" diff --git a/packages/core/src/cards/repo.js b/packages/core/src/cards/repo.js index 6c9a6c86790fb..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, isValidHexColor } 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"; @@ -33,7 +33,7 @@ const DESCRIPTION_MAX_LINES = 3; * @returns {string} Wrapped repo description SVG object. */ const getBadgeSVG = (label, textColor, xOffset = 0) => { - if (!isValidHexColor(textColor, true)) { + if (!isPrefixedHexColor(textColor)) { throw new Error(`Invalid text color: "${textColor}"`); } if (!Number.isFinite(xOffset)) { diff --git a/packages/core/src/cards/top-languages.js b/packages/core/src/cards/top-languages.js index 86c89de3fc14a..1c5089c89c78f 100644 --- a/packages/core/src/cards/top-languages.js +++ b/packages/core/src/cards/top-languages.js @@ -3,7 +3,7 @@ import { I18n } from "../common/I18n.js"; import { fallbackColor, getCardColors, - isValidHexColor, + isPrefixedHexColor, } from "../common/color.js"; import { formatBytes } from "../common/fmt.js"; import { encodeHTML } from "../common/html.js"; @@ -290,7 +290,7 @@ const createCompactLangNode = ({ const staggerDelay = (index + 3) * 150; const color = lang.color || "#858585"; - if (!isValidHexColor(color, true)) { + if (!isPrefixedHexColor(color)) { throw new Error(`Invalid language color: "${color}"`); } @@ -449,7 +449,7 @@ const renderCompactLayout = ( const compactProgressBar = langs .map((lang) => { const langColor = lang.color || DEFAULT_LANG_COLOR; - if (!isValidHexColor(langColor, true)) { + if (!isPrefixedHexColor(langColor)) { throw new Error(`Invalid language color: "${langColor}"`); } @@ -529,7 +529,7 @@ const renderDonutVerticalLayout = ( // Generate each donut vertical chart part for (const lang of langs) { const langColor = lang.color || DEFAULT_LANG_COLOR; - if (!isValidHexColor(langColor, true)) { + if (!isPrefixedHexColor(langColor)) { throw new Error(`Invalid language color: "${langColor}"`); } @@ -609,7 +609,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => { // Generate each pie chart part for (const lang of langs) { const langColor = lang.color || DEFAULT_LANG_COLOR; - if (!isValidHexColor(langColor, true)) { + if (!isPrefixedHexColor(langColor)) { throw new Error(`Invalid language color: "${langColor}"`); } @@ -751,7 +751,7 @@ const renderDonutLayout = ( const colors = langs.map((lang) => { const langColor = lang.color || DEFAULT_LANG_COLOR; - if (!isValidHexColor(langColor, true)) { + if (!isPrefixedHexColor(langColor)) { throw new Error(`Invalid language color: "${langColor}"`); } return langColor; @@ -817,7 +817,7 @@ const renderDonutLayout = ( * @returns {string} No languages data SVG node string. */ const noLanguagesDataNode = ({ color, text, layout }) => { - if (!isValidHexColor(color, true)) { + if (!isPrefixedHexColor(color)) { throw new Error(`Invalid text color: "${color}"`); } diff --git a/packages/core/src/cards/wakatime.js b/packages/core/src/cards/wakatime.js index 72bdfff69fa15..6e4e8a4ed37ce 100644 --- a/packages/core/src/cards/wakatime.js +++ b/packages/core/src/cards/wakatime.js @@ -1,6 +1,6 @@ import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.js"; -import { getCardColors, isValidHexColor } 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"; @@ -25,7 +25,7 @@ const TOTAL_TEXT_WIDTH = 275; * @returns {string} No coding activity SVG node string. */ const noCodingActivityNode = ({ color, text }) => { - if (!isValidHexColor(color, true)) { + if (!isPrefixedHexColor(color)) { throw new Error(`Invalid text color: "${color}"`); } @@ -203,7 +203,7 @@ const getStyles = function ({ titleColor, textColor, }) { - if (!isValidHexColor(textColor, true)) { + if (!isPrefixedHexColor(textColor)) { throw new Error(`Invalid text color: "${textColor}"`); } diff --git a/packages/core/src/common/Card.ts b/packages/core/src/common/Card.ts index f19d1716bd755..00aac90dfc8fa 100644 --- a/packages/core/src/common/Card.ts +++ b/packages/core/src/common/Card.ts @@ -1,4 +1,4 @@ -import { isValidGradient, isValidHexColor } from "./color.js"; +import { isPrefixedHexColor, isValidGradient } from "./color.js"; import { encodeHTML } from "./html.js"; import { flexLayout } from "./render.js"; @@ -248,13 +248,13 @@ class Card { } if ( this.colors.titleColor !== undefined && - !isValidHexColor(this.colors.titleColor, true) + !isPrefixedHexColor(this.colors.titleColor) ) { throw new Error(`Invalid title color: "${this.colors.titleColor}"`); } if ( this.colors.borderColor !== undefined && - !isValidHexColor(this.colors.borderColor, true) + !isPrefixedHexColor(this.colors.borderColor) ) { throw new Error(`Invalid border color: "${this.colors.borderColor}"`); } @@ -262,7 +262,7 @@ class Card { this.colors.bgColor !== undefined && !(typeof this.colors.bgColor === "object" ? isValidGradient(this.colors.bgColor) - : isValidHexColor(this.colors.bgColor, true)) + : isPrefixedHexColor(this.colors.bgColor)) ) { throw new Error( `Invalid background color: ${String(this.colors.bgColor)}`, diff --git a/packages/core/src/common/color.ts b/packages/core/src/common/color.ts index 43b2a19934325..ad24c1434db24 100644 --- a/packages/core/src/common/color.ts +++ b/packages/core/src/common/color.ts @@ -1,38 +1,53 @@ import { themes } from "../themes/index.js"; +/** Matches a 3-, 4-, 6-, or 8-digit hex color with no leading `#`. */ +const HEX_COLOR = + /^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{3})$/; + +/** + * Checks if a value is a bare hex color, i.e. hex digits with no `#` prefix + * (`"f00"`, `"ffffff"`). This is the form user-supplied color params and + * gradient stops arrive in. + * + * @param value Value to check. + * @returns True if the value is a bare hex color. + */ +const isBareHexColor = (value: unknown): boolean => { + return typeof value === "string" && HEX_COLOR.test(value); +}; + /** - * Checks if a string is a valid hex color. + * Checks if a value is a `#`-prefixed hex color (`"#f00"`, `"#ffffff"`). This + * is the form colors take once resolved by {@link getCardColors}, i.e. right + * before they are written into the SVG. * - * @param hexColor String to check. - * @param numberSignPrefix Whether the hex color must have a '#' prefix. - * @returns True if the given string is a valid hex color. + * @param value Value to check. + * @returns True if the value is a `#`-prefixed hex color. */ -const isValidHexColor = ( - hexColor: string, - numberSignPrefix = false, -): boolean => { - return new RegExp( - `^${numberSignPrefix ? "#" : ""}([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$`, - ).test(hexColor); +const isPrefixedHexColor = (value: unknown): boolean => { + return ( + typeof value === "string" && + value.startsWith("#") && + HEX_COLOR.test(value.slice(1)) + ); }; /** - * Check if the given string is a valid gradient. + * Checks if the given parts form a valid gradient: a finite numeric angle + * followed by at least two bare-hex color stops, e.g. `["90", "f00", "0f0"]`. + * The angle is written into the SVG `gradientTransform="rotate(...)"`. * - * @param colors Array of colors. - * @returns True if the given string is a valid gradient. + * @param parts Gradient parts: `[angle, ...stops]`. + * @returns True if the parts form a valid gradient. */ -const isValidGradient = (colors: Array): boolean => { +const isValidGradient = (parts: Array): boolean => { + const [angle, ...stops] = parts; return ( - colors.length > 2 && - colors - .slice(1) - .every( - (color) => - isValidHexColor(color) && - !isNaN(Number(colors[0])) && - colors[0]?.trim() !== "", - ) + stops.length >= 2 && + angle !== undefined && + angle.trim() !== "" && + Number.isFinite(Number(angle)) && + stops.every(isBareHexColor) ); }; @@ -46,8 +61,7 @@ const isValidColorInput = (color: string | null | undefined): boolean => { if (color === null || color === undefined) { return true; } - const colors = color.split(","); - return isValidGradient(colors) || isValidHexColor(color); + return isValidGradient(color.split(",")) || isBareHexColor(color); }; /** @@ -83,7 +97,7 @@ const fallbackColor = ( return colors; } - if (color !== undefined && isValidHexColor(color)) { + if (color !== undefined && isBareHexColor(color)) { return `#${color}`; } @@ -193,5 +207,6 @@ export { getCardColors, findInvalidColor, isValidGradient, - isValidHexColor, + isBareHexColor, + isPrefixedHexColor, }; diff --git a/packages/core/src/common/render.ts b/packages/core/src/common/render.ts index 0a15f441abedb..f1d5d2c6ef227 100644 --- a/packages/core/src/common/render.ts +++ b/packages/core/src/common/render.ts @@ -1,4 +1,4 @@ -import { getCardColors, isValidHexColor } from "./color.js"; +import { getCardColors, isPrefixedHexColor } from "./color.js"; import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js"; import { encodeHTML } from "./html.js"; import { clampValue } from "./ops.js"; @@ -52,7 +52,7 @@ const flexLayout = ({ * @returns Language display SVG object. */ const createLanguageNode = (langName: string, langColor: string): string => { - if (!isValidHexColor(langColor, true)) { + if (!isPrefixedHexColor(langColor)) { throw new Error(`Invalid language color: "${langColor}"`); } @@ -94,10 +94,10 @@ const createProgressNode = ({ progressBarBackgroundColor: string; delay: number; }): string => { - if (!isValidHexColor(color, true)) { + if (!isPrefixedHexColor(color)) { throw new Error(`Invalid progress color: "${color}"`); } - if (!isValidHexColor(progressBarBackgroundColor, true)) { + if (!isPrefixedHexColor(progressBarBackgroundColor)) { throw new Error( `Invalid progress bar background color: "${progressBarBackgroundColor}"`, ); @@ -204,7 +204,7 @@ const wrappedTextNode = ({ * @returns CSS rules block (without the surrounding selector). */ const wrappedTextStyles = (color: string): string => { - if (!isValidHexColor(color, true)) { + if (!isPrefixedHexColor(color)) { throw new Error(`Invalid text color: "${color}"`); } diff --git a/packages/core/tests/color.test.ts b/packages/core/tests/color.test.ts index 4c6e0149fa814..11e7d7d8ba96a 100644 --- a/packages/core/tests/color.test.ts +++ b/packages/core/tests/color.test.ts @@ -3,8 +3,9 @@ import { describe, expect, it } from "vitest"; import { findInvalidColor, getCardColors, + isBareHexColor, + isPrefixedHexColor, isValidGradient, - isValidHexColor, } from "../src/common/color.js"; describe("getCardColors", () => { @@ -95,23 +96,35 @@ describe("getCardColors", () => { }); }); -describe("isValidHexColor", () => { +describe("isPrefixedHexColor", () => { it("should validate hex colors with # prefix", () => { - expect(isValidHexColor("#f00", true)).toBe(true); - expect(isValidHexColor("#ffffff", true)).toBe(true); - expect(isValidHexColor("#12345678", true)).toBe(true); - expect(isValidHexColor("f00", true)).toBe(false); - expect(isValidHexColor("#red", true)).toBe(false); - expect(isValidHexColor("red", true)).toBe(false); + expect(isPrefixedHexColor("#f00")).toBe(true); + expect(isPrefixedHexColor("#ffffff")).toBe(true); + expect(isPrefixedHexColor("#12345678")).toBe(true); + expect(isPrefixedHexColor("f00")).toBe(false); + expect(isPrefixedHexColor("#red")).toBe(false); + expect(isPrefixedHexColor("red")).toBe(false); }); + it("should reject non-string values", () => { + expect(isPrefixedHexColor(null)).toBe(false); + expect(isPrefixedHexColor(undefined)).toBe(false); + }); +}); + +describe("isBareHexColor", () => { it("should validate hex colors without # prefix", () => { - expect(isValidHexColor("f00")).toBe(true); - expect(isValidHexColor("ffffff")).toBe(true); - expect(isValidHexColor("12345678")).toBe(true); - expect(isValidHexColor("#f00")).toBe(false); - expect(isValidHexColor("#red")).toBe(false); - expect(isValidHexColor("red")).toBe(false); + expect(isBareHexColor("f00")).toBe(true); + expect(isBareHexColor("ffffff")).toBe(true); + expect(isBareHexColor("12345678")).toBe(true); + expect(isBareHexColor("#f00")).toBe(false); + expect(isBareHexColor("#red")).toBe(false); + expect(isBareHexColor("red")).toBe(false); + }); + + it("should reject non-string values", () => { + expect(isBareHexColor(null)).toBe(false); + expect(isBareHexColor(undefined)).toBe(false); }); }); @@ -129,6 +142,9 @@ describe("isValidGradient", () => { expect(isValidGradient(["90"])).toBe(false); expect(isValidGradient(["", "f00", "0f0"])).toBe(false); // empty angle expect(isValidGradient(["90", "f00", "red"])).toBe(false); // invalid color + expect(isValidGradient(["Infinity", "f00", "0f0"])).toBe(false); // non-finite angle + expect(isValidGradient(["-Infinity", "f00", "0f0"])).toBe(false); // non-finite angle + expect(isValidGradient(["abc", "f00", "0f0"])).toBe(false); // non-numeric angle }); }); From 6ad168da58ef3e63fbad57df14fbfc10e7892063 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:43:58 +0200 Subject: [PATCH 3/3] remove 'fix-warnings' from branch list --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d65fd3858928..db9a95007c650 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,6 @@ on: pull_request: branches: - master - - fix-warnings concurrency: group: "${{ github.workflow }}-${{ github.head_ref }}"