4 Commits

Author SHA1 Message Date
Jakub Zych
5b38a3b20d feat: plugin.yaml manifest for the SummerCMS marketplace
- plugin.yaml generated by summer plugin:manifest (name override: Journal)
- TestPluginManifest guards it with kiosk.CheckT
- require SummerCMS v0.6.0, the framework minimum in plugin.yaml
2026-10-29 19:25:49 +01:00
a6fc7cd954 fix: fire RecordSaved for tags the post API creates by name
journal-tag is a content type, but tags created on the fly from names in a
post write fired no event, so SEO and sitemap subscribers only heard of them
from a later bulk job. Each created tag now fires a RecordSaved with Created
inside the post write's transaction.
2026-10-27 15:37:02 +01:00
7a03640b88 fix: report the old translated content_html on API post events
syncTranslations adds content_html to a translation that sends content, after
snapshotPost read the old values, so the event saw content_html as newly
submitted and reported Old: nil for a translation that existed.
submittedTranslations now lists content_html whenever a non-blank content is
sent, so the snapshot and the event cover the same fields.
2026-10-27 15:36:11 +01:00
62c75b1fc2 fix: write API posts in one transaction and fire RecordSaved after its commit
POST and PUT /_journal/api/v1/posts saved the row, then the categories, tags
and translations, and fired RecordSaved only when all succeeded. A failure
part-way left a committed row (possibly a new slug) with no event, so
subscribers such as an SEO plugin never created the redirect. The write now
runs in one lagoon transaction with the event emitted inside it.
2026-10-27 15:35:07 +01:00
9 changed files with 189 additions and 61 deletions

View File

