From d11e4c9544f544b3a024c7ac705a674b4096cf50 Mon Sep 17 00:00:00 2001 From: grm Date: Sun, 13 Sep 2026 00:38:24 +0300 Subject: Add the Layout tab: five areas with modules, columns and a menu editor A blog page is now header, left column, main content, right column and footer, each holding an ordered list of modules (blog title, logo, menu, archive by year/month, recent posts, custom HTML, footer text, RSS link, site map). Side columns have percentage widths, can be hidden, and can keep their space when empty; on phones they stack under the posts. The menu becomes its own ordered list mixing pages and outside links. Announcements are placed per column at its top or bottom. Modules and menu items get tables; the migration derives them from each blog's old theme (nav position, footer text, show-in-nav pages) so existing blogs look the same. Custom HTML is deliberately stored and served as-is (owner's decision; see AGENTS.md for why the blast radius is the blogger's own origin). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/web/web_test.go | 192 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 159 insertions(+), 33 deletions(-) (limited to 'internal/web/web_test.go') diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 9afa11a..b8f761a 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -51,7 +51,7 @@ func TestHostRoutingWithoutDB(t *testing.T) { func TestRootRoutePrecedence(t *testing.T) { cfg := &config.Config{BaseDomain: "example.com", JWTSecret: []byte("x"), MaxUploadBytes: 1 << 20} s := NewServer(cfg, nil) - for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/account/password": 303} { + for path, want := range map[string]int{"/webadmin": 200, "/healthz": 200, "/dashboard": 303, "/admin/": 303, "/b/alice/posts": 303, "/b/alice/announcements": 303, "/b/alice/layout": 303, "/account/password": 303} { rec := httptest.NewRecorder() req := httptest.NewRequest("GET", path, nil) req.Host = "example.com" @@ -68,19 +68,25 @@ func TestRootRoutePrecedence(t *testing.T) { } func TestThemeNormalizeAndCSS(t *testing.T) { - th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","nav_position":"left-sidebar","link_hover":"blue","heading_font":"wingdings","favicon":"x"}`)) - if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.NavPosition != "left-sidebar" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" { + th := ParseTheme([]byte(`{"bg_color":"red;}body{display:none","font":"comic","bg_image":"../etc","logo":"../etc","link_hover":"blue","heading_font":"wingdings","favicon":"x","logo_size":"huge","left_width":5,"right_width":90}`)) + if th.BgColor != DefaultTheme().BgColor || th.Font != "sans" || th.BgImage != "" || th.Logo != "" || th.LinkHover != "" || th.HeadingFont != "same" || th.Favicon != "" || th.LogoSize != "medium" { t.Errorf("normalize: %+v", th) } - // Themes saved before the new options existed keep today's look. - if !th.ShowDates || !th.FooterShowRSS || th.LinkUnderline != "always" || th.ContentStyle != "card" || th.DateFormat != "long" { + if th.LeftWidth != 15 || th.RightWidth != 40 { + t.Errorf("width clamp: %+v", th) + } + // Themes saved before the new options existed keep today's look: every area on, columns a quarter each. + if !th.ShowDates || th.LinkUnderline != "always" || th.ContentStyle != "card" || th.DateFormat != "long" { t.Errorf("old theme defaults: %+v", th) } - f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "header_show_title": {"on"}, "bg_image": {"not-a-uuid"}, + if !th.HeaderOn || !th.LeftOn || !th.RightOn || !th.FooterOn || th.LeftWidth != 15 { + t.Errorf("old theme layout defaults: %+v", th) + } + f := url.Values{"bg_color": {"#ABCDEF"}, "content_width": {"wide"}, "bg_image": {"not-a-uuid"}, "logo_size": {"large"}, "link_hover": {"#ff0000"}, "nav_hover": {"#00ff00"}, "nav_hover_custom": {"on"}, "nav_style": {"uppercase"}, "nav_align": {"center"}, "link_underline": {"hover"}, "heading_font": {"serif"}, "content_style": {"flat"}, "content_padding": {"roomy"}, "date_format": {"iso"}} th = ThemeFromForm(DefaultTheme(), f) - if th.BgColor != "#abcdef" || th.ContentWidth != "wide" || !th.HeaderShowTitle || th.BgImage != "" { + if th.BgColor != "#abcdef" || th.ContentWidth != "wide" || th.BgImage != "" || th.LogoSize != "large" { t.Errorf("from form: %+v", th) } if th.LinkHover != "" || th.NavHover != "#00ff00" || th.ShowDates || th.DateFormat != "iso" { @@ -88,7 +94,7 @@ func TestThemeNormalizeAndCSS(t *testing.T) { } css := th.CSS() for _, want := range []string{"background-color:#abcdef", "max-width:1100px", "text-transform:uppercase", ".site-nav a:hover { color:#00ff00", "a:hover { text-decoration:underline", - "h1, h2, h3, h4, h5, h6 { font-family:Georgia", "text-align:center", "border-radius:0", "padding:2.2em 3em", "@media (max-width: 700px)"} { + "h1, h2, h3, h4, h5, h6 { font-family:Georgia", "text-align:center", "border-radius:0", "padding:2.2em 3em", "@media (max-width: 700px)", ".logo { max-height:140px"} { if !strings.Contains(css, want) { t.Errorf("css missing %q:\n%s", want, css) } @@ -102,20 +108,139 @@ func TestThemeNormalizeAndCSS(t *testing.T) { } } -func TestSidebarWidthCSS(t *testing.T) { - th := ThemeFromForm(DefaultTheme(), url.Values{"nav_position": {"left-sidebar"}, "nav_sidebar_width": {"wide"}}) +func TestLayoutTheme(t *testing.T) { + th := LayoutFromForm(DefaultTheme(), url.Values{"left_width": {"40"}, "right_width": {"40"}, "keep_columns": {"on"}}) + if th.LeftWidth != 40 || th.RightWidth != 30 || th.MainWidth() != 30 || !th.KeepColumns { + t.Errorf("the right column should give way: %+v", th) + } + th = LayoutFromForm(th, url.Values{"left_width": {"junk"}, "right_width": {"20"}}) + if th.LeftWidth != 15 || th.RightWidth != 20 || th.KeepColumns { + t.Errorf("junk clamps to the minimum: %+v", th) + } css := th.CSS() - if !strings.Contains(css, "width:240px") || !strings.Contains(css, "margin-left:260px") || !strings.Contains(css, "margin-left:0") { - t.Errorf("sidebar css: %s", css) + for _, want := range []string{"@media (min-width: 701px)", ".col-left { width:15%", ".col-right { width:20%", "body.has-left.has-right .col-main { width:65%", + "body.has-left:not(.has-right) .col-main { width:85%", "body.has-right:not(.has-left) .col-main { width:80%"} { + if !strings.Contains(css, want) { + t.Errorf("css missing %q:\n%s", want, css) + } + } + th.SetAreaOn("left", false) + th.SetAreaOn("bogus", false) + if th.AreaOn("left") || !th.AreaOn("right") || !th.AreaOn("above") { + t.Errorf("area switches: %+v", th) + } +} + +func TestBuildLayout(t *testing.T) { + mods := []store.Module{{ID: 1, Area: "header", Kind: "title"}, {ID: 2, Area: "left", Kind: "recent", Count: 3}, {ID: 3, Area: "right", Kind: "recent", Count: 8}, + {ID: 4, Area: "right", Kind: "archive"}, {ID: 5, Area: "footer", Kind: "rss"}, {ID: 6, Area: "above", Kind: "html"}} + th := DefaultTheme() + l := buildLayout(th, mods) + if !l.HasLeft || !l.HasRight || len(l.Header) != 1 || len(l.Footer) != 1 || len(l.Above) != 1 || l.MaxRecent() != 8 || !l.NeedsArchive() { + t.Errorf("layout: %+v", l) + } + th.RightOn, th.FooterOn = false, false + l = buildLayout(th, mods) + if l.HasRight || len(l.Footer) != 0 || l.MaxRecent() != 3 || l.NeedsArchive() { + t.Errorf("switched-off areas should drop their modules: %+v", l) + } + if buildLayout(th, nil).HasLeft { + t.Error("an empty column is not shown") + } + th.KeepColumns = true + if k := buildLayout(th, nil); !k.HasLeft || !k.HasRight || len(k.Right) != 0 { + t.Errorf("keep_columns reserves empty and hidden columns: %+v", k) + } + th.KeepColumns = false + // announcements go to their column, or to the main one when that column is not laid out + secs := []store.Section{{ID: 1, Placement: "main-top"}, {ID: 2, Placement: "right-bottom"}, {ID: 3, Placement: "left-top"}, {ID: 4, Placement: "left-bottom"}} + n := placeNotices(secs, l) // l: left laid out, right not + if len(n["main-top"]) != 1 || len(n["main-bottom"]) != 1 || n["main-bottom"][0].ID != 2 || len(n["left-top"]) != 1 || len(n["left-bottom"]) != 1 || len(n["right-bottom"]) != 0 { + t.Errorf("left column only: %v", n) + } + n = placeNotices(secs, Layout{}) + if len(n["main-top"]) != 2 || n["main-top"][1].ID != 3 || len(n["main-bottom"]) != 2 || len(n) != 2 { // no columns: everything in main, order kept + t.Errorf("no columns: %v", n) + } +} + +func TestModuleKinds(t *testing.T) { + for area, kinds := range moduleKinds { + if _, ok := areaNames[area]; !ok { + t.Errorf("area %q has no name", area) + } + for _, k := range kinds { + if moduleNames[k] == "" { + t.Errorf("kind %q has no name", k) + } + } + } + if !allowedKind("header", "menu") || allowedKind("header", "archive") || allowedKind("above", "menu") || allowedKind("attic", "html") { + t.Error("allowedKind") + } + if !moduleHasSettings("html") || moduleHasSettings("rss") { + t.Error("moduleHasSettings") + } + if got := moduleSummary(store.Module{Kind: "recent", Count: 5, Title: "Fresh"}); got != "5 posts, heading “Fresh”" { + t.Errorf("recent summary: %q", got) + } + if got := moduleSummary(store.Module{Kind: "html", Body: "

hi\n there

"}); got != "

hi there

" { + t.Errorf("html summary: %q", got) } - // The width only matters for the sidebar layout, and "normal" is blog.css's own value. - th.NavPosition = "top-bar" - if strings.Contains(th.CSS(), "width:240px") { - t.Error("sidebar width leaked into a horizontal menu") + if got := moduleSummary(store.Module{Kind: "html", Body: strings.Repeat("x", 100)}); len([]rune(got)) != 58 || !strings.HasSuffix(got, "…") { + t.Errorf("long html summary: %q", got) } - th = ThemeFromForm(DefaultTheme(), url.Values{"nav_position": {"left-sidebar"}}) - if strings.Contains(th.CSS(), ".nav-left-sidebar") { - t.Error("normal width should emit nothing") + if got := moduleSummary(store.Module{Kind: "menu"}); got != "" { + t.Errorf("menu summary: %q", got) + } +} + +func TestValidLinkURL(t *testing.T) { + for _, ok := range []string{"https://example.org", "http://example.org/a?b=c", "mailto:me@example.org", "/about", "/news/hello"} { + if !validLinkURL(ok) { + t.Errorf("%q should be accepted", ok) + } + } + for _, bad := range []string{"", "javascript:alert(1)", "//evil.org", "ftp://x", "https://", "example.org", "https://a b", "\">