@abreen/tada 1.1.0 → 1.3.1

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 (94) hide show
  1. package/README.md +69 -17
  2. package/bin/tada.ts +317 -25
  3. package/bin/validators.test.ts +4 -3
  4. package/bin/validators.ts +4 -2
  5. package/build/apply-base-path-plugin.ts +24 -13
  6. package/build/build-manifest.test.ts +349 -0
  7. package/build/build-manifest.ts +165 -0
  8. package/build/bundle.ts +19 -20
  9. package/build/code.test.ts +7 -3
  10. package/build/content-watch.ts +6 -6
  11. package/build/copy.ts +2 -2
  12. package/build/deflist-id-plugin.ts +1 -1
  13. package/build/external-links-plugin.ts +2 -2
  14. package/build/features.ts +1 -1
  15. package/build/generate-content-assets.ts +12 -7
  16. package/build/generate-favicon.ts +13 -6
  17. package/build/generate-fonts.ts +2 -2
  18. package/build/generate-katex-assets.ts +35 -0
  19. package/build/generate-manifest.ts +2 -2
  20. package/build/globals.test.ts +10 -11
  21. package/build/globals.ts +14 -7
  22. package/build/json-schema.test.ts +1 -1
  23. package/build/log.test.ts +1 -1
  24. package/build/log.ts +2 -2
  25. package/build/markdown-plugins.test.ts +116 -10
  26. package/build/pagefind.test.ts +2 -2
  27. package/build/pagefind.ts +5 -5
  28. package/build/pdf-text.test.ts +52 -0
  29. package/build/pipeline.ts +42 -15
  30. package/build/reachability.test.ts +1 -1
  31. package/build/serve.ts +20 -8
  32. package/build/site-variables.ts +13 -5
  33. package/build/templates.ts +16 -7
  34. package/build/toc-plugin.test.ts +2 -2
  35. package/build/toc-plugin.ts +1 -1
  36. package/build/types.ts +15 -6
  37. package/build/util.ts +6 -6
  38. package/build/utils/code.ts +5 -5
  39. package/build/utils/content-files.ts +3 -3
  40. package/build/utils/derive-theme.test.ts +1 -1
  41. package/build/utils/derive-theme.ts +1 -1
  42. package/build/utils/file-types.test.ts +1 -1
  43. package/build/utils/front-matter.test.ts +1 -4
  44. package/build/utils/front-matter.ts +2 -2
  45. package/build/utils/jdi-runner/LiterateRunner.java +13 -0
  46. package/build/utils/literate-java.ts +24 -12
  47. package/build/utils/markdown.ts +41 -9
  48. package/build/utils/paths.test.ts +2 -2
  49. package/build/utils/paths.ts +5 -1
  50. package/build/utils/render.ts +57 -36
  51. package/build/utils/shiki-highlighter.ts +1 -1
  52. package/build/validate-internal-links-plugin.test.ts +1 -1
  53. package/build/validate-internal-links-plugin.ts +1 -1
  54. package/build/watch-reachability-state.test.ts +2 -2
  55. package/build/watch-reachability-state.ts +1 -1
  56. package/build/watch.ts +91 -110
  57. package/init/content/labs/00/VowelCounter.java.md +129 -0
  58. package/init/content/labs/00/index.md +29 -0
  59. package/init/content/labs/index.md +9 -0
  60. package/{content → init/content}/lectures/01/Pair.java.md +1 -0
  61. package/{content → init/content}/lectures/index.md +1 -1
  62. package/{content → init/content}/markdown.md +26 -7
  63. package/package.json +19 -7
  64. package/{templates → schema}/nav.schema.json +1 -2
  65. package/{build → schema}/site.schema.json +3 -5
  66. package/src/_alerts.scss +4 -0
  67. package/src/_base.scss +90 -6
  68. package/src/_layout.scss +39 -2
  69. package/src/anchor/index.ts +0 -1
  70. package/src/index.ts +3 -3
  71. package/src/question/style.scss +8 -3
  72. package/src/search/index.test.ts +203 -0
  73. package/src/search/index.ts +3 -79
  74. package/src/search/pdf-utils.ts +79 -0
  75. package/src/search/style.scss +2 -20
  76. package/src/style.scss +20 -101
  77. package/src/timezone/index.ts +9 -25
  78. package/src/util.ts +1 -1
  79. package/templates/_top.html +2 -1
  80. package/types/dev.d.ts +1 -3
  81. package/types/site-variables.d.ts +6 -10
  82. package/types/untyped-modules.d.ts +7 -0
  83. package/build/utils/jdi-runner/LiterateRunner.class +0 -0
  84. package/config/authors.json +0 -1
  85. package/config/nav.json +0 -28
  86. /package/{content → init/content}/index.md +0 -0
  87. /package/{content → init/content}/lectures/01/Rectangle.java +0 -0
  88. /package/{content → init/content}/lectures/01/demo.py +0 -0
  89. /package/{content → init/content}/lectures/01/index.md +0 -0
  90. /package/{content → init/content}/lectures/01/lecture1.pdf +0 -0
  91. /package/{content → init/content}/problem_sets/index.html +0 -0
  92. /package/{public → init/public}/avatars/alex.jpg +0 -0
  93. /package/{public → init/public}/test.txt +0 -0
  94. /package/{templates → schema}/authors.schema.json +0 -0
