@microflash/rehype-starry-night
Version:
rehype plugin to highlight codeblocks with Starry Night
246 lines (208 loc) • 6.38 kB
JavaScript
import defu from "defu";
import { createStarryNight, all } from "@wooorm/starry-night";
import { visit } from "unist-util-visit";
import { toString } from "hast-util-to-string";
import parse from "@microflash/fenceparser";
import { h } from "hastscript";
import headerLanguagePlugin from "./plugins/header-language-plugin.js";
import headerTitlePlugin from "./plugins/header-title-plugin.js";
import lineMarkPlugin from "./plugins/line-mark-plugin.js";
import linePromptPlugin from "./plugins/line-prompt-plugin.js";
import lineOutputPlugin from "./plugins/line-output-plugin.js";
import lineInsPlugin from "./plugins/line-ins-plugin.js";
import lineDelPlugin from "./plugins/line-del-plugin.js";
const prefix = "language-";
const search = /\r?\n|\r/g;
const defaults = {
classNamePrefix: "hl"
};
export const defaultPluginPack = [
headerLanguagePlugin,
headerTitlePlugin,
lineMarkPlugin,
linePromptPlugin,
lineOutputPlugin,
lineInsPlugin,
lineDelPlugin
];
export default function rehypeStarryNight(userOptions = {}) {
const {
aliases = {},
grammars = all,
plugins = defaultPluginPack,
classNamePrefix
} = defu(userOptions, defaults);
const starryNightPromise = createStarryNight(grammars);
return async function(tree) {
const starryNight = await starryNightPromise;
visit(tree, "element", (node, index, parent) => {
if (!parent || index === null || node.tagName !== "pre") return;
const [ head ] = node.children;
if (!head || head.type !== "element" || head.tagName !== "code" || !head.properties) return;
const classes = head.properties.className;
let languageFragment;
let languageId;
if (classes) {
const languageClass = classes.find(d => typeof d === "string" && d.startsWith(prefix));
languageFragment = languageClass.slice(prefix.length);
languageId = aliases[languageFragment] || languageFragment;
} else {
languageId = "txt";
}
const code = toString(head);
const scope = starryNight.flagToScope(languageId);
let children;
if (scope) {
const fragment = starryNight.highlight(code, scope);
children = fragment.children;
} else {
console.warn(`[rehype-starry-night]: Skipping syntax highlighting for code in unknown language '${languageId}'`);
children = head.children;
}
const globalOptions = {
id: btoa(Math.random()).replace(/=/g, "").substring(0, 12),
metadata: extractMetadata(head),
language: languageFragment,
classNamePrefix
};
const codeParent = h(`div.${classNamePrefix}.${classNamePrefix}-${languageId}`);
// apply header plugins
if (plugins) {
const headerPlugins = plugins.filter(p => p.type === "header");
if (headerPlugins) {
const headerNodes = [];
headerPlugins.forEach(p => p.plugin(globalOptions, headerNodes));
if (headerNodes.length > 0) {
const header = h(`div.${classNamePrefix}-header`, headerNodes);
codeParent.children = [
header,
...codeParent.children || []
];
}
}
}
// apply line plugins
const lines = linesByLineNumber(children);
if (plugins) {
const linePlugins = plugins.filter(p => p.type === "line");
if (linePlugins) {
linePlugins.forEach(p => p.plugin(globalOptions, lines));
}
}
const preProps = {};
// add line number gutter width for codeblock with multiple lines
const lineNumberGutterFactor = `${lines.size}`.length;
if (lines.size > 1) {
preProps["style"] = `--hl-line-number-gutter-factor: ${lineNumberGutterFactor}`;
}
if (globalOptions.lineMarkerGutterFactor) {
preProps["style"] += `; --hl-line-marker-gutter-factor: ${globalOptions.lineMarkerGutterFactor}`;
}
// prepare codeblock nodes
const preChildren = [];
for (const [lineNumber, line] of lines) {
const { "data-line-number": dataLineNumber, ...lineProps } = line.properties;
const lineNodes = dataLineNumber ?
[
h("span.line-number", { "aria-hidden": "true" }, `${lineNumber}`),
...line.children
] : line.children;
preChildren.push(h("span.line", { ...lineProps }, lineNodes));
if (line.eol) {
preChildren.push(line.eol);
}
}
codeParent.children.push(
h(`pre#${globalOptions.id}`, preProps,
h("code", { tabindex: 0 }, preChildren)
)
);
parent.children.splice(index, 1, codeParent);
});
};
}
const fenceparserOptions = {
rangeKey: "highlight"
}
function extractMetadata(node) {
let metadata;
try {
const { meta } = node.data || {};
metadata = parse(meta, fenceparserOptions);
} catch (e) { }
return metadata || {};
}
function linesByLineNumber(nodes) {
let index = -1;
let start = 0;
let lineNumber = 0;
let startTextRemainder = "";
const lines = new Map();
while (++index < nodes.length) {
const node = nodes[index];
if (node.type === "text") {
let textStart = 0;
let match = search.exec(node.value);
while (match) {
// nodes in this line
const line = nodes.slice(start, index);
// prepend text from a partial matched earlier text
if (startTextRemainder) {
line.unshift({ type: "text", value: startTextRemainder });
startTextRemainder = "";
}
// append text from this text
if (match.index > textStart) {
line.push({
type: "text",
value: node.value.slice(textStart, match.index)
});
}
// add a line, and the eol
lineNumber += 1;
lines.set(
lineNumber,
{
children: line,
properties: {
"data-line-number": lineNumber
},
eol: {
type: "text",
value: match[0]
}
}
);
start = index + 1;
textStart = match.index + match[0].length;
match = search.exec(node.value);
}
// if we matched, make sure to not drop the text after the last line ending
if (start === index + 1) {
startTextRemainder = node.value.slice(textStart);
}
}
}
const line = nodes.slice(start);
// prepend text from a partial matched earlier text
if (startTextRemainder) {
line.unshift({ type: "text", value: startTextRemainder });
startTextRemainder = "";
}
if (line.length > 0) {
lineNumber += 1;
lines.set(
lineNumber,
{
children: line,
properties: {
"data-line-number": lineNumber
}
}
);
}
if (lines.size === 1) {
delete lines.get(1).properties["data-line-number"];
}
return lines;
}