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:
Jakub Zych
2026-11-04 09:44:58 +01:00
parent f7bfb3313b
commit 305f94afd9
15 changed files with 553 additions and 7 deletions

View File

@@ -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
View 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
View 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 &amp; text</p>", "<p>no heading &amp; 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)
}
}
}

View 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
}