diff --git a/apps/frontend/src/content/docs/docs/customization/common-options.md b/apps/frontend/src/content/docs/docs/customization/common-options.md
index b912daa33b4a2..e4d95e4638e89 100644
--- a/apps/frontend/src/content/docs/docs/customization/common-options.md
+++ b/apps/frontend/src/content/docs/docs/customization/common-options.md
@@ -4,18 +4,18 @@ title: Common Options
Every card accepts the options below, on top of the exclusive options listed on its own page.
-| Name | Description | Type | Default value |
-| -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------- |
-| `title_color`1 | Card's title color. | string (hex color) | `2f80ed` |
-| `text_color`1 | Body text color. | string (hex color) | `434d58` |
-| `icon_color`1 | Icons color if available. | string (hex color) | `4c71f2` |
-| `border_color`1 | Card's border color. Does not apply when `hide_border` is enabled. | string (hex color) | `e4e2e2` |
-| `bg_color`1 | Card's background color. | string (hex color or a gradient in the form of _angle,start,end_) | `fffefe` |
-| `hide_border` | Hides the card's border. | boolean | `false` |
-| `theme`1 | Name of the theme, choose from [all available themes](/frontend/docs/customization/themes/). | enum | `default` |
-| `cache_seconds` | Sets the cache header manually (min: 21600, max: 86400). | integer | `21600` |
-| `locale` | Sets the language in the card, you can check full list of available locales [here](/frontend/docs/customization/locales/). | enum | `en` |
-| `border_radius` | Corner rounding on the card. | number | `4.5` |
+| Name | Description | Type | Default value |
+| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------- |
+| `title_color`1 | Card's title color. | string (hex color) | `2f80ed` |
+| `text_color`1 | Body text color. | string (hex color) | `434d58` |
+| `icon_color`1 | Icons color if available. | string (hex color) | `4c71f2` |
+| `border_color`1 | Card's border color. Does not apply when `hide_border` is enabled. | string (hex color) | `e4e2e2` |
+| `bg_color`1 | Card's background color. | string (hex color or a gradient in the form of _angle,start,end_) | `fffefe` |
+| `hide_border` | Hides the card's border. | boolean | `false` |
+| `theme`1 | Name of the theme, choose from [all available themes](/frontend/docs/customization/themes/). | enum | `default` |
+| `cache_seconds` | Sets the cache header manually (min: 21600, max: 86400). | integer | `21600` |
+| `locale` | Sets the language in the card, you can check full list of available locales [here](/frontend/docs/customization/locales/). The [gist card](/frontend/docs/cards/gist-pin/) has no translated text, so it ignores this option. | enum | `en` |
+| `border_radius` | Corner rounding on the card. | number | `4.5` |
1: Supports light and dark mode via `*_light` / `*_dark` variants (e.g. `title_color_light`). See [Light & Dark Mode Parameters](/frontend/docs/customization/theming/#light--dark-mode-parameters) for details.
diff --git a/packages/core/src/api/api-result.ts b/packages/core/src/api/api-result.ts
new file mode 100644
index 0000000000000..6808d1673bad0
--- /dev/null
+++ b/packages/core/src/api/api-result.ts
@@ -0,0 +1,5 @@
+/** What every api handler returns: a rendered card, or a rendered error. */
+export interface ApiResult {
+ status: "success" | "error - permanent" | "error - temporary";
+ content: string;
+}
diff --git a/packages/core/src/api/gist.js b/packages/core/src/api/gist.ts
similarity index 62%
rename from packages/core/src/api/gist.js
rename to packages/core/src/api/gist.ts
index e367b68f441d6..aaa616f229089 100644
--- a/packages/core/src/api/gist.js
+++ b/packages/core/src/api/gist.ts
@@ -1,4 +1,5 @@
import { renderGistCard } from "../cards/gist.js";
+import type { ColorParams } from "../common/color.js";
import { findInvalidColorParam, pickColorParams } from "../common/color.js";
import {
MissingParamError,
@@ -7,21 +8,44 @@ import {
import { parseBoolean } from "../common/ops.js";
import { renderError } from "../common/render.js";
import { fetchGist } from "../fetchers/gist.js";
-import { isLocaleAvailable } from "../translations.js";
-// @ts-ignore
+import type { ApiResult } from "./api-result.js";
+
+/** Query params the gist endpoint accepts, on top of the shared color params. */
+interface GistApiQuery extends ColorParams {
+ id?: string;
+ border_radius?: string;
+ show_owner?: string;
+ browser_rendering?: string;
+ hide_border?: string;
+}
+
+/** Characters a gist ID may contain. */
+const SAFE_PATTERN = /^[-\w/.,]+$/;
+
+/**
+ * Render the gist card for a set of query params.
+ *
+ * @param query Raw query params.
+ * @param query.id GitHub gist ID.
+ * @param query.border_radius Card border radius.
+ * @param query.show_owner Whether to show the gist owner.
+ * @param query.browser_rendering Whether the browser wraps the description text.
+ * @param query.hide_border Whether to hide the card border.
+ * @param pat Optional PAT override.
+ * @returns The rendered card, or a rendered error.
+ */
export default async (
{
id,
- locale,
border_radius,
show_owner,
browser_rendering,
hide_border,
...remainingParams
- },
- pat = null,
-) => {
+ }: GistApiQuery,
+ pat: string | null = null,
+): Promise => {
const colorParams = pickColorParams(remainingParams);
const invalidColorInput = findInvalidColorParam(colorParams);
@@ -35,19 +59,20 @@ export default async (
};
}
- if (locale && !isLocaleAvailable(locale)) {
+ const borderRadius =
+ border_radius === undefined ? undefined : parseFloat(border_radius);
+ if (borderRadius !== undefined && !Number.isFinite(borderRadius)) {
return {
status: "error - permanent",
content: renderError({
message: "Something went wrong",
- secondaryMessage: "Language not found",
+ secondaryMessage: 'Invalid number input for parameter "border_radius"',
renderOptions: colorParams,
}),
};
}
- const safePattern = /^[-\w/.,]+$/;
- if (id && !safePattern.test(id)) {
+ if (id && !SAFE_PATTERN.test(id)) {
return {
status: "error - permanent",
content: renderError({
@@ -65,8 +90,7 @@ export default async (
status: "success",
content: renderGistCard(gistData, {
...colorParams,
- border_radius,
- locale: locale ? locale.toLowerCase() : null,
+ border_radius: borderRadius,
show_owner: parseBoolean(show_owner),
browser_rendering: parseBoolean(browser_rendering),
hide_border: parseBoolean(hide_border),
diff --git a/packages/core/src/cards/common-options.ts b/packages/core/src/cards/common-options.ts
deleted file mode 100644
index 3b0401fc7fc0e..0000000000000
--- a/packages/core/src/cards/common-options.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { ThemeName } from "../themes/index.js";
-
-export interface CommonOptions {
- title_color: string;
- icon_color: string;
- text_color: string;
- bg_color: string;
- theme: ThemeName;
- border_radius: number;
- border_color: string;
- locale: string;
- hide_border: boolean;
-}
diff --git a/packages/core/src/cards/gist.ts b/packages/core/src/cards/gist.ts
index 26de36529545b..05434079ecddb 100644
--- a/packages/core/src/cards/gist.ts
+++ b/packages/core/src/cards/gist.ts
@@ -16,7 +16,7 @@ import {
} from "../common/render.js";
import type { GistData } from "../fetchers/types.js";
-import type { CommonOptions } from "./common-options.js";
+import type { CardOptions, CommonCardOptions } from "./options.js";
const ICON_SIZE = 16;
const CARD_DEFAULT_WIDTH = 400;
@@ -27,7 +27,7 @@ const DESCRIPTION_FONT_SIZE = 13;
const DESCRIPTION_LINE_HEIGHT_PX = 16;
const DESCRIPTION_MAX_LINES = 10;
-interface GistCardOptions extends CommonOptions {
+interface GistCardOptions extends CommonCardOptions {
show_owner: boolean;
browser_rendering: boolean;
}
@@ -41,7 +41,7 @@ interface GistCardOptions extends CommonOptions {
*/
const renderGistCard = (
gistData: GistData,
- options: Partial = {},
+ options: CardOptions = {},
): string => {
const { name, nameWithOwner, description, language, starsCount, forksCount } =
gistData;
diff --git a/packages/core/src/cards/options.ts b/packages/core/src/cards/options.ts
new file mode 100644
index 0000000000000..c75ce83968fcd
--- /dev/null
+++ b/packages/core/src/cards/options.ts
@@ -0,0 +1,18 @@
+import type { ColorParams } from "../common/color.js";
+
+/**
+ * Options every card accepts.
+ * Cards spread these into `getLightDarkColors`, hence {@link ColorParams}.
+ */
+export interface CommonCardOptions extends ColorParams {
+ title_color: string;
+ icon_color: string;
+ text_color: string;
+ bg_color: string;
+ border_radius: number;
+ border_color: string;
+ hide_border: boolean;
+}
+
+/** `Partial` that also accepts an explicit `undefined`, as api handlers forward. */
+export type CardOptions = { [K in keyof T]?: T[K] | undefined };
diff --git a/packages/core/src/cards/repo.ts b/packages/core/src/cards/repo.ts
index e56c1d8ffb43d..4e1bc4e4f6c9f 100644
--- a/packages/core/src/cards/repo.ts
+++ b/packages/core/src/cards/repo.ts
@@ -18,7 +18,7 @@ import {
import type { RepositoryData } from "../fetchers/types.js";
import { repoCardLocales } from "../translations.js";
-import type { CommonOptions } from "./common-options.js";
+import type { CommonCardOptions } from "./options.js";
const ICON_SIZE = 16;
const CARD_DEFAULT_WIDTH = 400;
@@ -27,7 +27,8 @@ const DESCRIPTION_FONT_SIZE = 13;
const DESCRIPTION_LINE_HEIGHT_PX = 16;
const DESCRIPTION_MAX_LINES = 3;
-interface RepoCardOptions extends CommonOptions {
+interface RepoCardOptions extends CommonCardOptions {
+ locale: string;
show_owner: boolean;
browser_rendering: boolean;
description_lines_count: number;
diff --git a/packages/core/src/cards/stats.ts b/packages/core/src/cards/stats.ts
index a8d54c0097a4d..e37214b6ba39b 100644
--- a/packages/core/src/cards/stats.ts
+++ b/packages/core/src/cards/stats.ts
@@ -8,7 +8,7 @@ import { createTextNode, flexLayout, measureText } from "../common/render.js";
import type { StatsData } from "../fetchers/types.js";
import { statCardLocales, wakatimeCardLocales } from "../translations.js";
-import type { CommonOptions } from "./common-options.js";
+import type { CommonCardOptions } from "./options.js";
const CARD_MIN_WIDTH = 287;
const CARD_DEFAULT_WIDTH = 287;
@@ -19,7 +19,8 @@ const RANK_ONLY_CARD_DEFAULT_WIDTH = 290;
type RankIcon = "default" | "github" | "percentile";
-interface StatCardOptions extends CommonOptions {
+interface StatCardOptions extends CommonCardOptions {
+ locale: string;
hide: Array;
show_icons: boolean;
hide_title: boolean;
diff --git a/packages/core/src/cards/top-languages.ts b/packages/core/src/cards/top-languages.ts
index 9d2d0a6c9e30f..10473252ced3c 100644
--- a/packages/core/src/cards/top-languages.ts
+++ b/packages/core/src/cards/top-languages.ts
@@ -13,7 +13,7 @@ import {
import type { Lang, TopLangData } from "../fetchers/types.js";
import { langCardLocales } from "../translations.js";
-import type { CommonOptions } from "./common-options.js";
+import type { CommonCardOptions } from "./options.js";
const DEFAULT_CARD_WIDTH = 300;
const MIN_CARD_WIDTH = 280;
@@ -29,7 +29,8 @@ const DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT = 6;
type TopLangLayout = "compact" | "normal" | "donut" | "donut-vertical" | "pie";
-interface TopLangOptions extends CommonOptions {
+interface TopLangOptions extends CommonCardOptions {
+ locale: string;
hide_title: boolean;
card_width: number;
hide: Array;
diff --git a/packages/core/src/cards/wakatime.ts b/packages/core/src/cards/wakatime.ts
index 9a389f563217e..236f97533da49 100644
--- a/packages/core/src/cards/wakatime.ts
+++ b/packages/core/src/cards/wakatime.ts
@@ -8,7 +8,7 @@ import { createProgressNode, flexLayout } from "../common/render.js";
import type { WakaTimeData, WakaTimeLang } from "../fetchers/types.js";
import { wakatimeCardLocales } from "../translations.js";
-import type { CommonOptions } from "./common-options.js";
+import type { CommonCardOptions } from "./options.js";
const DEFAULT_CARD_WIDTH = 495;
const MIN_CARD_WIDTH = 250;
@@ -22,7 +22,8 @@ const TOTAL_TEXT_WIDTH = 275;
type WakaTimeLayout = "compact" | "normal";
type DisplayFormat = "time" | "percent";
-interface WakaTimeOptions extends CommonOptions {
+interface WakaTimeOptions extends CommonCardOptions {
+ locale: string;
hide_title: boolean;
hide: Array;
card_width: number;
diff --git a/packages/core/src/common/color.ts b/packages/core/src/common/color.ts
index e798eb7eb016f..0c40a3900101c 100644
--- a/packages/core/src/common/color.ts
+++ b/packages/core/src/common/color.ts
@@ -1,5 +1,4 @@
-import { themes } from "../themes/index.js";
-import type { ThemeName } from "../themes/index.js";
+import { isThemeName, themes } from "../themes/index.js";
/** Matches a 3-, 4-, 6-, or 8-digit hex color with no leading `#`. */
const HEX_COLOR =
@@ -172,11 +171,7 @@ const getCardColors = ({
theme,
}: ColorInput): CardColors => {
const defaultTheme = themes.default;
- const isThemeProvided = theme !== undefined && theme in themes;
-
- const selectedTheme = isThemeProvided
- ? themes[theme as ThemeName]
- : defaultTheme;
+ const selectedTheme = isThemeName(theme) ? themes[theme] : defaultTheme;
const defaultBorderColor =
"border_color" in selectedTheme
@@ -343,6 +338,8 @@ const findInvalidColorParam = (params: ColorParams): string | null =>
),
);
+export type { ColorParams };
+
export {
getCardColors,
getLightDarkColors,
diff --git a/packages/core/src/common/ops.ts b/packages/core/src/common/ops.ts
index 9f3b1ec9d4662..ab066fd95a63c 100644
--- a/packages/core/src/common/ops.ts
+++ b/packages/core/src/common/ops.ts
@@ -8,10 +8,12 @@ import { CustomError } from "./error.js";
/**
* Returns boolean if value is either "true" or "false" else the value as it is.
*
- * @param value The value to parse.
+ * @param value The value to parse; `undefined` when the param was not sent.
* @returns The parsed value.
*/
-const parseBoolean = (value: string | boolean): boolean | undefined => {
+const parseBoolean = (
+ value: string | boolean | undefined,
+): boolean | undefined => {
if (typeof value === "boolean") {
return value;
}
diff --git a/packages/core/src/common/render.ts b/packages/core/src/common/render.ts
index c8b1adb456ef9..9ffeaf9f9564b 100644
--- a/packages/core/src/common/render.ts
+++ b/packages/core/src/common/render.ts
@@ -394,14 +394,15 @@ const renderError = ({
renderOptions = {},
}: {
message: string;
- secondaryMessage?: string;
+ secondaryMessage?: string | undefined;
+ // `| undefined`: api handlers forward absent query params
renderOptions?: {
- title_color?: string;
- text_color?: string;
- bg_color?: string;
- border_color?: string;
- theme?: string;
- show_repo_link?: boolean;
+ title_color?: string | undefined;
+ text_color?: string | undefined;
+ bg_color?: string | undefined;
+ border_color?: string | undefined;
+ theme?: string | undefined;
+ show_repo_link?: boolean | undefined;
};
}): string => {
const {
diff --git a/packages/core/src/fetchers/gist.ts b/packages/core/src/fetchers/gist.ts
index 6f8cef9720c09..736681dc16896 100644
--- a/packages/core/src/fetchers/gist.ts
+++ b/packages/core/src/fetchers/gist.ts
@@ -46,7 +46,7 @@ const calculatePrimaryLanguage = (
* @returns Gist data.
*/
const fetchGist = async (
- id: string,
+ id: string | undefined,
pat: string | null = null,
): Promise => {
if (!id) {
diff --git a/packages/core/src/themes/index.ts b/packages/core/src/themes/index.ts
index f2d94b7ea14ec..762de1ae26017 100644
--- a/packages/core/src/themes/index.ts
+++ b/packages/core/src/themes/index.ts
@@ -507,3 +507,13 @@ export const themes = {
* Name of one of the {@link themes}.
*/
export type ThemeName = keyof typeof themes;
+
+/**
+ * Checks whether a value is one of the {@link themes}
+ */
+export const isThemeName = (value: unknown): value is ThemeName => {
+ if (typeof value !== "string") {
+ return false;
+ }
+ return value in themes;
+};
diff --git a/packages/core/tests/ops.test.ts b/packages/core/tests/ops.test.ts
index 7d6d1fdde156e..40bb6ad40e2f0 100644
--- a/packages/core/tests/ops.test.ts
+++ b/packages/core/tests/ops.test.ts
@@ -25,7 +25,6 @@ describe("Test ops.js", () => {
expect(parseBoolean("1")).toBe(undefined);
expect(parseBoolean("0")).toBe(undefined);
expect(parseBoolean("")).toBe(undefined);
- // @ts-expect-error testing invalid input
expect(parseBoolean(undefined)).toBe(undefined);
});
diff --git a/packages/core/tests/renderGistCard.test.ts b/packages/core/tests/renderGistCard.test.ts
index c1a5b07e22664..a0d75920a46a6 100644
--- a/packages/core/tests/renderGistCard.test.ts
+++ b/packages/core/tests/renderGistCard.test.ts
@@ -138,7 +138,7 @@ describe("test renderGistCard", () => {
it("should render with all the themes", () => {
Object.entries(themes).forEach(([name, themeData]) => {
document.body.innerHTML = renderGistCard(data, {
- theme: name as keyof typeof themes,
+ theme: name,
});
const styleTag = document.querySelector("style");
@@ -269,16 +269,26 @@ describe("test renderGistCard", () => {
describe("test gist API", () => {
it("should return permanent error for invalid color input", async () => {
- const result = await gistApi(
- // api handler accepts a partial options object at runtime
- { id: "abc123", title_color: "not-a-color" } as Parameters<
- typeof gistApi
- >[0],
- );
+ const result = await gistApi({
+ id: "abc123",
+ title_color: "not-a-color",
+ });
expect(result.status).toBe("error - permanent");
expect(result.content).toContain(
`Invalid color input for parameter "title_color"`,
);
});
+
+ it.each(["abc", ""])(
+ "should return permanent error for border_radius %j",
+ async (border_radius) => {
+ const result = await gistApi({ id: "abc123", border_radius });
+
+ expect(result.status).toBe("error - permanent");
+ expect(result.content).toContain(
+ `Invalid number input for parameter "border_radius"`,
+ );
+ },
+ );
});
diff --git a/packages/core/tests/renderRepoCard.test.ts b/packages/core/tests/renderRepoCard.test.ts
index e4775553275df..b6bc7809884e8 100644
--- a/packages/core/tests/renderRepoCard.test.ts
+++ b/packages/core/tests/renderRepoCard.test.ts
@@ -201,7 +201,7 @@ describe("Test renderRepoCard", () => {
it("should render with all the themes", () => {
Object.entries(themes).forEach(([name, themeData]) => {
document.body.innerHTML = renderRepoCard(data_repo.repository, {
- theme: name as keyof typeof themes,
+ theme: name,
});
const styleTag = document.querySelector("style");
diff --git a/packages/core/tests/renderStatsCard.test.ts b/packages/core/tests/renderStatsCard.test.ts
index 7c2c8c14aad29..32b88ce035b51 100644
--- a/packages/core/tests/renderStatsCard.test.ts
+++ b/packages/core/tests/renderStatsCard.test.ts
@@ -253,7 +253,7 @@ describe("Test renderStatsCard", () => {
it("should render with all the themes", () => {
Object.entries(themes).forEach(([name, themeData]) => {
document.body.innerHTML = renderStatsCard(stats, {
- theme: name as keyof typeof themes,
+ theme: name,
});
const styleTag = document.querySelector("style");
diff --git a/packages/core/tests/renderTopLanguagesCard.test.ts b/packages/core/tests/renderTopLanguagesCard.test.ts
index ce74265681ca7..42ed61b9058e4 100644
--- a/packages/core/tests/renderTopLanguagesCard.test.ts
+++ b/packages/core/tests/renderTopLanguagesCard.test.ts
@@ -524,7 +524,7 @@ describe("Test renderTopLanguages", () => {
it("should render with all the themes", () => {
Object.entries(themes).forEach(([name, themeData]) => {
document.body.innerHTML = renderTopLanguages(langs, {
- theme: name as keyof typeof themes,
+ theme: name,
});
const styleTag = document.querySelector("style");