A page's headings already describe its structure: an <h2> sits under the <h1> above it, an <h3> under that <h2>, and so on. Given a root element, walk its subtree in document order, find every heading (h1 through h6), and build a nested tree that mirrors that hierarchy — the same kind of outline a docs site renders in its sidebar.
type TocNode = { text: string; level: number; children: TocNode[] };
function tableOfContents(root: Element): TocNode[];
Each node carries the heading's text (its textContent), its level (the number in the tag: h2 is level 2), and a children array of headings that nest beneath it. The return value is an array of the top-level entries.
const root = document.createElement('div');
root.innerHTML = '<h1>Guide</h1><h2>Setup</h2><h2>Usage</h2>';
tableOfContents(root);
// → [
// { text: 'Guide', level: 1, children: [
// { text: 'Setup', level: 2, children: [] },
// { text: 'Usage', level: 2, children: [] },
// ] },
// ]
root.innerHTML = '<h1>A</h1><h3>Deep</h3>';
tableOfContents(root);
// → [{ text: 'A', level: 1, children: [
// { text: 'Deep', level: 3, children: [] }, // h3 nests under h1
// ] }]
root.innerHTML = '<p>no headings here</p>';
tableOfContents(root); // → []
h1 followed directly by an h3 (no h2) nests the h3 under the h1. Do not invent a missing level.h3, an h2 is not a child of that h3 — it climbs back to attach under the nearest shallower heading.h2 that appears before any h1.text is the full textContent, including the text of any inline markup.You'll scan the document for headings in order, then thread each one onto a running stack of ancestors so it lands under the nearest shallower heading — turning a flat list into a nested outline.
Think of the sidebar on a documentation page: the page title, with its sections indented beneath it, and sub-sections indented under those. That outline is built entirely from the page's headings. The DOM gives you those headings as a flat sequence in document order, but it does not hand you the tree — <h2> and <h3> are siblings in the markup even when one logically belongs inside the other. Your job is to read the levels (h1 is 1, h2 is 2, and so on) and reconstruct the nesting: each heading becomes a child of the most recent heading with a smaller level.
Headings arrive flat, but each carries a level number, and that number tells you where it belongs. Reading top to bottom, a heading slots underneath the closest earlier heading that is shallower than it. So Setup (h2) and Usage (h2) both fall under Guide (h1), and Basics (h3) tucks under whichever h2 came just before it. The picture you're building is a tree, even though the input is a line.
The obvious move is to grab every heading and map each to a node:
function tableOfContents(root) {
const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6');
const result = [];
for (const heading of headings) {
result.push({
text: heading.textContent,
level: Number(heading.tagName[1]),
children: [],
});
}
return result;
}
This collects the right headings with the right text and level, but every node ends up at the top level with an empty children array — the hierarchy is gone. An <h1> with two <h2>s underneath it should return one root holding two children; this returns three flat roots. The function answers "what are the headings?" but never asks the question the problem is actually about: "whose child is each heading?"
The fix keeps a stack of open ancestors — the chain of headings we're currently nested inside. For each new heading, pop off any ancestor that is at the same level or deeper (it can't be a parent), then attach to whatever is left on top:
function tableOfContents(root) {
const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6');
const roots = []; // top-level entries (no shallower ancestor)
const stack = []; // the open ancestors, shallowest at the bottom
for (const heading of headings) {
const node = {
text: heading.textContent,
level: Number(heading.tagName[1]), // 'H2' -> 2
children: [],
};
// Pop ancestors that can't be this heading's parent: anything at the same
// level or deeper is a sibling or descendant, not a container for it.
while (stack.length > 0 && stack[stack.length - 1].level >= node.level) {
stack.pop();
}
if (stack.length === 0) {
roots.push(node); // nothing shallower remains -> it's top-level
} else {
stack[stack.length - 1].children.push(node); // child of nearest shallower
}
stack.push(node); // this heading is now itself an open ancestor
}
return roots;
}
module.exports = { tableOfContents };
The shift from the naive version is the stack. Instead of dropping every heading at the top level, we maintain the live chain of ancestors and use the level numbers to decide where the new heading attaches. The >= in the pop condition is the crux: an equal level means a sibling (pop the previous one so they share a parent), and a smaller level means we've risen back out of a subsection (pop until we find the shallower heading that should contain us). Whatever sits on top after popping is the correct parent — or, if the stack empties, the heading is top-level.
Trace tableOfContents(root) on <h1>A</h1><h2>B</h2><h3>C</h3><h2>D</h2>:
A (level 1). The stack is empty, so the while loop does nothing. stack.length === 0, so A is pushed to roots. Then push A — stack is [A].B (level 2). Top is A (level 1); 1 >= 2 is false, so nothing pops. The stack isn't empty, so B becomes a child of the top, A. Push B — stack is [A, B].C (level 3). Top is B (level 2); 2 >= 3 is false, nothing pops. C becomes a child of B. Push C — stack is [A, B, C].D (level 2). Top is C (level 3); 3 >= 2 is true — pop C. New top is B (level 2); 2 >= 2 is true — pop B. New top is A (level 1); 1 >= 2 is false — stop. D becomes a child of A. Push D — stack is [A, D].roots = one node A, whose children are B (holding C) and D.The naive version would have returned four flat nodes; the stack rebuilt the actual tree: A → [B → [C], D].
The same machinery handles a skipped level for free. Given <h1>Title</h1><h3>Detail</h3>, when Detail (level 3) arrives the stack holds only Title (level 1); since 1 >= 3 is false, nothing pops and Detail attaches directly to Title. No phantom h2 is invented — nesting follows relative levels, not a strict one-step ladder.
<section>s can hold an h1 and an h2 that should still be parent and child. Fix: decide nesting purely from the level numbers, in document order.> instead of >= when popping. With >, an h2 following another h2 would nest inside its sibling rather than beside it. Fix: pop while the top's level is >= the new level, so equal levels become siblings.h3 straight after an h1 must attach to the h1 (don't invent a missing h2), and an h2 before any h1 has no ancestor at all. Fix: after popping, attach to whatever remains on top — and if the stack is empty, treat the heading as top-level.id to each node derived from the text (lowercase, spaces to hyphens, deduped) so the outline can render <a href="#install"> links.h1 to h4, or starts at h3, is often an accessibility smell. The same walk can flag skipped levels as warnings while still building the tree.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A page's headings already describe its structure: an <h2> sits under the <h1> above it, an <h3> under that <h2>, and so on. Given a root element, walk its subtree in document order, find every heading (h1 through h6), and build a nested tree that mirrors that hierarchy — the same kind of outline a docs site renders in its sidebar.
type TocNode = { text: string; level: number; children: TocNode[] };
function tableOfContents(root: Element): TocNode[];
Each node carries the heading's text (its textContent), its level (the number in the tag: h2 is level 2), and a children array of headings that nest beneath it. The return value is an array of the top-level entries.
const root = document.createElement('div');
root.innerHTML = '<h1>Guide</h1><h2>Setup</h2><h2>Usage</h2>';
tableOfContents(root);
// → [
// { text: 'Guide', level: 1, children: [
// { text: 'Setup', level: 2, children: [] },
// { text: 'Usage', level: 2, children: [] },
// ] },
// ]
root.innerHTML = '<h1>A</h1><h3>Deep</h3>';
tableOfContents(root);
// → [{ text: 'A', level: 1, children: [
// { text: 'Deep', level: 3, children: [] }, // h3 nests under h1
// ] }]
root.innerHTML = '<p>no headings here</p>';
tableOfContents(root); // → []
h1 followed directly by an h3 (no h2) nests the h3 under the h1. Do not invent a missing level.h3, an h2 is not a child of that h3 — it climbs back to attach under the nearest shallower heading.h2 that appears before any h1.text is the full textContent, including the text of any inline markup.You'll scan the document for headings in order, then thread each one onto a running stack of ancestors so it lands under the nearest shallower heading — turning a flat list into a nested outline.
Think of the sidebar on a documentation page: the page title, with its sections indented beneath it, and sub-sections indented under those. That outline is built entirely from the page's headings. The DOM gives you those headings as a flat sequence in document order, but it does not hand you the tree — <h2> and <h3> are siblings in the markup even when one logically belongs inside the other. Your job is to read the levels (h1 is 1, h2 is 2, and so on) and reconstruct the nesting: each heading becomes a child of the most recent heading with a smaller level.
Headings arrive flat, but each carries a level number, and that number tells you where it belongs. Reading top to bottom, a heading slots underneath the closest earlier heading that is shallower than it. So Setup (h2) and Usage (h2) both fall under Guide (h1), and Basics (h3) tucks under whichever h2 came just before it. The picture you're building is a tree, even though the input is a line.
The obvious move is to grab every heading and map each to a node:
function tableOfContents(root) {
const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6');
const result = [];
for (const heading of headings) {
result.push({
text: heading.textContent,
level: Number(heading.tagName[1]),
children: [],
});
}
return result;
}
This collects the right headings with the right text and level, but every node ends up at the top level with an empty children array — the hierarchy is gone. An <h1> with two <h2>s underneath it should return one root holding two children; this returns three flat roots. The function answers "what are the headings?" but never asks the question the problem is actually about: "whose child is each heading?"
The fix keeps a stack of open ancestors — the chain of headings we're currently nested inside. For each new heading, pop off any ancestor that is at the same level or deeper (it can't be a parent), then attach to whatever is left on top:
function tableOfContents(root) {
const headings = root.querySelectorAll('h1, h2, h3, h4, h5, h6');
const roots = []; // top-level entries (no shallower ancestor)
const stack = []; // the open ancestors, shallowest at the bottom
for (const heading of headings) {
const node = {
text: heading.textContent,
level: Number(heading.tagName[1]), // 'H2' -> 2
children: [],
};
// Pop ancestors that can't be this heading's parent: anything at the same
// level or deeper is a sibling or descendant, not a container for it.
while (stack.length > 0 && stack[stack.length - 1].level >= node.level) {
stack.pop();
}
if (stack.length === 0) {
roots.push(node); // nothing shallower remains -> it's top-level
} else {
stack[stack.length - 1].children.push(node); // child of nearest shallower
}
stack.push(node); // this heading is now itself an open ancestor
}
return roots;
}
module.exports = { tableOfContents };
The shift from the naive version is the stack. Instead of dropping every heading at the top level, we maintain the live chain of ancestors and use the level numbers to decide where the new heading attaches. The >= in the pop condition is the crux: an equal level means a sibling (pop the previous one so they share a parent), and a smaller level means we've risen back out of a subsection (pop until we find the shallower heading that should contain us). Whatever sits on top after popping is the correct parent — or, if the stack empties, the heading is top-level.
Trace tableOfContents(root) on <h1>A</h1><h2>B</h2><h3>C</h3><h2>D</h2>:
A (level 1). The stack is empty, so the while loop does nothing. stack.length === 0, so A is pushed to roots. Then push A — stack is [A].B (level 2). Top is A (level 1); 1 >= 2 is false, so nothing pops. The stack isn't empty, so B becomes a child of the top, A. Push B — stack is [A, B].C (level 3). Top is B (level 2); 2 >= 3 is false, nothing pops. C becomes a child of B. Push C — stack is [A, B, C].D (level 2). Top is C (level 3); 3 >= 2 is true — pop C. New top is B (level 2); 2 >= 2 is true — pop B. New top is A (level 1); 1 >= 2 is false — stop. D becomes a child of A. Push D — stack is [A, D].roots = one node A, whose children are B (holding C) and D.The naive version would have returned four flat nodes; the stack rebuilt the actual tree: A → [B → [C], D].
The same machinery handles a skipped level for free. Given <h1>Title</h1><h3>Detail</h3>, when Detail (level 3) arrives the stack holds only Title (level 1); since 1 >= 3 is false, nothing pops and Detail attaches directly to Title. No phantom h2 is invented — nesting follows relative levels, not a strict one-step ladder.
<section>s can hold an h1 and an h2 that should still be parent and child. Fix: decide nesting purely from the level numbers, in document order.> instead of >= when popping. With >, an h2 following another h2 would nest inside its sibling rather than beside it. Fix: pop while the top's level is >= the new level, so equal levels become siblings.h3 straight after an h1 must attach to the h1 (don't invent a missing h2), and an h2 before any h1 has no ancestor at all. Fix: after popping, attach to whatever remains on top — and if the stack is empty, treat the heading as top-level.id to each node derived from the text (lowercase, spaces to hyphens, deduped) so the outline can render <a href="#install"> links.h1 to h4, or starts at h3, is often an accessibility smell. The same walk can flag skipped levels as warnings while still building the tree.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.