diff --git a/themes/tsuki/assets/css/components.css b/themes/tsuki/assets/css/components.css index dda9d49..650412a 100644 --- a/themes/tsuki/assets/css/components.css +++ b/themes/tsuki/assets/css/components.css @@ -155,10 +155,12 @@ } .pagination a, .pagination .pagination-current { - display: inline-block; - min-width: 2rem; - padding: var(--tsuki-space-1) var(--tsuki-space-3); - text-align: center; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.75rem; + min-height: 2.75rem; + padding-inline: var(--tsuki-space-3); border-radius: var(--tsuki-radius); } .pagination .pagination-current { @@ -167,8 +169,8 @@ font-weight: 600; } .pagination .disabled { - color: var(--tsuki-fg-subtle); - opacity: 0.5; + color: var(--tsuki-fg-muted); + cursor: not-allowed; } /* code copy button */ diff --git a/themes/tsuki/assets/css/layout.css b/themes/tsuki/assets/css/layout.css index 97eea77..b8658ed 100644 --- a/themes/tsuki/assets/css/layout.css +++ b/themes/tsuki/assets/css/layout.css @@ -42,12 +42,55 @@ main, .site-nav a:hover { color: var(--tsuki-fg); } .site-nav a[aria-current="page"] { color: var(--tsuki-fg); font-weight: 550; } +.lang-switcher { + display: flex; + gap: var(--tsuki-space-2); + list-style: none; + padding: 0; + margin: 0; + font-size: var(--tsuki-fs-sm); +} +.lang-switcher a { + color: var(--tsuki-fg-muted); + padding: var(--tsuki-space-1) var(--tsuki-space-2); +} +.lang-switcher a:hover { color: var(--tsuki-fg); } +.lang-switcher a[aria-current="page"] { + color: var(--tsuki-fg); + font-weight: 550; + text-decoration: none; +} + +.site-header-actions { + display: flex; + align-items: center; + gap: var(--tsuki-space-2); + margin-inline-start: var(--tsuki-space-3); +} + +.search-button { + display: inline-grid; + place-items: center; + min-width: 2.5rem; + min-height: 2.5rem; + border: 1px solid var(--tsuki-border); + border-radius: 999px; + color: var(--tsuki-fg-muted); + transition: var(--tsuki-transition); +} +.search-button:hover { + color: var(--tsuki-fg); + border-color: var(--tsuki-fg-muted); + text-decoration: none; +} +.search-button svg { width: 1rem; height: 1rem; } + .theme-toggle { background: transparent; border: 1px solid var(--tsuki-border); border-radius: 999px; - width: 2rem; - height: 2rem; + min-width: 2.5rem; + min-height: 2.5rem; display: inline-grid; place-items: center; color: var(--tsuki-fg-muted); @@ -79,6 +122,7 @@ body.list main { max-width: var(--tsuki-wide-width); } gap: var(--tsuki-space-3); } .site-footer p { margin: 0; } +.site-footer a { display: inline-block; padding-block: var(--tsuki-space-1); } .list-header { margin-block-end: var(--tsuki-space-8); diff --git a/themes/tsuki/assets/css/search.css b/themes/tsuki/assets/css/search.css index 1d01939..edcd2f6 100644 --- a/themes/tsuki/assets/css/search.css +++ b/themes/tsuki/assets/css/search.css @@ -1,30 +1,4 @@ -/* tsuki: search page — Pagefind UI overrides */ - -.site-header-actions { - display: flex; - align-items: center; - gap: var(--tsuki-space-2); - margin-inline-start: var(--tsuki-space-3); -} - -.search-button { - display: inline-grid; - place-items: center; - width: 2rem; - height: 2rem; - border: 1px solid var(--tsuki-border); - border-radius: 999px; - color: var(--tsuki-fg-muted); - transition: var(--tsuki-transition); -} -.search-button:hover { - color: var(--tsuki-fg); - border-color: var(--tsuki-fg-muted); - text-decoration: none; -} -.search-button svg { width: 1rem; height: 1rem; } - -.site-nav { margin-inline-start: auto; } +/* tsuki: search page — Pagefind UI container + token mapping */ .search-container { margin-block: var(--tsuki-space-6); min-block-size: 6rem; } diff --git a/themes/tsuki/assets/css/single-extras.css b/themes/tsuki/assets/css/single-extras.css new file mode 100644 index 0000000..406b125 --- /dev/null +++ b/themes/tsuki/assets/css/single-extras.css @@ -0,0 +1,47 @@ +/* tsuki: per-post extras — breadcrumbs, prev/next, details, code-copy polish, TOC narrow */ + +.breadcrumbs { + margin-block-end: var(--tsuki-space-4); + font-size: var(--tsuki-fs-sm); + color: var(--tsuki-fg-muted); +} +.breadcrumbs ol { + list-style: none; padding: 0; margin: 0; + display: flex; flex-wrap: wrap; gap: var(--tsuki-space-2); +} +.breadcrumbs li + li::before { content: "›"; margin-inline-end: var(--tsuki-space-2); color: var(--tsuki-fg-subtle); } +.breadcrumbs a { color: var(--tsuki-fg-muted); } +.breadcrumbs a:hover { color: var(--tsuki-fg); } +.breadcrumbs [aria-current="page"] { color: var(--tsuki-fg); } + +.prev-next { + display: grid; grid-template-columns: 1fr 1fr; gap: var(--tsuki-space-4); + margin-block-start: var(--tsuki-space-12); + padding-block-start: var(--tsuki-space-6); + border-block-start: 1px solid var(--tsuki-border); +} +.prev-next a { + display: flex; flex-direction: column; gap: var(--tsuki-space-1); + padding: var(--tsuki-space-3); + border: 1px solid var(--tsuki-border); border-radius: var(--tsuki-radius); + color: var(--tsuki-fg); text-decoration: none; min-block-size: 3rem; +} +.prev-next a:hover { border-color: var(--tsuki-fg-muted); } +.prev-next-next { text-align: end; } +.prev-next-label { font-size: var(--tsuki-fs-xs); color: var(--tsuki-fg-muted); } +.prev-next-title { font-weight: 550; } +@media (max-width: 32rem) { + .prev-next { grid-template-columns: 1fr; } + .prev-next-next { text-align: start; } +} + +details { + border: 1px solid var(--tsuki-border); border-radius: var(--tsuki-radius); + padding: var(--tsuki-space-3) var(--tsuki-space-4); + margin-block: var(--tsuki-space-4); +} +details > summary { cursor: pointer; font-weight: 550; } +details[open] > summary { margin-block-end: var(--tsuki-space-3); } + +.code-copy:hover { background: var(--tsuki-code-bg); } +.code-copy[data-state="copied"] { opacity: 1; color: var(--tsuki-accent); border-color: var(--tsuki-accent); } diff --git a/themes/tsuki/assets/css/tokens.css b/themes/tsuki/assets/css/tokens.css index 4ccaf57..5611965 100644 --- a/themes/tsuki/assets/css/tokens.css +++ b/themes/tsuki/assets/css/tokens.css @@ -4,7 +4,7 @@ --tsuki-bg: #fbfaf7; --tsuki-fg: #1a1a1a; --tsuki-fg-muted: #666; - --tsuki-fg-subtle: #888; + --tsuki-fg-subtle: #6b6b6b; --tsuki-border: #e5e3dd; --tsuki-accent: #4a6fa5; --tsuki-accent-hover: #2c4a7a; diff --git a/themes/tsuki/assets/js/code-copy.js b/themes/tsuki/assets/js/code-copy.js index 9c7d20b..e7de689 100644 --- a/themes/tsuki/assets/js/code-copy.js +++ b/themes/tsuki/assets/js/code-copy.js @@ -1,6 +1,6 @@ -const COPY = "Sao chép"; -const COPIED = "Đã chép"; -const FAILED = "Lỗi"; +const dataset = document.documentElement.dataset; +const COPY = dataset.copyCode || "Copy"; +const COPIED = dataset.copiedCode || "Copied"; if (navigator.clipboard) { for (const pre of document.querySelectorAll("pre")) { @@ -16,10 +16,14 @@ if (navigator.clipboard) { try { await navigator.clipboard.writeText(code.innerText); btn.textContent = COPIED; + btn.setAttribute("data-state", "copied"); } catch { - btn.textContent = FAILED; + btn.textContent = COPY; } - setTimeout(() => { btn.textContent = COPY; }, 1500); + setTimeout(() => { + btn.textContent = COPY; + btn.removeAttribute("data-state"); + }, 1500); }); } } diff --git a/themes/tsuki/exampleSite/hugo.yaml b/themes/tsuki/exampleSite/hugo.yaml index 1069cb5..15027b8 100644 --- a/themes/tsuki/exampleSite/hugo.yaml +++ b/themes/tsuki/exampleSite/hugo.yaml @@ -16,6 +16,15 @@ taxonomies: permalinks: post: /:year/:month/:day/:contentbasename/ +outputFormats: + LlmTxt: + mediaType: text/plain + baseName: llm + isPlainText: true + +outputs: + home: [HTML, RSS, LlmTxt] + markup: goldmark: renderer: @@ -47,6 +56,12 @@ params: search: enable: true + breadcrumbs: + enable: true + + prevNextNav: + enable: true + # Giscus comments — uncomment and fill in your repo IDs to enable. # See https://giscus.app to generate values for your repo. # comments: diff --git a/themes/tsuki/i18n/en.yml b/themes/tsuki/i18n/en.yml new file mode 100644 index 0000000..3e4af32 --- /dev/null +++ b/themes/tsuki/i18n/en.yml @@ -0,0 +1,131 @@ +- id: readingTime + translation: "{{ .Count }} min read" + +- id: wordCount + translation: "{{ .Count }} words" + +- id: updatedOn + translation: "Updated" + +- id: search + translation: "Search" + +- id: searchPlaceholder + translation: "Find posts..." + +- id: searchNoScript + translation: "Search requires JavaScript." + +- id: searchDisabled + translation: "Search is currently unavailable on this page." + +- id: clearSearch + translation: "Clear" + +- id: loadMore + translation: "Load more" + +- id: filters + translation: "Filters" + +- id: zeroResults + translation: "No results for [SEARCH_TERM]" + +- id: oneResult + translation: "[COUNT] result for [SEARCH_TERM]" + +- id: manyResults + translation: "[COUNT] results for [SEARCH_TERM]" + +- id: searching + translation: "Searching [SEARCH_TERM]..." + +- id: searchSuggestion + translation: "No results for [SEARCH_TERM]. Try: [DIFFERENT_TERMS]." + +- id: altSearch + translation: "No results for [SEARCH_TERM]. Showing results for [DIFFERENT_TERM]." + +- id: recentPosts + translation: "Recent posts" + +- id: relatedPosts + translation: "Related posts" + +- id: featuredProjects + translation: "Featured projects" + +- id: viewAll + translation: "View all" + +- id: tableOfContents + translation: "Table of contents" + +- id: prev + translation: "Previous" + +- id: next + translation: "Next" + +- id: posts + translation: "posts" + +- id: archiveEmpty + translation: "No posts yet." + +- id: month + translation: "{{ .Number | printf \"Month %d\" }}" + +- id: comments + translation: "Comments" + +- id: toggleTheme + translation: "Toggle theme" + +- id: skipToContent + translation: "Skip to content" + +- id: poweredBy + translation: "Powered by" + +- id: pageNotFound + translation: "Page not found." + +- id: backHome + translation: "Back home" + +- id: calloutNote + translation: "Note" + +- id: calloutTip + translation: "Tip" + +- id: calloutImportant + translation: "Important" + +- id: calloutWarning + translation: "Warning" + +- id: calloutCaution + translation: "Caution" + +- id: linkToSection + translation: "Link to section" + +- id: breadcrumbHome + translation: "Home" + +- id: breadcrumb + translation: "Breadcrumb" + +- id: prevPost + translation: "Previous post" + +- id: nextPost + translation: "Next post" + +- id: copyCode + translation: "Copy" + +- id: copiedCode + translation: "Copied" diff --git a/themes/tsuki/i18n/vi.yml b/themes/tsuki/i18n/vi.yml index ddb6c00..6db9182 100644 --- a/themes/tsuki/i18n/vi.yml +++ b/themes/tsuki/i18n/vi.yml @@ -108,3 +108,24 @@ - id: calloutCaution translation: "Thận trọng" + +- id: linkToSection + translation: "Liên kết đến mục" + +- id: breadcrumbHome + translation: "Trang chủ" + +- id: breadcrumb + translation: "Đường dẫn" + +- id: prevPost + translation: "Bài trước" + +- id: nextPost + translation: "Bài tiếp" + +- id: copyCode + translation: "Sao chép" + +- id: copiedCode + translation: "Đã sao chép" diff --git a/themes/tsuki/layouts/_markup/render-heading.html b/themes/tsuki/layouts/_markup/render-heading.html index 7fa6b56..7f48155 100644 --- a/themes/tsuki/layouts/_markup/render-heading.html +++ b/themes/tsuki/layouts/_markup/render-heading.html @@ -1,6 +1,6 @@ {{- $level := .Level -}} {{- $id := .Anchor -}} - # + # {{ .Text | safeHTML }} diff --git a/themes/tsuki/layouts/_partials/breadcrumbs.html b/themes/tsuki/layouts/_partials/breadcrumbs.html new file mode 100644 index 0000000..248e2c4 --- /dev/null +++ b/themes/tsuki/layouts/_partials/breadcrumbs.html @@ -0,0 +1,34 @@ +{{- /* + Breadcrumbs trail + BreadcrumbList schema.org JSON-LD. + Gated by params.breadcrumbs.enable (default off for backwards compat). + Skipped on home; renders Home → Section → Page for posts, Home → Section for section listings. +*/ -}} +{{- if site.Params.breadcrumbs.enable -}} +{{- if not .IsHome -}} +{{- $home := site.Home -}} +{{- $section := .CurrentSection -}} +{{- $isSection := and $section (ne $section.RelPermalink $home.RelPermalink) (ne $section.RelPermalink .RelPermalink) -}} +{{- $isLeaf := and (eq .Kind "page") $isSection -}} +{{- $homeName := i18n "breadcrumbHome" | default "Home" -}} + +{{- $items := slice (dict "@type" "ListItem" "position" 1 "name" $homeName "item" $home.Permalink) -}} +{{- if $isSection -}} + {{- $items = $items | append (dict "@type" "ListItem" "position" 2 "name" ($section.LinkTitle | default $section.Title) "item" $section.Permalink) -}} +{{- end -}} +{{- if $isLeaf -}} + {{- $items = $items | append (dict "@type" "ListItem" "position" 3 "name" (.LinkTitle | default .Title) "item" .Permalink) -}} +{{- end -}} +{{- $bc := dict "@context" "https://schema.org" "@type" "BreadcrumbList" "itemListElement" $items -}} + +{{- end -}} +{{- end -}} diff --git a/themes/tsuki/layouts/_partials/footer.html b/themes/tsuki/layouts/_partials/footer.html index 90e04de..b271561 100644 --- a/themes/tsuki/layouts/_partials/footer.html +++ b/themes/tsuki/layouts/_partials/footer.html @@ -10,12 +10,12 @@

-{{- $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 -}} - +{{- $themeToggle := resources.Get "js/theme-toggle.js" | js.Build (dict "minify" true) | fingerprint -}} + +{{- if eq .Kind "page" }} +{{- $codeCopy := resources.Get "js/code-copy.js" | js.Build (dict "minify" true) | fingerprint -}} + +{{- end }} {{- if partial "toc-enabled.html" . }} {{- $tocJs := resources.Get "js/toc-active.js" | js.Build (dict "minify" true) | fingerprint -}} diff --git a/themes/tsuki/layouts/_partials/head.html b/themes/tsuki/layouts/_partials/head.html index 7a56f2f..7bac76e 100644 --- a/themes/tsuki/layouts/_partials/head.html +++ b/themes/tsuki/layouts/_partials/head.html @@ -4,6 +4,8 @@ + + {{ $title }} @@ -29,6 +31,14 @@ {{- end }} +{{/* hreflang alternates — emit only on multilingual sites */}} +{{- if hugo.IsMultilingual }} +{{- range .AllTranslations }} + +{{- end }} +{{- with site.Home }}{{- end }} +{{- end }} + {{/* Pagination prev/next for SEO — only on paginated kinds with >1 page */}} {{- if or (eq .Kind "section") (eq .Kind "taxonomy") (eq .Kind "term") }} {{- with .Paginator }} @@ -43,20 +53,51 @@ -{{/* Theme CSS — concatenated, minified, fingerprinted (no build step required) */}} -{{- $cssFiles := slice +{{/* Speculation Rules — Chromium prefetch hints; gated, off by default */}} +{{- $prefetch := site.Params.prefetch -}} +{{- if and $prefetch $prefetch.enable }} + +{{- end }} + +{{/* Preconnect to Giscus on comment-enabled posts to shave third-party DNS+TLS handshake */}} +{{- $giscusCfg := site.Params.comments.giscus -}} +{{- if and (eq .Kind "page") $giscusCfg $giscusCfg.enable $giscusCfg.repo $giscusCfg.repoId $giscusCfg.categoryId (ne .Params.comments false) }} + +{{- end }} + +{{/* Theme CSS — per-page-kind bundles. Core loads everywhere; kind-specific bundles add only when needed. */}} +{{- $coreFiles := 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/search.css") - (resources.Get "css/comments.css") - (resources.Get "css/callouts.css") (resources.Get "css/view-transitions.css") -}} -{{- $css := $cssFiles | resources.Concat "css/tsuki.bundle.css" | minify | fingerprint -}} - +{{- $core := $coreFiles | resources.Concat "css/tsuki.core.css" | minify | fingerprint -}} + + +{{- if .IsHome }} +{{- $home := resources.Get "css/home.css" | minify | fingerprint -}} + +{{- end }} + +{{- if eq .Kind "page" }} +{{- $singleFiles := slice + (resources.Get "css/toc.css") + (resources.Get "css/callouts.css") + (resources.Get "css/comments.css") + (resources.Get "css/single-extras.css") +-}} +{{- $single := $singleFiles | resources.Concat "css/tsuki.single.css" | minify | fingerprint -}} + +{{- end }} + +{{- if eq .Section "archives" }} +{{- $archive := resources.Get "css/archive.css" | minify | fingerprint -}} + +{{- end }} diff --git a/themes/tsuki/layouts/_partials/header.html b/themes/tsuki/layouts/_partials/header.html index a5683f5..919d5a8 100644 --- a/themes/tsuki/layouts/_partials/header.html +++ b/themes/tsuki/layouts/_partials/header.html @@ -4,10 +4,11 @@ {{ partial "nav.html" . }}
+ {{ partial "lang-switcher.html" . }} {{- if site.Params.search.enable | default true }} {{ partial "search-button.html" . }} {{- end }} -
diff --git a/themes/tsuki/layouts/_partials/lang-switcher.html b/themes/tsuki/layouts/_partials/lang-switcher.html new file mode 100644 index 0000000..fd528db --- /dev/null +++ b/themes/tsuki/layouts/_partials/lang-switcher.html @@ -0,0 +1,30 @@ +{{- /* + Inline language switcher. Auto-hidden on single-language sites. + Lists every configured language; marks the active one with aria-current="page". + Falls back to the localised home for languages where the current page has no translation. +*/ -}} +{{- if hugo.IsMultilingual -}} +{{- $currentLang := .Language.Lang -}} +{{- $page := . -}} + +{{- end -}} diff --git a/themes/tsuki/layouts/_partials/prev-next.html b/themes/tsuki/layouts/_partials/prev-next.html new file mode 100644 index 0000000..43b60ec --- /dev/null +++ b/themes/tsuki/layouts/_partials/prev-next.html @@ -0,0 +1,27 @@ +{{- /* + Prev/next post navigation within the same section. + Gated by params.prevNextNav.enable (default true). + Silently skipped when neither a prev nor a next exists. +*/ -}} +{{- if ne (site.Params.prevNextNav.enable | default true) false -}} +{{- $prev := .PrevInSection -}} +{{- $next := .NextInSection -}} +{{- if or $prev $next -}} + +{{- end -}} +{{- end -}} diff --git a/themes/tsuki/layouts/baseof.html b/themes/tsuki/layouts/baseof.html index 77cd36c..a906845 100644 --- a/themes/tsuki/layouts/baseof.html +++ b/themes/tsuki/layouts/baseof.html @@ -1,5 +1,7 @@ - + {{ partial "head.html" . }} {{ block "head_extra" . }}{{ end }} diff --git a/themes/tsuki/layouts/index.llmtxt.txt b/themes/tsuki/layouts/index.llmtxt.txt new file mode 100644 index 0000000..aa4bebc --- /dev/null +++ b/themes/tsuki/layouts/index.llmtxt.txt @@ -0,0 +1,30 @@ +{{- $profile := site.Data.profile -}} +# {{ site.Title }} + +{{ with site.Params.description }}> {{ . }}{{ end }} + +{{- with $profile }} +## About + +{{ with .name }}**{{ . }}**{{ with $profile.tagline }} — {{ . }}{{ end }}{{ end }} + +{{ with .bio }}{{ . }}{{ end }} +{{- end }} + +## Recent posts + +{{- range first 10 (where site.RegularPages "Section" "post") }} +- [{{ .Title }}]({{ .Permalink }}){{ with (.Description | default .Summary | plainify) }}: {{ . }}{{ end }} +{{- end }} + +{{- with site.Data.projects.featured }} + +## Projects + +{{- range . }} +- **{{ .title }}**{{ with .tagline }} — {{ . }}{{ end }}{{ with .repo }} — repo: {{ . }}{{ end }}{{ with .demo }} — demo: {{ . }}{{ end }} +{{- end }} +{{- end }} + +--- +Generated by [tsuki](https://github.com/tiennm99/tsuki) — Hugo blog + portfolio theme. diff --git a/themes/tsuki/layouts/search/list.html b/themes/tsuki/layouts/search/list.html index 2128dfc..2940254 100644 --- a/themes/tsuki/layouts/search/list.html +++ b/themes/tsuki/layouts/search/list.html @@ -1,8 +1,12 @@ {{ define "body_class" }}search{{ end }} {{ define "head_extra" }} +{{- $search := resources.Get "css/search.css" | minify | fingerprint -}} + {{- if site.Params.search.enable | default true }} - +{{- $pagefindCss := "/pagefind/pagefind-ui.css" | relURL }} + + {{- end }} {{ end }} diff --git a/themes/tsuki/layouts/single.html b/themes/tsuki/layouts/single.html index 26c5412..d1dbbc2 100644 --- a/themes/tsuki/layouts/single.html +++ b/themes/tsuki/layouts/single.html @@ -1,6 +1,8 @@ {{ define "body_class" }}post{{ end }} {{ define "main" }} +{{ partial "breadcrumbs.html" . }} +

{{ .Title }}

@@ -29,6 +31,8 @@ {{ partial "toc.html" . }} {{- end }} +{{ partial "prev-next.html" . }} + {{ partial "related-posts.html" . }} {{ partial "comments.html" . }}