Path: blob/main/src/format/html/format-html-scss.ts
6450 views
/*1* format-html-scss.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import { existsSync } from "../../deno_ral/fs.ts";7import { dirname, extname, isAbsolute, join } from "../../deno_ral/path.ts";89import { formatResourcePath } from "../../core/resources.ts";10import {11asBootstrapColor,12asCssColor,13asCssFont,14asCssNumber,15asCssSize,16} from "../../core/css.ts";17import { mergeLayers, sassLayer } from "../../core/sass.ts";1819import { outputVariable, SassVariable, sassVariable } from "../../core/sass.ts";2021import {22Format,23SassBundle,24SassBundleWithBrand,25SassLayer,26} from "../../config/types.ts";27import { Metadata } from "../../config/types.ts";28import { kGrid, kTheme } from "../../config/constants.ts";2930import {31kPageFooter,32kSiteNavbar,33kSiteSidebar,34kWebsite,35} from "../../project/types/website/website-constants.ts";36import {37bootstrapFunctions,38bootstrapMixins,39bootstrapResourceDir,40bootstrapRules,41bootstrapVariables,42bslibComponentMixins,43bslibComponentRules,44bslibResourceDir,45htmlToolsRules,46kBootstrapDependencyName,47quartoBootstrapCustomizationLayer,48quartoBootstrapFunctions,49quartoBootstrapMixins,50quartoBootstrapRules,51quartoCodeFilenameRules,52quartoCopyCodeDefaults,53quartoCopyCodeRules,54quartoDefaults,55quartoFunctions,56quartoGlobalCssVariableRules,57quartoLinkExternalRules,58quartoRules,59quartoUses,60sassUtilFunctions,61} from "./format-html-shared.ts";62import { readHighlightingTheme } from "../../quarto-core/text-highlighting.ts";63import { warn } from "log";6465export interface Themes {66light: string[];67dark?: string[];68}6970function layerQuartoScss(71key: string,72dependency: string,73sassLayer: (SassLayer | "brand")[],74format: Format,75darkLayer?: (SassLayer | "brand")[],76darkDefault?: boolean,77loadPaths?: string[],78): SassBundleWithBrand {79// Compose the base Quarto SCSS80const uses = quartoUses();81const defaults = [82quartoDefaults(format),83quartoBootstrapDefaults(format.metadata),84quartoCopyCodeDefaults(),85].join("\n");86const mixins = [quartoBootstrapMixins()].join("\n");87const functions = [quartoFunctions(), quartoBootstrapFunctions()].join("\n");88const rules = [89quartoRules(),90quartoCopyCodeRules(),91quartoBootstrapRules(),92quartoGlobalCssVariableRules(),93quartoLinkExternalRules(),94quartoCodeFilenameRules(),95].join("\n");96const quartoScss = {97uses,98defaults,99functions,100mixins,101rules,102};103104// Compose the framework level SCSS (bootstrap)105// The bootstrap framework functions106const frameworkFunctions = [107bootstrapFunctions(),108sassUtilFunctions("color-contrast.scss"),109].join(110"\n",111);112113// The bootstrap framework variables114const frameworkVariables = [115bootstrapVariables(),116pandocVariablesToThemeScss(format.metadata),117].join("\n");118119const frameworkMixins = [bootstrapMixins(), bslibComponentMixins()].join(120"\n",121);122const frameworkRules = [123bootstrapRules(),124bslibComponentRules(),125htmlToolsRules(),126].join("\n");127const bootstrapScss = {128uses: "",129defaults: frameworkVariables,130functions: frameworkFunctions,131mixins: frameworkMixins,132rules: frameworkRules,133};134135// Compute the load paths136const resolvedLoadPaths = [137...(loadPaths || []),138bootstrapResourceDir(),139bslibResourceDir(),140];141142return {143dependency,144key,145user: sassLayer,146quarto: quartoScss,147framework: bootstrapScss,148loadPaths: resolvedLoadPaths,149dark: darkLayer150? {151user: darkLayer,152default: darkDefault,153}154: undefined,155attribs: { id: "quarto-bootstrap" },156};157}158159export function resolveBootstrapScss(160input: string,161format: Format,162sassLayers: SassLayer[],163): SassBundleWithBrand[] {164// Quarto built in css165const quartoThemesDir = formatResourcePath(166"html",167join("bootstrap", "themes"),168);169170// Resolve the provided themes to a set of variables and styles171const theme = format.metadata[kTheme] || [];172const [themeSassLayers, defaultDark, loadPaths] = resolveThemeLayer(173format,174input,175theme,176quartoThemesDir,177sassLayers,178);179180// Find light and dark sass layers181const sassBundles: SassBundleWithBrand[] = [];182183// light184sassBundles.push(185layerQuartoScss(186"quarto-theme",187kBootstrapDependencyName,188themeSassLayers.light,189format,190themeSassLayers.dark,191defaultDark,192loadPaths,193),194);195196return sassBundles;197}198199export interface ThemeSassLayer {200light: (SassLayer | "brand")[];201dark?: (SassLayer | "brand")[];202}203204function layerTheme(205input: string,206themes: string[],207quartoThemesDir: string,208): { layers: (SassLayer | "brand")[]; loadPaths: string[] } {209let injectedCustomization = false;210const loadPaths: string[] = [];211const layers = themes.flatMap((theme) => {212const isAbs = isAbsolute(theme);213const isScssFile = [".scss", ".css"].includes(extname(theme));214215if (theme === "brand") {216// provide a brand order marker for downstream217// processing to know where to insert the brand scss218return "brand";219} else if (isAbs && isScssFile) {220// Absolute path to a SCSS file221if (existsSync(theme)) {222const themeDir = dirname(theme);223loadPaths.push(themeDir);224return sassLayer(theme);225} else {226warn(`Theme file not found: ${theme}`);227}228} else if (isScssFile) {229// Relative path to a SCSS file230const themePath = join(dirname(input), theme);231if (existsSync(themePath)) {232const themeDir = dirname(themePath);233loadPaths.push(themeDir);234return sassLayer(themePath);235} else {236warn(`Theme file not found: ${themePath}`);237}238} else {239// The directory for this theme240const resolvedThemePath = join(quartoThemesDir, `${theme}.scss`);241// Read the sass layers242if (existsSync(resolvedThemePath)) {243// The theme appears to be a built in theme244245// The theme layer from a built in theme246const themeLayer = sassLayer(resolvedThemePath);247248// Inject customization of the theme (this should go just after the theme)249injectedCustomization = true;250return [themeLayer, quartoBootstrapCustomizationLayer()];251}252}253return {254uses: "",255defaults: "",256functions: "",257mixins: "",258rules: "",259};260});261262// If no themes were provided, we still should inject our customization263if (!injectedCustomization) {264layers.unshift(quartoBootstrapCustomizationLayer());265}266return { layers, loadPaths };267}268269export function resolveTextHighlightingLayer(270input: string,271format: Format,272style: "dark" | "light",273) {274const layer = {275uses: "",276defaults: "",277functions: "",278mixins: "",279rules: "",280};281282const themeDescriptor = readHighlightingTheme(283dirname(input),284format.pandoc,285style,286);287288if (format.metadata[kCodeBlockBackground] === undefined) {289// Inject a background color, if present290if (themeDescriptor && !themeDescriptor.isAdaptive) {291const backgroundColor = () => {292if (themeDescriptor.json["background-color"]) {293return themeDescriptor.json["background-color"] as string;294} else {295const editorColors = themeDescriptor.json["editor-colors"] as296| Record<string, string>297| undefined;298if (editorColors && editorColors["BackgroundColor"]) {299return editorColors["BackgroundColor"] as string;300} else {301return undefined;302}303}304};305306const background = backgroundColor();307if (background) {308layer.defaults = outputVariable(309sassVariable(310"code-block-bg",311asCssColor(background),312),313true,314);315}316317const textColor = themeDescriptor.json["text-color"] as string;318if (textColor) {319layer.defaults = layer.defaults + "\n" + outputVariable(320sassVariable(321"code-block-color",322asCssColor(textColor),323),324true,325);326}327}328}329330if (themeDescriptor) {331const readTextColor = (name: string) => {332const textStyles = themeDescriptor.json["text-styles"];333if (textStyles && typeof textStyles === "object") {334const commentColor = (textStyles as Record<string, unknown>)[name];335if (commentColor && typeof commentColor === "object") {336const textColor =337(commentColor as Record<string, unknown>)["text-color"];338return textColor;339} else {340return undefined;341}342} else {343return undefined;344}345};346347const commentColor = readTextColor("Comment");348if (commentColor) {349layer.defaults = layer.defaults + "\n" + outputVariable(350sassVariable(351"btn-code-copy-color",352asCssColor(commentColor),353),354true,355);356}357358const functionColor = readTextColor("Function");359if (functionColor) {360layer.defaults = layer.defaults + "\n" + outputVariable(361sassVariable(362"btn-code-copy-color-active",363asCssColor(functionColor),364),365true,366);367}368}369370return layer;371}372373// Resolve the themes into a ThemeSassLayer374function resolveThemeLayer(375format: Format,376input: string,377themes: string | string[] | Themes | unknown,378quartoThemesDir: string,379sassLayers: SassLayer[],380): [ThemeSassLayer, boolean, string[]] {381let theme = undefined;382let defaultDark = false;383384if (typeof themes === "string") {385// The themes is just a string386theme = { light: [themes] };387} else if (Array.isArray(themes)) {388// The themes is an array389theme = { light: themes };390} else if (typeof themes === "object") {391// The themes are an object - look at each key and392// deal with them either as a string or a string[]393const themeArr = (theme?: unknown): string[] => {394const themes: string[] = [];395if (theme) {396if (typeof theme === "string") {397themes.push(theme);398} else if (Array.isArray(theme)) {399themes.push(...theme);400}401}402return themes;403};404405const themeObj = themes as Record<string, unknown>;406407// See whether the dark or light theme is the default408const keyList = Object.keys(themeObj);409defaultDark = keyList.length > 1 && keyList[0] === "dark";410411theme = {412light: themeArr(themeObj.light),413dark: themeObj.dark ? themeArr(themeObj.dark) : undefined,414};415} else {416theme = { light: [] };417}418const lightLayerContext = layerTheme(input, theme.light, quartoThemesDir);419lightLayerContext.layers.unshift(...sassLayers);420const highlightingLayer = resolveTextHighlightingLayer(421input,422format,423"light",424);425if (highlightingLayer) {426lightLayerContext.layers.unshift(highlightingLayer);427}428429const darkLayerContext = theme.dark430? layerTheme(input, theme.dark, quartoThemesDir)431: undefined;432if (darkLayerContext) {433darkLayerContext.layers.unshift(...sassLayers);434const darkHighlightingLayer = resolveTextHighlightingLayer(435input,436format,437"dark",438);439if (darkHighlightingLayer) {440darkLayerContext.layers.unshift(darkHighlightingLayer);441}442}443444const themeSassLayer = {445light: lightLayerContext.layers,446dark: darkLayerContext?.layers,447};448449const loadPaths = [450...lightLayerContext.loadPaths,451...darkLayerContext?.loadPaths || [],452];453return [themeSassLayer, defaultDark, loadPaths];454}455456function pandocVariablesToThemeDefaults(457metadata: Metadata,458): SassVariable[] {459const explicitVars: SassVariable[] = [];460461// Helper for adding explicitly set variables462const add = (463defaults: SassVariable[],464name: string,465value?: unknown,466formatter?: (val: unknown) => unknown,467) => {468if (value) {469const sassVar = sassVariable(name, value, formatter);470defaults.push(sassVar);471}472};473474// Pass through to some bootstrap variables475add(explicitVars, "line-height-base", metadata["linestretch"], asCssNumber);476add(explicitVars, "font-size-root", metadata["fontsize"]);477add(explicitVars, "body-bg", metadata["backgroundcolor"]);478add(explicitVars, "body-color", metadata["fontcolor"]);479add(explicitVars, "link-color", metadata["linkcolor"]);480add(explicitVars, "font-family-base", metadata["mainfont"], asCssFont);481add(explicitVars, "font-family-code", metadata["monofont"], asCssFont);482add(explicitVars, "mono-background-color", metadata["monobackgroundcolor"]);483484// Deal with sizes485const explicitSizes = [486"max-width",487"margin-top",488"margin-bottom",489"margin-left",490"margin-right",491];492explicitSizes.forEach((attrib) => {493add(explicitVars, attrib, metadata[attrib], asCssSize);494});495496// Resolve any grid variables497const gridObj = metadata[kGrid] as Metadata;498if (gridObj) {499add(explicitVars, "grid-sidebar-width", gridObj["sidebar-width"]);500add(explicitVars, "grid-margin-width", gridObj["margin-width"]);501add(explicitVars, "grid-body-width", gridObj["body-width"]);502add(explicitVars, "grid-column-gutter-width", gridObj["gutter-width"]);503}504return explicitVars;505}506507function pandocVariablesToThemeScss(508metadata: Metadata,509asDefaults = false,510) {511return pandocVariablesToThemeDefaults(metadata).map(512(variable) => {513return outputVariable(variable, asDefaults);514},515).join("\n");516}517518const kCodeBorderLeft = "code-block-border-left";519const kCodeBlockBackground = "code-block-bg";520const kBackground = "background";521const kForeground = "foreground";522const kTogglePosition = "toggle-position";523const kColor = "color";524const kBorder = "border";525526// Quarto variables and styles527export const quartoBootstrapDefaults = (metadata: Metadata) => {528const varFilePath = formatResourcePath(529"html",530join("bootstrap", "_bootstrap-variables.scss"),531);532const variables = [Deno.readTextFileSync(varFilePath)];533const colorDefaults: string[] = [];534535const navbar = (metadata[kWebsite] as Metadata)?.[kSiteNavbar];536if (navbar && typeof navbar === "object") {537// Forward navbar background color538const navbarBackground = (navbar as Record<string, unknown>)[kBackground];539if (navbarBackground !== undefined) {540resolveBootstrapColorDefault(navbarBackground, colorDefaults);541variables.push(542outputVariable(543sassVariable(544"navbar-bg",545navbarBackground,546typeof navbarBackground === "string" ? asBootstrapColor : undefined,547),548),549);550}551552// Forward navbar foreground color553const navbarForeground = (navbar as Record<string, unknown>)[kForeground];554if (navbarForeground !== undefined) {555resolveBootstrapColorDefault(navbarForeground, colorDefaults);556variables.push(557outputVariable(558sassVariable(559"navbar-fg",560navbarForeground,561typeof navbarForeground === "string" ? asBootstrapColor : undefined,562),563),564);565}566567// Forward the toggle-position568const navbarTogglePosition =569(navbar as Record<string, unknown>)[kTogglePosition];570if (navbarTogglePosition !== undefined) {571variables.push(572outputVariable(573sassVariable(574"navbar-toggle-position",575navbarTogglePosition,576),577),578);579}580}581582const sidebars = (metadata[kWebsite] as Metadata)?.[kSiteSidebar];583const sidebar = Array.isArray(sidebars)584? sidebars[0]585: typeof sidebars === "object"586? (sidebars as Metadata)587: undefined;588589if (sidebar) {590// Forward background color591const sidebarBackground = sidebar[kBackground];592if (sidebarBackground !== undefined) {593resolveBootstrapColorDefault(sidebarBackground, colorDefaults);594variables.push(595outputVariable(596sassVariable(597"sidebar-bg",598sidebarBackground,599typeof sidebarBackground === "string"600? asBootstrapColor601: undefined,602),603),604);605} else if (sidebar.style === "floating" || navbar) {606// If this is a floating sidebar or there is a navbar present,607// default to a body colored sidebar608variables.push(609`$sidebar-bg: if(variable-exists(body-bg), $body-bg, #fff) !default;`,610);611}612613// Forward foreground color614const sidebarForeground = sidebar[kForeground];615if (sidebarForeground !== undefined) {616resolveBootstrapColorDefault(sidebarForeground, colorDefaults);617variables.push(618outputVariable(619sassVariable(620"sidebar-fg",621sidebarForeground,622typeof sidebarForeground === "string"623? asBootstrapColor624: undefined,625),626),627);628}629630// Enable the sidebar border for docked by default631const sidebarBorder = sidebar[kBorder];632variables.push(633outputVariable(634sassVariable(635"sidebar-border",636sidebarBorder !== undefined637? sidebarBorder638: sidebar.style === "docked",639),640),641);642} else {643// If there is no sidebar, default to body color for any sidebar that may appear644variables.push(645`$sidebar-bg: if(variable-exists(body-bg), $body-bg, #fff) !default;`,646);647}648649const footer = (metadata[kWebsite] as Metadata)?.[kPageFooter] as Metadata;650if (footer !== undefined && typeof footer === "object") {651// Forward footer color652const footerBg = footer[kBackground];653if (footerBg !== undefined) {654resolveBootstrapColorDefault(footerBg, colorDefaults);655variables.push(656outputVariable(657sassVariable(658"footer-bg",659footerBg,660typeof footerBg === "string" ? asBootstrapColor : undefined,661),662),663);664}665666// Forward footer foreground667const footerFg = footer[kForeground];668if (footerFg !== undefined) {669resolveBootstrapColorDefault(footerFg, colorDefaults);670variables.push(671outputVariable(672sassVariable(673"footer-fg",674footerFg,675typeof footerFg === "string" ? asBootstrapColor : undefined,676),677),678);679}680681// Forward footer border682const footerBorder = footer[kBorder];683// Enable the border unless it is explicitly disabled684const showBorder = footerBorder !== undefined685? footerBorder686: sidebar?.style === "docked";687if (showBorder) {688variables.push(689outputVariable(690sassVariable(691"footer-border",692true,693),694),695);696}697698// If the footer border is a color, set that699if (footerBorder !== undefined && typeof footerBorder === "string") {700resolveBootstrapColorDefault(footerBorder, colorDefaults);701variables.push(702outputVariable(703sassVariable(704"footer-border-color",705footerBorder,706asBootstrapColor,707),708),709);710}711712// Forward any footer color713const footerColor = footer[kColor];714if (footerColor && typeof footerColor === "string") {715resolveBootstrapColorDefault(footerColor, colorDefaults);716variables.push(717outputVariable(718sassVariable(719"footer-color",720footerColor,721asBootstrapColor,722),723),724);725}726}727728// Forward codeleft-border729const codeblockLeftBorder = metadata[kCodeBorderLeft];730const codeblockBackground = metadata[kCodeBlockBackground];731732if (codeblockLeftBorder !== undefined) {733resolveBootstrapColorDefault(codeblockLeftBorder, colorDefaults);734variables.push(735outputVariable(736sassVariable(737kCodeBorderLeft,738codeblockLeftBorder,739typeof codeblockLeftBorder === "string"740? asBootstrapColor741: undefined,742),743),744);745746if (codeblockBackground === undefined && codeblockLeftBorder !== false) {747variables.push(outputVariable(sassVariable(kCodeBlockBackground, false)));748}749}750751// code background color752if (codeblockBackground !== undefined) {753variables.push(outputVariable(sassVariable(754kCodeBlockBackground,755codeblockBackground,756typeof codeblockBackground === "string" ? asBootstrapColor : undefined,757)));758}759760// Ensure any color variable defaults are present761colorDefaults.forEach((colorDefault) => {762variables.push(colorDefault);763});764765// Any of the variables that we added from metadata should go first766// So they provide the defaults767return variables.reverse().join("\n");768};769770function resolveBootstrapColorDefault(value: unknown, variables: string[]) {771if (value) {772const variable = bootstrapColorDefault(value);773if (774variable &&775!variables.find((existingVar) => {776return existingVar === variable;777})778) {779variables.unshift(variable);780}781}782}783784function bootstrapColorDefault(value: unknown) {785if (typeof value === "string") {786return bootstrapColorVars[value];787}788}789790const bootstrapColorVars: Record<string, string> = {791primary: "$primary: #0d6efd !default;",792secondary: "$secondary: #6c757d !default;",793success: "$success: #198754 !default;",794info: "$info: #0dcaf0 !default;",795warning: "$warning: #ffc107 !default;",796danger: "$danger: #dc3545 !default;",797light: "$light: #f8f9fa !default;",798dark: "$dark: #212529 !default;",799};800801802