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:
24
README.md
24
README.md
@@ -20,7 +20,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
|
||||
|
||||
- **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`, `mltextarea` and the plugin's own `mlblogeditor` field type for content (see [Admin: Blog Editor field types](#admin-blog-editor-field-types)); an admin save stores the sanitized HTML in both `content` and `content_html`. `classes.FormatHTML` regenerates `content_html` on the other write paths (`journal:import-posts`, Journal API writes). It renders markdown and passes raw HTML in `content` through the renderer (Winter parity), then sanitizes the result with a `bluemonday` allow-list: bluemonday's UGC policy plus `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` styles, goldmark's footnote roles, `dir="auto"`, two pasted-transcript `data-message-*` attributes, and exactly four rules for the Blog Editor's own output (Phase 16.2): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, and `start` matching `^[0-9]+$` on `ol`. No iframe, other data attribute, style property or URL scheme is allowed for the editor; `TestEditorOutputPassesPolicy` checks every entry of the editor corpus against the policy. Like Winter's `Html::clean`, it strips what the policy does not allow instead of refusing the save: script, style, iframe, object, embed, form, base and meta elements, comments, event handler attributes, and URLs other than http, https, mailto and relative ones. HTML the policy keeps whole is stored byte for byte, so imported bodies survive a save. Admin saves strip the same way and name what they removed (`classes.SanitizeHTMLReport`). A tag that repeats an attribute is never kept whole: the sanitized output, which keeps the first value, is stored. On the API and import paths `content` itself is stored as submitted. Translated `content_html` goes through the same policy on the API and `journal:import-posts` write paths (`classes.FormatTranslation`): when the translation carries a non-blank `content`, `content_html` is rendered from it and a supplied `content_html` is replaced, as the host column is always derived; a translation with only `content_html` is sanitized with `classes.SanitizeHTML`. Winter stored a supplied translated `content_html` verbatim. The API response shapes and status codes are unchanged.
|
||||
- **Categories and tags.** `models.Category` is translatable (name, description, slug). It also stores `content`, an HTML host column that is not translatable, edited in the admin with the `blogeditor` field type. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`.
|
||||
- **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) with the framework's menu item type registry, so menus and sitemaps can point at posts and categories. See [menu item types](#menu-item-types).
|
||||
- **Menu and sitemap item types.** The plugin registers its five PHP menu item types (`journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts`) and `journal-tag` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories and tags. Posts, categories and tags carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors).
|
||||
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense.
|
||||
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal.
|
||||
- **Record events.** Post writes through the admin fire the framework's record events (`pact.RecordSaved`, `pact.RecordDeleted`) after the commit, like every admin controller. The `/_journal/api/v1` post routes, which MCP tools call, fire the same events with `cabana.EmitRecordSaved` and `cabana.EmitRecordDeleted`: `POST /posts` a RecordSaved with `Created`, `PUT /posts/{id}` a RecordSaved whose changes hold the post's changed columns and each submitted translation that changed (a translated slug per locale), `DELETE /posts/{id}` a RecordDeleted. Their `Controller` is empty, as they do not go through an admin form. Another plugin (an SEO plugin creating redirects, a search indexer) therefore hears about a slug changed over MCP without importing the Journal.
|
||||
@@ -261,10 +261,11 @@ Editor images are uploaded by the admin through the field's upload route and sto
|
||||
| `journal-post` | A post (listed by title, drafts included) | `/blog/:slug` | | The post's URL while it is published with a past publish date; nothing for a draft or a future post, so the menu entry or sitemap row appears once the post goes live. PHP resolved any post. |
|
||||
| `all-journal-posts` | Nothing | `/blog/:slug` | `journal-post` | No URL of its own; the published posts ordered by title as items. |
|
||||
| `category-journal-posts` | A category from the category tree | `/blog/:slug` | `journal-post` | No URL of its own; the published posts of the category and its descendants ordered by title as items. |
|
||||
| `journal-tag` | A tag (listed by name) | `/blog/tag/:slug` | | The tag's URL; nothing when the host has no tag pages (`golem15.journal.tag_pages: false`). It has no PHP counterpart. |
|
||||
|
||||
Published means published with a publish date in the past, the public API's rule. Ties in every order break by id.
|
||||
|
||||
Placeholders are replaced literally, in order, like the PHP `buildUrlFromPattern`. A category pattern takes `:id` and `:slug`. A post pattern takes `:id`, `:slug`, `:category` (the slug of the post's first category by name) and, for a published post with a publish date, `:year`, `:month` and `:day` (UTC).
|
||||
Placeholders are replaced literally, in order, like the PHP `buildUrlFromPattern`. A category and a tag pattern take `:id` and `:slug`. A post pattern takes `:id`, `:slug`, `:category` (the slug of the post's first category by name) and, for a published post with a publish date, `:year`, `:month` and `:day` (UTC).
|
||||
|
||||
Every resolved record carries alternates, one per enabled translate locale in `sort_order` (ties by id). The unprefixed locale (the caller's, for example a sitemap's frontend default locale, else translate's default locale) gets no prefix; every other locale gets `/<code>`. Slugs and titles are translated per locale and fall back to the default value. Without an enabled locale the only alternate is `x-default`. A record's URL is its first alternate's URL, as in PHP.
|
||||
|
||||
@@ -280,6 +281,14 @@ A host may also override a list key itself (for example `all-journal-posts: /art
|
||||
|
||||
Category content stays on the Blog Editor (`blogeditor`) field type; it does not move to the framework's `richeditor`.
|
||||
|
||||
### Content descriptors
|
||||
|
||||
`journal-post`, `journal-category` and `journal-tag` set `pact.MenuItemType.Content` (built in `classes/content_descriptors.go`): their admin controllers (`golem15.journal.posts`, `golem15.journal.categories`, `golem15.journal.tags`), their morph names (the `Model` record events carry) and three functions the framework's registry calls with the layered pattern. The functions read through the admin save transaction when one is in the context, so a form extension's `Save` sees a slug the save has just changed.
|
||||
|
||||
- **Describe.** One record per enabled locale, with the URL the menu item type resolves to in that locale, the translated title, a plain-text description (a post's excerpt, a category's or tag's description), a post's first featured image and the word count of a post's or category's body. Posts are described with drafts included; `Published` says whether visitors see the post (published, with a past publish date). Categories and tags are always published. On a host with `golem15.journal.tag_pages: false` tag URLs are empty.
|
||||
- **Records.** Every record in id order, paged by the last id (at most 500 per page), drafts included.
|
||||
- **Match.** The record a public path belongs to. The path's locale prefix is read first (an enabled locale other than the unprefixed one), then the pattern's placeholders: a post or category is found by its slug in that locale (translate's slug index), else its default slug, else a slug of another locale, else by `:id`; a tag by its slug or id. The match is exact only when the path is the record's own URL in that locale, so every segment (category, date, id, locale prefix, slug) must fit; a path that names the record with a wrong segment, a stale prefix or another locale's slug matches inexactly, for a redirect to the record's URL. Drafts and future posts never match, and tags never match without tag pages. Path values reach the database only as query parameters.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Site cache purge
|
||||
@@ -309,6 +318,12 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
||||
- **What purges.** Admin creates, updates and deletes of posts and categories, including a save that changes nothing, and the Journal API post writes (create, update, delete, featured image upload and removal). Tags and the posts import action do not purge.
|
||||
- **Receiver.** The host's Nuxt app built on vue-journal-layer 0.4.0 or later provides the shared route `POST /api/__site__/cache/purge` with its token in `NUXT_SITE_CACHE_PURGE_TOKEN`; the old route `POST /api/__journal__/cache/purge` with `NUXT_JOURNAL_CACHE_PURGE_TOKEN` stays for one release. Generate one token on the server and put the same value in both environments.
|
||||
|
||||
### Tag pages
|
||||
|
||||
| Key | Environment variable | Value |
|
||||
|-----|----------------------|-------|
|
||||
| `golem15.journal.tag_pages` | `SUMMER_GOLEM15__JOURNAL__TAG_PAGES` | Whether the host site has tag pages, default `true`. With `false`, `journal-tag` items resolve to nothing, tag descriptions carry empty URLs and tag paths never match, for sites whose frontend turns tag routes off. |
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
@@ -344,8 +359,9 @@ The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Conten
|
||||
| `classes.BlogEditorTypes` | The `blogeditor` and `mlblogeditor` field types: Decode through `SanitizeHTMLReport` with removal notices, Load with the markdown fallback, list text and the image upload. |
|
||||
| `classes.BlogEditorElement` | The editor's custom element tag, `golem15-journal-blogeditor`. |
|
||||
| `classes.MenuItemTypes` | The Journal menu item types with their references and resolvers (see [menu item types](#menu-item-types)). |
|
||||
| `classes.MenuTypeCategory`, `classes.MenuTypeAllCategories`, `classes.MenuTypePost`, `classes.MenuTypeAllPosts`, `classes.MenuTypeCategoryPosts` | The type keys `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts` and `category-journal-posts`. |
|
||||
| `classes.DefaultPostPattern`, `classes.DefaultCategoryPattern` | The default patterns `/blog/:slug` and `/blog/category/:slug`. |
|
||||
| `classes.MenuTypeCategory`, `classes.MenuTypeAllCategories`, `classes.MenuTypePost`, `classes.MenuTypeAllPosts`, `classes.MenuTypeCategoryPosts`, `classes.MenuTypeTag` | The type keys `journal-category`, `all-journal-categories`, `journal-post`, `all-journal-posts`, `category-journal-posts` and `journal-tag`. |
|
||||
| `classes.DefaultPostPattern`, `classes.DefaultCategoryPattern`, `classes.DefaultTagPattern` | The default patterns `/blog/:slug`, `/blog/category/:slug` and `/blog/tag/:slug`. |
|
||||
| `classes.TagPagesKey` | `golem15.journal.tag_pages`, the host key that says whether the site has tag pages. |
|
||||
| `classes.LooksLikeHTML` | Whether stored content is HTML: its first non-space character is `<`. |
|
||||
| `classes.StoreMedia` | Stores an uploaded image under `/journal/<folder>/<name>` and returns its `StoredMedia`; shared by the public upload route and the editor upload. |
|
||||
| `classes.StoredMedia` | A stored media object's public `URL` and `Path`. |
|
||||
|
||||
@@ -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"))
|
||||
|
||||
251
content_descriptors_test.go
Normal file
251
content_descriptors_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"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/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// contentSeed is the records the descriptor tests read: a published post
|
||||
// with a Polish slug in the news category (Polish slug wiadomosci), a
|
||||
// draft, and a tag.
|
||||
type contentSeed struct {
|
||||
news models.Category
|
||||
hello models.Post
|
||||
draft models.Post
|
||||
tag models.Tag
|
||||
}
|
||||
|
||||
func seedContent(t *testing.T, gdb *gorm.DB) contentSeed {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
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)
|
||||
}
|
||||
}
|
||||
var s contentSeed
|
||||
s.news = menuCategory(t, gdb, "News", "news", nil, 1, 2)
|
||||
past := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
||||
s.hello = models.Post{Title: "Hello", Slug: "hello", Excerpt: "<p>Short & sweet</p>", Content: "x", ContentHTML: "<p>One two three</p>", Published: true, PublishedAt: &past}
|
||||
if err := gdb.Create(&s.hello).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.draft = menuPost(t, gdb, "Draft", "draft", false, &past)
|
||||
attachCategory(t, gdb, s.hello, s.news)
|
||||
for field, value := range map[string]string{"slug": "witaj", "title": "Witaj"} {
|
||||
if err := translate.SetTranslated(ctx, gdb, &s.hello, field, "pl", value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := translate.SetTranslated(ctx, gdb, &s.news, "slug", "pl", "wiadomosci"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.tag = models.Tag{Name: "Mushrooms", Slug: "mushrooms"}
|
||||
if err := gdb.Create(&s.tag).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func contentKeys(types []pact.MenuItemType) string {
|
||||
keys := []string{}
|
||||
for _, t := range types {
|
||||
keys = append(keys, t.Key)
|
||||
}
|
||||
return strings.Join(keys, " ")
|
||||
}
|
||||
|
||||
func localeURLs(rec *pact.ContentRecord) string {
|
||||
parts := []string{}
|
||||
for _, l := range rec.Locales {
|
||||
parts = append(parts, l.Locale+"="+l.URL)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// matchPost runs journal-post's Match directly with a pattern.
|
||||
func matchPost(t *testing.T, reg *signpost.Registry, path, pattern string) *pact.ContentMatchResult {
|
||||
t.Helper()
|
||||
typ, ok := reg.ContentType(classes.MenuTypePost)
|
||||
if !ok || typ.Content.Match == nil {
|
||||
t.Fatal("journal-post has no Match")
|
||||
}
|
||||
res, err := typ.Content.Match(context.Background(), pact.ContentMatch{Path: path, Pattern: pattern})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// TestContentDescriptors (D-93, D-11, D-20c): Journal's post and category
|
||||
// descriptors describe, page and match their records through signpost;
|
||||
// strict segments make a wrong category, a stale locale prefix or another
|
||||
// locale's slug inexact, and drafts never match.
|
||||
func TestContentDescriptors(t *testing.T) {
|
||||
requirePostgres(t)
|
||||
ctx := context.Background()
|
||||
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||
s := seedContent(t, gdb)
|
||||
|
||||
if got := contentKeys(reg.ContentTypes()); got != "journal-category journal-post journal-tag" {
|
||||
t.Fatalf("ContentTypes = %s", got)
|
||||
}
|
||||
for controller, key := range map[string]string{
|
||||
"golem15.journal.posts": classes.MenuTypePost,
|
||||
"golem15.journal.categories": classes.MenuTypeCategory,
|
||||
"golem15.journal.tags": classes.MenuTypeTag,
|
||||
} {
|
||||
if typ, ok := reg.ContentTypeForController(controller); !ok || typ.Key != key {
|
||||
t.Fatalf("ContentTypeForController(%s) = %q %v", controller, typ.Key, ok)
|
||||
}
|
||||
}
|
||||
if typ, ok := reg.ContentTypeForModel(models.Category{}.MorphName()); !ok || typ.Key != classes.MenuTypeCategory {
|
||||
t.Fatalf("category model maps to %q %v", typ.Key, ok)
|
||||
}
|
||||
|
||||
t.Run("describe", func(t *testing.T) {
|
||||
rec, err := reg.Describe(ctx, classes.MenuTypePost, id(s.hello.ID), "")
|
||||
if err != nil || rec == nil {
|
||||
t.Fatalf("Describe post = %+v, %v", rec, err)
|
||||
}
|
||||
if got := localeURLs(rec); got != "en=/blog/hello pl=/pl/blog/witaj" {
|
||||
t.Fatalf("post URLs = %s", got)
|
||||
}
|
||||
en := rec.Locales[0]
|
||||
if !rec.Published || en.Title != "Hello" || rec.Locales[1].Title != "Witaj" || en.Description != "Short & sweet" || en.Words == nil || *en.Words != 3 {
|
||||
t.Fatalf("post record = %+v, en %+v", rec, en)
|
||||
}
|
||||
draft, err := reg.Describe(ctx, classes.MenuTypePost, id(s.draft.ID), "")
|
||||
if err != nil || draft == nil || draft.Published {
|
||||
t.Fatalf("Describe draft = %+v, %v", draft, err)
|
||||
}
|
||||
cat, err := reg.Describe(ctx, classes.MenuTypeCategory, id(s.news.ID), "")
|
||||
if err != nil || cat == nil || localeURLs(cat) != "en=/blog/category/news pl=/pl/blog/category/wiadomosci" || !cat.Published {
|
||||
t.Fatalf("Describe category = %+v, %v", cat, err)
|
||||
}
|
||||
if missing, err := reg.Describe(ctx, classes.MenuTypePost, "999999", ""); err != nil || missing != nil {
|
||||
t.Fatalf("Describe of a missing post = %+v, %v", missing, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("records", func(t *testing.T) {
|
||||
page, err := reg.Records(ctx, classes.MenuTypePost, "", 1, "")
|
||||
if err != nil || len(page.Records) != 1 || page.Records[0].ID != id(s.hello.ID) || page.Next != id(s.hello.ID) {
|
||||
t.Fatalf("first page = %+v, %v", page, err)
|
||||
}
|
||||
page, err = reg.Records(ctx, classes.MenuTypePost, page.Next, 1, "")
|
||||
if err != nil || len(page.Records) != 1 || page.Records[0].ID != id(s.draft.ID) || page.Next != "" {
|
||||
t.Fatalf("last page = %+v, %v", page, err)
|
||||
}
|
||||
// The migrations seed a default category before news.
|
||||
cats, err := reg.Records(ctx, classes.MenuTypeCategory, "", 0, "")
|
||||
if err != nil || len(cats.Records) == 0 || cats.Records[len(cats.Records)-1].ID != id(s.news.ID) || cats.Next != "" {
|
||||
t.Fatalf("category page = %+v, %v", cats, err)
|
||||
}
|
||||
tags, err := reg.Records(ctx, classes.MenuTypeTag, "", 10, "")
|
||||
if err != nil || len(tags.Records) != 1 || tags.Records[0].ID != id(s.tag.ID) {
|
||||
t.Fatalf("tag page = %+v, %v", tags, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("match", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path, key, locale string
|
||||
exact bool
|
||||
}{
|
||||
{"/blog/hello", classes.MenuTypePost, "en", true},
|
||||
{"/pl/blog/witaj", classes.MenuTypePost, "pl", true},
|
||||
{"/pl/blog/hello", classes.MenuTypePost, "pl", false},
|
||||
{"/blog/witaj", classes.MenuTypePost, "en", false},
|
||||
{"/en/blog/hello", classes.MenuTypePost, "en", false},
|
||||
{"/blog/category/news", classes.MenuTypeCategory, "en", true},
|
||||
{"/pl/blog/category/wiadomosci", classes.MenuTypeCategory, "pl", true},
|
||||
{"/blog/tag/mushrooms", classes.MenuTypeTag, "en", true},
|
||||
} {
|
||||
key, res, err := reg.MatchPath(ctx, tc.path, "")
|
||||
if err != nil || key != tc.key || res == nil || res.Locale != tc.locale || res.Exact != tc.exact {
|
||||
t.Fatalf("MatchPath(%s) = %q %+v %v, want %s %s exact=%v", tc.path, key, res, err, tc.key, tc.locale, tc.exact)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/blog/draft", "/blog/nothing", "/blog/hello/extra", "/elsewhere"} {
|
||||
if key, res, err := reg.MatchPath(ctx, path, ""); err != nil || res != nil {
|
||||
t.Fatalf("MatchPath(%s) = %q %+v %v, want no match", path, key, res, err)
|
||||
}
|
||||
}
|
||||
// Strict segments under a host pattern with the category.
|
||||
if res := matchPost(t, reg, "/news/hello", "/:category/:slug"); res == nil || !res.Exact || res.ID != id(s.hello.ID) {
|
||||
t.Fatalf("right category = %+v", res)
|
||||
}
|
||||
if res := matchPost(t, reg, "/sport/hello", "/:category/:slug"); res == nil || res.Exact || res.ID != id(s.hello.ID) {
|
||||
t.Fatalf("wrong category = %+v, want the post, inexact", res)
|
||||
}
|
||||
if res := matchPost(t, reg, "/pl/wiadomosci/witaj", "/:category/:slug"); res == nil || !res.Exact || res.Locale != "pl" {
|
||||
t.Fatalf("Polish category path = %+v", res)
|
||||
}
|
||||
if res := matchPost(t, reg, "/blog/"+id(s.hello.ID)+"-x", "/blog/:id-x"); res != nil {
|
||||
t.Fatalf("a placeholder inside a segment matched: %+v", res)
|
||||
}
|
||||
if res := matchPost(t, reg, "/p/"+id(s.hello.ID), "/p/:id"); res == nil || !res.Exact {
|
||||
t.Fatalf("id pattern = %+v", res)
|
||||
}
|
||||
if res := matchPost(t, reg, "/p/"+id(s.draft.ID), "/p/:id"); res != nil {
|
||||
t.Fatalf("draft by id matched: %+v", res)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestJournalTagType (D-102, P-11): journal-tag lists tags as references,
|
||||
// resolves to /blog/tag/<slug>, and is described and matched; with
|
||||
// golem15.journal.tag_pages false its URLs are empty and its paths never
|
||||
// match.
|
||||
func TestJournalTagType(t *testing.T) {
|
||||
requirePostgres(t)
|
||||
ctx := context.Background()
|
||||
_, gdb, reg := bootMenuItemTypes(t, map[string]string{})
|
||||
s := seedContent(t, gdb)
|
||||
|
||||
typ, ok := reg.Type(classes.MenuTypeTag)
|
||||
if !ok || typ.DefaultPattern != classes.DefaultTagPattern || typ.Label != "golem15.journal::lang.menuitem.journal_tag" || typ.Content == nil {
|
||||
t.Fatalf("journal-tag = %+v %v", typ, ok)
|
||||
}
|
||||
refs, err := signpost.ReferenceOptions(ctx, reg, classes.MenuTypeTag)
|
||||
if err != nil || !refs.Applies || len(refs.Options) != 1 || refs.Options[0].Value != id(s.tag.ID) || refs.Options[0].Label != "Mushrooms" {
|
||||
t.Fatalf("tag references = %+v, %v", refs, err)
|
||||
}
|
||||
item := resolve(t, reg, classes.MenuTypeTag, id(s.tag.ID), "", false)
|
||||
if item.URL != "/blog/tag/mushrooms" || item.Title != "Mushrooms" {
|
||||
t.Fatalf("resolved tag = %+v", item)
|
||||
}
|
||||
rec, err := reg.Describe(ctx, classes.MenuTypeTag, id(s.tag.ID), "")
|
||||
if err != nil || rec == nil || localeURLs(rec) != "en=/blog/tag/mushrooms pl=/pl/blog/tag/mushrooms" {
|
||||
t.Fatalf("Describe tag = %+v, %v", rec, err)
|
||||
}
|
||||
|
||||
_, offDB, off := bootMenuItemTypes(t, map[string]string{"golem15.yaml": "journal:\n tag_pages: false\n"})
|
||||
offSeed := seedContent(t, offDB)
|
||||
rec, err = off.Describe(ctx, classes.MenuTypeTag, id(offSeed.tag.ID), "")
|
||||
if err != nil || rec == nil || localeURLs(rec) != "en= pl=" {
|
||||
t.Fatalf("Describe tag without tag pages = %+v, %v", rec, err)
|
||||
}
|
||||
if key, res, err := off.MatchPath(ctx, "/blog/tag/mushrooms", ""); err != nil || res != nil {
|
||||
t.Fatalf("tag path matched without tag pages: %q %+v %v", key, res, err)
|
||||
}
|
||||
if got, err := off.Resolve(ctx, classes.MenuTypeTag, id(offSeed.tag.ID), "", false, ""); err != nil || got != nil {
|
||||
t.Fatalf("tag resolved without tag pages: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,7 @@ menuitem:
|
||||
journal_post: Journal post
|
||||
all_journal_posts: All journal posts
|
||||
category_journal_posts: Journal category posts
|
||||
journal_tag: Journal tag
|
||||
scopes:
|
||||
journal: Journal
|
||||
journal_description: Create, edit, publish and delete posts as you, within your Journal permissions.
|
||||
|
||||
@@ -89,6 +89,7 @@ menuitem:
|
||||
journal_post: Wpis (Journal)
|
||||
all_journal_posts: Wszystkie wpisy (Journal)
|
||||
category_journal_posts: Wpisy kategorii (Journal)
|
||||
journal_tag: Tag bloga
|
||||
scopes:
|
||||
journal: Journal
|
||||
journal_description: Twórz, edytuj, publikuj i usuwaj posty jako Ty, w ramach swoich uprawnień Journal.
|
||||
|
||||
Reference in New Issue
Block a user