import { dirname, isAbsolute, join, relative } from "../deno_ral/path.ts";
import * as ld from "../core/lodash.ts";
import { inputTargetIndexCacheMetrics } from "./target-index-cache-metrics.ts";
import {
InputTarget,
InputTargetIndex,
kProjectType,
ProjectContext,
} from "./types.ts";
import { Metadata } from "../config/types.ts";
import { Format } from "../config/types.ts";
import {
dirAndStem,
normalizePath,
pathWithForwardSlashes,
removeIfExists,
safeExistsSync,
} from "../core/path.ts";
import { kTitle } from "../config/constants.ts";
import { fileExecutionEngine } from "../execute/engine.ts";
import { projectConfigFile, projectOutputDir } from "./project-shared.ts";
import { projectScratchPath } from "./project-scratch.ts";
import { parsePandocTitle } from "../core/pandoc/pandoc-partition.ts";
import { readYamlFromString } from "../core/yaml.ts";
import { formatKeys } from "../config/metadata.ts";
import {
formatsPreferHtml,
websiteFormatPreferHtml,
} from "./types/website/website-config.ts";
import { kDefaultProjectFileContents } from "./types/project-default.ts";
import { formatOutputFile } from "../core/render.ts";
import { projectType } from "./types/project-types.ts";
import { withRenderServices } from "../command/render/render-services.ts";
import { RenderServices } from "../command/render/types.ts";
import { kDraft } from "../format/html/format-html-shared.ts";
export async function inputTargetIndex(
project: ProjectContext,
input: string,
): Promise<InputTargetIndex | undefined> {
const inputFile = join(project.dir, input);
if (!safeExistsSync(inputFile) || Deno.statSync(inputFile).isDirectory) {
return Promise.resolve(undefined);
}
if (!project.files.input.includes(normalizePath(inputFile))) {
return Promise.resolve(undefined);
}
const { index: targetIndex } = readInputTargetIndex(
project.dir,
input,
);
if (targetIndex) {
return targetIndex;
}
const index = await readBaseInputIndex(inputFile, project);
if (index) {
const indexFile = inputTargetIndexFile(project.dir, input);
Deno.writeTextFileSync(indexFile, JSON.stringify(index));
}
return index;
}
export async function readBaseInputIndex(
inputFile: string,
project: ProjectContext,
) {
const engine = await fileExecutionEngine(inputFile, undefined, project);
if (engine === undefined) {
return Promise.resolve(undefined);
}
const formats = await withRenderServices(
project.notebookContext,
(services: RenderServices) =>
project.renderFormats(inputFile, services, "all", project),
);
const firstFormat = Object.values(formats)[0];
const markdown = await engine.partitionedMarkdown(inputFile, firstFormat);
const index: InputTargetIndex = {
title: (firstFormat?.metadata?.[kTitle] || markdown.yaml?.[kTitle] ||
markdown.headingText) as
| string
| undefined,
markdown,
formats,
draft: (firstFormat?.metadata?.[kDraft] || markdown.yaml?.[kDraft]) as
| boolean
| undefined,
};
if (index.title) {
if (typeof index.title !== "string") {
throw new Error(
`${
relative(project.dir, inputFile)
}: Title must be a string, but is instead of type ${typeof index
.title}`,
);
}
const parsedTitle = parsePandocTitle(index.title);
index.title = parsedTitle.heading;
} else {
index.title = index.markdown.headingText;
}
if (project.config) {
index.projectFormats = formatKeys(project.config);
}
return index;
}
export function readInputTargetIndex(
projectDir: string,
input: string,
): {
index?: InputTargetIndex;
missingReason?: "stale" | "formats";
} {
const index = readInputTargetIndexIfStillCurrent(projectDir, input);
if (!index) {
return {
missingReason: "stale",
};
}
if (Object.keys(index.formats).includes("html")) {
index.formats = websiteFormatPreferHtml(index.formats) as Record<
string,
Format
>;
}
const formats = (index.projectFormats as string[] | undefined) ??
Object.keys(index.formats);
const projConfigFile = projectConfigFile(projectDir);
if (!projConfigFile) {
return { index };
}
let contents = Deno.readTextFileSync(projConfigFile);
if (contents.trim().length === 0) {
contents = kDefaultProjectFileContents;
}
const config = readYamlFromString(contents) as Metadata;
const projFormats = formatKeys(config);
if (ld.isEqual(formats, projFormats)) {
return {
index,
};
} else {
return {
missingReason: "formats",
};
}
}
export function inputTargetIsEmpty(index: InputTargetIndex) {
if (index.markdown.markdown.trim().length > 0) {
return false;
}
if (
index.markdown.yaml &&
Object.keys(index.markdown.yaml).find((key) => key !== kTitle)
) {
return false;
}
return true;
}
const inputTargetIndexCache = new Map<string, InputTargetIndex>();
function readInputTargetIndexIfStillCurrent(projectDir: string, input: string) {
const inputFile = join(projectDir, input);
const indexFile = inputTargetIndexFile(projectDir, input);
try {
const inputMod = Deno.statSync(inputFile).mtime;
const indexMod = Deno.statSync(indexFile).mtime;
if (
inputMod && indexMod
) {
if (inputMod > indexMod) {
inputTargetIndexCacheMetrics.invalidations++;
inputTargetIndexCache.delete(indexFile);
return undefined;
}
if (inputTargetIndexCache.has(indexFile)) {
inputTargetIndexCacheMetrics.hits++;
return inputTargetIndexCache.get(indexFile);
} else {
inputTargetIndexCacheMetrics.misses++;
try {
const result = JSON.parse(
Deno.readTextFileSync(indexFile),
) as InputTargetIndex;
inputTargetIndexCache.set(indexFile, result);
return result;
} catch {
return undefined;
}
}
}
} catch (e) {
if (e instanceof Deno.errors.NotFound) {
return undefined;
} else {
throw e;
}
}
}
export async function resolveInputTarget(
project: ProjectContext,
href: string,
absolute = true,
): Promise<InputTarget | undefined> {
const index = await inputTargetIndex(project, href);
if (index) {
const formats = formatsPreferHtml(index.formats) as Record<string, Format>;
const format = Object.values(formats)[0];
const projType = projectType(project.config?.project?.[kProjectType]);
const projOutputFile = projType.outputFile
? projType.outputFile(href, format, project)
: undefined;
const [hrefDir, hrefStem] = dirAndStem(href);
const outputFile = projOutputFile || formatOutputFile(format) ||
`${hrefStem}.html`;
const outputHref = pathWithForwardSlashes(
(absolute ? "/" : "") + join(hrefDir, outputFile),
);
const inputTarget = {
input: href,
title: index.title,
outputHref,
draft: index.draft === true,
};
if (projType.filterInputTarget) {
return projType.filterInputTarget(inputTarget, project);
} else {
return inputTarget;
}
} else {
return undefined;
}
}
export async function inputFileForOutputFile(
project: ProjectContext,
output: string,
): Promise<{ file: string; format: Format } | undefined> {
const outputDir = projectOutputDir(project);
output = isAbsolute(output) ? output : join(outputDir, output);
if (project.outputNameIndex !== undefined) {
return project.outputNameIndex.get(output);
}
project.outputNameIndex = new Map();
for (const file of project.files.input) {
const inputRelative = relative(project.dir, file);
const index = await inputTargetIndex(
project,
relative(project.dir, file),
);
if (index) {
Object.keys(index.formats).forEach((key) => {
const format = index.formats[key];
const outputFile = formatOutputFile(format);
if (outputFile) {
const formatOutputPath = join(
outputDir!,
dirname(inputRelative),
outputFile,
);
project.outputNameIndex!.set(formatOutputPath, { file, format });
}
});
}
}
return project.outputNameIndex.get(output);
}
export async function inputTargetIndexForOutputFile(
project: ProjectContext,
outputRelative: string,
) {
const input = await inputFileForOutputFile(project, outputRelative);
if (!input) {
return undefined;
}
return await inputTargetIndex(
project,
relative(project.dir, input.file),
);
}
export async function resolveInputTargetForOutputFile(
project: ProjectContext,
outputRelative: string,
) {
const input = await inputFileForOutputFile(project, outputRelative);
if (!input) {
return undefined;
}
return await resolveInputTarget(
project,
pathWithForwardSlashes(relative(project.dir, input.file)),
);
}
export function clearProjectIndex(projectDir: string) {
const indexPath = projectScratchPath(projectDir, "idx");
removeIfExists(indexPath);
}
function inputTargetIndexFile(projectDir: string, input: string): string {
return indexPath(projectDir, `${input}.json`);
}
function indexPath(projectDir: string, path: string): string {
return projectScratchPath(projectDir, join("idx", path));
}