fix(gallery): render answer_html so card previews match the share page

Gallery was reading the plain answer field and HTML-escaping it,
showing literal `<ol>...` text where the actual share page rendered
the markdown. Now selects answer_html alongside, runs it through
the same sanitizer as the share-card render, and falls back to
escaped plaintext for legacy rows.
This commit is contained in:
Will Anderson
2026-05-02 12:38:48 -05:00
parent 42f8602457
commit ff054b9980
2 changed files with 88 additions and 2 deletions
@@ -0,0 +1,18 @@
-- 20260502173044_share_cards_answer_html.sql
--
-- Adds answer_html (nullable text) to public.share_cards so the gallery
-- /said preview can render the same marked.js-produced HTML that the
-- /share/<id> page already renders. Without this, gallery cards
-- HTML-escape the plaintext answer and show literal angle-bracket text
-- where the share page shows formatted lists, bold, code blocks, etc.
--
-- Nullable on purpose: existing rows pre-share-redesign have no rendered
-- HTML captured. The /said handler falls back to escaping the plain
-- answer field for those legacy rows.
--
-- Applied out-of-band via the Supabase Management API
-- (POST /v1/projects/<ref>/database/query). This file lands the change
-- in version control as the canonical record.
alter table public.share_cards
add column if not exists answer_html text;
+70 -2
View File
@@ -1,6 +1,46 @@
// components/gallery.el - "Things Neuron Said" gallery page.
// Per-card auth-gated voting via supabase-js + /api/vote.
// gal_sanitize_html defence-in-depth strip of dangerous HTML for the
// gallery preview. Mirrors sanitize_share_html in main.el exactly so the
// /said gallery and /share/<id> render the same allowlist. The DB column
// is already sanitized at write time; this is belt-and-braces in case a
// row was inserted out-of-band.
fn gal_sanitize_html(html: String) -> String {
let s1: String = str_replace(str_replace(html, "<script", "<!--script"), "<SCRIPT", "<!--script")
let s2: String = str_replace(str_replace(s1, "</script>", "/script-->"), "</SCRIPT>", "/script-->")
let s3: String = str_replace(str_replace(s2, "<iframe", "<!--iframe"), "<IFRAME", "<!--iframe")
let s4: String = str_replace(str_replace(s3, "</iframe>", "/iframe-->"), "</IFRAME>", "/iframe-->")
let s5: String = str_replace(str_replace(s4, "<style", "<!--style"), "<STYLE", "<!--style")
let s6: String = str_replace(str_replace(s5, "</style>", "/style-->"), "</STYLE>", "/style-->")
let s7: String = str_replace(str_replace(s6, "<object", "<!--object"), "<OBJECT", "<!--object")
let s8: String = str_replace(str_replace(s7, "</object>", "/object-->"), "</OBJECT>", "/object-->")
let s9: String = str_replace(str_replace(s8, "<embed", "<!--embed"), "<EMBED", "<!--embed")
let s10: String = str_replace(s9, "<form", "<!--form")
let s11: String = str_replace(s10, "</form>", "/form-->")
let s12: String = str_replace(str_replace(s11, "<link", "<!--link"), "<LINK", "<!--link")
let s13: String = str_replace(str_replace(s12, "<meta", "<!--meta"), "<META", "<!--meta")
let s14: String = str_replace(str_replace(s13, "<base", "<!--base"), "<BASE", "<!--base")
let e1: String = str_replace(str_replace(s14, " onclick=", " data-x-onclick="), " ONCLICK=", " data-x-onclick=")
let e2: String = str_replace(str_replace(e1, " onload=", " data-x-onload="), " ONLOAD=", " data-x-onload=")
let e3: String = str_replace(str_replace(e2, " onerror=", " data-x-onerror="), " ONERROR=", " data-x-onerror=")
let e4: String = str_replace(str_replace(e3, " onmouseover=", " data-x-onmouseover="), " ONMOUSEOVER=", " data-x-onmouseover=")
let e5: String = str_replace(str_replace(e4, " onfocus=", " data-x-onfocus="), " ONFOCUS=", " data-x-onfocus=")
let e6: String = str_replace(str_replace(e5, " onblur=", " data-x-onblur="), " ONBLUR=", " data-x-onblur=")
let e7: String = str_replace(str_replace(e6, " onsubmit=", " data-x-onsubmit="), " ONSUBMIT=", " data-x-onsubmit=")
let e8: String = str_replace(str_replace(e7, " onchange=", " data-x-onchange="), " ONCHANGE=", " data-x-onchange=")
let e9: String = str_replace(str_replace(e8, " onkeydown=", " data-x-onkeydown="), " ONKEYDOWN=", " data-x-onkeydown=")
let e10: String = str_replace(str_replace(e9, " onkeyup=", " data-x-onkeyup="), " ONKEYUP=", " data-x-onkeyup=")
let e11: String = str_replace(str_replace(e10, " onkeypress=", " data-x-onkeypress="), " ONKEYPRESS=", " data-x-onkeypress=")
let e12: String = str_replace(str_replace(e11, " onmouseenter=", " data-x-onmouseenter="), " ONMOUSEENTER=", " data-x-onmouseenter=")
let e13: String = str_replace(str_replace(e12, " onmouseleave=", " data-x-onmouseleave="), " ONMOUSELEAVE=", " data-x-onmouseleave=")
let e14: String = str_replace(str_replace(e13, " ontoggle=", " data-x-ontoggle="), " ONTOGGLE=", " data-x-ontoggle=")
let e15: String = str_replace(str_replace(e14, " onanimationend=", " data-x-onanimationend="), " ONANIMATIONEND=", " data-x-onanimationend=")
let j1: String = str_replace(str_replace(e15, "javascript:", "about:blank#"), "JAVASCRIPT:", "about:blank#")
let j2: String = str_replace(str_replace(j1, "data:text/html", "about:blank#"), "DATA:text/html", "about:blank#")
return j2
}
fn gallery_page(cards_json: String, supabase_url: String, supabase_anon_key: String) -> String {
let i: Int = 0
let cards_html: String = ""
@@ -10,10 +50,25 @@ fn gallery_page(cards_json: String, supabase_url: String, supabase_anon_key: Str
let cid: String = json_get(card, "id")
let cq: String = json_get(card, "question")
let ca: String = json_get(card, "answer")
let ca_html_raw: String = json_get(card, "answer_html")
let score_raw: String = json_get(card, "score")
let score: String = if str_eq(score_raw, "") { "0" } else { score_raw }
let q_html: String = str_replace(str_replace(str_replace(cq, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
let a_html: String = str_replace(str_replace(str_replace(ca, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
// a_html: prefer the marked.js-rendered HTML (run through the
// same sanitizer the share page uses) so the gallery preview
// matches /share/<id>. Cap at 600 rendered chars so the card
// stays the right visual height. Legacy rows (answer_html null)
// fall back to escaped plaintext.
// json_get returns the literal string "null" for JSON null cells
// (which is how legacy rows surface answer_html). Treat both empty
// and "null" as the no-html fallback.
let has_html: Bool = !str_eq(ca_html_raw, "") && !str_eq(ca_html_raw, "null")
let a_html: String = if !has_html {
str_replace(str_replace(str_replace(ca, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
} else {
let s: String = gal_sanitize_html(ca_html_raw)
if str_len(s) > 600 { str_slice(s, 0, 600) + "..." } else { s }
}
let ts_raw: String = json_get(card, "created_at")
let ts_short: String = str_slice(ts_raw, 0, 10)
let card_html: String = "<div class=\"gal-card\" data-share-id=\"" + cid + "\" data-score=\"" + score + "\" data-ts=\"" + cid + "\">
@@ -113,7 +168,20 @@ body::before {
.gal-card:hover { border-color: rgba(0,82,160,.25); box-shadow: 0 4px 20px rgba(0,82,160,.07); }
.gal-link { text-decoration: none; color: inherit; display: flex; flex-direction: column; gap: .875rem; flex: 1; }
.gal-q { font-family: var(--body); font-weight: 500; font-size: .875rem; color: var(--navy); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.gal-a { font-family: var(--body); font-weight: 300; font-size: .8125rem; color: var(--t2); line-height: 1.7; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; flex: 1; }
.gal-a { font-family: var(--body); font-weight: 300; font-size: .8125rem; color: var(--t2); line-height: 1.7; display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical; overflow: hidden; flex: 1; max-height: 11rem; }
.gal-a p { margin: 0; }
.gal-a p + p { margin-top: .35rem; }
.gal-a ol, .gal-a ul { padding-left: 1.25rem; margin: .25rem 0; }
.gal-a li + li { margin-top: .15rem; }
.gal-a strong { font-weight: 600; color: var(--t1); }
.gal-a em { font-style: italic; }
.gal-a code { background: rgba(0,0,0,0.05); padding: .1em .3em; border-radius: 3px; font-family: ui-monospace, 'IBM Plex Mono', Menlo, monospace; font-size: .85em; }
.gal-a pre { background: var(--bg2); padding: .5rem; overflow-x: auto; font-size: .75rem; margin: .4rem 0; border-radius: 3px; }
.gal-a pre code { background: none; padding: 0; }
.gal-a a { color: var(--navy); text-decoration: underline; }
.gal-a h1, .gal-a h2, .gal-a h3, .gal-a h4 { font-size: 1em; font-weight: 600; margin: .3rem 0; color: var(--t1); }
.gal-a blockquote { border-left: 2px solid var(--border2); padding-left: .5rem; color: var(--t2); margin: .3rem 0; }
.gal-a img { max-width: 100%; height: auto; }
.gal-meta { display: flex; justify-content: space-between; align-items: center; font-family: var(--body); font-size: .65rem; font-weight: 400; letter-spacing: .1em; text-transform: uppercase; color: var(--t3); margin-top: auto; padding-top: .75rem; border-top: 1px solid var(--border); }
.gal-date { font-size: .65rem; color: var(--t3); }
/* Vote controls */