diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..db604f1
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 48de50e..e35728f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ out/
.next/
.nuxt/
.cache/
+_site/
# Env / secrets
.env
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..4186ec3
--- /dev/null
+++ b/NOTICE
@@ -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.
diff --git a/README.md b/README.md
index 4821904..f34d692 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/build.js b/build.js
new file mode 100644
index 0000000..c9c6a4a
--- /dev/null
+++ b/build.js
@@ -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, ">")
+ .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
new file mode 100644
index 0000000..03773c6
--- /dev/null
+++ b/config.yaml
@@ -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"
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b6d559c
--- /dev/null
+++ b/package-lock.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c2949f9
--- /dev/null
+++ b/package.json
@@ -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"
+}
diff --git a/src/images/.gitkeep b/src/images/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..fe38910
--- /dev/null
+++ b/src/styles.css
@@ -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;
+ }
+}
diff --git a/src/template.html b/src/template.html
new file mode 100644
index 0000000..2eb7b8c
--- /dev/null
+++ b/src/template.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ ${site.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${profile.name}
+ ${profile.tagline}
+
+
+
+
+
+
+
+
+