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

theme config #442

Merged
merged 16 commits into from
Jan 8, 2024
6 changes: 3 additions & 3 deletions bin/observable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,21 @@ let command: string | undefined;
// Extract the command.
if (positionals.length > 0) {
const t = tokens.find((t) => t.kind === "positional")!;
args.splice(t.index - 2, 1);
args.splice(t.index, 1);
command = positionals[0];

// Convert help <command> into <command> --help.
if (command === "help" && positionals.length > 1) {
const p = tokens.find((p) => p.kind === "positional" && p !== t)!;
args.splice(p.index - 2, 1, "--help");
args.splice(p.index, 1, "--help");
command = positionals[1];
}
}

// Convert --help or -h (with no command) into help.
else if (values.help) {
const t = tokens.find((t) => t.kind === "option" && t.name === "help")!;
args.splice(t.index - 2, 1);
args.splice(t.index, 1);
command = "help";
}

Expand Down
61 changes: 57 additions & 4 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,70 @@ The path to the source root; defaults to `docs`.

The path to the output root; defaults to `dist`.

## theme

The theme names, if any; defaults to `auto`. Themes affect the visual appearance of pages by specifying colors and fonts, and possibly by augmenting default styles. The theme option is a convenient shorthand alternative to specifying a [custom stylesheet](#style).

The current built-in themes are:

- *auto* (default) - *light* or *dark* depending on the user’s preferred color scheme
- *auto-alt* - *light-alt* or *dark* depending on the user’s preferred color scheme
- *light* - light mode
- *dark* - dark mode
- *wide* - allows the main column to go full width; to be used with one of the above

You can combine themes like so:

```js
theme: ["auto-alt", "wide"]
```

A theme can be configured for individual pages via the [front matter](./markdown.md#front-matter):

```yaml
---
theme: [auto-alt, wide]
---
```

## style

The path to the project’s stylesheet. This is typically set to `docs/style.css` to override or augment the default stylesheet, or to apply a theme. For example, to use the *light* theme:
The path to a custom stylesheet, relative to the source root. This option takes precedence over [themes](#theme) (if any), providing more control by allowing you to remove or alter the default stylesheet and define a custom theme.

The custom stylesheet should typically import the `"observablehq:default.css"` to build on the default styles. You can also import any of the built-in themes. For example, to create a stylesheet that builds up on the *light* theme, create a `custom-style.css` file in the `docs` folder, then set the **style** option to `"custom-style.css"`:

```css
@import url("observablehq:theme-light.css");
@import url("observablehq:default.css");
@import url("observablehq:theme-light.css");

:root {
--theme-foreground-focus: green;
}
```

If you build on the *auto* or *auto-alt* themes, make sure that colors are chosen according to the user’s [preferred color scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme).

The default styles are implemented using CSS custom properties. These properties are designed to be defined by themes or custom stylesheets. The following custom properties are supported:

- `--theme-foreground` - foreground color, _e.g._ black
- `--theme-background` - background color, _e.g._ white
- `--theme-background-alt` - alternative background color, _e.g._ light gray
- `--theme-foreground-alt` - alternative foreground color, used for titles and section titles, _e.g._ brown
- `--theme-foreground-muted` - muted foreground color, _e.g._ dark gray
- `--theme-foreground-faint` - faint foreground color, _e.g._ middle gray
- `--theme-foreground-fainter` - fainter foreground color, _e.g._ light gray
- `--theme-foreground-faintest` - fainter foreground color, _e.g._ almost white
- `--theme-foreground-focus` - focus color, _e.g._ blue

A custom stylesheet can be configured for individual pages via the [front matter](./markdown.md#front-matter):

```yaml
---
style: custom-style.css
---
```

The current built-in themes are: *auto* (default), *light*, and *dark*.
In this case, the path to the stylesheet is resolved relative to the page’s Markdown file rather than the source root.

## title

Expand All @@ -62,7 +116,6 @@ export interface Section {
}
```


If a section’s **open** option is not set, it defaults to true.

Projects can have “unlisted” pages that are not included in the pages list. These pages will still be accessible if linked from other pages or visited directly, but they won’t be listed in the sidebar or linked to via the previous & next footer.
Expand Down
2 changes: 0 additions & 2 deletions docs/style.css

This file was deleted.

1 change: 0 additions & 1 deletion observablehq.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export default {
title: "Observable CLI",
style: "docs/style.css",
pages: [
{name: "Getting started", path: "/getting-started"},
{name: "Routing", path: "/routing"},
Expand Down
38 changes: 30 additions & 8 deletions src/build.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {existsSync} from "node:fs";
import {access, constants, copyFile, readFile, writeFile} from "node:fs/promises";
import {basename, dirname, join} from "node:path";
import {type Config} from "./config.js";
import type {Config, Style} from "./config.js";
import {mergeStyle} from "./config.js";
import {Loader} from "./dataloader.js";
import {isEnoent} from "./error.js";
import {prepareOutput, visitMarkdownFiles} from "./files.js";
Expand Down Expand Up @@ -71,6 +72,7 @@ export async function build(
// Render .md files, building a list of file attachments as we go.
const files: string[] = [];
const imports: string[] = [];
const styles: Style[] = [];
for await (const sourceFile of visitMarkdownFiles(root)) {
const sourcePath = join(root, sourceFile);
const outputPath = join(dirname(sourceFile), basename(sourceFile, ".md") + ".html");
Expand All @@ -81,23 +83,35 @@ export async function build(
files.push(...render.files.map(resolveFile));
imports.push(...render.imports.filter((i) => i.type === "local").map(resolveFile));
await effects.writeFile(outputPath, render.html);
const style = mergeStyle(path, render.data?.style, render.data?.theme, config.style);
if (style) {
if ("path" in style) style.path = resolvePath(sourceFile, style.path);
if (!styles.some((s) => styleEquals(s, style))) styles.push(style);
}
}

// Generate the client bundles.
if (addPublic) {
// Generate the client bundles.
for (const [entry, name] of clientBundles(clientEntry)) {
const clientPath = getClientPath(entry);
const outputPath = join("_observablehq", name);
effects.output.write(`${faint("bundle")} ${clientPath} ${faint("→")} `);
const code = await rollupClient(clientPath, {minify: true});
await effects.writeFile(outputPath, code);
}
// Generate the style bundles.
for (const [clientPath, name] of [[config.style, "style.css"]]) {
const outputPath = join("_observablehq", name);
effects.output.write(`${faint("bundle")} ${clientPath} ${faint("→")} `);
const code = await bundleStyles(clientPath);
await effects.writeFile(outputPath, code);
for (const style of styles) {
if ("path" in style) {
const outputPath = join("_import", style.path);
const sourcePath = join(root, style.path);
effects.output.write(`${faint("bundle")} ${sourcePath} ${faint("→")} `);
const code = await bundleStyles({path: sourcePath});
await effects.writeFile(outputPath, code);
} else {
const outputPath = join("_observablehq", `theme-${style.theme}.css`);
effects.output.write(`${faint("bundle")} theme-${style.theme}.css ${faint("→")} `);
const code = await bundleStyles({theme: style.theme});
await effects.writeFile(outputPath, code);
}
}
}

Expand Down Expand Up @@ -172,3 +186,11 @@ export class FileBuildEffects implements BuildEffects {
await writeFile(destination, contents);
}
}

function styleEquals(a: Style, b: Style): boolean {
return "path" in a && "path" in b
? a.path === b.path
: "theme" in a && "theme" in b
? a.theme.join() === b.theme.join()
: false;
}
28 changes: 24 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {readFile} from "node:fs/promises";
import {basename, dirname, extname, join} from "node:path";
import {visitFiles} from "./files.js";
import {parseMarkdown} from "./markdown.js";
import {getClientPath} from "./rollup.js";
import {resolvePath} from "./url.js";

export interface Page {
name: string;
Expand All @@ -20,14 +20,18 @@ export interface TableOfContents {
show: boolean; // defaults to true
}

export type Style =
| {path: string} // custom stylesheet
| {theme: string[]}; // zero or more named theme

export interface Config {
root: string; // defaults to docs
output: string; // defaults to dist
title?: string;
pages: (Page | Section)[]; // TODO rename to sidebar?
pager: boolean; // defaults to true
toc: TableOfContents;
style: string; // defaults to default stylesheet
style: null | Style; // defaults to {theme: ["auto"]}
deploy: null | {workspace: string; project: string};
}

Expand Down Expand Up @@ -62,10 +66,12 @@ async function readPages(root: string): Promise<Page[]> {
}

export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Promise<Config> {
let {root = defaultRoot, output = "dist", style = getClientPath("./src/style/index.css"), deploy} = spec;
let {root = defaultRoot, output = "dist", style, theme = "auto", deploy} = spec;
root = String(root);
output = String(output);
style = String(style);
if (style === null) style = null;
else if (style !== undefined) style = {path: String(style)};
else style = {theme: (theme = normalizeTheme(theme))};
let {title, pages = await readPages(root), pager = true, toc = true} = spec;
if (title !== undefined) title = String(title);
pages = Array.from(pages, normalizePageOrSection);
Expand All @@ -75,6 +81,10 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro
return {root, output, title, pages, pager, toc, style, deploy};
}

function normalizeTheme(spec: any): string[] {
return typeof spec === "string" ? [spec] : spec === null ? [] : Array.from(spec, String);
}

function normalizePageOrSection(spec: any): Page | Section {
return ("pages" in spec ? normalizeSection : normalizePage)(spec);
}
Expand Down Expand Up @@ -108,3 +118,13 @@ export function mergeToc(spec: any, toc: TableOfContents): TableOfContents {
show = Boolean(show);
return {label, show};
}

export function mergeStyle(path: string, style: any, theme: any, defaultStyle: null | Style): null | Style {
return style === undefined && theme === undefined
? defaultStyle
: style === null
? null // disable
: style !== undefined
? {path: resolvePath(path, style)}
: {theme: normalizeTheme(theme)};
}
52 changes: 37 additions & 15 deletions src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {difference} from "d3-array";
import send from "send";
import {type WebSocket, WebSocketServer} from "ws";
import {version} from "../package.json";
import {type Config} from "./config.js";
import type {Config} from "./config.js";
import {mergeStyle} from "./config.js";
import {Loader} from "./dataloader.js";
import {HttpError, isEnoent, isHttpError, isSystemError} from "./error.js";
import {FileWatchers} from "./fileWatchers.js";
Expand All @@ -21,6 +22,7 @@ import type {ParseResult, ReadMarkdownResult} from "./markdown.js";
import {renderPreview} from "./render.js";
import {bundleStyles, getClientPath, rollupClient} from "./rollup.js";
import {bold, faint, green, underline} from "./tty.js";
import {relativeUrl} from "./url.js";

const publicRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "public");

Expand Down Expand Up @@ -82,6 +84,7 @@ export class PreviewServer {
try {
const url = new URL(req.url!, "http://localhost");
let {pathname} = url;
let match: RegExpExecArray | null;
if (pathname === "/_observablehq/runtime.js") {
send(req, "/@observablehq/runtime/dist/runtime.js", {root: "./node_modules"}).pipe(res);
} else if (pathname.startsWith("/_observablehq/stdlib.js")) {
Expand All @@ -90,20 +93,28 @@ export class PreviewServer {
end(req, res, await rollupClient(getClientPath("./src/client/" + pathname.slice("/_observablehq/".length))), "text/javascript"); // prettier-ignore
} else if (pathname === "/_observablehq/client.js") {
end(req, res, await rollupClient(getClientPath("./src/client/preview.js")), "text/javascript");
} else if (pathname === "/_observablehq/style.css") {
end(req, res, await bundleStyles(config.style), "text/css");
} else if ((match = /^\/_observablehq\/theme-(?<theme>[\w-]+(,[\w-]+)*)?\.css$/.exec(pathname))) {
end(req, res, await bundleStyles({theme: match.groups!.theme?.split(",") ?? []}), "text/css");
} else if (pathname.startsWith("/_observablehq/")) {
send(req, pathname.slice("/_observablehq".length), {root: publicRoot}).pipe(res);
} else if (pathname.startsWith("/_import/")) {
const file = pathname.slice("/_import".length);
let js: string;
const path = pathname.slice("/_import".length);
const filepath = join(root, path);
try {
js = await readFile(join(root, file), "utf-8");
if (pathname.endsWith(".css")) {
await access(filepath, constants.R_OK);
end(req, res, await bundleStyles({path: filepath}), "text/css");
return;
} else if (pathname.endsWith(".js")) {
const input = await readFile(filepath, "utf-8");
const output = await rewriteModule(input, path, createImportResolver(root));
end(req, res, output, "text/javascript");
return;
}
} catch (error) {
if (!isEnoent(error)) throw error;
throw new HttpError(`Not found: ${pathname}`, 404);
}
end(req, res, await rewriteModule(js, file, createImportResolver(root)), "text/javascript");
throw new HttpError(`Not found: ${pathname}`, 404);
} else if (pathname.startsWith("/_file/")) {
const path = pathname.slice("/_file".length);
const filepath = join(root, path);
Expand Down Expand Up @@ -208,7 +219,7 @@ export class PreviewServer {

_handleConnection = async (socket: WebSocket, req: IncomingMessage) => {
if (req.url === "/_observablehq") {
handleWatch(socket, req, {root: this._config.root});
handleWatch(socket, req, this._config);
} else {
socket.close();
}
Expand Down Expand Up @@ -245,21 +256,32 @@ function getWatchPaths(parseResult: ParseResult): string[] {
return paths;
}

async function getStylesheets({cells}: ParseResult): Promise<Set<string>> {
const inputs = new Set<string>();
for (const cell of cells) cell.inputs?.forEach(inputs.add, inputs);
return getImplicitStylesheets(getImplicitSpecifiers(inputs));
export function getPreviewStylesheet(path: string, data: ParseResult["data"], style: Config["style"]): string | null {
style = mergeStyle(path, data?.style, data?.theme, style);
return !style
? null
: "path" in style
? relativeUrl(path, `/_import/${style.path}`)
: relativeUrl(path, `/_observablehq/theme-${style.theme.join(",")}.css`);
}

function handleWatch(socket: WebSocket, req: IncomingMessage, options: {root: string}) {
const {root} = options;
function handleWatch(socket: WebSocket, req: IncomingMessage, {root, style: defaultStyle}: Config) {
let path: string | null = null;
let current: ReadMarkdownResult | null = null;
let stylesheets: Set<string> | null = null;
let markdownWatcher: FSWatcher | null = null;
let attachmentWatcher: FileWatchers | null = null;
console.log(faint("socket open"), req.url);

async function getStylesheets({cells, data}: ParseResult): Promise<Set<string>> {
const inputs = new Set<string>();
for (const cell of cells) cell.inputs?.forEach(inputs.add, inputs);
const stylesheets = await getImplicitStylesheets(getImplicitSpecifiers(inputs));
const style = getPreviewStylesheet(path!, data, defaultStyle);
if (style) stylesheets.add(style);
return stylesheets;
}

function refreshAttachment(name: string) {
const {cells} = current!.parse;
if (cells.some((cell) => cell.imports?.some((i) => i.name === name))) {
Expand Down
Loading