@abreen/tada 1.4.1 → 1.5.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 (103) hide show
  1. package/README.md +4 -2
  2. package/bin/tada.ts +81 -57
  3. package/build/apply-base-path-plugin.ts +22 -6
  4. package/build/code.test.ts +108 -1
  5. package/build/colors.test.ts +66 -0
  6. package/build/columns-plugin.ts +116 -0
  7. package/build/content-watch.ts +30 -21
  8. package/build/copy.ts +1 -1
  9. package/build/external-links-plugin.ts +1 -1
  10. package/build/generate-content-assets.ts +46 -4
  11. package/build/generate-favicon.ts +1 -1
  12. package/build/generate-fonts.ts +1 -1
  13. package/build/generate-katex-assets.ts +1 -1
  14. package/build/log.test.ts +7 -1
  15. package/build/log.ts +4 -1
  16. package/build/markdown-plugins.test.ts +65 -3
  17. package/build/pagefind.ts +1 -1
  18. package/build/pipeline.ts +4 -20
  19. package/build/serve.test.ts +59 -0
  20. package/build/serve.ts +29 -63
  21. package/build/templates.ts +2 -2
  22. package/build/toc-plugin.test.ts +40 -25
  23. package/build/toc-plugin.ts +9 -2
  24. package/build/{types.ts → types.d.ts} +87 -1
  25. package/build/util.ts +1 -1
  26. package/build/utils/code.ts +76 -3
  27. package/build/utils/content-files.test.ts +56 -0
  28. package/build/utils/content-files.ts +15 -11
  29. package/build/utils/file-types.test.ts +18 -0
  30. package/build/utils/file-types.ts +4 -0
  31. package/build/utils/include.test.ts +117 -0
  32. package/build/utils/include.ts +53 -0
  33. package/build/utils/jdi-runner/TraceRunner.java +416 -0
  34. package/build/utils/literate-java.ts +2 -2
  35. package/build/utils/markdown.ts +32 -14
  36. package/build/utils/paths.ts +1 -1
  37. package/build/utils/render.ts +97 -11
  38. package/build/utils/shiki-highlighter.ts +1 -1
  39. package/build/utils/trace-layout.test.ts +570 -0
  40. package/build/utils/trace-layout.ts +848 -0
  41. package/build/utils/trace-svg.test.ts +425 -0
  42. package/build/utils/trace-svg.ts +905 -0
  43. package/build/utils/trace.test.ts +169 -0
  44. package/build/utils/trace.ts +334 -0
  45. package/build/validate-internal-links-plugin.test.ts +30 -2
  46. package/build/validate-internal-links-plugin.ts +3 -3
  47. package/build/watch-reload-client.ts +4 -20
  48. package/build/watch.ts +436 -448
  49. package/init/content/index.md +10 -5
  50. package/init/content/labs/00/index.md +1 -1
  51. package/init/content/labs/01/SearchTreeDemo.java +31 -0
  52. package/init/content/labs/01/TraceDemo.java +22 -0
  53. package/init/content/labs/01/index.md +27 -0
  54. package/init/content/labs/index.md +2 -0
  55. package/init/content/lectures/01/Rectangle.java +2 -0
  56. package/init/content/lectures/01/index.md +2 -1
  57. package/init/content/lectures/01/rectangle.py +45 -0
  58. package/init/content/lectures/02/_pr1.md +5 -0
  59. package/init/content/lectures/02/index.md +11 -0
  60. package/init/content/lectures/02/subdir/_foobar.html +3 -0
  61. package/init/content/lectures/02/subdir/_pr2.md +6 -0
  62. package/init/content/lectures/index.md +3 -0
  63. package/init/content/markdown.md +30 -0
  64. package/package.json +20 -3
  65. package/src/_base.scss +13 -2
  66. package/src/_layout.scss +6 -5
  67. package/src/anchor/index.test.ts +64 -0
  68. package/src/code/index.test.ts +71 -0
  69. package/src/code/style.scss +23 -0
  70. package/src/code.scss +5 -3
  71. package/src/header/_base.scss +48 -9
  72. package/src/header/index.test.ts +89 -0
  73. package/src/header/index.ts +1 -1
  74. package/src/header/style.scss +1 -5
  75. package/src/index.ts +18 -20
  76. package/src/literate/style.scss +15 -7
  77. package/src/navigate/eligible.test.ts +101 -0
  78. package/src/navigate/eligible.ts +62 -0
  79. package/src/navigate/index.test.ts +82 -0
  80. package/src/navigate/index.ts +365 -0
  81. package/src/navigate/lifecycle.test.ts +26 -0
  82. package/src/navigate/lifecycle.ts +65 -0
  83. package/src/navigate/style.scss +61 -0
  84. package/src/print/index.test.ts +71 -0
  85. package/src/question/index.test.ts +82 -0
  86. package/src/question/style.scss +12 -0
  87. package/src/search/index.ts +38 -14
  88. package/src/search/style.scss +23 -14
  89. package/src/style.scss +25 -12
  90. package/src/timezone/index.test.ts +301 -1
  91. package/src/timezone/index.ts +68 -56
  92. package/src/toc/index.test.ts +237 -0
  93. package/src/toc/index.ts +29 -69
  94. package/src/toc/style.scss +17 -0
  95. package/src/top/index.test.ts +46 -0
  96. package/src/top/index.ts +2 -2
  97. package/src/trace/index.test.ts +528 -0
  98. package/src/trace/index.ts +250 -0
  99. package/src/trace/style.scss +210 -0
  100. package/src/trace/types.d.ts +47 -0
  101. package/src/util.test.ts +135 -0
  102. package/templates/_top.html +23 -22
  103. package/types/jsdom.d.ts +9 -0
