Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(parse and sanitize markdown on server) #1634

Merged
merged 21 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
297b53f
add new parseAndSanitize method, move sanitize to same file
Spencer6497 Jan 20, 2025
8c907ae
markdown render + sanitize server-side PoC commit
Spencer6497 Jan 20, 2025
de5ec77
Revert "markdown render + sanitize server-side PoC commit"
Spencer6497 Jan 21, 2025
1bb832a
add buildRichTextValidation() function, swap out all instances of ric…
Spencer6497 Jan 21, 2025
eaf8970
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 21, 2025
3e20db9
fix paragraph mocker
Spencer6497 Jan 21, 2025
65f9aaf
remove parsing and sanitizing from RichText component
Spencer6497 Jan 21, 2025
1fcac29
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 22, 2025
aeae3b0
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 27, 2025
4930bfd
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 28, 2025
657390c
add helper methods and tests to properly handle edge case of lists wi…
Spencer6497 Jan 28, 2025
233aa02
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 28, 2025
a0a8072
tweak postprocess method for simplicity and add clarifying comment
Spencer6497 Jan 28, 2025
aed5163
fully remove unused renderer prop
Spencer6497 Jan 28, 2025
f169de4
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 28, 2025
279fb60
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 29, 2025
b84c7e6
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 29, 2025
2c90fb9
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 29, 2025
8bdea91
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 29, 2025
b9a4017
add tests for buildRichTextValidation() to ensure custom parsers are …
Spencer6497 Jan 29, 2025
2feb021
Merge branch 'main' into markdown-on-server
Spencer6497 Jan 31, 2025
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
3 changes: 2 additions & 1 deletion app/components/InlineNotice.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import LightbulbOutlinedIcon from "@digitalservicebund/icons/LightbulbOutlined";
import WarningAmberIcon from "@digitalservicebund/icons/WarningAmber";
import { removeMarkupTags } from "~/util/strings";
import Heading from "./Heading";
import RichText from "./RichText";

