- golem15_journal_image_variants (migration 202610280002): a thumbnail
per width of golem15.journal.image_widths (480, 800, 1200 by default,
never wider than the original) and its WebP copy, with the original's
size
- conga job golem15.journal.image_variants (three attempts), queued after
a featured image upload and after an admin save of a post whose images
lack a width; the upload request never encodes
- journal:image-variants [--all] backfills
- featured_images[].variants {width, height, url, webp_url, source_width,
source_height} on the post answers (additive key)
73 KiB
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.Postmapsgolem15_journal_posts. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not includeredactor_id,content_html, oruser_id. Attachmentsfeatured_imagesandcontent_imagesusesystem_filesunder the same MorphName. YAML admin usesmltext,mltextareaand the plugin's ownmlblogeditorfield type for content (see Admin: Blog Editor field types); an admin save stores the sanitized HTML in bothcontentandcontent_html.classes.FormatHTMLregeneratescontent_htmlon the other write paths (journal:import-posts, Journal API writes). It renders markdown and passes raw HTML incontentthrough the renderer (Winter parity), then sanitizes the result with abluemondayallow-list: bluemonday's UGC policy plusfooter,class, schema.org microdata, thecolor,font-size,font-weightandtext-alignstyles, goldmark's footnote roles,dir="auto", two pasted-transcriptdata-message-*attributes, and exactly five rules for the Blog Editor's own output (Phase 16.2, Phase 33):data-embedmatching^youtube$anddata-idmatching^[A-Za-z0-9_-]{11}$onfigure(the embed placeholder),altwith any text onimg,startmatching^[0-9]+$onol, andrelonaholding only the tokensnofollow,sponsoredandugc(^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$; any otherrelvalue is removed). No iframe, other data attribute, style property or URL scheme is allowed for the editor;TestEditorOutputPassesPolicychecks every entry of the editor corpus against the policy. Like Winter'sHtml::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 pathscontentitself is stored as submitted. Translatedcontent_htmlgoes through the same policy on the API andjournal:import-postswrite paths (classes.FormatTranslation): when the translation carries a non-blankcontent,content_htmlis rendered from it and a suppliedcontent_htmlis replaced, as the host column is always derived; a translation with onlycontent_htmlis sanitized withclasses.SanitizeHTML. Winter stored a supplied translatedcontent_htmlverbatim. The API response shapes and status codes are unchanged. - Categories and tags.
models.Categoryis translatable (name, description, slug). It also storescontent, an HTML host column that is not translatable, edited in the admin with theblogeditorfield type. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable.models.Tagis not translatable. Fillable is name, slug, description. Pivots aregolem15_journal_posts_categoriesandgolem15_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-tagandjournal-authorwith 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 PHPsystem_settings.search_use_typesensedefaults to false. A fresh save never contacts Typesense. The Headings and links tab holds the H1 settingcontent_h1_to_h2(on by default) and the generic link textsgeneric_anchors; see headings and links. - Image variants. After a featured image upload commits, the job
golem15.journal.image_variantswrites a thumbnail at each width ofgolem15.journal.image_widths(480, 800 and 1200 by default, never wider than the original) and a WebP copy of each next to it, recorded ingolem15_journal_image_variants. The API lists them asfeatured_images[].variants; the upload request never encodes.journal:image-variantsbackfills. 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-headingsand the MCP tooljournal_fix_headingsconvert stored content on request. The Blog Editor follows the setting and adds a heading outline, linkreloptions for external links and image file naming. See headings and links. - Search. With a configured search engine and
search_use_typesenseon, published posts are kept in the Typesense collectiongolem15_journal_postsafter every committed save, andGET /posts?search=answers in relevance order. Otherwise search is a SQLILIKE.?fields=suggestserves short rows for a search box, andjournal:reindexfills 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, fromAccept-Languageor 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/v1post routes, which MCP tools call, fire the same events withcabana.EmitRecordSavedandcabana.EmitRecordDeleted:POST /postsa RecordSaved withCreated,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 tagPOSTorPUTcreates from a name intagsfires its own RecordSaved withCreated.POSTandPUTwrite 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. TheirControlleris 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.MCPdeclares eleven tools and one prompt for the framework's MCP endpoint (pier, a binary built with thesummercms_mcptag):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_altand the promptjournal_draft_post. Each runs the matching/_journal/api/v1route as the backend account linked to the MCP user (guardbackend) and is listed only when that account hasgolem15.journal.access_posts(golem15.journal.manage_settingsfor the settings andfix_headingstools); 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-postsandjournal:import-postswrite and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators withgolem15.journal.access_import_export. - Phrasebooks. Embedded
lang/en/lang.yamlandlang/pl/lang.yamlhold 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
contentcolumn, the settings columnscontent_h1_to_h2andgeneric_anchors, andgolem15_journal_image_variants. AutoMigrate is not used. Table names aregolem15_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:
backend_users.golem15_bloghub_author_slug, the public author slug. A user with a slug resolves even before their first post.backend_users.login, but only for a user with at least one published post. That login is already public through the postauthorobject, 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"}
Search
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
ILIKEagainst title, slug, excerpt and content, and the rows keep the list order (sort, default newest first). - Typesense. When
search.driveristypesensewith an API key and the Journal settingsearch_use_typesenseis 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.totalcounts the gated rows, the rows keep the engine's rank, with pinned posts first whenpinned_firstis set, andpageandper_pagepage over them as over any list. An explicitsortoverrides 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.
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 keygolem15.journal.content_h1_to_h2is 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 thegolang.org/x/net/htmltokenizer: 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 (noticeeditor.h1_convertedoreditor.h1_converted_categoryon the editor field),POSTandPUT /_journal/api/v1/postsand so MCP (default and translatedcontentandcontent_html; the answer gainsnotices: [{message, count, locale}],localeonly on a translation's notice), markdown content (its rendered HTML) andjournal:import-posts(it printsConverted N H1 headings to H2.). The editor offers H2 to H4. - Off. H1 is stored as written, no notice appears, the editor offers H1 to H4 and
journal:fix-headingsrefuses. - Stored content.
journal:fix-headings(a dry run unless--apply) and the MCP tooljournal_fix_headings(dry_rundefaulttrue) convert H1 already stored in post content (default and translated) and in category pillar text, in one transaction, firing aRecordSavedper changed post and category; they answer the post, category and heading counts and refuse while the setting is off (exit code 1;409from the API). - The API flag.
GET /_journal/api/v1/posts/{slug}and every category row ofGET /_journal/api/v1/categoriescarrycontent_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_anchorssetting (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.
GETandPUT /_journal/api/v1/settingsread and write every settings column forgolem15.journal.manage_settings(403without it);PUTchecks the submitted columns againstmodels.Settings.Rulesand 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 whosesrcequalssrcin the post'scontent_html(and incontentwhen that holds the same image), in the default locale or inlocale;alt: ""marks it decorative. It needsgolem15.journal.access_postsand edit rights on each post, refuses the whole batch with422when one image is not in its post, answers{changes: [{post_id, locale, src, old_alt, alt, changed}], dry_run}and fires aRecordSavedper 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_countof 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 addsprevious_in_categoryandnext_in_category({id, title, slug}ornull): the neighbours by publish time within the post's first category by name (classes.PrimaryCategoryID,classes.CategoryNeighbour), in the direction ofprevious_postandnext_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-imagesstores the original and queues the conga jobgolem15.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_widthsno larger than the original,classes.GenerateImageVariantswrites the thumbnail (attach.File.ThumbKey, modeexact, the original's aspect ratio) and its WebP copy (webp.Variantof the framework'smodules/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-variantstakes the images that lack a width no larger than their original;--allchecks every featured image. - 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.soandlibwebpdemux.so, Debianlibwebp-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
valueto 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,divand similar blocks, and afigurethat 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 onspanelements, the view-safe unmodelled attributes survive an edit of their block:class,title,alt,width,height,colspan,rowspan,start,rel,targetanddata-*(hrefandsrcare kept by the link and image nodes, which check them). Every other attribute, such asid,name,style,srcset,form,contenteditable,popover,itempropor an event handler (on*), is never rendered into the admin page and is dropped from a block once that block is edited; aspanleft 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>, whereIDmatches^[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 inEMBED_PROVIDERS(embed.ts) and one in the layer. - Preview. The Preview button (
Podglądforui-localepl) is off by default, belongs to each element on its own and shows its state througharia-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_MSinpreview.ts) after the last change, and each update reloads the frame. The frame is an<iframe srcdoc>with an emptysandboxattribute, so no scripts run and its origin is opaque. It hasreferrerpolicy="no-referrer"and a Content-Security-Policy meta (PREVIEW_CSP) that allows only inline styles and images. The preview copy leaves outscript,iframe,frame,frameset,object,embed,meta,baseandlinkelements 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 firessummer-changeand never changes the value, so an untouched post is still saved byte for byte. - Headings (J1). The element reads
headingLevelsfrom itsoptions(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 (
genericAnchorsoption). 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,mailtoand relative URLs (isPolicyUrlinextensions.ts), the schemes both sanitizer policies keep; the scheme is read after removing control characters and spaces. A created link hashrefonly, orhrefandrel(J3): for an absolutehttp(s)URL the link form offersnofollow,sponsored (paid)andugc (user content)and Apply writes the checked ones; links within the site never getrel. 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 owntargetandrel; the server keeps only the threereltokens. - 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 bubblingsummer-uploadevent withdetail {id, file}. The admin uploads it with its own session and answers on the element withsummer-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 notice1 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'smeta.noticesnames what was removed per field and locale (for example2 elements were removed: script, iframe), in English or Polish. A save is never refused because of markup; a value that is not text is a422on the field. - For posts, the stored HTML goes to both
contentandcontent_htmlof that locale: the host columns through theFormBeforeCreate/FormBeforeUpdatehooks, the other locales throughFormAfterCreate/FormAfterUpdateand 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
contentdoes not start with<(classes.LooksLikeHTML) is a markdown post: the editor opens it with that locale'scontent_html, or withFormatHTML(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;
Publishedsays whether visitors see the post (published, with a past publish date). Categories and tags are always published. On a host withgolem15.journal.tag_pages: falsetag URLs are empty. - Facts (Phase 33). The descriptors also fill the optional content facts the SEO, sitemap and audit plugins read:
Kind(articlefor posts,collectionfor categories and tags,profilefor authors) andTools, the MCP tools that fix a record (posts:editjournal_update_post,headingsjournal_fix_headings,image_altjournal_set_image_alt; categories:headings). A post record carriesPublishedAt, its categories by name asParents, its author inAuthors(with ajournal-authorreference while author pages are on) andTopHeading(2 while the H1 setting is on, 0 while it is off); each locale listsImages(the featured image first, its alt the file's title or else the post title, then the body's images with their alt,Decorativeforalt=""and thewidthandheightattributes),Videos(YouTube embeds) andHeadings(the body's outline). A child category'sParentsis its parent, and a category carries its pillar text'sHeadingsand the sameTopHeading. The body facts are read once per locale withgolang.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 asLastMod. - 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:idamong 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.AfterCommiton 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 logscooler: site cache purgedwith 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/purgewith its token inNUXT_SITE_CACHE_PURGE_TOKEN; the old routePOST /api/__journal__/cache/purgewithNUXT_JOURNAL_CACHE_PURGE_TOKENstays for one release. Generate one token on the server and put the same value in both environments.
Headings and links
| 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. |
Related posts
| 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. |
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.RequestOptionsare 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, throughgithub.com/gen2brain/webpv0.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-tableand@tiptap/extension-image3.31.4, bundled intoassets/dist; dev-onlyvite,vitest,happy-domandtypescript, pinned exactly inpackage.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.
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