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