feat: visual layer with vi typography, dark mode, view transitions (Phase 6)

- 9 CSS files (tokens, reset, typography, layout, components, home,
  archive, toc, view-transitions) bundled via resources.Concat
- 3 ES module JS files (theme-toggle, code-copy, toc-active); toc-active
  loaded only on long posts
- @view-transition: navigation auto for cross-page morph (CSS-only)
- TOC partial gated by WordCount>400 and Params.toc!=false; sticky on
  wide viewports, framed block on narrow
- render-heading.html adds cosmetic anchor links
- Hugo asset pipeline: resources.Concat | minify | fingerprint (no
  SCSS, no PostCSS, no Node)
- i18n/vi.yml extended with prev/next/posts/archiveEmpty/toggleTheme
- Bundle sizes: CSS 3.3 KB gz (budget 15), JS 0.8 KB gz (budget 8)

Config: github-ascii heading IDs, :contentbasename permalink token for
clean ASCII URLs, pagination.pagerSize migration (Hugo 0.128+).
This commit is contained in:
2026-05-07 21:27:46 +07:00
parent c30268759f
commit e19bb67c88
23 changed files with 946 additions and 12 deletions
View File
+50
View File
@@ -0,0 +1,50 @@
/* tsuki: archive page — year/month groups */
.archive-year { margin-block-end: var(--tsuki-space-12); }
.archive-year-heading {
font-size: var(--tsuki-fs-2xl);
margin: 0 0 var(--tsuki-space-4);
padding-block-end: var(--tsuki-space-2);
border-block-end: 1px solid var(--tsuki-border);
letter-spacing: -0.02em;
}
.archive-month {
margin-block-end: var(--tsuki-space-6);
display: grid;
grid-template-columns: 6rem 1fr;
gap: var(--tsuki-space-4);
align-items: start;
}
.archive-month-heading {
font-size: var(--tsuki-fs-base);
margin: 0;
color: var(--tsuki-fg-muted);
font-weight: 550;
}
.archive-post-list {
list-style: none;
padding: 0;
margin: 0;
}
.archive-post-item {
display: grid;
grid-template-columns: 3.5rem 1fr;
gap: var(--tsuki-space-3);
padding-block: var(--tsuki-space-2);
align-items: baseline;
}
.archive-post-date {
color: var(--tsuki-fg-subtle);
font-size: var(--tsuki-fs-sm);
font-variant-numeric: tabular-nums;
}
.archive-post-link { color: var(--tsuki-fg); }
.archive-post-link:hover { color: var(--tsuki-accent); }
.archive-empty { color: var(--tsuki-fg-muted); }
@media (max-width: 40rem) {
.archive-month {
grid-template-columns: 1fr;
gap: var(--tsuki-space-2);
}
}
+183
View File
@@ -0,0 +1,183 @@
/* tsuki: components — post cards, post meta, terms, pagination, code copy */
/* post card */
.post-card {
padding-block: var(--tsuki-space-6);
border-block-end: 1px solid var(--tsuki-border);
}
.post-card:last-child { border-block-end: none; }
.post-card-title {
font-size: var(--tsuki-fs-xl);
margin: 0 0 var(--tsuki-space-2);
line-height: var(--tsuki-lh-tight);
}
.post-card-title a { color: var(--tsuki-fg); }
.post-card-title a:hover { color: var(--tsuki-accent); text-decoration: none; }
.post-card-date {
display: block;
color: var(--tsuki-fg-subtle);
font-size: var(--tsuki-fs-sm);
margin-block-end: var(--tsuki-space-2);
}
.post-card-summary {
color: var(--tsuki-fg-muted);
margin: 0;
}
.post-list { list-style: none; padding: 0; margin: 0; }
.post-list li { display: block; }
/* post detail */
.post-header {
margin-block-end: var(--tsuki-space-8);
border-block-end: 1px solid var(--tsuki-border);
padding-block-end: var(--tsuki-space-6);
}
.post-title {
font-size: var(--tsuki-fs-3xl);
margin: 0 0 var(--tsuki-space-3);
line-height: var(--tsuki-lh-tight);
}
.post-description {
color: var(--tsuki-fg-muted);
font-size: var(--tsuki-fs-lg);
margin: var(--tsuki-space-3) 0 0;
}
.post-meta {
display: flex;
flex-wrap: wrap;
gap: var(--tsuki-space-3);
color: var(--tsuki-fg-subtle);
font-size: var(--tsuki-fs-sm);
align-items: baseline;
}
.post-meta .post-date,
.post-meta .reading-time { color: var(--tsuki-fg-subtle); }
.post-meta .post-tags a {
color: var(--tsuki-fg-muted);
margin-inline-end: var(--tsuki-space-1);
}
.post-content { font-size: var(--tsuki-fs-lg); }
.post-content :is(h2, h3, h4) { scroll-margin-block-start: 6rem; }
.post-footer {
margin-block-start: var(--tsuki-space-12);
padding-block-start: var(--tsuki-space-6);
border-block-start: 1px solid var(--tsuki-border);
}
.post-tag-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: var(--tsuki-space-2);
}
.post-tag-list a {
display: inline-block;
padding: var(--tsuki-space-1) var(--tsuki-space-3);
background: var(--tsuki-code-bg);
border-radius: var(--tsuki-radius);
color: var(--tsuki-fg-muted);
font-size: var(--tsuki-fs-sm);
}
.post-tag-list a:hover { color: var(--tsuki-fg); text-decoration: none; }
/* terms list (taxonomy index) */
.terms-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: var(--tsuki-space-3);
}
.term-link {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: var(--tsuki-space-3) var(--tsuki-space-4);
border: 1px solid var(--tsuki-border);
border-radius: var(--tsuki-radius);
color: var(--tsuki-fg);
transition: var(--tsuki-transition);
}
.term-link:hover {
border-color: var(--tsuki-accent);
text-decoration: none;
color: var(--tsuki-accent);
}
.term-name { font-weight: 550; }
.term-count {
color: var(--tsuki-fg-subtle);
font-size: var(--tsuki-fs-sm);
font-variant-numeric: tabular-nums;
}
.term-prefix { color: var(--tsuki-fg-subtle); margin-inline-end: 0.125em; }
/* pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: var(--tsuki-space-3);
margin-block-start: var(--tsuki-space-12);
font-size: var(--tsuki-fs-sm);
}
.pagination-pages {
display: flex;
gap: var(--tsuki-space-2);
list-style: none;
padding: 0;
margin: 0;
}
.pagination a,
.pagination .pagination-current {
display: inline-block;
min-width: 2rem;
padding: var(--tsuki-space-1) var(--tsuki-space-3);
text-align: center;
border-radius: var(--tsuki-radius);
}
.pagination .pagination-current {
background: var(--tsuki-fg);
color: var(--tsuki-bg);
font-weight: 600;
}
.pagination .disabled {
color: var(--tsuki-fg-subtle);
opacity: 0.5;
}
/* code copy button */
.code-copy {
position: absolute;
inset-block-start: var(--tsuki-space-2);
inset-inline-end: var(--tsuki-space-2);
background: var(--tsuki-bg);
border: 1px solid var(--tsuki-border);
border-radius: var(--tsuki-radius);
padding: 0.125rem 0.5rem;
font-size: var(--tsuki-fs-xs);
color: var(--tsuki-fg-muted);
opacity: 0;
transition: var(--tsuki-transition);
}
pre:hover .code-copy,
.code-copy:focus-visible { opacity: 1; }
.code-copy:hover { color: var(--tsuki-fg); }
/* heading anchor */
.heading-anchor {
margin-inline-end: var(--tsuki-space-2);
color: var(--tsuki-fg-subtle);
opacity: 0;
transition: var(--tsuki-transition);
text-decoration: none;
font-weight: 400;
}
:is(h2, h3, h4):hover .heading-anchor,
.heading-anchor:focus-visible { opacity: 1; }
+134
View File
@@ -0,0 +1,134 @@
/* tsuki: homepage — hero, featured projects, recent posts */
.home-hero {
display: grid;
gap: var(--tsuki-space-3);
margin-block-end: var(--tsuki-space-16);
text-align: start;
}
.home-hero-avatar {
width: 7.5rem;
height: 7.5rem;
border-radius: 50%;
margin: 0;
background: var(--tsuki-code-bg);
}
.home-hero-name {
font-size: var(--tsuki-fs-3xl);
margin: var(--tsuki-space-2) 0 0;
letter-spacing: -0.02em;
}
.home-hero-tagline {
margin: 0;
color: var(--tsuki-fg-muted);
font-size: var(--tsuki-fs-lg);
}
.home-hero-bio {
max-width: var(--tsuki-content-width);
color: var(--tsuki-fg);
}
.home-hero-bio p { margin: 0; }
.home-hero-links {
list-style: none;
padding: 0;
margin: var(--tsuki-space-2) 0 0;
display: flex;
flex-wrap: wrap;
gap: var(--tsuki-space-3);
}
.home-hero-links a {
display: inline-flex;
align-items: center;
gap: var(--tsuki-space-2);
color: var(--tsuki-fg-muted);
font-size: var(--tsuki-fs-sm);
padding: var(--tsuki-space-1) var(--tsuki-space-3);
border: 1px solid var(--tsuki-border);
border-radius: var(--tsuki-radius);
transition: var(--tsuki-transition);
}
.home-hero-links a:hover {
color: var(--tsuki-fg);
border-color: var(--tsuki-fg-muted);
text-decoration: none;
}
.home-hero-links svg { width: 1em; height: 1em; }
/* featured projects */
.home-projects { margin-block-end: var(--tsuki-space-16); }
.home-projects-heading,
.home-recent-heading {
font-size: var(--tsuki-fs-xl);
margin: 0 0 var(--tsuki-space-6);
padding-block-end: var(--tsuki-space-3);
border-block-end: 1px solid var(--tsuki-border);
}
.project-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--tsuki-space-6);
}
.project-card {
display: grid;
gap: var(--tsuki-space-2);
padding: var(--tsuki-space-4);
border: 1px solid var(--tsuki-border);
border-radius: var(--tsuki-radius-lg);
transition: var(--tsuki-transition);
view-transition-name: var(--tsuki-vt-name, none);
}
.project-card:hover {
border-color: var(--tsuki-accent);
box-shadow: var(--tsuki-shadow);
}
.project-card-image {
aspect-ratio: 16 / 9;
border-radius: var(--tsuki-radius);
overflow: hidden;
background: var(--tsuki-code-bg);
}
.project-card-image img { width: 100%; height: 100%; object-fit: cover; }
.project-card-title {
font-size: var(--tsuki-fs-lg);
margin: var(--tsuki-space-2) 0 0;
}
.project-card-tagline {
margin: 0;
color: var(--tsuki-fg-muted);
font-size: var(--tsuki-fs-sm);
}
.project-card-tags {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: var(--tsuki-space-1);
}
.project-card-tags li {
font-size: var(--tsuki-fs-xs);
padding: 0.125rem var(--tsuki-space-2);
background: var(--tsuki-code-bg);
border-radius: var(--tsuki-radius);
color: var(--tsuki-fg-muted);
}
.project-card-links {
list-style: none;
padding: 0;
margin: 0;
display: flex;
gap: var(--tsuki-space-3);
font-size: var(--tsuki-fs-sm);
}
.project-card-links a { color: var(--tsuki-fg-muted); }
.project-card-links a:hover { color: var(--tsuki-accent); }
.home-recent-more {
margin-block-start: var(--tsuki-space-6);
text-align: end;
font-size: var(--tsuki-fs-sm);
}
+95
View File
@@ -0,0 +1,95 @@
/* tsuki: page layout — header, main, footer composition */
body {
display: grid;
grid-template-rows: auto 1fr auto;
}
.site-header,
main,
.site-footer { padding-inline: var(--tsuki-space-6); }
.site-header {
display: flex;
align-items: center;
gap: var(--tsuki-space-4);
flex-wrap: wrap;
max-width: var(--tsuki-wide-width);
margin-inline: auto;
width: 100%;
padding-block: var(--tsuki-space-6);
border-bottom: 1px solid var(--tsuki-border);
}
.site-title {
font-weight: 650;
font-size: var(--tsuki-fs-lg);
letter-spacing: -0.01em;
color: var(--tsuki-fg);
text-decoration: none;
}
.site-title:hover { text-decoration: none; color: var(--tsuki-accent); }
.site-nav { margin-inline-start: auto; }
.site-nav ul {
display: flex;
gap: var(--tsuki-space-4);
list-style: none;
padding: 0;
margin: 0;
}
.site-nav a { color: var(--tsuki-fg-muted); }
.site-nav a:hover { color: var(--tsuki-fg); }
.site-nav a[aria-current="page"] { color: var(--tsuki-fg); font-weight: 550; }
.theme-toggle {
background: transparent;
border: 1px solid var(--tsuki-border);
border-radius: 999px;
width: 2rem;
height: 2rem;
display: inline-grid;
place-items: center;
color: var(--tsuki-fg-muted);
transition: var(--tsuki-transition);
}
.theme-toggle:hover { color: var(--tsuki-fg); border-color: var(--tsuki-fg-muted); }
main {
max-width: var(--tsuki-content-width);
margin-inline: auto;
width: 100%;
padding-block: var(--tsuki-space-12) var(--tsuki-space-16);
}
body.home main,
body.list main { max-width: var(--tsuki-wide-width); }
.site-footer {
max-width: var(--tsuki-wide-width);
margin-inline: auto;
width: 100%;
padding-block: var(--tsuki-space-8);
border-top: 1px solid var(--tsuki-border);
color: var(--tsuki-fg-muted);
font-size: var(--tsuki-fs-sm);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--tsuki-space-3);
}
.site-footer p { margin: 0; }
.list-header {
margin-block-end: var(--tsuki-space-8);
border-block-end: 1px solid var(--tsuki-border);
padding-block-end: var(--tsuki-space-6);
}
.list-title {
font-size: var(--tsuki-fs-2xl);
margin: 0;
}
.list-description {
margin: var(--tsuki-space-2) 0 0;
color: var(--tsuki-fg-muted);
}
+42
View File
@@ -0,0 +1,42 @@
/* tsuki: minimal modern reset */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
font-family: var(--tsuki-font-sans);
background: var(--tsuki-bg);
color: var(--tsuki-fg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
img, picture, svg, video, canvas {
display: block;
max-width: 100%;
height: auto;
}
button { font: inherit; cursor: pointer; }
a { color: var(--tsuki-accent); text-decoration: none; }
a:hover { color: var(--tsuki-accent-hover); text-decoration: underline; }
::selection { background: var(--tsuki-selection-bg); }
ul, ol { padding-inline-start: 1.25rem; }
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+67
View File
@@ -0,0 +1,67 @@
/* tsuki: table of contents — sticky on wide, collapsible on narrow */
.toc {
font-size: var(--tsuki-fs-sm);
color: var(--tsuki-fg-muted);
}
.toc-heading {
font-size: var(--tsuki-fs-sm);
font-weight: 600;
margin: 0 0 var(--tsuki-space-3);
color: var(--tsuki-fg);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.toc nav ul {
list-style: none;
padding: 0;
margin: 0;
}
.toc nav ul ul {
padding-inline-start: var(--tsuki-space-3);
margin-block-start: var(--tsuki-space-1);
}
.toc nav li { margin-block: var(--tsuki-space-1); }
.toc nav a {
display: block;
padding: 0.125rem 0;
color: var(--tsuki-fg-muted);
border-inline-start: 2px solid transparent;
padding-inline-start: var(--tsuki-space-3);
margin-inline-start: calc(-1 * var(--tsuki-space-3));
transition: var(--tsuki-transition);
}
.toc nav a:hover { color: var(--tsuki-fg); text-decoration: none; }
.toc nav a[aria-current="true"] {
color: var(--tsuki-accent);
border-inline-start-color: var(--tsuki-accent);
font-weight: 550;
}
/* wide layout: 2-col grid with sticky toc */
@media (min-width: 64rem) {
body.post main {
max-width: var(--tsuki-wide-width);
display: grid;
grid-template-columns: 1fr 14rem;
gap: var(--tsuki-space-12);
align-items: start;
}
body.post .post { min-width: 0; }
body.post .toc {
position: sticky;
inset-block-start: var(--tsuki-space-6);
max-block-size: calc(100vh - var(--tsuki-space-12));
overflow-y: auto;
}
}
/* narrow: framed block above content */
@media (max-width: 63.99rem) {
.toc {
margin-block-end: var(--tsuki-space-8);
padding: var(--tsuki-space-4);
border: 1px solid var(--tsuki-border);
border-radius: var(--tsuki-radius);
}
}
+74
View File
@@ -0,0 +1,74 @@
/* tsuki: design tokens — colors, type scale, spacing */
:root {
--tsuki-bg: #fbfaf7;
--tsuki-fg: #1a1a1a;
--tsuki-fg-muted: #666;
--tsuki-fg-subtle: #888;
--tsuki-border: #e5e3dd;
--tsuki-accent: #4a6fa5;
--tsuki-accent-hover: #2c4a7a;
--tsuki-code-bg: #f1efe9;
--tsuki-selection-bg: #d4dff0;
--tsuki-shadow: 0 1px 2px rgb(0 0 0 / 0.04), 0 4px 12px rgb(0 0 0 / 0.04);
--tsuki-font-sans: "Inter", "Be Vietnam Pro", system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--tsuki-font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
--tsuki-font-serif: "Source Serif 4", Georgia, "Iowan Old Style", serif;
--tsuki-fs-xs: 0.8125rem;
--tsuki-fs-sm: 0.9375rem;
--tsuki-fs-base: 1rem;
--tsuki-fs-lg: 1.125rem;
--tsuki-fs-xl: 1.375rem;
--tsuki-fs-2xl: 1.75rem;
--tsuki-fs-3xl: 2.25rem;
--tsuki-lh-tight: 1.3;
--tsuki-lh-body: 1.7;
--tsuki-space-1: 0.25rem;
--tsuki-space-2: 0.5rem;
--tsuki-space-3: 0.75rem;
--tsuki-space-4: 1rem;
--tsuki-space-6: 1.5rem;
--tsuki-space-8: 2rem;
--tsuki-space-12: 3rem;
--tsuki-space-16: 4rem;
--tsuki-radius: 0.375rem;
--tsuki-radius-lg: 0.625rem;
--tsuki-content-width: 42rem;
--tsuki-wide-width: 64rem;
--tsuki-transition: 180ms ease;
}
:where([data-theme="dark"]) {
--tsuki-bg: #14151a;
--tsuki-fg: #ececec;
--tsuki-fg-muted: #a0a0a0;
--tsuki-fg-subtle: #777;
--tsuki-border: #2a2c33;
--tsuki-accent: #8ab0ea;
--tsuki-accent-hover: #b1c8f0;
--tsuki-code-bg: #1c1e25;
--tsuki-selection-bg: #2a3a5a;
--tsuki-shadow: 0 1px 2px rgb(0 0 0 / 0.2), 0 4px 12px rgb(0 0 0 / 0.25);
}
@media (prefers-color-scheme: dark) {
:where(:root:not([data-theme="light"])) {
--tsuki-bg: #14151a;
--tsuki-fg: #ececec;
--tsuki-fg-muted: #a0a0a0;
--tsuki-fg-subtle: #777;
--tsuki-border: #2a2c33;
--tsuki-accent: #8ab0ea;
--tsuki-accent-hover: #b1c8f0;
--tsuki-code-bg: #1c1e25;
--tsuki-selection-bg: #2a3a5a;
--tsuki-shadow: 0 1px 2px rgb(0 0 0 / 0.2), 0 4px 12px rgb(0 0 0 / 0.25);
}
}
+67
View File
@@ -0,0 +1,67 @@
/* tsuki: vi-optimized typography */
body {
font-size: var(--tsuki-fs-base);
line-height: var(--tsuki-lh-body);
font-feature-settings: "kern", "liga", "calt", "ss01";
}
h1, h2, h3, h4, h5, h6 {
line-height: var(--tsuki-lh-tight);
font-weight: 650;
letter-spacing: -0.01em;
margin-block: var(--tsuki-space-8) var(--tsuki-space-3);
}
h1 { font-size: var(--tsuki-fs-3xl); }
h2 { font-size: var(--tsuki-fs-2xl); }
h3 { font-size: var(--tsuki-fs-xl); }
h4 { font-size: var(--tsuki-fs-lg); }
p { margin-block: 0 var(--tsuki-space-4); }
blockquote {
margin-inline: 0;
padding-inline-start: var(--tsuki-space-4);
border-inline-start: 3px solid var(--tsuki-border);
color: var(--tsuki-fg-muted);
font-style: italic;
}
code {
font-family: var(--tsuki-font-mono);
font-size: 0.9em;
background: var(--tsuki-code-bg);
padding: 0.125em 0.375em;
border-radius: var(--tsuki-radius);
}
pre {
font-family: var(--tsuki-font-mono);
font-size: var(--tsuki-fs-sm);
line-height: 1.55;
background: var(--tsuki-code-bg);
padding: var(--tsuki-space-4);
border-radius: var(--tsuki-radius);
overflow-x: auto;
position: relative;
}
pre code { background: none; padding: 0; font-size: inherit; }
hr {
border: none;
border-top: 1px solid var(--tsuki-border);
margin-block: var(--tsuki-space-12);
}
table {
width: 100%;
border-collapse: collapse;
margin-block: var(--tsuki-space-6);
}
th, td {
text-align: start;
padding: var(--tsuki-space-2) var(--tsuki-space-3);
border-bottom: 1px solid var(--tsuki-border);
}
th { font-weight: 600; }
+25
View File
@@ -0,0 +1,25 @@
/* tsuki: View Transitions API — same-document navigation morph */
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: no-preference) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 180ms;
animation-timing-function: cubic-bezier(0.2, 0, 0.2, 1);
}
::view-transition-old(root) { animation-name: tsuki-fade-out; }
::view-transition-new(root) { animation-name: tsuki-fade-in; }
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) { animation: none; }
}
@keyframes tsuki-fade-out {
to { opacity: 0; }
}
@keyframes tsuki-fade-in {
from { opacity: 0; }
}
View File
+23
View File
@@ -0,0 +1,23 @@
const COPY = "Sao chép";
const COPIED = "Đã chép";
const FAILED = "Lỗi";
for (const pre of document.querySelectorAll("pre")) {
const code = pre.querySelector("code");
if (!code) continue;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "code-copy";
btn.setAttribute("aria-label", COPY);
btn.textContent = COPY;
pre.appendChild(btn);
btn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(code.innerText);
btn.textContent = COPIED;
} catch {
btn.textContent = FAILED;
}
setTimeout(() => { btn.textContent = COPY; }, 1500);
});
}
+21
View File
@@ -0,0 +1,21 @@
const KEY = "tsuki-theme";
const root = document.documentElement;
const btn = document.querySelector("[data-theme-toggle]");
if (btn) {
btn.hidden = false;
btn.addEventListener("click", () => {
const explicit = root.getAttribute("data-theme");
const current = explicit
|| (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
const next = current === "dark" ? "light" : "dark";
root.setAttribute("data-theme", next);
try { localStorage.setItem(KEY, next); } catch {}
btn.setAttribute("aria-pressed", String(next === "dark"));
});
const stored = (() => { try { return localStorage.getItem(KEY); } catch { return null; } })();
const isDark = stored === "dark"
|| (!stored && matchMedia("(prefers-color-scheme: dark)").matches);
btn.setAttribute("aria-pressed", String(isDark));
}
+32
View File
@@ -0,0 +1,32 @@
const toc = document.querySelector(".toc");
if (toc) {
const links = new Map();
for (const a of toc.querySelectorAll('a[href^="#"]')) {
const id = decodeURIComponent(a.getAttribute("href").slice(1));
if (id) links.set(id, a);
}
const headings = document.querySelectorAll(".post-content :is(h2, h3, h4)[id]");
if (headings.length && links.size) {
const visible = new Set();
const setActive = (id) => {
for (const a of toc.querySelectorAll('a[aria-current="true"]')) a.removeAttribute("aria-current");
const link = links.get(id);
if (link) link.setAttribute("aria-current", "true");
};
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) visible.add(entry.target.id);
else visible.delete(entry.target.id);
}
if (visible.size) {
for (const h of headings) {
if (visible.has(h.id)) { setActive(h.id); break; }
}
}
}, { rootMargin: "-15% 0px -70% 0px", threshold: 0 });
for (const h of headings) observer.observe(h);
}
}
@@ -4,17 +4,55 @@ date: 2025-08-15T09:00:00+07:00
draft: false
tags: ["hugo", "viet"]
categories: ["ghi-chu"]
description: "Một vài ghi chú khi quay lại với Hugo sau thời gian dài."
description: "Một vài ghi chú khi quay lại với Hugo sau thời gian dài: layout convention mới, asset pipeline, render hooks, và ít suy nghĩ về việc giữ một trang web nhỏ."
---
Hugo là một static site generator viết bằng Go. Lần này quay lại sau một khoảng dài, có nhiều thứ đã đổi.
Hugo là một static site generator viết bằng Go. Lần này quay lại sau một khoảng dài, có khá nhiều thứ đã đổi. Bài viết này là một bản ghi chú cá nhân — không phải hướng dẫn — về những điều tôi muốn nhớ.
## Layout convention mới
Từ phiên bản 0.146, Hugo dùng `_partials`, `_markup`, `_shortcodes` cho các thư mục bên trong `layouts/`. Tên thư mục bắt đầu bằng dấu gạch dưới để tách biệt với các kind/section thông thường.
Từ phiên bản 0.146, Hugo dùng `_partials`, `_markup`, `_shortcodes` cho các thư mục bên trong `layouts/`. Tên thư mục bắt đầu bằng dấu gạch dưới để tách biệt với các kind/section thông thường. Trước đây các thư mục này không có gạch dưới và đôi khi gây nhầm lẫn với section của trang.
### Thư mục `_partials`
Chứa các phần tử dùng lại được. Khác với section — section là một kiểu nội dung của trang, partial chỉ là mảnh template.
### Thư mục `_markup`
Chứa các render hook cho markdown — heading, link, image, code block. Lookup tự động khi Hugo render markdown.
### Thư mục `_shortcodes`
Như tên gọi. Shortcodes ở đây sẽ override shortcodes của theme.
## Asset pipeline
Hugo không cần build step ngoài. `resources.Get` lấy file trong `assets/`, `resources.Concat` nối lại, `minify` nén, `fingerprint` thêm hash. Đây là pipeline đầy đủ cho một site nhỏ-vừa.
```yaml
markup:
goldmark:
parser:
autoHeadingIDType: github-ascii
```
Một dòng config nhỏ này giải quyết được vấn đề lớn cho người viết tiếng Việt: heading ID được sinh ra dạng ASCII, ổn định và URL-safe. Không cần render hook tùy biến.
## Render hooks
Render hooks cho phép can thiệp vào quá trình render markdown mà không phải viết extension cho Goldmark. Có hooks cho heading, link, image, code block, và blockquote.
Một use case hữu ích: thêm anchor link trên heading, đánh dấu external link, hoặc lazy-load tất cả image trong post.
## Vài điều giữ lại
- Markdown thuần là tài sản lâu dài.
- Build chậm thì không phải Hugo.
- Theme nhẹ thì site nhẹ.
- Markdown thuần là tài sản lâu dài. Bất kỳ generator nào cũng có thể đọc nó.
- Build chậm thì không phải Hugo. Nếu Hugo build chậm, vấn đề thường nằm ở image processing hoặc resource pipeline.
- Theme nhẹ thì site nhẹ. Mỗi byte trong CSS/JS đều đi qua mạng tới người đọc cuối cùng.
- Tự viết theme là cách hiểu rõ từng dòng. Theme có sẵn tiện nhưng có nhiều thứ ta không cần.
## Đi tiếp
Đề bài cho mấy tháng tới: viết một theme Hugo riêng — nhỏ, không build step, ưu tiên tiếng Việt. Tên là `tsuki`. Tôi đang viết theme này song song với việc ghi chú lại quá trình.
Có thể tháng sau sẽ có thêm vài ghi chú về phần CSS, hoặc về cách xử lý dark mode mà không bị flash trên lần load đầu tiên.
+24
View File
@@ -5,5 +5,29 @@ title: tsuki — example
themesDir: ../..
theme: tsuki
pagination:
pagerSize: 10
path: page
taxonomies:
category: categories
tag: tags
permalinks:
post: /:year/:month/:day/:contentbasename/
markup:
goldmark:
renderer:
unsafe: true
parser:
autoHeadingIDType: github-ascii
highlight:
noClasses: false
lineNos: false
tableOfContents:
startLevel: 2
endLevel: 4
params:
description: Example site for the tsuki Hugo theme.
+6 -3
View File
@@ -1,20 +1,23 @@
languageCode: vi
defaultContentLanguage: vi
paginate: 10
paginatePath: page
pagination:
pagerSize: 10
path: page
taxonomies:
category: categories
tag: tags
permalinks:
post: /:year/:month/:day/:slug/
post: /:year/:month/:day/:contentbasename/
markup:
goldmark:
renderer:
unsafe: true
parser:
autoHeadingIDType: github-ascii
highlight:
noClasses: false
lineNos: false
+18
View File
@@ -40,6 +40,24 @@
- id: nextPage
translation: "Trang tiếp"
- id: prev
translation: "Trước"
- id: next
translation: "Tiếp"
- id: posts
translation: "bài"
- id: archiveEmpty
translation: "Chưa có bài viết."
- id: toggleTheme
translation: "Đổi giao diện"
- id: poweredBy
translation: "Chạy bằng"
- id: noResults
translation: "Không có kết quả."
+6
View File
@@ -0,0 +1,6 @@
{{- $level := .Level -}}
{{- $id := .Anchor -}}
<h{{ $level }} id="{{ $id }}">
<a class="heading-anchor" href="#{{ $id }}" aria-label="Liên kết đến phần này">#</a>
{{ .Text | safeHTML }}
</h{{ $level }}>
+11
View File
@@ -9,3 +9,14 @@
<a href="https://github.com/tiennm99/tsuki" rel="noopener">tsuki</a>
</p>
</footer>
{{- $jsFiles := slice
(resources.Get "js/theme-toggle.js")
(resources.Get "js/code-copy.js")
-}}
{{- $js := $jsFiles | resources.Concat "js/tsuki.bundle.js" | js.Build (dict "minify" true) | fingerprint -}}
<script type="module" src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}" crossorigin="anonymous"></script>
{{- if and (eq .Kind "page") (gt .WordCount 400) (ne .Params.toc false) }}
{{- $tocJs := resources.Get "js/toc-active.js" | js.Build (dict "minify" true) | fingerprint -}}
<script type="module" src="{{ $tocJs.RelPermalink }}" integrity="{{ $tocJs.Data.Integrity }}" crossorigin="anonymous"></script>
{{- end }}
+14 -3
View File
@@ -63,6 +63,17 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="32x32">
{{/* Theme CSS — placeholder; Phase 6 wires resources.Get */}}
{{/* {{ $css := resources.Get "css/tsuki.css" | minify | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}"> */}}
{{/* Theme CSS — concatenated, minified, fingerprinted (no build step required) */}}
{{- $cssFiles := slice
(resources.Get "css/tokens.css")
(resources.Get "css/reset.css")
(resources.Get "css/typography.css")
(resources.Get "css/layout.css")
(resources.Get "css/components.css")
(resources.Get "css/home.css")
(resources.Get "css/archive.css")
(resources.Get "css/toc.css")
(resources.Get "css/view-transitions.css")
-}}
{{- $css := $cssFiles | resources.Concat "css/tsuki.bundle.css" | minify | fingerprint -}}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" crossorigin="anonymous">
+6
View File
@@ -0,0 +1,6 @@
{{- with .TableOfContents -}}
<aside class="toc" aria-labelledby="toc-heading">
<h2 id="toc-heading" class="toc-heading">{{ i18n "tableOfContents" | default "Mục lục" }}</h2>
{{ . }}
</aside>
{{- end -}}
+4
View File
@@ -24,4 +24,8 @@
{{- end }}
</footer>
</article>
{{- if and (gt .WordCount 400) (ne .Params.toc false) }}
{{ partial "toc.html" . }}
{{- end }}
{{ end }}