From 6b6b5d2e35ff182a0732da245f4eb37c8afa0564 Mon Sep 17 00:00:00 2001 From: grm Date: Wed, 16 Sep 2026 18:37:20 +0300 Subject: 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 Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A --- internal/db/migrations/blog/00006_format.sql | 11 ++++ internal/i18n/el.go | 50 ++++++++-------- internal/store/pages.go | 17 +++--- internal/store/posts.go | 13 +++-- internal/store/sections.go | 15 ++--- internal/store/store.go | 16 ++++++ internal/web/handlers_blog.go | 2 +- internal/web/handlers_files.go | 45 ++++++++++++--- internal/web/handlers_pages.go | 6 +- internal/web/handlers_posts.go | 5 +- internal/web/handlers_sections.go | 6 +- internal/web/search.go | 21 ++++++- internal/web/search_test.go | 13 +++++ internal/web/static/dashboard.css | 8 +++ internal/web/templates/dashboard/page_form.html | 4 +- internal/web/templates/dashboard/post_form.html | 4 +- internal/web/templates/dashboard/section_form.html | 4 +- internal/web/templates/partials/editor.html | 67 +++++++++++++++++----- internal/web/web_test.go | 32 ++++++++++- 19 files changed, 250 insertions(+), 89 deletions(-) create mode 100644 internal/db/migrations/blog/00006_format.sql (limited to 'internal') 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 // -: 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 `` + name + `` + } + return `` + name + `` +} + +// 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: `

Tom & Jerry

`} + 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 @@ - - {{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)}} + + {{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)}}

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(); })(); - - {{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)}} + + {{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)}}

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 @@

{{t "“Top” of a side column means above its modules, “bottom” below them. If that column is not shown on your blog (see"}} {{t "Layout"}}) {{t "the announcement moves to the main content instead."}}

- - {{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)}} + + {{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)}}

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"}}

+{{define "editor"}}
+

+ {{t "Format"}}: + + + {{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."}} +

+
@@ -42,10 +51,22 @@ " + 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, "x") || strings.Contains(got, "