×
Home RSS feed info
i18n - Part 2 of 3 sequential tutorials for creating a Hugo multilingual website.

Hugo - Tutorial Part 2 - Create i18n multilingual site

Hugo - Tutorial Part 2 - Create i18n multilingual site

i18n - Part 2 of 3 instructions for creating a Hugo multilingual website. The second part goes into depth on theme development and shows how to program the multilingual website. With the following topics: Entries in the html and head tag of the baseof.html and menu structure in the hugo.toml, including the navigation partials nav.html and sidepanel.html.

Post updated on June 6, 2023

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

baseof.html - the framework for a multilingual website

The baseof.html must be thought of as a picture frame around the content. A base template that is used on all websites. This saves the eternal repetition of things that must be present on every web page. A web page created manually in HTML has to be changed in all web pages created until then.

In the Hugo Documentation - Base Templates and Blocks - the inclusion of content is explained by the Hugo statement of, for example, {{ block "main" . }}. But more on that later.

My shortened source code in the themes/tekki/layouts/_default/baseof.html looks like this:

<!doctype html>
<html lang="{{- .Site.Language.Lang -}}">
  <head>
    <meta charset="utf-8">
  
    {{ if eq .Params.sitemap_exclude true }}
    <meta name="robots" content="noindex, nofollow, noarchive">
    {{ else }}
    <meta name="robots" content="index, follow, archive">
    {{ end }}

    {{ if .IsHome }}
      <!-- Homepage -->
      <title>{{ .Site.Params.description }} - {{ .Site.Params.mydomain }}</title>
      {{ if .Site.Params.description }}
      <meta name="description" content="{{ .Site.Params.description }}">
      {{end}}
    {{ else }}
      <!-- Page, Blog Page -->
      <title>{{ .Title }} - {{ .Site.Params.mydomain }}</title>
      {{if .Description }}
        <meta name="description" content="{{ .Description }}">
      {{ else }}
        <!-- Tags -->
        {{ if eq .Site.Language.Lang "de" }}
          {{ if eq .Title "Tags" }}
            <meta name="description" content="Übersicht der Tags von {{ .Site.Params.mydomain }}">
          {{ else }}
            <meta name="description" content="Das Tag #{{ .Title }} ist mit diversen Blogbeiträgen verknüpft.">
          {{ end }}
        {{ else }}
          {{ if eq .Title "Tags" }}
            <meta name="description" content="Overview of the tags from {{ .Site.Params.mydomain }}">
          {{ else }}
            <meta name="description" content="The tag #{{ .Title }} is linked to various blog posts.">
          {{ end }}
        {{ end }}
      {{ end }}
    {{ end }}
  
    {{ if .IsTranslated }}
    {{ range .Translations }}
    <link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}">
    {{ end }}
    <link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}">
    {{ end }}
  </head>

  <body>
    {{ partial "header.html" . }}
    {{ partial "nav.html" . }}
    {{ partial "sidepanel.html" . }}
    <div id="content">
      {{ block "main" . }}{{ end }}
    </div>
    {{ partial "footer.html" . }}
  </body>
</html>

I shortened the source code to the entries relevant for i18n for clarity and explainability. For the explanations I have divided the baseof.html shown above into corresponding code blocks:

html lang=“de” or “en”

The language specification determines the language for the entire page. This signals to screen readers in which language they should convert the output. Google does not use this attribute for search, but determines the main language of a page based on its content.

With Site.Language.Lang, the language of the current page is output. Hugo creates this global variable by parsing the [languages] parameter in the hugo.toml. I described this parameter in part 1, in the section - i18n entries in the hugo.toml - described.

head - meta charset=“utf-8”

UTF-8 is a character set that can also represent characters of Far Eastern languages and languages from the African region as a multibyte character string. The default character encoding in HTML-5 is UTF-8, but your source code editor must also save the HTML document as UTF-8.

head - meta name=“robots” content=

    {{ if eq .Params.sitemap_exclude true }}
    <meta name="robots" content="noindex, nofollow, noarchive">
    {{ else }}
    <meta name="robots" content="index, follow, archive">
    {{ end }}

