From ea8439abbed98cbeb0e17b4715fcecf59a32d02b Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Tue, 4 Aug 2026 14:31:47 -0400 Subject: [PATCH 1/3] fix render podcast show descriptions as HTML instead of raw markup --- .../PodcastPage/PodcastDetailPage.test.tsx | 40 +++++++++++++++ .../PodcastPage/PodcastDetailPage.tsx | 50 +++++++++++++------ .../PodcastSection.test.tsx | 45 +++++++++++++++++ .../PodcastsListingPage/PodcastSection.tsx | 30 +++++++++-- 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx index cce80ce425..dda4052865 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx @@ -45,12 +45,15 @@ const makePodcastEpisodes = (count: number): PodcastEpisodeResource[] => const setupApis = ({ episodesPage1, episodesPage2, + podcastOverrides = {}, }: { episodesPage1: LearningResource[] episodesPage2?: LearningResource[] + podcastOverrides?: Partial }) => { const podcast = factories.learningResources.resource({ resource_type: ResourceTypeEnum.Podcast, + ...podcastOverrides, }) // Episodes of this podcast reference it as their parent, as they would in @@ -202,6 +205,43 @@ describe("PodcastDetailPage", () => { await screen.findByText(episodes[0].title!) }) + test("renders a sanitized, formatted show description", async () => { + const episodes = makePodcastEpisodes(1) + const { podcast } = setupApis({ + episodesPage1: episodes, + podcastOverrides: { + description: + '

Daryl Morey & Jessica Gelman

', + }, + }) + + renderWithProviders() + + expect( + await screen.findByText("Daryl Morey & Jessica Gelman"), + ).toBeInTheDocument() + expect(document.querySelector("script")).not.toBeInTheDocument() + }) + + test("opens external links in the show description in a new tab", async () => { + const episodes = makePodcastEpisodes(1) + const { podcast } = setupApis({ + episodesPage1: episodes, + podcastOverrides: { + description: + 'Relevant Resources: OCW and Search.', + }, + }) + + renderWithProviders() + + const externalLink = await screen.findByRole("link", { name: "OCW" }) + expect(externalLink).toHaveAttribute("target", "_blank") + + const internalLink = screen.getByRole("link", { name: "Search" }) + expect(internalLink).not.toHaveAttribute("target") + }) + test("shows an error when the podcast fails to load", async () => { const podcast = factories.learningResources.resource({ resource_type: ResourceTypeEnum.Podcast, diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx index edc1606ace..5b28cf9fa3 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx @@ -1,9 +1,10 @@ "use client" import React from "react" -import { Typography, Skeleton, styled } from "ol-components" +import { Typography, Skeleton, styled, TypographyProps } from "ol-components" import { Button } from "@mitodl/smoot-design" import { RiPlayFill, RiPauseFill } from "@remixicon/react" +import DOMPurify from "isomorphic-dompurify" import { useLearningResourcesDetail, useInfiniteLearningResourceItems, @@ -12,6 +13,7 @@ import { ResourceTypeEnum } from "api/v1" import type { LearningResource } from "api/v1" import { formatDate } from "ol-utilities" import { HOME, podcastEpisodePageView } from "@/common/urls" +import { addExternalLinkTargets } from "@/common/utils" import PodcastContainer from "./PodcastContainer" import PodcastBreadcrumbs from "./PodcastBreadcrumbs" import { usePodcastPage } from "./usePodcastPage" @@ -66,18 +68,28 @@ const MetaLine = styled(Typography)(({ theme }) => ({ }, })) -const Description = styled(Typography)(({ theme }) => ({ - color: theme.custom.colors.darkGray2, - display: "block", - marginBottom: "16px", - ...theme.typography.body1, - lineHeight: "26px", - [theme.breakpoints.down("sm")]: { - marginBottom: "8px", - ...theme.typography.body2, - lineHeight: "22px", - }, -})) +const Description = styled(Typography)>( + ({ theme }) => ({ + color: theme.custom.colors.darkGray2, + display: "block", + marginBottom: "16px", + ...theme.typography.body1, + lineHeight: "26px", + a: { + textDecoration: "underline", + color: theme.custom.colors.darkGray2, + fontWeight: theme.typography.fontWeightMedium, + }, + "a:hover": { + textDecoration: "none", + }, + [theme.breakpoints.down("sm")]: { + marginBottom: "8px", + ...theme.typography.body2, + lineHeight: "22px", + }, + }), +) const LatestEpisodeLine = styled(Typography)(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, @@ -316,9 +328,15 @@ export const PodcastDetailPage: React.FC = ({ )} {resource?.description && ( - - {resource.description} - + )} {latestEpisode && ( diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx index f67ae94f00..63617b454c 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx @@ -71,6 +71,51 @@ describe("PodcastSection", () => { ) }) + it("renders a sanitized, formatted summary for featured series", () => { + const series = makeSeries({ + description: + '

Teaching & learning at MIT

', + }) + renderWithProviders( + , + ) + expect( + screen.getByText("Teaching & learning at MIT"), + ).toBeInTheDocument() + expect(document.querySelector("script")).not.toBeInTheDocument() + }) + + it("strips links from the featured summary, keeping their text, since the card is itself a link", () => { + const series = makeSeries({ + description: + 'Relevant Resources: OCW and Search.', + }) + renderWithProviders( + , + ) + expect( + screen.getByText("Relevant Resources: OCW and Search.", { + exact: false, + }), + ).toBeInTheDocument() + // Only the card's own outer link should be present — no nested . + expect(screen.getAllByRole("link", { name: /Chalk Radio/ })).toHaveLength( + 1, + ) + }) + it("renders 'More Podcasts' rows with title and offered_by", () => { const series = makeSeries({ title: "The Aggregate", diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.tsx index 2cbbe0eae1..c5c3d5e4a9 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.tsx @@ -4,9 +4,11 @@ import { Typography, Skeleton, styled } from "ol-components" import type { TypographyProps } from "ol-components" import { ButtonLink } from "@mitodl/smoot-design" import { RiArrowRightLine, RiArrowRightSLine } from "@remixicon/react" +import DOMPurify from "isomorphic-dompurify" import { formatDate } from "ol-utilities" import type { LearningResource } from "api/v1" import { SEARCH_PODCASTS, podcastPageView } from "@/common/urls" +import { stripAnchorTags } from "@/common/utils" import { Section, SectionHeader, @@ -109,7 +111,9 @@ const FeaturedPodcastTitle = styled(Typography)< marginBottom: "8px", })) -const FeaturedPodcastSummary = styled(Typography)(({ theme }) => ({ +const FeaturedPodcastSummary = styled(Typography)< + Pick +>(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, lineHeight: "24px", marginBottom: "16px", @@ -117,6 +121,18 @@ const FeaturedPodcastSummary = styled(Typography)(({ theme }) => ({ WebkitBoxOrient: "vertical", WebkitLineClamp: 2, overflow: "hidden", + maxWidth: "100%", + overflowWrap: "break-word", + wordBreak: "break-word", + "& p": { + display: "inline", + margin: 0, + maxWidth: "100%", + overflowWrap: "break-word", + wordBreak: "break-word", + whiteSpace: "normal", + textWrap: "wrap", + }, })) const FeaturedPodcastMeta = styled(Typography)(({ theme }) => ({ @@ -324,9 +340,15 @@ const PodcastSection: React.FC = ({ {item.title} {item.description && ( - - {item.description} - + )} {[ From 5c42b515ed1eb7b364dad3996e60cb8c7312a5b5 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Tue, 4 Aug 2026 14:54:32 -0400 Subject: [PATCH 2/3] fix formatting --- .../PodcastsListingPage/PodcastSection.test.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx index 63617b454c..51317c7f6f 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastsListingPage/PodcastSection.test.tsx @@ -85,9 +85,7 @@ describe("PodcastSection", () => { isMobile={false} />, ) - expect( - screen.getByText("Teaching & learning at MIT"), - ).toBeInTheDocument() + expect(screen.getByText("Teaching & learning at MIT")).toBeInTheDocument() expect(document.querySelector("script")).not.toBeInTheDocument() }) @@ -111,9 +109,7 @@ describe("PodcastSection", () => { }), ).toBeInTheDocument() // Only the card's own outer link should be present — no nested . - expect(screen.getAllByRole("link", { name: /Chalk Radio/ })).toHaveLength( - 1, - ) + expect(screen.getAllByRole("link", { name: /Chalk Radio/ })).toHaveLength(1) }) it("renders 'More Podcasts' rows with title and offered_by", () => { From 6a8c3dd26fa413bd1fdbef765e6794d58e8dc932 Mon Sep 17 00:00:00 2001 From: Danielle Frappier Date: Thu, 6 Aug 2026 09:48:39 -0400 Subject: [PATCH 3/3] drop redundant client-side sanitization of podcast show description --- .../PodcastPage/PodcastDetailPage.test.tsx | 10 ++++---- .../PodcastPage/PodcastDetailPage.tsx | 24 ++++++++++++++----- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx index dda4052865..2a43638a36 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.test.tsx @@ -205,13 +205,12 @@ describe("PodcastDetailPage", () => { await screen.findByText(episodes[0].title!) }) - test("renders a sanitized, formatted show description", async () => { + test("renders a formatted show description", async () => { const episodes = makePodcastEpisodes(1) const { podcast } = setupApis({ episodesPage1: episodes, podcastOverrides: { - description: - '

Daryl Morey & Jessica Gelman

', + description: "

Daryl Morey & Jessica Gelman

", }, }) @@ -220,7 +219,6 @@ describe("PodcastDetailPage", () => { expect( await screen.findByText("Daryl Morey & Jessica Gelman"), ).toBeInTheDocument() - expect(document.querySelector("script")).not.toBeInTheDocument() }) test("opens external links in the show description in a new tab", async () => { @@ -228,8 +226,10 @@ describe("PodcastDetailPage", () => { const { podcast } = setupApis({ episodesPage1: episodes, podcastOverrides: { + // rel="noopener noreferrer" mirrors real backend output: nh3 adds it + // to every
during ETL sanitization, regardless of destination. description: - 'Relevant Resources: OCW and Search.', + 'Relevant Resources: OCW and Search.', }, }) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx index 5b28cf9fa3..d4d0e9c2ee 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx @@ -1,10 +1,9 @@ "use client" -import React from "react" +import React, { useMemo } from "react" import { Typography, Skeleton, styled, TypographyProps } from "ol-components" import { Button } from "@mitodl/smoot-design" import { RiPlayFill, RiPauseFill } from "@remixicon/react" -import DOMPurify from "isomorphic-dompurify" import { useLearningResourcesDetail, useInfiniteLearningResourceItems, @@ -286,6 +285,21 @@ export const PodcastDetailPage: React.FC = ({ const handlePlayClick = (episode: LearningResource) => toggle(episode, id) + // Podcast descriptions are sanitized on the backend with nh3 during ETL + // (only is allowed), so the HTML is safe to render verbatim + // — the same trust model as podcast episode descriptions. Rendering it + // directly keeps server and client output identical, avoiding a hydration + // mismatch; target="_blank" is added via addExternalLinkTargets so it's + // part of the HTML fed to dangerouslySetInnerHTML on both server and + // client, keeping SSR output byte-identical to the client's first render. + const description = useMemo( + () => + resource?.description + ? addExternalLinkTargets(resource.description) + : null, + [resource?.description], + ) + return ( <> @@ -327,14 +341,12 @@ export const PodcastDetailPage: React.FC = ({ )} - {resource?.description && ( + {description && ( )}