Hugo - Strukturierte Schema Daten für eine bessere SEO

Strukturierte Daten ermöglichen Suchmaschinen die Daten auf der Website besser zu verstehen und dadurch die Suchergebnisse für die Nutzer ansprechender darzustellen. Dieser Beitrag erklärt mein Hugo Partial für die Schema.org Typen WebSite und BlogPosting. Mit i18n Einträgen, Erklärung des Sourcecodes und Testen der JSON-LD Struktur.
Beitrag aktualisiert am 03.06.2023
Ich habe diesen Beitrag etwas überarbeitet und an die Hugo-Änderungen der Version 0.112 angepasst.
Allgemeine Informationen zu strukturierten Daten
Google stellt für Entwickler gute Informationen zur Verfügung. Nachfolgend einige Links zum Thema:
- Einführung in das Markup für strukturierte Daten in der Google Suche
- Markup für strukturierte Daten, das von der Google Suche unterstützt wird
Die von Google, Microsoft, Yahoo und Yandex gegründeten Schema.org-Vokabularien für strukturierte Daten werden in einem offenen Gemeinschaftsprozess entwickelt und von den Suchmaschinen entsprechend berücksichtigt.
- Schema.org - Vollständige Schema Hierarchie
- Schema.org - Schema Typ WebSite
- Schema.org - Schema Typ BlogPosting
Bedanken möchte ich mich bei Ashish Lahoti. Sein Blogbeitrag - Add Structure Data JSON-LD in Hugo Website Pages - hat mir beim programmieren meines Partial sehr geholfen.
Relevante hugo.toml Einträge
Nachfolgend die im Schema benutzten Einträge in der Konfigurationsdatei von Hugo:
[author]
name = "Frank Kunert"
license = "Frank Kunert"
..
[languages]
[languages.de]
languageName = "Deutsch"
weight = 1
title = "Hugo, Webdev, SEO, Tools"
[languages.de.params]
description = "Blog über Hugo, Webdev, SEO, Tools"
mydomain = "tekki-tipps.de 🇩🇪"
..
[languages.en]
languageName = "English"
weight = 2
title = "Hugo, Webdev, SEO, Tools"
[languages.en.params]
description = "Blog about Hugo, Webdev, SEO, Tools"
mydomain = "tekki-tipps.de/en/ 🇬🇧"
..
Die Einträge in der hugo.toml
wurden von mir nachträglich an die Hugo-Version 0.112 angepasst.
Relevante Front Matter Einträge
Folgende Front Matter Einträge werden im Partial site-schema.html verwendet:
title: "Hugo - Strukturierte Schema Daten für eine bessere SEO"
description: "Hugo Partial für die Schema.org Typen WebSite und Blog mit i18n Einträgen. Erklärung des Sourcecodes und Testen der JSON-LD Struktur. Für eine bessere SEO."
subtitle: "Schema.org Typ WebSite und Blog - strukturierte Daten für eine bessere SEO meiner Hugo Website."
author: "Frank Kunert"
Partial site-schema.html
Das Partial wird im head
Tag der baseof.html
eingebunden:
<!DOCTYPE html>
<html lang="{{- .Site.Language.Lang -}}">
<head>
<meta charset="UTF-8">
...
...
{{ partial "site-schema.html" . }}
</head>
<body>
</body>
</html>
Ein Partial für verschiedene Bereiche der Website:
- Startseite inklusive der Pagination Seiten - page/2, page/3 …
- Tag-Listen inklusive der Pagination Seiten - page/2, page/3 …
- Blog Webseiten
Der Sourcecode von themes/tekki/layouts/partials/site-schema.html
sieht wie folgt aus:
{{ if or ( .IsHome ) ( not .Description ) -}}
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "WebSite",
"name": "{{ .Site.Params.mydomain }}",
"url": "{{ printf "%s%s" .Site.BaseURL (substr .Paginator.URL 1) }}",
{{ if .IsHome }}
"description": "{{ .Site.Params.description }}",
{{ else }}
{{ if eq .Site.Language.Lang "de" }}
{{ $u := urls.Parse .Permalink }}
{{ $u := strings.TrimRight "/" $u.Path }}
{{ $u := substr $u 6 }}
"description": "Tag {{ $u }} - {{ .Site.Params.description }}",
{{ else }}
{{ $u := urls.Parse .Permalink }}
{{ $u := strings.TrimRight "/" $u.Path }}
{{ $u := substr $u 9 }}
"description": "Tag {{ $u }} - {{ .Site.Params.description }}",
{{ end }}
{{ end }}
"inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
"thumbnailUrl": "{{ "/icon-144.png" | absURL }}",
"image": {
"@type": "ImageObject",
"@id": "{{ "/tekki-tipps-og.png" | absURL }}",
"url": "{{ "/tekki-tipps-og.png" | absURL }}",
"height": "630",
"width": "1200"
},
"license": "{{ .Site.Author.license }}"
}
</script>
{{ else if .IsPage }}
{{ if eq .Section "blog" }}
{{ $author := or (.Params.author) (.Site.Author.name) }}
{{ $org_name := .Site.Params.mydomain }}
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "BlogPosting",
"articleSection": "{{ .Section }}",
"name": "{{ .Title | safeJS }}",
"headline": "{{ .Title | safeJS }}",
"alternativeHeadline": "{{ .Params.subtitle }}",
"description": "{{if .IsPage}}{{ .Summary | markdownify }}{{ else }}{{ if .Description }}{{ .Description | safeJS }}{{ end }}{{ end }}",
"inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
"isFamilyFriendly": "true",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ .Permalink }}"
},
"author" : {
"@type": "Person",
"name": "{{ $author }}",
{{ with .Site.GetPage "/ueber-mich" -}}"url": "{{- .Permalink -}}"{{ end }}
},
"creator" : {
"@type": "Person",
"name": "{{ $author }}"
},
"accountablePerson" : {
"@type": "Person",
"name": "{{ $author }}"
},
"copyrightHolder": "{{ $org_name }}",
"copyrightYear": "{{ .Date.Format "2006" }}",
"dateCreated": "{{ .Date.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"datePublished": "{{ .PublishDate.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"publisher":{
"@type":"Organization",
"name": {{ $org_name }},
"url": {{ .Permalink }},
"logo": {
"@type": "ImageObject",
"url": "{{ "/icon-144.png" | absURL }}",
"width": "144",
"height": "144"
}
},
{{ with .Resources.GetMatch "article-img" }}{{ $banner := $.Page.Resources.GetMatch "article-img" }}
"image": {
"@type": "ImageObject",
"@id": "{{ $banner.Permalink }}",
"url": "{{ $banner.Permalink }}",
"height": "{{ $banner.Height }}",
"width": "{{ $banner.Width}}"
},
{{ end }}
"url": "{{ .Permalink }}",
"wordCount": "{{ .WordCount }}",
"genre": [ {{ range $index, $tag := .Params.tags }}{{ if $index }}, {{ end }}"{{ $tag }}" {{ end }}]
}
</script>
{{ end }}
{{ end }}
Weiter unten teile ich den Sourcecode nach den einzelnen Bereichen auf und erkläre einige Dinge.
Startseite und Tag-Listen inkl. der Pagination Seiten - page/2 und folgende
{{ if or ( .IsHome ) ( not .Description ) -}}
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "WebSite",
"name": "{{ .Site.Params.mydomain }}",
"url": "{{ printf "%s%s" .Site.BaseURL (substr .Paginator.URL 1) }}",
{{ if .IsHome }}
"description": "{{ .Site.Params.description }}",
{{ else }}
{{/* Tag-Listen */}}
{{ if eq .Site.Language.Lang "de" }}
{{ $u := urls.Parse .Permalink }}
{{ $u := strings.TrimRight "/" $u.Path }}
{{ $u := substr $u 6 }}
"description": "Tag {{ $u }} - {{ .Site.Params.description }}",
{{ else }}
{{ $u := urls.Parse .Permalink }}
{{ $u := strings.TrimRight "/" $u.Path }}
{{ $u := substr $u 9 }}
"description": "Tag {{ $u }} - {{ .Site.Params.description }}",
{{ end }}
{{ end }}
"inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
"thumbnailUrl": "{{ "/icon-144.png" | absURL }}",
"image": {
"@type": "ImageObject",
"@id": "{{ "/tekki-tipps-og.png" | absURL }}",
"url": "{{ "/tekki-tipps-og.png" | absURL }}",
"height": "630",
"width": "1200"
},
"license": "{{ .Site.Author.license }}"
}
</script>
..
In diesem Sourcecode Abschnitt ist auch der Code für die Tag-Listen enthalten. Der einzige Unterschied ist das Erstellen der "description"
. Aber eins nach dem anderen.
Mit {{ if or ( .IsHome ) ( not .Description ) -}}
frage ich ab, ob die zu generierende Seite die Startseite oder Pagination Folgeseite der Startseite ist. Oder ob die Seite keine ( not .Description )
hat. Die Startseite hat auch keine Description, wird aber über .IsHome
erkannt. Die Tag-Listen sind die einzigen Seiten auf meiner Website die ebenfalls keine Description haben. Mit diesem Trick kann ich auf die Tag-Listen zugreifen. Dieses Partial ist ja in der baseof.html
eingebunden, also zu einem ganz frühen Zeitpunkt. Nur dort kann auf das head
HTML-Tag zugegriffen und die json-Datei erstellt werden.
Bei der Zuweisung von "name": "{{ .Site.Params.mydomain }}",
greife ich auf die hugo.toml
und dort auf den Parameter mydomain
zu. Je nach Sprache der aktuellen Webseite wird der Inhalt zugewiesen.
— UPDATE 03.06.2023
"url": "{{ printf “%s%s” .Site.BaseURL (substr .Paginator.URL 1) }}"
weist die aktuelle URL zu, einschließlich der Folgeseitennummer, falls vorhanden. Die URL setzt sich aus den Komponenten .Site.BaseURL
, die sich in der hugo.toml
befindet, und .Paginator.URL
zusammen. (substr .Paginator.URL 1)
entfernt den führenden Backslash aus der .Paginator.URL
. Andernfalls würde dieser doppelt vorkommen.
— Ende UPDATE 03.06.2023
Jetzt kommen wir zur description
. Mit {{ if .IsHome }}
frage ich wieder ab ob die aktuelle Webseite eine Start- oder Folgeseite der Startseite ist. Wenn ja, dann weise ich die sprachspezifische Description aus der hugo.toml
zu.
Wenn nein, ist die aktuelle Webseite eine Tag-Liste oder Folgeseite einer Tag-Liste. Nun wird es etwas komplizierter. Was ich machen möchte ist, vor der allgemeinen Description aus der hugo.toml
das Wort Tag und dann den Tag-Namen mit Bindestrich auszugeben. Dazu muss ich den Tag-Namen aus der URL herausoperieren. Da die URL bei übersetzten Seiten nach dem Muster /en/tags/<tag-name>/
aufgebaut ist muss ich dort 3 Zeichen mehr rausschneiden. Deshalb die Abfrage nach der Language. Mit Scratch
hätte ich noch mehr Code produziert, auch deshalb die Code-Wiederholung.
{{ $u := urls.Parse .Permalink }}
der Variablen $u
wird mit der Hugo Funktion - urls.Parse
- der .Permalink
zugewiesen. {{ $u := strings.TrimRight “/” $u.Path }}
schneidet den letzten /
aus dem String. Und {{ $u := substr $u 6 }}
schneidet die ersten 6 Zeichen in der deutschen Version ab. In der englischen Variante sind es 9 Zeichen. Der restliche Code ist selbsterklärend.
Test-Ergebnis für die Startseite und Tag-Liste
Die Website schema.org stellt einen - Schema Markup Validator - zur Verfügung. Das Test-Ergebnis für die Startseite sieht wie folgt aus:

