feat(f/[slug]): render description as sanitized markdown

Cherry-pick of b49f2e0 from mai/iris/hotfix-render onto main. iris's
hotfix branched off 538419f (May 6) to avoid Phase 2, but prod was
actually already on c13d84d (Phase 2 deployed May 7-8), so deploying
her branch directly would have regressed Phase 2. Cherry-pick onto
current main resolves cleanly: package.json + +page.svelte
auto-merged; bun.lock regenerated via bun install.

Replaces the bare <p>{data.description}</p> on the participant page
with a marked + isomorphic-dompurify pipeline so admins can author
descriptions with categorized link lists. Scoped .fb-description
styles restore list bullets, give h1-h6 sensible scale below the page
title, and use the existing --color-primary / dark-mode tokens.

Both deps land in dependencies (not devDependencies) because the
render runs SSR-first.

Hotfix for the UPC Deadlines training (HL PA, 2026-05-28) — m wants a
curated Resources/Links block above the form.
This commit is contained in:
mAi
2026-05-27 21:09:10 +02:00
parent c13d84d0f3
commit 288dfb31e8
3 changed files with 111 additions and 1 deletions

View File

@@ -6,6 +6,8 @@
"name": "fdbck", "name": "fdbck",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.104.1", "@supabase/supabase-js": "^2.104.1",
"isomorphic-dompurify": "^3.14.0",
"marked": "^18.0.4",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"zod": "^4.3.6", "zod": "^4.3.6",
}, },
@@ -294,6 +296,8 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="],
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
@@ -332,6 +336,8 @@
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"isomorphic-dompurify": ["isomorphic-dompurify@3.14.0", "", { "dependencies": { "dompurify": "^3.4.5", "jsdom": "^29.1.1" } }, "sha512-64W8/lsfqgaDWfEkvrIVk8FdIk29Mya0Fp39excQEdlcLUPg1Cn7CtCYe6CtPbFW90JpEKTXG0QQtIUNENJ7sw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
@@ -346,6 +352,8 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@18.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],

View File

@@ -29,6 +29,8 @@
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.104.1", "@supabase/supabase-js": "^2.104.1",
"isomorphic-dompurify": "^3.14.0",
"marked": "^18.0.4",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"zod": "^4.3.6" "zod": "^4.3.6"
} }

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'isomorphic-dompurify';
import type { FeedbackFormDefinition, FeedbackQuestion } from '$lib/schemas'; import type { FeedbackFormDefinition, FeedbackQuestion } from '$lib/schemas';
import type { AggregatedResults } from '$lib/server/results'; import type { AggregatedResults } from '$lib/server/results';
import { getQuestion } from '$lib/questions/registry'; import { getQuestion } from '$lib/questions/registry';
@@ -8,6 +10,10 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
function renderDescription(src: string): string {
return DOMPurify.sanitize(marked.parse(src, { breaks: true, gfm: true, async: false }));
}
const isClosed = data.status === 'closed'; const isClosed = data.status === 'closed';
const formDef = data.form_definition as FeedbackFormDefinition | null; const formDef = data.form_definition as FeedbackFormDefinition | null;
const chatEnabled = data.chat_enabled; const chatEnabled = data.chat_enabled;
@@ -502,7 +508,7 @@
<header class="fb-header"> <header class="fb-header">
<h1>{data.title}</h1> <h1>{data.title}</h1>
{#if data.description} {#if data.description}
<p>{data.description}</p> <div class="fb-description">{@html renderDescription(data.description)}</div>
{/if} {/if}
</header> </header>
@@ -708,4 +714,98 @@
.fb-foot__link:hover { .fb-foot__link:hover {
color: var(--color-text-primary); color: var(--color-text-primary);
} }
/* Markdown-rendered description block. The page already owns the <h1> for
data.title, so any headings inside the description shrink below it. */
.fb-description {
margin: 0 0 1.5rem;
color: var(--color-text-secondary);
line-height: 1.55;
}
.fb-description :global(> :first-child) {
margin-top: 0;
}
.fb-description :global(> :last-child) {
margin-bottom: 0;
}
.fb-description :global(p) {
margin: 0 0 0.75rem;
}
.fb-description :global(h1),
.fb-description :global(h2),
.fb-description :global(h3) {
margin: 1.25rem 0 0.5rem;
font-size: 1.05rem;
font-weight: 600;
line-height: 1.3;
color: var(--color-text-primary);
letter-spacing: -0.005em;
}
.fb-description :global(h4),
.fb-description :global(h5),
.fb-description :global(h6) {
margin: 1rem 0 0.4rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-primary);
}
.fb-description :global(ul),
.fb-description :global(ol) {
margin: 0.25rem 0 0.75rem;
padding-left: 1.4rem;
}
.fb-description :global(ul) {
list-style: disc;
}
.fb-description :global(ol) {
list-style: decimal;
}
.fb-description :global(li) {
margin: 0.2rem 0;
}
.fb-description :global(li > p) {
margin: 0;
}
.fb-description :global(a) {
color: var(--color-primary);
text-decoration: none;
border-bottom: 1px solid transparent;
transition:
color 0.15s ease,
border-color 0.15s ease;
}
.fb-description :global(a:hover) {
color: var(--color-primary-hover);
border-bottom-color: currentColor;
}
.fb-description :global(a:focus-visible) {
outline: 2px solid var(--color-border-focus);
outline-offset: 2px;
border-radius: 2px;
}
.fb-description :global(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
background: var(--color-bg-tertiary);
padding: 0.1rem 0.3rem;
border-radius: var(--radius-sm);
}
.fb-description :global(strong) {
color: var(--color-text-primary);
font-weight: 600;
}
.fb-description :global(em) {
font-style: italic;
}
.fb-description :global(blockquote) {
margin: 0.75rem 0;
padding-left: 0.75rem;
border-left: 3px solid var(--color-border-primary);
color: var(--color-text-muted);
}
.fb-description :global(hr) {
margin: 1rem 0;
border: 0;
border-top: 1px solid var(--color-border-primary);
}
</style> </style>