aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md21
-rw-r--r--README.md3
-rw-r--r--internal/i18n/el.go6
-rw-r--r--internal/web/handlers_design.go35
-rw-r--r--internal/web/routes.go1
-rw-r--r--internal/web/server.go3
-rw-r--r--internal/web/static/dashboard.css3
-rw-r--r--internal/web/templates/blog/preview.html18
-rw-r--r--internal/web/templates/dashboard/design.html48
-rw-r--r--internal/web/templates/layouts/blog.html6
-rw-r--r--internal/web/theme.go17
-rw-r--r--internal/web/web_test.go37
12 files changed, 182 insertions, 16 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 00a29f8..baf174a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -194,7 +194,26 @@ internal/web/ server.go (host router, middleware, render helpers)
return every row along with the error; a bad link stays as a row to fix).
`POST /b/{sub}/design/reset` stores `DefaultTheme()` and calls
`ResetModules`. `GET /b/{sub}/layout` (the old tab) redirects to
- `/design#columns`.
+ `/design#columns`. **Header preview** (`POST /b/{sub}/design/preview`,
+ `handleDesignPreview`, `blog/preview.html`): the Menu card's frame. The
+ handler reads the form exactly as a save would (theme, modules, menu —
+ errors ignored, what was sent is shown), drops the banner, and executes the
+ `preview` template alone (a whole small page: blog.css, the theme CSS, the
+ `siteheader` template) — never the layout. Because the dashboard is on the
+ root host, where `/media` is the root blog's files, the page's images and
+ fonts are linked through `view.Media` (`/media` from `renderStatus`,
+ `/b/<sub>/media` here; the theme CSS through `Theme.CSSAt`). The script
+ POSTs the form (file inputs dropped) 300 ms after any change — `touch()`
+ schedules it, so module order counts too — into
+ `<iframe sandbox="allow-same-origin" srcdoc>`: same origin, unlike the
+ editor's HTML preview, because the session cookie is `SameSite=Lax` and an
+ opaque origin would not send it for those images; safe only while no header
+ module kind is owner HTML (all four are template-escaped) and there is no
+ `allow-scripts`. The frame runs no scripts, so the dashboard's script folds
+ the menu inside it the way the blog's own script does (`fitFrame`), sizes
+ the frame to the header, and re-runs on the frame's `change` (the fold
+ button) and on the Wide/Phone toggle (the frame at 380px, so blog.css's
+ phone rules apply).
- **Theme** (`web/theme.go`): struct stored as jsonb on `settings.theme`.
`ParseTheme` merges over `DefaultTheme()` and `normalize()` clamps every
value to an allowlist (hex colours, enum strings, uuid image ids, column
diff --git a/README.md b/README.md
index 7358e8d..120a9a6 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,8 @@ Server-rendered HTML, no JavaScript required, works on old browsers and phones.
links can be bold, uppercase, underlined always, on hover or never, small or large,
tight or wide apart, in any combination; the current page's link is underlined, bold,
boxed or left alone, and the header menu sits left, centred, right or spread across the bar —
- or folds into a ☰ button (beside the title or logo above it) when its links do not fit. Posts per
+ or folds into a ☰ button (beside the title or logo above it) when its links do not fit.
+ A live preview of the header shows the menu as set, on a wide screen or a phone. Posts per
page, the date format, the site title size and the footer alignment live here too,
as does whether listings show whole posts or **summaries** with a "Read more" link
(a post's summary ends at a `<!--more-->` line, or after its first paragraphs).
diff --git a/internal/i18n/el.go b/internal/i18n/el.go
index 66446f1..00af43d 100644
--- a/internal/i18n/el.go
+++ b/internal/i18n/el.go
@@ -399,7 +399,11 @@ var el = map[string]string{
"Not marked": "Χωρίς σήμανση",
"When the menu does not fit": "Όταν το μενού δεν χωράει",
"Fold the header menu into a button when its links do not fit on one line": "Δίπλωμα του μενού της κεφαλίδας σε κουμπί όταν οι σύνδεσμοί του δεν χωρούν σε μία γραμμή",
- "Button": "Κουμπί",
+ "Button": "Κουμπί",
+ "Wide screen": "Πλατιά οθόνη",
+ "Phone": "Κινητό",
+ "Preview unavailable.": "Η προεπισκόπηση δεν είναι διαθέσιμη.",
+ "Your header with the menu as set here; the phone view shows the folded menu. Nothing is saved until you press Save.": "Η κεφαλίδα σας με το μενού όπως ρυθμίζεται εδώ· η προβολή κινητού δείχνει το διπλωμένο μενού. Τίποτα δεν αποθηκεύεται μέχρι να πατήσετε Αποθήκευση.",
"The button sits at the right of the title or logo just above the menu, or in the menu bar itself when nothing is above it, and opens the links as a list. Readers whose browser runs no scripts get it on phones.": "Το κουμπί μπαίνει δεξιά από τον τίτλο ή το λογότυπο ακριβώς πάνω από το μενού, ή μέσα στη μπάρα του μενού όταν δεν υπάρχει τίποτα από πάνω, και ανοίγει τους συνδέσμους σε λίστα. Όσοι αναγνώστες έχουν πρόγραμμα περιήγησης χωρίς scripts το βλέπουν στα κινητά.",
"Uppercase, spaced": "Κεφαλαία, αραιά",
"Show the date on posts": "Εμφάνιση ημερομηνίας στις δημοσιεύσεις",
diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go
index 792dcdc..a10f285 100644
--- a/internal/web/handlers_design.go
+++ b/internal/web/handlers_design.go
@@ -1,6 +1,7 @@
package web
import (
+ "log"
"net/http"
"time"
@@ -203,6 +204,40 @@ func (s *Server) handleDesign(w http.ResponseWriter, r *http.Request) {
redirectOK(w, r, back, s.tr(r, "Design saved. Refresh your blog to see it."))
}
+// handleDesignPreview renders the header as the design form would save it,
+// for the Menu card's preview frame: the form is read like a save (theme,
+// modules, menu), nothing is stored, and a bad row is shown rather than
+// reported. Login, ownership, CSRF and the body cap are withBlog's. The page
+// is on the root host, so the blog's files are linked through /b/<sub>/media.
+func (s *Server) handleDesignPreview(w http.ResponseWriter, r *http.Request) {
+ blog := currentBlog(r)
+ th := ThemeFromForm(ParseTheme(blog.ThemeJSON), r.Form)
+ th.HeaderImage = "" // the banner would dwarf the menu, which is what the frame is for
+ mods, _ := parseModules(r.Form, s.lang(r), blog.Language)
+ pages, err := blogStore(r).ListPages(r.Context())
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ menu, _ := parseMenu(r.Form, s.lang(r), pages)
+ media := "/b/" + blog.Subdomain + "/media"
+ data := map[string]any{"theme": th, "css": th.CSSAt(media), "layout": buildLayout(th, mods), "menu": menu, "pages": pages}
+ for i := range pages { // the home page is "current", so its link shows the mark
+ if pages[i].IsHome {
+ data["page"] = &pages[i]
+ }
+ }
+ tpl, err := s.tpl.get(blog.Language, "blog/preview.html")
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := tpl.ExecuteTemplate(w, "preview", view{Blog: blog, Lang: blog.Language, Media: media, Data: data}); err != nil {
+ log.Printf("render preview: %v", err)
+ }
+}
+
// handleDesignReset restores the defaults, layout modules included; images
// stay in the library and the menu is left alone.
func (s *Server) handleDesignReset(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/web/routes.go b/internal/web/routes.go
index d846160..52cdee1 100644
--- a/internal/web/routes.go
+++ b/internal/web/routes.go
@@ -51,6 +51,7 @@ func (s *Server) rootRoutes() http.Handler {
m.HandleFunc("GET /b/{sub}/design", s.withBlog(s.handleDesignForm))
m.HandleFunc("POST /b/{sub}/design", s.withBlog(s.handleDesign))
m.HandleFunc("POST /b/{sub}/design/reset", s.withBlog(s.handleDesignReset))
+ m.HandleFunc("POST /b/{sub}/design/preview", s.withBlog(s.handleDesignPreview))
m.HandleFunc("GET /b/{sub}/files", s.withBlog(s.handleFiles))
m.HandleFunc("POST /b/{sub}/files/upload", s.withBlogFiles(maxUploadFiles, s.handleFileUpload))
m.HandleFunc("POST /b/{sub}/files/{id}/rename", s.withBlog(s.handleFileRename))
diff --git a/internal/web/server.go b/internal/web/server.go
index f5d32d1..e3fef6e 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -373,6 +373,7 @@ type view struct {
RootURL string
Path string
Lang string // i18n code, also the <html lang>
+ Media string // where the blog's files are served: /media, or /b/<sub>/media for the design preview on the root host
Data map[string]any
}
@@ -384,7 +385,7 @@ func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int
if data == nil {
data = map[string]any{}
}
- v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Lang: s.lang(r), Data: data}
+ v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Lang: s.lang(r), Media: "/media", Data: data}
if v.User != nil {
v.CSRF = auth.CSRFToken(s.cfg.JWTSecret, v.User.ID, v.User.TokenVersion)
}
diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css
index 4aa0213..1c5142a 100644
--- a/internal/web/static/dashboard.css
+++ b/internal/web/static/dashboard.css
@@ -253,6 +253,9 @@ form:not(.js) .jsonly { display: none !important; }
.addrow select, .addrow input { width: auto; min-width: 14em; }
.addrow button { vertical-align: bottom; margin-bottom: 0.4em; }
textarea.mono { font-size: 0.9em; }
+.navpreview { margin: 0 0 1em; }
+.navpreview-frame { display: block; width: 100%; height: 12em; border: 1px solid var(--border-strong); background: #fff; }
+.navpreview-frame.phone { width: 380px; max-width: 100%; } /* a phone's viewport, so blog.css's phone rules apply inside */
.hoverpick { margin: 0.8em 0; }
.hoverpick > input { width: auto; margin: 0 0.4em 0 0; vertical-align: middle; }
.hoverpick > label.check { display: inline; margin: 0; }
diff --git a/internal/web/templates/blog/preview.html b/internal/web/templates/blog/preview.html
new file mode 100644
index 0000000..a87e410
--- /dev/null
+++ b/internal/web/templates/blog/preview.html
@@ -0,0 +1,18 @@
+{{/* The design page's preview of the header: a page of its own for the
+ sandboxed frame — the real blog.css and theme CSS, and the header as the
+ form would save it. Only "preview" is ever executed; the empty content
+ block keeps the layout's reference satisfied. */}}
+{{define "content"}}{{end}}
+{{define "preview"}}<!DOCTYPE html>
+<html lang="{{.Lang}}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{t "Preview"}}</title>
+<link rel="stylesheet" href="/static/blog.css">
+<style>{{css .Data.css}}</style>
+</head>
+<body class="{{.Data.theme.NavClass}}{{with .Data.theme.TitleRuleClass}} {{.}}{{end}}">
+{{template "siteheader" .}}
+</body>
+</html>{{end}}
diff --git a/internal/web/templates/dashboard/design.html b/internal/web/templates/dashboard/design.html
index 0b27e17..039eabe 100644
--- a/internal/web/templates/dashboard/design.html
+++ b/internal/web/templates/dashboard/design.html
@@ -149,6 +149,17 @@
</div>
<p class="muted small nojs">{{t "A page or link filled in here is added when you save."}}</p>
<h3>{{t "How the menu looks"}}</h3>
+ {{/* The header as the form would save it, rendered by the server into a
+ sandboxed frame. allow-same-origin (never allow-scripts) so the frame
+ can load the blog's logo and fonts through /b/<sub>/media, which need the
+ session cookie: an opaque origin would not send it. Safe because the
+ header is never owner HTML — every header module kind is escaped by the
+ templates — and nothing runs without scripts. */}}
+ <div class="navpreview jsonly">
+ <p class="muted small">{{t "Preview"}}: <button type="button" class="mini" data-pv="wide" aria-pressed="true">{{t "Wide screen"}}</button> <button type="button" class="mini" data-pv="phone" aria-pressed="false">{{t "Phone"}}</button> <span class="pvstatus"></span></p>
+ <iframe class="navpreview-frame" sandbox="allow-same-origin" scrolling="no" title="{{t "Preview"}}"></iframe>
+ <p class="muted small">{{t "Your header with the menu as set here; the phone view shows the folded menu. Nothing is saved until you press Save."}}</p>
+ </div>
<div class="row">
<label>{{t "Alignment"}} <span class="muted">({{t "menu in the header"}})</span><br><select name="nav_align">
<option value="left"{{if eq $t.NavAlign "left"}} selected{{end}}>{{t "Left"}}</option>
@@ -348,12 +359,47 @@
var next = +form.getAttribute("data-next") || 0, dirty = false, sending = false;
function q(sel, el) { return (el || document).querySelector(sel); }
function qa(sel, el) { return Array.prototype.slice.call((el || document).querySelectorAll(sel)); }
- function touch() { dirty = true; q(".unsaved").hidden = false; }
+ function touch() { dirty = true; q(".unsaved").hidden = false; schedule(); }
form.addEventListener("input", touch);
form.addEventListener("change", touch);
window.addEventListener("beforeunload", function (e) { if (dirty && !sending) { e.preventDefault(); e.returnValue = ""; } });
q("#discard").addEventListener("click", function (e) { if (dirty && !confirm({{t "Throw away your unsaved changes?"}})) e.preventDefault(); else dirty = false; });
+ // ---- the header preview: the server renders the form as it would save it
+ var frame = q(".navpreview-frame"), pvTimer, pvBusy = false, pvAgain = false;
+ function preview() {
+ if (!frame || !window.fetch || !window.FormData) return;
+ if (pvBusy) { pvAgain = true; return; } // one at a time; the last change wins
+ pvBusy = true;
+ var fd = new FormData(form);
+ qa("input[type=file]", form).forEach(function (i) { fd.delete(i.name); }); // never upload while typing
+ fetch("/b/" + form.getAttribute("data-sub") + "/design/preview", { method: "POST", body: fd, credentials: "same-origin" })
+ .then(function (r) { if (!r.ok) throw new Error(r.statusText); return r.text(); })
+ .then(function (html) { frame.srcdoc = html; q(".pvstatus").textContent = ""; })
+ .catch(function () { q(".pvstatus").textContent = {{t "Preview unavailable."}}; })
+ .then(function () { pvBusy = false; if (pvAgain) { pvAgain = false; preview(); } });
+ }
+ function schedule() { clearTimeout(pvTimer); pvTimer = setTimeout(preview, 300); }
+ function fitFrame() { // the frame runs no scripts, so fold the menu the way the blog's own script does, and size the frame to the header
+ try {
+ var d = frame.contentDocument, h = d.querySelector(".site-header.nav-fold"), links = h ? h.querySelectorAll(".site-nav .nav-inner a") : [];
+ if (links.length) {
+ h.classList.remove("nav-auto"); h.classList.remove("nav-folded");
+ for (var i = 1; i < links.length; i++) if (links[i].offsetTop !== links[0].offsetTop) { h.classList.add("nav-folded"); break; }
+ }
+ frame.style.height = d.body.scrollHeight + "px";
+ } catch (e) {}
+ }
+ if (frame) {
+ frame.addEventListener("load", function () { fitFrame(); try { frame.contentDocument.addEventListener("change", fitFrame); } catch (e) {} }); // change: the fold button opened or closed the panel
+ qa("[data-pv]").forEach(function (b) { b.addEventListener("click", function () {
+ frame.classList.toggle("phone", b.getAttribute("data-pv") === "phone");
+ qa("[data-pv]").forEach(function (o) { o.setAttribute("aria-pressed", String(o === b)); });
+ fitFrame();
+ }); });
+ preview();
+ }
+
// ---- module and menu rows: order, remove, add
function rows(list) { return qa(".modrow", list); }
function refresh(list) { // the empty note and the "add" control of a one-module area
diff --git a/internal/web/templates/layouts/blog.html b/internal/web/templates/layouts/blog.html
index d55512a..71a62b0 100644
--- a/internal/web/templates/layouts/blog.html
+++ b/internal/web/templates/layouts/blog.html
@@ -48,7 +48,7 @@
which drops nav-auto, the no-script fallback blog.css folds on phones. */}}
{{define "siteheader"}}{{$l := .Data.layout}}{{$t := .Data.theme}}<div class="site-header{{if $l.Fold}} nav-fold nav-auto{{end}}{{if eq $t.HeaderAlign "center"}} header-center{{end}}">
{{if $l.Fold}}<input type="checkbox" id="nav-toggle" class="nav-toggle" aria-label="{{t "Menu"}}">{{end}}
- {{if $t.HeaderImage}}<div class="header-image"><a href="/"><img src="/media/{{$t.HeaderImage}}" alt=""></a></div>{{end}}
+ {{if $t.HeaderImage}}<div class="header-image"><a href="/"><img src="{{$.Media}}/{{$t.HeaderImage}}" alt=""></a></div>{{end}}
{{range $i, $m := $l.Header}}{{template "module" (dict "m" $m "root" $ "burger" (and $l.Fold (eq $i $l.BurgerAt)))}}{{end}}
</div>{{if $l.Fold}}<script>(function () {
var h = document.querySelector && document.querySelector(".site-header.nav-fold"), cb = document.getElementById("nav-toggle");
@@ -86,8 +86,8 @@
<h1 class="site-title"><a href="/">{{$r.Blog.Title}}</a></h1>
{{if $r.Blog.Tagline}}<p class="tagline">{{$r.Blog.Tagline}}</p>{{end}}
</div>{{template "rule" (dict "before" false "rc" $rc "theme" $t)}}{{if $b}}{{template "burger" $t}}{{end}}</div>
-{{- else if eq $m.Kind "logo"}}{{if $t.Logo}}{{if eq $m.Area "header"}}<div class="wrap header-logo{{with $rc}} {{.}}{{end}}{{if $b}} has-burger{{end}}">{{template "rule" (dict "before" true "rc" $rc "theme" $t)}}<div class="brand"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{template "rule" (dict "before" false "rc" $rc "theme" $t)}}{{if $b}}{{template "burger" $t}}{{end}}</div>
-{{else}}<div class="mod mod-logo"><a href="/"><img class="logo" src="/media/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{end}}{{end}}
+{{- else if eq $m.Kind "logo"}}{{if $t.Logo}}{{if eq $m.Area "header"}}<div class="wrap header-logo{{with $rc}} {{.}}{{end}}{{if $b}} has-burger{{end}}">{{template "rule" (dict "before" true "rc" $rc "theme" $t)}}<div class="brand"><a href="/"><img class="logo" src="{{$r.Media}}/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{template "rule" (dict "before" false "rc" $rc "theme" $t)}}{{if $b}}{{template "burger" $t}}{{end}}</div>
+{{else}}<div class="mod mod-logo"><a href="/"><img class="logo" src="{{$r.Media}}/{{$t.Logo}}" alt="{{$r.Blog.Title}}"></a></div>{{end}}{{end}}
{{- else if eq $m.Kind "menu"}}{{if eq $m.Area "header"}}<div class="site-nav{{if $b}} has-burger{{end}}"><div class="wrap nav-inner">{{if $b}}{{template "burger" $t}}{{end}}{{if $r.Data.layout.HeaderSearch}}{{template "searchform" $r}}{{end}}{{template "menulinks" $r}}</div></div>
{{else}}<div class="mod site-nav mod-menu">{{template "modtitle" $m}}<div class="nav-inner">{{template "menulinks" $r}}</div></div>{{end}}
{{- else if eq $m.Kind "recent"}}<div class="mod mod-recent">{{template "modtitle" $m}}
diff --git a/internal/web/theme.go b/internal/web/theme.go
index a43603b..ee3ef11 100644
--- a/internal/web/theme.go
+++ b/internal/web/theme.go
@@ -576,13 +576,13 @@ var themeCSS = template.Must(template.New("theme").Funcs(template.FuncMap{"sub":
@font-face { font-family:"Fira Code"; src:url(/static/FiraCode-VF.woff2) format("woff2"); font-weight:300 700; font-display:swap; }
{{- end}}
{{- if .CustomFont}}
-@font-face { font-family:"BlogFont"; src:url(/media/{{.CustomFont}}); font-display:swap; }
+@font-face { font-family:"BlogFont"; src:url({{.Media}}/{{.CustomFont}}); font-display:swap; }
{{- end}}
{{- if .CustomHeadingFont}}
-@font-face { font-family:"BlogHeadingFont"; src:url(/media/{{.CustomHeadingFont}}); font-display:swap; }
+@font-face { font-family:"BlogHeadingFont"; src:url({{.Media}}/{{.CustomHeadingFont}}); font-display:swap; }
{{- end}}
body { margin:0; background-color:{{.BgColor}}; color:{{.TextColor}}; font-family:{{.FontFamily}}; font-size:{{.FontSizePx}}; line-height:1.5;
-{{- if .BgImage}} background-image:url(/media/{{.BgImage}});
+{{- if .BgImage}} background-image:url({{.Media}}/{{.BgImage}});
{{- if eq .BgMode "cover"}} background-size:cover; background-position:center top; background-repeat:no-repeat;
{{- else if eq .BgMode "fixed"}} background-size:cover; background-position:center; background-attachment:fixed; background-repeat:no-repeat;
{{- else}} background-repeat:repeat;{{end}}
@@ -636,8 +636,15 @@ a:hover {{"{"}}{{if .LinkHover}} color:{{.LinkHover}};{{end}}{{if eq .LinkUnderl
`))
// CSS renders the per-blog stylesheet; all values were normalized so it is safe to inline.
-func (t Theme) CSS() string {
+func (t Theme) CSS() string { return t.CSSAt("/media") }
+
+// CSSAt is CSS with the blog's files under another path: the design page's
+// preview is on the root host, where /media is the root blog's.
+func (t Theme) CSSAt(media string) string {
var b bytes.Buffer
- _ = themeCSS.Execute(&b, t)
+ _ = themeCSS.Execute(&b, struct {
+ Theme
+ Media string
+ }{t, media})
return b.String()
}
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 12e30f2..e9e38fb 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -216,7 +216,7 @@ func TestHeaderFold(t *testing.T) {
render := func(th Theme, mods []store.Module) string {
var b strings.Builder
l := buildLayout(th, mods)
- v := view{Blog: &store.Blog{Title: "Gizmo"}, Data: map[string]any{"theme": th, "layout": l, "menu": []store.MenuItem{{Label: "Home", URL: "/"}, {Label: "About", URL: "/about"}}}}
+ v := view{Blog: &store.Blog{Title: "Gizmo"}, Media: "/media", Data: map[string]any{"theme": th, "layout": l, "menu": []store.MenuItem{{Label: "Home", URL: "/"}, {Label: "About", URL: "/about"}}}}
if err := tpl.ExecuteTemplate(&b, "siteheader", v); err != nil {
t.Fatal(err)
}
@@ -244,6 +244,37 @@ func TestHeaderFold(t *testing.T) {
}
}
+// The design page's preview is the header alone as a page of its own, with
+// the blog's files linked through the dashboard's path for them.
+func TestDesignPreview(t *testing.T) {
+ tpl, err := newTemplates(false).get("en", "blog/preview.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ th := DefaultTheme()
+ th.Logo, th.BgImage = uuid.New().String(), uuid.New().String()
+ media := "/b/gizmo/media"
+ l := buildLayout(th, []store.Module{{Area: "header", Kind: "title"}, {Area: "header", Kind: "logo"}, {Area: "header", Kind: "menu"}, {Area: "left", Kind: "recent"}})
+ v := view{Blog: &store.Blog{Title: "Gizmo"}, Lang: "en", Media: media, Data: map[string]any{"theme": th, "css": th.CSSAt(media), "layout": l, "menu": []store.MenuItem{{Label: "About", URL: "/about"}}}}
+ var b strings.Builder
+ if err := tpl.ExecuteTemplate(&b, "preview", v); err != nil {
+ t.Fatal(err)
+ }
+ out := b.String()
+ for _, want := range []string{`<link rel="stylesheet" href="/static/blog.css">`, `<body class="nav-left nav-ul-hover nav-size-normal nav-gap-normal nav-active-underline nav-bold">`,
+ `<img class="logo" src="/b/gizmo/media/` + th.Logo, `background-image:url(/b/gizmo/media/` + th.BgImage, `<a href="/about">About</a>`} {
+ if !strings.Contains(out, want) {
+ t.Errorf("preview missing %q:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, "mod-recent") || strings.Contains(out, "url(/media/") {
+ t.Errorf("preview: %s", out)
+ }
+ if css := th.CSS(); !strings.Contains(css, "url(/media/"+th.BgImage) {
+ t.Errorf("blog css: %s", css)
+ }
+}
+
// Announcements on post pages are on by default (the look blogs had before
// the switch) and, like every checkbox, off when the box is unticked; stored
// themes from before the option get the default too.
@@ -318,7 +349,7 @@ func TestHeaderRule(t *testing.T) {
logo := uuid.New().String()
render := func(kind string, th Theme) string {
var b strings.Builder
- root := view{Blog: &store.Blog{Title: "Gizmo", Tagline: "a hacklab"}, Data: map[string]any{"theme": th, "layout": Layout{}}}
+ root := view{Blog: &store.Blog{Title: "Gizmo", Tagline: "a hacklab"}, Media: "/media", Data: map[string]any{"theme": th, "layout": Layout{}}}
if err := tpl.ExecuteTemplate(&b, "module", map[string]any{"m": store.Module{Area: "header", Kind: kind}, "root": root}); err != nil {
t.Fatal(err)
}
@@ -748,7 +779,7 @@ func TestAllTemplatesParse(t *testing.T) {
for _, name := range []string{"auth/login.html", "dashboard/overview.html", "dashboard/pages.html", "dashboard/page_form.html",
"dashboard/posts.html", "dashboard/post_form.html", "dashboard/design.html", "dashboard/files.html", "dashboard/settings.html",
"dashboard/password.html", "dashboard/confirm.html", "dashboard/sections.html", "dashboard/section_form.html", "admin/index.html", "admin/new_user.html", "admin/delete_user.html",
- "blog/page.html", "blog/post.html", "blog/tag.html", "blog/search.html", "blog/404.html"} {
+ "blog/page.html", "blog/post.html", "blog/tag.html", "blog/search.html", "blog/404.html", "blog/preview.html"} {
for _, l := range i18n.Languages() {
if _, err := tpl.get(l.Code, name); err != nil {
t.Errorf("%s (%s): %v", name, l.Code, err)