Files
sm-journal-plugin/README.md
Jakub Zych 31d901cf25 fix(17): WR-04 resolve journal-post only for published posts
journal-post resolved any post by id, so a menu entry or sitemap row
pointing at a draft or future post published its title and slug on the
anonymous menu API and in sitemap.xml. resolvePost now applies the
published filter; the reference list still offers drafts so editors can
wire a menu before the post goes live.
2026-10-12 21:35:46 +02:00

382 lines
37 KiB
Markdown

# golem15.journal
The compiled journal plugin for SummerCMS applications: translatable posts and categories, tags, a settings singleton, and the tables a host migrate creates for a blog.
```go
import _ "git.golem15.com/golem15/sm-journal-plugin"
```
Module `git.golem15.com/golem15/sm-journal-plugin`, package `journal`, plugin ID `golem15.journal`.
## Overview
The plugin is a compiled SummerCMS plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`.
It requires `golem15.translate`. Post and Category store default-locale values on host columns and other locales in `golem15_translate_attributes`. `MorphName` is the PHP class string (`Golem15\Journal\Models\Post`, `Golem15\Journal\Models\Category`); it is never `reflect.Type.String()`.
WinterCMS theme components such as `journalPost` and `journalPosts` are a later successor, not implemented here.
## Features
- **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).
- **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.
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve.
- **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`.
- **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON.
- **Schema.** Eight gormigrate steps create the six journal tables, the author-slug column and the category `content` column. AutoMigrate is not used. Table names are `golem15_journal_*` only.
## Usage
Mount the plugin in an application as a git submodule at `plugins/golem15/journal`:
```sh
git submodule add git@git.golem15.com:golem15/sm-journal-plugin.git plugins/golem15/journal
```
Add it to the application's `go.work`:
```text
use (
.
./plugins/golem15/journal
)
```
Require it in the application's `go.mod` with a local replace:
```text
require git.golem15.com/golem15/sm-journal-plugin v0.0.0
replace git.golem15.com/golem15/sm-journal-plugin => ./plugins/golem15/journal
```
List it in `summer.yaml` after translate and run `summer build`:
```yaml
module: git.golem15.com/golem15/acme
binary: acme
plugins:
- id: golem15.translate
module: git.golem15.com/golem15/sm-translate-plugin
- id: golem15.journal
module: git.golem15.com/golem15/sm-journal-plugin
```
The plugin's own `go.mod` replaces the framework with `../summercms.go` for a sibling checkout next to `summercms.go`. A host application that mounts the plugin at `plugins/golem15/journal` should replace the framework from the host `go.work`.
`Requires()` is exactly `golem15.translate`.
A blog frontend lists published posts and fetches one by slug without a token:
```http
GET /_journal/api/v1/posts
GET /_journal/api/v1/posts/hello-world
GET /_journal/api/v1/categories
GET /_journal/api/v1/tags
GET /_journal/api/v1/rss
GET /_journal/api/v1/authors/jane-doe
GET /_journal/api/v1/authors/jane-doe/posts?page=2
```
### Public categories API
`GET /_journal/api/v1/categories` returns `{"data":[...]}`, a tree of category rows. Each row has the keys `id`, `name`, `slug`, `description`, `content`, `parent_id`, `nest_depth` and `post_count`, plus `children` only when the category has listed children. A category with no published posts and no listed children is omitted unless the request adds `include_empty=1`.
`content` is the category's HTML body for a category landing page. It is stored and returned as is: it is not markdown and the API does not render, trim or sanitize it. It is `null` when unset, and it is the same in every locale, because it is a host column and not translatable. A consumer that renders it as HTML must apply its own gate or sanitizer first.
The admin Categories form edits content with the `blogeditor` field type: a save strips what the HTML policy does not allow, names what it removed in the response's `meta.notices`, and is never refused with a `422` because of markup. Blank content is stored as `null`. Rows written by SQL or by import commands are not sanitized by the form, so consumers keep their own gate.
```json
{"data":[{"id":4,"name":"Acme news","slug":"news","description":null,"content":"<h2>Acme news</h2><p>Pillar copy</p>","parent_id":null,"nest_depth":0,"post_count":3}]}
```
### Public author API
`GET /_journal/api/v1/authors/{author}` returns one author's public profile without a token. `{author}` resolves in this order:
1. `backend_users.golem15_bloghub_author_slug`, the public author slug. A user with a slug resolves even before their first post.
2. `backend_users.login`, but only for a user with at least one published post. That login is already public through the post `author` object, so other backend accounts cannot be enumerated.
Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login and no slug.
```json
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}
```
The author object has exactly these four keys. `slug` is the canonical identifier: the author slug when set, otherwise the login, so a consumer that links by login sets its canonical URL from `slug`. Missing names are empty strings. The object carries no contact, credential, permission or role data and has no `login` key; `slug` falls back to the login only for an author whose login is already public through a published post.
An unknown author, a login without published posts, a soft-deleted user and a malformed value (empty, over 255 bytes, invalid UTF-8 or containing a control character) all answer `404`:
```json
{"error":"Author not found"}
```
`GET /_journal/api/v1/authors/{author}/posts` lists that author's published posts (`published` true and `published_at` in the past), resolving `{author}` the same way. Rows have the same shape as `GET /_journal/api/v1/posts` rows. Bearer tokens are ignored, so an editor also sees published posts only. Query keys:
| Key | Default | Notes |
|-----|---------|-------|
| `page` | `1` | Values below 1 or non-numeric fall back to page 1. |
| `per_page` | `9` | Clamped to 1..30. |
| `sort` | `published_at desc` | Same columns as `/posts`: `published_at`, `created_at`, `updated_at`, `title`, `id`, each `asc` or `desc`. |
The response uses the `/posts` pagination envelope (rows trimmed here):
```json
{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}}
```
An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route.
Editors create a post with a cabana backend JWT (audience `backend`) or a personal admin API token:
```http
POST /_journal/api/v1/posts
Authorization: Bearer <backend-access-token-or-g15-token>
Content-Type: application/json
{"title":"Hello","content":"A blog post"}
```
### Personal API tokens
The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`):
| Scope | Methods | Cover |
|-------|---------|-------|
| `journal` | GET, HEAD, POST, PUT, PATCH, DELETE | `golem15.journal.*` |
| `journal:read` | GET, HEAD | `golem15.journal.*` |
Mint a token from My account (or `admin:token:create`) with scope `journal`, then call the Journal API as that administrator:
```http
POST /_journal/api/v1/posts
Authorization: Bearer g15_<40-hex>
Content-Type: application/json
{"title":"Agent draft","content":"Written with a journal token"}
```
Optional-auth reads (`GET /posts` and `GET /posts/{slug}`) unlock drafts the token owner may see when the Bearer token is a valid `journal` or `journal:read` token. A missing, invalid, expired, revoked or out-of-cover token on those GETs is treated as anonymous (no 401). Write routes stay Bearer-only: missing or bad credentials answer `401 {"error":"Authentication required"}`; a `journal:read` token on a write, or a token whose scopes do not cover `golem15.journal.*`, answers `403 {"error":"Insufficient permissions"}`.
This reverses Phase 15 D-15 (JWT-only Journal writes): principals resolve through `cabana.ResolveBackend` with cover `golem15.journal.*`, so the admin cookie never authenticates a Journal request. Featured-image upload and delete still authorize only through `Post.CanEdit` (PHP parity — no separate `access_posts` check on those routes). Because the resolver uses the admin guard's compiled registry, the Journal API now honors code-declared role grants (`pact.Permission.Roles`, for example the developer role on `golem15.journal.access_posts`); previously a nil registry meant only row grants applied. The `journal-api` throttle bucket still keys by client IP (it runs before the handler resolves the principal).
A blog post model already declares translatable fields:
```go
func (Post) Translatable() []string {
return []string{"title", "content", "content_html", "excerpt", "metadata", "slug"}
}
func (Post) TranslatableIndexes() []string { return []string{"slug"} }
func (Post) MorphName() string { return `Golem15\Journal\Models\Post` }
```
### Blog Editor
`assets/src/blogeditor` is the Blog Editor admin element, the custom element `golem15-journal-blogeditor` (`BlogEditorElement`). It is a WYSIWYG HTML editor on TipTap (ProseMirror, MIT) with a Source tab that shows and edits the exact HTML string. The toolbar offers paragraphs and headings 2 to 4, bold, italic, bullet and ordered lists, links, blockquotes, tables, images, YouTube embeds, a raw HTML block, undo and redo, plus a Preview toggle. Labels follow the `ui-locale` attribute (`en`, `pl`).
The element follows the admin field type element contract: the `value` property holds the HTML string; the attributes are `field-name`, `record-id`, `locale`, `ui-locale`, `options`, `disabled` and `invalid`; a bubbling `summer-change` event with `detail.value` fires on user edits only. It styles itself only through the admin `--c-*` variables, in `blogeditor.css` under its tag (TipTap's injected stylesheet is off), and never receives or sends credentials.
- **Untouched values.** The element returns the exact string it was given until the user edits; an untouched imported post is saved byte for byte. Setting `value` to the string it currently returns is a no-op. After an edit, top-level blocks that are still equal to the loaded ones keep their original bytes, so editing one paragraph changes only that paragraph. Markup the schema does not model (`footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, `div` and similar blocks, and a `figure` that is not an image with an optional caption) becomes a raw HTML card that shows its source as text and is written back verbatim. On paragraphs, headings, lists, blockquotes, tables, cells, images and links, and on `span` elements, the view-safe unmodelled attributes survive an edit of their block: `class`, `title`, `alt`, `width`, `height`, `colspan`, `rowspan`, `start`, `rel`, `target` and `data-*` (`href` and `src` are kept by the link and image nodes, which check them). Every other attribute, such as `id`, `name`, `style`, `srcset`, `form`, `contenteditable`, `popover`, `itemprop` or an event handler (`on*`), is never rendered into the admin page and is dropped from a block once that block is edited; a `span` left with none of the kept attributes is dropped and its text kept. Untouched documents and untouched blocks keep every stored attribute.
- **Embeds.** The embed button takes a YouTube URL (`youtube.com/watch?v=`, `youtu.be/`, `youtube.com/shorts/`, `youtube.com/embed/`) and stores exactly `<figure data-embed="youtube" data-id="ID"></figure>`, where `ID` matches `^[A-Za-z0-9_-]{11}$`. The admin shows a card and never an iframe; the site layer expands the placeholder. Another provider is one entry in `EMBED_PROVIDERS` (`embed.ts`) and one in the layer.
- **Preview.** The Preview button (`Podgląd` for `ui-locale` `pl`) is off by default, belongs to each element on its own and shows its state through `aria-pressed`. It splits the editor 50/50; when the element is narrower than about 720px, the pane wraps below the editor. The pane renders the current value from the WYSIWYG or Source tab 300 ms (`PREVIEW_DEBOUNCE_MS` in `preview.ts`) after the last change, and each update reloads the frame. The frame is an `<iframe srcdoc>` with an empty `sandbox` attribute, so no scripts run and its origin is opaque. It has `referrerpolicy="no-referrer"` and a Content-Security-Policy meta (`PREVIEW_CSP`) that allows only inline styles and images. The preview copy leaves out `script`, `iframe`, `frame`, `frameset`, `object`, `embed`, `meta`, `base` and `link` elements and event handler attributes. YouTube placeholders show as thumbnail cards loaded from img.youtube.com, never as a player; these and the post's own images are the only network traffic the pane makes. The styling is post-body typography, with the `--c-*` values read from the element at render time or neutral defaults. Previewing never fires `summer-change` and never changes the value, so an untouched post is still saved byte for byte.
- **Links.** The editor creates and keeps links only to `http`, `https`, `mailto` and relative URLs (`isPolicyUrl` in `extensions.ts`), the schemes both sanitizer policies keep; the scheme is read after removing control characters and spaces. A created link has `href` only; stored links keep their own `target` and `rel`, which the server strips on an edited save.
- **Images.** An image chosen with the file picker (JPEG, PNG, GIF, WebP) or dropped on the editor is handed to the admin page as a bubbling `summer-upload` event with `detail {id, file}`. The admin uploads it with its own session and answers on the element with `summer-upload-result` `{id, url}` or `{id, error}`. On a URL the editor asks for the alt text (required) and an optional caption and inserts `<img src alt>`, or `<figure>` with `<img>` and `<figcaption>` when a caption is given. An error, or no answer within 60 seconds, is shown in the status line and nothing is inserted.
- **Sanitizing.** The element never sanitizes. Every admin save goes through `classes.SanitizeHTMLReport`, which strips what the policy does not allow and reports it; the policy keeps all of the editor's own output.
The element is built by the plugin's own JS build into `assets/dist/blogeditor.js` and `assets/dist/blogeditor.css`. Both files are committed and embedded through `admin.go`, so a host application needs no Node:
```sh
npm ci # exact versions from package-lock.json
npm run build # typecheck, then vite build into assets/dist
npm test # vitest on happy-dom
bash scripts/check-dist.sh # fails when assets/dist differs from a fresh build
```
After changing `assets/src`, run `npm run build` and commit `assets/dist`.
`tests/blogeditor/fixtures/editor-corpus.json` holds the editor's own output, built through editor commands: an embed, images with and without a caption, a table with a header row, links, headings, lists (one starting at 3), a blockquote, inline marks, a code block, a horizontal rule, a hard break, and one document combining them. `tests/blogeditor/corpus.test.ts` rebuilds it and fails when the committed file differs; `UPDATE_CORPUS=1 npm test` rewrites it. The Go policy test and the site layer check the same entries against both sanitizer policies, so entry names stay stable.
### Admin: Blog Editor field types
The plugin registers two admin form field types (`journal.Plugin.FormFieldTypes`, built by `classes.BlogEditorTypes`) that render the Blog Editor element. Any controller's `fields.yaml` may use them:
| Type | Value | Options |
|------|-------|---------|
| `blogeditor` | One HTML string in a host column (category content). | `uploadFolder` |
| `mlblogeditor` | One HTML string per content locale, stored like `mlmarkdown`: the default locale in the host column, the others as translations (post content). | `htmlColumn`, `uploadFolder` |
Both types show the element's Preview pane, and previewing never changes what a save stores.
`htmlColumn` names the translatable attribute that holds the rendered HTML of a markdown value (`content_html` for posts). `uploadFolder` is the folder under `/journal/` that editor images are stored in (`posts`, `categories`; `editor` when unset); it follows the public upload route's folder rule (at most 100 letters, digits, `_`, `-` or `/`, no `..`) and is checked at boot.
```yaml
content:
label: golem15.journal::lang.post.content
type: mlblogeditor
htmlColumn: content_html
uploadFolder: posts
```
What an admin save stores:
- Each submitted locale's value goes through `classes.SanitizeHTMLReport`. A value the policy keeps whole is stored byte for byte. Anything the policy does not allow is removed, the save goes through, and the response's `meta.notices` names what was removed per field and locale (for example `2 elements were removed: script, iframe`), in English or Polish. A save is never refused because of markup; a value that is not text is a `422` on the field.
- For posts, the stored HTML goes to both `content` and `content_html` of that locale: the host columns through the `FormBeforeCreate`/`FormBeforeUpdate` hooks, the other locales through `FormAfterCreate`/`FormAfterUpdate` and the translation writer. The admin path does not run goldmark.
- A locale whose content the administrator did not change is not written, so an imported post keeps both columns byte for byte until its body is edited.
- A post whose `content` does not start with `<` (`classes.LooksLikeHTML`) is a markdown post: the editor opens it with that locale's `content_html`, or with `FormatHTML(content)` when that is empty. Its first edited save stores HTML in both columns.
- Category content follows the same rule and is no longer refused with a `422`.
The public Journal API is unchanged: `POST` and `PUT /_journal/api/v1/posts` still take markdown `content` and store `FormatHTML` output in `content_html`.
Editor images are uploaded by the admin through the field's upload route and stored by `classes.StoreMedia`, the same storage as `POST /_journal/api/v1/media/upload`: the key is `/journal/<uploadFolder>/<name>` with the same numbered suffix when the name is taken and the same public URL. An editor upload must be a JPEG, PNG, GIF or WebP file whose first bytes sniff as the type its extension names (no SVG), at most 10240 KiB. Its name is lowercased and every character outside `a-z`, `0-9`, `.`, `_` and `-` becomes `-`, so the inserted `src` needs no escaping. The public upload route keeps its own naming, request fields, response and messages.
### Menu item types
`journal.Plugin.MenuItemTypes` (built by `classes.MenuItemTypes`) registers the Journal types under the PHP keys of the WinterCMS plugin's `pages.menuitem` handlers. A menu or sitemap plugin offers them as item types and resolves its items through the registry (`signpost` in the framework), which hands each type the URL pattern it layered: the item's own pattern, else the host's override of the type, else, for a list type, the host's override of the record type it names in `PatternFrom`, else the type's default.
| Key | Picks | Default pattern | `PatternFrom` | Resolves to |
|-----|-------|-----------------|---------------|-------------|
| `journal-category` | A category from the category tree | `/blog/category/:slug` | | The category's URL; with nesting, its subcategories as sub-items, each once with its URL. |
| `all-journal-categories` | Nothing | `/blog/category/:slug` | `journal-category` | No URL of its own; every category ordered by name as items. |
| `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. |
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).
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.
A host changes the URLs in its config file `config/menu_items.yaml` (YAML only, because the keys contain dashes). Overriding the two record types is enough; the list types follow:
```yaml
patterns:
journal-post: /blog/:slug
journal-category: /:slug
```
A host may also override a list key itself (for example `all-journal-posts: /articles/:slug`) to give its items a different pattern than the single-record type, and a menu or sitemap item can carry its own pattern.
Category content stays on the Blog Editor (`blogeditor`) field type; it does not move to the framework's `richeditor`.
## Configuration
### Site cache purge
A host site that caches its pages (for example a Nuxt app with `swr` route rules) would keep serving a page rendered before an admin edit until its cache entry expires. With the two keys below set, an admin save of a Journal post or category asks the host site to drop its page caches, so the edit shows on the next page view.
| Key | Environment variable | Value |
|-----|----------------------|-------|
| `golem15.journal.site_cache.purge_url` | `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL` | The host site's purge route, for example `http://127.0.0.1:3000/api/__journal__/cache/purge`. |
| `golem15.journal.site_cache.purge_token` | `SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN` | The bearer token the route expects, at least 32 characters. Keep it in the server's environment file, never in a committed config file. |
With neither key set, nothing is purged and no request is sent. Boot fails, naming the key, when only one of them is set, when the URL is not http or https with a host, when it carries user info or a fragment, when an `http` URL points at a host other than loopback (`127.0.0.0/8`, `::1`, `localhost`; the token would cross the network in clear, so use `https` for any other host), or when the token is shorter than 32 characters. When the purge is enabled, Boot logs the URL's host and path once.
The request is a `POST` to the URL with `Authorization: Bearer <token>`, `Content-Type: application/json` and the body `{"kind":"post","slug":"<slug>"}` or `{"kind":"category","slug":"<slug>"}`. The receiver answers `200` with `{"purged":<n>}`, the number of cache entries it removed.
- **Timing.** The purge is registered with `lagoon.AfterCommit` on the admin save transaction: it runs only after the save commits, and never for a save that fails, so a concurrent page view cannot re-cache the old content. It runs before the save response, so the cache is clear when the administrator sees the save confirmed.
- **Never fails a save.** The request has a 2 s timeout and ignores the cancellation of the admin request. A refused, failing, slow or unreachable purge is logged as a warning (`journal: site cache purge failed`) and the save response is unchanged; a successful one logs `journal: site cache purged` with the kind, slug, status and purged count. The token and the Authorization header are never logged.
- **What purges.** Admin creates, updates and deletes of posts and categories, including a save that changes nothing. Tags, the posts import action and the public Journal API writes do not purge.
- **Receiver.** The host's Nuxt app built on vue-journal-layer provides the route (`POST /api/__journal__/cache/purge`, token in `NUXT_JOURNAL_CACHE_PURGE_TOKEN`). Generate one token on the server and put the same value in both environments.
## API reference
| Identifier | Description |
|------------|-------------|
| `journal.Plugin` | Compiled plugin; `ID` is `golem15.journal`. |
| `journal.Plugin.Requires` | Returns `golem15.translate` only. |
| `journal.Plugin.LangFS` | Embedded English and Polish UI phrasebooks. |
| `journal.Plugin.Migrations` | gormigrate steps registered by this plugin. |
| `journal.Plugin.Models` | Models registered by this plugin. |
| `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. |
| `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. |
| `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). |
| `journal.Plugin.FormFieldTypes` | Registers the `blogeditor` and `mlblogeditor` admin field types. |
| `journal.Plugin.MenuItemTypes` | Registers the five Journal menu item types (`pact.HasMenuItemTypes`). |
| `journal.Plugin.Buckets` | Limiter buckets `journal-public-api` and `journal-api` (Max 120). |
| `models.Post` | Post row; `TableName` is `golem15_journal_posts`. |
| `models.Post.MorphName` | PHP class string `Golem15\Journal\Models\Post`. |
| `models.Post.Fillable` | Allow-list excluding `redactor_id`, `content_html`, and `user_id`. |
| `models.Post.AttachRelations` | `featured_images` and `content_images` attachMany. |
| `models.Post.SearchableAs` | Index name `golem15_journal_posts`. |
| `models.Post.ShouldBeSearchable` | False when unpublished or `search_use_typesense` is off. |
| `models.Post.ToSearchableArray` | Typesense document built after a committed save. |
| `models.Post.CanEdit` | Owner or `golem15.journal.access_other_posts`. |
| `models.Category` | Category row; `TableName` is `golem15_journal_categories`; `Content` holds the HTML body served as `content`. |
| `models.Category.MorphName` | PHP class string `Golem15\Journal\Models\Category`. |
| `models.Category.Fillable` | `name`, `slug`, `code`, `description`, `content`, `parent_id`. |
| `models.Tag` | Tag row; `TableName` is `golem15_journal_tags`. |
| `models.Tag.Fillable` | `name`, `slug`, `description`. |
| `models.Settings` | Singleton settings row; `TableName` is `golem15_journal_settings`. |
| `models.All` | Models registered by this plugin. |
| `updates.All` | gormigrate steps registered by this plugin. |
| `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.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`. |
| `classes.MediaError` | An upload `StoreMedia` refuses, with the request `Field` and the public route's `Message`. |
| `classes.MediaMaxBytes` | The upload size limit, 10240 KiB. |
| `classes.MediaFolderPattern` | The characters an upload folder may hold. |
| `classes.SanitizeHTML` | Strips from HTML (not markdown) what the `FormatHTML` allow-list does not allow; HTML the policy keeps whole is returned byte for byte. The string half of `SanitizeHTMLReport`. |
| `classes.SanitizeHTMLReport` | `SanitizeHTML` plus an `HTMLRemovals` report of what the policy removed; the report is empty exactly when the input comes back unchanged. |
| `classes.HTMLRemovals` | Removed element and attribute names (distinct, first seen, at most ten each) with exact `ElementCount` and `AttributeCount`, removed `Comments`, and `Other` for any other change; `Empty` reports no removal. |
| `classes.FormatTranslation` | Sets `content_html` in one locale's translated attributes: rendered from a non-blank `content` with `FormatHTML`, otherwise a supplied `content_html` sanitized with `SanitizeHTML`. |
| `classes.CheckHTML` | Predicate: an error when the `FormatHTML` allow-list would strip or change the HTML, including any tag that repeats an attribute. No write path refuses with it any more (admin saves strip and report); it is the counterpart of the site layer's gate and is used by the policy tests. |
| `classes.SiteCachePurger` | Sends the host site cache purge request; a nil purger does nothing. |
| `classes.NewSiteCachePurger` | Builds the purger from the purge URL and token and validates them; both empty disables the purge (nil, nil). |
| `classes.SiteCachePurger.Purge` | One purge request for a kind and slug, with a 2 s timeout, logged and never returned as an error. |
| `classes.SiteCachePurger.Endpoint` | The purge URL's host and path, for log lines. |
| `classes.PurgeSiteCacheAfterCommit` | Registers a purge to run after the surrounding save transaction commits. |
| `classes.SiteCachePurgeURLKey`, `classes.SiteCachePurgeTokenKey` | The config keys `golem15.journal.site_cache.purge_url` and `purge_token`. |
| `classes.SiteCachePurgeMinTokenLength`, `classes.SiteCachePurgeTimeout` | The shortest accepted token (32) and the request timeout (2 s). |
## CLI commands
Both commands write JSON files (one per post slug), matching the PHP `journal:export-posts` / `journal:import-posts` tools. They are not a CSV ImportExport framework. A blog application binary registers them through `Plugin.Commands`.
| Command | Arguments and flags | Effect |
|---------|---------------------|--------|
| `journal:export-posts` | `--path` (default `journal-export`), `--dry-run` | Writes one JSON file per post, including non-default-locale translations. Attachments are omitted. |
| `journal:import-posts` | `--path` (default `journal-export`), `--force`, `--dry-run` | Creates posts from those files. Existing slugs are skipped unless `--force` updates them. |
```sh
./bin/acme journal:export-posts --path journal-export
./bin/acme journal:import-posts --path journal-export --dry-run
./bin/acme journal:import-posts --path journal-export --force
```
## Dependencies
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf; signpost in tests.
- Compiled plugin: `golem15.translate`.
- Third-party: `gorm.io/gorm`, `github.com/go-gormigrate/gormigrate/v2`, `github.com/yuin/goldmark`, `github.com/microcosm-cc/bluemonday`, `golang.org/x/net/html`, `gocloud.dev`. Test-only: `github.com/testcontainers/testcontainers-go`.
- Blog Editor JS build (not needed by a host application): `@tiptap/core`, `@tiptap/pm`, `@tiptap/starter-kit`, `@tiptap/extension-table` and `@tiptap/extension-image` 3.31.4, bundled into `assets/dist`; dev-only `vite`, `vitest`, `happy-dom` and `typescript`, pinned exactly in `package.json`.
## Testing
```sh
go test ./...
```
Postgres integration smokes start Docker unless `-short`. Missing Docker is a failure, not a skip, unless `-short`.
Personal API token coverage: `TestResolveErrorMapping` (Docker-free, `controllers/api`) maps resolver errors to the PHP bodies; `TestJournalTokenTracer` and `TestJournalTokenExpansion` (root package, Postgres) mint tokens through the admin account API and exercise write, read-only, unrelated, revoked, featured-image and code-declared-role paths.
`TestMenuItemTypesSmoke` (root package, Postgres) checks the five registered types and resolves each through the registry with host and item pattern overrides, ordered alternates, nesting, the published filter and the `x-default` rule.
The site cache purge has unit tests in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache`) that point the plugin at a local receiver and check one authorized request after each commit.
The Blog Editor has its own suite and drift gate:
```sh
npm test
bash scripts/check-dist.sh
```