/** * @typedef {import('mdast-util-from-markdown').CompileContext} CompileContext * @typedef {import('mdast-util-from-markdown').Extension} FromMarkdownExtension * @typedef {import('mdast-util-from-markdown').Handle} FromMarkdownHandle * @typedef {import('mdast-util-from-markdown').OnEnterError} OnEnterError * @typedef {import('mdast-util-from-markdown').OnExitError} OnExitError * @typedef {import('mdast-util-from-markdown').Token} Token * * @typedef {import('mdast-util-to-markdown').Handle} ToMarkdownHandle * @typedef {import('mdast-util-to-markdown').Options} ToMarkdownExtension * @typedef {import('mdast-util-to-markdown').State} State * @typedef {import('mdast-util-to-markdown').Tracker} Tracker * * @typedef {import('../index.js').MdxJsxAttribute} MdxJsxAttribute * @typedef {import('../index.js').MdxJsxAttributeValueExpression} MdxJsxAttributeValueExpression * @typedef {import('../index.js').MdxJsxExpressionAttribute} MdxJsxExpressionAttribute * @typedef {import('../index.js').MdxJsxFlowElement} MdxJsxFlowElement * @typedef {import('../index.js').MdxJsxTextElement} MdxJsxTextElement */ /** * @typedef Tag * Single tag. * @property {string | undefined} name * Name of tag, or `undefined` for fragment. * * > 👉 **Note**: `null` is used in the AST for fragments, as it serializes in * > JSON. * @property {Array} attributes * Attributes. * @property {boolean} close * Whether the tag is closing (``). * @property {boolean} selfClosing * Whether the tag is self-closing (``). * @property {Token['start']} start * Start point. * @property {Token['start']} end * End point. * * @typedef ToMarkdownOptions * Configuration. * @property {'"' | "'" | null | undefined} [quote='"'] * Preferred quote to use around attribute values (default: `'"'`). * @property {boolean | null | undefined} [quoteSmart=false] * Use the other quote if that results in less bytes (default: `false`). * @property {boolean | null | undefined} [tightSelfClosing=false] * Do not use an extra space when closing self-closing elements: `` * instead of `` (default: `false`). * @property {number | null | undefined} [printWidth=Infinity] * Try and wrap syntax at this width (default: `Infinity`). * * When set to a finite number (say, `80`), the formatter will print * attributes on separate lines when a tag doesn’t fit on one line. * The normal behavior is to print attributes with spaces between them * instead of line endings. */ import {ccount} from 'ccount' import {ok as assert} from 'devlop' import {parseEntities} from 'parse-entities' import {stringifyEntitiesLight} from 'stringify-entities' import {stringifyPosition} from 'unist-util-stringify-position' import {VFileMessage} from 'vfile-message' const indent = ' ' /** * Create an extension for `mdast-util-from-markdown` to enable MDX JSX. * * @returns {FromMarkdownExtension} * Extension for `mdast-util-from-markdown` to enable MDX JSX. * * When using the syntax extension with `addResult`, nodes will have a * `data.estree` field set to an ESTree `Program` node. */ export function mdxJsxFromMarkdown() { return { canContainEols: ['mdxJsxTextElement'], enter: { mdxJsxFlowTag: enterMdxJsxTag, mdxJsxFlowTagClosingMarker: enterMdxJsxTagClosingMarker, mdxJsxFlowTagAttribute: enterMdxJsxTagAttribute, mdxJsxFlowTagExpressionAttribute: enterMdxJsxTagExpressionAttribute, mdxJsxFlowTagAttributeValueLiteral: buffer, mdxJsxFlowTagAttributeValueExpression: buffer, mdxJsxFlowTagSelfClosingMarker: enterMdxJsxTagSelfClosingMarker, mdxJsxTextTag: enterMdxJsxTag, mdxJsxTextTagClosingMarker: enterMdxJsxTagClosingMarker, mdxJsxTextTagAttribute: enterMdxJsxTagAttribute, mdxJsxTextTagExpressionAttribute: enterMdxJsxTagExpressionAttribute, mdxJsxTextTagAttributeValueLiteral: buffer, mdxJsxTextTagAttributeValueExpression: buffer, mdxJsxTextTagSelfClosingMarker: enterMdxJsxTagSelfClosingMarker }, exit: { mdxJsxFlowTagClosingMarker: exitMdxJsxTagClosingMarker, mdxJsxFlowTagNamePrimary: exitMdxJsxTagNamePrimary, mdxJsxFlowTagNameMember: exitMdxJsxTagNameMember, mdxJsxFlowTagNameLocal: exitMdxJsxTagNameLocal, mdxJsxFlowTagExpressionAttribute: exitMdxJsxTagExpressionAttribute, mdxJsxFlowTagExpressionAttributeValue: data, mdxJsxFlowTagAttributeNamePrimary: exitMdxJsxTagAttributeNamePrimary, mdxJsxFlowTagAttributeNameLocal: exitMdxJsxTagAttributeNameLocal, mdxJsxFlowTagAttributeValueLiteral: exitMdxJsxTagAttributeValueLiteral, mdxJsxFlowTagAttributeValueLiteralValue: data, mdxJsxFlowTagAttributeValueExpression: exitMdxJsxTagAttributeValueExpression, mdxJsxFlowTagAttributeValueExpressionValue: data, mdxJsxFlowTagSelfClosingMarker: exitMdxJsxTagSelfClosingMarker, mdxJsxFlowTag: exitMdxJsxTag, mdxJsxTextTagClosingMarker: exitMdxJsxTagClosingMarker, mdxJsxTextTagNamePrimary: exitMdxJsxTagNamePrimary, mdxJsxTextTagNameMember: exitMdxJsxTagNameMember, mdxJsxTextTagNameLocal: exitMdxJsxTagNameLocal, mdxJsxTextTagExpressionAttribute: exitMdxJsxTagExpressionAttribute, mdxJsxTextTagExpressionAttributeValue: data, mdxJsxTextTagAttributeNamePrimary: exitMdxJsxTagAttributeNamePrimary, mdxJsxTextTagAttributeNameLocal: exitMdxJsxTagAttributeNameLocal, mdxJsxTextTagAttributeValueLiteral: exitMdxJsxTagAttributeValueLiteral, mdxJsxTextTagAttributeValueLiteralValue: data, mdxJsxTextTagAttributeValueExpression: exitMdxJsxTagAttributeValueExpression, mdxJsxTextTagAttributeValueExpressionValue: data, mdxJsxTextTagSelfClosingMarker: exitMdxJsxTagSelfClosingMarker, mdxJsxTextTag: exitMdxJsxTag } } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function buffer() { this.buffer() } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function data(token) { this.config.enter.data.call(this, token) this.config.exit.data.call(this, token) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterMdxJsxTag(token) { /** @type {Tag} */ const tag = { name: undefined, attributes: [], close: false, selfClosing: false, start: token.start, end: token.end } if (!this.data.mdxJsxTagStack) this.data.mdxJsxTagStack = [] this.data.mdxJsxTag = tag this.buffer() } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterMdxJsxTagClosingMarker(token) { const stack = this.data.mdxJsxTagStack assert(stack, 'expected `mdxJsxTagStack`') if (stack.length === 0) { throw new VFileMessage( 'Unexpected closing slash `/` in tag, expected an open tag first', {start: token.start, end: token.end}, 'mdast-util-mdx-jsx:unexpected-closing-slash' ) } } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterMdxJsxTagAnyAttribute(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') if (tag.close) { throw new VFileMessage( 'Unexpected attribute in closing tag, expected the end of the tag', {start: token.start, end: token.end}, 'mdast-util-mdx-jsx:unexpected-attribute' ) } } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterMdxJsxTagSelfClosingMarker(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') if (tag.close) { throw new VFileMessage( 'Unexpected self-closing slash `/` in closing tag, expected the end of the tag', {start: token.start, end: token.end}, 'mdast-util-mdx-jsx:unexpected-self-closing-slash' ) } } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTagClosingMarker() { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') tag.close = true } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTagNamePrimary(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') tag.name = this.sliceSerialize(token) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTagNameMember(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') tag.name += '.' + this.sliceSerialize(token) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTagNameLocal(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') tag.name += ':' + this.sliceSerialize(token) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterMdxJsxTagAttribute(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') enterMdxJsxTagAnyAttribute.call(this, token) tag.attributes.push({type: 'mdxJsxAttribute', name: '', value: null}) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function enterMdxJsxTagExpressionAttribute(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') enterMdxJsxTagAnyAttribute.call(this, token) tag.attributes.push({type: 'mdxJsxExpressionAttribute', value: ''}) this.buffer() } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTagExpressionAttribute(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') const tail = tag.attributes[tag.attributes.length - 1] assert(tail.type === 'mdxJsxExpressionAttribute') const estree = token.estree tail.value = this.resume() if (estree) { tail.data = {estree} } } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTagAttributeNamePrimary(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') const node = tag.attributes[tag.attributes.length - 1] assert(node.type === 'mdxJsxAttribute') node.name = this.sliceSerialize(token) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTagAttributeNameLocal(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') const node = tag.attributes[tag.attributes.length - 1] assert(node.type === 'mdxJsxAttribute') node.name += ':' + this.sliceSerialize(token) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTagAttributeValueLiteral() { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') tag.attributes[tag.attributes.length - 1].value = parseEntities( this.resume(), {nonTerminated: false} ) } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTagAttributeValueExpression(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') const tail = tag.attributes[tag.attributes.length - 1] assert(tail.type === 'mdxJsxAttribute') /** @type {MdxJsxAttributeValueExpression} */ const node = {type: 'mdxJsxAttributeValueExpression', value: this.resume()} const estree = token.estree if (estree) { node.data = {estree} } tail.value = node } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTagSelfClosingMarker() { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') tag.selfClosing = true } /** * @this {CompileContext} * @type {FromMarkdownHandle} */ function exitMdxJsxTag(token) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') const stack = this.data.mdxJsxTagStack assert(stack, 'expected `mdxJsxTagStack`') const tail = stack[stack.length - 1] if (tag.close && tail.name !== tag.name) { throw new VFileMessage( 'Unexpected closing tag `' + serializeAbbreviatedTag(tag) + '`, expected corresponding closing tag for `' + serializeAbbreviatedTag(tail) + '` (' + stringifyPosition(tail) + ')', {start: token.start, end: token.end}, 'mdast-util-mdx-jsx:end-tag-mismatch' ) } // End of a tag, so drop the buffer. this.resume() if (tag.close) { stack.pop() } else { this.enter( { type: token.type === 'mdxJsxTextTag' ? 'mdxJsxTextElement' : 'mdxJsxFlowElement', name: tag.name || null, attributes: tag.attributes, children: [] }, token, onErrorRightIsTag ) } if (tag.selfClosing || tag.close) { this.exit(token, onErrorLeftIsTag) } else { stack.push(tag) } } /** * @this {CompileContext} * @type {OnEnterError} */ function onErrorRightIsTag(closing, open) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') const place = closing ? ' before the end of `' + closing.type + '`' : '' const position = closing ? {start: closing.start, end: closing.end} : undefined throw new VFileMessage( 'Expected a closing tag for `' + serializeAbbreviatedTag(tag) + '` (' + stringifyPosition({start: open.start, end: open.end}) + ')' + place, position, 'mdast-util-mdx-jsx:end-tag-mismatch' ) } /** * @this {CompileContext} * @type {OnExitError} */ function onErrorLeftIsTag(a, b) { const tag = this.data.mdxJsxTag assert(tag, 'expected `mdxJsxTag`') throw new VFileMessage( 'Expected the closing tag `' + serializeAbbreviatedTag(tag) + '` either after the end of `' + b.type + '` (' + stringifyPosition(b.end) + ') or another opening tag after the start of `' + b.type + '` (' + stringifyPosition(b.start) + ')', {start: a.start, end: a.end}, 'mdast-util-mdx-jsx:end-tag-mismatch' ) } /** * Serialize a tag, excluding attributes. * `self-closing` is not supported, because we don’t need it yet. * * @param {Tag} tag * @returns {string} */ function serializeAbbreviatedTag(tag) { return '<' + (tag.close ? '/' : '') + (tag.name || '') + '>' } } /** * Create an extension for `mdast-util-to-markdown` to enable MDX JSX. * * This extension configures `mdast-util-to-markdown` with * `options.fences: true` and `options.resourceLink: true` too, do not * overwrite them! * * @param {ToMarkdownOptions | null | undefined} [options] * Configuration (optional). * @returns {ToMarkdownExtension} * Extension for `mdast-util-to-markdown` to enable MDX JSX. */ export function mdxJsxToMarkdown(options) { const options_ = options || {} const quote = options_.quote || '"' const quoteSmart = options_.quoteSmart || false const tightSelfClosing = options_.tightSelfClosing || false const printWidth = options_.printWidth || Number.POSITIVE_INFINITY const alternative = quote === '"' ? "'" : '"' if (quote !== '"' && quote !== "'") { throw new Error( 'Cannot serialize attribute values with `' + quote + '` for `options.quote`, expected `"`, or `\'`' ) } mdxElement.peek = peekElement return { handlers: { mdxJsxFlowElement: mdxElement, mdxJsxTextElement: mdxElement }, unsafe: [ {character: '<', inConstruct: ['phrasing']}, {atBreak: true, character: '<'} ], // Always generate fenced code (never indented code). fences: true, // Always generate links with resources (never autolinks). resourceLink: true } /** * @type {ToMarkdownHandle} * @param {MdxJsxFlowElement | MdxJsxTextElement} node */ // eslint-disable-next-line complexity function mdxElement(node, _, state, info) { const flow = node.type === 'mdxJsxFlowElement' const selfClosing = node.name ? !node.children || node.children.length === 0 : false const depth = inferDepth(state) const currentIndent = createIndent(depth) const trackerOneLine = state.createTracker(info) const trackerMultiLine = state.createTracker(info) /** @type {Array} */ const serializedAttributes = [] const prefix = (flow ? currentIndent : '') + '<' + (node.name || '') const exit = state.enter(node.type) trackerOneLine.move(prefix) trackerMultiLine.move(prefix) // None. if (node.attributes && node.attributes.length > 0) { if (!node.name) { throw new Error('Cannot serialize fragment w/ attributes') } let index = -1 while (++index < node.attributes.length) { const attribute = node.attributes[index] /** @type {string} */ let result if (attribute.type === 'mdxJsxExpressionAttribute') { result = '{' + (attribute.value || '') + '}' } else { if (!attribute.name) { throw new Error('Cannot serialize attribute w/o name') } const value = attribute.value const left = attribute.name /** @type {string} */ let right = '' if (value === null || value === undefined) { // Empty. } else if (typeof value === 'object') { right = '{' + (value.value || '') + '}' } else { // If the alternative is less common than `quote`, switch. const appliedQuote = quoteSmart && ccount(value, quote) > ccount(value, alternative) ? alternative : quote right = appliedQuote + stringifyEntitiesLight(value, {subset: [appliedQuote]}) + appliedQuote } result = left + (right ? '=' : '') + right } serializedAttributes.push(result) } } let attributesOnTheirOwnLine = false const attributesOnOneLine = serializedAttributes.join(' ') if ( // Block: flow && // Including a line ending (expressions). (/\r?\n|\r/.test(attributesOnOneLine) || // Current position (including ``. (selfClosing ? (tightSelfClosing ? 2 : 3) : 1) > printWidth) ) { attributesOnTheirOwnLine = true } let tracker = trackerOneLine let value = prefix if (attributesOnTheirOwnLine) { tracker = trackerMultiLine let index = -1 while (++index < serializedAttributes.length) { // Only indent first line of of attributes, we can’t indent attribute // values. serializedAttributes[index] = currentIndent + indent + serializedAttributes[index] } value += tracker.move( '\n' + serializedAttributes.join('\n') + '\n' + currentIndent ) } else if (attributesOnOneLine) { value += tracker.move(' ' + attributesOnOneLine) } if (selfClosing) { value += tracker.move( (tightSelfClosing || attributesOnTheirOwnLine ? '' : ' ') + '/' ) } value += tracker.move('>') if (node.children && node.children.length > 0) { if (node.type === 'mdxJsxTextElement') { value += tracker.move( // @ts-expect-error: `containerPhrasing` is typed correctly, but TS // generates *hardcoded* types, which means that our dynamically added // directives are not present. // At some point, TS should fix that, and `from-markdown` should be fine. state.containerPhrasing(node, { ...tracker.current(), before: '>', after: '<' }) ) } else { tracker.shift(2) value += tracker.move('\n') value += tracker.move(containerFlow(node, state, tracker.current())) value += tracker.move('\n') } } if (!selfClosing) { value += tracker.move( (flow ? currentIndent : '') + '' ) } exit() return value } } // Modified copy of: // . // // To do: add `indent` support to `mdast-util-to-markdown`. // As indents are only used for JSX, it’s fine for now, but perhaps better // there. /** * @param {MdxJsxFlowElement} parent * Parent of flow nodes. * @param {State} state * Info passed around about the current state. * @param {ReturnType} info * Info on where we are in the document we are generating. * @returns {string} * Serialized children, joined by (blank) lines. */ function containerFlow(parent, state, info) { const indexStack = state.indexStack const children = parent.children const tracker = state.createTracker(info) const currentIndent = createIndent(inferDepth(state)) /** @type {Array} */ const results = [] let index = -1 indexStack.push(-1) while (++index < children.length) { const child = children[index] indexStack[indexStack.length - 1] = index const childInfo = {before: '\n', after: '\n', ...tracker.current()} const result = state.handle(child, parent, state, childInfo) const serializedChild = child.type === 'mdxJsxFlowElement' ? result : state.indentLines(result, function (line, _, blank) { return (blank ? '' : currentIndent) + line }) results.push(tracker.move(serializedChild)) if (child.type !== 'list') { state.bulletLastUsed = undefined } if (index < children.length - 1) { results.push(tracker.move('\n\n')) } } indexStack.pop() return results.join('') } /** * * @param {State} state * @returns {number} */ function inferDepth(state) { let depth = 0 for (const x of state.stack) { if (x === 'mdxJsxFlowElement') { depth++ } } return depth } /** * @param {number} depth * @returns {string} */ function createIndent(depth) { return indent.repeat(depth) } /** * @type {ToMarkdownHandle} */ function peekElement() { return '<' }