UNPKG

@vivliostyle/vfm

Version:

Custom Markdown syntax specialized in book authoring.

143 lines (142 loc) 4.97 kB
/** * derived from `remark-sectionize`. * original: 2019 Jake Low * modified: 2020 Yasuaki Uechi, 2021 and later is Akabeko * @license MIT * @see https://github.com/jake-low/remark-sectionize */ import { findAfter } from 'unist-util-find-after'; import { visitParents as visit } from 'unist-util-visit-parents'; /** Maximum depth of hierarchy to process headings. */ const MAX_HEADING_DEPTH = 6; /** * Create the attribute properties of a section. * @param depth - Depth of heading elements that are sections. * @param node - Node of Markdown AST. * @returns Properties. */ const createProperties = (depth, node) => { const properties = { class: [`level${depth}`], }; return properties; }; const getHeadingLine = (node, file) => { if (node?.type !== 'heading') { return ''; } const startOffset = node.position?.start.offset ?? 0; const endOffset = node.position?.end.offset ?? 0; const text = file.toString().slice(startOffset, endOffset); return text.trim(); }; /** * Check if the heading has a non-section mark (sufficient number of closing hashes). * @param node Node of Markdown AST. * @param file Virtual file. * @returns `true` if the node has a non-section mark. */ const hasNonSectionMark = (node, file) => { const line = getHeadingLine(node, file); return (!!line && (/^#.*[ \t](#+)$/.exec(line)?.[1]?.length ?? 0) >= node.depth); }; /** * Check if the node is a section-end mark (line with only hashes). * @param node Node of Markdown AST. * @returns `true` if the node is a section-end mark. */ const isSectionEndMark = (node, file) => { const line = getHeadingLine(node, file); return !!line && /^(#+)$/.exec(line)?.[1]?.length === node.depth; }; /** * Wrap the header in sections. * - Do not sectionize if parent is `blockquote`. * - Set the `levelN` class in the section to match the heading depth. * @param node Node of Markdown AST. * @param ancestors Parents. * @todo handle `@subtitle` properly. */ const sectionizeIfRequired = (node, ancestors, file) => { if (hasNonSectionMark(node, file)) { return; } const parent = ancestors[ancestors.length - 1]; if (parent.type === 'blockquote') { return; } const start = node; const depth = start.depth; // check if it's HTML end tag without corresponding start tag in sibling nodes. const isHtmlEnd = (node) => { if (node.type !== 'html') { return false; } const tag = /<\/([^>\s]+)\s*>[^<]*$/.exec(node.value)?.[1]; if (!tag) { return false; } // it's HTML end tag, check if it has corresponding start tag const isHtmlStart = (node) => node.type === 'html' && new RegExp(`<${tag}\\b[^>]*>`).test(node.value); const htmlStart = findAfter(parent, start, isHtmlStart); if (!htmlStart || parent.children.indexOf(htmlStart) > parent.children.indexOf(node)) { // corresponding start tag is not found in this section level, // check if it is found earlier. const htmlStart1 = findAfter(parent, 0, isHtmlStart); if (htmlStart1 && parent.children.indexOf(htmlStart1) < parent.children.indexOf(start)) { return true; } } return false; }; const isEnd = (node) => (node.type === 'heading' && node.depth <= depth) || node.type === 'export' || isHtmlEnd(node); const end = findAfter(parent, start, isEnd); const startIndex = parent.children.indexOf(start); const endIndex = parent.children.indexOf(end); const between = parent.children.slice(startIndex, endIndex > 0 ? endIndex : undefined); const hProperties = createProperties(depth, node); // {hidden} specifier if (Object.keys(node.data.hProperties).includes('hidden')) { node.data.hProperties.hidden = 'hidden'; } const isDuplicated = parent.type === 'section'; if (isDuplicated) { if (parent.data?.hProperties) { parent.data.hProperties = { ...parent.data.hProperties, ...hProperties, }; } return; } const type = 'section'; const section = { type, data: { hName: type, hProperties, }, depth: depth, children: between, }; parent.children.splice(startIndex, section.children.length + (isSectionEndMark(end, file) && end.depth === depth ? 1 : 0), section); }; /** * Process Markdown AST. * @returns Transformer. */ export const mdast = () => (tree, file) => { const sectionize = (node, ancestors) => { sectionizeIfRequired(node, ancestors, file); }; for (let depth = MAX_HEADING_DEPTH; depth > 0; depth--) { visit(tree, (node) => { return node.type === 'heading' && node.depth === depth; }, sectionize); } };