Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/render/render-files.ts
3583 views
1
/*
2
* render-files.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
// ensures cell handlers are installed
8
import "../../core/handlers/handlers.ts";
9
10
import {
11
kExecuteEnabled,
12
kFreeze,
13
kIncludeInHeader,
14
kKeepMd,
15
kLang,
16
kQuartoRequired,
17
} from "../../config/constants.ts";
18
import { isHtmlCompatible } from "../../config/format.ts";
19
import { mergeConfigs } from "../../core/config.ts";
20
import { initDayJsPlugins, setDateLocale } from "../../core/date.ts";
21
import { initDenoDom } from "../../core/deno-dom.ts";
22
import { HandlerContextResults } from "../../core/handlers/types.ts";
23
import {
24
handleLanguageCells,
25
languages,
26
resetFigureCounter,
27
} from "../../core/handlers/base.ts";
28
import { LanguageCellHandlerOptions } from "../../core/handlers/types.ts";
29
import { asMappedString, mappedDiff } from "../../core/mapped-text.ts";
30
import {
31
validateDocument,
32
validateDocumentFromSource,
33
} from "../../core/schema/validate-document.ts";
34
import { createTempContext, TempContext } from "../../core/temp.ts";
35
import {
36
executionEngineKeepMd,
37
fileExecutionEngineAndTarget,
38
} from "../../execute/engine.ts";
39
import { annotateOjsLineNumbers } from "../../execute/ojs/annotate-source.ts";
40
import { ojsExecuteResult } from "../../execute/ojs/compile.ts";
41
import {
42
ExecuteOptions,
43
ExecuteResult,
44
MappedExecuteResult,
45
} from "../../execute/types.ts";
46
import { kProjectLibDir, ProjectContext } from "../../project/types.ts";
47
import { outputRecipe } from "./output.ts";
48
49
import { renderPandoc } from "./render.ts";
50
import { PandocRenderCompletion, RenderServices } from "./types.ts";
51
import { renderContexts } from "./render-contexts.ts";
52
import { renderProgress } from "./render-info.ts";
53
import {
54
ExecutedFile,
55
PandocRenderer,
56
RenderContext,
57
RenderedFile,
58
RenderedFormat,
59
RenderExecuteOptions,
60
RenderFile,
61
RenderFilesResult,
62
RenderFlags,
63
RenderOptions,
64
} from "./types.ts";
65
import { error, info } from "../../deno_ral/log.ts";
66
import * as ld from "../../core/lodash.ts";
67
import { basename, dirname, join, relative } from "../../deno_ral/path.ts";
68
import { Format } from "../../config/types.ts";
69
import {
70
figuresDir,
71
inputFilesDir,
72
isServerShiny,
73
isServerShinyKnitr,
74
} from "../../core/render.ts";
75
import {
76
normalizePath,
77
removeIfEmptyDir,
78
removeIfExists,
79
} from "../../core/path.ts";
80
import { resourcePath } from "../../core/resources.ts";
81
import { YAMLValidationError } from "../../core/yaml.ts";
82
import { resolveParams } from "./flags.ts";
83
import {
84
copyFromProjectFreezer,
85
copyToProjectFreezer,
86
defrostExecuteResult,
87
freezeExecuteResult,
88
freezerFigsDir,
89
freezerFreezeFile,
90
kProjectFreezeDir,
91
removeFreezeResults,
92
} from "./freeze.ts";
93
import { isJupyterNotebook } from "../../core/jupyter/jupyter.ts";
94
import { MappedString } from "../../core/lib/text-types.ts";
95
import {
96
createNamedLifetime,
97
Lifetime,
98
waitUntilNamedLifetime,
99
} from "../../core/lifetimes.ts";
100
import { resolveDependencies } from "./pandoc-dependencies-html.ts";
101
import {
102
getData as getTimingData,
103
pop as popTiming,
104
push as pushTiming,
105
withTiming,
106
withTimingAsync,
107
} from "../../core/timing.ts";
108
import { satisfies } from "semver/mod.ts";
109
import { quartoConfig } from "../../core/quarto.ts";
110
import { ensureNotebookContext } from "../../core/jupyter/jupyter-embed.ts";
111
import {
112
projectIsWebsite,
113
projectOutputDir,
114
} from "../../project/project-shared.ts";
115
import { NotebookContext } from "../../render/notebook/notebook-types.ts";
116
import { setExecuteEnvironment } from "../../execute/environment.ts";
117
import { safeCloneDeep } from "../../core/safe-clone-deep.ts";
118
import { warn } from "log";
119
120
export async function renderExecute(
121
context: RenderContext,
122
output: string,
123
options: RenderExecuteOptions,
124
): Promise<ExecuteResult> {
125
// are we running a compatible quarto version for this file?
126
const versionConstraint = context
127
.format.metadata[kQuartoRequired] as (string | undefined);
128
if (versionConstraint) {
129
const ourVersion = quartoConfig.version();
130
let result: boolean;
131
try {
132
result = satisfies(ourVersion, versionConstraint);
133
} catch (_e) {
134
throw new Error(
135
`In file ${context.target.source}:\nVersion constraint is invalid: ${versionConstraint}.`,
136
);
137
}
138
if (!result) {
139
throw new Error(
140
`in file ${context.target.source}:\nQuarto version ${ourVersion} does not satisfy version constraint ${versionConstraint}.`,
141
);
142
}
143
}
144
145
// alias options
146
const { resolveDependencies = true, alwaysExecute = false } = options;
147
148
// alias flags
149
const flags = context.options.flags || {};
150
151
// compute filesDir
152
const filesDir = inputFilesDir(context.target.source);
153
154
// compute project relative files dir (if we are in a project)
155
let projRelativeFilesDir: string | undefined;
156
if (context.project) {
157
const inputDir = relative(
158
context.project.dir,
159
dirname(context.target.source),
160
);
161
projRelativeFilesDir = join(inputDir, filesDir);
162
}
163
164
// are we eligible to freeze?
165
const canFreeze = context.engine.canFreeze &&
166
(context.format.execute[kExecuteEnabled] !== false);
167
168
// use previous frozen results if they are available
169
if (context.project && !context.project.isSingleFile && !alwaysExecute) {
170
// check if we are using the freezer
171
172
const thaw = canFreeze &&
173
(context.format.execute[kFreeze] ||
174
(context.options.useFreezer ? "auto" : false));
175
176
if (thaw) {
177
// copy from project freezer
178
const hidden = context.format.execute[kFreeze] === false;
179
copyFromProjectFreezer(
180
context.project,
181
projRelativeFilesDir!,
182
hidden,
183
);
184
185
const thawedResult = defrostExecuteResult(
186
context.target.source,
187
output,
188
context.options.services.temp,
189
thaw === true,
190
);
191
if (thawedResult) {
192
// copy the site_libs dir from the freezer
193
const libDir = context.project?.config?.project[kProjectLibDir];
194
if (libDir) {
195
copyFromProjectFreezer(context.project, libDir, hidden);
196
}
197
198
// remove the results dir
199
removeFreezeResults(join(context.project.dir, projRelativeFilesDir!));
200
201
// notify engine that we skipped execute
202
if (context.engine.executeTargetSkipped) {
203
context.engine.executeTargetSkipped(
204
context.target,
205
context.format,
206
context.project,
207
);
208
}
209
210
// return results
211
return thawedResult;
212
}
213
}
214
}
215
216
// calculate figsDir
217
const figsDir = join(filesDir, figuresDir(context.format.pandoc.to));
218
219
pushTiming("render-execute");
220
221
const executeOptions: ExecuteOptions = {
222
target: context.target,
223
resourceDir: resourcePath(),
224
tempDir: context.options.services.temp.createDir(),
225
dependencies: resolveDependencies,
226
libDir: context.libDir,
227
format: context.format,
228
projectDir: context.project?.dir,
229
cwd: flags.executeDir || dirname(normalizePath(context.target.source)),
230
params: resolveParams(flags.params, flags.paramsFile),
231
quiet: flags.quiet,
232
previewServer: context.options.previewServer,
233
handledLanguages: languages(),
234
project: context.project,
235
};
236
// execute computations
237
setExecuteEnvironment(executeOptions);
238
const executeResult = await context.engine.execute(executeOptions);
239
popTiming();
240
241
// write the freeze file if we are in a project
242
if (context.project && !context.project.isSingleFile && canFreeze) {
243
// write the freezer file
244
const freezeFile = freezeExecuteResult(
245
context.target.source,
246
output,
247
executeResult,
248
);
249
250
// always copy to the hidden freezer
251
copyToProjectFreezer(context.project, projRelativeFilesDir!, true, true);
252
253
// copy to the _freeze dir if explicit _freeze is requested
254
if (context.format.execute[kFreeze] !== false) {
255
copyToProjectFreezer(context.project, projRelativeFilesDir!, false, true);
256
} else {
257
// otherwise cleanup the _freeze subdir b/c we aren't explicitly freezing anymore
258
259
// figs dir for this target format
260
const freezeFigsDir = freezerFigsDir(
261
context.project,
262
projRelativeFilesDir!,
263
basename(figsDir),
264
);
265
removeIfExists(freezeFigsDir);
266
267
// freezer file
268
const projRelativeFreezeFile = relative(context.project.dir, freezeFile);
269
const freezerFile = freezerFreezeFile(
270
context.project,
271
projRelativeFreezeFile,
272
);
273
removeIfExists(freezerFile);
274
275
// remove empty directories
276
removeIfEmptyDir(dirname(freezerFile));
277
removeIfEmptyDir(dirname(freezeFigsDir));
278
removeIfEmptyDir(join(context.project.dir, kProjectFreezeDir));
279
}
280
281
// remove the freeze results file (now that it's safely in the freezer)
282
removeFreezeResults(join(context.project.dir, projRelativeFilesDir!));
283
}
284
285
// return result
286
return executeResult;
287
}
288
289
export async function renderFiles(
290
files: RenderFile[],
291
options: RenderOptions,
292
notebookContext: NotebookContext,
293
alwaysExecuteFiles: string[] | undefined,
294
pandocRenderer: PandocRenderer | undefined,
295
project: ProjectContext,
296
): Promise<RenderFilesResult> {
297
// provide default renderer
298
pandocRenderer = pandocRenderer || defaultPandocRenderer(options, project);
299
300
// create temp context
301
const tempContext = createTempContext();
302
303
try {
304
// make a copy of options so we don't mutate caller context
305
options = ld.cloneDeep(options);
306
307
// see if we should be using file-by-file progress
308
const progress = options.progress ||
309
(project && (files.length > 1) && !options.flags?.quiet);
310
311
// quiet pandoc output if we are doing file by file progress
312
const pandocQuiet = !!progress || !!options.quietPandoc;
313
314
// calculate num width
315
const numWidth = String(files.length).length;
316
317
for (let i = 0; i < files.length; i++) {
318
const file = files[i];
319
320
if (progress) {
321
renderProgress(
322
`\r[${String(i + 1).padStart(numWidth)}/${files.length}] ${
323
relative(project!.dir, file.path)
324
}`,
325
);
326
}
327
328
// get contexts
329
const fileLifetime = await waitUntilNamedLifetime("render-file");
330
try {
331
await renderFileInternal(
332
fileLifetime,
333
file,
334
options,
335
project,
336
pandocRenderer,
337
files,
338
tempContext,
339
alwaysExecuteFiles,
340
pandocQuiet,
341
notebookContext,
342
);
343
} finally {
344
fileLifetime.cleanup();
345
}
346
}
347
348
if (progress) {
349
info("");
350
}
351
352
return await pandocRenderer.onComplete(false, options.flags?.quiet);
353
} catch (error) {
354
if (!(error instanceof Error)) {
355
warn("Should not have arrived here:", error);
356
throw error;
357
}
358
return {
359
files: (await pandocRenderer.onComplete(true)).files,
360
error: error || new Error(),
361
};
362
} finally {
363
tempContext.cleanup();
364
if (Deno.env.get("QUARTO_PROFILER_OUTPUT")) {
365
Deno.writeTextFileSync(
366
Deno.env.get("QUARTO_PROFILER_OUTPUT")!,
367
JSON.stringify(getTimingData()),
368
);
369
}
370
}
371
}
372
373
export async function renderFile(
374
file: RenderFile,
375
options: RenderOptions,
376
services: RenderServices,
377
project: ProjectContext,
378
enforceProjectFormats: boolean = true,
379
): Promise<RenderFilesResult> {
380
// provide default renderer
381
const pandocRenderer = defaultPandocRenderer(options, project);
382
383
try {
384
// make a copy of options so we don't mutate caller context
385
options = ld.cloneDeep(options);
386
387
// quiet pandoc output if we are doing file by file progress
388
const pandocQuiet = !!options.quietPandoc;
389
390
// get contexts
391
const fileLifetime = createNamedLifetime("render-single-file");
392
try {
393
await renderFileInternal(
394
fileLifetime,
395
file,
396
options,
397
project,
398
pandocRenderer,
399
[file],
400
services.temp,
401
[],
402
pandocQuiet,
403
services.notebook,
404
enforceProjectFormats,
405
);
406
} finally {
407
fileLifetime.cleanup();
408
}
409
return await pandocRenderer.onComplete(false, options.flags?.quiet);
410
} catch (error) {
411
if (!(error instanceof Error)) {
412
warn("Should not have arrived here:", error);
413
throw error;
414
}
415
return {
416
files: (await pandocRenderer.onComplete(true)).files,
417
error: error || new Error(),
418
};
419
} finally {
420
if (Deno.env.get("QUARTO_PROFILER_OUTPUT")) {
421
Deno.writeTextFileSync(
422
Deno.env.get("QUARTO_PROFILER_OUTPUT")!,
423
JSON.stringify(getTimingData()),
424
);
425
}
426
}
427
}
428
429
async function renderFileInternal(
430
lifetime: Lifetime,
431
file: RenderFile,
432
options: RenderOptions,
433
project: ProjectContext,
434
pandocRenderer: PandocRenderer,
435
files: RenderFile[],
436
tempContext: TempContext,
437
alwaysExecuteFiles: string[] | undefined,
438
pandocQuiet: boolean,
439
notebookContext: NotebookContext,
440
enforceProjectFormats: boolean = true,
441
) {
442
const outputs: Array<RenderedFormat> = [];
443
let contexts: Record<string, RenderContext> | undefined;
444
try {
445
contexts = await renderContexts(
446
file,
447
options,
448
true,
449
notebookContext,
450
project,
451
false,
452
enforceProjectFormats,
453
);
454
455
// Allow renderers to filter the contexts
456
contexts = pandocRenderer.onFilterContexts(
457
file.path,
458
contexts,
459
files,
460
options,
461
);
462
} catch (e) {
463
// bad YAML can cause failure before validation. We
464
// reconstruct the context as best we can and try to validate.
465
// note that this ignores "validate-yaml: false"
466
const { engine, target } = await fileExecutionEngineAndTarget(
467
file.path,
468
options.flags,
469
project,
470
);
471
const validationResult = await validateDocumentFromSource(
472
target.markdown,
473
engine.name,
474
error,
475
);
476
if (validationResult.length) {
477
throw new RenderInvalidYAMLError();
478
} else {
479
// rethrow if no validation error happened.
480
throw e;
481
}
482
}
483
const mergeHandlerResults = (
484
results: HandlerContextResults | undefined,
485
executeResult: MappedExecuteResult,
486
context: RenderContext,
487
) => {
488
if (results === undefined) {
489
return;
490
}
491
if (executeResult.includes) {
492
executeResult.includes = mergeConfigs(
493
executeResult.includes,
494
results.includes,
495
);
496
} else {
497
executeResult.includes = results.includes;
498
}
499
const extras = resolveDependencies(
500
results.extras,
501
dirname(context.target.source),
502
context.libDir,
503
tempContext,
504
project,
505
);
506
if (extras[kIncludeInHeader]) {
507
// note that we merge engine execute results back into cell handler
508
// execute results so that jupyter widget dependencies appear at the
509
// end (so that they don't mess w/ other libs using require/define)
510
executeResult.includes[kIncludeInHeader] = [
511
...(extras[kIncludeInHeader] || []),
512
...(executeResult.includes[kIncludeInHeader] || []),
513
];
514
}
515
executeResult.supporting.push(...results.supporting);
516
};
517
518
for (const format of Object.keys(contexts)) {
519
pushTiming("render-context");
520
const context = safeCloneDeep(contexts[format]); // since we're going to mutate it...
521
522
// disquality some documents from server: shiny
523
if (isServerShiny(context.format) && context.project) {
524
const src = relative(context.project?.dir!, context.target.source);
525
if (projectIsWebsite(context.project)) {
526
error(
527
`${src} uses server: shiny so cannot be included in a website project ` +
528
`(shiny documents require a backend server and so can't be published as static web content).`,
529
);
530
throw new Error();
531
} else if (
532
(projectOutputDir(context.project) !==
533
normalizePath(context.project.dir)) &&
534
isServerShinyKnitr(context.format, context.engine.name)
535
) {
536
error(
537
`${src} is a knitr engine document that uses server: shiny so cannot be included in a project with an output-dir ` +
538
`(shiny document output must be rendered alongside its source document).`,
539
);
540
throw new Error();
541
}
542
}
543
544
// get output recipe
545
const recipe = outputRecipe(context);
546
outputs.push({
547
path: recipe.finalOutput || recipe.output,
548
isTransient: recipe.isOutputTransient,
549
format: context.format,
550
});
551
552
if (context.active) {
553
// Set the date locale for this render
554
// Used for date formatting
555
initDayJsPlugins();
556
const resolveLang = () => {
557
const lang = context.format.metadata[kLang] ||
558
options.flags?.pandocMetadata?.[kLang];
559
if (typeof lang === "string") {
560
return lang;
561
} else {
562
return undefined;
563
}
564
};
565
const dateFormatLang = resolveLang();
566
if (dateFormatLang) {
567
await setDateLocale(
568
dateFormatLang,
569
);
570
}
571
572
lifetime.attach({
573
cleanup() {
574
resetFigureCounter();
575
},
576
});
577
try {
578
// one time denoDom init for html compatible formats
579
if (isHtmlCompatible(context.format)) {
580
await initDenoDom();
581
}
582
583
// determine execute options
584
const executeOptions = mergeConfigs(
585
{
586
alwaysExecute: alwaysExecuteFiles?.includes(file.path),
587
},
588
pandocRenderer.onBeforeExecute(recipe.format),
589
);
590
591
const validate = context.format.render?.["validate-yaml"];
592
if (validate !== false) {
593
const validationResult = await validateDocument(context);
594
if (validationResult.length) {
595
throw new RenderInvalidYAMLError();
596
}
597
}
598
599
// FIXME it should be possible to infer this directly now
600
// based on the information in the mapped strings.
601
//
602
// collect line numbers to facilitate runtime error reporting
603
const { ojsBlockLineNumbers } = annotateOjsLineNumbers(context);
604
605
// execute
606
const baseExecuteResult = await renderExecute(
607
context,
608
recipe.output,
609
executeOptions,
610
);
611
612
// recover source map from diff and create a mappedExecuteResult
613
// for markdown processing pre-pandoc with mapped strings
614
let mappedMarkdown: MappedString;
615
616
withTiming("diff-execute-result", () => {
617
if (!isJupyterNotebook(context.target.source)) {
618
mappedMarkdown = mappedDiff(
619
context.target.markdown,
620
baseExecuteResult.markdown,
621
);
622
} else {
623
mappedMarkdown = asMappedString(baseExecuteResult.markdown);
624
}
625
});
626
627
const resourceFiles: string[] = [];
628
if (baseExecuteResult.resourceFiles) {
629
resourceFiles.push(...baseExecuteResult.resourceFiles);
630
}
631
632
const languageCellHandlerOptions: LanguageCellHandlerOptions = {
633
name: "",
634
temp: tempContext,
635
format: recipe.format,
636
markdown: mappedMarkdown!,
637
context,
638
flags: options.flags || {} as RenderFlags,
639
stage: "post-engine",
640
};
641
642
let unmappedExecuteResult: ExecuteResult;
643
await withTimingAsync("handle-language-cells", async () => {
644
// handle language cells
645
const { markdown, results } = await handleLanguageCells(
646
languageCellHandlerOptions,
647
);
648
const mappedExecuteResult: MappedExecuteResult = {
649
...baseExecuteResult,
650
markdown,
651
};
652
653
mergeHandlerResults(
654
context.target.preEngineExecuteResults,
655
mappedExecuteResult,
656
context,
657
);
658
mergeHandlerResults(results, mappedExecuteResult, context);
659
660
// process ojs
661
const { executeResult, resourceFiles: ojsResourceFiles } =
662
await ojsExecuteResult(
663
context,
664
mappedExecuteResult,
665
ojsBlockLineNumbers,
666
);
667
resourceFiles.push(...ojsResourceFiles);
668
669
// keep md if requested
670
const keepMd = executionEngineKeepMd(context);
671
if (keepMd && context.format.execute[kKeepMd]) {
672
Deno.writeTextFileSync(keepMd, executeResult.markdown.value);
673
}
674
675
// now get "unmapped" execute result back to send to pandoc
676
unmappedExecuteResult = {
677
...executeResult,
678
markdown: executeResult.markdown.value,
679
};
680
});
681
682
// Ensure that we have rendered any notebooks
683
await ensureNotebookContext(
684
unmappedExecuteResult!.markdown,
685
context.options.services,
686
project,
687
);
688
689
// callback
690
pushTiming("render-pandoc");
691
await pandocRenderer.onRender(format, {
692
context,
693
recipe,
694
executeResult: unmappedExecuteResult!,
695
resourceFiles,
696
}, pandocQuiet);
697
popTiming();
698
} finally {
699
popTiming();
700
}
701
}
702
}
703
await pandocRenderer.onPostProcess(outputs, project);
704
}
705
706
// default pandoc renderer immediately renders each execute result
707
function defaultPandocRenderer(
708
_options: RenderOptions,
709
_project: ProjectContext,
710
): PandocRenderer {
711
const renderCompletions: PandocRenderCompletion[] = [];
712
const renderedFiles: RenderedFile[] = [];
713
714
return {
715
onFilterContexts: (
716
_file: string,
717
contexts: Record<string, RenderContext>,
718
_files: RenderFile[],
719
_options: RenderOptions,
720
) => {
721
return contexts;
722
},
723
onBeforeExecute: (_format: Format) => ({}),
724
onRender: async (
725
_format: string,
726
executedFile: ExecutedFile,
727
quiet: boolean,
728
) => {
729
renderCompletions.push(await renderPandoc(executedFile, quiet));
730
},
731
onPostProcess: async (renderedFormats: RenderedFormat[]) => {
732
let completion = renderCompletions.pop();
733
while (completion) {
734
renderedFiles.push(await completion.complete(renderedFormats));
735
completion = renderCompletions.pop();
736
}
737
renderedFiles.reverse();
738
},
739
onComplete: async () => {
740
return {
741
files: await Promise.resolve(renderedFiles),
742
};
743
},
744
};
745
}
746
class RenderInvalidYAMLError extends YAMLValidationError {
747
constructor() {
748
super("Render failed due to invalid YAML.");
749
}
750
}
751
752