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:
@@ -178,9 +178,9 @@ The search document holds the default-locale title, excerpt and content; content
|
||||
Journal decides per site whether its rich text may hold an H1 (2026-10-28, D-50): a page template that shows the post title or the category name as the H1 needs the body to start at H2, and Journal cannot know which template a site uses.
|
||||
|
||||
- **The setting.** Settings › Journal, tab Headings and links, switch `content_h1_to_h2` (Convert H1 to H2 in post and category content), on by default. The settings row decides; the config key `golem15.journal.content_h1_to_h2` is used only when no row exists (`classes.EffectiveH1ToH2`). Switching it never rewrites stored content.
|
||||
- **On.** Every H1 saved in Journal rich text becomes an H2 (`classes.ConvertH1`, on the `golang.org/x/net/html` tokenizer: only the tag name changes, attributes and every other byte are kept), on every write path: an admin save of a post or a category (notice `editor.h1_converted` or `editor.h1_converted_category` on the editor field), `POST` and `PUT /_journal/api/v1/posts` and so MCP (default and translated `content` and `content_html`; the answer gains `notices: [{message, count, locale}]`, `locale` only on a translation's notice), markdown content (its rendered HTML) and `journal:import-posts` (it prints `Converted N H1 headings to H2.`). The editor offers H2 to H4.
|
||||
- **On.** Every H1 saved in Journal rich text becomes an H2 (`classes.ConvertH1`, on the `golang.org/x/net/html` tokenizer: only the tag name changes, attributes and every other byte are kept), on every write path: an admin save of a post or a category (notice `editor.h1_converted` or `editor.h1_converted_category` on the editor field), `POST` and `PUT /_journal/api/v1/posts` and so MCP (default and translated `content` and `content_html`; the answer gains `notices: [{message, count, locale}]`, `locale` only on a translation's notice), markdown content (its rendered HTML only: a markdown source is never rewritten, so a code sample that shows `<h1>` keeps it; `classes.ConvertContentH1` rewrites `content` only when it is HTML, `classes.LooksLikeHTML`) and `journal:import-posts` (it prints `Converted N H1 headings to H2.`). The bytes of an unfinished tag at the end of a text are kept. The editor offers H2 to H4.
|
||||
- **Off.** H1 is stored as written, no notice appears, the editor offers H1 to H4 and `journal:fix-headings` refuses.
|
||||
- **Stored content.** `journal:fix-headings` (a dry run unless `--apply`) and the MCP tool `journal_fix_headings` (`dry_run` default `true`) convert H1 already stored in post content (default and translated) and in category pillar text, in one transaction, firing a `RecordSaved` per changed post and category; they answer the post, category and heading counts and refuse while the setting is off (exit code 1; `409` from the API).
|
||||
- **Stored content.** `journal:fix-headings` (a dry run unless `--apply`) and the MCP tool `journal_fix_headings` (`dry_run` default `true`) convert H1 already stored in post content (default and translated; of a markdown post only `content_html`) and in category pillar text, in one transaction, firing a `RecordSaved` per changed post and category; they answer the post, category and heading counts and refuse while the setting is off (exit code 1; `409` from the API).
|
||||
- **The API flag.** `GET /_journal/api/v1/posts/{slug}` and every category row of `GET /_journal/api/v1/categories` carry `content_h1_to_h2` (boolean, an added key), so a frontend reads the setting from the Journal and not from its own option.
|
||||
- **Generic link texts.** The `generic_anchors` setting (one per line; the English and Polish seed lists when empty, `classes.DefaultGenericAnchors`) is the list the editor's outline and link form warn about.
|
||||
- **Settings API.** `GET` and `PUT /_journal/api/v1/settings` read and write every settings column for `golem15.journal.manage_settings` (`403` without it); `PUT` checks the submitted columns against `models.Settings.Rules` and ignores other keys.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "<h1>Hello</h1>") || !strings.Contains(h, "<code><h1></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><h1>Hi</h1></code></pre>"}
|
||||
if n := ConvertTranslationH1(tr); n != 0 || tr["content"] != md {
|
||||
t.Fatalf("a markdown translation was rewritten: %v n=%d", tr, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/console"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
@@ -291,6 +292,35 @@ func TestHeadingConversion(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("markdown with a fenced h1 sample keeps its source", func(t *testing.T) {
|
||||
setH1ToH2(t, env, true)
|
||||
md := "# Sample\n\n```html\n<h1>Hello</h1>\n```\n\nInline `<h1>` tag."
|
||||
id := apiPostID(t, env.expect(t, http.StatusCreated, http.MethodPost, posts, token, map[string]any{"title": "Markdown sample", "content": md}))
|
||||
p := storedPost(t, env, id)
|
||||
if p.Content != md || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h1>Hello</h1>") {
|
||||
t.Fatalf("API save: content = %q, content_html = %q", p.Content, p.ContentHTML)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
raw, err := json.Marshal(console.PostFile{Slug: "imported-markdown", Title: "Imported markdown", Content: md})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "imported-markdown.json"), raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf strings.Builder
|
||||
if err := console.ImportPosts(env.app).Run(t.Context(), cmdInput{"path": dir}, bonfire.NewOutput(strings.NewReader(""), &buf, &buf)); err != nil {
|
||||
t.Fatalf("import: %v (%s)", err, buf.String())
|
||||
}
|
||||
var imported models.Post
|
||||
if err := env.gdb.Where("slug = ?", "imported-markdown").Take(&imported).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if imported.Content != md || strings.Contains(imported.ContentHTML, "<h1") || !strings.Contains(imported.ContentHTML, "<h2") {
|
||||
t.Fatalf("import: content = %q, content_html = %q", imported.Content, imported.ContentHTML)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("category pillar text through the admin", func(t *testing.T) {
|
||||
catToken := env.admin(t, controllers.PermissionAccessCategories)
|
||||
save := func(t *testing.T, slug string) (*string, []adminNotice) {
|
||||
@@ -434,6 +464,24 @@ func TestFixHeadingsCommand(t *testing.T) {
|
||||
t.Fatalf("second run = %q, %v", text, err)
|
||||
}
|
||||
|
||||
// L1 CR-02: a markdown post with a fenced h1 sample keeps its source;
|
||||
// only its stored content_html is converted.
|
||||
md := "# Sample\n\n```html\n<h1>Hello</h1>\n```\n"
|
||||
rendered, err := classes.FormatHTML(md)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mdPost := models.Post{Title: "Markdown sample", Slug: "fix-cli-markdown", Content: md, ContentHTML: rendered}
|
||||
if err := env.gdb.Create(&mdPost).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if text, err = run(t, cmdInput{"apply": "true"}); err != nil || !strings.Contains(text, "Converted 1 H1 headings in 1 posts and 0 categories.") {
|
||||
t.Fatalf("markdown run = %q, %v", text, err)
|
||||
}
|
||||
if p := storedPost(t, env, mdPost.ID); p.Content != md || strings.Contains(p.ContentHTML, "<h1") || !strings.Contains(p.ContentHTML, "<h1>Hello</h1>") {
|
||||
t.Fatalf("markdown post after --apply = %q / %q", p.Content, p.ContentHTML)
|
||||
}
|
||||
|
||||
setH1ToH2(t, env, false)
|
||||
t.Cleanup(func() { setH1ToH2(t, env, true) })
|
||||
text, err = run(t, cmdInput{"apply": "true"})
|
||||
|
||||
Reference in New Issue
Block a user