doctoc
Version:
Generates TOC for markdown files of local git repo.
345 lines (293 loc) • 12.7 kB
JavaScript
;
var anchor = require("anchor-markdown-header"),
getHtmlHeaders = require("./get-html-headers"),
contentGenerator = require("./content-generation"),
md = require("@textlint/markdown-to-ast");
function matchesStart(syntax) {
var commentEscapedStart = contentGenerator.escapedStartTag(syntax);
return function(line){
return new RegExp(`${commentEscapedStart} START doctoc `).test(line);
}
}
function matchesEnd(syntax) {
var commentEscapedStart = contentGenerator.escapedStartTag(syntax);
return function(line){
return new RegExp(`${commentEscapedStart} END doctoc `).test(line);
}
}
function notNull(x) { return x !== null; }
function isString(y) {
return typeof y === 'string';
}
function getTocMarkers(docNodes, pragmaStyle, syntax, eol) {
var matchesStartBySyntax = matchesStart(syntax);
var matchesEndBySyntax = matchesEnd(syntax);
var nodes = docNodes.children.filter(function (x) {
return ((x.type === md.Syntax.Html || x.type === md.Syntax.HtmlBlock) && syntax === "md") || (x.type === md.Syntax.Paragraph && syntax === "mdx");
})
.map(function (x) {
if(matchesStartBySyntax(x.raw)){
return { type: "start", node: x };
}
else if(matchesEndBySyntax(x.raw)){
return { type: "end", node: x };
}
else {
return null;
}
})
.filter(notNull);
var marker = contentGenerator.pragmaMarkers(syntax, pragmaStyle, eol);
// This finds the position of the existing toc markers
var position = nodes.length < 2 ? null : {
start: nodes.find(n => n.type === "start")?.node,
end: nodes.find(n => n.type === "end")?.node,
};
if (!position) {
return { marker: marker };
}
position.existing = position.start && position.end;
if (pragmaStyle == "compact" && position?.existing) {
position.start.raw = position.start.raw.replace(" generated TOC please keep comment here to allow auto update", "");
position.end.raw = position.end.raw.replace(" generated TOC please keep comment here to allow auto update", "");
}
else if ((pragmaStyle == "legacy" || !pragmaStyle) && position?.existing) {
// Replace the old START comment with the canonical two-line marker (START + DON'T EDIT).
// Update range[1] to match so that determineTitle searches *after* both lines,
// preventing the DON'T EDIT comment from being mistaken for a title.
position.start.raw = marker.start;
position.start.range[1] = position.start.range[0] + marker.start.length;
}
return { marker: marker, positions: [position] };
}
function getMarkdownHeaders (lines, start, maxHeaderLevel, minHeaderLevel) {
function extractText (header) {
return header.children
.map(function (x) {
if (x.type === md.Syntax.Link || x.type === md.Syntax.LinkReference) {
return extractText(x);
}
else if (x.type === md.Syntax.Image || x.type === md.Syntax.ImageReference) {
// Images (at least on GitHub, untested elsewhere) are given a hyphen
// in the slug. We can achieve this behavior by adding an '*' to the
// TOC entry. Think of it as a "magic char" that represents the image.
return '*';
}
else {
return x.raw;
}
})
.join("");
}
function isHtmlHeader (html, minHeaderLevel) {
if (html[0] !== '<' || html[1] !== 'h') return false;
const level = html[2];
if (level !== '1' && level !== '2') return false;
if (Number(level) < minHeaderLevel) return false;
if (Number(level) > (maxHeaderLevel || 4)) return false;
if (!html.endsWith('</h' + level + '>')) return false;
return html.indexOf('<h', 3) === -1;
}
return lines
.children.filter(function (x) {
return (x.type === md.Syntax.Header || x.type === md.Syntax.Html || x.type === md.Syntax.HtmlBlock) && x.range[1] > start;
})
.map(function (x) {
if ((x.type === md.Syntax.Html || x.type === md.Syntax.HtmlBlock) && isHtmlHeader(x.raw, minHeaderLevel)) {
return { node: x };
}
else if (x.type == md.Syntax.Header && x.depth >= minHeaderLevel && (!maxHeaderLevel || x.depth <= maxHeaderLevel)) {
return { rank : x.depth
, name : extractText(x)
, line : x.loc.start.line
, node : x
};
}
else {
return null;
}
})
.filter(notNull);
}
function processHeaders(headers, mode) {
var instances = {};
headers.sort(function (a, b) {
return a.line - b.line;
});
for (var i = 0; i < headers.length; i++) {
var header = headers[i];
var anchorLink = anchor(header.name, mode, 0, header.href);
var name = header.href || anchorLink.split('#')[1].slice(0, -1);
if (header.href === undefined && Object.prototype.hasOwnProperty.call(instances, name)) {
// `instances.hasOwnProperty(name)` fails when there’s an instance named "hasOwnProperty".
instances[name]++;
} else {
instances[name] = 0;
}
header.anchor = instances[name] > 0 ? anchor(header.name, mode, instances[name], header.href) : anchorLink;
}
return headers;
}
// When updating an existing TOC, try to preserve the title that was there before.
// Falls back to CLI args (--title, --notitle) or the default title for new TOCs.
function determineTitle(nodes, tocPosition, title, notitle, header) {
var defaultTitle = '**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*';
if (notitle) return "";
if (title) return title;
if (!tocPosition?.existing) return defaultTitle;
// Find the TOC's list node so we know where the title region ends
var listStartIdx = nodes.children.filter(function (x) {
return x.type === md.Syntax.List && x.range[0] > tocPosition.start.range[0] && x.range[1] < tocPosition.end.range[1];
})
.map(function (x) { return x.range[0]; })?.[0];
// Look for paragraph nodes between the start marker and the list — the last one is the title.
// Uses range[1] (end of start marker) to skip the marker itself and the DON'T EDIT comment,
// which are both covered by the start marker's range (see getTocMarkers).
var paragraphs = nodes.children.filter(function (x) {
return x.range[0] >= tocPosition.start.range[1] && x.range[1] < listStartIdx;
});
var title = paragraphs?.[paragraphs.length - 1]?.raw;
// If the inferred title matches --toc-header-content, suppress it to avoid duplication
if (title && header?.includes(title)) return "";
return title || "";
}
function detectLineEnding(content, defaultEol) {
var eol = '', newEol = '';
for (var i = 0; i < content.length; i++) {
if (content[i] === '\r') {
if (content[i + 1] === '\n') {
newEol = '\r\n';
i++;
}
else { newEol = '\r'; }
}
else if (content[i] === '\n') { newEol = '\n'; }
else { continue; }
if (eol && eol != newEol) { return defaultEol; }
eol = newEol;
}
return eol || defaultEol;
}
exports = module.exports = function transform(content, mode, maxHeaderLevel, minHeaderLevel, minTocItems, title, notitle, entryPrefix, processAll, updateOnly, syntax, options) {
options ??= {};
options.toc ??= {};
options.toc.items ??= {};
syntax = syntax || "md";
options.toc.items.symbols ??= String(entryPrefix || "-").split(',');
var eol = '\n';
eol = detectLineEnding(content, eol);
var skipTag = contentGenerator.skipTag(syntax) + eol;
var index = content.indexOf(skipTag);
if (index === 0 || (index >= 0 && content[index-1] === eol)) return { transformed: false };
mode = mode || "github.com";
minHeaderLevel = minHeaderLevel || 1;
if (isNaN(minTocItems) || minTocItems < 1){
minTocItems = 1;
}
var padTitle = options?.toc?.title?.padding?.before > 0;
if (options?.toc?.title?.padding?.before === undefined){
padTitle = notitle || false;
}
// only limit *HTML* headings by default
var maxHeaderLevelHtml = maxHeaderLevel || 4;
var docNodes = md.parse(content);
var tocs = getTocMarkers(docNodes, options?.toc?.pragma?.style, syntax, eol);
if (!(tocs.positions?.length > 0) && updateOnly) {
return { transformed: false };
}
var startLine = 0;
var startPos = 0;
var firstChild = docNodes.children.length > 0 ? docNodes.children[0] : undefined;
var frontMatter = firstChild?.type === 'Json' || firstChild?.type === 'Toml' || firstChild?.type === 'Yaml';
var hasBody = docNodes.children.length > (frontMatter ? 1 : 0);
if (frontMatter && hasBody) {
startLine = docNodes.children[1].loc.start.line - 1;
startPos = docNodes.children[1].range[0];
}
// force it on to the next line after current content
else if (frontMatter) {
startLine = docNodes.children[0].loc.end.line;
startPos = docNodes.range[1] + eol.length;
}
// adds in the empty line between end of content & new toc if missing
if (frontMatter && docNodes.range[1] === docNodes.children[0].range[1]) {
startPos = startPos + eol.length;
content += eol;
}
// if there is no empty line between front matter & content add one
if (frontMatter && hasBody && docNodes.children[0].loc.end.line + 1 == docNodes.children[1].loc.start.line) {
content = content.substring(0, startPos) + eol + content.substring(startPos);
startPos = startPos + eol.length;
}
if (!(tocs.positions?.length > 0)) {
var insertPos = startPos;
tocs.positions = [{
end: {
range: [insertPos, insertPos],
raw: tocs.marker.end,
},
start: {
range: [insertPos, insertPos],
raw: tocs.marker.start,
},
existing: false
}];
}
var tocPosition = tocs.positions[0];
var tocableStart = !processAll ? tocPosition.end.range[1] : startPos;
var headers = getMarkdownHeaders(docNodes, tocableStart, maxHeaderLevel, minHeaderLevel)
.concat(getHtmlHeaders(docNodes, tocableStart, maxHeaderLevelHtml, minHeaderLevel));
var initialNode = headers[0]?.node;
if (!tocPosition.existing && options?.toc?.location?.toLowerCase() == 'before' && initialNode) {
var insertPos = initialNode.range[0];
tocPosition.start.range = [insertPos, insertPos];
tocPosition.end.range = [insertPos, insertPos];
}
var toc = '';
var wrappedToc;
var minLines = options?.document?.lines?.min + startLine || startLine;
if (tocPosition.existing) {
minLines = minLines + tocPosition.end.loc.end.line - tocPosition.start.loc.start.line + 2;
}
headers = headers.filter(function (x) {
return x.name && x.name?.length > 0;
});
if (headers.length >= minTocItems && docNodes.children[docNodes.children.length - 1].loc.end.line >= minLines) {
var tocLines = [];
var inferredTitle = determineTitle(docNodes, tocPosition, title, notitle, options?.toc?.header?.content);
var allHeaders = processHeaders(headers, mode);
var lowestRank = allHeaders.reduce((min, h) => Math.min(min, h.rank), Infinity);
var indentation = ' ';
var indentationWidth = options?.toc?.items?.indentation?.width;
// remove this fallback based on mode in v3
if(indentationWidth === undefined){
// 4 spaces required for proper indention on Bitbucket and GitLab
indentationWidth = (mode === 'bitbucket.org' || mode === 'gitlab.com') ? 4 : 2;
}
if (options?.toc?.header?.content) { tocLines.push(options.toc.header.content); }
if (padTitle && inferredTitle) { tocLines.push(''); }
if (inferredTitle) { tocLines.push(inferredTitle); }
tocLines.push('');
var symbolQty = options.toc.items.symbols.length;
tocLines.push(...allHeaders
.map(function (x) {
var level = x.rank - lowestRank;
var symbol;
symbol = options.toc.items.symbols[level % symbolQty].trim();
return indentation.repeat(level * indentationWidth) + symbol + ' ' + x.anchor;
}));
tocLines.push('');
if (options?.toc?.footer?.content) { tocLines.push(options.toc.footer.content); }
toc = tocLines.join(eol);
}
var data = [];
var wrappedToc = tocPosition.start.raw + eol + (toc != '' ? toc + eol : '') + tocPosition.end.raw;
var transformed = !tocPosition?.existing || content.slice(tocPosition.start.range[0], tocPosition.end.range[1]) != wrappedToc;
if (transformed) {
if (tocPosition.start.range[0] > 0) { data.push(content.slice(0, tocPosition.start.range[0] - eol.length)); }
data.push(wrappedToc);
if (!tocPosition.existing && hasBody) { data.push(''); }
data.push(content.slice(tocPosition.end.range[1] + (tocPosition.existing ? 1 : 0)));
}
return { transformed : transformed, data : data.join(eol), toc: toc, wrappedToc: wrappedToc };
};