feat: content descriptors for posts, categories and the new journal-tag type
- journal-post, journal-category and journal-tag carry pact.ContentDescriptor (controller, morph name, Describe, Records, Match) through signpost - Match reads the locale prefix and the pattern with signpost.MatchPattern, finds the record by its slug in that locale, the default slug, another locale's slug or :id, and is exact only on the record's own URL (D-20c) - journal-tag (/blog/tag/:slug, P-11) with references and Resolve; host key golem15.journal.tag_pages false empties tag URLs and stops tag matches - TestContentDescriptors, TestJournalTagType; README documents both
This commit is contained in:
@@ -9,9 +9,11 @@ import (
|
||||
"unicode"
|
||||
|
||||
"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/lagoon/attach"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -43,7 +45,9 @@ func (m menuTypes) postContent() *pact.ContentDescriptor {
|
||||
return &pact.ContentDescriptor{
|
||||
Controller: postsControllerID,
|
||||
Model: models.Post{}.MorphName(),
|
||||
Records: m.listPosts,
|
||||
Describe: m.describePost,
|
||||
Match: m.matchPost,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,3 +214,548 @@ func wordCount(s string) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TagPagesKey is the host configuration key that says whether the site has
|
||||
// tag pages (default true). With false, journal-tag items resolve to
|
||||
// nothing, tag descriptions carry empty URLs and tag paths never match.
|
||||
const TagPagesKey = "golem15.journal.tag_pages"
|
||||
|
||||
// maxContentPage caps the page size of a descriptor's Records; a limit of 0
|
||||
// or below asks for the cap.
|
||||
const maxContentPage = 500
|
||||
|
||||
func pageLimit(n int) int {
|
||||
if n <= 0 || n > maxContentPage {
|
||||
return maxContentPage
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// afterID parses a Records After cursor: "" or an invalid value is the
|
||||
// start.
|
||||
func afterID(after string) uint {
|
||||
id, _ := menuReferenceID(after)
|
||||
return id
|
||||
}
|
||||
|
||||
// tagPages reports TagPagesKey, true when unset.
|
||||
func (m menuTypes) tagPages() bool {
|
||||
if m.app == nil {
|
||||
return true
|
||||
}
|
||||
app := m.app()
|
||||
if app == nil || app.Config == nil || !app.Config.Has(TagPagesKey) {
|
||||
return true
|
||||
}
|
||||
return app.Config.Bool(TagPagesKey)
|
||||
}
|
||||
|
||||
// listPosts is the Records of journal-post: every post, drafts included, in
|
||||
// id order.
|
||||
func (m menuTypes) listPosts(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
db, err := m.contentDB(ctx)
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
limit := pageLimit(in.Limit)
|
||||
var posts []models.Post
|
||||
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&posts).Error; err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
more := len(posts) > limit
|
||||
if more {
|
||||
posts = posts[:limit]
|
||||
}
|
||||
records, err := describePosts(ctx, db, posts, in.Pattern, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
return contentPage(records, more), nil
|
||||
}
|
||||
|
||||
func contentPage(records []pact.ContentRecord, more bool) pact.ContentPage {
|
||||
page := pact.ContentPage{Records: records}
|
||||
if page.Records == nil {
|
||||
page.Records = []pact.ContentRecord{}
|
||||
}
|
||||
if more && len(records) > 0 {
|
||||
page.Next = records[len(records)-1].ID
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
// matchPost is the Match of journal-post. The post is found by its slug in
|
||||
// the path's locale, else its default slug, else a slug of another locale,
|
||||
// else by :id; drafts never match. The match is exact only when the path is
|
||||
// the post's own URL in that locale, so every segment (category, date, id,
|
||||
// locale prefix, slug) must fit the post.
|
||||
func (m menuTypes) matchPost(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||
db, err := m.contentDB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
morph := models.Post{}.MorphName()
|
||||
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
|
||||
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
|
||||
return publishedPosts(q.Model(&models.Post{}))
|
||||
})
|
||||
}
|
||||
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
|
||||
var posts []models.Post
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&posts).Error; err != nil || len(posts) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
items, err := resolvePostItems(ctx, db, posts, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items[0].Alternates, nil
|
||||
}
|
||||
return matchContent(ctx, db, in, find, urls)
|
||||
}
|
||||
|
||||
// categoryContent is the D-93 descriptor of journal-category.
|
||||
func (m menuTypes) categoryContent() *pact.ContentDescriptor {
|
||||
return &pact.ContentDescriptor{
|
||||
Controller: categoriesControllerID,
|
||||
Model: models.Category{}.MorphName(),
|
||||
Records: m.listCategories,
|
||||
Describe: m.describeCategory,
|
||||
Match: m.matchCategory,
|
||||
}
|
||||
}
|
||||
|
||||
func (m menuTypes) describeCategory(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
id, ok := menuReferenceID(in.ID)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := m.contentDB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cats []models.Category
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cats) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &records[0], nil
|
||||
}
|
||||
|
||||
func (m menuTypes) listCategories(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
db, err := m.contentDB(ctx)
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
limit := pageLimit(in.Limit)
|
||||
var cats []models.Category
|
||||
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&cats).Error; err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
more := len(cats) > limit
|
||||
if more {
|
||||
cats = cats[:limit]
|
||||
}
|
||||
records, err := describeCategories(ctx, db, cats, in.Pattern, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
return contentPage(records, more), nil
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ids := make([]uint, 0, len(cats))
|
||||
for _, c := range cats {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: pattern, UnprefixedLocale: unprefixed}, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)}
|
||||
var words *int
|
||||
if c.Content != nil && strings.TrimSpace(*c.Content) != "" {
|
||||
n := wordCount(plainText(*c.Content))
|
||||
words = &n
|
||||
}
|
||||
host := ""
|
||||
if c.Description != nil {
|
||||
host = *c.Description
|
||||
}
|
||||
for _, alt := range item.Alternates {
|
||||
locale := alt.Locale
|
||||
if locale == xDefaultLocale {
|
||||
locale = ""
|
||||
}
|
||||
rec.Locales = append(rec.Locales, pact.ContentLocale{
|
||||
Locale: alt.Locale,
|
||||
URL: alt.URL,
|
||||
Title: alt.Title,
|
||||
Description: plainText(ci.tr.value(locale, c.ID, "description", host)),
|
||||
Words: words,
|
||||
})
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// matchCategory is the Match of journal-category, by translated or default
|
||||
// slug or :id, exact only on the category's own URL in the path's locale.
|
||||
func (m menuTypes) matchCategory(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||
db, err := m.contentDB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
morph := models.Category{}.MorphName()
|
||||
find := func(values map[string]string, locale string, locales menuLocales) (uint, error) {
|
||||
return findMatchRecord(ctx, db, values, locale, locales, morph, func(q *gorm.DB) *gorm.DB {
|
||||
return q.Model(&models.Category{})
|
||||
})
|
||||
}
|
||||
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
|
||||
var cats []models.Category
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&cats).Error; err != nil || len(cats) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
ci, err := newCategoryItems(ctx, db, pact.MenuItemResolve{Pattern: in.Pattern, UnprefixedLocale: in.UnprefixedLocale}, []uint{id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ci.item(cats[0]).Alternates, nil
|
||||
}
|
||||
return matchContent(ctx, db, in, find, urls)
|
||||
}
|
||||
|
||||
// tagContent is the D-93 descriptor of journal-tag.
|
||||
func (m menuTypes) tagContent() *pact.ContentDescriptor {
|
||||
return &pact.ContentDescriptor{
|
||||
Controller: tagsControllerID,
|
||||
Model: models.Tag{}.MorphName(),
|
||||
Records: m.listTags,
|
||||
Describe: m.describeTag,
|
||||
Match: m.matchTag,
|
||||
}
|
||||
}
|
||||
|
||||
// tagReferences lists every tag by name, ties by id.
|
||||
func (m menuTypes) tagReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tags []models.Tag
|
||||
if err := db.Order("name ASC, id ASC").Find(&tags).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs := make([]pact.MenuItemReference, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
refs = append(refs, pact.MenuItemReference{Value: strconv.FormatUint(uint64(tag.ID), 10), Label: tag.Name})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// resolveTag resolves one journal-tag item; an unknown tag, and any tag on a
|
||||
// host without tag pages, resolve to nothing.
|
||||
func (m menuTypes) resolveTag(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
id, ok := menuReferenceID(in.Reference)
|
||||
if !ok || !m.tagPages() {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := m.db(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tags []models.Tag
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag := tags[0]
|
||||
item := resolvedMenuItem(in.Pattern, locales, tag.Name, tag.UpdatedAt, tagParams(tag), func(string) string { return tag.Name })
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// tagParams are a tag's placeholder values; tags are not translatable.
|
||||
func tagParams(tag models.Tag) func(string) [][2]string {
|
||||
return func(string) [][2]string {
|
||||
return [][2]string{{"id", strconv.FormatUint(uint64(tag.ID), 10)}, {"slug", tag.Slug}}
|
||||
}
|
||||
}
|
||||
|
||||
func (m menuTypes) describeTag(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
id, ok := menuReferenceID(in.ID)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := m.contentDB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tags []models.Tag
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &records[0], nil
|
||||
}
|
||||
|
||||
func (m menuTypes) listTags(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
db, err := m.contentDB(ctx)
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
limit := pageLimit(in.Limit)
|
||||
var tags []models.Tag
|
||||
if err := db.Where("id > ?", afterID(in.After)).Order("id ASC").Limit(limit + 1).Find(&tags).Error; err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
more := len(tags) > limit
|
||||
if more {
|
||||
tags = tags[:limit]
|
||||
}
|
||||
records, err := m.describeTags(ctx, db, tags, in.Pattern, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
return contentPage(records, more), nil
|
||||
}
|
||||
|
||||
// describeTags describes tags, always published, one locale per enabled
|
||||
// locale with the same name and slug; URLs are empty without tag pages.
|
||||
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()
|
||||
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)}
|
||||
desc := ""
|
||||
if tag.Description != nil {
|
||||
desc = plainText(*tag.Description)
|
||||
}
|
||||
for _, alt := range menuAlternates(pattern, locales, tagParams(tag), func(string) string { return tag.Name }) {
|
||||
url := alt.URL
|
||||
if !pages {
|
||||
url = ""
|
||||
}
|
||||
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: alt.Locale, URL: url, Title: tag.Name, Description: desc})
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// matchTag is the Match of journal-tag, by slug or :id; nothing matches on
|
||||
// a host without tag pages.
|
||||
func (m menuTypes) matchTag(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||
if !m.tagPages() {
|
||||
return nil, nil
|
||||
}
|
||||
db, err := m.contentDB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
find := func(values map[string]string, _ string, _ menuLocales) (uint, error) {
|
||||
q := db.Model(&models.Tag{})
|
||||
switch {
|
||||
case values["slug"] != "":
|
||||
q = q.Where("slug = ?", values["slug"])
|
||||
case values["id"] != "":
|
||||
id, ok := menuReferenceID(values["id"])
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
q = q.Where("id = ?", id)
|
||||
default:
|
||||
return 0, nil
|
||||
}
|
||||
var ids []uint
|
||||
if err := q.Order("id ASC").Limit(1).Pluck("id", &ids).Error; err != nil || len(ids) == 0 {
|
||||
return 0, err
|
||||
}
|
||||
return ids[0], nil
|
||||
}
|
||||
urls := func(id uint) ([]pact.MenuItemAlternate, error) {
|
||||
var tags []models.Tag
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&tags).Error; err != nil || len(tags) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tag := tags[0]
|
||||
return menuAlternates(in.Pattern, locales, tagParams(tag), func(string) string { return tag.Name }), nil
|
||||
}
|
||||
return matchContent(ctx, db, in, find, urls)
|
||||
}
|
||||
|
||||
// pathReading is one way to read a public path: the locale it addresses,
|
||||
// the path without its locale prefix, and whether the prefix is the one the
|
||||
// locale's URLs carry.
|
||||
type pathReading struct {
|
||||
locale string
|
||||
rest string
|
||||
prefixOK bool
|
||||
}
|
||||
|
||||
// readPath lists the readings of path: with a prefix of an enabled locale
|
||||
// other than the unprefixed one, with the unprefixed locale's own code as a
|
||||
// prefix (stale, never exact), and as a path of the unprefixed locale.
|
||||
func readPath(path string, locales menuLocales) []pathReading {
|
||||
out := []pathReading{}
|
||||
seg, rest := path, "/"
|
||||
if trimmed := strings.TrimPrefix(path, "/"); trimmed != path {
|
||||
if i := strings.IndexByte(trimmed, '/'); i >= 0 {
|
||||
seg, rest = trimmed[:i], trimmed[i:]
|
||||
} else {
|
||||
seg = trimmed
|
||||
}
|
||||
for _, code := range locales.codes {
|
||||
if seg == code {
|
||||
out = append(out, pathReading{locale: code, rest: rest, prefixOK: code != locales.unprefixed})
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(out, pathReading{locale: locales.unprefixed, rest: path, prefixOK: true})
|
||||
}
|
||||
|
||||
// matchContent reads the path in every way readPath lists, finds the record
|
||||
// its placeholder values name (find) and compares the path with the
|
||||
// record's URL in the reading's locale (urls). The first exact reading
|
||||
// wins, else the first that found a record.
|
||||
func matchContent(ctx context.Context, db *gorm.DB, in pact.ContentMatch, find func(values map[string]string, locale string, locales menuLocales) (uint, error), urls func(id uint) ([]pact.MenuItemAlternate, error)) (*pact.ContentMatchResult, error) {
|
||||
locales, err := loadMenuLocales(ctx, db, in.UnprefixedLocale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fallback *pact.ContentMatchResult
|
||||
for _, reading := range readPath(in.Path, locales) {
|
||||
values, ok := signpost.MatchPattern(in.Pattern, reading.rest)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, err := find(values, reading.locale, locales)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
alternates, err := urls(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exact := false
|
||||
for _, alt := range alternates {
|
||||
if alt.Locale == reading.locale || (len(locales.codes) == 0 && alt.Locale == xDefaultLocale) {
|
||||
exact = reading.prefixOK && alt.URL == in.Path
|
||||
break
|
||||
}
|
||||
}
|
||||
res := &pact.ContentMatchResult{ID: strconv.FormatUint(uint64(id), 10), Locale: reading.locale, Exact: exact}
|
||||
if exact {
|
||||
return res, nil
|
||||
}
|
||||
if fallback == nil {
|
||||
fallback = res
|
||||
}
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// findMatchRecord finds the record a path's values name among scope: by
|
||||
// :slug, first the slug of the path's locale (translate's slug index), then
|
||||
// the default slug, then a slug of any other locale; else by :id. A value
|
||||
// reaches the database only as a query parameter.
|
||||
func findMatchRecord(ctx context.Context, db *gorm.DB, values map[string]string, locale string, locales menuLocales, morph string, scope func(*gorm.DB) *gorm.DB) (uint, error) {
|
||||
first := func(ids []uint) (uint, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var found []uint
|
||||
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("id IN ?", ids).Order("id ASC").Limit(1).Pluck("id", &found).Error; err != nil || len(found) == 0 {
|
||||
return 0, err
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
if slug := values["slug"]; slug != "" {
|
||||
if locale != "" && locale != locales.defaultCode {
|
||||
ids, err := slugIndexIDs(ctx, db, morph, slug, locale)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if id, err := first(ids); err != nil || id != 0 {
|
||||
return id, err
|
||||
}
|
||||
}
|
||||
var hosts []uint
|
||||
if err := scope(db.Session(&gorm.Session{NewDB: true, Context: ctx})).Where("slug = ?", slug).Order("id ASC").Pluck("id", &hosts).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if id, err := first(hosts); err != nil || id != 0 {
|
||||
return id, err
|
||||
}
|
||||
ids, err := slugIndexIDs(ctx, db, morph, slug, "")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return first(ids)
|
||||
}
|
||||
if raw := values["id"]; raw != "" {
|
||||
id, ok := menuReferenceID(raw)
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
return first([]uint{id})
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// slugIndexIDs are the ids of morph records whose translated slug is slug
|
||||
// in locale ("" for any locale), from translate's index table.
|
||||
func slugIndexIDs(ctx context.Context, db *gorm.DB, morph, slug, locale string) ([]uint, error) {
|
||||
q := db.WithContext(ctx).Table(translatemodels.Index{}.TableName()).
|
||||
Where("model_type = ? AND item = ? AND value = ?", morph, "slug", slug)
|
||||
if locale != "" {
|
||||
q = q.Where("locale = ?", locale)
|
||||
}
|
||||
var raw []string
|
||||
if err := q.Order("id ASC").Pluck("model_id", &raw).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]uint, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
if id, ok := menuReferenceID(r); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ const (
|
||||
MenuTypePost = "journal-post"
|
||||
MenuTypeAllPosts = "all-journal-posts"
|
||||
MenuTypeCategoryPosts = "category-journal-posts"
|
||||
// MenuTypeTag is one tag; it has no PHP counterpart (Phase 32, D-102).
|
||||
MenuTypeTag = "journal-tag"
|
||||
)
|
||||
|
||||
// The phrase keys of the menu item type labels.
|
||||
@@ -33,6 +35,7 @@ const (
|
||||
menuLabelPost = "golem15.journal::lang.menuitem.journal_post"
|
||||
menuLabelAllPosts = "golem15.journal::lang.menuitem.all_journal_posts"
|
||||
menuLabelCategoryPosts = "golem15.journal::lang.menuitem.category_journal_posts"
|
||||
menuLabelTag = "golem15.journal::lang.menuitem.journal_tag"
|
||||
)
|
||||
|
||||
// The default URL patterns of the menu item types. A host overrides them
|
||||
@@ -43,6 +46,8 @@ const (
|
||||
const (
|
||||
DefaultPostPattern = "/blog/:slug"
|
||||
DefaultCategoryPattern = "/blog/category/:slug"
|
||||
// DefaultTagPattern is the tag route of the vue-journal-layer.
|
||||
DefaultTagPattern = "/blog/tag/:slug"
|
||||
)
|
||||
|
||||
// xDefaultLocale is the single alternate's locale when translate has no
|
||||
@@ -54,6 +59,7 @@ const xDefaultLocale = "x-default"
|
||||
var (
|
||||
postPlaceholders = []string{":slug", ":category", ":id", ":year", ":month", ":day"}
|
||||
categoryPlaceholders = []string{":slug", ":id"}
|
||||
tagPlaceholders = []string{":slug", ":id"}
|
||||
)
|
||||
|
||||
// MenuItemTypes returns the Journal menu item types, the typed successors
|
||||
@@ -74,6 +80,14 @@ var (
|
||||
// by title.
|
||||
// - category-journal-posts: a list whose items are the published posts of
|
||||
// the picked category and its descendants, ordered by title.
|
||||
// - journal-tag: one tag picked by id (References lists every tag by
|
||||
// name); it resolves to the tag's URL, /blog/tag/:slug by default, or to
|
||||
// nothing when the host has no tag pages (TagPagesKey false). It has no
|
||||
// PHP counterpart.
|
||||
//
|
||||
// journal-category, journal-post and journal-tag carry content descriptors
|
||||
// (pact.ContentDescriptor, content_descriptors.go), so consumers such as
|
||||
// sitemaps and metadata editors describe, page and match their records.
|
||||
//
|
||||
// A list type builds its items from the pattern it receives, which the
|
||||
// registry layers from the item's override, the host override of the list
|
||||
@@ -107,6 +121,7 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
|
||||
DynamicItems: true,
|
||||
References: m.categoryReferences,
|
||||
Resolve: m.resolveCategory,
|
||||
Content: m.categoryContent(),
|
||||
},
|
||||
{
|
||||
Key: MenuTypeAllCategories,
|
||||
@@ -145,6 +160,15 @@ func MenuItemTypes(app func() *backpack.App) []pact.MenuItemType {
|
||||
References: m.categoryReferences,
|
||||
Resolve: m.resolveCategoryPosts,
|
||||
},
|
||||
{
|
||||
Key: MenuTypeTag,
|
||||
Label: menuLabelTag,
|
||||
DefaultPattern: DefaultTagPattern,
|
||||
Placeholders: append([]string(nil), tagPlaceholders...),
|
||||
References: m.tagReferences,
|
||||
Resolve: m.resolveTag,
|
||||
Content: m.tagContent(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the five
|
||||
// registered types, the ordered placeholder fill of PHP buildUrlFromPattern,
|
||||
// TestMenuItemTypesPatterns pins the Docker-free half of D-04: the six
|
||||
// registered types (the five PHP ones and journal-tag), the ordered placeholder fill of PHP buildUrlFromPattern,
|
||||
// the post placeholders (date parts only for a published post), the
|
||||
// alternates (x-default, unprefixed locale, prefixes, first alternate as the
|
||||
// URL), the translation fallback, the reference parse, the category tree and
|
||||
// the refusals without a booted app.
|
||||
func TestMenuItemTypesPatterns(t *testing.T) {
|
||||
t.Run("the five types", func(t *testing.T) {
|
||||
t.Run("the six types", func(t *testing.T) {
|
||||
types := MenuItemTypes(nil)
|
||||
var got []string
|
||||
for _, typ := range types {
|
||||
@@ -35,6 +35,7 @@ func TestMenuItemTypesPatterns(t *testing.T) {
|
||||
"journal-post|/blog/:slug||nest=false|dyn=false|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.journal_post",
|
||||
"all-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=false|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.all_journal_posts",
|
||||
"category-journal-posts|/blog/:slug|journal-post|nest=false|dyn=true|refs=true|:slug,:category,:id,:year,:month,:day|golem15.journal::lang.menuitem.category_journal_posts",
|
||||
"journal-tag|/blog/tag/:slug||nest=false|dyn=false|refs=true|:slug,:id|golem15.journal::lang.menuitem.journal_tag",
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("types:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
|
||||
Reference in New Issue
Block a user