Skip to content

Commit

Permalink
Track file dependencies and only rebuild necessary files in watch mode.
Browse files Browse the repository at this point in the history
  • Loading branch information
Osmose committed Dec 20, 2023
1 parent c55dd66 commit 0d73333
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 15 deletions.
Binary file modified bun.lockb
Binary file not shown.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@
"cli": "bun run src/cli.ts",
"build-cli": "rm -rf node_modules/fsevents && bun build src/cli.ts --compile --minify --outfile=phantomake",
"build-docs": "bun run src/cli.ts docs docs_output",
"watch-docs": "bun run src/cli.ts watch docs docs_output"
"watch-docs": "bun run src/cli.ts watch docs"
},
"dependencies": {
"@types/ejs": "^3.1.5",
"chokidar": "^3.5.3",
"commander": "^11.1.0",
"consola": "^3.2.3",
"dayjs": "^1.11.10",
"dependency-graph": "^1.0.0",
"ejs": "^3.1.9",
"finalhandler": "^1.2.0",
"front-matter": "^4.0.2",
Expand Down
50 changes: 40 additions & 10 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import chokidar from 'chokidar';
import send from 'send';
import toml from 'toml';
import consola from 'consola';
import { DepGraph } from 'dependency-graph';

import phantomake, { PhantomakeOptions } from './index';

Expand Down Expand Up @@ -52,6 +53,18 @@ async function makePhantomakeOptions(
};
}

function replaceNode(dependencyGraph: DepGraph<string>, node: string, dependencies: string[]) {
// Remove existing dependencies
for (const existingDependency of dependencyGraph.directDependenciesOf(node)) {
dependencyGraph.removeDependency(node, existingDependency);
}

// Add new ones
for (const dependency of dependencies) {
dependencyGraph.addDependency(node, dependency);
}
}

program
.command('build', { isDefault: true })
.description('Build a directory and save the output')
Expand Down Expand Up @@ -84,36 +97,53 @@ program
baseUrl: `http://${options.host}:${options.port}`,
});

let dependencyGraph: DepGraph<string>;
consola.start(`Performing initial build of ${watchDirectory}`);
try {
await phantomake(watchDirectory, tempOutputDirectory, phantomakeOptions);
dependencyGraph = (await phantomake(watchDirectory, tempOutputDirectory, phantomakeOptions)).dependencyGraph;
consola.success('Build succeeded');
} catch (err) {
consola.error(err?.toString());
return;
}

