@abreen/tada 1.9.0 → 1.10.2

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 (75) hide show
  1. package/README.md +24 -34
  2. package/bin/tada.ts +1 -1
  3. package/build/apply-base-path-plugin.ts +79 -17
  4. package/build/bundle.ts +9 -1
  5. package/build/content-watch.ts +3 -3
  6. package/build/copy.ts +30 -0
  7. package/build/generate-content-assets.ts +45 -59
  8. package/build/globals.ts +1 -1
  9. package/build/pagefind.ts +0 -7
  10. package/build/serve.ts +4 -0
  11. package/build/types.d.ts +19 -1
  12. package/build/util.ts +3 -0
  13. package/build/utils/content-files.ts +98 -0
  14. package/build/utils/include.ts +4 -0
  15. package/build/utils/python-runner/trace_runner.py +272 -0
  16. package/build/utils/render.ts +34 -8
  17. package/build/utils/trace-core.ts +203 -0
  18. package/build/utils/trace-java.ts +132 -0
  19. package/build/utils/trace-layout.ts +31 -3
  20. package/build/utils/trace-python.ts +26 -0
  21. package/build/utils/trace.ts +174 -294
  22. package/build/validate-internal-links-plugin.ts +16 -4
  23. package/build/watch/assets.ts +111 -0
  24. package/build/watch/build-full.ts +124 -0
  25. package/build/watch/build-incremental.ts +139 -0
  26. package/build/watch/compiler-types.d.ts +19 -0
  27. package/build/watch/compiler.ts +112 -0
  28. package/build/watch/index.ts +22 -0
  29. package/build/watch/mutations.ts +75 -0
  30. package/build/watch/planner.ts +230 -0
  31. package/build/watch/records.ts +190 -0
  32. package/build/watch/runtime.ts +143 -0
  33. package/build/watch/snapshot.ts +602 -0
  34. package/build/watch/validation.ts +36 -0
  35. package/build/watch-reload-client.ts +10 -5
  36. package/init/content/labs/01/index.md +6 -0
  37. package/init/content/labs/01/trace_demo.py +16 -0
  38. package/package.json +47 -24
  39. package/python/command.ts +95 -0
  40. package/python/module.ts +18 -0
  41. package/src/_alerts.scss +11 -8
  42. package/src/_base.scss +25 -29
  43. package/src/_layout.scss +17 -14
  44. package/src/_mixins.scss +10 -9
  45. package/src/anchor/style.scss +4 -2
  46. package/src/code/index.ts +9 -2
  47. package/src/code/style.scss +14 -13
  48. package/src/code.scss +14 -13
  49. package/src/header/_base.scss +21 -24
  50. package/src/header/style.scss +16 -12
  51. package/src/index.ts +3 -0
  52. package/src/literate/style.scss +4 -4
  53. package/src/navigate/index.ts +43 -279
  54. package/src/navigate/lifecycle.ts +1 -1
  55. package/src/navigate/runtime.ts +341 -0
  56. package/src/page-update/README.md +4 -0
  57. package/src/page-update/index.ts +280 -0
  58. package/src/page-update/style.scss +53 -0
  59. package/src/print/style.scss +4 -4
  60. package/src/question/style.scss +10 -6
  61. package/src/search/index.ts +9 -12
  62. package/src/search/style.scss +44 -42
  63. package/src/style.scss +63 -58
  64. package/src/timezone/index.ts +13 -3
  65. package/src/timezone/style.scss +11 -5
  66. package/src/toc/style.scss +17 -16
  67. package/src/trace/style.scss +27 -27
  68. package/src/validators.ts +45 -0
  69. package/templates/_footer.html +1 -1
  70. package/templates/_theme.scss +8 -19
  71. package/watch/engine.ts +296 -0
  72. package/watch/events.ts +10 -0
  73. package/watch/fs-commit.ts +102 -0
  74. package/watch/types.d.ts +103 -0
  75. package/build/watch.ts +0 -493
@@ -1,44 +1,44 @@
1
1
  .trace-body {
2
2
  display: flex;
3
3
  flex-direction: column;
4
+ gap: var(--gap);
4
5
  height: calc(100dvh - var(--header-collapsed-height) - 3 * var(--gap));
5
6
  overflow: hidden;
6
- gap: var(--gap);
7
7
  }
