Path: blob/main/src/command/render/render-contexts.ts
6449 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 {62ExecutionEngineInstance,63ExecutionTarget,64} from "../../execute/types.ts";65import {66deleteProjectMetadata,67directoryMetadataForInputFile,68toInputRelativePaths,69} from "../../project/project-shared.ts";70import {71kProjectLibDir,72kProjectType,73ProjectContext,74} from "../../project/types.ts";75import { warnOnce } from "../../core/log.ts";76import { dirAndStem } from "../../core/path.ts";77import { fileExecutionEngineAndTarget } from "../../execute/engine.ts";78import { removePandocTo } from "./flags.ts";79import { filesDirLibDir } from "./render-paths.ts";80import { isJupyterNotebook } from "../../core/jupyter/jupyter.ts";81import { LanguageCellHandlerOptions } from "../../core/handlers/types.ts";82import { handleLanguageCells } from "../../core/handlers/base.ts";83import {84FormatDescriptor,85isValidFormat,86parseFormatString,87} from "../../core/pandoc/pandoc-formats.ts";88import { ExtensionContext } from "../../extension/types.ts";89import { NotebookContext } from "../../render/notebook/notebook-types.ts";90import { safeCloneDeep } from "../../core/safe-clone-deep.ts";91import { darkModeDefaultMetadata } from "../../format/html/format-html-info.ts";9293export async function resolveFormatsFromMetadata(94metadata: Metadata,95input: string,96formats: string[],97flags?: RenderFlags,98): Promise<Record<string, { format: Format; active: boolean }>> {99const includeDir = dirname(input);100101// Read any included metadata files and merge in and metadata from the command102const frontMatterSchema = await getFrontMatterSchema();103const included = await includedMetadata(104includeDir,105metadata,106frontMatterSchema,107);108const allMetadata = mergeQuartoConfigs(109metadata,110included.metadata,111flags?.metadata || {},112);113114// resolve any language file references115await resolveLanguageMetadata(allMetadata, includeDir);116117// divide allMetadata into format buckets118const baseFormat = metadataAsFormat(allMetadata);119120if (formats === undefined) {121formats = formatKeys(allMetadata);122}123124// provide a default format125if (formats.length === 0) {126formats.push(baseFormat.pandoc.to || baseFormat.pandoc.writer || "html");127}128129// determine render formats130const renderFormats: string[] = [];131if (flags?.to === undefined) {132renderFormats.push(...formats);133} else if (flags?.to === "default") {134renderFormats.push(formats[0]);135} else {136const toFormats = flags.to.split(",").flatMap((to) => {137if (to === "all") {138return formats;139} else {140return [to];141}142});143renderFormats.push(...toFormats);144}145146// get a list of _all_ formats147formats = ld.uniq(formats.concat(renderFormats));148149const resolved: Record<string, { format: Format; active: boolean }> = {};150151formats.forEach((to) => {152// determine the target format153const format = formatFromMetadata(154baseFormat,155to,156flags?.debug,157);158159// merge configs160const config = mergeFormatMetadata(baseFormat, format);161162// apply any metadata filter163const defaultFormat = defaultWriterFormat(to);164const resolveFormat = defaultFormat.resolveFormat;165if (resolveFormat) {166resolveFormat(config);167}168169// apply command line arguments170171// --no-execute-code172if (flags?.execute !== undefined) {173config.execute[kExecuteEnabled] = flags?.execute;174}175176// --cache177if (flags?.executeCache !== undefined) {178config.execute[kCache] = flags?.executeCache;179}180181// --execute-daemon182if (flags?.executeDaemon !== undefined) {183config.execute[kExecuteDaemon] = flags.executeDaemon;184}185186// --execute-daemon-restart187if (flags?.executeDaemonRestart !== undefined) {188config.execute[kExecuteDaemonRestart] = flags.executeDaemonRestart;189}190191// --execute-debug192if (flags?.executeDebug !== undefined) {193config.execute[kExecuteDebug] = flags.executeDebug;194}195196resolved[to] = {197format: config,198active: renderFormats.includes(to),199};200});201202return resolved;203}204205export async function renderContexts(206file: RenderFile,207options: RenderOptions,208forExecute: boolean,209notebookContext: NotebookContext,210project: ProjectContext,211cloneOptions: boolean = true,212enforceProjectFormats: boolean = true,213): Promise<Record<string, RenderContext>> {214if (cloneOptions) {215// clone options (b/c we will modify them)216// we make it optional because some of the callers have217// actually just cloned it themselves and don't need to preserve218// the original219options = safeCloneDeep(options);220}221222const { engine, target } = await fileExecutionEngineAndTarget(223file.path,224options.flags,225project,226);227228// resolve render target229const formats = await resolveFormats(230file,231target,232engine,233options,234notebookContext,235project,236enforceProjectFormats,237);238239// remove --to (it's been resolved into contexts)240options = removePandocTo(options);241242// see if there is a libDir243let libDir = project?.config?.project[kProjectLibDir];244if (project && libDir) {245libDir = relative(dirname(file.path), join(project.dir, libDir));246} else {247libDir = filesDirLibDir(file.path);248}249250// return contexts251const contexts: Record<string, RenderContext> = {};252for (const formatKey of Object.keys(formats)) {253formats[formatKey].format.language = await formatLanguage(254formats[formatKey].format.metadata,255formats[formatKey].format.language,256options.flags,257);258259// set format260const context: RenderContext = {261target,262options,263engine,264format: formats[formatKey].format,265active: formats[formatKey].active,266project,267libDir: libDir!,268};269contexts[formatKey] = context;270271// at this point we have enough to fix up the target and engine272// in case that's needed.273274if (!isJupyterNotebook(context.target.source)) {275// this is not a jupyter notebook input,276// so we can run pre-engine handlers277278const preEngineCellHandlerOptions: LanguageCellHandlerOptions = {279name: "", // this gets filled out by handleLanguageCells later.280temp: options.services.temp,281format: context.format,282markdown: context.target.markdown,283context,284flags: options.flags || {} as RenderFlags,285stage: "pre-engine",286};287288const { markdown, results } = await handleLanguageCells(289preEngineCellHandlerOptions,290);291292context.target.markdown = markdown;293294if (results) {295context.target.preEngineExecuteResults = results;296}297}298299// if this isn't for execute then cleanup context300if (!forExecute && engine.executeTargetSkipped) {301engine.executeTargetSkipped(target, formats[formatKey].format);302}303}304return contexts;305}306307export async function renderFormats(308file: string,309services: RenderServices,310to = "all",311project: ProjectContext,312): Promise<Record<string, Format>> {313const contexts = await renderContexts(314{ path: file },315{ services, flags: { to } },316false,317services.notebook,318project,319);320const formats: Record<string, Format> = {};321Object.keys(contexts).forEach((formatName) => {322// get the format323const context = contexts[formatName];324const format = context.format;325// remove other formats326delete format.metadata.format;327// remove project level metadata328deleteProjectMetadata(format.metadata);329// resolve output-file330if (!format.pandoc[kOutputFile]) {331const [_dir, stem] = dirAndStem(file);332format.pandoc[kOutputFile] = `${stem}.${format.render[kOutputExt]}`;333}334// provide engine335format.execute[kEngine] = context.engine.name;336formats[formatName] = format;337});338return formats;339}340341function mergeQuartoConfigs(342config: Metadata,343...configs: Array<Metadata>344): Metadata {345// copy all configs so we don't mutate them346config = safeCloneDeep(config);347configs = safeCloneDeep(configs);348349// bibliography needs to always be an array so it can be merged350const fixupMergeableScalars = (metadata: Metadata) => {351// see https://github.com/quarto-dev/quarto-cli/pull/12372352// and https://github.com/quarto-dev/quarto-cli/pull/12369353// for more details on why we need this check, as a consequence of an unintuitive354// ordering of YAML validation operations355if (metadata === null) return metadata;356[357kBibliography,358kCss,359kHeaderIncludes,360kIncludeBefore,361kIncludeAfter,362kIncludeInHeader,363kIncludeBeforeBody,364kIncludeAfterBody,365]366.forEach((key) => {367if (typeof (metadata[key]) === "string") {368metadata[key] = [metadata[key]];369}370});371};372373// formats need to always be objects374const fixupFormat = (config: Record<string, unknown>) => {375const format = config[kMetadataFormat];376if (typeof format === "string") {377config.format = { [format]: {} };378} else if (format instanceof Object) {379Object.keys(format).forEach((key) => {380if (typeof (Reflect.get(format, key)) !== "object") {381Reflect.set(format, key, {});382}383fixupMergeableScalars(Reflect.get(format, key) as Metadata);384});385}386fixupMergeableScalars(config);387return config;388};389390return mergeConfigs(391fixupFormat(config),392...configs.map((c) => fixupFormat(c)),393);394}395396async function resolveFormats(397file: RenderFile,398target: ExecutionTarget,399engine: ExecutionEngineInstance,400options: RenderOptions,401_notebookContext: NotebookContext,402project: ProjectContext,403enforceProjectFormats: boolean = true,404): Promise<Record<string, { format: Format; active: boolean }>> {405// input level metadata406const inputMetadata = target.metadata;407408// directory level metadata409const directoryMetadata = project?.dir410? await directoryMetadataForInputFile(411project,412dirname(target.input),413)414: {};415416// project level metadata417const projMetadata = project === undefined418? ({} as Metadata)419: await projectMetadataForInputFile(420target.input,421project,422);423// determine formats (treat dir format keys as part of 'input' format keys)424let formats: string[] = [];425const projFormatKeys = formatKeys(projMetadata);426const dirFormatKeys = formatKeys(directoryMetadata);427const inputFormatKeys = ld.uniq(428formatKeys(inputMetadata).concat(dirFormatKeys),429);430const projType = projectType(project?.config?.project?.[kProjectType]);431if (projType.projectFormatsOnly) {432// if the project specifies that only project formats are433// valid then use the project formats434formats = projFormatKeys;435} else if (inputFormatKeys.length > 0) {436// if the input metadata has a format then this is an override437// of the project so use its keys (and ignore the project)438formats = inputFormatKeys;439// otherwise use the project formats440} else {441formats = projFormatKeys;442}443444// If the file itself has specified permissible445// formats, filter the list of formats to only446// include those formats447if (file.formats) {448formats = formats.filter((format) => {449return file.formats?.includes(format);450});451452// Remove any 'to' information that will force the453// rendering to a particular format454options = safeCloneDeep(options);455delete options.flags?.to;456}457458// resolve formats for each type of metadata459const projFormats = await resolveFormatsFromMetadata(460projMetadata,461target.input,462formats,463options.flags,464);465466const directoryFormats = await resolveFormatsFromMetadata(467directoryMetadata,468target.input,469formats,470options.flags,471);472473const inputFormats = await resolveFormatsFromMetadata(474inputMetadata,475target.input,476formats,477options.flags,478);479480const activeKeys = (481formats: Record<string, { format: Format; active: boolean }>,482) => {483return Object.keys(formats).filter((key) => {484return formats[key].active;485});486};487488// A list of all the active format keys489const activeFormatKeys = ld.uniq(490activeKeys(projFormats).concat(activeKeys(directoryFormats)).concat(491activeKeys(inputFormats),492),493);494// A list of all the format keys included495const allFormatKeys = ld.uniq(496Object.keys(projFormats).concat(Object.keys(directoryFormats)).concat(497Object.keys(inputFormats),498),499);500501const mergedFormats: Record<string, Format> = {};502for (const format of allFormatKeys) {503// alias formats504const projFormat = projFormats[format].format;505const directoryFormat = directoryFormats[format].format;506const inputFormat = inputFormats[format].format;507508// combine user formats509const userFormat = mergeFormatMetadata(510projFormat || {},511directoryFormat || {},512inputFormat || {},513);514515// default 'echo' and 'ipynb-shell-interactivity'516// for documents with a server517if (userFormat.metadata[kServer] !== undefined) {518// default echo519if (userFormat.execute[kEcho] === undefined) {520userFormat.execute[kEcho] = false;521}522// default shell interactivity523if (userFormat.execute[kIpynbShellInteractivity] === undefined) {524userFormat.execute[kIpynbShellInteractivity] = "all";525}526}527528// If options request, force echo529if (options.echo) {530userFormat.execute[kEcho] = true;531}532533// If options request, force warning534if (options.warning) {535userFormat.execute[kWarning] = true;536}537538// The format description539const formatDesc = parseFormatString(format);540541// Read any extension metadata and merge it into the542// format metadata543const extensionMetadata = await readExtensionFormat(544target.source,545formatDesc,546options.services.extension,547project,548);549550// do the merge of the writer format into this format551mergedFormats[format] = mergeFormatMetadata(552defaultWriterFormat(formatDesc.formatWithVariants),553extensionMetadata[formatDesc.baseFormat]554? extensionMetadata[formatDesc.baseFormat].format555: {},556userFormat,557);558// Insist that the target format reflect the correct value.559mergedFormats[format].identifier[kTargetFormat] = format;560561//deno-lint-ignore no-explicit-any562mergedFormats[format].mergeAdditionalFormats = (...configs: any[]) => {563return mergeFormatMetadata(564defaultWriterFormat(formatDesc.formatWithVariants),565extensionMetadata[formatDesc.baseFormat]566? extensionMetadata[formatDesc.baseFormat].format567: {},568...configs,569userFormat,570);571};572573// resolve brand in project and forward it to format574const brand = await project.resolveBrand(target.source);575if (brand) {576mergedFormats[format].render.brand = {577light: brand.light,578dark: (brand.enablesDarkMode ||579darkModeDefaultMetadata(mergedFormats[format].metadata) !==580undefined)581? brand.dark582: undefined,583};584}585// apply defaults from brand yaml under the metadata of the current format586const brandFormatDefaults: Metadata =587(brand?.light?.data?.defaults?.quarto as unknown as Record<588string,589Record<string, Metadata>590>)?.format591?.[format as string];592if (brandFormatDefaults) {593mergedFormats[format].metadata = mergeConfigs(594brandFormatDefaults,595mergedFormats[format].metadata,596);597}598599// ensure that we have a valid forma600const formatIsValid = isValidFormat(601formatDesc,602mergedFormats[format].pandoc,603);604if (!formatIsValid) {605throw new Error(`Unknown format ${format}`);606}607}608609// filter on formats supported by this project610if (enforceProjectFormats) {611for (const formatName of Object.keys(mergedFormats)) {612const format: Format = mergedFormats[formatName];613if (projType.isSupportedFormat) {614if (!projType.isSupportedFormat(format)) {615delete mergedFormats[formatName];616warnOnce(617`The ${formatName} format is not supported by ${projType.type} projects`,618);619}620}621}622}623624// apply some others625for (const formatName of Object.keys(mergedFormats)) {626let format = mergedFormats[formatName];627628// run any ipynb-filters to discover generated metadata, then merge it back in629if (hasIpynbFilters(format.execute)) {630// read markdown w/ filter631const markdown = await engine.partitionedMarkdown(target.source, format);632// merge back metadata633if (markdown.yaml) {634const nbFormats = await resolveFormatsFromMetadata(635markdown.yaml,636target.source,637[formatName],638{ ...options.flags, to: undefined },639);640format = mergeConfigs(format, nbFormats[formatName]);641}642}643644// apply engine format filters645if (engine.filterFormat) {646format = engine.filterFormat(647target.source,648options,649format,650);651}652653// Allow the project type to filter the format654if (projType.filterFormat) {655format = projType.filterFormat(target.source, format, project);656}657658mergedFormats[formatName] = format;659}660661const finalFormats: Record<string, { format: Format; active: boolean }> = {};662for (const key of Object.keys(mergedFormats)) {663const active = activeFormatKeys.includes(key);664finalFormats[key] = {665format: mergedFormats[key],666active,667};668}669return finalFormats;670}671672const readExtensionFormat = async (673file: string,674formatDesc: FormatDescriptor,675extensionContext: ExtensionContext,676project?: ProjectContext,677) => {678// Determine effective extension - use default for certain project/format combinations679let effectiveExtension = formatDesc.extension;680681// For book projects with typst format and no explicit extension,682// use orange-book as the default typst book template683if (684!effectiveExtension &&685formatDesc.baseFormat === "typst" &&686project?.config?.project?.[kProjectType] === "book"687) {688effectiveExtension = "orange-book";689}690691// Read the format file and populate this692if (effectiveExtension) {693// Find the yaml file694const extension = await extensionContext.extension(695effectiveExtension,696file,697project?.config,698project?.dir,699);700701// Read the yaml file and resolve / bucketize702const extensionFormat = extension?.contributes.formats;703if (extensionFormat) {704const fmtTarget = formatDesc.modifiers705? `${formatDesc.baseFormat}${formatDesc.modifiers.join("")}`706: formatDesc.baseFormat;707const extensionMetadata =708(extensionFormat[fmtTarget] || extensionFormat[formatDesc.baseFormat] ||709{}) as Metadata;710extensionMetadata[kExtensionName] = extensionMetadata[kExtensionName] ||711effectiveExtension;712713const formats = await resolveFormatsFromMetadata(714extensionMetadata,715extension.path,716[formatDesc.baseFormat],717);718719return formats;720} else {721throw new Error(722`No valid format ${formatDesc.baseFormat} is provided by the extension ${effectiveExtension}`,723);724}725} else {726return {};727}728};729730function hasIpynbFilters(execute: FormatExecute) {731return execute[kIpynbFilters] && execute[kIpynbFilters]?.length;732}733734export async function projectMetadataForInputFile(735input: string,736project: ProjectContext,737): Promise<Metadata> {738if (project.dir && project.config) {739// If there is directory and configuration information740// process paths741return toInputRelativePaths(742projectType(project.config?.project?.[kProjectType]),743project.dir,744dirname(input),745safeCloneDeep(project.config),746) as Metadata;747} else {748// Just return the config or empty metadata749return safeCloneDeep(project.config) || {};750}751}752753754