mirror of
				https://github.com/livebook-dev/livebook.git
				synced 2025-11-01 00:06:04 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			287 lines
		
	
	
	
		
			8 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			287 lines
		
	
	
	
		
			8 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| import morphdom from "morphdom";
 | |
| 
 | |
| import { unified } from "unified";
 | |
| import remarkParse from "remark-parse";
 | |
| import remarkGfm from "remark-gfm";
 | |
| import remarkMath from "remark-math";
 | |
| import remarkRehype from "remark-rehype";
 | |
| import rehypeRaw from "rehype-raw";
 | |
| import rehypeKatex from "rehype-katex";
 | |
| import rehypeParse from "rehype-parse";
 | |
| import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
 | |
| import rehypeStringify from "rehype-stringify";
 | |
| 
 | |
| import "katex/contrib/copy-tex";
 | |
| 
 | |
| import { visit } from "unist-util-visit";
 | |
| import { toText } from "hast-util-to-text";
 | |
| import { removePosition } from "unist-util-remove-position";
 | |
| 
 | |
| import { highlight } from "../hooks/cell_editor/live_editor/highlight";
 | |
| import { renderMermaid } from "./markdown/mermaid";
 | |
| import { escapeHtml } from "../lib/utils";
 | |
| 
 | |
| /**
 | |
|  * Renders markdown content in the given container.
 | |
|  */
 | |
