Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/render/render-contexts.ts
6449 views
1
/*
2
* render-contexts.ts
3
*
4
* Copyright (C) 2021-2024 Posit Software, PBC
5
*/
6
7
import { Format, FormatExecute, Metadata } from "../../config/types.ts";
8
import {
9
RenderContext,
10
RenderFile,
11
RenderFlags,
12
RenderOptions,
13
RenderServices,
14
} from "./types.ts";
15
16
import { dirname, join, relative } from "../../deno_ral/path.ts";
17
18
import * as ld from "../../core/lodash.ts";
19
import { projectType } from "../../project/types/project-types.ts";
20
21
import { getFrontMatterSchema } from "../../core/lib/yaml-schema/front-matter.ts";
22
import {
23
formatFromMetadata,
24
formatKeys,
25
includedMetadata,
26
mergeFormatMetadata,
27
metadataAsFormat,
28
} from "../../config/metadata.ts";
29
30
import {
31
kBibliography,
32
kCache,
33
kCss,
34
kEcho,
35
kEngine,
36
kExecuteDaemon,
37
kExecuteDaemonRestart,
38
kExecuteDebug,
39
kExecuteEnabled,
40
kExtensionName,
41
kHeaderIncludes,
42
kIncludeAfter,
43
kIncludeAfterBody,
44
kIncludeBefore,
45
kIncludeBeforeBody,
46
kIncludeInHeader,
47
kIpynbFilters,
48
kIpynbShellInteractivity,
49
kMetadataFormat,
50
kOutputExt,
51
kOutputFile,
52
kServer,
53
kTargetFormat,
54
kWarning,
55
} from "../../config/constants.ts";
56
import {
57
formatLanguage,
58
resolveLanguageMetadata,
59
} from "../../core/language.ts";
60
import { defaultWriterFormat } from "../../format/formats.ts";
61
import { mergeConfigs } from "../../core/config.ts";
62
import {
63
ExecutionEngineInstance,
64
ExecutionTarget,
65
} from "../../execute/types.ts";
66
import {
67
deleteProjectMetadata,
68
directoryMetadataForInputFile,
69
toInputRelativePaths,
70
} from "../../project/project-shared.ts";
71
import {
72
kProjectLibDir,
73
kProjectType,
74
ProjectContext,
75
} from "../../project/types.ts";
76
import { warnOnce } from "../../core/log.ts";
77
import { dirAndStem } from "../../core/path.ts";
78
import { fileExecutionEngineAndTarget } from "../../execute/engine.ts";
79
import { removePandocTo } from "./flags.ts";
80
import { filesDirLibDir } from "./render-paths.ts";
81
import { isJupyterNotebook } from "../../core/jupyter/jupyter.ts";
82
import { LanguageCellHandlerOptions } from "../../core/handlers/types.ts";
83
import { handleLanguageCells } from "../../core/handlers/base.ts";
84
import {
85
FormatDescriptor,
86
isValidFormat,
87
parseFormatString,
88
} from "../../core/pandoc/pandoc-formats.ts";
89
import { ExtensionContext } from "../../extension/types.ts";
90
import { NotebookContext } from "../../render/notebook/notebook-types.ts";
91
import { safeCloneDeep } from "../../core/safe-clone-deep.ts";
92
import { darkModeDefaultMetadata } from "../../format/html/format-html-info.ts";
93
94
export async function resolveFormatsFromMetadata(
95
metadata: Metadata,
96
input: string,
97
formats: string[],
98
flags?: RenderFlags,
99
): Promise<Record<string, { format: Format; active: boolean }>> {
100
const includeDir = dirname(input);
101
102
// Read any included metadata files and merge in and metadata from the command
103
const frontMatterSchema = await getFrontMatterSchema();
104
const included = await includedMetadata(
105
includeDir,
106
metadata,
107
frontMatterSchema,
108
);
109
const allMetadata = mergeQuartoConfigs(
110
metadata,
111
included.metadata,
112
flags?.metadata || {},
113
);
114
115
// resolve any language file references
116
await resolveLanguageMetadata(allMetadata, includeDir);
117
118
// divide allMetadata into format buckets
119
const baseFormat = metadataAsFormat(allMetadata);
120
121
if (formats === undefined) {
122
formats = formatKeys(allMetadata);
123
}
124
125
// provide a default format
126
if (formats.length === 0) {
127
formats.push(baseFormat.pandoc.to || baseFormat.pandoc.writer || "html");
128
}
129
130
// determine render formats
131
const renderFormats: string[] = [];
132
if (flags?.to === undefined) {
133
renderFormats.push(...formats);
134
} else if (flags?.to === "default") {
135
renderFormats.push(formats[0]);
136
} else {
137
const toFormats = flags.to.split(",").flatMap((to) => {
138
if (to === "all") {
139
return formats;
140
} else {
141
return [to];
142
}
143
});
144
renderFormats.push(...toFormats);
145
}
146
147
// get a list of _all_ formats
148
formats = ld.uniq(formats.concat(renderFormats));
149
150
const resolved: Record<string, { format: Format; active: boolean }> = {};
151
152
formats.forEach((to) => {
153
// determine the target format
154
const format = formatFromMetadata(
155
baseFormat,
156
to,
157
flags?.debug,
158
);
159
160
// merge configs
161
const config = mergeFormatMetadata(baseFormat, format);
162
163
// apply any metadata filter
164
const defaultFormat = defaultWriterFormat(to);
165
const resolveFormat = defaultFormat.resolveFormat;
166
if (resolveFormat) {
167
resolveFormat(config);
168
}
169
170
// apply command line arguments
171
172
// --no-execute-code
173
if (flags?.execute !== undefined) {
174
config.execute[kExecuteEnabled] = flags?.execute;
175
}
176
177
// --cache
178
if (flags?.executeCache !== undefined) {
179
config.execute[kCache] = flags?.executeCache;
180
}
181
182
// --execute-daemon
183
if (flags?.executeDaemon !== undefined) {
184
config.execute[kExecuteDaemon] = flags.executeDaemon;
185
}
186
187
// --execute-daemon-restart
188
if (flags?.executeDaemonRestart !== undefined) {
189
config.execute[kExecuteDaemonRestart] = flags.executeDaemonRestart;
190
}
191
192
// --execute-debug
193
if (flags?.executeDebug !== undefined) {
194
config.execute[kExecuteDebug] = flags.executeDebug;
195
}
196
197
resolved[to] = {
198
format: config,
199
active: renderFormats.includes(to),
200
};
201
});
202
203
return resolved;
204
}
205
206
export async function renderContexts(
207
file: RenderFile,
208
options: RenderOptions,
209
forExecute: boolean,
210
notebookContext: NotebookContext,
211
project: ProjectContext,
212
cloneOptions: boolean = true,
213
enforceProjectFormats: boolean = true,
214
): Promise<Record<string, RenderContext>> {
215
if (cloneOptions) {
216
// clone options (b/c we will modify them)
217
// we make it optional because some of the callers have
218
// actually just cloned it themselves and don't need to preserve
219
// the original
220
options = safeCloneDeep(options);
221
}
222
223
const { engine, target } = await fileExecutionEngineAndTarget(
224
file.path,
225
options.flags,
226
project,
227
);
228
229
// resolve render target
230
const formats = await resolveFormats(
231
file,
232
target,
233
engine,
234
options,
235
notebookContext,
236
project,
237
enforceProjectFormats,
238
);
239
240
// remove --to (it's been resolved into contexts)
241
options = removePandocTo(options);
242
243
// see if there is a libDir
244
let libDir = project?.config?.project[kProjectLibDir];
245
if (project && libDir) {
246
libDir = relative(dirname(file.path), join(project.dir, libDir));
247
} else {
248
libDir = filesDirLibDir(file.path);
249
}
250
251
// return contexts
252
const contexts: Record<string, RenderContext> = {};
253
for (const formatKey of Object.keys(formats)) {
254
formats[formatKey].format.language = await formatLanguage(
255
formats[formatKey].format.metadata,
256
formats[formatKey].format.language,
257
options.flags,
258
);
259
260
// set format
261
const context: RenderContext = {
262
target,
263
options,
264
engine,
265
format: formats[formatKey].format,
266
active: formats[formatKey].active,
267
project,
268
libDir: libDir!,
269
};
270
contexts[formatKey] = context;
271
272
// at this point we have enough to fix up the target and engine
273
// in case that's needed.
274
275
if (!isJupyterNotebook(context.target.source)) {
276
// this is not a jupyter notebook input,
277
// so we can run pre-engine handlers
278
279
const preEngineCellHandlerOptions: LanguageCellHandlerOptions = {
280
name: "", // this gets filled out by handleLanguageCells later.
281
temp: options.services.temp,
282
format: context.format,
283
markdown: context.target.markdown,
284
context,
285
flags: options.flags || {} as RenderFlags,
286
stage: "pre-engine",
287
};
288
289
const { markdown, results } = await handleLanguageCells(
290
preEngineCellHandlerOptions,
291
);
292
293
context.target.markdown = markdown;
294
295
if (results) {
296
context.target.preEngineExecuteResults = results;
297
}
298
}
299
300
// if this isn't for execute then cleanup context
301
if (!forExecute && engine.executeTargetSkipped) {
302
engine.executeTargetSkipped(target, formats[formatKey].format);
303
}
304
}
305
return contexts;
306
}
307
308
export async function renderFormats(
309
file: string,
310
services: RenderServices,
311
to = "all",
312
project: ProjectContext,
313
): Promise<Record<string, Format>> {
314
const contexts = await renderContexts(
315
{ path: file },
316
{ services, flags: { to } },
317
false,
318
services.notebook,
319
project,
320
);
321
const formats: Record<string, Format> = {};
322
Object.keys(contexts).forEach((formatName) => {
323
// get the format
324
const context = contexts[formatName];
325
const format = context.format;
326
// remove other formats
327
delete format.metadata.format;
328
// remove project level metadata
329
deleteProjectMetadata(format.metadata);
330
// resolve output-file
331
if (!format.pandoc[kOutputFile]) {
332
const [_dir, stem] = dirAndStem(file);
333
format.pandoc[kOutputFile] = `${stem}.${format.render[kOutputExt]}`;
334
}
335
// provide engine
336
format.execute[kEngine] = context.engine.name;
337
formats[formatName] = format;
338
});
339
return formats;
340
}
341
342
function mergeQuartoConfigs(
343
config: Metadata,
344
...configs: Array<Metadata>
345
): Metadata {
346
// copy all configs so we don't mutate them
347
config = safeCloneDeep(config);
348
configs = safeCloneDeep(configs);
349
350
// bibliography needs to always be an array so it can be merged
351
const fixupMergeableScalars = (metadata: Metadata) => {
352
// see https://github.com/quarto-dev/quarto-cli/pull/12372
353
// and https://github.com/quarto-dev/quarto-cli/pull/12369
354
// for more details on why we need this check, as a consequence of an unintuitive
355
// ordering of YAML validation operations
356
if (metadata === null) return metadata;
357
[
358
kBibliography,
359
kCss,
360
kHeaderIncludes,
361
kIncludeBefore,
362
kIncludeAfter,
363
kIncludeInHeader,
364
kIncludeBeforeBody,
365
kIncludeAfterBody,
366
]
367
.forEach((key) => {
368
if (typeof (metadata[key]) === "string") {
369
metadata[key] = [metadata[key]];
370
}
371
});
372
};
373
374
// formats need to always be objects
375
const fixupFormat = (config: Record<string, unknown>) => {
376
const format = config[kMetadataFormat];
377
if (typeof format === "string") {
378
config.format = { [format]: {} };
379
} else if (format instanceof Object) {
380
Object.keys(format).forEach((key) => {
381
if (typeof (Reflect.get(format, key)) !== "object") {
382
Reflect.set(format, key, {});
383
}
384
fixupMergeableScalars(Reflect.get(format, key) as Metadata);
385
});
386
}
387
fixupMergeableScalars(config);
388
return config;
389
};
390
391
return mergeConfigs(
392
fixupFormat(config),
393
...configs.map((c) => fixupFormat(c)),
394
);
395
}
396
397
async function resolveFormats(
398
file: RenderFile,
399
target: ExecutionTarget,
400
engine: ExecutionEngineInstance,
401
options: RenderOptions,
402
_notebookContext: NotebookContext,
403
project: ProjectContext,
404
enforceProjectFormats: boolean = true,
405
): Promise<Record<string, { format: Format; active: boolean }>> {
406
// input level metadata
407
const inputMetadata = target.metadata;
408
409
// directory level metadata
410
const directoryMetadata = project?.dir
411
? await directoryMetadataForInputFile(
412
project,
413
dirname(target.input),
414
)
415
: {};
416
417
// project level metadata
418
const projMetadata = project === undefined
419
? ({} as Metadata)
420
: await projectMetadataForInputFile(
421
target.input,
422
project,
423
);
424
// determine formats (treat dir format keys as part of 'input' format keys)
425
let formats: string[] = [];
426
const projFormatKeys = formatKeys(projMetadata);
427
const dirFormatKeys = formatKeys(directoryMetadata);
428
const inputFormatKeys = ld.uniq(
429
formatKeys(inputMetadata).concat(dirFormatKeys),
430
);
431
const projType = projectType(project?.config?.project?.[kProjectType]);
432
if (projType.projectFormatsOnly) {
433
// if the project specifies that only project formats are
434
// valid then use the project formats
435
formats = projFormatKeys;
436
} else if (inputFormatKeys.length > 0) {
437
// if the input metadata has a format then this is an override
438
// of the project so use its keys (and ignore the project)
439
formats = inputFormatKeys;
440
// otherwise use the project formats
441
} else {
442
formats = projFormatKeys;
443
}
444
445
// If the file itself has specified permissible
446
// formats, filter the list of formats to only
447
// include those formats
448
if (file.formats) {
449
formats = formats.filter((format) => {
450
return file.formats?.includes(format);
451
});
452
453
// Remove any 'to' information that will force the
454
// rendering to a particular format
455
options = safeCloneDeep(options);
456
delete options.flags?.to;
457
}
458
459
// resolve formats for each type of metadata
460
const projFormats = await resolveFormatsFromMetadata(
461
projMetadata,
462
target.input,
463
formats,
464
options.flags,
465
);
466
467
const directoryFormats = await resolveFormatsFromMetadata(
468
directoryMetadata,
469
target.input,
470
formats,
471
options.flags,
472
);
473
474
const inputFormats = await resolveFormatsFromMetadata(
475
inputMetadata,
476
target.input,
477
formats,
478
options.flags,
479
);
480
481
const activeKeys = (
482
formats: Record<string, { format: Format; active: boolean }>,
483
) => {
484
return Object.keys(formats).filter((key) => {
485
return formats[key].active;
486
});
487
};
488
489
// A list of all the active format keys
490
const activeFormatKeys = ld.uniq(
491
activeKeys(projFormats).concat(activeKeys(directoryFormats)).concat(
492
activeKeys(inputFormats),
493
),
494
);
495
// A list of all the format keys included
496
const allFormatKeys = ld.uniq(
497
Object.keys(projFormats).concat(Object.keys(directoryFormats)).concat(
498
Object.keys(inputFormats),
499
),
500
);
501
502
const mergedFormats: Record<string, Format> = {};
503
for (const format of allFormatKeys) {
504
// alias formats
505
const projFormat = projFormats[format].format;
506
const directoryFormat = directoryFormats[format].format;
507
const inputFormat = inputFormats[format].format;
508
509
// combine user formats
510
const userFormat = mergeFormatMetadata(
511
projFormat || {},
512
directoryFormat || {},
513
inputFormat || {},
514
);
515
516
// default 'echo' and 'ipynb-shell-interactivity'
517
// for documents with a server
518
if (userFormat.metadata[kServer] !== undefined) {
519
// default echo
520
if (userFormat.execute[kEcho] === undefined) {
521
userFormat.execute[kEcho] = false;
522
}
523
// default shell interactivity
524
if (userFormat.execute[kIpynbShellInteractivity] === undefined) {
525
userFormat.execute[kIpynbShellInteractivity] = "all";
526
}
527
}
528
529
// If options request, force echo
530
if (options.echo) {
531
userFormat.execute[kEcho] = true;
532
}
533
534
// If options request, force warning
535
if (options.warning) {
536
userFormat.execute[kWarning] = true;
537
}
538
539
// The format description
540
const formatDesc = parseFormatString(format);
541
542
// Read any extension metadata and merge it into the
543
// format metadata
544
const extensionMetadata = await readExtensionFormat(
545
target.source,
546
formatDesc,
547
options.services.extension,
548
project,
549
);
550
551
// do the merge of the writer format into this format
552
mergedFormats[format] = mergeFormatMetadata(
553
defaultWriterFormat(formatDesc.formatWithVariants),
554
extensionMetadata[formatDesc.baseFormat]
555
? extensionMetadata[formatDesc.baseFormat].format
556
: {},
557
userFormat,
558
);
559
// Insist that the target format reflect the correct value.
560
mergedFormats[format].identifier[kTargetFormat] = format;
561
562
//deno-lint-ignore no-explicit-any
563
mergedFormats[format].mergeAdditionalFormats = (...configs: any[]) => {
564
return mergeFormatMetadata(
565
defaultWriterFormat(formatDesc.formatWithVariants),
566
extensionMetadata[formatDesc.baseFormat]
567
? extensionMetadata[formatDesc.baseFormat].format
568
: {},
569
...configs,
570
userFormat,
571
);
572
};
573
574
// resolve brand in project and forward it to format
575
const brand = await project.resolveBrand(target.source);
576
if (brand) {
577
mergedFormats[format].render.brand = {
578
light: brand.light,
579
dark: (brand.enablesDarkMode ||
580
darkModeDefaultMetadata(mergedFormats[format].metadata) !==
581
undefined)
582
? brand.dark
583
: undefined,
584
};
585
}
586
// apply defaults from brand yaml under the metadata of the current format
587
const brandFormatDefaults: Metadata =
588
(brand?.light?.data?.defaults?.quarto as unknown as Record<
589
string,
590
Record<string, Metadata>
591
>)?.format
592
?.[format as string];
593
if (brandFormatDefaults) {
594
mergedFormats[format].metadata = mergeConfigs(
595
brandFormatDefaults,
596
mergedFormats[format].metadata,
597
);
598
}
599
600
// ensure that we have a valid forma
601
const formatIsValid = isValidFormat(
602
formatDesc,
603
mergedFormats[format].pandoc,
604
);
605
if (!formatIsValid) {
606
throw new Error(`Unknown format ${format}`);
607
}
608
}
609
610
// filter on formats supported by this project
611
if (enforceProjectFormats) {
612
for (const formatName of Object.keys(mergedFormats)) {
613
const format: Format = mergedFormats[formatName];
614
if (projType.isSupportedFormat) {
615
if (!projType.isSupportedFormat(format)) {
616
delete mergedFormats[formatName];
617
warnOnce(
618
`The ${formatName} format is not supported by ${projType.type} projects`,
619
);
620
}
621
}
622
}
623
}
624
625
// apply some others
626
for (const formatName of Object.keys(mergedFormats)) {
627
let format = mergedFormats[formatName];
628
629
// run any ipynb-filters to discover generated metadata, then merge it back in
630
if (hasIpynbFilters(format.execute)) {
631
// read markdown w/ filter
632
const markdown = await engine.partitionedMarkdown(target.source, format);
633
// merge back metadata
634
if (markdown.yaml) {
635
const nbFormats = await resolveFormatsFromMetadata(
636
markdown.yaml,
637
target.source,
638
[formatName],
639
{ ...options.flags, to: undefined },
640
);
641
format = mergeConfigs(format, nbFormats[formatName]);
642
}
643
}
644
645
// apply engine format filters
646
if (engine.filterFormat) {
647
format = engine.filterFormat(
648
target.source,
649
options,
650
format,
651
);
652
}
653
654
// Allow the project type to filter the format
655
if (projType.filterFormat) {
656
format = projType.filterFormat(target.source, format, project);
657
}
658
659
mergedFormats[formatName] = format;
660
}
661
662
const finalFormats: Record<string, { format: Format; active: boolean }> = {};
663
for (const key of Object.keys(mergedFormats)) {
664
const active = activeFormatKeys.includes(key);
665
finalFormats[key] = {
666
format: mergedFormats[key],
667
active,
668
};
669
}
670
return finalFormats;
671
}
672
673
const readExtensionFormat = async (
674
file: string,
675
formatDesc: FormatDescriptor,
676
extensionContext: ExtensionContext,
677
project?: ProjectContext,
678
) => {
679
// Determine effective extension - use default for certain project/format combinations
680
let effectiveExtension = formatDesc.extension;
681
682
// For book projects with typst format and no explicit extension,
683
// use orange-book as the default typst book template
684
if (
685
!effectiveExtension &&
686
formatDesc.baseFormat === "typst" &&
687
project?.config?.project?.[kProjectType] === "book"
688
) {
689
effectiveExtension = "orange-book";
690
}
691
692
// Read the format file and populate this
693
if (effectiveExtension) {
694
// Find the yaml file
695
const extension = await extensionContext.extension(
696
effectiveExtension,
697
file,
698
project?.config,
699
project?.dir,
700
);
701
702
// Read the yaml file and resolve / bucketize
703
const extensionFormat = extension?.contributes.formats;
704
if (extensionFormat) {
705
const fmtTarget = formatDesc.modifiers
706
? `${formatDesc.baseFormat}${formatDesc.modifiers.join("")}`
707
: formatDesc.baseFormat;
708
const extensionMetadata =
709
(extensionFormat[fmtTarget] || extensionFormat[formatDesc.baseFormat] ||
710
{}) as Metadata;
711
extensionMetadata[kExtensionName] = extensionMetadata[kExtensionName] ||
712
effectiveExtension;
713
714
const formats = await resolveFormatsFromMetadata(
715
extensionMetadata,
716
extension.path,
717
[formatDesc.baseFormat],
718
);
719
720
return formats;
721
} else {
722
throw new Error(
723
`No valid format ${formatDesc.baseFormat} is provided by the extension ${effectiveExtension}`,
724
);
725
}
726
} else {
727
return {};
728
}
729
};
730
731
function hasIpynbFilters(execute: FormatExecute) {
732
return execute[kIpynbFilters] && execute[kIpynbFilters]?.length;
733
}
734
735
export async function projectMetadataForInputFile(
736
input: string,
737
project: ProjectContext,
738
): Promise<Metadata> {
739
if (project.dir && project.config) {
740
// If there is directory and configuration information
741
// process paths
742
return toInputRelativePaths(
743
projectType(project.config?.project?.[kProjectType]),
744
project.dir,
745
dirname(input),
746
safeCloneDeep(project.config),
747
) as Metadata;
748
} else {
749
// Just return the config or empty metadata
750
return safeCloneDeep(project.config) || {};
751
}
752
}
753
754