kanna

kanna / i18n

kanna-i18n

v0.0.10 Go 1.26 pkg.go.dev

CI codecov Go Reference License: MIT Release 日本語

kanna-i18n compiles a directory of locale files into Go: a typed constructor for every message, and the translations themselves, so nothing is parsed or read at run time.

Part of the kanna family of code generators. Requires Go 1.26.

Install

go get -tool github.com/go-kanna/i18n/cmd/kanna-i18n

The generated code calls the runtime at the root of this module, github.com/go-kanna/i18n, which the line above already requires. The runtime does what depends on run-time values: choosing the language, picking the CLDR plural form, and formatting numbers for the locale.

Use

One YAML file per language, named by its language tag:

# locales/en.yaml
greeting: "Hello!"
hello: "Hello, {name}!"
items_count:
  plural:
    one: "You have {count} item."
    other: "You have {count} items."
total_price: "Total: {price:number}"
user:
  not_found: "User not found."
# locales/ja.yaml
greeting: "こんにちは!"
hello: "こんにちは、{name}さん!"
items_count:
  plural:
    other: "アイテムが{count}個あります。"
total_price: "合計: {price}"
user:
  not_found: "ユーザーが見つかりません。"
//go:generate go tool kanna-i18n

With the defaults, that line reads locales/ and writes messages/i18n_gen.go: a constructor per message, a Localizer function, and the translations of every language.

// Code generated by kanna-i18n. DO NOT EDIT.

package messages

import (
	"github.com/go-kanna/i18n"
)

// Hello returns the message "hello":
//
//	Hello, {name}!
func Hello(name string) i18n.Message {
	return i18n.Message{Key: "hello", Args: []i18n.Arg{
		{Name: "name", Value: name},
	}}
}

// ItemsCount returns the message "items_count", in the form count selects:
//
//	one:   You have {count} item.
//	other: You have {count} items.
func ItemsCount(count int) i18n.Message {
	return i18n.Message{Key: "items_count", Args: []i18n.Arg{
		{Name: "count", Value: count},
	}}
}

// ...

// Localizer renders this package's messages in the language that best matches
// langs, each a language tag or an Accept-Language value, tried in order.
// A message missing from that language falls back to its parent languages and
// then to en.
func Localizer(langs ...string) i18n.Localizer {
	return bundle.Localizer(langs...)
}

// bundle holds every language this package was generated from, en, the
// default, first.
var bundle = i18n.NewBundle(
	i18n.Catalog{Lang: "en", Entries: map[string]i18n.Entry{
		"hello": {Single: i18n.Template{{Text: "Hello, "}, {Param: "name"}, {Text: "!"}}},
		"items_count": {Plural: &i18n.Plural{
			One:   i18n.Template{{Text: "You have "}, {Param: "count"}, {Text: " item."}},
			Other: i18n.Template{{Text: "You have "}, {Param: "count"}, {Text: " items."}},
		}},
		// ...
	}},
	i18n.Catalog{Lang: "ja", Entries: map[string]i18n.Entry{
		// ...
	}},
)

Each constructor's documentation carries what the default language says, so an editor shows the text of a message where it is called.

A constructor returns an i18n.Message, not a string: code that does not know the reader's language can hand one back — inside an error, say — and the code that does know renders it:

loc := messages.Localizer(r.Header.Get("Accept-Language"))
fmt.Fprintln(w, loc.Localize(messages.Hello("Alice")))      // Hello, Alice! or こんにちは、Aliceさん!
fmt.Fprintln(w, loc.Localize(messages.ItemsCount(1)))       // You have 1 item. or アイテムが1個あります。
fmt.Fprintln(w, loc.Localize(messages.TotalPrice(1234.56))) // Total: 1,234.56 or 合計: 1,234.56

A translation that does not fit the default language stops generation, rather than the first request that meets it. Had ja.yaml above misspelled a placeholder and a key, and given the price a kind of its own:

$ go generate ./...
locales/ja.yaml:2:8: error: parameter "namae" of key "hello" is not in en
  did you mean "name"?
locales/ja.yaml:6:14: error: parameter "price" of key "total_price" is int, but number in en
locales/ja.yaml:8:3: error: key "user.not_fund" is not in en
  did you mean "user.not_found"?
locales/ja.yaml: warning: missing keys: user.not_found

Locale files

The locale directory is flat — one file per language, no recursion — and the file name without its extension is the language: en.yaml, pt-BR.yaml, ja.yml. Nothing named like a language is skipped in silence: a subdirectory such as locales/en/ or locales/en.yaml/, or a file in another format such as en.json or en.toml, stops generation. Other files are skipped with a warning, so a stray config.yaml does not fail the run, and names starting with a dot are skipped without one.

Nested mappings become dot-joined keys, and keys become constructor names: user.not_found generates UserNotFound(). Each part of a key matches [a-z][a-z0-9_]*. Two keys that generate one name, or a key that generates a name the destination package already declares, stop generation; so do the key localizer, whose name the generated file takes for itself, and a Localizer the destination package declares by hand.

Every value is a string or a mapping. Quote a value that YAML would read as something else, such as "true" or "123". A message cannot hold a control character other than a line break or a tab, nor a byte order mark.

Plural forms

A message declares its plural forms under an explicit plural mapping, keyed by CLDR category (zero, one, two, few, many, other) and always defining other. Keys that merely happen to be named one or other are ordinary nesting: the only name a locale file cannot use freely is a mapping-valued plural, which takes no other keys beside it. At the root of a file, where there is no message for it to be the forms of, plural is ordinary nesting too.

The constructor takes count int first, and the count picks the form under the plural rules of the language rendering it: 1 is one in English, while Japanese has no one and uses other for every count. A plural message that lacks a form its language uses gets a warning, because those counts would render with other: Russian with only other lacks one, few, and many, while Japanese with only other lacks nothing.

A count of 0 renders with zero whenever the message has it, as in Rails, so zero: "You have no items." in en.yaml works although English has no zero category. In a language whose rules do have one, such as Latvian, zero takes the other counts of that category too: 10 through 20, for one.

Placeholders

Form Parameter Rendered
{name} string as it is
{name:int} int as it is: counts, IDs
{name:number} float64 in the language's format, three decimals at most: 1,234.56 in en, 1.234,56 in de
{{, }} literal braces

{name:string} is the same as {name}, and count in a plural message is always an int. A number is written as CLDR's default pattern for the language has it, which keeps three fraction digits at most: 1234.5678 renders as 1,234.568. Within a message, a placeholder written without a kind takes the one written for it elsewhere — in another plural form, say — and two different kinds are an error. The default language decides each parameter's type: a translation may leave the kind off, as ja.yaml above does for the price, and one that writes a different kind stops generation.

Parameters follow the order in which they first appear, count first and the forms of a plural message read in the order of their categories, zero to other, and are named in camelCase: {user_name} becomes userName. A name Go reserves or predeclares gets Arg appended: {type} becomes typeArg, and {error} becomes errorArg.

Languages

The default language — -default, en unless said otherwise — defines the constructors: their names, parameters, and types come from its file, and every other file is checked against it. A file for a regional variant serves a bare default when there is no exact one: -default en takes en-US.yaml in the absence of en.yaml, and warns when en-GB.yaml could have been meant as well. A default written with a region or script, such as -default en-US, is served by its own file alone: neither en.yaml nor en-GB.yaml stands in for it.

Localizer takes language tags such as "ja" or "pt-BR", Accept-Language values such as "fr, ja;q=0.8", or both, and tries them in order, value by value, until one is close to a language the package was generated with, as CLDR judges closeness: a request for en-US is served by en, and one for nb by da when the package has Danish but no Norwegian. So messages.Localizer(cookie.Value, r.Header.Get("Accept-Language")) prefers the language the user picked, then the ones the browser asks for. A value that does not parse is skipped as a whole, one bad entry and all, and when nothing matches, the default language is used.

A message the chosen language lacks falls back through its parent languages — en-GB to en — and then to the default language, and is rendered under the plural rules and number format of the language it is found in. Localize never fails: the zero Localizer renders every message as its key, and a placeholder whose argument is missing renders as {name}.

Validation errors

kanna-validate and kanna-value report a broken rule as a code with params, such as too_long with count, and leave its text to the caller, as Rails' errors.details does. i18n.FullMessages renders them as sentences naming their fields, and i18n.ErrorMessages by field, as Rails' errors.full_messages and errors.messages do:

loc := messages.Localizer(lang)
if errs, ok := errors.AsType[validate.Errors](err); ok {
	for _, m := range i18n.FullMessages(loc, errs) {
		fmt.Println(m)
	}
}
// Title can't be blank
// Tags is too long (maximum is 20 characters)
// Confirm doesn't match Password
//
// タイトルを入力してください
// Tagsは20文字以内で入力してください
// 確認用パスワードとパスワードの入力が一致しません

