Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/format/html/format-html-scss.ts
6450 views
1
/*
2
* format-html-scss.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { existsSync } from "../../deno_ral/fs.ts";
8
import { dirname, extname, isAbsolute, join } from "../../deno_ral/path.ts";
9
10
import { formatResourcePath } from "../../core/resources.ts";
11
import {
12
asBootstrapColor,
13
asCssColor,
14
asCssFont,
15
asCssNumber,
16
asCssSize,
17
} from "../../core/css.ts";
18
import { mergeLayers, sassLayer } from "../../core/sass.ts";
19
20
import { outputVariable, SassVariable, sassVariable } from "../../core/sass.ts";
21
22
import {
23
Format,
24
SassBundle,
25
SassBundleWithBrand,
26
SassLayer,
27
} from "../../config/types.ts";
28
import { Metadata } from "../../config/types.ts";
29
import { kGrid, kTheme } from "../../config/constants.ts";
30
31
import {
32
kPageFooter,
33
kSiteNavbar,
34
kSiteSidebar,
35
kWebsite,
36
} from "../../project/types/website/website-constants.ts";
37
import {
38
bootstrapFunctions,
39
bootstrapMixins,
40
bootstrapResourceDir,
41
bootstrapRules,
42
bootstrapVariables,
43
bslibComponentMixins,
44
bslibComponentRules,
45
bslibResourceDir,
46
htmlToolsRules,
47
kBootstrapDependencyName,
48
quartoBootstrapCustomizationLayer,
49
quartoBootstrapFunctions,
50
quartoBootstrapMixins,
51
quartoBootstrapRules,
52
quartoCodeFilenameRules,
53
quartoCopyCodeDefaults,
54
quartoCopyCodeRules,
55
quartoDefaults,
56
quartoFunctions,
57
quartoGlobalCssVariableRules,
58
quartoLinkExternalRules,
59
quartoRules,
60
quartoUses,
61
sassUtilFunctions,
62
} from "./format-html-shared.ts";
63
import { readHighlightingTheme } from "../../quarto-core/text-highlighting.ts";
64
import { warn } from "log";
65
66
export interface Themes {
67
light: string[];
68
dark?: string[];
69
}
70
71
function layerQuartoScss(
72
key: string,
73
dependency: string,
74
sassLayer: (SassLayer | "brand")[],
75
format: Format,
76
darkLayer?: (SassLayer | "brand")[],
77
darkDefault?: boolean,
78
loadPaths?: string[],
79
): SassBundleWithBrand {
80
// Compose the base Quarto SCSS
81
const uses = quartoUses();
82
const defaults = [
83
quartoDefaults(format),
84
quartoBootstrapDefaults(format.metadata),
85
quartoCopyCodeDefaults(),
86
].join("\n");
87
const mixins = [quartoBootstrapMixins()].join("\n");
88
const functions = [quartoFunctions(), quartoBootstrapFunctions()].join("\n");
89
const rules = [
90
quartoRules(),
91
quartoCopyCodeRules(),
92
quartoBootstrapRules(),
93
quartoGlobalCssVariableRules(),
94
quartoLinkExternalRules(),
95
quartoCodeFilenameRules(),
96
].join("\n");
97
const quartoScss = {
98
uses,
99
defaults,
100
functions,
101
mixins,
102
rules,
103
};
104
105
// Compose the framework level SCSS (bootstrap)
106
// The bootstrap framework functions
107
const frameworkFunctions = [
108
bootstrapFunctions(),
109
sassUtilFunctions("color-contrast.scss"),
110
].join(
111
"\n",
112
);
113
114
// The bootstrap framework variables
115
const frameworkVariables = [
116
bootstrapVariables(),
117
pandocVariablesToThemeScss(format.metadata),
118
].join("\n");
119
120
const frameworkMixins = [bootstrapMixins(), bslibComponentMixins()].join(
121
"\n",
122
);
123
const frameworkRules = [
124
bootstrapRules(),
125
bslibComponentRules(),
126
htmlToolsRules(),
127
].join("\n");
128
const bootstrapScss = {
129
uses: "",
130
defaults: frameworkVariables,
131
functions: frameworkFunctions,
132
mixins: frameworkMixins,
133
rules: frameworkRules,
134
};
135
136
// Compute the load paths
137
const resolvedLoadPaths = [
138
...(loadPaths || []),
139
bootstrapResourceDir(),
140
bslibResourceDir(),
141
];
142
143
return {
144
dependency,
145
key,
146
user: sassLayer,
147
quarto: quartoScss,
148
framework: bootstrapScss,
149
loadPaths: resolvedLoadPaths,
150
dark: darkLayer
151
? {
152
user: darkLayer,
153
default: darkDefault,
154
}
155
: undefined,
156
attribs: { id: "quarto-bootstrap" },
157
};
158
}
159
160
export function resolveBootstrapScss(
161
input: string,
162
format: Format,
163
sassLayers: SassLayer[],
164
): SassBundleWithBrand[] {
165
// Quarto built in css
166
const quartoThemesDir = formatResourcePath(
167
"html",
168
join("bootstrap", "themes"),
169
);
170
171
// Resolve the provided themes to a set of variables and styles
172
const theme = format.metadata[kTheme] || [];
173
const [themeSassLayers, defaultDark, loadPaths] = resolveThemeLayer(
174
format,
175
input,
176
theme,
177
quartoThemesDir,
178
sassLayers,
179
);
180
181
// Find light and dark sass layers
182
const sassBundles: SassBundleWithBrand[] = [];
183
184
// light
185
sassBundles.push(
186
layerQuartoScss(
187
"quarto-theme",
188
kBootstrapDependencyName,
189
themeSassLayers.light,
190
format,
191
themeSassLayers.dark,
192
defaultDark,
193
loadPaths,
194
),
195
);
196
197
return sassBundles;
198
}
199
200
export interface ThemeSassLayer {
201
light: (SassLayer | "brand")[];
202
dark?: (SassLayer | "brand")[];
203
}
204
205
function layerTheme(
206
input: string,
207
themes: string[],
208
quartoThemesDir: string,
209
): { layers: (SassLayer | "brand")[]; loadPaths: string[] } {
210
let injectedCustomization = false;
211
const loadPaths: string[] = [];
212
const layers = themes.flatMap((theme) => {
213
const isAbs = isAbsolute(theme);
214
const isScssFile = [".scss", ".css"].includes(extname(theme));
215
216
if (theme === "brand") {
217
// provide a brand order marker for downstream
218
// processing to know where to insert the brand scss
219
return "brand";
220
} else if (isAbs && isScssFile) {
221
// Absolute path to a SCSS file
222
if (existsSync(theme)) {
223
const themeDir = dirname(theme);
224
loadPaths.push(themeDir);
225
return sassLayer(theme);
226
} else {
227
warn(`Theme file not found: ${theme}`);
228
}
229
} else if (isScssFile) {
230
// Relative path to a SCSS file
231
const themePath = join(dirname(input), theme);
232
if (existsSync(themePath)) {
233
const themeDir = dirname(themePath);
234
loadPaths.push(themeDir);
235
return sassLayer(themePath);
236
} else {
237
warn(`Theme file not found: ${themePath}`);
238
}
239
} else {
240
// The directory for this theme
241
const resolvedThemePath = join(quartoThemesDir, `${theme}.scss`);
242
// Read the sass layers
243
if (existsSync(resolvedThemePath)) {
244
// The theme appears to be a built in theme
245
246
// The theme layer from a built in theme
247
const themeLayer = sassLayer(resolvedThemePath);
248
249
// Inject customization of the theme (this should go just after the theme)
250
injectedCustomization = true;
251
return [themeLayer, quartoBootstrapCustomizationLayer()];
252
}
253
}
254
return {
255
uses: "",
256
defaults: "",
257
functions: "",
258
mixins: "",
259
rules: "",
260
};
261
});
262
263
// If no themes were provided, we still should inject our customization
264
if (!injectedCustomization) {
265
layers.unshift(quartoBootstrapCustomizationLayer());
266
}
267
return { layers, loadPaths };
268
}
269
270
export function resolveTextHighlightingLayer(
271
input: string,
272
format: Format,
273
style: "dark" | "light",
274
) {
275
const layer = {
276
uses: "",
277
defaults: "",
278
functions: "",
279
mixins: "",
280
rules: "",
281
};
282
283
const themeDescriptor = readHighlightingTheme(
284
dirname(input),
285
format.pandoc,
286
style,
287
);
288
289
if (format.metadata[kCodeBlockBackground] === undefined) {
290
// Inject a background color, if present
291
if (themeDescriptor && !themeDescriptor.isAdaptive) {
292
const backgroundColor = () => {
293
if (themeDescriptor.json["background-color"]) {
294
return themeDescriptor.json["background-color"] as string;
295
} else {
296
const editorColors = themeDescriptor.json["editor-colors"] as
297
| Record<string, string>
298
| undefined;
299
if (editorColors && editorColors["BackgroundColor"]) {
300
return editorColors["BackgroundColor"] as string;
301
} else {
302
return undefined;
303
}
304
}
305
};
306
307
const background = backgroundColor();
308
if (background) {
309
layer.defaults = outputVariable(
310
sassVariable(
311
"code-block-bg",
312
asCssColor(background),
313
),
314
true,
315
);
316
}
317
318
const textColor = themeDescriptor.json["text-color"] as string;
319
if (textColor) {
320
layer.defaults = layer.defaults + "\n" + outputVariable(
321
sassVariable(
322
"code-block-color",
323
asCssColor(textColor),
324
),
325
true,
326
);
327
}
328
}
329
}
330
331
if (themeDescriptor) {
332
const readTextColor = (name: string) => {
333
const textStyles = themeDescriptor.json["text-styles"];
334
if (textStyles && typeof textStyles === "object") {
335
const commentColor = (textStyles as Record<string, unknown>)[name];
336
if (commentColor && typeof commentColor === "object") {
337
const textColor =
338
(commentColor as Record<string, unknown>)["text-color"];
339
return textColor;
340
} else {
341
return undefined;
342
}
343
} else {
344
return undefined;
345
}
346
};
347
348
const commentColor = readTextColor("Comment");
349
if (commentColor) {
350
layer.defaults = layer.defaults + "\n" + outputVariable(
351
sassVariable(
352
"btn-code-copy-color",
353
asCssColor(commentColor),
354
),
355
true,
356
);
357
}
358
359
const functionColor = readTextColor("Function");
360
if (functionColor) {
361
layer.defaults = layer.defaults + "\n" + outputVariable(
362
sassVariable(
363
"btn-code-copy-color-active",
364
asCssColor(functionColor),
365
),
366
true,
367
);
368
}
369
}
370
371
return layer;
372
}
373
374
// Resolve the themes into a ThemeSassLayer
375
function resolveThemeLayer(
376
format: Format,
377
input: string,
378
themes: string | string[] | Themes | unknown,
379
quartoThemesDir: string,
380
sassLayers: SassLayer[],
381
): [ThemeSassLayer, boolean, string[]] {
382
let theme = undefined;
383
let defaultDark = false;
384
385
if (typeof themes === "string") {
386
// The themes is just a string
387
theme = { light: [themes] };
388
} else if (Array.isArray(themes)) {
389
// The themes is an array
390
theme = { light: themes };
391
} else if (typeof themes === "object") {
392
// The themes are an object - look at each key and
393
// deal with them either as a string or a string[]
394
const themeArr = (theme?: unknown): string[] => {
395
const themes: string[] = [];
396
if (theme) {
397
if (typeof theme === "string") {
398
themes.push(theme);
399
} else if (Array.isArray(theme)) {
400
themes.push(...theme);
401
}
402
}
403
return themes;
404
};
405
406
const themeObj = themes as Record<string, unknown>;
407
408
// See whether the dark or light theme is the default
409
const keyList = Object.keys(themeObj);
410
defaultDark = keyList.length > 1 && keyList[0] === "dark";
411
412
theme = {
413
light: themeArr(themeObj.light),
414
dark: themeObj.dark ? themeArr(themeObj.dark) : undefined,
415
};
416
} else {
417
theme = { light: [] };
418
}
419
const lightLayerContext = layerTheme(input, theme.light, quartoThemesDir);
420
lightLayerContext.layers.unshift(...sassLayers);
421
const highlightingLayer = resolveTextHighlightingLayer(
422
input,
423
format,
424
"light",
425
);
426
if (highlightingLayer) {
427
lightLayerContext.layers.unshift(highlightingLayer);
428
}
429
430
const darkLayerContext = theme.dark
431
? layerTheme(input, theme.dark, quartoThemesDir)
432
: undefined;
433
if (darkLayerContext) {
434
darkLayerContext.layers.unshift(...sassLayers);
435
const darkHighlightingLayer = resolveTextHighlightingLayer(
436
input,
437
format,
438
"dark",
439
);
440
if (darkHighlightingLayer) {
441
darkLayerContext.layers.unshift(darkHighlightingLayer);
442
}
443
}
444
445
const themeSassLayer = {
446
light: lightLayerContext.layers,
447
dark: darkLayerContext?.layers,
448
};
449
450
const loadPaths = [
451
...lightLayerContext.loadPaths,
452
...darkLayerContext?.loadPaths || [],
453
];
454
return [themeSassLayer, defaultDark, loadPaths];
455
}
456
457
function pandocVariablesToThemeDefaults(
458
metadata: Metadata,
459
): SassVariable[] {
460
const explicitVars: SassVariable[] = [];
461
462
// Helper for adding explicitly set variables
463
const add = (
464
defaults: SassVariable[],
465
name: string,
466
value?: unknown,
467
formatter?: (val: unknown) => unknown,
468
) => {
469
if (value) {
470
const sassVar = sassVariable(name, value, formatter);
471
defaults.push(sassVar);
472
}
473
};
474
475
// Pass through to some bootstrap variables
476
add(explicitVars, "line-height-base", metadata["linestretch"], asCssNumber);
477
add(explicitVars, "font-size-root", metadata["fontsize"]);
478
add(explicitVars, "body-bg", metadata["backgroundcolor"]);
479
add(explicitVars, "body-color", metadata["fontcolor"]);
480
add(explicitVars, "link-color", metadata["linkcolor"]);
481
add(explicitVars, "font-family-base", metadata["mainfont"], asCssFont);
482
add(explicitVars, "font-family-code", metadata["monofont"], asCssFont);
483
add(explicitVars, "mono-background-color", metadata["monobackgroundcolor"]);
484
485
// Deal with sizes
486
const explicitSizes = [
487
"max-width",
488
"margin-top",
489
"margin-bottom",
490
"margin-left",
491
"margin-right",
492
];
493
explicitSizes.forEach((attrib) => {
494
add(explicitVars, attrib, metadata[attrib], asCssSize);
495
});
496
497
// Resolve any grid variables
498
const gridObj = metadata[kGrid] as Metadata;
499
if (gridObj) {
500
add(explicitVars, "grid-sidebar-width", gridObj["sidebar-width"]);
501
add(explicitVars, "grid-margin-width", gridObj["margin-width"]);
502
add(explicitVars, "grid-body-width", gridObj["body-width"]);
503
add(explicitVars, "grid-column-gutter-width", gridObj["gutter-width"]);
504
}
505
return explicitVars;
506
}
507
508
function pandocVariablesToThemeScss(
509
metadata: Metadata,
510
asDefaults = false,
511
) {
512
return pandocVariablesToThemeDefaults(metadata).map(
513
(variable) => {
514
return outputVariable(variable, asDefaults);
515
},
516
).join("\n");
517
}
518
519
const kCodeBorderLeft = "code-block-border-left";
520
const kCodeBlockBackground = "code-block-bg";
521
const kBackground = "background";
522
const kForeground = "foreground";
523
const kTogglePosition = "toggle-position";
524
const kColor = "color";
525
const kBorder = "border";
526
527
// Quarto variables and styles
528
export const quartoBootstrapDefaults = (metadata: Metadata) => {
529
const varFilePath = formatResourcePath(
530
"html",
531
join("bootstrap", "_bootstrap-variables.scss"),
532
);
533
const variables = [Deno.readTextFileSync(varFilePath)];
534
const colorDefaults: string[] = [];
535
536
const navbar = (metadata[kWebsite] as Metadata)?.[kSiteNavbar];
537
if (navbar && typeof navbar === "object") {
538
// Forward navbar background color
539
const navbarBackground = (navbar as Record<string, unknown>)[kBackground];
540
if (navbarBackground !== undefined) {
541
resolveBootstrapColorDefault(navbarBackground, colorDefaults);
542
variables.push(
543
outputVariable(
544
sassVariable(
545
"navbar-bg",
546
navbarBackground,
547
typeof navbarBackground === "string" ? asBootstrapColor : undefined,
548
),
549
),
550
);
551
}
552
553
// Forward navbar foreground color
554
const navbarForeground = (navbar as Record<string, unknown>)[kForeground];
555
if (navbarForeground !== undefined) {
556
resolveBootstrapColorDefault(navbarForeground, colorDefaults);
557
variables.push(
558
outputVariable(
559
sassVariable(
560
"navbar-fg",
561
navbarForeground,
562
typeof navbarForeground === "string" ? asBootstrapColor : undefined,
563
),
564
),
565
);
566
}
567
568
// Forward the toggle-position
569
const navbarTogglePosition =
570
(navbar as Record<string, unknown>)[kTogglePosition];
571
if (navbarTogglePosition !== undefined) {
572
variables.push(
573
outputVariable(
574
sassVariable(
575
"navbar-toggle-position",
576
navbarTogglePosition,
577
),
578
),
579
);
580
}
581
}
582
583
const sidebars = (metadata[kWebsite] as Metadata)?.[kSiteSidebar];
584
const sidebar = Array.isArray(sidebars)
585
? sidebars[0]
586
: typeof sidebars === "object"
587
? (sidebars as Metadata)
588
: undefined;
589
590
if (sidebar) {
591
// Forward background color
592
const sidebarBackground = sidebar[kBackground];
593
if (sidebarBackground !== undefined) {
594
resolveBootstrapColorDefault(sidebarBackground, colorDefaults);
595
variables.push(
596
outputVariable(
597
sassVariable(
598
"sidebar-bg",
599
sidebarBackground,
600
typeof sidebarBackground === "string"
601
? asBootstrapColor
602
: undefined,
603
),
604
),
605
);
606
} else if (sidebar.style === "floating" || navbar) {
607
// If this is a floating sidebar or there is a navbar present,
608
// default to a body colored sidebar
609
variables.push(
610
`$sidebar-bg: if(variable-exists(body-bg), $body-bg, #fff) !default;`,
611
);
612
}
613
614
// Forward foreground color
615
const sidebarForeground = sidebar[kForeground];
616
if (sidebarForeground !== undefined) {
617
resolveBootstrapColorDefault(sidebarForeground, colorDefaults);
618
variables.push(
619
outputVariable(
620
sassVariable(
621
"sidebar-fg",
622
sidebarForeground,
623
typeof sidebarForeground === "string"
624
? asBootstrapColor
625
: undefined,
626
),
627
),
628
);
629
}
630
631
// Enable the sidebar border for docked by default
632
const sidebarBorder = sidebar[kBorder];
633
variables.push(
634
outputVariable(
635
sassVariable(
636
"sidebar-border",
637
sidebarBorder !== undefined
638
? sidebarBorder
639
: sidebar.style === "docked",
640
),
641
),
642
);
643
} else {
644
// If there is no sidebar, default to body color for any sidebar that may appear
645
variables.push(
646
`$sidebar-bg: if(variable-exists(body-bg), $body-bg, #fff) !default;`,
647
);
648
}
649
650
const footer = (metadata[kWebsite] as Metadata)?.[kPageFooter] as Metadata;
651
if (footer !== undefined && typeof footer === "object") {
652
// Forward footer color
653
const footerBg = footer[kBackground];
654
if (footerBg !== undefined) {
655
resolveBootstrapColorDefault(footerBg, colorDefaults);
656
variables.push(
657
outputVariable(
658
sassVariable(
659
"footer-bg",
660
footerBg,
661
typeof footerBg === "string" ? asBootstrapColor : undefined,
662
),
663
),
664
);
665
}
666
667
// Forward footer foreground
668
const footerFg = footer[kForeground];
669
if (footerFg !== undefined) {
670
resolveBootstrapColorDefault(footerFg, colorDefaults);
671
variables.push(
672
outputVariable(
673
sassVariable(
674
"footer-fg",
675
footerFg,
676
typeof footerFg === "string" ? asBootstrapColor : undefined,
677
),
678
),
679
);
680
}
681
682
// Forward footer border
683
const footerBorder = footer[kBorder];
684
// Enable the border unless it is explicitly disabled
685
const showBorder = footerBorder !== undefined
686
? footerBorder
687
: sidebar?.style === "docked";
688
if (showBorder) {
689
variables.push(
690
outputVariable(
691
sassVariable(
692
"footer-border",
693
true,
694
),
695
),
696
);
697
}
698
699
// If the footer border is a color, set that
700
if (footerBorder !== undefined && typeof footerBorder === "string") {
701
resolveBootstrapColorDefault(footerBorder, colorDefaults);
702
variables.push(
703
outputVariable(
704
sassVariable(
705
"footer-border-color",
706
footerBorder,
707
asBootstrapColor,
708
),
709
),
710
);
711
}
712
713
// Forward any footer color
714
const footerColor = footer[kColor];
715
if (footerColor && typeof footerColor === "string") {
716
resolveBootstrapColorDefault(footerColor, colorDefaults);
717
variables.push(
718
outputVariable(
719
sassVariable(
720
"footer-color",
721
footerColor,
722
asBootstrapColor,
723
),
724
),
725
);
726
}
727
}
728
729
// Forward codeleft-border
730
const codeblockLeftBorder = metadata[kCodeBorderLeft];
731
const codeblockBackground = metadata[kCodeBlockBackground];
732
733
if (codeblockLeftBorder !== undefined) {
734
resolveBootstrapColorDefault(codeblockLeftBorder, colorDefaults);
735
variables.push(
736
outputVariable(
737
sassVariable(
738
kCodeBorderLeft,
739
codeblockLeftBorder,
740
typeof codeblockLeftBorder === "string"
741
? asBootstrapColor
742
: undefined,
743
),
744
),
745
);
746
747
if (codeblockBackground === undefined && codeblockLeftBorder !== false) {
748
variables.push(outputVariable(sassVariable(kCodeBlockBackground, false)));
749
}
750
}
751
752
// code background color
753
if (codeblockBackground !== undefined) {
754
variables.push(outputVariable(sassVariable(
755
kCodeBlockBackground,
756
codeblockBackground,
757
typeof codeblockBackground === "string" ? asBootstrapColor : undefined,
758
)));
759
}
760
761
// Ensure any color variable defaults are present
762
colorDefaults.forEach((colorDefault) => {
763
variables.push(colorDefault);
764
});
765
766
// Any of the variables that we added from metadata should go first
767
// So they provide the defaults
768
return variables.reverse().join("\n");
769
};
770
771
function resolveBootstrapColorDefault(value: unknown, variables: string[]) {
772
if (value) {
773
const variable = bootstrapColorDefault(value);
774
if (
775
variable &&
776
!variables.find((existingVar) => {
777
return existingVar === variable;
778
})
779
) {
780
variables.unshift(variable);
781
}
782
}
783
}
784
785
function bootstrapColorDefault(value: unknown) {
786
if (typeof value === "string") {
787
return bootstrapColorVars[value];
788
}
789
}
790
791
const bootstrapColorVars: Record<string, string> = {
792
primary: "$primary: #0d6efd !default;",
793
secondary: "$secondary: #6c757d !default;",
794
success: "$success: #198754 !default;",
795
info: "$info: #0dcaf0 !default;",
796
warning: "$warning: #ffc107 !default;",
797
danger: "$danger: #dc3545 !default;",
798
light: "$light: #f8f9fa !default;",
799
dark: "$dark: #212529 !default;",
800
};
801
802