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