Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

toc without highlighting and without mobile view #214

Merged
merged 13 commits into from
Nov 21, 2023
5 changes: 4 additions & 1 deletion docs/.observablehq/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,8 @@ export default {
]
},
{name: "Contributing", path: "/contributing"}
]
],
toc: {
show: true
}
};
21 changes: 20 additions & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ This is where you data loader cache will live. You don’t typically have to wor

#### `docs/.observablehq/config.ts`

This is where you configure project-level settings, such as the pages and sections in the sidebar navigation, and the project’s title. The config file can be written in either TypeScript (`.ts`) or JavaScript (`.js`).
This is where you configure project-level settings, such as the pages and sections in the sidebar navigation, and the project’s title. The config file can be written in either TypeScript (`.ts`) or JavaScript (`.js`). See [below](#configuration) for details.

#### `docs/components`

Expand Down Expand Up @@ -88,3 +88,22 @@ observable build
Creates `dist`.

You can use `npx http-server dist` to preview your built site.

## Configuration

A `config.js` (or `config.ts`) file residing under the `docs/.observablehq/` directory allows you to configure certain aspects of the project. The following optional configuration options are supported:

- **title** - the project’s title
- **pages** - the website hierarchy
- **toc** - configuration for the table of contents

If a **title** is specified, it is used as text to describe the link to the home page in the sidebar (for a multipage project), and to complement the titles of the webpages. For instance, a page titled _“Sales”_ in a project titled _“ACME, Inc.”_ will display _“Sales | ACME, Inc.”_ in the browser’s title bar.

The **pages** option is an array containing pages—described by a name and a path starting from the root— and sections—described by a name and a similar array of pages—, creating a website hierarchy. It defaults to the list of markdown files found in the project’s docs, in alphanumerical order, followed by pages found in subdirectories.

The **toc** option is an object describing the generation of the table of contents on each page. It supports the following options:

- **show** - a boolean which defaults to false
- **label** - the table of contents’s header

Both these options can also be set in the page’s front-matter, which takes precedence on the global setting. If **show** is true, and the page contains H2 headings (created for example with a line containing `## Section name`), a table of contents is generated from all the headings, and displayed on the right-hand side of the page.
5 changes: 5 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
toc:
show: false
---

<a href="https://observablehq.com" style="color: inherit;">
<svg width="160" viewBox="0 0 164.47500610351562 22.68549919128418" fill="currentColor">
<path d="M10.9646 18.9046C9.95224 18.9046 9.07507 18.6853 8.33313 18.2467C7.59386 17.8098 7.0028 17.1909 6.62722 16.4604C6.22789 15.7003 5.93558 14.8965 5.75735 14.0684C5.56825 13.1704 5.47613 12.2574 5.48232 11.3427C5.48232 10.6185 5.52984 9.92616 5.62578 9.26408C5.7208 8.60284 5.89715 7.93067 6.15391 7.24843C6.41066 6.56618 6.74143 5.97468 7.14438 5.47308C7.56389 4.9592 8.1063 4.54092 8.72969 4.25059C9.38391 3.93719 10.1277 3.78091 10.9646 3.78091C11.977 3.78091 12.8542 4.00021 13.5962 4.43879C14.3354 4.87564 14.9265 5.49454 15.3021 6.22506C15.6986 6.97704 15.9883 7.7744 16.1719 8.61712C16.3547 9.459 16.447 10.3681 16.447 11.3427C16.447 12.067 16.3995 12.7593 16.3035 13.4214C16.2013 14.1088 16.0206 14.7844 15.7644 15.437C15.4994 16.1193 15.1705 16.7108 14.7739 17.2124C14.3774 17.714 13.8529 18.1215 13.1996 18.4349C12.5463 18.7483 11.8016 18.9046 10.9646 18.9046ZM12.8999 13.3447C13.4242 12.8211 13.7159 12.0966 13.7058 11.3427C13.7058 10.5639 13.4436 9.89654 12.92 9.34074C12.3955 8.78495 11.7441 8.50705 10.9646 8.50705C10.1852 8.50705 9.53376 8.78495 9.00928 9.34074C8.49569 9.87018 8.21207 10.5928 8.22348 11.3427C8.22348 12.1216 8.48572 12.7889 9.00928 13.3447C9.53376 13.9005 10.1852 14.1784 10.9646 14.1784C11.7441 14.1784 12.3891 13.9005 12.8999 13.3447ZM10.9646 22.6855C17.0199 22.6855 21.9293 17.6068 21.9293 11.3427C21.9293 5.07871 17.0199 0 10.9646 0C4.90942 0 0 5.07871 0 11.3427C0 17.6068 4.90942 22.6855 10.9646 22.6855Z"></path>
Expand Down
12 changes: 12 additions & 0 deletions public/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -352,3 +352,15 @@ function enableCopyButtons() {
async function copy({currentTarget}) {
await navigator.clipboard.writeText(currentTarget.parentElement.textContent.trimEnd());
}

document.addEventListener("DOMContentLoaded", () => {
if (location.hash) highlightToc(location.hash);
window.addEventListener("hashchange", () => {
highlightToc(location.hash);
});
function highlightToc(hash) {
const currentSelected = document.querySelector("li.observablehq-secondary-link-active");
if (currentSelected) currentSelected.classList.remove("observablehq-secondary-link-active");
document.querySelector(`li a[href="${hash}"]`)?.parentElement.classList.add("observablehq-secondary-link-active");
}
});
65 changes: 62 additions & 3 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,69 @@ body {
color: var(--theme-foreground-focus);
}

.observablehq-link a[href] {

#observablehq-toc {
display: none; /** TODO - mobile view **/
width: 6em;
position: absolute;
right: 1rem;
background: var(--theme-background);
padding-left: 2rem;
padding-right: 2rem;
user-select: none;
}
#observablehq-toc:not([open]) summary span {
display: none;
}

#observablehq-toc summary::marker {
content: '';
}
#observablehq-toc ::-webkit-details-marker {
display: none;
}

