-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' into feat/docs-markdown
- Loading branch information
Showing
7 changed files
with
360 additions
and
0 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, "<").replace(/>/g, ">"); | ||
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 < & > 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), | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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( | ||
`<img src=""data:image/...">`, | ||
); | ||
}); | ||
}); | ||
|
||
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><svg>...</svg></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>`, | ||
); | ||
}); | ||
}); | ||
}); |