Skip to content

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.

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.

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

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.

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.

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.

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.

Mark the text and let the tool do the rest:

<h1 i18n>Welcome back</h1>
<input placeholder="Your name" i18n="placeholder">
Terminal window
pw i18n extract

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

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