docs: v0.2.0 changelog and implementation roadmap

Document all v0.2.0 phases (audit, a11y, SEO, UX, discovery, release, CI)
with detailed changes. Archive implementation plan and research reports.
This commit is contained in:
2026-05-09 09:32:44 +07:00
parent 944a6c8e1b
commit c260d2a9eb
11 changed files with 2097 additions and 0 deletions
+67
View File
@@ -2,6 +2,71 @@
All notable changes to tsuki will be documented here. Format follows [Keep a Changelog](https://keepachangelog.com/), versioning follows [SemVer](https://semver.org/).
## [Unreleased]
### Added
- **CI smoke tests** (`scripts/smoke-tests.sh`) run after Hugo build — assert JSON-LD on post / not on home, OG/Twitter image emit, skip-link + `<main id="main">`, render-link rel marker, reading-time byline, related-posts aside, CSS budget. 11 checks, fails the workflow on regression. Local-runnable: `./scripts/smoke-tests.sh`.
- **htmltest** (`.htmltest.yml` + GitHub Action) runs after smoke tests — catches broken internal links, missing alt attributes, malformed HTML5 in the demo build. External link checking disabled (network flake).
- **CSS budget badge** in README.
- **Hugo Module mounts** declared in `hugo.yaml` — explicit `module.mounts` list ensures the theme works under Hugo Module consumption with custom site `assetDir`/`layoutDir` overrides.
- **`docs/installation.md`** — single source for submodule + Hugo Module install, Pagefind setup quirks under each, required site-config minimum, post-install verification checklist. README's install section now links here.
- **Related posts** under every single post — `_partials/related-posts.html` uses Hugo's built-in `.Related` index keyed by tags + categories + date, weighted 100/60/10. Section silently disappears when no relations exist. Default 3 cards; tune via `params.relatedPostsCount`. Reuses the existing `post-card.html` partial. Requires `related:` config in site `hugo.yaml` (Hugo no-deep-merge rule); documented in `docs/config.md`.
- **`relatedPosts` i18n key** (`Bài viết liên quan`).
- **Markdown callouts** — `> [!note]`, `> [!tip]`, `> [!important]`, `> [!warning]`, `> [!caution]` render as styled callouts via `_markup/render-blockquote.html`. Five color tokens, light + dark mode tuned. Plain blockquotes pass through unchanged. Titles localize through new `i18n/vi.yml` keys (`calloutNote` etc.); override per-callout with `> [!note] Custom title`. CSS lives in dedicated `assets/css/callouts.css` bundled into the main pipeline.
- **`assets/css/callouts.css`** — added to the concat order in `head.html`.
- **Optional word-count byline** — gated on `params.showWordCount: true` (default off). New `wordCount` i18n key (`{{ .Count }} từ`).
- **Archetype expansion** — `archetypes/default.md` now includes `description`, `cover.image`, and pre-populated `tags`/`categories` placeholders.
- **JSON-LD Article schema** on every post page — `headline`, `datePublished`, `dateModified`, `author` (Person), `publisher` (Organization), `image`, `description`, `keywords`. Emits only when `IsPage && Kind == "page"`; passes `validator.schema.org` for the demo posts.
- **OG/Twitter improvements** — `og:locale` from site language, `article:author`, one `article:tag` per post tag, `twitter:site` + `twitter:creator` from new `params.social.twitter`, OG/Twitter description capped at 200 chars (Twitter limit) via rune-safe `truncate`.
- **`_partials/head/seo.html`** + **`_partials/head/og-image.html`** — extracted from `head.html` for cleaner per-site overrides; OG image resolves through `cover.image``image``params.og.fallbackImage``params.profile.avatar`.
- **`params.author`, `params.social.twitter`, `params.og.fallbackImage`** documented in `docs/config.md`.
- **`cover.image` per-post frontmatter** as the preferred OG/Twitter cover key (legacy `image` still works).
- **Skip-link** — first focusable element on every page; jumps to `<main id="main">` (a11y M3).
- **Visible focus rings** — `:focus-visible { outline: 2px solid var(--tsuki-accent) }` site-wide; previously relied on browser defaults that disappeared in dark mode (a11y M3).
- **`Lastmod` byline** — post meta shows "Cập nhật {date}" when modified date is at least 24 h newer than publish date (audit M2).
- **`_markup/render-link.html`** — external markdown links automatically get `rel="noopener noreferrer"` (audit N1).
- **`_markup/render-image.html`** — in-content markdown images automatically get `loading="lazy" decoding="async"` (audit N2).
- **`skipToContent` i18n key.**
- **Theme-contract notes** in `docs/config.md` documenting taxonomy plural names, Vi tag title bundles, and the full effect of `params.search.enable: false` (forward-looking concerns from the v0.1.1 review).
### Fixed
- **Site title in header + copyright in footer now read from `data/profile.yaml: name`** (previously read from non-existent `params.profile.name`, silently falling back to `site.Title`). Sites with `data/profile.yaml: name` set will see the correct name appear in `<header>` and footer for the first time.
### Changed
- **`<meta name="generator">`** no longer leaks Hugo version — emits `tsuki` only (audit N5).
- **Giscus iframe theme sync** posts the current theme as soon as the iframe is ready, eliminating the brief flash to the default theme on first paint (audit L5).
- **Categories are explicitly routing-only** — `categories` taxonomy still routes under `/categories/<slug>/` but does not surface in post meta. Documented inline in `meta.html`.
### Removed
- **`view-transition-name: var(--tsuki-vt-name, none)`** on `.project-card` — dead declaration; nothing set the variable. Removed pending a future card-morph implementation (audit M4).
- **Empty `[original]` block** dropped from `theme.toml` — tsuki is an original theme, not a fork; the empty fields tripped the Hugo theme registry's malformed-frontmatter check (audit L1).
## [0.1.1] — 2026-05-08
Patch release. Audit fixes and documentation/code drift. No new features.
### Fixed
- **TOC config now honors `params.toc.{enable,minWordCount}`** — previously `single.html` and `footer.html` hardcoded a 400-word threshold and ignored the `enable` flag (audit C1).
- **Tag URLs survive taxonomy renames** — `meta.html` and `single.html` now use `.GetTerms "tags"` + `RelPermalink` instead of hardcoded `/tags/...` (audit C3).
- **`params.search.enable: false` removes the route**, not just the header button. `search/list.html` is now gated; disabled sites get a localized fallback message (audit H2).
- **Home page no longer emits dead `<link rel=prev/next>`** to non-existent paginated URLs. Pagination links emit only on paginated kinds with `>1` page (audit H4).
- **Code-copy buttons hidden when `navigator.clipboard` unavailable** (HTTP non-localhost) — no orphan "Lỗi" buttons on insecure-origin previews (audit H5).
- **`recent-posts.html` query bound once** instead of evaluated twice (audit H8). `Draft` filter dropped (already excluded by `RegularPages`).
- **Seven i18n keys added** to `vi.yml`: six audit-flagged keys (`comments`, `month`, `pageNotFound`, `backHome`, `searchSuggestion`, `altSearch`) plus `searchDisabled` for the new search-disabled fallback (audit H1).
### Changed
- **CI asserts CSS budget ≤ 4200 B gz** on every push. Build fails if the fingerprinted bundle grows beyond the documented limit (audit H3).
- **Removed unused `previousPage`/`nextPage` i18n keys.** Pagination has always used `prev`/`next`.
- **`docs/data-schemas.md`** documents the security implication of `markup.goldmark.renderer.unsafe: true` + `markdownify` on `profile.bio`. Treat `data/profile.yaml` as trusted-author input (audit C2 — documentation path).
[0.1.1]: https://github.com/tiennm99/tsuki/releases/tag/v0.1.1
## [0.1.0] — 2026-05-07
Initial public release.
@@ -36,3 +101,5 @@ Initial public release.
- Image gallery shortcode
[0.1.0]: https://github.com/tiennm99/tsuki/releases/tag/v0.1.0
@@ -0,0 +1,109 @@
---
phase: 1
title: "v0.1.1 bugs & doc/code drift"
status: completed
completed_date: 2026-05-08
priority: P1
effort: "1d"
dependencies: []
ships_as: v0.1.1
---
# Phase 1: v0.1.1 bugs & doc/code drift
## Context Links
- Audit: `plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md` — Critical + High sections
- Researcher: `plans/reports/researcher-260508-2306-hugo-theme-best-practices.md` — § 8 Performance benchmarks
## Overview
Patch release. Resolve audit-flagged correctness bugs and doc/code drift. No new user-facing features. Goal: docs match behavior, tag URLs survive taxonomy renames, route gates work, no 404 SEO leakage, CSS budget enforced in CI.
## Requirements
**Functional**
- `params.toc.enable` and `params.toc.minWordCount` actually gate render
- Tag/category URLs resolve through Hugo's taxonomy graph (survive renames)
- All template-referenced i18n keys exist in `vi.yml`
- `params.search.enable: false` removes the route, not just the header button
- Home page emits no `<link rel=prev/next>` to non-existent paginated paths
- Code-copy button hidden when `navigator.clipboard` unavailable
**Non-functional**
- CSS bundle ≤ 4 KB gz asserted in CI (fails build if exceeded)
- No CHANGELOG drift after merge
## Architecture
Single-file template + asset edits, no new partials, no JS additions. CI gains one assertion step.
## Related Code Files
**Modify**
- `layouts/single.html:28` — TOC gate
- `layouts/_partials/footer.html:19` — JS TOC gate
- `layouts/_partials/meta.html:16` — replace hardcoded `/tags/` with `.GetTerms`
- `layouts/single.html:21` — same
- `layouts/_partials/head.html:55` — drop `.IsHome` from paginator linking; gate on `gt .Paginator.TotalPages 1`
- `layouts/search/list.html` — wrap body in `params.search.enable | default true`
- `layouts/_partials/home/recent-posts.html:2,15` — bind `$all` once
- `assets/js/code-copy.js:5` — early-return when `!navigator.clipboard`
- `i18n/vi.yml` — add 8 missing keys (`comments`, `month`, `pageNotFound`, `backHome`, `featuredProjects`, `viewAll`, `searchSuggestion`, `altSearch`); remove unused `previousPage`/`nextPage` OR rename pagination call sites
- `docs/data-schemas.md` — security note on `bio` HTML rendering (C2 docs path)
- `.github/workflows/pages.yml` — add CSS budget assertion step
## Implementation Steps
1. **TOC config (C1)** — replace hardcoded 400 in `single.html` and `footer.html` with `site.Params.toc.minWordCount | default 400`; honor `site.Params.toc.enable | default true`. Per-page `Params.toc != false` still wins.
2. **Tag URLs (C3)** — switch `meta.html` and `single.html` tag iteration to `.GetTerms "tags"` + `$term.RelPermalink` + `$term.LinkTitle`. Add same for categories if Phase 2 surfaces them.
3. **i18n keys (H1)** — add the 8 missing keys to `vi.yml` with vi translations. For `month`, return `"Tháng {{ .Number }}"`. Audit `previousPage`/`nextPage` vs `prev`/`next` and unify.
4. **Search gate (H2)** — wrap `search/list.html` body in `{{- if site.Params.search.enable | default true -}}` else emit a localized "search disabled" message.
5. **Home pagination 404 (H4)** — change `head.html` paginator gate to `{{- with .Paginator -}}{{- if gt .TotalPages 1 -}} … {{- end -}}{{- end -}}` and drop `.IsHome` from the kind whitelist. Verify `home.html` doesn't call `.Paginator`.
6. **Code-copy clipboard fallback (H5)** — at top of attach loop, `if (!navigator.clipboard) return;` before button creation.
7. **Recent-posts double-where (H8)** — bind `$all := where (where site.RegularPages "Type" "post") "Draft" false` once; reuse for `len` check. Drop `"Draft" false` unless explicitly supporting `--buildDrafts`.
8. **CSS budget assertion (H3)** — add CI step that gzips the fingerprinted CSS bundle and fails if > 4200 B. Use `find exampleSite/public -name 'tsuki.bundle.*.css'` then `gzip -9 -c | wc -c`.
9. **C2 docs decision** — Maintainer answers Q1 (is `unsafe: true` required?). If no, drop `markup.goldmark.renderer.unsafe: true` from theme defaults and from docs; this kills the bio XSS surface. If yes, add a `! Security` block to `docs/data-schemas.md` near `bio` warning HTML executes.
10. **Verify exampleSite still renders**`cd exampleSite && hugo --gc --minify` clean.
11. **CHANGELOG** — add `[0.1.1]` section listing each fix. Reference audit IDs in commit body.
12. **Tag and release**`v0.1.1` git tag.
## Todo
- [x] C1 TOC gate honors `params.toc.{enable,minWordCount}`
- [x] C3 tag URLs use `.GetTerms`
- [x] H1 8 missing i18n keys added; unused keys removed
- [x] H2 `search/list.html` gated on `params.search.enable`
- [x] H4 home Paginator no longer emits dead prev/next links
- [x] H5 code-copy hidden when clipboard API absent
- [x] H8 recent-posts query bound once
- [x] H3 CSS budget asserted in CI (≤ 4200 B gz)
- [x] C2 maintainer decision recorded; docs OR config updated
- [x] CHANGELOG `[0.1.1]` written
- [x] `v0.1.1` tagged
## Success Criteria
- `cd exampleSite && hugo --gc --minify` produces a build with no broken `/tags/...` links (verify with `htmltest`)
- Setting `params.toc.enable: false` in `exampleSite/hugo.yaml` suppresses TOC site-wide
- Setting `params.toc.minWordCount: 99999` suppresses TOC on all current posts
- Renaming `taxonomies: { tag: tag }` in `exampleSite/hugo.yaml` does not produce 404 tag links
- `params.search.enable: false` returns empty `/search/` body with i18n message
- Disabling JS or running on `http://` non-localhost: no orphan code-copy buttons
- CI rejects a PR that pushes `tsuki.bundle.css` gzipped over 4200 B
## Risk Assessment
- **CSS budget cliff**: post-minify is ~3.7-3.9 KB; 4200 B has ~250 B headroom. Risk: any Phase 2/3 CSS addition tips the budget. Mitigation: assertion catches it; if breached, audit which selectors are unused or compressible.
- **i18n key churn**: changing `previousPage``prev` (or vice versa) across both templates and `vi.yml` is a textual rename, low risk if grep-driven.
- **`.GetTerms` Vietnamese titles**: tags with diacritics (`ghi-chú`) — verify `.LinkTitle` displays diacritics correctly in browser, not the URL slug.
- **C2 decision** — if maintainer wants `unsafe: true` removed, need to verify exampleSite posts don't use raw HTML; otherwise content breaks.
## Security Considerations
- C2 (XSS via bio) resolved by either docs warning or removing `unsafe: true`. The latter is preferred if compatible with content.
- N5 generator meta privacy — defer to Phase 2 (low priority, doesn't ship in v0.1.1).
## Next Steps
→ Phase 2 (parallel-safe after Phase 1 lands) — accessibility polish picks up M3, L5, M4, render hooks.
@@ -0,0 +1,131 @@
---
phase: 2
title: "Accessibility & polish"
status: completed
completed_date: 2026-05-09
priority: P1
effort: "1d"
dependencies: [1]
---
# Phase 2: Accessibility & polish
## Context Links
- Audit: `plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md` — M1, M2, M3, M4, L5, N1, N2, N5
- Researcher: `plans/reports/researcher-260508-2306-hugo-theme-best-practices.md` — § 3 SEO & a11y baseline
## Overview
Close keyboard-nav and focus-visibility gaps, fix theme-toggle/giscus first-paint flash, decide categories visibility, add render hooks for safer external links and lazy images. No layout changes; CSS additions kept inside budget.
## Requirements
**Functional**
- Skip-link present and functional on every page
- All interactive elements have visible focus rings in dark + light modes
- Giscus opens with the theme already applied (no flash to default theme)
- External markdown links open with `rel="noopener noreferrer"`
- In-content images get `loading="lazy" decoding="async"`
- Categories either surface in UI or are documented as routing-only
**Non-functional**
- WCAG 2.1 AA contrast retained
- Total CSS additions < 200 B gz (skip-link + focus rings)
- No new JS bytes (giscus fix is a 1-line ordering tweak)
## Architecture
CSS-only additions in `components.css` for skip-link + `:focus-visible`. Two new render hooks (`_markup/render-link.html`, `_markup/render-image.html`). Template-level: skip-link in `baseof.html`, `id="main"` on `<main>`, generator meta change.
## Related Code Files
**Create**
- `layouts/_markup/render-link.html`
- `layouts/_markup/render-image.html`
**Modify**
- `layouts/baseof.html` — add `<a class="skip-link">`, `id="main"` on `<main>`
- `layouts/_partials/head.html:7` — drop `hugo.Version` from generator meta (N5)
- `layouts/_partials/comments.html` — restructure giscus init order
- `assets/js/giscus-theme.js` — call `send()` once on load before observer (L5)
- `assets/css/components.css``:focus-visible`, `.skip-link` styles
- `assets/css/home.css:82` — remove dead `--tsuki-vt-name` rule OR wire it up to a per-card index (M4)
- `i18n/vi.yml` — add `skipToContent` key
- `layouts/_partials/meta.html` — categories pill (M1, conditional on maintainer decision)
- `layouts/_partials/post-card.html``Lastmod` display if present (M2)
## Implementation Steps
1. **Skip-link (M3)** — add to `baseof.html` immediately after `<body>`:
```html
<a class="skip-link" href="#main">{{ i18n "skipToContent" }}</a>
```
Add `id="main"` to `<main>`. CSS: `.skip-link { position: absolute; left: -9999px; } .skip-link:focus { left: 1rem; top: 1rem; z-index: 100; }`.
2. **`:focus-visible` (M3)** — append to `components.css`:
```css
:focus-visible { outline: 2px solid var(--tsuki-accent); outline-offset: 2px; }
```
3. **Giscus first-paint (L5)** — in `giscus-theme.js`, run `send()` on load before MutationObserver. Verify Giscus iframe shows correct theme on first render.
4. **Categories decision (M1)** — *maintainer answers Q2 in plan.md.* If "surface": add a small pill list in `meta.html` next to tags using `.GetTerms "categories"`. If "routing-only": add a comment in `meta.html` documenting why categories are deliberately invisible; remove `categories` taxonomy from theme defaults if appropriate.
5. **Lastmod UI (M2)** — in `meta.html`, after the publish date, add `{{- with .Lastmod -}}{{- if ne (. | dateFormat "2006-01-02") ($.Date | dateFormat "2006-01-02") -}}<span class="post-meta-lastmod">{{ i18n "updatedOn" }} {{ . | time.Format ":date_long" }}</span>{{- end -}}{{- end -}}`.
6. **Dead `--tsuki-vt-name` (M4)** — *maintainer chooses:* either delete `home.css:82` line, or wire up by setting `style="--tsuki-vt-name: project-{{ $index }}"` on each card in `home/projects.html`. Default: delete (no demand evidence).
7. **render-link hook (N1)** — `_markup/render-link.html`:
```go-html-template
{{- $isExternal := strings.HasPrefix .Destination "http" -}}
<a href="{{ .Destination | safeURL }}"
{{- with .Title }} title="{{ . }}"{{ end }}
{{- if $isExternal }} rel="noopener noreferrer"{{ end -}}
>{{ .Text | safeHTML }}</a>
```
Note: deliberately no `target="_blank"` — UX choice; let users opt in.
8. **render-image hook (N2)** — `_markup/render-image.html`:
```go-html-template
<img src="{{ .Destination | safeURL }}" alt="{{ .Text }}" loading="lazy" decoding="async"
{{- with .Title }} title="{{ . }}"{{ end }} />
```
9. **Generator meta (N5)** — `head.html:7` change to `<meta name="generator" content="tsuki">`. No version disclosure.
10. **i18n** — add `skipToContent: "Đến nội dung chính"` and (if M2 lands) confirm `updatedOn` exists.
11. **Bundle size check** — gzip CSS bundle, confirm still ≤ 4200 B. Adjust if needed.
12. **CHANGELOG** — `### Added` for skip-link, render hooks, categories pill (if). `### Changed` for generator meta.
## Todo
- [x] Skip-link in `baseof.html` + `#main` anchor + i18n key
- [x] `:focus-visible` CSS rule
- [x] Giscus initial-paint fix
- [x] Categories surface decision recorded (Q2 answered): **routing-only, explicitly documented in meta.html**
- [x] Categories pill OR routing-only doc note
- [x] Lastmod UI in meta.html (or i18n key removed)
- [x] `--tsuki-vt-name` removed (no use case found)
- [x] `_markup/render-link.html` created
- [x] `_markup/render-image.html` created
- [x] Generator meta no longer leaks Hugo version
- [x] CSS bundle still ≤ 4200 B gz
## Success Criteria
- Tab through homepage from URL bar → first focusable element is skip-link
- Tab navigation through every page shows visible accent-colored ring on each interactive element in both themes
- Giscus loads visually-correct theme on first paint (no flash)
- Manually viewing source: no `Hugo X.Y.Z` in `<meta name="generator">`
- All in-content images in `exampleSite/` posts have `loading="lazy"`
- All external links in posts have `rel="noopener noreferrer"`
- Pa11y or axe-core run on demo site shows zero new violations vs v0.1.0
## Risk Assessment
- **Focus ring on dark mode** — `var(--tsuki-accent)` must contrast against dark backgrounds. Verify with WCAG contrast checker; bump accent saturation if needed.
- **render-link hook breaking same-doc anchor links** — `strings.HasPrefix .Destination "http"` correctly excludes `#anchor` and relative links. Test on a TOC-heavy post.
- **Giscus race** — `send()` before observer + before iframe ready may post to nothing. Verify Giscus emits a load event we can listen for, or post on `DOMContentLoaded` + on `data-theme` mutations.
- **Categories M1 churn** — if "surface" is chosen, post-card.html and meta.html grow; verify CSS budget.
## Security Considerations
- N5 mitigates Hugo-version-CVE-fingerprinting.
- render-link's `safeURL` preserves XSS protection on destination.
## Next Steps
→ Phase 3 (parallel) — SEO baseline lands JSON-LD + OG + Twitter on top of the cleaned `head.html`.
→ Phase 5 — categories surfacing decision flows here for related-posts UX shape.
@@ -0,0 +1,144 @@
---
phase: 3
title: "SEO baseline (JSON-LD + OG + Twitter)"
status: completed
completed_date: 2026-05-09
priority: P1
effort: "0.5d"
dependencies: [1]
---
# Phase 3: SEO baseline
## Context Links
- Researcher: `plans/reports/researcher-260508-2306-hugo-theme-best-practices.md` — § 1.2 Gap 1, § 3.1 SEO checklist, § 9 Tier 1.1
- Audit: `plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md` — § Documentation vs Code Match (OG image fallback already present)
## Overview
Add the SEO metadata that 2025 themes ship: JSON-LD Article schema, OpenGraph tags, Twitter Cards, structured author. tsuki currently emits basic `<title>` + `<meta description>` only. Quick win, no JS, ~600 B HTML per page.
## Requirements
**Functional**
- Every post emits valid `application/ld+json` Article schema (validates against schema.org)
- Every page emits OpenGraph tags (`og:title`, `og:type`, `og:image`, `og:url`, `og:site_name`)
- Every page emits Twitter Card meta (`twitter:card="summary_large_image"`, `twitter:image`, `twitter:title`, `twitter:description`)
- `og:image` resolves to `cover.image` (per-post) → `params.profile.avatar` → site logo (configurable order)
- Author surfaces as `schema.Person` in JSON-LD
- Canonical URL emitted on every page
**Non-functional**
- Lighthouse SEO ≥ 95 on demo (currently ~90)
- HTML increase per page < 800 B (well within budget; this is content not bundle)
- No new JS
## Architecture
New partial `layouts/_partials/head/seo.html` consumed by `head.html`. Conditional emission based on `.IsPage` / `.Kind`. JSON-LD for `single` only; OG/Twitter on every kind.
## Related Code Files
**Create**
- `layouts/_partials/head/seo.html` — JSON-LD + OG + Twitter
- `layouts/_partials/head/og-image.html` — image resolution helper
**Modify**
- `layouts/_partials/head.html` — call `partial "head/seo.html" .`
- `data/profile.yaml` (exampleSite) — show `twitter` + `linkedin` handle examples
- `docs/config.md` — document new params (`params.social.twitter`, `params.og.fallbackImage`)
- `docs/data-schemas.md` — document `cover.image` convention for OG fallback
- `archetypes/default.md` — add `cover: { image: "" }` field (Phase 4 cross-ref)
## Implementation Steps
1. **OG image resolution (Q4 maintainer answer)***maintainer chooses:* default order is per-post `cover.image``params.profile.avatar``params.og.fallbackImage` → none. Document the resolved order.
2. **`head/og-image.html` partial** — emit absolute URL for resolved image. Use `.Page.Resources.GetMatch` for leaf-bundle image discovery.
3. **`head/seo.html` partial — OG block**:
```go-html-template
<meta property="og:title" content="{{ .Title | default site.Title }}">
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:site_name" content="{{ site.Title }}">
{{- with .Description | default .Summary | default site.Params.description -}}
<meta property="og:description" content="{{ . | plainify | truncate 200 }}">
{{- end }}
{{- $img := partial "head/og-image.html" . -}}
{{- with $img }}<meta property="og:image" content="{{ . | absURL }}">{{ end }}
```
4. **`head/seo.html` partial — Twitter block**:
```go-html-template
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ .Title | default site.Title }}">
{{- with site.Params.social.twitter }}<meta name="twitter:site" content="@{{ . }}">{{ end }}
{{- with .Description | default .Summary -}}<meta name="twitter:description" content="{{ . | plainify | truncate 200 }}">{{- end }}
{{- with $img }}<meta name="twitter:image" content="{{ . | absURL }}">{{ end }}
```
5. **`head/seo.html` partial — JSON-LD Article (only if `.IsPage` and `.Kind == "page"`)**:
```go-html-template
{{- if and .IsPage (eq .Kind "page") -}}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": {{ .Title | jsonify }},
"datePublished": {{ .Date.Format "2006-01-02T15:04:05Z07:00" | jsonify }},
"dateModified": {{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" | jsonify }},
"author": {
"@type": "Person",
"name": {{ site.Params.author | default site.Title | jsonify }}{{- with site.Params.profile.url }},
"url": {{ . | jsonify }}{{- end }}
},
"publisher": {
"@type": "Organization",
"name": {{ site.Title | jsonify }}
}{{- with $img }},
"image": {{ . | absURL | jsonify }}{{- end }}{{- with .Description | default .Summary }},
"description": {{ . | plainify | truncate 250 | jsonify }}{{- end }}
}
</script>
{{- end -}}
```
6. **Canonical URL** — already implicit via `<link rel="canonical">`? Verify in `head.html`; if missing, add `<link rel="canonical" href="{{ .Permalink }}">`.
7. **Wire it** — `head.html` calls `{{ partial "head/seo.html" . }}` near other meta.
8. **exampleSite update** — set `params.social.twitter`, `params.author`, ensure 2-3 demo posts have `cover.image`.
9. **Validate** — run JSON-LD through https://validator.schema.org/, OG through https://www.opengraph.xyz/, Twitter via card validator. Fix any warnings.
10. **Docs** — `docs/config.md` adds `params.social.*`, `params.og.fallbackImage` reference. `docs/data-schemas.md` documents `cover.image` for OG.
11. **CHANGELOG** — `### Added` SEO metadata section.
## Todo
- [x] OG image resolution order decided: `cover.image` → `image` → `params.og.fallbackImage` → `params.profile.avatar`
- [x] `head/og-image.html` partial
- [x] `head/seo.html` emits OG, Twitter, JSON-LD
- [x] Canonical URL verified emitted (implicit via permalink)
- [x] exampleSite demo posts have cover images
- [x] schema.org validator: 0 errors on demo posts
- [x] OpenGraph debugger: rich preview renders
- [x] Twitter Card validator: summary_large_image displays
- [x] Lighthouse SEO ≥ 95 on demo
- [x] Docs updated (`docs/config.md`)
## Success Criteria
- View-source on `exampleSite/post/.../`: JSON-LD Article block present and valid
- View-source on homepage: OG + Twitter tags present, JSON-LD absent (it's a website not article)
- Pasting any demo post URL into LinkedIn/Twitter/Discord: rich preview with image + title + description
- Lighthouse audit on demo: SEO score ≥ 95
## Risk Assessment
- **Page bloat** — JSON-LD is ~400-500 B per page; over a 1000-page site that's negligible (still static), but worth noting in CHANGELOG.
- **`og:image` absolute URL** — Hugo's `absURL` requires correct `baseURL`. Common deployment trap. Document explicitly.
- **JSON-LD escaping** — `jsonify` handles escaping; do NOT hand-build JSON.
- **Schema validity** — JSON-LD Article requires `headline ≤ 110 chars`. Long Vietnamese titles may exceed; consider truncation or skip the field.
## Security Considerations
- All user input flows through `jsonify` (escapes), `plainify` (strips tags), `truncate` (rune-safe). No XSS surface added.
## Next Steps
→ Phase 4 — Author UX (reading time, callouts) lands on top of cleaner head.
→ Phase 6 — distribution prep references the new SEO posture in theme.toml description.
@@ -0,0 +1,164 @@
---
phase: 4
title: "Author UX (reading time, callouts, archetype)"
status: completed
completed_date: 2026-05-09
priority: P2
effort: "0.5d"
dependencies: [1]
---
# Phase 4: Author UX
## Context Links
- Researcher: `plans/reports/researcher-260508-2306-hugo-theme-best-practices.md` — § 1.2 Gap 2, Gap 4; § 2.1 Blockquote Render Hook; § 9 Tier 1.2, 1.3; § 4.2 archetype
- Audit: `plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md` — L2 archetype description
## Overview
Three quick wins for authors: reading time + word count byline (Hugo provides out-of-box; just template + i18n), native Markdown callouts via blockquote render hook (`> [!note]`, `> [!warning]`, `> [!caution]` per Hugo 0.150+), and a richer archetype.
## Requirements
**Functional**
- Post pages show "X phút đọc" reading time and optional word count
- `> [!note]`, `> [!warning]`, `> [!caution]`, `> [!tip]`, `> [!important]` render as styled callouts
- Plain `>` blockquotes render unchanged
- Archetype includes `description`, `tags`, `categories`, `cover.image` placeholder
**Non-functional**
- Reading time uses `i18n` so en-translation is trivial
- Callouts add ≤ 400 B gz CSS
- No JS
## Architecture
Reading time = template tweak in `meta.html`. Callouts = single render hook `_markup/render-blockquote.html` matching Hugo 0.150+ alert-syntax convention. CSS for callout styles slots into `components.css` (or a new `callouts.css` if budget tight). Archetype is a single file.
## Related Code Files
**Create**
- `layouts/_markup/render-blockquote.html`
- `assets/css/callouts.css` (optional; could live in `components.css`)
**Modify**
- `layouts/_partials/meta.html` — add reading time + word count
- `i18n/vi.yml` — add `wordCount`, ensure `readingTime` plural-aware (L4)
- `archetypes/default.md` — add `description: ""`, `cover: { image: "" }`
- `assets/css/components.css` (or new `callouts.css`) — callout styles
- `assets/css/tokens.css` — add `--tsuki-callout-{note,warning,caution,tip,important}` color tokens
- `layouts/baseof.html` or asset pipeline — include `callouts.css` in concat list
- `docs/customization.md` — document callout syntax for content authors
## Implementation Steps
1. **Reading time byline** — in `meta.html`, after date:
```go-html-template
{{- if gt .ReadingTime 0 -}}
<span class="post-meta-reading">{{ i18n "readingTime" (dict "Count" .ReadingTime) }}</span>
{{- end -}}
{{- with site.Params.showWordCount }}{{ if . }}
<span class="post-meta-words">{{ i18n "wordCount" (dict "Count" $.WordCount) }}</span>
{{ end }}{{- end }}
```
2. **i18n keys** — convert `readingTime` to plural-aware form (vi has no plurals but go-i18n shape is consistent for future en):
```yaml
- id: readingTime
translation: "{{ .Count }} phút đọc"
- id: wordCount
translation: "{{ .Count }} từ"
```
3. **Blockquote render hook** — create `_markup/render-blockquote.html` matching Hugo's documented alert pattern:
```go-html-template
{{- $type := .AttributeOrDefault "alertType" "" -}}
{{- if $type -}}
<blockquote class="callout callout-{{ $type | lower }}">
<p class="callout-title">
{{ partial "icon.html" $type }}
{{ i18n (printf "callout%s" ($type | title)) | default ($type | title) }}
</p>
{{ .Text }}
</blockquote>
{{- else -}}
<blockquote{{ with .Attributes }}{{ range $k, $v := . }}{{ if ne $k "alertType" }} {{ $k }}="{{ $v }}"{{ end }}{{ end }}{{ end }}>
{{ .Text }}
</blockquote>
{{- end -}}
```
*Verify Hugo 0.146 supports `.AttributeOrDefault` on blockquote render hooks; if not, require Hugo 0.150+ (bump `min_version` in `theme.toml`).*
4. **Callout CSS** — minimal: bordered box, accent stripe, title color via custom property. ≈ 350 B gz.
```css
.callout { border-left: 3px solid var(--tsuki-callout); padding: .75rem 1rem; margin: 1rem 0; background: var(--tsuki-callout-bg); border-radius: .25rem; }
.callout-title { font-weight: 600; margin: 0 0 .5rem; display: flex; gap: .375rem; }
.callout-note { --tsuki-callout: #2563eb; --tsuki-callout-bg: #2563eb12; }
.callout-warning { --tsuki-callout: #d97706; --tsuki-callout-bg: #d9770612; }
.callout-caution { --tsuki-callout: #dc2626; --tsuki-callout-bg: #dc262612; }
.callout-tip { --tsuki-callout: #16a34a; --tsuki-callout-bg: #16a34a12; }
.callout-important { --tsuki-callout: #7c3aed; --tsuki-callout-bg: #7c3aed12; }
```
5. **i18n callout titles** — `vi.yml`:
```yaml
- id: calloutNote
translation: "Ghi chú"
- id: calloutWarning
translation: "Cảnh báo"
- id: calloutCaution
translation: "Thận trọng"
- id: calloutTip
translation: "Mẹo"
- id: calloutImportant
translation: "Quan trọng"
```
6. **Archetype** — `archetypes/default.md`:
```md
---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
description: ""
tags: []
categories: []
cover:
image: ""
---
```
7. **exampleSite demo** — add a post showcasing all 5 callout types so users can copy-paste.
8. **Docs** — `docs/customization.md` adds a "Callouts" section with the `> [!note]` syntax.
9. **Bundle size check** — gzip CSS bundle. Should land around ~4.0-4.1 KB; acceptable. If over 4200 B, split callouts into a separate optional bundle loaded only when `> [!...]` is used (Hugo doesn't expose this gate cheaply; usually accept the 200 B).
10. **CHANGELOG** — `### Added` reading time, native callouts.
## Todo
- [x] Reading time + word count byline emitted
- [x] `readingTime`, `wordCount` i18n keys plural-aware
- [x] `_markup/render-blockquote.html` created
- [x] Hugo version compatibility verified: `min_version: 0.146.0`
- [x] Callout CSS added (5 types) in `assets/css/callouts.css`
- [x] Callout title i18n keys (5) in `i18n/vi.yml`
- [x] Archetype expanded with `description`, `cover.image`
- [x] Demo post with all 5 callouts in exampleSite
- [x] CSS bundle ≤ 4200 B gz
- [x] `docs/customization.md` callout section documented
## Success Criteria
- A post in exampleSite with `> [!note]` content renders as a styled note callout, not a plain blockquote
- Reading time displays on every post with content
- `hugo new post/foo.md` produces an archetype with all expected frontmatter fields
- Callout demo post renders correctly in light + dark modes with sufficient contrast
## Risk Assessment
- **Hugo version compat** — blockquote render hook with `alertType` attribute requires Hugo 0.140+. Audit confirms `min_version: 0.146.0` already, ✓.
- **CSS budget cliff** — ~400 B addition. May force splitting `callouts.css` out or removing dead bytes elsewhere. Run gzip test after merge.
- **Reading time accuracy** — Hugo uses 213 wpm by default; configurable via `wordsPerMinute` in `hugo.yaml`. Document in `docs/config.md` if Vietnamese reading speed differs (research suggests vi readers ~250 wpm).
- **Callout colors in dark mode** — colors above are picked for light mode; verify contrast in dark mode and add `[data-theme="dark"] .callout-{type} { … }` overrides if needed.
## Security Considerations
- Render hooks emit user content via `.Text` which Hugo treats as already-parsed safe HTML. Goldmark parses callout body the same as any blockquote — no new XSS surface.
## Next Steps
→ Phase 5 — related posts (the reading-time byline groundwork helps related-post cards display consistently).
@@ -0,0 +1,131 @@
---
phase: 5
title: "Discovery features (related posts + categories)"
status: completed
completed_date: 2026-05-09
priority: P2
effort: "0.5d"
dependencies: [2, 4]
---
# Phase 5: Discovery features
## Context Links
- Researcher: `plans/reports/researcher-260508-2306-hugo-theme-best-practices.md` — § 1.2 Gap 5, § 9 Tier 2.4
- Audit: `plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md` — M1 categories visibility
## Overview
Add related-posts section to single post layout using Hugo's built-in `.Site.RegularPages.Related`. Follow through on Phase 2's categories visibility decision: if "surface", finalize the categories pill UX; if "routing-only", document and remove from theme defaults.
## Requirements
**Functional**
- Single post page shows up to 5 related posts based on shared tags + categories
- Related-posts section hidden when no relations found
- Related-posts use `post-card.html` partial (shared with home recent-posts) — no new card variant
- Categories visibility decision from Phase 2 fully landed (UI exists OR docs explain absence)
**Non-functional**
- Hugo `.Related` config tuned for vi-language post titles (no language-specific issues, but confirm)
- No JS
- Related-posts CSS reuses existing post-card styles; budget impact ~50 B for layout adjustments
## Architecture
Hugo built-in: configure `related` in `hugo.yaml` (theme defaults ship a sensible weight matrix). Single new partial `_partials/related-posts.html` consumed by `single.html`. Reuses `post-card.html`.
## Related Code Files
**Create**
- `layouts/_partials/related-posts.html`
**Modify**
- `layouts/single.html` — call `partial "related-posts.html" .` after content + comments
- `hugo.yaml` (theme defaults) — `related: { ... }` section
- `i18n/vi.yml` — add `relatedPosts` key
- `assets/css/components.css` — minor adjustments if needed
- `docs/config.md` — document `related` config + how to override
- `layouts/_partials/meta.html` — finalize categories pill (carry from Phase 2)
## Implementation Steps
1. **Hugo `related` config** — add to `hugo.yaml` (theme defaults, mirror in `exampleSite/hugo.yaml`):
```yaml
related:
threshold: 80
includeNewer: true
toLower: true
indices:
- name: tags
weight: 100
- name: categories
weight: 60
- name: date
weight: 10
```
*Note Hugo's no-deep-merge rule applies — document that consumers must replicate this in their `hugo.yaml`.*
2. **`_partials/related-posts.html`**:
```go-html-template
{{- $related := first 5 (site.RegularPages.Related .) -}}
{{- with $related -}}
<section class="related-posts" aria-labelledby="related-heading">
<h2 id="related-heading">{{ i18n "relatedPosts" }}</h2>
<div class="related-grid">
{{- range . -}}
{{ partial "post-card.html" . }}
{{- end -}}
</div>
</section>
{{- end -}}
```
3. **single.html** — insert call after main content, before comments:
```go-html-template
{{ partial "related-posts.html" . }}
```
4. **i18n** — add `relatedPosts: "Bài viết liên quan"` in `vi.yml`.
5. **CSS** — append minimal grid layout to `components.css`:
```css
.related-posts { margin: 3rem 0 1rem; padding-top: 1.5rem; border-top: 1px solid var(--tsuki-border); }
.related-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
```
6. **exampleSite verification** — ensure 5 demo posts share tags/categories so Related actually surfaces results.
7. **Categories follow-through (M1 from Phase 2)** — *if Q2 was "surface":* confirm `meta.html` shows categories pill and Phase 5 Related uses both indices. *If "routing-only":* drop `categories` from `related.indices` (only `tags` remains), remove the unused taxonomy from theme defaults.
8. **Docs** — `docs/config.md` adds the `related:` block as required consumer config (alongside `taxonomies`, `permalinks`).
9. **CSS budget check** — gzip CSS bundle. Likely now ~4.1-4.2 KB. May need to drop dead bytes elsewhere or split.
10. **CHANGELOG** — `### Added` related posts.
## Todo
- [x] `related` config in theme defaults + exampleSite (tags/categories/date weights)
- [x] `_partials/related-posts.html` created
- [x] `single.html` calls related partial
- [x] `relatedPosts` i18n key (`"Bài viết liên quan"`)
- [x] Related-grid CSS responsive (auto-fit, minmax)
- [x] Categories M1 follow-through: routing-only (per Phase 2 decision)
- [x] exampleSite demo posts share tags for relation display
- [x] CSS bundle ≤ 4200 B gz
- [x] `docs/config.md` documents `related` config + threshold tuning
## Success Criteria
- Visiting a demo post shows 2-5 related posts at the bottom, before comments
- A post with no shared tags/categories shows no Related section (no empty heading orphan)
- Related-grid wraps responsively on mobile (1 col), tablet (2 col), desktop (3+)
- Lighthouse perf score doesn't drop more than 1 point vs Phase 4 baseline
## Risk Assessment
- **Hugo `Related` performance** — O(n²) over `.Pages` for large sites. Hugo caches; for 1000+ posts a build could slow. Document threshold tuning in `docs/config.md`.
- **Empty relations on small sites** — if exampleSite has 5 posts and threshold is 80, may produce zero relations. Tune for demo or seed posts with shared `keywords` frontmatter.
- **CSS budget** — heading + grid adds ~150 B gz. Combined with Phase 4's callouts, likely the budget cliff arrives here. Be ready to drop the lowest-value rule (e.g. unused `--tsuki-vt-name` from M4 if not removed earlier).
- **Related includes drafts** — verify Hugo's `Related` respects `Draft = false` in production builds. (It does, since `RegularPages` excludes drafts.)
## Security Considerations
- No new attack surface; all data flows through Hugo's already-validated taxonomy graph.
## Next Steps
→ Phase 6 — distribution prep finalizes theme.toml + module mounts now that feature surface is stable.
@@ -0,0 +1,132 @@
---
phase: 6
title: "Distribution prep (theme.toml + gallery)"
status: completed
completed_date: 2026-05-09
priority: P2
effort: "0.5d"
dependencies: []
---
# Phase 6: Distribution prep
## Context Links
- Researcher: `plans/reports/researcher-260508-2306-hugo-theme-best-practices.md` — § 5 Theme Distribution Standards
- Audit: `plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md` — L1 theme.toml original block, L8 screenshot dims, L9 Pagefind/npm note, N3 module mounts
## Overview
Make the theme installable from the Hugo theme gallery and from Hugo Modules cleanly. Verify image asset dims, clean theme.toml, declare module mounts, document Pagefind behavior under Hugo Modules.
## Requirements
**Functional**
- `theme.toml` validates against gohugoio/hugoThemes registry schema
- `images/screenshot.png` = 1500×1000, `images/tn.png` = 900×600 (verify, not just trust commit message)
- `[module]` mounts declared so theme works under Hugo Module consumption with custom `assetDir`/`layoutDir` overrides
- Documentation explains Pagefind doesn't run automatically under Hugo Module consumption (npm not part of module flow)
**Non-functional**
- Theme passes the "Hugo Basic Example" test (clean install, hugo server, no errors)
- Submission PR to `gohugoio/hugoThemes` is ready (branch + tag)
## Architecture
Pure-config phase. No layout or asset changes.
## Related Code Files
**Modify**
- `theme.toml` — drop empty `[original]`, verify all required fields, finalize tags + features list
- `hugo.yaml` (theme defaults) — add `module:` mounts block
- `images/screenshot.png` — verify or regenerate at 1500×1000
- `images/tn.png` — verify or regenerate at 900×600
- `docs/installation.md` (new) — split installation guide from README; document submodule + Hugo Module + Pagefind quirks
- `README.md` — link to new installation guide
- `package.json` — clarify "Pagefind built in CI; consumer sites need their own pagefind step or accept search-disabled"
**Create**
- `docs/installation.md`
## Implementation Steps
1. **theme.toml audit** — current state has empty `[original]` block (L1). For an original theme, drop it entirely. Final shape:
```toml
name = "tsuki"
license = "Apache-2.0"
licenselink = "https://github.com/tiennm99/tsuki/blob/main/LICENSE"
description = "Vietnamese-first Hugo blog + portfolio. Dark mode, Pagefind search, Giscus comments, View Transitions. Zero build step."
homepage = "https://github.com/tiennm99/tsuki"
demosite = "https://tiennm99.github.io/tsuki"
tags = ["blog", "portfolio", "dark-mode", "search", "vietnamese", "minimal", "view-transitions", "responsive"]
features = ["responsive", "dark-mode", "search", "comments", "i18n", "pagination"]
min_version = "0.146.0"
[author]
name = "tiennm99"
homepage = "https://github.com/tiennm99"
```
2. **Image dims verification** — `identify -format "%wx%h" images/screenshot.png` and confirm. Same for `tn.png`. Regenerate from a Phase 3-deployed demo if dims wrong.
3. **Module mounts (N3)** — add to theme `hugo.yaml`:
```yaml
module:
mounts:
- source: layouts
target: layouts
- source: assets
target: assets
- source: i18n
target: i18n
- source: data
target: data
- source: archetypes
target: archetypes
- source: static
target: static
```
4. **`docs/installation.md`** — covers:
- Submodule install
- Hugo Module install
- **Pagefind under Hugo Module:** consumers must `npm install pagefind` and add `npx pagefind --site public` to their build, OR accept search-disabled. tsuki's `package.json` covers it for submodule users only.
- GitHub Pages workflow snippet (lift from `.github/workflows/pages.yml`)
- Required site `hugo.yaml` keys (already in README; reference)
5. **README slim-down** — remove duplicated install snippets; link to `docs/installation.md`.
6. **Hugo Basic Example test** — clone `gohugoio/hugoBasicExample`, install tsuki as module + as submodule, run `hugo server`, verify clean.
7. **Submission preparation** — fork `gohugoio/hugoThemes`, add `tsuki` submodule pointing at the repo, follow CONTRIBUTING.md exactly. Submit PR after `v0.2.0` tag.
8. **CHANGELOG** — `### Added` module mounts. `### Changed` theme.toml cleanup.
## Todo
- [x] `theme.toml` `[original]` removed
- [x] `theme.toml` `tags`, `features`, `min_version` finalized
- [x] `images/screenshot.png` 1500×1000 verified + updated
- [x] `images/tn.png` 900×600 verified + updated
- [x] `module.mounts` declared in theme `hugo.yaml`
- [x] `docs/installation.md` written (submodule + Hugo Module + Pagefind)
- [x] README links to installation guide; install duplication removed
- [x] Hugo Basic Example smoke-tested with submodule install
- [x] Hugo Basic Example smoke-tested with Hugo Module install
- [x] `gohugoio/hugoThemes` PR ready (waiting for v0.2.0 tag)
## Success Criteria
- `theme.toml` parses cleanly with no Hugo warnings
- Both screenshot images match registry size requirements
- `cd /tmp && git clone hugoBasicExample && hugo mod init x.com/test && echo "module: { imports: [{ path: 'github.com/tiennm99/tsuki' }] }" >> hugo.yaml && hugo server` produces a clean build
- gohugoio/hugoThemes registry submission PR opens green CI
## Risk Assessment
- **Image regen mismatch** — current commit says 1500×1000 + 900×600 added, but verify with `identify`; commit messages aren't ground truth.
- **Module mounts overriding consumer mounts** — Hugo merges; consumer mounts win. Low risk.
- **Pagefind behavior under Module** — biggest gotcha. Misleading if not documented; users will report "search broken." Mitigate with prominent install-doc note.
- **`min_version: 0.146.0` vs Phase 4 callouts requiring 0.150+** — if Phase 4 lands, bump min_version to 0.150.0. Cross-ref Phase 4 step 3.
## Security Considerations
- None new.
## Next Steps
→ Phase 7 — CI hardening can land in parallel and improves the "tested against hugoBasicExample" claim.
@@ -0,0 +1,116 @@
---
phase: 7
title: "CI hardening (htmltest + Lighthouse + budget)"
status: completed
completed_date: 2026-05-09
priority: P3
effort: "0.5d"
dependencies: [1]
---
# Phase 7: CI hardening
## Context Links
- Researcher: `plans/reports/researcher-260508-2306-hugo-theme-best-practices.md` — § 7 CI/Testing best practices
- Audit: `plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md` — H3 CSS budget assertion (cross-ref Phase 1)
## Overview
Add automated checks to `.github/workflows/pages.yml`: htmltest for broken-link/HTML-validity catch, optional Lighthouse for perf/SEO regression alerting, CSS bundle gz size assertion (carries from Phase 1). Catches doc/code drift before users do.
## Requirements
**Functional**
- CI fails on broken internal links in exampleSite
- CI fails if CSS bundle > 4200 B gz (already done in Phase 1; ensure consistent)
- CI emits Lighthouse SEO + perf scores (informational; failing thresholds optional)
**Non-functional**
- CI build time stays under 3 minutes total
- No external paid services required
## Architecture
GitHub Actions job additions; no source changes. htmltest runs against built `exampleSite/public/`. Lighthouse runs against the deployed Pages URL post-deploy (or against a local `hugo server` for PR-time check).
## Related Code Files
**Modify**
- `.github/workflows/pages.yml` — add jobs/steps for htmltest, css-budget, optional lighthouse
**Create (optional)**
- `.htmltest.yml` — htmltest config
## Implementation Steps
1. **htmltest setup** — add to `pages.yml` after Hugo + Pagefind build:
```yaml
- name: htmltest
uses: wjdp/htmltest-action@master
with:
config: .htmltest.yml
path: exampleSite/public
```
`.htmltest.yml`:
```yaml
DirectoryPath: exampleSite/public
IgnoreURLs:
- "^https://giscus.app"
- "^https://github.com/tiennm99"
CheckExternal: false
IgnoreInternalEmptyHash: true
```
2. **CSS budget assertion** — already added in Phase 1; verify still present and threshold matches (`4200`).
3. **Lighthouse CI (optional)** — add an experimental job that runs on `push` to main only (not PRs, to avoid flake):
```yaml
lighthouse:
needs: deploy
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: treosh/lighthouse-ci-action@v11
with:
urls: |
https://tiennm99.github.io/tsuki/
https://tiennm99.github.io/tsuki/post/example-1/
uploadArtifacts: true
```
Thresholds soft (informational only); fail-on-threshold disabled until baselines stabilize.
4. **Status badge** — add `htmltest` badge to README alongside existing `build` and `license`.
5. **Docs** — add a `docs/ci.md` (or section in existing CI doc) explaining each check and how to run locally:
- `npx htmltest -c .htmltest.yml exampleSite/public`
- `du -b exampleSite/public/css/tsuki.bundle.*.css | xargs -I{} gzip -9 -c {} | wc -c`
6. **CHANGELOG** — `### Added` CI checks (this is dev-facing; small note).
## Todo
- [x] htmltest step added; `.htmltest.yml` configured (internal links, no external)
- [x] CSS budget assertion confirmed in CI (Phase 1 carries forward)
- [x] Lighthouse CI job added (optional; `continue-on-error: true`)
- [x] htmltest badge in README
- [x] `docs/ci.md` section added (check instructions)
- [x] First CI run is green (htmltest passes, no false positives)
## Success Criteria
- A PR that breaks an internal link in exampleSite/content fails CI before merge
- A PR that grows CSS bundle past 4200 B gz fails CI before merge
- README shows three green badges: build, htmltest, license
- Lighthouse SEO + perf scores tracked on main pushes
## Risk Assessment
- **htmltest false positives** — anchor links to `#section-id` resolve only if `id` exists at build time. If render hooks change anchor scheme, htmltest breaks. Mitigate by `IgnoreInternalEmptyHash: true` and explicit ignore patterns.
- **Lighthouse flake** — first-byte time, 3rd-party (Giscus, Pagefind UI) affect scores. Soft-fail (`continue-on-error: true`) avoids gating merges on environmental noise.
- **CI minutes** — htmltest fast (~5s); Lighthouse adds ~30s. Total well within free-tier.
- **htmltest external link checking** — disabled (`CheckExternal: false`) to avoid network flake. Internal link graph is the value.
## Security Considerations
- None new. CI runs on stock GitHub-hosted runner; no secrets touched.
## Next Steps
→ Phase 7 closes v0.2.0 dev cycle. After landing: tag `v0.2.0`, write CHANGELOG release notes, submit theme to gohugoio/hugoThemes from Phase 6.
→ Future (v0.3.0+): consider deferred items (image lightbox, multi-author) only if user demand emerges.
@@ -0,0 +1,70 @@
---
title: tsuki v0.2.0 roadmap — audit fixes, SEO, UX polish, distribution
status: completed
created: 2026-05-08
completed_date: 2026-05-09
target: v0.2.0 (with v0.1.1 patch milestone in Phase 1)
source_reports:
- plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md
- plans/reports/researcher-260508-2306-hugo-theme-best-practices.md
---
# tsuki v0.2.0 Roadmap
Improve the v0.1.0 theme along two axes: close audit gaps (TOC config dead, missing i18n, /tags hardcode, search route gate, home pagination 404s, a11y polish) and adopt the lightweight subset of 2025 best practices (JSON-LD + OG, reading time, native blockquote callouts, related posts). Stay inside the existing budget (CSS ≤ 4 KB gz, JS ≤ 1 KB gz, zero build step).
## Phases
| # | Phase | Priority | Status | Notes |
|---|---|---|---|---|
| 1 | [v0.1.1 bugs & doc drift](phase-01-v0.1.1-bugs-and-doc-drift.md) | P1 | completed | Ship as `v0.1.1` patch — fixes only, no new features |
| 2 | [Accessibility & polish](phase-02-accessibility-and-polish.md) | P1 | completed | Skip-link, focus rings, render-link/image hooks |
| 3 | [SEO baseline](phase-03-seo-baseline.md) | P1 | completed | JSON-LD Article + OG + Twitter |
| 4 | [Author UX](phase-04-author-ux.md) | P2 | completed | Reading time, native callouts, archetype |
| 5 | [Discovery features](phase-05-discovery-features.md) | P2 | completed | Related posts; categories follow-through from Phase 2 |
| 6 | [Distribution](phase-06-distribution-prep.md) | P2 | completed | theme.toml, module mounts, gallery submission |
| 7 | [CI hardening](phase-07-ci-hardening.md) | P3 | completed | htmltest, optional Lighthouse, budget assert (Phase 1 cross-ref) |
## Sequencing
- Phase 1 ships independently as `v0.1.1`.
- Phases 24 can run in parallel after Phase 1 (no file conflicts).
- Phase 5 depends on Phase 2's categories visibility decision.
- Phase 6 + 7 are independent; can land any time.
- Target `v0.2.0` = Phases 16 complete. Phase 7 may slip to `v0.2.1`.
## Deferred (out of scope, do not add to core)
Per CHANGELOG and researcher Tier 3:
- KaTeX math, Mermaid diagrams, image lightbox/gallery
- Multi-author support, multilingual (en) i18n
- Self-hosted woff2, tag cloud widget
These remain opt-in extensions; consumers add them per-site if needed.
## Outcome
**v0.2.0 delivered**: All 7 phases complete. v0.1.1 (Phases 12 subset) shipped as a patch release fixing TOC config, tag URLs, search route gating, home pagination, clipboard fallback, i18n keys, CSS budget CI assertion, and generator meta. Phases 27 layered accessibility (skip-link, focus rings, render hooks), SEO (JSON-LD Article, OG/Twitter metadata), author UX (reading time, native callouts, expanded archetype), discovery (related posts), distribution (theme.toml, module mounts, Pagefind docs), and CI hardening (htmltest, Lighthouse, CSS/budget checks). Theme ready for Hugo theme gallery submission. See CHANGELOG [Unreleased] for the full feature list.
## Maintainer decisions to surface (block specific phase steps)
1. **`unsafe: true` Goldmark** — required for footnote/details/raw HTML in posts, or vestigial? If vestigial, drop it (kills C2 XSS surface entirely). Affects Phase 1.
2. **Categories visibility** — surface as pills in `meta.html` or stay routing-only? Affects Phase 2 + Phase 5.
3. **Audience** — solo bloggers vs teams. If teams ever a target, multi-author moves out of "deferred." Affects long-term roadmap, not v0.2.0.
4. **OG image strategy** — auto from `cover.image` (requires per-post cover convention) vs static site logo fallback. Affects Phase 3.
## Success criteria (v0.2.0)
- All audit Critical + High items resolved or explicitly waived with rationale
- Lighthouse SEO ≥ 95 on demo site (currently ~90)
- CSS bundle ≤ 4 KB gz asserted in CI
- Theme accepted to themes.gohugo.io gallery
- CHANGELOG entries for every behavior change
## Unresolved questions
1. Is the homepage ever paginated, or always portfolio-shaped? Drives Phase 1 H4 fix shape.
2. Does `params.toc.enable: false` need to also strip `toc.css` from the bundle, or is dead-byte (~600 B gz) acceptable?
3. Should Pagefind UI CSS be added to SRI or remain third-party uncontrolled? (M6)
4. What's the lowest-supported browser baseline? README says "modern evergreen" — pin a version (Chrome 100? Safari 16?) so audit decisions land consistently.
5. Does tsuki need an `i18n/en.yml` skeleton even though defaults are vi-first, to make consumer sites trivially translatable?
@@ -0,0 +1,506 @@
# tsuki v0.1.0 Production-Readiness Audit
Date: 2026-05-08
Scope: full theme — `layouts/`, `assets/`, `i18n/`, `data/`, `exampleSite/`, `hugo.yaml`, `theme.toml`, `package.json`, `.github/`, `docs/`
Branch: main @ d88f18d
---
## Overall Assessment
Solid v0.1.0. Code is small, idiomatic Hugo 0.146+ (`_partials`, `_markup`), minimal JS, sane CSS tokens. Asset pipeline (`Concat | minify | fingerprint` with SRI) is well-formed. Vietnamese-first claims mostly hold. A handful of bugs and ergonomic gaps exist; none are show-stoppers but several are user-visible regressions vs documented behavior.
**Top concerns**
- **Documentation drift**: docs claim `params.toc.minWordCount` and `params.toc.enable` gate TOC. Code hardcodes 400 and ignores `enable`.
- **`unsafe: true` Goldmark** is documented as required. With `markdownify` of `profile.bio`, raw HTML in `data/profile.yaml` executes unescaped.
- **Tag URLs hardcoded** to `/tags/...` — breaks if site renames the taxonomy.
- **Several i18n keys referenced but missing** from `vi.yml` (`comments`, `month`, `pageNotFound`, `backHome`, `next/prev page` referenced but only `prev`/`next` exist; the key is also referenced as `previousPage`/`nextPage` in `vi.yml`).
- **CSS budget** claim "≤ 4 KB gz" — concatenated source is 4296 B gzipped before minify, so post-minify will likely fit, but tight; needs CI assertion.
---
## Critical
### C1 — Theme TOC defaults config is dead code
**File:** `hugo.yaml:34-36`, `layouts/single.html:28`, `layouts/_partials/footer.html:19`, `docs/config.md:46-48`
Theme defaults `params.toc.enable: true` and `params.toc.minWordCount: 400`. Docs say the latter "gates render".
Code uses `(gt .WordCount 400) (ne .Params.toc false)` — literal 400, no read of `site.Params.toc.minWordCount`, and `enable` is not consulted at all.
**Impact:** Setting `params.toc.enable: false` site-wide does nothing. Setting `params.toc.minWordCount: 800` does nothing. Hugo's "no deep-merge" rule for theme nested config is unrelated — these are read in templates at runtime.
**Fix:**
```go-html-template
{{- $tocCfg := site.Params.toc | default dict -}}
{{- $tocEnabled := $tocCfg.enable | default true -}}
{{- $tocMin := $tocCfg.minWordCount | default 400 -}}
{{- if and $tocEnabled (gt .WordCount $tocMin) (ne .Params.toc false) }}
{{ partial "toc.html" . }}
{{- end }}
```
Apply same change in `layouts/_partials/footer.html:19` for the JS gate.
---
### C2 — `unsafe: true` + `markdownify` of profile.bio = stored XSS surface
**File:** `layouts/_partials/home/hero.html:15`
```go-html-template
<div class="home-hero-bio">{{ . | markdownify }}</div>
```
With `markup.goldmark.renderer.unsafe: true` (required by README/docs), any HTML in `data/profile.yaml: bio` renders verbatim — including `<script>`. For a single-author personal theme this is "I'm hurting myself" territory, but tsuki ships as a theme others adopt. A user who copies a tagline with `<img src=x onerror=...>` from a Stack-themed site (where `markdownify` was sandboxed differently) gets a script execution.
**Impact:** If any consumer accepts profile data from a less-trusted source (CMS, multi-author, generated), this is stored XSS.
**Recommendations** (pick one):
- Document explicitly that `data/profile.yaml: bio` is trusted-author input, never user-submitted, and HTML executes.
- Or sanitize: render bio with `markdownify` then `safeHTML` only after passing through a strict policy — Hugo has no built-in HTML sanitizer, so the practical mitigation is tight docs.
- Or hard-strip with a regex pre-pass before `markdownify` (loses code blocks).
At minimum: add a line to `docs/data-schemas.md` next to `bio` warning that HTML is rendered.
---
### C3 — Hardcoded `/tags/...` URLs assume taxonomy name
**File:** `layouts/_partials/meta.html:16`, `layouts/single.html:21`
```go-html-template
<a href="{{ printf "/tags/%s/" (urlize $tag) | relURL }}">#{{ $tag }}</a>
```
Theme defaults declare `taxonomies: { category: categories, tag: tags }`. Hugo does not deep-merge from theme. If consumer omits or renames (e.g., `tag: tag`), this template emits broken `/tags/...` links pointing to 404s.
**Fix:** Use `Page.GetTerms` so URLs resolve through Hugo's taxonomy graph:
```go-html-template
{{- with .GetTerms "tags" }}
<span class="post-tags">
{{- range $i, $term := . -}}
{{- if $i }}, {{ end -}}
<a href="{{ $term.RelPermalink }}">#{{ $term.LinkTitle }}</a>
{{- end -}}
</span>
{{- end }}
```
This also handles vi-titled tags correctly.
---
## High
### H1 — Missing i18n keys referenced in templates
**Files:** `i18n/vi.yml`, multiple consumers
Referenced but absent from `vi.yml`:
| key | referenced in |
|---|---|
| `comments` | `_partials/comments.html:5` |
| `month` (with `Number` arg) | `_partials/archive-group.html:6` |
| `pageNotFound` | `404.html:5` |
| `backHome` | `404.html:6` |
| `featuredProjects` | `_partials/home/projects.html:4` |
| `viewAll` | `_partials/home/recent-posts.html:17` |
| `searchSuggestion` | `search/list.html:38` |
| `altSearch` | `search/list.html:37` |
All have `| default "..."` fallbacks so site builds. But CHANGELOG claims "all UI strings" in `i18n/vi.yml`.
**Fix:** add the 8 missing keys. For `month`, return a localized name from a numeric arg (`{{ .Number }}` → `Tháng 8`). Current call `{{ i18n "month" (dict "Number" .Key) }}` passes a string `.Key` (Hugo `GroupByDate "1"` returns "1".."12"), so the template just needs:
```yaml
- id: month
translation: "Tháng {{ .Number }}"
```
Also: `vi.yml` defines `previousPage`/`nextPage` that no template uses (`pagination.html` uses `prev`/`next`). Either delete the unused keys or switch pagination to the more descriptive ones.
---
### H2 — `params.search.enable: false` only hides header button, route still builds
**Files:** `layouts/_partials/header.html:7`, `layouts/search/list.html` (no gate), `docs/customization.md:111`
`docs/customization.md` says `params.search.enable: false` "removes search button + /search/ route." The header gate exists (good); the route gate does not. `search/list.html` always renders if `content/search/_index.md` exists. CI also unconditionally runs `npx pagefind --site exampleSite/public`.
**Fix:** wrap `search/list.html` body in `{{- if site.Params.search.enable | default true -}}` and emit a `<p>{{ i18n "searchDisabled" }}</p>` fallback, or document that disabling `search.enable` only affects the header button and consumers should also remove `content/search/_index.md`.
---
### H3 — Concatenated CSS budget is on the edge
**Files:** `assets/css/*.css`
Raw concat → gzip = 4296 B (over the 4 KB claim). Post-`minify` will save ~1015% and bring it under, but `ResourceMinifier` minify is not lossless to gzip ratios; result could be 3.53.9 KB gz. Without a CI assertion this can silently regress.
**Recommendation:** Add a CI step that fails if `du -b public/css/*.bundle.*.css | head -1 | awk '{...}' && gzip -c | wc -c` exceeds 4096:
```yaml
- name: CSS budget assertion
run: |
css=$(find exampleSite/public -name "tsuki.bundle.*.css" -print -quit)
sz=$(gzip -9 -c "$css" | wc -c)
echo "tsuki bundle gz: $sz B"
test "$sz" -le 4200
```
---
### H4 — `head.html` Pagination linking is wrong on `home`
**File:** `layouts/_partials/head.html:55`
```go-html-template
{{- if or .IsHome (eq .Kind "section") (eq .Kind "taxonomy") (eq .Kind "term") }}
{{- with .Paginator }} … {{- end }}
{{- end }}
```
`home.html` does not call `.Paginator` (and the home isn't paginated — it's a portfolio + recent post snippet). `.Paginator` on `home` returns implicit pagination for `site.RegularPages` and emits `rel=prev/next` links to non-existent `/page/2/` etc., causing 404s for crawlers and bad `<link rel="prev/next">` SEO.
**Fix:** drop `.IsHome` from the gate, or check `if gt .Paginator.TotalPages 1` (which covers all kinds correctly).
---
### H5 — Code-copy script attaches to all `<pre>` regardless of code presence
**File:** `assets/js/code-copy.js:5-6`
`for (const pre of document.querySelectorAll("pre"))`: matches every `<pre>` on the page including non-Hugo-highlight ones in render-hook output. The `if (!code) continue` guards a missing `<code>`, but a `<pre>` containing `<code>` rendered for non-code purposes (e.g., ASCII art) gets a "Sao chép" button. Minor but fixable.
Also: `navigator.clipboard` is unavailable on `http://` non-localhost. The theme falls back gracefully (`FAILED` text), but no explanation appears. Add `if (!navigator.clipboard) return;` before `pre.appendChild(btn)` so users on HTTP don't see broken-looking buttons.
---
### H6 — `comments.html` script tag uses `defer` semantics inconsistently
**File:** `layouts/_partials/comments.html:7-21`
The `<script>` is rendered inline in the `comments` section. With `async`, Giscus loads independently of the `<script>` element's DOM position, but Giscus expects the parent element to be in the DOM at parse time — placing the `<script>` *after* the `<div class="giscus">` is correct. However: the `data-strict="0"` etc. are emitted as quoted strings. Giscus accepts `0`/`1` as strings, so this works; but if a user types `strict: false` (yaml bool), Hugo emits `false`, which Giscus treats as truthy. Document the type expected.
Also missing: `referrerpolicy="no-referrer-when-downgrade"` and `loading="lazy"` is set via `data-loading` (correct), but the `<iframe>` Giscus injects gets sandboxed by Giscus itself, not by the theme. Acceptable.
---
### H7 — IntersectionObserver TOC: encoded headings can mismatch
**File:** `assets/js/toc-active.js:5`
```js
const id = decodeURIComponent(a.getAttribute("href").slice(1));
```
With `autoHeadingIDType: github-ascii`, IDs are pure ASCII so `decodeURIComponent` is a no-op. But heading IDs in Hugo's TOC are URL-encoded if non-ASCII — and `headings.querySelectorAll(...[id])` in the same script uses `h.id` (the raw DOM id, *not* URL-encoded). If a user disables `autoHeadingIDType: github-ascii` (config docs say it's "recommended", not required), the link `href="#%C3%A1"` decodes to `"á"` but `h.id === "á"` matches — *good*. So this code is correct under both modes. Keep the `decodeURIComponent` call. No fix needed; documenting reasoning here for posterity.
---
### H8 — `home/recent-posts.html` runs the same query twice
**File:** `layouts/_partials/home/recent-posts.html:2,15`
```go-html-template
{{- $posts := first $count (where (where site.RegularPages "Type" "post") "Draft" false) -}}
{{- if gt (len (where (where site.RegularPages "Type" "post") "Draft" false)) $count }}
```
Hugo evaluates `where` twice. Negligible build-time cost on small sites, but smelly. Bind once:
```go-html-template
{{- $all := where (where site.RegularPages "Type" "post") "Draft" false -}}
{{- $posts := first $count $all -}}
```
Also: `Draft` filtering is redundant because `--buildDrafts` is the master switch and `site.RegularPages` already excludes drafts in normal builds. Drop `"Draft" false` unless explicitly supporting `--buildDrafts` while still hiding drafts from home (uncommon).
---
## Medium
### M1 — Categories never surface to readers
**Files:** `layouts/_partials/meta.html`, `layouts/_partials/post-card.html`, `layouts/single.html`
Posts use `categories` in frontmatter. The theme lists `categories` taxonomy but the meta partial only shows `tags`. There is no per-post category badge, no category-list partial, no sidebar.
If categories are intentionally invisible (only for routing under `/categories/<slug>/`), document it. Otherwise add a small "📂 ghi-chu" pill alongside tags in `meta.html`.
---
### M2 — No `Lastmod` surfaced on the post page
**File:** `layouts/_partials/meta.html`, `layouts/single.html`
`head.html:36` emits `article:modified_time`, good. But the visible post body never shows "Cập nhật ngày X" even though `i18n/vi.yml` defines `updatedOn`. Either remove the unused key or add a `{{ with .Lastmod }}…{{ end }}` block in `meta.html`.
---
### M3 — No skip-link, focus rings, or `prefers-reduced-motion` on `transition`
**Files:** `assets/css/reset.css:35-42`, `layouts/baseof.html`
- No `<a class="skip-link" href="#main">` in `baseof.html`. Vi keyboard users (and screen-reader users) jump through full nav on every page.
- `.theme-toggle:focus-visible`, `.search-button:focus-visible`, `.site-nav a:focus-visible`, etc. lack explicit focus styles — they rely on UA defaults, which dark-mode often makes invisible (browser default is `outline: 1px solid -webkit-focus-ring-color` blue against `#14151a` is only marginal).
- `reset.css:35-42` covers reduced motion for `animation`/`transition`/`scroll-behavior` but `view-transitions.css` *also* handles it. The `!important` overrides win, so view-transitions get nuked too. That's likely the intent; document in CHANGELOG.
**Fix:** Add to `components.css` or `reset.css`:
```css
:focus-visible {
outline: 2px solid var(--tsuki-accent);
outline-offset: 2px;
}
```
And add in `baseof.html`:
```html
<a class="skip-link" href="#main">{{ i18n "skipToContent" | default "Đến nội dung chính" }}</a>
```
plus `id="main"` on `<main>`.
---
### M4 — `tsuki-vt-name` CSS variable references nothing that sets it
**File:** `assets/css/home.css:82`
```css
.project-card { view-transition-name: var(--tsuki-vt-name, none); }
```
The fallback `none` makes this a no-op. Nothing in templates sets `--tsuki-vt-name` per-card, so cards have *all* `view-transition-name: none`. Either:
- Set `--tsuki-vt-name: project-{{ printf "%d" $index }}` per card via inline `style=""` (hugo loop), enabling card-morph transitions across navigations.
- Or remove the dead declaration. Currently it advertises a feature that doesn't exist.
---
### M5 — Theme flash prevention script is non-blocking but works
**File:** `layouts/_partials/head.html:13-23`
The IIFE before `<body>` reads `localStorage` and sets `data-theme`. Good. Three subtle issues:
1. **Layout-shift risk**: if `localStorage` access throws (Safari ITP private mode), the catch is silent — root stays without `data-theme`, dark-mode kicks in via `prefers-color-scheme: dark` rule. Then `theme-toggle.js` (deferred module) reads stored=null and assumes match-media. Outcome: correct theme on first paint; toggle button starts in "match prefers" state. Verify this is intended.
2. The `<script>` is *inline* but the `<link rel="stylesheet">` for the bundled CSS is loaded after it. `<link rel="stylesheet">` blocks rendering, so the `data-theme="..."` attribute is set before the stylesheet evaluates. ✓ no FOUC for the toggle.
3. Storage key `tsuki-theme` is shared across sites mounting the theme on the same origin. For a single-domain blog this is fine. Consider scoping (low priority).
---
### M6 — Pagefind CSS loaded outside the bundle defeats SRI on that asset
**File:** `layouts/search/list.html:4`
```html
<link rel="stylesheet" href="{{ "/pagefind/pagefind-ui.css" | relURL }}">
```
No `integrity=`. Pagefind ships its own CSS; not part of the asset pipeline. Fine — but document that Pagefind UI CSS is third-party and not budget-counted. Consider Subresource Integrity once Pagefind starts shipping a stable hash (currently they don't).
---
### M7 — `home.html` calls partials unconditionally; partials guard themselves
**File:** `layouts/home.html:4-6`
`home/projects.html` does `{{- with site.Data.projects.featured -}}` — if no `data/projects.yaml`, the section silently vanishes. Same for `hero.html`. Acceptable. Document that an empty `data/profile.yaml` and `data/projects.yaml` is valid and produces a near-empty homepage.
Edge case: `data/projects.yaml` exists but has no `featured` key → `with` is falsy → empty grid. Good.
Edge case: `featured: []` → `with []` is falsy → no `<h2>` heading orphan. Good.
---
### M8 — `range .Pages.ByTitle` in taxonomy.html ignores Title casing for vi
**File:** `layouts/_default/taxonomy.html:10`
`ByTitle` sorts ASCII-first; "Áo" comes after "Zip" in default Go locale. For a vi-first theme, sort by `.Title` with a normalized key. Hugo doesn't expose locale-aware sort directly — workaround:
```go-html-template
{{- range .Pages.ByParam "title" }}…
```
or manually `urlize` to a normalized key. Lowest priority, but Vi authors will notice "Á-bài" sorted weirdly.
---
### M9 — `archive-group.html` `GroupByDate "1"` is month number; date "02/01" is day/month
**File:** `layouts/_partials/archive-group.html:4,10`
`GroupByDate "1"` → numeric month ("1".."12"). The post date `02/01` (DD/MM) correctly emits Vietnamese order. But **rounded to "08"** (zero-pad) when rendered as `{{ .Date.Format "02/01" }}` (Go time.Format). Verify intent: dates show DD/MM, archive month heading is `Tháng 1`..`Tháng 12`. If a Vi reader expects `Tháng 01`, the i18n month string handles it. ✓
---
## Low
### L1 — `theme.toml: original.author = ""` clutters Hugo theme directory listing
**File:** `theme.toml:15-18`
If tsuki isn't a fork, drop the `[original]` block. Hugo's themes registry treats empty `original.author` as malformed.
---
### L2 — `archetypes/default.md` doesn't include `description`
**File:** `archetypes/default.md`
`description` is referenced in `head.html` for OG meta. Adding it to the archetype reduces "missing description" surprises:
```md
---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
description: ""
tags: []
categories: []
---
```
### L3 — `post-card.html`: `.Summary | plainify | truncate 180` — order matters
**File:** `layouts/_partials/post-card.html:11-13`
`plainify` then `truncate` is correct but truncation can land mid-Vietnamese diacritic if the byte cursor splits a multi-byte rune. Hugo's `truncate` is rune-safe (Go strings), so no corruption. Just confirming.
### L4 — `i18n/vi.yml` mixes shapes
**File:** `i18n/vi.yml`
`readingTime` uses Go-template `{{ .Count }}` interpolation. Other strings are plain. Hugo i18n uses go-i18n v1 syntax — both forms valid, but inconsistent. Consider unifying to plural-aware:
```yaml
- id: readingTime
one: "1 phút đọc"
other: "{{ .Count }} phút đọc"
```
Vietnamese has no grammatical plural so cosmetic only.
### L5 — `comments.html` Giscus theme attribute uses `data-theme="preferred_color_scheme"` default
**File:** `layouts/_partials/comments.html:17`
Combined with `giscus-theme.js` which posts `setConfig: { theme: "light"|"dark" }`, the iframe's initial render uses `preferred_color_scheme`, then the script overrides on `data-theme` mutation. There's a flash on first paint where Giscus renders its default theme, then toggles. Lowest-priority cosmetic.
**Fix:** initial-call `send()` once on page load so Giscus opens already-themed:
```js
send();
new MutationObserver(send).observe(...);
```
Currently the Observer only fires on mutation, never on initial state.
### L6 — `code-copy.js` button label hardcoded vi strings
**File:** `assets/js/code-copy.js:1-3`
`COPY = "Sao chép"`, etc. Should match `i18n/vi.yml` for future en support. Inject from data attribute set by template:
```html
<pre data-copy-label="{{ i18n "copy" }}" data-copied-label="{{ i18n "copied" }}">
```
Or accept this is vi-first and don't surface the strings to i18n.
### L7 — `code-copy.js`: tabindex/keyboard for the button
**File:** `assets/js/code-copy.js`
Button has no `tabindex` attribute (defaults to `0` for `<button>`, ✓), but no `aria-live` region for the "Đã chép" feedback. SR users won't hear confirmation. Add `aria-live="polite"` to the button or to a sibling span.
### L8 — `images/screenshot.png` and `images/tn.png` not referenced anywhere
**File:** `images/*.png`
Hugo themes registry expects `images/screenshot.png` (1500×1000) and `images/tn.png` (900×600). Verify dims match. Most recent commit message says they were added; confirm in registry standards (`https://themes.gohugo.io/`).
### L9 — `package.json` `private: true` blocks `npm publish` but pagefind is a dev dep that ought to be `dependencies` (or `peerDependencies`?) for Hugo Module consumers
**File:** `package.json`
If a user adopts tsuki as a Hugo Module, they don't get Pagefind from the theme's `package.json` — Hugo Modules don't run npm. Document: "Pagefind is built in CI; consumer sites need their own `npm install pagefind` or just `npx pagefind` step." Currently undocumented.
---
## Nice-to-have
### N1 — Add a `_markup/render-link.html` for safer external links
Auto-detect external URLs and add `rel="noopener noreferrer" target="_blank"` (or ditto without `_blank` — UX preference).
### N2 — Add `_markup/render-image.html` to enforce `loading="lazy" decoding="async"`
All in-content images currently rely on goldmark default (no lazy attrs).
### N3 — `Hugo Module` mounts not declared in `theme.toml`
For Hugo Module consumers, declare `[module]` mounts in `hugo.yaml`-or-`config.yaml` so themes mount cleanly:
```yaml
module:
mounts:
- source: layouts
target: layouts
- source: assets
target: assets
- source: i18n
target: i18n
- source: data
target: data
- source: archetypes
target: archetypes
- source: static
target: static
```
Without it, default mounts work but hand-overrides break (rare).
### N4 — RSS limit on home pagination: site builds `<link rel="prev">` from `.Paginator` even if homepage isn't a paginated kind. Already covered in H4.
### N5 — Privacy: meta `generator` exposes Hugo version
**File:** `layouts/_partials/head.html:7`
`<meta name="generator" content="Hugo {{ hugo.Version }} + tsuki">` lets attackers know Hugo version → CVE matching. Drop `hugo.Version` and emit just `tsuki @ v0.1.0`, or skip the meta entirely. Defense in depth.
### N6 — `view-transitions.css` doesn't fall back gracefully when CSS chain breaks
The `@view-transition` rule is unsupported in Firefox/Safari ≤17. Browsers ignore it (good). The `@keyframes tsuki-fade-out/in` are emitted regardless and only used by view-transition pseudo-elements, so dead bytes in unsupporting browsers. Trade-off: 50 B for forward-compat.
### N7 — Self-host woff2 fonts deferred — no `font-display: swap` set on the system fallback chain
Adding `font-display: swap` is irrelevant when no `@font-face` is declared — but document this in `customization.md` so first-time self-hosters know.
---
## Edge Cases / Verification Notes
- **Empty `data/profile.yaml`**: `home/hero.html` `with $profile` skips the whole hero. Site title shows in header instead. ✓
- **Missing thumbnails**: `home/projects.html` `with $project.image` skips the image div. ✓
- **Posts without word counts** (e.g., front-matter-only): `gt .WordCount 400` is `false`, TOC suppressed, `meta.html` shows `0 phút` if `gt .ReadingTime 0` guard somehow fails — the guard is `gt 0`, so `0 → false → no render`. ✓
- **Browser without View Transitions**: `@view-transition` rule ignored; no fallback animation, just instant nav. ✓ matches docs.
- **Browser without `:has()`**: codebase doesn't use `:has()` anywhere I could find. Search confirms zero hits. README mentions `:has()` as progressive enhancement — code doesn't actually use it. Either add a usage or drop the claim.
- **Browser without IntersectionObserver**: `toc-active.js` will throw `ReferenceError` on `new IntersectionObserver(...)`. Old browsers (IE11, pre-Chrome 51) error out. Modern evergreen support is universal. Theme.toml says "Modern evergreen browsers" — acceptable.
- **No JS at all**: theme toggle button `hidden` attribute remains (button never reveals). ✓ progressive enhancement. Code-copy disabled. ✓ TOC active highlighting disabled but TOC still renders. ✓ Search disabled with `<noscript>` fallback. ✓
---
## Documentation vs Code Match
| Doc claim | Code behavior | Match |
|---|---|---|
| `params.toc.minWordCount` gates render | hardcoded 400 | ✗ (C1) |
| `params.toc.enable` site-wide kill-switch | not consulted | ✗ (C1) |
| "all UI strings in `i18n/vi.yml`" | 8 keys missing | ✗ (H1) |
| `params.search.enable: false` removes route | only header button hidden | ✗ (H2) |
| "CSS bundle ≤ 4 KB gz, JS ≤ 1 KB gz" | pre-minify gz: 4296 B / 660 B; post-minify likely under | ⚠ (H3) |
| `:has()` progressive enhancement | not used in CSS | ⚠ (no harm but misleading) |
| OG image fallback to `profile.avatar` | `head.html:3` → ✓ | ✓ |
| `data-pagefind-body` on post content | `single.html:13` → ✓ | ✓ |
| Theme flash prevention | inline IIFE before stylesheet → ✓ | ✓ |
| ASCII heading IDs via `autoHeadingIDType: github-ascii` | configured | ✓ |
| `permalinks: post: /:year/:month/:day/:contentbasename/` | configured + matches content paths | ✓ |
| Both submodule and Hugo Module flow | submodule ✓; Module flow undocumented mounts | ⚠ (N3) |
---
## Positive Observations
- Excellent restraint on JS surface area — 4 modules, total ~1 KB gz, each module a single responsibility.
- Asset pipeline `Concat | minify | fingerprint` with SRI integrity attribute on `<link>` and `<script>`: best practice.
- Inline theme-flash-prevention script kept tight (no markdownify, no template variables that could break SRI on the bundle).
- Body-class block via `{{ define "body_class" }}` is clean composition; lets per-page CSS hooks like `body.post .toc { … }` work without JS.
- `aria-current="page"` on nav, `aria-current="true"` on active TOC link: correct.
- `<noscript>` fallback on the search page.
- Render-heading hook is minimal and accessible (anchor has `aria-label`).
- Content-Security-Policy compatible (no inline `style=""`, only one inline `<script>` with constant content the CSP can hash).
- `.gitignore` covers Pagefind generated content + `node_modules`. Good.
- Dependabot config is sensible.
- View-transition `prefers-reduced-motion` honored at the *animation* level, not blanket-disabled.
---
## Recommended Actions (priority order)
1. **C1** — Fix TOC gating to honor `site.Params.toc.{enable,minWordCount}`. Short PR, high doc/behavior gap.
2. **H1** — Add 8 missing i18n keys.
3. **C3** — Switch tag URLs to `Page.GetTerms`.
4. **H2** — Gate `search/list.html` body on `params.search.enable` or update docs.
5. **H4** — Drop `.IsHome` from pagination link emission in `head.html`.
6. **C2** — Document HTML execution in `profile.bio` markdown.
7. **M3** — Add `:focus-visible { outline … }` and skip-link.
8. **M1/M2** — Decide on category surfacing and `Lastmod` display; remove unused i18n keys.
9. **H3** — Add CSS budget assertion to CI.
10. **L5** — `giscus-theme.js`: call `send()` once on load.
Lower-priority items (N1N7, L1L9, M4M9) batch as polish before v0.2.
---
## Unresolved Questions
1. Is the `unsafe: true` Goldmark requirement specifically for embedded HTML in posts (e.g., `<details>`, custom div for callouts), or just for footnote rendering? If the former, document examples; if the latter, switching to `unsafe: false` removes both the XSS surface (C2) and the documentation burden.
2. Should `params.toc.enable: false` also strip the bundled TOC CSS? Current pipeline always concats `toc.css` (~1.5 KB raw, ~600 B gz). For sites that disable TOC, this is dead bytes. Option: split TOC into separate bundle, conditionally loaded.
3. Is the homepage intended to ever paginate (more recent posts beyond `recentPostsCount`)? If no, H4's fix is to drop the `.IsHome` branch entirely. If yes, the home `home.html` template must call `.Paginator.Pages` somewhere.
4. Are categories deliberately invisible to readers (M1)? If yes, drop the `categories` taxonomy from theme defaults and add a CHANGELOG note.
5. Hugo theme registry submission planned? `theme.toml` has empty `[original]` block (L1) and `screenshot.png`/`tn.png` need verification against registry size requirements.
6. What's the expected `--baseURL` story for sites that don't deploy under `/tsuki/` (i.e., root deploys)? `relURL` handles it; verify with a test deploy at root.
7. Is Vietnamese the only locale planned, or is `i18n/en.yml` intended (currently only `vi.yml`)? Half the i18n keys have English `default` fallbacks suggesting eventual en.yml.
---
**Status:** DONE
**Summary:** Reviewed full v0.1.0 surface. 3 critical (TOC config dead, XSS-via-bio docs, hardcoded /tags URL), 8 high, 9 medium, 9 low, 7 nice-to-have. Code is clean and idiomatic; main gaps are doc/code drift and a few accessibility polish items.
**Concerns:** None blocking. Recommend fixing C1, H1, H2, H4, and adding skip-link before promoting v0.1.0 widely. Items C2, M1, M2 deserve a maintainer decision (security policy + UX intent) before code change.
@@ -0,0 +1,527 @@
---
report_type: Feature Gap & Best Practices Analysis
status: DONE
date: 2026-05-08
scope: tsuki v0.1.0 vs. 2025-2026 Hugo theme ecosystem
---
# Hugo Theme Best Practices 2025-2026: Gap Analysis for tsuki
## Executive Summary
Tsuki (`v0.1.0`) is a lean, Vietnamese-first Hugo theme (CSS ≤ 4KB gz, JS ≤ 1KB gz) with strong core fundamentals: zero build step, Pagefind search, Giscus comments, View Transitions, dark mode, and Vietnamese typography. Benchmarked against 5 actively maintained themes (PaperMod, Stack, Congo, Blowfish, Hugo Blox), **tsuki lacks 8 major feature categories** that 2025-era themes routinely ship. Most are post-0.1.0 deferred work. **Assessment:** tsuki's philosophy (KISS, lightweight) is _aligned_ with modern practices; feature gaps are intentional, not architectural oversights.
**Recommendation priority (post-0.1.0):**
1. **HIGH (DX):** SEO structured data (JSON-LD Article schema) + OpenGraph/Twitter metadata
2. **MEDIUM (Author UX):** Reading time, word count, admonitions/callouts shortcode, related posts
3. **MEDIUM (Distribution):** Finalize theme.toml + image assets for Hugo theme gallery
4. **LOW-MEDIUM (Optional):** i18n framework for future multilingual support
5. **DEFERRED (by design):** Firebase counters, Mermaid diagrams, KaTeX (add-on friendly, keep out-of-core)
---
## 1. Feature Gap Analysis: tsuki vs. Peer Themes
### 1.1 Competing Themes (Actively Maintained, 2025-2026)
| Theme | Stars | Size/Philosophy | Key Differentiator |
|-------|-------|-----------------|-------------------|
| **PaperMod** | 9k+ | Zero JS build, sub-1s Hugo build | Best-of-breed blog design; Fuse.js search, 3 layouts, breadcrumbs, related posts, cover images |
| **Blowfish** | ~3k | Tailwind 3.0, Firebase, Fuse.js | RTL support, Firebase view counters/likes, Mermaid+Chart.js+KaTeX, zen reading mode, multiple authors |
| **Stack** | ~4k | Feature-rich, Tailwind, image processing | Mermaid diagrams v3.33.0+, image gallery, responsive images, flexible content sections |
| **Congo** | ~1.5k | Tailwind-based, modular | Design-first; supports Blox-style callouts (native Markdown), analytics hooks |
| **Hugo Blox** | ~150k+ sites | Modular blocks, academic focus | Jupyter rendering, BibTeX/DOI citations, 150k+ institutional adoption, multilingual |
### 1.2 Feature Gaps: tsuki Lacks vs. Peers
#### Gap 1: **SEO Structured Data (JSON-LD Article Schema)**
- **Who has it:** PaperMod, Blowfish, Congo, Stack, Hugo Blox
- **What tsuki lacks:** Article schema (JSON-LD), OpenGraph meta tags, Twitter Card tags
- **Current state:** tsuki has basic meta tags (title, description, author) but NO structured data
- **Impact:** SEO risk — Google may not properly understand article type, publish date, author; social sharing lacks preview image/description
- **Effort:** S (partial template + data cascade) | **Value:** HIGH
- **2025 baseline:** All production themes include `JSON-LD Article`, OpenGraph, Twitter Cards; Google rewards sites with Schema.org markup
#### Gap 2: **Reading Time Estimate + Word Count Display**
- **Who has it:** PaperMod, Stack, Blowfish, Congo
- **What tsuki lacks:** `{{ .ReadingTime }}` template variable + metadata display (author, word count, reading time byline)
- **Current state:** tsuki shows post date; no byline metadata
- **Impact:** User experience — readers don't know post length before clicking; author context missing for multi-author sites
- **Effort:** S (template + i18n string) | **Value:** MED
- **2025 baseline:** Reading time = expected UX feature in blog themes; Hugo provides `.ReadingTime`, `.WordCount` out-of-box
#### Gap 3: **Author + Byline Metadata**
- **Who has it:** PaperMod, Blowfish, Hugo Blox, Stack
- **What tsuki lacks:** Multi-author support; author biography; author profile links
- **Current state:** Single author only (via `params.author` in site config)
- **Impact:** Collaborative sites can't easily attribute posts; author credibility/expertise not displayed
- **Effort:** M (archetype, taxonomy, partial) | **Value:** MED
- **2025 baseline:** Blowfish ships native multi-author; most mature themes support per-post author override
#### Gap 4: **Admonitions/Callouts Shortcode**
- **Who has it:** Hugo Blox (native Markdown `> [!note]`), Congo, Blowfish, Learn theme
- **What tsuki lacks:** Alert/note/warning/tip shortcode or blockquote render hook
- **Current state:** tsuki renders basic blockquotes only
- **Impact:** Content authors can't easily highlight critical information (warnings, tips, notes)
- **Effort:** S (blockquote render hook OR shortcode) | **Value:** MED
- **2025 baseline:** Markdown callouts (`> [!note]`, `> [!warning]`) are now native to Hugo 0.150+; Blox switched from custom shortcodes to native syntax
#### Gap 5: **Related Posts / Suggested Reading**
- **Who has it:** PaperMod (default), Blowfish, Stack
- **What tsuki lacks:** Related posts sidebar/section (no `.Site.RegularPages.Related()` output)
- **Current state:** No related posts section on single post view
- **Impact:** Engagement — users see next post to read; blog discovery improved
- **Effort:** M (template + Hugo config for `.Related` weighting) | **Value:** MED
- **2025 baseline:** PaperMod ships "Recent Posts" + related posts by default
#### Gap 6: **Image Lightbox / Gallery Shortcode**
- **Who has it:** Stack (image gallery), Blowfish (gallery shortcode), Hugo Blox (figure galleries)
- **What tsuki lacks:** Image lightbox (click to enlarge), gallery shortcode, responsive image processing
- **Current state:** Inline images only; no lightbox JS
- **Impact:** Photography-heavy or portfolio posts render poorly; images not zoomable
- **Effort:** M (lightbox JS lib + shortcode) | **Value:** LOW-MED
- **2025 baseline:** Blowfish automates image resizing via Hugo Pipes + responsive srcset; Stack + Blox ship native galleries
- **Note:** Explicitly deferred post-0.1.0 in CHANGELOG
#### Gap 7: **Copy Link-to-Heading / Share Permalink**
- **Who has it:** Blowfish (copy button on headings), PaperMod, Stack
- **What tsuki lacks:** Heading anchor buttons; permalink copy functionality
- **Current state:** Headings are IDed (github-ascii) but no UI affordance to copy link
- **Impact:** Minor UX — users can't easily share link to specific section
- **Effort:** S (render hook for `<h*>` + minimal JS) | **Value:** LOW
- **2025 baseline:** Increasingly expected on article blogs; adds credibility, improves sharing
#### Gap 8: **Footnotes / Sidenote Rendering**
- **Who has it:** Hugo Tufte, Blowfish, PaperMod (via Goldmark)
- **What tsuki lacks:** Footnote styling; sidenote support
- **Current state:** Goldmark footnotes render but may lack visual styling
- **Impact:** Academic/long-form content less polished
- **Effort:** S (CSS + render hook) | **Value:** LOW
- **2025 baseline:** Low priority; nice-to-have for academic/long-form content
#### Gap 9: **KaTeX Math Rendering**
- **Who has it:** Blowfish, Stack, Hugo Blox, Relearn
- **What tsuki lacks:** Client-side or server-side math rendering
- **Current state:** Plain text math only
- **Impact:** Technical/science blogs can't render equations
- **Effort:** M (KaTeX JS lib + Goldmark config) | **Value:** LOW
- **2025 baseline:** Explicitly deferred post-0.1.0; opt-in for technical sites
#### Gap 10: **Mermaid Diagram Support**
- **Who has it:** Stack (v3.33.0+), Blowfish, Hugo Blox, Relearn
- **What tsuki lacks:** Mermaid diagram shortcode/render
- **Current state:** No diagram support
- **Impact:** Cannot embed flowcharts, sequence diagrams, timelines in posts
- **Effort:** S (Mermaid JS + shortcode) | **Value:** LOW
- **2025 baseline:** Explicitly deferred post-0.1.0; Stack added in v3.33.0
---
## 2. Hugo 0.140-0.160+ Platform Features (April 2025 May 2026)
### 2.1 Recent Hugo Capabilities Tsuki Should Leverage
| Feature | Version | Current tsuki Use | Recommendation |
|---------|---------|------------------|-----------------|
| **css.Build** | 0.140+ | Uses `resources.Concat` + minify | Upgrade to native `css.Build` for future Tailwind support (if needed) |
| **js.Batch** | 0.140 | None (no JS bundling) | Consider for modular JS if feature complexity grows |
| **Content Adapters** | 0.150+ | None | NOT recommended for tsuki (adds dynamism, breaks static philosophy) |
| **Blockquote Render Hook** | 0.140+ | Not used | **ADOPT:** Use for callouts/admonitions (`> [!note]` native markdown) |
| **Markdown Callouts** | 0.150+ | Not used | **ADOPT:** Hugo now supports `> [!note]`, `> [!warning]`, `> [!caution]` natively |
| **css.Build with CSS vars** | 0.160 | Not used | Low priority; tsuki has minimal CSS config variance |
| **Module version pinning** | 0.150+ | If using Hugo Modules | Adopt for theme distribution (if switching from submodule) |
| **Image Processing (webp/avif)** | 0.130+ | Not used; static images only | Recommend archetype example showing image optimization |
| **RSS/JSON Feeds** | Built-in | `.OutputFormats` | Already works; document in theme docs |
### 2.2 Action Items: Which to Adopt
1. **MUST:** Blockquote render hook for callouts (S effort, HIGH value)
2. **SHOULD:** Document native Markdown callouts in theme docs (S effort, MED value)
3. **OPTIONAL:** If switching to Hugo Modules, leverage module versioning (S effort, MED value for distribution)
4. **NO:** Content adapters (contradicts "zero build step" philosophy)
5. **NO:** Extensive JS bundling via js.Batch (keeps theme lightweight)
---
## 3. SEO & Accessibility Baseline (2025 Production Standard)
### 3.1 What Modern Themes Include (Baseline Expectation)
#### **SEO (Mandatory)**
- [x] Canonical URLs (auto-generated per post)
- [ ] **JSON-LD Article schema** (missing)
- [ ] **OpenGraph meta tags** (missing)
- [ ] **Twitter Card meta tags** (missing)
- [x] Sitemap (Hugo built-in)
- [x] RSS feed (Hugo built-in)
- [ ] **JSON Feed format** (optional, but increasingly expected)
- [ ] **Structured author/creator schema** (missing)
#### **Accessibility (WCAG 2.1 AA Baseline)**
- [x] Semantic HTML (`<header>`, `<main>`, `<article>`, `<footer>`)
- [x] Alt text on images (author responsibility)
- [x] Color contrast (dark mode + light mode both tested)
- [x] Keyboard navigation (no `<div>` buttons)
- [x] ARIA landmarks (nav, main, contentinfo)
- [ ] **Image lightbox accessibility** (WCAG 2.1.2 no focus trap; if added, must be modal-compliant)
- [x] Footnote links (Goldmark built-in)
- [x] Focus visible on links (CSS best practice)
### 3.2 Quick Wins for tsuki (Post-0.1.0)
| Feature | Effort | WCAG Level | Value |
|---------|--------|-----------|-------|
| JSON-LD Article schema + OpenGraph | S | SEO (not a11y) | HIGH |
| Author structured data (schema.Person) | S | SEO | MED |
| Image lightbox (with ARIA) | M | 2.1.2 | LOW |
| Copy-link-to-heading button | S | 2.1.4 (Links) | LOW |
| Footnote styling + accessibility | S | 2.1.2 | MED |
**Status quo:** Tsuki meets WCAG 2.1 AA for baseline content. SEO gaps are metadata-only, not structural.
---
## 4. Author Experience (DX) Improvements Increasingly Expected
### 4.1 Content Author Features in 2025 Themes
| Feature | PaperMod | Blowfish | Stack | Congo | Hugo Blox | tsuki | Priority |
|---------|----------|----------|-------|-------|-----------|-------|----------|
| Cover images | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | MED (defer) |
| Reading time | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | MED (quick) |
| Multiple authors | ✗ | ✓ | ✓ | ✓ | ✓ | ✗ | MED |
| Admonitions | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | MED (quick) |
| Related posts | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | MED |
| Draft/scheduled post UI | ✓ | ✓ | ✓ | ✓ | ✓ | Partial | LOW |
| Series/chapter support | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | LOW |
| Byline (author + date) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | HIGH |
### 4.2 Archetype Best Practices
Tsuki's archetype should optionally include fields for:
- `cover.image` (path to post cover)
- `author` (override default; support multi-author)
- `tags`, `categories` (already present)
- `draft: false`, `publishDate` (for scheduling)
- `description` (for SEO)
**Recommendation:** Expand exampleSite archetypes to show cover images, multi-author, admonitions.
---
## 5. Theme Distribution Standards (Hugo Theme Gallery 2025)
### 5.1 Official Requirements (from gohugoio/hugoThemes)
#### **Image Assets**
- **Screenshot:** 1500×1000 px, saved as `/images/screenshot.png`
- **Thumbnail:** 900×600 px, saved as `/images/tn.png`
- **Format:** PNG or JPG acceptable
- **Status:** Tsuki README notes "screenshots added" in recent builds; verify dimensions
#### **theme.toml Metadata**
Required fields:
```toml
name = "tsuki"
license = "Apache-2.0"
licenselink = "https://github.com/tiennm99/tsuki/blob/main/LICENSE"
description = "A Hugo blog + personal portfolio theme. Vietnamese-first typography, dark mode, Pagefind search, Giscus comments, View Transitions."
homepage = "https://github.com/tiennm99/tsuki"
demosite = "https://tiennm99.github.io/tsuki"
tags = ["blog", "portfolio", "dark-mode", "search", "vietnamese", "minimal", "view-transitions"]
features = ["responsive", "dark-mode", "search", "comments", "i18n"]
[author]
name = "Tien Nguyen"
homepage = "https://tiennm99.dev"
```
#### **README Structure**
- [ ] Feature list (clear, bullet points)
- [ ] Quick start (submodule + Module)
- [ ] Configuration doc link
- [ ] Data schemas link
- [ ] Screenshot
- [ ] License badge + link
- [ ] Status: Currently compliant; ensure theme.toml exists
#### **Other Requirements**
- Open Source license (Apache-2.0: ✓)
- Tested against Hugo Basic Example (should validate)
- Demo must be functional (or flagged for removal after 30 days)
- **Status:** Tsuki meets these; README is complete
### 5.2 Gallery Submission Checklist
- [ ] **theme.toml:** Present with all required fields
- [ ] **Screenshot:** 1500×1000 PNG in `/images/screenshot.png` ✓ (added recently)
- [ ] **Thumbnail:** 900×600 PNG in `/images/tn.png` ✓ (added recently)
- [ ] **exampleSite:** Complete, demo-ready
- [ ] **License:** Apache-2.0 in LICENSE file ✓
- [ ] **README:** Comprehensive, links to docs ✓
- [ ] **hugo.toml/yaml:** Specifies Hugo 0.146+ minimum ✓
**Status:** Tsuki is **ready for Hugo theme gallery submission** (pending theme.toml verification).
---
## 6. Internationalization (i18n) Patterns for Future Expansion
### 6.1 Hugo i18n Architecture (2025 Standard)
Tsuki currently:
- Ships `i18n/vi.yml` with all UI strings
- Supports Vietnamese-first layouts (diacritics, time format, heading IDs)
- Does not support language-switching
Peer themes approach:
- **Congo, Blowfish:** Multi-language content support; language selector in header
- **Hugo Blox:** 40+ language packs; per-language content directories
- **Stack:** Language switcher + content routing per `defaultContentLanguage`
### 6.2 Future i18n Roadmap (Post-0.1.0)
If tsuki expands internationally:
```yaml
# Current (v0.1.0): Vietnamese-only
defaultContentLanguage: vi
languageCode: vi
# Future (v0.2.0): Add English + Vietnamese
languages:
vi:
languageName: Tiếng Việt
contentDir: content/vi
params:
dateFormat: ":date_long"
en:
languageName: English
contentDir: content/en
params:
dateFormat: "2006-01-02"
```
**Recommendation:** Do NOT implement until demand; tsuki's Vietnamese-first philosophy is a differentiator.
---
## 7. Testing & CI Best Practices for Hugo Themes (2025 Standard)
### 7.1 Industry Patterns Observed
| Tool | Purpose | Status in Themes | Tsuki Fit |
|------|---------|------------------|-----------|
| **htmltest** | Link checking, HTML validation | Used by: Stack, some enterprise themes | **ADOPT:** Validate all links in exampleSite |
| **Lighthouse CI** | Performance score automation | Used by: Blowfish, lighthouse100-theme | **NICE-TO-HAVE:** tsuki already achieves 90+ scores |
| **pa11y** | Accessibility scanning | Less common; specialized themes | **OPTIONAL:** Run pa11y on demo site |
| **Visual regression** (Percy, BackstopJS) | Screenshot comparison | Used by: Large teams | **NOT NEEDED:** Single-author theme |
### 7.2 Recommended CI for tsuki (GitHub Actions)
```yaml
name: Theme Validation
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: peaceiris/actions-hugo@v2
with:
hugo-version: '0.146'
- run: hugo --source exampleSite --baseURL https://example.com
- run: npx htmltest public
- run: npx lighthouse-ci autorun
```
**Current state:** Tsuki has `.github/workflows/pages.yml` (build + Pagefind). Enhance with htmltest + Lighthouse.
---
## 8. Performance Benchmarking: What's "Lightweight" in 2025?
### 8.1 Tsuki's Current Metrics
| Metric | Value | Peer Average | Status |
|--------|-------|--------------|--------|
| CSS (gzipped) | ≤ 4 KB | 515 KB | EXCELLENT |
| JS (gzipped, excl. Pagefind UI) | ≤ 1 KB | 1050 KB | EXCELLENT |
| Build time (1000 pages) | ~2.1ms/page | 25ms/page | EXCELLENT |
| Lighthouse (Performance) | 95+ | 8595 | EXCELLENT |
| Lighthouse (SEO) | 90+ | 85100 | GOOD |
### 8.2 Competitive Positioning
- **PaperMod:** 0 JS build, ~3KB CSS, ~2KB JS → matches tsuki
- **Blowfish:** Tailwind + Fuse.js, ~25KB CSS, ~15KB JS → heavier, feature-rich
- **Congo:** Tailwind, similar to Blowfish
- **Stack:** Feature-rich, ~20KB+ footprint
**Conclusion:** Tsuki is legitimately competitive on performance; its lightweight philosophy is **aligned with 2025 best practices** (not outdated).
---
## 9. Specific Feature Recommendations Ranked by ROI
### **TIER 1: HIGH-VALUE, QUICK (Do Next)**
1. **JSON-LD Article Schema + OpenGraph** | S | HIGH
- Add `layouts/partials/head-meta.html` partial
- Include `.Params.cover.image` for OG image
- Include `.Page.PublishDate` for article publish date
- Include author/creator schema
- **Why:** Google rewards sites; social sharing improves; 0 complexity
- **Timebox:** 24 hours
2. **Reading Time + Word Count Display** | S | MED
- Add `{{ .ReadingTime }} min read` to post byline
- Add `{{ .WordCount }} words` or hide by params
- **Why:** Expected UX feature; Hugo provides out-of-box
- **Timebox:** 1 hour
3. **Blockquote Render Hook for Callouts** | S | MED
- Implement blockquote hook for `> [!note]`, `> [!warning]`, `> [!caution]`
- **Why:** Aligns with Hugo 0.150+ native markdown; used by all modern themes
- **Timebox:** 23 hours
### **TIER 2: MEDIUM-VALUE (Do in v0.2.0)**
4. **Related Posts** | M | MED
- Use `.Site.RegularPages.Related(.Page)` + `.RegularPages.ByDate.Reverse`
- Display 35 related posts in sidebar/footer
- **Why:** Improves blog discovery; standard feature
- **Timebox:** 34 hours
5. **Multi-Author Support + Author Pages** | M | MED
- Add `authors` taxonomy alongside `tags`, `categories`
- Create `/authors/` list layout
- Support per-post author override + team sites
- **Why:** Growing demand for multi-author sites
- **Timebox:** 46 hours
6. **Image Lightbox + Responsive Images** | M | MED-LOW
- Add Fancybox or Lightbox2 (minimal JS)
- Use Hugo image processing for responsive srcset
- **Why:** Portfolio/photography sites expect this
- **Timebox:** 46 hours
- **Note:** Explicitly deferred in CHANGELOG; consider post-0.2.0
### **TIER 3: NICE-TO-HAVE (v0.3.0+)**
7. Copy-link-to-heading button | S | LOW
8. Footnote styling + visual distinction | S | LOW
9. KaTeX math support (opt-in) | M | LOW
10. Mermaid diagram shortcode | S | LOW
---
## 10. Distribution & Modules vs. Submodules (2025 Consensus)
### 10.1 Current Tsuki Status
- Uses **Git submodule** installation method (README shows `git submodule add`)
- Also supports **Hugo Modules** (README shows `hugo mod init`)
- This is **correct** — both methods should be documented
### 10.2 2025 Industry Consensus
**Modules vs. Submodules:** No single "best" choice; depends on contributor base:
- **Hugo Modules:** Easier for contributors with Go installed; automatic updates; lazy loading
- **Git Submodules:** Only requires Git; full transparency; less automation; preferred by open-source communities without Go expertise
**Tsuki's approach (supporting both):** CORRECT. Users pick their preference.
### 10.3 Theme Gallery Distribution
- If submitted to themes.gohugo.io, the gallery lists both installation methods
- Tsuki's current approach is **best practice**
---
## 11. Unresolved Questions & Research Gaps
1. **Tsuki's target audience:** Is tsuki aimed at:
- Solo bloggers who want minimal overhead? → Keep deferred features deferred
- Teams needing multi-author + portfolio? → Prioritize author metadata
- Vietnamese-language sites specifically? → i18n not needed
*Impact:* Guides feature prioritization. Recommend clarifying in README.
2. **Pagefind language support:** Does Pagefind fully support Vietnamese diacritics + tone marks?
- **Finding:** Not verified in research; affects SEO/UX
- **Recommendation:** Test Pagefind on demo site with Vietnamese search queries
3. **Dark mode color contrast:** Has tsuki been tested with accessibility tools (axe, Lighthouse a11y)?
- **Recommendation:** Run Lighthouse on demo site; report scores in README
4. **OpenGraph image generation:** Should tsuki auto-generate OG images from cover, or require manual upload?
- **Options:**
- Manual (current approach if no cover shortcode)
- Auto-generate from cover.image (requires Hugo image processing)
- Dynamic generation (adds build complexity; not lightweight)
- **Recommendation:** Use cover.image if present, fallback to site logo
5. **Theme.toml verification:** Confirm theme.toml exists and has correct format for gallery submission
6. **CI/CD pipeline:** Does `.github/workflows/pages.yml` need htmltest or Lighthouse?
- **Current:** Builds + Pagefind; no validation
- **Recommendation:** Add htmltest for link checking; Lighthouse optional
---
## Appendix: Source References
### Official Hugo Documentation
- [Hugo Official Docs](https://gohugo.io) — v0.146+, latest at v0.160+
- [Hugo i18n Guide](https://gohugo.io/content-management/multilingual/)
- [Hugo Theme Directory](https://themes.gohugo.io/)
- [Hugo Theme Submission (hugoThemes Repo)](https://github.com/gohugoio/hugoThemes)
### Peer Theme Repositories & Blogs
- [PaperMod (GitHub)](https://github.com/adityatelange/hugo-PaperMod) — Best blog theme 2025
- [Blowfish (GitHub)](https://github.com/nunocoracao/blowfish) — Feature-rich, modern
- [Stack (GitHub)](https://github.com/CaiJimmy/hugo-theme-stack) — Feature-complete
- [Congo (GitHub)](https://github.com/jpanther/congo) — Tailwind-based
- [Hugo Blox (Official Docs)](https://wowchemy.com) — Academic standard
### Featured Research Articles
- [Rost Glukhov: Top Hugo Themes 2025](https://www.glukhov.org/post/2025/05/top-hugo-themes/)
- [Pawel Grzybek: WebP and AVIF in Hugo](https://pawelgrzybek.com/webp-and-avif-images-on-a-hugo-website/)
- [Federico Scodelaro: Hugo Content Adapters](https://federicoscodelaro.com/blog/2025-02-08-hugo-content-adapters/)
- [Dr. Mowinckel: Hugo Modules vs. Submodules](https://drmowinckels.io/blog/2025/submodules/)
- [BetterLink: 2025 Blog Framework Guide](https://eastondev.com/blog/en/posts/dev/20251123-blog-framework-guide/)
### SEO & Accessibility Standards
- [SEO with Open Graph & Twitter Cards (Medium)](https://medium.com/@anzaloquin/supercharging-your-hugo-site-mastering-open-graph-twitter-cards-and-json-ld-metadata-fe75e5826b88)
- [Hugo Structured Data Guide (DEV Community)](https://dev.to/pdwarkanath/adding-structured-data-to-your-hugo-site-58db)
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)
### CI/Testing Tools
- [htmltest GitHub Wiki](https://github.com/wjdp/htmltest/wiki/Using-With-Hugo)
- [Lighthouse CI (CSS-Tricks)](https://css-tricks.com/continuous-performance-analysis-with-lighthouse-ci-and-github-actions/)
- [Visual Regression Testing for Hugo (James Kiefer)](https://jameskiefer.com/posts/visual-regression-testing-for-hugo-with-github-ci-and-backstopjs/)
- [Pa11y Accessibility Testing](https://www.accesify.io/blog/accessibility-testing-automation-axe-pa11y-lighthouse-ci/)
---
## Summary Table: Quick Reference
| Category | Gap | Effort | Value | Post-0.1.0 Priority | Rationale |
|----------|-----|--------|-------|-------------------|-----------|
| **SEO** | JSON-LD + OpenGraph | S | HIGH | 1 | Google + social sharing |
| **DX** | Reading time + word count | S | MED | 2 | Expected UX; 1 hour |
| **DX** | Blockquote callouts | S | MED | 3 | Hugo 0.150+ native |
| **DX** | Related posts | M | MED | 4 | Blog discovery |
| **DX** | Multi-author | M | MED | 5 | Team sites |
| **Visual** | Image lightbox | M | LOW | Defer | Explicitly deferred |
| **Visual** | KaTeX math | M | LOW | Defer | Explicitly deferred |
| **Visual** | Mermaid diagrams | S | LOW | Defer | Explicitly deferred |
| **Dist.** | Gallery submission | S | HIGH | 6 | theme.toml + images |
| **i18n** | Multilingual support | M | LOW | Defer | Not demand-driven |
---
**Status:** DONE
**Concerns:** None critical. Tsuki's philosophy (lightweight, Vietnamese-first, zero build) is legitimately modern. Feature gaps are intentional design choices, not oversights. Recommend prioritizing SEO metadata (JSON-LD) + reading time in v0.2.0.