feat: rebuild site on Hugo + bonsai theme

Replace the custom Node.js builder with Hugo and the bonsai theme
(added as a submodule at themes/bonsai). Site content moves from
config.yaml into [params] in hugo.toml. CI switched to Hugo Extended
0.154.0 build; daily cron dropped (no API decoration to refresh).
Avatar imported from miti99 project.
This commit is contained in:
tiennm99 committed 2026-05-03 10:45:54 +07:00
1 parent 191bf38d86
commit 37dbf84c14
16 files changed
+94 -569

No files matched your search

+28 -26
View File
@@ -3,9 +3,6 @@ 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:
@@ -18,33 +15,38 @@ concurrency:
cancel-in-progress: true
jobs:
build-and-deploy:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- id: pages
uses: actions/configure-pages@v5
with:
enablement: true
- name: Setup Hugo Extended
uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.154.0'
extended: true
- name: Build
run: hugo --gc --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
- uses: actions/upload-pages-artifact@v3
with:
path: public
deploy:
needs: build
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
with:
enablement: true
- uses: actions/upload-pages-artifact@v3
with:
path: _site
- id: deploy
uses: actions/deploy-pages@v4
+4 -24
View File
@@ -1,36 +1,16 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build output
dist/
build/
out/
.next/
.nuxt/
.cache/
_site/
# Hugo build output
public/
resources/_gen/
.hugo_build.lock
# Env / secrets
.env
.env.*
!.env.example
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor / OS
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# Misc
coverage/
.tmp/
+3
View File
@@ -0,0 +1,3 @@
[submodule "themes/bonsai"]
path = themes/bonsai
url = https://github.com/tiennm99/bonsai.git
+2 -4
View File
@@ -9,7 +9,5 @@ You may obtain a copy of the License at
---
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.
This site is built with the bonsai Hugo theme
(https://github.com/tiennm99/bonsai, Apache-2.0).
+21 -29
View File
@@ -1,51 +1,43 @@
# iammiti99
Personal link-in-bio page — a self-hosted Linktree alternative for my own use.
Inspired by [linkyee](https://github.com/ZhgChgLi/linkyee) (MIT). No source
code is copied; see [`NOTICE`](NOTICE).
Personal link-in-bio page — built with [Hugo](https://gohugo.io) and the
[bonsai](https://github.com/tiennm99/bonsai) theme. Self-hosted Linktree
alternative for my own use.
## How it works
Edit [`config.yaml`](config.yaml) → push to `main` → GitHub Actions runs
[`build.js`](build.js) (Node 20) → output deploys to GitHub Pages.
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.
Edit [`hugo.toml`](hugo.toml) (`[params]` block) → push to `main` → GitHub
Actions builds with Hugo + bonsai → output deploys to GitHub Pages.
## Local development
```bash
npm install
npm run build # writes _site/index.html
npm run serve # python http.server on :8080
```
Requires Hugo Extended ≥ 0.128.
`build.js` reads `GITHUB_TOKEN` if set; without it, GitHub API calls are
unauthenticated (60 req/h, plenty for a few links).
```bash
git submodule update --init --recursive
hugo server # http://localhost:1313
hugo --gc --minify # build to ./public
```
## Deployment
Every push to `main` (plus the daily 00:00 UTC cron) deploys to GitHub
Pages. The workflow self-enables Pages on first run via
`actions/configure-pages@v5` with `enablement: true`, so no manual UI
setup is required for fresh forks (provided **Settings → Actions →
Every push to `main` deploys to GitHub Pages. The workflow self-enables Pages
on first run via `actions/configure-pages@v5` with `enablement: true`, so no
manual UI setup is required for fresh forks (provided **Settings → Actions →
General → Workflow permissions** is set to "Read and write").
(Optional) Custom domain: drop a `src/CNAME` file containing your apex
domain — `build.js` copies it through to the deployed site.
(Optional) Custom domain: drop a `static/CNAME` file containing your apex
domain — Hugo will copy it through to the deployed site.
## 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
hugo.toml # site content — edit [params] here
content/_index.md # placeholder so Hugo renders the index
static/images/ # avatar etc.
themes/bonsai/ # bonsai theme (git submodule)
.github/workflows/deploy.yml
_site/ # build output (gitignored)
public/ # build output (gitignored)
```
## License
-118
View File
@@ -1,118 +0,0 @@
// 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
@@ -1,52 +0,0 @@
# 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"
+5
View File
@@ -0,0 +1,5 @@
---
title: "Home"
---
This file exists so Hugo renders the index page. All bio content comes from `[params]` in `hugo.toml`.
+30
View File
@@ -0,0 +1,30 @@
baseURL = "https://tiennm99.github.io/iammiti99/"
languageCode = "en"
title = "Tien — links"
theme = "bonsai"
# Single-page bio — disable everything Hugo doesn't need.
disableKinds = ["taxonomy", "term", "RSS", "sitemap", "404"]
[params]
name = "@miti99"
tagline = "Engineer building things on the web."
bio = "Tien Nguyen Minh — engineer, builder, writer."
avatar = "/images/avatar.jpg"
themeToggle = false
footerText = "Thanks for stopping by. © 2026 Tien Nguyen Minh"
[[params.links]]
title = "GitHub"
url = "https://github.com/tiennm99"
icon = "github"
[[params.links]]
title = "iammiti99"
url = "https://github.com/tiennm99/iammiti99"
icon = "link"
[[params.links]]
title = "Email"
url = "mailto:minhtienit99@gmail.com"
icon = "mail"
-37
View File
@@ -1,37 +0,0 @@
{
"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
@@ -1,18 +0,0 @@
{
"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
@@ -1,175 +0,0 @@
/* 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
@@ -1,86 +0,0 @@
<!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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Submodule
+1
Submodule themes/bonsai added at 9be87cd573.