If the current web page contains the Front Matter parameter sitemap_exclude: true, search robots are instructed not to index, follow, or archive the web page. If this is not the case they should do all this.

I described the Front Matter parameter sitemap_exclude: true in the first part of the HowTo series - What and for what are _index.md files? - Unintended effect on sitemap.xml .

head - HTML-Tag title and Meta-Tag name=“description”

In the following section, the Title and Description for the current web page are put together. There are the sections Homepage, Web Page and Tags. Each section must be created individually.

  
    {{ if .IsHome }}
      <!-- Homepage -->
      <title>{{ .Site.Params.description }} - {{ .Site.Params.mydomain }}</title>
      {{ if .Site.Params.description }}
      <meta name="description" content="{{ .Site.Params.description }}">
      {{end}}
    {{ else }}
      <!-- Page, Blog Page -->
      <title>{{ .Title }} - {{ .Site.Params.mydomain }}</title>
      {{if .Description }}
        <meta name="description" content="{{ .Description }}">
      {{ else }}
        <!-- Tags -->
        {{ if eq .Site.Language.Lang "de" }}
          {{ if eq .Title "Tags" }}
            <meta name="description" content="Übersicht der Tags von {{ .Site.Params.mydomain }}">
          {{ else }}
            <meta name="description" content="Das Tag #{{ .Title }} ist mit diversen Blogbeiträgen verknüpft.">
          {{ end }}
        {{ else }}
          {{ if eq .Title "Tags" }}
            <meta name="description" content="Overview of the tags from {{ .Site.Params.mydomain }}">
          {{ else }}
            <meta name="description" content="The tag #{{ .Title }} is linked to various blog posts.">
          {{ end }}
        {{ end }}
      {{ end }}
    {{ end }}

The start page has a different title and description than a normal web page. It gets the title and description from the hugo.toml. For the web page, the title and description comes from the Front Matter parameters of the md file. For the tags, I have the tag cloud and for a single tag, a list of posts. More about that below.

Using and keeping apart global variables .Site.Params.description and Page / Front Matter variables .Description made me run into my own errors several times in the beginning. But actually, once you understand it, it’s easy. The Hugo documentation is very informative about this:

Title and description of the home page

    {{ if .IsHome }}
      <!-- Homepage -->
      <title>{{ .Site.Params.description }} - {{ .Site.Params.mydomain }}</title>
      {{ if .Site.Params.description }}
      <meta name="description" content="{{ .Site.Params.description }}">
      {{end}}
    {{ else }}

If the current web page is the home page, the contents for description and title are taken from the hugo.toml. In .Site.Params.mydomain is the domain for me.

Title and description of the web pages

    {{ else }}
      <!-- Page, Blog Page -->
      <title>{{ .Title }} - {{ .Site.Params.mydomain }}</title>
      {{if .Description }}
        <meta name="description" content="{{ .Description }}">
      {{ else }}

If the current web page is not the start page, this is a normal web page and the title and description is taken from the front matter variables of the page. I extend the title with mydomain from the hugo.toml, which is the global, country-specific variable with the domain name.

Title and description of the tags

      {{ else }}
        <!-- Tags -->
        {{ if eq .Site.Language.Lang "de" }}
          {{ if eq .Title "Tags" }}
            <meta name="description" content="Übersicht der Tags von {{ .Site.Params.mydomain }}">
          {{ else }}
            <meta name="description" content="Das Tag #{{ .Title }} ist mit diversen Blogbeiträgen verknüpft.">
          {{ end }}
        {{ else }}
          {{ if eq .Title "Tags" }}
            <meta name="description" content="Overview of the tags from {{ .Site.Params.mydomain }}">
          {{ else }}
            <meta name="description" content="The tag #{{ .Title }} is linked to various blog posts.">
          {{ end }}
        {{ end }}
      {{ end }}
    {{ end }}

Now it gets a little more complicated. If the current web page is not the start page and the Front Matter parameter .Description is not present, tags are currently displayed.

