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..
This commit is contained in:
Alexandre Bourget 2016-05-14 00:35:16 -04:00 committed by Bjørn Erik Pedersen
parent faa3472fa2
commit ec33732fbe
29 changed files with 1014 additions and 243 deletions

View file

@ -57,7 +57,7 @@ func benchmark(cmd *cobra.Command, args []string) error {
return err return err
} }
for i := 0; i < benchmarkTimes; i++ { for i := 0; i < benchmarkTimes; i++ {
MainSite = nil MainSites = nil
_ = buildSite() _ = buildSite()
} }
pprof.WriteHeapProfile(f) pprof.WriteHeapProfile(f)
@ -76,7 +76,7 @@ func benchmark(cmd *cobra.Command, args []string) error {
pprof.StartCPUProfile(f) pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile() defer pprof.StopCPUProfile()
for i := 0; i < benchmarkTimes; i++ { for i := 0; i < benchmarkTimes; i++ {
MainSite = nil MainSites = nil
_ = buildSite() _ = buildSite()
} }
} }

View file

@ -46,10 +46,10 @@ import (
"github.com/spf13/viper" "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 // 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. // 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 // Reset resets Hugo ready for a new full build. This is mainly only useful
// for benchmark testing etc. via the CLI commands. // for benchmark testing etc. via the CLI commands.
@ -287,6 +287,7 @@ func loadDefaultSettings() {
viper.SetDefault("ArchetypeDir", "archetypes") viper.SetDefault("ArchetypeDir", "archetypes")
viper.SetDefault("PublishDir", "public") viper.SetDefault("PublishDir", "public")
viper.SetDefault("DataDir", "data") viper.SetDefault("DataDir", "data")
viper.SetDefault("I18nDir", "i18n")
viper.SetDefault("ThemesDir", "themes") viper.SetDefault("ThemesDir", "themes")
viper.SetDefault("DefaultLayout", "post") viper.SetDefault("DefaultLayout", "post")
viper.SetDefault("BuildDrafts", false) viper.SetDefault("BuildDrafts", false)
@ -323,6 +324,8 @@ func loadDefaultSettings() {
viper.SetDefault("EnableEmoji", false) viper.SetDefault("EnableEmoji", false)
viper.SetDefault("PygmentsCodeFencesGuessSyntax", false) viper.SetDefault("PygmentsCodeFencesGuessSyntax", false)
viper.SetDefault("UseModTimeAsFallback", false) viper.SetDefault("UseModTimeAsFallback", false)
viper.SetDefault("Multilingual", false)
viper.SetDefault("DefaultContentLanguage", "en")
} }
// InitializeConfig initializes a config file with sensible default configuration flags. // InitializeConfig initializes a config file with sensible default configuration flags.
@ -490,6 +493,8 @@ func InitializeConfig(subCmdVs ...*cobra.Command) error {
helpers.HugoReleaseVersion(), minVersion) helpers.HugoReleaseVersion(), minVersion)
} }
readMultilingualConfiguration()
return nil return nil
} }
@ -506,7 +511,7 @@ func watchConfig() {
viper.OnConfigChange(func(e fsnotify.Event) { viper.OnConfigChange(func(e fsnotify.Event) {
fmt.Println("Config file changed:", e.Name) fmt.Println("Config file changed:", e.Name)
// Force a full rebuild // Force a full rebuild
MainSite = nil MainSites = nil
utils.CheckErr(buildSite(true)) utils.CheckErr(buildSite(true))
if !viper.GetBool("DisableLiveReload") { if !viper.GetBool("DisableLiveReload") {
// Will block forever trying to write to a channel that nobody is reading if livereload isn't initialized // 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 { func getDirList() []string {
var a []string var a []string
dataDir := helpers.AbsPathify(viper.GetString("DataDir")) dataDir := helpers.AbsPathify(viper.GetString("DataDir"))
i18nDir := helpers.AbsPathify(viper.GetString("I18nDir"))
layoutDir := helpers.AbsPathify(viper.GetString("LayoutDir")) layoutDir := helpers.AbsPathify(viper.GetString("LayoutDir"))
staticDir := helpers.AbsPathify(viper.GetString("StaticDir")) staticDir := helpers.AbsPathify(viper.GetString("StaticDir"))
walker := func(path string, fi os.FileInfo, err error) error { walker := func(path string, fi os.FileInfo, err error) error {
@ -639,8 +645,13 @@ func getDirList() []string {
if path == dataDir && os.IsNotExist(err) { if path == dataDir && os.IsNotExist(err) {
jww.WARN.Println("Skip DataDir:", err) jww.WARN.Println("Skip DataDir:", err)
return nil return nil
} }
if path == i18nDir && os.IsNotExist(err) {
jww.WARN.Println("Skip I18nDir:", err)
return nil
}
if path == layoutDir && os.IsNotExist(err) { if path == layoutDir && os.IsNotExist(err) {
jww.WARN.Println("Skip LayoutDir:", err) jww.WARN.Println("Skip LayoutDir:", err)
return nil return nil
@ -684,6 +695,7 @@ func getDirList() []string {
helpers.SymbolicWalk(hugofs.Source(), dataDir, walker) helpers.SymbolicWalk(hugofs.Source(), dataDir, walker)
helpers.SymbolicWalk(hugofs.Source(), helpers.AbsPathify(viper.GetString("ContentDir")), 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(), helpers.AbsPathify(viper.GetString("LayoutDir")), walker)
helpers.SymbolicWalk(hugofs.Source(), staticDir, walker) helpers.SymbolicWalk(hugofs.Source(), staticDir, walker)
if helpers.ThemeSet() { if helpers.ThemeSet() {
@ -695,31 +707,52 @@ func getDirList() []string {
func buildSite(watching ...bool) (err error) { func buildSite(watching ...bool) (err error) {
fmt.Println("Started building site") fmt.Println("Started building site")
startTime := time.Now() t0 := time.Now()
if MainSite == nil {
MainSite = new(hugolib.Site) if MainSites == nil {
MainSites = make(map[string]*hugolib.Site)
} }
if len(watching) > 0 && watching[0] {
MainSite.RunMode.Watching = true 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)
} }
err = MainSite.Build()
if err != nil { jww.FEEDBACK.Printf("total in %v ms\n", int(1000*time.Since(t0).Seconds()))
return err
}
MainSite.Stats()
jww.FEEDBACK.Printf("in %v ms\n", int(1000*time.Since(startTime).Seconds()))
return nil return nil
} }
func rebuildSite(events []fsnotify.Event) error { func rebuildSite(events []fsnotify.Event) error {
startTime := time.Now() t0 := time.Now()
err := MainSite.ReBuild(events)
if err != nil { for _, lang := range langConfigsList {
return err 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 return nil
} }

View file

@ -57,7 +57,7 @@ var listDraftsCmd = &cobra.Command{
return newSystemError("Error Processing Source Content", err) return newSystemError("Error Processing Source Content", err)
} }
for _, p := range site.Pages { for _, p := range site.AllPages {
if p.IsDraft() { if p.IsDraft() {
fmt.Println(filepath.Join(p.File.Dir(), p.File.LogicalName())) 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) return newSystemError("Error Processing Source Content", err)
} }
for _, p := range site.Pages { for _, p := range site.AllPages {
if p.IsFuture() { if p.IsFuture() {
fmt.Println(filepath.Join(p.File.Dir(), p.File.LogicalName())) fmt.Println(filepath.Join(p.File.Dir(), p.File.LogicalName()))
} }
@ -119,7 +119,7 @@ expired.`,
return newSystemError("Error Processing Source Content", err) return newSystemError("Error Processing Source Content", err)
} }
for _, p := range site.Pages { for _, p := range site.AllPages {
if p.IsExpired() { if p.IsExpired() {
fmt.Println(filepath.Join(p.File.Dir(), p.File.LogicalName())) fmt.Println(filepath.Join(p.File.Dir(), p.File.LogicalName()))
} }

41
commands/multilingual.go Normal file
View file

@ -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"])
}

View file

@ -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.

View file

@ -38,7 +38,7 @@ each content piece are located in the usual place
<ul id="tags"> <ul id="tags">
{{ range .Params.tags }} {{ range .Params.tags }}
<li><a href="{{ "/tags/" | relURL }}{{ . | urlize }}">{{ . }}</a> </li> <li><a href="{{ "/tags/" | relLangURL }}{{ . | urlize }}">{{ . }}</a> </li>
{{ end }} {{ end }}
</ul> </ul>
@ -110,7 +110,8 @@ The following example displays all tag keys:
<ul id="all-tags"> <ul id="all-tags">
{{ range $name, $taxonomy := .Site.Taxonomies.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 }} {{ end }}
</ul> </ul>
@ -120,7 +121,7 @@ This example will list all taxonomies, each of their keys and all the content as
<section> <section>
<ul> <ul>
{{ range $taxonomyname, $taxonomy := .Site.Taxonomies }} {{ range $taxonomyname, $taxonomy := .Site.Taxonomies }}
<li><a href="{{ "/" | relURL}}{{ $taxonomyname | urlize }}">{{ $taxonomyname }}</a> <li><a href="{{ "/" | relLangURL}}{{ $taxonomyname | urlize }}">{{ $taxonomyname }}</a>
<ul> <ul>
{{ range $key, $value := $taxonomy }} {{ range $key, $value := $taxonomy }}
<li> {{ $key }} </li> <li> {{ $key }} </li>
@ -135,4 +136,3 @@ This example will list all taxonomies, each of their keys and all the content as
{{ end }} {{ end }}
</ul> </ul>
</section> </section>

View file

@ -29,7 +29,7 @@ Taxonomies can be ordered by either alphabetical key or by the number of content
<ul> <ul>
{{ $data := .Data }} {{ $data := .Data }}
{{ range $key, $value := .Data.Taxonomy.Alphabetical }} {{ 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 }} {{ end }}
</ul> </ul>
@ -38,7 +38,7 @@ Taxonomies can be ordered by either alphabetical key or by the number of content
<ul> <ul>
{{ $data := .Data }} {{ $data := .Data }}
{{ range $key, $value := .Data.Taxonomy.ByCount }} {{ 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 }} {{ end }}
</ul> </ul>

View file

@ -435,6 +435,13 @@ e.g.
## Strings ## 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 ### chomp
Removes any trailing newline characters. Useful in a pipeline to remove newlines added by other processing (including `markdownify`). 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. --> <!-- outputs a content length of 8 runes. -->
``` ```
### md5 ### md5
`md5` hashes the given input and returns its MD5 checksum. `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" --> <!-- 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 ## 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 * `{{ (time "2016-05-28").YearDay }}` → 149
* `{{ mul 1000 (time "2016-05-28T10:30:00.00+10:00").Unix }}` → 1464395400000 (Unix time in milliseconds) * `{{ mul 1000 (time "2016-05-28T10:30:00.00+10:00").Unix }}` → 1464395400000 (Unix time in milliseconds)
## URLs ## URLs
### absURL, relURL ### absURL, relURL

View file

@ -89,7 +89,7 @@ content tagged with each tag.
<ul> <ul>
{{ $data := .Data }} {{ $data := .Data }}
{{ range $key, $value := .Data.Terms }} {{ 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 }} {{ end }}
</ul> </ul>
</div> </div>
@ -109,7 +109,7 @@ Another example listing the content for each term (ordered by Date):
{{ $data := .Data }} {{ $data := .Data }}
{{ range $key,$value := .Data.Terms.ByCount }} {{ 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> <ul>
{{ range $value.Pages.ByDate }} {{ range $value.Pages.ByDate }}
<li><a href="{{ .Permalink }}">{{ .Title }}</a></li> <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> <ul>
{{ $data := .Data }} {{ $data := .Data }}
{{ range $key, $value := .Data.Terms.Alphabetical }} {{ 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 }} {{ end }}
</ul> </ul>
</div> </div>
@ -158,7 +158,7 @@ Hugo can order the meta data in two different ways. It can be ordered:
<ul> <ul>
{{ $data := .Data }} {{ $data := .Data }}
{{ range $key, $value := .Data.Terms.ByCount }} {{ 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 }} {{ end }}
</ul> </ul>
</div> </div>

View file

@ -58,6 +58,8 @@ matter, content or derived from file location.
**.IsPage** Always true for page.<br> **.IsPage** Always true for page.<br>
**.Site** See [Site Variables]({{< relref "#site-variables" >}}) below.<br> **.Site** See [Site Variables]({{< relref "#site-variables" >}}) below.<br>
**.Hugo** See [Hugo Variables]({{< relref "#hugo-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 ## Page Params
@ -119,9 +121,9 @@ includes taxonomies, lists and the homepage.
**.Site** See [Site Variables]({{< relref "#site-variables" >}}) below.<br> **.Site** See [Site Variables]({{< relref "#site-variables" >}}) below.<br>
**.Hugo** See [Hugo Variables]({{< relref "#hugo-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.Singular** The singular name of the taxonomy<br>
**.Data.Plural** The plural 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**. 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 ## Site Variables
Also available is `.Site` which has the following: 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.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.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.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.<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: **.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/" 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.Menus** All of the menus in the site.<br>
**.Site.Title** A string representing the title of 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.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.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.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> **.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.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.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.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 ## File Variables

View file

@ -182,6 +182,12 @@ func GetThemeDataDirPath() (string, error) {
return getThemeDirPath("data") 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) { func getThemeDirPath(path string) (string, error) {
if !ThemeSet() { if !ThemeSet() {
return "", errors.New("No theme set") return "", errors.New("No theme set")

View file

@ -56,8 +56,8 @@ func doTestShortcodeCrossrefs(t *testing.T, relative bool) {
templ := tpl.New() templ := tpl.New()
p, _ := pageFromString(simplePageWithURL, path) p, _ := pageFromString(simplePageWithURL, path)
p.Node.Site = &SiteInfo{ p.Node.Site = &SiteInfo{
Pages: &(Pages{p}), AllPages: &(Pages{p}),
BaseURL: template.URL(helpers.SanitizeURLKeepTrailingSlash(baseURL)), BaseURL: template.URL(helpers.SanitizeURLKeepTrailingSlash(baseURL)),
} }
output, err := HandleShortcodes(in, p, templ) output, err := HandleShortcodes(in, p, templ)

36
hugolib/i18n.go Normal file
View file

@ -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
}

View file

@ -691,13 +691,7 @@ func testSiteSetup(s *Site, t *testing.T) {
s.Menus = Menus{} s.Menus = Menus{}
s.initializeSiteInfo() s.initializeSiteInfo()
if err := s.createPages(); err != nil { createPagesAndMeta(t, s)
t.Fatalf("Unable to create pages: %s", err)
}
if err := s.buildSiteMeta(); err != nil {
t.Fatalf("Unable to build site metadata: %s", err)
}
} }
func tomlToMap(s string) (map[string]interface{}, error) { func tomlToMap(s string) (map[string]interface{}, error) {

48
hugolib/multilingual.go Normal file
View file

@ -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
}

View file

@ -61,8 +61,10 @@ type Page struct {
PublishDate time.Time PublishDate time.Time
ExpiryDate time.Time ExpiryDate time.Time
Markup string Markup string
Translations Translations
extension string extension string
contentType string contentType string
lang string
renderable bool renderable bool
Layout string Layout string
layoutsCalculated []string layoutsCalculated []string
@ -300,9 +302,11 @@ func (p *Page) getRenderingConfig() *helpers.Blackfriday {
func newPage(filename string) *Page { func newPage(filename string) *Page {
page := Page{contentType: "", page := Page{contentType: "",
Source: Source{File: *source.NewFile(filename)}, Source: Source{File: *source.NewFile(filename)},
Node: Node{Keywords: []string{}, Sitemap: Sitemap{Priority: -1}}, Node: Node{Keywords: []string{}, Sitemap: Sitemap{Priority: -1}},
Params: make(map[string]interface{})} Params: make(map[string]interface{}),
Translations: make(Translations),
}
jww.DEBUG.Println("Reading from", page.File.Path()) jww.DEBUG.Println("Reading from", page.File.Path())
return &page return &page
@ -445,11 +449,13 @@ func (p *Page) permalink() (*url.URL, error) {
if len(pSlug) > 0 { if len(pSlug) > 0 {
permalink = helpers.URLPrep(viper.GetBool("UglyURLs"), path.Join(dir, p.Slug+"."+p.Extension())) permalink = helpers.URLPrep(viper.GetBool("UglyURLs"), path.Join(dir, p.Slug+"."+p.Extension()))
} else { } 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 = 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 return helpers.MakePermalink(baseURL, permalink), nil
} }
@ -460,6 +466,10 @@ func (p *Page) Extension() string {
return viper.GetString("DefaultExtension") return viper.GetString("DefaultExtension")
} }
func (p *Page) Lang() string {
return p.lang
}
func (p *Page) LinkTitle() string { func (p *Page) LinkTitle() string {
if len(p.linkTitle) > 0 { if len(p.linkTitle) > 0 {
return p.linkTitle return p.linkTitle
@ -699,29 +709,29 @@ func (p *Page) getParam(key string, stringToLower bool) interface{} {
return nil return nil
} }
switch v.(type) { switch val := v.(type) {
case bool: case bool:
return v return val
case time.Time: case string:
return v if stringToLower {
return strings.ToLower(val)
}
return val
case int64, int32, int16, int8, int: case int64, int32, int16, int8, int:
return cast.ToInt(v) return cast.ToInt(v)
case float64, float32: case float64, float32:
return cast.ToFloat64(v) return cast.ToFloat64(v)
case time.Time:
return val
case []string:
if stringToLower {
return helpers.SliceToLower(val)
}
return v
case map[string]interface{}: // JSON and TOML case map[string]interface{}: // JSON and TOML
return v return v
case map[interface{}]interface{}: // YAML case map[interface{}]interface{}: // YAML
return v return v
case string:
if stringToLower {
return strings.ToLower(v.(string))
}
return v
case []string:
if stringToLower {
return helpers.SliceToLower(v.([]string))
}
return v
} }
jww.ERROR.Printf("GetParam(\"%s\"): Unknown type %s\n", key, reflect.TypeOf(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.renderable = psr.IsRenderable()
p.frontmatter = psr.FrontMatter() p.frontmatter = psr.FrontMatter()
p.rawContent = psr.Content() p.rawContent = psr.Content()
p.lang = p.Source.File.Lang()
meta, err := psr.Metadata() meta, err := psr.Metadata()
if meta != nil { if meta != nil {
@ -975,7 +986,6 @@ func (p *Page) FullFilePath() string {
} }
func (p *Page) TargetPath() (outfile string) { func (p *Page) TargetPath() (outfile string) {
// Always use URL if it's specified // Always use URL if it's specified
if len(strings.TrimSpace(p.URL)) > 2 { if len(strings.TrimSpace(p.URL)) > 2 {
outfile = strings.TrimSpace(p.URL) outfile = strings.TrimSpace(p.URL)
@ -997,6 +1007,7 @@ func (p *Page) TargetPath() (outfile string) {
outfile += "index.html" outfile += "index.html"
} }
outfile = filepath.FromSlash(outfile) outfile = filepath.FromSlash(outfile)
outfile = p.addMultilingualFilesystemPrefix(outfile)
return return
} }
} }
@ -1005,8 +1016,22 @@ func (p *Page) TargetPath() (outfile string) {
outfile = strings.TrimSpace(p.Slug) + "." + p.Extension() outfile = strings.TrimSpace(p.Slug) + "." + p.Extension()
} else { } else {
// Fall back to filename // 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)
} }

View file

@ -159,7 +159,7 @@ func pageToPermalinkTitle(p *Page, _ string) (string, error) {
func pageToPermalinkFilename(p *Page, _ string) (string, error) { func pageToPermalinkFilename(p *Page, _ string) (string, error) {
//var extension = p.Source.Ext //var extension = p.Source.Ext
//var name = p.Source.Path()[0 : len(p.Source.Path())-len(extension)] //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 // if the page has a slug, return the slug, else return the title

View file

@ -25,7 +25,7 @@ func (s *Site) ShowPlan(out io.Writer) (err error) {
fmt.Fprintf(out, "No source files provided.\n") 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()) fmt.Fprintf(out, "%s", p.Source.Path())
if p.IsRenderable() { if p.IsRenderable() {
fmt.Fprintf(out, " (renderer: markdown)") fmt.Fprintf(out, " (renderer: markdown)")

View file

@ -46,13 +46,7 @@ func TestRobotsTXTOutput(t *testing.T) {
s.prepTemplates("robots.txt", robotTxtTemplate) s.prepTemplates("robots.txt", robotTxtTemplate)
if err := s.createPages(); err != nil { createPagesAndMeta(t, s)
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.renderHomePage(); err != nil { if err := s.renderHomePage(); err != nil {
t.Fatalf("Unable to RenderHomePage: %s", err) t.Fatalf("Unable to RenderHomePage: %s", err)

View file

@ -59,13 +59,7 @@ func TestRSSOutput(t *testing.T) {
s.initializeSiteInfo() s.initializeSiteInfo()
s.prepTemplates("rss.xml", rssTemplate) s.prepTemplates("rss.xml", rssTemplate)
if err := s.createPages(); err != nil { createPagesAndMeta(t, s)
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.renderHomePage(); err != nil { if err := s.renderHomePage(); err != nil {
t.Fatalf("Unable to RenderHomePage: %s", err) t.Fatalf("Unable to RenderHomePage: %s", err)

View file

@ -20,6 +20,7 @@ import (
"io" "io"
"net/url" "net/url"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
@ -29,8 +30,6 @@ import (
"sync/atomic" "sync/atomic"
"path"
"github.com/bep/inflect" "github.com/bep/inflect"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/spf13/afero" "github.com/spf13/afero"
@ -76,6 +75,7 @@ var distinctErrorLogger = helpers.NewDistinctErrorLogger()
// 5. The entire collection of files is written to disk. // 5. The entire collection of files is written to disk.
type Site struct { type Site struct {
Pages Pages Pages Pages
AllPages Pages
Files []*source.File Files []*source.File
Tmpl tpl.Template Tmpl tpl.Template
Taxonomies TaxonomyList Taxonomies TaxonomyList
@ -87,6 +87,7 @@ type Site struct {
targets targetList targets targetList
targetListInit sync.Once targetListInit sync.Once
RunMode runmode RunMode runmode
Multilingual *Multilingual
draftCount int draftCount int
futureCount int futureCount int
expiredCount int expiredCount int
@ -106,7 +107,8 @@ type SiteInfo struct {
Authors AuthorList Authors AuthorList
Social SiteSocial Social SiteSocial
Sections Taxonomy 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 Files *[]*source.File
Menus *Menus Menus *Menus
Hugo *HugoInfo Hugo *HugoInfo
@ -125,6 +127,11 @@ type SiteInfo struct {
preserveTaxonomyNames bool preserveTaxonomyNames bool
paginationPageCount uint64 paginationPageCount uint64
Data *map[string]interface{} 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 // 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 return nil
} }
switch v.(type) { switch val := v.(type) {
case bool: case bool:
return cast.ToBool(v) return val
case string: case string:
return cast.ToString(v) return val
case int64, int32, int16, int8, int: case int64, int32, int16, int8, int:
return cast.ToInt(v) return cast.ToInt(v)
case float64, float32: case float64, float32:
return cast.ToFloat64(v) return cast.ToFloat64(v)
case time.Time: case time.Time:
return cast.ToTime(v) return val
case []string: case []string:
return v return v
} }
@ -181,7 +188,7 @@ func (s *SiteInfo) refLink(ref string, page *Page, relative bool) (string, error
var link string var link string
if refURL.Path != "" { if refURL.Path != "" {
for _, page := range []*Page(*s.Pages) { for _, page := range []*Page(*s.AllPages) {
refPath := filepath.FromSlash(refURL.Path) refPath := filepath.FromSlash(refURL.Path)
if page.Source.Path() == refPath || page.Source.LogicalName() == refPath { if page.Source.Path() == refPath || page.Source.LogicalName() == refPath {
target = page 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 { if page.Source.Path() == refPath {
target = page target = page
break 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 :/ // 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` // 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" 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 { if page.Source.Path() == mdPath {
target = page target = page
break break
} }
} }
indexPath := filepath.Join(refPath, "index.md") indexPath := filepath.Join(refPath, "index.md")
for _, page := range []*Page(*s.Pages) { for _, page := range []*Page(*s.AllPages) {
if page.Source.Path() == indexPath { if page.Source.Path() == indexPath {
target = page target = page
break 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. // 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 // This includes processing any meta-data for that content
// The second step is to convert the content into HTML // 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 { if len(tmplChanged) > 0 || len(dataChanged) > 0 {
// Do not need to read the files again, but they need conversion // Do not need to read the files again, but they need conversion
// for shortocde re-rendering. // for shortocde re-rendering.
for _, p := range s.Pages { for _, p := range s.AllPages {
pageChan <- p pageChan <- p
} }
} }
@ -538,6 +545,9 @@ func (s *Site) ReBuild(events []fsnotify.Event) error {
s.timerStep("read & convert pages from source") s.timerStep("read & convert pages from source")
// FIXME: does this go inside the next `if` statement ?
s.setupTranslations()
if len(sourceChanged) > 0 { if len(sourceChanged) > 0 {
s.setupPrevNext() s.setupPrevNext()
if err = s.buildSiteMeta(); err != nil { if err = s.buildSiteMeta(); err != nil {
@ -665,9 +675,9 @@ func (s *Site) readDataFromSourceFS() error {
dataSources = append(dataSources, &source.Filesystem{Base: s.absDataDir()}) dataSources = append(dataSources, &source.Filesystem{Base: s.absDataDir()})
// have to be last - duplicate keys in earlier entries will win // have to be last - duplicate keys in earlier entries will win
themeStaticDir, err := helpers.GetThemeDataDirPath() themeDataDir, err := helpers.GetThemeDataDirPath()
if err == nil { if err == nil {
dataSources = append(dataSources, &source.Filesystem{Base: themeStaticDir}) dataSources = append(dataSources, &source.Filesystem{Base: themeDataDir})
} }
err = s.loadData(dataSources) err = s.loadData(dataSources)
@ -688,10 +698,25 @@ func (s *Site) Process() (err error) {
return 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 { if err = s.createPages(); err != nil {
return return
} }
s.setupTranslations()
s.setupPrevNext() s.setupPrevNext()
if err = s.buildSiteMeta(); err != nil { if err = s.buildSiteMeta(); err != nil {
return 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) { func (s *Site) Render() (err error) {
if err = s.renderAliases(); err != nil { if err = s.renderAliases(); err != nil {
return return
@ -771,32 +817,47 @@ func (s *Site) initialize() (err error) {
} }
func (s *Site) initializeSiteInfo() { func (s *Site) initializeSiteInfo() {
params := viper.GetStringMap("Params") params := s.Multilingual.GetStringMap("Params")
permalinks := make(PermalinkOverrides) permalinks := make(PermalinkOverrides)
for k, v := range viper.GetStringMapString("Permalinks") { for k, v := range viper.GetStringMapString("Permalinks") {
permalinks[k] = pathPattern(v) 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{ s.Info = SiteInfo{
BaseURL: template.URL(helpers.SanitizeURLKeepTrailingSlash(viper.GetString("BaseURL"))), BaseURL: template.URL(helpers.SanitizeURLKeepTrailingSlash(viper.GetString("BaseURL"))),
Title: viper.GetString("Title"), Title: s.Multilingual.GetString("Title"),
Author: viper.GetStringMap("author"), Author: s.Multilingual.GetStringMap("author"),
Social: viper.GetStringMapString("social"), Social: s.Multilingual.GetStringMapString("social"),
LanguageCode: viper.GetString("languagecode"), LanguageCode: s.Multilingual.GetString("languagecode"),
Copyright: viper.GetString("copyright"), Copyright: s.Multilingual.GetString("copyright"),
DisqusShortname: viper.GetString("DisqusShortname"), DisqusShortname: s.Multilingual.GetString("DisqusShortname"),
Multilingual: s.multilingualEnabled(),
CurrentLanguage: s.Multilingual.GetString("CurrentLanguage"),
LanguagePrefix: languagePrefix,
Languages: languages,
GoogleAnalytics: viper.GetString("GoogleAnalytics"), GoogleAnalytics: viper.GetString("GoogleAnalytics"),
RSSLink: s.permalinkStr(viper.GetString("RSSUri")), RSSLink: s.permalinkStr(viper.GetString("RSSUri")),
BuildDrafts: viper.GetBool("BuildDrafts"), BuildDrafts: viper.GetBool("BuildDrafts"),
canonifyURLs: viper.GetBool("CanonifyURLs"), canonifyURLs: viper.GetBool("CanonifyURLs"),
preserveTaxonomyNames: viper.GetBool("PreserveTaxonomyNames"), preserveTaxonomyNames: viper.GetBool("PreserveTaxonomyNames"),
Pages: &s.Pages, AllPages: &s.AllPages,
Files: &s.Files, Pages: &s.Pages,
Menus: &s.Menus, Files: &s.Files,
Params: params, Menus: &s.Menus,
Permalinks: permalinks, Params: params,
Data: &s.Data, Permalinks: permalinks,
Data: &s.Data,
} }
} }
@ -808,6 +869,10 @@ func (s *Site) absDataDir() string {
return helpers.AbsPathify(viper.GetString("DataDir")) return helpers.AbsPathify(viper.GetString("DataDir"))
} }
func (s *Site) absI18nDir() string {
return helpers.AbsPathify(viper.GetString("I18nDir"))
}
func (s *Site) absThemeDir() string { func (s *Site) absThemeDir() string {
return helpers.AbsPathify(viper.GetString("themesDir") + "/" + viper.GetString("theme")) return helpers.AbsPathify(viper.GetString("themesDir") + "/" + viper.GetString("theme"))
} }
@ -903,7 +968,7 @@ func (s *Site) convertSource() chan error {
go converterCollator(s, results, errs) go converterCollator(s, results, errs)
for _, p := range s.Pages { for _, p := range s.AllPages {
pageChan <- p pageChan <- p
} }
@ -997,7 +1062,7 @@ func converterCollator(s *Site, results <-chan HandledResult, errs chan<- error)
func (s *Site) addPage(page *Page) { func (s *Site) addPage(page *Page) {
if page.shouldBuild() { if page.shouldBuild() {
s.Pages = append(s.Pages, page) s.AllPages = append(s.AllPages, page)
} }
if page.IsDraft() { if page.IsDraft() {
@ -1014,8 +1079,8 @@ func (s *Site) addPage(page *Page) {
} }
func (s *Site) removePageByPath(path string) { func (s *Site) removePageByPath(path string) {
if i := s.Pages.FindPagePosByFilePath(path); i >= 0 { if i := s.AllPages.FindPagePosByFilePath(path); i >= 0 {
page := s.Pages[i] page := s.AllPages[i]
if page.IsDraft() { if page.IsDraft() {
s.draftCount-- s.draftCount--
@ -1029,12 +1094,12 @@ func (s *Site) removePageByPath(path string) {
s.expiredCount-- 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) { 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() { if page.IsDraft() {
s.draftCount-- s.draftCount--
} }
@ -1047,7 +1112,7 @@ func (s *Site) removePage(page *Page) {
s.expiredCount-- 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) close(coordinator)
if len(errMsgs) == 0 { 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 { if len(errMsgs) == 0 {
errs <- nil errs <- nil
return return
@ -1298,9 +1363,8 @@ func (s *Site) resetPageBuildState() {
s.Info.paginationPageCount = 0 s.Info.paginationPageCount = 0
for _, p := range s.Pages { for _, p := range s.AllPages {
p.scratch = newScratch() 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. // renderAliases renders shell pages that simply have a redirect in the header.
func (s *Site) renderAliases() error { func (s *Site) renderAliases() error {
for _, p := range s.Pages { for _, p := range s.Pages {
@ -1536,6 +1589,19 @@ func (s *Site) newTaxonomyNode(t taxRenderInfo) (*Node, string) {
return n, base 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) { func taxonomyRenderer(s *Site, taxes <-chan taxRenderInfo, results chan<- error, wg *sync.WaitGroup) {
defer wg.Done() defer wg.Done()
@ -1549,6 +1615,8 @@ func taxonomyRenderer(s *Site, taxes <-chan taxRenderInfo, results chan<- error,
n, base = s.newTaxonomyNode(t) n, base = s.newTaxonomyNode(t)
base = s.addMultilingualPrefix(base)
dest := base dest := base
if viper.GetBool("UglyURLs") { if viper.GetBool("UglyURLs") {
dest = helpers.Uglify(base + ".html") 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 := []string{"taxonomy/" + singular + ".terms.html", "_default/terms.html", "indexes/indexes.html"}
layouts = s.appendThemeTemplates(layouts) layouts = s.appendThemeTemplates(layouts)
if s.layoutExists(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 return err
} }
} }
@ -1664,8 +1732,10 @@ func (s *Site) renderSectionLists() error {
section = helpers.MakePathSanitized(section) section = helpers.MakePathSanitized(section)
} }
base := s.addMultilingualPrefix(section)
n := s.newSectionListNode(sectionName, section, data) 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 return err
} }
@ -1674,7 +1744,7 @@ func (s *Site) renderSectionLists() error {
paginatePath := viper.GetString("paginatePath") paginatePath := viper.GetString("paginatePath")
// write alias for page 1 // 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() pagers := n.paginator.Pagers()
@ -1692,7 +1762,7 @@ func (s *Site) renderSectionLists() error {
sectionPagerNode.Lastmod = first.Lastmod sectionPagerNode.Lastmod = first.Lastmod
} }
pageNumber := i + 1 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 { if err := s.renderAndWritePage(fmt.Sprintf("section %s", section), filepath.FromSlash(htmlBase), sectionPagerNode, layouts...); err != nil {
return err return err
} }
@ -1702,10 +1772,10 @@ func (s *Site) renderSectionLists() error {
if !viper.GetBool("DisableRSS") && section != "" { if !viper.GetBool("DisableRSS") && section != "" {
// XML Feed // XML Feed
rssuri := viper.GetString("RSSUri") rssuri := viper.GetString("RSSUri")
n.URL = s.permalinkStr(section + "/" + rssuri) n.URL = s.permalinkStr(base + "/" + rssuri)
n.Permalink = s.permalink(section) n.Permalink = s.permalink(base)
rssLayouts := []string{"section/" + section + ".rss.xml", "_default/rss.xml", "rss.xml", "_internal/_default/rss.xml"} 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 return err
} }
} }
@ -1713,24 +1783,11 @@ func (s *Site) renderSectionLists() error {
return nil 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 { func (s *Site) renderHomePage() error {
n := s.newHomeNode() n := s.newHomeNode()
layouts := s.appendThemeTemplates([]string{"index.html", "_default/list.html"}) 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 return err
} }
@ -1739,7 +1796,7 @@ func (s *Site) renderHomePage() error {
paginatePath := viper.GetString("paginatePath") paginatePath := viper.GetString("paginatePath")
// write alias for page 1 // 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() pagers := n.paginator.Pagers()
@ -1758,6 +1815,7 @@ func (s *Site) renderHomePage() error {
} }
pageNumber := i + 1 pageNumber := i + 1
htmlBase := fmt.Sprintf("/%s/%d", paginatePath, pageNumber) 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 { if err := s.renderAndWritePage(fmt.Sprintf("homepage"), filepath.FromSlash(htmlBase), homePagerNode, layouts...); err != nil {
return err return err
} }
@ -1780,7 +1838,7 @@ func (s *Site) renderHomePage() error {
rssLayouts := []string{"rss.xml", "_default/rss.xml", "_internal/_default/rss.xml"} 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 return err
} }
} }
@ -1804,6 +1862,19 @@ func (s *Site) renderHomePage() error {
return nil 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 { func (s *Site) renderSitemap() error {
if viper.GetBool("DisableSitemap") { if viper.GetBool("DisableSitemap") {
return nil return nil
@ -1845,7 +1916,7 @@ func (s *Site) renderSitemap() error {
smLayouts := []string{"sitemap.xml", "_default/sitemap.xml", "_internal/_default/sitemap.xml"} 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 return err
} }
@ -1874,7 +1945,7 @@ func (s *Site) renderRobotsTXT() error {
// Stats prints Hugo builds stats to the console. // Stats prints Hugo builds stats to the console.
// This is what you see after a successful hugo build. // 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.draftStats())
jww.FEEDBACK.Println(s.futureStats()) jww.FEEDBACK.Println(s.futureStats())
jww.FEEDBACK.Println(s.expiredStats()) jww.FEEDBACK.Println(s.expiredStats())
@ -1886,9 +1957,14 @@ func (s *Site) Stats() {
for _, pl := range taxonomies { for _, pl := range taxonomies {
jww.FEEDBACK.Printf("%d %s created\n", len(s.Taxonomies[pl]), pl) 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) { func (s *Site) setURLs(n *Node, in string) {
in = s.addMultilingualPrefix(in)
n.URL = helpers.URLizeAndPrep(in) n.URL = helpers.URLizeAndPrep(in)
n.Permalink = s.permalink(n.URL) n.Permalink = s.permalink(n.URL)
n.RSSLink = template.HTML(s.permalink(in + ".xml")) n.RSSLink = template.HTML(s.permalink(in + ".xml"))

View file

@ -18,6 +18,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
"io/ioutil"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@ -92,16 +93,27 @@ func TestReadPagesFromSourceWithEmptySource(t *testing.T) {
} }
func createAndRenderPages(t *testing.T, s *Site) { func createAndRenderPages(t *testing.T, s *Site) {
if err := s.createPages(); err != nil { createPagesAndMeta(t, s)
t.Fatalf("Unable to create pages: %s", err)
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 { if err := s.buildSiteMeta(); err != nil {
t.Fatalf("Unable to build site metadata: %s", err) t.Fatalf("Unable to build site metadata: %s", err)
} }
}
if err := s.renderPages(); err != nil { func createPages(t *testing.T, s *Site) {
t.Fatalf("Unable to render pages. %s", err) 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() s.initializeSiteInfo()
if err := s.createPages(); err != nil { createPages(t, s)
t.Fatalf("Unable to create pages: %s", err)
}
return 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 // Testing Defaults.. Only draft:true and publishDate in the past should be rendered
s := siteSetup() s := siteSetup()
if len(s.Pages) != 1 { if len(s.AllPages) != 1 {
t.Fatal("Draft or Future dated content published unexpectedly") t.Fatal("Draft or Future dated content published unexpectedly")
} }
// only publishDate in the past should be rendered // only publishDate in the past should be rendered
viper.Set("BuildDrafts", true) viper.Set("BuildDrafts", true)
s = siteSetup() s = siteSetup()
if len(s.Pages) != 2 { if len(s.AllPages) != 2 {
t.Fatal("Future Dated Posts published unexpectedly") t.Fatal("Future Dated Posts published unexpectedly")
} }
@ -279,7 +290,7 @@ func TestDraftAndFutureRender(t *testing.T) {
viper.Set("BuildDrafts", false) viper.Set("BuildDrafts", false)
viper.Set("BuildFuture", true) viper.Set("BuildFuture", true)
s = siteSetup() s = siteSetup()
if len(s.Pages) != 2 { if len(s.AllPages) != 2 {
t.Fatal("Draft posts published unexpectedly") t.Fatal("Draft posts published unexpectedly")
} }
@ -287,7 +298,7 @@ func TestDraftAndFutureRender(t *testing.T) {
viper.Set("BuildDrafts", true) viper.Set("BuildDrafts", true)
viper.Set("BuildFuture", true) viper.Set("BuildFuture", true)
s = siteSetup() s = siteSetup()
if len(s.Pages) != 4 { if len(s.AllPages) != 4 {
t.Fatal("Drafts or Future posts not included as expected") t.Fatal("Drafts or Future posts not included as expected")
} }
@ -313,9 +324,8 @@ func TestFutureExpirationRender(t *testing.T) {
s.initializeSiteInfo() s.initializeSiteInfo()
if err := s.createPages(); err != nil { createPages(t, s)
t.Fatalf("Unable to create pages: %s", err)
}
return s return s
} }
@ -323,17 +333,17 @@ func TestFutureExpirationRender(t *testing.T) {
s := siteSetup() s := siteSetup()
if len(s.Pages) != 1 { if len(s.AllPages) != 1 {
if len(s.Pages) > 1 { if len(s.AllPages) > 1 {
t.Fatal("Expired content published unexpectedly") t.Fatal("Expired content published unexpectedly")
} }
if len(s.Pages) < 1 { if len(s.AllPages) < 1 {
t.Fatal("Valid content expired unexpectedly") t.Fatal("Valid content expired unexpectedly")
} }
} }
if s.Pages[0].Title == "doc2" { if s.AllPages[0].Title == "doc2" {
t.Fatal("Expired content published unexpectedly") t.Fatal("Expired content published unexpectedly")
} }
} }
@ -689,17 +699,7 @@ func TestAbsURLify(t *testing.T) {
s.prepTemplates("blue/single.html", templateWithURLAbs) s.prepTemplates("blue/single.html", templateWithURLAbs)
if err := s.createPages(); err != nil { createAndRenderPages(t, s)
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)
}
tests := []struct { tests := []struct {
file, expected string file, expected string
@ -791,13 +791,7 @@ func TestOrderedPages(t *testing.T) {
} }
s.initializeSiteInfo() s.initializeSiteInfo()
if err := s.createPages(); err != nil { createPagesAndMeta(t, s)
t.Fatalf("Unable to create pages: %s", err)
}
if err := s.buildSiteMeta(); err != nil {
t.Fatalf("Unable to build site metadata: %s", err)
}
if s.Sections["sect"][0].Weight != 2 || s.Sections["sect"][3].Weight != 6 { 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) 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() s.initializeSiteInfo()
if err := s.createPages(); err != nil { createPagesAndMeta(t, s)
t.Fatalf("Unable to create pages: %s", err)
}
if err := s.buildSiteMeta(); err != nil {
t.Fatalf("Unable to build site metadata: %s", err)
}
rbysection, err := s.Pages.GroupBy("Section", "desc") rbysection, err := s.Pages.GroupBy("Section", "desc")
if err != nil { if err != nil {
@ -1055,13 +1043,7 @@ func TestWeightedTaxonomies(t *testing.T) {
} }
s.initializeSiteInfo() s.initializeSiteInfo()
if err := s.createPages(); err != nil { createPagesAndMeta(t, s)
t.Fatalf("Unable to create pages: %s", err)
}
if err := s.buildSiteMeta(); err != nil {
t.Fatalf("Unable to build site metadata: %s", err)
}
if s.Taxonomies["tags"]["a"][0].Page.Title != "foo" { 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) 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() site.initializeSiteInfo()
if err := site.createPages(); err != nil { createPagesAndMeta(t, site)
t.Fatalf("Unable to create pages: %s", err)
}
return 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))
}

View file

@ -97,12 +97,7 @@ func TestPageCount(t *testing.T) {
s.initializeSiteInfo() s.initializeSiteInfo()
s.prepTemplates("indexes/blue.html", indexTemplate) s.prepTemplates("indexes/blue.html", indexTemplate)
if err := s.createPages(); err != nil { createPagesAndMeta(t, s)
t.Errorf("Unable to create pages: %s", err)
}
if err := s.buildSiteMeta(); err != nil {
t.Errorf("Unable to build site metadata: %s", err)
}
if err := s.renderSectionLists(); err != nil { if err := s.renderSectionLists(); err != nil {
t.Errorf("Unable to render section lists: %s", err) t.Errorf("Unable to render section lists: %s", err)

View file

@ -51,13 +51,7 @@ func TestSitemapOutput(t *testing.T) {
s.prepTemplates("sitemap.xml", SITEMAP_TEMPLATE) s.prepTemplates("sitemap.xml", SITEMAP_TEMPLATE)
if err := s.createPages(); err != nil { createPagesAndMeta(t, s)
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.renderHomePage(); err != nil { if err := s.renderHomePage(); err != nil {
t.Fatalf("Unable to RenderHomePage: %s", err) t.Fatalf("Unable to RenderHomePage: %s", err)

View file

@ -20,18 +20,6 @@ import (
"github.com/spf13/viper" "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) { func TestByCountOrderOfTaxonomies(t *testing.T) {
viper.Reset() viper.Reset()
defer viper.Reset() defer viper.Reset()

59
hugolib/translations.go Normal file
View file

@ -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
}
}
}

View file

@ -19,6 +19,7 @@ import (
"strings" "strings"
"github.com/spf13/hugo/helpers" "github.com/spf13/hugo/helpers"
"github.com/spf13/viper"
) )
// File represents a source content file. // File represents a source content file.
@ -26,11 +27,15 @@ import (
type File struct { type File struct {
relpath string // Original relative path, e.g. content/foo.txt relpath string // Original relative path, e.g. content/foo.txt
logicalName string // foo.txt logicalName string // foo.txt
baseName string // `post` for `post.md`, also `post.en` for `post.en.md`
Contents io.Reader Contents io.Reader
section string // The first directory section string // The first directory
dir string // The relative directory Path (minus file name) dir string // The relative directory Path (minus file name)
ext string // Just the ext (eg txt) ext string // Just the ext (eg txt)
uniqueID string // MD5 of the filename 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, // 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. // BaseFileName Filename without extension.
func (f *File) BaseFileName() string { 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. // 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.dir, f.logicalName = filepath.Split(f.relpath)
f.ext = strings.TrimPrefix(filepath.Ext(f.LogicalName()), ".") 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.section = helpers.GuessSection(f.Dir())
f.uniqueID = helpers.Md5String(f.LogicalName()) f.uniqueID = helpers.Md5String(f.LogicalName())

View file

@ -1920,5 +1920,7 @@ func init() {
"upper": func(a string) string { return strings.ToUpper(a) }, "upper": func(a string) string { return strings.ToUpper(a) },
"urlize": helpers.URLize, "urlize": helpers.URLize,
"where": where, "where": where,
"i18n": I18nTranslate,
"T": I18nTranslate,
} }
} }

47
tpl/template_i18n.go Normal file
View file

@ -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
}