From da32044d5bf6982bb870514e39b90f68f38f2ec3 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 23:08:38 +0100 Subject: [PATCH] fix: HTML sitemap headings follow each locale until an editor sets one Settings > Sitemaps no longer prefills a content type's heading with its label in the administrator's language, so a save that did not touch the heading stores none and each locale's HTML sitemap keeps the label translated into it. The field's comment says so. --- README.md | 2 +- extensions_settings.go | 10 ++-- lang/en/lang.yaml | 1 + lang/pl/lang.yaml | 1 + type_titles_test.go | 128 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 type_titles_test.go diff --git a/README.md b/README.md index cf2b763..fb64eb8 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ The plugin also publishes `classes.Listing` as `contract.Rebuilder`. `RequestReb The Settings page has one Sitemaps tile (icon `network`, category CMS, permission `golem15.sitemap.access_definitions`). It opens the singleton controller `golem15.sitemap.settings`: - **Files:** Site address, Addresses per file and the files table: the index and every file with its address count and newest lastmod, the addresses left out by reason, and a note naming the public index address. Before the first build it says there are no files yet; after a save and until the rebuild ran, that it is rebuilding. -- **Content types:** per signpost content type, in registration order, a switch labelled with the type's label, the file name (`[a-z0-9-]`, unique, `images` and `news` reserved) and the HTML sitemap heading per locale. Stored in `golem15_sitemap_types`. +- **Content types:** per signpost content type, in registration order, a switch labelled with the type's label, the file name (`[a-z0-9-]`, unique, `images` and `news` reserved) and the HTML sitemap heading per locale. The heading loads empty until an editor sets one, so a save that does not touch it stores none and each language's HTML sitemap shows the type's label translated into that language. Stored in `golem15_sitemap_types`. - **Additional addresses:** a repeater of paths (or absolute URLs) with an optional lastmod as `YYYY-MM-DD`. They go into the `pages` group. - **Images and News:** the image sitemap switch, the News sitemap switch, and while it is on the publication name, the News sources (a tag list of `type:` and `category:` values) and the table of the values the site has. - **IndexNow:** the switch, the status partial and, while it is on, "Generate a new key on save". diff --git a/extensions_settings.go b/extensions_settings.go index 03480cb..c7fa344 100644 --- a/extensions_settings.go +++ b/extensions_settings.go @@ -80,6 +80,7 @@ func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) { }}, yaml.MapItem{Key: base + "title", Value: yaml.MapSlice{ {Key: "label", Value: "golem15.sitemap::lang.settings.type_title"}, + {Key: "comment", Value: "golem15.sitemap::lang.settings.type_title_comment"}, {Key: "type", Value: "mltext"}, {Key: "span", Value: "full"}, {Key: "tab", Value: TabTypes}, @@ -118,17 +119,18 @@ func (p *Plugin) typeSettingsTab(types []pact.MenuItemType) *pact.AdminFormExten if err != nil { return nil, err } - def := p.defaultLocale(ctx, in.DB) - tr := p.translator() out := map[string]any{} for _, typ := range types { cfg := classes.EffectiveType(typ, rows) base := classes.TypeFieldBase(typ.Key) out[base+"include"] = cfg.Include out[base+"slug"] = cfg.Slug + // Only stored headings (L2 WR-05): a type without one loads + // empty, so a save that did not touch it stores none and each + // locale's HTML sitemap keeps the label translated into it. titles := cfg.Titles - if len(titles) == 0 { - titles = map[string]string{def: controllers.Phrase(ctx, tr, typ.Label, nil)} + if titles == nil { + titles = map[string]string{} } out[base+"title"] = titles } diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index dc2c9cc..d9b72be 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -33,6 +33,7 @@ settings: type_slug: File name type_slug_comment: Files are named sitemap--1.xml, sitemap--2.xml and so on. type_title: Heading in the HTML sitemap + type_title_comment: "Leave empty to show the content type's name in each language's HTML sitemap." extra_urls: Additional addresses extra_urls_add: Add address extra_urls_comment: Addresses that no content type lists, e.g. the site's static pages. They go into the sitemap-pages files. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index b750644..45def94 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -33,6 +33,7 @@ settings: type_slug: Nazwa pliku type_slug_comment: Pliki nazywają się sitemap--1.xml, sitemap--2.xml i tak dalej. type_title: Nagłówek grupy w mapie HTML + type_title_comment: "Puste pole: mapa HTML w każdym języku pokazuje nazwę typu treści w tym języku." extra_urls: Dodatkowe adresy extra_urls_add: Dodaj adres extra_urls_comment: Adresy, których nie podaje żaden typ treści, np. statyczne strony witryny. Trafiają do plików sitemap-pages. diff --git a/type_titles_test.go b/type_titles_test.go new file mode 100644 index 0000000..1100156 --- /dev/null +++ b/type_titles_test.go @@ -0,0 +1,128 @@ +package sitemap_test + +import ( + "context" + "encoding/json" + "io/fs" + "net/http" + "testing" + "testing/fstest" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// labelsPluginID is a fake content plugin whose type label is a phrase +// with an English and a Polish translation, as a real plugin's is. +const labelsPluginID = "acme.labels" + +const noteType = "acme-note" + +type labelsPlugin struct{} + +func (labelsPlugin) ID() string { return labelsPluginID } +func (labelsPlugin) Requires() []string { return nil } +func (labelsPlugin) Register(*backpack.App) error { return nil } +func (labelsPlugin) Boot(*backpack.App) error { return nil } + +func (labelsPlugin) LangFS() fs.FS { + return fstest.MapFS{ + "lang/en/lang.yaml": {Data: []byte("types:\n notes: Notes\n")}, + "lang/pl/lang.yaml": {Data: []byte("types:\n notes: Notatki\n")}, + } +} + +func (labelsPlugin) MenuItemTypes() []pact.MenuItemType { + return []pact.MenuItemType{{ + Key: noteType, Label: "acme.labels::lang.types.notes", DefaultPattern: "/notes/:slug", Placeholders: []string{"slug"}, + Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { return nil, nil }, + Content: &pact.ContentDescriptor{ + Model: "acme_notes", + Records: func(_ context.Context, in pact.ContentList) (pact.ContentPage, error) { + return contentStore.page(noteType, in), nil + }, + Describe: func(context.Context, pact.ContentDescribe) (*pact.ContentRecord, error) { return nil, nil }, + }, + }} +} + +var ( + _ pact.HasMenuItemTypes = labelsPlugin{} + _ pact.HasLang = labelsPlugin{} +) + +func init() { + party.Register(labelsPlugin{}) +} + +// TestTypeTitlesNotPinned pins L2 WR-05 (D-34): an administrator with an +// English admin loads Settings › Sitemaps and saves it back unchanged; no +// heading is stored for the content type, so the Polish HTML sitemap keeps +// the Polish translation of the type's label and the English one the +// English. +func TestTypeTitlesNotPinned(t *testing.T) { + contentStore.reset(t) + e := bootPlugins(t, []string{labelsPluginID, "golem15.sitemap"}) + e.exec(t, `UPDATE golem15_sitemap_settings SET default_locale = 'pl', html_enabled = TRUE WHERE id = 1`) + at := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) + contentStore.put(noteType, fakeRecord{ID: 1, Published: true, LastMod: at, Locales: []pact.ContentLocale{ + {Locale: "pl", URL: "/notes/pierwsza", Title: "Pierwsza"}, + {Locale: "en", URL: "/en/notes/first", Title: "First"}, + }}) + token := e.admin(t, permSitemap) + const base = "/golem15/sitemap/settings" + rec := e.call(t, http.MethodGet, base+"/1", token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET settings: %d %s", rec.Code, rec.Body.String()) + } + var loaded struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &loaded); err != nil { + t.Fatal(err) + } + values := loaded.Data + if values == nil { + if err := json.Unmarshal(rec.Body.Bytes(), &values); err != nil { + t.Fatal(err) + } + } + title, ok := values["type_acme_note_title"] + if !ok { + t.Fatalf("the form loads no heading field: %s", rec.Body.String()) + } + m, _ := title.(map[string]any) + if len(m) != 0 { + t.Errorf("the form loads a heading %v for a type without one", title) + } + // Without a translation writer the form sends the default locale's + // text of the loaded value back. + sent, _ := m["pl"].(string) + if rec := e.call(t, http.MethodPut, base+"/1", token, map[string]any{ + "type_acme_note_include": values["type_acme_note_include"], + "type_acme_note_slug": values["type_acme_note_slug"], + "type_acme_note_title": sent, + }); rec.Code != http.StatusOK { + t.Fatalf("PUT settings: %d %s", rec.Code, rec.Body.String()) + } + var stored []string + e.gdb.Raw(`SELECT titles::text FROM golem15_sitemap_types WHERE type_key = ?`, noteType).Scan(&stored) + for _, s := range stored { + if s != "{}" && s != "null" { + t.Fatalf("a save that did not touch the heading stored %s", s) + } + } + e.rebuild(t) + for locale, want := range map[string]string{"pl": "Notatki", "en": "Notes"} { + ov := e.htmlJSON(t, "/_sitemap/api/v1/html?locale="+locale, http.StatusOK) + groups, _ := ov["groups"].([]any) + if len(groups) != 1 { + t.Fatalf("%s groups = %v", locale, ov) + } + if got := groups[0].(map[string]any)["title"]; got != want { + t.Errorf("%s group title = %v, want %s", locale, got, want) + } + } +}