Skip to content

Commit

Permalink
Merge branch 'main' into feat/docs-markdown
Browse files Browse the repository at this point in the history
  • Loading branch information
peterpeterparker authored Sep 12, 2024
2 parents 2691175 + 642e497 commit c99af7a
Show file tree
Hide file tree
Showing 7 changed files with 360 additions and 0 deletions.
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"dependencies": {
"dompurify": "^3.1.6",
"html5-qrcode": "^2.3.8",
"marked": "^9.1.0",
"qr-creator": "^1.0.0"
},
"keywords": [
Expand Down
1 change: 1 addition & 0 deletions src/lib/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export { default as ItemAction } from "./components/ItemAction.svelte";
export { default as KeyValuePair } from "./components/KeyValuePair.svelte";
export { default as KeyValuePairInfo } from "./components/KeyValuePairInfo.svelte";
export { default as Layout } from "./components/Layout.svelte";
export { default as Markdown } from "./components/Markdown.svelte";
export { default as Menu } from "./components/Menu.svelte";
export { default as MenuButton } from "./components/MenuButton.svelte";
export { default as MenuItem } from "./components/MenuItem.svelte";
Expand Down
27 changes: 27 additions & 0 deletions src/lib/components/Markdown.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script lang="ts">
import { markdownToHTML } from "$lib/utils/markdown.utils";
import Spinner from "$lib/components/Spinner.svelte";
import Html from "$lib/components/Html.svelte";
export let text: string | undefined;
let html: string | undefined;
let error = false;
const transform = async (text: string) => {
try {
html = await markdownToHTML(text);
} catch (err) {
console.error(err);
error = true;
}
};
$: if (text !== undefined) transform(text).then();
</script>

{#if error}
<p data-tid="markdown-text">{text}</p>
{:else if html === undefined}
<Spinner inline />
{:else}
<Html text={html} />
{/if}
106 changes: 106 additions & 0 deletions src/lib/utils/markdown.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { isNullish } from "@dfinity/utils";
import type { marked as markedTypes, Renderer } from "marked";

type Marked = typeof markedTypes;

export const targetBlankLinkRenderer = (
href: string | null | undefined,
title: string | null | undefined,
text: string,
): string =>
`<a${
href === null || href === undefined
? ""
: ` target="_blank" rel="noopener noreferrer" href="${href}"`
}${title === null || title === undefined ? "" : ` title="${title}"`}>${
text.length === 0 ? (href ?? title) : text
}</a>`;

/**
* Based on https://github.com/markedjs/marked/blob/master/src/Renderer.js#L186
* @returns <a> tag to image
*/
export const imageToLinkRenderer = (
src: string | null | undefined,
title: string | null | undefined,
alt: string,
): string => {
if (src === undefined || src === null || src?.length === 0) {
return alt;
}
const fileExtention = src.includes(".")
? (src.split(".").pop() as string)
: "";
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-type
const typeProp =
fileExtention === "" ? undefined : ` type="image/${fileExtention}"`;
const titleDefined = title !== undefined && title !== null;
const titleProp = titleDefined ? ` title="${title}"` : undefined;
const text = alt === "" ? (titleDefined ? title : src) : alt;

return `<a href="${src}" target="_blank" rel="noopener noreferrer"${
typeProp ?? ""
}${titleProp ?? ""}>${text}</a>`;
};

const escapeHtml = (html: string): string =>
html.replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escapeSvgs = (html: string): string =>
html.replace(/<svg[^>]*>[\s\S]*?<\/svg>/gi, escapeHtml);

/**
* Escape <img> tags or convert them to links
*/
const transformImg = (img: string): string => {
const src = img.match(/src="([^"]+)"/)?.[1];
const alt = img.match(/alt="([^"]+)"/)?.[1] || "img";
const title = img.match(/title="([^"]+)"/)?.[1];
const shouldEscape = isNullish(src) || src.startsWith("data:image");
const imageHtml = shouldEscape
? escapeHtml(img)
: imageToLinkRenderer(src, title, alt);

return imageHtml;
};

/** Avoid <img> tags; instead, apply the same logic as for markdown images by either escaping them or converting them to links. */
export const htmlRenderer = (html: string): string =>
/<img\s+[^>]*>/gi.test(html) ? transformImg(html) : html;

