@abreen/tada 1.0.2 → 1.2.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 (146) hide show
  1. package/README.md +96 -48
  2. package/bin/tada.ts +621 -0
  3. package/bin/validators.test.ts +205 -0
  4. package/bin/validators.ts +85 -0
  5. package/{webpack/apply-base-path-plugin.js → build/apply-base-path-plugin.ts} +37 -17
  6. package/build/build-manifest.test.ts +349 -0
  7. package/build/build-manifest.ts +165 -0
  8. package/build/bundle.ts +116 -0
  9. package/{webpack/code.test.js → build/code.test.ts} +10 -7
  10. package/build/colors.ts +25 -0
  11. package/build/content-watch.ts +107 -0
  12. package/build/copy.ts +118 -0
  13. package/{webpack/deflist-id-plugin.js → build/deflist-id-plugin.ts} +7 -6
  14. package/{webpack/external-links-plugin.js → build/external-links-plugin.ts} +14 -5
  15. package/build/features.ts +11 -0
  16. package/build/generate-content-assets.ts +320 -0
  17. package/build/generate-favicon.ts +172 -0
  18. package/build/generate-fonts.ts +31 -0
  19. package/build/generate-katex-assets.ts +35 -0
  20. package/{webpack/generate-manifest-plugin.js → build/generate-manifest.ts} +29 -36
  21. package/build/globals.test.ts +99 -0
  22. package/build/globals.ts +88 -0
  23. package/{webpack/heading-subtitle-plugin.js → build/heading-subtitle-plugin.ts} +4 -2
  24. package/build/json-schema.test.ts +57 -0
  25. package/build/json-schema.ts +33 -0
  26. package/build/log.test.ts +111 -0
  27. package/build/log.ts +167 -0
  28. package/build/markdown-plugins.test.ts +396 -0
  29. package/{webpack/pagefind-plugin.test.js → build/pagefind.test.ts} +74 -13
  30. package/build/pagefind.ts +339 -0
  31. package/build/pdf-text.test.ts +52 -0
  32. package/{webpack/pdf-text.js → build/pdf-text.ts} +47 -27
  33. package/build/pipeline.ts +120 -0
  34. package/{webpack/reachability.test.js → build/reachability.test.ts} +3 -3
  35. package/{webpack/reachability.js → build/reachability.ts} +77 -34
  36. package/build/serve.ts +112 -0
  37. package/build/site-variables.ts +68 -0
  38. package/{webpack/templates.js → build/templates.ts} +38 -35
  39. package/{webpack/text-to-id.js → build/text-to-id.ts} +2 -2
  40. package/build/toc-plugin.test.ts +105 -0
  41. package/{webpack/toc-plugin.js → build/toc-plugin.ts} +32 -13
  42. package/build/types.ts +181 -0
  43. package/build/util.ts +26 -0
  44. package/{webpack/utils/code.js → build/utils/code.ts} +119 -60
  45. package/{webpack/utils/content-files.js → build/utils/content-files.ts} +40 -35
  46. package/build/utils/derive-theme.test.ts +111 -0
  47. package/build/utils/derive-theme.ts +85 -0
  48. package/build/utils/file-types.test.ts +61 -0
  49. package/build/utils/file-types.ts +13 -0
  50. package/build/utils/front-matter.test.ts +77 -0
  51. package/{webpack/utils/front-matter.js → build/utils/front-matter.ts} +22 -9
  52. package/{webpack → build}/utils/jdi-runner/LiterateRunner.java +1 -1
  53. package/{webpack/utils/literate-java.js → build/utils/literate-java.ts} +68 -38
  54. package/{webpack/utils/markdown.js → build/utils/markdown.ts} +126 -49
  55. package/build/utils/paths.test.ts +91 -0
  56. package/{webpack/utils/paths.js → build/utils/paths.ts} +18 -22
  57. package/{webpack/utils/render.js → build/utils/render.ts} +208 -130
  58. package/build/utils/shiki-highlighter.ts +29 -0
  59. package/build/validate-internal-links-plugin.test.ts +106 -0
  60. package/{webpack/validate-internal-links-plugin.js → build/validate-internal-links-plugin.ts} +47 -20
  61. package/{webpack/watch-reachability-state.test.js → build/watch-reachability-state.test.ts} +8 -8
  62. package/{webpack/watch-reachability-state.js → build/watch-reachability-state.ts} +63 -24
  63. package/{webpack/watch-reload-client.js → build/watch-reload-client.ts} +3 -1
  64. package/build/watch.ts +549 -0
  65. package/fonts/google-sans-code/woff2/GoogleSansCodeVariable-Italic.woff2 +0 -0
  66. package/fonts/google-sans-code/woff2/GoogleSansCodeVariable.woff2 +0 -0
  67. package/fonts/inter/woff2/InterVariable-Italic.woff2 +0 -0
  68. package/fonts/inter/woff2/InterVariable.woff2 +0 -0
  69. package/{content → init/content}/index.md +9 -3
  70. package/{content → init/content}/lectures/index.md +1 -1
  71. package/{content → init/content}/markdown.md +28 -8
  72. package/init/content/problem_sets/index.html +14 -0
  73. package/package.json +33 -22
  74. package/{templates → schema}/nav.schema.json +1 -2
  75. package/{webpack → schema}/site.schema.json +4 -12
  76. package/src/_alerts.scss +96 -0
  77. package/src/_base.scss +145 -0
  78. package/src/{layout.scss → _layout.scss} +36 -1
  79. package/src/anchor/index.ts +0 -1
  80. package/src/anchor/style.scss +1 -9
  81. package/src/code/index.ts +3 -3
  82. package/src/code.scss +1 -1
  83. package/src/critical.scss +5 -0
  84. package/src/header/_base.scss +129 -0
  85. package/src/header/style.scss +3 -131
  86. package/src/index.ts +4 -5
  87. package/src/question/style.scss +1 -1
  88. package/src/search/index.test.ts +203 -0
  89. package/src/search/index.ts +38 -93
  90. package/src/search/pdf-utils.ts +79 -0
  91. package/src/search/style.scss +8 -28
  92. package/src/style.scss +15 -326
  93. package/src/timezone/index.ts +5 -6
  94. package/src/toc/style.scss +5 -39
  95. package/src/util.ts +9 -6
  96. package/templates/_theme.scss +38 -14
  97. package/templates/_top.html +1 -1
  98. package/tsconfig.json +10 -6
  99. package/types/dev.d.ts +1 -0
  100. package/types/file-system-access.d.ts +5 -0
  101. package/types/markdown-it-plugins.d.ts +11 -0
  102. package/types/site-variables.d.ts +6 -10
  103. package/types/untyped-modules.d.ts +47 -0
  104. package/bin/tada.js +0 -361
  105. package/config/authors.json +0 -1
  106. package/config/nav.json +0 -28
  107. package/content/problem_sets/index.md +0 -6
  108. package/types/dev.ts +0 -3
  109. package/webpack/build-state.js +0 -97
  110. package/webpack/colors.js +0 -15
  111. package/webpack/config.base.js +0 -151
  112. package/webpack/config.dev.js +0 -23
  113. package/webpack/config.prod.js +0 -32
  114. package/webpack/content-watch-plugin.js +0 -153
  115. package/webpack/features.js +0 -5
  116. package/webpack/generate-content-assets-plugin.js +0 -308
  117. package/webpack/generate-favicon-plugin.js +0 -198
  118. package/webpack/generate-fonts-plugin.js +0 -69
  119. package/webpack/globals.js +0 -74
  120. package/webpack/json-schema.js +0 -19
  121. package/webpack/log.js +0 -143
  122. package/webpack/markdown-plugins.test.js +0 -203
  123. package/webpack/pagefind-plugin.js +0 -379
  124. package/webpack/print-flair-plugin.js +0 -22
  125. package/webpack/serve.js +0 -104
  126. package/webpack/site-variables.js +0 -53
  127. package/webpack/util.js +0 -49
  128. package/webpack/utils/define-plugin.js +0 -20
  129. package/webpack/utils/file-types.js +0 -26
  130. package/webpack/utils/parse-hsl.js +0 -8
  131. package/webpack/utils/shiki-highlighter.js +0 -26
  132. package/webpack/watch.js +0 -166
  133. /package/{webpack → build}/flair.json +0 -0
  134. /package/{webpack → build}/utils/jdi-runner/LiterateRunner.class +0 -0
  135. /package/fonts/google-sans-code/{GoogleSansCodeVariable-Italic.ttf → ttf/GoogleSansCodeVariable-Italic.ttf} +0 -0
  136. /package/fonts/google-sans-code/{GoogleSansCodeVariable.ttf → ttf/GoogleSansCodeVariable.ttf} +0 -0
  137. /package/fonts/inter/{InterVariable-Italic.ttf → ttf/InterVariable-Italic.ttf} +0 -0
  138. /package/fonts/inter/{InterVariable.ttf → ttf/InterVariable.ttf} +0 -0
  139. /package/{content → init/content}/lectures/01/Pair.java.md +0 -0
  140. /package/{content → init/content}/lectures/01/Rectangle.java +0 -0
  141. /package/{content → init/content}/lectures/01/demo.py +0 -0
  142. /package/{content → init/content}/lectures/01/index.md +0 -0
  143. /package/{content → init/content}/lectures/01/lecture1.pdf +0 -0
  144. /package/{public → init/public}/avatars/alex.jpg +0 -0
  145. /package/{public → init/public}/test.txt +0 -0
  146. /package/{templates → schema}/authors.schema.json +0 -0
