@abreen/tada 1.17.1 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +37 -6
  2. package/bin/tada.ts +16 -7
  3. package/bin/validators.ts +33 -0
  4. package/build/branding.ts +41 -0
  5. package/build/build-manifest.ts +9 -4
  6. package/build/{watch/compiler-types.d.ts → build-types.d.ts} +8 -5
  7. package/build/build-validation.ts +176 -0
  8. package/build/bundle.ts +75 -65
  9. package/build/config-loader.ts +3 -3
  10. package/build/custom-fonts.ts +8 -5
  11. package/build/generate-favicon.ts +7 -6
  12. package/build/generate-fonts.ts +8 -15
  13. package/build/generate-katex-assets.ts +19 -17
  14. package/build/generate-web-app-manifest.ts +3 -11
  15. package/build/lodash-template.ts +14 -0
  16. package/build/output-publication.ts +238 -0
  17. package/build/pagefind.ts +49 -21
  18. package/build/pdf-text.ts +41 -1
  19. package/build/pipeline.ts +95 -101
  20. package/build/site-assets.ts +46 -0
  21. package/build/site-build.ts +267 -0
  22. package/build/source-model.ts +223 -244
  23. package/build/source-records.ts +69 -81
  24. package/build/template-globals.ts +3 -0
  25. package/build/templates.ts +5 -2
  26. package/build/toc-plugin.ts +21 -21
  27. package/build/types.d.ts +38 -38
  28. package/build/util.ts +0 -4
  29. package/build/utils/code.ts +38 -101
  30. package/build/utils/final-html.ts +171 -47
  31. package/build/utils/literate-java.ts +11 -7
  32. package/build/utils/markdown-partials.ts +4 -3
  33. package/build/utils/markdown.ts +6 -17
  34. package/build/utils/paths.ts +3 -0
  35. package/build/utils/plain-text.ts +18 -0
  36. package/build/utils/render.ts +197 -154
  37. package/build/utils/shiki-highlighter.ts +11 -0
  38. package/build/utils/source-template.ts +2 -2
  39. package/build/utils/trace-core.ts +8 -83
  40. package/build/utils/trace.ts +38 -95
  41. package/build/validate-config-links.ts +5 -5
  42. package/build/watch/build-incremental.ts +46 -103
  43. package/build/watch/compiler.ts +158 -37
  44. package/build/watch/engine.ts +207 -176
  45. package/build/watch/index.ts +9 -2
  46. package/build/watch/planner.ts +88 -144
  47. package/build/watch/runtime.ts +21 -34
  48. package/build/watch/types.d.ts +32 -64
  49. package/package.json +4 -4
  50. package/schema/site.schema.json +18 -2
  51. package/src/_content.scss +447 -0
  52. package/src/_fonts.scss +90 -0
  53. package/src/_layout.scss +50 -35
  54. package/src/appearance-picker/{style.scss → _index.scss} +1 -1
  55. package/src/code/{style.scss → _index.scss} +61 -0
  56. package/src/header/{_base.scss → _index.scss} +98 -6
  57. package/src/index.ts +1 -17
  58. package/src/navigate/{style.scss → _index.scss} +20 -0
  59. package/src/navigate/eligible.ts +0 -40
  60. package/src/navigate/index.ts +38 -4
  61. package/src/navigate/lifecycle.ts +19 -2
  62. package/src/navigate/runtime.ts +20 -4
  63. package/src/page-update/index.ts +33 -20
  64. package/src/print/{style.scss → _index.scss} +3 -1
  65. package/src/search/index.ts +7 -0
  66. package/src/slides/{style.scss → _index.scss} +5 -0
  67. package/src/style.scss +24 -536
  68. package/src/toc/index.ts +2 -2
  69. package/templates/_author.html +2 -2
  70. package/templates/_download.html +1 -1
  71. package/templates/_footer.html +1 -1
  72. package/templates/_heading.html +3 -3
  73. package/templates/_nav.html +2 -2
  74. package/templates/_page-bottom.html +6 -0
  75. package/templates/_top.html +18 -14
  76. package/templates/code.html +22 -21
  77. package/templates/default.html +12 -11
  78. package/templates/literate.html +6 -5
  79. package/build/copy.ts +0 -82
  80. package/build/generate-content-assets.ts +0 -121
  81. package/build/utils/content-files.ts +0 -170
  82. package/build/watch/assets.ts +0 -103
  83. package/build/watch/build-full.ts +0 -97
  84. package/build/watch/build-helpers.ts +0 -66
  85. package/build/watch/build-result.ts +0 -19
  86. package/build/watch/fs-commit.ts +0 -218
  87. package/build/watch/mutations.ts +0 -73
  88. package/build/watch/snapshot.ts +0 -201
  89. package/build/watch/validation.ts +0 -57
  90. package/src/code.scss +0 -60
  91. package/src/critical.scss +0 -5
  92. package/src/header/style.scss +0 -91
  93. /package/src/{literate.scss → _literate.scss} +0 -0
  94. /package/src/{material-symbols.scss → _material-symbols.scss} +0 -0
  95. /package/src/anchor/{style.scss → _index.scss} +0 -0
  96. /package/src/page-update/{style.scss → _index.scss} +0 -0
  97. /package/src/question/{style.scss → _index.scss} +0 -0
  98. /package/src/search/{style.scss → _index.scss} +0 -0
  99. /package/src/timezone/{style.scss → _index.scss} +0 -0
  100. /package/src/toc/{style.scss → _index.scss} +0 -0
  101. /package/src/trace/{style.scss → _index.scss} +0 -0
