feat: implement config-driven link page

Single-page link-in-bio site at config.yaml -> build.js -> _site/index.html.
Links flagged with `repo: owner/name` get live star count and last-commit
age decoration from the GitHub REST API at build time.

Stack: vanilla HTML/CSS output, Node 20 build script (sole dep: js-yaml).
Deploy: GitHub Actions Pages artifact, daily cron + push triggers.

Architecture inspired by linkyee (MIT); no source code copied. See NOTICE.
This commit is contained in:
tiennm99 committed 2026-04-30 22:05:05 +07:00
1 parent 9fca485f14
commit a90cb8126a
11 files changed
+589 -4

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
name: Deploy to Pages
on:
push:
branches: [main]
schedule:
# Daily 00:00 UTC — refreshes stars + last-commit decoration on repo links.
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Build site
run: node build.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: _site
- id: deploy
uses: actions/deploy-pages@v4
+1
View File
@@ -10,6 +10,7 @@ out/
.next/
.nuxt/
.cache/
_site/
# Env / secrets
.env
+15
View File
@@ -0,0 +1,15 @@
iammiti99
Copyright 2026 Tien Nguyen Minh
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
---
This project is inspired by linkyee (https://github.com/ZhgChgLi/linkyee, MIT).
The architectural idea (config-driven personal link page deployed via GitHub
Pages, with build-time decoration of GitHub repo links) is borrowed; no source
code is copied.
+39 -4
View File
@@ -2,13 +2,48 @@
Personal link-in-bio page — a self-hosted Linktree alternative for my own use.
## About
Inspired by [linkyee](https://github.com/ZhgChgLi/linkyee) (MIT). No source
code is copied; see [`NOTICE`](NOTICE).
A minimalist, single-user replacement for Linktree. Hosts all my public-facing links (social, projects, contact) in one place that I fully control.
## How it works
## Status
Edit [`config.yaml`](config.yaml) → push to `main` → GitHub Actions runs
[`build.js`](build.js) (Node 20) → output deploys to GitHub Pages.
Empty project — implementation pending.
Links flagged with `repo: owner/name` are decorated at build time with
GitHub stars and "last updated N days ago", refreshed daily by a cron run.
## Local development
```bash
npm install
npm run build # writes _site/index.html
npm run serve # python http.server on :8080
```
`build.js` reads `GITHUB_TOKEN` if set; without it, GitHub API calls are
unauthenticated (60 req/h, plenty for a few links).
## One-time GitHub Pages setup
1. Settings → Pages → Source: **GitHub Actions**.
2. (Optional) Settings → Pages → Custom domain: enter your domain and add
a `src/CNAME` file with the same value (`build.js` copies it through).
After that, every push to `main` (and the daily 00:00 UTC cron) deploys.
## Project layout
```
config.yaml # site content — edit this
src/
template.html # HTML template with ${var} placeholders
styles.css # mobile-first, light + dark via prefers-color-scheme
images/ # avatar etc.
build.js # ~70 LOC, Node 20, only dep is js-yaml
.github/workflows/deploy.yml
_site/ # build output (gitignored)
```
## License
+118
View File
@@ -0,0 +1,118 @@
// Build iammiti99: render src/template.html with values from config.yaml,
// decorate `repo:`-flagged links with live GitHub stars + last-commit age.
// Output: _site/index.html plus copied static assets from src/.
//
// Runs locally and in GitHub Actions. Uses Node 20+ built-in fetch.
import { readFile, writeFile, mkdir, cp } from "node:fs/promises";
import { existsSync } from "node:fs";
import yaml from "js-yaml";
const SITE_DIR = "_site";
const TEMPLATE_PATH = "src/template.html";
const CONFIG_PATH = "config.yaml";
const TOKEN = process.env.GITHUB_TOKEN;
const config = yaml.load(await readFile(CONFIG_PATH, "utf8"));
const template = await readFile(TEMPLATE_PATH, "utf8");
if (!config?.site || !config?.profile) {
throw new Error("config.yaml: `site` and `profile` are required.");
}
// --- GitHub data fetcher ---------------------------------------------------
async function fetchRepo(slug) {
const headers = { "User-Agent": "iammiti99-build", Accept: "application/vnd.github+json" };
if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
try {
const r = await fetch(`https://api.github.com/repos/${slug}`, { headers });
if (!r.ok) {
console.warn(`[warn] GitHub API ${slug} → ${r.status}`);
return null;
}
const j = await r.json();
return { stars: j.stargazers_count, pushed: j.pushed_at };
} catch (e) {
console.warn(`[warn] GitHub API ${slug} threw: ${e.message}`);
return null;
}
}
const repoSlugs = (config.links ?? []).filter((l) => l.repo).map((l) => l.repo);
const repoData = Object.fromEntries(
await Promise.all(repoSlugs.map(async (s) => [s, await fetchRepo(s)])),
);
// --- Helpers ---------------------------------------------------------------
const daysSince = (iso) => Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
// Minimal HTML escape for config-derived text. Single-user content but cheap insurance.
const esc = (s) =>
String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// --- Renderers -------------------------------------------------------------
function renderLink(l) {
const data = l.repo ? repoData[l.repo] : null;
const meta = data
? `<span class="meta">★ ${data.stars} · ${daysSince(data.pushed)}d ago</span>`
: "";
const target = esc(l.target ?? "_self");
const rel = target === "_blank" ? ' rel="noopener"' : "";
return ` <li><a href="${esc(l.url)}" target="${target}"${rel}><i class="${esc(l.icon)}" aria-hidden="true"></i><span>${esc(l.text)}</span>${meta}</a></li>`;
}
function renderSocial(s) {
return ` <a href="${esc(s.url)}" title="${esc(s.title)}" rel="noopener" target="_blank" aria-label="${esc(s.title)}"><i class="${esc(s.icon)}" aria-hidden="true"></i></a>`;
}
const linksHtml = (config.links ?? []).map(renderLink).join("\n");
const socialsHtml = (config.socials ?? []).map(renderSocial).join("\n");
// --- Template substitution -------------------------------------------------
const replacements = {
"${site.title}": esc(config.site.title),
"${site.description}": esc(config.site.description),
"${site.url}": esc(config.site.url),
"${site.lang}": esc(config.site.lang ?? "en"),
"${site.theme_color_light}": esc(config.site.theme_color_light ?? "#ffffff"),
"${site.theme_color_dark}": esc(config.site.theme_color_dark ?? "#0d1117"),
"${profile.name}": esc(config.profile.name),
"${profile.tagline}": esc(config.profile.tagline),
"${profile.avatar}": esc(config.profile.avatar),
"${links_html}": linksHtml,
"${socials_html}": socialsHtml,
"${footer.text}": esc(config.footer?.text ?? ""),
"${footer.copyright}": esc(config.footer?.copyright ?? ""),
"${last_modified}": new Date().toISOString(),
};
let output = template;
for (const [key, value] of Object.entries(replacements)) {
output = output.replaceAll(key, value);
}
// --- Write output ----------------------------------------------------------
if (!existsSync(SITE_DIR)) await mkdir(SITE_DIR, { recursive: true });
await writeFile(`${SITE_DIR}/index.html`, output);
// Copy static assets (skip template.html and the gitkeep).
for (const name of ["styles.css", "scripts.js", "images", "favicons", "CNAME"]) {
const src = `src/${name}`;
if (existsSync(src)) {
await cp(src, `${SITE_DIR}/${name}`, { recursive: true });
}
}
console.log(
`Built ${SITE_DIR}/index.html (${repoSlugs.length} repos, ${Object.values(repoData).filter(Boolean).length} resolved)`,
);
+52
View File
@@ -0,0 +1,52 @@
# iammiti99 — site configuration
# Single source of truth. build.js reads this and renders src/template.html
# into _site/index.html. Links flagged with `repo:` get live star + last-commit
# decorations from the GitHub REST API at build time.
site:
title: "Tien — links"
description: "Tien Nguyen Minh — engineer, builder, writer."
url: "https://tiennm99.github.io/iammiti99"
lang: "en"
theme_color_light: "#f7f7f7"
theme_color_dark: "#1b1b1e"
profile:
name: "@miti99"
tagline: "Engineer building things on the web."
avatar: "./images/avatar.jpg"
# Optional. Leave empty string to disable.
analytics:
google_analytics_id: ""
# Primary list — rendered as full-width buttons in order.
# Fields: text (required), url (required), icon (FA class), target, repo (owner/name)
# `repo:` triggers a GitHub API fetch and adds "★ N · Md ago" to the button.
links:
- text: "GitHub"
url: "https://github.com/tiennm99"
icon: "fa-brands fa-github"
target: "_blank"
- text: "iammiti99"
url: "https://github.com/tiennm99/iammiti99"
icon: "fa-solid fa-link"
target: "_blank"
repo: "tiennm99/iammiti99"
- text: "Email"
url: "mailto:minhtienit99@gmail.com"
icon: "fa-solid fa-envelope"
target: "_self"
# Small icon row below the link buttons.
socials:
- icon: "fa-brands fa-github"
url: "https://github.com/tiennm99"
title: "GitHub"
- icon: "fa-solid fa-envelope"
url: "mailto:minhtienit99@gmail.com"
title: "Email"
footer:
text: "Thanks for stopping by."
copyright: "© 2026 Tien Nguyen Minh"
+37
View File
@@ -0,0 +1,37 @@
{
"name": "iammiti99",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "iammiti99",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"js-yaml": "^4.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "iammiti99",
"version": "0.1.0",
"description": "Personal link page for tiennm99 — config-driven, deployed to GitHub Pages.",
"type": "module",
"private": true,
"engines": {
"node": ">=20"
},
"scripts": {
"build": "node build.js",
"serve": "python3 -m http.server -d _site 8080"
},
"dependencies": {
"js-yaml": "^4.1.0"
},
"license": "Apache-2.0"
}
View File
Whitespace-only changes.
+175
View File
@@ -0,0 +1,175 @@
/* iammiti99 — minimalist link page
Light + dark via prefers-color-scheme. No JS toggle. */
:root {
--bg: #f7f7f7;
--fg: #1b1b1e;
--muted: #5b6068;
--card: #ffffff;
--button-bg: #ffffff;
--button-bg-hover: #efefef;
--border: #e3e5e8;
--accent: #1b1b1e;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 16px rgba(0, 0, 0, 0.06);
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #8b949e;
--card: #161b22;
--button-bg: #21262d;
--button-bg-hover: #30363d;
--border: #30363d;
--accent: #e6edf3;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.card {
width: 100%;
max-width: 480px;
text-align: center;
}
.avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border);
margin: 0 auto 12px;
display: block;
}
.name {
font-size: 1.5rem;
font-weight: 700;
margin: 8px 0 4px;
}
.tagline {
color: var(--muted);
margin: 0 0 28px;
line-height: 1.6;
}
.links {
list-style: none;
padding: 0;
margin: 0 0 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.links a {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: var(--button-bg);
color: var(--fg);
text-decoration: none;
padding: 14px 18px;
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
font-weight: 500;
transition: background 0.15s ease, transform 0.15s ease;
}
.links a:hover {
background: var(--button-bg-hover);
transform: translateY(-1px);
}
.links a:active {
transform: translateY(0);
}
.links a i {
font-size: 18px;
width: 22px;
text-align: center;
}
.links a .meta {
margin-left: auto;
font-size: 0.8rem;
color: var(--muted);
font-weight: 400;
white-space: nowrap;
}
.socials {
display: flex;
justify-content: center;
gap: 18px;
margin-bottom: 32px;
}
.socials a {
color: var(--fg);
font-size: 22px;
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: color 0.15s ease, transform 0.15s ease;
}
.socials a:hover {
color: var(--muted);
transform: scale(1.08);
}
.footer {
color: var(--muted);
font-size: 0.875rem;
}
.footer p {
margin: 0 0 6px;
}
.copyright {
display: block;
font-size: 0.75rem;
}
@media (max-width: 360px) {
.links a {
padding: 12px 14px;
font-size: 0.95rem;
}
.links a .meta {
display: none;
}
}
+86
View File
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="${site.lang}">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>${site.title}</title>
<meta name="description" content="${site.description}" />
<meta name="author" content="${profile.name}" />
<meta name="last-modified" content="${last_modified}" />
<!-- OpenGraph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="${site.title}" />
<meta property="og:description" content="${site.description}" />
<meta property="og:url" content="${site.url}" />
<meta property="og:image" content="${profile.avatar}" />
<!-- Twitter -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="${site.title}" />
<meta name="twitter:description" content="${site.description}" />
<meta name="twitter:image" content="${profile.avatar}" />
<!-- Theme color (light + dark) -->
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content="${site.theme_color_light}"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content="${site.theme_color_dark}"
/>
<!-- Apple PWA -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="apple-mobile-web-app-title" content="${site.title}" />
<!-- Font Awesome 6 (CDN). TODO: add SRI integrity hash. -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="card">
<img
class="avatar"
src="${profile.avatar}"
alt="${profile.name} avatar"
width="120"
height="120"
loading="eager"
decoding="async"
/>
<h1 class="name">${profile.name}</h1>
<p class="tagline">${profile.tagline}</p>
<ul class="links">
${links_html}
</ul>
<nav class="socials" aria-label="Social profiles">
${socials_html}
</nav>
<footer class="footer">
<p>${footer.text}</p>
<small class="copyright">${footer.copyright}</small>
</footer>
</main>
</body>
</html>