feat: descriptor dates, parents, authors, media and outline

- posts describe PublishedAt, their categories by name as Parents, the
  author (a journal-author reference while author pages are on), the
  featured and body images, YouTube embeds, the heading outline and
  TopHeading (2 while the H1 setting is on, else 0)
- categories describe their parent, the pillar text's outline and
  TopHeading; kinds article/collection and the journal_ fixing tools
- golem15.journal.author_pages (AuthorPagesKey, default true)
This commit is contained in:
Jakub Zych
2026-11-04 10:16:15 +01:00
parent af25eaa677
commit e8e77a154c
4 changed files with 534 additions and 16 deletions

View File

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

211
classes/content_facts.go Normal file
View File

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

View File

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

View File

@@ -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 := "<h1>Pillar</h1><p>x</p><h2>Part</h2>"
if err := gdb.Model(&models.Category{}).Where("id = ?", alpha.ID).Update("content", pillar).Error; err != nil {
t.Fatal(err)
}
author := seedAuthor(t, gdb, "ada", "Ada", "Lovelace", "ada-l")
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
body := `<h1>Top</h1><p>x</p><h2> Sub heading </h2>` +
`<p><img src="/a.jpg" alt="A" width="640" height="480"><img src="/d.jpg" alt=""></p>` +
`<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure><h3><a href="/x">Linked</a></h3>`
post := models.Post{Title: "Hello", Slug: "hello", Content: body, ContentHTML: body, Published: true, PublishedAt: &past, UserID: &author}
if err := gdb.Create(&post).Error; err != nil {
t.Fatal(err)
}
attachCategory(t, gdb, post, beta)
attachCategory(t, gdb, post, alpha)
for field, value := range map[string]string{"slug": "witaj", "title": "Witaj", "content_html": `<h2>PL</h2><img src="/p.jpg" alt="P">`} {
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)
}
})
}
}