UNPKG

carta-md

Version:

A lightweight, fully customizable, Markdown editor

276 lines (275 loc) 10.6 kB
import * as shiki from 'shiki'; export const customMarkdownLangName = 'cartamd'; export const defaultLightThemeName = 'carta-light'; export const defaultDarkThemeName = 'carta-dark'; /** * Load the return the default light and dark themes. * @returns The default light and dark themes. */ export async function loadDefaultTheme() { return { light: structuredClone((await import('./assets/theme-light')).default), dark: structuredClone((await import('./assets/theme-dark')).default) }; } /** * Checks if a language is a bundled language. * @param lang The language to check. * @returns Whether the language is a bundled language. */ export const isBundleLanguage = (lang) => Object.keys(shiki.bundledLanguages).includes(lang); /** * Checks if a theme is a bundled theme. * @param theme The theme to check. * @returns Whether the theme is a bundled theme. */ export const isBundleTheme = (theme) => Object.keys(shiki.bundledThemes).includes(theme); /** * Checks if a theme is a dual theme. * @param theme The theme to check. * @returns Whether the theme is a dual theme. */ export const isDualTheme = (theme) => typeof theme == 'object' && 'light' in theme && 'dark' in theme; /** * Checks if a theme is a single theme. * @param theme The theme to check. * @returns Whether the theme is a single theme. */ export const isSingleTheme = (theme) => !isDualTheme(theme); /** * Checks if a theme is a theme registration. * @param theme The theme to check. * @returns Whether the theme is a theme registration. */ export const isThemeRegistration = (theme) => typeof theme == 'object'; /** * Injects custom grammar rules into the language definition. * @param lang The language definition to inject the rules into. * @param rules The grammar rules to inject. * @returns The language definition with the injected rules. */ export function injectGrammarRules(lang, rules) { lang.repository = { ...lang.repository, ...Object.fromEntries(rules.map(({ name, definition }) => [name, definition])) }; for (const rule of rules) { if (rule.type === 'block') { lang.repository.block.patterns.unshift({ include: `#${rule.name}` }); } else { lang.repository.inline.patterns.unshift({ include: `#${rule.name}` }); } } } /** * Injects custom highlighting rules into the theme. * @param theme The theme to inject the rules into. * @param rules The highlighting rules to inject. * @returns The theme with the injected rules. */ export function injectHighlightRules(theme, rules) { if (theme.type === 'light') { theme.tokenColors ||= []; theme.tokenColors.unshift(...rules.map(({ light }) => light)); } else { theme.tokenColors ||= []; theme.tokenColors.unshift(...rules.map(({ dark }) => dark)); } } // Store a single highlighter manager, with multiple languages if necessary. let manager = null; async function getManager() { if (manager !== null) return manager; // Immediately assign a promise to the manager variable // to prevent multiple calls to getManager from creating multiple instances // since shiki.getHighlighter is an async function. manager = (async () => ({ shikiHighlighter: await shiki.createHighlighter({ langs: [], themes: [] }), highlighters: [] }))(); return manager; } /** * Simple hash function to generate a hash from a string. */ function simpleHash(str, seed = 0) { let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; for (let i = 0, ch; i < str.length; i++) { ch = str.charCodeAt(i); h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); } h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); return 4294967296 * (2097151 & h2) + (h1 >>> 0); } function langAndGrammarHash(lang, rules) { const grammarHash = rules .map(({ name }) => name) .toSorted() .join(''); return `${lang}-${simpleHash(`${lang}${grammarHash}`)}`; } function themeAndHighlightingHash(theme, rules) { const highlightingHash = rules .map((rule) => `${rule.light}${rule.dark}`) .toSorted() .join(''); return `${theme}-${simpleHash(`${theme}${highlightingHash}`)}`; } export async function loadHighlighter(options) { const { grammarRules, highlightingRules, theme, shiki: shikiOptions } = options; const manager = await getManager(); // Additional themes and languages provided by the user const langs = shikiOptions?.langs ?? []; const themes = shikiOptions?.themes ?? []; const loadedLangs = manager.shikiHighlighter.getLoadedLanguages(); const loadedThemes = manager.shikiHighlighter.getLoadedThemes(); const langsToLoad = langs.filter((lang) => typeof lang != 'string' || !loadedLangs.includes(lang)); const themesToLoad = themes.filter((theme) => typeof theme != 'string' || !loadedThemes.includes(theme)); manager.shikiHighlighter.loadLanguage(...langsToLoad); manager.shikiHighlighter.loadTheme(...themesToLoad); // Custom markdown language const langDefinition = (await import('./assets/markdown')).default; const langHash = langAndGrammarHash(langDefinition.name, grammarRules); // Check if there is an existing highlighter with the same language and grammar hash const langAlreadyLoaded = manager.highlighters.find((highlighter) => highlighter.settings.langHash === langHash); if (!langAlreadyLoaded) { const langClone = structuredClone(langDefinition); // Load the custom language injectGrammarRules(langClone, grammarRules); langClone.name = langHash; await manager.shikiHighlighter.loadLanguage(langClone); } let themeHash; // Themes if (isSingleTheme(theme)) { let themeRegistration; if (isThemeRegistration(theme)) { themeRegistration = theme; } else { themeRegistration = (await shiki.bundledThemes[theme]()).default; } themeHash = themeAndHighlightingHash(themeRegistration.name ?? 'unknown', highlightingRules); const existingHighlighter = manager.highlighters.find((highlighter) => highlighter.settings.themeHash === themeHash); if (!existingHighlighter) { const langClone = structuredClone(langDefinition); injectHighlightRules(langClone, highlightingRules); langClone.name = themeHash; await manager.shikiHighlighter.loadTheme(langClone); } } else { const { light, dark } = theme; let lightRegistration; let darkRegistration; if (isThemeRegistration(light)) { lightRegistration = light; } else { lightRegistration = (await shiki.bundledThemes[light]()).default; } if (isThemeRegistration(dark)) { darkRegistration = dark; } else { darkRegistration = (await shiki.bundledThemes[dark]()).default; } const lightHash = themeAndHighlightingHash(lightRegistration.name ?? 'unknown', highlightingRules); const darkHash = themeAndHighlightingHash(darkRegistration.name ?? 'unknown', highlightingRules); themeHash = { light: lightHash, dark: darkHash }; const existingHighlighter = manager.highlighters.find((highlighter) => highlighter.settings.themeHash === lightHash || highlighter.settings.themeHash === darkHash); if (!existingHighlighter) { const lightClone = structuredClone(lightRegistration); const darkClone = structuredClone(darkRegistration); injectHighlightRules(lightClone, highlightingRules); injectHighlightRules(darkClone, highlightingRules); lightClone.name = lightHash; darkClone.name = darkHash; await manager.shikiHighlighter.loadTheme(lightClone); await manager.shikiHighlighter.loadTheme(darkClone); } } const highlighter = { codeToHtml: (code) => { if (isSingleTheme(theme)) { // Single theme return manager.shikiHighlighter.codeToHtml(code, { lang: langHash, theme: themeHash, tabindex: -1 }); } else { // Dual theme return manager.shikiHighlighter.codeToHtml(code, { lang: langHash, themes: { light: themeHash.light, dark: themeHash.dark }, tabindex: -1 }); } }, shikiHighlighter: () => manager.shikiHighlighter, settings: { langHash, themeHash }, utils: { isBundleLanguage, isBundleTheme, isDualTheme, isSingleTheme, isThemeRegistration } }; manager.highlighters.push(highlighter); return highlighter; } /** * Find all nested languages in the markdown text and load them into the highlighter. * @param text Markdown text to parse for nested languages. * @returns The set of nested languages found in the text. */ const findNestedLanguages = (text) => { const languages = new Set(); const regex = /```(\w+)$/gm; let match; while ((match = regex.exec(text))) { languages.add(match[1]); } return languages; }; /** * Load all nested languages found in the markdown text into the highlighter. * @param highlighter The highlighter instance. * @param text The text to parse for nested languages. * @returns Whether the highlighter was updated with new languages. */ export const loadNestedLanguages = async (highlighter, text) => { text = text.replaceAll('\r\n', '\n'); // Normalize line endings const languages = findNestedLanguages(text); const loadedLanguages = highlighter.shikiHighlighter().getLoadedLanguages(); let updated = false; for (const lang of languages) { if (isBundleLanguage(lang) && !loadedLanguages.includes(lang)) { await highlighter.shikiHighlighter().loadLanguage(lang); loadedLanguages.push(lang); updated = true; } } return { updated }; };