mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-12-22 00:09:47 +01:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
101
scm-ui/ui-components/src/markdown/createComponentList.ts
Normal file
101
scm-ui/ui-components/src/markdown/createComponentList.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user