8
8
 
9
9
  .trace-content {
10
10
  display: flex;
11
- flex-direction: column;
12
11
  flex: 1;
13
- min-height: 0;
12
+ flex-direction: column;
14
13
  gap: var(--gap);
14
+ min-height: 0;
15
15
  }
16
16
 
17
17
  .trace-content > .trace-diagram {
18
18
  flex: 1 1 100%;
19
19
  min-height: 8rem;
20
+ overflow: auto;
20
21
  border: 1px solid var(--fg2-color);
21
22
  border-radius: var(--border-radius);
22
- overflow: auto;
23
23
  }
24
24
 
25
25
  .trace-source-wrapper {
26
+ position: relative;
26
27
  min-height: calc(
27
28
  3 * var(--font-size-smaller) * var(--mono-line-height) + 1em
28
29
  );
29
30
  max-height: calc(
30
31
  5 * var(--font-size-smaller) * var(--mono-line-height) + 1em
31
32
  );
32
- position: relative;
33
33
 
34
34
  &:has(:focus-visible)::after {
35
- content: '';
36
35
  position: absolute;
37
36
  inset: 0;
37
+ z-index: 1;
38
+ pointer-events: none;
39
+ content: '';
38
40
  border: 2px solid var(--theme-color);
39
41
  border-radius: var(--mono-border-radius);
40
- pointer-events: none;
41
- z-index: 1;
42
42
  }
43
43
 
44
44
  & > .trace-source {
@@ -48,64 +48,64 @@
48
48
 
49
49
  .trace-toolbar {
50
50
  display: flex;
51
+ flex-shrink: 0;
51
52
  align-items: center;
52
53
  justify-content: center;
53
- flex-shrink: 0;
54
54
  padding: 3px;
55
55
  }
56
56
 
57
57
  .trace-controls {
58
58
  display: flex;
59
- align-items: center;
60
59
  gap: 0.5rem;
60
+ align-items: center;
61
+ width: 100%;
62
+ max-width: 386px;
61
63
  padding: 6px;
62
- border-radius: calc(var(--border-radius) + 4px);
63
64
  background: var(--bg2-color);
65
+ border-radius: calc(var(--border-radius) + 4px);
64
66
 
65
67
  &:focus-within {
66
68
  outline: 2px solid var(--fg-color);
67
69
  outline-offset: 1px;
68
70
  }
69
71
 
70
- width: 100%;
71
- max-width: 386px;
72
-
73
- @media (max-width: 410px) {
72
+ @media (width <= 410px) {
74
73
  max-width: none;
75
74
  }
76
75
  }
77
76
 
78
77
  .trace-btn {
79
78
  display: inline-flex;
79
+ flex: 1;
80
80
  align-items: center;
81
81
  justify-content: center;
82
- flex: 1;
83
82
  min-width: 2.5em;
84
83
  padding: 0.5em 0.75em;
85
84
  }
86
85
 
87
86
  .trace-step-counter {
88
- text-align: center;
89
- font-variant-numeric: tabular-nums;
90
87
  font-size: var(--font-size-smaller);
88
+ font-variant-numeric: tabular-nums;
89
+ text-align: center;
91
90
  }
92
91
 
93
92
  .trace-source {
94
93
  --code-gutter-width: 4rem;
94
+
95
95
  overflow: auto;
96
- clip-path: inset(0 round var(--mono-border-radius));
97
96
  scroll-behavior: smooth;
97
+ clip-path: inset(0 round var(--mono-border-radius));
98
98
 
99
99
  &:focus-visible {
100
100
  outline: none;
101
101
  }
102
102
 
103
103
  & > pre {
104
- margin: 0;
105
- padding: 0;
106
- overflow: visible;
107
104
  width: max-content;
108
105
  min-width: 100%;
106
+ padding: 0;
107
+ margin: 0;
108
+ overflow: visible;
109
109
 
110
110
  code {
111
111
  padding-right: var(--gap);
@@ -117,11 +117,11 @@
117
117
  color: var(--fg-color);
118
118
 
119
119
  &::before {
120
- content: '\25B6';
121
120
  position: absolute;
122
- margin-right: 0.25em;
123
121
  left: 0;
122
+ margin-right: 0.25em;
124
123
  line-height: 1.3;
124
+ content: '\25B6';
125
125
  }
126
126
  }
127
127
  }
@@ -133,18 +133,18 @@
133
133
  }
134
134
 
135
135
  .trace-output::before {
136
- content: 'Output';
137
136
  display: block;
138
137
  font-size: var(--font-size-small);
139
138
  font-style: italic;
140
139
  color: var(--fg2-color);
140
+ content: 'Output';
141
141
  }
142
142
 
143
143
  .trace-memory {
144
- font-family: var(--mono-font, monospace);
145
- font-size: var(--font-size-smaller, 15px);
146
144
  max-width: 100%;
147
145
  height: auto;
146
+ font-family: var(--mono-font, monospace);
147
+ font-size: var(--font-size-smaller, 15px);
148
148
 
149
149
  .trace-separator {
150
150
  stroke: var(--fg2-color);
@@ -0,0 +1,45 @@
1
+ export interface ResponseValidators {
2
+ etag: string | null;
3
+ lastModified: string | null;
4
+ }
5
+
6
+ export const EMPTY_RESPONSE_VALIDATORS: ResponseValidators = Object.freeze({
7
+ etag: null,
8
+ lastModified: null,
9
+ });
10
+
11
+ export function getResponseValidators(source: {
12
+ headers: { get(name: string): string | null };
13
+ }): ResponseValidators {
14
+ return {
15
+ etag: source.headers.get('ETag'),
16
+ lastModified: source.headers.get('Last-Modified'),
17
+ };
18
+ }
19
+
20
+ export function hasUsableResponseValidators(
21
+ validators: ResponseValidators,
22
+ ): boolean {
23
+ return validators.etag !== null || validators.lastModified !== null;
24
+ }
25
+
26
+ export function hasResponseValidatorsChanged(
27
+ previous: ResponseValidators,
28
+ current: ResponseValidators,
29
+ ): boolean {
30
+ if (previous.etag !== null && current.etag !== null) {
31
+ return current.etag !== previous.etag;
32
+ }
33
+
34
+ if (previous.lastModified !== null && current.lastModified !== null) {
35
+ return current.lastModified !== previous.lastModified;
36
+ }
37
+
38
+ return false;
39
+ }
40
+
41
+ export function getPreferredValidatorKey(
42
+ validators: ResponseValidators,
43
+ ): string | null {
44
+ return validators.etag ?? validators.lastModified;
45
+ }
@@ -1,5 +1,5 @@
1
1
  <% if (site.features.footer) { %>
2
2
  <footer data-pagefind-ignore>
3
- <p>Generated by <a href="https://github.com/abreen/Tada" target="_blank">Tada</a> <%= tadaVersion %></p>
3
+ <p>Built using <a href="https://github.com/abreen/Tada" target="_blank">Tada</a> <%= tadaVersion %></p>
4
4
  </footer>
5
5
  <% } %>
@@ -6,11 +6,9 @@
6
6
  // Background & foreground color tint
7
7
  --tint-hue: <%= tintHue %>deg;
8
8
  --tint-amount: <%= tintAmount / 100 %>;
9
-
10
9
  --link-active-color: #000;
11
-
12
- --warning-bg-color: hsl(36 95% 70%);
13
- --note-bg-color: hsl(209 95% 75%);
10
+ --warning-bg-color: hsl(36deg 95% 70%);
11
+ --note-bg-color: hsl(209deg 95% 75%);
14
12
 
15
13
  // Main foreground color (the color of most text)
16
14
  --fg-color: hsl(var(--tint-hue) calc(8% * var(--tint-amount)) 8%);
@@ -20,7 +18,6 @@
20
18
 
21
19
  // Secondary foreground color (lighter)
22
20
  --fg2-color: hsl(var(--tint-hue) calc(6% * var(--tint-amount)) 45%);
23
-
24
21
  --link-color: <%= linkColor %>;
25
22
  --link-color-hover: <%= linkColorHover %>;
26
23
 
@@ -35,24 +32,20 @@
35
32
  --bg2-color-translucent: hsl(
36
33
  var(--tint-hue) calc(12% * var(--tint-amount)) 92% / 66%
37
34
  );
38
-
39
35
  --box-shadow: 0 2px 9px
40
36
  hsl(var(--tint-hue) calc(85% * var(--tint-amount)) 10% / 25%);
41
37
  --backdrop-filter: blur(10px);
42
-
43
38
  --font:
44
- 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica,
45
- Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
39
+ 'Inter', -apple-system, 'BlinkMacSystemFont', 'Segoe UI', 'Roboto',
40
+ 'Helvetica', 'Arial', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
41
+ 'Segoe UI Symbol';
46
42
  --heading-font: var(--font);
47
- --mono-font: 'Google Sans Code', Consolas, Courier, monospace;
48
-
43
+ --mono-font: 'Google Sans Code', 'Consolas', 'Courier', monospace;
49
44
  --font-size: 16px;
50
45
  --font-size-smaller: 15px;
51
46
  --font-size-small: 13px;
52
-
53
47
  --font-weight: 400;
54
48
  --line-height: 1.7;
55
-
56
49
  --mono-font-size: 0.94em;
57
50
  --mono-line-height: 1.5;
58
51
  --mono-border-radius: 0.375em;
@@ -77,7 +70,6 @@
77
70
  --theme-color: <%= themeColorDark %>;
78
71
  --theme-color-text: <%= themeColorTextDark %>;
79
72
  --text-on-theme: <%= textOnThemeDark %>;
80
-
81
73
  --fg-color: hsl(var(--tint-hue) calc(10% * var(--tint-amount)) 92%);
82
74
  --fg-color-translucent: hsl(
83
75
  var(--tint-hue) calc(85% * var(--tint-amount)) 90% / 33%
@@ -93,14 +85,11 @@
93
85
  --bg2-color-translucent: hsl(
94
86
  var(--tint-hue) calc(12% * var(--tint-amount)) 12% / 66%
95
87
  );
96
-
97
88
  --box-shadow: 0 3px 8px
98
89
  hsl(var(--tint-hue) calc(33% * var(--tint-amount)) 8% / 70%);
99
-
100
90
  --link-active-color: #fff;
101
-
102
- --warning-bg-color: hsl(36 77% 27%);
103
- --note-bg-color: hsl(209 90% 25%);
91
+ --warning-bg-color: hsl(36deg 77% 27%);
92
+ --note-bg-color: hsl(209deg 90% 25%);
104
93
 
105
94
  // SVG icons (dark overrides)
106
95
  --icon-search: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><circle cx='11' cy='11' r='7' stroke='<%= iconColorDark %>' stroke-width='2' fill='none'/><line x1='16.5' y1='16.5' x2='22' y2='22' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round'/></svg>");
@@ -0,0 +1,296 @@
1
+ import chokidar from 'chokidar';
2
+ import { applyCommitPlan } from './fs-commit';
3
+ import { emitWatchEvent } from './events';
4
+ import type {
5
+ ChangeBatch,
6
+ CompilerBuildResult,
7
+ CompilerPlanResult,
8
+ WatchEngineOptions,
9
+ WatchEventKind,
10
+ } from './types';
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 mergeChangeKind(
21
+ previous: WatchEventKind | undefined,
22
+ next: WatchEventKind,
23
+ ): WatchEventKind {
24
+ if (!previous) {
25
+ return next;
26
+ }
27
+ if (previous === 'add' && next === 'unlink') {
28
+ return 'change';
29
+ }
30
+ if (previous === 'unlink' && next === 'add') {
31
+ return 'change';
32
+ }
33
+ if (previous === 'add' || next === 'add') {
34
+ return 'add';
35
+ }
36
+ if (previous === 'unlink' || next === 'unlink') {
37
+ return 'unlink';
38
+ }
39
+ return 'change';
40
+ }
41
+
42
+ function toDiagnostics(error: unknown): { message: string }[] {
43
+ if (error instanceof Error) {
44
+ return [{ message: error.message }];
45
+ }
46
+ return [{ message: String(error) }];
47
+ }
48
+
49
+ function isNoopCommit(commit: {
50
+ kind: string;
51
+ mutations?: unknown[];
52
+ }): boolean {
53
+ return (
54
+ commit.kind === 'apply-mutations' && (commit.mutations?.length ?? 0) === 0
55
+ );
56
+ }
57
+
58
+ type BuildAttemptOutcome<Snapshot, Meta> =
59
+ | { kind: 'skipped'; batch: ChangeBatch; initial: boolean }
60
+ | {
61
+ kind: 'succeeded';
62
+ batch?: ChangeBatch;
63
+ initial: boolean;
64
+ result: Extract<CompilerBuildResult<Snapshot, Meta>, { ok: true }>;
65
+ }
66
+ | {
67
+ kind: 'failed';
68
+ batch?: ChangeBatch;
69
+ initial: boolean;
70
+ diagnostics: { message: string }[];
71
+ meta?: Meta;
72
+ };
73
+
74
+ export async function runWatchEngine<Snapshot, Plan, Meta>(
75
+ options: WatchEngineOptions<Snapshot, Plan, Meta>,
76
+ ): Promise<void> {
77
+ const debounceMs = options.debounceMs ?? 300;
78
+ let snapshot: Snapshot | undefined;
79
+ let pending = new Map<string, WatchEventKind>();
80
+ let blocked = new Map<string, WatchEventKind>();
81
+ let timer: ReturnType<typeof setTimeout> | null = null;
82
+ let running = false;
83
+
84
+ async function waitForQuietPeriod(): Promise<void> {
85
+ await new Promise(resolve => setTimeout(resolve, debounceMs));
86
+ }
87
+
88
+ async function runBuild(
89
+ batch?: ChangeBatch,
90
+ initial: boolean = false,
91
+ ): Promise<BuildAttemptOutcome<Snapshot, Meta>> {
92
+ await emitWatchEvent(options, { kind: 'build-started', batch, initial });
93
+ try {
94
+ let result: CompilerBuildResult<Snapshot, Meta> | null = null;
95
+ if (initial) {
96
+ result = await options.compiler.buildInitial();
97
+ } else if (batch) {
98
+ const nextPlan: CompilerPlanResult<Plan> = await options.compiler.plan(
99
+ snapshot,
100
+ batch,
101
+ );
102
+ if (nextPlan.kind === 'skip') {
103
+ return { kind: 'skipped', batch, initial };
104
+ }
105
+ result = await options.compiler.run(nextPlan.plan, snapshot);
106
+ }
107
+ if (!result) {
108
+ return { kind: 'skipped', batch: batch!, initial };
109
+ }
110
+
111
+ if (result.ok) {
112
+ return { kind: 'succeeded', batch, initial, result };
113
+ }
114
+
115
+ return {
116
+ kind: 'failed',
117
+ batch,
118
+ initial,
119
+ diagnostics: result.diagnostics,
120
+ meta: result.meta,
121
+ };
122
+ } catch (error) {
123
+ return {
124
+ kind: 'failed',
125
+ batch,
126
+ initial,
127
+ diagnostics: toDiagnostics(error),
128
+ };
129
+ }
130
+ }
131
+
132
+ async function flush(): Promise<void> {
133
+ if (running) {
134
+ return;
135
+ }
136
+ running = true;
137
+ try {
138
+ let finalSuccess:
139
+ | {
140
+ batch?: ChangeBatch;
141
+ initial: boolean;
142
+ result: Extract<CompilerBuildResult<Snapshot, Meta>, { ok: true }>;
143
+ changed: boolean;
144
+ }
145
+ | undefined;
146
+
147
+ while (pending.size > 0) {
148
+ const batch = normalizeBatch(pending);
149
+ pending = new Map();
150
+ const outcome = await runBuild(batch, false);
151
+
152
+ if (outcome.kind === 'skipped') {
153
+ if (pending.size === 0 && !finalSuccess) {
154
+ await emitWatchEvent(options, {
155
+ kind: 'build-skipped',
156
+ batch: outcome.batch,
157
+ });
158
+ }
159
+ continue;
160
+ }
161
+
162
+ if (outcome.kind === 'failed') {
163
+ await emitWatchEvent(options, {
164
+ kind: 'build-failed',
165
+ initial: outcome.initial,
166
+ batch: outcome.batch,
167
+ diagnostics: outcome.diagnostics,
168
+ meta: outcome.meta,
169
+ });
170
+ if (outcome.batch) {
171
+ for (const change of outcome.batch.changes) {
172
+ blocked.set(
173
+ change.path,
174
+ mergeChangeKind(blocked.get(change.path), change.kind),
175
+ );
176
+ }
177
+ }
178
+ return;
179
+ }
180
+
181
+ const changed = !isNoopCommit(outcome.result.commit);
182
+ if (changed) {
183
+ applyCommitPlan(outcome.result.commit);
184
+ }
185
+ snapshot = outcome.result.snapshot;
186
+ finalSuccess = {
187
+ batch: outcome.batch,
188
+ initial: outcome.initial,
189
+ result: outcome.result,
190
+ changed: (finalSuccess?.changed ?? false) || changed,
191
+ };
192
+
193
+ if (pending.size === 0) {
194
+ await waitForQuietPeriod();
195
+ }
196
+ }
197
+
198
+ if (finalSuccess) {
199
+ await emitWatchEvent(options, {
200
+ kind: 'build-succeeded',
201
+ initial: finalSuccess.initial,
202
+ batch: finalSuccess.batch,
203
+ changed: finalSuccess.changed,
204
+ snapshot: finalSuccess.result.snapshot,
205
+ meta: finalSuccess.result.meta,
206
+ diagnostics: finalSuccess.result.diagnostics || [],
207
+ });
208
+ }
209
+ } finally {
210
+ running = false;
211
+ if (pending.size > 0) {
212
+ schedule();
213
+ }
214
+ }
215
+ }
216
+
217
+ function schedule(): void {
218
+ if (timer) {
219
+ clearTimeout(timer);
220
+ }
221
+ timer = setTimeout(() => {
222
+ timer = null;
223
+ void flush();
224
+ }, debounceMs);
225
+ }
226
+
227
+ function onFileChange(filePath: string, kind: WatchEventKind): void {
228
+ if (blocked.size > 0) {
229
+ for (const [blockedPath, blockedKind] of blocked) {
230
+ pending.set(
231
+ blockedPath,
232
+ mergeChangeKind(pending.get(blockedPath), blockedKind),
233
+ );
234
+ }
235
+ blocked = new Map();
236
+ }
237
+ pending.set(filePath, mergeChangeKind(pending.get(filePath), kind));
238
+ schedule();
239
+ }
240
+
241
+ const initialOutcome = await runBuild(undefined, true);
242
+ if (initialOutcome.kind === 'failed') {
243
+ await emitWatchEvent(options, {
244
+ kind: 'build-failed',
245
+ initial: true,
246
+ diagnostics: initialOutcome.diagnostics,
247
+ meta: initialOutcome.meta,
248
+ });
249
+ } else if (initialOutcome.kind === 'succeeded') {
250
+ applyCommitPlan(initialOutcome.result.commit);
251
+ snapshot = initialOutcome.result.snapshot;
252
+ await emitWatchEvent(options, {
253
+ kind: 'build-succeeded',
254
+ initial: true,
255
+ changed: !isNoopCommit(initialOutcome.result.commit),
256
+ snapshot: initialOutcome.result.snapshot,
257
+ meta: initialOutcome.result.meta,
258
+ diagnostics: initialOutcome.result.diagnostics || [],
259
+ });
260
+ }
261
+
262
+ const watcherEntries = options.compiler
263
+ .getWatchTargets()
264
+ .map(target => ({
265
+ target,
266
+ watcher: chokidar.watch(target.path, {
267
+ ignoreInitial: true,
268
+ atomic: true,
269
+ awaitWriteFinish: { stabilityThreshold: 100 },
270
+ depth: target.chokidar?.depth,
271
+ }),
272
+ }));
273
+
274
+ let readyCount = 0;
275
+ const readyGoal = watcherEntries.length;
276
+ const onReady = async () => {
277
+ readyCount += 1;
278
+ if (readyCount === readyGoal) {
279
+ await emitWatchEvent(options, { kind: 'watching' });
280
+ }
281
+ };
282
+
283
+ for (const { watcher, target } of watcherEntries) {
284
+ const emitIfIncluded = (filePath: string, kind: WatchEventKind) => {
285
+ if (!target.filter || target.filter(filePath)) {
286
+ onFileChange(filePath, kind);
287
+ }
288
+ };
289
+ watcher.on('add', filePath => emitIfIncluded(filePath, 'add'));
290
+ watcher.on('change', filePath => emitIfIncluded(filePath, 'change'));
291
+ watcher.on('unlink', filePath => emitIfIncluded(filePath, 'unlink'));
292
+ watcher.on('ready', () => void onReady());
293
+ }
294
+
295
+ return new Promise(() => {});
296
+ }
@@ -0,0 +1,10 @@
1
+ import type { WatchEngineOptions, WatchLifecycleEvent } from './types';
2
+
3
+ export async function emitWatchEvent<Snapshot, Plan, Meta>(
4
+ options: WatchEngineOptions<Snapshot, Plan, Meta>,
5
+ event: WatchLifecycleEvent<Snapshot, Meta>,
6
+ ): Promise<void> {
7
+ if (options.onEvent) {
8
+ await options.onEvent(event);
9
+ }
10
+ }
@@ -0,0 +1,102 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import type {
4
+ ApplyMutationsCommitPlan,
5
+ CommitPlan,
6
+ FileMutation,
7
+ ReplaceRootCommitPlan,
8
+ } from './types';
9
+
10
+ function removeDirIfExists(dir: string): void {
11
+ fs.rmSync(dir, { recursive: true, force: true });
12
+ }
13
+
14
+ function ensureParentDir(filePath: string): void {
15
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
16
+ }
17
+
18
+ function pruneEmptyDirs(rootDir: string, filePath: string): void {
19
+ let currentDir = path.dirname(filePath);
20
+ const root = path.resolve(rootDir);
21
+ while (currentDir !== root) {
22
+ try {
23
+ fs.rmdirSync(currentDir);
24
+ } catch (err) {
25
+ const code = (err as NodeJS.ErrnoException).code;
26
+ if (code === 'ENOTEMPTY' || code === 'ENOENT') {
27
+ break;
28
+ }
29
+ throw err;
30
+ }
31
+ currentDir = path.dirname(currentDir);
32
+ }
33
+ }
34
+
35
+ function applyFileMutation(
36
+ rootDir: string,
37
+ mutation: FileMutation,
38
+ stagedRoot: string,
39
+ ): void {
40
+ const targetPath = path.join(rootDir, mutation.path);
41
+ const stagedPath = path.join(stagedRoot, mutation.path);
42
+
43
+ if (mutation.kind === 'write') {
44
+ ensureParentDir(stagedPath);
45
+ fs.writeFileSync(stagedPath, mutation.content);
46
+ ensureParentDir(targetPath);
47
+ fs.renameSync(stagedPath, targetPath);
48
+ return;
49
+ }
50
+
51
+ fs.rmSync(targetPath, { force: true });
52
+ pruneEmptyDirs(rootDir, targetPath);
53
+ }
54
+
55
+ function applyMutations(plan: ApplyMutationsCommitPlan): void {
56
+ const stagedRoot = fs.mkdtempSync(path.join(plan.rootDir, '.watch-stage-'));
57
+ try {
58
+ for (const mutation of plan.mutations) {
59
+ if (mutation.kind === 'write') {
60
+ const stagedPath = path.join(stagedRoot, mutation.path);
61
+ ensureParentDir(stagedPath);
62
+ fs.writeFileSync(stagedPath, mutation.content);
63
+ }
64
+ }
65
+
66
+ for (const mutation of plan.mutations) {
67
+ applyFileMutation(plan.rootDir, mutation, stagedRoot);
68
+ }
69
+ } finally {
70
+ removeDirIfExists(stagedRoot);
71
+ }
72
+ }
73
+
74
+ function replaceRoot(plan: ReplaceRootCommitPlan): void {
75
+ const { targetPath, stagedPath } = plan;
76
+ const backupDir = `${targetPath}.bak-${process.pid}-${Date.now()}`;
77
+ const targetExists = fs.existsSync(targetPath);
78
+
79
+ try {
80
+ if (targetExists) {
81
+ fs.renameSync(targetPath, backupDir);
82
+ }
83
+ fs.renameSync(stagedPath, targetPath);
84
+ if (targetExists) {
85
+ removeDirIfExists(backupDir);
86
+ }
87
+ } catch (err) {
88
+ if (!fs.existsSync(targetPath) && fs.existsSync(backupDir)) {
89
+ fs.renameSync(backupDir, targetPath);
90
+ }
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ export function applyCommitPlan(plan: CommitPlan): void {
96
+ if (plan.kind === 'replace-root') {
97
+ replaceRoot(plan);
98
+ return;
99
+ }
100
+
101
+ applyMutations(plan);
102
+ }