Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/cards/repo.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down
36 changes: 20 additions & 16 deletions packages/core/src/cards/top-languages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}"`);
}

Expand Down Expand Up @@ -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 (!isPrefixedHexColor(langColor)) {
throw new Error(`Invalid language color: "${langColor}"`);
}

const percentage = parseFloat(
Expand All @@ -466,7 +467,7 @@ const renderCompactLayout = (
y="0"
width="${progress}"
height="8"
fill="${lang.color || "#858585"}"
fill="${langColor}"
/>
`;
progressOffset += percentage;
Expand Down Expand Up @@ -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 (!isPrefixedHexColor(langColor)) {
throw new Error(`Invalid language color: "${langColor}"`);
}

const percentage = (lang.size / totalLanguageSize) * 100;
Expand All @@ -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}"
Expand Down Expand Up @@ -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 (!isPrefixedHexColor(langColor)) {
throw new Error(`Invalid language color: "${langColor}"`);
}

if (langs.length === 1) {
Expand All @@ -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"
/>
Expand Down Expand Up @@ -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}"
/>
</g>
`);
Expand Down Expand Up @@ -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 (!isPrefixedHexColor(langColor)) {
throw new Error(`Invalid language color: "${langColor}"`);
}
return lang.color;
return langColor;
});
const langsPercents = langs.map((lang) =>
parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
Expand Down Expand Up @@ -813,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}"`);
}

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/cards/wakatime.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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}"`);
}

Expand Down Expand Up @@ -203,7 +203,7 @@ const getStyles = function ({
titleColor,
textColor,
}) {
if (!isValidHexColor(textColor, true)) {
if (!isPrefixedHexColor(textColor)) {
throw new Error(`Invalid text color: "${textColor}"`);
}

Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/common/Card.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -248,21 +248,21 @@ 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}"`);
}
if (
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)}`,
Expand Down
71 changes: 43 additions & 28 deletions packages/core/src/common/color.ts
Original file line number Diff line number Diff line change
@@ -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<string>): boolean => {
const isValidGradient = (parts: Array<string>): 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)
);
};

Expand All @@ -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);
};

/**
Expand Down Expand Up @@ -83,7 +97,7 @@ const fallbackColor = (
return colors;
}

if (color !== undefined && isValidHexColor(color)) {
if (color !== undefined && isBareHexColor(color)) {
return `#${color}`;
}

Expand Down Expand Up @@ -193,5 +207,6 @@ export {
getCardColors,
findInvalidColor,
isValidGradient,
isValidHexColor,
isBareHexColor,
isPrefixedHexColor,
};
10 changes: 5 additions & 5 deletions packages/core/src/common/render.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}"`);
}

Expand Down Expand Up @@ -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}"`,
);
Expand Down Expand Up @@ -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}"`);
}

Expand Down
44 changes: 30 additions & 14 deletions packages/core/tests/color.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { describe, expect, it } from "vitest";
import {
findInvalidColor,
getCardColors,
isBareHexColor,
isPrefixedHexColor,
isValidGradient,
isValidHexColor,
} from "../src/common/color.js";

describe("getCardColors", () => {
Expand Down Expand Up @@ -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);
});
});

Expand All @@ -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
});
});

Expand Down
Loading