From 04b37c1f4ad54b8a09b26ac0e5b3ef9a11fb835b Mon Sep 17 00:00:00 2001 From: Devin-AXIS Date: Thu, 20 Aug 2026 00:24:10 +0800 Subject: [PATCH] docs: add dynamic star history chart --- .github/workflows/star-history.yml | 64 ++++++++++ README.md | 12 ++ docs/translations/README_JA.md | 12 ++ docs/translations/README_ZH.md | 12 ++ docs/translations/README_ZH_hk.md | 12 ++ scripts/update-star-history.mjs | 186 +++++++++++++++++++++++++++++ 6 files changed, 298 insertions(+) create mode 100644 .github/workflows/star-history.yml create mode 100644 scripts/update-star-history.mjs diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml new file mode 100644 index 000000000..58ce30224 --- /dev/null +++ b/.github/workflows/star-history.yml @@ -0,0 +1,64 @@ +name: Star History + +on: + schedule: + - cron: "17 2 * * *" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: star-history + cancel-in-progress: true + +jobs: + update: + if: github.repository == 'Devin-AXIS/iPolloWork' + runs-on: ubuntu-latest + steps: + - name: Check out updater + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + path: source + persist-credentials: false + + - name: Check out chart data + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: star-history + path: chart + + - name: Read current star count + id: repository + env: + GH_TOKEN: ${{ github.token }} + run: | + response="$(curl --fail --silent --show-error \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer ${GH_TOKEN}" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}")" + count="$(jq --exit-status '.stargazers_count | select(type == "number" and . >= 0)' <<<"${response}")" + echo "count=${count}" >> "${GITHUB_OUTPUT}" + + - name: Render chart + run: | + node source/scripts/update-star-history.mjs \ + --repository "${GITHUB_REPOSITORY}" \ + --count "${{ steps.repository.outputs.count }}" \ + --data chart/docs/star-history/data.json \ + --output-dir chart/docs/star-history + + - name: Publish changed chart + working-directory: chart + run: | + if git diff --quiet -- docs/star-history; then + echo "Star history is already current." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docs/star-history/data.json docs/star-history/star-history-light.svg docs/star-history/star-history-dark.svg + git commit -m "chore: update star history [skip ci]" + git push origin HEAD:star-history diff --git a/README.md b/README.md index 2b2db44df..672b4ea93 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,18 @@ git diff --check See `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, and `SECURITY.md` for contribution, community, and security policies. +## Star History + +

+ + + + + iPolloWork Star History + + +

+ ## License iPolloWork uses the **iPolloWork Source Available License 1.0**: diff --git a/docs/translations/README_JA.md b/docs/translations/README_JA.md index 07f022841..baa04a7fd 100644 --- a/docs/translations/README_JA.md +++ b/docs/translations/README_JA.md @@ -199,6 +199,18 @@ git diff --check コントリビューション、コミュニティ、セキュリティに関する方針は、`CONTRIBUTING.md`、`CODE_OF_CONDUCT.md`、`SECURITY.md` を参照してください。 +## Star の推移 + +

+ + + + + iPolloWork Star の推移 + + +

+ ## ライセンス iPolloWork は **iPolloWork Source Available License 1.0** を使用します。 diff --git a/docs/translations/README_ZH.md b/docs/translations/README_ZH.md index ee41dd95b..2c4dc9b16 100644 --- a/docs/translations/README_ZH.md +++ b/docs/translations/README_ZH.md @@ -172,6 +172,18 @@ iPolloWork 桌面/UI ──> iPolloWork Server ──> OpenCode - 不连接 Cloud 时,iPolloWork 仍可完整本地运行。 - iPolloWork 不修改 OpenCode,OpenCode 可以继续独立升级。 +## Star 增长趋势 + +

+ + + + + iPolloWork Star 增长趋势 + + +

+ ## 使用许可 - 仅个人自己使用免费;少于 3 名总用户的小规模内部使用免费。 diff --git a/docs/translations/README_ZH_hk.md b/docs/translations/README_ZH_hk.md index 38aef2233..10a3b10a7 100644 --- a/docs/translations/README_ZH_hk.md +++ b/docs/translations/README_ZH_hk.md @@ -174,6 +174,18 @@ iPolloWork 桌面/UI ──> iPolloWork Server ──> OpenCode - 不連接 Cloud 時,iPolloWork 仍可完整本地運行。 - iPolloWork 不修改 OpenCode,OpenCode 可以繼續獨立升級。 +## Star 增長趨勢 + +

+ + + + + iPolloWork Star 增長趨勢 + + +

+ ## 使用許可 - 僅個人自己使用免費;少於 3 名總用户的小規模內部使用免費。 diff --git a/scripts/update-star-history.mjs b/scripts/update-star-history.mjs new file mode 100644 index 000000000..2d39c9e31 --- /dev/null +++ b/scripts/update-star-history.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + const key = process.argv[index]; + const value = process.argv[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + throw new Error(`Invalid argument near ${key ?? "end of command"}`); + } + args.set(key.slice(2), value); +} + +const repository = args.get("repository"); +const count = Number(args.get("count")); +const dataPath = args.get("data"); +const outputDir = args.get("output-dir"); +const updatedAt = args.get("updated-at") ?? new Date().toISOString(); + +if (!repository?.match(/^[^/]+\/[^/]+$/)) { + throw new Error("--repository must use owner/name format"); +} +if (!Number.isSafeInteger(count) || count < 0) { + throw new Error("--count must be a non-negative integer"); +} +if (!dataPath || !outputDir || Number.isNaN(Date.parse(updatedAt))) { + throw new Error("--data, --output-dir, and a valid --updated-at are required"); +} + +const data = JSON.parse(await readFile(dataPath, "utf8")); +if (data.repository !== repository || !Array.isArray(data.points)) { + throw new Error("Star history data does not match the requested repository"); +} + +const date = updatedAt.slice(0, 10); +const lastPoint = data.points.at(-1); +if (lastPoint && date < lastPoint.date) { + throw new Error(`Refusing to insert ${date} before existing point ${lastPoint.date}`); +} +if (lastPoint?.date === date && lastPoint.count !== count) { + lastPoint.count = count; + data.updatedAt = updatedAt; +} else if (lastPoint?.date !== date) { + data.points.push({ date, count }); + data.updatedAt = updatedAt; +} + +await mkdir(outputDir, { recursive: true }); +await writeFile(dataPath, `${JSON.stringify(data, null, 2)}\n`); +await Promise.all([ + writeFile(path.join(outputDir, "star-history-light.svg"), renderChart(data, "light")), + writeFile(path.join(outputDir, "star-history-dark.svg"), renderChart(data, "dark")), +]); + +function renderChart(history, themeName) { + const theme = + themeName === "dark" + ? { + background: "#0d1117", + grid: "#30363d", + muted: "#8b949e", + text: "#f0f6fc", + line: "#ff7b54", + fill: "#ff7b54", + } + : { + background: "#ffffff", + grid: "#d8dee4", + muted: "#57606a", + text: "#24292f", + line: "#f05a37", + fill: "#f05a37", + }; + const width = 900; + const height = 500; + const margin = { top: 88, right: 40, bottom: 64, left: 76 }; + const plotWidth = width - margin.left - margin.right; + const plotHeight = height - margin.top - margin.bottom; + const parsed = history.points.map((point) => ({ + count: point.count, + date: point.date, + time: Date.parse(`${point.date}T00:00:00Z`), + })); + if (parsed.length === 0) { + throw new Error("Star history needs at least one point"); + } + + const minTime = parsed[0].time; + const maxTime = parsed.at(-1).time; + const maxCount = Math.max(...parsed.map((point) => point.count), 1); + const yMax = niceCeiling(maxCount); + const timeSpan = Math.max(maxTime - minTime, 1); + const sampled = samplePoints(parsed, 720); + const x = (time) => margin.left + ((time - minTime) / timeSpan) * plotWidth; + const y = (value) => margin.top + plotHeight - (value / yMax) * plotHeight; + const linePath = sampled + .map((point, index) => `${index === 0 ? "M" : "L"}${x(point.time).toFixed(1)} ${y(point.count).toFixed(1)}`) + .join(" "); + const fillPath = `${linePath} L${x(sampled.at(-1).time).toFixed(1)} ${margin.top + plotHeight} L${x(sampled[0].time).toFixed(1)} ${margin.top + plotHeight} Z`; + const identifier = `star-history-${themeName}`; + const latest = parsed.at(-1); + + const yTicks = Array.from({ length: 6 }, (_, index) => (yMax * index) / 5); + const xTicks = Array.from({ length: 5 }, (_, index) => minTime + (timeSpan * index) / 4); + const grid = yTicks + .map( + (value) => + `` + + `${formatCount(value)}`, + ) + .join(""); + const dates = xTicks + .map( + (time, index) => + `${formatDate(time)}`, + ) + .join(""); + + return ` + ${escapeXml(history.repository)} star history + ${formatCount(latest.count)} stars as of ${escapeXml(latest.date)} + + + + + + + + Star History + ${escapeXml(history.repository)} · ${formatCount(latest.count)} stars · Updated ${escapeXml(latest.date)} + + ${grid} + ${dates} + + + + Interactive chart: star-history.com + + +`; +} + +function samplePoints(points, limit) { + if (points.length <= limit) return points; + const result = []; + for (let index = 0; index < limit; index += 1) { + result.push(points[Math.round((index * (points.length - 1)) / (limit - 1))]); + } + return result; +} + +function niceCeiling(value) { + const magnitude = 10 ** Math.floor(Math.log10(value)); + const normalized = value / magnitude; + const ceiling = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return ceiling * magnitude; +} + +function formatCount(value) { + if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}M`; + if (value >= 1_000) return `${trimDecimal(value / 1_000)}K`; + return String(Math.round(value)); +} + +function trimDecimal(value) { + return value >= 10 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1); +} + +function formatDate(time) { + return new Intl.DateTimeFormat("en", { + month: "short", + timeZone: "UTC", + year: "numeric", + }).format(new Date(time)); +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +}