feat: release v0.26 portable i18n and presentation adapters
This commit is contained in:
180
i18n/README.md
Normal file
180
i18n/README.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 通用国际化
|
||||
|
||||
本功能从 `v0.26.0-preview` 提供。`i18n` 可以单独用于 CLI、后台任务、通知或 Web 服务,不依赖 App、HTTP、错误目录、数据库或前端框架。业务文案、启用语言和用户偏好由接入项目维护。
|
||||
|
||||
模块没有全局当前语言、全局可变词典或自动文件扫描。词典构建后只读,支持多个实例、多个命名空间和并发调用。完整可运行示例见 [examples/i18n](../examples/i18n/README.md),生成契约见 [i18n.api](../examples/contracts/i18n.api)。
|
||||
|
||||
## 独立使用
|
||||
|
||||
```go
|
||||
bundle, err := i18n.New(i18n.Config{
|
||||
SourceLocale: "en", DefaultLocale: "en",
|
||||
Locales: []string{"en", "zh-Hans"},
|
||||
},
|
||||
i18n.Document{Version: 1, Namespace: "common", Locale: "en",
|
||||
Messages: map[string]i18n.Message{
|
||||
"greeting": {
|
||||
Params: map[string]i18n.Kind{"name": i18n.String},
|
||||
Value: i18n.Pattern{Text: "Hello, {name}"},
|
||||
},
|
||||
}},
|
||||
i18n.Document{Version: 1, Namespace: "common", Locale: "zh-Hans",
|
||||
Messages: map[string]i18n.Message{
|
||||
"greeting": {Value: i18n.Pattern{Text: "你好,{name}"}},
|
||||
}},
|
||||
)
|
||||
if err != nil { return err }
|
||||
result, err := bundle.Render("zh-CN", i18n.Ref{
|
||||
Namespace: "common", Key: "greeting",
|
||||
}, map[string]any{"name": "Alex"})
|
||||
// result.Text == "你好,Alex"; result.Locale == "zh-Hans"
|
||||
```
|
||||
|
||||
`Render` 显式接受语言。也可以用 `WithLocale(ctx, locale)` 和 `RenderContext`,语言只属于该上下文。任务消费者需由项目显式取得接收者语言;Go context 不会自动跨进程或写入队列。
|
||||
|
||||
`Result` 提供文本、匹配后的请求语言、实际词典语言和是否发生回退。缺键、缺参、类型错误、渲染限额返回 `*Diagnostic`;核心不决定 HTTP 状态,也不把任意错误自动转换成业务错误。诊断没有参数值、内部原因或原始文案。
|
||||
|
||||
## 语言选择与回退
|
||||
|
||||
- `SourceLocale` 是定义消息和参数的语言,可以只用作最终回退而不对用户开放。
|
||||
- `DefaultLocale` 是无合适偏好时的选择,必须在 `Locales` 中。
|
||||
- `Locales` 是有序的启用语言。`Match` 使用 `x/text/language` 的 BCP 47 匹配,返回真实配置的词典标签;不会用匹配器生成的带扩展标签直接索引文件。
|
||||
- `Fallbacks` 是显式、有序、无环的关系。先查选中语言,再深度优先查配置的回退;然后查默认语言及其回退,最后查源语言,过程中去重。不会从文件名或地域字符串猜一条词典继承链。
|
||||
- 非法请求偏好忽略并使用可用偏好或默认语言;非法配置、重复规范化语言、回退循环在 `New` 时失败。
|
||||
|
||||
运行时允许部分语言缺词,`Check` 单独列出各启用语言缺少的直接翻译。CI 可因此失败,在线请求仍使用回退。存在但格式损坏的译文在构建时拒绝;替换词典应先构建新实例,成功后由项目替换实例。
|
||||
|
||||
## 文案资源 v1
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"namespace": "notifications",
|
||||
"locale": "en",
|
||||
"messages": {
|
||||
"unread": {
|
||||
"params": {"count": "integer"},
|
||||
"value": {
|
||||
"plural": "count",
|
||||
"cases": {
|
||||
"=0": {"text": "No unread messages"},
|
||||
"one": {"text": "{count} unread message"},
|
||||
"other": {"text": "{count} unread messages"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同一命名空间的同一键只有一份源定义。译文省略 `params` 时继承源定义;重复声明时必须完全一致。参数是消息级契约:各语言使用相同参数集合,分支可以不使用其中某个参数,但整条消息的所有分支合起来必须使用全部参数。
|
||||
|
||||
`value` 支持:
|
||||
|
||||
- `text`:命名插值 `{name}`,`{{`、`}}` 表示字面花括号;空字符串是合法译文。
|
||||
- `literal: true`:文本原样保留,适用于已有固定消息。插入参数不会再次解析,也不会自动查词典。
|
||||
- `select`:根据 string/boolean 参数选择命名分支,必须有 `other`。
|
||||
- `plural`:根据 integer/number 参数选择基数复数分支,必须有 `other`;先匹配 `=0` 等精确 int64 分支,再匹配 `zero/one/two/few/many/other`。可与 select 嵌套。
|
||||
|
||||
复数规则固定为 [Unicode CLDR JSON 46.1.0](https://github.com/unicode-org/cldr-json/blob/46.1.0/cldr-json/cldr-core/supplemental/plurals.json)。Go 和浏览器使用同一份规则,导出包携带实际语言及回退语言的规则,避免浏览器自带规则版本不同。数据来源和许可见 [cldr](cldr/README.md)。规则版本与文案格式版本分别记录。
|
||||
|
||||
参数类型:
|
||||
|
||||
| 类型 | Go 调用 | 浏览器调用 |
|
||||
| --- | --- | --- |
|
||||
| `string` | 字符串 | string |
|
||||
| `boolean` | bool | boolean |
|
||||
| `integer` | int/int32/int64 等整数,或精确的 json.Number;范围为 int64 | 安全整数 number、bigint 或十进制整数字符串;超范围拒绝 |
|
||||
| `number` | 有限 float32/float64 或 json.Number | 有限 number |
|
||||
|
||||
Number 按规范化数值处理:`1.0` 与 `1` 等价,负零显示为 `0`,不保留输入小数位数。整数不会经过 float64;不接受任意对象、Stringer、nil、自动字符串转数值。日期、货币、精度敏感的小数和字段标签可由项目显式格式化为字符串参数,时区与币种不会从语言推断。
|
||||
|
||||
v1 的范围是普通文本、命名参数、select 和基数复数,不宣称兼容完整 ICU MessageFormat;不含 ordinal、紧凑数字格式、HTML 执行或任意表达式。复数计算使用普通数值,CLDR 的 compact-number `c/e` 操作数为 0。字段文案可以先显式 Render 后再作为参数;用户输入和业务数据保持原样。
|
||||
|
||||
## 加载、覆盖与资源限制
|
||||
|
||||
`LoadFS(fs.FS, paths...)` 读取明确列出的 JSON 文件,支持 `embed.FS` 和 `os.DirFS`。`DecodeJSON` 可用于业务自己取得的内存数据,拒绝重复键、未知字段、无效 UTF-8、超深结构。它们不展开 `${...}`,不执行项目代码,不自动拉远程词典。文件系统实际访问范围由传入的 FS 决定。
|
||||
|
||||
项目已有配置加载器可加载 `i18n.Config` 的语言设置,词典正文用本包加载;避免配置加载器把词典里的 `${...}` 解释成环境变量。数据库或远程词典由项目读取为 Document,再调用 New。
|
||||
|
||||
同一语言、命名空间可拆成多个文件;重复键默认报错。`Overlay(base, override)` 明确替换完整消息并返回独立副本,再传给 New 校验;不隐式合并分支或参数。项目决定覆盖的先后顺序。
|
||||
|
||||
主要限额:每个 JSON 文件 1 MiB、LoadFS 总量 4 MiB、最多 1024 个文档、64 个资源语言、128 个命名空间、10000 条消息、每条 64 个参数、单文本/字符串参数 64 KiB、模式深度 24、渲染结果 1 MiB。导出的单语言包最多 4 MiB,可按命名空间拆分。
|
||||
|
||||
`Config()`、`Documents()` 和 `Schema()` 返回独立副本;`Config.Validate()` 可预检语言配置。运行时没有修改词典的方法。
|
||||
|
||||
## 接入 go-omni 错误与 HTTP
|
||||
|
||||
```go
|
||||
// 项目自己的 Catalog,既有 status/code/message/params 继续维护在这里。
|
||||
catalog = errori18n.Bind(catalog, "accountErrors")
|
||||
sourceDocuments, err := errori18n.Source(catalog, "en")
|
||||
// 把 sourceDocuments 与项目加载的译文一起传入 i18n.New。
|
||||
// Bind 不修改输入;已有显式 translation 引用保持。
|
||||
|
||||
languagePolicy, err := httpi18n.New(bundle)
|
||||
localizer := errori18n.New(bundle, nil) // 第二参数可提供并发安全的诊断回调。
|
||||
if err := localizer.Validate(catalog); err != nil { return err }
|
||||
|
||||
app := omni.New(
|
||||
omni.WithErrors(catalog),
|
||||
omni.WithLanguagePolicy(languagePolicy),
|
||||
omni.WithErrorLocalizer(localizer),
|
||||
// 继续传入项目自己的 WithResponse / WithResponseEncoder。
|
||||
)
|
||||
```
|
||||
|
||||
也可在单项 Definition 或 `.api errorCatalog` 条目明确声明:
|
||||
|
||||
```json
|
||||
"translation": {"namespace":"accountErrors", "key":"username.invalid"}
|
||||
```
|
||||
|
||||
翻译引用与逻辑错误标识、对外业务码分别独立。不同目录可以为同名错误配置不同引用;多个错误引用同一源消息时,Source 会拒绝不一致的源文案或参数。Go 源目录通过 Source 导出,保留原固定文案的字面花括号,不包含状态、业务码、example 或 cause。
|
||||
|
||||
HTTP 策略在应用中间件之前选语言,默认读取有界 `Accept-Language`,保留请求上下文中显式设置的语言优先级。`httpi18n.WithResolver` 可替换语言来源,并显式声明缓存 Vary 所需的头;无头可声明的个性化来源自动标记 private。HTTP 策略不会读取用户数据库、JWT 或 Cookie 中的业务字段,也不自动设置可能与逐词回退不符的 Content-Language。独立标准 HTTP 可以用 `Policy.Wrap`。
|
||||
|
||||
自定义 Resolver panic 时按无偏好处理,继续使用显式上下文语言或默认语言,避免偏好查询故障中断业务请求。项目可在自己的 Resolver 中记录诊断。
|
||||
|
||||
错误适配器先使用原有规则解析业务错误,只替换展示文案;缺资源、适配失败保持原文,参数调用错误仍遵循原有 INTERNAL_ERROR 处理。没有 translation 引用的条目保持原行为。标准响应的平铺字段、嵌套 `${message}`、RawJSON 错误、旧 Messages 校验、路由独立目录、认证提前返回和缓冲克隆均保留原语义。
|
||||
|
||||
`response.Result.Message` 继续是契约原文;新增 `MessageOverride` 和 `PublicMessage()`。内置 Formatter 使用最终公开文案。自定义 Encoder 需要翻译时显式使用 `PublicMessage()`;固定回执继续由 Encoder 决定。`c.JSON/Text/XML/Bytes` 等直接输出不自动重写。
|
||||
|
||||
失败快照保存当次展示文案。幂等重放保留首次响应和失败元数据,不重新翻译,不把语言加入业务幂等键。`Localizer.Localize(ctx, registry, resolvedError)` 也可供非 HTTP 入口使用;返回值只包含文本和是否应用,不修改传入错误。
|
||||
|
||||
生成器识别 translation 元数据并写入 Catalog、路由目录和 OpenAPI;已有无 translation 的契约不变。新 metadata 的远程 runtime 下限为 `v0.26.0-preview`,CLI 和框架使用同版;开发框架时可以显式使用本地 checkout。生成到不具备能力的旧依赖会在写入前失败。
|
||||
|
||||
## CLI 提取、检查与导出
|
||||
|
||||
下面的命令在框架根目录可以直接运行;安装后的 CLI 把 `go run ./cmd/go-omni` 换成 `go-omni`。
|
||||
|
||||
```sh
|
||||
go run ./cmd/go-omni i18n check --config examples/i18n/config.json \
|
||||
--resource examples/i18n/locales/ui.en.json \
|
||||
--resource examples/i18n/locales/ui.zh-Hans.json
|
||||
|
||||
go run ./cmd/go-omni i18n export --config examples/i18n/config.json \
|
||||
--resource examples/i18n/locales/ui.en.json \
|
||||
--resource examples/i18n/locales/ui.zh-Hans.json \
|
||||
--locale zh-Hans --namespace ui
|
||||
|
||||
go run ./cmd/go-omni i18n types --config examples/i18n/config.json \
|
||||
--resource examples/i18n/locales/ui.en.json \
|
||||
--resource examples/i18n/locales/ui.zh-Hans.json --namespace ui
|
||||
|
||||
go run ./cmd/go-omni i18n extract --go examples/i18n/main.go
|
||||
```
|
||||
|
||||
- `check` 检查资源和直接翻译遗漏,非空诊断退出非零;可重复提供 `--go` 检查静态引用。
|
||||
- `extract --go` 识别 Go 中 `i18n.Ref` 的字符串字面量及 import 别名;动态引用、非字面量 Render 调用和 dot import 会报告,不能声称完成任意 Go/JS/模板的静态分析。提取命令输出发现结果,check 把未解决动态引用视为诊断。
|
||||
- `extract --catalog exported-errors.json --namespace errors --locale en` 读取已有错误目录导出物,生成单个源语言 Document,不执行项目 Go。目录可以由已有 workspace Go Catalog 导出流程产生。
|
||||
- `export` 显式选择命名空间,输出一个选中语言及全部回退资源的 Pack,不输出业务错误元数据。可重复 `--namespace`。
|
||||
- `types` 输出按 namespace/key 分层的 TypeScript 参数类型及 Translate 类型,不强制 Go 命名风格,不把 int64 简单降成 number。
|
||||
|
||||
这些命令只读输入并输出 stdout,没有自动覆盖业务文件、扫描其他项目、翻译服务调用或安装前端依赖。`Bundle.Export`、`TypeScript` 提供相同的库入口。
|
||||
|
||||
## 前端
|
||||
|
||||
见 [browser](browser/README.md)。运行时是普通 ES module,带 TypeScript 声明,可按项目需要复制进前端或通过 `browseri18n.Files()` 提供静态文件。语言包按需加载、先验证再提交;连续切换只有最新选择能生效,失败保留已提交语言。没有 DOM、localStorage、表单或业务请求的隐式操作。
|
||||
|
||||
升级本模块时必须一起验证资源格式、规则版本、前后端一致性、错误兼容和生成消费者。现有项目不启用这两个适配选项时,响应与错误行为保持原样。
|
||||
39
i18n/browser/README.md
Normal file
39
i18n/browser/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 浏览器运行时
|
||||
|
||||
`runtime.mjs` 是可独立导入的 ES module,`runtime.d.mts` 提供类型。需要支持 ES modules、BigInt、TextEncoder 和 structuredClone 的现代浏览器;Node.js 22 用于本仓库验证。复数规则来自导出包,与 Go 共用固定 CLDR 数据,不调用浏览器自己的 Intl.PluralRules。语言选择使用包中 `locales` 的精确标识,不由浏览器再次改写别名或推断回退链。
|
||||
|
||||
```js
|
||||
import { createLanguageController, fetchPack } from './runtime.mjs';
|
||||
|
||||
const urls = { en: '/locales/en.json', 'zh-Hans': '/locales/zh-Hans.json' };
|
||||
const language = createLanguageController(
|
||||
await fetchPack(urls.en),
|
||||
locale => fetchPack(urls[locale])
|
||||
);
|
||||
|
||||
await language.setLanguage('zh-Hans');
|
||||
const text = language.current.t('ui', 'greeting', { name: 'Alex' });
|
||||
```
|
||||
|
||||
`current.render` 额外返回实际语言和 fallback 标记。`setLanguage` 成功提交返回 true;被更新选择取代返回 false;最新加载失败抛错并保留 current。甚至“切回当前语言”也会取消旧请求的提交资格。未知语言、错误语言包、参数 schema 或启用语言集合变化都不能提交。
|
||||
|
||||
`fetchPack` 检查 HTTP 状态和 4 MiB 限额,支持 AbortSignal 和自定义 fetch。加载资源、鉴权、缓存策略与 URL 映射由调用者决定。它只返回 JSON 数据,`createLocalizer` / controller 负责完整资源检查。
|
||||
|
||||
`overlayPack(pack, documents)` 显式覆盖已导出键的完整消息并重新校验,不修改输入,不允许改变参数契约。例如替换品牌标题;覆写源消息时保留原 params。业务可在 loader 返回之前应用 overrides。
|
||||
|
||||
```ts
|
||||
import type { Translate } from './messages.generated';
|
||||
const t: Translate = language.current.t;
|
||||
// t('ui', 'greeting', { name: 'Alex' })
|
||||
```
|
||||
|
||||
应用需要在切换成功后重新取得 current.t。视图使用 textContent 或框架的文本绑定,动态参数不递归解析、不作为 HTML 执行。金额格式化、RTL 布局、表单、路由、业务提交和偏好持久化属于应用;运行时不重建页面或重新发业务请求。
|
||||
|
||||
完整表单示例:[examples/i18n](../../examples/i18n/README.md)。验证:
|
||||
|
||||
```sh
|
||||
node --test i18n/browser/runtime.test.mjs
|
||||
go test ./i18n -run TestBrowserConformance
|
||||
# PATH 中提供 tsc 时,验证导出类型与实际 runtime 声明及错误参数诊断。
|
||||
go test ./i18n -run TestTypeScriptConsumer -v
|
||||
```
|
||||
13
i18n/browser/assets.go
Normal file
13
i18n/browser/assets.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Package browseri18n embeds the optional framework-neutral browser module.
|
||||
// Frontend projects can also copy/import runtime.mjs directly.
|
||||
package browseri18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed runtime.mjs runtime.d.mts
|
||||
var files embed.FS
|
||||
|
||||
func Files() fs.FS { return files }
|
||||
6
i18n/browser/conformance.mjs
Normal file
6
i18n/browser/conformance.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import assert from "node:assert/strict";
|
||||
import { createLocalizer } from "./runtime.mjs";
|
||||
const { pack, cases } = JSON.parse(readFileSync(0, "utf8"));
|
||||
const localizer = createLocalizer(pack);
|
||||
for (const sample of cases) assert.equal(localizer.t("common", sample.key, sample.params), sample.want);
|
||||
29
i18n/browser/runtime.d.mts
Normal file
29
i18n/browser/runtime.d.mts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type Kind = "string" | "boolean" | "integer" | "number";
|
||||
export interface Pattern { text?: string; literal?: boolean; select?: string; plural?: string; cases?: Record<string, Pattern>; }
|
||||
export interface Message { params?: Record<string, Kind>; value: Pattern; }
|
||||
export interface Document { version: 1; namespace: string; locale: string; messages: Record<string, Message>; }
|
||||
export interface Configuration { sourceLocale: string; defaultLocale: string; locales: string[]; fallbacks?: Record<string, string[]>; }
|
||||
export interface Pack {
|
||||
version: 1;
|
||||
locale: string;
|
||||
config: Configuration;
|
||||
documents: Document[];
|
||||
ruleVersion: "cldr-46.1";
|
||||
pluralRules: Record<string, Partial<Record<"zero" | "one" | "two" | "few" | "many", string>>>;
|
||||
license?: string;
|
||||
}
|
||||
export interface Rendered { text: string; locale: string; requested: string; fallback: boolean; }
|
||||
export interface Localizer {
|
||||
readonly locale: string;
|
||||
readonly locales: readonly string[];
|
||||
render(namespace: string, key: string, params?: Record<string, unknown>): Rendered;
|
||||
t(namespace: string, key: string, params?: Record<string, unknown>): string;
|
||||
}
|
||||
export class MessageError extends Error { readonly kind: string; constructor(kind: string, problem: string); }
|
||||
export function createLocalizer(pack: Pack): Localizer;
|
||||
export function overlayPack(pack: Pack, overrides: Document[]): Pack;
|
||||
export function createLanguageController(initialPack: Pack, loadPack: (locale: string) => Promise<Pack>): {
|
||||
readonly current: Localizer;
|
||||
setLanguage(locale: string): Promise<boolean>;
|
||||
};
|
||||
export function fetchPack(url: string | URL, options?: { signal?: AbortSignal; fetch?: typeof globalThis.fetch }): Promise<Pack>;
|
||||
305
i18n/browser/runtime.mjs
Normal file
305
i18n/browser/runtime.mjs
Normal file
@@ -0,0 +1,305 @@
|
||||
// Framework-neutral runtime for go-omni message resource version 1.
|
||||
// No globals, DOM changes, persistence, HTML insertion, or business requests.
|
||||
const owns = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
||||
const kinds = new Set(["string", "boolean", "integer", "number"]);
|
||||
const pluralCases = new Set(["zero", "one", "two", "few", "many", "other"]);
|
||||
const integer = /^-?(0|[1-9][0-9]*)$/;
|
||||
const paramName = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
|
||||
const encoder = new TextEncoder();
|
||||
const contractKey = Symbol("messageContract");
|
||||
const bytes = text => encoder.encode(text).length;
|
||||
const object = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const id = v => typeof v === "string" && v.length > 0 && bytes(v) <= 256 && v.trim() === v && !/[\u0000-\u001f\u007f-\u009f]/u.test(v);
|
||||
const fail = (kind, problem) => { throw new MessageError(kind, problem); };
|
||||
const fields = (value, allowed) => {
|
||||
if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail("resource", "invalid resource fields");
|
||||
};
|
||||
const localeTag = value => {
|
||||
// Tags are canonicalized by the Go exporter. Keep their identity verbatim:
|
||||
// host Intl versions can remap aliases (e.g. cmn) or reject private-use tags.
|
||||
if (typeof value !== "string" || bytes(value) > 128 || value === "und" ||
|
||||
!/^(?:[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*|[xX](?:-[A-Za-z0-9]{1,8})+)$/.test(value)) fail("locale", "invalid exported locale identifier");
|
||||
return value;
|
||||
};
|
||||
|
||||
export class MessageError extends Error {
|
||||
constructor(kind, problem) { super(`i18n: ${problem}`); this.name = "MessageError"; this.kind = kind; }
|
||||
}
|
||||
|
||||
function scalar(kind, value) {
|
||||
if (kind === "string" && typeof value === "string" && bytes(value) <= 65536) return value;
|
||||
if (kind === "boolean" && typeof value === "boolean") return String(value);
|
||||
if (kind === "number" && typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (kind === "integer") {
|
||||
let n;
|
||||
if (typeof value === "bigint") n = value;
|
||||
else if (typeof value === "number" && Number.isSafeInteger(value)) n = BigInt(value);
|
||||
else if (typeof value === "string" && value.length <= 20 && integer.test(value)) n = BigInt(value);
|
||||
else fail("parameter", "expected an exact integer");
|
||||
if (n >= -(1n << 63n) && n < (1n << 63n)) return String(n);
|
||||
}
|
||||
fail("parameter", "wrong argument type, range or size");
|
||||
}
|
||||
|
||||
function compile(pattern, schema, used, budget, depth = 0) {
|
||||
fields(pattern, ["text", "literal", "select", "plural", "cases"]);
|
||||
const text = pattern.text ?? "";
|
||||
if (typeof text !== "string" || (pattern.literal !== undefined && typeof pattern.literal !== "boolean")) fail("pattern", "invalid text or literal flag");
|
||||
budget.nodes++; budget.bytes += bytes(text);
|
||||
if (depth > 24 || budget.nodes > 100000 || budget.bytes > 4194304 || bytes(text) > 65536) fail("resource", "pattern exceeds limits");
|
||||
const select = pattern.select ?? "", plural = pattern.plural ?? "";
|
||||
if (typeof select !== "string" || typeof plural !== "string") fail("pattern", "invalid branch parameter");
|
||||
if (select || plural) {
|
||||
if ((select && plural) || text || pattern.literal || !object(pattern.cases)) fail("pattern", "invalid branch");
|
||||
const entries = Object.entries(pattern.cases), key = select || plural;
|
||||
if (!owns(pattern.cases, "other") || entries.length > 128) fail("pattern", "branch requires other and at most 128 cases");
|
||||
const kind = schema.get(key);
|
||||
if (plural ? kind !== "integer" && kind !== "number" : kind !== "string" && kind !== "boolean") fail("parameter", "branch parameter type mismatch");
|
||||
used.add(key);
|
||||
const cases = new Map();
|
||||
for (const [name, child] of entries) {
|
||||
if (!id(name)) fail("pattern", "invalid case name");
|
||||
if (plural && !pluralCases.has(name)) {
|
||||
if (!name.startsWith("=") || !integer.test(name.slice(1)) || scalar("integer", name.slice(1)) !== name.slice(1)) fail("pattern", "invalid plural case");
|
||||
}
|
||||
cases.set(name, compile(child, schema, used, budget, depth + 1));
|
||||
}
|
||||
return { select, plural, cases };
|
||||
}
|
||||
if (pattern.cases !== undefined) fail("pattern", "cases require a branch");
|
||||
if (pattern.literal) return { parts: [{ text }] };
|
||||
const parts = [];
|
||||
let literal = "";
|
||||
const flush = () => { if (literal) parts.push({ text: literal }); literal = ""; };
|
||||
for (let i = 0; i < text.length;) {
|
||||
const ch = text[i];
|
||||
if (ch !== "{" && ch !== "}") { literal += ch; i++; continue; }
|
||||
if (text[i + 1] === ch) { literal += ch; i += 2; continue; }
|
||||
if (ch === "}") fail("pattern", "unescaped closing brace");
|
||||
const end = text.indexOf("}", i + 1);
|
||||
if (end < 0) fail("pattern", "unclosed placeholder");
|
||||
const key = text.slice(i + 1, end);
|
||||
if (!schema.has(key)) fail("parameter", "undeclared placeholder");
|
||||
used.add(key); flush(); parts.push({ param: key }); i = end + 1;
|
||||
}
|
||||
flush(); return { parts };
|
||||
}
|
||||
|
||||
function pluralOperands(text) {
|
||||
const [mantissa, exponent = "0"] = text.replace(/^-/, "").split("e");
|
||||
let [whole, fraction = ""] = mantissa.split(".");
|
||||
const digits = whole + fraction, point = whole.length + Number(exponent);
|
||||
if (point <= 0) { whole = "0"; fraction = "0".repeat(-point) + digits; }
|
||||
else if (point >= digits.length) { whole = digits + "0".repeat(point - digits.length); fraction = ""; }
|
||||
else { whole = digits.slice(0, point); fraction = digits.slice(point); }
|
||||
const tail = fraction.replace(/0+$/, ""), i = BigInt(whole || "0"), f = BigInt(fraction || "0");
|
||||
return { n: i, i, v: BigInt(fraction.length), w: BigInt(tail.length), f, t: BigInt(tail || "0"), c: 0n, e: 0n, fractional: f !== 0n };
|
||||
}
|
||||
|
||||
function compilePluralRules(rules) {
|
||||
if (!object(rules) || Object.keys(rules).some(k => !["zero", "one", "two", "few", "many"].includes(k))) fail("resource", "invalid plural rules");
|
||||
const compiled = new Map();
|
||||
for (const [category, text] of Object.entries(rules)) {
|
||||
if (typeof text !== "string" || text.length > 2048) fail("resource", "invalid plural rule size");
|
||||
compiled.set(category, text.split(" or ").map(or => or.split(" and ").map(and => {
|
||||
const match = /^([nivwftec])(?: % ([1-9][0-9]*))? (!=|=) ([0-9]+(?:\.\.[0-9]+)?(?:,\s*[0-9]+(?:\.\.[0-9]+)?)*)$/.exec(and.trim());
|
||||
if (!match) fail("resource", "invalid CLDR plural condition");
|
||||
const ranges = match[4].split(",").map(raw => { const [lo, hi = lo] = raw.trim().split(".."); const range = [BigInt(lo), BigInt(hi)]; if (range[0] > range[1]) fail("resource", "reversed plural range"); return range; });
|
||||
return { operand: match[1], mod: match[2] ? BigInt(match[2]) : undefined, negate: match[3] === "!=", ranges };
|
||||
})));
|
||||
}
|
||||
return text => {
|
||||
const values = pluralOperands(text);
|
||||
for (const category of ["zero", "one", "two", "few", "many"]) {
|
||||
const rule = compiled.get(category);
|
||||
if (rule?.some(group => group.every(condition => {
|
||||
let value = values[condition.operand]; if (condition.mod) value %= condition.mod;
|
||||
let found = (condition.operand !== "n" || !values.fractional) && condition.ranges.some(([lo, hi]) => value >= lo && value <= hi);
|
||||
return condition.negate ? !found : found;
|
||||
}))) return category;
|
||||
}
|
||||
return "other";
|
||||
};
|
||||
}
|
||||
|
||||
function render(pattern, locale, values, pluralRules) {
|
||||
if (pattern.cases) {
|
||||
let key = values.get(pattern.select);
|
||||
if (pattern.plural) {
|
||||
const value = values.get(pattern.plural);
|
||||
key = `=${value}`;
|
||||
if (!pattern.cases.has(key)) key = pluralRules.get(locale)(value);
|
||||
}
|
||||
return render(pattern.cases.get(key) ?? pattern.cases.get("other"), locale, values, pluralRules);
|
||||
}
|
||||
let text = "", size = 0;
|
||||
for (const part of pattern.parts) {
|
||||
const value = part.param === undefined ? part.text : values.get(part.param);
|
||||
size += bytes(value); if (size > 1048576) fail("resource", "rendered message exceeds 1 MiB");
|
||||
text += value;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Compiles a detached pack for one locale. Arguments are never recursively
|
||||
// interpreted as messages or HTML. Applications insert output as text.
|
||||
export function createLocalizer(pack) {
|
||||
fields(pack, ["version", "locale", "config", "documents", "ruleVersion", "pluralRules", "license"]);
|
||||
if (pack.version !== 1 || !Array.isArray(pack.documents) || pack.documents.length > 1024) fail("resource", "unsupported pack version or size");
|
||||
const c = pack.config;
|
||||
fields(c, ["sourceLocale", "defaultLocale", "locales", "fallbacks"]);
|
||||
const source = localeTag(c.sourceLocale), defaultLocale = localeTag(c.defaultLocale), locale = localeTag(pack.locale);
|
||||
if (!Array.isArray(c.locales) || c.locales.length < 1 || c.locales.length > 64) fail("locale", "invalid enabled locales");
|
||||
const locales = c.locales.map(localeTag), enabled = new Set(locales);
|
||||
if (enabled.size !== locales.length || ![defaultLocale, locale].every(l => enabled.has(l))) fail("locale", "duplicate or disabled locale");
|
||||
const fallback = new Map();
|
||||
if (c.fallbacks !== undefined && !object(c.fallbacks)) fail("locale", "invalid fallback configuration");
|
||||
for (const [raw, list] of Object.entries(c.fallbacks ?? {})) {
|
||||
const tag = localeTag(raw);
|
||||
if (!enabled.has(tag) || fallback.has(tag) || !Array.isArray(list) || list.length > 64) fail("locale", "invalid fallback configuration");
|
||||
const targets = list.map(localeTag);
|
||||
if (new Set(targets).size !== targets.length || targets.some(l => !enabled.has(l))) fail("locale", "invalid fallback targets");
|
||||
fallback.set(tag, targets);
|
||||
}
|
||||
const state = new Map();
|
||||
const visit = tag => { if (state.get(tag) === 1) fail("locale", "fallback cycle"); if (state.get(tag) === 2) return; state.set(tag, 1); for (const to of fallback.get(tag) ?? []) visit(to); state.set(tag, 2); };
|
||||
for (const l of locales) visit(l);
|
||||
const chain = [], seen = new Set();
|
||||
const add = l => { if (seen.has(l)) return; seen.add(l); chain.push(l); for (const to of fallback.get(l) ?? []) add(to); };
|
||||
add(locale); add(defaultLocale); add(source);
|
||||
if (pack.ruleVersion !== "cldr-46.1" || !object(pack.pluralRules) || Object.keys(pack.pluralRules).length > 65) fail("resource", "unsupported plural rule version");
|
||||
const pluralRules = new Map();
|
||||
for (const tag of chain) {
|
||||
if (!owns(pack.pluralRules, tag)) fail("resource", "missing plural rules for fallback language");
|
||||
pluralRules.set(tag, compilePluralRules(pack.pluralRules[tag]));
|
||||
}
|
||||
const namespaces = new Map(), documents = [], all = new Map();
|
||||
let count = 0;
|
||||
for (const doc of pack.documents) {
|
||||
fields(doc, ["version", "namespace", "locale", "messages"]);
|
||||
if (doc.version !== 1 || !id(doc.namespace) || !object(doc.messages)) fail("resource", "invalid document");
|
||||
const tag = localeTag(doc.locale);
|
||||
if (!enabled.has(tag) && tag !== source) fail("locale", "pack contains a disabled locale");
|
||||
if (!all.has(tag)) all.set(tag, new Map());
|
||||
const byNamespace = all.get(tag);
|
||||
if (!byNamespace.has(doc.namespace)) byNamespace.set(doc.namespace, new Set());
|
||||
for (const key of Object.keys(doc.messages)) {
|
||||
if (!id(key) || byNamespace.get(doc.namespace).has(key)) fail("resource", "invalid or duplicate message");
|
||||
byNamespace.get(doc.namespace).add(key);
|
||||
if (++count > 10000) fail("resource", "too many messages");
|
||||
}
|
||||
documents.push({ namespace: doc.namespace, tag, messages: doc.messages });
|
||||
}
|
||||
for (const doc of documents.filter(d => d.tag === source)) {
|
||||
if (!namespaces.has(doc.namespace)) namespaces.set(doc.namespace, new Map());
|
||||
const messages = namespaces.get(doc.namespace);
|
||||
for (const [key, message] of Object.entries(doc.messages)) {
|
||||
fields(message, ["params", "value"]);
|
||||
if (!object(message.value) || message.params !== undefined && !object(message.params)) fail("resource", "message requires value and valid params");
|
||||
const schema = new Map(Object.entries(message.params ?? {}));
|
||||
if (schema.size > 64 || [...schema].some(([k, v]) => !paramName.test(k) || bytes(k) > 128 || !kinds.has(v))) fail("parameter", "invalid parameter schema");
|
||||
messages.set(key, { schema, patterns: new Map() });
|
||||
}
|
||||
}
|
||||
if (namespaces.size > 128) fail("resource", "too many namespaces");
|
||||
const budget = { nodes: 0, bytes: 0 };
|
||||
for (const doc of documents) {
|
||||
for (const [key, message] of Object.entries(doc.messages)) {
|
||||
fields(message, ["params", "value"]);
|
||||
const entry = namespaces.get(doc.namespace)?.get(key);
|
||||
if (!entry) fail("missing", "translation has no source message");
|
||||
if (message.params !== undefined && (!object(message.params) || Object.keys(message.params).length !== entry.schema.size || Object.entries(message.params).some(([k, v]) => entry.schema.get(k) !== v))) fail("parameter", "translation schema differs from source");
|
||||
const used = new Set();
|
||||
entry.patterns.set(doc.tag, compile(message.value, entry.schema, used, budget));
|
||||
if ([...entry.schema.keys()].some(k => !used.has(k))) fail("parameter", "declared parameter is unused");
|
||||
}
|
||||
}
|
||||
const translate = (namespace, key, params = {}) => {
|
||||
const entry = namespaces.get(namespace)?.get(key);
|
||||
if (!entry) fail("missing", "unknown message");
|
||||
if (!object(params)) fail("parameter", "expected parameter object");
|
||||
const values = new Map();
|
||||
for (const [name, kind] of entry.schema) {
|
||||
if (!owns(params, name)) fail("parameter", "missing argument");
|
||||
values.set(name, scalar(kind, params[name]));
|
||||
}
|
||||
if (Object.keys(params).some(k => !entry.schema.has(k))) fail("parameter", "undeclared argument");
|
||||
for (const tag of chain) {
|
||||
const pattern = entry.patterns.get(tag);
|
||||
if (pattern) return { text: render(pattern, tag, values, pluralRules), locale: tag, requested: locale, fallback: tag !== locale };
|
||||
}
|
||||
fail("missing", "no message in fallback chain");
|
||||
};
|
||||
const contract = JSON.stringify([pack.ruleVersion, source, defaultLocale, locales, [...fallback].sort(), [...namespaces].sort().map(([ns, messages]) => [ns, [...messages].sort().map(([key, entry]) => [key, [...entry.schema].sort()])])]);
|
||||
return Object.freeze({ [contractKey]: contract, locale, locales: Object.freeze([...locales]), render: translate, t: (ns, key, params) => translate(ns, key, params).text });
|
||||
}
|
||||
|
||||
// Only a fully loaded and validated pack can replace the current language.
|
||||
// Last selection wins. A failed latest request keeps the committed language;
|
||||
// older requests cannot commit after it, even when they later succeed.
|
||||
export function createLanguageController(initialPack, loadPack) {
|
||||
if (typeof loadPack !== "function") fail("resource", "loader required");
|
||||
let current = createLocalizer(initialPack), sequence = 0;
|
||||
const enabled = [...current.locales];
|
||||
return Object.freeze({
|
||||
get current() { return current; },
|
||||
async setLanguage(raw) {
|
||||
const ticket = ++sequence, locale = localeTag(raw);
|
||||
if (!enabled.includes(locale)) fail("locale", "locale is not enabled");
|
||||
if (locale === current.locale) return true;
|
||||
let pack;
|
||||
try { pack = await loadPack(locale); }
|
||||
catch (error) { if (ticket !== sequence) return false; throw error; }
|
||||
if (ticket !== sequence) return false;
|
||||
const next = createLocalizer(pack);
|
||||
if (next.locale !== locale || next[contractKey] !== current[contractKey]) fail("locale", "loaded pack does not match requested language/configuration");
|
||||
current = next;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Explicit whole-message overrides. Originals stay untouched; unknown keys and
|
||||
// changed parameter schemas are rejected by validation before the pack is used.
|
||||
export function overlayPack(pack, overrides) {
|
||||
createLocalizer(pack);
|
||||
if (!Array.isArray(overrides) || overrides.length > 1024) fail("resource", "invalid overrides");
|
||||
const result = structuredClone(pack), seen = new Set();
|
||||
for (const doc of overrides) {
|
||||
fields(doc, ["version", "namespace", "locale", "messages"]);
|
||||
if (doc.version !== 1 || !object(doc.messages)) fail("resource", "invalid override document");
|
||||
const tag = localeTag(doc.locale);
|
||||
const target = result.documents.find(d => d.namespace === doc.namespace && localeTag(d.locale) === tag);
|
||||
if (!target) fail("resource", "override must target an exported namespace and locale");
|
||||
for (const [key, message] of Object.entries(doc.messages)) {
|
||||
const identity = JSON.stringify([tag, doc.namespace, key]);
|
||||
if (seen.has(identity) || !owns(target.messages, key)) fail("resource", "unknown or duplicate override key");
|
||||
seen.add(identity);
|
||||
Object.defineProperty(target.messages, key, { value: structuredClone(message), enumerable: true, writable: true, configurable: true });
|
||||
}
|
||||
}
|
||||
const checked = createLocalizer(result), original = createLocalizer(pack);
|
||||
if (checked[contractKey] !== original[contractKey]) fail("parameter", "overrides cannot change the message contract");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Optional bounded fetch loader. The caller supplies the URL mapping; locale is
|
||||
// never treated as an arbitrary path or endpoint by this runtime.
|
||||
export async function fetchPack(url, { signal, fetch: fetcher = globalThis.fetch } = {}) {
|
||||
const response = await fetcher(url, { signal, credentials: "same-origin" });
|
||||
if (!response.ok) fail("load", "language pack request failed");
|
||||
if (!response.body) fail("load", "language pack response has no body");
|
||||
const reader = response.body.getReader(), chunks = []; let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > 4194304) { await reader.cancel(); fail("resource", "language pack exceeds 4 MiB"); }
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
const raw = new Uint8Array(size); let offset = 0;
|
||||
for (const chunk of chunks) { raw.set(chunk, offset); offset += chunk.length; }
|
||||
try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); }
|
||||
catch { fail("load", "invalid language pack JSON"); }
|
||||
}
|
||||
50
i18n/browser/runtime.test.mjs
Normal file
50
i18n/browser/runtime.test.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createLocalizer, createLanguageController, overlayPack, fetchPack } from "./runtime.mjs";
|
||||
|
||||
const pack = locale => ({ version: 1, locale, ruleVersion: "cldr-46.1", pluralRules: { en: { one: "i = 1 and v = 0" }, fr: { one: "i = 0,1" }, de: { one: "i = 1 and v = 0" } }, config: { sourceLocale: "en", defaultLocale: "en", locales: ["en", "fr", "de"] }, documents: [
|
||||
{ version: 1, locale: "en", namespace: "ui", messages: { title: { value: { text: "English" } }, greet: { params: { name: "string" }, value: { text: "Hello {name}" } } } },
|
||||
... (locale === "en" ? [] : [{ version: 1, locale, namespace: "ui", messages: { title: { value: { text: locale } } } }])
|
||||
] });
|
||||
|
||||
test("detached resources, explicit overrides, text arguments and fallback", () => {
|
||||
const data = pack("fr"), localizer = createLocalizer(data);
|
||||
data.documents[0].messages.greet.value.text = "changed";
|
||||
assert.equal(localizer.t("ui", "greet", { name: "<b>{name}</b>" }), "Hello <b>{name}</b>");
|
||||
assert.equal(localizer.render("ui", "greet", { name: "x" }).locale, "en");
|
||||
const original = pack("en");
|
||||
const overridden = overlayPack(original, [{ version: 1, namespace: "ui", locale: "en", messages: { title: { value: { text: "Custom" } } } }]);
|
||||
assert.equal(createLocalizer(overridden).t("ui", "title"), "Custom");
|
||||
assert.equal(createLocalizer(original).t("ui", "title"), "English");
|
||||
assert.throws(() => overlayPack(original, [{ version: 1, namespace: "ui", locale: "en", messages: { unknown: { value: {} } } }]));
|
||||
});
|
||||
|
||||
test("latest selection wins and failures preserve committed state", async () => {
|
||||
const pending = new Map();
|
||||
const controller = createLanguageController(pack("en"), locale => new Promise((resolve, reject) => pending.set(locale, { resolve, reject })));
|
||||
const french = controller.setLanguage("fr"), german = controller.setLanguage("de");
|
||||
pending.get("de").resolve(pack("de")); assert.equal(await german, true);
|
||||
pending.get("fr").resolve(pack("fr")); assert.equal(await french, false);
|
||||
assert.equal(controller.current.locale, "de");
|
||||
const failed = controller.setLanguage("fr"); pending.get("fr").reject(Error("offline"));
|
||||
await assert.rejects(failed); assert.equal(controller.current.locale, "de");
|
||||
const stale = controller.setLanguage("fr"); await controller.setLanguage("de");
|
||||
pending.get("fr").reject(Error("stale failure")); assert.equal(await stale, false);
|
||||
assert.equal(controller.current.t("ui", "title"), "de");
|
||||
});
|
||||
|
||||
test("bad packs cannot commit or change enabled languages/schemas", async () => {
|
||||
const controller = createLanguageController(pack("en"), async () => { const data = pack("fr"); delete data.documents[0].messages.greet; return data; });
|
||||
await assert.rejects(controller.setLanguage("fr")); assert.equal(controller.current.locale, "en");
|
||||
await assert.rejects(controller.setLanguage("zz"));
|
||||
const cycle = pack("en"); cycle.config.fallbacks = { en: ["fr"], fr: ["en"] }; assert.throws(() => createLocalizer(cycle));
|
||||
const duplicate = pack("en"); duplicate.documents.push(duplicate.documents[0]); assert.throws(() => createLocalizer(duplicate));
|
||||
});
|
||||
|
||||
test("bounded fetching and validation before switching", async () => {
|
||||
const data = await fetchPack("/en.json", { fetch: async () => new Response(JSON.stringify(pack("en"))) });
|
||||
assert.equal(createLocalizer(data).locale, "en");
|
||||
await assert.rejects(fetchPack("/bad", { fetch: async () => new Response("bad", { status: 500 }) }));
|
||||
await assert.rejects(fetchPack("/bad", { fetch: async () => new Response("bad") }));
|
||||
await assert.rejects(fetchPack("/huge", { fetch: async () => new Response(new Uint8Array(4194305)) }));
|
||||
});
|
||||
26
i18n/browser/testdata/consumer.mts
vendored
Normal file
26
i18n/browser/testdata/consumer.mts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
import {createLocalizer, createLanguageController, fetchPack, overlayPack} from './runtime.mjs';
|
||||
import type {Pack} from './runtime.mjs';
|
||||
import type {Translate} from './messages.generated.mjs';
|
||||
|
||||
declare const pack: Pack;
|
||||
const messages = createLocalizer(pack);
|
||||
const t: Translate = messages.t;
|
||||
t('common', 'greeting', {first_name: 'Alex'});
|
||||
t('common', 'count', {count: 9223372036854775807n});
|
||||
t('common', 'count', {count: '9223372036854775807'});
|
||||
t('common', 'literal', {});
|
||||
// @ts-expect-error a message without parameters accepts only an empty object
|
||||
t('common', 'literal', {extra: 1});
|
||||
// @ts-expect-error missing parameter
|
||||
t('common', 'greeting', {});
|
||||
// @ts-expect-error invalid parameter type
|
||||
t('common', 'greeting', {first_name: false});
|
||||
// @ts-expect-error undeclared parameter
|
||||
t('common', 'greeting', {first_name: 'Alex', secret: 'extra'});
|
||||
// @ts-expect-error unknown key
|
||||
t('common', 'unknown', {});
|
||||
// @ts-expect-error unknown namespace
|
||||
t('missing', 'literal', {});
|
||||
const controller = createLanguageController(pack, locale => fetchPack('/' + locale + '.json'));
|
||||
void controller.setLanguage('en');
|
||||
overlayPack(pack, [{version: 1, locale: 'en', namespace: 'common', messages: {literal: {value: {text: 'Custom'}}}}]);
|
||||
434
i18n/bundle.go
Normal file
434
i18n/bundle.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type entry struct {
|
||||
schema map[string]Kind
|
||||
patterns map[string]*compiled
|
||||
}
|
||||
|
||||
// Bundle owns detached, compiled resources and is safe for concurrent reads.
|
||||
// Reconfiguration constructs another Bundle; no global current language exists.
|
||||
type Bundle struct {
|
||||
config Config
|
||||
matcher language.Matcher
|
||||
locales []string
|
||||
tags map[string]language.Tag
|
||||
entries map[Ref]*entry
|
||||
documents []Document
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
func validID(s string) bool {
|
||||
return s != "" && len(s) <= 256 && utf8.ValidString(s) && strings.TrimSpace(s) == s && strings.IndexFunc(s, unicode.IsControl) < 0
|
||||
}
|
||||
func canonical(s string) (string, error) {
|
||||
if s == "" || len(s) > 128 {
|
||||
return "", fmt.Errorf("i18n: language tag required (maximum 128 bytes)")
|
||||
}
|
||||
t, err := language.Parse(s)
|
||||
if err != nil || t == language.Und {
|
||||
return "", fmt.Errorf("i18n: invalid language tag")
|
||||
}
|
||||
return t.String(), nil
|
||||
}
|
||||
|
||||
func normalizeConfig(c Config) (Config, error) {
|
||||
var err error
|
||||
if c.SourceLocale, err = canonical(c.SourceLocale); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.DefaultLocale, err = canonical(c.DefaultLocale); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if len(c.Locales) == 0 || len(c.Locales) > 64 {
|
||||
return Config{}, fmt.Errorf("i18n: enable between 1 and 64 locales")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
locales := make([]string, 0, len(c.Locales))
|
||||
for _, raw := range c.Locales {
|
||||
l, e := canonical(raw)
|
||||
if e != nil {
|
||||
return Config{}, e
|
||||
}
|
||||
if seen[l] {
|
||||
return Config{}, fmt.Errorf("i18n: duplicate normalized locale %s", l)
|
||||
}
|
||||
seen[l] = true
|
||||
locales = append(locales, l)
|
||||
}
|
||||
if !seen[c.DefaultLocale] {
|
||||
return Config{}, fmt.Errorf("i18n: default locale must be enabled")
|
||||
}
|
||||
c.Locales = locales
|
||||
fallbacks := map[string][]string{}
|
||||
for _, raw := range sortedKeys(c.Fallbacks) {
|
||||
l, e := canonical(raw)
|
||||
if e != nil || !seen[l] {
|
||||
return Config{}, fmt.Errorf("i18n: fallback locale must be enabled")
|
||||
}
|
||||
if _, ok := fallbacks[l]; ok {
|
||||
return Config{}, fmt.Errorf("i18n: duplicate normalized fallback locale")
|
||||
}
|
||||
if len(c.Fallbacks[raw]) > 64 {
|
||||
return Config{}, fmt.Errorf("i18n: too many fallback locales")
|
||||
}
|
||||
list := []string{}
|
||||
unique := map[string]bool{}
|
||||
for _, v := range c.Fallbacks[raw] {
|
||||
to, e := canonical(v)
|
||||
if e != nil || !seen[to] || unique[to] {
|
||||
return Config{}, fmt.Errorf("i18n: fallback targets must be distinct enabled locales")
|
||||
}
|
||||
unique[to] = true
|
||||
list = append(list, to)
|
||||
}
|
||||
fallbacks[l] = list
|
||||
}
|
||||
state := map[string]uint8{}
|
||||
var visit func(string) error
|
||||
visit = func(l string) error {
|
||||
if state[l] == 1 {
|
||||
return fmt.Errorf("i18n: fallback cycle")
|
||||
}
|
||||
if state[l] == 2 {
|
||||
return nil
|
||||
}
|
||||
state[l] = 1
|
||||
for _, to := range fallbacks[l] {
|
||||
if err := visit(to); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
state[l] = 2
|
||||
return nil
|
||||
}
|
||||
for _, l := range locales {
|
||||
if err := visit(l); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
c.Fallbacks = fallbacks
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c Config) Validate() error { _, err := normalizeConfig(c); return err }
|
||||
|
||||
// New accepts split documents; duplicate (locale, namespace, key) definitions
|
||||
// are rejected. Use Overlay explicitly when replacement is intended.
|
||||
func New(config Config, documents ...Document) (*Bundle, error) {
|
||||
c, err := normalizeConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := &Bundle{config: c, tags: map[string]language.Tag{}, entries: map[Ref]*entry{}}
|
||||
b.locales = append([]string{c.DefaultLocale}, c.Locales...)
|
||||
b.locales = unique(b.locales)
|
||||
tags := make([]language.Tag, 0, len(b.locales))
|
||||
for _, l := range b.locales {
|
||||
t := language.MustParse(l)
|
||||
tags = append(tags, t)
|
||||
b.tags[l] = t
|
||||
}
|
||||
b.matcher = language.NewMatcher(tags)
|
||||
if len(documents) > 1024 {
|
||||
return nil, fmt.Errorf("i18n: too many resource documents")
|
||||
}
|
||||
groups := map[string]map[string]map[string]Message{}
|
||||
total := 0
|
||||
names := map[string]bool{}
|
||||
languages := map[string]bool{}
|
||||
for _, doc := range documents {
|
||||
l, e := canonical(doc.Locale)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if doc.Version != Version || !validID(doc.Namespace) {
|
||||
return nil, fmt.Errorf("i18n: version 1 and a valid namespace are required")
|
||||
}
|
||||
names[doc.Namespace] = true
|
||||
languages[l] = true
|
||||
if len(names) > 128 || len(languages) > 64 {
|
||||
return nil, fmt.Errorf("i18n: namespace or locale resource limit exceeded")
|
||||
}
|
||||
if groups[l] == nil {
|
||||
groups[l] = map[string]map[string]Message{}
|
||||
}
|
||||
if groups[l][doc.Namespace] == nil {
|
||||
groups[l][doc.Namespace] = map[string]Message{}
|
||||
}
|
||||
for _, k := range sortedKeys(doc.Messages) {
|
||||
if !validID(k) {
|
||||
return nil, fmt.Errorf("i18n: invalid message key")
|
||||
}
|
||||
if _, ok := groups[l][doc.Namespace][k]; ok {
|
||||
return nil, &Diagnostic{Kind: "duplicate", Namespace: doc.Namespace, Key: k, Locale: l, Problem: "use an explicit overlay to replace a message"}
|
||||
}
|
||||
total++
|
||||
if total > 10000 {
|
||||
return nil, fmt.Errorf("i18n: resource exceeds 10000 messages")
|
||||
}
|
||||
groups[l][doc.Namespace][k] = doc.Messages[k]
|
||||
}
|
||||
}
|
||||
for _, ns := range sortedKeys(groups[c.SourceLocale]) {
|
||||
for _, key := range sortedKeys(groups[c.SourceLocale][ns]) {
|
||||
m := groups[c.SourceLocale][ns][key]
|
||||
if len(m.Params) > 64 {
|
||||
return nil, fmt.Errorf("i18n: message exceeds 64 parameters")
|
||||
}
|
||||
schema := map[string]Kind{}
|
||||
for name, kind := range m.Params {
|
||||
if !paramName.MatchString(name) || len(name) > 128 {
|
||||
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Param: name, Problem: "invalid parameter name"}
|
||||
}
|
||||
switch kind {
|
||||
case String, Boolean, Integer, Number:
|
||||
default:
|
||||
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Param: name, Problem: "unsupported type"}
|
||||
}
|
||||
schema[name] = kind
|
||||
}
|
||||
b.entries[Ref{ns, key}] = &entry{schema: schema, patterns: map[string]*compiled{}}
|
||||
}
|
||||
}
|
||||
limits := &budget{}
|
||||
for _, l := range sortedKeys(groups) {
|
||||
for _, ns := range sortedKeys(groups[l]) {
|
||||
doc := Document{Version: Version, Locale: l, Namespace: ns, Messages: map[string]Message{}}
|
||||
for _, key := range sortedKeys(groups[l][ns]) {
|
||||
m := groups[l][ns][key]
|
||||
e := b.entries[Ref{ns, key}]
|
||||
if e == nil {
|
||||
return nil, &Diagnostic{Kind: "unknown", Namespace: ns, Key: key, Locale: l, Problem: "message has no source definition"}
|
||||
}
|
||||
if m.Params != nil && !sameSchema(m.Params, e.schema) {
|
||||
return nil, &Diagnostic{Kind: "parameter", Namespace: ns, Key: key, Locale: l, Problem: "translation parameter schema differs from source"}
|
||||
}
|
||||
used := map[string]bool{}
|
||||
p, err := compile(m.Value, e.schema, used, limits, 0)
|
||||
if err == nil {
|
||||
for _, name := range sortedKeys(e.schema) {
|
||||
if !used[name] {
|
||||
err = &Diagnostic{Kind: "parameter", Param: name, Problem: "declared parameter is unused in this message"}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
d := *err.(*Diagnostic)
|
||||
d.Namespace, d.Key, d.Locale = ns, key, l
|
||||
return nil, &d
|
||||
}
|
||||
e.patterns[l] = p
|
||||
doc.Messages[key] = Message{Params: cloneSchema(e.schema), Value: clonePattern(m.Value)}
|
||||
}
|
||||
b.documents = append(b.documents, doc)
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func unique(in []string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func sameSchema(a, b map[string]Kind) bool {
|
||||
return len(a) == len(b) && reflect.DeepEqual(cloneSchema(a), cloneSchema(b))
|
||||
}
|
||||
func cloneSchema(m map[string]Kind) map[string]Kind {
|
||||
out := map[string]Kind{}
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
func clonePattern(p Pattern) Pattern {
|
||||
if p.Cases != nil {
|
||||
m := make(map[string]Pattern, len(p.Cases))
|
||||
for k, v := range p.Cases {
|
||||
m[k] = clonePattern(v)
|
||||
}
|
||||
p.Cases = m
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (b *Bundle) Config() Config {
|
||||
if b == nil {
|
||||
return Config{}
|
||||
}
|
||||
c := b.config
|
||||
c.Locales = append([]string(nil), c.Locales...)
|
||||
c.Fallbacks = map[string][]string{}
|
||||
for k, v := range b.config.Fallbacks {
|
||||
c.Fallbacks[k] = append([]string{}, v...)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Match accepts ordered language tags, not an HTTP header. It returns an enabled
|
||||
// dictionary tag (not a synthetic matcher tag with user extensions).
|
||||
func (b *Bundle) Match(preferences ...string) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
tags := make([]language.Tag, 0, len(preferences))
|
||||
for _, s := range preferences {
|
||||
if len(tags) >= 64 {
|
||||
break
|
||||
}
|
||||
if len(s) > 128 {
|
||||
continue
|
||||
}
|
||||
if t, e := language.Parse(s); s != "" && e == nil {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
_, index, _ := b.matcher.Match(tags...)
|
||||
return b.locales[index]
|
||||
}
|
||||
|
||||
func (b *Bundle) chain(locale string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
var add func(string)
|
||||
add = func(l string) {
|
||||
if seen[l] {
|
||||
return
|
||||
}
|
||||
seen[l] = true
|
||||
out = append(out, l)
|
||||
for _, to := range b.config.Fallbacks[l] {
|
||||
add(to)
|
||||
}
|
||||
}
|
||||
add(locale)
|
||||
add(b.config.DefaultLocale)
|
||||
add(b.config.SourceLocale)
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *Bundle) Render(locale string, ref Ref, params map[string]any) (Result, error) {
|
||||
if b == nil {
|
||||
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Problem: "no bundle"}
|
||||
}
|
||||
requested := b.Match(locale)
|
||||
e := b.entries[ref]
|
||||
if e == nil {
|
||||
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Locale: requested, Problem: "unknown message"}
|
||||
}
|
||||
values := make(map[string]string, len(e.schema))
|
||||
for _, k := range sortedKeys(e.schema) {
|
||||
v, ok := params[k]
|
||||
if !ok {
|
||||
return Result{}, &Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Param: k, Problem: "missing argument"}
|
||||
}
|
||||
s, err := scalar(e.schema[k], v)
|
||||
if err != nil {
|
||||
d := *err.(*Diagnostic)
|
||||
d.Namespace, d.Key, d.Param = ref.Namespace, ref.Key, k
|
||||
return Result{}, &d
|
||||
}
|
||||
values[k] = s
|
||||
}
|
||||
for _, k := range sortedKeys(params) {
|
||||
if _, ok := e.schema[k]; !ok {
|
||||
return Result{}, &Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Param: k, Problem: "undeclared argument"}
|
||||
}
|
||||
}
|
||||
for _, l := range b.chain(requested) {
|
||||
if p := e.patterns[l]; p != nil {
|
||||
text, err := p.render(language.MustParse(l), values)
|
||||
return Result{Text: text, Requested: requested, Locale: l, Fallback: l != requested}, err
|
||||
}
|
||||
}
|
||||
return Result{}, &Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Locale: requested, Problem: "no message in fallback chain"}
|
||||
}
|
||||
func (b *Bundle) RenderContext(ctx context.Context, ref Ref, params map[string]any) (Result, error) {
|
||||
return b.Render(Locale(ctx), ref, params)
|
||||
}
|
||||
|
||||
// Schema returns a detached source schema for adapters and static tools.
|
||||
func (b *Bundle) Schema(ref Ref) (map[string]Kind, bool) {
|
||||
if b == nil {
|
||||
return nil, false
|
||||
}
|
||||
e := b.entries[ref]
|
||||
if e == nil {
|
||||
return nil, false
|
||||
}
|
||||
return cloneSchema(e.schema), true
|
||||
}
|
||||
|
||||
// Check reports missing direct translations. Runtime fallback remains usable;
|
||||
// build tooling can reject these diagnostics without changing runtime behavior.
|
||||
func (b *Bundle) Check() []Diagnostic {
|
||||
out := []Diagnostic{}
|
||||
if b == nil {
|
||||
return out
|
||||
}
|
||||
for _, d := range b.documents {
|
||||
if d.Locale != b.config.SourceLocale {
|
||||
continue
|
||||
}
|
||||
for _, key := range sortedKeys(d.Messages) {
|
||||
e := b.entries[Ref{d.Namespace, key}]
|
||||
for _, l := range b.config.Locales {
|
||||
if e.patterns[l] == nil {
|
||||
out = append(out, Diagnostic{Kind: "missing", Namespace: d.Namespace, Key: key, Locale: l, Problem: "direct translation missing"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Documents returns detached resources. An empty namespace filter includes all;
|
||||
// exporting for a browser should normally supply explicit namespaces.
|
||||
func (b *Bundle) Documents(locale string, namespaces ...string) []Document {
|
||||
out := []Document{}
|
||||
if b == nil {
|
||||
return out
|
||||
}
|
||||
wanted := map[string]bool{}
|
||||
for _, ns := range namespaces {
|
||||
wanted[ns] = true
|
||||
}
|
||||
for _, d := range b.documents {
|
||||
if locale != "" && d.Locale != locale || len(wanted) > 0 && !wanted[d.Namespace] {
|
||||
continue
|
||||
}
|
||||
copy := d
|
||||
copy.Messages = map[string]Message{}
|
||||
for k, m := range d.Messages {
|
||||
copy.Messages[k] = Message{Params: cloneSchema(m.Params), Value: clonePattern(m.Value)}
|
||||
}
|
||||
out = append(out, copy)
|
||||
}
|
||||
return out
|
||||
}
|
||||
353
i18n/bundle_test.go
Normal file
353
i18n/bundle_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package i18n_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
)
|
||||
|
||||
func settings() i18n.Config {
|
||||
return i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans", "ru", "ar", "fr"}, Fallbacks: map[string][]string{"zh-Hans": {"en"}}}
|
||||
}
|
||||
func docs() []i18n.Document {
|
||||
plural := i18n.Pattern{Plural: "count", Cases: map[string]i18n.Pattern{}}
|
||||
for _, category := range []string{"zero", "one", "two", "few", "many", "other"} {
|
||||
plural.Cases[category] = i18n.Pattern{Text: category + ":{count}"}
|
||||
}
|
||||
out := []i18n.Document{}
|
||||
for _, locale := range settings().Locales {
|
||||
out = append(out, i18n.Document{Version: 1, Namespace: "common", Locale: locale, Messages: map[string]i18n.Message{
|
||||
"count": {Params: map[string]i18n.Kind{"count": i18n.Integer}, Value: plural},
|
||||
"decimal": {Params: map[string]i18n.Kind{"count": i18n.Number}, Value: plural},
|
||||
"choice": {Params: map[string]i18n.Kind{"type": i18n.String, "count": i18n.Integer}, Value: i18n.Pattern{Select: "type", Cases: map[string]i18n.Pattern{"admin": {Text: "admin:{count}"}, "other": plural}}},
|
||||
"literal": {Value: i18n.Pattern{Text: "${HOME} {notAParameter}", Literal: true}},
|
||||
"empty": {Value: i18n.Pattern{}},
|
||||
}})
|
||||
}
|
||||
out[0].Messages["greeting"] = i18n.Message{Params: map[string]i18n.Kind{"first_name": i18n.String}, Value: i18n.Pattern{Text: "Hello {first_name} {{literal}}"}}
|
||||
return out
|
||||
}
|
||||
func makeBundle(t testing.TB) *i18n.Bundle {
|
||||
t.Helper()
|
||||
b, err := i18n.New(settings(), docs()...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestIndependentImmutableBundlesAndFallback(t *testing.T) {
|
||||
resources := docs()
|
||||
cfg := settings()
|
||||
b, err := i18n.New(cfg, resources...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resources[0].Messages["greeting"] = i18n.Message{Value: i18n.Pattern{Text: "changed"}}
|
||||
cfg.Locales[0] = "de"
|
||||
cfg.Fallbacks["zh-Hans"][0] = "de"
|
||||
snapshot := b.Config()
|
||||
snapshot.Fallbacks["zh-Hans"][0] = "fr"
|
||||
exported := b.Documents("")
|
||||
exported[0].Messages["count"] = i18n.Message{}
|
||||
result, err := b.RenderContext(i18n.WithLocale(context.Background(), "zh-CN"), i18n.Ref{Namespace: "common", Key: "greeting"}, map[string]any{"first_name": "{count}"})
|
||||
if err != nil || result.Text != "Hello {count} {literal}" || result.Locale != "en" || !result.Fallback || result.Requested != "zh-Hans" {
|
||||
t.Fatal(result, err)
|
||||
}
|
||||
if len(b.Check()) != 4 {
|
||||
t.Fatal(b.Check())
|
||||
}
|
||||
if got := b.Match("ru-RU"); got != "ru" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
for _, key := range []string{"literal", "empty"} {
|
||||
got, err := b.Render("en", i18n.Ref{Namespace: "common", Key: key}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key == "literal" && got.Text != "${HOME} {notAParameter}" || key == "empty" && got.Text != "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for n := 0; n < 80; n++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
l, want := "en", "other:22"
|
||||
if n%2 == 0 {
|
||||
l, want = "ru", "few:22"
|
||||
}
|
||||
got, err := b.Render(l, i18n.Ref{Namespace: "common", Key: "count"}, map[string]any{"count": int64(22)})
|
||||
if err != nil || got.Text != want {
|
||||
t.Errorf("%+v %v", got, err)
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
wg.Wait()
|
||||
other := docs()
|
||||
other[0].Messages["greeting"] = i18n.Message{Value: i18n.Pattern{Text: "different application"}}
|
||||
b2, err := i18n.New(settings(), other...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := b2.Render("en", i18n.Ref{Namespace: "common", Key: "greeting"}, nil)
|
||||
if got.Text != "different application" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceAndParameterDiagnostics(t *testing.T) {
|
||||
b := makeBundle(t)
|
||||
for _, params := range []map[string]any{nil, {"count": "21"}, {"count": 21.0}, {"count": 21, "extra": "secret"}, {"count": uint64(math.MaxUint64)}, {"count": []string{"secret"}}} {
|
||||
_, err := b.Render("en", i18n.Ref{Namespace: "common", Key: "count"}, params)
|
||||
var d *i18n.Diagnostic
|
||||
if !errors.As(err, &d) || d.Kind != "parameter" || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, err := b.Render("en", i18n.Ref{Namespace: "common", Key: "missing"}, nil)
|
||||
var d *i18n.Diagnostic
|
||||
if !errors.As(err, &d) || d.Kind != "missing" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = b.Render("en", i18n.Ref{Namespace: "common", Key: "decimal"}, map[string]any{"count": 1e-16})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, change := range []func([]i18n.Document){
|
||||
func(d []i18n.Document) { d[1].Messages["unknown"] = i18n.Message{} },
|
||||
func(d []i18n.Document) {
|
||||
m := d[1].Messages["count"]
|
||||
m.Params = map[string]i18n.Kind{"count": i18n.String}
|
||||
d[1].Messages["count"] = m
|
||||
},
|
||||
func(d []i18n.Document) {
|
||||
m := d[1].Messages["count"]
|
||||
m.Value = i18n.Pattern{Text: "{missing}"}
|
||||
d[1].Messages["count"] = m
|
||||
},
|
||||
func(d []i18n.Document) {
|
||||
m := d[1].Messages["count"]
|
||||
m.Value = i18n.Pattern{Text: "unused"}
|
||||
d[1].Messages["count"] = m
|
||||
},
|
||||
func(d []i18n.Document) {
|
||||
m := d[1].Messages["count"]
|
||||
delete(m.Value.Cases, "other")
|
||||
d[1].Messages["count"] = m
|
||||
},
|
||||
func(d []i18n.Document) { d[1].Version = 2 },
|
||||
} {
|
||||
d := docs()
|
||||
change(d)
|
||||
if _, err := i18n.New(settings(), d...); err == nil {
|
||||
t.Fatal("invalid document accepted")
|
||||
}
|
||||
}
|
||||
for _, change := range []func(*i18n.Config){func(c *i18n.Config) { c.Fallbacks["en"] = []string{"zh-Hans"} }, func(c *i18n.Config) { c.DefaultLocale = "de" }, func(c *i18n.Config) { c.Locales = append(c.Locales, "EN") }} {
|
||||
c := settings()
|
||||
change(&c)
|
||||
if _, err := i18n.New(c, docs()...); err == nil {
|
||||
t.Fatal("invalid config accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceLocaleCanBeFallbackOnly(t *testing.T) {
|
||||
// These tags also exercise browser portability: Intl can alias cmn to zh,
|
||||
// and rejects private-use tags despite Go's BCP 47 matcher accepting them.
|
||||
for _, locale := range []string{"fr", "cmn", "x-example"} {
|
||||
t.Run(locale, func(t *testing.T) {
|
||||
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: locale, Locales: []string{locale}},
|
||||
i18n.Document{Version: 1, Namespace: "common", Locale: "en", Messages: map[string]i18n.Message{"fallback": {Value: i18n.Pattern{Text: "Source"}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := b.Render(locale, i18n.Ref{Namespace: "common", Key: "fallback"}, nil)
|
||||
if err != nil || got.Text != "Source" || !got.Fallback || got.Locale != "en" || got.Requested != locale || len(b.Check()) != 1 {
|
||||
t.Fatal(got, err, b.Check())
|
||||
}
|
||||
var pack bytes.Buffer
|
||||
if err := b.Export(&pack, locale, "common"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(pack.Bytes(), []byte("UNICODE LICENSE")) {
|
||||
t.Fatal("exported rule data must carry its license")
|
||||
}
|
||||
if node, err := exec.LookPath("node"); err == nil {
|
||||
input, _ := json.Marshal(struct {
|
||||
Pack json.RawMessage `json:"pack"`
|
||||
Cases []any `json:"cases"`
|
||||
}{pack.Bytes(), []any{map[string]any{"key": "fallback", "params": map[string]any{}, "want": "Source"}}})
|
||||
cmd := exec.Command(node, "browser/conformance.mjs")
|
||||
cmd.Stdin = bytes.NewReader(input)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%s: %v", out, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictLoadingAndExplicitOverlay(t *testing.T) {
|
||||
raw := `{"version":1,"locale":"en","namespace":"common","messages":{"x":{"value":{"literal":true,"text":"${HOME}"}}}}`
|
||||
fs := fstest.MapFS{"en.json": {Data: []byte(raw)}}
|
||||
loaded, err := i18n.LoadFS(fs, "en.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded[0].Messages["x"].Value.Text != "${HOME}" {
|
||||
t.Fatal("environment interpolation")
|
||||
}
|
||||
for _, bad := range []string{strings.Replace(raw, `"version":1`, `"version":1,"version":1`, 1), strings.Replace(raw, `"version":1`, `"unknown":1`, 1), strings.Replace(raw, `{"value":{"literal":true,"text":"${HOME}"}}`, `{}`, 1), strings.Replace(raw, `"literal":true`, `"literal":true,"unknown":true`, 1), raw + ` {}`, strings.Repeat("[", 34) + "0" + strings.Repeat("]", 34)} {
|
||||
var d i18n.Document
|
||||
if err := i18n.DecodeJSON([]byte(bad), &d); err == nil {
|
||||
t.Fatal("invalid JSON accepted", bad)
|
||||
}
|
||||
}
|
||||
c := i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en"}}
|
||||
if _, err := i18n.New(c, loaded[0], loaded[0]); err == nil {
|
||||
t.Fatal("duplicate accepted")
|
||||
}
|
||||
override := i18n.Document{Version: 1, Locale: "EN", Namespace: "common", Messages: map[string]i18n.Message{"x": {Value: i18n.Pattern{Text: "replacement"}}}}
|
||||
merged, err := i18n.Overlay(loaded[0], override)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := i18n.New(c, merged)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := b.Render("en", i18n.Ref{Namespace: "common", Key: "x"}, nil)
|
||||
if got.Text != "replacement" || loaded[0].Messages["x"].Value.Text != "${HOME}" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserConformance(t *testing.T) {
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("Node.js is required for cross-runtime conformance")
|
||||
}
|
||||
b := makeBundle(t)
|
||||
type sample struct {
|
||||
Key string `json:"key"`
|
||||
Params map[string]any `json:"params"`
|
||||
Want string `json:"want"`
|
||||
}
|
||||
cases := map[string][]sample{
|
||||
"en": {{"count", map[string]any{"count": 1}, "one:1"}, {"count", map[string]any{"count": 2}, "other:2"}, {"count", map[string]any{"count": "9223372036854775807"}, "other:9223372036854775807"}, {"decimal", map[string]any{"count": 1.5}, "other:1.5"}, {"decimal", map[string]any{"count": 1e-16}, "other:1e-16"}, {"choice", map[string]any{"type": "admin", "count": 2}, "admin:2"}},
|
||||
"ru": {{"count", map[string]any{"count": 21}, "one:21"}, {"count", map[string]any{"count": 22}, "few:22"}, {"count", map[string]any{"count": 11}, "many:11"}, {"count", map[string]any{"count": 10000021}, "one:10000021"}, {"decimal", map[string]any{"count": 1.25}, "other:1.25"}},
|
||||
"ar": {{"count", map[string]any{"count": 0}, "zero:0"}, {"count", map[string]any{"count": 2}, "two:2"}, {"count", map[string]any{"count": 3}, "few:3"}, {"count", map[string]any{"count": 11}, "many:11"}, {"count", map[string]any{"count": 100}, "other:100"}},
|
||||
"zh-Hans": {{"count", map[string]any{"count": 1}, "other:1"}, {"greeting", map[string]any{"first_name": "{count}"}, "Hello {count} {literal}"}},
|
||||
"fr": {{"count", map[string]any{"count": 0}, "one:0"}, {"count", map[string]any{"count": 2}, "other:2"}, {"count", map[string]any{"count": 1000000}, "many:1000000"}},
|
||||
}
|
||||
for locale, samples := range cases {
|
||||
for _, s := range samples {
|
||||
params := map[string]any{}
|
||||
for k, v := range s.Params {
|
||||
params[k] = v
|
||||
}
|
||||
if v, ok := params["count"].(string); ok {
|
||||
params["count"] = json.Number(v)
|
||||
}
|
||||
got, err := b.Render(locale, i18n.Ref{Namespace: "common", Key: s.Key}, params)
|
||||
if err != nil || got.Text != s.Want {
|
||||
t.Fatalf("Go %s %s: %+v %v", locale, s.Key, got, err)
|
||||
}
|
||||
}
|
||||
var pack bytes.Buffer
|
||||
if err := b.Export(&pack, locale, "common"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input, _ := json.Marshal(struct {
|
||||
Pack json.RawMessage `json:"pack"`
|
||||
Cases []sample `json:"cases"`
|
||||
}{pack.Bytes(), samples})
|
||||
cmd := exec.Command(node, "browser/conformance.mjs")
|
||||
cmd.Stdin = bytes.NewReader(input)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("browser %s: %s %v", locale, output, err)
|
||||
}
|
||||
}
|
||||
var ts bytes.Buffer
|
||||
if err := b.TypeScript(&ts, "common"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(ts.String(), "bigint") || !strings.Contains(ts.String(), `"first_name": string`) {
|
||||
t.Fatal(ts.String())
|
||||
}
|
||||
if err := b.Export(&bytes.Buffer{}, "en"); err == nil {
|
||||
t.Fatal("export requires explicit namespaces")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeScriptConsumer(t *testing.T) {
|
||||
tsc, err := exec.LookPath("tsc")
|
||||
if err != nil {
|
||||
t.Skip("TypeScript compiler is required for generated type conformance")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
var types bytes.Buffer
|
||||
if err := makeBundle(t).TypeScript(&types, "common"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "messages.generated.d.mts"), types.Bytes(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for target, source := range map[string]string{"runtime.d.mts": "browser/runtime.d.mts", "consumer.mts": "browser/testdata/consumer.mts"} {
|
||||
data, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, target), data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cmd := exec.Command(tsc, "--noEmit", "--strict", "--target", "ES2022", "--module", "NodeNext", "--moduleResolution", "NodeNext", "consumer.mts")
|
||||
cmd.Dir = dir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("TypeScript consumer: %s %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzDecodeJSON(f *testing.F) {
|
||||
f.Add([]byte(`{"version":1,"locale":"en","namespace":"x","messages":{"x":{"value":{"text":"hello"}}}}`))
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
var d i18n.Document
|
||||
if i18n.DecodeJSON(data, &d) == nil {
|
||||
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en"}}, d)
|
||||
if err == nil {
|
||||
for _, doc := range b.Documents("") {
|
||||
if !reflect.DeepEqual(doc, b.Documents(doc.Locale)[0]) {
|
||||
t.Fatal("unstable resource")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkRender(b *testing.B) {
|
||||
catalog := makeBundle(b)
|
||||
params := map[string]any{"count": 22}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := catalog.Render("ru", i18n.Ref{Namespace: "common", Key: "count"}, params); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
i18n/cldr/LICENSE
Normal file
41
i18n/cldr/LICENSE
Normal file
@@ -0,0 +1,41 @@
|
||||
UNICODE LICENSE V3
|
||||
|
||||
COPYRIGHT AND PERMISSION NOTICE
|
||||
|
||||
Copyright © 2015-2024 Unicode, Inc.
|
||||
|
||||
NOTICE TO USER: Carefully read the following legal agreement. BY
|
||||
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
|
||||
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
|
||||
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
|
||||
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of data files and any associated documentation (the "Data Files") or
|
||||
software and any associated documentation (the "Software") to deal in the
|
||||
Data Files or Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, and/or sell
|
||||
copies of the Data Files or Software, and to permit persons to whom the
|
||||
Data Files or Software are furnished to do so, provided that either (a)
|
||||
this copyright and permission notice appear with all copies of the Data
|
||||
Files or Software, or (b) this copyright and permission notice appear in
|
||||
associated Documentation.
|
||||
|
||||
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
||||
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
THIRD PARTY RIGHTS.
|
||||
|
||||
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
|
||||
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
|
||||
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
|
||||
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
||||
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
|
||||
FILES OR SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the name of a copyright holder shall
|
||||
not be used in advertising or otherwise to promote the sale, use or other
|
||||
dealings in these Data Files or Software without prior written
|
||||
authorization of the copyright holder.
|
||||
|
||||
SPDX-License-Identifier: Unicode-3.0
|
||||
19
i18n/cldr/README.md
Normal file
19
i18n/cldr/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Pinned cardinal plural data
|
||||
|
||||
`plurals.json` is copied without modification from Unicode CLDR JSON **46.1.0**:
|
||||
|
||||
https://github.com/unicode-org/cldr-json/blob/46.1.0/cldr-json/cldr-core/supplemental/plurals.json
|
||||
|
||||
The upstream data identifies CLDR version 46 / Unicode 16.0.0. See the accompanying
|
||||
Unicode license. No data is downloaded at application startup or during generation.
|
||||
|
||||
Both Go and the browser evaluate these same rules. Browser packs include only the
|
||||
rules for the exported language and its fallback chain, together with the Unicode
|
||||
license. They do not depend on the
|
||||
browser's built-in `Intl.PluralRules` data version. Language-tag matching is a
|
||||
separate responsibility provided by `golang.org/x/text/language`.
|
||||
|
||||
When updating this data, update `PluralRulesVersion`, preserve the license, and run
|
||||
the upstream-sample and Go/browser conformance tests. This version supports cardinal
|
||||
rules for ordinary numbers; compact-number operands `c`/`e` are zero. Ordinal and
|
||||
compact-number formatting are not part of the v1 message format.
|
||||
933
i18n/cldr/plurals.json
Normal file
933
i18n/cldr/plurals.json
Normal file
@@ -0,0 +1,933 @@
|
||||
{
|
||||
"supplemental": {
|
||||
"version": {
|
||||
"_unicodeVersion": "16.0.0",
|
||||
"_cldrVersion": "46"
|
||||
},
|
||||
"plurals-type-cardinal": {
|
||||
"af": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ak": {
|
||||
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"am": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"an": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ar": {
|
||||
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-few": "n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …",
|
||||
"pluralRule-count-many": "n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …",
|
||||
"pluralRule-count-other": " @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ars": {
|
||||
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-few": "n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …",
|
||||
"pluralRule-count-many": "n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …",
|
||||
"pluralRule-count-other": " @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"as": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"asa": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ast": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"az": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"bal": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"be": {
|
||||
"pluralRule-count-one": "n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, …",
|
||||
"pluralRule-count-few": "n % 10 = 2..4 and n % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 2.0, 3.0, 4.0, 22.0, 23.0, 24.0, 32.0, 33.0, 102.0, 1002.0, …",
|
||||
"pluralRule-count-many": "n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-other": " @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, …"
|
||||
},
|
||||
"bem": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"bez": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"bg": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"bho": {
|
||||
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"blo": {
|
||||
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"bm": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"bn": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"bo": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"br": {
|
||||
"pluralRule-count-one": "n % 10 = 1 and n % 100 != 11,71,91 @integer 1, 21, 31, 41, 51, 61, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 81.0, 101.0, 1001.0, …",
|
||||
"pluralRule-count-two": "n % 10 = 2 and n % 100 != 12,72,92 @integer 2, 22, 32, 42, 52, 62, 82, 102, 1002, … @decimal 2.0, 22.0, 32.0, 42.0, 52.0, 62.0, 82.0, 102.0, 1002.0, …",
|
||||
"pluralRule-count-few": "n % 10 = 3..4,9 and n % 100 != 10..19,70..79,90..99 @integer 3, 4, 9, 23, 24, 29, 33, 34, 39, 43, 44, 49, 103, 1003, … @decimal 3.0, 4.0, 9.0, 23.0, 24.0, 29.0, 33.0, 34.0, 103.0, 1003.0, …",
|
||||
"pluralRule-count-many": "n != 0 and n % 1000000 = 0 @integer 1000000, … @decimal 1000000.0, 1000000.00, 1000000.000, 1000000.0000, …",
|
||||
"pluralRule-count-other": " @integer 0, 5~8, 10~20, 100, 1000, 10000, 100000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, …"
|
||||
},
|
||||
"brx": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"bs": {
|
||||
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …",
|
||||
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ca": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
|
||||
},
|
||||
"ce": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ceb": {
|
||||
"pluralRule-count-one": "v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-other": " @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, …"
|
||||
},
|
||||
"cgg": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"chr": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ckb": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"cs": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-few": "i = 2..4 and v = 0 @integer 2~4",
|
||||
"pluralRule-count-many": "v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …"
|
||||
},
|
||||
"csw": {
|
||||
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"cy": {
|
||||
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-few": "n = 3 @integer 3 @decimal 3.0, 3.00, 3.000, 3.0000",
|
||||
"pluralRule-count-many": "n = 6 @integer 6 @decimal 6.0, 6.00, 6.000, 6.0000",
|
||||
"pluralRule-count-other": " @integer 4, 5, 7~20, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"da": {
|
||||
"pluralRule-count-one": "n = 1 or t != 0 and i = 0,1 @integer 1 @decimal 0.1~1.6",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 2.0~3.4, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"de": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"doi": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"dsb": {
|
||||
"pluralRule-count-one": "v = 0 and i % 100 = 1 or f % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-two": "v = 0 and i % 100 = 2 or f % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … @decimal 0.2, 1.2, 2.2, 3.2, 4.2, 5.2, 6.2, 7.2, 10.2, 100.2, 1000.2, …",
|
||||
"pluralRule-count-few": "v = 0 and i % 100 = 3..4 or f % 100 = 3..4 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.3, 0.4, 1.3, 1.4, 2.3, 2.4, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 7.3, 7.4, 10.3, 100.3, 1000.3, …",
|
||||
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"dv": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"dz": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ee": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"el": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"en": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"eo": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"es": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
|
||||
},
|
||||
"et": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"eu": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"fa": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ff": {
|
||||
"pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"fi": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"fil": {
|
||||
"pluralRule-count-one": "v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-other": " @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, …"
|
||||
},
|
||||
"fo": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"fr": {
|
||||
"pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5",
|
||||
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
|
||||
},
|
||||
"fur": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"fy": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ga": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-few": "n = 3..6 @integer 3~6 @decimal 3.0, 4.0, 5.0, 6.0, 3.00, 4.00, 5.00, 6.00, 3.000, 4.000, 5.000, 6.000, 3.0000, 4.0000, 5.0000, 6.0000",
|
||||
"pluralRule-count-many": "n = 7..10 @integer 7~10 @decimal 7.0, 8.0, 9.0, 10.0, 7.00, 8.00, 9.00, 10.00, 7.000, 8.000, 9.000, 10.000, 7.0000, 8.0000, 9.0000, 10.0000",
|
||||
"pluralRule-count-other": " @integer 0, 11~25, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"gd": {
|
||||
"pluralRule-count-one": "n = 1,11 @integer 1, 11 @decimal 1.0, 11.0, 1.00, 11.00, 1.000, 11.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2,12 @integer 2, 12 @decimal 2.0, 12.0, 2.00, 12.00, 2.000, 12.000, 2.0000",
|
||||
"pluralRule-count-few": "n = 3..10,13..19 @integer 3~10, 13~19 @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 3.00",
|
||||
"pluralRule-count-other": " @integer 0, 20~34, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"gl": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"gsw": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"gu": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"guw": {
|
||||
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"gv": {
|
||||
"pluralRule-count-one": "v = 0 and i % 10 = 1 @integer 1, 11, 21, 31, 41, 51, 61, 71, 101, 1001, …",
|
||||
"pluralRule-count-two": "v = 0 and i % 10 = 2 @integer 2, 12, 22, 32, 42, 52, 62, 72, 102, 1002, …",
|
||||
"pluralRule-count-few": "v = 0 and i % 100 = 0,20,40,60,80 @integer 0, 20, 40, 60, 80, 100, 120, 140, 1000, 10000, 100000, 1000000, …",
|
||||
"pluralRule-count-many": "v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-other": " @integer 3~10, 13~19, 23, 103, 1003, …"
|
||||
},
|
||||
"ha": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"haw": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"he": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 or i = 0 and v != 0 @integer 1 @decimal 0.0~0.9, 0.00~0.05",
|
||||
"pluralRule-count-two": "i = 2 and v = 0 @integer 2",
|
||||
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.0~2.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"hi": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"hnj": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"hr": {
|
||||
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …",
|
||||
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"hsb": {
|
||||
"pluralRule-count-one": "v = 0 and i % 100 = 1 or f % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-two": "v = 0 and i % 100 = 2 or f % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … @decimal 0.2, 1.2, 2.2, 3.2, 4.2, 5.2, 6.2, 7.2, 10.2, 100.2, 1000.2, …",
|
||||
"pluralRule-count-few": "v = 0 and i % 100 = 3..4 or f % 100 = 3..4 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.3, 0.4, 1.3, 1.4, 2.3, 2.4, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 7.3, 7.4, 10.3, 100.3, 1000.3, …",
|
||||
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"hu": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"hy": {
|
||||
"pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ia": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"id": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ig": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ii": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"io": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"is": {
|
||||
"pluralRule-count-one": "t = 0 and i % 10 = 1 and i % 100 != 11 or t % 10 = 1 and t % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~0.9, 1.2~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"it": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
|
||||
},
|
||||
"iu": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ja": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"jbo": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"jgo": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"jmc": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"jv": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"jw": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ka": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"kab": {
|
||||
"pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"kaj": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"kcg": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"kde": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"kea": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"kk": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"kkj": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"kl": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"km": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"kn": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ko": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ks": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ksb": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ksh": {
|
||||
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ku": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"kw": {
|
||||
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n % 100 = 2,22,42,62,82 or n % 1000 = 0 and n % 100000 = 1000..20000,40000,60000,80000 or n != 0 and n % 1000000 = 100000 @integer 2, 22, 42, 62, 82, 102, 122, 142, 1000, 10000, 100000, … @decimal 2.0, 22.0, 42.0, 62.0, 82.0, 102.0, 122.0, 142.0, 1000.0, 10000.0, 100000.0, …",
|
||||
"pluralRule-count-few": "n % 100 = 3,23,43,63,83 @integer 3, 23, 43, 63, 83, 103, 123, 143, 1003, … @decimal 3.0, 23.0, 43.0, 63.0, 83.0, 103.0, 123.0, 143.0, 1003.0, …",
|
||||
"pluralRule-count-many": "n != 1 and n % 100 = 1,21,41,61,81 @integer 21, 41, 61, 81, 101, 121, 141, 161, 1001, … @decimal 21.0, 41.0, 61.0, 81.0, 101.0, 121.0, 141.0, 161.0, 1001.0, …",
|
||||
"pluralRule-count-other": " @integer 4~19, 100, 1004, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.1, 1000000.0, …"
|
||||
},
|
||||
"ky": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"lag": {
|
||||
"pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000",
|
||||
"pluralRule-count-one": "i = 0,1 and n != 0 @integer 1 @decimal 0.1~1.6",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"lb": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"lg": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"lij": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"lkt": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"lld": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
|
||||
},
|
||||
"ln": {
|
||||
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"lo": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"lt": {
|
||||
"pluralRule-count-one": "n % 10 = 1 and n % 100 != 11..19 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, …",
|
||||
"pluralRule-count-few": "n % 10 = 2..9 and n % 100 != 11..19 @integer 2~9, 22~29, 102, 1002, … @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 22.0, 102.0, 1002.0, …",
|
||||
"pluralRule-count-many": "f != 0 @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-other": " @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"lv": {
|
||||
"pluralRule-count-zero": "n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19 @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-one": "n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-other": " @integer 2~9, 22~29, 102, 1002, … @decimal 0.2~0.9, 1.2~1.9, 10.2, 100.2, 1000.2, …"
|
||||
},
|
||||
"mas": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"mg": {
|
||||
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"mgo": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"mk": {
|
||||
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~1.0, 1.2~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ml": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"mn": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"mo": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-few": "v != 0 or n = 0 or n != 1 and n % 100 = 1..19 @integer 0, 2~16, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-other": " @integer 20~35, 100, 1000, 10000, 100000, 1000000, …"
|
||||
},
|
||||
"mr": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ms": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"mt": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-few": "n = 0 or n % 100 = 3..10 @integer 0, 3~10, 103~109, 1003, … @decimal 0.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …",
|
||||
"pluralRule-count-many": "n % 100 = 11..19 @integer 11~19, 111~117, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …",
|
||||
"pluralRule-count-other": " @integer 20~35, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"my": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"nah": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"naq": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"nb": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"nd": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ne": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"nl": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"nn": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"nnh": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"no": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"nqo": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"nr": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"nso": {
|
||||
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ny": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"nyn": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"om": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"or": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"os": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"osa": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"pa": {
|
||||
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"pap": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"pcm": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"pl": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …",
|
||||
"pluralRule-count-many": "v = 0 and i != 1 and i % 10 = 0..1 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 12..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …",
|
||||
"pluralRule-count-other": " @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"prg": {
|
||||
"pluralRule-count-zero": "n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19 @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-one": "n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-other": " @integer 2~9, 22~29, 102, 1002, … @decimal 0.2~0.9, 1.2~1.9, 10.2, 100.2, 1000.2, …"
|
||||
},
|
||||
"ps": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"pt": {
|
||||
"pluralRule-count-one": "i = 0..1 @integer 0, 1 @decimal 0.0~1.5",
|
||||
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
|
||||
},
|
||||
"pt-PT": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
|
||||
},
|
||||
"rm": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ro": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-few": "v != 0 or n = 0 or n != 1 and n % 100 = 1..19 @integer 0, 2~16, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-other": " @integer 20~35, 100, 1000, 10000, 100000, 1000000, …"
|
||||
},
|
||||
"rof": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ru": {
|
||||
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …",
|
||||
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …",
|
||||
"pluralRule-count-many": "v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …",
|
||||
"pluralRule-count-other": " @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"rwk": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sah": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"saq": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sat": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sc": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"scn": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
|
||||
},
|
||||
"sd": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sdh": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"se": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"seh": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ses": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sg": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sh": {
|
||||
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …",
|
||||
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"shi": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-few": "n = 2..10 @integer 2~10 @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00",
|
||||
"pluralRule-count-other": " @integer 11~26, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~1.9, 2.1~2.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"si": {
|
||||
"pluralRule-count-one": "n = 0,1 or i = 0 and f = 1 @integer 0, 1 @decimal 0.0, 0.1, 1.0, 0.00, 0.01, 1.00, 0.000, 0.001, 1.000, 0.0000, 0.0001, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.2~0.9, 1.1~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sk": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-few": "i = 2..4 and v = 0 @integer 2~4",
|
||||
"pluralRule-count-many": "v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …"
|
||||
},
|
||||
"sl": {
|
||||
"pluralRule-count-one": "v = 0 and i % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, …",
|
||||
"pluralRule-count-two": "v = 0 and i % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, …",
|
||||
"pluralRule-count-few": "v = 0 and i % 100 = 3..4 or v != 0 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …"
|
||||
},
|
||||
"sma": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"smi": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"smj": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"smn": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sms": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000",
|
||||
"pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sn": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"so": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sq": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sr": {
|
||||
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …",
|
||||
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …",
|
||||
"pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ss": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ssy": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"st": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"su": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sv": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"sw": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"syr": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ta": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"te": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"teo": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"th": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ti": {
|
||||
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"tig": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"tk": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"tl": {
|
||||
"pluralRule-count-one": "v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …",
|
||||
"pluralRule-count-other": " @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, …"
|
||||
},
|
||||
"tn": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"to": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"tpi": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"tr": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ts": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"tzm": {
|
||||
"pluralRule-count-one": "n = 0..1 or n = 11..99 @integer 0, 1, 11~24 @decimal 0.0, 1.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0",
|
||||
"pluralRule-count-other": " @integer 2~10, 100~106, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ug": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"uk": {
|
||||
"pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …",
|
||||
"pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …",
|
||||
"pluralRule-count-many": "v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …",
|
||||
"pluralRule-count-other": " @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"und": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ur": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"uz": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"ve": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"vec": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-many": "e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, …",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, …"
|
||||
},
|
||||
"vi": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"vo": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"vun": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"wa": {
|
||||
"pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"wae": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"wo": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"xh": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"xog": {
|
||||
"pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"yi": {
|
||||
"pluralRule-count-one": "i = 1 and v = 0 @integer 1",
|
||||
"pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"yo": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"yue": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"zh": {
|
||||
"pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
},
|
||||
"zu": {
|
||||
"pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04",
|
||||
"pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
151
i18n/errori18n/errors.go
Normal file
151
i18n/errori18n/errors.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Package errori18n adapts public errors to independent i18n dictionaries.
|
||||
package errori18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
)
|
||||
|
||||
type Localizer struct {
|
||||
bundle *i18n.Bundle
|
||||
report func(error)
|
||||
}
|
||||
|
||||
// New borrows an immutable Bundle. report is optional, must be concurrency-safe,
|
||||
// and receives diagnostics without argument values or internal error causes.
|
||||
func New(bundle *i18n.Bundle, report func(error)) *Localizer {
|
||||
return &Localizer{bundle: bundle, report: report}
|
||||
}
|
||||
|
||||
func kind(t string) i18n.Kind {
|
||||
switch t {
|
||||
case "string":
|
||||
return i18n.String
|
||||
case "bool":
|
||||
return i18n.Boolean
|
||||
case "int", "int32", "int64":
|
||||
return i18n.Integer
|
||||
case "float64":
|
||||
return i18n.Number
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Bind returns a detached catalog, assigning references only to entries which
|
||||
// do not already have one. Different catalogs can bind to different namespaces.
|
||||
func Bind(c apierrors.Catalog, namespace string) apierrors.Catalog {
|
||||
c = c.Clone()
|
||||
for key, d := range c.Definitions {
|
||||
if d.Translation == nil {
|
||||
d.Translation = &apierrors.Translation{Namespace: namespace, Key: key}
|
||||
c.Definitions[key] = d
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Source exports only explicitly referenced public messages. Codes, statuses,
|
||||
// examples and causes are not included. Fixed legacy braces remain literal.
|
||||
func Source(c apierrors.Catalog, locale string) ([]i18n.Document, error) {
|
||||
r, err := apierrors.NewRegistry(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := map[string]i18n.Document{}
|
||||
for _, d := range r.Catalog().Definitions {
|
||||
if d.Translation == nil {
|
||||
continue
|
||||
}
|
||||
ref := d.Translation
|
||||
doc, ok := groups[ref.Namespace]
|
||||
if !ok {
|
||||
doc = i18n.Document{Version: i18n.Version, Locale: locale, Namespace: ref.Namespace, Messages: map[string]i18n.Message{}}
|
||||
}
|
||||
m := i18n.Message{Params: map[string]i18n.Kind{}, Value: i18n.Pattern{Text: d.Message, Literal: d.Params == nil}}
|
||||
for _, p := range d.Params {
|
||||
m.Params[p.Name] = kind(p.Type)
|
||||
}
|
||||
if prior, exists := doc.Messages[ref.Key]; exists && !reflect.DeepEqual(prior, m) {
|
||||
return nil, fmt.Errorf("errori18n: conflicting source messages for one translation reference")
|
||||
}
|
||||
doc.Messages[ref.Key] = m
|
||||
groups[ref.Namespace] = doc
|
||||
}
|
||||
names := []string{}
|
||||
for ns := range groups {
|
||||
names = append(names, ns)
|
||||
}
|
||||
sort.Strings(names)
|
||||
docs := []i18n.Document{}
|
||||
for _, ns := range names {
|
||||
docs = append(docs, groups[ns])
|
||||
}
|
||||
_, err = i18n.New(i18n.Config{SourceLocale: locale, DefaultLocale: locale, Locales: []string{locale}}, docs...)
|
||||
return docs, err
|
||||
}
|
||||
|
||||
// Validate can be used during startup or generation to check explicit references
|
||||
// and type compatibility. A runtime mismatch always preserves the original error.
|
||||
func (l *Localizer) Validate(c apierrors.Catalog) error {
|
||||
r, err := apierrors.NewRegistry(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range r.Catalog().Definitions {
|
||||
if d.Translation != nil {
|
||||
if err := l.check(d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (l *Localizer) check(d apierrors.Definition) error {
|
||||
ref := d.Translation
|
||||
if l == nil || l.bundle == nil {
|
||||
return &i18n.Diagnostic{Kind: "missing", Problem: "error localizer has no bundle"}
|
||||
}
|
||||
schema, ok := l.bundle.Schema(i18n.Ref{Namespace: ref.Namespace, Key: ref.Key})
|
||||
if !ok {
|
||||
return &i18n.Diagnostic{Kind: "missing", Namespace: ref.Namespace, Key: ref.Key, Problem: "error translation reference is not defined"}
|
||||
}
|
||||
expected := map[string]i18n.Kind{}
|
||||
for _, p := range d.Params {
|
||||
expected[p.Name] = kind(p.Type)
|
||||
}
|
||||
if !reflect.DeepEqual(schema, expected) {
|
||||
return &i18n.Diagnostic{Kind: "parameter", Namespace: ref.Namespace, Key: ref.Key, Problem: "error parameter schema differs from message schema"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (l *Localizer) Localize(ctx context.Context, registry *apierrors.Registry, failure *apierrors.APIError) (string, bool) {
|
||||
if l == nil || failure == nil {
|
||||
return "", false
|
||||
}
|
||||
d, ok := registry.Definition(failure.Code)
|
||||
if !ok || d.Translation == nil {
|
||||
return "", false
|
||||
}
|
||||
if err := l.check(d); err != nil {
|
||||
if l.report != nil {
|
||||
l.report(err)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
result, err := l.bundle.RenderContext(ctx, i18n.Ref{Namespace: d.Translation.Namespace, Key: d.Translation.Key}, failure.Parameters())
|
||||
if err != nil {
|
||||
if l.report != nil {
|
||||
l.report(err)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if result.Fallback && l.report != nil {
|
||||
l.report(&i18n.Diagnostic{Kind: "fallback", Namespace: d.Translation.Namespace, Key: d.Translation.Key, Locale: result.Requested, Problem: "used a fallback dictionary"})
|
||||
}
|
||||
return result.Text, true
|
||||
}
|
||||
54
i18n/errori18n/errors_test.go
Normal file
54
i18n/errori18n/errors_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package errori18n_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||
)
|
||||
|
||||
func TestCatalogAdapterIsExplicitAndDetached(t *testing.T) {
|
||||
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{"A": {Status: 409, Code: 7, Message: "{value}", Params: []apierrors.Parameter{{Name: "value", Type: "int64"}}}, "LITERAL": {Code: 8, Message: "${HOME}"}}}
|
||||
bound := errori18n.Bind(c, "errors")
|
||||
if c.Definitions["A"].Translation != nil {
|
||||
t.Fatal("mutated source")
|
||||
}
|
||||
source, err := errori18n.Source(bound, "en")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en"}}, source...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := errori18n.New(b, nil)
|
||||
if err := l.Validate(bound); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := apierrors.MustRegistry(bound)
|
||||
bound.Definitions["A"].Translation.Namespace = "changed"
|
||||
metadata, _ := r.Definition("A")
|
||||
metadata.Translation.Namespace = "changed"
|
||||
metadata.Params[0].Type = "bool"
|
||||
private := errors.New("private")
|
||||
failure := r.Resolve(apierrors.ByCode("A").WithParams(map[string]any{"value": int64(9223372036854775807)}).WithCause(private))
|
||||
message, ok := l.Localize(context.Background(), r, failure)
|
||||
if !ok || message != "9223372036854775807" || failure.Status != 409 || !errors.Is(failure, private) {
|
||||
t.Fatal(message, ok, failure)
|
||||
}
|
||||
if _, ok := l.Localize(context.Background(), apierrors.MustRegistry(c), failure); ok {
|
||||
t.Fatal("implicit translation")
|
||||
}
|
||||
missing := errori18n.New(nil, nil)
|
||||
if _, ok := missing.Localize(context.Background(), r, failure); ok {
|
||||
t.Fatal("missing bundle translated")
|
||||
}
|
||||
args := failure.Parameters()
|
||||
args["value"] = int64(0)
|
||||
if failure.Parameters()["value"] != int64(9223372036854775807) {
|
||||
t.Fatal("mutable parameters")
|
||||
}
|
||||
}
|
||||
123
i18n/export.go
Normal file
123
i18n/export.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Pack is a self-contained browser artifact for one selected language. Its
|
||||
// documents include the entire configured fallback chain and parameter schemas.
|
||||
type Pack struct {
|
||||
Version int `json:"version"`
|
||||
Locale string `json:"locale"`
|
||||
Config Config `json:"config"`
|
||||
Documents []Document `json:"documents"`
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
PluralRules map[string]map[string]string `json:"pluralRules"`
|
||||
License string `json:"license"`
|
||||
}
|
||||
|
||||
func (b *Bundle) namespaces(names []string) error {
|
||||
if b == nil {
|
||||
return fmt.Errorf("i18n: bundle required")
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("i18n: export requires explicit namespaces")
|
||||
}
|
||||
known := map[string]bool{}
|
||||
for ref := range b.entries {
|
||||
known[ref.Namespace] = true
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, ns := range names {
|
||||
if !known[ns] || seen[ns] {
|
||||
return fmt.Errorf("i18n: export namespace is unknown or duplicated: %s", ns)
|
||||
}
|
||||
seen[ns] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bundle) Export(w io.Writer, locale string, namespaces ...string) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("i18n: writer required")
|
||||
}
|
||||
if err := b.namespaces(namespaces); err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := canonical(locale)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := b.tags[l]; !ok {
|
||||
return fmt.Errorf("i18n: export locale must be enabled")
|
||||
}
|
||||
docs := []Document{}
|
||||
for _, candidate := range b.chain(l) {
|
||||
docs = append(docs, b.Documents(candidate, namespaces...)...)
|
||||
}
|
||||
// Source is always needed by clients even if absent from an explicit chain.
|
||||
rules := map[string]map[string]string{}
|
||||
for _, locale := range b.chain(l) {
|
||||
rules[locale] = rulesFor(language.MustParse(locale)).text
|
||||
}
|
||||
pack := Pack{Version: Version, Locale: l, Config: b.Config(), Documents: docs, RuleVersion: PluralRulesVersion, PluralRules: rules, License: unicodeLicense}
|
||||
var buffer bytes.Buffer
|
||||
enc := json.NewEncoder(&buffer)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(pack); err != nil {
|
||||
return err
|
||||
}
|
||||
if buffer.Len() > 4<<20 {
|
||||
return fmt.Errorf("i18n: exported pack exceeds 4 MiB; split namespaces")
|
||||
}
|
||||
_, err = io.Copy(w, &buffer)
|
||||
return err
|
||||
}
|
||||
|
||||
// TypeScript emits nested namespace/key types, so keys containing punctuation
|
||||
// never collide through concatenation or Go-specific identifier conversion.
|
||||
func (b *Bundle) TypeScript(w io.Writer, namespaces ...string) error {
|
||||
if w == nil {
|
||||
return fmt.Errorf("i18n: writer required")
|
||||
}
|
||||
if err := b.namespaces(namespaces); err != nil {
|
||||
return err
|
||||
}
|
||||
var out strings.Builder
|
||||
out.WriteString("// Generated by go-omni i18n types. Do not edit.\nexport interface MessageParams {\n")
|
||||
for _, doc := range b.Documents(b.config.SourceLocale, namespaces...) {
|
||||
fmt.Fprintf(&out, " %s: {\n", strconv.Quote(doc.Namespace))
|
||||
for _, key := range sortedKeys(doc.Messages) {
|
||||
m := doc.Messages[key]
|
||||
if len(m.Params) == 0 {
|
||||
fmt.Fprintf(&out, " %s: Record<string, never>;\n", strconv.Quote(key))
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&out, " %s: {", strconv.Quote(key))
|
||||
for _, name := range sortedKeys(m.Params) {
|
||||
typ := "string"
|
||||
switch m.Params[name] {
|
||||
case Boolean:
|
||||
typ = "boolean"
|
||||
case Integer:
|
||||
typ = "number | bigint | `${bigint}`"
|
||||
case Number:
|
||||
typ = "number"
|
||||
}
|
||||
fmt.Fprintf(&out, " %s: %s;", strconv.Quote(name), typ)
|
||||
}
|
||||
out.WriteString(" };\n")
|
||||
}
|
||||
out.WriteString(" };\n")
|
||||
}
|
||||
out.WriteString("}\nexport type Namespace = keyof MessageParams;\nexport type Translate = <N extends Namespace, K extends Extract<keyof MessageParams[N], string>>(namespace: N, key: K, params: Extract<MessageParams[N][K], Record<string, unknown>>) => string;\n")
|
||||
_, err := io.WriteString(w, out.String())
|
||||
return err
|
||||
}
|
||||
131
i18n/httpi18n/http.go
Normal file
131
i18n/httpi18n/http.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Package httpi18n negotiates an i18n language for standard HTTP requests.
|
||||
package httpi18n
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Resolver supplies ordered language tags, e.g. from an authenticated preference.
|
||||
// It must be safe for concurrent requests. It does not receive a mutable Bundle.
|
||||
// A panic is treated as no preference, preserving request execution.
|
||||
type Resolver func(*http.Request) []string
|
||||
type Option func(*Policy) error
|
||||
type Policy struct {
|
||||
bundle *i18n.Bundle
|
||||
resolver Resolver
|
||||
vary []string
|
||||
private bool
|
||||
}
|
||||
|
||||
// WithResolver overrides the default Accept-Language resolver. Cache variation
|
||||
// is explicit. With no varying headers, responses are marked private.
|
||||
func WithResolver(resolver Resolver, varyHeaders ...string) Option {
|
||||
return func(p *Policy) error {
|
||||
if resolver == nil {
|
||||
return fmt.Errorf("httpi18n: nil resolver")
|
||||
}
|
||||
p.resolver = resolver
|
||||
p.vary = nil
|
||||
p.private = len(varyHeaders) == 0
|
||||
for _, h := range varyHeaders {
|
||||
if !headerName(h) {
|
||||
return fmt.Errorf("httpi18n: invalid Vary header")
|
||||
}
|
||||
p.vary = append(p.vary, http.CanonicalHeaderKey(h))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func headerName(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if c > 127 || !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", c)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func New(bundle *i18n.Bundle, options ...Option) (*Policy, error) {
|
||||
if bundle == nil {
|
||||
return nil, fmt.Errorf("httpi18n: bundle required")
|
||||
}
|
||||
p := &Policy{bundle: bundle, vary: []string{"Accept-Language"}, resolver: func(r *http.Request) []string {
|
||||
header := r.Header.Get("Accept-Language")
|
||||
if len(header) > 8192 {
|
||||
return nil
|
||||
}
|
||||
tags, _, err := language.ParseAcceptLanguage(header)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := []string{}
|
||||
for _, tag := range tags {
|
||||
if len(out) == 64 {
|
||||
break
|
||||
}
|
||||
out = append(out, tag.String())
|
||||
}
|
||||
return out
|
||||
}}
|
||||
for _, o := range options {
|
||||
if o == nil {
|
||||
return nil, fmt.Errorf("httpi18n: nil option")
|
||||
}
|
||||
if err := o(p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Apply installs the negotiated language before application middleware. An
|
||||
// explicit context preference takes priority and makes the response private.
|
||||
// It does not claim Content-Language: individual messages may use fallbacks.
|
||||
func (p *Policy) Apply(w http.ResponseWriter, r *http.Request) *http.Request {
|
||||
if p == nil {
|
||||
return r
|
||||
}
|
||||
preferences := []string{}
|
||||
explicit := i18n.Locale(r.Context())
|
||||
if explicit != "" {
|
||||
preferences = append(preferences, explicit)
|
||||
}
|
||||
preferences = append(preferences, p.resolve(r)...)
|
||||
for _, h := range p.vary {
|
||||
addVary(w.Header(), h)
|
||||
}
|
||||
if p.private || explicit != "" {
|
||||
w.Header().Add("Cache-Control", "private")
|
||||
}
|
||||
return r.WithContext(i18n.WithLocale(r.Context(), p.bundle.Match(preferences...)))
|
||||
}
|
||||
|
||||
func (p *Policy) resolve(r *http.Request) (preferences []string) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
preferences = nil
|
||||
}
|
||||
}()
|
||||
return p.resolver(r)
|
||||
}
|
||||
func addVary(h http.Header, value string) {
|
||||
for _, line := range h.Values("Vary") {
|
||||
for _, v := range strings.Split(line, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(v), value) || strings.TrimSpace(v) == "*" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
h.Add("Vary", value)
|
||||
}
|
||||
func (p *Policy) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, p.Apply(w, r)) })
|
||||
}
|
||||
59
i18n/httpi18n/http_test.go
Normal file
59
i18n/httpi18n/http_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package httpi18n_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||
)
|
||||
|
||||
func TestNegotiationAndExplicitResolvers(t *testing.T) {
|
||||
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "fr", "zh-Hans", "zh-Hant"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := httpi18n.New(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct{ header, explicit, want string }{{"fr;q=0.8,en;q=0.1", "", "fr"}, {"fr;q=0,en;q=1", "", "en"}, {"zh-TW", "", "zh-Hant"}, {"zh-CN", "", "zh-Hans"}, {"invalid;garbage", "", "en"}, {strings.Repeat("x", 8193), "", "en"}, {"fr", "en", "en"}} {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept-Language", test.header)
|
||||
if test.explicit != "" {
|
||||
r = r.WithContext(i18n.WithLocale(r.Context(), test.explicit))
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
w.Header().Set("Vary", "Origin")
|
||||
p.Wrap(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
if got := i18n.Locale(r.Context()); got != test.want {
|
||||
t.Errorf("got %s want %s", got, test.want)
|
||||
}
|
||||
})).ServeHTTP(w, r)
|
||||
if strings.Join(w.Header().Values("Vary"), ",") != "Origin,Accept-Language" {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
if test.explicit != "" && !strings.Contains(w.Header().Get("Cache-Control"), "private") {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
}
|
||||
custom, err := httpi18n.New(b, httpi18n.WithResolver(func(*http.Request) []string { return []string{"fr"} }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := custom.Apply(w, httptest.NewRequest("GET", "/", nil))
|
||||
if i18n.Locale(r.Context()) != "fr" || w.Header().Get("Cache-Control") != "private" {
|
||||
t.Fatal(r, w.Header())
|
||||
}
|
||||
broken, err := httpi18n.New(b, httpi18n.WithResolver(func(*http.Request) []string { panic("preference lookup failed") }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = broken.Apply(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||
if i18n.Locale(r.Context()) != "en" {
|
||||
t.Fatal("resolver panic must preserve default selection")
|
||||
}
|
||||
}
|
||||
122
i18n/i18ntool/extract.go
Normal file
122
i18n/i18ntool/extract.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Package i18ntool provides conservative, syntax-only Go key extraction.
|
||||
package i18ntool
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Reference struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Key string `json:"key"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
}
|
||||
type Finding struct {
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Problem string `json:"problem"`
|
||||
}
|
||||
type Extraction struct {
|
||||
References []Reference `json:"references"`
|
||||
Findings []Finding `json:"findings"`
|
||||
}
|
||||
|
||||
// ExtractGo reads literal i18n.Ref expressions, including import aliases. It
|
||||
// neither executes project code nor claims to resolve dynamic keys or wrappers.
|
||||
// Render/RenderContext calls without a literal reference are reported for review.
|
||||
func ExtractGo(filename string, source []byte) (Extraction, error) {
|
||||
out := Extraction{References: []Reference{}, Findings: []Finding{}}
|
||||
fs := token.NewFileSet()
|
||||
file, err := parser.ParseFile(fs, filename, source, 0)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
aliases := map[string]bool{}
|
||||
for _, imp := range file.Imports {
|
||||
path, _ := strconv.Unquote(imp.Path.Value)
|
||||
if path == "git.yuntuo.hk/k2safe/go-omni/i18n" {
|
||||
name := "i18n"
|
||||
if imp.Name != nil {
|
||||
name = imp.Name.Name
|
||||
}
|
||||
if name == "." {
|
||||
out.Findings = append(out.Findings, Finding{filename, fs.Position(imp.Pos()).Line, "dot import requires manual extraction"})
|
||||
} else {
|
||||
aliases[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(aliases) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := lit.Type.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Ref" {
|
||||
return true
|
||||
}
|
||||
pkg, ok := sel.X.(*ast.Ident)
|
||||
if !ok || !aliases[pkg.Name] {
|
||||
return true
|
||||
}
|
||||
values := map[string]string{}
|
||||
valid := true
|
||||
for i, el := range lit.Elts {
|
||||
key := ""
|
||||
value := el
|
||||
if kv, ok := el.(*ast.KeyValueExpr); ok {
|
||||
id, ok := kv.Key.(*ast.Ident)
|
||||
if !ok {
|
||||
valid = false
|
||||
continue
|
||||
}
|
||||
key = id.Name
|
||||
value = kv.Value
|
||||
} else if i == 0 {
|
||||
key = "Namespace"
|
||||
} else if i == 1 {
|
||||
key = "Key"
|
||||
} else {
|
||||
valid = false
|
||||
}
|
||||
s, ok := value.(*ast.BasicLit)
|
||||
if !ok || s.Kind != token.STRING {
|
||||
valid = false
|
||||
continue
|
||||
}
|
||||
v, e := strconv.Unquote(s.Value)
|
||||
if e != nil {
|
||||
valid = false
|
||||
}
|
||||
values[key] = v
|
||||
}
|
||||
line := fs.Position(lit.Pos()).Line
|
||||
if valid && values["Namespace"] != "" && values["Key"] != "" {
|
||||
out.References = append(out.References, Reference{values["Namespace"], values["Key"], filename, line})
|
||||
} else {
|
||||
out.Findings = append(out.Findings, Finding{filename, line, "dynamic or incomplete message reference"})
|
||||
}
|
||||
return true
|
||||
})
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Render" && sel.Sel.Name != "RenderContext" || len(call.Args) != 3 {
|
||||
return true
|
||||
}
|
||||
if _, ok := call.Args[1].(*ast.CompositeLit); !ok {
|
||||
out.Findings = append(out.Findings, Finding{filename, fs.Position(call.Pos()).Line, "nonliteral Render reference; verify manually (syntax-only extraction)"})
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
14
i18n/i18ntool/extract_test.go
Normal file
14
i18n/i18ntool/extract_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package i18ntool_test
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAliasesLiteralsAndDynamicDiagnostics(t *testing.T) {
|
||||
source := []byte("package demo\nimport tr \"git.yuntuo.hk/k2safe/go-omni/i18n\"\nfunc f(){ b.Render(\"en\",tr.Ref{Namespace:\"forms\",Key:\"button\"},nil); b.Render(\"en\",dynamic,nil); _=tr.Ref{Namespace:\"forms\",Key:name} }\n")
|
||||
result, err := i18ntool.ExtractGo("demo.go", source)
|
||||
if err != nil || len(result.References) != 1 || len(result.Findings) != 2 || result.References[0].Namespace != "forms" {
|
||||
t.Fatal(result, err)
|
||||
}
|
||||
}
|
||||
143
i18n/load.go
Normal file
143
i18n/load.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// DecodeJSON rejects unknown fields, duplicate keys and excessive nesting.
|
||||
// It never expands environment references or evaluates message text.
|
||||
func DecodeJSON(data []byte, dst any) error {
|
||||
if len(data) > 1<<20 || !utf8.Valid(data) {
|
||||
return fmt.Errorf("i18n: document exceeds 1 MiB or is not UTF-8")
|
||||
}
|
||||
scan := json.NewDecoder(bytes.NewReader(data))
|
||||
scan.UseNumber()
|
||||
nodes := 0
|
||||
var walk func(int) error
|
||||
walk = func(depth int) error {
|
||||
nodes++
|
||||
if depth > 32 || nodes > 100000 {
|
||||
return fmt.Errorf("i18n: JSON nesting or node limit exceeded")
|
||||
}
|
||||
t, err := scan.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("i18n: invalid JSON")
|
||||
}
|
||||
d, ok := t.(json.Delim)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch d {
|
||||
case '{':
|
||||
seen := map[string]bool{}
|
||||
for scan.More() {
|
||||
k, e := scan.Token()
|
||||
if e != nil {
|
||||
return fmt.Errorf("i18n: invalid JSON object")
|
||||
}
|
||||
key, ok := k.(string)
|
||||
if !ok || seen[key] {
|
||||
return fmt.Errorf("i18n: duplicate JSON key")
|
||||
}
|
||||
seen[key] = true
|
||||
if err := walk(depth + 1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case '[':
|
||||
for scan.More() {
|
||||
if err := walk(depth + 1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("i18n: invalid JSON delimiter")
|
||||
}
|
||||
_, err = scan.Token()
|
||||
return err
|
||||
}
|
||||
if err := walk(0); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := scan.Token(); err != io.EOF {
|
||||
return fmt.Errorf("i18n: expected one JSON value")
|
||||
}
|
||||
d := json.NewDecoder(bytes.NewReader(data))
|
||||
d.UseNumber()
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(dst); err != nil {
|
||||
return fmt.Errorf("i18n: invalid document schema")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFS reads exactly the explicitly listed files; it never scans directories.
|
||||
func LoadFS(source fs.FS, paths ...string) ([]Document, error) {
|
||||
if source == nil || len(paths) > 1024 {
|
||||
return nil, fmt.Errorf("i18n: filesystem required; at most 1024 files")
|
||||
}
|
||||
docs := make([]Document, 0, len(paths))
|
||||
total := 0
|
||||
for _, path := range paths {
|
||||
if !fs.ValidPath(path) {
|
||||
return nil, fmt.Errorf("i18n: invalid resource path")
|
||||
}
|
||||
f, err := source.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("i18n: cannot open %s: %w", path, err)
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("i18n: resource must be a regular file: %s", path)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total += len(data)
|
||||
if total > 4<<20 {
|
||||
return nil, fmt.Errorf("i18n: resources exceed 4 MiB")
|
||||
}
|
||||
var doc Document
|
||||
if err := DecodeJSON(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
return docs, nil
|
||||
}
|
||||
|
||||
// Overlay replaces whole messages, never individual branches or parameter
|
||||
// schemas. The returned document is detached and must still pass New validation.
|
||||
func Overlay(base, override Document) (Document, error) {
|
||||
l1, e1 := canonical(base.Locale)
|
||||
l2, e2 := canonical(override.Locale)
|
||||
if e1 != nil || e2 != nil || l1 != l2 || base.Namespace != override.Namespace || base.Version != Version || override.Version != Version {
|
||||
return Document{}, fmt.Errorf("i18n: overlay requires matching version, locale and namespace")
|
||||
}
|
||||
// A bounded JSON round trip also rejects cyclic Go values before cloning.
|
||||
data, err := json.Marshal([]Document{base, override})
|
||||
if err != nil || len(data) > 1<<20 {
|
||||
return Document{}, fmt.Errorf("i18n: invalid or oversized overlay")
|
||||
}
|
||||
var docs []Document
|
||||
if err := DecodeJSON(data, &docs); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
out := docs[0]
|
||||
out.Locale = l1
|
||||
if out.Messages == nil {
|
||||
out.Messages = map[string]Message{}
|
||||
}
|
||||
for key, m := range docs[1].Messages {
|
||||
out.Messages[key] = m
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
221
i18n/pattern.go
Normal file
221
i18n/pattern.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
var paramName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]*$`)
|
||||
var integerText = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
|
||||
|
||||
type part struct{ text, param string }
|
||||
type compiled struct {
|
||||
parts []part
|
||||
selectKey, pluralKey string
|
||||
cases map[string]*compiled
|
||||
}
|
||||
type budget struct{ nodes, bytes int }
|
||||
|
||||
func compile(p Pattern, schema map[string]Kind, used map[string]bool, b *budget, depth int) (*compiled, error) {
|
||||
b.nodes++
|
||||
b.bytes += len(p.Text)
|
||||
if depth > 24 || b.nodes > 100000 || b.bytes > 4<<20 || len(p.Text) > 64<<10 || !utf8.ValidString(p.Text) {
|
||||
return nil, &Diagnostic{Kind: "resource", Problem: "pattern exceeds limits or contains invalid UTF-8"}
|
||||
}
|
||||
c := &compiled{selectKey: p.Select, pluralKey: p.Plural}
|
||||
if p.Select != "" || p.Plural != "" {
|
||||
if p.Select != "" && p.Plural != "" || p.Text != "" || p.Literal || len(p.Cases) == 0 || len(p.Cases) > 128 {
|
||||
return nil, &Diagnostic{Kind: "pattern", Problem: "expected one select/plural branch with cases"}
|
||||
}
|
||||
key := p.Select
|
||||
if key == "" {
|
||||
key = p.Plural
|
||||
}
|
||||
kind, ok := schema[key]
|
||||
if !ok || p.Plural != "" && kind != Integer && kind != Number || p.Select != "" && kind != String && kind != Boolean {
|
||||
return nil, &Diagnostic{Kind: "parameter", Param: key, Problem: "branch requires a declared parameter of the appropriate type"}
|
||||
}
|
||||
used[key] = true
|
||||
if _, ok := p.Cases["other"]; !ok {
|
||||
return nil, &Diagnostic{Kind: "pattern", Problem: "branch requires other"}
|
||||
}
|
||||
c.cases = make(map[string]*compiled, len(p.Cases))
|
||||
for _, name := range sortedKeys(p.Cases) {
|
||||
if !validID(name) {
|
||||
return nil, &Diagnostic{Kind: "pattern", Problem: "invalid case name"}
|
||||
}
|
||||
if p.Plural != "" && !pluralCase(name) {
|
||||
return nil, &Diagnostic{Kind: "pattern", Problem: "invalid plural category or exact integer case"}
|
||||
}
|
||||
child, err := compile(p.Cases[name], schema, used, b, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.cases[name] = child
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
if p.Cases != nil {
|
||||
return nil, &Diagnostic{Kind: "pattern", Problem: "cases require select or plural"}
|
||||
}
|
||||
if p.Literal {
|
||||
c.parts = []part{{text: p.Text}}
|
||||
return c, nil
|
||||
}
|
||||
var literal strings.Builder
|
||||
flush := func() {
|
||||
if literal.Len() > 0 {
|
||||
c.parts = append(c.parts, part{text: literal.String()})
|
||||
literal.Reset()
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(p.Text); {
|
||||
ch := p.Text[i]
|
||||
if ch != '{' && ch != '}' {
|
||||
literal.WriteByte(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if i+1 < len(p.Text) && p.Text[i+1] == ch {
|
||||
literal.WriteByte(ch)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if ch == '}' {
|
||||
return nil, &Diagnostic{Kind: "pattern", Problem: "unescaped closing brace"}
|
||||
}
|
||||
end := strings.IndexByte(p.Text[i+1:], '}')
|
||||
if end < 0 {
|
||||
return nil, &Diagnostic{Kind: "pattern", Problem: "unclosed placeholder"}
|
||||
}
|
||||
name := p.Text[i+1 : i+1+end]
|
||||
if _, ok := schema[name]; !ok {
|
||||
return nil, &Diagnostic{Kind: "parameter", Param: name, Problem: "undeclared placeholder"}
|
||||
}
|
||||
used[name] = true
|
||||
flush()
|
||||
c.parts = append(c.parts, part{param: name})
|
||||
i += end + 2
|
||||
}
|
||||
flush()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func pluralCase(s string) bool {
|
||||
switch s {
|
||||
case "zero", "one", "two", "few", "many", "other":
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(s, "=") {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.ParseInt(s[1:], 10, 64)
|
||||
return err == nil && strconv.FormatInt(n, 10) == s[1:]
|
||||
}
|
||||
|
||||
func scalar(kind Kind, value any) (string, error) {
|
||||
bad := func() (string, error) {
|
||||
return "", &Diagnostic{Kind: "parameter", Problem: "wrong type, range, or non-finite value"}
|
||||
}
|
||||
if value == nil {
|
||||
return bad()
|
||||
}
|
||||
v := reflect.ValueOf(value)
|
||||
switch kind {
|
||||
case String:
|
||||
if v.Kind() == reflect.String && v.Type() != reflect.TypeOf(json.Number("")) && utf8.ValidString(v.String()) && len(v.String()) <= 64<<10 {
|
||||
return v.String(), nil
|
||||
}
|
||||
case Boolean:
|
||||
if v.Kind() == reflect.Bool {
|
||||
return strconv.FormatBool(v.Bool()), nil
|
||||
}
|
||||
case Integer:
|
||||
if n, ok := value.(json.Number); ok {
|
||||
x, e := n.Int64()
|
||||
if e == nil {
|
||||
return strconv.FormatInt(x, 10), nil
|
||||
}
|
||||
return bad()
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return strconv.FormatInt(v.Int(), 10), nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
if v.Uint() <= math.MaxInt64 {
|
||||
return strconv.FormatUint(v.Uint(), 10), nil
|
||||
}
|
||||
}
|
||||
case Number:
|
||||
var n float64
|
||||
if j, ok := value.(json.Number); ok {
|
||||
var e error
|
||||
n, e = j.Float64()
|
||||
if e != nil {
|
||||
return bad()
|
||||
}
|
||||
} else {
|
||||
switch v.Kind() {
|
||||
case reflect.Float32, reflect.Float64:
|
||||
n = v.Float()
|
||||
default:
|
||||
return bad()
|
||||
}
|
||||
}
|
||||
if !math.IsNaN(n) && !math.IsInf(n, 0) {
|
||||
if n == 0 {
|
||||
return "0", nil
|
||||
}
|
||||
// ECMAScript's plain/scientific thresholds, shared with the browser runtime.
|
||||
format := byte('f')
|
||||
if math.Abs(n) < 1e-6 || math.Abs(n) >= 1e21 {
|
||||
format = 'e'
|
||||
}
|
||||
s := strconv.FormatFloat(n, format, -1, 64)
|
||||
s = strings.ReplaceAll(strings.ReplaceAll(s, "e-0", "e-"), "e+0", "e+")
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return bad()
|
||||
}
|
||||
|
||||
func (c *compiled) render(tag language.Tag, values map[string]string) (string, error) {
|
||||
if c.cases != nil {
|
||||
key := values[c.selectKey]
|
||||
if c.pluralKey != "" {
|
||||
n := values[c.pluralKey]
|
||||
key = "=" + n
|
||||
if _, ok := c.cases[key]; !ok {
|
||||
var err error
|
||||
key, err = category(tag, n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
next := c.cases[key]
|
||||
if next == nil {
|
||||
next = c.cases["other"]
|
||||
}
|
||||
return next.render(tag, values)
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, p := range c.parts {
|
||||
value := p.text
|
||||
if p.param != "" {
|
||||
value = values[p.param]
|
||||
}
|
||||
if out.Len()+len(value) > 1<<20 {
|
||||
return "", &Diagnostic{Kind: "resource", Problem: "rendered message exceeds 1 MiB"}
|
||||
}
|
||||
out.WriteString(value)
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
196
i18n/plural.go
Normal file
196
i18n/plural.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// PluralRulesVersion pins both runtimes to the same Unicode data. Changing it
|
||||
// is a presentation compatibility change, independent of message format Version.
|
||||
const PluralRulesVersion = "cldr-46.1"
|
||||
|
||||
//go:embed cldr/plurals.json
|
||||
var pluralData []byte
|
||||
|
||||
//go:embed cldr/LICENSE
|
||||
var unicodeLicense string
|
||||
|
||||
type pluralRange struct{ lo, hi *big.Int }
|
||||
type pluralCondition struct {
|
||||
operand byte
|
||||
modulus *big.Int
|
||||
negate bool
|
||||
ranges []pluralRange
|
||||
}
|
||||
type pluralRule [][]pluralCondition // OR of AND groups, per the CLDR grammar.
|
||||
type pluralSet struct {
|
||||
text map[string]string
|
||||
rules map[string]pluralRule
|
||||
}
|
||||
|
||||
var pluralConditionSyntax = regexp.MustCompile(`^([nivwftec])(?: % ([1-9][0-9]*))? (!=|=) ([0-9]+(?:\.\.[0-9]+)?(?:,\s*[0-9]+(?:\.\.[0-9]+)?)*)$`)
|
||||
var cardinalRules = loadCardinalRules()
|
||||
|
||||
func bigDecimal(s string) *big.Int {
|
||||
if s == "" {
|
||||
return new(big.Int)
|
||||
}
|
||||
n, ok := new(big.Int).SetString(s, 10)
|
||||
if !ok {
|
||||
panic("i18n: invalid embedded plural integer")
|
||||
}
|
||||
return n
|
||||
}
|
||||
func parsePluralRule(text string) (pluralRule, error) {
|
||||
var result pluralRule
|
||||
if len(text) > 2048 {
|
||||
return nil, fmt.Errorf("plural rule too large")
|
||||
}
|
||||
for _, or := range strings.Split(text, " or ") {
|
||||
var group []pluralCondition
|
||||
for _, and := range strings.Split(or, " and ") {
|
||||
parts := pluralConditionSyntax.FindStringSubmatch(strings.TrimSpace(and))
|
||||
if parts == nil {
|
||||
return nil, fmt.Errorf("invalid CLDR plural condition")
|
||||
}
|
||||
c := pluralCondition{operand: parts[1][0], negate: parts[3] == "!="}
|
||||
if parts[2] != "" {
|
||||
c.modulus = bigDecimal(parts[2])
|
||||
}
|
||||
for _, r := range strings.Split(parts[4], ",") {
|
||||
lo, hi, has := strings.Cut(strings.TrimSpace(r), "..")
|
||||
if !has {
|
||||
hi = lo
|
||||
}
|
||||
low, high := bigDecimal(lo), bigDecimal(hi)
|
||||
if low.Cmp(high) > 0 {
|
||||
return nil, fmt.Errorf("reversed plural range")
|
||||
}
|
||||
c.ranges = append(c.ranges, pluralRange{low, high})
|
||||
}
|
||||
group = append(group, c)
|
||||
}
|
||||
result = append(result, group)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadCardinalRules() map[string]pluralSet {
|
||||
var data struct {
|
||||
Supplemental struct {
|
||||
Rules map[string]map[string]string `json:"plurals-type-cardinal"`
|
||||
} `json:"supplemental"`
|
||||
}
|
||||
if err := json.Unmarshal(pluralData, &data); err != nil {
|
||||
panic("i18n: invalid embedded CLDR data")
|
||||
}
|
||||
out := map[string]pluralSet{}
|
||||
for locale, entries := range data.Supplemental.Rules {
|
||||
set := pluralSet{text: map[string]string{}, rules: map[string]pluralRule{}}
|
||||
for name, raw := range entries {
|
||||
category := strings.TrimPrefix(name, "pluralRule-count-")
|
||||
text, _, _ := strings.Cut(raw, "@")
|
||||
text = strings.TrimSpace(text)
|
||||
if category == "other" {
|
||||
continue
|
||||
}
|
||||
rule, err := parsePluralRule(text)
|
||||
if err != nil {
|
||||
panic("i18n: unsupported embedded CLDR rule for " + locale)
|
||||
}
|
||||
set.text[category] = text
|
||||
set.rules[category] = rule
|
||||
}
|
||||
out[strings.ReplaceAll(locale, "_", "-")] = set
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rulesFor(tag language.Tag) pluralSet {
|
||||
for {
|
||||
if rules, ok := cardinalRules[tag.String()]; ok {
|
||||
return rules
|
||||
}
|
||||
if tag == language.Und {
|
||||
break
|
||||
}
|
||||
tag = tag.Parent()
|
||||
}
|
||||
return pluralSet{text: map[string]string{}, rules: map[string]pluralRule{}}
|
||||
}
|
||||
|
||||
// operands expands the canonical numeric string exactly, avoiding any float
|
||||
// conversion for int64 and any browser rounding of tiny fractional operands.
|
||||
func operands(text string) (map[byte]*big.Int, bool) {
|
||||
text = strings.TrimPrefix(text, "-")
|
||||
mantissa, exponent, has := strings.Cut(text, "e")
|
||||
shift := 0
|
||||
if has {
|
||||
shift, _ = strconv.Atoi(exponent)
|
||||
}
|
||||
whole, fraction, _ := strings.Cut(mantissa, ".")
|
||||
digits := whole + fraction
|
||||
point := len(whole) + shift
|
||||
switch {
|
||||
case point <= 0:
|
||||
whole = "0"
|
||||
fraction = strings.Repeat("0", -point) + digits
|
||||
case point >= len(digits):
|
||||
whole = digits + strings.Repeat("0", point-len(digits))
|
||||
fraction = ""
|
||||
default:
|
||||
whole = digits[:point]
|
||||
fraction = digits[point:]
|
||||
}
|
||||
tail := strings.TrimRight(fraction, "0")
|
||||
i, f := bigDecimal(whole), bigDecimal(fraction)
|
||||
return map[byte]*big.Int{'n': i, 'i': i, 'v': big.NewInt(int64(len(fraction))), 'w': big.NewInt(int64(len(tail))), 'f': f, 't': bigDecimal(tail), 'c': new(big.Int), 'e': new(big.Int)}, f.Sign() != 0
|
||||
}
|
||||
func (rule pluralRule) matches(values map[byte]*big.Int, fractional bool) bool {
|
||||
for _, group := range rule {
|
||||
all := true
|
||||
for _, condition := range group {
|
||||
value := values[condition.operand]
|
||||
if condition.modulus != nil {
|
||||
value = new(big.Int).Mod(value, condition.modulus)
|
||||
}
|
||||
found := false
|
||||
if condition.operand != 'n' || !fractional {
|
||||
for _, r := range condition.ranges {
|
||||
if value.Cmp(r.lo) >= 0 && value.Cmp(r.hi) <= 0 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if condition.negate {
|
||||
found = !found
|
||||
}
|
||||
if !found {
|
||||
all = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if all {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func category(tag language.Tag, text string) (string, error) {
|
||||
values, fractional := operands(text)
|
||||
rules := rulesFor(tag).rules
|
||||
for _, category := range []string{"zero", "one", "two", "few", "many"} {
|
||||
if rule, ok := rules[category]; ok && rule.matches(values, fractional) {
|
||||
return category, nil
|
||||
}
|
||||
}
|
||||
return "other", nil
|
||||
}
|
||||
61
i18n/plural_test.go
Normal file
61
i18n/plural_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPinnedCLDRSamples(t *testing.T) {
|
||||
var data struct {
|
||||
Supplemental struct {
|
||||
Rules map[string]map[string]string `json:"plurals-type-cardinal"`
|
||||
} `json:"supplemental"`
|
||||
}
|
||||
if err := json.Unmarshal(pluralData, &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
for locale, entries := range data.Supplemental.Rules {
|
||||
for name, raw := range entries {
|
||||
want := strings.TrimPrefix(name, "pluralRule-count-")
|
||||
for _, section := range strings.Split(raw, "@")[1:] {
|
||||
_, samples, ok := strings.Cut(section, " ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, sample := range strings.Split(samples, ",") {
|
||||
for _, number := range strings.Split(strings.TrimSpace(sample), "~") {
|
||||
if number == "" || strings.ContainsAny(number, "…ce") {
|
||||
continue
|
||||
}
|
||||
values, fractional := operands(number)
|
||||
got := "other"
|
||||
for _, cat := range []string{"zero", "one", "two", "few", "many"} {
|
||||
if rule, ok := cardinalRules[locale].rules[cat]; ok && rule.matches(values, fractional) {
|
||||
got = cat
|
||||
break
|
||||
}
|
||||
}
|
||||
count++
|
||||
if got != want {
|
||||
t.Errorf("%s %s: got %s want %s", locale, number, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count < 5000 {
|
||||
t.Fatalf("too few CLDR samples: %d", count)
|
||||
}
|
||||
t.Logf("validated %d upstream cardinal samples", count)
|
||||
}
|
||||
|
||||
func TestRuleParserRejectsUnsupportedSyntax(t *testing.T) {
|
||||
for _, text := range []string{"n = 1; run()", "n % 0 = 1", "n = 5..1", "unknown = 1"} {
|
||||
if _, err := parsePluralRule(text); err == nil {
|
||||
t.Fatal(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
120
i18n/types.go
Normal file
120
i18n/types.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Package i18n provides immutable, instance-scoped message catalogs. It has no
|
||||
// dependency on HTTP, application configuration, business errors or storage.
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const Version = 1
|
||||
|
||||
// Kind describes portable message arguments, independently of Go identifiers.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
String Kind = "string"
|
||||
Boolean Kind = "boolean"
|
||||
Integer Kind = "integer" // Signed int64; browser callers may use bigint/string.
|
||||
Number Kind = "number" // Finite IEEE-754 float64.
|
||||
)
|
||||
|
||||
// Pattern is a text leaf or a select/plural branch. Branches require "other".
|
||||
// Text uses {name}; {{ and }} escape braces. Literal disables interpolation.
|
||||
// Plural is cardinal, with optional exact integer cases such as "=0".
|
||||
type Pattern struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Literal bool `json:"literal,omitempty"`
|
||||
Select string `json:"select,omitempty"`
|
||||
Plural string `json:"plural,omitempty"`
|
||||
Cases map[string]Pattern `json:"cases,omitempty"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Params map[string]Kind `json:"params,omitempty"`
|
||||
Value Pattern `json:"value"`
|
||||
}
|
||||
|
||||
func (m *Message) UnmarshalJSON(data []byte) error {
|
||||
type plain Message
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if value, ok := raw["value"]; !ok || string(value) == "null" {
|
||||
return fmt.Errorf("i18n: message requires a value")
|
||||
}
|
||||
for key := range raw {
|
||||
if key != "value" && key != "params" {
|
||||
return fmt.Errorf("i18n: unknown message field")
|
||||
}
|
||||
}
|
||||
var value plain
|
||||
// Pattern's fields are checked by the strict decoder, including nested cases.
|
||||
if err := DecodeJSON(data, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = Message(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Document is the versioned interchange format. Source-locale documents own
|
||||
// parameter schemas; translations inherit them and may repeat identical schemas.
|
||||
type Document struct {
|
||||
Version int `json:"version"`
|
||||
Namespace string `json:"namespace"`
|
||||
Locale string `json:"locale"`
|
||||
Messages map[string]Message `json:"messages"`
|
||||
}
|
||||
|
||||
// Config distinguishes source text from the default selection and enabled locales.
|
||||
// Fallbacks are explicit, ordered edges; cycles are rejected. The default locale
|
||||
// must be enabled. The source locale can remain a fallback-only language.
|
||||
type Config struct {
|
||||
SourceLocale string `json:"sourceLocale"`
|
||||
DefaultLocale string `json:"defaultLocale"`
|
||||
Locales []string `json:"locales"`
|
||||
Fallbacks map[string][]string `json:"fallbacks,omitempty"`
|
||||
}
|
||||
|
||||
type Ref struct{ Namespace, Key string }
|
||||
|
||||
// Result reports the actual dictionary language, which may differ from Requested.
|
||||
type Result struct {
|
||||
Text string
|
||||
Requested string
|
||||
Locale string
|
||||
Fallback bool
|
||||
}
|
||||
|
||||
// Diagnostic deliberately excludes argument values and message contents.
|
||||
type Diagnostic struct {
|
||||
Kind string `json:"kind"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Problem string `json:"problem"`
|
||||
}
|
||||
|
||||
func (d *Diagnostic) Error() string {
|
||||
return fmt.Sprintf("i18n: %s namespace=%q key=%q locale=%q param=%q: %s", d.Kind, d.Namespace, d.Key, d.Locale, d.Param, d.Problem)
|
||||
}
|
||||
|
||||
type localeKey struct{}
|
||||
|
||||
// WithLocale records a preference; rendering still matches it against the bundle.
|
||||
func WithLocale(ctx context.Context, locale string) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, localeKey{}, locale)
|
||||
}
|
||||
func Locale(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := ctx.Value(localeKey{}).(string)
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user