refactor(projects): rename pages section to projects with contributions

- rename content/page/pages → content/page/projects, keep /pages alias
- list owned + contributed repos (GraphQL repositoriesContributedTo)
- filter by URL (homepage or has_pages), not just GitHub Pages enabled
- rename script/workflow to update-projects-list.mjs / update-projects.yml
This commit is contained in:
tiennm99 committed 2026-04-22 20:09:33 +07:00
1 parent 06c2aeeb22
commit 5876b79689
5 files changed
+217 -169

No files matched your search

-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env node
// Fetch all repos for $GH_USER with has_pages=true and inject a markdown
// table into content/page/pages/index.md between the PAGES_LIST markers.
import fs from "node:fs/promises";
import path from "node:path";
const token = process.env.GH_TOKEN;
const user = process.env.GH_USER;
if (!token || !user) {
console.error("GH_TOKEN and GH_USER env vars are required");
process.exit(1);
}
const headers = {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": `${user}-pages-updater`,
};
async function fetchAllRepos() {
const all = [];
for (let page = 1; page <= 20; page += 1) {
const url = `https://api.github.com/users/${user}/repos?type=all&sort=updated&per_page=100&page=${page}`;
const res = await fetch(url, { headers });
if (!res.ok) {
const body = await res.text();
throw new Error(`GitHub API ${res.status} ${res.statusText}: ${body}`);
}
const batch = await res.json();
if (!Array.isArray(batch) || batch.length === 0) break;
all.push(...batch);
if (batch.length < 100) break;
}
return all;
}
function toRow(repo) {
const pagesUrl =
repo.homepage && repo.homepage.trim().length > 0
? repo.homepage.trim()
: `https://${user}.github.io/${repo.name}`;
const description = (repo.description || "")
.replace(/\|/g, "\\|")
.replace(/\r?\n/g, " ")
.trim();
return `| [${repo.name}](${repo.html_url}) | ${description} | [${pagesUrl}](${pagesUrl}) |`;
}
const START = "<!-- PAGES_LIST_START -->";
const END = "<!-- PAGES_LIST_END -->";
function buildBlock(rows) {
return [
START,
"",
"| Name | Description | Pages URL |",
"| --- | --- | --- |",
...rows,
"",
END,
].join("\n");
}
async function main() {
const repos = await fetchAllRepos();
const pages = repos
.filter((r) => r.has_pages)
.sort((a, b) => a.name.localeCompare(b.name));
const rows = pages.map(toRow);
const block = buildBlock(rows);
const file = path.resolve("content/page/pages/index.md");
const current = await fs.readFile(file, "utf8");
const marker = /<!-- PAGES_LIST_START -->[\s\S]*?<!-- PAGES_LIST_END -->/;
const next = marker.test(current)
? current.replace(marker, block)
: `${current.trimEnd()}\n\n${block}\n`;
if (next === current) {
console.log(`No changes (${pages.length} pages).`);
return;
}
await fs.writeFile(file, next);
console.log(`Updated ${file} with ${pages.length} pages.`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
// Build a projects list (owned + contributed) and inject it into
// content/page/projects/index.md between the PROJECTS_LIST markers.
//
// A project is included if it has a URL, meaning either:
// - repo.homepage is set, OR
// - repo.has_pages is true (owned repos only — GraphQL does not expose
// has_pages for contributed repos, so those must have homepage set).
import fs from "node:fs/promises";
import path from "node:path";
const token = process.env.GH_TOKEN;
const user = process.env.GH_USER;
if (!token || !user) {
console.error("GH_TOKEN and GH_USER env vars are required");
process.exit(1);
}
const restHeaders = {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": `${user}-projects-updater`,
};
async function fetchOwnedRepos() {
const all = [];
for (let page = 1; page <= 20; page += 1) {
const url = `https://api.github.com/users/${user}/repos?type=all&sort=updated&per_page=100&page=${page}`;
const res = await fetch(url, { headers: restHeaders });
if (!res.ok) {
throw new Error(`REST /users/${user}/repos ${res.status}: ${await res.text()}`);
}
const batch = await res.json();
if (!Array.isArray(batch) || batch.length === 0) break;
all.push(...batch);
if (batch.length < 100) break;
}
return all.map((r) => ({
nameWithOwner: r.full_name,
name: r.name,
htmlUrl: r.html_url,
description: r.description,
homepage: r.homepage,
hasPages: !!r.has_pages,
owner: r.owner?.login,
isOwned: true,
}));
}
const CONTRIB_QUERY = `
query($login: String!, $cursor: String) {
user(login: $login) {
repositoriesContributedTo(
first: 100
after: $cursor
includeUserRepositories: false
privacy: PUBLIC
contributionTypes: [COMMIT, PULL_REQUEST, PULL_REQUEST_REVIEW]
orderBy: { field: UPDATED_AT, direction: DESC }
) {
pageInfo { hasNextPage endCursor }
nodes {
nameWithOwner
name
url
description
homepageUrl
owner { login }
}
}
}
}`;
async function fetchContributedRepos() {
const all = [];
let cursor = null;
for (let i = 0; i < 20; i += 1) {
const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": `${user}-projects-updater`,
},
body: JSON.stringify({
query: CONTRIB_QUERY,
variables: { login: user, cursor },
}),
});
if (!res.ok) {
throw new Error(`GraphQL ${res.status}: ${await res.text()}`);
}
const body = await res.json();
if (body.errors) {
throw new Error(`GraphQL errors: ${JSON.stringify(body.errors)}`);
}
const node = body.data?.user?.repositoriesContributedTo;
if (!node) break;
for (const r of node.nodes) {
all.push({
nameWithOwner: r.nameWithOwner,
name: r.name,
htmlUrl: r.url,
description: r.description,
homepage: r.homepageUrl,
hasPages: false, // not exposed via GraphQL; rely on homepage
owner: r.owner.login,
isOwned: false,
});
}
if (!node.pageInfo.hasNextPage) break;
cursor = node.pageInfo.endCursor;
}
return all;
}
function resolveUrl(repo) {
const home = (repo.homepage || "").trim();
if (home) return home;
if (repo.hasPages && repo.isOwned) {
return `https://${repo.owner}.github.io/${repo.name}`;
}
return null;
}
function toRow(repo, url) {
const desc = (repo.description || "")
.replace(/\|/g, "\\|")
.replace(/\r?\n/g, " ")
.trim();
return `| [${repo.nameWithOwner}](${repo.htmlUrl}) | ${desc} | [${url}](${url}) |`;
}
const START = "<!-- PROJECTS_LIST_START -->";
const END = "<!-- PROJECTS_LIST_END -->";
function buildBlock(rows) {
return [
START,
"",
"| Name | Description | URL |",
"| --- | --- | --- |",
...rows,
"",
END,
].join("\n");
}
async function main() {
const [owned, contributed] = await Promise.all([
fetchOwnedRepos(),
fetchContributedRepos(),
]);
const merged = new Map();
for (const r of [...owned, ...contributed]) {
if (!merged.has(r.nameWithOwner)) merged.set(r.nameWithOwner, r);
}
const rows = [];
const sorted = [...merged.values()].sort((a, b) =>
a.nameWithOwner.localeCompare(b.nameWithOwner),
);
for (const r of sorted) {
const url = resolveUrl(r);
if (!url) continue;
rows.push(toRow(r, url));
}
const block = buildBlock(rows);
const file = path.resolve("content/page/projects/index.md");
const current = await fs.readFile(file, "utf8");
const marker = /<!-- PROJECTS_LIST_START -->[\s\S]*?<!-- PROJECTS_LIST_END -->/;
const next = marker.test(current)
? current.replace(marker, block)
: `${current.trimEnd()}\n\n${block}\n`;
if (next === current) {
console.log(`No changes (${rows.length} projects).`);
return;
}
await fs.writeFile(file, next);
console.log(`Updated ${file} with ${rows.length} projects.`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -1,4 +1,4 @@
name: Update GitHub Pages list
name: Update projects list
on:
schedule:
@@ -10,7 +10,7 @@ permissions:
contents: write
concurrency:
group: update-pages
group: update-projects
cancel-in-progress: false
jobs:
@@ -30,20 +30,20 @@ jobs:
with:
node-version: 24
- name: Refresh pages list
- name: Refresh projects list
env:
GH_TOKEN: ${{ secrets.PAGES_FETCH_TOKEN }}
GH_USER: tiennm99
run: node .github/scripts/update-pages-list.mjs
run: node .github/scripts/update-projects-list.mjs
- name: Commit and push
run: |
if [[ -z "$(git status --porcelain content/page/pages/index.md)" ]]; then
if [[ -z "$(git status --porcelain content/page/projects/index.md)" ]]; then
echo "No changes to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add content/page/pages/index.md
git commit -m "chore(pages): refresh GitHub Pages list"
git add content/page/projects/index.md
git commit -m "chore(projects): refresh projects list"
git push
-66
View File
@@ -1,66 +0,0 @@
---
title: Pages
description: Danh sách pages đã làm
menu:
main:
weight: -40
params:
icon: list
---
Danh sách những trang mình host trên [GitHub Pages](https://pages.github.com/). Thường khá thú vị :>
_(Danh sách này được tạo tự động, thứ tự không quan trọng nhé)_
<!-- PAGES_LIST_START -->
| Name | Description | Pages URL |
| --- | --- | --- |
| [20200411](https://github.com/tiennm99/20200411) | An old gift website. Archived 2021-02-06. | [https://tiennm99.github.io/20200411/](https://tiennm99.github.io/20200411/) |
| [20221225](https://github.com/tiennm99/20221225) | A small just-for-fun website built on Christmas Day 2022. | [https://tiennm99.github.io/20221225/](https://tiennm99.github.io/20221225/) |
| [apart](https://github.com/tiennm99/apart) | Small personal website with photos and music for someone far away. | [https://tiennm99.github.io/apart/](https://tiennm99.github.io/apart/) |
| [beentogether](https://github.com/tiennm99/beentogether) | Been Together — a small website celebrating time spent together with music and photos. | [https://tiennm99.github.io/beentogether/](https://tiennm99.github.io/beentogether/) |
| [bkfcduchoa.github.io](https://github.com/bkfcduchoa/bkfcduchoa.github.io) | | [https://bkfcduchoa.github.io/](https://bkfcduchoa.github.io/) |
| [cf-worker-telegram](https://github.com/tiennm99/cf-worker-telegram) | A Cloudflare Worker that acts as a transparent proxy for the Telegram Bot API. Useful for bypassing network restrictions or as a middleware for your Telegram bot. | [https://telegram.miti99.workers.dev](https://telegram.miti99.workers.dev) |
| [cowork-complete-guide](https://github.com/tiennm99/cowork-complete-guide) | Practice repo for the Complete Guide to Claude Cowork course | [https://tiennm99.github.io/cowork-complete-guide/](https://tiennm99.github.io/cowork-complete-guide/) |
| [cv](https://github.com/tiennm99/cv) | My personal CV written in Typst with RenderCV. | [https://tiennm99.github.io/cv/](https://tiennm99.github.io/cv/) |
| [demngayxaem](https://github.com/tiennm99/demngayxaem) | Đếm ngày xa em — a small relationship 'days-apart' counter website. | [https://tiennm99.github.io/demngayxaem/](https://tiennm99.github.io/demngayxaem/) |
| [dental-visit](https://github.com/tiennm99/dental-visit) | Small website created for a dental visit appointment. | [https://tiennm99.github.io/dental-visit/](https://tiennm99.github.io/dental-visit/) |
| [dobattu](https://github.com/tiennm99/dobattu) | Website đổi tên quán ăn Ốc Rốt thành Đô Bất Tử | [https://tiennm99.github.io/dobattu/](https://tiennm99.github.io/dobattu/) |
| [double-click-test](https://github.com/tiennm99/double-click-test) | Website to quickly check a mouse for double-click issues. | [https://tiennm99.github.io/double-click-test/](https://tiennm99.github.io/double-click-test/) |
| [exchange-rate-export](https://github.com/tiennm99/exchange-rate-export) | Export BIDV and Techcombank FX rates to Excel — Next.js web app with date range selection | [https://exchange-rate-export.vercel.app/](https://exchange-rate-export.vercel.app/) |
| [hurt-page](https://github.com/tiennm99/hurt-page) | Built/hosted static version of the 'hurt' website. | [https://tiennm99.github.io/hurt-page/](https://tiennm99.github.io/hurt-page/) |
| [latex-cv](https://github.com/tiennm99/latex-cv) | My CV written in LaTeX | [https://tiennm99.github.io/latex-cv/](https://tiennm99.github.io/latex-cv/) |
| [loldle](https://github.com/tiennm99/loldle) | LoLdle-style League of Legends daily champion guessing game — data auto-updated from my loldle-data repo | [https://tiennm99.github.io/loldle/](https://tiennm99.github.io/loldle/) |
| [loto](https://github.com/tiennm99/loto) | Lô Tô (Vietnamese lottery bingo) number board — Next.js web app | [https://tiennm99.github.io/loto/](https://tiennm99.github.io/loto/) |
| [MaiBD2021](https://github.com/tiennm99/MaiBD2021) | Birthday page for Trúc Mai (2021) | [https://tiennm99.github.io/MaiBD2021/](https://tiennm99.github.io/MaiBD2021/) |
| [miti-app-store-scraper](https://github.com/tiennm99/miti-app-store-scraper) | app-store-scraper hosted as serverless functions on Cloudflare Workers | [https://miti-app-store-scraper.vercel.app](https://miti-app-store-scraper.vercel.app) |
| [miti-google-play-scraper](https://github.com/tiennm99/miti-google-play-scraper) | google-play-scraper hosted as serverless functions on Vercel | [https://miti-google-play-scraper.vercel.app](https://miti-google-play-scraper.vercel.app) |
| [ngamtheproject.github.io](https://github.com/ngamtheproject/ngamtheproject.github.io) | Website for Ngăm - a charity organization founded by high school students. | [https://ngamtheproject.github.io/](https://ngamtheproject.github.io/) |
| [nntv](https://github.com/tiennm99/nntv) | Night Ninja: Twilight Voyage — turn-based stealth puzzle game where a ninja rabbit rescues the carrot princess from vegetable guards | [https://tiennm99.github.io/nntv/](https://tiennm99.github.io/nntv/) |
| [ocrot](https://github.com/tiennm99/ocrot) | Website mừng bạn Thân trở thành ông chủ quán ăn Ốc Rốt | [https://tiennm99.github.io/ocrot/](https://tiennm99.github.io/ocrot/) |
| [penny-pincher-provider](https://github.com/tiennm99/penny-pincher-provider) | A curated list of affordable (or almost free) LLM providers for people who are unwilling to pay premium prices for AI. | [https://tiennm99.github.io/penny-pincher-provider/](https://tiennm99.github.io/penny-pincher-provider/) |
| [pikachu](https://github.com/tiennm99/pikachu) | Pikachu tile-matching puzzle game — Phaser 3 + Next.js + TypeScript | [https://tiennm99.github.io/pikachu/](https://tiennm99.github.io/pikachu/) |
| [quycongdongvi](https://github.com/tiennm99/quycongdongvi) | Website của Quỹ cộng đồng Vị | [https://tiennm99.github.io/quycongdongvi/](https://tiennm99.github.io/quycongdongvi/) |
| [sokoban](https://github.com/tiennm99/sokoban) | From-scratch Sokoban puzzle game — Svelte 5 + Vite, all 155 Microban levels, undo, progress tracking | [https://tiennm99.github.io/sokoban/](https://tiennm99.github.io/sokoban/) |
| [styleguide](https://github.com/tiennm99/styleguide) | Style guides for Google-originated open-source projects | [https://google.github.io/styleguide/](https://google.github.io/styleguide/) |
| [survival2d.github.io](https://github.com/survival2d/survival2d.github.io) | Repo to deploy client to GitHub Pages | [https://survival2d.github.io/](https://survival2d.github.io/) |
| [test-deeplink-sms](https://github.com/tiennm99/test-deeplink-sms) | Small test page for verifying SMS deeplink behavior on mobile devices. | [https://tiennm99.github.io/test-deeplink-sms/](https://tiennm99.github.io/test-deeplink-sms/) |
| [thptqg2016](https://github.com/tiennm99/thptqg2016) | Tra cứu điểm thi THPT Quốc gia 2016 — 877.461 thí sinh · Hỗ trợ truy vấn SQL tùy chỉnh | [https://tiennm99.github.io/thptqg2016/](https://tiennm99.github.io/thptqg2016/) |
| [thptqg2017](https://github.com/tiennm99/thptqg2017) | Tra cứu điểm thi THPT Quốc gia 2017 — ~861k thí sinh · Truy vấn SQL client-side với sql.js | [https://tiennm99.github.io/thptqg2017/](https://tiennm99.github.io/thptqg2017/) |
| [thuvienhoangphap](https://github.com/tiennm99/thuvienhoangphap) | Thư Viện Hoằng Pháp — Vietnamese Buddhist dharma library website. | [https://thuvienhoangphap.netlify.app/](https://thuvienhoangphap.netlify.app/) |
| [tiennm99.github.io](https://github.com/tiennm99/tiennm99.github.io) | Redirect page for my personal website | [https://tiennm99.github.io/](https://tiennm99.github.io/) |
| [tienthieusac.github.io](https://github.com/tienthieusac/tienthieusac.github.io) | Tiên Thiếu Sắc | [https://tienthieusac.github.io/](https://tienthieusac.github.io/) |
| [tn1](https://github.com/tiennm99/tn1) | Hugo photo gallery site built on the Gallery Deluxe module | [https://tiennm99.github.io/tn1/](https://tiennm99.github.io/tn1/) |
| [try-bmad](https://github.com/tiennm99/try-bmad) | 2048 puzzle clone built as a practice project using the BMad Method multi-agent framework | [https://tiennm99.github.io/try-bmad/](https://tiennm99.github.io/try-bmad/) |
| [try-claudekit](https://github.com/tiennm99/try-claudekit) | Suika (watermelon) game clone — physics-based fruit-merging puzzle built with ClaudeKit | [https://tiennm99.github.io/try-claudekit/](https://tiennm99.github.io/try-claudekit/) |
| [try-oh-my-claudecode](https://github.com/tiennm99/try-oh-my-claudecode) | OMC Neon Arena — a browser canvas arcade shooter built end-to-end by an oh-my-claudecode multi-agent team (engine, gameplay, UI, docs). | [https://tiennm99.github.io/try-oh-my-claudecode/](https://tiennm99.github.io/try-oh-my-claudecode/) |
| [try-superpowers](https://github.com/tiennm99/try-superpowers) | Practice project exploring the Superpowers workflow to build a Pikachu tile-matching game | [https://tiennm99.github.io/try-superpowers/](https://tiennm99.github.io/try-superpowers/) |
| [Valentine-Vue-Tailwind](https://github.com/tiennm99/Valentine-Vue-Tailwind) | Support @lds217 for Valentine 2025 | [https://tiennm99.github.io/Valentine-Vue-Tailwind/](https://tiennm99.github.io/Valentine-Vue-Tailwind/) |
| [valentine2025](https://github.com/tiennm99/valentine2025) | A fun Valentine's Day 2025 website. | [https://tiennm99.github.io/valentine2025/](https://tiennm99.github.io/valentine2025/) |
| [vigeoguessr](https://github.com/tiennm99/vigeoguessr) | Guess place on map from street view image | [https://vigeoguessr.netlify.app/](https://vigeoguessr.netlify.app/) |
| [vngeoguessr](https://github.com/tiennm99/vngeoguessr) | GeoGuessr clone for Vietnamese locations — Mapillary street view, Nominatim + Turf.js boundaries, Redis leaderboards | [https://vngeoguessr.vercel.app](https://vngeoguessr.vercel.app) |
| [webcv](https://github.com/tiennm99/webcv) | | [https://tiennm99.github.io/webcv/](https://tiennm99.github.io/webcv/) |
| [webgl-heart](https://github.com/tiennm99/webgl-heart) | | [https://tiennm99.github.io/webgl-heart/](https://tiennm99.github.io/webgl-heart/) |
<!-- PAGES_LIST_END -->
+18
View File
@@ -0,0 +1,18 @@
---
title: Projects
description: Danh sách projects mình sở hữu hoặc đóng góp
aliases:
- pages
menu:
main:
weight: -40
params:
icon: list
---
Danh sách những projects mình sở hữu hoặc đã đóng góp, có URL demo / homepage. Bao gồm cả [GitHub Pages](https://pages.github.com/) và các project host ở nơi khác.
_(Danh sách này được tạo tự động, sắp xếp theo tên repo)_
<!-- PROJECTS_LIST_START -->
<!-- PROJECTS_LIST_END -->