From 7c45b3679f4483d4f96e05613a422e6a75d06560 Mon Sep 17 00:00:00 2001 From: Evan Simkowitz Date: Mon, 9 Sep 2024 11:56:21 -0700 Subject: [PATCH] Add syntax highlighting to the Markdown element (#356) Also fixes the styling for the copy button --- frontend/app/element/copybutton.less | 10 +++- frontend/app/element/copybutton.tsx | 15 +++--- frontend/app/element/markdown.less | 8 ++- frontend/app/element/markdown.tsx | 27 ++++++++-- frontend/app/theme.less | 3 ++ package.json | 2 + yarn.lock | 78 +++++++++++++++++++++++++++- 7 files changed, 126 insertions(+), 17 deletions(-) diff --git a/frontend/app/element/copybutton.less b/frontend/app/element/copybutton.less index 2c0f9642..db485579 100644 --- a/frontend/app/element/copybutton.less +++ b/frontend/app/element/copybutton.less @@ -2,9 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 .copy-button { - padding: 5px 5px; + padding: 5px 5px !important; + opacity: 0.5; - .fa-check { + &:hover { + opacity: 1; + } + + &.copied { + opacity: 1; color: var(--success-color); } } diff --git a/frontend/app/element/copybutton.tsx b/frontend/app/element/copybutton.tsx index cf1ed543..0fbe3090 100644 --- a/frontend/app/element/copybutton.tsx +++ b/frontend/app/element/copybutton.tsx @@ -2,9 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { clsx } from "clsx"; -import * as React from "react"; +import { useEffect, useRef, useState } from "react"; import { Button } from "./button"; - import "./copybutton.less"; type CopyButtonProps = { @@ -14,8 +13,8 @@ type CopyButtonProps = { }; const CopyButton = ({ title, className, onClick }: CopyButtonProps) => { - const [isCopied, setIsCopied] = React.useState(false); - const timeoutRef = React.useRef(null); + const [isCopied, setIsCopied] = useState(false); + const timeoutRef = useRef(null); const handleOnClick = (e: React.MouseEvent) => { if (isCopied) { @@ -35,7 +34,7 @@ const CopyButton = ({ title, className, onClick }: CopyButtonProps) => { } }; - React.useEffect(() => { + useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); @@ -44,7 +43,11 @@ const CopyButton = ({ title, className, onClick }: CopyButtonProps) => { }, []); return ( - ); diff --git a/frontend/app/element/markdown.less b/frontend/app/element/markdown.less index e98cb239..fb29a5fe 100644 --- a/frontend/app/element/markdown.less +++ b/frontend/app/element/markdown.less @@ -81,13 +81,11 @@ visibility: hidden; display: flex; position: absolute; - top: 4px; - right: 4px; + top: 0; + right: 0; border-radius: 4px; align-items: center; justify-content: flex-end; - padding-left: 4px; - padding-right: 4px; i { color: var(--line-actions-inactive-color); @@ -118,7 +116,7 @@ code { color: var(--app-text-color); - background-color: var(--panel-bg-color); + background-color: transparent; font-family: var(--termfontfamily); border-radius: 4px; } diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 6795e325..8b565f97 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -10,7 +10,9 @@ import { Atom } from "jotai"; import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react"; import { useEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown, { Components } from "react-markdown"; +import rehypeHighlight from "rehype-highlight"; import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import rehypeSlug from "rehype-slug"; import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc"; import remarkGfm from "remark-gfm"; @@ -47,8 +49,8 @@ const Heading = ({ props, hnum }: { props: React.HTMLAttributes { - return {children}; +const Code = ({ className, children }: { className: string; children: React.ReactNode }) => { + return {children}; }; type CodeBlockProps = { @@ -235,7 +237,26 @@ const Markdown = ({ text, textAtom, showTocAtom, style, className, resolveOpts, > rehypeSlug({ prefix: idPrefix })]} + rehypePlugins={[ + rehypeRaw, + () => rehypeSlug({ prefix: idPrefix }), + rehypeHighlight, + () => + rehypeSanitize({ + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + span: [ + ...(defaultSchema.attributes?.span || []), + // Allow all class names starting with `hljs-`. + ["className", /^hljs-./], + // Alternatively, to allow only certain class names: + // ['className', 'hljs-number', 'hljs-title', 'hljs-variable'] + ], + }, + tagNames: [...(defaultSchema.tagNames || []), "span"], + }), + ]} components={markdownComponents} > {text} diff --git a/frontend/app/theme.less b/frontend/app/theme.less index adb640f5..4fbe7347 100644 --- a/frontend/app/theme.less +++ b/frontend/app/theme.less @@ -1,6 +1,9 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +// Used for syntax highlighting in markdown +@import url("../../node_modules/highlight.js/styles/github-dark-dimmed.min.css"); + :root { --main-text-color: #f7f7f7; --title-font-size: 18px; diff --git a/package.json b/package.json index 936b9e59..d17737bc 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,9 @@ "react-frame-component": "^5.2.7", "react-gauge-chart": "^0.5.1", "react-markdown": "^9.0.1", + "rehype-highlight": "^7.0.0", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rehype-slug": "^6.0.0", "remark-flexible-toc": "^1.1.1", "remark-gfm": "^4.0.0", diff --git a/yarn.lock b/yarn.lock index 19a7c298..f8a8dc1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3967,7 +3967,7 @@ __metadata: languageName: node linkType: hard -"@ungap/structured-clone@npm:^1.0.0": +"@ungap/structured-clone@npm:^1.0.0, @ungap/structured-clone@npm:^1.2.0": version: 1.2.0 resolution: "@ungap/structured-clone@npm:1.2.0" checksum: 10c0/8209c937cb39119f44eb63cf90c0b73e7c754209a6411c707be08e50e29ee81356dca1a848a405c8bdeebfe2f5e4f831ad310ae1689eeef65e7445c090c6657d @@ -7659,6 +7659,17 @@ __metadata: languageName: node linkType: hard +"hast-util-sanitize@npm:^5.0.0": + version: 5.0.1 + resolution: "hast-util-sanitize@npm:5.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + "@ungap/structured-clone": "npm:^1.2.0" + unist-util-position: "npm:^5.0.0" + checksum: 10c0/30b3e69effcd6c3c6b40264a169dca9ab6568d98b9aeaa75ee5e0a6b3c7f9843fe6bc9c2cb11c24172b32c2ab7d88e0f243e72a9526107ba35ca58f410cd7ca5 + languageName: node + linkType: hard + "hast-util-to-jsx-runtime@npm:^2.0.0": version: 2.3.0 resolution: "hast-util-to-jsx-runtime@npm:2.3.0" @@ -7706,6 +7717,18 @@ __metadata: languageName: node linkType: hard +"hast-util-to-text@npm:^4.0.0": + version: 4.0.2 + resolution: "hast-util-to-text@npm:4.0.2" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + hast-util-is-element: "npm:^3.0.0" + unist-util-find-after: "npm:^5.0.0" + checksum: 10c0/93ecc10e68fe5391c6e634140eb330942e71dea2724c8e0c647c73ed74a8ec930a4b77043b5081284808c96f73f2bee64ee416038ece75a63a467e8d14f09946 + languageName: node + linkType: hard + "hast-util-whitespace@npm:^3.0.0": version: 3.0.0 resolution: "hast-util-whitespace@npm:3.0.0" @@ -7728,6 +7751,13 @@ __metadata: languageName: node linkType: hard +"highlight.js@npm:~11.9.0": + version: 11.9.0 + resolution: "highlight.js@npm:11.9.0" + checksum: 10c0/27cfa8717dc9d200aecbdb383eb122d5f45ce715d2f468583785d36fbfe5076ce033abb02486dc13b407171721cda6f474ed3f3a5a8e8c3d91367fa5f51ee374 + languageName: node + linkType: hard + "hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" @@ -8780,6 +8810,17 @@ __metadata: languageName: node linkType: hard +"lowlight@npm:^3.0.0": + version: 3.1.0 + resolution: "lowlight@npm:3.1.0" + dependencies: + "@types/hast": "npm:^3.0.0" + devlop: "npm:^1.0.0" + highlight.js: "npm:~11.9.0" + checksum: 10c0/ee230ba1da3b339bae640479a09a4c82e5727bae38345421767c6407db4d514c10387300900ba79aa8c64dd79ae7f8d1acff847c01d5b0a20364a5ce04685f27 + languageName: node + linkType: hard + "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" @@ -11086,6 +11127,19 @@ __metadata: languageName: node linkType: hard +"rehype-highlight@npm:^7.0.0": + version: 7.0.0 + resolution: "rehype-highlight@npm:7.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-to-text: "npm:^4.0.0" + lowlight: "npm:^3.0.0" + unist-util-visit: "npm:^5.0.0" + vfile: "npm:^6.0.0" + checksum: 10c0/bf9eba61ac2635db6c6635d3485456f2d6bdf43e3acba34deb673ddde82dc8e0a7a4ba81c4f26dda85ecc5e99a9e949c05ed1b4fb25c0414e970d9623894c935 + languageName: node + linkType: hard + "rehype-raw@npm:^7.0.0": version: 7.0.0 resolution: "rehype-raw@npm:7.0.0" @@ -11097,6 +11151,16 @@ __metadata: languageName: node linkType: hard +"rehype-sanitize@npm:^6.0.0": + version: 6.0.0 + resolution: "rehype-sanitize@npm:6.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-sanitize: "npm:^5.0.0" + checksum: 10c0/43d6c056e63c994cf56e5ee0e157052d2030dc5ac160845ee494af9a26e5906bf5ec5af56c7d90c99f9c4dc0091e45a48a168618135fb6c64a76481ad3c449e9 + languageName: node + linkType: hard + "rehype-slug@npm:^6.0.0": version: 6.0.0 resolution: "rehype-slug@npm:6.0.0" @@ -12180,7 +12244,9 @@ __metadata: react-frame-component: "npm:^5.2.7" react-gauge-chart: "npm:^0.5.1" react-markdown: "npm:^9.0.1" + rehype-highlight: "npm:^7.0.0" rehype-raw: "npm:^7.0.0" + rehype-sanitize: "npm:^6.0.0" rehype-slug: "npm:^6.0.0" remark-flexible-toc: "npm:^1.1.1" remark-gfm: "npm:^4.0.0" @@ -12655,6 +12721,16 @@ __metadata: languageName: node linkType: hard +"unist-util-find-after@npm:^5.0.0": + version: 5.0.0 + resolution: "unist-util-find-after@npm:5.0.0" + dependencies: + "@types/unist": "npm:^3.0.0" + unist-util-is: "npm:^6.0.0" + checksum: 10c0/a7cea473c4384df8de867c456b797ff1221b20f822e1af673ff5812ed505358b36f47f3b084ac14c3622cb879ed833b71b288e8aa71025352a2aab4c2925a6eb + languageName: node + linkType: hard + "unist-util-is@npm:^6.0.0": version: 6.0.0 resolution: "unist-util-is@npm:6.0.0"