| class Markdown {
 | |
|   constructor(
 | |
|     container,
 | |
|     content,
 | |
|     {
 | |
|       baseUrl = null,
 | |
|       defaultCodeLanguage = null,
 | |
|       emptyText = "",
 | |
|       allowedUriSchemes = [],
 | |
|       useDarkTheme = false,
 | |
|     } = {},
 | |
|   ) {
 | |
|     this.container = container;
 | |
|     this.content = content;
 | |
|     this.baseUrl = baseUrl;
 | |
|     this.defaultCodeLanguage = defaultCodeLanguage;
 | |
|     this.emptyText = emptyText;
 | |
|     this.allowedUriSchemes = allowedUriSchemes;
 | |
|     this.useDarkTheme = useDarkTheme;
 | |
| 
 | |
|     this.render();
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Sets new markdown content to be rendered in the container.
 | |
|    */
 | |
|   setContent(content) {
 | |
|     this.content = content;
 | |
|     this.render();
 | |
|   }
 | |
| 
 | |
|   /** @private */
 | |
|   render() {
 | |
|     this.getHtml().then((html) => {
 | |
|       // Wrap the HTML in another element, so that we
 | |
|       // can use morphdom's childrenOnly option
 | |
|       const wrappedHtml = `<div>${html}</div>`;
 | |
|       morphdom(this.container, wrappedHtml, { childrenOnly: true });
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   /** @private */
 | |
|   getHtml() {
 | |
|     return (
 | |
|       unified()
 | |
|         .use(remarkParse)
 | |
|         .use(remarkGfm)
 | |
|         .use(remarkMath)
 | |
|         .use(remarkPrepareMermaid)
 | |
|         .use(remarkSyntaxHiglight, {
 | |
|           highlight,
 | |
|           defaultLanguage: this.defaultCodeLanguage,
 | |
|         })
 | |
|         // We keep the HTML nodes, parse with rehype-raw and then sanitize
 | |
|         .use(remarkRehype, { allowDangerousHtml: true })
 | |
|         .use(rehypeRaw)
 | |
|         .use(rehypeExpandUrls, { baseUrl: this.baseUrl })
 | |
|         .use(rehypeSanitize, sanitizeSchema(this.allowedUriSchemes))
 | |
|         .use(rehypeKatex)
 | |
|         .use(rehypeMermaid, { useDarkTheme: this.useDarkTheme })
 | |
|         .use(rehypeExternalLinks, { baseUrl: this.baseUrl })
 | |
|         .use(rehypeStringify, {
 | |
|           // Mermaid allows HTML tags, such as <br /> in diagram labels.
 | |
|           // We parse the SVG earlier, then stringify it, closeEmptyElements
 | |
|           // makes sure we emit <br /> as a self-closing tag, rather than
 | |
|           // <br></br> (which the browser would interpret as two breaks).
 | |
|           // See https://github.com/syntax-tree/hast-util-to-html/blob/9d7a2f7d63ec2d11b3ae5a5e4201181fdeedf6ea/lib/handle/element.js#L66-L69
 | |
|           closeEmptyElements: true,
 | |
|         })
 | |
|         .process(this.content)
 | |
|         .then((file) => String(file))
 | |
|         .catch((error) => {
 | |
|           console.error(`Failed to render markdown, reason: ${error.message}`);
 | |
|         })
 | |
|         .then((html) => {
 | |
|           if (html) {
 | |
|             return html;
 | |
|           } else {
 | |
|             return `
 | |
|               <div class="text-gray-300">
 | |
|                 ${this.emptyText}
 | |
|               </div>
 | |
|             `;
 | |
|           }
 | |
|         })
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| export default Markdown;
 | |
| 
 | |
| // Plugins
 | |
| 
 | |
| function sanitizeSchema(allowedUriSchemes) {
 | |
|   // Allow class and style attributes on tags for syntax highlighting,
 | |
|   // remarkMath tags, or user-written styles
 | |
| 
 | |
|   return {
 | |
|     ...defaultSchema,
 | |
|     attributes: {
 | |
|       ...defaultSchema.attributes,
 | |
|       "*": [...(defaultSchema.attributes["*"] || []), "className", "style"],
 | |
|     },
 | |
|     protocols: {
 | |
|       ...defaultSchema.protocols,
 | |
|       href: [...defaultSchema.protocols.href, ...allowedUriSchemes],
 | |
|     },
 | |
|   };
 | |
| }
 | |
| 
 | |
| // Highlights code snippets with the given function (possibly asynchronous)
 | |
| function remarkSyntaxHiglight(options) {
 | |
|   return (ast) => {
 | |
|     const promises = [];
 | |
| 
 | |
|     visit(ast, "code", (node) => {
 | |
|       const language = node.lang || options.defaultLanguage;
 | |
| 
 | |
|       if (language) {
 | |
|         function updateNode(html) {
 | |
|           node.type = "html";
 | |
|           node.value = `<pre><code>${html}</code></pre>`;
 | |
|         }
 | |
| 
 | |
|         const result = options.highlight(node.value, language);
 | |
| 
 | |
|         if (result && typeof result.then === "function") {
 | |
|           const promise = Promise.resolve(result).then(updateNode);
 | |
|           promises.push(promise);
 | |
|         } else {
 | |
|           updateNode(result);
 | |
|         }
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     return Promise.all(promises).then(() => null);
 | |
|   };
 | |
| }
 | |
| 
 | |
| // Expands relative URLs against the given base url
 | |
| // and deals with ".." in URLs
 | |
| function rehypeExpandUrls(options) {
 | |
|   return (ast) => {
 | |
|     if (options.baseUrl) {
 | |
|       visit(ast, "element", (node) => {
 | |
|         if (node.tagName === "a" && node.properties) {
 | |
|           const url = node.properties.href;
 | |
| 
 | |
|           if (
 | |
|             url &&
 | |
|             !isAbsoluteUrl(url) &&
 | |
|             !isInternalUrl(url) &&
 | |
|             !isPageAnchor(url)
 | |
|           ) {
 | |
|             node.properties.href = urlAppend(options.baseUrl, url);
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         if (node.tagName === "img" && node.properties) {
 | |
|           const url = node.properties.src;
 | |
| 
 | |
|           if (url && !isAbsoluteUrl(url) && !isInternalUrl(url)) {
 | |
|             node.properties.src = urlAppend(options.baseUrl, url);
 | |
|           }
 | |
|         }
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     // Browser normalizes URLs with ".." so we use a "__parent__"
 | |
|     // modifier instead and handle it on the server
 | |
|     visit(ast, "element", (node) => {
 | |
|       if (node.tagName === "a" && node.properties && node.properties.href) {
 | |
|         node.properties.href = node.properties.href
 | |
|           .split("/")
 | |
|           .map((part) => (part === ".." ? "__parent__" : part))
 | |
|           .join("/");
 | |
|       }
 | |
|     });
 | |
|   };
 | |
| }
 | |
| 
 | |
| const parseHtml = unified().use(rehypeParse, { fragment: true });
 | |
| 
 | |
| function remarkPrepareMermaid(options) {
 | |
|   return (ast) => {
 | |
|     visit(ast, "code", (node, index, parent) => {
 | |
|       if (node.lang === "mermaid") {
 | |
|         node.type = "html";
 | |
|         node.value = `
 | |
|           <div class="mermaid">${escapeHtml(node.value)}</div>
 | |
|         `;
 | |
|       }
 | |
|     });
 | |
|   };
 | |
| }
 | |
| 
 | |
| function rehypeMermaid(options) {
 | |
|   return (ast) => {
 | |
|     const promises = [];
 | |
| 
 | |
|     visit(ast, "element", (element) => {
 | |
|       const classes =
 | |
|         element.properties && Array.isArray(element.properties.className)
 | |
|           ? element.properties.className
 | |
|           : [];
 | |
| 
 | |
|       if (classes.includes("mermaid")) {
 | |
|         function updateNode(html) {
 | |
|           const ast = parseHtml.parse(html);
 | |
|           removePosition(ast, true);
 | |
|           element.children = ast.children;
 | |
|         }
 | |
| 
 | |
|         const value = toText(element, { whitespace: "pre" });
 | |
|         const theme = options.useDarkTheme ? "dark" : "default";
 | |
|         const promise = renderMermaid(value, { theme }).then(updateNode);
 | |
|         promises.push(promise);
 | |
|       }
 | |
|     });
 | |
| 
 | |
|     return Promise.all(promises).then(() => null);
 | |
|   };
 | |
| }
 | |
| 
 | |
| // Modifies external links, so that they open in a new tab
 | |
| function rehypeExternalLinks(options) {
 | |
|   return (ast) => {
 | |
|     visit(ast, "element", (node) => {
 | |
|       if (node.properties && node.properties.href) {
 | |
|         const url = node.properties.href;
 | |
| 
 | |
|         if (isInternalUrl(url)) {
 | |
|           node.properties["data-phx-link"] =
 | |
|             options.baseUrl && url.startsWith(options.baseUrl)
 | |
|               ? "patch"
 | |
|               : "redirect";
 | |
|           node.properties["data-phx-link-state"] = "push";
 | |
|         } else if (isAbsoluteUrl(url)) {
 | |
|           node.properties.target = "_blank";
 | |
|           node.properties.rel = "noreferrer noopener";
 | |
|         }
 | |
|       }
 | |
|     });
 | |
|   };
 | |
| }
 | |
| 
 | |
| function isAbsoluteUrl(url) {
 | |
|   return /^(?:[a-z]+:)?\/\//i.test(url);
 | |
| }
 | |
| 
 | |
| function isPageAnchor(url) {
 | |
|   return url.startsWith("#");
 | |
| }
 | |
| 
 | |
| function isInternalUrl(url) {
 | |
|   return url.startsWith("/") || url.startsWith(window.location.origin);
 | |
| }
 | |
| 
 | |
| function urlAppend(url, relativePath) {
 | |
|   return url.replace(/\/$/, "") + "/" + relativePath;
 | |
| }
 |