diff --git a/src/build.ts b/src/build.ts index a76d1b580..9ec019a1f 100644 --- a/src/build.ts +++ b/src/build.ts @@ -9,6 +9,7 @@ import {createImportResolver, rewriteModule} from "./javascript/imports.js"; import type {Logger, Writer} from "./logger.js"; import {renderServerless} from "./render.js"; import {bundleStyles, getClientPath, rollupClient} from "./rollup.js"; +import {styleHash} from "./theme.js"; import {faint} from "./tty.js"; import {resolvePath} from "./url.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 = new Set([null]); for await (const sourceFile of visitMarkdownFiles(root)) { const sourcePath = join(root, sourceFile); const outputPath = join(dirname(sourceFile), basename(sourceFile, ".md") + ".html"); @@ -80,6 +82,7 @@ export async function build( const resolveFile = ({name}) => resolvePath(sourceFile, name); files.push(...render.files.map(resolveFile)); imports.push(...render.imports.filter((i) => i.type === "local").map(resolveFile)); + if (render.theme) styles.add(render.theme); await effects.writeFile(outputPath, render.html); } @@ -92,11 +95,13 @@ export async function build( const code = await rollupClient(clientPath, {minify: true}); await effects.writeFile(outputPath, code); } - // Generate the style bundle. - const outputPath = join("_observablehq", "style.css"); - effects.output.write(`${faint("bundle")} config.style ${faint("→")} `); - const code = await bundleStyles(config); - await effects.writeFile(outputPath, code); + // Generate the page style bundles. + for (const theme of styles) { + const outputPath = join("_observablehq", `${theme === null ? "style" : `style-${styleHash(theme)}`}.css`); + effects.output.write(`${faint("bundle")} config.style ${faint("→")} `); + const code = await bundleStyles(theme === null ? config : {...config, theme: theme.split(",")}); + await effects.writeFile(outputPath, code); + } } // Copy over the referenced files. diff --git a/src/markdown.ts b/src/markdown.ts index 2c60c7850..667045b36 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -18,6 +18,7 @@ import {parseInfo} from "./info.js"; import type {FileReference, ImportReference, PendingTranspile, Transpile} from "./javascript.js"; import {transpileJavaScript} from "./javascript.js"; import {transpileTag} from "./tag.js"; +import {normalizeTheme} from "./theme.js"; import {resolvePath} from "./url.js"; export interface ReadMarkdownResult { @@ -47,6 +48,7 @@ export interface ParseResult { pieces: HtmlPiece[]; cells: CellPiece[]; hash: string; + theme?: string; } interface RenderPiece { @@ -439,7 +441,8 @@ export async function parseMarkdown(source: string, root: string, sourcePath: st imports: context.imports, pieces: toParsePieces(context.pieces), cells: await toParseCells(context.pieces), - hash: await computeMarkdownHash(source, root, sourcePath, context.imports) + hash: await computeMarkdownHash(source, root, sourcePath, context.imports), + theme: normalizeTheme(parts.data.theme) }; } diff --git a/src/preview.ts b/src/preview.ts index a4045f4fd..17a854ba0 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -20,6 +20,7 @@ import {diffMarkdown, readMarkdown} from "./markdown.js"; import type {ParseResult, ReadMarkdownResult} from "./markdown.js"; import {renderPreview} from "./render.js"; import {bundleStyles, getClientPath, rollupClient} from "./rollup.js"; +import {styleHash} from "./theme.js"; import {bold, faint, green, underline} from "./tty.js"; const publicRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "public"); @@ -93,7 +94,12 @@ export class PreviewServer { } else if (pathname === "/_observablehq/style.css") { end(req, res, await bundleStyles(config), "text/css"); } else if (pathname.startsWith("/_observablehq/")) { - send(req, pathname.slice("/_observablehq".length), {root: publicRoot}).pipe(res); + const style = pathname.slice("/_observablehq/".length).match(/^style-([0-9a-f]+)\.css$/); + if (style) { + const theme = decodeURIComponent(url.search.slice(1)); + if (styleHash(theme) !== style[1]) throw new Error(`unexpected style signature ${style[1]}`); + end(req, res, await bundleStyles({...config, theme: theme.split(",")}), "text/css"); + } else send(req, pathname.slice("/_observablehq".length), {root: publicRoot}).pipe(res); } else if (pathname.startsWith("/_import/")) { const file = pathname.slice("/_import".length); let js: string; diff --git a/src/render.ts b/src/render.ts index 14d1d86dd..32592414d 100644 --- a/src/render.ts +++ b/src/render.ts @@ -8,12 +8,14 @@ import {addImplicitSpecifiers, addImplicitStylesheets} from "./libraries.js"; import {type ParseResult, parseMarkdown} from "./markdown.js"; import {type PageLink, findLink, normalizePath} from "./pager.js"; import {getClientPath, rollupClient} from "./rollup.js"; +import {styleUrl} from "./theme.js"; import {relativeUrl} from "./url.js"; export interface Render { html: string; files: FileReference[]; imports: ImportReference[]; + theme?: string; } export interface RenderOptions extends Config { @@ -23,20 +25,16 @@ export interface RenderOptions extends Config { export async function renderPreview(source: string, options: RenderOptions): Promise { const parseResult = await parseMarkdown(source, options.root, options.path); - return { - html: await render(parseResult, {...options, preview: true}), - files: parseResult.files, - imports: parseResult.imports - }; + const {files, imports} = parseResult; + const html = await render(parseResult, {...options, preview: true}); + return {html, files, imports}; } export async function renderServerless(source: string, options: RenderOptions): Promise { const parseResult = await parseMarkdown(source, options.root, options.path); - return { - html: await render(parseResult, options), - files: parseResult.files, - imports: parseResult.imports - }; + const {files, imports, theme} = parseResult; + const html = await render(parseResult, options); + return {html, files, imports, theme}; } export function renderDefineCell(cell: Transpile): string { @@ -63,7 +61,7 @@ ${ .filter((title): title is string => !!title) .join(" | ")}\n` : "" -}${await renderLinks(parseResult, path, createImportResolver(root, "_import"))}${ +}${await renderLinks(parseResult, path, createImportResolver(root, "_import"), options.preview)}${ path === "/404" ? html.unsafe(`\n