Für die Tag-Liste sieht das Ergebnis so aus:

Blog Webseiten
Da ich nur meine Blog Webseiten den Suchmaschinen per Schema anbieten möchte, filter ich auch nur diese Seiten durch {{ if eq .Section "blog" }}
raus. Datenschutz und Impressum benötigen meiner Meinung nach kein Schema.
..
{{ else if .IsPage }}
{{ if eq .Section "blog" }}
{{ $author := or (.Params.author) (.Site.Author.name) }}
{{ $org_name := .Site.Params.mydomain }}
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "BlogPosting",
"articleSection": "{{ .Section }}",
"name": "{{ .Title | safeJS }}",
"headline": "{{ .Title | safeJS }}",
"alternativeHeadline": "{{ .Params.subtitle }}",
"description": "{{if .IsPage}}{{ .Summary | markdownify }}{{ else }}{{ if .Description }}{{ .Description | safeJS }}{{ end }}{{ end }}",
"inLanguage": {{ if eq .Site.Language.Lang "de" }}{{"de"}}{{ else }}{{"en"}}{{ end }},
"isFamilyFriendly": "true",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ .Permalink }}"
},
"author" : {
"@type": "Person",
"name": "{{ $author }}",
{{ with .Site.GetPage "/ueber-mich" -}}"url": "{{- .Permalink -}}"{{ end }}
},
"creator" : {
"@type": "Person",
"name": "{{ $author }}"
},
"accountablePerson" : {
"@type": "Person",
"name": "{{ $author }}"
},
"copyrightHolder": "{{ $org_name }}",
"copyrightYear": "{{ .Date.Format "2006" }}",
"dateCreated": "{{ .Date.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"datePublished": "{{ .PublishDate.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"dateModified": "{{ .Lastmod.Format "2006-01-02T15:04:05.00Z" | safeHTML }}",
"publisher":{
"@type":"Organization",
"name": {{ $org_name }},
"url": {{ .Permalink }},
"logo": {
"@type": "ImageObject",
"url": "{{ "/icon-144.png" | absURL }}",
"width": "144",
"height": "144"
}
},
{{ with .Resources.GetMatch "article-img" }}{{ $banner := $.Page.Resources.GetMatch "article-img" }}
"image": {
"@type": "ImageObject",
"@id": "{{ $banner.Permalink }}",
"url": "{{ $banner.Permalink }}",
"height": "{{ $banner.Height }}",
"width": "{{ $banner.Width}}"
},
{{ end }}
"url": "{{ .Permalink }}",
"wordCount": "{{ .WordCount }}",
"genre": [ {{ range $index, $tag := .Params.tags }}{{ if $index }}, {{ end }}"{{ $tag }}" {{ end }}]
}
</script>
{{ end }}
{{ end }}
Da $author
und $org_name
an mehr als einer Stelle benötigt werden, habe ich dafür diese Variablen mit dem entsprechenden Inhalt gefüllt. Jeder Blog Beitrag hat bei mir einen Subtitel. Mit "alternativeHeadline": "{{ .Params.subtitle }}"
weise ich den Subtitel dem Property zu.
Der Description weise ich mit "description": "{{if .IsPage}}{{ .Summary | markdownify }}{{ else }}{{ if .Description }}{{ .Description | safeJS }}{{ end }}{{ end }}"
die Summary des Blog Beitrags zu. Mit | markdownify
filter ich das p
HTML-Tag raus. Falls keine Summary vorhanden - kommt bei mir nicht vor - wird die Description aus dem Front Matter der aktuellen Seite entnommen.
Das Test-Tool von Google hat mich bei dem Property author
darauf hingewiesen, dass eine URL zu einer Webseite über den Autor hinzugefügt werden sollte. Aus diesem Grund habe ich meine "Ich über mich" Webseite auf dieser Site angegeben. Mit {{ with .Site.GetPage "/ueber-mich" -}}"url": "{{- .Permalink -}}"{{ end }}
suche ich die Seite und füge deren Permalink hinzu. Dies funktioniert ebenfalls in der englische Version. "/ueber-mich" ist der Page Bundle Verzeichnisname meiner "Ich über mich" Webseite.
Meinen Blog Beiträgen weise ich im Page Bundle ein Bild zu. Dies hat immer den gleichen Resourcenamen. Mit {{ with .Resources.GetMatch "article-img" }}{{ $banner := $.Page.Resources.GetMatch "article-img" }} .. {{ end }}
hole ich das Bild und weise es dem Property "image":
zu.
Test-Ergebnis für die Blog Webseiten
Das Test-Ergebnis für die Blog Webseiten sieht so aus:

Test der JSON-LD Schema Daten
Neben dem schon weiter oben beschriebenen Schema.org Validator gibt es auch von Google einen entsprechenden Validator für Rich-Suchergebnisse. Schema.org prüft ob die Schemas eingehalten werden. Googles Test für Rich-Suchergebnisse prüft ob die zusätzlichen Kriterien von Google eingehalten werden.
-
Google Search Central - Strukturierte Daten testen
Zuerst wusste ich nicht welchen Validator ich benutzen sollte. Google hat für Blogs keine speziellen Rich-Results. Deshalb ist der Schema.org Validator für die Prüfung besser geeignet. - Schema.org - Schema Markup Validator
- Google - Test für Rich-Suchergebnisse
Auch wenn auf der eigenen Website keine oder nur wenige Rich-Results möglich sind, werden den Suchmaschinen mit den Schemas eine zusätzliche Möglichkeit zur Verfügung gestellt, die Webseite besser zu verstehen. Ob sich das dann auch auf die Suchergebnis-Position auswirkt ist nicht garantiert.
Fazit
Auf den ersten Blick sieht der Partial Sourcecode etwas kompliziert aus, aber bei näherer Betrachtung ist dies gar nicht so schlimm. Die Properties für die einzelnen Schema.org Typen werden auf Schema.org ganz gut erklärt. Erste Auswirkungen habe ich in der Google Suche schon bemerkt. Es wird jetzt teilweise ein kleines Bild neben den Sucheinträgen meiner Blog Beiträge angezeigt.
Linkliste zu diesem Beitrag
- Einführung in das Markup für strukturierte Daten in der Google Suche
- Markup für strukturierte Daten, das von der Google Suche unterstützt wird
- Schema.org - Vollständige Schema Hierarchie
- Schema.org - Schema Typ WebSite
- Schema.org - Schema Typ BlogPosting
- Add Structure Data JSON-LD in Hugo Website Pages
- Hugo Dokumentation - urls.Parse
- Schema.org - Schema Markup Validator
- Google Search Central - Strukturierte Daten testen
- Google - Test für Rich-Suchergebnisse
Das könnte Sie auch interessieren
- SEO - Selbstreferenzierender hreflang auf mehrsprachiger Website
Suchmaschinen die lokalisierten Versionen der eigenen Webseite bekannt machen.
- Hugo - Canonical Pagination Links für Blogbeiträge und Tags
Canonical Links für Pagination Seiten - page/2, page/3 …
Kommentare werden bei deutscher Spracheinstellung nicht in der englischen Variante der Webseite angezeigt und umgekehrt.