Γ—
Home RSS feed info
Schema.org type WebSite and BlogPosting - structured data for better SEO of my Hugo website.

Hugo - Structured schema data for better SEO

Hugo - Structured schema data for better SEO

Structured data allows search engines to better understand the data on the website, and thereby make search results more appealing to users. This post explains my Hugo Partial for the Schema.org types WebSite and BlogPosting. With i18n entries, explanation of the source code and testing of the JSON-LD structure.

Post updated on June 3, 2023

I have revised this post a bit and adapted it to the Hugo changes of version 0.112.

General information about structured data

Google provides good information for developers. Below are some links on the topic:

The Schema.org vocabularies for structured data, founded by Google, Microsoft, Yahoo and Yandex, are developed in an open community process and are taken into account by the search engines accordingly.

I would like to thank Ashish Lahoti. His blog post - Add Structure Data JSON-LD in Hugo Website Pages - helped me a lot in programming my partial.

Relevant hugo.toml entries

The following are the entries in the Hugo configuration file used in the schema:

[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/ πŸ‡¬πŸ‡§"
    ..

The entries in the hugo.toml were subsequently adapted by me to the Hugo version 0.112.

Relevant Front Matter entries

The following Front Matter entries are used in the partial site-schema.html:

title: "Hugo - Structured schema data for better SEO"
description: "Hugo Partial for Schema.org types WebSite and Blog with i18n entries. Explanation of the source code and testing of the JSON-LD structure. For better SEO."
subtitle: "Schema.org type WebSite and BlogPosting - structured data for better SEO of my Hugo website."
author: "Frank Kunert"

Partial site-schema.html

The partial is included in the head tag of the baseof.html:

<!DOCTYPE html>
<html lang="{{- .Site.Language.Lang -}}">
  <head>
    <meta charset="UTF-8">
    ...
    ...
    {{ partial "site-schema.html" . }}
  </head>
  <body>
  </body>
</html>

A partial for different areas of the website:

  • Home page including the pagination pages - page/2, page/3 …
  • Tag lists including the pagination pages - page/2, page/3 …
  • Blog web pages

The source code of themes/tekki/layouts/partials/site-schema.html looks like this:

{{ 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 }}

Further below I divide the source code according to the individual areas and explain some things.

Home page and tag lists incl. pagination pages - page/2 and following

{{ 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>
  ..

This source code section also contains the code for the tag lists. The only difference is the creation of the "description". But first things first.

With {{ if or ( .IsHome ) ( not .Description ) -}} I query whether the page to be generated is the home page or pagination following page of the home page. Or if the page has ( not .Description ). The home page also does not have a Description, but it is recognized by .IsHome. The tag lists are the only pages on my site that don’t have a description either. With this trick I can access the tag lists. This partial is included in the baseof.html, so at a very early stage. Only there the head HTML tag can be accessed and the json file can be created.

When assigning "name": "{{ .Site.Params.mydomain }}", I access the hugo.toml and there the parameter mydomain. Depending on the language of the current web page, the content is assigned.

— UPDATE June 03, 2023
"url": "{{ printf “%s%s” .Site.BaseURL (substr .Paginator.URL 1) }}" assigns the current URL, including the continuation page number, if any. The URL is composed of the components .Site.BaseURL, located in the hugo.toml, and .Paginator.URL. (substr .Paginator.URL 1) removes the leading backslash from the .Paginator.URL. Otherwise it would appear twice.
— End UPDATE June 03, 2023

Now we come to the description. With {{ if .IsHome }} I ask again if the current web page is a start or follow page of the home page. If yes, then I assign the language specific description from the hugo.toml.

If no, the current web page is a tag list or subsequent page of a tag list. Now it gets a little more complicated. What I want to do is to output before the general description from the hugo.toml the word tag and then the tag name with hyphen. To do this, I need to operate the tag name out of the URL. Since the URL for translated pages is built after the pattern /en/tags/<tag-name>/ I have to cut out 3 more characters there. Therefore the query for the language. With Scratch I would have produced even more code, also therefore the code repetition.

{{ $u := urls.Parse .Permalink }} the variable $u is assigned the Hugo function - urls.Parse - is assigned to the .Permalink. {{ $u := strings.TrimRight “/” $u.Path }} trims the last / from the string. And {{ $u := substr $u 6 }} cuts the first 6 characters in the German version. In the English version it is 9 characters. The rest of the code is self-explanatory.

Test result for the home page and tag list

The schema.org website provides a - Schema Markup Validator - is available. The test result for the home page looks like this:

Schema.org Test Typ Website
Schema.org - Testing the schema JSON-LD data of the website type for the home page.

For the tag list, the result looks like this:

Schema.org Test Typ Website Tag-List
Testing the schema JSON-LD data of the website type for the tag-list.

Blog Websites

Since I only want to offer my blog web pages to the search engines by schema, I also filter out only these pages by {{ if eq .Section "blog" }}. Privacy and imprint do not need a schema in my opinion.

..
{{ 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 }}

Since $author and $org_name are needed in more than one place, I filled these variables with the appropriate content for this. Each blog post has a subtitle for me. With "alternativeHeadline": "{{ .Params.subtitle }}" I assign the subtitle to the property.

I assign the summary of the blog post to the description with "description": "{{if .IsPage}}{{ .Summary | markdownify }}{{ else }}{{ if .Description }}{{ .Description | safeJS }}{{ end }}{{ end }}". With | markdownify I filter out the p HTML tag. If there is no summary - doesn’t happen with me - the description is taken from the front matter of the current page.

The test tool of Google pointed out me with the property author that a URL should be added to a web page about the author. For this reason, I specified my "I about me" web page on this site. With {{ with .Site.GetPage "/ueber-mich" -}}"url": "{{- .Permalink -}}"{{ end }} I search for the page and add its permalink. This also works in the English version. "/ueber-mich" is the Page Bundle directory name of my "I about me" website.

I assign an image to my blog posts in the page bundle. This always has the same resource name. With {{ with .Resources.GetMatch "article-img" }}{{ $banner := $.Page.Resources.GetMatch "article-img" }} … {{ end }}, I get the image and assign it to the property "image":.

Test result for the blog web pages

The test result for the blog websites looks like this:

Schema.org Test Type Blog
Schema.org - Test of schema JSON-LD data of type BlogPosting.

Testing the JSON-LD schema data

In addition to the Schema.org validator described above, there is also a corresponding validator for rich search results from Google. Schema.org checks if the schemas are respected. Google’s test for rich search results checks if the additional criteria of Google are met.

Even if no or only few rich results are possible on the own website, the search engines are provided with an additional possibility to understand the web page better with the schemas. Whether this then also affects the search results position is not guaranteed.

Conclusion

At first glance the partial source code looks a bit complicated, but on closer inspection this is not so bad. The properties for each Schema.org type are explained quite well on Schema.org. I have already noticed the first effects in the Google search. There is now sometimes a small image next to the search entries of my blog posts.

Link list to this post

This might also interest you

Update:  |
12 minutes to read
0
This post was created with Hugo version 0.115.2.

With the German language setting, comments are not displayed in the English version of the website and vice versa.

© 2023 - Frank Kunert  -  About me