From c260d2a9ebc0a3d99b871da90ddd05abd0ed0808 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 9 May 2026 09:32:44 +0700 Subject: [PATCH] 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. --- CHANGELOG.md | 67 +++ .../phase-01-v0.1.1-bugs-and-doc-drift.md | 109 ++++ .../phase-02-accessibility-and-polish.md | 131 +++++ .../phase-03-seo-baseline.md | 144 +++++ .../phase-04-author-ux.md | 164 ++++++ .../phase-05-discovery-features.md | 131 +++++ .../phase-06-distribution-prep.md | 132 +++++ .../phase-07-ci-hardening.md | 116 ++++ .../260508-2305-tsuki-v0.2.0-roadmap/plan.md | 70 +++ ...reviewer-260508-2305-tsuki-v0.1.0-audit.md | 506 +++++++++++++++++ ...r-260508-2306-hugo-theme-best-practices.md | 527 ++++++++++++++++++ 11 files changed, 2097 insertions(+) create mode 100644 plans/260508-2305-tsuki-v0.2.0-roadmap/phase-01-v0.1.1-bugs-and-doc-drift.md create mode 100644 plans/260508-2305-tsuki-v0.2.0-roadmap/phase-02-accessibility-and-polish.md create mode 100644 plans/260508-2305-tsuki-v0.2.0-roadmap/phase-03-seo-baseline.md create mode 100644 plans/260508-2305-tsuki-v0.2.0-roadmap/phase-04-author-ux.md create mode 100644 plans/260508-2305-tsuki-v0.2.0-roadmap/phase-05-discovery-features.md create mode 100644 plans/260508-2305-tsuki-v0.2.0-roadmap/phase-06-distribution-prep.md create mode 100644 plans/260508-2305-tsuki-v0.2.0-roadmap/phase-07-ci-hardening.md create mode 100644 plans/260508-2305-tsuki-v0.2.0-roadmap/plan.md create mode 100644 plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md create mode 100644 plans/reports/researcher-260508-2306-hugo-theme-best-practices.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c32e404..a5767d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 + `
`, 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 `
` (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 `
` and footer for the first time. + +### Changed + +- **``** 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//` 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 ``** 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 + + diff --git a/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-01-v0.1.1-bugs-and-doc-drift.md b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-01-v0.1.1-bugs-and-doc-drift.md new file mode 100644 index 0000000..27486a4 --- /dev/null +++ b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-01-v0.1.1-bugs-and-doc-drift.md @@ -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 `` 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. diff --git a/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-02-accessibility-and-polish.md b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-02-accessibility-and-polish.md new file mode 100644 index 0000000..048757f --- /dev/null +++ b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-02-accessibility-and-polish.md @@ -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 `
`, generator meta change. + +## Related Code Files + +**Create** +- `layouts/_markup/render-link.html` +- `layouts/_markup/render-image.html` + +**Modify** +- `layouts/baseof.html` — add `
+ ``` + Add `id="main"` to `
`. 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") -}}{{- 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" -}} + {{ .Text | safeHTML }} + ``` + Note: deliberately no `target="_blank"` — UX choice; let users opt in. +8. **render-image hook (N2)** — `_markup/render-image.html`: + ```go-html-template + {{ .Text }} + ``` +9. **Generator meta (N5)** — `head.html:7` change to ``. 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 `` +- 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. diff --git a/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-03-seo-baseline.md b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-03-seo-baseline.md new file mode 100644 index 0000000..6eae5e8 --- /dev/null +++ b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-03-seo-baseline.md @@ -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 `` + `<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. diff --git a/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-04-author-ux.md b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-04-author-ux.md new file mode 100644 index 0000000..ddd8623 --- /dev/null +++ b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-04-author-ux.md @@ -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). diff --git a/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-05-discovery-features.md b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-05-discovery-features.md new file mode 100644 index 0000000..9baf6f3 --- /dev/null +++ b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-05-discovery-features.md @@ -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. diff --git a/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-06-distribution-prep.md b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-06-distribution-prep.md new file mode 100644 index 0000000..15440cc --- /dev/null +++ b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-06-distribution-prep.md @@ -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. diff --git a/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-07-ci-hardening.md b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-07-ci-hardening.md new file mode 100644 index 0000000..f69231f --- /dev/null +++ b/plans/260508-2305-tsuki-v0.2.0-roadmap/phase-07-ci-hardening.md @@ -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. diff --git a/plans/260508-2305-tsuki-v0.2.0-roadmap/plan.md b/plans/260508-2305-tsuki-v0.2.0-roadmap/plan.md new file mode 100644 index 0000000..653f3c6 --- /dev/null +++ b/plans/260508-2305-tsuki-v0.2.0-roadmap/plan.md @@ -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 2–4 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 1–6 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 1–2 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 2–7 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? diff --git a/plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md b/plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md new file mode 100644 index 0000000..16489a0 --- /dev/null +++ b/plans/reports/code-reviewer-260508-2305-tsuki-v0.1.0-audit.md @@ -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 ~10–15% and bring it under, but `ResourceMinifier` minify is not lossless to gzip ratios; result could be 3.5–3.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 (N1–N7, L1–L9, M4–M9) 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. diff --git a/plans/reports/researcher-260508-2306-hugo-theme-best-practices.md b/plans/reports/researcher-260508-2306-hugo-theme-best-practices.md new file mode 100644 index 0000000..7f8664c --- /dev/null +++ b/plans/reports/researcher-260508-2306-hugo-theme-best-practices.md @@ -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 | 5–15 KB | EXCELLENT | +| JS (gzipped, excl. Pagefind UI) | ≤ 1 KB | 10–50 KB | EXCELLENT | +| Build time (1000 pages) | ~2.1ms/page | 2–5ms/page | EXCELLENT | +| Lighthouse (Performance) | 95+ | 85–95 | EXCELLENT | +| Lighthouse (SEO) | 90+ | 85–100 | 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:** 2–4 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:** 2–3 hours + +### **TIER 2: MEDIUM-VALUE (Do in v0.2.0)** + +4. **Related Posts** | M | MED + - Use `.Site.RegularPages.Related(.Page)` + `.RegularPages.ByDate.Reverse` + - Display 3–5 related posts in sidebar/footer + - **Why:** Improves blog discovery; standard feature + - **Timebox:** 3–4 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:** 4–6 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:** 4–6 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.