Source: elements.js

// Licensed under the MIT License
// https://github.com/craigahobbs/markdown-model/blob/main/LICENSE

/** @module lib/elements */

import {codeBlockElements} from './highlight.js';
import {getMarkdownParagraphText} from './parser.js';


/**
 * The markdownElements function's options object
 *
 * @typedef {Object} MarkdownElementsOptions
 * @property {Object.<string, function>} [codeBlocks] - The [code block]{@link module:lib/elements~CodeBlockFn} render function map
 * @property {boolean} [copyLinks] - If true, generate copy-to-clipboard buttons on fenced code
 *     blocks
 * @property {number} [copyLinksIndex] - Next copy-button id index (default 1). Mutated when
 *     `copyLinks` is true.
 * @property {function} [urlFn] - The [URL modifier function]{@link module:lib/elements~URLFn}
 * @property {boolean} [headerIds] - If true, generate header IDs
 * @property {Set} [usedHeaderIds] - Set of used header IDs
 */

/**
 * A code block render function
 *
 * @callback CodeBlockFn
 * @param {Object} codeBlock - The [code block model]{@link module:lib/elements~CodeBlock}
 * @param {?Object} options - The [options object]{@link module:lib/elements~MarkdownElementsOptions}
 * @returns {*} The code block's element model
 */

/**
 * @typedef {Object} CodeBlock
 * @property {?string} language - The code block language
 * @property {string[]} lines - The code blocks lines
 * @property {number} [startLineNumber] - The code blocks lines
 */

/**
 * A URL modifier function
 *
 * @callback URLFn
 * @param {string} url - The URL
 * @returns {string} The modified URL
 */


/**
 * Generate an element model from a Markdown model.
 *
 * @param {Object} markdown - The [Markdown model]{@link https://craigahobbs.github.io/markdown-model/model/#var.vName='Markdown'}
 * @param {?Object} [options] - The [options object]{@link module:lib/elements~MarkdownElementsOptions}
 * @returns {*} The Markdown's [element model]{@link https://github.com/craigahobbs/element-model#readme}
 */
export function markdownElements(markdown, options = null) {
    const usedHeaderIds = (options !== null && 'usedHeaderIds' in options ? options.usedHeaderIds : new Set());
    return markdownPartsElements(markdown.parts, options, usedHeaderIds);
}


function markdownPartsElements(parts, options, usedHeaderIds) {
    const elements = [];
    for (const part of parts) {
        elements.push(markdownPartElements(part, options, usedHeaderIds));
    }
    return elements;
}


function markdownPartElements(part, options, usedHeaderIds) {
    // List?
    const {list} = part;
    if (list) {
        const listItemElements = [];
        for (const item of list.items) {
            const itemElements = markdownPartsElements(item.parts, options, usedHeaderIds);
            listItemElements.push({'html': 'li', 'elem': itemElements});
        }
        return markdownListPartElements(part, listItemElements);
    }

    // Block quote?
    const {quote} = part;
    if (quote) {
        return {
            'html': 'blockquote',
            'elem': markdownPartsElements(quote.parts, options, usedHeaderIds)
        };
    }

    // Code block?
    const {codeBlock} = part;
    if (codeBlock) {
        return codeBlockElements(codeBlock, options);
    }

    return markdownPartElementsBase(part, options, usedHeaderIds);
}


/**
 * Generate an element model from a Markdown model.
 *
 * This is the asynchronous form of the [markdownElements function]{@link module:lib/elements.markdownElements}.
 * Use this form of the function if you have one or more asynchronous code block functions.
 *
 * @param {Object} markdown - The [Markdown model]{@link https://craigahobbs.github.io/markdown-model/model/#var.vName='Markdown'}
 * @param {?Object} [options] - The [options object]{@link module:lib/elements~MarkdownElementsOptions}
 * @returns {*} The Markdown's [element model]{@link https://github.com/craigahobbs/element-model#readme}
 */
export function markdownElementsAsync(markdown, options = null) {
    const usedHeaderIds = (options !== null && 'usedHeaderIds' in options ? options.usedHeaderIds : new Set());
    return markdownPartsElementsAsync(markdown.parts, options, usedHeaderIds);
}


async function markdownPartsElementsAsync(parts, options, usedHeaderIds) {
    const elements = [];
    for (const part of parts) {
        elements.push(await markdownPartElementsAsync(part, options, usedHeaderIds));
    }
    return elements;
}


async function markdownPartElementsAsync(part, options, usedHeaderIds) {
    // List?
    const {list} = part;
    if (list) {
        const listItemElements = [];
        for (const item of list.items) {
            const itemElements = await markdownPartsElementsAsync(item.parts, options, usedHeaderIds);
            listItemElements.push({'html': 'li', 'elem': itemElements});
        }
        return markdownListPartElements(part, listItemElements);
    }

    // Block quote?
    const {quote} = part;
    if (quote) {
        return {
            'html': 'blockquote',
            'elem': await markdownPartsElementsAsync(quote.parts, options, usedHeaderIds)
        };
    }

    // Code block?
    const {codeBlock} = part;
    if (codeBlock) {
        return codeBlockElements(codeBlock, options);
    }

    return markdownPartElementsBase(part, options, usedHeaderIds);
}