I have already put together the title for the tags for the “normal” web pages. With {{ if eq .Site.Language.Lang "de" }} I distinguish in which language the page is displayed. The now following effort for meta name=description is based on different pages. With {{ if eq .Title "tags" }} my tag cloud is caught. In the else branch, the teaser list is displayed with the respective tag.

SEO tip for the HTML tag title

A longer and accurately descriptive title pleases search engines better than 1 or 2 words. In the search results, only 55 to 60 characters of the title are displayed. The rest is cut off. So the important stuff should be in those first 55 characters.

Source: MDN - Page titles and SEO

SEO Tip for the Meta Tag name=description

Google displays the description from the description meta tag in the results list below the title links. The description tag is therefore very important. If you check the character length of the texts on the results page, it shows that about 160 characters are displayed. I limit myself to less than 140 characters. This should ensure that everything I want is displayed.

If a web page is also available in a translated version, you should include an alternative link for search engines. In addition, there must be an alternative link to the page itself. A so-called self-referencing.

    {{ if .IsTranslated }}
    {{ range .Translations }}
    <link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}">
    {{ end }}
    <link rel="alternate" hreflang="{{ .Language.Lang }}" href="{{ .Permalink }}">
    {{ end }}

When the current web page is translated, the range creates the alternative links to the other web pages. Since I only provide one other language, the range is exited after one pass. The output of the second alternative link references the language of the current web page.

This source text is the simplest variant. For paginated web pages - i.e. URLs with the extension /page/X/ such as the second page of the homepage or tag lists starting on page 2 - it gets more complicated. In my blog post - SEO - Self-referencing hreflang on multilingual website - I have described this.

Google explains the link rel=alternate extensively in the Google Search Central documentation:

Google Search Central Documentation - Tell Google about localized versions of your page - HTML tags

body - Hugo’s block keyword

In body the content of the web page is included by {{ block "main" }}. There is nothing multilingual-specific at this level. block is explained in the - Hugo Documentation - Base Templates and Blocks - explained:

 <body>
    {{ partial "header.html" . }}
    {{ partial "nav.html" . }}
    {{ partial "sidepanel.html" . }}
    <div id="content">
      {{ block "main" . }}
      {{end}}
    </div>
    {{ partial "footer.html" . }}
  </body>
</html>

In the partial nav.html the menu is created. The menu is made invisible from a certain display width with the CSS statement display: none. Otherwise the menu items pile up ugly on top of each other. The partial sidepanel.html then takes over the menu function. But more about that below.

hugo.toml - Multilingual menu structure

Since my menu structure is very clear, I put it directly into the hugo.toml. The Hugo menu documentation consists of the following documents:

Following are my multilingual relevant entries in the hugo.toml:

