mirror of
https://github.com/tiennm99/blog.git
synced 2026-10-11 03:13:10 +00:00
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:
1 parent
b9c07dbc98
commit
a45f750783
3 files changed
+137
-3
No files matched your search
@@ -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 -}}
|
||||
Reference in new issue
Block a user