From a45f750783c33af02c00af920c4616aa7ea550ec Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 9 May 2026 09:32:25 +0700 Subject: [PATCH] feat(seo): JSON-LD Article schema and OG/Twitter cards Add semantic structured data and social sharing metadata for better discoverability and previews across platforms. --- themes/tsuki/docs/config.md | 50 +++++++++++- .../layouts/_partials/head/og-image.html | 11 +++ themes/tsuki/layouts/_partials/head/seo.html | 79 +++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 themes/tsuki/layouts/_partials/head/og-image.html create mode 100644 themes/tsuki/layouts/_partials/head/seo.html diff --git a/themes/tsuki/docs/config.md b/themes/tsuki/docs/config.md index e629272..bfc596d 100644 --- a/themes/tsuki/docs/config.md +++ b/themes/tsuki/docs/config.md @@ -29,6 +29,18 @@ markup: tableOfContents: startLevel: 2 endLevel: 4 + +related: + threshold: 80 + includeNewer: true + toLower: true + indices: + - name: tags + weight: 100 + - name: categories + weight: 60 + - name: date + weight: 10 ``` ## Theme params @@ -36,6 +48,13 @@ markup: ```yaml params: description: "Site description used in , falls back to site.Title." + author: "Your Name" # used in OG + JSON-LD article author + + social: + twitter: "yourhandle" # adds twitter:site + twitter:creator (no @ prefix) + + og: + fallbackImage: "img/og-default.png" # site-wide OG/Twitter image when no per-post or profile.avatar search: enable: true # mounts /search/ + header search button @@ -44,9 +63,11 @@ params: recentPostsCount: 5 # how many post-cards on the homepage toc: - enable: true # gate at template level (currently informational) + enable: true # site-wide kill switch (per-post `toc: false` still wins) minWordCount: 400 # post must exceed this to render TOC + relatedPostsCount: 3 # number of cards under each post (0 disables — section vanishes) + comments: giscus: enable: false # opt-in @@ -77,13 +98,16 @@ The homepage portfolio reads from data files, **not** `params`: --- title: "Tiêu đề bài viết" date: 2026-05-07T19:00:00+07:00 +lastmod: 2026-05-08T10:00:00+07:00 # shows "Cập nhật {date}" if ≥24h newer than date draft: false description: "Tóm tắt 1-2 câu (dùng cho meta + post-card)." tags: ["hugo", "viet"] categories: ["ghi-chu"] toc: false # opt out of TOC for this post (default: render if WordCount > 400) comments: false # opt out of comments for this post (default: enabled if giscus.enable) -image: "img/cover.jpg" # OG image override +cover: + image: "img/cover.jpg" # OG/Twitter card image (preferred) +image: "img/cover.jpg" # legacy alias for `cover.image`; either works --- ``` @@ -103,6 +127,26 @@ menus: weight: 30 ``` +## SEO output + +Tsuki emits SEO metadata in `_partials/head/seo.html` (called from `head.html`): + +- **OpenGraph** — `og:title`, `og:description`, `og:url`, `og:type`, `og:site_name`, `og:locale`, `og:image`. Posts also get `article:published_time`, `article:modified_time`, `article:author`, one `article:tag` per tag. +- **Twitter Card** — `summary_large_image` with title, description, image; `twitter:site` + `twitter:creator` only when `params.social.twitter` is set. +- **JSON-LD Article schema** — emitted only on single post pages (`IsPage` + `Kind == "page"`); covers `headline`, `url`, `datePublished`, `dateModified`, `author` (Person), `publisher` (Organization), `image`, `description`, `keywords`. +- **OG image resolution** — per-post `cover.image` → per-post `image` (legacy) → `params.og.fallbackImage` → `params.profile.avatar`. The first non-empty value wins. + +To override, drop `_partials/head/seo.html` (and optionally `_partials/head/og-image.html`) into your site `layouts/`. + ## Why theme defaults don't merge -Hugo's config-merging strategy for nested keys (markup, permalinks, pagination) is "none" by default. The theme ships a complete `hugo.yaml` for reference, but consumer sites must duplicate the keys above. See `exampleSite/hugo.yaml` for a working example. +Hugo's config-merging strategy for nested top-level keys (markup, permalinks, pagination, `related`) is "none" by default. `params.*` *does* merge, but anything outside `params:` (including `related:`) does not. The theme ships a complete `hugo.yaml` for reference, but consumer sites must duplicate the keys above. See `exampleSite/hugo.yaml` for a working example. + +## Theme contract: taxonomies and content + +Two conventions the theme assumes; deviating from them produces broken links or unstyled output. + +- **Taxonomy keys** — the singular keys in `taxonomies:` must be `tag` and `category`; the plural names (`tags`, `categories`) are referenced by name in templates (`.GetTerms "tags"`). Renaming the plural breaks tag rendering. Keep the defaults shown above. +- **Tag titles for Vietnamese diacritics** — `.GetTerms "tags"` returns `LinkTitle`, which falls back to the URL slug if no `_index.md` exists for that term. To display `Ghi chú` instead of `ghi-chu`, create `content/tags/ghi-chu/_index.md` with `title: "Ghi chú"`. +- **`params.search.enable: false`** — disables the search route body, header button, and Pagefind UI loader. The route page (`/search/`) still resolves; if you want it to 404 entirely, also delete `content/search/_index.md` from your site. +- **`profile.bio` is trusted-author input** — see [data-schemas.md](data-schemas.md) for the security note. diff --git a/themes/tsuki/layouts/_partials/head/og-image.html b/themes/tsuki/layouts/_partials/head/og-image.html new file mode 100644 index 0000000..23c3d0b --- /dev/null +++ b/themes/tsuki/layouts/_partials/head/og-image.html @@ -0,0 +1,11 @@ +{{- /* + Resolves the OG/Twitter image URL for the current page. + Order: per-post `cover.image` → per-post `image` (legacy) → `params.og.fallbackImage` → `data/profile.yaml: avatar`. + Returns the bare site-relative path; caller applies `absURL`. +*/ -}} +{{- $img := "" -}} +{{- with .Params.cover -}}{{- $img = .image -}}{{- end -}} +{{- if not $img -}}{{- $img = .Params.image -}}{{- end -}} +{{- if not $img -}}{{- $img = site.Params.og.fallbackImage -}}{{- end -}} +{{- if not $img -}}{{- with site.Data.profile -}}{{- $img = .avatar -}}{{- end -}}{{- end -}} +{{- $img -}} diff --git a/themes/tsuki/layouts/_partials/head/seo.html b/themes/tsuki/layouts/_partials/head/seo.html new file mode 100644 index 0000000..da505b7 --- /dev/null +++ b/themes/tsuki/layouts/_partials/head/seo.html @@ -0,0 +1,79 @@ +{{- /* + SEO partial — OpenGraph, Twitter Cards, JSON-LD Article schema. + Override: drop `layouts/_partials/head/seo.html` into your site to replace. +*/ -}} +{{- $ogTitle := cond .IsHome site.Title .Title -}} +{{- $fullTitle := cond .IsHome site.Title (printf "%s · %s" .Title site.Title) -}} +{{- $rawDesc := .Description | default .Summary | default site.Params.description -}} +{{- $desc := "" -}} +{{- with $rawDesc -}}{{- $desc = . | plainify | truncate 200 -}}{{- end -}} +{{- $img := partial "head/og-image.html" . -}} + +{{- /* OG locale: map common Hugo lang codes to OpenGraph IETF-with-region. */ -}} +{{- $ogLocale := site.Params.og.locale | default (index (dict "vi" "vi_VN" "en" "en_US" "ja" "ja_JP" "zh" "zh_CN" "ko" "ko_KR" "fr" "fr_FR" "de" "de_DE") site.Language.Lang) | default site.LanguageCode -}} + +{{/* OpenGraph */}} + +{{- with $desc }} + +{{- end }} + + + +{{- with $ogLocale }} + +{{- end }} +{{- with $img }} + +{{- end }} +{{- if and .IsPage (eq .Kind "page") .Date }} + +{{- with .Lastmod }} + +{{- end }} +{{- with site.Params.author | default (and site.Data.profile site.Data.profile.name) }} + +{{- end }} +{{- range .GetTerms "tags" }} + +{{- end }} +{{- end }} + +{{/* Twitter Card */}} + + +{{- with $desc }} + +{{- end }} +{{- with site.Params.social.twitter }} + + +{{- end }} +{{- with $img }} + +{{- end }} + +{{- /* JSON-LD Article — only on single posts (IsPage + Kind page). */ -}} +{{- if and .IsPage (eq .Kind "page") -}} +{{- $authorName := site.Params.author | default (and site.Data.profile site.Data.profile.name) | default site.Title -}} +{{- $authorURL := site.Params.profile.url | default (and site.Data.profile site.Data.profile.url) | default site.Home.Permalink -}} +{{- $keywords := slice -}} +{{- range .GetTerms "tags" -}}{{- $keywords = $keywords | append .LinkTitle -}}{{- end -}} +{{- $publisher := dict "@type" "Organization" "name" site.Title -}} +{{- with $img }}{{- $publisher = merge $publisher (dict "logo" (dict "@type" "ImageObject" "url" (. | absURL))) -}}{{- end -}} +{{- $ld := dict + "@context" "https://schema.org" + "@type" "Article" + "headline" .Title + "url" .Permalink + "mainEntityOfPage" (dict "@type" "WebPage" "@id" .Permalink) + "datePublished" (.Date.Format "2006-01-02T15:04:05Z07:00") + "dateModified" (.Lastmod.Format "2006-01-02T15:04:05Z07:00") + "author" (dict "@type" "Person" "name" $authorName "url" $authorURL) + "publisher" $publisher +-}} +{{- with $img }}{{- $ld = merge $ld (dict "image" (. | absURL)) -}}{{- end -}} +{{- with $desc }}{{- $ld = merge $ld (dict "description" .) -}}{{- end -}} +{{- if $keywords }}{{- $ld = merge $ld (dict "keywords" $keywords) -}}{{- end -}} + +{{- end -}}