Translated Pages
Text written into a template is one language. Adding a second usually means threading a locale value through every component and every layout, then hoping nobody forgets a string.
Popcorn Web moves both problems: the locale reaches every template without
being a parameter, and a message a translation is missing fails pw generate
rather than rendering blank in production.
None of this exists until you declare a second locale. A single-language project writes plain text in its templates and never opens a catalog. Reach for this when you actually have another language to ship, not in advance.
A translated page
Section titled “A translated page”Declare the languages and where their text lives:
[i18n]locales = ["ja", "en"]default_locale = "ja"path_routes = ["/"]
[i18n.label]ja = "日本語"en = "English"Write the text in messages/shop.yaml — one file per scope:
title: locales: ja: "お店" en: "Shop"
greeting: params: ["name string"] locales: ja: "ようこそ、{name}さん" en: "Welcome, {name}!"Then reference it:
package pages
messages shop
export component Page(name: string): html {<section> <h1>{t title}</h1> <p>{t greeting, name: name}</p> <a href="/{lang}/about">{t title}</a></section>}pw generate compiles the catalog into a typed Go package and checks every
reference against it. A misspelled ID, a missing argument, or a language that
never supplied a translation stops the build.
The scope declaration
Section titled “The scope declaration”messages shop at the top of the file says which catalog file this template’s
references resolve against, so {t title} means shop.title. It is one line
per file, and a file carrying a reference without it is an error — nothing is
inferred from the path, because then moving a component would silently change
which text it renders.
Two templates declaring the same scope share its messages. That is how a
common scope for Save and Cancel works: both files declare it, and both
read one entry. A message from elsewhere is reached by writing the qualified ID,
{t common.save}.
Links and assets carry the locale explicitly
Section titled “Links and assets carry the locale explicitly”Two names are in scope in every template, and they are not interchangeable.
{lang} is the URL segment. It holds the tag under path routing and the empty
string everywhere else, and an empty value removes the slash before it — so one
template serves every configuration:
<a href="/{lang}/about"> <!-- /ja/about, or /about where the locale is not in the path -->{langtag} is the tag itself, never empty. It is what the document language
attribute and a localized asset path want:
<html lang="{langtag}"><img src="/assets/{langtag}/hero.png"><img src="/assets/logo.png"> <!-- not localized: neither name appears -->The split exists because an image can be translated too. If the framework
rewrote links for you it would have to decide whether /assets/hero.png is a
route or a file, and no attribute tells it — an anchor can point at a PDF and an
image source can point at a route. Writing the name yourself is the only thing
that gets both cases right.
Plurals belong to the target language
Section titled “Plurals belong to the target language”How many forms a count needs is a fact about the language, not about the message. Japanese has one, English two, Russian three. Declare them per locale:
item-count: params: ["n int"] plural: n locales: ja: "カートに{n}件" en: one: "{n} item in your cart" other: "{n} items in your cart"<p>{t item-count, n: count}</p>A locale that omits a form its rules require fails the build, so the case you
did not think about is reported rather than rendered wrong. Numbers are grouped
for the locale on the way in — 1,234 in English and Japanese, 1.234 in
German.
A sentence with a link inside it
Section titled “A sentence with a link inside it”Splitting a sentence into three messages makes it untranslatable, because word order moves. Write it as a block instead, and let the translation decide where the link lands:
{t agree}<a href="/{lang}/start"></a>{/t}agree: rich: true locales: ja: "利用規約に同意の上、<a>開始</a>してください" en: "Please <a>get started</a> after agreeing to the terms"The <a> in the translation is a name, not markup. It refers to the anchor the
template wrote, so a translator can move it through the sentence and cannot add
an element, an attribute, or a script. The anchor is written empty in the
template — the text between its tags comes from the translation.
The language switcher
Section titled “The language switcher”One call answers what the page is available in, and the same data builds the
switcher and the hreflang alternates, so the two cannot disagree:
func Header(w http.ResponseWriter, r *http.Request) { for _, choice := range pw.LocaleChoices(r) { // choice.Label is the name in its own language: 日本語, English. // choice.URL is this same page in that language. // choice.Current marks the one being read. }}Under path routing every entry carries a URL, so the control is a list of links.
Where the locale is not in the URL there is nothing to link to, and switching is
a form post to an action that calls pw.SetLocale and redirects back. The
framework owns the cookie and its validation; the markup is yours, on the same
terms as the theme toggle.
Moving an existing page into the catalog
Section titled “Moving an existing page into the catalog”Mark the text and let the tool do the rest:
<h1 i18n>Welcome back</h1><input placeholder="Your name" i18n="placeholder">pw i18n extractIt proposes an ID for each string, writes the catalog entries with the source text recorded, replaces the marked text with a reference, and adds the scope declaration. Everything it decided is printed, because a name it chose is one you will read for years.
It declines a sentence that carries markup, and says so. That shape is the block form above, and a hole name invented by a tool is one no translator can check — converting those by hand is a few minutes and the only way to get the names right.
pw i18n check answers the question a build cannot: whether a message you
declared is still used by anything.
Pitfalls
Section titled “Pitfalls”{t} and {t x} are different things. {t} interpolates a parameter named
t, exactly as it always did. Only an identifier followed by another identifier
is a message reference, so a component taking t time.Time keeps working.
A rich-text block is legal where children are legal, not inside an attribute value. It produces structure. An ordinary reference is a string and goes anywhere an expression does, including attributes.
Changing the source text marks every translation stale. That is the point — the recorded snapshot is compared, and the report tells you which languages now disagree with the text they were translated from.
Adding a language adds no generated code. Tables grow by a row, and nothing else changes. If a build got noticeably bigger, something else did it.
The URL modes, what each one varies on, and calling messages from Go are in Locale routing. The full configuration key list is in Configuration reference.
