Skip to content
This repository has been archived by the owner on Jan 8, 2025. It is now read-only.

Fix bug in rehype-hljs-var #506

Merged
merged 1 commit into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions server/fixtures/hcl-addr-var.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
1. Create a `main.tf` file containing this minimal Terraform code:

```hcl
terraform {
required_providers {
teleport = {
source = "terraform.releases.teleport.dev/gravitational/teleport"
version = "~> (=teleport.major_version=).0"
}
}
}

provider "teleport" {
addr = '<Var name="teleport.example.com:443" />'
}
```

1. Then, init your Terraform working directory to download the Teleport provider:

```code
$ terraform init
Initializing the backend...

Initializing provider plugins...
- Finding terraform.releases.teleport.dev/gravitational/teleport versions matching ...
```

1. Finally, run a Terraform plan:

30 changes: 30 additions & 0 deletions server/fixtures/result/hcl-addr-var.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<ol>
<li>
<p>Create a <code>main.tf</code> file containing this minimal Terraform code:</p>
<pre><code class="hljs language-hcl"><span class="hljs-keyword">terraform</span> {
required_providers {
teleport = {
source = <span class="hljs-string">"terraform.releases.teleport.dev/gravitational/teleport"</span>
version = <span class="hljs-string">"~> (=teleport.major_version=).0"</span>
}
}
}

<span class="hljs-keyword">provider</span> <span class="hljs-string">"teleport"</span> {
addr = '<var name="teleport.example.com:443"></var>'
}
</code></pre>
</li>
<li>
<p>Then, init your Terraform working directory to download the Teleport provider:</p>
<pre><code class="hljs language-code">$ terraform init
Initializing the backend...

Initializing provider plugins...
- Finding terraform.releases.teleport.dev/gravitational/teleport versions matching ...
</code></pre>
</li>
<li>
<p>Finally, run a Terraform plan:</p>
</li>
</ol>
220 changes: 111 additions & 109 deletions server/rehype-hljs-var.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
import { unified, Transformer } from "unified";
import type { VFile } from "vfile";
import type {
MdxJsxFlowElement,
MdxJsxTextElement,
MdxJsxAttribute,
MdxJsxAttributeValueExpression,
} from "mdast-util-mdx-jsx";
import type { MDXJSEsm } from "mdast-util-mdxjs-esm";
import type {
MDXFlowExpression,
MDXTextExpression,
} from "mdast-util-mdx-expression";
import rehypeHighlight, {
Options as RehypeHighlightOptions,
} from "rehype-highlight";
Expand All @@ -35,46 +24,47 @@ export const rehypeVarInHLJS = (
return (root: Parent, file: VFile) => {
const highlighter = rehypeHighlight(options);

let placeholdersToVars: Record<string, MdxJsxFlowElement> = {};
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, undefined, (node: Node, index: number, parent: Parent) => {
// We only visit text nodes inside code snippets
if (
node.type === "text" &&
parent.hasOwnProperty("tagName") &&
(parent as Element).tagName === "code"
node.type !== "text" ||
!parent.hasOwnProperty("tagName") ||
(parent as Element).tagName !== "code"
) {
const varPattern = new RegExp("<Var [^>]+/>", "g");
(node as Text).value = (node as Text).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);
if (
varElement.children.length !== 1 ||
(varElement.children[0] as MdxJsxFlowElement).name !== "Var"
) {
throw new Error(
`Problem parsing file ${file.path}: malformed Var element within a code snippet`
);
}

placeholdersToVars[placeholder] = varElement
.children[0] as MdxJsxFlowElement;
return placeholder;
}
);
return [CONTINUE];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what a weird pattern to return a const like that in an array lol, never seen anyhting like it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a tuple to control AST traversal: https://www.npmjs.com/package/unist-util-visit#visittree-test-visitor-reverse

Yeah, pretty odd!

}
});

