diff --git a/docs/.observablehq/config.ts b/docs/.observablehq/config.ts index f74bd135c..ed911bf7a 100644 --- a/docs/.observablehq/config.ts +++ b/docs/.observablehq/config.ts @@ -28,5 +28,8 @@ export default { ] }, {name: "Contributing", path: "/contributing"} - ] + ], + toc: { + label: "Contents" + } }; diff --git a/docs/getting-started.md b/docs/getting-started.md index 3a971a6e6..4dcc80cfd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,3 +1,8 @@ +--- +toc: + show: true +--- + # Getting started The Observable CLI is a Node.js application and is published to npm as [`@observablehq/cli`](https://www.npmjs.com/package/@observablehq/cli). As the name suggests, the CLI lives on the command line; the instructions below are intended to run in your [terminal](https://support.apple.com/guide/terminal/open-or-quit-terminal-apd5265185d-f365-44cb-8b09-71a064a42125/mac). You’ll need to install [Node.js 18 or later](https://nodejs.org/) before you can install the CLI. @@ -88,3 +93,41 @@ observable build Creates `dist`. You can use `npx http-server dist` to preview your built site. + +## Configuration + +Add a `config.js` or `config.ts` file under the `.observablehq` directory. Example config: + +``` +{ + title: "Hello World", + pages: [ + {name: "Getting started", path: "/getting-started"}, + { + name: "JavaScript", + pages: [ + {name: "Reactivity", path: "/javascript/reactivity"}, + {name: "Display", path: "/javascript/display"}, + ] + } + ], + toc: { + label: "Contents" + show: true + } +} +``` + +You can configure: + +### `title` + +Customize the title on the left sidebar. + +### `pages` + +A page has a name and path. The page path corresponds to the path of the `.md` file from your root directory. For example, if `docs` is your root directory, the `docs/javascript.md` file corresponds to the `/javascript` path + +### `table of contents on a page` + +`label` is the name of the TOC (table of contents) section. Setting `show` to `true` renders the TOC globally with `h2` tags. diff --git a/docs/loaders.md b/docs/loaders.md index 3f06fcc59..40c88293e 100644 --- a/docs/loaders.md +++ b/docs/loaders.md @@ -1,3 +1,8 @@ +--- +toc: + show: true +--- + # Data loaders **Data loaders** generate files — typically static snapshots of data — at build time. For example, a data loader might query a database and output a CSV or Parquet file, or server-side render a chart and output a PNG image. @@ -28,7 +33,9 @@ And that’s it! The CLI automatically runs the data loader. (More details below Now we can display the earthquakes in a map: ```js -const world = await fetch("https://cdn.jsdelivr.net/npm/world-atlas@1/world/110m.json").then((response) => response.json()); +const world = await fetch("https://cdn.jsdelivr.net/npm/world-atlas@1/world/110m.json").then((response) => + response.json() +); const land = topojson.feature(world, world.objects.land); ``` @@ -44,7 +51,7 @@ Plot.plot({ Plot.geo(land, {stroke: "var(--theme-foreground-faint)"}), Plot.dot(quakes, {x: "longitude", y: "latitude", r: "magnitude", stroke: "#f43f5e"}) ] -}) +}); ``` Here are some more details on data loaders. @@ -53,21 +60,21 @@ Here are some more details on data loaders. Data loaders live in `docs` alongside your other source files. When a file is referenced from JavaScript, either via [`FileAttachment`](./javascript/files) or [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), if the file does not exist, the CLI will look for a file of the same name with a double extension to see if there is a corresponding data loader. The following second extensions are checked, in order, with the corresponding language and interpreter: -* `.js` - JavaScript (`node`) -* `.ts` - TypeScript (`tsx`) -* `.py` - Python (`python3`) -* `.R` - R (`Rscript`) -* `.sh` - shell script (`sh`) -* `.exe` - arbitrary executable +- `.js` - JavaScript (`node`) +- `.ts` - TypeScript (`tsx`) +- `.py` - Python (`python3`) +- `.R` - R (`Rscript`) +- `.sh` - shell script (`sh`) +- `.exe` - arbitrary executable For example, for the file `earthquakes.csv`, the following data loaders are considered: -* `earthquakes.csv.js` -* `earthquakes.csv.ts` -* `earthquakes.csv.py` -* `earthquakes.csv.R` -* `earthquakes.csv.sh` -* `earthquakes.csv.exe` +- `earthquakes.csv.js` +- `earthquakes.csv.ts` +- `earthquakes.csv.py` +- `earthquakes.csv.R` +- `earthquakes.csv.sh` +- `earthquakes.csv.exe` If you use `.py` or `.R`, the corresponding interpreter (`python3` or `Rscript`, respectively) must be installed and available on your `$PATH`. Any additional modules, packages, libraries, _etc._, must also be installed before you can use them. @@ -77,7 +84,7 @@ Whereas `.js`, `.ts`, `.py`, `.R`, and `.sh` data loaders are run via interprete chmod +x docs/earthquakes.csv.exe ``` -While a `.exe` data loader may be any binary executable (_e.g.,_ compiled from C), it is often convenient to specify another interpreter using a [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)). For example, to write a data loader in Julia: +While a `.exe` data loader may be any binary executable (_e.g.,_ compiled from C), it is often convenient to specify another interpreter using a [shebang](). For example, to write a data loader in Julia: ```julia #!/usr/bin/env julia @@ -89,7 +96,7 @@ If multiple requests are made concurrently for the same data loader, the data lo ## Output -Data loaders must output to [stdout](https://en.wikipedia.org/wiki/Standard_streams#Standard_output_(stdout)). The first extension (such as `.csv`) is not considered by the CLI; the data loader is solely responsible for producing the expected output (such as CSV). If you wish to log additional information from within a data loader, be sure to log to stderr, say by using [`console.warn`](https://developer.mozilla.org/en-US/docs/Web/API/console/warn); otherwise the logs will be included in the output file and sent to the client. +Data loaders must output to [stdout](). The first extension (such as `.csv`) is not considered by the CLI; the data loader is solely responsible for producing the expected output (such as CSV). If you wish to log additional information from within a data loader, be sure to log to stderr, say by using [`console.warn`](https://developer.mozilla.org/en-US/docs/Web/API/console/warn); otherwise the logs will be included in the output file and sent to the client. ## Caching diff --git a/public/client.js b/public/client.js index f9b452f16..ab299a4a2 100644 --- a/public/client.js +++ b/public/client.js @@ -223,7 +223,7 @@ export function open({hash, eval: compile} = {}) { }); break; case "update": { - const root = document.querySelector("main"); + const root = document.querySelector("#observablehq-cells"); if (message.previousHash !== hash) { console.log("contents out of sync"); location.reload(); @@ -352,3 +352,144 @@ function enableCopyButtons() { async function copy({currentTarget}) { await navigator.clipboard.writeText(currentTarget.parentElement.textContent.trimEnd()); } + +class Node { + constructor(value) { + this.value = value; + this.previous = null; + this.next = null; + } +} + +class DoublyLinkedList { + constructor() { + this.first = null; + this.last = null; + this.size = 0; + } + + insert(value) { + this.size++; + let newNode = new Node(value); + if (this.last) { + this.last.next = newNode; + newNode.previous = this.last; + this.last = newNode; + return newNode; + } + this.first = this.last = newNode; + return newNode; + } + + find(hash) { + let node = this.first; + while (node) { + if (node.value.firstElementChild.getAttribute("href") === hash) { + return node; + } + node = node.next; + } + } +} + +const toc = document.querySelector("#observablehq-toc"); +const secondaryActiveLinkClass = "observablehq-secondary-link-active"; +let headingNodes = document.querySelectorAll("h2"); +let headings = new DoublyLinkedList(); +headingNodes.forEach((headingNode) => headings.insert(headingNode)); + +if (toc) { + highlightSection(); +} + +function highlightSection() { + function getHeaderPositionY(heading) { + return heading && heading.getBoundingClientRect().y + window.scrollY; + } + + function isTopOfPage() { + return window.scrollY === 0; + } + + function isBottomOfPage() { + return window.scrollY + window.innerHeight >= document.body.scrollHeight; + } + + function highlightSection(hash) { + return document.querySelector(`nav ol li a[href="${hash}"]`).parentElement.classList.add(secondaryActiveLinkClass); + } + + function unhighlightSection(hash) { + return document + .querySelector(`nav ol li a[href="${hash}"]`) + .parentElement.classList.remove(secondaryActiveLinkClass); + } + + function headingInView(heading) { + if (!heading) return false; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const bounding = heading.getBoundingClientRect(); + return viewportHeight - bounding.top > 0 && bounding.left > 0; + } + + let currHeading; + let debounce; + function handleScroll() { + if (debounce) return; + if (isTopOfPage()) { + // top heading in view + if (headingInView(headings.first.value)) { + if (currHeading) unhighlightSection(currHeading.value.firstElementChild.getAttribute("href")); + currHeading = headings.first; + highlightSection(currHeading.value.firstElementChild.getAttribute("href")); + } + } else if (isBottomOfPage()) { + // reached the bottom + if (currHeading) unhighlightSection(currHeading.value.firstElementChild.getAttribute("href")); + if (headings.last.value) { + currHeading = headings.last; + highlightSection(currHeading.value.firstElementChild.getAttribute("href")); + } + } else { + if (!currHeading) { + if (headingInView(headings.first.value)) { + // first section is not at the top of the page + // it hasn't been set yet, setting now + currHeading = headings.first; + highlightSection(currHeading.value.firstElementChild.getAttribute("href")); + } + } else { + if (currHeading.next && window.scrollY > getHeaderPositionY(currHeading.next.value)) { + // scrolling down + if (currHeading) unhighlightSection(currHeading.value.firstElementChild.getAttribute("href")); + highlightSection(currHeading.next.value.firstElementChild.getAttribute("href")); + currHeading = currHeading.next; + } else { + // scrolling up + if (currHeading !== headings.first && window.scrollY < getHeaderPositionY(currHeading.value)) { + if (currHeading) unhighlightSection(currHeading.value.firstElementChild.getAttribute("href")); + highlightSection(currHeading.previous.value.firstElementChild.getAttribute("href")); + currHeading = currHeading.previous; + } else if (!headingInView(currHeading.value)) { + unhighlightSection(currHeading.value.firstElementChild.getAttribute("href")); + } + } + } + } + } + document.addEventListener("scroll", handleScroll); + + function highlightHash() { + if (location.hash) { + debounce = true; + if (currHeading) unhighlightSection(currHeading.value.firstElementChild.getAttribute("href")); + currHeading = headings.find(location.hash); + highlightSection(currHeading.value.firstElementChild.getAttribute("href")); + setTimeout(() => (debounce = false), 1000); + } + } + + document.addEventListener("DOMContentLoaded", highlightHash); + + window.addEventListener("hashchange", highlightHash); +} diff --git a/public/style.css b/public/style.css index 7cc6ec280..e2b4e7454 100644 --- a/public/style.css +++ b/public/style.css @@ -10,8 +10,10 @@ --theme-foreground-focus: #3182bd; --monospace: Menlo, Consolas, monospace; --monospace-font: 14px/1.5 var(--monospace); - --serif: "Source Serif Pro", "Iowan Old Style", "Apple Garamond", "Palatino Linotype", "Times New Roman", "Droid Serif", Times, serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - --sans-serif: -apple-system, BlinkMacSystemFont, "avenir next", avenir, helvetica, "helvetica neue", ubuntu, roboto, noto, "segoe ui", arial, sans-serif; + --serif: "Source Serif Pro", "Iowan Old Style", "Apple Garamond", "Palatino Linotype", "Times New Roman", + "Droid Serif", Times, serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + --sans-serif: -apple-system, BlinkMacSystemFont, "avenir next", avenir, helvetica, "helvetica neue", ubuntu, roboto, + noto, "segoe ui", arial, sans-serif; } @media (prefers-color-scheme: dark) { @@ -44,6 +46,58 @@ body { #observablehq-main { min-height: calc(100vh - 17rem); + display: flex; + flex-direction: row; + justify-content: space-between; +} + +#observablehq-cells { + flex-grow: 1; +} + +#observablehq-center { + margin: 1rem; +} + +#observablehq-toc { + position: sticky; + align-self: flex-start; + top: 1rem; + width: 100px; + margin: 0; + margin-left: 2rem; + padding-left: 12px; + padding-right: 12px; + border-left: solid 1px var(--theme-foreground-faintest); +} + +#menu-btn { + display: none; +} + +@media (max-width: calc(1152px - 100px)) { + #observablehq-toc { + display: none; + } +} + +#observablehq-toc nav ol { + margin: 0; + padding-inline-start: 5px; +} + +#observablehq-toc div { + font: 16px var(--sans-serif); + color: var(--theme-foreground-mute); + margin-bottom: 8px; +} + +#observablehq-toc nav ol li { + font: 14px var(--sans-serif); + font-weight: 350; + line-height: 1.5; + list-style-type: none; + color: var(--theme-foreground-faint); } #observablehq-footer { @@ -229,11 +283,13 @@ body { color: var(--theme-foreground-focus); } -.observablehq-link a[href] { +.observablehq-link a[href], +.observablehq-secondary-link a[href] { color: inherit; } -.observablehq-link-active { +.observablehq-link-active, +.observablehq-secondary-link { position: relative; } @@ -247,6 +303,27 @@ body { background: var(--theme-foreground-focus); } +.observablehq-secondary-link-active::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: -1rem; + width: 2px; + background: var(--theme-foreground-focus); + opacity: 1; + animation: fadein 0.25s ease-in-out; +} + +@keyframes fadein { + from { + opacity: 0.5; + } + to { + opacity: 1; + } +} + a[href], .observablehq-link-active a[href] { color: var(--theme-foreground-focus); @@ -424,7 +501,8 @@ hr { margin: 1rem 0; padding: 1rem 0; border: none; - background: no-repeat center/100% 1px linear-gradient(to right, var(--theme-foreground-faintest), var(--theme-foreground-faintest)); + background: no-repeat center/100% 1px + linear-gradient(to right, var(--theme-foreground-faintest), var(--theme-foreground-faintest)); } pre { @@ -456,6 +534,40 @@ pre[data-copy]::after { content: attr(data-copy); } +pre code.language-md::after { + content: "md"; +} +pre code.language-js::after { + content: "js"; +} +pre code.language-sh::after { + content: "sh"; +} +pre code.language-sql::after { + content: "sql"; +} +pre code.language-tex::after { + content: "tex"; +} +pre code.language-dot::after { + content: "dot"; +} +pre code.language-mermaid::after { + content: "mermaid"; +} +pre[data-copy="copy"] code::after { + content: "copy"; +} +pre[data-copy="copied"] code::after { + content: "copied"; +} +pre[data-copy="error"] code::after { + content: "error"; +} +pre code::after { + pointer-events: none; +} + input:not([type]), input[type="email"], input[type="number"], @@ -762,7 +874,11 @@ a[href].observablehq-header-anchor { color: var(--syntax-keyword); } -.__ns__ button, .__ns__ input, .__ns__ select, .__ns__ table, .__ns__ textarea { +.__ns__ button, +.__ns__ input, +.__ns__ select, +.__ns__ table, +.__ns__ textarea { color: initial !important; } diff --git a/src/build.ts b/src/build.ts index e33fce372..5beb66499 100644 --- a/src/build.ts +++ b/src/build.ts @@ -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(); @@ -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); diff --git a/src/config.ts b/src/config.ts index 2821afb39..933cc6de0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { diff --git a/src/preview.ts b/src/preview.ts index 1b5e2c30b..9f54d40e2 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -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"); diff --git a/src/render.ts b/src/render.ts index ad09fa625..89c05135e 100644 --- a/src/render.ts +++ b/src/render.ts @@ -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"; @@ -52,7 +53,7 @@ type RenderInternalOptions = function render( parseResult: ParseResult, - {path, pages, title, preview, hash, resolver}: RenderOptions & RenderInternalOptions + {path, pages, title, toc: tocConfig, preview, hash, resolver}: RenderOptions & RenderInternalOptions ): string { return ` ${path === "/404" ? `\n` : ""} @@ -89,7 +90,9 @@ ${JSON.stringify(parseResult.data)} ${pages.length > 0 ? sidebar(title, pages, path) : ""}
-${parseResult.html}
+
${parseResult.html}
+${tableOfContents(parseResult, tocConfig)} + ${footer(path, {pages, title})}
`; @@ -144,6 +147,50 @@ function sidebar(title: string | undefined, pages: (Page | Section)[], path: str }`; } +function tableOfContentsSections( + parseResult: ParseResult, + globalConfig?: Config["toc"] +): {label: string; headers: string[]} { + const pageConfig = parseResult.data?.toc; + const pageShow = pageConfig?.show; + const globalShow = globalConfig?.show; + const headers: string[] = []; + if (pageShow || globalShow) { + headers.push("h2"); + } + return {label: pageConfig?.label ?? globalConfig?.label ?? "Sections", headers}; +} + +function tableOfContents(parseResult: ParseResult, tocConfig: RenderOptions["toc"]) { + const toc = tableOfContentsSections(parseResult, tocConfig); + let showToc = toc.headers.length > 0; + let headers; + if (showToc) { + headers = Array.from(parseHTML(parseResult.html).document.querySelectorAll(toc.headers.join(", "))).map((node) => ({ + label: node.firstElementChild?.textContent, + href: node.firstElementChild?.getAttribute("href") + })); + } + showToc = showToc && headers?.length > 0; + return showToc + ? ` + +
+
${toc.label}
+
` + : ""; +} + function renderListItem(p: Page, path: string): string { return `
  • -

    Index

    +