Skip to content

Commit 3cb2e17

Browse files
committed
page navigation
1 parent 8d6643c commit 3cb2e17

18 files changed

+188
-27
lines changed

public/style.css

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
--theme-background-color: rgb(var(--theme-background-rgb));
88
--theme-background-color-alt: rgba(var(--theme-foreground-rgb), 0.05);
99
--theme-foreground-color: rgb(var(--theme-foreground-rgb));
10+
--theme-foreground-focus: #20bbfc;
1011
--syntax-comment: #828282;
1112
--syntax-diff: #24292e;
1213
--syntax-diff-bg: #ffffff;
@@ -32,14 +33,81 @@ html {
3233
-moz-osx-font-smoothing: grayscale;
3334
background: var(--theme-background-color);
3435
color: var(--theme-foreground-color);
35-
margin: 2rem 1rem;
3636
}
3737

3838
body {
39-
margin: auto;
39+
margin: 0;
40+
}
41+
42+
main {
43+
margin: 2rem auto;
4044
max-width: 1152px;
4145
}
4246

47+
#observablehq-center {
48+
margin: 1rem;
49+
}
50+
51+
#observablehq-sidebar {
52+
display: none;
53+
position: fixed;
54+
background: var(--theme-background-color-alt);
55+
color: var(--theme-foreground-color);
56+
font: 14px var(--sans-serif);
57+
font-weight: 600;
58+
width: 240px;
59+
z-index: 1;
60+
top: 0;
61+
bottom: 0;
62+
left: 0;
63+
padding: 1rem;
64+
}
65+
66+
#observablehq-sidebar ol {
67+
margin: 0;
68+
padding: 0;
69+
}
70+
71+
#observablehq-sidebar ol li {
72+
display: block;
73+
margin: 0;
74+
}
75+
76+
.observablehq-link a {
77+
display: block;
78+
padding: 0.5rem 1rem;
79+
}
80+
81+
.observablehq-link a:hover {
82+
background: var(--theme-background-color);
83+
}
84+
85+
.observablehq-link a[href] {
86+
color: inherit;
87+
}
88+
89+
.observablehq-link-active::before {
90+
position: absolute;
91+
content: "";
92+
height: 2rem;
93+
left: 0;
94+
width: 3px;
95+
background: var(--theme-foreground-focus);
96+
}
97+
98+
.observablehq-link-active a[href] {
99+
color: var(--theme-foreground-focus);
100+
}
101+
102+
@media (min-width: 1200px) {
103+
#observablehq-sidebar {
104+
display: initial;
105+
}
106+
#observablehq-center {
107+
padding-left: calc(240px + 2rem);
108+
}
109+
}
110+
43111
h1,
44112
h2,
45113
h3,
@@ -87,7 +155,9 @@ h6 code {
87155
font-size: 90%;
88156
}
89157

