diff --git a/classes/blog_editor.go b/classes/blog_editor.go index 68685d0..64563e1 100644 --- a/classes/blog_editor.go +++ b/classes/blog_editor.go @@ -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 diff --git a/classes/headings.go b/classes/headings.go new file mode 100644 index 0000000..ac3ae00 --- /dev/null +++ b/classes/headings.go @@ -0,0 +1,62 @@ +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, "
x
", "x
", 1}, + {`no heading & text
", "no heading & text
", 0}, + {"x
", "x
", 0}, + {"", "", 0}, + {"unterminated
unterminated
x
" + + save := func(t *testing.T, slug string) (uint, []adminNotice) { + t.Helper() + rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminPostsPath), token, map[string]any{ + "title": map[string]any{"en": "Heading " + slug, "pl": "Nagłówek " + slug}, + "slug": map[string]any{"en": slug, "pl": slug + "-pl"}, + "content": map[string]any{"en": submitted, "pl": submitted}, + }) + return adminCreatedID(t, rec), adminNotices(t, rec) + } + + t.Run("setting on", func(t *testing.T) { + setH1ToH2(t, env, true) + id, notices := save(t, "heading-on") + assertHostContent(t, env, id, "on", "x
", "x
") + pl := translatedAttrs(t, env, id, "pl") + if pl["content"] != "x
" || pl["content_html"] != "x
" { + t.Fatalf("pl content = %#v, content_html = %#v, want the H2", pl["content"], pl["content_html"]) + } + for _, locale := range []string{"en", "pl"} { + found := false + for _, n := range notices { + if n.Field == "content" && n.Locale == locale && + n.Message == "1 H1 heading was converted to H2, because the page shows the post title as the H1." { + found = true + } + } + if !found { + t.Fatalf("%s notices = %#v, want the h1_converted phrase with count 1", locale, notices) + } + } + publishPost(t, env, id) + if flag := showH1Flag(t, env, "heading-on"); flag != true { + t.Fatalf("content_h1_to_h2 = %#v, want true", flag) + } + }) + + t.Run("setting off", func(t *testing.T) { + setH1ToH2(t, env, false) + t.Cleanup(func() { setH1ToH2(t, env, true) }) + id, notices := save(t, "heading-off") + assertHostContent(t, env, id, "off", submitted, submitted) + for _, n := range notices { + if strings.Contains(n.Message, "H1") { + t.Fatalf("setting off: notice %#v", n) + } + } + if notices != nil { + t.Fatalf("setting off: clean save carries meta.notices %#v", notices) + } + publishPost(t, env, id) + if flag := showH1Flag(t, env, "heading-off"); flag != false { + t.Fatalf("content_h1_to_h2 = %#v, want false", flag) + } + // D-50: switching the setting never rewrites stored content. + setH1ToH2(t, env, true) + var stored models.Post + if err := env.gdb.First(&stored, id).Error; err != nil { + t.Fatal(err) + } + if stored.Content != submitted || stored.ContentHTML != submitted { + t.Fatalf("switching the setting on rewrote content: %q / %q", stored.Content, stored.ContentHTML) + } + }) +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 6268e3c..ee3c41c 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -55,6 +55,11 @@ journal: rss_image_url_comment: URL to an image/logo displayed in feed readers. rss_posts_per_feed_label: Posts per feed rss_posts_per_feed_comment: Default number of posts in the RSS feed. Can be overridden per component. + tab_headings: Headings and links + h1_to_h2_label: Convert H1 to H2 in post and category content + h1_to_h2_comment: 'Turn it off only when the page template does not show the post title or the category name as the H1. On: the editor offers H2–H4, and every H1 saved through the admin, the API, MCP or an import becomes an H2. Off: content keeps its H1 headings, and the site shows them unchanged.' + generic_anchors_label: Generic link texts + generic_anchors_comment: One per line. The editor warns when the whole text of a link is one of these. posts: list_title: Manage the journal posts filter_category: Category @@ -157,3 +162,6 @@ editor: one: ":count HTML comment was removed" other: ":count HTML comments were removed" markup_changed: The HTML filter changed some markup. + h1_converted: + one: ":count H1 heading was converted to H2, because the page shows the post title as the H1." + other: ":count H1 headings were converted to H2, because the page shows the post title as the H1." diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 2572006..d300760 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -32,6 +32,11 @@ journal: search_excerpt_weight_comment: Waga trafności dla dopasowań w zajawce posta (domyślnie 3). search_content_weight_label: Waga treści search_content_weight_comment: Waga trafności dla dopasowań w treści posta (domyślnie 1). + tab_headings: Nagłówki i linki + h1_to_h2_label: Zamieniaj H1 na H2 w treści wpisów i kategorii + h1_to_h2_comment: 'Wyłącz tylko wtedy, gdy szablon strony nie wyświetla tytułu wpisu ani nazwy kategorii jako H1. Włączone: edytor oferuje H2–H4, a każdy H1 zapisany przez panel, API, MCP lub import staje się H2. Wyłączone: treść zachowuje nagłówki H1, a witryna wyświetla je bez zmian.' + generic_anchors_label: Ogólnikowe teksty linków + generic_anchors_comment: Jeden w wierszu. Edytor ostrzega, gdy cały tekst linku jest jednym z nich. posts: list_title: Zarządzaj postami filter_category: Kategoria @@ -115,3 +120,8 @@ editor: many: "Usunięto :count komentarzy HTML" other: "Usunięto :count komentarza HTML" markup_changed: Filtr HTML zmienił część znaczników. + h1_converted: + one: "Zamieniono :count nagłówek H1 na H2, bo strona wyświetla tytuł wpisu jako H1." + few: "Zamieniono :count nagłówki H1 na H2, bo strona wyświetla tytuł wpisu jako H1." + many: "Zamieniono :count nagłówków H1 na H2, bo strona wyświetla tytuł wpisu jako H1." + other: "Zamieniono :count nagłówka H1 na H2, bo strona wyświetla tytuł wpisu jako H1." diff --git a/models/settings.go b/models/settings.go index 63144f4..77e7674 100644 --- a/models/settings.go +++ b/models/settings.go @@ -17,6 +17,12 @@ type Settings struct { RSSCopyright *string `gorm:"column:rss_copyright"` RSSImageURL *string `gorm:"column:rss_image_url"` RSSPostsPerFeed int `gorm:"column:rss_posts_per_feed"` + // ContentH1ToH2 turns H1 in Journal rich text (post content and + // category pillar text) into H2 on every write path. + ContentH1ToH2 bool `gorm:"column:content_h1_to_h2"` + // GenericAnchors lists the link texts the editor warns about, one per + // line; nil means the built-in lists. + GenericAnchors *string `gorm:"column:generic_anchors"` } func (Settings) TableName() string { return "golem15_journal_settings" } @@ -27,6 +33,7 @@ func (Settings) Fillable() []string { "search_title_weight", "search_excerpt_weight", "search_content_weight", "rss_enabled", "rss_include_content", "rss_title", "rss_description", "rss_language", "rss_copyright", "rss_image_url", "rss_posts_per_feed", + "content_h1_to_h2", "generic_anchors", } } @@ -46,6 +53,8 @@ func (Settings) Rules() map[string]string { "rss_copyright": "nullable|string|max:255", "rss_image_url": "nullable|url|max:500", "rss_posts_per_feed": "nullable|integer|min:1|max:100", + "content_h1_to_h2": "boolean", + "generic_anchors": "nullable|string|max:5000", } } diff --git a/models/settings/fields.yaml b/models/settings/fields.yaml index 1197c2b..3cf9175 100644 --- a/models/settings/fields.yaml +++ b/models/settings/fields.yaml @@ -13,6 +13,21 @@ fields: type: switch default: false tab: golem15.journal::lang.journal.tab_general + content_h1_to_h2: + span: left + label: golem15.journal::lang.journal.h1_to_h2_label + comment: golem15.journal::lang.journal.h1_to_h2_comment + type: switch + default: true + tab: golem15.journal::lang.journal.tab_headings + generic_anchors: + span: full + label: golem15.journal::lang.journal.generic_anchors_label + comment: golem15.journal::lang.journal.generic_anchors_comment + type: textarea + size: small + default: "click here\nhere\nmore\nread more\nlearn more\nthis link\nlink\nkliknij tutaj\ntutaj\ntu\nwięcej\nczytaj więcej\nczytaj dalej\nten link" + tab: golem15.journal::lang.journal.tab_headings search_use_typesense: span: left label: golem15.journal::lang.journal.search_use_typesense_label diff --git a/plugin.go b/plugin.go index de78163..83c1c2a 100644 --- a/plugin.go +++ b/plugin.go @@ -25,6 +25,7 @@ import ( var ( _ party.Plugin = (*Plugin)(nil) + _ pact.HasConfig = (*Plugin)(nil) _ pact.HasMigrations = (*Plugin)(nil) _ pact.HasModels = (*Plugin)(nil) _ pact.HasLang = (*Plugin)(nil) @@ -33,6 +34,9 @@ var ( _ surf.BucketProvider = (*Plugin)(nil) ) +//go:embed config +var configFS embed.FS + //go:embed lang var langFS embed.FS @@ -104,6 +108,9 @@ func (p *Plugin) wireSiteCache(app *backpack.App) error { return nil } +// ConfigFS ships config/config.yaml (golem15.journal.content_h1_to_h2). +func (p *Plugin) ConfigFS() fs.FS { return configFS } + func (p *Plugin) LangFS() fs.FS { return langFS } func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() } diff --git a/updates/202610280001_add_golem15_journal_settings_headings.go b/updates/202610280001_add_golem15_journal_settings_headings.go new file mode 100644 index 0000000..0920e55 --- /dev/null +++ b/updates/202610280001_add_golem15_journal_settings_headings.go @@ -0,0 +1,43 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// addSettingsHeadings adds the Headings and links settings to the +// golem15_journal_settings row: content_h1_to_h2 (on by default, so a site +// keeps converting H1 in Journal rich text to H2) and generic_anchors (the +// link texts the editor warns about, one per line; NULL means the built-in +// lists). Adding the columns rewrites no stored content. +var addSettingsHeadings = []*gormigrate.Migration{ + { + ID: "202610280001_add_golem15_journal_settings_headings", + Migrate: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `ALTER TABLE golem15_journal_settings ADD COLUMN content_h1_to_h2 BOOLEAN NOT NULL DEFAULT TRUE`, + `ALTER TABLE golem15_journal_settings ADD COLUMN generic_anchors TEXT NULL`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + for _, stmt := range []string{ + `ALTER TABLE golem15_journal_settings DROP COLUMN IF EXISTS generic_anchors`, + `ALTER TABLE golem15_journal_settings DROP COLUMN IF EXISTS content_h1_to_h2`, + } { + if err := tx.Exec(stmt).Error; err != nil { + return err + } + } + return nil + }, + }, +} + +func init() { + Register(addSettingsHeadings...) +} diff --git a/updates/category_content_migration_test.go b/updates/category_content_migration_test.go index 4cf1873..980e813 100644 --- a/updates/category_content_migration_test.go +++ b/updates/category_content_migration_test.go @@ -28,9 +28,20 @@ func TestCategoryContentMigration(t *testing.T) { t.Fatalf("migrate: %v", err) } - all := All() - if last := all[len(all)-1].ID; last != "202610090001_add_content_to_golem15_journal_categories" { - t.Fatalf("last migration = %s, want 202610090001_add_content_to_golem15_journal_categories", last) + // The content column's step and the one before it: later migrations + // are rolled back with it, so the test does not depend on being last. + const contentStep = "202610090001_add_content_to_golem15_journal_categories" + previous := "" + for i, mig := range All() { + if mig.ID == contentStep { + if i == 0 { + t.Fatalf("%s is the first migration", contentStep) + } + previous = All()[i-1].ID + } + } + if previous == "" { + t.Fatalf("migration %s is not registered", contentStep) } if !gdb.Migrator().HasColumn("golem15_journal_categories", "content") { t.Fatal("golem15_journal_categories.content missing") @@ -49,8 +60,8 @@ func TestCategoryContentMigration(t *testing.T) { if err := gdb.Exec(`UPDATE golem15_journal_categories SET content = 'x
' WHERE slug = 'uncategorized'`).Error; err != nil { t.Fatal(err) } - if err := m.RollbackLast(); err != nil { - t.Fatalf("rollback last: %v", err) + if err := m.RollbackTo(previous); err != nil { + t.Fatalf("rollback to %s: %v", previous, err) } if !gdb.Migrator().HasTable("golem15_journal_categories") { t.Fatal("golem15_journal_categories dropped by the content rollback") diff --git a/updates/settings_headings_migration_test.go b/updates/settings_headings_migration_test.go new file mode 100644 index 0000000..6246afb --- /dev/null +++ b/updates/settings_headings_migration_test.go @@ -0,0 +1,72 @@ +package updates + +import ( + "database/sql" + "fmt" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" + "github.com/go-gormigrate/gormigrate/v2" +) + +// TestSettingsHeadingsMigration checks the Headings and links columns: +// content_h1_to_h2 defaults to true on the existing settings row, +// generic_anchors is NULL, and the rollback drops both and keeps the row. +func TestSettingsHeadingsMigration(t *testing.T) { + sqlDB := dedicatedDB(t, fmt.Sprintf("journal_settings_headings_%d", time.Now().UnixNano())) + gdb, err := lagoon.Use(t.Context(), sqlDB) + if err != nil { + t.Fatal(err) + } + backend := gormigrate.New(gdb, &gormigrate.Options{ + TableName: "summer_migrations_summercms_cabana", + UseTransaction: true, + }, lagoon.BackendAdminMigrations) + if err := backend.Migrate(); err != nil { + t.Fatalf("backend_users migrations: %v", err) + } + m := journalMigrator(gdb) + if err := m.Migrate(); err != nil { + t.Fatalf("migrate: %v", err) + } + assertUdt(t, gdb, "golem15_journal_settings", "content_h1_to_h2", "bool") + assertUdt(t, gdb, "golem15_journal_settings", "generic_anchors", "text") + var on bool + var anchors sql.NullString + if err := gdb.Raw(`SELECT content_h1_to_h2, generic_anchors FROM golem15_journal_settings WHERE id = 1`).Row().Scan(&on, &anchors); err != nil { + t.Fatal(err) + } + if !on || anchors.Valid { + t.Fatalf("settings row: content_h1_to_h2 = %v, generic_anchors = %v, want true and NULL", on, anchors) + } + + const step = "202610280001_add_golem15_journal_settings_headings" + previous := "" + for i, mig := range All() { + if mig.ID == step && i > 0 { + previous = All()[i-1].ID + } + } + if previous == "" { + t.Fatalf("migration %s is not registered after another step", step) + } + if err := m.RollbackTo(previous); err != nil { + t.Fatalf("rollback to %s: %v", previous, err) + } + for _, column := range []string{"content_h1_to_h2", "generic_anchors"} { + if gdb.Migrator().HasColumn("golem15_journal_settings", column) { + t.Fatalf("%s survived rollback", column) + } + } + var rows int64 + if err := gdb.Raw(`SELECT COUNT(*) FROM golem15_journal_settings WHERE id = 1`).Scan(&rows).Error; err != nil { + t.Fatal(err) + } + if rows != 1 { + t.Fatalf("settings rows after rollback = %d, want 1", rows) + } + if err := m.Migrate(); err != nil { + t.Fatalf("re-migrate: %v", err) + } +}