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 -}}