Closes the last two Learning Hub follow-ups flagged in the earlier
commit body: rich-HTML body rendering (instead of pre-wrap plain text)
and in-React Marp slide playback (instead of the legacy-viewer link).
client/src/lib/sanitize.ts (new)
Inline HTML sanitizer. Parses via DOMParser (sandboxed — no scripts
run), walks the tree and:
• Removes script / style / iframe / object / embed / link / meta /
base / form / input / button / select / textarea.
• Drops every on* event-handler attribute.
• Drops href / src / xlink:href values starting with javascript:
or data:text/html.
• Drops any attribute whose value contains "javascript:".
• Falls back to entity-escaping the raw string if parsing throws.
Admin-authored Learning Hub content is the trust model here —
essentially CMS content. A full DOMPurify dep would be strictly
better but adding a package requires a network install; the inline
sanitizer covers the realistic XSS vectors without the dep bump.
client/src/pages/Learning.tsx
• Body rendering (non-presentation content_type) now runs through
sanitizeHtml + dangerouslySetInnerHTML with a `prose prose-sm`
Tailwind-typography class. Markdown/HTML formatting from admin
content now appears correctly (headings, lists, code, bold,
italics, links) instead of raw text.
• SlideViewer component (new) replaces the "Open in legacy viewer"
button for content_type === 'presentation'. Fetches
/api/learning/content/:slug/slides (returns { css, slides[] } —
server-side Marp output), sanitizes each slide's HTML + the CSS
block, renders one slide at a time with:
- prev/next buttons
- keyboard ←/→ and PageUp/PageDown navigation
- slide counter (N/total)
- fullscreen toggle (Escape exits)
Marp's own CSS is injected scoped-ish via sanitizer so slide
theming survives.
shared/types.ts + client/src/shared/types.ts — additive:
LearningSlidesOk { css: string; slides: string[] }
Client tsc + vite build clean. Initial bundle unchanged
(342.86 kB / 106.03 kB gz) since Learning.tsx was already lazy-loaded;
the sanitizer + SlideViewer roll into its chunk.
1 line
No EOL
2.4 KiB
JavaScript
1 line
No EOL
2.4 KiB
JavaScript
import{t as e}from"./jsx-runtime-ByY1xr43.js";import{i as t,o as n}from"./index-DTb5Y4u0.js";var r=e();function i(){let{data:e,isLoading:i,error:a}=n({queryKey:[`schedule-data`],queryFn:()=>t.get(`/api/schedule-data`)});return i?(0,r.jsx)(`div`,{className:`p-6 text-sm text-muted-foreground`,children:`Loading…`}):a?(0,r.jsx)(`div`,{className:`p-6 text-sm text-destructive`,children:a.message}):e?(0,r.jsxs)(`div`,{className:`max-w-4xl mx-auto p-6 space-y-4`,children:[(0,r.jsxs)(`header`,{children:[(0,r.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Catch-Up Schedule`}),(0,r.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`CDC 2025 catch-up immunization schedule — minimum ages and intervals per vaccine.`})]}),Object.entries(e.catchUpSchedule).map(([t,n])=>{let i=e.vaccineFullNames[t]||t,a=n.catchUpNotes?Array.isArray(n.catchUpNotes)?n.catchUpNotes:[n.catchUpNotes]:[];return(0,r.jsxs)(`section`,{className:`rounded-lg border border-border bg-card overflow-hidden`,children:[(0,r.jsxs)(`header`,{className:`px-4 py-2 border-b border-border bg-muted/40 flex items-center justify-between`,children:[(0,r.jsx)(`h2`,{className:`text-sm font-semibold`,children:i}),n.minimumAgeForDose1&&(0,r.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`Min age dose 1: `,(0,r.jsx)(`strong`,{children:n.minimumAgeForDose1})]})]}),n.series&&n.series.length>0&&(0,r.jsxs)(`table`,{className:`w-full text-xs`,children:[(0,r.jsx)(`thead`,{className:`bg-muted/20`,children:(0,r.jsxs)(`tr`,{children:[(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Dose`}),(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Min age`}),(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Min interval from prev`}),(0,r.jsx)(`th`,{className:`text-left px-3 py-2`,children:`Notes`})]})}),(0,r.jsx)(`tbody`,{children:n.series.map(e=>(0,r.jsxs)(`tr`,{className:`border-t border-border`,children:[(0,r.jsxs)(`td`,{className:`px-3 py-2 font-semibold`,children:[`Dose `,e.dose]}),(0,r.jsx)(`td`,{className:`px-3 py-2`,children:e.minimumAge||`—`}),(0,r.jsx)(`td`,{className:`px-3 py-2`,children:e.minimumIntervalToPrev||`—`}),(0,r.jsx)(`td`,{className:`px-3 py-2 text-muted-foreground`,children:e.notes||``})]},String(e.dose)))})]}),a.length>0&&(0,r.jsx)(`ul`,{className:`list-disc pl-8 py-2 text-xs text-muted-foreground space-y-1`,children:a.map((e,t)=>(0,r.jsx)(`li`,{children:e},t))})]},t)})]}):null}export{i as default}; |