# 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":"

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 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 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 `
`, 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 `