/** * @import {Directives, LeafDirective, TextDirective, ToMarkdownOptions} from 'mdast-util-directive' * @import { * CompileContext, * Extension as FromMarkdownExtension, * Handle as FromMarkdownHandle, * Token * } from 'mdast-util-from-markdown' * @import { * ConstructName, * Handle as ToMarkdownHandle, * Options as ToMarkdownExtension, * State * } from 'mdast-util-to-markdown' * @import {Nodes, Paragraph} from 'mdast' */ import { ccount } from 'ccount' import { ok as assert } from 'devlop' import { parseEntities } from 'parse-entities' import { stringifyEntitiesLight } from 'stringify-entities' import { visitParents } from 'unist-util-visit-parents' import { PRIMARY_SYMBOL_STR } from './const' const own = {}.hasOwnProperty /** @type {Readonly} */ const emptyOptions = {} const shortcut = /^[^\t\n\r "#'.<=>`}]+$/ const unquoted = /^[^\t\n\r "'<=>`}]+$/ /** * Create an extension for `mdast-util-from-markdown` to enable directives in * markdown. * * @returns {FromMarkdownExtension} * Extension for `mdast-util-from-markdown` to enable directives. */ export function outputVarsFromMarkdown() { return { canContainEols: ['textDirective'], enter: { outputVarContainer: enterContainer, outputVarContainerAttributes: enterAttributes, outputVarContainerLabel: enterContainerLabel, }, exit: { outputVarContainer: exit, outputVarContainerAttributeClassValue: exitAttributeClassValue, outputVarContainerAttributeIdValue: exitAttributeIdValue, outputVarContainerAttributeName: exitAttributeName, outputVarContainerAttributeValue: exitAttributeValue, outputVarContainerAttributes: exitAttributes, outputVarContainerLabel: exitContainerLabel, outputVarContainerName: exitName, } } } /** * Create an extension for `mdast-util-to-markdown` to enable directives in * markdown. * * @param {Readonly | null | undefined} [options] * Configuration (optional). * @returns {ToMarkdownExtension} * Extension for `mdast-util-to-markdown` to enable directives. */ export function outputVarsToMarkdown(options?: any) { const settings = options || emptyOptions if ( settings.quote !== '"' && settings.quote !== "'" && settings.quote !== null && settings.quote !== undefined ) { throw new Error( 'Invalid quote `' + settings.quote + '`, expected `\'` or `"`' ) } handleOutputVars.peek = peekDirective return { handlers: { outputVars: handleOutputVars, }, unsafe: [ { character: '\r', inConstruct: ['leafDirectiveLabel', 'outputVarsLabel'] }, { character: '\n', inConstruct: ['leafDirectiveLabel', 'outputVarsLabel'] }, { before: '[^>]', character: '>', after: '[A-Za-z]', inConstruct: ['phrasing'] }, { atBreak: true, character: '>', after: '>' } ] } /** * @type {ToMarkdownHandle} * @param {Directives} node */ function handleOutputVars(node, _, state, info) { const tracker = state.createTracker(info) const sequence = fence(node) const exit = state.enter(node.type) let value = tracker.move(sequence + (node.name || '')) /** @type {LeafDirective | Paragraph | TextDirective | undefined} */ let label if (node.type === 'outputVars') { const head = (node.children || [])[0] label = inlineDirectiveLabel(head) ? head : undefined } else { label = node } if (label && label.children && label.children.length > 0) { const exit = state.enter('label') /** @type {ConstructName} */ const labelType = `${node.type}Label` const subexit = state.enter(labelType) value += tracker.move('[') value += tracker.move( state.containerPhrasing(label, { ...tracker.current(), before: value, after: ']' }) ) value += tracker.move(']') subexit() exit() } value += tracker.move(attributes(node, state)) if (node.type === 'outputVars') { const head = (node.children || [])[0] let shallow = node if (inlineDirectiveLabel(head)) { shallow = Object.assign({}, node, { children: node.children.slice(1) }) } if (shallow && shallow.children && shallow.children.length > 0) { value += tracker.move('\n') value += tracker.move(state.containerFlow(shallow, tracker.current())) } value += tracker.move('\n' + sequence) } exit() return value } /** * @param {Directives} node * @param {State} state * @returns {string} */ function attributes(node, state) { const attributes = node.attributes || {} /** @type {Array} */ const values = [] /** @type {string | undefined} */ let classesFull /** @type {string | undefined} */ let classes /** @type {string | undefined} */ let id /** @type {string} */ let key for (key in attributes) { if ( own.call(attributes, key) && attributes[key] !== undefined && attributes[key] !== null ) { const value = String(attributes[key]) // To do: next major: // Do not reorder `id` and `class` attributes when they do not turn into // shortcuts. // Additionally, join shortcuts: `#a .b.c d="e"` -> `#a.b.c d="e"` if (key === 'id') { id = settings.preferShortcut !== false && shortcut.test(value) ? '#' + value : quoted('id', value, node, state) } else if (key === 'class') { const list = value.split(/[\t\n\r ]+/g) /** @type {Array} */ const classesFullList = [] /** @type {Array} */ const classesList = [] let index = -1 while (++index < list.length) { ; (settings.preferShortcut !== false && shortcut.test(list[index]) ? classesList : classesFullList ).push(list[index]) } classesFull = classesFullList.length > 0 ? quoted('class', classesFullList.join(' '), node, state) : '' classes = classesList.length > 0 ? '.' + classesList.join('.') : '' } else { values.push(quoted(key, value, node, state)) } } } if (classesFull) { values.unshift(classesFull) } if (classes) { values.unshift(classes) } if (id) { values.unshift(id) } return values.length > 0 ? '{' + values.join(' ') + '}' : '' } /** * @param {string} key * @param {string} value * @param {Directives} node * @param {State} state * @returns {string} */ function quoted(key, value, node, state) { if (settings.collapseEmptyAttributes !== false && !value) return key if (settings.preferUnquoted && unquoted.test(value)) { return key + '=' + value } // If the alternative is less common than `quote`, switch. const preferred = settings.quote || state.options.quote || '"' const alternative = preferred === '"' ? "'" : '"' // If the alternative is less common than `quote`, switch. const appliedQuote = settings.quoteSmart && ccount(value, preferred) > ccount(value, alternative) ? alternative : preferred const subset = node.type === 'textDirective' ? [appliedQuote] : [appliedQuote, '\n', '\r'] return ( key + '=' + appliedQuote + stringifyEntitiesLight(value, { subset }) + appliedQuote ) } } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterContainer(token) { enter.call(this, 'outputVars', token) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterLeaf(token) { enter.call(this, 'leafDirective', token) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterText(token) { enter.call(this, 'textDirective', token) } /** * @this {CompileContext} * @param {Directives['type']} type * @param {Token} token */ function enter(type, token) { this.enter({ type, name: '', attributes: {}, children: [] }, token) } /** * @this {CompileContext} * @param {Token} token */ function exitName(token) { const node = this.stack[this.stack.length - 1] assert( node.type === 'outputVars' || node.type === 'leafDirective' || node.type === 'textDirective' ) node.name = this.sliceSerialize(token) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterContainerLabel(token) { this.enter( { type: 'paragraph', data: { directiveLabel: true }, children: [] }, token ) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitContainerLabel(token) { this.exit(token) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterAttributes() { this.data.directiveAttributes = [] this.buffer() // Capture EOLs } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitAttributeIdValue(token) { const list = this.data.directiveAttributes assert(list, 'expected `directiveAttributes`') list.push([ 'id', parseEntities(this.sliceSerialize(token), { attribute: true }) ]) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitAttributeClassValue(token) { const list = this.data.directiveAttributes assert(list, 'expected `directiveAttributes`') list.push([ 'class', parseEntities(this.sliceSerialize(token), { attribute: true }) ]) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitAttributeValue(token) { const list = this.data.directiveAttributes assert(list, 'expected `directiveAttributes`') list[list.length - 1][1] = parseEntities(this.sliceSerialize(token), { attribute: true }) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitAttributeName(token) { const list = this.data.directiveAttributes assert(list, 'expected `directiveAttributes`') // Attribute names in CommonMark are significantly limited, so character // references can't exist. list.push([this.sliceSerialize(token), '']) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitAttributes() { const list = this.data.directiveAttributes assert(list, 'expected `directiveAttributes`') /** @type {Record} */ const cleaned = {} let index = -1 while (++index < list.length) { const attribute = list[index] if (attribute[0] === 'class' && cleaned.class) { cleaned.class += ' ' + attribute[1] } else { cleaned[attribute[0]] = attribute[1] } } this.data.directiveAttributes = undefined this.resume() // Drop EOLs const node = this.stack[this.stack.length - 1] assert( node.type === 'outputVars' || node.type === 'leafDirective' || node.type === 'textDirective' ) node.attributes = cleaned } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exit(token) { this.exit(token) } /** @type {ToMarkdownHandle} */ function peekDirective() { return ':' } /** * @param {Nodes} node * @returns {node is Paragraph & {data: {directiveLabel: true}}} */ function inlineDirectiveLabel(node) { return Boolean( node && node.type === 'paragraph' && node.data && node.data.directiveLabel ) } /** * @param {Directives} node * @returns {string} */ function fence(node) { let size = 0 if (node.type === 'outputVars') { visitParents(node, function (node, parents) { if (node.type === 'outputVars') { let index = parents.length let nesting = 0 while (index--) { if (parents[index].type === 'outputVars') { nesting++ } } if (nesting > size) size = nesting } }) size += 3 return `${PRIMARY_SYMBOL_STR}`.repeat(size) } else if (node.type === 'leafDirective') { size = 2 return `${PRIMARY_SYMBOL_STR}`.repeat(size) } else { size = 1 return `${PRIMARY_SYMBOL_STR}`.repeat(size) } }