Browse Source

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 <term>" 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNHsWYTXPThURz6TbVS1j3
master
Nicolas Massé 1 month ago
parent
commit
5001ac8f05
  1. 14
      config.yaml
  2. 42
      layouts/_partials/head.html
  3. 82
      layouts/_partials/opengraph.html
  4. 28
      layouts/_partials/page-description.html
  5. 76
      layouts/_partials/social-image.html
  6. 36
      layouts/_partials/twitter_cards.html

14
config.yaml

@ -16,6 +16,8 @@ languages:
en: en:
weight: 1 weight: 1
label: English label: English
params:
description: "Nicolas Massé's website: articles, talks and publications about Kubernetes, OpenShift, API Management, Single Sign-On and homelab."
menu: menu:
main: main:
- name: "Articles" - name: "Articles"
@ -42,6 +44,8 @@ languages:
fr: fr:
weight: 2 weight: 2
label: Français 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: menu:
main: main:
- name: "Articles" - name: "Articles"
@ -70,6 +74,12 @@ languages:
# has not been translated (french files take precedence over english files). # has not been translated (french files take precedence over english files).
module: module:
mounts: 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 - source: content/english
target: content target: content
sites: sites:
@ -99,6 +109,10 @@ taxonomies:
params: params:
enablePlausible: false enablePlausible: false
# Used in the structured data (JSON-LD)
author:
name: "Nicolas Massé"
url: "https://www.linkedin.com/in/nicolasmasse/"
images: images:
- /images/avatar-2022-12.webp - /images/avatar-2022-12.webp

42
layouts/_partials/head.html

@ -30,19 +30,7 @@
-- Ensure the <meta name=description> tag is never empty since it is used -- Ensure the <meta name=description> tag is never empty since it is used
-- in the SEO score. -- in the SEO score.
--> -->
{{- if .IsHome }} <meta name="description" content="{{ partialCached "page-description.html" . .Permalink }}">
{{- with .Site.Params.description }}
<meta name="description" content="{{ . }}">
{{- else }}
<meta name="description" content="{{ .Title }}">
{{- end }}
{{- else }}
{{- with .Params.description }}
<meta name="description" content="{{ . }}">
{{- else }}
<meta name="description" content="{{ .Title }}">
{{- end }}
{{- end }}
<!-- Add all the relevant RSS Feed URLs --> <!-- Add all the relevant RSS Feed URLs -->
{{ with .OutputFormats.Get "rss" -}} {{ with .OutputFormats.Get "rss" -}}
@ -115,30 +103,32 @@
-- Structured Data can be tested using the Google Search Console. -- Structured Data can be tested using the Google Search Console.
-- https://search.google.com/test/rich-results -- https://search.google.com/test/rich-results
--> -->
{{- $structuredData := dict -}}
{{- $description := partialCached "page-description.html" . .Permalink -}}
{{- if .IsHome -}} {{- if .IsHome -}}
{{- $.Store.Set "structured_data" (dict "@context" "http://schema.org" "@type" "WebSite" "name" .Site.Title "url" .Site.BaseURL) -}} {{- $structuredData = dict "@context" "https://schema.org" "@type" "WebSite" "name" .Site.Title "url" .Permalink "description" $description "inLanguage" .Language.Lang -}}
{{- with .Site.Params.subtitle -}} {{- else if and .IsPage (in (slice "blog" "speaking" "writing") .Section) -}}
{{- $.Store.SetInMap "structured_data" "description" . -}} {{- $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 -}} {{- end -}}
{{- else if and .IsPage (eq .Section "blog") -}} {{- if .Store.Get "en_article_in_fr_site" -}}
{{- $.Store.Set "structured_data" (dict "@context" "http://schema.org" "@type" "BlogPosting" "headline" .Title "image" (slice) "articleSection" .Section) -}} {{- $structuredData = merge $structuredData (dict "inLanguage" "en") -}}
{{- if and (eq .Lang "fr") (.Store.Get "en_article_in_fr_site") -}}
{{- $.Store.SetInMap "structured_data" "inLanguage" "en" -}}
{{- range first 1 .Translations -}} {{- 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 -}} {{- end -}}
{{- else -}} {{- else -}}
{{- $.Store.SetInMap "structured_data" "inLanguage" .Language.Lang -}} {{- $structuredData = merge $structuredData (dict "inLanguage" .Language.Lang "mainEntityOfPage" (dict "@type" "WebPage" "@id" .Permalink)) -}}
{{- $.Store.SetInMap "structured_data" "mainEntityOfPage" (dict "@type" "WebPage" "@id" .Permalink) -}}
{{- end -}} {{- end -}}
{{- if not .PublishDate.IsZero -}} {{- if not .PublishDate.IsZero -}}
{{- $.Store.SetInMap "structured_data" "datePublished" .PublishDate -}} {{- $structuredData = merge $structuredData (dict "datePublished" .PublishDate) -}}
{{- end -}} {{- end -}}
{{- if and (not .Lastmod.IsZero) (ne .Lastmod .PublishDate) -}} {{- if and (not .Lastmod.IsZero) (ne .Lastmod .PublishDate) -}}
{{- $.Store.SetInMap "structured_data" "dateModified" .Lastmod -}} {{- $structuredData = merge $structuredData (dict "dateModified" .Lastmod) -}}
{{- end -}} {{- end -}}
{{- end -}} {{- end -}}
{{- with $.Store.Get "structured_data" }} {{- with $structuredData }}
<script type="application/ld+json"> <script type="application/ld+json">
{{- . | jsonify | safeJS -}} {{- . | jsonify | safeJS -}}
</script> </script>

