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 --- AGENTS.md | 52 +++++++++++------ README.md | 6 +- cmd/blogspace/seed.go | 17 ++++-- 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 ++++++++++- 22 files changed, 301 insertions(+), 113 deletions(-) create mode 100644 internal/db/migrations/blog/00006_format.sql 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_`, 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 ``), 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. ``/``), 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 + `
@@ -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, "