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

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