Skip to content

Commit 522cf58

Browse files
authored
fix: render GitHub cards from build cache (#588)
* fix: handle GitHub card API errors * fix: render GitHub cards from build cache * fix: centralize GitHub card validation
1 parent 230b0cf commit 522cf58

5 files changed

Lines changed: 196 additions & 33 deletions

File tree

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"dev": "astro dev",
1111
"start": "astro dev",
1212
"check": "astro check",
13-
"build": "npx tsx scripts/generate-lqips.ts && npx tsx scripts/generate-vndb-covers.ts && astro build && npx tsx scripts/prune-pio-assets.ts && npx tsx scripts/subset-fonts.ts && npx tsx scripts/minify-inline-scripts.ts && pagefind --site dist",
13+
"build": "npx tsx scripts/generate-github-card-data.ts && npx tsx scripts/generate-lqips.ts && npx tsx scripts/generate-vndb-covers.ts && astro build && npx tsx scripts/prune-pio-assets.ts && npx tsx scripts/subset-fonts.ts && npx tsx scripts/minify-inline-scripts.ts && pagefind --site dist",
1414
"preview": "astro preview",
1515
"astro": "astro",
1616
"type-check": "tsc --noEmit --isolatedDeclarations",
@@ -20,7 +20,8 @@
2020
"format": "biome format --write ./src ./scripts",
2121
"lint": "biome check --write ./src ./scripts",
2222
"preinstall": "npx only-allow pnpm",
23-
"lqips": "npx tsx scripts/generate-lqips.ts"
23+
"lqips": "npx tsx scripts/generate-lqips.ts",
24+
"github-cards": "npx tsx scripts/generate-github-card-data.ts"
2425
},
2526
"dependencies": {
2627
"@astrojs/check": "^0.9.10",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import fs from "node:fs/promises";
2+
import path from "node:path";
3+
import { glob } from "glob";
4+
import { isValidGithubRepository } from "../src/utils/github-card-utils";
5+
6+
const OUTPUT_FILE = "src/constants/github-card-data.json";
7+
const CONTENT_GLOB = "src/content/**/*.{md,mdx}";
8+
const GITHUB_DIRECTIVE_PATTERN =
9+
/::github\s*\{[^}]*\brepo\s*=\s*["']([^"']+)["'][^}]*\}/g;
10+
11+
interface GithubCardData {
12+
description: string | null;
13+
language: string | null;
14+
forks: number;
15+
stars: number;
16+
avatarUrl: string | null;
17+
license: string | null;
18+
}
19+
20+
type GithubCardCache = Record<string, GithubCardData>;
21+
22+
async function readCache(): Promise<GithubCardCache> {
23+
try {
24+
return JSON.parse(await fs.readFile(OUTPUT_FILE, "utf-8"));
25+
} catch {
26+
return {};
27+
}
28+
}
29+
30+
async function findRepositories(): Promise<Map<string, string>> {
31+
const repositories = new Map<string, string>();
32+
const contentFiles = await glob(CONTENT_GLOB);
33+
34+
for (const file of contentFiles) {
35+
const content = await fs.readFile(file, "utf-8");
36+
for (const match of content.matchAll(GITHUB_DIRECTIVE_PATTERN)) {
37+
const repo = match[1];
38+
if (isValidGithubRepository(repo)) {
39+
repositories.set(repo.toLowerCase(), repo);
40+
}
41+
}
42+
}
43+
44+
return repositories;
45+
}
46+
47+
async function fetchRepositoryData(repo: string): Promise<GithubCardData> {
48+
const [owner, name] = repo.split("/");
49+
const headers: Record<string, string> = {
50+
Accept: "application/vnd.github+json",
51+
"X-GitHub-Api-Version": "2022-11-28",
52+
};
53+
const token = process.env.GITHUB_TOKEN?.trim();
54+
if (token) headers.Authorization = `Bearer ${token}`;
55+
56+
const response = await fetch(
57+
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,
58+
{
59+
headers,
60+
signal: AbortSignal.timeout(5000),
61+
},
62+
);
63+
if (!response.ok) {
64+
throw new Error(
65+
`GitHub API returned ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`,
66+
);
67+
}
68+
69+
const data = await response.json();
70+
return {
71+
description:
72+
typeof data.description === "string"
73+
? data.description.replace(/:[a-zA-Z0-9_]+:/g, "")
74+
: null,
75+
language: typeof data.language === "string" ? data.language : null,
76+
forks: typeof data.forks === "number" ? data.forks : 0,
77+
stars:
78+
typeof data.stargazers_count === "number" ? data.stargazers_count : 0,
79+
avatarUrl:
80+
typeof data.owner?.avatar_url === "string" ? data.owner.avatar_url : null,
81+
license:
82+
typeof data.license?.spdx_id === "string" ? data.license.spdx_id : null,
83+
};
84+
}
85+
86+
async function main() {
87+
const existingCache = await readCache();
88+
const repositories = await findRepositories();
89+
const nextCache: GithubCardCache = {};
90+
let updated = 0;
91+
92+
for (const [cacheKey, repo] of repositories) {
93+
try {
94+
nextCache[cacheKey] = await fetchRepositoryData(repo);
95+
updated++;
96+
} catch (error) {
97+
if (existingCache[cacheKey]) {
98+
nextCache[cacheKey] = existingCache[cacheKey];
99+
console.warn(
100+
`[GITHUB-CARD] Failed to refresh ${repo}; keeping cached data.`,
101+
error,
102+
);
103+
} else {
104+
console.warn(
105+
`[GITHUB-CARD] Failed to load ${repo}; the card will use its fallback state.`,
106+
error,
107+
);
108+
}
109+
}
110+
}
111+
112+
const sortedCache = Object.fromEntries(
113+
Object.entries(nextCache).sort(([a], [b]) => a.localeCompare(b)),
114+
);
115+
await fs.mkdir(path.dirname(OUTPUT_FILE), { recursive: true });
116+
await fs.writeFile(
117+
OUTPUT_FILE,
118+
`${JSON.stringify(sortedCache, null, "\t")}\n`,
119+
);
120+
console.log(
121+
`[GITHUB-CARD] Cached ${Object.keys(sortedCache).length} repositories (${updated} refreshed).`,
122+
);
123+
}
124+
125+
main();
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"cuteleaf/firefly": {
3+
"description": "🍀Firefly, fresh and aesthetic Astro blog theme template. ",
4+
"language": "Astro",
5+
"forks": 1535,
6+
"stars": 1953,
7+
"avatarUrl": "https://avatars.githubusercontent.com/u/43440669?v=4",
8+
"license": "MIT"
9+
},
10+
"saicaca/fuwari": {
11+
"description": "✨A static blog template built with Astro. ",
12+
"language": "Astro",
13+
"forks": 1243,
14+
"stars": 4935,
15+
"avatarUrl": "https://avatars.githubusercontent.com/u/25200299?v=4",
16+
"license": "MIT"
17+
}
18+
}
Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
/// <reference types="mdast" />
22
import { h } from "hastscript";
3+
import githubCardData from "../constants/github-card-data.json" with {
4+
type: "json",
5+
};
6+
import { isValidGithubRepository } from "../utils/github-card-utils.ts";
7+
8+
function formatCount(value) {
9+
return Intl.NumberFormat("en-us", {
10+
notation: "compact",
11+
maximumFractionDigits: 1,
12+
})
13+
.format(value)
14+
.replaceAll("\u202f", "");
15+
}
316

