mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-06 03:34:57 +08:00
Switch client side markdown processing to remark (#495)
This commit is contained in:
parent
41a60a57c0
commit
e0febac309
4 changed files with 3484 additions and 144 deletions
|
@ -1,46 +1,19 @@
|
||||||
import marked from "marked";
|
|
||||||
import morphdom from "morphdom";
|
import morphdom from "morphdom";
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
import katex from "katex";
|
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 rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||||
|
import rehypeStringify from "rehype-stringify";
|
||||||
|
|
||||||
|
import { visit } from "unist-util-visit";
|
||||||
|
|
||||||
import { highlight } from "./live_editor/monaco";
|
import { highlight } from "./live_editor/monaco";
|
||||||
|
|
||||||
// Custom renderer overrides
|
|
||||||
const renderer = new marked.Renderer();
|
|
||||||
renderer.link = function (href, title, text) {
|
|
||||||
// Browser normalizes URLs with .. so we use a __parent__ modifier
|
|
||||||
// instead and handle it on the server
|
|
||||||
href = href
|
|
||||||
.split("/")
|
|
||||||
.map((part) => (part === ".." ? "__parent__" : part))
|
|
||||||
.join("/");
|
|
||||||
|
|
||||||
return marked.Renderer.prototype.link.call(this, href, title, text);
|
|
||||||
};
|
|
||||||
|
|
||||||
marked.setOptions({
|
|
||||||
renderer,
|
|
||||||
// Reuse Monaco highlighter for Markdown code blocks
|
|
||||||
highlight: (code, lang, callback) => {
|
|
||||||
highlight(code, lang)
|
|
||||||
.then((html) => callback(null, html))
|
|
||||||
.catch((error) => callback(error, null));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Modify external links, so that they open in a new tab.
|
|
||||||
// See https://github.com/cure53/DOMPurify/tree/main/demos#hook-to-open-all-links-in-a-new-window-link
|
|
||||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
|
||||||
if (node.tagName.toLowerCase() === "a") {
|
|
||||||
if (node.host !== window.location.host) {
|
|
||||||
node.setAttribute("target", "_blank");
|
|
||||||
node.setAttribute("rel", "noreferrer noopener");
|
|
||||||
} else {
|
|
||||||
node.setAttribute("data-phx-link", "redirect");
|
|
||||||
node.setAttribute("data-phx-link-state", "push");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders markdown content in the given container.
|
* Renders markdown content in the given container.
|
||||||
*/
|
*/
|
||||||
|
@ -62,58 +35,148 @@ class Markdown {
|
||||||
__render() {
|
__render() {
|
||||||
this.__getHtml().then((html) => {
|
this.__getHtml().then((html) => {
|
||||||
// Wrap the HTML in another element, so that we
|
// Wrap the HTML in another element, so that we
|
||||||
// can use morphdom's childrenOnly option.
|
// can use morphdom's childrenOnly option
|
||||||
const wrappedHtml = `<div>${html}</div>`;
|
const wrappedHtml = `<div>${html}</div>`;
|
||||||
|
|
||||||
morphdom(this.container, wrappedHtml, { childrenOnly: true });
|
morphdom(this.container, wrappedHtml, { childrenOnly: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
__getHtml() {
|
__getHtml() {
|
||||||
return new Promise((resolve, reject) => {
|
return (
|
||||||
// Marked requires a trailing slash in the base URL
|
unified()
|
||||||
const opts = { baseUrl: this.baseUrl + "/" };
|
.use(remarkParse)
|
||||||
|
.use(remarkGfm)
|
||||||
// Render math formulas using KaTeX.
|
.use(remarkMath)
|
||||||
// The resulting <span> tags will pass through
|
.use(remarkSyntaxHiglight, { highlight })
|
||||||
// marked.js and sanitization unchanged.
|
.use(remarkExpandUrls, { baseUrl: this.baseUrl })
|
||||||
//
|
// We keep the HTML nodes, parse with rehype-raw and then sanitize
|
||||||
// We render math before anything else, because passing
|
.use(remarkRehype, { allowDangerousHtml: true })
|
||||||
// TeX through markdown renderer may have undesired
|
.use(rehypeRaw)
|
||||||
// effects like rendering \\ as \.
|
.use(rehypeKatex)
|
||||||
const contentWithRenderedMath = this.__renderMathInString(this.content);
|
.use(rehypeSanitize, sanitizeSchema())
|
||||||
|
.use(rehypeExternalLinks)
|
||||||
marked(contentWithRenderedMath, opts, (error, html) => {
|
.use(rehypeStringify)
|
||||||
const sanitizedHtml = DOMPurify.sanitize(html);
|
.process(this.content)
|
||||||
|
.then((file) => String(file))
|
||||||
if (sanitizedHtml) {
|
.catch((error) => {
|
||||||
resolve(sanitizedHtml);
|
console.error(`Failed to render markdown, reason: ${error.message}`);
|
||||||
} else {
|
})
|
||||||
resolve(`
|
.then((html) => {
|
||||||
<div class="text-gray-300">
|
if (html) {
|
||||||
${this.emptyText}
|
return html;
|
||||||
</div>
|
} else {
|
||||||
`);
|
return `
|
||||||
}
|
<div class="text-gray-300">
|
||||||
});
|
${this.emptyText}
|
||||||
});
|
</div>
|
||||||
}
|
`;
|
||||||
|
}
|
||||||
// Replaces TeX formulas in string with rendered HTML using KaTeX.
|
})
|
||||||
__renderMathInString(string) {
|
|
||||||
return string.replace(
|
|
||||||
/(\${1,2})([\s\S]*?)\1/g,
|
|
||||||
(match, delimiter, math) => {
|
|
||||||
const displayMode = delimiter === "$$";
|
|
||||||
|
|
||||||
return katex.renderToString(math.trim(), {
|
|
||||||
displayMode,
|
|
||||||
throwOnError: false,
|
|
||||||
errorColor: "inherit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Markdown;
|
export default Markdown;
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
|
||||||
|
function sanitizeSchema() {
|
||||||
|
// Allow class ane style attributes on span tags for
|
||||||
|
// syntax highlighting and KaTeX tags
|
||||||
|
return {
|
||||||
|
...defaultSchema,
|
||||||
|
attributes: {
|
||||||
|
...defaultSchema.attributes,
|
||||||
|
span: [...(defaultSchema.attributes.span || []), "className", "style"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlights code snippets with the given function (possibly asynchronous)
|
||||||
|
function remarkSyntaxHiglight(options) {
|
||||||
|
return (ast) => {
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
visit(ast, "code", (node) => {
|
||||||
|
if (node.lang) {
|
||||||
|
function updateNode(html) {
|
||||||
|
node.type = "html";
|
||||||
|
node.value = `<pre><code>${html}</code></pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = options.highlight(node.value, node.lang);
|
||||||
|
|
||||||
|
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 remarkExpandUrls(options) {
|
||||||
|
return (ast) => {
|
||||||
|
if (options.baseUrl) {
|
||||||
|
visit(ast, "link", (node) => {
|
||||||
|
if (node.url && !isAbsoluteUrl(node.url)) {
|
||||||
|
node.url = urlAppend(options.baseUrl, node.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
visit(ast, "image", (node) => {
|
||||||
|
if (node.url && !isAbsoluteUrl(node.url)) {
|
||||||
|
node.url = urlAppend(options.baseUrl, node.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser normalizes URLs with ".." so we use a "__parent__"
|
||||||
|
// modifier instead and handle it on the server
|
||||||
|
visit(ast, "link", (node) => {
|
||||||
|
if (node.url) {
|
||||||
|
node.url = node.url
|
||||||
|
.split("/")
|
||||||
|
.map((part) => (part === ".." ? "__parent__" : part))
|
||||||
|
.join("/");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"] = "redirect";
|
||||||
|
node.properties["data-phx-link-state"] = "push";
|
||||||
|
} else {
|
||||||
|
node.properties.target = "_blank";
|
||||||
|
node.properties.rel = "noreferrer noopener";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsoluteUrl(url) {
|
||||||
|
return url.startsWith("http") || url.startsWith("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInternalUrl(url) {
|
||||||
|
return url.startsWith("/") || url.startsWith(window.location.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlAppend(url, relativePath) {
|
||||||
|
return url.replace(/\/$/, "") + "/" + relativePath;
|
||||||
|
}
|
||||||
|
|
3371
assets/package-lock.json
generated
3371
assets/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -12,20 +12,29 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^4.2.2",
|
"@fontsource/inter": "^4.2.2",
|
||||||
"@fontsource/jetbrains-mono": "^4.2.2",
|
"@fontsource/jetbrains-mono": "^4.2.2",
|
||||||
|
"assert": "^2.0.0",
|
||||||
"crypto-js": "^4.0.0",
|
"crypto-js": "^4.0.0",
|
||||||
"dompurify": "^2.2.6",
|
|
||||||
"hyperlist": "^1.0.0",
|
"hyperlist": "^1.0.0",
|
||||||
"katex": "^0.13.2",
|
|
||||||
"marked": "^2.0.0",
|
|
||||||
"monaco-editor": "^0.25.0",
|
"monaco-editor": "^0.25.0",
|
||||||
"morphdom": "^2.6.1",
|
"morphdom": "^2.6.1",
|
||||||
"phoenix": "file:../deps/phoenix",
|
"phoenix": "file:../deps/phoenix",
|
||||||
"phoenix_html": "file:../deps/phoenix_html",
|
"phoenix_html": "file:../deps/phoenix_html",
|
||||||
"phoenix_live_view": "file:../deps/phoenix_live_view",
|
"phoenix_live_view": "file:../deps/phoenix_live_view",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"rehype-katex": "^6.0.0",
|
||||||
|
"rehype-raw": "^6.0.0",
|
||||||
|
"rehype-sanitize": "^5.0.0",
|
||||||
|
"rehype-stringify": "^9.0.1",
|
||||||
|
"remark-gfm": "^2.0.0",
|
||||||
|
"remark-math": "^5.0.0",
|
||||||
|
"remark-parse": "^10.0.0",
|
||||||
|
"remark-rehype": "^9.0.0",
|
||||||
"remixicon": "^2.5.0",
|
"remixicon": "^2.5.0",
|
||||||
"scroll-into-view-if-needed": "^2.2.28",
|
"scroll-into-view-if-needed": "^2.2.28",
|
||||||
"tailwindcss": "^2.1.1",
|
"tailwindcss": "^2.1.1",
|
||||||
"topbar": "^1.0.1",
|
"topbar": "^1.0.1",
|
||||||
|
"unified": "^10.1.0",
|
||||||
|
"unist-util-visit": "^4.0.0",
|
||||||
"vega": "^5.20.2",
|
"vega": "^5.20.2",
|
||||||
"vega-embed": "^6.18.1",
|
"vega-embed": "^6.18.1",
|
||||||
"vega-lite": "^5.1.0"
|
"vega-lite": "^5.1.0"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const glob = require("glob");
|
const glob = require("glob");
|
||||||
|
const webpack = require("webpack");
|
||||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||||
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
|
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
|
||||||
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
|
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
|
||||||
|
@ -44,6 +45,10 @@ module.exports = (env, options) => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// Polyfill the global "process" variable required by "remark" internals
|
||||||
|
new webpack.ProvidePlugin({
|
||||||
|
process: "process/browser",
|
||||||
|
}),
|
||||||
new MiniCssExtractPlugin({ filename: "../css/app.css" }),
|
new MiniCssExtractPlugin({ filename: "../css/app.css" }),
|
||||||
new MonacoWebpackPlugin({
|
new MonacoWebpackPlugin({
|
||||||
languages: ["markdown", "elixir"],
|
languages: ["markdown", "elixir"],
|
||||||
|
@ -52,11 +57,13 @@ module.exports = (env, options) => {
|
||||||
optimization: {
|
optimization: {
|
||||||
minimizer: ["...", new CssMinimizerPlugin()],
|
minimizer: ["...", new CssMinimizerPlugin()],
|
||||||
},
|
},
|
||||||
// The crypto-js package relies no the crypto module, but it has
|
|
||||||
// fine support in browsers, so we don't provide polyfills
|
|
||||||
resolve: {
|
resolve: {
|
||||||
fallback: {
|
fallback: {
|
||||||
|
// The crypto-js package relies no the crypto module, but it has
|
||||||
|
// fine support in browsers, so we don't provide polyfills
|
||||||
crypto: false,
|
crypto: false,
|
||||||
|
// Polyfill the assert module required by "remark" internals
|
||||||
|
assert: require.resolve("assert"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue