The Node.js® Website
at main 6.7 kB view raw
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}