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.
This commit is contained in:
tiennm99 committed 2026-05-09 09:32:25 +07:00
1 parent b9c07dbc98
commit a45f750783
3 files changed
+137 -3

No files matched your search

+47 -3
View File
@@ -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 <meta>, 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.
@@ -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 -}}
@@ -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 */}}
<meta property="og:title" content="{{ $ogTitle }}">
{{- with $desc }}
<meta property="og:description" content="{{ . }}">
{{- end }}
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:type" content="{{ if and .IsPage (eq .Kind "page") }}article{{ else }}website{{ end }}">
<meta property="og:site_name" content="{{ site.Title }}">
{{- with $ogLocale }}
<meta property="og:locale" content="{{ . }}">
{{- end }}
{{- with $img }}
<meta property="og:image" content="{{ . | absURL }}">
{{- end }}
{{- if and .IsPage (eq .Kind "page") .Date }}
<meta property="article:published_time" content="{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}">
{{- with .Lastmod }}
<meta property="article:modified_time" content="{{ .Format "2006-01-02T15:04:05Z07:00" }}">
{{- end }}
{{- with site.Params.author | default (and site.Data.profile site.Data.profile.name) }}
<meta property="article:author" content="{{ . }}">
{{- end }}
{{- range .GetTerms "tags" }}
<meta property="article:tag" content="{{ .LinkTitle }}">
{{- end }}
{{- end }}
{{/* Twitter Card */}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $fullTitle }}">
{{- with $desc }}
<meta name="twitter:description" content="{{ . }}">
{{- end }}
{{- with site.Params.social.twitter }}
<meta name="twitter:site" content="@{{ . }}">
<meta name="twitter:creator" content="@{{ . }}">
{{- end }}
{{- with $img }}
<meta name="twitter:image" content="{{ . | absURL }}">
{{- 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 -}}
<script type="application/ld+json">{{ $ld | jsonify (dict "indent" " ") | safeJS }}</script>
{{- end -}}