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