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:
261
classes/blog_editor.go
Normal file
261
classes/blog_editor.go
Normal file
@@ -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) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user