@@ -1,27 +1,33 @@
1
1
  import path from 'path';
2
2
  import { getProjectConfigDir } from '../templates';
3
- import { getContentDir, getPublicDir } from '../util';
4
- import type {
5
- ChangeBatch,
6
- CompilerBuildResult,
7
- WatchCompiler,
8
- WatchTarget,
9
- } from './types';
10
- import { buildFull } from './build-full';
3
+ import { getContentDir, getDistDir, getPublicDir } from '../utils/paths';
4
+ import type { CompileOutcome, WatchTarget, WatchBuildResult } from './types';
5
+ import type { TadaBuildMeta, TraceCache } from '../build-types';
6
+ import type { TraceToolAvailability } from '../types';
7
+ import {
8
+ applyMutations,
9
+ computeMutations,
10
+ planFullWrite,
11
+ } from '../output-publication';
12
+ import { loadProjectConfig } from '../config-loader';
13
+ import { getKeptOutputPrefixes } from '../pagefind';
14
+ import { getDevSiteVariables } from '../site-variables';
15
+ import { scanProject } from '../source-model';
16
+ import { buildSite, createBuildMeta, type TadaSnapshot } from '../site-build';
11
17
  import { buildIncremental } from './build-incremental';
12
- import type { TraceCache, WatchTraceOptions } from './compiler-types';
18
+ import type { TadaSourceRecord } from '../source-records';
13
19
  import { checkTraceToolAvailability, isTraceSourceFile } from '../utils/trace';
14
- import { createTadaWatchPlan } from './planner';
15
- import { updateProjectScan } from '../source-model';
16
- import type { TadaSnapshot } from './snapshot';
17
- import { getWatchConfigFilePaths } from './config-paths';
20
+ import { createTadaWatchPlan, diffAuthorKeys } from './planner';
21
+ import {
22
+ getWatchConfigFilePaths,
23
+ classifyWatchConfigPath,
24
+ } from './config-paths';
18
25
 
