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} avatar -

    ${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