@vivliostyle/vfm
Version:
Custom Markdown syntax specialized in book authoring.
143 lines (142 loc) • 4.97 kB
JavaScript
/**
* 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);
}
};