shithub: hugo

Download patch

ref: ec33732fbe84f67c1164fb713d6cb738609f2e2e
parent: faa3472fa299adb287d575e6d404d4ddcddbff4e
author: Alexandre Bourget <[email protected]>
date: Fri May 13 20:35:16 EDT 2016

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

--- a/commands/benchmark.go
+++ b/commands/benchmark.go
@@ -57,7 +57,7 @@
 			return err
 		}
 		for i := 0; i < benchmarkTimes; i++ {
-			MainSite = nil
+			MainSites = nil
 			_ = buildSite()
 		}
 		pprof.WriteHeapProfile(f)
@@ -76,7 +76,7 @@
 		pprof.StartCPUProfile(f)
 		defer pprof.StopCPUProfile()
 		for i := 0; i < benchmarkTimes; i++ {
-			MainSite = nil
+			MainSites = nil
 			_ = buildSite()
 		}
 	}
--- a/commands/hugo.go
+++ b/commands/hugo.go
@@ -46,10 +46,10 @@
 	"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 @@
 	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 @@
 	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 @@
 			helpers.HugoReleaseVersion(), minVersion)
 	}
 
+	readMultilingualConfiguration()
+
 	return nil
 }
 
@@ -506,7 +511,7 @@
 	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 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 @@
 			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 @@
 
 	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 buildSite(watching ...bool) (err error) {
 	fmt.Println("Started building site")
-	startTime := time.Now()
-	if MainSite == nil {
-		MainSite = new(hugolib.Site)
+	t0 := time.Now()
+
+	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 {
-		return err
-	}
-	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
 }
--- a/commands/list.go
+++ b/commands/list.go
@@ -57,7 +57,7 @@
 			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 @@
 			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 @@
 			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()))
 			}
--- /dev/null
+++ b/commands/multilingual.go
@@ -1,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"])
+}
--- /dev/null
+++ b/docs/content/content/multilingual.md
@@ -1,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.
--- a/docs/content/taxonomies/displaying.md
+++ b/docs/content/taxonomies/displaying.md
@@ -38,7 +38,7 @@
 
     <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 @@
 
     <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 @@
     <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 @@
         {{ end }}
       </ul>
     </section>
-
--- a/docs/content/taxonomies/ordering.md
+++ b/docs/content/taxonomies/ordering.md
@@ -29,7 +29,7 @@
     <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 @@
     <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>
 
