diff --git a/src/command/render/pandoc-html.ts b/src/command/render/pandoc-html.ts index e0612e7800d..758c2546e7e 100644 --- a/src/command/render/pandoc-html.ts +++ b/src/command/render/pandoc-html.ts @@ -14,6 +14,8 @@ import { kQuartoCssVariables, kTextHighlightingMode, SassBundle, + SassBundleWithBrand, + SassLayer, } from "../../config/types.ts"; import { ProjectContext } from "../../project/types.ts"; @@ -39,6 +41,7 @@ import { kSassBundles } from "../../config/types.ts"; import { md5HashBytes } from "../../core/hash.ts"; import { InternalError } from "../../core/lib/error.ts"; import { writeTextFileSyncPreserveMode } from "../../core/write.ts"; +import { assert } from "testing/asserts"; // The output target for a sass bundle // (controls the overall style tag that is emitted) @@ -57,12 +60,12 @@ export async function resolveSassBundles( ) { extras = cloneDeep(extras); - const mergedBundles: Record = {}; + const mergedBundles: Record = {}; // groups the bundles by dependency name const group = ( - bundles: SassBundle[], - groupedBundles: Record, + bundles: SassBundleWithBrand[], + groupedBundles: Record, ) => { bundles.forEach((bundle) => { if (!groupedBundles[bundle.dependency]) { @@ -82,19 +85,37 @@ export async function resolveSassBundles( let defaultStyle: "dark" | "light" | undefined = undefined; for (const dependency of Object.keys(mergedBundles)) { // compile the cssPath - const bundles = mergedBundles[dependency]; + const bundlesWithBrand = mergedBundles[dependency]; + // first, pull out the brand-specific layers + // + // the brand bundle itself doesn't have any 'brand' entries; + // those are used to specify where the brand-specific layers should be inserted + // in the final bundle. We filter + const brandLayersMaybeBrand = bundlesWithBrand.find((bundle) => + bundle.key === "brand" + )?.user || []; + assert(!brandLayersMaybeBrand.find((v) => v === "brand")); + const brandLayers = brandLayersMaybeBrand as SassLayer[]; + const bundles: SassBundle[] = bundlesWithBrand.filter((bundle) => + bundle.key !== "brand" + ).map((bundle) => { + const userBrand = bundle.user?.findIndex((layer) => layer === "brand"); + if (userBrand && userBrand !== -1) { + bundle = cloneDeep(bundle); + bundle.user!.splice(userBrand, 1, ...brandLayers); + } + return bundle as SassBundle; + }); // See if any bundles are providing dark specific css const hasDark = bundles.some((bundle) => bundle.dark !== undefined); - defaultStyle = bundles.some((bundle) => - bundle.dark !== undefined && bundle.dark.default - ) - ? "dark" - : "light"; - + defaultStyle = + bundles.some((bundle) => bundle.dark !== undefined && bundle.dark.default) + ? "dark" + : "light"; const targets: SassTarget[] = [{ name: `${dependency}.min.css`, - bundles, + bundles: (bundles as any), attribs: { "append-hash": "true", }, @@ -119,7 +140,7 @@ export async function resolveSassBundles( }); targets.push({ name: `${dependency}-dark.min.css`, - bundles: darkBundles, + bundles: darkBundles as any, attribs: { "append-hash": "true", ...attribForThemeStyle("dark", defaultStyle), @@ -141,6 +162,9 @@ export async function resolveSassBundles( // it can happen that processing generate an empty css file (e.g quarto-html deps with Quarto CSS variables) // in that case, no need to insert the cssPath in the dependency if (!cssPath) continue; + if (Deno.readTextFileSync(cssPath).length === 0) { + continue; + } // Process attributes (forward on to the target) for (const bundle of target.bundles) { diff --git a/src/config/types.ts b/src/config/types.ts index 309b9878378..b7ade818fb3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -305,9 +305,28 @@ export interface SassLayer { rules: string; } +export interface SassBundleLayersWithBrand { + key: string; + user?: (SassLayer | "brand")[]; + quarto?: SassLayer; + framework?: SassLayer; + loadPaths?: string[]; +} + +export interface SassBundleWithBrand extends SassBundleLayersWithBrand { + dependency: string; + dark?: { + user?: (SassLayer | "brand")[]; + quarto?: SassLayer; + framework?: SassLayer; + default?: boolean; + }; + attribs?: Record; +} + export interface SassBundleLayers { key: string; - user?: SassLayer; + user?: SassLayer[]; quarto?: SassLayer; framework?: SassLayer; loadPaths?: string[]; @@ -316,7 +335,7 @@ export interface SassBundleLayers { export interface SassBundle extends SassBundleLayers { dependency: string; dark?: { - user?: SassLayer; + user?: SassLayer[]; quarto?: SassLayer; framework?: SassLayer; default?: boolean; @@ -376,7 +395,7 @@ export interface FormatExtras { templateContext?: FormatTemplateContext; html?: { [kDependencies]?: FormatDependency[]; - [kSassBundles]?: SassBundle[]; + [kSassBundles]?: SassBundleWithBrand[]; [kBodyEnvelope]?: BodyEnvelope; [kHtmlPostprocessors]?: Array; [kHtmlFinalizers]?: Array< diff --git a/src/core/sass.ts b/src/core/sass.ts index 5adfeef0077..a5aa41a5e5d 100644 --- a/src/core/sass.ts +++ b/src/core/sass.ts @@ -81,15 +81,18 @@ export async function compileSass( ); const quartoDefaults = bundles.map((bundle) => bundle.quarto?.defaults || ""); const quartoRules = bundles.map((bundle) => bundle.quarto?.rules || ""); - const quartoMixins = bundles.map((bundle) => bundle.quarto?.mixins || ""); + const userLayers = mergeLayers( + ...bundles.map((bundle) => bundle.user || []).flat(), + ); + // Gather sasslayer for the user - const userUses = bundles.map((bundle) => bundle.user?.uses || ""); - const userFunctions = bundles.map((bundle) => bundle.user?.functions || ""); - const userDefaults = bundles.map((bundle) => bundle.user?.defaults || ""); - const userRules = bundles.map((bundle) => bundle.user?.rules || ""); - const userMixins = bundles.map((bundle) => bundle.user?.mixins || ""); + const userUses = userLayers.uses; //bundles.map((bundle) => bundle.user?.uses || ""); + const userFunctions = userLayers.functions; // bundles.map((bundle) => bundle.user?.functions || ""); + const userDefaults = userLayers.defaults; // bundles.map((bundle) => bundle.user?.defaults || ""); + const userRules = userLayers.rules; // bundles.map((bundle) => bundle.user?.rules || ""); + const userMixins = userLayers.mixins; // bundles.map((bundle) => bundle.user?.mixins || ""); // Set any load paths used to resolve imports const loadPaths: string[] = []; @@ -114,15 +117,15 @@ export async function compileSass( '// quarto-scss-analysis-annotation { "origin": "\'use\' section from Quarto" }', ...quartoUses, '// quarto-scss-analysis-annotation { "origin": "\'use\' section from user-defined SCSS" }', - ...userUses, + userUses, '// quarto-scss-analysis-annotation { "origin": "\'functions\' section from format" }', ...frameworkFunctions, '// quarto-scss-analysis-annotation { "origin": "\'functions\' section from Quarto" }', ...quartoFunctions, '// quarto-scss-analysis-annotation { "origin": "\'functions\' section from user-defined SCSS" }', - ...userFunctions, + userFunctions, '// quarto-scss-analysis-annotation { "origin": "Defaults from user-defined SCSS" }', - ...userDefaults.reverse(), + userDefaults, '// quarto-scss-analysis-annotation { "origin": "Defaults from Quarto\'s SCSS" }', ...quartoDefaults.reverse(), '// quarto-scss-analysis-annotation { "origin": "Defaults from the format SCSS" }', @@ -132,13 +135,13 @@ export async function compileSass( '// quarto-scss-analysis-annotation { "origin": "\'mixins\' section from Quarto" }', ...quartoMixins, '// quarto-scss-analysis-annotation { "origin": "\'mixins\' section from user-defined SCSS" }', - ...userMixins, + userMixins, '// quarto-scss-analysis-annotation { "origin": "\'rules\' section from format" }', ...frameworkRules, '// quarto-scss-analysis-annotation { "origin": "\'rules\' section from Quarto" }', ...quartoRules, '// quarto-scss-analysis-annotation { "origin": "\'rules\' section from user-defined SCSS" }', - ...userRules, + userRules, '// quarto-scss-analysis-annotation { "origin": null }', ].join("\n\n"); @@ -191,7 +194,7 @@ const layoutBoundary = const kLayerBoundaryLine = RegExp(layoutBoundary); const kLayerBoundaryTest = RegExp(layoutBoundary, "m"); -export function mergeLayers(...layers: SassLayer[]) { +export function mergeLayers(...layers: SassLayer[]): SassLayer { const themeUses: string[] = []; const themeDefaults: string[] = []; const themeRules: string[] = []; @@ -202,10 +205,7 @@ export function mergeLayers(...layers: SassLayer[]) { themeUses.push(theme.uses); } if (theme.defaults) { - // We need to reverse the order of defaults - // since defaults override one another by being - // set first - themeDefaults.unshift(theme.defaults); + themeDefaults.push(theme.defaults); } if (theme.rules) { @@ -223,7 +223,10 @@ export function mergeLayers(...layers: SassLayer[]) { return { uses: themeUses.join("\n"), - defaults: themeDefaults.join("\n"), + // We need to reverse the order of defaults + // since defaults override one another by being + // set first + defaults: themeDefaults.reverse().join("\n"), functions: themeFunctions.join("\n"), mixins: themeMixins.join("\n"), rules: themeRules.join("\n"), @@ -419,5 +422,5 @@ export function cleanSourceMappingUrl(cssPath: string): void { kSourceMappingRegexes[1], "", ); - writeTextFileSyncPreserveMode(cssPath, cleaned); + writeTextFileSyncPreserveMode(cssPath, cleaned.trim()); } diff --git a/src/core/sass/brand.ts b/src/core/sass/brand.ts index 85691fc99ca..480957b11ed 100644 --- a/src/core/sass/brand.ts +++ b/src/core/sass/brand.ts @@ -11,9 +11,8 @@ import { FormatExtras, kSassBundles, SassBundle, - SassBundleLayers, + SassLayer, } from "../../config/types.ts"; -import { join, relative } from "../../deno_ral/path.ts"; import { ProjectContext } from "../../project/types.ts"; import { BrandFont, @@ -25,6 +24,7 @@ import { import { Brand } from "../brand/brand.ts"; const defaultColorNameMap: Record = { + "link-color": "link", "pre-color": "foreground", "body-bg": "background", "body-color": "foreground", @@ -67,50 +67,18 @@ export async function brandBootstrapSassBundles( project: ProjectContext, key: string, ): Promise { - return (await brandBootstrapSassBundleLayers( + const layers = await brandBootstrapSassLayers( fileName, project, - key, defaultColorNameMap, - )).map( - (layer: SassBundleLayers) => { - return { - ...layer, - dependency: "bootstrap", - }; - }, ); + return [{ + key, + dependency: "bootstrap", + user: layers, + }]; } -const fontFileFormat = (file: string): string => { - const fragments = file.split("."); - if (fragments.length < 2) { - throw new Error(`Invalid font file ${file}; expected extension.`); - } - const ext = fragments.pop(); - // https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/src#font_formats - switch (ext) { - case "otc": - case "ttc": - return "collection"; - case "woff": - return "woff"; - case "woff2": - return "woff2"; - case "ttf": - return "truetype"; - case "otf": - return "opentype"; - case "svg": - case "svgz": - return "svg"; - case "eot": - return "embedded-opentype"; - default: - throw new Error(`Unknown font format ${ext} in ${file}`); - } -}; - const bunnyFontImportString = (description: BrandFontCommon) => { const bunnyName = (name: string) => name.replace(/ /g, "-"); const bunnyFamily = description.family; @@ -175,11 +143,10 @@ const googleFontImportString = (description: BrandFontGoogle) => { }:${styleString}wght@${weights}&display=${display}');`; }; -const brandColorBundle = ( +const brandColorLayer = ( brand: Brand, - key: string, nameMap: Record, -): SassBundleLayers => { +): SassLayer => { const colorVariables: string[] = [ "/* color variables from _brand.yml */", '// quarto-scss-analysis-annotation { "action": "push", "origin": "_brand.yml color" }', @@ -226,24 +193,18 @@ const brandColorBundle = ( "}", '// quarto-scss-analysis-annotation { "action": "pop" }', ); - const colorBundle: SassBundleLayers = { - key, - // dependency: "bootstrap", - quarto: { - defaults: colorVariables.join("\n"), - uses: "", - functions: "", - mixins: "", - rules: colorCssVariables.join("\n"), - }, + return { + defaults: colorVariables.join("\n"), + uses: "", + functions: "", + mixins: "", + rules: colorCssVariables.join("\n"), }; - return colorBundle; }; -const brandBootstrapBundle = ( +const brandDefaultsBootstrapLayer = ( brand: Brand, - key: string, -): SassBundleLayers => { +): SassLayer => { // Bootstrap Variables from brand.defaults.bootstrap const brandBootstrap = brand?.data?.defaults?.bootstrap as unknown as Record< string, @@ -300,24 +261,18 @@ const brandBootstrapBundle = ( bsColors.push('// quarto-scss-analysis-annotation { "action": "pop" }'); - const bsBundle: SassBundleLayers = { - key, - // dependency: "bootstrap", - quarto: { - defaults: bsColors.join("\n") + "\n" + bsVariables.join("\n"), - uses: "", - functions: "", - mixins: "", - rules: "", - }, + return { + defaults: bsColors.join("\n") + "\n" + bsVariables.join("\n"), + uses: "", + functions: "", + mixins: "", + rules: "", }; - return bsBundle; }; -const brandTypographyBundle = ( +const brandTypographyLayer = ( brand: Brand, - key: string, -): SassBundleLayers => { +): SassLayer => { const typographyVariables: string[] = [ "/* typography variables from _brand.yml */", '// quarto-scss-analysis-annotation { "action": "push", "origin": "_brand.yml typography" }', @@ -325,18 +280,6 @@ const brandTypographyBundle = ( const typographyImports: Set = new Set(); const fonts = brand.data?.typography?.fonts ?? []; - const pathCorrection = relative(brand.projectDir, brand.brandDir); - const computePath = (file: string) => { - if (file.startsWith("http://") || file.startsWith("https://")) { - return file; - } - // paths in our CSS are always relative to the project directory - if (file.startsWith("/")) { - return file.slice(1); - } - return join(pathCorrection, file); - }; - const getFontFamilies = (family: string | undefined) => { return fonts.filter((font) => typeof font !== "string" && font.family === family @@ -579,75 +522,61 @@ const brandTypographyBundle = ( typographyVariables.push( '// quarto-scss-analysis-annotation { "action": "pop" }', ); - const typographyBundle: SassBundleLayers = { - key, - // dependency: "bootstrap", - quarto: { - defaults: typographyVariables.join("\n"), - uses: Array.from(typographyImports).join("\n"), - functions: "", - mixins: "", - rules: "", - }, + return { + defaults: typographyVariables.join("\n"), + uses: Array.from(typographyImports).join("\n"), + functions: "", + mixins: "", + rules: "", }; - return typographyBundle; }; -export async function brandSassBundleLayers( +export async function brandSassLayers( fileName: string | undefined, project: ProjectContext, - key: string, nameMap: Record = {}, -): Promise { +): Promise { const brand = await project.resolveBrand(fileName); - const sassBundles: SassBundleLayers[] = []; + const sassLayers: SassLayer[] = []; if (brand?.data.color) { - sassBundles.push(brandColorBundle(brand, key, nameMap)); + sassLayers.push(brandColorLayer(brand, nameMap)); } if (brand?.data.typography) { - sassBundles.push(brandTypographyBundle(brand, key)); + sassLayers.push(brandTypographyLayer(brand)); } - return sassBundles; + return sassLayers; } -export async function brandBootstrapSassBundleLayers( +export async function brandBootstrapSassLayers( fileName: string | undefined, project: ProjectContext, - key: string, nameMap: Record = {}, -): Promise { - const brand = await project.resolveBrand(fileName); - const sassBundles = await brandSassBundleLayers( +): Promise { + const layers = await brandSassLayers( fileName, project, - key, nameMap, ); + const brand = await project.resolveBrand(fileName); if (brand?.data?.defaults?.bootstrap) { - const bsBundle = brandBootstrapBundle(brand, key); - if (bsBundle) { - // Add bsBundle to the beginning of the array so that defaults appear - // *after* the rest of the brand variables. - sassBundles.unshift(bsBundle); - } + layers.push(brandDefaultsBootstrapLayer(brand)); } - return sassBundles; + return layers; } -export async function brandRevealSassBundleLayers( +export async function brandRevealSassLayers( input: string | undefined, _format: Format, project: ProjectContext, -): Promise { - return brandSassBundleLayers( +): Promise { + return brandSassLayers( input, project, - "reveal-theme", defaultColorNameMap, ); } @@ -657,25 +586,20 @@ export async function brandSassFormatExtras( _format: Format, project: ProjectContext, ): Promise { - const htmlSassBundleLayers = await brandBootstrapSassBundleLayers( + const htmlSassBundleLayers = await brandBootstrapSassLayers( input, project, - "brand", defaultColorNameMap, ); - const htmlSassBundles: SassBundle[] = htmlSassBundleLayers.map((layer) => { - return { - ...layer, - dependency: "bootstrap", - }; - }); - if (htmlSassBundles.length === 0) { - return {}; - } else { - return { - html: { - [kSassBundles]: htmlSassBundles, - }, - }; - } + return { + html: { + [kSassBundles]: [ + { + key: "brand", + dependency: "bootstrap", + user: htmlSassBundleLayers, + }, + ], + }, + }; } diff --git a/src/format/html/format-html-scss.ts b/src/format/html/format-html-scss.ts index b355249c6d9..5516df9fd3c 100644 --- a/src/format/html/format-html-scss.ts +++ b/src/format/html/format-html-scss.ts @@ -19,7 +19,12 @@ import { mergeLayers, sassLayer } from "../../core/sass.ts"; import { outputVariable, SassVariable, sassVariable } from "../../core/sass.ts"; -import { Format, SassBundle, SassLayer } from "../../config/types.ts"; +import { + Format, + SassBundle, + SassBundleWithBrand, + SassLayer, +} from "../../config/types.ts"; import { Metadata } from "../../config/types.ts"; import { kGrid, kTheme } from "../../config/constants.ts"; @@ -56,6 +61,7 @@ import { sassUtilFunctions, } from "./format-html-shared.ts"; import { readHighlightingTheme } from "../../quarto-core/text-highlighting.ts"; +import { warn } from "log"; export interface Themes { light: string[]; @@ -65,12 +71,12 @@ export interface Themes { function layerQuartoScss( key: string, dependency: string, - sassLayer: SassLayer, + sassLayer: (SassLayer | "brand")[], format: Format, - darkLayer?: SassLayer, + darkLayer?: (SassLayer | "brand")[], darkDefault?: boolean, loadPaths?: string[], -): SassBundle { +): SassBundleWithBrand { // Compose the base Quarto SCSS const uses = quartoUses(); const defaults = [ @@ -155,7 +161,7 @@ export function resolveBootstrapScss( input: string, format: Format, sassLayers: SassLayer[], -): SassBundle[] { +): SassBundleWithBrand[] { // Quarto built in css const quartoThemesDir = formatResourcePath( "html", @@ -173,7 +179,7 @@ export function resolveBootstrapScss( ); // Find light and dark sass layers - const sassBundles: SassBundle[] = []; + const sassBundles: SassBundleWithBrand[] = []; // light sassBundles.push( @@ -192,27 +198,33 @@ export function resolveBootstrapScss( } export interface ThemeSassLayer { - light: SassLayer; - dark?: SassLayer; + light: (SassLayer | "brand")[]; + dark?: (SassLayer | "brand")[]; } function layerTheme( input: string, themes: string[], quartoThemesDir: string, -): { layers: SassLayer[]; loadPaths: string[] } { +): { layers: (SassLayer | "brand")[]; loadPaths: string[] } { let injectedCustomization = false; const loadPaths: string[] = []; const layers = themes.flatMap((theme) => { const isAbs = isAbsolute(theme); const isScssFile = [".scss", ".css"].includes(extname(theme)); - if (isAbs && isScssFile) { + if (theme === "brand") { + // provide a brand order marker for downstream + // processing to know where to insert the brand scss + return "brand"; + } else if (isAbs && isScssFile) { // Absolute path to a SCSS file if (existsSync(theme)) { const themeDir = dirname(theme); loadPaths.push(themeDir); return sassLayer(theme); + } else { + warn(`Theme file not found: ${theme}`); } } else if (isScssFile) { // Relative path to a SCSS file @@ -221,6 +233,8 @@ function layerTheme( const themeDir = dirname(themePath); loadPaths.push(themeDir); return sassLayer(themePath); + } else { + warn(`Theme file not found: ${themePath}`); } } else { // The directory for this theme @@ -429,10 +443,8 @@ function resolveThemeLayer( } const themeSassLayer = { - light: mergeLayers(...lightLayerContext.layers), - dark: darkLayerContext?.layers - ? mergeLayers(...darkLayerContext?.layers) - : undefined, + light: lightLayerContext.layers, + dark: darkLayerContext?.layers, }; const loadPaths = [ diff --git a/src/format/reveal/format-reveal-theme.ts b/src/format/reveal/format-reveal-theme.ts index 2b1b6fcd21c..b4e12f25844 100644 --- a/src/format/reveal/format-reveal-theme.ts +++ b/src/format/reveal/format-reveal-theme.ts @@ -13,6 +13,7 @@ import { kTextHighlightingMode, Metadata, SassBundleLayers, + SassBundleLayersWithBrand, SassLayer, } from "../../config/types.ts"; @@ -40,7 +41,7 @@ import { asCssFont, asCssNumber } from "../../core/css.ts"; import { cssHasDarkModeSentinel } from "../../core/pandoc/css.ts"; import { pandocNativeStr } from "../../core/pandoc/codegen.ts"; import { ProjectContext } from "../../project/types.ts"; -import { brandRevealSassBundleLayers } from "../../core/sass/brand.ts"; +import { brandRevealSassLayers } from "../../core/sass/brand.ts"; import { md5HashBytes } from "../../core/hash.ts"; export const kRevealLightThemes = [ @@ -108,18 +109,28 @@ export async function revealTheme( join(cssThemeDir, "template"), ]; + const brandLayers: SassLayer[] = await brandRevealSassLayers( + input, + format, + project, + ); + // theme is either user provided scss or something in our 'themes' dir // (note that standard reveal scss themes must be converted to quarto // theme format so they can participate in the pipeline) const themeConfig = (format.metadata?.[kTheme] as string | string[] | undefined) || "default"; + let usedBrandLayers = false; const themeLayers = (Array.isArray(themeConfig) ? themeConfig : [themeConfig]) .map( (theme) => { const themePath = join(relative(Deno.cwd(), dirname(input)), theme); - if (existsSync(themePath)) { + if (themePath === "brand") { + usedBrandLayers = true; + return brandLayers; + } else if (existsSync(themePath)) { loadPaths.unshift(join(dirname(input), dirname(theme))); - return themeLayer(themePath); + return [themeLayer(themePath)]; } else { // alias revealjs theme names if (theme === "white") { @@ -132,11 +143,13 @@ export async function revealTheme( "revealjs", join("themes", `${theme}.scss`), ); - return themeLayer(theme); + return [themeLayer(theme)]; } }, - ); - + ).flat(); + if (!usedBrandLayers) { + themeLayers.unshift(...brandLayers); + } // get any variables defined in yaml const yamlLayer: SassLayer = { uses: "", @@ -174,7 +187,7 @@ export async function revealTheme( // create sass bundle layers const bundleLayers: SassBundleLayers = { key: "reveal-theme", - user: mergeLayers(...userLayers), + user: userLayers, quarto: mergeLayers( ...quartoLayers, ), @@ -182,14 +195,8 @@ export async function revealTheme( loadPaths, }; - const brandLayers: SassBundleLayers[] = await brandRevealSassBundleLayers( - input, - format, - project, - ); - // compile sass - const css = await compileSass([bundleLayers, ...brandLayers], temp); + const css = await compileSass([bundleLayers], temp); // Remove sourcemap information cleanSourceMappingUrl(css); // convert from string to bytes diff --git a/tests/docs/smoke-all/2022/10/14/invalid-highlight-theme.qmd b/tests/docs/smoke-all/2022/10/14/invalid-highlight-theme.qmd index 033588b7152..66b126ef581 100644 --- a/tests/docs/smoke-all/2022/10/14/invalid-highlight-theme.qmd +++ b/tests/docs/smoke-all/2022/10/14/invalid-highlight-theme.qmd @@ -8,7 +8,10 @@ format: # code-fold: true # code-line-numbers: true highlight-style: asa - +_quarto: + tests: + html: + noErrors: true --- ```r