Skip to content

Commit f1fe72d

Browse files
committed
Merge branch 'watch-docs-and-css' into dev
2 parents aff795d + 323f8d9 commit f1fe72d

File tree

15 files changed

+213
-38
lines changed

15 files changed

+213
-38
lines changed

bin/typedoc

+13-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
#!/usr/bin/env node
22
//@ts-check
33

4-
/* eslint-disable @typescript-eslint/no-var-requires */
5-
import("../dist/lib/cli.js");
4+
const { fork } = require("child_process");
5+
6+
function main() {
7+
fork(__dirname + "/../dist/lib/cli.js", process.argv.slice(2), {
8+
stdio: "inherit",
9+
}).on("exit", (code) => {
10+
// Watch restart required? Fork a new child
11+
if (code === 7) main();
12+
else process.exit(code || 0);
13+
});
14+
}
15+
16+
main();

site/options/other.md

+4-3
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ $ typedoc --watch
1313
Use TypeScript's incremental compiler to watch source files for changes and
1414
build the docs on change. May be combined with `--emit`.
1515

16-
> [!note] This mode will only detect changes to files watched by the TypeScript
17-
> compiler. Changes to other files (`README.md`, imported files with `@include` or
18-
> `@includeCode`) will not cause a rebuild.
16+
This mode detects changes to project documents, readme, custom JS/CSS,
17+
configuration files, files imported by `@include`/`@includeCode`, and any
18+
files explicitly registered by plugins as needing to be watched, as well
19+
as all your TypeScript source files.
1920

2021
## preserveWatchOutput
2122

src/lib/application.ts

+118-9
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,9 @@ export class Application extends AbstractComponent<
231231
readers.forEach((r) => app.options.addReader(r));
232232
app.options.reset();
233233
app.setOptions(options, /* reportErrors */ false);
234-
await app.options.read(new Logger());
234+
await app.options.read(new Logger(), undefined, (path) =>
235+
app.watchConfigFile(path),
236+
);
235237
app.logger.level = app.options.getValue("logLevel");
236238

