Skip to content

theme config #442

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

Merged
merged 16 commits into from
Jan 8, 2024
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(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;
}
27 changes: 23 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ 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";

export interface Page {
name: string;
Expand All @@ -20,14 +19,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 +65,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 +80,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 +117,13 @@ export function mergeToc(spec: any, toc: TableOfContents): TableOfContents {
show = Boolean(show);
return {label, show};
}

export function mergeStyle(style: any, theme: any, defaultStyle: null | Style): null | Style {
return style === undefined && theme === undefined
? defaultStyle
: style === null
? null // disable
: style !== undefined
? {path: String(style)} // TODO resolve path?
: {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, resolvePath} 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(data?.style, data?.theme, style);
return !style
? null
: "path" in style
? relativeUrl(path, `/_import/${resolvePath(path, 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
24 changes: 18 additions & 6 deletions src/render.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
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";
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";

export interface Render {
html: string;
files: FileReference[];
imports: ImportReference[];
data: ParseResult["data"];
}

export interface RenderOptions extends Config {
Expand All @@ -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
};
}

Expand All @@ -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
};
}

Expand Down Expand Up @@ -63,7 +68,7 @@ ${
.filter((title): title is string => !!title)
.join(" | ")}</title>\n`
: ""
}${await renderLinks(parseResult, path, createImportResolver(root, "_import"))}${
}${await renderLinks(parseResult, options, path, createImportResolver(root, "_import"))}${
path === "/404"
? html.unsafe(`\n<script type="module">

Expand Down Expand Up @@ -169,8 +174,15 @@ function prettyPath(path: string): string {
return path.replace(/\/index$/, "/") || "/";
}

async function renderLinks(parseResult: ParseResult, path: string, resolver: ImportResolver): Promise<Html> {
const stylesheets = new Set<string>([relativeUrl(path, "/_observablehq/style.css"), "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"]); // prettier-ignore
async function renderLinks(
parseResult: ParseResult,
options: Pick<Config, "style">,
path: string,
resolver: ImportResolver
): Promise<Html> {
const stylesheets = new Set<string>(["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"]); // prettier-ignore
Copy link
Member Author

@mbostock mbostock Jan 6, 2024

Choose a reason for hiding this comment

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

If we generated the style bundle here, we could scan it for external imports and add preloads here to fix #423.

const style = getPreviewStylesheet(path, parseResult.data, options.style);
if (style) stylesheets.add(style);
const specifiers = new Set<string>(["npm:@observablehq/runtime", "npm:@observablehq/stdlib"]);
for (const {name} of parseResult.imports) specifiers.add(name);
const inputs = new Set(parseResult.cells.flatMap((cell) => cell.inputs ?? []));
Expand Down
13 changes: 11 additions & 2 deletions src/rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,19 @@ const STYLE_MODULES = {
"observablehq:theme-light.css": getClientPath("./src/style/theme-light.css")
};

export async function bundleStyles(clientPath: string): Promise<string> {
export async function bundleStyles({path, theme}: {path?: string; theme?: string[]}): Promise<string> {
const result = await build({
bundle: true,
entryPoints: [clientPath],
...(path
? {entryPoints: [path]}
: {
stdin: {
contents: `${theme!
.map((t) => `@import url(${JSON.stringify(`observablehq:theme-${t}.css`)});\n`)
.join("")}@import url("observablehq:default.css");\n`,
loader: "css"
}
}),
write: false,
alias: STYLE_MODULES
});
Expand Down
2 changes: 0 additions & 2 deletions src/style/index.css

This file was deleted.

4 changes: 2 additions & 2 deletions test/config-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe("readConfig(undefined, root)", () => {
assert.deepStrictEqual(await readConfig(undefined, "test/input/build/config"), {
root: "test/input/build/config",
output: "dist",
style: "src/style/index.css",
style: {theme: ["auto"]},
pages: [
{path: "/index", name: "Index"},
{path: "/one", name: "One<Two"},
Expand All @@ -28,7 +28,7 @@ describe("readConfig(undefined, root)", () => {
assert.deepStrictEqual(await readConfig(undefined, "test/input/build/simple"), {
root: "test/input/build/simple",
output: "dist",
style: "src/style/index.css",
style: {theme: ["auto"]},
pages: [{name: "Build test case", path: "/simple"}],
title: undefined,
toc: {label: "Contents", show: true},
Expand Down
4 changes: 2 additions & 2 deletions test/output/build/404/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Page not found</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="style" href="./_observablehq/style.css">
<link rel="preload" as="style" href="./_observablehq/theme-auto.css">
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&amp;display=swap" crossorigin>
<link rel="stylesheet" type="text/css" href="./_observablehq/style.css">
<link rel="stylesheet" type="text/css" href="./_observablehq/theme-auto.css">
<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&amp;display=swap" crossorigin>
<link rel="modulepreload" href="./_observablehq/client.js">
<link rel="modulepreload" href="./_observablehq/runtime.js">
Expand Down
4 changes: 2 additions & 2 deletions test/output/build/archives/tar.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Tar</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preload" as="style" href="./_observablehq/style.css">
<link rel="preload" as="style" href="./_observablehq/theme-auto.css">
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&amp;display=swap" crossorigin>
<link rel="stylesheet" type="text/css" href="./_observablehq/style.css">
<link rel="stylesheet" type="text/css" href="./_observablehq/theme-auto.css">
<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&amp;display=swap" crossorigin>
<link rel="modulepreload" href="./_observablehq/client.js">
<link rel="modulepreload" href="./_observablehq/runtime.js">
Expand Down
Loading