feat: add dynamic release badge and github stats

This commit is contained in:
Qingshan Chen
2026-04-02 12:04:31 +08:00
parent 5be544d4df
commit a249fe58b7
2 changed files with 364 additions and 0 deletions
+363
View File
@@ -0,0 +1,363 @@
(() => {
const DEFAULT_REPO = "Yuan-lab-LLM/ClawManager";
const CACHE_PREFIX = "clawmanager-release-badge";
const CACHE_TTL_MS = 30 * 60 * 1000;
let latestRelease = null;
let repoStats = null;
let repoStatsRequest = null;
function parseRepoFromHref(href) {
if (!href) return DEFAULT_REPO;
try {
const url = new URL(href);
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]}/${parts[1]}`;
}
return DEFAULT_REPO;
} catch {
return DEFAULT_REPO;
}
}
function repoPage(repo, path = "") {
return `https://github.com/${repo}${path}`;
}
function latestReleasePage(repo) {
return repoPage(repo, "/releases/latest");
}
function latestReleaseApi(repo) {
return `https://api.github.com/repos/${repo}/releases/latest`;
}
function repoApi(repo) {
return `https://api.github.com/repos/${repo}`;
}
function cacheKey(repo, kind) {
return `${CACHE_PREFIX}:${kind}:${repo}`;
}
function readCache(repo, kind) {
try {
const raw = localStorage.getItem(cacheKey(repo, kind));
if (!raw) return null;
const cached = JSON.parse(raw);
if (!cached?.fetchedAt) return null;
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) return null;
return cached;
} catch {
return null;
}
}
function writeCache(repo, kind, value) {
try {
localStorage.setItem(
cacheKey(repo, kind),
JSON.stringify({ ...value, fetchedAt: Date.now() }),
);
} catch {
// Ignore storage failures.
}
}
function formatBadgeText(currentText, tag) {
if (!currentText) {
return `${tag} Released - See what's new`;
}
if (/^[vV]?[0-9][^ ]*/.test(currentText)) {
return currentText.replace(/^[vV]?[0-9][^ ]*/, tag);
}
if (/^latest release/i.test(currentText)) {
return currentText.replace(/^latest release/i, tag);
}
return `${tag} Released - See what's new`;
}
function findBadge() {
return document.querySelector('a[href*="github.com/"][href*="/releases"]');
}
function findHeaderGitHubButton() {
return (
document.querySelector(`a[href="${repoPage(DEFAULT_REPO)}"]`) ||
document.querySelector('a[href^="https://github.com/"]:not([href*="/releases"])')
);
}
function getBadgeTextNode(badge) {
return Array.from(badge.childNodes).find(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim(),
);
}
function formatCount(value) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
return new Intl.NumberFormat("en", {
notation: "compact",
maximumFractionDigits: value >= 10000 ? 0 : 1,
}).format(value);
}
function createStatIcon(kind) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.dataset.statIcon = kind;
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("width", "15");
svg.setAttribute("height", "15");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "2");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("aria-hidden", "true");
svg.style.flexShrink = "0";
const pathsByKind = {
stars: [
["polygon", "12 2 15.1 8.3 22 9.3 17 14.2 18.2 21 12 17.7 5.8 21 7 14.2 2 9.3 8.9 8.3"],
],
forks: [
["circle", "6 5 3"],
["circle", "18 19 3"],
["circle", "18 5 3"],
["path", "M9 5h6"],
["path", "M12 5v9"],
["path", "M12 14l6 2"],
],
};
for (const [tagName, value] of pathsByKind[kind] || []) {
const element = document.createElementNS("http://www.w3.org/2000/svg", tagName);
if (tagName === "path") {
element.setAttribute("d", value);
} else if (tagName === "polygon") {
element.setAttribute("points", value);
} else if (tagName === "circle") {
const [cx, cy, r] = value.split(" ");
element.setAttribute("cx", cx);
element.setAttribute("cy", cy);
element.setAttribute("r", r);
}
svg.appendChild(element);
}
return svg;
}
function applyLatestRelease() {
const badge = findBadge();
if (!badge) return;
const repo = parseRepoFromHref(badge.href);
const fallbackHref = latestReleasePage(repo);
const textNode = getBadgeTextNode(badge);
badge.href = latestRelease?.href || fallbackHref;
if (!textNode) return;
const currentText = textNode.textContent.trim();
if (!latestRelease?.tag) return;
textNode.textContent = ` ${formatBadgeText(currentText, latestRelease.tag)} `;
}
async function loadLatestRelease() {
const badge = findBadge();
const repo = parseRepoFromHref(badge?.href);
const cached = readCache(repo, "release");
if (cached) {
latestRelease = cached;
applyLatestRelease();
}
try {
const response = await fetch(latestReleaseApi(repo), {
headers: {
Accept: "application/vnd.github+json",
},
});
if (!response.ok) {
throw new Error(`Failed to load release: ${response.status}`);
}
const release = await response.json();
if (!release?.tag_name) return;
latestRelease = {
tag: release.tag_name,
href: release.html_url || latestReleasePage(repo),
};
writeCache(repo, "release", latestRelease);
applyLatestRelease();
} catch {
applyLatestRelease();
}
}
function upsertRepoStatLink({ parent, before, githubButton, key, href, label, value }) {
let link = parent.querySelector(`[data-github-stat="${key}"]`);
if (!link) {
link = githubButton.cloneNode(false);
link.dataset.githubStat = key;
}
const valueText = value || "—";
const ariaText = value ? `GitHub ${label.toLowerCase()} ${value}` : `GitHub ${label.toLowerCase()}`;
if (link.className !== githubButton.className) {
link.className = githubButton.className;
}
if (link.href !== href) {
link.href = href;
}
if (link.target !== "_blank") {
link.target = "_blank";
}
if (link.rel !== "noopener noreferrer") {
link.rel = "noopener noreferrer";
}
if (link.getAttribute("aria-label") !== ariaText) {
link.setAttribute("aria-label", ariaText);
}
if (link.title !== ariaText) {
link.title = ariaText;
}
if (link.style.whiteSpace !== "nowrap") {
link.style.whiteSpace = "nowrap";
}
if (link.dataset.renderedStat !== `${key}:${valueText}`) {
const icon = createStatIcon(key);
const count = document.createElement("span");
count.dataset.statCount = "true";
count.textContent = valueText;
link.replaceChildren(icon, count);
link.dataset.renderedStat = `${key}:${valueText}`;
}
if (link.parentElement !== parent || link.nextElementSibling !== before) {
parent.insertBefore(link, before);
}
}
function applyRepoStats() {
const githubButton = findHeaderGitHubButton();
if (!githubButton?.parentElement) return;
const repo = parseRepoFromHref(githubButton.href);
const parent = githubButton.parentElement;
const getStartedButton = parent.querySelector('a[href="#quickstart"]');
const forkLink = parent.querySelector('[data-github-stat="forks"]');
upsertRepoStatLink({
parent,
before: forkLink || getStartedButton,
githubButton,
key: "stars",
href: repoPage(repo, "/stargazers"),
label: "Stars",
value: formatCount(repoStats?.stars),
});
upsertRepoStatLink({
parent,
before: getStartedButton,
githubButton,
key: "forks",
href: repoPage(repo, "/network/members"),
label: "Forks",
value: formatCount(repoStats?.forks),
});
}
async function loadRepoStats() {
const githubButton = findHeaderGitHubButton();
const repo = parseRepoFromHref(githubButton?.href);
const cached = readCache(repo, "repo");
if (cached?.stars != null && cached?.forks != null) {
repoStats = cached;
applyRepoStats();
}
if (repoStatsRequest) {
return repoStatsRequest;
}
repoStatsRequest = (async () => {
try {
const response = await fetch(repoApi(repo), {
headers: {
Accept: "application/vnd.github+json",
},
});
if (!response.ok) {
throw new Error(`Failed to load repo stats: ${response.status}`);
}
const data = await response.json();
repoStats = {
stars: data.stargazers_count,
forks: data.forks_count,
};
writeCache(repo, "repo", repoStats);
applyRepoStats();
} catch {
applyRepoStats();
} finally {
repoStatsRequest = null;
}
})();
return repoStatsRequest;
}
function start() {
applyLatestRelease();
applyRepoStats();
loadLatestRelease();
loadRepoStats();
const observer = new MutationObserver(() => {
applyLatestRelease();
applyRepoStats();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
})();
+1
View File
@@ -16,6 +16,7 @@
code, pre, .font-mono { font-family: 'JetBrains Mono', monospace; }
</style>
<script type="module" crossorigin src="/ClawManager/assets/index-CH2K3oHw.js"></script>
<script defer src="/ClawManager/assets/release-badge.js"></script>
<link rel="stylesheet" crossorigin href="/ClawManager/assets/index-B4e3PzX-.css">
</head>
<body>