UNPKG

@wooorm/starry-night

Version:
350 lines (323 loc) 10.5 kB
/** * @import {Root} from 'hast' * @import {IGrammar, IRawGrammar} from 'vscode-textmate' * @import {Grammar, Options} from './types.js' */ import vscodeOniguruma from 'vscode-oniguruma' import vscodeTextmate from 'vscode-textmate' import {parse} from './parse.js' import {theme} from './theme.js' import {getOniguruma} from '#get-oniguruma' /** * Create a `StarryNight` that can highlight things with the given * `grammars`. * This is async to allow async loading and registering, which is currently * only used for WASM. * * @param {ReadonlyArray<Grammar>} grammars * Grammars to support. * @param {Readonly<Options> | null | undefined} [options] * Configuration (optional). * @returns * Promise that resolves to an instance which highlights with the bound * grammars. */ export async function createStarryNight(grammars, options) { /** @type {Map<string, Readonly<Grammar>>} */ const registered = new Map() /** @type {Map<string, string>} */ const names = new Map() /** @type {Map<string, string>} */ const extensions = new Map() /** @type {Map<string, string>} */ const extensionsWithDot = new Map() let currentRegistry = await createRegistry(grammars, options) return {flagToScope, highlight, missingScopes, register, scopes} /** * Get the grammar scope (such as `text.md`) associated with a grammar name * (such as `markdown`) or grammar extension (such as `.mdwn`). * * This function uses the first word (when splitting on spaces and tabs) that * is used after the opening of a fenced code block: * * ````markdown * ```js * console.log(1) * ``` * ```` * * To match GitHub, this also accepts entire paths: * * ````markdown * ```path/to/example.js * console.log(1) * ``` * ```` * * > **Note**: languages can use the same extensions. * > For example, `.h` is reused by many languages. * > In those cases, you will get one scope back, but it might not be the * > most popular language associated with an extension. * * @param {string} flag * Grammar name (such as `'markdown'`), grammar extension (such as * `'.mdwn'`), or path ending in extension. * @returns {string | undefined} * Grammar scope (such as `'text.md'`). * @example * ```js * import {common, createStarryNight} from '@wooorm/starry-night' * * const starryNight = await createStarryNight(common) * * console.log(starryNight.flagToScope('pandoc')) // `'text.md'` * console.log(starryNight.flagToScope('workbook')) // `'text.md'` * console.log(starryNight.flagToScope('.workbook')) // `'text.md'` * console.log(starryNight.flagToScope('path/to/example.js')) // `'source.js'` * console.log(starryNight.flagToScope('whatever')) // `undefined` * ``` */ function flagToScope(flag) { if (typeof flag !== 'string') { throw new TypeError('Expected `string` for `flag`, got `' + flag + '`') } const normal = flag .toLowerCase() .replace(/^[ \t]+/, '') .replace(/\/*[ \t]*$/g, '') const scopeByName = names.get(normal) if (scopeByName) { return scopeByName } const dot = normal.lastIndexOf('.') if (dot === -1) { return extensions.get('.' + normal) } const extension = normal.slice(dot) return extensions.get(extension) || extensionsWithDot.get(extension) } /** * Highlight programming code. * * @param {string} value * Code to highlight. * @param {string} scope * Registered grammar scope to highlight as (such as `'text.md'`). * @returns {Root} * Node representing highlighted code. * @example * ```js * import {createStarryNight} from '@wooorm/starry-night' * import sourceCss from '@wooorm/starry-night/source.css' * * const starryNight = await createStarryNight([sourceCss]) * * console.log(starryNight.highlight('em { color: red }', 'source.css')) * ``` * * Yields: * * ```js * { * type: 'root', * children: [ * {type: 'element', tagName: 'span', properties: [Object], children: [Array]}, * {type: 'text', value: ' { '}, * {type: 'element', tagName: 'span', properties: [Object], children: [Array]}, * {type: 'text', value: ': '}, * {type: 'element', tagName: 'span', properties: [Object], children: [Array]}, * {type: 'text', value: ' }'} * ] * } * ``` */ function highlight(value, scope) { if (typeof value !== 'string') { throw new TypeError('Expected `string` for `value`, got `' + value + '`') } if (typeof scope !== 'string') { throw new TypeError('Expected `string` for `scope`, got `' + scope + '`') } // Use the private API so we don’t need to cache again. /** @type {unknown} */ // @ts-expect-error: untyped internals of `vscode-textmate`. // type-coverage:ignore-next-line const map = currentRegistry._syncRegistry._grammars /** @type {IGrammar} */ // @ts-expect-error: untyped internals of `vscode-textmate`. const grammar = map.get(scope) if (!grammar) { throw new Error('Expected grammar `' + scope + '` to be registered') } return parse(value, grammar, currentRegistry.getColorMap()) } /** * List scopes that are needed by the registered grammars but that are * missing. * * To illustrate, the `text.xml.svg` grammar needs the `text.xml` grammar. * When you register `text.xml.svg` without `text.xml`, it will be listed * here. * * @returns {ReadonlyArray<string>} * List of grammar scopes, such as `'text.md'`. * @example * ```js * import {createStarryNight} from '@wooorm/starry-night' * import textXml from '@wooorm/starry-night/text.xml' * import textXmlSvg from '@wooorm/starry-night/text.xml.svg' * * const svg = await createStarryNight([textXmlSvg]) * console.log(svg.missingScopes()) //=> ['text.xml'] * * const svgAndXml = await createStarryNight([textXmlSvg, textXml]) * console.log(svgAndXml.missingScopes()) //=> [] * ``` */ function missingScopes() { /** @type {Set<string>} */ const available = new Set() /** @type {Set<string>} */ const needed = new Set() for (const [scopeName, grammar] of registered) { available.add(scopeName) if (grammar.dependencies) { for (const dep of grammar.dependencies) { needed.add(dep) } } } return [...needed] .filter(function (d) { return !available.has(d) }) .sort() } /** * Add more grammars. * * @param {ReadonlyArray<Readonly<Grammar>>} grammars * Grammars to support. * @returns {Promise<undefined>} * Promise resolving to nothing. * @example * ````js * import {createStarryNight} from '@wooorm/starry-night' * import sourceCss from '@wooorm/starry-night/source.css' * import textMd from '@wooorm/starry-night/text.md' * import {toHtml} from 'hast-util-to-html' * * const markdown = '```css\nem { color: red }\n```' * * const starryNight = await createStarryNight([textMd]) * * console.log(toHtml(starryNight.highlight(markdown, 'text.md'))) * * await starryNight.register([sourceCss]) * * console.log(toHtml(starryNight.highlight(markdown, 'text.md'))) * ```` * * Yields: * * ````html * <span class="pl-s">```</span><span class="pl-en">css</span> * <span class="pl-c1">em { color: red }</span> * <span class="pl-s">```</span> * ```` * * ````html * <span class="pl-s">```</span><span class="pl-en">css</span> * <span class="pl-ent">em</span> { <span class="pl-c1">color</span>: <span class="pl-c1">red</span> } * <span class="pl-s">```</span> * ```` */ async function register(grammars) { currentRegistry = await createRegistry(grammars) } /** * List all registered scopes. * * @returns {ReadonlyArray<string>} * List of grammar scopes, such as `'text.md'`. * @example * ```js * import {common, createStarryNight} from '@wooorm/starry-night' * * const starryNight = await createStarryNight(common) * * console.log(starryNight.scopes()) * ``` * * Yields: * * ```js * [ * 'source.c', * 'source.c++', * // … * 'text.xml', * 'text.xml.svg' * ] * ``` */ function scopes() { return [...registered.keys()].sort() } /** * @param {ReadonlyArray<Readonly<Grammar>>} grammars * Grammars. * @param {Readonly<Options> | null | undefined} [options] * Configuration (optional). * @returns * Registry. */ async function createRegistry(grammars, options) { for (const grammar of grammars) { const scope = grammar.scopeName for (const d of grammar.extensions) extensions.set(d, scope) if (grammar.extensionsWithDot) for (const d of grammar.extensionsWithDot) extensionsWithDot.set(d, scope) for (const d of grammar.names) names.set(d, scope) registered.set(scope, grammar) } const registry = new vscodeTextmate.Registry({ async loadGrammar(scopeName) { // Cast because `vscode-textmate` has much stricter types that needed by // textmate, or by what they actually support. // Given that we can’t fix the grammars provided by the world here, and // given that `vscode-textmate` is crying without a reason, we tell it to // shut up instead. const grammar = /** @type {IRawGrammar | undefined} */ ( registered.get(scopeName) ) return grammar }, onigLib: createOniguruma(options) }) registry.setTheme(theme) await Promise.all( [...registered.keys()].map(function (d) { return registry.loadGrammar(d) }) ) return registry } } /** * Small function needed for oniguruma to work. * * Idea: as this seems to be a singleton, would it help if we call it once and * keep the promise? * * @param {Readonly<Options> | null | undefined} [options] * Configuration (optional). * @returns * `vscode-oniguruma`. */ async function createOniguruma(options) { const wasmBinary = await getOniguruma(options || undefined) await vscodeOniguruma.loadWASM(wasmBinary) return vscodeOniguruma }