package/build/watch.ts ADDED
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env bun
2
+ import fs from 'fs';
3
+ import { fork } from 'child_process';
4
+ import path from 'path';
5
+ import chokidar from 'chokidar';
6
+ import WebSocket, { WebSocketServer } from 'ws';
7
+ import type { SiteVariables, WatchState } from './types';
8
+ import { B } from './colors';
9
+ import { makeLogger, printFlair } from './log';
10
+ import { getDevSiteVariables } from './site-variables';
11
+ import { compileTemplates, getJsonDataDir, JSON_DATA_FILES } from './templates';
12
+ import {
13
+ getContentDir,
14
+ getPublicDir,
15
+ getDistDir,
16
+ getPackageDir,
17
+ } from './utils/paths';
18
+ import { isFeatureEnabled } from './features';
19
+ import { bundle } from './bundle';
20
+ import { copyFonts } from './generate-fonts';
21
+ import { copyKatexAssets } from './generate-katex-assets';
22
+ import { generateFavicons } from './generate-favicon';
23
+ import { generateManifest } from './generate-manifest';
24
+ import {
25
+ copyPublicFiles,
26
+ copyContentAssets,
27
+ copyContentFile,
28
+ copyPublicFile,
29
+ } from './copy';
30
+ import { getProcessedExtensions } from './utils/file-types';
31
+ import { ContentRenderer } from './generate-content-assets';
32
+ import { WatchPagefindRunner } from './pagefind';
33
+ import { ContentChangeDetector } from './content-watch';
34
+
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
+ }
42
+ }
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
+
73
+ type ChangeCategory = 'content' | 'public' | 'config';
74
+
75
+ const log = makeLogger(__filename);
76
+ const wslog = makeLogger('WebSocket');
77
+
78
+ // --- WebSocket server (unchanged) ---
79
+
80
+ let webSocketsReady = false;
81
+ let watcherReady = false;
82
+ let webServerReady = false;
83
+ let webServerTimeout: ReturnType<typeof setTimeout> | undefined;
84
+ let serveStarted = false;
85
+
86
+ let wss: WebSocketServer | null = null;
87
+ try {
88
+ wss = new WebSocketServer({ port: WEBSOCKET_PORT });
89
+
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');
95
+ }
96
+ conn.send('ready');
97
+ }
98
+ conn.on('close', () => {
99
+ wslog.debug`WebSocket client disconnected`;
100
+ });
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
+
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
+ }
126
+
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
+ });
149
+
150
+ webServerTimeout = setTimeout(() => {
151
+ if (webServerReady) {
152
+ return;
153
+ }
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;
168
+ }
169
+
170
+ const normalizedContentDir = path.resolve(contentDir) + path.sep;
171
+ const normalizedFilePath = path.resolve(filePath);
172
+ if (!normalizedFilePath.startsWith(normalizedContentDir)) {
173
+ return null;
174
+ }
175
+
176
+ return path
177
+ .relative(contentDir, normalizedFilePath)
178
+ .split(path.sep)
179
+ .join(path.posix.sep);
180
+ }
181
+
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;
190
+ }
191
+ return path
192
+ .relative(publicDir, normalizedFilePath)
193
+ .split(path.sep)
194
+ .join(path.posix.sep);
195
+ }
196
+
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 });
221
+
222
+ compileTemplates(siteVariables);
223
+ contentRenderer = new ContentRenderer(siteVariables);
224
+ changeDetector = new ContentChangeDetector(siteVariables);
225
+
226
+ if (isFeatureEnabled(siteVariables, 'search')) {
227
+ pagefindRunner = new WatchPagefindRunner(siteVariables);
228
+ }
229
+
230
+ await contentRenderer.initHighlighter();
231
+
232
+ assetFiles = [
233
+ ...(await bundle(siteVariables, { mode: 'development' })),
234
+ ...(await bundleReloadClient()),
235
+ ];
236
+
237
+ copyFonts(distDir);
238
+ copyKatexAssets(distDir);
239
+
240
+ if (isFeatureEnabled(siteVariables, 'favicon')) {
241
+ await generateFavicons(siteVariables, distDir);
242
+ generateManifest(siteVariables, distDir);
243
+ }
244
+
245
+ publicRelPaths = copyPublicFiles(publicDir, distDir);
246
+ contentAssetRelPaths = copyContentAssets(
247
+ contentDir,
248
+ distDir,
249
+ [...processedExtSet],
250
+ publicRelPaths,
251
+ );
252
+
253
+ const result = contentRenderer.processContent({ distDir, assetFiles });
254
+
255
+ for (const err of result.errors) {
256
+ log.error`${err.message}`;
257
+ }
258
+
259
+ if (result.errors.length === 0) {
260
+ printFlair();
261
+
262
+ if (pagefindRunner) {
263
+ pagefindRunner.update(distDir, result.htmlAssetsByPath);
264
+ setImmediate(() => pagefindRunner!.run());
265
+ }
266
+
267
+ if (!serveStarted) {
268
+ serveStarted = true;
269
+ serve();
270
+ }
271
+ }
272
+ }
273
+
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');
280
+
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';
292
+ }
293
+ return null;
294
+ }
295
+
296
+ async function rebuild(): Promise<void> {
297
+ if (rebuilding) {
298
+ return;
299
+ }
300
+ rebuilding = true;
301
+
302
+ const changes = new Set(pendingChanges);
303
+ pendingChanges.clear();
304
+
305
+ broadcast('rebuilding');
306
+
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);
313
+ }
314
+ }
315
+
316
+ let succeeded = false;
317
+
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();
333
+
334
+ assetFiles = [
335
+ ...(await bundle(siteVariables, { mode: 'development' })),
336
+ ...(await bundleReloadClient()),
337
+ ];
338
+
339
+ copyFonts(distDir);
340
+ copyKatexAssets(distDir);
341
+
342
+ if (isFeatureEnabled(siteVariables, 'favicon')) {
343
+ await generateFavicons(siteVariables, distDir);
344
+ generateManifest(siteVariables, distDir);
345
+ }
346
+
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();
356
+ }
357
+ broadcast('reload');
358
+ if (pagefindRunner) {
359
+ pagefindRunner.update(distDir, result.htmlAssetsByPath);
360
+ setImmediate(() => pagefindRunner!.run());
361
+ }
362
+ succeeded = true;
363
+ }
364
+ return;
365
+ }
366
+
367
+ // Public file changed, copy just that file
368
+ if (categories.has('public') && !categories.has('content')) {
369
+ 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);
379
+ }
380
+ }
381
+ }
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
+
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`;
406
+ }
407
+ }
408
+ }
409
+
410
+ // Copy any changed public files too
411
+ if (categories.has('public')) {
412
+ for (const filePath of changes) {
413
+ if (classifyChange(filePath) === 'public') {
414
+ const absPath = path.resolve(filePath);
415
+ if (fs.existsSync(absPath)) {
416
+ copyPublicFile(publicDir, distDir, absPath, contentAssetRelPaths);
417
+ const rel = path
418
+ .relative(publicDir, absPath)
419
+ .split(path.sep)
420
+ .join(path.posix.sep);
421
+ publicRelPaths.add(rel);
422
+ }
423
+ }
424
+ }
425
+ }
426
+
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
+ }
445
+
446
+ const watchState: WatchState | undefined = detection.needsRestart
447
+ ? undefined
448
+ : {
449
+ changedContentFiles: detection.changedContentFiles,
450
+ templatesChanged: detection.templatesChanged,
451
+ };
452
+
453
+ const result = contentRenderer.processContent({
454
+ distDir,
455
+ assetFiles,
456
+ watchState,
457
+ });
458
+
459
+ for (const err of result.errors) {
460
+ log.error`${err.message}`;
461
+ }
462
+
463
+ if (result.errors.length === 0) {
464
+ printFlair();
465
+ if (!serveStarted) {
466
+ serveStarted = true;
467
+ serve();
468
+ }
469
+ broadcast('reload');
470
+
471
+ if (pagefindRunner) {
472
+ pagefindRunner.update(distDir, result.htmlAssetsByPath);
473
+ setImmediate(() => pagefindRunner!.run());
474
+ }
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
+ }
483
+ rebuilding = false;
484
+
485
+ // If more changes accumulated during rebuild, schedule another
486
+ if (pendingChanges.size > 0) {
487
+ scheduleRebuild();
488
+ }
489
+ }
490
+ }
491
+
492
+ function scheduleRebuild(): void {
493
+ if (debounceTimer) {
494
+ clearTimeout(debounceTimer);
495
+ }
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
+
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
+ // Watch content, public, data files, site config
515
+ const watchPaths: string[] = [contentDir, publicDir];
516
+
517
+ // Watch data files (nav.json, authors.json)
518
+ const jsonDataDir = getJsonDataDir();
519
+ for (const dataFile of JSON_DATA_FILES) {
520
+ const dataPath = path.join(jsonDataDir, dataFile);
521
+ if (fs.existsSync(dataPath)) {
522
+ watchPaths.push(dataPath);
523
+ }
524
+ }
525
+
526
+ // Watch site config
527
+ const siteConfigPath = path.resolve('site.dev.json');
528
+ if (fs.existsSync(siteConfigPath)) {
529
+ watchPaths.push(siteConfigPath);
530
+ }
531
+
532
+ const watcher = chokidar.watch(watchPaths, {
533
+ ignoreInitial: true,
534
+ awaitWriteFinish: { stabilityThreshold: 100 },
535
+ });
536
+
537
+ watcher.on('change', onFileChange);
538
+ watcher.on('add', onFileChange);
539
+ watcher.on('unlink', onFileChange);
540
+
541
+ watcher.on('ready', () => {
542
+ watcherReady = true;
543
+ if (initialBuildFailed) {
544
+ broadcast('error');
545
+ }
546
+ broadcast('ready');
547
+ log.info`Watching for changes...`;
548
+ });
549
+ });
@@ -1,10 +1,13 @@
1
1
  title: Home
2
2
  description: The home page for <%= site.title %>.
3
3
  author: alex
4
+ published: 2026-03-20
5
+ toolName: Tada
4
6
 
5
7
  ## Welcome
6
8
 
7
- This is an example site built with [Tada](https://github.com/abreen/tada).
9
+ This is an example site built with <%= page.toolName %>.
10
+
8
11
 
9
12
  ## Getting started
10
13
 
@@ -14,6 +17,9 @@ contains the site configuration, and `templates/` contains the HTML layouts.
14
17
  See the [Markdown examples page](/markdown.html) for syntax examples.
15
18
 
16
19
  !!! note
17
- Consult the [readme](https://github.com/abreen/tada) for full
18
- documentation on configuration, templates, and content authoring.
20
+ For more documentation, see the [GitHub page][github].
19
21
  !!!
22
+
23
+
24
+
25
+ [github]: https://github.com/abreen/Tada
@@ -2,7 +2,7 @@ title: Lectures
2
2
  author: alex
3
3
  description: An example page for <%= site.title %>.
4
4
 
5
- <%= timezoneChooser %>
5
+ <%= renderTimeZoneChooser() %>
6
6
 
7
7
  One-hour lectures are held weekly at <time datetime="17:40">5:40 PM</time>.
8
8
  Each lecture is recorded and made available by password-protected link.
@@ -1,7 +1,8 @@
1
1
  title: Markdown Examples
2
2
  description: Examples of Markdown syntax supported by Tada.
3
- toc: true
4
3
  author: alex
4
+ published: 2026-03-20
5
+ toc: true
5
6
 
6
7
  Markdown and HTML files in the `content/` directory
7
8
  must contain ["front matter"][front-matter] (YAML-formatted metadata).
@@ -127,6 +128,20 @@ See `config/site.dev.json` and `config/site.prod.json` to change `site.basePath`
127
128
  and `site.internalDomains`.
128
129
 
129
130
 
131
+ ## LaTeX
132
+
133
+ $\LaTeX$ support is implemented via [KaTeX](https://katex.org/). The standard
134
+ dollar sign-based syntax is supported. Wrap inline math in single `$`,
135
+ or use `$$` for a math block.
136
+
137
+ Here's an example: selection sort performs $O(n^2)$ comparisons. And to
138
+ demonstrate a block, here's the exact number of comparisons:
139
+
140
+ $$
141
+ (n-1) + (n-2) + \dots + 1 = \sum_{i=1}^{n-1}i
142
+ $$
143
+
144
+
130
145
  ## Additional syntax
131
146
 
132
147
  !!! note Headings are links
@@ -227,7 +242,7 @@ Beware of this rule.
227
242
  A time zone chooser for testing styles (see the "time zone chooser" section
228
243
  below):
229
244
 
230
- <%= timezoneChooser %>
245
+ <%= renderTimeZoneChooser() %>
231
246
 
232
247
  A `<time>` element for testing styles: <time datetime="15:00">3 pm</time>
233
248
 
@@ -325,14 +340,19 @@ These features aren't Markdown-specific, but are included here for reference.
325
340
 
326
341
  Allows the user to see times in their local time zone.
327
342
 
328
- !!! note
329
- Look at the source code for this file to see how to insert it.
330
- !!!
343
+ Insert the time zone chooser using Lodash templates, calling the
344
+ `renderTimeZoneChooser()` function (made available on any page as a global):
345
+
346
+ ```
347
+ <%= '\x3C%= renderTimeZoneChooser() %\x3E' %>
348
+ ```
349
+
350
+ To produce:
331
351
 
332
- <%= timezoneChooser %>
352
+ <%= renderTimeZoneChooser() %>
333
353
 
334
- Wrap your times in `<time datetime="...">` elements and they will be updated
335
- when the user makes a time zone selection.
354
+ Then, wrap your times in `<time datetime="...">` elements and they will be
355
+ updated when the user makes a time zone selection.
336
356
 
337
357
  Use
338
358
 
@@ -0,0 +1,14 @@
1
+ title: Problem Sets
2
+ author: alex
3
+ description: An example page for <%= site.title %> which is not generated.
4
+ skip: true
5
+
6
+ <p>
7
+ For convenience or backwards compatibility, you can write standard HTML
8
+ files. The front matter section should still be specified for HTML files.
9
+ </p>
10
+
11
+ <p>
12
+ This page is not generated during the build because <code>skip</code>
13
+ is set to <code>true</code>.
14
+ </p>