# 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 five rules for the Blog Editor's own output (Phase 16.2, Phase 33): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, `start` matching `^[0-9]+$` on `ol`, and `rel` on `a` holding only the tokens `nofollow`, `sponsored` and `ugc` (`^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$`; any other `rel` value is removed). 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`), `journal-tag` and `journal-author` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories, tags and author pages. Posts, categories, tags and authors 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. The Headings and links tab holds the H1 setting `content_h1_to_h2` (on by default) and the generic link texts `generic_anchors`; see [headings and links](#headings-and-links).
- **Image variants.** After a featured image upload commits, the job `golem15.journal.image_variants` writes a thumbnail at each width of `golem15.journal.image_widths` (480, 800 and 1200 by default, never wider than the original, and never taller than 4096 px: such a width of a tall original is skipped with a warning and the job goes on) and a WebP copy of each next to it, recorded in `golem15_journal_image_variants`. The API lists them as `featured_images[].variants`; the upload request never encodes. `journal:image-variants` backfills. See [image variants](#image-variants).
- **Headings and links.** While the H1 setting is on, every H1 saved in Journal rich text (post content and category pillar text) becomes an H2 on every write path, with a notice; `journal:fix-headings` and the MCP tool `journal_fix_headings` convert stored content on request. The Blog Editor follows the setting and adds a heading outline, link `rel` options for external links and image file naming. See [headings and links](#headings-and-links).
- **Search.** With a configured search engine and `search_use_typesense` on, published posts are kept in the Typesense collection `golem15_journal_posts` after every committed save, and `GET /posts?search=` answers in relevance order. Otherwise search is a SQL `ILIKE`. `?fields=suggest` serves short rows for a search box, and `journal:reindex` fills the index. See [search](#search).
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. `/posts/{slug}` finds the post by its default-locale slug, then by its slug in the request locale (the router's locale, from `Accept-Language` or the translate plugin's resolver), then by id, so a translated slug resolves. 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. A tag `POST` or `PUT` creates from a name in `tags` fires its own RecordSaved with `Created`. `POST` and `PUT` write the row, its categories, tags and translations in one transaction and fire the event after it commits, so a write that fails part-way changes nothing and fires nothing. 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.
- **MCP tools.** `journal.Plugin.MCP` declares eleven tools and one prompt for the framework's MCP endpoint (pier, a binary built with the `summercms_mcp` tag): `journal_list_posts`, `journal_show_post`, `journal_list_categories`, `journal_list_tags`, `journal_create_post`, `journal_update_post`, `journal_delete_post`, `journal_get_settings`, `journal_update_settings`, `journal_fix_headings`, `journal_set_image_alt` and the prompt `journal_draft_post`. Each runs the matching `/_journal/api/v1` route as the backend account linked to the MCP user (guard `backend`) and is listed only when that account has `golem15.journal.access_posts` (`golem15.journal.manage_settings` for the settings and `fix_headings` tools); the route checks its own permissions on every call. See [MCP tools](#mcp-tools).
- **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.** Ten gormigrate steps create the six journal tables, the author-slug column, the category `content` column, the settings columns `content_h1_to_h2` and `generic_anchors`, and `golem15_journal_image_variants`. 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":"
Acme news
Pillar copy
","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. Since v0.3.1 that object also carries `slug` (the same canonical identifier as below), and consumers link by it.
```json
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe","subtitle":"Editor","description":"Writes about releases.","avatar":"https://example.com/storage/app/uploads/public/012/345/678/0123456789abcdef01234567.webp"}}
```
The author object has exactly these seven 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`. `subtitle` and `description` are the `subtitle` and `description` keys of `backend_users.metadata` (the subtitle the admin's My account form edits, read as `cabana.BackendUser.Subtitle` reads it); `avatar` is the public URL of the admin avatar (`cabana.BackendUserAvatar`), or `null` when there is none. Missing names, subtitles and descriptions 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","slug":"jane-doe"}}],"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
Content-Type: application/json
{"title":"Hello","content":"A blog post"}
```
### Search
`GET /_journal/api/v1/posts?search=` filters the list by a term of 3 or more characters; a shorter term is ignored and the list comes back unfiltered.
- **SQL.** By default, and whenever the engine cannot be used, the term is matched with `ILIKE` against title, slug, excerpt and content, and the rows keep the list order (`sort`, default newest first).
- **Typesense.** When `search.driver` is `typesense` with an API key and the Journal setting `search_use_typesense` is on, the engine is asked for the first 250 candidates (`typesense.MaxPerPage`), ranked by text match with the setting weights for title, excerpt and content (default 5, 3, 1), then by date. The candidates are re-gated in SQL with the same filters as any list (published, `category`, `tag`, `author`), so a stale index entry never shows. `meta.total` counts the gated rows, the rows keep the engine's rank, with pinned posts first when `pinned_first` is set, and `page` and `per_page` page over them as over any list. An explicit `sort` overrides the rank. A search that matches more than 250 posts keeps the first 250. When the engine fails, the request is answered from SQL and the failure is logged at Warn.
The setting row is read on every engine search, so turning `search_use_typesense` off takes effect on the next request, without a restart.
`?fields=suggest` replaces each row with the short shape a search box needs, under the same pagination envelope, filters and limits:
| Key | Value |
|-----|-------|
| `id` | Post id. |
| `title` | Default-locale title, as in the full row. |
| `slug` | Default-locale slug, as in the full row. |
| `excerpt` | The excerpt as plain text, cut to about 160 characters on a word boundary with `…`; the content as plain text when the post has no excerpt. |
| `featured_image_url` | The first featured image URL, or `null`. |
| `published_at` | As in the full row. |
Any other `fields` value, or none, serves the full rows.
```http
GET /_journal/api/v1/posts?search=borowik&fields=suggest&per_page=5
```
The search document holds the default-locale title, excerpt and content; content is indexed as plain text, from `content_html` (or `content` when nothing was rendered) with tags removed and entities decoded. The collection is created from `models.Post.SearchIndexSchema` when it does not exist. A collection created before the schema existed keeps its auto-detected fields until `journal:reindex --fresh` recreates it.
### Headings and links
Journal decides per site whether its rich text may hold an H1 (2026-10-28, D-50): a page template that shows the post title or the category name as the H1 needs the body to start at H2, and Journal cannot know which template a site uses.
- **The setting.** Settings › Journal, tab Headings and links, switch `content_h1_to_h2` (Convert H1 to H2 in post and category content), on by default. The settings row decides; the config key `golem15.journal.content_h1_to_h2` is used only when no row exists (`classes.EffectiveH1ToH2`). Switching it never rewrites stored content.
- **On.** Every H1 saved in Journal rich text becomes an H2 (`classes.ConvertH1`, on the `golang.org/x/net/html` tokenizer: only the tag name changes, attributes and every other byte are kept), on every write path: an admin save of a post or a category (notice `editor.h1_converted` or `editor.h1_converted_category` on the editor field), `POST` and `PUT /_journal/api/v1/posts` and so MCP (default and translated `content` and `content_html`; the answer gains `notices: [{message, count, locale}]`, `locale` only on a translation's notice), markdown content (its source is converted outside code only; `classes.ConvertContentH1` reads `content` as markdown, as the renderer does: an `
` in a code block or code span keeps its tag, also when the body starts with an HTML block, and a raw `
` elsewhere becomes `
`, as in the rendered HTML) and `journal:import-posts` (it prints `Converted N H1 headings to H2.`). The bytes of an unfinished tag at the end of a text are kept. The editor offers H2 to H4.
- **Off.** H1 is stored as written, no notice appears, the editor offers H1 to H4 and `journal:fix-headings` refuses.
- **Stored content.** `journal:fix-headings` (a dry run unless `--apply`) and the MCP tool `journal_fix_headings` (`dry_run` default `true`) convert H1 already stored in post content (default and translated; in a markdown source outside code only) and in category pillar text, in one transaction, firing a `RecordSaved` per changed post and category; they answer the post, category and heading counts and refuse while the setting is off (exit code 1; `409` from the API).
- **The API flag.** `GET /_journal/api/v1/posts/{slug}` and every category row of `GET /_journal/api/v1/categories` carry `content_h1_to_h2` (boolean, an added key), so a frontend reads the setting from the Journal and not from its own option.
- **Generic link texts.** The `generic_anchors` setting (one per line; the English and Polish seed lists when empty, `classes.DefaultGenericAnchors`) is the list the editor's outline and link form warn about.
- **Settings API.** `GET` and `PUT /_journal/api/v1/settings` read and write every settings column for `golem15.journal.manage_settings` (`403` without it); `PUT` checks the submitted columns against `models.Settings.Rules` and ignores other keys.
- **Image alt.** `POST /_journal/api/v1/images/alt` `{dry_run, changes: [{post_id, locale, src, alt}]}` sets the alt of the image whose `src` equals `src` in the post's `content_html` and in `content`, in the default locale or in `locale`. `content` is read as markdown, as the renderer reads it (an HTML body included, whose `` is a raw image outside code), and gets the alt in its inline image `` (with or without a title, outside code; `classes.SetMarkdownImageAlt`) or a raw `` outside code (`classes.SetImageAltOutsideCode`), also when the markdown starts with an HTML tag, so the next content save keeps the alt; a locale whose `content` is blank (a translation that carries only `content_html`) gets the alt in `content_html` alone. An image the markdown does not hold that way (a reference-style image) refuses the change with `422` `changes.N.src` ("The image is not an inline image of the post's markdown; edit the post."); `alt: ""` marks it decorative. It needs `golem15.journal.access_posts` and edit rights on each post, refuses the whole batch with `422` when one image is not in its post, answers `{changes: [{post_id, locale, src, old_alt, alt, changed}], dry_run}` and fires a `RecordSaved` per changed post. Alt text is post content, so the bulk alt tool is Journal's (`journal_set_image_alt`), not the SEO plugin's.
### Related posts
`GET /_journal/api/v1/posts/{slug}` answers `related_posts` with the post's related posts and `previous_post` and `next_post` with its site-wide neighbours by publish time. The ranking is opt-in (D-82; `classes.ReadRelatedSettings`):
- **`golem15.journal.related_mode: category`** (the default): as before, up to three posts sharing a category with the post, newest first; none for a post without a category. The answer is unchanged.
- **`scored`**: posts sharing a tag or a category, ranked by 2 points per shared tag plus 1 point per shared category, ties by the newest publish time, `golem15.journal.related_count` of them (3 to 6, clamped; 3 when unset). A post that shares nothing is never related. One query computes the scores (`classes.ScoredRelatedPosts`). The answer adds `previous_in_category` and `next_in_category` (`{id, title, slug}` or `null`): the neighbours by publish time within the post's first category by name (`classes.PrimaryCategoryID`, `classes.CategoryNeighbour`), in the direction of `previous_post` and `next_post` (previous is the nearest post published after it, next the nearest one published before it).
Visitors only ever see published posts with a past publish time; an editor who may see drafts also gets drafts among the scored related posts, as the default mode does. The neighbours are published posts only.
### Image variants
Every entry of `featured_images` (on `GET /posts`, `GET /posts/{slug}` and the author lists) carries `variants`, the image's sizes by width, `[]` until they are made (additive key; the other keys are unchanged):
```json
{"id": 12, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg", "file_name": "cover.jpg", "file_size": 183402,
"variants": [
{"width": 480, "height": 288, "url": "/storage/uploads/5f1/d0c/2e9/thumb_12_480_288_0_0_exact.jpg", "webp_url": "/storage/uploads/5f1/d0c/2e9/thumb_12_480_288_0_0_exact.webp", "source_width": 1000, "source_height": 600},
{"width": 800, "height": 480, "url": "…_800_480_0_0_exact.jpg", "webp_url": "…_800_480_0_0_exact.webp", "source_width": 1000, "source_height": 600}
]}
```
`url` is the thumbnail in the original's format (a `.webp` original's thumbnail holds JPEG bytes), `webp_url` its WebP copy at quality 80, and `source_width` and `source_height` the original's size, so a page can list the original in a `srcset` too. One query loads the variants of a post's images.
- **When they are made.** `POST /_journal/api/v1/posts/{id}/featured-images` stores the original and queues the conga job `golem15.journal.image_variants` (`classes.ImageVariantsArgs`, three attempts) after the row is written; the request itself never resizes or encodes, and a queue that cannot take the job is logged without failing the upload. An admin save of a post queues the job, after the save commits, for its featured images that lack a width.
- **What is made.** For each width of `golem15.journal.image_widths` no larger than the original, `classes.GenerateImageVariants` writes the thumbnail (`attach.File.ThumbKey`, mode `exact`, the original's aspect ratio) and its WebP copy (`webp.Variant` of the framework's `modules/lagoon/attach/webp`). A width that has its row is skipped, so the job and the command can run any number of times. A missing or undecodable original, or one over the thumbnailer's 4096 by 4096 pixel limit, is skipped and logged.
- **Backfill.** `journal:image-variants` takes the images that lack a width no larger than their original; `--all` checks every featured image. It runs from the application's CLI too: without a running server it opens `database.dsn` and `storage.uploads.bucket_url` itself.
- **Not made.** AVIF (deferred). Images inserted into a post body keep their original file.
- **Server.** WebP is encoded in Go. With the system libwebp installed (`libwebp.so` and `libwebpdemux.so`, Debian `libwebp-dev`) it is about five times faster. A binary that mounts this plugin is dynamically linked against glibc, unless it is built with `-tags nodynamic` (see the framework's attachments documentation).
### 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, or when the MCP endpoint dispatches the request in-process as a backend principal (no `Authorization` header then; `cabana.ResolveBackend` reads the dispatch marker). 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` }
```
### MCP tools
With the framework's MCP endpoint mounted (`summer.yaml` `build.tags: [summercms_mcp]`, `mcp.enabled: true`), an MCP client connected through the application's OAuth server with the `backend` scope and a verified link to an administrator sees these tools when that administrator has `golem15.journal.access_posts`:
| Tool | Route | Input |
|------|-------|-------|
| `journal_list_posts` | `GET /_journal/api/v1/posts` | query `page`, `per_page`, `search`, `category`, `tag`, `author`, `published`, `sort` |
| `journal_show_post` | `GET /_journal/api/v1/posts/{slug}` | `slug` |
| `journal_list_categories` | `GET /_journal/api/v1/categories` | none |
| `journal_list_tags` | `GET /_journal/api/v1/tags` | none |
| `journal_create_post` | `POST /_journal/api/v1/posts` | `title`, `content`, optional `slug`, `excerpt`, `published`, `published_at`, `is_pinned`, `categories` (ids), `tags` (names) |
| `journal_update_post` | `PUT /_journal/api/v1/posts/{id}` | `id` and any of the create fields; only the fields sent change |
| `journal_delete_post` | `DELETE /_journal/api/v1/posts/{id}` | `id` (destructive) |
| `journal_get_settings` | `GET /_journal/api/v1/settings` | none (`golem15.journal.manage_settings`) |
| `journal_update_settings` | `PUT /_journal/api/v1/settings` | any settings column, for example `content_h1_to_h2` or `generic_anchors`; only the fields sent change (`golem15.journal.manage_settings`) |
| `journal_fix_headings` | `POST /_journal/api/v1/fix-headings` | `dry_run` (default `true`) (`golem15.journal.manage_settings`) |
| `journal_set_image_alt` | `POST /_journal/api/v1/images/alt` | `dry_run`, `changes` (`post_id`, `locale`, `src`, `alt`) (destructive) |
The prompt `journal_draft_post` (arguments `topic`, required, and `tone`) asks the agent to draft a post, reuse existing categories and tags and save it with `journal_create_post` as a draft; it is marked `Writes`, so it is offered only to grants that may write. A grant without the `write` scope lists only the read tools and no prompt. Publishing still needs `golem15.journal.access_publish` and editing another author's post `golem15.journal.access_other_posts`; the route answers its usual error body, which the agent receives as the tool error. The media and featured-image uploads (multipart) are not tools.
### 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 (1 to 4 while the H1 setting is off), 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 ``, 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 `