import { join, relative } from "../../deno_ral/path.ts";
import { existsSync } from "../../deno_ral/fs.ts";
import * as ld from "../../core/lodash.ts";
import { normalizePath, pathWithForwardSlashes } from "../../core/path.ts";
import { md5HashAsync, md5HashSync } from "../../core/hash.ts";
import { logError } from "../../core/log.ts";
import { isRevealjsOutput } from "../../config/format.ts";
import {
kProjectLibDir,
kProjectWatchInputs,
ProjectContext,
} from "../../project/types.ts";
import { projectOutputDir } from "../../project/project-shared.ts";
import { projectContext } from "../../project/project-context.ts";
import { ProjectWatcher, ServeOptions } from "./types.ts";
import { httpDevServer } from "../../core/http-devserver.ts";
import { RenderOptions } from "../../command/render/types.ts";
import { renderProject } from "../../command/render/project.ts";
import { render } from "../../command/render/render-shared.ts";
import { renderServices } from "../../command/render/render-services.ts";
import { isRStudio } from "../../core/platform.ts";
import { inputTargetIndexForOutputFile } from "../../project/project-index.ts";
import { isPdfContent } from "../../core/mime.ts";
import { ServeRenderManager } from "./render.ts";
import { existsSync1 } from "../../core/file.ts";
import { watchForFileChanges } from "../../core/watch.ts";
import { extensionFilesFromDirs } from "../../extension/extension.ts";
import { notebookContext } from "../../render/notebook/notebook-context.ts";
interface WatchChanges {
config: boolean;
output: boolean;
reloadTarget?: string;
}
export function watchProject(
project: ProjectContext,
extensionDirs: string[],
resourceFiles: string[],
renderOptions: RenderOptions,
pandocArgs: string[],
options: ServeOptions,
renderingOnReload: boolean,
renderManager: ServeRenderManager,
stopServer: VoidFunction,
): Promise<ProjectWatcher> {
const nbContext = notebookContext();
const flags = renderOptions.flags;
const refreshProjectConfig = async () => {
project.cleanup();
project =
(await projectContext(project.dir, nbContext, renderOptions, false))!;
};
const projDir = normalizePath(project.dir);
const projDirHidden = projDir + "/.";
const outputDir = projectOutputDir(project);
const libDirConfig = project.config?.project[kProjectLibDir];
const libDirSource = libDirConfig
? join(project.dir, libDirConfig)
: undefined;
const isResourceFile = (path: string) => {
if (libDirSource && path.startsWith(libDirSource)) {
return false;
} else {
return project.files.resources?.includes(path) ||
resourceFiles.includes(path);
}
};
const extensionFiles = extensionFilesFromDirs(extensionDirs);
const isExtensionFile = (path: string) => {
return extensionFiles.includes(path);
};
const isInputFile = (path: string) => {
return project.files.input.includes(path);
};
const rendered = new Map<string, string>();
const handleWatchEvent = async (
event: Deno.FsEvent,
): Promise<WatchChanges | undefined> => {
try {
const paths = ld.uniq(
event.paths
.filter((path) => !path.startsWith(projDirHidden))
.filter((path) =>
outputDir === project.dir || !path.startsWith(outputDir)
),
);
if (paths.length === 0) {
return;
}
if (["modify", "create"].includes(event.kind)) {
if (options[kProjectWatchInputs]) {
const inputs = paths.filter(isInputFile).filter(existsSync1).filter(
(input: string) => {
return !rendered.has(input) ||
rendered.get(input) !==
md5HashSync(Deno.readTextFileSync(input));
},
);
if (inputs.length) {
const services = renderServices(nbContext);
try {
const result = await renderManager.submitRender(() => {
if (inputs.length > 1) {
return renderProject(
project!,
{
services,
progress: true,
flags,
pandocArgs,
previewServer: true,
},
inputs,
);
} else {
return render(inputs[0], {
services,
flags,
pandocArgs: pandocArgs,
previewServer: true,
});
}
});
if (result.error) {
result.context.cleanup();
renderManager.onRenderError(result.error);
return undefined;
}
for (const input of inputs.filter(existsSync1)) {
rendered.set(
input,
await md5HashAsync(Deno.readTextFileSync(input)),
);
}
renderManager.onRenderResult(
result,
extensionDirs,
resourceFiles,
project!,
);
const nonSupplementalFiles = result.files.filter(
(renderResultFile) => {
return !renderResultFile.supplemental;
},
);
result.context.cleanup();
return {
config: false,
output: true,
reloadTarget: (nonSupplementalFiles.length &&
!isPdfContent(nonSupplementalFiles[0].file))
? join(outputDir, nonSupplementalFiles[0].file)
: undefined,
};
} finally {
services.cleanup();
}
}
}
const configFile = paths.some((path: string) =>
(project.files.config || []).includes(path)
);
const inputFileRemoved = project.files.input.some((file) =>
!existsSync(file)
);
const configResourceFile = paths.some((path: string) =>
(project.files.configResources || []).includes(path) &&
!project.files.input.includes(path)
);
const resourceFile = paths.some(isResourceFile);
const extensionFile = paths.some(isExtensionFile);
const reload = configFile || configResourceFile ||
resourceFile || extensionFile ||
inputFileRemoved;
if (reload) {
return {
config: configFile || configResourceFile || inputFileRemoved,
output: false,
};
} else {
return;
}
} else {
return;
}
} catch (e) {
logError(e);
return;
}
};
const devServer = httpDevServer(
options.timeout!,
() => renderManager.isRendering(),
stopServer,
);
const reloadClients = ld.debounce(async (changes: WatchChanges) => {
const services = renderServices(nbContext);
try {
if (!changes.output && !renderingOnReload) {
await refreshProjectConfig();
const result = await renderManager.submitRender(() =>
renderProject(
project,
{
services,
useFreezer: true,
devServerReload: true,
flags,
pandocArgs,
previewServer: true,
},
)
);
if (result.error) {
renderManager.onRenderError(result.error);
} else {
renderManager.onRenderResult(
result,
extensionDirs,
resourceFiles,
project,
);
}
}
if (changes.config) {
await refreshProjectConfig();
}
let reloadTarget = changes.reloadTarget || "";
if (reloadTarget && await preventReload(project, reloadTarget, options)) {
return;
}
if (reloadTarget && options.navigate) {
if (reloadTarget.startsWith(outputDir)) {
reloadTarget = relative(outputDir, reloadTarget);
} else {
reloadTarget = relative(projDir, reloadTarget);
}
if (existsSync(join(outputDir, reloadTarget))) {
reloadTarget = "/" + pathWithForwardSlashes(reloadTarget);
} else {
reloadTarget = "";
}
} else {
reloadTarget = "";
}
devServer.reloadClients(reloadTarget);
} catch (e) {
logError(e);
} finally {
services.cleanup();
}
}, 100);
const watcher = watchForFileChanges(() => {
return ld.uniq([
...project.files.input,
...(project.files.resources || []),
...(project.files.config || []),
...(project.files.configResources || []),
...resourceFiles,
...extensionFiles,
]) as string[];
});
const watchForChanges = async () => {
for await (const event of watcher) {
const result = await handleWatchEvent(event);
if (result) {
await reloadClients(result);
}
}
};
watchForChanges();
return Promise.resolve({
handle: (req: Request) => {
return devServer.handle(req);
},
request: devServer.request,
injectClient: (
req: Request,
file: Uint8Array,
inputFile?: string,
contentType?: string,
) => {
return devServer.injectClient(req, file, inputFile, contentType);
},
clientHtml: (req: Request, inputFile?: string) => {
return devServer.clientHtml(req, inputFile);
},
hasClients: () => devServer.hasClients(),
reloadClients: async (output: boolean, reloadTarget?: string) => {
await reloadClients({
config: false,
output,
reloadTarget,
});
},
project: () => project,
refreshProject: async () => {
await refreshProjectConfig();
return project;
},
});
}
interface WatcherOptions {
paths: string | string[];
options?: { recursive: boolean };
}
async function preventReload(
project: ProjectContext,
lastHtmlFile: string,
options: ServeOptions,
) {
if (isRStudio() && !options[kProjectWatchInputs]) {
const index = await inputTargetIndexForOutputFile(
project,
relative(projectOutputDir(project), lastHtmlFile),
);
if (index) {
return isRevealjsOutput(Object.keys(index.formats)[0]);
}
}
return false;
}