Path: blob/main/src/command/render/pandoc-html.ts
3584 views
/*1* pandoc-html.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import { join } from "../../deno_ral/path.ts";7import { uniqBy } from "../../core/lodash.ts";89import {10Format,11FormatExtras,12kDependencies,13kQuartoCssVariables,14kTextHighlightingMode,15SassBundle,16SassBundleWithBrand,17SassLayer,18} from "../../config/types.ts";19import { ProjectContext } from "../../project/types.ts";2021import { cssImports, cssResources } from "../../core/css.ts";22import { cleanSourceMappingUrl, compileSass } from "../../core/sass.ts";2324import { kQuartoHtmlDependency } from "../../format/html/format-html-constants.ts";25import {26kAbbrevs,27readHighlightingTheme,28} from "../../quarto-core/text-highlighting.ts";2930import { isHtmlOutput } from "../../config/format.ts";31import {32cssHasDarkModeSentinel,33generateCssKeyValues,34} from "../../core/pandoc/css.ts";35import { kMinimal } from "../../format/html/format-html-shared.ts";36import { kSassBundles } from "../../config/types.ts";37import { md5HashBytes } from "../../core/hash.ts";38import { InternalError } from "../../core/lib/error.ts";39import { assert } from "testing/asserts";40import { safeModeFromFile } from "../../deno_ral/fs.ts";41import { safeCloneDeep } from "../../core/safe-clone-deep.ts";4243// The output target for a sass bundle44// (controls the overall style tag that is emitted)45interface SassTarget {46name: string;47bundles: SassBundle[];48attribs: Record<string, string>;49}5051export async function resolveSassBundles(52inputDir: string,53extras: FormatExtras,54format: Format,55project: ProjectContext,56) {57extras = safeCloneDeep(extras);5859const mergedBundles: Record<string, SassBundleWithBrand[]> = {};6061// groups the bundles by dependency name62const group = (63bundles: SassBundleWithBrand[],64groupedBundles: Record<string, SassBundleWithBrand[]>,65) => {66bundles.forEach((bundle) => {67if (!groupedBundles[bundle.dependency]) {68groupedBundles[bundle.dependency] = [];69}70groupedBundles[bundle.dependency].push(bundle);71});72};7374// group available sass bundles75if (extras?.["html"]?.[kSassBundles]) {76group(extras["html"][kSassBundles], mergedBundles);77}7879// Go through and compile the cssPath for each dependency80let hasDarkStyles = false;81let defaultStyle: "dark" | "light" | undefined = undefined;82for (const dependency of Object.keys(mergedBundles)) {83// compile the cssPath84const bundlesWithBrand = mergedBundles[dependency];85// first, pull out the brand-specific layers86//87// the brand bundle itself doesn't have any 'brand' entries;88// those are used to specify where the brand-specific layers should be inserted89// in the final bundle.90const maybeBrandBundle = bundlesWithBrand.find((bundle) =>91bundle.key === "brand"92);93assert(94!maybeBrandBundle ||95!maybeBrandBundle.user?.find((v) => v === "brand") &&96!maybeBrandBundle.dark?.user?.find((v) => v === "brand"),97);98const foundBrand = { light: false, dark: false };99const bundles: SassBundle[] = bundlesWithBrand.filter((bundle) =>100bundle.key !== "brand"101).map((bundle) => {102const userBrand = bundle.user?.findIndex((layer) => layer === "brand");103let cloned = false;104if (userBrand && userBrand !== -1) {105bundle = safeCloneDeep(bundle);106cloned = true;107bundle.user!.splice(userBrand, 1, ...(maybeBrandBundle?.user || []));108foundBrand.light = true;109}110const darkBrand = bundle.dark?.user?.findIndex((layer) =>111layer === "brand"112);113if (darkBrand && darkBrand !== -1) {114if (!cloned) {115bundle = safeCloneDeep(bundle);116}117bundle.dark!.user!.splice(118darkBrand,1191,120...(maybeBrandBundle?.dark?.user || []),121);122foundBrand.dark = true;123}124return bundle as SassBundle;125});126if (maybeBrandBundle && (!foundBrand.light || !foundBrand.dark)) {127bundles.unshift({128dependency,129key: "brand",130user: !foundBrand.light && maybeBrandBundle.user as SassLayer[] || [],131dark: !foundBrand.dark && maybeBrandBundle.dark?.user && {132user: maybeBrandBundle.dark.user as SassLayer[],133default: maybeBrandBundle.dark.default,134} || undefined,135});136}137138// See if any bundles are providing dark specific css139const hasDark = bundles.some((bundle) => bundle.dark !== undefined);140defaultStyle = bundles.some((bundle) =>141bundle.dark !== undefined && bundle.dark.default142)143? "dark"144: "light";145let targets: SassTarget[] = [{146name: `${dependency}.min.css`,147bundles: (bundles as any),148attribs: {149"append-hash": "true",150},151}];152if (hasDark) {153// Note that the other bundle provides light154targets[0].attribs = {155...targets[0].attribs,156...attribForThemeStyle("light"),157};158159// Provide a dark bundle for this160const darkBundles = bundles.map((bundle) => {161bundle = safeCloneDeep(bundle);162bundle.user = bundle.dark?.user || bundle.user;163bundle.quarto = bundle.dark?.quarto || bundle.quarto;164bundle.framework = bundle.dark?.framework || bundle.framework;165166// Mark this bundle with a dark key so it is differentiated from the light theme167bundle.key = bundle.key + "-dark";168return bundle;169});170const darkTarget = {171name: `${dependency}-dark.min.css`,172bundles: darkBundles as any,173attribs: {174"append-hash": "true",175...attribForThemeStyle("dark"),176},177};178if (defaultStyle === "dark") { // light, dark179targets.push(darkTarget);180} else { // light, dark, light181const lightTargetExtra = {182...targets[0],183attribs: {184...targets[0].attribs,185class: "quarto-color-scheme-extra",186},187};188189targets = [190targets[0],191darkTarget,192lightTargetExtra,193];194}195196hasDarkStyles = true;197}198199for (const target of targets) {200let cssPath: string | undefined;201cssPath = await compileSass(target.bundles, project);202// First, Clean CSS203cleanSourceMappingUrl(cssPath);204// look for a sentinel 'dark' value, extract variables205const cssResult = await processCssIntoExtras(cssPath, extras, project);206cssPath = cssResult.path;207208// it can happen that processing generate an empty css file (e.g quarto-html deps with Quarto CSS variables)209// in that case, no need to insert the cssPath in the dependency210if (!cssPath) continue;211if (Deno.readTextFileSync(cssPath).length === 0) {212continue;213}214215// Process attributes (forward on to the target)216for (const bundle of target.bundles) {217if (bundle.attribs) {218for (const key of Object.keys(bundle.attribs)) {219if (target.attribs[key] === undefined) {220target.attribs[key] = bundle.attribs[key];221}222}223}224}225target.attribs["data-mode"] = cssResult.dark ? "dark" : "light";226227// Find any imported stylesheets or url references228// (These could come from user scss that is merged into our theme, for example)229const css = Deno.readTextFileSync(cssPath);230const toDependencies = (paths: string[]) => {231return paths.map((path) => {232return {233name: path,234path: project ? join(project.dir, path) : path,235attribs: target.attribs,236};237});238};239const resources = toDependencies(cssResources(css));240const imports = toDependencies(cssImports(css));241242// Push the compiled Css onto the dependency243const extraDeps = extras.html?.[kDependencies];244245if (extraDeps) {246const existingDependency = extraDeps.find((extraDep) =>247extraDep.name === dependency248);249250let targetName = target.name;251if (target.attribs["append-hash"] === "true") {252const hashFragment = `-${await md5HashBytes(253Deno.readFileSync(cssPath),254)}`;255let extension = "";256if (target.name.endsWith(".min.css")) {257extension = ".min.css";258} else if (target.name.endsWith(".css")) {259extension = ".css";260} else {261throw new InternalError("Unexpected target name: " + target.name);262}263targetName =264targetName.slice(0, target.name.length - extension.length) +265hashFragment + extension;266} else {267targetName = target.name;268}269270if (existingDependency) {271if (!existingDependency.stylesheets) {272existingDependency.stylesheets = [];273}274existingDependency.stylesheets.push({275name: targetName,276path: cssPath,277attribs: target.attribs,278});279280// Add any css references281existingDependency.stylesheets.push(...imports);282existingDependency.resources?.push(...resources);283} else {284extraDeps.push({285name: dependency,286stylesheets: [{287name: targetName,288path: cssPath,289attribs: target.attribs,290}, ...imports],291resources,292});293}294}295}296}297298// light only: light299// author prefers dark: light, dark300// author prefers light: light, dark, light301extras = await resolveQuartoSyntaxHighlighting(302inputDir,303extras,304format,305project,306hasDarkStyles ? "light" : "default",307defaultStyle,308);309310if (hasDarkStyles) {311// find the last entry, for the light highlight stylesheet312// so we can duplicate it below.313// (note we must do this before adding the dark highlight stylesheet)314const lightDep = extras.html?.[kDependencies]?.find((extraDep) =>315extraDep.name === kQuartoHtmlDependency316);317const lightEntry = lightDep?.stylesheets &&318lightDep.stylesheets[lightDep.stylesheets.length - 1];319extras = await resolveQuartoSyntaxHighlighting(320inputDir,321extras,322format,323project,324"dark",325defaultStyle,326);327if (defaultStyle === "light") {328const dep2 = extras.html?.[kDependencies]?.find((extraDep) =>329extraDep.name === kQuartoHtmlDependency330);331assert(dep2?.stylesheets && lightEntry);332dep2.stylesheets.push({333...lightEntry,334attribs: {335...lightEntry.attribs,336class: "quarto-color-scheme-extra",337},338});339}340}341342if (isHtmlOutput(format.pandoc, true)) {343// We'll take care of text highlighting for HTML344setTextHighlightStyle("none", extras);345}346347return extras;348}349350// Generates syntax highlighting Css and Css variables351async function resolveQuartoSyntaxHighlighting(352inputDir: string,353extras: FormatExtras,354format: Format,355project: ProjectContext,356style: "dark" | "light" | "default",357defaultStyle?: "dark" | "light",358) {359// if360const minimal = format.metadata[kMinimal] === true;361if (minimal) {362return extras;363}364365extras = safeCloneDeep(extras);366367// If we're using default highlighting, use theme darkness to select highlight style368const mediaAttr = attribForThemeStyle(style);369if (style === "default") {370if (extras.html?.[kTextHighlightingMode] === "dark") {371style = "dark";372}373}374mediaAttr.id = "quarto-text-highlighting-styles";375376// Generate and inject the text highlighting css377const cssFileName = `quarto-syntax-highlighting${378style === "dark" ? "-dark" : ""379}`;380381// Read the highlight style (theme name)382const themeDescriptor = readHighlightingTheme(inputDir, format.pandoc, style);383if (themeDescriptor) {384// Other variables that need to be injected (if any)385const extraVariables = extras.html?.[kQuartoCssVariables] || [];386for (let i = 0; i < extraVariables.length; ++i) {387// For the same reason as outlined in https://github.com/rstudio/bslib/issues/1104,388// we need to patch the text to include a semicolon inside the declaration389// if it doesn't have one.390// This happens because scss-parser is brittle, and will fail to parse a declaration391// if it doesn't end with a semicolon.392//393// In addition, we know that some our variables come from the output394// of sassCompile which395// - misses the last semicolon396// - emits a :root declaration397// - triggers the scss-parser bug398// So we'll attempt to target the last declaration in the :root399// block specifically and add a semicolon if it doesn't have one.400let variable = extraVariables[i].trim();401if (402variable.endsWith("}") && variable.startsWith(":root") &&403!variable.match(/.*;\s?}$/)404) {405variable = variable.slice(0, -1) + ";}";406extraVariables[i] = variable;407}408}409410// The text highlighting CSS variables411const highlightCss = generateThemeCssVars(themeDescriptor.json);412if (highlightCss) {413const rules = [414highlightCss,415"",416"/* other quarto variables */",417...extraVariables,418];419420// The text highlighting CSS rules421const textHighlightCssRules = generateThemeCssClasses(422themeDescriptor.json,423);424if (textHighlightCssRules) {425rules.push(...textHighlightCssRules);426}427428// Add this string literal to the rule set, which prevents pandoc429// from inlining this style sheet430// See https://github.com/jgm/pandoc/commit/7c0a80c323f81e6262848bfcfc922301e3f406e0431rules.push(".prevent-inlining { content: '</'; }");432433// Compile the scss434const highlightCssPath = await compileSass(435[{436key: cssFileName + ".css",437quarto: {438uses: "",439defaults: "",440functions: "",441mixins: "",442rules: rules.join("\n"),443},444}],445project,446false,447);448449// Find the bootstrap or quarto-html dependency and inject this stylesheet450const extraDeps = extras.html?.[kDependencies];451if (extraDeps) {452// Inject an scss variable for setting the background color of code blocks453// with defaults, before the other bootstrap variables?454// don't put it in css (basically use the value to set the default), allow455// default to be override by user456457const quartoDependency = extraDeps.find((extraDep) =>458extraDep.name === kQuartoHtmlDependency459);460const existingDependency = quartoDependency;461if (existingDependency) {462existingDependency.stylesheets = existingDependency.stylesheets ||463[];464465const hash = await md5HashBytes(Deno.readFileSync(highlightCssPath));466existingDependency.stylesheets.push({467name: cssFileName + `-${hash}.css`,468path: highlightCssPath,469attribs: mediaAttr,470});471}472}473}474}475return extras;476}477478// Generates CSS variables based upon the syntax highlighting rules in a theme file479function generateThemeCssVars(480themeJson: Record<string, unknown>,481) {482const textStyles = themeJson["text-styles"] as Record<483string,484Record<string, unknown>485>;486if (textStyles) {487const lines: string[] = [];488lines.push("/* quarto syntax highlight colors */");489lines.push(":root {");490Object.keys(textStyles).forEach((styleName) => {491const abbr = kAbbrevs[styleName];492if (abbr) {493const textValues = textStyles[styleName];494Object.keys(textValues).forEach((textAttr) => {495switch (textAttr) {496case "text-color":497lines.push(498` --quarto-hl-${abbr}-color: ${499textValues[textAttr] ||500"inherit"501};`,502);503break;504}505});506}507});508lines.push("}");509return lines.join("\n");510}511return undefined;512}513514// Generates CSS rules based upon the syntax highlighting rules in a theme file515function generateThemeCssClasses(516themeJson: Record<string, unknown>,517) {518const textStyles = themeJson["text-styles"] as Record<519string,520Record<string, unknown>521>;522if (textStyles) {523const otherLines: string[] = [];524otherLines.push("/* syntax highlight based on Pandoc's rules */");525const tokenCssByAbbr: Record<string, string[]> = {};526527const toCSS = function (528abbr: string,529styleName: string,530cssValues: string[],531) {532const lines: string[] = [];533lines.push(`/* ${styleName} */`);534lines.push(`\ncode span${abbr !== "" ? `.${abbr}` : ""} {`);535cssValues.forEach((value) => {536lines.push(` ${value}`);537});538lines.push("}\n");539540// Store by abbreviation for sorting later541tokenCssByAbbr[abbr] = lines;542};543544Object.keys(textStyles).forEach((styleName) => {545const abbr = kAbbrevs[styleName];546if (abbr !== undefined) {547const textValues = textStyles[styleName];548const cssValues = generateCssKeyValues(textValues);549550toCSS(abbr, styleName, cssValues);551552if (abbr == "") {553[554"pre > code.sourceCode > span",555"code.sourceCode > span",556"div.sourceCode,\ndiv.sourceCode pre.sourceCode",557]558.forEach((selector) => {559otherLines.push(`\n${selector} {`);560otherLines.push(...cssValues);561otherLines.push("}\n");562});563}564}565});566567// Sort tokenCssLines by abbr and flatten them568// Ensure empty abbr ("") comes first by using a custom sort function569const sortedTokenCssLines: string[] = [];570Object.keys(tokenCssByAbbr)571.sort((a, b) => {572// Empty string ("") should come first573if (a === "") return -1;574if (b === "") return 1;575// Otherwise normal alphabetical sort576return a.localeCompare(b);577})578.forEach((abbr) => {579sortedTokenCssLines.push(...tokenCssByAbbr[abbr]);580});581582// return otherLines followed by tokenCssLines (now sorted by abbr)583return otherLines.concat(sortedTokenCssLines);584}585return undefined;586}587588interface CSSResult {589path: string | undefined;590dark: boolean;591}592593// Processes CSS into format extras (scanning for variables and removing them)594async function processCssIntoExtras(595cssPath: string,596extras: FormatExtras,597project: ProjectContext,598): Promise<CSSResult> {599const { temp } = project;600extras.html = extras.html || {};601602const css = Deno.readTextFileSync(cssPath);603604// Extract dark sentinel value605const hasDarkSentinel = cssHasDarkModeSentinel(css);606if (!extras.html[kTextHighlightingMode] && hasDarkSentinel) {607setTextHighlightStyle("dark", extras);608}609610// Extract variables611const matches = css.matchAll(kVariablesRegex);612if (matches) {613extras.html[kQuartoCssVariables] = extras.html[kQuartoCssVariables] || [];614let dirty = false;615for (const match of matches) {616const variables = match[1];617extras.html[kQuartoCssVariables]?.push(variables);618dirty = true;619}620621// Don't include duplicate variables622extras.html[kQuartoCssVariables] = uniqBy(623extras.html[kQuartoCssVariables],624(val: string) => {625return val;626},627);628629if (dirty) {630const cleanedCss = css.replaceAll(kVariablesRegex, "");631let newCssPath: string | undefined;632if (cleanedCss.trim() === "") {633newCssPath = undefined;634} else {635const hash = await md5HashBytes(new TextEncoder().encode(cleanedCss));636newCssPath = temp.createFile({ suffix: `-${hash}.css` });637Deno.writeTextFileSync(newCssPath, cleanedCss, {638mode: safeModeFromFile(cssPath),639});640}641642return {643dark: hasDarkSentinel,644path: newCssPath,645};646}647}648return {649dark: hasDarkSentinel,650path: cssPath,651};652}653const kVariablesRegex =654/\/\*\! quarto-variables-start \*\/([\S\s]*)\/\*\! quarto-variables-end \*\//g;655656// Attributes for the style tag657function attribForThemeStyle(658style: "dark" | "light" | "default",659): Record<string, string> {660const colorModeAttrs = (mode: string) => {661const attr: Record<string, string> = {662class: `quarto-color-scheme${663mode === "dark" ? " quarto-color-alternate" : ""664}`,665};666return attr;667};668669switch (style) {670case "dark":671return colorModeAttrs("dark");672case "light":673return colorModeAttrs("light");674case "default":675default:676return {};677}678}679680// Note the text highlight style in extras681export function setTextHighlightStyle(682style: "light" | "dark" | "none",683extras: FormatExtras,684) {685extras.html = extras.html || {};686extras.html[kTextHighlightingMode] = style;687}688689690