fix: H1 conversion leaves markdown and unfinished tags alone

ConvertContentH1 rewrites content only when it is HTML; a markdown
source, its code samples included, is kept byte for byte and only its
rendered content_html is converted, on API saves, imports,
journal:fix-headings and translations. ConvertH1 and SetImageAlt keep
the bytes of an unfinished tag at the end of the input.
This commit is contained in:
Jakub Zych
2026-11-04 23:14:42 +01:00
parent 45d66f6071
commit 0e739c882e
6 changed files with 123 additions and 11 deletions

View File

@@ -13,7 +13,8 @@ import (
// 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.
// tag and is not touched. html without an h1 comes back unchanged with 0,
// and the bytes of an unfinished tag at the end of html are kept.
func ConvertH1(html string) (string, int) {
if !containsFold(html, "<h1") && !containsFold(html, "</h1") {
return html, 0
@@ -22,12 +23,17 @@ func ConvertH1(html string) (string, int) {
var b bytes.Buffer
b.Grow(len(html))
count := 0
consumed := 0
for {
tt := z.Next()
if tt == xhtml.ErrorToken {
// The input may end inside something the tokenizer reads as a
// tag ("a <b"): those bytes are kept as they are.
b.WriteString(html[min(consumed, len(html)):])
break
}
raw := z.Raw()
consumed += len(raw)
switch tt {
case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
if name, _ := z.TagName(); string(name) == "h1" && len(raw) >= 3 {
@@ -63,11 +69,18 @@ func containsFold(s, sub string) bool {
// 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).
// converted: content_html's count, or content's when content_html had none.
// content is rewritten only when it is HTML (LooksLikeHTML, the rich
// editor's body): a markdown body is never rewritten, so a code sample that
// shows an <h1> keeps it, and only its rendered content_html is converted
// (every render path derives content_html from the markdown again and
// converts it again).
func ConvertContentH1(content, contentHTML string) (string, string, int) {
content, nContent := ConvertH1(content)
contentHTML, nHTML := ConvertH1(contentHTML)
if !LooksLikeHTML(content) {
return content, contentHTML, nHTML
}
content, nContent := ConvertH1(content)
if nHTML > 0 {
return content, contentHTML, nHTML
}
@@ -76,8 +89,9 @@ func ConvertContentH1(content, contentHTML string) (string, string, int) {
// 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.
// the number of headings converted. A markdown content is never rewritten
// (ConvertContentH1). Other attributes and values that are not strings are
// left alone.
func ConvertTranslationH1(attrs map[string]any) int {
if attrs == nil {
return 0

View File

@@ -1,6 +1,9 @@
package classes
import "testing"
import (
"strings"
"testing"
)
// TestConvertH1 pins ConvertH1's byte rule: only h1 tag names change.
func TestConvertH1(t *testing.T) {
@@ -27,6 +30,16 @@ func TestConvertH1(t *testing.T) {
t.Errorf("ConvertH1(%q) = %q, %d; want %q, %d", c.in, got, n, c.want, c.n)
}
}
// L1 WR-03: input without a match comes back identical, and an h1
// followed by an unfinished tag keeps the tail.
for _, in := range []string{"p a <b", "x<y", "<p>a <b class=\"c", "a < b"} {
if out, n := ConvertH1(in); out != in || n != 0 {
t.Errorf("ConvertH1(%q) = %q %d", in, out, n)
}
}
if out, n := ConvertH1("<h1>x</h1><p>a <b"); out != "<h2>x</h2><p>a <b" || n != 1 {
t.Errorf("unfinished tail = %q %d", out, n)
}
}
// TestConvertContentH1 pins the body pair: content_html's count when its
@@ -47,6 +60,25 @@ func TestConvertContentH1(t *testing.T) {
if CountH1(`<h1>a</h1><div><h1 class="x">b</h1></div><h2>c</h2>`) != 2 || CountH1("") != 0 {
t.Fatal("CountH1 is wrong")
}
// L1 CR-02: markdown with a fenced html sample and an inline code h1 is
// kept byte for byte; its rendered HTML has the real heading converted
// and the code still escaped.
md := "# Title\n\nExample:\n\n```html\n<h1>Hello</h1>\n```\n\nInline `<h1>` tag."
rendered, err := FormatHTML(md)
if err != nil {
t.Fatal(err)
}
c, h, n = ConvertContentH1(md, rendered)
if c != md {
t.Fatalf("the markdown source was rewritten:\n%s", c)
}
if n != 1 || !strings.Contains(h, "<h2") || strings.Contains(h, "<h1") || !strings.Contains(h, "&lt;h1&gt;Hello&lt;/h1&gt;") || !strings.Contains(h, "<code>&lt;h1&gt;</code>") {
t.Fatalf("rendered HTML = %q n=%d", h, n)
}
// Markdown without rendered HTML: nothing changes, nothing counted.
if c, h, n := ConvertContentH1("plain <h1>x</h1> text", ""); c != "plain <h1>x</h1> text" || h != "" || n != 0 {
t.Fatalf("markdown without HTML = %q %q %d", c, h, n)
}
}
// TestConvertTranslationH1 pins a locale's translated attributes,
@@ -65,4 +97,9 @@ func TestConvertTranslationH1(t *testing.T) {
if ConvertTranslationH1(nil) != 0 || ConvertTranslationH1(map[string]any{"title": "x"}) != 0 {
t.Fatal("nothing to convert")
}
md := "Sample:\n\n```html\n<h1>Hi</h1>\n```\n"
tr := map[string]any{"content": md, "content_html": "<pre><code>&lt;h1&gt;Hi&lt;/h1&gt;</code></pre>"}
if n := ConvertTranslationH1(tr); n != 0 || tr["content"] != md {
t.Fatalf("a markdown translation was rewritten: %v n=%d", tr, n)
}
}

View File

@@ -13,8 +13,8 @@ import (
// (nil when it had none). It works on the x/net/html tokenizer: a matching
// img tag is written again from its token, so every attribute value,
// alt included, is escaped by the tokenizer and never concatenated; every
// other byte of html is copied as it is. An empty alt marks a decorative
// image (alt="").
// other byte of html is copied as it is, the bytes of an unfinished tag at
// the end included. An empty alt marks a decorative image (alt="").
func SetImageAlt(html, src, alt string) (string, int, *string) {
if src == "" || !containsFold(html, "<img") {
return html, 0, nil
@@ -24,12 +24,16 @@ func SetImageAlt(html, src, alt string) (string, int, *string) {
b.Grow(len(html) + len(alt))
matches := 0
var old *string
consumed := 0
for {
tt := z.Next()
if tt == xhtml.ErrorToken {
// The bytes of an unfinished tag at the end are kept.
b.WriteString(html[min(consumed, len(html)):])
break
}
raw := z.Raw()
consumed += len(raw)
if tt == xhtml.StartTagToken || tt == xhtml.SelfClosingTagToken {
// Raw is copied before Token, which reads the tag; Token is
// the only reader of the tag (TagName would consume it).

View File

@@ -44,4 +44,13 @@ func TestSetImageAltEdges(t *testing.T) {
t.Errorf("SetImageAlt(%q, %q) = %q %d %v", tc.html, tc.src, out, n, old)
}
}
// L1 WR-03: the bytes of an unfinished tag at the end are kept.
for _, in := range []string{"p a <img", "x<y", `<img src="b.jpg"><p>a <b`} {
if out, n, _ := SetImageAlt(in, "a.jpg", "z"); out != in || n != 0 {
t.Errorf("SetImageAlt(%q) = %q %d", in, out, n)
}
}
if out, n, _ := SetImageAlt(`<img src="a.jpg"><p>a <b`, "a.jpg", "z"); out != `<img src="a.jpg" alt="z"><p>a <b` || n != 1 {
t.Errorf("match with an unfinished tail = %q %d", out, n)
}
}