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.ts
6450 views
1
// noinspection TypeScriptUnresolvedReference
2
3
/*
4
* format-html.ts
5
*
6
* Copyright (C) 2020-2022 Posit Software, PBC
7
*/
8
import { join, relative } from "../../deno_ral/path.ts";
9
import { warning } from "../../deno_ral/log.ts";
10
11
import * as ld from "../../core/lodash.ts";
12
13
import { Document, Element } from "../../core/deno-dom.ts";
14
15
import { renderEjs } from "../../core/ejs.ts";
16
import { mergeConfigs } from "../../core/config.ts";
17
import { formatResourcePath, resourcePath } from "../../core/resources.ts";
18
import { TempContext } from "../../core/temp.ts";
19
import { asCssSize } from "../../core/css.ts";
20
21
import {
22
kBodyClasses,
23
kCodeLink,
24
kFigResponsive,
25
kFilterParams,
26
kHeaderIncludes,
27
kIncludeAfterBody,
28
kIncludeBeforeBody,
29
kIncludeInHeader,
30
kLinkExternalFilter,
31
kLinkExternalIcon,
32
kLinkExternalNewwindow,
33
kNotebookLinks,
34
kNotebookViewStyle,
35
kRespectUserColorScheme,
36
kTheme,
37
} from "../../config/constants.ts";
38
39
import {
40
DependencyHtmlFile,
41
Format,
42
FormatDependency,
43
FormatExtras,
44
kDependencies,
45
kHtmlPostprocessors,
46
kSassBundles,
47
Metadata,
48
PandocFlags,
49
SassBundle,
50
} from "../../config/types.ts";
51
52
import {
53
formatHasCodeTools,
54
kEmbeddedSourceModalId,
55
} from "../../command/render/codetools.ts";
56
57
import { createHtmlFormat } from "./../formats-shared.ts";
58
59
import {
60
darkModeDefault,
61
formatDarkMode,
62
formatHasBootstrap,
63
} from "./format-html-info.ts";
64
65
import { bootstrapExtras } from "./format-html-bootstrap.ts";
66
67
import {
68
clipboardDependency,
69
createCodeCopyButton,
70
kAnchorSections,
71
kAxe,
72
kBootstrapDependencyName,
73
kCitationsHover,
74
kCodeAnnotations,
75
kCodeCopy,
76
kComments,
77
kDocumentCss,
78
kFootnotesHover,
79
kGiscus,
80
kGiscusCategoryId,
81
kGiscusRepoId,
82
kHypothesis,
83
kMinimal,
84
kSmoothScroll,
85
kTabsets,
86
kUtterances,
87
kXrefsHover,
88
quartoBaseLayer,
89
quartoGlobalCssVariableRules,
90
} from "./format-html-shared.ts";
91
import {
92
kSiteUrl,
93
kWebsite,
94
} from "../../project/types/website/website-constants.ts";
95
import {
96
HtmlPostProcessResult,
97
RenderServices,
98
} from "../../command/render/types.ts";
99
import {
100
buildGiscusThemeKeys,
101
getDiscussionCategoryId,
102
getGithubDiscussionsMetadata,
103
GiscusTheme,
104
GiscusThemeToggleRecord,
105
} from "../../core/giscus.ts";
106
import { metadataPostProcessor } from "./format-html-meta.ts";
107
import { kHtmlEmptyPostProcessResult } from "../../command/render/constants.ts";
108
import { kNotebookViewStyleNotebook } from "./format-html-constants.ts";
109
import { notebookViewPostProcessor } from "./format-html-notebook.ts";
110
import { ProjectContext } from "../../project/types.ts";
111
import { kListing } from "../../project/types/website/listing/website-listing-shared.ts";
112
import {
113
HtmlFormatFeatureDefaults,
114
HtmlFormatScssOptions,
115
HtmlFormatTippyOptions,
116
} from "./format-html-types.ts";
117
import { kQuartoHtmlDependency } from "./format-html-constants.ts";
118
import { registerWriterFormatHandler } from "../format-handlers.ts";
119
import { brandSassFormatExtras } from "../../core/sass/brand.ts";
120
import { ESBuildAnalysis } from "../../core/esbuild.ts";
121
import { assert } from "testing/asserts";
122
import { axeFormatDependencies } from "./format-html-axe.ts";
123
124
let esbuildAnalysisCache: Record<string, ESBuildAnalysis> | undefined;
125
export function esbuildCachedAnalysis(
126
input: string,
127
): ESBuildAnalysis {
128
if (!esbuildAnalysisCache) {
129
esbuildAnalysisCache = JSON.parse(
130
Deno.readTextFileSync(
131
formatResourcePath("html", "esbuild-analysis-cache.json"),
132
),
133
) as Record<string, ESBuildAnalysis>;
134
}
135
const result = esbuildAnalysisCache[input];
136
assert(result, `Cached analysis not found for ${input}`);
137
return result;
138
}
139
140
function recursiveModuleDependencies(
141
path: string,
142
): DependencyHtmlFile[] {
143
const result: DependencyHtmlFile[] = [];
144
const inpRelPath = relative(join(resourcePath("formats"), "html"), path);
145
146
result.push({
147
name: inpRelPath,
148
path: formatResourcePath("html", inpRelPath),
149
attribs: { type: "module" },
150
});
151
152
const analysis = esbuildCachedAnalysis(inpRelPath);
153
// console.log(JSON.stringify(analysis, null, 2));
154
for (const [_key, value] of Object.entries(analysis.outputs)) {
155
for (const imp of value.imports) {
156
if (imp.external) {
157
const relPath = relative(path, join(path, imp.path));
158
result.push({
159
name: relPath,
160
path: formatResourcePath("html", relPath),
161
attribs: { type: "module" },
162
});
163
}
164
}
165
}
166
return result;
167
}
168
169
export function htmlFormat(
170
figwidth: number,
171
figheight: number,
172
): Format {
173
return mergeConfigs(
174
createHtmlFormat("HTML", figwidth, figheight),
175
{
176
render: {
177
[kNotebookLinks]: true,
178
},
179
resolveFormat: (format: Format) => {
180
if (format.metadata[kMinimal] === true) {
181
if (format.metadata[kFigResponsive] === undefined) {
182
format.metadata[kFigResponsive] = false;
183
}
184
if (format.metadata[kTheme] === undefined) {
185
format.metadata[kTheme] = "none";
186
}
187
}
188
},
189
formatExtras: async (
190
input: string,
191
_markdown: string,
192
flags: PandocFlags,
193
format: Format,
194
_libDir: string,
195
services: RenderServices,
196
offset: string,
197
project: ProjectContext,
198
quiet?: boolean,
199
) => {
200
// Warn the user if they are using a listing outside of a website
201
if (!project && format.metadata[kListing]) {
202
warning(
203
`Quarto only supports listings within websites. Please ensure that the file ${input} is a part of a website project to enable listing rendering.`,
204
);
205
}
206
207
const htmlFilterParams = htmlFormatFilterParams(format);
208
return mergeConfigs(
209
await htmlFormatExtras(
210
input,
211
flags,
212
offset,
213
format,
214
services.temp,
215
project,
216
),
217
themeFormatExtras(
218
input,
219
flags,
220
format,
221
services,
222
offset,
223
project,
224
quiet,
225
),
226
await brandSassFormatExtras(input, format, project),
227
{ [kFilterParams]: htmlFilterParams },
228
);
229
},
230
extensions: {
231
book: {
232
multiFile: true,
233
},
234
},
235
},
236
);
237
}
238
239
export async function htmlFormatExtras(
240
input: string,
241
_flags: PandocFlags,
242
offset: string,
243
format: Format,
244
temp: TempContext,
245
_project?: ProjectContext,
246
featureDefaults?: HtmlFormatFeatureDefaults,
247
tippyOptions?: HtmlFormatTippyOptions,
248
scssOptions?: HtmlFormatScssOptions,
249
): Promise<FormatExtras> {
250
const configurableExtras: FormatExtras[] = [
251
axeFormatDependencies(format, temp, format.metadata[kAxe]),
252
];
253
254
// note whether we are targeting bootstrap
255
const bootstrap = formatHasBootstrap(format);
256
257
// populate feature defaults if none provided
258
if (!featureDefaults) {
259
featureDefaults = htmlFormatFeatureDefaults(format);
260
}
261
262
// empty tippy options if none provided
263
if (!tippyOptions) {
264
tippyOptions = {};
265
}
266
if (!tippyOptions.config) {
267
tippyOptions.config = {};
268
}
269
if (!scssOptions) {
270
scssOptions = {};
271
}
272
if (scssOptions.quartoBase === undefined) {
273
scssOptions.quartoBase = true;
274
}
275
if (scssOptions.quartoCssVars === undefined) {
276
scssOptions.quartoCssVars = true;
277
}
278
279
// lists of scripts and ejs data for the orchestration script
280
const scripts: DependencyHtmlFile[] = [];
281
const stylesheets: DependencyHtmlFile[] = [];
282
const sassBundles: SassBundle[] = [];
283
const dependencies: FormatDependency[] = [];
284
285
const options: Record<string, unknown> =
286
ld.isObject(format.metadata[kComments])
287
? {
288
[kHypothesis]: (format.metadata[kComments] as Record<string, unknown>)[
289
kHypothesis
290
] ||
291
false,
292
[kUtterances]: (format.metadata[kComments] as Record<string, unknown>)[
293
kUtterances
294
] ||
295
false,
296
[kGiscus]:
297
(format.metadata[kComments] as Record<string, unknown>)[kGiscus] ||
298
false,
299
}
300
: {};
301
options.codeLink = format.metadata[kCodeLink] || false;
302
303
// apply defaults
304
if (featureDefaults.tabby) {
305
options.tabby = format.metadata[kTabsets] !== false;
306
} else {
307
options.tabby = format.metadata[kTabsets] || false;
308
}
309
if (featureDefaults.copyCode) {
310
options.copyCode = format.metadata[kCodeCopy] !== false;
311
} else {
312
options.copyCode = format.metadata[kCodeCopy] || false;
313
}
314
if (featureDefaults.anchors) {
315
options.anchors = format.metadata[kAnchorSections] !== false;
316
} else {
317
options.anchors = format.metadata[kAnchorSections] || false;
318
}
319
if (featureDefaults.hoverCitations) {
320
options.hoverCitations = format.metadata[kCitationsHover] !== false;
321
} else {
322
options.hoverCitations = format.metadata[kCitationsHover] || false;
323
}
324
if (featureDefaults.hoverFootnotes) {
325
options.hoverFootnotes = format.metadata[kFootnotesHover] !== false;
326
} else {
327
options.hoverFootnotes = format.metadata[kFootnotesHover] || false;
328
}
329
330
// Books don't currently support hover xrefs (since the content to preview in the xref
331
// is likely to be on another page and we don't want to do a full fetch of that page
332
// to get the preview)
333
if (featureDefaults.hoverXrefs) {
334
options.hoverXrefs = format.metadata[kXrefsHover] !== false;
335
} else {
336
options.hoverXrefs = format.metadata[kXrefsHover] || false;
337
}
338
if (featureDefaults.figResponsive) {
339
options.figResponsive = format.metadata[kFigResponsive] !== false;
340
} else {
341
options.figResponsive = format.metadata[kFigResponsive] || false;
342
}
343
if (featureDefaults.codeAnnotations) {
344
options.codeAnnotations = format.metadata[kCodeAnnotations] || true;
345
} else {
346
options.codeAnnotations = format.metadata[kCodeAnnotations] || false;
347
}
348
349
options.zenscroll = format.metadata[kSmoothScroll];
350
options.codeTools = formatHasCodeTools(format);
351
options.darkMode = formatDarkMode(format);
352
options.darkModeDefault = darkModeDefault(format);
353
options.respectUserColorScheme = format.metadata[kRespectUserColorScheme] ||
354
false;
355
options.linkExternalIcon = format.render[kLinkExternalIcon];
356
options.linkExternalNewwindow = format.render[kLinkExternalNewwindow];
357
options.linkExternalFilter = format.render[kLinkExternalFilter];
358
359
// If there is a site URL, we can use that as the default filter
360
const siteMetadata = format.metadata[kWebsite] as Metadata;
361
if (!options.linkExternalFilter && siteMetadata) {
362
const siteUrl = siteMetadata[kSiteUrl] as string;
363
if (siteUrl) {
364
options.linkExternalFilter = siteUrl.replaceAll(".", "\\.").replaceAll(
365
"/",
366
"\\/",
367
);
368
}
369
}
370
371
// quarto.js helpers
372
if (bootstrap) {
373
const deps = recursiveModuleDependencies(
374
formatResourcePath("html", "quarto.js"),
375
);
376
scripts.push(...deps);
377
}
378
379
// tabby if required
380
if (options.tabby) {
381
scripts.push({
382
name: "tabby.min.js",
383
path: formatResourcePath("html", join("tabby", "js", "tabby.js")),
384
});
385
}
386
387
// header includes
388
const includeInHeader: string[] = [];
389
390
// zenscroll if required
391
if (options.zenscroll) {
392
scripts.push({
393
name: "zenscroll-min.js",
394
path: formatResourcePath("html", join("zenscroll", "zenscroll-min.js")),
395
afterBody: true,
396
});
397
398
const zenscrollStyle = temp.createFile({ suffix: "-zen.html" });
399
Deno.writeTextFileSync(
400
zenscrollStyle,
401
"<style>html{ scroll-behavior: smooth; }</style>",
402
);
403
includeInHeader.push(zenscrollStyle);
404
}
405
406
// popper if required
407
options.tippy = options.hoverCitations || options.hoverFootnotes ||
408
options.codeAnnotations || options.hoverXrefs;
409
if (bootstrap || options.tippy) {
410
scripts.push({
411
name: "popper.min.js",
412
path: formatResourcePath("html", join("popper", "popper.min.js")),
413
});
414
}
415
416
// tippy if required
417
if (options.tippy) {
418
scripts.push({
419
name: "tippy.umd.min.js",
420
path: formatResourcePath("html", join("tippy", "tippy.umd.min.js")),
421
});
422
stylesheets.push({
423
name: "tippy.css",
424
path: formatResourcePath("html", join("tippy", "tippy.css")),
425
});
426
427
// If this is a bootstrap format, include requires sass
428
if (tippyOptions.theme === undefined) {
429
if (bootstrap) {
430
tippyOptions.theme = "quarto";
431
sassBundles.push({
432
key: "tippy.scss",
433
dependency: kBootstrapDependencyName,
434
quarto: {
435
uses: "",
436
functions: "",
437
defaults: "",
438
mixins: "",
439
rules: Deno.readTextFileSync(
440
formatResourcePath("html", join("tippy", "_tippy.scss")),
441
),
442
},
443
});
444
} else {
445
tippyOptions.theme = "light-border";
446
stylesheets.push({
447
name: "light-border.css",
448
path: formatResourcePath("html", join("tippy", "light-border.css")),
449
});
450
}
451
}
452
}
453
454
// propagate tippyOptions
455
options.tippyOptions = tippyOptions;
456
457
// clipboard.js if required
458
if (options.copyCode) {
459
dependencies.push(clipboardDependency());
460
}
461
462
// Add localization strings
463
options.language = format.language;
464
465
// anchors if required
466
if (options.anchors) {
467
scripts.push({
468
name: "anchor.min.js",
469
path: formatResourcePath("html", join("anchor", "anchor.min.js")),
470
});
471
options.anchors = typeof (options.anchors) === "string"
472
? options.anchors
473
: true;
474
}
475
476
// add quarto sass bundle of we aren't in bootstrap
477
const minimal = format.metadata[kMinimal] === true;
478
if (!bootstrap && !minimal) {
479
if (scssOptions.quartoBase) {
480
sassBundles.push({
481
dependency: kQuartoHtmlDependency,
482
key: kQuartoHtmlDependency,
483
quarto: quartoBaseLayer(
484
format,
485
!!options.copyCode,
486
!!options.tabby,
487
!!options.figResponsive,
488
),
489
});
490
}
491
if (scssOptions.quartoCssVars) {
492
sassBundles.push({
493
dependency: kQuartoHtmlDependency,
494
key: kQuartoHtmlDependency,
495
quarto: {
496
uses: "",
497
defaults: "",
498
functions: "",
499
mixins: "",
500
rules: quartoGlobalCssVariableRules(),
501
},
502
});
503
}
504
}
505
506
// hypothesis
507
if (options.hypothesis) {
508
const hypothesisHeader = temp.createFile({ suffix: "-hypoth.html" });
509
Deno.writeTextFileSync(
510
hypothesisHeader,
511
renderEjs(
512
formatResourcePath("html", join("hypothesis", "hypothesis.ejs")),
513
{ hypothesis: options.hypothesis },
514
),
515
);
516
includeInHeader.push(hypothesisHeader);
517
}
518
519
// before and after body
520
const includeBeforeBody: string[] = [];
521
const includeAfterBody: string[] = [];
522
523
// add main orchestion script if we have any options enabled
524
const quartoHtmlRequired = Object.keys(options).some((option) =>
525
!!options[option]
526
);
527
if (quartoHtmlRequired) {
528
for (
529
const { dest, ejsfile } of [
530
{ dest: includeBeforeBody, ejsfile: "quarto-html-before-body.ejs" },
531
{ dest: includeAfterBody, ejsfile: "quarto-html-after-body.ejs" },
532
]
533
) {
534
const quartoHtmlScript = temp.createFile();
535
const renderedHtml = renderEjs(
536
formatResourcePath("html", join("templates", ejsfile)),
537
options,
538
);
539
if (renderedHtml.trim() !== "") {
540
Deno.writeTextFileSync(quartoHtmlScript, renderedHtml);
541
dest.push(quartoHtmlScript);
542
}
543
}
544
}
545
546
// utterances
547
if (options.utterances) {
548
if (typeof (options.utterances) !== "object") {
549
throw new Error("Invalid utterances configuration (must provide a repo");
550
}
551
const utterances = options.utterances as Record<string, string>;
552
if (!utterances["repo"]) {
553
throw new Error("Invalid utterances coniguration (must provide a repo)");
554
}
555
utterances["issue-term"] = utterances["issue-term"] || "pathname";
556
utterances["theme"] = utterances["theme"] || "github-light";
557
const utterancesAfterBody = temp.createFile({ suffix: "-utter.html" });
558
Deno.writeTextFileSync(
559
utterancesAfterBody,
560
renderEjs(
561
formatResourcePath("html", join("utterances", "utterances.ejs")),
562
{ utterances },
563
),
564
);
565
includeAfterBody.push(utterancesAfterBody);
566
}
567
568
// giscus
569
if (options.giscus) {
570
const giscus = options.giscus as Record<string, unknown>;
571
572
giscus.category = giscus.category || "General";
573
giscus.theme = giscus.theme || "";
574
575
const themeToggleRecord: GiscusThemeToggleRecord = buildGiscusThemeKeys(
576
Boolean(options.darkModeDefault),
577
giscus.theme as GiscusTheme,
578
);
579
580
giscus.baseTheme = themeToggleRecord.baseTheme;
581
giscus.altTheme = themeToggleRecord.altTheme;
582
giscus.theme = giscus.baseTheme;
583
584
giscus.mapping = giscus.mapping || "title";
585
giscus["reactions-enabled"] = giscus["reactions-enabled"] !== undefined
586
? giscus["reactions-enabled"]
587
: true;
588
giscus["input-position"] = giscus["input-position"] || "top";
589
giscus.language = giscus.language || "en";
590
591
if (
592
giscus[kGiscusRepoId] === undefined ||
593
giscus[kGiscusCategoryId] === undefined
594
) {
595
const discussionData = await getGithubDiscussionsMetadata(
596
giscus.repo as string,
597
);
598
599
// Fetch repo info
600
if (giscus[kGiscusRepoId] === undefined && discussionData.repositoryId) {
601
giscus[kGiscusRepoId] = discussionData.repositoryId;
602
}
603
604
const categoryId = getDiscussionCategoryId(
605
giscus.category as string,
606
discussionData,
607
);
608
if (giscus[kGiscusCategoryId] === undefined && categoryId) {
609
giscus[kGiscusCategoryId] = categoryId;
610
}
611
}
612
613
const giscusAfterBody = temp.createFile({ suffix: "-giscus.html" });
614
Deno.writeTextFileSync(
615
giscusAfterBody,
616
renderEjs(
617
formatResourcePath("html", join("giscus", "giscus.ejs")),
618
{ giscus, darkMode: options.darkMode },
619
),
620
);
621
includeAfterBody.push(giscusAfterBody);
622
}
623
624
// return extras
625
dependencies.push({
626
name: kQuartoHtmlDependency,
627
scripts,
628
stylesheets,
629
});
630
631
// Provide a template and partials
632
const templateDir = formatResourcePath("html", "pandoc");
633
const partials = [
634
"metadata.html",
635
"title-block.html",
636
"toc.html",
637
"styles.html",
638
];
639
const templateContext = {
640
template: join(templateDir, "template.html"),
641
partials: partials.map((partial) => join(templateDir, partial)),
642
};
643
644
const htmlPostProcessors = [
645
htmlFormatPostprocessor(format, featureDefaults),
646
metadataPostProcessor(input, format, offset),
647
];
648
const viewStyle = format.render[kNotebookViewStyle];
649
if (viewStyle === kNotebookViewStyleNotebook) {
650
htmlPostProcessors.push(notebookViewPostProcessor());
651
}
652
653
const metadata: Metadata = {};
654
const result: FormatExtras = {
655
[kIncludeInHeader]: includeInHeader,
656
[kIncludeBeforeBody]: includeBeforeBody,
657
[kIncludeAfterBody]: includeAfterBody,
658
metadata,
659
templateContext,
660
html: {
661
[kDependencies]: dependencies,
662
[kSassBundles]: sassBundles,
663
[kHtmlPostprocessors]: htmlPostProcessors,
664
},
665
};
666
667
return mergeConfigs(
668
result,
669
...configurableExtras,
670
) as FormatExtras;
671
}
672
673
const kFormatHasBootstrap = "has-bootstrap";
674
function htmlFormatFilterParams(format: Format) {
675
return {
676
[kFormatHasBootstrap]: formatHasBootstrap(format),
677
};
678
}
679
680
function htmlFormatFeatureDefaults(
681
format: Format,
682
): HtmlFormatFeatureDefaults {
683
const bootstrap = formatHasBootstrap(format);
684
const minimal = format.metadata[kMinimal] === true;
685
return {
686
tabby: !minimal && !bootstrap,
687
copyCode: !minimal,
688
anchors: !minimal,
689
hoverCitations: !minimal,
690
hoverFootnotes: !minimal,
691
figResponsive: !minimal,
692
codeAnnotations: !minimal,
693
hoverXrefs: !minimal,
694
};
695
}
696
697
function htmlFormatPostprocessor(
698
format: Format,
699
featureDefaults?: HtmlFormatFeatureDefaults,
700
) {
701
// do we have haveBootstrap
702
const haveBootstrap = formatHasBootstrap(format);
703
704
// get feature defaults
705
if (!featureDefaults) {
706
featureDefaults = htmlFormatFeatureDefaults(format);
707
}
708
709
// read options
710
const codeCopy = featureDefaults.copyCode
711
? format.metadata[kCodeCopy] !== false
712
: format.metadata[kCodeCopy] || false;
713
714
const anchors = featureDefaults.anchors
715
? format.metadata[kAnchorSections] !== false
716
: format.metadata[kAnchorSections] || false;
717
718
return (doc: Document): Promise<HtmlPostProcessResult> => {
719
// Add body class, if present
720
if (format.render[kBodyClasses]) {
721
const clz = format.render[kBodyClasses].split(" ");
722
clz.forEach((cls) => {
723
doc.body.classList.add(cls);
724
});
725
}
726
727
// process all of the code blocks
728
const codeBlocks = doc.querySelectorAll("pre.sourceCode");
729
const EmbedSourceModal = doc.querySelector(
730
`#${kEmbeddedSourceModalId}`,
731
);
732
for (let i = 0; i < codeBlocks.length; i++) {
733
const code = codeBlocks[i] as Element;
734
735
// hoist hidden and cell-code to parent div
736
const parentHoist = (clz: string) => {
737
if (code.classList.contains(clz)) {
738
code.classList.remove(clz);
739
code.parentElement?.classList.add(clz);
740
}
741
};
742
parentHoist("cell-code");
743
parentHoist("hidden");
744
745
// hoist hidden to parent div
746
if (code.classList.contains("hidden")) {
747
code.classList.remove("hidden");
748
code.parentElement?.classList.add("hidden");
749
}
750
751
// insert code copy button (with specfic attribute when inside a modal)
752
if (codeCopy) {
753
// the interaction of code copy button fixed position
754
// and scrolling overflow behavior requires a scaffold div to be inserted
755
// as a parent of the code block and the copy button both
756
// (see #13009, #5538, and #12787)
757
const outerScaffold = doc.createElement("div");
758
outerScaffold.classList.add("code-copy-outer-scaffold");
759
760
const copyButton = createCodeCopyButton(doc, format);
761
if (EmbedSourceModal && EmbedSourceModal.contains(code)) {
762
copyButton.setAttribute("data-in-quarto-modal", "");
763
}
764
code.classList.add("code-with-copy");
765
766
const sourceCodeDiv = code.parentElement!;
767
const sourceCodeDivParent = code.parentElement?.parentElement;
768
sourceCodeDivParent!.replaceChild(outerScaffold, sourceCodeDiv);
769
770
outerScaffold.appendChild(sourceCodeDiv);
771
outerScaffold.appendChild(copyButton);
772
}
773
774
// insert example iframe
775
if (code.parentElement?.getAttribute("data-code-preview")) {
776
const codeExample = doc.createElement("iframe");
777
for (const parentClass of code.classList) {
778
codeExample.classList.add(parentClass);
779
}
780
codeExample.setAttribute(
781
"src",
782
code.parentElement.getAttribute("data-code-preview")!.replace(
783
/\.qmd$/,
784
".html",
785
),
786
);
787
code.parentElement.removeAttribute(
788
"data-code-preview",
789
);
790
code.parentElement.appendChild(codeExample);
791
}
792
}
793
794
// add .anchored class to headings
795
if (anchors) {
796
const container = haveBootstrap
797
? doc.querySelector("main")
798
: doc.querySelector("body");
799
800
if (container) {
801
["h2", "h3", "h4", "h5", "h6", ".quarto-figure[id]", "div[id^=tbl-]"]
802
.forEach(
803
(selector) => {
804
const headings = container.querySelectorAll(selector);
805
for (let i = 0; i < headings.length; i++) {
806
const heading = headings[i] as Element;
807
if (heading.id !== "toc-title") {
808
if (!heading.classList.contains("no-anchor")) {
809
heading.classList.add("anchored");
810
}
811
}
812
}
813
},
814
);
815
}
816
}
817
818
// remove toc-section-number if we have provided our own section numbers
819
const headerSections = doc.querySelectorAll(".header-section-number");
820
for (let i = 0; i < headerSections.length; i++) {
821
const secNumber = headerSections[i] as Element;
822
const prevElement = secNumber.previousElementSibling;
823
if (prevElement && prevElement.classList.contains("toc-section-number")) {
824
secNumber.remove();
825
}
826
}
827
828
// Process code annotations that may appear in this document
829
processCodeAnnotations(format, doc);
830
831
// Process tables to restore th-vs-td markers
832
const tables = doc.querySelectorAll(
833
'table[data-quarto-postprocess="true"]',
834
);
835
for (let i = 0; i < tables.length; ++i) {
836
const table = tables[i] as Element;
837
if (table.getAttribute("data-quarto-disable-processing") === "true") {
838
continue;
839
}
840
table.removeAttribute("data-quarto-postprocess");
841
table.removeAttribute("data-quarto-disable-processing");
842
table.querySelectorAll("tr").forEach((tr) => {
843
const { children } = tr as Element;
844
for (let j = 0; j < children.length; ++j) {
845
const child = children[j] as Element;
846
if (child.tagName === "TH" || child.tagName === "TD") {
847
const isTH =
848
child.getAttribute("data-quarto-table-cell-role") === "th";
849
// create a new element with the correct tag and move all children and attributes to
850
// new element
851
const newElement = doc.createElement(isTH ? "th" : "td");
852
while (child.firstChild) {
853
newElement.appendChild(child.firstChild);
854
}
855
for (let k = 0; k < child.attributes.length; ++k) {
856
const attr = child.attributes[k];
857
newElement.setAttribute(attr.name, attr.value);
858
}
859
860
// replace the old element with the new one
861
child.parentNode?.replaceChild(newElement, child);
862
}
863
}
864
});
865
}
866
867
// Process drafts, if needed
868
const metadraftEl = doc.querySelector("meta[name='quarto:status']");
869
if (metadraftEl !== null) {
870
const status = metadraftEl.getAttribute("content");
871
if (status === "draft") {
872
const draftDivEl = doc.createElement("DIV");
873
874
const iconEl = doc.createElement("I");
875
iconEl.classList.add("bi");
876
iconEl.classList.add("bi-pencil-square");
877
const textNode = doc.createTextNode(format.language.draft || "Draft");
878
879
draftDivEl.appendChild(iconEl);
880
draftDivEl.appendChild(textNode);
881
draftDivEl.setAttribute("id", "quarto-draft-alert");
882
draftDivEl.classList.add("alert");
883
draftDivEl.classList.add("alert-warning");
884
885
// Find the header and place it there
886
let targetEl = doc.body;
887
const headerEl = doc.getElementById("quarto-header");
888
if (headerEl !== null) {
889
targetEl = headerEl;
890
}
891
targetEl.insertBefore(draftDivEl, targetEl.firstChild);
892
}
893
}
894
895
// no resource refs
896
return Promise.resolve(kHtmlEmptyPostProcessResult);
897
};
898
}
899
900
const kCodeCellAttr = "data-code-cell";
901
const kCodeLinesAttr = "data-code-lines";
902
const kCodeAnnotationAttr = "data-code-annotation";
903
904
const kCodeCellTargetAttr = "data-target-cell";
905
const kCodeAnnotationTargetAttr = "data-target-annotation";
906
907
const kCodeAnnotationHiddenClz = "code-annotation-container-hidden";
908
const kCodeAnnotationGridClz = "code-annotation-container-grid";
909
const kCodeAnnotationAnchorClz = "code-annotation-anchor";
910
const kCodeAnnotationTargetClz = "code-annotation-target";
911
912
const kCodeAnnotationParentClz = "code-annotated";
913
const kCodeAnnotationGutterClz = "code-annotation-gutter";
914
const kCodeAnnotationGutterBgClz = "code-annotation-gutter-bg";
915
916
function processCodeAnnotations(format: Format, doc: Document) {
917
const annotationStyle: boolean | string = format.metadata[kCodeAnnotations] as
918
| string
919
| boolean;
920
921
const replaceLineNumberWithAnnote = (annoteEl: Element, dtEl: Element) => {
922
const annotation = annoteEl.getAttribute(kCodeAnnotationAttr);
923
if (annotation !== null) {
924
const ddEl = dtEl.previousElementSibling;
925
if (ddEl) {
926
ddEl.innerHTML = "";
927
ddEl.innerText = annotation;
928
const codeCell = annoteEl.getAttribute(kCodeCellAttr);
929
if (codeCell) {
930
ddEl.setAttribute(kCodeCellTargetAttr, codeCell);
931
ddEl.setAttribute(kCodeAnnotationTargetAttr, annotation);
932
}
933
}
934
}
935
};
936
937
if (annotationStyle === false) {
938
// Read the definition list values which contain the annotations
939
const annoteNodes = doc.querySelectorAll(`span[${kCodeCellAttr}]`);
940
941
// annotations are disabled, just hide the DL container
942
for (const annoteNode of annoteNodes) {
943
const annoteEl = annoteNode as Element;
944
945
// Mark the parent DL container with a class
946
// so CSS can target it
947
const parentDL = annoteEl.parentElement?.parentElement;
948
if (
949
parentDL && !parentDL.classList.contains(kCodeAnnotationHiddenClz)
950
) {
951
parentDL.classList.add(kCodeAnnotationHiddenClz);
952
}
953
}
954
} else if (annotationStyle === "hover" || annotationStyle === "select") {
955
const definitionLists = processCodeBlockAnnotation(
956
doc,
957
true,
958
"start",
959
replaceLineNumberWithAnnote,
960
);
961
962
Object.values(definitionLists).forEach((dl) => {
963
dl.classList.add(kCodeAnnotationHiddenClz);
964
dl.classList.add(kCodeAnnotationGridClz);
965
});
966
} else {
967
const definitionLists = processCodeBlockAnnotation(
968
doc,
969
false,
970
"start",
971
replaceLineNumberWithAnnote,
972
);
973
974
Object.values(definitionLists).forEach((dl) => {
975
dl.classList.add(kCodeAnnotationGridClz);
976
});
977
}
978
}
979
980
// returns DLs that were processed
981
982
function processCodeBlockAnnotation(
983
doc: Document,
984
interactiveAnnotations: boolean,
985
annotationPosition: "start" | "middle",
986
processDt?: (annotationEl: Element, dtEl: Element) => void,
987
) {
988
const definitionLists: Record<string, Element> = {};
989
const codeBlockParents: Element[] = [];
990
991
// Read the definition list values which contain the annotations
992
const annoteNodes = doc.querySelectorAll(`span[${kCodeCellAttr}]`);
993
for (const annoteNode of annoteNodes) {
994
const annoteEl = annoteNode as Element;
995
996
// Accumulate the Code Blocks
997
const parentCodeBlock = processLineAnnotation(
998
doc,
999
annoteEl,
1000
interactiveAnnotations,
1001
annotationPosition,
1002
);
1003
if (parentCodeBlock && !codeBlockParents.includes(parentCodeBlock)) {
1004
codeBlockParents.push(parentCodeBlock);
1005
}
1006
1007
// Accumulate the Definition Lists
1008
const parentDL = annoteEl.parentElement?.parentElement;
1009
const codeParentDivId = parentCodeBlock?.parentElement?.parentElement?.id;
1010
if (
1011
parentDL && codeParentDivId &&
1012
!Object.keys(definitionLists).includes(codeParentDivId)
1013
) {
1014
definitionLists[codeParentDivId] = parentDL;
1015
}
1016
1017
if (annoteEl.parentElement && processDt) {
1018
processDt(annoteEl, annoteEl.parentElement);
1019
}
1020
}
1021
1022
// Inject a gutter for the annotations
1023
for (const codeParentEl of codeBlockParents) {
1024
if (codeParentEl.parentElement) {
1025
// Decorate the pre so that we can adjust styles if needed
1026
codeParentEl.parentElement.classList.add(kCodeAnnotationParentClz);
1027
}
1028
1029
const gutterBgDivEl = doc.createElement("div");
1030
gutterBgDivEl.classList.add(kCodeAnnotationGutterBgClz);
1031
codeParentEl?.appendChild(gutterBgDivEl);
1032
1033
const gutterDivEl = doc.createElement("div");
1034
gutterDivEl.classList.add(kCodeAnnotationGutterClz);
1035
codeParentEl?.appendChild(gutterDivEl);
1036
}
1037
1038
return definitionLists;
1039
}
1040
1041
function processLineAnnotation(
1042
doc: Document,
1043
annoteEl: Element,
1044
interactive: boolean,
1045
position: "start" | "middle",
1046
) {
1047
// Read the target values from the annotation DL
1048
const targetCell = annoteEl.getAttribute(kCodeCellAttr);
1049
const targetLines = annoteEl.getAttribute(kCodeLinesAttr);
1050
const targetAnnotation = annoteEl.getAttribute(kCodeAnnotationAttr);
1051
if (targetCell && targetLines) {
1052
const lineArr = targetLines?.split(",");
1053
1054
const targetIndex = position === "start"
1055
? 0
1056
: Math.floor(lineArr.length / 2);
1057
const line = lineArr[targetIndex];
1058
1059
const targetId = `${targetCell}-${line}`;
1060
const targetEl = doc.getElementById(targetId);
1061
if (targetEl) {
1062
const annoteAnchorEl = doc.createElement(interactive ? "button" : "a");
1063
annoteAnchorEl.classList.add(kCodeAnnotationAnchorClz);
1064
annoteAnchorEl.setAttribute(
1065
kCodeCellTargetAttr,
1066
`${targetCell}`,
1067
);
1068
annoteAnchorEl.setAttribute(
1069
kCodeAnnotationTargetAttr,
1070
`${targetAnnotation}`,
1071
);
1072
if (!interactive) {
1073
annoteAnchorEl.setAttribute("onclick", "event.preventDefault();");
1074
}
1075
annoteAnchorEl.innerText = targetAnnotation || "?";
1076
targetEl.parentElement?.insertBefore(annoteAnchorEl, targetEl);
1077
targetEl.classList.add(kCodeAnnotationTargetClz);
1078
return targetEl.parentElement;
1079
}
1080
}
1081
}
1082
1083
function themeFormatExtras(
1084
input: string,
1085
flags: PandocFlags,
1086
format: Format,
1087
sevices: RenderServices,
1088
offset: string | undefined,
1089
project: ProjectContext,
1090
quiet?: boolean,
1091
) {
1092
const theme = format.metadata[kTheme];
1093
if (theme === "none") {
1094
return {
1095
metadata: {
1096
[kDocumentCss]: false,
1097
},
1098
};
1099
} else if (theme === "pandoc") {
1100
return pandocExtras(format);
1101
} else {
1102
return bootstrapExtras(
1103
input,
1104
flags,
1105
format,
1106
sevices,
1107
offset,
1108
project,
1109
quiet,
1110
);
1111
}
1112
}
1113
1114
function pandocExtras(format: Format) {
1115
// see if there is a max-width
1116
const maxWidth = format.metadata["max-width"];
1117
const headerIncludes = maxWidth
1118
? `<style type="text/css">body { max-width: ${
1119
asCssSize(maxWidth)
1120
};}</style>`
1121
: undefined;
1122
1123
return {
1124
metadata: {
1125
[kDocumentCss]: true,
1126
[kHeaderIncludes]: headerIncludes,
1127
},
1128
};
1129
}
1130
1131
registerWriterFormatHandler((format) => {
1132
switch (format) {
1133
case "html":
1134
case "html4":
1135
case "html5":
1136
return {
1137
format: htmlFormat(7, 5),
1138
};
1139
}
1140
});
1141
1142