From f35b63979b331e6e45479eaa1f43a5bd2f8f2011 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti Date: Tue, 25 Aug 2026 07:45:21 +0200 Subject: [PATCH] feat(core): convert the gist api handler to TypeScript --- .../docs/docs/customization/common-options.md | 24 +++++----- packages/core/src/api/api-result.ts | 5 ++ packages/core/src/api/{gist.js => gist.ts} | 48 ++++++++++++++----- packages/core/src/cards/common-options.ts | 13 ----- packages/core/src/cards/gist.ts | 6 +-- packages/core/src/cards/options.ts | 18 +++++++ packages/core/src/cards/repo.ts | 5 +- packages/core/src/cards/stats.ts | 5 +- packages/core/src/cards/top-languages.ts | 5 +- packages/core/src/cards/wakatime.ts | 5 +- packages/core/src/common/color.ts | 11 ++--- packages/core/src/common/ops.ts | 6 ++- packages/core/src/common/render.ts | 15 +++--- packages/core/src/fetchers/gist.ts | 2 +- packages/core/src/themes/index.ts | 10 ++++ packages/core/tests/ops.test.ts | 1 - packages/core/tests/renderGistCard.test.ts | 24 +++++++--- packages/core/tests/renderRepoCard.test.ts | 2 +- packages/core/tests/renderStatsCard.test.ts | 2 +- .../core/tests/renderTopLanguagesCard.test.ts | 2 +- 20 files changed, 133 insertions(+), 76 deletions(-) create mode 100644 packages/core/src/api/api-result.ts rename packages/core/src/api/{gist.js => gist.ts} (62%) delete mode 100644 packages/core/src/cards/common-options.ts create mode 100644 packages/core/src/cards/options.ts 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");