feat(admin): post content on the Blog Editor field type
- blogeditor and mlblogeditor field types (classes.BlogEditorTypes) on the plugin; post content is mlblogeditor with htmlColumn content_html - Decode stores SanitizeHTMLReport output per locale with notices naming what was removed; clean HTML is stored byte for byte, nothing is refused because of markup - Load serves HTML as stored and markdown posts as their content_html - the admin hooks copy written content to content_html per locale without goldmark; untouched content leaves both columns as they are - editor lang keys (en, pl plural notices), tracer smoke test, updated Postgres admin tests
This commit is contained in:
@@ -27,6 +27,8 @@ func init() {
|
||||
var _ pact.FilterOptions = postsAdminController{}
|
||||
var _ pact.FormBeforeCreate = postsAdminController{}
|
||||
var _ pact.FormBeforeUpdate = postsAdminController{}
|
||||
var _ pact.FormAfterCreate = postsAdminController{}
|
||||
var _ pact.FormAfterUpdate = postsAdminController{}
|
||||
var _ pact.FormBeforeDelete = postsAdminController{}
|
||||
var _ pact.HasAdminActions = postsAdminController{}
|
||||
var _ cabana.FieldRelationProvider = postsAdminController{}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
@@ -210,13 +209,75 @@ func preparePostWrite(ctx context.Context, post *models.Post) error {
|
||||
"published_at": []string{"golem15.journal::lang.post.published_validation"},
|
||||
}}
|
||||
}
|
||||
html, err := classes.FormatHTML(post.Content)
|
||||
if err != nil {
|
||||
return &cabana.ValidationError{Details: map[string]any{
|
||||
"content": []string{err.Error()},
|
||||
}}
|
||||
// D-02: the Blog Editor's Decode already stored the policy's output in
|
||||
// content; content_html gets the same HTML, without goldmark. Content the
|
||||
// save does not write (untouched, D-03) leaves content_html as it is.
|
||||
if writes, ok := cabana.FieldWritesFromContext(ctx); ok {
|
||||
for _, w := range writes["content"] {
|
||||
if !w.Default {
|
||||
continue
|
||||
}
|
||||
if html, isString := w.Value.(string); isString {
|
||||
post.ContentHTML = html
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormAfterCreate writes content_html for every non-default locale whose
|
||||
// content the save wrote (D-02).
|
||||
func (c postsAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||
return c.writeContentHTMLTranslations(ctx, model)
|
||||
}
|
||||
|
||||
// FormAfterUpdate writes content_html for every non-default locale whose
|
||||
// content the save wrote (D-02). Untouched locales are not listed and keep
|
||||
// their content_html.
|
||||
func (c postsAdminController) FormAfterUpdate(ctx context.Context, model any) error {
|
||||
return c.writeContentHTMLTranslations(ctx, model)
|
||||
}
|
||||
|
||||
// writeContentHTMLTranslations stores each written non-default content
|
||||
// value as that locale's content_html through the published
|
||||
// cabana.TranslationWriter, inside the save transaction. Without a writer
|
||||
// there is nothing to write.
|
||||
func (c postsAdminController) writeContentHTMLTranslations(ctx context.Context, model any) error {
|
||||
post, ok := model.(*models.Post)
|
||||
if !ok || post == nil {
|
||||
return errors.New("posts admin: form model is not a post")
|
||||
}
|
||||
writes, ok := cabana.FieldWritesFromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var pending []cabana.FieldWrite
|
||||
for _, w := range writes["content"] {
|
||||
if !w.Default && w.Locale != "" {
|
||||
pending = append(pending, w)
|
||||
}
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
app := c.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
writer, ok := app.Lookup[cabana.TranslationWriter]()
|
||||
if !ok || writer == nil {
|
||||
return nil
|
||||
}
|
||||
tx := c.db(ctx)
|
||||
for _, w := range pending {
|
||||
html, isString := w.Value.(string)
|
||||
if !isString {
|
||||
return fmt.Errorf("posts admin: content for %s is %T, want a string", w.Locale, w.Value)
|
||||
}
|
||||
if err := writer.WriteTranslated(ctx, tx, post, "content_html", w.Locale, html); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
post.ContentHTML = html
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
336
controllers/posts_editor_smoke_test.go
Normal file
336
controllers/posts_editor_smoke_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// recordingWriter is a cabana.TranslationWriter that records writes.
|
||||
type recordingWriter struct {
|
||||
writes []recordedWrite
|
||||
}
|
||||
|
||||
type recordedWrite struct {
|
||||
field, locale, value string
|
||||
}
|
||||
|
||||
func (*recordingWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "pl", nil }
|
||||
|
||||
func (*recordingWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
|
||||
return []string{"pl", "en"}, nil
|
||||
}
|
||||
|
||||
func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, _ any, field, locale, value string) error {
|
||||
w.writes = append(w.writes, recordedWrite{field: field, locale: locale, value: value})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*recordingWriter) TranslatedExact(context.Context, *gorm.DB, any, string, string) (string, bool, error) {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// blogType returns the named Blog Editor field type.
|
||||
func blogType(t *testing.T, name string) pact.FormFieldType {
|
||||
t.Helper()
|
||||
for _, ft := range classes.BlogEditorTypes(nil) {
|
||||
if ft.Name == name {
|
||||
return ft
|
||||
}
|
||||
}
|
||||
t.Fatalf("no field type %s", name)
|
||||
return pact.FormFieldType{}
|
||||
}
|
||||
|
||||
// formField returns a compiled form field by name.
|
||||
func formField(t *testing.T, schema *cabana.FormSchema, name string) cabana.FormField {
|
||||
t.Helper()
|
||||
for _, f := range schema.Fields {
|
||||
if f.Name == name {
|
||||
return f
|
||||
}
|
||||
}
|
||||
t.Fatalf("form has no field %s", name)
|
||||
return cabana.FormField{}
|
||||
}
|
||||
|
||||
// editorPrincipal is a backend administrator who may edit and publish any
|
||||
// post.
|
||||
func editorPrincipal() *bouncer.Principal {
|
||||
return &bouncer.Principal{ID: 7, Backend: true, IsSuperuser: true}
|
||||
}
|
||||
|
||||
// TestBlogEditorTracerSmoke is the Phase 16.2 plan 03 tracer: post content
|
||||
// compiles as mlblogeditor, Decode stores the policy's output with notices
|
||||
// naming what it removed, Load serves HTML and markdown posts per D-07, and
|
||||
// the Form hooks write the written content to content_html per locale
|
||||
// without goldmark.
|
||||
func TestBlogEditorTracerSmoke(t *testing.T) {
|
||||
ml := blogType(t, "mlblogeditor")
|
||||
|
||||
t.Run("posts form compiles with mlblogeditor", func(t *testing.T) {
|
||||
schema, err := cabana.CompileFormWithTypes("golem15.journal", postsAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := formField(t, schema, "content")
|
||||
if content.Type != "mlblogeditor" || content.Element != classes.BlogEditorElement || !content.Translatable {
|
||||
t.Fatalf("content = type %q element %q translatable %v", content.Type, content.Element, content.Translatable)
|
||||
}
|
||||
if content.TypeOptions["htmlColumn"] != "content_html" || content.TypeOptions["uploadFolder"] != "posts" {
|
||||
t.Fatalf("content typeOptions = %#v", content.TypeOptions)
|
||||
}
|
||||
if excerpt := formField(t, schema, "excerpt"); excerpt.Type != "mltextarea" {
|
||||
t.Fatalf("excerpt type = %q, want mltextarea", excerpt.Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("type definitions", func(t *testing.T) {
|
||||
if ml.Element != "golem15-journal-blogeditor" || len(ml.Scripts) != 1 || ml.Scripts[0] != "assets/dist/blogeditor.js" ||
|
||||
len(ml.Styles) != 1 || ml.Styles[0] != "assets/dist/blogeditor.css" {
|
||||
t.Fatalf("mlblogeditor = %+v", ml)
|
||||
}
|
||||
for _, asset := range append(append([]string{}, ml.Scripts...), ml.Styles...) {
|
||||
if _, err := os.Stat("../" + asset); err != nil {
|
||||
t.Fatalf("asset %s: %v", asset, err)
|
||||
}
|
||||
}
|
||||
plain := blogType(t, "blogeditor")
|
||||
if plain.Translatable || len(plain.Options) != 1 || plain.Options[0] != "uploadFolder" {
|
||||
t.Fatalf("blogeditor = %+v", plain)
|
||||
}
|
||||
for _, bad := range []map[string]any{
|
||||
{"htmlColumn": "content html"},
|
||||
{"htmlColumn": 3},
|
||||
{"uploadFolder": "../up"},
|
||||
{"uploadFolder": "a b"},
|
||||
{"uploadFolder": strings.Repeat("a", 101)},
|
||||
} {
|
||||
if err := ml.CheckOptions(bad); err == nil {
|
||||
t.Fatalf("CheckOptions(%v) = nil, want an error", bad)
|
||||
}
|
||||
}
|
||||
if err := ml.CheckOptions(map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts/2026"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
decode := func(t *testing.T, value any) (pact.FormFieldDecoded, error) {
|
||||
t.Helper()
|
||||
return ml.Decode(context.Background(), pact.FormFieldValue{Field: "content", Locale: "pl", Default: true, Value: value})
|
||||
}
|
||||
|
||||
t.Run("decode keeps clean HTML byte for byte", func(t *testing.T) {
|
||||
const clean = "<h2>Grzyby</h2>\n<p>Tekst z <a href=\"https://example.test/a\">linkiem</a> & <strong>pogrubieniem</strong>.</p>\n"
|
||||
got, err := decode(t, clean)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Value != clean || len(got.Notices) != 0 {
|
||||
t.Fatalf("Decode(clean) = %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode strips and reports", func(t *testing.T) {
|
||||
got, err := decode(t, `<p>a<script>alert(1)</script></p><img src="https://example.test/x.png" alt="x" onerror="alert(1)">`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Value != `<p>a</p><img src="https://example.test/x.png" alt="x">` {
|
||||
t.Fatalf("Decode value = %q", got.Value)
|
||||
}
|
||||
if len(got.Notices) != 2 {
|
||||
t.Fatalf("notices = %#v, want two", got.Notices)
|
||||
}
|
||||
if n := got.Notices[0]; n.Message != "golem15.journal::lang.editor.removed_elements" || n.Count != 1 || n.Params["names"] != "script" {
|
||||
t.Fatalf("first notice = %#v", n)
|
||||
}
|
||||
if n := got.Notices[1]; n.Message != "golem15.journal::lang.editor.removed_attributes" || n.Count != 1 || n.Params["names"] != "onerror" {
|
||||
t.Fatalf("second notice = %#v", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode nil and non-string", func(t *testing.T) {
|
||||
got, err := decode(t, nil)
|
||||
if err != nil || got.Value != "" || len(got.Notices) != 0 {
|
||||
t.Fatalf("Decode(nil) = %#v, %v", got, err)
|
||||
}
|
||||
_, err = decode(t, 42.0)
|
||||
var refused *pact.FormFieldError
|
||||
if !errors.As(err, &refused) || len(refused.Messages) != 1 || refused.Messages[0] != "golem15.journal::lang.editor.content_invalid" {
|
||||
t.Fatalf("Decode(42) error = %v, want a FormFieldError", err)
|
||||
}
|
||||
})
|
||||
|
||||
load := func(t *testing.T, value any, translated func(string) (string, bool, error)) any {
|
||||
t.Helper()
|
||||
got, err := ml.Load(context.Background(), pact.FormFieldValue{
|
||||
Field: "content",
|
||||
Options: map[string]any{"htmlColumn": "content_html", "uploadFolder": "posts"},
|
||||
Locale: "en",
|
||||
Value: value,
|
||||
Translated: translated,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
noRead := func(string) (string, bool, error) {
|
||||
t.Fatal("Translated read for an HTML value")
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
t.Run("load serves HTML content as it is", func(t *testing.T) {
|
||||
const html = " <!-- lead -->\n<p>Stored <em>HTML</em></p>"
|
||||
if got := load(t, html, noRead); got != html {
|
||||
t.Fatalf("Load(HTML) = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("load serves a markdown post's content_html", func(t *testing.T) {
|
||||
got := load(t, "Hello **world**", func(attribute string) (string, bool, error) {
|
||||
if attribute != "content_html" {
|
||||
t.Fatalf("Translated(%q), want content_html", attribute)
|
||||
}
|
||||
return "<p>Stored render</p>", true, nil
|
||||
})
|
||||
if got != "<p>Stored render</p>" {
|
||||
t.Fatalf("Load(markdown) = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("load renders markdown when content_html is empty", func(t *testing.T) {
|
||||
got := load(t, "Hello **world**", func(string) (string, bool, error) { return "", true, nil })
|
||||
if got != "<p>Hello <strong>world</strong></p>\n" {
|
||||
t.Fatalf("Load(markdown, empty content_html) = %q", got)
|
||||
}
|
||||
if got := load(t, "Hello **world**", nil); got != "<p>Hello <strong>world</strong></p>\n" {
|
||||
t.Fatalf("Load(markdown, no reader) = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list value", func(t *testing.T) {
|
||||
got := ml.ListValue(context.Background(), "<h2>Title</h2><p>Some <strong>bold</strong>\n text</p><style>p{}</style>")
|
||||
if got != "Title Some bold text" {
|
||||
t.Fatalf("ListValue = %q", got)
|
||||
}
|
||||
long := ml.ListValue(context.Background(), "<p>"+strings.Repeat("ż", 130)+"</p>")
|
||||
if []rune(long)[120] != '…' || len([]rune(long)) != 121 {
|
||||
t.Fatalf("ListValue(long) = %q", long)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hooks write content_html per written locale", func(t *testing.T) {
|
||||
writer := &recordingWriter{}
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctl := postsAdminController{app: func() *backpack.App { return app }}
|
||||
ctx := bouncer.WithUser(context.Background(), editorPrincipal())
|
||||
ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{
|
||||
"content": {
|
||||
{Locale: "pl", Default: true, Value: "<p>A</p>"},
|
||||
{Locale: "en", Value: "<p>B</p>"},
|
||||
},
|
||||
})
|
||||
post := &models.Post{Content: "<p>A</p>", ContentHTML: "old"}
|
||||
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.ContentHTML != "<p>A</p>" {
|
||||
t.Fatalf("ContentHTML = %q, want <p>A</p>", post.ContentHTML)
|
||||
}
|
||||
if err := ctl.FormAfterUpdate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(writer.writes) != 1 || writer.writes[0] != (recordedWrite{field: "content_html", locale: "en", value: "<p>B</p>"}) {
|
||||
t.Fatalf("translation writes = %#v", writer.writes)
|
||||
}
|
||||
if err := ctl.FormAfterCreate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(writer.writes) != 2 {
|
||||
t.Fatalf("FormAfterCreate writes = %#v", writer.writes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hooks leave untouched content alone", func(t *testing.T) {
|
||||
writer := &recordingWriter{}
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish[cabana.TranslationWriter](writer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctl := postsAdminController{app: func() *backpack.App { return app }}
|
||||
ctx := bouncer.WithUser(context.Background(), editorPrincipal())
|
||||
const markdown = "Hello **world**"
|
||||
post := &models.Post{Content: markdown, ContentHTML: "<p>kept</p>"}
|
||||
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.ContentHTML != "<p>kept</p>" {
|
||||
t.Fatalf("ContentHTML = %q, want it unchanged (no goldmark)", post.ContentHTML)
|
||||
}
|
||||
ctx = cabana.WithFieldWrites(ctx, map[string][]cabana.FieldWrite{"title": {{Locale: "en", Value: "x"}}})
|
||||
if err := ctl.FormBeforeUpdate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ctl.FormAfterUpdate(ctx, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.ContentHTML != "<p>kept</p>" || len(writer.writes) != 0 {
|
||||
t.Fatalf("ContentHTML = %q, writes = %#v", post.ContentHTML, writer.writes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("notice phrases have plural forms", func(t *testing.T) {
|
||||
fsys := fstest.MapFS{}
|
||||
for _, locale := range []string{"en", "pl"} {
|
||||
raw, err := os.ReadFile("../lang/" + locale + "/lang.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fsys["lang/"+locale+"/lang.yaml"] = &fstest.MapFile{Data: raw}
|
||||
}
|
||||
cat := phrasebook.NewCatalog()
|
||||
if err := cat.Load("golem15.journal", fsys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
|
||||
names := map[string]string{"names": "script, iframe"}
|
||||
for _, tc := range []struct {
|
||||
locale, key string
|
||||
count int
|
||||
want string
|
||||
}{
|
||||
{"en", "removed_elements", 1, "1 element was removed: script, iframe"},
|
||||
{"en", "removed_elements", 2, "2 elements were removed: script, iframe"},
|
||||
{"en", "removed_attributes", 3, "3 attributes were removed: script, iframe"},
|
||||
{"en", "removed_comments", 1, "1 HTML comment was removed"},
|
||||
{"pl", "removed_elements", 1, "Usunięto 1 element: script, iframe"},
|
||||
{"pl", "removed_elements", 3, "Usunięto 3 elementy: script, iframe"},
|
||||
{"pl", "removed_attributes", 5, "Usunięto 5 atrybutów: script, iframe"},
|
||||
{"pl", "removed_comments", 2, "Usunięto 2 komentarze HTML"},
|
||||
} {
|
||||
if got := tr.ChoiceIn(tc.locale, "golem15.journal::lang.editor."+tc.key, tc.count, names); got != tc.want {
|
||||
t.Fatalf("%s %s %d = %q, want %q", tc.locale, tc.key, tc.count, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := tr.GetIn("pl", "golem15.journal::lang.editor.content_invalid", nil); got != "Treść musi być tekstem HTML." {
|
||||
t.Fatalf("pl content_invalid = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user