90-
pre, code, tt {
158+
pre,
159+
code,
160+
tt {
91161
font-family: var(--monospace);
92162
font-size: 14px;
93163
line-height: 1.5;
@@ -121,8 +191,7 @@ blockquote {
121191
margin: 1rem 1.5rem;
122192
}
123193

124-
ul
125-
ol {
194+
ul ol {
126195
padding-left: 28px;
127196
}
128197

@@ -201,7 +270,8 @@ td {
201270
padding: 3px 6.5px 3px 0;
202271
}
203272

204-
th:last-child, td:last-child {
273+
th:last-child,
274+
td:last-child {
205275
padding-right: 0;
206276
}
207277

src/markdown.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface ParseContext {
1313
}
1414

1515
export interface ParseResult {
16+
title: string | null;
1617
html: string;
1718
js: string;
1819
data: {[key: string]: any} | null;
@@ -198,11 +199,29 @@ export function parseMarkdown(source: string): ParseResult {
198199
const context: ParseContext = {id: 0, js: "", files: []};
199200
const tokens = md.parse(parts.content, context);
200201
const html = md.renderer.render(tokens, md.options, context);
201-
return {html, js: context.js, data: isEmpty(parts.data) ? null : parts.data, files: context.files};
202+
return {
203+
html,
204+
js: context.js,
205+
data: isEmpty(parts.data) ? null : parts.data,
206+
title: parts.data?.title ?? findTitle(tokens) ?? null,
207+
files: context.files
208+
};
202209
}
203210

204211
// TODO Use gray-matter’s parts.isEmpty, but only when it’s accurate.
205212
function isEmpty(object) {
206213
for (const key in object) return false;
207214
return true;
208215
}
216+
217+
// TODO Make this smarter.
218+
function findTitle(tokens: ReturnType<MarkdownIt["parse"]>): string | undefined {
219+
for (const [i, token] of tokens.entries()) {
220+
if (token.type === "heading_open" && token.tag === "h1") {
221+
const next = tokens[i + 1];
222+
if (next?.type === "inline" && next.children?.[0]?.type === "text") {
223+
return next.content;
224+
}
225+
}
226+
}
227+
}

src/preview.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {watch, type FSWatcher} from "node:fs";
2-
import {readFile, stat} from "node:fs/promises";
2+
import {readFile, readdir, stat} from "node:fs/promises";
33
import type {IncomingMessage, RequestListener} from "node:http";
44
import {createServer} from "node:http";
55
import {basename, dirname, extname, join, normalize} from "node:path";
@@ -9,7 +9,8 @@ import send from "send";
99
import {WebSocketServer, type WebSocket} from "ws";
1010
import {HttpError, isHttpError, isNodeError} from "./error.js";
1111
import {computeHash} from "./hash.js";
12-
import {renderPreview} from "./render.js";
12+
import {type ParseResult, parseMarkdown} from "./markdown.js";
13+
import {type RenderOptions, renderPreview} from "./render.js";
1314

1415
const DEFAULT_ROOT = "docs";
1516

@@ -18,9 +19,9 @@ const publicRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "public")
1819
class Server {
1920
private _server: ReturnType<typeof createServer>;
2021
private _socketServer: WebSocketServer;
21-
private readonly port: number;
22-
private readonly hostname: string;
23-
private readonly root: string;
22+
readonly port: number;
23+
readonly hostname: string;
24+
readonly root: string;
2425

2526
constructor({port, hostname, root}: CommandContext) {
2627
this.port = port;
@@ -33,9 +34,9 @@ class Server {
3334
this._socketServer.on("connection", this._handleConnection);
3435
}
3536

36-
start() {
37-
this._server.listen(this.port, this.hostname, () => {
38-
console.log(`Server running at http://${this.hostname}:${this.port}/`);
37+
async start() {
38+
return new Promise<void>((resolve) => {
39+
this._server.listen(this.port, this.hostname, resolve);
3940
});
4041
}
4142

@@ -75,7 +76,8 @@ class Server {
7576
// Otherwise, serve the corresponding Markdown file, if it exists.
7677
// Anything else should 404; static files should be matched above.
7778
try {
78-
res.end(renderPreview(await readFile(path + ".md", "utf-8")).html);
79+
const pages = await this._readPages(); // TODO cache
80+
res.end(renderPreview(await readFile(path + ".md", "utf-8"), {path: pathname, pages}).html);
7981
} catch (error) {
8082
if (!isNodeError(error) || error.code !== "ENOENT") throw error; // internal error
8183
throw new HttpError("Not found", 404);
@@ -89,6 +91,24 @@ class Server {
8991
}
9092
};
9193

94+
async _readPages() {
95+
const pages: RenderOptions["pages"] = [];
96+
for (const file of await readdir(this.root)) {
97+
if (extname(file) !== ".md") continue;
98+
let parsed: ParseResult;
99+
try {
100+
parsed = parseMarkdown(await readFile(join(this.root, file), "utf-8"));
101+
} catch (error) {
102+
if (!isNodeError(error) || error.code !== "ENOENT") throw error; // internal error
103+
continue;
104+
}
105+
const page = {path: `/${basename(file, ".md")}`, name: parsed.title ?? "Untitled"};
106+
if (page.path === "/index") pages.unshift(page);
107+
else pages.push(page);
108+
}
109+
return pages;
110+
}
111+
92112
_handleConnection = (socket: WebSocket, req: IncomingMessage) => {
93113
if (req.url === "/_observablehq") {
94114
handleWatch(socket, this.root);
@@ -190,5 +210,7 @@ function makeCommandContext(): CommandContext {
190210

191211
await (async function () {
192212
const context = makeCommandContext();
193-
new Server(context).start();
213+
const server = new Server(context);
214+
await server.start();
215+
console.log(`Server running at http://${server.hostname}:${server.port}/`);
194216
})();

src/render.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,26 @@ export interface Render {
77
files: {name: string; mimeType: string}[];
88
}
99

10-
export function renderPreview(source: string): Render {
10+
export interface RenderOptions {
11+
path?: string;
12+
pages?: {path: string; name: string}[];
13+
}
14+
15+
export function renderPreview(source: string, options: RenderOptions = {}): Render {
1116
const parseResult = parseMarkdown(source);
12-
return {html: render(parseResult, {preview: true, hash: computeHash(source)}), files: parseResult.files};
17+
return {html: render(parseResult, {...options, preview: true, hash: computeHash(source)}), files: parseResult.files};
1318
}
1419

15-
export function renderServerless(source: string): Render {
20+
export function renderServerless(source: string, options: RenderOptions = {}): Render {
1621
const parseResult = parseMarkdown(source);
17-
return {html: render(parseResult), files: parseResult.files};
22+
return {html: render(parseResult, options), files: parseResult.files};
1823
}
1924

20-
type RenderOptions =
21-
| {preview?: false; hash?: never} // serverless mode
22-
| {preview: true; hash: string}; // preview mode
25+
type RenderInternalOptions =
26+
| {preview?: false; hash?: never} // serverless
27+
| {preview: true; hash: string}; // preview
2328

24-
function render(parseResult: ParseResult, {preview, hash}: RenderOptions = {}): string {
29+
function render(parseResult: ParseResult, {path, pages, preview, hash}: RenderOptions & RenderInternalOptions): string {
2530
return `<!DOCTYPE html>
2631
<meta charset="utf-8">
2732
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
@@ -40,7 +45,38 @@ ${JSON.stringify(parseResult.data)}
4045
</script>`
4146
: ""
4247
}
48+
${
49+
pages
50+
? `<nav id="observablehq-sidebar">
51+
<ol>${pages
52+
?.map(
53+
(p) => `
54+
<li class="observablehq-link${p.path === path ? " observablehq-link-active" : ""}"><a href="${escapeDoubleQuoted(
55+
p.path
56+
)}">${escapeData(p.name)}</a></li>`
57+
)
58+
.join("")}
59+
</ol>
60+
</nav>
61+
`
62+
: ""
63+
}<div id="observablehq-center">
4364
<main>
44-
${parseResult.html}
45-
</main>`;
65+
${parseResult.html}</main>
66+
</div>
67+
`;
68+
}
69+
70+
// TODO Adopt Hypertext Literal?
71+
function escapeDoubleQuoted(value): string {
72+
return `${value}`.replace(/["&]/g, entity);
73+
}
74+
75+
// TODO Adopt Hypertext Literal?
76+
function escapeData(value: string): string {
77+
return `${value}`.replace(/[<&]/g, entity);
78+
}
79+
80+
function entity(character) {
81+
return `&#${character.charCodeAt(0).toString()};`;
4682
}

test/output/block-expression.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
22
"data": null,
3+
"title": null,
34
"files": []
45
}

test/output/dollar-expression.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
22
"data": null,
3+
"title": null,
34
"files": []
45
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
22
"data": null,
3+
"title": null,
34
"files": []
45
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
22
"data": null,
3+
"title": "Embedded expression",
34
"files": []
45
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
22
"data": null,
3+
"title": null,
34
"files": []
45
}

test/output/fenced-code.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
22
"data": null,
3+
"title": "Fenced code",
34
"files": []
45
}

0 commit comments

Comments
 (0)