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
6449 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
);
207
}
208
209
// return results
210
return thawedResult;
211
}
212
}
213
}
214
215
// calculate figsDir
216
const figsDir = join(filesDir, figuresDir(context.format.pandoc.to));
217
218
pushTiming("render-execute");
219
220
const executeOptions: ExecuteOptions = {
221
target: context.target,
222
resourceDir: resourcePath(),
223
tempDir: context.options.services.temp.createDir(),
224
dependencies: resolveDependencies,
225
libDir: context.libDir,
226
format: context.format,
227
projectDir: context.project?.dir,
228
cwd: flags.executeDir || dirname(normalizePath(context.target.source)),
229
params: resolveParams(flags.params, flags.paramsFile),
230
quiet: flags.quiet,
231
previewServer: context.options.previewServer,
232
handledLanguages: languages(),
233
project: context.project,
234
};
235
// execute computations
236
setExecuteEnvironment(executeOptions);
237
const executeResult = await context.engine.execute(executeOptions);
238
popTiming();
239
240
// write the freeze file if we are in a project
241
if (context.project && !context.project.isSingleFile && canFreeze) {
242
// write the freezer file
243
const freezeFile = freezeExecuteResult(
244
context.target.source,
245
output,
246
executeResult,
247
);
248
249
// always copy to the hidden freezer
250
copyToProjectFreezer(context.project, projRelativeFilesDir!, true, true);
251
252
// copy to the _freeze dir if explicit _freeze is requested
253
if (context.format.execute[kFreeze] !== false) {
254
copyToProjectFreezer(context.project, projRelativeFilesDir!, false, true);
255
} else {
256
// otherwise cleanup the _freeze subdir b/c we aren't explicitly freezing anymore
257
258
// figs dir for this target format
259
const freezeFigsDir = freezerFigsDir(
260
context.project,
261
projRelativeFilesDir!,
262
basename(figsDir),
263
);
264
removeIfExists(freezeFigsDir);
265
266
// freezer file
267
const projRelativeFreezeFile = relative(context.project.dir, freezeFile);
268
const freezerFile = freezerFreezeFile(
269
context.project,
270
projRelativeFreezeFile,
271
);
272
removeIfExists(freezerFile);
273
274
// remove empty directories
275
removeIfEmptyDir(dirname(freezerFile));
276
removeIfEmptyDir(dirname(freezeFigsDir));
277
removeIfEmptyDir(join(context.project.dir, kProjectFreezeDir));
278
}
279
280
// remove the freeze results file (now that it's safely in the freezer)
281
removeFreezeResults(join(context.project.dir, projRelativeFilesDir!));
282
}
283
284
// return result
285
return executeResult;
286
}
287
288
export async function renderFiles(
289
files: RenderFile[],
290
options: RenderOptions,
291
notebookContext: NotebookContext,
292
alwaysExecuteFiles: string[] | undefined,
293
pandocRenderer: PandocRenderer | undefined,
294
project: ProjectContext,
295
): Promise<RenderFilesResult> {
296
// provide default renderer
297
pandocRenderer = pandocRenderer || defaultPandocRenderer(options, project);
298
299
// create temp context
300
const tempContext = createTempContext();
301
302
try {
303
// make a copy of options so we don't mutate caller context
304
options = ld.cloneDeep(options);
305
306
// see if we should be using file-by-file progress
307
const progress = options.progress ||
308
(project && (files.length > 1) && !options.flags?.quiet);
309
310
// quiet pandoc output if we are doing file by file progress
311
const pandocQuiet = !!progress || !!options.quietPandoc;
312
313
// calculate num width
314
const numWidth = String(files.length).length;
315
316
for (let i = 0; i < files.length; i++) {
317
const file = files[i];
318
319
if (progress) {
320
renderProgress(
321
`\r[${String(i + 1).padStart(numWidth)}/${files.length}] ${
322
relative(project!.dir, file.path)
323
}`,
324
);
325
}
326
327
// get contexts
328
const fileLifetime = await waitUntilNamedLifetime("render-file");
329
try {
330
await renderFileInternal(
331
fileLifetime,
332
file,
333
options,
334
project,
335
pandocRenderer,
336
files,
337
tempContext,
338
alwaysExecuteFiles,
339
pandocQuiet,
340
notebookContext,
341
);
342
} finally {
343
fileLifetime.cleanup();
344
}
345
}
346
347
if (progress) {
348
info("");
349
}
350
351
return await pandocRenderer.onComplete(false, options.flags?.quiet);
352
} catch (error) {
353
if (!(error instanceof Error)) {
354
warn(`Error encountered when rendering files`);
355
}
356
return {
357
files: (await pandocRenderer.onComplete(true)).files,
358
error: error instanceof Error
359
? error
360
: new Error(error ? String(error) : undefined),
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(`Error encountered when rendering ${file.path}`);
413
}
414
return {
415
files: (await pandocRenderer.onComplete(true)).files,
416
error: error instanceof Error
417
? error
418
: new Error(error ? String(error) : undefined),
419
};
420
} finally {
421
if (Deno.env.get("QUARTO_PROFILER_OUTPUT")) {
422
Deno.writeTextFileSync(
423
Deno.env.get("QUARTO_PROFILER_OUTPUT")!,
424
JSON.stringify(getTimingData()),
425
);
426
}
427
}
428
}
429
430
async function renderFileInternal(
431
lifetime: Lifetime,
432
file: RenderFile,
433
options: RenderOptions,
434
project: ProjectContext,
435
pandocRenderer: PandocRenderer,
436
files: RenderFile[],
437
tempContext: TempContext,
438
alwaysExecuteFiles: string[] | undefined,
439
pandocQuiet: boolean,
440
notebookContext: NotebookContext,
441
enforceProjectFormats: boolean = true,
442
) {
443
const outputs: Array<RenderedFormat> = [];
444
let contexts: Record<string, RenderContext> | undefined;
445
try {
446
contexts = await renderContexts(
447
file,
448
options,
449
true,
450
notebookContext,
451
project,
452
false,
453
enforceProjectFormats,
454
);
455
456
// Allow renderers to filter the contexts
457
contexts = pandocRenderer.onFilterContexts(
458
file.path,
459
contexts,
460
files,
461
options,
462
);
463
} catch (e) {
464
// bad YAML can cause failure before validation. We
465
// reconstruct the context as best we can and try to validate.
466
// note that this ignores "validate-yaml: false"
467
const { engine, target } = await fileExecutionEngineAndTarget(
468
file.path,
469
options.flags,
470
project,
471
);
472
const validationResult = await validateDocumentFromSource(
473
target.markdown,
474
engine.name,
475
error,
476
);
477
if (validationResult.length) {
478
throw new RenderInvalidYAMLError();
479
} else {
480
// rethrow if no validation error happened.
481
throw e;
482
}
483
}
484
const mergeHandlerResults = (
485
results: HandlerContextResults | undefined,
486
executeResult: MappedExecuteResult,
487
context: RenderContext,
488
) => {
489
if (results === undefined) {
490
return;
491
}
492
if (executeResult.includes) {
493
executeResult.includes = mergeConfigs(
494
executeResult.includes,
495
results.includes,
496
);
497
} else {
498
executeResult.includes = results.includes;
499
}
500
const extras = resolveDependencies(
501
results.extras,
502
dirname(context.target.source),
503
context.libDir,
504
tempContext,
505
project,
506
);
507
if (extras[kIncludeInHeader]) {
508
// note that we merge engine execute results back into cell handler
509
// execute results so that jupyter widget dependencies appear at the
510
// end (so that they don't mess w/ other libs using require/define)
511
executeResult.includes[kIncludeInHeader] = [
512
...(extras[kIncludeInHeader] || []),
513
...(executeResult.includes[kIncludeInHeader] || []),
514
];
515
}
516
executeResult.supporting.push(...results.supporting);
517
};
518
519
for (const format of Object.keys(contexts)) {
520
pushTiming("render-context");
521
const context = safeCloneDeep(contexts[format]); // since we're going to mutate it...
522
523
// disquality some documents from server: shiny
524
if (isServerShiny(context.format) && context.project) {
525
const src = relative(context.project?.dir!, context.target.source);
526
if (projectIsWebsite(context.project)) {
527
error(
528
`${src} uses server: shiny so cannot be included in a website project ` +
529
`(shiny documents require a backend server and so can't be published as static web content).`,
530
);
531
throw new Error();
532
} else if (
533
(projectOutputDir(context.project) !==
534
normalizePath(context.project.dir)) &&
535
isServerShinyKnitr(context.format, context.engine.name)
536
) {
537
error(
538
`${src} is a knitr engine document that uses server: shiny so cannot be included in a project with an output-dir ` +
539
`(shiny document output must be rendered alongside its source document).`,
540
);
541
throw new Error();
542
}
543
}
544
545
// get output recipe
546
const recipe = outputRecipe(context);
547
outputs.push({
548
path: recipe.finalOutput || recipe.output,
549
isTransient: recipe.isOutputTransient,
550
format: context.format,
551
});
552
553
if (context.active) {
554
// Set the date locale for this render
555
// Used for date formatting
556
initDayJsPlugins();
557
const resolveLang = () => {
558
const lang = context.format.metadata[kLang] ||
559
options.flags?.pandocMetadata?.[kLang];
560
if (typeof lang === "string") {
561
return lang;
562
} else {
563
return undefined;
564
}
565
};
566
const dateFormatLang = resolveLang();
567
if (dateFormatLang) {
568
await setDateLocale(
569
dateFormatLang,
570
);
571
}
572
573
lifetime.attach({
574
cleanup() {
575
resetFigureCounter();
576
},
577
});
578
try {
579
// one time denoDom init for html compatible formats
580
if (isHtmlCompatible(context.format)) {
581
await initDenoDom();
582
}
583
584
// determine execute options
585
const executeOptions = mergeConfigs(
586
{
587
alwaysExecute: alwaysExecuteFiles?.includes(file.path),
588
},
589
pandocRenderer.onBeforeExecute(recipe.format),
590
);
591
592
const validate = context.format.render?.["validate-yaml"];
593
if (validate !== false) {
594
const validationResult = await validateDocument(context);
595
if (validationResult.length) {
596
throw new RenderInvalidYAMLError();
597
}
598
}
599
600
// FIXME it should be possible to infer this directly now
601
// based on the information in the mapped strings.
602
//
603
// collect line numbers to facilitate runtime error reporting
604
const { ojsBlockLineNumbers } = annotateOjsLineNumbers(context);
605
606
// execute
607
const baseExecuteResult = await renderExecute(
608
context,
609
recipe.output,
610
executeOptions,
611
);
612
613
// recover source map from diff and create a mappedExecuteResult
614
// for markdown processing pre-pandoc with mapped strings
615
let mappedMarkdown: MappedString;
616
617
withTiming("diff-execute-result", () => {
618
if (!isJupyterNotebook(context.target.source)) {
619
mappedMarkdown = mappedDiff(
620
context.target.markdown,
621
baseExecuteResult.markdown,
622
);
623
} else {
624
mappedMarkdown = asMappedString(baseExecuteResult.markdown);
625
}
626
});
627
628
const resourceFiles: string[] = [];
629
if (baseExecuteResult.resourceFiles) {
630
resourceFiles.push(...baseExecuteResult.resourceFiles);
631
}
632
633
const languageCellHandlerOptions: LanguageCellHandlerOptions = {
634
name: "",
635
temp: tempContext,
636
format: recipe.format,
637
markdown: mappedMarkdown!,
638
context,
639
flags: options.flags || {} as RenderFlags,
640
stage: "post-engine",
641
};
642
643
let unmappedExecuteResult: ExecuteResult;
644
await withTimingAsync("handle-language-cells", async () => {
645
// handle language cells
646
const { markdown, results } = await handleLanguageCells(
647
languageCellHandlerOptions,
648
);
649
const mappedExecuteResult: MappedExecuteResult = {
650
...baseExecuteResult,
651
markdown,
652
};
653
654
mergeHandlerResults(
655
context.target.preEngineExecuteResults,
656
mappedExecuteResult,
657
context,
658
);
659
mergeHandlerResults(results, mappedExecuteResult, context);
660
661
// process ojs
662
const { executeResult, resourceFiles: ojsResourceFiles } =
663
await ojsExecuteResult(
664
context,
665
mappedExecuteResult,
666
ojsBlockLineNumbers,
667
);
668
resourceFiles.push(...ojsResourceFiles);
669
670
// keep md if requested
671
const keepMd = executionEngineKeepMd(context);
672
if (keepMd && context.format.execute[kKeepMd]) {
673
Deno.writeTextFileSync(keepMd, executeResult.markdown.value);
674
}
675
676
// now get "unmapped" execute result back to send to pandoc
677
unmappedExecuteResult = {
678
...executeResult,
679
markdown: executeResult.markdown.value,
680
};
681
});
682
683
// Ensure that we have rendered any notebooks
684
await ensureNotebookContext(
685
unmappedExecuteResult!.markdown,
686
context.options.services,
687
project,
688
);
689
690
// callback
691
pushTiming("render-pandoc");
692
await pandocRenderer.onRender(format, {
693
context,
694
recipe,
695
executeResult: unmappedExecuteResult!,
696
resourceFiles,
697
}, pandocQuiet);
698
popTiming();
699
} finally {
700
popTiming();
701
}
702
}
703
}
704
await pandocRenderer.onPostProcess(outputs, project);
705
}
706
707
// default pandoc renderer immediately renders each execute result
708
function defaultPandocRenderer(
709
_options: RenderOptions,
710
_project: ProjectContext,
711
): PandocRenderer {
712
const renderCompletions: PandocRenderCompletion[] = [];
713
const renderedFiles: RenderedFile[] = [];
714
715
return {
716
onFilterContexts: (
717
_file: string,
718
contexts: Record<string, RenderContext>,
719
_files: RenderFile[],
720
_options: RenderOptions,
721
) => {
722
return contexts;
723
},
724
onBeforeExecute: (_format: Format) => ({}),
725
onRender: async (
726
_format: string,
727
executedFile: ExecutedFile,
728
quiet: boolean,
729
) => {
730
renderCompletions.push(await renderPandoc(executedFile, quiet));
731
},
732
onPostProcess: async (renderedFormats: RenderedFormat[]) => {
733
let completion = renderCompletions.pop();
734
while (completion) {
735
renderedFiles.push(await completion.complete(renderedFormats));
736
completion = renderCompletions.pop();
737
}
738
renderedFiles.reverse();
739
},
740
onComplete: async () => {
741
return {
742
files: await Promise.resolve(renderedFiles),
743
};
744
},
745
};
746
}
747
class RenderInvalidYAMLError extends YAMLValidationError {
748
constructor() {
749
super("Render failed due to invalid YAML.");
750
}
751
}
752
753