diff --git a/bin/observable.ts b/bin/observable.ts index f39a7757a..8a37a177b 100755 --- a/bin/observable.ts +++ b/bin/observable.ts @@ -38,13 +38,13 @@ 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 into --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]; } } @@ -52,7 +52,7 @@ if (positionals.length > 0) { // 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"; } diff --git a/docs/config.md b/docs/config.md index 403683c44..4d100088b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 @@ -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. diff --git a/docs/style.css b/docs/style.css deleted file mode 100644 index b9ddb5624..000000000 --- a/docs/style.css +++ /dev/null @@ -1,2 +0,0 @@ -@import url("observablehq:theme-auto.css"); -@import url("observablehq:default.css"); diff --git a/observablehq.config.ts b/observablehq.config.ts index ad9db843a..b4715a942 100644 --- a/observablehq.config.ts +++ b/observablehq.config.ts @@ -1,6 +1,5 @@ export default { title: "Observable CLI", - style: "docs/style.css", pages: [ {name: "Getting started", path: "/getting-started"}, {name: "Routing", path: "/routing"}, diff --git a/src/build.ts b/src/build.ts index 055a1348a..6a8b28470 100644 --- a/src/build.ts +++ b/src/build.ts @@ -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"; @@ -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"); @@ -81,10 +83,15 @@ 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); @@ -92,12 +99,19 @@ export async function build( 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); + } } } @@ -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; +} diff --git a/src/config.ts b/src/config.ts index 2c49adb0a..2c5171ec3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; @@ -20,6 +20,10 @@ 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 @@ -27,7 +31,7 @@ export interface Config { 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}; } @@ -62,10 +66,12 @@ async function readPages(root: string): Promise { } export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Promise { - 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); @@ -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); } @@ -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)}; +} diff --git a/src/preview.ts b/src/preview.ts index 1a022b056..6f864a457 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -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"; @@ -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"); @@ -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")) { @@ -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-(?[\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); @@ -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(); } @@ -245,14 +256,16 @@ function getWatchPaths(parseResult: ParseResult): string[] { return paths; } -async function getStylesheets({cells}: ParseResult): Promise> { - const inputs = new Set(); - 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 | null = null; @@ -260,6 +273,15 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, options: {root: st let attachmentWatcher: FileWatchers | null = null; console.log(faint("socket open"), req.url); + async function getStylesheets({cells, data}: ParseResult): Promise> { + const inputs = new Set(); + 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))) { diff --git a/src/render.ts b/src/render.ts index 14d1d86dd..8e127f428 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,5 +1,6 @@ import {parseHTML} from "linkedom"; -import {type Config, type Page, type Section, mergeToc} from "./config.js"; +import type {Config, Page, Section} from "./config.js"; +import {mergeToc} from "./config.js"; import {type Html, html} from "./html.js"; import type {ImportResolver} from "./javascript/imports.js"; import {createImportResolver, resolveModuleIntegrity, resolveModulePreloads} from "./javascript/imports.js"; @@ -7,6 +8,7 @@ import type {FileReference, ImportReference, Transpile} from "./javascript.js"; import {addImplicitSpecifiers, addImplicitStylesheets} from "./libraries.js"; import {type ParseResult, parseMarkdown} from "./markdown.js"; import {type PageLink, findLink, normalizePath} from "./pager.js"; +import {getPreviewStylesheet} from "./preview.js"; import {getClientPath, rollupClient} from "./rollup.js"; import {relativeUrl} from "./url.js"; @@ -14,6 +16,7 @@ export interface Render { html: string; files: FileReference[]; imports: ImportReference[]; + data: ParseResult["data"]; } export interface RenderOptions extends Config { @@ -26,7 +29,8 @@ export async function renderPreview(source: string, options: RenderOptions): Pro return { html: await render(parseResult, {...options, preview: true}), files: parseResult.files, - imports: parseResult.imports + imports: parseResult.imports, + data: parseResult.data }; } @@ -35,7 +39,8 @@ export async function renderServerless(source: string, options: RenderOptions): return { html: await render(parseResult, options), files: parseResult.files, - imports: parseResult.imports + imports: parseResult.imports, + data: parseResult.data }; } @@ -63,7 +68,7 @@ ${ .filter((title): title is string => !!title) .join(" | ")}\n` : "" -}${await renderLinks(parseResult, path, createImportResolver(root, "_import"))}${ +}${await renderLinks(parseResult, options, path, createImportResolver(root, "_import"))}${ path === "/404" ? html.unsafe(`\n