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:
2026-10-11 11:45:25 +02:00
co-authored by Cursor
parent afbd76445d
commit 623ada5f99
14 changed files with 1051 additions and 53 deletions
+65 -25
View File
@@ -12,10 +12,13 @@ const shellCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame
// Item is one App navigation entry. The ExApp chooses the ids and labels.
// Children may nest. The shell does not interpret the ids.
// Icon is an optional same-origin image URL. NextcloudIcons names the core SVGs.
// Phrase names the ExApp Phrase for Label. The English label stays in the
// page until the script replaces it. Empty leaves the label as written.
type Item struct {
ID string
Label string
Icon string
Phrase string
Children []Item
}
@@ -36,6 +39,15 @@ type AppNavigation struct {
// DisableTheme skips the Nextcloud theme stylesheets. The zero value
// loads the active theme.
DisableTheme bool
// Cred reads the UI language for this page from Nextcloud.
// The zero value leaves the page in English.
// The caller does not pass a language.
Cred Credentials
// Phrases is the ExApp's set. The page script receives it.
// Library chrome is not included. The zero value leaves the page in English.
Phrases Phrases
// MissingPhrase names the ExApp Phrase for MissingMessage.
MissingPhrase string
}
// Handler serves the shell. The selected item is the SelectKey query parameter.
@@ -54,6 +66,8 @@ func (n AppNavigation) selectKey() string {
}
func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
lang := n.Cred.UILanguage(r)
r = rememberUILanguage(r, lang)
var items []Item
if n.Items != nil {
items = n.Items(r)
@@ -97,15 +111,21 @@ func (n AppNavigation) serve(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
// Status is already committed; a template error cannot change the response.
_ = shellTmpl.Execute(w, shellView{
Selected: selected,
Address: n.address(r, selected),
Header: template.HTML(header),
Items: viewItems(r, items, selected, n.address),
Page: template.HTML(page),
Missing: msg,
Title: title,
Theme: !n.DisableTheme,
Dialog: DialogHTML(),
Selected: selected,
Address: n.address(r, selected),
Header: template.HTML(header),
Items: viewItems(r, items, selected, n.address, lang),
Page: template.HTML(page),
Missing: msg,
Title: title,
Theme: !n.DisableTheme,
Dialog: n.Cred.DialogHTML(r),
Lang: lang,
OpenLabel: chromePhrase(lang, "nav.open", nil),
CloseLabel: chromePhrase(lang, "nav.close", nil),
PhraseScript: n.Phrases.Script(lang),
PhraseFill: FillScript(),
MissingPhrase: n.MissingPhrase,
})
}
@@ -128,15 +148,21 @@ func findItem(items []Item, id string) (Item, bool) {
}
type shellView struct {
Selected string
Address string
Header template.HTML
Items []itemView
Page template.HTML
Missing string
Title string
Theme bool
Dialog template.HTML
Selected string
Address string
Header template.HTML
Items []itemView
Page template.HTML
Missing string
Title string
Theme bool
Dialog template.HTML
Lang string
OpenLabel string
CloseLabel string
PhraseScript template.HTML
PhraseFill template.HTML
MissingPhrase string
}
type itemView struct {
@@ -147,12 +173,21 @@ type itemView struct {
Current bool
HasChildren bool
Children []itemView
Fold string
FoldPattern string
Phrase string
}
func viewItems(r *http.Request, items []Item, selected string, href func(*http.Request, string) string) []itemView {
func viewItems(r *http.Request, items []Item, selected string, href func(*http.Request, string) string, lang string) []itemView {
out := make([]itemView, 0, len(items))
for _, it := range items {
children := viewItems(r, it.Children, selected, href)
children := viewItems(r, it.Children, selected, href, lang)
fold := ""
pattern := ""
if len(children) > 0 {
fold = chromePhrase(lang, "nav.fold", map[string]string{"label": it.Label})
pattern = chromePhrase(lang, "nav.fold", nil)
}
out = append(out, itemView{
ID: it.ID,
Label: it.Label,
@@ -161,17 +196,21 @@ func viewItems(r *http.Request, items []Item, selected string, href func(*http.R
Current: it.ID == selected,
HasChildren: len(children) > 0,
Children: children,
Fold: fold,
FoldPattern: pattern,
Phrase: it.Phrase,
})
}
return out
}
var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
<html lang="en">
<html lang="{{.Lang}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
{{.PhraseScript}}
{{if .Theme}}
<link rel="stylesheet" href="/apps/theming/css/default.css">
<link rel="stylesheet" href="/index.php/apps/theming/theme/default.css">
@@ -355,7 +394,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
{{.Dialog}}
<div id="app-nav">
<div id="nav-header">
<button type="button" id="nav-toggle" aria-controls="app-navigation" aria-expanded="false" aria-label="Open navigation"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h18v2H3v-2m0 5h18v2H3v-2z"/></svg></button>
<button type="button" id="nav-toggle" aria-controls="app-navigation" aria-expanded="false" aria-label="{{.OpenLabel}}"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h18v2H3v-2m0 5h18v2H3v-2z"/></svg></button>
<div id="nav-header-text">{{.Header}}</div>
</div>
<div id="nav-backdrop"></div>
@@ -364,7 +403,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
<ul class="app-navigation-list">{{template "items" .Items}}</ul>
</nav>
<main>
{{if .Missing}}<p id="nav-missing">{{.Missing}}</p>{{end}}
{{if .Missing}}<p id="nav-missing"{{if .MissingPhrase}} data-phrase="{{.MissingPhrase}}"{{end}}>{{.Missing}}</p>{{end}}
{{.Page}}
</main>
</div>
@@ -396,7 +435,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
document.body.classList.toggle("nav-open", open);
if (!toggle) return;
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.setAttribute("aria-label", open ? "Close navigation" : "Open navigation");
toggle.setAttribute("aria-label", open ? {{.CloseLabel}} : {{.OpenLabel}});
}
placeNav();
window.addEventListener("resize", placeNav);
@@ -416,8 +455,9 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
});
})();
</script>
{{.PhraseFill}}
</body>
</html>
{{define "items"}}{{range .}}<li class="app-navigation-entry-wrapper{{if .HasChildren}} app-navigation-entry--collapsible app-navigation-entry--opened{{end}}" data-id="{{.ID}}"{{if .HasChildren}} data-expanded="true"{{end}}><div class="app-navigation-entry{{if .Current}} active{{end}}"><a class="app-navigation-entry-link" href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{if .Icon}}<img class="app-navigation-entry-icon" src="{{.Icon}}" alt="">{{end}}<span class="app-navigation-entry__name">{{.Label}}</span></a>{{if .HasChildren}}<div class="app-navigation-entry__utils"><button type="button" class="fold" aria-expanded="true" aria-label="Fold {{.Label}}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 6.5 8 10l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>{{end}}</div>{{if .HasChildren}}<ul class="app-navigation-entry__children">{{template "items" .Children}}</ul>{{end}}</li>{{end}}{{end}}
{{define "items"}}{{range .}}<li class="app-navigation-entry-wrapper{{if .HasChildren}} app-navigation-entry--collapsible app-navigation-entry--opened{{end}}" data-id="{{.ID}}"{{if .HasChildren}} data-expanded="true"{{end}}><div class="app-navigation-entry{{if .Current}} active{{end}}"><a class="app-navigation-entry-link" href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{if .Icon}}<img class="app-navigation-entry-icon" src="{{.Icon}}" alt="">{{end}}<span class="app-navigation-entry__name"{{if .Phrase}} data-phrase="{{.Phrase}}"{{end}}>{{.Label}}</span></a>{{if .HasChildren}}<div class="app-navigation-entry__utils"><button type="button" class="fold" aria-expanded="true" aria-label="{{.Fold}}" data-fold="{{.FoldPattern}}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 6.5 8 10l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>{{end}}</div>{{if .HasChildren}}<ul class="app-navigation-entry__children">{{template "items" .Children}}</ul>{{end}}</li>{{end}}{{end}}
`))