@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/bin/tada.ts ADDED
@@ -0,0 +1,621 @@
1
+ #!/usr/bin/env bun
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { execSync } from 'child_process';
5
+ import { parseArgs } from 'util';
6
+ import packageJson from '../package.json' with { type: 'json' };
7
+ import {
8
+ validateSymbol,
9
+ validateColor,
10
+ validateHue,
11
+ validateUrl,
12
+ validateBasePath,
13
+ createSiteConfig,
14
+ } from './validators';
15
+ import {
16
+ loadManifest,
17
+ diffManifests,
18
+ copyChangedFiles,
19
+ getVersions,
20
+ pruneOldVersions,
21
+ } from '../build/build-manifest';
22
+
23
+ const { version } = packageJson;
24
+
25
+ const SYSTEM_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
26
+
27
+ const packageDir = path.resolve(__dirname, '..');
28
+
29
+ function requireSiteConfig(env: string): void {
30
+ const configPath = path.resolve(process.cwd(), `site.${env}.json`);
31
+ if (!fs.existsSync(configPath)) {
32
+ console.error(`Error: Missing config file: site.${env}.json`);
33
+ process.exit(1);
34
+ }
35
+ }
36
+
37
+ const COMMANDS = {
38
+ init: null,
39
+ dev: 'Build the site for development',
40
+ prod: 'Build the site for production',
41
+ watch: 'Watch for changes and rebuild',
42
+ serve: 'Start a local development server',
43
+ clean: null,
44
+ diff: null,
45
+ };
46
+
47
+ interface InitQuestion {
48
+ prompt: string;
49
+ defaultValue: string;
50
+ validate: (value: string) => string | null;
51
+ }
52
+
53
+ const INIT_QUESTIONS: Record<string, InitQuestion> = {
54
+ title: {
55
+ prompt: 'Site title',
56
+ defaultValue: 'Introduction to Computer Science',
57
+ validate: (v: string) => (v ? null : 'Title is required'),
58
+ },
59
+ symbol: {
60
+ prompt: 'Logo symbol (1-5 uppercase chars)',
61
+ defaultValue: 'CS 0',
62
+ validate: validateSymbol,
63
+ },
64
+ themeColor: {
65
+ prompt: 'Theme color',
66
+ defaultValue: 'hsl(195 70% 40%)',
67
+ validate: validateColor,
68
+ },
69
+ tintHue: {
70
+ prompt: 'Background tint hue (0-360)',
71
+ defaultValue: '20',
72
+ validate: validateHue,
73
+ },
74
+ tintAmount: {
75
+ prompt: 'Background tint amount (0-100)',
76
+ defaultValue: '100',
77
+ validate: (v: string) => {
78
+ const n = Number(v);
79
+ if (!Number.isInteger(n) || n < 0 || n > 100) {
80
+ return 'Must be an integer from 0 to 100';
81
+ }
82
+ return null;
83
+ },
84
+ },
85
+ defaultTimeZone: {
86
+ prompt: 'Default time zone',
87
+ defaultValue: SYSTEM_TIME_ZONE,
88
+ validate: (v: string) => (v ? null : 'Time zone is required'),
89
+ },
90
+ prodBase: {
91
+ prompt: 'Production base URL',
92
+ defaultValue: 'https://example.edu',
93
+ validate: validateUrl,
94
+ },
95
+ prodBasePath: {
96
+ prompt: 'Production base path',
97
+ defaultValue: '/',
98
+ validate: validateBasePath,
99
+ },
100
+ };
101
+
102
+ function printUsage() {
103
+ console.log(`tada v${version}\n`);
104
+ console.log('Usage: tada <command>\n');
105
+ console.log('Commands:');
106
+ for (const [cmd, desc] of Object.entries(COMMANDS)) {
107
+ if (cmd === 'init') {
108
+ console.log(' init <dirname> Initialize a new site');
109
+ console.log(
110
+ ' [--no-interactive] (Skip prompts; use defaults or flags)',
111
+ );
112
+ console.log(
113
+ ' [--bare] (Create a minimal site with one page)',
114
+ );
115
+ console.log(' [--title, --symbol, --theme-color, --tint-hue,');
116
+ console.log(' --tint-amount, --default-time-zone, --prod-base,');
117
+ console.log(' --prod-base-path]');
118
+ continue;
119
+ } else if (cmd === 'clean') {
120
+ console.log(' clean Remove the dist/ directory');
121
+ console.log(
122
+ ' [--prod] Prune old prod builds, keeping the two latest',
123
+ );
124
+ continue;
125
+ } else if (cmd === 'diff') {
126
+ console.log(
127
+ ' diff Show changed files since last prod build',
128
+ );
129
+ console.log(' [N M] Compare version N vs version M');
130
+ console.log(
131
+ ' [--copy <dir>] Copy changed files to a directory',
132
+ );
133
+ continue;
134
+ }
135
+ console.log(` ${cmd.padEnd(18)} ${desc}`);
136
+ }
137
+ }
138
+
139
+ function run(cmd: string): void {
140
+ execSync(cmd, { cwd: process.cwd(), stdio: 'inherit' });
141
+ }
142
+
143
+ function copyDirRecursive(src: string, dest: string): void {
144
+ fs.mkdirSync(dest, { recursive: true });
145
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
146
+ const srcPath = path.join(src, entry.name);
147
+ const destPath = path.join(dest, entry.name);
148
+ if (entry.isDirectory()) {
149
+ copyDirRecursive(srcPath, destPath);
150
+ } else {
151
+ fs.copyFileSync(srcPath, destPath);
152
+ }
153
+ }
154
+ }
155
+
156
+ const FLAG_TO_KEY: Record<string, string> = {
157
+ title: 'title',
158
+ symbol: 'symbol',
159
+ 'theme-color': 'themeColor',
160
+ 'tint-hue': 'tintHue',
161
+ 'tint-amount': 'tintAmount',
162
+ 'default-time-zone': 'defaultTimeZone',
163
+ 'prod-base': 'prodBase',
164
+ 'prod-base-path': 'prodBasePath',
165
+ };
166
+
167
+ async function initCommand(args: string[]): Promise<void> {
168
+ let values: Record<string, string | boolean | undefined>;
169
+ let positionals: string[];
170
+
171
+ try {
172
+ ({ values, positionals } = parseArgs({
173
+ args,
174
+ options: {
175
+ 'no-interactive': { type: 'boolean', default: false },
176
+ bare: { type: 'boolean', default: false },
177
+ title: { type: 'string', default: INIT_QUESTIONS.title.defaultValue },
178
+ symbol: { type: 'string', default: INIT_QUESTIONS.symbol.defaultValue },
179
+ 'theme-color': {
180
+ type: 'string',
181
+ default: INIT_QUESTIONS.themeColor.defaultValue,
182
+ },
183
+ 'tint-hue': {
184
+ type: 'string',
185
+ default: INIT_QUESTIONS.tintHue.defaultValue,
186
+ },
187
+ 'tint-amount': {
188
+ type: 'string',
189
+ default: INIT_QUESTIONS.tintAmount.defaultValue,
190
+ },
191
+ 'default-time-zone': {
192
+ type: 'string',
193
+ default: INIT_QUESTIONS.defaultTimeZone.defaultValue,
194
+ },
195
+ 'prod-base': {
196
+ type: 'string',
197
+ default: INIT_QUESTIONS.prodBase.defaultValue,
198
+ },
199
+ 'prod-base-path': {
200
+ type: 'string',
201
+ default: INIT_QUESTIONS.prodBasePath.defaultValue,
202
+ },
203
+ },
204
+ strict: true,
205
+ allowPositionals: true,
206
+ }));
207
+ } catch (e: unknown) {
208
+ console.error(`Error: ${(e as Error).message}`);
209
+ process.exit(1);
210
+ }
211
+
212
+ const noInteractive = values['no-interactive'] as boolean;
213
+ const bare = values.bare as boolean;
214
+ const dirname = positionals[0];
215
+
216
+ if (!dirname) {
217
+ console.error('Error: Provide a name for the new directory');
218
+ console.log('Usage: tada init <dirname> [--no-interactive] [--bare]');
219
+ process.exit(1);
220
+ }
221
+
222
+ const projectDir = path.resolve(process.cwd(), dirname);
223
+ if (fs.existsSync(projectDir)) {
224
+ console.error(`Error: "${dirname}" already exists`);
225
+ process.exit(1);
226
+ }
227
+
228
+ const message = `Creating a new Tada site in ${projectDir}`;
229
+ if (noInteractive && bare) {
230
+ console.log(message + ' using default config (bare)');
231
+ } else if (noInteractive) {
232
+ console.log(message + ' using default config');
233
+ } else if (bare) {
234
+ console.log(message + ' (bare)');
235
+ } else {
236
+ console.log(message);
237
+ }
238
+
239
+ const config: Record<string, string> = {};
240
+
241
+ if (noInteractive) {
242
+ for (const [flag, key] of Object.entries(FLAG_TO_KEY)) {
243
+ const value = values[flag] as string;
244
+ const error = INIT_QUESTIONS[key].validate(value);
245
+ if (error) {
246
+ console.error(`Error: --${flag}: ${error}`);
247
+ process.exit(1);
248
+ }
249
+ config[key] = value;
250
+ }
251
+ } else {
252
+ const questions = Object.entries(INIT_QUESTIONS);
253
+ let qi = 0;
254
+ let { prompt, defaultValue, validate } = questions[qi][1];
255
+ let suffix = defaultValue != null ? ` (default: ${defaultValue})` : '';
256
+ process.stdout.write(`${prompt}${suffix}? `);
257
+
258
+ for await (const line of console) {
259
+ const value = line.trim() || defaultValue || '';
260
+
261
+ if (validate) {
262
+ const error = validate(value);
263
+ if (error) {
264
+ console.error(`Error: ${error}`);
265
+ process.stdout.write(`${prompt}${suffix}? `);
266
+ continue;
267
+ }
268
+ }
269
+
270
+ config[questions[qi][0]] = value;
271
+ qi++;
272
+
273
+ if (qi >= questions.length) {
274
+ break;
275
+ }
276
+
277
+ ({ prompt, defaultValue, validate } = questions[qi][1]);
278
+
279
+ suffix = defaultValue != null ? ` (default: ${defaultValue})` : '';
280
+
281
+ process.stdout.write(`${prompt}${suffix}? `);
282
+ }
283
+ }
284
+
285
+ const {
286
+ title,
287
+ symbol,
288
+ themeColor,
289
+ tintHue,
290
+ tintAmount,
291
+ defaultTimeZone,
292
+ prodBase,
293
+ prodBasePath,
294
+ } = config;
295
+
296
+ // Derive internal domain from production base URL
297
+ const prodDomain = new URL(prodBase).hostname;
298
+
299
+ // Create project directory
300
+ fs.mkdirSync(projectDir);
301
+
302
+ // Generate site configs
303
+ const devConfig = createSiteConfig({
304
+ title,
305
+ symbol,
306
+ themeColor,
307
+ tintHue,
308
+ tintAmount,
309
+ defaultTimeZone,
310
+ base: 'http://localhost:8080',
311
+ basePath: '/',
312
+ internalDomains: ['localhost'],
313
+ features: { search: true, code: true, favicon: false },
314
+ });
315
+
316
+ const prodConfig = createSiteConfig({
317
+ title,
318
+ symbol,
319
+ themeColor,
320
+ tintHue,
321
+ tintAmount,
322
+ defaultTimeZone,
323
+ base: prodBase,
324
+ basePath: prodBasePath,
325
+ internalDomains: [prodDomain],
326
+ features: { search: true, code: true, favicon: true },
327
+ });
328
+
329
+ fs.writeFileSync(
330
+ path.join(projectDir, 'site.dev.json'),
331
+ JSON.stringify(devConfig, null, 2) + '\n',
332
+ );
333
+
334
+ fs.writeFileSync(
335
+ path.join(projectDir, 'site.prod.json'),
336
+ JSON.stringify(prodConfig, null, 2) + '\n',
337
+ );
338
+
339
+ if (bare) {
340
+ fs.mkdirSync(path.join(projectDir, 'content'), { recursive: true });
341
+ fs.writeFileSync(
342
+ path.join(projectDir, 'content', 'index.md'),
343
+ 'title: Home\n\nWelcome to your new site.\n',
344
+ );
345
+ fs.mkdirSync(path.join(projectDir, 'public'), { recursive: true });
346
+ fs.writeFileSync(
347
+ path.join(projectDir, 'nav.json'),
348
+ JSON.stringify(
349
+ [
350
+ {
351
+ title: 'Navigation',
352
+ links: [{ text: 'Home', internal: '/index.html' }],
353
+ },
354
+ ],
355
+ null,
356
+ 2,
357
+ ) + '\n',
358
+ );
359
+ } else {
360
+ // Create nav and authors data files in the project root
361
+ fs.writeFileSync(
362
+ path.join(projectDir, 'nav.json'),
363
+ JSON.stringify(
364
+ [
365
+ {
366
+ title: 'Navigation',
367
+ links: [{ text: 'Home', internal: '/index.html' }],
368
+ },
369
+ {
370
+ title: 'Topics',
371
+ links: [
372
+ { text: 'Lectures', internal: '/lectures/index.html' },
373
+ {
374
+ text: 'Problem Sets',
375
+ internal: '/problem_sets/index.html',
376
+ disabled: true,
377
+ },
378
+ ],
379
+ },
380
+ {
381
+ title: 'Links',
382
+ links: [
383
+ { text: 'Zoom', external: 'https://zoom.com' },
384
+ {
385
+ text: 'Canvas',
386
+ external: 'https://www.instructure.com/',
387
+ disabled: true,
388
+ },
389
+ ],
390
+ },
391
+ ],
392
+ null,
393
+ 2,
394
+ ) + '\n',
395
+ );
396
+ fs.writeFileSync(
397
+ path.join(projectDir, 'authors.json'),
398
+ JSON.stringify(
399
+ { alex: { name: 'Alex Breen', avatar: '/avatars/alex.jpg' } },
400
+ null,
401
+ 2,
402
+ ) + '\n',
403
+ );
404
+
405
+ // Copy content/ and public/ from the package
406
+ copyDirRecursive(
407
+ path.join(packageDir, 'init/content'),
408
+ path.join(projectDir, 'content'),
409
+ );
410
+ copyDirRecursive(
411
+ path.join(packageDir, 'init/public'),
412
+ path.join(projectDir, 'public'),
413
+ );
414
+ }
415
+
416
+ console.log(`\nGenerated a new site in ${projectDir}`);
417
+ console.log(`\nNext steps:`);
418
+ console.log(` cd ${dirname}`);
419
+ console.log(` tada dev`);
420
+ console.log(` tada serve`);
421
+ }
422
+
423
+ function cleanCommand(args: string[]): void {
424
+ if (args.includes('--prod')) {
425
+ const prodBase = path.resolve(process.cwd(), 'dist-prod');
426
+ const removed = pruneOldVersions(prodBase);
427
+ if (removed.length === 0) {
428
+ console.log('Nothing to clean.');
429
+ return;
430
+ }
431
+ for (const v of removed) {
432
+ console.log(`Removed v${v}`);
433
+ }
434
+ return;
435
+ }
436
+
437
+ const distDir = path.resolve(process.cwd(), 'dist');
438
+ if (!fs.existsSync(distDir)) {
439
+ console.log('Nothing to clean.');
440
+ return;
441
+ }
442
+ fs.rmSync(distDir, { recursive: true });
443
+ console.log('Cleaned dist/');
444
+ }
445
+
446
+ function diffCommand(args: string[]): void {
447
+ const projectDir = process.cwd();
448
+ const prodBase = path.resolve(projectDir, 'dist-prod');
449
+ const versions = getVersions(prodBase);
450
+
451
+ if (versions.length === 0) {
452
+ console.error('Error: No prod builds found. Run "tada prod" first.');
453
+ process.exit(1);
454
+ }
455
+
456
+ const copyIdx = args.indexOf('--copy');
457
+ const versionArgs = copyIdx === -1 ? args : args.slice(0, copyIdx);
458
+ const numericArgs = versionArgs
459
+ .map(a => parseInt(a, 10))
460
+ .filter(n => !isNaN(n));
461
+
462
+ let oldVer: number;
463
+ let newVer: number;
464
+
465
+ if (numericArgs.length === 2) {
466
+ [oldVer, newVer] = numericArgs.sort((a, b) => a - b);
467
+ } else if (numericArgs.length === 0) {
468
+ if (versions.length < 2) {
469
+ console.error(
470
+ 'Error: Need at least two prod builds to diff. Run "tada prod" again.',
471
+ );
472
+ process.exit(1);
473
+ }
474
+ oldVer = versions[versions.length - 2];
475
+ newVer = versions[versions.length - 1];
476
+ } else {
477
+ console.error('Error: Provide zero or two version numbers.');
478
+ console.log('Usage: tada diff [N M] [--copy <dir>]');
479
+ process.exit(1);
480
+ }
481
+
482
+ const oldManifestPath = path.join(prodBase, `v${oldVer}.manifest.json`);
483
+ const newManifestPath = path.join(prodBase, `v${newVer}.manifest.json`);
484
+
485
+ const oldManifest = loadManifest(oldManifestPath);
486
+ if (!oldManifest) {
487
+ console.error(`Error: No manifest for v${oldVer}.`);
488
+ process.exit(1);
489
+ }
490
+ const newManifest = loadManifest(newManifestPath);
491
+ if (!newManifest) {
492
+ console.error(`Error: No manifest for v${newVer}.`);
493
+ process.exit(1);
494
+ }
495
+
496
+ const diff = diffManifests(oldManifest, newManifest);
497
+ const totalChanges =
498
+ diff.added.length + diff.changed.length + diff.removed.length;
499
+
500
+ const fmtDate = (iso: string) => new Date(iso).toLocaleString();
501
+ console.log(`v${oldVer} ${fmtDate(oldManifest.buildTime)}`);
502
+ console.log(`v${newVer} ${fmtDate(newManifest.buildTime)}`);
503
+
504
+ if (totalChanges === 0) {
505
+ console.log('\nNo changes between builds.');
506
+ return;
507
+ }
508
+
509
+ if (diff.added.length > 0) {
510
+ console.log(`\nAdded (${diff.added.length}):`);
511
+ for (const f of diff.added) {
512
+ console.log(` + ${f}`);
513
+ }
514
+ }
515
+ if (diff.changed.length > 0) {
516
+ console.log(`\nChanged (${diff.changed.length}):`);
517
+ for (const f of diff.changed) {
518
+ console.log(` ~ ${f}`);
519
+ }
520
+ }
521
+ if (diff.removed.length > 0) {
522
+ console.log(`\nRemoved (${diff.removed.length}):`);
523
+ for (const f of diff.removed) {
524
+ console.log(` - ${f}`);
525
+ }
526
+ }
527
+
528
+ const noun = (n: number) => (n === 1 ? 'file' : 'files');
529
+ console.log(`\nTotal: ${totalChanges} ${noun(totalChanges)} differ`);
530
+
531
+ if (copyIdx !== -1) {
532
+ const outDirArg = args[copyIdx + 1];
533
+ if (!outDirArg) {
534
+ console.error('Error: --copy requires a directory argument');
535
+ process.exit(1);
536
+ }
537
+
538
+ const newDistDir = path.join(prodBase, `v${newVer}`);
539
+ const resolvedOutDir = path.resolve(projectDir, outDirArg);
540
+ copyChangedFiles(diff, newDistDir, resolvedOutDir);
541
+
542
+ // Always include pagefind/ (excluded from manifest but needed for search)
543
+ const pagefindSrc = path.join(newDistDir, 'pagefind');
544
+ if (fs.existsSync(pagefindSrc)) {
545
+ copyDirRecursive(pagefindSrc, path.join(resolvedOutDir, 'pagefind'));
546
+ }
547
+
548
+ const manifestSrc = path.join(prodBase, `v${newVer}.manifest.json`);
549
+ fs.copyFileSync(manifestSrc, path.join(resolvedOutDir, 'manifest.json'));
550
+
551
+ const copiedCount = diff.added.length + diff.changed.length;
552
+ console.log(
553
+ `\nCopied ${copiedCount} ${noun(copiedCount)} + manifest.json to ${outDirArg}`,
554
+ );
555
+
556
+ if (diff.removed.length > 0) {
557
+ console.log(
558
+ `\nNote: ${diff.removed.length} ${noun(diff.removed.length)} were removed from the build.`,
559
+ );
560
+ }
561
+ }
562
+ }
563
+
564
+ /*
565
+ * Start of script
566
+ */
567
+
568
+ const command = process.argv[2];
569
+
570
+ switch (command) {
571
+ case 'init':
572
+ initCommand(process.argv.slice(3));
573
+ break;
574
+
575
+ case 'dev':
576
+ requireSiteConfig('dev');
577
+ run(`bun ${path.join(packageDir, 'build/pipeline.ts')} dev`);
578
+ break;
579
+
580
+ case 'prod':
581
+ requireSiteConfig('prod');
582
+ run(`bun ${path.join(packageDir, 'build/pipeline.ts')} prod`);
583
+ break;
584
+
585
+ case 'watch': {
586
+ requireSiteConfig('dev');
587
+ const watchArgs = process.argv.slice(3).join(' ');
588
+ run(
589
+ `bun ${path.join(packageDir, 'build/watch.ts')}${watchArgs ? ' ' + watchArgs : ''}`,
590
+ );
591
+ break;
592
+ }
593
+
594
+ case 'serve': {
595
+ const serveArgs = process.argv.slice(3).join(' ');
596
+ run(
597
+ `bun ${path.join(packageDir, 'build/serve.ts')}${serveArgs ? ' ' + serveArgs : ''}`,
598
+ );
599
+ break;
600
+ }
601
+
602
+ case 'clean':
603
+ cleanCommand(process.argv.slice(3));
604
+ break;
605
+
606
+ case 'diff':
607
+ diffCommand(process.argv.slice(3));
608
+ break;
609
+
610
+ case '--version':
611
+ case '-v':
612
+ console.log(`tada v${version}`);
613
+ break;
614
+
615
+ default:
616
+ printUsage();
617
+ if (command && command !== '--help' && command !== '-h') {
618
+ process.exit(1);
619
+ }
620
+ break;
621
+ }