@@ -23,7 +23,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later
- **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`) and `journal-tag` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories and tags. Posts, categories and tags carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors). - **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`) and `journal-tag` with the framework's menu item type registry, so menus and sitemaps can point at posts, categories and tags. Posts, categories and tags carry content descriptors, so plugins that work on every content type (sitemaps, SEO, search) describe, page and match Journal records without the Journal importing them. See [menu item types](#menu-item-types) and [content descriptors](#content-descriptors).
- **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. - **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense.
- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. `/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. - **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. 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. - **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 seven 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` 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`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools). - **MCP tools.** `journal.Plugin.MCP` declares seven 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` 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`; the route checks its own permissions on every call. See [MCP tools](#mcp-tools).
- **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve. - **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`. - **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`.
@@ -313,7 +313,7 @@ With only these set, Boot validates them with the same rules (errors name the Jo
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. 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 run outside a transaction: their purge is queued once the write succeeded and sent in the background, so the response does not wait for the site. - **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. - **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. - **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. - **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.

View File

@@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"encoding/json" "encoding/json"
"html" "html"
"net/http" "net/http"
@@ -220,20 +221,30 @@ func Store(app *backpack.App) http.HandlerFunc {
writeAPIError(w, http.StatusForbidden, errPublishPermission) writeAPIError(w, http.StatusForbidden, errPublishPermission)
return return
} }
if err := db.WithContext(r.Context()).Create(post).Error; err != nil { // D-92/D-102: the row, its associations and translations commit
writeAPIError(w, http.StatusInternalServerError, "Internal server error") // together, and RecordSaved fires only once they have.
return err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
} if err := tx.Create(post).Error; err != nil {
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil { return err
writeAPIError(w, http.StatusInternalServerError, "Internal server error") }
return tags, err := applyPostAssocs(ctx, tx, post, body)
} if err != nil {
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil { return err
}
if err := loadPostAssocs(tx, post); err != nil {
return err
}
cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, nil, post, body))
for _, tag := range tags {
cabana.EmitRecordSaved(ctx, app, tx, tagSavedEvent(ctx, &tag))
}
return nil
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error") writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return return
} }
purgeSiteCache(r, app, post) purgeSiteCache(r, app, post)
cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), nil, post, body))
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}) wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
} }
} }
@@ -278,20 +289,30 @@ func Update(app *backpack.App) http.HandlerFunc {
writeAPIError(w, http.StatusForbidden, errPublishPermission) writeAPIError(w, http.StatusForbidden, errPublishPermission)
return return
} }
if err := db.WithContext(r.Context()).Save(post).Error; err != nil { // D-92/D-102: a slug change commits with its translations and
writeAPIError(w, http.StatusInternalServerError, "Internal server error") // associations, or not at all, and RecordSaved follows the commit.
return err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
} if err := tx.Save(post).Error; err != nil {
if err := applyPostAssocs(r.Context(), db.WithContext(r.Context()), post, body); err != nil { return err
writeAPIError(w, http.StatusInternalServerError, "Internal server error") }
return tags, err := applyPostAssocs(ctx, tx, post, body)
} if err != nil {
if err := loadPostAssocs(db.WithContext(r.Context()), post); err != nil { return err
}
if err := loadPostAssocs(tx, post); err != nil {
return err
}
cabana.EmitRecordSaved(ctx, app, tx, postSavedEvent(ctx, tx, before, post, body))
for _, tag := range tags {
cabana.EmitRecordSaved(ctx, app, tx, tagSavedEvent(ctx, &tag))
}
return nil
})
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error") writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return return
} }
purgeSiteCache(r, app, post) purgeSiteCache(r, app, post)
cabana.EmitRecordSaved(r.Context(), app, nil, postSavedEvent(r.Context(), db.WithContext(r.Context()), before, post, body))
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))}) wire.WriteJSON(w, http.StatusOK, map[string]any{"data": serializePost(post, db.WithContext(r.Context()))})
} }
} }

View File

@@ -695,54 +695,59 @@ func asUintSlice(raw any) ([]uint, bool) {
} }
} }
func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) error { // applyPostAssocs writes the post's categories, tags and translations the
// body submits. created are the tags the write created by name, for their
// record events.
func applyPostAssocs(ctx context.Context, db *gorm.DB, post *models.Post, body map[string]any) (created []models.Tag, err error) {
if _, has := body["categories"]; has { if _, has := body["categories"]; has {
ids, _ := asUintSlice(body["categories"]) ids, _ := asUintSlice(body["categories"])
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil { if err := db.Where("post_id = ?", post.ID).Delete(&models.PostCategory{}).Error; err != nil {
return err return nil, err
} }
for _, id := range ids { for _, id := range ids {
if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil { if err := db.Create(&models.PostCategory{PostID: post.ID, CategoryID: id}).Error; err != nil {
return err return nil, err
} }
} }
} }
if _, has := body["tags"]; has { if _, has := body["tags"]; has {
ids, err := resolveTagIDs(db, body["tags"]) var ids []uint
ids, created, err = resolveTagIDs(db, body["tags"])
if err != nil { if err != nil {
return err return nil, err
} }
if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil { if err := db.Where("post_id = ?", post.ID).Delete(&models.PostTag{}).Error; err != nil {
return err return nil, err
} }
for _, id := range ids { for _, id := range ids {
if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil { if err := db.Create(&models.PostTag{PostID: post.ID, TagID: id}).Error; err != nil {
return err return nil, err
} }
} }
} }
if raw, has := body["translations"]; has { if raw, has := body["translations"]; has {
if err := syncTranslations(ctx, db, post, raw); err != nil { if err := syncTranslations(ctx, db, post, raw); err != nil {
return err return nil, err
} }
} }
return nil return created, nil
} }
func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) { // resolveTagIDs turns the submitted tags (ids or names) into tag ids; a name
// no tag has yet creates the tag, returned in created.
func resolveTagIDs(db *gorm.DB, raw any) (ids []uint, created []models.Tag, err error) {
items, ok := raw.([]any) items, ok := raw.([]any)
if !ok { if !ok {
if raw == nil { if raw == nil {
return nil, nil return nil, nil, nil
} }
return nil, fmt.Errorf("tags must be an array") return nil, nil, fmt.Errorf("tags must be an array")
} }
var ids []uint
for _, item := range items { for _, item := range items {
if id, ok := asUint(item); ok { if id, ok := asUint(item); ok {
var n int64 var n int64
if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil { if err := db.Model(&models.Tag{}).Where("id = ?", id).Count(&n).Error; err != nil {
return nil, err return nil, nil, err
} }
if n > 0 { if n > 0 {
ids = append(ids, id) ids = append(ids, id)
@@ -765,11 +770,12 @@ func resolveTagIDs(db *gorm.DB, raw any) ([]uint, error) {
} }
tag = models.Tag{Name: name, Slug: slug} tag = models.Tag{Name: name, Slug: slug}
if err := db.Create(&tag).Error; err != nil { if err := db.Create(&tag).Error; err != nil {
return nil, err return nil, nil, err
} }
ids = append(ids, tag.ID) ids = append(ids, tag.ID)
created = append(created, tag)
} }
return ids, nil return ids, created, nil
} }
func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error { func syncTranslations(ctx context.Context, db *gorm.DB, post *models.Post, raw any) error {

View File

@@ -2,9 +2,11 @@ package api
import ( import (
"context" "context"
"fmt"
"reflect" "reflect"
"sort" "sort"
"strconv" "strconv"
"strings"
"time" "time"
"git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/sm-journal-plugin/models"
@@ -66,8 +68,8 @@ func snapshotPost(ctx context.Context, db *gorm.DB, post *models.Post, body map[
} }
// postSavedEvent is the RecordSaved of an API write, which the handler // postSavedEvent is the RecordSaved of an API write, which the handler
// fires with cabana.EmitRecordSaved once the write is committed (the API // fires with cabana.EmitRecordSaved inside the write's transaction, so it
// writes run outside a transaction, so at once). before is nil on create: // reaches subscribers once the write commits. before is nil on create:
// every column with a value and every submitted translation is then // every column with a value and every submitted translation is then
// reported with a nil Old. Controller is "": the write did not go through an // reported with a nil Old. Controller is "": the write did not go through an
// admin form. // admin form.
@@ -120,8 +122,28 @@ func postSavedEvent(ctx context.Context, db *gorm.DB, before *postSnapshot, post
} }
} }
// tagSavedEvent is the RecordSaved of a tag a post write created by name
// (journal-tag is a content type, D-102): Created, with its name and slug.
func tagSavedEvent(ctx context.Context, tag *models.Tag) pact.RecordSaved {
locale, _ := towel.Locale(ctx)
return pact.RecordSaved{
Model: tag.MorphName(),
ID: strconv.FormatUint(uint64(tag.ID), 10),
Locale: locale,
Created: true,
Changes: []pact.RecordChange{
{Attribute: "name", New: tag.Name},
{Attribute: "slug", New: tag.Slug},
},
}
}
// submittedTranslations lists, per locale, the translated fields a request // submittedTranslations lists, per locale, the translated fields a request
// body submits with a value, in translatedEventFields order. // body submits with a value, in translatedEventFields order. content_html is
// listed whenever a non-blank content is, as the write derives it from the
// content (classes.FormatTranslation): the snapshot before the write and the
// event after it then cover the same fields, whether or not the write has
// already added content_html to the body.
func submittedTranslations(body map[string]any) map[string][]string { func submittedTranslations(body map[string]any) map[string][]string {
out := map[string][]string{} out := map[string][]string{}
m, ok := body["translations"].(map[string]any) m, ok := body["translations"].(map[string]any)
@@ -134,7 +156,8 @@ func submittedTranslations(body map[string]any) map[string][]string {
continue continue
} }
for _, field := range translatedEventFields { for _, field := range translatedEventFields {
if v, has := attrs[field]; has && v != nil { v, has := attrs[field]
if (has && v != nil) || (field == "content_html" && derivesContentHTML(attrs)) {
out[locale] = append(out[locale], field) out[locale] = append(out[locale], field)
} }
} }
@@ -142,6 +165,13 @@ func submittedTranslations(body map[string]any) map[string][]string {
return out return out
} }
// derivesContentHTML reports whether a translation's attributes carry a
// content the write turns into content_html (a non-blank value).
func derivesContentHTML(attrs map[string]any) bool {
content, has := attrs["content"]
return has && content != nil && strings.TrimSpace(fmt.Sprint(content)) != ""
}
// derefValue is a pointer column's value, nil for a nil pointer. // derefValue is a pointer column's value, nil for a nil pointer.
func derefValue[T any](p *T) any { func derefValue[T any](p *T) any {
if p == nil { if p == nil {

8
go.mod
View File

@@ -5,7 +5,7 @@ go 1.27.0
require ( require (
git.golem15.com/golem15/sm-translate-plugin v0.1.0 git.golem15.com/golem15/sm-translate-plugin v0.1.0
git.golem15.com/golem15/sm-user-plugin v0.1.0 git.golem15.com/golem15/sm-user-plugin v0.1.0
git.golem15.com/golem15/summercms v0.5.1 git.golem15.com/golem15/summercms v0.6.0
github.com/go-gormigrate/gormigrate/v2 v2.1.7 github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/goccy/go-yaml v1.19.2 github.com/goccy/go-yaml v1.19.2
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
@@ -21,7 +21,6 @@ require (
require ( require (
charm.land/bubbles/v2 v2.2.1 // indirect charm.land/bubbles/v2 v2.2.1 // indirect
charm.land/bubbletea/v2 v2.1.0 // indirect charm.land/bubbletea/v2 v2.1.0 // indirect
charm.land/fang/v2 v2.0.1 // indirect
charm.land/huh/v2 v2.0.3 // indirect charm.land/huh/v2 v2.0.3 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect charm.land/lipgloss/v2 v2.0.6 // indirect
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
@@ -55,7 +54,6 @@ require (
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/x/ansi v0.11.9 // indirect github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
@@ -124,10 +122,6 @@ require (
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect

12
go.sum
View File

@@ -4,8 +4,6 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw= charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU= charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
@@ -114,8 +112,6 @@ github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
@@ -303,14 +299,6 @@ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtw
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=

13
manifest_test.go Normal file
View File

@@ -0,0 +1,13 @@
package journal
import (
"testing"
"git.golem15.com/golem15/summercms/modules/kiosk"
)
// TestPluginManifest fails when plugin.yaml is missing or no longer matches
// what `summer plugin:manifest` derives from this repository.
func TestPluginManifest(t *testing.T) {
kiosk.CheckT(t, ".")
}

12
plugin.yaml Normal file
View File

@@ -0,0 +1,12 @@
# Generated by summer plugin:manifest. Do not edit; rerun the command.
schema: 1
id: golem15.journal
module: git.golem15.com/golem15/sm-journal-plugin
package: git.golem15.com/golem15/sm-journal-plugin
name: Journal
description: "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."
framework: v0.6.0
requires:
golem15.translate: v0.1.0
overrides:
name: Journal

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"sync" "sync"
"testing" "testing"
@@ -239,6 +240,69 @@ func TestAPIRecordEvents(t *testing.T) {
t.Fatalf("PUT described URL = %q", got[0].url) t.Fatalf("PUT described URL = %q", got[0].url)
} }
// A translated content derives its content_html: the event reports the
// stored content_html as Old, not nil (WR-05).
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"translations": map[string]any{"pl": map[string]any{"content": "Stara treść"}},
})
l.reset()
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"translations": map[string]any{"pl": map[string]any{"content": "Nowa treść"}},
})
got = l.savedEvents()
if len(got) != 1 {
t.Fatalf("PUT content events = %+v, want one RecordSaved", got)
}
html, ok := findChange(got[0].ev.Changes, "content_html", "pl")
if old, _ := html.Old.(string); !ok || !strings.Contains(old, "Stara") || !strings.Contains(fmt.Sprint(html.New), "Nowa") {
t.Fatalf("pl content_html change = %+v (found %v); changes %+v", html, ok, got[0].ev.Changes)
}
// A tag the write creates by name fires its own RecordSaved (WR-06).
l.reset()
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"tags": []any{"Fresh event tag"},
})
got = l.savedEvents()
tagMorph := (models.Tag{}).MorphName()
var tagEvent *savedEvent
for i := range got {
if got[i].ev.Model == tagMorph {
tagEvent = &got[i]
}
}
if len(got) != 2 || tagEvent == nil || !tagEvent.ev.Created || tagEvent.ev.ID == "" || tagEvent.key != classes.MenuTypeTag {
t.Fatalf("PUT with a new tag events = %+v, want the post and the created tag", got)
}
if c, ok := findChange(tagEvent.ev.Changes, "name", ""); !ok || c.New != "Fresh event tag" {
t.Fatalf("tag name change = %+v (found %v)", c, ok)
}
l.reset()
env.expect(t, http.StatusOK, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"tags": []any{"Fresh event tag"},
})
if got = l.savedEvents(); len(got) != 1 {
t.Fatalf("PUT with an existing tag events = %+v, want only the post", got)
}
// A write that fails after the row is saved rolls the row back and
// fires nothing (WR-04).
l.reset()
failed := env.call(t, http.MethodPut, "/_journal/api/v1/posts/"+id, token, map[string]any{
"slug": "api-lost",
"tags": "not a list",
})
if failed.Code != http.StatusInternalServerError {
t.Fatalf("failing PUT = %d, want 500: %s", failed.Code, failed.Body.String())
}
if saved := l.savedEvents(); len(saved) != 0 {
t.Fatalf("failing PUT fired RecordSaved: %+v", saved)
}
var slug string
if err := env.gdb.Raw("SELECT slug FROM golem15_journal_posts WHERE id = ?", id).Scan(&slug).Error; err != nil || slug != "api-new" {
t.Fatalf("slug after failing PUT = %q (err %v), want api-new", slug, err)
}
l.reset() l.reset()
env.expect(t, http.StatusOK, http.MethodDelete, "/_journal/api/v1/posts/"+id, token, nil) env.expect(t, http.StatusOK, http.MethodDelete, "/_journal/api/v1/posts/"+id, token, nil)
if saved := l.savedEvents(); len(saved) != 0 { if saved := l.savedEvents(); len(saved) != 0 {