..
defaultContentLanguage = "de"
[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.de.menu.main]]
      url    = "/tags/"
      name   = "Tags"
      weight = 1
    [[languages.de.menu.main]]
      url    = "/datenschutz/"
      name   = "Datenschutz"
      weight = 10
    [[languages.de.menu.main]]
      url    = "/impressum/"
      name   = "Impressum"
      weight = 20
    [[languages.de.menu.main]]
      url    = "/kontakt/"
      name   = "Kontakt"
      weight = 30

  [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/ 🇬🇧"

    [[languages.en.menu.main]]
      url    = "/tags/"
      name   = "Tags"
      weight = 1
    [[languages.en.menu.main]]
      url    = "/data-protection/"
      name   = "Data Protection"
      weight = 10
    [[languages.en.menu.main]]
      url    = "/legal-notice/"
      name   = "Legal notice"
      weight = 20
    [[languages.en.menu.main]]
      url    = "/contact/"
      name   = "Contact"
      weight = 30

The entries in the hugo.toml were subsequently adapted by me to the Hugo version 0.112. See also - Multilingual - Changes in Hugo as of version 0.112 .

The menu contents for German and English are identical except for the language. Hugo is very sensitive about the spelling of the menu item in the different languages. The syntax [[languages.de.menu.main]] and [[languages.en.menu.main]] must be followed.

The content of url is relative to the baseURL in the hugo.toml. The url = “/tags/" entry results in https:/tekki-tipps. de/tags/ and in the English one in https://tekki-tipps.de/en/tags/.

The variable name is displayed as menu item text. The language assignment displays the country-specific text in each language.

The order of the menu items is determined by the weight variable and is in ascending order.

i18n Menu - Partial nav.html

The navigator bar disappears on this website when you scroll down the page. I use the hamburger icon for a sidepanel that also displays the menu. Once the navigation bar is no longer displayed, the hamburger button slides into the header bar to the left of my logo. Below is the sourcecode for the navigation themes/tekki/layouts/partial/nav.html:

<nav id="navigation-bar">
	<div class="container">
		<div class="navbutton">
			<button type="button" class="btn btn-link menu-hamburger openbtn" onclick="openNav()"><span>&#9776;</span> {{ T "Menu" }}</button>
		</div>
		<ul class="menulinks">
			{{ $currentPage := . }}
			{{ range .Site.Menus.main }}
				{{ $menu_item_url := .URL | relLangURL }}
				{{ $page_url := $currentPage.RelPermalink | relLangURL }}
				<li {{ if eq $menu_item_url $page_url }}class="active"{{ end }}>
					<a href="{{ $menu_item_url | absLangURL }}">{{ .Name }}</a>
				</li>
			{{ end }}
		</ul>
	</div>
</nav>

Hamburger-Button for the side panel

The hamburger button, the correct name in Unicode is Trigram for Heaven , is displayed by the decimal HTML encoding &#9776;. When the button is clicked, the JavaScript function openNav() is called. More on this below. The Hugo T function is used to translate a string, in this case - Menü / Menu - into the current language. More about this in part 3 of this HowTo series.

Assembling the menu list structure

With {{ range .Site.Menus.main }} in the current language, that menu main in the hugo.toml is searched for menu items. “.Site” is interpreted by Hugo as [languages.de] or [languages.en]. After that, the variable $menu_item_url stores the country-specific URL, for the menu item searched in the loop. The $page_url variable is assigned the country-specific URL of the web page. When the HTML tag li is merged, it examines whether the current web page matches the URL of the menu list item. If it does, then the CSS class active is assigned. Then the link is built and the name of the menu item is inserted.

CSS/SCSS - Partial nav.html

I will not explain the SCSS instructions in particular. That is web design and belongs to the tools of the trade. If you don’t understand SCSS, there are websites on the internet that offer a conversion from SCSS to CSS.

In the CSS ID navigation-bar and other CSS classes, I assign the value to a variable, for example var(–wk-container-bg);. The reason for assigning by variable is the darkmode of my theme. By default, the theme starts in lightmode. By a Theme Switcher icon - on top, the second from the right - can be switched to darkmode.

Further down, the width of the container, the variable $site-width is assigned. In SCSS, such assignments are possible and provide flexibility. The SCSS is stored with me in the following file - themes/tekki/assets/scss/layouts/basic.scss:

#navigation-bar {
	display: flex;
	width: 100%;
	margin-top: 5.5rem;
	background-color: var(--wk-container-bg);
	border-top: 1px dotted var(--wk-accent-border-color);
	border-bottom: 1px dotted var(--wk-accent-border-color);

	.container {
		display: flex;
		width: $site-width;
		background-color: var(--wk-container-bg);
	
		.navbutton {
			display: flex;
			padding-right: 1.0rem;

			.btn {
				border-radius: 0;
				color: var(--wk-link-color);
			}
			.btn:focus {
				box-shadow: none;
			}
			.btn-link {
				text-decoration: none;
			}
			span {
				padding-bottom: 0.1rem;
				padding-right: 0.3rem;
			}
			.menu-hamburger {
				margin-left: -1.5rem;
				padding-right: 1.0rem;
				border-right: 1px dotted var(--wk-accent-border-color);
				background-image: none;
			}
		}
		.menulinks {
			display: flex;
			flex-direction: row;
			list-style: none;
			margin: 0;
			padding: 0;

			a {
				float: left;
				display: block;
				color: var(--wk-link-color);
				text-align: center;
				padding: 15px;
				text-decoration: none;
			}
			.active {
				border-top: 2px solid red;
			}
		}
	}
}
// Responsive
//
@media (max-width: 767.8px) {
	#navigation-bar {
		.container {
			.menulinks {
				display: none;
			}
		}
	}
}
@media (max-width: 575.8px) {
	#navigation-bar {
		.container {
			width: 100%;

			.navbutton {
				.menu-hamburger {
					margin-left: -1.0rem;
				}
			}
		}
	}
}