package/README.md CHANGED
@@ -23,11 +23,12 @@ A static site generator. The successor to Presto.
23
23
  - Internal link validation at build time (broken links fail the build)
24
24
  - Internal links automatically prefixed with base path, if specified
25
25
  - Time zone chooser (automatically adjusts `<datetime>` elements)
26
+ - LaTeX math rendered at build time via [KaTeX][katex]
26
27
  - Extended Markdown syntax
27
28
  * `<<< details ... <<<` renders a collapsible box
28
29
  * `::: section ... :::` renders a special section with a fancy background
29
30
  * `!!! note ... !!!` and `!!! warning ... !!!` render alert boxes
30
- * `??? review ... ???` renders a Q & A section; answers hidden until click
31
+ * `??? question ... ???` renders a Q & A section; answers hidden until click
31
32
  * Special heading subtitles with `## Heading # A subtitle here`
32
33
  - Automatically generated favicon
33
34
  * Text, color, font and font weight taken from config file
@@ -78,8 +79,13 @@ Create a new Tada site in a new directory. Prompts for:
78
79
  - **Production base URL**: e.g. `https://example.edu`
79
80
  - **Production base path**: e.g. `/cs101` (defaults to `/`)
80
81
 
81
- Pass `--default` to use the default values for all options without being
82
- prompted.
82
+ Pass `--no-interactive` to skip prompts and use default values for all options.
83
+ You can also override specific defaults with flags:
84
+
85
+ tada init mysite --no-interactive --prod-base https://example.edu --prod-base-path /cs101
86
+
87
+ Available flags: `--title`, `--symbol`, `--theme-color`, `--tint-hue`,
88
+ `--tint-amount`, `--default-time-zone`, `--prod-base`, `--prod-base-path`.
83
89
 
84
90
 
85
91
  ### `tada dev`
@@ -101,12 +107,69 @@ Start a development web server, watch for changes and rebuild automatically.
101
107
 
102
108
  ### `tada clean`
103
109
 
104
- Remove the `dist/` directory.
110
+ Remove the `dist/` directory. Pass `--prod` to also prune old production
111
+ builds (keeps the latest two versions).
105
112
 
106
113
 
107
114
  ### `tada prod`
108
115
 
