213 lines
6.0 KiB
Go
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>`)
|