Skip to content

Commit 22325e7

Browse files
authored
A slightly better TOC (#298)
* A slightly better TOC: - add intermediate h1 sections - skip subtitles (h1+h2) closes #281 * toc selector source of truth
1 parent 21772b1 commit 22325e7

File tree

6 files changed

+91
-7
lines changed

6 files changed

+91
-7
lines changed

src/client/toc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const toc = document.querySelector("#observablehq-toc");
22
if (toc) {
33
const highlight = toc.appendChild(document.createElement("div"));
44
highlight.classList.add("observablehq-secondary-link-highlight");
5-
const headings = Array.from(document.querySelectorAll("#observablehq-main h2")).reverse();
5+
const headings = Array.from(document.querySelectorAll(toc.dataset.selector)).reverse();
66
const links = toc.querySelectorAll(".observablehq-secondary-link");
77
const relink = () => {
88
for (const link of links) {

src/render.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,18 @@ interface Header {
123123
href: string;
124124
}
125125

126+
const tocSelector = ["h1:not(:first-of-type)", "h2:not(h1 + h2)"];
127+
126128
function findHeaders(parseResult: ParseResult): Header[] {
127-
return Array.from(parseHTML(parseResult.html).document.querySelectorAll("h2"))
129+
return Array.from(parseHTML(parseResult.html).document.querySelectorAll(tocSelector.join(", ")))
128130
.map((node) => ({label: node.textContent, href: node.firstElementChild?.getAttribute("href")}))
129131
.filter((d): d is Header => !!d.label && !!d.href);
130132
}
131133

132134
function renderToc(headers: Header[], label = "Contents"): Html {
133-
return html`<aside id="observablehq-toc">
135+
return html`<aside id="observablehq-toc" data-selector="${tocSelector
136+
.map((selector) => `#observablehq-main ${selector}`)
137+
.join(", ")}">
134138
<nav>
135139
<div>${label}</div>
136140
<ol>${headers.map(

test/input/build/subtitle/index.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# A title
2+
## A subtitle
3+
4+
The subtitle is not part of the TOC.
5+
6+
## First section
7+
8+
The first section is part of the TOC.
9+
10+
## Second section
11+
12+
The second section is also part of the TOC.
13+
14+
# An intermediate H1
15+
## With its own subtitle
16+
17+
Not part of the TOC either.
18+
19+
## Third section
20+
21+
The third section is part of the TOC.
22+

test/output/build/config/toc-override.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,10 @@
3030
if (initialState) toggle.checked = initialState === "true";
3131
else toggle.indeterminate = true;
3232
}</script>
33-
<aside id="observablehq-toc">
33+
<aside id="observablehq-toc" data-selector="#observablehq-main h1:not(:first-of-type), #observablehq-main h2:not(h1 + h2)">
3434
<nav>
3535
<div>dollar&amp;pound</div>
3636
<ol>
37-
<li class="observablehq-secondary-link"><a href="#h2%3A-section-1">H2: Section 1</a></li>
3837
<li class="observablehq-secondary-link"><a href="#h2%3A-section-2">H2: Section 2</a></li>
3938
</ol>
4039
</nav>

test/output/build/config/toc.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,10 @@
3030
if (initialState) toggle.checked = initialState === "true";
3131
else toggle.indeterminate = true;
3232
}</script>
33-
<aside id="observablehq-toc">
33+
<aside id="observablehq-toc" data-selector="#observablehq-main h1:not(:first-of-type), #observablehq-main h2:not(h1 + h2)">
3434
<nav>
3535
<div>On this page</div>
3636
<ol>
37-
<li class="observablehq-secondary-link"><a href="#h2%3A-section-1">H2: Section 1</a></li>
3837
<li class="observablehq-secondary-link"><a href="#h2%3A-section-2">H2: Section 2</a></li>
3938
<li class="observablehq-secondary-link"><a href="#h2-%3Cscript%3Ealert(1)%3C%2Fscript%3E-not-nice">H2 &lt;script&gt;alert(1)&lt;/script&gt; not nice</a></li>
4039
</ol>

test/output/build/subtitle/index.html

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf-8">
3+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
4+
<title>A title</title>
5+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
6+
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&display=swap">
7+
<link rel="stylesheet" type="text/css" href="./_observablehq/style.css">
8+
<link rel="modulepreload" href="./_observablehq/runtime.js">
9+
<script type="module">
10+
11+
import {define} from "./_observablehq/client.js";
12+
13+
14+
</script>
15+
<input id="observablehq-sidebar-toggle" type="checkbox">
16+
<label id="observablehq-sidebar-backdrop" for="observablehq-sidebar-toggle"></label>
17+
<nav id="observablehq-sidebar">
18+
<ol>
19+
<li class="observablehq-link observablehq-link-active"><a href="./">Home</a></li>
20+
</ol>
21+
<ol>
22+
<li class="observablehq-link observablehq-link-active"><a href="./">A title</a></li>
23+
</ol>
24+
</nav>
25+
<script>{
26+
const toggle = document.querySelector("#observablehq-sidebar-toggle");
27+
const initialState = localStorage.getItem("observablehq-sidebar");
28+
if (initialState) toggle.checked = initialState === "true";
29+
else toggle.indeterminate = true;
30+
}</script>
31+
<aside id="observablehq-toc" data-selector="#observablehq-main h1:not(:first-of-type), #observablehq-main h2:not(h1 + h2)">
32+
<nav>
33+
<div>Contents</div>
34+
<ol>
35+
<li class="observablehq-secondary-link"><a href="#first-section">First section</a></li>
36+
<li class="observablehq-secondary-link"><a href="#second-section">Second section</a></li>
37+
<li class="observablehq-secondary-link"><a href="#an-intermediate-h1">An intermediate H1</a></li>
38+
<li class="observablehq-secondary-link"><a href="#third-section">Third section</a></li>
39+
</ol>
40+
</nav>
41+
</aside>
42+
<div id="observablehq-center">
43+
<main id="observablehq-main" class="observablehq">
44+
<h1 id="a-title" tabindex="-1"><a class="observablehq-header-anchor" href="#a-title">A title</a></h1>
45+
<h2 id="a-subtitle" tabindex="-1"><a class="observablehq-header-anchor" href="#a-subtitle">A subtitle</a></h2>
46+
<p>The subtitle is not part of the TOC.</p>
47+
<h2 id="first-section" tabindex="-1"><a class="observablehq-header-anchor" href="#first-section">First section</a></h2>
48+
<p>The first section is part of the TOC.</p>
49+
<h2 id="second-section" tabindex="-1"><a class="observablehq-header-anchor" href="#second-section">Second section</a></h2>
50+
<p>The second section is also part of the TOC.</p>
51+
<h1 id="an-intermediate-h1" tabindex="-1"><a class="observablehq-header-anchor" href="#an-intermediate-h1">An intermediate H1</a></h1>
52+
<h2 id="with-its-own-subtitle" tabindex="-1"><a class="observablehq-header-anchor" href="#with-its-own-subtitle">With its own subtitle</a></h2>
53+
<p>Not part of the TOC either.</p>
54+
<h2 id="third-section" tabindex="-1"><a class="observablehq-header-anchor" href="#third-section">Third section</a></h2>
55+
<p>The third section is part of the TOC.</p>
56+
</main>
57+
<footer id="observablehq-footer">
58+
<div>© 2023 Observable, Inc.</div>
59+
</footer>
60+
</div>

0 commit comments

Comments
 (0)