diff --git a/classes/blog_editor.go b/classes/blog_editor.go new file mode 100644 index 0000000..3fcff14 --- /dev/null +++ b/classes/blog_editor.go @@ -0,0 +1,261 @@ +package classes + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "unicode" + "unicode/utf8" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + xhtml "golang.org/x/net/html" +) + +// BlogEditorElement is the custom element of the blogeditor and mlblogeditor +// field types, defined by assets/dist/blogeditor.js. +const BlogEditorElement = "golem15-journal-blogeditor" + +// Phrase keys of the Blog Editor field types. +const ( + editorContentInvalid = "golem15.journal::lang.editor.content_invalid" + editorRemovedElements = "golem15.journal::lang.editor.removed_elements" + editorRemovedAttributes = "golem15.journal::lang.editor.removed_attributes" + editorRemovedComments = "golem15.journal::lang.editor.removed_comments" + editorMarkupChanged = "golem15.journal::lang.editor.markup_changed" +) + +// blogListValueRunes is how many runes of text a list column shows. +const blogListValueRunes = 120 + +var ( + blogHTMLColumnPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + blogUploadFolderPattern = regexp.MustCompile(`^[A-Za-z0-9_\-/]*$`) +) + +// BlogEditorTypes returns the Blog Editor admin field types: blogeditor for +// a column that holds one HTML string, and mlblogeditor for a translatable +// one. Both render BlogEditorElement. Decode stores what the HTML policy +// keeps (SanitizeHTMLReport) and reports what it removed as notices; it +// never refuses a string because of its markup. Load serves HTML content as +// it is and a markdown value (one that does not start with '<') as the +// rendered HTML of the htmlColumn option. app is asked for the booted +// application when a type needs it at request time; it may be nil in tests. +// +// Options: htmlColumn (mlblogeditor only) names the translatable attribute +// that holds the rendered HTML of a markdown value; uploadFolder is the +// folder under /journal/ that editor image uploads are stored in. +func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType { + _ = app // used by the editor image upload + return []pact.FormFieldType{ + { + Name: "blogeditor", + Element: BlogEditorElement, + Scripts: []string{"assets/dist/blogeditor.js"}, + Styles: []string{"assets/dist/blogeditor.css"}, + Options: []string{"uploadFolder"}, + CheckOptions: checkBlogOptions, + Decode: decodeBlogHTML, + Load: loadBlogHTML, + ListValue: blogListValue, + }, + { + Name: "mlblogeditor", + Element: BlogEditorElement, + Translatable: true, + Scripts: []string{"assets/dist/blogeditor.js"}, + Styles: []string{"assets/dist/blogeditor.css"}, + Options: []string{"htmlColumn", "uploadFolder"}, + CheckOptions: checkBlogOptions, + Decode: decodeBlogHTML, + Load: loadBlogHTML, + ListValue: blogListValue, + }, + } +} + +// LooksLikeHTML reports whether s is stored HTML rather than markdown: its +// first character that is not white space is '<' (an HTML comment +// included). Phase 16.2 assumption A1. +func LooksLikeHTML(s string) bool { + t := strings.TrimLeftFunc(s, unicode.IsSpace) + return strings.HasPrefix(t, "<") +} + +// checkBlogOptions checks a Blog Editor field's options at boot. +func checkBlogOptions(options map[string]any) error { + if raw, ok := options["htmlColumn"]; ok { + column, isString := raw.(string) + if !isString || !blogHTMLColumnPattern.MatchString(column) { + return fmt.Errorf("htmlColumn must be an attribute name, got %v", raw) + } + } + if raw, ok := options["uploadFolder"]; ok { + folder, isString := raw.(string) + if !isString { + return fmt.Errorf("uploadFolder must be a string, got %v", raw) + } + if err := checkUploadFolder(folder); err != nil { + return err + } + } + return nil +} + +// checkUploadFolder applies the public media upload route's folder rule. +func checkUploadFolder(folder string) error { + if len(folder) > 100 || !blogUploadFolderPattern.MatchString(folder) || strings.Contains(folder, "..") { + return errors.New("uploadFolder must be at most 100 letters, digits, '_', '-' or '/' without '..'") + } + return nil +} + +// decodeBlogHTML stores the HTML policy's output for a submitted value: the +// exact submitted bytes with no notice when the policy keeps everything, +// otherwise the stripped HTML with notices naming what was removed. nil +// decodes to the empty string; any other value that is not a string is +// refused. +func decodeBlogHTML(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + var text string + switch v := in.Value.(type) { + case nil: + return pact.FormFieldDecoded{Value: ""}, nil + case string: + text = v + default: + return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{editorContentInvalid}} + } + clean, report := SanitizeHTMLReport(text) + return pact.FormFieldDecoded{Value: clean, Notices: removalNotices(report)}, nil +} + +// removalNotices turns a removal report into admin notices: removed +// elements, removed attributes, removed comments and other changes, in that +// order and only when non-zero. +func removalNotices(r HTMLRemovals) []pact.FormFieldNotice { + var out []pact.FormFieldNotice + if r.ElementCount > 0 { + out = append(out, pact.FormFieldNotice{ + Message: editorRemovedElements, + Params: map[string]string{"names": removalNames(r.Elements, r.moreElements)}, + Count: r.ElementCount, + }) + } + if r.AttributeCount > 0 { + out = append(out, pact.FormFieldNotice{ + Message: editorRemovedAttributes, + Params: map[string]string{"names": removalNames(r.Attributes, r.moreAttributes)}, + Count: r.AttributeCount, + }) + } + if r.Comments > 0 { + out = append(out, pact.FormFieldNotice{Message: editorRemovedComments, Count: r.Comments}) + } + if r.Other { + out = append(out, pact.FormFieldNotice{Message: editorMarkupChanged}) + } + return out +} + +func removalNames(names []string, cut bool) string { + joined := strings.Join(names, ", ") + if cut { + joined += ", …" + } + return joined +} + +// loadBlogHTML serves a stored value to the editor (D-07). HTML content is +// served as it is. A markdown value is served as its rendered HTML: the +// htmlColumn attribute in the same locale, or FormatHTML of the value when +// that is empty or cannot be read (the value itself when rendering fails). +func loadBlogHTML(_ context.Context, in pact.FormFieldValue) (any, error) { + text := storedText(in.Value) + column, _ := in.Options["htmlColumn"].(string) + if column == "" || LooksLikeHTML(text) || strings.TrimSpace(text) == "" { + return text, nil + } + if in.Translated != nil { + html, found, err := in.Translated(column) + if err != nil { + return nil, err + } + if found && html != "" { + return html, nil + } + } + html, err := FormatHTML(text) + if err != nil { + return text, nil + } + return html, nil +} + +// storedText is a stored column value as text: a string, a *string (nil is +// empty) or nil. +func storedText(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return t + case *string: + if t == nil { + return "" + } + return *t + default: + return fmt.Sprint(t) + } +} + +// inlineListTags are the elements that do not separate words in a list +// value's text. +var inlineListTags = map[string]bool{ + "a": true, "abbr": true, "b": true, "cite": true, "code": true, "del": true, + "em": true, "i": true, "ins": true, "kbd": true, "mark": true, "q": true, + "s": true, "small": true, "span": true, "strong": true, "sub": true, + "sup": true, "u": true, +} + +// blogListValue is the text a list column of a Blog Editor type shows: the +// value without tags, white space collapsed, cut to 120 runes with an +// ellipsis. +func blogListValue(_ context.Context, value any) string { + var b strings.Builder + z := xhtml.NewTokenizer(strings.NewReader(storedText(value))) + skip := 0 // inside script or style + for { + tt := z.Next() + if tt == xhtml.ErrorToken { + break + } + switch tt { + case xhtml.TextToken: + if skip == 0 { + b.WriteString(z.Token().Data) + } + case xhtml.StartTagToken, xhtml.EndTagToken, xhtml.SelfClosingTagToken: + name, _ := z.TagName() + switch string(name) { + case "script", "style": + if tt == xhtml.StartTagToken { + skip++ + } else if tt == xhtml.EndTagToken && skip > 0 { + skip-- + } + } + if !inlineListTags[string(name)] { + b.WriteByte(' ') + } + } + } + text := strings.Join(strings.Fields(b.String()), " ") + if utf8.RuneCountInString(text) <= blogListValueRunes { + return text + } + runes := []rune(text) + return strings.TrimRightFunc(string(runes[:blogListValueRunes]), unicode.IsSpace) + "…" +} diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index 12650bc..4c641b9 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -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{} diff --git a/controllers/posts.go b/controllers/posts.go index bcd1a20..5e59c50 100644 --- a/controllers/posts.go +++ b/controllers/posts.go @@ -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 } diff --git a/controllers/posts_editor_smoke_test.go b/controllers/posts_editor_smoke_test.go new file mode 100644 index 0000000..93bdda3 --- /dev/null +++ b/controllers/posts_editor_smoke_test.go @@ -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 = "