The texts of every code kanna-validate and kanna-value report are built in, in English as Rails writes them and in Japanese as rails-i18n does. A locale file names the fields, overrides any built-in text, and adds the codes of rules of your own:

# locales/ja.yaml
errors:
  attributes:
    title: タイトル
    confirm: 確認用パスワード
    password: パスワード
    address:
      zip: 郵便番号
  messages:
    corp_domain: は会社のアドレスにしてください
  • errors.format puts a field's name and its message together: "{attribute} {message}" in English, "{attribute}{message}" in Japanese.
  • A field is named by errors.attributes: its path with any index dropped, address.zip or tags for tags[1], then the last part of it, zip, and failing both, the last part written out: Completed at for completed_at. A confirmation's attribute, the field it is checked against, is named the same way.
  • A problem with the value as a whole, at base, reads as its message alone.
  • A message is looked up in the reader's language and its parents, the application's text first and then the built-in one, before the default language: a reader of Japanese gets the built-in Japanese rather than the application's English. A translation may override a built-in text that the default language leaves as it is, and name a field that the default language leaves to be written out: Japanese names title, while English reads Title anyway.

Anything with Path, Code, and Params methods renders: this package imports no validation package. For one message without its field, i18n.Coded("errors.messages", code, params) builds it. A *rule.Error of go-kanna/value, from building or decoding a value type, has Code and Params as well.

A code no language has renders as its key, errors.messages.taken, so a rule without a text shows rather than vanishes.

The kind of a placeholder types the generated constructor and nothing else: the runtime never sees it, so the params of a code render by their Go type whatever the locale file says, and a kind that does not match fails nothing. {count:number} does not write an int in the language's number format, nor does {count:int} keep a float64 out of it. What a count's type does decide is the plural form: only an int picks one, and any other count takes other. The lengths of too_short, too_long, and wrong_length are always an int, so write them {count:int}, or in plural forms. The bound of greater_than and the other numeric codes has the type of the field it was checked on, an int, a float64, or an int64, so leave its kind off and its message single.

A param that is neither a string, an int, nor a float64 renders through fmt, as i18n.Arg says: a time.Time a hook passed as count reads 2000-01-01 00:00:00 +0000 UTC. To write one otherwise, replace it in the params before building the message; Params of either package returns a copy.

Validation

Errors stop generation:

  • a key a translation has but the default language does not
  • a message that is plural in one language and not in the other
  • a placeholder the default language's message does not have, or one written with a different kind
  • two different kinds for one placeholder within a message
  • a malformed file, key, or placeholder, and names that collide

Warnings do not:

  • keys a translation lacks. The runtime falls back, so the generated code never breaks when a translation lags behind; it renders the fallback until the translation lands.
  • plural forms a language uses that a message does not provide
  • an ambiguous default language, and files skipped in the locale directory

A misspelled key, placeholder, plural category, or kind comes with the name it was probably meant to be.

Flags

kanna-i18n [flags]
Flag Meaning
-locales <dir> directory holding the locale files (default locales)
-default <lang> language that defines the constructors and is the last fallback (default en)
-destination <dir> directory to write i18n_gen.go into (default messages)
-package <name> package name for a destination without Go files (default: the directory's name)
-check verify the output is up to date instead of writing it
-v list the file written or left unchanged
-version print the version

Paths are relative to the directory go generate runs in, the one holding the //go:generate line. The package of the generated file is the one the Go files already in the destination declare; -package names it when there are none yet, and one that disagrees with them is an error. A run that succeeds prints nothing but its warnings, which Validation lists, such as the keys a translation lacks. A file named i18n_gen.go without the generated-code header is never touched.

Code calling the messages is checked with what a run is about to write in place: the destination's own, and that of the packages of its module importing it, tests included, which a run loads through the go command. A key removed or renamed, or a placeholder changed, stops the run while code still calls the message as it was, and so does a mistake that a call to a message added hid; nothing is written then. An error that code had before the run does not stop it, but one in the destination does, the output's own package, as does a destination that does not parse. Code may call a message before the first run has written it.

Example

example renders the same messages in English and Japanese — plurals, locale-formatted numbers, a fallback for a missing translation, and a language chosen from an Accept-Language value — with nothing to set up at run time. CI regenerates it and fails if the output would change.

Development

make test       # unit tests
make lint       # golangci-lint
make example    # regenerate the example, then build and run it

License

MIT