feat: release v0.26 portable i18n and presentation adapters

This commit is contained in:
2026-09-29 13:31:42 +08:00
parent 6a95cad184
commit d1a747c36f
75 changed files with 5681 additions and 52 deletions

View File

@@ -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)
}

28
examples/i18n/README.md Normal file
View File

@@ -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)。

View File

@@ -0,0 +1,6 @@
{
"sourceLocale": "en",
"defaultLocale": "en",
"locales": ["en", "zh-Hans"],
"fallbacks": {"zh-Hans": ["en"]}
}

46
examples/i18n/index.html Normal file
View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GoOmni language example</title>
<style>
body{font:17px system-ui,sans-serif;max-width:620px;margin:60px auto;padding:0 24px;color:#172c36;background:#f6f8fa}h1{font-size:30px}label{display:block;margin:24px 0 8px}input,select,button{font:inherit;padding:10px;border:1px solid #9babb3;border-radius:6px}input{box-sizing:border-box;width:100%}button{margin-top:16px;background:#17627a;color:white}p{min-height:24px}#status{color:#a12626}
</style>
<main>
<select id="language" aria-label="Language"><option value="en">English</option><option value="zh-Hans">简体中文</option></select>
<h1 id="title">Language example</h1>
<form id="form">
<label id="draftLabel" for="draft">Your draft</label>
<input id="draft" name="draft" autocomplete="off">
<button id="submit" type="submit">Submit demo</button>
</form>
<p id="count" aria-live="polite"></p>
<p id="status" role="status"></p>
</main>
<script type="module">
import {createLanguageController, fetchPack} from '/runtime.mjs';
const select=document.querySelector('#language'), status=document.querySelector('#status');
const controller=createLanguageController(await fetchPack('/locales/en.json'), locale=>fetchPack(`/locales/${encodeURIComponent(locale)}.json`));
let count=0;
function paint(){
const {t,locale}=controller.current;
document.documentElement.lang=locale;
document.querySelector('#title').textContent=t('ui','title');
document.querySelector('#draftLabel').textContent=t('ui','draft');
document.querySelector('#submit').textContent=t('ui','submit');
document.querySelector('#count').textContent=t('ui','submitted',{count});
select.value=locale;
}
select.addEventListener('change',async()=>{
status.textContent='';
try{if(await controller.setLanguage(select.value))paint();}
catch{select.value=controller.current.locale;status.textContent='Language could not be loaded. / 语言加载失败。';}
});
document.querySelector('#form').addEventListener('submit',async event=>{
event.preventDefault();
const response=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json','Accept-Language':controller.current.locale},body:JSON.stringify({draft:document.querySelector('#draft').value})});
if(response.ok){count++;paint();}
});
paint();
</script>
</html>

View File

@@ -0,0 +1,8 @@
{
"version": 1,
"namespace": "errors",
"locale": "zh-Hans",
"messages": {
"LIMIT_EXCEEDED": {"value": {"text": "数量不能超过 {max}"}}
}
}

View File

@@ -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"}
}}}
}
}

View File

@@ -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} 次"}}}}
}
}

135
examples/i18n/main.go Normal file
View File

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

View File

@@ -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())
}
}
}

View File

@@ -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

View File

@@ -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
)