/**
* Marked.js renderer for proposal summary.
* Customized renderers
* - targetBlankLinkRenderer
* - imageToLinkRenderer
* - htmlRenderer
*
* @param marked
*/
const proposalSummaryRenderer = (marked: Marked): Renderer => {
const renderer = new marked.Renderer();

renderer.link = targetBlankLinkRenderer;
renderer.image = imageToLinkRenderer;
renderer.html = htmlRenderer;

return renderer;
};

/**
* Uses markedjs.
* Escape or transform to links some raw HTML tags (img, svg)
* @see {@link https://github.com/markedjs/marked}
*/
export const markdownToHTML = async (text: string): Promise<string> => {
// Replace the SVG elements in the HTML with their escaped versions to improve security.
// It's not possible to do it with html renderer because the svg consists of multiple tags.
// One edge case is not covered: if the svg is inside the <code> tag, it will be rendered as with &lt; & &gt; instead of "<" & ">"
const escapedText = escapeSvgs(text);

// The dynamic import cannot be analyzed by Vite. As it is intended, we use the /* @vite-ignore */ comment inside the import() call to suppress this warning.
const { marked }: { marked: Marked } = await import("marked");

return marked(escapedText, {
renderer: proposalSummaryRenderer(marked),
});
};
56 changes: 56 additions & 0 deletions src/tests/lib/components/Markdown.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Markdown from "$lib/components/Markdown.svelte";
import { render, waitFor } from "@testing-library/svelte";

const mockWaiting = (seconds: number, value?: unknown) =>
new Promise((resolve) => setTimeout(() => resolve(value), seconds * 1000));

const silentConsoleErrors = () => vi.spyOn(console, "error").mockReturnValue();

let transform: (unknown: unknown) => Promise<unknown>;
vi.mock("$lib/utils/markdown.utils", () => ({
markdownToHTML: (value: string) => transform(value),
}));

describe("Markdown", () => {
beforeEach(() => {
silentConsoleErrors();
});
afterAll(() => {
vi.clearAllMocks();
});

it("should render html content", async () => {
transform = (value) => Promise.resolve(value);
const { getByText, queryByTestId } = render(Markdown, {
props: { text: "test1" },
});
await waitFor(() => expect(getByText("test1")).not.toBeNull());
expect(queryByTestId("markdown-text")).toBeNull();
});

it("should render spinner until the text is transformed", async () => {
transform = (value) => mockWaiting(0.5, value);
const { container, queryByText, queryByTestId } = render(Markdown, {
props: { text: "test2" },
});

expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.querySelector("circle")).toBeInTheDocument();
await waitFor(() => expect(queryByText("test2")).not.toBeNull());
expect(queryByTestId("markdown-text")).toBeNull();
expect(container.querySelector("svg")).not.toBeInTheDocument();
});

it("should render text content on marked error", async () => {
transform = () => {
throw new Error("test");
};
const { queryByTestId, queryByText } = render(Markdown, {
props: { text: "text" },
});
await waitFor(() =>
expect(queryByTestId("markdown-text")).toBeInTheDocument(),
);
expect(queryByText("text")).toBeInTheDocument();
});
});
151 changes: 151 additions & 0 deletions src/tests/lib/utils/markdown.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import {
htmlRenderer,
imageToLinkRenderer,
markdownToHTML,
targetBlankLinkRenderer,
} from "$lib/utils/markdown.utils";