237239
await loadPlugins(app, app.options.getValue("plugin"));
@@ -265,7 +267,9 @@ export class Application extends AbstractComponent<
265267
private async _bootstrap(options: Partial<TypeDocOptions>) {
266268
this.options.reset();
267269
this.setOptions(options, /* reportErrors */ false);
268-
await this.options.read(this.logger);
270+
await this.options.read(this.logger, undefined, (path) =>
271+
this.watchConfigFile(path),
272+
);
269273
this.setOptions(options);
270274
this.logger.level = this.options.getValue("logLevel");
271275
for (const [lang, locales] of Object.entries(
@@ -425,9 +429,49 @@ export class Application extends AbstractComponent<
425429
return project;
426430
}
427431

428-
public convertAndWatch(
432+
private watchers = new Map<string, ts.FileWatcher>();
433+
private _watchFile?: (path: string, shouldRestart?: boolean) => void;
434+
private criticalFiles = new Set<string>();
435+
436+
private clearWatches() {
437+
this.watchers.forEach((w) => w.close());
438+
this.watchers.clear();
439+
}
440+
441+
private watchConfigFile(path: string) {
442+
this.criticalFiles.add(path);
443+
}
444+
445+
/**
446+
* Register that the current build depends on a file, so that in watch mode
447+
* the build will be repeated. Has no effect if a watch build is not
448+
* running, or if the file has already been registered.
449+
*
450+
* @param path The file to watch. It does not need to exist, and you should
451+
* in fact register files you look for, but which do not exist, so that if
452+
* they are created the build will re-run. (e.g. if you look through a list
453+
* of 5 possibilities and find the third, you should register the first 3.)
454+
*
455+
* @param shouldRestart Should the build be completely restarted? (This is
456+
* normally only used for configuration files -- i.e. files whose contents
457+
* determine how conversion, rendering, or compiling will be done, as
458+
* opposed to files that are only read *during* the conversion or
459+
* rendering.)
460+
*/
461+
public watchFile(path: string, shouldRestart = false) {
462+
this._watchFile?.(path, shouldRestart);
463+
}
464+
465+
/**
466+
* Run a convert / watch process.
467+
*
468+
* @param success Callback to run after each convert, receiving the project
469+
* @returns True if the watch process should be restarted due to a
470+
* configuration change, false for an options error
471+
*/
472+
public async convertAndWatch(
429473
success: (project: ProjectReflection) => Promise<void>,
430-
): void {
474+
): Promise<boolean> {
431475
if (
432476
!this.options.getValue("preserveWatchOutput") &&
433477
this.logger instanceof ConsoleLogger
@@ -459,7 +503,7 @@ export class Application extends AbstractComponent<
459503
// have reported in the first time... just error out for now. I'm not convinced anyone will actually notice.
460504
if (this.options.getFileNames().length === 0) {
461505
this.logger.error(this.i18n.solution_not_supported_in_watch_mode());
462-
return;
506+
return false;
463507
}
464508

465509
// Support for packages mode is currently unimplemented
@@ -468,7 +512,7 @@ export class Application extends AbstractComponent<
468512
this.entryPointStrategy !== EntryPointStrategy.Expand
469513
) {
470514
this.logger.error(this.i18n.strategy_not_supported_in_watch_mode());
471-
return;
515+
return false;
472516
}
473517

474518
const tsconfigFile =
@@ -506,16 +550,69 @@ export class Application extends AbstractComponent<
506550

507551
let successFinished = true;
508552
let currentProgram: ts.Program | undefined;
553+
let lastProgram = currentProgram;
554+
let restarting = false;
555+
556+
this._watchFile = (path: string, shouldRestart = false) => {
557+
this.logger.verbose(
558+
`Watching ${nicePath(path)}, shouldRestart=${shouldRestart}`,
559+
);
560+
if (this.watchers.has(path)) return;
561+
this.watchers.set(
562+
path,
563+
host.watchFile(
564+
path,
565+
(file) => {
566+
if (shouldRestart) {
567+
restartMain(file);
568+
} else if (!currentProgram) {
569+
currentProgram = lastProgram;
570+
this.logger.info(
571+
this.i18n.file_0_changed_rebuilding(
572+
nicePath(file),
573+
),
574+
);
575+
}
576+
if (successFinished) runSuccess();
577+
},
578+
2000,
579+
),
580+
);
581+
};
582+
583+
/** resolver for the returned promise */
584+
let exitWatch: (restart: boolean) => unknown;
585+
const restartMain = (file: string) => {
586+
if (restarting) return;
587+
this.logger.info(
588+
this.i18n.file_0_changed_restarting(nicePath(file)),
589+
);
590+
restarting = true;
591+
currentProgram = undefined;
592+
this.clearWatches();
593+
tsWatcher.close();
594+
};
509595

510596
const runSuccess = () => {
597+
if (restarting && successFinished) {
598+
successFinished = false;
599+
exitWatch(true);
600+
return;
601+
}
602+
511603
if (!currentProgram) {
512604
return;
513605
}
514606

515607
if (successFinished) {
516-
if (this.options.getValue("emit") === "both") {
608+
if (
609+
this.options.getValue("emit") === "both" &&
610+
currentProgram !== lastProgram
611+
) {
517612
currentProgram.emit();
518613
}
614+
// Save for possible re-run due to non-.ts file change
615+
lastProgram = currentProgram;
519616

520617
this.logger.resetErrors();
521618
this.logger.resetWarnings();
@@ -527,6 +624,10 @@ export class Application extends AbstractComponent<
527624
if (!entryPoints) {
528625
return;
529626
}
627+
this.clearWatches();
628+
this.criticalFiles.forEach((path) =>
629+
this.watchFile(path, true),
630+
);
530631
const project = this.converter.convert(entryPoints);
531632
currentProgram = undefined;
532633
successFinished = false;
@@ -563,14 +664,22 @@ export class Application extends AbstractComponent<
563664

564665
const origAfterProgramCreate = host.afterProgramCreate;
565666
host.afterProgramCreate = (program) => {
566-
if (ts.getPreEmitDiagnostics(program.getProgram()).length === 0) {
667+
if (
668+
!restarting &&
669+
ts.getPreEmitDiagnostics(program.getProgram()).length === 0
670+
) {
567671
currentProgram = program.getProgram();
568672
runSuccess();
569673
}
570674
origAfterProgramCreate?.(program);
571675
};
572676

573-
ts.createWatchProgram(host);
677+
const tsWatcher = ts.createWatchProgram(host);
678+
679+
// Don't return to caller until the watch needs to restart
680+
return await new Promise((res) => {
681+
exitWatch = res;
682+
});
574683
}
575684

576685
validate(project: ProjectReflection) {

src/lib/cli.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ async function main() {
3232
if (exitCode !== ExitCodes.Watching) {
3333
app.logger.verbose(`Full run took ${Date.now() - start}ms`);
3434
logRunSummary(app.logger);
35-
process.exit(exitCode);
3635
}
36+
process.exit(exitCode);
3737
} catch (error) {
3838
console.error("TypeDoc exiting with unexpected error:");
3939
console.error(error);
@@ -73,11 +73,12 @@ async function run(app: td.Application) {
7373
}
7474

7575
if (app.options.getValue("watch")) {
76-
app.convertAndWatch(async (project) => {
76+
return (await app.convertAndWatch(async (project) => {
7777
app.validate(project);
7878
await app.generateOutputs(project);
79-
});
80-
return ExitCodes.Watching;
79+
}))
80+
? ExitCodes.Watching
81+
: ExitCodes.OptionError;
8182
}
8283

8384
const project = await app.convert();

src/lib/converter/converter.ts

+1
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,7 @@ export class Converter extends AbstractComponent<Application, ConverterEvents> {
688688
frontmatter,
689689
);
690690

691+
this.application.watchFile(file.fileName);
691692
parent.addChild(docRefl);
692693
parent.project.registerReflection(docRefl, undefined, file.fileName);
693694
this.trigger(ConverterEvents.CREATE_DOCUMENT, undefined, docRefl);

src/lib/converter/plugins/IncludePlugin.ts

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export class IncludePlugin extends ConverterComponent {
6767
parseIncludeCodeTextPart(part.text);
6868

6969
const file = path.resolve(relative, filename);
70+
this.application.watchFile(file);
7071
if (included.includes(file) && part.tag === "@include") {
7172
this.logger.error(
7273
this.logger.i18n.include_0_in_1_specified_2_circular_include_3(

src/lib/converter/plugins/PackagePlugin.ts

+2
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export class PackagePlugin extends ConverterComponent {
9898

9999
if (this.readme) {
100100
// Readme path provided, read only that file.
101+
this.application.watchFile(this.readme);
101102
try {
102103
this.readmeContents = readFile(this.readme);
103104
this.readmeFile = this.readme;
@@ -119,6 +120,7 @@ export class PackagePlugin extends ConverterComponent {
119120
if (result) {
120121
this.readmeFile = result.file;
121122
this.readmeContents = result.content;
123+
this.application.watchFile(this.readmeFile);
122124
}
123125
}
124126
}

src/lib/internationalization/locales/en.cts

+3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export = {
1515
"The provided tsconfig file looks like a solution style tsconfig, which is not supported in watch mode",
1616
strategy_not_supported_in_watch_mode:
1717
"entryPointStrategy must be set to either resolve or expand for watch mode",
18+
file_0_changed_restarting:
19+
"Configuration file {0} changed: full restart required...",
20+
file_0_changed_rebuilding: "File {0} changed: rebuilding output...",
1821
found_0_errors_and_1_warnings: "Found {0} errors and {1} warnings",
1922

2023
output_0_could_not_be_generated:

src/lib/output/plugins/AssetsPlugin.ts

+2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class AssetsPlugin extends RendererComponent {
5151
}
5252

5353
if (this.customCss) {
54+
this.application.watchFile(this.customCss);
5455
if (existsSync(this.customCss)) {
5556
copySync(this.customCss, join(dest, "custom.css"));
5657
} else {
@@ -63,6 +64,7 @@ export class AssetsPlugin extends RendererComponent {
6364
}
6465

6566
if (this.customJs) {
67+
this.application.watchFile(this.customJs);
6668
if (existsSync(this.customJs)) {
6769
copySync(this.customJs, join(dest, "custom.js"));
6870
} else {

src/lib/utils/fs.ts

+17-7
Original file line numberDiff line numberDiff line change
@@ -341,13 +341,15 @@ export function discoverInParentDirExactMatch<T extends {}>(
341341
name: string,
342342
dir: string,
343343
read: (content: string) => T | undefined,
344+
usedFile?: (path: string) => void,
344345
): { file: string; content: T } | undefined {
345346
if (!isDir(dir)) return;
346347

347348
const reachedTopDirectory = (dirName: string) =>
348349
dirName === resolve(join(dirName, ".."));
349350

350351
while (!reachedTopDirectory(dir)) {
352+
usedFile?.(join(dir, name));
351353
try {
352354
const content = read(readFile(join(dir, name)));
353355
if (content != null) {
@@ -360,13 +362,21 @@ export function discoverInParentDirExactMatch<T extends {}>(
360362
}
361363
}
362364

363-
export function discoverPackageJson(dir: string) {
364-
return discoverInParentDirExactMatch("package.json", dir, (content) => {
365-
const pkg: unknown = JSON.parse(content);
366-
if (validate({ name: String, version: optional(String) }, pkg)) {
367-
return pkg;
368-
}
369-
});
365+
export function discoverPackageJson(
366+
dir: string,
367+
usedFile?: (path: string) => void,
368+
) {
369+
return discoverInParentDirExactMatch(
370+
"package.json",
371+
dir,
372+
(content) => {
373+
const pkg: unknown = JSON.parse(content);
374+
if (validate({ name: String, version: optional(String) }, pkg)) {
375+
return pkg;
376+
}
377+
},
378+
usedFile,
379+
);
370380
}
371381

372382
// dir -> package name according to package.json in this or some parent dir

src/lib/utils/options/options.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,14 @@ export interface OptionsReader {
5656
* @param container the options container that provides declarations
5757
* @param logger logger to be used to report errors
5858
* @param cwd the directory which should be treated as the current working directory for option file discovery
59+
* @param usedFile a callback to track files that were read or whose existence was checked, for purposes of restarting a build when watching files
5960
*/
60-
read(container: Options, logger: Logger, cwd: string): void | Promise<void>;
61+
read(
62+
container: Options,
63+
logger: Logger,
64+
cwd: string,
65+
usedFile: (file: string) => void,
66+
): void | Promise<void>;
6167
}
6268

6369
const optionSnapshots = new WeakMap<
@@ -194,9 +200,13 @@ export class Options {
194200
insertOrderSorted(this._readers, reader);
195201
}
196202

197-
async read(logger: Logger, cwd = process.cwd()) {
203+
async read(
204+
logger: Logger,
205+
cwd = process.cwd(),
206+
usedFile: (path: string) => void = () => {},
207+
) {
198208
for (const reader of this._readers) {
199-
await reader.read(this, logger, cwd);
209+
await reader.read(this, logger, cwd, usedFile);
200210
}
201211
}
202212

0 commit comments

Comments
 (0)