From fee8aeaab843e16a930ecc935933495afaf45fc9 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti Date: Wed, 26 Aug 2026 04:23:13 +0200 Subject: [PATCH 1/3] docs: render card previews from one line of markdown, and lazy-load them --- apps/frontend/astro.config.ts | 9 ++ apps/frontend/e2e/docs-card-previews.spec.ts | 108 ++++++++++++++++++ apps/frontend/package.json | 1 + .../src/content/docs/docs/cards/gist-pin.md | 6 +- .../src/content/docs/docs/cards/repo-pin.md | 12 +- .../content/docs/docs/cards/top-languages.md | 31 ++--- .../src/content/docs/docs/cards/wakatime.md | 9 +- .../docs/docs/customization/theming.md | 3 +- apps/frontend/src/content/docs/docs/demos.md | 42 +++---- apps/frontend/src/content/docs/docs/fork.md | 13 +-- apps/frontend/src/content/docs/docs/index.md | 24 +--- apps/frontend/src/plugins/rehypeCardImages.ts | 88 ++++++++++++++ apps/frontend/src/styles/starlight-theme.css | 5 +- apps/frontend/src/wizard/Home/Home.tsx | 23 +--- .../src/wizard/models/cardThemeDefault.ts | 17 +++ pnpm-lock.yaml | 3 + 16 files changed, 278 insertions(+), 116 deletions(-) create mode 100644 apps/frontend/e2e/docs-card-previews.spec.ts create mode 100644 apps/frontend/src/plugins/rehypeCardImages.ts create mode 100644 apps/frontend/src/wizard/models/cardThemeDefault.ts diff --git a/apps/frontend/astro.config.ts b/apps/frontend/astro.config.ts index 4e0191c38d3c1..942f105beac9d 100644 --- a/apps/frontend/astro.config.ts +++ b/apps/frontend/astro.config.ts @@ -1,11 +1,14 @@ import path from "node:path"; +import { unified } from "@astrojs/markdown-remark"; import react from "@astrojs/react"; import starlight from "@astrojs/starlight"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig, passthroughImageService } from "astro/config"; import starlightLinksValidator from "starlight-links-validator"; +import { rehypeCardImages } from "./src/plugins/rehypeCardImages.js"; + const base = "/frontend"; // `pnpm dev:backend` serves the card endpoints; override to point at another instance. @@ -21,6 +24,12 @@ export default defineConfig({ outDir: "./build", // One screenshot; not worth a native image dependency. image: { service: passthroughImageService() }, + markdown: { + // Starlight appends its own plugins to whatever processor is configured here. + processor: unified({ + rehypePlugins: [rehypeCardImages], + }), + }, // Astro prefixes `base` onto the source but not the destination, hence it spelled out here. redirects: { // Sidebar group labels are not routes, so send them to the group's first page. diff --git a/apps/frontend/e2e/docs-card-previews.spec.ts b/apps/frontend/e2e/docs-card-previews.spec.ts new file mode 100644 index 0000000000000..822a3e3617597 --- /dev/null +++ b/apps/frontend/e2e/docs-card-previews.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +/* + * `rehypeCardImages` assembles the previews at build time, + * so the markdown names neither the themes nor the classes asserted on here. + */ +const cardImage = (page: Page, alt: string, variant: "light" | "dark") => + page.locator(`main img[alt="${alt}"].card-preview-${variant}`); + +test("a preview that names no theme renders one image per site theme", async ({ + page, +}) => { + await page.goto("docs/"); + + await expect(cardImage(page, "Top Langs", "light")).toHaveAttribute( + "src", + "/api/top-langs?username=anuraghazra&langs_count=4&theme=light_github", + ); + await expect(cardImage(page, "Top Langs", "dark")).toHaveAttribute( + "src", + "/api/top-langs?username=anuraghazra&langs_count=4&theme=dark_github", + ); +}); + +test("the pin and gist previews use the repocard themes", async ({ page }) => { + await page.goto("docs/"); + + for (const alt of ["Readme Card", "Gist Card"]) { + await expect(cardImage(page, alt, "light")).toHaveAttribute( + "src", + /theme=light_github_repocard$/, + ); + await expect(cardImage(page, alt, "dark")).toHaveAttribute( + "src", + /theme=dark_github_repocard$/, + ); + } +}); + +test("a preview that names a theme stays a single image", async ({ page }) => { + await page.goto("docs/customization/theming/"); + + const transparent = page.locator('main img[src*="theme=transparent"]'); + await expect(transparent).toHaveCount(1); + await expect(transparent).toHaveAttribute("loading", "lazy"); +}); + +test("every card image on the themes page is deferred", async ({ page }) => { + await page.goto("docs/customization/themes/"); + + await expect(page.locator('main img[src^="/api"]')).not.toHaveCount(0); + await expect( + page.locator('main img[src^="/api"]:not([loading="lazy"])'), + ).toHaveCount(0); +}); + +test("cards laid out as HTML are deferred, not split", async ({ page }) => { + await page.goto("docs/customization/aligning-cards/"); + + // The rows are hand-written HTML, so only the plugin's raw branch reaches them. + await expect( + page.locator('main .card-row img:not([loading="lazy"])'), + ).toHaveCount(0); + + // Each already names a theme, so the pairs stay as the markdown wrote them. + await expect(page.locator('main .card-row img[src^="/api"]')).toHaveCount(8); + await expect( + page.locator("main .card-row img.card-preview-light"), + ).toHaveCount(4); + await expect( + page.locator("main .card-row img.card-preview-dark"), + ).toHaveCount(4); +}); + +// Starlight server-renders its theme onto ``, which `prefers-color-scheme` cannot see. +test.describe("in a browser set to dark", () => { + test.use({ colorScheme: "dark" }); + + test("the visible preview follows the site theme, not the browser", async ({ + page, + }) => { + const requested: Array = []; + page.on("request", (request) => requested.push(request.url())); + + await page.goto("docs/"); + await expect(cardImage(page, "Top Langs", "dark")).toBeVisible(); + await expect(cardImage(page, "Top Langs", "light")).toBeHidden(); + + /* + * A hidden preview has no layout box, so lazy loading never requests it. The + * pin and gist pair is the fair comparison: both sit at the same depth, and + * below the fold, where the dev server's late CSS cannot briefly reveal one. + */ + await expect + .poll(() => requested.some((url) => url.includes("dark_github_repocard"))) + .toBe(true); + expect( + requested.filter((url) => url.includes("light_github_repocard")), + ).toEqual([]); + + const themeSelect = page.locator("header").getByRole("combobox"); + await themeSelect.selectOption({ label: "Light" }); + + await expect(cardImage(page, "Top Langs", "light")).toBeVisible(); + await expect(cardImage(page, "Top Langs", "dark")).toBeHidden(); + }); +}); diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 3c1bf9f115620..da142482783e4 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -35,6 +35,7 @@ "uuid": "^14.0.1" }, "devDependencies": { + "@astrojs/markdown-remark": "7.2.2", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "clsx": "2.1.1", diff --git a/apps/frontend/src/content/docs/docs/cards/gist-pin.md b/apps/frontend/src/content/docs/docs/cards/gist-pin.md index cb0277fbaa89c..842f113919678 100644 --- a/apps/frontend/src/content/docs/docs/cards/gist-pin.md +++ b/apps/frontend/src/content/docs/docs/cards/gist-pin.md @@ -25,10 +25,8 @@ You can customize the appearance and behavior of the gist card using the [common ## Demo -Gist Card -Gist Card +![Gist Card](/api/gist?id=bbfce31e0217a3689c8d961a356cb10d) Use [show\_owner](#options) query option to include the gist's owner username -Gist Card -Gist Card +![Gist Card](/api/gist?id=bbfce31e0217a3689c8d961a356cb10d&show_owner=true) diff --git a/apps/frontend/src/content/docs/docs/cards/repo-pin.md b/apps/frontend/src/content/docs/docs/cards/repo-pin.md index c307642a7eed6..af09a9240cbef 100644 --- a/apps/frontend/src/content/docs/docs/cards/repo-pin.md +++ b/apps/frontend/src/content/docs/docs/cards/repo-pin.md @@ -34,20 +34,16 @@ You can customize the appearance and behavior of the pinned repository card usin ## Demo -Readme Card -Readme Card +![Readme Card](/api/pin?username=anuraghazra&repo=github-readme-stats) Use [show\_owner](#options) query option to include the repo's owner username: -Readme Card -Readme Card +![Readme Card](/api/pin?username=anuraghazra&repo=github-readme-stats&show_owner=true) Use [show](#options) query option to display the user's contributions to the repository: -Readme Card -Readme Card +![Readme Card](/api/pin?username=anuraghazra&repo=github-readme-stats&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented) You can also specify the `repo` parameter in the form `/` to pin a repository from any user or organization, not just your own. This allows you to showcase repositories you contributed to, regardless of ownership. -Readme Card -Readme Card +![Readme Card](/api/pin?username=anuraghazra&repo=statykjs/statyk&show_owner=true&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented) diff --git a/apps/frontend/src/content/docs/docs/cards/top-languages.md b/apps/frontend/src/content/docs/docs/cards/top-languages.md index e98898fdcb764..938f43348fedf 100644 --- a/apps/frontend/src/content/docs/docs/cards/top-languages.md +++ b/apps/frontend/src/content/docs/docs/cards/top-languages.md @@ -153,45 +153,28 @@ You can use the `&stats_format=bytes` option to display the stats in bytes inste ## Demo -Top Langs -Top Langs +![Top Langs](/api/top-langs?username=anuraghazra) ### Compact layout -Top Langs -Top Langs +![Top Langs](/api/top-langs?username=anuraghazra&layout=compact) ### Donut Chart layout - - Top Langs - Top Langs - +[![Top Langs](/api/top-langs?username=anuraghazra&layout=donut)](/api/top-langs?username=anuraghazra&layout=donut) ### Donut Vertical Chart layout - - Top Langs - Top Langs - +[![Top Langs](/api/top-langs?username=anuraghazra&layout=donut-vertical)](/api/top-langs?username=anuraghazra&layout=donut-vertical) ### Pie Chart layout - - Top Langs - Top Langs - +[![Top Langs](/api/top-langs?username=anuraghazra&layout=pie)](/api/top-langs?username=anuraghazra&layout=pie) ### Hidden progress bars - - Top Langs - Top Langs - +[![Top Langs](/api/top-langs?username=anuraghazra&hide_progress=true)](/api/top-langs?username=anuraghazra&hide_progress=true) ### Display bytes instead of percentage - - Top Langs - Top Langs - +[![Top Langs](/api/top-langs?username=anuraghazra&stats_format=bytes)](/api/top-langs?username=anuraghazra&stats_format=bytes) diff --git a/apps/frontend/src/content/docs/docs/cards/wakatime.md b/apps/frontend/src/content/docs/docs/cards/wakatime.md index ca44b3b6bc0b1..6757ca156ddec 100644 --- a/apps/frontend/src/content/docs/docs/cards/wakatime.md +++ b/apps/frontend/src/content/docs/docs/cards/wakatime.md @@ -42,13 +42,10 @@ Custom title should be URI-escaped, as specified in [Percent Encoding](https://e ## Demo -Alan's WakaTime stats -Alan's WakaTime stats +![Alan's WakaTime stats](/api/wakatime?username=alan) -Alan's WakaTime stats -Alan's WakaTime stats +![Alan's WakaTime stats](/api/wakatime?username=alan&card_width=315&hide_progress=true) ### Compact layout -Alan's WakaTime stats -Alan's WakaTime stats +![Alan's WakaTime stats](/api/wakatime?username=alan&layout=compact) diff --git a/apps/frontend/src/content/docs/docs/customization/theming.md b/apps/frontend/src/content/docs/docs/customization/theming.md index 1dfd63d0e197c..e1ddd6dc6cc36 100644 --- a/apps/frontend/src/content/docs/docs/customization/theming.md +++ b/apps/frontend/src/content/docs/docs/customization/theming.md @@ -24,8 +24,7 @@ Preview [all available themes](/frontend/docs/customization/themes/) or read the ## Light and Dark Mode -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra&show_icons=true) There are several ways to switch a card between modes on the client side. diff --git a/apps/frontend/src/content/docs/docs/demos.md b/apps/frontend/src/content/docs/docs/demos.md index 5993626fcdbe3..54d09bcf2bc66 100644 --- a/apps/frontend/src/content/docs/docs/demos.md +++ b/apps/frontend/src/content/docs/docs/demos.md @@ -4,53 +4,43 @@ title: Demo Gallery ## Default -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra) ## Hiding specific stats -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra&hide=contribs,issues) ## Showing additional stats -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra&show_icons=true&show=contributions,reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage,prs_commented,prs_reviewed,issues_commented) ## Showing stats for a specific repository -Anurag's GitHub stats for anuraghazra/github-readme-stats -Anurag's GitHub stats for anuraghazra/github-readme-stats +![Anurag's GitHub stats for anuraghazra/github-readme-stats](/api?username=anuraghazra&repo=anuraghazra/github-readme-stats&hide=prs,issues,stars,commits,contribs&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented&hide_rank=true&custom_title=Anurag%27s%20Stats%20for%20github-readme-stats&card_width=370) ## Showing stats for a specific organization -Anurag's GitHub stats for razorpay -Anurag's GitHub stats for razorpay +![Anurag's GitHub stats for razorpay](/api?username=anuraghazra&owner=razorpay&hide=prs,issues,stars,commits,contribs&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented&hide_rank=true&custom_title=Anurag%27s%20Stats%20for%20razorpay&card_width=370) ## Showing icons -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra&hide=issues&show_icons=true) ## Shows GitHub logo instead rank level -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra&rank_icon=github) ## Shows user rank percentile instead of rank level -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra&rank_icon=percentile) ## Customize Border Color -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra&border_color=2e4058) ## Include All Commits -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra&include_all_commits=true) ## Themes @@ -68,8 +58,7 @@ Choose from any of the [default themes](/frontend/docs/customization/themes/) ## Setting card locale -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra&locale=es) ## Customizing repo card @@ -77,8 +66,7 @@ Choose from any of the [default themes](/frontend/docs/customization/themes/) ## Gist card -Gist Card -Gist Card +![Gist Card](/api/gist?id=bbfce31e0217a3689c8d961a356cb10d) ## Customizing gist card @@ -86,10 +74,8 @@ Choose from any of the [default themes](/frontend/docs/customization/themes/) ## Top languages -Top Langs -Top Langs +![Top Langs](/api/top-langs?username=anuraghazra) ## WakaTime card -Alan's WakaTime stats -Alan's WakaTime stats +![Alan's WakaTime stats](/api/wakatime?username=alan) diff --git a/apps/frontend/src/content/docs/docs/fork.md b/apps/frontend/src/content/docs/docs/fork.md index 2468d91b5676a..365dc28178124 100644 --- a/apps/frontend/src/content/docs/docs/fork.md +++ b/apps/frontend/src/content/docs/docs/fork.md @@ -58,8 +58,7 @@ The pre-existing "Contributed to" stat counts repositories a user has contribute GitHub-Stats-Extended can show your top languages without any numbers via the `hide_values` parameter. And the new `prog_bar_bg_color` parameter sets the background color of progress bars, e.g. to transparent: -anuraghazra's top languages without numbers -anuraghazra's top languages without numbers +![Anuraghazra's top languages without numbers](/api/top-langs?username=anuraghazra&langs_count=4&hide_values=true&prog_bar_bg_color=0000) ### Private contributions support @@ -75,19 +74,17 @@ See [here](/frontend/docs/cards/stats/#filtering-by-repository-and-owner) for fu --- -anuraghazra's contributions to github-readme-stats: +Anuraghazra's contributions to github-readme-stats: -anuraghazra's contributions to github-readme-stats -anuraghazra's contributions to github-readme-stats +![Anuraghazra's contributions to github-readme-stats](/api/pin?username=anuraghazra&repo=github-readme-stats&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented) Add `&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented` to your repo card url to display your contributions to the pinned repository. --- -anurag's contributions to razorpay: +Anurag's contributions to razorpay: -anurag's contributions to razorpay -anurag's contributions to razorpay +![Anurag's contributions to razorpay](/api?username=anuraghazra&owner=razorpay&hide=prs,issues,stars,commits,contribs&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented&hide_rank=true&custom_title=Anurag%27s%20contributions%20to%20razorpay&card_width=333) Add `&repo=userA/repoA,orgB/repoB` or `&owner=userC,orgD` to your profile stats url to filter your contributions by repo or organization. (The screenshot above uses further customization options.) diff --git a/apps/frontend/src/content/docs/docs/index.md b/apps/frontend/src/content/docs/docs/index.md index 47bbb985048e3..f9561208f4a14 100644 --- a/apps/frontend/src/content/docs/docs/index.md +++ b/apps/frontend/src/content/docs/docs/index.md @@ -4,8 +4,7 @@ title: GitHub Stats Extended Dynamically generate GitHub stats for your READMEs. -Anurag's GitHub stats -Anurag's GitHub stats +![Anurag's GitHub stats](/api?username=anuraghazra) GitHub-Stats-Extended is the [extended, actively maintained successor](/frontend/docs/fork/) of [github-readme-stats](https://github.com/anuraghazra/github-readme-stats). It generates [various stats cards](#card-types) about your GitHub contributions, your top languages and more. You can [customize](/frontend/docs/customization/common-options/) the cards via multiple parameters. @@ -34,34 +33,23 @@ GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For - Show your GitHub statistics: - Anurag's GitHub stats - Anurag's GitHub stats + ![Anurag's GitHub stats](/api?username=anuraghazra) - ...your top languages...: - Top Langs - Top Langs + ![Top Langs](/api/top-langs?username=anuraghazra&langs_count=4) - ...and development time: - - Alan's WakaTime stats - Alan's WakaTime stats - + [![Alan's WakaTime stats](/api/wakatime?username=alan&langs_count=6)](https://wakatime.com/@alan) - Pin more than 6 repos in your GitHub profile: - - Readme Card - Readme Card - + [![Readme Card](/api/pin?username=anuraghazra&repo=github-readme-stats)](https://github.com/anuraghazra/github-readme-stats) - Pin Gists in your GitHub profile: - - Gist Card - Gist Card - + [![Gist Card](/api/gist?id=bbfce31e0217a3689c8d961a356cb10d)](https://gist.github.com/Yizack/bbfce31e0217a3689c8d961a356cb10d) - Customize all the cards: diff --git a/apps/frontend/src/plugins/rehypeCardImages.ts b/apps/frontend/src/plugins/rehypeCardImages.ts new file mode 100644 index 0000000000000..a2368baf373aa --- /dev/null +++ b/apps/frontend/src/plugins/rehypeCardImages.ts @@ -0,0 +1,88 @@ +import type { RehypePlugin } from "@astrojs/markdown-remark"; + +import { CATEGORY_BY_CARD_TYPE, CardType } from "../wizard/models/CardType"; +import { getCardThemeDefault } from "../wizard/models/cardThemeDefault"; + +const CARD_ENDPOINT = "/api"; + +/** The card each endpoint renders; `/api` itself is the stats card. */ +const CARD_TYPE_BY_PATH: Record = { + [CARD_ENDPOINT]: CardType.STATS, + [`${CARD_ENDPOINT}/top-langs`]: CardType.TOP_LANGS, + [`${CARD_ENDPOINT}/pin`]: CardType.PIN, + [`${CARD_ENDPOINT}/gist`]: CardType.GIST, + [`${CARD_ENDPOINT}/wakatime`]: CardType.WAKATIME, +}; + +/** An `` for a card that does not already say how it loads. */ +const RAW_CARD_IMAGE = /]*\bloading=)(?=[^>]*\bsrc="\/api)/g; + +/** Card URLs are root-relative, so `URL` needs a base it never reads. */ +const RELATIVE_BASE = "https://cards.invalid"; + +/** + * Defers every card image, and renders one that names no theme twice, + * once per site theme — so a preview is a single line of markdown. + */ +export const rehypeCardImages: RehypePlugin = () => (tree) => { + // Typed off the tree so the plugin needs no hast types of its own. + function walk(children: typeof tree.children) { + for (const [index, child] of children.entries()) { + // `rehype-raw` runs after this plugin, so a card written as HTML is still text. + if (child.type === "raw") { + child.value = child.value.replaceAll( + RAW_CARD_IMAGE, + ' ({ + ...child, + properties: { + ...properties, + // Appended, not re-serialized, so the rest of the query survives verbatim. + src: `${src}${search === "" ? "?" : "&"}theme=${getCardThemeDefault(variant === "dark", category)}`, + className: [ + variant === "dark" ? `card-preview-dark` : `card-preview-light`, + ], + }, + }); + + // Both copies name a theme, so the one the iterator lands on next is skipped. + children.splice(index, 1, copy("light"), copy("dark")); + } + } + + walk(tree.children); +}; diff --git a/apps/frontend/src/styles/starlight-theme.css b/apps/frontend/src/styles/starlight-theme.css index f28472b28b73f..fccf728f979e4 100644 --- a/apps/frontend/src/styles/starlight-theme.css +++ b/apps/frontend/src/styles/starlight-theme.css @@ -37,7 +37,10 @@ height: auto; } -/* `prefers-color-scheme` cannot see the site theme, so pick the card by `data-theme`. */ +/* + * `prefers-color-scheme` cannot see the site theme, so pick the card by `data-theme`. + * `plugins/rehypeCardImages.ts` writes these classes; rename them in both places. + */ :root[data-theme="dark"] .card-preview-light, :root[data-theme="light"] .card-preview-dark { display: none; diff --git a/apps/frontend/src/wizard/Home/Home.tsx b/apps/frontend/src/wizard/Home/Home.tsx index 14a84c7d009bd..5b0a0c8e3bb68 100644 --- a/apps/frontend/src/wizard/Home/Home.tsx +++ b/apps/frontend/src/wizard/Home/Home.tsx @@ -1,4 +1,3 @@ -import type { ThemeName } from "@stats-organization/github-readme-stats-core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { JSX } from "react"; import { useDispatch } from "react-redux"; @@ -16,13 +15,10 @@ import { LinkExternal } from "../../shared/LinkExternal"; import { authenticate } from "../api/user"; import { DEFAULT_OPTION as LANGUAGES_DEFAULT_LAYOUT } from "../components/Home/LanguagesLayoutSection"; import { DEFAULT_OPTION as WAKATIME_DEFAULT_LAYOUT } from "../components/Home/WakatimeLayoutSection"; -import { - CATEGORY_BY_CARD_TYPE, - CardCategory, - CardType, -} from "../models/CardType"; +import { CATEGORY_BY_CARD_TYPE, CardType } from "../models/CardType"; import { STAGE_LABELS } from "../models/Stage"; import type { StageIndex } from "../models/Stage"; +import { getCardThemeDefault } from "../models/cardThemeDefault"; import { useIsDarkTheme } from "../useIsDarkTheme"; import { buildCardUrl } from "./buildCardUrl"; @@ -40,13 +36,6 @@ interface HomeScreenProps { setStage: (stageIndex: StageIndex) => void; } -function getDefaultTheme(isDark: boolean, category: CardCategory): ThemeName { - if (category === CardCategory.REPO) { - return isDark ? "dark_github_repocard" : "light_github_repocard"; - } - return isDark ? "dark_github" : "light_github"; -} - export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { const [isLoading, setIsLoading] = useState(false); @@ -81,7 +70,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { const isDark = useIsDarkTheme(); const cardCategory = CATEGORY_BY_CARD_TYPE[selectedCard]; const [theme, setTheme] = useState(() => - getDefaultTheme(isDark, cardCategory), + getCardThemeDefault(isDark, cardCategory), ); const handleCardTypeChange = (cardType: CardType) => { @@ -99,8 +88,8 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { })); } - if (theme === getDefaultTheme(isDark, cardCategory)) { - setTheme(getDefaultTheme(isDark, CATEGORY_BY_CARD_TYPE[cardType])); + if (theme === getCardThemeDefault(isDark, cardCategory)) { + setTheme(getCardThemeDefault(isDark, CATEGORY_BY_CARD_TYPE[cardType])); } setSelectedCard(cardType); @@ -119,7 +108,7 @@ export function HomeScreen({ stage, setStage }: HomeScreenProps): JSX.Element { // Preview builder for the customize stage, dark-themed to match the surroundings. const customizeCardBuilder = useMemo( - () => cardBuilder.theme(getDefaultTheme(isDark, cardCategory)), + () => cardBuilder.theme(getCardThemeDefault(isDark, cardCategory)), [cardBuilder, isDark, cardCategory], ); diff --git a/apps/frontend/src/wizard/models/cardThemeDefault.ts b/apps/frontend/src/wizard/models/cardThemeDefault.ts new file mode 100644 index 0000000000000..13d3509ce72e6 --- /dev/null +++ b/apps/frontend/src/wizard/models/cardThemeDefault.ts @@ -0,0 +1,17 @@ +import type { ThemeName } from "@stats-organization/github-readme-stats-core"; + +import { CardCategory } from "./CardType"; + +/** + * Theme a card falls back to on a light or dark background. + * Shared with the docs previews, so both halves of the site render a card the same way. + */ +export function getCardThemeDefault( + isDark: boolean, + category: CardCategory, +): ThemeName { + if (category === CardCategory.REPO) { + return isDark ? "dark_github_repocard" : "light_github_repocard"; + } + return isDark ? "dark_github" : "light_github"; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fba5d942ac8c..cd2662154362a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ importers: specifier: ^14.0.1 version: 14.0.1 devDependencies: + '@astrojs/markdown-remark': + specifier: 7.2.2 + version: 7.2.2 '@types/react': specifier: 19.2.17 version: 19.2.17 From 7c20d1e6077ae86ce68a0fdcb4587b3f685915d7 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti Date: Thu, 27 Aug 2026 14:31:17 +0200 Subject: [PATCH 2/3] apply review --- apps/frontend/e2e/docs-card-previews.spec.ts | 44 +++++++-- .../content/docs/docs/cards/top-languages.md | 10 +- apps/frontend/src/content/docs/docs/index.md | 2 +- apps/frontend/src/plugins/rehypeCardImages.ts | 92 ++++++++++++------- 4 files changed, 102 insertions(+), 46 deletions(-) diff --git a/apps/frontend/e2e/docs-card-previews.spec.ts b/apps/frontend/e2e/docs-card-previews.spec.ts index 822a3e3617597..e6a3dfbb49966 100644 --- a/apps/frontend/e2e/docs-card-previews.spec.ts +++ b/apps/frontend/e2e/docs-card-previews.spec.ts @@ -38,18 +38,46 @@ test("the pin and gist previews use the repocard themes", async ({ page }) => { } }); +test("a preview links to itself, unless the markdown links it elsewhere", async ({ + page, +}) => { + await page.goto("docs/"); + + // Opening a preview shows its query string, so each copy links to its own theme. + await expect( + page.locator('main a.card-preview-light:has(img[alt="Top Langs"])'), + ).toHaveAttribute( + "href", + "/api/top-langs?username=anuraghazra&langs_count=4&theme=light_github", + ); + + // The repo card already points at the repo it describes, which is more useful. + await expect( + page.locator('main a:has(img[alt="Readme Card"])'), + ).toHaveAttribute( + "href", + "https://github.com/anuraghazra/github-readme-stats", + ); +}); + test("a preview that names a theme stays a single image", async ({ page }) => { await page.goto("docs/customization/theming/"); - const transparent = page.locator('main img[src*="theme=transparent"]'); - await expect(transparent).toHaveCount(1); - await expect(transparent).toHaveAttribute("loading", "lazy"); + await expect(page.locator('main img[src*="theme=transparent"]')).toHaveCount( + 1, + ); }); -test("every card image on the themes page is deferred", async ({ page }) => { +test("every sample on the themes page is deferred and links to itself", async ({ + page, +}) => { await page.goto("docs/customization/themes/"); - await expect(page.locator('main img[src^="/api"]')).not.toHaveCount(0); + const samples = page.locator('main img[src^="/api"]'); + await expect(samples).not.toHaveCount(0); + await expect( + page.locator('main a[href^="/api"] > img[src^="/api"]'), + ).toHaveCount(await samples.count()); await expect( page.locator('main img[src^="/api"]:not([loading="lazy"])'), ).toHaveCount(0); @@ -88,9 +116,9 @@ test.describe("in a browser set to dark", () => { await expect(cardImage(page, "Top Langs", "light")).toBeHidden(); /* - * A hidden preview has no layout box, so lazy loading never requests it. The - * pin and gist pair is the fair comparison: both sit at the same depth, and - * below the fold, where the dev server's late CSS cannot briefly reveal one. + * A hidden copy has no layout box, so lazy loading never requests it. + * The pin and gist pair is the fair comparison: + * both sit below the fold, where the dev server's late CSS cannot briefly reveal one. */ await expect .poll(() => requested.some((url) => url.includes("dark_github_repocard"))) diff --git a/apps/frontend/src/content/docs/docs/cards/top-languages.md b/apps/frontend/src/content/docs/docs/cards/top-languages.md index 938f43348fedf..304ff4ab0d655 100644 --- a/apps/frontend/src/content/docs/docs/cards/top-languages.md +++ b/apps/frontend/src/content/docs/docs/cards/top-languages.md @@ -161,20 +161,20 @@ You can use the `&stats_format=bytes` option to display the stats in bytes inste ### Donut Chart layout -[![Top Langs](/api/top-langs?username=anuraghazra&layout=donut)](/api/top-langs?username=anuraghazra&layout=donut) +![Top Langs](/api/top-langs?username=anuraghazra&layout=donut) ### Donut Vertical Chart layout -[![Top Langs](/api/top-langs?username=anuraghazra&layout=donut-vertical)](/api/top-langs?username=anuraghazra&layout=donut-vertical) +![Top Langs](/api/top-langs?username=anuraghazra&layout=donut-vertical) ### Pie Chart layout -[![Top Langs](/api/top-langs?username=anuraghazra&layout=pie)](/api/top-langs?username=anuraghazra&layout=pie) +![Top Langs](/api/top-langs?username=anuraghazra&layout=pie) ### Hidden progress bars -[![Top Langs](/api/top-langs?username=anuraghazra&hide_progress=true)](/api/top-langs?username=anuraghazra&hide_progress=true) +![Top Langs](/api/top-langs?username=anuraghazra&hide_progress=true) ### Display bytes instead of percentage -[![Top Langs](/api/top-langs?username=anuraghazra&stats_format=bytes)](/api/top-langs?username=anuraghazra&stats_format=bytes) +![Top Langs](/api/top-langs?username=anuraghazra&stats_format=bytes) diff --git a/apps/frontend/src/content/docs/docs/index.md b/apps/frontend/src/content/docs/docs/index.md index f9561208f4a14..d4c94fba4fa81 100644 --- a/apps/frontend/src/content/docs/docs/index.md +++ b/apps/frontend/src/content/docs/docs/index.md @@ -53,7 +53,7 @@ GitHub-Stats-Extended aims to be fully compatible with github-readme-stats. For - Customize all the cards: - [![Anurag's GitHub stats](/api?username=anuraghazra&show_icons=true&theme=calm&rank_icon=github&include_all_commits=true&custom_title=Anurag's+Stats&disable_animations=true&number_format=long&show=prs_merged_percentage,prs_reviewed)](/api?username=anuraghazra&show_icons=true&theme=calm&rank_icon=github&include_all_commits=true&custom_title=Anurag's+Stats&disable_animations=true&number_format=long&show=prs_merged_percentage,prs_reviewed) + ![Anurag's GitHub stats](/api?username=anuraghazra&show_icons=true&theme=calm&rank_icon=github&include_all_commits=true&custom_title=Anurag's+Stats&disable_animations=true&number_format=long&show=prs_merged_percentage,prs_reviewed) ## Where to next diff --git a/apps/frontend/src/plugins/rehypeCardImages.ts b/apps/frontend/src/plugins/rehypeCardImages.ts index a2368baf373aa..823a2b2316b28 100644 --- a/apps/frontend/src/plugins/rehypeCardImages.ts +++ b/apps/frontend/src/plugins/rehypeCardImages.ts @@ -3,15 +3,13 @@ import type { RehypePlugin } from "@astrojs/markdown-remark"; import { CATEGORY_BY_CARD_TYPE, CardType } from "../wizard/models/CardType"; import { getCardThemeDefault } from "../wizard/models/cardThemeDefault"; -const CARD_ENDPOINT = "/api"; - /** The card each endpoint renders; `/api` itself is the stats card. */ const CARD_TYPE_BY_PATH: Record = { - [CARD_ENDPOINT]: CardType.STATS, - [`${CARD_ENDPOINT}/top-langs`]: CardType.TOP_LANGS, - [`${CARD_ENDPOINT}/pin`]: CardType.PIN, - [`${CARD_ENDPOINT}/gist`]: CardType.GIST, - [`${CARD_ENDPOINT}/wakatime`]: CardType.WAKATIME, + "/api": CardType.STATS, + "/api/top-langs": CardType.TOP_LANGS, + "/api/pin": CardType.PIN, + "/api/gist": CardType.GIST, + "/api/wakatime": CardType.WAKATIME, }; /** An `` for a card that does not already say how it loads. */ @@ -21,18 +19,38 @@ const RAW_CARD_IMAGE = /]*\bloading=)(?=[^>]*\bsrc="\/api)/g; const RELATIVE_BASE = "https://cards.invalid"; /** - * Defers every card image, and renders one that names no theme twice, - * once per site theme — so a preview is a single line of markdown. + * Builds a card preview out of one line of markdown. + * + * A `/api` image that names no theme is rendered twice, once per site theme, + * and `styles/starlight-theme.css` shows whichever copy matches. + * Every card image loads lazily, so the hidden copy is never fetched, + * and links to its own URL unless the markdown already points it somewhere better. */ export const rehypeCardImages: RehypePlugin = () => (tree) => { // Typed off the tree so the plugin needs no hast types of its own. - function walk(children: typeof tree.children) { + type ElementNode = Extract< + (typeof tree.children)[number], + { tagName: string } + >; + + /** Opening a preview shows how the card is configured. */ + const linked = ( + image: ElementNode, + className?: Array, + ): ElementNode => ({ + type: "element", + tagName: "a", + properties: { href: image.properties.src, className }, + children: [image], + }); + + function walk(children: typeof tree.children, insideLink: boolean) { for (const [index, child] of children.entries()) { // `rehype-raw` runs after this plugin, so a card written as HTML is still text. if (child.type === "raw") { child.value = child.value.replaceAll( RAW_CARD_IMAGE, - ' (tree) => { } if (child.tagName !== "img") { - walk(child.children); + walk(child.children, insideLink || child.tagName === "a"); continue; } const { properties } = child; const src = properties.src; - if (typeof src !== "string" || !src.startsWith(CARD_ENDPOINT)) { + if (typeof src !== "string") { continue; } - properties.loading ??= "lazy"; - properties.decoding ??= "async"; - const { pathname, search, searchParams } = new URL(src, RELATIVE_BASE); const cardType = CARD_TYPE_BY_PATH[pathname]; + if (cardType === undefined) { + continue; + } + + // A hidden copy has no layout box, so only the shown theme is fetched. + properties.loading ??= "lazy"; // A named theme is the point of the preview, so leave it as one image. - if (cardType === undefined || searchParams.has("theme")) { + if (searchParams.has("theme")) { + if (!insideLink) { + children.splice(index, 1, linked(child)); + } continue; } const category = CATEGORY_BY_CARD_TYPE[cardType]; - // `styles/starlight-theme.css` hides the copy that does not match the - // site theme; rename these classes in both places. - const copy = (variant: "light" | "dark") => ({ - ...child, - properties: { - ...properties, - // Appended, not re-serialized, so the rest of the query survives verbatim. - src: `${src}${search === "" ? "?" : "&"}theme=${getCardThemeDefault(variant === "dark", category)}`, - className: [ - variant === "dark" ? `card-preview-dark` : `card-preview-light`, - ], - }, - }); + const themed = (variant: "light" | "dark") => { + // `styles/starlight-theme.css` hides the class that does not match the + // site theme; rename it in both places. It also goes on the link, so a + // hidden copy leaves nothing focusable behind. + const className = + variant === "dark" ? "card-preview-dark" : "card-preview-light"; + const image = { + ...child, + properties: { + ...properties, + // Appended, not re-serialized, so the rest of the query survives verbatim. + src: `${src}${search === "" ? "?" : "&"}theme=${getCardThemeDefault(variant === "dark", category)}`, + className: [className], + }, + }; + return insideLink ? image : linked(image, [className]); + }; // Both copies name a theme, so the one the iterator lands on next is skipped. - children.splice(index, 1, copy("light"), copy("dark")); + children.splice(index, 1, themed("light"), themed("dark")); } } - walk(tree.children); + walk(tree.children, false); }; From 6da57628e002f5b69adf7162eff978db80e0e3d9 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:43:13 +0200 Subject: [PATCH 3/3] refinements --- .../src/content/docs/docs/customization/theming.md | 4 +++- apps/frontend/src/content/docs/docs/fork.md | 6 +++--- apps/frontend/src/plugins/rehypeCardImages.ts | 10 +++++++--- apps/frontend/src/styles/starlight-theme.css | 2 +- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/frontend/src/content/docs/docs/customization/theming.md b/apps/frontend/src/content/docs/docs/customization/theming.md index e1ddd6dc6cc36..5f4592ca410d2 100644 --- a/apps/frontend/src/content/docs/docs/customization/theming.md +++ b/apps/frontend/src/content/docs/docs/customization/theming.md @@ -124,6 +124,8 @@ Any of [the available themes](/frontend/docs/customization/themes/) turns transp
👀 Show example -![Anurag's GitHub stats](/api?username=anuraghazra&show_icons=true&bg_color=00000000) + + +![Anurag's GitHub stats](/api?username=anuraghazra&show_icons=true&bg_color=00000000&theme=default)
diff --git a/apps/frontend/src/content/docs/docs/fork.md b/apps/frontend/src/content/docs/docs/fork.md index 365dc28178124..f80505e280c71 100644 --- a/apps/frontend/src/content/docs/docs/fork.md +++ b/apps/frontend/src/content/docs/docs/fork.md @@ -37,7 +37,7 @@ GitHub-Stats-Extended fetches up to 1000 of your starred repositories to accurat GitHub-Stats-Extended adds parameters `theme_light`, `theme_dark` and the `*_light` / `*_dark` color variants (e.g. `title_color_light`), to specify both modes in one card URL. The card then follows the viewer's browser or OS setting. ```md -![Anurag's GitHub stats](https://github-stats-extended.vercel.app/api?username=anuraghazra&theme_light=light_github&theme_dark=dark_github) +![Anuraghazra's GitHub stats](https://github-stats-extended.vercel.app/api?username=anuraghazra&theme_light=light_github&theme_dark=dark_github) ``` It works everywhere, including GitHub sponsorship pages, where the other light/dark approaches do not. See [Set light and dark mode in one card](/frontend/docs/customization/theming/#set-light-and-dark-mode-in-one-card) for the details. @@ -82,9 +82,9 @@ Add `&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commen --- -Anurag's contributions to razorpay: +Anuraghazra's contributions to razorpay: -![Anurag's contributions to razorpay](/api?username=anuraghazra&owner=razorpay&hide=prs,issues,stars,commits,contribs&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented&hide_rank=true&custom_title=Anurag%27s%20contributions%20to%20razorpay&card_width=333) +![Anuraghazra's contributions to razorpay](/api?username=anuraghazra&owner=razorpay&hide=prs,issues,stars,commits,contribs&show=prs_authored,prs_commented,prs_reviewed,issues_authored,issues_commented&hide_rank=true&custom_title=Anurag%27s%20contributions%20to%20razorpay&card_width=333) Add `&repo=userA/repoA,orgB/repoB` or `&owner=userC,orgD` to your profile stats url to filter your contributions by repo or organization. (The screenshot above uses further customization options.) diff --git a/apps/frontend/src/plugins/rehypeCardImages.ts b/apps/frontend/src/plugins/rehypeCardImages.ts index 823a2b2316b28..fde46b6d7d889 100644 --- a/apps/frontend/src/plugins/rehypeCardImages.ts +++ b/apps/frontend/src/plugins/rehypeCardImages.ts @@ -80,7 +80,11 @@ export const rehypeCardImages: RehypePlugin = () => (tree) => { properties.loading ??= "lazy"; // A named theme is the point of the preview, so leave it as one image. - if (searchParams.has("theme")) { + if ( + searchParams.has("theme") || + searchParams.has("theme_light") || + searchParams.has("theme_dark") + ) { if (!insideLink) { children.splice(index, 1, linked(child)); } @@ -91,8 +95,8 @@ export const rehypeCardImages: RehypePlugin = () => (tree) => { const themed = (variant: "light" | "dark") => { // `styles/starlight-theme.css` hides the class that does not match the - // site theme; rename it in both places. It also goes on the link, so a - // hidden copy leaves nothing focusable behind. + // site theme; keep class names in sync with that file. It also goes on + // the link, so a hidden copy leaves nothing focusable behind. const className = variant === "dark" ? "card-preview-dark" : "card-preview-light"; const image = { diff --git a/apps/frontend/src/styles/starlight-theme.css b/apps/frontend/src/styles/starlight-theme.css index fccf728f979e4..f56c6922f376e 100644 --- a/apps/frontend/src/styles/starlight-theme.css +++ b/apps/frontend/src/styles/starlight-theme.css @@ -39,7 +39,7 @@ /* * `prefers-color-scheme` cannot see the site theme, so pick the card by `data-theme`. - * `plugins/rehypeCardImages.ts` writes these classes; rename them in both places. + * `plugins/rehypeCardImages.ts` writes these classes; keep class names in sync with that file. */ :root[data-theme="dark"] .card-preview-light, :root[data-theme="light"] .card-preview-dark {