diff --git a/classes/content_descriptors.go b/classes/content_descriptors.go index c83bea2..1c213c3 100644 --- a/classes/content_descriptors.go +++ b/classes/content_descriptors.go @@ -11,6 +11,7 @@ import ( "git.golem15.com/golem15/sm-journal-plugin/models" translatemodels "git.golem15.com/golem15/sm-translate-plugin/models" "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/signpost" @@ -57,19 +58,41 @@ func (m menuTypes) describePost(ctx context.Context, in pact.ContentDescribe) (* if len(posts) == 0 { return nil, nil } - records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale) + records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db)) if err != nil { return nil, err } return &records[0], nil } +// descriptorFacts are the site settings the descriptors' facts follow. +type descriptorFacts struct { + h1ToH2 bool // EffectiveH1ToH2: TopHeading is 2, else 0 + authorPages bool // AuthorPagesKey: authors carry a journal-author reference +} + +// facts reads the settings the descriptors follow, on db. +func (m menuTypes) facts(ctx context.Context, db *gorm.DB) descriptorFacts { + var cfg *compass.Config + if m.app != nil { + if app := m.app(); app != nil { + cfg = app.Config + } + } + return descriptorFacts{h1ToH2: EffectiveH1ToH2(ctx, db, cfg), authorPages: m.authorPages()} +} + // describePosts describes posts in the given order. Each locale's URL is // the alternate resolvePost builds; Title is the translated title, // Description the excerpt as plain text, Image the first featured image and // Words the body's word count, each translated with the default value as -// fallback. -func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, pattern, unprefixed string) ([]pact.ContentRecord, error) { +// fallback. The record carries its first publication, its categories by +// name (Parents), its author (with a journal-author reference while author +// 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. +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) if err != nil { @@ -87,7 +110,21 @@ func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, patter if err != nil { return nil, err } - images, err := firstImages(ctx, db, models.Post{}.MorphName(), "featured_images", ids) + featured, err := firstFiles(ctx, db, models.Post{}.MorphName(), "featured_images", ids) + if err != nil { + return nil, err + } + parents, err := postCategoryRefs(ctx, db, ids) + if err != nil { + return nil, err + } + userIDs := []uint{} + for _, p := range posts { + if p.UserID != nil { + userIDs = append(userIDs, *p.UserID) + } + } + authors, err := loadAuthors(ctx, db, userIDs) if err != nil { return nil, err } @@ -95,10 +132,23 @@ func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, patter for i := range posts { p := &posts[i] rec := pact.ContentRecord{ - ID: strconv.FormatUint(uint64(p.ID), 10), - Published: postPublished(p), - LastMod: copyTime(p.UpdatedAt), + ID: strconv.FormatUint(uint64(p.ID), 10), + Published: postPublished(p), + LastMod: copyTime(p.UpdatedAt), + PublishedAt: copyTime(p.PublishedAt), + Parents: parents[p.ID], + TopHeading: topHeading(facts.h1ToH2), } + if p.UserID != nil { + if a, ok := authors[*p.UserID]; ok { + person := pact.ContentPerson{Name: a.name()} + if facts.authorPages { + person.Ref = pact.ContentRef{Type: MenuTypeAuthor, ID: strconv.FormatUint(uint64(a.ID), 10)} + } + rec.Authors = []pact.ContentPerson{person} + } + } + file, hasFile := featured[p.ID] for _, alt := range items[i].Alternates { locale := alt.Locale if locale == xDefaultLocale { @@ -109,13 +159,28 @@ func describePosts(ctx context.Context, db *gorm.DB, posts []models.Post, patter body = tr.value(locale, p.ID, "content", p.Content) } words := wordCount(plainText(body)) + found := parseContentFacts(body) + var images []pact.ContentImage + image := "" + if hasFile { + image = file.URL() + altText := alt.Title + if file.Title != nil && strings.TrimSpace(*file.Title) != "" { + altText = strings.TrimSpace(*file.Title) + } + images = append(images, pact.ContentImage{URL: image, Alt: altText}) + } + images = append(images, found.images...) rec.Locales = append(rec.Locales, pact.ContentLocale{ Locale: alt.Locale, URL: alt.URL, Title: alt.Title, Description: strings.TrimSpace(plainText(tr.value(locale, p.ID, "excerpt", p.Excerpt))), - Image: images[p.ID], + Image: image, Words: &words, + Images: images, + Videos: found.videos, + Headings: found.headings, }) } out = append(out, rec) @@ -131,7 +196,21 @@ func postPublished(p *models.Post) bool { // firstImages maps each record id to the public URL of its first file in // field (sort order, then id). func firstImages(ctx context.Context, db *gorm.DB, morph, field string, ids []uint) (map[uint]string, error) { - out := map[uint]string{} + files, err := firstFiles(ctx, db, morph, field, ids) + if err != nil { + return nil, err + } + out := make(map[uint]string, len(files)) + for id, f := range files { + out[id] = f.URL() + } + return out, nil +} + +// firstFiles maps each record id to its first file in field (sort order, +// then id). +func firstFiles(ctx context.Context, db *gorm.DB, morph, field string, ids []uint) (map[uint]attach.File, error) { + out := map[uint]attach.File{} if len(ids) == 0 { return out, nil } @@ -153,7 +232,7 @@ func firstImages(ctx context.Context, db *gorm.DB, morph, field string, ids []ui continue } if _, seen := out[uint(id)]; !seen { - out[uint(id)] = files[i].URL() + out[uint(id)] = files[i] } } return out, nil @@ -226,6 +305,24 @@ func afterID(after string) uint { return id } +// AuthorPagesKey is the host configuration key that says whether the site +// has author pages (default true). With false, journal-author items resolve +// to nothing, author descriptions carry empty URLs, author paths never +// match and post authors carry no journal-author reference. +const AuthorPagesKey = "golem15.journal.author_pages" + +// authorPages reports AuthorPagesKey, true when unset. +func (m menuTypes) authorPages() bool { + if m.app == nil { + return true + } + app := m.app() + if app == nil || app.Config == nil || !app.Config.Has(AuthorPagesKey) { + return true + } + return app.Config.Bool(AuthorPagesKey) +} + // tagPages reports TagPagesKey, true when unset. func (m menuTypes) tagPages() bool { if m.app == nil { @@ -254,7 +351,7 @@ func (m menuTypes) listPosts(ctx context.Context, in pact.ContentList) (pact.Con if more { posts = posts[:limit] } - records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale) + records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db)) if err != nil { return pact.ContentPage{}, err } @@ -318,7 +415,7 @@ func (m menuTypes) describeCategory(ctx context.Context, in pact.ContentDescribe if len(cats) == 0 { return nil, nil } - records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale) + records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db)) if err != nil { return nil, err } @@ -339,7 +436,7 @@ func (m menuTypes) listCategories(ctx context.Context, in pact.ContentList) (pac if more { cats = cats[:limit] } - records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale) + records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale, m.facts(ctx, db)) if err != nil { return pact.ContentPage{}, err } @@ -348,8 +445,12 @@ func (m menuTypes) listCategories(ctx context.Context, in pact.ContentList) (pac // describeCategories describes categories, always published: each locale's // URL and title as resolveCategory builds them, the translated description -// as plain text and the word count of the category's content. -func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category, pattern, unprefixed string) ([]pact.ContentRecord, error) { +// as plain text and the word count of the category's content. A child +// category's Parents is its parent; every locale carries the pillar text's +// 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). +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 { ids = append(ids, c.ID) @@ -361,11 +462,17 @@ func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category out := make([]pact.ContentRecord, 0, len(cats)) for _, c := range cats { item := ci.item(c) - rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(c.ID), 10), Published: true, LastMod: copyTime(c.UpdatedAt)} + rec := pact.ContentRecord{ID: strconv.FormatUint(uint64(c.ID), 10), Published: true, LastMod: copyTime(c.UpdatedAt), + TopHeading: topHeading(facts.h1ToH2)} + if c.ParentID != nil && *c.ParentID != 0 && *c.ParentID != c.ID { + rec.Parents = []pact.ContentRef{{Type: MenuTypeCategory, ID: strconv.FormatUint(uint64(*c.ParentID), 10)}} + } var words *int + var headings []pact.ContentHeading if c.Content != nil && strings.TrimSpace(*c.Content) != "" { n := wordCount(plainText(*c.Content)) words = &n + headings = parseContentFacts(*c.Content).headings } host := "" if c.Description != nil { @@ -382,6 +489,7 @@ func describeCategories(ctx context.Context, db *gorm.DB, cats []models.Category Title: alt.Title, Description: plainText(ci.tr.value(locale, c.ID, "description", host)), Words: words, + Headings: headings, }) } out = append(out, rec) diff --git a/classes/content_facts.go b/classes/content_facts.go new file mode 100644 index 0000000..209b02b --- /dev/null +++ b/classes/content_facts.go @@ -0,0 +1,211 @@ +package classes + +import ( + "context" + "strconv" + "strings" + + "git.golem15.com/golem15/summercms/modules/pact" + xhtml "golang.org/x/net/html" + "gorm.io/gorm" +) + +// The MCP tools a Journal content descriptor names (pact.ContentDescriptor +// Tools, D-96): Journal fixes its own content. +const ( + toolUpdatePost = "journal_update_post" + toolFixHeadings = "journal_fix_headings" + toolSetImageAlt = "journal_set_image_alt" +) + +// contentFacts is what one locale's body says about itself: its images +// (src, alt, decorative, size attributes), its YouTube embeds and its +// heading outline, each in document order. +type contentFacts struct { + images []pact.ContentImage + videos []pact.ContentVideo + headings []pact.ContentHeading +} + +// parseContentFacts reads html once with the x/net/html tokenizer. An img +// without src is skipped; an img whose alt attribute is present and empty +// is decorative. A figure with data-embed="youtube" and a data-id is a +// video (the Blog Editor's embed placeholder). A heading's text is its +// text content with white space collapsed; a heading inside a heading is +// read as part of the outer one. +func parseContentFacts(html string) contentFacts { + var out contentFacts + if strings.TrimSpace(html) == "" { + return out + } + z := xhtml.NewTokenizer(strings.NewReader(html)) + level := 0 + var text strings.Builder + for { + tt := z.Next() + if tt == xhtml.ErrorToken { + break + } + switch tt { + case xhtml.TextToken: + if level > 0 { + text.WriteString(z.Token().Data) + } + case xhtml.StartTagToken, xhtml.SelfClosingTagToken: + tok := z.Token() + switch tok.Data { + case "img": + if img, ok := contentImage(tok); ok { + out.images = append(out.images, img) + } + case "figure": + if attr(tok, "data-embed") == "youtube" { + if id := attr(tok, "data-id"); id != "" { + out.videos = append(out.videos, pact.ContentVideo{Provider: "youtube", ID: id}) + } + } + case "h1", "h2", "h3", "h4", "h5", "h6": + if level == 0 && tt == xhtml.StartTagToken { + level = int(tok.Data[1] - '0') + text.Reset() + } + } + if level > 0 && tt == xhtml.StartTagToken && !inlineListTags[tok.Data] && !isHeading(tok.Data) { + text.WriteByte(' ') + } + case xhtml.EndTagToken: + tok := z.Token() + if level > 0 && tok.Data == "h"+strconv.Itoa(level) { + out.headings = append(out.headings, pact.ContentHeading{ + Level: level, + Text: strings.Join(strings.Fields(text.String()), " "), + }) + level = 0 + } + } + } + return out +} + +func isHeading(name string) bool { + return len(name) == 2 && name[0] == 'h' && name[1] >= '1' && name[1] <= '6' +} + +func contentImage(tok xhtml.Token) (pact.ContentImage, bool) { + src := strings.TrimSpace(attr(tok, "src")) + if src == "" { + return pact.ContentImage{}, false + } + img := pact.ContentImage{URL: src} + if alt, has := attrOK(tok, "alt"); has { + img.Alt = strings.TrimSpace(alt) + img.Decorative = img.Alt == "" + } + img.Width, _ = strconv.Atoi(strings.TrimSpace(attr(tok, "width"))) + img.Height, _ = strconv.Atoi(strings.TrimSpace(attr(tok, "height"))) + if img.Width < 0 { + img.Width = 0 + } + if img.Height < 0 { + img.Height = 0 + } + return img, true +} + +func attr(tok xhtml.Token, key string) string { + v, _ := attrOK(tok, key) + return v +} + +func attrOK(tok xhtml.Token, key string) (string, bool) { + for _, a := range tok.Attr { + if a.Namespace == "" && a.Key == key { + return a.Val, true + } + } + return "", false +} + +// topHeading is a record's TopHeading: 2 while the H1 setting is on (the +// page title is the H1), 0 (any level) while it is off. +func topHeading(h1ToH2 bool) int { + if h1ToH2 { + return 2 + } + return 0 +} + +// postCategoryRefs maps each post id to its categories as journal-category +// references, by category name, ties by id (the order :category takes its +// first category in). +func postCategoryRefs(ctx context.Context, db *gorm.DB, ids []uint) (map[uint][]pact.ContentRef, error) { + out := map[uint][]pact.ContentRef{} + if len(ids) == 0 { + return out, nil + } + var rows []struct { + PostID uint `gorm:"column:post_id"` + CategoryID uint `gorm:"column:category_id"` + } + err := db.WithContext(ctx).Table("golem15_journal_posts_categories AS pc"). + Select("pc.post_id, pc.category_id"). + Joins("JOIN golem15_journal_categories c ON c.id = pc.category_id"). + Where("pc.post_id IN ?", ids). + Order("pc.post_id ASC, c.name ASC, c.id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + for _, r := range rows { + out[r.PostID] = append(out[r.PostID], pact.ContentRef{Type: MenuTypeCategory, ID: strconv.FormatUint(uint64(r.CategoryID), 10)}) + } + return out, nil +} + +// authorRow is the part of a backend user a descriptor shows. +type authorRow struct { + ID uint `gorm:"column:id"` + FirstName string `gorm:"column:first_name"` + LastName string `gorm:"column:last_name"` + Login string `gorm:"column:login"` + AuthorSlug string `gorm:"column:author_slug"` +} + +// authorColumns is the only projection descriptors read from backend_users: +// no contact, credential or permission column. +const authorColumns = "id, COALESCE(first_name, '') AS first_name, COALESCE(last_name, '') AS last_name, " + + "login, COALESCE(golem15_bloghub_author_slug, '') AS author_slug" + +// name is the author's display name: first and last name, else the login. +func (a authorRow) name() string { + if n := strings.TrimSpace(strings.TrimSpace(a.FirstName) + " " + strings.TrimSpace(a.LastName)); n != "" { + return n + } + return a.Login +} + +// slug is the :author value: the author slug, else the login (the author +// API's own lookup order). +func (a authorRow) slug() string { + if a.AuthorSlug != "" { + return a.AuthorSlug + } + return a.Login +} + +// loadAuthors reads the live backend users with the given ids. +func loadAuthors(ctx context.Context, db *gorm.DB, ids []uint) (map[uint]authorRow, error) { + out := map[uint]authorRow{} + if len(ids) == 0 { + return out, nil + } + var rows []authorRow + if err := db.WithContext(ctx).Table("backend_users").Select(authorColumns). + Where("deleted_at IS NULL AND id IN ?", ids).Find(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + out[r.ID] = r + } + return out, nil +} diff --git a/classes/menu_item_types.go b/classes/menu_item_types.go index 214a7d8..9300670 100644 --- a/classes/menu_item_types.go +++ b/classes/menu_item_types.go @@ -26,6 +26,9 @@ const ( MenuTypeCategoryPosts = "category-journal-posts" // MenuTypeTag is one tag; it has no PHP counterpart (Phase 32, D-102). MenuTypeTag = "journal-tag" + // MenuTypeAuthor is one author's page; it has no PHP counterpart + // (Phase 33, D-43). + MenuTypeAuthor = "journal-author" ) // The phrase keys of the menu item type labels. @@ -127,6 +130,8 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType { Records: m.listCategories, Describe: m.describeCategory, Match: m.matchCategory, + Kind: pact.ContentKindCollection, + Tools: map[string]string{pact.ContentToolHeadings: toolFixHeadings}, }, }, { @@ -151,6 +156,12 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType { Records: m.listPosts, Describe: m.describePost, Match: m.matchPost, + Kind: pact.ContentKindArticle, + Tools: map[string]string{ + pact.ContentToolEdit: toolUpdatePost, + pact.ContentToolHeadings: toolFixHeadings, + pact.ContentToolImageAlt: toolSetImageAlt, + }, }, }, { @@ -185,6 +196,7 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType { Records: m.listTags, Describe: m.describeTag, Match: m.matchTag, + Kind: pact.ContentKindCollection, }, }, } diff --git a/journal_descriptor_fields_test.go b/journal_descriptor_fields_test.go new file mode 100644 index 0000000..325bad5 --- /dev/null +++ b/journal_descriptor_fields_test.go @@ -0,0 +1,187 @@ +package journal_test + +import ( + "context" + "reflect" + "testing" + "time" + + "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/sm-journal-plugin/models" + translate "git.golem15.com/golem15/sm-translate-plugin/classes" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/signpost" + "gorm.io/gorm" +) + +// seedLocales enables en (default) and pl. +func seedLocales(t *testing.T, gdb *gorm.DB) { + t.Helper() + for _, row := range []struct { + code, name string + def bool + order int + }{{"en", "English", true, 1}, {"pl", "Polski", false, 2}} { + if err := gdb.Exec(`INSERT INTO golem15_translate_locales (code, name, is_default, is_enabled, sort_order) + SELECT ?, ?, ?, TRUE, ? WHERE NOT EXISTS (SELECT 1 FROM golem15_translate_locales WHERE code = ?)`, + row.code, row.name, row.def, row.order, row.code).Error; err != nil { + t.Fatal(err) + } + } +} + +// seedAuthor inserts a backend user with an optional author slug. +func seedAuthor(t *testing.T, gdb *gorm.DB, login, first, last, slug string) uint { + t.Helper() + var userID uint + if err := gdb.Raw(`INSERT INTO backend_users (login, email, password, first_name, last_name, is_activated, is_superuser, created_at, updated_at) + VALUES (?, ?, 'x', ?, ?, TRUE, FALSE, NOW(), NOW()) RETURNING id`, login, login+"@example.test", first, last).Scan(&userID).Error; err != nil || userID == 0 { + t.Fatalf("backend user %s: %d %v", login, userID, err) + } + if slug != "" { + if err := gdb.Exec(`UPDATE backend_users SET golem15_bloghub_author_slug = ? WHERE id = ?`, slug, userID).Error; err != nil { + t.Fatal(err) + } + } + return userID +} + +func describe(t *testing.T, reg *signpost.Registry, key, recordID string) *pact.ContentRecord { + t.Helper() + rec, err := reg.Describe(context.Background(), key, recordID, "") + if err != nil || rec == nil { + t.Fatalf("Describe %s %s = %+v, %v", key, recordID, rec, err) + } + return rec +} + +// TestJournalDescriptorFields (D-93, D-43, D-96, P33-06): posts describe +// their publication, categories in order, authors, featured and body +// images, YouTube embeds, heading outline, top heading level, kind and +// tools; categories their parent, pillar outline, top heading and tools; +// tags their kind. TopHeading follows the H1 setting. +func TestJournalDescriptorFields(t *testing.T) { + requirePostgres(t) + for _, tc := range []struct { + name string + files map[string]string + authorPages bool + }{ + {"author pages on", map[string]string{}, true}, + {"author pages off", map[string]string{"golem15.yaml": "journal:\n author_pages: false\n"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + _, gdb, reg := bootMenuItemTypes(t, tc.files) + seedLocales(t, gdb) + ctx := context.Background() + alpha := menuCategory(t, gdb, "Alpha", "alpha", nil, 1, 4) + beta := menuCategory(t, gdb, "Beta", "beta", nil, 5, 6) + child := menuCategory(t, gdb, "Child", "child", &alpha.ID, 2, 3) + pillar := "
x
x


