Path: blob/main/src/command/render/render-files.ts
6449 views
/*1* render-files.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56// ensures cell handlers are installed7import "../../core/handlers/handlers.ts";89import {10kExecuteEnabled,11kFreeze,12kIncludeInHeader,13kKeepMd,14kLang,15kQuartoRequired,16} from "../../config/constants.ts";17import { isHtmlCompatible } from "../../config/format.ts";18import { mergeConfigs } from "../../core/config.ts";19import { initDayJsPlugins, setDateLocale } from "../../core/date.ts";20import { initDenoDom } from "../../core/deno-dom.ts";21import { HandlerContextResults } from "../../core/handlers/types.ts";22import {23handleLanguageCells,24languages,25resetFigureCounter,26} from "../../core/handlers/base.ts";27import { LanguageCellHandlerOptions } from "../../core/handlers/types.ts";28import { asMappedString, mappedDiff } from "../../core/mapped-text.ts";29import {30validateDocument,31validateDocumentFromSource,32} from "../../core/schema/validate-document.ts";33import { createTempContext, TempContext } from "../../core/temp.ts";34import {35executionEngineKeepMd,36fileExecutionEngineAndTarget,37} from "../../execute/engine.ts";38import { annotateOjsLineNumbers } from "../../execute/ojs/annotate-source.ts";39import { ojsExecuteResult } from "../../execute/ojs/compile.ts";40import {41ExecuteOptions,42ExecuteResult,43MappedExecuteResult,44} from "../../execute/types.ts";45import { kProjectLibDir, ProjectContext } from "../../project/types.ts";46import { outputRecipe } from "./output.ts";4748import { renderPandoc } from "./render.ts";49import { PandocRenderCompletion, RenderServices } from "./types.ts";50import { renderContexts } from "./render-contexts.ts";51import { renderProgress } from "./render-info.ts";52import {53ExecutedFile,54PandocRenderer,55RenderContext,56RenderedFile,57RenderedFormat,58RenderExecuteOptions,59RenderFile,60RenderFilesResult,61RenderFlags,62RenderOptions,63} from "./types.ts";64import { error, info } from "../../deno_ral/log.ts";65import * as ld from "../../core/lodash.ts";66import { basename, dirname, join, relative } from "../../deno_ral/path.ts";67import { Format } from "../../config/types.ts";68import {69figuresDir,70inputFilesDir,71isServerShiny,72isServerShinyKnitr,73} from "../../core/render.ts";74import {75normalizePath,76removeIfEmptyDir,77removeIfExists,78} from "../../core/path.ts";79import { resourcePath } from "../../core/resources.ts";80import { YAMLValidationError } from "../../core/yaml.ts";81import { resolveParams } from "./flags.ts";82import {83copyFromProjectFreezer,84copyToProjectFreezer,85defrostExecuteResult,86freezeExecuteResult,87freezerFigsDir,88freezerFreezeFile,89kProjectFreezeDir,90removeFreezeResults,91} from "./freeze.ts";92import { isJupyterNotebook } from "../../core/jupyter/jupyter.ts";93import { MappedString } from "../../core/lib/text-types.ts";94import {95createNamedLifetime,96Lifetime,97waitUntilNamedLifetime,98} from "../../core/lifetimes.ts";99import { resolveDependencies } from "./pandoc-dependencies-html.ts";100import {101getData as getTimingData,102pop as popTiming,103push as pushTiming,104withTiming,105withTimingAsync,106} from "../../core/timing.ts";107import { satisfies } from "semver/mod.ts";108import { quartoConfig } from "../../core/quarto.ts";109import { ensureNotebookContext } from "../../core/jupyter/jupyter-embed.ts";110import {111projectIsWebsite,112projectOutputDir,113} from "../../project/project-shared.ts";114import { NotebookContext } from "../../render/notebook/notebook-types.ts";115import { setExecuteEnvironment } from "../../execute/environment.ts";116import { safeCloneDeep } from "../../core/safe-clone-deep.ts";117import { warn } from "log";118119export async function renderExecute(120context: RenderContext,121output: string,122options: RenderExecuteOptions,123): Promise<ExecuteResult> {124// are we running a compatible quarto version for this file?125const versionConstraint = context126.format.metadata[kQuartoRequired] as (string | undefined);127if (versionConstraint) {128const ourVersion = quartoConfig.version();129let result: boolean;130try {131result = satisfies(ourVersion, versionConstraint);132} catch (_e) {133throw new Error(134`In file ${context.target.source}:\nVersion constraint is invalid: ${versionConstraint}.`,135);136}137if (!result) {138throw new Error(139`in file ${context.target.source}:\nQuarto version ${ourVersion} does not satisfy version constraint ${versionConstraint}.`,140);141}142}143144// alias options145const { resolveDependencies = true, alwaysExecute = false } = options;146147// alias flags148const flags = context.options.flags || {};149150// compute filesDir151const filesDir = inputFilesDir(context.target.source);152153// compute project relative files dir (if we are in a project)154let projRelativeFilesDir: string | undefined;155if (context.project) {156const inputDir = relative(157context.project.dir,158dirname(context.target.source),159);160projRelativeFilesDir = join(inputDir, filesDir);161}162163// are we eligible to freeze?164const canFreeze = context.engine.canFreeze &&165(context.format.execute[kExecuteEnabled] !== false);166167// use previous frozen results if they are available168if (context.project && !context.project.isSingleFile && !alwaysExecute) {169// check if we are using the freezer170171const thaw = canFreeze &&172(context.format.execute[kFreeze] ||173(context.options.useFreezer ? "auto" : false));174175if (thaw) {176// copy from project freezer177const hidden = context.format.execute[kFreeze] === false;178copyFromProjectFreezer(179context.project,180projRelativeFilesDir!,181hidden,182);183184const thawedResult = defrostExecuteResult(185context.target.source,186output,187context.options.services.temp,188thaw === true,189);190if (thawedResult) {191// copy the site_libs dir from the freezer192const libDir = context.project?.config?.project[kProjectLibDir];193if (libDir) {194copyFromProjectFreezer(context.project, libDir, hidden);195}196197// remove the results dir198removeFreezeResults(join(context.project.dir, projRelativeFilesDir!));199200// notify engine that we skipped execute201if (context.engine.executeTargetSkipped) {202context.engine.executeTargetSkipped(203context.target,204context.format,205);206}207208// return results209return thawedResult;210}211}212}213214// calculate figsDir215const figsDir = join(filesDir, figuresDir(context.format.pandoc.to));216217pushTiming("render-execute");218219const executeOptions: ExecuteOptions = {220target: context.target,221resourceDir: resourcePath(),222tempDir: context.options.services.temp.createDir(),223dependencies: resolveDependencies,224libDir: context.libDir,225format: context.format,226projectDir: context.project?.dir,227cwd: flags.executeDir || dirname(normalizePath(context.target.source)),228params: resolveParams(flags.params, flags.paramsFile),229quiet: flags.quiet,230previewServer: context.options.previewServer,231handledLanguages: languages(),232project: context.project,233};234// execute computations235setExecuteEnvironment(executeOptions);236const executeResult = await context.engine.execute(executeOptions);237popTiming();238239// write the freeze file if we are in a project240if (context.project && !context.project.isSingleFile && canFreeze) {241// write the freezer file242const freezeFile = freezeExecuteResult(243context.target.source,244output,245executeResult,246);247248// always copy to the hidden freezer249copyToProjectFreezer(context.project, projRelativeFilesDir!, true, true);250251// copy to the _freeze dir if explicit _freeze is requested252if (context.format.execute[kFreeze] !== false) {253copyToProjectFreezer(context.project, projRelativeFilesDir!, false, true);254} else {255// otherwise cleanup the _freeze subdir b/c we aren't explicitly freezing anymore256257// figs dir for this target format258const freezeFigsDir = freezerFigsDir(259context.project,260projRelativeFilesDir!,261basename(figsDir),262);263removeIfExists(freezeFigsDir);264265// freezer file266const projRelativeFreezeFile = relative(context.project.dir, freezeFile);267const freezerFile = freezerFreezeFile(268context.project,269projRelativeFreezeFile,270);271removeIfExists(freezerFile);272273// remove empty directories274removeIfEmptyDir(dirname(freezerFile));275removeIfEmptyDir(dirname(freezeFigsDir));276removeIfEmptyDir(join(context.project.dir, kProjectFreezeDir));277}278279// remove the freeze results file (now that it's safely in the freezer)280removeFreezeResults(join(context.project.dir, projRelativeFilesDir!));281}282283// return result284return executeResult;285}286287export async function renderFiles(288files: RenderFile[],289options: RenderOptions,290notebookContext: NotebookContext,291alwaysExecuteFiles: string[] | undefined,292pandocRenderer: PandocRenderer | undefined,293project: ProjectContext,294): Promise<RenderFilesResult> {295// provide default renderer296pandocRenderer = pandocRenderer || defaultPandocRenderer(options, project);297298// create temp context299const tempContext = createTempContext();300301try {302// make a copy of options so we don't mutate caller context303options = ld.cloneDeep(options);304305// see if we should be using file-by-file progress306const progress = options.progress ||307(project && (files.length > 1) && !options.flags?.quiet);308309// quiet pandoc output if we are doing file by file progress310const pandocQuiet = !!progress || !!options.quietPandoc;311312// calculate num width313const numWidth = String(files.length).length;314315for (let i = 0; i < files.length; i++) {316const file = files[i];317318if (progress) {319renderProgress(320`\r[${String(i + 1).padStart(numWidth)}/${files.length}] ${321relative(project!.dir, file.path)322}`,323);324}325326// get contexts327const fileLifetime = await waitUntilNamedLifetime("render-file");328try {329await renderFileInternal(330fileLifetime,331file,332options,333project,334pandocRenderer,335files,336tempContext,337alwaysExecuteFiles,338pandocQuiet,339notebookContext,340);341} finally {342fileLifetime.cleanup();343}344}345346if (progress) {347info("");348}349350return await pandocRenderer.onComplete(false, options.flags?.quiet);351} catch (error) {352if (!(error instanceof Error)) {353warn(`Error encountered when rendering files`);354}355return {356files: (await pandocRenderer.onComplete(true)).files,357error: error instanceof Error358? error359: new Error(error ? String(error) : undefined),360};361} finally {362tempContext.cleanup();363if (Deno.env.get("QUARTO_PROFILER_OUTPUT")) {364Deno.writeTextFileSync(365Deno.env.get("QUARTO_PROFILER_OUTPUT")!,366JSON.stringify(getTimingData()),367);368}369}370}371372export async function renderFile(373file: RenderFile,374options: RenderOptions,375services: RenderServices,376project: ProjectContext,377enforceProjectFormats: boolean = true,378): Promise<RenderFilesResult> {379// provide default renderer380const pandocRenderer = defaultPandocRenderer(options, project);381382try {383// make a copy of options so we don't mutate caller context384options = ld.cloneDeep(options);385386// quiet pandoc output if we are doing file by file progress387const pandocQuiet = !!options.quietPandoc;388389// get contexts390const fileLifetime = createNamedLifetime("render-single-file");391try {392await renderFileInternal(393fileLifetime,394file,395options,396project,397pandocRenderer,398[file],399services.temp,400[],401pandocQuiet,402services.notebook,403enforceProjectFormats,404);405} finally {406fileLifetime.cleanup();407}408return await pandocRenderer.onComplete(false, options.flags?.quiet);409} catch (error) {410if (!(error instanceof Error)) {411warn(`Error encountered when rendering ${file.path}`);412}413return {414files: (await pandocRenderer.onComplete(true)).files,415error: error instanceof Error416? error417: new Error(error ? String(error) : undefined),418};419} finally {420if (Deno.env.get("QUARTO_PROFILER_OUTPUT")) {421Deno.writeTextFileSync(422Deno.env.get("QUARTO_PROFILER_OUTPUT")!,423JSON.stringify(getTimingData()),424);425}426}427}428429async function renderFileInternal(430lifetime: Lifetime,431file: RenderFile,432options: RenderOptions,433project: ProjectContext,434pandocRenderer: PandocRenderer,435files: RenderFile[],436tempContext: TempContext,437alwaysExecuteFiles: string[] | undefined,438pandocQuiet: boolean,439notebookContext: NotebookContext,440enforceProjectFormats: boolean = true,441) {442const outputs: Array<RenderedFormat> = [];443let contexts: Record<string, RenderContext> | undefined;444try {445contexts = await renderContexts(446file,447options,448true,449notebookContext,450project,451false,452enforceProjectFormats,453);454455// Allow renderers to filter the contexts456contexts = pandocRenderer.onFilterContexts(457file.path,458contexts,459files,460options,461);462} catch (e) {463// bad YAML can cause failure before validation. We464// reconstruct the context as best we can and try to validate.465// note that this ignores "validate-yaml: false"466const { engine, target } = await fileExecutionEngineAndTarget(467file.path,468options.flags,469project,470);471const validationResult = await validateDocumentFromSource(472target.markdown,473engine.name,474error,475);476if (validationResult.length) {477throw new RenderInvalidYAMLError();478} else {479// rethrow if no validation error happened.480throw e;481}482}483const mergeHandlerResults = (484results: HandlerContextResults | undefined,485executeResult: MappedExecuteResult,486context: RenderContext,487) => {488if (results === undefined) {489return;490}491if (executeResult.includes) {492executeResult.includes = mergeConfigs(493executeResult.includes,494results.includes,495);496} else {497executeResult.includes = results.includes;498}499const extras = resolveDependencies(500results.extras,501dirname(context.target.source),502context.libDir,503tempContext,504project,505);506if (extras[kIncludeInHeader]) {507// note that we merge engine execute results back into cell handler508// execute results so that jupyter widget dependencies appear at the509// end (so that they don't mess w/ other libs using require/define)510executeResult.includes[kIncludeInHeader] = [511...(extras[kIncludeInHeader] || []),512...(executeResult.includes[kIncludeInHeader] || []),513];514}515executeResult.supporting.push(...results.supporting);516};517518for (const format of Object.keys(contexts)) {519pushTiming("render-context");520const context = safeCloneDeep(contexts[format]); // since we're going to mutate it...521522// disquality some documents from server: shiny523if (isServerShiny(context.format) && context.project) {524const src = relative(context.project?.dir!, context.target.source);525if (projectIsWebsite(context.project)) {526error(527`${src} uses server: shiny so cannot be included in a website project ` +528`(shiny documents require a backend server and so can't be published as static web content).`,529);530throw new Error();531} else if (532(projectOutputDir(context.project) !==533normalizePath(context.project.dir)) &&534isServerShinyKnitr(context.format, context.engine.name)535) {536error(537`${src} is a knitr engine document that uses server: shiny so cannot be included in a project with an output-dir ` +538`(shiny document output must be rendered alongside its source document).`,539);540throw new Error();541}542}543544// get output recipe545const recipe = outputRecipe(context);546outputs.push({547path: recipe.finalOutput || recipe.output,548isTransient: recipe.isOutputTransient,549format: context.format,550});551552if (context.active) {553// Set the date locale for this render554// Used for date formatting555initDayJsPlugins();556const resolveLang = () => {557const lang = context.format.metadata[kLang] ||558options.flags?.pandocMetadata?.[kLang];559if (typeof lang === "string") {560return lang;561} else {562return undefined;563}564};565const dateFormatLang = resolveLang();566if (dateFormatLang) {567await setDateLocale(568dateFormatLang,569);570}571572lifetime.attach({573cleanup() {574resetFigureCounter();575},576});577try {578// one time denoDom init for html compatible formats579if (isHtmlCompatible(context.format)) {580await initDenoDom();581}582583// determine execute options584const executeOptions = mergeConfigs(585{586alwaysExecute: alwaysExecuteFiles?.includes(file.path),587},588pandocRenderer.onBeforeExecute(recipe.format),589);590591const validate = context.format.render?.["validate-yaml"];592if (validate !== false) {593const validationResult = await validateDocument(context);594if (validationResult.length) {595throw new RenderInvalidYAMLError();596}597}598599// FIXME it should be possible to infer this directly now600// based on the information in the mapped strings.601//602// collect line numbers to facilitate runtime error reporting603const { ojsBlockLineNumbers } = annotateOjsLineNumbers(context);604605// execute606const baseExecuteResult = await renderExecute(607context,608recipe.output,609executeOptions,610);611612// recover source map from diff and create a mappedExecuteResult613// for markdown processing pre-pandoc with mapped strings614let mappedMarkdown: MappedString;615616withTiming("diff-execute-result", () => {617if (!isJupyterNotebook(context.target.source)) {618mappedMarkdown = mappedDiff(619context.target.markdown,620baseExecuteResult.markdown,621);622} else {623mappedMarkdown = asMappedString(baseExecuteResult.markdown);624}625});626627const resourceFiles: string[] = [];628if (baseExecuteResult.resourceFiles) {629resourceFiles.push(...baseExecuteResult.resourceFiles);630}631632const languageCellHandlerOptions: LanguageCellHandlerOptions = {633name: "",634temp: tempContext,635format: recipe.format,636markdown: mappedMarkdown!,637context,638flags: options.flags || {} as RenderFlags,639stage: "post-engine",640};641642let unmappedExecuteResult: ExecuteResult;643await withTimingAsync("handle-language-cells", async () => {644// handle language cells645const { markdown, results } = await handleLanguageCells(646languageCellHandlerOptions,647);648const mappedExecuteResult: MappedExecuteResult = {649...baseExecuteResult,650markdown,651};652653mergeHandlerResults(654context.target.preEngineExecuteResults,655mappedExecuteResult,656context,657);658mergeHandlerResults(results, mappedExecuteResult, context);659660// process ojs661const { executeResult, resourceFiles: ojsResourceFiles } =662await ojsExecuteResult(663context,664mappedExecuteResult,665ojsBlockLineNumbers,666);667resourceFiles.push(...ojsResourceFiles);668669// keep md if requested670const keepMd = executionEngineKeepMd(context);671if (keepMd && context.format.execute[kKeepMd]) {672Deno.writeTextFileSync(keepMd, executeResult.markdown.value);673}674675// now get "unmapped" execute result back to send to pandoc676unmappedExecuteResult = {677...executeResult,678markdown: executeResult.markdown.value,679};680});681682// Ensure that we have rendered any notebooks683await ensureNotebookContext(684unmappedExecuteResult!.markdown,685context.options.services,686project,687);688689// callback690pushTiming("render-pandoc");691await pandocRenderer.onRender(format, {692context,693recipe,694executeResult: unmappedExecuteResult!,695resourceFiles,696}, pandocQuiet);697popTiming();698} finally {699popTiming();700}701}702}703await pandocRenderer.onPostProcess(outputs, project);704}705706// default pandoc renderer immediately renders each execute result707function defaultPandocRenderer(708_options: RenderOptions,709_project: ProjectContext,710): PandocRenderer {711const renderCompletions: PandocRenderCompletion[] = [];712const renderedFiles: RenderedFile[] = [];713714return {715onFilterContexts: (716_file: string,717contexts: Record<string, RenderContext>,718_files: RenderFile[],719_options: RenderOptions,720) => {721return contexts;722},723onBeforeExecute: (_format: Format) => ({}),724onRender: async (725_format: string,726executedFile: ExecutedFile,727quiet: boolean,728) => {729renderCompletions.push(await renderPandoc(executedFile, quiet));730},731onPostProcess: async (renderedFormats: RenderedFormat[]) => {732let completion = renderCompletions.pop();733while (completion) {734renderedFiles.push(await completion.complete(renderedFormats));735completion = renderCompletions.pop();736}737renderedFiles.reverse();738},739onComplete: async () => {740return {741files: await Promise.resolve(renderedFiles),742};743},744};745}746class RenderInvalidYAMLError extends YAMLValidationError {747constructor() {748super("Render failed due to invalid YAML.");749}750}751752753