109
- Build the site for production (uses `site.prod.json`).
116
+ Build the site for production (uses `site.prod.json`). Each prod build is
117
+ saved to a versioned directory under `dist-prod/` (e.g., `dist-prod/v1/`,
118
+ `dist-prod/v2/`) with a manifest file that records the SHA-256 hash of every
119
+ output file.
120
+
121
+
122
+ ### `tada diff`
123
+
124
+ Compare two production builds and list added, changed, and removed files.
125
+ With no arguments, compares the last two builds. You can also specify version
126
+ numbers explicitly:
127
+
128
+ tada diff # compare latest two builds
129
+ tada diff 1 3 # compare v1 and v3
130
+
131
+ Use `--copy <dir>` to copy only the changed and added files to a directory:
132
+
133
+ tada diff --copy upload/
134
+
135
+ The output directory will also include a `manifest.json` for the newer build.
136
+
137
+
138
+ ## Development vs. production builds
139
+
140
+ `tada dev` and `tada watch` build to the `dist/` directory using
141
+ `site.dev.json` (typically `localhost` URLs). `tada watch` includes a
142
+ development server with live reload; `tada serve` is a standalone server
143
+ for previewing a `tada dev` build. Dev builds overwrite `dist/` each time.
144
+
145
+ `tada prod` builds to a new versioned directory under `dist-prod/` each time
146
+ it runs. Previous production builds are preserved, so you can compare any
147
+ two versions with `tada diff`.
148
+
149
+
150
+ ## Deploying to S3
151
+
152
+ If you host your site in an S3 bucket, `tada diff --copy` lets you upload
153
+ only the files that changed between prod builds instead of re-uploading
154
+ everything.
155
+
156
+ First time:
157
+
158
+ ```
159
+ tada prod
160
+ # Upload the entire dist-prod/v1/ directory to your S3 bucket
161
+ ```
162
+
163
+ After making changes:
164
+
165
+ ```
166
+ tada prod
167
+ tada diff --copy upload/
168
+ # Upload just the upload/ directory to S3 (only changed files)
169
+ ```
170
+
171
+ If `tada diff` reports removed files, delete those from your S3 bucket
172
+ manually.
110
173
 
111
174
 
112
175
  ## Prerequisites
