← All components

Content · 13 of 13

PainPointsItem

A fixed-width card whose body is CMS markdown rendered to HTML at component scope, with a custom marked renderer supplying the Tailwind classes.

Vue island src/components/project/pain-points-item.vue rendered live from src/components/project/pain-points-item.vue v-html sanitized · dompurify 3.4.14

Props

PropTypeNotes
item *PainPointThe two fields the component reads, declared in the component itself: image.url, image.name, content (markdown). A generated CMS entity satisfies it unchanged; the component carries no CMS schema.
resolveImage(url: string) => stringSupplied by the caller for relative media URLs; omit it when they are already absolute.

The renderer overrides two node types only: paragraph → <p class="text-lg"> and list → <ul|ol class="pt-2 pl-5 list-disc">. Everything else falls through to marked's defaults, unstyled.

Stories

Paragraph only content = one paragraph 18px body on a 24px-radius #0F0F15 card, fixed 14rem wide. The markdown really runs through the component's marked instance.
With a list content = paragraph + bullets The custom renderer is why the bullets have discs at all — list-disc is not a browser default under Tailwind's reset.
Hostile CMS content content = markdown containing raw HTML Markdown permits raw HTML and marked passes it through, so whatever a CMS editor types reaches v-html. This story feeds the component an onload handler, a javascript: link and a <script> tag, each of which sets a flag if it ever executes. The readout below is generated after mount from the DOM the real component produced — nothing here is written by hand. Note the icon still appears: sanitising strips the handler, not the image.
running…

Findings

Fixed upstream — v-html is now sanitized. This card used to parse item.content with marked and bind the result straight to v-html. Markdown permits raw HTML and marked dropped its own sanitize option in v5, so anything an editor could type into the CMS content field executed in every visitor's page — an XSS sink gated only by CMS access control. As of the 2026-08-20 resync the component wraps the parse in sanitize() from isomorphic-dompurify, which closes it. Kept here rather than deleted because the specimen is the record of what the component was, and this was the site's only P0.

The sanitizer here is real. The storybook resolves isomorphic-dompurify to kitchen-sink/ports/sanitizer.adapter.ts, which is plain dompurify at the version the isomorphic wrapper itself resolves to. The wrapper's only job is pairing DOMPurify with jsdom so the call also works during SSR; a docs page is always a browser, so the port drops the half that has nothing to do here and keeps the engine. Same defaults as the site — no config is passed there either. The third story above is the proof rather than this paragraph.

Markdown is parsed once, outside any reactive scope. htmlContent is computed at <script setup> body level from props.item.content, not in a computed. If the parent swaps item without remounting, the card keeps rendering the previous body while the image updates — the two halves of the card can disagree.

The ordered-list branch still gets list-disc. The renderer picks ol vs ul from token.ordered but applies the same class string either way, so a numbered list renders with bullets and no numbers.

Width is fixed at w-56. 14rem regardless of viewport or content, with only the horizontal margin responding to breakpoints. Long copy grows the card downward indefinitely, which is what the carousel parent expects — but it means the component cannot be reused in a wider slot.