Skip to content

Commit a66cd0f

Browse files
refactor(toc): support skipping heading level
1 parent e7a701b commit a66cd0f

File tree

2 files changed

+100
-76
lines changed

2 files changed

+100
-76
lines changed

lib/plugins/helper/toc.ts

Lines changed: 87 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@ interface Options {
1414
list_number?: boolean;
1515
}
1616

17-
function tocHelper(str: string, options: Options = {}) {
17+
/**
18+
* Hexo TOC helper: generates a nested <ol> list from markdown headings
19+
* @param {string} str Raw markdown/html string
20+
* @param {Options} options Configuration options
21+
*/
22+
function tocHelper(str, options: Options = {}) {
23+
// Default options
1824
options = Object.assign({
1925
min_depth: 1,
2026
max_depth: 6,
@@ -29,10 +35,15 @@ function tocHelper(str: string, options: Options = {}) {
2935
list_number: true
3036
}, options);
3137

32-
const data = getAndTruncateTocObj(str, { min_depth: options.min_depth, max_depth: options.max_depth }, options.max_items);
33-
34-
if (!data.length) return '';
38+
// Extract and truncate flat TOC data
39+
const flat = getAndTruncateTocObj(
40+
str,
41+
{ min_depth: options.min_depth, max_depth: options.max_depth },
42+
options.max_items
43+
);
44+
if (!flat.length) return '';
3545

46+
// Prepare class names
3647
const className = escapeHTML(options.class);
3748
const itemClassName = escapeHTML(options.class_item || options.class + '-item');
3849
const linkClassName = escapeHTML(options.class_link || options.class + '-link');
@@ -42,89 +53,89 @@ function tocHelper(str: string, options: Options = {}) {
4253
const levelClassName = escapeHTML(options.class_level || options.class + '-level');
4354
const listNumber = options.list_number;
4455

45-
let result = `<ol class="${className}">`;
46-
47-
const lastNumber = [0, 0, 0, 0, 0, 0];
48-
let firstLevel = 0;
49-
let lastLevel = 0;
50-
51-
for (let i = 0, len = data.length; i < len; i++) {
52-
const el = data[i];
53-
const { level, id, text } = el;
54-
const href = id ? `#${encodeURI(id)}` : null;
55-
56-
if (!el.unnumbered) {
57-
lastNumber[level - 1]++;
58-
}
59-
60-
for (let i = level; i <= 5; i++) {
61-
lastNumber[i] = 0;
62-
}
63-
64-
if (firstLevel) {
65-
for (let i = level; i < lastLevel; i++) {
66-
result += '</li></ol>';
56+
// Build tree, assign numbers, render HTML
57+
const tree = buildTree(flat);
58+
if (listNumber) assignNumbers(tree);
59+
60+
function render(list, depth = 0) {
61+
if (!list.length) return '';
62+
const olCls = depth === 0 ? className : childClassName;
63+
let out = `<ol class="${olCls}">`;
64+
65+
list.forEach(node => {
66+
const lvl = node.level;
67+
out += `<li class="${itemClassName} ${levelClassName}-${lvl}">`;
68+
out += `<a class="${linkClassName}"${node.id ? ` href="#${encodeURI(node.id)}"` : ''}>`;
69+
if (listNumber && !node.unnumbered) {
70+
out += `<span class="${numberClassName}">${node.number}</span> `;
6771
}
72+
out += `<span class="${textClassName}">${node.text}</span></a>`;
73+
out += render(node.children, depth + 1);
74+
out += '</li>';
75+
});
6876

69-
if (level > lastLevel) {
70-
result += `<ol class="${childClassName}">`;
71-
} else {
72-
result += '</li>';
73-
}
74-
} else {
75-
firstLevel = level;
76-
}
77-
78-
result += `<li class="${itemClassName} ${levelClassName}-${level}">`;
79-
if (href) {
80-
result += `<a class="${linkClassName}" href="${href}">`;
81-
} else {
82-
result += `<a class="${linkClassName}">`;
83-
}
84-
85-
if (listNumber && !el.unnumbered) {
86-
result += `<span class="${numberClassName}">`;
77+
out += '</ol>';
78+
return out;
79+
}
8780

88-
for (let i = firstLevel - 1; i < level; i++) {
89-
result += `${lastNumber[i]}.`;
90-
}
81+
return render(tree);
82+
}
9183

92-
result += '</span> ';
84+
/**
85+
* Extract flat TOC data and enforce max_items
86+
*/
87+
function getAndTruncateTocObj(str, { min_depth, max_depth }, max_items) {
88+
let data = tocObj(str, { min_depth, max_depth });
89+
if (max_items < Infinity && data.length > max_items) {
90+
const levels = data.map(i => i.level);
91+
const min = Math.min(...levels);
92+
let curMax = Math.max(...levels);
93+
// remove deeper headings until within limit
94+
while (data.length > max_items && curMax > min) {
95+
// eslint-disable-next-line no-loop-func
96+
data = data.filter(i => i.level < curMax);
97+
curMax--;
9398
}
94-
95-
result += `<span class="${textClassName}">${text}</span></a>`;
96-
97-
lastLevel = level;
98-
}
99-
100-
for (let i = firstLevel - 1; i < lastLevel; i++) {
101-
result += '</li></ol>';
99+
data = data.slice(0, max_items);
102100
}
103-
104-
return result;
101+
return data;
105102
}
106103

107-
function getAndTruncateTocObj(str: string, options: {min_depth: number, max_depth: number}, max_items: number) {
108-
let data = tocObj(str, { min_depth: options.min_depth, max_depth: options.max_depth });
109-
110-
if (data.length === 0) {
111-
return data;
112-
}
113-
if (max_items < 1 || max_items === Infinity) {
114-
return data;
115-
}
104+
/**
105+
* Build nested tree from flat heading list
106+
*/
107+
function buildTree(headings) {
108+
const root = { level: 0, children: [] };
109+
const stack = [root];
110+
111+
headings.forEach(h => {
112+
// pop until parent.level < h.level
113+
while (stack[stack.length - 1].level >= h.level) {
114+
stack.pop();
115+
}
116+
const parent = stack[stack.length - 1];
117+
const node = { ...h, children: [] };
118+
parent.children.push(node);
119+
stack.push(node);
120+
});
116121

117-
const levels = data.map(item => item.level);
118-
const min = Math.min(...levels);
119-
const max = Math.max(...levels);
122+
return root.children;
123+
}
120124

121-
for (let currentLevel = max; data.length > max_items && currentLevel > min; currentLevel--) {
122-
data = data.filter(item => item.level < currentLevel);
125+
/**
126+
* Assign hierarchical numbering to each node
127+
*/
128+
function assignNumbers(nodes) {
129+
const counters = [];
130+
function dfs(list, depth) {
131+
counters[depth] = 0;
132+
list.forEach(node => {
133+
counters[depth]++;
134+
node.number = counters.slice(0, depth + 1).join('.') + '.';
135+
if (node.children.length) dfs(node.children, depth + 1);
136+
});
123137
}
124-
125-
data = data.slice(0, max_items);
126-
127-
return data;
138+
dfs(nodes, 0);
128139
}
129140

130141
export = tocHelper;

test/scripts/helpers/toc.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,19 @@ describe('toc', () => {
437437
toc(input).should.eql('');
438438
});
439439

440+
it('skipping heading level', () => {
441+
const input = [
442+
'<h1>Title 1</h1>',
443+
'<h3>Title 3</h3>',
444+
'<h4>Title 4</h4>',
445+
'<h2>Title 2</h2>',
446+
'<h5>Title 5</h5>',
447+
'<h1>Title 1</h1>'
448+
].join('');
449+
450+
toc(input).should.eql('<ol class="toc"><li class="toc-item toc-level-1"><a class="toc-link"><span class="toc-number">1.</span> <span class="toc-text">Title 1</span></a><ol class="toc-child"><li class="toc-item toc-level-3"><a class="toc-link"><span class="toc-number">1.1.</span> <span class="toc-text">Title 3</span></a><ol class="toc-child"><li class="toc-item toc-level-4"><a class="toc-link"><span class="toc-number">1.1.1.</span> <span class="toc-text">Title 4</span></a></li></ol></li><li class="toc-item toc-level-2"><a class="toc-link"><span class="toc-number">1.2.</span> <span class="toc-text">Title 2</span></a><ol class="toc-child"><li class="toc-item toc-level-5"><a class="toc-link"><span class="toc-number">1.2.1.</span> <span class="toc-text">Title 5</span></a></li></ol></li></ol></li><li class="toc-item toc-level-1"><a class="toc-link"><span class="toc-number">2.</span> <span class="toc-text">Title 1</span></a></li></ol>');
451+
});
452+
440453
it('unnumbered headings', () => {
441454
const className = 'toc';
442455

0 commit comments

Comments
 (0)