refractor
Version:
Lightweight, robust, elegant virtual syntax highlighting using Prism
317 lines (276 loc) • 7.26 kB
JavaScript
/**
* @import {Element, Root, Text} from 'hast'
* @import {Grammar, Languages} from 'prismjs'
*/
/**
* @typedef _Token
* Hidden Prism token.
* @property {string} alias
* Alias.
* @property {string} content
* Content.
* @property {number} length
* Length.
* @property {string} type
* Type.
*/
/**
* @typedef _Env
* Hidden Prism environment.
* @property {Record<string, string>} attributes
* Attributes.
* @property {Array<string>} classes
* Classes.
* @property {Array<Element | Text> | Element | Text} content
* Content.
* @property {string} language
* Language.
* @property {string} tag
* Tag.
* @property {string} type
* Type.
*/
/**
* @typedef {((prism: Refractor) => undefined | void) & {aliases?: Array<string> | undefined, displayName: string}} Syntax
* Refractor syntax function.
*/
/**
* @typedef Refractor
* Virtual syntax highlighting
* @property {typeof alias} alias
* @property {Languages} languages
* @property {typeof listLanguages} listLanguages
* @property {typeof highlight} highlight
* @property {typeof registered} registered
* @property {typeof register} register
*/
// Load all stuff in `prism.js` itself, except for `prism-file-highlight.js`.
// The wrapped non-leaky grammars are loaded instead of Prism’s originals.
import {h} from 'hastscript'
import {parseEntities} from 'parse-entities'
import {Prism} from './prism-core.js'
// Inherit.
function Refractor() {}
Refractor.prototype = Prism
/** @type {Refractor} */
// @ts-expect-error: TS is wrong.
export const refractor = new Refractor()
// Create.
refractor.highlight = highlight
refractor.register = register
refractor.alias = alias
refractor.registered = registered
refractor.listLanguages = listLanguages
// @ts-expect-error Overwrite Prism.
refractor.util.encode = encode
// @ts-expect-error Overwrite Prism.
refractor.Token.stringify = stringify
/**
* Highlight `value` (code) as `language` (programming language).
*
* @param {string} value
* Code to highlight.
* @param {Grammar | string} language
* Programming language name, alias, or grammar.
* @returns {Root}
* Node representing highlighted code.
*/
function highlight(value, language) {
if (typeof value !== 'string') {
throw new TypeError('Expected `string` for `value`, got `' + value + '`')
}
/** @type {Grammar} */
let grammar
/** @type {string | undefined} */
let name
// `name` is a grammar object.
// This was called internally by Prism.js before 1.28.0.
/* c8 ignore next 2 */
if (language && typeof language === 'object') {
grammar = language
} else {
name = language
if (typeof name !== 'string') {
throw new TypeError('Expected `string` for `name`, got `' + name + '`')
}
if (Object.hasOwn(refractor.languages, name)) {
grammar = refractor.languages[name]
} else {
throw new Error('Unknown language: `' + name + '` is not registered')
}
}
return {
type: 'root',
// @ts-expect-error: we hacked Prism to accept and return the things we want.
children: Prism.highlight.call(refractor, value, grammar, name)
}
}
/**
* Register a syntax.
*
* @param {Syntax} syntax
* Language function made for refractor, as in, the files in
* `refractor/lang/*.js`.
* @returns {undefined}
* Nothing.
*/
function register(syntax) {
if (typeof syntax !== 'function' || !syntax.displayName) {
throw new Error('Expected `function` for `syntax`, got `' + syntax + '`')
}
// Do not duplicate registrations.
if (!Object.hasOwn(refractor.languages, syntax.displayName)) {
syntax(refractor)
}
}
/**
* Register aliases for already registered languages.
*
* @param {Record<string, ReadonlyArray<string> | string> | string} language
* Language to alias.
* @param {ReadonlyArray<string> | string | null | undefined} [alias]
* Aliases.
* @returns {undefined}
* Nothing.
*/
function alias(language, alias) {
const languages = refractor.languages
/** @type {Record<string, ReadonlyArray<string> | string>} */
let map = {}
if (typeof language === 'string') {
if (alias) {
map[language] = alias
}
} else {
map = language
}
/** @type {string} */
let key
for (key in map) {
if (Object.hasOwn(map, key)) {
const value = map[key]
const list = typeof value === 'string' ? [value] : value
let index = -1
while (++index < list.length) {
languages[list[index]] = languages[key]
}
}
}
}
/**
* Check whether an `alias` or `language` is registered.
*
* @param {string} aliasOrLanguage
* Language or alias to check.
* @returns {boolean}
* Whether the language is registered.
*/
function registered(aliasOrLanguage) {
if (typeof aliasOrLanguage !== 'string') {
throw new TypeError(
'Expected `string` for `aliasOrLanguage`, got `' + aliasOrLanguage + '`'
)
}
return Object.hasOwn(refractor.languages, aliasOrLanguage)
}
/**
* List all registered languages (names and aliases).
*
* @returns {Array<string>}
* List of language names.
*/
function listLanguages() {
const languages = refractor.languages
/** @type {Array<string>} */
const list = []
/** @type {string} */
let language
for (language in languages) {
if (
Object.hasOwn(languages, language) &&
typeof languages[language] === 'object'
) {
list.push(language)
}
}
return list
}
/**
* @param {Array<_Token | string> | _Token | string} value
* Token to stringify.
* @param {string} language
* Language of the token.
* @returns {Array<Element | Text> | Element | Text}
* Node representing the token.
*/
function stringify(value, language) {
if (typeof value === 'string') {
return {type: 'text', value}
}
if (Array.isArray(value)) {
/** @type {Array<Element | Text>} */
const result = []
let index = -1
while (++index < value.length) {
if (
value[index] !== null &&
value[index] !== undefined &&
value[index] !== ''
) {
// Cast because we assume no sub-arrays.
result.push(
/** @type {Element | Text} */ (stringify(value[index], language))
)
}
}
return result
}
/** @type {_Env} */
const env = {
attributes: {},
classes: ['token', value.type],
content: stringify(value.content, language),
language,
tag: 'span',
type: value.type
}
if (value.alias) {
env.classes.push(
...(typeof value.alias === 'string' ? [value.alias] : value.alias)
)
}
// @ts-expect-error Prism.
refractor.hooks.run('wrap', env)
return h(
env.tag + '.' + env.classes.join('.'),
attributes(env.attributes),
env.content
)
}
/**
* @template {unknown} T
* Tokens.
* @param {T} tokens
* Input.
* @returns {T}
* Output, same as input.
*/
function encode(tokens) {
return tokens
}
/**
* @param {Record<string, string>} record
* Attributes.
* @returns {Record<string, string>}
* Attributes.
*/
function attributes(record) {
/** @type {string} */
let key
for (key in record) {
if (Object.hasOwn(record, key)) {
record[key] = parseEntities(record[key])
}
}
return record
}