diff options
author | Alexandre Bourget <alex@bourget.cc> | 2016-05-14 00:35:16 -0400 |
---|---|---|
committer | Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com> | 2016-09-06 18:32:15 +0300 |
commit | ec33732fbe84f67c1164fb713d6cb738609f2e2e (patch) | |
tree | e4d361fda15e254617fb0fc2fdba275a269afc65 | |
parent | faa3472fa299adb287d575e6d404d4ddcddbff4e (diff) |
Add multilingual support in Hugo
Implements:
* support to render:
* content/post/whatever.en.md to /en/2015/12/22/whatever/index.html
* content/post/whatever.fr.md to /fr/2015/12/22/whatever/index.html
* gets enabled when `Multilingual:` is specified in config.
* support having language switchers in templates, that know
where the translated page is (with .Page.Translations)
(when you're on /en/about/, you can have a "Francais" link pointing to
/fr/a-propos/)
* all translations are in the `.Page.Translations` map, including the current one.
* easily tweak themes to support Multilingual mode
* renders in a single swift, no need for two config files.
Adds a couple of variables useful for multilingual sites
Adds documentation (content/multilingual.md)
Added language prefixing for all URL generation/permalinking see in the
code base.
Implements i18n. Leverages the great github.com/nicksnyder/go-i18n lib.. thanks Nick.
* Adds "i18n" and "T" template functions..
29 files changed, 1013 insertions, 242 deletions
diff --git a/commands/benchmark.go b/commands/benchmark.go index 3f5aa6ef3..53d2c8e9e 100644 --- a/commands/benchmark.go +++ b/commands/benchmark.go @@ -57,7 +57,7 @@ func benchmark(cmd *cobra.Command, args []string) error { return err } for i := 0; i < benchmarkTimes; i++ { - MainSite = nil + MainSites = nil _ = buildSite() } pprof.WriteHeapProfile(f) @@ -76,7 +76,7 @@ func benchmark(cmd *cobra.Command, args []string) error { pprof.StartCPUProfile(f) defer pprof.StopCPUProfile() for i := 0; i < benchmarkTimes; i++ { - MainSite = nil + MainSites = nil _ = buildSite() } } diff --git a/commands/hugo.go b/commands/hugo.go index 7afd78a9d..57a426458 100644 --- a/commands/hugo.go +++ b/commands/hugo.go @@ -46,10 +46,10 @@ import ( "github.com/spf13/viper" ) -// MainSite represents the Hugo site to build. This variable is exported as it +// MainSites represents the Hugo sites to build. This variable is exported as it // is used by at least one external library (the Hugo caddy plugin). We should // provide a cleaner external API, but until then, this is it. -var MainSite *hugolib.Site +var MainSites map[string]*hugolib.Site // Reset resets Hugo ready for a new full build. This is mainly only useful // for benchmark testing etc. via the CLI commands. @@ -287,6 +287,7 @@ func loadDefaultSettings() { viper.SetDefault("ArchetypeDir", "archetypes") viper.SetDefault("PublishDir", "public") viper.SetDefault("DataDir", "data") + viper.SetDefault("I18nDir", "i18n") viper.SetDefault("ThemesDir", "themes") viper.SetDefault("DefaultLayout", "post") viper.SetDefault("BuildDrafts", false) @@ -323,6 +324,8 @@ func loadDefaultSettings() { viper.SetDefault("EnableEmoji", false) viper.SetDefault("PygmentsCodeFencesGuessSyntax", false) viper.SetDefault("UseModTimeAsFallback", false) + viper.SetDefault("Multilingual", false) + viper.SetDefault("DefaultContentLanguage", "en") } // InitializeConfig initializes a config file with sensible default configuration flags. @@ -490,6 +493,8 @@ func InitializeConfig(subCmdVs ...*cobra.Command) error { helpers.HugoReleaseVersion(), minVersion) } + readMultilingualConfiguration() + return nil } @@ -506,7 +511,7 @@ func watchConfig() { viper.OnConfigChange(func(e fsnotify.Event) { fmt.Println("Config file changed:", e.Name) // Force a full rebuild - MainSite = nil + MainSites = nil utils.CheckErr(buildSite(true)) if !viper.GetBool("DisableLiveReload") { // Will block forever trying to write to a channel that nobody is reading if livereload isn't initialized @@ -632,6 +637,7 @@ func copyStatic() error { func getDirList() []string { var a []string dataDir := helpers.AbsPathify(viper.GetString("DataDir")) + i18nDir := helpers.AbsPathify(viper.GetString("I18nDir")) layoutDir := helpers.AbsPathify(viper.GetString("LayoutDir")) staticDir := helpers.AbsPathify(viper.GetString("StaticDir")) walker := func(path string, fi os.FileInfo, err error) error { @@ -639,8 +645,13 @@ func getDirList() []string { if path == dataDir && os.IsNotExist(err) { jww.WARN.Println("Skip DataDir:", err) return nil + } + if path == i18nDir && os.IsNotExist(err) { + jww.WARN.Println("Skip I18nDir:", err) + return nil } + if path == layoutDir && os.IsNotExist(err) { jww.WARN.Println("Skip LayoutDir:", err) return nil @@ -684,6 +695,7 @@ func getDirList() []string { helpers.SymbolicWalk(hugofs.Source(), dataDir, walker) helpers.SymbolicWalk(hugofs.Source(), helpers.AbsPathify(viper.GetString("ContentDir")), walker) + helpers.SymbolicWalk(hugofs.Source(), i18nDir, walker) helpers.SymbolicWalk(hugofs.Source(), helpers.AbsPathify(viper.GetString("LayoutDir")), walker) helpers.SymbolicWalk(hugofs.Source(), staticDir, walker) if helpers.ThemeSet() { @@ -695,31 +707,52 @@ func getDirList() []string { func buildSite(watching ...bool) (err error) { fmt.Println("Started building site") - startTime := time.Now() - if MainSite == nil { - MainSite = new(hugolib.Site) - } - if len(watching) > 0 && watching[0] { - MainSite.RunMode.Watching = true + t0 := time.Now() + + if MainSites == nil { + MainSites = make(map[string]*hugolib.Site) } - err = MainSite.Build() - if err != nil { - return err + + for _, lang := range langConfigsList { + t1 := time.Now() + mainSite, present := MainSites[lang] + if !present { + mainSite = new(hugolib.Site) + MainSites[lang] = mainSite + mainSite.SetMultilingualConfig(lang, langConfigsList, langConfigs) + } + + if len(watching) > 0 && watching[0] { + mainSite.RunMode.Watching = true + } + + if err := mainSite.Build(); err != nil { + return err + } + + mainSite.Stats(lang, t1) } - MainSite.Stats() - jww.FEEDBACK.Printf("in %v ms\n", int(1000*time.Since(startTime).Seconds())) + + jww.FEEDBACK.Printf("total in %v ms\n", int(1000*time.Since(t0).Seconds())) return nil } func rebuildSite(events []fsnotify.Event) error { - startTime := time.Now() - err := MainSite.ReBuild(events) - if err != nil { - return err + t0 := time.Now() + + for _, lang := range langConfigsList { + t1 := time.Now() + mainSite := MainSites[lang] + + if err := mainSite.ReBuild(events); err != nil { + return err + } + + mainSite.Stats(lang, t1) } - MainSite.Stats() - jww.FEEDBACK.Printf("in %v ms\n", int(1000*time.Since(startTime).Seconds())) + + jww.FEEDBACK.Printf("total in %v ms\n", int(1000*time.Since(t0).Seconds())) return nil } diff --git a/commands/list.go b/commands/list.go index 5267a4f8b..bc5bb557a 100644 --- a/commands/list.go +++ b/commands/list.go @@ -57,7 +57,7 @@ var listDraftsCmd = &cobra.Command{ return newSystemError("Error Processing Source Content", err) } - for _, p := range site.Pages { + for _, p := range site.AllPages { if p.IsDraft() { fmt.Println(filepath.Join(p.File.Dir(), p.File.LogicalName())) } @@ -88,7 +88,7 @@ posted in the future.`, return newSystemError("Error Processing Source Content", err) } - for _, p := range site.Pages { + for _, p := range site.AllPages { if p.IsFuture() { fmt.Println(filepath.Join(p.File.Dir(), p.File.LogicalName())) } @@ -119,7 +119,7 @@ expired.`, return newSystemError("Error Processing Source Content", err) } - for _, p := range site.Pages { + for _, p := range site.AllPages { if p.IsExpired() { fmt.Println(filepath.Join(p.File.Dir(), p.File.LogicalName())) } diff --git a/commands/multilingual.go b/commands/multilingual.go new file mode 100644 index 000000000..68da7c96d --- /dev/null +++ b/commands/multilingual.go @@ -0,0 +1,41 @@ +package commands + +import ( + "sort" + + "github.com/spf13/cast" + "github.com/spf13/viper" +) + +var langConfigs map[string]interface{} +var langConfigsList langConfigsSortable + +func readMultilingualConfiguration() { + multilingual := viper.GetStringMap("Multilingual") + if len(multilingual) == 0 { + langConfigsList = append(langConfigsList, "") + return + } + + langConfigs = make(map[string]interface{}) + for lang, config := range multilingual { + langConfigs[lang] = config + langConfigsList = append(langConfigsList, lang) + } + sort.Sort(langConfigsList) +} + +type langConfigsSortable []string + +func (p langConfigsSortable) Len() int { return len(p) } +func (p langConfigsSortable) Less(i, j int) bool { return weightForLang(p[i]) < weightForLang(p[j]) } +func (p langConfigsSortable) Swap(i, j int) { p[i], p[j] = p[j], p[i] } + +func weightForLang(lang string) int { + conf := langConfigs[lang] + if conf == nil { + return 0 + } + m := cast.ToStringMap(conf) + return cast.ToInt(m["weight"]) +} diff --git a/docs/content/content/multilingual.md b/docs/content/content/multilingual.md new file mode 100644 index 000000000..8edc6a600 --- /dev/null +++ b/docs/content/content/multilingual.md @@ -0,0 +1,238 @@ +--- +date: 2016-01-02T21:21:00Z +menu: + main: + parent: content +next: /content/example +prev: /content/summaries +title: Multilingual Mode +weight: 68 +toc: true +--- + +Since version 0.17, Hugo supports a native Multilingual mode. In your +top-level `config.yaml` (or equivalent), you define the available +languages in a `Multilingual` section such as: + +``` +Multilingual: + en: + weight: 1 + title: "My blog" + params: + linkedin: "english-link" + fr: + weight: 2 + + title: "Mon blog" + params: + linkedin: "lien-francais" + copyright: "Tout est miens" + +copyright: "Everything is mine" +``` + +Anything not defined in a `[lang]:` block will fall back to the global +value for that key (like `copyright` for the `en` lang in this +example). + +With the config above, all content, sitemap, RSS feeds, paginations +and taxonomy pages will be rendered under `/en` in English, and under +`/fr` in French. + +Only those keys are read under `Multilingual`: `weight`, `title`, +`author`, `social`, `languageCode`, `copyright`, `disqusShortname`, +`params` (which can contain a map of several other keys). + + +### Translating your content + +Translated articles are picked up by the name of the content files. + +Example of translated articles: + +1. `/content/about.en.md` +2. `/content/about.fr.md` + +You can also have: + +1. `/content/about.md` +2. `/content/about.fr.md` + +in which case the config variable `DefaultContentLanguage` will be +used to affect the default language `about.md`. This way, you can +slowly start to translate your current content without having to +rename everything. + +If left unspecified, the value for `DefaultContentLanguage` defaults +to `en`. + +By having the same _base file name_, the content pieces are linked +together as translated pieces. Only the content pieces in the language +defined by **.Site.CurrentLanguage** will be rendered in a run of +`hugo`. The translated content will be available in the +`.Page.Translations` so you can create links to the corresponding +translated pieces. + + +### Language switching links + +Here is a simple example if all your pages are translated: + +``` +{{if .IsPage}} + {{ range $txLang := .Site.Languages }} + {{if isset $.Translations $txLang}} + <a href="{{ (index $.Translations $txLang).Permalink }}">{{ i18n ( printf "language_switcher_%s" $txLang ) }}</a> + {{end}} + {{end}} +{{end}} + +{{if .IsNode}} + {{ range $txLang := .Site.Languages }} + <a href="/{{$txLang}}">{{ i18n ( printf "language_switcher_%s" $txLang ) }}</a> + {{end}} +{{end}} +``` + +This is a more complete example. It handles missing translations and will support non-multilingual sites. Better for theme authors: + +``` +{{if .Site.Multilingual}} + {{if .IsPage}} + {{ range $txLang := .Site.Languages }} + {{if isset $.Translations $txLang}} + <a href="{{ (index $.Translations $txLang).Permalink }}">{{ i18n ( printf "language_switcher_%s" $txLang ) }}</a> + {{else}} + <a href="/{{$txLang}}">{{ i18n ( printf "language_switcher_%s" $txLang ) }}</a> + {{end}} + {{end}} + {{end}} + + {{if .IsNode}} + {{ range $txLang := .Site.Languages }} + <a href="/{{$txLang}}">{{ i18n ( printf "language_switcher_%s" $txLang ) }}</a> + {{end}} + {{end}} +{{end}} +``` + +This makes use of the **.Site.Languages** variable to create links to +the other available languages. The order in which the languages are +listed is defined by the `weight` attribute in each language under +`Multilingual`. + +This will also require you to have some content in your `i18n/` files +(see below) that would look like: + +``` +- id: language_switcher_en + translation: "English" +- id: language_switcher_fr + translation: "Français" +``` + +and a copy of this in translations for each language. + +As you might notice, node pages link to the root of the other +available translations (`/en`), as those pages do not necessarily have +a translated counterpart. + +Taxonomies (tags, categories) are completely segregated between +translations and will have their own tag clouds and list views. + + +### Translation of strings + +Hugo uses [go-i18n](https://github.com/nicksnyder/go-i18n) to support +string translations. Follow the link to find tools to manage your +translation workflows. + +Translations are collected from the `themes/[name]/i18n/` folder +(built into the theme), as well as translations present in `i18n/` at +the root of your project. In the `i18n`, the translations will be +merged and take precedence over what is in the theme folder. Files in +there follow RFC 5646 and should be named something like `en-US.yaml`, +`fr.yaml`, etc.. + +From within your templates, use the `i18n` function as such: + +``` +{{ i18n "home" }} +``` + +to use a definition like this one in `i18n/en-US.yaml`: + +``` +- id: home + translation: "Home" +``` + + +### Multilingual Themes support + +To support Multilingual mode in your themes, you only need to make +sure URLs defined manually (those not using `.Permalink` or `.URL` +variables) in your templates are prefixed with `{{ +.Site.LanguagePrefix }}`. If `Multilingual` mode is enabled, the +`LanguagePrefix` variable will equal `"/en"` (or whatever your +`CurrentLanguage` is). If not enabled, it will be an empty string, so +it is harmless for non-multilingual sites. + + +### Multilingual index.html and 404.html + +To redirect your users to their closest language, drop an `index.html` +in `/static` of your site, with the following content (tailored to +your needs) to redirect based on their browser's language: + +``` +<html><head> +<meta http-equiv="refresh" content="1;url=/en" /><!-- just in case JS doesn't work --> +<script> +lang = window.navigator.language.substr(0, 2); +if (lang == "fr") { + window.location = "/fr"; +} else { + window.location = "/en"; +} + +/* or simply: +window.location = "/en"; +*/ +</script></head><body></body></html> +``` + +An even simpler version will always redirect your users to a given language: + +``` +<html><head> +<meta http-equiv="refresh" content="0;url=/en" /> +</head><body></body></html> +``` + +You can do something similar with your `404.html` page, as you don't +know the language of someone arriving at a non-existing page. You +could inspect the prefix of the navigator path in Javascript or use +the browser's language detection like above. + + +### Sitemaps + +As sitemaps are generated once per language and live in +`[lang]/sitemap.xml`. Write this content in `static/sitemap.xml` to +link all your sitemaps together: + +``` +<?xml version="1.0" encoding="UTF-8"?> +<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <sitemap> + <loc>https://example.com/en/sitemap.xml</loc> + </sitemap> + <sitemap> + <loc>https://example.com/fr/sitemap.xml</loc> + </sitemap> +</sitemapindex> +``` + +and explicitly list all the languages you want referenced. diff --git a/docs/content/taxonomies/displaying.md b/docs/content/taxonomies/displaying.md index 8719807f9..c66c3de56 100644 --- a/docs/content/taxonomies/displaying.md +++ b/docs/content/taxonomies/displaying.md @@ -38,7 +38,7 @@ each content piece are located in the usual place <ul id="tags"> {{ range .Params.tags }} - <li><a href="{{ "/tags/" | relURL }}{{ . | urlize }}">{{ . }}</a> </li> + <li><a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a> </li> {{ end }} </ul> @@ -110,7 +110,8 @@ The following example displays all tag keys: <ul id="all-tags"> {{ range $name, $taxonomy := .Site.Taxonomies.tags }} - <li><a href="{{ "/tags/" | relURL }}{{ $name | urlize }}">{{ $name }}</a></li> +<<<<<<< HEAD + <li><a href="{{ "/tags/" | relLangURL }}{{ $name | urlize }}">{{ $name }}</a></li> {{ end }} </ul> @@ -120,7 +121,7 @@ This example will list all taxonomies, each of their keys and all the content as <section> <ul> {{ range $taxonomyname, $taxonomy := .Site.Taxonomies }} - <li><a href="{{ "/" | relURL}}{{ $taxonomyname | urlize }}">{{ $taxonomyname }}</a> + <li><a href="{{ "/" | relLangURL}}{{ $taxonomyname | urlize }}">{{ $taxonomyname }}</a> <ul> {{ range $key, $value := $taxonomy }} <li> {{ $key }} </li> @@ -135,4 +136,3 @@ This example will list all taxonomies, each of their keys and all the content as {{ end }} </ul> </section> - diff --git a/docs/content/taxonomies/ordering.md b/docs/content/taxonomies/ordering.md index baecd7b4c..ac86bc69d 100644 --- a/docs/content/taxonomies/ordering.md +++ b/docs/content/taxonomies/ordering.md @@ -29,7 +29,7 @@ Taxonomies can be ordered by either alphabetical key or by the number of content <ul> {{ $data := .Data }} {{ range $key, $value := .Data.Taxonomy.Alphabetical }} - <li><a href="{{ $data.Plural }}/{{ $value.Name | urlize }}"> {{ $value.Name }} </a> {{ $value.Count }} </li> + <li><a href="{{ .Site.LanguagePrefix }}/{{ $data.Plural }}/{{ $value.Name | urlize }}"> {{ $value.Name }} </a> {{ $value.Count }} </li> {{ end }} </ul> @@ -38,7 +38,7 @@ Taxonomies can be ordered by either alphabetical key or by the number of content <ul> {{ $data := .Data }} {{ range $key, $value := .Data.Taxonomy.ByCount }} - <li><a href="{{ $data.Plural }}/{{ $value.Name | urlize }}"> {{ $value.Name }} </a> {{ $value.Count }} </li> + <li><a href="{{ .Site.LanguagePrefix }}/{{ $data.Plural }}/{{ $value.Name | urlize }}"> {{ $value.Name }} </a> {{ $value.Count }} </li> {{ end }} </ul> diff --git a/docs/content/templates/functions.md b/docs/content/templates/functions.md index cd212b85d..aa55ced4f 100644 --- a/docs/content/templates/functions.md +++ b/docs/content/templates/functions.md @@ -435,6 +435,13 @@ e.g. ## Strings +### printf + +Format a string using the standard `fmt.Sprintf` function. See [the go +doc](https://golang.org/pkg/fmt/) for reference. + +e.g., `{{ i18n ( printf "combined_%s" $var ) }}` or `{{ printf "formatted %.2f" 3.1416 }}` + ### chomp Removes any trailing newline characters. Useful in a pipeline to remove newlines added by other processing (including `markdownify`). @@ -726,7 +733,6 @@ CJK-like languages. <!-- outputs a content length of 8 runes. --> ``` - ### md5 `md5` hashes the given input and returns its MD5 checksum. @@ -752,6 +758,23 @@ This can be useful if you want to use Gravatar for generating a unique avatar: <!-- returns the string "c8b5b0e33d408246e30f53e32b8f7627a7a649d4" --> ``` +## Internationalization + +### i18n + +This translates a piece of content based on your `i18n/en-US.yaml` +(and friends) files. You can use the +[go-i18n](https://github.com/nicksnyder/go-i18n) tools to manage your +translations. The translations can exist in both the theme and at the +root of your repository. + +e.g.: `{{ i18n "translation_id" }}` + + +### T + +`T` is an alias to `i18n`. E.g. `{{ T "translation_id" }}`. +>>>>>>> Add multilingual support in Hugo ## Times @@ -763,7 +786,6 @@ This can be useful if you want to use Gravatar for generating a unique avatar: * `{{ (time "2016-05-28").YearDay }}` → 149 * `{{ mul 1000 (time "2016-05-28T10:30:00.00+10:00").Unix }}` → 1464395400000 (Unix time in milliseconds) - ## URLs ### absURL, relURL diff --git a/docs/content/templates/terms.md b/docs/content/templates/terms.md index 3e6b43878..3711fd8aa 100644 --- a/docs/content/templates/terms.md +++ b/docs/content/templates/terms.md @@ -89,7 +89,7 @@ content tagged with each tag. <ul> {{ $data := .Data }} {{ range $key, $value := .Data.Terms }} - <li><a href="{{ $data.Plural }}/{{ $key | urlize }}">{{ $key }}</a> {{ len $value }}</li> + <li><a href="{{ .Site.LanguagePrefix }}/{{ $data.Plural }}/{{ $key | urlize }}">{{ $key }}</a> {{ len $value }}</li> {{ end }} </ul> </div> @@ -109,7 +109,7 @@ Another example listing the content for each term (ordered by Date): {{ $data := .Data }} {{ range $key,$value := .Data.Terms.ByCount }} - <h2><a href="{{ $data.Plural }}/{{ $value.Name | urlize }}">{{ $value.Name }}</a> {{ $value.Count }}</h2> + <h2><a href="{{ .Site.LanguagePrefix }}/{{ $data.Plural }}/{{ $value.Name | urlize }}">{{ $value.Name }}</a> {{ $value.Count }}</h2> <ul> {{ range $value.Pages.ByDate }} <li><a href="{{ .Permalink }}">{{ .Title }}</a></li> @@ -140,7 +140,7 @@ Hugo can order the meta data in two different ways. It can be ordered: <ul> {{ $data := .Data }} {{ range $key, $value := .Data.Terms.Alphabetical }} |