19
26
  export function invalidateTraceCacheForBatch(
20
27
  traceCache: TraceCache,
21
- batch: ChangeBatch,
28
+ paths: ReadonlySet<string>,
22
29
  ): void {
23
- for (const change of batch.changes) {
24
- const sourcePath = path.resolve(change.path);
30
+ for (const sourcePath of paths) {
25
31
  if (isTraceSourceFile(sourcePath)) {
26
32
  for (const [cacheKey, entry] of traceCache) {
27
33
  if (sourcePath in entry.sourceMtims) {
@@ -32,13 +38,39 @@ export function invalidateTraceCacheForBatch(
32
38
  }
33
39
  }
34
40
 
35
- export class TadaWatchCompiler implements WatchCompiler {
41
+ /** Drops cached traces whose source files no page in the build traces. */
42
+ export function pruneTraceCache(
43
+ traceCache: TraceCache,
44
+ records: ReadonlyMap<string, TadaSourceRecord>,
45
+ ): void {
46
+ const traced = new Set<string>();
47
+ for (const record of records.values()) {
48
+ for (const sourcePath of record.traceDeps) {
49
+ traced.add(sourcePath);
50
+ }
51
+ }
52
+ for (const [cacheKey, entry] of traceCache) {
53
+ if (!Object.keys(entry.sourceMtims).every(file => traced.has(file))) {
54
+ traceCache.delete(cacheKey);
55
+ }
56
+ }
57
+ }
58
+
59
+ export class TadaWatchCompiler {
36
60
  private traceCache: TraceCache;
37
- private traceOptions: WatchTraceOptions;
61
+ private traceToolAvailability: TraceToolAvailability;
62
+
63
+ readonly build = createBuildSession(async (snapshot, paths) => {
64
+ const outcome = await this.compile(snapshot, paths);
65
+ if (outcome.ok) {
66
+ pruneTraceCache(this.traceCache, outcome.snapshot.records);
67
+ }
68
+ return outcome;
69
+ });
38
70
 
39
71
  constructor() {
40
72
  this.traceCache = new Map();
41
- this.traceOptions = { toolAvailability: checkTraceToolAvailability() };
73
+ this.traceToolAvailability = checkTraceToolAvailability();
42
74
  }
43
75
 
44
76
  getWatchTargets(): WatchTarget[] {
@@ -58,35 +90,124 @@ export class TadaWatchCompiler implements WatchCompiler {
58
90
  ];
59
91
  }
60
92
 
61
- async build(
93
+ private async buildFull(
94
+ reuse?: Pick<TadaSnapshot, 'siteVariables' | 'scan'>,
95
+ ): Promise<CompileOutcome> {
96
+ const result = await buildSite({
97
+ siteVariables: reuse?.siteVariables ?? getDevSiteVariables(),
98
+ scan: reuse?.scan,
99
+ mode: 'development',
100
+ isWatchMode: true,
101
+ traceCache: this.traceCache,
102
+ traceToolAvailability: this.traceToolAvailability,
103
+ });
104
+ return result.ok ? { ...result, full: true } : result;
105
+ }
106
+
107
+ private async compile(
62
108
  snapshot: TadaSnapshot | undefined,
63
- batch?: ChangeBatch,
64
- ): Promise<CompilerBuildResult> {
65
- if (batch) {
66
- invalidateTraceCacheForBatch(this.traceCache, batch);
109
+ paths?: ReadonlySet<string>,
110
+ ): Promise<CompileOutcome> {
111
+ if (paths) {
112
+ invalidateTraceCacheForBatch(this.traceCache, paths);
67
113
  }
68
-
69
- if (!snapshot || !batch) {
70
- return buildFull({
71
- traceCache: this.traceCache,
72
- traceOptions: this.traceOptions,
73
- });
114
+ const configKinds = new Set(
115
+ [...(paths ?? [])].map(classifyWatchConfigPath),
116
+ );
117
+ if (
118
+ !snapshot ||
119
+ !paths ||
120
+ configKinds.has('site') ||
121
+ configKinds.has('nav')
122
+ ) {
123
+ return this.buildFull();
74
124
  }
75
125
 
76
- const scan = updateProjectScan(snapshot.scan, batch);
77
- const plan = createTadaWatchPlan({ snapshot, batch, scan });
126
+ const authorsChanged = configKinds.has('authors');
127
+ const nextAuthors = authorsChanged
128
+ ? loadProjectConfig(
129
+ getProjectConfigDir(),
130
+ 'authors',
131
+ snapshot.siteVariables,
132
+ )?.value
133
+ : snapshot.authorsData;
134
+ // Every build rescans the source directories instead of reconciling
135
+ // individual file and directory events.
136
+ const scan = scanProject(snapshot.siteVariables);
137
+ const plan = createTadaWatchPlan({
138
+ snapshot,
139
+ paths,
140
+ scan,
141
+ full:
142
+ authorsChanged &&
143
+ (snapshot.authorsData === undefined || nextAuthors === undefined),
144
+ changedAuthors: authorsChanged
145
+ ? diffAuthorKeys(snapshot.authorsData, nextAuthors)
146
+ : new Set(),
147
+ });
78
148
  if (plan.kind === 'full') {
79
- return buildFull({
80
- traceCache: this.traceCache,
81
- traceOptions: this.traceOptions,
82
- });
149
+ // Only the authors file changed, so the site config and scan still hold.
150
+ return this.buildFull({ siteVariables: snapshot.siteVariables, scan });
83
151
  }
84
152
 
85
153
  return buildIncremental({
86
154
  plan,
87
155
  snapshot,
88
156
  traceCache: this.traceCache,
89
- traceOptions: this.traceOptions,
157
+ traceToolAvailability: this.traceToolAvailability,
90
158
  });
91
159
  }
92
160
  }
161
+
162
+ /**
163
+ * Writes a successful build into `dist/` in place. Full builds compare
164
+ * against the files on disk; incremental builds against the previous build.
165
+ */
166
+ function writeBuild(
167
+ outcome: Extract<CompileOutcome, { ok: true }>,
168
+ previous: TadaSnapshot | undefined,
169
+ distDir: string = getDistDir(),
170
+ ): void {
171
+ const keep = getKeptOutputPrefixes(outcome.snapshot.siteVariables);
172
+ applyMutations(
173
+ distDir,
174
+ outcome.full || !previous
175
+ ? planFullWrite(distDir, outcome.snapshot.outputs, keep)
176
+ : computeMutations(
177
+ previous.outputs,
178
+ outcome.snapshot.outputs,
179
+ outcome.forceSourcePaths,
180
+ ),
181
+ );
182
+ }
183
+
184
+ export function createBuildSession(
185
+ compile: (
186
+ snapshot: TadaSnapshot | undefined,
187
+ paths?: ReadonlySet<string>,
188
+ ) => Promise<CompileOutcome>,
189
+ write: typeof writeBuild = writeBuild,
190
+ ): (paths?: ReadonlySet<string>) => Promise<WatchBuildResult<TadaBuildMeta>> {
191
+ let snapshot: TadaSnapshot | undefined;
192
+ return async (
193
+ paths?: ReadonlySet<string>,
194
+ ): Promise<WatchBuildResult<TadaBuildMeta>> => {
195
+ const outcome = await compile(snapshot, paths);
196
+ if (!outcome.ok) {
197
+ return outcome;
198
+ }
199
+ try {
200
+ write(outcome, snapshot);
201
+ } catch (error) {
202
+ // The next change rebuilds everything against what is on disk.
203
+ snapshot = undefined;
204
+ const message = error instanceof Error ? error.message : String(error);
205
+ return {
206
+ ok: false,
207
+ diagnostics: [{ message: `Failed to write output: ${message}` }],
208
+ };
209
+ }
210
+ snapshot = outcome.snapshot;
211
+ return { ok: true, meta: createBuildMeta(outcome.snapshot) };
212
+ };
213
+ }
@@ -1,213 +1,244 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
1
3
  import chokidar from 'chokidar';
2
- import { applyCommitPlan } from './fs-commit';
3
4
  import type {
4
- ChangeBatch,
5
- CompilerBuildResult,
5
+ WatchBuildResult,
6
+ WatchClock,
6
7
  WatchEngineOptions,
7
- WatchEventKind,
8
+ WatchHandle,
8
9
  WatchLifecycleEvent,
9
10
  } from './types';
10
- import type { TadaSnapshot } from './snapshot';
11
11
 
12
- function normalizeBatch(changes: Map<string, WatchEventKind>): ChangeBatch {
13
- return {
14
- changes: [...changes.entries()]
15
- .sort(([a], [b]) => a.localeCompare(b))
16
- .map(([path, kind]) => ({ path, kind })),
17
- };
18
- }
19
-
20
- function mapBatch(batch: ChangeBatch): Map<string, WatchEventKind> {
21
- return new Map(batch.changes.map(change => [change.path, change.kind]));
22
- }
23
-
24
- function mergeChangeKind(
25
- previous: WatchEventKind | undefined,
26
- next: WatchEventKind,
27
- ): WatchEventKind {
28
- if (!previous) {
29
- return next;
30
- }
31
- if (previous === 'add' && next === 'unlink') {
32
- return 'change';
33
- }
34
- if (previous === 'unlink' && next === 'add') {
35
- return 'change';
36
- }
37
- if (previous === 'add' || next === 'add') {
38
- return 'add';
39
- }
40
- if (previous === 'unlink' || next === 'unlink') {
41
- return 'unlink';
12
+ export function runWatchEngine<Meta>(
13
+ options: WatchEngineOptions<Meta>,
14
+ dependencies = {
15
+ watch: chokidar.watch,
16
+ stat: (filePath: string): Pick<fs.Stats, 'isFile'> | undefined =>
17
+ fs.statSync(filePath, { throwIfNoEntry: false }),
18
+ clock: { setTimeout, clearTimeout } as WatchClock,
19
+ },
20
+ ): WatchHandle {
21
+ const { clock } = dependencies;
22
+ const debounceMs = options.debounceMs ?? 300;
23
+ const watchers: ReturnType<typeof chokidar.watch>[] = [];
24
+ const pending = new Set<string>();
25
+ let uncommitted = new Set<string>();
26
+ let closed = false;
27
+ let fatal: { error: unknown } | undefined;
28
+ let closing: Promise<PromiseSettledResult<void>[]> | undefined;
29
+ let timer: ReturnType<typeof setTimeout> | undefined;
30
+ let wake: (() => void) | undefined;
31
+ const stopped = Promise.withResolvers<void>();
32
+
33
+ function stop(): void {
34
+ closed = true;
35
+ pending.clear();
36
+ if (timer !== undefined) {
37
+ clock.clearTimeout(timer);
38
+ }
39
+ timer = undefined;
40
+ wake?.();
41
+ stopped.resolve();
42
+ closing ??= Promise.allSettled(
43
+ watchers.map(async watcher => watcher.close()),
44
+ );
42
45
  }
43
- return 'change';
44
- }
45
46
 
46
- function toDiagnostics(error: unknown): { message: string }[] {
47
- if (error instanceof Error) {
48
- return [{ message: error.message }];
47
+ function fail(error: unknown): void {
48
+ fatal ??= { error };
49
+ stop();
49
50
  }
50
- return [{ message: String(error) }];
51
- }
52
51
 
53
- export async function runWatchEngine(
54
- options: WatchEngineOptions,
55
- dependencies = { watch: chokidar.watch, applyCommitPlan },
56
- ): Promise<void> {
57
- const debounceMs = options.debounceMs ?? 300;
58
- let snapshot: TadaSnapshot | undefined;
59
- let uncommitted = new Map<string, WatchEventKind>();
60
- let pending = new Map<string, WatchEventKind>();
61
- let timer: ReturnType<typeof setTimeout> | null = null;
62
- let running = false;
52
+ function wait(quiet = false): Promise<void> {
53
+ if (closed) {
54
+ return Promise.resolve();
55
+ }
56
+ return new Promise(resolve => {
57
+ wake = () => {
58
+ wake = undefined;
59
+ timer = undefined;
60
+ resolve();
61
+ };
62
+ if (quiet) {
63
+ timer = clock.setTimeout(() => wake?.(), debounceMs);
64
+ }
65
+ });
66
+ }
63
67
 
64
- async function emit(event: WatchLifecycleEvent): Promise<void> {
65
- await options.onEvent?.(event);
68
+ function changed(filePath: string): void {
69
+ if (closed) {
70
+ return;
71
+ }
72
+ pending.add(path.resolve(filePath));
73
+ if (timer !== undefined) {
74
+ clock.clearTimeout(timer);
75
+ timer = clock.setTimeout(() => wake?.(), debounceMs);
76
+ } else {
77
+ wake?.();
78
+ }
66
79
  }
67
80
 
68
- async function waitForQuietPeriod(): Promise<void> {
69
- await new Promise(resolve => setTimeout(resolve, debounceMs));
81
+ async function emit(event: WatchLifecycleEvent<Meta>): Promise<void> {
82
+ if (!closed) {
83
+ await options.onEvent?.(event);
84
+ }
70
85
  }
71
86
 
72
- async function runBuild(batch?: ChangeBatch): Promise<CompilerBuildResult> {
73
- await emit({ kind: 'build-started', batch });
87
+ async function build(
88
+ paths?: ReadonlySet<string>,
89
+ ): Promise<WatchBuildResult<Meta> | undefined> {
90
+ await emit({ kind: 'build-started', paths });
91
+ if (closed) {
92
+ return;
93
+ }
94
+ let outcome: WatchBuildResult<Meta>;
74
95
  try {
75
- const outcome = await options.compiler.build(snapshot, batch);
76
- if (outcome.ok) {
77
- try {
78
- dependencies.applyCommitPlan(outcome.commit);
79
- } catch (error) {
80
- // The compiler may have mutated cached state before publication failed.
81
- // Rebuild from sources on the next change instead of reusing that state.
82
- snapshot = undefined;
83
- return {
84
- ok: false,
85
- diagnostics: toDiagnostics(error).map(diagnostic => ({
86
- message: `Failed to publish build output: ${diagnostic.message}`,
87
- })),
88
- };
89
- }
90
- snapshot = outcome.snapshot;
91
- }
92
- return outcome;
96
+ outcome = await options.build(paths);
93
97
  } catch (error) {
94
- return { ok: false, diagnostics: toDiagnostics(error) };
98
+ outcome = {
99
+ ok: false,
100
+ diagnostics: [
101
+ { message: error instanceof Error ? error.message : String(error) },
102
+ ],
103
+ };
95
104
  }
105
+ if (!outcome.ok) {
106
+ await emit({
107
+ kind: 'build-failed',
108
+ paths,
109
+ diagnostics: outcome.diagnostics,
110
+ });
111
+ }
112
+ return outcome;
96
113
  }
97
114
 
98
- async function flush(): Promise<void> {
99
- if (running) {
115
+ async function run(): Promise<void> {
116
+ if (closed) {
100
117
  return;
101
118
  }
102
- running = true;
103
- try {
104
- let finalSuccess:
105
- | {
106
- batch?: ChangeBatch;
107
- result: Extract<CompilerBuildResult, { ok: true }>;
119
+ const ready = options.targets.map(target => {
120
+ if (closed) {
121
+ return Promise.resolve();
122
+ }
123
+ const watcher = dependencies.watch(target.path, {
124
+ ignoreInitial: true,
125
+ atomic: true,
126
+ awaitWriteFinish: { stabilityThreshold: 100 },
127
+ ...target.chokidar,
128
+ });
129
+ watchers.push(watcher);
130
+ const included = (filePath: string) => {
131
+ if (closed) {
132
+ return;
133
+ }
134
+ try {
135
+ if (!target.filter || target.filter(filePath)) {
136
+ changed(filePath);
108
137
  }
109
- | undefined;
110
-
111
- while (pending.size > 0) {
112
- const changes = new Map(uncommitted);
113
- for (const [filePath, kind] of pending) {
114
- changes.set(filePath, mergeChangeKind(changes.get(filePath), kind));
138
+ } catch (error) {
139
+ fail(error);
115
140
  }
116
-
117
- const batch = normalizeBatch(changes);
118
- pending = new Map();
119
- const outcome = await runBuild(batch);
120
-
121
- if (!outcome.ok) {
122
- uncommitted = mapBatch(batch);
123
- await emit({
124
- kind: 'build-failed',
125
- batch,
126
- diagnostics: outcome.diagnostics,
127
- });
141
+ };
142
+ watcher.on('add', included).on('unlink', included);
143
+ watcher.on('change', (filePath, stats) => {
144
+ if (closed) {
128
145
  return;
129
146
  }
130
-
131
- uncommitted = new Map();
132
- finalSuccess = { batch, result: outcome };
133
-
134
- if (pending.size === 0) {
135
- await waitForQuietPeriod();
147
+ // A polling file subscription does not become recursive on its own.
148
+ try {
149
+ if (stats?.isDirectory()) {
150
+ watcher.add(filePath);
151
+ }
152
+ included(filePath);
153
+ } catch (error) {
154
+ fail(error);
136
155
  }
137
- }
138
-
139
- if (finalSuccess) {
140
- await emit({
141
- kind: 'build-succeeded',
142
- batch: finalSuccess.batch,
143
- meta: finalSuccess.result.meta,
156
+ });
157
+ watcher.on('addDir', included);
158
+ watcher.on('unlinkDir', filePath => {
159
+ included(filePath);
160
+ // Wait until Chokidar finishes closing the old directory subscription.
161
+ queueMicrotask(() => {
162
+ if (closed) {
163
+ return;
164
+ }
165
+ try {
166
+ if (dependencies.stat(filePath)?.isFile()) {
167
+ watcher.add(filePath);
168
+ }
169
+ } catch (error) {
170
+ if ((error as NodeJS.ErrnoException).code !== 'ENOTDIR') {
171
+ fail(error);
172
+ }
173
+ }
144
174
  });
175
+ });
176
+ watcher.on('error', fail);
177
+ return new Promise<void>(resolve => watcher.once('ready', resolve));
178
+ });
179
+ await Promise.race([Promise.all(ready), stopped.promise]);
180
+ if (closed) {
181
+ return;
182
+ }
183
+ const startup = await build();
184
+ if (startup?.ok) {
185
+ await emit({ kind: 'build-succeeded', meta: startup.meta });
186
+ }
187
+ await emit({ kind: 'watching' });
188
+ while (!closed) {
189
+ if (!pending.size) {
190
+ await wait();
145
191
  }
146
- } finally {
147
- running = false;
148
- if (pending.size > 0) {
149
- schedule();
192
+ if (closed) {
193
+ break;
194
+ }
195
+ await wait(true);
196
+ let success: { paths: ReadonlySet<string>; meta: Meta } | undefined;
197
+ while (!closed && pending.size) {
198
+ const paths = new Set([...uncommitted, ...pending]);
199
+ pending.clear();
200
+ const outcome = await build(paths);
201
+ if (!outcome?.ok) {
202
+ uncommitted = paths;
203
+ success = undefined;
204
+ break;
205
+ }
206
+ uncommitted.clear();
207
+ success = { paths, meta: outcome.meta };
208
+ if (!pending.size) {
209
+ await wait(true);
210
+ }
211
+ }
212
+ if (success) {
213
+ await emit({ kind: 'build-succeeded', ...success });
150
214
  }
151
215
  }
152
216
  }
153
217
 
154
- function schedule(): void {
155
- if (timer) {
156
- clearTimeout(timer);
218
+ const done = (async () => {
219
+ // Defer setup so callers can install their completion handler first.
220
+ await Promise.resolve();
221
+ try {
222
+ await run();
223
+ } catch (error) {
224
+ fatal ??= { error };
225
+ } finally {
226
+ stop();
227
+ const results = await closing!;
228
+ const failure = results.find(result => result.status === 'rejected');
229
+ if (failure?.status === 'rejected') {
230
+ fatal ??= { error: failure.reason };
231
+ }
157
232
  }
158
- timer = setTimeout(() => {
159
- timer = null;
160
- void flush();
161
- }, debounceMs);
162
- }
163
-
164
- function onFileChange(filePath: string, kind: WatchEventKind): void {
165
- pending.set(filePath, mergeChangeKind(pending.get(filePath), kind));
166
- schedule();
167
- }
168
-
169
- const startupOutcome = await runBuild();
170
- if (!startupOutcome.ok) {
171
- await emit({
172
- kind: 'build-failed',
173
- diagnostics: startupOutcome.diagnostics,
174
- });
175
- } else {
176
- await emit({ kind: 'build-succeeded', meta: startupOutcome.meta });
177
- }
178
-
179
- const watcherEntries = options.compiler
180
- .getWatchTargets()
181
- .map(target => ({
182
- target,
183
- watcher: dependencies.watch(target.path, {
184
- ignoreInitial: true,
185
- atomic: true,
186
- awaitWriteFinish: { stabilityThreshold: 100 },
187
- ...target.chokidar,
188
- }),
189
- }));
190
-
191
- let readyCount = 0;
192
- const readyGoal = watcherEntries.length;
193
- const onReady = async () => {
194
- readyCount += 1;
195
- if (readyCount === readyGoal) {
196
- await emit({ kind: 'watching' });
233
+ if (fatal) {
234
+ throw fatal.error;
197
235
  }
236
+ })();
237
+ return {
238
+ done,
239
+ close: () => {
240
+ stop();
241
+ return done;
242
+ },
198
243
  };
199
-
200
- for (const { watcher, target } of watcherEntries) {
201
- const emitIfIncluded = (filePath: string, kind: WatchEventKind) => {
202
- if (!target.filter || target.filter(filePath)) {
203
- onFileChange(filePath, kind);
204
- }
205
- };
206
- watcher.on('add', filePath => emitIfIncluded(filePath, 'add'));
207
- watcher.on('change', filePath => emitIfIncluded(filePath, 'change'));
208
- watcher.on('unlink', filePath => emitIfIncluded(filePath, 'unlink'));
209
- watcher.on('ready', () => void onReady());
210
- }
211
-
212
- return new Promise(() => {});
213
244
  }
@@ -9,9 +9,16 @@ export async function runWatch(options: { httpPort?: number }): Promise<void> {
9
9
  distDir: getDistDir(),
10
10
  });
11
11
 
12
- await runWatchEngine({
13
- compiler: new TadaWatchCompiler(),
12
+ const compiler = new TadaWatchCompiler();
13
+ const handle = runWatchEngine({
14
+ targets: compiler.getWatchTargets(),
15
+ build: paths => compiler.build(paths),
14
16
  onEvent: event => runtime.onEvent(event),
15
17
  debounceMs: 300,
16
18
  });
19
+ try {
20
+ await handle.done;
21
+ } finally {
22
+ runtime.close();
23
+ }
17
24
  }