aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md24
-rw-r--r--README.md4
-rw-r--r--internal/i18n/el.go16
-rw-r--r--internal/web/handlers_posts.go7
-rw-r--r--internal/web/routes.go1
-rw-r--r--internal/web/static/dashboard.css342
-rw-r--r--internal/web/templates/dashboard/page_form.html2
-rw-r--r--internal/web/templates/dashboard/post_form.html2
-rw-r--r--internal/web/templates/dashboard/section_form.html2
-rw-r--r--internal/web/templates/partials/dashnav.html4
-rw-r--r--internal/web/templates/partials/editor.html177
-rw-r--r--internal/web/web_test.go19
12 files changed, 425 insertions, 175 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 4a04077..c716515 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -29,8 +29,9 @@ table and deployment notes.
blog is backed up and restored with plain `pg_dump`/`psql`; the control
database (`DATABASE_URL`) holds only `users` and the `blogs` registry.
- Posts are Markdown (goldmark → bluemonday). No WYSIWYG. The editor's
- "Insert file" is the one scripted convenience (`partials/editor.html`);
- it must keep its no-JS fallback (file appended on save).
+ toolbar, preview and "Insert file" are scripted conveniences over a plain
+ textarea (`partials/editor.html`); the no-JS fallback (bare textarea, file
+ appended on save) must keep working.
- Changes are live immediately — there is no draft/preview system. The UX
is "save, then refresh your blog tab"; keep the "View blog ↗" links.
- Theme customisation is a structured form only; **no custom CSS input**.
@@ -62,8 +63,9 @@ internal/web/ server.go (host router, middleware, render helpers)
files = the upload library), filetype.go (what an upload is, how it may be served)
theme.go (colours/fonts/layout switches), layout.go (module kinds, archive grouping)
templates/ (embedded; layouts/, partials/, auth/, dashboard/, admin/, blog/)
- static/ (dashboard.css "paper & ink" look — plain CSS, no variables/flex;
- blog.css — floats except the three-column `.cols`, which is flexbox)
+ static/ (dashboard.css — lean look; custom properties, flexbox and grid
+ are fine there. blog.css — floats except the three-column `.cols`,
+ which is flexbox; it must keep working on old browsers)
```
## Key mechanics
@@ -211,10 +213,16 @@ internal/web/ server.go (host router, middleware, render helpers)
10), set at `POST /admin/blogs/{id}/upload-limit` from `admin/index.html`;
`Blog.UploadLimit(cfg)` resolves it.
- **Editor** (`partials/editor.html`, args via `dict`: name, value, rows,
- tall, upload, csrf): textarea + "Insert file" + cheat-sheet; paste and
- drop take any file. Forms using it must be `multipart/form-data` and
- their save handler must call `s.readUpload(r, "inline_file", false)` +
- `appendFileMD` (the no-JS path).
+ tall, upload, preview, csrf): a textarea with a Markdown toolbar (bold,
+ italic, strike, heading cycle, quote, code, lists, rule, link box, Insert
+ file), Ctrl+B/I/K, list continuation on Enter, and a Write/Preview toggle
+ that POSTs the text to `/b/{sub}/preview` (`handlePreview`: `markdown.Render`
+ → `{html}`, nothing stored). Every edit goes through one `replace()` built on
+ `execCommand("insertText")` so browser undo works. The toolbar is `hidden`
+ until the script runs; without JS it is a bare textarea + "Insert file" +
+ cheat-sheet, and paste/drop take any file. Forms using it must be
+ `multipart/form-data` and their save handler must call
+ `s.readUpload(r, "inline_file", false)` + `appendFileMD` (the no-JS path).
- **Announcements** (`sections` table, `store/sections.go`,
`handlers_sections.go`, `/b/{sub}/announcements…`): per-blog notices with
`placement` (`<column>-<position>`: left|main|right × top|bottom, split by
diff --git a/README.md b/README.md
index f0b3fd8..ca63bf7 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,9 @@ A small multi-tenant blog host. One Go binary + Postgres. Bloggers log in at
`example.com` to manage their blog; each blog is served at `<name>.example.com`.
Server-rendered HTML, no JavaScript required, works on old browsers and phones.
-- Posts and page intros are written in **Markdown** (sanitized on save). Files of any
+- Posts and page intros are written in **Markdown** (sanitized on save), in an editor
+ with a formatting toolbar (bold, italic, headings, links, lists, quotes, code…),
+ keyboard shortcuts and a rendered preview. Files of any
kind — images, PDFs, archives, audio… — can be inserted straight from the editor (file
picker, paste or drag-and-drop; with JavaScript off the file is appended on save):
images are shown, everything else becomes a download link. The **Files** tab lists
diff --git a/internal/i18n/el.go b/internal/i18n/el.go
index 1fe5860..ba29c83 100644
--- a/internal/i18n/el.go
+++ b/internal/i18n/el.go
@@ -206,6 +206,22 @@ var el = map[string]string{
"upload failed": "η μεταφόρτωση απέτυχε",
"Inserted": "Προστέθηκε η",
"Could not upload:": "Αποτυχία μεταφόρτωσης:",
+ "Bold": "Έντονα",
+ "Italic": "Πλάγια",
+ "Strikethrough": "Διαγραμμένα",
+ "Quote": "Παράθεση",
+ "Code": "Κώδικας",
+ "Bulleted list": "Λίστα με κουκκίδες",
+ "Numbered list": "Αριθμημένη λίστα",
+ "Horizontal rule": "Οριζόντια γραμμή",
+ "Link": "Σύνδεσμος",
+ "Insert link": "Εισαγωγή συνδέσμου",
+ "text": "κείμενο",
+ "Write": "Γράψιμο",
+ "Preview": "Προεπισκόπηση",
+ "Rendering…": "Απόδοση…",
+ "Nothing to show yet.": "Δεν υπάρχει τίποτα ακόμη.",
+ "Could not render:": "Αποτυχία απόδοσης:",
"Formatting cheat-sheet": "Οδηγός μορφοποίησης",
"Smaller heading": "Μικρότερη επικεφαλίδα",
"bold": "έντονα",
diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go
index 2ec2b2e..d38c5f2 100644
--- a/internal/web/handlers_posts.go
+++ b/internal/web/handlers_posts.go
@@ -12,6 +12,13 @@ import (
"github.com/gramanas/blogspace/internal/store"
)
+// handlePreview renders Markdown for the editor's Preview toggle, with the same
+// pipeline a save uses; nothing is stored. Login, ownership, CSRF and the body
+// cap are all withBlog's.
+func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]string{"html": markdown.Render(r.FormValue("body"))})
+}
+
func (s *Server) handlePosts(w http.ResponseWriter, r *http.Request) {
pageID, _ := strconv.ParseInt(r.URL.Query().Get("page"), 10, 64)
pages, err := blogStore(r).ListPages(r.Context())
diff --git a/internal/web/routes.go b/internal/web/routes.go
index 273a832..26264ef 100644
--- a/internal/web/routes.go
+++ b/internal/web/routes.go
@@ -37,6 +37,7 @@ func (s *Server) rootRoutes() http.Handler {
m.HandleFunc("POST /b/{sub}/posts/{id}/edit", s.withBlog(s.handlePostSave))
m.HandleFunc("GET /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDeleteConfirm))
m.HandleFunc("POST /b/{sub}/posts/{id}/delete", s.withBlog(s.handlePostDelete))
+ m.HandleFunc("POST /b/{sub}/preview", s.withBlog(s.handlePreview))
m.HandleFunc("GET /b/{sub}/announcements", s.withBlog(s.handleSections))
m.HandleFunc("GET /b/{sub}/announcements/new", s.withBlog(s.handleSectionForm))
m.HandleFunc("POST /b/{sub}/announcements/new", s.withBlog(s.handleSectionSave))
diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css
index 8fe5f8a..b85d515 100644
--- a/internal/web/static/dashboard.css
+++ b/internal/web/static/dashboard.css
@@ -1,216 +1,256 @@
-/* Blogspace dashboard — "paper & ink": flat colours, 2px rules, hard shadows,
- square corners. Plain CSS (no variables, no flex for layout) so it renders on
- old browsers; works down to ~320px. */
+/* Blogspace dashboard — lean: neutral surfaces, 1px borders, one accent, system
+ sans. Flexbox/grid and custom properties are fine here (the public blog.css
+ stays float-based); works down to ~320px. */
+:root {
+ --bg: #f5f6f8; --surface: #fff; --surface-2: #f8f9fb;
+ --border: #e2e5ea; --border-strong: #c9ced6;
+ --text: #1f2328; --muted: #6b7280;
+ --accent: #2563eb; --accent-hover: #1d4ed8; --accent-soft: #eef3ff; --accent-ring: rgba(37, 99, 235, 0.25);
+ --danger: #dc2626; --danger-hover: #b91c1c; --danger-soft: #fef2f2;
+ --ok-bg: #ecfdf3; --ok-border: #a7e3bd; --ok-text: #14532d;
+ --err-bg: #fef2f2; --err-border: #f5b5b5; --err-text: #7f1d1d;
+ --radius: 8px; --radius-sm: 6px;
+ --shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.04);
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace;
+}
* { box-sizing: border-box; }
-html { background: #f4efe4; }
-body { margin: 0; font-family: -apple-system, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #1d1a17; background: #f4efe4; }
-a { color: #c8541a; }
-a:hover { color: #a3420f; }
-h1, h2, h3, .mono { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; letter-spacing: -0.01em; }
-h1 { font-size: 1.5em; margin: 0.2em 0 0.6em; line-height: 1.2; }
-h2 { font-size: 1.05em; margin: 0 0 0.8em; text-transform: uppercase; letter-spacing: 0.06em; }
-h3 { font-size: 0.95em; margin: 0 0 0.4em; text-transform: uppercase; letter-spacing: 0.06em; }
-code { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; background: #ebe5d6; padding: 0 0.3em; font-size: 0.92em; }
+[hidden] { display: none !important; }
+html { background: var(--bg); }
+body { margin: 0; font-family: var(--font); font-size: 15px; line-height: 1.55; color: var(--text); background: var(--bg); -webkit-text-size-adjust: 100%; }
+a { color: var(--accent); }
+a:hover { color: var(--accent-hover); }
+h1, h2, h3 { font-weight: 600; letter-spacing: -0.01em; line-height: 1.25; }
+h1 { font-size: 1.5em; margin: 0 0 0.6em; }
+h2 { font-size: 1.1em; margin: 0 0 0.8em; }
+h3 { font-size: 0.95em; margin: 0 0 0.4em; }
+code, .mono { font-family: var(--mono); }
+code { background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; padding: 0 0.3em; font-size: 0.9em; }
p { margin: 0.6em 0; }
-.muted { color: #6b655c; }
+.muted { color: var(--muted); }
.nowrap { white-space: nowrap; }
.narrow { max-width: 480px; }
.center { margin-left: auto; margin-right: auto; }
.small { font-size: 0.9em; }
/* ---- bars ---------------------------------------------------------------- */
-.topbar { background: #1d1a17; color: #f4efe4; }
-.topbar a { color: #f4efe4; text-decoration: none; }
-.topbar-inner { max-width: 1100px; margin: 0 auto; padding: 0.55em 1em; overflow: hidden; }
-.brand { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-weight: bold; text-transform: uppercase; letter-spacing: 0.12em; margin-right: 1.2em; }
-.who { color: #b8b0a3; font-size: 0.9em; }
-.topbar .links { float: right; font-size: 0.9em; }
-.topbar .links a, .topbar .links form { margin-left: 1.2em; }
-.topbar .links a:hover, .topbar .links .linkbtn:hover { color: #f2a67c; }
-.topbar .links a.active { border-bottom: 2px solid #f2a67c; }
-.blogbar { background: #fffdf8; border-bottom: 2px solid #1d1a17; }
-.blogbar .topbar-inner { padding: 0.5em 1em 0; }
-.blogbar .blogname { display: inline-block; margin: 0 1em 0.5em 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; }
-.blogbar .tabs { display: inline-block; vertical-align: bottom; }
-.blogbar .tabs a { display: inline-block; padding: 0.35em 0.8em; margin: 0 0.15em 0 0; text-decoration: none; color: #1d1a17; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9em; border: 2px solid transparent; border-bottom: 0; }
-.blogbar .tabs a:hover { border-color: #1d1a17; }
-.blogbar .tabs a.active { background: #1d1a17; color: #f4efe4; border-color: #1d1a17; }
-.blogbar .view { float: right; margin: 0 0 0.5em; padding: 0.25em 0.7em; border: 2px solid #1d1a17; color: #1d1a17; text-decoration: none; font-size: 0.85em; font-weight: bold; background: #fffdf8; }
-.blogbar .view:hover { background: #1d1a17; color: #f4efe4; }
+.topbar { background: var(--surface); border-bottom: 1px solid var(--border); }
+.topbar a { color: var(--text); text-decoration: none; }
+.topbar-inner { max-width: 1100px; margin: 0 auto; padding: 0.6em 1em; display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em 1em; }
+.brand { font-weight: 700; letter-spacing: -0.01em; }
+.who { color: var(--muted); font-size: 0.9em; }
+.topbar .links { margin-left: auto; font-size: 0.9em; display: flex; gap: 1.2em; align-items: center; }
+.topbar .links a, .topbar .links .linkbtn { color: var(--muted); }
+.topbar .links a:hover, .topbar .links .linkbtn:hover { color: var(--text); }
+.topbar .links a.active { color: var(--text); font-weight: 600; }
+.blogbar { background: var(--surface); border-bottom: 1px solid var(--border); }
+.blogbar .topbar-inner { padding: 0.5em 1em 0; gap: 0 1.2em; }
+.blogbar .blogname { font-weight: 600; padding-bottom: 0.55em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 20em; }
+.blogbar .tabs { display: flex; gap: 0.2em; }
+.blogbar .tabs a { display: inline-block; padding: 0.35em 0.7em 0.55em; text-decoration: none; color: var(--muted); font-size: 0.92em; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
+.blogbar .tabs a:hover { color: var(--text); }
+.blogbar .tabs a.active { color: var(--accent); border-color: var(--accent); }
+.blogbar .view { margin: 0 0 0.5em auto; padding: 0.3em 0.75em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); color: var(--text); text-decoration: none; font-size: 0.85em; font-weight: 500; background: var(--surface); white-space: nowrap; }
+.blogbar .view:hover { background: var(--surface-2); border-color: var(--muted); }
/* ---- page ---------------------------------------------------------------- */
-.main { max-width: 1100px; margin: 0 auto; padding: 1.4em 1em 3em; }
-.pagehead { overflow: hidden; margin-bottom: 0.8em; }
-.pagehead h1 { float: left; margin: 0.1em 1em 0.3em 0; }
-.pagehead .actions { float: right; margin-top: 0.1em; }
-.pagehead .lead { clear: both; margin: 0; }
-.card { background: #fffdf8; border: 2px solid #1d1a17; box-shadow: 4px 4px 0 #1d1a17; padding: 1.1em 1.3em; margin: 0 4px 1.6em 0; }
+.main { max-width: 1100px; margin: 0 auto; padding: 1.5em 1em 3em; }
+.pagehead { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4em 1em; margin-bottom: 1em; }
+.pagehead h1 { margin: 0; flex: 1 1 auto; }
+.pagehead .actions { margin-left: auto; }
+.pagehead .lead { flex-basis: 100%; margin: 0; }
+.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.25em 1.4em; margin: 0 0 1.4em; }
.card > :last-child { margin-bottom: 0; }
-.cols { overflow: hidden; }
-.cols .card { float: left; width: 48.5%; margin-right: 3%; }
-.cols .card + .card { margin-right: 4px; }
-.flash { padding: 0.6em 1em; border: 2px solid #1d1a17; margin: 0 4px 1.2em 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9em; }
-.flash.ok { background: #e4f0dc; }
-.flash.err { background: #f6dfd9; }
-.tag { display: inline-block; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; border: 1px solid #1d1a17; color: #1d1a17; padding: 0 0.45em; margin-left: 0.5em; vertical-align: middle; line-height: 1.6; }
-.tag.off { color: #6b655c; border-color: #b8b0a3; }
+.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1.4em; margin-bottom: 1.4em; }
+.cols .card { margin: 0; }
+.flash { padding: 0.65em 1em; border: 1px solid; border-radius: var(--radius-sm); margin: 0 0 1.2em; font-size: 0.95em; }
+.flash.ok { background: var(--ok-bg); border-color: var(--ok-border); color: var(--ok-text); }
+.flash.err { background: var(--err-bg); border-color: var(--err-border); color: var(--err-text); }
+.tag { display: inline-block; font-size: 0.7em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; border-radius: 999px; background: var(--accent-soft); color: var(--accent-hover); padding: 0.1em 0.6em; margin-left: 0.5em; vertical-align: middle; line-height: 1.6; }
+.tag.off { background: var(--surface-2); color: var(--muted); border: 1px solid var(--border); }
/* ---- forms --------------------------------------------------------------- */
-label { display: block; margin: 0.8em 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; }
-label .muted { text-transform: none; letter-spacing: 0; }
-label code { text-transform: none; letter-spacing: 0; }
-input[type=text], input[type=password], input[type=number], input[type=datetime-local], input:not([type]), input[type=file], select, textarea {
- width: 100%; max-width: 100%; padding: 0.5em 0.6em; margin-top: 0.25em; border: 2px solid #1d1a17; font: inherit; font-family: -apple-system, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; text-transform: none; letter-spacing: 0; background: #fffdf8; color: #1d1a17; }
-input:focus, select:focus, textarea:focus, button:focus, .btn:focus { outline: 2px solid #c8541a; outline-offset: 1px; }
-input[type=color] { width: 4em; height: 2.3em; padding: 0.1em; margin-top: 0.25em; border: 2px solid #1d1a17; background: #fffdf8; }
+label { display: block; margin: 0.9em 0; font-size: 0.875em; font-weight: 500; }
+input[type=text], input[type=password], input[type=number], input[type=datetime-local], input[type=url], input[type=search], input:not([type]), input[type=file], select, textarea {
+ width: 100%; max-width: 100%; padding: 0.5em 0.65em; margin-top: 0.3em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); font: inherit; font-size: 15px; font-weight: normal; background: var(--surface); color: var(--text); }
+input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
+button:focus-visible, .btn:focus-visible, .tabs a:focus-visible, .view:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+input[type=color] { width: 4em; height: 2.4em; padding: 0.15em; margin-top: 0.3em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); }
input[type=file] { padding: 0.35em; }
-select { -webkit-appearance: none; appearance: none; padding-right: 1.8em; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%231d1a17' stroke-width='2'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.6em center; }
-textarea { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.95em; line-height: 1.5; }
-textarea.editor { min-height: 28em; }
-label.check { text-transform: none; letter-spacing: 0; font-family: -apple-system, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 1em; }
-label.check input { width: auto; margin: 0 0.4em 0 0; vertical-align: middle; }
-.row { overflow: hidden; }
-.row label { float: left; margin-right: 1.5em; min-width: 12em; }
-.row label:last-child { margin-right: 0; }
-button, .btn { display: inline-block; font: inherit; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9em; font-weight: bold; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.5em 1.1em; border: 2px solid #1d1a17; background: #c8541a; color: #fffdf8; cursor: pointer; text-decoration: none; box-shadow: 3px 3px 0 #1d1a17; vertical-align: middle; }
-button:hover, .btn:hover { background: #a3420f; color: #fffdf8; }
-button:active, .btn:active { box-shadow: none; position: relative; top: 3px; left: 3px; }
-.btn.secondary, button.secondary { background: #fffdf8; color: #1d1a17; }
-.btn.secondary:hover, button.secondary:hover { background: #ebe5d6; color: #1d1a17; }
-.btn.small { font-size: 0.75em; padding: 0.4em 0.9em; }
-button.mini, .mini { font-size: 0.72em; padding: 0.15em 0.55em; background: #fffdf8; color: #1d1a17; border-width: 1px; box-shadow: none; font-weight: normal; }
-button.mini:hover, .mini:hover { background: #1d1a17; color: #fffdf8; }
-button.mini:active { top: 0; left: 0; }
-button.danger { background: #a12b1e; }
-button.danger:hover { background: #7d1f14; }
-a.danger { color: #a12b1e; }
-button.mini.danger { color: #a12b1e; border-color: #a12b1e; background: #fffdf8; }
-button.mini.danger:hover { background: #a12b1e; color: #fffdf8; }
-button.linkbtn { background: none; border: 0; padding: 0; color: inherit; font: inherit; cursor: pointer; text-decoration: none; box-shadow: none; text-transform: none; letter-spacing: 0; }
-button.linkbtn:active { top: 0; left: 0; }
+input[type=search] { -webkit-appearance: none; appearance: none; }
+select { -webkit-appearance: none; appearance: none; padding-right: 1.9em; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%236b7280' stroke-width='1.8'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 0.7em center; }
+textarea { font-family: var(--mono); font-size: 0.92em; line-height: 1.55; }
+textarea.tall { min-height: 28em; }
+label.check { font-weight: normal; font-size: 0.95em; }
+label.check input { width: auto; margin: 0 0.45em 0 0; vertical-align: middle; accent-color: var(--accent); }
+input[type=radio], input[type=checkbox] { accent-color: var(--accent); }
+.row { display: flex; flex-wrap: wrap; gap: 0 1.5em; }
+.row label { flex: 1 1 12em; min-width: 12em; }
+button, .btn { display: inline-block; font: inherit; font-size: 0.9em; font-weight: 600; padding: 0.5em 1.05em; border: 1px solid var(--accent); border-radius: var(--radius-sm); background: var(--accent); color: #fff; cursor: pointer; text-decoration: none; vertical-align: middle; line-height: 1.4; }
+button:hover, .btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
+.btn.secondary, button.secondary { background: var(--surface); color: var(--text); border-color: var(--border-strong); }
+.btn.secondary:hover, button.secondary:hover { background: var(--surface-2); color: var(--text); border-color: var(--muted); }
+.btn.small, button.small { font-size: 0.8em; padding: 0.4em 0.85em; }
+button.mini, .mini { font-size: 0.75em; font-weight: 500; padding: 0.15em 0.55em; background: var(--surface); color: var(--text); border-color: var(--border-strong); }
+button.mini:hover, .mini:hover { background: var(--surface-2); color: var(--text); border-color: var(--muted); }
+button.danger { background: var(--danger); border-color: var(--danger); }
+button.danger:hover { background: var(--danger-hover); border-color: var(--danger-hover); }
+a.danger { color: var(--danger); }
+button.mini.danger { color: var(--danger); border-color: var(--border-strong); background: var(--surface); }
+button.mini.danger:hover { background: var(--danger-soft); border-color: var(--danger); }
+button.linkbtn { background: none; border: 0; padding: 0; color: inherit; font: inherit; cursor: pointer; text-decoration: none; }
form.inline, .inline { display: inline; }
details.inline summary { display: inline; cursor: pointer; }
-summary.mini { display: inline-block; list-style: none; }
+summary.mini { display: inline-block; list-style: none; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); cursor: pointer; line-height: 1.4; }
+summary.mini::-webkit-details-marker { display: none; }
.filter { margin: 0 0 1em; }
.filter label { display: inline-block; margin: 0; }
.filter select { width: auto; margin: 0 0 0 0.4em; }
-.actionrow { margin: 1.2em 0 0; }
-.actionrow a { margin-left: 0.8em; }
+.actionrow { margin: 1.2em 0 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0.6em 0.9em; }
+.actionrow a:not(.btn) { font-size: 0.95em; }
/* ---- tables & lists ------------------------------------------------------ */
table { border-collapse: collapse; width: 100%; }
-th, td { text-align: left; padding: 0.5em 0.6em; border-bottom: 1px solid #ddd6c7; vertical-align: middle; }
-th { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.08em; color: #1d1a17; border-bottom: 2px solid #1d1a17; }
-tr:nth-child(even) td { background: #f9f6ee; }
-tr:hover td { background: #f1ecdf; }
-tr.disabled td { color: #8f887c; }
+th, td { text-align: left; padding: 0.55em 0.6em; border-bottom: 1px solid var(--border); vertical-align: middle; }
+th { font-size: 0.75em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
+tr:last-child td { border-bottom: 0; }
+tr:hover td { background: var(--surface-2); }
+tr.disabled td { color: var(--muted); }
ul.plain { list-style: none; padding: 0; margin: 0; }
-ul.plain li { padding: 0.3em 0; border-bottom: 1px solid #ddd6c7; }
+ul.plain li { padding: 0.35em 0; border-bottom: 1px solid var(--border); }
ul.plain li:last-child { border-bottom: 0; }
-details.help { margin: 0.8em 0 0; border: 2px dashed #1d1a17; padding: 0.5em 1em; background: #fbf8f0; }
-details.help summary { cursor: pointer; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.06em; }
-details.help[open] summary { margin-bottom: 0.5em; }
+details.help { margin: 0.8em 0 0; border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); padding: 0.5em 1em; background: var(--surface-2); font-size: 0.95em; }
+details.help summary { cursor: pointer; color: var(--muted); font-weight: 500; font-size: 0.9em; }
+details.help[open] summary { margin-bottom: 0.5em; color: var(--text); }
.cheat td { border: 0; padding: 0.2em 1em 0.2em 0; background: none; }
+.cheat tr:hover td { background: none; }
-.editor-tools { margin: 0.5em 0 0; font-size: 0.9em; }
-.editor-tools .upload { display: inline-block; margin: 0 0.8em 0 0; padding: 0.25em 0.7em; border: 2px solid #1d1a17; background: #fffdf8; cursor: pointer; font-size: 0.8em; }
-.editor-tools .upload:hover { background: #ebe5d6; }
+/* ---- editor: toolbar, textarea and preview share one frame ------------------ */
+.editor { margin-top: 0.3em; }
+.editor textarea { margin-top: 0; display: block; }
+.editor.js textarea { border-top-left-radius: 0; border-top-right-radius: 0; }
+.ed-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25em; padding: 0.35em 0.4em; background: var(--surface-2); border: 1px solid var(--border-strong); border-bottom: 0; border-radius: var(--radius-sm) var(--radius-sm) 0 0; }
+.ed-group { display: inline-flex; gap: 0.1em; padding-right: 0.35em; margin-right: 0.1em; border-right: 1px solid var(--border-strong); }
+.ed-group:last-of-type { border-right: 0; }
+.ed-toolbar button[data-cmd] { width: 2em; height: 2em; padding: 0; border: 1px solid transparent; background: none; color: var(--text); border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; }
+.ed-toolbar button[data-cmd]:hover { background: var(--accent-soft); color: var(--accent-hover); border-color: transparent; }
+.ed-toolbar svg { width: 16px; height: 16px; fill: currentColor; }
+.ed-modes { margin-left: auto; display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
+.ed-modes button { font-size: 0.8em; font-weight: 500; padding: 0.25em 0.75em; border: 0; border-radius: 0; background: var(--surface); color: var(--muted); }
+.ed-modes button + button { border-left: 1px solid var(--border-strong); }
+.ed-modes button:hover { background: var(--surface-2); color: var(--text); }
+.ed-modes button[aria-pressed=true] { background: var(--accent); color: #fff; }
+.editor.previewing .ed-toolbar button[data-cmd] { opacity: 0.35; pointer-events: none; }
+.ed-linkbox { display: flex; flex-wrap: wrap; gap: 0.4em; align-items: center; padding: 0.5em; background: var(--accent-soft); border: 1px solid var(--border-strong); border-bottom: 0; }
+.ed-linkbox input { flex: 1 1 12em; margin: 0; padding: 0.35em 0.5em; font-size: 0.9em; }
+.ed-preview { min-height: 10em; padding: 0.8em 1em; border: 1px solid var(--border-strong); border-radius: 0 0 var(--radius-sm) var(--radius-sm); background: var(--surface); overflow-x: auto; }
+.ed-preview > :first-child { margin-top: 0; }
+.ed-preview > :last-child { margin-bottom: 0; }
+.ed-preview h1, .ed-preview h2, .ed-preview h3, .ed-preview h4 { margin: 1em 0 0.4em; }
+.ed-preview h1 { font-size: 1.5em; } .ed-preview h2 { font-size: 1.3em; } .ed-preview h3 { font-size: 1.1em; }
+.ed-preview img { max-width: 100%; height: auto; }
+.ed-preview blockquote { margin: 0.8em 0; padding: 0.2em 1em; border-left: 3px solid var(--border-strong); color: var(--muted); }
+.ed-preview pre { padding: 0.7em 0.9em; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow-x: auto; font-size: 0.9em; }
+.ed-preview pre code { border: 0; padding: 0; background: none; }
+.ed-preview table { width: auto; margin: 0.8em 0; }
+.ed-preview th, .ed-preview td { border: 1px solid var(--border); }
+.ed-preview hr { border: 0; border-top: 1px solid var(--border-strong); margin: 1.2em 0; }
+.editor-tools { margin: 0.5em 0 0; font-size: 0.9em; display: flex; flex-wrap: wrap; align-items: center; gap: 0.3em 0.8em; }
+.editor-tools .upload { display: inline-block; margin: 0; padding: 0.25em 0.7em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); cursor: pointer; font-size: 0.85em; }
+.editor-tools .upload:hover { background: var(--surface-2); }
.editor-tools .upload input { display: inline-block; width: auto; max-width: 14em; margin: 0 0 0 0.4em; padding: 0; border: 0; background: none; font-size: 0.9em; }
-.editor-tools .upload-status { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; margin-right: 0.6em; }
+.editor.js .editor-tools .upload { display: none; } /* the toolbar's Insert file button takes over */
+.editor-tools .upload-status { font-size: 0.9em; color: var(--muted); }
+.editor-tools .upload-status:empty { display: none; }
/* ---- files --------------------------------------------------------------- */
-.dropzone.over { border-style: dashed; background: #ebe5d6; }
-.progress { list-style: none; margin: 0.6em 0 0; padding: 0; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; }
+.dropzone.over { border: 1px dashed var(--accent); background: var(--accent-soft); }
+.progress { list-style: none; margin: 0.6em 0 0; padding: 0; font-size: 0.9em; color: var(--muted); }
.progress:empty { display: none; }
-.files-filter { overflow: hidden; }
-.files-filter input[type=search] { width: auto; margin: 0 0.3em 0.5em 0; padding: 0.3em 0.5em; vertical-align: middle; }
-.kinds { display: inline-block; margin: 0 1em 0.5em 0; vertical-align: middle; }
-.kinds a { display: inline-block; padding: 0.2em 0.7em; margin: 0 0.3em 0.3em 0; border: 2px solid #1d1a17; color: #1d1a17; text-decoration: none; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; }
-.kinds a:hover { background: #ebe5d6; }
-.kinds a.active { background: #1d1a17; color: #f4efe4; }
+.files-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em; }
+.files-filter input[type=search] { width: auto; margin: 0; padding: 0.35em 0.6em; }
+.kinds { display: inline-flex; flex-wrap: wrap; gap: 0.3em; margin-right: 0.5em; }
+.kinds a { display: inline-block; padding: 0.25em 0.8em; border: 1px solid var(--border-strong); border-radius: 999px; color: var(--text); text-decoration: none; font-size: 0.85em; font-weight: 500; }
+.kinds a:hover { background: var(--surface-2); }
+.kinds a.active { background: var(--accent); color: #fff; border-color: var(--accent); }
table.files td.icon { width: 56px; }
-table.files .thumb { display: block; width: 48px; height: 48px; object-fit: contain; border: 1px solid #ddd6c7; background: #f4efe4; }
-table.files .badge { display: block; width: 48px; padding: 0.9em 0; border: 1px solid #1d1a17; background: #fffdf8; text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.65em; letter-spacing: 0.05em; overflow: hidden; }
+table.files .thumb { display: block; width: 48px; height: 48px; object-fit: contain; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); }
+table.files .badge { display: block; width: 48px; padding: 0.9em 0; border: 1px solid var(--border-strong); border-radius: 4px; background: var(--surface); text-align: center; font-family: var(--mono); font-size: 0.65em; letter-spacing: 0.05em; overflow: hidden; }
table.files td.name { word-break: break-all; }
-table.files .copy { width: 100%; min-width: 11em; padding: 0.3em; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.75em; }
-.pager { margin: 0 4px 1.6em 0; text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.9em; }
+table.files .copy { width: 100%; min-width: 11em; padding: 0.3em 0.4em; margin: 0; font-family: var(--mono); font-size: 0.75em; }
+.pager { margin: 0 0 1.4em; text-align: center; font-size: 0.92em; }
.pager a, .pager span { margin: 0 0.8em; }
-.imagepick { margin-top: 0.5em; padding-top: 0.5em; border-top: 2px dashed #ddd6c7; }
+.imagepick { margin-top: 0.8em; padding-top: 0.6em; border-top: 1px solid var(--border); }
.imagepick > label { margin-bottom: 0.3em; }
-.imagepick .picks { overflow: hidden; margin: 0.4em 0 0; }
-.imagepick .pick { float: left; width: 120px; margin: 0 0.7em 0.7em 0; padding: 0.35em; border: 2px solid #ddd6c7; background: #fffdf8; text-align: center; font-size: 0.75em; text-transform: none; letter-spacing: 0; cursor: pointer; }
-.imagepick .pick:hover { border-color: #1d1a17; }
+.imagepick .picks { display: flex; flex-wrap: wrap; gap: 0.7em; margin: 0.4em 0 0; }
+.imagepick .pick { width: 120px; margin: 0; padding: 0.4em; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); text-align: center; font-size: 0.75em; font-weight: normal; cursor: pointer; }
+.imagepick .pick:hover { border-color: var(--border-strong); background: var(--surface-2); }
.imagepick .pick input { display: block; width: auto; margin: 0 auto 0.3em; }
-.imagepick .pick img, .imagepick .pick .swatch { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid #ddd6c7; background: #f4efe4; }
+.imagepick .pick img, .imagepick .pick .swatch { display: block; width: 100%; height: 64px; margin: 0 auto 0.3em; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); }
.imagepick .pick img { object-fit: contain; }
-.imagepick .pick .swatch { line-height: 64px; font-size: 1.6em; color: #b8b0a3; }
+.imagepick .pick .swatch { line-height: 64px; font-size: 1.6em; color: var(--muted); }
.imagepick .pick .name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.imagepick .pick input:checked ~ img, .imagepick .pick input:checked ~ .swatch { border: 3px solid #c8541a; }
+.imagepick .pick input:checked ~ img, .imagepick .pick input:checked ~ .swatch { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
/* ---- design page ---------------------------------------------------------- */
-.jumps { margin: 0 0 1.2em; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; }
-.jumps a { display: inline-block; margin: 0 0.8em 0.3em 0; color: #1d1a17; text-decoration: none; border-bottom: 2px solid #ddd6c7; }
-.jumps a:hover { border-color: #c8541a; }
-.presets { overflow: hidden; }
-.presets .preset { float: left; width: 8.5em; margin: 0 0.9em 0.9em 0; padding: 0.45em; background: #fffdf8; color: #1d1a17; font-size: 0.8em; text-align: center; }
-.presets .preset:hover { background: #ebe5d6; color: #1d1a17; }
-.presets .sw { display: inline-block; width: 22%; height: 2.2em; margin: 0 1%; border: 1px solid rgba(0,0,0,0.25); vertical-align: middle; }
+.jumps { display: flex; flex-wrap: wrap; gap: 0.4em; margin: 0 0 1.2em; font-size: 0.85em; }
+.jumps a { display: inline-block; padding: 0.2em 0.75em; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--text); text-decoration: none; }
+.jumps a:hover { border-color: var(--accent); color: var(--accent); }
+.presets { display: flex; flex-wrap: wrap; gap: 0.8em; }
+.presets .preset { width: 8.5em; margin: 0; padding: 0.55em 0.45em; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.8em; font-weight: 500; text-align: center; }
+.presets .preset:hover { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); }
+.presets .sw { display: inline-block; width: 22%; height: 2.2em; margin: 0 1%; border: 1px solid rgba(0,0,0,0.15); border-radius: 3px; vertical-align: middle; }
.presets .pname { display: block; margin-top: 0.4em; }
/* layout page: the column diagram, area cards with their module tables */
-.lay { margin: 0 0 1em; border: 2px solid #1d1a17; background: #f4efe4; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; text-align: center; }
-.lay-row { padding: 0.5em; background: #1d1a17; color: #f4efe4; }
-.lay-cols { overflow: hidden; padding: 0.5em 0; }
-.lay-col { float: left; padding: 1.6em 0.3em; border: 1px dashed #1d1a17; background: #fffdf8; }
-.lay-col.lay-main { background: #ebe5d6; }
-.lay .off { opacity: 0.4; text-decoration: line-through; }
+.lay { margin: 0 0 1em; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-2); font-size: 0.75em; font-weight: 500; text-align: center; overflow: hidden; }
+.lay-row { padding: 0.5em; background: var(--text); color: #fff; }
+.lay-cols { display: flex; padding: 0.5em; gap: 0.3em; }
+.lay-col { padding: 1.6em 0.3em; border: 1px dashed var(--border-strong); border-radius: 4px; background: var(--surface); }
+.lay-col.lay-main { background: var(--accent-soft); border-color: var(--accent); border-style: solid; color: var(--accent-hover); }
+.lay .off { opacity: 0.45; text-decoration: line-through; }
.lay-col.off { text-decoration: none; }
.lay-col.empty { background: transparent; }
.row input[type=number] { width: 6em; }
-.cardhead { overflow: hidden; }
-.cardhead h2 { float: left; margin-right: 1em; }
-.cardhead .switch { float: right; margin-top: -0.2em; }
+.cardhead { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 1em; }
+.cardhead h2 { margin: 0 0 0.6em; }
+.cardhead .switch { margin: 0 0 0.6em auto; }
.card.area-off { border-style: dashed; box-shadow: none; }
.card.area-off .modules, .card.area-off .addmod { opacity: 0.6; }
table.modules { margin: 0.5em 0 1em; }
table.modules td.order, table.modules td.actions { width: 1%; }
table.modules th { width: 10em; }
-.addmod { border-top: 2px dashed #ddd6c7; padding-top: 0.6em; }
+.addmod { border-top: 1px solid var(--border); padding-top: 0.6em; }
.addmod label, .add label { display: inline-block; margin: 0 0.8em 0.4em 0; min-width: 14em; }
.addmod select, .add select, .add input { width: auto; min-width: 14em; }
.addmod button, .add button { vertical-align: bottom; margin-bottom: 0.4em; }
-.addrow { margin-top: 1em; border-top: 2px dashed #ddd6c7; padding-top: 0.6em; }
-.addrow .add { float: left; width: 48%; margin-right: 4%; }
-.addrow .add + .add { margin-right: 0; }
+.addrow { margin-top: 1em; border-top: 1px solid var(--border); padding-top: 0.6em; margin-bottom: 0; }
textarea.mono { font-size: 0.9em; }
.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; }
.hoverpick .hover-color { margin: 0.4em 0 0 1.6em; }
.hoverpick > input:not(:checked) ~ .hover-color { display: none; }
-.savebar { position: sticky; bottom: 0; margin: 0 4px 0 0; padding: 0.8em 0; background: #f4efe4; border-top: 2px solid #1d1a17; }
-.savebar .btn { margin-left: 0.8em; }
+.savebar { position: sticky; bottom: 0; margin: 0; padding: 0.8em 0; background: var(--bg); border-top: 1px solid var(--border-strong); display: flex; gap: 0.8em; align-items: center; }
details.reset { margin-top: 1.6em; }
/* ---- login --------------------------------------------------------------- */
-.login-brand { text-align: center; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; text-transform: uppercase; letter-spacing: 0.2em; margin: 2em 0 1em; }
+.login-brand { text-align: center; font-weight: 700; font-size: 1.2em; letter-spacing: -0.01em; margin: 2.5em 0 1em; }
@media (max-width: 700px) {
- .cols .card, .row label { float: none; width: auto; margin-right: 4px; }
- .imagepick .pick { width: 46%; margin-right: 4%; }
- .presets .preset { width: 46%; margin-right: 4%; }
- .addrow .add { float: none; width: auto; margin-right: 0; }
- .cardhead h2, .cardhead .switch { float: none; margin-top: 0; }
- .cardhead .switch { display: block; margin-bottom: 0.6em; }
- .cols .card + .card { margin-right: 4px; }
- .topbar .links { float: none; display: block; margin-top: 0.3em; }
- .topbar .links a, .topbar .links form { margin-left: 0; margin-right: 1em; }
- .blogbar .topbar-inner { overflow: visible; }
- .blogbar .blogname { display: block; margin-right: 0; }
- .blogbar .view { float: none; display: inline-block; margin: 0 0 0.6em; }
- .blogbar .tabs { display: block; overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; margin: 0 -1em; padding: 0 1em; }
- .pagehead h1, .pagehead .actions { float: none; }
- .pagehead .actions { margin-bottom: 0.6em; }
- .card { padding: 0.9em 1em; overflow-x: auto; }
+ body { font-size: 15px; }
+ .cols { grid-template-columns: 1fr; gap: 1.2em; }
+ .row { gap: 0; }
+ .row label { flex-basis: 100%; }
+ .imagepick .pick { width: 46%; }
+ .presets .preset { width: 46%; }
+ .topbar .links { margin-left: 0; flex-basis: 100%; gap: 1em; }
+ .blogbar .topbar-inner { display: block; }
+ .blogbar .blogname { display: block; max-width: none; padding-bottom: 0.3em; }
+ .blogbar .view { display: inline-block; margin: 0 0 0.6em; }
+ .blogbar .tabs { overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; margin: 0 -1em; padding: 0 1em; }
+ .pagehead .actions { margin-left: 0; }
+ .card { padding: 1em 1em; overflow-x: auto; }
table { font-size: 0.9em; }
- th, td { padding: 0.35em 0.3em; }
+ th, td { padding: 0.4em 0.35em; }
+ .ed-modes { margin-left: 0; }
}
diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html
index 18d1119..775222d 100644
--- a/internal/web/templates/dashboard/page_form.html
+++ b/internal/web/templates/dashboard/page_form.html
@@ -8,7 +8,7 @@
<label>{{t "Address"}} <span class="muted">({{t "leave empty to make one from the title; e.g."}} <code>about</code> → {{.BlogURL}}/about)</span><br>
<input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label>
<label for="ed-intro">{{t "Intro text"}} <span class="muted">({{t "Markdown, shown above the posts; optional"}})</span></label>
- {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}}
+ {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
<label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> {{t "Show in menu"}}</label>
<p class="actionrow">
<button type="submit">{{t "Save"}}</button>
diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html
index d12d15f..9f188f7 100644
--- a/internal/web/templates/dashboard/post_form.html
+++ b/internal/web/templates/dashboard/post_form.html
@@ -15,7 +15,7 @@
<input type="datetime-local" name="posted_at" value="{{.Data.post.CreatedAt.Format "2006-01-02T15:04"}}" placeholder="YYYY-MM-DD HH:MM"></label>
</div>
<label for="ed-body">{{t "Content"}} <span class="muted">(Markdown)</span></label>
- {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}}
+ {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
<label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> {{t "Visible on the blog"}}</label>
<p class="actionrow">
<button type="submit">{{t "Save"}}</button>
diff --git a/internal/web/templates/dashboard/section_form.html b/internal/web/templates/dashboard/section_form.html
index b401d9c..dc971e5 100644
--- a/internal/web/templates/dashboard/section_form.html
+++ b/internal/web/templates/dashboard/section_form.html
@@ -24,7 +24,7 @@
</div>
<p class="muted small">{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} <a href="/b/{{.Blog.Subdomain}}/layout">{{t "Layout"}}</a>) {{t "the announcement moves to the main content instead."}}</p>
<label for="ed-body">{{t "Text"}} <span class="muted">(Markdown)</span></label>
- {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "csrf" .CSRF)}}
+ {{template "editor" (dict "name" "body" "value" $s.BodyMD "rows" 10 "tall" false "upload" (print "/b/" .Blog.Subdomain "/files/upload") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
<label class="check"><input type="checkbox" name="enabled"{{if $s.Enabled}} checked{{end}}> {{t "Shown on the blog"}}</label>
<p class="actionrow">
<button type="submit">{{t "Save"}}</button>
diff --git a/internal/web/templates/partials/dashnav.html b/internal/web/templates/partials/dashnav.html
index 8b44e55..40ca33f 100644
--- a/internal/web/templates/partials/dashnav.html
+++ b/internal/web/templates/partials/dashnav.html
@@ -16,7 +16,7 @@
<div class="blogbar">
<div class="topbar-inner">
<strong class="blogname">{{.Blog.Title}}</strong>
- <span class="tabs">
+ <nav class="tabs">
<a href="{{$b}}"{{if eq .Path $b}} class="active"{{end}}>{{t "Overview"}}</a>
<a href="{{$b}}posts"{{if hasPrefix .Path (print $b "posts")}} class="active"{{end}}>{{t "Posts"}}</a>
<a href="{{$b}}pages"{{if hasPrefix .Path (print $b "pages")}} class="active"{{end}}>{{t "Pages"}}</a>
@@ -25,7 +25,7 @@
<a href="{{$b}}design"{{if hasPrefix .Path (print $b "design")}} class="active"{{end}}>{{t "Design"}}</a>
<a href="{{$b}}files"{{if hasPrefix .Path (print $b "files")}} class="active"{{end}}>{{t "Files"}}</a>
<a href="{{$b}}settings"{{if hasPrefix .Path (print $b "settings")}} class="active"{{end}}>{{t "Settings"}}</a>
- </span>
+ </nav>
<a class="view" href="{{.BlogURL}}" target="_blank">{{t "View blog"}} &#8599;</a>
</div>
</div>
diff --git a/internal/web/templates/partials/editor.html b/internal/web/templates/partials/editor.html
index 4371624..33b2a1c 100644
--- a/internal/web/templates/partials/editor.html
+++ b/internal/web/templates/partials/editor.html
@@ -1,4 +1,36 @@
-{{define "editor"}}<textarea name="{{.name}}" id="ed-{{.name}}" rows="{{.rows}}"{{if .tall}} class="editor"{{end}}>{{.value}}</textarea>
+{{define "editor"}}<div class="editor" id="ed-{{.name}}-wrap">
+<div class="ed-toolbar" id="ed-{{.name}}-bar" hidden>
+ <span class="ed-group">
+ <button type="button" data-cmd="bold" title="{{t "Bold"}} (Ctrl+B)"><svg viewBox="0 0 16 16"><path d="M4 2h5a3.5 3.5 0 0 1 2.4 6 3.75 3.75 0 0 1-2.2 6.8H4zM6 4v3.5h3a1.75 1.75 0 0 0 0-3.5zm0 5.5V13h3.2a1.75 1.75 0 0 0 0-3.5z"/></svg></button>
+ <button type="button" data-cmd="italic" title="{{t "Italic"}} (Ctrl+I)"><svg viewBox="0 0 16 16"><path d="M6.5 2h6v2h-2l-2.5 8h2v2h-6v-2h2l2.5-8h-2z"/></svg></button>
+ <button type="button" data-cmd="strike" title="{{t "Strikethrough"}}"><svg viewBox="0 0 16 16"><path d="M2 7.5h12V9H2z"/><path d="M8 2c2.3 0 3.8 1 4.1 2.8l-1.9.3C10 4.2 9.2 3.7 8 3.7c-1.3 0-2 .5-2 1.3 0 .5.3.9 1.2 1.2H4.3C4 5.8 4 5.3 4 5c0-1.8 1.6-3 4-3zm3.9 8.4c0 1.9-1.6 3.1-4 3.1-2.5 0-4.1-1.2-4.4-3.2l1.9-.3c.2 1.1 1.1 1.7 2.5 1.7 1.3 0 2-.5 2-1.3 0-.2 0-.4-.1-.5h2c.1.2.1.4.1.5z"/></svg></button>
+ </span>
+ <span class="ed-group">
+ <button type="button" data-cmd="heading" title="{{t "Heading"}}"><svg viewBox="0 0 16 16"><path d="M3 2h2v5h6V2h2v12h-2V9H5v5H3z"/></svg></button>
+ <button type="button" data-cmd="quote" title="{{t "Quote"}}"><svg viewBox="0 0 16 16"><path d="M3 4h4v4H5v1.5c0 .6.4 1 1 1h1V12H6a3 3 0 0 1-3-3zm6 0h4v4h-2v1.5c0 .6.4 1 1 1h1V12h-1a3 3 0 0 1-3-3z"/></svg></button>
+ <button type="button" data-cmd="code" title="{{t "Code"}}"><svg viewBox="0 0 16 16"><path d="M5.5 3.5 1 8l4.5 4.5 1.4-1.4L3.8 8l3.1-3.1zm5 0L9.1 4.9 12.2 8l-3.1 3.1 1.4 1.4L15 8z"/></svg></button>
+ </span>
+ <span class="ed-group">
+ <button type="button" data-cmd="ul" title="{{t "Bulleted list"}}"><svg viewBox="0 0 16 16"><path d="M2 3h2v2H2zm4 0h8v2H6zM2 7h2v2H2zm4 0h8v2H6zm-4 4h2v2H2zm4 0h8v2H6z"/></svg></button>
+ <button type="button" data-cmd="ol" title="{{t "Numbered list"}}"><svg viewBox="0 0 16 16"><path d="M2.2 2h1.3v3.5H2.4v-2.3l-.7.4V2.5zM6 3h8v2H6zm-4.1 4.2c0-.8.6-1.3 1.5-1.3s1.4.5 1.4 1.2c0 .5-.3.9-.8 1.3l-.7.6h1.6v1H1.9v-.8L3.3 8c.3-.3.4-.5.4-.7 0-.3-.2-.4-.4-.4-.3 0-.5.2-.5.5zM6 7h8v2H6zm-4.2 4.1c0-.7.6-1.1 1.5-1.1.8 0 1.4.4 1.4 1 0 .4-.3.7-.7.8.5.1.8.5.8.9 0 .7-.6 1.2-1.5 1.2-.9 0-1.5-.4-1.6-1.1h1c0 .2.2.4.5.4s.5-.1.5-.4-.2-.4-.6-.4h-.3v-.8h.3c.3 0 .5-.1.5-.4 0-.2-.2-.3-.4-.3s-.4.1-.4.3zM6 11h8v2H6z"/></svg></button>
+ <button type="button" data-cmd="hr" title="{{t "Horizontal rule"}}"><svg viewBox="0 0 16 16"><path d="M2 7h12v2H2z"/></svg></button>
+ </span>
+ <span class="ed-group">
+ <button type="button" data-cmd="link" title="{{t "Link"}} (Ctrl+K)"><svg viewBox="0 0 16 16"><path d="M6.4 9.6a3.5 3.5 0 0 0 4.9 0l2-2a3.5 3.5 0 0 0-4.9-5l-1 1 1.1 1.1 1-1a2 2 0 0 1 2.8 2.9l-2 2a2 2 0 0 1-2.9 0zm3.2-3.2a3.5 3.5 0 0 0-4.9 0l-2 2a3.5 3.5 0 0 0 4.9 5l1-1-1.1-1.1-1 1a2 2 0 0 1-2.8-2.9l2-2a2 2 0 0 1 2.9 0z"/></svg></button>
+ <button type="button" data-cmd="file" title="{{t "Insert file"}}"><svg viewBox="0 0 16 16"><path d="M10.5 1.5a3.5 3.5 0 0 1 2.5 6l-5.6 5.6a2.25 2.25 0 0 1-3.2-3.2L9.6 4.6 10.7 5.7l-5.4 5.3a.75.75 0 0 0 1.1 1.1L12 6.4a2 2 0 0 0-2.9-2.8L3.6 9.1 2.5 8l5.5-5.5a3.5 3.5 0 0 1 2.5-1z"/></svg></button>
+ </span>
+ <span class="ed-modes">
+ <button type="button" data-mode="write" aria-pressed="true">{{t "Write"}}</button><button type="button" data-mode="preview" aria-pressed="false">{{t "Preview"}}</button>
+ </span>
+</div>
+<div class="ed-linkbox" id="ed-{{.name}}-link" hidden>
+ <input type="url" placeholder="https://…" aria-label="URL" data-link="url">
+ <input type="text" placeholder="{{t "link text"}}" aria-label="{{t "link text"}}" data-link="text">
+ <button type="button" class="small" data-link="ok">{{t "Insert link"}}</button>
+ <button type="button" class="small secondary" data-link="cancel">{{t "Cancel"}}</button>
+</div>
+<textarea name="{{.name}}" id="ed-{{.name}}" rows="{{.rows}}"{{if .tall}} class="tall"{{end}}>{{.value}}</textarea>
+<div class="ed-preview" id="ed-{{.name}}-preview" hidden></div>
<div class="editor-tools">
<label class="upload">{{t "Insert file"}} <input type="file" name="inline_file" id="ed-{{.name}}-file"></label>
<span class="upload-status" id="ed-{{.name}}-status"></span>
@@ -6,17 +38,142 @@
<noscript><span class="muted small">{{t "The file is added at the end of the text when you save."}}</span></noscript>
</div>
{{template "mdhelp"}}
+</div>
<script>
(function () {
- var ta = document.getElementById("ed-{{.name}}"), inp = document.getElementById("ed-{{.name}}-file"), st = document.getElementById("ed-{{.name}}-status");
- if (!window.fetch || !window.FormData || !ta || !inp) return; // old browser: the file is uploaded on save instead
- function insert(text) {
- var s = ta.selectionStart, e = ta.selectionEnd, pre = ta.value.slice(0, s), post = ta.value.slice(e);
- var chunk = (pre && !/\n$/.test(pre) ? "\n" : "") + text + "\n";
- ta.value = pre + chunk + post;
- ta.selectionStart = ta.selectionEnd = (pre + chunk).length;
- ta.focus();
+ var id = "ed-{{.name}}", ta = document.getElementById(id), inp = document.getElementById(id + "-file"), st = document.getElementById(id + "-status"),
+ wrap = document.getElementById(id + "-wrap"), bar = document.getElementById(id + "-bar"), lk = document.getElementById(id + "-link"), pv = document.getElementById(id + "-preview");
+ if (!window.fetch || !window.FormData || !ta || !inp) return; // old browser: plain textarea, the file is uploaded on save
+ bar.hidden = false;
+ wrap.className = "editor js";
+
+ // ---- text primitives: everything goes through replace() so Ctrl+Z works
+ function setSel(s, e) { ta.focus(); ta.setSelectionRange(s, e); }
+ function replace(s, e, text) {
+ setSel(s, e);
+ var ok = false;
+ try { ok = document.execCommand(text ? "insertText" : "delete", false, text); } catch (err) {}
+ if (!ok || ta.value.slice(s, s + text.length) !== text) ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
+ }
+ function insertLine(text) { // a block on its own line, after the caret
+ var s = ta.selectionStart, pre = ta.value.slice(0, s), chunk = (pre && !/\n$/.test(pre) ? "\n" : "") + text + "\n";
+ replace(s, ta.selectionEnd, chunk);
+ setSel(s + chunk.length, s + chunk.length);
+ }
+ function wrapSel(before, after, ph) { // wrap the selection, or unwrap it when it already is
+ var s = ta.selectionStart, e = ta.selectionEnd, v = ta.value, sel = v.slice(s, e), bl = before.length, al = after.length;
+ if (sel.length >= bl + al && sel.slice(0, bl) === before && sel.slice(-al) === after) {
+ replace(s, e, sel.slice(bl, sel.length - al)); setSel(s, e - bl - al); return;
+ }
+ if (v.slice(s - bl, s) === before && v.slice(e, e + al) === after) {
+ replace(s - bl, e + al, sel); setSel(s - bl, e - bl); return;
+ }
+ var inner = sel || ph;
+ replace(s, e, before + inner + after);
+ setSel(s + bl, s + bl + inner.length);
+ }
+ function block() { // the whole lines the selection touches
+ var v = ta.value, s = ta.selectionStart, e = ta.selectionEnd, ls = v.lastIndexOf("\n", s - 1) + 1, le = v.indexOf("\n", e > s ? e - 1 : e);
+ if (le < 0) le = v.length;
+ return { s: ls, e: le, text: v.slice(ls, le) };
+ }
+ function prefixLines(strip, own, mark) { // toggle a line marker: off when every line has this one, else replace whatever strip matches
+ var b = block(), ls = b.text.split("\n"), on = ls.every(function (l) { return own.test(l); });
+ var out = ls.map(function (l, i) { l = l.replace(strip, ""); return on ? l : (typeof mark === "function" ? mark(i) : mark) + l; }).join("\n");
+ replace(b.s, b.e, out); setSel(b.s, b.s + out.length);
+ }
+ var listRe = /^[-*+] (\[[ x]\] )?|^\d+\. /;
+ var cmds = {
+ bold: function () { wrapSel("**", "**", {{t "bold"}}); },
+ italic: function () { wrapSel("_", "_", {{t "italic"}}); },
+ strike: function () { wrapSel("~~", "~~", {{t "text"}}); },
+ code: function () {
+ if (ta.value.slice(ta.selectionStart, ta.selectionEnd).indexOf("\n") >= 0) { var b = block(); replace(b.s, b.e, "```\n" + b.text + "\n```"); setSel(b.s + 4, b.s + 4 + b.text.length); }
+ else wrapSel("`", "`", {{t "code"}});
+ },
+ heading: function () { // cycles ## → ### → plain
+ var b = block(), m = /^(#{1,6}) /.exec(b.text), next = !m ? "## " : m[1].length === 2 ? "### " : "";
+ var out = next + b.text.replace(/^#{1,6} /, "");
+ replace(b.s, b.e, out); setSel(b.s + next.length, b.s + out.length);
+ },
+ quote: function () { prefixLines(/^> /, /^> /, "> "); },
+ ul: function () { prefixLines(listRe, /^[-*+] /, "- "); },
+ ol: function () { prefixLines(listRe, /^\d+\. /, function (i) { return (i + 1) + ". "; }); },
+ hr: function () { insertLine("\n---\n"); },
+ link: openLink,
+ file: function () { inp.click(); }
+ };
+ bar.addEventListener("click", function (ev) {
+ var b = ev.target.closest("button");
+ if (!b) return;
+ if (b.getAttribute("data-mode")) setMode(b.getAttribute("data-mode") === "preview");
+ else if (cmds[b.getAttribute("data-cmd")]) cmds[b.getAttribute("data-cmd")]();
+ });
+
+ // ---- link box: replaces window.prompt, keeps the selection it was opened on
+ var linkSel = null, lkURL = lk.querySelector("[data-link=url]"), lkText = lk.querySelector("[data-link=text]");
+ function openLink() {
+ linkSel = [ta.selectionStart, ta.selectionEnd];
+ lkText.value = ta.value.slice(linkSel[0], linkSel[1]);
+ lkURL.value = "";
+ lk.hidden = false;
+ lkURL.focus();
+ }
+ function closeLink(insert) {
+ lk.hidden = true;
+ if (!insert || !linkSel) { setSel(linkSel ? linkSel[0] : 0, linkSel ? linkSel[1] : 0); return; }
+ var url = lkURL.value.trim(), text = lkText.value.trim() || url, md = "[" + text + "](" + url + ")";
+ replace(linkSel[0], linkSel[1], md);
+ setSel(linkSel[0] + md.length, linkSel[0] + md.length);
+ linkSel = null;
+ }
+ lk.addEventListener("click", function (ev) {
+ var b = ev.target.closest("button");
+ if (b) closeLink(b.getAttribute("data-link") === "ok" && lkURL.value.trim() !== "");
+ });
+ lk.addEventListener("keydown", function (ev) {
+ if (ev.key === "Enter") { ev.preventDefault(); if (lkURL.value.trim()) closeLink(true); }
+ else if (ev.key === "Escape") { ev.preventDefault(); closeLink(false); }
+ });
+
+ // ---- shortcuts and list continuation
+ ta.addEventListener("keydown", function (ev) {
+ if ((ev.ctrlKey || ev.metaKey) && !ev.altKey) {
+ var k = { b: "bold", i: "italic", k: "link" }[ev.key.toLowerCase()];
+ if (k) { ev.preventDefault(); cmds[k](); }
+ return;
+ }
+ if (ev.key !== "Enter" || ev.shiftKey || ta.selectionStart !== ta.selectionEnd) return;
+ var s = ta.selectionStart, v = ta.value, ls = v.lastIndexOf("\n", s - 1) + 1, m = /^(\s*)([-*+] (?:\[[ x]\] )?|(\d+)\. )(.*)$/.exec(v.slice(ls, s));
+ if (!m) return;
+ ev.preventDefault();
+ if (!m[4]) { replace(ls, s, ""); return; } // Enter on an empty item ends the list
+ var mark = m[3] ? (parseInt(m[3], 10) + 1) + ". " : m[2].replace("[x]", "[ ]");
+ replace(s, s, "\n" + m[1] + mark);
+ });
+
+ // ---- preview: the server renders exactly what a save would
+ var modes = bar.querySelectorAll("[data-mode]");
+ function setMode(preview) {
+ wrap.className = "editor js" + (preview ? " previewing" : "");
+ for (var i = 0; i < modes.length; i++) modes[i].setAttribute("aria-pressed", String((modes[i].getAttribute("data-mode") === "preview") === preview));
+ lk.hidden = true;
+ if (!preview) { pv.hidden = true; ta.hidden = false; ta.focus(); return; }
+ pv.style.minHeight = ta.offsetHeight + "px";
+ pv.innerHTML = "";
+ pv.hidden = false; ta.hidden = true;
+ st.textContent = {{t "Rendering…"}};
+ var fd = new FormData();
+ fd.append("_csrf", {{.csrf}});
+ fd.append("body", ta.value);
+ fetch({{.preview}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } })
+ .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || r.statusText); return j; }); })
+ .then(function (j) { pv.innerHTML = j.html || "<p class=\"muted\">" + {{t "Nothing to show yet."}} + "</p>"; st.textContent = ""; })
+ .catch(function (err) { st.textContent = {{t "Could not render:"}} + " " + err.message; setMode(false); });
}
+ pv.addEventListener("keydown", function (ev) { if (ev.key === "Escape") setMode(false); });
+
+ // ---- uploads: button, paste and drop all land at the caret
function upload(file) {
st.textContent = {{t "Uploading"}} + " " + file.name + "…";
var fd = new FormData();
@@ -24,7 +181,7 @@
fd.append("file", file);
return fetch({{.upload}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } })
.then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || {{t "upload failed"}}); return j; }); })
- .then(function (j) { insert(j.markdown); st.textContent = {{t "Inserted"}} + " " + j.filename + "."; inp.value = ""; })
+ .then(function (j) { insertLine(j.markdown); st.textContent = {{t "Inserted"}} + " " + j.filename + "."; inp.value = ""; })
.catch(function (err) { st.textContent = {{t "Could not upload:"}} + " " + err.message; });
}
function uploadAll(files) { // one after the other, so each lands where the caret is
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index e2bd335..9520953 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -1,6 +1,7 @@
package web
import (
+ "encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
@@ -444,6 +445,24 @@ func TestFileMarkdown(t *testing.T) {
}
}
+func TestPreviewRendersMarkdown(t *testing.T) {
+ s := NewServer(&config.Config{BaseDomain: "example.com", JWTSecret: []byte("x")}, nil)
+ req := httptest.NewRequest("POST", "/b/www/preview", strings.NewReader("body=**x**+<script>1</script>"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ s.handlePreview(rec, req)
+ if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
+ t.Errorf("content type %q", ct)
+ }
+ var out map[string]string
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ if h := out["html"]; !strings.Contains(h, "<strong>x</strong>") || strings.Contains(h, "<script>") {
+ t.Errorf("unexpected preview %q", h)
+ }
+}
+
func TestNormalizeSection(t *testing.T) {
sec := &store.Section{Placement: "footer", Style: "<script>"}
normalizeSection(sec)