feat: H1 to H2 setting with the admin save conversion (tracer)
- content_h1_to_h2 and generic_anchors on the settings row (migration
202610280001), Headings and links tab, config key
golem15.journal.content_h1_to_h2 used when no settings row exists
- classes.ConvertH1 on the x/net/html tokenizer; the Blog Editor types
convert while the setting is on and answer editor.h1_converted
- GET /posts/{slug} reports content_h1_to_h2 (additive key)
This commit is contained in:
@@ -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
|
||||
|
||||
62
classes/headings.go
Normal file
62
classes/headings.go
Normal file
@@ -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, "<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)
|
||||
}
|
||||
30
classes/headings_test.go
Normal file
30
classes/headings_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package classes
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestConvertH1 pins ConvertH1's byte rule: only h1 tag names change.
|
||||
func TestConvertH1(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
n int
|
||||
}{
|
||||
{"<h1>A</h1><p>x</p>", "<h2>A</h2><p>x</p>", 1},
|
||||
{`<h1 class="x" id='y'>T</h1>`, `<h2 class="x" id='y'>T</h2>`, 1},
|
||||
{"<H1>Up</H1>", "<h2>Up</h2>", 1},
|
||||
{"<h1>a</h1>\n<h1>b</h1>", "<h2>a</h2>\n<h2>b</h2>", 2},
|
||||
{"<p>no heading & text</p>", "<p>no heading & text</p>", 0},
|
||||
{"<!-- <h1>c</h1> --><p>x</p>", "<!-- <h1>c</h1> --><p>x</p>", 0},
|
||||
{"<script>var s = '<h1>';</script>", "<script>var s = '<h1>';</script>", 0},
|
||||
{"<h10>x</h10><h1>y</h1>", "<h10>x</h10><h2>y</h2>", 1},
|
||||
{"# Markdown <h1>raw</h1> a < b", "# Markdown <h2>raw</h2> a < b", 1},
|
||||
{"<p>unterminated <h1>x", "<p>unterminated <h2>x", 1},
|
||||
{"text <", "text <", 0},
|
||||
{"", "", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, n := ConvertH1(c.in)
|
||||
if got != c.want || n != c.n {
|
||||
t.Errorf("ConvertH1(%q) = %q, %d; want %q, %d", c.in, got, n, c.want, c.n)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
classes/journal_settings.go
Normal file
84
classes/journal_settings.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// H1ToH2Key is the config key of the H1 setting, used only when the Journal
|
||||
// settings row does not exist (EffectiveH1ToH2). Its shipped default is true.
|
||||
const H1ToH2Key = "golem15.journal.content_h1_to_h2"
|
||||
|
||||
// DefaultGenericAnchors are the link texts the editor warns about when the
|
||||
// settings row has no list of its own: the English and the Polish seed list,
|
||||
// each entry once.
|
||||
var DefaultGenericAnchors = []string{
|
||||
"click here", "here", "more", "read more", "learn more", "this link", "link",
|
||||
"kliknij tutaj", "tutaj", "tu", "więcej", "czytaj więcej", "czytaj dalej", "ten link",
|
||||
}
|
||||
|
||||
// headingSettings is the part of the settings row the heading rules read.
|
||||
type headingSettings struct {
|
||||
ContentH1ToH2 bool
|
||||
GenericAnchors *string
|
||||
}
|
||||
|
||||
// readHeadingSettings reads the settings row (ID 1); found is false when
|
||||
// there is no row or no database.
|
||||
func readHeadingSettings(ctx context.Context, db *gorm.DB) (headingSettings, bool) {
|
||||
if db == nil {
|
||||
return headingSettings{}, false
|
||||
}
|
||||
if ctx != nil {
|
||||
db = db.WithContext(ctx)
|
||||
}
|
||||
var rows []headingSettings
|
||||
if err := db.Model(&models.Settings{}).Select("content_h1_to_h2", "generic_anchors").
|
||||
Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||
return headingSettings{}, false
|
||||
}
|
||||
return rows[0], true
|
||||
}
|
||||
|
||||
// EffectiveH1ToH2 is the one reader of the H1 setting (P33-04): the settings
|
||||
// row's content_h1_to_h2 decides; without a row (or a database) the config
|
||||
// key golem15.journal.content_h1_to_h2 does, and true when that is unset
|
||||
// too. While it is true every H1 saved in Journal rich text becomes an H2.
|
||||
func EffectiveH1ToH2(ctx context.Context, db *gorm.DB, cfg *compass.Config) bool {
|
||||
if row, ok := readHeadingSettings(ctx, db); ok {
|
||||
return row.ContentH1ToH2
|
||||
}
|
||||
if cfg != nil && cfg.Has(H1ToH2Key) {
|
||||
return cfg.Bool(H1ToH2Key)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GenericAnchors is the list of generic link texts: the settings row's
|
||||
// generic_anchors, one entry per line, trimmed and lowercased with blank
|
||||
// lines dropped, or DefaultGenericAnchors when the row has no list (no
|
||||
// row, NULL or only blank lines).
|
||||
func GenericAnchors(ctx context.Context, db *gorm.DB) []string {
|
||||
row, ok := readHeadingSettings(ctx, db)
|
||||
if !ok || row.GenericAnchors == nil {
|
||||
return append([]string(nil), DefaultGenericAnchors...)
|
||||
}
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(*row.GenericAnchors, "\n") {
|
||||
entry := strings.ToLower(strings.TrimSpace(line))
|
||||
if entry == "" || seen[entry] {
|
||||
continue
|
||||
}
|
||||
seen[entry] = true
|
||||
out = append(out, entry)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return append([]string(nil), DefaultGenericAnchors...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
10
config/config.yaml
Normal file
10
config/config.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
# golem15.journal defaults, read as golem15.journal.<key>.
|
||||
#
|
||||
# content_h1_to_h2 is the H1 setting used when the Journal settings row
|
||||
# (Settings › Journal, Headings and links) does not exist; the row's own
|
||||
# value decides otherwise. On, every H1 saved in Journal rich text (post
|
||||
# content and category pillar text) becomes an H2 and the editor offers
|
||||
# H2 to H4. Off, H1 headings are stored as written and the editor offers
|
||||
# H1 to H4. Changing it never rewrites stored content; journal:fix-headings
|
||||
# --apply does.
|
||||
content_h1_to_h2: true
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/cooler"
|
||||
@@ -173,6 +175,10 @@ func Show(app *backpack.App) http.HandlerFunc {
|
||||
rows = append(rows, serializePost(&related[i], db))
|
||||
}
|
||||
payload["related_posts"] = rows
|
||||
// Additive (2026-10-28 Journal H1 setting): whether H1 in post
|
||||
// content is stored as H2, so a frontend knows whether the page
|
||||
// title is the only H1.
|
||||
payload["content_h1_to_h2"] = classes.EffectiveH1ToH2(r.Context(), db, appConfig(app))
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": payload})
|
||||
}
|
||||
}
|
||||
@@ -444,6 +450,14 @@ func UploadFeaturedImage(app *backpack.App) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// appConfig is app's config, nil without an application.
|
||||
func appConfig(app *backpack.App) *compass.Config {
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
return app.Config
|
||||
}
|
||||
|
||||
// purgeSiteCache queues the host site's cache purge for post, as the admin
|
||||
// save hooks do. The API writes run outside a transaction, so the purge is
|
||||
// queued at once and never delays the response. Without site_cache.purge_url
|
||||
|
||||
125
headings_test.go
Normal file
125
headings_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package journal_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-journal-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-journal-plugin/models"
|
||||
)
|
||||
|
||||
const journalAPIPrefix = "/_journal/api/v1"
|
||||
|
||||
// setH1ToH2 switches the Journal H1 setting on the settings row.
|
||||
func setH1ToH2(t *testing.T, env *adminEnv, on bool) {
|
||||
t.Helper()
|
||||
if err := env.gdb.Exec(`UPDATE golem15_journal_settings SET content_h1_to_h2 = ? WHERE id = 1`, on).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishPost publishes a post with a past published_at.
|
||||
func publishPost(t *testing.T, env *adminEnv, id uint) {
|
||||
t.Helper()
|
||||
if err := env.gdb.Exec(`UPDATE golem15_journal_posts SET published = TRUE, published_at = NOW() - INTERVAL '1 hour' WHERE id = ?`, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// showH1Flag is GET /_journal/api/v1/posts/{slug}'s content_h1_to_h2.
|
||||
func showH1Flag(t *testing.T, env *adminEnv, slug string) any {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, journalAPIPrefix+"/posts/"+slug, "", nil)
|
||||
var body struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("show body %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
flag, ok := body.Data["content_h1_to_h2"]
|
||||
if !ok {
|
||||
t.Fatalf("show answer has no content_h1_to_h2: %s", rec.Body.String())
|
||||
}
|
||||
return flag
|
||||
}
|
||||
|
||||
// TestHeadingConversionTracer is the 33-02 tracer: an editor saves a post
|
||||
// whose content holds an H1 through the admin and gets an H2 back with the
|
||||
// h1_converted notice, and the public post answer reports the setting.
|
||||
// With the setting off the same save keeps the H1, adds no notice, and the
|
||||
// answer reports false.
|
||||
func TestHeadingConversionTracer(t *testing.T) {
|
||||
env := newPostsEnv(t)
|
||||
token := env.admin(t,
|
||||
controllers.PermissionAccessPosts,
|
||||
controllers.PermissionAccessPublish,
|
||||
controllers.PermissionAccessOtherPosts,
|
||||
)
|
||||
const submitted = "<h1>A</h1><p>x</p>"
|
||||
|
||||
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", "<h2>A</h2><p>x</p>", "<h2>A</h2><p>x</p>")
|
||||
pl := translatedAttrs(t, env, id, "pl")
|
||||
if pl["content"] != "<h2>A</h2><p>x</p>" || pl["content_html"] != "<h2>A</h2><p>x</p>" {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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 = '<p>x</p>' 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")
|
||||
|
||||
72
updates/settings_headings_migration_test.go
Normal file
72
updates/settings_headings_migration_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user