// 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 {function} [copyFn] - The [copy function]{@link module:lib/elements~CopyFn}. Copy links
* for fenced code blocks are only rendered when `codeFn` is present.
* @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 copy (to the clipboard) function
*
* @callback CopyFn
* @param {string} text - The text to copy
*/
/**
* 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) {
return parts.map((part) => markdownPartElements(part, options, usedHeaderIds));
}
function markdownPartElements(part, options, usedHeaderIds) {
const [partKey] = Object.keys(part);
// List?
if (partKey === 'list') {
const {items} = part.list;
const itemElements = items.map((item) => markdownPartsElements(item.parts, options, usedHeaderIds));
return markdownListPartElements(part, itemElements.map((elem) => ({'html': 'li', 'elem': elem})));
// Block quote?
} else if (partKey === 'quote') {
return {
'html': 'blockquote',
'elem': markdownPartsElements(part.quote.parts, options, usedHeaderIds)
};
// Code block?
} else if (partKey === 'codeBlock') {
return codeBlockElements(part.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) {
const [partKey] = Object.keys(part);
// List?
if (partKey === 'list') {
const {items} = part.list;
const itemElements = [];
for (const item of items) {
itemElements.push(await markdownPartsElementsAsync(item.parts, options, usedHeaderIds));
}
return markdownListPartElements(part, itemElements.map((elem) => ({'html': 'li', 'elem': elem})));
// Block quote?
} else if (partKey === 'quote') {
return {
'html': 'blockquote',
'elem': await markdownPartsElementsAsync(part.quote.parts, options, usedHeaderIds)
};
// Code block?
} else if (partKey === 'codeBlock') {
return codeBlockElements(part.codeBlock, options);
}
return markdownPartElementsBase(part, options, usedHeaderIds);
}
function markdownListPartElements(part, listItemElements) {
const {list} = part;
return {
'html': 'start' in list ? 'ol' : 'ul',
'attr': 'start' in list && list.start > 1 ? {'start': `${list.start}`} : null,
'elem': listItemElements
};
}
function markdownPartElementsBase(part, options, usedHeaderIds) {
const [partKey] = Object.keys(part);
// Paragraph?
if (partKey === 'paragraph') {
const {paragraph} = part;
if ('style' in paragraph) {
// Determine the header ID, if requested
let headerId = null;
if (options !== null && 'headerIds' in options && options.headerIds) {
headerId = markdownHeaderId(getMarkdownParagraphText(paragraph));
// Duplicate header ID?
if (usedHeaderIds.has(headerId)) {
let ix = 1;
let headerIdNew;
do {
ix += 1;
headerIdNew = `${headerId}${ix}`;
} while (usedHeaderIds.has(headerIdNew));
headerId = headerIdNew;
}
usedHeaderIds.add(headerId);
// Header ID hash URL fixup?
if (options !== null && 'urlFn' in options) {
headerId = options.urlFn(`#${headerId}`).slice(1);
}
}
return {
'html': paragraph.style,
'attr': headerId !== null ? {'id': headerId} : null,
'elem': paragraphSpanElements(paragraph.spans, options)
};
}
return {
'html': 'p',
'elem': paragraphSpanElements(paragraph.spans, options)
};
// Table?
} else if (partKey === 'table') {
const {table} = part;
return {
'html': 'table',
'elem': [
{
'html': 'thead',
'elem': {
'html': 'tr',
'elem': table.headers.map((header, ixHeader) => ({
'html': 'th',
'attr': {'style': `text-align: ${ixHeader < table.aligns.length ? table.aligns[ixHeader] : 'left'}`},
'elem': paragraphSpanElements(header, options)
}))
}
},
!('rows' in table) ? null : ({
'html': 'tbody',
'elem': table.rows.map((row) => ({
'html': 'tr',
'elem': row.map((cell, ixCell) => ({
'html': 'td',
'attr': {'style': `text-align: ${ixCell < table.aligns.length ? table.aligns[ixCell] : 'left'}`},
'elem': paragraphSpanElements(cell, options)
}))
}))
})
]
};
}
// Horizontal rule?
// else if (partKey === 'hr')
return {'html': 'hr'};
}
// Helper function to generate an element model from a markdown span model array
function paragraphSpanElements(spans, options) {
const spanElements = [];
for (const span of spans) {
const [spanKey] = Object.keys(span);
// Text span?
if (spanKey === 'text') {
spanElements.push({'text': span.text});
// Line break?
} else if (spanKey === 'br') {
spanElements.push({'html': 'br'});
// Style span?
} else if (spanKey === 'style') {
const {style} = span;
spanElements.push({
'html': (style.style === 'strikethrough' ? 'del' : (style.style === 'italic' ? 'em' : 'strong')),
'elem': paragraphSpanElements(style.spans, options)
});
// Link span?
} else if (spanKey === 'link') {
const {link} = span;
let {href} = link;
// URL fixup?
if (options !== null && 'urlFn' in options) {
href = options.urlFn(href);
}
const linkElements = {
'html': 'a',
'attr': {'href': href},
'elem': paragraphSpanElements(link.spans, options)
};
if ('title' in link) {
linkElements.attr.title = link.title;
}
spanElements.push(linkElements);
// Image span?
} else if (spanKey === 'image') {
const {image} = span;
let {src} = image;
// Relative link fixup?
if (options !== null && 'urlFn' in options) {
src = options.urlFn(src);
}
const imageElement = {
'html': 'img',
'attr': {'src': src, 'alt': image.alt, 'style': 'max-width: 100%;'}
};
if ('title' in image) {
imageElement.attr.title = image.title;
}
spanElements.push(imageElement);
// Link reference span?
} else if (spanKey === 'linkRef') {
const {linkRef} = span;
spanElements.push(...paragraphSpanElements(linkRef.spans, options));
// Code span?
} else if (spanKey === 'code') {
spanElements.push({'html': 'code', 'elem': {'text': span.code}});
}
}
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;