Path: blob/main/src/command/render/render-contexts.ts
3584 views
/*1* render-contexts.ts2*3* Copyright (C) 2021-2024 Posit Software, PBC4*/56import { Format, FormatExecute, Metadata } from "../../config/types.ts";7import {8RenderContext,9RenderFile,10RenderFlags,11RenderOptions,12RenderServices,13} from "./types.ts";1415import { dirname, join, relative } from "../../deno_ral/path.ts";1617import * as ld from "../../core/lodash.ts";18import { projectType } from "../../project/types/project-types.ts";1920import { getFrontMatterSchema } from "../../core/lib/yaml-schema/front-matter.ts";21import {22formatFromMetadata,23formatKeys,24includedMetadata,25mergeFormatMetadata,26metadataAsFormat,27} from "../../config/metadata.ts";2829import {30kBibliography,31kCache,32kCss,33kEcho,34kEngine,35kExecuteDaemon,36kExecuteDaemonRestart,37kExecuteDebug,38kExecuteEnabled,39kExtensionName,40kHeaderIncludes,41kIncludeAfter,42kIncludeAfterBody,43kIncludeBefore,44kIncludeBeforeBody,45kIncludeInHeader,46kIpynbFilters,47kIpynbShellInteractivity,48kMetadataFormat,49kOutputExt,50kOutputFile,51kServer,52kTargetFormat,53kWarning,54} from "../../config/constants.ts";55import {56formatLanguage,57resolveLanguageMetadata,58} from "../../core/language.ts";59import { defaultWriterFormat } from "../../format/formats.ts";60import { mergeConfigs } from "../../core/config.ts";61import { ExecutionEngine, ExecutionTarget } from "../../execute/types.ts";62import {63deleteProjectMetadata,64directoryMetadataForInputFile,65toInputRelativePaths,66} from "../../project/project-shared.ts";67import {68kProjectLibDir,69kProjectType,70ProjectContext,71} from "../../project/types.ts";72import { warnOnce } from "../../core/log.ts";73import { dirAndStem } from "../../core/path.ts";74import { fileExecutionEngineAndTarget } from "../../execute/engine.ts";75import { removePandocTo } from "./flags.ts";76import { filesDirLibDir } from "./render-paths.ts";77import { isJupyterNotebook } from "../../core/jupyter/jupyter.ts";78import { LanguageCellHandlerOptions } from "../../core/handlers/types.ts";79import { handleLanguageCells } from "../../core/handlers/base.ts";80import {81FormatDescriptor,82isValidFormat,83parseFormatString,84} from "../../core/pandoc/pandoc-formats.ts";85import { ExtensionContext } from "../../extension/types.ts";86import { NotebookContext } from "../../render/notebook/notebook-types.ts";87import { safeCloneDeep } from "../../core/safe-clone-deep.ts";88import { darkModeDefaultMetadata } from "../../format/html/format-html-info.ts";8990export async function resolveFormatsFromMetadata(91metadata: Metadata,92input: string,93formats: string[],94flags?: RenderFlags,95): Promise<Record<string, { format: Format; active: boolean }>> {96const includeDir = dirname(input);9798// Read any included metadata files and merge in and metadata from the command99const frontMatterSchema = await getFrontMatterSchema();100const included = await includedMetadata(101includeDir,102metadata,103frontMatterSchema,104);105const allMetadata = mergeQuartoConfigs(106metadata,107included.metadata,108flags?.metadata || {},109);110111// resolve any language file references112await resolveLanguageMetadata(allMetadata, includeDir);113114// divide allMetadata into format buckets115const baseFormat = metadataAsFormat(allMetadata);116117if (formats === undefined) {118formats = formatKeys(allMetadata);119}120121// provide a default format122if (formats.length === 0) {123formats.push(baseFormat.pandoc.to || baseFormat.pandoc.writer || "html");124}125126// determine render formats127const renderFormats: string[] = [];128if (flags?.to === undefined) {129renderFormats.push(...formats);130} else if (flags?.to === "default") {131renderFormats.push(formats[0]);132} else {133const toFormats = flags.to.split(",").flatMap((to) => {134if (to === "all") {135return formats;136} else {137return [to];138}139});140renderFormats.push(...toFormats);141}142143// get a list of _all_ formats144formats = ld.uniq(formats.concat(renderFormats));145146const resolved: Record<string, { format: Format; active: boolean }> = {};147148formats.forEach((to) => {149// determine the target format150const format = formatFromMetadata(151baseFormat,152to,153flags?.debug,154);155156// merge configs157const config = mergeFormatMetadata(baseFormat, format);158159// apply any metadata filter160const defaultFormat = defaultWriterFormat(to);161const resolveFormat = defaultFormat.resolveFormat;162if (resolveFormat) {163resolveFormat(config);164}165166// apply command line arguments167168// --no-execute-code169if (flags?.execute !== undefined) {170config.execute[kExecuteEnabled] = flags?.execute;171}172173// --cache174if (flags?.executeCache !== undefined) {175config.execute[kCache] = flags?.executeCache;176}177178// --execute-daemon179if (flags?.executeDaemon !== undefined) {180config.execute[kExecuteDaemon] = flags.executeDaemon;181}182183// --execute-daemon-restart184if (flags?.executeDaemonRestart !== undefined) {185config.execute[kExecuteDaemonRestart] = flags.executeDaemonRestart;186}187188// --execute-debug189if (flags?.executeDebug !== undefined) {190config.execute[kExecuteDebug] = flags.executeDebug;191}192193resolved[to] = {194format: config,195active: renderFormats.includes(to),196};197});198199return resolved;200}201202export async function renderContexts(203file: RenderFile,204options: RenderOptions,205forExecute: boolean,206notebookContext: NotebookContext,207project: ProjectContext,208cloneOptions: boolean = true,209enforceProjectFormats: boolean = true,210): Promise<Record<string, RenderContext>> {211if (cloneOptions) {212// clone options (b/c we will modify them)213// we make it optional because some of the callers have214// actually just cloned it themselves and don't need to preserve215// the original216options = safeCloneDeep(options);217}218219const { engine, target } = await fileExecutionEngineAndTarget(220file.path,221options.flags,222project,223);224225// resolve render target226const formats = await resolveFormats(227file,228target,229engine,230options,231notebookContext,232project,233enforceProjectFormats,234);235236// remove --to (it's been resolved into contexts)237options = removePandocTo(options);238239// see if there is a libDir240let libDir = project?.config?.project[kProjectLibDir];241if (project && libDir) {242libDir = relative(dirname(file.path), join(project.dir, libDir));243} else {244libDir = filesDirLibDir(file.path);245}246247// return contexts248const contexts: Record<string, RenderContext> = {};249for (const formatKey of Object.keys(formats)) {250formats[formatKey].format.language = await formatLanguage(251formats[formatKey].format.metadata,252formats[formatKey].format.language,253options.flags,254);255256// set format257const context: RenderContext = {258target,259options,260engine,261format: formats[formatKey].format,262active: formats[formatKey].active,263project,264libDir: libDir!,265};266contexts[formatKey] = context;267268// at this point we have enough to fix up the target and engine269// in case that's needed.270271if (!isJupyterNotebook(context.target.source)) {272// this is not a jupyter notebook input,273// so we can run pre-engine handlers274275const preEngineCellHandlerOptions: LanguageCellHandlerOptions = {276name: "", // this gets filled out by handleLanguageCells later.277temp: options.services.temp,278format: context.format,279markdown: context.target.markdown,280context,281flags: options.flags || {} as RenderFlags,282stage: "pre-engine",283};284285const { markdown, results } = await handleLanguageCells(286preEngineCellHandlerOptions,287);288289context.target.markdown = markdown;290291if (results) {292context.target.preEngineExecuteResults = results;293}294}295296// if this isn't for execute then cleanup context297if (!forExecute && engine.executeTargetSkipped) {298engine.executeTargetSkipped(target, formats[formatKey].format, project);299}300}301return contexts;302}303304export async function renderFormats(305file: string,306services: RenderServices,307to = "all",308project: ProjectContext,309): Promise<Record<string, Format>> {310const contexts = await renderContexts(311{ path: file },312{ services, flags: { to } },313false,314services.notebook,315project,316);317const formats: Record<string, Format> = {};318Object.keys(contexts).forEach((formatName) => {319// get the format320const context = contexts[formatName];321const format = context.format;322// remove other formats323delete format.metadata.format;324// remove project level metadata325deleteProjectMetadata(format.metadata);326// resolve output-file327if (!format.pandoc[kOutputFile]) {328const [_dir, stem] = dirAndStem(file);329format.pandoc[kOutputFile] = `${stem}.${format.render[kOutputExt]}`;330}331// provide engine332format.execute[kEngine] = context.engine.name;333formats[formatName] = format;334});335return formats;336}337338function mergeQuartoConfigs(339config: Metadata,340...configs: Array<Metadata>341): Metadata {342// copy all configs so we don't mutate them343config = safeCloneDeep(config);344configs = safeCloneDeep(configs);345346// bibliography needs to always be an array so it can be merged347const fixupMergeableScalars = (metadata: Metadata) => {348// see https://github.com/quarto-dev/quarto-cli/pull/12372349// and https://github.com/quarto-dev/quarto-cli/pull/12369350// for more details on why we need this check, as a consequence of an unintuitive351// ordering of YAML validation operations352if (metadata === null) return metadata;353[354kBibliography,355kCss,356kHeaderIncludes,357kIncludeBefore,358kIncludeAfter,359kIncludeInHeader,360kIncludeBeforeBody,361kIncludeAfterBody,362]363.forEach((key) => {364if (typeof (metadata[key]) === "string") {365metadata[key] = [metadata[key]];366}367});368};369370// formats need to always be objects371const fixupFormat = (config: Record<string, unknown>) => {372const format = config[kMetadataFormat];373if (typeof format === "string") {374config.format = { [format]: {} };375} else if (format instanceof Object) {376Object.keys(format).forEach((key) => {377if (typeof (Reflect.get(format, key)) !== "object") {378Reflect.set(format, key, {});379}380fixupMergeableScalars(Reflect.get(format, key) as Metadata);381});382}383fixupMergeableScalars(config);384return config;385};386387return mergeConfigs(388fixupFormat(config),389...configs.map((c) => fixupFormat(c)),390);391}392393async function resolveFormats(394file: RenderFile,395target: ExecutionTarget,396engine: ExecutionEngine,397options: RenderOptions,398_notebookContext: NotebookContext,399project: ProjectContext,400enforceProjectFormats: boolean = true,401): Promise<Record<string, { format: Format; active: boolean }>> {402// input level metadata403const inputMetadata = target.metadata;404405// directory level metadata406const directoryMetadata = project?.dir407? await directoryMetadataForInputFile(408project,409dirname(target.input),410)411: {};412413// project level metadata414const projMetadata = project === undefined415? ({} as Metadata)416: await projectMetadataForInputFile(417target.input,418project,419);420// determine formats (treat dir format keys as part of 'input' format keys)421let formats: string[] = [];422const projFormatKeys = formatKeys(projMetadata);423const dirFormatKeys = formatKeys(directoryMetadata);424const inputFormatKeys = ld.uniq(425formatKeys(inputMetadata).concat(dirFormatKeys),426);427const projType = projectType(project?.config?.project?.[kProjectType]);428if (projType.projectFormatsOnly) {429// if the project specifies that only project formats are430// valid then use the project formats431formats = projFormatKeys;432} else if (inputFormatKeys.length > 0) {433// if the input metadata has a format then this is an override434// of the project so use its keys (and ignore the project)435formats = inputFormatKeys;436// otherwise use the project formats437} else {438formats = projFormatKeys;439}440441// If the file itself has specified permissible442// formats, filter the list of formats to only443// include those formats444if (file.formats) {445formats = formats.filter((format) => {446return file.formats?.includes(format);447});448449// Remove any 'to' information that will force the450// rendering to a particular format451options = safeCloneDeep(options);452delete options.flags?.to;453}454455// resolve formats for each type of metadata456const projFormats = await resolveFormatsFromMetadata(457projMetadata,458target.input,459formats,460options.flags,461);462463const directoryFormats = await resolveFormatsFromMetadata(464directoryMetadata,465target.input,466formats,467options.flags,468);469470const inputFormats = await resolveFormatsFromMetadata(471inputMetadata,472target.input,473formats,474options.flags,475);476477const activeKeys = (478formats: Record<string, { format: Format; active: boolean }>,479) => {480return Object.keys(formats).filter((key) => {481return formats[key].active;482});483};484485// A list of all the active format keys486const activeFormatKeys = ld.uniq(487activeKeys(projFormats).concat(activeKeys(directoryFormats)).concat(488activeKeys(inputFormats),489),490);491// A list of all the format keys included492const allFormatKeys = ld.uniq(493Object.keys(projFormats).concat(Object.keys(directoryFormats)).concat(494Object.keys(inputFormats),495),496);497498const mergedFormats: Record<string, Format> = {};499for (const format of allFormatKeys) {500// alias formats501const projFormat = projFormats[format].format;502const directoryFormat = directoryFormats[format].format;503const inputFormat = inputFormats[format].format;504505// combine user formats506const userFormat = mergeFormatMetadata(507projFormat || {},508directoryFormat || {},509inputFormat || {},510);511512// default 'echo' and 'ipynb-shell-interactivity'513// for documents with a server514if (userFormat.metadata[kServer] !== undefined) {515// default echo516if (userFormat.execute[kEcho] === undefined) {517userFormat.execute[kEcho] = false;518}519// default shell interactivity520if (userFormat.execute[kIpynbShellInteractivity] === undefined) {521userFormat.execute[kIpynbShellInteractivity] = "all";522}523}524525// If options request, force echo526if (options.echo) {527userFormat.execute[kEcho] = true;528}529530// If options request, force warning531if (options.warning) {532userFormat.execute[kWarning] = true;533}534535// The format description536const formatDesc = parseFormatString(format);537538// Read any extension metadata and merge it into the539// format metadata540const extensionMetadata = await readExtensionFormat(541target.source,542formatDesc,543options.services.extension,544project,545);546547// do the merge of the writer format into this format548mergedFormats[format] = mergeFormatMetadata(549defaultWriterFormat(formatDesc.formatWithVariants),550extensionMetadata[formatDesc.baseFormat]551? extensionMetadata[formatDesc.baseFormat].format552: {},553userFormat,554);555// Insist that the target format reflect the correct value.556mergedFormats[format].identifier[kTargetFormat] = format;557558//deno-lint-ignore no-explicit-any559mergedFormats[format].mergeAdditionalFormats = (...configs: any[]) => {560return mergeFormatMetadata(561defaultWriterFormat(formatDesc.formatWithVariants),562extensionMetadata[formatDesc.baseFormat]563? extensionMetadata[formatDesc.baseFormat].format564: {},565...configs,566userFormat,567);568};569570// resolve brand in project and forward it to format571const brand = await project.resolveBrand(target.source);572if (brand) {573mergedFormats[format].render.brand = {574light: brand.light,575dark: (brand.enablesDarkMode ||576darkModeDefaultMetadata(mergedFormats[format].metadata) !==577undefined)578? brand.dark579: undefined,580};581}582// apply defaults from brand yaml under the metadata of the current format583const brandFormatDefaults: Metadata =584(brand?.light?.data?.defaults?.quarto as unknown as Record<585string,586Record<string, Metadata>587>)?.format588?.[format as string];589if (brandFormatDefaults) {590mergedFormats[format].metadata = mergeConfigs(591brandFormatDefaults,592mergedFormats[format].metadata,593);594}595596// ensure that we have a valid forma597const formatIsValid = isValidFormat(598formatDesc,599mergedFormats[format].pandoc,600);601if (!formatIsValid) {602throw new Error(`Unknown format ${format}`);603}604}605606// filter on formats supported by this project607if (enforceProjectFormats) {608for (const formatName of Object.keys(mergedFormats)) {609const format: Format = mergedFormats[formatName];610if (projType.isSupportedFormat) {611if (!projType.isSupportedFormat(format)) {612delete mergedFormats[formatName];613warnOnce(614`The ${formatName} format is not supported by ${projType.type} projects`,615);616}617}618}619}620621// apply some others622for (const formatName of Object.keys(mergedFormats)) {623let format = mergedFormats[formatName];624625// run any ipynb-filters to discover generated metadata, then merge it back in626if (hasIpynbFilters(format.execute)) {627// read markdown w/ filter628const markdown = await engine.partitionedMarkdown(target.source, format);629// merge back metadata630if (markdown.yaml) {631const nbFormats = await resolveFormatsFromMetadata(632markdown.yaml,633target.source,634[formatName],635{ ...options.flags, to: undefined },636);637format = mergeConfigs(format, nbFormats[formatName]);638}639}640641// apply engine format filters642if (engine.filterFormat) {643format = engine.filterFormat(644target.source,645options,646format,647);648}649650// Allow the project type to filter the format651if (projType.filterFormat) {652format = projType.filterFormat(target.source, format, project);653}654655mergedFormats[formatName] = format;656}657658const finalFormats: Record<string, { format: Format; active: boolean }> = {};659for (const key of Object.keys(mergedFormats)) {660const active = activeFormatKeys.includes(key);661finalFormats[key] = {662format: mergedFormats[key],663active,664};665}666return finalFormats;667}668669const readExtensionFormat = async (670file: string,671formatDesc: FormatDescriptor,672extensionContext: ExtensionContext,673project?: ProjectContext,674) => {675// Read the format file and populate this676if (formatDesc.extension) {677// Find the yaml file678const extension = await extensionContext.extension(679formatDesc.extension,680file,681project?.config,682project?.dir,683);684685// Read the yaml file and resolve / bucketize686const extensionFormat = extension?.contributes.formats;687if (extensionFormat) {688const fmtTarget = formatDesc.modifiers689? `${formatDesc.baseFormat}${formatDesc.modifiers.join("")}`690: formatDesc.baseFormat;691const extensionMetadata =692(extensionFormat[fmtTarget] || extensionFormat[formatDesc.baseFormat] ||693{}) as Metadata;694extensionMetadata[kExtensionName] = extensionMetadata[kExtensionName] ||695formatDesc.extension;696697const formats = await resolveFormatsFromMetadata(698extensionMetadata,699extension.path,700[formatDesc.baseFormat],701);702703return formats;704} else {705throw new Error(706`No valid format ${formatDesc.baseFormat} is provided by the extension ${formatDesc.extension}`,707);708}709} else {710return {};711}712};713714function hasIpynbFilters(execute: FormatExecute) {715return execute[kIpynbFilters] && execute[kIpynbFilters]?.length;716}717718export async function projectMetadataForInputFile(719input: string,720project: ProjectContext,721): Promise<Metadata> {722if (project.dir && project.config) {723// If there is directory and configuration information724// process paths725return toInputRelativePaths(726projectType(project.config?.project?.[kProjectType]),727project.dir,728dirname(input),729safeCloneDeep(project.config),730) as Metadata;731} else {732// Just return the config or empty metadata733return safeCloneDeep(project.config) || {};734}735}736737738