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