UNPKG

quizdown-extended

Version:

A fork from [bonartm/quizdown-js](https://github.com/bonartm/quizdown-js) with more features. > The Katex-extension is currently not supported

255 lines (217 loc) 7.48 kB
import { createHighlighterCore, type HighlighterCore } from '@shikijs/core'; import { createOnigurumaEngine } from '@shikijs/engine-oniguruma'; import { getShadowRoots } from './shadowRootManager'; /** * Load the default export of a small ESM/CJS snippet (string) without using dynamic import. * This avoids Webpack's "Critical dependency: the request of a dependency is an expression" warning * in downstream apps (e.g., Docusaurus) while still supporting URL-based loaders. */ function loadDefaultFromSource(jsText: string): any { // Try ESM: transform `export default X` into `return X` if (/\bexport\s+default\b/.test(jsText)) { const body = jsText.replace(/\bexport\s+default\b/, 'return '); // Execute in a fresh function scope return new Function(body)(); } // Try CommonJS: `module.exports = X` or `exports.default = X` if (/module\.exports\s*=/.test(jsText)) { const match = jsText.match(/module\.exports\s*=\s*([\s\S]*?);?\s*$/); if (match) { return new Function('return (' + match[1] + ')')(); } } if (/exports\.default\s*=/.test(jsText)) { const match = jsText.match(/exports\.default\s*=\s*([\s\S]*?);?\s*$/); if (match) { return new Function('return (' + match[1] + ')')(); } } // As a last resort, attempt to eval and look for a `default` global this assigns // (not expected for Shiki themes/languages). return new Function(jsText)(); } let globalHighlighter: HighlighterCore | null = null; let initializationPromise: Promise<HighlighterCore> | null = null; type ThemeEntry = { url: string; name: string } // Set to keep track of loaded URLs const loadedThemes = new Map<'light' | 'dark', ThemeEntry>(); const loadedLanguages = new Set<string>(); // Map to track loaded theme CSS by theme name const themeCssMap = new Map<string, string>(); /** * Get singleton highlighter instance */ export async function getHighlighterInstance(): Promise<HighlighterCore> { if (globalHighlighter) return globalHighlighter; if (initializationPromise) return initializationPromise; initializationPromise = (async () => { try { const wasm = await import('shiki/wasm'); const highlighter = await createHighlighterCore({ themes: [], langs: [], engine: createOnigurumaEngine(wasm), }); globalHighlighter = highlighter; return highlighter; } catch (error) { console.error('Error initializing highlighter:', error); initializationPromise = null; throw error; } })(); return initializationPromise; } /** * Load theme if not already loaded */ export async function registerTheme(name: string, type: 'light' | 'dark', url: string): Promise<void>; export async function registerTheme(name: string, type: 'light' | 'dark', themeObject: any): Promise<void>; // Implementation export async function registerTheme( name: string, type: 'light' | 'dark', third: string | any ): Promise<void> { if (loadedThemes.has(type)) { return; } try { let theme: any; let url: string = 'inlined'; if (typeof third === 'string') { // Remote URL url = third; const res = await fetch(url); const jsText = await res.text(); theme = loadDefaultFromSource(jsText); } else { // Direct theme object theme = third; } const highlighter = await getHighlighterInstance(); await highlighter.loadTheme(theme); if (typeof theme.css === 'string') { const themeName = theme.name || name; themeCssMap.set(themeName, theme.css); } loadedThemes.set(type, { url, name: theme.name || name }); } catch (error) { console.error(`Error loading theme:`, error); } } /** * Load language if not already loaded */ // Overload signatures export async function registerLanguage(url: string): Promise<void>; export async function registerLanguage(languageObject: any): Promise<void>; // Implementation export async function registerLanguage( arg: string | any ): Promise<void> { try { let language: any; let key: string; if (typeof arg === 'string') { // Remote language by URL if (loadedLanguages.has(arg)) { return; } const res = await fetch(arg); const jsText = await res.text(); language = loadDefaultFromSource(jsText); key = arg; } else { // Direct language object language = arg; key = language.name || JSON.stringify(language); // fallback if no name if (loadedLanguages.has(key)) { return; } } const highlighter = await getHighlighterInstance(); await highlighter.loadLanguage(language); loadedLanguages.add(key); } catch (error) { console.error(`Error loading language:`, error); } } type HighlightOptions = { includeDocument?: boolean; }; /** * Highlight all code blocks */ export async function highlightAllCodeBlocks( root: ShadowRoot | Document | HTMLElement = document, themes: Map<'light' | 'dark', ThemeEntry> = loadedThemes, options: HighlightOptions = {} ): Promise<void> { const highlighter = await getHighlighterInstance(); const includeDocument = options.includeDocument ?? root === document; const roots = new Set<ShadowRoot | Document | HTMLElement>(); const addRoot = ( candidate: ShadowRoot | Document | HTMLElement | null | undefined ) => { if (!candidate || typeof candidate.querySelectorAll !== 'function') { return; } roots.add(candidate); }; addRoot(root); if (includeDocument && typeof document !== 'undefined') { addRoot(document); } for (const shadowRoot of getShadowRoots()) { addRoot(shadowRoot); } const codeBlocks = Array.from(roots).flatMap((ctx) => Array.from( ctx.querySelectorAll<HTMLElement>('code[class^="language-"]') ) ); for (const code of Array.from(codeBlocks)) { const langClass = Array.from(code.classList).find(cls => cls.startsWith('language-')); const lang = langClass?.replace('language-', ''); if (!lang || !code.textContent) continue; try { const html = await highlighter.codeToHtml(code.textContent, { lang, themes: { light: themes.get('light')?.name, dark: themes.get('dark')?.name }, defaultColor: 'light-dark()', }); // Parse the returned HTML const container = document.createElement('div'); container.innerHTML = html; // Shiki always returns <pre class="shiki ..."><code>...</code></pre> const shikiPre = container.querySelector('pre.shiki') as HTMLPreElement | null; if (!shikiPre) continue; // Make each code block horizontally scrollable (no wrapping) shikiPre.style.maxWidth = '100%'; shikiPre.style.overflowX = 'auto'; shikiPre.style.overflowY = 'visible'; shikiPre.style.whiteSpace = 'pre'; shikiPre.tabIndex = 0; // keep keyboard scrollability // If original code lived inside a <pre>, replace THAT <pre> (prevents <pre><pre> nesting) const originalPre = code.closest('pre'); if (originalPre && originalPre.parentNode) { originalPre.replaceWith(shikiPre); } else if (code.parentNode) { // Fallback: no <pre> wrapper; just replace the <code> node code.parentNode.replaceChild(shikiPre, code); } } catch (err) { console.error(`Error highlighting code block (lang: ${lang}):`, err); } } }