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/mlmarkdown.classes.FormatHTMLregeneratescontent_htmlon every write path (admin save,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"and two pasted-transcriptdata-message-*attributes. 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. A tag that repeats an attribute is never kept whole: the sanitized output, which keeps the first value, is stored.contentitself 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 as a textarea. 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. - Settings. Singleton row ID=1 on
golem15_journal_settings, not PHPsystem_settings.search_use_typesensedefaults to false. A fresh save never contacts Typesense. - Public HTTP. Anonymous GET
/_journal/api/v1/posts,/posts/{slug},/categories,/tags,/rss,/authors/{author}, and/authors/{author}/posts. Writes under the same prefix require a cabana backend JWT (aud=backend). - 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. Eight gormigrate steps create the six journal tables, the author-slug column and the category
contentcolumn. 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 refuses content that fails classes.CheckHTML with a 422 on content, and stores blank content as null. Rows written by SQL or by import commands bypass that gate, 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 and no slug.
{"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe"}}
The author object has exactly these four keys. slug is the canonical identifier: the author slug when set, otherwise the login, so a consumer that links by login sets its canonical URL from slug. Missing names are empty strings. The object carries no contact, credential, permission or role data and has no login key; slug falls back to the login only for an author whose login is already public through a published post.
An unknown author, a login without published posts, a soft-deleted user and a malformed value (empty, over 255 bytes, invalid UTF-8 or containing a control character) all answer 404:
{"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"}}],"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):
POST /_journal/api/v1/posts
Authorization: Bearer <backend-access-token>
Content-Type: application/json
{"title":"Hello","content":"A blog post"}
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` }
API reference
| Identifier | Description |
|---|---|
journal.Plugin |
Compiled plugin; ID is golem15.journal. |
journal.Plugin.Requires |
Returns golem15.translate only. |
journal.Plugin.LangFS |
Embedded English and Polish UI phrasebooks. |
journal.Plugin.Migrations |
gormigrate steps registered by this plugin. |
journal.Plugin.Models |
Models registered by this plugin. |
journal.Plugin.Commands |
Registers journal:export-posts and journal:import-posts. |
journal.Plugin.Routes |
Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under /_journal/api/v1. |
journal.Plugin.Buckets |
Limiter buckets journal-public-api and journal-api (Max 120). |
models.Post |
Post row; TableName is golem15_journal_posts. |
models.Post.MorphName |
PHP class string Golem15\Journal\Models\Post. |
models.Post.Fillable |
Allow-list excluding redactor_id, content_html, and user_id. |
models.Post.AttachRelations |
featured_images and content_images attachMany. |
models.Post.SearchableAs |
Index name golem15_journal_posts. |
models.Post.ShouldBeSearchable |
False when unpublished or search_use_typesense is off. |
models.Post.ToSearchableArray |
Typesense document built after a committed save. |
models.Post.CanEdit |
Owner or golem15.journal.access_other_posts. |
models.Category |
Category row; TableName is golem15_journal_categories; Content holds the HTML body served as content. |
models.Category.MorphName |
PHP class string Golem15\Journal\Models\Category. |
models.Category.Fillable |
name, slug, code, description, content, parent_id. |
models.Tag |
Tag row; TableName is golem15_journal_tags. |
models.Tag.Fillable |
name, slug, description. |
models.Settings |
Singleton settings row; TableName is golem15_journal_settings. |
models.All |
Models registered by this plugin. |
updates.All |
gormigrate steps registered by this plugin. |
classes.SanitizeHTML |
Strips from HTML (not markdown) what the FormatHTML allow-list does not allow; HTML the policy keeps whole is returned byte for byte. |
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 |
Gate for HTML stored as is, such as category content: refuses HTML that the FormatHTML allow-list would strip or change, including any tag that repeats an attribute, instead of stripping it. |
CLI commands
Both commands write JSON files (one per post slug), matching the PHP journal:export-posts / journal:import-posts tools. They are not a CSV ImportExport framework. A blog application binary registers them through Plugin.Commands.
| Command | Arguments and flags | Effect |
|---|---|---|
journal:export-posts |
--path (default journal-export), --dry-run |
Writes one JSON file per post, including non-default-locale translations. Attachments are omitted. |
journal:import-posts |
--path (default journal-export), --force, --dry-run |
Creates posts from those files. Existing slugs are skipped unless --force updates them. |
./bin/acme journal:export-posts --path journal-export
./bin/acme journal:import-posts --path journal-export --dry-run
./bin/acme journal:import-posts --path journal-export --force
Dependencies
- SummerCMS modules: backpack, beachcomber, bonfire, cabana, lagoon/attach, pact, party, surf.
- 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.
Testing
go test ./...
Postgres integration smokes start Docker unless -short. Missing Docker is a failure, not a skip, unless -short.