Resolve the page UI language once and let each ExApp fill its own Phrases.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+212
@@ -0,0 +1,212 @@
|
||||
package gonexapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Phrases is one ExApp's merged Phrase set.
|
||||
// English is the complete set. A module set is merged with Merge;
|
||||
// a repeated id uses the set added later.
|
||||
// The page script receives this set. Library chrome is not included.
|
||||
type Phrases struct {
|
||||
en map[string]string
|
||||
de map[string]string
|
||||
}
|
||||
|
||||
// PhrasesFromFS reads i18n/en.json and i18n/de.json.
|
||||
// Each file is one JSON object of id → text.
|
||||
func PhrasesFromFS(fsys fs.FS) (Phrases, error) {
|
||||
en, err := fs.ReadFile(fsys, "i18n/en.json")
|
||||
if err != nil {
|
||||
return Phrases{}, fmt.Errorf("read i18n/en.json: %w", err)
|
||||
}
|
||||
de, err := fs.ReadFile(fsys, "i18n/de.json")
|
||||
if err != nil {
|
||||
return Phrases{}, fmt.Errorf("read i18n/de.json: %w", err)
|
||||
}
|
||||
return ParsePhrases(en, de)
|
||||
}
|
||||
|
||||
// ParsePhrases reads an English object and a German object.
|
||||
func ParsePhrases(en, de []byte) (Phrases, error) {
|
||||
enMap, err := decodePhraseFile(en)
|
||||
if err != nil {
|
||||
return Phrases{}, fmt.Errorf("en: %w", err)
|
||||
}
|
||||
deMap, err := decodePhraseFile(de)
|
||||
if err != nil {
|
||||
return Phrases{}, fmt.Errorf("de: %w", err)
|
||||
}
|
||||
return Phrases{en: enMap, de: deMap}, nil
|
||||
}
|
||||
|
||||
func decodePhraseFile(raw []byte) (map[string]string, error) {
|
||||
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Merge returns the sets combined in order.
|
||||
// A repeated id uses the text from the set added later.
|
||||
func Merge(sets ...Phrases) Phrases {
|
||||
out := Phrases{en: map[string]string{}, de: map[string]string{}}
|
||||
for _, set := range sets {
|
||||
maps.Copy(out.en, set.en)
|
||||
maps.Copy(out.de, set.de)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p Phrases) has(code string) bool {
|
||||
switch code {
|
||||
case "en":
|
||||
return p.en != nil
|
||||
case "de":
|
||||
return p.de != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (p Phrases) texts(lang string) map[string]string {
|
||||
if lang == "de" {
|
||||
return p.de
|
||||
}
|
||||
return p.en
|
||||
}
|
||||
|
||||
// Sentence is the finished text for lang.
|
||||
// A missing text for that language uses the English text.
|
||||
// An id missing from English is the id.
|
||||
// A hole with no value stays visible as {name}.
|
||||
// The result is plain text; the caller escapes it when writing HTML.
|
||||
func (p Phrases) Sentence(lang, id string, holes map[string]string) string {
|
||||
lang = matchLanguage(lang, p.has)
|
||||
if text, ok := phraseText(p.texts(lang), id); ok {
|
||||
return fillHoles(text, holes)
|
||||
}
|
||||
if lang != "en" {
|
||||
if text, ok := phraseText(p.en, id); ok {
|
||||
return fillHoles(text, holes)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func phraseText(m map[string]string, id string) (string, bool) {
|
||||
text, ok := m[id]
|
||||
if !ok || text == "" {
|
||||
return "", false
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
|
||||
// resolved is the map the page script sees: one text per id for lang,
|
||||
// with English filling gaps. Ids missing from English are omitted.
|
||||
func (p Phrases) resolved(lang string) map[string]string {
|
||||
lang = matchLanguage(lang, p.has)
|
||||
out := map[string]string{}
|
||||
for id, text := range p.en {
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out[id] = text
|
||||
}
|
||||
if lang == "en" {
|
||||
return out
|
||||
}
|
||||
for id, text := range p.de {
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := phraseText(p.en, id); !ok {
|
||||
continue
|
||||
}
|
||||
out[id] = text
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Script defines exappPhrase for the page, from this set only.
|
||||
func (p Phrases) Script(lang string) template.HTML {
|
||||
raw, err := json.Marshal(p.resolved(lang))
|
||||
if err != nil {
|
||||
raw = []byte("{}")
|
||||
}
|
||||
return template.HTML("<script>\n" + phraseRuntimeJS + "\nwindow.exappPhrase = exappPhraseMap(" + string(raw) + ");\n</script>")
|
||||
}
|
||||
|
||||
const phraseRuntimeJS = `function exappFill(text, holes) {
|
||||
if (!holes || text.indexOf("{") < 0) return text;
|
||||
var out = "";
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
if (text.charAt(i) !== "{") { out += text.charAt(i); continue; }
|
||||
var end = text.indexOf("}", i + 1);
|
||||
if (end < 0) { out += text.charAt(i); continue; }
|
||||
var name = text.slice(i + 1, end);
|
||||
if (Object.prototype.hasOwnProperty.call(holes, name)) {
|
||||
out += String(holes[name]);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
out += text.charAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function exappPhraseMap(map) {
|
||||
return function (id, holes) {
|
||||
var text = Object.prototype.hasOwnProperty.call(map, id) ? map[id] : String(id);
|
||||
return exappFill(text, holes);
|
||||
};
|
||||
}`
|
||||
|
||||
// FillScript replaces English source after load and refreshes Fold from the
|
||||
// visible item name. It does not parse Phrase text as HTML.
|
||||
// App navigation includes it. A page the ExApp renders itself places Script
|
||||
// in the head and FillScript at the end of the body.
|
||||
func FillScript() template.HTML {
|
||||
return phraseFillScript
|
||||
}
|
||||
|
||||
// phraseFillScript replaces English source after load and refreshes Fold
|
||||
// from the visible item name. It does not parse Phrase text as HTML.
|
||||
const phraseFillScript = template.HTML(`<script>
|
||||
(function () {
|
||||
function holesOf(el) {
|
||||
var raw = el.getAttribute("data-phrase-holes");
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch (e) { return null; }
|
||||
}
|
||||
function apply(el, attr, set) {
|
||||
var id = el.getAttribute(attr);
|
||||
if (!id || typeof exappPhrase !== "function") return;
|
||||
set(exappPhrase(id, holesOf(el)));
|
||||
}
|
||||
document.querySelectorAll("[data-phrase],[data-phrase-placeholder],[data-phrase-title],[data-phrase-aria-label]").forEach(function (el) {
|
||||
apply(el, "data-phrase", function (t) { el.textContent = t; });
|
||||
apply(el, "data-phrase-placeholder", function (t) { el.placeholder = t; });
|
||||
apply(el, "data-phrase-title", function (t) { el.title = t; });
|
||||
apply(el, "data-phrase-aria-label", function (t) { el.setAttribute("aria-label", t); });
|
||||
});
|
||||
document.querySelectorAll("button.fold").forEach(function (btn) {
|
||||
var pattern = btn.getAttribute("data-fold");
|
||||
var entry = btn.closest(".app-navigation-entry");
|
||||
var name = entry ? entry.querySelector(".app-navigation-entry__name") : null;
|
||||
if (!pattern || !name || typeof exappFill !== "function") return;
|
||||
btn.setAttribute("aria-label", exappFill(pattern, { label: name.textContent }));
|
||||
});
|
||||
})();
|
||||
</script>`)
|
||||
Reference in New Issue
Block a user