fix: content descriptors list only locales with content

The post, category, tag and author descriptors keep one entry per
enabled locale but give a locale a URL only when the record has content
in it: posts and categories by their own translation (a translated slug
alone is no content), tags and authors when one of their published
posts is translated into it, the default locale always. Without content
the entry's URL is "", so hreflang and the sitemaps never advertise an
untranslated locale. The menu item types' Resolve is unchanged.
This commit is contained in:
Jakub Zych
2026-11-04 23:27:57 +01:00
parent 7662d54ab3
commit 0a62090435
5 changed files with 283 additions and 13 deletions

View File

@@ -351,6 +351,8 @@ Placeholders are replaced literally, in order, like the PHP `buildUrlFromPattern
Every resolved record carries alternates, one per enabled translate locale in `sort_order` (ties by id). The unprefixed locale (the caller's, for example a sitemap's frontend default locale, else translate's default locale) gets no prefix; every other locale gets `/<code>`. Slugs and titles are translated per locale and fall back to the default value. Without an enabled locale the only alternate is `x-default`. A record's URL is its first alternate's URL, as in PHP.
The content descriptors (posts, categories, tags and authors, which feed the SEO plugin's hreflang and the sitemaps) keep one locale entry per enabled locale but give a locale an address only when the record has content in it: the default locale (translate's default, else the unprefixed locale, else the first enabled one) always; a post or a category when its own translation in that locale holds text (a post's title, content, content_html or excerpt, a category's name or description; a translated slug alone is no content); a tag or an author when at least one of its published posts has such a translation. An entry without content has the URL `""`, pact's "no public page", and the default locale's title, description and word count, so an untranslated locale is never advertised as an alternate. The menu item types' alternates above are unchanged.
A host changes the URLs in its config file `config/menu_items.yaml` (YAML only, because the keys contain dashes). Overriding the two record types is enough; the list types follow:
```yaml

View File

