The Node.js® Website
1'use strict';
2
3import classNames from 'classnames';
4import { toString } from 'hast-util-to-string';
5import { SKIP, visit } from 'unist-util-visit';
6
7import { getShiki, highlightToHast } from './util/getHighlighter';
8
9// This is what Remark will use as prefix within a <pre> className
10// to attribute the current language of the <pre> element
11const languagePrefix = 'language-';
12
13// We do a top-level await, since the Unist-tree visitor
14// is synchronous, and it makes more sense to do a top-level
15// await, rather than an await inside the visitor function
16const memoizedShiki = await getShiki();
17
18/**
19 * Retrieve the value for the given meta key.
20 *
21 * @example - Returns "CommonJS"
22 * getMetaParameter('displayName="CommonJS"', 'displayName');
23 *
24 * @param {any} meta - The meta parameter.
25 * @param {string} key - The key to retrieve the value.
26 *
27 * @return {string | undefined} - The value related to the given key.
28 */
29function getMetaParameter(meta, key) {
30 if (typeof meta !== 'string') {
31 return;
32 }
33
34 const matches = meta.match(new RegExp(`${key}="(?<parameter>[^"]*)"`));
35 const parameter = matches?.groups.parameter;
36
37 return parameter !== undefined && parameter.length > 0
38 ? parameter
39 : undefined;
40}
41
42/**
43 * @typedef {import('unist').Node} Node
44 * @property {string} tagName
45 * @property {Array<import('unist').Node>} children
46 */
47
48/**
49 * Checks if the given node is a valid code element.
50 *
51 * @param {import('unist').Node} node - The node to be verified.
52 *
53 * @return {boolean} - True when it is a valid code element, false otherwise.
54 */
55function isCodeBlock(node) {
56 return Boolean(
57 node?.tagName === 'pre' && node?.children[0].tagName === 'code'
58 );
59}
60
61export default function rehypeShikiji() {
62 return async function (tree) {
63 visit(tree, 'element', (_, index, parent) => {
64 const languages = [];
65 const displayNames = [];
66 const codeTabsChildren = [];
67
68 let defaultTab = '0';
69 let currentIndex = index;
70
71 while (isCodeBlock(parent?.children[currentIndex])) {
72 const codeElement = parent?.children[currentIndex].children[0];
73
74 const displayName = getMetaParameter(
75 codeElement.data?.meta,
76 'displayName'
77 );
78
79 // We should get the language name from the class name
80 if (codeElement.properties.className?.length) {
81 const className = codeElement.properties.className.join(' ');
82 const matches = className.match(/language-(?<language>.*)/);
83
84 languages.push(matches?.groups.language ?? 'text');
85 }
86
87 // Map the display names of each variant for the CodeTab
88 displayNames.push(displayName?.replaceAll('|', '') ?? '');
89
90 codeTabsChildren.push(parent?.children[currentIndex]);
91
92 // If `active="true"` is provided in a CodeBox
93 // then the default selected entry of the CodeTabs will be the desired entry
94 const specificActive = getMetaParameter(
95 codeElement.data?.meta,
96 'active'
97 );
98
99 if (specificActive === 'true') {
100 defaultTab = String(codeTabsChildren.length - 1);
101 }
102
103 const nextNode = parent?.children[currentIndex + 1];
104
105 // If the CodeBoxes are on the root tree the next Element will be
106 // an empty text element so we should skip it
107 currentIndex += nextNode && nextNode?.type === 'text' ? 2 : 1;
108 }
109
110 if (codeTabsChildren.length >= 2) {
111 const codeTabElement = {
112 type: 'element',
113 tagName: 'CodeTabs',
114 children: codeTabsChildren,
115 properties: {
116 languages: languages.join('|'),
117 displayNames: displayNames.join('|'),
118 defaultTab,
119 },
120 };
121
122 // This removes all the original Code Elements and adds a new CodeTab Element
123 // at the original start of the first Code Element
124 parent.children.splice(index, currentIndex - index, codeTabElement);
125
126 // Prevent visiting the code block children and for the next N Elements
127 // since all of them belong to this CodeTabs Element
128 return [SKIP];
129 }
130 });
131
132 visit(tree, 'element', (node, index, parent) => {
133 // We only want to process <pre>...</pre> elements
134 if (!parent || index == null || node.tagName !== 'pre') {
135 return;
136 }
137
138 // We want the contents of the <pre> element, hence we attempt to get the first child
139 const preElement = node.children[0];
140
141 // If thereÄs nothing inside the <pre> element... What are we doing here?
142 if (!preElement || !preElement.properties) {
143 return;
144 }
145
146 // Ensure that we're not visiting a <code> element but it's inner contents
147 // (keep iterating further down until we reach where we want)
148 if (preElement.type !== 'element' || preElement.tagName !== 'code') {
149 return;
150 }
151
152 // Get the <pre> element class names
153 const preClassNames = preElement.properties.className;
154
155 // The current classnames should be an array and it should have a length
156 if (typeof preClassNames !== 'object' || preClassNames.length === 0) {
157 return;
158 }
159
160 // We want to retrieve the language class name from the class names
161 const codeLanguage = preClassNames.find(
162 c => typeof c === 'string' && c.startsWith(languagePrefix)
163 );
164
165 // If we didn't find any `language-` classname then we shouldn't highlight
166 if (typeof codeLanguage !== 'string') {
167 return;
168 }
169
170 // Retrieve the whole <pre> contents as a parsed DOM string
171 const preElementContents = toString(preElement);
172
173 // Grabs the relevant alias/name of the language
174 const languageId = codeLanguage.slice(languagePrefix.length);
175
176 // Parses the <pre> contents and returns a HAST tree with the highlighted code
177 const { children } = highlightToHast(memoizedShiki)(
178 preElementContents,
179 languageId
180 );
181
182 // Adds the original language back to the <pre> element
183 children[0].properties.class = classNames(
184 children[0].properties.class,
185 codeLanguage
186 );
187
188 const showCopyButton = getMetaParameter(
189 preElement.data?.meta,
190 'showCopyButton'
191 );
192
193 // Adds a Copy Button to the CodeBox if requested as an additional parameter
194 // And avoids setting the property (overriding) if undefined or invalid value
195 if (showCopyButton && ['true', 'false'].includes(showCopyButton)) {
196 children[0].properties.showCopyButton = showCopyButton;
197 }
198
199 // Replaces the <pre> element with the updated one
200 parent.children.splice(index, 1, ...children);
201 });
202 };
203}