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.
This commit is contained in:
Jakub Zych
2026-11-04 23:08:38 +01:00
parent b37b2265ed
commit da32044d5b
5 changed files with 137 additions and 5 deletions

View File

@@ -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".

View File

@@ -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
}

View File

@@ -33,6 +33,7 @@ settings:
type_slug: File name
type_slug_comment: Files are named sitemap-<name>-1.xml, sitemap-<name>-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.

View File

@@ -33,6 +33,7 @@ settings:
type_slug: Nazwa pliku
type_slug_comment: Pliki nazywają się sitemap-<nazwa>-1.xml, sitemap-<nazwa>-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.

128
type_titles_test.go Normal file
View File

@@ -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)
}
}
}