diff --git a/README.md b/README.md index 0ef0fc5..b67570c 100644 --- a/README.md +++ b/README.md @@ -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 `

` 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. diff --git a/classes/headings.go b/classes/headings.go index 9224acd..0f1a76e 100644 --- a/classes/headings.go +++ b/classes/headings.go @@ -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, "= 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

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 diff --git a/classes/headings_test.go b/classes/headings_test.go index cb3f05e..04be22f 100644 --- a/classes/headings_test.go +++ b/classes/headings_test.go @@ -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 a x

a a

b

c

`) != 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

Hello

\n```\n\nInline `

` 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, "<h1>") { + t.Fatalf("rendered HTML = %q n=%d", h, n) + } + // Markdown without rendered HTML: nothing changes, nothing counted. + if c, h, n := ConvertContentH1("plain

x

text", ""); c != "plain

x

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

Hi

\n```\n" + tr := map[string]any{"content": md, "content_html": "
<h1>Hi</h1>
"} + if n := ConvertTranslationH1(tr); n != 0 || tr["content"] != md { + t.Fatalf("a markdown translation was rewritten: %v n=%d", tr, n) + } } diff --git a/classes/image_alt.go b/classes/image_alt.go index b034f4e..9f604c4 100644 --- a/classes/image_alt.go +++ b/classes/image_alt.go @@ -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, "

a

a

a