Expand Down Expand Up @@ -34,7 +35,7 @@ export const InlineNotice = ({
content,
}: InlineNoticeProps) => {
const { backgroundColor, borderColor, IconComponent } = lookConfig[look];
const shouldHideNotice = !content || content.trim().length === 0;
const shouldHideNotice = !content || removeMarkupTags(content).length === 0;

return (
!shouldHideNotice && (
Expand Down
25 changes: 10 additions & 15 deletions app/components/List.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Renderer } from "marked";
import { removeMarkupTags } from "~/util/strings";
import Heading, { type HeadingProps } from "./Heading";
import ListItem, { type ListItemProps } from "./ListItem";
import RichText from "./RichText";
Expand All @@ -11,12 +11,6 @@ export type ListProps = {
isNumeric?: boolean;
};

const paragraphRenderer: Partial<Renderer> = {
paragraph({ tokens }) {
return `<p class="ds-subhead max-w-full">${this.parser?.parseInline(tokens)}</p>`;
},
};

const List = ({
identifier,
items,
Expand All @@ -27,17 +21,11 @@ const List = ({
return (
<div className="ds-stack-8 scroll-my-40" id={identifier}>
{heading && <Heading {...heading} />}
{subheading && (
<RichText
markdown={subheading}
renderer={paragraphRenderer}
className="pt-16"
/>
)}
{subheading && <RichText markdown={subheading} className="pt-16" />}
<ol className="list-none ds-stack-32 ps-0">
{items
// Need to filter out empty list items when conditionally rendering with mustache templating
.filter((item) => !(item.headline?.text === "" && !item.content))
.filter(listItemNotEmpty)
.map((item, index) => (
<li
key={item.identifier ?? item.headline?.text ?? item.content}
Expand All @@ -51,4 +39,11 @@ const List = ({
);
};

export function listItemNotEmpty(item: ListItemProps): boolean {
return (
removeMarkupTags(item.headline?.text ?? "").length > 0 ||
removeMarkupTags(item.content ?? "").length > 0
);
}

export default List;
37 changes: 1 addition & 36 deletions app/components/RichText.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,20 @@
import { type Renderer, Marked } from "marked";
import { renderToString } from "react-dom/server";
import { sanatize } from "~/services/security/sanatizeHtml";
import { StandaloneLink } from "./StandaloneLink";

const CSS_HEADING_CLASSES = [
"ds-heading-01-reg",
"ds-heading-02-reg",
"ds-heading-03-reg",
"ds-label-01-bold",
];

export type RichTextProps = {
markdown: string;
};

const defaultRenderer: Partial<Renderer> = {
link({ href, text }) {
/* Either renders a Standalone link or Inline link,
but we use the StandaloneLink component, because both has the same structure and style */
return renderToString(<StandaloneLink text={text} url={href} />);
},
heading({ depth, text }) {
// can't use .at() due to old browsers
const cssClass = CSS_HEADING_CLASSES[depth - 1] ?? "ds-label-01-reg";
return `<h${depth} class="${cssClass}">${text}</h${depth}>`;
},
} as const;

const RichText = ({
markdown,
renderer,
className = "",
...props
}: RichTextProps & {
renderer?: Partial<Renderer>;
id?: string;
className?: string;
}) => {
const marked = new Marked({
renderer: renderer ?? defaultRenderer,
async: false,
});
const html = marked.parse(markdown) as string;

if (!html) return null;

return (
<div
{...props}
className={`rich-text ds-stack-8 ${className}`}
dangerouslySetInnerHTML={{ __html: sanatize(html) }}
dangerouslySetInnerHTML={{ __html: markdown }}
/>
);
};
Expand Down
5 changes: 0 additions & 5 deletions app/components/__test__/Details.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@ describe("Details", () => {
expect(screen.getByText(testContent)).toBeInTheDocument();
});

it("renders RichText with correct markdown", () => {
render(<Details title="Test Title" content="**Test Markdown**" />);
expect(screen.getByText("Test Markdown")).toBeInTheDocument();
});

it("toggles visibility of content on summary click", () => {
render(<Details title="Test Title" content="Test Content" />);
const summaryElement = screen.getByText("Test Title");
Expand Down
43 changes: 42 additions & 1 deletion app/components/__test__/List.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { render } from "@testing-library/react";
import List from "../List";
import { ListItemProps } from "~/components/ListItem";
import List, { listItemNotEmpty } from "../List";

describe("List", () => {
it("should render subheading when it is given", () => {
Expand All @@ -19,3 +20,43 @@ describe("List", () => {
expect(queryByText(mockSubheadinText)).not.toBeInTheDocument();
});
});

describe("listItemNotEmpty", () => {
it("should return true if a ListItem has a headline but no content", () => {
const listItem: ListItemProps = {
headline: {
text: "headline",
},
};
expect(listItemNotEmpty(listItem)).toBe(true);
expect(listItemNotEmpty({ ...listItem, content: "<p></p>\n" })).toBe(true);
});

it("should return true if a ListItem has content but no headline", () => {
const listItem: ListItemProps = {
content: "some content",
};
expect(listItemNotEmpty(listItem)).toBe(true);
expect(
listItemNotEmpty({ ...listItem, headline: { text: "\n<h1></h1>" } }),
).toBe(true);
});

it("should return true when a ListItem has both headline and content", () => {
const listItem: ListItemProps = {
headline: {
text: "headline",
},
content: "<p>some content</p>",
};
expect(listItemNotEmpty(listItem)).toBe(true);
});

it("should return false if a ListItem doesn't have headline or content", () => {
const listItem: ListItemProps = {};
expect(listItemNotEmpty(listItem)).toBe(false);
expect(
listItemNotEmpty({ headline: { text: "" }, content: "<h3></h3>\n" }),
).toBe(false);
});
});
75 changes: 0 additions & 75 deletions app/components/__test__/RichText.test.tsx

This file was deleted.

11 changes: 1 addition & 10 deletions app/components/inputs/tile/TileRadio.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,8 @@
import type { Renderer } from "marked";
import { useField } from "remix-validated-form";
import TileTag, { type TileDescriptionProps } from "./TileTag";
import Image, { type ImageProps } from "../../Image";
import RichText from "../../RichText";

const paragraphRenderer: Partial<Renderer> = {
paragraph({ text }) {
return `<p class="ds-subhead">${text}</p>`;
},
Copy link
Contributor

Choose a reason for hiding this comment

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

can you check if this component renders the RichText with the paragraph with this className?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a new test suite, richtest.test.ts, as the rendering is occurring inside of buildRichTextValidation

};

const IMAGE_HEIGHT = 32;
const IMAGE_WIDTH = 32;

Expand Down Expand Up @@ -69,9 +62,7 @@ const TileRadio = ({
<TileTag tagDescription={tagDescription} />
</div>
<span className="ds-label-01-bold">{title}</span>
{description && (
<RichText markdown={description} renderer={paragraphRenderer} />
)}
{description && <RichText markdown={description} />}
</div>
</label>
</div>
Expand Down
3 changes: 2 additions & 1 deletion app/services/cms/components/StrapiTextarea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import {
StrapiErrorRelationSchema,
} from "~/services/cms/flattenStrapiErrors";
import { StrapiDetailsSchema } from "~/services/cms/models/StrapiDetails";
import { buildRichTextValidation } from "~/services/validation/richtext";
import { omitNull } from "~/util/omitNull";
import { HasOptionalStrapiIdSchema } from "../models/HasStrapiId";

const StrapiTextareaSchema = z
.object({
name: z.string(),
description: z.string().nullable(),
description: buildRichTextValidation().nullable(),
details: StrapiDetailsSchema.nullable(),
label: z.string().nullable(),
placeholder: z.string().nullable(),
Expand Down
3 changes: 2 additions & 1 deletion app/services/cms/models/StrapiBoxWithImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type Variant,
type BoxWithImageProps,
} from "~/components/BoxWithImage";
import { buildRichTextValidation } from "~/services/validation/richtext";
import { omitNull } from "~/util/omitNull";
import { HasOptionalStrapiIdSchema } from "./HasStrapiId";
import { OptionalStrapiLinkIdentifierSchema } from "./HasStrapiLinkIdentifier";
Expand All @@ -21,7 +22,7 @@ const StrapiBoxWithImageSchema = z
.object({
heading: StrapiHeadingSchema.nullable(),
image: StrapiImageSchema,
content: z.string().nullable(),
content: buildRichTextValidation().nullable(),
outerBackground: StrapiBackgroundSchema.nullable(),
variant: z.enum([firstWidth, ...widths]).nullable(),
container: StrapiContainerSchema,
Expand Down
3 changes: 2 additions & 1 deletion app/services/cms/models/StrapiDetails.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import pick from "lodash/pick";
import { z } from "zod";
import { type DetailsProps } from "~/components/Details";
import { buildRichTextValidation } from "~/services/validation/richtext";
import { HasOptionalStrapiIdSchema } from "./HasStrapiId";

export const StrapiDetailsSchema = z
.object({
title: z.string(),
content: z.string(),
content: buildRichTextValidation(),
})
.merge(HasOptionalStrapiIdSchema);

Expand Down
5 changes: 3 additions & 2 deletions app/services/cms/models/StrapiInfoBoxItem.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
import pick from "lodash/pick";
import { z } from "zod";
import type { InfoBoxItemProps } from "~/components/InfoBoxItem";
import { StrapiHeadingSchema } from "~/services/cms/models/StrapiHeading";
import {
getInlineNoticeProps,
StrapiInlineNoticeSchema,
} from "~/services/cms/models/StrapiInlineNotice";
import { buildRichTextValidation } from "~/services/validation/richtext";
import { omitNull } from "~/util/omitNull";
import { HasOptionalStrapiIdSchema } from "./HasStrapiId";
import { OptionalStrapiLinkIdentifierSchema } from "./HasStrapiLinkIdentifier";
import { StrapiButtonSchema } from "./StrapiButton";
import { getDetailsProps, StrapiDetailsSchema } from "./StrapiDetails";
import { StrapiHeadingSchema } from "./StrapiHeading";
import { StrapiImageSchema, getImageProps } from "./StrapiImage";

export const StrapiInfoBoxItemSchema = z
.object({
label: StrapiHeadingSchema.nullable(),
headline: StrapiHeadingSchema.nullable(),
image: StrapiImageSchema.nullable(),
content: z.string().nullable(),
content: buildRichTextValidation().nullable(),
detailsSummary: z.array(StrapiDetailsSchema),
inlineNotice: z.array(StrapiInlineNoticeSchema),
buttons: z.array(StrapiButtonSchema),
Expand Down
3 changes: 2 additions & 1 deletion app/services/cms/models/StrapiInlineNotice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pick from "lodash/pick";
import { z } from "zod";
import { type InlineNoticeProps } from "~/components/InlineNotice";
import { buildRichTextValidation } from "~/services/validation/richtext";
import { omitNull } from "~/util/omitNull";
import { HasOptionalStrapiIdSchema } from "./HasStrapiId";
import { OptionalStrapiLinkIdentifierSchema } from "./HasStrapiLinkIdentifier";
Expand All @@ -12,7 +13,7 @@ export const StrapiInlineNoticeSchema = z
title: z.string(),
tagName: z.enum(["h1", "h2", "h3", "h4", "h5", "h6", "p", "div"]),
look: z.enum(["warning", "tips"]),
content: z.string().nullable(),
content: buildRichTextValidation().nullable(),
container: StrapiContainerSchema,
outerBackground: StrapiBackgroundSchema.nullable(),
})
Expand Down
Loading
Loading