From d1a747c36f15d964dd445d661623b68a45892adf Mon Sep 17 00:00:00 2001 From: k2safe Date: Tue, 29 Sep 2026 13:31:42 +0800 Subject: [PATCH] feat: release v0.26 portable i18n and presentation adapters --- CHANGELOG.md | 11 + CONFIG.md | 4 + DEVELOPMENT.md | 2 +- PROJECT.md | 4 +- README.md | 11 +- RELEASE.md | 21 + RESPONSE.md | 2 + app/i18n_test.go | 241 +++++ app/router.go | 4 + cmd/go-omni/README.md | 8 +- cmd/go-omni/apigen/contractMetadata.go | 1 + cmd/go-omni/apigen/declarations.go | 17 +- cmd/go-omni/apigen/errorCatalog.go | 15 +- cmd/go-omni/apigen/generate.go | 9 +- cmd/go-omni/apigen/i18nVersion.go | 57 ++ cmd/go-omni/apigen/i18n_test.go | 42 + .../apigen/testdata/i18n/consumer_test.go | 70 ++ cmd/go-omni/apigen/workspaceCatalog_test.go | 2 +- cmd/go-omni/i18n.go | 195 ++++ cmd/go-omni/i18n_test.go | 80 ++ cmd/go-omni/main.go | 6 + cmd/go-omni/scaffold/version.go | 2 +- config/config.go | 12 + contract/bind.go | 10 +- contract/policy_test.go | 13 + errors/README.md | 2 + errors/catalog.go | 22 +- errors/localization.go | 46 + examples/contracts/i18n.api | 34 + examples/i18n/README.md | 28 + examples/i18n/config.json | 6 + examples/i18n/index.html | 46 + examples/i18n/locales/errors.zh-Hans.json | 8 + examples/i18n/locales/ui.en.json | 15 + examples/i18n/locales/ui.zh-Hans.json | 12 + examples/i18n/main.go | 135 +++ examples/i18n/main_test.go | 34 + examples/managedService/go.mod | 2 +- examples/userService/go.mod | 2 +- go.mod | 2 +- httpx/context.go | 30 +- httpx/response.go | 20 + i18n/README.md | 180 ++++ i18n/browser/README.md | 39 + i18n/browser/assets.go | 13 + i18n/browser/conformance.mjs | 6 + i18n/browser/runtime.d.mts | 29 + i18n/browser/runtime.mjs | 305 ++++++ i18n/browser/runtime.test.mjs | 50 + i18n/browser/testdata/consumer.mts | 26 + i18n/bundle.go | 434 ++++++++ i18n/bundle_test.go | 353 +++++++ i18n/cldr/LICENSE | 41 + i18n/cldr/README.md | 19 + i18n/cldr/plurals.json | 933 ++++++++++++++++++ i18n/errori18n/errors.go | 151 +++ i18n/errori18n/errors_test.go | 54 + i18n/export.go | 123 +++ i18n/httpi18n/http.go | 131 +++ i18n/httpi18n/http_test.go | 59 ++ i18n/i18ntool/extract.go | 122 +++ i18n/i18ntool/extract_test.go | 14 + i18n/load.go | 143 +++ i18n/pattern.go | 221 +++++ i18n/plural.go | 196 ++++ i18n/plural_test.go | 61 ++ i18n/types.go | 120 +++ omni.go | 6 + reports/i18nAcceptance.md | 75 ++ reports/i18nManagedLocal.json | 61 ++ reports/i18nReleaseValidation.txt | 194 ++++ reports/i18nValidation.txt | 241 +++++ response/config.go | 3 + response/encoder.go | 11 + scripts/verifyManagedRelease.py | 36 +- 75 files changed, 5681 insertions(+), 52 deletions(-) create mode 100644 app/i18n_test.go create mode 100644 cmd/go-omni/apigen/i18nVersion.go create mode 100644 cmd/go-omni/apigen/i18n_test.go create mode 100644 cmd/go-omni/apigen/testdata/i18n/consumer_test.go create mode 100644 cmd/go-omni/i18n.go create mode 100644 cmd/go-omni/i18n_test.go create mode 100644 errors/localization.go create mode 100644 examples/contracts/i18n.api create mode 100644 examples/i18n/README.md create mode 100644 examples/i18n/config.json create mode 100644 examples/i18n/index.html create mode 100644 examples/i18n/locales/errors.zh-Hans.json create mode 100644 examples/i18n/locales/ui.en.json create mode 100644 examples/i18n/locales/ui.zh-Hans.json create mode 100644 examples/i18n/main.go create mode 100644 examples/i18n/main_test.go create mode 100644 i18n/README.md create mode 100644 i18n/browser/README.md create mode 100644 i18n/browser/assets.go create mode 100644 i18n/browser/conformance.mjs create mode 100644 i18n/browser/runtime.d.mts create mode 100644 i18n/browser/runtime.mjs create mode 100644 i18n/browser/runtime.test.mjs create mode 100644 i18n/browser/testdata/consumer.mts create mode 100644 i18n/bundle.go create mode 100644 i18n/bundle_test.go create mode 100644 i18n/cldr/LICENSE create mode 100644 i18n/cldr/README.md create mode 100644 i18n/cldr/plurals.json create mode 100644 i18n/errori18n/errors.go create mode 100644 i18n/errori18n/errors_test.go create mode 100644 i18n/export.go create mode 100644 i18n/httpi18n/http.go create mode 100644 i18n/httpi18n/http_test.go create mode 100644 i18n/i18ntool/extract.go create mode 100644 i18n/i18ntool/extract_test.go create mode 100644 i18n/load.go create mode 100644 i18n/pattern.go create mode 100644 i18n/plural.go create mode 100644 i18n/plural_test.go create mode 100644 i18n/types.go create mode 100644 reports/i18nAcceptance.md create mode 100644 reports/i18nManagedLocal.json create mode 100644 reports/i18nReleaseValidation.txt create mode 100644 reports/i18nValidation.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d42ad..63fd254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v0.26.0-preview(2026-09-29) + +- 新增独立 i18n:不可变命名空间词典、源/默认/启用语言、BCP 47 匹配、显式回退、参数/条件/基数复数和有界资源加载;不依赖 HTTP、业务或数据库。 +- Go/浏览器共用固定 CLDR 46.1 复数规则;JSON 资源版本、类型导出和按命名空间选择的语言包独立于业务错误格式。 +- 新增可选 httpi18n/errori18n 适配;错误 translation 元数据、PublicMessage 展示入口,保留原业务码/状态/失败语义/原始原因和首次幂等响应。 +- 新增 i18n check/export/types/extract;静态与动态键诊断、未知字段/重复定义/参数校验、显式覆盖;输入只读,不执行任意项目 Go。 +- 新增无前端框架依赖的浏览器模块、按需加载/切换竞态/失败保留、CLI/HTTP/表单示例及通用验收。旧项目默认行为不变。 +- 新 translation 生成代码要求 v0.26.0-preview 或具备能力的本地源码,未使用该元数据的模板最低版本保持 v0.22。 + +- 修复 JSON 对象/数组结束定界符读取失败时丢失原始错误的问题,保留请求体限额的 HTTP 413;共享 Go 目录测试工程改用声明支持的 Go 1.22。 + ## v0.25.0-preview(2026-09-26) - database.InTx 新增 TxError、TxStage、TxOutcome,区分开启/回调/提交失败以及未执行回调、已确认回滚、结果不确定;原签名保留。 diff --git a/CONFIG.md b/CONFIG.md index 695964e..c701a4f 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -190,6 +190,10 @@ Decoder 返回 Document(map/string/bool/json.Number/nil/[]any,XML 类标量 低层 `DecodeFile(path, defaults[, loader])` 也解析显式引用,但不调用 Validate;默认值和原始 Document 都不会因失败而被部分修改。日志 Reloader 仍只允许 log.level 变化,其他静态字段变更要求重启。 +## 国际化配置 + +通用国际化的 `i18n.Config` 可作为项目配置的字段显式加载并调用 Validate。正文词典使用 `i18n.LoadFS` / `DecodeJSON`,不经过环境引用展开。构建 Bundle 后通过 `WithLanguagePolicy` / `WithErrorLocalizer` 装配借用的只读实例;没有隐式环境变量或全局当前语言。见 [国际化指南](i18n/README.md) 和 [完整示例](examples/i18n/README.md)。 + ## 跨域配置 生成项目的 `server.cors` 由本加载流程直接绑定、校验并交给 App,不需要修改生成入口。支持固定来源数组或元素内显式环境引用;非法来源定位具体数组元素。默认关闭,完整配置与行为见 [CORS 指南](cors/README.md)。 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3ff49db..e882cf2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,4 +1,4 @@ -# 通用业务开发指南(v0.25.0-preview) +# 通用业务开发指南(v0.26.0-preview) 从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。 diff --git a/PROJECT.md b/PROJECT.md index 927434a..0b20711 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,6 +1,6 @@ # 生成项目与文件维护边界 -v0.25.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 +v0.26.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 ## 创建和日常开发 @@ -215,7 +215,7 @@ get /download (DownloadReq) ## 已有服务接入步骤 -每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.25.0-preview: +每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.26.0-preview: 1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。 2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。 diff --git a/README.md b/README.md index 7181195..9c1c84c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。 -**从这份 README 开始接入。** 当前版本 **`v0.25.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 +**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.26.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。 + +**从这份 README 开始接入。** 当前版本 **`v0.26.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 **v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。 @@ -43,6 +45,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可 | 数组、null、原始请求、上传下载 | `.api` 声明数组/可空返回、body/multipart 与大小限制;必要时使用 Bind/Manual 扩展 | [契约示例](examples/contracts)、[含上传/回调/下载的项目](examples/managedService/README.md) | | 公共响应与特殊协议 | `.api` 定义 response 并 `@app(defaultResponse: ...)`;Go 用 `WithResponse` / Encoder | [响应配置](RESPONSE.md)、[响应模板契约](examples/contracts/responses.api) | | 错误目录与动态提示 | `WithErrors(catalog)` 注册一次;`ByCode(...).WithParams(...).WithCause(err)` | [错误目录](errors/README.md)、[模板参数示例](examples/contracts/errorParams.api) | +| 通用国际化 | `i18n.New/Render`;可选 `WithLanguagePolicy/WithErrorLocalizer`;`go-omni i18n check/export/types/extract` | [资源格式与边界](i18n/README.md)、[CLI/HTTP/前端示例](examples/i18n/README.md)、[生成契约](examples/contracts/i18n.api) | | OpenAPI 与错误公共引用 | `.api` 自动生成;手写接口可使用 `openapi` 注册器;Go 错误目录显式导出 | [契约工具](DEVELOPMENT.md)、[注册示例](examples/platform/main.go) | | 结构化业务日志 | `logx.Info/Error`、`logx.FromContext(ctx)`、`logx.New`;支持独立时区、轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) | | 文件配置与环境引用 | Default()→JSON/YAML/XML 字段引用→类型绑定→校验;无隐式覆盖,支持 `-check-config` | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) | @@ -153,7 +156,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可 ```sh export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk" -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview export PATH="$(go env GOPATH)/bin:$PATH" go-omni --help ``` @@ -310,7 +313,7 @@ go-omni new --api catalog.api \ 在业务项目的 **go.mod 所在目录**执行: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview +go get git.yuntuo.hk/k2safe/go-omni@v0.26.0-preview ``` 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 @@ -636,7 +639,7 @@ go test ./... `main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。 -CLI 与业务 module 固定使用 `v0.25.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 +CLI 与业务 module 固定使用 `v0.26.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。 ## 按需深入 diff --git a/RELEASE.md b/RELEASE.md index 9ebd766..9373657 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,3 +1,24 @@ +# v0.26.0-preview + +新增可独立使用的 i18n 底层模块及可选 HTTP/错误适配。项目维护文案和语言设置,框架提供不可变词典、语言匹配、回退、参数与分支、检查和导出;没有绑定业务身份、页面框架、数据库或响应字段名。 + +前后端共享版本化消息资源和固定 Unicode CLDR 46.1 基数复数规则。CLI 只读输入,支持源目录提取、缺译/参数/动态引用检查、按命名空间导出语言包和 TypeScript 类型。可选浏览器模块先加载并验证再切换,失败保留当前语言,旧请求不能覆盖新选择。 + +错误目录新增可选 `translation` 引用。内置响应使用展示译文,原目录文案继续参与契约校验;自定义 Encoder 通过 `PublicMessage()` 显式采用译文。业务码、HTTP 状态、成功/失败语义、cause、直接协议输出和幂等首次快照保持。旧配置与生成项目不启用时无需迁移。 + +完整 [使用指南](i18n/README.md)、[通用示例](examples/i18n/README.md)、[验收记录](reports/i18nAcceptance.md)。包含 translation 的新契约远程依赖最低版本为 v0.26.0-preview;未使用该元数据的模板最低版本仍为 v0.22。CLI 与框架使用同版。 + +同时修复 JSON 结束定界符读取失败时丢失原始错误的问题,使请求体超过外层限制时在 Go 1.22 中也保持 HTTP 413;共享目录测试工程使用 Go 1.22。 + +```sh +go get git.yuntuo.hk/k2safe/go-omni@v0.26.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview +go-omni api generate --check +go test ./... +``` + +--- + # v0.25.0-preview 事务失败可以按阶段和已知结果处理,避免把“提交错误”误判为“数据库没有保存”。 diff --git a/RESPONSE.md b/RESPONSE.md index 1e624cd..95bac26 100644 --- a/RESPONSE.md +++ b/RESPONSE.md @@ -1,5 +1,7 @@ # 公共响应体与独立接口协议 +v0.26 新增的 [国际化适配](i18n/README.md#接入-go-omni-错误与-http)保留现有响应契约。`Result.Message` 是校验使用的目录原文;`MessageOverride` 是可选展示译文,`PublicMessage()` 返回最终文案。内置 Formatter 自动使用译文;自定义 Encoder 显式选择,固定文本/XML 回执和直接输出不自动改写。 + 这些功能从 `v0.8.0-preview` 提供。CLI 与项目框架依赖请固定到同一版本;源码开发可显式使用 `--framework-dir`。`v0.7.0-preview.1` 不包含响应模板声明。 go-omni 的响应编码由应用或接口配置。没有配置时保留旧版默认值;业务可以定义自己的字段、嵌套结构、错误码类型、提示和空值行为。`response.Response` 仅保留为兼容 DTO,运行时与生成器不再通过它固定响应结构。 diff --git a/app/i18n_test.go b/app/i18n_test.go new file mode 100644 index 0000000..423d63b --- /dev/null +++ b/app/i18n_test.go @@ -0,0 +1,241 @@ +package app_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + omni "git.yuntuo.hk/k2safe/go-omni" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/i18n" + "git.yuntuo.hk/k2safe/go-omni/i18n/errori18n" + "git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n" + "git.yuntuo.hk/k2safe/go-omni/middleware" + "git.yuntuo.hk/k2safe/go-omni/response" +) + +func localizedCatalog(namespace string, status int) apierrors.Catalog { + return errori18n.Bind(apierrors.Catalog{DefaultStatus: status, Definitions: map[string]apierrors.Definition{ + "SAME": {Code: int64(9223372036854775807), Message: namespace + ":{value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}}, + "INTERNAL_ERROR": {Status: 500, Code: int64(500), Message: "internal"}, + "FIXED": {Status: 401, Code: int64(401), Message: "fixed {literal}"}, + }}, namespace) +} +func localizedOptions(t *testing.T, catalogs ...apierrors.Catalog) (*httpi18n.Policy, *errori18n.Localizer) { + t.Helper() + docs := []i18n.Document{} + for _, catalog := range catalogs { + source, err := errori18n.Source(catalog, "en") + if err != nil { + t.Fatal(err) + } + docs = append(docs, source...) + for _, d := range source { + docs = append(docs, i18n.Document{Version: 1, Namespace: d.Namespace, Locale: "zh-Hans", Messages: map[string]i18n.Message{ + "SAME": {Value: i18n.Pattern{Text: d.Namespace + "译文:{value}"}}, "FIXED": {Value: i18n.Pattern{Text: "需要认证"}}, "INTERNAL_ERROR": {Value: i18n.Pattern{Text: "系统错误"}}, + }}) + } + } + b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, docs...) + if err != nil { + t.Fatal(err) + } + l := errori18n.New(b, nil) + for _, c := range catalogs { + if err := l.Validate(c); err != nil { + t.Fatal(err) + } + } + p, err := httpi18n.New(b) + if err != nil { + t.Fatal(err) + } + return p, l +} + +func TestI18nRouteScopesEarlyFailuresAndReplay(t *testing.T) { + global, local := localizedCatalog("app", 200), localizedCatalog("route", 409) + policy, localizer := localizedOptions(t, global, local) + format := response.Config{CodeType: "int64", SuccessCode: int64(0), ErrorCode: int64(999), Body: map[string]any{"error": map[string]any{"reason": "${message}", "id": "${code}"}, "ok": "${ok}", "data": "${data}"}, DataMode: "always", Messages: map[string]string{"FIXED": "fixed {literal}"}} + app := omni.New(omni.WithErrors(global), omni.WithResponse(format), omni.WithLanguagePolicy(policy), omni.WithErrorLocalizer(localizer)) + defer app.Close() + private := errors.New("private-value") + app.Use(func(next httpx.Handler) httpx.Handler { + return func(c *httpx.Context) error { + switch c.Header("X-Early") { + case "auth": + return apierrors.ByCode("FIXED") + case "panic": + panic(private) + } + return next(c) + } + }) + handler := func(c *httpx.Context) error { + if c.Header("X-Params") == "missing" { + return apierrors.ByCode("SAME") + } + return apierrors.ByCode("SAME").WithParams(map[string]any{"value": "{literal}"}).WithCause(private) + } + app.GET("/global", handler) + bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) }) + }) + app.HandleWithResponse("GET", "/route", response.RawJSON(response.MustWithErrors(local, response.MustNew(format))), handler, bridge) + for _, path := range []string{"/global", "/route"} { + for _, locale := range []string{"en", "zh-CN"} { + for _, mode := range []string{"", "auth", "panic", "missing"} { + r := httptest.NewRequest("GET", path, nil) + r.Header.Set("Accept-Language", locale) + r.Header.Set("X-Early", mode) + r.Header.Set("X-Params", mode) + w := httptest.NewRecorder() + app.ServeHTTP(w, r) + var body struct { + Error struct { + Reason string `json:"reason"` + ID json.Number `json:"id"` + } `json:"error"` + OK bool `json:"ok"` + Data any `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err, w.Body.String()) + } + ns, status := "app", 200 + if path == "/route" { + ns, status = "route", 409 + } + want := ns + ":{literal}" + if locale != "en" { + want = ns + "译文:{literal}" + } + if mode == "auth" { + status = 401 + want = "fixed {literal}" + if locale != "en" { + want = "需要认证" + } + } else if mode == "panic" || mode == "missing" { + status = 500 + want = "internal" + if locale != "en" { + want = "系统错误" + } + } + if w.Code != status || body.OK || body.Error.Reason != want || strings.Contains(w.Body.String(), "private-value") { + t.Fatal(path, locale, mode, w.Code, w.Body.String()) + } + if mode == "" && body.Error.ID.String() != "9223372036854775807" { + t.Fatal("public code changed", body.Error) + } + } + } + } + for _, shared := range []bool{false, true} { + cfg := middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "test", nil }} + if shared { + cfg.Store = &catalogStore{records: map[string]middleware.IdempotencyRecord{}} + } + idem, err := middleware.NewIdempotency(cfg) + if err != nil { + t.Fatal(err) + } + path := "/localReplay" + if shared { + path = "/sharedReplay" + } + calls := 0 + app.POST(path, func(c *httpx.Context) error { calls++; return handler(c) }, idem.Middleware()) + var first string + for _, locale := range []string{"en", "zh-CN"} { + r := httptest.NewRequest("POST", path, strings.NewReader(`{}`)) + r.Header.Set("Idempotency-Key", "same") + r.Header.Set("Accept-Language", locale) + w := httptest.NewRecorder() + app.ServeHTTP(w, r) + if locale == "en" { + first = w.Body.String() + } else if w.Body.String() != first || w.Header().Get("Idempotency-Replayed") != "true" { + t.Fatal("replay changed", w.Body.String(), first) + } + } + if calls != 1 { + t.Fatal("language retriggered business", calls) + } + } +} + +func TestI18nExplicitProtocolsAndConcurrentApps(t *testing.T) { + apps := []*omni.App{} + for _, ns := range []string{"one", "two"} { + c := localizedCatalog(ns, 200) + p, l := localizedOptions(t, c) + a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(l)) + defer a.Close() + a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") }) + a.HandleWithResponse("GET", "/ack", response.EncoderFunc(func(r response.Result) (response.Payload, error) { + if !r.IsError() || r.Message != "fixed {literal}" || r.PublicMessage() != "需要认证" { + t.Error(r) + } + return response.Text(200, "ACK"), nil + }), func(*httpx.Context) error { return apierrors.ByCode("FIXED") }) + a.GET("/direct", func(c *httpx.Context) error { return c.Text(200, "fixed-protocol") }) + apps = append(apps, a) + } + var wg sync.WaitGroup + for n := 0; n < 60; n++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + locale, want := "en", "fixed {literal}" + if n%2 == 0 { + locale, want = "zh-CN", "需要认证" + } + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Accept-Language", locale) + w := httptest.NewRecorder() + apps[n%len(apps)].ServeHTTP(w, r) + if w.Code != 401 || !strings.Contains(w.Body.String(), want) { + t.Error(w.Code, w.Body.String()) + } + }(n) + } + wg.Wait() + for _, path := range []string{"/ack", "/direct"} { + r := httptest.NewRequest("GET", path, nil) + r.Header.Set("Accept-Language", "zh-CN") + w := httptest.NewRecorder() + apps[0].ServeHTTP(w, r) + want := "ACK" + if path == "/direct" { + want = "fixed-protocol" + } + if w.Body.String() != want { + t.Fatal(w.Body.String()) + } + } +} + +type panickingLocalizer struct{} + +func (panickingLocalizer) Localize(context.Context, *apierrors.Registry, *apierrors.APIError) (string, bool) { + panic("presentation failed") +} +func TestI18nAdapterFailurePreservesBusiness(t *testing.T) { + c := localizedCatalog("test", 200) + a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithErrorLocalizer(panickingLocalizer{})) + defer a.Close() + a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") }) + w := httptest.NewRecorder() + a.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + if w.Code != 401 || !strings.Contains(w.Body.String(), "fixed {literal}") { + t.Fatal(w.Code, w.Body.String()) + } +} diff --git a/app/router.go b/app/router.go index 3a89642..e196d37 100644 --- a/app/router.go +++ b/app/router.go @@ -28,6 +28,9 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid } } return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if a.config.LanguagePolicy != nil { + request = a.config.LanguagePolicy.Apply(writer, request) + } request = request.WithContext(logx.WithLogger(contract.WithRequestPolicy(request.Context(), a.config.Request), a.config.LogInstance)) if len(a.config.TrustedProxies) > 0 { request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver)) @@ -40,6 +43,7 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid requestContext.SetErrorRegistry(provider.ErrorRegistry()) } requestContext.SetResponseEncoder(encoder) + requestContext.SetErrorLocalizer(a.config.ErrorLocalizer) defer func() { httpx.CleanupMultipart(requestContext.Request()) }() if err := wrapped(requestContext); err != nil { a.writeError(requestContext, err) diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index f486368..51b855e 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -1,5 +1,7 @@ # go-omni CLI +v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用同版 v0.26 CLI 与框架。 + v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。 公共字段组合、中间件工厂和按需 Repository 继续支持,见 [生成能力指南](../../GENERATION.md)。 @@ -14,7 +16,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目 ```sh # 仓库读取权限与 PATH 配置见根目录 README -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview go-omni new --module example.com/hello ./hello # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 @@ -49,14 +51,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节 ## 版本与迁移 -CLI 与框架依赖固定使用 v0.25.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: +CLI 与框架依赖固定使用 v0.26.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: ```sh # 在 go-omni 源码仓库中执行 go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello ``` -当前模板与依赖以 v0.25.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 +当前模板与依赖以 v0.26.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 ## 数据库迁移命令 diff --git a/cmd/go-omni/apigen/contractMetadata.go b/cmd/go-omni/apigen/contractMetadata.go index a50a3c4..75022bf 100644 --- a/cmd/go-omni/apigen/contractMetadata.go +++ b/cmd/go-omni/apigen/contractMetadata.go @@ -55,6 +55,7 @@ func prepareMetadata(original *Spec) (*Spec, error) { for i := range spec.Errors { spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code spec.Errors[i].Params = normalized.Definitions[spec.Errors[i].Code].Params + spec.Errors[i].Translation = normalized.Definitions[spec.Errors[i].Code].Translation } if err := validateErrorPolicies(&spec); err != nil { return nil, err diff --git a/cmd/go-omni/apigen/declarations.go b/cmd/go-omni/apigen/declarations.go index f96740f..d499824 100644 --- a/cmd/go-omni/apigen/declarations.go +++ b/cmd/go-omni/apigen/declarations.go @@ -11,14 +11,15 @@ import ( // ErrorDefinition declares a public, stable error. Generated constructors return // a fresh error each time so WithCause cannot mutate a shared singleton. type ErrorDefinition struct { - Name string `json:"name"` - BusinessCode any `json:"businessCode,omitempty"` - Params []apierrors.Parameter `json:"params,omitempty"` - Catalog string `json:"catalog,omitempty"` - Status int `json:"status"` - Code string `json:"code"` - Message string `json:"message"` - Position string `json:"-"` + Name string `json:"name"` + BusinessCode any `json:"businessCode,omitempty"` + Params []apierrors.Parameter `json:"params,omitempty"` + Translation *apierrors.Translation `json:"translation,omitempty"` + Catalog string `json:"catalog,omitempty"` + Status int `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + Position string `json:"-"` } type AuthDefinition struct { Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string diff --git a/cmd/go-omni/apigen/errorCatalog.go b/cmd/go-omni/apigen/errorCatalog.go index 56817d8..8c6d7a5 100644 --- a/cmd/go-omni/apigen/errorCatalog.go +++ b/cmd/go-omni/apigen/errorCatalog.go @@ -49,11 +49,12 @@ func (p *parser) parseErrorCatalog() error { } } type entry struct { - Key string `json:"key"` - Code any `json:"code"` - Message string `json:"message"` - Status int `json:"status"` - Params []apierrors.Parameter `json:"params"` + Key string `json:"key"` + Code any `json:"code"` + Message string `json:"message"` + Status int `json:"status"` + Params []apierrors.Parameter `json:"params"` + Translation *apierrors.Translation `json:"translation"` } var entries map[string]entry if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil { @@ -75,7 +76,7 @@ func (p *parser) parseErrorCatalog() error { if e.Status == 0 { e.Status = defaultStatus } - p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Catalog: name, Position: pos}) + p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Translation: e.Translation, Catalog: name, Position: pos}) } return nil } @@ -90,7 +91,7 @@ func errorCatalog(spec *Spec, names []string) apierrors.Catalog { if names != nil && !selected[e.Name] { continue } - c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params} + c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params, Translation: e.Translation} } return c } diff --git a/cmd/go-omni/apigen/generate.go b/cmd/go-omni/apigen/generate.go index 2db6825..97cf376 100644 --- a/cmd/go-omni/apigen/generate.go +++ b/cmd/go-omni/apigen/generate.go @@ -22,7 +22,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Runtime-only releases need not raise the managed template compatibility floor. const minimumVersion = "v0.22.0-preview" -const currentVersion = "v0.25.0-preview" +const currentVersion = "v0.26.0-preview" const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n" type Options struct { @@ -194,6 +194,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) { } seeds["go.mod"] = data } + capabilityModule := moduleData + if seeded, ok := seeds["go.mod"]; ok { + capabilityModule = seeded + } + if err := checkI18nRuntime(c.Spec, capabilityModule, directory); err != nil { + return Output{}, err + } if err = module.CheckImportPath(o.Module); err != nil { return Output{}, err } diff --git a/cmd/go-omni/apigen/i18nVersion.go b/cmd/go-omni/apigen/i18nVersion.go new file mode 100644 index 0000000..6d6379f --- /dev/null +++ b/cmd/go-omni/apigen/i18nVersion.go @@ -0,0 +1,57 @@ +package apigen + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/semver" +) + +// Translation metadata was introduced in this release. Consumers of older +// runtimes must upgrade or use an explicitly supported local replacement. +const i18nMinimumVersion = "v0.26.0-preview" + +func checkI18nRuntime(spec *Spec, moduleData []byte, directory string) error { + needed := false + for _, e := range spec.Errors { + needed = needed || e.Translation != nil + } + if !needed { + return nil + } + mf, err := modfile.Parse("go.mod", moduleData, nil) + if err != nil { + return err + } + version := "" + for _, r := range mf.Require { + if r.Mod.Path == frameworkModule { + version = r.Mod.Version + } + } + for _, r := range mf.Replace { + if r.Old.Path != frameworkModule || r.Old.Version != "" && r.Old.Version != version { + continue + } + if r.New.Version != "" { + version = r.New.Version + continue + } + root := r.New.Path + if !filepath.IsAbs(root) { + root = filepath.Join(directory, root) + } + data, e := os.ReadFile(filepath.Join(root, "errors", "localization.go")) + if e == nil && strings.Contains(string(data), "type Translation struct") { + return nil + } + return fmt.Errorf("translation metadata requires a local framework with errors.Translation support") + } + if semver.Compare(version, i18nMinimumVersion) < 0 { + return fmt.Errorf("translation metadata requires %s or newer; use --framework-dir for unpublished source", i18nMinimumVersion) + } + return nil +} diff --git a/cmd/go-omni/apigen/i18n_test.go b/cmd/go-omni/apigen/i18n_test.go new file mode 100644 index 0000000..afeb442 --- /dev/null +++ b/cmd/go-omni/apigen/i18n_test.go @@ -0,0 +1,42 @@ +package apigen + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestI18nTranslationMetadataAndVersionBoundary(t *testing.T) { + spec, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/i18n.api")) + if err != nil { + t.Fatal(err) + } + compiled, err := Compile(spec) + if err != nil { + t.Fatal(err) + } + ref := compiled.Spec.Errors[0].Translation + if ref == nil || ref.Namespace != "limits" || ref.Key != "exceeded" { + t.Fatal(ref) + } + if err := checkI18nRuntime(spec, []byte("module example.com/test\ngo 1.22\nrequire "+frameworkModule+" v0.25.0-preview\n"), t.TempDir()); err == nil { + t.Fatal("old runtime accepted") + } + generated, _, err := renderProject(Options{Module: "example.com/test"}, compiled, false) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(generated["internal/apierrors/errorsGen.go"]), "translation") || !strings.Contains(string(generated["docs/openapi.json"]), "translation") { + t.Fatal("translation metadata lost") + } + bad := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/i18n.api")) + bad = strings.Replace(bad, `"namespace":"limits"`, `"namespace":""`, 1) + parsed, err := parseText(t, bad) + if err == nil { + _, err = Compile(parsed) + } + if err == nil { + t.Fatal("invalid translation reference accepted") + } +} +func TestI18nGeneratedConsumer(t *testing.T) { testCatalogConsumer(t, "i18n.api", "i18n") } diff --git a/cmd/go-omni/apigen/testdata/i18n/consumer_test.go b/cmd/go-omni/apigen/testdata/i18n/consumer_test.go new file mode 100644 index 0000000..a5e66c5 --- /dev/null +++ b/cmd/go-omni/apigen/testdata/i18n/consumer_test.go @@ -0,0 +1,70 @@ +package main_test + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "testing" + + "example.com/errorCatalog/internal/apierrors" + "example.com/errorCatalog/internal/handler" + "example.com/errorCatalog/internal/svc" + omni "git.yuntuo.hk/k2safe/go-omni" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/i18n" + "git.yuntuo.hk/k2safe/go-omni/i18n/errori18n" + "git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n" + "git.yuntuo.hk/k2safe/go-omni/logx" +) + +func TestGeneratedTranslationAndParameterCompatibility(t *testing.T) { + catalog := apierrors.Catalog() + source, err := errori18n.Source(catalog, "en") + if err != nil { + t.Fatal(err) + } + source = append(source, i18n.Document{Version: 1, Locale: "zh-Hans", Namespace: "limits", Messages: map[string]i18n.Message{"exceeded": {Value: i18n.Pattern{Text: "上限:{max}"}}}}) + b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, source...) + if err != nil { + t.Fatal(err) + } + p, err := httpi18n.New(b) + if err != nil { + t.Fatal(err) + } + lc := logx.Default() + lc.Writer = io.Discard + logger, err := logx.New(lc) + if err != nil { + t.Fatal(err) + } + defer logger.Close() + deps, err := svc.New(context.Background(), logger) + if err != nil { + t.Fatal(err) + } + defer deps.Close() + a := omni.New(omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(errori18n.New(b, nil))) + defer a.Close() + a.Use(func(next httpx.Handler) httpx.Handler { + return func(c *httpx.Context) error { return apierrors.LimitExceeded(9223372036854775807) } + }) + if err := handler.RegisterRoutes(a, deps); err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("GET", "/value", nil) + r.Header.Set("Accept-Language", "zh-CN") + w := httptest.NewRecorder() + a.ServeHTTP(w, r) + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if w.Code != 200 || body["detail"] != "上限:9223372036854775807" || body["code"] != float64(400001) || body["ok"] != false { + t.Fatal(w.Code, body) + } + if apierrors.LimitExceeded(5).Message != "Limit: 5" { + t.Fatal("non-HTTP constructor changed") + } +} diff --git a/cmd/go-omni/apigen/workspaceCatalog_test.go b/cmd/go-omni/apigen/workspaceCatalog_test.go index ce2b1aa..c2cdf41 100644 --- a/cmd/go-omni/apigen/workspaceCatalog_test.go +++ b/cmd/go-omni/apigen/workspaceCatalog_test.go @@ -13,7 +13,7 @@ import ( func TestWorkspaceSharedGoCatalog(t *testing.T) { root := t.TempDir() - writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.24.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t)))) + writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.22.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t)))) writeFile(t, filepath.Join(root, "internal/apierrors/catalog.go"), `package apierrors import errors "git.yuntuo.hk/k2safe/go-omni/errors" func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{ diff --git a/cmd/go-omni/i18n.go b/cmd/go-omni/i18n.go new file mode 100644 index 0000000..f7bffcd --- /dev/null +++ b/cmd/go-omni/i18n.go @@ -0,0 +1,195 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/i18n" + "git.yuntuo.hk/k2safe/go-omni/i18n/errori18n" + "git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool" +) + +type i18nFlags []string + +func (v *i18nFlags) String() string { return strings.Join(*v, ",") } +func (v *i18nFlags) Set(s string) error { + if s == "" { + return errors.New("empty value") + } + *v = append(*v, s) + return nil +} + +func runI18n(args []string) error { return runI18nTo(args, os.Stdout) } +func runI18nTo(args []string, w io.Writer) error { + if len(args) == 0 { + return errors.New("expected check, export, types or extract") + } + fs := flag.NewFlagSet("i18n "+args[0], flag.ContinueOnError) + fs.SetOutput(io.Discard) + var resources, namespaces, goFiles i18nFlags + configPath := fs.String("config", "", "i18n Config JSON file") + locale := fs.String("locale", "", "selected/source locale") + catalog := fs.String("catalog", "", "exported errors.Catalog JSON (extract only)") + fs.Var(&resources, "resource", "resource JSON file (repeatable)") + fs.Var(&namespaces, "namespace", "export namespace (repeatable)") + fs.Var(&goFiles, "go", "Go source file (repeatable)") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if fs.NArg() != 0 { + return errors.New("unexpected positional arguments") + } + encode := func(v any) error { e := json.NewEncoder(w); e.SetIndent("", " "); return e.Encode(v) } + if args[0] == "extract" && *catalog != "" { + if len(goFiles) > 0 || len(resources) > 0 || *configPath != "" || len(namespaces) != 1 || *locale == "" { + return errors.New("catalog extraction requires --catalog, --locale and exactly one --namespace") + } + raw, err := readI18nFile(*catalog) + if err != nil { + return err + } + var c apierrors.Catalog + if err := apierrors.DecodeCatalogJSON(raw, &c); err != nil { + return err + } + docs, err := errori18n.Source(errori18n.Bind(c, namespaces[0]), *locale) + if err != nil { + return err + } + for _, doc := range docs { + if doc.Namespace == namespaces[0] { + return encode(doc) + } + } + return errors.New("catalog has no messages in the selected namespace") + } + if *catalog != "" { + return errors.New("--catalog is only supported by extract") + } + if args[0] == "extract" { + if len(goFiles) == 0 || len(resources) > 0 || len(namespaces) > 0 || *configPath != "" || *locale != "" { + return errors.New("Go extraction requires one or more --go files") + } + out := i18ntool.Extraction{References: []i18ntool.Reference{}, Findings: []i18ntool.Finding{}} + for _, path := range goFiles { + raw, err := readI18nFile(path) + if err != nil { + return err + } + result, err := i18ntool.ExtractGo(path, raw) + if err != nil { + return err + } + out.References = append(out.References, result.References...) + out.Findings = append(out.Findings, result.Findings...) + } + return encode(out) + } + if args[0] != "check" && args[0] != "export" && args[0] != "types" { + return errors.New("expected check, export, types or extract") + } + if *configPath == "" || len(resources) == 0 { + return errors.New("--config and at least one --resource are required") + } + data, err := readI18nFile(*configPath) + if err != nil { + return err + } + var cfg i18n.Config + if err := i18n.DecodeJSON(data, &cfg); err != nil { + return err + } + docs := []i18n.Document{} + total := 0 + for _, path := range resources { + raw, err := readI18nFile(path) + if err != nil { + return err + } + total += len(raw) + if total > 4<<20 { + return errors.New("resources exceed 4 MiB") + } + var doc i18n.Document + if err := i18n.DecodeJSON(raw, &doc); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + docs = append(docs, doc) + } + bundle, err := i18n.New(cfg, docs...) + if err != nil { + return err + } + switch args[0] { + case "export": + if len(goFiles) > 0 { + return errors.New("--go is only supported by check/extract") + } + return bundle.Export(w, *locale, namespaces...) + case "types": + if *locale != "" || len(goFiles) > 0 { + return errors.New("types does not accept --locale or --go") + } + return bundle.TypeScript(w, namespaces...) + case "check": + if *locale != "" || len(namespaces) > 0 { + return errors.New("check validates all configured locales and namespaces") + } + findings := bundle.Check() + for _, path := range goFiles { + raw, e := readI18nFile(path) + if e != nil { + return e + } + ex, e := i18ntool.ExtractGo(path, raw) + if e != nil { + return e + } + for _, r := range ex.References { + if _, ok := bundle.Schema(i18n.Ref{Namespace: r.Namespace, Key: r.Key}); !ok { + findings = append(findings, i18n.Diagnostic{Kind: "missing", Namespace: r.Namespace, Key: r.Key, Problem: fmt.Sprintf("referenced by %s:%d", r.File, r.Line)}) + } + } + for _, f := range ex.Findings { + findings = append(findings, i18n.Diagnostic{Kind: "dynamic", Problem: fmt.Sprintf("%s:%d: %s", f.File, f.Line, f.Problem)}) + } + } + if err := encode(findings); err != nil { + return err + } + if len(findings) > 0 { + return errors.New("internationalization checks failed") + } + return nil + } + return nil +} +func readI18nFile(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + s, err := f.Stat() + if err != nil { + return nil, err + } + if !s.Mode().IsRegular() { + return nil, errors.New("expected a regular file") + } + b, err := io.ReadAll(io.LimitReader(f, (1<<20)+1)) + if err != nil { + return nil, err + } + if len(b) > 1<<20 { + return nil, errors.New("file exceeds 1 MiB") + } + return b, nil +} diff --git a/cmd/go-omni/i18n_test.go b/cmd/go-omni/i18n_test.go new file mode 100644 index 0000000..260c680 --- /dev/null +++ b/cmd/go-omni/i18n_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "git.yuntuo.hk/k2safe/go-omni/i18n" +) + +func TestI18nCommandsAreReadOnlyAndCheckMissing(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "config.json": `{"sourceLocale":"en","defaultLocale":"en","locales":["en","fr"]}`, + "en.json": `{"version":1,"namespace":"ui","locale":"en","messages":{"hello":{"params":{"type":"string"},"value":{"text":"Hello {type}"}}}}`, + "fr.json": `{"version":1,"namespace":"ui","locale":"fr","messages":{"hello":{"value":{"text":"Bonjour {type}"}}}}`, + "use.go": "package demo\nimport words \"git.yuntuo.hk/k2safe/go-omni/i18n\"\nvar key=words.Ref{Namespace:\"ui\",Key:\"hello\"}\n", + } + for path, data := range files { + if err := os.WriteFile(filepath.Join(dir, path), []byte(data), 0600); err != nil { + t.Fatal(err) + } + } + base := []string{"--config", filepath.Join(dir, "config.json"), "--resource", filepath.Join(dir, "en.json"), "--resource", filepath.Join(dir, "fr.json")} + var out bytes.Buffer + args := append([]string{"check"}, base...) + args = append(args, "--go", filepath.Join(dir, "use.go")) + if err := runI18nTo(args, &out); err != nil || strings.TrimSpace(out.String()) != "[]" { + t.Fatal(err, out.String()) + } + out.Reset() + args = append([]string{"export"}, base...) + args = append(args, "--locale", "fr", "--namespace", "ui") + if err := runI18nTo(args, &out); err != nil { + t.Fatal(err) + } + var pack i18n.Pack + if err := json.Unmarshal(out.Bytes(), &pack); err != nil || pack.Locale != "fr" || len(pack.Documents) != 2 { + t.Fatal(err, pack) + } + out.Reset() + args = append([]string{"types"}, base...) + args = append(args, "--namespace", "ui") + if err := runI18nTo(args, &out); err != nil || !strings.Contains(out.String(), `"type": string`) { + t.Fatal(err, out.String()) + } + out.Reset() + if err := runI18nTo([]string{"extract", "--go", filepath.Join(dir, "use.go")}, &out); err != nil || !strings.Contains(out.String(), `"key": "hello"`) { + t.Fatal(err, out.String()) + } + for path, want := range files { + got, _ := os.ReadFile(filepath.Join(dir, path)) + if string(got) != want { + t.Fatal("command mutated", path) + } + } + out.Reset() + args = append([]string{"check"}, base[:4]...) + if err := runI18nTo(args, &out); err == nil || !strings.Contains(out.String(), "direct translation missing") { + t.Fatal(err, out.String()) + } +} + +func TestI18nCatalogExtractionPreservesLiteralBraces(t *testing.T) { + path := filepath.Join(t.TempDir(), "errors.json") + if err := os.WriteFile(path, []byte(`{"definitions":{"FIXED":{"code":1,"message":"${NAME}"}}}`), 0600); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if err := runI18nTo([]string{"extract", "--catalog", path, "--namespace", "errors", "--locale", "en"}, &out); err != nil { + t.Fatal(err) + } + var d i18n.Document + if err := i18n.DecodeJSON(out.Bytes(), &d); err != nil || !d.Messages["FIXED"].Value.Literal || d.Messages["FIXED"].Value.Text != "${NAME}" { + t.Fatal(err, d) + } +} diff --git a/cmd/go-omni/main.go b/cmd/go-omni/main.go index 8413d1a..18d39da 100644 --- a/cmd/go-omni/main.go +++ b/cmd/go-omni/main.go @@ -18,6 +18,11 @@ func main() { } switch os.Args[1] { + case "i18n": + if err := runI18n(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "go-omni i18n:", err) + os.Exit(1) + } case "workspace": if err := runWorkspace(os.Args[2:]); err != nil { fmt.Fprintln(os.Stderr, "go-omni workspace:", err) @@ -170,6 +175,7 @@ func usage() { fmt.Fprintln(os.Stderr, "GoOmni project tool") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Usage:") + fmt.Fprintln(os.Stderr, " go-omni i18n Validate and export independent language resources") fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]") fmt.Fprintln(os.Stderr, " go-omni new [--module ] ") fmt.Fprintln(os.Stderr, " go-omni new --api [--module ] ") diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 8d826a7..d8f7cb4 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" // Source builds can carry an unpublished VCS version (including +dirty). // Keep this fallback in sync with the next release; installed CLIs use their // downloaded module version, which also has a module checksum. -const defaultFrameworkVersion = "v0.25.0-preview" +const defaultFrameworkVersion = "v0.26.0-preview" // This target is not a claim that the release already exists. Source users // select a local framework checkout until the module templates are published. diff --git a/config/config.go b/config/config.go index 097f3b9..9670c1f 100644 --- a/config/config.go +++ b/config/config.go @@ -8,6 +8,7 @@ import ( "git.yuntuo.hk/k2safe/go-omni/cors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n" "git.yuntuo.hk/k2safe/go-omni/lifecycle" "git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/response" @@ -47,6 +48,8 @@ type Config struct { Response response.Config `json:"response"` ResponseEncoder response.Encoder `json:"-"` LogInstance *logx.Logger `json:"-"` + LanguagePolicy *httpi18n.Policy `json:"-"` + ErrorLocalizer apierrors.Localizer `json:"-"` } func Default() Config { @@ -253,6 +256,15 @@ func WithErrors(catalog apierrors.Catalog) Option { // WithLogInstance borrows a logger for this App without changing process logging. func WithLogInstance(logger *logx.Logger) Option { return func(c *Config) { c.LogInstance = logger } } +// WithLanguagePolicy borrows an immutable HTTP language policy. Resource loading +// remains explicit in the project; configuration strings are not dictionaries. +func WithLanguagePolicy(policy *httpi18n.Policy) Option { + return func(c *Config) { c.LanguagePolicy = policy } +} +func WithErrorLocalizer(localizer apierrors.Localizer) Option { + return func(c *Config) { c.ErrorLocalizer = localizer } +} + func WithRequestPolicy(policy contract.RequestPolicy) Option { return func(c *Config) { c.Request = policy } } diff --git a/contract/bind.go b/contract/bind.go index 2db0cc3..dd76be1 100644 --- a/contract/bind.go +++ b/contract/bind.go @@ -380,7 +380,10 @@ func decodeValue(dec *json.Decoder, depth int) (any, error) { object[name] = value } end, err := dec.Token() - if err != nil || end != json.Delim('}') { + if err != nil { + return nil, err + } + if end != json.Delim('}') { return nil, errors.New("unterminated object") } return object, nil @@ -394,7 +397,10 @@ func decodeValue(dec *json.Decoder, depth int) (any, error) { items = append(items, v) } end, err := dec.Token() - if err != nil || end != json.Delim(']') { + if err != nil { + return nil, err + } + if end != json.Delim(']') { return nil, errors.New("unterminated array") } return items, nil diff --git a/contract/policy_test.go b/contract/policy_test.go index 92aae29..2e6a68c 100644 --- a/contract/policy_test.go +++ b/contract/policy_test.go @@ -3,6 +3,7 @@ package contract import ( "bytes" "context" + "encoding/json" "errors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "io" @@ -10,6 +11,7 @@ import ( "net/http/httptest" "strings" "testing" + "testing/iotest" ) type byteReader struct{ r io.Reader } @@ -34,6 +36,17 @@ func TestBodyPolicyPropagatesOuterLimit(t *testing.T) { } } +func TestJSONClosingDelimiterPreservesReaderCause(t *testing.T) { + cause := errors.New("body reader failed") + for _, prefix := range []string{"{", "[", `{"value":[]`, `[{}`} { + reader := io.MultiReader(byteReader{strings.NewReader(prefix)}, iotest.ErrReader(cause)) + _, err := decodeValue(json.NewDecoder(reader), 0) + if !errors.Is(err, cause) { + t.Fatalf("prefix %q: lost reader cause: %v", prefix, err) + } + } +} + func TestStrictRequestPolicy(t *testing.T) { type request struct { Name string `json:"name"` diff --git a/errors/README.md b/errors/README.md index 6422d35..37a9f53 100644 --- a/errors/README.md +++ b/errors/README.md @@ -1,5 +1,7 @@ # 统一错误目录 +v0.26 新增的通用多语言适配见 [i18n](../i18n/README.md)。目录条目可显式引用 `translation: {namespace, key}`;`errori18n.Bind/Source` 支持从同一份 Go 目录生成源词典。翻译只影响公开展示,不改变业务码、HTTP 状态、失败语义、参数契约或 cause。没有引用或没有启用适配时沿用现有行为;幂等重放保留首次语言。该能力需要 v0.26.0-preview 或更新版本。 + 从 `v0.10.0-preview` 起,业务可以只返回逻辑错误标识,框架从项目目录取得 HTTP 状态、对外业务码与公开提示。目录不依赖具体业务、数据库、身份模型或网络层,也不定义固定返回体。 ## 基本规则 diff --git a/errors/catalog.go b/errors/catalog.go index ce62bac..416a41d 100644 --- a/errors/catalog.go +++ b/errors/catalog.go @@ -9,16 +9,19 @@ import ( "math" "reflect" "strings" + "unicode" + "unicode/utf8" ) // Definition belongs to the project. Code is the public string/integer code; // the Catalog map key is the distinct logical identifier used by ByCode. // Nil Code retains legacy response code mapping (useful when migrating). type Definition struct { - Status int `json:"status,omitempty"` - Code any `json:"code,omitempty"` - Message string `json:"message"` - Params []Parameter `json:"params,omitempty"` + Status int `json:"status,omitempty"` + Code any `json:"code,omitempty"` + Message string `json:"message"` + Params []Parameter `json:"params,omitempty"` + Translation *Translation `json:"translation,omitempty"` } type Catalog struct { @@ -99,6 +102,10 @@ func (c Catalog) Clone() Catalog { out := c out.Definitions = make(map[string]Definition, len(c.Definitions)) for key, d := range c.Definitions { + if d.Translation != nil { + ref := *d.Translation + d.Translation = &ref + } if d.Params != nil { params := make([]Parameter, len(d.Params)) copy(params, d.Params) @@ -126,6 +133,13 @@ func NewRegistry(c Catalog) (*Registry, error) { } messages := map[string][]messagePart{} for key, d := range c.Definitions { + if d.Translation != nil { + for _, value := range []string{d.Translation.Namespace, d.Translation.Key} { + if value == "" || len(value) > 256 || !utf8.ValidString(value) || strings.TrimSpace(value) != value || strings.IndexFunc(value, unicode.IsControl) >= 0 { + return nil, fmt.Errorf("errors: %s has invalid translation reference", key) + } + } + } if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || strings.ContainsAny(key, "\r\n\t") { return nil, fmt.Errorf("errors: invalid logical identifier %q", key) } diff --git a/errors/localization.go b/errors/localization.go new file mode 100644 index 0000000..3a84884 --- /dev/null +++ b/errors/localization.go @@ -0,0 +1,46 @@ +package apierrors + +import "context" + +// Translation is an explicit reference into an independently owned dictionary. +// It does not change the logical error key, status, public code or parameters. +type Translation struct { + Namespace string `json:"namespace"` + Key string `json:"key"` +} + +// Localizer is an optional presentation adapter. false preserves the original +// public message. Implementations must support concurrent calls and must not +// mutate registry or failure, or expose causes/parameter values in diagnostics. +type Localizer interface { + Localize(context.Context, *Registry, *APIError) (string, bool) +} + +// Definition returns detached metadata for the effective catalog scope. +func (r *Registry) Definition(key string) (Definition, bool) { + if r == nil { + return Definition{}, false + } + d, ok := r.catalog.Definitions[key] + if d.Params != nil { + d.Params = append([]Parameter{}, d.Params...) + } + if d.Translation != nil { + ref := *d.Translation + d.Translation = &ref + } + return d, ok +} + +// Parameters returns detached public template arguments. This is for explicit +// presentation adapters, not automatic serialization into an API response. +func (e *APIError) Parameters() map[string]any { + if e == nil { + return nil + } + out := make(map[string]any, len(e.params)) + for k, v := range e.params { + out[k] = v + } + return out +} diff --git a/examples/contracts/i18n.api b/examples/contracts/i18n.api new file mode 100644 index 0000000..cf05413 --- /dev/null +++ b/examples/contracts/i18n.api @@ -0,0 +1,34 @@ +syntax = "v1" + +errorCatalog CommonErrors ( + defaultStatus: 200 + errors: { + "LimitExceeded": { + "key": "LIMIT_EXCEEDED", "code": 400001, + "message": "Limit: {max}", + "params": [{"name":"max","type":"int64","example":100}], + "translation": {"namespace":"limits","key":"exceeded"} + } + } +) + +response CommonResponse ( + codeType: int32 + successCode: 0 + errorCode: 9999 + body: {"code":"${code}", "detail":"${message}", "ok":"${ok}"} +) + +type ValueResponse { + Value string `json:"value"` +} + +@server ( + group: limits + response: CommonResponse + errors: LimitExceeded +) +service localized-api { + @handler value + get /value returns (ValueResponse) +} diff --git a/examples/i18n/README.md b/examples/i18n/README.md new file mode 100644 index 0000000..9852e95 --- /dev/null +++ b/examples/i18n/README.md @@ -0,0 +1,28 @@ +# 独立国际化、HTTP 和前端示例 + +不依赖数据库、其他项目或外部服务。默认作为 CLI 运行;同一 Bundle 也接入 HTTP 错误和前端导出。 + +```sh +go run ./examples/i18n +go run ./examples/i18n -serve 127.0.0.1:8098 +``` + +访问 `http://127.0.0.1:8098/`,输入草稿并切换语言。只更新标题、标签和按钮文字;输入内容、提交次数和表单节点保留。浏览器模拟 `/locales/zh-Hans.json` 加载失败时保留当前语言;快速连续切换不会被旧加载覆盖。 + +```sh +curl -i -H 'Accept-Language: zh-CN' http://127.0.0.1:8098/api/limit +``` + +返回 HTTP 409,业务码 400001,中文公开提示;改成 `en` 时只改变展示文案。静态页面和语言包使用直接输出,不受公共响应格式包装。 + +项目管理普通 UI 词典;错误源文案只在 Go Catalog 中维护,`errori18n.Source` 在装配时提供源词典,JSON 只维护中文译文。所有语言包显式嵌入,不受进程工作目录或环境变量影响。 + +```sh +go run ./examples/i18n -export-locale zh-Hans +go run ./examples/i18n -catalog > /tmp/omni-i18n-errors.json +go run ./cmd/go-omni i18n extract --catalog /tmp/omni-i18n-errors.json \ + --locale en --namespace errors +go test ./examples/i18n +``` + +前端 Pack 只导出 `ui` 命名空间,不把错误业务码、HTTP 状态、内部 cause 或未选择的文案混进去。更多检查与类型导出命令见 [通用说明](../../i18n/README.md)。 diff --git a/examples/i18n/config.json b/examples/i18n/config.json new file mode 100644 index 0000000..2dca932 --- /dev/null +++ b/examples/i18n/config.json @@ -0,0 +1,6 @@ +{ + "sourceLocale": "en", + "defaultLocale": "en", + "locales": ["en", "zh-Hans"], + "fallbacks": {"zh-Hans": ["en"]} +} diff --git a/examples/i18n/index.html b/examples/i18n/index.html new file mode 100644 index 0000000..148b7d4 --- /dev/null +++ b/examples/i18n/index.html @@ -0,0 +1,46 @@ + + + + +GoOmni language example + +
+ +

