Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,15 @@ const makePodcastEpisodes = (count: number): PodcastEpisodeResource[] =>
const setupApis = ({
episodesPage1,
episodesPage2,
podcastOverrides = {},
}: {
episodesPage1: LearningResource[]
episodesPage2?: LearningResource[]
podcastOverrides?: Partial<LearningResource>
}) => {
const podcast = factories.learningResources.resource({
resource_type: ResourceTypeEnum.Podcast,
...podcastOverrides,
})

// Episodes of this podcast reference it as their parent, as they would in
Expand Down Expand Up @@ -202,6 +205,43 @@ describe("PodcastDetailPage", () => {
await screen.findByText(episodes[0].title!)
})

test("renders a formatted show description", async () => {
const episodes = makePodcastEpisodes(1)
const { podcast } = setupApis({
episodesPage1: episodes,
podcastOverrides: {
description: "<p>Daryl Morey &amp; Jessica Gelman</p>",
},
})

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

expect(
await screen.findByText("Daryl Morey & Jessica Gelman"),
).toBeInTheDocument()
})

test("opens external links in the show description in a new tab", async () => {
const episodes = makePodcastEpisodes(1)
const { podcast } = setupApis({
episodesPage1: episodes,
podcastOverrides: {
// rel="noopener noreferrer" mirrors real backend output: nh3 adds it
// to every <a> during ETL sanitization, regardless of destination.
description:
'Relevant Resources: <a href="https://ocw.mit.edu/" rel="noopener noreferrer">OCW</a> and <a href="/search" rel="noopener noreferrer">Search</a>.',
},
})

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

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,
Expand Down
66 changes: 48 additions & 18 deletions frontends/main/src/app-pages/PodcastPage/PodcastDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client"

import React from "react"
import { Typography, Skeleton, styled } from "ol-components"
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 {
Expand All @@ -12,6 +12,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"
Expand Down Expand Up @@ -66,18 +67,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)<Pick<TypographyProps, "component">>(
({ 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,
Expand Down Expand Up @@ -274,6 +285,21 @@ export const PodcastDetailPage: React.FC<PodcastDetailPageProps> = ({

const handlePlayClick = (episode: LearningResource) => toggle(episode, id)

// Podcast descriptions are sanitized on the backend with nh3 during ETL
// (only <a href/title> 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 (
<>
<PageSection variant="white">
Expand Down Expand Up @@ -315,10 +341,14 @@ export const PodcastDetailPage: React.FC<PodcastDetailPageProps> = ({
</MetaLine>
)}

{resource?.description && (
<Description variant="body2">
{resource.description}
</Description>
{description && (
<Description
variant="body2"
component="div"
dangerouslySetInnerHTML={{
__html: description,
}}
/>
)}

{latestEpisode && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,47 @@ describe("PodcastSection", () => {
)
})

it("renders a sanitized, formatted summary for featured series", () => {
const series = makeSeries({
description:
'<script>alert("xss")</script><p>Teaching &amp; learning at MIT</p>',
})
renderWithProviders(
<PodcastSection
featuredPodcasts={[series]}
morePodcasts={[]}
hasMorePodcasts={false}
totalPodcasts={0}
isMobile={false}
/>,
)
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: <a href="https://ocw.mit.edu/">OCW</a> and <a href="/search">Search</a>.',
})
renderWithProviders(
<PodcastSection
featuredPodcasts={[series]}
morePodcasts={[]}
hasMorePodcasts={false}
totalPodcasts={0}
isMobile={false}
/>,
)
expect(
screen.getByText("Relevant Resources: OCW and Search.", {
exact: false,
}),
).toBeInTheDocument()
// Only the card's own outer link should be present — no nested <a>.
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -109,14 +111,28 @@ const FeaturedPodcastTitle = styled(Typography)<
marginBottom: "8px",
}))

const FeaturedPodcastSummary = styled(Typography)(({ theme }) => ({
const FeaturedPodcastSummary = styled(Typography)<
Pick<TypographyProps, "component">
>(({ theme }) => ({
color: theme.custom.colors.silverGrayDark,
lineHeight: "24px",
marginBottom: "16px",
display: "-webkit-box",
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 }) => ({
Expand Down Expand Up @@ -324,9 +340,15 @@ const PodcastSection: React.FC<PodcastSectionProps> = ({
{item.title}
</FeaturedPodcastTitle>
{item.description && (
<FeaturedPodcastSummary variant="body1">
{item.description}
</FeaturedPodcastSummary>
<FeaturedPodcastSummary
variant="body1"
component="div"
dangerouslySetInnerHTML={{
__html: stripAnchorTags(
DOMPurify.sanitize(item.description),
),
}}
/>
)}
<FeaturedPodcastMeta variant="body2">
{[
Expand Down
Loading