From e19bb67c88da8f44870a66e3d76ec91f7f46f0dc Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 7 May 2026 21:27:46 +0700 Subject: [PATCH] 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+). --- assets/css/.gitkeep | 0 assets/css/archive.css | 50 +++++ assets/css/components.css | 183 ++++++++++++++++++ assets/css/home.css | 134 +++++++++++++ assets/css/layout.css | 95 +++++++++ assets/css/reset.css | 42 ++++ assets/css/toc.css | 67 +++++++ assets/css/tokens.css | 74 +++++++ assets/css/typography.css | 67 +++++++ assets/css/view-transitions.css | 25 +++ assets/js/.gitkeep | 0 assets/js/code-copy.js | 23 +++ assets/js/theme-toggle.js | 21 ++ assets/js/toc-active.js | 32 +++ .../post/2025/08/15/ghi-chu-hugo/index.md | 50 ++++- exampleSite/hugo.yaml | 24 +++ hugo.yaml | 9 +- i18n/vi.yml | 18 ++ layouts/_markup/render-heading.html | 6 + layouts/_partials/footer.html | 11 ++ layouts/_partials/head.html | 17 +- layouts/_partials/toc.html | 6 + layouts/single.html | 4 + 23 files changed, 946 insertions(+), 12 deletions(-) delete mode 100644 assets/css/.gitkeep create mode 100644 assets/css/archive.css create mode 100644 assets/css/components.css create mode 100644 assets/css/home.css create mode 100644 assets/css/layout.css create mode 100644 assets/css/reset.css create mode 100644 assets/css/toc.css create mode 100644 assets/css/tokens.css create mode 100644 assets/css/typography.css create mode 100644 assets/css/view-transitions.css delete mode 100644 assets/js/.gitkeep create mode 100644 assets/js/code-copy.js create mode 100644 assets/js/theme-toggle.js create mode 100644 assets/js/toc-active.js create mode 100644 layouts/_markup/render-heading.html create mode 100644 layouts/_partials/toc.html diff --git a/assets/css/.gitkeep b/assets/css/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/css/archive.css b/assets/css/archive.css new file mode 100644 index 0000000..b4e4064 --- /dev/null +++ b/assets/css/archive.css @@ -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); + } +} diff --git a/assets/css/components.css b/assets/css/components.css new file mode 100644 index 0000000..83a09b2 --- /dev/null +++ b/assets/css/components.css @@ -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; } diff --git a/assets/css/home.css b/assets/css/home.css new file mode 100644 index 0000000..5466696 --- /dev/null +++ b/assets/css/home.css @@ -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); +} diff --git a/assets/css/layout.css b/assets/css/layout.css new file mode 100644 index 0000000..97eea77 --- /dev/null +++ b/assets/css/layout.css @@ -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); +} diff --git a/assets/css/reset.css b/assets/css/reset.css new file mode 100644 index 0000000..7eed8de --- /dev/null +++ b/assets/css/reset.css @@ -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; + } +} diff --git a/assets/css/toc.css b/assets/css/toc.css new file mode 100644 index 0000000..2dc2b70 --- /dev/null +++ b/assets/css/toc.css @@ -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); + } +} diff --git a/assets/css/tokens.css b/assets/css/tokens.css new file mode 100644 index 0000000..4ccaf57 --- /dev/null +++ b/assets/css/tokens.css @@ -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); + } +} diff --git a/assets/css/typography.css b/assets/css/typography.css new file mode 100644 index 0000000..7b03ac7 --- /dev/null +++ b/assets/css/typography.css @@ -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; } diff --git a/assets/css/view-transitions.css b/assets/css/view-transitions.css new file mode 100644 index 0000000..21050fe --- /dev/null +++ b/assets/css/view-transitions.css @@ -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; } +} diff --git a/assets/js/.gitkeep b/assets/js/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/js/code-copy.js b/assets/js/code-copy.js new file mode 100644 index 0000000..792a50e --- /dev/null +++ b/assets/js/code-copy.js @@ -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); + }); +} diff --git a/assets/js/theme-toggle.js b/assets/js/theme-toggle.js new file mode 100644 index 0000000..5591dfe --- /dev/null +++ b/assets/js/theme-toggle.js @@ -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)); +} diff --git a/assets/js/toc-active.js b/assets/js/toc-active.js new file mode 100644 index 0000000..44102ed --- /dev/null +++ b/assets/js/toc-active.js @@ -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); + } +} diff --git a/exampleSite/content/post/2025/08/15/ghi-chu-hugo/index.md b/exampleSite/content/post/2025/08/15/ghi-chu-hugo/index.md index fdcd60b..ad7a248 100644 --- a/exampleSite/content/post/2025/08/15/ghi-chu-hugo/index.md +++ b/exampleSite/content/post/2025/08/15/ghi-chu-hugo/index.md @@ -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. diff --git a/exampleSite/hugo.yaml b/exampleSite/hugo.yaml index 1dbdee2..7b265c0 100644 --- a/exampleSite/hugo.yaml +++ b/exampleSite/hugo.yaml @@ -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. diff --git a/hugo.yaml b/hugo.yaml index 3750019..5a4113b 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -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 diff --git a/i18n/vi.yml b/i18n/vi.yml index 4f38c3e..be861dd 100644 --- a/i18n/vi.yml +++ b/i18n/vi.yml @@ -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ả." diff --git a/layouts/_markup/render-heading.html b/layouts/_markup/render-heading.html new file mode 100644 index 0000000..7fa6b56 --- /dev/null +++ b/layouts/_markup/render-heading.html @@ -0,0 +1,6 @@ +{{- $level := .Level -}} +{{- $id := .Anchor -}} + + # + {{ .Text | safeHTML }} + diff --git a/layouts/_partials/footer.html b/layouts/_partials/footer.html index 4fa206d..4597e00 100644 --- a/layouts/_partials/footer.html +++ b/layouts/_partials/footer.html @@ -9,3 +9,14 @@ tsuki

+ +{{- $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 -}} + +{{- 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 -}} + +{{- end }} diff --git a/layouts/_partials/head.html b/layouts/_partials/head.html index c5e357e..aea4b0d 100644 --- a/layouts/_partials/head.html +++ b/layouts/_partials/head.html @@ -63,6 +63,17 @@ -{{/* Theme CSS — placeholder; Phase 6 wires resources.Get */}} -{{/* {{ $css := resources.Get "css/tsuki.css" | minify | fingerprint }} - */}} +{{/* 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 -}} + diff --git a/layouts/_partials/toc.html b/layouts/_partials/toc.html new file mode 100644 index 0000000..81691b5 --- /dev/null +++ b/layouts/_partials/toc.html @@ -0,0 +1,6 @@ +{{- with .TableOfContents -}} + +{{- end -}} diff --git a/layouts/single.html b/layouts/single.html index fc2baa5..2c668d1 100644 --- a/layouts/single.html +++ b/layouts/single.html @@ -24,4 +24,8 @@ {{- end }} + +{{- if and (gt .WordCount 400) (ne .Params.toc false) }} +{{ partial "toc.html" . }} +{{- end }} {{ end }}