Files
sm-journal-plugin/README.md
Jakub Zych 7662d54ab3 fix: image alt texts on markdown posts are written into the markdown
POST /_journal/api/v1/images/alt writes the alt of a markdown post into
its source: the inline image ![alt](src) outside code
(classes.SetMarkdownImageAlt) or a raw img outside code
(classes.SetImageAltOutsideCode), so the next content save keeps it. An
image the markdown does not hold that way is refused with 422
changes.N.src and nothing is written.
2026-11-04 23:20:02 +01:00

74 KiB
Raw Blame History

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.

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); 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 and 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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:

git submodule add git@git.golem15.com:golem15/sm-journal-plugin.git plugins/golem15/journal

Add it to the application's go.work:

use (
	.
	./plugins/golem15/journal
)

Require it in the application's go.mod with a local replace:

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:

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:

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.

{"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. Since v0.3.1 that object also carries slug (the same canonical identifier as below), and consumers link by it.

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

{"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):

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

POST /_journal/api/v1/posts
Authorization: Bearer <backend-access-token-or-g15-token>
Content-Type: application/json

{"title":"Hello","content":"A blog post"}

GET /_journal/api/v1/posts?search=<term> 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.

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.

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 rendered HTML only: a markdown source is never rewritten, so a code sample that shows <h1> keeps it; classes.ConvertContentH1 rewrites content only when it is HTML, classes.LooksLikeHTML) 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; of a markdown post only content_html) 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: an HTML body when it holds the same image; a markdown body in its inline image ![alt](src) (with or without a title, outside code; classes.SetMarkdownImageAlt) or a raw <img> outside code (classes.SetImageAltOutsideCode), so the next content save keeps the alt. 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.

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):

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

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:

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 <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.
  • Headings (J1). The element reads headingLevels from its options (the field types serve them per form request, see headings and links): [2, 3, 4] while the H1 setting is on, when the toolbar offers H2 to H4 and a pasted or loaded <h1> is parsed as an H2, so the editor never holds an H1; [1, 2, 3, 4] while it is off, with an H1 button first. Stored H5 and H6 still parse. The Source tab accepts any HTML; the server decides what is stored.
  • Outline (J2). A full-width bar between the editor and the status line shows the heading count and, when there are any, the problem count; it starts collapsed and never takes focus. Opened, it lists the headings in document order with their level, indented 12px per level, and flags a skipped level (the page title is the H1), an empty heading, a heading that is only a link and, while the H1 setting is on, an H1 in the Source tab; then the links whose whole text is on the generic list (genericAnchors option). It recomputes 300 ms (OUTLINE_DEBOUNCE_MS) after a change, from the Source text in the Source tab. Activating a heading puts the caret in it. It never edits the content.
  • 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, or href and rel (J3): for an absolute http(s) URL the link form offers nofollow, sponsored (paid) and ugc (user content) and Apply writes the checked ones; links within the site never get rel. When the link text is on the generic list the form warns (“click here” does not say where the link leads) without blocking Apply. Stored links keep their own target and rel; the server keeps only the three rel tokens.
  • Images. An image chosen with the file picker (JPEG, PNG, GIF, WebP) or dropped on the editor opens the image form, one file at a time (J4): the file name, prefilled with the slug of the original name (lowercase, Polish letters transliterated, hyphens, at most 80 characters, the extension shown fixed after it), the alt text (required unless the image is marked decorative), the Decorative image checkbox (it clears and disables the alt and inserts alt="") and an optional caption. Insert hands the renamed file 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 the URL the editor inserts <img src alt>, or <figure> with <img> and <figcaption> when a caption is given. The status line shows the upload until the answer arrives; an error, or no answer within 60 seconds, is shown there 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:

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 decorative image, a table with a header row, links, external links with rel, 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 also serve two options per form request (pact.FormFieldType.RequestOptions): headingLevels ([2, 3, 4] while the H1 setting is on, [1, 2, 3, 4] while it is off) and genericAnchors (the settings' generic link texts, classes.GenericAnchors). A fields.yaml option of the same name wins.

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.

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. While the H1 setting is on, every H1 left is then stored as an H2 (classes.ConvertH1) with the notice 1 H1 heading was converted to H2, because the page shows the post title as the H1. (the category name, on a category). 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. While the H1 setting is on they store every H1 of content, content_html and the submitted translations as an H2, and the answer gains a notices key (see headings and links).

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. Both uploads store the name classes.MediaFileName makes (D-85): the stem lowercased, Polish letters transliterated, every other run of characters outside a-z and 0-9 one hyphen, at most 80 characters (image when nothing is left), and the extension lowercased, so Lwia Grzywa – w lesie!.JPG is stored as lwia-grzywa-w-lesie.jpg and the inserted src needs no escaping. The public upload route keeps its own 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.
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.
journal-author An author: a backend user with at least one published post (listed by name) /blog/author/:author The author page's URL, :author being the author slug, else the login (the public author API's lookup order); nothing when the host has no author pages (golem15.journal.author_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 and a tag pattern take :id and :slug, an author pattern :author and :id. 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:

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.

Content descriptors

journal-post, journal-category, journal-tag and journal-author set pact.MenuItemType.Content (built in classes/content_descriptors.go and classes/menu_item_types.go): their admin controllers (golem15.journal.posts, golem15.journal.categories, golem15.journal.tags; none for authors, who are backend users with the morph name Backend\Models\User), 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.
  • Facts (Phase 33). The descriptors also fill the optional content facts the SEO, sitemap and audit plugins read: Kind (article for posts, collection for categories and tags, profile for authors) and Tools, the MCP tools that fix a record (posts: edit journal_update_post, headings journal_fix_headings, image_alt journal_set_image_alt; categories: headings). A post record carries PublishedAt, its categories by name as Parents, its author in Authors (with a journal-author reference while author pages are on) and TopHeading (2 while the H1 setting is on, 0 while it is off); each locale lists Images (the featured image first, its alt the file's title or else the post title, then the body's images with their alt, Decorative for alt="" and the width and height attributes), Videos (YouTube embeds) and Headings (the body's outline). A child category's Parents is its parent, and a category carries its pillar text's Headings and the same TopHeading. The body facts are read once per locale with golang.org/x/net/html; the queries are batched per page. An author record has the author's name as every locale's title, the author URL per locale (empty without author pages) and the newest update of their published posts as LastMod.
  • Records. Every record in id order, paged by the last id (at most 500 per page), drafts included; for journal-author, the authors with a published post.
  • 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. An author is found by :author (the author slug, then the login) or :id among the authors with a published post. Drafts and future posts never match, tags never match without tag pages and authors never match without author pages. Path values reach the database only as query parameters.

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. An admin save of a Journal post or category, and every post write through the token API (create, update, delete, featured image upload and removal), asks the host site to drop its page caches, so the edit shows on the next page view. The purge itself is the framework's site-cache purger (cooler.SiteCachePurger), shared with the menu and FAQ plugins, so one setting serves every plugin:

Key Environment variable Value
site_cache.purge_url SUMMER_SITE_CACHE__PURGE_URL The host site's purge route, for example http://127.0.0.1:3000/api/__site__/cache/purge.
site_cache.purge_token SUMMER_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. serve fails at boot, 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, start-up logs the URL's host and path once.

Deprecated Journal keys (one release). The Journal's own keys still work when the framework keys are unset:

Deprecated key Environment variable
golem15.journal.site_cache.purge_url SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_URL
golem15.journal.site_cache.purge_token SUMMER_GOLEM15__JOURNAL__SITE_CACHE__PURGE_TOKEN

With only these set, Boot validates them with the same rules (errors name the Journal keys), publishes the purger they describe, so menu and FAQ saves purge through it as well, and logs one deprecation warning naming site_cache.purge_url and site_cache.purge_token. With the framework keys set, the Journal keys are ignored with a warning. Move the values to the framework keys before the next release removes them.

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. The token API writes purge outside their transaction: the purge is queued once the write committed and sent in the background, so the response does not wait for the site.
  • 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 (cooler: site cache purge failed) and the save response is unchanged; a successful one logs cooler: 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, 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.
Key Environment variable Value
golem15.journal.content_h1_to_h2 SUMMER_GOLEM15__JOURNAL__CONTENT_H1_TO_H2 The H1 setting used only when the Journal settings row does not exist, default true (config/config.yaml). The row's content_h1_to_h2 (Settings › Journal, Headings and links) decides otherwise; classes.EffectiveH1ToH2 is the one reader.
Key Environment variable Value
golem15.journal.related_mode SUMMER_GOLEM15__JOURNAL__RELATED_MODE category (default, config/config.yaml): related posts as before. scored: ranked by shared tags and categories, plus previous_in_category and next_in_category on the post answer. See related posts.
golem15.journal.related_count SUMMER_GOLEM15__JOURNAL__RELATED_COUNT The number of scored related posts, default 3, clamped to 3 to 6. Read in scored mode only.

Image variants

Key Environment variable Value
golem15.journal.image_widths SUMMER_GOLEM15__JOURNAL__IMAGE_WIDTHS The widths in pixels of the featured image variants, default [480, 800, 1200] (config/config.yaml); the environment takes a comma-separated list such as 480,960. Each is 16 to 4096; an invalid list falls back to the default. See image variants.

Author pages

Key Environment variable Value
golem15.journal.author_pages SUMMER_GOLEM15__JOURNAL__AUTHOR_PAGES Whether the host site has author pages, default true. With false, journal-author items resolve to nothing, author descriptions carry empty URLs, author paths never match and post descriptors name their author without a journal-author reference.

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
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.ConfigFS Ships config/config.yaml (golem15.journal.content_h1_to_h2, golem15.journal.related_mode, golem15.journal.related_count, golem15.journal.image_widths).
journal.Plugin.Migrations gormigrate steps registered by this plugin.
journal.Plugin.Models Models registered by this plugin.
journal.Plugin.Commands Registers journal:export-posts, journal:import-posts, journal:reindex, journal:fix-headings and journal:image-variants.
journal.Plugin.Jobs Registers the image variants job golem15.journal.image_variants (pact.HasJobs).
journal.Plugin.Routes Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under /_journal/api/v1.
journal.Plugin.MCP The MCP tools and the draft_post prompt (pact.HasMCP), guard backend, permission golem15.journal.access_posts (golem15.journal.manage_settings for the settings and fix_headings tools).
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 Journal menu item types (pact.HasMenuItemTypes).
console.FixHeadings journal:fix-headings.
console.ImageVariants journal:image-variants.
classes.ImageVariantsArgs, classes.ImageVariantsKind The image variants job's arguments (FileIDs) and kind golem15.journal.image_variants.
classes.GenerateImageVariants, classes.VariantsResult Writes the thumbnails and WebP copies of files at the widths no larger than their originals and records them; what one run did.
classes.ImageWidths, classes.ImageWidthsKey, classes.DefaultImageWidths, classes.MinImageWidth, classes.MaxImageWidth golem15.journal.image_widths read from the config (a list or a comma-separated text, 16 to 4096, sorted), its default 480, 800 and 1200.
classes.ImageVariantsMode, classes.FeaturedImagesField The variants' thumbnail mode exact and the featured_images field.
classes.FeaturedImageIDs, classes.DispatchImageVariants, classes.DispatchPostImageVariantsAfterCommit The featured images to backfill, and queueing the job for files or, after a commit, for a post's images that lack a width.
models.ImageVariant A row of golem15_journal_image_variants: file, width, height, thumbnail and WebP keys, the original's size.
console.ImportFromDirConfig, console.ImportStats.H1Converted The import with the config the H1 setting falls back to, and the number of H1 headings it stored as H2.
controllers.PermissionManageSettings golem15.journal.manage_settings.
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; content is plain text.
models.Post.SearchIndexSchema The Typesense collection schema a missing collection is created with (beachcomber.IndexSchemaProvider).
models.PlainText HTML to plain text: tags dropped, entities decoded, whitespace collapsed.
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; ContentH1ToH2 and GenericAnchors hold the Headings and links tab.
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 and, while the H1 setting is on, ConvertH1 with the conversion notice; Load with the markdown fallback, list text, the image upload and the per-request headingLevels and genericAnchors options.
classes.EditorH1Converted, classes.EditorH1ConvertedCategory The phrase keys of the H1 conversion notice for a post and for a category.
classes.H1ToH2Key golem15.journal.content_h1_to_h2, the config key of the H1 setting when no settings row exists.
classes.EffectiveH1ToH2 The one reader of the H1 setting: the settings row, else the config key, else true.
classes.RelatedModeKey, classes.RelatedCountKey golem15.journal.related_mode and golem15.journal.related_count.
classes.RelatedModeCategory, classes.RelatedModeScored, classes.RelatedCountDefault, classes.RelatedCountMin, classes.RelatedCountMax The related-posts modes and the count's default and bounds.
classes.RelatedSettings, classes.ReadRelatedSettings The related-posts mode and count read from the config (Scored reports the scored mode).
classes.ScoredRelatedPosts The scored mode's related posts: 2 points per shared tag plus 1 per shared category, ties by newest, at most the count, published only unless drafts are allowed.
classes.PrimaryCategoryID, classes.CategoryNeighbour The post's first category by name, and its previous or next published post by publish time within that category.
classes.GenericAnchors, classes.DefaultGenericAnchors The generic link texts: the settings row's list, one per line, lowercased, else the default English and Polish lists.
classes.ConvertH1 Turns every h1 of HTML into an h2 on the tokenizer, keeping every other byte; returns the converted count.
classes.CountH1 The number of h1 start tags ConvertH1 would convert.
classes.ConvertContentH1, classes.ConvertTranslationH1 ConvertH1 over a post's content and content_html, and over one locale's translated attributes.
classes.FixStoredHeadings, classes.HeadingFix The stored-H1 conversion behind journal:fix-headings and POST /fix-headings: posts in every locale and category pillar text, counted or applied in one transaction with record events.
classes.H1ConversionOffText, classes.ErrH1ConversionOff The refusal while the H1 setting is off.
classes.SetImageAlt Sets the alt of the matching img through the tokenizer; returns the HTML, the match count and the old alt.
classes.MediaFileName, classes.MaxMediaStem The D-85 slug rule for uploaded image names, at most 80 characters before the extension.
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).
classes.MenuTypeCategory, classes.MenuTypeAllCategories, classes.MenuTypePost, classes.MenuTypeAllPosts, classes.MenuTypeCategoryPosts, classes.MenuTypeTag, classes.MenuTypeAuthor The type keys journal-category, all-journal-categories, journal-post, all-journal-posts, category-journal-posts, journal-tag and journal-author.
classes.DefaultPostPattern, classes.DefaultCategoryPattern, classes.DefaultTagPattern, classes.DefaultAuthorPattern The default patterns /blog/:slug, /blog/category/:slug, /blog/tag/:slug and /blog/author/:author.
classes.TagPagesKey golem15.journal.tag_pages, the host key that says whether the site has tag pages.
classes.AuthorPagesKey golem15.journal.author_pages, the host key that says whether the site has author pages.
classes.LooksLikeHTML Whether stored content is HTML: its first non-space character is <.
classes.StoreMedia Stores an uploaded image under /journal/<folder>/<MediaFileName> 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 Deprecated alias of cooler.SiteCachePurger, the framework's purger; a nil purger does nothing.
classes.NewSiteCachePurger Deprecated: builds the framework purger from values of the Journal's keys (cooler.NewSiteCachePurgerFor), errors naming those keys; 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 Deprecated alias of cooler.PurgeSiteCacheAfterCommit: registers a purge to run after the surrounding save transaction commits.
classes.SiteCachePurgeURLKey, classes.SiteCachePurgeTokenKey The deprecated config keys golem15.journal.site_cache.purge_url and purge_token (framework keys: cooler.SiteCachePurgeURLKey, cooler.SiteCachePurgeTokenKey).
classes.SiteCachePurgeMinTokenLength, classes.SiteCachePurgeTimeout Deprecated aliases of cooler's shortest accepted token (32) and request timeout (2 s).

CLI commands

The export and import 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, with a progress bar over the posts. Attachments are omitted. Ends with Exported N posts to: <path>.
journal:import-posts --path (default journal-export), --force, --dry-run Creates posts from those files, one line per post; while the H1 setting is on, H1 headings are stored as H2 and the count is printed. Existing slugs are skipped unless --force updates them. Ends with Results: N created, N updated, N skipped, N failed.
journal:image-variants --all Writes the thumbnails and WebP copies of featured images at golem15.journal.image_widths that are missing (with --all, checks every featured image) and prints Checked N featured images, wrote N variants, skipped N., or Every featured image has its variants.
journal:fix-headings --apply, --dry-run (the default) Converts H1 to H2 in stored post content (every locale) and category pillar text while the H1 setting is on, firing a record event per changed post and category. Prints Would convert N H1 headings in N posts and N categories. Run with --apply to save., Converted N H1 headings in N posts and N categories. or No H1 headings found in post or category content.; refuses with exit code 1 while the setting is off. Runs from the application CLI like journal:image-variants: without a published database it opens database.dsn (SUMMER_DATABASE__DSN).
journal:reindex --fresh Syncs every post into the search index in batches of 200: published posts are upserted, the others removed. --fresh first drops the collection and recreates it from the schema. Refuses, sending nothing, when the engine is not configured or search_use_typesense is off. Ends with Reindexed: N synced, N removed, N failed and exits non-zero on any failure.
./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
./bin/acme journal:reindex --fresh
./bin/acme journal:fix-headings --apply

./bin/acme help journal:import-posts shows each command's long help and examples. The plugin logs through the component journal (log.components.journal in config/log.yaml).

Dependencies

  • SummerCMS v0.6.1 modules (the content facts and pact.FormFieldType.RequestOptions are new in v0.6.1): backpack, beachcomber, bonfire, cabana, compass, conga (the image variants job), cooler (site-cache purge), diary, lagoon/attach, lagoon/attach/webp (WebP variants, through github.com/gen2brain/webp v0.6.4), pact, party, phrasebook, signpost (content descriptors), surf.
  • 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

go test ./...

Postgres integration smokes start Docker unless -short. Missing Docker is a failure, not a skip, unless -short.

TestCommandsDocumented (root package, Docker-free) logs, without failing, each command whose help lacks a description, long help, examples or an option description (bonfire.CheckDocs).

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.

Headings, links and media (Phase 33): TestHeadingConversionTracer, TestHeadingConversion, TestFixHeadingsCommand, TestJournalSettingsTools, TestSetImageAlt and TestBlogEditorRequestOptions (headings_test.go), TestJournalDescriptorFields and TestJournalAuthorType (journal_descriptor_fields_test.go) run on Postgres; TestConvertH1, TestSetImageAlt and TestMediaFileName (classes) are Docker-free; TestSettingsHeadingsMigration (updates) checks the migration and its rollback. The editor's J1 to J4 parts are covered by tests/blogeditor/additions.test.ts.

TestJournalMCPSmoke (root package, Docker-free) pins the eleven tools and the prompt (guard, permission, destructive delete, the prompt's arguments and text) and checks that each tool's method and path is a route the plugin registers; TestOptionalBackendPrincipalDispatch (controllers/api) checks that the list and show handlers see a dispatched backend principal and stay anonymous without one. TestJournalPromptWrites (root package, Docker-free) checks that draft_post declares Writes and, through a served pier endpoint, that a read-only grant's prompts/list omits journal_draft_post while a read and write grant lists it.

Related posts: TestRelatedDefaultUnchanged (the default and the explicit category mode answer as before, without the scored keys), TestRelatedScored (ranking, ties, a draft and a future post sharing every tag and category never shown, the category neighbours) and TestRelatedScoredCountClamped (related_media_test.go) run on Postgres. TestImageVariants (same file, Postgres) checks that an upload answers before any encoding and queues the job, that the job writes the default widths no wider than the original with a WebP copy of each, the API's variants, that a second run writes nothing, and that an admin save queues the job only for an image that lacks a width; TestImageVariantsTallOriginal that a 1000×9000 original followed by a normal one fails neither the job nor journal:image-variants --all.

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 of the deprecated keys in classes (TestSiteCachePurgeConfig, TestSiteCachePurgeRequest; the request rules are tested in the framework's cooler) and Postgres tests through the admin API (TestCategoriesAdminPurgesSiteCache, TestPostsAdminPurgesSiteCache on the deprecated keys, TestSiteCacheFrameworkKeysWin with both sets) 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:

npm test
bash scripts/check-dist.sh