--- a/docs/content/templates/functions.md
+++ b/docs/content/templates/functions.md
@@ -435,6 +435,13 @@
 
 ## 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 @@
 <!-- outputs a content length of 8 runes. -->
 ```
 
-
 ### md5
 
 `md5` hashes the given input and returns its MD5 checksum.
@@ -752,7 +758,24 @@
 <!-- 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
 
 ### time
@@ -762,7 +785,6 @@
 * `{{ time "2016-05-28" }}` → "2016-05-28T00:00:00Z"
 * `{{ (time "2016-05-28").YearDay }}` → 149
 * `{{ mul 1000 (time "2016-05-28T10:30:00.00+10:00").Unix }}` → 1464395400000 (Unix time in milliseconds)
-
 
 ## URLs
 
--- a/docs/content/templates/terms.md
+++ b/docs/content/templates/terms.md
@@ -89,7 +89,7 @@
         <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 @@
 
         {{ $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 @@
         <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 @@
         <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>
--- a/docs/content/templates/variables.md
+++ b/docs/content/templates/variables.md
@@ -58,6 +58,8 @@
 **.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 @@
 **.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,6 +134,16 @@
 
 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:
@@ -138,8 +150,9 @@
 
 **.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 @@
 **.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 @@
 **.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
 
--- a/helpers/path.go
+++ b/helpers/path.go
@@ -182,6 +182,12 @@
 	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")
--- a/hugolib/embedded_shortcodes_test.go
+++ b/hugolib/embedded_shortcodes_test.go
@@ -56,8 +56,8 @@
 	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)
--- /dev/null
+++ b/hugolib/i18n.go
@@ -1,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
+}
--- a/hugolib/menu_test.go
+++ b/hugolib/menu_test.go
@@ -691,13 +691,7 @@
 	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) {
--- /dev/null
+++ b/hugolib/multilingual.go
@@ -1,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
+}
--- a/hugolib/page.go
+++ b/hugolib/page.go
@@ -61,8 +61,10 @@
 	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 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 @@
 		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 @@
 	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 @@
 		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 @@
 	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) 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 @@
 				outfile += "index.html"
 			}
 			outfile = filepath.FromSlash(outfile)
+			outfile = p.addMultilingualFilesystemPrefix(outfile)
 			return
 		}
 	}
@@ -1005,8 +1016,22 @@
 		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)
 }
--- a/hugolib/permalinks.go
+++ b/hugolib/permalinks.go
@@ -159,7 +159,7 @@
 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
--- a/hugolib/planner.go
+++ b/hugolib/planner.go
@@ -25,7 +25,7 @@
 		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)")
--- a/hugolib/robotstxt_test.go
+++ b/hugolib/robotstxt_test.go
@@ -46,13 +46,7 @@
 
 	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)
--- a/hugolib/rss_test.go
+++ b/hugolib/rss_test.go
@@ -59,13 +59,7 @@
 	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)
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -20,6 +20,7 @@
 	"io"
 	"net/url"
 	"os"
+	"path"
 	"path/filepath"
 	"sort"
 	"strconv"
@@ -29,8 +30,6 @@
 
 	"sync/atomic"
 
-	"path"
-
 	"github.com/bep/inflect"
 	"github.com/fsnotify/fsnotify"
 	"github.com/spf13/afero"
@@ -76,6 +75,7 @@
 // 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 @@
 	targets        targetList
 	targetListInit sync.Once
 	RunMode        runmode
+	Multilingual   *Multilingual
 	draftCount     int
 	futureCount    int
 	expiredCount   int
@@ -106,7 +107,8 @@
 	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 @@
 	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 @@
 		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 @@
 	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 @@
 			}
 		}
 
-		for _, page := range []*Page(*s.Pages) {
+		for _, page := range []*Page(*s.AllPages) {
 			if page.Source.Path() == refPath {
 				target = page
 				break
@@ -265,7 +272,7 @@
 		// 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
@@ -272,7 +279,7 @@
 			}
 		}
 		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 @@
 
 	// 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 @@
 	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 @@
 
 	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 @@
 	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 @@
 		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) 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,7 +817,7 @@
 }
 
 func (s *Site) initializeSiteInfo() {
-	params := viper.GetStringMap("Params")
+	params := s.Multilingual.GetStringMap("Params")
 
 	permalinks := make(PermalinkOverrides)
 	for k, v := range viper.GetStringMapString("Permalinks") {
@@ -778,25 +824,40 @@
 		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 @@
 	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 @@
 
 	go converterCollator(s, results, errs)
 
-	for _, p := range s.Pages {
+	for _, p := range s.AllPages {
 		pageChan <- p
 	}
 
@@ -997,7 +1062,7 @@
 
 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) 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 @@
 			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 @@
 			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 @@
 		}
 	}
 
-	s.Pages.Sort()
+	s.AllPages.Sort()
 	close(coordinator)
 
 	if len(errMsgs) == 0 {
@@ -1112,7 +1177,7 @@
 		}
 	}
 
-	s.Pages.Sort()
+	s.AllPages.Sort()
 	if len(errMsgs) == 0 {
 		errs <- nil
 		return
@@ -1298,9 +1363,8 @@
 
 	s.Info.paginationPageCount = 0
 
-	for _, p := range s.Pages {
+	for _, p := range s.AllPages {
 		p.scratch = newScratch()
-
 	}
 }
 
@@ -1326,17 +1390,6 @@
 	}
 }
 
-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 @@
 	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 @@
 
 		n, base = s.newTaxonomyNode(t)
 
+		base = s.addMultilingualPrefix(base)
+
 		dest := base
 		if viper.GetBool("UglyURLs") {
 			dest = helpers.Uglify(base + ".html")
@@ -1623,7 +1691,7 @@
 		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 @@
 			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 @@
 			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 @@
 					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 @@
 		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 @@
 	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 @@
 		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 @@
 			}
 			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 @@
 
 		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 @@
 	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 @@
 
 	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 @@
 
 // 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 @@
 	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"))
--- a/hugolib/site_test.go
+++ b/hugolib/site_test.go
@@ -18,6 +18,7 @@
 	"fmt"
 	"html/template"
 	"io"
+	"io/ioutil"
 	"path/filepath"
 	"strings"
 	"testing"
@@ -92,16 +93,27 @@
 }
 
 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 @@
 
 		s.initializeSiteInfo()
 
-		if err := s.createPages(); err != nil {
-			t.Fatalf("Unable to create pages: %s", err)
-		}
+		createPages(t, s)
+
 		return s
 	}
 
@@ -264,7 +275,7 @@
 
 	// 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")
 	}
 
@@ -271,7 +282,7 @@
 	// 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 @@
 	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 @@
 	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 @@
 
 		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 @@
 
 	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,18 +699,8 @@
 
 			s.prepTemplates("blue/single.html", templateWithURLAbs)
 
-			if err := s.createPages(); err != nil {
-				t.Fatalf("Unable to create pages: %s", err)
-			}
+			createAndRenderPages(t, s)
 
-			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 {
 				file, expected string
 			}{
@@ -791,14 +791,8 @@
 	}
 	s.initializeSiteInfo()
 
-	if err := s.createPages(); err != nil {
-		t.Fatalf("Unable to create pages: %s", err)
-	}
+	createPagesAndMeta(t, s)
 
-	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 {
 		t.Errorf("Pages in unexpected order. First should be '%d', got '%d'", 2, s.Sections["sect"][0].Weight)
 	}
@@ -865,14 +859,8 @@
 	}
 	s.initializeSiteInfo()
 
-	if err := s.createPages(); err != nil {
-		t.Fatalf("Unable to create pages: %s", err)
-	}
+	createPagesAndMeta(t, s)
 
-	if err := s.buildSiteMeta(); err != nil {
-		t.Fatalf("Unable to build site metadata: %s", err)
-	}
-
 	rbysection, err := s.Pages.GroupBy("Section", "desc")
 	if err != nil {
 		t.Fatalf("Unable to make PageGroup array: %s", err)
@@ -1055,14 +1043,8 @@
 	}
 	s.initializeSiteInfo()
 
-	if err := s.createPages(); err != nil {
-		t.Fatalf("Unable to create pages: %s", err)
-	}
+	createPagesAndMeta(t, s)
 
-	if err := s.buildSiteMeta(); err != nil {
-		t.Fatalf("Unable to build site metadata: %s", err)
-	}
-
 	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 @@
 
 	site.initializeSiteInfo()
 
-	if err := site.createPages(); err != nil {
-		t.Fatalf("Unable to create pages: %s", err)
-	}
+	createPagesAndMeta(t, site)
 
 	return site
 }
@@ -1340,4 +1320,160 @@
 			}
 		}
 	}
+}
+
+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))
 }
--- a/hugolib/site_url_test.go
+++ b/hugolib/site_url_test.go
@@ -97,12 +97,7 @@
 	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)
--- a/hugolib/sitemap_test.go
+++ b/hugolib/sitemap_test.go
@@ -51,13 +51,7 @@
 
 	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)
--- a/hugolib/taxonomy_test.go
+++ b/hugolib/taxonomy_test.go
@@ -20,18 +20,6 @@
 	"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()
--- /dev/null
+++ b/hugolib/translations.go
@@ -1,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
+		}
+	}
+}
--- a/source/file.go
+++ b/source/file.go
@@ -19,6 +19,7 @@
 	"strings"
 
 	"github.com/spf13/hugo/helpers"
+	"github.com/spf13/viper"
 )
 
 // File represents a source content file.
@@ -26,11 +27,15 @@
 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,9 +56,19 @@
 
 // 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.
 func (f *File) Section() string {
 	return f.section
@@ -108,6 +123,17 @@
 
 	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())
 
--- a/tpl/template_funcs.go
+++ b/tpl/template_funcs.go
@@ -1920,5 +1920,7 @@
 		"upper":        func(a string) string { return strings.ToUpper(a) },
 		"urlize":       helpers.URLize,
 		"where":        where,
+		"i18n":         I18nTranslate,
+		"T":            I18nTranslate,
 	}
 }
--- /dev/null
+++ b/tpl/template_i18n.go
@@ -1,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
+}