Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/format/reveal/format-reveal.ts
12926 views
1
/*
2
* format-reveal.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
import { join } from "../../deno_ral/path.ts";
7
8
import { Document, Element, NodeType } from "../../core/deno-dom.ts";
9
import {
10
kBrandMode,
11
kCodeLineNumbers,
12
kFrom,
13
kHtmlMathMethod,
14
kIncludeInHeader,
15
kLinkCitations,
16
kReferenceLocation,
17
kRevealJsScripts,
18
kSlideLevel,
19
} from "../../config/constants.ts";
20
21
import {
22
Format,
23
kHtmlPostprocessors,
24
kMarkdownAfterBody,
25
kTextHighlightingMode,
26
Metadata,
27
PandocFlags,
28
} from "../../config/types.ts";
29
import { BrandNamedLogo, Zod } from "../../resources/types/zod/schema-types.ts";
30
31
import { mergeConfigs } from "../../core/config.ts";
32
import { formatResourcePath } from "../../core/resources.ts";
33
import { renderEjs } from "../../core/ejs.ts";
34
import { findParent } from "../../core/html.ts";
35
import { createHtmlPresentationFormat } from "../formats-shared.ts";
36
import { pandocFormatWith } from "../../core/pandoc/pandoc-formats.ts";
37
import { htmlFormatExtras } from "../html/format-html.ts";
38
import { revealPluginExtras } from "./format-reveal-plugin.ts";
39
import { RevealPluginScript } from "./format-reveal-plugin-types.ts";
40
import { revealTheme } from "./format-reveal-theme.ts";
41
import {
42
revealMuliplexPreviewFile,
43
revealMultiplexExtras,
44
} from "./format-reveal-multiplex.ts";
45
import {
46
insertFootnotesTitle,
47
removeFootnoteBacklinks,
48
} from "../html/format-html-shared.ts";
49
import {
50
HtmlPostProcessResult,
51
RenderServices,
52
} from "../../command/render/types.ts";
53
import {
54
kAutoAnimateDuration,
55
kAutoAnimateEasing,
56
kAutoAnimateUnmatched,
57
kAutoStretch,
58
kCenter,
59
kCenterTitleSlide,
60
kControlsAuto,
61
kHashType,
62
kJumpToSlide,
63
kPdfMaxPagesPerSlide,
64
kPdfSeparateFragments,
65
kPreviewLinksAuto,
66
kRevealJsConfig,
67
kScrollable,
68
kScrollActivationWidth,
69
kScrollLayout,
70
kScrollProgress,
71
kScrollProgressAuto,
72
kScrollSnap,
73
kScrollView,
74
kSlideFooter,
75
kSlideLogo,
76
kView,
77
} from "./constants.ts";
78
import { revealMetadataFilter } from "./metadata.ts";
79
import { ProjectContext } from "../../project/types.ts";
80
import { titleSlidePartial } from "./format-reveal-title.ts";
81
import { registerWriterFormatHandler } from "../format-handlers.ts";
82
import { pandocNativeStr } from "../../core/pandoc/codegen.ts";
83
import { logoAddLeadingSlashes, resolveLogo } from "../../core/brand/brand.ts";
84
85
export function revealResolveFormat(format: Format) {
86
format.metadata = revealMetadataFilter(format.metadata);
87
88
// map "vertical" navigation mode to "default"
89
if (format.metadata["navigationMode"] === "vertical") {
90
format.metadata["navigationMode"] = "default";
91
}
92
93
// normalize scroll-view to map to revealjs configuration
94
const scrollView = format.metadata[kScrollView];
95
if (typeof scrollView === "boolean" && scrollView) {
96
// if scroll-view is true then set view to scroll by default
97
// using all default option
98
format.metadata[kView] = "scroll";
99
} else if (typeof scrollView === "object") {
100
// if scroll-view is an object then map to revealjs configuration individually
101
const scrollViewRecord = scrollView as Record<string, unknown>;
102
// Only activate scroll by default when ask explicitly
103
if (scrollViewRecord["activate"] === true) {
104
format.metadata[kView] = "scroll";
105
}
106
if (scrollViewRecord["progress"] !== undefined) {
107
format.metadata[kScrollProgress] = scrollViewRecord["progress"];
108
}
109
if (scrollViewRecord["snap"] !== undefined) {
110
format.metadata[kScrollSnap] = scrollViewRecord["snap"];
111
}
112
if (scrollViewRecord["layout"] !== undefined) {
113
format.metadata[kScrollLayout] = scrollViewRecord["layout"];
114
}
115
if (scrollViewRecord["activation-width"] !== undefined) {
116
format.metadata[kScrollActivationWidth] =
117
scrollViewRecord["activation-width"];
118
}
119
}
120
// remove scroll-view from metadata
121
delete format.metadata[kScrollView];
122
123
// Handle scrollProgress "auto" for the template.
124
// Pandoc templates render BoolVal as true/false literals, but "auto" needs
125
// to be a quoted string. A helper variable scrollProgressAuto handles this.
126
// When no scrollProgress is specified and view is "scroll", default to "auto"
127
// (RevealJS default) rather than Pandoc's defField default of true.
128
if (format.metadata[kView] === "scroll") {
129
if (
130
format.metadata[kScrollProgress] === "auto" ||
131
format.metadata[kScrollProgress] === undefined
132
) {
133
format.metadata[kScrollProgressAuto] = true;
134
delete format.metadata[kScrollProgress];
135
}
136
}
137
}
138
139
export function revealjsFormat() {
140
return mergeConfigs(
141
createHtmlPresentationFormat("RevealJS", 10, 5),
142
{
143
pandoc: {
144
[kHtmlMathMethod]: {
145
method: "mathjax",
146
url:
147
"https://cdn.jsdelivr.net/npm/[email protected]/MathJax.js?config=TeX-AMS_HTML-full",
148
},
149
[kSlideLevel]: 2,
150
},
151
render: {
152
[kCodeLineNumbers]: true,
153
},
154
metadata: {
155
[kAutoStretch]: true,
156
},
157
resolveFormat: revealResolveFormat,
158
formatPreviewFile: revealMuliplexPreviewFile,
159
formatExtras: async (
160
input: string,
161
_markdown: string,
162
flags: PandocFlags,
163
format: Format,
164
libDir: string,
165
services: RenderServices,
166
offset: string,
167
project: ProjectContext,
168
) => {
169
// render styles template based on options
170
const stylesFile = services.temp.createFile({ suffix: ".html" });
171
const styles = renderEjs(
172
formatResourcePath("revealjs", "styles.html"),
173
{ [kScrollable]: format.metadata[kScrollable] },
174
);
175
Deno.writeTextFileSync(stylesFile, styles);
176
177
// specify controlsAuto if there is no boolean 'controls'
178
const metadataOverride: Metadata = {};
179
const controlsAuto = typeof (format.metadata["controls"]) !== "boolean";
180
if (controlsAuto) {
181
metadataOverride.controls = false;
182
}
183
184
// specify previewLinksAuto if there is no boolean 'previewLinks'
185
const previewLinksAuto = format.metadata["previewLinks"] === "auto";
186
if (previewLinksAuto) {
187
metadataOverride.previewLinks = false;
188
}
189
190
// additional options not supported by pandoc
191
const extraConfig: Record<string, unknown> = {
192
[kControlsAuto]: controlsAuto,
193
[kPreviewLinksAuto]: previewLinksAuto,
194
[kPdfSeparateFragments]: !!format.metadata[kPdfSeparateFragments],
195
[kAutoAnimateEasing]: format.metadata[kAutoAnimateEasing] || "ease",
196
[kAutoAnimateDuration]: format.metadata[kAutoAnimateDuration] ||
197
1.0,
198
[kAutoAnimateUnmatched]:
199
format.metadata[kAutoAnimateUnmatched] !== undefined
200
? format.metadata[kAutoAnimateUnmatched]
201
: true,
202
[kJumpToSlide]: format.metadata[kJumpToSlide] !== undefined
203
? !!format.metadata[kJumpToSlide]
204
: true,
205
};
206
207
if (format.metadata[kPdfMaxPagesPerSlide]) {
208
extraConfig[kPdfMaxPagesPerSlide] =
209
format.metadata[kPdfMaxPagesPerSlide];
210
}
211
212
// Scroll view settings (view, scrollProgress, scrollSnap, scrollLayout,
213
// scrollActivationWidth) are rendered by the template via metadata
214
// variables set in revealResolveFormat().
215
216
// get theme info (including text highlighing mode)
217
const theme = await revealTheme(
218
format,
219
input,
220
libDir,
221
project,
222
);
223
224
const revealPluginData = await revealPluginExtras(
225
input,
226
format,
227
flags,
228
services.temp,
229
theme.revealUrl,
230
theme.revealDestDir,
231
services.extension,
232
project,
233
); // Add plugin scripts to metadata for template to use
234
235
// Provide a template context
236
const templateDir = formatResourcePath("revealjs", "pandoc");
237
const partials = [
238
"toc-slide.html",
239
titleSlidePartial(format),
240
];
241
const templateContext = {
242
template: join(templateDir, "template.html"),
243
partials: partials.map((partial) => join(templateDir, partial)),
244
};
245
246
// start with html format extras and our standard & plugin extras
247
let extras = mergeConfigs(
248
// extras for all html formats
249
await htmlFormatExtras(
250
input,
251
flags,
252
offset,
253
format,
254
services.temp,
255
project,
256
{
257
tabby: true,
258
anchors: false,
259
copyCode: true,
260
hoverCitations: true,
261
hoverFootnotes: true,
262
hoverXrefs: false,
263
figResponsive: false,
264
}, // tippy options
265
{
266
parent: "section.slide",
267
config: {
268
offset: [0, 0],
269
maxWidth: 700,
270
},
271
},
272
{
273
quartoBase: false,
274
},
275
),
276
// default extras for reveal
277
{
278
args: [],
279
pandoc: {},
280
metadata: {
281
[kLinkCitations]: true,
282
[kRevealJsScripts]: revealPluginData.pluginInit.scripts.map(
283
(script) => {
284
// escape to avoid pandoc markdown parsing from YAML default file
285
// https://github.com/quarto-dev/quarto-cli/issues/9117
286
return pandocNativeStr(script.path).mappedString().value;
287
},
288
),
289
} as Metadata,
290
metadataOverride,
291
templateContext,
292
[kIncludeInHeader]: [
293
stylesFile,
294
],
295
html: {
296
[kHtmlPostprocessors]: [
297
revealHtmlPostprocessor(
298
format,
299
extraConfig,
300
revealPluginData.pluginInit,
301
theme["text-highlighting-mode"],
302
),
303
],
304
[kMarkdownAfterBody]: [revealMarkdownAfterBody(format, input)],
305
},
306
},
307
);
308
309
extras.metadataOverride = {
310
...extras.metadataOverride,
311
...theme.metadata,
312
};
313
extras.html![kTextHighlightingMode] = theme[kTextHighlightingMode];
314
315
// add plugins
316
extras = mergeConfigs(
317
revealPluginData.extras,
318
extras,
319
);
320
321
// add multiplex if we have it
322
const multiplexExtras = revealMultiplexExtras(format, flags);
323
if (multiplexExtras) {
324
extras = mergeConfigs(extras, multiplexExtras);
325
}
326
327
// provide alternate defaults unless the user requests revealjs defaults
328
if (format.metadata[kRevealJsConfig] !== "default") {
329
// detect whether we are using vertical slides
330
const navigationMode = format.metadata["navigationMode"];
331
const verticalSlides = navigationMode === "default" ||
332
navigationMode === "grid";
333
334
// if the user set slideNumber to true then provide
335
// linear slides (if they haven't specified vertical slides)
336
if (format.metadata["slideNumber"] === true) {
337
extras.metadataOverride!["slideNumber"] = verticalSlides
338
? "h.v"
339
: "c/t";
340
}
341
342
// opinionated version of reveal config defaults
343
extras.metadata = {
344
...extras.metadata,
345
...revealMetadataFilter({
346
width: 1050,
347
height: 700,
348
margin: 0.1,
349
center: false,
350
navigationMode: "linear",
351
controlsLayout: "edges",
352
controlsTutorial: false,
353
hash: true,
354
history: true,
355
hashOneBasedIndex: false,
356
fragmentInURL: false,
357
transition: "none",
358
backgroundTransition: "none",
359
pdfSeparateFragments: false,
360
}),
361
};
362
}
363
364
// Scroll-view defaults (only when view is "scroll").
365
// Set explicitly so the template $if/$else$ type guards always have
366
// values and don't depend on Pandoc's defField.
367
if (format.metadata[kView] === "scroll") {
368
extras.metadata = {
369
...extras.metadata,
370
[kScrollSnap]: "mandatory",
371
[kScrollLayout]: "full",
372
[kScrollActivationWidth]: 0,
373
};
374
}
375
376
// hash-type: number (as shorthand for -auto_identifiers)
377
if (format.metadata[kHashType] === "number") {
378
extras.pandoc = {
379
...extras.pandoc,
380
from: pandocFormatWith(
381
format.pandoc[kFrom] || "markdown",
382
"",
383
"-auto_identifiers",
384
),
385
};
386
}
387
388
// return extras
389
return extras;
390
},
391
},
392
);
393
}
394
395
function revealMarkdownAfterBody(format: Format, input: string) {
396
let brandMode: "light" | "dark" = "light";
397
if (format.metadata[kBrandMode] === "dark") {
398
brandMode = "dark";
399
}
400
const lines: string[] = [];
401
lines.push("::: {.quarto-auto-generated-content style='display: none;'}\n");
402
const revealLogo = format
403
.metadata[kSlideLogo] as (string | { path: string } | undefined);
404
let logo = resolveLogo(format.render.brand, revealLogo, [
405
"small",
406
"medium",
407
"large",
408
]);
409
if (logo && logo[brandMode]) {
410
logo = logoAddLeadingSlashes(logo, format.render.brand, input);
411
const modeLogo = logo![brandMode]!;
412
const altText = modeLogo.alt ? `alt="${modeLogo.alt}" ` : "";
413
lines.push(
414
`<img src="${modeLogo.path}" ${altText}class="slide-logo" />`,
415
);
416
lines.push("\n");
417
}
418
lines.push("::: {.footer .footer-default}");
419
if (format.metadata[kSlideFooter]) {
420
lines.push(String(format.metadata[kSlideFooter]));
421
} else {
422
lines.push("");
423
}
424
lines.push(":::");
425
lines.push("\n");
426
lines.push(":::");
427
lines.push("\n");
428
429
return lines.join("\n");
430
}
431
432
const handleOutputLocationSlide = (
433
doc: Document,
434
slideHeadingTags: string[],
435
) => {
436
// find output-location-slide and inject slides as required
437
const slideOutputs = doc.querySelectorAll(`.${kOutputLocationSlide}`);
438
for (const slideOutput of slideOutputs) {
439
// find parent slide
440
const slideOutputEl = slideOutput as Element;
441
const parentSlide = findParentSlide(slideOutputEl);
442
if (parentSlide && parentSlide.parentElement) {
443
const newSlide = doc.createElement("section");
444
newSlide.setAttribute(
445
"id",
446
parentSlide?.id ? parentSlide.id + "-output" : "",
447
);
448
for (const clz of parentSlide.classList) {
449
newSlide.classList.add(clz);
450
}
451
newSlide.classList.add(kOutputLocationSlide);
452
// repeat header if there is one
453
if (
454
slideHeadingTags.includes(parentSlide.firstElementChild?.tagName || "")
455
) {
456
const headingEl = doc.createElement(
457
parentSlide.firstElementChild?.tagName!,
458
);
459
headingEl.innerHTML = parentSlide.firstElementChild?.innerHTML || "";
460
newSlide.appendChild(headingEl);
461
}
462
newSlide.appendChild(slideOutputEl);
463
// Place the new slide after the current one
464
const nextSlide = parentSlide.nextElementSibling;
465
parentSlide.parentElement.insertBefore(newSlide, nextSlide);
466
}
467
}
468
};
469
470
const handleHashTypeNumber = (
471
doc: Document,
472
format: Format,
473
) => {
474
// if we are using 'number' as our hash type then remove the
475
// title slide id
476
if (format.metadata[kHashType] === "number") {
477
const titleSlide = doc.getElementById("title-slide");
478
if (titleSlide) {
479
titleSlide.removeAttribute("id");
480
// required for title-slide-style: pandoc
481
titleSlide.classList.add("quarto-title-block");
482
}
483
}
484
};
485
486
const handleAutoGeneratedContent = (doc: Document) => {
487
// Move quarto auto-generated content outside of slides and hide it
488
// Content is moved with appendChild in quarto-support plugin
489
const slideContentFromQuarto = doc.querySelector(
490
".quarto-auto-generated-content",
491
);
492
if (slideContentFromQuarto) {
493
doc.querySelector("div.reveal")?.appendChild(slideContentFromQuarto);
494
}
495
};
496
497
type RevealJsPluginInit = {
498
scripts: RevealPluginScript[];
499
register: string[];
500
revealConfig: Record<string, unknown>;
501
};
502
503
const fixupRevealJsInitialization = (
504
doc: Document,
505
extraConfig: Record<string, unknown>,
506
pluginInit: RevealJsPluginInit,
507
) => {
508
// find reveal initialization and perform fixups
509
const scripts = doc.querySelectorAll("script");
510
for (const script of scripts) {
511
const scriptEl = script as Element;
512
if (
513
scriptEl.innerText &&
514
scriptEl.innerText.indexOf("Reveal.initialize({") !== -1
515
) {
516
// quote slideNumber
517
scriptEl.innerText = scriptEl.innerText.replace(
518
/slideNumber: (h[\.\/]v|c(?:\/t)?)/,
519
"slideNumber: '$1'",
520
);
521
522
// quote width and heigh if in %
523
scriptEl.innerText = scriptEl.innerText.replace(
524
/width: (\d+(\.\d+)?%)/,
525
"width: '$1'",
526
);
527
scriptEl.innerText = scriptEl.innerText.replace(
528
/height: (\d+(\.\d+)?%)/,
529
"height: '$1'",
530
);
531
532
// plugin registration
533
if (pluginInit.register.length > 0) {
534
const kRevealPluginArray = "plugins: [";
535
scriptEl.innerText = scriptEl.innerText.replace(
536
kRevealPluginArray,
537
kRevealPluginArray + pluginInit.register.join(", ") + ",\n",
538
);
539
}
540
541
// Write any additional configuration of reveal
542
const configJs: string[] = [];
543
Object.keys(extraConfig).forEach((key) => {
544
configJs.push(
545
`'${key}': ${JSON.stringify(extraConfig[key])}`,
546
);
547
});
548
549
// Plugin initialization
550
Object.keys(pluginInit.revealConfig).forEach((key) => {
551
configJs.push(
552
`'${key}': ${JSON.stringify(pluginInit.revealConfig[key])}`,
553
);
554
});
555
556
const configStr = configJs.join(",\n");
557
558
scriptEl.innerText = scriptEl.innerText.replace(
559
"Reveal.initialize({",
560
`Reveal.initialize({\n${configStr},\n`,
561
);
562
}
563
}
564
};
565
const kOutputLocationSlide = "output-location-slide";
566
567
const handleInvisibleSlides = (doc: Document) => {
568
// remove slides with data-visibility=hidden
569
const invisibleSlides = doc.querySelectorAll(
570
'section.slide[data-visibility="hidden"]',
571
);
572
for (let i = invisibleSlides.length - 1; i >= 0; i--) {
573
const slide = invisibleSlides.item(i);
574
// remove from toc
575
const id = (slide as Element).id;
576
if (id) {
577
const tocEntry = doc.querySelector(
578
'nav[role="doc-toc"] a[href="#/' + id + '"]',
579
);
580
if (tocEntry) {
581
tocEntry.parentElement?.remove();
582
}
583
}
584
585
// remove slide
586
slide.parentNode?.removeChild(slide);
587
}
588
};
589
590
const handleUntitledSlidesInToc = (doc: Document) => {
591
// remove from toc all slides that have no title
592
const tocEntries = Array.from(doc.querySelectorAll(
593
'nav[role="doc-toc"] ul > li',
594
));
595
for (const tocEntry of tocEntries) {
596
const tocEntryEl = tocEntry as Element;
597
if (tocEntryEl.textContent.trim() === "") {
598
tocEntryEl.remove();
599
}
600
}
601
};
602
603
const handleSlideHeadingAttributes = (
604
doc: Document,
605
slideHeadingTags: string[],
606
) => {
607
// remove all attributes from slide headings (pandoc has already moved
608
// them to the enclosing section)
609
const slideHeadings = doc.querySelectorAll("section.slide > :first-child");
610
slideHeadings.forEach((slideHeading) => {
611
const slideHeadingEl = slideHeading as Element;
612
if (slideHeadingTags.includes(slideHeadingEl.tagName)) {
613
// remove attributes
614
for (const attrib of slideHeadingEl.getAttributeNames()) {
615
slideHeadingEl.removeAttribute(attrib);
616
// if it's auto-animate then do some special handling
617
if (attrib === "data-auto-animate") {
618
// link slide titles for animation
619
slideHeadingEl.setAttribute("data-id", "quarto-animate-title");
620
// add animation id to code blocks
621
const codeBlocks = slideHeadingEl.parentElement?.querySelectorAll(
622
"div.sourceCode > pre > code",
623
);
624
if (codeBlocks?.length === 1) {
625
const codeEl = codeBlocks.item(0) as Element;
626
const preEl = codeEl.parentElement!;
627
preEl.setAttribute(
628
"data-id",
629
"quarto-animate-code",
630
);
631
// markup with highlightjs classes so that are sucessfully targeted by
632
// autoanimate.js
633
codeEl.classList.add("hljs");
634
codeEl.childNodes.forEach((spanNode) => {
635
if (spanNode.nodeType === NodeType.ELEMENT_NODE) {
636
const spanEl = spanNode as Element;
637
spanEl.classList.add("hljs-ln-code");
638
}
639
});
640
}
641
}
642
}
643
}
644
});
645
};
646
647
const handleCenteredSlides = (doc: Document, format: Format) => {
648
// center title slide if requested
649
// note that disabling title slide centering when the rest of the
650
// slides are centered doesn't currently work b/c reveal consults
651
// the global 'center' config as well as the class. to overcome
652
// this we'd need to always set 'center: false` and then
653
// put the .center classes onto each slide manually. we're not
654
// doing this now the odds a user would want all of their
655
// slides cnetered but NOT the title slide are close to zero
656
if (format.metadata[kCenterTitleSlide] !== false) {
657
const titleSlide = doc.getElementById("title-slide") as Element ??
658
// when hash-type: number, id are removed
659
doc.querySelector(".reveal .slides section.quarto-title-block");
660
if (titleSlide) {
661
titleSlide.classList.add("center");
662
}
663
const titleSlides = doc.querySelectorAll(".title-slide");
664
for (const slide of titleSlides) {
665
(slide as Element).classList.add("center");
666
}
667
}
668
// center other slides if requested
669
if (format.metadata[kCenter] === true) {
670
for (const slide of doc.querySelectorAll("section.slide")) {
671
const slideEl = slide as Element;
672
slideEl.classList.add("center");
673
}
674
}
675
};
676
677
const fixupAssistiveMmlInNotes = (doc: Document) => {
678
// inject css to hide assistive mml in speaker notes (have to do it for each aside b/c the asides are
679
// slurped into speaker mode one at a time using innerHTML) note that we can remvoe this hack when we begin
680
// defaulting to MathJax 3 (after Pandoc updates their template to support Reveal 4.2 / MathJax 3)
681
// see discussion of underlying issue here: https://github.com/hakimel/reveal.js/issues/1726
682
// hack here: https://stackoverflow.com/questions/35534385/mathjax-config-for-web-mobile-and-assistive
683
const notes = doc.querySelectorAll("aside.notes");
684
for (const note of notes) {
685
const style = doc.createElement("style");
686
style.setAttribute("type", "text/css");
687
style.innerHTML = `
688
span.MJX_Assistive_MathML {
689
position:absolute!important;
690
clip: rect(1px, 1px, 1px, 1px);
691
padding: 1px 0 0 0!important;
692
border: 0!important;
693
height: 1px!important;
694
width: 1px!important;
695
overflow: hidden!important;
696
display:block!important;
697
}`;
698
note.appendChild(style);
699
}
700
};
701
702
const coalesceAsides = (doc: Document, slideFootnotes: boolean) => {
703
// collect up asides into a single aside
704
const slides = doc.querySelectorAll("section.slide");
705
for (const slide of slides) {
706
const slideEl = slide as Element;
707
const asides = slideEl.querySelectorAll("aside:not(.notes)");
708
const asideDivs = slideEl.querySelectorAll("div.aside");
709
const footnotes = slideEl.querySelectorAll('a[role="doc-noteref"]');
710
if (asides.length > 0 || asideDivs.length > 0 || footnotes.length > 0) {
711
const aside = doc.createElement("aside");
712
// deno-lint-ignore no-explicit-any
713
const collectAsides = (asideList: any) => {
714
asideList.forEach((asideEl: Element) => {
715
const asideDiv = doc.createElement("div");
716
asideDiv.innerHTML = (asideEl as Element).innerHTML;
717
aside.appendChild(asideDiv);
718
});
719
asideList.forEach((asideEl: Element) => {
720
asideEl.remove();
721
});
722
};
723
// start with asides and div.aside
724
collectAsides(asides);
725
collectAsides(asideDivs);
726
727
// append footnotes
728
if (slideFootnotes && footnotes.length > 0) {
729
const ol = doc.createElement("ol");
730
ol.classList.add("aside-footnotes");
731
footnotes.forEach((note, index) => {
732
const noteEl = note as Element;
733
const href = noteEl.getAttribute("href");
734
if (href) {
735
const noteLi = doc.getElementById(href.replace(/^#\//, ""));
736
if (noteLi) {
737
// remove backlink
738
const footnoteBack = noteLi.querySelector(".footnote-back");
739
if (footnoteBack) {
740
footnoteBack.remove();
741
}
742
ol.appendChild(noteLi);
743
}
744
}
745
const sup = doc.createElement("sup");
746
sup.innerText = (index + 1) + "";
747
noteEl.replaceWith(sup);
748
});
749
aside.appendChild(ol);
750
}
751
752
slide.appendChild(aside);
753
}
754
}
755
};
756
757
const handleSlideFootnotes = (
758
doc: Document,
759
slideFootnotes: boolean,
760
format: Format,
761
slideLevel: number,
762
) => {
763
const footnotes = doc.querySelectorAll('section[role="doc-endnotes"]');
764
if (slideFootnotes) {
765
// we are using slide based footnotes so remove footnotes slide from end
766
for (const footnoteSection of footnotes) {
767
(footnoteSection as Element).remove();
768
}
769
} else {
770
let footnotesId: string | undefined;
771
const footnotes = doc.querySelectorAll('section[role="doc-endnotes"]');
772
if (footnotes.length === 1) {
773
const footnotesEl = footnotes[0] as Element;
774
footnotesId = footnotesEl?.getAttribute("id") || "footnotes";
775
footnotesEl.setAttribute("id", footnotesId);
776
insertFootnotesTitle(doc, footnotesEl, format.language, slideLevel);
777
footnotesEl.classList.add("smaller");
778
footnotesEl.classList.add("scrollable");
779
footnotesEl.classList.remove("center");
780
removeFootnoteBacklinks(footnotesEl);
781
}
782
783
// we are keeping footnotes at the end so disable the links (we use popups)
784
// and tweak the footnotes slide (add a title add smaller/scrollable)
785
const notes = doc.querySelectorAll('a[role="doc-noteref"]');
786
for (const note of notes) {
787
const noteEl = note as Element;
788
noteEl.setAttribute("data-footnote-href", noteEl.getAttribute("href"));
789
noteEl.setAttribute("href", footnotesId ? `#/${footnotesId}` : "");
790
noteEl.setAttribute("onclick", footnotesId ? "" : "return false;");
791
}
792
}
793
};
794
795
const handleRefs = (doc: Document): string | undefined => {
796
// add scrollable to refs slide
797
let refsId: string | undefined;
798
const refs = doc.querySelector("#refs");
799
if (refs) {
800
const refsSlide = findParentSlide(refs);
801
if (refsSlide) {
802
refsId = refsSlide?.getAttribute("id") || "references";
803
refsSlide.setAttribute("id", refsId);
804
}
805
applyClassesToParentSlide(refs, ["smaller", "scrollable"]);
806
removeClassesFromParentSlide(refs, ["center"]);
807
}
808
return refsId;
809
};
810
811
const handleScrollable = (doc: Document, format: Format) => {
812
// #6866: add .scrollable to all sections with ordered lists if format.scrollable is true
813
if (format.metadata[kScrollable] === true) {
814
const ol = doc.querySelectorAll("ol");
815
for (const olEl of ol) {
816
const olParent = findParent(olEl as Element, (el: Element) => {
817
return el.nodeName === "SECTION";
818
});
819
if (olParent) {
820
olParent.classList.add("scrollable");
821
}
822
}
823
}
824
};
825
826
const handleCitationLinks = (doc: Document, refsId: string | undefined) => {
827
// handle citation links
828
const cites = doc.querySelectorAll('a[role="doc-biblioref"]');
829
for (const cite of cites) {
830
const citeEl = cite as Element;
831
citeEl.setAttribute("href", refsId ? `#/${refsId}` : "");
832
citeEl.setAttribute("onclick", refsId ? "" : "return false;");
833
}
834
};
835
836
const handleChalkboard = (result: HtmlPostProcessResult, format: Format) => {
837
// include chalkboard src json if specified
838
const chalkboard = format.metadata["chalkboard"];
839
if (typeof chalkboard === "object") {
840
const chalkboardSrc = (chalkboard as Record<string, unknown>)["src"];
841
if (typeof chalkboardSrc === "string") {
842
result.resources.push(chalkboardSrc);
843
}
844
}
845
};
846
847
const handleAnchors = (doc: Document) => {
848
// Remove anchors on numbered code chunks as they can't work
849
// because ids are used for sections in revealjs
850
const codeLinesAnchors = doc.querySelectorAll(
851
"span[id^='cb'] > a[href^='#c']",
852
);
853
codeLinesAnchors.forEach((codeLineAnchor) => {
854
const codeLineAnchorEl = codeLineAnchor as Element;
855
codeLineAnchorEl.removeAttribute("href");
856
});
857
};
858
859
const handleInterColumnDivSpaces = (doc: Document) => {
860
// https://github.com/quarto-dev/quarto-cli/issues/8498
861
// columns with spaces between them can cause
862
// layout problems when their total width is almost 100%
863
for (const slide of doc.querySelectorAll("section.slide")) {
864
for (const column of (slide as Element).querySelectorAll("div.column")) {
865
const columnEl = column as Element;
866
let next = columnEl.nextSibling;
867
while (
868
next &&
869
next.nodeType === NodeType.TEXT_NODE &&
870
next.textContent?.trim() === ""
871
) {
872
next.parentElement?.removeChild(next);
873
next = columnEl.nextSibling;
874
}
875
}
876
}
877
};
878
879
function revealHtmlPostprocessor(
880
format: Format,
881
extraConfig: Record<string, unknown>,
882
pluginInit: RevealJsPluginInit,
883
highlightingMode: "light" | "dark",
884
) {
885
return (doc: Document): Promise<HtmlPostProcessResult> => {
886
const result: HtmlPostProcessResult = {
887
resources: [],
888
supporting: [],
889
};
890
891
// Remove blockquote scaffolding added in Lua post-render to prevent Pandoc syntax for applying
892
if (doc.querySelectorAll("div.blockquote-list-scaffold")) {
893
const blockquoteListScaffolds = doc.querySelectorAll(
894
"div.blockquote-list-scaffold",
895
);
896
for (const blockquoteListScaffold of blockquoteListScaffolds) {
897
const blockquoteListScaffoldEL = blockquoteListScaffold as Element;
898
const blockquoteListScaffoldParent =
899
blockquoteListScaffoldEL.parentNode;
900
if (blockquoteListScaffoldParent) {
901
while (blockquoteListScaffoldEL.firstChild) {
902
blockquoteListScaffoldParent.insertBefore(
903
blockquoteListScaffoldEL.firstChild,
904
blockquoteListScaffoldEL,
905
);
906
}
907
blockquoteListScaffoldParent.removeChild(blockquoteListScaffoldEL);
908
}
909
}
910
}
911
912
// apply highlighting mode to body
913
doc.body.classList.add("quarto-" + highlightingMode);
914
915
// determine if we are embedding footnotes on slides
916
const slideFootnotes = format.pandoc[kReferenceLocation] !== "document";
917
918
// compute slide level and slide headings
919
const slideLevel = format.pandoc[kSlideLevel] || 2;
920
const slideHeadingTags = Array.from(Array(slideLevel)).map((_e, i) =>
921
"H" + (i + 1)
922
);
923
924
handleOutputLocationSlide(doc, slideHeadingTags);
925
handleHashTypeNumber(doc, format);
926
fixupRevealJsInitialization(doc, extraConfig, pluginInit);
927
handleAutoGeneratedContent(doc);
928
handleInvisibleSlides(doc);
929
handleUntitledSlidesInToc(doc);
930
handleSlideHeadingAttributes(doc, slideHeadingTags);
931
handleCenteredSlides(doc, format);
932
fixupAssistiveMmlInNotes(doc);
933
coalesceAsides(doc, slideFootnotes);
934
handleSlideFootnotes(doc, slideFootnotes, format, slideLevel);
935
const refsId = handleRefs(doc);
936
handleScrollable(doc, format);
937
handleCitationLinks(doc, refsId);
938
// apply stretch to images as required
939
applyStretch(doc, format.metadata[kAutoStretch] as boolean);
940
handleChalkboard(result, format);
941
handleAnchors(doc);
942
handleInterColumnDivSpaces(doc);
943
944
// return result
945
return Promise.resolve(result);
946
};
947
}
948
949
function applyStretch(doc: Document, autoStretch: boolean) {
950
// Add stretch class to images in slides with only one image
951
const allSlides = doc.querySelectorAll("section.slide");
952
for (const slide of allSlides) {
953
const slideEl = slide as Element;
954
955
// opt-out mechanism per slide
956
if (slideEl.classList.contains("nostretch")) continue;
957
958
const images = slideEl.querySelectorAll("img");
959
// only target slides with one image
960
if (images.length === 1) {
961
const image = images[0];
962
const imageEl = image as Element;
963
964
// opt-out if nostrech is applied at image level too
965
if (imageEl.classList.contains("nostretch")) {
966
imageEl.classList.remove("nostretch");
967
continue;
968
}
969
970
if (
971
// screen out early specials divs (layout panels, columns, fragments, ...)
972
findParent(imageEl, (el: Element) => {
973
return el.classList.contains("column") ||
974
el.classList.contains("quarto-layout-panel") ||
975
el.classList.contains("fragment") ||
976
el.classList.contains(kOutputLocationSlide) ||
977
!!el.className.match(/panel-/);
978
}) ||
979
// Do not autostrech if an aside is used
980
slideEl.querySelectorAll("aside:not(.notes)").length !== 0
981
) {
982
continue;
983
}
984
985
// find the first level node that contains the img
986
let selNode: Element | undefined;
987
for (const node of slide.childNodes) {
988
if (node.contains(image)) {
989
selNode = node as Element;
990
break;
991
}
992
}
993
const nodeEl = selNode;
994
995
// Do not apply stretch if this is an inline image among text
996
if (
997
!nodeEl || (nodeEl.nodeName === "P" && nodeEl.childNodes.length > 1)
998
) {
999
continue;
1000
}
1001
1002
const hasStretchClass = function (el: Element): boolean {
1003
return el.classList.contains("stretch") ||
1004
el.classList.contains("r-stretch");
1005
};
1006
1007
// Only apply auto stretch on specific known structures
1008
// and avoid applying automatically on custom divs
1009
if (
1010
// on <p><img> (created by Pandoc)
1011
nodeEl.nodeName === "P" ||
1012
// on quarto figure divs
1013
nodeEl.nodeName === "DIV" &&
1014
nodeEl.classList.contains("quarto-figure") ||
1015
// on computation output created image
1016
nodeEl.nodeName === "DIV" && nodeEl.classList.contains("cell") ||
1017
// on other divs (custom divs) when explicitly opt-in
1018
nodeEl.nodeName === "DIV" && hasStretchClass(nodeEl)
1019
) {
1020
// for custom divs, remove stretch class as it should only be present on img
1021
if (nodeEl.nodeName === "DIV" && hasStretchClass(nodeEl)) {
1022
nodeEl.classList.remove("r-stretch");
1023
nodeEl.classList.remove("stretch");
1024
}
1025
1026
// add stretch class if not already when auto-stretch is set
1027
if (
1028
autoStretch === true &&
1029
!hasStretchClass(imageEl) &&
1030
// if height is already set, we do nothing
1031
!imageEl.getAttribute("style")?.match("height:") &&
1032
!imageEl.hasAttribute("height") &&
1033
// do not add when .absolute is used
1034
!imageEl.classList.contains("absolute") &&
1035
// do not add when image is inside a link
1036
imageEl.parentElement?.nodeName !== "A"
1037
) {
1038
imageEl.classList.add("r-stretch");
1039
}
1040
1041
// If <img class="stretch"> is not a direct child of <section>, move it
1042
if (
1043
hasStretchClass(imageEl) &&
1044
imageEl.parentNode?.nodeName !== "SECTION"
1045
) {
1046
// Remove element then maybe remove its parents if empty
1047
const removeEmpty = function (el: Element) {
1048
const parentEl = el.parentElement;
1049
parentEl?.removeChild(el);
1050
if (
1051
parentEl?.innerText.trim() === "" &&
1052
// Stop at section leveal and do not remove empty slides
1053
parentEl?.nodeName !== "SECTION"
1054
) {
1055
removeEmpty(parentEl);
1056
}
1057
};
1058
1059
// Figure environment ? Get caption, id and alignment
1060
const quartoFig = slideEl.querySelector("div.quarto-figure");
1061
const caption = doc.createElement("p");
1062
if (quartoFig) {
1063
// Get alignment
1064
const align = quartoFig.className.match(
1065
"quarto-figure-(center|left|right)",
1066
);
1067
if (align) imageEl.classList.add(align[0]);
1068
// Get id
1069
const quartoFigId = quartoFig?.id;
1070
if (quartoFigId) imageEl.id = quartoFigId;
1071
// Get Caption
1072
const figCaption = nodeEl.querySelector("figcaption");
1073
if (figCaption) {
1074
caption.classList.add("caption");
1075
caption.innerHTML = figCaption.innerHTML;
1076
}
1077
}
1078
1079
// Target position of image
1080
// first level after the element
1081
const nextEl = nodeEl.nextElementSibling;
1082
// Remove image from its parent
1083
removeEmpty(imageEl);
1084
// insert at target position
1085
slideEl.insertBefore(image, nextEl);
1086
1087
// If there was a caption processed add it after
1088
if (caption.classList.contains("caption")) {
1089
slideEl.insertBefore(
1090
caption,
1091
imageEl.nextElementSibling,
1092
);
1093
}
1094
// Remove container if still there
1095
if (quartoFig) removeEmpty(quartoFig);
1096
}
1097
}
1098
}
1099
}
1100
}
1101
1102
function applyClassesToParentSlide(
1103
el: Element,
1104
classes: string[],
1105
slideClass = "slide",
1106
) {
1107
const slideEl = findParentSlide(el, slideClass);
1108
if (slideEl) {
1109
classes.forEach((clz) => slideEl.classList.add(clz));
1110
}
1111
}
1112
1113
function removeClassesFromParentSlide(
1114
el: Element,
1115
classes: string[],
1116
slideClass = "slide",
1117
) {
1118
const slideEl = findParentSlide(el, slideClass);
1119
if (slideEl) {
1120
classes.forEach((clz) => slideEl.classList.remove(clz));
1121
}
1122
}
1123
1124
function findParentSlide(el: Element, slideClass = "slide") {
1125
return findParent(el, (el: Element) => {
1126
return el.classList.contains(slideClass);
1127
});
1128
}
1129
1130
registerWriterFormatHandler((format) => {
1131
switch (format) {
1132
case "revealjs":
1133
return {
1134
format: revealjsFormat(),
1135
};
1136
}
1137
});
1138
1139