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:
@@ -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
211
classes/content_facts.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user