Skip to content

Commit e7f8dbe

Browse files
committed
scss - include SCSS variables as CSS variables in themes
1 parent d847388 commit e7f8dbe

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1048
-16
lines changed

src/command/render/pandoc-html.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export async function resolveSassBundles(
5353
extras: FormatExtras,
5454
format: Format,
5555
temp: TempContext,
56-
project?: ProjectContext,
56+
project: ProjectContext,
5757
) {
5858
extras = cloneDeep(extras);
5959

@@ -286,6 +286,29 @@ async function resolveQuartoSyntaxHighlighting(
286286
if (themeDescriptor) {
287287
// Other variables that need to be injected (if any)
288288
const extraVariables = extras.html?.[kQuartoCssVariables] || [];
289+
for (let i = 0; i < extraVariables.length; ++i) {
290+
// For the same reason as outlined in https://github.com/rstudio/bslib/issues/1104,
291+
// we need to patch the text to include a semicolon inside the declaration
292+
// if it doesn't have one.
293+
// This happens because scss-parser is brittle, and will fail to parse a declaration
294+
// if it doesn't end with a semicolon.
295+
//
296+
// In addition, we know that some our variables come from the output
297+
// of sassCompile which
298+
// - misses the last semicolon
299+
// - emits a :root declaration
300+
// - triggers the scss-parser bug
301+
// So we'll attempt to target the last declaration in the :root
302+
// block specifically and add a semicolon if it doesn't have one.
303+
let variable = extraVariables[i].trim();
304+
if (
305+
variable.endsWith("}") && variable.startsWith(":root") &&
306+
!variable.match(/.*;\s?}$/)
307+
) {
308+
variable = variable.slice(0, -1) + ";}";
309+
extraVariables[i] = variable;
310+
}
311+
}
289312

290313
// The text highlighting CSS variables
291314
const highlightCss = generateThemeCssVars(themeDescriptor.json);
@@ -308,7 +331,7 @@ async function resolveQuartoSyntaxHighlighting(
308331
// Add this string literal to the rule set, which prevents pandoc
309332
// from inlining this style sheet
310333
// See https://github.com/jgm/pandoc/commit/7c0a80c323f81e6262848bfcfc922301e3f406e0
311-
rules.push(".prevent-inlining { content: '</' }");
334+
rules.push(".prevent-inlining { content: '</'; }");
312335

313336
// Compile the scss
314337
const highlightCssPath = await compileSass(

src/core/sass.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { dartCompile } from "./dart-sass.ts";
1616
import * as ld from "./lodash.ts";
1717
import { lines } from "./text.ts";
1818
import { sassCache } from "./sass/cache.ts";
19+
import { cssVarsBlock } from "./sass/add-css-vars.ts";
1920
import { md5HashBytes } from "./hash.ts";
2021
import { kSourceMappingRegexes } from "../config/constants.ts";
2122
import { writeTextFileSyncPreserveMode } from "./write.ts";
@@ -44,6 +45,7 @@ export function outputVariable(
4445
return `$${variable.name}: ${variable.value}${isDefault ? " !default" : ""};`;
4546
}
4647

48+
let counter: number = 1;
4749
export async function compileSass(
4850
bundles: SassBundleLayers[],
4951
temp: TempContext,
@@ -104,39 +106,83 @@ export async function compileSass(
104106
// * Mixins are available to rules as well
105107
// * Rules may use functions, variables, and mixins
106108
// (theme follows framework so it can override the framework rules)
107-
const scssInput = [
109+
let scssInput = [
110+
'// quarto-scss-analysis-annotation { "origin": null }',
108111
...frameWorkUses,
112+
'// quarto-scss-analysis-annotation { "origin": null }',
109113
...quartoUses,
114+
'// quarto-scss-analysis-annotation { "origin": null }',
110115
...userUses,
116+
'// quarto-scss-analysis-annotation { "origin": null }',
111117
...frameworkFunctions,
118+
'// quarto-scss-analysis-annotation { "origin": null }',
112119
...quartoFunctions,
120+
'// quarto-scss-analysis-annotation { "origin": null }',
113121
...userFunctions,
122+
'// quarto-scss-analysis-annotation { "origin": null }',
114123
...userDefaults.reverse(),
124+
'// quarto-scss-analysis-annotation { "origin": null }',
115125
...quartoDefaults.reverse(),
126+
'// quarto-scss-analysis-annotation { "origin": null }',
116127
...frameworkDefaults.reverse(),
128+
'// quarto-scss-analysis-annotation { "origin": null }',
117129
...frameworkMixins,
130+
'// quarto-scss-analysis-annotation { "origin": null }',
118131
...quartoMixins,
132+
'// quarto-scss-analysis-annotation { "origin": null }',
119133
...userMixins,
134+
'// quarto-scss-analysis-annotation { "origin": null }',
120135
...frameworkRules,
136+
'// quarto-scss-analysis-annotation { "origin": null }',
121137
...quartoRules,
138+
'// quarto-scss-analysis-annotation { "origin": null }',
122139
...userRules,
140+
'// quarto-scss-analysis-annotation { "origin": null }',
123141
].join("\n\n");
124-
125-
const hash = md5HashBytes(new TextEncoder().encode(scssInput));
142+
try {
143+
scssInput += "\n" + cssVarsBlock(scssInput);
144+
} catch (e) {
145+
console.error("Error adding css vars block", e);
146+
Deno.writeTextFileSync("scss-error.scss", scssInput);
147+
console.error(
148+
"This is a Quarto bug.\nPlease consider reporting it at https://github.com/quarto-dev/quarto-cli,\nalong with the scss-error.scss file that can be found in the current working directory.",
149+
);
150+
throw e;
151+
}
126152

127153
// Compile the scss
128-
// Note that you can set this to undefined to bypass the cache entirely
129-
const cacheKey = hash;
130-
// bundles.map((bundle) => bundle.key).join("|") + "-" +
131-
// (minified ? "min" : "nomin");
132-
133-
return await compileWithCache(
154+
const result = await compileWithCache(
134155
scssInput,
135156
loadPaths,
136157
temp,
137158
minified,
138-
cacheKey,
159+
md5HashBytes(new TextEncoder().encode(scssInput)),
160+
);
161+
if (!Deno.env.get("QUARTO_SAVE_SCSS")) {
162+
return result;
163+
}
164+
const partialOutput = Deno.readTextFileSync(result);
165+
// now we attempt to find the SCSS variables in the output
166+
// and inject them back in the SCSS file so that our debug tooling can use them.
167+
const scssToWrite = [scssInput];
168+
const internalVars = Array.from(
169+
partialOutput.matchAll(/(--quarto-scss-export-[^;}]+;?)/g),
170+
).map((m) => m[0]);
171+
const annotation = {
172+
"css-vars": internalVars,
173+
};
174+
scssToWrite.push(
175+
`// quarto-scss-analysis-annotation ${JSON.stringify(annotation)}`,
139176
);
177+
scssInput = scssToWrite.join("\n");
178+
const prefix = Deno.env.get("QUARTO_SAVE_SCSS");
179+
const counterValue = counter++;
180+
Deno.writeTextFileSync(
181+
`${prefix}-${counterValue}.scss`,
182+
scssInput,
183+
);
184+
185+
return result;
140186
}
141187

142188
/*-- scss:uses --*/
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
export const walk = (node: any, cb: (node: any) => unknown) => {
2+
if (!node || typeof node !== "object") return;
3+
if (!cb(node)) {
4+
return;
5+
};
6+
for (const key of Object.keys(node)) {
7+
walk(node[key], cb);
8+
}
9+
}
10+
11+
export const withType = (node: any, func: (ast: any) => any) => {
12+
if (!node?.type) {
13+
return node;
14+
}
15+
return func(node);
16+
}
17+
18+
export const withTypeAndArray = (node: any, func: (ast: any) => any) => {
19+
if (!node?.type) {
20+
return node;
21+
}
22+
if (!node?.children || !Array.isArray(node.children)) {
23+
return node;
24+
}
25+
return func(node);
26+
}
27+
28+
export const filterDeep = (outer: any, cb: (v: any) => boolean): any =>
29+
withType(outer, (ast: any) => {
30+
return Object.fromEntries(Object.entries(ast).map(([k, v]) => {
31+
if (Array.isArray(v)) {
32+
return [k, v.filter(cb).map((v: any) => filterDeep(v, cb))];
33+
} else if (v && typeof v === "object") {
34+
return [k, filterDeep(v, cb)];
35+
} else {
36+
return [k, v];
37+
}
38+
}));
39+
});
40+
41+
export const mapDeep = (outer: any, cb: (mapped: any) => any): any =>
42+
withType(outer, (ast: any) => {
43+
if (Array.isArray(ast.children)) {
44+
ast.children = ast.children.map((v: any) => mapDeep(v, cb));
45+
}
46+
if (Array.isArray(ast.value)) {
47+
ast.value = ast.value.map((v: any) => mapDeep(v, cb));
48+
}
49+
return cb(ast);
50+
});
51+
52+
export const collect = (outer: any, cb: (v: any) => boolean): any[] => {
53+
const results: any = [];
54+
walk(outer, (node: any) => {
55+
if (cb(node)) {
56+
results.push(node);
57+
}
58+
return true;
59+
});
60+
return results;
61+
}
62+
63+
export const annotateNode = (node: any, annotation: Record<string, unknown>) => {
64+
if (!node.annotation) {
65+
node.annotation = {};
66+
}
67+
Object.assign(node.annotation, annotation);
68+
return node;
69+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { withTypeAndArray, filterDeep, mapDeep } from "./ast-utils.ts";
2+
import { isRealContent, isNotPunctuation } from "./remove-nonsemantic-entries.ts";
3+
import { simplifyLineInfo } from "./line-info.ts";
4+
import { explicitlyTagDefaultValues } from "./default-declarations.ts";
5+
import { fixImmediateTypes, tagNamedColors, tagColorConstructors, findDimensionValues } from "./value-types.ts";
6+
import { groupArguments } from "./group-arguments.ts";
7+
import { forwardAnnotations } from "./forward-annotations.ts";
8+
9+
const valueArrayToObjectKeys = (outer: any) =>
10+
withTypeAndArray(outer, (node: any) => {
11+
const keys = node.children.map((v: any) => v.type);
12+
if (keys.length !== new Set(keys).size) {
13+
return node;
14+
}
15+
return {
16+
...node,
17+
...Object.fromEntries(node.children
18+
.map((v: any) => {
19+
const key = v.type;
20+
let children = v.children;
21+
return [key, {...v, children}];
22+
})),
23+
};
24+
});
25+
26+
export const cleanSassAst = (ast: any) => {
27+
// we now attempt to turn this glorified lexer into a real AST
28+
29+
// before everything else, we associate declarations with the
30+
// annotations that tell us which part of the theming system
31+
// they belong to
32+
ast = forwardAnnotations(ast);
33+
34+
// before clearing out the punctuation, group arguments
35+
ast = filterDeep(ast, isRealContent);
36+
ast = mapDeep(ast, groupArguments);
37+
38+
// clear out all the gunk from the AST
39+
ast = filterDeep(ast, isNotPunctuation);
40+
41+
ast = mapDeep(ast, simplifyLineInfo);
42+
ast = mapDeep(ast, explicitlyTagDefaultValues);
43+
ast = mapDeep(ast, findDimensionValues);
44+
ast = mapDeep(ast, fixImmediateTypes);
45+
ast = mapDeep(ast, tagColorConstructors);
46+
ast = mapDeep(ast, tagNamedColors);
47+
48+
// if the value array looks like an array of keys and values,
49+
// insert those values into the parent object
50+
// additionally, in these properties, if the value is an array
51+
// with a single element, remove the array and just use the
52+
// element.
53+
ast = mapDeep(ast, valueArrayToObjectKeys);
54+
55+
return ast;
56+
}

0 commit comments

Comments
 (0)