Feature/remark rehype (#1622)

Make remark compatible with rehype plugins so we can sanitize the content with rehype-sanitize-plugin.

Co-authored-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2021-04-21 12:05:37 +02:00
committed by GitHub
parent 8b1c56c43d
commit b5d4d7f75c
13 changed files with 15043 additions and 5986 deletions

View File

@@ -56,6 +56,7 @@ type Props = {
children: ReactNode;
level: number;
permalink: string;
id?: string;
};
function flatten(text: string, child: any): any {
@@ -71,14 +72,14 @@ export function headingToAnchorId(heading: string) {
return heading.toLowerCase().replace(/\W/g, "-");
}
const MarkdownHeadingRenderer: FC<Props> = ({ children, level, permalink }) => {
const MarkdownHeadingRenderer: FC<Props> = ({ children, level, permalink, id }) => {
const [copying, setCopying] = useState(false);
const [t] = useTranslation("repos");
const location = useLocation();
const history = useHistory();
const reactChildren = React.Children.toArray(children);
const heading = reactChildren.reduce(flatten, "");
const anchorId = headingToAnchorId(heading);
const anchorId = id || headingToAnchorId(heading);
const copyPermalink = (event: React.MouseEvent) => {
event.preventDefault();
setCopying(true);
@@ -93,7 +94,7 @@ const MarkdownHeadingRenderer: FC<Props> = ({ children, level, permalink }) => {
<Icon name="link" onClick={copyPermalink} />
</Tooltip>
);
const headingElement = React.createElement("h" + level, {}, [...reactChildren, CopyButton]);
const headingElement = React.createElement("h" + level, {id: anchorId}, [...reactChildren, CopyButton]);
const href = urls.withContextPath(location.pathname + "#" + anchorId);
const permalinkHref =
window.location.protocol +
@@ -102,7 +103,7 @@ const MarkdownHeadingRenderer: FC<Props> = ({ children, level, permalink }) => {
urls.withContextPath((permalink || location.pathname) + "#" + anchorId);
return (
<Link id={`${anchorId}`} className="anchor" href={href}>
<Link className="anchor" href={href}>
{headingElement}
</Link>
);

View File

@@ -34,6 +34,7 @@ import MarkdownInlineXml from "../__resources__/markdown-inline-xml.md";
import MarkdownLinks from "../__resources__/markdown-links.md";
import MarkdownCommitLinks from "../__resources__/markdown-commit-link.md";
import MarkdownXss from "../__resources__/markdown-xss.md";
import MarkdownChangelog from "../__resources__/markdown-changelog.md";
import Title from "../layout/Title";
import { Subtitle } from "../layout";
import { MemoryRouter } from "react-router-dom";
@@ -60,7 +61,7 @@ storiesOf("MarkdownView", module)
.add("Links", () => <MarkdownView content={MarkdownLinks} basePath="/" />)
.add("Header Anchor Links", () => (
<MarkdownView
content={TestPage}
content={MarkdownChangelog}
basePath={"/"}
permalink={"/?path=/story/markdownview--header-anchor-links"}
enableAnchorHeadings={true}

View File

@@ -23,8 +23,11 @@
*/
import React, { FC } from "react";
import { RouteComponentProps, withRouter } from "react-router-dom";
import Markdown from "react-markdown";
import MarkdownWithHtml from "react-markdown/with-html";
import unified from "unified";
import parseMarkdown from "remark-parse";
import sanitize from "rehype-sanitize";
import remark2rehype from "remark-rehype";
import rehype2react from "rehype-react";
import gfm from "remark-gfm";
import { binder } from "@scm-manager/ui-extensions";
import ErrorBoundary from "../ErrorBoundary";
@@ -32,10 +35,17 @@ import { create as createMarkdownHeadingRenderer } from "./MarkdownHeadingRender
import { create as createMarkdownLinkRenderer } from "./MarkdownLinkRenderer";
import { useTranslation, WithTranslation, withTranslation } from "react-i18next";
import Notification from "../Notification";
import { createTransformer } from "./remarkChangesetShortLinkParser";
import { createTransformer as createChangesetShortlinkParser } from "./remarkChangesetShortLinkParser";
import { createTransformer as createValuelessTextAdapter } from "./remarkValuelessTextAdapter";
import MarkdownCodeRenderer from "./MarkdownCodeRenderer";
import { AstPlugin } from "./PluginApi";
import createMdastPlugin from "./createMdastPlugin";
// @ts-ignore
import gh from "hast-util-sanitize/lib/github";
import raw from "rehype-raw";
import slug from "rehype-slug";
import merge from "deepmerge";
import { createComponentList } from "./createComponentList";
type Props = RouteComponentProps &
WithTranslation & {
@@ -112,7 +122,8 @@ class MarkdownView extends React.Component<Props, State> {
if (contentRef && hash) {
// we query only child elements, to avoid strange scrolling with multiple
// markdown elements on one page.
const element = contentRef.querySelector(hash);
const elementId = decodeURIComponent(hash.substring(1) /* remove # */);
const element = contentRef.querySelector(`[id="${elementId}"]`);
if (element && element.scrollIntoView) {
element.scrollIntoView();
}
@@ -133,42 +144,65 @@ class MarkdownView extends React.Component<Props, State> {
} = this.props;
const rendererFactory = binder.getExtension("markdown-renderer-factory");
let rendererList = renderers;
let remarkRendererList = renderers;
if (rendererFactory) {
rendererList = rendererFactory(renderContext);
remarkRendererList = rendererFactory(renderContext);
}
if (!rendererList) {
rendererList = {};
if (!remarkRendererList) {
remarkRendererList = {};
}
if (enableAnchorHeadings && permalink && !rendererList.heading) {
rendererList.heading = createMarkdownHeadingRenderer(permalink);
if (enableAnchorHeadings && permalink && !remarkRendererList.heading) {
remarkRendererList.heading = createMarkdownHeadingRenderer(permalink);
}
if (basePath && !rendererList.link) {
rendererList.link = createMarkdownLinkRenderer(basePath);
if (basePath && !remarkRendererList.link) {
remarkRendererList.link = createMarkdownLinkRenderer(basePath);
}
if (!rendererList.code) {
rendererList.code = MarkdownCodeRenderer;
if (!remarkRendererList.code) {
remarkRendererList.code = MarkdownCodeRenderer;
}
const plugins = [...mdastPlugins, createTransformer(t)].map(createMdastPlugin);
const remarkPlugins = [...mdastPlugins, createChangesetShortlinkParser(t), createValuelessTextAdapter()].map(
createMdastPlugin
);
const baseProps = {
className: "content is-word-break",
renderers: rendererList,
plugins: [gfm],
astPlugins: plugins,
children: content
};
let processor = unified()
.use(parseMarkdown)
.use(gfm)
.use(remarkPlugins)
.use(remark2rehype, { allowDangerousHtml: true });
if (!skipHtml) {
processor = processor.use(raw);
}
processor = processor
.use(slug)
.use(
sanitize,
merge(gh, {
attributes: {
code: ["className"] // Allow className for code elements, this is necessary to extract the code language
},
clobberPrefix: "" // Do not prefix user-provided ids and class names
})
)
.use(rehype2react, {
createElement: React.createElement,
passNode: true,
components: createComponentList(remarkRendererList, { permalink })
});
const renderedMarkdown: any = processor.processSync(content).result;
return (
<ErrorBoundary fallback={MarkdownErrorNotification}>
<div ref={el => this.setState({ contentRef: el })}>
{skipHtml ? <Markdown {...baseProps} /> : <MarkdownWithHtml {...baseProps} allowDangerousHtml={true} />}
<div ref={el => this.setState({ contentRef: el })} className="content is-word-break">
{renderedMarkdown}
</div>
</ErrorBoundary>
);

View File

@@ -0,0 +1,101 @@
import {
createRemark2RehypeCodeRendererAdapter,
createRemark2RehypeHeadingRendererAdapterFactory,
createRemark2RehypeLinkRendererAdapter
} from "./remarkToRehypeRendererAdapters";
export type CreateComponentListOptions = {
permalink?: string;
};
export const createComponentList = (
remarkRendererList: Record<string, any>,
{ permalink }: CreateComponentListOptions
) => {
const components: Record<string, any> = {};
if (remarkRendererList.code) {
components.code = createRemark2RehypeCodeRendererAdapter(remarkRendererList.code);
}
if (remarkRendererList.link) {
components.a = createRemark2RehypeLinkRendererAdapter(remarkRendererList.link);
}
if (remarkRendererList.heading) {
const createHeadingRendererAdapter = createRemark2RehypeHeadingRendererAdapterFactory(
remarkRendererList.heading,
permalink
);
components.h1 = createHeadingRendererAdapter(1);
components.h2 = createHeadingRendererAdapter(2);
components.h3 = createHeadingRendererAdapter(3);
components.h4 = createHeadingRendererAdapter(4);
components.h5 = createHeadingRendererAdapter(5);
components.h6 = createHeadingRendererAdapter(6);
}
if (remarkRendererList.break) {
components.br = remarkRendererList.break;
}
if (remarkRendererList.delete) {
components.del = remarkRendererList.delete;
}
if (remarkRendererList.emphasis) {
components.em = remarkRendererList.emphasis;
}
if (remarkRendererList.blockquote) {
components.blockquote = remarkRendererList.blockquote;
}
if (remarkRendererList.image) {
components.img = remarkRendererList.image;
}
if (remarkRendererList.list) {
components.ol = remarkRendererList.list;
components.ul = remarkRendererList.list;
}
if (remarkRendererList.listItem) {
components.li = remarkRendererList.listItem;
}
if (remarkRendererList.paragraph) {
components.p = remarkRendererList.paragraph;
}
if (remarkRendererList.strong) {
components.strong = remarkRendererList.strong;
}
if (remarkRendererList.table) {
components.table = remarkRendererList.table;
}
if (remarkRendererList.tableHead) {
components.thead = remarkRendererList.tableHead;
}
if (remarkRendererList.tableBody) {
components.tbody = remarkRendererList.tableBody;
}
if (remarkRendererList.tableRow) {
components.tr = remarkRendererList.tableRow;
}
if (remarkRendererList.tableCell) {
components.td = remarkRendererList.tableCell;
components.th = remarkRendererList.tableCell;
}
if (remarkRendererList.thematicBreak) {
components.hr = remarkRendererList.thematicBreak;
}
return components;
};

View File

@@ -2,11 +2,18 @@ import { AstPlugin } from "./PluginApi";
// @ts-ignore No types available
import visit from "unist-util-visit";
/**
* Transforms the abstraction layer into an actual remark plugin to be used with unified.
*
* @see https://unifiedjs.com/learn/guide/create-a-plugin/
*/
export default function createMdastPlugin(plugin: AstPlugin): any {
return (tree: any) => {
plugin({
visit: (type, visitor) => visit(tree, type, visitor)
});
return tree;
return function attach() {
return function transform(tree: any) {
plugin({
visit: (type, visitor) => visit(tree, type, visitor)
});
return tree;
};
};
}

View File

@@ -0,0 +1,34 @@
import React from "react";
export const createRemark2RehypeCodeRendererAdapter = (remarkRenderer: any) => {
return ({ node, children }: any) => {
children = children || [];
const renderProps = {
value: children[0],
language: Array.isArray(node.properties.className) ? node.properties.className[0].split("language-")[1] : ""
};
return React.createElement(remarkRenderer, renderProps, ...children);
};
};
export const createRemark2RehypeLinkRendererAdapter = (remarkRenderer: any) => {
return ({ node, children }: any) => {
const renderProps = {
href: node.properties.href || ""
};
children = children || [];
return React.createElement(remarkRenderer, renderProps, ...children);
};
};
export const createRemark2RehypeHeadingRendererAdapterFactory = (remarkRenderer: any, permalink?: string) => {
return (level: number) => ({ node, children }: any) => {
const renderProps = {
id: node.properties.id,
level,
permalink
};
children = children || [];
return React.createElement(remarkRenderer, renderProps, ...children);
};
};

View File

@@ -0,0 +1,41 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import { AstPlugin } from "./PluginApi";
import { Node, Parent } from "unist";
/**
* Some existing remark plugins (e.g. changesetShortLinkParser or the plugin for issue tracker links) create
* text nodes without values but with children. This does not get parsed properly by remark2rehype.
* This remark-plugin transforms all of these invalid text nodes to valid paragraphs.
*/
export const createTransformer = (): AstPlugin => {
return ({ visit }) => {
visit("text", (node: Node, index: number, parent?: Parent) => {
if (node.value === undefined && Array.isArray(node.children) && node.children.length > 0) {
node.type = "paragraph";
}
});
};
};