package/build/watch.ts CHANGED
@@ -1,6 +1,4 @@
1
- #!/usr/bin/env bun
2
1
  import fs from 'fs';
3
- import { fork } from 'child_process';
4
2
  import path from 'path';
5
3
  import chokidar from 'chokidar';
6
4
  import WebSocket, { WebSocketServer } from 'ws';
@@ -31,541 +29,531 @@ import { getProcessedExtensions } from './utils/file-types';
31
29
  import { ContentRenderer } from './generate-content-assets';
32
30
  import { WatchPagefindRunner } from './pagefind';
33
31
  import { ContentChangeDetector } from './content-watch';
32
+ import { startServer } from './serve';
33
+
34
+ export async function runWatch(options: {
35
+ httpPort?: number;
36
+ wsPort?: number;
37
+ }): Promise<void> {
38
+ const httpPort = options.httpPort ?? null;
39
+ const WEBSOCKET_PORT = options.wsPort ?? 35729;
40
+ const DEBOUNCE_MS = 300;
41
+ const RELOAD_CLIENT_PATH = path.resolve(
42
+ getPackageDir(),
43
+ 'build/watch-reload-client.ts',
44
+ );
34
45
 
35
- function getArg(name: string): number | null {
36
- const idx = process.argv.indexOf(name);
37
- if (idx !== -1 && process.argv[idx + 1]) {
38
- const val = parseInt(process.argv[idx + 1], 10);
39
- if (val > 0 && val < 65536) {
40
- return val;
41
- }
46
+ // Bundle the reload client separately to work around a Bun bundler bug where
47
+ // side-effect-only JS entrypoints are tree-shaken when bundled alongside SCSS
48
+ // entrypoints processed by a plugin.
49
+ async function bundleReloadClient(): Promise<string[]> {
50
+ const result = await Bun.build({
51
+ entrypoints: [RELOAD_CLIENT_PATH],
52
+ outdir: getDistDir(),
53
+ naming: '[name].bundle.[ext]',
54
+ sourcemap: 'inline',
55
+ define: { __WEBSOCKET_PORT__: String(WEBSOCKET_PORT) },
56
+ });
57
+ return result.outputs.map(output =>
58
+ path
59
+ .relative(getDistDir(), output.path)
60
+ .split(path.sep)
61
+ .join(path.posix.sep),
62
+ );
42
63
  }
43
- return null;
44
- }
45
-
46
- const httpPort = getArg('--port');
47
- const WEBSOCKET_PORT = getArg('--ws-port') ?? 35729;
48
- const DEBOUNCE_MS = 300;
49
- const RELOAD_CLIENT_PATH = path.resolve(
50
- getPackageDir(),
51
- 'build/watch-reload-client.ts',
52
- );
53
-
54
- // Bundle the reload client separately to work around a Bun bundler bug where
55
- // side-effect-only JS entrypoints are tree-shaken when bundled alongside SCSS
56
- // entrypoints processed by a plugin.
57
- async function bundleReloadClient(): Promise<string[]> {
58
- const result = await Bun.build({
59
- entrypoints: [RELOAD_CLIENT_PATH],
60
- outdir: getDistDir(),
61
- naming: '[name].bundle.[ext]',
62
- sourcemap: 'inline',
63
- define: { __WEBSOCKET_PORT__: String(WEBSOCKET_PORT) },
64
- });
65
- return result.outputs.map(output =>
66
- path
67
- .relative(getDistDir(), output.path)
68
- .split(path.sep)
69
- .join(path.posix.sep),
70
- );
71
- }
72
64
 
73
- type ChangeCategory = 'content' | 'public' | 'config';
65
+ type ChangeCategory = 'content' | 'public' | 'config';
74
66
 
75
- const log = makeLogger(__filename);
76
- const wslog = makeLogger('WebSocket');
67
+ const log = makeLogger(import.meta.url);
68
+ const wslog = makeLogger('WebSocket');
77
69
 
78
- // --- WebSocket server ---
70
+ // WebSocket server
79
71
 
80
- let webSocketsReady = false;
81
- let watcherReady = false;
82
- let webServerReady = false;
83
- let webServerTimeout: ReturnType<typeof setTimeout> | undefined;
84
- let serveStarted = false;
72
+ let webSocketsReady = false;
73
+ let watcherReady = false;
74
+ let webServerReady = false;
75
+ let webServerTimeout: ReturnType<typeof setTimeout> | undefined;
76
+ let serveStarted = false;
85
77
 
86
- let wss: WebSocketServer | null = null;
87
- try {
88
- wss = new WebSocketServer({ port: WEBSOCKET_PORT });
78
+ let wss: WebSocketServer | null = null;
79
+ try {
80
+ wss = new WebSocketServer({ port: WEBSOCKET_PORT });
89
81
 
90
- wss.on('connection', (conn: WebSocket) => {
91
- wslog.debug`WebSocket client connected`;
92
- if (watcherReady && conn.readyState === WebSocket.OPEN) {
93
- if (initialBuildFailed) {
94
- conn.send('error');
82
+ wss.on('connection', (conn: WebSocket) => {
83
+ wslog.debug`WebSocket client connected`;
84
+ if (watcherReady && conn.readyState === WebSocket.OPEN) {
85
+ if (initialBuildFailed) {
86
+ conn.send('error');
87
+ }
88
+ conn.send('ready');
95
89
  }
96
- conn.send('ready');
97
- }
98
- conn.on('close', () => {
99
- wslog.debug`WebSocket client disconnected`;
90
+ conn.on('close', () => {
91
+ wslog.debug`WebSocket client disconnected`;
92
+ });
100
93
  });
101
- });
102
-
103
- wss.on('error', (err: Error) => {
104
- wslog.error`WebSocket server error: ${err.message}`;
105
- });
106
-
107
- wss.on('listening', () => {
108
- wslog.debug`WebSocket server listening at ws://localhost:${WEBSOCKET_PORT}`;
109
- webSocketsReady = true;
110
- });
111
- } catch (err) {
112
- wslog.error`Failed to start WebSocket server on port ${WEBSOCKET_PORT}: ${(err as Error).message}`;
113
- }
114
94
 
115
- function broadcast(msg: string): void {
116
- if (wss == null || !webSocketsReady) {
117
- return;
118
- }
119
- wslog.debug`Broadcasting "${msg}" to WebSocket clients`;
120
- wss.clients.forEach((client: WebSocket) => {
121
- if (client.readyState === WebSocket.OPEN) {
122
- client.send(msg);
123
- }
124
- });
125
- }
95
+ wss.on('error', (err: Error) => {
96
+ wslog.error`WebSocket server error: ${err.message}`;
97
+ });
126
98
 
127
- // --- Dev server ---
128
-
129
- function serve(): void {
130
- const serveArgs = httpPort != null ? ['--port', String(httpPort)] : [];
131
- const child = fork(path.join(__dirname, 'serve.js'), serveArgs, {
132
- stdio: 'inherit',
133
- });
134
- child.on('close', (code: number | null) => {
135
- webServerReady = false;
136
- log.error`Web server exited with code ${code}`;
137
- process.exit(2);
138
- });
139
- child.on('error', (err: Error) => {
140
- webServerReady = false;
141
- log.error`Web server failed: ${err.message}`;
142
- });
143
- child.on('message', (msg: Record<string, unknown>) => {
144
- if (msg.ready) {
145
- webServerReady = true;
146
- clearTimeout(webServerTimeout);
147
- }
148
- });
99
+ wss.on('listening', () => {
100
+ wslog.debug`WebSocket server listening at ws://localhost:${WEBSOCKET_PORT}`;
101
+ webSocketsReady = true;
102
+ });
103
+ } catch (err) {
104
+ wslog.error`Failed to start WebSocket server on port ${WEBSOCKET_PORT}: ${(err as Error).message}`;
105
+ }
149
106
 
150
- webServerTimeout = setTimeout(() => {
151
- if (webServerReady) {
107
+ function broadcast(msg: string): void {
108
+ if (wss == null || !webSocketsReady) {
152
109
  return;
153
110
  }
154
- log.error`Web server failed to report within 10 seconds, exiting`;
155
- process.exit(3);
156
- }, 10000);
157
- }
158
-
159
- // --- Path helpers ---
160
-
161
- function toContentMarkdownPath(filePath: string): string | null {
162
- if (!filePath) {
163
- return null;
164
- }
165
- const ext = path.extname(filePath).toLowerCase();
166
- if (!['.md', '.markdown'].includes(ext)) {
167
- return null;
111
+ wslog.debug`Broadcasting "${msg}" to WebSocket clients`;
112
+ wss.clients.forEach((client: WebSocket) => {
113
+ if (client.readyState === WebSocket.OPEN) {
114
+ client.send(msg);
115
+ }
116
+ });
168
117
  }
169
118
 
170
- const normalizedContentDir = path.resolve(contentDir) + path.sep;
171
- const normalizedFilePath = path.resolve(filePath);
172
- if (!normalizedFilePath.startsWith(normalizedContentDir)) {
173
- return null;
174
- }
119
+ // Dev server
175
120
 
176
- return path
177
- .relative(contentDir, normalizedFilePath)
178
- .split(path.sep)
179
- .join(path.posix.sep);
180
- }
121
+ function serve(): void {
122
+ startServer({
123
+ port: httpPort ?? undefined,
124
+ distDir,
125
+ onReady: _port => {
126
+ webServerReady = true;
127
+ clearTimeout(webServerTimeout);
128
+ },
129
+ });
181
130
 
182
- function toPublicRelativePath(filePath: string): string | null {
183
- if (!filePath) {
184
- return null;
185
- }
186
- const normalizedPublicDir = path.resolve(publicDir) + path.sep;
187
- const normalizedFilePath = path.resolve(filePath);
188
- if (!normalizedFilePath.startsWith(normalizedPublicDir)) {
189
- return null;
131
+ webServerTimeout = setTimeout(() => {
132
+ if (webServerReady) {
133
+ return;
134
+ }
135
+ log.error`Web server failed to report within 10 seconds, exiting`;
136
+ process.exit(3);
137
+ }, 10000);
190
138
  }
191
- return path
192
- .relative(publicDir, normalizedFilePath)
193
- .split(path.sep)
194
- .join(path.posix.sep);
195
- }
196
139
 
197
- // --- Watch mode ---
198
-
199
- const contentDir: string = getContentDir();
200
- const publicDir: string = getPublicDir();
201
- const distDir: string = getDistDir();
202
-
203
- let siteVariables: SiteVariables;
204
- let processedExtSet = new Set<string>();
205
- let publicRelPaths: Set<string> = new Set();
206
- let contentAssetRelPaths: Set<string> = new Set();
207
- let contentRenderer: ContentRenderer;
208
- let changeDetector: ContentChangeDetector;
209
- let pagefindRunner: WatchPagefindRunner | undefined;
210
- let assetFiles: string[] = [];
211
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
212
- const pendingChanges = new Set<string>();
213
- let rebuilding = false;
214
-
215
- async function initialBuild(): Promise<void> {
216
- siteVariables = getDevSiteVariables();
217
- processedExtSet = new Set(
218
- getProcessedExtensions(Object.keys(siteVariables.codeLanguages || {})),
219
- );
220
- fs.mkdirSync(distDir, { recursive: true });
140
+ // Path helpers
141
+
142
+ function toContentMarkdownPath(filePath: string): string | null {
143
+ if (!filePath) {
144
+ return null;
145
+ }
146
+ const ext = path.extname(filePath).toLowerCase();
147
+ if (!['.md', '.markdown'].includes(ext)) {
148
+ return null;
149
+ }
221
150
 
222
- compileTemplates(siteVariables);
223
- contentRenderer = new ContentRenderer(siteVariables);
224
- changeDetector = new ContentChangeDetector(siteVariables);
151
+ const normalizedContentDir = path.resolve(contentDir) + path.sep;
152
+ const normalizedFilePath = path.resolve(filePath);
153
+ if (!normalizedFilePath.startsWith(normalizedContentDir)) {
154
+ return null;
155
+ }
225
156
 
226
- if (isFeatureEnabled(siteVariables, 'search')) {
227
- pagefindRunner = new WatchPagefindRunner(siteVariables);
157
+ return path
158
+ .relative(contentDir, normalizedFilePath)
159
+ .split(path.sep)
160
+ .join(path.posix.sep);
228
161
  }
229
162
 
230
- await contentRenderer.initHighlighter();
163
+ function toPublicRelativePath(filePath: string): string | null {
164
+ if (!filePath) {
165
+ return null;
166
+ }
167
+ const normalizedPublicDir = path.resolve(publicDir) + path.sep;
168
+ const normalizedFilePath = path.resolve(filePath);
169
+ if (!normalizedFilePath.startsWith(normalizedPublicDir)) {
170
+ return null;
171
+ }
172
+ return path
173
+ .relative(publicDir, normalizedFilePath)
174
+ .split(path.sep)
175
+ .join(path.posix.sep);
176
+ }
231
177
 
232
- assetFiles = [
233
- ...(await bundle(siteVariables, { mode: 'development' })),
234
- ...(await bundleReloadClient()),
235
- ];
178
+ // Watch mode
179
+
180
+ const contentDir: string = getContentDir();
181
+ const publicDir: string = getPublicDir();
182
+ const distDir: string = getDistDir();
183
+
184
+ let siteVariables: SiteVariables;
185
+ let processedExtSet = new Set<string>();
186
+ let publicRelPaths: Set<string> = new Set();
187
+ let contentAssetRelPaths: Set<string> = new Set();
188
+ let contentRenderer: ContentRenderer;
189
+ let changeDetector: ContentChangeDetector;
190
+ let pagefindRunner: WatchPagefindRunner | undefined;
191
+ let assetFiles: string[] = [];
192
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
193
+ const pendingChanges = new Set<string>();
194
+ let rebuilding = false;
195
+
196
+ async function initialBuild(): Promise<void> {
197
+ siteVariables = getDevSiteVariables();
198
+ processedExtSet = new Set(
199
+ getProcessedExtensions(Object.keys(siteVariables.codeLanguages || {})),
200
+ );
201
+ fs.mkdirSync(distDir, { recursive: true });
202
+
203
+ compileTemplates(siteVariables);
204
+ contentRenderer = new ContentRenderer(siteVariables);
205
+ changeDetector = new ContentChangeDetector(siteVariables);
206
+
207
+ if (isFeatureEnabled(siteVariables, 'search')) {
208
+ pagefindRunner = new WatchPagefindRunner(siteVariables);
209
+ }
236
210
 
237
- copyFonts(distDir);
238
- copyKatexAssets(distDir);
211
+ await contentRenderer.initHighlighter();
239
212
 
240
- if (isFeatureEnabled(siteVariables, 'favicon')) {
241
- await generateFavicons(siteVariables, distDir);
242
- generateWebAppManifest(siteVariables, distDir);
243
- }
213
+ assetFiles = [
214
+ ...(await bundle(siteVariables, { mode: 'development' })),
215
+ ...(await bundleReloadClient()),
216
+ ];
244
217
 
245
- publicRelPaths = copyPublicFiles(publicDir, distDir);
246
- contentAssetRelPaths = copyContentAssets(
247
- contentDir,
248
- distDir,
249
- [...processedExtSet],
250
- publicRelPaths,
251
- );
218
+ copyFonts(distDir);
219
+ copyKatexAssets(distDir);
252
220
 
253
- const result = contentRenderer.processContent({ distDir, assetFiles });
221
+ if (isFeatureEnabled(siteVariables, 'favicon')) {
222
+ await generateFavicons(siteVariables, distDir);
223
+ generateWebAppManifest(siteVariables, distDir);
224
+ }
254
225
 
255
- for (const err of result.errors) {
256
- log.error`${err.message}`;
257
- }
226
+ publicRelPaths = copyPublicFiles(publicDir, distDir);
227
+ contentAssetRelPaths = copyContentAssets(
228
+ contentDir,
229
+ distDir,
230
+ [...processedExtSet],
231
+ publicRelPaths,
232
+ );
258
233
 
259
- if (result.errors.length === 0) {
260
- printFlair();
234
+ const result = contentRenderer.processContent({ distDir, assetFiles });
261
235
 
262
- if (pagefindRunner) {
263
- pagefindRunner.update(distDir, result.htmlAssetsByPath);
264
- setImmediate(() => pagefindRunner!.run());
236
+ for (const err of result.errors) {
237
+ log.error`${err.message}`;
265
238
  }
266
239
 
267
- if (!serveStarted) {
268
- serveStarted = true;
269
- serve();
240
+ if (result.errors.length === 0) {
241
+ printFlair();
242
+
243
+ if (pagefindRunner) {
244
+ pagefindRunner.update(distDir, result.htmlAssetsByPath);
245
+ setImmediate(() => pagefindRunner!.run());
246
+ }
247
+
248
+ if (!serveStarted) {
249
+ serveStarted = true;
250
+ serve();
251
+ }
270
252
  }
271
253
  }
272
- }
273
254
 
274
- function classifyChange(filePath: string): ChangeCategory | null {
275
- const resolved = path.resolve(filePath);
276
- const resolvedContentDir = path.resolve(contentDir) + path.sep;
277
- const resolvedPublicDir = path.resolve(publicDir) + path.sep;
278
- const jsonDataDir = getJsonDataDir();
279
- const siteConfigPath = path.resolve('site.dev.json');
255
+ function classifyChange(filePath: string): ChangeCategory | null {
256
+ const resolved = path.resolve(filePath);
257
+ const resolvedContentDir = path.resolve(contentDir) + path.sep;
258
+ const resolvedPublicDir = path.resolve(publicDir) + path.sep;
259
+ const jsonDataDir = getJsonDataDir();
260
+ const siteConfigPath = path.resolve('site.dev.json');
280
261
 
281
- if (resolved.startsWith(resolvedContentDir)) {
282
- return 'content';
283
- }
284
- if (resolved.startsWith(resolvedPublicDir)) {
285
- return 'public';
286
- }
287
- if (
288
- resolved === siteConfigPath ||
289
- JSON_DATA_FILES.some(f => resolved === path.resolve(jsonDataDir, f))
290
- ) {
291
- return 'config';
262
+ if (resolved.startsWith(resolvedContentDir)) {
263
+ return 'content';
264
+ }
265
+ if (resolved.startsWith(resolvedPublicDir)) {
266
+ return 'public';
267
+ }
268
+ if (
269
+ resolved === siteConfigPath ||
270
+ JSON_DATA_FILES.some(f => resolved === path.resolve(jsonDataDir, f))
271
+ ) {
272
+ return 'config';
273
+ }
274
+ return null;
292
275
  }
293
- return null;
294
- }
295
276
 
296
- async function rebuild(): Promise<void> {
297
- if (rebuilding) {
298
- return;
299
- }
300
- rebuilding = true;
277
+ async function rebuild(): Promise<void> {
278
+ if (rebuilding) {
279
+ return;
280
+ }
281
+ rebuilding = true;
301
282
 
302
- const changes = new Set(pendingChanges);
303
- pendingChanges.clear();
283
+ const changes = new Set(pendingChanges);
284
+ pendingChanges.clear();
304
285
 
305
- broadcast('rebuilding');
286
+ broadcast('rebuilding');
306
287
 
307
- // Classify changes
308
- const categories = new Set<ChangeCategory>();
309
- for (const filePath of changes) {
310
- const category = classifyChange(filePath);
311
- if (category) {
312
- categories.add(category);
288
+ // Classify changes
289
+ const categories = new Set<ChangeCategory>();
290
+ for (const filePath of changes) {
291
+ const category = classifyChange(filePath);
292
+ if (category) {
293
+ categories.add(category);
294
+ }
313
295
  }
314
- }
315
296
 
316
- let succeeded = false;
297
+ let succeeded = false;
298
+
299
+ try {
300
+ // Site config changed, full restart
301
+ if (categories.has('config')) {
302
+ log.event`Site config changed, restarting`;
303
+ siteVariables = getDevSiteVariables();
304
+ processedExtSet = new Set(
305
+ getProcessedExtensions(
306
+ Object.keys(siteVariables.codeLanguages || {}),
307
+ ),
308
+ );
309
+ contentRenderer = new ContentRenderer(siteVariables);
310
+ changeDetector = new ContentChangeDetector(siteVariables);
311
+ if (isFeatureEnabled(siteVariables, 'search')) {
312
+ pagefindRunner = new WatchPagefindRunner(siteVariables);
313
+ }
314
+ compileTemplates(siteVariables);
315
+ await contentRenderer.initHighlighter();
317
316
 
318
- try {
319
- // Site config changed, full restart
320
- if (categories.has('config')) {
321
- log.event`Site config changed, restarting`;
322
- siteVariables = getDevSiteVariables();
323
- processedExtSet = new Set(
324
- getProcessedExtensions(Object.keys(siteVariables.codeLanguages || {})),
325
- );
326
- contentRenderer = new ContentRenderer(siteVariables);
327
- changeDetector = new ContentChangeDetector(siteVariables);
328
- if (isFeatureEnabled(siteVariables, 'search')) {
329
- pagefindRunner = new WatchPagefindRunner(siteVariables);
330
- }
331
- compileTemplates(siteVariables);
332
- await contentRenderer.initHighlighter();
317
+ assetFiles = [
318
+ ...(await bundle(siteVariables, { mode: 'development' })),
319
+ ...(await bundleReloadClient()),
320
+ ];
333
321
 
334
- assetFiles = [
335
- ...(await bundle(siteVariables, { mode: 'development' })),
336
- ...(await bundleReloadClient()),
337
- ];
322
+ copyFonts(distDir);
323
+ copyKatexAssets(distDir);
338
324
 
339
- copyFonts(distDir);
340
- copyKatexAssets(distDir);
325
+ if (isFeatureEnabled(siteVariables, 'favicon')) {
326
+ await generateFavicons(siteVariables, distDir);
327
+ generateWebAppManifest(siteVariables, distDir);
328
+ }
341
329
 
342
- if (isFeatureEnabled(siteVariables, 'favicon')) {
343
- await generateFavicons(siteVariables, distDir);
344
- generateWebAppManifest(siteVariables, distDir);
330
+ const result = contentRenderer.processContent({ distDir, assetFiles });
331
+ for (const err of result.errors) {
332
+ log.error`${err.message}`;
333
+ }
334
+ if (result.errors.length === 0) {
335
+ printFlair();
336
+ if (!serveStarted) {
337
+ serveStarted = true;
338
+ serve();
339
+ }
340
+ broadcast('reload');
341
+ if (pagefindRunner) {
342
+ pagefindRunner.update(distDir, result.htmlAssetsByPath);
343
+ setImmediate(() => pagefindRunner!.run());
344
+ }
345
+ succeeded = true;
346
+ }
347
+ return;
345
348
  }
346
349
 
347
- const result = contentRenderer.processContent({ distDir, assetFiles });
348
- for (const err of result.errors) {
349
- log.error`${err.message}`;
350
- }
351
- if (result.errors.length === 0) {
352
- printFlair();
353
- if (!serveStarted) {
354
- serveStarted = true;
355
- serve();
350
+ // Public file changed, copy just that file
351
+ if (categories.has('public') && !categories.has('content')) {
352
+ for (const filePath of changes) {
353
+ if (classifyChange(filePath) === 'public') {
354
+ const absPath = path.resolve(filePath);
355
+ if (fs.existsSync(absPath)) {
356
+ copyPublicFile(publicDir, distDir, absPath, contentAssetRelPaths);
357
+ const rel = path
358
+ .relative(publicDir, absPath)
359
+ .split(path.sep)
360
+ .join(path.posix.sep);
361
+ publicRelPaths.add(rel);
362
+ }
363
+ }
356
364
  }
365
+ printFlair();
357
366
  broadcast('reload');
358
- if (pagefindRunner) {
359
- pagefindRunner.update(distDir, result.htmlAssetsByPath);
360
- setImmediate(() => pagefindRunner!.run());
361
- }
362
367
  succeeded = true;
368
+ return;
369
+ }
370
+
371
+ // Content changed, incremental rebuild
372
+ const detection = changeDetector.detectChanges(changes);
373
+
374
+ if (detection.needsRestart) {
375
+ log.event`Content structure changed, full rebuild`;
376
+ contentRenderer = new ContentRenderer(siteVariables);
377
+ await contentRenderer.initHighlighter();
363
378
  }
364
- return;
365
- }
366
379
 
367
- // Public file changed, copy just that file
368
- if (categories.has('public') && !categories.has('content')) {
380
+ // Log changed content files
369
381
  for (const filePath of changes) {
370
- if (classifyChange(filePath) === 'public') {
371
- const absPath = path.resolve(filePath);
372
- if (fs.existsSync(absPath)) {
373
- copyPublicFile(publicDir, distDir, absPath, contentAssetRelPaths);
374
- const rel = path
375
- .relative(publicDir, absPath)
376
- .split(path.sep)
377
- .join(path.posix.sep);
378
- publicRelPaths.add(rel);
382
+ const markdownPath = toContentMarkdownPath(filePath);
383
+ if (markdownPath) {
384
+ log.event`${B`${markdownPath}`} changed, rebuilding`;
385
+ } else {
386
+ const pubPath = toPublicRelativePath(filePath);
387
+ if (pubPath) {
388
+ log.event`${B`public/${pubPath}`} changed`;
379
389
  }
380
390
  }
381
391
  }
382
- printFlair();
383
- broadcast('reload');
384
- succeeded = true;
385
- return;
386
- }
387
-
388
- // Content changed, incremental rebuild
389
- const detection = changeDetector.detectChanges(changes);
390
-
391
- if (detection.needsRestart) {
392
- log.event`Content structure changed, full rebuild`;
393
- contentRenderer = new ContentRenderer(siteVariables);
394
- await contentRenderer.initHighlighter();
395
- }
396
392
 
397
- // Log changed content files
398
- for (const filePath of changes) {
399
- const markdownPath = toContentMarkdownPath(filePath);
400
- if (markdownPath) {
401
- log.event`${B`${markdownPath}`} changed, rebuilding`;
402
- } else {
403
- const pubPath = toPublicRelativePath(filePath);
404
- if (pubPath) {
405
- log.event`${B`public/${pubPath}`} changed`;
393
+ // Copy any changed public files too
394
+ if (categories.has('public')) {
395
+ for (const filePath of changes) {
396
+ if (classifyChange(filePath) === 'public') {
397
+ const absPath = path.resolve(filePath);
398
+ if (fs.existsSync(absPath)) {
399
+ copyPublicFile(publicDir, distDir, absPath, contentAssetRelPaths);
400
+ const rel = path
401
+ .relative(publicDir, absPath)
402
+ .split(path.sep)
403
+ .join(path.posix.sep);
404
+ publicRelPaths.add(rel);
405
+ }
406
+ }
406
407
  }
407
408
  }
408
- }
409
409
 
410
- // Copy any changed public files too
411
- if (categories.has('public')) {
412
- for (const filePath of changes) {
413
- if (classifyChange(filePath) === 'public') {
410
+ // Copy any changed non-processed content files (images, PDFs, etc.)
411
+ if (categories.has('content')) {
412
+ for (const filePath of changes) {
413
+ if (classifyChange(filePath) !== 'content') {
414
+ continue;
415
+ }
414
416
  const absPath = path.resolve(filePath);
415
- if (fs.existsSync(absPath)) {
416
- copyPublicFile(publicDir, distDir, absPath, contentAssetRelPaths);
417
+ const ext = path.extname(absPath).slice(1).toLowerCase();
418
+ if (!processedExtSet.has(ext) && fs.existsSync(absPath)) {
419
+ copyContentFile(contentDir, distDir, absPath, publicRelPaths);
417
420
  const rel = path
418
- .relative(publicDir, absPath)
421
+ .relative(contentDir, absPath)
419
422
  .split(path.sep)
420
423
  .join(path.posix.sep);
421
- publicRelPaths.add(rel);
424
+ contentAssetRelPaths.add(rel);
422
425
  }
423
426
  }
424
427
  }
425
- }
426
428
 
427
- // Copy any changed non-processed content files (images, PDFs, etc.)
428
- if (categories.has('content')) {
429
- for (const filePath of changes) {
430
- if (classifyChange(filePath) !== 'content') {
431
- continue;
432
- }
433
- const absPath = path.resolve(filePath);
434
- const ext = path.extname(absPath).slice(1).toLowerCase();
435
- if (!processedExtSet.has(ext) && fs.existsSync(absPath)) {
436
- copyContentFile(contentDir, distDir, absPath, publicRelPaths);
437
- const rel = path
438
- .relative(contentDir, absPath)
439
- .split(path.sep)
440
- .join(path.posix.sep);
441
- contentAssetRelPaths.add(rel);
442
- }
443
- }
444
- }
429
+ const watchState: WatchState | undefined = detection.needsRestart
430
+ ? undefined
431
+ : {
432
+ changedContentFiles: detection.changedContentFiles,
433
+ jsonDataChanged: detection.jsonDataChanged,
434
+ partialsChanged: detection.partialsChanged,
435
+ };
445
436
 
446
- const watchState: WatchState | undefined = detection.needsRestart
447
- ? undefined
448
- : {
449
- changedContentFiles: detection.changedContentFiles,
450
- templatesChanged: detection.templatesChanged,
451
- };
437
+ const result = contentRenderer.processContent({
438
+ distDir,
439
+ assetFiles,
440
+ watchState,
441
+ });
452
442
 
453
- const result = contentRenderer.processContent({
454
- distDir,
455
- assetFiles,
456
- watchState,
457
- });
443
+ for (const err of result.errors) {
444
+ log.error`${err.message}`;
445
+ }
458
446
 
459
- for (const err of result.errors) {
460
- log.error`${err.message}`;
461
- }
447
+ if (result.errors.length === 0) {
448
+ printFlair();
449
+ if (!serveStarted) {
450
+ serveStarted = true;
451
+ serve();
452
+ }
453
+ broadcast('reload');
462
454
 
463
- if (result.errors.length === 0) {
464
- printFlair();
465
- if (!serveStarted) {
466
- serveStarted = true;
467
- serve();
455
+ if (pagefindRunner) {
456
+ pagefindRunner.update(distDir, result.htmlAssetsByPath);
457
+ setImmediate(() => pagefindRunner!.run());
458
+ }
459
+ succeeded = true;
460
+ }
461
+ } catch (err) {
462
+ log.error`Build failed: ${(err as Error).message}`;
463
+ } finally {
464
+ if (!succeeded) {
465
+ broadcast('error');
468
466
  }
469
- broadcast('reload');
467
+ rebuilding = false;
470
468
 
471
- if (pagefindRunner) {
472
- pagefindRunner.update(distDir, result.htmlAssetsByPath);
473
- setImmediate(() => pagefindRunner!.run());
469
+ // If more changes accumulated during rebuild, schedule another
470
+ if (pendingChanges.size > 0) {
471
+ scheduleRebuild();
474
472
  }
475
- succeeded = true;
476
- }
477
- } catch (err) {
478
- log.error`Build failed: ${(err as Error).message}`;
479
- } finally {
480
- if (!succeeded) {
481
- broadcast('error');
482
473
  }
483
- rebuilding = false;
474
+ }
484
475
 
485
- // If more changes accumulated during rebuild, schedule another
486
- if (pendingChanges.size > 0) {
487
- scheduleRebuild();
476
+ function scheduleRebuild(): void {
477
+ if (debounceTimer) {
478
+ clearTimeout(debounceTimer);
488
479
  }
480
+ debounceTimer = setTimeout(rebuild, DEBOUNCE_MS);
489
481
  }
490
- }
491
482
 
492
- function scheduleRebuild(): void {
493
- if (debounceTimer) {
494
- clearTimeout(debounceTimer);
483
+ function onFileChange(filePath: string): void {
484
+ pendingChanges.add(filePath);
485
+ scheduleRebuild();
495
486
  }
496
- debounceTimer = setTimeout(rebuild, DEBOUNCE_MS);
497
- }
498
-
499
- function onFileChange(filePath: string): void {
500
- pendingChanges.add(filePath);
501
- scheduleRebuild();
502
- }
503
-
504
- // --- Start ---
505
487
 
506
- let initialBuildFailed = false;
507
-
508
- initialBuild()
509
- .catch(err => {
510
- log.error`Initial build failed: ${(err as Error).message}`;
511
- initialBuildFailed = true;
512
- })
513
- .finally(() => {
514
- const watchPaths: string[] = [contentDir, publicDir];
515
-
516
- // Watch config files (nav.json, authors.json, site config) via the
517
- // project root directory (depth 0). Chokidar v4 doesn't emit unlink
518
- // for individually watched files, so we must watch the directory.
519
- const jsonDataDir = getJsonDataDir();
520
- const configFilePaths = new Set([
521
- ...JSON_DATA_FILES.map(f => path.resolve(jsonDataDir, f)),
522
- path.resolve('site.dev.json'),
523
- ]);
524
- const configWatcher = chokidar.watch(jsonDataDir, {
525
- ignoreInitial: true,
526
- depth: 0,
527
- awaitWriteFinish: { stabilityThreshold: 100 },
528
- });
529
- const onConfigFileChange = (filePath: string) => {
530
- if (configFilePaths.has(path.resolve(filePath))) {
531
- onFileChange(filePath);
488
+ // Start
489
+
490
+ let initialBuildFailed = false;
491
+
492
+ initialBuild()
493
+ .catch(err => {
494
+ log.error`Initial build failed: ${(err as Error).message}`;
495
+ initialBuildFailed = true;
496
+ })
497
+ .finally(() => {
498
+ const watchPaths: string[] = [contentDir, publicDir];
499
+
500
+ // Watch config files (nav.json, authors.json, site config) via the
501
+ // project root directory (depth 0). Chokidar v4 doesn't emit unlink
502
+ // for individually watched files, so we must watch the directory.
503
+ const jsonDataDir = getJsonDataDir();
504
+ const configFilePaths = new Set([
505
+ ...JSON_DATA_FILES.map(f => path.resolve(jsonDataDir, f)),
506
+ path.resolve('site.dev.json'),
507
+ ]);
508
+ const configWatcher = chokidar.watch(jsonDataDir, {
509
+ ignoreInitial: true,
510
+ depth: 0,
511
+ awaitWriteFinish: { stabilityThreshold: 100 },
512
+ });
513
+ const onConfigFileChange = (filePath: string) => {
514
+ if (configFilePaths.has(path.resolve(filePath))) {
515
+ onFileChange(filePath);
516
+ }
517
+ };
518
+ configWatcher.on('add', onConfigFileChange);
519
+ configWatcher.on('change', onConfigFileChange);
520
+ configWatcher.on('unlink', onConfigFileChange);
521
+
522
+ const watcher = chokidar.watch(watchPaths, {
523
+ ignoreInitial: true,
524
+ awaitWriteFinish: { stabilityThreshold: 100 },
525
+ });
526
+
527
+ watcher.on('change', onFileChange);
528
+ watcher.on('add', onFileChange);
529
+ watcher.on('unlink', onFileChange);
530
+
531
+ let configWatcherReady = false;
532
+ let contentWatcherReady = false;
533
+
534
+ function onWatcherReady(): void {
535
+ if (!configWatcherReady || !contentWatcherReady) {
536
+ return;
537
+ }
538
+ watcherReady = true;
539
+ if (initialBuildFailed) {
540
+ broadcast('error');
541
+ }
542
+ broadcast('ready');
543
+ log.info`Watching for changes...`;
532
544
  }
533
- };
534
- configWatcher.on('add', onConfigFileChange);
535
- configWatcher.on('change', onConfigFileChange);
536
- configWatcher.on('unlink', onConfigFileChange);
537
-
538
- const watcher = chokidar.watch(watchPaths, {
539
- ignoreInitial: true,
540
- awaitWriteFinish: { stabilityThreshold: 100 },
541
- });
542
-
543
- watcher.on('change', onFileChange);
544
- watcher.on('add', onFileChange);
545
- watcher.on('unlink', onFileChange);
546
545
 
547
- let configWatcherReady = false;
548
- let contentWatcherReady = false;
546
+ configWatcher.on('ready', () => {
547
+ configWatcherReady = true;
548
+ onWatcherReady();
549
+ });
549
550
 
550
- function onWatcherReady(): void {
551
- if (!configWatcherReady || !contentWatcherReady) {
552
- return;
553
- }
554
- watcherReady = true;
555
- if (initialBuildFailed) {
556
- broadcast('error');
557
- }
558
- broadcast('ready');
559
- log.info`Watching for changes...`;
560
- }
561
-
562
- configWatcher.on('ready', () => {
563
- configWatcherReady = true;
564
- onWatcherReady();
551
+ watcher.on('ready', () => {
552
+ contentWatcherReady = true;
553
+ onWatcherReady();
554
+ });
565
555
  });
566
556
 
567
- watcher.on('ready', () => {
568
- contentWatcherReady = true;
569
- onWatcherReady();
570
- });
571
- });
557
+ // Return a promise that never resolves (watch mode runs until killed)
558
+ return new Promise(() => {});
559
+ }