const varPattern = new RegExp("<Var [^>]+/>", "g");
(node as Text).value = (node as Text).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);
if (
varElement.children.length !== 1 ||
(varElement.children[0] as any).name !== "Var"
) {
throw new Error(
`Problem parsing file ${file.path}: malformed Var element within a code snippet`
);
}

placeholdersToVars[placeholder] = varElement.children[0];
return placeholder;
}
);
});
// Apply syntax highlighting
(highlighter as Function)(root);

Expand All @@ -83,91 +73,103 @@ export const rehypeVarInHLJS = (
// placeholder UUIDs and replace them with their original Var elements,
// inserting these as HTML AST nodes.
visit(root, undefined, (node: Node, index: number, parent: Parent) => {
const el = node as Element;
const el = node as Element | Text;
// We expect the element to have a single text node or be a single text
// node.
if (
el.type === "element" &&
el.children.length === 1 &&
el.children[0].type === "text"
!(
el.type === "element" &&
el.children.length === 1 &&
el.children[0].type === "text"
) &&
!(el.type === "text")
) {
const hljsSpanValue = (el.children[0] as Text).value;
return [CONTINUE];
}

// This is an hljs span with only the placeholder as its child.
// We don't need the span, so replace it with the original Var.
if (placeholdersToVars[hljsSpanValue]) {
(parent as any).children[index] = placeholdersToVars[hljsSpanValue];
return [CONTINUE];
}
let hljsSpanValue = "";
if (el.type === "text") {
hljsSpanValue = el.value;
} else {
hljsSpanValue = (el.children[0] as Text).value;
}

const placeholders = Array.from(
hljsSpanValue.matchAll(new RegExp(placeholderPattern, "g"))
);
// 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];
}

// No placeholders to recover, so there's nothing more to do.
if (placeholders.length == 0) {
return [CONTINUE];
}
const placeholders = Array.from(
hljsSpanValue.matchAll(new RegExp(placeholderPattern, "g"))
);

// An hljs span's text includes one or more Vars among other content, so
// we need to replace the span with a series of spans separated by
// Vars.
let lastIndex = 0;
let newChildren: Array<MdxJsxFlowElement | Element> = [];
// If there is content before the first Var, separate it into a new hljs
// span.
if (placeholders[0].index > 0) {
// 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: hljsSpanValue.substring(
lastIndex,
placeholders[0].index
),
value: textVal,
},
],
});
lastIndex = placeholders[0].index;
}
placeholders.forEach((ph, i) => {
const placeholderValue = ph[0];
newChildren.push(placeholdersToVars[placeholderValue]);
lastIndex += placeholderValue.length;

// Check if there is some non-Var text between either (a) this and the
// next Var or (b) between this Var and the end of the content. If
// so, add another span and advance the last index.
let nextIndex = 0;
if (i < placeholders.length - 1) {
nextIndex = placeholders[i + 1].index;
} else if (i == placeholders.length - 1) {
nextIndex = hljsSpanValue.length;
}
if (lastIndex < nextIndex) {
newChildren.push({
tagName: "span",
type: "element",
properties: el.properties,
children: [
{
type: "text",
value: hljsSpanValue.substring(lastIndex, nextIndex),
},
],
});
lastIndex = nextIndex;
}
});
// Delete the current span and replace it with the new children.
(parent.children as Array<MdxJsxFlowElement | Element>).splice(
index,
1,
...newChildren
);
return [SKIP, index + newChildren.length];
}

// 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];
});
};
};
15 changes: 15 additions & 0 deletions uvu-tests/rehype-hljs-var.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,19 @@ Suite("Ignore VarList in code snippet components", () => {
});
});

Suite("Next node as one of several code node children", () => {
const result = transformer({
value: readFileSync(resolve("server/fixtures/hcl-addr-var.mdx"), "utf-8"),
path: "/docs/index.mdx",
});

assert.equal(
(result.value as string).trim(),
readFileSync(
resolve("server/fixtures/result/hcl-addr-var.html"),
"utf-8"
).trim()
);
});

Suite.run();
Loading