- phrasebook ships the backend::lang admin strings (pl, en) with CLDR plural maps, loads them as namespace backend, applies pact.HasLangOverrides trees (lang/<locale>/<namespace>/<group>.yaml) after every namespace, and fails activation when a backend key cannot convert to plural forms - Translator.Forms, Bundle, Resolved and Has serve keys as CLDR form maps - Public GET /lang returns every backend::lang key for the request locale over the fallback locale, Cache-Control no-cache - config_list, config_form and config_relation accept a strict messages block; omitted keys take framework defaults, schemas serve every message as CLDR forms, and activation fails on a missing phrase key - toolbar.buttons is an ordered [create, delete] list; the Winter string form, duplicates, unknown actions and delete without showCheckboxes fail at boot, and create is dropped when the controller has no form - Form schema serves the raw Winter redirects; scaffold emits the list syntax; form and relation schema routes are typed in the admin OpenAPI
521 lines
13 KiB
Go
521 lines
13 KiB
Go
package phrasebook
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
)
|
|
|
|
// Catalog is an immutable-after-load set of namespaced translation entries.
|
|
type Catalog struct {
|
|
// locale -> full key (vendor.plugin::group.dot.path) -> entry
|
|
messages map[string]map[string]entry
|
|
// namespace (plugin ID) -> owning plugin ID
|
|
namespaces map[string]string
|
|
// replace lets put overwrite an existing key; only Override sets it.
|
|
replace bool
|
|
}
|
|
|
|
type entry struct {
|
|
text string
|
|
plurals map[string]string
|
|
pipes []pipePart
|
|
}
|
|
|
|
type pipePart struct {
|
|
exact *float64
|
|
lo *float64
|
|
hi *float64
|
|
text string
|
|
cat string
|
|
}
|
|
|
|
var cldrNames = map[string]struct{}{
|
|
"zero": {},
|
|
"one": {},
|
|
"two": {},
|
|
"few": {},
|
|
"many": {},
|
|
"other": {},
|
|
}
|
|
|
|
var cldrOrder = []string{"zero", "one", "two", "few", "many", "other"}
|
|
|
|
// NewCatalog returns an empty translation catalog.
|
|
func NewCatalog() *Catalog {
|
|
return &Catalog{
|
|
messages: make(map[string]map[string]entry),
|
|
namespaces: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// Load walks fsys for lang/<locale>/<group>.yaml files, flattens nested YAML
|
|
// maps into vendor.plugin::group.dot.path keys, and merges them into c.
|
|
func (c *Catalog) Load(pluginID string, fsys fs.FS) error {
|
|
if c == nil {
|
|
return fmt.Errorf("phrasebook: catalog is nil")
|
|
}
|
|
if pluginID == "" {
|
|
return fmt.Errorf("phrasebook: plugin id is empty")
|
|
}
|
|
if fsys == nil {
|
|
return nil
|
|
}
|
|
if owner, ok := c.namespaces[pluginID]; ok {
|
|
return catalogErrorf(pluginID, "", "", "duplicate namespace owner %q", owner)
|
|
}
|
|
if c.messages == nil {
|
|
c.messages = make(map[string]map[string]entry)
|
|
}
|
|
if c.namespaces == nil {
|
|
c.namespaces = make(map[string]string)
|
|
}
|
|
|
|
var files []string
|
|
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
files = append(files, path.Clean(p))
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return catalogErrorf(pluginID, "", "", "walk lang fs: %s", err.Error())
|
|
}
|
|
sort.Strings(files)
|
|
|
|
for _, file := range files {
|
|
locale, group, err := parseLangPath(file)
|
|
if err != nil {
|
|
return catalogErrorf(pluginID, file, "", "%s", err.Error())
|
|
}
|
|
raw, err := fs.ReadFile(fsys, file)
|
|
if err != nil {
|
|
return catalogErrorf(pluginID, file, "", "read: %s", err.Error())
|
|
}
|
|
if err := c.loadFile(pluginID, file, locale, group, raw); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
c.namespaces[pluginID] = pluginID
|
|
return nil
|
|
}
|
|
|
|
func (c *Catalog) loadFile(pluginID, file, locale, group string, raw []byte) error {
|
|
var decoded any
|
|
if err := yaml.Unmarshal(raw, &decoded); err != nil {
|
|
return catalogErrorf(pluginID, file, "", "parse: %s", err.Error())
|
|
}
|
|
if decoded == nil {
|
|
return nil
|
|
}
|
|
root, err := asStringMap(decoded)
|
|
if err != nil {
|
|
return catalogErrorf(pluginID, file, "", "%s", err.Error())
|
|
}
|
|
prefix := pluginID + "::" + group
|
|
return flatten(c, pluginID, file, locale, prefix, root)
|
|
}
|
|
|
|
func flatten(c *Catalog, pluginID, file, locale, prefix string, v any) error {
|
|
switch val := v.(type) {
|
|
case string:
|
|
return c.putString(pluginID, file, locale, prefix, val)
|
|
case map[string]any:
|
|
if len(val) == 0 {
|
|
return nil
|
|
}
|
|
if isPluralMap(val) {
|
|
return c.putPluralMap(pluginID, file, locale, prefix, val)
|
|
}
|
|
keys := make([]string, 0, len(val))
|
|
for k := range val {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
next := joinKey(prefix, k)
|
|
if err := flatten(c, pluginID, file, locale, next, val[k]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
case map[any]any:
|
|
converted, err := anyMapToStringMap(val)
|
|
if err != nil {
|
|
return catalogErrorf(pluginID, file, prefix, "%s", err.Error())
|
|
}
|
|
return flatten(c, pluginID, file, locale, prefix, converted)
|
|
case nil:
|
|
return catalogErrorf(pluginID, file, prefix, "non-string leaf (null)")
|
|
default:
|
|
return catalogErrorf(pluginID, file, prefix, "non-string leaf (%T)", v)
|
|
}
|
|
}
|
|
|
|
func isPluralMap(m map[string]any) bool {
|
|
if len(m) == 0 {
|
|
return false
|
|
}
|
|
for k := range m {
|
|
if _, ok := cldrNames[k]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (c *Catalog) putString(pluginID, file, locale, key, text string) error {
|
|
e := entry{text: text}
|
|
if strings.Contains(text, "|") {
|
|
parts, err := parsePipe(text, locale)
|
|
if err != nil {
|
|
return catalogErrorf(pluginID, file, key, "%s", err.Error())
|
|
}
|
|
e.pipes = parts
|
|
}
|
|
return c.put(pluginID, file, locale, key, e)
|
|
}
|
|
|
|
func (c *Catalog) putPluralMap(pluginID, file, locale, key string, raw map[string]any) error {
|
|
if _, ok := raw["other"]; !ok {
|
|
return catalogErrorf(pluginID, file, key, "plural map requires other")
|
|
}
|
|
allowed, err := cldrCategorySet(locale)
|
|
if err != nil {
|
|
return catalogErrorf(pluginID, file, key, "%s", err.Error())
|
|
}
|
|
plurals := make(map[string]string, len(raw))
|
|
keys := make([]string, 0, len(raw))
|
|
for k := range raw {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
if _, ok := allowed[k]; !ok {
|
|
return catalogErrorf(pluginID, file, key, "invalid plural category %q for locale %s", k, locale)
|
|
}
|
|
s, ok := raw[k].(string)
|
|
if !ok {
|
|
return catalogErrorf(pluginID, file, joinKey(key, k), "non-string leaf (%T)", raw[k])
|
|
}
|
|
plurals[k] = s
|
|
}
|
|
return c.put(pluginID, file, locale, key, entry{plurals: plurals})
|
|
}
|
|
|
|
func (c *Catalog) put(pluginID, file, locale, key string, e entry) error {
|
|
if key == "" || strings.HasSuffix(key, "::") || strings.HasSuffix(key, ".") {
|
|
return catalogErrorf(pluginID, file, key, "empty key")
|
|
}
|
|
byKey := c.messages[locale]
|
|
if byKey == nil {
|
|
byKey = make(map[string]entry)
|
|
c.messages[locale] = byKey
|
|
}
|
|
if _, exists := byKey[key]; exists && !c.replace {
|
|
return catalogErrorf(pluginID, file, key, "duplicate key")
|
|
}
|
|
byKey[key] = e
|
|
return nil
|
|
}
|
|
|
|
func (c *Catalog) lookup(locale, key string) (entry, bool) {
|
|
if c == nil {
|
|
return entry{}, false
|
|
}
|
|
byKey := c.messages[locale]
|
|
if byKey == nil {
|
|
return entry{}, false
|
|
}
|
|
e, ok := byKey[key]
|
|
return e, ok
|
|
}
|
|
|
|
// Override applies owner's override tree, laid out as
|
|
// lang/<locale>/<namespace>/<group>.yaml, over the loaded catalog. It may
|
|
// replace existing keys of a loaded namespace and add locales; a malformed
|
|
// path or an unknown namespace fails.
|
|
func (c *Catalog) Override(owner string, fsys fs.FS) error {
|
|
if c == nil {
|
|
return fmt.Errorf("phrasebook: catalog is nil")
|
|
}
|
|
if fsys == nil {
|
|
return nil
|
|
}
|
|
var files []string
|
|
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !d.IsDir() {
|
|
files = append(files, p)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return catalogErrorf(owner, "", "", "walk lang overrides: %s", err.Error())
|
|
}
|
|
sort.Strings(files)
|
|
c.replace = true
|
|
defer func() { c.replace = false }()
|
|
for _, file := range files {
|
|
locale, namespace, group, err := parseOverridePath(file)
|
|
if err != nil {
|
|
return catalogErrorf(owner, file, "", "%s", err.Error())
|
|
}
|
|
if _, loaded := c.namespaces[namespace]; !loaded {
|
|
return catalogErrorf(owner, file, "", "override names unknown namespace %s", namespace)
|
|
}
|
|
raw, err := fs.ReadFile(fsys, file)
|
|
if err != nil {
|
|
return catalogErrorf(owner, file, "", "read: %s", err.Error())
|
|
}
|
|
if err := c.loadFile(namespace, file, locale, group, raw); err != nil {
|
|
return fmt.Errorf("phrasebook: override by %s: %w", owner, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseOverridePath(p string) (locale, namespace, group string, err error) {
|
|
bad := fmt.Errorf("malformed override path (want lang/<locale>/<namespace>/<group>.yaml)")
|
|
if strings.Contains(p, "\\") || path.Clean(p) != p {
|
|
return "", "", "", bad
|
|
}
|
|
parts := strings.Split(p, "/")
|
|
if len(parts) != 4 || parts[0] != "lang" || !strings.HasSuffix(parts[3], ".yaml") {
|
|
return "", "", "", bad
|
|
}
|
|
locale, namespace, group = parts[1], parts[2], strings.TrimSuffix(parts[3], ".yaml")
|
|
if !validLocale(locale) || !validNamespace(namespace) || !validGroup(group) {
|
|
return "", "", "", bad
|
|
}
|
|
return locale, namespace, group, nil
|
|
}
|
|
|
|
func validNamespace(s string) bool {
|
|
if s == "" || s == "." || s == ".." || strings.HasPrefix(s, ".") || strings.HasSuffix(s, ".") {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// checkForms fails when a key under prefix cannot convert to CLDR forms, so
|
|
// strings served to the admin SPA never need Laravel exact or range pipes.
|
|
func (c *Catalog) checkForms(prefix string) error {
|
|
locales := make([]string, 0, len(c.messages))
|
|
for locale := range c.messages {
|
|
locales = append(locales, locale)
|
|
}
|
|
sort.Strings(locales)
|
|
for _, locale := range locales {
|
|
keys := make([]string, 0)
|
|
for key := range c.messages[locale] {
|
|
if strings.HasPrefix(key, prefix) {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
if _, ok := entryForms(c.messages[locale][key]); !ok {
|
|
return fmt.Errorf("phrasebook: %s key %s cannot convert to CLDR plural forms (use a plural map, not an exact or range pipe)", locale, key)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseLangPath(p string) (locale, group string, err error) {
|
|
p = path.Clean("/" + strings.ReplaceAll(p, "\\", "/"))
|
|
p = strings.TrimPrefix(p, "/")
|
|
if p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") {
|
|
return "", "", fmt.Errorf("malformed path")
|
|
}
|
|
parts := strings.Split(p, "/")
|
|
if len(parts) != 3 || parts[0] != "lang" {
|
|
return "", "", fmt.Errorf("malformed path")
|
|
}
|
|
locale = parts[1]
|
|
base := parts[2]
|
|
if !strings.HasSuffix(base, ".yaml") {
|
|
return "", "", fmt.Errorf("malformed path")
|
|
}
|
|
group = strings.TrimSuffix(base, ".yaml")
|
|
if !validLocale(locale) || !validGroup(group) {
|
|
return "", "", fmt.Errorf("malformed path")
|
|
}
|
|
return locale, group, nil
|
|
}
|
|
|
|
func validLocale(s string) bool {
|
|
if s == "" || s == "." || s == ".." {
|
|
return false
|
|
}
|
|
for i, r := range s {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
continue
|
|
}
|
|
if (r == '-' || r == '_') && i > 0 && i < len(s)-1 {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validGroup(s string) bool {
|
|
if s == "" || s == "." || s == ".." {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func joinKey(prefix, key string) string {
|
|
if prefix == "" {
|
|
return key
|
|
}
|
|
if key == "" {
|
|
return prefix
|
|
}
|
|
return prefix + "." + key
|
|
}
|
|
|
|
func asStringMap(v any) (map[string]any, error) {
|
|
switch val := v.(type) {
|
|
case map[string]any:
|
|
return val, nil
|
|
case map[any]any:
|
|
return anyMapToStringMap(val)
|
|
default:
|
|
return nil, fmt.Errorf("root must be a mapping")
|
|
}
|
|
}
|
|
|
|
func anyMapToStringMap(v map[any]any) (map[string]any, error) {
|
|
out := make(map[string]any, len(v))
|
|
for k, val := range v {
|
|
sk, ok := k.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("non-string key (%T)", k)
|
|
}
|
|
out[sk] = val
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
var (
|
|
pipeExact = regexp.MustCompile(`^\{(-?\d+)\}\s*(.*)$`)
|
|
pipeRange = regexp.MustCompile(`^\[(-?\d+),(-?\d+|\*)\]\s*(.*)$`)
|
|
)
|
|
|
|
func parsePipe(text, locale string) ([]pipePart, error) {
|
|
raw := strings.Split(text, "|")
|
|
if len(raw) < 2 {
|
|
return nil, fmt.Errorf("malformed pipe")
|
|
}
|
|
cats, _, err := cldrCategories(locale)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts := make([]pipePart, 0, len(raw))
|
|
var forms []pipePart
|
|
for _, seg := range raw {
|
|
seg = strings.TrimSpace(seg)
|
|
if seg == "" {
|
|
return nil, fmt.Errorf("malformed pipe")
|
|
}
|
|
part, err := parsePipeSegment(seg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts = append(parts, part)
|
|
if part.exact == nil && part.lo == nil {
|
|
forms = append(forms, part)
|
|
}
|
|
}
|
|
if len(forms) > 0 && len(forms) != len(cats) {
|
|
return nil, fmt.Errorf("incomplete pipe")
|
|
}
|
|
fi := 0
|
|
for i := range parts {
|
|
if parts[i].exact == nil && parts[i].lo == nil {
|
|
parts[i].cat = cats[fi]
|
|
fi++
|
|
}
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
func parsePipeSegment(seg string) (pipePart, error) {
|
|
if strings.HasPrefix(seg, "{") {
|
|
m := pipeExact.FindStringSubmatch(seg)
|
|
if m == nil {
|
|
return pipePart{}, fmt.Errorf("malformed pipe")
|
|
}
|
|
n, err := strconv.ParseFloat(m[1], 64)
|
|
if err != nil {
|
|
return pipePart{}, fmt.Errorf("malformed pipe")
|
|
}
|
|
return pipePart{exact: &n, text: m[2]}, nil
|
|
}
|
|
if strings.HasPrefix(seg, "[") {
|
|
m := pipeRange.FindStringSubmatch(seg)
|
|
if m == nil {
|
|
return pipePart{}, fmt.Errorf("malformed pipe")
|
|
}
|
|
lo, err := strconv.ParseFloat(m[1], 64)
|
|
if err != nil {
|
|
return pipePart{}, fmt.Errorf("malformed pipe")
|
|
}
|
|
part := pipePart{lo: &lo, text: m[3]}
|
|
if m[2] == "*" {
|
|
return part, nil
|
|
}
|
|
hi, err := strconv.ParseFloat(m[2], 64)
|
|
if err != nil {
|
|
return pipePart{}, fmt.Errorf("malformed pipe")
|
|
}
|
|
if lo > hi {
|
|
return pipePart{}, fmt.Errorf("malformed pipe")
|
|
}
|
|
part.hi = &hi
|
|
return part, nil
|
|
}
|
|
return pipePart{text: seg}, nil
|
|
}
|
|
|
|
func catalogErrorf(pluginID, file, key, format string, args ...any) error {
|
|
msg := fmt.Sprintf(format, args...)
|
|
switch {
|
|
case key != "":
|
|
return fmt.Errorf("phrasebook: plugin %s file %s key %s: %s", pluginID, file, key, msg)
|
|
case file != "":
|
|
return fmt.Errorf("phrasebook: plugin %s file %s: %s", pluginID, file, msg)
|
|
default:
|
|
return fmt.Errorf("phrasebook: plugin %s: %s", pluginID, msg)
|
|
}
|
|
}
|