Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/format/html/format-html-bootstrap.ts
6450 views
1
/*
2
* format-html-bootstrap.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { Document, Element, NodeList } from "../../core/deno-dom.ts";
8
import { join } from "../../deno_ral/path.ts";
9
10
import { renderEjs } from "../../core/ejs.ts";
11
import { formatResourcePath } from "../../core/resources.ts";
12
import { findParent } from "../../core/html.ts";
13
14
import {
15
kCodeLinks,
16
kCodeLinksTitle,
17
kContentMode,
18
kDisableArticleLayout,
19
kFormatLinks,
20
kGrid,
21
kHtmlMathMethod,
22
kIncludeInHeader,
23
kLaunchBinderTitle,
24
kLaunchDevContainerTitle,
25
kLinkCitations,
26
kNotebookLinks,
27
kOtherLinks,
28
kOtherLinksTitle,
29
kQuartoTemplateParams,
30
kRelatedFormatsTitle,
31
kSectionDivs,
32
kTocDepth,
33
kTocExpand,
34
kTocLocation,
35
} from "../../config/constants.ts";
36
import {
37
Format,
38
FormatExtras,
39
kBodyEnvelope,
40
kDependencies,
41
kHtmlFinalizers,
42
kHtmlPostprocessors,
43
kSassBundles,
44
Metadata,
45
OtherLink,
46
SassLayer,
47
} from "../../config/types.ts";
48
import { PandocFlags } from "../../config/types.ts";
49
import { hasTableOfContents } from "../../config/toc.ts";
50
51
import { resolveBootstrapScss } from "./format-html-scss.ts";
52
import {
53
formatHasArticleLayout,
54
formatHasFullLayout,
55
formatPageLayout,
56
hasMarginCites,
57
hasMarginFigCaps,
58
hasMarginRefs,
59
kAppendixStyle,
60
kBootstrapDependencyName,
61
kDocumentCss,
62
kPageLayout,
63
kPageLayoutCustom,
64
setMainColumn,
65
} from "./format-html-shared.ts";
66
import {
67
HtmlPostProcessor,
68
HtmlPostProcessResult,
69
PandocInputTraits,
70
RenderedFormat,
71
RenderServices,
72
} from "../../command/render/types.ts";
73
import { processDocumentAppendix } from "./format-html-appendix.ts";
74
import {
75
documentTitleIncludeInHeader,
76
documentTitleMetadata,
77
documentTitlePartial,
78
documentTitleScssLayer,
79
processDocumentTitle,
80
} from "./format-html-title.ts";
81
import { darkModeDefault } from "./format-html-info.ts";
82
83
import { kTemplatePartials } from "../../command/render/template.ts";
84
import { isHtmlOutput } from "../../config/format.ts";
85
import { emplaceNotebookPreviews } from "./format-html-notebook.ts";
86
import { ProjectContext } from "../../project/types.ts";
87
import { AlternateLink, otherFormatLinks } from "./format-html-links.ts";
88
import { warning } from "../../deno_ral/log.ts";
89
import { binderUrl } from "../../core/container.ts";
90
import { codeSpacesUrl } from "../../core/container.ts";
91
92
export function bootstrapFormatDependency() {
93
const boostrapResource = (resource: string) =>
94
formatResourcePath(
95
"html",
96
join("bootstrap", "dist", resource),
97
);
98
const bootstrapDependency = (resource: string) => ({
99
name: resource,
100
path: boostrapResource(resource),
101
});
102
103
return {
104
name: kBootstrapDependencyName,
105
stylesheets: [
106
bootstrapDependency("bootstrap-icons.css"),
107
],
108
scripts: [
109
bootstrapDependency("bootstrap.min.js"),
110
],
111
resources: [
112
bootstrapDependency("bootstrap-icons.woff"),
113
],
114
};
115
}
116
117
export function bootstrapExtras(
118
input: string,
119
flags: PandocFlags,
120
format: Format,
121
services: RenderServices,
122
offset: string | undefined,
123
project: ProjectContext,
124
quiet?: boolean,
125
): FormatExtras {
126
const toc = hasTableOfContents(flags, format);
127
const tocLocation = toc
128
? format.metadata[kTocLocation] || "right"
129
: undefined;
130
131
const renderTemplate = (template: string, pageLayout: string) => {
132
return renderEjs(formatResourcePath("html", `templates/${template}`), {
133
toc,
134
tocLocation,
135
pageLayout,
136
});
137
};
138
139
const pageLayout = formatPageLayout(format);
140
const bodyEnvelope = formatHasArticleLayout(format)
141
? {
142
before: renderTemplate("before-body-article.ejs", pageLayout),
143
afterPreamble: renderTemplate(
144
"after-body-article-preamble.ejs",
145
pageLayout,
146
),
147
afterPostamble: renderTemplate(
148
"after-body-article-postamble.ejs",
149
pageLayout,
150
),
151
}
152
: {
153
before: renderTemplate("before-body-custom.ejs", kPageLayoutCustom),
154
afterPreamble: renderTemplate(
155
"after-body-custom-preamble.ejs",
156
kPageLayoutCustom,
157
),
158
afterPostamble: renderTemplate(
159
"after-body-custom-postamble.ejs",
160
kPageLayoutCustom,
161
),
162
};
163
164
// Gather the title data for this page
165
const { partials, templateParams } = documentTitlePartial(
166
format,
167
);
168
const sassLayers: SassLayer[] = [];
169
const titleSassLayer = documentTitleScssLayer(format);
170
if (titleSassLayer) {
171
sassLayers.push(titleSassLayer);
172
}
173
const includeInHeader: string[] = [];
174
const titleInclude = documentTitleIncludeInHeader(
175
input,
176
format,
177
services.temp,
178
);
179
if (titleInclude) {
180
includeInHeader.push(titleInclude);
181
}
182
183
const titleMetadata = documentTitleMetadata(format);
184
185
const scssBundles = resolveBootstrapScss(input, format, sassLayers);
186
187
return {
188
pandoc: {
189
[kSectionDivs]: true,
190
[kHtmlMathMethod]: "mathjax",
191
},
192
metadata: {
193
[kDocumentCss]: false,
194
[kLinkCitations]: true,
195
[kTemplatePartials]: partials,
196
[kQuartoTemplateParams]: templateParams,
197
...titleMetadata,
198
},
199
[kIncludeInHeader]: includeInHeader,
200
html: {
201
[kSassBundles]: scssBundles,
202
[kDependencies]: [bootstrapFormatDependency()],
203
[kBodyEnvelope]: bodyEnvelope,
204
[kHtmlPostprocessors]: [
205
bootstrapHtmlPostprocessor(
206
input,
207
format,
208
flags,
209
services,
210
offset,
211
project,
212
quiet,
213
),
214
],
215
[kHtmlFinalizers]: [
216
bootstrapHtmlFinalizer(format, flags),
217
],
218
},
219
};
220
}
221
222
// Find any elements that are using fancy layouts (columns)
223
const getColumnLayoutElements = (doc: Document) => {
224
return doc.querySelectorAll(kColumnSelector);
225
};
226
227
const removeColumnClasses = (el: Element) => {
228
for (const cls of allColumnClz) {
229
el.classList.remove(cls);
230
}
231
};
232
233
const removeNestedColumnLayouts = (doc: Document) => {
234
const columnNodes = doc.querySelectorAll(kColumnSelector);
235
columnNodes.forEach((columnNode) => {
236
const columnEl = columnNode as Element;
237
238
// Process nested column layouts
239
if (isInColumnLayout(columnEl, doc, nonScreenColumnClz)) {
240
removeColumnClasses(columnEl);
241
}
242
});
243
};
244
245
const cleanNonsensicalMarginCaps = (doc: Document) => {
246
const marginCapNodes = doc.querySelectorAll(".margin-caption,.margin-ref");
247
marginCapNodes.forEach((capNode) => {
248
const capEl = capNode as Element;
249
if (isInColumnLayout(capEl, doc, removeMarginClz)) {
250
capEl.classList.remove("margin-caption");
251
capEl.classList.remove("margin-ref");
252
}
253
});
254
};
255
256
const isInColumnLayout = (
257
el: Element,
258
doc: Document,
259
clzList: string[],
260
): boolean => {
261
const parent = el.parentElement;
262
if (!parent) {
263
return false;
264
}
265
266
for (const cls of parent.classList) {
267
if (clzList.includes(cls)) {
268
return true;
269
}
270
}
271
return isInColumnLayout(parent, doc, clzList);
272
};
273
274
const kColumnSelector =
275
'[class^="column-"], [class*=" column-"], aside:not(.footnotes):not(.sidebar), [class*="margin-caption"], [class*=" margin-caption"], [class*="margin-ref"], [class*=" margin-ref"]';
276
277
function bootstrapHtmlPostprocessor(
278
input: string,
279
format: Format,
280
flags: PandocFlags,
281
services: RenderServices,
282
offset: string | undefined,
283
project: ProjectContext,
284
quiet?: boolean,
285
): HtmlPostProcessor {
286
return async (
287
doc: Document,
288
options: {
289
inputMetadata: Metadata;
290
inputTraits: PandocInputTraits;
291
renderedFormats: RenderedFormat[];
292
quiet?: boolean;
293
},
294
): Promise<HtmlPostProcessResult> => {
295
// Resources used in this post processor
296
const resources: string[] = [];
297
const supporting: string[] = [];
298
299
// use display-7 style for title
300
const title = doc.querySelector("header > .title");
301
if (title) {
302
title.classList.add("display-7");
303
}
304
305
// add 'lead' to subtitle
306
const subtitle = doc.querySelector("header > .subtitle");
307
if (subtitle) {
308
subtitle.classList.add("lead");
309
}
310
311
// add 'blockquote' class to blockquotes
312
const blockquotes = doc.querySelectorAll("blockquote");
313
for (let i = 0; i < blockquotes.length; i++) {
314
const classList = (blockquotes[i] as Element).classList;
315
classList.add("blockquote");
316
}
317
318
// add figure classes to figures
319
const figures = doc.querySelectorAll("figure");
320
for (let i = 0; i < figures.length; i++) {
321
const figure = figures[i] as Element;
322
figure.classList.add("figure");
323
const images = figure.querySelectorAll("img");
324
for (let j = 0; j < images.length; j++) {
325
(images[j] as Element).classList.add("figure-img");
326
}
327
// const captions = figure.querySelectorAll("figcaption");
328
// for (let j = 0; j < captions.length; j++) {
329
// (captions[j] as Element).classList.add("figure-caption");
330
// }
331
}
332
333
// Ensure that any magin figures / images are marked as fluid
334
// Attempt to fix https://github.com/quarto-dev/quarto-cli/issues/5516
335
const marginImgNodes = doc.querySelectorAll(
336
".column-margin .cell-output-display img:not(.img-fluid)",
337
);
338
for (const marginImgNode of marginImgNodes) {
339
const marginImgEl = marginImgNode as Element;
340
marginImgEl.classList.add("img-fluid");
341
}
342
343
// move the toc if there is a sidebar
344
const toc = doc.querySelector('nav[role="doc-toc"]');
345
346
const tocTarget = doc.getElementById("quarto-toc-target");
347
348
const useDoubleToc =
349
(format.metadata[kTocLocation] as string)?.includes("-body") ?? false;
350
351
if (toc && tocTarget) {
352
if (useDoubleToc) {
353
// Clone the TOC
354
// Leave it where it is in the document, and just mutate it
355
const clonedToc = toc.cloneNode(true) as Element;
356
clonedToc.id = "TOC-body";
357
const tocActionsEl = clonedToc.querySelector(".toc-actions");
358
if (tocActionsEl) {
359
tocActionsEl.remove();
360
}
361
362
toc.parentElement?.insertBefore(clonedToc, toc);
363
}
364
365
// activate selection behavior for this
366
toc.classList.add("toc-active");
367
368
const expanded = format.metadata[kTocExpand];
369
if (expanded !== undefined) {
370
if (expanded === true) {
371
toc.setAttribute("data-toc-expanded", 99);
372
} else if (expanded) {
373
toc.setAttribute("data-toc-expanded", expanded);
374
} else {
375
toc.setAttribute("data-toc-expanded", -1);
376
}
377
}
378
// add nav-link class to the TOC links
379
const tocLinks = doc.querySelectorAll('nav#TOC[role="doc-toc"] > ul a');
380
381
for (let i = 0; i < tocLinks.length; i++) {
382
// Mark the toc links as nav-links
383
const tocLink = tocLinks[i] as Element;
384
tocLink.classList.add("nav-link");
385
if (i === 0) {
386
tocLink.classList.add("active");
387
}
388
389
// move the raw href to the target attribute (need the raw value, not the full path)
390
if (!tocLink.hasAttribute("data-scroll-target")) {
391
tocLink.setAttribute(
392
"data-scroll-target",
393
tocLink.getAttribute("href")?.replaceAll(":", "\\:"),
394
);
395
}
396
}
397
398
// default collapse non-top level TOC nodes
399
const tocDepth = format.pandoc[kTocDepth] || 3;
400
if (tocDepth > 1) {
401
const ulSelector = "ul ".repeat(tocDepth - 1).trim();
402
403
const nestedUls = toc.querySelectorAll(ulSelector);
404
for (let i = 0; i < nestedUls.length; i++) {
405
const ul = nestedUls[i] as Element;
406
ul.classList.add("collapse");
407
}
408
}
409
410
toc.remove();
411
tocTarget.replaceWith(toc);
412
} else {
413
tocTarget?.remove();
414
}
415
416
// Inject links to other formats if there is another
417
// format that of this file that has been rendered
418
if (format.render[kFormatLinks] !== false) {
419
processAlternateFormatLinks(input, options, doc, format, resources);
420
}
421
422
// Look for included / embedded notebooks and include those
423
if (format.render[kNotebookLinks] !== false) {
424
const renderedHtml = options.renderedFormats.find((renderedFormat) => {
425
return isHtmlOutput(renderedFormat.format.pandoc, true);
426
});
427
const notebookResults = await emplaceNotebookPreviews(
428
input,
429
doc,
430
format,
431
services,
432
project,
433
renderedHtml?.path,
434
quiet,
435
);
436
if (notebookResults) {
437
resources.push(...notebookResults.resources);
438
supporting.push(...notebookResults.supporting);
439
}
440
}
441
442
// Process additional links for this document
443
await processOtherLinks(doc, format, project);
444
445
// default treatment for computational tables
446
const addTableClasses = (table: Element, computational = false) => {
447
table.classList.add("table");
448
if (computational) {
449
table.classList.add("table-sm");
450
table.classList.add("table-striped");
451
table.classList.add("small");
452
}
453
};
454
455
// add .table class to pandoc tables
456
const tableHeaders = doc.querySelectorAll("tbody > tr:first-child.odd");
457
for (let i = 0; i < tableHeaders.length; i++) {
458
const th = tableHeaders[i];
459
if (th.parentNode?.parentNode) {
460
const table = th.parentNode.parentNode as Element;
461
table.removeAttribute("style");
462
// see https://github.com/quarto-dev/quarto-cli/issues/6945
463
// for a why we want to check for 'plain' here
464
addTableClasses(
465
table,
466
!!findParent(
467
table,
468
(el) =>
469
el.classList.contains("cell") && !el.classList.contains("plain"),
470
),
471
);
472
}
473
}
474
475
// add .table class to pandas tables
476
const pandasTables = doc.querySelectorAll("table.dataframe");
477
for (let i = 0; i < pandasTables.length; i++) {
478
const table = pandasTables[i] as Element;
479
table.removeAttribute("border");
480
addTableClasses(table, true);
481
const headerRows = table.querySelectorAll("tr");
482
for (let r = 0; r < headerRows.length; r++) {
483
(headerRows[r] as Element).removeAttribute("style");
484
}
485
if (
486
table.previousElementSibling &&
487
table.previousElementSibling.tagName === "STYLE"
488
) {
489
table.previousElementSibling.remove();
490
}
491
}
492
493
// add .table class to DataFrames.jl tables
494
const dataFramesTables = doc.querySelectorAll("table.data-frame");
495
for (let i = 0; i < dataFramesTables.length; i++) {
496
const table = dataFramesTables[i] as Element;
497
addTableClasses(table, true);
498
}
499
500
// provide data-anchor-id to headings
501
const sections = doc.querySelectorAll('section[class^="level"]');
502
for (let i = 0; i < sections.length; i++) {
503
const section = sections[i] as Element;
504
const heading = section.querySelector("h2") ||
505
section.querySelector("h3") || section.querySelector("h4") ||
506
section.querySelector("h5") || section.querySelector("h6");
507
if (heading) {
508
heading.setAttribute("data-anchor-id", section.id);
509
}
510
}
511
512
// Process the title elements of this document
513
const titleResourceFiles = processDocumentTitle(
514
input,
515
format,
516
flags,
517
doc,
518
);
519
resources.push(...titleResourceFiles);
520
521
// put quarto-html-before-body script at top of body
522
const beforeBodyScript = doc.querySelector(
523
"script#quarto-html-before-body",
524
);
525
if (beforeBodyScript) {
526
doc.body.insertBefore(beforeBodyScript, doc.body.firstChild);
527
}
528
529
// Process the elements of this document into an appendix
530
if (
531
format.metadata[kAppendixStyle] !== false &&
532
format.metadata[kAppendixStyle] !== "none"
533
) {
534
await processDocumentAppendix(
535
input,
536
options.inputTraits,
537
format,
538
flags,
539
doc,
540
offset,
541
);
542
}
543
// no resource refs
544
return Promise.resolve({ resources, supporting });
545
};
546
}
547
548
function createLinkChild(formatLink: AlternateLink, doc: Document) {
549
const link = doc.createElement("a");
550
link.setAttribute("href", formatLink.href);
551
if (formatLink.attr) {
552
for (const key of Object.keys(formatLink.attr)) {
553
const value = formatLink.attr[key];
554
link.setAttribute(key, value);
555
}
556
}
557
558
if (formatLink.dlAttrValue) {
559
link.setAttribute("download", formatLink.dlAttrValue);
560
}
561
562
const icon = doc.createElement("i");
563
icon.classList.add("bi");
564
icon.classList.add(`bi-${formatLink.icon}`);
565
link.appendChild(icon);
566
link.appendChild(doc.createTextNode(formatLink.title));
567
568
return link;
569
}
570
571
async function processOtherLinks(
572
doc: Document,
573
format: Format,
574
context?: ProjectContext,
575
) {
576
const processLinks = (
577
otherLinks: OtherLink[],
578
clz: string,
579
title: string,
580
) => {
581
const dlLinkTarget = getLinkTarget(doc, kLinkProvidersOtherLinks);
582
if (otherLinks.length > 0 && dlLinkTarget) {
583
const containerEl = doc.createElement("div");
584
containerEl.classList.add(clz);
585
586
const heading = dlLinkTarget.makeHeadingEl(
587
title,
588
);
589
containerEl.appendChild(heading);
590
591
const getAttrs = (otherLink: OtherLink) => {
592
if (otherLink.rel || otherLink.target) {
593
const attrs: Record<string, string> = {};
594
if (otherLink.rel) {
595
attrs.rel = otherLink.rel;
596
}
597
if (otherLink.target) {
598
attrs.target = otherLink.target;
599
}
600
return attrs;
601
} else {
602
return undefined;
603
}
604
};
605
606
const linkList = dlLinkTarget.makeContainerEl();
607
let order = 0;
608
for (let i = 0; i < otherLinks.length; i++) {
609
const otherLink = otherLinks[i];
610
const alternateLink: AlternateLink = {
611
icon: otherLink.icon || "link-45deg",
612
href: otherLink.href,
613
title: otherLink.text,
614
order: ++order,
615
attr: getAttrs(otherLink),
616
};
617
const li = dlLinkTarget.makeItemEl(
618
createLinkChild(alternateLink, doc),
619
i,
620
otherLinks.length,
621
);
622
if (linkList) {
623
linkList.appendChild(li);
624
} else {
625
containerEl.appendChild(li);
626
}
627
}
628
if (linkList) {
629
containerEl.appendChild(linkList);
630
}
631
632
dlLinkTarget.targetEl.appendChild(containerEl);
633
}
634
};
635
636
const resolveCodeLinks = async (
637
metadata: Metadata,
638
context?: ProjectContext,
639
): Promise<OtherLink[]> => {
640
const codeLinks = metadata[kCodeLinks] as
641
| boolean
642
| string
643
| string[]
644
| OtherLink[];
645
if (codeLinks !== undefined) {
646
if (typeof codeLinks === "boolean") {
647
return [];
648
} else if (typeof codeLinks === "string") {
649
if (!context) {
650
throw new Error(
651
`The code-link value '${codeLinks}' is only supported from within a project.`,
652
);
653
}
654
const resolvedCodeLink = await resolveCodeLink(codeLinks, context);
655
if (resolvedCodeLink) {
656
return [resolvedCodeLink];
657
} else {
658
throw new Error(`Unknown code-link value '${codeLinks}'`);
659
}
660
} else {
661
const outputLinks: OtherLink[] = [];
662
for (const codeLink of codeLinks) {
663
if (typeof codeLink === "string") {
664
if (!context) {
665
throw new Error(
666
`The code-link value '${codeLink}' is only supported from within a project.`,
667
);
668
}
669
const resolvedCodeLink = await resolveCodeLink(codeLink, context);
670
if (resolvedCodeLink) {
671
outputLinks.push(resolvedCodeLink);
672
} else {
673
throw new Error(`Unknown code-link value '${codeLink}'`);
674
}
675
} else {
676
outputLinks.push(codeLink);
677
}
678
}
679
return outputLinks;
680
}
681
}
682
return [];
683
};
684
685
const resolveCodeLink = async (
686
link: string,
687
context: ProjectContext,
688
): Promise<OtherLink | undefined> => {
689
if (link === "repo") {
690
const env = await context.environment();
691
if (env.github.repoUrl) {
692
return {
693
icon: "github",
694
text: "GitHub Repo",
695
href: env.github.repoUrl,
696
target: "_blank",
697
};
698
} else {
699
warning(
700
"The 'repo' code link is not able to be created as the project isn't a GitHub project.",
701
);
702
}
703
} else if (link === "devcontainer") {
704
const env = await context.environment();
705
if (
706
env.github.organization && env.github.repository && env.github.repoUrl
707
) {
708
const containerUrl = codeSpacesUrl(env.github.repoUrl);
709
return {
710
icon: "github",
711
text: format.language[kLaunchDevContainerTitle] ||
712
"Launch Dev Container",
713
href: containerUrl,
714
target: "_blank",
715
};
716
} else {
717
warning(
718
"The 'devcontainer' code link is not able to be created as the project isn't a GitHub project.",
719
);
720
}
721
} else if (link === "binder") {
722
const env = await context.environment();
723
if (env.github.organization && env.github.repository) {
724
const containerUrl = binderUrl(
725
env.github.organization,
726
env.github.repository,
727
{
728
// TODO: figure out open file path (if support in vscode/rstudio)
729
// openFile: extname(source) === ".ipynb" ? source : undefined
730
editor: env.codeEnvironment,
731
},
732
);
733
return {
734
icon: "journals",
735
text: format.language[kLaunchBinderTitle] ||
736
"Launch Binder",
737
href: containerUrl,
738
target: "_blank",
739
};
740
} else {
741
warning(
742
"The 'binder' code link is not able to be created as the project isn't a GitHub project.",
743
);
744
}
745
}
746
};
747
748
const codeLinks = await resolveCodeLinks(format.metadata, context);
749
750
const otherLinkOptions = [{
751
links: (format.metadata[kOtherLinks] || []) as OtherLink[],
752
clz: "quarto-other-links",
753
title: format.language[kOtherLinksTitle] || "Other Links",
754
}, {
755
links: codeLinks,
756
clz: "quarto-code-links",
757
title: format.language[kCodeLinksTitle] || "Code Links",
758
}];
759
otherLinkOptions.forEach((linkDesc) => {
760
processLinks(linkDesc.links, linkDesc.clz, linkDesc.title);
761
});
762
}
763
764
type selector = string;
765
interface LinkProvider {
766
makeHeadingEl: (doc: Document, text?: string) => Element;
767
makeContainerEl: (_doc: Document) => Element | undefined;
768
makeItemEl: (doc: Document, el: Element, idx: number, len: number) => Element;
769
}
770
771
const bannerHeadingLinkProvider = {
772
makeHeadingEl: (doc: Document, text?: string) => {
773
const headingEl = doc.createElement("div");
774
headingEl.classList.add("quarto-title-meta-heading");
775
if (text) {
776
headingEl.innerText = text;
777
}
778
return headingEl;
779
},
780
makeContainerEl: (_doc: Document) => {
781
return undefined;
782
},
783
makeItemEl: (doc: Document, el: Element) => {
784
const itemEl = doc.createElement("div");
785
itemEl.classList.add("quarto-title-meta-contents");
786
787
const pEl = doc.createElement("p");
788
pEl.appendChild(el);
789
itemEl.appendChild(pEl);
790
return itemEl;
791
},
792
};
793
794
const bannerTextLinkProvider = {
795
makeHeadingEl: (doc: Document, text?: string) => {
796
const headingEl = doc.createElement("div");
797
headingEl.classList.add("quarto-title-meta-heading");
798
if (text) {
799
headingEl.innerText = text;
800
}
801
return headingEl;
802
},
803
makeContainerEl: (doc: Document) => {
804
const headingEl = doc.createElement("div");
805
headingEl.classList.add("quarto-title-meta-contents");
806
return headingEl;
807
},
808
makeItemEl: (doc: Document, el: Element, idx: number, len: number) => {
809
const itemEl = doc.createElement("span");
810
811
itemEl.appendChild(el);
812
if (idx < len - 1) {
813
itemEl.append(doc.createTextNode(","));
814
itemEl.setAttribute("style", "padding-right: 0.5em;");
815
}
816
817
return itemEl;
818
},
819
};
820
821
const kLinkProvidersOtherLinks: Record<selector, LinkProvider> = {
822
"quarto-other-links-target": bannerHeadingLinkProvider,
823
"quarto-other-links-text-target": bannerTextLinkProvider,
824
};
825
826
const kLinkProvidersOtherFormats: Record<selector, LinkProvider> = {
827
"quarto-other-formats-target": bannerHeadingLinkProvider,
828
"quarto-other-formats-text-target": bannerTextLinkProvider,
829
};
830
831
function getLinkTarget(
832
doc: Document,
833
linkProviders?: Record<selector, LinkProvider>,
834
) {
835
// Look for an explicit target
836
if (linkProviders) {
837
for (const sel of Object.keys(linkProviders)) {
838
const explicitTarget = doc.querySelector(`.${sel}`);
839
if (explicitTarget !== null) {
840
const linkProvider = linkProviders[sel];
841
return {
842
targetEl: explicitTarget,
843
makeHeadingEl: (text?: string) => {
844
return linkProvider.makeHeadingEl(doc, text);
845
},
846
makeContainerEl: () => {
847
return linkProvider.makeContainerEl(doc);
848
},
849
makeItemEl: (el: Element, idx: number, len: number) => {
850
return linkProvider.makeItemEl(doc, el, idx, len);
851
},
852
};
853
}
854
}
855
}
856
857
// Now search for a place to put the links
858
let dlLinkTarget = doc.querySelector(`nav[role="doc-toc"]`);
859
if (dlLinkTarget === null) {
860
dlLinkTarget = doc.getElementById("quarto-sidebar-toc-left");
861
}
862
if (dlLinkTarget === null) {
863
dlLinkTarget = doc.getElementById(kMarginSidebarId);
864
}
865
if (dlLinkTarget !== null) {
866
return {
867
targetEl: dlLinkTarget,
868
makeHeadingEl: (text?: string) => {
869
const headingEl = doc.createElement("h2");
870
if (text) {
871
headingEl.innerText = text;
872
}
873
return headingEl;
874
},
875
makeContainerEl: () => {
876
return doc.createElement("ul");
877
},
878
makeItemEl: (el: Element) => {
879
const liEl = doc.createElement("li");
880
liEl.appendChild(el);
881
return liEl;
882
},
883
};
884
}
885
}
886
887
function processAlternateFormatLinks(
888
input: string,
889
options: {
890
inputMetadata: Metadata;
891
inputTraits: PandocInputTraits;
892
renderedFormats: RenderedFormat[];
893
},
894
doc: Document,
895
format: Format,
896
resources: string[],
897
) {
898
if (options.renderedFormats.length > 1) {
899
const dlLinkTarget = getLinkTarget(doc, kLinkProvidersOtherFormats);
900
if (dlLinkTarget) {
901
const containerEl = doc.createElement("div");
902
containerEl.classList.add("quarto-alternate-formats");
903
904
const heading = dlLinkTarget.makeHeadingEl(
905
format.language[kRelatedFormatsTitle],
906
);
907
containerEl.appendChild(heading);
908
909
const otherLinks = otherFormatLinks(
910
input,
911
format,
912
options.renderedFormats,
913
);
914
915
const formatList = dlLinkTarget.makeContainerEl();
916
const sortedLinks = otherLinks.sort(({ order: a }, { order: b }) =>
917
a - b
918
);
919
for (let i = 0; i < sortedLinks.length; i++) {
920
const alternateLink = sortedLinks[i];
921
const link = doc.createElement("a");
922
link.setAttribute("href", alternateLink.href);
923
if (alternateLink.dlAttrValue) {
924
link.setAttribute("download", alternateLink.dlAttrValue);
925
}
926
if (alternateLink.attr) {
927
for (const key of Object.keys(alternateLink.attr)) {
928
const value = alternateLink.attr[key];
929
link.setAttribute(key, value);
930
}
931
}
932
933
const icon = doc.createElement("i");
934
icon.classList.add("bi");
935
icon.classList.add(`bi-${alternateLink.icon}`);
936
link.appendChild(icon);
937
link.appendChild(doc.createTextNode(alternateLink.title));
938
939
const li = dlLinkTarget.makeItemEl(link, i, sortedLinks.length);
940
if (formatList) {
941
formatList.appendChild(li);
942
} else {
943
containerEl.appendChild(li);
944
}
945
946
resources.push(alternateLink.href);
947
}
948
949
if (otherLinks.length > 0) {
950
if (formatList) {
951
containerEl.appendChild(formatList);
952
}
953
dlLinkTarget.targetEl.appendChild(containerEl);
954
}
955
}
956
}
957
}
958
959
function bootstrapHtmlFinalizer(format: Format, flags: PandocFlags) {
960
return (doc: Document): Promise<void> => {
961
const { citesInMargin, refsInMargin } = processColumnElements(
962
doc,
963
format,
964
flags,
965
);
966
967
if (format.metadata[kDisableArticleLayout]) {
968
const stripColumnClasses = (el: Element) => {
969
const stripClz: string[] = [];
970
el.classList.forEach((clz) => {
971
if (
972
clz === "margin-caption" || clz === "margin-ref" ||
973
clz.startsWith("column-") || clz === "page-columns" ||
974
clz === "page-full"
975
) {
976
stripClz.push(clz);
977
}
978
});
979
el.classList.remove(...stripClz);
980
for (const childEl of el.children) {
981
stripColumnClasses(childEl);
982
}
983
};
984
985
const mainEl = doc.body.querySelector("main");
986
if (mainEl) {
987
stripColumnClasses(mainEl);
988
}
989
}
990
991
// provide heading for footnotes (but only if there is one section, there could
992
// be multiple if they used reference-location: block/section)
993
if (refsInMargin) {
994
const footNoteSectionEl = doc.querySelector("section.footnotes");
995
if (footNoteSectionEl) {
996
footNoteSectionEl.remove();
997
}
998
}
999
1000
// Purge the bibliography if we're using refs in margin
1001
if (citesInMargin) {
1002
const bibliographyDiv = doc.querySelector("div#refs");
1003
if (bibliographyDiv) {
1004
bibliographyDiv.remove();
1005
}
1006
}
1007
1008
const fullLayout = formatHasFullLayout(format);
1009
if (fullLayout) {
1010
// If we're in a full layout, get rid of empty sidebar elements
1011
const leftSidebar = hasContents(kSidebarId, doc);
1012
if (!leftSidebar) {
1013
const sidebarEl = doc.getElementById(kSidebarId);
1014
sidebarEl?.remove();
1015
}
1016
1017
const column = suggestColumn(doc);
1018
setMainColumn(doc, column);
1019
}
1020
1021
// Note whether we need a narrow or wide margin layout
1022
const hasToc = !!format.pandoc.toc;
1023
const leftSidebar = doc.getElementById("quarto-sidebar");
1024
const hasLeftContent = leftSidebar && leftSidebar.children.length > 0;
1025
const rightSidebar = doc.getElementById("quarto-margin-sidebar");
1026
const hasRightContent = rightSidebar && rightSidebar.children.length > 0;
1027
const hasMarginContent =
1028
doc.querySelectorAll(".column-margin").length > 0 ||
1029
doc.querySelectorAll(".margin-caption").length > 0 ||
1030
doc.querySelectorAll(".margin-ref").length > 0;
1031
1032
if (rightSidebar && !hasRightContent && !hasMarginContent && !hasToc) {
1033
rightSidebar.remove();
1034
}
1035
1036
// Set the content mode for the grid system
1037
const gridObj = format.metadata[kGrid] as Metadata;
1038
let contentMode = "auto";
1039
if (gridObj) {
1040
contentMode =
1041
gridObj[kContentMode] as ("auto" | "standard" | "full" | "slim");
1042
}
1043
1044
if (contentMode === undefined || contentMode === "auto") {
1045
const hasColumnElements = getColumnLayoutElements(doc).length > 0;
1046
if (hasColumnElements) {
1047
if (hasLeftContent && hasMarginContent) {
1048
// Slim down the content area so there are sizable margins
1049
// for the column element
1050
doc.body.classList.add("slimcontent");
1051
} else if (
1052
hasRightContent || hasMarginContent || fullLayout || hasToc
1053
) {
1054
// Use the default layout, so don't add any classes
1055
} else {
1056
doc.body.classList.add("fullcontent");
1057
}
1058
} else {
1059
if (!hasRightContent && !hasMarginContent && !hasToc) {
1060
doc.body.classList.add("fullcontent");
1061
} else {
1062
// Use the deafult layout, don't add any classes
1063
}
1064
}
1065
} else {
1066
if (contentMode === "slim") {
1067
doc.body.classList.add("slimcontent");
1068
} else if (contentMode === "full") {
1069
doc.body.classList.add("fullcontent");
1070
}
1071
}
1072
1073
// start body with light or dark class for proper display when JS is disabled
1074
let initialLightDarkClass = "quarto-light";
1075
if (darkModeDefault(format)) {
1076
initialLightDarkClass = "quarto-dark";
1077
}
1078
doc.body.classList.add(initialLightDarkClass);
1079
1080
// If there is no margin content and no toc in the right margin
1081
// then lower the z-order so everything else can get on top
1082
// of the sidebar
1083
const isFullLayout = format.metadata[kPageLayout] === "full";
1084
const marginSidebarEl = doc.getElementById("quarto-margin-sidebar");
1085
if (
1086
(!hasMarginContent && isFullLayout && !hasRightContent) ||
1087
marginSidebarEl?.childElementCount === 0
1088
) {
1089
marginSidebarEl?.classList.add("zindex-bottom");
1090
}
1091
return Promise.resolve();
1092
};
1093
}
1094
1095
function processColumnElements(
1096
doc: Document,
1097
format: Format,
1098
flags: PandocFlags,
1099
) {
1100
// Clean nested columns - the outer layout will win
1101
removeNestedColumnLayouts(doc);
1102
1103
// Remove any margin captions that don't make sense (since the right
1104
// margin is occluded by the element with the caption)
1105
cleanNonsensicalMarginCaps(doc);
1106
1107
// Margin and column elements are only functional in article based layouts
1108
if (!formatHasArticleLayout(format)) {
1109
return {
1110
citesInMargin: false,
1111
refsInMargin: false,
1112
};
1113
}
1114
1115
// Process captions that may appear in the margin
1116
processMarginCaptions(doc);
1117
1118
// Process margin elements that may appear in callouts
1119
processMarginElsInCallouts(doc);
1120
1121
// Process margin elements that may appear in tabsets
1122
processMarginElsInTabsets(doc);
1123
1124
// Process non-margin figures - forward the column class
1125
// down into the figure so that the caption remains in the document
1126
// flow and the figure itself takes the column sizing
1127
processFigureOutputs(doc);
1128
1129
// Group margin elements by their parents and wrap them in a container
1130
// Be sure to ignore containers which are already processed
1131
// and should be left alone
1132
const marginProcessors: MarginNodeProcessor[] = [
1133
simpleMarginProcessor,
1134
];
1135
1136
// If figure captions are enabled, look out for them in callouts
1137
if (hasMarginFigCaps(format)) {
1138
marginProcessors.push(figCapInCalloutMarginProcessor);
1139
}
1140
1141
// If margin footnotes are enabled move them
1142
const refsInMargin = hasMarginRefs(format, flags);
1143
if (refsInMargin) {
1144
marginProcessors.unshift(footnoteMarginProcessor);
1145
}
1146
1147
// If margin cites are enabled, move them
1148
const citesInMargin = hasMarginCites(format);
1149
if (citesInMargin) {
1150
marginProcessors.push(referenceMarginProcessor);
1151
}
1152
processMarginNodes(doc, marginProcessors);
1153
1154
// If margin footnotes are enabled, remove any containers provided
1155
if (refsInMargin) {
1156
const footnoteContainer = doc.getElementById("footnotes");
1157
// Since it has margin footnotes, remove the end notes section
1158
if (footnoteContainer) {
1159
footnoteContainer.remove();
1160
}
1161
}
1162
1163
const columnLayouts = getColumnLayoutElements(doc);
1164
1165
// If there are any of these elements, we need to be sure that their
1166
// parents have acess to the grid system, so make the parent full screen width
1167
// and apply the grid system to it (now the child 'column-' element can be positioned
1168
// anywhere in the grid system)
1169
if (columnLayouts && columnLayouts.length > 0) {
1170
const processEl = (el: Element) => {
1171
if (el.tagName === "DIV" && el.id === "quarto-content") {
1172
return false;
1173
} else if (el.tagName === "BODY") {
1174
return false;
1175
} else {
1176
return true;
1177
}
1178
};
1179
1180
const ensureInGrid = (el: Element, setLayout: boolean) => {
1181
if (processEl(el)) {
1182
// Add the grid system. Children of the grid system
1183
// are placed into the body-content column by default
1184
// (CSS implements this)
1185
if (
1186
!el.classList.contains("quarto-layout-row") &&
1187
!el.classList.contains("page-columns")
1188
) {
1189
el.classList.add("page-columns");
1190
}
1191
1192
// Mark full width
1193
if (setLayout && !el.classList.contains("page-full")) {
1194
el.classList.add("page-full");
1195
}
1196
1197
// Process parents up to the main tag
1198
const parent = el.parentElement;
1199
if (parent) {
1200
ensureInGrid(parent, true);
1201
}
1202
}
1203
};
1204
1205
columnLayouts.forEach((node) => {
1206
const el = node as Element;
1207
if (el.parentElement) {
1208
ensureInGrid(el.parentElement, true);
1209
}
1210
});
1211
}
1212
1213
return {
1214
citesInMargin,
1215
refsInMargin,
1216
};
1217
}
1218
1219
const processMarginNodes = (
1220
doc: Document,
1221
processors: MarginNodeProcessor[],
1222
) => {
1223
const marginSelector = processors.map((proc) => proc.selector).join(
1224
", ",
1225
);
1226
const marginNodes = doc.querySelectorAll(marginSelector);
1227
marginNodes.forEach((marginNode) => {
1228
const marginEl = marginNode as Element;
1229
for (const processor of processors) {
1230
if (processor.canProcess(marginEl)) {
1231
processor.process(marginEl, doc);
1232
break;
1233
}
1234
}
1235
marginEl.classList.remove("column-margin");
1236
});
1237
};
1238
1239
const findQuartoFigure = (el: Element): Element | undefined => {
1240
if (
1241
el.classList.contains("quarto-figure") ||
1242
el.classList.contains("quarto-layout-panel") ||
1243
el.classList.contains("quarto-float")
1244
) {
1245
return el;
1246
} else if (el.parentElement) {
1247
return findQuartoFigure(el.parentElement);
1248
} else {
1249
return undefined;
1250
}
1251
};
1252
1253
const moveClassToCaption = (container: Element, sel: string) => {
1254
const target = container.querySelector(sel);
1255
if (target) {
1256
target.classList.add("margin-caption");
1257
return true;
1258
} else {
1259
return false;
1260
}
1261
};
1262
1263
const removeCaptionClass = (el: Element) => {
1264
// Remove this since it will place the contents in the margin if it remains present
1265
el.classList.remove("margin-caption");
1266
};
1267
1268
const processLayoutPanelMarginCaption = (captionContainer: Element) => {
1269
const figure = captionContainer.querySelector("figure");
1270
if (figure) {
1271
// It is a figure panel, find a direct child caption of the outer figure.
1272
for (const child of figure.children) {
1273
if (child.tagName === "FIGCAPTION") {
1274
child.classList.add("margin-caption");
1275
removeCaptionClass(captionContainer);
1276
break;
1277
}
1278
}
1279
} else {
1280
// it is not a figure panel, find the panel caption
1281
const caption = captionContainer.querySelector(".panel-caption");
1282
if (caption) {
1283
caption.classList.add("margin-caption");
1284
removeCaptionClass(captionContainer);
1285
}
1286
}
1287
};
1288
1289
const processFigureMarginCaption = (
1290
captionContainer: Element,
1291
doc: Document,
1292
) => {
1293
// First try finding a fig caption
1294
const foundCaption = moveClassToCaption(captionContainer, "figcaption");
1295
if (!foundCaption) {
1296
// find a table caption and copy the contents into a div with style figure-caption
1297
// note that for tables, our grid inception approach isn't going to work, so
1298
// we make a copy of the caption contents and place that in the same container as the
1299
// table and bind it to the grid
1300
const captionEl = captionContainer.querySelector("caption");
1301
if (captionEl) {
1302
const parentDivEl = captionEl?.parentElement?.parentElement;
1303
if (parentDivEl) {
1304
captionEl.classList.add("hidden");
1305
1306
const divCopy = doc.createElement("div");
1307
divCopy.classList.add("figure-caption");
1308
divCopy.classList.add("margin-caption");
1309
divCopy.innerHTML = captionEl.innerHTML;
1310
parentDivEl.appendChild(divCopy);
1311
removeCaptionClass(captionContainer);
1312
}
1313
}
1314
} else {
1315
removeCaptionClass(captionContainer);
1316
}
1317
};
1318
1319
const processTableMarginCaption = (
1320
captionContainer: Element,
1321
doc: Document,
1322
) => {
1323
// Find a caption
1324
const caption = captionContainer.querySelector("caption");
1325
if (caption) {
1326
const marginCapEl = doc.createElement("DIV");
1327
marginCapEl.classList.add("quarto-table-caption");
1328
marginCapEl.classList.add("margin-caption");
1329
marginCapEl.innerHTML = caption.innerHTML;
1330
1331
captionContainer.parentElement?.insertBefore(
1332
marginCapEl,
1333
captionContainer.nextElementSibling,
1334
);
1335
1336
caption.remove();
1337
removeCaptionClass(captionContainer);
1338
}
1339
};
1340
1341
// Process any captions that appear in margins
1342
const processMarginCaptions = (doc: Document) => {
1343
// Identify elements that already appear in the margin
1344
// and in this case, remove the margin-caption class
1345
// since we do not want to further process the caption into the margin
1346
const captionsAlreadyInMargin = doc.querySelectorAll(
1347
".column-margin .margin-caption",
1348
);
1349
captionsAlreadyInMargin.forEach((node) => {
1350
const el = node as Element;
1351
el.classList.remove("margin-caption");
1352
});
1353
1354
// Forward caption class from parents to the child fig caps
1355
const marginCaptions = doc.querySelectorAll(".margin-caption");
1356
marginCaptions.forEach((node) => {
1357
const figureEl = node as Element;
1358
const captionContainer = findQuartoFigure(figureEl);
1359
if (captionContainer) {
1360
// Deal with layout panels (we will only handle the main caption not the internals)
1361
const isLayoutPanel = captionContainer.classList.contains(
1362
"quarto-layout-panel",
1363
);
1364
1365
// has explicitly set cap location
1366
const explicitCapLoc = captionContainer.getAttribute("data-cap-location");
1367
if (explicitCapLoc == null || explicitCapLoc == "margin") {
1368
if (isLayoutPanel) {
1369
processLayoutPanelMarginCaption(captionContainer);
1370
} else {
1371
processFigureMarginCaption(captionContainer, doc);
1372
}
1373
}
1374
} else {
1375
// Deal with table margin captions
1376
if (figureEl.classList.contains("tbl-parent")) {
1377
// This is table panel, so only grab the main caption
1378
const capDivEl = figureEl.querySelector("div.panel-caption");
1379
if (capDivEl) {
1380
capDivEl.classList.add("margin-caption");
1381
capDivEl.remove();
1382
figureEl.appendChild(capDivEl);
1383
}
1384
} else {
1385
// This is just a table, grab that caption
1386
const table = figureEl.querySelector("table");
1387
if (table) {
1388
processTableMarginCaption(table, doc);
1389
}
1390
}
1391
}
1392
removeCaptionClass(figureEl);
1393
});
1394
};
1395
1396
const processMarginElsInCallouts = (doc: Document) => {
1397
const calloutNodes = doc.querySelectorAll("div.callout");
1398
calloutNodes.forEach((calloutNode) => {
1399
const calloutEl = calloutNode as Element;
1400
const collapseEl = calloutEl.querySelector(".callout-collapse");
1401
const isSimple = calloutEl.classList.contains("callout-style-simple");
1402
1403
//Get the collapse classes (if any) to forward long
1404
const collapseClasses: string[] = [];
1405
if (collapseEl) {
1406
collapseEl.classList.forEach((clz) => collapseClasses.push(clz));
1407
}
1408
1409
const marginNodes = calloutEl.querySelectorAll(
1410
".callout-body-container .column-margin, .callout-body-container aside:not(.footnotes):not(.sidebar), .callout-body-container .aside:not(.footnotes)",
1411
);
1412
1413
if (marginNodes.length > 0) {
1414
const marginArr = Array.from(marginNodes);
1415
marginArr.reverse().forEach((marginNode) => {
1416
const marginEl = marginNode as Element;
1417
collapseClasses.forEach((clz) => {
1418
marginEl.classList.add(clz);
1419
});
1420
marginEl.classList.add("callout-margin-content");
1421
if (isSimple) {
1422
marginEl.classList.add("callout-margin-content-simple");
1423
}
1424
1425
calloutEl.after(marginEl);
1426
});
1427
}
1428
});
1429
};
1430
1431
const figCapInCalloutMarginProcessor: MarginNodeProcessor = {
1432
selector: ".callout",
1433
canProcess(el: Element) {
1434
const hasFigCap = el.querySelector("figcaption");
1435
return hasFigCap !== null;
1436
},
1437
process(el: Element, doc: Document) {
1438
const collapseEl = el.querySelector(".callout-collapse");
1439
const isSimple = el.classList.contains("callout-style-simple");
1440
//Get the collapse classes (if any) to forward long
1441
const collapseClasses: string[] = [];
1442
if (collapseEl) {
1443
collapseEl.classList.forEach((clz) => collapseClasses.push(clz));
1444
}
1445
1446
const figNodes = el.querySelectorAll("figure");
1447
for (const figNode of Array.from(figNodes).reverse()) {
1448
const figEl = figNode as Element;
1449
const figCaptionEl = figEl.querySelector("figcaption");
1450
1451
// Usually the figure id is on the parent div
1452
let figureId = figEl.id;
1453
if (figureId === "") {
1454
figureId = figEl.parentElement?.id || "";
1455
}
1456
1457
const captionId = figureId + "-caption";
1458
figEl.setAttribute("aria-labelledby", captionId);
1459
1460
if (figCaptionEl !== null) {
1461
// Move the caption contents into a div
1462
figCaptionEl.remove();
1463
const div = doc.createElement("DIV");
1464
div.id = captionId;
1465
div.classList.add("margin-figure-caption");
1466
div.classList.add("column-margin");
1467
collapseClasses.forEach((clz) => {
1468
div.classList.add(clz);
1469
});
1470
1471
div.classList.add("callout-margin-content");
1472
if (isSimple) {
1473
div.classList.add("callout-margin-content-simple");
1474
}
1475
1476
figCaptionEl.childNodes.forEach((node) => {
1477
div.append(node);
1478
});
1479
el.parentElement?.insertBefore(div, el.nextElementSibling);
1480
}
1481
}
1482
},
1483
};
1484
1485
const kPreviewFigColumnForwarding = [".grid"];
1486
1487
const isInsideAbout = (el: Element) =>
1488
!!findParent(
1489
el,
1490
(parent) =>
1491
Array.from(parent.classList).some((x) => x.startsWith("quarto-about-")),
1492
);
1493
1494
const processFigureOutputs = (doc: Document) => {
1495
// For any non-margin figures, we want to actually place the figure itself
1496
// into the column, and leave the caption as is, if possible
1497
const columnEls = doc.querySelectorAll(
1498
'[class^="column-"]:not(.column-margin), [class*=" column-"]:not(.column-margin)',
1499
);
1500
1501
const moveColumnClasses = (fromEl: Element, toEl: Element) => {
1502
const clzList: string[] = [];
1503
for (const clz of fromEl.classList) {
1504
if (clz.startsWith("column-")) {
1505
clzList.push(clz);
1506
}
1507
}
1508
fromEl.classList.remove(...clzList);
1509
toEl.classList.add(...clzList);
1510
};
1511
1512
for (const columnNode of columnEls) {
1513
// See if this is a code cell with a single figure output
1514
const columnEl = columnNode as Element;
1515
1516
// See if there are any classes which should prohibit forwarding
1517
// the column information
1518
if (
1519
kPreviewFigColumnForwarding.some((sel) => {
1520
return columnEl.querySelector(sel) !== null;
1521
})
1522
) {
1523
// There are matching ignore selectors, just skip
1524
// this column
1525
continue;
1526
}
1527
1528
// If there is a single figure, then forward the column class onto that
1529
const figures = columnEl.querySelectorAll("figure img.figure-img");
1530
1531
if (
1532
figures && figures.length === 1 && !isInsideAbout(figures[0] as Element)
1533
) {
1534
moveColumnClasses(columnEl, figures[0] as Element);
1535
} else {
1536
const layoutFigures = columnEl.querySelectorAll(
1537
".quarto-layout-panel > figure.figure .quarto-layout-row",
1538
);
1539
if (
1540
layoutFigures && layoutFigures.length === 1 &&
1541
!isInsideAbout(layoutFigures[0] as Element)
1542
) {
1543
moveColumnClasses(columnEl, layoutFigures[0] as Element);
1544
}
1545
}
1546
}
1547
};
1548
1549
const processMarginElsInTabsets = (doc: Document) => {
1550
// Move margin elements inside tabsets into a separate container that appears
1551
// before the tabset- this will hold the margin content
1552
// quarto.js will detect tab changed events and propery show and hide elements
1553
// by marking them with a collapse class.
1554
1555
const tabSetNodes = doc.querySelectorAll("div.panel-tabset");
1556
tabSetNodes.forEach((tabsetNode) => {
1557
const tabSetEl = tabsetNode as Element;
1558
const tabNodes = tabSetEl.querySelectorAll("div.tab-pane");
1559
1560
const marginEls: Element[] = [];
1561
let count = 0;
1562
tabNodes.forEach((tabNode) => {
1563
const tabEl = tabNode as Element;
1564
const tabId = tabEl.id;
1565
1566
const marginNodes = tabEl.querySelectorAll(
1567
".column-margin, aside:not(.footnotes):not(.sidebar), .aside:not(.footnotes)",
1568
);
1569
1570
if (tabId && marginNodes.length > 0) {
1571
const marginArr = Array.from(marginNodes);
1572
marginArr.forEach((marginNode) => {
1573
const marginEl = marginNode as Element;
1574
marginEl.classList.add("tabset-margin-content");
1575
marginEl.classList.add(`${tabId}-tab-margin-content`);
1576
if (count > 0) {
1577
marginEl.classList.add("collapse");
1578
}
1579
marginEls.push(marginEl);
1580
});
1581
}
1582
count++;
1583
});
1584
1585
if (marginEls) {
1586
const containerEl = doc.createElement("div");
1587
containerEl.classList.add("tabset-margin-container");
1588
marginEls.forEach((marginEl) => {
1589
containerEl.appendChild(marginEl);
1590
});
1591
tabSetEl.before(containerEl);
1592
}
1593
});
1594
};
1595
1596
interface MarginNodeProcessor {
1597
selector: string;
1598
canProcess(el: Element): boolean;
1599
process(el: Element, doc: Document): void;
1600
}
1601
1602
const simpleMarginProcessor: MarginNodeProcessor = {
1603
selector: ".column-margin:not(.column-container)",
1604
canProcess(el: Element) {
1605
return el.classList.contains("column-margin") &&
1606
!el.classList.contains("column-container");
1607
},
1608
process(el: Element, doc: Document) {
1609
el.classList.remove("column-margin");
1610
1611
const kPopMarginElOutOfTags = ["DD"];
1612
1613
// Specially deal with DD
1614
if (
1615
el.parentElement &&
1616
kPopMarginElOutOfTags.includes(el.parentElement?.tagName)
1617
) {
1618
const parentElement = el.parentElement;
1619
// This is in a tag which itself can't be a container
1620
// pop it out
1621
// make a container which is next to the parent and
1622
// place that in the margin
1623
// For examples of this, see:
1624
// https://github.com/quarto-dev/quarto-cli/issues/8862
1625
const marginContainer = doc.createElement("DIV");
1626
el.remove();
1627
marginContainer.appendChild(el);
1628
parentElement.parentElement?.insertBefore(
1629
marginContainer,
1630
parentElement.nextSibling,
1631
);
1632
addContentToMarginContainerForEl(marginContainer, marginContainer, doc);
1633
} else {
1634
addContentToMarginContainerForEl(el, el, doc);
1635
}
1636
},
1637
};
1638
1639
const footnoteMarginProcessor: MarginNodeProcessor = {
1640
selector: ".footnote-ref",
1641
canProcess(el: Element) {
1642
return el.classList.contains("footnote-ref");
1643
},
1644
process(el: Element, doc: Document) {
1645
if (el.hasAttribute("href")) {
1646
const target = el.getAttribute("href");
1647
if (target) {
1648
// First try to grab a the citation or footnote.
1649
const refId = target.slice(1);
1650
const refContentsEl = doc.getElementById(refId);
1651
1652
if (refContentsEl) {
1653
// Find and remove the backlink
1654
const backLinkEl = refContentsEl.querySelector(".footnote-back");
1655
if (backLinkEl) {
1656
backLinkEl.remove();
1657
}
1658
1659
const invalidParentTags = [
1660
"SPAN",
1661
"EM",
1662
"STRONG",
1663
"DEL",
1664
"H1",
1665
"H2",
1666
"H3",
1667
"H4",
1668
"H5",
1669
"H6",
1670
];
1671
const findValidParentEl = (el: Element): Element | undefined => {
1672
if (
1673
el.parentElement &&
1674
!invalidParentTags.includes(el.parentElement.tagName)
1675
) {
1676
return el.parentElement;
1677
} else if (el.parentElement) {
1678
return findValidParentEl(el.parentElement);
1679
} else {
1680
return undefined;
1681
}
1682
};
1683
1684
// Prepend the footnote mark
1685
if (refContentsEl.childNodes.length > 0) {
1686
const firstChild = refContentsEl.childNodes[0];
1687
// Prepend the reference identified (e.g. <sup>1</sup> and a non breaking space)
1688
firstChild.insertBefore(
1689
doc.createTextNode("\u00A0"),
1690
firstChild.firstChild,
1691
);
1692
1693
firstChild.insertBefore(
1694
el.firstChild.cloneNode(true),
1695
firstChild.firstChild,
1696
);
1697
}
1698
const validParent = findValidParentEl(el);
1699
1700
if (refContentsEl.tagName === "LI") {
1701
// Ensure that there is a list to place this footnote within
1702
const containerEl = doc.createElement("DIV");
1703
containerEl.id = refContentsEl.id;
1704
containerEl.append(...refContentsEl.childNodes);
1705
1706
addContentToMarginContainerForEl(
1707
validParent || el,
1708
containerEl,
1709
doc,
1710
);
1711
} else {
1712
addContentToMarginContainerForEl(
1713
validParent || el,
1714
refContentsEl,
1715
doc,
1716
);
1717
}
1718
}
1719
}
1720
}
1721
},
1722
};
1723
1724
const referenceMarginProcessor: MarginNodeProcessor = {
1725
selector: "a[role='doc-biblioref']",
1726
canProcess(el: Element) {
1727
return el.hasAttribute("role") &&
1728
el.getAttribute("role") === "doc-biblioref";
1729
},
1730
process(el: Element, doc: Document) {
1731
if (el.hasAttribute("href")) {
1732
const target = el.getAttribute("href");
1733
if (target) {
1734
// First try to grab a the citation.
1735
const refId = target.slice(1);
1736
const refContentsEl = doc.getElementById(refId);
1737
1738
// Walks up the parent stack until a figure element is found
1739
const findCaptionEl = (el: Element): Element | undefined => {
1740
if (el.parentElement?.tagName === "FIGCAPTION") {
1741
return el.parentElement;
1742
} else if (el.parentElement) {
1743
return findCaptionEl(el.parentElement);
1744
} else {
1745
return undefined;
1746
}
1747
};
1748
1749
const findNonSpanParentEl = (el: Element): Element | undefined => {
1750
if (el.parentElement && el.parentElement.tagName !== "SPAN") {
1751
return el.parentElement;
1752
} else if (el.parentElement) {
1753
return findNonSpanParentEl(el.parentElement);
1754
} else {
1755
return undefined;
1756
}
1757
};
1758
1759
// The parent is a figcaption that contains the reference.
1760
// The parent.parent is the figure
1761
const figureCaptionEl = findCaptionEl(el);
1762
if (refContentsEl && figureCaptionEl) {
1763
if (figureCaptionEl.classList.contains("margin-caption")) {
1764
figureCaptionEl.appendChild(refContentsEl.cloneNode(true));
1765
} else {
1766
addContentToMarginContainerForEl(
1767
figureCaptionEl,
1768
refContentsEl,
1769
doc,
1770
);
1771
}
1772
} else if (refContentsEl) {
1773
const nonSpanParent = findNonSpanParentEl(el);
1774
if (nonSpanParent) {
1775
addContentToMarginContainerForEl(
1776
nonSpanParent,
1777
refContentsEl,
1778
doc,
1779
);
1780
}
1781
}
1782
}
1783
}
1784
},
1785
};
1786
1787
// Tests whether element is a margin container
1788
const isContainer = (el: Element | null) => {
1789
return (
1790
el &&
1791
el.tagName === "DIV" &&
1792
el.classList.contains("column-container") &&
1793
el.classList.contains("column-margin")
1794
);
1795
};
1796
1797
const isAlreadyInMargin = (el: Element): boolean => {
1798
const elInMargin = el.classList.contains("column-margin") ||
1799
(el.classList.contains("aside") &&
1800
!el.classList.contains("footnotes")) ||
1801
el.classList.contains("margin-caption");
1802
if (elInMargin) {
1803
return true;
1804
} else if (el.parentElement !== null) {
1805
return isAlreadyInMargin(el.parentElement);
1806
} else {
1807
return false;
1808
}
1809
};
1810
1811
// Creates a margin container
1812
const createMarginContainer = (doc: Document) => {
1813
const container = doc.createElement("div");
1814
container.classList.add("no-row-height");
1815
container.classList.add("column-margin");
1816
container.classList.add("column-container");
1817
return container;
1818
};
1819
1820
const marginContainerForEl = (el: Element, doc: Document) => {
1821
// The elements direct parent is in the margin
1822
if (el.parentElement && isAlreadyInMargin(el.parentElement)) {
1823
return el.parentElement;
1824
}
1825
1826
// If the container would be directly adjacent to another container
1827
// we should use that adjacent container
1828
if (el.nextElementSibling && isContainer(el.nextElementSibling)) {
1829
return el.nextElementSibling;
1830
}
1831
if (el.previousElementSibling && isContainer(el.previousElementSibling)) {
1832
return el.previousElementSibling;
1833
}
1834
1835
// Find the callout parent and create a container for the callout there
1836
// Walks up the parent stack until a callout element is found
1837
const findCalloutEl = (el: Element): Element | undefined => {
1838
if (el.parentElement?.classList.contains("callout")) {
1839
return el.parentElement;
1840
} else if (el.parentElement) {
1841
return findCalloutEl(el.parentElement);
1842
} else {
1843
return undefined;
1844
}
1845
};
1846
const calloutEl = findCalloutEl(el);
1847
if (calloutEl) {
1848
const container = createMarginContainer(doc);
1849
calloutEl.parentNode?.insertBefore(
1850
container,
1851
calloutEl.nextElementSibling,
1852
);
1853
return container;
1854
}
1855
1856
// Check for a list or table
1857
const list = findOutermostParentElOfType(el, ["OL", "UL", "TABLE"]);
1858
if (list) {
1859
if (list.nextElementSibling && isContainer(list.nextElementSibling)) {
1860
return list.nextElementSibling;
1861
} else {
1862
const container = createMarginContainer(doc);
1863
if (list.parentNode) {
1864
list.parentNode.insertBefore(container, list.nextElementSibling);
1865
}
1866
return container;
1867
}
1868
}
1869
1870
// Deal with a paragraph
1871
const parentEl = el.parentElement;
1872
const cantContainBlockTags = ["P", "BLOCKQUOTE"];
1873
if (parentEl && cantContainBlockTags.includes(parentEl.tagName)) {
1874
// See if this para has a parent div with a container
1875
if (
1876
parentEl.parentElement &&
1877
parentEl.parentElement.tagName === "DIV" &&
1878
parentEl.nextElementSibling &&
1879
isContainer(parentEl.nextElementSibling)
1880
) {
1881
return parentEl.nextElementSibling;
1882
} else {
1883
const container = createMarginContainer(doc);
1884
const wrapper = doc.createElement("div");
1885
parentEl.replaceWith(wrapper);
1886
wrapper.appendChild(parentEl);
1887
wrapper.appendChild(container);
1888
return container;
1889
}
1890
}
1891
1892
// We couldn't find a container, so just cook one up and return
1893
const container = createMarginContainer(doc);
1894
el.parentNode?.insertBefore(container, el.nextElementSibling);
1895
return container;
1896
};
1897
1898
const addContentToMarginContainerForEl = (
1899
el: Element,
1900
content: Element,
1901
doc: Document,
1902
) => {
1903
const container = marginContainerForEl(el, doc);
1904
if (container) {
1905
container.appendChild(content);
1906
}
1907
};
1908
1909
const addNodesToMarginContainerForEl = (
1910
el: Element,
1911
nodes: NodeList,
1912
doc: Document,
1913
) => {
1914
const container = marginContainerForEl(el, doc);
1915
if (container) {
1916
container.append(...nodes);
1917
}
1918
};
1919
1920
const findOutermostParentElOfType = (
1921
el: Element,
1922
tagNames: string[],
1923
): Element | undefined => {
1924
let outEl = undefined;
1925
if (el.parentElement) {
1926
if (el.parentElement.tagName === "MAIN") {
1927
return outEl;
1928
} else {
1929
if (tagNames.includes(el.parentElement.tagName)) {
1930
outEl = el.parentElement;
1931
}
1932
outEl = findOutermostParentElOfType(el.parentElement, tagNames) || outEl;
1933
return outEl;
1934
}
1935
} else {
1936
return undefined;
1937
}
1938
};
1939
1940
const hasContents = (id: string, doc: Document) => {
1941
const el = doc.getElementById(id);
1942
// Does the element exist
1943
if (el === null) {
1944
return false;
1945
}
1946
1947
// Does it have any element children?
1948
if (el.children.length > 0) {
1949
return true;
1950
}
1951
1952
// If it doesn't have any element children
1953
// see if there is any text
1954
return !!el.innerText.trim();
1955
};
1956
1957
// Suggests a default column by inspecting sidebars
1958
// if there are none or some, take up the extra space!
1959
function suggestColumn(doc: Document) {
1960
const leftSidebar = hasContents(kSidebarId, doc);
1961
const leftToc = hasContents(kTocLeftSidebarId, doc);
1962
const rightSidebar = hasContents(kMarginSidebarId, doc);
1963
1964
const columnClasses = getColumnClasses(doc);
1965
const leftContent = [...fullOccludeClz, ...leftOccludeClz].some((clz) => {
1966
return columnClasses.has(clz);
1967
});
1968
const rightContent = [...fullOccludeClz, ...rightOccludeClz].some((clz) => {
1969
return columnClasses.has(clz);
1970
});
1971
1972
const leftUsed = leftSidebar || leftContent || leftToc;
1973
const rightUsed = rightSidebar || rightContent;
1974
1975
if (leftUsed && rightUsed) {
1976
return "column-body";
1977
} else if (leftUsed) {
1978
return "column-page-right";
1979
} else if (rightUsed) {
1980
return "column-page-left";
1981
} else {
1982
return "column-page";
1983
}
1984
}
1985
const kSidebarId = "quarto-sidebar";
1986
const kMarginSidebarId = "quarto-margin-sidebar";
1987
const kTocLeftSidebarId = "quarto-sidebar-toc-left";
1988
1989
const fullOccludeClz = [
1990
"column-page",
1991
"column-screen",
1992
"column-screen-inset",
1993
];
1994
const leftOccludeClz = [
1995
"column-page-left",
1996
"column-screen-inset-left",
1997
"column-screen-left",
1998
];
1999
const rightOccludeClz = [
2000
"column-margin",
2001
"column-page-right",
2002
"column-screen-inset-right",
2003
"column-screen-right",
2004
"margin-caption",
2005
"margin-ref",
2006
];
2007
2008
const allColumnClz = [
2009
"column-body-outset",
2010
"column-body-outset-left",
2011
"column-body-outset-right",
2012
"column-page-inset",
2013
"column-page-inset-left",
2014
"column-page-inset-right",
2015
"column-page",
2016
"column-page-left",
2017
"column-page-right",
2018
"column-screen-inset",
2019
"column-screen-inset-left",
2020
"column-screen-inset-right",
2021
"column-screen",
2022
"column-screen-left",
2023
"column-screen-right",
2024
"column-margin",
2025
];
2026
2027
const removeMarginClz = [
2028
"column-body-outset",
2029
"column-body-outset-right",
2030
"column-page-inset",
2031
"column-page-inset-right",
2032
"column-page",
2033
"column-page-right",
2034
"column-screen-inset",
2035
"column-screen-inset-right",
2036
"column-screen",
2037
"column-screen-right",
2038
"column-margin",
2039
];
2040
2041
const nonScreenColumnClz = [
2042
"column-body-outset",
2043
"column-body-outset-left",
2044
"column-body-outset-right",
2045
"column-page-inset",
2046
"column-page-inset-left",
2047
"column-page-inset-right",
2048
"column-page",
2049
"column-page-left",
2050
"column-page-right",
2051
"column-screen-inset",
2052
"column-screen-inset-left",
2053
"column-screen-inset-right",
2054
"column-screen-left",
2055
"column-screen-right",
2056
"column-margin",
2057
];
2058
2059
const getColumnClasses = (doc: Document) => {
2060
const classes = new Set<string>();
2061
const colNodes = getColumnLayoutElements(doc);
2062
for (const colNode of colNodes) {
2063
const colEl = colNode as Element;
2064
colEl.classList.forEach((clz) => {
2065
if (
2066
clz === "margin-caption" || clz === "margin-ref" ||
2067
clz.startsWith("column-")
2068
) {
2069
classes.add(clz);
2070
}
2071
});
2072
}
2073
return classes;
2074
};
2075
2076