`} {
+ if err := translate.SetTranslated(ctx, gdb, &post, field, "pl", value); err != nil {
+ t.Fatal(err)
+ }
+ }
+ cover := attach.File{DiskName: "cover.jpg", FileName: "cover.jpg", ContentType: "image/jpeg",
+ AttachmentType: post.MorphName(), AttachmentID: id(post.ID), Field: "featured_images"}
+ if err := gdb.Create(&cover).Error; err != nil {
+ t.Fatal(err)
+ }
+ tag := models.Tag{Name: "Mushrooms", Slug: "mushrooms"}
+ if err := gdb.Create(&tag).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ typ, ok := reg.ContentType(classes.MenuTypePost)
+ wantTools := map[string]string{
+ pact.ContentToolEdit: "journal_update_post",
+ pact.ContentToolHeadings: "journal_fix_headings",
+ pact.ContentToolImageAlt: "journal_set_image_alt",
+ }
+ if !ok || typ.Content.Kind != pact.ContentKindArticle || !reflect.DeepEqual(typ.Content.Tools, wantTools) {
+ t.Fatalf("post descriptor kind %q tools %v", typ.Content.Kind, typ.Content.Tools)
+ }
+ rec := describe(t, reg, classes.MenuTypePost, id(post.ID))
+ if rec.PublishedAt == nil || !rec.PublishedAt.Equal(past) {
+ t.Fatalf("PublishedAt = %v", rec.PublishedAt)
+ }
+ wantParents := []pact.ContentRef{{Type: classes.MenuTypeCategory, ID: id(alpha.ID)}, {Type: classes.MenuTypeCategory, ID: id(beta.ID)}}
+ if !reflect.DeepEqual(rec.Parents, wantParents) {
+ t.Fatalf("Parents = %+v, want %+v", rec.Parents, wantParents)
+ }
+ wantRef := pact.ContentRef{}
+ if tc.authorPages {
+ wantRef = pact.ContentRef{Type: classes.MenuTypeAuthor, ID: id(author)}
+ }
+ if !reflect.DeepEqual(rec.Authors, []pact.ContentPerson{{Name: "Ada Lovelace", Ref: wantRef}}) {
+ t.Fatalf("Authors = %+v", rec.Authors)
+ }
+ if rec.TopHeading != 2 {
+ t.Fatalf("TopHeading = %d, want 2 while the setting is on", rec.TopHeading)
+ }
+ en, pl := rec.Locales[0], rec.Locales[1]
+ wantImages := []pact.ContentImage{
+ {URL: cover.URL(), Alt: "Hello"},
+ {URL: "/a.jpg", Alt: "A", Width: 640, Height: 480},
+ {URL: "/d.jpg", Alt: "", Decorative: true},
+ }
+ if !reflect.DeepEqual(en.Images, wantImages) {
+ t.Fatalf("en Images = %+v, want %+v", en.Images, wantImages)
+ }
+ if !reflect.DeepEqual(pl.Images, []pact.ContentImage{{URL: cover.URL(), Alt: "Witaj"}, {URL: "/p.jpg", Alt: "P"}}) {
+ t.Fatalf("pl Images = %+v", pl.Images)
+ }
+ if !reflect.DeepEqual(en.Videos, []pact.ContentVideo{{Provider: "youtube", ID: "dQw4w9WgXcQ"}}) || len(pl.Videos) != 0 {
+ t.Fatalf("Videos = %+v / %+v", en.Videos, pl.Videos)
+ }
+ wantHeadings := []pact.ContentHeading{{Level: 1, Text: "Top"}, {Level: 2, Text: "Sub heading"}, {Level: 3, Text: "Linked"}}
+ if !reflect.DeepEqual(en.Headings, wantHeadings) || !reflect.DeepEqual(pl.Headings, []pact.ContentHeading{{Level: 2, Text: "PL"}}) {
+ t.Fatalf("Headings = %+v / %+v", en.Headings, pl.Headings)
+ }
+
+ catType, _ := reg.ContentType(classes.MenuTypeCategory)
+ if catType.Content.Kind != pact.ContentKindCollection || !reflect.DeepEqual(catType.Content.Tools, map[string]string{pact.ContentToolHeadings: "journal_fix_headings"}) {
+ t.Fatalf("category descriptor kind %q tools %v", catType.Content.Kind, catType.Content.Tools)
+ }
+ kid := describe(t, reg, classes.MenuTypeCategory, id(child.ID))
+ if !reflect.DeepEqual(kid.Parents, []pact.ContentRef{{Type: classes.MenuTypeCategory, ID: id(alpha.ID)}}) || len(kid.Locales[0].Headings) != 0 {
+ t.Fatalf("child category = %+v", kid)
+ }
+ top := describe(t, reg, classes.MenuTypeCategory, id(alpha.ID))
+ if len(top.Parents) != 0 || top.TopHeading != 2 ||
+ !reflect.DeepEqual(top.Locales[0].Headings, []pact.ContentHeading{{Level: 1, Text: "Pillar"}, {Level: 2, Text: "Part"}}) {
+ t.Fatalf("pillar category = %+v", top)
+ }
+ tagType, _ := reg.ContentType(classes.MenuTypeTag)
+ if tagType.Content.Kind != pact.ContentKindCollection {
+ t.Fatalf("tag kind %q", tagType.Content.Kind)
+ }
+
+ if err := gdb.Exec(`UPDATE golem15_journal_settings SET content_h1_to_h2 = FALSE WHERE id = 1`).Error; err != nil {
+ t.Fatal(err)
+ }
+ if got := describe(t, reg, classes.MenuTypePost, id(post.ID)).TopHeading; got != 0 {
+ t.Fatalf("post TopHeading with the setting off = %d, want 0", got)
+ }
+ if got := describe(t, reg, classes.MenuTypeCategory, id(alpha.ID)).TopHeading; got != 0 {
+ t.Fatalf("category TopHeading with the setting off = %d, want 0", got)
+ }
+ })
+ }
+}