From 7ddbe642491e474565ff949b66942f769659b726 Mon Sep 17 00:00:00 2001 From: Paul Gottschling Date: Wed, 4 Dec 2024 12:30:54 -0500 Subject: [PATCH] Use hljs to highlight code blocks (#33) Partially addresses #6 Use the hljs library to perform syntax highlighting on code blocks. This library achieves clearer syntax highlighting than the default Docusaurus solution. This change adds the `rehype-hljs` plugin, copied from `rehype-hljs-var` in `gravitational/docs`. By copying this plugin, rather than using `rehype-highlight` (which this plugin uses), we can add functionality from `gravitational/docs` in which the docs engine renders `Var` components in syntax-highlighted code snippets. Implementation details: - Eject src/theme/MDXComponents/index.tsx - Add `Pre` from gravitational/docs (but don't include the `CommandLine`-handling logic, which depends on `remark-code-snippet`) - Set variables for `--font-ubunt` and `--font-lato`. - Remove conflicting default MDXComponents exports. - Fix `remark-update-tags`: Change `Var` to `var` to accommodate the new `remark-hljs` logic, which which `Var`s have lowercased tags. - Use attribute selectors for HLJS classes. This is because Docusaurus hashes `hljs-` class names in production but not in dev. There's probably a more elegant solution here, but this quick hack works for now. --- docusaurus.config.ts | 14 ++ package.json | 4 +- server/rehype-hljs.ts | 172 +++++++++++++++++++ server/remark-update-tags.ts | 4 +- src/styles/variables.css | 2 + src/theme/MDXComponents.tsx | 14 -- src/theme/MDXComponents/Code.module.css | 8 + src/theme/MDXComponents/Code.tsx | 20 +++ src/theme/MDXComponents/CodeBlock.module.css | 111 ++++++++++++ src/theme/MDXComponents/Pre.module.css | 61 +++++++ src/theme/MDXComponents/Pre.tsx | 76 ++++++++ src/theme/MDXComponents/index.tsx | 43 +++++ yarn.lock | 8 +- 13 files changed, 518 insertions(+), 19 deletions(-) create mode 100644 server/rehype-hljs.ts delete mode 100644 src/theme/MDXComponents.tsx create mode 100644 src/theme/MDXComponents/Code.module.css create mode 100644 src/theme/MDXComponents/Code.tsx create mode 100644 src/theme/MDXComponents/CodeBlock.module.css create mode 100644 src/theme/MDXComponents/Pre.module.css create mode 100644 src/theme/MDXComponents/Pre.tsx create mode 100644 src/theme/MDXComponents/index.tsx diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 24b3ccd..b99932f 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -22,6 +22,8 @@ import { updatePathsInIncludes, } from "./server/asset-path-helpers"; import { extendedPostcssConfigPlugin } from "./server/postcss"; +import { rehypeHLJS } from "./server/rehype-hljs"; +import { definer as hcl } from "highlightjs-terraform"; const latestVersion = getLatestVersion(); @@ -185,6 +187,18 @@ const config: Config = { remarkTOC, remarkUpdateTags, ], + beforeDefaultRehypePlugins: [ + [ + rehypeHLJS, + { + aliases: { + bash: ["bsh", "systemd", "code", "powershell"], + yaml: ["conf", "toml"], + }, + languages: { hcl: hcl }, + }, + ], + ], }, ], extendedPostcssConfigPlugin, diff --git a/package.json b/package.json index 34c3b36..774a665 100644 --- a/package.json +++ b/package.json @@ -44,9 +44,11 @@ "@docusaurus/theme-classic": "^3.6.3", "@inkeep/widgets": "^0.2.288", "@mdx-js/react": "^3.0.0", - "classnames": "^2.3", + "classnames": "^2.3.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "highlightjs-terraform": "https://github.com/highlightjs/highlightjs-terraform#eb1b9661e143a43dff6b58b391128ce5cdad31d4", + "lowlight": "^3.1.0", "prism-react-renderer": "^2.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/server/rehype-hljs.ts b/server/rehype-hljs.ts new file mode 100644 index 0000000..a270db1 --- /dev/null +++ b/server/rehype-hljs.ts @@ -0,0 +1,172 @@ +import { unified, Transformer } from "unified"; +import type { VFile } from "vfile"; +import rehypeHighlight, { + Options as RehypeHighlightOptions, +} from "rehype-highlight"; +import { common } from "lowlight"; +import { visit, CONTINUE, SKIP } from "unist-util-visit"; +import { v4 as uuid } from "uuid"; +import remarkParse from "remark-parse"; +import type { Text, Element, Node, Parent } from "hast"; +import remarkMDX from "remark-mdx"; + +const makePlaceholder = (): string => { + // UUID for uniqueness, but remove hyphens since these are often parsed + // as operators or other non-identifier tokens. Make sure the placeholder + // begins with a letter so it gets parsed as an identifier. + return "var" + uuid().replaceAll("-", ""); +}; + +const placeholderPattern = "var[a-z0-9]{32}"; + +// We only visit text nodes inside code snippets +const isVarContainer = (node: Node) => { + if (node.type === "text") { + return node.value.includes(" { + return (root: Parent, file: VFile) => { + options.languages = { ...options.languages, ...common }; + const highlighter = rehypeHighlight(options); + let placeholdersToVars: Record = {}; + + // In a code snippet, Var elements are parsed as text. Replace these with + // UUID strings to ensure that the parser won't split these up and make + // them unrecoverable. + visit(root, isVarContainer, (node: Node, index: number, parent: Parent) => { + const varPattern = new RegExp("]+/>", "g"); + let txt: Text; + if (node.type == "text") { + txt = node; + } else { + // isVarContainer enforces having a single child text node + txt = node.children[0]; + } + + const newVal = txt.value.replace(varPattern, (match) => { + const placeholder = makePlaceholder(); + // Since the Var element was originally text, parse it so we can recover + // its properties. The result should be a small HTML AST with a root + // node and one child, the Var node. + const varElement = unified() + .use(remarkParse) + .use(remarkMDX) + .parse(match); + + placeholdersToVars[placeholder] = varElement.children[0]; + return placeholder; + }); + if (node.type == "text") { + node.value = newVal; + } else { + node.children[0].value = newVal; + } + }); + + // Apply syntax highlighting + (highlighter as Function)(root, file); + + // After syntax highlighting, the content of the code snippet will be a + // series of span elements with different "hljs-*" classes. Find the + // placeholder UUIDs and replace them with their original Var elements, + // inserting these as HTML AST nodes. + visit(root, isVarContainer, (node: Node, index: number, parent: Parent) => { + const el = node as Element | Text; + let hljsSpanValue = ""; + if (el.type === "text") { + hljsSpanValue = el.value; + } else { + hljsSpanValue = (el.children[0] as Text).value; + } + + // This is either a text node or an hljs span with only the placeholder as + // its child. We don't need the node, so replace it with the original + // Var. + if (placeholdersToVars[hljsSpanValue]) { + (parent as any).children[index] = placeholdersToVars[hljsSpanValue]; + return [CONTINUE]; + } + + const placeholders = Array.from( + hljsSpanValue.matchAll(new RegExp(placeholderPattern, "g")) + ); + + // No placeholders to recover, so there's nothing more to do. + if (placeholders.length == 0) { + return [CONTINUE]; + } + + // The element's text includes one or more Vars among other content, so we + // need to replace the span (or text node) with a series of spans (or + // text nodes) separated by Vars. + let newChildren: Array = []; + + // Assemble a map of indexes to their corresponding placeholders so we + // can tell whether a given index falls within a placeholder. + const placeholderIndices = new Map(); + placeholders.forEach((p) => { + placeholderIndices.set(p.index, p[0]); + }); + + let valueIdx = 0; + while (valueIdx < hljsSpanValue.length) { + // The current index is in a placeholder, so add the original Var + // component to newChildren. + if (placeholderIndices.has(valueIdx)) { + const placeholder = placeholderIndices.get(valueIdx); + valueIdx += placeholder.length; + newChildren.push(placeholdersToVars[placeholder] as Element); + continue; + } + // The current index is outside a placeholder, so assemble a text or + // span node and push that to newChildren. + let textVal = ""; + while ( + !placeholderIndices.has(valueIdx) && + valueIdx < hljsSpanValue.length + ) { + textVal += hljsSpanValue[valueIdx]; + valueIdx++; + } + if (el.type === "text") { + newChildren.push({ + type: "text", + value: textVal, + }); + } else { + newChildren.push({ + tagName: "span", + type: "element", + properties: el.properties, + children: [ + { + type: "text", + value: textVal, + }, + ], + }); + } + } + + // Delete the current span and replace it with the new children. + (parent.children as Array).splice( + index, + 1, + ...newChildren + ); + return [SKIP, index + newChildren.length]; + }); + }; +}; diff --git a/server/remark-update-tags.ts b/server/remark-update-tags.ts index bdc6e6b..5b89a46 100644 --- a/server/remark-update-tags.ts +++ b/server/remark-update-tags.ts @@ -109,7 +109,7 @@ export default function remarkMigrationUpdateTags(): Transformer { } // Replace Var with uppercase content - if (isMdxNode(node) && node.name === "Var") { + if (isMdxNode(node) && node.name.toLowerCase() === "var") { parent.children[index] = { type: "text", value: @@ -120,7 +120,7 @@ export default function remarkMigrationUpdateTags(): Transformer { // Replace Var in clode blocks if (isCodeNode(node)) { - const regexNode = /(\<\s*Var\s+(.*?)\s*\/\>)/g; + const regexNode = /(\<\s*[vV]ar\s+(.*?)\s*\/\>)/g; const regexProperty = /([a-z]+)\s*=\s*"([^"]*?)"/gi; node.value = node.value.replaceAll(regexNode, (match) => { diff --git a/src/styles/variables.css b/src/styles/variables.css index d8228ae..18539b4 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -22,6 +22,8 @@ --color-note: #009cf1; /* fonts */ + --font-ubunt: "Ubuntu Mono"; + --font-lato: Lato; --font-base: Lato, sans-serif; --font-body: var(--font-base); --font-serif: "Georgia", serif; diff --git a/src/theme/MDXComponents.tsx b/src/theme/MDXComponents.tsx deleted file mode 100644 index bdf8cba..0000000 --- a/src/theme/MDXComponents.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import MDXComponents from "@theme-original/MDXComponents"; -import Tabs from "@theme/Tabs"; -import TabItem from "@theme/TabItem"; - -import Admonition from "@theme/Admonition"; - -const components = { - ...MDXComponents, - Tabs, - TabItem, - Admonition, -}; - -export default components; diff --git a/src/theme/MDXComponents/Code.module.css b/src/theme/MDXComponents/Code.module.css new file mode 100644 index 0000000..4f0141e --- /dev/null +++ b/src/theme/MDXComponents/Code.module.css @@ -0,0 +1,8 @@ +.wrapper { + padding: 0 var(--m-0-5); + border: 1px solid var(--color-light-gray); + border-radius: var(--r-sm); + font-size: var(--fs-text-md); + word-break: break-word; + background-color: var(--color-lightest-gray); +} diff --git a/src/theme/MDXComponents/Code.tsx b/src/theme/MDXComponents/Code.tsx new file mode 100644 index 0000000..0916cfb --- /dev/null +++ b/src/theme/MDXComponents/Code.tsx @@ -0,0 +1,20 @@ +import cn from "classnames"; +import { DetailedHTMLProps, HTMLAttributes } from "react"; +import styles from "./Code.module.css"; + +export const CodeLine = (props: CodeLineProps) => { + return ; +}; + +const isHLJSNode = (className?: string) => + Boolean(className) && className.indexOf("hljs") !== -1; + +export default function ( + props: DetailedHTMLProps, HTMLElement> +) { + if (isHLJSNode(props.className)) { + return ; + } + + return ; +}; diff --git a/src/theme/MDXComponents/CodeBlock.module.css b/src/theme/MDXComponents/CodeBlock.module.css new file mode 100644 index 0000000..df47370 --- /dev/null +++ b/src/theme/MDXComponents/CodeBlock.module.css @@ -0,0 +1,111 @@ +/* stylelint-disable selector-class-pattern */ +/* stylelint-disable no-descending-specificity */ + +/* theme taken from https://github.com/highlightjs/highlight.js/blob/master/src/styles/monokai.css */ + +.wrapper { + display: block; + overflow-x: auto; + margin: 0; + padding: var(--m-1) var(--m-2); + border-radius: var(--r-default); + color: #ddd; + font-family: var(--font-monospace); + line-height: var(--lh-md); + white-space: pre; + background-color: var(--color-code); + + @media (--sm-scr) { + font-size: var(--fs-text-sm); + } + + @media (--md-scr) { + font-size: var(--fs-text-md); + } + + & code { + font-family: inherit; + } + + /* We use attribute selectors to select hljs- classes because Docusaurus + * hashes them in production builds.*/ + & :global { + & [class^="hljs-tag"], + & [class^="hljs-keyword"], + & [class^="hljs-selector-tag"], + & [class^="hljs-literal"], + & [class^="hljs-strong"], + & [class^="hljs-name"] { + color: #f92672; + } + + & [class^="hljs-code"] { + color: #66d9ef; + } + + & [class^="hljs-class"] [class^="hljs-title"] { + color: white; + } + + & [class^="hljs-attribute"], + & [class^="hljs-symbol"], + & [class^="hljs-regexp"], + & [class^="hljs-link"] { + color: #bf79db; + } + + & [class^="hljs-string"], + & [class^="hljs-bullet"], + & [class^="hljs-subst"], + & [class^="hljs-title"], + & [class^="hljs-section"], + & [class^="hljs-emphasis"], + & [class^="hljs-type"], + & [class^="hljs-built_in"], + & [class^="hljs-builtin-name"], + & [class^="hljs-selector-attr"], + & [class^="hljs-selector-pseudo"], + & [class^="hljs-addition"], + & [class^="hljs-variable"], + & [class^="hljs-template-tag"], + & [class^="hljs-template-variable"] { + color: #a6e22e; + } + + & [class^="hljs-comment"], + & [class^="hljs-quote"], + & [class^="hljs-deletion"], + & [class^="hljs-meta"] { + color: #75715e; + } + + & [class^="hljs-keyword"], + & [class^="hljs-selector-tag"], + & [class^="hljs-literal"], + & [class^="hljs-doctag"], + & [class^="hljs-title"], + & [class^="hljs-section"], + & [class^="hljs-type"], + & [class^="hljs-selector-id"] { + font-weight: bold; + } + } +} + +.line { + display: block; + & :global { + & .wrapper-input input { + color: var(--color-light-blue); + background-color: transparent; + } + + & .wrapper-input input::placeholder { + color: var(--color-light-gray); + } + + & .wrapper-input svg { + color: var(--color-light-blue); + } + } +} diff --git a/src/theme/MDXComponents/Pre.module.css b/src/theme/MDXComponents/Pre.module.css new file mode 100644 index 0000000..c723f18 --- /dev/null +++ b/src/theme/MDXComponents/Pre.module.css @@ -0,0 +1,61 @@ +@keyframes button-appearance { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.wrapper { + position: relative; + background-color: var(--color-code); + border-radius: var(--r-default); + margin-top: calc(9 - var(--m-0-5)); + margin-bottom: var(--m-3); + box-shadow: 0 1px 4px rgba(0 0 0 / 24%); + + &:hover > button { + display: flex; + } + + &:last-child { + margin-bottom: 0; + } +} + +.button { + display: none; + align-items: center; + position: absolute; + top: 0; + right: 0; + z-index: 2; + padding: var(--m-1); + color: var(--color-lighter-gray); + cursor: pointer; + border-top-right-radius: var(--r-default); + border-bottom-right-radius: var(--r-default); + opacity: 0; + animation-duration: 0.3s; + animation-fill-mode: forwards; + transition: color var(--r-interaction); + animation-name: button-appearance; + + &:hover, + &:focus, + &:active { + color: var(--color-white); + outline: none; + } +} + +.copied { + margin-left: var(--m-1); +} + +.code { + border-radius: var(--r-default); + white-space: break-spaces; +} diff --git a/src/theme/MDXComponents/Pre.tsx b/src/theme/MDXComponents/Pre.tsx new file mode 100644 index 0000000..620641f --- /dev/null +++ b/src/theme/MDXComponents/Pre.tsx @@ -0,0 +1,76 @@ +import cn from "classnames"; +import { useRef, useState, useCallback, ReactNode } from "react"; +import Icon from "/src/components/Icon"; +import HeadlessButton from "/src/components/HeadlessButton"; +import { toCopyContent } from "/utils/general"; +import styles from "./Pre.module.css"; +import codeBlockStyles from "./CodeBlock.module.css"; + +const TIMEOUT = 1000; + +interface CodeProps { + children: ReactNode; + className?: string; +} + +const Pre = ({ children, className }: CodeProps) => { + const [isCopied, setIsCopied] = useState(false); + const codeRef = useRef(); + const buttonRef = useRef(); + + const handleCopy = useCallback(() => { + if (!navigator.clipboard) { + return; + } + + if (codeRef.current) { + const copyText = codeRef.current.cloneNode(true) as HTMLElement; + const descriptions = copyText.querySelectorAll("[data-type]"); + + if (descriptions.length) { + for (let i = 0; i < descriptions.length; i++) { + descriptions[i].remove(); + } + } + + // Assemble an array of class names of elements within copyText to copy + // when a user clicks the copy button. + let classesToCopy = [ + // Class name added by rehype-highlight to a `code` element when + // highlighting syntax in code snippets + ".hljs", + ]; + + document.body.appendChild(copyText); + const processedInnerText = toCopyContent(copyText, classesToCopy); + + navigator.clipboard.writeText(processedInnerText); + document.body.removeChild(copyText); + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + buttonRef.current?.blur(); + }, TIMEOUT); + } + }, []); + + return ( +
+ + + {isCopied &&
Copied!
} +
+
+
{children}
+
+
+ ); +}; + +export default Pre; diff --git a/src/theme/MDXComponents/index.tsx b/src/theme/MDXComponents/index.tsx new file mode 100644 index 0000000..30f4249 --- /dev/null +++ b/src/theme/MDXComponents/index.tsx @@ -0,0 +1,43 @@ +import OriginalMDXComponents from "@theme-original/MDXComponents"; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import Admonition from "@theme/Admonition"; +import React, { type ComponentProps } from "react"; +import Head from "@docusaurus/Head"; +import { default as Code, CodeLine } from "@theme/MDXComponents/Code"; +import MDXA from "@theme/MDXComponents/A"; +import MDXPre from "@theme/MDXComponents/Pre"; +import MDXDetails from "@theme/MDXComponents/Details"; +import MDXHeading from "@theme/MDXComponents/Heading"; +import MDXUl from "@theme/MDXComponents/Ul"; +import MDXLi from "@theme/MDXComponents/Li"; +import MDXImg from "@theme/MDXComponents/Img"; +import Mermaid from "@theme/Mermaid"; + +import type { MDXComponentsObject } from "@theme/MDXComponents"; + +const MDXComponents: MDXComponentsObject = { + ...OriginalMDXComponents, + Details: MDXDetails, + Head, + TabItem, + Tabs, + a: MDXA, + Admonition, + code: Code, + codeline: CodeLine, + details: MDXDetails, // For MD mode support, see https://github.com/facebook/docusaurus/issues/9092#issuecomment-1602902274 + h1: (props: ComponentProps<"h1">) => , + h2: (props: ComponentProps<"h2">) => , + h3: (props: ComponentProps<"h3">) => , + h4: (props: ComponentProps<"h4">) => , + h5: (props: ComponentProps<"h5">) => , + h6: (props: ComponentProps<"h6">) => , + img: MDXImg, + li: MDXLi, + mermaid: Mermaid, + pre: MDXPre, + ul: MDXUl, +}; + +export default MDXComponents; diff --git a/yarn.lock b/yarn.lock index 033244b..dd9f331 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6120,7 +6120,7 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== -classnames@^2.3: +classnames@^2.3.1: version "2.5.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== @@ -8232,6 +8232,10 @@ highlight.js@~11.9.0: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0" integrity sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw== +"highlightjs-terraform@https://github.com/highlightjs/highlightjs-terraform#eb1b9661e143a43dff6b58b391128ce5cdad31d4": + version "1.0.6" + resolved "https://github.com/highlightjs/highlightjs-terraform#eb1b9661e143a43dff6b58b391128ce5cdad31d4" + history@^4.9.0: version "4.10.1" resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" @@ -9240,7 +9244,7 @@ lowercase-keys@^3.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-3.0.0.tgz#c5e7d442e37ead247ae9db117a9d0a467c89d4f2" integrity sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ== -lowlight@^3.0.0: +lowlight@^3.0.0, lowlight@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-3.1.0.tgz#aa394c5f3a7689fce35fa49a7c850ba3ead4f590" integrity sha512-CEbNVoSikAxwDMDPjXlqlFYiZLkDJHwyGu/MfOsJnF3d7f3tds5J3z8s/l9TMXhzfsJCCJEAsD78842mwmg0PQ==