import {
dirname,
globToRegExp,
isAbsolute,
join,
relative,
resolve,
SEP,
} from "../deno_ral/path.ts";
import { existsSync, walk, walkSync } from "../deno_ral/fs.ts";
import * as ld from "../core/lodash.ts";
import { ProjectType } from "./types/types.ts";
import { Format, Metadata, PandocFlags } from "../config/types.ts";
import {
kProjectLibDir,
kProjectOutputDir,
kProjectPostRender,
kProjectPreRender,
kProjectRender,
kProjectType,
ProjectConfig,
ProjectContext,
} from "./types.ts";
import { isYamlPath, readYaml } from "../core/yaml.ts";
import { mergeConfigs } from "../core/config.ts";
import {
ensureTrailingSlash,
kSkipHidden,
normalizePath,
pathWithForwardSlashes,
safeExistsSync,
} from "../core/path.ts";
import { includedMetadata, mergeProjectMetadata } from "../config/metadata.ts";
import {
kHtmlMathMethod,
kLanguageDefaults,
kMetadataFile,
kMetadataFiles,
kMetadataFormat,
kQuartoVarsKey,
} from "../config/constants.ts";
import { projectType, projectTypes } from "./types/project-types.ts";
import { resolvePathGlobs } from "../core/path.ts";
import {
readLanguageTranslations,
resolveLanguageMetadata,
} from "../core/language.ts";
import {
engineIgnoreDirs,
executionEngineIntermediateFiles,
fileExecutionEngine,
fileExecutionEngineAndTarget,
projectIgnoreGlobs,
resolveEngines,
} from "../execute/engine.ts";
import { ExecutionEngineInstance, kMarkdownEngine } from "../execute/types.ts";
import { projectResourceFiles } from "./project-resources.ts";
import {
cleanupFileInformationCache,
FileInformationCacheMap,
ignoreFieldsForProjectType,
normalizeFormatYaml,
projectConfigFile,
projectFileMetadata,
projectResolveBrand,
projectResolveFullMarkdownForFile,
projectVarsFile,
} from "./project-shared.ts";
import { RenderOptions, RenderServices } from "../command/render/types.ts";
import { kWebsite } from "./types/website/website-constants.ts";
import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts";
import { getProjectConfigSchema } from "../core/lib/yaml-schema/project-config.ts";
import { kDefaultProjectFileContents } from "./types/project-default.ts";
import {
createExtensionContext,
filterExtensions,
} from "../extension/extension.ts";
import { initializeProfileConfig } from "./project-profile.ts";
import { dotenvSetVariables } from "../quarto-core/dotenv.ts";
import { ConcreteSchema } from "../core/lib/yaml-schema/types.ts";
import { ExtensionContext } from "../extension/types.ts";
import { asArray } from "../core/array.ts";
import { renderFormats } from "../command/render/render-contexts.ts";
import { computeProjectEnvironment } from "./project-environment.ts";
import { ProjectEnvironment } from "./project-environment-types.ts";
import { NotebookContext } from "../render/notebook/notebook-types.ts";
import { MappedString } from "../core/mapped-text.ts";
import { makeTimedFunctionAsync } from "../core/performance/function-times.ts";
import { createProjectCache } from "../core/cache/cache.ts";
import { createTempContext } from "../core/temp.ts";
import { onCleanup } from "../core/cleanup.ts";
import { Zod } from "../resources/types/zod/schema-types.ts";
import { ExternalEngine } from "../resources/types/schema-types.ts";
export const mergeExtensionMetadata = async (
context: ProjectContext,
pOptions: RenderOptions,
) => {
if (context.config) {
const extensions = await pOptions.services.extension.extensions(
undefined,
context.config,
context.dir,
{ builtIn: false },
);
const projectMetadata = extensions.filter((extension) =>
extension.contributes.metadata?.project
).map((extension) => {
return Zod.ProjectConfig.parse(extension.contributes.metadata!.project);
});
context.config.project = mergeProjectMetadata(
context.config.project,
...projectMetadata,
);
}
};
export async function projectContext(
path: string,
notebookContext: NotebookContext,
renderOptions?: RenderOptions,
force = false,
): Promise<ProjectContext | undefined> {
const flags = renderOptions?.flags;
let dir = normalizePath(
Deno.statSync(path).isDirectory ? path : dirname(path),
);
const originalDir = dir;
const extensionContext = renderOptions?.services.extension ||
createExtensionContext();
const configSchema = await getProjectConfigSchema();
const configResolvers = [
quartoYamlProjectConfigResolver(configSchema),
await projectExtensionsConfigResolver(extensionContext, dir),
];
let cachedEnv: ProjectEnvironment | undefined = undefined;
const environment = async (
project: ProjectContext,
) => {
if (cachedEnv) {
return Promise.resolve(cachedEnv);
} else {
cachedEnv = await computeProjectEnvironment(notebookContext, project);
return cachedEnv;
}
};
const returnResult = async (
context: ProjectContext,
) => {
if (renderOptions) {
await mergeExtensionMetadata(context, renderOptions);
}
onCleanup(context.cleanup);
return context;
};
while (true) {
const resolver = configResolvers[0];
const resolved = await resolver(dir);
if (resolved) {
let projectConfig = resolved.config;
const configFiles = resolved.files;
projectConfig = migrateProjectConfig(projectConfig);
const projType = projectConfig.project[kProjectType];
if (projType && !(projectTypes().includes(projType))) {
projectConfig = await resolveProjectExtension(
extensionContext,
projType,
projectConfig,
dir,
);
const configSchema = await getProjectConfigSchema();
const includedMeta = await includedMetadata(
dir,
projectConfig,
configSchema,
);
const metadata = includedMeta.metadata;
projectConfig = mergeProjectMetadata(projectConfig, metadata);
}
if (extensionContext) {
projectConfig = await resolveEngineExtensions(
extensionContext,
projectConfig,
dir,
);
}
const result = await initializeProfileConfig(
dir,
projectConfig,
configSchema,
);
projectConfig = result.config;
configFiles.push(...result.files);
const dotenvFiles = await dotenvSetVariables(dir);
configFiles.push(...dotenvFiles);
const varsFile = projectVarsFile(dir);
if (varsFile) {
configFiles.push(varsFile);
const vars = readYaml(varsFile) as Metadata;
projectConfig[kQuartoVarsKey] = mergeConfigs(
projectConfig[kQuartoVarsKey] || {},
vars,
);
}
const translationFiles = await resolveLanguageTranslations(
projectConfig,
dir,
);
configFiles.push(...translationFiles);
if (flags?.to && flags?.to !== "all" && flags?.to !== "default") {
const projectFormats = normalizeFormatYaml(
projectConfig[kMetadataFormat],
);
const toFormat = projectFormats[flags?.to] || {};
delete projectFormats[flags?.to];
const formats = {
[flags?.to]: toFormat,
};
Object.keys(projectFormats).forEach((format) => {
formats[format] = projectFormats[format] as Record<never, never>;
});
projectConfig[kMetadataFormat] = formats;
}
if (projectConfig?.project) {
if (flags?.outputDir) {
projectConfig.project[kProjectOutputDir] = flags.outputDir;
}
if (typeof (projectConfig.project[kProjectPreRender]) === "string") {
projectConfig.project[kProjectPreRender] = [
projectConfig.project[kProjectPreRender] as unknown as string,
];
}
if (typeof (projectConfig.project[kProjectPostRender]) === "string") {
projectConfig.project[kProjectPostRender] = [
projectConfig.project[kProjectPostRender] as unknown as string,
];
}
const type = projectType(projectConfig.project?.[kProjectType]);
if (
projectConfig.project[kProjectLibDir] === undefined && type.libDir
) {
projectConfig.project[kProjectLibDir] = type.libDir;
}
if (!projectConfig.project[kProjectOutputDir] && type.outputDir) {
projectConfig.project[kProjectOutputDir] = type.outputDir;
}
const outputDir = projectConfig.project[kProjectOutputDir];
if (outputDir) {
const resolvedOutputDir = resolve(dir, outputDir);
const resolvedDir = resolve(dir);
if (resolvedOutputDir === resolvedDir) {
delete projectConfig.project[kProjectOutputDir];
}
}
const projOutputDir = projectConfig.project[kProjectOutputDir];
if (projOutputDir && isAbsolute(projOutputDir)) {
projectConfig.project[kProjectOutputDir] = relative(
dir,
projOutputDir,
);
}
const temp = createTempContext({
dir: join(dir, ".quarto"),
prefix: "quarto-session-temp",
});
const fileInformationCache = new FileInformationCacheMap();
const result: ProjectContext = {
clone: () => result,
resolveBrand: async (fileName?: string) =>
projectResolveBrand(result, fileName),
resolveFullMarkdownForFile: (
engine: ExecutionEngineInstance | undefined,
file: string,
markdown?: MappedString,
force?: boolean,
) => {
return projectResolveFullMarkdownForFile(
result,
engine,
file,
markdown,
force,
);
},
dir,
engines: [],
fileInformationCache,
files: {
input: [],
},
config: projectConfig,
renderFormats,
environment: () => environment(result),
notebookContext,
fileExecutionEngineAndTarget: (
file: string,
) => {
return fileExecutionEngineAndTarget(
file,
flags,
result,
);
},
fileMetadata: async (file: string, force?: boolean) => {
return projectFileMetadata(result, file, force);
},
isSingleFile: false,
previewServer: renderOptions?.previewServer,
diskCache: await createProjectCache(join(dir, ".quarto")),
temp,
cleanup: () => {
cleanupFileInformationCache(result);
result.diskCache.close();
temp.cleanup();
},
};
if (type.config) {
result.config = await type.config(
result,
projectConfig,
flags,
);
}
const { files, engines } = await projectInputFiles(
result,
projectConfig,
);
const fullPath = normalizePath(path);
if (Deno.statSync(fullPath).isFile && !files.includes(fullPath)) {
return undefined;
}
if (type.formatExtras) {
result.formatExtras = async (
source: string,
flags: PandocFlags,
format: Format,
services: RenderServices,
) => type.formatExtras!(result, source, flags, format, services);
}
result.engines = engines;
result.files = {
input: files,
resources: projectResourceFiles(dir, projectConfig),
config: configFiles,
configResources: projectConfigResources(dir, projectConfig, type),
};
return await returnResult(result);
} else {
const temp = createTempContext({
dir: join(dir, ".quarto"),
prefix: "quarto-session-temp",
});
const fileInformationCache = new FileInformationCacheMap();
const result: ProjectContext = {
clone: () => result,
resolveBrand: async (fileName?: string) =>
projectResolveBrand(result, fileName),
resolveFullMarkdownForFile: (
engine: ExecutionEngineInstance | undefined,
file: string,
markdown?: MappedString,
force?: boolean,
) => {
return projectResolveFullMarkdownForFile(
result,
engine,
file,
markdown,
force,
);
},
dir,
config: projectConfig,
engines: [],
fileInformationCache,
files: {
input: [],
},
renderFormats,
environment: () => environment(result),
fileExecutionEngineAndTarget: (
file: string,
) => {
return fileExecutionEngineAndTarget(
file,
flags,
result,
);
},
fileMetadata: async (file: string, force?: boolean) => {
return projectFileMetadata(result, file, force);
},
notebookContext,
isSingleFile: false,
previewServer: renderOptions?.previewServer,
diskCache: await createProjectCache(join(dir, ".quarto")),
temp,
cleanup: () => {
cleanupFileInformationCache(result);
result.diskCache.close();
temp.cleanup();
},
};
const { files, engines } = await projectInputFiles(
result,
projectConfig,
);
result.engines = engines;
result.files = {
input: files,
resources: projectResourceFiles(dir, projectConfig),
config: configFiles,
configResources: projectConfigResources(dir, projectConfig),
};
return await returnResult(result);
}
} else {
const nextDir = dirname(dir);
if (nextDir === dir) {
if (configResolvers.length > 1) {
dir = originalDir;
configResolvers.shift();
} else if (force) {
const temp = createTempContext({
dir: join(originalDir, ".quarto"),
prefix: "quarto-session-temp",
});
const fileInformationCache = new FileInformationCacheMap();
const context: ProjectContext = {
clone: () => context,
resolveBrand: async (fileName?: string) =>
projectResolveBrand(context, fileName),
resolveFullMarkdownForFile: (
engine: ExecutionEngineInstance | undefined,
file: string,
markdown?: MappedString,
force?: boolean,
) => {
return projectResolveFullMarkdownForFile(
context,
engine,
file,
markdown,
force,
);
},
dir: originalDir,
engines: [],
config: {
project: {
[kProjectOutputDir]: flags?.outputDir,
},
},
fileInformationCache,
files: {
input: [],
},
renderFormats,
environment: () => environment(context),
notebookContext,
fileExecutionEngineAndTarget: (
file: string,
) => {
return fileExecutionEngineAndTarget(
file,
flags,
context,
);
},
fileMetadata: async (file: string, force?: boolean) => {
return projectFileMetadata(context, file, force);
},
isSingleFile: false,
previewServer: renderOptions?.previewServer,
diskCache: await createProjectCache(join(temp.baseDir, ".quarto")),
temp,
cleanup: () => {
cleanupFileInformationCache(context);
context.diskCache.close();
temp.cleanup();
},
};
if (Deno.statSync(path).isDirectory) {
const { files, engines } = await projectInputFiles(context);
context.engines = engines;
context.files.input = files;
} else {
const input = normalizePath(path);
const engine = await fileExecutionEngine(input, undefined, context);
context.engines = [engine?.name ?? kMarkdownEngine];
context.files.input = [input];
}
return await returnResult(context);
} else {
return undefined;
}
} else {
dir = nextDir;
}
}
}
}
type ResolvedProjectConfig = {
config: ProjectConfig;
files: string[];
};
function quartoYamlProjectConfigResolver(
configSchema: ConcreteSchema,
) {
return async (dir: string): Promise<ResolvedProjectConfig | undefined> => {
let configFile: string | undefined = undefined;
try {
configFile = projectConfigFile(dir);
} catch (e) {
if (e instanceof Deno.errors.PermissionDenied) {
} else {
throw e;
}
}
if (configFile) {
const files = [configFile];
const errMsg = `Project ${configFile} validation failed.`;
let config = (await readAndValidateYamlFromFile(
configFile,
configSchema,
errMsg,
kDefaultProjectFileContents,
)) as ProjectConfig;
config.project = config.project || {};
const includedMeta = await includedMetadata(
dir,
config,
configSchema,
);
const metadata = includedMeta.metadata;
files.push(...includedMeta.files);
config = mergeProjectMetadata(config, metadata);
delete config[kMetadataFile];
delete config[kMetadataFiles];
return { config, files };
} else {
return undefined;
}
};
}
type ProjectTypeDetector = {
type: string;
detect: string[][];
};
async function projectExtensionsConfigResolver(
context: ExtensionContext,
dir: string,
) {
const projectTypeDetectors: ProjectTypeDetector[] =
(await context.extensions(dir)).reduce(
(projectTypeDetectors, extension) => {
if (extension.contributes.project) {
const project = extension.contributes.project as
| ProjectConfig
| undefined;
if (project?.project?.detect) {
const detect = asArray<string[]>(project?.project.detect);
projectTypeDetectors.push({
type: extension.id.name,
detect,
});
}
}
return projectTypeDetectors;
},
[] as ProjectTypeDetector[],
);
return (dir: string): Promise<ResolvedProjectConfig | undefined> => {
for (const detector of projectTypeDetectors) {
if (
detector.detect.some((files) =>
files.every((file) => safeExistsSync(join(dir, file)))
)
) {
return Promise.resolve({
config: {
project: {
type: detector.type,
},
},
files: [],
});
}
}
return Promise.resolve(undefined);
};
}
async function resolveProjectExtension(
context: ExtensionContext,
projectType: string,
projectConfig: ProjectConfig,
dir: string,
) {
const extensions = await context.find(
projectType,
dir,
"project",
projectConfig,
dir,
);
const filtered = filterExtensions(extensions, projectType, "project");
if (filtered.length > 0) {
const extension = filtered[0];
const projectExt = extension.contributes.project;
if (projectExt) {
const projectExtConfig = ld.cloneDeep(projectExt) as ProjectConfig;
delete projectExtConfig.project.detect;
if (projectConfig.project.render) {
delete projectExtConfig.project.render;
}
const extProjType = () => {
const projectMeta = projectExt.project;
if (projectMeta && typeof projectMeta === "object") {
const extType = (projectMeta as Record<string, unknown>).type;
if (typeof extType === "string") {
return extType;
} else {
return "default";
}
} else {
return "default";
}
};
projectConfig.project[kProjectType] = extProjType();
projectConfig = mergeProjectMetadata(
projectExtConfig,
projectConfig,
);
}
}
return projectConfig;
}
export async function resolveEngineExtensions(
context: ExtensionContext,
projectConfig: ProjectConfig,
dir: string,
) {
if (projectConfig.engines) {
projectConfig.engines =
(projectConfig.engines as (string | ExternalEngine)[]).map(
(engine) => {
if (
typeof engine === "object" && engine.path &&
!isAbsolute(engine.path)
) {
return {
...engine,
path: join(dir, engine.path),
};
}
return engine;
},
);
}
const extensions = await context.extensions(
undefined,
projectConfig,
dir,
);
const engineExtensions = extensions.filter((extension) =>
extension.contributes.engines !== undefined &&
extension.contributes.engines.length > 0
);
if (engineExtensions.length > 0) {
if (!projectConfig.engines) {
projectConfig.engines = [];
}
const existingEngines = projectConfig
.engines as (string | ExternalEngine)[];
const extensionEngines = engineExtensions
.map((extension) => extension.contributes.engines)
.flat();
projectConfig.engines = [...existingEngines, ...extensionEngines];
}
return projectConfig;
}
function migrateProjectConfig(projectConfig: ProjectConfig) {
const kSite = "site";
if (
projectConfig.project[kProjectType] !== kSite &&
projectConfig[kSite] === undefined
) {
return projectConfig;
}
projectConfig = ld.cloneDeep(projectConfig);
if (projectConfig.project[kProjectType] === kSite) {
projectConfig.project[kProjectType] = kWebsite;
}
if (projectConfig[kSite]) {
projectConfig[kWebsite] = ld.cloneDeep(projectConfig[kSite]);
delete projectConfig[kSite];
}
return projectConfig;
}
async function resolveLanguageTranslations(
projectConfig: ProjectConfig,
dir: string,
) {
const files: string[] = [];
files.push(...(await resolveLanguageMetadata(projectConfig, dir)));
const translations = await readLanguageTranslations(
join(dir, "_language.yml"),
);
projectConfig[kLanguageDefaults] = mergeConfigs(
translations.language,
projectConfig[kLanguageDefaults],
);
files.push(...translations.files);
return files;
}
export function projectContextForDirectory(
path: string,
notebookContext: NotebookContext,
renderOptions?: RenderOptions,
): Promise<ProjectContext> {
return projectContext(path, notebookContext, renderOptions, true) as Promise<
ProjectContext
>;
}
export function projectYamlFiles(dir: string): string[] {
const files: string[] = [];
const projIgnoreGlobs = projectHiddenIgnoreGlob(dir);
for (
const walk of walkSync(dir, {
includeDirs: true,
followSymlinks: false,
skip: [kSkipHidden].concat(
projIgnoreGlobs.map((ignore) => globToRegExp(join(dir, ignore) + SEP)),
),
})
) {
if (walk.isFile && isYamlPath(walk.path)) {
files.push(walk.path);
}
}
return files;
}
function projectHiddenIgnoreGlob(dir: string) {
return projectIgnoreGlobs(dir)
.concat(["**/_*", "**/_*/**"])
.concat(["**/.*", "**/.*/**"])
.concat(["**/README.?([Rrq])md"])
.concat(["**/CLAUDE.md"])
.concat(["**/AGENTS.md"]);
}
export const projectInputFiles = makeTimedFunctionAsync(
"projectInputFiles",
projectInputFilesInternal,
);
async function projectInputFilesInternal(
project: ProjectContext,
metadata?: ProjectConfig,
): Promise<{ files: string[]; engines: string[] }> {
await resolveEngines(project);
const { dir } = project;
const outputDir = metadata?.project[kProjectOutputDir];
const projIgnoreGlobs = projectHiddenIgnoreGlob(dir);
const projectIgnores = projIgnoreGlobs.map((glob) =>
globToRegExp(glob, { extended: true, globstar: true })
);
type FileInclusion = {
file: string;
engineName: string;
engineIntermediates: string[];
};
const addFile = async (file: string): Promise<FileInclusion[]> => {
if (
outputDir &&
ensureTrailingSlash(dirname(file)).startsWith(
ensureTrailingSlash(join(dir, outputDir)),
) &&
ensureTrailingSlash(join(dir, outputDir)).startsWith(
ensureTrailingSlash(dir),
)
) {
return [];
}
const engine = await fileExecutionEngine(file, undefined, project);
if (!engine) {
return [];
}
const engineIntermediates = executionEngineIntermediateFiles(
engine,
file,
);
return [{
file,
engineName: engine.name,
engineIntermediates: engineIntermediates,
}];
};
const addDir = async (dir: string): Promise<FileInclusion[]> => {
const promises: Promise<FileInclusion[]>[] = [];
for await (
const walkEntry of walk(dir, {
includeDirs: false,
followSymlinks: false,
skip: [kSkipHidden].concat(
engineIgnoreDirs().map((ignore) =>
globToRegExp(join(dir, ignore) + SEP)
),
),
})
) {
const pathRelative = pathWithForwardSlashes(
relative(dir, walkEntry.path),
);
if (projectIgnores.some((regex) => regex.test(pathRelative))) {
continue;
}
promises.push(addFile(walkEntry.path));
}
const inclusions = await Promise.all(promises);
return inclusions.flat();
};
const addEntry = async (entry: string) => {
if (Deno.statSync(entry).isDirectory) {
return addDir(entry);
} else {
return addFile(entry);
}
};
const renderFiles = metadata?.project[kProjectRender];
let inclusions: FileInclusion[];
if (renderFiles) {
const exclude = projIgnoreGlobs.concat(outputDir ? [outputDir] : []);
const resolved = resolvePathGlobs(dir, renderFiles, exclude, {
mode: "auto",
});
const toInclude = ld.difference(
resolved.include,
resolved.exclude,
) as string[];
inclusions = (await Promise.all(toInclude.map(addEntry))).flat();
} else {
inclusions = await addDir(dir);
}
const files = inclusions.map((inclusion) => inclusion.file);
const engines = ld.uniq(inclusions.map((inclusion) => inclusion.engineName));
const intermediateFiles = inclusions.map((inclusion) =>
inclusion.engineIntermediates
).flat();
const inputFiles = Array.from(
new Set(files).difference(new Set(intermediateFiles)),
);
return { files: inputFiles, engines };
}
function projectConfigResources(
dir: string,
metadata: Metadata,
type?: ProjectType,
) {
const resourceIgnoreFields = ignoreFieldsForProjectType(type);
const resources: string[] = [];
const findResources = (
collection: Array<unknown> | Record<string, unknown>,
parentKey?: unknown,
) => {
ld.forEach(
collection,
(value: unknown, index: unknown) => {
if (parentKey === kHtmlMathMethod && index === "method") {
} else if (resourceIgnoreFields.includes(index as string)) {
} else if (Array.isArray(value)) {
findResources(value);
} else if (typeof value === "object") {
findResources(value as Record<string, unknown>, index);
} else if (typeof value === "string") {
const path = isAbsolute(value) ? value : join(dir, value);
try {
if (existsSync(path) && !Deno.statSync(path).isDirectory) {
resources.push(normalizePath(path));
}
} catch {
}
}
},
);
};
findResources(metadata);
return resources;
}