import {
ensureDirSync,
existsSync,
safeMoveSync,
safeRemoveDirSync,
safeRemoveSync,
UnsafeRemovalError,
} from "../../deno_ral/fs.ts";
import { dirname, isAbsolute, join, relative } from "../../deno_ral/path.ts";
import { info, warning } from "../../deno_ral/log.ts";
import * as colors from "fmt/colors";
import { copyMinimal, copyTo } from "../../core/copy.ts";
import * as ld from "../../core/lodash.ts";
import {
kKeepMd,
kKeepTex,
kKeepTyp,
kTargetFormat,
} from "../../config/constants.ts";
import {
kProjectExecuteDir,
kProjectLibDir,
kProjectPostRender,
kProjectPreRender,
kProjectType,
ProjectContext,
} from "../../project/types.ts";
import { kQuartoScratch } from "../../project/project-scratch.ts";
import { projectType } from "../../project/types/project-types.ts";
import { copyResourceFile } from "../../project/project-resources.ts";
import { ensureGitignore } from "../../project/project-gitignore.ts";
import { partitionedMarkdownForInput } from "../../project/project-config.ts";
import { renderFiles } from "./render-files.ts";
import {
RenderedFile,
RenderFile,
RenderOptions,
RenderResult,
} from "./types.ts";
import {
copyToProjectFreezer,
kProjectFreezeDir,
pruneProjectFreezer,
pruneProjectFreezerDir,
} from "./freeze.ts";
import { resourceFilesFromRenderedFile } from "./resources.ts";
import { inputFilesDir } from "../../core/render.ts";
import {
removeIfEmptyDir,
removeIfExists,
safeRemoveIfExists,
} from "../../core/path.ts";
import { handlerForScript } from "../../core/run/run.ts";
import { execProcess } from "../../core/process.ts";
import { parseShellRunCommand } from "../../core/run/shell.ts";
import { clearProjectIndex } from "../../project/project-index.ts";
import {
hasProjectOutputDir,
projectExcludeDirs,
projectFormatOutputDir,
projectOutputDir,
} from "../../project/project-shared.ts";
import { asArray } from "../../core/array.ts";
import { normalizePath } from "../../core/path.ts";
import { isSubdir } from "../../deno_ral/fs.ts";
import { Format } from "../../config/types.ts";
import { fileExecutionEngine } from "../../execute/engine.ts";
import { projectContextForDirectory } from "../../project/project-context.ts";
import { ProjectType } from "../../project/types/types.ts";
const noMutationValidations = (
projType: ProjectType,
projOutputDir: string,
projDir: string,
) => {
return [{
val: projType,
newVal: (context: ProjectContext) => {
return projectType(context.config?.project?.[kProjectType]);
},
msg: "The project type may not be mutated by the pre-render script",
}, {
val: projOutputDir,
newVal: (context: ProjectContext) => {
return projectOutputDir(context);
},
msg: "The project output-dir may not be mutated by the pre-render script",
}, {
val: projDir,
newVal: (context: ProjectContext) => {
return normalizePath(context.dir);
},
msg: "The project dir may not be mutated by the pre-render script",
}];
};
interface ProjectInputs {
projType: ProjectType;
projOutputDir: string;
projDir: string;
context: ProjectContext;
files: string[] | undefined;
options: RenderOptions;
}
interface ProjectRenderConfig {
behavior: {
incremental: boolean;
renderAll: boolean;
};
alwaysExecuteFiles: string[] | undefined;
filesToRender: RenderFile[];
options: RenderOptions;
supplements: {
files: RenderFile[];
onRenderComplete?: (
project: ProjectContext,
files: string[],
incremental: boolean,
) => Promise<void>;
};
}
const computeProjectRenderConfig = async (
inputs: ProjectInputs,
): Promise<ProjectRenderConfig> => {
const incremental = !!inputs.files;
let alwaysExecuteFiles = incremental && !inputs.options.useFreezer
? [...(inputs.files!)]
: undefined;
const normalizeFiles = (targetFiles: string[]) => {
return targetFiles.map((file) => {
const target = isAbsolute(file) ? file : join(Deno.cwd(), file);
if (!existsSync(target)) {
throw new Error("Render target does not exist: " + file);
}
return normalizePath(target);
});
};
if (inputs.files) {
if (alwaysExecuteFiles) {
alwaysExecuteFiles = normalizeFiles(alwaysExecuteFiles);
inputs.files = normalizeFiles(inputs.files);
} else if (inputs.options.useFreezer) {
inputs.files = normalizeFiles(inputs.files);
}
}
if (
inputs.files && alwaysExecuteFiles &&
inputs.projType.incrementalRenderAll &&
await inputs.projType.incrementalRenderAll(
inputs.context,
inputs.options,
inputs.files,
)
) {
inputs.files = inputs.context.files.input;
inputs.options = { ...inputs.options, useFreezer: true };
}
const renderAll = !inputs.files ||
(inputs.files.length === inputs.context.files.input.length);
inputs.files = inputs.files || inputs.context.files.input;
const filesToRender: RenderFile[] = inputs.files.map((file) => {
return { path: file };
});
const projectSupplement = (filesToRender: RenderFile[]) => {
if (inputs.projType.supplementRender && !inputs.options.devServerReload) {
return inputs.projType.supplementRender(
inputs.context,
filesToRender,
incremental,
);
} else {
return { files: [] };
}
};
const supplements = projectSupplement(filesToRender);
filesToRender.push(...supplements.files);
return {
alwaysExecuteFiles,
filesToRender,
options: inputs.options,
supplements,
behavior: {
renderAll,
incremental,
},
};
};
const getProjectRenderScripts = async (
context: ProjectContext,
) => {
const preRenderScripts: string[] = [],
postRenderScripts: string[] = [];
if (context.config?.project?.[kProjectPreRender]) {
preRenderScripts.push(
...asArray(context.config?.project?.[kProjectPreRender]!),
);
}
if (context.config?.project?.[kProjectPostRender]) {
postRenderScripts.push(
...asArray(context.config?.project?.[kProjectPostRender]!),
);
}
return { preRenderScripts, postRenderScripts };
};
export async function renderProject(
context: ProjectContext,
pOptions: RenderOptions,
pFiles?: string[],
): Promise<RenderResult> {
const { preRenderScripts, postRenderScripts } = await getProjectRenderScripts(
context,
);
const projType = projectType(context.config?.project?.[kProjectType]);
const projOutputDir = projectOutputDir(context);
const projDir = normalizePath(context.dir);
let projectRenderConfig = await computeProjectRenderConfig({
context,
projType,
projOutputDir,
projDir,
options: pOptions,
files: pFiles,
});
await ensureGitignore(context.dir);
const progress = !!projectRenderConfig.options.progress ||
(projectRenderConfig.filesToRender.length > 1);
if (
projectRenderConfig.behavior.renderAll && hasProjectOutputDir(context) &&
(projectRenderConfig.options.forceClean ||
(projectRenderConfig.options.flags?.clean == true) &&
(projType.cleanOutputDir === true))
) {
const realProjectDir = normalizePath(context.dir);
if (existsSync(projOutputDir)) {
const realOutputDir = normalizePath(projOutputDir);
if (
(realOutputDir !== realProjectDir) &&
realOutputDir.startsWith(realProjectDir)
) {
removeIfExists(realOutputDir);
}
}
clearProjectIndex(realProjectDir);
}
const prePostEnv = {
"QUARTO_PROJECT_OUTPUT_DIR": projOutputDir,
...(projectRenderConfig.behavior.renderAll
? { QUARTO_PROJECT_RENDER_ALL: "1" }
: {}),
};
if (preRenderScripts.length) {
const filesToRender = projectRenderConfig.filesToRender
.map((fileToRender) => fileToRender.path)
.map((file) => relative(projDir, file));
const env: Record<string, string> = {
...prePostEnv,
};
if (Deno.env.get("QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES")) {
Deno.writeTextFileSync(
Deno.env.get("QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES")!,
filesToRender.join("\n"),
);
} else {
env.QUARTO_PROJECT_INPUT_FILES = filesToRender.join("\n");
}
await runPreRender(
projDir,
preRenderScripts,
progress,
!!projectRenderConfig.options.flags?.quiet,
env,
);
context = await projectContextForDirectory(
context.dir,
context.notebookContext,
projectRenderConfig.options,
);
noMutationValidations(projType, projOutputDir, projDir).some(
(validation) => {
if (!ld.isEqual(validation.newVal(context), validation.val)) {
throw new Error(
`Pre-render script resulted in a project change that is now allowed.\n${validation.msg}`,
);
}
},
);
projectRenderConfig = await computeProjectRenderConfig({
context,
projType,
projOutputDir,
projDir,
options: pOptions,
files: pFiles,
});
}
if (projType.preRender) {
await projType.preRender(context);
}
const executeDir = context.config?.project?.[kProjectExecuteDir];
if (
projectRenderConfig.options.flags?.executeDir === undefined &&
executeDir === "project"
) {
projectRenderConfig.options = {
...projectRenderConfig.options,
flags: {
...projectRenderConfig.options.flags,
executeDir: projDir,
},
};
}
if (
projectRenderConfig.filesToRender.length > 3 &&
projectRenderConfig.options.flags &&
projectRenderConfig.options.flags.executeDaemon === undefined
) {
projectRenderConfig.options.flags.executeDaemon = 0;
}
const projResults: RenderResult = {
context,
baseDir: projDir,
outputDir: relative(projDir, projOutputDir),
files: [],
};
const outputDir = projResults.outputDir;
const outputDirAbsolute = outputDir ? join(projDir, outputDir) : undefined;
if (outputDirAbsolute) {
ensureDirSync(outputDirAbsolute);
}
const libDir = context.config?.project[kProjectLibDir];
const resourcesFrom = async (file: RenderedFile) => {
const partitioned = await partitionedMarkdownForInput(
context,
file.input,
);
const excludeDirs = context ? projectExcludeDirs(context) : [];
const resourceFiles = resourceFilesFromRenderedFile(
projDir,
excludeDirs,
file,
partitioned,
);
return resourceFiles;
};
const fileResults = await renderFiles(
projectRenderConfig.filesToRender,
projectRenderConfig.options,
context.notebookContext,
projectRenderConfig.alwaysExecuteFiles,
projType?.pandocRenderer
? projType.pandocRenderer(projectRenderConfig.options, context)
: undefined,
context,
);
const directoryRelocator = (destinationDir: string) => {
return (dir: string, copy = false) => {
const targetDir = join(destinationDir, dir);
const srcDir = join(projDir, dir);
if (!existsSync(srcDir)) {
return;
}
if (existsSync(targetDir)) {
try {
safeRemoveDirSync(targetDir, context.dir);
} catch (e) {
if (e instanceof UnsafeRemovalError) {
warning(
`Refusing to remove directory ${targetDir} since it is not a subdirectory of the main project directory.`,
);
warning(
`Quarto did not expect the path configuration being used in this project, and strange behavior may result.`,
);
}
}
}
ensureDirSync(dirname(targetDir));
if (copy) {
copyTo(srcDir, targetDir);
} else {
try {
Deno.renameSync(srcDir, targetDir);
} catch (_e) {
copyTo(srcDir, targetDir);
safeRemoveDirSync(srcDir, context.dir);
}
}
};
};
let moveOutputResult: Record<string, unknown> | undefined;
if (outputDirAbsolute) {
let keepLibsDir = false;
interface FileOperation {
key: string;
src: string;
performOperation: () => void;
}
const fileOperations: FileOperation[] = [];
for (let i = 0; i < fileResults.files.length; i++) {
const renderedFile = fileResults.files[i];
const formatOutputDir = projectFormatOutputDir(
renderedFile.format,
context,
projectType(context.config?.project.type),
);
const formatRelocateDir = directoryRelocator(formatOutputDir);
const moveFormatDir = formatRelocateDir;
const copyFormatDir = (dir: string) => formatRelocateDir(dir, true);
if (!renderedFile.isTransient) {
const outputFile = join(formatOutputDir, renderedFile.file);
ensureDirSync(dirname(outputFile));
safeMoveSync(join(projDir, renderedFile.file), outputFile);
}
const keepFiles = !!renderedFile.format.execute[kKeepMd] ||
!!renderedFile.format.render[kKeepTex] ||
!!renderedFile.format.render[kKeepTyp];
keepLibsDir = keepLibsDir || keepFiles;
if (renderedFile.supporting) {
renderedFile.supporting = renderedFile.supporting.filter((file) =>
file !== libDir
);
renderedFile.supporting = renderedFile.supporting.filter((file) => {
return !renderedFile.supporting!.some((dir) =>
file.startsWith(dir) && file !== dir
);
});
if (keepFiles) {
renderedFile.supporting.forEach((file) => {
fileOperations.push({
key: `${file}|copy`,
src: file,
performOperation: () => {
copyFormatDir(file);
},
});
});
} else {
renderedFile.supporting.forEach((file) => {
fileOperations.push({
key: `${file}|move`,
src: file,
performOperation: () => {
moveFormatDir(file);
removeIfEmptyDir(dirname(file));
},
});
});
}
}
if (!keepFiles) {
const filesDir = join(
projDir,
dirname(renderedFile.file),
inputFilesDir(renderedFile.file),
);
removeIfEmptyDir(filesDir);
}
projResults.files.push({
isTransient: renderedFile.isTransient,
input: renderedFile.input,
markdown: renderedFile.markdown,
format: renderedFile.format,
file: renderedFile.file,
supporting: renderedFile.supporting,
resourceFiles: await resourcesFrom(renderedFile),
});
}
const uniqOps = ld.uniqBy(fileOperations, (op: FileOperation) => {
return op.key;
});
const sortedOperations = uniqOps.sort((a, b) => {
if (a.src === b.src) {
return 0;
}
if (isSubdir(a.src, b.src)) {
return -1;
}
return a.src.localeCompare(b.src);
});
if (projType.beforeMoveOutput) {
moveOutputResult = await projType.beforeMoveOutput(
context,
projResults.files,
);
}
sortedOperations.forEach((op) => {
op.performOperation();
});
if (libDir) {
const libDirFull = join(context.dir, libDir);
if (existsSync(libDirFull)) {
const libsIncremental = !!(projectRenderConfig.behavior.incremental ||
projectRenderConfig.options.useFreezer);
const formatLibDirs = projType.formatLibDirs
? projType.formatLibDirs()
: [];
const freezeLibDir = (hidden: boolean) => {
copyToProjectFreezer(context, libDir, hidden, false);
pruneProjectFreezerDir(context, libDir, formatLibDirs, hidden);
pruneProjectFreezer(context, hidden);
};
freezeLibDir(true);
if (existsSync(join(context.dir, kProjectFreezeDir))) {
freezeLibDir(false);
}
if (libsIncremental) {
for (const lib of Deno.readDirSync(libDirFull)) {
if (lib.isDirectory) {
const copyDir = join(libDir, lib.name);
const srcDir = join(projDir, copyDir);
const targetDir = join(outputDirAbsolute, copyDir);
copyMinimal(srcDir, targetDir);
if (!keepLibsDir) {
safeRemoveIfExists(srcDir);
}
}
}
if (!keepLibsDir) {
safeRemoveIfExists(libDirFull);
}
} else {
const relocateDir = directoryRelocator(outputDirAbsolute);
if (keepLibsDir) {
relocateDir(libDir, true);
} else {
relocateDir(libDir);
}
}
}
}
const outputFiles = projResults.files.map((result) =>
join(projDir, result.file)
);
projResults.files.forEach((file) => {
file.resourceFiles = file.resourceFiles.filter((resource) =>
!outputFiles.includes(resource)
);
});
const resourceFilesToCopy: Record<string, Set<string>> = {};
const projectFormats: Record<string, Format> = {};
projResults.files.forEach((file) => {
if (
file.format.identifier[kTargetFormat] &&
projectFormats[file.format.identifier[kTargetFormat]] === undefined
) {
projectFormats[file.format.identifier[kTargetFormat]] = file.format;
}
});
const isSelfContainedOutput = (format: Format) => {
return projType.selfContainedOutput &&
projType.selfContainedOutput(format);
};
Object.values(projectFormats).forEach((format) => {
if (isSelfContainedOutput(format)) {
return;
}
const formatOutputDir = projectFormatOutputDir(
format,
context,
projType,
);
context.files.resources?.forEach((resource) => {
resourceFilesToCopy[resource] = resourceFilesToCopy[resource] ||
new Set();
const relativePath = relative(context.dir, resource);
resourceFilesToCopy[resource].add(
join(formatOutputDir, relativePath),
);
});
});
projResults.files.forEach((file) => {
if (isSelfContainedOutput(file.format)) {
return;
}
const formatOutputDir = projectFormatOutputDir(
file.format,
context,
projType,
);
file.resourceFiles.forEach((file) => {
resourceFilesToCopy[file] = resourceFilesToCopy[file] || new Set();
const relativePath = relative(projDir, file);
resourceFilesToCopy[file].add(join(formatOutputDir, relativePath));
});
});
Object.keys(resourceFilesToCopy).forEach((srcPath) => {
const destinationFiles = resourceFilesToCopy[srcPath];
destinationFiles.forEach((destPath: string) => {
if (existsSync(srcPath)) {
if (Deno.statSync(srcPath).isFile) {
copyResourceFile(context.dir, srcPath, destPath);
}
} else if (!existsSync(destPath)) {
warning(`File '${srcPath}' was not found.`);
}
});
});
} else {
for (const result of fileResults.files) {
const resourceFiles = await resourcesFrom(result);
projResults.files.push({
input: result.input,
markdown: result.markdown,
format: result.format,
file: result.file,
supporting: result.supporting,
resourceFiles,
});
}
}
projResults.error = fileResults.error;
if (!projResults.error) {
for (const file of projResults.files) {
const path = join(context.dir, file.input);
const engine = await fileExecutionEngine(
path,
projectRenderConfig.options.flags,
context,
);
if (engine?.postRender) {
await engine.postRender(file, projResults.context);
}
}
const outputFiles = projResults.files
.filter((x) => !x.isTransient)
.map((result) => {
const outputDir = projectFormatOutputDir(
result.format,
context,
projType,
);
const file = outputDir
? join(outputDir, result.file)
: join(projDir, result.file);
return {
file,
input: join(projDir, result.input),
format: result.format,
resources: result.resourceFiles,
supporting: result.supporting,
};
});
if (projType.postRender) {
await projType.postRender(
context,
projectRenderConfig.behavior.incremental,
outputFiles,
moveOutputResult,
);
}
if (postRenderScripts.length) {
const env: Record<string, string> = {
...prePostEnv,
};
if (Deno.env.get("QUARTO_USE_FILE_FOR_PROJECT_OUTPUT_FILES")) {
Deno.writeTextFileSync(
Deno.env.get("QUARTO_USE_FILE_FOR_PROJECT_OUTPUT_FILES")!,
outputFiles.map((outputFile) => relative(projDir, outputFile.file))
.join("\n"),
);
} else {
env.QUARTO_PROJECT_OUTPUT_FILES = outputFiles
.map((outputFile) => relative(projDir, outputFile.file))
.join("\n");
}
await runPostRender(
projDir,
postRenderScripts,
progress,
!!projectRenderConfig.options.flags?.quiet,
env,
);
}
}
const supplements = projectRenderConfig.supplements;
projResults.files.forEach((file) => {
if (
supplements.files.find((supFile) => {
return supFile.path === join(projDir, file.input);
})
) {
file.supplemental = true;
}
});
const nonSupplementalFiles = projResults.files.filter((file) =>
!file.supplemental
).map((file) => file.file);
if (supplements.onRenderComplete) {
await supplements.onRenderComplete(
context,
nonSupplementalFiles,
projectRenderConfig.behavior.incremental,
);
}
if (projectRenderConfig.options.forceClean) {
const scratchDir = join(projDir, kQuartoScratch);
if (existsSync(scratchDir)) {
safeRemoveSync(scratchDir, { recursive: true });
}
}
return projResults;
}
async function runPreRender(
projDir: string,
preRender: string[],
progress: boolean,
quiet: boolean,
env?: { [key: string]: string },
) {
await runScripts(projDir, preRender, progress, quiet, env);
}
async function runPostRender(
projDir: string,
postRender: string[],
progress: boolean,
quiet: boolean,
env?: { [key: string]: string },
) {
await runScripts(projDir, postRender, progress, quiet, env);
}
async function runScripts(
projDir: string,
scripts: string[],
progress: boolean,
quiet: boolean,
env?: { [key: string]: string },
) {
for (let i = 0; i < scripts.length; i++) {
const args = parseShellRunCommand(scripts[i]);
const script = args[0];
if (progress && !quiet) {
info(colors.bold(colors.blue(`${script}`)));
}
const handler = handlerForScript(script);
if (handler) {
if (env) {
env = {
...env,
};
} else {
env = {};
}
if (!env) throw new Error("should never get here");
const input = Deno.env.get("QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES");
const output = Deno.env.get("QUARTO_USE_FILE_FOR_PROJECT_OUTPUT_FILES");
if (input) {
env["QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES"] = input;
}
if (output) {
env["QUARTO_USE_FILE_FOR_PROJECT_OUTPUT_FILES"] = output;
}
const result = await handler.run(script, args.splice(1), undefined, {
cwd: projDir,
stdout: quiet ? "piped" : "inherit",
env,
});
if (!result.success) {
throw new Error();
}
} else {
const result = await execProcess({
cmd: args[0],
args: args.slice(1),
cwd: projDir,
stdout: quiet ? "piped" : "inherit",
env,
});
if (!result.success) {
throw new Error();
}
}
}
if (scripts.length > 0) {
info("");
}
}