function markdownListPartElements(part, listItemElements) {
    const startValue = part.list.start;
    return {
        'html': typeof startValue === 'number' ? 'ol' : 'ul',
        'attr': typeof startValue === 'number' && startValue > 1 ? {'start': `${startValue}`} : null,
        'elem': listItemElements
    };
}


function markdownPartElementsBase(part, options, usedHeaderIds) {
    // Paragraph?
    const {paragraph} = part;
    if (paragraph) {
        const {style: paragraphStyle} = paragraph;
        if (paragraphStyle) {
            // Determine the header ID, if requested
            let headerId = null;
            if (options && options.headerIds) {
                headerId = markdownHeaderId(getMarkdownParagraphText(paragraph));

                // Duplicate header ID?
                if (usedHeaderIds.has(headerId)) {
                    let ix = 2;
                    let headerIdNew = `${headerId}${ix}`;
                    while (usedHeaderIds.has(headerIdNew)) {
                        ix += 1;
                        headerIdNew = `${headerId}${ix}`;
                    }
                    headerId = headerIdNew;
                }
                usedHeaderIds.add(headerId);

                // Header ID hash URL fixup?
                const {urlFn} = options;
                if (urlFn) {
                    headerId = urlFn(`#${headerId}`).slice(1);
                }
            }

            return {
                'html': paragraphStyle,
                'attr': headerId !== null ? {'id': headerId} : null,
                'elem': paragraphSpanElements(paragraph.spans, options)
            };
        }

        return {
            'html': 'p',
            'elem': paragraphSpanElements(paragraph.spans, options)
        };
    }

    // Table?
    const {table} = part;
    if (table) {
        const {aligns} = table;
        const alignsLength = aligns.length;

        // Header elements
        const headerElements = [];
        for (const [ixHeader, header] of table.headers.entries()) {
            const headerAlign = ixHeader < alignsLength ? aligns[ixHeader] : 'left';
            headerElements.push({
                'html': 'th',
                'attr': {'style': `text-align: ${headerAlign}`},
                'elem': paragraphSpanElements(header, options)
            });
        }

        // Row elements
        let rowsElement = null;
        if ('rows' in table) {
            const rowElements = [];
            for (const row of table.rows) {
                const cellElements = [];
                for (const [ixCell, cell] of row.entries()) {
                    const cellAlign = ixCell < alignsLength ? aligns[ixCell] : 'left';
                    cellElements.push({
                        'html': 'td',
                        'attr': {'style': `text-align: ${cellAlign}`},
                        'elem': paragraphSpanElements(cell, options)
                    });
                }
                rowElements.push({'html': 'tr', 'elem': cellElements});
            }
            rowsElement = {'html': 'tbody', 'elem': rowElements};
        }

        return {
            'html': 'table',
            'elem': [
                {
                    'html': 'thead',
                    'elem': {
                        'html': 'tr',
                        'elem': headerElements
                    }
                },
                rowsElement
            ]
        };
    }

    // Horizontal rule
    // elif partKey === 'hr'
    return {'html': 'hr'};
}


// Helper function to generate an element model from a markdown span model array
function paragraphSpanElements(spans, options) {
    const urlFn = options ? options.urlFn : null;
    const spanElements = [];
    for (const span of spans) {
        // Text span?
        const {text} = span;
        if (text) {
            spanElements.push({'text': text});
            continue;
        }

        // Style span?
        const {style} = span;
        if (style) {
            const {style: styleStyle} = style;
            spanElements.push({
                'html': (styleStyle === 'bold' ? 'strong' : (styleStyle === 'italic' ? 'em' : 'del')),
                'elem': paragraphSpanElements(style.spans, options)
            });
            continue;
        }

        // Link span?
        const {link} = span;
        if (link) {
            let {href} = link;
            if (urlFn) {
                href = urlFn(href);
            }
            const linkAttr = {'href': href};
            if ('title' in link) {
                linkAttr.title = link.title;
            }
            spanElements.push({
                'html': 'a',
                'attr': linkAttr,
                'elem': paragraphSpanElements(link.spans, options)
            });
            continue;
        }

        // Link reference span?
        const {linkRef} = span;
        if (linkRef) {
            spanElements.push(...paragraphSpanElements(linkRef.spans, options));
            continue;
        }

        // Code span?
        const {code} = span;
        if (code) {
            spanElements.push({'html': 'code', 'elem': {'text': code}});
            continue;
        }

        // Image span?
        const {image} = span;
        if (image) {
            let {src} = image;
            if (urlFn) {
                src = urlFn(src);
            }
            const imageAttr = {'src': src, 'alt': image.alt, 'style': 'max-width: 100%;'};
            if ('title' in image) {
                imageAttr.title = image.title;
            }
            spanElements.push({'html': 'img', 'attr': imageAttr});
            continue;
        }

        // Line break?
        if (span.br) {
            spanElements.push({'html': 'br'});
        }
    }

    return spanElements;
}


/**
 * Generate a Markdown header ID from text
 *
 * @param {string} text - The text
 * @returns {string}
 */
export function markdownHeaderId(text) {
    return text.toLowerCase().
        replace(rHeaderStart, '').replace(rHeaderEnd, '').
        replace(rHeaderIdRemove, '').replace(rHeaderIdDash, '-');
}

const rHeaderStart = /^[^a-z0-9]+/;
const rHeaderEnd = /[^a-z0-9]+$/;
const rHeaderIdRemove = /['"]/g;
const rHeaderIdDash = /[^a-z0-9]+/g;