feat: H1 to H2 setting with the admin save conversion (tracer)
- content_h1_to_h2 and generic_anchors on the settings row (migration
202610280001), Headings and links tab, config key
golem15.journal.content_h1_to_h2 used when no settings row exists
- classes.ConvertH1 on the x/net/html tokenizer; the Blog Editor types
convert while the setting is on and answer editor.h1_converted
- GET /posts/{slug} reports content_h1_to_h2 (additive key)
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gocloud.dev/blob"
|
||||
xhtml "golang.org/x/net/html"
|
||||
@@ -31,6 +32,9 @@ const (
|
||||
editorRemovedAttributes = "golem15.journal::lang.editor.removed_attributes"
|
||||
editorRemovedComments = "golem15.journal::lang.editor.removed_comments"
|
||||
editorMarkupChanged = "golem15.journal::lang.editor.markup_changed"
|
||||
// EditorH1Converted is the notice of a save that turned H1 headings
|
||||
// into H2 (Count is the number of headings).
|
||||
EditorH1Converted = "golem15.journal::lang.editor.h1_converted"
|
||||
)
|
||||
|
||||
// blogListValueRunes is how many runes of text a list column shows.
|
||||
@@ -79,6 +83,7 @@ var (
|
||||
// when unset).
|
||||
func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
||||
upload := uploadBlogImage(app)
|
||||
decode := decodeBlogHTMLFor(app)
|
||||
return []pact.FormFieldType{
|
||||
{
|
||||
Name: "blogeditor",
|
||||
@@ -87,7 +92,7 @@ func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
||||
Styles: []string{"assets/dist/blogeditor.css"},
|
||||
Options: []string{"uploadFolder"},
|
||||
CheckOptions: checkBlogOptions,
|
||||
Decode: decodeBlogHTML,
|
||||
Decode: decode,
|
||||
Load: loadBlogHTML,
|
||||
ListValue: blogListValue,
|
||||
Upload: upload,
|
||||
@@ -100,7 +105,7 @@ func BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType {
|
||||
Styles: []string{"assets/dist/blogeditor.css"},
|
||||
Options: []string{"htmlColumn", "uploadFolder"},
|
||||
CheckOptions: checkBlogOptions,
|
||||
Decode: decodeBlogHTML,
|
||||
Decode: decode,
|
||||
Load: loadBlogHTML,
|
||||
ListValue: blogListValue,
|
||||
Upload: upload,
|
||||
@@ -146,6 +151,47 @@ func checkUploadFolder(folder string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeBlogHTMLFor is the Blog Editor types' Decode: decodeBlogHTML, then,
|
||||
// while the H1 setting is on (EffectiveH1ToH2 on the save transaction and
|
||||
// the booted application's config), ConvertH1 on the stored HTML with the
|
||||
// EditorH1Converted notice counting the converted headings. app may be nil
|
||||
// or answer nil; the setting is then read from the database alone.
|
||||
func decodeBlogHTMLFor(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
out, err := decodeBlogHTML(ctx, in)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
text, isString := out.Value.(string)
|
||||
if !isString || text == "" {
|
||||
return out, nil
|
||||
}
|
||||
if !EffectiveH1ToH2(ctx, in.DB, appConfig(app)) {
|
||||
return out, nil
|
||||
}
|
||||
converted, n := ConvertH1(text)
|
||||
if n == 0 {
|
||||
return out, nil
|
||||
}
|
||||
out.Value = converted
|
||||
out.Notices = append(out.Notices, pact.FormFieldNotice{Message: EditorH1Converted, Count: n})
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// appConfig is the config of the application app answers, nil when there
|
||||
// is none.
|
||||
func appConfig(app func() *backpack.App) *compass.Config {
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
a := app()
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return a.Config
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
62
classes/headings.go
Normal file
62
classes/headings.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
xhtml "golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// ConvertH1 turns every h1 element of html into an h2 and returns the
|
||||
// result with the number of headings it converted. It works on the
|
||||
// x/net/html tokenizer's raw tokens: only the tag name of an h1 start, end
|
||||
// or self-closing tag changes, and every other byte (attributes, text,
|
||||
// comments, the content of script or style) is copied as it is. Text that
|
||||
// merely looks like a tag inside raw-text elements or comments is not a
|
||||
// tag and is not touched. html without an h1 comes back unchanged with 0.
|
||||
func ConvertH1(html string) (string, int) {
|
||||
if !containsFold(html, "<h1") && !containsFold(html, "</h1") {
|
||||
return html, 0
|
||||
}
|
||||
z := xhtml.NewTokenizer(strings.NewReader(html))
|
||||
var b bytes.Buffer
|
||||
b.Grow(len(html))
|
||||
count := 0
|
||||
for {
|
||||
tt := z.Next()
|
||||
if tt == xhtml.ErrorToken {
|
||||
break
|
||||
}
|
||||
raw := z.Raw()
|
||||
switch tt {
|
||||
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
|
||||
if name, _ := z.TagName(); string(name) == "h1" && len(raw) >= 3 {
|
||||
b.WriteString("<h2")
|
||||
b.Write(raw[3:])
|
||||
count++
|
||||
continue
|
||||
}
|
||||
case xhtml.EndTagToken:
|
||||
if name, _ := z.TagName(); string(name) == "h1" && len(raw) >= 4 {
|
||||
b.WriteString("</h2")
|
||||
b.Write(raw[4:])
|
||||
continue
|
||||
}
|
||||
}
|
||||
b.Write(raw)
|
||||
}
|
||||
if count == 0 && b.String() == html {
|
||||
return html, 0
|
||||
}
|
||||
return b.String(), count
|
||||
}
|
||||
|
||||
// CountH1 is the number of h1 start tags in html, as ConvertH1 counts them.
|
||||
func CountH1(html string) int {
|
||||
_, n := ConvertH1(html)
|
||||
return n
|
||||
}
|
||||
|
||||
func containsFold(s, sub string) bool {
|
||||
return strings.Contains(strings.ToLower(s), sub)
|
||||
}
|
||||
30
classes/headings_test.go
Normal file
30
classes/headings_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package classes
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestConvertH1 pins ConvertH1's byte rule: only h1 tag names change.
|
||||
func TestConvertH1(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
n int
|
||||
}{
|
||||
{"<h1>A</h1><p>x</p>", "<h2>A</h2><p>x</p>", 1},
|
||||
{`<h1 class="x" id='y'>T</h1>`, `<h2 class="x" id='y'>T</h2>`, 1},
|
||||
{"<H1>Up</H1>", "<h2>Up</h2>", 1},
|
||||
{"<h1>a</h1>\n<h1>b</h1>", "<h2>a</h2>\n<h2>b</h2>", 2},
|
||||
{"<p>no heading & text</p>", "<p>no heading & text</p>", 0},
|
||||
{"<!-- <h1>c</h1> --><p>x</p>", "<!-- <h1>c</h1> --><p>x</p>", 0},
|
||||
{"<script>var s = '<h1>';</script>", "<script>var s = '<h1>';</script>", 0},
|
||||
{"<h10>x</h10><h1>y</h1>", "<h10>x</h10><h2>y</h2>", 1},
|
||||
{"# Markdown <h1>raw</h1> a < b", "# Markdown <h2>raw</h2> a < b", 1},
|
||||
{"<p>unterminated <h1>x", "<p>unterminated <h2>x", 1},
|
||||
{"text <", "text <", 0},
|
||||
{"", "", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, n := ConvertH1(c.in)
|
||||
if got != c.want || n != c.n {
|
||||
t.Errorf("ConvertH1(%q) = %q, %d; want %q, %d", c.in, got, n, c.want, c.n)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
classes/journal_settings.go
Normal file
84
classes/journal_settings.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// H1ToH2Key is the config key of the H1 setting, used only when the Journal
|
||||
// settings row does not exist (EffectiveH1ToH2). Its shipped default is true.
|
||||
const H1ToH2Key = "golem15.journal.content_h1_to_h2"
|
||||
|
||||
// DefaultGenericAnchors are the link texts the editor warns about when the
|
||||
// settings row has no list of its own: the English and the Polish seed list,
|
||||
// each entry once.
|
||||
var DefaultGenericAnchors = []string{
|
||||
"click here", "here", "more", "read more", "learn more", "this link", "link",
|
||||
"kliknij tutaj", "tutaj", "tu", "więcej", "czytaj więcej", "czytaj dalej", "ten link",
|
||||
}
|
||||
|
||||
// headingSettings is the part of the settings row the heading rules read.
|
||||
type headingSettings struct {
|
||||
ContentH1ToH2 bool
|
||||
GenericAnchors *string
|
||||
}
|
||||
|
||||
// readHeadingSettings reads the settings row (ID 1); found is false when
|
||||
// there is no row or no database.
|
||||
func readHeadingSettings(ctx context.Context, db *gorm.DB) (headingSettings, bool) {
|
||||
if db == nil {
|
||||
return headingSettings{}, false
|
||||
}
|
||||
if ctx != nil {
|
||||
db = db.WithContext(ctx)
|
||||
}
|
||||
var rows []headingSettings
|
||||
if err := db.Model(&models.Settings{}).Select("content_h1_to_h2", "generic_anchors").
|
||||
Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||
return headingSettings{}, false
|
||||
}
|
||||
return rows[0], true
|
||||
}
|
||||
|
||||
// EffectiveH1ToH2 is the one reader of the H1 setting (P33-04): the settings
|
||||
// row's content_h1_to_h2 decides; without a row (or a database) the config
|
||||
// key golem15.journal.content_h1_to_h2 does, and true when that is unset
|
||||
// too. While it is true every H1 saved in Journal rich text becomes an H2.
|
||||
func EffectiveH1ToH2(ctx context.Context, db *gorm.DB, cfg *compass.Config) bool {
|
||||
if row, ok := readHeadingSettings(ctx, db); ok {
|
||||
return row.ContentH1ToH2
|
||||
}
|
||||
if cfg != nil && cfg.Has(H1ToH2Key) {
|
||||
return cfg.Bool(H1ToH2Key)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GenericAnchors is the list of generic link texts: the settings row's
|
||||
// generic_anchors, one entry per line, trimmed and lowercased with blank
|
||||
// lines dropped, or DefaultGenericAnchors when the row has no list (no
|
||||
// row, NULL or only blank lines).
|
||||
func GenericAnchors(ctx context.Context, db *gorm.DB) []string {
|
||||
row, ok := readHeadingSettings(ctx, db)
|
||||
if !ok || row.GenericAnchors == nil {
|
||||
return append([]string(nil), DefaultGenericAnchors...)
|
||||
}
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(*row.GenericAnchors, "\n") {
|
||||
entry := strings.ToLower(strings.TrimSpace(line))
|
||||
if entry == "" || seen[entry] {
|
||||
continue
|
||||
}
|
||||
seen[entry] = true
|
||||
out = append(out, entry)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return append([]string(nil), DefaultGenericAnchors...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user