Language example

+
+ + + +
+

+

+
+ + diff --git a/examples/i18n/locales/errors.zh-Hans.json b/examples/i18n/locales/errors.zh-Hans.json new file mode 100644 index 0000000..eed104a --- /dev/null +++ b/examples/i18n/locales/errors.zh-Hans.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "namespace": "errors", + "locale": "zh-Hans", + "messages": { + "LIMIT_EXCEEDED": {"value": {"text": "数量不能超过 {max}"}} + } +} diff --git a/examples/i18n/locales/ui.en.json b/examples/i18n/locales/ui.en.json new file mode 100644 index 0000000..7e66deb --- /dev/null +++ b/examples/i18n/locales/ui.en.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "namespace": "ui", + "locale": "en", + "messages": { + "title": {"value": {"text": "Language example"}}, + "draft": {"value": {"text": "Your draft"}}, + "submit": {"value": {"text": "Submit demo"}}, + "greeting": {"params": {"name": "string"}, "value": {"text": "Hello, {name}"}}, + "submitted": {"params": {"count": "integer"}, "value": {"plural": "count", "cases": { + "one": {"text": "Submitted {count} time"}, + "other": {"text": "Submitted {count} times"} + }}} + } +} diff --git a/examples/i18n/locales/ui.zh-Hans.json b/examples/i18n/locales/ui.zh-Hans.json new file mode 100644 index 0000000..60d955c --- /dev/null +++ b/examples/i18n/locales/ui.zh-Hans.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "namespace": "ui", + "locale": "zh-Hans", + "messages": { + "title": {"value": {"text": "语言切换示例"}}, + "draft": {"value": {"text": "你的草稿"}}, + "submit": {"value": {"text": "提交演示"}}, + "greeting": {"value": {"text": "你好,{name}"}}, + "submitted": {"value": {"plural": "count", "cases": {"other": {"text": "已提交 {count} 次"}}}} + } +} diff --git a/examples/i18n/main.go b/examples/i18n/main.go new file mode 100644 index 0000000..eac2bf9 --- /dev/null +++ b/examples/i18n/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "bytes" + "embed" + "flag" + "fmt" + "io/fs" + "os" + + omni "git.yuntuo.hk/k2safe/go-omni" + apierrors "git.yuntuo.hk/k2safe/go-omni/errors" + "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/i18n" + browseri18n "git.yuntuo.hk/k2safe/go-omni/i18n/browser" + "git.yuntuo.hk/k2safe/go-omni/i18n/errori18n" + "git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n" + "git.yuntuo.hk/k2safe/go-omni/response" +) + +//go:embed config.json locales/*.json index.html +var resources embed.FS + +func catalog() apierrors.Catalog { + return errori18n.Bind(apierrors.Catalog{Definitions: map[string]apierrors.Definition{ + "LIMIT_EXCEEDED": {Status: 409, Code: 400001, Message: "Count must not exceed {max}", Params: []apierrors.Parameter{{Name: "max", Type: "int64"}}}, + }}, "errors") +} + +func bundle() (*i18n.Bundle, error) { + raw, err := resources.ReadFile("config.json") + if err != nil { + return nil, err + } + var cfg i18n.Config + if err := i18n.DecodeJSON(raw, &cfg); err != nil { + return nil, err + } + docs, err := i18n.LoadFS(resources, "locales/ui.en.json", "locales/ui.zh-Hans.json", "locales/errors.zh-Hans.json") + if err != nil { + return nil, err + } + source, err := errori18n.Source(catalog(), cfg.SourceLocale) + if err != nil { + return nil, err + } + docs = append(docs, source...) + return i18n.New(cfg, docs...) +} + +func application(b *i18n.Bundle) (*omni.App, error) { + policy, err := httpi18n.New(b) + if err != nil { + return nil, err + } + localizer := errori18n.New(b, nil) + if err := localizer.Validate(catalog()); err != nil { + return nil, err + } + a := omni.New(omni.WithErrors(catalog()), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999}), omni.WithLanguagePolicy(policy), omni.WithErrorLocalizer(localizer)) + a.GET("/", func(c *httpx.Context) error { + data, err := resources.ReadFile("index.html") + if err != nil { + return err + } + return c.Bytes(200, "text/html; charset=utf-8", data) + }) + a.GET("/runtime.mjs", func(c *httpx.Context) error { + data, err := fs.ReadFile(browseri18n.Files(), "runtime.mjs") + if err != nil { + return err + } + return c.Bytes(200, "text/javascript; charset=utf-8", data) + }) + a.GET("/locales/:file", func(c *httpx.Context) error { + var locale string + switch c.Param("file") { + case "en.json": + locale = "en" + case "zh-Hans.json": + locale = "zh-Hans" + default: + return apierrors.NotFound("NOT_FOUND", "language pack not found") + } + var out bytes.Buffer + if err := b.Export(&out, locale, "ui"); err != nil { + return err + } + return c.Bytes(200, "application/json; charset=utf-8", out.Bytes()) + }) + a.GET("/api/limit", func(*httpx.Context) error { + return apierrors.ByCode("LIMIT_EXCEEDED").WithParams(map[string]any{"max": int64(100)}) + }) + a.POST("/api/action", func(c *httpx.Context) error { return c.OK(map[string]bool{"accepted": true}) }) + return a, nil +} + +func main() { + serve := flag.String("serve", "", "optional local HTTP listen address") + locale := flag.String("export-locale", "", "export a UI language pack to stdout") + exportCatalog := flag.Bool("catalog", false, "export the Go error catalog to stdout") + flag.Parse() + if err := run(*serve, *locale, *exportCatalog); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +func run(serve, locale string, exportCatalog bool) error { + if exportCatalog { + return apierrors.ExportCatalog(os.Stdout, catalog()) + } + b, err := bundle() + if err != nil { + return err + } + if locale != "" { + return b.Export(os.Stdout, locale, "ui") + } + if serve != "" { + a, err := application(b) + if err != nil { + return err + } + defer a.Close() + return a.Run(serve) + } + for _, l := range b.Config().Locales { + result, err := b.Render(l, i18n.Ref{Namespace: "ui", Key: "greeting"}, map[string]any{"name": "Alex"}) + if err != nil { + return err + } + fmt.Println(result.Locale + ": " + result.Text) + } + return nil +} diff --git a/examples/i18n/main_test.go b/examples/i18n/main_test.go new file mode 100644 index 0000000..ae08072 --- /dev/null +++ b/examples/i18n/main_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "net/http/httptest" + "strings" + "testing" +) + +func TestIndependentExampleAndHTTP(t *testing.T) { + b, err := bundle() + if err != nil { + t.Fatal(err) + } + if missing := b.Check(); len(missing) != 0 { + t.Fatal(missing) + } + a, err := application(b) + if err != nil { + t.Fatal(err) + } + defer a.Close() + for _, test := range []struct { + path, part string + status int + }{{"/api/limit", "数量不能超过 100", 409}, {"/locales/zh-Hans.json", `"namespace": "ui"`, 200}, {"/runtime.mjs", "createLanguageController", 200}, {"/", "draft", 200}} { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", test.path, nil) + r.Header.Set("Accept-Language", "zh-CN") + a.ServeHTTP(w, r) + if w.Code != test.status || !strings.Contains(w.Body.String(), test.part) { + t.Fatal(test.path, w.Code, w.Body.String()) + } + } +} diff --git a/examples/managedService/go.mod b/examples/managedService/go.mod index b45226a..d51b964 100644 --- a/examples/managedService/go.mod +++ b/examples/managedService/go.mod @@ -2,7 +2,7 @@ module example.com/managed go 1.22.0 -require git.yuntuo.hk/k2safe/go-omni v0.25.0-preview +require git.yuntuo.hk/k2safe/go-omni v0.26.0-preview require ( github.com/gabriel-vasile/mimetype v1.4.3 // indirect diff --git a/examples/userService/go.mod b/examples/userService/go.mod index 77094a0..7c6d1c4 100644 --- a/examples/userService/go.mod +++ b/examples/userService/go.mod @@ -3,7 +3,7 @@ module example.com/userService go 1.22.0 require ( - git.yuntuo.hk/k2safe/go-omni v0.25.0-preview + git.yuntuo.hk/k2safe/go-omni v0.26.0-preview github.com/go-sql-driver/mysql v1.9.3 ) diff --git a/go.mod b/go.mod index c92bc7c..0daf91d 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( go.opentelemetry.io/otel/trace v1.31.0 golang.org/x/mod v0.21.0 golang.org/x/net v0.33.0 + golang.org/x/text v0.21.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 @@ -48,6 +49,5 @@ require ( go.uber.org/atomic v1.7.0 // indirect golang.org/x/crypto v0.31.0 // indirect golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.5.0 // indirect ) diff --git a/httpx/context.go b/httpx/context.go index bd82fe1..d4a8525 100644 --- a/httpx/context.go +++ b/httpx/context.go @@ -88,15 +88,16 @@ func RoutePattern(ctx context.Context) string { // only standard-library HTTP types, so it remains usable when the network // implementation is replaced. type Context struct { - request *http.Request - writer http.ResponseWriter - params map[string]string - values map[string]any - responded bool - status int - encoder response.Encoder - errorRegistry *apierrors.Registry - failure *apierrors.APIError + request *http.Request + writer http.ResponseWriter + params map[string]string + values map[string]any + responded bool + status int + encoder response.Encoder + errorRegistry *apierrors.Registry + errorLocalizer apierrors.Localizer + failure *apierrors.APIError } // NewContext creates a transport-neutral GoOmni context. @@ -136,6 +137,7 @@ func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Contex clone := NewContext(c.request.Clone(ctx), writer, c.params) clone.encoder = c.encoder clone.errorRegistry = c.errorRegistry + clone.errorLocalizer = c.errorLocalizer for key, value := range c.values { clone.values[key] = value } @@ -301,16 +303,20 @@ func (c *Context) WriteError(err error) { // SetErrorRegistry sets an application fallback. Route encoders can supply their // own nonempty registry; an encoder without a registry inherits this fallback. // Clones retain both policies without any process-global lookup. -func (c *Context) SetErrorRegistry(r *apierrors.Registry) { c.errorRegistry = r } -func (c *Context) ResolveError(err error) *apierrors.APIError { +func (c *Context) SetErrorRegistry(r *apierrors.Registry) { c.errorRegistry = r } +func (c *Context) SetErrorLocalizer(l apierrors.Localizer) { c.errorLocalizer = l } + +// ErrorRegistry returns the effective route or application error scope. +func (c *Context) ErrorRegistry() *apierrors.Registry { registry := c.errorRegistry if provider, ok := c.encoder.(response.ErrorRegistryProvider); ok { if scoped := provider.ErrorRegistry(); scoped != nil { registry = scoped } } - return registry.Resolve(err) + return registry } +func (c *Context) ResolveError(err error) *apierrors.APIError { return c.ErrorRegistry().Resolve(err) } // RecordError preserves failure semantics when a middleware commits buffered bytes. func (c *Context) RecordError(err error) { c.failure = c.ResolveError(err) } diff --git a/httpx/response.go b/httpx/response.go index ad073f5..87d3a8e 100644 --- a/httpx/response.go +++ b/httpx/response.go @@ -53,6 +53,15 @@ func (c *Context) writeResult(result response.Result) (err error) { if encoder == nil { encoder = response.Default() } + if c.errorLocalizer != nil && result.Err != nil { + if message, ok := c.localizedMessage(result.Err); ok { + result.MessageOverride = &message + if c.failure != nil { + c.failure = c.failure.Clone() + c.failure.Message = message + } + } + } payload, err := encoder.Encode(result) if err != nil { return apierrors.Internal(err) @@ -69,6 +78,17 @@ func (c *Context) writeResult(result response.Result) (err error) { return c.WriteResponse(payload.Status, payload.Header, payload.Body) } +// Presentation adapter failures cannot turn a business result into a new error. +func (c *Context) localizedMessage(err error) (message string, ok bool) { + defer func() { + if recover() != nil { + message, ok = "", false + } + }() + registry := c.ErrorRegistry() + return c.errorLocalizer.Localize(c.Context(), registry, registry.Resolve(err)) +} + // RespondStatus uses the current response format and error catalog, but keeps // the explicit HTTP status even if a catalog/custom encoder selects another. // This is useful for protocol endpoints such as health probes. Ordinary diff --git a/i18n/README.md b/i18n/README.md new file mode 100644 index 0000000..8b81616 --- /dev/null +++ b/i18n/README.md @@ -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、表单或业务请求的隐式操作。 + +升级本模块时必须一起验证资源格式、规则版本、前后端一致性、错误兼容和生成消费者。现有项目不启用这两个适配选项时,响应与错误行为保持原样。 diff --git a/i18n/browser/README.md b/i18n/browser/README.md new file mode 100644 index 0000000..5735ae6 --- /dev/null +++ b/i18n/browser/README.md @@ -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 +``` diff --git a/i18n/browser/assets.go b/i18n/browser/assets.go new file mode 100644 index 0000000..a1a644a --- /dev/null +++ b/i18n/browser/assets.go @@ -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 } diff --git a/i18n/browser/conformance.mjs b/i18n/browser/conformance.mjs new file mode 100644 index 0000000..dd97016 --- /dev/null +++ b/i18n/browser/conformance.mjs @@ -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); diff --git a/i18n/browser/runtime.d.mts b/i18n/browser/runtime.d.mts new file mode 100644 index 0000000..c34f6c4 --- /dev/null +++ b/i18n/browser/runtime.d.mts @@ -0,0 +1,29 @@ +export type Kind = "string" | "boolean" | "integer" | "number"; +export interface Pattern { text?: string; literal?: boolean; select?: string; plural?: string; cases?: Record; } +export interface Message { params?: Record; value: Pattern; } +export interface Document { version: 1; namespace: string; locale: string; messages: Record; } +export interface Configuration { sourceLocale: string; defaultLocale: string; locales: string[]; fallbacks?: Record; } +export interface Pack { + version: 1; + locale: string; + config: Configuration; + documents: Document[]; + ruleVersion: "cldr-46.1"; + pluralRules: Record>>; + 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): Rendered; + t(namespace: string, key: string, params?: Record): 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): { + readonly current: Localizer; + setLanguage(locale: string): Promise; +}; +export function fetchPack(url: string | URL, options?: { signal?: AbortSignal; fetch?: typeof globalThis.fetch }): Promise; diff --git a/i18n/browser/runtime.mjs b/i18n/browser/runtime.mjs new file mode 100644 index 0000000..40331b0 --- /dev/null +++ b/i18n/browser/runtime.mjs @@ -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"); } +} diff --git a/i18n/browser/runtime.test.mjs b/i18n/browser/runtime.test.mjs new file mode 100644 index 0000000..cd515c8 --- /dev/null +++ b/i18n/browser/runtime.test.mjs @@ -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: "{name}" }), "Hello {name}"); + 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)) })); +}); diff --git a/i18n/browser/testdata/consumer.mts b/i18n/browser/testdata/consumer.mts new file mode 100644 index 0000000..03b6f06 --- /dev/null +++ b/i18n/browser/testdata/consumer.mts @@ -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'}}}}]); diff --git a/i18n/bundle.go b/i18n/bundle.go new file mode 100644 index 0000000..4365851 --- /dev/null +++ b/i18n/bundle.go @@ -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 +} diff --git a/i18n/bundle_test.go b/i18n/bundle_test.go new file mode 100644 index 0000000..c9d8960 --- /dev/null +++ b/i18n/bundle_test.go @@ -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) + } + } +} diff --git a/i18n/cldr/LICENSE b/i18n/cldr/LICENSE new file mode 100644 index 0000000..f56d93a --- /dev/null +++ b/i18n/cldr/LICENSE @@ -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 diff --git a/i18n/cldr/README.md b/i18n/cldr/README.md new file mode 100644 index 0000000..6d816d5 --- /dev/null +++ b/i18n/cldr/README.md @@ -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. diff --git a/i18n/cldr/plurals.json b/i18n/cldr/plurals.json new file mode 100644 index 0000000..c082ae8 --- /dev/null +++ b/i18n/cldr/plurals.json @@ -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, …" + } + } + } +} diff --git a/i18n/errori18n/errors.go b/i18n/errori18n/errors.go new file mode 100644 index 0000000..3113c9d --- /dev/null +++ b/i18n/errori18n/errors.go @@ -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 +} diff --git a/i18n/errori18n/errors_test.go b/i18n/errori18n/errors_test.go new file mode 100644 index 0000000..9661f87 --- /dev/null +++ b/i18n/errori18n/errors_test.go @@ -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") + } +} diff --git a/i18n/export.go b/i18n/export.go new file mode 100644 index 0000000..4bdd569 --- /dev/null +++ b/i18n/export.go @@ -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;\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 = >(namespace: N, key: K, params: Extract>) => string;\n") + _, err := io.WriteString(w, out.String()) + return err +} diff --git a/i18n/httpi18n/http.go b/i18n/httpi18n/http.go new file mode 100644 index 0000000..5ea0376 --- /dev/null +++ b/i18n/httpi18n/http.go @@ -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)) }) +} diff --git a/i18n/httpi18n/http_test.go b/i18n/httpi18n/http_test.go new file mode 100644 index 0000000..b5008e9 --- /dev/null +++ b/i18n/httpi18n/http_test.go @@ -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") + } +} diff --git a/i18n/i18ntool/extract.go b/i18n/i18ntool/extract.go new file mode 100644 index 0000000..357f06e --- /dev/null +++ b/i18n/i18ntool/extract.go @@ -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 +} diff --git a/i18n/i18ntool/extract_test.go b/i18n/i18ntool/extract_test.go new file mode 100644 index 0000000..da28095 --- /dev/null +++ b/i18n/i18ntool/extract_test.go @@ -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) + } +} diff --git a/i18n/load.go b/i18n/load.go new file mode 100644 index 0000000..4412a86 --- /dev/null +++ b/i18n/load.go @@ -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 +} diff --git a/i18n/pattern.go b/i18n/pattern.go new file mode 100644 index 0000000..dc78654 --- /dev/null +++ b/i18n/pattern.go @@ -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 +} diff --git a/i18n/plural.go b/i18n/plural.go new file mode 100644 index 0000000..eaf9f3a --- /dev/null +++ b/i18n/plural.go @@ -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 +} diff --git a/i18n/plural_test.go b/i18n/plural_test.go new file mode 100644 index 0000000..0ad7c67 --- /dev/null +++ b/i18n/plural_test.go @@ -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) + } + } +} diff --git a/i18n/types.go b/i18n/types.go new file mode 100644 index 0000000..dd76249 --- /dev/null +++ b/i18n/types.go @@ -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 +} diff --git a/omni.go b/omni.go index e53f2b3..833de02 100644 --- a/omni.go +++ b/omni.go @@ -9,6 +9,7 @@ import ( "git.yuntuo.hk/k2safe/go-omni/cors" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" + "git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n" "git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/metrics" "git.yuntuo.hk/k2safe/go-omni/response" @@ -56,6 +57,11 @@ func WithLogger(enabled bool) Option { return config.WithLogger(enabled) } func WithErrors(catalog apierrors.Catalog) Option { return config.WithErrors(catalog) } +func WithLanguagePolicy(policy *httpi18n.Policy) Option { return config.WithLanguagePolicy(policy) } +func WithErrorLocalizer(localizer apierrors.Localizer) Option { + return config.WithErrorLocalizer(localizer) +} + func WithResponse(settings response.Config) Option { return config.WithResponse(settings) } func WithResponseEncoder(encoder response.Encoder) Option { return config.WithResponseEncoder(encoder) } diff --git a/reports/i18nAcceptance.md b/reports/i18nAcceptance.md new file mode 100644 index 0000000..bde412d --- /dev/null +++ b/reports/i18nAcceptance.md @@ -0,0 +1,75 @@ +# v0.26 通用国际化验收(2026-09-29) + +交付范围:独立不可变词典、可选 HTTP/错误适配、CLI 检查/提取/导出、TypeScript 类型和独立浏览器运行时。普通消息、错误源文案、启用语言、用户偏好、资源提供方式均由项目维护;示例不依赖数据库或其他项目。 + +## 已通过的验证 + +环境:macOS / Apple M2 Max,Go 1.27.1,Node.js 22.15.1,TypeScript 5.6.3;最低版本另用 Go 1.22.12 核验。TypeScript 编译器通过 npm 临时缓存使用,没有添加项目 npm 依赖。 + +| 验证 | 结果 | +| --- | --- | +| `go test -race ./...` | 54 个有测试的包通过;未启用集成环境变量的生成消费者单独执行如下 | +| `go vet ./...`、`go build ./...`、`git diff --check` | 通过 | +| `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test ./cmd/go-omni/apigen -run 'TestI18nGeneratedConsumer\|TestErrorInheritance\|TestErrorParamsConsumer' -count=1 -v` | 3 组真实生成项目生成、重复生成、check 和消费者 race 测试通过 | +| Go 1.22.12:`go test ./i18n/... ./app ./errors ./response ./httpx ./config ./cmd/go-omni ./examples/i18n` | 全部通过 | +| Go 1.22.12:开启集成测试,`go test ./cmd/go-omni/apigen -run TestI18n -count=1 -v` | translation 元数据边界、真实生成消费者通过 | +| `node --test i18n/browser/runtime.test.mjs` | 4 组通过,包含覆盖、并发切换、最新失败/过期请求、坏包与读取上限 | +| `TestPinnedCLDRSamples` | 5,777 个上游普通基数规则样例端点通过;不把省略号和紧凑数字样例计入 | +| `TestBrowserConformance`、`TestSourceLocaleCanBeFallbackOnly` | Go 导出包在 Node 中执行,中文/英文/俄文/阿拉伯文/法文、精确 int64、小数、回退与源语言未启用通过;cmn 别名及私用标签不由浏览器改写 | +| `TestTypeScriptConsumer`(PATH 提供 tsc) | strict / ES2022 / NodeNext 编译通过;缺参、错类型、多余参数、未知键/命名空间必须产生编译诊断 | +| `go test ./i18n -run '^$' -fuzz FuzzDecodeJSON -fuzztime 10s` | 419,121 次执行通过 | +| 示例 CLI、词典 check、示例 HTTP 测试 | 通过;示例中英文输出及中文错误 HTTP 409 / code 400001 保持 | + +最后的 TypeScript 类型修正后,重新执行了 `i18n/...`、CLI、示例 race 测试及对应 Go 1.22 验证。核心一致性及类型验证可复现为: + +```sh +npm exec --yes --package typescript@5.6.3 -- go test ./i18n \ + -run 'TestTypeScriptConsumer|TestPinnedCLDRSamples|TestBrowserConformance|TestSourceLocaleCanBeFallbackOnly' \ + -count=1 -v +``` + +原始日志见 [i18nValidation.txt](i18nValidation.txt)。 + +## 兼容和隔离覆盖 + +- 多 Bundle、多 App、并发请求与多个命名空间不共享可变语言状态;构建输入与导出快照修改不影响实例。 +- 项目目录和路由独立目录同名错误正确选取各自翻译;固定旧文案的字面花括号保留。 +- 翻译仅进入展示层。HTTP 200 业务失败、HTTP 409/401/500、int64 业务码、嵌套响应模板、旧 Messages 校验、RawJSON 错误、认证提前返回、panic、缺参数和缓冲桥接均有回归。 +- 参数值不重新解释为模板,内部 cause 不进入响应;Localizer 或偏好 Resolver panic 不把语言故障传播成新的业务执行结果。 +- 自定义固定回执由原 Encoder 控制;直接 Text/JSON 等输出不自动改写。自定义 Encoder 可显式采用 PublicMessage。 +- 本地/共享幂等重放保留首次响应,即使后续请求语言不同也只执行业务一次。 +- 资源严格校验、循环回退、参数契约不一致、缺键诊断、显式覆盖、CLI 输入不被改写、旧 runtime 能力门槛均有测试。 + +## 浏览器实测 + +使用独立示例启动本机 `127.0.0.1:8098`,执行了: + +1. 输入测试草稿,从英文切为中文:草稿保留,提交次数为 0。 +2. 点击提交:次数变为 1。切回英文:草稿保留,次数仍为 1。 +3. 停止示例服务后再选择中文:显示加载失败,仍停留在英文,草稿和一次提交保留。 + +示例服务和临时浏览器页面已关闭。乱序和快速重复选择由浏览器运行时自动测试验证。 + +## Go 1.22 发布前兼容性修复 + +初轮全仓 Go 1.22 验收暴露了两处旧问题,发布准备阶段已修复: + +- 共享 Go 目录测试工程的 go.mod 从 1.24.0 调整为框架声明支持的 1.22.0,远程验收的同类测试工程同步调整。 +- JSON 对象/数组结束定界符读取失败时保留底层错误,外层 MaxBytesReader 的限额错误继续被识别为 HTTP 413。新增对象、数组、嵌套结构的 reader cause 回归,原有 limit=0/1/4 测试保留。 + +最初发现问题的日志仍保留在 i18nValidation.txt 中,便于追溯;发布前的最终结果单独记录在 i18nReleaseValidation.txt 中。 + +## 发布前最终验收 + +- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:54 个有测试的包通过,包含实际生成消费者;PATH 提供 TypeScript 5.6.3,跨端和类型测试实际执行。 +- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test ./...`:全仓通过,先前两处失败已消除。 +- `go vet ./...`、`go build ./...`、浏览器运行时测试、发布脚本语法检查、`git diff --check`:通过。 +- `python3 scripts/verifyManagedProject.py --report reports/i18nManagedLocal.json`:真实生成项目运行、构建、重复生成、nethttp/native 信号退出、上传下载与故障清理通过;临时进程已清理。[机器记录](i18nManagedLocal.json)。 + +完整最终输出见 [i18nReleaseValidation.txt](i18nReleaseValidation.txt)。发布后再从空 module 缓存安装固定标签,验证没有本地 replace;远程报告独立提交,不移动发布标签。 + +## 性能与发布边界 + +`BenchmarkRender` 对俄文基数复数、22 参数的单条消息,当前机器一次测量为 3,753 ns/op、1,040 B/op、27 allocs/op;这只是开发机样本,不代表 SLA 或与旧实现的性能对比。 + +本次按 Go module 标签 `v0.26.0-preview` 发布;浏览器运行时随 module 分发,没有单独发布 npm 包。CLDR 46.1.0 数据随源码和导出包携带许可,启动不下载规则。v1 支持普通文本、命名参数、select 和基数复数;不是完整 ICU MessageFormat,不自动处理金额、日期、时区、RTL 布局或业务字段翻译。 diff --git a/reports/i18nManagedLocal.json b/reports/i18nManagedLocal.json new file mode 100644 index 0000000..f57c481 --- /dev/null +++ b/reports/i18nManagedLocal.json @@ -0,0 +1,61 @@ +{ + "passed": true, + "mode": "localSource", + "durationSeconds": 11.57, + "checks": [ + "rootGoRun", + "rootGoBuild", + "stableRegeneration", + "checkMainOwnership", + "contractChangePreview", + "logicSignatureCheckPersists", + "businessSourcePreserved", + "fixedConsumerBuild", + "nethttpSIGINT", + "nethttpSIGTERM", + "nativeSIGINT", + "nativeSIGTERM", + "listenerReleased", + "temporaryProcessesRemoved", + "nethttpGeneratedTransfers", + "nativeGeneratedTransfers", + "malformedMultipart400", + "goOwnedErrors", + "default404And405" + ], + "sourceHashes": { + "cmd/go-omni/apigen/generate.go": "2305b168701c4564f83d680fc62e6f5a3589f4a3d7e78e66d82a6328bf0a6a89", + "cmd/go-omni/apigen/templates.go": "17b3c76390eb965a580f595ca1de0e98b584a13c729d5e2726baa8a94e24b977", + "cmd/go-omni/apigen/startup.go": "e3322fa3e2aff9f45d66427f44187535c7c0960edc0f5fdfd5d7b4943b349334", + "cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94", + "cmd/go-omni/apigen/contractChanges.go": "aeb83990e908738faaa3cdfa20c3b8938023db05b6e55f2d24352c44144056a8", + "cmd/go-omni/apigen/logicCheck.go": "ca1af4f876a3a0db7b7b9ba9b82c1d6399fd89d29f667dad39cfd3de526eb1cb", + "cmd/go-omni/apigen/write.go": "7dcdac99760eaa16ec4f729998a2c29c0bf84eefbda229bd5c2d850dd4fc0646", + "cmd/go-omni/api.go": "5203a17c5f66977a9eba838d61cf5a7356de53d5f72a1196fd4f2046aa43ac85", + "config/config.go": "f8007526a9ebbbc781b2e27e37a49ec768deb568d56846a6ccc62b059a012fd0", + "httpx/transfer.go": "c9fe4e98c0e599d367eaf19ebecf367db914edfca9ad3a65527496d1dea80a3d", + "httpx/multipart.go": "b942ede7a810b268cd43b395c3bddcfaaacad92812bbb60e653466b8d86bc875", + "scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18", + "lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31", + "health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866", + "config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d", + "httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd", + "app/app.go": "528bd1a9194389d469cd8ace2206366184626a84d626fc935914aa2d75088c71", + "app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73", + "cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150", + "cmd/go-omni/scaffold/templates/api-config.go.tmpl": "7638869e0bf47f33b3d0b6f9f64d1413a6beb0cdb986f6d65ec68bf477bfd2e7", + "cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969", + "cmd/go-omni/scaffold/templates/api-close.go.tmpl": "3f22d272e92f3b64b62123b3d80ec1a6ce8834257d64752fd75facf12623902b", + "cmd/go-omni/scaffold/templates/api-databaseOptions.go.tmpl": "6e5395b4a787c77a6cabfd85f16049a39578d6ec751a9b78203f3a03baabdf28", + "cmd/go-omni/scaffold/templates/api-options.go.tmpl": "11fe34ce45e8c98f7b5c9c0a2c512eb0b3abf86f95b4b576e083bcb6f5d0f824", + "cmd/go-omni/scaffold/templates/api-context.go.tmpl": "6cf67d37ef64f388b23a98781c3ee3abb71a2e0b9e71eeb70f1e5492ff7e32b4", + "cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708", + "cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247", + "cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167", + "cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a", + "cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe", + "cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319", + "cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1", + "cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1" + } +} diff --git a/reports/i18nReleaseValidation.txt b/reports/i18nReleaseValidation.txt new file mode 100644 index 0000000..a3cd0df --- /dev/null +++ b/reports/i18nReleaseValidation.txt @@ -0,0 +1,194 @@ +=== Go 1.27.1 full race including generated consumers and TypeScript === +ok git.yuntuo.hk/k2safe/go-omni (cached) +ok git.yuntuo.hk/k2safe/go-omni/app (cached) +ok git.yuntuo.hk/k2safe/go-omni/audit (cached) +ok git.yuntuo.hk/k2safe/go-omni/auth (cached) +ok git.yuntuo.hk/k2safe/go-omni/cache (cached) +ok git.yuntuo.hk/k2safe/go-omni/cache/redis (cached) +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 4.178s +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 85.649s +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 43.902s +ok git.yuntuo.hk/k2safe/go-omni/config (cached) +ok git.yuntuo.hk/k2safe/go-omni/contract 2.065s +ok git.yuntuo.hk/k2safe/go-omni/copyx (cached) +ok git.yuntuo.hk/k2safe/go-omni/cors (cached) +ok git.yuntuo.hk/k2safe/go-omni/database (cached) +ok git.yuntuo.hk/k2safe/go-omni/database/gormx (cached) +ok git.yuntuo.hk/k2safe/go-omni/database/migrate (cached) +ok git.yuntuo.hk/k2safe/go-omni/database/mysql (cached) +ok git.yuntuo.hk/k2safe/go-omni/errors (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/compatibility [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/configuration (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/copying (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/cors [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/developerTools [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/httpClient (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/i18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/modules (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/platform (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/reference (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/transactions [no test files] +ok git.yuntuo.hk/k2safe/go-omni/health (cached) +ok git.yuntuo.hk/k2safe/go-omni/httpc (cached) +ok git.yuntuo.hk/k2safe/go-omni/httpx (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n 3.128s +? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files] +ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool (cached) +? git.yuntuo.hk/k2safe/go-omni/internal/configdiag [no test files] +ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck (cached) +? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files] +? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files] +ok git.yuntuo.hk/k2safe/go-omni/lifecycle (cached) +ok git.yuntuo.hk/k2safe/go-omni/logx (cached) +ok git.yuntuo.hk/k2safe/go-omni/metrics (cached) +ok git.yuntuo.hk/k2safe/go-omni/middleware (cached) +ok git.yuntuo.hk/k2safe/go-omni/middleware/redis (cached) +ok git.yuntuo.hk/k2safe/go-omni/openapi (cached) +ok git.yuntuo.hk/k2safe/go-omni/pagination (cached) +? git.yuntuo.hk/k2safe/go-omni/queue [no test files] +ok git.yuntuo.hk/k2safe/go-omni/queue/redis (cached) +ok git.yuntuo.hk/k2safe/go-omni/resilience (cached) +ok git.yuntuo.hk/k2safe/go-omni/response (cached) +? git.yuntuo.hk/k2safe/go-omni/scripts/corsBrowser [no test files] +ok git.yuntuo.hk/k2safe/go-omni/storage (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/local (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/oss (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/s3 (cached) +? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files] +ok git.yuntuo.hk/k2safe/go-omni/task (cached) +ok git.yuntuo.hk/k2safe/go-omni/telemetry (cached) +ok git.yuntuo.hk/k2safe/go-omni/transport (cached) +ok git.yuntuo.hk/k2safe/go-omni/transport/http2 (cached) +? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files] +ok git.yuntuo.hk/k2safe/go-omni/transport/native (cached) +ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp (cached) +? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files] +? git.yuntuo.hk/k2safe/go-omni/validate [no test files] + +=== Go 1.22.12 full suite including generated consumers === +ok git.yuntuo.hk/k2safe/go-omni (cached) +ok git.yuntuo.hk/k2safe/go-omni/app (cached) +ok git.yuntuo.hk/k2safe/go-omni/audit (cached) +ok git.yuntuo.hk/k2safe/go-omni/auth (cached) +ok git.yuntuo.hk/k2safe/go-omni/cache (cached) +ok git.yuntuo.hk/k2safe/go-omni/cache/redis (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/compatibility [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/cors [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/developerTools [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/transactions [no test files] +? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files] +? git.yuntuo.hk/k2safe/go-omni/internal/configdiag [no test files] +? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files] +? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files] +? git.yuntuo.hk/k2safe/go-omni/queue [no test files] +? git.yuntuo.hk/k2safe/go-omni/scripts/corsBrowser [no test files] +? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files] +? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files] +? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files] +? git.yuntuo.hk/k2safe/go-omni/validate [no test files] +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 3.709s +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 77.621s +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 54.857s +ok git.yuntuo.hk/k2safe/go-omni/config (cached) +ok git.yuntuo.hk/k2safe/go-omni/contract 1.242s +ok git.yuntuo.hk/k2safe/go-omni/copyx (cached) +ok git.yuntuo.hk/k2safe/go-omni/cors (cached) +ok git.yuntuo.hk/k2safe/go-omni/database (cached) +ok git.yuntuo.hk/k2safe/go-omni/database/gormx (cached) +ok git.yuntuo.hk/k2safe/go-omni/database/migrate (cached) +ok git.yuntuo.hk/k2safe/go-omni/database/mysql (cached) +ok git.yuntuo.hk/k2safe/go-omni/errors (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/configuration (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/copying (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/httpClient (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/i18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/modules (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/platform (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/reference (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository (cached) +ok git.yuntuo.hk/k2safe/go-omni/health (cached) +ok git.yuntuo.hk/k2safe/go-omni/httpc (cached) +ok git.yuntuo.hk/k2safe/go-omni/httpx (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool (cached) +ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck (cached) +ok git.yuntuo.hk/k2safe/go-omni/lifecycle (cached) +ok git.yuntuo.hk/k2safe/go-omni/logx (cached) +ok git.yuntuo.hk/k2safe/go-omni/metrics (cached) +ok git.yuntuo.hk/k2safe/go-omni/middleware (cached) +ok git.yuntuo.hk/k2safe/go-omni/middleware/redis (cached) +ok git.yuntuo.hk/k2safe/go-omni/openapi (cached) +ok git.yuntuo.hk/k2safe/go-omni/pagination (cached) +ok git.yuntuo.hk/k2safe/go-omni/queue/redis (cached) +ok git.yuntuo.hk/k2safe/go-omni/resilience (cached) +ok git.yuntuo.hk/k2safe/go-omni/response (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/local (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/oss (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/s3 (cached) +ok git.yuntuo.hk/k2safe/go-omni/task (cached) +ok git.yuntuo.hk/k2safe/go-omni/telemetry (cached) +ok git.yuntuo.hk/k2safe/go-omni/transport (cached) +ok git.yuntuo.hk/k2safe/go-omni/transport/http2 (cached) +ok git.yuntuo.hk/k2safe/go-omni/transport/native (cached) +ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp (cached) + +=== go vet ./... === +(no diagnostics) + +=== go build ./... === +(no diagnostics) + +=== Node browser runtime tests === +TAP version 13 +# Subtest: detached resources, explicit overrides, text arguments and fallback +ok 1 - detached resources, explicit overrides, text arguments and fallback + --- + duration_ms: 2.926334 + type: 'test' + ... +# Subtest: latest selection wins and failures preserve committed state +ok 2 - latest selection wins and failures preserve committed state + --- + duration_ms: 0.386666 + type: 'test' + ... +# Subtest: bad packs cannot commit or change enabled languages/schemas +ok 3 - bad packs cannot commit or change enabled languages/schemas + --- + duration_ms: 0.266708 + type: 'test' + ... +# Subtest: bounded fetching and validation before switching +ok 4 - bounded fetching and validation before switching + --- + duration_ms: 12.806416 + type: 'test' + ... +1..4 +# tests 4 +# suites 0 +# pass 4 +# fail 0 +# cancelled 0 +# skipped 0 +# todo 0 +# duration_ms 62.584417 + +=== Local generated process acceptance === +PASS generated root project: go run/build, regeneration, nethttp/native signals; temporary files removed diff --git a/reports/i18nValidation.txt b/reports/i18nValidation.txt new file mode 100644 index 0000000..6413869 --- /dev/null +++ b/reports/i18nValidation.txt @@ -0,0 +1,241 @@ +=== Go 1.27.1 full repository race === +ok git.yuntuo.hk/k2safe/go-omni 2.110s +ok git.yuntuo.hk/k2safe/go-omni/app 3.749s +ok git.yuntuo.hk/k2safe/go-omni/audit (cached) +ok git.yuntuo.hk/k2safe/go-omni/auth 2.176s +ok git.yuntuo.hk/k2safe/go-omni/cache (cached) +ok git.yuntuo.hk/k2safe/go-omni/cache/redis (cached) +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 4.648s +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 29.880s +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold (cached) +ok git.yuntuo.hk/k2safe/go-omni/config 2.829s +ok git.yuntuo.hk/k2safe/go-omni/contract (cached) +ok git.yuntuo.hk/k2safe/go-omni/copyx (cached) +ok git.yuntuo.hk/k2safe/go-omni/cors (cached) +ok git.yuntuo.hk/k2safe/go-omni/database 2.260s +ok git.yuntuo.hk/k2safe/go-omni/database/gormx (cached) +ok git.yuntuo.hk/k2safe/go-omni/database/migrate (cached) +ok git.yuntuo.hk/k2safe/go-omni/database/mysql (cached) +ok git.yuntuo.hk/k2safe/go-omni/errors 3.366s +? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/compatibility [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/configuration 3.039s +ok git.yuntuo.hk/k2safe/go-omni/examples/copying (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/cors [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/developerTools [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/httpClient (cached) +ok git.yuntuo.hk/k2safe/go-omni/examples/i18n 3.686s +ok git.yuntuo.hk/k2safe/go-omni/examples/modules 4.007s +ok git.yuntuo.hk/k2safe/go-omni/examples/platform 4.383s +ok git.yuntuo.hk/k2safe/go-omni/examples/reference 4.158s +? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files] +ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository (cached) +? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/transactions [no test files] +ok git.yuntuo.hk/k2safe/go-omni/health 3.842s +ok git.yuntuo.hk/k2safe/go-omni/httpc (cached) +ok git.yuntuo.hk/k2safe/go-omni/httpx 3.850s +ok git.yuntuo.hk/k2safe/go-omni/i18n 3.384s +? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files] +ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n 3.169s +ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n 3.277s +ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool 3.480s +? git.yuntuo.hk/k2safe/go-omni/internal/configdiag [no test files] +ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 3.467s +? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files] +? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files] +ok git.yuntuo.hk/k2safe/go-omni/lifecycle (cached) +ok git.yuntuo.hk/k2safe/go-omni/logx (cached) +ok git.yuntuo.hk/k2safe/go-omni/metrics 3.470s +ok git.yuntuo.hk/k2safe/go-omni/middleware 3.697s +ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 3.573s +ok git.yuntuo.hk/k2safe/go-omni/openapi 3.448s +ok git.yuntuo.hk/k2safe/go-omni/pagination (cached) +? git.yuntuo.hk/k2safe/go-omni/queue [no test files] +ok git.yuntuo.hk/k2safe/go-omni/queue/redis (cached) +ok git.yuntuo.hk/k2safe/go-omni/resilience (cached) +ok git.yuntuo.hk/k2safe/go-omni/response 3.564s +? git.yuntuo.hk/k2safe/go-omni/scripts/corsBrowser [no test files] +ok git.yuntuo.hk/k2safe/go-omni/storage (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/local (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/oss (cached) +ok git.yuntuo.hk/k2safe/go-omni/storage/s3 (cached) +? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files] +ok git.yuntuo.hk/k2safe/go-omni/task (cached) +ok git.yuntuo.hk/k2safe/go-omni/telemetry 2.867s +ok git.yuntuo.hk/k2safe/go-omni/transport 3.380s +ok git.yuntuo.hk/k2safe/go-omni/transport/http2 (cached) +? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files] +ok git.yuntuo.hk/k2safe/go-omni/transport/native 4.302s +ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 3.669s +? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files] +? git.yuntuo.hk/k2safe/go-omni/validate [no test files] + +=== Go 1.27.1 vet === +(no diagnostics) + +=== Go 1.27.1 build === +(no diagnostics) + +=== Generated consumers === +=== RUN TestErrorInheritanceConsumer +--- PASS: TestErrorInheritanceConsumer (4.65s) +=== RUN TestErrorParamsConsumer +--- PASS: TestErrorParamsConsumer (3.93s) +=== RUN TestI18nGeneratedConsumer +--- PASS: TestI18nGeneratedConsumer (2.18s) +PASS +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 12.002s + +=== Go 1.22 full repository: TWO KNOWN FAILURES === +ok git.yuntuo.hk/k2safe/go-omni 0.601s +ok git.yuntuo.hk/k2safe/go-omni/app 3.748s +ok git.yuntuo.hk/k2safe/go-omni/audit 2.939s +ok git.yuntuo.hk/k2safe/go-omni/auth 0.770s +ok git.yuntuo.hk/k2safe/go-omni/cache 1.306s +ok git.yuntuo.hk/k2safe/go-omni/cache/redis 2.414s +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 6.033s +? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/compatibility [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/cors [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/developerTools [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files] +? git.yuntuo.hk/k2safe/go-omni/examples/transactions [no test files] +? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files] +? git.yuntuo.hk/k2safe/go-omni/internal/configdiag [no test files] +? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files] +? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files] +? git.yuntuo.hk/k2safe/go-omni/queue [no test files] +? git.yuntuo.hk/k2safe/go-omni/scripts/corsBrowser [no test files] +? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files] +? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files] +? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files] +? git.yuntuo.hk/k2safe/go-omni/validate [no test files] +--- FAIL: TestWorkspaceSharedGoCatalog (0.24s) + workspaceCatalog_test.go:36: workspace catalog example.com/catalogs/internal/apierrors.Catalog: build failed (exit status 1); check module dependencies and func() errors.Catalog signature +FAIL +FAIL git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 33.292s +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 4.926s +ok git.yuntuo.hk/k2safe/go-omni/config 4.921s +--- FAIL: TestBodyPolicyPropagatesOuterLimit (0.00s) + policy_test.go:32: limit 1: expected 413, got INVALID_REQUEST: request parameters are invalid: unterminated object +FAIL +FAIL git.yuntuo.hk/k2safe/go-omni/contract 5.102s +ok git.yuntuo.hk/k2safe/go-omni/copyx 4.564s +ok git.yuntuo.hk/k2safe/go-omni/cors 5.632s +ok git.yuntuo.hk/k2safe/go-omni/database 5.107s +ok git.yuntuo.hk/k2safe/go-omni/database/gormx 5.540s +ok git.yuntuo.hk/k2safe/go-omni/database/migrate 4.580s +ok git.yuntuo.hk/k2safe/go-omni/database/mysql 2.955s +ok git.yuntuo.hk/k2safe/go-omni/errors 3.089s +ok git.yuntuo.hk/k2safe/go-omni/examples/configuration 3.126s +ok git.yuntuo.hk/k2safe/go-omni/examples/copying 3.375s +ok git.yuntuo.hk/k2safe/go-omni/examples/httpClient 3.375s +ok git.yuntuo.hk/k2safe/go-omni/examples/i18n 4.191s +ok git.yuntuo.hk/k2safe/go-omni/examples/modules 3.972s +ok git.yuntuo.hk/k2safe/go-omni/examples/platform 3.265s +ok git.yuntuo.hk/k2safe/go-omni/examples/reference 3.128s +ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 3.181s +ok git.yuntuo.hk/k2safe/go-omni/health 3.212s +ok git.yuntuo.hk/k2safe/go-omni/httpc 3.472s +ok git.yuntuo.hk/k2safe/go-omni/httpx 3.782s +ok git.yuntuo.hk/k2safe/go-omni/i18n 3.603s +ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n 3.390s +ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n 3.279s +ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool 2.712s +ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 2.535s +ok git.yuntuo.hk/k2safe/go-omni/lifecycle 2.721s +ok git.yuntuo.hk/k2safe/go-omni/logx 2.725s +ok git.yuntuo.hk/k2safe/go-omni/metrics 2.734s +ok git.yuntuo.hk/k2safe/go-omni/middleware 2.605s +ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 2.344s +ok git.yuntuo.hk/k2safe/go-omni/openapi 1.869s +ok git.yuntuo.hk/k2safe/go-omni/pagination 2.125s +ok git.yuntuo.hk/k2safe/go-omni/queue/redis 2.273s +ok git.yuntuo.hk/k2safe/go-omni/resilience 2.439s +ok git.yuntuo.hk/k2safe/go-omni/response 2.687s +ok git.yuntuo.hk/k2safe/go-omni/storage 3.068s +ok git.yuntuo.hk/k2safe/go-omni/storage/local 3.291s +ok git.yuntuo.hk/k2safe/go-omni/storage/oss 3.284s +ok git.yuntuo.hk/k2safe/go-omni/storage/s3 0.837s +ok git.yuntuo.hk/k2safe/go-omni/task 1.183s +ok git.yuntuo.hk/k2safe/go-omni/telemetry 1.391s +ok git.yuntuo.hk/k2safe/go-omni/transport 0.320s +ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.096s +ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.570s +ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.642s +FAIL + +=== Go 1.22 feature and integration packages === +? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files] +ok git.yuntuo.hk/k2safe/go-omni/i18n 0.894s +ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool (cached) +ok git.yuntuo.hk/k2safe/go-omni/app (cached) +ok git.yuntuo.hk/k2safe/go-omni/errors (cached) +ok git.yuntuo.hk/k2safe/go-omni/response (cached) +ok git.yuntuo.hk/k2safe/go-omni/httpx (cached) +ok git.yuntuo.hk/k2safe/go-omni/config (cached) +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 3.552s +ok git.yuntuo.hk/k2safe/go-omni/examples/i18n (cached) + +=== Go 1.22 i18n generated consumer === +=== RUN TestI18nTranslationMetadataAndVersionBoundary +--- PASS: TestI18nTranslationMetadataAndVersionBoundary (0.01s) +=== RUN TestI18nGeneratedConsumer +--- PASS: TestI18nGeneratedConsumer (2.43s) +PASS +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 2.866s + +=== CLDR / Go-browser / TypeScript conformance === +=== RUN TestPinnedCLDRSamples + plural_test.go:52: validated 5777 upstream cardinal samples +--- PASS: TestPinnedCLDRSamples (0.00s) +=== RUN TestSourceLocaleCanBeFallbackOnly +=== RUN TestSourceLocaleCanBeFallbackOnly/fr +=== RUN TestSourceLocaleCanBeFallbackOnly/cmn +=== RUN TestSourceLocaleCanBeFallbackOnly/x-example +--- PASS: TestSourceLocaleCanBeFallbackOnly (0.10s) + --- PASS: TestSourceLocaleCanBeFallbackOnly/fr (0.04s) + --- PASS: TestSourceLocaleCanBeFallbackOnly/cmn (0.03s) + --- PASS: TestSourceLocaleCanBeFallbackOnly/x-example (0.03s) +=== RUN TestBrowserConformance +--- PASS: TestBrowserConformance (0.16s) +=== RUN TestTypeScriptConsumer +--- PASS: TestTypeScriptConsumer (0.55s) +PASS +ok git.yuntuo.hk/k2safe/go-omni/i18n 1.325s + +=== Bounded JSON fuzz === +fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed +fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 12 workers +fuzz: elapsed: 3s, execs: 264525 (88141/sec), new interesting: 179 (total: 180) +fuzz: elapsed: 6s, execs: 264525 (0/sec), new interesting: 179 (total: 180) +fuzz: elapsed: 9s, execs: 413057 (49511/sec), new interesting: 186 (total: 187) +fuzz: elapsed: 11s, execs: 419121 (2995/sec), new interesting: 192 (total: 193) +PASS +ok git.yuntuo.hk/k2safe/go-omni/i18n 11.341s + +=== Development-machine benchmark === +goos: darwin +goarch: arm64 +pkg: git.yuntuo.hk/k2safe/go-omni/i18n +cpu: Apple M2 Max +BenchmarkRender-12 623985 3753 ns/op 1040 B/op 27 allocs/op +PASS +ok git.yuntuo.hk/k2safe/go-omni/i18n 2.745s + +=== Final changed-package race including TypeScript === +ok git.yuntuo.hk/k2safe/go-omni/i18n 2.518s +? git.yuntuo.hk/k2safe/go-omni/i18n/browser [no test files] +ok git.yuntuo.hk/k2safe/go-omni/i18n/errori18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n (cached) +ok git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool (cached) +ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 4.929s +ok git.yuntuo.hk/k2safe/go-omni/examples/i18n (cached) diff --git a/response/config.go b/response/config.go index 01a34af..52a18da 100644 --- a/response/config.go +++ b/response/config.go @@ -187,6 +187,9 @@ func (f *Formatter) Value(r Result) map[string]any { if isError && r.BusinessCode != nil { code, message = r.BusinessCode, r.Message } + if r.MessageOverride != nil { + message = *r.MessageOverride + } body := c.Body if isError && c.ErrorBody != nil { body = c.ErrorBody diff --git a/response/encoder.go b/response/encoder.go index 569f387..8fccffa 100644 --- a/response/encoder.go +++ b/response/encoder.go @@ -16,6 +16,10 @@ type Result struct { Err error BusinessCode any Failed bool + // MessageOverride is optional localized presentation. Message remains the + // canonical catalog text for validation. Custom protocols opt in by calling + // PublicMessage; fixed acknowledgments remain under their encoder's control. + MessageOverride *string // PreserveStatus keeps an explicit protocol status through catalog mapping // and HTTP output (for example, readiness must remain 503 when unhealthy). PreserveStatus bool @@ -24,6 +28,13 @@ type Result struct { // IsError preserves failure semantics independently of the HTTP status. func (r Result) IsError() bool { return r.Failed || r.Err != nil || r.Status >= 400 } +func (r Result) PublicMessage() string { + if r.MessageOverride != nil { + return *r.MessageOverride + } + return r.Message +} + // Payload is a complete response. Encoders must be safe for concurrent use. // Status and headers are intentionally part of the contract: callback protocols // may acknowledge an error using a different HTTP status or media type. diff --git a/scripts/verifyManagedRelease.py b/scripts/verifyManagedRelease.py index 0d8455f..8e9d460 100644 --- a/scripts/verifyManagedRelease.py +++ b/scripts/verifyManagedRelease.py @@ -22,7 +22,7 @@ def verify_shared_catalog(cli, temp, env, version): workspace = temp / "workspace" (workspace / "internal/apierrors").mkdir(parents=True) (workspace / "contracts").mkdir() - (workspace / "go.mod").write_text(f"module example.com/releaseWorkspace\n\ngo 1.24.0\nrequire {MODULE} {version}\n") + (workspace / "go.mod").write_text(f"module example.com/releaseWorkspace\n\ngo 1.22.0\nrequire {MODULE} {version}\n") provider = workspace / "internal/apierrors/catalog.go" provider.write_text('package apierrors\nimport errors "' + MODULE + '/errors"\n' 'func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{' @@ -124,7 +124,7 @@ def main(): checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"] env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version) - tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer", "TestGeneratedCORSConsumer"] + tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer", "TestGeneratedCORSConsumer", "TestI18nGeneratedConsumer"] print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True) output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env) events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] @@ -162,7 +162,11 @@ def main(): "copyx": {"TestCheckTypesAndEmptyCollections", "TestCheckRulesAndConverterBoundaries", "TestCopierCheckConcurrentAndOptionsIsolation"}, "logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"}, "cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"}, - "app": {"TestAppCORSRoutingAndErrors", "TestAppCORSConfigIsolation", "TestCORSTransports"}, + "app": {"TestAppCORSRoutingAndErrors", "TestAppCORSConfigIsolation", "TestCORSTransports", "TestI18nRouteScopesEarlyFailuresAndReplay", "TestI18nExplicitProtocolsAndConcurrentApps", "TestI18nAdapterFailurePreservesBusiness"}, + "contract": {"TestBodyPolicyPropagatesOuterLimit", "TestJSONClosingDelimiterPreservesReaderCause"}, + "i18n": {"TestIndependentImmutableBundlesAndFallback", "TestResourceAndParameterDiagnostics", "TestStrictLoadingAndExplicitOverlay", "TestPinnedCLDRSamples", "TestBrowserConformance", "TestSourceLocaleCanBeFallbackOnly", "TestTypeScriptConsumer"}, + "i18n/httpi18n": {"TestNegotiationAndExplicitResolvers"}, + "i18n/errori18n": {"TestCatalogAdapterIsExplicitAndDetached"}, "database": {"TestInTxStagesAndCauses", "TestInTxCommitOutcomeUnknown", "TestInTxCancellationAndOwnership", "TestInTxCancellationRace", "TestInTxSuccessPanicAndConcurrency", "TestInTxPublicErrorCompatibility"}, }.items(): output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/" + package], starter, env) @@ -179,6 +183,30 @@ def main(): "remoteCancellationOutcomeUnknown", "remoteTransactionPanicAndConcurrency", "remoteTransactionPublicErrors", "remoteTransactionExample"] + downloaded = Path(dependency["Dir"]) + example = downloaded / "examples/i18n" + resources = ["--config", example / "config.json", "--resource", example / "locales/ui.en.json", + "--resource", example / "locales/ui.zh-Hans.json"] + assert json.loads(run([cli, "i18n", "check", *resources], starter, env)) == [] + pack = json.loads(run([cli, "i18n", "export", *resources, "--locale", "zh-Hans", "--namespace", "ui"], starter, env)) + assert pack["locale"] == "zh-Hans" and pack["ruleVersion"] == "cldr-46.1" + assert "UNICODE LICENSE" in pack["license"] + assert {doc["namespace"] for doc in pack["documents"]} == {"ui"} + types = run([cli, "i18n", "types", *resources, "--namespace", "ui"], starter, env) + assert "export type Translate" in types and "bigint" in types + extracted = json.loads(run([cli, "i18n", "extract", "--go", example / "main.go"], starter, env)) + assert any(ref["namespace"] == "ui" and ref["key"] == "greeting" for ref in extracted["references"]) + catalog_path = temp / "catalog.json" + catalog_path.write_text(run(["go", "run", MODULE + "/examples/i18n", "-catalog"], starter, env)) + source = json.loads(run([cli, "i18n", "extract", "--catalog", catalog_path, "--locale", "en", "--namespace", "errors"], starter, env)) + assert source["messages"]["LIMIT_EXCEEDED"]["params"] == {"max": "integer"} + browser_output = run(["node", "--test", downloaded / "i18n/browser/runtime.test.mjs"], starter, env) + assert "# fail 0" in browser_output + checks += ["remoteI18nRuntimeRace", "remoteI18nGeneratedConsumer", "remoteI18nHTTPBusinessInvariants", + "remoteI18nSourceOnlyFallback", "remoteCLDRSamplesAndBrowserConformance", "remoteTypeScriptConsumer", + "remoteI18nCLIExtractionCheckExportTypes", "remoteI18nNamespaceIsolation", + "remoteBrowserRaceAndFailureRetention", "remoteJSONReaderCausePreservation"] + checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"] checks += ["generatedUnifiedConfig", "jsonYamlXmlAndCustomDecoder", "configValidationBeforeDependencies", "configEnvProvenance", "configExtensionPreserved", "configLoaderDriftCheck"] @@ -201,6 +229,8 @@ def main(): sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cors").glob("*.go")] sources += ["app/cors_test.go", "cmd/go-omni/apigen/corsConsumer_test.go", "cmd/go-omni/apigen/testdata/cors/consumer_test.go", "examples/cors/main.go", "examples/cors/config.yaml", "scripts/corsBrowser/main.go"] sources += ["database/transaction.go", "database/transaction_test.go", "database/transactionResponse_test.go", "database/mysqlConnector_test.go", "examples/transactions/main.go", "examples/transactions/demoDriver.go"] + sources += [str(p.relative_to(ROOT)) for directory in ("i18n", "examples/i18n") for p in (ROOT / directory).rglob("*") if p.is_file()] + sources += ["app/i18n_test.go", "errors/localization.go", "cmd/go-omni/i18n.go", "cmd/go-omni/apigen/i18nVersion.go", "cmd/go-omni/apigen/testdata/i18n/consumer_test.go", "contract/policy_test.go"] report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"], "freshModuleCache": True, "noLocalReplace": True, "durationSeconds": round(time.monotonic() - started, 2), "checks": checks,