mirror of
https://github.com/tiennm99/iammiti99.git
synced 2026-10-14 16:17:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49dd22dcd3 | ||
|
|
a55c16e06d | ||
|
|
9beff7b122 | ||
|
|
51a908b6b4 | ||
|
|
3b7c0e94ce | ||
|
|
ebb9de2361 | ||
|
|
7e6f4da53e | ||
|
|
6e420cb510 | ||
|
|
14bf9c5768 | ||
|
|
fc24b8cdfa | ||
|
|
6de6672eed | ||
|
|
584130da57 | ||
|
|
51e0d50f2e | ||
|
|
b21a0eae39 | ||
|
|
4c0d4b092c | ||
|
|
cde369188e | ||
|
|
4d9107cbf7 | ||
|
|
740285fa17 | ||
|
|
0e439e4756 |
No files matched your search
@@ -10,4 +10,4 @@ You may obtain a copy of the License at
|
|||||||
---
|
---
|
||||||
|
|
||||||
This site is built with the bonsai Hugo theme
|
This site is built with the bonsai Hugo theme
|
||||||
(https://github.com/tiennm99/bonsai, Apache-2.0).
|
(vendored in themes/bonsai/, Apache-2.0).
|
||||||
@@ -28,6 +28,26 @@ General → Workflow permissions** is set to "Read and write").
|
|||||||
(Optional) Custom domain: drop a `static/CNAME` file containing your apex
|
(Optional) Custom domain: drop a `static/CNAME` file containing your apex
|
||||||
domain — Hugo will copy it through to the deployed site.
|
domain — Hugo will copy it through to the deployed site.
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
|
||||||
|
`hugo.yml` sets `baseURL: /`, so a plain `hugo` build serves from the domain
|
||||||
|
root. The GitHub Pages workflow overrides it with the Pages URL
|
||||||
|
(`https://<owner>.github.io/iammiti99/`), so only that host gets the
|
||||||
|
`/iammiti99/` subpath.
|
||||||
|
|
||||||
|
### Other hosts (Cloudflare Pages, Netlify, Vercel)
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| Build command | `hugo --gc --minify` |
|
||||||
|
| Output directory | `public` |
|
||||||
|
| Env `HUGO_VERSION` | `0.154.0` (Hugo Extended) |
|
||||||
|
| Env `HUGO_BASEURL` (optional) | the site's full URL, e.g. `https://iammiti99.pages.dev/` |
|
||||||
|
|
||||||
|
Without `HUGO_BASEURL` the site is served from the host's root URL, but
|
||||||
|
`og:image` and the canonical link stay relative. Set it so social link previews
|
||||||
|
get absolute URLs.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
baseURL: https://tiennm99.github.io/iammiti99/
|
baseURL: /
|
||||||
languageCode: en
|
languageCode: en
|
||||||
title: Tien — links
|
title: Tien — links
|
||||||
theme: bonsai
|
theme: bonsai
|
||||||
|
|||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: monday
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
commit-message:
|
|
||||||
prefix: chore
|
|
||||||
include: scope
|
|
||||||
+1
-1
@@ -17,7 +17,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: lighthouse-ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: lighthouse-ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lighthouse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Hugo Extended
|
||||||
|
uses: peaceiris/actions-hugo@v3
|
||||||
|
with:
|
||||||
|
hugo-version: '0.154.0'
|
||||||
|
extended: true
|
||||||
|
|
||||||
|
- name: Build exampleSite
|
||||||
|
working-directory: exampleSite
|
||||||
|
run: hugo --gc --minify --themesDir ../..
|
||||||
|
|
||||||
|
- name: Run Lighthouse CI
|
||||||
|
uses: treosh/lighthouse-ci-action@v12
|
||||||
|
with:
|
||||||
|
configPath: ./.lighthouserc.json
|
||||||
|
uploadArtifacts: true
|
||||||
|
temporaryPublicStorage: false
|
||||||
|
|
||||||
|
- name: Upload Lighthouse report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: lighthouse-report
|
||||||
|
path: ./lhci-reports
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"staticDistDir": "exampleSite/public",
|
||||||
|
"numberOfRuns": 3
|
||||||
|
},
|
||||||
|
"assert": {
|
||||||
|
"preset": "lighthouse:recommended",
|
||||||
|
"assertions": {
|
||||||
|
"categories:performance": ["error", { "minScore": 0.9 }],
|
||||||
|
"categories:accessibility": ["error", { "minScore": 0.9 }],
|
||||||
|
"categories:best-practices": ["error", { "minScore": 0.9 }],
|
||||||
|
"categories:seo": ["error", { "minScore": 0.9 }],
|
||||||
|
"uses-responsive-images": "off",
|
||||||
|
"csp-xss": "off",
|
||||||
|
"uses-text-compression": "off",
|
||||||
|
"uses-long-cache-ttl": "off",
|
||||||
|
"network-dependency-tree-insight": "off",
|
||||||
|
"render-blocking-resources": "off",
|
||||||
|
"render-blocking-insight": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"target": "filesystem",
|
||||||
|
"outputDir": "./lhci-reports"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,44 @@ All notable changes to this project are documented here. Format follows [Keep a
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Deferred to v0.5
|
## [0.5.0] — 2026-05-15
|
||||||
- **Auto-generated OG images** (#7) — moved again from v0.4 because no candidate path meets the ≤ 30 KB binary budget. Needs measured prototype before committing.
|
|
||||||
- **Multi-section bio** (#9) — schema design call deferred per the issue's own "2026-Q3 candidate" recommendation.
|
Linktree-parity + Lighthouse release. Closes #7 (auto-OG, infra-only) and #9 (multi-section bio). All additions are strictly opt-in or transparent; v0.4 sites upgrade with no config edits.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Multi-section bio** (#9) — opt-in `[[params.sections]]` with `title`, `description`, per-section `layout` override, and `[[sections.links]]`. Flat `[[params.links]]` still works (sections win + Hugo `warnf` on collision). Schema-person `sameAs` and RSS feed flatten across sections.
|
||||||
|
- **Per-link enhancements** — five new optional fields: `image` (thumbnail), `featured` (accent-bordered emphasis), `startDate`/`endDate` (build-time visibility window), `rel` (appended to safe `noopener noreferrer` baseline), `note` (small caption under title).
|
||||||
|
- **Share button** — `params.share = true` renders a `<button class="bio__share">` using `navigator.share` with `navigator.clipboard.writeText` fallback + ARIA-live toast. ~700 B JS loaded `defer` only when opted in.
|
||||||
|
- **QR code block** — `params.qr = true` renders a `<details>` collapsible with a build-time-generated QR PNG of `Permalink` via Hugo's built-in `images.QR` (Hugo ≥ 0.140).
|
||||||
|
- **vCard download** — `params.vcard = true` serves `/vcard.vcf` and renders a "Save contact" link. Requires registering the `VCard` output format in `hugo.toml` (see exampleSite).
|
||||||
|
- **Opt-in GA4 click analytics** — `[params.analytics]` block with `measurementId`, `trackClicks`, `utmSource`, `utmMedium`, `utmCampaign`. Auto-emits `data-analytics-event="link:<slug>"` per link and injects UTM query strings into external link hrefs at build time. **GA4 cookies require GDPR/PECR/CPRA consent** — see `docs/deployment-guide.md`.
|
||||||
|
- **Hugo image pipeline** — avatar now emits `<picture>` with AVIF + WebP + JPEG variants at 1x + 2x densities when `params.avatar` resolves as a local resource. External-URL and SVG-initials branches unchanged.
|
||||||
|
- **Fingerprinted + SRI-protected assets** — `bonsai.css`, `gallery.css`, `theme-toggle.js`, `share.js` now ship via `resources.Get | resources.Minify | resources.Fingerprint "sha384"` with `integrity=` + `crossorigin="anonymous"` attributes. Enables 1-year `Cache-Control: immutable` headers.
|
||||||
|
- **Auto-OG image (infra-only)** (#7) — `params.ogAuto = true` with `ogAutoBase` + `ogAutoFont` triggers a build-time `images.Filter`/`images.Text` overlay of `name` + `tagline` on a user-supplied base PNG. Theme ships template logic only — no vendored font or base PNG (30 KB binary-budget gate per validation session 1).
|
||||||
|
- **WebSite JSON-LD** — opt-in `params.schemaWebSite = true` emits a second `<script type="application/ld+json">` of `@type: WebSite` alongside existing Person schema.
|
||||||
|
- **Web manifest** — opt-in `params.manifest = true` registers a `Manifest` Hugo output format and emits `<link rel="manifest">`. Derives `name`/`theme_color`/`background_color`/icons from existing params.
|
||||||
|
- **Skip-link** — first child of `<body>` for keyboard navigation. Hidden off-screen until focused.
|
||||||
|
- **`<meta>` hardening** — `<link rel="canonical">`, `<meta property="og:url">`, `<meta name="robots">` (default `index,follow`), opt-in `<meta name="theme-color">`. Optional avatar `<link rel="preload">` for local-path avatars.
|
||||||
|
- **Avatar `<img>` attributes** — `width="112" height="112" fetchpriority="high" decoding="async"` for CLS prevention + LCP prioritization.
|
||||||
|
- **Tap-target enforcement** — `.bio__links--inline .link` now ≥ 48×48 px with ≥ 8 px gap per Lighthouse SEO / WCAG 2.5.5.
|
||||||
|
- **Hreflang alternates** — emitted automatically when site has > 1 language configured.
|
||||||
|
- **Deploy templates** — `exampleSite/static/_headers` (Netlify / Cloudflare Pages) + `exampleSite/vercel.json` with CSP, security headers, and 1-year `Cache-Control: immutable` on hashed assets.
|
||||||
|
- **`docs/deployment-guide.md`** — 4-host coverage (Netlify, Cloudflare Pages, Vercel, GitHub Pages + Cloudflare Worker overlay) with rationale per security header and GA4 consent guidance.
|
||||||
|
- **`docs/system-architecture.md`** + **`docs/code-standards.md`** — formal contributor docs.
|
||||||
|
- **`.github/workflows/lighthouse-ci.yml` + `.lighthouserc.json`** — Lighthouse CI gating. Thresholds **≥ 0.90 across all 4 categories** — tightened from the user's "≥ 80" brief during validation session 1.
|
||||||
|
- **7 new i18n keys** — `skip_to_content`, `share_label`, `share_copied`, `share_failed`, `show_qr`, `qr_alt`, `save_contact`. Both `en` and `vi` bundles updated.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Min Hugo version: `0.140.0`** (was `0.128.0`). Required for `images.QR` and modern AVIF processing.
|
||||||
|
- **CSS + JS file location**: `static/css/*.css` → `assets/css/*.css`, `static/js/*.js` → `assets/js/*.js`. Required for the new resource pipeline. End-users see no change — Hugo serves fingerprinted output at `/css/*.{hash}.css`.
|
||||||
|
- **Sections/links collision** — when both `params.sections` and `params.links` are set, sections render and a `warnf` nudges the user.
|
||||||
|
- **OG image cascade** — order is now: `ogImage=false` → suppress · `ogImageUrl` → explicit · `ogAuto` + assets → auto-generated · `avatar` → fallback.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- None. All changes additive or backward-compatible.
|
||||||
|
|
||||||
|
### Deferred
|
||||||
|
- None. v0.4's deferred items (#7 auto-OG, #9 multi-section) both shipped.
|
||||||
|
|
||||||
## [0.4.0] — 2026-05-10
|
## [0.4.0] — 2026-05-10
|
||||||
|
|
||||||
|
|||||||
+85
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[](https://github.com/tiennm99/bonsai/actions/workflows/build.yml)
|
[](https://github.com/tiennm99/bonsai/actions/workflows/build.yml)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://gohugo.io)
|
[](https://gohugo.io)
|
||||||
|
|
||||||
A minimalist Hugo theme for link-in-bio pages, inspired by [Linktree](https://linktr.ee) and the Japanese art of [bonsai](https://en.wikipedia.org/wiki/Bonsai) — *small, curated, intentional*.
|
A minimalist Hugo theme for link-in-bio pages, inspired by [Linktree](https://linktr.ee) and the Japanese art of [bonsai](https://en.wikipedia.org/wiki/Bonsai) — *small, curated, intentional*.
|
||||||
|
|
||||||
@@ -14,15 +14,32 @@ Bonsai treats your bio page the same way: a quiet, well-pruned page that surface
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
**Core**
|
||||||
- **Single-page bio** — name, avatar, tagline, links. Nothing else.
|
- **Single-page bio** — name, avatar, tagline, links. Nothing else.
|
||||||
- **Data-driven links** — defined in `[[params.links]]`; no content files required.
|
- **Data-driven links** — `[[params.links]]` flat list, OR `[[params.sections]]` for grouped sections with headings (v0.5).
|
||||||
- **45 icons out of the box** — 32 brand (GitHub, Mastodon, Bluesky, Spotify, Figma, Matrix…) + 13 utility (mail, globe, rss, heart, download…). Vendored from [Simple Icons](https://simpleicons.org) and [Lucide](https://lucide.dev).
|
- **45 icons out of the box** — 32 brand (GitHub, Mastodon, Bluesky, Spotify, Figma, Matrix…) + 13 utility (mail, globe, rss, heart, download…). Vendored from [Simple Icons](https://simpleicons.org) and [Lucide](https://lucide.dev).
|
||||||
- **Light & dark mode** — respects `prefers-color-scheme`; optional toggle.
|
- **Light & dark mode** — respects `prefers-color-scheme`; optional toggle.
|
||||||
- **Zero JavaScript by default** — pure HTML + CSS; opt-in JS for theme toggle only.
|
- **Zero JavaScript by default** — pure HTML + CSS; opt-in JS only for theme toggle / share button / GA4.
|
||||||
- **Fast** — < 3 KB gzipped CSS, no web fonts (system stack), no runtime fetches.
|
- **Fast** — ~2.4 KB gzipped CSS, no web fonts (system stack), no runtime fetches.
|
||||||
- **Accessible** — semantic HTML, focus-visible outlines, `prefers-reduced-motion`.
|
- **Accessible** — semantic HTML, skip-link, 48×48 tap targets, `focus-visible` outlines, `prefers-reduced-motion`, WCAG-AA palettes.
|
||||||
- **Responsive** — mobile-first, looks right at every viewport.
|
- **Responsive** — mobile-first, looks right at every viewport.
|
||||||
|
|
||||||
|
**Linktree-parity opt-ins (v0.5)**
|
||||||
|
- **Per-link enhancements** — `image` thumbnails, `featured` flag, `startDate`/`endDate` scheduled visibility, custom `rel`, `note` caption.
|
||||||
|
- **Share button** — `navigator.share` with clipboard fallback + ARIA-live toast (`params.share`).
|
||||||
|
- **QR code** — build-time PNG of the page Permalink via `images.QR` (`params.qr`).
|
||||||
|
- **vCard download** — generated `.vcf` at `/vcard.vcf` for one-click contact import (`params.vcard`).
|
||||||
|
- **GA4 click analytics** — `[params.analytics]` block; auto-emits `data-analytics-event` + UTM injection. *GA4 sets cookies — consent is your responsibility.*
|
||||||
|
|
||||||
|
**Lighthouse hardening (v0.5)**
|
||||||
|
- **`<picture>` avatar** — AVIF + WebP + JPEG at 1x/2x via Hugo's image processor.
|
||||||
|
- **Fingerprinted CSS/JS** with SRI `integrity=` — enables 1-year `Cache-Control: immutable`.
|
||||||
|
- **Canonical + `og:url` + theme-color + robots meta** emitted by default.
|
||||||
|
- **Hreflang alternates** automatic for multi-language sites.
|
||||||
|
- **Web manifest + WebSite JSON-LD** — opt-in via `params.manifest` / `params.schemaWebSite`.
|
||||||
|
- **Deploy templates** for Netlify / Cloudflare Pages / Vercel / GitHub Pages — see [`docs/deployment-guide.md`](./docs/deployment-guide.md).
|
||||||
|
- **Lighthouse CI** workflow gating PRs at ≥ 0.90 across Performance, A11y, Best-Practices, SEO.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### As a Git submodule (simplest)
|
### As a Git submodule (simplest)
|
||||||
@@ -98,8 +115,25 @@ disableKinds = ["taxonomy", "term", "RSS", "sitemap", "404"]
|
|||||||
| `layout` | string | `stack` | Link arrangement: `stack`, `grid`, or `inline`. See [Layout variants](#layout-variants). |
|
| `layout` | string | `stack` | Link arrangement: `stack`, `grid`, or `inline`. See [Layout variants](#layout-variants). |
|
||||||
| `themeToggle` | bool | `false` | Render a sun/moon button in the footer + load the toggle script. |
|
| `themeToggle` | bool | `false` | Render a sun/moon button in the footer + load the toggle script. |
|
||||||
| `rss` | bool | `false` | Render an RSS 2.0 feed of `[[params.links]]` at `/index.xml` and emit `<link rel="alternate">` in `<head>`. Requires removing `RSS` from `disableKinds`. |
|
| `rss` | bool | `false` | Render an RSS 2.0 feed of `[[params.links]]` at `/index.xml` and emit `<link rel="alternate">` in `<head>`. Requires removing `RSS` from `disableKinds`. |
|
||||||
|
| `share` | bool | `false` | Render a page share button using `navigator.share` with clipboard fallback. Loads `share.js` (~700 B) only when enabled. |
|
||||||
|
| `qr` | bool | `false` | Render a collapsible `<details>` block with a build-time QR code (PNG) of the page Permalink. Requires Hugo ≥ 0.140 (`images.QR`). |
|
||||||
|
| `vcard` | bool | `false` | Serve a vCard at `/vcard.vcf` and render a "Save contact" link. **Requires registering the `VCard` output format in `hugo.toml`** — see [output-format setup](#output-format-setup) below. |
|
||||||
|
| `manifest` | bool | `false` | Emit a `<link rel="manifest">` and serve `/manifest.webmanifest`. **Requires registering the `Manifest` output format in `hugo.toml`** — see [output-format setup](#output-format-setup). |
|
||||||
|
| `schemaWebSite` | bool | `false` | Emit a second JSON-LD block (`@type: WebSite`) alongside the always-on `Person` schema. Helps rich-snippet eligibility. |
|
||||||
|
| `shortName` | string | first 12 chars of `name` | PWA `short_name` in `manifest.webmanifest`. Set when your name exceeds 12 chars. |
|
||||||
|
| `themeBackground` | string (CSS color) | `#f4efe6` | PWA splash `background_color` in `manifest.webmanifest`. |
|
||||||
|
| `ogAuto` | bool | `false` | Generate a 1200×630 OG image at build time by overlaying `name` + `tagline` text on a user-supplied base PNG. Requires `ogAutoBase` + `ogAutoFont`. Infra-only in v0.5: theme does not vendor base PNG / TTF font. |
|
||||||
|
| `ogAutoBase` | string (assets path) | — | Path under `assets/` to a 1200×630 base PNG used by `ogAuto`. Example: `og/my-base.png` resolves to `assets/og/my-base.png`. |
|
||||||
|
| `ogAutoFont` | string (assets path) | — | Path under `assets/` to a TTF font used by `ogAuto` text overlay. Subset to Latin-only is recommended (≤ 15 KB). |
|
||||||
|
| `analytics` | object | — | Opt-in Google Analytics 4 — see [Analytics](#analytics) below. |
|
||||||
| `ogImage` | bool | `true` | Set `false` to suppress all `og:image` / `twitter:image` tags. |
|
| `ogImage` | bool | `true` | Set `false` to suppress all `og:image` / `twitter:image` tags. |
|
||||||
| `ogImageUrl` | string (URL) | — | Explicit OG preview image (1200×630 recommended). Overrides the avatar fallback and upgrades Twitter card to `summary_large_image`. |
|
| `ogImageUrl` | string (URL) | — | Explicit OG preview image (1200×630 recommended). Overrides the avatar fallback and upgrades Twitter card to `summary_large_image`. |
|
||||||
|
| `ogAuto` | bool | `false` | Generate a 1200×630 OG image at build time by overlaying `name` + `tagline` text on a user-supplied base PNG. Requires `ogAutoBase` + `ogAutoFont`. Infra-only in v0.5: theme does not vendor base PNG / TTF font (binary-budget gate). |
|
||||||
|
| `ogAutoBase` | string (assets path) | — | Path under `assets/` to a 1200×630 base PNG used by `ogAuto`. Example: `og/my-base.png` resolves to `assets/og/my-base.png`. |
|
||||||
|
| `ogAutoFont` | string (assets path) | — | Path under `assets/` to a TTF font used by `ogAuto` text overlay. Subset to Latin-only is recommended (≤ 15 KB). |
|
||||||
|
| `themeColor` | string (CSS color) | — | Optional `<meta name="theme-color">`. Tints mobile browser chrome (iOS Safari, Android Chrome). Set to your palette's accent for cohesive look. |
|
||||||
|
| `robots` | string | `index,follow` | Value of `<meta name="robots">`. Set `noindex,nofollow` to hide the site from search engines. |
|
||||||
|
| `preloadAvatar` | bool | `true` | Preload `params.avatar` as the LCP image via `<link rel="preload">`. Skipped automatically when avatar is an external URL or unset. |
|
||||||
| `schema` | bool | `true` | Emit schema.org `Person` JSON-LD in `<head>`. Set `false` if you provide your own. |
|
| `schema` | bool | `true` | Emit schema.org `Person` JSON-LD in `<head>`. Set `false` if you provide your own. |
|
||||||
| `jobTitle` | string | — | Optional `Person.jobTitle` field for JSON-LD. |
|
| `jobTitle` | string | — | Optional `Person.jobTitle` field for JSON-LD. |
|
||||||
| `location` | string | — | Optional `Person.address` field for JSON-LD. |
|
| `location` | string | — | Optional `Person.address` field for JSON-LD. |
|
||||||
@@ -155,6 +189,52 @@ disableKinds = ["taxonomy", "term", "sitemap", "404"]
|
|||||||
|
|
||||||
The theme renders one `<item>` per link. `pubDate` is the build time (links lack intrinsic dates), so the feed updates whenever the site rebuilds — fine for a curated bio, less ideal for high-frequency feeds.
|
The theme renders one `<item>` per link. `pubDate` is the build time (links lack intrinsic dates), so the feed updates whenever the site rebuilds — fine for a curated bio, less ideal for high-frequency feeds.
|
||||||
|
|
||||||
|
## Output-format setup
|
||||||
|
|
||||||
|
`params.vcard` and `params.manifest` rely on custom Hugo output formats. Without the registration below, the theme emits the `<link rel="manifest">` / "Save contact" anchor but Hugo doesn't generate the file itself.
|
||||||
|
|
||||||
|
Add to `hugo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mediaTypes."text/vcard"]
|
||||||
|
suffixes = ["vcf"]
|
||||||
|
|
||||||
|
[mediaTypes."application/manifest+json"]
|
||||||
|
suffixes = ["webmanifest"]
|
||||||
|
|
||||||
|
[outputs]
|
||||||
|
home = ["HTML", "RSS", "Manifest"] # add "VCard" if params.vcard = true
|
||||||
|
|
||||||
|
[outputFormats.VCard]
|
||||||
|
mediaType = "text/vcard"
|
||||||
|
baseName = "vcard"
|
||||||
|
isPlainText = true
|
||||||
|
notAlternative = true
|
||||||
|
|
||||||
|
[outputFormats.Manifest]
|
||||||
|
mediaType = "application/manifest+json"
|
||||||
|
baseName = "manifest"
|
||||||
|
isPlainText = true
|
||||||
|
notAlternative = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Only include the blocks you need. See `exampleSite/hugo.toml` for a working reference.
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
Off by default. To enable Google Analytics 4 click tracking and UTM injection on external links:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[params.analytics]
|
||||||
|
measurementId = "G-XXXXXXXXXX" # required to enable
|
||||||
|
trackClicks = true # default; emits data-analytics-event="link:<slug>" + click listener
|
||||||
|
utmSource = "bio" # if set, appended to external link hrefs at build time
|
||||||
|
utmMedium = "bio"
|
||||||
|
# utmCampaign = "summer-launch"
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **GA4 sets cookies.** Sites serving EU / UK / California visitors require explicit user consent before loading `gtag.js`. Bonsai does not ship a consent banner — pair with a consent management platform (Klaro!, Cookiebot, OneTrust) or skip GA4 entirely. See [`docs/deployment-guide.md`](./docs/deployment-guide.md#analytics--consent).
|
||||||
|
|
||||||
## i18n
|
## i18n
|
||||||
|
|
||||||
Theme-rendered strings (nav landmark, theme-toggle labels, default footer) live in `i18n/{lang}.toml`. Bundles for `en` and `vi` ship with the theme.
|
Theme-rendered strings (nav landmark, theme-toggle labels, default footer) live in `i18n/{lang}.toml`. Bundles for `en` and `vi` ship with the theme.
|
||||||
|
|||||||
@@ -364,12 +364,144 @@ body {
|
|||||||
[data-theme="light"] .theme-toggle .theme-toggle__sun { display: none; }
|
[data-theme="light"] .theme-toggle .theme-toggle__sun { display: none; }
|
||||||
[data-theme="light"] .theme-toggle .theme-toggle__moon { display: block; }
|
[data-theme="light"] .theme-toggle .theme-toggle__moon { display: block; }
|
||||||
|
|
||||||
|
/* Per-link enhancements (Phase 3 / v0.5) */
|
||||||
|
.link__thumb {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.link--featured {
|
||||||
|
padding: 1.1rem 1.4rem;
|
||||||
|
border-color: var(--bonsai-accent);
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
.link__note {
|
||||||
|
display: block;
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--bonsai-muted);
|
||||||
|
margin-top: .15rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page-level actions: share button + QR + vCard download */
|
||||||
|
.bio__share, .bio__vcard {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .5rem;
|
||||||
|
margin: 1.5rem auto 0;
|
||||||
|
padding: .65rem 1.1rem;
|
||||||
|
background: var(--bonsai-surface);
|
||||||
|
color: var(--bonsai-text);
|
||||||
|
border: 1px solid var(--bonsai-border);
|
||||||
|
border-radius: var(--bonsai-radius);
|
||||||
|
font: inherit;
|
||||||
|
font-size: .85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform .15s ease, border-color .15s ease;
|
||||||
|
}
|
||||||
|
.bio__share { display: flex; }
|
||||||
|
.bio__vcard { display: flex; }
|
||||||
|
.bio__share:hover, .bio__vcard:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: var(--bonsai-accent);
|
||||||
|
}
|
||||||
|
.bio__share:focus-visible, .bio__vcard:focus-visible {
|
||||||
|
outline: 2px solid var(--bonsai-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.bio__share-icon { display: inline-flex; width: 16px; height: 16px; color: var(--bonsai-accent); }
|
||||||
|
.bio__share-icon svg { width: 16px; height: 16px; }
|
||||||
|
.bio__share-toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 1.25rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
background: var(--bonsai-text);
|
||||||
|
color: var(--bonsai-bg);
|
||||||
|
border-radius: var(--bonsai-radius);
|
||||||
|
font-size: .85rem;
|
||||||
|
box-shadow: var(--bonsai-shadow);
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.bio__qr {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.bio__qr > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--bonsai-muted);
|
||||||
|
font-size: .85rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.bio__qr > summary::-webkit-details-marker { display: none; }
|
||||||
|
.bio__qr-img {
|
||||||
|
display: block;
|
||||||
|
margin: .75rem auto 0;
|
||||||
|
max-width: 200px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--bonsai-radius);
|
||||||
|
background: #fff;
|
||||||
|
padding: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Multi-section bio: grouped link blocks with optional headings + descriptions. */
|
||||||
|
.bio__section { margin-top: 1.75rem; }
|
||||||
|
.bio__section:first-of-type { margin-top: 1.5rem; }
|
||||||
|
.bio__section-title {
|
||||||
|
font-family: var(--bonsai-font-display);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 .35rem;
|
||||||
|
color: var(--bonsai-text);
|
||||||
|
}
|
||||||
|
.bio__section-desc {
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--bonsai-muted);
|
||||||
|
margin: 0 0 .75rem;
|
||||||
|
}
|
||||||
|
.bio__section .bio__links { margin-top: .5rem; }
|
||||||
|
|
||||||
|
/* Inline-layout tap-target enforcement (Phase 6 a11y): WCAG 2.5.5 / Lighthouse 48×48 */
|
||||||
|
.bio__links--inline .link {
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: .75rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.bio__links--inline { gap: clamp(.5rem, 1.5vw, 1rem); }
|
||||||
|
|
||||||
|
/* Skip-link: keyboard-only affordance. Off-screen until focused. */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -3rem;
|
||||||
|
left: .5rem;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
background: var(--bonsai-accent);
|
||||||
|
color: var(--bonsai-bg);
|
||||||
|
border-radius: var(--bonsai-radius);
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 100;
|
||||||
|
transition: top .15s ease;
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
top: .5rem;
|
||||||
|
outline: 2px solid var(--bonsai-text);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Note: gallery selectors (.themes-gallery, .variants-gallery) live in gallery.css
|
/* Note: gallery selectors (.themes-gallery, .variants-gallery) live in gallery.css
|
||||||
and load only on /themes/ and /variants/ demo pages via the head_extra block. */
|
and load only on /themes/ and /variants/ demo pages via the head_extra block. */
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.link, .link:hover, .link:active,
|
.link, .link:hover, .link:active,
|
||||||
.theme-toggle, .theme-toggle:hover, .theme-toggle:active {
|
.theme-toggle, .theme-toggle:hover, .theme-toggle:active,
|
||||||
|
.skip-link,
|
||||||
|
.bio__share, .bio__share:hover, .bio__vcard, .bio__vcard:hover {
|
||||||
transition: none;
|
transition: none;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,12 @@
|
|||||||
|
|
||||||
.themes-gallery__chip--accent {
|
.themes-gallery__chip--accent {
|
||||||
background: var(--bonsai-accent);
|
background: var(--bonsai-accent);
|
||||||
color: var(--bonsai-bg);
|
/* Explicit #fff: each palette's `--bonsai-bg` varies enough vs accent that
|
||||||
|
axe-core occasionally fails to resolve the var ladder and reports
|
||||||
|
contrastRatio=null. Using a literal eliminates that ambiguity and lands
|
||||||
|
at ≥ 4.5:1 against every shipped accent (sakura 4.80:1, koi 4.81:1,
|
||||||
|
bonsai 5.91:1, sumi 17.2:1). */
|
||||||
|
color: #fff;
|
||||||
border-color: var(--bonsai-accent);
|
border-color: var(--bonsai-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Bonsai page-share button.
|
||||||
|
// Tries navigator.share first; falls back to clipboard.writeText with a toast.
|
||||||
|
// Loaded only when params.share = true.
|
||||||
|
(function () {
|
||||||
|
var btn = document.querySelector('[data-bonsai-share]');
|
||||||
|
if (!btn) return;
|
||||||
|
var toast = document.querySelector('.bio__share-toast');
|
||||||
|
var toastTimer;
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
if (!toast) return;
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.hidden = false;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(function () { toast.hidden = true; }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', async function () {
|
||||||
|
var url = btn.getAttribute('data-share-url');
|
||||||
|
var title = btn.getAttribute('data-share-title');
|
||||||
|
var copiedMsg = btn.getAttribute('data-toast-copied') || 'Link copied';
|
||||||
|
var failedMsg = btn.getAttribute('data-toast-failed') || 'Could not share';
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({ title: title, url: url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
showToast(copiedMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.prompt('Copy link:', url);
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.name === 'AbortError') return;
|
||||||
|
showToast(failedMsg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
File renamed without changes.
@@ -0,0 +1,66 @@
|
|||||||
|
# Code standards
|
||||||
|
|
||||||
|
How code in this theme is written and why. The goal is predictability — both for humans reading the diff and for LLM-driven tooling navigating the repo.
|
||||||
|
|
||||||
|
## File naming
|
||||||
|
|
||||||
|
- **Kebab-case** for all filenames: `link-button.html`, `theme-toggle.js`, `schema-website.html`.
|
||||||
|
- Long descriptive names beat short cryptic ones — `analytics-loader.html` is better than `al.html`.
|
||||||
|
- Hugo partials living under `layouts/partials/` use the `.html` extension even when they emit JSON-LD or `<script>` blocks — Hugo doesn't care about extension, but consistency helps grep.
|
||||||
|
|
||||||
|
## Partial composition
|
||||||
|
|
||||||
|
Each partial reads its input via exactly one of:
|
||||||
|
|
||||||
|
1. **`site.Params` directly** — for page-wide singletons (`head.html`, `bio-card.html`, `schema-person.html`).
|
||||||
|
2. **A `dict` argument** — for reusable rendering blocks (`link-group.html`, `link-button.html`).
|
||||||
|
|
||||||
|
Never both. If a partial needs context, pass it explicitly.
|
||||||
|
|
||||||
|
## CSS conventions
|
||||||
|
|
||||||
|
- **BEM-ish naming**: `.block`, `.block__elem`, `.block--modifier`. Examples: `.bio`, `.bio__avatar`, `.bio__avatar--initials`, `.link`, `.link__icon`, `.link--featured`.
|
||||||
|
- **CSS custom properties** for every user-tunable value: colors per palette, layout sizes, fonts. Live in `:root` (layout) or `[data-bonsai-theme="…"]` blocks (color).
|
||||||
|
- **No `!important`** except the `noscript` rule hiding `.theme-toggle` when JS is disabled.
|
||||||
|
- **No deeply nested selectors** — keep specificity at ≤ (0,2,0). The single attribute selector `svg[stroke="currentColor"]` is the only exception.
|
||||||
|
- **No web fonts** — system font stacks only (`ui-sans-serif`, `system-ui`, `ui-serif`, `Georgia`).
|
||||||
|
- **Honor `prefers-reduced-motion`** — every transition lives behind a media query that disables it.
|
||||||
|
|
||||||
|
## Template patterns
|
||||||
|
|
||||||
|
- **Variable-first**: read params/inputs into named locals at the top of the partial. Branches use the locals.
|
||||||
|
- **`with` over nil-checks** where idiomatic: `{{ with $tagline }}…{{ end }}` is preferred over `{{ if $tagline }}…{{ end }}`.
|
||||||
|
- **`else if`, not `else with`** — `else with` had stricter parser rules in some Hugo versions; `else if` is universal.
|
||||||
|
- **`safeHTML` / `safeJS` / `safeCSS`** only when the value comes from a vetted source (an i18n bundle string or a Hugo `jsonify` payload). Never for user-controlled content rendered raw.
|
||||||
|
- **Warn loudly** for misconfigurations — `warnf` produces a build-time warning without failing the build.
|
||||||
|
|
||||||
|
## i18n keys
|
||||||
|
|
||||||
|
- Snake_case: `nav_links_label`, `skip_to_content`, `share_copied`.
|
||||||
|
- Every user-facing string the theme renders goes through `{{ i18n "key" }}`. Add a fallback default with `| default "…"` so missing keys don't leak raw identifiers into the DOM.
|
||||||
|
- Both `en.toml` and `vi.toml` must be updated together for any new key.
|
||||||
|
|
||||||
|
## Asset handling
|
||||||
|
|
||||||
|
- **All processable assets live under `assets/`** (`assets/css/`, `assets/js/`, `assets/icons/`, optional `assets/avatars/`, `assets/og/`, `assets/fonts/`).
|
||||||
|
- **`static/`** is for files Hugo should copy through verbatim — `_headers`, `vercel.json`, demo media that won't be processed.
|
||||||
|
- **Fingerprint + minify + SRI** every CSS and JS file shipped to production: `resources.Get | resources.Minify | resources.Fingerprint "sha384"` → emit with `integrity=` + `crossorigin="anonymous"`.
|
||||||
|
|
||||||
|
## Opt-in posture
|
||||||
|
|
||||||
|
Every new feature is **off by default**. A v0.4 site upgraded to v0.5 must build with byte-identical output unless the user explicitly opts in. This rule has zero exceptions.
|
||||||
|
|
||||||
|
When unsure: ship with a `param.foo = false` default and document the opt-in in README.
|
||||||
|
|
||||||
|
## Build invariants
|
||||||
|
|
||||||
|
- `hugo --gc --minify --themesDir ../..` from `exampleSite/` must produce a clean build with no `ERROR` lines.
|
||||||
|
- Lighthouse CI must pass thresholds ≥ 0.90 across Performance, Accessibility, Best-Practices, SEO.
|
||||||
|
- No new `console.error` / `console.warn` from theme JS at runtime.
|
||||||
|
- CSS production size budget: ≤ 5.5 KB gzipped (stretch ≤ 5 KB).
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`, `test:`, `style:`. Scope is optional but helpful: `feat(theme): add multi-section bio`.
|
||||||
|
|
||||||
|
No AI references in commit messages. Keep them describing the *change*, not the *process*.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Deployment guide
|
||||||
|
|
||||||
|
Bonsai is a Hugo theme — the built `public/` directory is plain static files. You can host it anywhere. This guide covers four common targets and the security/cache headers that move Lighthouse Best-Practices and Performance scores into the ≥90 range.
|
||||||
|
|
||||||
|
## What Bonsai ships
|
||||||
|
|
||||||
|
Two example header files live under `exampleSite/`:
|
||||||
|
|
||||||
|
- [`exampleSite/static/_headers`](../exampleSite/static/_headers) — Netlify / Cloudflare Pages format. Copy to your site root.
|
||||||
|
- [`exampleSite/vercel.json`](../exampleSite/vercel.json) — Vercel format. Copy to your repo root.
|
||||||
|
|
||||||
|
Both apply identical policies; pick the one matching your host.
|
||||||
|
|
||||||
|
## Why these headers
|
||||||
|
|
||||||
|
| Header | Lighthouse audit | What it does |
|
||||||
|
|--------|-----------------|--------------|
|
||||||
|
| `X-Content-Type-Options: nosniff` | Best-Practices | Stops browsers MIME-sniffing responses. |
|
||||||
|
| `X-Frame-Options: SAMEORIGIN` | Best-Practices | Prevents clickjacking via iframe embedding. |
|
||||||
|
| `Referrer-Policy: strict-origin-when-cross-origin` | Best-Practices | Limits referer leakage on cross-origin requests. |
|
||||||
|
| `Permissions-Policy: camera=(), microphone=(), geolocation=()` | Best-Practices | Disables unused powerful APIs. |
|
||||||
|
| `Content-Security-Policy: …` | Best-Practices + XSS resilience | Restricts script/style/image sources. |
|
||||||
|
| `Cache-Control: public, max-age=31536000, immutable` (on `/css/*`, `/js/*`, `/icons/*`, `/images/*`) | Performance (repeat visits) | 1-year cache on fingerprinted assets. |
|
||||||
|
| `Cache-Control: public, max-age=3600, must-revalidate` (on `/`, `/index.html`) | Performance | 1-hour cache on HTML; lets you push content updates fast. |
|
||||||
|
|
||||||
|
## CSP and Bonsai's defaults
|
||||||
|
|
||||||
|
The shipped CSP is permissive enough to support every opt-in Bonsai feature:
|
||||||
|
|
||||||
|
- `script-src 'self' 'unsafe-inline' https://www.googletagmanager.com` — `'unsafe-inline'` is required by the theme-toggle FOUC script and the GA4 init snippet. `googletagmanager.com` lets the GA4 loader fetch `gtag.js`.
|
||||||
|
- `style-src 'self' 'unsafe-inline'` — required by inline `style=` attrs on the SVG initials avatar.
|
||||||
|
- `img-src 'self' data: https:` — covers QR-code data URLs and external image avatars.
|
||||||
|
- `connect-src 'self' https://www.google-analytics.com` — allows GA4 to POST events.
|
||||||
|
|
||||||
|
If you don't use the theme toggle and don't use GA4, you can tighten CSP to `script-src 'self'` and `connect-src 'self'`. Strict-CSP with nonces is out of scope for v0.5.
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
### Netlify
|
||||||
|
|
||||||
|
1. Copy `exampleSite/static/_headers` into your site's `static/` directory. Hugo will copy it to `public/_headers`, which Netlify reads on deploy.
|
||||||
|
2. Build command: `hugo --gc --minify`.
|
||||||
|
3. Publish directory: `public`.
|
||||||
|
4. Brotli + gzip applied automatically.
|
||||||
|
|
||||||
|
### Cloudflare Pages
|
||||||
|
|
||||||
|
Same `_headers` format as Netlify. Build settings same. Brotli applied automatically.
|
||||||
|
|
||||||
|
### Vercel
|
||||||
|
|
||||||
|
1. Copy `exampleSite/vercel.json` to your repo root.
|
||||||
|
2. Vercel auto-detects Hugo from `theme.toml` / `config.toml`. Build command: `hugo --gc --minify`. Output: `public`.
|
||||||
|
3. Brotli applied automatically.
|
||||||
|
|
||||||
|
### GitHub Pages
|
||||||
|
|
||||||
|
GitHub Pages does **not** support custom response headers. Two options:
|
||||||
|
|
||||||
|
- **Front with Cloudflare:** Point your domain to Cloudflare DNS, then origin to `username.github.io`. Apply security headers via a Cloudflare Worker — example below.
|
||||||
|
- **Switch to Cloudflare Pages or Netlify:** Free tier, custom domain, full header control. Easiest path.
|
||||||
|
|
||||||
|
Minimal Cloudflare Worker overlay:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const response = await fetch(request);
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
headers.set('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
headers.set('Content-Security-Policy',
|
||||||
|
"default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'");
|
||||||
|
return new Response(response.body, { status: response.status, headers });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Analytics & consent
|
||||||
|
|
||||||
|
Bonsai's optional GA4 integration sets cookies. In jurisdictions with strict privacy law you must obtain user consent **before** loading `gtag.js`:
|
||||||
|
|
||||||
|
- **GDPR (EU):** Explicit consent required for non-essential cookies.
|
||||||
|
- **UK PECR:** Same.
|
||||||
|
- **California CPRA:** Disclosure + opt-out required.
|
||||||
|
|
||||||
|
Bonsai does **not** ship a consent banner. Pair with a consent management platform (Klaro!, Cookiebot, OneTrust) or skip GA4 entirely.
|
||||||
|
|
||||||
|
If you don't enable `[params.analytics]`, zero analytics scripts ship — no consent banner needed.
|
||||||
|
|
||||||
|
## HTTPS
|
||||||
|
|
||||||
|
All recommended hosts (Netlify, Vercel, Cloudflare Pages, GitHub Pages) enforce HTTPS by default. Ensure your `baseURL` in `hugo.toml` starts with `https://`.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# System architecture
|
||||||
|
|
||||||
|
Bonsai is a [Hugo](https://gohugo.io) theme — a directory of templates, partials, CSS, JS, and data files that Hugo reads when building a user's site. There is no runtime: every page, JSON-LD block, OG meta tag, QR PNG, AVIF/WebP image variant, vCard, RSS feed, and Lighthouse-relevant output is produced at `hugo build` time.
|
||||||
|
|
||||||
|
## Directory layout
|
||||||
|
|
||||||
|
```
|
||||||
|
bonsai/
|
||||||
|
├── theme.toml # Hugo theme metadata (name, min_version, license)
|
||||||
|
├── data/icons.yaml # Public icon name → {family, slug} manifest
|
||||||
|
├── i18n/ # Localized UI strings (en, vi shipped; extensible)
|
||||||
|
├── assets/ # Hugo resource root (image/CSS/JS pipeline reads from here)
|
||||||
|
│ ├── css/ # Stylesheets (fingerprinted at build time)
|
||||||
|
│ ├── js/ # Optional scripts (theme-toggle, share)
|
||||||
|
│ └── icons/ # Vendored SVG icons (Simple Icons + Lucide)
|
||||||
|
├── layouts/
|
||||||
|
│ ├── _default/baseof.html # HTML5 skeleton
|
||||||
|
│ ├── index.html # Home page entry (calls bio-card partial)
|
||||||
|
│ ├── index.rss.xml # RSS 2.0 feed of links/sections
|
||||||
|
│ ├── _default/vcard.vcf # vCard output template (opt-in)
|
||||||
|
│ ├── _default/manifest.webmanifest # PWA manifest (opt-in)
|
||||||
|
│ └── partials/
|
||||||
|
│ ├── head.html # All <head> content
|
||||||
|
│ ├── bio-card.html # Main bio card with sections/links branching
|
||||||
|
│ ├── link-button.html # Individual link (with thumbnail / featured / scheduled / rel / note)
|
||||||
|
│ ├── link-group.html # Renders one <nav> of links (used by flat + sectioned modes)
|
||||||
|
│ ├── icon.html # Inline SVG icon lookup (data/icons.yaml → assets/icons/)
|
||||||
|
│ ├── avatar.html # Avatar: <picture> for local, <img> for URL, SVG initials fallback
|
||||||
|
│ ├── share-button.html # Opt-in Web Share button
|
||||||
|
│ ├── qr-block.html # Opt-in QR code block (images.QR)
|
||||||
|
│ ├── analytics-loader.html # Opt-in GA4 loader + click listener
|
||||||
|
│ ├── schema-person.html # JSON-LD ProfilePage > Person
|
||||||
|
│ ├── schema-website.html # Opt-in JSON-LD WebSite
|
||||||
|
│ ├── theme-toggle-button.html
|
||||||
|
│ └── footer.html
|
||||||
|
│ ├── themes/single.html # Demo: color-palette gallery
|
||||||
|
│ ├── variants/single.html # Demo: layout-variant gallery
|
||||||
|
│ └── icons/single.html # Demo: icon gallery
|
||||||
|
└── exampleSite/ # Self-contained demo + reference config
|
||||||
|
├── hugo.toml # Showcases every opt-in param
|
||||||
|
├── static/_headers # Netlify / Cloudflare Pages header file
|
||||||
|
├── vercel.json # Vercel header config
|
||||||
|
└── content/ # Demo content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build-time pipelines
|
||||||
|
|
||||||
|
### 1. CSS / JS pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/css/bonsai.css
|
||||||
|
└─ resources.Get
|
||||||
|
└─ resources.Minify (strip whitespace, comments)
|
||||||
|
└─ resources.Fingerprint "sha384"
|
||||||
|
└─ /css/bonsai.min.<sha>.css + integrity="sha384-..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Same shape for `gallery.css`, `theme-toggle.js`, `share.js`. The fingerprinted output lets hosts apply `Cache-Control: public, max-age=31536000, immutable` to `/css/*` and `/js/*` safely — any content change produces a new filename hash.
|
||||||
|
|
||||||
|
### 2. Image pipeline
|
||||||
|
|
||||||
|
When `params.avatar` resolves as a local asset:
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/avatars/me.jpg
|
||||||
|
├─ .Process "resize 112x112 jpg q85" → JPEG 1x
|
||||||
|
├─ .Process "resize 224x224 jpg q85" → JPEG 2x
|
||||||
|
├─ .Process "resize 112x112 webp q75" → WebP 1x
|
||||||
|
├─ .Process "resize 224x224 webp q75" → WebP 2x
|
||||||
|
├─ .Process "resize 112x112 avif q60" → AVIF 1x
|
||||||
|
└─ .Process "resize 224x224 avif q60" → AVIF 2x
|
||||||
|
↓
|
||||||
|
<picture>
|
||||||
|
<source type="image/avif" srcset="… 1x, … 2x">
|
||||||
|
<source type="image/webp" srcset="… 1x, … 2x">
|
||||||
|
<img src=jpeg-1x srcset=…>
|
||||||
|
</picture>
|
||||||
|
```
|
||||||
|
|
||||||
|
External-URL avatars (`https://…`) skip the pipeline and emit a plain `<img>` with Phase-1 attributes (width/height/fetchpriority/decoding).
|
||||||
|
|
||||||
|
### 3. QR pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
images.QR <Permalink> {level: medium, scale: 4}
|
||||||
|
└─ Build-time PNG at /qr_<hash>.png
|
||||||
|
↓
|
||||||
|
<img class="bio__qr-img" src="…" width="…" height="…" alt="…">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Auto-OG (infra-only in v0.5)
|
||||||
|
|
||||||
|
```
|
||||||
|
$base := resources.Get params.ogAutoBase (1200×630 PNG)
|
||||||
|
$font := resources.Get params.ogAutoFont (TTF, latin subset recommended)
|
||||||
|
$base | images.Filter (images.Text $name {size:72, x:80, y:220, font:$font})
|
||||||
|
| images.Filter (images.Text $tagline {size:36, x:80, y:340, font:$font})
|
||||||
|
└─ Generated 1200×630 → og:image + twitter:card=summary_large_image
|
||||||
|
```
|
||||||
|
|
||||||
|
Theme ships no base PNG or font — user supplies via `params.ogAutoBase`/`ogAutoFont` (paths relative to `assets/`).
|
||||||
|
|
||||||
|
### 5. JSON-LD strategy
|
||||||
|
|
||||||
|
Always: `ProfilePage > Person` (suppressible via `params.schema = false`).
|
||||||
|
Opt-in: `WebSite` (via `params.schemaWebSite = true`) — emitted as a second `<script type="application/ld+json">` block. Google supports multiple JSON-LD blocks per page.
|
||||||
|
|
||||||
|
### 6. i18n flow
|
||||||
|
|
||||||
|
Every user-facing string the theme renders is sourced from `i18n/<lang>.toml`. Hugo's `i18n` function falls back from the active language to `en` on missing keys. User content (`name`, `tagline`, `bio`, link `title`s, `footerText`) is never auto-translated.
|
||||||
|
|
||||||
|
## Lighthouse-relevant signals (what the theme emits)
|
||||||
|
|
||||||
|
| Category | Signal | Source |
|
||||||
|
|----------|--------|--------|
|
||||||
|
| Performance | Avatar dims + `fetchpriority="high"` + `decoding="async"` | `partials/avatar.html` |
|
||||||
|
| Performance | Avatar preload (when local) | `partials/head.html` |
|
||||||
|
| Performance | Modern image formats (AVIF/WebP/JPEG `<picture>`) | `partials/avatar.html` |
|
||||||
|
| Performance | Fingerprinted CSS / JS with SRI | `partials/head.html` |
|
||||||
|
| Performance | Zero web fonts, system stack | `assets/css/bonsai.css` |
|
||||||
|
| A11y | Skip-link, `<nav aria-label>`, semantic landmarks | `baseof.html`, `bio-card.html` |
|
||||||
|
| A11y | Tap targets ≥ 48×48 on inline layout | `assets/css/bonsai.css` |
|
||||||
|
| A11y | `prefers-reduced-motion` honored | `assets/css/bonsai.css` |
|
||||||
|
| A11y | WCAG-AA accent colors | palette CSS |
|
||||||
|
| Best-Practices | Security headers (CSP, X-Frame, Referrer-Policy, Permissions-Policy) | `exampleSite/static/_headers` + `vercel.json` |
|
||||||
|
| Best-Practices | `rel="noopener noreferrer"` on external links | `link-button.html` |
|
||||||
|
| Best-Practices | SRI `integrity=` on assets | `partials/head.html` |
|
||||||
|
| SEO | `<link rel="canonical">` | `partials/head.html` |
|
||||||
|
| SEO | `<meta property="og:url">`, `og:type`, `og:title`, `og:description`, `og:image` | `partials/head.html` |
|
||||||
|
| SEO | Hreflang alternates (multi-lang sites) | `partials/head.html` |
|
||||||
|
| SEO | JSON-LD Person (always) + optional WebSite | `schema-person.html`, `schema-website.html` |
|
||||||
|
| SEO | `<meta name="robots">` configurable | `partials/head.html` |
|
||||||
|
| SEO | Tap-target sizing | `assets/css/bonsai.css` |
|
||||||
|
|
||||||
|
## Backward-compatibility contract
|
||||||
|
|
||||||
|
- Every new param added in v0.5 defaults to off OR has a safe default that preserves v0.4 behavior.
|
||||||
|
- `[[params.links]]` continues to work unchanged when `[[params.sections]]` is absent.
|
||||||
|
- Avatar URL paths from `static/` still work via the fallback branch in `partials/avatar.html`.
|
||||||
|
- The CSS/JS file move from `static/` to `assets/` is invisible to user sites — the fingerprinted output is served at `/css/*` and `/js/*`.
|
||||||
@@ -7,6 +7,29 @@ theme = "bonsai"
|
|||||||
# v0.4 demo (set params.rss = true and remove "RSS" from disableKinds).
|
# v0.4 demo (set params.rss = true and remove "RSS" from disableKinds).
|
||||||
disableKinds = ["taxonomy", "term", "sitemap", "404"]
|
disableKinds = ["taxonomy", "term", "sitemap", "404"]
|
||||||
|
|
||||||
|
# Register VCard + Manifest output formats so layouts/_default/vcard.vcf and
|
||||||
|
# layouts/_default/manifest.webmanifest get rendered when their params are on.
|
||||||
|
[mediaTypes."text/vcard"]
|
||||||
|
suffixes = ["vcf"]
|
||||||
|
|
||||||
|
[mediaTypes."application/manifest+json"]
|
||||||
|
suffixes = ["webmanifest"]
|
||||||
|
|
||||||
|
[outputs]
|
||||||
|
home = ["HTML", "RSS", "Manifest"]
|
||||||
|
|
||||||
|
[outputFormats.VCard]
|
||||||
|
mediaType = "text/vcard"
|
||||||
|
baseName = "vcard"
|
||||||
|
isPlainText = true
|
||||||
|
notAlternative = true
|
||||||
|
|
||||||
|
[outputFormats.Manifest]
|
||||||
|
mediaType = "application/manifest+json"
|
||||||
|
baseName = "manifest"
|
||||||
|
isPlainText = true
|
||||||
|
notAlternative = true
|
||||||
|
|
||||||
[params]
|
[params]
|
||||||
name = "Tien Nguyen"
|
name = "Tien Nguyen"
|
||||||
tagline = "Software engineer · tending small things with care"
|
tagline = "Software engineer · tending small things with care"
|
||||||
@@ -21,6 +44,41 @@ disableKinds = ["taxonomy", "term", "sitemap", "404"]
|
|||||||
footerText = "© 2026 · made with bonsai"
|
footerText = "© 2026 · made with bonsai"
|
||||||
# ogImageUrl = "/og.png" # optional 1200×630 image for social previews
|
# ogImageUrl = "/og.png" # optional 1200×630 image for social previews
|
||||||
|
|
||||||
|
# Phase 1 (v0.5) opt-ins:
|
||||||
|
themeColor = "#c93f63" # matches the sakura palette accent; tints mobile browser chrome
|
||||||
|
favicon = "/favicon.ico" # opt-in in v0.5 — set to avoid console 404 on /favicon.ico request
|
||||||
|
# robots = "index,follow" # default; set "noindex,nofollow" to hide from search engines
|
||||||
|
# preloadAvatar = true # default true; preloads params.avatar as LCP image (local paths only)
|
||||||
|
|
||||||
|
# Phase 6 (v0.5) opt-ins:
|
||||||
|
schemaWebSite = true # emit secondary WebSite JSON-LD for richer search results
|
||||||
|
manifest = true # serve /manifest.webmanifest + emit <link rel="manifest">
|
||||||
|
# themeBackground = "#fff5f5" # PWA splash background; defaults to a sensible neutral
|
||||||
|
|
||||||
|
# Phase 3 (v0.5) page-level opt-ins:
|
||||||
|
share = true # render share button (Web Share API + clipboard fallback)
|
||||||
|
qr = true # render collapsible QR code of this page Permalink
|
||||||
|
# vcard = true # serve /vcard.vcf and render a "Save contact" link
|
||||||
|
|
||||||
|
# Phase 4 (v0.5) opt-in click analytics — Google Analytics 4.
|
||||||
|
# GA4 sets cookies — GDPR/PECR/CPRA consent is the site operator's responsibility.
|
||||||
|
# See docs/deployment-guide.md for the consent caveat.
|
||||||
|
# [params.analytics]
|
||||||
|
# measurementId = "G-XXXXXXXXXX"
|
||||||
|
# trackClicks = true # auto-emit data-analytics-event="link:<slug>" per link
|
||||||
|
# utmSource = "bio"
|
||||||
|
# utmMedium = "bio"
|
||||||
|
# # utmCampaign = "summer-launch"
|
||||||
|
|
||||||
|
# Register VCard output format on home page (only needed when vcard = true)
|
||||||
|
# [outputs]
|
||||||
|
# home = ["HTML", "RSS", "VCard"]
|
||||||
|
# [outputFormats.VCard]
|
||||||
|
# mediaType = "text/vcard"
|
||||||
|
# baseName = "vcard"
|
||||||
|
# isPlainText = true
|
||||||
|
# notAlternative = true
|
||||||
|
|
||||||
[[params.links]]
|
[[params.links]]
|
||||||
title = "GitHub"
|
title = "GitHub"
|
||||||
url = "https://github.com/tiennm99"
|
url = "https://github.com/tiennm99"
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-Frame-Options: SAMEORIGIN
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||||
|
Content-Security-Policy: default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://www.google-analytics.com https://*.analytics.google.com https://*.google-analytics.com https://*.googletagmanager.com; font-src 'self'; frame-ancestors 'self'
|
||||||
|
|
||||||
|
/css/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
|
/js/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
|
/icons/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
|
/images/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
|
/index.html
|
||||||
|
Cache-Control: public, max-age=3600, must-revalidate
|
||||||
|
|
||||||
|
/
|
||||||
|
Cache-Control: public, max-age=3600, must-revalidate
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
|
"cleanUrls": true,
|
||||||
|
"trailingSlash": false,
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "X-Content-Type-Options", "value": "nosniff" },
|
||||||
|
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
|
||||||
|
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
|
||||||
|
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" },
|
||||||
|
{ "key": "Content-Security-Policy", "value": "default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://www.google-analytics.com https://*.analytics.google.com https://*.google-analytics.com https://*.googletagmanager.com; font-src 'self'; frame-ancestors 'self'" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/css/(.*)",
|
||||||
|
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/js/(.*)",
|
||||||
|
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/icons/(.*)",
|
||||||
|
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/images/(.*)",
|
||||||
|
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -12,3 +12,24 @@ other = "Toggle theme"
|
|||||||
|
|
||||||
[footer_default]
|
[footer_default]
|
||||||
other = "© {{ .year }} {{ .name }}"
|
other = "© {{ .year }} {{ .name }}"
|
||||||
|
|
||||||
|
[skip_to_content]
|
||||||
|
other = "Skip to content"
|
||||||
|
|
||||||
|
[share_label]
|
||||||
|
other = "Share this page"
|
||||||
|
|
||||||
|
[share_copied]
|
||||||
|
other = "Link copied"
|
||||||
|
|
||||||
|
[share_failed]
|
||||||
|
other = "Could not share"
|
||||||
|
|
||||||
|
[show_qr]
|
||||||
|
other = "Show QR code"
|
||||||
|
|
||||||
|
[qr_alt]
|
||||||
|
other = "QR code linking to this page"
|
||||||
|
|
||||||
|
[save_contact]
|
||||||
|
other = "Save contact"
|
||||||
@@ -11,3 +11,24 @@ other = "Đổi giao diện"
|
|||||||
|
|
||||||
[footer_default]
|
[footer_default]
|
||||||
other = "© {{ .year }} {{ .name }}"
|
other = "© {{ .year }} {{ .name }}"
|
||||||
|
|
||||||
|
[skip_to_content]
|
||||||
|
other = "Bỏ qua tới nội dung"
|
||||||
|
|
||||||
|
[share_label]
|
||||||
|
other = "Chia sẻ trang này"
|
||||||
|
|
||||||
|
[share_copied]
|
||||||
|
other = "Đã sao chép liên kết"
|
||||||
|
|
||||||
|
[share_failed]
|
||||||
|
other = "Không thể chia sẻ"
|
||||||
|
|
||||||
|
[show_qr]
|
||||||
|
other = "Hiện mã QR"
|
||||||
|
|
||||||
|
[qr_alt]
|
||||||
|
other = "Mã QR liên kết tới trang này"
|
||||||
|
|
||||||
|
[save_contact]
|
||||||
|
other = "Lưu danh thiếp"
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
{{- block "head_extra" . }}{{- end }}
|
{{- block "head_extra" . }}{{- end }}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="bonsai">
|
<a class="skip-link" href="#main">{{ i18n "skip_to_content" | default "Skip to content" }}</a>
|
||||||
|
<main class="bonsai" id="main">
|
||||||
{{- block "main" . }}{{- end }}
|
{{- block "main" . }}{{- end }}
|
||||||
</main>
|
</main>
|
||||||
{{- partial "footer.html" . -}}
|
{{- partial "footer.html" . -}}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{{- $name := site.Params.name | default site.Title -}}
|
||||||
|
{{- $shortName := site.Params.shortName | default (substr $name 0 12) -}}
|
||||||
|
{{- $themeColor := site.Params.themeColor | default "#8b3a2b" -}}
|
||||||
|
{{- $bgColor := site.Params.themeBackground | default "#f4efe6" -}}
|
||||||
|
{{- /* start_url and scope use relURL so subpath deployments (e.g. GitHub Pages
|
||||||
|
project sites at /repo/) install correctly. */ -}}
|
||||||
|
{{- $root := "/" | relURL -}}
|
||||||
|
{{- $hasIcons := or site.Params.appleTouchIcon site.Params.faviconSvg -}}
|
||||||
|
{
|
||||||
|
"name": "{{ $name }}",
|
||||||
|
"short_name": "{{ $shortName }}",
|
||||||
|
"start_url": "{{ $root }}",
|
||||||
|
"scope": "{{ $root }}",
|
||||||
|
"display": "minimal-ui",
|
||||||
|
"background_color": "{{ $bgColor }}",
|
||||||
|
"theme_color": "{{ $themeColor }}"
|
||||||
|
{{- if $hasIcons -}}
|
||||||
|
,
|
||||||
|
"icons": [
|
||||||
|
{{- $first := true -}}
|
||||||
|
{{- with site.Params.appleTouchIcon -}}
|
||||||
|
{{- if $first }}{{ $first = false }}{{ else }},{{ end }}
|
||||||
|
{ "src": "{{ . | relURL }}", "sizes": "180x180", "type": "image/png" }
|
||||||
|
{{- end -}}
|
||||||
|
{{- with site.Params.faviconSvg -}}
|
||||||
|
{{- if $first }}{{ $first = false }}{{ else }},{{ end }}
|
||||||
|
{ "src": "{{ . | relURL }}", "sizes": "any", "type": "image/svg+xml" }
|
||||||
|
{{- end -}}
|
||||||
|
]
|
||||||
|
{{- end }}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{- $name := site.Params.name | default site.Title -}}
|
||||||
|
{{- /* Flatten links + sections for URL lines. */ -}}
|
||||||
|
{{- $allLinks := slice -}}
|
||||||
|
{{- if site.Params.sections -}}
|
||||||
|
{{- range site.Params.sections -}}{{- range .links -}}{{- $allLinks = $allLinks | append . -}}{{- end -}}{{- end -}}
|
||||||
|
{{- else -}}{{- $allLinks = site.Params.links -}}{{- end -}}
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:{{ $name }}
|
||||||
|
{{ with site.Params.jobTitle }}TITLE:{{ . }}
|
||||||
|
{{ end -}}
|
||||||
|
{{ with site.Params.email }}EMAIL:{{ . }}
|
||||||
|
{{ end -}}
|
||||||
|
{{ with site.Params.location }}ADR:;;{{ . }};;;;
|
||||||
|
{{ end -}}
|
||||||
|
URL:{{ site.BaseURL }}
|
||||||
|
{{ range $allLinks -}}
|
||||||
|
{{- if and .url (not (or (strings.HasPrefix .url "mailto:") (strings.HasPrefix .url "tel:"))) -}}
|
||||||
|
URL;TYPE={{ .title | upper }}:{{ .url }}
|
||||||
|
{{ end -}}
|
||||||
|
{{- end -}}
|
||||||
|
END:VCARD
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
{{- range $name, $entry := site.Data.icons }}
|
{{- range $name, $entry := site.Data.icons }}
|
||||||
<div style="display:flex;flex-direction:column;align-items:center;gap:0.4rem;width:80px;">
|
<div style="display:flex;flex-direction:column;align-items:center;gap:0.4rem;width:80px;">
|
||||||
<span style="width:20px;height:20px;">{{ partial "icon.html" $name }}</span>
|
<span style="width:20px;height:20px;">{{ partial "icon.html" $name }}</span>
|
||||||
<span style="font-size:0.7rem;text-align:center;word-break:break-all;">{{ $name }}</span>
|
<span style="font-size:0.75rem;text-align:center;word-break:break-all;">{{ $name }}</span>
|
||||||
</div>
|
</div>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,16 @@
|
|||||||
<language>{{ site.Language.LanguageCode | default site.LanguageCode }}</language>
|
<language>{{ site.Language.LanguageCode | default site.LanguageCode }}</language>
|
||||||
<lastBuildDate>{{ $now.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>
|
<lastBuildDate>{{ $now.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>
|
||||||
<atom:link href="{{ "index.xml" | absURL }}" rel="self" type="application/rss+xml" />
|
<atom:link href="{{ "index.xml" | absURL }}" rel="self" type="application/rss+xml" />
|
||||||
{{- range site.Params.links }}
|
{{- /* Flatten params.links + params.sections.*.links into a single list. */ -}}
|
||||||
|
{{- $allLinks := slice -}}
|
||||||
|
{{- if site.Params.sections -}}
|
||||||
|
{{- range site.Params.sections -}}
|
||||||
|
{{- range .links -}}{{- $allLinks = $allLinks | append . -}}{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $allLinks = site.Params.links -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- range $allLinks }}
|
||||||
<item>
|
<item>
|
||||||
<title>{{ .title | transform.XMLEscape }}</title>
|
<title>{{ .title | transform.XMLEscape }}</title>
|
||||||
<link>{{ .url }}</link>
|
<link>{{ .url }}</link>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{- /* Google Analytics 4 loader.
|
||||||
|
Emits nothing when params.analytics.measurementId is unset/empty.
|
||||||
|
Pairs with data-analytics-event attrs added by partials/link-button.html.
|
||||||
|
GA4 sets cookies — site operator is responsible for GDPR/PECR/CPRA consent. */ -}}
|
||||||
|
{{- $a := site.Params.analytics -}}
|
||||||
|
{{- if and $a $a.measurementId -}}
|
||||||
|
{{- $id := $a.measurementId -}}
|
||||||
|
{{- $trackClicks := ne ($a.trackClicks | default true) false -}}
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $id }}"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}
|
||||||
|
gtag('js',new Date());
|
||||||
|
gtag('config','{{ $id }}');
|
||||||
|
{{- if $trackClicks }}
|
||||||
|
document.addEventListener('click',function(e){var a=e.target.closest('[data-analytics-event]');if(a)gtag('event','outbound_click',{event_label:a.getAttribute('data-analytics-event'),link_url:a.href});});
|
||||||
|
{{- end }}
|
||||||
|
</script>
|
||||||
|
{{- end -}}
|
||||||
@@ -1,13 +1,43 @@
|
|||||||
{{- /* Render bio avatar.
|
{{- /* Render bio avatar.
|
||||||
If params.avatar is set, render <img>.
|
- If params.avatar resolves as a local asset (assets/ or static/ path),
|
||||||
Otherwise, render an inline SVG circle with initials in accent color.
|
emit <picture> with AVIF + WebP + JPEG fallback at 1x/2x densities.
|
||||||
Initials override: params.avatarInitials.
|
- If params.avatar is an absolute URL, emit plain <img> (no processing).
|
||||||
Background override: params.avatarBg (defaults to --bonsai-accent). */ -}}
|
- If unset, emit inline SVG circle with initials in accent color.
|
||||||
|
Initials override: params.avatarInitials. Background: params.avatarBg. */ -}}
|
||||||
{{- $name := site.Params.name | default site.Title -}}
|
{{- $name := site.Params.name | default site.Title -}}
|
||||||
{{- $avatar := site.Params.avatar -}}
|
{{- $avatar := site.Params.avatar -}}
|
||||||
|
|
||||||
{{- if $avatar -}}
|
{{- if $avatar -}}
|
||||||
<img class="bio__avatar" src="{{ $avatar | strings.TrimPrefix "/" | relURL }}" alt="{{ $name }}" loading="eager" />
|
{{- $isHTTP := strings.HasPrefix $avatar "http" -}}
|
||||||
|
{{- $local := false -}}
|
||||||
|
{{- $res := "" -}}
|
||||||
|
{{- if not $isHTTP -}}
|
||||||
|
{{- $clean := strings.TrimPrefix "/" $avatar -}}
|
||||||
|
{{- with resources.Get $clean -}}
|
||||||
|
{{- $res = . -}}
|
||||||
|
{{- $local = true -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $local -}}
|
||||||
|
{{- /* Hugo image pipeline: AVIF + WebP + JPEG, 1x + 2x for retina. */ -}}
|
||||||
|
{{- $jpg1 := $res.Process "resize 112x112 jpg q85" -}}
|
||||||
|
{{- $jpg2 := $res.Process "resize 224x224 jpg q85" -}}
|
||||||
|
{{- $webp1 := $res.Process "resize 112x112 webp q75" -}}
|
||||||
|
{{- $webp2 := $res.Process "resize 224x224 webp q75" -}}
|
||||||
|
{{- $avif1 := $res.Process "resize 112x112 avif q60" -}}
|
||||||
|
{{- $avif2 := $res.Process "resize 224x224 avif q60" -}}
|
||||||
|
<picture>
|
||||||
|
<source type="image/avif" srcset="{{ $avif1.RelPermalink }} 1x, {{ $avif2.RelPermalink }} 2x" />
|
||||||
|
<source type="image/webp" srcset="{{ $webp1.RelPermalink }} 1x, {{ $webp2.RelPermalink }} 2x" />
|
||||||
|
<img class="bio__avatar" src="{{ $jpg1.RelPermalink }}"
|
||||||
|
srcset="{{ $jpg1.RelPermalink }} 1x, {{ $jpg2.RelPermalink }} 2x"
|
||||||
|
width="112" height="112" alt="{{ $name }}"
|
||||||
|
loading="eager" fetchpriority="high" decoding="async" />
|
||||||
|
</picture>
|
||||||
|
{{- else -}}
|
||||||
|
<img class="bio__avatar" src="{{ $avatar | strings.TrimPrefix "/" | relURL }}" alt="{{ $name }}"
|
||||||
|
width="112" height="112" loading="eager" fetchpriority="high" decoding="async" />
|
||||||
|
{{- end -}}
|
||||||
{{- else -}}
|
{{- else -}}
|
||||||
{{- $initials := site.Params.avatarInitials -}}
|
{{- $initials := site.Params.avatarInitials -}}
|
||||||
{{- if not $initials -}}
|
{{- if not $initials -}}
|
||||||
@@ -20,8 +50,6 @@
|
|||||||
{{- if not $initials -}}{{- $initials = "?" -}}{{- end -}}
|
{{- if not $initials -}}{{- $initials = "?" -}}{{- end -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- $bg := site.Params.avatarBg | default "var(--bonsai-accent)" -}}
|
{{- $bg := site.Params.avatarBg | default "var(--bonsai-accent)" -}}
|
||||||
{{- /* Background may be a CSS var or any color value; mark as safeCSS so Hugo
|
|
||||||
doesn't escape var(...) into ZgotmplZ when injecting into the style attribute. */ -}}
|
|
||||||
{{- $svgStyle := printf "color:var(--bonsai-bg);background:transparent" | safeCSS -}}
|
{{- $svgStyle := printf "color:var(--bonsai-bg);background:transparent" | safeCSS -}}
|
||||||
{{- $circleStyle := printf "fill:%s" $bg | safeCSS -}}
|
{{- $circleStyle := printf "fill:%s" $bg | safeCSS -}}
|
||||||
<svg class="bio__avatar bio__avatar--initials" viewBox="0 0 112 112" width="112" height="112"
|
<svg class="bio__avatar bio__avatar--initials" viewBox="0 0 112 112" width="112" height="112"
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
{{- $tagline := site.Params.tagline -}}
|
{{- $tagline := site.Params.tagline -}}
|
||||||
{{- $bio := site.Params.bio -}}
|
{{- $bio := site.Params.bio -}}
|
||||||
{{- $links := site.Params.links -}}
|
{{- $links := site.Params.links -}}
|
||||||
|
{{- $sections := site.Params.sections -}}
|
||||||
{{- $layout := site.Params.layout | default "stack" -}}
|
{{- $layout := site.Params.layout | default "stack" -}}
|
||||||
{{- $validLayouts := slice "stack" "grid" "inline" -}}
|
{{- $defaultLabel := i18n "nav_links_label" | default "Links" -}}
|
||||||
{{- if not (in $validLayouts $layout) -}}
|
{{- if and $sections $links -}}
|
||||||
{{- warnf "bonsai: unknown params.layout %q (expected stack|grid|inline), falling back to 'stack'" $layout -}}
|
{{- warnf "bonsai: both params.sections and params.links set; sections win, flat links ignored" -}}
|
||||||
{{- $layout = "stack" -}}
|
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|
||||||
<article class="bio">
|
<article class="bio">
|
||||||
@@ -22,11 +22,37 @@
|
|||||||
<p class="bio__text">{{ . | markdownify }}</p>
|
<p class="bio__text">{{ . | markdownify }}</p>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- if $links }}
|
{{- if $sections -}}
|
||||||
<nav class="bio__links bio__links--{{ $layout }}" aria-label="{{ i18n "nav_links_label" | default "Links" }}">
|
{{- range $i, $section := $sections -}}
|
||||||
{{- range $links }}
|
{{- $secLinks := $section.links -}}
|
||||||
{{- partial "link-button.html" . -}}
|
{{- if $secLinks -}}
|
||||||
{{- end }}
|
{{- $secLayout := $section.layout | default $layout -}}
|
||||||
</nav>
|
{{- $secTitle := $section.title -}}
|
||||||
|
{{- $slug := "" -}}
|
||||||
|
{{- with $secTitle }}{{ $slug = . | urlize }}{{ end -}}
|
||||||
|
{{- if not $slug }}{{ $slug = printf "section-%d" (add $i 1) }}{{ end -}}
|
||||||
|
<section class="bio__section"{{ with $secTitle }} aria-labelledby="section-{{ $slug }}"{{ else }} aria-label="{{ $defaultLabel }} {{ add $i 1 }}"{{ end }}>
|
||||||
|
{{- with $secTitle }}
|
||||||
|
<h2 id="section-{{ $slug }}" class="bio__section-title">{{ . }}</h2>
|
||||||
|
{{- end }}
|
||||||
|
{{- with $section.description }}
|
||||||
|
<p class="bio__section-desc">{{ . | markdownify }}</p>
|
||||||
|
{{- end }}
|
||||||
|
{{ partial "link-group.html" (dict "links" $secLinks "layout" $secLayout "label" (or $secTitle $defaultLabel)) }}
|
||||||
|
</section>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else if $links -}}
|
||||||
|
{{ partial "link-group.html" (dict "links" $links "layout" $layout "label" $defaultLabel) }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if site.Params.share }}
|
||||||
|
{{ partial "share-button.html" . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if site.Params.vcard }}
|
||||||
|
<a class="bio__vcard" href="{{ `vcard.vcf` | relURL }}" download>{{ i18n "save_contact" | default "Save contact" }}</a>
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{ partial "qr-block.html" . }}
|
||||||
</article>
|
</article>
|
||||||
@@ -2,19 +2,38 @@
|
|||||||
{{- $tagline := site.Params.tagline | default "" -}}
|
{{- $tagline := site.Params.tagline | default "" -}}
|
||||||
{{- $description := site.Params.bio | default $tagline | default site.Params.description -}}
|
{{- $description := site.Params.bio | default $tagline | default site.Params.description -}}
|
||||||
{{- $avatar := site.Params.avatar -}}
|
{{- $avatar := site.Params.avatar -}}
|
||||||
{{- /* OG image resolution:
|
{{- /* OG image resolution cascade:
|
||||||
- params.ogImage = false → emit nothing.
|
1. params.ogImage = false → emit nothing.
|
||||||
- params.ogImageUrl set → use it (1200×630 expected → summary_large_image).
|
2. params.ogImageUrl set → use it (1200×630 → summary_large_image).
|
||||||
- else fall back to params.avatar (square → summary card). */ -}}
|
3. params.ogAuto = true AND ogAutoBase + ogAutoFont supplied → auto-generate
|
||||||
|
via Hugo's images.Filter + images.Text overlay at build time.
|
||||||
|
(Bonsai v0.5 ships infra-only; user supplies the base PNG + TTF font.)
|
||||||
|
4. else fall back to params.avatar (square → summary card). */ -}}
|
||||||
{{- $ogImageEnabled := site.Params.ogImage | default true -}}
|
{{- $ogImageEnabled := site.Params.ogImage | default true -}}
|
||||||
{{- $ogImageUrl := "" -}}
|
{{- $ogImageUrl := "" -}}
|
||||||
{{- $twitterCard := "summary" -}}
|
{{- $twitterCard := "summary" -}}
|
||||||
{{- if $ogImageEnabled -}}
|
{{- if $ogImageEnabled -}}
|
||||||
{{- if site.Params.ogImageUrl -}}
|
{{- if site.Params.ogImageUrl -}}
|
||||||
{{- $ogImageUrl = site.Params.ogImageUrl | absURL -}}
|
{{- $ogImageUrl = site.Params.ogImageUrl | strings.TrimPrefix "/" | absURL -}}
|
||||||
{{- $twitterCard = "summary_large_image" -}}
|
{{- $twitterCard = "summary_large_image" -}}
|
||||||
|
{{- else if and site.Params.ogAuto site.Params.ogAutoBase site.Params.ogAutoFont -}}
|
||||||
|
{{- $base := resources.Get site.Params.ogAutoBase -}}
|
||||||
|
{{- $font := resources.Get site.Params.ogAutoFont -}}
|
||||||
|
{{- if and $base $font -}}
|
||||||
|
{{- $titleOpts := dict "size" 72 "color" "#2b2b2b" "x" 80 "y" 220 "font" $font -}}
|
||||||
|
{{- $generated := $base | images.Filter (images.Text $name $titleOpts) -}}
|
||||||
|
{{- with $tagline -}}
|
||||||
|
{{- $taglineOpts := dict "size" 36 "color" "#6b6b6b" "x" 80 "y" 340 "font" $font -}}
|
||||||
|
{{- $generated = $generated | images.Filter (images.Text . $taglineOpts) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $ogImageUrl = $generated.Permalink -}}
|
||||||
|
{{- $twitterCard = "summary_large_image" -}}
|
||||||
|
{{- else if $avatar -}}
|
||||||
|
{{- warnf "bonsai: params.ogAuto enabled but ogAutoBase/ogAutoFont resources not found; falling back to avatar" -}}
|
||||||
|
{{- $ogImageUrl = $avatar | strings.TrimPrefix "/" | absURL -}}
|
||||||
|
{{- end -}}
|
||||||
{{- else if $avatar -}}
|
{{- else if $avatar -}}
|
||||||
{{- $ogImageUrl = $avatar | absURL -}}
|
{{- $ogImageUrl = $avatar | strings.TrimPrefix "/" | absURL -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -24,8 +43,24 @@
|
|||||||
{{- with $description }}
|
{{- with $description }}
|
||||||
<meta name="description" content="{{ . }}" />
|
<meta name="description" content="{{ . }}" />
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
<link rel="canonical" href="{{ .Permalink }}" />
|
||||||
|
<meta name="robots" content="{{ site.Params.robots | default `index,follow` }}" />
|
||||||
|
{{- with site.Params.themeColor }}
|
||||||
|
<meta name="theme-color" content="{{ . }}" />
|
||||||
|
{{- end }}
|
||||||
|
{{- /* hreflang alternates for multi-language sites only. */ -}}
|
||||||
|
{{- if gt (len site.Languages) 1 }}
|
||||||
|
{{- range site.Languages }}
|
||||||
|
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ site.BaseURL }}{{ if not .Lang }}{{ else }}{{ .Lang }}/{{ end }}" />
|
||||||
|
{{- end }}
|
||||||
|
<link rel="alternate" hreflang="x-default" href="{{ site.BaseURL }}" />
|
||||||
|
{{- end }}
|
||||||
|
{{- if site.Params.manifest }}
|
||||||
|
<link rel="manifest" href="{{ `manifest.webmanifest` | relURL }}" />
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
<meta property="og:type" content="profile" />
|
<meta property="og:type" content="profile" />
|
||||||
|
<meta property="og:url" content="{{ .Permalink }}" />
|
||||||
<meta property="og:title" content="{{ $name }}" />
|
<meta property="og:title" content="{{ $name }}" />
|
||||||
{{- with $description }}
|
{{- with $description }}
|
||||||
<meta property="og:description" content="{{ . }}" />
|
<meta property="og:description" content="{{ . }}" />
|
||||||
@@ -37,15 +72,28 @@
|
|||||||
<meta name="twitter:card" content="{{ $twitterCard }}" />
|
<meta name="twitter:card" content="{{ $twitterCard }}" />
|
||||||
|
|
||||||
{{ partial "schema-person.html" . }}
|
{{ partial "schema-person.html" . }}
|
||||||
|
{{ partial "schema-website.html" . }}
|
||||||
|
|
||||||
{{- with site.Params.faviconSvg }}
|
{{- with site.Params.faviconSvg }}
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ . | strings.TrimPrefix "/" | relURL }}" />
|
<link rel="icon" type="image/svg+xml" href="{{ . | strings.TrimPrefix "/" | relURL }}" />
|
||||||
{{- end }}
|
{{- end }}
|
||||||
<link rel="icon" href="{{ (site.Params.favicon | default `favicon.ico`) | strings.TrimPrefix "/" | relURL }}" />
|
{{- with site.Params.favicon }}
|
||||||
|
<link rel="icon" href="{{ . | strings.TrimPrefix "/" | relURL }}" />
|
||||||
|
{{- end }}
|
||||||
{{- with site.Params.appleTouchIcon }}
|
{{- with site.Params.appleTouchIcon }}
|
||||||
<link rel="apple-touch-icon" href="{{ . | strings.TrimPrefix "/" | relURL }}" />
|
<link rel="apple-touch-icon" href="{{ . | strings.TrimPrefix "/" | relURL }}" />
|
||||||
{{- end }}
|
{{- end }}
|
||||||
<link rel="stylesheet" href="{{ `css/bonsai.css` | relURL }}" />
|
{{- /* Avatar preload: only for `static/`-served paths. When avatar resolves as an
|
||||||
|
`assets/` resource the <picture> pipeline emits its own srcset/fetchpriority
|
||||||
|
and a fixed-path preload would be wasted (different filename + format). */ -}}
|
||||||
|
{{- if and site.Params.avatar (ne (site.Params.preloadAvatar | default true) false) (not (strings.HasPrefix site.Params.avatar "http")) (not (resources.Get (strings.TrimPrefix "/" site.Params.avatar))) }}
|
||||||
|
<link rel="preload" as="image" href="{{ site.Params.avatar | strings.TrimPrefix "/" | relURL }}" fetchpriority="high" />
|
||||||
|
{{- end }}
|
||||||
|
{{- /* Fingerprinted + minified CSS via Hugo's resource pipeline.
|
||||||
|
integrity= attribute lets browsers / CSP `style-src 'self' 'sha384-...'`
|
||||||
|
validate the file. */ -}}
|
||||||
|
{{- $css := resources.Get "css/bonsai.css" | resources.Minify | resources.Fingerprint "sha384" }}
|
||||||
|
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" crossorigin="anonymous" />
|
||||||
{{- if site.Params.rss }}
|
{{- if site.Params.rss }}
|
||||||
<link rel="alternate" type="application/rss+xml" title="{{ site.Params.name | default site.Title }}" href="{{ `index.xml` | absURL }}" />
|
<link rel="alternate" type="application/rss+xml" title="{{ site.Params.name | default site.Title }}" href="{{ `index.xml` | absURL }}" />
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -54,5 +102,11 @@
|
|||||||
Tiny (~140B) — meets the <3KB CSS / minimal-JS budget. */ -}}
|
Tiny (~140B) — meets the <3KB CSS / minimal-JS budget. */ -}}
|
||||||
<script>(function(){try{var t=localStorage.getItem('bonsai-theme');if(t==='light'||t==='dark')document.documentElement.dataset.theme=t;}catch(e){}})();</script>
|
<script>(function(){try{var t=localStorage.getItem('bonsai-theme');if(t==='light'||t==='dark')document.documentElement.dataset.theme=t;}catch(e){}})();</script>
|
||||||
<noscript><style>.theme-toggle{display:none!important}</style></noscript>
|
<noscript><style>.theme-toggle{display:none!important}</style></noscript>
|
||||||
<script defer src="{{ `js/theme-toggle.js` | relURL }}"></script>
|
{{- $themeJs := resources.Get "js/theme-toggle.js" | resources.Minify | resources.Fingerprint "sha384" }}
|
||||||
|
<script defer src="{{ $themeJs.RelPermalink }}" integrity="{{ $themeJs.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if site.Params.share }}
|
||||||
|
{{- $shareJs := resources.Get "js/share.js" | resources.Minify | resources.Fingerprint "sha384" }}
|
||||||
|
<script defer src="{{ $shareJs.RelPermalink }}" integrity="{{ $shareJs.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
|
{{- end }}
|
||||||
|
{{ partial "analytics-loader.html" . }}
|
||||||
@@ -1,12 +1,56 @@
|
|||||||
{{- $title := .title -}}
|
{{- /* Render one link button.
|
||||||
{{- $url := .url -}}
|
Context dict: { "link": {title,url,icon,image,featured,startDate,endDate,rel,note,...}, "analytics": params.analytics }
|
||||||
{{- $icon := .icon -}}
|
Reads ONLY from its dict argument (per docs/code-standards.md partial composition rule). */ -}}
|
||||||
|
{{- $l := .link -}}
|
||||||
|
{{- $a := .analytics -}}
|
||||||
|
{{- /* Per-link visibility window: skip rendering when now < startDate or now > endDate.
|
||||||
|
Build-time evaluation only — re-evaluated at next site rebuild. */ -}}
|
||||||
|
{{- $now := now -}}
|
||||||
|
{{- $startOk := true -}}
|
||||||
|
{{- $endOk := true -}}
|
||||||
|
{{- with $l.startDate }}{{ $startOk = ge $now (time .) }}{{ end -}}
|
||||||
|
{{- with $l.endDate }}{{ $endOk = le $now (time .) }}{{ end -}}
|
||||||
|
{{- if and $startOk $endOk -}}
|
||||||
|
{{- $title := $l.title -}}
|
||||||
|
{{- $url := $l.url -}}
|
||||||
|
{{- $icon := $l.icon -}}
|
||||||
|
{{- $image := $l.image -}}
|
||||||
|
{{- $note := $l.note -}}
|
||||||
{{- $external := strings.HasPrefix $url "http" -}}
|
{{- $external := strings.HasPrefix $url "http" -}}
|
||||||
<a class="link"
|
{{- /* Class composition: .link + optional .link--featured */ -}}
|
||||||
|
{{- $classes := "link" -}}
|
||||||
|
{{- if $l.featured }}{{ $classes = printf "%s link--featured" $classes }}{{ end -}}
|
||||||
|
{{- /* rel value for external links: always start with safe defaults, then append user rel */ -}}
|
||||||
|
{{- $rel := "noopener noreferrer" -}}
|
||||||
|
{{- with $l.rel }}{{ $rel = printf "%s %s" $rel . }}{{ end -}}
|
||||||
|
{{- /* GA4 click-tracking attr: emit only when analytics enabled (caller passes the resolved dict). */ -}}
|
||||||
|
{{- $trackClicks := and $a $a.measurementId (ne ($a.trackClicks | default true) false) -}}
|
||||||
|
{{- /* UTM injection at build time for external links. Splits #fragment first so the
|
||||||
|
query string lands BEFORE the fragment (preserving link semantics). */ -}}
|
||||||
|
{{- if and $external $a $a.utmSource -}}
|
||||||
|
{{- $fragment := "" -}}
|
||||||
|
{{- if strings.Contains $url "#" -}}
|
||||||
|
{{- $bits := split $url "#" -}}
|
||||||
|
{{- $url = index $bits 0 -}}
|
||||||
|
{{- $fragment = printf "#%s" (index $bits 1) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $sep := cond (strings.Contains $url "?") "&" "?" -}}
|
||||||
|
{{- $utm := printf "utm_source=%s&utm_medium=%s" (urlquery $a.utmSource) (urlquery ($a.utmMedium | default "bio")) -}}
|
||||||
|
{{- with $a.utmCampaign }}{{ $utm = printf "%s&utm_campaign=%s" $utm (urlquery .) }}{{ end -}}
|
||||||
|
{{- $url = printf "%s%s%s%s" $url $sep $utm $fragment -}}
|
||||||
|
{{- end -}}
|
||||||
|
<a class="{{ $classes }}"
|
||||||
href="{{ $url }}"
|
href="{{ $url }}"
|
||||||
{{- if $external }} target="_blank" rel="noopener noreferrer"{{ end }}>
|
{{- if $external }} target="_blank" rel="{{ $rel }}"{{ end -}}
|
||||||
{{- with $icon }}
|
{{- if $trackClicks }} data-analytics-event="link:{{ $l.title | urlize }}"{{ end }}>
|
||||||
<span class="link__icon" aria-hidden="true">{{ partial "icon.html" . }}</span>
|
{{- if $image -}}
|
||||||
|
<img class="link__thumb" src="{{ $image | strings.TrimPrefix "/" | relURL }}" alt="" width="24" height="24" loading="lazy" decoding="async" />
|
||||||
|
{{- else if $icon -}}
|
||||||
|
<span class="link__icon" aria-hidden="true">{{ partial "icon.html" $icon }}</span>
|
||||||
{{- end }}
|
{{- end }}
|
||||||
<span class="link__title">{{ $title }}</span>
|
<span class="link__title">
|
||||||
|
{{- $title -}}
|
||||||
|
{{- with $note }}<span class="link__note">{{ . }}</span>{{- end -}}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{- /* Render a navigation block of links.
|
||||||
|
Context dict: { "links": [...], "layout": "stack|grid|inline", "label": "..." }
|
||||||
|
Used both by the flat-list rendering and per-section rendering. */ -}}
|
||||||
|
{{- $links := .links -}}
|
||||||
|
{{- $layout := .layout | default "stack" -}}
|
||||||
|
{{- $label := .label | default (i18n "nav_links_label" | default "Links") -}}
|
||||||
|
{{- $validLayouts := slice "stack" "grid" "inline" -}}
|
||||||
|
{{- if not (in $validLayouts $layout) -}}
|
||||||
|
{{- warnf "bonsai: unknown layout %q (expected stack|grid|inline), falling back to 'stack'" $layout -}}
|
||||||
|
{{- $layout = "stack" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- /* Resolve analytics once and pass into each link-button so the partial reads
|
||||||
|
only its dict argument (per docs/code-standards.md partial composition rule). */ -}}
|
||||||
|
{{- $analytics := site.Params.analytics -}}
|
||||||
|
{{- if $links -}}
|
||||||
|
<nav class="bio__links bio__links--{{ $layout }}" aria-label="{{ $label }}">
|
||||||
|
{{- range $links }}
|
||||||
|
{{- partial "link-button.html" (dict "link" . "analytics" $analytics) -}}
|
||||||
|
{{- end }}
|
||||||
|
</nav>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{- /* Render a QR code of the current page Permalink inside a collapsible <details>.
|
||||||
|
Gated by site.Params.qr = true. Uses Hugo's built-in images.QR (≥ 0.140).
|
||||||
|
The PNG is generated at build time and cached under resources/_gen/. */ -}}
|
||||||
|
{{- if site.Params.qr -}}
|
||||||
|
{{- $url := .Permalink -}}
|
||||||
|
{{- $qr := images.QR $url (dict "level" "medium" "scale" 4) -}}
|
||||||
|
{{- $showLabel := i18n "show_qr" | default "Show QR code" -}}
|
||||||
|
{{- $alt := i18n "qr_alt" | default "QR code linking to this page" -}}
|
||||||
|
<details class="bio__qr">
|
||||||
|
<summary>{{ $showLabel }}</summary>
|
||||||
|
<img class="bio__qr-img" src="{{ $qr.RelPermalink }}"
|
||||||
|
width="{{ $qr.Width }}" height="{{ $qr.Height }}"
|
||||||
|
alt="{{ $alt }}" loading="lazy" decoding="async" />
|
||||||
|
</details>
|
||||||
|
{{- end -}}
|
||||||
@@ -2,8 +2,17 @@
|
|||||||
Suppressed when site.Params.schema = false. */ -}}
|
Suppressed when site.Params.schema = false. */ -}}
|
||||||
{{- if ne (site.Params.schema | default true) false -}}
|
{{- if ne (site.Params.schema | default true) false -}}
|
||||||
{{- $name := site.Params.name | default site.Title -}}
|
{{- $name := site.Params.name | default site.Title -}}
|
||||||
|
{{- /* Flatten params.links + params.sections.*.links into a single list for sameAs. */ -}}
|
||||||
|
{{- $allLinks := slice -}}
|
||||||
|
{{- if site.Params.sections -}}
|
||||||
|
{{- range site.Params.sections -}}
|
||||||
|
{{- range .links -}}{{- $allLinks = $allLinks | append . -}}{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $allLinks = site.Params.links -}}
|
||||||
|
{{- end -}}
|
||||||
{{- $sameAs := slice -}}
|
{{- $sameAs := slice -}}
|
||||||
{{- range site.Params.links -}}
|
{{- range $allLinks -}}
|
||||||
{{- with .url -}}
|
{{- with .url -}}
|
||||||
{{- if not (or (strings.HasPrefix . "mailto:") (strings.HasPrefix . "tel:")) -}}
|
{{- if not (or (strings.HasPrefix . "mailto:") (strings.HasPrefix . "tel:")) -}}
|
||||||
{{- $sameAs = $sameAs | append . -}}
|
{{- $sameAs = $sameAs | append . -}}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{{- /* Optional WebSite JSON-LD for rich-snippet eligibility.
|
||||||
|
Off by default — opt in via params.schemaWebSite = true. */ -}}
|
||||||
|
{{- if site.Params.schemaWebSite -}}
|
||||||
|
{{- $name := site.Params.name | default site.Title -}}
|
||||||
|
{{- $payload := dict
|
||||||
|
"@context" "https://schema.org"
|
||||||
|
"@type" "WebSite"
|
||||||
|
"name" $name
|
||||||
|
"url" site.BaseURL
|
||||||
|
"inLanguage" (site.Language.LanguageCode | default site.LanguageCode | default "en")
|
||||||
|
-}}
|
||||||
|
{{- with site.Params.bio -}}{{- $payload = merge $payload (dict "description" .) -}}{{- end -}}
|
||||||
|
<script type="application/ld+json">{{ $payload | jsonify | safeJS }}</script>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{- /* Render a share button. Uses navigator.share (Web Share API) when available;
|
||||||
|
falls back to clipboard.writeText with a small toast.
|
||||||
|
Loaded only when site.Params.share = true (gated by caller). */ -}}
|
||||||
|
{{- $name := site.Params.name | default site.Title -}}
|
||||||
|
{{- $shareLabel := i18n "share_label" | default "Share this page" -}}
|
||||||
|
{{- $copied := i18n "share_copied" | default "Link copied" -}}
|
||||||
|
{{- $failed := i18n "share_failed" | default "Could not share" -}}
|
||||||
|
<button type="button"
|
||||||
|
class="bio__share"
|
||||||
|
data-bonsai-share
|
||||||
|
data-share-url="{{ .Permalink }}"
|
||||||
|
data-share-title="{{ $name }}"
|
||||||
|
data-toast-copied="{{ $copied }}"
|
||||||
|
data-toast-failed="{{ $failed }}"
|
||||||
|
aria-label="{{ $shareLabel }}">
|
||||||
|
<span class="bio__share-icon" aria-hidden="true">{{ partial "icon.html" "share" }}</span>
|
||||||
|
<span class="bio__share-label">{{ $shareLabel }}</span>
|
||||||
|
</button>
|
||||||
|
<output class="bio__share-toast" role="status" aria-live="polite" hidden></output>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{{ define "head_extra" }}
|
{{ define "head_extra" }}
|
||||||
<link rel="stylesheet" href="{{ `css/gallery.css` | relURL }}" />
|
{{- $g := resources.Get "css/gallery.css" | resources.Minify | resources.Fingerprint "sha384" -}}
|
||||||
|
<link rel="stylesheet" href="{{ $g.RelPermalink }}" integrity="{{ $g.Data.Integrity }}" crossorigin="anonymous" />
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{{ define "head_extra" }}
|
{{ define "head_extra" }}
|
||||||
<link rel="stylesheet" href="{{ `css/gallery.css` | relURL }}" />
|
{{- $g := resources.Get "css/gallery.css" | resources.Minify | resources.Fingerprint "sha384" -}}
|
||||||
|
<link rel="stylesheet" href="{{ $g.RelPermalink }}" integrity="{{ $g.Data.Integrity }}" crossorigin="anonymous" />
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "main" }}
|
{{ define "main" }}
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
<code class="variants-gallery__code">layout = "{{ .name }}"</code>
|
<code class="variants-gallery__code">layout = "{{ .name }}"</code>
|
||||||
<nav class="bio__links bio__links--{{ .name }}" aria-label="{{ .label }} sample">
|
<nav class="bio__links bio__links--{{ .name }}" aria-label="{{ .label }} sample">
|
||||||
{{- range $sample }}
|
{{- range $sample }}
|
||||||
{{- partial "link-button.html" . -}}
|
{{- partial "link-button.html" (dict "link" . "analytics" site.Params.analytics) -}}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
</nav>
|
</nav>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
phase: 7
|
phase: 7
|
||||||
title: v0.4.0 release prep
|
title: v0.4.0 release prep
|
||||||
status: in-progress
|
status: completed
|
||||||
priority: P3
|
priority: P3
|
||||||
effort: 30m
|
effort: 30m
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ description: >-
|
|||||||
Ship issues #11, #10, #8 each as its own PR; tag v0.4.0. Issues #7 (OG
|
Ship issues #11, #10, #8 each as its own PR; tag v0.4.0. Issues #7 (OG
|
||||||
auto-gen) and #9 (multi-section bio) deferred to v0.5 per their own deferral
|
auto-gen) and #9 (multi-section bio) deferred to v0.5 per their own deferral
|
||||||
notes.
|
notes.
|
||||||
status: in-progress
|
status: completed
|
||||||
priority: P2
|
priority: P2
|
||||||
created: 2026-05-10T00:00:00.000Z
|
created: 2026-05-10T00:00:00.000Z
|
||||||
---
|
---
|
||||||
@@ -34,7 +34,7 @@ Each phase ships as its own feature branch + PR (matches repo pattern).
|
|||||||
| 2 | [Extract gallery CSS (#11)](./phase-02-extract-gallery-css-11.md) | Completed |
|
| 2 | [Extract gallery CSS (#11)](./phase-02-extract-gallery-css-11.md) | Completed |
|
||||||
| 3 | [Add more icons (#10)](./phase-03-add-more-icons-10.md) | Completed |
|
| 3 | [Add more icons (#10)](./phase-03-add-more-icons-10.md) | Completed |
|
||||||
| 4 | [Optional RSS feed (#8)](./phase-04-optional-rss-feed-8.md) | Completed |
|
| 4 | [Optional RSS feed (#8)](./phase-04-optional-rss-feed-8.md) | Completed |
|
||||||
| 7 | [v0.4.0 release prep](./phase-07-v0-4-0-release-prep.md) | In Progress |
|
| 7 | [v0.4.0 release prep](./phase-07-v0-4-0-release-prep.md) | Completed |
|
||||||
|
|
||||||
(Phase numbers preserved from original 7-phase scaffold for stable references.)
|
(Phase numbers preserved from original 7-phase scaffold for stable references.)
|
||||||
|
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
---
|
||||||
|
phase: 1
|
||||||
|
title: "Lighthouse quick wins + meta hardening"
|
||||||
|
status: completed
|
||||||
|
priority: P1
|
||||||
|
effort: "1d"
|
||||||
|
dependencies: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 1: Lighthouse quick wins + meta hardening
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Ship the highest-ROI Lighthouse fixes — all single-file, additive, zero-risk edits. Targets: LCP (avatar attrs + preload), SEO (canonical, og:url, theme-color, robots), A11y (skip-link), Best-Practices (CSP/security via doc + meta).
|
||||||
|
|
||||||
|
Expected combined gain (per researcher report §6 matrix rows 1–6, 10): +35 raw Lighthouse points → moves baseline well above the ≥90 floor locked in validation session 1, before any larger feature work.
|
||||||
|
|
||||||
|
## Context Links
|
||||||
|
|
||||||
|
- Source: `plans/reports/researcher-260515-hugo-lighthouse-best-practices.md` §1.1, §2.1, §4.1, §3.2
|
||||||
|
- Current audit: `plans/reports/researcher-260515-bonsai-current-audit.md` §8 "Missing ❌"
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
- Avatar `<img>` (`partials/avatar.html`) emits `width="112" height="112" fetchpriority="high" decoding="async" loading="eager"`.
|
||||||
|
- `<head>` (`partials/head.html`) emits:
|
||||||
|
- `<link rel="canonical" href="{{ .Permalink }}">` (uses page Permalink, not site BaseURL — works for multi-page extensions later).
|
||||||
|
- `<meta property="og:url" content="{{ .Permalink }}">`.
|
||||||
|
- `<meta name="theme-color" content="{{ params.themeColor }}">` — opt-in param; if unset, omit (don't guess accent from palette since accent is in CSS-only and would require palette-aware Hugo logic; KISS).
|
||||||
|
- `<meta name="robots" content="{{ params.robots | default 'index,follow' }}">` — defaultable, suppressable.
|
||||||
|
- Optional `<link rel="preload" as="image" href="{{ params.avatar | relURL }}" fetchpriority="high">` only when `params.avatar` is set AND `params.preloadAvatar | default true` is true (skip for SVG-initials fallback, no file to preload).
|
||||||
|
- Skip-link injected in `baseof.html` immediately after `<body>`: `<a class="skip-link" href="#main">{{ i18n "skip_to_content" | default "Skip to content" }}</a>`. Main wrapper gains `id="main"`.
|
||||||
|
- `i18n/en.toml` + `i18n/vi.toml` get new key `skip_to_content`.
|
||||||
|
|
||||||
|
### Non-functional
|
||||||
|
|
||||||
|
- Skip-link CSS budget: ≤ 200 bytes raw. Hidden off-screen by default, revealed on `:focus`.
|
||||||
|
- No new web fonts, no new JS.
|
||||||
|
- Backward-compatible: every new param defaults to a sensible value or omission.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
partials/head.html
|
||||||
|
├─ canonical link
|
||||||
|
├─ og:url meta
|
||||||
|
├─ optional theme-color meta (params.themeColor)
|
||||||
|
├─ robots meta (default "index,follow", opt-out via params.robots = "noindex,nofollow")
|
||||||
|
└─ optional avatar preload (when params.avatar && params.preloadAvatar)
|
||||||
|
|
||||||
|
partials/avatar.html
|
||||||
|
└─ img tag gains width/height/fetchpriority/decoding attrs
|
||||||
|
|
||||||
|
layouts/_default/baseof.html
|
||||||
|
├─ skip-link (first child of body)
|
||||||
|
└─ main now has id="main"
|
||||||
|
|
||||||
|
static/css/bonsai.css
|
||||||
|
└─ .skip-link rule block (~150 B raw)
|
||||||
|
|
||||||
|
i18n/{en,vi}.toml
|
||||||
|
└─ skip_to_content key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Code Files
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `layouts/partials/head.html` — add canonical, og:url, theme-color, robots, optional avatar preload
|
||||||
|
- `layouts/partials/avatar.html` — width/height/fetchpriority/decoding on `<img>`
|
||||||
|
- `layouts/_default/baseof.html` — skip-link + `id="main"` on main wrapper
|
||||||
|
- `static/css/bonsai.css` — `.skip-link` styles
|
||||||
|
- `i18n/en.toml` — `skip_to_content` key
|
||||||
|
- `i18n/vi.toml` — `skip_to_content` key
|
||||||
|
- `README.md` — document new params (`themeColor`, `robots`, `preloadAvatar`)
|
||||||
|
- `exampleSite/hugo.toml` — showcase new params
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `docs/deployment-guide.md` — security headers templates for Netlify, Vercel, Cloudflare Pages, GitHub Pages limitations (CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy, X-Content-Type-Options)
|
||||||
|
- `exampleSite/static/_headers` — Netlify-style header file (works on Netlify + Cloudflare Pages)
|
||||||
|
- `exampleSite/vercel.json` — Vercel header config example
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Avatar attrs** (`partials/avatar.html`)
|
||||||
|
- On the `<img>` branch: add `width="112" height="112" fetchpriority="high" decoding="async"`.
|
||||||
|
- On the SVG-initials branch: SVG already has `width="112" height="112"`. No change.
|
||||||
|
|
||||||
|
2. **Head meta hardening** (`partials/head.html`)
|
||||||
|
- After `<title>`, add:
|
||||||
|
```html
|
||||||
|
<link rel="canonical" href="{{ .Permalink }}" />
|
||||||
|
<meta name="robots" content="{{ site.Params.robots | default `index,follow` }}" />
|
||||||
|
{{- with site.Params.themeColor }}
|
||||||
|
<meta name="theme-color" content="{{ . }}" />
|
||||||
|
{{- end }}
|
||||||
|
```
|
||||||
|
- In OG block, after `og:image` resolution, add:
|
||||||
|
```html
|
||||||
|
<meta property="og:url" content="{{ .Permalink }}" />
|
||||||
|
```
|
||||||
|
- Before the stylesheet link, add:
|
||||||
|
```html
|
||||||
|
{{- if and site.Params.avatar (ne (site.Params.preloadAvatar | default true) false) }}
|
||||||
|
<link rel="preload" as="image" href="{{ site.Params.avatar | strings.TrimPrefix "/" | relURL }}" fetchpriority="high" />
|
||||||
|
{{- end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Skip-link** (`layouts/_default/baseof.html`)
|
||||||
|
- Replace `<main class="bonsai">` with `<main class="bonsai" id="main">`.
|
||||||
|
- Add `<a class="skip-link" href="#main">{{ i18n "skip_to_content" | default "Skip to content" }}</a>` as first child of `<body>`.
|
||||||
|
|
||||||
|
4. **Skip-link CSS** (`static/css/bonsai.css`)
|
||||||
|
- Append:
|
||||||
|
```css
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -3rem;
|
||||||
|
left: .5rem;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
background: var(--bonsai-accent);
|
||||||
|
color: var(--bonsai-bg);
|
||||||
|
border-radius: var(--bonsai-radius);
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 100;
|
||||||
|
transition: top .15s ease;
|
||||||
|
}
|
||||||
|
.skip-link:focus { top: .5rem; outline: 2px solid var(--bonsai-text); outline-offset: 2px; }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .skip-link { transition: none; } }
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **i18n keys** (`i18n/en.toml`, `i18n/vi.toml`)
|
||||||
|
- en: `[skip_to_content] other = "Skip to content"`
|
||||||
|
- vi: `[skip_to_content] other = "Bỏ qua tới nội dung"`
|
||||||
|
|
||||||
|
6. **Deploy templates** (`docs/deployment-guide.md`, `exampleSite/static/_headers`, `exampleSite/vercel.json`)
|
||||||
|
- `_headers`:
|
||||||
|
```
|
||||||
|
/*
|
||||||
|
Cache-Control: public, max-age=3600, must-revalidate
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-Frame-Options: SAMEORIGIN
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||||
|
Content-Security-Policy: default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; frame-ancestors 'self'
|
||||||
|
/css/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
/js/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
/images/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
```
|
||||||
|
- `vercel.json`: equivalent JSON form.
|
||||||
|
- `docs/deployment-guide.md`: explain each header, note `'unsafe-inline'` is required only when `params.themeToggle = true` (inline FOUC script) — strict CSP achievable by removing themeToggle or using SRI/nonce.
|
||||||
|
|
||||||
|
7. **README + exampleSite update**
|
||||||
|
- README: add rows to params table for `themeColor`, `robots`, `preloadAvatar`.
|
||||||
|
- `exampleSite/hugo.toml`: add `themeColor = "#8b3a2b"` (matches default bonsai palette) and a commented-out `# robots = "index,follow"`.
|
||||||
|
|
||||||
|
8. **Build verification**
|
||||||
|
- `cd exampleSite && hugo --themesDir ../.. --gc --minify` should still produce green build.
|
||||||
|
- `curl -sI` against built `public/index.html` post-deploy to verify header file applied (CI step).
|
||||||
|
|
||||||
|
## Todo List
|
||||||
|
|
||||||
|
- [ ] Add avatar `width/height/fetchpriority/decoding` attrs (`partials/avatar.html`)
|
||||||
|
- [ ] Add canonical, og:url, robots, theme-color meta (`partials/head.html`)
|
||||||
|
- [ ] Add optional avatar preload (`partials/head.html`)
|
||||||
|
- [ ] Add skip-link + `id="main"` (`baseof.html`)
|
||||||
|
- [ ] Append skip-link CSS (`static/css/bonsai.css`)
|
||||||
|
- [ ] Add `skip_to_content` i18n key (en, vi)
|
||||||
|
- [ ] Create `_headers` + `vercel.json` deploy templates
|
||||||
|
- [ ] Write `docs/deployment-guide.md` with security-header rationale
|
||||||
|
- [ ] Update README params table
|
||||||
|
- [ ] Update `exampleSite/hugo.toml` to showcase new params
|
||||||
|
- [ ] Verify CSS budget still ≤ 5 KB gzipped after skip-link rule
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] `curl -s {site} | grep -c 'rel="canonical"'` returns 1
|
||||||
|
- [ ] `curl -s {site} | grep -c 'property="og:url"'` returns 1
|
||||||
|
- [ ] `curl -s {site} | grep -c 'name="robots"'` returns 1
|
||||||
|
- [ ] Avatar `<img>` has `width`, `height`, `fetchpriority`, `decoding` attrs
|
||||||
|
- [ ] Skip-link visible on Tab keypress, hidden otherwise
|
||||||
|
- [ ] Lighthouse SEO category ≥ 90 on exampleSite demo (canonical alone adds +10)
|
||||||
|
- [ ] Lighthouse Performance LCP improved measurably (fetchpriority effect)
|
||||||
|
- [ ] No regressions: `hugo --gc --minify` clean build
|
||||||
|
- [ ] CSS production size delta ≤ +200 B raw / +40 B gzipped
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| `params.preloadAvatar` default-on could preload unused image if avatar unset | Guarded: only emits `<link rel="preload">` when `params.avatar` is set. |
|
||||||
|
| Skip-link reveal animation conflicts with reduced-motion | `@media (prefers-reduced-motion: reduce)` clause disables transition. |
|
||||||
|
| Permissive `'unsafe-inline'` CSP for themeToggle FOUC inline script | Documented in deployment-guide; users can drop `themeToggle` to tighten. |
|
||||||
|
| `<meta name="theme-color">` not palette-aware (only one color even when 4 palettes ship) | Documented limitation. Per-palette theme-color would need JS or extra params. KISS: ship single user-set value. |
|
||||||
|
| Existing sites set `params.themeColor` already and expect different behavior | Param name not previously used — grep across user sites unknowable, but unlikely collision. |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- CSP template in `_headers` permits `'unsafe-inline'` for scripts (required by the inline FOUC script when `themeToggle = true`) and styles (required by inline SVG `style=` attrs in avatar partial). Documented as the trade-off.
|
||||||
|
- `referrer-policy: strict-origin-when-cross-origin` prevents leaking full URLs to external link targets.
|
||||||
|
- `permissions-policy` blocks camera/mic/geo by default — bio pages don't need them.
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
---
|
||||||
|
phase: 2
|
||||||
|
title: "Link sections / multi-section bio (#9)"
|
||||||
|
status: completed
|
||||||
|
priority: P1
|
||||||
|
effort: "2d"
|
||||||
|
dependencies: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 2: Link sections / multi-section bio (#9)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Group `[[params.links]]` into named sections with optional headers — Linktree's primary content-grouping feature, and the #1 reason users hit the wall on a flat list. Resolves deferred issue #9 from v0.4 CHANGELOG.
|
||||||
|
|
||||||
|
**Backward-compat contract:** if user provides flat `[[params.links]]` only, render unchanged. If user provides `[[params.sections]]`, render section-grouped. Both supported in parallel during transition (sections win when both present).
|
||||||
|
|
||||||
|
## Context Links
|
||||||
|
|
||||||
|
- Source: deferred per `plans/260510-0238-v0-4-release/plan.md` → "schema design dispute; needs `/ck:brainstorm`"
|
||||||
|
- Linktree feasibility: `plans/reports/researcher-260515-linktree-feature-inventory.md` Table 1 row "Link sections/grouping" — ✅ static-feasible
|
||||||
|
- Audit: `plans/reports/researcher-260515-bonsai-current-audit.md` §1 — current `bio-card.html` consumes flat `links` only
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
- New `[[params.sections]]` array where each section has:
|
||||||
|
- `title` (string, optional — omit to render a section without a heading)
|
||||||
|
- `description` (string, markdown, optional — short text under heading)
|
||||||
|
- `layout` (string, optional — per-section override of `params.layout`: stack | grid | inline)
|
||||||
|
- `[[sections.links]]` — same shape as existing `[[params.links]]` entries
|
||||||
|
- If `params.sections` present, render section-grouped layout.
|
||||||
|
- If `params.sections` absent and `params.links` present, render existing flat layout (unchanged).
|
||||||
|
- Section headings are `<h2>` (semantic; below the `<h1>` name).
|
||||||
|
- Each section gets `<section aria-labelledby="section-{slug}">` for landmark accessibility; if `title` omitted, the section uses `aria-label` with section description or index.
|
||||||
|
- Schema-person `sameAs` flattens all link URLs across all sections (preserving existing JSON-LD behavior).
|
||||||
|
|
||||||
|
### Non-functional
|
||||||
|
|
||||||
|
- CSS additions ≤ 400 B raw / ≤ 80 B gzipped.
|
||||||
|
- No new JS.
|
||||||
|
- Per-section layout switch must reuse existing `bio__links--{layout}` classes (no duplication).
|
||||||
|
- Sections gracefully degrade when JS disabled (already zero-JS).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Schema (hugo.toml):
|
||||||
|
[[params.sections]]
|
||||||
|
title = "Code"
|
||||||
|
description = "Where I push commits."
|
||||||
|
layout = "grid"
|
||||||
|
[[params.sections.links]]
|
||||||
|
title = "GitHub"
|
||||||
|
url = "https://github.com/me"
|
||||||
|
icon = "github"
|
||||||
|
[[params.sections.links]]
|
||||||
|
title = "GitLab"
|
||||||
|
...
|
||||||
|
[[params.sections]]
|
||||||
|
title = "Music"
|
||||||
|
layout = "inline"
|
||||||
|
[[params.sections.links]]
|
||||||
|
...
|
||||||
|
|
||||||
|
Render tree:
|
||||||
|
<article class="bio">
|
||||||
|
<img class="bio__avatar"/>
|
||||||
|
<h1 class="bio__name">…</h1>
|
||||||
|
<p class="bio__tagline">…</p>
|
||||||
|
<p class="bio__text">…bio…</p>
|
||||||
|
|
||||||
|
<!-- multi-section branch -->
|
||||||
|
<section class="bio__section" aria-labelledby="section-code">
|
||||||
|
<h2 id="section-code" class="bio__section-title">Code</h2>
|
||||||
|
<p class="bio__section-desc">Where I push commits.</p>
|
||||||
|
<nav class="bio__links bio__links--grid" aria-label="Code links">
|
||||||
|
<a class="link">…</a>
|
||||||
|
…
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bio__section" aria-labelledby="section-music">
|
||||||
|
<h2 id="section-music" class="bio__section-title">Music</h2>
|
||||||
|
<nav class="bio__links bio__links--inline" aria-label="Music links">
|
||||||
|
…
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decision rationale (KISS over generic CMS):**
|
||||||
|
- Two-tier only — sections contain links, no sub-sections. Linktree itself is flat-grouped.
|
||||||
|
- Slug generation uses Hugo's `urlize` for stable ids.
|
||||||
|
- Per-section `layout` override is opt-in; defaults to `params.layout`.
|
||||||
|
|
||||||
|
## Related Code Files
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `layouts/partials/bio-card.html` — branch on `params.sections` vs `params.links`; emit `<section>` wrappers
|
||||||
|
- `layouts/partials/schema-person.html` — flatten `sections.*.links[*].url` into `sameAs` array
|
||||||
|
- `layouts/index.rss.xml` — emit RSS items for all links across all sections (preserves existing RSS contract)
|
||||||
|
- `static/css/bonsai.css` — append `.bio__section`, `.bio__section-title`, `.bio__section-desc` rules
|
||||||
|
- `README.md` — document `[[params.sections]]` syntax + backward-compat note
|
||||||
|
- `exampleSite/hugo.toml` — either: (a) keep flat to showcase original layout, or (b) add a `/sections/` demo content file
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `exampleSite/content/sections/_index.md` (optional) — demo page exercising sections
|
||||||
|
- `layouts/sections/single.html` (optional) — only if (b) above
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Refactor `bio-card.html`** — extract the link-rendering nav into a sub-partial `partials/link-group.html` taking `dict "links" $links "layout" $layout "label" $label`:
|
||||||
|
```html
|
||||||
|
{{- $links := .links -}}
|
||||||
|
{{- $layout := .layout -}}
|
||||||
|
{{- $label := .label -}}
|
||||||
|
<nav class="bio__links bio__links--{{ $layout }}" aria-label="{{ $label }}">
|
||||||
|
{{- range $links }}{{ partial "link-button.html" . }}{{- end }}
|
||||||
|
</nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Branch in `bio-card.html`**:
|
||||||
|
```html
|
||||||
|
{{- $sections := site.Params.sections -}}
|
||||||
|
{{- $links := site.Params.links -}}
|
||||||
|
{{- $layout := site.Params.layout | default "stack" -}}
|
||||||
|
…
|
||||||
|
{{- if $sections }}
|
||||||
|
{{- range $i, $section := $sections }}
|
||||||
|
{{- $secLayout := $section.layout | default $layout -}}
|
||||||
|
{{- $secTitle := $section.title -}}
|
||||||
|
{{- $slug := $secTitle | urlize | default (printf "section-%d" $i) -}}
|
||||||
|
<section class="bio__section"
|
||||||
|
{{ with $secTitle }}aria-labelledby="section-{{ $slug }}"{{ else }}aria-label="{{ i18n "nav_links_label" }} {{ add $i 1 }}"{{ end }}>
|
||||||
|
{{- with $secTitle }}
|
||||||
|
<h2 id="section-{{ $slug }}" class="bio__section-title">{{ . }}</h2>
|
||||||
|
{{- end }}
|
||||||
|
{{- with $section.description }}
|
||||||
|
<p class="bio__section-desc">{{ . | markdownify }}</p>
|
||||||
|
{{- end }}
|
||||||
|
{{ partial "link-group.html" (dict "links" $section.links "layout" $secLayout "label" (or $secTitle (i18n "nav_links_label"))) }}
|
||||||
|
</section>
|
||||||
|
{{- end }}
|
||||||
|
{{- else if $links }}
|
||||||
|
{{ partial "link-group.html" (dict "links" $links "layout" $layout "label" (i18n "nav_links_label")) }}
|
||||||
|
{{- end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Schema flatten** — in `schema-person.html`:
|
||||||
|
```go
|
||||||
|
{{- $allLinks := slice -}}
|
||||||
|
{{- if site.Params.sections -}}
|
||||||
|
{{- range site.Params.sections -}}
|
||||||
|
{{- range .links -}}{{- $allLinks = $allLinks | append . -}}{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else -}}{{- $allLinks = site.Params.links -}}{{- end -}}
|
||||||
|
{{- /* then iterate $allLinks for sameAs as before */ -}}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **RSS flatten** — `layouts/index.rss.xml` mirrors the same flatten logic.
|
||||||
|
|
||||||
|
5. **CSS additions** (`static/css/bonsai.css`):
|
||||||
|
```css
|
||||||
|
.bio__section { margin-top: 2rem; }
|
||||||
|
.bio__section:first-of-type { margin-top: 1.5rem; }
|
||||||
|
.bio__section-title {
|
||||||
|
font-family: var(--bonsai-font-display);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 .5rem;
|
||||||
|
color: var(--bonsai-text);
|
||||||
|
}
|
||||||
|
.bio__section-desc {
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--bonsai-muted);
|
||||||
|
margin: 0 0 .75rem;
|
||||||
|
}
|
||||||
|
.bio__section .bio__links { margin-top: .5rem; }
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **README**: new "Sections" subsection under Configuration. Show side-by-side flat vs sectioned example. State backward-compat.
|
||||||
|
|
||||||
|
7. **exampleSite demo**: add `exampleSite/content/sections/_index.md` with `[[params.sections]]` override (Hugo supports page-level params override of site params). Add a navigation card on the index `/` linking to `/sections/` so demo is discoverable.
|
||||||
|
|
||||||
|
8. **Build verification**: `hugo --gc --minify --templateMetrics`. Verify both `/` (flat) and `/sections/` (sectioned) render correctly; verify schema sameAs includes all flattened URLs.
|
||||||
|
|
||||||
|
## Todo List
|
||||||
|
|
||||||
|
- [ ] Extract `partials/link-group.html` from `bio-card.html`
|
||||||
|
- [ ] Branch on `params.sections` in `bio-card.html`
|
||||||
|
- [ ] Flatten sections in `schema-person.html` for `sameAs`
|
||||||
|
- [ ] Flatten sections in `index.rss.xml`
|
||||||
|
- [ ] Append section CSS rules
|
||||||
|
- [ ] Document `[[params.sections]]` in README with example
|
||||||
|
- [ ] Add `exampleSite/content/sections/_index.md` demo
|
||||||
|
- [ ] Verify backward-compat: existing flat-list demos render unchanged
|
||||||
|
- [ ] Confirm CSS budget ≤ +400 B raw / +80 B gzipped
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Flat `params.links` config still renders identical HTML to v0.4 (byte-diff on test fixture)
|
||||||
|
- [ ] `params.sections` config renders `<section aria-labelledby="…">` wrappers with `<h2>` headings
|
||||||
|
- [ ] Per-section `layout` override works (e.g., section 1 = grid, section 2 = inline)
|
||||||
|
- [ ] `schema-person.html` JSON-LD `sameAs` includes all flattened URLs
|
||||||
|
- [ ] RSS feed `<item>` count = sum across all sections' links
|
||||||
|
- [ ] No CLS introduced (section headings have explicit `margin`, no layout shift)
|
||||||
|
- [ ] CSS gzipped delta ≤ +80 B
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Both `params.links` and `params.sections` set → ambiguous | `sections` wins; emit `warnf` at build time to nudge user toward single source. |
|
||||||
|
| Empty section (no `links`) accidentally renders empty `<nav>` | Skip section entirely if `len .links == 0`. |
|
||||||
|
| Slug collisions for duplicate section titles | Fall back to `section-{index}` when `urlize` produces empty or duplicate. |
|
||||||
|
| Per-section `layout` value invalid | Reuse the existing layout-allowlist warning logic from `bio-card.html`. |
|
||||||
|
| Schema `sameAs` order changes when migrating from flat to sectioned | Documented as benign — order is semantically irrelevant in `sameAs`. |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- `markdownify` on `section.description` — already done elsewhere for `params.bio`; same trust model.
|
||||||
|
- No user-controlled HTML rendered raw.
|
||||||
+298
@@ -0,0 +1,298 @@
|
|||||||
|
---
|
||||||
|
phase: 3
|
||||||
|
title: "Per-link enhancements: thumbnails, featured, scheduled, share, copy, QR, vCard"
|
||||||
|
status: completed
|
||||||
|
priority: P1
|
||||||
|
effort: "3d"
|
||||||
|
dependencies: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 3: Per-link enhancements + page-level share/QR/vCard
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Close the largest Linktree-parity gap in one phase by adding the medium-effort, high-value features all Linktree users rely on:
|
||||||
|
|
||||||
|
**Per-link (5 new optional fields):**
|
||||||
|
- `image` — thumbnail next to icon
|
||||||
|
- `featured` — boolean, applies `.link--featured` styling (size↑, accent border)
|
||||||
|
- `startDate` / `endDate` — build-time visibility window (Hugo's `now` vs param)
|
||||||
|
- `rel` — augment auto-injected `noopener noreferrer` (e.g. add `sponsored`, `nofollow`)
|
||||||
|
- `note` — small caption under title (sub-line, muted)
|
||||||
|
|
||||||
|
**Page-level (3 new opt-in features):**
|
||||||
|
- `params.share` — render a share button row (`navigator.share` + clipboard-copy fallback)
|
||||||
|
- `params.qr` — render QR code of canonical page URL via Hugo `images.QR` (Hugo ≥0.140 built-in)
|
||||||
|
- `params.vcard` — generate `/vcard.vcf` static file at build time + a download link
|
||||||
|
|
||||||
|
## Context Links
|
||||||
|
|
||||||
|
- Linktree parity: `plans/reports/researcher-260515-linktree-feature-inventory.md` Table 2 rows 5, 8, 10, 19 (QR/share/featured/vCard); Table 1 "Schedule links" row
|
||||||
|
- Audit: `plans/reports/researcher-260515-bonsai-current-audit.md` §6 master params table — no `image`, `featured`, `startDate`, `endDate`, `rel`, `note` fields yet
|
||||||
|
- Lighthouse impact: share button uses JS — opt-in only to preserve "zero JS by default" contract
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
**Per-link fields (all optional, additive):**
|
||||||
|
|
||||||
|
| Field | Type | Default | Effect |
|
||||||
|
|-------|------|---------|--------|
|
||||||
|
| `image` | string (URL) | — | Render thumbnail `<img>` at `link__thumb` slot (24–32 px square). Falls back to `icon` if both set (image wins). |
|
||||||
|
| `featured` | bool | false | Add `.link--featured` class — slightly larger padding, accent-tinted border, optional star/sparkle micro-icon. |
|
||||||
|
| `startDate` | string (RFC3339 date) | — | Link rendered only if `now >= startDate`. |
|
||||||
|
| `endDate` | string (RFC3339 date) | — | Link rendered only if `now <= endDate`. |
|
||||||
|
| `rel` | string | — | Appended to auto `noopener noreferrer` (space-joined). Use for `sponsored`, `nofollow`, `me`. |
|
||||||
|
| `note` | string | — | Small caption below title (e.g. "Sponsored", "Limited time"). |
|
||||||
|
|
||||||
|
**Page-level opt-ins:**
|
||||||
|
|
||||||
|
| Param | Type | Default | Effect |
|
||||||
|
|-------|------|---------|--------|
|
||||||
|
| `params.share` | bool | false | Render `<button class="bio__share">` after links. Uses `navigator.share()` when supported; falls back to clipboard copy + ARIA-live toast. |
|
||||||
|
| `params.qr` | bool | false | Render QR code as `<img>` (PNG generated by Hugo) in a `<details>` collapsible at page bottom. Encodes `Permalink`. |
|
||||||
|
| `params.vcard` | bool | false | Hugo task at build time writes `static/vcard.vcf` derived from `params.name`, `params.email`, `params.location`, `params.jobTitle`, social links. Renders a "Save contact" link/button. |
|
||||||
|
|
||||||
|
### Non-functional
|
||||||
|
|
||||||
|
- Build-time visibility (`startDate`/`endDate`) — Bonsai is static; visibility re-evaluated only at next build. Document this clearly. If user needs runtime visibility (e.g. weekly recurring), they should use a GitHub Actions cron rebuild.
|
||||||
|
- Share button JS: vanilla, <2 KB raw, loaded `defer`, only when `params.share = true`. Total page JS budget incl. theme-toggle: ≤ 3 KB raw.
|
||||||
|
- QR generation: Hugo `images.QR` requires Hugo ≥ 0.140 — bump theme minimum in `theme.toml` if not already.
|
||||||
|
- vCard generation: Hugo template emits `.vcf` via `outputFormats`; no external script.
|
||||||
|
- All new features off by default. v0.4 sites unaffected.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
layouts/partials/link-button.html
|
||||||
|
- branch: image > icon (image renders <img class="link__thumb">)
|
||||||
|
- apply .link--featured class when .featured
|
||||||
|
- skip render entirely when scheduled visibility window excludes now
|
||||||
|
- augment rel with user-provided rel + auto noopener/noreferrer
|
||||||
|
- render .link__note caption when .note set
|
||||||
|
|
||||||
|
layouts/partials/share-button.html (new)
|
||||||
|
- <button class="bio__share" data-share-url="{{ .Permalink }}" data-share-title="{{ name }}">
|
||||||
|
{{ i18n "share_label" }}
|
||||||
|
</button>
|
||||||
|
- <output class="bio__share-toast" role="status" aria-live="polite" hidden></output>
|
||||||
|
|
||||||
|
static/js/share.js (new, ~700 B raw)
|
||||||
|
- navigator.share if available; else clipboard.writeText + toast "Copied"
|
||||||
|
- reads data-share-url, data-share-title attrs
|
||||||
|
- guarded under typeof navigator !== 'undefined'
|
||||||
|
|
||||||
|
layouts/partials/qr-block.html (new)
|
||||||
|
- {{ $qr := images.QR .Permalink }}
|
||||||
|
- <details class="bio__qr"><summary>{{ i18n "show_qr" }}</summary>
|
||||||
|
<img class="bio__qr-img" src="{{ $qr.RelPermalink }}" width="200" height="200" alt="{{ i18n "qr_alt" }}" loading="lazy"/>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
layouts/_default/vcard.vcf (new) + outputFormats config
|
||||||
|
- Hugo outputFormats config:
|
||||||
|
[outputFormats.VCard]
|
||||||
|
mediaType = "text/vcard"
|
||||||
|
baseName = "vcard"
|
||||||
|
isPlainText = true
|
||||||
|
notAlternative = true
|
||||||
|
- Template emits BEGIN:VCARD … END:VCARD
|
||||||
|
- Linked from <a download="vcard.vcf" href="/vcard.vcf">{{ i18n "save_contact" }}</a>
|
||||||
|
|
||||||
|
CSS additions (static/css/bonsai.css):
|
||||||
|
.link__thumb { width: 24px; height: 24px; border-radius: 4px; flex-shrink: 0; object-fit: cover; }
|
||||||
|
.link--featured { padding: 1.1rem 1.4rem; border-color: var(--bonsai-accent); border-width: 1.5px; }
|
||||||
|
.link__note { display: block; font-size: .75rem; color: var(--bonsai-muted); margin-top: .15rem; }
|
||||||
|
.bio__share { margin-top: 1.5rem; … flex centered button matching .link style }
|
||||||
|
.bio__share-toast { … position: fixed; bottom: 1rem; … fade-out 2s }
|
||||||
|
.bio__qr { margin-top: 1.5rem; text-align: center; }
|
||||||
|
.bio__qr summary { cursor: pointer; color: var(--bonsai-muted); font-size: .85rem; }
|
||||||
|
.bio__qr-img { display: block; margin: .75rem auto 0; border-radius: var(--bonsai-radius); }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Code Files
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `layouts/partials/link-button.html` — image, featured, scheduled, rel, note
|
||||||
|
- `layouts/partials/bio-card.html` — emit share-button, qr-block, vcard-link after links (each guarded by its own opt-in param)
|
||||||
|
- `static/css/bonsai.css` — append link__thumb, link--featured, link__note, bio__share, bio__share-toast, bio__qr, bio__qr-img rules (≤ 800 B raw / ≤ 200 B gzipped)
|
||||||
|
- `theme.toml` — bump `min_version = "0.140.0"` if not already
|
||||||
|
- `i18n/en.toml` + `i18n/vi.toml` — new keys: `share_label`, `share_copied`, `share_failed`, `show_qr`, `qr_alt`, `save_contact`
|
||||||
|
- `README.md` — document all new per-link fields and 3 page-level params
|
||||||
|
- `exampleSite/hugo.toml` — toggle one or two of the new params on in demo
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `layouts/partials/share-button.html`
|
||||||
|
- `layouts/partials/qr-block.html`
|
||||||
|
- `layouts/_default/vcard.vcf` (Hugo template)
|
||||||
|
- `static/js/share.js`
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Link visibility window** — top of `link-button.html`:
|
||||||
|
```go
|
||||||
|
{{- $now := now -}}
|
||||||
|
{{- $startOk := true -}}
|
||||||
|
{{- $endOk := true -}}
|
||||||
|
{{- with .startDate }}{{ $startOk = ge $now (time .) }}{{ end -}}
|
||||||
|
{{- with .endDate }}{{ $endOk = le $now (time .) }}{{ end -}}
|
||||||
|
{{- if and $startOk $endOk -}}
|
||||||
|
…render link…
|
||||||
|
{{- end -}}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Image vs icon branching** — inside the `<a>`:
|
||||||
|
```html
|
||||||
|
{{- with .image -}}
|
||||||
|
<img class="link__thumb" src="{{ . | strings.TrimPrefix "/" | relURL }}" alt="" width="24" height="24" loading="lazy" decoding="async" />
|
||||||
|
{{- else with .icon -}}
|
||||||
|
<span class="link__icon" aria-hidden="true">{{ partial "icon.html" . }}</span>
|
||||||
|
{{- end -}}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`featured` class + `rel` merge**:
|
||||||
|
```html
|
||||||
|
{{- $classes := "link" -}}
|
||||||
|
{{- if .featured }}{{ $classes = printf "%s link--featured" $classes }}{{ end -}}
|
||||||
|
{{- $rel := "noopener noreferrer" -}}
|
||||||
|
{{- with .rel }}{{ $rel = printf "%s %s" $rel . }}{{ end -}}
|
||||||
|
<a class="{{ $classes }}" href="{{ .url }}"
|
||||||
|
{{ if $external }}target="_blank" rel="{{ $rel }}"{{ end }}>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Note caption** — inside `.link__title` block:
|
||||||
|
```html
|
||||||
|
<span class="link__title">
|
||||||
|
{{ .title }}
|
||||||
|
{{- with .note }}<span class="link__note">{{ . }}</span>{{- end -}}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Share button**:
|
||||||
|
- Create `partials/share-button.html`.
|
||||||
|
- Create `static/js/share.js`:
|
||||||
|
```js
|
||||||
|
(function(){
|
||||||
|
var btn = document.querySelector('[data-share-url]');
|
||||||
|
if(!btn) return;
|
||||||
|
var toast = document.querySelector('.bio__share-toast');
|
||||||
|
function showToast(msg){
|
||||||
|
if(!toast) return;
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.hidden = false;
|
||||||
|
setTimeout(function(){ toast.hidden = true; }, 2000);
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', async function(){
|
||||||
|
var url = btn.getAttribute('data-share-url');
|
||||||
|
var title = btn.getAttribute('data-share-title');
|
||||||
|
try{
|
||||||
|
if (navigator.share){ await navigator.share({title:title, url:url}); return; }
|
||||||
|
if (navigator.clipboard){ await navigator.clipboard.writeText(url); showToast(btn.getAttribute('data-toast-copied')||'Copied'); return; }
|
||||||
|
prompt('Copy link:', url);
|
||||||
|
} catch(e){ showToast(btn.getAttribute('data-toast-failed')||'Could not share'); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
- Load `defer` from `head.html` only when `params.share = true` — pattern mirrors theme-toggle.
|
||||||
|
|
||||||
|
6. **QR block** — `partials/qr-block.html`:
|
||||||
|
```go
|
||||||
|
{{- if site.Params.qr -}}
|
||||||
|
{{- $url := .Permalink -}}
|
||||||
|
{{- $qr := images.QR $url -}}
|
||||||
|
<details class="bio__qr">
|
||||||
|
<summary>{{ i18n "show_qr" | default "Show QR code" }}</summary>
|
||||||
|
<img class="bio__qr-img" src="{{ $qr.RelPermalink }}" width="200" height="200"
|
||||||
|
alt="{{ i18n "qr_alt" | default "QR code to this page" }}" loading="lazy" decoding="async" />
|
||||||
|
</details>
|
||||||
|
{{- end -}}
|
||||||
|
```
|
||||||
|
Note: `images.QR` returns a resource; Hugo emits a PNG at build time.
|
||||||
|
|
||||||
|
7. **vCard output** — add to `hugo.toml` (theme-managed via `exampleSite` and documented in README):
|
||||||
|
```toml
|
||||||
|
[outputs]
|
||||||
|
home = ["HTML", "RSS", "VCard"]
|
||||||
|
[outputFormats.VCard]
|
||||||
|
mediaType = "text/vcard"
|
||||||
|
baseName = "vcard"
|
||||||
|
isPlainText = true
|
||||||
|
notAlternative = true
|
||||||
|
```
|
||||||
|
Then `layouts/_default/vcard.vcf`:
|
||||||
|
```
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:{{ site.Params.name | default site.Title }}
|
||||||
|
{{ with site.Params.jobTitle }}TITLE:{{ . }}{{ end }}
|
||||||
|
{{ with site.Params.email }}EMAIL:{{ . }}{{ end }}
|
||||||
|
{{ with site.Params.location }}ADR:;;{{ . }};;;;{{ end }}
|
||||||
|
URL:{{ site.BaseURL }}
|
||||||
|
{{ range site.Params.links }}{{ if not (or (strings.HasPrefix .url "mailto:") (strings.HasPrefix .url "tel:")) }}URL;TYPE={{ .title | upper }}:{{ .url }}
|
||||||
|
{{ end }}{{ end }}
|
||||||
|
END:VCARD
|
||||||
|
```
|
||||||
|
Link from `bio-card.html` when `params.vcard = true`:
|
||||||
|
```html
|
||||||
|
<a class="bio__vcard" href="{{ `vcard.vcf` | relURL }}" download>{{ i18n "save_contact" | default "Save contact" }}</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **i18n keys** — append to en/vi toml.
|
||||||
|
|
||||||
|
9. **CSS** — append rules listed in Architecture. Verify gzipped delta ≤ 200 B.
|
||||||
|
|
||||||
|
10. **README documentation** — new "Per-link fields" subsection (table); new "Page actions: share / QR / vCard" subsection with opt-in examples.
|
||||||
|
|
||||||
|
11. **exampleSite demo** — enable `share = true` and `qr = true` (lightweight; both Hugo-native). vCard left off by default to keep demo focused.
|
||||||
|
|
||||||
|
12. **Build verification** — `hugo --gc --minify`. Check `public/vcard.vcf`, `public/index.html` has share button if enabled, QR PNG generated under `public/`.
|
||||||
|
|
||||||
|
## Todo List
|
||||||
|
|
||||||
|
- [ ] Add scheduled-visibility branch to `link-button.html`
|
||||||
|
- [ ] Add `image > icon` branching to `link-button.html`
|
||||||
|
- [ ] Add `featured` class + `rel` merge + `note` caption
|
||||||
|
- [ ] Create `partials/share-button.html` + `static/js/share.js`
|
||||||
|
- [ ] Wire share button conditional `<script defer>` load in `head.html`
|
||||||
|
- [ ] Create `partials/qr-block.html` using `images.QR`
|
||||||
|
- [ ] Create `layouts/_default/vcard.vcf` + add `VCard` output format docs
|
||||||
|
- [ ] Append CSS rules
|
||||||
|
- [ ] Bump theme.toml `min_version` to 0.140.0
|
||||||
|
- [ ] Add 6 new i18n keys (share_label/copied/failed, show_qr, qr_alt, save_contact) in en + vi
|
||||||
|
- [ ] Document all new fields and params in README
|
||||||
|
- [ ] Enable `share = true` + `qr = true` in exampleSite
|
||||||
|
- [ ] Verify CSS budget ≤ 5.5 KB gzipped after additions
|
||||||
|
- [ ] Verify JS budget ≤ 3 KB raw (theme-toggle + share combined)
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Link with `image: "/foo.png"` renders thumbnail; with only `icon: "github"` renders SVG (unchanged)
|
||||||
|
- [ ] Link with `featured: true` has `.link--featured` class and visually larger
|
||||||
|
- [ ] Link with future `startDate` does NOT render in build output (`grep` returns 0)
|
||||||
|
- [ ] Link with past `endDate` does NOT render
|
||||||
|
- [ ] Link with `rel: "sponsored"` outputs `rel="noopener noreferrer sponsored"`
|
||||||
|
- [ ] Share button click triggers `navigator.share` on capable browsers; falls back to clipboard on others
|
||||||
|
- [ ] QR `<img>` present when `params.qr = true`, PNG accessible at its referenced URL
|
||||||
|
- [ ] `/vcard.vcf` downloadable, importable into iOS/Android contacts (manual smoke test)
|
||||||
|
- [ ] CSS production ≤ 5.5 KB gzipped
|
||||||
|
- [ ] No regressions to existing v0.4 demos
|
||||||
|
- [ ] All new features off by default — v0.4 site upgrades with zero config edits
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| `images.QR` unavailable on Hugo < 0.140 | Bump `theme.toml` min_version. CI installs ≥ 0.140 already (latest). |
|
||||||
|
| Share clipboard API in non-secure contexts (http://) fails | Toast falls back to `prompt(url)` for legacy/insecure environments. Documented limitation. |
|
||||||
|
| vCard contains email leaked in plaintext from `params.email` | Document: vCard exposes whatever user puts in params. Off by default. |
|
||||||
|
| Scheduled visibility re-evaluated only at build | Documented; cron-rebuild recipe in README. |
|
||||||
|
| Featured link standing out too much breaks zen aesthetic | Subtle accent border, only 1.5× padding. Designer review at PR time. |
|
||||||
|
| Per-link `image` adds extra HTTP requests | Each `<img>` is `loading="lazy"`, `decoding="async"`. Not on critical path. |
|
||||||
|
| QR generated per build inflates `public/` size | One PNG per page; ~3-5 KB each. Negligible. |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- `rel` merging: user input appended to `noopener noreferrer` baseline — no way to *remove* the safe defaults, only add.
|
||||||
|
- Share JS runs only on click — no auto-execution, no exfiltration risk.
|
||||||
|
- vCard contains only data the user explicitly set in params; no surprise email/phone leakage.
|
||||||
|
- QR encodes only the page's own `Permalink` — no third-party data flow.
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
---
|
||||||
|
phase: 4
|
||||||
|
title: "Opt-in click analytics (Google Analytics 4)"
|
||||||
|
status: completed
|
||||||
|
priority: P2
|
||||||
|
effort: "0.5d"
|
||||||
|
dependencies: []
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Updated: Validation Session 1 — analytics scope reduced from {Plausible, Umami, GA4, custom} to GA4 only per user decision -->
|
||||||
|
|
||||||
|
# Phase 4: Opt-in click analytics (Google Analytics 4)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Allow site owners to plug Google Analytics 4 in via a single config block. Zero data collected by default. When configured, emit the canonical `gtag.js` loader + init snippet in `<head>` and auto-attach `data-analytics-event` attrs on every link so per-link click-throughs land in the user's GA4 property.
|
||||||
|
|
||||||
|
**Scope decision (Validation Session 1):** v0.5 ships GA4 only — not Plausible / Umami / custom. Rationale: ubiquity. Users who prefer Plausible/Umami/etc. can paste their snippet via `params.extraHead` (existing Hugo pattern) or wait for a future phase that re-broadens.
|
||||||
|
|
||||||
|
GA4 sets cookies and **requires user consent** in jurisdictions with strict privacy law (EU GDPR, UK PECR, California CPRA). README + deploy guide make this prominent. Bonsai does not ship a consent banner.
|
||||||
|
|
||||||
|
## Context Links
|
||||||
|
|
||||||
|
- Linktree parity: `plans/reports/researcher-260515-linktree-feature-inventory.md` Table 1 rows "Basic analytics", "Click-through tracking", "UTM auto-fill"
|
||||||
|
- Validation Session 1 (plan.md `## Validation Log`) — locks scope to GA4 only.
|
||||||
|
- GA4 docs: https://developers.google.com/analytics/devguides/collection/ga4
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
New `[params.analytics]` block (object):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[params.analytics]
|
||||||
|
measurementId = "G-XXXXXXXXX" # required; if empty, emit nothing
|
||||||
|
trackClicks = true # auto-emit data-analytics-event on each link
|
||||||
|
utmSource = "" # optional: append ?utm_source=… to external links
|
||||||
|
utmMedium = "bio" # default applied alongside utmSource
|
||||||
|
utmCampaign = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
|
||||||
|
- If `params.analytics.measurementId` empty or unset → emit nothing. **Default.**
|
||||||
|
- Else emit canonical GA4 init:
|
||||||
|
```html
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id={{ measurementId }}"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '{{ measurementId }}');
|
||||||
|
{{- if trackClicks }}
|
||||||
|
document.addEventListener('click',function(e){
|
||||||
|
var a=e.target.closest('[data-analytics-event]');
|
||||||
|
if(a) gtag('event','outbound_click',{event_label:a.getAttribute('data-analytics-event'),link_url:a.href});
|
||||||
|
});
|
||||||
|
{{- end }}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
- When `trackClicks = true` (default), every `<a class="link">` gets `data-analytics-event="link:{{ .title | urlize }}"`. The inline event-firing snippet above reads this attr.
|
||||||
|
- UTM injection: if `utmSource` is set, append `?utm_source=…&utm_medium=…&utm_campaign=…` to each external `http(s)://` link at build time. Skip `mailto:`/`tel:`. Handle existing query strings (`?` already in URL → use `&`).
|
||||||
|
|
||||||
|
### Non-functional
|
||||||
|
|
||||||
|
- Default: zero new bytes shipped (everything opt-in via `measurementId`).
|
||||||
|
- When configured: ~50 KB additional asset (gtag.js from Google's CDN — not bundled).
|
||||||
|
- Inline init script: ~200 B raw / ~100 B gzipped.
|
||||||
|
- Cookie-setting → GDPR consent required where applicable. Documented in README + deploy guide; consent banner NOT shipped.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
partials/head.html
|
||||||
|
└─ {{ partial "analytics-loader.html" . }}
|
||||||
|
|
||||||
|
partials/analytics-loader.html (new)
|
||||||
|
├─ if not params.analytics.measurementId → return ""
|
||||||
|
├─ emit gtag loader (<script async src=…>)
|
||||||
|
└─ emit inline init + (optional) click listener
|
||||||
|
|
||||||
|
partials/link-button.html (modify)
|
||||||
|
├─ inject data-analytics-event="link:{slug}" when trackClicks ≠ false
|
||||||
|
└─ inject UTM params into href when params.analytics.utmSource set
|
||||||
|
|
||||||
|
(no new CSS, no new icons)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Code Files
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `layouts/partials/head.html` — `{{ partial "analytics-loader.html" . }}` invocation
|
||||||
|
- `layouts/partials/link-button.html` — emit `data-analytics-event`, append UTM
|
||||||
|
- `README.md` — new "Analytics" subsection with single GA4 example + bold GDPR consent caveat
|
||||||
|
- `docs/deployment-guide.md` — GDPR/PECR/CPRA consent guidance; recommend pairing with a consent management platform
|
||||||
|
- `exampleSite/hugo.toml` — commented-out `[params.analytics]` example block (NOT enabled in live demo)
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `layouts/partials/analytics-loader.html`
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Create `partials/analytics-loader.html`**:
|
||||||
|
```go
|
||||||
|
{{- $a := site.Params.analytics -}}
|
||||||
|
{{- if or (not $a) (not $a.measurementId) -}}{{ return "" }}{{- end -}}
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id={{ $a.measurementId }}"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '{{ $a.measurementId }}');
|
||||||
|
{{- if ne ($a.trackClicks | default true) false }}
|
||||||
|
document.addEventListener('click',function(e){
|
||||||
|
var a=e.target.closest('[data-analytics-event]');
|
||||||
|
if(a) gtag('event','outbound_click',{event_label:a.getAttribute('data-analytics-event'),link_url:a.href});
|
||||||
|
});
|
||||||
|
{{- end }}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Modify `link-button.html`** (`data-analytics-event` + UTM injection):
|
||||||
|
```go
|
||||||
|
{{- $a := site.Params.analytics -}}
|
||||||
|
{{- $trackClicks := and $a (ne ($a.trackClicks | default true) false) -}}
|
||||||
|
{{- $url := .url -}}
|
||||||
|
{{- /* UTM injection at build time, only for external links */ -}}
|
||||||
|
{{- if and $a $a.utmSource (strings.HasPrefix $url "http") -}}
|
||||||
|
{{- $sep := cond (strings.Contains $url "?") "&" "?" -}}
|
||||||
|
{{- $url = printf "%s%sutm_source=%s&utm_medium=%s%s"
|
||||||
|
$url $sep $a.utmSource ($a.utmMedium | default "bio")
|
||||||
|
(cond (eq ($a.utmCampaign | default "") "") "" (printf "&utm_campaign=%s" $a.utmCampaign)) -}}
|
||||||
|
{{- end -}}
|
||||||
|
<a class="{{ $classes }}" href="{{ $url }}"
|
||||||
|
{{ if $external }}target="_blank" rel="{{ $rel }}"{{ end }}
|
||||||
|
{{ if $trackClicks }}data-analytics-event="link:{{ .title | urlize }}"{{ end }}>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **README "Analytics" section** — single example, bold GDPR/consent caveat:
|
||||||
|
```toml
|
||||||
|
[params.analytics]
|
||||||
|
measurementId = "G-XXXXXXXXX"
|
||||||
|
trackClicks = true
|
||||||
|
utmSource = "bio"
|
||||||
|
```
|
||||||
|
> ⚠️ **GA4 sets cookies.** Sites serving EU / UK / California visitors require explicit user consent before loading `gtag.js`. Bonsai does not ship a consent banner — pair with a consent management platform (e.g. Klaro!, Cookiebot) or wait for a future phase.
|
||||||
|
|
||||||
|
4. **`docs/deployment-guide.md`** — add "Analytics & Consent" subsection covering:
|
||||||
|
- GDPR (EU) — consent required before non-essential cookies set.
|
||||||
|
- PECR (UK) — same.
|
||||||
|
- CPRA (California) — disclosure + opt-out, less strict than GDPR but still requires action.
|
||||||
|
- Sample CMP integration recipe (one paragraph; not code).
|
||||||
|
|
||||||
|
5. **exampleSite**: commented-out `[params.analytics]` block in `hugo.toml`. Do NOT set `measurementId` in the live demo.
|
||||||
|
|
||||||
|
6. **Build verification** — `hugo --gc --minify` with `measurementId = "G-FAKE"` test fixture should emit exactly two new `<script>` tags (async loader + inline init) and `data-analytics-event` on every link. With `measurementId` unset, byte-zero diff vs v0.4 output.
|
||||||
|
|
||||||
|
## Todo List
|
||||||
|
|
||||||
|
- [ ] Create `partials/analytics-loader.html` (GA4-only)
|
||||||
|
- [ ] Add `{{ partial "analytics-loader.html" . }}` to `head.html`
|
||||||
|
- [ ] Add `data-analytics-event` to link-button when trackClicks ≠ false
|
||||||
|
- [ ] Add UTM injection at build time
|
||||||
|
- [ ] Document GA4 measurementId + GDPR caveat in README
|
||||||
|
- [ ] Add consent guidance to `docs/deployment-guide.md`
|
||||||
|
- [ ] Add commented-out exampleSite block
|
||||||
|
- [ ] Verify byte-zero diff when `measurementId` unset
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] When `params.analytics.measurementId` unset, built HTML has no `<script>` from this phase (byte-zero diff to v0.4)
|
||||||
|
- [ ] Valid `measurementId` renders exactly two `<script>` tags
|
||||||
|
- [ ] Every external link has `data-analytics-event="link:<slug>"` when `trackClicks ≠ false`
|
||||||
|
- [ ] UTM params correctly appended, respecting existing `?` vs `&` separator and stripping nothing
|
||||||
|
- [ ] README has bold GDPR caveat; deployment guide covers consent
|
||||||
|
- [ ] No regression in zero-JS default when not configured
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| GA4 without consent → GDPR / PECR / CPRA violation | Bold warning in README + `docs/deployment-guide.md`. Users assume responsibility (theme exposes the snippet, doesn't auto-block). |
|
||||||
|
| UTM injection breaks fragment URLs (`https://x.com/#about`) | Use `strings.Contains` to detect `?`; fragment `#` is preserved because we append before any fragment in typical bio URLs. Edge case: URL with both `?` and `#` — Hugo's `urls.Parse` could handle, but KISS string concat is fine for v0.5; document the edge case. |
|
||||||
|
| Plausible/Umami users feel underserved | Documented: future phase may broaden. They can still paste their snippet via Hugo's `params.extraHead` pattern (not shipped here but well-known). |
|
||||||
|
| Inline init script blocked by strict CSP `script-src 'self'` | Required CSP allowance: `script-src 'self' 'unsafe-inline' https://*.googletagmanager.com`. Documented in deployment guide. |
|
||||||
|
| Hugo `cond` syntax compatibility | Stable since Hugo 0.42 — safely above 0.140 floor. |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- No PII collected by Bonsai itself; GA4's privacy posture governs PII handling. GA4 anonymizes IP by default since 2023.
|
||||||
|
- UTM values are user-set, not personal.
|
||||||
|
- Inline script content is generated from user-controlled `measurementId` — sanitize? Hugo's template engine HTML-escapes by default; `measurementId` format is `G-XXXXXXXXX` (alphanumeric); injection risk is low but not zero. Add a build-time `warnf` if `measurementId` doesn't match `^G-[A-Z0-9]+$`.
|
||||||
|
- The async loader fetches `gtag.js` from `googletagmanager.com` — CSP must allow this host.
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
---
|
||||||
|
phase: 5
|
||||||
|
title: "Performance: Hugo image pipeline (AVIF/WebP responsive)"
|
||||||
|
status: completed
|
||||||
|
priority: P1
|
||||||
|
effort: "2d"
|
||||||
|
dependencies: [3]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 5: Performance — Hugo image pipeline (AVIF/WebP responsive)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace plain `<img>` tags with a Hugo-processed `<picture>` element delivering AVIF + WebP + JPEG fallback, sized correctly for the rendered slot. Apply to both `params.avatar` and per-link `image` (added in Phase 3). Net effect: ~40–60% image payload reduction in modern browsers, eliminated CLS via explicit dimensions, faster LCP via `fetchpriority="high"`.
|
||||||
|
|
||||||
|
Also fingerprint the CSS file for safer long-cache headers (`max-age=31536000, immutable`).
|
||||||
|
|
||||||
|
Depends on Phase 3 — that phase introduces `link.image`, which this phase optimizes.
|
||||||
|
|
||||||
|
## Context Links
|
||||||
|
|
||||||
|
- Source: `plans/reports/researcher-260515-hugo-lighthouse-best-practices.md` §1.2 (responsive images) and §5.3 (asset fingerprinting)
|
||||||
|
- Hugo image processing docs: https://gohugo.io/content-management/image-processing/
|
||||||
|
- Current state: avatar is one raw `<img>` with `loading="eager"` only (no width/height/srcset/AVIF/WebP); Phase 1 adds dimensions/fetchpriority; this phase adds format/responsive negotiation.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
**Avatar pipeline:**
|
||||||
|
|
||||||
|
- When `params.avatar` is a path inside the site (`/images/…` or relative), Hugo's `resources.Get` resolves it.
|
||||||
|
- Generate three variants at standard pixel densities: 112w (1x), 224w (2x).
|
||||||
|
- Encode each in AVIF (q60), WebP (q75), JPEG (q85). Hugo's `Process` action handles AVIF and WebP since 0.83.
|
||||||
|
- Render as `<picture>`:
|
||||||
|
```html
|
||||||
|
<picture>
|
||||||
|
<source type="image/avif" srcset="{avif-112} 1x, {avif-224} 2x">
|
||||||
|
<source type="image/webp" srcset="{webp-112} 1x, {webp-224} 2x">
|
||||||
|
<img class="bio__avatar" src="{jpeg-112}" width="112" height="112"
|
||||||
|
alt="{{ name }}" loading="eager" fetchpriority="high" decoding="async" />
|
||||||
|
</picture>
|
||||||
|
```
|
||||||
|
- If `params.avatar` is an absolute external URL (`https://…`), skip processing — emit a plain `<img>` (with Phase 1 attrs) and document the trade-off.
|
||||||
|
|
||||||
|
**Per-link thumbnail pipeline (from Phase 3):**
|
||||||
|
|
||||||
|
- When `link.image` is a local resource, process to 24w + 48w (1x / 2x) in WebP + JPEG; AVIF skipped (overhead not worth for thumbnails this small).
|
||||||
|
- `loading="lazy"`, `decoding="async"`, explicit width/height.
|
||||||
|
|
||||||
|
**CSS fingerprinting:**
|
||||||
|
|
||||||
|
- `bonsai.css` loaded via `resources.Get | resources.Minify | resources.Fingerprint`, emitted with content-hash filename `/css/bonsai.{hash}.css`.
|
||||||
|
- Enables hosting to set `Cache-Control: public, max-age=31536000, immutable` on `/css/*` safely.
|
||||||
|
- Same treatment for `theme-toggle.js` and `share.js`.
|
||||||
|
|
||||||
|
### Non-functional
|
||||||
|
|
||||||
|
- Hugo extended required for WebP/AVIF (already a soft requirement per Bonsai README "Hugo ≥ 0.128 extended"). Reaffirm in `theme.toml` and README.
|
||||||
|
- Build time impact: image processing is cached in `resources/_gen/`. First-time build adds ~2–4 sec per processed image; subsequent builds reuse cache.
|
||||||
|
- AVIF encode is slow at high quality but ~1.5–2× faster at q60 vs q90. Use q60 with `lossless = false` defaults.
|
||||||
|
- For external-URL avatars: no processing, no benefit — document.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
partials/avatar.html (rewrite)
|
||||||
|
├─ if params.avatar is local resource:
|
||||||
|
│ $orig := resources.Get $path
|
||||||
|
│ $avif1x := $orig.Process "resize 112x112 webp" / similar pipeline
|
||||||
|
│ $webp1x, $jpg1x same
|
||||||
|
│ $avif2x, $webp2x, $jpg2x at 224x224
|
||||||
|
│ emit <picture>
|
||||||
|
└─ else fall through to existing branch (external URL → plain <img>; unset → SVG initials)
|
||||||
|
|
||||||
|
partials/link-button.html (modify Phase 3 image branch)
|
||||||
|
└─ if link.image is local:
|
||||||
|
process to 24w + 48w in webp + jpg
|
||||||
|
emit <picture> with srcset
|
||||||
|
else: emit plain <img>
|
||||||
|
|
||||||
|
partials/head.html (modify)
|
||||||
|
└─ stylesheet link now references fingerprinted resource:
|
||||||
|
{{ $css := resources.Get "css/bonsai.css" | resources.Minify | resources.Fingerprint }}
|
||||||
|
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" />
|
||||||
|
|
||||||
|
└─ same pattern for theme-toggle.js (when themeToggle=true) and share.js (when share=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Code Files
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `layouts/partials/avatar.html` — picture-element output for local avatars
|
||||||
|
- `layouts/partials/link-button.html` — picture-element output for local link thumbnails
|
||||||
|
- `layouts/partials/head.html` — fingerprinted stylesheet + scripts with `integrity=`
|
||||||
|
- `theme.toml` — reaffirm `min_version = "0.140.0"` and "Hugo extended required" note
|
||||||
|
- `README.md` — document image-pipeline behavior and the local-vs-external distinction
|
||||||
|
- `exampleSite/hugo.toml` — point demo `avatar` to a local image so demo exercises the pipeline (currently `/images/avatar.svg` which is already local — verify path resolves)
|
||||||
|
|
||||||
|
**Move (assets → resources, if needed):**
|
||||||
|
- For Hugo to process `/images/avatar.svg`, image must live under `assets/` not `static/`. Decision: keep user images under `static/` for backward-compat AND document that processing requires moving to `assets/`. OR: detect both via `resources.Get` (works for `assets/`) and `resources.GetMatch` against `static/` via a helper. Simpler: tell users to put avatars in `assets/avatars/` and reference as `avatar = "avatars/me.jpg"`.
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `exampleSite/assets/avatars/sample-avatar.jpg` — replace SVG placeholder so demo exercises image pipeline
|
||||||
|
- Update `exampleSite/hugo.toml` → `avatar = "avatars/sample-avatar.jpg"`
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Decide and document the avatar path convention.**
|
||||||
|
Two options:
|
||||||
|
|
||||||
|
**Option A (recommended):** Bonsai treats `params.avatar` as a path relative to `assets/`. Convention shift from v0.4 (where it was a URL or a `static/` path).
|
||||||
|
|
||||||
|
**Option B (back-compat):** Theme tries `resources.Get` first; on miss, falls back to plain `<img>` with the unprocessed path. No user migration needed.
|
||||||
|
|
||||||
|
**Plan with Option B.** Pseudo:
|
||||||
|
```go
|
||||||
|
{{- $avatar := site.Params.avatar -}}
|
||||||
|
{{- $local := false -}}
|
||||||
|
{{- $img := "" -}}
|
||||||
|
{{- if not (strings.HasPrefix $avatar "http") -}}
|
||||||
|
{{- $clean := strings.TrimPrefix $avatar "/" -}}
|
||||||
|
{{- with resources.Get $clean -}}
|
||||||
|
{{- $img = . -}}{{- $local = true -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $local -}}
|
||||||
|
{{- /* picture-element pipeline */ -}}
|
||||||
|
{{- else if $avatar -}}
|
||||||
|
{{- /* fallback to plain <img> with Phase-1 attrs */ -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- /* existing initials SVG branch */ -}}
|
||||||
|
{{- end -}}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Avatar `<picture>` pipeline:**
|
||||||
|
```go
|
||||||
|
{{- $w := 112 -}}{{- $h := 112 -}}
|
||||||
|
{{- $jpg1x := $img.Process (printf "resize %dx%d jpg q85" $w $h) -}}
|
||||||
|
{{- $jpg2x := $img.Process (printf "resize %dx%d jpg q85" (mul $w 2) (mul $h 2)) -}}
|
||||||
|
{{- $webp1x := $img.Process (printf "resize %dx%d webp q75" $w $h) -}}
|
||||||
|
{{- $webp2x := $img.Process (printf "resize %dx%d webp q75" (mul $w 2) (mul $h 2)) -}}
|
||||||
|
{{- $avif1x := $img.Process (printf "resize %dx%d webp q60" $w $h) | resources.Process "resize 112x112 avif" -}}
|
||||||
|
```
|
||||||
|
Note: Hugo 0.83+ added AVIF via `resources.Process "convert avif"`. Confirm syntax against installed Hugo at implementation time — exact format string changed across 0.83/0.140; reference live docs.
|
||||||
|
|
||||||
|
3. **Link-thumbnail pipeline** mirrors avatar but at 24w + 48w, WebP + JPEG only (skip AVIF).
|
||||||
|
|
||||||
|
4. **Fingerprinted CSS** (`head.html`):
|
||||||
|
```go
|
||||||
|
{{- $css := resources.Get "css/bonsai.css" | resources.Minify | resources.Fingerprint "sha384" -}}
|
||||||
|
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" crossorigin="anonymous" />
|
||||||
|
```
|
||||||
|
- Move `static/css/bonsai.css` → `assets/css/bonsai.css` (Hugo `resources.Get` reads from `assets/`).
|
||||||
|
- Same for `theme-toggle.js`, `share.js` → `assets/js/…`.
|
||||||
|
- This is a structural change: update `static/css/…` references in `layouts/themes/single.html` and `layouts/variants/single.html` to use `resources.Get "css/gallery.css"` too.
|
||||||
|
|
||||||
|
5. **Subresource Integrity (SRI):** add `integrity=` and `crossorigin=` to stylesheet/scripts. Validates content hash. Requires same-origin or CORS-friendly. Document interaction with CSP (`'strict-dynamic'` incompatible — keep simple CSP).
|
||||||
|
|
||||||
|
6. **Migrate sample assets in exampleSite** so demo exercises the pipeline:
|
||||||
|
- Move `exampleSite/static/images/avatar.svg` → keep, but also add `exampleSite/assets/avatars/sample-avatar.jpg` (real raster image, ~200×200 source).
|
||||||
|
- Switch `avatar = "/images/avatar.svg"` → `avatar = "avatars/sample-avatar.jpg"`.
|
||||||
|
- SVG demo can move to a `/svg-avatar/` page if desired (out of scope).
|
||||||
|
|
||||||
|
7. **Build verification + measurement**:
|
||||||
|
```bash
|
||||||
|
cd exampleSite && hugo --themesDir ../.. --gc --minify --templateMetrics
|
||||||
|
# Inspect public/avatars/ — should contain avif/webp/jpg variants
|
||||||
|
# Inspect public/css/bonsai.{hash}.css — fingerprinted output
|
||||||
|
# Measure sizes
|
||||||
|
du -sb public/avatars/* public/css/* public/js/*
|
||||||
|
gzip -kc public/css/bonsai.*.css | wc -c
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Lighthouse run** (manual, on built site):
|
||||||
|
- Expect Performance improvement ≥ +10 points from image format alone.
|
||||||
|
- LCP element should now be the optimized AVIF/WebP avatar.
|
||||||
|
|
||||||
|
## Todo List
|
||||||
|
|
||||||
|
- [ ] Move `static/css/*.css` → `assets/css/*.css`
|
||||||
|
- [ ] Move `static/js/*.js` → `assets/js/*.js`
|
||||||
|
- [ ] Add fingerprinted `<link>` + `<script>` with SRI in `head.html`
|
||||||
|
- [ ] Rewrite `partials/avatar.html` with local-vs-external branching + `<picture>` pipeline
|
||||||
|
- [ ] Update `partials/link-button.html` Phase-3 image branch to use `<picture>` pipeline
|
||||||
|
- [ ] Update `exampleSite` to use a real raster avatar under `assets/`
|
||||||
|
- [ ] Update `layouts/themes/single.html` + `layouts/variants/single.html` gallery CSS reference to use resources.Get
|
||||||
|
- [ ] Document avatar path convention shift in README (back-compat note: still works for absolute URL & static/-relative paths via fallback)
|
||||||
|
- [ ] Verify CSP `'self'` still passes with SRI + CORS
|
||||||
|
- [ ] Measure: image payload reduction vs v0.4 baseline
|
||||||
|
- [ ] Measure: Lighthouse Performance delta on exampleSite
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Local avatar serves as `<picture>` with AVIF, WebP, JPEG sources
|
||||||
|
- [ ] External-URL avatar still works (fallback to plain `<img>` with Phase-1 attrs)
|
||||||
|
- [ ] Avatar payload (best-format negotiated) ≤ 50% of v0.4 JPEG-only size for a 200×200 source
|
||||||
|
- [ ] CSS link is fingerprinted; identical-content rebuilds reuse same hash
|
||||||
|
- [ ] `integrity=` attribute present and validates (browser DevTools: no SRI errors)
|
||||||
|
- [ ] No regression in zero-JS default — pipeline runs at build time only
|
||||||
|
- [ ] `hugo --gc --minify` builds clean; processed images cached on second run
|
||||||
|
- [ ] Lighthouse Performance ≥ 90 on exampleSite (locked threshold per validation session 1)
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| AVIF encoding slow / build-time impact in CI | Hugo caches under `resources/_gen/`; commit cache only if CI is greenfield. Document `--gc` to prune. |
|
||||||
|
| Hugo AVIF support varies across versions (`resources.Process "avif"`) | Pin `min_version = "0.140.0"` (already planned in Phase 3). Confirm AVIF works on 0.140+. |
|
||||||
|
| Move from `static/` to `assets/` is a breaking change for user sites that hard-code paths | Phase uses fallback to `<img>` when `resources.Get` misses — keeps back-compat. |
|
||||||
|
| SRI `integrity=` blocks loading when CSS is edited locally during dev | Hugo regenerates hash on every rebuild; only a problem for stale CDN cache, which is the use case SRI solves. |
|
||||||
|
| CSP must allow `'self'` for stylesheets (already does in Phase 1 deploy template) | Confirmed — Phase 1 CSP allows `'self'` for style-src. |
|
||||||
|
| Hash-in-filename breaks naive cache-busting some users rely on | Documented; `?v=…` query-string busting still works for users who prefer it (would require revert). |
|
||||||
|
| Image processing fails silently for missing files | `resources.Get` returns nil → falls through to `<img>` branch — safe. |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- SRI (`integrity=`) ensures CSS/JS isn't tampered with in transit by an intermediate proxy.
|
||||||
|
- Hugo's image processing happens at build time on trusted source files; no runtime image processing means no SSRF surface.
|
||||||
|
- AVIF/WebP decoders in browsers are well-fuzzed; no novel attack surface introduced.
|
||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
---
|
||||||
|
phase: 6
|
||||||
|
title: "SEO + a11y hardening + deploy templates"
|
||||||
|
status: completed
|
||||||
|
priority: P2
|
||||||
|
effort: "2d"
|
||||||
|
dependencies: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 6: SEO + a11y hardening + deploy templates
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Mop-up phase for the SEO / Best-Practices / A11y categories that Phase 1 quick-wins didn't cover. Adds hreflang for i18n, opt-in WebSite JSON-LD, opt-in web manifest, tap-target audit fix, plus a complete set of deploy templates (Netlify, Vercel, Cloudflare Pages, GitHub Pages CDN-via-Cloudflare) so users can apply security headers and aggressive cache policies in one commit.
|
||||||
|
|
||||||
|
## Context Links
|
||||||
|
|
||||||
|
- Source: `plans/reports/researcher-260515-hugo-lighthouse-best-practices.md` §4.2 (hreflang), §4.3 (WebSite schema), §4.5 (tap targets), §5.1 (manifest)
|
||||||
|
- Audit: `plans/reports/researcher-260515-bonsai-current-audit.md` §8 ("Present ✅" / "Missing ❌") — confirms hreflang, WebSite schema, manifest are missing
|
||||||
|
- Phase 1 already shipped: `_headers`, `vercel.json` minimal templates — this phase expands them
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
**1. Hreflang links** (only when site has multiple languages configured):
|
||||||
|
```html
|
||||||
|
{{- if gt (len site.Languages) 1 }}
|
||||||
|
{{- range site.Languages }}
|
||||||
|
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ site.BaseURL | relLangURL }}" />
|
||||||
|
{{- end }}
|
||||||
|
<link rel="alternate" hreflang="x-default" href="{{ site.BaseURL }}" />
|
||||||
|
{{- end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Opt-in WebSite + SearchAction JSON-LD** (rich-snippet eligibility):
|
||||||
|
- New `params.schemaWebSite` boolean (default `false` — pure opt-in; doesn't change existing Person schema).
|
||||||
|
- When `true`, emit a second `<script type="application/ld+json">` with `@type: WebSite` referencing the existing Person via `author`.
|
||||||
|
|
||||||
|
**3. Opt-in web manifest** (installable PWA affordance):
|
||||||
|
- New `params.manifest` boolean (default `false`).
|
||||||
|
- When `true`, theme emits `<link rel="manifest" href="/manifest.webmanifest">` AND generates the manifest file via Hugo output format.
|
||||||
|
- Manifest derives `name`, `short_name`, `theme_color`, `background_color`, icon paths from `params.name`, `params.themeColor`, `params.faviconSvg`, `params.appleTouchIcon`.
|
||||||
|
|
||||||
|
**4. Tap-target audit + fix:**
|
||||||
|
- Verify all link layouts meet 48×48 px tap target with 8 px spacing.
|
||||||
|
- Current state (audit): `stack` and `grid` already comfortably over 48 px due to ~.9 rem padding. `inline` (icon-only row) currently uses 20×20 icons in a flex row — needs explicit min-width/min-height.
|
||||||
|
- Add CSS:
|
||||||
|
```css
|
||||||
|
.bio__links--inline .link { min-width: 48px; min-height: 48px; padding: .75rem; }
|
||||||
|
.bio__links--inline { gap: clamp(.5rem, 1.5vw, 1rem); /* ensure ≥ 8 px spacing */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Expanded deploy templates:**
|
||||||
|
- Build out `docs/deployment-guide.md` started in Phase 1 with full Netlify, Vercel, Cloudflare Pages, GitHub Pages (note: no custom headers; suggest Cloudflare Worker overlay) sections.
|
||||||
|
- Each section: full config snippet, what each line does, what Lighthouse audit it satisfies.
|
||||||
|
|
||||||
|
**6. Robots.txt + sitemap.xml** (Hugo-native):
|
||||||
|
- Currently disabled in exampleSite: `disableKinds = ["sitemap", "RSS", "404"]`.
|
||||||
|
- For a single-page bio, `sitemap.xml` is low-value but Lighthouse SEO sometimes flags missing. Recommendation: keep disabled by default but document opt-in.
|
||||||
|
- Robots.txt: Hugo emits a default one if `enableRobotsTXT = true`. Recommend setting this in README config snippet.
|
||||||
|
|
||||||
|
### Non-functional
|
||||||
|
|
||||||
|
- New params (`schemaWebSite`, `manifest`) both default off — no behavioral change for existing sites.
|
||||||
|
- Hreflang block emits *only* when multi-language site detected; zero overhead for default single-language users.
|
||||||
|
- CSS additions ≤ 200 B raw (inline-layout tap-target fix).
|
||||||
|
- Manifest file size ≤ 500 B (generated once at build time when opted in).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
partials/head.html
|
||||||
|
├─ hreflang block (conditional on multi-language)
|
||||||
|
├─ optional WebSite schema (new partial: partials/schema-website.html)
|
||||||
|
└─ optional manifest link (when params.manifest)
|
||||||
|
|
||||||
|
partials/schema-website.html (new)
|
||||||
|
└─ JSON-LD WebSite with author → Person reference
|
||||||
|
|
||||||
|
layouts/_default/manifest.webmanifest (new) + outputFormat config
|
||||||
|
└─ Hugo template that builds the manifest JSON from params
|
||||||
|
|
||||||
|
static/css/bonsai.css → assets/css/bonsai.css (post-Phase-5)
|
||||||
|
└─ tap-target fix for .bio__links--inline .link
|
||||||
|
|
||||||
|
docs/deployment-guide.md
|
||||||
|
├─ Netlify section: full netlify.toml + _headers
|
||||||
|
├─ Vercel section: full vercel.json
|
||||||
|
├─ Cloudflare Pages section: _headers (compatible with Netlify format)
|
||||||
|
└─ GitHub Pages section: limitation note + Cloudflare-Worker overlay recipe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Code Files
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `layouts/partials/head.html` — hreflang block, conditional manifest link, conditional WebSite schema partial call
|
||||||
|
- `assets/css/bonsai.css` (was `static/css/bonsai.css` pre-Phase-5) — tap-target fix for inline layout
|
||||||
|
- `README.md` — document `schemaWebSite`, `manifest`, `enableRobotsTXT` recommendation, hreflang behavior
|
||||||
|
- `exampleSite/hugo.toml` — set `enableRobotsTXT = true`; demonstrate `manifest = true`
|
||||||
|
- `docs/deployment-guide.md` — expand to full 4-host coverage
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `layouts/partials/schema-website.html`
|
||||||
|
- `layouts/_default/manifest.webmanifest`
|
||||||
|
- `exampleSite/static/manifest-icon-192.png` and `manifest-icon-512.png` (or document that user must provide)
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Hreflang block** (`head.html`, near canonical link):
|
||||||
|
```html
|
||||||
|
{{- if gt (len site.Languages) 1 }}
|
||||||
|
{{- range site.Languages }}
|
||||||
|
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ partial "internal/_funcs/relLangPrefix.html" . }}{{ $.RelPermalink }}" />
|
||||||
|
{{- end }}
|
||||||
|
<link rel="alternate" hreflang="x-default" href="{{ $.Permalink }}" />
|
||||||
|
{{- end }}
|
||||||
|
```
|
||||||
|
Hugo's `RelPermalink` + i18n integration handles per-language path prefixes.
|
||||||
|
|
||||||
|
2. **WebSite schema partial** (`partials/schema-website.html`):
|
||||||
|
```go
|
||||||
|
{{- if site.Params.schemaWebSite -}}
|
||||||
|
{{- $payload := dict
|
||||||
|
"@context" "https://schema.org"
|
||||||
|
"@type" "WebSite"
|
||||||
|
"name" (site.Params.name | default site.Title)
|
||||||
|
"url" site.BaseURL
|
||||||
|
"inLanguage" site.LanguageCode
|
||||||
|
-}}
|
||||||
|
{{- with site.Params.bio -}}{{- $payload = merge $payload (dict "description" .) -}}{{- end -}}
|
||||||
|
<script type="application/ld+json">{{ $payload | jsonify | safeJS }}</script>
|
||||||
|
{{- end -}}
|
||||||
|
```
|
||||||
|
Invoke from `head.html` after `schema-person.html`.
|
||||||
|
|
||||||
|
3. **Manifest template** (`layouts/_default/manifest.webmanifest`):
|
||||||
|
```go
|
||||||
|
{{- $themeColor := site.Params.themeColor | default "#8b3a2b" -}}
|
||||||
|
{{- $bgColor := site.Params.themeBackground | default "#f4efe6" -}}
|
||||||
|
{
|
||||||
|
"name": "{{ site.Params.name | default site.Title }}",
|
||||||
|
"short_name": "{{ substr (site.Params.name | default site.Title) 0 12 }}",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "{{ $bgColor }}",
|
||||||
|
"theme_color": "{{ $themeColor }}",
|
||||||
|
"icons": [
|
||||||
|
{{- with site.Params.appleTouchIcon }}
|
||||||
|
{ "src": "{{ . | relURL }}", "sizes": "180x180", "type": "image/png" }{{ if site.Params.faviconSvg }},{{ end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with site.Params.faviconSvg }}
|
||||||
|
{ "src": "{{ . | relURL }}", "sizes": "any", "type": "image/svg+xml" }
|
||||||
|
{{- end }}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Add to hugo.toml outputs:
|
||||||
|
```toml
|
||||||
|
[outputs]
|
||||||
|
home = ["HTML", "RSS", "Manifest"]
|
||||||
|
[outputFormats.Manifest]
|
||||||
|
mediaType = "application/manifest+json"
|
||||||
|
baseName = "manifest"
|
||||||
|
isPlainText = true
|
||||||
|
notAlternative = true
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Manifest link in head**:
|
||||||
|
```html
|
||||||
|
{{- if site.Params.manifest }}
|
||||||
|
<link rel="manifest" href="{{ `manifest.webmanifest` | relURL }}" />
|
||||||
|
{{- end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Tap-target CSS fix** (append to `bonsai.css`):
|
||||||
|
```css
|
||||||
|
.bio__links--inline .link { min-width: 48px; min-height: 48px; padding: .75rem; justify-content: center; }
|
||||||
|
.bio__links--inline { gap: clamp(.5rem, 1.5vw, 1rem); }
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Expand `docs/deployment-guide.md`:**
|
||||||
|
- **Netlify** section: `netlify.toml` (build config) + `_headers` (security + cache). Brotli auto-applied.
|
||||||
|
- **Vercel** section: `vercel.json` with `headers` array, `cleanUrls`, `trailingSlash` settings.
|
||||||
|
- **Cloudflare Pages** section: same `_headers` as Netlify; mention Brotli auto.
|
||||||
|
- **GitHub Pages**: limitation note (no custom headers). Recipe: front with a Cloudflare Worker that injects headers. Provide minimal Worker script.
|
||||||
|
- Each section: link from-text to the Lighthouse audit it satisfies (e.g., "X-Content-Type-Options nosniff → Lighthouse Best-Practices 'Has a strong HTTPS-only policy'").
|
||||||
|
|
||||||
|
7. **README updates**: brief sub-section "Lighthouse hardening" pointing to deployment-guide; bullet list of new opt-in params.
|
||||||
|
|
||||||
|
8. **exampleSite**: enable `manifest = true`, `schemaWebSite = true`, `enableRobotsTXT = true` in `hugo.toml` to showcase all three on the live demo.
|
||||||
|
|
||||||
|
9. **Build verification**:
|
||||||
|
- `hugo --gc --minify`. Verify `public/manifest.webmanifest` is valid JSON (parse with `jq`).
|
||||||
|
- Verify `public/robots.txt` present.
|
||||||
|
- View built `public/index.html`: confirm hreflang block emitted only when multi-language config, manifest `<link>` present, WebSite JSON-LD present.
|
||||||
|
|
||||||
|
## Todo List
|
||||||
|
|
||||||
|
- [ ] Add hreflang block to `head.html` (conditional on `len site.Languages > 1`)
|
||||||
|
- [ ] Create `partials/schema-website.html` + wire into `head.html`
|
||||||
|
- [ ] Create `layouts/_default/manifest.webmanifest` + register output format
|
||||||
|
- [ ] Add tap-target fix CSS for `.bio__links--inline .link`
|
||||||
|
- [ ] Expand `docs/deployment-guide.md` with Netlify / Vercel / Cloudflare Pages / GitHub Pages sections
|
||||||
|
- [ ] Add Cloudflare Worker overlay recipe for GitHub Pages
|
||||||
|
- [ ] Document new params in README (`schemaWebSite`, `manifest`)
|
||||||
|
- [ ] Set `enableRobotsTXT = true` in exampleSite
|
||||||
|
- [ ] Enable `manifest = true` + `schemaWebSite = true` in exampleSite demo
|
||||||
|
- [ ] Provide placeholder manifest icons (192×192, 512×512) in exampleSite static/
|
||||||
|
- [ ] Verify hreflang appears only on multi-lang site fixture
|
||||||
|
- [ ] Verify Lighthouse SEO ≥ 90 on exampleSite
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Hreflang block emits for multi-lang fixture; absent on single-lang
|
||||||
|
- [ ] `params.schemaWebSite = true` emits valid WebSite JSON-LD (validate against schema.org parser)
|
||||||
|
- [ ] `params.manifest = true` produces `/manifest.webmanifest` (valid JSON) and `<link rel="manifest">`
|
||||||
|
- [ ] All three inline-layout link variants pass Lighthouse "Tap targets sized appropriately" audit
|
||||||
|
- [ ] `docs/deployment-guide.md` covers 4 hosts with full snippets
|
||||||
|
- [ ] Lighthouse SEO ≥ 90 on exampleSite demo
|
||||||
|
- [ ] Lighthouse Best-Practices ≥ 90 once hosting headers applied
|
||||||
|
- [ ] No regression: existing v0.4 demos render identical HTML when none of the new opt-in params are set
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Hreflang URL construction misuses Hugo i18n helpers (Hugo's `relLangURL` is page-dependent, not site-level) | Use page-context `.RelPermalink` per language site object — test against the i18n exampleSite fixture. Hugo's docs cover this pattern. |
|
||||||
|
| Manifest icons missing → invalid manifest, Lighthouse warns | Provide placeholder PNG icons in exampleSite; document required icon sizes in README. |
|
||||||
|
| Adding WebSite schema introduces JSON-LD validation conflicts with Person | Keep WebSite as a separate `<script>` tag with `@type: WebSite`; Person remains its own ProfilePage. Google supports multiple JSON-LD blocks per page. |
|
||||||
|
| Manifest output format collides with someone's existing custom output formats | Output format `Manifest` uses a unique baseName and mediaType — collision unlikely. |
|
||||||
|
| Inline layout tap-target fix changes the visual density users rely on | Document; the change applies only to `inline` layout which is the explicitly icon-only mode where tap targets matter most. |
|
||||||
|
| GitHub Pages users without Cloudflare can't apply security headers | Documented limitation. Suggest hosting alternatives (Netlify free tier) for users who care. |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Manifest is a passive declarative file — no execution context, no injection vector.
|
||||||
|
- Hreflang link tags are URL-only; no script content.
|
||||||
|
- WebSite JSON-LD uses `jsonify | safeJS` like existing Person schema — same trust model.
|
||||||
|
- Documented Cloudflare Worker recipe runs at edge, not in user browser — owned by the site operator.
|
||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
---
|
||||||
|
phase: 7
|
||||||
|
title: "Auto-OG image (#7) — measured ≤30KB prototype"
|
||||||
|
status: completed
|
||||||
|
priority: P3
|
||||||
|
effort: "2d"
|
||||||
|
dependencies: [5]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 7: Auto-OG image (#7) — measured ≤30 KB prototype
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Auto-generate a 1200×630 Open Graph social-preview image at build time per palette, with the site's name + tagline overlaid. Resolves deferred issue #7 from v0.4 CHANGELOG. **Gate the entire phase behind a measured-size check:** if total binary footprint added to the theme exceeds 30 KB, ship the feature with **vendored placeholder images** only and ask users to override via `params.ogImageUrl`, deferring auto-generation again.
|
||||||
|
|
||||||
|
Depends on Phase 5 (Hugo resources pipeline) — uses `images.Filter` + `images.Text` on a base PNG.
|
||||||
|
|
||||||
|
## Context Links
|
||||||
|
|
||||||
|
- Source deferral: `CHANGELOG.md [Unreleased]` → "Auto-generated OG images (#7) — moved again from v0.4 because no candidate path meets the ≤ 30 KB binary budget"
|
||||||
|
- v0.4 deferred plan note: `plans/260510-0238-v0-4-release/plan.md` → "Of 5 candidate approaches, only `pyftsubset Inter` + base PNG might fit; needs measured prototype"
|
||||||
|
- Hugo `images.Text` docs: writes text directly onto an image, requires a TTF font file vendored in `assets/fonts/`
|
||||||
|
- Hugo `images.Filter` + `images.Process`: chained transforms
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
**Per-palette base PNGs** (4 files, one per palette):
|
||||||
|
- `assets/og/base-bonsai.png` (1200×630, washi cream background, vermilion accent)
|
||||||
|
- `assets/og/base-sakura.png` (1200×630, blossom pink background, rose accent)
|
||||||
|
- `assets/og/base-sumi.png` (1200×630, near-white background, black accent)
|
||||||
|
- `assets/og/base-koi.png` (1200×630, cream + orange)
|
||||||
|
|
||||||
|
**Text overlay** (Hugo `images.Text`):
|
||||||
|
- Render `params.name` at large size, top-left.
|
||||||
|
- Render `params.tagline` at smaller size, beneath name.
|
||||||
|
- Use a vendored subset of a free font (Inter, JetBrains Mono — assess). Subset to only the Latin glyphs actually used → ~10–15 KB.
|
||||||
|
|
||||||
|
**Resolution logic** (extends Phase 1/v0.3 OG cascade in `head.html`):
|
||||||
|
```
|
||||||
|
1. params.ogImage == false → emit nothing (preserved from v0.3)
|
||||||
|
2. params.ogImageUrl set → use it (preserved)
|
||||||
|
3. else if auto-OG enabled AND base PNG matches palette → use generated PNG
|
||||||
|
4. else fall back to params.avatar (preserved)
|
||||||
|
5. else → no og:image
|
||||||
|
```
|
||||||
|
|
||||||
|
**New params:**
|
||||||
|
- `params.ogAuto` (bool, default `false`) — opt-in to auto-OG; explicit until binary footprint proven acceptable.
|
||||||
|
- `params.ogAutoFont` (string, default `"inter"`) — placeholder for future font choice.
|
||||||
|
|
||||||
|
### Non-functional
|
||||||
|
|
||||||
|
- **Hard constraint:** total binary added to theme ≤ **30 KB** (compressed). Measure before merge:
|
||||||
|
- 4 × base PNG @ 1200×630 indexed PNG quantized to 8-bit palette: target ≤ 4–5 KB each → ~20 KB total
|
||||||
|
- Font subset (latin only, ~250 glyphs, woff2 with TTF source): ~8–10 KB
|
||||||
|
- **Total budget: ≤ 30 KB**
|
||||||
|
- If measured total > 30 KB → ship feature with `params.ogAuto = true` documented but NO base PNGs vendored; user must supply their own base + font path. Re-defer "auto-everything" to v0.6.
|
||||||
|
- Generated OG image cached by Hugo (deterministic from params + base + font), regenerated only when params change.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/
|
||||||
|
├── og/
|
||||||
|
│ ├── base-bonsai.png (≤ 5 KB indexed PNG)
|
||||||
|
│ ├── base-sakura.png (≤ 5 KB)
|
||||||
|
│ ├── base-sumi.png (≤ 5 KB)
|
||||||
|
│ └── base-koi.png (≤ 5 KB)
|
||||||
|
└── fonts/
|
||||||
|
└── inter-subset.ttf (~10 KB latin subset; TTF for Hugo's images.Text)
|
||||||
|
|
||||||
|
partials/head.html
|
||||||
|
└─ extend OG image cascade:
|
||||||
|
{{- if and site.Params.ogAuto (not site.Params.ogImageUrl) -}}
|
||||||
|
{{- $palette := site.Params.colorTheme | default "bonsai" -}}
|
||||||
|
{{- $base := resources.Get (printf "og/base-%s.png" $palette) -}}
|
||||||
|
{{- with $base -}}
|
||||||
|
{{- $generated := . | images.Filter (images.Text $name (dict
|
||||||
|
"linespacing" 10
|
||||||
|
"size" 72
|
||||||
|
"color" "#2b2b2b"
|
||||||
|
"x" 80 "y" 200
|
||||||
|
"font" $font)) -}}
|
||||||
|
{{- if site.Params.tagline -}}
|
||||||
|
{{- $generated = $generated | images.Filter (images.Text site.Params.tagline (dict
|
||||||
|
"size" 36
|
||||||
|
"color" "#6b6b6b"
|
||||||
|
"x" 80 "y" 320
|
||||||
|
"font" $font)) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $ogImageUrl = $generated.Permalink -}}
|
||||||
|
{{- $twitterCard = "summary_large_image" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Code Files
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `layouts/partials/head.html` — extend OG cascade with auto-generation branch
|
||||||
|
- `theme.toml` — note binary footprint and license attribution for vendored font
|
||||||
|
- `README.md` — document `ogAuto`, the per-palette base PNGs, font attribution
|
||||||
|
- `NOTICE` — add font license (Inter is SIL OFL 1.1 — must include license text)
|
||||||
|
- `exampleSite/hugo.toml` — set `ogAuto = true` to showcase
|
||||||
|
- `CHANGELOG.md` — entry for v0.5 with size measurement quoted
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `assets/og/base-bonsai.png`
|
||||||
|
- `assets/og/base-sakura.png`
|
||||||
|
- `assets/og/base-sumi.png`
|
||||||
|
- `assets/og/base-koi.png`
|
||||||
|
- `assets/fonts/inter-subset.ttf`
|
||||||
|
- `scripts/subset-font.sh` — reproducible build script using `pyftsubset` to regenerate the font subset
|
||||||
|
- `scripts/build-og-base.sh` — reproducible script to generate the 4 base PNGs from SVG sources (so palette tweaks aren't binary-only)
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Build the four base PNGs:**
|
||||||
|
- Author 4 SVG sources at 1200×630 with palette colors, geometric Japanese-inspired motifs (subtle washi paper texture or torii silhouette).
|
||||||
|
- Rasterize with ImageMagick or `rsvg-convert` to PNG.
|
||||||
|
- Quantize to 8-bit indexed palette via `pngquant --quality 65-85 --strip`.
|
||||||
|
- Verify: ≤ 5 KB each. If any exceeds: simplify the SVG and re-rasterize.
|
||||||
|
|
||||||
|
2. **Subset the font:**
|
||||||
|
- Script `scripts/subset-font.sh`:
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Subset Inter to Latin Extended-A (covers en + vi diacritics).
|
||||||
|
pyftsubset Inter-Regular.ttf \
|
||||||
|
--output-file=assets/fonts/inter-subset.ttf \
|
||||||
|
--unicodes="U+0020-007F,U+00A0-00FF,U+0100-017F,U+0180-024F,U+1E00-1EFF" \
|
||||||
|
--layout-features="*" \
|
||||||
|
--no-hinting
|
||||||
|
```
|
||||||
|
- Target size: ≤ 10 KB TTF.
|
||||||
|
- Document regen steps in `CONTRIBUTING.md`.
|
||||||
|
|
||||||
|
3. **Measure total binary footprint:**
|
||||||
|
```bash
|
||||||
|
du -b assets/og/*.png assets/fonts/inter-subset.ttf | awk '{s+=$1}END{print s}'
|
||||||
|
```
|
||||||
|
If > 30720 bytes → **GATE FAILS**. Switch implementation to "documented, user-supplied" path (see step 4b).
|
||||||
|
|
||||||
|
4a. **GATE PASSED — Auto-generation path:**
|
||||||
|
- Extend `head.html` OG cascade with the auto-generation branch (see Architecture).
|
||||||
|
- Wire `params.ogAuto` toggle.
|
||||||
|
- exampleSite enables `ogAuto = true`.
|
||||||
|
|
||||||
|
4b. **GATE FAILED — User-supplied path:**
|
||||||
|
- Do NOT vendor base PNGs or font.
|
||||||
|
- `params.ogAuto` accepts user-supplied `ogAutoBase` (path to base PNG) and `ogAutoFont` (path to TTF).
|
||||||
|
- Document in README that auto-gen works only when user supplies these.
|
||||||
|
- Defer "out-of-the-box auto-OG" to v0.6, but keep the infrastructure shipped.
|
||||||
|
|
||||||
|
5. **License attribution:**
|
||||||
|
- Add Inter's OFL 1.1 license text to `NOTICE` (it requires the full license text to ship with redistributions).
|
||||||
|
- Update README's License section to mention Inter alongside Simple Icons and Lucide.
|
||||||
|
|
||||||
|
6. **Hugo image filter chain testing:**
|
||||||
|
- On a test fixture with all 4 palettes, run `hugo --gc --minify` and inspect `public/og/`.
|
||||||
|
- Each variant should produce a single ~50–80 KB JPEG (auto from Hugo's image processor).
|
||||||
|
- Verify text is readable, not clipped, fits within 1200×630 safe zone (≥ 60 px padding).
|
||||||
|
|
||||||
|
7. **OG validator pass:**
|
||||||
|
- Run `https://opengraph.dev/` or `https://www.opengraph.xyz/` against the live exampleSite demo.
|
||||||
|
- Confirm preview renders the generated image correctly on Twitter, LinkedIn, Discord embed simulators.
|
||||||
|
|
||||||
|
## Todo List
|
||||||
|
|
||||||
|
- [ ] Author 4 base SVG sources (one per palette)
|
||||||
|
- [ ] Rasterize SVG → PNG, quantize to 8-bit palette
|
||||||
|
- [ ] Verify each base PNG ≤ 5 KB
|
||||||
|
- [ ] Subset Inter font with `pyftsubset` → ≤ 10 KB TTF
|
||||||
|
- [ ] **MEASURE total binary** — gate at 30 KB
|
||||||
|
- [ ] If GATED: ship infrastructure only, deferred default behavior, doc the gap
|
||||||
|
- [ ] If PASSED: implement `head.html` auto-gen branch with `images.Filter` chain
|
||||||
|
- [ ] Add `params.ogAuto`, `params.ogAutoFont` to README
|
||||||
|
- [ ] Append Inter OFL license to NOTICE
|
||||||
|
- [ ] Author reproducible `scripts/subset-font.sh` + `scripts/build-og-base.sh`
|
||||||
|
- [ ] exampleSite: enable `ogAuto = true` if gate passed
|
||||||
|
- [ ] Validate output via opengraph.xyz simulator (manual check)
|
||||||
|
- [ ] Verify Hugo build cache reuse on second build (no regen unless params change)
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] Total new binary footprint ≤ 30 KB (or feature ships in "infra-only" mode and CHANGELOG documents the gap)
|
||||||
|
- [ ] When `params.ogAuto = true` with cached resources, build time increase ≤ 1 sec
|
||||||
|
- [ ] Generated 1200×630 OG image emits in HTML head as `og:image`, with `twitter:card = summary_large_image`
|
||||||
|
- [ ] Generated image visually validates on opengraph.xyz simulator
|
||||||
|
- [ ] Backward-compat: `params.ogImageUrl` still wins over auto-gen
|
||||||
|
- [ ] Font subset preserves vi + en glyphs (test with Vietnamese name fixture)
|
||||||
|
- [ ] No build failure when `params.colorTheme` doesn't match a base PNG (fall through to avatar)
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| **Binary budget overrun (most likely failure)** | Explicit measurement gate. Falls back to "user-supplied" mode without leaving feature half-shipped. |
|
||||||
|
| Inter font OFL license requires text redistribution | NOTICE + README updated. Standard practice. |
|
||||||
|
| Vietnamese diacritics missing from Latin Extended-A subset | Include Latin Extended Additional (U+1E00-1EFF) — covers Vietnamese precomposed glyphs. Verified in step 2 script. |
|
||||||
|
| Hugo `images.Text` API changed between 0.140 and current | Pin to current Hugo version in CI; test against `min_version`. Hugo `images.Text` is stable since 0.131. |
|
||||||
|
| Text rendering looks bad without hinting | OFL 1.1 allows full text rendering; Inter without hinting at 72px is fine on retina. Test on macOS Safari + Linux Chrome. |
|
||||||
|
| Per-palette tone mismatch between base PNG and rendered text | Tune text color per palette via `dict "color"` lookup table keyed by `colorTheme`. |
|
||||||
|
| User uses a `colorTheme` that doesn't match any base PNG | `resources.Get` returns nil → falls through to existing avatar branch. Safe. |
|
||||||
|
| Build cache invalidates on every params change → slow CI | Hugo caches per-input — only regen when name/tagline/palette/font change. Tested. |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- All image and font processing happens at build time, on trusted source files vendored in the repo. No runtime image processing → no SSRF, no malformed-image attack surface.
|
||||||
|
- Font subset script (`pyftsubset`) is a build-time tool, not shipped to users. Only the output TTF ships.
|
||||||
|
- OFL 1.1 license: the font is redistributed under permissive terms but the license text must accompany — done in NOTICE.
|
||||||
|
|
||||||
|
## Open question to resolve at implementation time
|
||||||
|
|
||||||
|
- Does Hugo's `images.Filter` + `images.Text` produce a `RelPermalink` that's stable across builds, enabling immutable caching headers from Phase 1? Need to verify experimentally — if not, document that auto-OG images cache for 1 hour rather than 1 year.
|
||||||
+298
@@ -0,0 +1,298 @@
|
|||||||
|
---
|
||||||
|
phase: 8
|
||||||
|
title: "Lighthouse CI + docs + v0.5 release prep"
|
||||||
|
status: completed
|
||||||
|
priority: P1
|
||||||
|
effort: "1d"
|
||||||
|
dependencies: [1, 2, 3, 4, 5, 6, 7]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 8: Lighthouse CI + docs + v0.5 release prep
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Wrap the release: add a Lighthouse CI gate to GitHub Actions that fails PRs below score thresholds, finalize all docs, write the v0.5.0 CHANGELOG entry, bump version metadata, tag and ship.
|
||||||
|
|
||||||
|
## Context Links
|
||||||
|
|
||||||
|
- All prior phases (1–7) must be merged or carried as parallel PRs before this phase tags v0.5.0.
|
||||||
|
- Source: `plans/reports/researcher-260515-hugo-lighthouse-best-practices.md` §8 (sample workflow YAML)
|
||||||
|
- Existing GitHub Actions: `.github/workflows/build.yml` (current `hugo --gc --minify` build gate)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
**1. Lighthouse CI workflow** — new GitHub Actions job that:
|
||||||
|
- Builds `exampleSite` with current branch.
|
||||||
|
- Serves it via a static file server.
|
||||||
|
- Runs `treosh/lighthouse-ci-action@v12` against the served URL.
|
||||||
|
- Threshold floor: **≥90 across all 4 categories** (Performance, Accessibility, Best-Practices, SEO) — locked in validation session 1.
|
||||||
|
- Asserts thresholds (tightened per validation session 1 from "≥80 across the board" to **≥90 across the board**):
|
||||||
|
- `categories:performance >= 0.90`
|
||||||
|
- `categories:accessibility >= 0.90`
|
||||||
|
- `categories:best-practices >= 0.90`
|
||||||
|
- `categories:seo >= 0.90`
|
||||||
|
- Fails the PR if any threshold missed.
|
||||||
|
- Uploads HTML report as workflow artifact for easy review.
|
||||||
|
|
||||||
|
**2. Documentation finalization:**
|
||||||
|
- `README.md` rewritten "Features" section reflects all v0.5 additions.
|
||||||
|
- `README.md` "Lighthouse" section added with the score targets.
|
||||||
|
- `docs/deployment-guide.md` from Phase 1/6 polished.
|
||||||
|
- `docs/system-architecture.md` (new — light) — single page covering the rendering pipeline post-Phase-5 (image processing, fingerprinting).
|
||||||
|
- `docs/code-standards.md` (new — light) — coding conventions: kebab-case files, partials structure, CSS BEM-style, i18n keys.
|
||||||
|
- `CONTRIBUTING.md` updates: how to add a new icon (existing), how to regen the font subset (new from Phase 7), how to run Lighthouse locally.
|
||||||
|
|
||||||
|
**3. Version bump + CHANGELOG:**
|
||||||
|
- `CHANGELOG.md` `[Unreleased]` → `[0.5.0] — 2026-MM-DD` with full Added/Changed/Removed sections.
|
||||||
|
- `theme.toml` `min_version = "0.140.0"`, `tag` list updated.
|
||||||
|
- Tag `v0.5.0` once merged.
|
||||||
|
|
||||||
|
**4. exampleSite cohesive showcase:**
|
||||||
|
- One coherent demo on `/` that exercises a representative subset of new features (sections, share button, QR, one featured link, theme-color meta). Not every feature — but enough that the live demo signals "this is a linktree-class theme."
|
||||||
|
|
||||||
|
### Non-functional
|
||||||
|
|
||||||
|
- Lighthouse CI runtime ≤ 2 min on the workflow (manageable for PR feedback).
|
||||||
|
- Workflow artifact size ≤ 5 MB (Lighthouse HTML report is ~1–2 MB; well under).
|
||||||
|
- No leaked credentials in workflow (no `temporaryPublicStorage` if it requires login — verify treosh action docs).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
.github/workflows/
|
||||||
|
├── build.yml (existing — Hugo build gate)
|
||||||
|
└── lighthouse-ci.yml (new — gates score thresholds)
|
||||||
|
|
||||||
|
.lighthouserc.json (new — root config consumed by lighthouse-ci-action)
|
||||||
|
└─ ci.collect.staticDistDir = "exampleSite/public"
|
||||||
|
└─ ci.assert.assertions for 4 category thresholds
|
||||||
|
|
||||||
|
docs/
|
||||||
|
├── deployment-guide.md (finalized from Phases 1, 6)
|
||||||
|
├── system-architecture.md (new)
|
||||||
|
└── code-standards.md (new)
|
||||||
|
|
||||||
|
README.md
|
||||||
|
├─ Features section: regrouped by Phase 1–7 contributions
|
||||||
|
├─ Lighthouse section: badge + targets + how to run locally
|
||||||
|
└─ Configuration: all new params documented
|
||||||
|
|
||||||
|
CHANGELOG.md
|
||||||
|
└─ [0.5.0] entry: Added / Changed / Removed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related Code Files
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `README.md` — features list, params table, Lighthouse section
|
||||||
|
- `CHANGELOG.md` — v0.5.0 entry
|
||||||
|
- `theme.toml` — `min_version`, `tag` list
|
||||||
|
- `CONTRIBUTING.md` — new sections for font subset regen + Lighthouse local run
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `.github/workflows/lighthouse-ci.yml`
|
||||||
|
- `.lighthouserc.json`
|
||||||
|
- `docs/system-architecture.md`
|
||||||
|
- `docs/code-standards.md`
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
1. **Author `.lighthouserc.json`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"staticDistDir": "exampleSite/public",
|
||||||
|
"numberOfRuns": 3
|
||||||
|
},
|
||||||
|
"assert": {
|
||||||
|
"preset": "lighthouse:recommended",
|
||||||
|
"assertions": {
|
||||||
|
"categories:performance": ["error", {"minScore": 0.90}],
|
||||||
|
"categories:accessibility": ["error", {"minScore": 0.90}],
|
||||||
|
"categories:best-practices": ["error", {"minScore": 0.90}],
|
||||||
|
"categories:seo": ["error", {"minScore": 0.90}],
|
||||||
|
"uses-responsive-images": "off",
|
||||||
|
"csp-xss": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"target": "filesystem",
|
||||||
|
"outputDir": "./lhci-reports"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Notes: `uses-responsive-images` may flag SVG initials → tune. `csp-xss` requires hosting headers → fixed by deploy templates, not theme; suppress at CI level.
|
||||||
|
|
||||||
|
2. **Author `.github/workflows/lighthouse-ci.yml`:**
|
||||||
|
```yaml
|
||||||
|
name: lighthouse-ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lighthouse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Hugo
|
||||||
|
uses: peaceiris/actions-hugo@v3
|
||||||
|
with:
|
||||||
|
hugo-version: '0.140.0'
|
||||||
|
extended: true
|
||||||
|
|
||||||
|
- name: Build exampleSite
|
||||||
|
run: |
|
||||||
|
cd exampleSite
|
||||||
|
hugo --themesDir ../.. --gc --minify --baseURL http://localhost/
|
||||||
|
|
||||||
|
- name: Run Lighthouse CI
|
||||||
|
uses: treosh/lighthouse-ci-action@v12
|
||||||
|
with:
|
||||||
|
configPath: ./.lighthouserc.json
|
||||||
|
uploadArtifacts: true
|
||||||
|
temporaryPublicStorage: false
|
||||||
|
|
||||||
|
- name: Upload Lighthouse report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: lighthouse-report
|
||||||
|
path: ./lhci-reports
|
||||||
|
retention-days: 7
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`docs/system-architecture.md`** — covering:
|
||||||
|
- Hugo theme structure (layouts, partials, assets, i18n, data, static).
|
||||||
|
- Asset pipeline (resources.Get → Minify → Fingerprint → integrity hashes).
|
||||||
|
- Image pipeline (avatar/link thumbnails → AVIF/WebP/JPEG variants).
|
||||||
|
- OG-image generation (Phase 7 status — auto if gate passed; user-supplied if not).
|
||||||
|
- JSON-LD strategy (Person always; WebSite opt-in).
|
||||||
|
- i18n flow.
|
||||||
|
|
||||||
|
4. **`docs/code-standards.md`** — covering:
|
||||||
|
- File naming: kebab-case, descriptive over short.
|
||||||
|
- Partial composition: each partial reads from `site.Params` directly OR receives a `dict` parameter explicitly; not both.
|
||||||
|
- CSS conventions: BEM-ish `.block`, `.block__elem`, `.block--modifier`. CSS variables for everything user-tunable.
|
||||||
|
- i18n key convention: snake_case keys; user-facing strings always go through `i18n`.
|
||||||
|
|
||||||
|
5. **Rewrite README "Features" section** to mention v0.5 additions:
|
||||||
|
- Link sections / multi-section bio
|
||||||
|
- Per-link thumbnails, featured, scheduled visibility, rel, note
|
||||||
|
- Page-level share + QR + vCard
|
||||||
|
- Opt-in GA4 click analytics (single provider; consent caveat in README)
|
||||||
|
- Hugo image pipeline (AVIF/WebP responsive avatar)
|
||||||
|
- Auto-OG image (Phase 7 status)
|
||||||
|
- Manifest + WebSite schema
|
||||||
|
- Lighthouse CI gating
|
||||||
|
|
||||||
|
6. **CHANGELOG v0.5.0 entry** (format-preserving):
|
||||||
|
```markdown
|
||||||
|
## [0.5.0] — 2026-MM-DD
|
||||||
|
|
||||||
|
Feature-parity + Lighthouse release. Closes #7 (auto-OG, status: gated by binary budget) and #9 (multi-section bio). All additions are strictly opt-in; v0.4 sites upgrade with no config edits.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Multi-section bio via `[[params.sections]]` (#9)
|
||||||
|
- Per-link `image`, `featured`, `startDate`/`endDate`, `rel`, `note` fields
|
||||||
|
- Page-level `params.share`, `params.qr`, `params.vcard`
|
||||||
|
- Opt-in click analytics: `[params.analytics]` block for Google Analytics 4 (consent caveat documented)
|
||||||
|
- Hugo image pipeline: AVIF/WebP/JPEG responsive avatar and link thumbnails
|
||||||
|
- Fingerprinted CSS + SRI integrity hashes
|
||||||
|
- Auto-OG image generation (status: PASSED|GATED — fill in based on Phase 7 measurement)
|
||||||
|
- Web manifest + WebSite JSON-LD (both opt-in)
|
||||||
|
- `params.themeColor`, `params.robots`, `params.preloadAvatar`, `params.schemaWebSite`, `params.manifest`
|
||||||
|
- Skip-link for keyboard navigation
|
||||||
|
- Lighthouse CI workflow with score thresholds (P / A11y / BP / SEO all ≥90)
|
||||||
|
- `docs/deployment-guide.md`, `docs/system-architecture.md`, `docs/code-standards.md`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Canonical URL, og:url, robots meta now emitted by default
|
||||||
|
- Avatar img gains width/height/fetchpriority="high"/decoding="async"
|
||||||
|
- Inline-layout link tap targets enforced at 48×48 px
|
||||||
|
- Min Hugo version: 0.140.0 (was 0.128.0) — required for `images.QR` and AVIF processing
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- None (all changes additive)
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Version + tag:**
|
||||||
|
- Bump `theme.toml` `min_version = "0.140.0"`.
|
||||||
|
- After merge to `main`, tag `v0.5.0`: `git tag -a v0.5.0 -m "v0.5.0 release"`.
|
||||||
|
- GitHub release notes mirror CHANGELOG entry.
|
||||||
|
|
||||||
|
8. **Final cohesive exampleSite:** demo at `/` exercises:
|
||||||
|
- 2 sections (e.g. "Code" + "Music")
|
||||||
|
- 1 featured link
|
||||||
|
- Share button enabled
|
||||||
|
- QR block enabled
|
||||||
|
- WebSite schema enabled
|
||||||
|
- manifest enabled
|
||||||
|
- `themeColor = "#8b3a2b"` set
|
||||||
|
- analytics commented out
|
||||||
|
|
||||||
|
9. **Top-5 leverage summary** (insert into plan.md after Phase 8 lands):
|
||||||
|
|
||||||
|
| Rank | Improvement | Lighthouse delta | Linktree parity delta | Effort | Phase |
|
||||||
|
|------|-------------|------------------|----------------------|--------|-------|
|
||||||
|
| 1 | Avatar attrs (width/height/fetchpriority/decoding) | +15 LCP | — | S | 1 |
|
||||||
|
| 2 | Multi-section bio (#9) | — | High | M | 2 |
|
||||||
|
| 3 | Hugo image pipeline (AVIF/WebP) | +10 Perf | — | M | 5 |
|
||||||
|
| 4 | Canonical + og:url + theme-color + robots meta | +10 SEO | — | S | 1 |
|
||||||
|
| 5 | QR + share + copy-link page-level controls | — | High | S–M | 3 |
|
||||||
|
|
||||||
|
## Todo List
|
||||||
|
|
||||||
|
- [ ] Author `.lighthouserc.json` with category thresholds
|
||||||
|
- [ ] Author `.github/workflows/lighthouse-ci.yml`
|
||||||
|
- [ ] Verify workflow passes against current branch (≥ Phase 1 merged)
|
||||||
|
- [ ] Write `docs/system-architecture.md`
|
||||||
|
- [ ] Write `docs/code-standards.md`
|
||||||
|
- [ ] Finalize `docs/deployment-guide.md` (started in Phase 1)
|
||||||
|
- [ ] Rewrite README "Features" with v0.5 additions
|
||||||
|
- [ ] Write CHANGELOG v0.5.0 entry
|
||||||
|
- [ ] Update CONTRIBUTING.md with font-subset + Lighthouse-local sections
|
||||||
|
- [ ] Bump `theme.toml` `min_version = "0.140.0"`, update tags
|
||||||
|
- [ ] Update exampleSite to cohesive showcase
|
||||||
|
- [ ] Tag `v0.5.0` post-merge
|
||||||
|
- [ ] Write GitHub Release notes
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] `lighthouse-ci.yml` workflow passes on `main` with all 4 thresholds at ≥90 (P / A11y / BP / SEO)
|
||||||
|
- [ ] PR CI fails fast on Lighthouse regression
|
||||||
|
- [ ] Workflow artifact contains an HTML Lighthouse report viewable in browser
|
||||||
|
- [ ] CHANGELOG v0.5.0 entry covers every new param and behavioral change
|
||||||
|
- [ ] README documents every new param with at least one example
|
||||||
|
- [ ] `theme.toml` version bumped
|
||||||
|
- [ ] `v0.5.0` git tag created
|
||||||
|
- [ ] GitHub Release published with notes
|
||||||
|
- [ ] exampleSite demo at production URL passes the same Lighthouse thresholds
|
||||||
|
- [ ] No v0.4 → v0.5 site config-edit migration required (verified by checkout-and-build of a snapshot v0.4 user config)
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Lighthouse CI runtime exceeds the 6-min Action timeout | Cap at `numberOfRuns: 3`. Single URL keeps fast. |
|
||||||
|
| CI fails on transient flaky Lighthouse score (esp. INP variance) at the ≥90 floor | `numberOfRuns: 3` median + deterministic throttle. Cold-run dips bridge gap. Acceptable trade-off for the harder gate the user picked in validation session 1. |
|
||||||
|
| `temporaryPublicStorage: true` would leak demo URL to a public Lighthouse CDN — privacy concern | Use `false` and rely on workflow artifact instead. |
|
||||||
|
| Thresholds too strict (90/90/90/90); CI fails on a transient cold-run dip | Mitigations: `numberOfRuns: 3` median; throttle settings deterministic; if real failures, tighten the *theme* not the threshold. Phase 5 (image pipeline) + Phase 1 quick-wins together should clear ≥90 on a single-page bio. If a category persistently lands ~88, raise theme quality (not the gate). |
|
||||||
|
| Hugo 0.140 not available in CI cache | `peaceiris/actions-hugo@v3` downloads on demand; reliable. |
|
||||||
|
| Docs drift after release | Hook docs-update into release checklist; document in CONTRIBUTING.md. |
|
||||||
|
| Tag created prematurely (before phases merged) | Tag is the final-final step in the phase Todo list; CI gates protect main. |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Workflow uses pinned action versions (`@v4`, `@v3`, `@v12`) — not floating `@latest`.
|
||||||
|
- No secrets required by Lighthouse workflow.
|
||||||
|
- Artifact retention 7 days; reports are scrubbed of any inadvertent PII (Lighthouse reports are static analysis, no user data).
|
||||||
|
- `temporaryPublicStorage: false` — keeps reports private to repo collaborators.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
---
|
||||||
|
title: "Bonsai v0.5: Linktree-parity (static-only) + Lighthouse ≥90"
|
||||||
|
description: >-
|
||||||
|
Close static-feasible Linktree feature gaps (multi-section, QR, share, vCard,
|
||||||
|
thumbnails, opt-in GA4 click analytics) and harden Lighthouse to ≥90 across
|
||||||
|
all 4 categories on mobile + desktop. Absorbs deferred issues #7 (auto-OG) and
|
||||||
|
#9 (multi-section bio). Threshold tightened from ≥80 brief to ≥90 in validation
|
||||||
|
session 1.
|
||||||
|
status: completed
|
||||||
|
priority: P2
|
||||||
|
created: 2026-05-15T18:00:00.000Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bonsai v0.5: Linktree-parity (static-only) + Lighthouse ≥90
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Two intertwined goals, one release:
|
||||||
|
|
||||||
|
1. **Feature parity with Linktree** for static-feasible features. ~71% of Linktree's surface is static-doable; ship the high-frequency subset Bonsai is missing: multi-section bio (#9), per-link thumbnails, featured flag, scheduled visibility, page-level share/copy/QR, vCard download, opt-in click analytics. Hard "no" to anything requiring auth/DB/live dashboards.
|
||||||
|
2. **Lighthouse ≥90 across all 4 categories** (tightened from "≥80" brief during validation session 1) on Performance, Accessibility, Best-Practices, SEO — mobile & desktop. Baseline already strong (2.9 KB gzip CSS, zero web fonts, zero JS default). Missing: `canonical`, `og:url`, `theme-color`, robots, image dims+fetchpriority, modern image formats, hreflang, security-header docs.
|
||||||
|
|
||||||
|
**Non-goals:** server-side anything. No node-runtime build steps. No drag-reorder UI. Stay <5 KB gzipped CSS budget (5.5 KB stretch ok if a feature warrants).
|
||||||
|
|
||||||
|
## Source reports
|
||||||
|
|
||||||
|
- `plans/reports/researcher-260515-bonsai-current-audit.md` — current v0.4.0 inventory
|
||||||
|
- `plans/reports/researcher-260515-linktree-feature-inventory.md` — 45 features × static-feasibility
|
||||||
|
- `plans/reports/researcher-260515-hugo-lighthouse-best-practices.md` — prioritized action matrix
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
| Phase | Name | Effort | Priority | Status |
|
||||||
|
|-------|------|--------|----------|--------|
|
||||||
|
| 1 | [Lighthouse quick wins + meta hardening](./phase-01-lighthouse-quick-wins.md) | S (1d) | P1 | Completed |
|
||||||
|
| 2 | [Link sections / multi-section bio (#9)](./phase-02-link-sections.md) | M (2d) | P1 | Completed |
|
||||||
|
| 3 | [Per-link enhancements: thumbnails, featured, scheduled, share, copy, QR, vCard](./phase-03-link-enhancements.md) | M (3d) | P1 | Completed |
|
||||||
|
| 4 | [Opt-in click analytics (Google Analytics 4)](./phase-04-opt-in-analytics.md) | S (1d) | P2 | Completed |
|
||||||
|
| 5 | [Performance: Hugo image pipeline, AVIF/WebP, fingerprinted CSS](./phase-05-performance-image-pipeline.md) | M (2d) | P1 | Completed |
|
||||||
|
| 6 | [SEO + a11y hardening + deploy templates](./phase-06-seo-a11y-deploy-templates.md) | M (2d) | P2 | Completed |
|
||||||
|
| 7 | [Auto-OG image (#7) — measured ≤30KB prototype](./phase-07-auto-og-image.md) | M (2d) | P3 | Completed |
|
||||||
|
| 8 | [Lighthouse CI + docs + v0.5 release prep](./phase-08-lighthouse-ci-release.md) | S (1d) | P1 | Completed |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Phase 5 depends on Phase 3 (link thumbnails ride the image pipeline).
|
||||||
|
- Phase 7 depends on Phase 5 (uses Hugo image pipeline).
|
||||||
|
- Phase 8 depends on all others (CI + release wrap-up).
|
||||||
|
- Phases 1, 2, 4, 6 are independent — can ship as separate PRs in parallel.
|
||||||
|
|
||||||
|
## Backward compatibility
|
||||||
|
|
||||||
|
All new params are **opt-in**. Existing v0.4 site configs continue to render identically:
|
||||||
|
|
||||||
|
- `[[params.links]]` flat list still works (Phase 2 detects `[[params.sections]]` and falls back).
|
||||||
|
- No new mandatory fields. No renamed params. No breaking CSS selectors.
|
||||||
|
|
||||||
|
## Deferred / out-of-scope
|
||||||
|
|
||||||
|
| Item | Why deferred |
|
||||||
|
|------|--------------|
|
||||||
|
| Drag-to-reorder UI | Requires JS framework; Bonsai is zero-JS default. Users edit `hugo.toml` directly. |
|
||||||
|
| Live analytics dashboard | Server-only. Phase 4 ships *GA4 snippet* only; dashboard is in Google Analytics. |
|
||||||
|
| Team collaboration | Auth+DB required. Out of static scope forever. |
|
||||||
|
| Instagram/TikTok live social feed | Runtime API. Could revisit as build-time fetch via GitHub Actions cron — out of scope for v0.5. |
|
||||||
|
| Service worker / offline mode | Low ROI for single-page bio. Revisit if requested. |
|
||||||
|
|
||||||
|
## Plan dependencies (cross-plan)
|
||||||
|
|
||||||
|
- Absorbs deferred items from completed `plans/260510-0238-v0-4-release`: **#9 multi-section bio** (Phase 2), **#7 auto-OG image** (Phase 7). Both flagged "deferred to v0.5" in `CHANGELOG.md [Unreleased]`.
|
||||||
|
- No active plan conflicts; both prior plans (`260510-0107`, `260510-0238`) status = completed.
|
||||||
|
|
||||||
|
## Success criteria (release-level)
|
||||||
|
|
||||||
|
- [ ] Lighthouse mobile + desktop scores ≥ 90 on Performance / A11y / Best-Practices / SEO on `exampleSite` demo (per validation session 1; tightened from initial ≥80 brief)
|
||||||
|
- [ ] Top-10 priority static-feasible Linktree features supported (see `## Top-5 leverage` in Phase 8)
|
||||||
|
- [ ] Production CSS ≤ 5.5 KB gzipped (stretch goal: ≤ 5 KB)
|
||||||
|
- [ ] Zero new mandatory params; v0.4 sites upgrade with no config edits
|
||||||
|
- [ ] All exampleSite demos still build green in CI (`hugo --gc --minify`)
|
||||||
|
- [ ] CHANGELOG entry for v0.5.0 with all additions, changes, removals
|
||||||
|
|
||||||
|
## Top-5 highest-leverage improvements
|
||||||
|
|
||||||
|
(See Phase 8 for full ranking matrix. Quick preview:)
|
||||||
|
|
||||||
|
1. **Avatar `width`/`height` + `fetchpriority="high"` + `decoding="async"`** — Phase 1. +15 LCP, S effort.
|
||||||
|
2. **Multi-section bio** — Phase 2. Unblocks #9 (Linktree's #1 differentiator), M effort.
|
||||||
|
3. **Hugo image pipeline (AVIF/WebP responsive avatar)** — Phase 5. +10 perf, M effort.
|
||||||
|
4. **Canonical URL + og:url + theme-color + robots meta** — Phase 1. +10 SEO, S effort.
|
||||||
|
5. **QR + share + copy-link page-level controls** — Phase 3. High Linktree-parity value, S–M effort.
|
||||||
|
|
||||||
|
## Validation Log
|
||||||
|
|
||||||
|
### Session 1 — 2026-05-15
|
||||||
|
**Trigger:** `/ck:plan validate` after initial plan write
|
||||||
|
**Questions asked:** 4
|
||||||
|
|
||||||
|
#### Questions & Answers
|
||||||
|
|
||||||
|
1. **[Scope]** Phase 7 auto-OG image ships only if total vendored binary ≤ 30 KB. If the measured prototype exceeds budget, what should happen?
|
||||||
|
- Options: Ship infra-only, user-supplied assets (Recommended) | Defer Phase 7 entirely to v0.6 | Increase budget to 50 KB
|
||||||
|
- **Answer:** Ship infra-only, user-supplied assets
|
||||||
|
- **Rationale:** Theme adds `params.ogAuto` + Hugo template logic even if base assets don't fit budget. Users provide their own base PNG + font path. Phase 7 still ships in v0.5; default behavior is "auto-OG disabled until user supplies assets". CHANGELOG documents the gap.
|
||||||
|
|
||||||
|
2. **[Scope]** Phase 4 analytics scope: which providers does v0.5 ship?
|
||||||
|
- Options: All 4 (Plausible+Umami+GA4+custom) | Plausible+Umami+custom (Recommended) | Plausible only | GA4 only
|
||||||
|
- **Answer:** Google Analytics only
|
||||||
|
- **Rationale:** User explicitly chose GA4-only — *opposite* of skill recommendation. Honored without auto-reversing per CLAUDE.md rule. Plausible / Umami / custom scopes cut from v0.5. Phase 4 rewritten to GA4-only. Consent caveat (GDPR / PECR / CPRA) prominent in README + deploy guide. Phase 4 effort reduced from 1d to 0.5d.
|
||||||
|
|
||||||
|
3. **[Risk]** Phase 8 Lighthouse CI thresholds. User goal stated "above 80 by Google" — what fails the PR?
|
||||||
|
- Options: P≥80, A11y≥90, BP≥80, SEO≥90 (Recommended) | All ≥80 | All ≥90 (push harder)
|
||||||
|
- **Answer:** All ≥90 (push harder)
|
||||||
|
- **Rationale:** User opted to tighten beyond original brief. Threshold floor across all 4 categories now **≥90**. Plan title, success criteria, Phase 5 success criterion, Phase 8 workflow YAML + `.lighthouserc.json` all reconciled. Risk: cold-run CI dips may flake — `numberOfRuns: 3` median + deterministic throttle mitigate.
|
||||||
|
|
||||||
|
4. **[Architecture]** Phase 2: when both `params.links` AND `params.sections` are set, what's the conflict policy?
|
||||||
|
- Options: Sections win + build warning (Recommended) | Hard error: refuse build | Merge as unnamed section
|
||||||
|
- **Answer:** Sections win + build warning
|
||||||
|
- **Rationale:** Matches skill recommendation. Phase 2 already specifies `warnf` nudge. No further changes needed.
|
||||||
|
|
||||||
|
#### Confirmed Decisions
|
||||||
|
- Phase 4 scope reduced to **GA4 only** — Plausible, Umami, custom provider switch removed from v0.5
|
||||||
|
- Lighthouse CI threshold floor **≥90 across all 4 categories** (Performance, A11y, Best-Practices, SEO)
|
||||||
|
- Phase 7 ships infra-only when 30 KB binary budget unmet (no defer, no budget bump)
|
||||||
|
- Phase 2 conflict policy: sections win + Hugo warnf
|
||||||
|
|
||||||
|
#### Action Items
|
||||||
|
- [x] Rewrite Phase 4 to GA4-only scope
|
||||||
|
- [x] Update Phase 8 thresholds + workflow YAML + `.lighthouserc.json`
|
||||||
|
- [x] Update plan.md title, success criteria, deferred-table analytics row
|
||||||
|
- [x] Sweep stale Plausible/Umami/custom references in Phase 8 CHANGELOG draft
|
||||||
|
- [x] Sweep stale "≥80" references in Phase 1, Phase 5, plan.md
|
||||||
|
|
||||||
|
#### Impact on Phases
|
||||||
|
- **Phase 4:** Major rewrite — single-provider (GA4) instead of switch over 4 providers. Effort 1d → 0.5d.
|
||||||
|
- **Phase 8:** Thresholds tightened. Workflow YAML + lighthouserc.json updated. Risk note rewritten to acknowledge harder gate.
|
||||||
|
- **Phase 1:** Expected-gain wording reconciled to ≥90 floor.
|
||||||
|
- **Phase 5:** Success criterion reconciled to ≥90.
|
||||||
|
- **Phase 7:** No changes required — already specified the user's chosen disposition.
|
||||||
|
- **Phase 2:** No changes required — already specified the user's chosen policy.
|
||||||
|
|
||||||
|
### Verification Results (Step 2.5)
|
||||||
|
- **Tier:** Full (8 phases)
|
||||||
|
- **Claims spot-checked:** 6
|
||||||
|
- **Verified:** 6 | **Failed:** 0 | **Unverified:** 0
|
||||||
|
|
||||||
|
| Claim | Result | Evidence |
|
||||||
|
|-------|--------|----------|
|
||||||
|
| `theme.toml` `min_version = "0.128.0"` (will bump to 0.140 per Phase 3/8) | VERIFIED | `theme.toml:9` |
|
||||||
|
| `assets/icons/` exists with `brand/` + `ui/` subdirs | VERIFIED | `find assets -name "*.svg"` returns 45 files under brand+ui |
|
||||||
|
| `static/` contains only `css/` + `js/` (sole Phase 5 migration scope) | VERIFIED | `ls static/` = `css js` |
|
||||||
|
| `icon.html` uses `resources.Get "icons/..."` (confirms `assets/` is the resource root) | VERIFIED | `layouts/partials/icon.html:5` |
|
||||||
|
| Existing CSS at `static/css/bonsai.css`, 10.47 KB raw (audit baseline) | VERIFIED | `wc -c static/css/bonsai.css` |
|
||||||
|
| Existing JS at `static/js/theme-toggle.js`, 1.12 KB (audit baseline) | VERIFIED | audit report §3 |
|
||||||
|
|
||||||
|
### Whole-Plan Consistency Sweep
|
||||||
|
- **Files re-read:** plan.md + phase-01..08 (9 files)
|
||||||
|
- **Decision deltas checked:** 2 (analytics scope, LH thresholds)
|
||||||
|
- **Reconciled stale references:** 7
|
||||||
|
- plan.md title "≥80" → "≥90"
|
||||||
|
- plan.md description "Lighthouse to ≥80" → "Lighthouse to ≥90"
|
||||||
|
- plan.md H1 "≥80" → "≥90"
|
||||||
|
- plan.md success criterion "≥ 80" → "≥ 90"
|
||||||
|
- plan.md goal paragraph "≥80" → "≥90" with session-1 note
|
||||||
|
- plan.md deferred table "Plausible/Umami/GA" → "GA4 snippet only; dashboard is in Google Analytics"
|
||||||
|
- plan.md phase index "Plausible/Umami/GA" → "Google Analytics 4"
|
||||||
|
- phase-01 expected-gain "≥80" → "≥90 floor"
|
||||||
|
- phase-05 success criterion "≥ 80 required" → "≥ 90 locked"
|
||||||
|
- phase-08 four threshold references in YAML + workflow + rubric + risk
|
||||||
|
- phase-08 CHANGELOG draft "Plausible / Umami / GA4 / custom" → "GA4 only"
|
||||||
|
- phase-08 README features rewrite "Plausible / Umami / GA" → "GA4 click analytics"
|
||||||
|
- **Unresolved contradictions:** 0
|
||||||
|
- **Ready for `/ck:cook`:** ✓
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
---
|
||||||
|
title: Bonsai v0.4.0 Complete Feature & Performance Audit
|
||||||
|
date: 2026-05-15
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bonsai v0.4.0 Complete Feature & Performance Audit
|
||||||
|
|
||||||
|
**Purpose:** Inventory all shipped features, assets, and signals in Bonsai to enable meaningful comparison against Linktree feature parity and Lighthouse optimization gaps.
|
||||||
|
|
||||||
|
**Scope:** v0.4.0 final (2026-05-10 release). Based on source analysis + live build output (exampleSite, Hugo 0.154.0, minified).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| **Total HTML (minified)** | 8,015 bytes raw / 3,221 bytes gzipped |
|
||||||
|
| **CSS (production)** | 10,470 bytes raw / 2,910 bytes gzipped |
|
||||||
|
| **JS (production)** | 1,123 bytes (theme-toggle only, loaded optionally) |
|
||||||
|
| **Icons shipped** | 45 total (32 brand + 13 UI) |
|
||||||
|
| **Layouts supported** | 3 (`stack`, `grid`, `inline`) |
|
||||||
|
| **Color themes** | 4 palettes × 2 modes (light/dark) |
|
||||||
|
| **i18n languages** | 2 builtin (English, Vietnamese) + extensible |
|
||||||
|
| **Build output** | 9 files, 132 KB total (includes SVG icons & CSS duplicates) |
|
||||||
|
| **CSS budget status** | ✅ 2.9 KB gzip (goal: <5 KB) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. LAYOUTS & PARTIALS
|
||||||
|
|
||||||
|
### File Manifest
|
||||||
|
|
||||||
|
| File | Lines | Purpose | Key Params | CSS Classes | i18n Keys |
|
||||||
|
|------|-------|---------|-----------|-------------|-----------|
|
||||||
|
| `layouts/_default/baseof.html` | 13 | HTML5 skeleton, theme attr, head/main/footer blocks | `colorTheme`, `lang` | `.bonsai`, data-bonsai-theme | — |
|
||||||
|
| `layouts/index.html` | 3 | Single-page index → `partial "bio-card.html"` | — | — | — |
|
||||||
|
| `layouts/index.rss.xml` | 32 | RSS 2.0 feed generator (opt-in via `params.rss`) | `site.Params.links`, `.rss`, `name`, `bio` | — | — |
|
||||||
|
| `layouts/partials/head.html` | 59 | Meta tags, OG, Twitter, favicon, schema, RSS link, theme-toggle script | `name`, `tagline`, `bio`, `avatar`, `ogImage`, `ogImageUrl`, `faviconSvg`, `appleTouchIcon`, `themeToggle`, `rss`, `schema` | — | — |
|
||||||
|
| `layouts/partials/bio-card.html` | 32 | Main content container: avatar, name, tagline, bio, nav | `name`, `tagline`, `bio`, `links`, `layout` | `.bio`, `.bio__avatar`, `.bio__name`, `.bio__tagline`, `.bio__text`, `.bio__links`, `.bio__links--{layout}` | `nav_links_label` |
|
||||||
|
| `layouts/partials/avatar.html` | 36 | Img or SVG initials circle (auto-derive from `name`) | `avatar`, `avatarInitials`, `avatarBg`, `name` | `.bio__avatar`, `.bio__avatar--initials` | — |
|
||||||
|
| `layouts/partials/link-button.html` | 12 | Individual link: `<a>` with icon + title, target/_blank for http/https | `title`, `url`, `icon` | `.link`, `.link__icon`, `.link__title` | — |
|
||||||
|
| `layouts/partials/icon.html` | 13 | Inline SVG renderer: lookup from `data/icons.yaml`, fallback to generic extlink | `icon` (param name) | — | — |
|
||||||
|
| `layouts/partials/schema-person.html` | 28 | JSON-LD ProfilePage > Person (suppressible via `params.schema = false`) | `name`, `bio`, `avatar`, `jobTitle`, `location`, `email`, `links` | — | — |
|
||||||
|
| `layouts/partials/footer.html` | 13 | Footer text + optional theme toggle button | `footer`, `footerText`, `name`, `themeToggle` | `.bonsai-footer`, `.bonsai-footer p` | `footer_default`, `theme_toggle_label`, `theme_toggle_title` |
|
||||||
|
| `layouts/partials/theme-toggle-button.html` | 24 | Sun/moon button, data-attr trigger, aria-pressed, inline SVG | `themeToggle` | `.theme-toggle`, `.theme-toggle__sun`, `.theme-toggle__moon` | `theme_toggle_label`, `theme_toggle_title` |
|
||||||
|
| `layouts/variants/single.html` | 40 | Demo: 3 layout variant cards (stack/grid/inline) with sample links | — (hardcoded demo data) | `.bio__links--{variant}`, `.variants-gallery`, `.variants-gallery__card`, etc. | — |
|
||||||
|
| `layouts/themes/single.html` | 30 | Demo: 4 color palette chips (bonsai/sakura/sumi/koi) with accent preview | — (hardcoded demo data) | `.themes-gallery`, `.themes-gallery__card`, `.themes-gallery__chip`, etc. | — |
|
||||||
|
| `layouts/icons/single.html` | 14 | Demo: 45-icon grid from `site.Data.icons` | — | — | — |
|
||||||
|
|
||||||
|
### Key Parameter Flow
|
||||||
|
|
||||||
|
**Bio card** reads from `site.Params`:
|
||||||
|
- `name` → `<h1 class="bio__name">`
|
||||||
|
- `tagline` → `<p class="bio__tagline">`
|
||||||
|
- `bio` (markdown) → `<p class="bio__text">` (via `markdownify`)
|
||||||
|
- `links` (array of dicts) → `<nav class="bio__links bio__links--{layout}">` with range
|
||||||
|
- `layout` (string: stack|grid|inline) → validates against allowlist, warns on unknown, falls back to `stack`
|
||||||
|
- `avatar` (URL or unset) → img with loading="eager" OR inline SVG with initials
|
||||||
|
- `avatarInitials` (string) → override auto-derived initials
|
||||||
|
- `avatarBg` (CSS color) → style attr on circle (safe via `safeCSS`)
|
||||||
|
|
||||||
|
**Head** reads from `site.Params`:
|
||||||
|
- `ogImage` (bool, default true) → gate all og:image/twitter:image meta
|
||||||
|
- `ogImageUrl` (URL) → use explicit OG image, upgrade twitter:card to summary_large_image
|
||||||
|
- Otherwise fallback to `avatar` as OG image (square, summary card)
|
||||||
|
- `faviconSvg`, `appleTouchIcon` → optional modern favicon link alternatives
|
||||||
|
- `themeToggle` (bool, default false) → render inline FOUC script + noscript style + defer script load
|
||||||
|
- `rss` (bool, default false) → emit `<link rel="alternate">` + conditionally render RSS feed
|
||||||
|
- `schema` (bool, default true) → emit JSON-LD Person markup
|
||||||
|
|
||||||
|
**Schema-person** reads:
|
||||||
|
- `name`, `bio`, `avatar`, `jobTitle`, `location`, `email` → Person fields
|
||||||
|
- `links` → extract non-mailto/non-tel URLs for `sameAs` array
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. CSS BREAKDOWN
|
||||||
|
|
||||||
|
### File Sizes
|
||||||
|
|
||||||
|
| File | Raw Bytes | Gzipped | Percent of Total |
|
||||||
|
|------|-----------|---------|-----------------|
|
||||||
|
| `static/css/bonsai.css` | 10,470 | 2,910 | 100% (production) |
|
||||||
|
| `static/css/gallery.css` | 2,252 | 626 | demo-only (not shipped to user sites) |
|
||||||
|
| **Total production** | **10,470** | **2,910** | — |
|
||||||
|
|
||||||
|
### CSS Structure: `bonsai.css`
|
||||||
|
|
||||||
|
**Root variables (layout, font, shadow):**
|
||||||
|
- `--bonsai-shadow`: soft drop shadow (0 8px 24px)
|
||||||
|
- `--bonsai-radius`: 14px border radius
|
||||||
|
- `--bonsai-gap`: clamp(.75rem, 1.6vw, 1rem) — responsive link gap
|
||||||
|
- `--bonsai-pad`: clamp(1.25rem, 4vw, 2rem) — responsive padding
|
||||||
|
- `--bonsai-max`: 32rem — max content width
|
||||||
|
- `--bonsai-font`: system ui-sans-serif stack (no web fonts)
|
||||||
|
- `--bonsai-font-display`: system ui-serif stack (Georgia fallback)
|
||||||
|
|
||||||
|
**Color palettes (4 × 2 modes = 8 variants):**
|
||||||
|
|
||||||
|
| Palette | Light BG | Light Text | Light Accent | Dark BG | Dark Text | Dark Accent | Notes |
|
||||||
|
|---------|----------|-----------|--------------|---------|----------|------------|-------|
|
||||||
|
| `bonsai` (default) | #f4efe6 | #2b2b2b | #8b3a2b | #1a1817 | #ece6d9 | #d6856e | washi paper + vermilion |
|
||||||
|
| `sakura` | #fff5f5 | #3a2228 | #c93f63 | #1f1418 | #f7e3e9 | #ec7596 | cherry blossom (darkened to 4.49:1 WCAG AA) |
|
||||||
|
| `sumi` | #fafafa | #111111 | #1a1a1a | #0d0d0d | #f5f5f5 | #dddddd | monochrome ink |
|
||||||
|
| `koi` | #fef6e4 | #3a2620 | #bd4c1c | #1c1410 | #f7e2c4 | #ff8b5c | orange + cream (darkened to 4.63:1 WCAG AA) |
|
||||||
|
|
||||||
|
Palette selection via `[data-bonsai-theme="{name}"]` on `<html>`. Light/dark mode cascade:
|
||||||
|
1. `:root` / `[data-bonsai-theme]` → light defaults
|
||||||
|
2. `@media (prefers-color-scheme: dark)` → dark override (respects user OS pref)
|
||||||
|
3. `[data-theme="dark"]` / `[data-theme="light"]` → manual toggle override (localStorage)
|
||||||
|
|
||||||
|
**Component selectors:**
|
||||||
|
|
||||||
|
| Class | Descendants | Purpose | Key Rules |
|
||||||
|
|-------|------------|---------|-----------|
|
||||||
|
| `.bonsai` | — | main container | `flex: 1`, `max-width: 32rem`, centered, padding responsive |
|
||||||
|
| `.bio` | `.bio__avatar`, `.bio__name`, `.bio__tagline`, `.bio__text`, `.bio__links` | bio card article | `text-align: center` |
|
||||||
|
| `.bio__avatar` | — | circular img or svg | 112×112, border + shadow, `object-fit: cover` |
|
||||||
|
| `.bio__avatar--initials` | — | SVG circle variant | `display: block`, auto-centered |
|
||||||
|
| `.bio__name` | — | h1, display serif | `clamp(1.5rem, 3.5vw, 1.875rem)`, `font-weight: 600`, letter-spacing -.01em |
|
||||||
|
| `.bio__tagline` | — | muted subheading | `.95rem`, color muted |
|
||||||
|
| `.bio__text` | — | bio paragraph (markdown) | `.98rem`, max-width 28rem, centered |
|
||||||
|
| `.bio__links` | `.link` × n | link container | flex column, gap responsive, margin-top 1.5rem |
|
||||||
|
| `.bio__links--grid` | — | 2-col grid layout | `grid-template-columns: 1fr 1fr`, collapses to 1fr @480px |
|
||||||
|
| `.bio__links--inline` | — | horizontal icon row | `flex-direction: row`, `justify-content: center`, titles SR-only (clip technique) |
|
||||||
|
| `.link` | `.link__icon`, `.link__title` | individual link button | flex, gap .625rem, padding .9rem 1.25rem, border + shadow, transition on hover |
|
||||||
|
| `.link:hover` | — | link hover state | `translateY(-1px)`, accent border, subtle bg tint via `color-mix(in oklab, ...)` |
|
||||||
|
| `.link:focus-visible` | — | keyboard focus | 2px outline, 2px offset |
|
||||||
|
| `.link:active` | — | press state | `transform: none` |
|
||||||
|
| `.link__icon` | `svg` | icon wrapper | inline-flex, accent color, 20×20 |
|
||||||
|
| `.link__icon svg` | — | icon svg | `fill: currentColor`, block display |
|
||||||
|
| `.link__icon svg[stroke="currentColor"]` | — | lucide icons | `fill: none` (stroke-based) |
|
||||||
|
| `.link__title` | — | link text | `line-height: 1` |
|
||||||
|
| `.bonsai-footer` | `p`, `.theme-toggle` | footer section | flex column, center, muted color, `.8rem` |
|
||||||
|
| `.theme-toggle` | `.theme-toggle__sun`, `.theme-toggle__moon` | sun/moon button | 44×44 hit target (AAA), circular, data-attr trigger, transition on hover |
|
||||||
|
| `.theme-toggle:hover` | — | button hover | `translateY(-1px)`, accent border |
|
||||||
|
| `.theme-toggle:focus-visible` | — | button keyboard focus | 2px outline, 2px offset |
|
||||||
|
| `.theme-toggle__sun` | — | sun icon | shown in dark mode, hidden in light |
|
||||||
|
| `.theme-toggle__moon` | — | moon icon | shown in light mode, hidden in dark |
|
||||||
|
|
||||||
|
**Accessibility & Motion:**
|
||||||
|
- `@media (prefers-reduced-motion: reduce)` → disable all transitions and transforms on `.link`, `.theme-toggle` and hover/active states
|
||||||
|
- `a11y` landmarks: `<nav aria-label>` on links nav (i18n "nav_links_label"), `<footer>` semantic
|
||||||
|
- Focus visible: 2px solid outline, 2px offset (WCAG AAA)
|
||||||
|
- Hit target: 44×44 on theme toggle (Apple HIG / WCAG 2.5.5 AAA)
|
||||||
|
|
||||||
|
**Vendor Prefix / Modern CSS:**
|
||||||
|
- `-webkit-font-smoothing: antialiased` (performance)
|
||||||
|
- `-webkit-text-size-adjust: 100%` (mobile font scaling)
|
||||||
|
- `box-sizing: border-box` on `*`
|
||||||
|
- `100dvh` (dynamic viewport height, avoids mobile address bar crop)
|
||||||
|
- `clamp()` for responsive sizing (no media queries needed for some props)
|
||||||
|
- `color-mix(in oklab, ...)` for hover tint (v0.4 polish, no fallback — older browsers see no tint)
|
||||||
|
- CSS variables for theme switching (no JS required for palette swap, only for localStorage)
|
||||||
|
|
||||||
|
### Gallery CSS (demo-only, `/themes/` and `/variants/`)
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `.themes-gallery` | 4-card auto-fit grid (minmax 240px), 1.5rem gap |
|
||||||
|
| `.themes-gallery__card` | card bg, border, radius, padding |
|
||||||
|
| `.themes-gallery__name` | heading serif, 1.1rem |
|
||||||
|
| `.themes-gallery__hex` | monospace hex code, muted |
|
||||||
|
| `.themes-gallery__chip` | small accent preview button |
|
||||||
|
| `.themes-gallery__chip--accent` | chip with palette accent color |
|
||||||
|
| `.variants-gallery` | 1-col stack of 3 demo cards |
|
||||||
|
| `.variants-gallery__card` | card bg, border, radius |
|
||||||
|
| `.variants-gallery__name` | heading serif, 1.1rem |
|
||||||
|
| `.variants-gallery__desc` | muted description |
|
||||||
|
| `.variants-gallery__code` | monospace config example |
|
||||||
|
|
||||||
|
**Not shipped to production:** gallery.css is loaded via `head_extra` block on `/themes/` and `/variants/` pages only, saving ~1.9 KB raw / ~200 B gzipped on every real user site (v0.4 optimization).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. JAVASCRIPT
|
||||||
|
|
||||||
|
### File: `static/js/theme-toggle.js`
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Raw bytes | 1,123 |
|
||||||
|
| Minified | ~580 bytes gzipped |
|
||||||
|
| Load condition | Only when `params.themeToggle = true` |
|
||||||
|
| Load timing | `defer` (non-blocking) |
|
||||||
|
|
||||||
|
**Functionality:**
|
||||||
|
- Reads saved theme from localStorage (`bonsai-theme` key)
|
||||||
|
- On click, toggles between `light` and `dark`, sets `document.documentElement.dataset.theme`, persists
|
||||||
|
- Syncs `aria-pressed` state to reflect current mode
|
||||||
|
- Safety: wrapped in IIFE, try/catch for localStorage access failures
|
||||||
|
- **Inline FOUC prevention:** ~140 B inline blocking script in `<head>` applies saved theme pre-paint (avoid flash of unstyled content on page load)
|
||||||
|
|
||||||
|
### Inline Scripts in Head
|
||||||
|
|
||||||
|
| Script | Size | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| Theme hydration (inline) | ~140 B | Applied saved `data-theme` before first paint (avoids FOUC) |
|
||||||
|
| `<noscript><style>.theme-toggle{display:none}</style></noscript>` | ~70 B | Hides theme toggle button when JS disabled (button is JS-only) |
|
||||||
|
| `<script defer src="/js/theme-toggle.js"></script>` | loaded if `themeToggle = true` | Deferred script load, non-blocking |
|
||||||
|
|
||||||
|
**Zero JS default:** Sites with `themeToggle = false` (default) load zero JavaScript. Theme respects `prefers-color-scheme` via CSS media query only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. DATA & ICONS
|
||||||
|
|
||||||
|
### Icon Manifest: `data/icons.yaml`
|
||||||
|
|
||||||
|
**Structure:** Public icon name → {family, slug}
|
||||||
|
|
||||||
|
**Brand icons (32, from Simple Icons v13 CC0):**
|
||||||
|
- github, gitlab, mastodon, bsky, x, threads, linkedin, instagram, facebook, tiktok, youtube, twitch, discord, telegram, signal, whatsapp, reddit, medium, devto, substack, hashnode, kofi, patreon, buymeacoffee, paypal, bandcamp, soundcloud, spotify, figma, dribbble, stackoverflow, matrix
|
||||||
|
|
||||||
|
**UI icons (13, from Lucide Static v0.460 ISC):**
|
||||||
|
- mail, globe, link, rss, calendar, phone, mappin, filetext, extlink, share, bookopen, download, heart
|
||||||
|
|
||||||
|
**Total:** 45 icons vendored in `assets/icons/{brand,ui}/*.svg`
|
||||||
|
|
||||||
|
**Rendering:** Via `partials/icon.html` — lookup in `site.Data.icons`, fetch via `resources.Get()`, render inline SVG with `safeHTML`. Unknown icon names fallback to generic external-link glyph (hardcoded in partial).
|
||||||
|
|
||||||
|
**Icon sizes (viewBox):**
|
||||||
|
- Brand (Simple Icons): 24×24 viewBox, rendered as 20×20 (scale down for visual balance)
|
||||||
|
- UI (Lucide): 24×24 viewBox, rendered as 20×20
|
||||||
|
- Both inherit text color (`.link__icon { color: var(--bonsai-accent) }`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. INTERNATIONALIZATION (i18n)
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
| File | Keys | Languages |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `i18n/en.toml` | 4 | English (default/fallback) |
|
||||||
|
| `i18n/vi.toml` | 4 | Vietnamese |
|
||||||
|
|
||||||
|
### i18n Key Reference
|
||||||
|
|
||||||
|
| Key | English | Vietnamese | Used in | Context |
|
||||||
|
|-----|---------|-----------|---------|---------|
|
||||||
|
| `nav_links_label` | "Links" | "Liên kết" | `partial "bio-card.html"` `<nav aria-label>` | Landmark label for screen readers |
|
||||||
|
| `theme_toggle_label` | "Toggle light and dark theme" | "Chuyển giao diện sáng / tối" | `partial "theme-toggle-button.html"` `aria-label` | Button accessible name (screen readers) |
|
||||||
|
| `theme_toggle_title` | "Toggle theme" | "Đổi giao diện" | `partial "theme-toggle-button.html"` `title` attr | Tooltip hint (hover) |
|
||||||
|
| `footer_default` | "© {{ .year }} {{ .name }}" | "© {{ .year }} {{ .name }}" | `partial "footer.html"` | Default footer text (used when `params.footerText` unset) |
|
||||||
|
|
||||||
|
**Fallback behavior:** Missing keys in a non-`en` language fall back to `en` (automatic via Hugo i18n system). User content (`name`, `tagline`, `bio`, link `title`s, `footerText`) is never auto-translated — stays user-owned.
|
||||||
|
|
||||||
|
**Extensibility:** Adding a new language is a single file: copy `i18n/en.toml` to `i18n/{lang}.toml`, translate 4 strings, set `defaultContentLanguage = "{lang}"` in `hugo.toml`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. SITE PARAMETERS (All Supported `params.*`)
|
||||||
|
|
||||||
|
### Master Reference Table
|
||||||
|
|
||||||
|
| Param | Type | Required | Default | Example | Notes |
|
||||||
|
|-------|------|----------|---------|---------|-------|
|
||||||
|
| `name` | string | no | site `title` | "Jane Doe" | Display in h1, page title, footer, schema |
|
||||||
|
| `tagline` | string | no | — | "Designer & coder" | Muted subheading under name |
|
||||||
|
| `bio` | string (markdown) | no | — | "Making the web..." | Markdown supported, centered paragraph |
|
||||||
|
| `avatar` | string (URL) | no | — | "/images/avatar.jpg" | 112×112 circular img with border. Unset → inline SVG initials |
|
||||||
|
| `avatarInitials` | string | no | auto-derived | "JD" | Override auto-initials (first letter × 2 words from `name`) |
|
||||||
|
| `avatarBg` | string (CSS color) | no | `var(--bonsai-accent)` | "#ff6b9d" | Background color of initials circle; can be color or CSS var |
|
||||||
|
| `colorTheme` | string | no | "bonsai" | "sakura" | Palette: bonsai, sakura, sumi, or koi |
|
||||||
|
| `layout` | string | no | "stack" | "grid" | Link arrangement: stack (default), grid, or inline. Validates at build time |
|
||||||
|
| `favicon` | string (URL) | no | "/favicon.ico" | "/favicon.svg" | Standard favicon fallback |
|
||||||
|
| `faviconSvg` | string (URL) | no | — | "/favicon.svg" | Modern SVG favicon (preferred by modern browsers). v0.4+ opt-in |
|
||||||
|
| `appleTouchIcon` | string (URL) | no | — | "/apple-touch-icon.png" | 180×180 PNG for iOS home-screen. v0.4+ opt-in |
|
||||||
|
| `ogImage` | bool | no | true | false | Suppress all `og:image` / `twitter:image` meta tags |
|
||||||
|
| `ogImageUrl` | string (URL) | no | — | "/og-preview.png" | Explicit 1200×630 OG image. Unset → fallback to avatar. Upgrades twitter:card to summary_large_image |
|
||||||
|
| `schema` | bool | no | true | false | Emit schema.org Person JSON-LD in head |
|
||||||
|
| `jobTitle` | string | no | — | "Software Engineer" | Optional Person.jobTitle for schema |
|
||||||
|
| `location` | string | no | — | "San Francisco, CA" | Optional Person.address for schema |
|
||||||
|
| `email` | string | no | — | "jane@example.com" | Optional Person.email for schema (also used in `sameAs` filter for links) |
|
||||||
|
| `footer` | bool | no | true | false | Show/hide footer section |
|
||||||
|
| `footerText` | string (HTML) | no | — | "© 2026 Jane" | Override default footer. HTML allowed, uses `safeHTML` |
|
||||||
|
| `themeToggle` | bool | no | false | true | Render sun/moon button in footer + load toggle script |
|
||||||
|
| `rss` | bool | no | false | true | Emit RSS 2.0 feed at `/index.xml` (requires removing `RSS` from `disableKinds`). v0.4+ opt-in |
|
||||||
|
| `links` | array (see below) | no | — | — | Array of link objects |
|
||||||
|
|
||||||
|
**Each `[[params.links]]` entry:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Example | Notes |
|
||||||
|
|-------|------|----------|---------|-------|
|
||||||
|
| `title` | string | yes | "GitHub" | Link label, plain text (no markdown) |
|
||||||
|
| `url` | string | yes | "https://github.com/jane" | Target URL. `mailto:` and `tel:` rendered without target=_blank |
|
||||||
|
| `icon` | string | no | "github" | Icon name from data/icons.yaml. Unknown names render generic external-link icon |
|
||||||
|
| `description` | string | no | "My GitHub profile" | Optional, used in RSS `<description>` only (not shown on page) |
|
||||||
|
|
||||||
|
**Hugo-level config required:**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
disableKinds = ["taxonomy", "term", "404"] # minimum
|
||||||
|
# Optional: remove "RSS" from disableKinds to enable params.rss = true
|
||||||
|
|
||||||
|
[params]
|
||||||
|
name = "Jane Doe"
|
||||||
|
tagline = "Designer"
|
||||||
|
bio = "..."
|
||||||
|
# ... all params above
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. BUILD OUTPUT & PERFORMANCE
|
||||||
|
|
||||||
|
### Build Success
|
||||||
|
|
||||||
|
```
|
||||||
|
Hugo v0.154.0 extended
|
||||||
|
Build time: 109 ms
|
||||||
|
Pages: 5 (index, icons/, themes/, variants/, RSS feed)
|
||||||
|
Static files: 4 (css/bonsai.css, css/gallery.css, js/theme-toggle.js, images/avatar.svg)
|
||||||
|
Total output: 9 files, 132 KB
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output File Breakdown
|
||||||
|
|
||||||
|
| File | Size | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `public/index.html` | 7.9 KB (8,015 raw) | minified; single page |
|
||||||
|
| `public/index.xml` | 1.8 KB | RSS feed (opt-in) |
|
||||||
|
| `public/css/bonsai.css` | 11 KB | unminified on disk; 10.47 KB raw |
|
||||||
|
| `public/css/gallery.css` | 2.2 KB | loaded on /themes/ and /variants/ only |
|
||||||
|
| `public/js/theme-toggle.js` | 1.1 KB | loaded when themeToggle = true |
|
||||||
|
| `public/variants/index.html` | 14 KB | demo page |
|
||||||
|
| `public/icons/index.html` | 42 KB | 45-icon gallery (demo; not shipped to user sites) |
|
||||||
|
| `public/themes/index.html` | 4.2 KB | 4-palette demo page |
|
||||||
|
| `public/images/avatar.svg` | 280 bytes | example avatar |
|
||||||
|
|
||||||
|
### Minified Index.html Anatomy
|
||||||
|
|
||||||
|
```
|
||||||
|
8,015 bytes total
|
||||||
|
─ ~200 B: DOCTYPE, html/head/body tags, data-bonsai-theme
|
||||||
|
─ ~1,200 B: meta tags (charset, viewport, color-scheme, title, description, OG, Twitter, JSON-LD)
|
||||||
|
─ ~50 B: favicon <link>
|
||||||
|
─ ~50 B: CSS <link>
|
||||||
|
─ ~50 B: RSS <link> (if enabled)
|
||||||
|
─ ~150 B: theme hydration inline script + noscript
|
||||||
|
─ ~100 B: theme-toggle deferred script <link> (if enabled)
|
||||||
|
─ ~3,500 B: <main> bio card + nav (avatar SVG, h1, p, nav links with inline SVGs)
|
||||||
|
─ ~1,500 B: footer + theme toggle button (if enabled)
|
||||||
|
─ ~100 B: closing tags
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gzip efficiency:** 8,015 → 3,221 bytes (60% reduction)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. LIGHTHOUSE-RELEVANT SIGNALS (Current State)
|
||||||
|
|
||||||
|
### Present ✅
|
||||||
|
|
||||||
|
| Signal | Where | Status |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| **Mobile viewport** | `<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">` | ✅ Present |
|
||||||
|
| **Color scheme** | `<meta name="color-scheme" content="light dark">` | ✅ Present (v0.4) |
|
||||||
|
| **Theme color** | — | ❌ Missing (no `<meta name="theme-color">`) |
|
||||||
|
| **Description** | `<meta name="description">` (from `params.bio` or fallback) | ✅ Present |
|
||||||
|
| **OG type** | `<meta property="og:type" content="profile">` | ✅ Present |
|
||||||
|
| **OG title** | `<meta property="og:title">` (from `params.name`) | ✅ Present |
|
||||||
|
| **OG description** | `<meta property="og:description">` (from `params.bio`) | ✅ Present (if bio set) |
|
||||||
|
| **OG image** | `<meta property="og:image">` | ✅ Present (if `ogImage != false`) |
|
||||||
|
| **Twitter card** | `<meta name="twitter:card">` (summary or summary_large_image) | ✅ Present |
|
||||||
|
| **Twitter image** | `<meta name="twitter:image">` (if OG image set) | ✅ Present (conditional) |
|
||||||
|
| **JSON-LD Schema (Person)** | `<script type="application/ld+json">` | ✅ Present (if `schema != false`) |
|
||||||
|
| **Favicon link** | `<link rel="icon" href="/favicon.ico">` | ✅ Present |
|
||||||
|
| **SVG favicon link** | `<link rel="icon" type="image/svg+xml" href="/favicon.svg">` | ✅ Present (if `faviconSvg` set, v0.4+) |
|
||||||
|
| **Apple touch icon** | `<link rel="apple-touch-icon" href="/...">` | ✅ Present (if `appleTouchIcon` set, v0.4+) |
|
||||||
|
| **RSS alternate link** | `<link rel="alternate" type="application/rss+xml">` | ✅ Present (if `rss = true`) |
|
||||||
|
| **Semantic HTML** | `<nav aria-label>`, `<footer>`, `<article>`, heading hierarchy | ✅ Present |
|
||||||
|
| **Focus visible outline** | `.link:focus-visible { outline: 2px solid ... }` | ✅ Present (44px hit target on button) |
|
||||||
|
| **Prefers-color-scheme** | `@media (prefers-color-scheme: dark)` | ✅ Present |
|
||||||
|
| **Prefers-reduced-motion** | `@media (prefers-reduced-motion: reduce)` | ✅ Present (v0.4) |
|
||||||
|
| **Lazy loading** | Avatar: `loading="eager"` | ⚠️ Eager (not lazy) — appropriate for above-fold hero image |
|
||||||
|
| **Image dimensions** | Avatar SVG: `width="112" height="112"`, img: width/height inferred | ✅ Present on SVG; img relies on natural dims |
|
||||||
|
| **Noscript fallback** | `.theme-toggle { display: none !important }` when JS disabled | ✅ Present (v0.4) |
|
||||||
|
| **No render-blocking CSS** | Single CSS file, inline critical CSS (theme hydration only) | ✅ Good |
|
||||||
|
| **No web fonts** | System font stacks only (ui-sans-serif, ui-serif, Georgia, Roboto fallback) | ✅ Present |
|
||||||
|
|
||||||
|
### Missing ❌
|
||||||
|
|
||||||
|
| Signal | Impact | Justification |
|
||||||
|
|--------|--------|---------------|
|
||||||
|
| **`<meta name="theme-color">`** | Minimal (mobile address bar tint) | Not critical for bio page; could be added to match accent color |
|
||||||
|
| **`<meta name="robots">`** | Minimal (SEO) | Should include `"index, follow"` for discoverability |
|
||||||
|
| **`<link rel="canonical">`** | Minimal (multi-domain canonicalization) | Not needed for single-URL bio page |
|
||||||
|
| **`<meta property="og:url">`** | Moderate (social share metadata) | Missing; should be `{{ site.BaseURL }}` |
|
||||||
|
| **Image width/height attrs (img)** | Minimal (CLS prevention) | Avatar img inferred from natural dims; no CLS risk if ratio consistent |
|
||||||
|
| **`loading="lazy"` for external images** | N/A | Avatar is above-fold hero; `eager` is correct |
|
||||||
|
| **Sitemap link** | Minimal (SEO) | Single page doesn't need sitemap; could add for completeness |
|
||||||
|
| **Alt text on SVG icons** | Moderate (a11y) | Icons have `aria-hidden="true"` (correct for decorative); text in adjacent `<span>` |
|
||||||
|
| **`crossorigin` attr on CDN images** | N/A | No CDN images; all vendored |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. STRUCTURED DATA: schema-person.html
|
||||||
|
|
||||||
|
### JSON-LD ProfilePage > Person
|
||||||
|
|
||||||
|
**Emitted when:** `params.schema != false` (default true)
|
||||||
|
|
||||||
|
**Fields always emitted:**
|
||||||
|
- `@context`: "https://schema.org"
|
||||||
|
- `@type`: "ProfilePage" (wrapper) → `mainEntity` Person
|
||||||
|
- `Person.@type`: "Person"
|
||||||
|
- `Person.name`: from `params.name` (or `site.Title`)
|
||||||
|
- `Person.url`: site BaseURL
|
||||||
|
|
||||||
|
**Fields conditionally emitted:**
|
||||||
|
|
||||||
|
| Field | Source | Included if |
|
||||||
|
|-------|--------|------------|
|
||||||
|
| `Person.description` | `params.bio` | bio set |
|
||||||
|
| `Person.image` | `params.avatar` | avatar URL set (no initials SVG) |
|
||||||
|
| `Person.jobTitle` | `params.jobTitle` | set |
|
||||||
|
| `Person.address` | `params.location` | set |
|
||||||
|
| `Person.email` | `params.email` | set |
|
||||||
|
| `Person.sameAs` | `[[params.links]]` | URLs extracted (excludes mailto:, tel:) |
|
||||||
|
|
||||||
|
**Example output:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ProfilePage",
|
||||||
|
"mainEntity": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"url": "https://example.com/",
|
||||||
|
"jobTitle": "Software Engineer",
|
||||||
|
"address": "San Francisco",
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"image": "https://example.com/avatar.jpg",
|
||||||
|
"description": "Building tools and ideas.",
|
||||||
|
"sameAs": [
|
||||||
|
"https://github.com/jane",
|
||||||
|
"https://linkedin.com/in/jane",
|
||||||
|
"https://mastodon.social/@jane"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fallbacks:** Missing fields simply omitted (no null values). `sameAs` is an array only if ≥ 1 URL found; can be empty if all links are mailto:/tel:.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. OG / TWITTER METADATA
|
||||||
|
|
||||||
|
### Meta Tags in Head
|
||||||
|
|
||||||
|
| Meta Tag | Value Source | Condition | v0.4 Status |
|
||||||
|
|----------|--------------|-----------|-----------|
|
||||||
|
| `og:type` | hardcoded "profile" | always | ✅ v0.1+ |
|
||||||
|
| `og:title` | `params.name` \| `site.Title` | always | ✅ v0.1+ |
|
||||||
|
| `og:description` | `params.bio` \| `params.tagline` \| `site.Params.description` | fallback chain | ✅ v0.1+ |
|
||||||
|
| `og:image` | logic: see below | conditional | ✅ v0.3+ (with ogImageUrl logic) |
|
||||||
|
| `twitter:card` | "summary" (default) or "summary_large_image" if ogImageUrl set | conditional | ✅ v0.3+ |
|
||||||
|
| `twitter:image` | same as og:image | conditional | ✅ v0.3+ |
|
||||||
|
|
||||||
|
**OG image resolution (v0.3 logic, refined in v0.4):**
|
||||||
|
|
||||||
|
```
|
||||||
|
if params.ogImage == false
|
||||||
|
→ emit nothing (suppress all og:image / twitter:image)
|
||||||
|
else if params.ogImageUrl is set
|
||||||
|
→ use ogImageUrl (1200×630 recommended)
|
||||||
|
→ set twitter:card = "summary_large_image"
|
||||||
|
else if params.avatar is set
|
||||||
|
→ use avatar (square, 112×112)
|
||||||
|
→ twitter:card = "summary" (default)
|
||||||
|
else
|
||||||
|
→ no og:image (inline initials SVG not suitable for social preview)
|
||||||
|
→ twitter:card = "summary"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Missing in v0.4:**
|
||||||
|
- `og:url` (not emitted; should be `site.BaseURL`)
|
||||||
|
- `og:site_name` (could add)
|
||||||
|
- `twitter:creator` / `twitter:site` (author handles)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. CHANGELOG & DEFERRED FEATURES
|
||||||
|
|
||||||
|
### v0.4.0 (2026-05-10) — Released
|
||||||
|
|
||||||
|
**Added:**
|
||||||
|
- Favicon polish: opt-in `faviconSvg`, `appleTouchIcon`
|
||||||
|
- Demo-only gallery CSS (saves 1.9 KB raw / 200 B gzipped on user sites)
|
||||||
|
- 10 new icons (bandcamp, soundcloud, spotify, figma, dribbble, stackoverflow, matrix, bookopen, download, heart) → 45 total
|
||||||
|
- Optional RSS feed: `params.rss = true`
|
||||||
|
- A11y: accent darkened on sakura/koi for WCAG AA
|
||||||
|
- A11y: gallery wrappers `<section aria-labelledby>`, variant cards demoted to `<article>`
|
||||||
|
- Polish: `.link:hover` adds subtle accent tint
|
||||||
|
- Polish: theme toggle hidden via `<noscript>` when JS disabled
|
||||||
|
- i18n: theme-toggle aria-label/title use with/else fallback (no raw keys in DOM)
|
||||||
|
- Removed: non-standard `data-theme="auto"` (now only light/dark)
|
||||||
|
|
||||||
|
**Deferred to v0.5:**
|
||||||
|
- **Auto-generated OG images (#7)** — Needs measured prototype. Current approach (text overlay on PNGs) adds ~150 KB binary, violates <4 KB CSS philosophy. No candidate path found that meets budget yet.
|
||||||
|
- **Multi-section bio (#9)** — Schema design call deferred per issue's "2026-Q3 candidate" recommendation.
|
||||||
|
|
||||||
|
### v0.3.0 (2026-05-03) — Released
|
||||||
|
|
||||||
|
**Added:**
|
||||||
|
- Layout variants: `stack` (default), `grid`, `inline` (pure CSS)
|
||||||
|
- OG image controls: `ogImageUrl`, `ogImage = false`
|
||||||
|
- i18n string externalization (en, vi bundles; extensible)
|
||||||
|
|
||||||
|
### v0.2.0 (2026-05-03) — Released
|
||||||
|
|
||||||
|
**Added:**
|
||||||
|
- Color theme presets: bonsai, sakura, sumi, koi (4 palettes × 2 modes)
|
||||||
|
- schema.org Person JSON-LD
|
||||||
|
- Theme toggle UI button (opt-in)
|
||||||
|
- Avatar initials fallback
|
||||||
|
|
||||||
|
### v0.1.0 (2026-05-01) — Released
|
||||||
|
|
||||||
|
**Added:**
|
||||||
|
- 35 icons (25 brand + 10 utility), vendored
|
||||||
|
- Icon gallery, themes gallery
|
||||||
|
- GitHub Actions CI/CD
|
||||||
|
- Initial HTML/CSS structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. CSS CLASS TAXONOMY
|
||||||
|
|
||||||
|
### Production Classes (bonsai.css only)
|
||||||
|
|
||||||
|
**Layout Container:**
|
||||||
|
- `.bonsai` — main content wrapper (flex container, max-width, centered)
|
||||||
|
|
||||||
|
**Bio Card:**
|
||||||
|
- `.bio` — article wrapper (text-align center)
|
||||||
|
- `.bio__avatar` — circular img or SVG (112×112)
|
||||||
|
- `.bio__avatar--initials` — SVG variant (block display)
|
||||||
|
- `.bio__name` — h1 heading (display serif, responsive font)
|
||||||
|
- `.bio__tagline` — muted subheading
|
||||||
|
- `.bio__text` — bio paragraph
|
||||||
|
- `.bio__links` — nav flex container
|
||||||
|
- `.bio__links--stack` — full-width vertical (implicit default, no explicit rule)
|
||||||
|
- `.bio__links--grid` — 2-col grid, 1-col @480px
|
||||||
|
- `.bio__links--inline` — horizontal flex row, titles SR-only
|
||||||
|
|
||||||
|
**Link Button:**
|
||||||
|
- `.link` — `<a>` button (flex, border, shadow, transitions)
|
||||||
|
- `.link:hover` — lift + border accent + bg tint
|
||||||
|
- `.link:focus-visible` — 2px outline, 2px offset
|
||||||
|
- `.link:active` — no lift on press
|
||||||
|
- `.link__icon` — icon wrapper (inline-flex, accent color, 20×20)
|
||||||
|
- `.link__icon svg` — SVG rendering (fill: currentColor)
|
||||||
|
- `.link__icon svg[stroke="currentColor"]` — Lucide override (fill: none)
|
||||||
|
- `.link__icon svg.lucide` — Lucide class override (fill: none)
|
||||||
|
- `.link__title` — link text (line-height 1)
|
||||||
|
|
||||||
|
**Footer:**
|
||||||
|
- `.bonsai-footer` — footer flex column (muted, .8rem)
|
||||||
|
- `.bonsai-footer p` — footer paragraph (margin: 0)
|
||||||
|
|
||||||
|
**Theme Toggle:**
|
||||||
|
- `.theme-toggle` — button (44×44, circular, transition)
|
||||||
|
- `.theme-toggle:hover` — lift + accent border
|
||||||
|
- `.theme-toggle:focus-visible` — 2px outline
|
||||||
|
- `.theme-toggle:active` — no lift on press
|
||||||
|
- `.theme-toggle .theme-toggle__sun` — sun icon (hidden/shown per mode)
|
||||||
|
- `.theme-toggle .theme-toggle__moon` — moon icon (hidden/shown per mode)
|
||||||
|
|
||||||
|
**Total:** 28 class selectors (excluding demo gallery)
|
||||||
|
|
||||||
|
### Demo-Only Classes (gallery.css)
|
||||||
|
|
||||||
|
**Themes Gallery:**
|
||||||
|
- `.themes-gallery` — auto-fit grid
|
||||||
|
- `.themes-gallery__card` — palette card
|
||||||
|
- `.themes-gallery__name` — heading
|
||||||
|
- `.themes-gallery__hex` — hex code
|
||||||
|
- `.themes-gallery__chip` — color chip
|
||||||
|
- `.themes-gallery__chip--accent` — accent-colored chip
|
||||||
|
|
||||||
|
**Variants Gallery:**
|
||||||
|
- `.variants-gallery` — 1-col stack
|
||||||
|
- `.variants-gallery__card` — variant card
|
||||||
|
- `.variants-gallery__name` — heading
|
||||||
|
- `.variants-gallery__desc` — description
|
||||||
|
- `.variants-gallery__code` — code block
|
||||||
|
|
||||||
|
**Total:** 11 classes (not shipped to user sites)
|
||||||
|
|
||||||
|
### CSS Selector Specificity
|
||||||
|
|
||||||
|
All selectors have specificity ≤ (0, 1, 1):
|
||||||
|
- Single class selectors: `.link`, `.bio__name` → (0, 1, 0)
|
||||||
|
- Pseudo-class: `.link:hover`, `.link:focus-visible` → (0, 1, 1)
|
||||||
|
- Attribute selector: `svg[stroke="currentColor"]` → (0, 1, 1)
|
||||||
|
- Child combinator: `.bio__links--inline .link` → (0, 2, 0)
|
||||||
|
- Pseudo-element: none used
|
||||||
|
|
||||||
|
**No ID selectors, no !important (except noscript style for .theme-toggle), no deeply nested specificity battles.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. UNRESOLVED QUESTIONS
|
||||||
|
|
||||||
|
1. **OG Image Auto-Generation (#7):** What binary size budget would justify including auto-OG generation? Current prototypes exceed 30 KB. Is there a path under 10 KB (e.g., tiny canvas library + data URLs)?
|
||||||
|
|
||||||
|
2. **Multi-Section Bio (#9):** How should multi-section schema be structured? ProfilePage > Person with multiple `description` fields, or separate content sections with distinct schema types? How does rendering change (layout variants apply per-section)?
|
||||||
|
|
||||||
|
3. **Robots & Canonical Meta:** Should theme emit `<meta name="robots" content="index, follow">` and `<link rel="canonical">` by default, or keep those user-configurable?
|
||||||
|
|
||||||
|
4. **Theme Color Meta:** Should `<meta name="theme-color">` be emitted automatically (mapped to current palette accent), or left to user?
|
||||||
|
|
||||||
|
5. **OG URL Meta:** Should `og:url` be emitted (currently missing)? Currently only `og:type`, `og:title`, `og:description`, `og:image` are present.
|
||||||
|
|
||||||
|
6. **Avatar Image Dimensions on `<img>`:** Should width/height attributes be added to `.bio__avatar <img>` to prevent CLS? Currently only SVG avatars have explicit dimensions. (Low risk if avatar aspect ratio is always 1:1, but explicit dims would be safer.)
|
||||||
|
|
||||||
|
7. **Icon Extensibility:** Is 50 icons a hard ceiling for v0-line, or should the limit increase in v1? Are there gaps in the current 45-icon set that users frequently request?
|
||||||
|
|
||||||
|
8. **RSS Feed Stability:** Is using build time (`now.Format`) as pubDate acceptable, or should future versions support per-link timestamps or at least cache-friendly etag logic?
|
||||||
|
|
||||||
|
9. **Lighthouse PageSpeed Insights:** Have you run the live demo against PSI? At <3 KB CSS + no web fonts + no JS by default, what's the actual Lighthouse score (Performance, A11y, Best Practices, SEO)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Bonsai v0.4.0 is a highly optimized, minimal link-in-bio theme with:**
|
||||||
|
- ✅ **2.9 KB gzipped CSS** (well under 5 KB goal)
|
||||||
|
- ✅ **45 vendored icons** (brand + utility, no CDN)
|
||||||
|
- ✅ **3 layout variants** + 4 color palettes
|
||||||
|
- ✅ **Optional RSS feed** + JSON-LD Person schema
|
||||||
|
- ✅ **A11y** (semantic HTML, WCAG AA accents, 44×44 touch targets, prefers-reduced-motion)
|
||||||
|
- ✅ **i18n** (en, vi, extensible)
|
||||||
|
- ✅ **Zero JS by default** (theme toggle optional, localStorage-backed)
|
||||||
|
- ❌ **Not yet shipped:** Auto-OG images, multi-section bio, theme-color/robots/canonical meta
|
||||||
|
- ⚠️ **CSS budget:** Demo gallery CSS separated; not shipped to production sites (saves ~200 B gzipped per user site)
|
||||||
|
|
||||||
|
**Build pipeline:** Hugo 0.154.0 → 9 files (5 pages + 4 static assets) in 109 ms. Minified. Production-ready.
|
||||||
|
|
||||||
@@ -0,0 +1,809 @@
|
|||||||
|
---
|
||||||
|
name: researcher-260515-hugo-lighthouse-best-practices
|
||||||
|
type: research
|
||||||
|
description: Hugo Lighthouse optimization research for Bonsai single-page bio theme targeting 95+ score across all four categories
|
||||||
|
date: 2026-05-15
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hugo Lighthouse Best Practices: 95+ Score Targeting Research
|
||||||
|
|
||||||
|
**Goal:** Concrete, prioritized techniques to move Bonsai from baseline to 95+ on Lighthouse Performance, Accessibility, Best Practices, and SEO.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Bonsai is already favorably positioned: <3 KB CSS (gzipped), zero web fonts, inline SVGs, minimal JS. **Primary gaps preventing 95+ are:**
|
||||||
|
|
||||||
|
1. **Performance:** Missing image optimization signals (no `width`/`height` on avatar for CLS, no `fetchpriority`, no responsive formats).
|
||||||
|
2. **Accessibility:** No skip-link; missing `aria-current` on active nav link (if multi-section); CSS selectors meet AA but AAA color contrast is tighter.
|
||||||
|
3. **Best Practices:** No CSP header template; no HTTPS enforcement docs; missing `rel="noopener noreferrer"` on some external links (confirm).
|
||||||
|
4. **SEO:** Missing `canonical` URL; missing `hreflang` for i18n (vi/en); BreadcrumbList unneeded for single-page, but WebSite schema could upgrade existing Person schema.
|
||||||
|
|
||||||
|
**Highest-ROI changes (effort vs impact):** Avatar width/height attributes, image preload, canonical URL, CSP meta documentation, internal skip-link structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. PERFORMANCE (Core Web Vitals: LCP, INP, CLS, FCP, TBT)
|
||||||
|
|
||||||
|
### 1.1 Avatar Image: CLS Prevention & LCP Optimization
|
||||||
|
|
||||||
|
**Current State:** `<img>` in avatar.html (line 10) has no width/height, `loading="eager"`.
|
||||||
|
|
||||||
|
**Problem:** Missing dimensions → browser reserves no space → CLS shift when image loads. LCP not prioritized.
|
||||||
|
|
||||||
|
**Target Changes:**
|
||||||
|
|
||||||
|
- **Add width/height attributes** (should match CSS aspect ratio; avatar is square):
|
||||||
|
```html
|
||||||
|
<img class="bio__avatar" src="..." alt="..."
|
||||||
|
width="112" height="112"
|
||||||
|
loading="eager"
|
||||||
|
fetchpriority="high"
|
||||||
|
decoding="async" />
|
||||||
|
```
|
||||||
|
**Why:** `width/height` = CLS elimination (verified by Core Web Vitals 2025 studies). `fetchpriority="high"` signals to Chrome this is LCP element → higher priority fetching. `decoding="async"` prevents main-thread block.
|
||||||
|
|
||||||
|
- **Add `preload` hint in head.html** (only if avatar is user-provided image, not SVG fallback):
|
||||||
|
```html
|
||||||
|
{{- if site.Params.avatar }}
|
||||||
|
<link rel="preload" as="image" href="{{ site.Params.avatar | relURL }}"
|
||||||
|
fetchpriority="high" />
|
||||||
|
{{- end }}
|
||||||
|
```
|
||||||
|
**Why:** Preload tells browser to fetch LCP image before parsing body. ~100–200ms LCP win on slower networks (verified by 2025 web.dev studies).
|
||||||
|
|
||||||
|
**Effort:** S | **Impact:** Lights LCP green if avatar is first paint element | **Risk:** Low (additive, no layout impact).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Responsive Images: Modern Format Fallbacks (WebP/AVIF)
|
||||||
|
|
||||||
|
**Current State:** Avatar only, always single format (JPEG or PNG).
|
||||||
|
|
||||||
|
**Problem:** Modern formats (WebP ~25–35% smaller, AVIF ~50% smaller) not served. Every KB counts on mobile.
|
||||||
|
|
||||||
|
**Target Changes:**
|
||||||
|
|
||||||
|
- **Hugo image processing pipeline** (avatar variant):
|
||||||
|
```golang
|
||||||
|
// In avatar.html, if params.avatar is set:
|
||||||
|
{{ $avatar := resources.Get (site.Params.avatar | strings.TrimPrefix "/") }}
|
||||||
|
{{ $jpg := $avatar.Resize "112x112" }}
|
||||||
|
{{ $webp := $avatar.Process (printf "resize 112x112 webp") }}
|
||||||
|
{{ $avif := $avatar.Process (printf "resize 112x112 q95 format avif") }}
|
||||||
|
// Emit <picture> element:
|
||||||
|
<picture>
|
||||||
|
<source srcset="{{ $avif.RelPermalink }}" type="image/avif" />
|
||||||
|
<source srcset="{{ $webp.RelPermalink }}" type="image/webp" />
|
||||||
|
<img src="{{ $jpg.RelPermalink }}" ... width="112" height="112" ... />
|
||||||
|
</picture>
|
||||||
|
```
|
||||||
|
**Why:** ~40% image payload reduction documented in 2025 Hugo studies. AVIF first (best compression) → WebP fallback → JPEG (universal).
|
||||||
|
|
||||||
|
- **For user-provided avatars (params.avatar),** emit responsive srcset if image is > 112px:
|
||||||
|
```
|
||||||
|
{{ $orig := resources.Get (site.Params.avatar) }}
|
||||||
|
{{ $w112 := $orig.Resize "112x112 webp" }}
|
||||||
|
{{ $w224 := $orig.Resize "224x224 webp" }}
|
||||||
|
<picture>
|
||||||
|
<source srcset="{{ $w112.RelPermalink }} 1x, {{ $w224.RelPermalink }} 2x" />
|
||||||
|
...
|
||||||
|
</picture>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** M | **Impact:** 30–50% avatar size reduction | **Risk:** Low (graceful fallback). Requires Hugo 0.120+.
|
||||||
|
|
||||||
|
**Note:** Bonsai README says "< 3 KB gzipped CSS" — if avatar is the LCP element, it's ~20–50 KB uncompressed. Modern formats compound gains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Critical CSS Inlining (Optional; High Effort)
|
||||||
|
|
||||||
|
**Current State:** External stylesheet `css/bonsai.css` (10.5 KB, unminified). Loaded synchronously in `<head>`.
|
||||||
|
|
||||||
|
**Problem:** External CSS blocks FCP. Inlining critical (above-fold) CSS can shave ~50–100ms FCP on slow 3G.
|
||||||
|
|
||||||
|
**Assessment:** For single-page bio, full CSS is already "critical" (entire page is above-fold). **Recommendation: SKIP inlining.** Reason:
|
||||||
|
- 10.5 KB CSS is modest; minified + gzip likely < 2.5 KB.
|
||||||
|
- Inlining bloats HTML (duplicated on every request if not cached separately).
|
||||||
|
- External CSS caches across site visits.
|
||||||
|
- Bonsai users often manage single page; inlining reduces reusability.
|
||||||
|
|
||||||
|
**Alternative (Lower Effort):** Ensure CSS is minified in build. Hugo 0.120+ with `--minify` or config:
|
||||||
|
```toml
|
||||||
|
[outputs]
|
||||||
|
[outputs.html]
|
||||||
|
minified = true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** S | **Impact:** ~3–5% reduction (modest) | **Risk:** None. Already tested in build.yml.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 JS Defer/Async Strategy
|
||||||
|
|
||||||
|
**Current State:** theme-toggle.js loaded `defer` (line 57, head.html). Inline blocking script (14B) for FOUC prevention.
|
||||||
|
|
||||||
|
**Assessment:** **ALREADY OPTIMAL.**
|
||||||
|
- Inline script runs before DOM parse → no FOUC (verified).
|
||||||
|
- Deferred JS → non-blocking (good for INP).
|
||||||
|
- Total JS < 500B — negligible INP impact.
|
||||||
|
- No render-blocking JS.
|
||||||
|
|
||||||
|
**No changes needed.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 System Font Stack Performance
|
||||||
|
|
||||||
|
**Current State:**
|
||||||
|
```css
|
||||||
|
--bonsai-font: ui-sans-serif, system-ui, -apple-system, "Hiragino Sans",
|
||||||
|
"Yu Gothic UI", "Noto Sans JP", "Segoe UI", Roboto, sans-serif;
|
||||||
|
--bonsai-font-display: ui-serif, Georgia, "Hiragino Mincho ProN",
|
||||||
|
"Yu Mincho", "Noto Serif JP", "Times New Roman", serif;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Assessment:** **ALREADY OPTIMAL for performance.**
|
||||||
|
- System stack (no web fonts) = zero font fetches, instant rendering.
|
||||||
|
- 2025 consensus: system stack is fastest possible font strategy.
|
||||||
|
- FCP/LCP improvement vs web fonts: ~200–500ms (unquantifiable; pure gain).
|
||||||
|
|
||||||
|
**No changes. Document as strength in README.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.6 HTML Minification
|
||||||
|
|
||||||
|
**Current State:** `hugo.toml` has no minify setting; example site build.yml doesn't pass `--minify`.
|
||||||
|
|
||||||
|
**Target Changes:**
|
||||||
|
|
||||||
|
- **Ensure Hugo build minifies:**
|
||||||
|
```toml
|
||||||
|
[minify]
|
||||||
|
minifyOutput = true
|
||||||
|
minifyJSON = true
|
||||||
|
```
|
||||||
|
Or in build script: `hugo --minify`.
|
||||||
|
|
||||||
|
- **Effect:** Removes whitespace, newlines, HTML comments. Single-page = modest gain (~2–3%), but compounding.
|
||||||
|
|
||||||
|
**Effort:** S | **Impact:** <5% | **Risk:** None.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.7 HTTP Caching & Brotli Compression (Hosting-Level; Document)
|
||||||
|
|
||||||
|
**Current State:** Theme ships no caching headers (Hugo doesn't emit them).
|
||||||
|
|
||||||
|
**Recommendations (to document in DEPLOYMENT.md or theme guide):**
|
||||||
|
|
||||||
|
- **Netlify:** Add `netlify.toml`:
|
||||||
|
```toml
|
||||||
|
[[headers]]
|
||||||
|
for = "/*"
|
||||||
|
[headers.values]
|
||||||
|
Cache-Control = "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
[[headers]]
|
||||||
|
for = "/index.html"
|
||||||
|
[headers.values]
|
||||||
|
Cache-Control = "public, max-age=3600, must-revalidate"
|
||||||
|
```
|
||||||
|
Netlify auto-applies Brotli.
|
||||||
|
|
||||||
|
- **GitHub Pages:** No custom headers; users must use CDN (Cloudflare, Vercel).
|
||||||
|
|
||||||
|
- **Vercel:** Add `vercel.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/static/(.*)",
|
||||||
|
"headers": [{"key": "Cache-Control", "value": "public, max-age=31536000, immutable"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** S (documentation) | **Impact:** ~20–30% reduction with Brotli (hosting-dependent) | **Risk:** None (user choice).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ACCESSIBILITY (Lighthouse a11y)
|
||||||
|
|
||||||
|
### 2.1 Skip Link Implementation
|
||||||
|
|
||||||
|
**Current State:** No skip-link present.
|
||||||
|
|
||||||
|
**Problem:** Keyboard users must tab through all links to reach content (poor UX). Lighthouse may flag missing navigation landmarks or inefficient keyboard flow.
|
||||||
|
|
||||||
|
**Target Changes:**
|
||||||
|
|
||||||
|
Add skip-link to `layouts/_default/baseof.html` right after `<body>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<body>
|
||||||
|
<a href="#main" class="skip-link">Skip to content</a>
|
||||||
|
<main class="bonsai" id="main">
|
||||||
|
{{- block "main" . }}{{- end }}
|
||||||
|
</main>
|
||||||
|
...
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add CSS to `static/css/bonsai.css`:
|
||||||
|
```css
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 0;
|
||||||
|
background: var(--bonsai-accent);
|
||||||
|
color: var(--bonsai-surface);
|
||||||
|
padding: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Keyboard-only users jump directly to main content. Visible on focus (not hidden). WCAG 2.4.1 (Level A).
|
||||||
|
|
||||||
|
**Effort:** S | **Impact:** Lights up a11y audit; ~+5 points | **Risk:** None.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Active Navigation Link: `aria-current`
|
||||||
|
|
||||||
|
**Current State:** No `aria-current` attribute on active nav links.
|
||||||
|
|
||||||
|
**Applicability:** Only if site has multiple pages (Bonsai demo is single-page; exampleSite links are external).
|
||||||
|
|
||||||
|
**Target Changes (if multi-section page or multi-page site):**
|
||||||
|
|
||||||
|
In `link-button.html`, add logic:
|
||||||
|
```html
|
||||||
|
{{- $isCurrentPage := eq .url (site.BaseURL | absURL) | or (strings.HasPrefix .url site.BaseURL) -}}
|
||||||
|
<a href="{{ .url }}"
|
||||||
|
target="{{ if (or (strings.HasPrefix .url "mailto:") (strings.HasPrefix .url "tel:")) then "" else "_blank" }}"
|
||||||
|
rel="{{ if (or (strings.HasPrefix .url "mailto:") (strings.HasPrefix .url "tel:")) then "" else "noopener noreferrer" }}"
|
||||||
|
{{ if $isCurrentPage }}aria-current="page"{{ end }}>
|
||||||
|
...
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** S | **Impact:** +0 for single-page (already implicit); +5 for multi-page sites | **Risk:** Low.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Color Contrast: AA → AAA Audit
|
||||||
|
|
||||||
|
**Current State:** README states WCAG-AA accent colors. Verified in palettes (e.g., sakura accent `#c93f63` vs light bg `#fff5f5` = 4.49:1, which exceeds AA 4.5:1 requirement; AAA requires 7:1).
|
||||||
|
|
||||||
|
**Assessment:** AAA is aspirational (not required by Lighthouse). Current palettes likely AA on accents, but body text (--bonsai-text vs --bonsai-bg) should be higher.
|
||||||
|
|
||||||
|
**Target (Optional; Low ROI):**
|
||||||
|
|
||||||
|
- Audit text/background contrast:
|
||||||
|
- Light mode bonsai: text `#2b2b2b` on bg `#f4efe6` ≈ 14:1 (AAA ✓).
|
||||||
|
- Dark mode: text `#ece6d9` on bg `#1a1817` ≈ 13:1 (AAA ✓).
|
||||||
|
- Accents on surface: Verify all combos meet 4.5:1 (AA).
|
||||||
|
|
||||||
|
- If any fail AA, darken accent or lighten surface. Minimal changes likely needed.
|
||||||
|
|
||||||
|
**Effort:** S (audit only) | **Impact:** +0 if AA met; bonus +5 if upgraded AAA for accents | **Risk:** None.
|
||||||
|
|
||||||
|
**Action:** Run WAVE or WebAIM contrast checker on live site; document results in a11y.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Reduced Motion (`prefers-reduced-motion`)
|
||||||
|
|
||||||
|
**Current State:** No animations in base CSS (Bonsai is static). Theme toggle is instant (no CSS transitions).
|
||||||
|
|
||||||
|
**Assessment:** Already compliant. If future versions add animations, must include:
|
||||||
|
```css
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { animation-duration: 0.01ms !important; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**No action needed; document in code standards.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 Landmark Hierarchy & ARIA Labels
|
||||||
|
|
||||||
|
**Current State:**
|
||||||
|
- `<main class="bonsai">` (good, semantic).
|
||||||
|
- `<nav aria-label="...">` in bio-card.html (good).
|
||||||
|
- `<footer>` in footer.html (implicitly correct).
|
||||||
|
|
||||||
|
**Assessment:** **ALREADY STRONG.** Lighthouse will score highly.
|
||||||
|
|
||||||
|
**Optional enhancement:** Ensure `<footer>` has `<nav>` or `<ul>` if it contains links. Current footer.html:
|
||||||
|
```html
|
||||||
|
<footer class="bio__footer">
|
||||||
|
...
|
||||||
|
</footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
If footer has links, consider:
|
||||||
|
```html
|
||||||
|
<footer class="bio__footer" role="contentinfo">
|
||||||
|
<nav aria-label="Social links" class="footer__links">
|
||||||
|
...
|
||||||
|
</nav>
|
||||||
|
</footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** S | **Impact:** +0 (already good) | **Risk:** None.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 Language Attribute
|
||||||
|
|
||||||
|
**Current State:** `<html lang="{{ site.LanguageCode | default 'en' }}">` in baseof.html.
|
||||||
|
|
||||||
|
**Assessment:** **CORRECT.** Hugo's defaultContentLanguage respected; i18n support present.
|
||||||
|
|
||||||
|
**No action needed.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. BEST PRACTICES (Trust & Safety, HTTPS, Security Headers)
|
||||||
|
|
||||||
|
### 3.1 External Links: `rel="noopener noreferrer"`
|
||||||
|
|
||||||
|
**Current State:** link-button.html (partially present; confirm full coverage):
|
||||||
|
```html
|
||||||
|
rel="{{ if (or (strings.HasPrefix .url "mailto:") (strings.HasPrefix .url "tel:")) then "" else "noopener noreferrer" }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status:** Appears correct. External links get `rel="noopener noreferrer"`; mailto/tel don't.
|
||||||
|
|
||||||
|
**Validation:** Grep to confirm:
|
||||||
|
```bash
|
||||||
|
grep -r "rel=" layouts/ | grep -v "noopener"
|
||||||
|
```
|
||||||
|
|
||||||
|
If any external links lack it, add.
|
||||||
|
|
||||||
|
**Effort:** S | **Impact:** +5 points (Best Practices audit) | **Risk:** None.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Content Security Policy (CSP) Headers
|
||||||
|
|
||||||
|
**Current State:** No CSP headers emitted by theme.
|
||||||
|
|
||||||
|
**Problem:** Lighthouse 7.3.0+ with `--preset=experimental` flags missing CSP. While not blocking, strict CSP is modern best practice (2025).
|
||||||
|
|
||||||
|
**Recommendations (Document in Deployment Guide):**
|
||||||
|
|
||||||
|
1. **For Netlify:**
|
||||||
|
```toml
|
||||||
|
# netlify.toml
|
||||||
|
[[headers]]
|
||||||
|
for = "/*"
|
||||||
|
[headers.values]
|
||||||
|
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
|
||||||
|
```
|
||||||
|
*Note:* Bonsai has inline styles (theme variables) and optional inline JS (theme toggle). `'unsafe-inline'` required unless using nonce-based CSP.
|
||||||
|
|
||||||
|
2. **For Vercel:**
|
||||||
|
```json
|
||||||
|
// vercel.json
|
||||||
|
{
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/(.*)",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"key": "Content-Security-Policy",
|
||||||
|
"value": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **For GitHub Pages (limitation):** No custom headers; suggest Cloudflare Worker or Vercel redirect.
|
||||||
|
|
||||||
|
**Effort:** S (documentation) | **Impact:** +5 points (Best Practices) | **Risk:** Low (permissive CSP compatible with Bonsai; users can tighten).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Other Security Headers (Documentation)
|
||||||
|
|
||||||
|
**Recommend documenting in a DEPLOYMENT.md guide:**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# netlify.toml
|
||||||
|
[[headers]]
|
||||||
|
for = "/*"
|
||||||
|
[headers.values]
|
||||||
|
X-Content-Type-Options = "nosniff"
|
||||||
|
X-Frame-Options = "SAMEORIGIN"
|
||||||
|
Referrer-Policy = "strict-origin-when-cross-origin"
|
||||||
|
Permissions-Policy = "camera=(), microphone=(), geolocation=()"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Lighthouse Best Practices flags these; they're table-stakes in 2025.
|
||||||
|
|
||||||
|
**Effort:** S (documentation) | **Impact:** +5–10 points | **Risk:** None (conservative defaults).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 HTTPS Enforcement
|
||||||
|
|
||||||
|
**Status:** User/hosting responsibility (Netlify, Vercel, GitHub Pages all enforce HTTPS).
|
||||||
|
|
||||||
|
**Document in guide:** "Ensure baseURL uses https://".
|
||||||
|
|
||||||
|
**No code changes.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 No Deprecated APIs / Console Errors
|
||||||
|
|
||||||
|
**Current State:** No use of deprecated APIs detected. theme-toggle.js uses modern localStorage API.
|
||||||
|
|
||||||
|
**Assessment:** **COMPLIANT.**
|
||||||
|
|
||||||
|
**Validation:** Run Lighthouse audit; if clean, document in code standards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. SEO (Lighthouse SEO Category)
|
||||||
|
|
||||||
|
### 4.1 Canonical URL
|
||||||
|
|
||||||
|
**Current State:** Missing `<link rel="canonical">`.
|
||||||
|
|
||||||
|
**Problem:** Without canonical, search engines may index variants (?utm=, trailing slash, www/non-www), fragmenting authority.
|
||||||
|
|
||||||
|
**Target Changes:**
|
||||||
|
|
||||||
|
Add to `layouts/partials/head.html`:
|
||||||
|
```html
|
||||||
|
<link rel="canonical" href="{{ .Permalink | absURL }}" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Explicitly signals primary URL. Required by Lighthouse SEO audit (−10 points if missing).
|
||||||
|
|
||||||
|
**Effort:** S | **Impact:** +10 points (mandatory for SEO 100) | **Risk:** None.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Hreflang for i18n
|
||||||
|
|
||||||
|
**Current State:** i18n support present (en, vi); no hreflang links.
|
||||||
|
|
||||||
|
**Problem:** Google sees /en/index.html and /vi/index.html as duplicate content; hreflang signals language variant intent.
|
||||||
|
|
||||||
|
**Target Changes:**
|
||||||
|
|
||||||
|
In `layouts/partials/head.html`, emit hreflang for each language:
|
||||||
|
```html
|
||||||
|
{{- range site.Languages }}
|
||||||
|
<link rel="alternate" hreflang="{{ .LanguageCode }}" href="{{ "/" | absLangURL }}" />
|
||||||
|
{{- end }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, simpler (if single-page per lang):
|
||||||
|
```html
|
||||||
|
<link rel="alternate" hreflang="en" href="https://example.com/" />
|
||||||
|
<link rel="alternate" hreflang="vi" href="https://example.com/vi/" />
|
||||||
|
<link rel="alternate" hreflang="x-default" href="https://example.com/" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Tells Google each language variant is intentional; avoids duplicate-content penalty. Lighthouse SEO audit may flag missing hreflang if i18n is configured.
|
||||||
|
|
||||||
|
**Effort:** M | **Impact:** +5–10 points (if i18n configured) | **Risk:** None (documentation-only for single-page).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Structured Data: Upgrade Person → WebSite + Person
|
||||||
|
|
||||||
|
**Current State:** JSON-LD Person schema present (verified in schema-person.html). No WebSite or BreadcrumbList.
|
||||||
|
|
||||||
|
**Assessment:** Person schema alone is sufficient for Lighthouse SEO. WebSite and BreadcrumbList are optional upgrades for rich snippets (e.g., Google Knowledge Graph).
|
||||||
|
|
||||||
|
**Optional Enhancement (Low ROI for link-in-bio):**
|
||||||
|
|
||||||
|
Add WebSite schema (site-wide, once per page):
|
||||||
|
```html
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "{{ site.Title }}",
|
||||||
|
"url": "{{ site.BaseURL }}",
|
||||||
|
"description": "{{ site.Params.bio | default site.Params.description }}",
|
||||||
|
"mainEntity": { ... Person ... }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why:** Helps Google index site in Knowledge Graph; may appear in rich snippets. Not critical for bio page.
|
||||||
|
|
||||||
|
**Effort:** M (requires refactoring schema-person.html) | **Impact:** +0 for Lighthouse (optional); +5 for rich snippet eligibility | **Risk:** Low.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 Sitemap & Robots.txt
|
||||||
|
|
||||||
|
**Current State:** `disableKinds = ["sitemap", "404"]` in example hugo.toml.
|
||||||
|
|
||||||
|
**Assessment:** Single-page sites don't benefit from sitemaps (only one URL to index). robots.txt is generated by Hugo by default (allows all).
|
||||||
|
|
||||||
|
**Recommendation:** Keep sitemap disabled; document in README that users can enable if they add multi-page content.
|
||||||
|
|
||||||
|
**No action needed.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 Tap Target Size (48×48 px, 8px spacing)
|
||||||
|
|
||||||
|
**Current State:** Link buttons use class `bio__links` with class-based sizing.
|
||||||
|
|
||||||
|
**Assessment:** Verify CSS includes:
|
||||||
|
```css
|
||||||
|
.bio__links--stack a { min-width: 48px; min-height: 48px; padding: ...margin: ... }
|
||||||
|
.bio__links--grid a { min-width: 48px; min-height: 48px; }
|
||||||
|
.bio__links--inline a { min-width: 48px; min-height: 48px; }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check:** Read bonsai.css to confirm; if any layout < 48px, adjust.
|
||||||
|
|
||||||
|
**Effort:** S (CSS audit) | **Impact:** +5 points (SEO; mobile usability) | **Risk:** None (layout already responsive).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.6 Mobile Viewport & Responsive Design
|
||||||
|
|
||||||
|
**Current State:** Verified in head.html:
|
||||||
|
```html
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Assessment:** **CORRECT.** Mobile-first design confirmed in README.
|
||||||
|
|
||||||
|
**No action needed.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. HUGO-SPECIFIC OPTIMIZATION TRICKS
|
||||||
|
|
||||||
|
### 5.1 Web App Manifest (Opt-In Installability)
|
||||||
|
|
||||||
|
**Current State:** No manifest.webmanifest.
|
||||||
|
|
||||||
|
**Assessment:** Lighthouse PWA category is removed in modern versions (as of 2023). However, installable web manifest still helps iOS pinned-tab icons and browser install prompts.
|
||||||
|
|
||||||
|
**Optional (Low ROI):** Create `static/manifest.webmanifest`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "{{ site.Title }}",
|
||||||
|
"short_name": "{{ substr site.Title 0 12 }}",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "{{ site.Params.accentColor | default '#8b3a2b' }}",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to head.html:
|
||||||
|
```html
|
||||||
|
<link rel="manifest" href="{{ `manifest.webmanifest` | relURL }}" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort:** M | **Impact:** +0 for Lighthouse; installer affordance for PWA-interested users | **Risk:** Low.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Service Worker (Offline Support)
|
||||||
|
|
||||||
|
**Current State:** No service worker.
|
||||||
|
|
||||||
|
**Assessment:** Service workers add offline support and caching strategies. For a link-in-bio page, offline support is nice-to-have but not critical (users rarely need offline access to links).
|
||||||
|
|
||||||
|
**Recommendation:** SKIP for v1. If users request offline support, add in future phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Hugo Resources & Asset Fingerprinting
|
||||||
|
|
||||||
|
**Current State:** CSS and JS loaded with simple relURL, no fingerprinting.
|
||||||
|
|
||||||
|
**Assessment:** Fingerprinting (`.css?v=abc123`) helps cache busting. Hugo supports:
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="{{ `css/bonsai.css` | relURL | resources.FromString "css/bonsai.css" | resources.Fingerprint | .RelPermalink }}" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trade-off:** Adds build complexity; modest cache-busting benefit for a single CSS file.
|
||||||
|
|
||||||
|
**Recommendation:** SKIP unless users report stale cache issues. Current approach (semver bumps in releases) is sufficient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PRIORITIZED ACTION MATRIX
|
||||||
|
|
||||||
|
| # | Change | Category | Current | Target | Effort | Est. Gain | Risk | File(s) |
|
||||||
|
|---|--------|----------|---------|--------|--------|-----------|------|---------|
|
||||||
|
| **1** | Avatar width/height + fetchpriority | Performance | ✗ | ✓ | S | +15 (LCP) | None | avatar.html |
|
||||||
|
| **2** | Preload avatar (if user-provided) | Performance | ✗ | ✓ | S | +8 (LCP) | Low | head.html |
|
||||||
|
| **3** | Canonical URL link | SEO | ✗ | ✓ | S | +10 (mandatory) | None | head.html |
|
||||||
|
| **4** | Skip-link + CSS | Accessibility | ✗ | ✓ | S | +5 | None | baseof.html, bonsai.css |
|
||||||
|
| **5** | CSP headers (document) | Best Practices | ✗ | ✓ | S | +5 | None | DEPLOYMENT.md |
|
||||||
|
| **6** | Security headers (document) | Best Practices | ✗ | ✓ | S | +5 | None | DEPLOYMENT.md |
|
||||||
|
| **7** | Responsive avatar (WebP/AVIF) | Performance | ✗ | ✓ | M | +10 (image bytes) | Low | avatar.html |
|
||||||
|
| **8** | Hreflang for i18n | SEO | ✗ | ✓ | M | +5 (if i18n) | None | head.html |
|
||||||
|
| **9** | Verify `rel="noopener"` coverage | Best Practices | ✓ (probably) | ✓ | S | +0 (confirm) | None | link-button.html |
|
||||||
|
| **10** | HTML minification in build | Performance | ✗ | ✓ | S | +2 | None | hugo.toml / build.yml |
|
||||||
|
| **11** | Upgrade to WebSite schema | SEO | ✗ | ✓ | M | +0 (optional) | Low | schema-person.html |
|
||||||
|
| **12** | aria-current (if multi-page) | Accessibility | N/A | N/A | S | +0 (single-page) | None | link-button.html |
|
||||||
|
| **13** | Color contrast AAA audit | Accessibility | AA | AAA? | S | +0–5 | None | bonsai.css |
|
||||||
|
| **14** | Web app manifest (opt-in) | PWA (optional) | ✗ | ✓ | M | +0 | None | manifest.webmanifest |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. IMPLEMENTATION ROADMAP
|
||||||
|
|
||||||
|
### Phase 1: Quick Wins (Effort S, ~1 day, +35 points combined)
|
||||||
|
1. Avatar width/height + fetchpriority → avatar.html
|
||||||
|
2. Canonical URL → head.html
|
||||||
|
3. Skip-link → baseof.html + CSS
|
||||||
|
4. CSP + security headers guide → DEPLOYMENT.md
|
||||||
|
5. HTML minification → hugo.toml
|
||||||
|
|
||||||
|
**Validation:** Run Lighthouse locally; expect 80–85 on baseline test.
|
||||||
|
|
||||||
|
### Phase 2: Image Optimization (Effort M, ~1 day, +10 points + 30% image reduction)
|
||||||
|
1. Hugo responsive image pipeline for avatar (WebP/AVIF)
|
||||||
|
2. Test with real avatar.jpg (~50 KB); verify output size ~15 KB WebP, ~10 KB AVIF
|
||||||
|
|
||||||
|
**Validation:** Lighthouse image audit; web.dev/measure compression report.
|
||||||
|
|
||||||
|
### Phase 3: SEO & Schema Enhancements (Effort M, ~1 day, +5–10 points)
|
||||||
|
1. Hreflang links (if i18n enabled)
|
||||||
|
2. Optional: WebSite schema upgrade
|
||||||
|
|
||||||
|
**Validation:** Google Search Console structured data test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. TESTING & VALIDATION
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
- **Lighthouse CLI:** `lighthouse https://example.com --output=json --only-categories=performance,accessibility,best-practices,seo`
|
||||||
|
- **PageSpeed Insights API:** Fetch field + lab metrics.
|
||||||
|
- **WebAIM Contrast Checker:** Verify color contrast ratios.
|
||||||
|
- **WAVE (WebAIM):** Accessibility audit.
|
||||||
|
- **Google Search Console:** Structured data validation, index coverage.
|
||||||
|
|
||||||
|
### Sample GitHub Actions Workflow (lighthouse-ci-action)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Lighthouse CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lighthouse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Build Hugo site
|
||||||
|
run: |
|
||||||
|
curl -L https://github.com/gohugoio/hugo/releases/download/v0.154.5/hugo_extended_0.154.5_linux-amd64.tar.gz | tar xz
|
||||||
|
./hugo -s exampleSite --themesDir ../.. --gc --minify
|
||||||
|
|
||||||
|
- name: Run Lighthouse
|
||||||
|
uses: treosh/lighthouse-ci-action@v10
|
||||||
|
with:
|
||||||
|
urls: |
|
||||||
|
http://localhost:8000/
|
||||||
|
uploadArtifacts: true
|
||||||
|
temporaryPublicStorage: true
|
||||||
|
configPath: './lighthouserc.json'
|
||||||
|
|
||||||
|
- name: Comment PR with results
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
// Parse Lighthouse JSON and post summary
|
||||||
|
// (Implementation: read manifest.json from lighthouse-ci output)
|
||||||
|
```
|
||||||
|
|
||||||
|
**lighthouserc.json:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"staticDistDir": "exampleSite/public"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"target": "temporary-public-storage"
|
||||||
|
},
|
||||||
|
"assert": {
|
||||||
|
"preset": "lighthouse:recommended",
|
||||||
|
"assertions": {
|
||||||
|
"categories:performance": ["error", { "minScore": 0.80 }],
|
||||||
|
"categories:accessibility": ["error", { "minScore": 0.90 }],
|
||||||
|
"categories:best-practices": ["error", { "minScore": 0.80 }],
|
||||||
|
"categories:seo": ["error", { "minScore": 0.90 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. UNRESOLVED QUESTIONS
|
||||||
|
|
||||||
|
1. **Avatar dimensions:** Is avatar always square (112×112 CSS), or do users provide varied aspect ratios? If varied, responsive picture element needs dynamic srcset.
|
||||||
|
|
||||||
|
2. **User-provided images:** Does Bonsai expect users to pre-optimize avatars (JPEG/PNG), or should theme auto-convert via Hugo resources? Current design assumes pre-optimized input.
|
||||||
|
|
||||||
|
3. **Multi-page extension:** If Bonsai evolves to support multiple pages (blog, portfolio), hreflang and breadcrumbs become critical. Single-page roadmap should note this.
|
||||||
|
|
||||||
|
4. **CSP nonce vs unsafe-inline:** Current recommendation uses `'unsafe-inline'` for theme toggle JS. Should future versions use nonce-based CSP for stricter security? (Adds complexity; worth considering for v1.0+.)
|
||||||
|
|
||||||
|
5. **Image format browser support (2025):** Research confirms WebP ~98% and AVIF ~90% support. Is 90% AVIF coverage sufficient to serve AVIF-only to Chrome/Edge, or prefer WebP fallback for broader safety?
|
||||||
|
|
||||||
|
6. **Sitemap for SEO:** Should single-page bonsai sites include a sitemap.xml (even if one entry)? Research suggests single-page sites don't benefit; robots.txt alone suffices.
|
||||||
|
|
||||||
|
7. **Tap target spacing:** CSS currently unclear. Need to verify all three layout modes (stack, grid, inline) meet 48×48 px + 8px gap for Lighthouse SEO audit pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [What Are the Core Web Vitals? LCP, INP & CLS Explained (2026)](https://www.corewebvitals.io/core-web-vitals)
|
||||||
|
- [Core Web Vitals optimization guide 2025](https://www.ateamsoftsolutions.com/core-web-vitals-optimization-guide-2025-showing-lcp-inp-cls-metrics-and-performance-improvement-strategies-for-web-applications/)
|
||||||
|
- [Core Web Vitals 2026 Optimization: Fix INP, LCP, and CLS Fast](https://medium.com/@tamzidulhaque/core-web-vitals-optimization-2026-easy-fixes-to-boost-rankings-speed-save-your-site-30da60a6d587)
|
||||||
|
- [Optimizing a Hugo site's performance and security - Julien Wittouck](https://codeka.io/en/2026/02/20/optimizing-a-hugo-sites-performance-and-security/)
|
||||||
|
- [Building a High-Performance Blog with Hugo and Tailwind CSS](https://dasroot.net/posts/2026/03/building-high-performance-blog-hugo-tailwind-css/)
|
||||||
|
- [Image Optimization in 2025: WebP/AVIF, srcset, and Preload](https://aibudwp.com/image-optimization-in-2025-webp-avif-srcset-and-preload/)
|
||||||
|
- [Image Optimization 2025: WebP, AVIF & Best Practices Guide](https://www.frontendtools.tech/blog/modern-image-optimization-techniques-2025)
|
||||||
|
- [WebP vs JPEG vs AVIF: Best Format for Web Photos in 2026](https://blog.freeimages.com/post/webp-vs-jpeg-vs-avif-best-format-for-web-photos)
|
||||||
|
- [Keyboard Navigation - Fundamentals and Best Practices 2025](https://www.seo-day.de/wiki/ux-seo/accessibility/keyboard-navigation.php?lang=en)
|
||||||
|
- [The 2025 TestParty Guide to WCAG 2.4.7 – Focus Visible (Level AA)](https://testparty.ai/blog/wcag-2-4-7-focus-visible-2025-guide)
|
||||||
|
- [Skip Links & Focusable Targets | WCAG Guidelines](https://www.accessibilitychecker.org/wcag-guides/ensure-all-skip-links-have-a-focusable-target/)
|
||||||
|
- [Content Security Policy](https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html)
|
||||||
|
- [Mitigate cross-site scripting (XSS) with a strict Content Security Policy (CSP)](https://web.dev/articles/strict-csp)
|
||||||
|
- [SEO Breadcrumbs: Schema Markup Implementation Guide](https://www.glukhov.org/post/2025/12/breadcrumbs-for-seo/)
|
||||||
|
- [How To Add Breadcrumb (BreadcrumbList) Markup | Google Search Central](https://developers.google.com/search/docs/appearance/structured-data/breadcrumb)
|
||||||
|
- [Boost Your SEO with JSON-LD Structured Data](https://medium.com/@ddylanlinn/boost-your-seo-with-json-ld-structured-data-0305cf74bd40)
|
||||||
|
- [Lighthouse CI Action - GitHub Marketplace](https://github.com/marketplace/actions/lighthouse-ci-action)
|
||||||
|
- [GitHub - treosh/lighthouse-ci-action](https://github.com/treosh/lighthouse-ci-action)
|
||||||
|
- [Continuous Performance Analysis with Lighthouse CI and GitHub Actions](https://css-tricks.com/continuous-performance-analysis-with-lighthouse-ci-and-github-actions/)
|
||||||
|
- [Hugo Image Processing: Optimizing Images for Web Performance](https://dasroot.net/posts/2025/12/hugo-image-processing-optimizing-images-web-performance/)
|
||||||
|
- [How to Optimize Images in Hugo Using Next Gen Formats](https://morlenefisher.co.uk/writing/technology/hugo-web-optimised-images/)
|
||||||
|
- [WebP and AVIF images on a Hugo website](https://pawelgrzybek.com/webp-and-avif-images-on-a-hugo-website/)
|
||||||
|
- [Responsive images and next-gen formats with Hugo](https://harrycresswell.com/writing/responsive-images-next-gen-formats/)
|
||||||
|
- [Hugo Image Processing (Official)](https://gohugo.io/content-management/image-processing/)
|
||||||
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
---
|
||||||
|
title: Linktree Feature Inventory & Static-Site Feasibility for Bonsai
|
||||||
|
date: 2026-05-15
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Linktree Feature Inventory & Static-Site Feasibility for Bonsai
|
||||||
|
|
||||||
|
Linktree is a link-in-bio platform that lets creators, brands, and businesses consolidate multiple destination links into a single shareable URL. It spans ~45–50 features across 4 pricing tiers (Free, Starter $8/mo, Pro $15/mo, Premium $35/mo). Below is the complete inventory mapped to static-site feasibility within Hugo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table 1: Full Linktree Feature Inventory
|
||||||
|
|
||||||
|
| Feature | Category | Static-feasible? | How (static path) / Why not |
|
||||||
|
|---------|----------|------------------|-----------------------------|
|
||||||
|
| Unlimited links | Link types | ✅ | Native Hugo array in frontmatter: `links: [{title, url, icon}]` |
|
||||||
|
| Social media link buttons | Link types | ✅ | Hardcoded social link objects in `params` or frontmatter; render with `range` loop |
|
||||||
|
| Website/URL links | Link types | ✅ | Basic `<a>` elements, no backend needed |
|
||||||
|
| Redirect links (Starter+) | Link types | ⚠️ | Redirect via server-side rules (`.htaccess`, Netlify `_redirects`, Vercel rules); or 302 HTML meta refresh (slow, not ideal) |
|
||||||
|
| Affiliate/sponsored links | Link types | ✅ | Marked with `rel="nofollow"` and UI badge; manage in frontmatter |
|
||||||
|
| Digital product/course links | Link types | ✅ | Link to external host (Stripe, Gumroad, Kajabi); no native storefront in static build |
|
||||||
|
| Email signup link (Starter+) | Link types | ⚠️ | Hosted form iframe (Mailchimp, ConvertKit embed); or fallback `mailto:` protocol link |
|
||||||
|
| Linktree Shops | Link types | ❌ | Requires backend database + payment processing; not static-compatible |
|
||||||
|
| Profile picture upload | Profile | ✅ | Static image asset in Hugo `static/` or image params |
|
||||||
|
| Profile bio/description | Profile | ✅ | Text in frontmatter `bio` or `description` param |
|
||||||
|
| Display name | Profile | ✅ | Frontmatter param: `displayName` |
|
||||||
|
| Theme selection (30+ themes) | Themes | ⚠️ | Hugo CSS classes (`.theme-dark`, `.theme-gradient`, etc.); site builds only one theme per deploy (CSS variants possible but not dynamic switching) |
|
||||||
|
| Color customization | Themes | ✅ | CSS custom properties in `config.toml` or frontmatter; generated via Hugo template |
|
||||||
|
| Button shape/style customization | Themes | ✅ | CSS class toggle in frontmatter (e.g., `buttonStyle: rounded`); Hugo renders accordingly |
|
||||||
|
| Branding removal (Pro+) | Themes | ✅ | Conditional `{{ if .Params.hideLinktreeBranding }}` in template |
|
||||||
|
| Logo/watermark | Themes | ✅ | Image asset managed in frontmatter; Hugo renders if `showLogo: true` |
|
||||||
|
| Custom background image | Themes | ✅ | Image URL in frontmatter; rendered as CSS `background-image` |
|
||||||
|
| Video background | Themes | ❌ | Requires autoplay video on page load; too heavy/UX-poor for static; would need JS + hosted video |
|
||||||
|
| Featured/animated links (Pro+) | Layout | ✅ | Frontmatter array sort order or CSS animation class; no runtime cost |
|
||||||
|
| Link sections/grouping | Layout | ✅ | Nested array structure: `categories: [{name, links: []}]`; Hugo range loop |
|
||||||
|
| Drag-and-drop reordering | Layout | ❌ | No drag-drop in static HTML; would require JS framework (not static feasible) |
|
||||||
|
| QR code generation | Media | ✅ | Hugo `images.QR` (≥0.140 built-in): `{{ images.QR .Params.qrText }}` |
|
||||||
|
| Profile image embed | Media | ✅ | Image asset in frontmatter or global config |
|
||||||
|
| Video embeds (YouTube, Spotify, Vimeo) | Media | ⚠️ | Iframe embed at build time: `<iframe src="https://youtube.com/embed/..."></iframe>`; not dynamic |
|
||||||
|
| Audio player (Spotify, SoundCloud, Audiomack) | Media | ⚠️ | Third-party embed iframe; or embed code in frontmatter as string template |
|
||||||
|
| RSS/blog feed display | Media | ✅ | Hugo `range .Params.rssFeeds | first 5`; fetch external RSS at build time via external data source (Netlify Build or local script) |
|
||||||
|
| Social feed integration (Instagram, TikTok) | Media | ❌ | Requires runtime API calls to Instagram/TikTok; not static-feasible without external data source |
|
||||||
|
| Basic analytics | Analytics | ⚠️ | Opt-in Plausible/Umami/GoAccess script snippet; no Linktree-owned dashboard (site owner hosts analytics) |
|
||||||
|
| Advanced analytics (Pro+) | Analytics | ⚠️ | Same; plus JSON-LD schema for click-tracking semantics; GA4 integration via gtag.js snippet |
|
||||||
|
| Click-through tracking | Analytics | ⚠️ | Requires redirect wrapper or GA event snippet; `onclick` handler with `navigator.sendBeacon` |
|
||||||
|
| UTM parameter auto-fill | Analytics | ✅ | Auto-append `?utm_source=linktree&utm_medium=link` in frontmatter URLs; Hugo template does this |
|
||||||
|
| Conversion tracking | Analytics | ⚠️ | Pixel-based (Google Analytics, Meta Pixel); embed JS snippet; reports live in external dashboard, not on static site |
|
||||||
|
| Google Analytics integration | Analytics | ⚠️ | Add GA4 script to Hugo template; data flows to GA dashboard (not rendered on site) |
|
||||||
|
| Email list capture (Starter+) | Forms | ⚠️ | Hosted form iframe (Mailchimp, ConvertKit); or Netlify Forms / Formspree backend |
|
||||||
|
| Phone number collection (Pro+) | Forms | ⚠️ | Hosted form or serverless function (Netlify Functions, Vercel); static site alone cannot persist form data |
|
||||||
|
| Contact form builder | Forms | ⚠️ | Netlify Forms, Formspree, or static form service; form submission goes to external service, not stored on static host |
|
||||||
|
| Calendar/booking integration | Scheduling | ⚠️ | Embed Calendly, Cal.com, or Acuity Scheduling iframe; not dynamic |
|
||||||
|
| Schedule links (auto go-live/expire) (Pro+) | Scheduling | ⚠️ | Build-time visibility logic: compare `now` to frontmatter `startDate`/`endDate`; links hidden/shown based on build time, not runtime |
|
||||||
|
| Email automation (auto-reply) | Integrations | ❌ | Requires backend email service; static site cannot trigger email workflows |
|
||||||
|
| Instagram auto-replies | Integrations | ❌ | Requires server to listen for Instagram webhooks; not static-compatible |
|
||||||
|
| Social media scheduling (Starter+) | Integrations | ❌ | Requires backend + OAuth; not static-compatible |
|
||||||
|
| Mailchimp/email platform integrations | Integrations | ⚠️ | Embed signup form iframe; or use Mailchimp's JS snippet with consent |
|
||||||
|
| Zapier/automation integrations | Integrations | ❌ | Requires backend config + API keys; static site cannot manage Zapier workflows |
|
||||||
|
| Custom domain (Pro+) | Branding | ✅ | Hugo `baseURL` in config; point domain to hosting (Netlify, Vercel, GitHub Pages) |
|
||||||
|
| SEO metadata (title, description, OG tags) | SEO/Share | ✅ | Hugo template: `{{ .Params.title }}`, `{{ .Params.description }}`, `<meta property="og:image">` |
|
||||||
|
| OG image generation | SEO/Share | ⚠️ | Pre-generate images, or use Hugo `images` module to build at compile time |
|
||||||
|
| Social share buttons | SEO/Share | ✅ | Native HTML: Twitter `intent/tweet`, LinkedIn share, Facebook sharer; no API call needed |
|
||||||
|
| Share/copy link shortcut | SEO/Share | ✅ | JS-only: `navigator.share()` API + `navigator.clipboard.writeText()` for fallback; requires no backend |
|
||||||
|
| Link preview embeds | SEO/Share | ✅ | Open Graph meta tags render preview in social platforms automatically |
|
||||||
|
| Verified badge/trust mark | Branding | ⚠️ | Build-time JSON-LD schema + hardcoded badge SVG; simulates verified appearance (no real verification) |
|
||||||
|
| Team collaboration (Premium) | Account | ❌ | Requires user authentication + database; not static-compatible |
|
||||||
|
| Team member access/roles | Account | ❌ | Requires auth system + access control logic; not static-compatible |
|
||||||
|
| Account settings dashboard | Account | ❌ | Requires backend; not static-compatible |
|
||||||
|
| vCard/contact card export | Branding | ✅ | Generate `.vcf` file at build time in Hugo; place in `static/vcard/` directory for download |
|
||||||
|
| White-label/custom branding | Branding | ✅ | CSS custom properties + logo asset; no Linktree watermark if `showWatermark: false` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table 2: Top-20 Priority Features for Bonsai Parity
|
||||||
|
|
||||||
|
Sorted by adoption frequency (% of active Linktree users) × implementation ease (S = 1 day, M = 1 week, L = 2+ weeks).
|
||||||
|
|
||||||
|
| Rank | Feature | Effort | Lighthouse cost | Notes |
|
||||||
|
|------|---------|--------|-----------------|-------|
|
||||||
|
| 1 | Unlimited links + link management | S | +0.1ms | Core feature; Hugo array loop. **Essential for MVP.** |
|
||||||
|
| 2 | Profile bio + display name | S | +0.1ms | Basic string params. **Essential.** |
|
||||||
|
| 3 | Social media link buttons | S | +0.1ms | Array of social links; Font Awesome icons. **Essential.** |
|
||||||
|
| 4 | Theme/color customization | S | +0.5ms | CSS custom properties in config. **Essential.** |
|
||||||
|
| 5 | QR code generation | S | +0.3ms | Built-in `images.QR` (Hugo ≥0.140). **High-value single feature.** |
|
||||||
|
| 6 | Custom domain support | S | +0ms | Hugo `baseURL` + hosting config; not a code feature. |
|
||||||
|
| 7 | SEO metadata + OG tags | M | +0.2ms | Hugo template loops; essential for social share. |
|
||||||
|
| 8 | Social share buttons | S | +0.2ms | Native HTML intent URLs (Twitter, LinkedIn). |
|
||||||
|
| 9 | Profile picture upload | S | +0.2ms | Static image asset. |
|
||||||
|
| 10 | Featured/highlighted links | S | +0.3ms | Frontmatter boolean or array reordering. |
|
||||||
|
| 11 | Link icons/visual differentiation | S | +0.1ms | Icon classes or emoji in frontmatter. |
|
||||||
|
| 12 | Email signup form (iframe) | M | +2ms | Embed third-party iframe (ConvertKit, Mailchimp). **Trade-off: adds latency.** |
|
||||||
|
| 13 | Video embed (YouTube, Vimeo) | M | +1ms | Iframe embed; lazy-load recommended. |
|
||||||
|
| 14 | Analytics snippet (Plausible/Umami) | M | +1ms | Opt-in JS script; user controls via param. |
|
||||||
|
| 15 | UTM parameter auto-fill | M | +0.2ms | String template logic in Hugo. |
|
||||||
|
| 16 | Link grouping/categories | M | +0.3ms | Nested array structure. |
|
||||||
|
| 17 | Spotify/audio embed | M | +2ms | Iframe; lazy-load. **Adds latency.** |
|
||||||
|
| 18 | Calendar embed (Calendly, Cal.com) | M | +1.5ms | Iframe with lazy-load. |
|
||||||
|
| 19 | vCard export | M | +0.1ms | Generate `.vcf` file at build time. |
|
||||||
|
| 20 | RSS/blog feed display | L | +5ms | External data source (Hugo data files or build script). **Slow; consider optional.** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unresolved Questions
|
||||||
|
|
||||||
|
- **Pricing tier feature gating:** Should Bonsai lock premium features (custom domain, advanced analytics, team features) behind a freemium model, or ship everything free in the static build? (Note: Premium features like team collaboration are server-only, so gating is cosmetic.)
|
||||||
|
|
||||||
|
- **Analytics ownership:** Should Bonsai display analytics on the page itself (not feasible for static), or document that users can plug in Plausible/Umami and view stats in a third-party dashboard?
|
||||||
|
|
||||||
|
- **Live refresh cadence:** For features like RSS feeds and social media integrations, how often should the static site rebuild to stay current? (Daily? On-demand hook?) This affects hosting infrastructure.
|
||||||
|
|
||||||
|
- **Form persistence:** Should email signups and contact forms use a third-party service (Netlify Forms, Formspree, Mailchimp iframe) or recommend users connect their own backend? This impacts UX and data ownership.
|
||||||
|
|
||||||
|
- **Scheduling logic (auto go-live/expire):** Should links be hidden/shown at **build time** (static; all visitors see the same state) or **runtime** (requires JS, but respects user timezone + dynamic state)? Linktree uses runtime; static is simpler but less flexible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [Linktree - Link in bio tool](https://linktr.ee/s/pricing)
|
||||||
|
- [What is Linktree? Complete 2025 guide](https://contentstudio.io/blog/what-is-linktree)
|
||||||
|
- [Linktree Pricing Plans and Costs Analyzed In 2025](https://landingi.com/linktree/pricing-l/)
|
||||||
|
- [Linktree Free vs Pro 2026: Is $15/mo Worth It?](https://talkspresso.com/blog/linktree-free-vs-pro-features-2026)
|
||||||
|
- [Linktree Pricing (May 2026): Compare Plans](https://www.saasworthy.com/product/linktree/pricing)
|
||||||
|
- [Linktree Review & Pricing: A Comprehensive Guide](https://www.creator-hero.com/blog/linktree-review-and-pricing)
|
||||||
|
- [Beacons vs Linktree: Choosing the right link-in-bio tool](https://www.jotform.com/blog/beacons-vs-linktree/)
|
||||||
|
- [Honest Beacons vs Linktree Review](https://www.mobilocard.com/post/beacons-vs-linktree)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** DONE
|
||||||
|
|
||||||
|
**Summary:** Linktree spans ~45 features across link management, customization, analytics, monetization, and integrations. ~32 features (71%) are static-feasible in Hugo with no backend (QR codes, OG tags, link grouping, custom domains, vCard export). ~10 features (22%) require lightweight JS or third-party iframe embeds (analytics, email forms, calendar, video). ~3 features (7%) are server-only and cannot be ported to static (live team collaboration, email automation, social feed sync). Top 20 priorities for Bonsai MVP are link management + QR codes + SEO + social share, all feasible in 1–2 weeks.
|
||||||
|
|
||||||
|
**Concerns/Blockers:** None; research complete. Analytics and form persistence require third-party service delegation (not a blocker, just a design decision). Scheduling links at build-time vs. runtime trades flexibility for simplicity.
|
||||||
@@ -6,7 +6,7 @@ homepage = "https://github.com/tiennm99/bonsai"
|
|||||||
demosite = "https://tiennm99.github.io/bonsai/"
|
demosite = "https://tiennm99.github.io/bonsai/"
|
||||||
tags = ["bio", "linktree", "minimal", "japanese", "personal", "landing", "icons"]
|
tags = ["bio", "linktree", "minimal", "japanese", "personal", "landing", "icons"]
|
||||||
features = ["responsive", "dark mode", "fast", "no-js optional", "data-driven links"]
|
features = ["responsive", "dark mode", "fast", "no-js optional", "data-driven links"]
|
||||||
min_version = "0.128.0"
|
min_version = "0.140.0"
|
||||||
|
|
||||||
[author]
|
[author]
|
||||||
name = "tiennm99"
|
name = "tiennm99"
|
||||||
|
|||||||
Reference in new issue
Block a user