417
/**
518
* Creates a GitHub Card component.
@@ -15,7 +28,7 @@ export function GithubCardComponent(properties, children) {
1528
'Invalid directive. ("github" directive must be leaf type "::github{repo="owner/repo"}")',
1629
]);
1730

18-
if (!properties.repo?.includes("/"))
31+
if (!isValidGithubRepository(properties.repo))
1932
return h(
2033
"div",
2134
{ class: "hidden" },
@@ -24,12 +37,23 @@ export function GithubCardComponent(properties, children) {
2437

2538
const repo = properties.repo;
2639
const cardUuid = `GC${Math.random().toString(36).slice(-6)}`; // Collisions are not important
40+
const data = githubCardData[repo.toLowerCase()] ?? null;
41+
const hasData = data !== null;
2742

28-
const nAvatar = h(`div#${cardUuid}-avatar`, { class: "gc-avatar" });
43+
const avatarUrl = data?.avatarUrl
44+
? `${data.avatarUrl}${data.avatarUrl.includes("?") ? "&" : "?"}s=32`
45+
: null;
46+
const avatarStyle = avatarUrl
47+
? `background-image: url("${avatarUrl}"); background-color: transparent;`
48+
: undefined;
49+
const nAvatar = h(`div#${cardUuid}-avatar`, {
50+
class: "gc-avatar",
51+
style: avatarStyle,
52+
});
2953
const nLanguage = h(
3054
`span#${cardUuid}-language`,
3155
{ class: "gc-language" },
32-
"Waiting...",
56+
data?.language ?? "Unavailable",
3357
);
3458

3559
const nTitle = h("div", { class: "gc-titlebar" }, [
@@ -47,40 +71,31 @@ export function GithubCardComponent(properties, children) {
4771
const nDescription = h(
4872
`div#${cardUuid}-description`,
4973
{ class: "gc-description" },
50-
"Waiting for api.github.com...",
74+
hasData
75+
? data.description || "Description not set"
76+
: "Repository details unavailable",
5177
);
5278

53-
const nStars = h(`div#${cardUuid}-stars`, { class: "gc-stars" }, "00K");
54-
const nForks = h(`div#${cardUuid}-forks`, { class: "gc-forks" }, "0K");
55-
const nLicense = h(`div#${cardUuid}-license`, { class: "gc-license" }, "0K");
56-
57-
const nScript = h(
58-
`script#${cardUuid}-script`,
59-
{ type: "text/javascript", defer: true },
60-
`
61-
fetch('https://api.github.com/repos/${repo}', { referrerPolicy: "no-referrer" }).then(response => response.json()).then(data => {
62-
document.getElementById('${cardUuid}-description').innerText = data.description?.replace(/:[a-zA-Z0-9_]+:/g, '') || "Description not set";
63-
document.getElementById('${cardUuid}-language').innerText = data.language;
64-
document.getElementById('${cardUuid}-forks').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\u202f", '');
65-
document.getElementById('${cardUuid}-stars').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\u202f", '');
66-
const avatarEl = document.getElementById('${cardUuid}-avatar');
67-
avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + '&s=32' + ')';
68-
avatarEl.style.backgroundColor = 'transparent';
69-
document.getElementById('${cardUuid}-license').innerText = data.license?.spdx_id || "no-license";
70-
document.getElementById('${cardUuid}-card').classList.remove("fetch-waiting");
71-
console.log("[GITHUB-CARD] Loaded card for ${repo} | ${cardUuid}.")
72-
}).catch(err => {
73-
const c = document.getElementById('${cardUuid}-card');
74-
c?.classList.add("fetch-error");
75-
console.warn("[GITHUB-CARD] (Error) Loading card for ${repo} | ${cardUuid}.")
76-
})
77-
`,
79+
const nStars = h(
80+
`div#${cardUuid}-stars`,
81+
{ class: "gc-stars" },
82+
hasData ? formatCount(data.stars) : "—",
83+
);
84+
const nForks = h(
85+
`div#${cardUuid}-forks`,
86+
{ class: "gc-forks" },
87+
hasData ? formatCount(data.forks) : "—",
88+
);
89+
const nLicense = h(
90+
`div#${cardUuid}-license`,
91+
{ class: "gc-license" },
92+
hasData ? data.license || "no-license" : "—",
7893
);
7994

8095
return h(
8196
`a#${cardUuid}-card`,
8297
{
83-
class: "card-github fetch-waiting no-styling",
98+
class: `card-github${hasData ? "" : " fetch-error"} no-styling`,
8499
href: `https://github.com/${repo}`,
85100
target: "_blank",
86101
repo,
@@ -89,7 +104,6 @@ export function GithubCardComponent(properties, children) {
89104
nTitle,
90105
nDescription,
91106
h("div", { class: "gc-infobar" }, [nStars, nForks, nLicense, nLanguage]),
92-
nScript,
93107
],
94108
);
95109
}

src/utils/github-card-utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const GITHUB_REPOSITORY_PATTERN = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
2+
3+
export function isValidGithubRepository(repo: unknown): repo is string {
4+
return typeof repo === "string" && GITHUB_REPOSITORY_PATTERN.test(repo);
5+
}

0 commit comments

Comments
 (0)