mirror of
https://github.com/tiennm99/iammiti99.git
synced 2026-10-03 07:13:12 +00:00
feat: implement config-driven link page
Single-page link-in-bio site at config.yaml -> build.js -> _site/index.html. Links flagged with `repo: owner/name` get live star count and last-commit age decoration from the GitHub REST API at build time. Stack: vanilla HTML/CSS output, Node 20 build script (sole dep: js-yaml). Deploy: GitHub Actions Pages artifact, daily cron + push triggers. Architecture inspired by linkyee (MIT); no source code copied. See NOTICE.
This commit is contained in:
1 parent
9fca485f14
commit
a90cb8126a
11 files changed
+589
-4
No files matched your search
@@ -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
|
||||
@@ -10,6 +10,7 @@ out/
|
||||
.next/
|
||||
.nuxt/
|
||||
.cache/
|
||||
_site/
|
||||
|
||||
# Env / secrets
|
||||
.env
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, ">")
|
||||
.replace(/"/g, """);
|
||||
|
||||
// --- 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
@@ -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"
|
||||
Generated
+37
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Whitespace-only changes.
+175
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<!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>
|
||||
Reference in new issue
Block a user