@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
117 lines (102 loc) • 4.17 kB
text/typescript
import type { HighlighterGeneric } from "@shikijs/types";
import { Parser, createHighlightPlugin } from "prosemirror-highlight";
import { createParser } from "prosemirror-highlight/shiki";
import type { SyntaxHighlightingOptions } from "./SyntaxHighlighting.js";
import { CustomBlockNoteSchema } from "../../schema/schema.js";
export const shikiParserSymbol = Symbol.for("blocknote.shikiParser");
export const shikiHighlighterPromiseSymbol = Symbol.for(
"blocknote.shikiHighlighterPromise",
);
// Languages that represent "no highlighting" - skipped without asking Shiki to
// load a grammar for them.
const PLAIN_TEXT_LANGUAGES = ["text", "none", "plaintext", "txt"];
/**
* Creates the syntax highlighting plugin for the given block types, lazily
* loading the highlighter on first use.
*
* Each spec's `meta.highlight` callback resolves a node to a language, which is
* passed straight to Shiki - it resolves aliases and loads the grammar from its
* bundle, so any language the provided highlighter bundles can be highlighted.
*/
export function lazyShikiPlugin(
options: SyntaxHighlightingOptions,
nodeTypes: string[],
schema: CustomBlockNoteSchema<any, any, any>,
) {
const globalThisForShiki = globalThis as {
[shikiHighlighterPromiseSymbol]?: Promise<HighlighterGeneric<any, any>>;
[shikiParserSymbol]?: Parser;
};
let highlighter: HighlighterGeneric<any, any> | undefined;
let parser: Parser | undefined;
// Languages the highlighter failed to load (e.g. not in its bundle). Tracked
// so we don't keep retrying - and re-triggering re-highlights - forever.
const unsupportedLanguages = new Set<string>();
const lazyParser: Parser = (parserOptions) => {
if (!options.createHighlighter) {
return [];
}
if (!highlighter) {
globalThisForShiki[shikiHighlighterPromiseSymbol] =
globalThisForShiki[shikiHighlighterPromiseSymbol] ||
options.createHighlighter();
return globalThisForShiki[shikiHighlighterPromiseSymbol].then(
(createdHighlighter) => {
highlighter = createdHighlighter;
},
);
}
const language = parserOptions.language;
if (
!language ||
PLAIN_TEXT_LANGUAGES.includes(language) ||
unsupportedLanguages.has(language)
) {
return [];
}
if (!highlighter.getLoadedLanguages().includes(language)) {
return highlighter.loadLanguage(language as any).catch(() => {
// The highlighter doesn't bundle this language - give up on it so we
// don't loop trying to load it on every re-highlight.
unsupportedLanguages.add(language);
});
}
if (!parser) {
parser =
globalThisForShiki[shikiParserSymbol] ||
createParser(highlighter as any, pickThemeOptions(highlighter));
globalThisForShiki[shikiParserSymbol] = parser;
}
return parser(parserOptions);
};
return createHighlightPlugin({
parser: lazyParser,
// The highlight plugin only gives us the block content node, so we can only
// reconstruct the block's `type` and `props` (which is all a spec's
// `meta.highlight` needs to pick a language).
languageExtractor: (node) => {
const nodeShape = {
type: node.type.name,
props: node.attrs,
};
// search for the node in the blockSpec or inlineContentSpecs
const spec =
schema.blockSpecs[nodeShape.type] ||
schema.inlineContentSpecs[nodeShape.type];
return spec?.implementation?.meta?.highlight?.(nodeShape) ?? undefined;
},
nodeTypes,
});
}
// If a light and dark theme is added to the highlighter, this function specifies them in
// `createParser`. This lets us use `--shiki-light` and `--shiki-dark` CSS variables for correct
// styling for both light & dark editor themes.
function pickThemeOptions(highlighter: HighlighterGeneric<any, any>) {
const themes = highlighter.getLoadedThemes();
const light = themes.find((t) => /light/i.test(t));
const dark = themes.find((t) => /dark/i.test(t));
if (light && dark) {
return { themes: { light, dark }, defaultColor: false as const };
}
return undefined;
}