Skip to content

Fix: render podcast show descriptions as HTML instead of raw markup - #3721

Open
daniellefrappier18 wants to merge 3 commits into
mainfrom
daniellef/12690-fix-raw-html-on-podcast-detail
Open

Fix: render podcast show descriptions as HTML instead of raw markup#3721
daniellefrappier18 wants to merge 3 commits into
mainfrom
daniellef/12690-fix-raw-html-on-podcast-detail

Conversation

@daniellefrappier18

@daniellefrappier18 daniellefrappier18 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What are the relevant tickets?

Fixes https://github.com/mitodl/hq/issues/12690

Description (What does it do?)

The podcast detail page and the "Featured" cards on the podcasts listing page, the show-level description was rendered as plain JSX text, so users saw raw markup like <p> tags and unescaped entities like &amp; instead of formatted copy.

The backend sanitizes show descriptions as allowlisted HTML (nh3 via ALLOWED_HTML_TAGS_WITH_LINKS) — the same treatment already given to podcast episode descriptions, which render correctly. The two show-level sites just hadn't been updated to match.

  • PodcastDetailPage.tsx — description now sanitized (DOMPurify.sanitize) and rendered via dangerouslySetInnerHTML, with external links opening in a new tab (addExternalLinkTargets), mirroring PodcastEpisodeDetailPage.tsx.
  • PodcastSection.tsx (featured podcast cards) — same sanitization, but links are stripped (stripAnchorTags) instead of kept clickable, since the whole card is already a Link — mirroring EpisodeItem.tsx's handling of the same situation.
  • Both containers switched from the default <p> to component="div", since sanitized descriptions can contain their own <p> tags (nesting <p> in <p> is invalid HTML).
  • Added link styling (a { textDecoration: underline; ... }) to the show detail page's description, matching the episode detail page, so links don't fall back to default browser blue.
  • Added a & p override on the featured card's summary so paragraph tags don't break its 2-line clamp truncation.

Screenshots (if appropriate):

  • Desktop screenshots
  • Mobile width screenshots

BEFORE
Screenshot 2026-08-04 at 11 41 25 AM
Screenshot 2026-08-04 at 11 38 13 AM

AFTER
Screenshot 2026-08-04 at 2 36 55 PM

Screenshot 2026-08-04 at 2 36 52 PM

How can this be tested?

  • Manually verify against the two examples from the issue: If you don't currently have in the local DB you can seed with the following script.
# scripts/seed_test_podcasts.py — run via:
# docker compose run --rm web python manage.py shell < scripts/seed_test_podcasts.py
from learning_resources.constants import LearningResourceType, PlatformType
from learning_resources.models import LearningResource, LearningResourcePlatform, Podcast

platform, _ = LearningResourcePlatform.objects.get_or_create(code=PlatformType.podcast.name)

SHOWS = [
    {
        "readable_id": "test-trash-talking-show",
        "title": "Trash Talking",
        "description": "<p>Daryl Morey &amp; Jessica Gelman on sports analytics.</p>",
    },
    {
        "readable_id": "test-lock-the-quill-show",
        "title": "Lock the Quill",
        "description": "<p>Conversations on writing and craft.</p><p>New episodes weekly.</p>",
    },
]

for show in SHOWS:
    readable_id = show.pop("readable_id")
    lr, _ = LearningResource.objects.update_or_create(
        readable_id=readable_id,
        platform=platform,
        defaults={
            "resource_type": LearningResourceType.podcast.name,
            "resource_category": LearningResourceType.podcast.value,
            "published": True,
            **show,
        },
    )
    Podcast.objects.update_or_create(learning_resource=lr)
    print(f"seeded {lr.id}: /podcast/{lr.id}/{lr.title.lower().replace(' ', '-')}")
  • For example: /podcast/15925/trash-talking — "&" should render properly instead of &amp;
  • For example: /podcast/17779/lock-the-quill — paragraphs should render as formatted text instead of showing literal <p> tags
  • Spot-check a featured podcast card (podcasts listing page) whose show description contains a link — confirm the link text still shows but isn't a separate clickable <a> nested inside the card.

Additional Context

Out of scope: a related issue: https://github.com/mitodl/hq/issues/12700 where sanitized HTML/entities leak into <meta name="description"> / Open Graph / Twitter Card tags on the podcast (and other resource-type) pages — that needs a different fix (strip-to-plain-text, not parse-as-HTML) and touches a shared metadata utility used by all resource types, so it's being tracked separately.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

OpenAPI Changes

No changes detected

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

@daniellefrappier18
daniellefrappier18 marked this pull request as ready for review August 4, 2026 19:01
Copilot AI lite review requested due to automatic review settings August 4, 2026 19:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Next.js podcast UI to render podcast show descriptions as sanitized HTML (instead of plain text), matching the existing behavior for episode descriptions and fixing cases where users saw raw tags/entities.

Changes:

  • Render podcast show descriptions using dangerouslySetInnerHTML with DOMPurify sanitization and appropriate link handling (open external links in a new tab on the detail page; strip nested links in featured cards).
  • Update typography wrappers to render as div to avoid invalid <p> nesting when descriptions contain block elements.
  • Add/extend frontend tests to cover XSS sanitization, entity decoding, and link behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.tsx Render featured show summaries as sanitized HTML and strip nested anchors; adjust styling for clamped summaries containing <p> tags.
frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx Add tests for sanitization/entity rendering and link stripping in featured cards.
frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx Render show description as sanitized HTML, add link styling, and open external links in a new tab.
frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx Add tests for show description sanitization/entity decoding and external-link target behavior.
Suppressed comments (1)

frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx:240

  • After updating the fixture HTML to include the backend-provided rel attribute, the test should also assert it on the external link. This guards the assumption that links rendered with target="_blank" are protected against tabnabbing (noopener/noreferrer).
    const externalLink = await screen.findByRole("link", { name: "OCW" })
    expect(externalLink).toHaveAttribute("target", "_blank")

Comment on lines +230 to +233
podcastOverrides: {
description:
'Relevant Resources: <a href="https://ocw.mit.edu/">OCW</a> and <a href="/search">Search</a>.',
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addExternalLinkTargets only reads href and appends target, it doesn't read, use, or need rel at all, and it passes through whatever other attributes are already in the tag unchanged. Adding rel="noopener noreferrer" to the test fixture wouldn't change what's asserted or add any new coverage; it'd just make the mock string longer to look more like real nh3 output.

component="div"
dangerouslySetInnerHTML={{
__html: addExternalLinkTargets(
DOMPurify.sanitize(resource.description),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question:

The sibling PodcastEpisodeDetailPage.tsx deliberately renders its already-backend-sanitized (nh3) description without a client-side DOMPurify.sanitize, and memoizes the transform (useMemo, lines 274–278), with a comment (lines 267–273) explaining this keeps SSR and client output identical to avoid a hydration mismatch. Here we re-sanitize inline on every render via isomorphic-dompurify, which runs against jsdom on the server and the native DOM in the browser — the two can produce subtly different HTML, and feeding that to dangerouslySetInnerHTML during SSR is a known cause of React hydration mismatches. Since the content is already backend-sanitized, consider dropping the redundant DOMPurify.sanitize (and/or memoizing) to mirror PodcastEpisodeDetailPage.tsx.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, you're right. I pulled it out and memoized it the same way PodcastEpisodeDetailPage does. Also had to tweak one test that was asserting a <script> tag got stripped client-side.

@ahtesham-quraish ahtesham-quraish left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good just left one comment

@ahtesham-quraish ahtesham-quraish left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants