feat(f/[slug]): render description as sanitized markdown
Cherry-pick ofb49f2e0from mai/iris/hotfix-render onto main. iris's hotfix branched off538419f(May 6) to avoid Phase 2, but prod was actually already onc13d84d(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:
8
bun.lock
8
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user