diff --git a/migrations/20260502173044_share_cards_answer_html.sql b/migrations/20260502173044_share_cards_answer_html.sql new file mode 100644 index 0000000..f02e174 --- /dev/null +++ b/migrations/20260502173044_share_cards_answer_html.sql @@ -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/ 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//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; diff --git a/src/gallery.el b/src/gallery.el index 0ae14b7..7fdc951 100644 --- a/src/gallery.el +++ b/src/gallery.el @@ -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/ 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-->") + let s3: String = str_replace(str_replace(s2, "", "/iframe-->"), "", "/iframe-->") + let s5: String = str_replace(str_replace(s4, "", "/style-->"), "", "/style-->") + let s7: String = str_replace(str_replace(s6, "", "/object-->"), "", "/object-->") + let s9: String = str_replace(str_replace(s8, "", "/form-->") + let s12: String = str_replace(str_replace(s11, " 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, "&", "&"), "<", "<"), ">", ">") - let a_html: String = str_replace(str_replace(str_replace(ca, "&", "&"), "<", "<"), ">", ">") + // a_html: prefer the marked.js-rendered HTML (run through the + // same sanitizer the share page uses) so the gallery preview + // matches /share/. 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, "&", "&"), "<", "<"), ">", ">") + } 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 = "
@@ -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 */