<%
/* Flat [{ id, text, level }] for h2 to h6, nested by level */
const tree = [];
const stack = [{ level: 1, children: tree }];
for (const entry of stati.page.toc) {
while (stack.length > 1 && stack.at(-1).level >= entry.level) stack.pop();
const node = { ...entry, children: [] };
stack.at(-1).children.push(node);
stack.push(node);
}
/* Writes its markup as it runs: call it with <% %>, not <%~ %> */
function list(nodes) { %>
<ul>
<% for (const node of nodes) { %>
<li>
<a href="<%= `#${node.id}` %>"><%= node.text %></a>
<% if (node.children.length) list(node.children); %>
</li>
<% } %>
</ul>
<% }
%>
<% if (tree.length) { %>
<nav aria-label="On this page">
<% list(tree); %>
</nav>
<% } %>
Stati gives every h2 to h6 an id and lists them in stati.page.toc as { id, text, level }, with -1, -2 added to the ids of repeated headings. Link to those ids rather than slugifying the text again.
The function is the part to remember. Markup inside a function defined in a template goes straight to the output when the function runs, and the function itself returns undefined. <% list(tree); %> is right; <%~ list(tree) %> prints the word undefined and loses the list.
In the Stati docs: Table of contents and the table of contents recipe.