Files
go-nc-exapp/phrases.go
T

213 lines
6.0 KiB
Go

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