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