Skip to content

Commit

Permalink
Add Var component support (#37)
Browse files Browse the repository at this point in the history
* Add Var component support

Closes #14

Copy the `Var` component `VarsProvider` context provider from
`gravitational/docs` and adapt them to the Docusaurus site. Edit the
`remark-update-tags` to not remove `Var` components before building the
docs site.

Fix the visitor test function in `rehype-hljs`. Check for `Var`
placeholders as well as `Var` tags, since otherwise the test won't work
in the second `visit` call.

Also improve `rehype-hljs` by highlighting any unlabeled code snippets
as `text` to avoid rendering issues.

* Edit rehype-hljs

Don't check text values in the visitor test function. Since we execute
this function against every rehype AST node in the docs, don't create a
RegExp and match it to find `Var` tags or placeholders. We do this
inside the visitor functions anyway.
  • Loading branch information
ptgott authored Dec 6, 2024
1 parent 9d97ae8 commit 833b276
Show file tree
Hide file tree
Showing 9 changed files with 384 additions and 160 deletions.
264 changes: 140 additions & 124 deletions server/rehype-hljs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,61 +19,73 @@ const makePlaceholder = (): string => {

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) => {
// We only visit text nodes inside code snippets that include either the
// <Var tag or (if we have already swapped out Vars with placeholders) a
// placeholder.
const isPossibleVarContainer = (node: Node) => {
let textValue;
if (
node.type === "text" ||
(node.type === "element" &&
node.children.length === 1 &&
node.children[0].type === "text")
) {
return true;
} else {
return false;
}
};

// Highlight common languages in addition to any additional configured ones.
options.languages = { ...options.languages, ...common };

// Configure the highlighter to treat unlabeled code snippets as having the
// "text" language by enabling detection and making "text" the only
// possible language.
options.detect = true;
options.subset = ["text"];

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];
}
visit(
root,
isPossibleVarContainer,
(node: Node, index: number, parent: Parent) => {
const varPattern = new RegExp("<Var [^>]+/>", "g");
let txt: Text;
if (node.type == "text") {
txt = node;
} else {
// isPossibleVarContainer 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;
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);
Expand All @@ -82,91 +94,95 @@ export const rehypeHLJS = (options?: RehypeHighlightOptions): Transformer => {
// 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];
}
visit(
root,
isPossibleVarContainer,
(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;
}

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"))
);

// 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++;
// No placeholders to recover, so there's nothing more to do.
if (placeholders.length == 0) {
return [CONTINUE];
}
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,
},
],
});

// 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];
});
// 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];
}
);
};
};
2 changes: 1 addition & 1 deletion server/remark-code-snippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const getVariableNode = (

return {
type: "mdxJsxFlowElement",
name: "var",
name: "Var",
attributes: [
{ type: "mdxJsxAttribute", name: "name", value },
{
Expand Down
34 changes: 0 additions & 34 deletions server/remark-update-tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,40 +108,6 @@ export default function remarkMigrationUpdateTags(): Transformer {
return index; // index of the next element to traverse, in this case repeat the same index again
}

// Replace Var with uppercase content
if (isMdxNode(node) && node.name.toLowerCase() === "var") {
parent.children[index] = {
type: "text",
value:
getAttributeValue(node, "initial") ||
getAttributeValue(node, "name"),
} as MdastText;
}

// Replace Var in clode blocks
if (isCodeNode(node)) {
const regexNode = /(\<\s*[vV]ar\s+(.*?)\s*\/\>)/g;
const regexProperty = /([a-z]+)\s*=\s*"([^"]*?)"/gi;

node.value = node.value.replaceAll(regexNode, (match) => {
const propsHash = Array.from(match.matchAll(regexProperty)).reduce(
(result, value) => {
return { ...result, [value[1]]: value[2] };
},
{} as { initial: string; name: string }
);

return propsHash.initial || propsHash.name;
});
}

// Remove "code" code type
if (isCodeNode(node)) {
if (node.lang === "code") {
node.lang = "bash";
}
}

// Remove string styles from nodes
if (isMdxNode(node)) {
node.attributes = node.attributes.filter(
Expand Down
Loading

0 comments on commit 833b276

Please sign in to comment.