diff --git a/README.md b/README.md index 12f0656..8d54567 100644 --- a/README.md +++ b/README.md @@ -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 `/`. 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 diff --git a/classes/content_descriptors.go b/classes/content_descriptors.go index 1c213c3..dafe016 100644 --- a/classes/content_descriptors.go +++ b/classes/content_descriptors.go @@ -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}) diff --git a/classes/menu_item_types.go b/classes/menu_item_types.go index e987e33..51aa304 100644 --- a/classes/menu_item_types.go +++ b/classes/menu_item_types.go @@ -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] +} diff --git a/content_descriptors_test.go b/content_descriptors_test.go index 7bab0e9..adfa3ee 100644 --- a/content_descriptors_test.go +++ b/content_descriptors_test.go @@ -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) + } + } + } +} diff --git a/journal_descriptor_fields_test.go b/journal_descriptor_fields_test.go index b470f92..1c7d52a 100644 --- a/journal_descriptor_fields_test.go +++ b/journal_descriptor_fields_test.go @@ -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) } }