Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/render/pandoc-html.ts
3584 views
1
/*
2
* pandoc-html.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { join } from "../../deno_ral/path.ts";
8
import { uniqBy } from "../../core/lodash.ts";
9
10
import {
11
Format,
12
FormatExtras,
13
kDependencies,
14
kQuartoCssVariables,
15
kTextHighlightingMode,
16
SassBundle,
17
SassBundleWithBrand,
18
SassLayer,
19
} from "../../config/types.ts";
20
import { ProjectContext } from "../../project/types.ts";
21
22
import { cssImports, cssResources } from "../../core/css.ts";
23
import { cleanSourceMappingUrl, compileSass } from "../../core/sass.ts";
24
25
import { kQuartoHtmlDependency } from "../../format/html/format-html-constants.ts";
26
import {
27
kAbbrevs,
28
readHighlightingTheme,
29
} from "../../quarto-core/text-highlighting.ts";
30
31
import { isHtmlOutput } from "../../config/format.ts";
32
import {
33
cssHasDarkModeSentinel,
34
generateCssKeyValues,
35
} from "../../core/pandoc/css.ts";
36
import { kMinimal } from "../../format/html/format-html-shared.ts";
37
import { kSassBundles } from "../../config/types.ts";
38
import { md5HashBytes } from "../../core/hash.ts";
39
import { InternalError } from "../../core/lib/error.ts";
40
import { assert } from "testing/asserts";
41
import { safeModeFromFile } from "../../deno_ral/fs.ts";
42
import { safeCloneDeep } from "../../core/safe-clone-deep.ts";
43
44
// The output target for a sass bundle
45
// (controls the overall style tag that is emitted)
46
interface SassTarget {
47
name: string;
48
bundles: SassBundle[];
49
attribs: Record<string, string>;
50
}
51
52
export async function resolveSassBundles(
53
inputDir: string,
54
extras: FormatExtras,
55
format: Format,
56
project: ProjectContext,
57
) {
58
extras = safeCloneDeep(extras);
59
60
const mergedBundles: Record<string, SassBundleWithBrand[]> = {};
61
62
// groups the bundles by dependency name
63
const group = (
64
bundles: SassBundleWithBrand[],
65
groupedBundles: Record<string, SassBundleWithBrand[]>,
66
) => {
67
bundles.forEach((bundle) => {
68
if (!groupedBundles[bundle.dependency]) {
69
groupedBundles[bundle.dependency] = [];
70
}
71
groupedBundles[bundle.dependency].push(bundle);
72
});
73
};
74
75
// group available sass bundles
76
if (extras?.["html"]?.[kSassBundles]) {
77
group(extras["html"][kSassBundles], mergedBundles);
78
}
79
80
// Go through and compile the cssPath for each dependency
81
let hasDarkStyles = false;
82
let defaultStyle: "dark" | "light" | undefined = undefined;
83
for (const dependency of Object.keys(mergedBundles)) {
84
// compile the cssPath
85
const bundlesWithBrand = mergedBundles[dependency];
86
// first, pull out the brand-specific layers
87
//
88
// the brand bundle itself doesn't have any 'brand' entries;
89
// those are used to specify where the brand-specific layers should be inserted
90
// in the final bundle.
91
const maybeBrandBundle = bundlesWithBrand.find((bundle) =>
92
bundle.key === "brand"
93
);
94
assert(
95
!maybeBrandBundle ||
96
!maybeBrandBundle.user?.find((v) => v === "brand") &&
97
!maybeBrandBundle.dark?.user?.find((v) => v === "brand"),
98
);
99
const foundBrand = { light: false, dark: false };
100
const bundles: SassBundle[] = bundlesWithBrand.filter((bundle) =>
101
bundle.key !== "brand"
102
).map((bundle) => {
103
const userBrand = bundle.user?.findIndex((layer) => layer === "brand");
104
let cloned = false;
105
if (userBrand && userBrand !== -1) {
106
bundle = safeCloneDeep(bundle);
107
cloned = true;
108
bundle.user!.splice(userBrand, 1, ...(maybeBrandBundle?.user || []));
109
foundBrand.light = true;
110
}
111
const darkBrand = bundle.dark?.user?.findIndex((layer) =>
112
layer === "brand"
113
);
114
if (darkBrand && darkBrand !== -1) {
115
if (!cloned) {
116
bundle = safeCloneDeep(bundle);
117
}
118
bundle.dark!.user!.splice(
119
darkBrand,
120
1,
121
...(maybeBrandBundle?.dark?.user || []),
122
);
123
foundBrand.dark = true;
124
}
125
return bundle as SassBundle;
126
});
127
if (maybeBrandBundle && (!foundBrand.light || !foundBrand.dark)) {
128
bundles.unshift({
129
dependency,
130
key: "brand",
131
user: !foundBrand.light && maybeBrandBundle.user as SassLayer[] || [],
132
dark: !foundBrand.dark && maybeBrandBundle.dark?.user && {
133
user: maybeBrandBundle.dark.user as SassLayer[],
134
default: maybeBrandBundle.dark.default,
135
} || undefined,
136
});
137
}
138
139
// See if any bundles are providing dark specific css
140
const hasDark = bundles.some((bundle) => bundle.dark !== undefined);
141
defaultStyle = bundles.some((bundle) =>
142
bundle.dark !== undefined && bundle.dark.default
143
)
144
? "dark"
145
: "light";
146
let targets: SassTarget[] = [{
147
name: `${dependency}.min.css`,
148
bundles: (bundles as any),
149
attribs: {
150
"append-hash": "true",
151
},
152
}];
153
if (hasDark) {
154
// Note that the other bundle provides light
155
targets[0].attribs = {
156
...targets[0].attribs,
157
...attribForThemeStyle("light"),
158
};
159
160
// Provide a dark bundle for this
161
const darkBundles = bundles.map((bundle) => {
162
bundle = safeCloneDeep(bundle);
163
bundle.user = bundle.dark?.user || bundle.user;
164
bundle.quarto = bundle.dark?.quarto || bundle.quarto;
165
bundle.framework = bundle.dark?.framework || bundle.framework;
166
167
// Mark this bundle with a dark key so it is differentiated from the light theme
168
bundle.key = bundle.key + "-dark";
169
return bundle;
170
});
171
const darkTarget = {
172
name: `${dependency}-dark.min.css`,
173
bundles: darkBundles as any,
174
attribs: {
175
"append-hash": "true",
176
...attribForThemeStyle("dark"),
177
},
178
};
179
if (defaultStyle === "dark") { // light, dark
180
targets.push(darkTarget);
181
} else { // light, dark, light
182
const lightTargetExtra = {
183
...targets[0],
184
attribs: {
185
...targets[0].attribs,
186
class: "quarto-color-scheme-extra",
187
},
188
};
189
190
targets = [
191
targets[0],
192
darkTarget,
193
lightTargetExtra,
194
];
195
}
196
197
hasDarkStyles = true;
198
}
199
200
for (const target of targets) {
201
let cssPath: string | undefined;
202
cssPath = await compileSass(target.bundles, project);
203
// First, Clean CSS
204
cleanSourceMappingUrl(cssPath);
205
// look for a sentinel 'dark' value, extract variables
206
const cssResult = await processCssIntoExtras(cssPath, extras, project);
207
cssPath = cssResult.path;
208
209
// it can happen that processing generate an empty css file (e.g quarto-html deps with Quarto CSS variables)
210
// in that case, no need to insert the cssPath in the dependency
211
if (!cssPath) continue;
212
if (Deno.readTextFileSync(cssPath).length === 0) {
213
continue;
214
}
215
216
// Process attributes (forward on to the target)
217
for (const bundle of target.bundles) {
218
if (bundle.attribs) {
219
for (const key of Object.keys(bundle.attribs)) {
220
if (target.attribs[key] === undefined) {
221
target.attribs[key] = bundle.attribs[key];
222
}
223
}
224
}
225
}
226
target.attribs["data-mode"] = cssResult.dark ? "dark" : "light";
227
228
// Find any imported stylesheets or url references
229
// (These could come from user scss that is merged into our theme, for example)
230
const css = Deno.readTextFileSync(cssPath);
231
const toDependencies = (paths: string[]) => {
232
return paths.map((path) => {
233
return {
234
name: path,
235
path: project ? join(project.dir, path) : path,
236
attribs: target.attribs,
237
};
238
});
239
};
240
const resources = toDependencies(cssResources(css));
241
const imports = toDependencies(cssImports(css));
242
243
// Push the compiled Css onto the dependency
244
const extraDeps = extras.html?.[kDependencies];
245
246
if (extraDeps) {
247
const existingDependency = extraDeps.find((extraDep) =>
248
extraDep.name === dependency
249
);
250
251
let targetName = target.name;
252
if (target.attribs["append-hash"] === "true") {
253
const hashFragment = `-${await md5HashBytes(
254
Deno.readFileSync(cssPath),
255
)}`;
256
let extension = "";
257
if (target.name.endsWith(".min.css")) {
258
extension = ".min.css";
259
} else if (target.name.endsWith(".css")) {
260
extension = ".css";
261
} else {
262
throw new InternalError("Unexpected target name: " + target.name);
263
}
264
targetName =
265
targetName.slice(0, target.name.length - extension.length) +
266
hashFragment + extension;
267
} else {
268
targetName = target.name;
269
}
270
271
if (existingDependency) {
272
if (!existingDependency.stylesheets) {
273
existingDependency.stylesheets = [];
274
}
275
existingDependency.stylesheets.push({
276
name: targetName,
277
path: cssPath,
278
attribs: target.attribs,
279
});
280
281
// Add any css references
282
existingDependency.stylesheets.push(...imports);
283
existingDependency.resources?.push(...resources);
284
} else {
285
extraDeps.push({
286
name: dependency,
287
stylesheets: [{
288
name: targetName,
289
path: cssPath,
290
attribs: target.attribs,
291
}, ...imports],
292
resources,
293
});
294
}
295
}
296
}
297
}
298
299
// light only: light
300
// author prefers dark: light, dark
301
// author prefers light: light, dark, light
302
extras = await resolveQuartoSyntaxHighlighting(
303
inputDir,
304
extras,
305
format,
306
project,
307
hasDarkStyles ? "light" : "default",
308
defaultStyle,
309
);
310
311
if (hasDarkStyles) {
312
// find the last entry, for the light highlight stylesheet
313
// so we can duplicate it below.
314
// (note we must do this before adding the dark highlight stylesheet)
315
const lightDep = extras.html?.[kDependencies]?.find((extraDep) =>
316
extraDep.name === kQuartoHtmlDependency
317
);
318
const lightEntry = lightDep?.stylesheets &&
319
lightDep.stylesheets[lightDep.stylesheets.length - 1];
320
extras = await resolveQuartoSyntaxHighlighting(
321
inputDir,
322
extras,
323
format,
324
project,
325
"dark",
326
defaultStyle,
327
);
328
if (defaultStyle === "light") {
329
const dep2 = extras.html?.[kDependencies]?.find((extraDep) =>
330
extraDep.name === kQuartoHtmlDependency
331
);
332
assert(dep2?.stylesheets && lightEntry);
333
dep2.stylesheets.push({
334
...lightEntry,
335
attribs: {
336
...lightEntry.attribs,
337
class: "quarto-color-scheme-extra",
338
},
339
});
340
}
341
}
342
343
if (isHtmlOutput(format.pandoc, true)) {
344
// We'll take care of text highlighting for HTML
345
setTextHighlightStyle("none", extras);
346
}
347
348
return extras;
349
}
350
351
// Generates syntax highlighting Css and Css variables
352
async function resolveQuartoSyntaxHighlighting(
353
inputDir: string,
354
extras: FormatExtras,
355
format: Format,
356
project: ProjectContext,
357
style: "dark" | "light" | "default",
358
defaultStyle?: "dark" | "light",
359
) {
360
// if
361
const minimal = format.metadata[kMinimal] === true;
362
if (minimal) {
363
return extras;
364
}
365
366
extras = safeCloneDeep(extras);
367
368
// If we're using default highlighting, use theme darkness to select highlight style
369
const mediaAttr = attribForThemeStyle(style);
370
if (style === "default") {
371
if (extras.html?.[kTextHighlightingMode] === "dark") {
372
style = "dark";
373
}
374
}
375
mediaAttr.id = "quarto-text-highlighting-styles";
376
377
// Generate and inject the text highlighting css
378
const cssFileName = `quarto-syntax-highlighting${
379
style === "dark" ? "-dark" : ""
380
}`;
381
382
// Read the highlight style (theme name)
383
const themeDescriptor = readHighlightingTheme(inputDir, format.pandoc, style);
384
if (themeDescriptor) {
385
// Other variables that need to be injected (if any)
386
const extraVariables = extras.html?.[kQuartoCssVariables] || [];
387
for (let i = 0; i < extraVariables.length; ++i) {
388
// For the same reason as outlined in https://github.com/rstudio/bslib/issues/1104,
389
// we need to patch the text to include a semicolon inside the declaration
390
// if it doesn't have one.
391
// This happens because scss-parser is brittle, and will fail to parse a declaration
392
// if it doesn't end with a semicolon.
393
//
394
// In addition, we know that some our variables come from the output
395
// of sassCompile which
396
// - misses the last semicolon
397
// - emits a :root declaration
398
// - triggers the scss-parser bug
399
// So we'll attempt to target the last declaration in the :root
400
// block specifically and add a semicolon if it doesn't have one.
401
let variable = extraVariables[i].trim();
402
if (
403
variable.endsWith("}") && variable.startsWith(":root") &&
404
!variable.match(/.*;\s?}$/)
405
) {
406
variable = variable.slice(0, -1) + ";}";
407
extraVariables[i] = variable;
408
}
409
}
410
411
// The text highlighting CSS variables
412
const highlightCss = generateThemeCssVars(themeDescriptor.json);
413
if (highlightCss) {
414
const rules = [
415
highlightCss,
416
"",
417
"/* other quarto variables */",
418
...extraVariables,
419
];
420
421
// The text highlighting CSS rules
422
const textHighlightCssRules = generateThemeCssClasses(
423
themeDescriptor.json,
424
);
425
if (textHighlightCssRules) {
426
rules.push(...textHighlightCssRules);
427
}
428
429
// Add this string literal to the rule set, which prevents pandoc
430
// from inlining this style sheet
431
// See https://github.com/jgm/pandoc/commit/7c0a80c323f81e6262848bfcfc922301e3f406e0
432
rules.push(".prevent-inlining { content: '</'; }");
433
434
// Compile the scss
435
const highlightCssPath = await compileSass(
436
[{
437
key: cssFileName + ".css",
438
quarto: {
439
uses: "",
440
defaults: "",
441
functions: "",
442
mixins: "",
443
rules: rules.join("\n"),
444
},
445
}],
446
project,
447
false,
448
);
449
450
// Find the bootstrap or quarto-html dependency and inject this stylesheet
451
const extraDeps = extras.html?.[kDependencies];
452
if (extraDeps) {
453
// Inject an scss variable for setting the background color of code blocks
454
// with defaults, before the other bootstrap variables?
455
// don't put it in css (basically use the value to set the default), allow
456
// default to be override by user
457
458
const quartoDependency = extraDeps.find((extraDep) =>
459
extraDep.name === kQuartoHtmlDependency
460
);
461
const existingDependency = quartoDependency;
462
if (existingDependency) {
463
existingDependency.stylesheets = existingDependency.stylesheets ||
464
[];
465
466
const hash = await md5HashBytes(Deno.readFileSync(highlightCssPath));
467
existingDependency.stylesheets.push({
468
name: cssFileName + `-${hash}.css`,
469
path: highlightCssPath,
470
attribs: mediaAttr,
471
});
472
}
473
}
474
}
475
}
476
return extras;
477
}
478
479
// Generates CSS variables based upon the syntax highlighting rules in a theme file
480
function generateThemeCssVars(
481
themeJson: Record<string, unknown>,
482
) {
483
const textStyles = themeJson["text-styles"] as Record<
484
string,
485
Record<string, unknown>
486
>;
487
if (textStyles) {
488
const lines: string[] = [];
489
lines.push("/* quarto syntax highlight colors */");
490
lines.push(":root {");
491
Object.keys(textStyles).forEach((styleName) => {
492
const abbr = kAbbrevs[styleName];
493
if (abbr) {
494
const textValues = textStyles[styleName];
495
Object.keys(textValues).forEach((textAttr) => {
496
switch (textAttr) {
497
case "text-color":
498
lines.push(
499
` --quarto-hl-${abbr}-color: ${
500
textValues[textAttr] ||
501
"inherit"
502
};`,
503
);
504
break;
505
}
506
});
507
}
508
});
509
lines.push("}");
510
return lines.join("\n");
511
}
512
return undefined;
513
}
514
515
// Generates CSS rules based upon the syntax highlighting rules in a theme file
516
function generateThemeCssClasses(
517
themeJson: Record<string, unknown>,
518
) {
519
const textStyles = themeJson["text-styles"] as Record<
520
string,
521
Record<string, unknown>
522
>;
523
if (textStyles) {
524
const otherLines: string[] = [];
525
otherLines.push("/* syntax highlight based on Pandoc's rules */");
526
const tokenCssByAbbr: Record<string, string[]> = {};
527
528
const toCSS = function (
529
abbr: string,
530
styleName: string,
531
cssValues: string[],
532
) {
533
const lines: string[] = [];
534
lines.push(`/* ${styleName} */`);
535
lines.push(`\ncode span${abbr !== "" ? `.${abbr}` : ""} {`);
536
cssValues.forEach((value) => {
537
lines.push(` ${value}`);
538
});
539
lines.push("}\n");
540
541
// Store by abbreviation for sorting later
542
tokenCssByAbbr[abbr] = lines;
543
};
544
545
Object.keys(textStyles).forEach((styleName) => {
546
const abbr = kAbbrevs[styleName];
547
if (abbr !== undefined) {
548
const textValues = textStyles[styleName];
549
const cssValues = generateCssKeyValues(textValues);
550
551
toCSS(abbr, styleName, cssValues);
552
553
if (abbr == "") {
554
[
555
"pre > code.sourceCode > span",
556
"code.sourceCode > span",
557
"div.sourceCode,\ndiv.sourceCode pre.sourceCode",
558
]
559
.forEach((selector) => {
560
otherLines.push(`\n${selector} {`);
561
otherLines.push(...cssValues);
562
otherLines.push("}\n");
563
});
564
}
565
}
566
});
567
568
// Sort tokenCssLines by abbr and flatten them
569
// Ensure empty abbr ("") comes first by using a custom sort function
570
const sortedTokenCssLines: string[] = [];
571
Object.keys(tokenCssByAbbr)
572
.sort((a, b) => {
573
// Empty string ("") should come first
574
if (a === "") return -1;
575
if (b === "") return 1;
576
// Otherwise normal alphabetical sort
577
return a.localeCompare(b);
578
})
579
.forEach((abbr) => {
580
sortedTokenCssLines.push(...tokenCssByAbbr[abbr]);
581
});
582
583
// return otherLines followed by tokenCssLines (now sorted by abbr)
584
return otherLines.concat(sortedTokenCssLines);
585
}
586
return undefined;
587
}
588
589
interface CSSResult {
590
path: string | undefined;
591
dark: boolean;
592
}
593
594
// Processes CSS into format extras (scanning for variables and removing them)
595
async function processCssIntoExtras(
596
cssPath: string,
597
extras: FormatExtras,
598
project: ProjectContext,
599
): Promise<CSSResult> {
600
const { temp } = project;
601
extras.html = extras.html || {};
602
603
const css = Deno.readTextFileSync(cssPath);
604
605
// Extract dark sentinel value
606
const hasDarkSentinel = cssHasDarkModeSentinel(css);
607
if (!extras.html[kTextHighlightingMode] && hasDarkSentinel) {
608
setTextHighlightStyle("dark", extras);
609
}
610
611
// Extract variables
612
const matches = css.matchAll(kVariablesRegex);
613
if (matches) {
614
extras.html[kQuartoCssVariables] = extras.html[kQuartoCssVariables] || [];
615
let dirty = false;
616
for (const match of matches) {
617
const variables = match[1];
618
extras.html[kQuartoCssVariables]?.push(variables);
619
dirty = true;
620
}
621
622
// Don't include duplicate variables
623
extras.html[kQuartoCssVariables] = uniqBy(
624
extras.html[kQuartoCssVariables],
625
(val: string) => {
626
return val;
627
},
628
);
629
630
if (dirty) {
631
const cleanedCss = css.replaceAll(kVariablesRegex, "");
632
let newCssPath: string | undefined;
633
if (cleanedCss.trim() === "") {
634
newCssPath = undefined;
635
} else {
636
const hash = await md5HashBytes(new TextEncoder().encode(cleanedCss));
637
newCssPath = temp.createFile({ suffix: `-${hash}.css` });
638
Deno.writeTextFileSync(newCssPath, cleanedCss, {
639
mode: safeModeFromFile(cssPath),
640
});
641
}
642
643
return {
644
dark: hasDarkSentinel,
645
path: newCssPath,
646
};
647
}
648
}
649
return {
650
dark: hasDarkSentinel,
651
path: cssPath,
652
};
653
}
654
const kVariablesRegex =
655
/\/\*\! quarto-variables-start \*\/([\S\s]*)\/\*\! quarto-variables-end \*\//g;
656
657
// Attributes for the style tag
658
function attribForThemeStyle(
659
style: "dark" | "light" | "default",
660
): Record<string, string> {
661
const colorModeAttrs = (mode: string) => {
662
const attr: Record<string, string> = {
663
class: `quarto-color-scheme${
664
mode === "dark" ? " quarto-color-alternate" : ""
665
}`,
666
};
667
return attr;
668
};
669
670
switch (style) {
671
case "dark":
672
return colorModeAttrs("dark");
673
case "light":
674
return colorModeAttrs("light");
675
case "default":
676
default:
677
return {};
678
}
679
}
680
681
// Note the text highlight style in extras
682
export function setTextHighlightStyle(
683
style: "light" | "dark" | "none",
684
extras: FormatExtras,
685
) {
686
extras.html = extras.html || {};
687
extras.html[kTextHighlightingMode] = style;
688
}
689
690