describe("markdown.utils", () => {
describe("targetBlankLinkRenderer", () => {
it("should return rendered a tag", () => {
expect(targetBlankLinkRenderer("/", "title", "text")).toEqual(
`<a target="_blank" rel="noopener noreferrer" href="/" title="title">text</a>`,
);
});

it("should skip title if not provided", () => {
expect(targetBlankLinkRenderer("/", null, "text")).toEqual(
`<a target="_blank" rel="noopener noreferrer" href="/">text</a>`,
);
expect(targetBlankLinkRenderer("/", undefined, "text")).toEqual(
`<a target="_blank" rel="noopener noreferrer" href="/">text</a>`,
);
});

it("should skip href if not provided", () => {
expect(targetBlankLinkRenderer(null, "title", "text")).toEqual(
`<a title="title">text</a>`,
);
expect(targetBlankLinkRenderer(undefined, "title", "text")).toEqual(
`<a title="title">text</a>`,
);
});

it("should render href of title if no text", () => {
expect(targetBlankLinkRenderer("/", "title", "")).toEqual(
`<a target="_blank" rel="noopener noreferrer" href="/" title="title">/</a>`,
);
expect(targetBlankLinkRenderer(null, "title", "")).toEqual(
`<a title="title">title</a>`,
);
});
});

describe("imageToLinkRenderer", () => {
it("should render link instead of image", () => {
expect(imageToLinkRenderer("image.png", "title", "alt")).toEqual(
`<a href="image.png" target="_blank" rel="noopener noreferrer" type="image/png" title="title">alt</a>`,
);
});

it("should render link without alt", () => {
expect(imageToLinkRenderer("image.png", "title", "")).toEqual(
`<a href="image.png" target="_blank" rel="noopener noreferrer" type="image/png" title="title">title</a>`,
);
});

it("should render link without title", () => {
expect(imageToLinkRenderer("image.png", undefined, "alt")).toEqual(
`<a href="image.png" target="_blank" rel="noopener noreferrer" type="image/png">alt</a>`,
);
expect(imageToLinkRenderer("image.png", null, "alt")).toEqual(
`<a href="image.png" target="_blank" rel="noopener noreferrer" type="image/png">alt</a>`,
);
});

it("should render link without alt and title", () => {
expect(imageToLinkRenderer("image.png", undefined, "")).toEqual(
`<a href="image.png" target="_blank" rel="noopener noreferrer" type="image/png">image.png</a>`,
);
expect(imageToLinkRenderer("image.png", null, "")).toEqual(
`<a href="image.png" target="_blank" rel="noopener noreferrer" type="image/png">image.png</a>`,
);
});

it("should not render mime type withoug file extention", () => {
expect(imageToLinkRenderer("/image", undefined, "")).toEqual(
`<a href="/image" target="_blank" rel="noopener noreferrer">/image</a>`,
);
});

it("should render empty string w/o href", () => {
expect(imageToLinkRenderer("", undefined, "")).toEqual(``);
expect(imageToLinkRenderer(null, null, "")).toEqual(``);
expect(imageToLinkRenderer("", "title", "")).toEqual(``);
expect(imageToLinkRenderer(undefined, "title", "")).toEqual(``);
});

it("should render alt w/o href", () => {
expect(imageToLinkRenderer("", undefined, "alt")).toEqual(`alt`);
expect(imageToLinkRenderer(undefined, null, "alt")).toEqual(`alt`);
expect(imageToLinkRenderer(null, null, "alt")).toEqual(`alt`);
expect(imageToLinkRenderer("", "", "alt")).toEqual(`alt`);
});
});

describe("htmlRenderer", () => {
it("should apply imageToLinkRenderer to img tag", () => {
const src = "image.png";
const title = "title";
const alt = "alt";
const expectation = imageToLinkRenderer(src, title, alt);
expect(
htmlRenderer(`<img src="${src}" alt="${alt}" title="${title}" />`),
).toEqual(expectation);
expect(
htmlRenderer(`<img src="${src}" alt="${alt}" title="${title}">...`),
).toEqual(expectation);
expect(
htmlRenderer(
`<img data-test="123" src="${src}" alt="${alt}" title="${title}" />`,
),
).toEqual(expectation);
});

it("should escape img tag with data src", () => {
expect(htmlRenderer(`<img src=""data:image/...">`)).toEqual(
`&lt;img src=""data:image/..."&gt;`,
);
});
});

describe("markdown", () => {
it("should call markedjs/marked", async () => {
expect(await markdownToHTML("test")).toBe("<p>test</p>\n");
});

it("should escape all SVGs", async () => {
expect(await markdownToHTML("<h1><svg>...</svg></h1>")).toBe(
"<h1>&lt;svg&gt;...&lt;/svg&gt;</h1>",
);
});

it("should render link with a custom renderer", async () => {
expect(await markdownToHTML("[test](https://test.com)")).toBe(
'<p><a target="_blank" rel="noopener noreferrer" href="https://test.com">test</a></p>\n',
);
});

it("should render image link with a custom renderer", async () => {
expect(await markdownToHTML(`![alt](image.png "title")`)).toBe(
`<p><a href="image.png" target="_blank" rel="noopener noreferrer" type="image/png" title="title">alt</a></p>\n`,
);
});

it("should render image provided as html with a custom renderer", async () => {
expect(await markdownToHTML(`<img src="image.png" alt="title" />`)).toBe(
`<a href="image.png" target="_blank" rel="noopener noreferrer" type="image/png">title</a>`,
);
});
});
});

0 comments on commit c99af7a

Please sign in to comment.