Files
sm-journal-plugin/classes/headings.go
Jakub Zych faaa9b0f55 feat: H1 conversion on the API, markdown and import paths
- the /_journal/api/v1 post writes convert default and translated content
  in the write transaction and answer an additive notices key
- journal:import-posts converts and prints the converted count
  (ImportFromDirConfig, ImportStats.H1Converted)
- a category's pillar text saved in the admin answers
  editor.h1_converted_category
- every GET /categories row reports content_h1_to_h2 (additive key)
2026-11-04 09:51:47 +01:00

99 lines
2.8 KiB
Go

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)
}
// ConvertContentH1 applies ConvertH1 to a post body and its rendered HTML
// (content and content_html) and returns both with the number of headings
// converted: content_html's count, or content's when content_html had none
// (a markdown body has no h1 tag, its rendered HTML does).
func ConvertContentH1(content, contentHTML string) (string, string, int) {
content, nContent := ConvertH1(content)
contentHTML, nHTML := ConvertH1(contentHTML)
if nHTML > 0 {
return content, contentHTML, nHTML
}
return content, contentHTML, nContent
}
// ConvertTranslationH1 applies ConvertContentH1 to the string content and
// content_html of one locale's translated attributes, in place, and returns
// the number of headings converted. Other attributes and values that are
// not strings are left alone.
func ConvertTranslationH1(attrs map[string]any) int {
if attrs == nil {
return 0
}
content, hasContent := attrs["content"].(string)
html, hasHTML := attrs["content_html"].(string)
if !hasContent && !hasHTML {
return 0
}
content, html, n := ConvertContentH1(content, html)
if hasContent {
attrs["content"] = content
}
if hasHTML {
attrs["content_html"] = html
}
return n
}