aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-16 18:37:20 +0300
committergrm <grm@eyesin.space>2026-09-16 18:37:20 +0300
commit6b6b5d2e35ff182a0732da245f4eb37c8afa0564 (patch)
treef56960ae86c3c289f9a68e38ec01f1e3ff997466
parent5119018feeaa22c47c0e91e15d3b9414dd6e0772 (diff)
downloadblogspace-6b6b5d2e35ff182a0732da245f4eb37c8afa0564.tar.gz
blogspace-6b6b5d2e35ff182a0732da245f4eb37c8afa0564.tar.bz2
blogspace-6b6b5d2e35ff182a0732da245f4eb37c8afa0564.zip
Add an HTML mode to posts, page intros and announcements
Markdown is the default and unchanged; the editor's new Format switch stores the text as raw HTML instead, put on the blog exactly as written. Like the custom HTML module it is unsanitised on purpose: the escape hatch for embeds, scripts and inline styles that Markdown cannot express. The source column keeps holding the text in both modes and a `format` column says how to read it, so the public templates and the feed still print the stored `*_html`. The dashboard preview of HTML goes into a sandboxed iframe rather than the page, because a superadmin edits other people's blogs and their markup must never run on the dashboard origin. Search snippets of HTML posts are cut from a tag-stripped copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
-rw-r--r--AGENTS.md52
-rw-r--r--README.md6
-rw-r--r--cmd/blogspace/seed.go17
-rw-r--r--internal/db/migrations/blog/00006_format.sql11
-rw-r--r--internal/i18n/el.go50
-rw-r--r--internal/store/pages.go17
-rw-r--r--internal/store/posts.go13
-rw-r--r--internal/store/sections.go15
-rw-r--r--internal/store/store.go16
-rw-r--r--internal/web/handlers_blog.go2
-rw-r--r--internal/web/handlers_files.go45
-rw-r--r--internal/web/handlers_pages.go6
-rw-r--r--internal/web/handlers_posts.go5
-rw-r--r--internal/web/handlers_sections.go6
-rw-r--r--internal/web/search.go21
-rw-r--r--internal/web/search_test.go13
-rw-r--r--internal/web/static/dashboard.css8
-rw-r--r--internal/web/templates/dashboard/page_form.html4
-rw-r--r--internal/web/templates/dashboard/post_form.html4
-rw-r--r--internal/web/templates/dashboard/section_form.html4
-rw-r--r--internal/web/templates/partials/editor.html67
-rw-r--r--internal/web/web_test.go32
22 files changed, 301 insertions, 113 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 3959498..1ea5ef9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,10 +28,14 @@ table and deployment notes.
**Each blog is its own database** (`blog_<sub>`, see Key mechanics) so a
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
- 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.
+- Posts, page intros and announcements are Markdown (goldmark → bluemonday)
+ **or raw HTML**: each has a `format` column (`markdown` | `html`), the
+ editor's Format radios. HTML is stored and output **unsanitised** — the
+ owner's decision, same as the custom HTML module below, same blast radius.
+ No WYSIWYG. The editor's toolbar, preview and "Insert file" are scripted
+ conveniences over a plain textarea (`partials/editor.html`); the no-JS
+ fallback (bare textarea, format radios, 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**.
@@ -212,7 +216,7 @@ internal/web/ server.go (host router, middleware, render helpers)
`![name](…)` for images and `[name](…)` for the rest. Deleting a file
clears theme references to it. `POST /b/{sub}/files/upload` takes several
`file` parts (the no-JS `<input multiple>`), or answers JSON
- (`{id, filename, kind, size, markdown}` / `{error}`) for one file when the
+ (`{id, filename, kind, size, markdown, html}` / `{error}`) for one file when the
request has `Accept: application/json` — what the editor and the Files
page scripts call. `dashboard/files.html` lists by `?kind=&q=&p=`
(`ListFiles`, 50 per page, `pageBounds` clamps), shows `FileUsage` and the
@@ -220,17 +224,31 @@ internal/web/ server.go (host router, middleware, render helpers)
is `blogs.max_upload_bytes` in the control DB (NULL = `MAX_UPLOAD_MB`, now
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, 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).
+- **Editor** (`partials/editor.html`, args via `dict`: name, value, format,
+ rows, tall, upload, preview, csrf): a Format row (radios `format` =
+ `markdown`|`html`, always rendered) and 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 set `Format` with
+ `pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML)`, call
+ `s.readUpload(r, "inline_file", false)` + `appendFile(body, f, format)`
+ (the no-JS path: `fileMarkdown` or `fileHTML`, i.e. `<img>`/`<a>`), and
+ store `renderBody(format, src)` (`markdown.Render`, or the source untouched
+ for HTML) in the `*_html` column — the public templates only ever print
+ that column.
+ **HTML mode** (`.editor.html`, toggled by the radios): the Markdown toolbar
+ groups (`.ed-md`), link box, shortcuts, list continuation and cheat-sheet
+ are off, the textarea is monospace, Insert file inserts the upload JSON's
+ `html` field. Preview does not touch the server: the text goes into
+ `<iframe sandbox srcdoc>` (`.ed-preview-frame`) — an opaque origin with no
+ scripts — because the text may not be the viewer's own (a superadmin edits
+ other people's blogs) and must never run on the dashboard origin, where the
+ session lives. Markdown preview keeps using `.ed-preview` in the page.
- **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
@@ -342,7 +360,7 @@ superadmin password to `admin`. Production refuses both.
`internal/db/migrations/control/` for users and the registry; goose
`-- +goose Up/Down` sections; they run automatically at startup. Never
edit an applied migration. Blog chain so far: `00001_init`, `00002_language`,
- `00003_files`, `00004_tags`, `00005_search`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after
+ `00003_files`, `00004_tags`, `00005_search`, `00006_format`; control: `00001_init`, `00002_upload_limit`. Both chains were re-baselined at 00001 after
the move to per-blog databases; deployments from before it have
`goose_db_version` rows 2–7 in the control DB that must be deleted once
(README "Upgrading from a single database") or the next control migration
diff --git a/README.md b/README.md
index 901616c..b5ced42 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,11 @@ 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), in an editor
+- Posts, page intros and announcements 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
+ keyboard shortcuts and a rendered preview — or, with the editor's Format switch, in
+ **raw HTML** that goes on the blog exactly as written (embeds, scripts, inline styles;
+ the blogger's own responsibility, like the custom HTML module). 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/cmd/blogspace/seed.go b/cmd/blogspace/seed.go
index 14c378f..44171a3 100644
--- a/cmd/blogspace/seed.go
+++ b/cmd/blogspace/seed.go
@@ -124,14 +124,21 @@ func seed(ctx context.Context, cfg *config.Config, st *store.Store) error {
posts := []struct {
page, title, body string
tags []string
+ html bool // written in HTML mode: stored as is
}{
- {"home", "Welcome to my corner of the web", "This is the **first post**. It lives on the home page.\n\nThings I plan to write about:\n\n- tomatoes\n- Go\n- the occasional recipe\n\n![gradient](/media/" + bg.ID.String() + ")\n\nThat image above was uploaded through the dashboard, and here is a file to download: [notes.txt](/media/" + notes.ID.String() + ").", []string{"Go", "tomatoes", "meta"}},
- {"home", "Why I still like plain HTML", "No frameworks, no build step. Just `<p>` tags and a stylesheet.\n\n> Simplicity is prerequisite for reliability. — Dijkstra\n\n```go\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n```", []string{"Go", "web"}},
- {"news", "Site is up", "The blog is live. Expect sporadic updates.", []string{"meta"}},
- {"news", "Tomato season", "First ripe tomato of the year! Table for the record:\n\n| Variety | Days |\n|---|---|\n| Cherry | 62 |\n| Beefsteak | 85 |", []string{"tomatoes", "garden", "Συνταγές"}},
+ {"home", "Welcome to my corner of the web", "This is the **first post**. It lives on the home page.\n\nThings I plan to write about:\n\n- tomatoes\n- Go\n- the occasional recipe\n\n![gradient](/media/" + bg.ID.String() + ")\n\nThat image above was uploaded through the dashboard, and here is a file to download: [notes.txt](/media/" + notes.ID.String() + ").", []string{"Go", "tomatoes", "meta"}, false},
+ {"home", "Why I still like plain HTML", "No frameworks, no build step. Just `<p>` tags and a stylesheet.\n\n> Simplicity is prerequisite for reliability. — Dijkstra\n\n```go\nfunc main() {\n\tfmt.Println(\"hello\")\n}\n```", []string{"Go", "web"}, false},
+ {"news", "Site is up", "The blog is live. Expect sporadic updates.", []string{"meta"}, false},
+ {"news", "Tomato season", "First ripe tomato of the year! Table for the record:\n\n| Variety | Days |\n|---|---|\n| Cherry | 62 |\n| Beefsteak | 85 |", []string{"tomatoes", "garden", "Συνταγές"}, false},
+ {"home", "A post written in HTML", "<p>This one is <em>raw HTML</em>, not Markdown: the editor's Format switch.</p>\n<style>.seed-box { padding: .6em 1em; border: 2px dashed #c33; }</style>\n<div class=\"seed-box\">Any markup goes — this box has its own inline stylesheet.</div>\n<details><summary>And a folding section</summary><p>Which Markdown alone cannot make.</p></details>", []string{"web", "meta"}, true},
}
for _, p := range posts {
- post := &store.Post{PageID: pages[p.page].ID, Title: p.title, Slug: slug.Make(p.title), BodyMD: p.body, BodyHTML: markdown.Render(p.body), Published: true}
+ post := &store.Post{PageID: pages[p.page].ID, Title: p.title, Slug: slug.Make(p.title), BodyMD: p.body, Published: true}
+ if p.html {
+ post.Format, post.BodyHTML = store.FormatHTML, p.body
+ } else {
+ post.Format, post.BodyHTML = store.FormatMarkdown, markdown.Render(p.body)
+ }
created, err := bs.CreatePost(ctx, post)
if err != nil {
return err
diff --git a/internal/db/migrations/blog/00006_format.sql b/internal/db/migrations/blog/00006_format.sql
new file mode 100644
index 0000000..865e4d8
--- /dev/null
+++ b/internal/db/migrations/blog/00006_format.sql
@@ -0,0 +1,11 @@
+-- +goose Up
+-- Posts, page intros and announcements can be written as raw HTML instead of
+-- Markdown; the *_md column holds the source either way, format says which.
+ALTER TABLE posts ADD COLUMN format text NOT NULL DEFAULT 'markdown' CHECK (format IN ('markdown', 'html'));
+ALTER TABLE pages ADD COLUMN format text NOT NULL DEFAULT 'markdown' CHECK (format IN ('markdown', 'html'));
+ALTER TABLE sections ADD COLUMN format text NOT NULL DEFAULT 'markdown' CHECK (format IN ('markdown', 'html'));
+
+-- +goose Down
+ALTER TABLE posts DROP COLUMN format;
+ALTER TABLE pages DROP COLUMN format;
+ALTER TABLE sections DROP COLUMN format;
diff --git a/internal/i18n/el.go b/internal/i18n/el.go
index bb0fcd8..26e3a60 100644
--- a/internal/i18n/el.go
+++ b/internal/i18n/el.go
@@ -106,8 +106,8 @@ var el = map[string]string{
"not in menu": "εκτός μενού",
"Make home": "Ορισμός ως αρχικής",
"leave empty to make one from the title; e.g.": "αφήστε το κενό για να φτιαχτεί από τον τίτλο· π.χ.",
- "Intro text": "Εισαγωγικό κείμενο",
- "Markdown, shown above the posts; optional": "Markdown, εμφανίζεται πάνω από τις δημοσιεύσεις· προαιρετικό",
+ "Intro text": "Εισαγωγικό κείμενο",
+ "shown above the posts; optional": "εμφανίζεται πάνω από τις δημοσιεύσεις· προαιρετικό",
"Show in menu": "Εμφάνιση στο μενού",
"Back to pages": "Πίσω στις σελίδες",
"Page deleted.": "Η σελίδα διαγράφηκε.",
@@ -205,28 +205,30 @@ var el = map[string]string{
"Insert file": "Εισαγωγή αρχείου",
"…or paste / drop a file into the text. Images are shown, other files linked.": "…ή επικολλήστε / σύρετε ένα αρχείο μέσα στο κείμενο. Οι εικόνες εμφανίζονται, τα άλλα αρχεία γίνονται σύνδεσμοι.",
"The file is added at the end of the text when you save.": "Το αρχείο προστίθεται στο τέλος του κειμένου όταν αποθηκεύσετε.",
- "File not added:": "Το αρχείο δεν προστέθηκε:",
- "file name": "όνομα αρχείου",
- "Uploading": "Μεταφόρτωση",
- "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:": "Αποτυχία απόδοσης:",
+ "File not added:": "Το αρχείο δεν προστέθηκε:",
+ "file name": "όνομα αρχείου",
+ "Uploading": "Μεταφόρτωση",
+ "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:": "Αποτυχία απόδοσης:",
+ "Format": "Μορφή",
+ "Put on your blog exactly as written — embeds and scripts all work. An unclosed tag can break the page around it, so check your blog after saving.": "Μπαίνει στο ιστολόγιό σας ακριβώς όπως γράφεται — ενσωματώσεις και scripts δουλεύουν όλα. Μια ετικέτα που δεν κλείνει μπορεί να χαλάσει τη σελίδα γύρω της, γι' αυτό ελέγξτε το ιστολόγιό σας μετά την αποθήκευση.",
"Formatting cheat-sheet": "Οδηγός μορφοποίησης",
"Smaller heading": "Μικρότερη επικεφαλίδα",
"bold": "έντονα",
diff --git a/internal/store/pages.go b/internal/store/pages.go
index 3d5b02d..74da379 100644
--- a/internal/store/pages.go
+++ b/internal/store/pages.go
@@ -11,6 +11,7 @@ type Page struct {
Title string
IntroMD string
IntroHTML string
+ Format string // FormatMarkdown | FormatHTML: how IntroMD is read
NavOrder int
ShowInNav bool // derived: the page has a menu entry
IsHome bool
@@ -18,11 +19,11 @@ type Page struct {
PostCount int // filled by ListPages only
}
-const pageCols = `id, slug, title, intro_md, intro_html, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at`
+const pageCols = `id, slug, title, intro_md, intro_html, format, nav_order, EXISTS (SELECT 1 FROM menu_items m WHERE m.page_id=pages.id), is_home, created_at`
func scanPage(row interface{ Scan(...any) error }) (*Page, error) {
var p Page
- err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt)
+ err := row.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt)
if err != nil {
return nil, wrap(err)
}
@@ -39,7 +40,7 @@ func (bs *BlogStore) ListPages(ctx context.Context) ([]Page, error) {
var out []Page
for rows.Next() {
var p Page
- if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
+ if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.IntroMD, &p.IntroHTML, &p.Format, &p.NavOrder, &p.ShowInNav, &p.IsHome, &p.CreatedAt, &p.PostCount); err != nil {
return nil, err
}
out = append(out, p)
@@ -67,9 +68,9 @@ func (bs *BlogStore) CreatePage(ctx context.Context, p *Page) (*Page, error) {
return nil, err
}
defer tx.Rollback(ctx)
- created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (slug, title, intro_md, intro_html, nav_order)
- VALUES ($1,$2,$3,$4,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols,
- p.Slug, p.Title, p.IntroMD, p.IntroHTML))
+ created, err := scanPage(tx.QueryRow(ctx, `INSERT INTO pages (slug, title, intro_md, intro_html, format, nav_order)
+ VALUES ($1,$2,$3,$4,$5,(SELECT coalesce(max(nav_order),-1)+1 FROM pages)) RETURNING `+pageCols,
+ p.Slug, p.Title, p.IntroMD, p.IntroHTML, formatOrMD(p.Format)))
if err != nil {
return nil, err
}
@@ -89,8 +90,8 @@ func (bs *BlogStore) UpdatePage(ctx context.Context, p *Page) error {
return err
}
defer tx.Rollback(ctx)
- if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$2, title=$3, intro_md=$4, intro_html=$5 WHERE id=$1`,
- p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML); err != nil {
+ if _, err := tx.Exec(ctx, `UPDATE pages SET slug=$2, title=$3, intro_md=$4, intro_html=$5, format=$6 WHERE id=$1`,
+ p.ID, p.Slug, p.Title, p.IntroMD, p.IntroHTML, formatOrMD(p.Format)); err != nil {
return wrap(err)
}
if p.ShowInNav {
diff --git a/internal/store/posts.go b/internal/store/posts.go
index 1bffd51..0e01e9c 100644
--- a/internal/store/posts.go
+++ b/internal/store/posts.go
@@ -12,6 +12,7 @@ type Post struct {
Title string
BodyMD string
BodyHTML string
+ Format string // FormatMarkdown | FormatHTML: how BodyMD is read
Published bool
CreatedAt time.Time
UpdatedAt time.Time
@@ -23,14 +24,14 @@ type Post struct {
// The tags come along as two parallel arrays (names and slugs, both by name)
// so every post query stays a single round trip; the alias p is the posts row.
-const postCols = `p.id, p.page_id, p.slug, p.title, p.body_md, p.body_html, p.published, p.created_at, p.updated_at, g.slug, g.title,
+const postCols = `p.id, p.page_id, p.slug, p.title, p.body_md, p.body_html, p.format, p.published, p.created_at, p.updated_at, g.slug, g.title,
coalesce((SELECT array_agg(t.name ORDER BY t.name) FROM post_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.post_id=p.id), '{}'),
coalesce((SELECT array_agg(t.slug ORDER BY t.name) FROM post_tags pt JOIN tags t ON t.id=pt.tag_id WHERE pt.post_id=p.id), '{}')`
func scanPost(row interface{ Scan(...any) error }) (*Post, error) {
var p Post
var names, slugs []string
- err := row.Scan(&p.ID, &p.PageID, &p.Slug, &p.Title, &p.BodyMD, &p.BodyHTML, &p.Published, &p.CreatedAt, &p.UpdatedAt, &p.PageSlug, &p.PageTitle, &names, &slugs)
+ err := row.Scan(&p.ID, &p.PageID, &p.Slug, &p.Title, &p.BodyMD, &p.BodyHTML, &p.Format, &p.Published, &p.CreatedAt, &p.UpdatedAt, &p.PageSlug, &p.PageTitle, &names, &slugs)
if err != nil {
return nil, wrap(err)
}
@@ -139,8 +140,8 @@ func (bs *BlogStore) CreatePost(ctx context.Context, p *Post) (*Post, error) {
p.CreatedAt = time.Now()
}
var id int64
- err := bs.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, published, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
- p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published, p.CreatedAt).Scan(&id)
+ err := bs.db.QueryRow(ctx, `INSERT INTO posts (page_id, slug, title, body_md, body_html, format, published, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
+ p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, formatOrMD(p.Format), p.Published, p.CreatedAt).Scan(&id)
if err != nil {
return nil, wrap(err)
}
@@ -148,8 +149,8 @@ func (bs *BlogStore) CreatePost(ctx context.Context, p *Post) (*Post, error) {
}
func (bs *BlogStore) UpdatePost(ctx context.Context, p *Post) error {
- _, err := bs.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, published=$7, created_at=$8, updated_at=now() WHERE id=$1`,
- p.ID, p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, p.Published, p.CreatedAt)
+ _, err := bs.db.Exec(ctx, `UPDATE posts SET page_id=$2, slug=$3, title=$4, body_md=$5, body_html=$6, format=$7, published=$8, created_at=$9, updated_at=now() WHERE id=$1`,
+ p.ID, p.PageID, p.Slug, p.Title, p.BodyMD, p.BodyHTML, formatOrMD(p.Format), p.Published, p.CreatedAt)
return wrap(err)
}
diff --git a/internal/store/sections.go b/internal/store/sections.go
index 2f742f7..2d72ea4 100644
--- a/internal/store/sections.go
+++ b/internal/store/sections.go
@@ -12,6 +12,7 @@ type Section struct {
Title string
BodyMD string
BodyHTML string
+ Format string // FormatMarkdown | FormatHTML: how BodyMD is read
Placement string // <column>-<position>: left|main|right - top|bottom
Style string // plain | note | warning
Enabled bool
@@ -24,11 +25,11 @@ type Section struct {
func (s Section) Column() string { c, _, _ := strings.Cut(s.Placement, "-"); return c }
func (s Section) Position() string { _, p, _ := strings.Cut(s.Placement, "-"); return p }
-const sectionCols = `id, title, body_md, body_html, placement, style, enabled, sort_order, created_at, updated_at`
+const sectionCols = `id, title, body_md, body_html, format, placement, style, enabled, sort_order, created_at, updated_at`
func scanSection(row interface{ Scan(...any) error }) (*Section, error) {
var s Section
- err := row.Scan(&s.ID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt)
+ err := row.Scan(&s.ID, &s.Title, &s.BodyMD, &s.BodyHTML, &s.Format, &s.Placement, &s.Style, &s.Enabled, &s.SortOrder, &s.CreatedAt, &s.UpdatedAt)
if err != nil {
return nil, wrap(err)
}
@@ -66,14 +67,14 @@ func (bs *BlogStore) SectionByID(ctx context.Context, id int64) (*Section, error
}
func (bs *BlogStore) CreateSection(ctx context.Context, sec *Section) (*Section, error) {
- return scanSection(bs.db.QueryRow(ctx, `INSERT INTO sections (title, body_md, body_html, placement, style, enabled, sort_order)
- VALUES ($1,$2,$3,$4,$5,$6,(SELECT coalesce(max(sort_order),-1)+1 FROM sections)) RETURNING `+sectionCols,
- sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled))
+ return scanSection(bs.db.QueryRow(ctx, `INSERT INTO sections (title, body_md, body_html, format, placement, style, enabled, sort_order)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,(SELECT coalesce(max(sort_order),-1)+1 FROM sections)) RETURNING `+sectionCols,
+ sec.Title, sec.BodyMD, sec.BodyHTML, formatOrMD(sec.Format), sec.Placement, sec.Style, sec.Enabled))
}
func (bs *BlogStore) UpdateSection(ctx context.Context, sec *Section) error {
- _, err := bs.db.Exec(ctx, `UPDATE sections SET title=$2, body_md=$3, body_html=$4, placement=$5, style=$6, enabled=$7, updated_at=now() WHERE id=$1`,
- sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, sec.Placement, sec.Style, sec.Enabled)
+ _, err := bs.db.Exec(ctx, `UPDATE sections SET title=$2, body_md=$3, body_html=$4, format=$5, placement=$6, style=$7, enabled=$8, updated_at=now() WHERE id=$1`,
+ sec.ID, sec.Title, sec.BodyMD, sec.BodyHTML, formatOrMD(sec.Format), sec.Placement, sec.Style, sec.Enabled)
return wrap(err)
}
diff --git a/internal/store/store.go b/internal/store/store.go
index c8e97ea..30e5af0 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -18,6 +18,22 @@ import (
var ErrNotFound = errors.New("not found")
var ErrConflict = errors.New("already exists")
+// How a post, page intro or announcement source is read: Markdown (rendered
+// and sanitised) or raw HTML (put on the blog as written).
+const (
+ FormatMarkdown = "markdown"
+ FormatHTML = "html"
+)
+
+// formatOrMD is what gets stored: an unset Format means Markdown, so callers
+// that predate the switch (the seed) keep working and the CHECK never fires.
+func formatOrMD(f string) string {
+ if f == FormatHTML {
+ return FormatHTML
+ }
+ return FormatMarkdown
+}
+
type Store struct {
db *pgxpool.Pool // control database
cluster *db.Cluster
diff --git a/internal/web/handlers_blog.go b/internal/web/handlers_blog.go
index e842e84..6538c34 100644
--- a/internal/web/handlers_blog.go
+++ b/internal/web/handlers_blog.go
@@ -171,7 +171,7 @@ func (s *Server) handleBlogSearch(w http.ResponseWriter, r *http.Request) {
re := regexp.MustCompile("(?is)" + pattern) // (?s): "." crosses lines as it does in Postgres; QuoteMeta output always compiles
hits := make([]searchHit, 0, len(posts))
for _, p := range posts {
- hits = append(hits, searchHit{Post: p, Snippet: searchSnippet(p.BodyMD, re)})
+ hits = append(hits, searchHit{Post: p, Snippet: searchSnippet(snippetSource(&p), re)})
}
v["hits"], v["total"], v["pageNum"], v["lastPage"] = hits, total, n, (total+searchPerPage-1)/searchPerPage
}
diff --git a/internal/web/handlers_files.go b/internal/web/handlers_files.go
index 1d90b91..2b68871 100644
--- a/internal/web/handlers_files.go
+++ b/internal/web/handlers_files.go
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
+ "html"
"io"
"mime/multipart"
"net/http"
@@ -12,6 +13,7 @@ import (
"strings"
"github.com/google/uuid"
+ "github.com/gramanas/blogspace/internal/markdown"
"github.com/gramanas/blogspace/internal/store"
)
@@ -71,17 +73,42 @@ func fileMarkdown(f *store.File) string {
return open + text + "](/media/" + f.ID.String() + ")"
}
-// appendFileMD is the no-JavaScript path of "Insert file": the file arrives
-// with the form itself and is appended to the end of the text on save.
-func appendFileMD(md string, f *store.File) string {
+// fileHTML is fileMarkdown for content written in HTML mode.
+func fileHTML(f *store.File) string {
+ name := html.EscapeString(f.Filename)
+ src := "/media/" + f.ID.String()
+ if f.Kind == "image" {
+ return `<img src="` + src + `" alt="` + name + `">`
+ }
+ return `<a href="` + src + `">` + name + `</a>`
+}
+
+// appendFile is the no-JavaScript path of "Insert file": the file arrives
+// with the form itself and is appended to the end of the text on save, in
+// the text's format.
+func appendFile(body string, f *store.File, format string) string {
if f == nil {
- return md
+ return body
}
- md = strings.TrimRight(md, "\n")
- if md != "" {
- md += "\n\n"
+ line := fileMarkdown(f)
+ if format == store.FormatHTML {
+ line = fileHTML(f)
+ }
+ body = strings.TrimRight(body, "\n")
+ if body != "" {
+ body += "\n\n"
+ }
+ return body + line + "\n"
+}
+
+// renderBody is what a save stores for the blog to show: Markdown is rendered
+// and sanitised, HTML goes out exactly as the blogger wrote it — the same
+// owner's decision as the custom HTML module (see AGENTS.md).
+func renderBody(format, src string) string {
+ if format == store.FormatHTML {
+ return src
}
- return md + fileMarkdown(f) + "\n"
+ return markdown.Render(src)
}
// ---- file library ----------------------------------------------------------
@@ -182,7 +209,7 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
return
}
f := files[0]
- writeJSON(w, http.StatusOK, map[string]any{"id": f.ID.String(), "filename": f.Filename, "kind": f.Kind, "size": f.Size, "markdown": fileMarkdown(f)})
+ writeJSON(w, http.StatusOK, map[string]any{"id": f.ID.String(), "filename": f.Filename, "kind": f.Kind, "size": f.Size, "markdown": fileMarkdown(f), "html": fileHTML(f)})
return
}
if msg != "" {
diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go
index 6b467ce..cf72d39 100644
--- a/internal/web/handlers_pages.go
+++ b/internal/web/handlers_pages.go
@@ -6,7 +6,6 @@ import (
"strconv"
"strings"
- "github.com/gramanas/blogspace/internal/markdown"
"github.com/gramanas/blogspace/internal/slug"
"github.com/gramanas/blogspace/internal/store"
)
@@ -60,6 +59,7 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
p.Title = strings.TrimSpace(r.FormValue("title"))
p.Slug = strings.TrimSpace(r.FormValue("slug"))
p.IntroMD = strings.ReplaceAll(r.FormValue("intro"), "\r\n", "\n")
+ p.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML)
p.ShowInNav = r.FormValue("show_in_nav") == "on"
autoSlug := p.Slug == ""
if autoSlug {
@@ -79,8 +79,8 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) {
s.renderStatus(w, r, http.StatusBadRequest, "dashboard/page_form.html", map[string]any{"page": p, "error": msg})
return
}
- p.IntroMD = appendFileMD(p.IntroMD, img)
- p.IntroHTML = markdown.Render(p.IntroMD)
+ p.IntroMD = appendFile(p.IntroMD, img, p.Format)
+ p.IntroHTML = renderBody(p.Format, p.IntroMD)
base := p.Slug
for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict
p.Slug = slug.WithSuffix(base, n)
diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go
index ad0a241..e284e57 100644
--- a/internal/web/handlers_posts.go
+++ b/internal/web/handlers_posts.go
@@ -93,6 +93,7 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
p.Title = strings.TrimSpace(r.FormValue("title"))
p.Slug = strings.TrimSpace(r.FormValue("slug"))
p.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n")
+ p.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML)
p.Published = r.FormValue("published") == "on"
p.PageID, _ = strconv.ParseInt(r.FormValue("page_id"), 10, 64)
tags, tagsOK := parseTags(strings.Split(r.FormValue("tags"), ","))
@@ -117,7 +118,7 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
fail(http.StatusBadRequest, s.tr(r, "File not added:")+" "+err.Error())
return
}
- p.BodyMD = appendFileMD(p.BodyMD, img)
+ p.BodyMD = appendFile(p.BodyMD, img, p.Format)
pageOK := false
for _, pg := range pages {
if pg.ID == p.PageID {
@@ -141,7 +142,7 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) {
fail(http.StatusBadRequest, s.tr(r, "Tags: at most 20 per post, 40 characters each."))
return
}
- p.BodyHTML = markdown.Render(p.BodyMD)
+ p.BodyHTML = renderBody(p.Format, p.BodyMD)
base := p.Slug
for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict
p.Slug = slug.WithSuffix(base, n)
diff --git a/internal/web/handlers_sections.go b/internal/web/handlers_sections.go
index eb45e1c..77ac90d 100644
--- a/internal/web/handlers_sections.go
+++ b/internal/web/handlers_sections.go
@@ -6,7 +6,6 @@ import (
"strconv"
"strings"
- "github.com/gramanas/blogspace/internal/markdown"
"github.com/gramanas/blogspace/internal/store"
)
@@ -66,6 +65,7 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) {
}
sec.Title = strings.TrimSpace(r.FormValue("title"))
sec.BodyMD = strings.ReplaceAll(r.FormValue("body"), "\r\n", "\n")
+ sec.Format = pick(r.FormValue("format"), store.FormatMarkdown, store.FormatHTML)
sec.Placement = r.FormValue("column") + "-" + r.FormValue("position")
sec.Style = r.FormValue("style")
sec.Enabled = r.FormValue("enabled") == "on"
@@ -88,8 +88,8 @@ func (s *Server) handleSectionSave(w http.ResponseWriter, r *http.Request) {
fail(s.tr(r, "Announcement is too long (20 KB max)."))
return
}
- sec.BodyMD = appendFileMD(sec.BodyMD, img)
- sec.BodyHTML = markdown.Render(sec.BodyMD)
+ sec.BodyMD = appendFile(sec.BodyMD, img, sec.Format)
+ sec.BodyHTML = renderBody(sec.Format, sec.BodyMD)
if sec.ID == 0 {
sec, err = blogStore(r).CreateSection(r.Context(), sec)
} else {
diff --git a/internal/web/search.go b/internal/web/search.go
index ef554ae..9af980d 100644
--- a/internal/web/search.go
+++ b/internal/web/search.go
@@ -1,11 +1,14 @@
package web
import (
+ "html"
"html/template"
"regexp"
"strings"
"unicode/utf8"
+ "github.com/microcosm-cc/bluemonday"
+
"github.com/gramanas/blogspace/internal/store"
)
@@ -32,8 +35,22 @@ type searchHit struct {
Snippet template.HTML
}
-// searchSnippet is a short piece of the Markdown body around the first match,
-// with the match marked; when only the title matched it is the body's start.
+var stripTags = bluemonday.StrictPolicy()
+
+// snippetSource is the text a post's snippet is cut from: the Markdown as
+// written, or an HTML post with its tags stripped so the excerpt reads as
+// prose. (Postgres still matches against the source, so a query can hit a
+// tag or attribute name in an HTML post; the snippet then shows the text
+// nearest to it.)
+func snippetSource(p *store.Post) string {
+ if p.Format == store.FormatHTML {
+ return html.UnescapeString(stripTags.Sanitize(p.BodyMD))
+ }
+ return p.BodyMD
+}
+
+// searchSnippet is a short piece of the body around the first match, with the
+// match marked; when only the title matched it is the body's start.
func searchSnippet(body string, re *regexp.Regexp) template.HTML {
text := strings.Join(strings.Fields(body), " ")
loc := re.FindStringIndex(text)
diff --git a/internal/web/search_test.go b/internal/web/search_test.go
index 6a9b0a5..6ced39b 100644
--- a/internal/web/search_test.go
+++ b/internal/web/search_test.go
@@ -4,6 +4,8 @@ import (
"regexp"
"strings"
"testing"
+
+ "github.com/gramanas/blogspace/internal/store"
)
func TestSearchPattern(t *testing.T) {
@@ -42,3 +44,14 @@ func TestSearchSnippet(t *testing.T) {
t.Errorf("title-only snippet: %q", got)
}
}
+
+func TestSnippetSource(t *testing.T) {
+ p := &store.Post{Format: store.FormatHTML, BodyMD: `<p class="x">Tom &amp; <b>Jerry</b></p><script>alert(1)</script>`}
+ if got := snippetSource(p); got != "Tom & Jerry" {
+ t.Errorf("html post: %q", got)
+ }
+ p.Format = store.FormatMarkdown
+ if got := snippetSource(p); got != p.BodyMD {
+ t.Errorf("markdown post is used as written: %q", got)
+ }
+}
diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css
index 679fdde..3ef96c2 100644
--- a/internal/web/static/dashboard.css
+++ b/internal/web/static/dashboard.css
@@ -134,6 +134,14 @@ details.help[open] summary { margin-bottom: 0.5em; color: var(--text); }
/* ---- editor: toolbar, textarea and preview share one frame ------------------ */
.editor { margin-top: 0.3em; }
+.ed-format { margin: 0 0 0.4em; font-size: 0.9em; display: flex; flex-wrap: wrap; align-items: center; gap: 0.3em 0.9em; }
+.ed-format label { display: inline-flex; align-items: center; gap: 0.3em; margin: 0; font-weight: normal; }
+.ed-format input { width: auto; margin: 0; }
+.editor.js:not(.html) .ed-htmlnote { display: none; } /* without JS the note is always there: cheap, and true */
+.editor.html .ed-md, .editor.html .ed-linkbox, .editor.html .help { display: none; }
+.editor.html .ed-group { border-right: 0; } /* Insert file stands alone */
+.editor.html textarea { font-family: var(--mono); font-size: 0.9em; }
+.ed-preview-frame { display: block; width: 100%; min-height: 10em; border: 1px solid var(--border-strong); border-radius: 0 0 var(--radius-sm) var(--radius-sm); background: #fff; }
.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; }
diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html
index 775222d..4754c91 100644
--- a/internal/web/templates/dashboard/page_form.html
+++ b/internal/web/templates/dashboard/page_form.html
@@ -7,8 +7,8 @@
<label>{{t "Title"}}<br><input name="title" value="{{.Data.page.Title}}" required maxlength="120"></label>
<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") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
+ <label for="ed-intro">{{t "Intro text"}} <span class="muted">({{t "shown above the posts; optional"}})</span></label>
+ {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "format" .Data.page.Format "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 ab05b34..70cddcc 100644
--- a/internal/web/templates/dashboard/post_form.html
+++ b/internal/web/templates/dashboard/post_form.html
@@ -37,8 +37,8 @@
document.querySelector('.tagref').className += ' live'; mark();
})();
</script>
- <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") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
+ <label for="ed-body">{{t "Content"}}</label>
+ {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "format" .Data.post.Format "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 dc971e5..d5b4d25 100644
--- a/internal/web/templates/dashboard/section_form.html
+++ b/internal/web/templates/dashboard/section_form.html
@@ -23,8 +23,8 @@
</select></label>
</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") "preview" (print "/b/" .Blog.Subdomain "/preview") "csrf" .CSRF)}}
+ <label for="ed-body">{{t "Text"}}</label>
+ {{template "editor" (dict "name" "body" "value" $s.BodyMD "format" $s.Format "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/editor.html b/internal/web/templates/partials/editor.html
index 33b2a1c..a0f8c7f 100644
--- a/internal/web/templates/partials/editor.html
+++ b/internal/web/templates/partials/editor.html
@@ -1,22 +1,30 @@
-{{define "editor"}}<div class="editor" id="ed-{{.name}}-wrap">
+{{define "editor"}}<div class="editor{{if eq .format "html"}} html{{end}}" id="ed-{{.name}}-wrap">
+<p class="ed-format" id="ed-{{.name}}-format">
+ <span class="muted">{{t "Format"}}:</span>
+ <label><input type="radio" name="format" value="markdown"{{if ne .format "html"}} checked{{end}}> Markdown</label>
+ <label><input type="radio" name="format" value="html"{{if eq .format "html"}} checked{{end}}> HTML</label>
+ <span class="muted small ed-htmlnote">{{t "Put on your blog exactly as written — embeds and scripts all work. An unclosed tag can break the page around it, so check your blog after saving."}}</span>
+</p>
<div class="ed-toolbar" id="ed-{{.name}}-bar" hidden>
- <span class="ed-group">
+ <span class="ed-group ed-md">
<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">
+ <span class="ed-group ed-md">
<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">
+ <span class="ed-group ed-md">
<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">
+ <span class="ed-group ed-md">
<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>
+ </span>
+ <span class="ed-group">
<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">
@@ -31,6 +39,7 @@
</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>
+<iframe class="ed-preview-frame" id="ed-{{.name}}-frame" sandbox title="{{t "Preview"}}" hidden></iframe>
<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>
@@ -42,10 +51,22 @@
<script>
(function () {
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");
+ wrap = document.getElementById(id + "-wrap"), bar = document.getElementById(id + "-bar"), lk = document.getElementById(id + "-link"), pv = document.getElementById(id + "-preview"),
+ frame = document.getElementById(id + "-frame"), fmt = document.getElementById(id + "-format");
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";
+
+ // ---- format: HTML mode keeps the toolbar to Insert file and Write/Preview
+ function isHTML() { var r = fmt.querySelector("input[value=html]"); return !!(r && r.checked); }
+ var previewing = false;
+ function setClass() { wrap.className = "editor js" + (isHTML() ? " html" : "") + (previewing ? " previewing" : ""); }
+ function setFormat() {
+ ta.spellcheck = !isHTML();
+ lk.hidden = true;
+ if (previewing) setMode(true); else setClass(); // re-render the preview the other way
+ }
+ fmt.addEventListener("change", setFormat);
+ setFormat();
// ---- text primitives: everything goes through replace() so Ctrl+Z works
function setSel(s, e) { ta.focus(); ta.setSelectionRange(s, e); }
@@ -103,11 +124,13 @@
link: openLink,
file: function () { inp.click(); }
};
+ var htmlCmds = { file: true }; // the only command that makes sense in HTML mode
+ function run(name) { if (cmds[name] && (!isHTML() || htmlCmds[name])) cmds[name](); }
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")]();
+ else run(b.getAttribute("data-cmd"));
});
// ---- link box: replaces window.prompt, keeps the selection it was opened on
@@ -140,10 +163,10 @@
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](); }
+ if (k && !isHTML()) { ev.preventDefault(); cmds[k](); }
return;
}
- if (ev.key !== "Enter" || ev.shiftKey || ta.selectionStart !== ta.selectionEnd) return;
+ if (ev.key !== "Enter" || ev.shiftKey || isHTML() || 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();
@@ -152,14 +175,28 @@
replace(s, s, "\n" + m[1] + mark);
});
- // ---- preview: the server renders exactly what a save would
+ // ---- preview: the server renders Markdown exactly as a save would. HTML is
+ // shown in a sandboxed frame (opaque origin, no scripts): the text may not be
+ // the viewer's own — a superadmin edits other people's blogs — and must never
+ // run in the dashboard.
var modes = bar.querySelectorAll("[data-mode]");
function setMode(preview) {
- wrap.className = "editor js" + (preview ? " previewing" : "");
+ previewing = preview;
+ setClass();
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";
+ if (!preview) { pv.hidden = true; frame.hidden = true; ta.hidden = false; ta.focus(); return; }
+ var h = ta.offsetHeight || pv.offsetHeight || frame.offsetHeight;
+ if (isHTML()) {
+ pv.hidden = true;
+ frame.style.height = h + "px";
+ frame.srcdoc = ta.value;
+ frame.hidden = false; ta.hidden = true;
+ st.textContent = "";
+ return;
+ }
+ frame.hidden = true;
+ pv.style.minHeight = h + "px";
pv.innerHTML = "";
pv.hidden = false; ta.hidden = true;
st.textContent = {{t "Rendering…"}};
@@ -181,7 +218,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) { insertLine(j.markdown); st.textContent = {{t "Inserted"}} + " " + j.filename + "."; inp.value = ""; })
+ .then(function (j) { insertLine(isHTML() ? j.html : 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 b803003..48f380e 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -491,15 +491,41 @@ func TestFileMarkdown(t *testing.T) {
if got := fileMarkdown(doc); got != "[notes v2.pdf](/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8)" {
t.Errorf("document should be a link: %q", got)
}
- if got := appendFileMD("", img); got != line+"\n" {
+ if got := appendFile("", img, store.FormatMarkdown); got != line+"\n" {
t.Errorf("empty body: %q", got)
}
- if got := appendFileMD("hello\n", img); got != "hello\n\n"+line+"\n" {
+ if got := appendFile("hello\n", img, store.FormatMarkdown); got != "hello\n\n"+line+"\n" {
t.Errorf("appended: %q", got)
}
- if got := appendFileMD("hello", nil); got != "hello" {
+ if got := appendFile("hello", nil, store.FormatMarkdown); got != "hello" {
t.Errorf("nil file should not change the body: %q", got)
}
+ // HTML mode: tags, with the name escaped
+ img.Filename = `a "cat" <3.png`
+ tag := `<img src="/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8" alt="a &#34;cat&#34; &lt;3.png">`
+ if got := fileHTML(img); got != tag {
+ t.Errorf("image tag: %q", got)
+ }
+ if got := fileHTML(doc); got != `<a href="/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8">notes
+v2.pdf</a>` {
+ t.Errorf("document link: %q", got)
+ }
+ if got := appendFile("<p>hi</p>", img, store.FormatHTML); got != "<p>hi</p>\n\n"+tag+"\n" {
+ t.Errorf("html appended: %q", got)
+ }
+}
+
+func TestRenderBody(t *testing.T) {
+ raw := "**x**\n\n<script>1</script>"
+ if got := renderBody(store.FormatHTML, raw); got != raw {
+ t.Errorf("html must pass through untouched: %q", got)
+ }
+ if got := renderBody(store.FormatMarkdown, raw); !strings.Contains(got, "<strong>x</strong>") || strings.Contains(got, "<script>") {
+ t.Errorf("markdown must be rendered and sanitised: %q", got)
+ }
+ if got := renderBody("", "**x**"); !strings.Contains(got, "<strong>") {
+ t.Errorf("an unset format is markdown: %q", got)
+ }
}
func TestPreviewRendersMarkdown(t *testing.T) {