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`. |
|
||||
|
||||
Reference in New Issue
Block a user