If the navigation bar is still visible, i.e. it has not yet been scrolled down, the menu items are made invisible up to a maximum view width of 767.8px. Otherwise the menu items would pile up and that doesn’t look good. The menu is always accessible via the sidepanel even in Responsive Mode.

How you can check the Responsive display, I described in another post - Check the responsive viewport .

i18n Menu - Partial sidepanel.html

Sourcecode: themes/tekki/layouts/partial/sidepanel.html:

<div id="tt-sidepanel" class="sidepanel">
	<div class="closebtn" onclick="closeNav()">×</div>
	<a href='{{ "/" | relLangURL }}'>{{ T "Startseite" }}</a>
	<ul class="menulinks">
		{{ $currentPage := . }}
		{{ range .Site.Menus.main }}
			{{ $menu_item_url := .URL | relLangURL }}
			{{ $page_url:= $currentPage.RelPermalink | relLangURL }}
			<li {{ if eq $menu_item_url $page_url }}class="active"{{ end }}>
				<a href="{{ $menu_item_url | absLangURL }}">{{ .Name }}</a>
			</li>
		{{ end }}
	</ul>
	<a href='{{ "/rss-info/" | relLangURL }}'>{{ T "rssInfo" }}</a>
</div>

The second div displays the close symbol. When the x is clicked, the JavaScript function closeNav is called. More on this below. The first link is responsible for the Home page menu item. In the hugo.toml I have not entered a menu item for this. All entries between the ul correspond to the partial nav.html explained above. At the end, a link is added that is also not contained in the partial nav.html. Otherwise the horizontal menu will be too large.

CSS/SCSS - Partial sidepanel.html

Sourcecode: themes/tekki/assets/scss/layouts/basic.scss:

.sidepanel {
  width: 0;
  position: fixed;
  z-index: 100;
  top: 0;
  left: 0;
  background-color: var(--wk-container-bg-4);
  overflow-x: hidden; /* Disable horizontal scroll */
  padding: 3.0rem 0 2.0rem;
  transition: 0.5s;

	a {
		display: block;
		padding: 0.5rem 0.5rem 0.5rem 1.0rem;
		text-decoration: none;
		font-size: 25px;
		color: var(--wk-accent-color-5);
		transition: 0.3s;
	}
	a:hover {
		color: #f1f1f1;
	}
	.closebtn {
		position: absolute;
		top: 0;
		right: 0.5rem;
		font-size: 36px;
		background-image: none;
	}
	.menulinks {
		list-style: none;
		margin: 0;
		padding: 0;
	}
}

JavaScript - Partial sidepanel.html

Sourcecode: themes/tekki/assets/wkjs/wk-1.0.js:

/* Set the width of the sidebar to 250px (show it) */
function openNav() {
  document.getElementById("tt-sidepanel").style.width = "250px";
}

/* Set the width of the sidebar to 0 (hide it) */
function closeNav() {
  document.getElementById("tt-sidepanel").style.width = "0";
}

Conclusion

If you are using a ready-made theme for your Hugo project, you probably won’t be able to use these deep interventions described here. Nevertheless, some hints may be useful.

As I said in part 1 - this is my way to build a multilingual website. I didn’t want to depend on the theme again. It goes without saying that if theme authors don’t want to continue developing a theme, they have the freedom to discontinue the service. As I have experienced this myself, I prefer to program my themes myself.

I completely underestimated the amount of work required for a series like this. I try to explain the single Hugo steps exactly, so that hopefully also a i18n beginner understands my sourcecode. At the same time I notice unpleasant things in my own sourcecode. I then correct them and try around until I am satisfied with the public result.

At least until I look at my own sourcecode again weeks later …

Link list for this article

This might also interest you

Update:  |
18 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