diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 2e9ed37..43ae32b 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index e35728f..c26c4df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..13995f7
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "themes/bonsai"]
+ path = themes/bonsai
+ url = https://github.com/tiennm99/bonsai.git
diff --git a/NOTICE b/NOTICE
index 4186ec3..0b2ae9f 100644
--- a/NOTICE
+++ b/NOTICE
@@ -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).
diff --git a/README.md b/README.md
index ac03dd7..1bfede0 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/build.js b/build.js
deleted file mode 100644
index c9c6a4a..0000000
--- a/build.js
+++ /dev/null
@@ -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, ">")
- .replace(/"/g, """);
-
-// --- Renderers -------------------------------------------------------------
-
-function renderLink(l) {
- const data = l.repo ? repoData[l.repo] : null;
- const meta = data
- ? `★ ${data.stars} · ${daysSince(data.pushed)}d ago`
- : "";
- const target = esc(l.target ?? "_self");
- const rel = target === "_blank" ? ' rel="noopener"' : "";
- return `
${esc(l.text)}${meta}`;
-}
-
-function renderSocial(s) {
- return ` `;
-}
-
-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)`,
-);
diff --git a/config.yaml b/config.yaml
deleted file mode 100644
index 03773c6..0000000
--- a/config.yaml
+++ /dev/null
@@ -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"
diff --git a/content/_index.md b/content/_index.md
new file mode 100644
index 0000000..e6dbe72
--- /dev/null
+++ b/content/_index.md
@@ -0,0 +1,5 @@
+---
+title: "Home"
+---
+
+This file exists so Hugo renders the index page. All bio content comes from `[params]` in `hugo.toml`.
diff --git a/hugo.toml b/hugo.toml
new file mode 100644
index 0000000..51631df
--- /dev/null
+++ b/hugo.toml
@@ -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"
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index b6d559c..0000000
--- a/package-lock.json
+++ /dev/null
@@ -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"
- }
- }
- }
-}
diff --git a/package.json b/package.json
deleted file mode 100644
index c2949f9..0000000
--- a/package.json
+++ /dev/null
@@ -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"
-}
diff --git a/src/images/.gitkeep b/src/images/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/src/styles.css b/src/styles.css
deleted file mode 100644
index fe38910..0000000
--- a/src/styles.css
+++ /dev/null
@@ -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;
- }
-}
diff --git a/src/template.html b/src/template.html
deleted file mode 100644
index 2eb7b8c..0000000
--- a/src/template.html
+++ /dev/null
@@ -1,86 +0,0 @@
-
-
-
-
-
- ${site.title}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${profile.name}
- ${profile.tagline}
-
-
-
-
-
-
-
-
-
diff --git a/static/images/avatar.jpg b/static/images/avatar.jpg
new file mode 100644
index 0000000..4a4bdcb
Binary files /dev/null and b/static/images/avatar.jpg differ
diff --git a/themes/bonsai b/themes/bonsai
new file mode 160000
index 0000000..9be87cd
--- /dev/null
+++ b/themes/bonsai
@@ -0,0 +1 @@
+Subproject commit 9be87cd5732541f19eb4675fdc19ccfc3b6a777a