@abreen/tada 1.13.4 → 1.13.9

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.
@@ -0,0 +1,199 @@
1
+ import chokidar from 'chokidar';
2
+ import { applyCommitPlan } from './fs-commit';
3
+ import type {
4
+ ChangeBatch,
5
+ CompilerBuildResult,
6
+ WatchEngineOptions,
7
+ WatchEventKind,
8
+ WatchLifecycleEvent,
9
+ } from './types';
10
+ import type { TadaSnapshot } from './snapshot';
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';
42
+ }
43
+ return 'change';
44
+ }
45
+
46
+ function toDiagnostics(error: unknown): { message: string }[] {
47
+ if (error instanceof Error) {
48
+ return [{ message: error.message }];
49
+ }
50
+ return [{ message: String(error) }];
51
+ }
52
+
53
+ export async function runWatchEngine(
54
+ options: WatchEngineOptions,
55
+ ): Promise<void> {
56
+ const debounceMs = options.debounceMs ?? 300;
57
+ let snapshot: TadaSnapshot | undefined;
58
+ let uncommitted = new Map<string, WatchEventKind>();
59
+ let pending = new Map<string, WatchEventKind>();
60
+ let timer: ReturnType<typeof setTimeout> | null = null;
61
+ let running = false;
62
+
63
+ async function emit(event: WatchLifecycleEvent): Promise<void> {
64
+ await options.onEvent?.(event);
65
+ }
66
+
67
+ async function waitForQuietPeriod(): Promise<void> {
68
+ await new Promise(resolve => setTimeout(resolve, debounceMs));
69
+ }
70
+
71
+ async function runBuild(batch?: ChangeBatch): Promise<CompilerBuildResult> {
72
+ await emit({ kind: 'build-started', batch });
73
+ try {
74
+ return await options.compiler.build(snapshot, batch);
75
+ } catch (error) {
76
+ return { ok: false, diagnostics: toDiagnostics(error) };
77
+ }
78
+ }
79
+
80
+ async function flush(): Promise<void> {
81
+ if (running) {
82
+ return;
83
+ }
84
+ running = true;
85
+ try {
86
+ let finalSuccess:
87
+ | {
88
+ batch?: ChangeBatch;
89
+ result: Extract<CompilerBuildResult, { ok: true }>;
90
+ }
91
+ | undefined;
92
+
93
+ while (pending.size > 0) {
94
+ const changes = new Map(uncommitted);
95
+ for (const [filePath, kind] of pending) {
96
+ changes.set(filePath, mergeChangeKind(changes.get(filePath), kind));
97
+ }
98
+
99
+ const batch = normalizeBatch(changes);
100
+ pending = new Map();
101
+ const outcome = await runBuild(batch);
102
+
103
+ if (!outcome.ok) {
104
+ uncommitted = mapBatch(batch);
105
+ await emit({
106
+ kind: 'build-failed',
107
+ batch,
108
+ diagnostics: outcome.diagnostics,
109
+ });
110
+ return;
111
+ }
112
+
113
+ applyCommitPlan(outcome.commit);
114
+ snapshot = outcome.snapshot;
115
+ uncommitted = new Map();
116
+ finalSuccess = { batch, result: outcome };
117
+
118
+ if (pending.size === 0) {
119
+ await waitForQuietPeriod();
120
+ }
121
+ }
122
+
123
+ if (finalSuccess) {
124
+ await emit({
125
+ kind: 'build-succeeded',
126
+ batch: finalSuccess.batch,
127
+ meta: finalSuccess.result.meta,
128
+ });
129
+ }
130
+ } finally {
131
+ running = false;
132
+ if (pending.size > 0) {
133
+ schedule();
134
+ }
135
+ }
136
+ }
137
+
138
+ function schedule(): void {
139
+ if (timer) {
140
+ clearTimeout(timer);
141
+ }
142
+ timer = setTimeout(() => {
143
+ timer = null;
144
+ void flush();
145
+ }, debounceMs);
146
+ }
147
+
148
+ function onFileChange(filePath: string, kind: WatchEventKind): void {
149
+ pending.set(filePath, mergeChangeKind(pending.get(filePath), kind));
150
+ schedule();
151
+ }
152
+
153
+ const startupOutcome = await runBuild();
154
+ if (!startupOutcome.ok) {
155
+ await emit({
156
+ kind: 'build-failed',
157
+ diagnostics: startupOutcome.diagnostics,
158
+ });
159
+ } else {
160
+ applyCommitPlan(startupOutcome.commit);
161
+ snapshot = startupOutcome.snapshot;
162
+ await emit({ kind: 'build-succeeded', meta: startupOutcome.meta });
163
+ }
164
+
165
+ const watcherEntries = options.compiler
166
+ .getWatchTargets()
167
+ .map(target => ({
168
+ target,
169
+ watcher: chokidar.watch(target.path, {
170
+ ignoreInitial: true,
171
+ atomic: true,
172
+ awaitWriteFinish: { stabilityThreshold: 100 },
173
+ ...target.chokidar,
174
+ }),
175
+ }));
176
+
177
+ let readyCount = 0;
178
+ const readyGoal = watcherEntries.length;
179
+ const onReady = async () => {
180
+ readyCount += 1;
181
+ if (readyCount === readyGoal) {
182
+ await emit({ kind: 'watching' });
183
+ }
184
+ };
185
+
186
+ for (const { watcher, target } of watcherEntries) {
187
+ const emitIfIncluded = (filePath: string, kind: WatchEventKind) => {
188
+ if (!target.filter || target.filter(filePath)) {
189
+ onFileChange(filePath, kind);
190
+ }
191
+ };
192
+ watcher.on('add', filePath => emitIfIncluded(filePath, 'add'));
193
+ watcher.on('change', filePath => emitIfIncluded(filePath, 'change'));
194
+ watcher.on('unlink', filePath => emitIfIncluded(filePath, 'unlink'));
195
+ watcher.on('ready', () => void onReady());
196
+ }
197
+
198
+ return new Promise(() => {});
199
+ }
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { globals, type Globals } from '../build/globals';
3
+ import { globals, type Globals } from '../globals';
4
4
  import type {
5
5
  ApplyMutationsCommitPlan,
6
6
  CommitPlan,
@@ -87,8 +87,6 @@ function applyFileMutation(
87
87
  const stagedPath = path.join(stagedRoot, mutation.path);
88
88
 
89
89
  if (mutation.kind === 'write') {
90
- ensureParentDir(stagedPath);
91
- fs.writeFileSync(stagedPath, mutation.content);
92
90
  ensureParentDir(targetPath);
93
91
  renameWithRetry(stagedPath, targetPath, globals);
94
92
  return;
@@ -1,5 +1,5 @@
1
1
  import { getDistDir } from '../util';
2
- import { runWatchEngine } from '../../watch/engine';
2
+ import { runWatchEngine } from './engine';
3
3
  import { TadaWatchCompiler } from './compiler';
4
4
  import { TadaWatchRuntime } from './runtime';
5
5
 
@@ -1,4 +1,5 @@
1
- import type { TadaSnapshot } from './snapshot';
1
+ import type { FileMutation } from './types';
2
+ import type { TadaOutputOwner, TadaSnapshot } from './snapshot';
2
3
 
3
4
  function outputsEqual(
4
5
  left: string | Buffer | undefined,
@@ -15,16 +16,25 @@ function outputsEqual(
15
16
  return Buffer.compare(leftBuffer, rightBuffer) === 0;
16
17
  }
17
18
 
19
+ function getOutputContent(
20
+ snapshot: TadaSnapshot,
21
+ owner: TadaOutputOwner | undefined,
22
+ outputPath: string,
23
+ ): string | Buffer | undefined {
24
+ if (!owner) {
25
+ return undefined;
26
+ }
27
+ const records =
28
+ owner.kind === 'content' ? snapshot.contentRecords : snapshot.publicRecords;
29
+ return records.get(owner.sourcePath)?.outputs.get(outputPath);
30
+ }
31
+
18
32
  export function computeMutations(
19
33
  previous: TadaSnapshot,
20
34
  next: TadaSnapshot,
21
35
  forceSourcePaths: Set<string> = new Set(),
22
- ): { path: string; kind: 'write' | 'delete'; content?: string | Buffer }[] {
23
- const mutations: {
24
- path: string;
25
- kind: 'write' | 'delete';
26
- content?: string | Buffer;
27
- }[] = [];
36
+ ): FileMutation[] {
37
+ const mutations: FileMutation[] = [];
28
38
  const allPaths = new Set([
29
39
  ...previous.outputOwners.keys(),
30
40
  ...next.outputOwners.keys(),
@@ -34,24 +44,12 @@ export function computeMutations(
34
44
  const previousOwner = previous.outputOwners.get(outputPath);
35
45
  const nextOwner = next.outputOwners.get(outputPath);
36
46
 
37
- const previousContent =
38
- previousOwner?.kind === 'content'
39
- ? previous.contentRecords
40
- .get(previousOwner.sourcePath)
41
- ?.outputs.get(outputPath)
42
- : previousOwner
43
- ? previous.publicRecords
44
- .get(previousOwner.sourcePath)
45
- ?.outputs.get(outputPath)
46
- : undefined;
47
- const nextContent =
48
- nextOwner?.kind === 'content'
49
- ? next.contentRecords.get(nextOwner.sourcePath)?.outputs.get(outputPath)
50
- : nextOwner
51
- ? next.publicRecords
52
- .get(nextOwner.sourcePath)
53
- ?.outputs.get(outputPath)
54
- : undefined;
47
+ const previousContent = getOutputContent(
48
+ previous,
49
+ previousOwner,
50
+ outputPath,
51
+ );
52
+ const nextContent = getOutputContent(next, nextOwner, outputPath);
55
53
 
56
54
  if (!nextOwner) {
57
55
  mutations.push({ path: outputPath, kind: 'delete' });
@@ -1,16 +1,12 @@
1
1
  import path from 'path';
2
- import {
3
- getProjectConfigBaseName,
4
- getSiteConfigBaseName,
5
- getSupportedConfigFilePaths,
6
- } from '../config-files';
7
2
  import { loadProjectConfig } from '../config-loader';
8
3
  import { getProjectConfigDir } from '../templates';
9
- import type { ChangeBatch } from '../../watch/types';
4
+ import type { ChangeBatch } from './types';
10
5
  import type { TadaProjectScan, TadaSnapshot } from './snapshot';
6
+ import { classifyWatchConfigPath } from './config-paths';
11
7
 
12
- export interface TadaWatchPlan {
13
- kind: 'full' | 'incremental';
8
+ export interface TadaIncrementalWatchPlan {
9
+ kind: 'incremental';
14
10
  scan: TadaProjectScan;
15
11
  contentToRender: Set<string>;
16
12
  publicToRender: Set<string>;
@@ -18,6 +14,8 @@ export interface TadaWatchPlan {
18
14
  publicToRemove: Set<string>;
19
15
  }
20
16
 
17
+ export type TadaWatchPlan = { kind: 'full' } | TadaIncrementalWatchPlan;
18
+
21
19
  export function diffAuthorKeys(previous: unknown, next: unknown): Set<string> {
22
20
  const previousMap =
23
21
  previous && typeof previous === 'object'
@@ -60,7 +58,7 @@ export function createTadaWatchPlan({
60
58
  batch,
61
59
  scan,
62
60
  }: {
63
- snapshot: TadaSnapshot | undefined;
61
+ snapshot: TadaSnapshot;
64
62
  batch: ChangeBatch;
65
63
  scan: TadaProjectScan;
66
64
  }): TadaWatchPlan {
@@ -69,66 +67,24 @@ export function createTadaWatchPlan({
69
67
  const contentToRemove = new Set<string>();
70
68
  const publicToRemove = new Set<string>();
71
69
  const projectConfigDir = getProjectConfigDir();
72
- const siteConfigPaths = new Set(
73
- getSupportedConfigFilePaths('.', getSiteConfigBaseName('dev')).map(
74
- filePath => path.resolve(filePath),
75
- ),
76
- );
77
- const navPaths = new Set(
78
- getSupportedConfigFilePaths(
79
- projectConfigDir,
80
- getProjectConfigBaseName('nav'),
81
- ).map(filePath => path.resolve(filePath)),
82
- );
83
- const authorsPaths = new Set(
84
- getSupportedConfigFilePaths(
85
- projectConfigDir,
86
- getProjectConfigBaseName('authors'),
87
- ).map(filePath => path.resolve(filePath)),
88
- );
89
-
90
- if (!snapshot) {
91
- return {
92
- kind: 'full',
93
- scan,
94
- contentToRender,
95
- publicToRender,
96
- contentToRemove,
97
- publicToRemove,
98
- };
99
- }
100
70
 
101
71
  for (const change of batch.changes) {
102
- const resolvedPath = path.resolve(change.path);
103
- if (siteConfigPaths.has(resolvedPath) || navPaths.has(resolvedPath)) {
104
- return {
105
- kind: 'full',
106
- scan,
107
- contentToRender,
108
- publicToRender,
109
- contentToRemove,
110
- publicToRemove,
111
- };
72
+ const configKind = classifyWatchConfigPath(change.path);
73
+ if (configKind === 'site' || configKind === 'nav') {
74
+ return { kind: 'full' };
112
75
  }
113
76
  }
114
77
 
115
78
  for (const change of batch.changes) {
116
79
  const resolvedPath = path.resolve(change.path);
117
- if (authorsPaths.has(resolvedPath)) {
80
+ if (classifyWatchConfigPath(resolvedPath) === 'authors') {
118
81
  const nextAuthorsData = loadProjectConfig(
119
82
  projectConfigDir,
120
83
  'authors',
121
84
  snapshot.siteVariables,
122
85
  )?.value;
123
86
  if (snapshot.authorsData === undefined || nextAuthorsData === undefined) {
124
- return {
125
- kind: 'full',
126
- scan,
127
- contentToRender,
128
- publicToRender,
129
- contentToRemove,
130
- publicToRemove,
131
- };
87
+ return { kind: 'full' };
132
88
  }
133
89
  const changedAuthorKeys = diffAuthorKeys(
134
90
  snapshot.authorsData,
@@ -144,17 +100,6 @@ export function createTadaWatchPlan({
144
100
  contentToRender.add(resolvedPath);
145
101
  } else if (snapshot.contentRecords.has(resolvedPath)) {
146
102
  contentToRemove.add(resolvedPath);
147
- const previousRecord = snapshot.contentRecords.get(resolvedPath)!;
148
- for (const partialPath of previousRecord.partialDeps) {
149
- addDependents(
150
- contentToRender,
151
- snapshot.reversePartialDeps,
152
- partialPath,
153
- );
154
- }
155
- for (const tracePath of previousRecord.traceDeps) {
156
- addDependents(contentToRender, snapshot.reverseTraceDeps, tracePath);
157
- }
158
103
  }
159
104
 
160
105
  if (scan.publicFiles.has(resolvedPath)) {
@@ -168,15 +113,13 @@ export function createTadaWatchPlan({
168
113
  }
169
114
 
170
115
  const changedTargets = new Set<string>();
171
- const removedTargets = new Set<string>();
172
- for (const target of snapshot.validTargets) {
116
+ for (const target of snapshot.scan.validTargets) {
173
117
  if (!scan.validTargets.has(target)) {
174
118
  changedTargets.add(target);
175
- removedTargets.add(target);
176
119
  }
177
120
  }
178
121
  for (const target of scan.validTargets) {
179
- if (!snapshot.validTargets.has(target)) {
122
+ if (!snapshot.scan.validTargets.has(target)) {
180
123
  changedTargets.add(target);
181
124
  }
182
125
  }
@@ -184,13 +127,6 @@ export function createTadaWatchPlan({
184
127
  addDependents(contentToRender, snapshot.reverseInternalTargetDeps, target);
185
128
  }
186
129
 
187
- if (removedTargets.size > 0 && contentToRender.size === 0) {
188
- const rootOwner = snapshot.outputOwners.get('index.html');
189
- if (rootOwner?.kind === 'content') {
190
- contentToRender.add(rootOwner.sourcePath);
191
- }
192
- }
193
-
194
130
  for (const [outputPath, owner] of snapshot.outputOwners) {
195
131
  const nextContentOwner = scan.contentOwners.get(outputPath);
196
132
  const nextPublicOwner = scan.publicOwners.get(outputPath);
@@ -3,9 +3,7 @@ import { B } from '../colors';
3
3
  import { makeLogger, printFlair } from '../log';
4
4
  import { startServer } from '../serve';
5
5
  import { WatchPagefindRunner } from '../pagefind';
6
- import type { TadaBuildMeta } from './compiler-types';
7
- import type { TadaSnapshot } from './snapshot';
8
- import type { WatchLifecycleEvent } from '../../watch/types';
6
+ import type { WatchLifecycleEvent } from './types';
9
7
  import {
10
8
  WATCH_RELOAD_MESSAGE_REBUILDING,
11
9
  WATCH_RELOAD_MESSAGE_RELOAD,
@@ -30,7 +28,6 @@ export class TadaWatchRuntime {
30
28
  private httpPort: number | undefined;
31
29
  private distDir: string;
32
30
  private server: Bun.Server<undefined> | null;
33
- private lastBuildFailed: boolean;
34
31
  private pagefindRunner: WatchPagefindRunner | undefined;
35
32
  private pagefindSiteVariablesKey: string | undefined;
36
33
 
@@ -38,7 +35,6 @@ export class TadaWatchRuntime {
38
35
  this.httpPort = httpPort;
39
36
  this.distDir = distDir;
40
37
  this.server = null;
41
- this.lastBuildFailed = false;
42
38
  this.pagefindSiteVariablesKey = undefined;
43
39
  }
44
40
 
@@ -68,51 +64,45 @@ export class TadaWatchRuntime {
68
64
  wslog.debug`WebSocket server listening at ws://localhost:${this.server.port}${WATCH_RELOAD_PATH}`;
69
65
  }
70
66
 
71
- async onEvent(
72
- event: WatchLifecycleEvent<TadaSnapshot, TadaBuildMeta>,
73
- ): Promise<void> {
67
+ async onEvent(event: WatchLifecycleEvent): Promise<void> {
74
68
  switch (event.kind) {
75
69
  case 'build-started':
76
- if (!event.initial) {
77
- for (const change of event.batch?.changes || []) {
78
- log.event`${B`${path.basename(change.path)}`} ${describeChange(change.kind)}`;
79
- }
80
- this.broadcast(WATCH_RELOAD_MESSAGE_REBUILDING);
70
+ if (!event.batch) {
71
+ return;
72
+ }
73
+ for (const change of event.batch.changes) {
74
+ log.event`${B`${path.basename(change.path)}`} ${describeChange(change.kind)}`;
81
75
  }
76
+ this.broadcast(WATCH_RELOAD_MESSAGE_REBUILDING);
82
77
  return;
83
78
  case 'build-succeeded': {
84
- const recoveredFromFailure = this.lastBuildFailed;
85
- this.lastBuildFailed = false;
86
79
  printFlair();
87
80
  this.ensureServerStarted();
88
- if (!event.initial && (event.changed || recoveredFromFailure)) {
81
+ if (event.batch) {
89
82
  this.broadcast(WATCH_RELOAD_MESSAGE_RELOAD);
90
83
  }
91
- if (event.meta && event.changed) {
92
- if (event.meta.siteVariables.features.search !== false) {
93
- const siteVariablesKey = JSON.stringify(event.meta.siteVariables);
94
- if (
95
- !this.pagefindRunner ||
96
- this.pagefindSiteVariablesKey !== siteVariablesKey
97
- ) {
98
- this.pagefindRunner = new WatchPagefindRunner();
99
- this.pagefindSiteVariablesKey = siteVariablesKey;
100
- }
101
- this.pagefindRunner.update(
102
- this.distDir,
103
- event.meta.htmlAssetsByPath,
104
- event.meta.htmlAnalysisByPath,
105
- );
106
- setImmediate(() => this.pagefindRunner!.run());
107
- } else {
108
- this.pagefindRunner = undefined;
109
- this.pagefindSiteVariablesKey = undefined;
84
+ if (event.meta.siteVariables.features.search !== false) {
85
+ const siteVariablesKey = JSON.stringify(event.meta.siteVariables);
86
+ if (
87
+ !this.pagefindRunner ||
88
+ this.pagefindSiteVariablesKey !== siteVariablesKey
89
+ ) {
90
+ this.pagefindRunner = new WatchPagefindRunner();
91
+ this.pagefindSiteVariablesKey = siteVariablesKey;
110
92
  }
93
+ this.pagefindRunner.update(
94
+ this.distDir,
95
+ event.meta.htmlAssetsByPath,
96
+ event.meta.htmlAnalysisByPath,
97
+ );
98
+ setImmediate(() => this.pagefindRunner!.run());
99
+ } else {
100
+ this.pagefindRunner = undefined;
101
+ this.pagefindSiteVariablesKey = undefined;
111
102
  }
112
103
  return;
113
104
  }
114
105
  case 'build-failed':
115
- this.lastBuildFailed = true;
116
106
  for (const diagnostic of event.diagnostics) {
117
107
  log.error`${diagnostic.message}`;
118
108
  }
@@ -120,8 +110,6 @@ export class TadaWatchRuntime {
120
110
  case 'watching':
121
111
  log.info`Watching for changes...`;
122
112
  return;
123
- case 'build-skipped':
124
- return;
125
113
  }
126
114
  }
127
115
  }