// Only run one make call at a time; if a change happens during a run, finish the current one and schedule
// a re-run when it finishes.
let makePromise: Promise<void> | null = null;
let remakeAfterFinish = false;
function runPhantomake() {
let pendingChangedFiles: string[] = [];
function runPhantomake(changedFiles: string[]) {
if (makePromise) {
remakeAfterFinish = true;
pendingChangedFiles = pendingChangedFiles.concat(changedFiles);
} else {
makePromise = phantomake(watchDirectory, tempOutputDirectory, phantomakeOptions)
const matchFiles = changedFiles.flatMap((relativeFilePath) => [
relativeFilePath,
...dependencyGraph.dependantsOf(relativeFilePath), // Include files that depend on changed files in build
]);
consola.verbose(`Rebuilding files: \n ${matchFiles.join('\n ')}`);

makePromise = phantomake(watchDirectory, tempOutputDirectory, {
...phantomakeOptions,
matchFiles,
})
.then(
() => {
(globalContext) => {
consola.success('Build succeeded');

// Update dependencies only for files that actually got rendered
for (const fileName of matchFiles) {
replaceNode(dependencyGraph, fileName, globalContext.dependencyGraph.directDependenciesOf(fileName));
}
},
(err) => {
consola.error(err?.toString());
}
)
.finally(() => {
makePromise = null;
if (remakeAfterFinish) {
remakeAfterFinish = false;
runPhantomake();
if (pendingChangedFiles.length > 0) {
const rerunChangedFiles = pendingChangedFiles;
pendingChangedFiles = [];
runPhantomake(rerunChangedFiles);
}
});
}
Expand All @@ -124,7 +154,7 @@ program
watcher.on('all', async (event, filename) => {
const relativeFilename = nodePath.relative(watchDirectory, filename);
consola.log(`Detected ${event} in ${relativeFilename}, rebuilding`);
runPhantomake();
runPhantomake([relativeFilename]);
});
});

Expand Down
37 changes: 35 additions & 2 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dayjs from 'dayjs';
import { globSync } from 'glob';
import _ from 'lodash';
import renderMarkdown from './markdown';
import { DepGraph } from 'dependency-graph';

interface GlobalContextOptions {
baseUrl?: string;
Expand All @@ -14,14 +15,20 @@ interface GlobalContextOptions {
export class GlobalContext {
public readonly inputFileMap: Record<string, InputFile> = {};
public readonly paginators: Record<string, Paginator<any>> = {};
public readonly dependencyGraph: DepGraph<string> = new DepGraph();
private fileContexts: Record<string, FileContext> = {};

constructor(inputFiles: InputFile[], public readonly options: GlobalContextOptions = {}) {
for (const inputFile of inputFiles) {
this.inputFileMap[inputFile.path] = inputFile;
this.dependencyGraph.addNode(inputFile.relativePath);
}
}

addDependency(inputFile: InputFile, dependencyInputFile: InputFile) {
this.dependencyGraph.addDependency(inputFile.relativePath, dependencyInputFile.relativePath);
}

fileContext(inputFile: InputFile) {
let fileContext = this.fileContexts[inputFile.path];
if (!fileContext) {
Expand All @@ -47,7 +54,10 @@ interface GetFilesOptions {

/** Contains data about the file currently being rendered, as well as utility functions for EJS. */
export class FileContext {
constructor(private globalCtx: GlobalContext, public readonly file: InputFile) {}
constructor(private globalCtx: GlobalContext, public readonly file: InputFile) {
// Bind so that we can pass this as around as a function argument
this._includer = this._includer.bind(this);
}

getFiles(pattern: string, options: GetFilesOptions = {}) {
const paths = globSync(pattern, { cwd: nodePath.dirname(this.file.path), absolute: true });
Expand All @@ -68,9 +78,24 @@ export class FileContext {
}
}

// Add to dependencies list
for (const file of files) {
this.globalCtx.addDependency(this.file, file);
}

return files;
}

/** Passed as the includer option to EJS so we can capture included dependencies. */
_includer(originalPath: string, parsedPath: string) {
const inputFile = this.globalCtx.inputFileMap[parsedPath];
if (inputFile) {
this.globalCtx.addDependency(this.file, inputFile);
}

return { filename: parsedPath };
}

paginate<T>(items: T[], config?: Partial<PaginatorConfig>) {
if (this.file.isTemplate) {
// TODO: Fix issue with templates associating a paginator with the source file and not the template.
Expand Down Expand Up @@ -106,7 +131,15 @@ export class FileContext {
}

readJson(path: string) {
const file = fs.readFileSync(nodePath.resolve(this.file.path, '..', path), { encoding: 'utf-8' });
const resolvedPath = nodePath.resolve(this.file.path, '..', path);

// Store dependency
const jsonInputFile = this.globalCtx.inputFileMap[resolvedPath];
if (jsonInputFile) {
this.globalCtx.addDependency(this.file, jsonInputFile);
}

const file = fs.readFileSync(resolvedPath, { encoding: 'utf-8' });
return JSON.parse(file);
}
}
Expand Down
26 changes: 25 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,24 @@ class Templates {
return template.render({ ...data, ctx: template.context });
}

contextFor(templateName: string) {
return this.templates[templateName].context;
}

static async fromInputFiles(globalContext: GlobalContext, inputFiles: InputFile[]) {
const templates: { [name: string]: Template } = {};
for (const inputFile of inputFiles) {
if (!inputFile.isTemplate) {
continue;
}

const context = globalContext.fileContext(inputFile);
templates[inputFile.parsedRelativePath.name] = {
render: ejs.compile(await inputFile.file.text(), {
filename: inputFile.path,
includer: context._includer,
}),
context: globalContext.fileContext(inputFile),
context,
};
}

Expand Down Expand Up @@ -90,6 +96,10 @@ async function renderOutput(inputFile: InputFile, globalContext: GlobalContext,
const attributes = inputFile.attributes;
if (attributes.template) {
output.content = templates.apply(attributes.template, { ctx: context, output });

// Add template to the input file's dependencies
const templateInputFile = templates.contextFor(attributes.template).file;
globalContext.addDependency(inputFile, templateInputFile);
}

return output;
Expand All @@ -100,6 +110,9 @@ export interface PhantomakeOptions {

/** URL / domain name to be prepended to absolute URLs. */
baseUrl?: string;

/** Only build input files that match the given file paths. */
matchFiles?: string[];
}

export default async function phantomake(
Expand All @@ -126,6 +139,14 @@ export default async function phantomake(
continue;
}

// If matchFiles is specified, only process input files that match the given paths
if (options.matchFiles) {
const matchingPath = options.matchFiles.find((filePath) => inputFile.relativePath === filePath);
if (!matchingPath) {
continue;
}
}

if (inputFile.isText) {
const outputs = [await renderOutput(inputFile, globalContext, templates)];

Expand Down Expand Up @@ -166,4 +187,7 @@ export default async function phantomake(

// Write output
await fs.cp(tempOutputDirectory, outputDirectory, { recursive: true });

// TODO: Maybe return a more sane result rather than just returning the global context
return globalContext;
}
2 changes: 1 addition & 1 deletion src/processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const EJSProcessor: TextFileProcessor = {
throw new Error(`Input file ${inputFile.relativePath} is missing it's body.`);
}

return ejs.render(inputFile.body, { ctx: context }, { filename: inputFile.path });
return ejs.render(inputFile.body, { ctx: context }, { filename: inputFile.path, includer: context._includer });
},
};

Expand Down

0 comments on commit 0d73333

Please sign in to comment.