Skip to content
Merged
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
43 changes: 43 additions & 0 deletions frontends/main/src/common/htmlToPlainText.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { htmlToPlainText } from "./htmlToPlainText"

describe("htmlToPlainText", () => {
it("strips tags and decodes entities", () => {
expect(htmlToPlainText("<p>Daryl Morey &amp; Jessica Gelman</p>")).toBe(
"Daryl Morey & Jessica Gelman",
)
})

it("keeps a space between adjacent block-level elements", () => {
expect(htmlToPlainText("<p>First</p><p>Second</p>")).toBe("First Second")
})

it("keeps a space where a <br> separates lines", () => {
expect(htmlToPlainText("Line one<br>Line two")).toBe("Line one Line two")
})

it("keeps a space between adjacent table cells", () => {
expect(
htmlToPlainText("<table><tr><td>A</td><td>B</td></tr></table>"),
).toBe("A B")
})

it("keeps a space after a blockquote or pre block", () => {
expect(
htmlToPlainText("<blockquote>Quote</blockquote><pre>Code</pre>Text"),
).toBe("Quote Code Text")
})

it("strips links but keeps their text", () => {
expect(
htmlToPlainText('<p><a href="https://ocw.mit.edu">OCW</a> resources</p>'),
).toBe("OCW resources")
})

it("leaves plain text unchanged", () => {
expect(htmlToPlainText("Just plain text")).toBe("Just plain text")
})

it("returns an empty string for empty input", () => {
expect(htmlToPlainText("")).toBe("")
})
})
33 changes: 33 additions & 0 deletions frontends/main/src/common/htmlToPlainText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import DOMPurify from "isomorphic-dompurify"
import { collapseWhitespace } from "@/common/utils"

const BLOCK_BOUNDARY_TAGS =
/<\/(?:p|div|li|h[1-6]|td|th|tr|blockquote|pre)>|<br\s*\/?>/gi

/**
* Converts a sanitized-HTML string (e.g. a resource `description`) to plain
* text suitable for contexts that must not contain markup, like <meta
* name="description">, og:description, and twitter:description. Strips all
* tags and decodes entities (&amp; -> &); a space is inserted at block-level
* boundaries first so adjacent paragraphs/list items don't get mashed
* together once their tags are removed.
*
* Kept out of common/utils.ts and imported only by server-only code (e.g.
* metadata.ts): isomorphic-dompurify has no `sideEffects: false`, so any
* client component importing anything from utils.ts would otherwise pull
* DOMPurify into its bundle even when htmlToPlainText itself is unused.
*/
const htmlToPlainText = (html: string): string => {
if (!html) return ""
const withBreaks = html.replace(BLOCK_BOUNDARY_TAGS, (match) => `${match} `)
// RETURN_DOM_FRAGMENT gives back real DOM nodes rather than a serialized
// HTML string, so reading .textContent decodes entities for free (a
// serialized-string result stays HTML-escaped, e.g. "&amp;", since it's
// meant to be re-inserted as HTML).
const fragment = DOMPurify.sanitize(withBreaks, {
RETURN_DOM_FRAGMENT: true,
})
return collapseWhitespace(fragment.textContent ?? "")
}

export { htmlToPlainText }
12 changes: 12 additions & 0 deletions frontends/main/src/common/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ describe("safeGenerateMetadata", () => {
})
})

describe("standardizeMetadata", () => {
test("converts an HTML description to plain text in all description fields", async () => {
const meta = await standardizeMetadata({
description: "<p>Daryl Morey &amp; Jessica Gelman</p>",
})

expect(meta.description).toBe("Daryl Morey & Jessica Gelman")
expect(meta.openGraph?.description).toBe("Daryl Morey & Jessica Gelman")
expect(meta.twitter?.description).toBe("Daryl Morey & Jessica Gelman")
})
})

describe("getMetadataAsync drawer canonical", () => {
test("emits a slugged separate-param canonical for a valid ?resource=", async () => {
const resource = factories.learningResources.course()
Expand Down
4 changes: 3 additions & 1 deletion frontends/main/src/common/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
RESOURCE_DRAWER_PARAMS,
} from "@/common/urls"
import { parseResourceId } from "@/common/slugs"
import { htmlToPlainText } from "@/common/htmlToPlainText"
import type { AxiosError } from "axios"
import type { Metadata } from "next"
import * as Sentry from "@sentry/nextjs"
Expand Down Expand Up @@ -86,7 +87,7 @@ export const getMetadataAsync = async ({
learningResourceQueries.detail(learningResourceId),
)
title = data?.title
description = data?.description?.replace(/<\/[^>]+(>|$)/g, "") ?? ""
description = data?.description ?? ""
image = data?.image?.url || image
imageAlt = image === data?.image?.url ? imageAlt : data?.image?.alt || ""
alts.canonical = canonicalResourceDrawerUrl(learningResourceId, data?.title)
Expand Down Expand Up @@ -118,6 +119,7 @@ export const standardizeMetadata = ({
...otherMeta
}: MetadataProps = {}): Metadata => {
title = `${title} | ${env("NEXT_PUBLIC_SITE_NAME")}`
description = htmlToPlainText(description)
const socialMetadata = social
? {
openGraph: {
Expand Down
Loading