From 5001ac8f05b00dfb08fdfb33efc52fb534251d31 Mon Sep 17 00:00:00 2001 From: Nicolas MASSE Date: Sun, 4 Oct 2026 12:00:34 +0200 Subject: [PATCH] improve the descriptions, social images and structured data Descriptions (meta description, Open Graph, Twitter cards, JSON-LD): the description front matter parameter, the site description for the home page, "Articles related to " for the term pages, the page summary truncated to 160 characters, or the site description. The meta description used to be the title of the page. Social images: the images front matter parameter, a *feature*, *cover* or *thumbnail* page resource, the first image of the page content or the default image of the site. The image is converted to JPEG and resized to 1200 px wide max, without cropping, and its dimensions are given in the Open Graph tags. The default image (avatar) uses a small Twitter card. Structured data: BlogPosting for the blog, Article for the talks and publications, with the description, the image and the author. Refs #11 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YNHsWYTXPThURz6TbVS1j3 --- config.yaml | 14 +++++ layouts/_partials/head.html | 42 +++++-------- layouts/_partials/opengraph.html | 82 +++++++++++++++++++++++++ layouts/_partials/page-description.html | 28 +++++++++ layouts/_partials/social-image.html | 76 +++++++++++++++++++++++ layouts/_partials/twitter_cards.html | 36 +++++++++++ 6 files changed, 252 insertions(+), 26 deletions(-) create mode 100644 layouts/_partials/opengraph.html create mode 100644 layouts/_partials/page-description.html create mode 100644 layouts/_partials/social-image.html create mode 100644 layouts/_partials/twitter_cards.html diff --git a/config.yaml b/config.yaml index 65bb6f4..e469c20 100644 --- a/config.yaml +++ b/config.yaml @@ -16,6 +16,8 @@ languages: en: weight: 1 label: English + params: + description: "Nicolas Massé's website: articles, talks and publications about Kubernetes, OpenShift, API Management, Single Sign-On and homelab." menu: main: - name: "Articles" @@ -42,6 +44,8 @@ languages: fr: weight: 2 label: Français + params: + description: "Le site de Nicolas Massé : articles, conférences et publications sur Kubernetes, OpenShift, l'API Management, le Single Sign-On et le homelab." menu: main: - name: "Articles" @@ -70,6 +74,12 @@ languages: # has not been translated (french files take precedence over english files). module: mounts: + # Default assets mount, plus the images of the static directory so that + # they can be processed by Hugo (social images). + - source: assets + target: assets + - source: static/images + target: assets/images - source: content/english target: content sites: @@ -99,6 +109,10 @@ taxonomies: params: enablePlausible: false + # Used in the structured data (JSON-LD) + author: + name: "Nicolas Massé" + url: "https://www.linkedin.com/in/nicolasmasse/" images: - /images/avatar-2022-12.webp diff --git a/layouts/_partials/head.html b/layouts/_partials/head.html index adb4bf1..0e56a8e 100644 --- a/layouts/_partials/head.html +++ b/layouts/_partials/head.html @@ -30,19 +30,7 @@ -- Ensure the tag is never empty since it is used -- in the SEO score. --> -{{- if .IsHome }} -{{- with .Site.Params.description }} - -{{- else }} - -{{- end }} -{{- else }} -{{- with .Params.description }} - -{{- else }} - -{{- end }} -{{- end }} + {{ with .OutputFormats.Get "rss" -}} @@ -115,30 +103,32 @@ -- Structured Data can be tested using the Google Search Console. -- https://search.google.com/test/rich-results --> +{{- $structuredData := dict -}} +{{- $description := partialCached "page-description.html" . .Permalink -}} {{- if .IsHome -}} - {{- $.Store.Set "structured_data" (dict "@context" "http://schema.org" "@type" "WebSite" "name" .Site.Title "url" .Site.BaseURL) -}} - {{- with .Site.Params.subtitle -}} - {{- $.Store.SetInMap "structured_data" "description" . -}} + {{- $structuredData = dict "@context" "https://schema.org" "@type" "WebSite" "name" .Site.Title "url" .Permalink "description" $description "inLanguage" .Language.Lang -}} +{{- else if and .IsPage (in (slice "blog" "speaking" "writing") .Section) -}} + {{- $author := dict "@type" "Person" "name" site.Params.author.name "url" site.Params.author.url -}} + {{- $structuredData = dict "@context" "https://schema.org" "@type" (cond (eq .Section "blog") "BlogPosting" "Article") "headline" .Title "description" $description "author" $author "articleSection" .Section -}} + {{- with (partialCached "social-image.html" . .Permalink).image -}} + {{- $structuredData = merge $structuredData (dict "image" (slice .Permalink)) -}} {{- end -}} -{{- else if and .IsPage (eq .Section "blog") -}} - {{- $.Store.Set "structured_data" (dict "@context" "http://schema.org" "@type" "BlogPosting" "headline" .Title "image" (slice) "articleSection" .Section) -}} - {{- if and (eq .Lang "fr") (.Store.Get "en_article_in_fr_site") -}} - {{- $.Store.SetInMap "structured_data" "inLanguage" "en" -}} + {{- if .Store.Get "en_article_in_fr_site" -}} + {{- $structuredData = merge $structuredData (dict "inLanguage" "en") -}} {{- range first 1 .Translations -}} - {{- $.Store.SetInMap "structured_data" "mainEntityOfPage" (dict "@type" "WebPage" "@id" .Permalink) -}} + {{- $structuredData = merge $structuredData (dict "mainEntityOfPage" (dict "@type" "WebPage" "@id" .Permalink)) -}} {{- end -}} {{- else -}} - {{- $.Store.SetInMap "structured_data" "inLanguage" .Language.Lang -}} - {{- $.Store.SetInMap "structured_data" "mainEntityOfPage" (dict "@type" "WebPage" "@id" .Permalink) -}} + {{- $structuredData = merge $structuredData (dict "inLanguage" .Language.Lang "mainEntityOfPage" (dict "@type" "WebPage" "@id" .Permalink)) -}} {{- end -}} {{- if not .PublishDate.IsZero -}} - {{- $.Store.SetInMap "structured_data" "datePublished" .PublishDate -}} + {{- $structuredData = merge $structuredData (dict "datePublished" .PublishDate) -}} {{- end -}} {{- if and (not .Lastmod.IsZero) (ne .Lastmod .PublishDate) -}} - {{- $.Store.SetInMap "structured_data" "dateModified" .Lastmod -}} + {{- $structuredData = merge $structuredData (dict "dateModified" .Lastmod) -}} {{- end -}} {{- end -}} -{{- with $.Store.Get "structured_data" }} +{{- with $structuredData }} diff --git a/layouts/_partials/opengraph.html b/layouts/_partials/opengraph.html new file mode 100644 index 0000000..2edec20 --- /dev/null +++ b/layouts/_partials/opengraph.html @@ -0,0 +1,82 @@ +{{- /* + -- Based on the Hugo embedded opengraph.html partial (v0.167.0). + -- + -- Changes: the description comes from page-description.html and the image + -- from social-image.html (JPEG, 1200 px wide max). + */ -}} + + +{{- with or site.Title site.Params.title | plainify }} + +{{- end }} + +{{- with or .Title site.Title site.Params.title | plainify }} + +{{- end }} + +{{- with partialCached "page-description.html" . .Permalink }} + +{{- end }} + +{{- with or .Params.locale site.Language.Locale }} + +{{- end }} + +{{- if .IsPage }} + + {{- with .Section }} + + {{- end }} + {{- $ISO8601 := "2006-01-02T15:04:05-07:00" }} + {{- with .PublishDate }} + + {{- end }} + {{- with .Lastmod }} + + {{- end }} + {{- range .GetTerms "tags" | first 6 }} + + {{- end }} +{{- else }} + +{{- end }} + +{{- with (partialCached "social-image.html" . .Permalink).image }} + + + + + +{{- end }} + +{{- with .Params.audio }} + {{- range . | first 6 }} + + {{- end }} +{{- end }} + +{{- with .Params.videos }} + {{- range . | first 6 }} + + {{- end }} +{{- end }} + +{{- range .GetTerms "series" }} + {{- range .Pages | first 7 }} + {{- if ne $ . }} + + {{- end }} + {{- end }} +{{- end }} + +{{- with site.Params.social }} + {{- if reflect.IsMap . }} + {{- with .facebook_app_id }} + + {{- else }} + {{- with .facebook_admin }} + + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/layouts/_partials/page-description.html b/layouts/_partials/page-description.html new file mode 100644 index 0000000..c73c176 --- /dev/null +++ b/layouts/_partials/page-description.html @@ -0,0 +1,28 @@ +{{- /* + -- Returns the description of a page (plain text), used by the meta + -- description, Open Graph, Twitter cards and structured data. + -- + -- In order of preference: + -- 1. the "description" front matter parameter + -- 2. the description of the site, for the home page + -- 3. "Articles related to " for the taxonomy term pages + -- 4. the summary of the page, truncated to ~160 characters + -- 5. the description of the site + -- 6. the title of the page + */ -}} +{{- $description := "" -}} +{{- if .Description -}} + {{- $description = .Description -}} +{{- else if and .IsHome site.Params.description -}} + {{- $description = site.Params.description -}} +{{- else if eq .Kind "term" -}} + {{- $description = printf "%s %s" (i18n "articles_related_to" | strings.TrimSpace) .Title -}} +{{- else -}} + {{- $summary := .Summary | plainify | htmlUnescape | replaceRE `\s+` " " | strings.TrimSpace -}} + {{- with $summary -}} + {{- $description = truncate 160 . -}} + {{- else -}} + {{- $description = or site.Params.description .Title -}} + {{- end -}} +{{- end -}} +{{- return ($description | plainify | htmlUnescape | strings.TrimSpace) -}} diff --git a/layouts/_partials/social-image.html b/layouts/_partials/social-image.html new file mode 100644 index 0000000..945bb1f --- /dev/null +++ b/layouts/_partials/social-image.html @@ -0,0 +1,76 @@ +{{- /* + -- Returns the image used when the page is shared on social networks + -- (Open Graph, Twitter cards) and in the structured data, as a dict: + -- - image: the processed image resource + -- - isDefault: true if the default image of the site is used + -- + -- The image is chosen in this order: + -- 1. the first image of the "images" front matter parameter + -- 2. a page resource named *feature*, *cover* or *thumbnail* + -- 3. the first image displayed in the page content + -- 4. the first image of the "images" site parameter + -- + -- The image is converted to JPEG (WebP is not supported everywhere) and + -- resized to 1200 px wide max, without cropping: the social networks crop + -- it to their own aspect ratio. + */ -}} +{{- $formats := slice "jpeg" "png" "webp" "gif" "bmp" "tiff" -}} +{{- $images := slice -}} +{{- range .Resources.ByType "image" -}} + {{- if in $formats .MediaType.SubType -}} + {{- $images = $images | append . -}} + {{- end -}} +{{- end -}} + +{{- $source := false -}} +{{- $isDefault := false -}} + +{{- /* 1. The "images" front matter parameter */ -}} +{{- range $name := .Params.images -}} + {{- if not $source -}} + {{- range $images -}} + {{- if and (not $source) (eq .Name $name) -}}{{- $source = . -}}{{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} + +{{- /* 2. A page resource named *feature*, *cover* or *thumbnail* */ -}} +{{- if not $source -}} + {{- range $images -}} + {{- if and (not $source) (findRE `(?i)(feature|cover|thumbnail)` .Name) -}}{{- $source = . -}}{{- end -}} + {{- end -}} +{{- end -}} + +{{- /* 3. The first image displayed in the page content */ -}} +{{- if and (not $source) $images -}} + {{- range findRESubmatch `]*\ssrc="([^"]+)"` .Content -}} + {{- $src := index . 1 -}} + {{- if not $source -}} + {{- range $images -}} + {{- if and (not $source) (or (eq .RelPermalink $src) (eq .Permalink $src)) -}}{{- $source = . -}}{{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} + +{{- /* 4. The default image of the site */ -}} +{{- if not $source -}} + {{- with index site.Params.images 0 -}} + {{- with resources.Get (strings.TrimPrefix "/" .) -}} + {{- $source = . -}} + {{- $isDefault = true -}} + {{- end -}} + {{- end -}} +{{- end -}} + +{{- $result := dict -}} +{{- with $source -}} + {{- $image := . -}} + {{- if gt .Width 1200 -}} + {{- $image = .Resize "1200x jpg q85" -}} + {{- else -}} + {{- $image = .Process "jpg q85" -}} + {{- end -}} + {{- $result = dict "image" $image "isDefault" $isDefault -}} +{{- end -}} +{{- return $result -}} diff --git a/layouts/_partials/twitter_cards.html b/layouts/_partials/twitter_cards.html new file mode 100644 index 0000000..85e68df --- /dev/null +++ b/layouts/_partials/twitter_cards.html @@ -0,0 +1,36 @@ +{{- /* + -- Based on the Hugo embedded twitter_cards.html partial (v0.167.0). + -- + -- Changes: the description comes from page-description.html and the image + -- from social-image.html (JPEG, 1200 px wide max). + */ -}} +{{- /* The default image (avatar) is better displayed as a small card. */}} +{{- $social := partialCached "social-image.html" . .Permalink }} +{{- with $social.image }} + + + +{{- else }} + +{{- end }} + +{{- with or .Title site.Title site.Params.title | plainify }} + +{{- end }} + +{{- with partialCached "page-description.html" . .Permalink }} + +{{- end }} + +{{- $twitterSite := "" }} +{{- with site.Params.social }} + {{- if reflect.IsMap . }} + {{- with .twitter }} + {{- $content := . }} + {{- if not (strings.HasPrefix . "@") }} + {{- $content = printf "@%v" . }} + {{- end }} + + {{- end }} + {{- end }} +{{- end }}