@@ -224,18 +287,6 @@ with `name`, `avatar`, and optionally `url`.
224
287
  ```
225
288
 
226
289
 
227
- ### Log level
228
-
229
- Set the `TADA_LOG_LEVEL` environment variable to control build log verbosity.
230
- Valid levels (from most to least verbose): `debug`, `info`, `warn`, `error`.
231
- The default level is `info`. To see *all* logs generated by Tada, set the env
232
- var to `debug`:
233
-
234
- ```
235
- TADA_LOG_LEVEL=debug tada dev
236
- ```
237
-
238
-
239
290
  ## Content
240
291
 
241
292
  Site content lives in the `content/` directory. Markdown is converted to HTML.
@@ -284,3 +335,4 @@ templates][lodash].
284
335
  [pagefind]: https://pagefind.app/
285
336
  [mutool]: https://mupdf.readthedocs.io/en/latest/tools/mutool.html
286
337
  [jep467]: https://openjdk.org/jeps/467
338
+ [katex]: https://katex.org/
package/bin/tada.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { execSync } from 'child_process';
5
+ import { parseArgs } from 'util';
5
6
  import packageJson from '../package.json' with { type: 'json' };
6
7
  import {
7
8
  validateSymbol,
@@ -10,7 +11,14 @@ import {
10
11
  validateUrl,
11
12
  validateBasePath,
12
13
  createSiteConfig,
13
- } from './validators.js';
14
+ } from './validators';
15
+ import {
16
+ loadManifest,
17
+ diffManifests,
18
+ copyChangedFiles,
19
+ getVersions,
20
+ pruneOldVersions,
21
+ } from '../build/build-manifest';
14
22
 
15
23
  const { version } = packageJson;
16
24
 
@@ -33,6 +41,7 @@ const COMMANDS = {
33
41
  watch: 'Watch for changes and rebuild',
34
42
  serve: 'Start a local development server',
35
43
  clean: null,
44
+ diff: null,
36
45
  };
37
46
 
38
47
  interface InitQuestion {
@@ -97,13 +106,45 @@ function printUsage() {
97
106
  for (const [cmd, desc] of Object.entries(COMMANDS)) {
98
107
  if (cmd === 'init') {
99
108
  console.log(' init <dirname> Initialize a new site');
100
- console.log(' [--default] (Use defaults for all options)');
101
109
  console.log(
102
- ' [--bare] (Create a minimal site with one page)',
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 === 'watch') {
120
+ console.log(` ${cmd.padEnd(18)} ${desc}`);
121
+ console.log(
122
+ ' [--port <n>] HTTP server port (default: 8080)',
123
+ );
124
+ console.log(
125
+ ' [--ws-port <n>] WebSocket port for live reload (default: 35729)',
126
+ );
127
+ continue;
128
+ } else if (cmd === 'serve') {
129
+ console.log(` ${cmd.padEnd(18)} ${desc}`);
130
+ console.log(
131
+ ' [--port <n>] HTTP server port (default: 8080)',
103
132
  );
104
133
  continue;
105
134
  } else if (cmd === 'clean') {
106
135
  console.log(' clean Remove the dist/ directory');
136
+ console.log(
137
+ ' [--prod] Prune old prod builds, keeping the two latest',
138
+ );
139
+ continue;
140
+ } else if (cmd === 'diff') {
141
+ console.log(
142
+ ' diff Show changed files since last prod build',
143
+ );
144
+ console.log(' [N M] Compare version N vs version M');
145
+ console.log(
146
+ ' [--copy <dir>] Copy changed files to a directory',
147
+ );
107
148
  continue;
108
149
  }
109
150
  console.log(` ${cmd.padEnd(18)} ${desc}`);
@@ -111,7 +152,18 @@ function printUsage() {
111
152
  }
112
153
 
113
154
  function run(cmd: string): void {
114
- execSync(cmd, { cwd: process.cwd(), stdio: 'inherit' });
155
+ try {
156
+ execSync(cmd, { cwd: process.cwd(), stdio: 'inherit' });
157
+ } catch (err: unknown) {
158
+ const code = (err as { status?: number }).status;
159
+ if (code != null) {
160
+ console.error(`tada ${command} failed with exit code ${code}`);
161
+ process.exit(code);
162
+ } else {
163
+ console.error(`tada ${command} failed`);
164
+ process.exit(1);
165
+ }
166
+ }
115
167
  }
116
168
 
117
169
  function copyDirRecursive(src: string, dest: string): void {
@@ -127,14 +179,69 @@ function copyDirRecursive(src: string, dest: string): void {
127
179
  }
128
180
  }
129
181
 
182
+ const FLAG_TO_KEY: Record<string, string> = {
183
+ title: 'title',
184
+ symbol: 'symbol',
185
+ 'theme-color': 'themeColor',
186
+ 'tint-hue': 'tintHue',
187
+ 'tint-amount': 'tintAmount',
188
+ 'default-time-zone': 'defaultTimeZone',
189
+ 'prod-base': 'prodBase',
190
+ 'prod-base-path': 'prodBasePath',
191
+ };
192
+
130
193
  async function initCommand(args: string[]): Promise<void> {
131
- const useDefaults = args.includes('--default');
132
- const bare = args.includes('--bare');
133
- const dirname = args.filter(a => !a.startsWith('--'))[0];
194
+ let values: Record<string, string | boolean | undefined>;
195
+ let positionals: string[];
196
+
197
+ try {
198
+ ({ values, positionals } = parseArgs({
199
+ args,
200
+ options: {
201
+ 'no-interactive': { type: 'boolean', default: false },
202
+ bare: { type: 'boolean', default: false },
203
+ title: { type: 'string', default: INIT_QUESTIONS.title.defaultValue },
204
+ symbol: { type: 'string', default: INIT_QUESTIONS.symbol.defaultValue },
205
+ 'theme-color': {
206
+ type: 'string',
207
+ default: INIT_QUESTIONS.themeColor.defaultValue,
208
+ },
209
+ 'tint-hue': {
210
+ type: 'string',
211
+ default: INIT_QUESTIONS.tintHue.defaultValue,
212
+ },
213
+ 'tint-amount': {
214
+ type: 'string',
215
+ default: INIT_QUESTIONS.tintAmount.defaultValue,
216
+ },
217
+ 'default-time-zone': {
218
+ type: 'string',
219
+ default: INIT_QUESTIONS.defaultTimeZone.defaultValue,
220
+ },
221
+ 'prod-base': {
222
+ type: 'string',
223
+ default: INIT_QUESTIONS.prodBase.defaultValue,
224
+ },
225
+ 'prod-base-path': {
226
+ type: 'string',
227
+ default: INIT_QUESTIONS.prodBasePath.defaultValue,
228
+ },
229
+ },
230
+ strict: true,
231
+ allowPositionals: true,
232
+ }));
233
+ } catch (e: unknown) {
234
+ console.error(`Error: ${(e as Error).message}`);
235
+ process.exit(1);
236
+ }
237
+
238
+ const noInteractive = values['no-interactive'] as boolean;
239
+ const bare = values.bare as boolean;
240
+ const dirname = positionals[0];
134
241
 
135
242
  if (!dirname) {
136
243
  console.error('Error: Provide a name for the new directory');
137
- console.log('Usage: tada init <dirname> [--default] [--bare]');
244
+ console.log('Usage: tada init <dirname> [--no-interactive] [--bare]');
138
245
  process.exit(1);
139
246
  }
140
247
 
@@ -145,9 +252,9 @@ async function initCommand(args: string[]): Promise<void> {
145
252
  }
146
253
 
147
254
  const message = `Creating a new Tada site in ${projectDir}`;
148
- if (useDefaults && bare) {
255
+ if (noInteractive && bare) {
149
256
  console.log(message + ' using default config (bare)');
150
- } else if (useDefaults) {
257
+ } else if (noInteractive) {
151
258
  console.log(message + ' using default config');
152
259
  } else if (bare) {
153
260
  console.log(message + ' (bare)');
@@ -157,9 +264,15 @@ async function initCommand(args: string[]): Promise<void> {
157
264
 
158
265
  const config: Record<string, string> = {};
159
266
 
160
- if (useDefaults) {
161
- for (const [key, { defaultValue }] of Object.entries(INIT_QUESTIONS)) {
162
- config[key] = defaultValue;
267
+ if (noInteractive) {
268
+ for (const [flag, key] of Object.entries(FLAG_TO_KEY)) {
269
+ const value = values[flag] as string;
270
+ const error = INIT_QUESTIONS[key].validate(value);
271
+ if (error) {
272
+ console.error(`Error: --${flag}: ${error}`);
273
+ process.exit(1);
274
+ }
275
+ config[key] = value;
163
276
  }
164
277
  } else {
165
278
  const questions = Object.entries(INIT_QUESTIONS);
@@ -223,6 +336,7 @@ async function initCommand(args: string[]): Promise<void> {
223
336
  base: 'http://localhost:8080',
224
337
  basePath: '/',
225
338
  internalDomains: ['localhost'],
339
+ features: { search: true, code: true, favicon: false },
226
340
  });
227
341
 
228
342
  const prodConfig = createSiteConfig({
@@ -235,6 +349,7 @@ async function initCommand(args: string[]): Promise<void> {
235
349
  base: prodBase,
236
350
  basePath: prodBasePath,
237
351
  internalDomains: [prodDomain],
352
+ features: { search: true, code: true, favicon: true },
238
353
  });
239
354
 
240
355
  fs.writeFileSync(
@@ -268,23 +383,59 @@ async function initCommand(args: string[]): Promise<void> {
268
383
  ) + '\n',
269
384
  );
270
385
  } else {
271
- // Copy nav and authors data files to the project root
272
- fs.copyFileSync(
273
- path.join(packageDir, 'config/nav.json'),
386
+ // Create nav and authors data files in the project root
387
+ fs.writeFileSync(
274
388
  path.join(projectDir, 'nav.json'),
389
+ JSON.stringify(
390
+ [
391
+ {
392
+ title: 'Navigation',
393
+ links: [{ text: 'Home', internal: '/index.html' }],
394
+ },
395
+ {
396
+ title: 'Topics',
397
+ links: [
398
+ { text: 'Lectures', internal: '/lectures/index.html' },
399
+ { text: 'Labs', internal: '/labs/index.html' },
400
+ {
401
+ text: 'Problem Sets',
402
+ internal: '/problem_sets/index.html',
403
+ disabled: true,
404
+ },
405
+ ],
406
+ },
407
+ {
408
+ title: 'Links',
409
+ links: [
410
+ { text: 'Zoom', external: 'https://zoom.com' },
411
+ {
412
+ text: 'Canvas',
413
+ external: 'https://www.instructure.com/',
414
+ disabled: true,
415
+ },
416
+ ],
417
+ },
418
+ ],
419
+ null,
420
+ 2,
421
+ ) + '\n',
275
422
  );
276
- fs.copyFileSync(
277
- path.join(packageDir, 'config/authors.json'),
423
+ fs.writeFileSync(
278
424
  path.join(projectDir, 'authors.json'),
425
+ JSON.stringify(
426
+ { alex: { name: 'Alex Breen', avatar: '/avatars/alex.jpg' } },
427
+ null,
428
+ 2,
429
+ ) + '\n',
279
430
  );
280
431
 
281
432
  // Copy content/ and public/ from the package
282
433
  copyDirRecursive(
283
- path.join(packageDir, 'content'),
434
+ path.join(packageDir, 'init/content'),
284
435
  path.join(projectDir, 'content'),
285
436
  );
286
437
  copyDirRecursive(
287
- path.join(packageDir, 'public'),
438
+ path.join(packageDir, 'init/public'),
288
439
  path.join(projectDir, 'public'),
289
440
  );
290
441
  }
@@ -296,6 +447,147 @@ async function initCommand(args: string[]): Promise<void> {
296
447
  console.log(` tada serve`);
297
448
  }
298
449
 
450
+ function cleanCommand(args: string[]): void {
451
+ if (args.includes('--prod')) {
452
+ const prodBase = path.resolve(process.cwd(), 'dist-prod');
453
+ const removed = pruneOldVersions(prodBase);
454
+ if (removed.length === 0) {
455
+ console.log('Nothing to clean.');
456
+ return;
457
+ }
458
+ for (const v of removed) {
459
+ console.log(`Removed v${v}`);
460
+ }
461
+ return;
462
+ }
463
+
464
+ const distDir = path.resolve(process.cwd(), 'dist');
465
+ if (!fs.existsSync(distDir)) {
466
+ console.log('Nothing to clean.');
467
+ return;
468
+ }
469
+ fs.rmSync(distDir, { recursive: true });
470
+ console.log('Cleaned dist/');
471
+ }
472
+
473
+ function diffCommand(args: string[]): void {
474
+ const projectDir = process.cwd();
475
+ const prodBase = path.resolve(projectDir, 'dist-prod');
476
+ const versions = getVersions(prodBase);
477
+
478
+ if (versions.length === 0) {
479
+ console.error('Error: No prod builds found. Run "tada prod" first.');
480
+ process.exit(1);
481
+ }
482
+
483
+ const copyIdx = args.indexOf('--copy');
484
+ const versionArgs = copyIdx === -1 ? args : args.slice(0, copyIdx);
485
+ const numericArgs = versionArgs
486
+ .map(a => parseInt(a, 10))
487
+ .filter(n => !isNaN(n));
488
+
489
+ let oldVer: number;
490
+ let newVer: number;
491
+
492
+ if (numericArgs.length === 2) {
493
+ [oldVer, newVer] = numericArgs.sort((a, b) => a - b);
494
+ } else if (numericArgs.length === 0) {
495
+ if (versions.length < 2) {
496
+ console.error(
497
+ 'Error: Need at least two prod builds to diff. Run "tada prod" again.',
498
+ );
499
+ process.exit(1);
500
+ }
501
+ oldVer = versions[versions.length - 2];
502
+ newVer = versions[versions.length - 1];
503
+ } else {
504
+ console.error('Error: Provide zero or two version numbers.');
505
+ console.log('Usage: tada diff [N M] [--copy <dir>]');
506
+ process.exit(1);
507
+ }
508
+
509
+ const oldManifestPath = path.join(prodBase, `v${oldVer}.manifest.json`);
510
+ const newManifestPath = path.join(prodBase, `v${newVer}.manifest.json`);
511
+
512
+ const oldManifest = loadManifest(oldManifestPath);
513
+ if (!oldManifest) {
514
+ console.error(`Error: No manifest for v${oldVer}.`);
515
+ process.exit(1);
516
+ }
517
+ const newManifest = loadManifest(newManifestPath);
518
+ if (!newManifest) {
519
+ console.error(`Error: No manifest for v${newVer}.`);
520
+ process.exit(1);
521
+ }
522
+
523
+ const diff = diffManifests(oldManifest, newManifest);
524
+ const totalChanges =
525
+ diff.added.length + diff.changed.length + diff.removed.length;
526
+
527
+ const fmtDate = (iso: string) => new Date(iso).toLocaleString();
528
+ console.log(`v${oldVer} ${fmtDate(oldManifest.buildTime)}`);
529
+ console.log(`v${newVer} ${fmtDate(newManifest.buildTime)}`);
530
+
531
+ if (totalChanges === 0) {
532
+ console.log('\nNo changes between builds.');
533
+ return;
534
+ }
535
+
536
+ if (diff.added.length > 0) {
537
+ console.log(`\nAdded (${diff.added.length}):`);
538
+ for (const f of diff.added) {
539
+ console.log(` + ${f}`);
540
+ }
541
+ }
542
+ if (diff.changed.length > 0) {
543
+ console.log(`\nChanged (${diff.changed.length}):`);
544
+ for (const f of diff.changed) {
545
+ console.log(` ~ ${f}`);
546
+ }
547
+ }
548
+ if (diff.removed.length > 0) {
549
+ console.log(`\nRemoved (${diff.removed.length}):`);
550
+ for (const f of diff.removed) {
551
+ console.log(` - ${f}`);
552
+ }
553
+ }
554
+
555
+ const noun = (n: number) => (n === 1 ? 'file' : 'files');
556
+ console.log(`\nTotal: ${totalChanges} ${noun(totalChanges)} differ`);
557
+
558
+ if (copyIdx !== -1) {
559
+ const outDirArg = args[copyIdx + 1];
560
+ if (!outDirArg) {
561
+ console.error('Error: --copy requires a directory argument');
562
+ process.exit(1);
563
+ }
564
+
565
+ const newDistDir = path.join(prodBase, `v${newVer}`);
566
+ const resolvedOutDir = path.resolve(projectDir, outDirArg);
567
+ copyChangedFiles(diff, newDistDir, resolvedOutDir);
568
+
569
+ // Always include pagefind/ (excluded from manifest but needed for search)
570
+ const pagefindSrc = path.join(newDistDir, 'pagefind');
571
+ if (fs.existsSync(pagefindSrc)) {
572
+ copyDirRecursive(pagefindSrc, path.join(resolvedOutDir, 'pagefind'));
573
+ }
574
+
575
+ const manifestSrc = path.join(prodBase, `v${newVer}.manifest.json`);
576
+ fs.copyFileSync(manifestSrc, path.join(resolvedOutDir, 'manifest.json'));
577
+
578
+ const copiedCount = diff.added.length + diff.changed.length;
579
+ console.log(
580
+ `\nCopied ${copiedCount} ${noun(copiedCount)} + manifest.json to ${outDirArg}`,
581
+ );
582
+
583
+ if (diff.removed.length > 0) {
584
+ console.log(
585
+ `\nNote: ${diff.removed.length} ${noun(diff.removed.length)} were removed from the build.`,
586
+ );
587
+ }
588
+ }
589
+ }
590
+
299
591
  /*
300
592
  * Start of script
301
593
  */
@@ -335,11 +627,11 @@ switch (command) {
335
627
  }
336
628
 
337
629
  case 'clean':
338
- fs.rmSync(path.resolve(process.cwd(), 'dist'), {
339
- recursive: true,
340
- force: true,
341
- });
342
- console.log('Cleaned dist/');
630
+ cleanCommand(process.argv.slice(3));
631
+ break;
632
+
633
+ case 'diff':
634
+ diffCommand(process.argv.slice(3));
343
635
  break;
344
636
 
345
637
  case '--version':
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import type { SiteConfigInput } from '../build/types.js';
2
+ import type { SiteConfigInput } from '../build/types';
3
3
  import {
4
4
  validateSymbol,
5
5
  validateColor,
@@ -7,7 +7,7 @@ import {
7
7
  validateUrl,
8
8
  validateBasePath,
9
9
  createSiteConfig,
10
- } from './validators.js';
10
+ } from './validators';
11
11
 
12
12
  describe('validateSymbol', () => {
13
13
  test('accepts a single uppercase letter', () => {
@@ -172,6 +172,7 @@ describe('createSiteConfig', () => {
172
172
  base: 'https://example.edu',
173
173
  basePath: '/',
174
174
  internalDomains: ['example.edu'],
175
+ features: { search: true, code: true, favicon: false },
175
176
  };
176
177
 
177
178
  test('returns an object with all expected keys', () => {
@@ -184,7 +185,7 @@ describe('createSiteConfig', () => {
184
185
  basePath: '/',
185
186
  internalDomains: ['example.edu'],
186
187
  defaultTimeZone: 'America/New_York',
187
- features: { search: true, code: true, favicon: true },
188
+ features: { search: true, code: true, favicon: false },
188
189
  codeLanguages: { java: 'java', py: 'python' },
189
190
  vars: {},
190
191
  });
package/bin/validators.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SiteConfigInput, SiteVariables } from '../build/types.js';
1
+ import type { SiteConfigInput, SiteVariables } from '../build/types';
2
2
 
3
3
  export function validateSymbol(value: string): string | null {
4
4
  if (!value) {
@@ -65,11 +65,13 @@ export function createSiteConfig({
65
65
  base,
66
66
  basePath,
67
67
  internalDomains,
68
+ features,
68
69
  }: SiteConfigInput): SiteVariables {
69
70
  return {
70
71
  title,
72
+ titlePostfix: ` - ${title}`,
71
73
  symbol,
72
- features: { search: true, code: true, favicon: true },
74
+ features,
73
75
  base,
74
76
  basePath,
75
77
  internalDomains,
@@ -1,8 +1,9 @@
1
1
  import type MarkdownIt from 'markdown-it';
2
2
  import type Token from 'markdown-it/lib/token.mjs';
3
- import { createApplyBasePath } from './utils/paths.js';
4
- import { makeLogger } from './log.js';
5
- import type { SiteVariables } from './types.js';
3
+ import { createApplyBasePath } from './utils/paths';
4
+ import { isFeatureEnabled } from './features';
5
+ import { makeLogger } from './log';
6
+ import type { SiteVariables } from './types';
6
7
 
7
8
  const log = makeLogger(__filename);
8
9
 
@@ -11,7 +12,7 @@ export default function applyBasePathPlugin(
11
12
  siteVariables: SiteVariables,
12
13
  ): void {
13
14
  const applyBasePath = createApplyBasePath(siteVariables);
14
- const rewriteCodeLinks = siteVariables.features?.code !== false;
15
+ const rewriteCodeLinks = isFeatureEnabled(siteVariables, 'code');
15
16
 
16
17
  function rewriteInternalHref(href: string): string {
17
18
  const match = href.match(/^([^?#]*)(.*)$/);
@@ -67,15 +68,25 @@ export default function applyBasePathPlugin(
67
68
  token.attrSet('src', afterApply);
68
69
  }
69
70
  } else if (token.type === 'html_block' || token.type === 'html_inline') {
70
- token.content = token.content.replace(
71
- /(<img\b[^>]*\bsrc=")(\/)([^"]*")/g,
72
- (match, prefix, slash, rest) => {
73
- const src = slash + rest.slice(0, -1);
74
- const afterApply = applyBasePath(src);
75
- log.debug`Applying base path to img tag src: ${src} -> ${afterApply}`;
76
- return prefix + afterApply + '"';
77
- },
78
- );
71
+ token.content = token.content
72
+ .replace(
73
+ /(<a\b[^>]*\bhref=")(\/)([^"]*")/g,
74
+ (match, prefix, slash, rest) => {
75
+ const href = slash + rest.slice(0, -1);
76
+ const afterApply = applyBasePath(href);
77
+ log.debug`Applying base path to a tag href: ${href} -> ${afterApply}`;
78
+ return prefix + afterApply + '"';
79
+ },
80
+ )
81
+ .replace(
82
+ /(<img\b[^>]*\bsrc=")(\/)([^"]*")/g,
83
+ (match, prefix, slash, rest) => {
84
+ const src = slash + rest.slice(0, -1);
85
+ const afterApply = applyBasePath(src);
86
+ log.debug`Applying base path to img tag src: ${src} -> ${afterApply}`;
87
+ return prefix + afterApply + '"';
88
+ },
89
+ );
79
90
  }
80
91
 
81
92
  token.children?.map(checkAndApplyBasePath);