Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/render/render.ts
6428 views
1
/*
2
* render.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { ensureDirSync, existsSync } from "../../deno_ral/fs.ts";
8
9
import { dirname, isAbsolute, join, relative } from "../../deno_ral/path.ts";
10
11
import { Document, parseHtml } from "../../core/deno-dom.ts";
12
13
import { mergeConfigs } from "../../core/config.ts";
14
import { resourcePath } from "../../core/resources.ts";
15
import { inputFilesDir } from "../../core/render.ts";
16
import {
17
normalizePath,
18
pathWithForwardSlashes,
19
safeExistsSync,
20
} from "../../core/path.ts";
21
22
import { FormatPandoc } from "../../config/types.ts";
23
import {
24
executionEngine,
25
executionEngineKeepMd,
26
} from "../../execute/engine.ts";
27
import { engineProjectContext } from "../../project/engine-project-context.ts";
28
29
import {
30
HtmlPostProcessor,
31
HtmlPostProcessResult,
32
PandocInputTraits,
33
PandocOptions,
34
PandocRenderCompletion,
35
RenderedFormat,
36
} from "./types.ts";
37
import { runPandoc } from "./pandoc.ts";
38
import { renderCleanup } from "./cleanup.ts";
39
import { projectOffset } from "../../project/project-shared.ts";
40
41
import { ExecutedFile, RenderedFile, RenderResult } from "./types.ts";
42
import { PandocIncludes } from "../../execute/types.ts";
43
import { Metadata } from "../../config/types.ts";
44
import { isHtmlFileOutput } from "../../config/format.ts";
45
46
import { isSelfContainedOutput } from "./render-info.ts";
47
import {
48
pop as popTiming,
49
push as pushTiming,
50
withTiming,
51
withTimingAsync,
52
} from "../../core/timing.ts";
53
import { filesDirMediabagDir } from "./render-paths.ts";
54
import { replaceNotebookPlaceholders } from "../../core/jupyter/jupyter-embed.ts";
55
import {
56
kIncludeAfterBody,
57
kIncludeBeforeBody,
58
kIncludeInHeader,
59
kInlineIncludes,
60
kResourcePath,
61
} from "../../config/constants.ts";
62
import { pandocIngestSelfContainedContent } from "../../core/pandoc/self-contained.ts";
63
import { existsSync1 } from "../../core/file.ts";
64
import { projectType } from "../../project/types/project-types.ts";
65
66
export async function renderPandoc(
67
file: ExecutedFile,
68
quiet: boolean,
69
): Promise<PandocRenderCompletion> {
70
// alias options
71
const { context, recipe, executeResult, resourceFiles } = file;
72
73
// alias format
74
const format = recipe.format;
75
76
// merge any pandoc options provided by the computation
77
if (executeResult.includes) {
78
format.pandoc = mergePandocIncludes(
79
format.pandoc || {},
80
executeResult.includes,
81
);
82
}
83
if (executeResult.pandoc) {
84
format.pandoc = mergeConfigs(
85
format.pandoc || {},
86
executeResult.pandoc,
87
);
88
}
89
90
// run the dependencies step if we didn't do it during execute
91
if (executeResult.engineDependencies) {
92
for (const engineName of Object.keys(executeResult.engineDependencies)) {
93
const engine = executionEngine(engineName)!;
94
const engineInstance = engine.launch(engineProjectContext(context.project));
95
const dependenciesResult = await engineInstance.dependencies({
96
target: context.target,
97
format,
98
output: recipe.output,
99
resourceDir: resourcePath(),
100
tempDir: context.options.services.temp.createDir(),
101
projectDir: context.project?.dir,
102
libDir: context.libDir,
103
dependencies: executeResult.engineDependencies[engineName],
104
quiet: context.options.flags?.quiet,
105
});
106
format.pandoc = mergePandocIncludes(
107
format.pandoc,
108
dependenciesResult.includes,
109
);
110
}
111
}
112
113
// the mediabag dir should be created here based on the context
114
// (it could be in the _files dir). if its a single file book
115
// though it can even be in a temp dir
116
const mediabagDir = filesDirMediabagDir(context.target.source);
117
ensureDirSync(join(dirname(context.target.source), mediabagDir));
118
119
// Process any placeholder for notebooks that have been injected
120
const notebookResult = await replaceNotebookPlaceholders(
121
format.pandoc.to || "html",
122
context,
123
context.options.flags || {},
124
executeResult.markdown,
125
context.options.services,
126
);
127
128
const embedSupporting: string[] = [];
129
if (notebookResult.supporting.length) {
130
embedSupporting.push(...notebookResult.supporting);
131
}
132
133
// Map notebook includes to pandoc includes
134
const pandocIncludes: PandocIncludes = {
135
[kIncludeAfterBody]: notebookResult.includes?.afterBody
136
? [notebookResult.includes?.afterBody]
137
: undefined,
138
[kIncludeInHeader]: notebookResult.includes?.inHeader
139
? [notebookResult.includes?.inHeader]
140
: undefined,
141
};
142
143
// Inject dependencies
144
format.pandoc = mergePandocIncludes(
145
format.pandoc,
146
pandocIncludes,
147
);
148
149
// resolve markdown. for [ ] output type we collect up
150
// the includes so they can be proccessed by Lua
151
let markdownInput = notebookResult.markdown
152
? notebookResult.markdown
153
: executeResult.markdown;
154
if (format.render[kInlineIncludes]) {
155
const collectIncludes = (
156
location:
157
| "include-in-header"
158
| "include-before-body"
159
| "include-after-body",
160
) => {
161
const includes = format.pandoc[location];
162
if (includes) {
163
const append = location === "include-after-body";
164
for (const include of includes) {
165
const includeMd = Deno.readTextFileSync(include);
166
if (append) {
167
markdownInput = `${markdownInput}\n\n${includeMd}`;
168
} else {
169
markdownInput = `${includeMd}\n\n${markdownInput}`;
170
}
171
}
172
delete format.pandoc[location];
173
}
174
};
175
collectIncludes(kIncludeInHeader);
176
collectIncludes(kIncludeBeforeBody);
177
collectIncludes(kIncludeAfterBody);
178
}
179
180
// pandoc options
181
const pandocOptions: PandocOptions = {
182
markdown: markdownInput,
183
source: context.target.source,
184
output: recipe.output,
185
keepYaml: recipe.keepYaml,
186
mediabagDir,
187
libDir: context.libDir,
188
format,
189
executionEngine: executeResult.engine,
190
project: context.project,
191
args: recipe.args,
192
services: context.options.services,
193
metadata: executeResult.metadata,
194
quiet,
195
flags: context.options.flags,
196
};
197
198
// add offset if we are in a project
199
if (context.project) {
200
pandocOptions.offset = projectOffset(context.project, context.target.input);
201
}
202
203
// run pandoc conversion (exit on failure)
204
const pandocResult = await runPandoc(pandocOptions, executeResult.filters);
205
if (!pandocResult) {
206
return Promise.reject();
207
}
208
209
return {
210
complete: async (renderedFormats: RenderedFormat[], cleanup?: boolean) => {
211
pushTiming("render-postprocessor");
212
// run optional post-processor (e.g. to restore html-preserve regions)
213
if (executeResult.postProcess) {
214
await withTimingAsync("engine-postprocess", async () => {
215
return await context.engine.postprocess({
216
engine: context.engine,
217
target: context.target,
218
format,
219
output: recipe.output,
220
tempDir: context.options.services.temp.createDir(),
221
projectDir: context.project?.dir,
222
preserve: executeResult.preserve,
223
quiet: context.options.flags?.quiet,
224
});
225
});
226
}
227
228
// run html postprocessors if we have them
229
const canHtmlPostProcess = isHtmlFileOutput(format.pandoc);
230
if (!canHtmlPostProcess && pandocResult.htmlPostprocessors.length > 0) {
231
const postProcessorNames = pandocResult.htmlPostprocessors.map((p) =>
232
p.name
233
).join(", ");
234
const msg =
235
`Attempt to HTML post process non HTML output using: ${postProcessorNames}`;
236
throw new Error(msg);
237
}
238
const htmlPostProcessors = canHtmlPostProcess
239
? pandocResult.htmlPostprocessors
240
: [];
241
const htmlFinalizers = canHtmlPostProcess
242
? pandocResult.htmlFinalizers || []
243
: [];
244
245
const htmlPostProcessResult = await runHtmlPostprocessors(
246
pandocResult.inputMetadata,
247
pandocResult.inputTraits,
248
pandocOptions,
249
htmlPostProcessors,
250
htmlFinalizers,
251
renderedFormats,
252
quiet,
253
);
254
255
// Compute the path to the output file
256
const outputFile = isAbsolute(pandocOptions.output)
257
? pandocOptions.output
258
: join(dirname(pandocOptions.source), pandocOptions.output);
259
260
// run generic postprocessors
261
const postProcessSupporting: string[] = [];
262
const postProcessResources: string[] = [];
263
if (pandocResult.postprocessors) {
264
for (const postprocessor of pandocResult.postprocessors) {
265
const result = await postprocessor(outputFile);
266
if (result && result.supporting) {
267
postProcessSupporting.push(...result.supporting);
268
}
269
if (result && result.resources) {
270
postProcessResources.push(...result.resources);
271
}
272
}
273
}
274
275
let finalOutput: string;
276
let selfContained: boolean;
277
278
await withTimingAsync("postprocess-selfcontained", async () => {
279
// ensure flags
280
const flags = context.options.flags || {};
281
// determine whether this is self-contained output
282
finalOutput = recipe.output;
283
284
// note that we intentionally call isSelfContainedOutput twice
285
// the first needs to happen before recipe completion
286
// because ingestion of self-contained output needs
287
// to happen before recipe completion (which cleans up some files)
288
selfContained = isSelfContainedOutput(
289
flags,
290
format,
291
finalOutput,
292
);
293
294
if (selfContained && isHtmlFileOutput(format.pandoc)) {
295
await pandocIngestSelfContainedContent(
296
outputFile,
297
format.pandoc[kResourcePath],
298
);
299
}
300
301
// call complete handler (might e.g. run latexmk to complete the render)
302
finalOutput = (await recipe.complete(pandocOptions)) || recipe.output;
303
304
// note that we intentionally call isSelfContainedOutput twice
305
// the second call happens because some recipes change
306
// their output extension on completion (notably, .pdf files)
307
// and become self-contained for purposes of cleanup
308
selfContained = isSelfContainedOutput(
309
flags,
310
format,
311
finalOutput,
312
);
313
});
314
315
// compute the relative path to the files dir
316
let filesDir: string | undefined = inputFilesDir(context.target.source);
317
// undefine it if it doesn't exist
318
filesDir = existsSync(join(dirname(context.target.source), filesDir))
319
? filesDir
320
: undefined;
321
322
// add any injected libs to supporting
323
let supporting = filesDir ? executeResult.supporting : undefined;
324
if (filesDir && isHtmlFileOutput(format.pandoc)) {
325
const filesDirAbsolute = join(dirname(context.target.source), filesDir);
326
if (
327
existsSync(filesDirAbsolute) &&
328
(!supporting || !supporting.includes(filesDirAbsolute))
329
) {
330
const filesLibs = join(
331
dirname(context.target.source),
332
context.libDir,
333
);
334
if (
335
existsSync(filesLibs) &&
336
(!supporting || !supporting.includes(filesLibs))
337
) {
338
supporting = supporting || [];
339
supporting.push(filesLibs);
340
}
341
}
342
}
343
if (
344
htmlPostProcessResult.supporting &&
345
htmlPostProcessResult.supporting.length > 0
346
) {
347
supporting = supporting || [];
348
supporting.push(...htmlPostProcessResult.supporting);
349
}
350
if (embedSupporting && embedSupporting.length > 0) {
351
supporting = supporting || [];
352
supporting.push(...embedSupporting);
353
}
354
if (postProcessSupporting && postProcessSupporting.length > 0) {
355
supporting = supporting || [];
356
supporting.push(...postProcessSupporting);
357
}
358
359
// Deal with self contained by passing them to be cleaned up
360
// but if this is a project, instead make sure that we're not
361
// including the lib dir
362
let cleanupSelfContained: string[] | undefined = undefined;
363
if (selfContained! && supporting) {
364
cleanupSelfContained = [...supporting];
365
if (context.project!) {
366
const libDir = context.project?.config?.project["lib-dir"];
367
if (libDir) {
368
const absLibDir = join(context.project.dir, libDir);
369
cleanupSelfContained = cleanupSelfContained.filter((file) =>
370
!file.startsWith(absLibDir)
371
);
372
}
373
}
374
}
375
376
if (cleanup !== false) {
377
withTiming("render-cleanup", () =>
378
renderCleanup(
379
context.target.input,
380
finalOutput!,
381
format,
382
file.context.project,
383
cleanupSelfContained,
384
executionEngineKeepMd(context),
385
));
386
}
387
388
// if there is a project context then return paths relative to the project
389
const projectPath = (path: string) => {
390
if (context.project) {
391
if (isAbsolute(path)) {
392
return relative(
393
normalizePath(context.project.dir),
394
normalizePath(path),
395
);
396
} else {
397
return relative(
398
normalizePath(context.project.dir),
399
normalizePath(join(dirname(context.target.source), path)),
400
);
401
}
402
} else {
403
return path;
404
}
405
};
406
popTiming();
407
408
// Forward along any specific resources
409
const files = resourceFiles.concat(htmlPostProcessResult.resources)
410
.concat(postProcessResources);
411
412
const result: RenderedFile = {
413
isTransient: recipe.isOutputTransient,
414
input: projectPath(context.target.source),
415
markdown: executeResult.markdown,
416
format,
417
supporting: supporting
418
? supporting.filter(existsSync1).map((file: string) =>
419
context.project ? relative(context.project.dir, file) : file
420
)
421
: undefined,
422
file: recipe.isOutputTransient
423
? finalOutput!
424
: projectPath(finalOutput!),
425
resourceFiles: {
426
globs: pandocResult.resources,
427
files,
428
},
429
selfContained: selfContained!,
430
};
431
return result;
432
},
433
};
434
}
435
436
export function renderResultFinalOutput(
437
renderResults: RenderResult,
438
relativeToInputDir?: string,
439
) {
440
// final output defaults to the first output of the first result
441
// that isn't a supplemental render file (a file that wasn't explicitly
442
// rendered but that was a side effect of rendering some other file)
443
let result = renderResults.files.find((file) => {
444
return !file.supplemental;
445
});
446
if (!result) {
447
return undefined;
448
}
449
450
// see if we can find an index.html instead
451
for (const fileResult of renderResults.files) {
452
if (fileResult.file === "index.html" && !fileResult.supplemental) {
453
result = fileResult;
454
break;
455
}
456
}
457
458
// Allow project types to provide this
459
if (renderResults.context) {
460
const projType = projectType(renderResults.context.config?.project.type);
461
if (projType && projType.renderResultFinalOutput) {
462
const projectResult = projType.renderResultFinalOutput(
463
renderResults,
464
relativeToInputDir,
465
);
466
if (projectResult) {
467
result = projectResult;
468
}
469
}
470
}
471
472
// determine final output
473
let finalInput = result.input;
474
let finalOutput = result.file;
475
476
if (renderResults.baseDir) {
477
finalInput = join(renderResults.baseDir, finalInput);
478
if (renderResults.outputDir) {
479
finalOutput = join(
480
renderResults.baseDir,
481
renderResults.outputDir,
482
finalOutput,
483
);
484
} else {
485
finalOutput = join(renderResults.baseDir, finalOutput);
486
}
487
} else {
488
finalOutput = join(dirname(finalInput), finalOutput);
489
}
490
491
// if the final output doesn't exist then we must have been targetin stdout,
492
// so return undefined
493
if (!safeExistsSync(finalOutput)) {
494
return undefined;
495
}
496
497
// return a path relative to the input file
498
if (relativeToInputDir) {
499
const inputRealPath = normalizePath(relativeToInputDir);
500
const outputRealPath = normalizePath(finalOutput);
501
return relative(inputRealPath, outputRealPath);
502
} else {
503
return finalOutput;
504
}
505
}
506
507
export function renderResultUrlPath(
508
renderResult: RenderResult,
509
) {
510
if (renderResult.baseDir && renderResult.outputDir) {
511
const finalOutput = renderResultFinalOutput(
512
renderResult,
513
);
514
if (finalOutput) {
515
const targetPath = pathWithForwardSlashes(relative(
516
join(renderResult.baseDir, renderResult.outputDir),
517
finalOutput,
518
));
519
return targetPath;
520
}
521
}
522
return undefined;
523
}
524
525
function mergePandocIncludes(
526
format: FormatPandoc,
527
pandocIncludes: PandocIncludes,
528
) {
529
return mergeConfigs(format, pandocIncludes);
530
}
531
532
async function runHtmlPostprocessors(
533
inputMetadata: Metadata,
534
inputTraits: PandocInputTraits,
535
options: PandocOptions,
536
htmlPostprocessors: Array<HtmlPostProcessor>,
537
htmlFinalizers: Array<(doc: Document) => Promise<void>>,
538
renderedFormats: RenderedFormat[],
539
quiet?: boolean,
540
): Promise<HtmlPostProcessResult> {
541
const postProcessResult: HtmlPostProcessResult = {
542
resources: [],
543
supporting: [],
544
};
545
if (htmlPostprocessors.length > 0 || htmlFinalizers.length > 0) {
546
await withTimingAsync("htmlPostprocessors", async () => {
547
const outputFile = isAbsolute(options.output)
548
? options.output
549
: join(dirname(options.source), options.output);
550
const htmlInput = Deno.readTextFileSync(outputFile);
551
const doctypeMatch = htmlInput.match(/^<!DOCTYPE.*?>/);
552
const doc = await parseHtml(htmlInput);
553
for (let i = 0; i < htmlPostprocessors.length; i++) {
554
const postprocessor = htmlPostprocessors[i];
555
const result = await postprocessor(
556
doc,
557
{
558
inputMetadata,
559
inputTraits,
560
renderedFormats,
561
quiet,
562
},
563
);
564
565
postProcessResult.resources.push(...result.resources);
566
postProcessResult.supporting.push(...result.supporting);
567
}
568
569
// After the post processing is complete, allow any finalizers
570
// an opportunity at the document
571
for (let i = 0; i < htmlFinalizers.length; i++) {
572
const finalizer = htmlFinalizers[i];
573
await finalizer(doc);
574
}
575
576
const htmlOutput = (doctypeMatch ? doctypeMatch[0] + "\n" : "") +
577
doc.documentElement?.outerHTML!;
578
Deno.writeTextFileSync(outputFile, htmlOutput);
579
});
580
}
581
return postProcessResult;
582
}
583
584