Making this Hugo-built blog multilingual

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    Making this Hugo-built blog multilingual

    I've made this blog built with Hugo multilingual. For now, it's only available in English.

    Why I implemented multilingual support

    As part of my OSS activities at work, I often cross-post translated articles to platforms like dev.to and Medium. Recently, I've been casually translating and cross-posting articles on my own as well.


    For example, the English translation of 2025-08-15-1 was cross-posted to these three sites:
    • dev.to - This served as the original English version
    • Hashnode - Set dev.to's article as the canonical version
    • Medium - Set dev.to's article as the canonical version


    However, since I'm maintaining this blog masutaka.net, I thought it would be better to store the original English articles here and cross-post them to the above three sites. Setting the canonical URL to my own blog seems like it would also be beneficial for SEO.


    Hugo's Multilingual Support

    Hugo includes built-in multilingual support with simple configuration. All you need to do is add the following settings in your config.toml file:






    defaultContentLanguage = "ja"

    [languages.ja]
    weight = 1
    languageName = "日本語"
    title = "マスタカの ChangeLog メモ"

    [languages.en]
    weight = 2
    languageName = "English"
    title = "Masutaka's ChangeLog Memo"







    Articles with .en.md in their filenames will be the English versions.
    • Japanese version: content/posts/2025-08-15-1.md
    • English version: content/posts/2025-08-15-1.en.md


    With this configuration, articles can be accessed via the following URLs:

    Multilingual Implementation Strategy

    When implementing multilingual support, I followed this approach:


    1. Maintain Existing URLs Unchanged

    The most important policy was to absolutely preserve all existing URLs. Given that this blog has been operational for over 20 years, any changes to URLs would cause numerous broken links.


    Therefore, I kept the Japanese version as is and added an /en/ prefix only for the English version.
    • /index.html - Almost unchanged, with /en/index.html added
    • /index.xml - Unchanged, with /en/index.xml added
    • /sitemap.xml - Changed to reference /ja/sitemap.xml and /en/sitemap.xml
    • /llms.txt, /llms-full.txt - Unchanged, with /en/llms.txt and /en/llms-full.txt added


    👉 The behavior described above is achieved through the configuration settings for defaultContentLanguage, [languages.ja], and [languages.en].


    2. Keep English Menu Options in the Top Right Minimal

    The Device, History, and About pages contain content that is primarily Japanese-specific with limited value in translation, so I decided to skip them this time.

    Implementation Details

    Here's an overview of the actual changes.


    1. Modifications to config.toml

    Added language configuration settings and corresponding menu settings for each language.


    👉 The diff and the updated config.toml are attached in the Appendix.


    2. Creating English versions of static pages

    • content/archives.en.md - Archives page
    • content/search.en.md - Search page
    • content/privacy.en.md - Privacy Policy


    ⚠️ Initially, I attempted to handle this by creating symbolic links from *.en.md to *.md, but Hugo did not recognize these links.


    3. Modifying custom shortcodes

    Updated the custom post shortcode to accept an lang argument.


    layouts/shortcodes/post.html:






    {{- $id := .Get "id" | default (.Get 0) -}}
    {{- $lang := .Get "lang" | default .Page.Lang -}}
    {{- $url := relref . (dict "path" $id "lang" $lang) -}}
    {{- $title := .Get "title" -}}

    href="{{ $url }}">{{ with $title }}{{ $title }}{{ else }}[{{ $id }}]{{ end }}







    Example usage:







    {{ post "2025-09-23-1" >}}



    {{ post id="2025-09-23-1" lang="ja" >}}








    4. Translation Support for Custom Partials

    Since some translations were needed in layouts/partials/*.html, I created i18n/ja.toml and i18n/en.toml files.


    Usage example:






    href="https://example.com/" target="_blank" rel="noopener">{{ i18n "sendMessage" }}







    i18n/ja.toml:






    sendMessage = "メッセージ送信"







    i18n/en.toml:






    sendMessage = "Send Message"







    Conclusion

    I successfully added multilingual support to this Hugo-built blog. The implementation was cleaner than I expected 👍
    • All existing URLs remained unchanged, limiting the impact to the /en/ directory
    • Achieved using only Hugo's standard features with minimal customization
    • Reduced maintenance costs by keeping the English menu minimal
    • sitemap.xml and llms.txt were automatically multilingualized


    Moving forward, I'll translate new articles as needed and use https://masutaka.net/en/ as the original source while cross-posting to dev.to, Hashnode, and Medium.


    While I'm not sure if there's actually an English-speaking audience, I'm satisfied with the result. With recent AI translation being fairly accurate, it's not that difficult anyway 😎


    References



    Appendix

    Changes to config.toml





    diff --git a/config.toml b/config.toml
    index 0830be8b..a6bae12c 100644
    --- a/config.toml
    +++ b/config.toml
    @@ -7,7 +7,6 @@ googleAnalytics = "G-K28CQCC064"
    hasCJKLanguage = true
    languageCode = "ja"
    theme = "papermod"
    -title = "マスタカの ChangeLog メモ"

    [permalinks]
    posts = "/:filename"
    @@ -27,14 +26,12 @@ title = "マスタカの ChangeLog メモ"
    isPlainText = true
    mediaType = "text/plain"
    rel = "alternate"
    - root = true

    [outputFormats.llmsfull]
    baseName = "llms-full"
    isPlainText = true
    mediaType = "text/plain"
    rel = "alternate"
    - root = true

    #
    # papermod configuration
    @@ -51,14 +48,9 @@ title = "マスタカの ChangeLog メモ"
    author = "masutaka"
    comments = true
    defaultTheme = "auto"
    - description = "マスタカの変更履歴が記録されていくブログです。"
    showtoc = true
    tocopen = true

    -[params.homeInfoParams]
    - Title = "マスタカネット"
    - Content = "マスタカの変更履歴が記録されていくブログです。"
    -
    [params.assets]
    theme_color = "#ffffff"
    msapplication_TileColor = "#da532c"
    @@ -79,46 +71,81 @@ title = "マスタカの ChangeLog メモ"
    [[params.socialIcons]]
    name = "GitHub"
    url = "https://github.com/masutaka"
    -[[params.socialIcons]]
    - name = "Dev"
    - url = "https://dev.to/masutaka"
    -[[params.socialIcons]]
    - name = "Hashnode"
    - url = "https://masutaka.hashnode.dev/"
    -[[params.socialIcons]]
    - name = "Medium"
    - url = "https://medium.com/@masutaka"
    [[params.socialIcons]]
    name = "RSS"
    url = "/index.xml"

    -[[menu.main]]
    +#
    +# Multilingual
    +#
    +
    +[languages.ja]
    + weight = 1
    + languageName = "日本語"
    + title = "マスタカの ChangeLog メモ"
    +
    +[languages.ja.params]
    + description = "マスタカの変更履歴が記録されていくブログです。"
    +
    +[languages.ja.params.homeInfoParams]
    + Title = "マスタカネット"
    + Content = "マスタカの変更履歴が記録されていくブログです。"
    +
    +[[languages.ja.menu.main]]
    identifier = "archives"
    name = "Archive"
    url = "/archives/"
    weight = 1
    -[[menu.main]]
    +[[languages.ja.menu.main]]
    identifier = "tags"
    name = "Tags"
    url = "/tags/"
    weight = 2
    -[[menu.main]]
    +[[languages.ja.menu.main]]
    identifier = "search"
    name = "Search"
    url = "/search/"
    weight = 3
    -[[menu.main]]
    +[[languages.ja.menu.main]]
    identifier = "device"
    name = "Device"
    url = "/device/"
    weight = 4
    -[[menu.main]]
    +[[languages.ja.menu.main]]
    identifier = "history"
    name = "History"
    url = "/history/"
    weight = 5
    -[[menu.main]]
    +[[languages.ja.menu.main]]
    identifier = "about"
    name = "About"
    url = "/about/"
    weight = 6
    +
    +[languages.en]
    + weight = 2
    + languageName = "English"
    + title = "Masutaka's ChangeLog Memo"
    +
    +[languages.en.params]
    + description = "This is a blog that records Masutaka's change history."
    +
    +[languages.en.params.homeInfoParams]
    + Title = "Masutaka Net"
    + Content = "This is a blog that records Masutaka's change history."
    +
    +[[languages.en.menu.main]]
    + identifier = "archives"
    + name = "Archive"
    + url = "/en/archives/"
    + weight = 1
    +[[languages.en.menu.main]]
    + identifier = "tags"
    + name = "Tags"
    + url = "/en/tags/"
    + weight = 2
    +[[languages.en.menu.main]]
    + identifier = "search"
    + name = "Search"
    + url = "/en/search/"
    + weight = 3







    config.toml after multilingualization





    baseURL = "https://masutaka.net/"
    defaultContentLanguage = "ja"
    disablePathToLower = true
    enableEmoji = true
    enableRobotsTXT = true
    googleAnalytics = "G-K28CQCC064"
    hasCJKLanguage = true
    languageCode = "ja"
    theme = "papermod"

    [permalinks]
    posts = "/:filename"

    [taxonomies]
    tag = "tags"

    [markup.goldmark.renderer]
    hardWraps = true
    unsafe = true

    [outputs]
    home = ["html", "rss", "llms", "llmsfull"]

    [outputFormats.llms]
    baseName = "llms"
    isPlainText = true
    mediaType = "text/plain"
    rel = "alternate"

    [outputFormats.llmsfull]
    baseName = "llms-full"
    isPlainText = true
    mediaType = "text/plain"
    rel = "alternate"

    #
    # papermod configuration
    #

    [params]
    AmazonJpAffiliateID = "masutaka04-22"
    DateFormat = "2006-01-02 (Mon)"
    ShowAllPagesInArchive = true
    ShowCodeCopyButtons = true
    ShowFullTextinRSS = true
    ShowPageNums = true
    ShowPostNavLinks = true
    author = "masutaka"
    comments = true
    defaultTheme = "auto"
    showtoc = true
    tocopen = true

    [params.assets]
    theme_color = "#ffffff"
    msapplication_TileColor = "#da532c"

    [params.social]
    fediverse_creator = "@masutaka@mstdn.love"
    twitter = "@masutaka"

    [[params.socialIcons]]
    name = "Mastodon"
    url = "https://mstdn.love/@masutaka"
    [[params.socialIcons]]
    name = "Bluesky"
    url = "https://bsky.app/profile/masutaka.net"
    [[params.socialIcons]]
    name = "Twitter"
    url = "https://twitter.com/masutaka"
    [[params.socialIcons]]
    name = "GitHub"
    url = "https://github.com/masutaka"
    [[params.socialIcons]]
    name = "RSS"
    url = "/index.xml"

    #
    # Multilingual
    #

    [languages.ja]
    weight = 1
    languageName = "日本語"
    title = "マスタカの ChangeLog メモ"

    [languages.ja.params]
    description = "マスタカの変更履歴が記録されていくブログです。"

    [languages.ja.params.homeInfoParams]
    Title = "マスタカネット"
    Content = "マスタカの変更履歴が記録されていくブログです。"

    [[languages.ja.menu.main]]
    identifier = "archives"
    name = "Archive"
    url = "/archives/"
    weight = 1
    [[languages.ja.menu.main]]
    identifier = "tags"
    name = "Tags"
    url = "/tags/"
    weight = 2
    [[languages.ja.menu.main]]
    identifier = "search"
    name = "Search"
    url = "/search/"
    weight = 3
    [[languages.ja.menu.main]]
    identifier = "device"
    name = "Device"
    url = "/device/"
    weight = 4
    [[languages.ja.menu.main]]
    identifier = "history"
    name = "History"
    url = "/history/"
    weight = 5
    [[languages.ja.menu.main]]
    identifier = "about"
    name = "About"
    url = "/about/"
    weight = 6

    [languages.en]
    weight = 2
    languageName = "English"
    title = "Masutaka's ChangeLog Memo"

    [languages.en.params]
    description = "This is a blog that records Masutaka's change history."

    [languages.en.params.homeInfoParams]
    Title = "Masutaka Net"
    Content = "This is a blog that records Masutaka's change history."

    [[languages.en.menu.main]]
    identifier = "archives"
    name = "Archive"
    url = "/en/archives/"
    weight = 1
    [[languages.en.menu.main]]
    identifier = "tags"
    name = "Tags"
    url = "/en/tags/"
    weight = 2
    [[languages.en.menu.main]]
    identifier = "search"
    name = "Search"
    url = "/en/search/"
    weight = 3









    More...
Working...