SEO-friendly Localization with Kobweb

May 26, 2024 by Fluense

In the previous article, we explored a fairly straightforward way to localize your Kobweb site using Libres. It is a suitable method for most web apps, but web crawlers will only see the default language. To improve SEO and have the site indexed in multiple languages, we need to export it multiple times, once for each language.

Prerequisiteslink

This is a more advanced topic, so a more in-depth understanding of Kobweb as well as web hosting is required. If you haven't already, check out the previous article for a step-by-step guide on how to add localization functionality to your Kobweb app and getting acquainted with Libres.

Kobweb's Static Site Generationlink

During the export process, Kobweb visits each page of your site and snapshots it's static HTML. This serves two purposes: it speeds up page loading and makes the site SEO-friendly - the static HTML is what web crawlers see. The method we used in the previous article only exports the site once, in the default language, and then uses JavaScript to switch between languages.

So, the idea is to export the site separately for each language, each with its own static HTML. For this we will add two new Gradle tasks, which, in turn, modify the string resource and Kobweb config files before exporting the site.

Libres' String Resource Fileslink

To understand how this will work, let's take a look at the files Libres generates.

public object ResStrings {
    private val baseLocale: StringsEn = StringsEn

    private val locales: Map<String, Strings> = mapOf("en" to StringsEn, "de" to StringsDe)

    public val home: String
        get() = locales[getCurrentLanguageCode()]?.home ?: baseLocale.home

    public val about: String
        get() = locales[getCurrentLanguageCode()]?.about ?: baseLocale.about
}
public object StringsEn : Strings {
    override val home: String = "Home"

    override val about: String = "About"
}

So, what we will do is remove the locales property and replace locales[getCurrentLanguageCode()] with the respective object for each language before exporting the site. Which means get() = locales[getCurrentLanguageCode()]?.home will become get() = StringsDe.home for German, for example.

String Resource Tasklink

Our two new Gradle tasks will communicate with each-other via a temporary file containing the current locale. We need this to specify which Strings object to use.

val resTask by tasks.register("modifyStrings") {
    val dir = layout.buildDirectory.dir("generated/libres/common/src/strings")
    val file = layout.buildDirectory.file("${dir.get().asFile.path}/ResStrings.kt")
    val localeFile = layout.projectDirectory.file("tmp/locale.txt")
    outputs.dir(dir)

    doLast {
        val locale = if (localeFile.asFile.exists()) localeFile.asFile.readText() else "StringsEn"
        val asFile = file.get().asFile
        val string = asFile.readText().replace("locales[getCurrentLanguageCode()]?", locale)
        val output = string.substringBefore("private val locales") + string.substringAfter(")")

        asFile.writeText(output)
    }
}

Kobweb's Configuration Filelink

Kobweb comes with a conf.yaml file which we need to modify.

site:
  title: "Fluense"
  routePrefix: "/"

server:
  files:
    dev:
      contentRoot: "build/processedResources/js/main/public"
      script: "build/dist/js/developmentExecutable/web.js"
      api: "build/libs/web.jar"
    prod:
      script: "build/dist/js/productionExecutable/web.js"
      siteRoot: ".kobweb/web"

  port: 8080

Route Prefixlink

You may host your site on different subdomains or subdirectories for each language. In the latter case, you must add the route prefix routePrefix: "/" which we will later change to each locale in the Gradle task.

The second line we will modify in the tasks is the export path, siteRoot. We will add the locale to the path, so the site is exported to a different directory for each language.

Export Tasklink

For each language, we first create a temporary file with the locale, then modify the configuration file and finally export the site calling the Kobweb CLI.

tasks.register("exportSite") {
    group = "kobweb"
    val inputDir = layout.projectDirectory.dir("src/commonMain/libres/strings")
    val locales =
        inputDir.asFile.listFiles()
            ?.map { file -> file.nameWithoutExtension.split("_").joinToString("") { it.uppercaseFirstChar() } }
            ?: emptyList()
    val configFile = layout.projectDirectory.file(".kobweb/conf.yaml")
    val localeFile = layout.projectDirectory.file("tmp/locale.txt")

    doLast {
        val configContent = configFile.asFile.readLines()
        locales.forEach { localeString ->
//            localeString = "StringsEn", locale = "en"
            val locale = localeString.substringAfter("Strings").lowercase()
            println("Exporting $locale...")

            localeFile.asFile.apply {
                parentFile?.mkdirs()
                writeText(localeString)
            }

            val updatedConfigContent = configContent.map {
                when {
                    it.contains("routePrefix:") -> "  routePrefix: \"$locale\""
                    it.contains("siteRoot:") -> "      siteRoot: \".kobweb/$locale\""
                    else -> it
                }
            }
            configFile.asFile.writeText(updatedConfigContent.joinToString("\n"))

            exec {
                executable = "cmd"
                args = listOf("/C", "kobweb export --layout static --notty")
            }
        }
        configFile.asFile.writeText(configContent.joinToString("\n"))
        localeFile.asFile.delete()
    }
}

Add Task Dependencieslink

Finally, we need to make sure our Gradle tasks run in the correct order. The Libres task should be finalized by our own modifyStrings task, and the Ksp task should depend on it as well.

tasks.withType(LibresResourcesGenerationTask::class.java).configureEach {
    finalizedBy(resTask)
}

tasks.withType(KspTaskJS::class.java).configureEach {
    dependsOn(resTask)
}

Now, all that's left to do is run the exportSite task and your site will be exported in multiple languages.

Manually Switching Languageslink

To allow users to manually switch languages we simply redirect them to the respective locale's site. If using subdirectories, you can replace the current locale in the URL. To keep track of the current locale we could either read it from the URL, or add a locale string to each string resource xml file.

<string name="locale">en</string>

Use Browser's Preferred Languagelink

This depends on how you are hosting your site and will likely require a bespoke solution. An example with nginx would be to use the $http_accept_language variable to set the locale in the URL and redirect accordingly.

Specifying Hreflang Tagslink

To help search engines understand the relationship between your site's different language versions, you can add hreflang tags to your HTML. These tags specify the language and optionally the region of the content.

Res.locales.forEach {
    document.head?.append(document.createElement("link").apply {
        setAttribute("rel", "alternate")
        setAttribute("hreflang", it.iso)
        setAttribute(
            "href",
            "https://site.com" + window.location.pathname.replace("/${Res.string.locale}/", "/$it/")
                .substringBefore("?")
        )
    })
}

Conclusionlink

By exporting your Kobweb site in multiple languages, you can improve your SEO and reach a wider audience. This method is more complex than the one we used in the previous article, but it is necessary if you want your site to be indexed in multiple languages. While the former method is sufficient for our web app, we are using this SEO-friendly way for our Fluense landing page to score higher in search engine results.

Happy coding!


Subscribe to our newsletter

2024 © FluenseAll rights reserved