Grzyby

\n

Tekst z linkiem & pogrubieniem.

\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, `

a

x`) + if err != nil { + t.Fatal(err) + } + if got.Value != `

a

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 = " \n

Stored HTML

" + 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 "

Stored render

", true, nil + }) + if got != "

Stored render

" { + 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 != "

Hello world

\n" { + t.Fatalf("Load(markdown, empty content_html) = %q", got) + } + if got := load(t, "Hello **world**", nil); got != "

Hello world

\n" { + t.Fatalf("Load(markdown, no reader) = %q", got) + } + }) + + t.Run("list value", func(t *testing.T) { + got := ml.ListValue(context.Background(), "

Title

Some bold\n text

") + if got != "Title Some bold text" { + t.Fatalf("ListValue = %q", got) + } + long := ml.ListValue(context.Background(), "

"+strings.Repeat("ż", 130)+"

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

A

"}, + {Locale: "en", Value: "

B

"}, + }, + }) + post := &models.Post{Content: "

A

", ContentHTML: "old"} + if err := ctl.FormBeforeUpdate(ctx, post); err != nil { + t.Fatal(err) + } + if post.ContentHTML != "

A

" { + t.Fatalf("ContentHTML = %q, want

A

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

B

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

kept

"} + if err := ctl.FormBeforeUpdate(ctx, post); err != nil { + t.Fatal(err) + } + if post.ContentHTML != "

kept

" { + 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 != "

kept

" || 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) + } + }) +} diff --git a/field_types.go b/field_types.go new file mode 100644 index 0000000..94fda82 --- /dev/null +++ b/field_types.go @@ -0,0 +1,21 @@ +package journal + +import ( + "git.golem15.com/golem15/sm-journal-plugin/classes" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// FormFieldTypes registers the Blog Editor field types, blogeditor and +// mlblogeditor (classes.BlogEditorTypes), with a lazy accessor of the booted +// app. +func (p *Plugin) FormFieldTypes() []pact.FormFieldType { + return classes.BlogEditorTypes(func() *backpack.App { + if p == nil { + return nil + } + return p.app + }) +} + +var _ pact.HasFormFieldTypes = (*Plugin)(nil) diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 8bd9aef..a89dcb2 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -131,3 +131,17 @@ tag: delete_confirm: Delete this tag? delete_success: Successfully deleted those tags. return_to_tags: Return to the journal tag list +editor: + content_invalid: The content must be HTML text. + image_invalid: The image must be a JPEG, PNG, GIF or WebP file. + upload_failed: The image could not be uploaded. + removed_elements: + one: ":count element was removed: :names" + other: ":count elements were removed: :names" + removed_attributes: + one: ":count attribute was removed: :names" + other: ":count attributes were removed: :names" + removed_comments: + one: ":count HTML comment was removed" + other: ":count HTML comments were removed" + markup_changed: The HTML filter changed some markup. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index dbdad7b..94e4e72 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -83,3 +83,23 @@ category: delete_success: Kategorie zostały pomyślnie usunięte. return_to_categories: Wróć do listy kategorii reorder: Zmień kolejność kategorii +editor: + content_invalid: Treść musi być tekstem HTML. + image_invalid: Obraz musi być plikiem JPEG, PNG, GIF lub WebP. + upload_failed: Nie udało się przesłać obrazu. + removed_elements: + one: "Usunięto :count element: :names" + few: "Usunięto :count elementy: :names" + many: "Usunięto :count elementów: :names" + other: "Usunięto :count elementu: :names" + removed_attributes: + one: "Usunięto :count atrybut: :names" + few: "Usunięto :count atrybuty: :names" + many: "Usunięto :count atrybutów: :names" + other: "Usunięto :count atrybutu: :names" + removed_comments: + one: "Usunięto :count komentarz HTML" + few: "Usunięto :count komentarze HTML" + many: "Usunięto :count komentarzy HTML" + other: "Usunięto :count komentarza HTML" + markup_changed: Filtr HTML zmienił część znaczników. diff --git a/models/post/fields.yaml b/models/post/fields.yaml index 9d50f72..7f9922c 100644 --- a/models/post/fields.yaml +++ b/models/post/fields.yaml @@ -16,7 +16,9 @@ fields: type: slug content: label: golem15.journal::lang.post.content - type: mlmarkdown + type: mlblogeditor + htmlColumn: content_html + uploadFolder: posts size: huge tab: golem15.journal::lang.post.tab_edit required: true diff --git a/posts_admin_smoke_test.go b/posts_admin_smoke_test.go index 6e33364..06e2281 100644 --- a/posts_admin_smoke_test.go +++ b/posts_admin_smoke_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" "strings" "testing" @@ -16,8 +17,8 @@ func TestPostsFormCompiles(t *testing.T) { token := env.admin(t, controllers.PermissionAccessPosts) rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminPostsPath+"/schema/form"), token, nil) body := rec.Body.String() - if !strings.Contains(body, "mlmarkdown") { - t.Fatalf("form schema missing mlmarkdown: %s", body) + if !strings.Contains(body, `"type":"mlblogeditor"`) { + t.Fatalf("form schema missing mlblogeditor: %s", body) } if !strings.Contains(body, "mltext") { t.Fatalf("form schema missing mltext: %s", body) @@ -40,9 +41,12 @@ func TestPostsAdminCreateSmoke(t *testing.T) { rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), privileged, map[string]any{ "title": map[string]any{"en": "Hello", "pl": "Witaj"}, "slug": map[string]any{"en": "hello-admin", "pl": "witaj-admin"}, - "content": map[string]any{"en": "Hello **world**", "pl": "Witaj **świecie**"}, + "content": map[string]any{"en": "

Hello world

", "pl": "

Witaj świecie

"}, "excerpt": map[string]any{"en": "Excerpt", "pl": "Wyjątek"}, }) + if notices := adminNotices(t, rec); notices != nil { + t.Fatalf("clean create carries meta.notices: %s", rec.Body.String()) + } var created struct { ID uint `json:"id"` } @@ -60,14 +64,9 @@ func TestPostsAdminCreateSmoke(t *testing.T) { if stored.Slug != "hello-admin" { t.Fatalf("host slug = %q, want hello-admin", stored.Slug) } - if stored.ContentHTML == "" { - t.Fatal("content_html is empty") - } - if strings.Contains(strings.ToLower(stored.ContentHTML), "") && !strings.Contains(stored.ContentHTML, "

") { - t.Fatalf("content_html missing markup: %s", stored.ContentHTML) + // D-02: the admin path stores the same HTML in both columns. + if stored.Content != "

Hello world

" || stored.ContentHTML != stored.Content { + t.Fatalf("content = %q, content_html = %q, want both the submitted HTML", stored.Content, stored.ContentHTML) } var plCount int64 @@ -139,8 +138,8 @@ func TestPostsAdminResaveKeepsHTML(t *testing.T) { if strings.Contains(stored.ContentHTML, "raw HTML omitted") { t.Fatalf("%s: content_html holds the goldmark placeholder: %q", stage, stored.ContentHTML) } - if strings.TrimSpace(stored.ContentHTML) != content { - t.Fatalf("%s: content_html = %q, want %q", stage, strings.TrimSpace(stored.ContentHTML), content) + if stored.ContentHTML != content { + t.Fatalf("%s: content_html = %q, want %q", stage, stored.ContentHTML, content) } if stored.Content != content { t.Fatalf("%s: content = %q, want %q", stage, stored.Content, content) @@ -152,10 +151,12 @@ func TestPostsAdminResaveKeepsHTML(t *testing.T) { assertHTML("re-save") } -// TestPostsAdminSaveSanitizesHTML pins CR-01 and WR-01 on the admin write -// path: unsafe markup in content is stripped from content_html, as Winter's -// Html::clean strips it, instead of refusing the save, and a "Data:" label is -// ordinary text. content itself is stored as submitted. +// TestPostsAdminSaveSanitizesHTML pins CR-01, WR-01 and Phase 16.2 D-02 and +// D-10 on the admin write path: unsafe markup in content is stripped, as +// Winter's Html::clean strips it, instead of refusing the save; content and +// content_html both hold the sanitized HTML for the host locale (en) and the +// translation (pl); a "Data:" label is ordinary text; and the response's +// meta.notices names what was removed for each locale. func TestPostsAdminSaveSanitizesHTML(t *testing.T) { env := newPostsEnv(t) token := env.admin(t, @@ -184,11 +185,68 @@ func TestPostsAdminSaveSanitizesHTML(t *testing.T) { } const want = "

Data: 12 maja 2024

\n" + "

\"a\"

\n" + - "

x

" - if got := strings.TrimSpace(stored.ContentHTML); got != want { - t.Fatalf("content_html = %q, want %q", got, want) + "

x

\n" + if stored.Content != want || stored.ContentHTML != want { + t.Fatalf("host content = %q, content_html = %q, want both %q", stored.Content, stored.ContentHTML, want) } - if stored.Content != content { - t.Fatalf("content = %q, want it stored as submitted", stored.Content) + var raw string + if err := env.gdb.Table("golem15_translate_attributes"). + Select("attribute_data"). + Where("model_id = ? AND model_type = ? AND locale = ?", fmt.Sprint(stored.ID), stored.MorphName(), "pl"). + Scan(&raw).Error; err != nil { + t.Fatal(err) + } + var pl map[string]any + if err := json.Unmarshal([]byte(raw), &pl); err != nil { + t.Fatalf("attribute_data %q: %v", raw, err) + } + if pl["content"] != want || pl["content_html"] != want { + t.Fatalf("pl content = %#v, content_html = %#v, want both %q", pl["content"], pl["content_html"], want) + } + + notices := adminNotices(t, rec) + for _, locale := range []string{"en", "pl"} { + var elements, attributes bool + for _, n := range notices { + if n.Field != "content" || n.Locale != locale { + continue + } + elements = elements || strings.Contains(n.Message, "2 elements were removed: a, base") + attributes = attributes || strings.Contains(n.Message, "1 attribute was removed: onerror") + } + if !elements || !attributes { + t.Fatalf("%s notices = %#v, want removed elements a, base and attribute onerror", locale, notices) + } } } + +// adminNotice is one meta.notices entry of an admin save response. +type adminNotice struct { + Field string `json:"field"` + Locale string `json:"locale"` + Message string `json:"message"` +} + +// adminNotices returns a save response's meta.notices, nil when the key is +// absent. +func adminNotices(t *testing.T, rec *httptest.ResponseRecorder) []adminNotice { + t.Helper() + var body struct { + Meta map[string]json.RawMessage `json:"meta"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("admin body %q: %v", rec.Body.String(), err) + } + raw, ok := body.Meta["notices"] + if !ok { + return nil + } + var notices []adminNotice + if err := json.Unmarshal(raw, ¬ices); err != nil { + t.Fatalf("meta.notices %s: %v", raw, err) + } + if notices == nil { + notices = []adminNotice{} + } + return notices +}