Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/render/codetools.ts
3583 views
1
/*
2
* codetools.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { Document, Element } from "../../core/deno-dom.ts";
8
import {
9
kHtmlEmptyPostProcessResult,
10
kMarkdownBlockSeparator,
11
} from "./constants.ts";
12
import { HtmlPostProcessResult } from "./types.ts";
13
import { Format } from "../../config/types.ts";
14
import {
15
kCodeTools,
16
kCodeToolsHideAllCode,
17
kCodeToolsMenuCaption,
18
kCodeToolsShowAllCode,
19
kCodeToolsSourceCode,
20
kCodeToolsViewSource,
21
kKeepSource,
22
} from "../../config/constants.ts";
23
import { ExecutionEngine, ExecutionTarget } from "../../execute/types.ts";
24
25
import { isHtmlOutput } from "../../config/format.ts";
26
import { executionEngineCanKeepSource } from "../../execute/engine-info.ts";
27
import { formatHasBootstrap } from "../../format/html/format-html-info.ts";
28
import { withTiming } from "../../core/timing.ts";
29
30
const kHideAllCodeLinkId = "quarto-hide-all-code";
31
const kShowAllCodeLinkId = "quarto-show-all-code";
32
const kViewSourceLinkId = "quarto-view-source";
33
const kEmbeddedSourceClass = "quarto-embedded-source-code";
34
export const kEmbeddedSourceModalId = kEmbeddedSourceClass + "-modal";
35
const kEmbeddedSourceModalLabelId = kEmbeddedSourceClass + "-modal-label";
36
const kKeepSourceSentinel = "quarto-executable-code-5450563D";
37
38
export const kCodeToolsSourceButtonId = "quarto-code-tools-source";
39
export const kCodeToolsMenuButtonId = "quarto-code-tools-menu";
40
export const kDataQuartoSourceUrl = "data-quarto-source-url";
41
42
export function formatHasCodeTools(format: Format) {
43
const codeTools = format.render?.[kCodeTools];
44
return !!codeTools && isHtmlOutput(format.pandoc, true) &&
45
formatHasBootstrap(format);
46
}
47
48
export function resolveKeepSource(
49
format: Format,
50
engine: ExecutionEngine,
51
target: ExecutionTarget,
52
) {
53
// keep source if requested (via keep-source or code-tools), we are targeting html,
54
// and engine can keep it (e.g. we wouldn't keep an .ipynb file as source)
55
const codeTools = format.render?.[kCodeTools];
56
if (
57
codeTools === true ||
58
(typeof codeTools === "object" &&
59
(codeTools?.source === undefined || codeTools?.source === true))
60
) {
61
format.render[kKeepSource] = true;
62
}
63
format.render[kKeepSource] = format.render[kKeepSource] &&
64
isHtmlOutput(format.pandoc, true) &&
65
formatHasBootstrap(format) &&
66
executionEngineCanKeepSource(engine, target);
67
}
68
69
export function keepSourceBlock(format: Format, source: string) {
70
if (format.render[kKeepSource]) {
71
// read code
72
let code = Deno.readTextFileSync(source).trimLeft();
73
if (!code.endsWith("\n")) {
74
code = code + "\n";
75
}
76
77
// make sure that quarto code blocks get correct highlighting
78
code = code.replaceAll(
79
/\n```{(\w+)}\s*\n/g,
80
"\n" + kKeepSourceSentinel + "\n\n```$1\n",
81
);
82
83
const kKeepSourceBackticks = "```````````````````";
84
return `${kMarkdownBlockSeparator}::: {.${kEmbeddedSourceClass}}\n${kKeepSourceBackticks}` +
85
`{.markdown shortcodes="false"}\n${code}` +
86
`${kKeepSourceBackticks}\n:::\n`;
87
} else {
88
return "";
89
}
90
}
91
92
export function codeToolsPostprocessor(format: Format) {
93
return (doc: Document): Promise<HtmlPostProcessResult> => {
94
return withTiming("codeToolsPostprocessor", () => {
95
if (format.render[kKeepSource]) {
96
// fixup the lines in embedded source
97
const lines = doc.querySelectorAll(
98
`.${kEmbeddedSourceClass} > div.sourceCode > pre > code > span`,
99
);
100
if (lines.length > 0) {
101
const newLines: Element[] = [];
102
for (let i = 0; i < lines.length; i++) {
103
const line = lines[i] as Element;
104
if (line.innerText === kKeepSourceSentinel) {
105
i += 2;
106
const codeBlockLine = lines[i] as Element;
107
const anchor = codeBlockLine.querySelector("a");
108
const text = codeBlockLine.innerText;
109
110
codeBlockLine.textContent = "";
111
codeBlockLine.appendChild(anchor!);
112
const newSpan = doc.createElement("span");
113
newSpan.classList.add("in");
114
newSpan.innerText = text.replace(
115
/```(\w+)/,
116
"```{$1}",
117
);
118
codeBlockLine.appendChild(newSpan);
119
120
newLines.push(codeBlockLine);
121
} else {
122
newLines.push(line);
123
}
124
}
125
if (newLines.length !== lines.length) {
126
const parent = (lines[0] as Element).parentElement!;
127
parent.innerHTML = "";
128
newLines.forEach((line) => {
129
parent.appendChild(line);
130
parent.appendChild(doc.createTextNode("\n"));
131
});
132
}
133
}
134
}
135
136
// provide code tools in header
137
if (formatHasCodeTools(format)) {
138
// resolve what sort of code tools we will present
139
const codeTools = resolveCodeTools(format, doc);
140
if (codeTools.source || codeTools.toggle) {
141
const title = doc.querySelector("#title-block-header h1");
142
const header = title !== null
143
? (title as Element).parentElement
144
: doc.querySelector("main.content");
145
146
if (header) {
147
const titleDiv = doc.createElement("div");
148
titleDiv.classList.add("quarto-title-block");
149
const layoutDiv = doc.createElement("div");
150
titleDiv.appendChild(layoutDiv);
151
if (title) {
152
header?.replaceChild(titleDiv, title);
153
layoutDiv.appendChild(title);
154
} else {
155
// create an empty title
156
const h1El = doc.createElement("h1");
157
layoutDiv.appendChild(h1El);
158
layoutDiv.classList.add("quarto-title-tools-only");
159
}
160
const button = doc.createElement("button");
161
button.setAttribute("type", "button");
162
button.classList.add("btn");
163
button.classList.add("code-tools-button");
164
const icon = doc.createElement("i");
165
icon.classList.add("bi");
166
button.appendChild(icon);
167
if (codeTools.caption !== "none") {
168
button.appendChild(doc.createTextNode(" " + codeTools.caption));
169
}
170
layoutDiv.appendChild(button);
171
172
if (title) {
173
header!.appendChild(titleDiv);
174
} else {
175
header!.prepend(titleDiv);
176
}
177
178
if (codeTools.toggle) {
179
button.setAttribute("id", kCodeToolsMenuButtonId);
180
button.classList.add("dropdown-toggle");
181
button.setAttribute("data-bs-toggle", "dropdown");
182
button.setAttribute("aria-expanded", "false");
183
const ul = doc.createElement("ul");
184
ul.classList.add("dropdown-menu");
185
ul.classList.add("dropdown-menu-end");
186
ul.setAttribute("aria-labelelledby", kCodeToolsMenuButtonId);
187
const addListItem = (id: string, text: string) => {
188
const a = doc.createElement("a");
189
a.setAttribute("id", id);
190
a.classList.add("dropdown-item");
191
a.setAttribute("href", "javascript:void(0)");
192
a.setAttribute("role", "button");
193
a.appendChild(doc.createTextNode(text));
194
const li = doc.createElement("li");
195
li.appendChild(a);
196
ul.appendChild(li);
197
return li;
198
};
199
const addDivider = () => {
200
const hr = doc.createElement("hr");
201
hr.classList.add("dropdown-divider");
202
const li = doc.createElement("li");
203
li.appendChild(hr);
204
ul.appendChild(li);
205
};
206
addListItem(
207
kShowAllCodeLinkId,
208
format.language[kCodeToolsShowAllCode]!,
209
);
210
addListItem(
211
kHideAllCodeLinkId,
212
format.language[kCodeToolsHideAllCode]!,
213
);
214
if (codeTools.source) {
215
addDivider();
216
const vsLi = addListItem(
217
kViewSourceLinkId,
218
format.language[kCodeToolsViewSource]!,
219
);
220
if (typeof (codeTools.source) === "string") {
221
(vsLi.firstChild as Element).setAttribute(
222
kDataQuartoSourceUrl,
223
codeTools.source,
224
);
225
}
226
}
227
layoutDiv.appendChild(ul);
228
} else {
229
// no toggle, so just a button to show source code
230
button.setAttribute("id", kCodeToolsSourceButtonId);
231
if (typeof (codeTools.source) === "string") {
232
button.setAttribute(kDataQuartoSourceUrl, codeTools.source);
233
}
234
}
235
}
236
if (codeTools.source) {
237
// grab the embedded source code element
238
const embeddedCode = doc.querySelector(`.${kEmbeddedSourceClass}`);
239
if (embeddedCode) {
240
// create a bootstrap model to wrap it
241
const modalDiv = doc.createElement("div");
242
modalDiv.classList.add("modal");
243
modalDiv.classList.add("fade");
244
modalDiv.setAttribute("id", kEmbeddedSourceModalId);
245
modalDiv.setAttribute("tabindex", "-1");
246
modalDiv.setAttribute(
247
"aria-labelledby",
248
kEmbeddedSourceModalLabelId,
249
);
250
modalDiv.setAttribute("aria-hidden", "true");
251
const modalDialogDiv = doc.createElement("div");
252
modalDialogDiv.classList.add("modal-dialog");
253
modalDialogDiv.classList.add("modal-dialog-scrollable");
254
const modalContentDiv = doc.createElement("div");
255
modalContentDiv.classList.add("modal-content");
256
const modalDialogHeader = doc.createElement("div");
257
modalDialogHeader.classList.add("modal-header");
258
const h5 = doc.createElement("h5");
259
h5.classList.add("modal-title");
260
h5.setAttribute("id", kEmbeddedSourceModalLabelId);
261
h5.appendChild(
262
doc.createTextNode(format.language[kCodeToolsSourceCode]!),
263
);
264
modalDialogHeader.appendChild(h5);
265
const button = doc.createElement("button");
266
button.classList.add("btn-close");
267
button.setAttribute("data-bs-dismiss", "modal");
268
modalDialogHeader.appendChild(button);
269
modalContentDiv.appendChild(modalDialogHeader);
270
const modalBody = doc.createElement("div");
271
modalBody.classList.add("modal-body");
272
modalContentDiv.appendChild(modalBody);
273
modalDialogDiv.appendChild(modalContentDiv);
274
modalDiv.appendChild(modalDialogDiv);
275
276
// insert it next to the main content
277
const mainEl = doc.querySelector("main.content");
278
if (mainEl) {
279
const mainParentEl = mainEl.parentElement;
280
mainParentEl?.insertBefore(modalDiv, mainParentEl.lastChild);
281
} else {
282
embeddedCode.parentElement?.insertBefore(
283
modalDiv,
284
embeddedCode,
285
);
286
}
287
288
modalBody.appendChild(embeddedCode);
289
embeddedCode.classList.remove(kEmbeddedSourceClass);
290
}
291
}
292
293
// if code is statically hidden, hide code-tools chrome as well.
294
295
// note that we're querying on pre.hidden and div.hidden both
296
//
297
// because for regular code cells, the hidden class hasn't been
298
// hoisted to the parent by the html postprocessor yet,
299
// but for OJS cells, they're emitted as hidden from the start.
300
301
for (
302
const el of Array.from(
303
doc.querySelectorAll(
304
"details div pre.hidden",
305
),
306
)
307
) {
308
const det = el.parentElement!.parentElement;
309
det!.classList.add("hidden");
310
}
311
}
312
}
313
314
return Promise.resolve(kHtmlEmptyPostProcessResult);
315
});
316
};
317
}
318
319
interface CodeTools {
320
source: boolean | string;
321
toggle: boolean;
322
caption: string;
323
}
324
325
function resolveCodeTools(format: Format, doc: Document): CodeTools {
326
// determine user prefs
327
const kCodeCaption = format.language[kCodeToolsMenuCaption]!;
328
const codeTools = format?.render[kCodeTools];
329
const codeToolsResolved = {
330
source: typeof codeTools === "boolean"
331
? codeTools
332
: codeTools?.source !== undefined
333
? codeTools?.source
334
: true,
335
toggle: typeof codeTools === "boolean"
336
? codeTools
337
: codeTools?.toggle !== undefined
338
? !!codeTools?.toggle
339
: true,
340
caption: typeof codeTools === "boolean"
341
? kCodeCaption
342
: codeTools?.caption || kCodeCaption,
343
};
344
345
// if we have request source without an external url,
346
// make sure we are able to keep source
347
if (codeToolsResolved.source === true) {
348
codeToolsResolved.source = !!format.render[kKeepSource];
349
}
350
351
// if we have requested toggle, make sure there are things to toggle
352
if (codeToolsResolved.toggle) {
353
const codeDetails = doc.querySelector(".cell > details > .sourceCode");
354
355
// we don't OJS hidden cells in this check, since when echo: false, we emit them hidden
356
const codeHidden = doc.querySelector(
357
".cell .sourceCode.hidden:not(div pre.js)",
358
);
359
codeToolsResolved.toggle = !!codeDetails || !!codeHidden;
360
}
361
362
// return resolved
363
return codeToolsResolved;
364
}
365
366