From fef8e90c86e8ea539af4578aeb6169516a210adc Mon Sep 17 00:00:00 2001
From: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com>
Date: Fri, 3 Jul 2026 16:26:23 +0200
Subject: [PATCH 01/26] feat(core): migrate common/ render, ops, http,
calculateRank to TypeScript (#322)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Second wave of the `packages/core` TypeScript migration (#140),
continuing #292.
Converts the remaining `common/` modules and their tests from JSDoc
`.js` to real `.ts`.
## Changes
- **Modules ➡️ `.ts`**: `common/http`, `common/ops`, `common/render`,
`calculateRank`.
- **Tests ➡️ `.ts`**: `ops`, `flexLayout`, `render`, `calculateRank`.
- **`src/_emoji-name-map.d.ts`**: `declare module` shim.
`emoji-name-map` ships no types
and has no `@types` package (same as the existing
`@uppercod/css-to-object` shim).
- **`common/color.ts`**: widened `getCardColors` optional params to `?:
string | undefined`
so `renderError` can forward possibly-`undefined` colors under
`exactOptionalPropertyTypes`.
---
packages/core/src/_emoji-name-map.d.ts | 10 +
.../{calculateRank.js => calculateRank.ts} | 48 ++--
packages/core/src/common/color.ts | 14 +-
packages/core/src/common/http.js | 19 --
packages/core/src/common/http.ts | 31 +++
packages/core/src/common/{ops.js => ops.ts} | 98 ++++-----
.../core/src/common/{render.js => render.ts} | 206 +++++++++++-------
...lateRank.test.js => calculateRank.test.ts} | 0
...{flexLayout.test.js => flexLayout.test.ts} | 0
.../core/tests/{ops.test.js => ops.test.ts} | 8 +-
.../tests/{render.test.js => render.test.ts} | 0
11 files changed, 257 insertions(+), 177 deletions(-)
create mode 100644 packages/core/src/_emoji-name-map.d.ts
rename packages/core/src/{calculateRank.js => calculateRank.ts} (58%)
delete mode 100644 packages/core/src/common/http.js
create mode 100644 packages/core/src/common/http.ts
rename packages/core/src/common/{ops.js => ops.ts} (54%)
rename packages/core/src/common/{render.js => render.ts} (70%)
rename packages/core/tests/{calculateRank.test.js => calculateRank.test.ts} (100%)
rename packages/core/tests/{flexLayout.test.js => flexLayout.test.ts} (100%)
rename packages/core/tests/{ops.test.js => ops.test.ts} (95%)
rename packages/core/tests/{render.test.js => render.test.ts} (100%)
diff --git a/packages/core/src/_emoji-name-map.d.ts b/packages/core/src/_emoji-name-map.d.ts
new file mode 100644
index 0000000000000..a22d709a0c074
--- /dev/null
+++ b/packages/core/src/_emoji-name-map.d.ts
@@ -0,0 +1,10 @@
+/**
+ * `emoji-name-map` ships no type definitions and exposes no `exports` map, so it
+ * cannot be resolved under `nodenext`. Declare the minimal surface used here.
+ */
+declare module "emoji-name-map" {
+ const emojiNameMap: {
+ get(name: string): string | undefined;
+ };
+ export default emojiNameMap;
+}
diff --git a/packages/core/src/calculateRank.js b/packages/core/src/calculateRank.ts
similarity index 58%
rename from packages/core/src/calculateRank.js
rename to packages/core/src/calculateRank.ts
index 377b3fbe11597..586752c314737 100644
--- a/packages/core/src/calculateRank.js
+++ b/packages/core/src/calculateRank.ts
@@ -1,20 +1,20 @@
/**
* Calculates the exponential cdf.
*
- * @param {number} x The value.
- * @returns {number} The exponential cdf.
+ * @param x The value.
+ * @returns The exponential cdf.
*/
-function exponential_cdf(x) {
+function exponential_cdf(x: number): number {
return 1 - 2 ** -x;
}
/**
* Calculates the log normal cdf.
*
- * @param {number} x The value.
- * @returns {number} The log normal cdf.
+ * @param x The value.
+ * @returns The log normal cdf.
*/
-function log_normal_cdf(x) {
+function log_normal_cdf(x: number): number {
// approximation
return x / (1 + x);
}
@@ -22,16 +22,16 @@ function log_normal_cdf(x) {
/**
* Calculates the users rank.
*
- * @param {object} params Parameters on which the user's rank depends.
- * @param {boolean} params.all_commits Whether `include_all_commits` was used.
- * @param {number} params.commits Number of commits.
- * @param {number} params.prs The number of pull requests.
- * @param {number} params.issues The number of issues.
- * @param {number} params.reviews The number of reviews.
- * @param {number} params.repos Total number of repos.
- * @param {number} params.stars The number of stars.
- * @param {number} params.followers The number of followers.
- * @returns {{ level: string, percentile: number }} The users rank.
+ * @param params Parameters on which the user's rank depends.
+ * @param params.all_commits Whether `include_all_commits` was used.
+ * @param params.commits Number of commits.
+ * @param params.prs The number of pull requests.
+ * @param params.issues The number of issues.
+ * @param params.reviews The number of reviews.
+ * @param params.repos Total number of repos (accepted for compatibility, unused in the calculation).
+ * @param params.stars The number of stars.
+ * @param params.followers The number of followers.
+ * @returns The users rank.
*/
function calculateRank({
all_commits,
@@ -39,11 +39,18 @@ function calculateRank({
prs,
issues,
reviews,
- // eslint-disable-next-line no-unused-vars
- repos, // unused
stars,
followers,
-}) {
+}: {
+ all_commits: boolean;
+ commits: number;
+ prs: number;
+ issues: number;
+ reviews: number;
+ repos: number;
+ stars: number;
+ followers: number;
+}): { level: string; percentile: number } {
const COMMITS_MEDIAN = all_commits ? 1000 : 250,
COMMITS_WEIGHT = 2;
const PRS_MEDIAN = 50,
@@ -79,6 +86,9 @@ function calculateRank({
TOTAL_WEIGHT;
const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)];
+ if (level === undefined) {
+ throw new Error("Unable to determine rank level");
+ }
return { level, percentile: rank * 100 };
}
diff --git a/packages/core/src/common/color.ts b/packages/core/src/common/color.ts
index 2e9de30545e01..2ad524a5daf75 100644
--- a/packages/core/src/common/color.ts
+++ b/packages/core/src/common/color.ts
@@ -82,13 +82,13 @@ const getCardColors = ({
ring_color,
theme,
}: {
- title_color?: string;
- text_color?: string;
- icon_color?: string;
- bg_color?: string;
- border_color?: string;
- ring_color?: string;
- theme?: string;
+ title_color?: string | undefined;
+ text_color?: string | undefined;
+ icon_color?: string | undefined;
+ bg_color?: string | undefined;
+ border_color?: string | undefined;
+ ring_color?: string | undefined;
+ theme?: string | undefined;
}): CardColors => {
const defaultTheme = themes.default;
const isThemeProvided = theme !== undefined && theme in themes;
diff --git a/packages/core/src/common/http.js b/packages/core/src/common/http.js
deleted file mode 100644
index 388d105289906..0000000000000
--- a/packages/core/src/common/http.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import axios from "axios";
-
-/**
- * Send GraphQL request to GitHub API.
- *
- * @param {import('axios').AxiosRequestConfig['data']} data Request data.
- * @param {import('axios').AxiosRequestConfig['headers']} headers Request headers.
- * @returns {Promise} Request response.
- */
-const request = (data, headers) => {
- return axios({
- url: "https://api.github.com/graphql",
- method: "post",
- headers,
- data,
- });
-};
-
-export { request };
diff --git a/packages/core/src/common/http.ts b/packages/core/src/common/http.ts
new file mode 100644
index 0000000000000..4c03701d8380c
--- /dev/null
+++ b/packages/core/src/common/http.ts
@@ -0,0 +1,31 @@
+import axios from "axios";
+import type { AxiosRequestConfig, AxiosResponse } from "axios";
+
+/** Body of a GraphQL request sent to the GitHub API. */
+interface GraphQLRequest {
+ /** The GraphQL query. */
+ query: string;
+ /** Variables referenced by the query. */
+ variables: Record;
+}
+
+/**
+ * Send GraphQL request to GitHub API.
+ *
+ * @param data Request data.
+ * @param headers Request headers.
+ * @returns Request response.
+ */
+const request = (
+ data: GraphQLRequest,
+ headers: NonNullable,
+): Promise => {
+ return axios({
+ url: "https://api.github.com/graphql",
+ method: "post",
+ headers,
+ data,
+ });
+};
+
+export { request };
diff --git a/packages/core/src/common/ops.js b/packages/core/src/common/ops.ts
similarity index 54%
rename from packages/core/src/common/ops.js
rename to packages/core/src/common/ops.ts
index 04df76eed4092..2331e9ec704c6 100644
--- a/packages/core/src/common/ops.js
+++ b/packages/core/src/common/ops.ts
@@ -6,10 +6,10 @@ import { CustomError } from "./error.js";
/**
* Returns boolean if value is either "true" or "false" else the value as it is.
*
- * @param {string | boolean} value The value to parse.
- * @returns {boolean | undefined } The parsed value.
+ * @param value The value to parse.
+ * @returns The parsed value.
*/
-const parseBoolean = (value) => {
+const parseBoolean = (value: string | boolean): boolean | undefined => {
if (typeof value === "boolean") {
return value;
}
@@ -27,10 +27,10 @@ const parseBoolean = (value) => {
/**
* Parse string to array of strings.
*
- * @param {string} str The string to parse.
- * @returns {string[]} The array of strings.
+ * @param str The string to parse.
+ * @returns The array of strings.
*/
-const parseArray = (str) => {
+const parseArray = (str: string): Array => {
if (!str) {
return [];
}
@@ -40,47 +40,44 @@ const parseArray = (str) => {
/**
* Clamp the given number between the given range.
*
- * @param {number} number The number to clamp.
- * @param {number} min The minimum value.
- * @param {number} max The maximum value.
- * @returns {number} The clamped number.
+ * @param number The number to clamp.
+ * @param min The minimum value.
+ * @param max The maximum value.
+ * @returns The clamped number.
*/
-const clampValue = (number, min, max) => {
- // @ts-ignore
- if (Number.isNaN(parseInt(number, 10))) {
+const clampValue = (
+ number: string | number,
+ min: number,
+ max: number,
+): number => {
+ if (Number.isNaN(parseInt(String(number), 10))) {
return min;
}
- return Math.max(min, Math.min(number, max));
+ return Math.max(min, Math.min(Number(number), max));
};
/**
* Lowercase and trim string.
*
- * @param {string} name String to lowercase and trim.
- * @returns {string} Lowercased and trimmed string.
+ * @param name String to lowercase and trim.
+ * @returns Lowercased and trimmed string.
*/
-const lowercaseTrim = (name) => name.toLowerCase().trim();
+const lowercaseTrim = (name: string): string => name.toLowerCase().trim();
/**
* Split array of languages in two columns.
*
* @template T Language object.
- * @param {Array} arr Array of languages.
- * @param {number} perChunk Number of languages per column.
- * @returns {Array} Array of languages split in two columns.
+ * @param arr Array of languages.
+ * @param perChunk Number of languages per column.
+ * @returns Array of languages split in two columns.
*/
-const chunkArray = (arr, perChunk) => {
- return arr.reduce((resultArray, item, index) => {
+const chunkArray = (arr: Array, perChunk: number): Array> => {
+ return arr.reduce>>((resultArray, item, index) => {
const chunkIndex = Math.floor(index / perChunk);
-
- if (!resultArray[chunkIndex]) {
- // @ts-ignore
- resultArray[chunkIndex] = []; // start a new chunk
- }
-
- // @ts-ignore
- resultArray[chunkIndex].push(item);
-
+ const chunk = resultArray[chunkIndex] ?? [];
+ chunk.push(item);
+ resultArray[chunkIndex] = chunk;
return resultArray;
}, []);
};
@@ -88,10 +85,10 @@ const chunkArray = (arr, perChunk) => {
/**
* Parse emoji from string.
*
- * @param {string} str String to parse emoji from.
- * @returns {string} String with emoji parsed.
+ * @param str String to parse emoji from.
+ * @returns String with emoji parsed.
*/
-const parseEmojis = (str) => {
+const parseEmojis = (str: string): string => {
if (!str) {
throw new Error("[parseEmoji]: str argument not provided");
}
@@ -103,11 +100,11 @@ const parseEmojis = (str) => {
/**
* Get diff in minutes between two dates.
*
- * @param {Date} d1 First date.
- * @param {Date} d2 Second date.
- * @returns {number} Number of minutes between the two dates.
+ * @param d1 First date.
+ * @param d2 Second date.
+ * @returns Number of minutes between the two dates.
*/
-const dateDiff = (d1, d2) => {
+const dateDiff = (d1: Date, d2: Date): number => {
const date1 = new Date(d1);
const date2 = new Date(d2);
const diff = date1.getTime() - date2.getTime();
@@ -117,40 +114,41 @@ const dateDiff = (d1, d2) => {
/**
* Parse owner affiliations.
*
- * @param {string[]} affiliations input affiliations to be parsed.
- * @returns {string[]} Parsed affiliations.
+ * @param affiliations input affiliations to be parsed.
+ * @returns Parsed affiliations.
*
* @throws {CustomError} If affiliations contains invalid values.
*/
-const parseOwnerAffiliations = (affiliations) => {
+const parseOwnerAffiliations = (affiliations: Array): Array => {
// Set default value for ownerAffiliations.
// NOTE: Done here since parseArray() will always return an empty array even nothing
//was specified.
- affiliations =
- affiliations && affiliations.length > 0
+ const normalized =
+ affiliations.length > 0
? affiliations.map((affiliation) => affiliation.toUpperCase())
: ["OWNER"];
// Check if ownerAffiliations contains valid values.
if (
- affiliations.some(
- (affiliation) => !OWNER_AFFILIATIONS.includes(affiliation),
- )
+ normalized.some((affiliation) => !OWNER_AFFILIATIONS.includes(affiliation))
) {
throw new CustomError(
"Invalid query parameter",
CustomError.INVALID_AFFILIATION,
);
}
- return affiliations;
+ return normalized;
};
-const buildSearchFilter = (repos = [], owners = []) => {
- let repoFilter =
+const buildSearchFilter = (
+ repos: Array | string = [],
+ owners: Array | string = [],
+): string => {
+ const repoFilter =
Array.isArray(repos) && repos.length > 0
? repos.map((r) => `repo:${r} `).join("")
: "";
- let orgFilter =
+ const orgFilter =
Array.isArray(owners) && owners.length > 0
? owners.map((o) => `owner:${o} `).join("")
: "";
diff --git a/packages/core/src/common/render.js b/packages/core/src/common/render.ts
similarity index 70%
rename from packages/core/src/common/render.js
rename to packages/core/src/common/render.ts
index 24dd8c624e2aa..f4a5517022c76 100644
--- a/packages/core/src/common/render.js
+++ b/packages/core/src/common/render.ts
@@ -7,14 +7,24 @@ import { clampValue } from "./ops.js";
* Auto layout utility, allows us to layout things vertically or horizontally with
* proper gaping.
*
- * @param {object} props Function properties.
- * @param {string[]} props.items Array of items to layout.
- * @param {number} props.gap Gap between items.
- * @param {"column" | "row"=} props.direction Direction to layout items.
- * @param {number[]=} props.sizes Array of sizes for each item.
- * @returns {string[]} Array of items with proper layout.
+ * @param props Function properties.
+ * @param props.items Array of items to layout.
+ * @param props.gap Gap between items.
+ * @param props.direction Direction to layout items.
+ * @param props.sizes Array of sizes for each item.
+ * @returns Array of items with proper layout.
*/
-const flexLayout = ({ items, gap, direction, sizes = [] }) => {
+const flexLayout = ({
+ items,
+ gap,
+ direction,
+ sizes = [],
+}: {
+ items: Array;
+ gap: number;
+ direction?: "column" | "row";
+ sizes?: Array;
+}): Array => {
let lastSize = 0;
// filter() for filtering out empty strings
return items.filter(Boolean).map((item, i) => {
@@ -31,11 +41,11 @@ const flexLayout = ({ items, gap, direction, sizes = [] }) => {
/**
* Creates a node to display the primary programming language of the repository/gist.
*
- * @param {string} langName Language name.
- * @param {string} langColor Language color.
- * @returns {string} Language display SVG object.
+ * @param langName Language name.
+ * @param langColor Language color.
+ * @returns Language display SVG object.
*/
-const createLanguageNode = (langName, langColor) => {
+const createLanguageNode = (langName: string, langColor: string): string => {
return `
@@ -47,15 +57,15 @@ const createLanguageNode = (langName, langColor) => {
/**
* Create a node to indicate progress in percentage along a horizontal line.
*
- * @param {Object} params Object that contains the createProgressNode parameters.
- * @param {number} params.x X-axis position.
- * @param {number} params.y Y-axis position.
- * @param {number} params.width Width of progress bar.
- * @param {string} params.color Progress color.
- * @param {number} params.progress Progress value.
- * @param {string} params.progressBarBackgroundColor Progress bar bg color.
- * @param {number} params.delay Delay before animation starts.
- * @returns {string} Progress node.
+ * @param params Object that contains the createProgressNode parameters.
+ * @param params.x X-axis position.
+ * @param params.y Y-axis position.
+ * @param params.width Width of progress bar.
+ * @param params.color Progress color.
+ * @param params.progress Progress value.
+ * @param params.progressBarBackgroundColor Progress bar bg color.
+ * @param params.delay Delay before animation starts.
+ * @returns Progress node.
*/
const createProgressNode = ({
x,
@@ -65,7 +75,15 @@ const createProgressNode = ({
progress,
progressBarBackgroundColor,
delay,
-}) => {
+}: {
+ x: number;
+ y: number;
+ width: number;
+ color: string;
+ progress: number;
+ progressBarBackgroundColor: string;
+ delay: number;
+}): string => {
const progressPercentage = clampValue(progress, 2, 100);
return `
@@ -89,16 +107,16 @@ const createProgressNode = ({
* native, font-aware wrapping. Content overflowing `lineCount` lines is
* clipped (with an ellipsis on the last visible line) by CSS line-clamp.
*
- * @param {object} props Function properties.
- * @param {string} props.text Text to render (will be HTML-encoded).
- * @param {number} props.x X position of the foreignObject.
- * @param {number} props.y Y position of the foreignObject.
- * @param {number} props.width Width of the wrap box.
- * @param {number} props.height Height of the wrap box.
- * @param {number} props.lineCount Maximum number of lines to display.
- * @param {string} props.className CSS class applied to the inner element.
- * @param {string=} props.testId Optional test id for the inner element.
- * @returns {string} foreignObject SVG node.
+ * @param props Function properties.
+ * @param props.text Text to render (will be HTML-encoded).
+ * @param props.x X position of the foreignObject.
+ * @param props.y Y position of the foreignObject.
+ * @param props.width Width of the wrap box.
+ * @param props.height Height of the wrap box.
+ * @param props.lineCount Maximum number of lines to display.
+ * @param props.className CSS class applied to the inner element.
+ * @param props.testId Optional test id for the inner element.
+ * @returns foreignObject SVG node.
*/
const wrappedTextNode = ({
text,
@@ -109,7 +127,16 @@ const wrappedTextNode = ({
lineCount,
className,
testId,
-}) => {
+}: {
+ text: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ lineCount: number;
+ className: string;
+ testId?: string;
+}): string => {
const testIdAttr = testId ? ` data-testid="${testId}"` : "";
return `
@@ -126,10 +153,10 @@ const wrappedTextNode = ({
* browser handles wrapping and the line count is taken from the `--lines`
* custom property set on the element.
*
- * @param {string} color Text color (CSS `color` property).
- * @returns {string} CSS rules block (without the surrounding selector).
+ * @param color Text color (CSS `color` property).
+ * @returns CSS rules block (without the surrounding selector).
*/
-const wrappedTextStyles = (color) => `
+const wrappedTextStyles = (color: string): string => `
color: ${color};
margin: 0;
line-height: 1.2;
@@ -147,13 +174,18 @@ const wrappedTextStyles = (color) => `
/**
* Creates an icon with label to display repository/gist stats like forks, stars, etc.
*
- * @param {string} icon The icon to display.
- * @param {number|string} label The label to display.
- * @param {string} testid The testid to assign to the label.
- * @param {number} iconSize The size of the icon.
- * @returns {string} Icon with label SVG object.
+ * @param icon The icon to display.
+ * @param label The label to display.
+ * @param testid The testid to assign to the label.
+ * @param iconSize The size of the icon.
+ * @returns Icon with label SVG object.
*/
-const iconWithLabel = (icon, label, testid, iconSize) => {
+const iconWithLabel = (
+ icon: string,
+ label: number | string,
+ testid: string,
+ iconSize: number,
+): string => {
if (typeof label === "number" && label <= 0) {
return "";
}
@@ -184,23 +216,34 @@ const UPSTREAM_API_ERRORS = [
/**
* Renders error message on the card.
*
- * @param {object} args Function arguments.
- * @param {string} args.message Main error message.
- * @param {string} [args.secondaryMessage=""] The secondary error message.
- * @param {object} [args.renderOptions={}] Render options.
- * @param {string=} args.renderOptions.title_color Card title color.
- * @param {string=} args.renderOptions.text_color Card text color.
- * @param {string=} args.renderOptions.bg_color Card background color.
- * @param {string=} args.renderOptions.border_color Card border color.
- * @param {Parameters[0]["theme"]=} args.renderOptions.theme Card theme.
- * @param {boolean=} args.renderOptions.show_repo_link Whether to show repo link or not.
- * @returns {string} The SVG markup.
+ * @param args Function arguments.
+ * @param args.message Main error message.
+ * @param args.secondaryMessage The secondary error message.
+ * @param args.renderOptions Render options.
+ * @param args.renderOptions.title_color Card title color.
+ * @param args.renderOptions.text_color Card text color.
+ * @param args.renderOptions.bg_color Card background color.
+ * @param args.renderOptions.border_color Card border color.
+ * @param args.renderOptions.theme Card theme.
+ * @param args.renderOptions.show_repo_link Whether to show repo link or not.
+ * @returns The SVG markup.
*/
const renderError = ({
message,
secondaryMessage = "",
renderOptions = {},
-}) => {
+}: {
+ message: string;
+ secondaryMessage?: string;
+ renderOptions?: {
+ title_color?: string;
+ text_color?: string;
+ bg_color?: string;
+ border_color?: string;
+ theme?: string;
+ show_repo_link?: boolean;
+ };
+}): string => {
const {
title_color,
text_color,
@@ -222,7 +265,7 @@ const renderError = ({
});
return `
-
) : (
- [
- "",
- "You will be able to customize your card in future steps.",
- "",
- "",
- "Display the finished card on GitHub, Twitter/X, LinkedIn, or anywhere else!",
- ][stage]
+ STAGE_LABELS[stage].description
)}
@@ -309,42 +242,8 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element {
{stage === 2 && (
diff --git a/apps/frontend/src/pages/Home/buildCardUrl.test.ts b/apps/frontend/src/pages/Home/buildCardUrl.test.ts
index 2f36b4ad0c979..350eecd12c848 100644
--- a/apps/frontend/src/pages/Home/buildCardUrl.test.ts
+++ b/apps/frontend/src/pages/Home/buildCardUrl.test.ts
@@ -6,11 +6,14 @@ import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home
import { CardType } from "../../models/CardType";
import { buildCardUrl } from "./buildCardUrl";
+import type { CardOptions } from "./cardOptions";
-const baseOptions = {
- selectedCard: CardType.STATS,
+const USER_ID = "john-github";
+
+// Built inline instead of via `getDefaultCardOptions`, which imports
+// `constants.ts` and needs a `window` global these node-based tests lack.
+const baseOptions: CardOptions = {
selectedUserId: "john",
- userId: "john-github",
repo: "repo1",
gist: "gist1",
wakatimeUser: "wakaUser",
@@ -32,13 +35,13 @@ const baseOptions = {
describe("buildCardUrl", () => {
it("builds stats suffix with defaults", () => {
- const result = buildCardUrl(baseOptions);
+ const result = buildCardUrl(USER_ID, CardType.STATS, baseOptions);
expect(result.toString()).toBe("?username=john");
});
it("adds stats options", () => {
- const result = buildCardUrl({
+ const result = buildCardUrl(USER_ID, CardType.STATS, {
...baseOptions,
showIcons: true,
includeAllCommits: true,
@@ -57,9 +60,8 @@ describe("buildCardUrl", () => {
});
it("builds top-langs suffix", () => {
- const result = buildCardUrl({
+ const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, {
...baseOptions,
- selectedCard: CardType.TOP_LANGS,
langsCount: 5,
showTitle: false,
});
@@ -70,9 +72,8 @@ describe("buildCardUrl", () => {
});
it("builds pin suffix using userId not selectedUserId", () => {
- const result = buildCardUrl({
+ const result = buildCardUrl(USER_ID, CardType.PIN, {
...baseOptions,
- selectedCard: CardType.PIN,
showOwner: true,
descriptionLines: 3,
});
@@ -83,9 +84,8 @@ describe("buildCardUrl", () => {
});
it("builds gist suffix", () => {
- const result = buildCardUrl({
+ const result = buildCardUrl(USER_ID, CardType.GIST, {
...baseOptions,
- selectedCard: CardType.GIST,
showOwner: true,
});
@@ -93,9 +93,8 @@ describe("buildCardUrl", () => {
});
it("builds wakatime suffix with percent and custom title", () => {
- const result = buildCardUrl({
+ const result = buildCardUrl(USER_ID, CardType.WAKATIME, {
...baseOptions,
- selectedCard: CardType.WAKATIME,
wakatimeUser: "waka",
usePercent: true,
customTitle: "My Stats",
@@ -108,9 +107,8 @@ describe("buildCardUrl", () => {
});
it("adds non-default layouts", () => {
- const result = buildCardUrl({
+ const result = buildCardUrl(USER_ID, CardType.TOP_LANGS, {
...baseOptions,
- selectedCard: CardType.TOP_LANGS,
selectedLanguagesLayout: { id: 2, value: "compact", label: "Compact" },
});
diff --git a/apps/frontend/src/pages/Home/buildCardUrl.ts b/apps/frontend/src/pages/Home/buildCardUrl.ts
index b129930c71547..abab84e067010 100644
--- a/apps/frontend/src/pages/Home/buildCardUrl.ts
+++ b/apps/frontend/src/pages/Home/buildCardUrl.ts
@@ -1,4 +1,3 @@
-import type { SelectOption } from "../../components/Generic/Select";
import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
@@ -6,51 +5,34 @@ import { CardType } from "../../models/CardType";
import { cardUrl } from "../../models/CardUrl";
import type { CardUrlBuilder } from "../../models/CardUrl";
-interface Options {
- userId: string;
- selectedUserId: string;
- selectedCard: CardType;
- repo: string;
- gist: string;
- wakatimeUser: string;
- selectedStatsRank: SelectOption;
- selectedLanguagesLayout: SelectOption;
- selectedWakatimeLayout: SelectOption;
- showTitle: boolean;
- showOwner: boolean;
- descriptionLines: number | undefined;
- customTitle: string;
- langsCount: number | undefined;
- hideValues: boolean;
- showAllStats: boolean;
- showIcons: boolean;
- includeAllCommits: boolean;
- enableAnimations: boolean;
- usePercent: boolean;
-}
+import type { CardOptions } from "./cardOptions";
+
+export function buildCardUrl(
+ userId: string,
+ selectedCard: CardType,
+ options: CardOptions,
+): CardUrlBuilder {
+ const {
+ selectedUserId,
+ repo,
+ gist,
+ wakatimeUser,
+ selectedStatsRank,
+ selectedLanguagesLayout,
+ selectedWakatimeLayout,
+ showTitle,
+ showOwner,
+ descriptionLines,
+ customTitle,
+ langsCount,
+ hideValues,
+ showAllStats,
+ showIcons,
+ includeAllCommits,
+ enableAnimations,
+ usePercent,
+ } = options;
-export function buildCardUrl({
- userId,
- selectedCard,
- selectedUserId,
- repo,
- gist,
- wakatimeUser,
- selectedStatsRank,
- selectedLanguagesLayout,
- selectedWakatimeLayout,
- showTitle,
- showOwner,
- descriptionLines,
- customTitle,
- langsCount,
- hideValues,
- showAllStats,
- showIcons,
- includeAllCommits,
- enableAnimations,
- usePercent,
-}: Options): CardUrlBuilder {
switch (selectedCard) {
case CardType.STATS: {
let url = cardUrl(CardType.STATS);
diff --git a/apps/frontend/src/pages/Home/cardOptions.ts b/apps/frontend/src/pages/Home/cardOptions.ts
new file mode 100644
index 0000000000000..7e9a3650a60e1
--- /dev/null
+++ b/apps/frontend/src/pages/Home/cardOptions.ts
@@ -0,0 +1,53 @@
+import type { SelectOption } from "../../components/Generic/Select";
+import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../../components/Home/LanguagesLayoutSection";
+import { DEFAULT_OPTION as STATS_DEFAULT_RANK } from "../../components/Home/StatsRankSection";
+import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../../components/Home/WakatimeLayoutSection";
+import { DEMO_GIST, DEMO_REPO, DEMO_WAKATIME_USER } from "../../constants";
+
+/**
+ * All user-tunable card parameters collected during the customize stage.
+ * Held as a single state object in `HomeScreen` and updated one key at a time.
+ */
+export interface CardOptions {
+ selectedUserId: string;
+ repo: string;
+ gist: string;
+ wakatimeUser: string;
+ selectedStatsRank: SelectOption;
+ selectedLanguagesLayout: SelectOption;
+ selectedWakatimeLayout: SelectOption;
+ showTitle: boolean;
+ showOwner: boolean;
+ descriptionLines: number | undefined;
+ customTitle: string;
+ langsCount: number | undefined;
+ hideValues: boolean;
+ showAllStats: boolean;
+ showIcons: boolean;
+ includeAllCommits: boolean;
+ enableAnimations: boolean;
+ usePercent: boolean;
+}
+
+export function getDefaultCardOptions(userId: string): CardOptions {
+ return {
+ selectedUserId: userId,
+ repo: DEMO_REPO,
+ gist: DEMO_GIST,
+ wakatimeUser: DEMO_WAKATIME_USER,
+ selectedStatsRank: STATS_DEFAULT_RANK,
+ selectedLanguagesLayout: LANGUAGES_DEFAULT_LAYOUT,
+ selectedWakatimeLayout: WAKATIME_DEFAULT_LAYOUT,
+ showTitle: true,
+ showOwner: false,
+ descriptionLines: undefined,
+ customTitle: "",
+ langsCount: undefined,
+ hideValues: false,
+ showAllStats: false,
+ showIcons: false,
+ includeAllCommits: true,
+ enableAnimations: true,
+ usePercent: false,
+ };
+}
diff --git a/apps/frontend/src/pages/Home/stages/Customize.tsx b/apps/frontend/src/pages/Home/stages/Customize.tsx
index 414231c0dae2d..50c828ec55d04 100644
--- a/apps/frontend/src/pages/Home/stages/Customize.tsx
+++ b/apps/frontend/src/pages/Home/stages/Customize.tsx
@@ -1,7 +1,6 @@
-import type { JSX, default as React } from "react";
+import type { JSX } from "react";
import { CardImage } from "../../../components/Card/CardImage";
-import type { SelectOption } from "../../../components/Generic/Select";
import { CheckboxSection } from "../../../components/Home/CheckboxSection";
import { LanguagesLayoutSection } from "../../../components/Home/LanguagesLayoutSection";
import { NumericSection } from "../../../components/Home/NumericSection";
@@ -18,96 +17,66 @@ import { CardType } from "../../../models/CardType";
import type { CardUrlBuilder } from "../../../models/CardUrl";
import type { StageIndex } from "../../../models/Stage";
import { useIsAuthenticated } from "../../../redux/selectors/userSelectors";
+import type { CardOptions } from "../cardOptions";
-type Updater = React.Dispatch>;
+/**
+ * Extract the trailing path segments from pasted text, so pasting a full
+ * GitHub URL yields just the username (1 segment), owner/repo (2), or Gist id (1).
+ */
+function pastedPathTail(text: string, segments: number): string {
+ let value = text;
+ if (value.endsWith("/")) {
+ value = value.slice(0, -1);
+ }
+ const parts = value.split("/");
+ if (parts.length > segments) {
+ value = parts.slice(-segments).join("/");
+ }
+ return value;
+}
-/** @todo todo consider using React context API to avoid prop drilling */
interface CustomizeStageProps {
selectedCard: CardType;
- selectedStatsRank: SelectOption;
- setSelectedStatsRank: Updater;
- selectedLanguagesLayout: SelectOption;
- setSelectedLanguagesLayout: Updater;
- selectedWakatimeLayout: SelectOption;
- setSelectedWakatimeLayout: Updater;
- selectedUserId: string;
- setSelectedUserId: Updater;
- repo: string;
- setRepo: Updater;
- gist: string;
- setGist: Updater;
- wakatimeUser: string;
- setWakatimeUser: Updater;
- showTitle: boolean;
- setShowTitle: Updater;
- descriptionLines: number | undefined;
- setDescriptionLines: Updater;
- showOwner: boolean;
- setShowOwner: Updater;
- customTitle: string;
- setCustomTitle: Updater;
- langsCount: number | undefined;
- setLangsCount: Updater;
- hideValues: boolean;
- setHideValues: Updater;
- showIcons: boolean;
- setShowIcons: Updater;
- showAllStats: boolean;
- setShowAllStats: Updater;
- includeAllCommits: boolean;
- setIncludeAllCommits: Updater;
- enableAnimations: boolean;
- setEnableAnimations: Updater;
- usePercent: boolean;
- setUsePercent: Updater;
+ options: CardOptions;
+ onOptionChange: (
+ key: K,
+ value: CardOptions[K],
+ ) => void;
card: CardUrlBuilder;
setStage: (stageIndex: StageIndex) => void;
}
export function CustomizeStage({
selectedCard,
- selectedStatsRank,
- setSelectedStatsRank,
- selectedLanguagesLayout,
- setSelectedLanguagesLayout,
- selectedWakatimeLayout,
- setSelectedWakatimeLayout,
- selectedUserId,
- setSelectedUserId,
- repo,
- setRepo,
- gist,
- setGist,
- wakatimeUser,
- setWakatimeUser,
- showTitle,
- setShowTitle,
- showOwner,
- setShowOwner,
- descriptionLines,
- setDescriptionLines,
- customTitle,
- setCustomTitle,
- langsCount,
- setLangsCount,
- hideValues,
- setHideValues,
- showIcons,
- setShowIcons,
- showAllStats,
- setShowAllStats,
- includeAllCommits,
- setIncludeAllCommits,
- enableAnimations,
- setEnableAnimations,
- usePercent,
- setUsePercent,
+ options,
+ onOptionChange,
card,
setStage,
}: CustomizeStageProps): JSX.Element {
const cardType = selectedCard;
const isAuthenticated = useIsAuthenticated();
+ const {
+ selectedUserId,
+ repo,
+ gist,
+ wakatimeUser,
+ selectedStatsRank,
+ selectedLanguagesLayout,
+ selectedWakatimeLayout,
+ showTitle,
+ showOwner,
+ descriptionLines,
+ customTitle,
+ langsCount,
+ hideValues,
+ showAllStats,
+ showIcons,
+ includeAllCommits,
+ enableAnimations,
+ usePercent,
+ } = options;
+
return (
@@ -138,19 +107,16 @@ export function CustomizeStage({
}
placeholder={`e.g. "${DEMO_USER}"`}
value={selectedUserId}
- onValueChange={setSelectedUserId}
+ onValueChange={(value) => {
+ onOptionChange("selectedUserId", value);
+ }}
onPaste={(e) => {
e.preventDefault();
- let newValue = e.clipboardData.getData("text");
// if the user pasted a full GitHub URL, extract username
- if (newValue.endsWith("/")) {
- newValue = newValue.slice(0, -1);
- }
- const parts = newValue.split("/");
- if (parts.length > 1) {
- newValue = parts.slice(-1).join("/");
- }
- setSelectedUserId(newValue);
+ onOptionChange(
+ "selectedUserId",
+ pastedPathTail(e.clipboardData.getData("text"), 1),
+ );
}}
disabled={!isAuthenticated}
/>
@@ -182,19 +148,16 @@ export function CustomizeStage({
}
placeholder={`e.g. "${DEMO_REPO}"`}
value={repo}
- onValueChange={setRepo}
+ onValueChange={(value) => {
+ onOptionChange("repo", value);
+ }}
onPaste={(e) => {
e.preventDefault();
- let newValue = e.clipboardData.getData("text");
// if the user pasted a full GitHub URL, extract owner/repo
- if (newValue.endsWith("/")) {
- newValue = newValue.slice(0, -1);
- }
- const parts = newValue.split("/");
- if (parts.length > 2) {
- newValue = parts.slice(-2).join("/");
- }
- setRepo(newValue);
+ onOptionChange(
+ "repo",
+ pastedPathTail(e.clipboardData.getData("text"), 2),
+ );
}}
disabled={!isAuthenticated}
/>
@@ -226,19 +189,16 @@ export function CustomizeStage({
}
placeholder={`e.g. "${DEMO_GIST}"`}
value={gist}
- onValueChange={setGist}
+ onValueChange={(value) => {
+ onOptionChange("gist", value);
+ }}
onPaste={(e) => {
e.preventDefault();
- let newValue = e.clipboardData.getData("text");
// if the user pasted a full GitHub URL, extract Gist ID
- if (newValue.endsWith("/")) {
- newValue = newValue.slice(0, -1);
- }
- const parts = newValue.split("/");
- if (parts.length > 1) {
- newValue = parts.slice(-1).join("/");
- }
- setGist(newValue);
+ onOptionChange(
+ "gist",
+ pastedPathTail(e.clipboardData.getData("text"), 1),
+ );
}}
disabled={!isAuthenticated}
/>
@@ -261,7 +221,9 @@ export function CustomizeStage({
}
placeholder={`e.g. "${DEMO_WAKATIME_USER}"`}
value={wakatimeUser}
- onValueChange={setWakatimeUser}
+ onValueChange={(value) => {
+ onOptionChange("wakatimeUser", value);
+ }}
/>
)}
{cardType === CardType.STATS && (
@@ -270,13 +232,17 @@ export function CustomizeStage({
text="Show all available statistics."
question="Show all stats?"
checked={showAllStats}
- onCheckedChange={setShowAllStats}
+ onCheckedChange={(checked) => {
+ onOptionChange("showAllStats", checked);
+ }}
/>
)}
{cardType === CardType.STATS && (
{
+ onOptionChange("selectedStatsRank", option);
+ }}
/>
)}
{cardType === CardType.STATS && (
@@ -285,7 +251,9 @@ export function CustomizeStage({
text="Show icons next to all stats."
question="Show icons?"
checked={showIcons}
- onCheckedChange={setShowIcons}
+ onCheckedChange={(checked) => {
+ onOptionChange("showIcons", checked);
+ }}
/>
)}
{cardType === CardType.STATS && (
@@ -294,19 +262,25 @@ export function CustomizeStage({
text="Count total commits or just commits of the last 365 days."
question="Include all commits?"
checked={includeAllCommits}
- onCheckedChange={setIncludeAllCommits}
+ onCheckedChange={(checked) => {
+ onOptionChange("includeAllCommits", checked);
+ }}
/>
)}
{cardType === CardType.TOP_LANGS && (
{
+ onOptionChange("selectedLanguagesLayout", option);
+ }}
/>
)}
{cardType === CardType.WAKATIME && (
{
+ onOptionChange("selectedWakatimeLayout", option);
+ }}
/>
)}
{(cardType === CardType.TOP_LANGS ||
@@ -321,7 +295,9 @@ export function CustomizeStage({
>
}
value={langsCount}
- onValueChange={setLangsCount}
+ onValueChange={(value) => {
+ onOptionChange("langsCount", value);
+ }}
min={1}
max={20}
/>
@@ -332,7 +308,9 @@ export function CustomizeStage({
text="Hide language percentages or bytes while keeping the selected layout visible."
question="Hide values?"
checked={hideValues}
- onCheckedChange={setHideValues}
+ onCheckedChange={(checked) => {
+ onOptionChange("hideValues", checked);
+ }}
/>
)}
{cardType === CardType.WAKATIME && (
@@ -341,7 +319,9 @@ export function CustomizeStage({
text="Show time spent in hours or percentages."
question="Show percentages?"
checked={usePercent}
- onCheckedChange={setUsePercent}
+ onCheckedChange={(checked) => {
+ onOptionChange("usePercent", checked);
+ }}
/>
)}
{(cardType === CardType.STATS ||
@@ -352,7 +332,9 @@ export function CustomizeStage({
text="Shows a title at the top of the card."
question="Show title?"
checked={showTitle}
- onCheckedChange={setShowTitle}
+ onCheckedChange={(checked) => {
+ onOptionChange("showTitle", checked);
+ }}
/>
)}
{(cardType === CardType.STATS || cardType === CardType.WAKATIME) && (
@@ -367,7 +349,9 @@ export function CustomizeStage({
}
placeholder='e.g. "My GitHub Stats"'
value={customTitle}
- onValueChange={setCustomTitle}
+ onValueChange={(value) => {
+ onOptionChange("customTitle", value);
+ }}
/>
)}
{(cardType === CardType.STATS ||
@@ -378,7 +362,9 @@ export function CustomizeStage({
// text="Enable Animations."
question="enable animations?"
checked={enableAnimations}
- onCheckedChange={setEnableAnimations}
+ onCheckedChange={(checked) => {
+ onOptionChange("enableAnimations", checked);
+ }}
/>
)}
{(cardType === CardType.PIN || cardType === CardType.GIST) && (
@@ -387,7 +373,9 @@ export function CustomizeStage({
text="Shows the repo owner's name next to the repo name."
question="Show owner?"
checked={showOwner}
- onCheckedChange={setShowOwner}
+ onCheckedChange={(checked) => {
+ onOptionChange("showOwner", checked);
+ }}
/>
)}
{cardType === CardType.PIN && (
@@ -402,7 +390,9 @@ export function CustomizeStage({
>
}
value={descriptionLines}
- onValueChange={setDescriptionLines}
+ onValueChange={(value) => {
+ onOptionChange("descriptionLines", value);
+ }}
min={1}
max={3}
/>
From 4ccec4604b7a0f067e7f2ab03cd77ba86105f269 Mon Sep 17 00:00:00 2001
From: Martin <2026226+martin-mfg@users.noreply.github.com>
Date: Sun, 12 Jul 2026 19:51:53 +0200
Subject: [PATCH 14/26] fix warnings (#342)
Co-authored-by: Marco Pasqualetti
---
.github/workflows/repeat-recent-requests.yml | 2 +
packages/core/src/api/gist.js | 18 +
packages/core/src/api/index.js | 19 +
packages/core/src/api/pin.js | 18 +
packages/core/src/api/top-langs.js | 18 +
packages/core/src/api/wakatime.js | 18 +
packages/core/src/cards/repo.js | 50 ++-
packages/core/src/cards/stats.js | 30 +-
packages/core/src/cards/top-languages.js | 60 ++-
packages/core/src/cards/wakatime.js | 37 +-
packages/core/src/common/Card.ts | 67 ++-
packages/core/src/common/color.ts | 93 +++-
packages/core/src/common/html.ts | 2 +-
packages/core/src/common/render.ts | 104 ++++-
packages/core/src/fetchers/repo.js | 2 +-
packages/core/tests/color.test.ts | 122 +++++-
packages/core/tests/html.test.ts | 10 +-
packages/core/tests/render.test.ts | 27 ++
packages/core/tests/renderGistCard.test.js | 23 +-
packages/core/tests/renderRepoCard.test.js | 18 +-
packages/core/tests/renderStatsCard.test.js | 17 +-
.../core/tests/renderTopLanguagesCard.test.js | 39 +-
.../core/tests/renderWakatimeCard.test.js | 17 +-
packages/core/tests/xss.test.js | 403 ++++++++++++++++++
24 files changed, 1094 insertions(+), 120 deletions(-)
create mode 100644 packages/core/tests/xss.test.js
diff --git a/.github/workflows/repeat-recent-requests.yml b/.github/workflows/repeat-recent-requests.yml
index 39220a8c91bcd..536199b728b70 100644
--- a/.github/workflows/repeat-recent-requests.yml
+++ b/.github/workflows/repeat-recent-requests.yml
@@ -13,6 +13,8 @@ on:
- cron: "15,45 * * * *"
workflow_dispatch:
+permissions: {}
+
jobs:
triggerRepeatRecent:
if: |
diff --git a/packages/core/src/api/gist.js b/packages/core/src/api/gist.js
index ab35c654bfaeb..67505392b76f7 100644
--- a/packages/core/src/api/gist.js
+++ b/packages/core/src/api/gist.js
@@ -1,4 +1,5 @@
import { renderGistCard } from "../cards/gist.js";
+import { findInvalidColor } from "../common/color.js";
import {
MissingParamError,
retrieveSecondaryMessage,
@@ -26,6 +27,23 @@ export default async (
},
pat = null,
) => {
+ const invalidColorInput = findInvalidColor({
+ title_color,
+ icon_color,
+ text_color,
+ bg_color,
+ border_color,
+ });
+ if (invalidColorInput) {
+ return {
+ status: "error - permanent",
+ content: renderError({
+ message: "Something went wrong",
+ secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
+ }),
+ };
+ }
+
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
diff --git a/packages/core/src/api/index.js b/packages/core/src/api/index.js
index 4751560082eab..87f6bfd9056cf 100644
--- a/packages/core/src/api/index.js
+++ b/packages/core/src/api/index.js
@@ -1,4 +1,5 @@
import { renderStatsCard } from "../cards/stats.js";
+import { findInvalidColor } from "../common/color.js";
import {
MissingParamError,
retrieveSecondaryMessage,
@@ -44,6 +45,24 @@ export default async (
},
pat = null,
) => {
+ const invalidColorInput = findInvalidColor({
+ title_color,
+ ring_color,
+ icon_color,
+ text_color,
+ bg_color,
+ border_color,
+ });
+ if (invalidColorInput) {
+ return {
+ status: "error - permanent",
+ content: renderError({
+ message: "Something went wrong",
+ secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
+ }),
+ };
+ }
+
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
diff --git a/packages/core/src/api/pin.js b/packages/core/src/api/pin.js
index 118aa70cc2204..fe59500dfe228 100644
--- a/packages/core/src/api/pin.js
+++ b/packages/core/src/api/pin.js
@@ -1,4 +1,5 @@
import { renderRepoCard } from "../cards/repo.js";
+import { findInvalidColor } from "../common/color.js";
import {
MissingParamError,
retrieveSecondaryMessage,
@@ -34,6 +35,23 @@ export default async (
},
pat = null,
) => {
+ const invalidColorInput = findInvalidColor({
+ title_color,
+ icon_color,
+ text_color,
+ bg_color,
+ border_color,
+ });
+ if (invalidColorInput) {
+ return {
+ status: "error - permanent",
+ content: renderError({
+ message: "Something went wrong",
+ secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
+ }),
+ };
+ }
+
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
diff --git a/packages/core/src/api/top-langs.js b/packages/core/src/api/top-langs.js
index 2ebc6218718df..46e106eeb6cac 100644
--- a/packages/core/src/api/top-langs.js
+++ b/packages/core/src/api/top-langs.js
@@ -1,4 +1,5 @@
import { renderTopLanguages } from "../cards/top-languages.js";
+import { findInvalidColor } from "../common/color.js";
import {
MissingParamError,
retrieveSecondaryMessage,
@@ -38,6 +39,23 @@ export default async (
},
pat = null,
) => {
+ const invalidColorInput = findInvalidColor({
+ title_color,
+ text_color,
+ bg_color,
+ prog_bar_bg_color,
+ border_color,
+ });
+ if (invalidColorInput) {
+ return {
+ status: "error - permanent",
+ content: renderError({
+ message: "Something went wrong",
+ secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
+ }),
+ };
+ }
+
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
diff --git a/packages/core/src/api/wakatime.js b/packages/core/src/api/wakatime.js
index d9333f49a926c..7d03c58e49813 100644
--- a/packages/core/src/api/wakatime.js
+++ b/packages/core/src/api/wakatime.js
@@ -1,4 +1,5 @@
import { renderWakatimeCard } from "../cards/wakatime.js";
+import { findInvalidColor } from "../common/color.js";
import {
MissingParamError,
retrieveSecondaryMessage,
@@ -32,6 +33,23 @@ export default async ({
display_format,
disable_animations,
}) => {
+ const invalidColorInput = findInvalidColor({
+ title_color,
+ icon_color,
+ text_color,
+ bg_color,
+ border_color,
+ });
+ if (invalidColorInput) {
+ return {
+ status: "error - permanent",
+ content: renderError({
+ message: "Something went wrong",
+ secondaryMessage: `Invalid color input for parameter "${invalidColorInput}"`,
+ }),
+ };
+ }
+
if (locale && !isLocaleAvailable(locale)) {
return {
status: "error - permanent",
diff --git a/packages/core/src/cards/repo.js b/packages/core/src/cards/repo.js
index a352f357c1ea5..4150967d0284a 100644
--- a/packages/core/src/cards/repo.js
+++ b/packages/core/src/cards/repo.js
@@ -1,6 +1,6 @@
import { Card } from "../common/Card.js";
import { I18n } from "../common/I18n.js";
-import { getCardColors } from "../common/color.js";
+import { getCardColors, isPrefixedHexColor } from "../common/color.js";
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
import { encodeHTML } from "../common/html.js";
import { icons } from "../common/icons.js";
@@ -32,20 +32,29 @@ const DESCRIPTION_MAX_LINES = 3;
* @param {string} textColor The color of the text.
* @returns {string} Wrapped repo description SVG object.
*/
-const getBadgeSVG = (label, textColor, xOffset = 0) => `
-
-
-
- ${label}
-
-
-`;
+const getBadgeSVG = (label, textColor, xOffset = 0) => {
+ if (!isPrefixedHexColor(textColor)) {
+ throw new Error(`Invalid text color: "${textColor}"`);
+ }
+ if (!Number.isFinite(xOffset)) {
+ throw new Error(`Invalid xOffset: "${xOffset}"`);
+ }
+
+ return `
+
+
+
+ ${encodeHTML(label)}
+
+
+ `;
+};
/**
* @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data.
@@ -110,6 +119,7 @@ const renderRepoCard = (repo, options = {}) => {
});
let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], []));
+ const encodedUsername = encodeURIComponent(username);
const STATS = {};
if (show.includes("prs_authored")) {
STATS.prs_authored = {
@@ -117,7 +127,7 @@ const renderRepoCard = (repo, options = {}) => {
label: i18n.t("repocard.prs-authored"),
value: totalPRsAuthored,
id: "prs_authored",
- link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`,
+ link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&type=pullrequests`,
};
}
if (show.includes("prs_commented")) {
@@ -126,7 +136,7 @@ const renderRepoCard = (repo, options = {}) => {
label: i18n.t("repocard.prs-commented"),
value: totalPRsCommented,
id: "prs_commented",
- link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`,
+ link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&type=pullrequests`,
};
}
if (show.includes("prs_reviewed")) {
@@ -135,7 +145,7 @@ const renderRepoCard = (repo, options = {}) => {
label: i18n.t("repocard.prs-reviewed"),
value: totalPRsReviewed,
id: "prs_reviewed",
- link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`,
+ link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${encodedUsername}+-author%3A${encodedUsername}&type=pullrequests`,
};
}
if (show.includes("issues_authored")) {
@@ -144,7 +154,7 @@ const renderRepoCard = (repo, options = {}) => {
label: i18n.t("repocard.issues-authored"),
value: totalIssuesAuthored,
id: "issues_authored",
- link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`,
+ link: `https://github.com/search?q=${repoFilter}author%3A${encodedUsername}&type=issues`,
};
}
if (show.includes("issues_commented")) {
@@ -153,7 +163,7 @@ const renderRepoCard = (repo, options = {}) => {
label: i18n.t("repocard.issues-commented"),
value: totalIssuesCommented,
id: "issues_commented",
- link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`,
+ link: `https://github.com/search?q=${repoFilter}commenter%3A${encodedUsername}+-author%3A${encodedUsername}&type=issues`,
};
}
diff --git a/packages/core/src/cards/stats.js b/packages/core/src/cards/stats.js
index cbaf2b61f3e43..cd65180eed967 100644
--- a/packages/core/src/cards/stats.js
+++ b/packages/core/src/cards/stats.js
@@ -3,6 +3,7 @@ import { I18n } from "../common/I18n.js";
import { getCardColors } from "../common/color.js";
import { CustomError } from "../common/error.js";
import { kFormatter } from "../common/fmt.js";
+import { encodeHTML } from "../common/html.js";
import { icons, rankIcon } from "../common/icons.js";
import { buildSearchFilter, clampValue } from "../common/ops.js";
import { flexLayout, measureText } from "../common/render.js";
@@ -52,8 +53,10 @@ const LONG_LOCALES = [
/**
* Create a stats card text item.
*
+ * The caller must ensure that the passed `icon` and `link` are properly sanitized!
+ *
* @param {object} params Object that contains the createTextNode parameters.
- * @param {string} params.icon The icon to display.
+ * @param {string} params.icon The sanitized icon to display.
* @param {string} params.label The label to display.
* @param {number} params.value The value to display.
* @param {string} params.id The id of the stat.
@@ -64,7 +67,7 @@ const LONG_LOCALES = [
* @param {boolean} params.bold Whether to bold the label.
* @param {string} params.numberFormat The format of numbers on card.
* @param {number=} params.numberPrecision The precision of numbers on card.
- * @param {string} params.link Url to link to.
+ * @param {string} params.link Sanitized url to link to.
* @param {number} params.labelXOffset horizontal offset for label.
* @returns {string} The stats card text item SVG object.
*/
@@ -83,6 +86,16 @@ const createTextNode = ({
link,
labelXOffset = 25,
}) => {
+ if (!Number.isFinite(labelXOffset)) {
+ throw new Error(`Invalid labelXOffset: "${labelXOffset}"`);
+ }
+ if (!Number.isFinite(shiftValuePos)) {
+ throw new Error(`Invalid shiftValuePos: "${shiftValuePos}"`);
+ }
+ if (!Number.isFinite(index)) {
+ throw new Error(`Invalid index: "${index}"`);
+ }
+
const precision =
typeof numberPrecision === "number" && !isNaN(numberPrecision)
? clampValue(numberPrecision, 0, 2)
@@ -109,7 +122,7 @@ const createTextNode = ({
${iconSvg}
${label}:
+ }" ${labelOffset} y="12.5">${encodeHTML(label)}:
- ${name}
- ${hideValues ? "" : `${displayValue}`}
+ ${encodeHTML(name)}
+ ${hideValues ? "" : `${encodeHTML(displayValue)}`}
${createProgressNode({
x: 0,
y: 25,
@@ -285,11 +290,15 @@ const createCompactLangNode = ({
const staggerDelay = (index + 3) * 150;
const color = lang.color || "#858585";
+ if (!isPrefixedHexColor(color)) {
+ throw new Error(`Invalid language color: "${color}"`);
+ }
+
return `
- ${lang.name} ${hideProgress || hideValues ? "" : displayValue}
+ ${encodeHTML(lang.name)} ${hideProgress || hideValues ? "" : encodeHTML(displayValue)}
`;
@@ -439,6 +448,11 @@ const renderCompactLayout = (
let progressOffset = 0;
const compactProgressBar = langs
.map((lang) => {
+ const langColor = lang.color || DEFAULT_LANG_COLOR;
+ if (!isPrefixedHexColor(langColor)) {
+ throw new Error(`Invalid language color: "${langColor}"`);
+ }
+
const percentage = parseFloat(
((lang.size / totalLanguageSize) * offsetWidth).toFixed(2),
);
@@ -453,7 +467,7 @@ const renderCompactLayout = (
y="0"
width="${progress}"
height="8"
- fill="${lang.color || "#858585"}"
+ fill="${langColor}"
/>
`;
progressOffset += percentage;
@@ -514,18 +528,23 @@ const renderDonutVerticalLayout = (
// Generate each donut vertical chart part
for (const lang of langs) {
+ const langColor = lang.color || DEFAULT_LANG_COLOR;
+ if (!isPrefixedHexColor(langColor)) {
+ throw new Error(`Invalid language color: "${langColor}"`);
+ }
+
const percentage = (lang.size / totalLanguageSize) * 100;
const circleLength = totalCircleLength * (percentage / 100);
const delay = startDelayCoefficient * 100;
circles.push(`
- {
// Generate each pie chart part
for (const lang of langs) {
+ const langColor = lang.color || DEFAULT_LANG_COLOR;
+ if (!isPrefixedHexColor(langColor)) {
+ throw new Error(`Invalid language color: "${langColor}"`);
+ }
+
if (langs.length === 1) {
paths.push(`
{
cy="${centerY}"
r="${radius}"
stroke="none"
- fill="${lang.color}"
+ fill="${langColor}"
data-testid="lang-pie"
size="100"
/>
@@ -629,7 +653,7 @@ const renderPieLayout = (langs, totalLanguageSize, statsFormat, hideValues) => {
data-testid="lang-pie"
size="${percentage}"
d="M ${centerX} ${centerY} L ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y} Z"
- fill="${lang.color}"
+ fill="${langColor}"
/>
`);
@@ -716,12 +740,22 @@ const renderDonutLayout = (
statsFormat,
hideValues,
) => {
+ if (!Number.isFinite(width)) {
+ throw new Error(`Invalid width: "${width}"`);
+ }
+
const centerX = width / 3;
const centerY = width / 3;
const radius = centerX - 60;
const strokeWidth = 12;
- const colors = langs.map((lang) => lang.color);
+ const colors = langs.map((lang) => {
+ const langColor = lang.color || DEFAULT_LANG_COLOR;
+ if (!isPrefixedHexColor(langColor)) {
+ throw new Error(`Invalid language color: "${langColor}"`);
+ }
+ return langColor;
+ });
const langsPercents = langs.map((lang) =>
parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
);
@@ -783,10 +817,14 @@ const renderDonutLayout = (
* @returns {string} No languages data SVG node string.
*/
const noLanguagesDataNode = ({ color, text, layout }) => {
+ if (!isPrefixedHexColor(color)) {
+ throw new Error(`Invalid text color: "${color}"`);
+ }
+
return `
${text}
+ }" y="11" class="stat bold" fill="${color}">${encodeHTML(text)}
`;
};
diff --git a/packages/core/src/cards/wakatime.js b/packages/core/src/cards/wakatime.js
index a717e753587fc..6e4e8a4ed37ce 100644
--- a/packages/core/src/cards/wakatime.js
+++ b/packages/core/src/cards/wakatime.js
@@ -1,6 +1,7 @@
import { Card } from "../common/Card.js";
import { I18n } from "../common/I18n.js";
-import { getCardColors } from "../common/color.js";
+import { getCardColors, isPrefixedHexColor } from "../common/color.js";
+import { encodeHTML } from "../common/html.js";
import languageColors from "../common/languageColors.json" with { type: "json" };
import { clampValue, lowercaseTrim } from "../common/ops.js";
import { createProgressNode, flexLayout } from "../common/render.js";
@@ -24,8 +25,12 @@ const TOTAL_TEXT_WIDTH = 275;
* @returns {string} No coding activity SVG node string.
*/
const noCodingActivityNode = ({ color, text }) => {
+ if (!isPrefixedHexColor(color)) {
+ throw new Error(`Invalid text color: "${color}"`);
+ }
+
return `
- ${text}
+ ${encodeHTML(text)}
`;
};
@@ -58,6 +63,13 @@ const formatLanguageValue = ({ display_format, lang }) => {
* @returns {string} The compact layout language SVG node.
*/
const createCompactLangNode = ({ lang, x, y, display_format }) => {
+ if (!Number.isFinite(x)) {
+ throw new Error(`Invalid x: "${x}"`);
+ }
+ if (!Number.isFinite(y)) {
+ throw new Error(`Invalid y: "${y}"`);
+ }
+
// @ts-ignore
const color = languageColors[lang.name] || "#858585";
const value = formatLanguageValue({ display_format, lang });
@@ -66,7 +78,7 @@ const createCompactLangNode = ({ lang, x, y, display_format }) => {
- ${lang.name} - ${value}
+ ${encodeHTML(lang.name)} - ${encodeHTML(value)}
`;
@@ -125,6 +137,13 @@ const createTextNode = ({
progressBarBackgroundColor,
progressBarWidth,
}) => {
+ if (!Number.isFinite(index)) {
+ throw new Error(`Invalid index: "${index}"`);
+ }
+ if (!Number.isFinite(progressBarWidth)) {
+ throw new Error(`Invalid progressBarWidth: "${progressBarWidth}"`);
+ }
+
const staggerDelay = (index + 3) * 150;
const cardProgress = hideProgress
? null
@@ -142,12 +161,12 @@ const createTextNode = ({
return `
- ${label}:
+ ${encodeHTML(label)}:
${value}
+ >${encodeHTML(value)}
${cardProgress}
`;
@@ -179,11 +198,15 @@ const recalculatePercentages = (languages) => {
* @param {string} colors.textColor The text color.
* @returns {string} Card CSS styles.
*/
-const getStyles = ({
+const getStyles = function ({
// eslint-disable-next-line no-unused-vars
titleColor,
textColor,
-}) => {
+}) {
+ if (!isPrefixedHexColor(textColor)) {
+ throw new Error(`Invalid text color: "${textColor}"`);
+ }
+
return `
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
diff --git a/packages/core/src/common/Card.ts b/packages/core/src/common/Card.ts
index 7ae629f83c8cf..00aac90dfc8fa 100644
--- a/packages/core/src/common/Card.ts
+++ b/packages/core/src/common/Card.ts
@@ -1,3 +1,4 @@
+import { isPrefixedHexColor, isValidGradient } from "./color.js";
import { encodeHTML } from "./html.js";
import { flexLayout } from "./render.js";
@@ -33,6 +34,8 @@ class Card {
/**
* Creates a new card instance.
*
+ * The caller must ensure that the passed `titlePrefixIcon` is properly sanitized!
+ *
* @param props Card arguments.
* @param props.width Card width.
* @param props.height Card height.
@@ -40,7 +43,7 @@ class Card {
* @param props.colors Card colors arguments.
* @param props.customTitle Card custom title.
* @param props.defaultTitle Card default title.
- * @param props.titlePrefixIcon Card title prefix icon.
+ * @param props.titlePrefixIcon Sanitized card title prefix icon.
*/
constructor({
width = 100,
@@ -65,13 +68,11 @@ class Card {
this.hideBorder = false;
this.hideTitle = false;
- this.border_radius = border_radius;
+ this.border_radius = parseFloat(String(border_radius));
// returns theme based colors with proper overrides and defaults
this.colors = colors;
- this.title = encodeHTML(
- customTitle === undefined ? defaultTitle : customTitle,
- );
+ this.title = customTitle === undefined ? defaultTitle : customTitle;
this.css = "";
@@ -104,7 +105,9 @@ class Card {
}
/**
- * @param value The CSS to add to the card.
+ * The caller must ensure that the passed `css` string is properly sanitized!
+ *
+ * @param value The sanitized CSS to add to the card.
*/
setCSS(value: string): void {
this.css = value;
@@ -121,10 +124,13 @@ class Card {
* @param value Whether to hide the title or not.
*/
setHideTitle(value: boolean): void {
- this.hideTitle = value;
- if (value) {
+ if (value && !this.hideTitle) {
this.height -= 30;
}
+ if (!value && this.hideTitle) {
+ this.height += 30;
+ }
+ this.hideTitle = value;
}
/**
@@ -144,7 +150,7 @@ class Card {
y="0"
class="header"
data-testid="header"
- >${this.title}
+ >${encodeHTML(this.title)}
`;
const prefixIcon = `
@@ -180,10 +186,12 @@ class Card {
if (typeof this.colors.bgColor !== "object") {
return "";
}
+ if (!isValidGradient(this.colors.bgColor)) {
+ throw new Error(`Invalid gradient: ${this.colors.bgColor.join(",")}`);
+ }
const gradients = this.colors.bgColor.slice(1);
- return typeof this.colors.bgColor === "object"
- ? `
+ return `
- `
- : "";
+ `;
}
/**
@@ -230,10 +237,38 @@ class Card {
};
/**
- * @param body The inner body of the card.
+ * The caller must ensure that the passed `body` string is properly sanitized!
+ *
+ * @param body The sanitized inner body of the card.
* @returns The rendered card.
*/
render(body: string): string {
+ if (!Number.isFinite(this.border_radius)) {
+ throw new Error(`Invalid border radius: "${this.border_radius}"`);
+ }
+ if (
+ this.colors.titleColor !== undefined &&
+ !isPrefixedHexColor(this.colors.titleColor)
+ ) {
+ throw new Error(`Invalid title color: "${this.colors.titleColor}"`);
+ }
+ if (
+ this.colors.borderColor !== undefined &&
+ !isPrefixedHexColor(this.colors.borderColor)
+ ) {
+ throw new Error(`Invalid border color: "${this.colors.borderColor}"`);
+ }
+ if (
+ this.colors.bgColor !== undefined &&
+ !(typeof this.colors.bgColor === "object"
+ ? isValidGradient(this.colors.bgColor)
+ : isPrefixedHexColor(this.colors.bgColor))
+ ) {
+ throw new Error(
+ `Invalid background color: ${String(this.colors.bgColor)}`,
+ );
+ }
+
return `
- ${this.a11yTitle}
- ${this.a11yDesc}
+ ${encodeHTML(this.a11yTitle)}
+ ${encodeHTML(this.a11yDesc)}
+
+
+
+
+ ,,
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Other:
+ 19 mins
+
+
+
+
+
+
+
+
+
+
+
+ TypeScript:
+ 1 min
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "
+`;
+
exports[`Test Render WakaTime Card > should render correctly with percent display format 1`] = `
"
{
});
expect(card).toMatchSnapshot();
});
+
+ it("should render correctly with gradient background", () => {
+ const card = renderWakatimeCard(wakaTimeData.data, {
+ theme: "ambient_gradient",
+ });
+ expect(card).toMatchSnapshot();
+ });
});
describe("test wakatime API", () => {
From caccc4d69aca243ae90fe049482bd83ebea839f0 Mon Sep 17 00:00:00 2001
From: Martin <2026226+martin-mfg@users.noreply.github.com>
Date: Mon, 27 Jul 2026 19:20:46 +0200
Subject: [PATCH 26/26] bump core version to 2.1.5 (#407)
---
packages/core/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/core/package.json b/packages/core/package.json
index ff0be7f668789..3459fa3a67e92 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@stats-organization/github-readme-stats-core",
- "version": "2.1.4",
+ "version": "2.1.5",
"type": "module",
"homepage": "https://github-stats-extended.vercel.app/frontend",
"bugs": {