@@ -91,7 +91,10 @@ func (m menuTypes) facts(ctx context.Context, db *gorm.DB) descriptorFacts {
// pages are on) and TopHeading (2 while the H1 setting is on, else 0); each
// locale lists the featured image (alt: the file's title, else the
// translated post title, P33-06) and the body's images, YouTube embeds and
// headings (parseContentFacts). The queries are batched per page.
// headings (parseContentFacts). A locale in which the post has no content
// (no translated title, content, content_html or excerpt, P33-64) keeps its
// entry with URL "" (no public page, D-57). The queries are batched per
// page.
func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, pattern, unprefixed string, facts descriptorFacts) ([]pact.ContentRecord, error) {
in := pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}
items, err := resolvePostItems(ctx, db, posts, in)
@@ -171,9 +174,15 @@ func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, patter
images = append(images, pact.ContentImage{URL: image, Alt: altText})
}
images = append(images, found.images...)
url := alt.URL
if !tr.hasContent(locales, alt.Locale, p.ID, postContentFields) {
// No translation in this locale (P33-64, D-57): no public
// page for it, so no hreflang or sitemap address.
url = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{
Locale: alt.Locale,
URL: alt.URL,
URL: url,
Title: alt.Title,
Description: strings.TrimSpace(plainText(tr.value(locale, p.ID, "excerpt", p.Excerpt))),
Image: image,
@@ -450,6 +459,8 @@ func (m menuTypes) listCategories(ctx context.Context, in pact.ContentList) (pac
// heading outline (content is not translatable) and TopHeading follows the
// H1 setting (2 on, 0 off), so the pillar text is checked as post content
// is (CONTEXT 2026-10-28 H1 conversion scope).
// A locale in which the category has no translated name or description
// (P33-64) gets URL "".
func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category, pattern, unprefixed string, facts descriptorFacts) ([]pact.ContentRecord, error) {
ids := make([]uint, 0, len(cats))
for _, c := range cats {
@@ -483,9 +494,13 @@ func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category
if locale == xDefaultLocale {
locale = ""
}
url := alt.URL
if !ci.tr.hasContent(ci.locales, alt.Locale, c.ID, categoryContentFields) {
url = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{
Locale: alt.Locale,
URL: alt.URL,
URL: url,
Title: alt.Title,
Description: plainText(ci.tr.value(locale, c.ID, "description", host)),
Words: words,
@@ -620,13 +635,23 @@ func (m menuTypes) listTags(ctx context.Context, in pact.ContentList) (pact.Cont
}
// describeTags describes tags, always published, one locale per enabled
// locale with the same name and slug; URLs are empty without tag pages.
// locale with the same name and slug; URLs are empty without tag pages, and
// in a locale in which none of the tag's published posts has content
// (P33-64).
func (m menuTypes) describeTags(ctx context.Context, db *gorm.DB, tags []models.Tag, pattern, unprefixed string) ([]pact.ContentRecord, error) {
locales, err := loadMenuLocales(ctx, db, unprefixed)
if err != nil {
return nil, err
}
pages := m.tagPages()
ids := make([]uint, 0, len(tags))
for _, tag := range tags {
ids = append(ids, tag.ID)
}
content, err := tagContentLocales(ctx, db, locales, ids)
if err != nil {
return nil, err
}
out := make([]pact.ContentRecord, 0, len(tags))
for _, tag := range tags {
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(tag.ID), 10), Published: true, LastMod: copyTime(tag.UpdatedAt)}
@@ -636,7 +661,7 @@ func (m menuTypes) describeTags(ctx context.Context, db *gorm.DB, tags []models.
}
for _, alt := range menuAlternates(pattern, locales, tagParams(tag), func(string) string { return tag.Name }) {
url := alt.URL
if !pages {
if !pages || !ownerHasContent(locales, content, tag.ID, alt.Locale) {
url = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: tag.Name, Description: desc})

View File

@@ -1013,8 +1013,9 @@ func (m menuTypes) describeAuthor(ctx context.Context, in pact.ContentDescribe)
}
// describeAuthors describes authors: the name as every locale's Title, the
// author URL per locale (empty without author pages) and the newest update
// of their published posts as LastMod.
// author URL per locale (empty without author pages, and in a locale in
// which none of their published posts has content, P33-64) and the newest
// update of their published posts as LastMod.
func (m menuTypes) describeAuthors(ctx context.Context, db *gorm.DB, rows []authorRow, published bool, pattern, unprefixed string) ([]pact.ContentRecord, error) {
locales, err := loadMenuLocales(ctx, db, unprefixed)
if err != nil {
@@ -1029,6 +1030,10 @@ func (m menuTypes) describeAuthors(ctx context.Context, db *gorm.DB, rows []auth
return nil, err
}
pages := m.authorPages()
content, err := authorContentLocales(ctx, db, locales, ids)
if err != nil {
return nil, err
}
out := make([]pact.ContentRecord, 0, len(rows))
for _, a := range rows {
rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(a.ID), 10), Published: published}
@@ -1038,7 +1043,7 @@ func (m menuTypes) describeAuthors(ctx context.Context, db *gorm.DB, rows []auth
name := a.name()
for _, alt := range menuAlternates(pattern, locales, authorParams(a), func(string) string { return name }) {
url := alt.URL
if !pages {
if !pages || !ownerHasContent(locales, content, a.ID, alt.Locale) {
url = ""
}
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: name})
@@ -1100,3 +1105,124 @@ func (m menuTypes) matchAuthor(ctx context.Context, in pact.ContentMatch) (*pact
}
return matchContent(ctx, db, in, find, urls)
}
// The text fields whose translation gives a post or a category content in
// a locale (P33-64).
var (
postContentFields = []string{"title", "content", "content_html", "excerpt"}
categoryContentFields = []string{"name", "description"}
)
// hostLocale is the locale whose values are the host columns: translate's
// default locale, else the unprefixed one, else the first enabled one.
func (l menuLocales) hostLocale() string {
switch {
case l.defaultCode != "":
return l.defaultCode
case l.unprefixed != "":
return l.unprefixed
case len(l.codes) > 0:
return l.codes[0]
}
return ""
}
// hasContent reports whether record id has content in locale (P33-64, D-57):
// the host locale (and x-default, without enabled locales) always; another
// locale when the record's translation in it holds a truthy (phpTruthy)
// value of one of fields.
func (t menuTranslations) hasContent(locales menuLocales, locale string, id uint, fields []string) bool {
if locale == "" || locale == xDefaultLocale || locale == locales.hostLocale() || locale == t.defaultCode {
return true
}
row := t.data[locale][id]
for _, f := range fields {
if phpTruthy(row[f]) {
return true
}
}
return false
}
// postContentLocales maps each owner (a tag or an author) to the locales
// in which at least one of its published posts has content (P33-64): the
// posts are read with one query (postsOf answers owner id and post id
// pairs), their translations with one more.
func postContentLocales(ctx context.Context, db *gorm.DB, locales menuLocales, pairs []ownerPost) (map[uint]map[string]bool, error) {
out := map[uint]map[string]bool{}
if len(pairs) == 0 {
return out, nil
}
postIDs := make([]uint, 0, len(pairs))
seen := map[uint]bool{}
for _, p := range pairs {
if !seen[p.PostID] {
seen[p.PostID] = true
postIDs = append(postIDs, p.PostID)
}
}
tr, err := loadMenuTranslations(ctx, db, models.Post{}.MorphName(), postIDs, locales)
if err != nil {
return nil, err
}
for _, p := range pairs {
for _, code := range locales.codes {
if tr.hasContent(locales, code, p.PostID, postContentFields) {
if out[p.OwnerID] == nil {
out[p.OwnerID] = map[string]bool{}
}
out[p.OwnerID][code] = true
}
}
}
return out, nil
}
// ownerPost is one published post of a tag or an author.
type ownerPost struct {
OwnerID uint `gorm:"column:owner_id"`
PostID uint `gorm:"column:post_id"`
}
// tagContentLocales is postContentLocales for tags, through the posts-tags
// pivot.
func tagContentLocales(ctx context.Context, db *gorm.DB, locales menuLocales, tagIDs []uint) (map[uint]map[string]bool, error) {
if len(tagIDs) == 0 {
return map[uint]map[string]bool{}, nil
}
var pairs []ownerPost
posts := publishedPosts(db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Model(&models.Post{})).Select("id")
if err := db.WithContext(ctx).Table(models.PostTag{}.TableName()).
Select("tag_id AS owner_id, post_id").
Where("tag_id IN ? AND post_id IN (?)", tagIDs, posts).
Scan(&pairs).Error; err != nil {
return nil, fmt.Errorf("golem15.journal: tag posts: %w", err)
}
return postContentLocales(ctx, db, locales, pairs)
}
// authorContentLocales is postContentLocales for authors, by the posts'
// user_id.
func authorContentLocales(ctx context.Context, db *gorm.DB, locales menuLocales, userIDs []uint) (map[uint]map[string]bool, error) {
if len(userIDs) == 0 {
return map[uint]map[string]bool{}, nil
}
var pairs []ownerPost
if err := publishedPosts(db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Model(&models.Post{})).
Select("user_id AS owner_id, id AS post_id").
Where("user_id IN ?", userIDs).
Scan(&pairs).Error; err != nil {
return nil, fmt.Errorf("golem15.journal: author posts: %w", err)
}
return postContentLocales(ctx, db, locales, pairs)
}
// ownerHasContent reports whether an owner (tag or author) has content in
// locale: the host locale (and x-default) always, another locale when one
// of its published posts has content in it.
func ownerHasContent(locales menuLocales, byOwner map[uint]map[string]bool, id uint, locale string) bool {
if locale == "" || locale == xDefaultLocale || locale == locales.hostLocale() {
return true
}
return byOwner[id][locale]
}

View File

@@ -52,13 +52,21 @@ func seedContent(t *testing.T, gdb *gorm.DB) contentSeed {
t.Fatal(err)
}
}
if err := translate.SetTranslated(ctx, gdb, &s.news, "slug", "pl", "wiadomosci"); err != nil {
t.Fatal(err)
// The category and the tag have content in pl (P33-64): the category
// its own translated name, the tag through its published post Hello,
// translated into pl.
for field, value := range map[string]string{"slug": "wiadomosci", "name": "Wiadomości"} {
if err := translate.SetTranslated(ctx, gdb, &s.news, field, "pl", value); err != nil {
t.Fatal(err)
}
}
s.tag = models.Tag{Name: "Mushrooms", Slug: "mushrooms"}
if err := gdb.Create(&s.tag).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Create(&models.PostTag{PostID: s.hello.ID, TagID: s.tag.ID}).Error; err != nil {
t.Fatal(err)
}
return s
}
@@ -323,7 +331,8 @@ func TestContentDescriptorsMatrix(t *testing.T) {
}
}
draft, err := reg.Describe(ctx, classes.MenuTypePost, id(s.draft.ID), "")
if err != nil || draft == nil || draft.Published || localeURLs(draft) != "en=/blog/draft pl=/pl/blog/draft" {
// The draft has no pl translation (P33-64): no pl address.
if err != nil || draft == nil || draft.Published || localeURLs(draft) != "en=/blog/draft pl=" {
t.Fatalf("Describe draft = %+v, %v", draft, err)
}
})
@@ -353,7 +362,8 @@ func TestContentDescriptorsMatrix(t *testing.T) {
if err != nil || len(next.Records) != 1 || next.Records[0].ID != id(second.ID) || next.Next != "" {
t.Fatalf("second tag page = %+v, %v", next, err)
}
if got := localeURLs(&next.Records[0]); got != "en=/blog/tag/forest pl=/pl/blog/tag/forest" {
// Forest has no published post with content in pl (P33-64).
if got := localeURLs(&next.Records[0]); got != "en=/blog/tag/forest pl=" {
t.Fatalf("paged tag URLs = %s", got)
}
if !all.Records[0].Published || all.Records[1].Published {
@@ -393,3 +403,103 @@ func TestContentDescriptorsMatrix(t *testing.T) {
}
})
}
// localeTitles lists a record's locales with their URL and title.
func localeTitles(rec *pact.ContentRecord) string {
parts := []string{}
for _, l := range rec.Locales {
parts = append(parts, l.Locale+"="+l.URL+"|"+l.Title)
}
return strings.Join(parts, " ")
}
// TestContentDescriptorsTranslatedLocales pins G3 (D-55, D-57, P33-64): with
// pl as translate's default and en and de enabled, the descriptors give a
// locale a URL only when the record has content in it: a post and a
// category by their own translation, a tag and an author through one of
// their published posts, the default locale always. Every enabled locale
// keeps its entry (URL "" and the default locale's values for one without
// content), and the menu item types' Resolve still lists all three.
func TestContentDescriptorsTranslatedLocales(t *testing.T) {
requirePostgres(t)
ctx := context.Background()
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
if err := gdb.Exec(`DELETE FROM golem15_translate_locales`).Error; err != nil {
t.Fatal(err)
}
for _, row := range []struct {
code string
def bool
order int
}{{"pl", true, 1}, {"en", false, 2}, {"de", false, 3}} {
if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) VALUES (?, ?, ?, TRUE, ?)`,
row.code, row.code, row.def, row.order).Error; err != nil {
t.Fatal(err)
}
}
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
ada := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada")
bob := seedAuthor(t, gdb, "bob", "Bob", "Builder", "bob")
translated := models.Post{Title: "Grzyby", Slug: "grzyby", Content: "Treść", Published: true, PublishedAt: &past, UserID: &ada}
plain := models.Post{Title: "Tylko po polsku", Slug: "tylko", Content: "Treść", Published: true, PublishedAt: &past, UserID: &bob}
for _, p := range []*models.Post{&translated, &plain} {
if err := gdb.Create(p).Error; err != nil {
t.Fatal(err)
}
}
for field, value := range map[string]string{"title": "Mushrooms", "content": "Body", "slug": "mushrooms"} {
if err := translate.SetTranslated(ctx, gdb, &translated, field, "en", value); err != nil {
t.Fatal(err)
}
}
// A slug alone is no content.
if err := translate.SetTranslated(ctx, gdb, &plain, "slug", "de", "nur"); err != nil {
t.Fatal(err)
}
cat := menuCategory(t, gdb, "Leśne", "lesne", nil, 1, 2)
bare := menuCategory(t, gdb, "Puste", "puste", nil, 3, 4)
if err := translate.SetTranslated(ctx, gdb, &cat, "name", "en", "Forest"); err != nil {
t.Fatal(err)
}
attachCategory(t, gdb, translated, cat)
tag := models.Tag{Name: "Las", Slug: "las"}
if err := gdb.Create(&tag).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Create(&models.PostTag{PostID: translated.ID, TagID: tag.ID}).Error; err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
key, id, want string
}{
{classes.MenuTypePost, id(translated.ID), "pl=/blog/grzyby|Grzyby en=/en/blog/mushrooms|Mushrooms de=|Grzyby"},
{classes.MenuTypePost, id(plain.ID), "pl=/blog/tylko|Tylko po polsku en=|Tylko po polsku de=|Tylko po polsku"},
{classes.MenuTypeCategory, id(cat.ID), "pl=/blog/category/lesne|Leśne en=/en/blog/category/lesne|Forest de=|Leśne"},
{classes.MenuTypeCategory, id(bare.ID), "pl=/blog/category/puste|Puste en=|Puste de=|Puste"},
{classes.MenuTypeTag, id(tag.ID), "pl=/blog/tag/las|Las en=/en/blog/tag/las|Las de=|Las"},
{classes.MenuTypeAuthor, id(ada), "pl=/blog/author/ada|Ada Lovelace en=/en/blog/author/ada|Ada Lovelace de=|Ada Lovelace"},
{classes.MenuTypeAuthor, id(bob), "pl=/blog/author/bob|Bob Builder en=|Bob Builder de=|Bob Builder"},
} {
rec := describe(t, reg, tc.key, tc.id)
if got := localeTitles(rec); got != tc.want {
t.Errorf("Describe(%s, %s) = %s\nwant %s", tc.key, tc.id, got, tc.want)
}
}
// The untranslated locale keeps the default locale's word count.
if rec := describe(t, reg, classes.MenuTypePost, id(translated.ID)); rec.Locales[2].Words == nil || *rec.Locales[2].Words != *rec.Locales[0].Words {
t.Errorf("de words = %v, want the pl count", rec.Locales[2].Words)
}
// The menu item types' Resolve is unchanged: every enabled locale.
for key, ref := range map[string]string{classes.MenuTypePost: id(translated.ID), classes.MenuTypeCategory: id(cat.ID)} {
item, err := reg.Resolve(ctx, key, ref, "", false, "")
if err != nil || item == nil || len(item.Alternates) != 3 {
t.Fatalf("Resolve(%s) = %+v, %v", key, item, err)
}
for _, a := range item.Alternates {
if a.URL == "" {
t.Errorf("Resolve(%s) %s has no URL", key, a.Locale)
}
}
}
}

View File

@@ -218,6 +218,12 @@ func TestJournalAuthorType(t *testing.T) {
if err := gdb.Create(&p).Error; err != nil {
t.Fatal(err)
}
// Ada's post is translated into pl, Bob's is not (P33-64).
if p.UserID != nil && *p.UserID == ada {
if err := translate.SetTranslated(ctx, gdb, &p, "title", "pl", "Od Ady"); err != nil {
t.Fatal(err)
}
}
}
typ, ok := reg.ContentType(classes.MenuTypeAuthor)
@@ -237,7 +243,8 @@ func TestJournalAuthorType(t *testing.T) {
t.Fatalf("Describe Ada = %+v; want URLs %s", rec, want)
}
if tc.on {
if got := localeURLs(describe(t, reg, classes.MenuTypeAuthor, id(bob))); got != "en=/blog/author/bob pl=/pl/blog/author/bob" {
// Bob has no post with content in pl: no pl address.
if got := localeURLs(describe(t, reg, classes.MenuTypeAuthor, id(bob))); got != "en=/blog/author/bob pl=" {
t.Fatalf("login fallback URLs = %s", got)
}
}