82
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).
*/ -}}
<meta property="og:url" content="{{ .Permalink }}">
{{- with or site.Title site.Params.title | plainify }}
<meta property="og:site_name" content="{{ . }}">
{{- end }}
{{- with or .Title site.Title site.Params.title | plainify }}
<meta property="og:title" content="{{ . }}">
{{- end }}
{{- with partialCached "page-description.html" . .Permalink }}
<meta property="og:description" content="{{ . }}">
{{- end }}
{{- with or .Params.locale site.Language.Locale }}
<meta property="og:locale" content="{{ replace . `-` `_` }}">
{{- end }}
{{- if .IsPage }}
<meta property="og:type" content="article">
{{- with .Section }}
<meta property="article:section" content="{{ . }}">
{{- end }}
{{- $ISO8601 := "2006-01-02T15:04:05-07:00" }}
{{- with .PublishDate }}
<meta property="article:published_time" {{ .Format $ISO8601 | printf "content=%q" | safeHTMLAttr }}>
{{- end }}
{{- with .Lastmod }}
<meta property="article:modified_time" {{ .Format $ISO8601 | printf "content=%q" | safeHTMLAttr }}>
{{- end }}
{{- range .GetTerms "tags" | first 6 }}
<meta property="article:tag" content="{{ .Page.Title | plainify }}">
{{- end }}
{{- else }}
<meta property="og:type" content="website">
{{- end }}
{{- with (partialCached "social-image.html" . .Permalink).image }}
<meta property="og:image" content="{{ .Permalink }}">
<meta property="og:image:type" content="{{ .MediaType.Type }}">
<meta property="og:image:width" content="{{ .Width }}">
<meta property="og:image:height" content="{{ .Height }}">
<meta property="og:image:alt" content="{{ $.Title | plainify }}">
{{- end }}
{{- with .Params.audio }}
{{- range . | first 6 }}
<meta property="og:audio" content="{{ . | absURL }}">
{{- end }}
{{- end }}
{{- with .Params.videos }}
{{- range . | first 6 }}
<meta property="og:video" content="{{ . | absURL }}">
{{- end }}
{{- end }}
{{- range .GetTerms "series" }}
{{- range .Pages | first 7 }}
{{- if ne $ . }}
<meta property="og:see_also" content="{{ .Permalink }}">
{{- end }}
{{- end }}
{{- end }}
{{- with site.Params.social }}
{{- if reflect.IsMap . }}
{{- with .facebook_app_id }}
<meta property="fb:app_id" content="{{ . }}">
{{- else }}
{{- with .facebook_admin }}
<meta property="fb:admins" content="{{ . }}">
{{- end }}
{{- end }}
{{- end }}
{{- end }}

28
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 <term>" 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) -}}

76
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 `<img[^>]*\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 -}}

36
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 }}
<meta name="twitter:card" content="{{ cond $social.isDefault "summary" "summary_large_image" }}">
<meta name="twitter:image" content="{{ .Permalink }}">
<meta name="twitter:image:alt" content="{{ $.Title | plainify }}">
{{- else }}
<meta name="twitter:card" content="summary">
{{- end }}
{{- with or .Title site.Title site.Params.title | plainify }}
<meta name="twitter:title" content="{{ . }}">
{{- end }}
{{- with partialCached "page-description.html" . .Permalink }}
<meta name="twitter:description" content="{{ . }}">
{{- end }}
{{- $twitterSite := "" }}
{{- with site.Params.social }}
{{- if reflect.IsMap . }}
{{- with .twitter }}
{{- $content := . }}
{{- if not (strings.HasPrefix . "@") }}
{{- $content = printf "@%v" . }}
{{- end }}
<meta name="twitter:site" content="{{ $content }}">
{{- end }}
{{- end }}
{{- end }}
Loading…
Cancel
Save