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

    ${profile.name}

    +

    ${profile.tagline}

    + + + + + + +
    + +