@media (min-width: calc(1152px + 6em)) {
#observablehq-toc {
display: block;
position: fixed;
top: 1rem;
border-left: solid 1px var(--theme-foreground-faintest);
}
#observablehq-toc summary {
pointer-events: none;
}
#observablehq-main.has-toc {
padding-right: 11em;
}
}

#observablehq-toc nav ol {
margin: 0;
padding-inline-start: 5px;
}

#observablehq-toc>* {
color: var(--theme-foreground-mute);
margin-bottom: 8px;
font: 14px var(--sans-serif);
}

#observablehq-toc nav ol li {
font: 12px var(--sans-serif);
font-weight: 350;
line-height: 1.5;
list-style-type: none;
color: var(--theme-foreground-faint);
}

.observablehq-link a[href],
.observablehq-secondary-link a[href] {
color: inherit;
}

.observablehq-link-active {
.observablehq-link-active,
.observablehq-secondary-link-active {
position: relative;
}

Expand All @@ -248,7 +306,8 @@ body {
}

a[href],
.observablehq-link-active a[href] {
.observablehq-link-active a[href],
.observablehq-secondary-link-active a[href] {
color: var(--theme-foreground-focus);
}

Expand Down
5 changes: 3 additions & 2 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export async function build(context: CommandContext = makeCommandContext()) {

// Render .md files, building a list of file attachments as we go.
const pages = await readPages(sourceRoot);
const title = (await readConfig(sourceRoot))?.title;
const config = await readConfig(sourceRoot);
const files: string[] = [];
const imports: string[] = [];
const resolver = await makeCLIResolver();
Expand All @@ -44,7 +44,8 @@ export async function build(context: CommandContext = makeCommandContext()) {
root: sourceRoot,
path,
pages,
title,
title: config?.title,
toc: config?.toc,
resolver
});
const resolveFile = ({name}) => join(name.startsWith("/") ? "." : dirname(sourceFile), name);
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@ export interface Section {
pages: Page[];
}

export interface TableOfContents {
label?: string;
show?: boolean;
}

export interface Config {
title?: string;
pages?: (Page | Section)[]; // TODO rename to sidebar?
toc?: TableOfContents;
}

export async function readConfig(root: string): Promise<Config | undefined> {
Expand Down
4 changes: 3 additions & 1 deletion src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,13 @@ class Server {
// Anything else should 404; static files should be matched above.
try {
pages = await readPages(this.root); // TODO cache? watcher?
const config = await readConfig(this.root);
const {html} = await renderPreview(await readFile(path + ".md", "utf-8"), {
root: this.root,
path: pathname,
pages,
title: (await readConfig(this.root))?.title,
title: config?.title,
toc: config?.toc,
resolver: this._resolver!
});
end(req, res, html, "text/html");
Expand Down
31 changes: 29 additions & 2 deletions src/render.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {dirname, join} from "node:path";
import {parseHTML} from "linkedom";
import {type Config, type Page, type Section} from "./config.js";
import {computeHash} from "./hash.js";
import {resolveImport} from "./javascript/imports.js";
Expand Down Expand Up @@ -52,8 +53,9 @@ type RenderInternalOptions =

function render(
parseResult: ParseResult,
{path, pages, title, preview, hash, resolver}: RenderOptions & RenderInternalOptions
{path, pages, title, toc, preview, hash, resolver}: RenderOptions & RenderInternalOptions
): string {
const table = tableOfContents(parseResult, toc);
return `<!DOCTYPE html>
<meta charset="utf-8">${path === "/404" ? `\n<base href="/">` : ""}
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
Expand Down Expand Up @@ -88,7 +90,7 @@ ${JSON.stringify(parseResult.data)}
}
${pages.length > 0 ? sidebar(title, pages, path) : ""}
<div id="observablehq-center">
<main id="observablehq-main" class="observablehq">
${table}<main id="observablehq-main" class="observablehq${table ? " has-toc" : ""}">
${parseResult.html}</main>
${footer(path, {pages, title})}
</div>
Expand Down Expand Up @@ -144,6 +146,31 @@ function sidebar(title: string | undefined, pages: (Page | Section)[], path: str
}</script>`;
}

function tableOfContents(parseResult: ParseResult, toc: RenderOptions["toc"]) {
const pageTocConfig = parseResult.data?.toc;
const headers =
(pageTocConfig?.show ?? toc?.show) &&
Array.from(parseHTML(parseResult.html).document.querySelectorAll("h2"))
.map((node) => ({
label: node.textContent,
href: node.firstElementChild?.getAttribute("href")
}))
.filter((d) => d.label && d.href);
return headers?.length
? `<details open id="observablehq-toc">
<summary><span>${pageTocConfig?.label ?? toc?.label ?? "Contents"}</span></summary>
<nav><ol>\n${headers
.map(
({label, href}) =>
`<li class="observablehq-secondary-link"><a href="${escapeDoubleQuoted(href)}">${escapeData(
label
)}</a></li>`
)
.join("\n")}\n</ol></nav>
</details>\n`
: "";
}

function renderListItem(p: Page, path: string): string {
return `<li class="observablehq-link${
p.path === path ? " observablehq-link-active" : ""
Expand Down
5 changes: 4 additions & 1 deletion test/input/build/config/.observablehq/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ export default {
{path: "/index", name: "Index"},
{path: "/one", name: "One<Two"},
{path: "/sub/two", name: "Two"}
]
],
toc: {
label: "On this page"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we want to follow the exact same naming as Vitepress? I think we should opt for something different

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the override test, it needs to be different from the new default which is "Contents"

}
};
15 changes: 15 additions & 0 deletions test/input/build/config/toc-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
toc:
label: TOC
show: true
---

# H1: Section

## H2: Section 1
Some text here

## H2: Section 2
Some text here again

### H3: Section 1
17 changes: 17 additions & 0 deletions test/input/build/config/toc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
toc:
show: true
---

# H1: Section

## H2: Section 1
Some text here

## H2: Section 2
Some text here again

### H3: Section 1

## H2 &lt;script>alert(1)&lt;/script> not nice

54 changes: 54 additions & 0 deletions test/output/build/config/toc-override.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>H1: Section</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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">
<link rel="stylesheet" type="text/css" href="./_observablehq/style.css">
<link rel="modulepreload" href="./_observablehq/runtime.js">
<script type="module">

import {define} from "./_observablehq/client.js";


</script>
<script type="application/json">
{"toc":{"label":"TOC","show":true}}
</script>
<input id="observablehq-sidebar-toggle" type="checkbox">
<nav id="observablehq-sidebar">
<ol>
<li class="observablehq-link"><a href="./">Home</a></li>
</ol>
<ol>
<li class="observablehq-link"><a href="./">Index</a></li>
<li class="observablehq-link"><a href="./one">One&#60;Two</a></li>
<li class="observablehq-link"><a href="./sub/two">Two</a></li>
</ol>
</nav>
<script>{
const toggle = document.querySelector("#observablehq-sidebar-toggle");
const initialState = localStorage.getItem("observablehq-sidebar");
if (initialState) toggle.checked = initialState === "true";
else toggle.indeterminate = true;
}</script>
<div id="observablehq-center">
<details open id="observablehq-toc">
<summary><span>TOC</span></summary>
<nav><ol>
<li class="observablehq-secondary-link"><a href="#h2%3A-section-1">H2: Section 1</a></li>
<li class="observablehq-secondary-link"><a href="#h2%3A-section-2">H2: Section 2</a></li>
</ol></nav>
</details>
<main id="observablehq-main" class="observablehq has-toc">
<h1 id="h1%3A-section" tabindex="-1"><a class="observablehq-header-anchor" href="#h1%3A-section">H1: Section</a></h1>
<h2 id="h2%3A-section-1" tabindex="-1"><a class="observablehq-header-anchor" href="#h2%3A-section-1">H2: Section 1</a></h2>
<p>Some text here</p>
<h2 id="h2%3A-section-2" tabindex="-1"><a class="observablehq-header-anchor" href="#h2%3A-section-2">H2: Section 2</a></h2>
<p>Some text here again</p>
<h3 id="h3%3A-section-1" tabindex="-1"><a class="observablehq-header-anchor" href="#h3%3A-section-1">H3: Section 1</a></h3>
</main>
<footer id="observablehq-footer">
<div>© 2023 Observable, Inc.</div>
</footer>
</div>
Loading