Skip to content

Commit

Permalink
Use hljs to highlight code blocks (#33)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ptgott authored Dec 4, 2024
1 parent bd93063 commit 7ddbe64
Show file tree
Hide file tree
Showing 13 changed files with 518 additions and 19 deletions.
14 changes: 14 additions & 0 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -185,6 +187,18 @@ const config: Config = {
remarkTOC,
remarkUpdateTags,
],
beforeDefaultRehypePlugins: [
[
rehypeHLJS,
{
aliases: {
bash: ["bsh", "systemd", "code", "powershell"],
yaml: ["conf", "toml"],
},
languages: { hcl: hcl },
},
],
],
},
],
extendedPostcssConfigPlugin,
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
172 changes: 172 additions & 0 deletions server/rehype-hljs.ts
Original file line number Diff line number Diff line change
@@ -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("<Var");
}

if (
node.type === "element" &&
node.children.length === 1 &&
node.children[0].type === "text"
) {
return node.children[0].value.includes("<Var");
}

return false;
};

export const rehypeHLJS = (options?: RehypeHighlightOptions): Transformer => {
return (root: Parent, file: VFile) => {
options.languages = { ...options.languages, ...common };
const highlighter = rehypeHighlight(options);
let placeholdersToVars: Record<string, Node> = {};

// 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("<Var [^>]+/>", "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<Text | Element> = [];

// 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<Text | Element>).splice(
index,
1,
...newChildren
);
return [SKIP, index + newChildren.length];
});
};
};
4 changes: 2 additions & 2 deletions server/remark-update-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions src/styles/variables.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 0 additions & 14 deletions src/theme/MDXComponents.tsx

This file was deleted.

8 changes: 8 additions & 0 deletions src/theme/MDXComponents/Code.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
20 changes: 20 additions & 0 deletions src/theme/MDXComponents/Code.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className={styles.line} {...props} />;
};

const isHLJSNode = (className?: string) =>
Boolean(className) && className.indexOf("hljs") !== -1;

export default function (
props: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>
) {
if (isHLJSNode(props.className)) {
return <code {...props} />;
}

return <code {...props} className={cn(styles.wrapper, props.className)} />;
};
111 changes: 111 additions & 0 deletions src/theme/MDXComponents/CodeBlock.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading

0 comments on commit 7ddbe64

Please sign in to comment.