diff options
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 }} - <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> </div> @@ -158,7 +158,7 @@ Hugo can order the meta data in two different ways. It can be ordered: <ul> {{ $data := .Data }} {{ range $key, $value := .Data.Terms.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> </div> diff --git a/docs/content/templates/variables.md b/docs/content/templates/variables.md index 953ba5cb6..8f37dd19f 100644 --- a/docs/content/templates/variables.md +++ b/docs/content/templates/variables.md @@ -58,6 +58,8 @@ matter, content or derived from file location. **.IsPage** Always true for page.<br> **.Site** See [Site Variables]({{< relref "#site-variables" >}}) below.<br> **.Hugo** See [Hugo Variables]({{< relref "#hugo-variables" >}}) below.<br> +**.Translations** A map to other pages with the same filename, but with a different language-extension (like `post.fr.md`). Populated only if `Multilingual` is enabled in your site config. +**.Lang** Taken from the language extension notation. Populated only if `Multilingual` is enabled for your site config. ## Page Params @@ -119,9 +121,9 @@ includes taxonomies, lists and the homepage. **.Site** See [Site Variables]({{< relref "#site-variables" >}}) below.<br> **.Hugo** See [Hugo Variables]({{< relref "#hugo-variables" >}}) below.<br> -### Taxonomy Term Variables +### Taxonomy Terms Node Variables -[Taxonomy Terms](/templates/terms/) pages are of the type "node" and have the following additional variables. +[Taxonomy Terms](/templates/terms/) pages are of the type "node" and have the following additional variables. These are available in `layouts/_defaults/terms.html` for example. **.Data.Singular** The singular name of the taxonomy<br> **.Data.Plural** The plural name of the taxonomy<br> @@ -132,14 +134,25 @@ includes taxonomies, lists and the homepage. The last two can also be reversed: **.Data.Terms.Alphabetical.Reverse**, **.Data.Terms.ByCount.Reverse**. +### Taxonomies elsewhere + +The **.Site.Taxonomies** variable holds all taxonomies defines site-wide. It is a map of the taxonomy name to a list of its values. For example: "tags" -> ["tag1", "tag2", "tag3"]. Each value, though, is not a string but rather a [Taxonomy variable](#the-taxonomy-variable). + +#### The Taxonomy variable + +The Taxonomy variable, available as **.Site.Taxonomies.tags** for example, contains the list of tags (values) and, for each of those, their corresponding content pages. + + + ## Site Variables Also available is `.Site` which has the following: **.Site.BaseURL** The base URL for the site as defined in the site configuration file.<br> **.Site.RSSLink** The URL for the site RSS.<br> -**.Site.Taxonomies** The [taxonomies](/taxonomies/usage/) for the entire site. Replaces the now-obsolete `.Site.Indexes` since v0.11.<br> -**.Site.Pages** Array of all content ordered by Date, newest first. Replaces the now-deprecated `.Site.Recent` starting v0.13.<br> +**.Site.Taxonomies** The [taxonomies](/taxonomies/usage/) for the entire site. Replaces the now-obsolete `.Site.Indexes` since v0.11. Also see section [Taxonomies elsewhere](#taxonomies-elsewhere).<br> +**.Site.Pages** Array of all content ordered by Date, newest first. Replaces the now-deprecated `.Site.Recent` starting v0.13. This array contains only the pages in the current language.<br> +**.Site.AllPages** Array of all pages regardless of their translation.<br> **.Site.Params** A container holding the values from the `params` section of your site configuration file. For example, a TOML config file might look like this: baseurl = "http://yoursite.example.com/" @@ -152,7 +165,7 @@ Also available is `.Site` which has the following: **.Site.Menus** All of the menus in the site.<br> **.Site.Title** A string representing the title of the site.<br> **.Site.Author** A map of the authors as defined in the site configuration.<br> -**.Site.LanguageCode** A string representing the language as defined in the site configuration.<br> +**.Site.LanguageCode** A string representing the language as defined in the site configuration. This is mostly used to populate the RSS feeds with the right language code.<br> **.Site.DisqusShortname** A string representing the shortname of the Disqus shortcode as defined in the site configuration.<br> **.Site.GoogleAnalytics** A string representing your tracking code for Google Analytics as defined in the site configuration.<br> **.Site.Copyright** A string representing the copyright of your web site as defined in the site configuration.<br> @@ -160,6 +173,10 @@ Also available is `.Site` which has the following: **.Site.Permalinks** A string to override the default permalink format. Defined in the site configuration.<br> **.Site.BuildDrafts** A boolean (Default: false) to indicate whether to build drafts. Defined in the site configuration.<br> **.Site.Data** Custom data, see [Data Files](/extras/datafiles/).<br> +**.Site.Multilingual** Whether the site supports internationalization of the content. With this mode enabled, all your posts' URLs will be prefixed with the language (ex: `/en/2016/01/01/my-post`)<br> +**.Site.CurrentLanguage** This indicates which language you are currently rendering the website for. When using `Multilingual` mode, will render the site in this language. You can then run `hugo` again with a second `config` file, with the other languages. When using `i18n` and `T` template functions, it will use the `i18n/*.yaml` files (in either `/themes/[yourtheme]/i18n` or the `/i18n`, translations in the latter having precedence).<br> +**.Site.LanguagePrefix** When `Multilingual` is enabled, this will hold `/{{ .Site.CurrentLanguage}}`, otherwise will be an empty string. Using this to prefix taxonomies or other hard-coded links ensures your keep your theme compatible with Multilingual configurations. +**.Site.Languages** An ordered list of languages when Multilingual is enabled. Used in your templates to iterate through and create links to different languages.<br> ## File Variables diff --git a/helpers/path.go b/helpers/path.go index 0c18e5397..a8a50aab3 100644 --- a/helpers/path.go +++ b/helpers/path.go @@ -182,6 +182,12 @@ func GetThemeDataDirPath() (string, error) { return getThemeDirPath("data") } +// GetThemeI18nDirPath returns the theme's i18n dir path if theme is set. +// If theme is set and the i18n dir doesn't exist, an error is returned. +func GetThemeI18nDirPath() (string, error) { + return getThemeDirPath("i18n") +} + func getThemeDirPath(path string) (string, error) { if !ThemeSet() { return "", errors.New("No theme set") diff --git a/hugolib/embedded_shortcodes_test.go b/hugolib/embedded_shortcodes_test.go index 360ec1209..18f807fbf 100644 --- a/hugolib/embedded_shortcodes_test.go +++ b/hugolib/embedded_shortcodes_test.go @@ -56,8 +56,8 @@ func doTestShortcodeCrossrefs(t *testing.T, relative bool) { templ := tpl.New() p, _ := pageFromString(simplePageWithURL, path) p.Node.Site = &SiteInfo{ - Pages: &(Pages{p}), - BaseURL: template.URL(helpers.SanitizeURLKeepTrailingSlash(baseURL)), + AllPages: &(Pages{p}), + BaseURL: template.URL(helpers.SanitizeURLKeepTrailingSlash(baseURL)), } output, err := HandleShortcodes(in, p, templ) diff --git a/hugolib/i18n.go b/hugolib/i18n.go new file mode 100644 index 000000000..cb5061501 --- /dev/null +++ b/hugolib/i18n.go @@ -0,0 +1,36 @@ +// Copyright 2016 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hugolib + +import ( + "github.com/nicksnyder/go-i18n/i18n/bundle" + "github.com/spf13/hugo/source" + "github.com/spf13/hugo/tpl" +) + +func loadI18n(sources []source.Input, lang string) (err error) { + i18nBundle := bundle.New() + for _, currentSource := range sources { + for _, r := range currentSource.Files() { + err = i18nBundle.ParseTranslationFileBytes(r.LogicalName(), r.Bytes()) + if err != nil { + return + } + } + } + + tpl.SetI18nTfunc(lang, i18nBundle) + + return nil +} diff --git a/hugolib/menu_test.go b/hugolib/menu_test.go index df6ccf629..602775a4b 100644 --- a/hugolib/menu_test.go +++ b/hugolib/menu_test.go @@ -691,13 +691,7 @@ func testSiteSetup(s *Site, t *testing.T) { s.Menus = Menus{} s.initializeSiteInfo() - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } - - if err := s.buildSiteMeta(); err != nil { - t.Fatalf("Unable to build site metadata: %s", err) - } + createPagesAndMeta(t, s) } func tomlToMap(s string) (map[string]interface{}, error) { diff --git a/hugolib/multilingual.go b/hugolib/multilingual.go new file mode 100644 index 000000000..2552bad4c --- /dev/null +++ b/hugolib/multilingual.go @@ -0,0 +1,48 @@ +package hugolib + +import ( + "github.com/spf13/cast" + "github.com/spf13/viper" +) + +type Multilingual struct { + enabled bool + config *viper.Viper + + Languages []string +} + +func (ml *Multilingual) GetString(key string) string { return cast.ToString(ml.Get(key)) } +func (ml *Multilingual) GetStringMap(key string) map[string]interface{} { + return cast.ToStringMap(ml.Get(key)) +} + +func (ml *Multilingual) GetStringMapString(key string) map[string]string { + return cast.ToStringMapString(ml.Get(key)) +} + +func (ml *Multilingual) Get(key string) interface{} { + if ml != nil && ml.config != nil && ml.config.IsSet(key) { + return ml.config.Get(key) + } + return viper.Get(key) +} + +func (s *Site) SetMultilingualConfig(currentLang string, orderedLanguages []string, langConfigs map[string]interface{}) { + conf := viper.New() + for k, val := range cast.ToStringMap(langConfigs[currentLang]) { + conf.Set(k, val) + } + conf.Set("CurrentLanguage", currentLang) + ml := &Multilingual{ + enabled: len(langConfigs) > 0, + config: conf, + Languages: orderedLanguages, + } + viper.Set("Multilingual", ml.enabled) + s.Multilingual = ml +} + +func (s *Site) multilingualEnabled() bool { + return s.Multilingual != nil && s.Multilingual.enabled +} diff --git a/hugolib/page.go b/hugolib/page.go index f64cdf95f..5c281bb96 100644 --- a/hugolib/page.go +++ b/hugolib/page.go @@ -61,8 +61,10 @@ type Page struct { PublishDate time.Time ExpiryDate time.Time Markup string + Translations Translations extension string contentType string + lang string renderable bool Layout string layoutsCalculated []string @@ -300,9 +302,11 @@ func (p *Page) getRenderingConfig() *helpers.Blackfriday { func newPage(filename string) *Page { page := Page{contentType: "", - Source: Source{File: *source.NewFile(filename)}, - Node: Node{Keywords: []string{}, Sitemap: Sitemap{Priority: -1}}, - Params: make(map[string]interface{})} + Source: Source{File: *source.NewFile(filename)}, + Node: Node{Keywords: []string{}, Sitemap: Sitemap{Priority: -1}}, + Params: make(map[string]interface{}), + Translations: make(Translations), + } jww.DEBUG.Println("Reading from", page.File.Path()) return &page @@ -445,11 +449,13 @@ func (p *Page) permalink() (*url.URL, error) { if len(pSlug) > 0 { permalink = helpers.URLPrep(viper.GetBool("UglyURLs"), path.Join(dir, p.Slug+"."+p.Extension())) } else { - _, t := filepath.Split(p.Source.LogicalName()) + t := p.Source.TranslationBaseName() permalink = helpers.URLPrep(viper.GetBool("UglyURLs"), path.Join(dir, helpers.ReplaceExtension(strings.TrimSpace(t), p.Extension()))) } } + permalink = p.addMultilingualWebPrefix(permalink) + return helpers.MakePermalink(baseURL, permalink), nil } @@ -460,6 +466,10 @@ func (p *Page) Extension() string { return viper.GetString("DefaultExtension") } +func (p *Page) Lang() string { + return p.lang +} + func (p *Page) LinkTitle() string { if len(p.linkTitle) > 0 { return p.linkTitle @@ -699,29 +709,29 @@ func (p *Page) getParam(key string, stringToLower bool) interface{} { return nil } - switch v.(type) { + switch val := v.(type) { case bool: - return v - case time.Time: - return v + return val + case string: + if stringToLower { + return strings.ToLower(val) + } + return val case int64, int32, int16, int8, int: return cast.ToInt(v) case float64, float32: return cast.ToFloat64(v) - case map[string]interface{}: // JSON and TOML - return v - case map[interface{}]interface{}: // YAML - return v - case string: - if stringToLower { - return strings.ToLower(v.(string)) - } - return v + case time.Time: + return val case []string: if stringToLower { - return helpers.SliceToLower(v.([]string)) + return helpers.SliceToLower(val) } return v + case map[string]interface{}: // JSON and TOML + return v + case map[interface{}]interface{}: // YAML + return v } jww.ERROR.Printf("GetParam(\"%s\"): Unknown type %s\n", key, reflect.TypeOf(v)) @@ -851,6 +861,7 @@ func (p *Page) parse(reader io.Reader) error { p.renderable = psr.IsRenderable() p.frontmatter = psr.FrontMatter() p.rawContent = psr.Content() + p.lang = p.Source.File.Lang() meta, err := psr.Metadata() if meta != nil { @@ -975,7 +986,6 @@ func (p *Page) FullFilePath() string { } func (p *Page) TargetPath() (outfile string) { - // Always use URL if it's specified if len(strings.TrimSpace(p.URL)) > 2 { outfile = strings.TrimSpace(p.URL) @@ -997,6 +1007,7 @@ func (p *Page) TargetPath() (outfile string) { outfile += "index.html" } outfile = filepath.FromSlash(outfile) + outfile = p.addMultilingualFilesystemPrefix(outfile) return } } @@ -1005,8 +1016,22 @@ func (p *Page) TargetPath() (outfile string) { outfile = strings.TrimSpace(p.Slug) + "." + p.Extension() } else { // Fall back to filename - outfile = helpers.ReplaceExtension(p.Source.LogicalName(), p.Extension()) + outfile = helpers.ReplaceExtension(p.Source.TranslationBaseName(), p.Extension()) } - return filepath.Join(strings.ToLower(helpers.MakePath(p.Source.Dir())), strings.TrimSpace(outfile)) + return p.addMultilingualFilesystemPrefix(filepath.Join(strings.ToLower(helpers.MakePath(p.Source.Dir())), strings.TrimSpace(outfile))) +} + +func (p *Page) addMultilingualWebPrefix(outfile string) string { + if p.Lang() == "" { + return outfile + } + return "/" + path.Join(p.Lang(), outfile) +} + +func (p *Page) addMultilingualFilesystemPrefix(outfile string) string { + if p.Lang() == "" { + return outfile + } + return string(filepath.Separator) + filepath.Join(p.Lang(), outfile) } diff --git a/hugolib/permalinks.go b/hugolib/permalinks.go index 497fc10b2..581f8740f 100644 --- a/hugolib/permalinks.go +++ b/hugolib/permalinks.go @@ -159,7 +159,7 @@ func pageToPermalinkTitle(p *Page, _ string) (string, error) { func pageToPermalinkFilename(p *Page, _ string) (string, error) { //var extension = p.Source.Ext //var name = p.Source.Path()[0 : len(p.Source.Path())-len(extension)] - return helpers.URLize(p.Source.BaseFileName()), nil + return helpers.URLize(p.Source.TranslationBaseName()), nil } // if the page has a slug, return the slug, else return the title diff --git a/hugolib/planner.go b/hugolib/planner.go index 22dd2586b..6086718c0 100644 --- a/hugolib/planner.go +++ b/hugolib/planner.go @@ -25,7 +25,7 @@ func (s *Site) ShowPlan(out io.Writer) (err error) { fmt.Fprintf(out, "No source files provided.\n") } - for _, p := range s.Pages { + for _, p := range s.AllPages { fmt.Fprintf(out, "%s", p.Source.Path()) if p.IsRenderable() { fmt.Fprintf(out, " (renderer: markdown)") diff --git a/hugolib/robotstxt_test.go b/hugolib/robotstxt_test.go index 776ff3aca..461d04e5d 100644 --- a/hugolib/robotstxt_test.go +++ b/hugolib/robotstxt_test.go @@ -46,13 +46,7 @@ func TestRobotsTXTOutput(t *testing.T) { s.prepTemplates("robots.txt", robotTxtTemplate) - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } - - if err := s.buildSiteMeta(); err != nil { - t.Fatalf("Unable to build site metadata: %s", err) - } + createPagesAndMeta(t, s) if err := s.renderHomePage(); err != nil { t.Fatalf("Unable to RenderHomePage: %s", err) diff --git a/hugolib/rss_test.go b/hugolib/rss_test.go index 5eca01143..ec6ee87bd 100644 --- a/hugolib/rss_test.go +++ b/hugolib/rss_test.go @@ -59,13 +59,7 @@ func TestRSSOutput(t *testing.T) { s.initializeSiteInfo() s.prepTemplates("rss.xml", rssTemplate) - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } - - if err := s.buildSiteMeta(); err != nil { - t.Fatalf("Unable to build site metadata: %s", err) - } + createPagesAndMeta(t, s) if err := s.renderHomePage(); err != nil { t.Fatalf("Unable to RenderHomePage: %s", err) diff --git a/hugolib/site.go b/hugolib/site.go index c5851e813..308e35c90 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -20,6 +20,7 @@ import ( "io" "net/url" "os" + "path" "path/filepath" "sort" "strconv" @@ -29,8 +30,6 @@ import ( "sync/atomic" - "path" - "github.com/bep/inflect" "github.com/fsnotify/fsnotify" "github.com/spf13/afero" @@ -76,6 +75,7 @@ var distinctErrorLogger = helpers.NewDistinctErrorLogger() // 5. The entire collection of files is written to disk. type Site struct { Pages Pages + AllPages Pages Files []*source.File Tmpl tpl.Template Taxonomies TaxonomyList @@ -87,6 +87,7 @@ type Site struct { targets targetList targetListInit sync.Once RunMode runmode + Multilingual *Multilingual draftCount int futureCount int expiredCount int @@ -106,7 +107,8 @@ type SiteInfo struct { Authors AuthorList Social SiteSocial Sections Taxonomy - Pages *Pages + Pages *Pages // Includes only pages in this language + AllPages *Pages // Includes other translated pages, excluding those in this language. Files *[]*source.File Menus *Menus Hugo *HugoInfo @@ -125,6 +127,11 @@ type SiteInfo struct { preserveTaxonomyNames bool paginationPageCount uint64 Data *map[string]interface{} + + Multilingual bool + CurrentLanguage string + LanguagePrefix string + Languages []string } // SiteSocial is a place to put social details on a site level. These are the @@ -150,17 +157,17 @@ func (s *SiteInfo) GetParam(key string) interface{} { return nil } - switch v.(type) { + switch val := v.(type) { case bool: - return cast.ToBool(v) + return val case string: - return cast.ToString(v) + return val case int64, int32, int16, int8, int: return cast.ToInt(v) case float64, float32: return cast.ToFloat64(v) case time.Time: - return cast.ToTime(v) + return val case []string: return v } @@ -181,7 +188,7 @@ func (s *SiteInfo) refLink(ref string, page *Page, relative bool) (string, error var link string if refURL.Path != "" { - for _, page := range []*Page(*s.Pages) { + for _, page := range []*Page(*s.AllPages) { refPath := filepath.FromSlash(refURL.Path) if page.Source.Path() == refPath || page.Source.LogicalName() == refPath { target = page @@ -256,7 +263,7 @@ func (s *SiteInfo) SourceRelativeLink(ref string, currentPage *Page) (string, er } } - for _, page := range []*Page(*s.Pages) { + for _, page := range []*Page(*s.AllPages) { if page.Source.Path() == refPath { target = page break @@ -265,14 +272,14 @@ func (s *SiteInfo) SourceRelativeLink(ref string, currentPage *Page) (string, er // need to exhaust the test, then try with the others :/ // if the refPath doesn't end in a filename with extension `.md`, then try with `.md` , and then `/index.md` mdPath := strings.TrimSuffix(refPath, string(os.PathSeparator)) + ".md" - for _, page := range []*Page(*s.Pages) { + for _, page := range []*Page(*s.AllPages) { if page.Source.Path() == mdPath { target = page break } } indexPath := filepath.Join(refPath, "index.md") - for _, page := range []*Page(*s.Pages) { + for _, page := range []*Page(*s.AllPages) { if page.Source.Path() == indexPath { target = page break @@ -443,7 +450,7 @@ func (s *Site) ReBuild(events []fsnotify.Event) error { // If a content file changes, we need to reload only it and re-render the entire site. - // First step is to read the changed files and (re)place them in site.Pages + // First step is to read the changed files and (re)place them in site.AllPages // This includes processing any meta-data for that content // The second step is to convert the content into HTML @@ -479,7 +486,7 @@ func (s *Site) ReBuild(events []fsnotify.Event) error { if len(tmplChanged) > 0 || len(dataChanged) > 0 { // Do not need to read the files again, but they need conversion // for shortocde re-rendering. - for _, p := range s.Pages { + for _, p := range s.AllPages { pageChan <- p } } @@ -538,6 +545,9 @@ func (s *Site) ReBuild(events []fsnotify.Event) error { s.timerStep("read & convert pages from source") + // FIXME: does this go inside the next `if` statement ? + s.setupTranslations() + if len(sourceChanged) > 0 { s.setupPrevNext() if err = s.buildSiteMeta(); err != nil { @@ -665,9 +675,9 @@ func (s *Site) readDataFromSourceFS() error { dataSources = append(dataSources, &source.Filesystem{Base: s.absDataDir()}) // have to be last - duplicate keys in earlier entries will win - themeStaticDir, err := helpers.GetThemeDataDirPath() + themeDataDir, err := helpers.GetThemeDataDirPath() if err == nil { - dataSources = append(dataSources, &source.Filesystem{Base: themeStaticDir}) + dataSources = append(dataSources, &source.Filesystem{Base: themeDataDir}) } err = s.loadData(dataSources) @@ -688,10 +698,25 @@ func (s *Site) Process() (err error) { return } + i18nSources := []source.Input{&source.Filesystem{Base: s.absI18nDir()}} + + themeI18nDir, err := helpers.GetThemeI18nDirPath() + if err == nil { + i18nSources = []source.Input{&source.Filesystem{Base: themeI18nDir}, i18nSources[0]} + } + + if err = loadI18n(i18nSources, s.Multilingual.GetString("CurrentLanguage")); err != nil { + return + } + s.timerStep("load i18n") + if err = s.createPages(); err != nil { return } + + s.setupTranslations() s.setupPrevNext() + if err = s.buildSiteMeta(); err != nil { return } @@ -711,6 +736,27 @@ func (s *Site) setupPrevNext() { } } +func (s *Site) setupTranslations() { + if !s.multilingualEnabled() { + s.Pages = s.AllPages + return + } + + currentLang := s.Multilingual.GetString("CurrentLanguage") + + allTranslations := pagesToTranslationsMap(s.AllPages) + assignTranslationsToPages(allTranslations, s.AllPages) + + var currentLangPages []*Page + for _, p := range s.AllPages { + if p.Lang() == "" || strings.HasPrefix(currentLang, p.lang) { + currentLangPages = append(currentLangPages, p) + } + } + + s.Pages = currentLangPages +} + func (s *Site) Render() (err error) { if err = s.renderAliases(); err != nil { return @@ -771,32 +817,47 @@ func (s *Site) initialize() (err error) { } func (s *Site) initializeSiteInfo() { - params := viper.GetStringMap("Params") + params := s.Multilingual.GetStringMap("Params") permalinks := make(PermalinkOverrides) for k, v := range viper.GetStringMapString("Permalinks") { permalinks[k] = pathPattern(v) } + languagePrefix := "" + if s.multilingualEnabled() { + languagePrefix = "/" + s.Multilingual.GetString("CurrentLanguage") + } + + languages := []string{} + if s.Multilingual != nil { + languages = s.Multilingual.Languages + } + s.Info = SiteInfo{ BaseURL: template.URL(helpers.SanitizeURLKeepTrailingSlash(viper.GetString("BaseURL"))), - Title: viper.GetString("Title"), - Author: viper.GetStringMap("author"), - Social: viper.GetStringMapString("social"), - LanguageCode: viper.GetString("languagecode"), - Copyright: viper.GetString("copyright"), - DisqusShortname: viper.GetString("DisqusShortname"), + Title: s.Multilingual.GetString("Title"), + Author: s.Multilingual.GetStringMap("author"), + Social: s.Multilingual.GetStringMapString("social"), + LanguageCode: s.Multilingual.GetString("languagecode"), + Copyright: s.Multilingual.GetString("copyright"), + DisqusShortname: s.Multilingual.GetString("DisqusShortname"), + Multilingual: s.multilingualEnabled(), + CurrentLanguage: s.Multilingual.GetString("CurrentLanguage"), + LanguagePrefix: languagePrefix, + Languages: languages, GoogleAnalytics: viper.GetString("GoogleAnalytics"), RSSLink: s.permalinkStr(viper.GetString("RSSUri")), BuildDrafts: viper.GetBool("BuildDrafts"), canonifyURLs: viper.GetBool("CanonifyURLs"), preserveTaxonomyNames: viper.GetBool("PreserveTaxonomyNames"), - Pages: &s.Pages, - Files: &s.Files, - Menus: &s.Menus, - Params: params, - Permalinks: permalinks, - Data: &s.Data, + AllPages: &s.AllPages, + Pages: &s.Pages, + Files: &s.Files, + Menus: &s.Menus, + Params: params, + Permalinks: permalinks, + Data: &s.Data, } } @@ -808,6 +869,10 @@ func (s *Site) absDataDir() string { return helpers.AbsPathify(viper.GetString("DataDir")) } +func (s *Site) absI18nDir() string { + return helpers.AbsPathify(viper.GetString("I18nDir")) +} + func (s *Site) absThemeDir() string { return helpers.AbsPathify(viper.GetString("themesDir") + "/" + viper.GetString("theme")) } @@ -903,7 +968,7 @@ func (s *Site) convertSource() chan error { go converterCollator(s, results, errs) - for _, p := range s.Pages { + for _, p := range s.AllPages { pageChan <- p } @@ -997,7 +1062,7 @@ func converterCollator(s *Site, results <-chan HandledResult, errs chan<- error) func (s *Site) addPage(page *Page) { if page.shouldBuild() { - s.Pages = append(s.Pages, page) + s.AllPages = append(s.AllPages, page) } if page.IsDraft() { @@ -1014,8 +1079,8 @@ func (s *Site) addPage(page *Page) { } func (s *Site) removePageByPath(path string) { - if i := s.Pages.FindPagePosByFilePath(path); i >= 0 { - page := s.Pages[i] + if i := s.AllPages.FindPagePosByFilePath(path); i >= 0 { + page := s.AllPages[i] if page.IsDraft() { s.draftCount-- @@ -1029,12 +1094,12 @@ func (s *Site) removePageByPath(path string) { s.expiredCount-- } - s.Pages = append(s.Pages[:i], s.Pages[i+1:]...) + s.AllPages = append(s.AllPages[:i], s.AllPages[i+1:]...) } } func (s *Site) removePage(page *Page) { - if i := s.Pages.FindPagePos(page); i >= 0 { + if i := s.AllPages.FindPagePos(page); i >= 0 { if page.IsDraft() { s.draftCount-- } @@ -1047,7 +1112,7 @@ func (s *Site) removePage(page *Page) { s.expiredCount-- } - s.Pages = append(s.Pages[:i], s.Pages[i+1:]...) + s.AllPages = append(s.AllPages[:i], s.AllPages[i+1:]...) } } @@ -1086,7 +1151,7 @@ func incrementalReadCollator(s *Site, results <-chan HandledResult, pageChan cha } } - s.Pages.Sort() + s.AllPages.Sort() close(coordinator) if len(errMsgs) == 0 { @@ -1112,7 +1177,7 @@ func readCollator(s *Site, results <-chan HandledResult, errs chan<- error) { } } - s.Pages.Sort() + s.AllPages.Sort() if len(errMsgs) == 0 { errs <- nil return @@ -1298,9 +1363,8 @@ func (s *Site) resetPageBuildState() { s.Info.paginationPageCount = 0 - for _, p := range s.Pages { + for _, p := range s.AllPages { p.scratch = newScratch() - } } @@ -1326,17 +1390,6 @@ func (s *Site) assembleSections() { } } -func (s *Site) possibleTaxonomies() (taxonomies []string) { - for _, p := range s.Pages { - for k := range p.Params { - if !helpers.InStringArray(taxonomies, k) { - taxonomies = append(taxonomies, k) - } - } - } - return -} - // renderAliases renders shell pages that simply have a redirect in the header. func (s *Site) renderAliases() error { for _, p := range s.Pages { @@ -1536,6 +1589,19 @@ func (s *Site) newTaxonomyNode(t taxRenderInfo) (*Node, string) { return n, base } +// addMultilingualPrefix adds the `en/` prefix to the path passed as parameter. +// `basePath` must not start with http:// +func (s *Site) addMultilingualPrefix(basePath string) string { + hadPrefix := strings.HasPrefix(basePath, "/") + if s.multilingualEnabled() { + basePath = path.Join(s.Multilingual.GetString("CurrentLanguage"), basePath) + if hadPrefix { + basePath = "/" + basePath + } + } + return basePath +} + func taxonomyRenderer(s *Site, taxes <-chan taxRenderInfo, results chan<- error, wg *sync.WaitGroup) { defer wg.Done() @@ -1549,6 +1615,8 @@ func taxonomyRenderer(s *Site, taxes <-chan taxRenderInfo, results chan<- error, n, base = s.newTaxonomyNode(t) + base = s.addMultilingualPrefix(base) + dest := base if viper.GetBool("UglyURLs") { dest = helpers.Uglify(base + ".html") @@ -1623,7 +1691,7 @@ func (s *Site) renderListsOfTaxonomyTerms() (err error) { layouts := []string{"taxonomy/" + singular + ".terms.html", "_default/terms.html", "indexes/indexes.html"} layouts = s.appendThemeTemplates(layouts) if s.layoutExists(layouts...) { - if err := s.renderAndWritePage("taxonomy terms for "+singular, plural+"/index.html", n, layouts...); err != nil { + if err := s.renderAndWritePage("taxonomy terms for "+singular, s.addMultilingualPrefix(plural+"/index.html"), n, layouts...); err != nil { return err } } @@ -1664,8 +1732,10 @@ func (s *Site) renderSectionLists() error { section = helpers.MakePathSanitized(section) } + base := s.addMultilingualPrefix(section) + n := s.newSectionListNode(sectionName, section, data) - if err := s.renderAndWritePage(fmt.Sprintf("section %s", section), section, n, s.appendThemeTemplates(layouts)...); err != nil { + if err := s.renderAndWritePage(fmt.Sprintf("section %s", section), base, n, s.appendThemeTemplates(layouts)...); err != nil { return err } @@ -1674,7 +1744,7 @@ func (s *Site) renderSectionLists() error { paginatePath := viper.GetString("paginatePath") // write alias for page 1 - s.writeDestAlias(helpers.PaginateAliasPath(section, 1), s.permalink(section)) + s.writeDestAlias(helpers.PaginateAliasPath(base, 1), s.permalink(base)) pagers := n.paginator.Pagers() @@ -1692,7 +1762,7 @@ func (s *Site) renderSectionLists() error { sectionPagerNode.Lastmod = first.Lastmod } pageNumber := i + 1 - htmlBase := fmt.Sprintf("/%s/%s/%d", section, paginatePath, pageNumber) + htmlBase := fmt.Sprintf("/%s/%s/%d", base, paginatePath, pageNumber) if err := s.renderAndWritePage(fmt.Sprintf("section %s", section), filepath.FromSlash(htmlBase), sectionPagerNode, layouts...); err != nil { return err } @@ -1702,10 +1772,10 @@ func (s *Site) renderSectionLists() error { if !viper.GetBool("DisableRSS") && section != "" { // XML Feed rssuri := viper.GetString("RSSUri") - n.URL = s.permalinkStr(section + "/" + rssuri) - n.Permalink = s.permalink(section) + n.URL = s.permalinkStr(base + "/" + rssuri) + n.Permalink = s.permalink(base) rssLayouts := []string{"section/" + section + ".rss.xml", "_default/rss.xml", "rss.xml", "_internal/_default/rss.xml"} - if err := s.renderAndWriteXML("section "+section+" rss", section+"/"+rssuri, n, s.appendThemeTemplates(rssLayouts)...); err != nil { + if err := s.renderAndWriteXML("section "+section+" rss", base+"/"+rssuri, n, s.appendThemeTemplates(rssLayouts)...); err != nil { return err } } @@ -1713,24 +1783,11 @@ func (s *Site) renderSectionLists() error { return nil } -func (s *Site) newHomeNode() *Node { - n := s.newNode() - n.Title = n.Site.Title - n.IsHome = true - s.setURLs(n, "/") - n.Data["Pages"] = s.Pages - if len(s.Pages) != 0 { - n.Date = s.Pages[0].Date - n.Lastmod = s.Pages[0].Lastmod - } - return n -} - func (s *Site) renderHomePage() error { n := s.newHomeNode() layouts := s.appendThemeTemplates([]string{"index.html", "_default/list.html"}) - if err := s.renderAndWritePage("homepage", helpers.FilePathSeparator, n, layouts...); err != nil { + if err := s.renderAndWritePage("homepage", s.addMultilingualPrefix(helpers.FilePathSeparator), n, layouts...); err != nil { return err } @@ -1739,7 +1796,7 @@ func (s *Site) renderHomePage() error { paginatePath := viper.GetString("paginatePath") // write alias for page 1 - s.writeDestAlias(helpers.PaginateAliasPath("", 1), s.permalink("/")) + s.writeDestAlias(s.addMultilingualPrefix(helpers.PaginateAliasPath("", 1)), s.permalink("/")) pagers := n.paginator.Pagers() @@ -1758,6 +1815,7 @@ func (s *Site) renderHomePage() error { } pageNumber := i + 1 htmlBase := fmt.Sprintf("/%s/%d", paginatePath, pageNumber) + htmlBase = s.addMultilingualPrefix(htmlBase) if err := s.renderAndWritePage(fmt.Sprintf("homepage"), filepath.FromSlash(htmlBase), homePagerNode, layouts...); err != nil { return err } @@ -1780,7 +1838,7 @@ func (s *Site) renderHomePage() error { rssLayouts := []string{"rss.xml", "_default/rss.xml", "_internal/_default/rss.xml"} - if err := s.renderAndWriteXML("homepage rss", viper.GetString("RSSUri"), n, s.appendThemeTemplates(rssLayouts)...); err != nil { + if err := s.renderAndWriteXML("homepage rss", s.addMultilingualPrefix(viper.GetString("RSSUri")), n, s.appendThemeTemplates(rssLayouts)...); err != nil { return err } } @@ -1804,6 +1862,19 @@ func (s *Site) renderHomePage() error { return nil } +func (s *Site) newHomeNode() *Node { + n := s.newNode() + n.Title = n.Site.Title + n.IsHome = true + s.setURLs(n, "/") + n.Data["Pages"] = s.Pages + if len(s.Pages) != 0 { + n.Date = s.Pages[0].Date + n.Lastmod = s.Pages[0].Lastmod + } + return n +} + func (s *Site) renderSitemap() error { if viper.GetBool("DisableSitemap") { return nil @@ -1845,7 +1916,7 @@ func (s *Site) renderSitemap() error { smLayouts := []string{"sitemap.xml", "_default/sitemap.xml", "_internal/_default/sitemap.xml"} - if err := s.renderAndWriteXML("sitemap", page.Sitemap.Filename, n, s.appendThemeTemplates(smLayouts)...); err != nil { + if err := s.renderAndWriteXML("sitemap", s.addMultilingualPrefix(page.Sitemap.Filename), n, s.appendThemeTemplates(smLayouts)...); err != nil { return err } @@ -1874,7 +1945,7 @@ func (s *Site) renderRobotsTXT() error { // Stats prints Hugo builds stats to the console. // This is what you see after a successful hugo build. -func (s *Site) Stats() { +func (s *Site) Stats(lang string, t0 time.Time) { jww.FEEDBACK.Println(s.draftStats()) jww.FEEDBACK.Println(s.futureStats()) jww.FEEDBACK.Println(s.expiredStats()) @@ -1886,9 +1957,14 @@ func (s *Site) Stats() { for _, pl := range taxonomies { jww.FEEDBACK.Printf("%d %s created\n", len(s.Taxonomies[pl]), pl) } + + if lang != "" { + jww.FEEDBACK.Printf("rendered lang %q in %v ms\n", lang, int(1000*time.Since(t0).Seconds())) + } } func (s *Site) setURLs(n *Node, in string) { + in = s.addMultilingualPrefix(in) n.URL = helpers.URLizeAndPrep(in) n.Permalink = s.permalink(n.URL) n.RSSLink = template.HTML(s.permalink(in + ".xml")) diff --git a/hugolib/site_test.go b/hugolib/site_test.go index f06c9de81..2f03dec97 100644 --- a/hugolib/site_test.go +++ b/hugolib/site_test.go @@ -18,6 +18,7 @@ import ( "fmt" "html/template" "io" + "io/ioutil" "path/filepath" "strings" "testing" @@ -92,16 +93,27 @@ func TestReadPagesFromSourceWithEmptySource(t *testing.T) { } func createAndRenderPages(t *testing.T, s *Site) { - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) + createPagesAndMeta(t, s) + + if err := s.renderPages(); err != nil { + t.Fatalf("Unable to render pages. %s", err) } +} + +func createPagesAndMeta(t *testing.T, s *Site) { + createPages(t, s) + + s.setupTranslations() + s.setupPrevNext() if err := s.buildSiteMeta(); err != nil { t.Fatalf("Unable to build site metadata: %s", err) } +} - if err := s.renderPages(); err != nil { - t.Fatalf("Unable to render pages. %s", err) +func createPages(t *testing.T, s *Site) { + if err := s.createPages(); err != nil { + t.Fatalf("Unable to create pages: %s", err) } } @@ -254,9 +266,8 @@ func TestDraftAndFutureRender(t *testing.T) { s.initializeSiteInfo() - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } + createPages(t, s) + return s } @@ -264,14 +275,14 @@ func TestDraftAndFutureRender(t *testing.T) { // Testing Defaults.. Only draft:true and publishDate in the past should be rendered s := siteSetup() - if len(s.Pages) != 1 { + if len(s.AllPages) != 1 { t.Fatal("Draft or Future dated content published unexpectedly") } // only publishDate in the past should be rendered viper.Set("BuildDrafts", true) s = siteSetup() - if len(s.Pages) != 2 { + if len(s.AllPages) != 2 { t.Fatal("Future Dated Posts published unexpectedly") } @@ -279,7 +290,7 @@ func TestDraftAndFutureRender(t *testing.T) { viper.Set("BuildDrafts", false) viper.Set("BuildFuture", true) s = siteSetup() - if len(s.Pages) != 2 { + if len(s.AllPages) != 2 { t.Fatal("Draft posts published unexpectedly") } @@ -287,7 +298,7 @@ func TestDraftAndFutureRender(t *testing.T) { viper.Set("BuildDrafts", true) viper.Set("BuildFuture", true) s = siteSetup() - if len(s.Pages) != 4 { + if len(s.AllPages) != 4 { t.Fatal("Drafts or Future posts not included as expected") } @@ -313,9 +324,8 @@ func TestFutureExpirationRender(t *testing.T) { s.initializeSiteInfo() - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } + createPages(t, s) + return s } @@ -323,17 +333,17 @@ func TestFutureExpirationRender(t *testing.T) { s := siteSetup() - if len(s.Pages) != 1 { - if len(s.Pages) > 1 { + if len(s.AllPages) != 1 { + if len(s.AllPages) > 1 { t.Fatal("Expired content published unexpectedly") } - if len(s.Pages) < 1 { + if len(s.AllPages) < 1 { t.Fatal("Valid content expired unexpectedly") } } - if s.Pages[0].Title == "doc2" { + if s.AllPages[0].Title == "doc2" { t.Fatal("Expired content published unexpectedly") } } @@ -689,17 +699,7 @@ func TestAbsURLify(t *testing.T) { s.prepTemplates("blue/single.html", templateWithURLAbs) - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } - - if err := s.buildSiteMeta(); err != nil { - t.Fatalf("Unable to build site metadata: %s", err) - } - - if err := s.renderPages(); err != nil { - t.Fatalf("Unable to render pages. %s", err) - } + createAndRenderPages(t, s) tests := []struct { file, expected string @@ -791,13 +791,7 @@ func TestOrderedPages(t *testing.T) { } s.initializeSiteInfo() - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } - - if err := s.buildSiteMeta(); err != nil { - t.Fatalf("Unable to build site metadata: %s", err) - } + createPagesAndMeta(t, s) if s.Sections["sect"][0].Weight != 2 || s.Sections["sect"][3].Weight != 6 { t.Errorf("Pages in unexpected order. First should be '%d', got '%d'", 2, s.Sections["sect"][0].Weight) @@ -865,13 +859,7 @@ func TestGroupedPages(t *testing.T) { } s.initializeSiteInfo() - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } - - if err := s.buildSiteMeta(); err != nil { - t.Fatalf("Unable to build site metadata: %s", err) - } + createPagesAndMeta(t, s) rbysection, err := s.Pages.GroupBy("Section", "desc") if err != nil { @@ -1055,13 +1043,7 @@ func TestWeightedTaxonomies(t *testing.T) { } s.initializeSiteInfo() - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } - - if err := s.buildSiteMeta(); err != nil { - t.Fatalf("Unable to build site metadata: %s", err) - } + createPagesAndMeta(t, s) if s.Taxonomies["tags"]["a"][0].Page.Title != "foo" { t.Errorf("Pages in unexpected order, 'foo' expected first, got '%v'", s.Taxonomies["tags"]["a"][0].Page.Title) @@ -1129,9 +1111,7 @@ func setupLinkingMockSite(t *testing.T) *Site { site.initializeSiteInfo() - if err := site.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } + createPagesAndMeta(t, site) return site } @@ -1341,3 +1321,159 @@ func TestSourceRelativeLinkFileing(t *testing.T) { } } } + +func TestMultilingualSwitch(t *testing.T) { + // General settings + viper.Set("DefaultExtension", "html") + viper.Set("baseurl", "http://example.com/blog") + viper.Set("DisableSitemap", false) + viper.Set("DisableRSS", false) + viper.Set("RSSUri", "index.xml") + viper.Set("Taxonomies", map[string]string{"tag": "tags"}) + viper.Set("Permalinks", map[string]string{"other": "/somewhere/else/:filename"}) + + // Sources + sources := []source.ByteSource{ + {filepath.FromSlash("sect/doc1.en.md"), []byte(`--- +title: doc1 +slug: doc1-slug +tags: + - tag1 +publishdate: "2000-01-01" +--- +# doc1 +*some content* +NOTE: slug should be used as URL +`)}, + {filepath.FromSlash("sect/doc1.fr.md"), []byte(`--- +title: doc1 +tags: + - tag1 + - tag2 +publishdate: "2000-01-04" +--- +# doc1 +*quelque contenu* +NOTE: should be in the 'en' Page's 'Translations' field. +NOTE: date is after "doc3" +`)}, + {filepath.FromSlash("sect/doc2.en.md"), []byte(`--- +title: doc2 +publishdate: "2000-01-02" +--- +# doc2 +*some content* +NOTE: without slug, "doc2" should be used, without ".en" as URL +`)}, + {filepath.FromSlash("sect/doc3.en.md"), []byte(`--- +title: doc3 +publishdate: "2000-01-03" +tags: + - tag2 +url: /superbob +--- +# doc3 +*some content* +NOTE: third 'en' doc, should trigger pagination on home page. +`)}, + {filepath.FromSlash("sect/doc4.md"), []byte(`--- +title: doc4 +tags: + - tag1 +publishdate: "2000-01-05" +--- +# doc4 +*du contenu francophone* +NOTE: should use the DefaultContentLanguage and mark this doc as 'fr'. +NOTE: doesn't have any corresponding translation in 'en' +`)}, + {filepath.FromSlash("other/doc5.fr.md"), []byte(`--- +title: doc5 +publishdate: "2000-01-06" +--- +# doc5 +*autre contenu francophone* +NOTE: should use the "permalinks" configuration with :filename +`)}, + } + + hugofs.InitMemFs() + + s := &Site{ + Source: &source.InMemorySource{ByteSource: sources}, + Multilingual: &Multilingual{ + config: viper.New(), + enabled: true, + }, + } + // Multilingual settings + viper.Set("Multilingual", true) + s.Multilingual.config.Set("CurrentLanguage", "en") + viper.Set("DefaultContentLanguage", "fr") + viper.Set("paginate", "2") + + s.prepTemplates() + s.initializeSiteInfo() + + createPagesAndMeta(t, s) + + assert.Len(t, s.Source.Files(), 6, "should have 6 source files") + assert.Len(t, s.Pages, 3, "should have 3 pages") + assert.Len(t, s.AllPages, 6, "should have 6 total pages (including translations)") + + doc1en := s.Pages[0] + permalink, err := doc1en.Permalink() + assert.NoError(t, err, "permalink call failed") + assert.Equal(t, "http://example.com/blog/en/sect/doc1-slug", permalink, "invalid doc1.en permalink") + assert.Len(t, doc1en.Translations, 1, "doc1-en should have one translation, excluding itself") + + doc2 := s.Pages[1] + permalink, err = doc2.Permalink() + assert.NoError(t, err, "permalink call failed") + assert.Equal(t, "http://example.com/blog/en/sect/doc2", permalink, "invalid doc2 permalink") + + doc3 := s.Pages[2] + permalink, err = doc3.Permalink() + assert.NoError(t, err, "permalink call failed") + assert.Equal(t, "http://example.com/blog/superbob", permalink, "invalid doc3 permalink") + assert.Equal(t, "/superbob", doc3.URL, "invalid url, was specified on doc3") + + assert.Equal(t, doc2.Next, doc3, "doc3 should follow doc2, in .Next") + + doc1fr := doc1en.Translations["fr"] + permalink, err = doc1fr.Permalink() + assert.NoError(t, err, "permalink call failed") + assert.Equal(t, "http://example.com/blog/fr/sect/doc1", permalink, "invalid doc1fr permalink") + + assert.Equal(t, doc1en.Translations["fr"], doc1fr, "doc1-en should have doc1-fr as translation") + assert.Equal(t, doc1fr.Translations["en"], doc1en, "doc1-fr should have doc1-en as translation") + + doc4 := s.AllPages[4] + permalink, err = doc4.Permalink() + assert.NoError(t, err, "permalink call failed") + assert.Equal(t, "http://example.com/blog/fr/sect/doc4", permalink, "invalid doc4 permalink") + assert.Len(t, doc4.Translations, 0, "found translations for doc4") + + doc5 := s.AllPages[5] + permalink, err = doc5.Permalink() + assert.NoError(t, err, "permalink call failed") + assert.Equal(t, "http://example.com/blog/fr/somewhere/else/doc5", permalink, "invalid doc5 permalink") + + // Taxonomies and their URLs + assert.Len(t, s.Taxonomies, 1, "should have 1 taxonomy") + tags := s.Taxonomies["tags"] + assert.Len(t, tags, 2, "should have 2 different tags") + assert.Equal(t, tags["tag1"][0].Page, doc1en, "first tag1 page should be doc1") + + // Expect the tags locations to be in certain places, with the /en/ prefixes, etc.. +} + +func assertFileContent(t *testing.T, path string, content string) { + fl, err := hugofs.Destination().Open(path) + assert.NoError(t, err, "file content not found when asserting on content of %s", path) + + cnt, err := ioutil.ReadAll(fl) + assert.NoError(t, err, "cannot read file content when asserting on content of %s", path) + + assert.Equal(t, content, string(cnt)) +} diff --git a/hugolib/site_url_test.go b/hugolib/site_url_test.go index c910c918d..6e011a500 100644 --- a/hugolib/site_url_test.go +++ b/hugolib/site_url_test.go @@ -97,12 +97,7 @@ func TestPageCount(t *testing.T) { s.initializeSiteInfo() s.prepTemplates("indexes/blue.html", indexTemplate) - if err := s.createPages(); err != nil { - t.Errorf("Unable to create pages: %s", err) - } - if err := s.buildSiteMeta(); err != nil { - t.Errorf("Unable to build site metadata: %s", err) - } + createPagesAndMeta(t, s) if err := s.renderSectionLists(); err != nil { t.Errorf("Unable to render section lists: %s", err) diff --git a/hugolib/sitemap_test.go b/hugolib/sitemap_test.go index 2ce842085..b9270ba58 100644 --- a/hugolib/sitemap_test.go +++ b/hugolib/sitemap_test.go @@ -51,13 +51,7 @@ func TestSitemapOutput(t *testing.T) { s.prepTemplates("sitemap.xml", SITEMAP_TEMPLATE) - if err := s.createPages(); err != nil { - t.Fatalf("Unable to create pages: %s", err) - } - - if err := s.buildSiteMeta(); err != nil { - t.Fatalf("Unable to build site metadata: %s", err) - } + createPagesAndMeta(t, s) if err := s.renderHomePage(); err != nil { t.Fatalf("Unable to RenderHomePage: %s", err) diff --git a/hugolib/taxonomy_test.go b/hugolib/taxonomy_test.go index ab9df1080..03d567aa7 100644 --- a/hugolib/taxonomy_test.go +++ b/hugolib/taxonomy_test.go @@ -20,18 +20,6 @@ import ( "github.com/spf13/viper" ) -func TestSitePossibleTaxonomies(t *testing.T) { - site := new(Site) - page, _ := NewPageFrom(strings.NewReader(pageYamlWithTaxonomiesA), "path/to/page") - site.Pages = append(site.Pages, page) - taxonomies := site.possibleTaxonomies() - if !compareStringSlice(taxonomies, []string{"tags", "categories"}) { - if !compareStringSlice(taxonomies, []string{"categories", "tags"}) { - t.Fatalf("possible taxonomies do not match [tags categories]. Got: %s", taxonomies) - } - } -} - func TestByCountOrderOfTaxonomies(t *testing.T) { viper.Reset() defer viper.Reset() diff --git a/hugolib/translations.go b/hugolib/translations.go new file mode 100644 index 000000000..b503071d8 --- /dev/null +++ b/hugolib/translations.go @@ -0,0 +1,59 @@ +// Copyright 2016 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package hugolib + +// Translations represent the other translations for a given page. The +// string here is the language code, as affected by the `post.LANG.md` +// filename. +type Translations map[string]*Page + +func pagesToTranslationsMap(pages []*Page) map[string]Translations { + out := make(map[string]Translations) + + for _, page := range pages { + base := page.TranslationBaseName() + + pageTranslation, present := out[base] + if !present { + pageTranslation = make(Translations) + } + + pageLang := page.Lang() + if pageLang == "" { + continue + } + + pageTranslation[pageLang] = page + out[base] = pageTranslation + } + + return out +} + +func assignTranslationsToPages(allTranslations map[string]Translations, pages []*Page) { + for _, page := range pages { + base := page.TranslationBaseName() + trans, exist := allTranslations[base] + if !exist { + continue + } + + for lang, translatedPage := range trans { + if translatedPage == page { + continue + } + page.Translations[lang] = translatedPage + } + } +} diff --git a/source/file.go b/source/file.go index 469c35eee..9012b91c4 100644 --- a/source/file.go +++ b/source/file.go @@ -19,6 +19,7 @@ import ( "strings" "github.com/spf13/hugo/helpers" + "github.com/spf13/viper" ) // File represents a source content file. @@ -26,11 +27,15 @@ import ( type File struct { relpath string // Original relative path, e.g. content/foo.txt logicalName string // foo.txt + baseName string // `post` for `post.md`, also `post.en` for `post.en.md` Contents io.Reader section string // The first directory dir string // The relative directory Path (minus file name) ext string // Just the ext (eg txt) uniqueID string // MD5 of the filename + + translationBaseName string // `post` for `post.es.md` (if `Multilingual` is enabled.) + lang string // The language code if `Multilingual` is enabled } // UniqueID is the MD5 hash of the filename and is for most practical applications, @@ -51,7 +56,17 @@ func (f *File) Bytes() []byte { // BaseFileName Filename without extension. func (f *File) BaseFileName() string { - return helpers.Filename(f.LogicalName()) + return f.baseName +} + +// Filename with no extension, not even the optional language extension part. +func (f *File) TranslationBaseName() string { + return f.translationBaseName +} + +// Lang for this page, if `Multilingual` is enabled on your site. +func (f *File) Lang() string { + return f.lang } // Section is first directory below the content root. @@ -108,6 +123,17 @@ func NewFile(relpath string) *File { f.dir, f.logicalName = filepath.Split(f.relpath) f.ext = strings.TrimPrefix(filepath.Ext(f.LogicalName()), ".") + f.baseName = helpers.Filename(f.LogicalName()) + if viper.GetBool("Multilingual") { + f.lang = strings.TrimPrefix(filepath.Ext(f.baseName), ".") + if f.lang == "" { + f.lang = viper.GetString("DefaultContentLanguage") + } + f.translationBaseName = helpers.Filename(f.baseName) + } else { + f.translationBaseName = f.baseName + } + f.section = helpers.GuessSection(f.Dir()) f.uniqueID = helpers.Md5String(f.LogicalName()) diff --git a/tpl/template_funcs.go b/tpl/template_funcs.go index 09191f41a..ed931c44b 100644 --- a/tpl/template_funcs.go +++ b/tpl/template_funcs.go @@ -1920,5 +1920,7 @@ func init() { "upper": func(a string) string { return strings.ToUpper(a) }, "urlize": helpers.URLize, "where": where, + "i18n": I18nTranslate, + "T": I18nTranslate, } } diff --git a/tpl/template_i18n.go b/tpl/template_i18n.go new file mode 100644 index 000000000..3b0f0cb63 --- /dev/null +++ b/tpl/template_i18n.go @@ -0,0 +1,47 @@ +// Copyright 2015 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tpl + +import ( + "fmt" + + "github.com/nicksnyder/go-i18n/i18n/bundle" + jww "github.com/spf13/jwalterweatherman" +) + +var i18nTfunc bundle.TranslateFunc + +func SetI18nTfunc(lang string, bndl *bundle.Bundle) { + tFunc, err := bndl.Tfunc(lang) + if err == nil { + i18nTfunc = tFunc + return + } + + jww.WARN.Printf("could not load translations for language %q (%s), will not translate!\n", lang, err.Error()) + i18nTfunc = bundle.TranslateFunc(func(id string, args ...interface{}) string { + // TODO: depending on the site mode, we might want to fall back on the default + // language's translation. + // TODO: eventually, we could add --i18n-warnings and print something when + // such things happen. + return fmt.Sprintf("[i18n: %s]", id) + }) +} + +func I18nTranslate(id string, args ...interface{}) (string, error) { + if i18nTfunc == nil { + return "", fmt.Errorf("i18n not initialized, have you configured everything properly?") + } + return i18nTfunc(id, args...), nil +} |