@abreen/tada 1.3.2 → 1.4.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.
package/README.md CHANGED
@@ -178,6 +178,7 @@ manually.
178
178
  - [MuPDF](https://mupdf.com/) (optional, for PDF text extraction in search)
179
179
  - On macOS: `brew install mupdf-tools`
180
180
  - On Fedora: `dnf install mupdf`
181
+ - On Ubuntu: `apt-get install mupdf-tools`
181
182
 
182
183
  > You may skip MuPDF if you don't need search results to include links to PDF
183
184
  > pages. You can also turn off `features.search` in the config to disable
package/bin/tada.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import { execSync } from 'child_process';
4
+ import { execFileSync } from 'child_process';
5
5
  import { parseArgs } from 'util';
6
6
  import packageJson from '../package.json' with { type: 'json' };
7
7
  import {
@@ -151,9 +151,9 @@ function printUsage() {
151
151
  }
152
152
  }
153
153
 
154
- function run(cmd: string): void {
154
+ function run(args: string[]): void {
155
155
  try {
156
- execSync(cmd, { cwd: process.cwd(), stdio: 'inherit' });
156
+ execFileSync('bun', args, { cwd: process.cwd(), stdio: 'inherit' });
157
157
  } catch (err: unknown) {
158
158
  const code = (err as { status?: number }).status;
159
159
  if (code != null) {
@@ -506,8 +506,16 @@ function diffCommand(args: string[]): void {
506
506
  process.exit(1);
507
507
  }
508
508
 
509
- const oldManifestPath = path.join(prodBase, `v${oldVer}.manifest.json`);
510
- const newManifestPath = path.join(prodBase, `v${newVer}.manifest.json`);
509
+ const oldManifestPath = path.join(
510
+ prodBase,
511
+ `v${oldVer}`,
512
+ 'tada.manifest.json',
513
+ );
514
+ const newManifestPath = path.join(
515
+ prodBase,
516
+ `v${newVer}`,
517
+ 'tada.manifest.json',
518
+ );
511
519
 
512
520
  const oldManifest = loadManifest(oldManifestPath);
513
521
  if (!oldManifest) {
@@ -572,12 +580,15 @@ function diffCommand(args: string[]): void {
572
580
  copyDirRecursive(pagefindSrc, path.join(resolvedOutDir, 'pagefind'));
573
581
  }
574
582
 
575
- const manifestSrc = path.join(prodBase, `v${newVer}.manifest.json`);
576
- fs.copyFileSync(manifestSrc, path.join(resolvedOutDir, 'manifest.json'));
583
+ const manifestSrc = path.join(newDistDir, 'tada.manifest.json');
584
+ fs.copyFileSync(
585
+ manifestSrc,
586
+ path.join(resolvedOutDir, 'tada.manifest.json'),
587
+ );
577
588
 
578
589
  const copiedCount = diff.added.length + diff.changed.length;
579
590
  console.log(
580
- `\nCopied ${copiedCount} ${noun(copiedCount)} + manifest.json to ${outDirArg}`,
591
+ `\nCopied ${copiedCount} ${noun(copiedCount)} + tada.manifest.json to ${outDirArg}`,
581
592
  );
582
593
 
583
594
  if (diff.removed.length > 0) {
@@ -601,28 +612,22 @@ switch (command) {
601
612
 
602
613
  case 'dev':
603
614
  requireSiteConfig('dev');
604
- run(`bun ${path.join(packageDir, 'build/pipeline.ts')} dev`);
615
+ run([path.join(packageDir, 'build/pipeline.ts'), 'dev']);
605
616
  break;
606
617
 
607
618
  case 'prod':
608
619
  requireSiteConfig('prod');
609
- run(`bun ${path.join(packageDir, 'build/pipeline.ts')} prod`);
620
+ run([path.join(packageDir, 'build/pipeline.ts'), 'prod']);
610
621
  break;
611
622
 
612
623
  case 'watch': {
613
624
  requireSiteConfig('dev');
614
- const watchArgs = process.argv.slice(3).join(' ');
615
- run(
616
- `bun ${path.join(packageDir, 'build/watch.ts')}${watchArgs ? ' ' + watchArgs : ''}`,
617
- );
625
+ run([path.join(packageDir, 'build/watch.ts'), ...process.argv.slice(3)]);
618
626
  break;
619
627
  }
620
628
 
621
629
  case 'serve': {
622
- const serveArgs = process.argv.slice(3).join(' ');
623
- run(
624
- `bun ${path.join(packageDir, 'build/serve.ts')}${serveArgs ? ' ' + serveArgs : ''}`,
625
- );
630
+ run([path.join(packageDir, 'build/serve.ts'), ...process.argv.slice(3)]);
626
631
  break;
627
632
  }
628
633
 
@@ -15,8 +15,11 @@ import {
15
15
  } from './build-manifest';
16
16
  import type { BuildManifest, ManifestDiff } from './build-manifest';
17
17
 
18
- function makeManifest(files: Record<string, string>): BuildManifest {
19
- return { version: 1, buildTime: '2025-01-01T00:00:00.000Z', files };
18
+ function makeManifest(
19
+ files: Record<string, string>,
20
+ build: number = 1,
21
+ ): BuildManifest {
22
+ return { schema: 1, build, buildTime: '2025-01-01T00:00:00.000Z', files };
20
23
  }
21
24
 
22
25
  let tmpDir: string;
@@ -149,6 +152,14 @@ describe('walkAndHash', () => {
149
152
  expect('pagefind/pagefind.js' in result).toBe(false);
150
153
  });
151
154
 
155
+ test('excludes tada.manifest.json', async () => {
156
+ fs.writeFileSync(path.join(tmpDir, 'index.html'), '<html></html>');
157
+ fs.writeFileSync(path.join(tmpDir, 'tada.manifest.json'), '{"schema":1}');
158
+ const result = await walkAndHash(tmpDir);
159
+ expect(Object.keys(result)).toEqual(['index.html']);
160
+ expect('tada.manifest.json' in result).toBe(false);
161
+ });
162
+
152
163
  test('returns empty object for empty directory', async () => {
153
164
  const result = await walkAndHash(tmpDir);
154
165
  expect(result).toEqual({});
@@ -179,7 +190,7 @@ describe('getVersions', () => {
179
190
  fs.mkdirSync(path.join(tmpDir, 'v1'));
180
191
  fs.mkdirSync(path.join(tmpDir, 'other'));
181
192
  fs.mkdirSync(path.join(tmpDir, 'v2x'));
182
- fs.writeFileSync(path.join(tmpDir, 'v3.manifest.json'), '{}');
193
+ fs.writeFileSync(path.join(tmpDir, 'some-file.json'), '{}');
183
194
  expect(getVersions(tmpDir)).toEqual([1]);
184
195
  });
185
196
 
@@ -209,29 +220,31 @@ describe('getNextVersion', () => {
209
220
  });
210
221
 
211
222
  describe('generateBuildManifest', () => {
212
- test('writes valid JSON with version, buildTime, and files', async () => {
223
+ test('writes valid JSON with schema, build, buildTime, and files', async () => {
213
224
  const distDir = path.join(tmpDir, 'dist');
214
225
  fs.mkdirSync(distDir);
215
226
  fs.writeFileSync(path.join(distDir, 'index.html'), '<html></html>');
216
227
 
217
- const manifestPath = path.join(tmpDir, 'manifest.json');
218
- await generateBuildManifest(distDir, manifestPath);
228
+ const manifestPath = path.join(distDir, 'tada.manifest.json');
229
+ await generateBuildManifest(distDir, manifestPath, 3);
219
230
 
220
231
  const raw = fs.readFileSync(manifestPath, 'utf8');
221
232
  const manifest = JSON.parse(raw) as BuildManifest;
222
233
 
223
- expect(manifest.version).toBe(1);
234
+ expect(manifest.schema).toBe(1);
235
+ expect(manifest.build).toBe(3);
224
236
  expect(typeof manifest.buildTime).toBe('string');
225
237
  expect(manifest.buildTime).toMatch(/^\d{4}-\d{2}-\d{2}T/);
226
238
  expect(typeof manifest.files).toBe('object');
227
239
  expect('index.html' in manifest.files).toBe(true);
240
+ expect('tada.manifest.json' in manifest.files).toBe(false);
228
241
  });
229
242
  });
230
243
 
231
244
  describe('loadManifest', () => {
232
245
  test('loads and parses a manifest from a file', () => {
233
246
  const manifest = makeManifest({ 'index.html': 'abc123' });
234
- const filePath = path.join(tmpDir, 'manifest.json');
247
+ const filePath = path.join(tmpDir, 'tada.manifest.json');
235
248
  fs.writeFileSync(filePath, JSON.stringify(manifest, null, 2) + '\n');
236
249
 
237
250
  const loaded = loadManifest(filePath);
@@ -247,10 +260,11 @@ describe('loadManifest', () => {
247
260
  describe('pruneOldVersions', () => {
248
261
  function setupVersions(versions: number[]) {
249
262
  for (const v of versions) {
250
- fs.mkdirSync(path.join(tmpDir, `v${v}`));
263
+ const vDir = path.join(tmpDir, `v${v}`);
264
+ fs.mkdirSync(vDir);
251
265
  fs.writeFileSync(
252
- path.join(tmpDir, `v${v}.manifest.json`),
253
- JSON.stringify(makeManifest({})),
266
+ path.join(vDir, 'tada.manifest.json'),
267
+ JSON.stringify(makeManifest({}, v)),
254
268
  );
255
269
  }
256
270
  }
@@ -263,12 +277,18 @@ describe('pruneOldVersions', () => {
263
277
  expect(fs.existsSync(path.join(tmpDir, 'v3'))).toBe(true);
264
278
  });
265
279
 
266
- test('also removes manifest files for pruned versions', () => {
280
+ test('removes manifests inside pruned version directories', () => {
267
281
  setupVersions([1, 2, 3]);
268
282
  pruneOldVersions(tmpDir);
269
- expect(fs.existsSync(path.join(tmpDir, 'v1.manifest.json'))).toBe(false);
270
- expect(fs.existsSync(path.join(tmpDir, 'v2.manifest.json'))).toBe(true);
271
- expect(fs.existsSync(path.join(tmpDir, 'v3.manifest.json'))).toBe(true);
283
+ expect(fs.existsSync(path.join(tmpDir, 'v1', 'tada.manifest.json'))).toBe(
284
+ false,
285
+ );
286
+ expect(fs.existsSync(path.join(tmpDir, 'v2', 'tada.manifest.json'))).toBe(
287
+ true,
288
+ );
289
+ expect(fs.existsSync(path.join(tmpDir, 'v3', 'tada.manifest.json'))).toBe(
290
+ true,
291
+ );
272
292
  });
273
293
 
274
294
  test('keeps all versions when there are 2 or fewer', () => {
@@ -2,9 +2,11 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
4
  const EXCLUDED_DIRS = new Set(['pagefind']);
5
+ const EXCLUDED_FILES = new Set(['tada.manifest.json']);
5
6
 
6
7
  export interface BuildManifest {
7
- version: 1;
8
+ schema: 1;
9
+ build: number;
8
10
  buildTime: string;
9
11
  files: Record<string, string>;
10
12
  }
@@ -72,6 +74,9 @@ export async function walkAndHash(
72
74
  if (EXCLUDED_DIRS.has(topLevel)) {
73
75
  continue;
74
76
  }
77
+ if (EXCLUDED_FILES.has(entry.name)) {
78
+ continue;
79
+ }
75
80
 
76
81
  result[rel] = await hashFile(abs);
77
82
  }
@@ -112,10 +117,12 @@ export function getNextVersion(prodBaseDir: string): number {
112
117
  export async function generateBuildManifest(
113
118
  distDir: string,
114
119
  manifestPath: string,
120
+ buildVersion: number,
115
121
  ): Promise<void> {
116
122
  const files = await walkAndHash(distDir);
117
123
  const manifest: BuildManifest = {
118
- version: 1,
124
+ schema: 1,
125
+ build: buildVersion,
119
126
  buildTime: new Date().toISOString(),
120
127
  files,
121
128
  };
@@ -142,9 +149,6 @@ export function pruneOldVersions(prodBaseDir: string): number[] {
142
149
  for (const v of toRemove) {
143
150
  const vDir = path.join(prodBaseDir, `v${v}`);
144
151
  fs.rmSync(vDir, { recursive: true, force: true });
145
-
146
- const manifestFile = path.join(prodBaseDir, `v${v}.manifest.json`);
147
- fs.rmSync(manifestFile, { force: true });
148
152
  }
149
153
 
150
154
  return toRemove;
@@ -2,6 +2,8 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { makeLogger } from './log';
4
4
  import { isFeatureEnabled } from './features';
5
+ import { validateConfigLinks } from './validate-config-links';
6
+ import { json } from './templates';
5
7
  import { initHighlighter } from './utils/shiki-highlighter';
6
8
  import {
7
9
  getBuildContentFiles,
@@ -257,6 +259,15 @@ export class ContentRenderer {
257
259
  );
258
260
  const errors: Error[] = [];
259
261
 
262
+ const configLinkErrors = validateConfigLinks(
263
+ validInternalTargets,
264
+ json('nav.json'),
265
+ json('authors.json'),
266
+ );
267
+ for (const msg of configLinkErrors) {
268
+ errors.push(new Error(msg));
269
+ }
270
+
260
271
  this.pruneRemovedSources(buildFileSet, removedHtmlAssetPaths);
261
272
 
262
273
  if (dirtySourceFiles.size > 0) {
@@ -95,7 +95,7 @@ function createManifest(siteVariables: SiteVariables): WebAppManifest {
95
95
  };
96
96
  }
97
97
 
98
- export function generateManifest(
98
+ export function generateWebAppManifest(
99
99
  siteVariables: SiteVariables,
100
100
  distDir: string,
101
101
  ): void {
package/build/pipeline.ts CHANGED
@@ -13,7 +13,7 @@ import { bundle } from './bundle';
13
13
  import { copyFonts } from './generate-fonts';
14
14
  import { copyKatexAssets } from './generate-katex-assets';
15
15
  import { generateFavicons } from './generate-favicon';
16
- import { generateManifest } from './generate-manifest';
16
+ import { generateWebAppManifest } from './generate-web-app-manifest';
17
17
  import { copyPublicFiles, copyContentAssets } from './copy';
18
18
  import { getProcessedExtensions } from './utils/file-types';
19
19
  import { ContentRenderer } from './generate-content-assets';
@@ -64,7 +64,7 @@ async function runPipeline(mode: 'development' | 'production'): Promise<void> {
64
64
  const assetFiles = results[0] as string[]; // bundle output filenames
65
65
 
66
66
  if (isFeatureEnabled(siteVariables, 'favicon')) {
67
- generateManifest(siteVariables, distDir);
67
+ generateWebAppManifest(siteVariables, distDir);
68
68
  }
69
69
 
70
70
  const publicRelPaths = copyPublicFiles(publicDir, distDir);
@@ -93,9 +93,8 @@ async function runPipeline(mode: 'development' | 'production'): Promise<void> {
93
93
 
94
94
  // Phase 5: Build manifest (production only)
95
95
  if (!isDev) {
96
- const prodBase = getProdDistDir();
97
- const manifestPath = path.join(prodBase, `v${prodVersion!}.manifest.json`);
98
- await generateBuildManifest(distDir, manifestPath);
96
+ const manifestPath = path.join(distDir, 'tada.manifest.json');
97
+ await generateBuildManifest(distDir, manifestPath, prodVersion!);
99
98
  log.info`Built dist-prod/v${prodVersion!}/`;
100
99
  }
101
100
 
@@ -14,6 +14,8 @@ import type {
14
14
 
15
15
  const log = makeLogger(__filename);
16
16
 
17
+ let runnerClassDir: string | null = null;
18
+
17
19
  const MAIN_PATTERN = /\bvoid\s+main\s*\(/m;
18
20
 
19
21
  export function parseLiterateJava(
@@ -111,33 +113,36 @@ export function compileJavaSource(
111
113
  return tempDir;
112
114
  }
113
115
 
114
- function ensureRunnerCompiled(runnerDir: string): void {
115
- const sourceFile = path.join(runnerDir, 'LiterateRunner.java');
116
- const classFile = path.join(runnerDir, 'LiterateRunner.class');
117
-
118
- if (
119
- fs.existsSync(classFile) &&
120
- fs.statSync(classFile).mtimeMs >= fs.statSync(sourceFile).mtimeMs
121
- ) {
122
- log.debug`LiterateRunner.class is up to date`;
123
- return;
116
+ // Compile LiterateRunner.java into a per-process temp directory so that
117
+ // parallel builds never clobber each other and a stale .class file from a
118
+ // different Java version is never reused.
119
+ function ensureRunnerCompiled(): string {
120
+ if (runnerClassDir) {
121
+ return runnerClassDir;
124
122
  }
125
123
 
126
- log.debug`Compiling LiterateRunner.java`;
124
+ const sourceDir = path.join(__dirname, 'jdi-runner');
125
+ const sourceFile = path.join(sourceDir, 'LiterateRunner.java');
126
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-runner-'));
127
+
128
+ log.debug`Compiling LiterateRunner.java into ${tempDir}`;
127
129
 
128
130
  try {
129
- execFileSync('javac', ['LiterateRunner.java'], {
130
- cwd: runnerDir,
131
+ execFileSync('javac', ['-d', tempDir, sourceFile], {
131
132
  timeout: 30000,
132
133
  stdio: ['pipe', 'pipe', 'pipe'],
133
134
  });
134
135
  } catch (err: unknown) {
135
136
  const execErr = err as { stderr?: Buffer; message: string };
136
137
  const stderr = execErr.stderr ? execErr.stderr.toString() : execErr.message;
138
+ fs.rmSync(tempDir, { recursive: true, force: true });
137
139
  throw new Error(`Failed to compile LiterateRunner.java:\n${stderr}`, {
138
140
  cause: err,
139
141
  });
140
142
  }
143
+
144
+ runnerClassDir = tempDir;
145
+ return tempDir;
141
146
  }
142
147
 
143
148
  export function executeLiterateJava(
@@ -146,8 +151,7 @@ export function executeLiterateJava(
146
151
  codeBlocks: LiterateCodeBlock[],
147
152
  stdin?: string,
148
153
  ): LiterateRunnerEntry[] {
149
- const runnerDir = path.join(__dirname, 'jdi-runner');
150
- ensureRunnerCompiled(runnerDir);
154
+ const runnerDir = ensureRunnerCompiled();
151
155
 
152
156
  const blockRanges = codeBlocks.map(b => [b.javaStartLine, b.javaEndLine]);
153
157
  const rangesJson = JSON.stringify(blockRanges);
@@ -7,6 +7,7 @@ import { makeLogger } from '../log';
7
7
  import { B } from '../colors';
8
8
  import createGlobals from '../globals';
9
9
  import { render, json } from '../templates';
10
+ import { validateParentLink } from '../validate-config-links';
10
11
  import {
11
12
  extractJavaMethodToc,
12
13
  renderCodeSegment,
@@ -131,8 +132,22 @@ export function injectAssetTags(
131
132
  )
132
133
  .join('');
133
134
 
135
+ const fontPreloadTags = [
136
+ 'inter/InterVariable.woff2',
137
+ 'google-sans-code/GoogleSansCodeVariable.woff2',
138
+ ]
139
+ .filter(f => fs.existsSync(path.join(distDir, f)))
140
+ .map(
141
+ f =>
142
+ `<link rel="preload" href="${applyBasePath('/' + f)}" as="font" type="font/woff2" crossorigin>`,
143
+ )
144
+ .join('');
145
+
134
146
  return html
135
- .replace('<head>', `<head>${criticalTags}${asyncLinkTags}`)
147
+ .replace(
148
+ '<head>',
149
+ `<head>${fontPreloadTags}${criticalTags}${asyncLinkTags}`,
150
+ )
136
151
  .replace('</head>', `${scriptTags}</head>`);
137
152
  }
138
153
 
@@ -348,6 +363,15 @@ function renderPlainTextContent(
348
363
 
349
364
  resolveAuthor(pageVariablesProcessed, filePath);
350
365
 
366
+ const parentError = validateParentLink(
367
+ pageVariablesProcessed.parent,
368
+ filePath,
369
+ validInternalTargets,
370
+ );
371
+ if (parentError) {
372
+ throw new Error(parentError);
373
+ }
374
+
351
375
  const strippedContent = stripHtmlComments(content);
352
376
 
353
377
  const params = createTemplateParameters({
@@ -0,0 +1,239 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ validateNavLinks,
4
+ validateAuthorLinks,
5
+ validateConfigLinks,
6
+ validateParentLink,
7
+ } from './validate-config-links';
8
+
9
+ describe('validateNavLinks', () => {
10
+ test('passes for valid internal links', () => {
11
+ const validTargets = new Set(['/lectures/index.html', '/labs/index.html']);
12
+ const navData = [
13
+ {
14
+ title: 'Topics',
15
+ links: [
16
+ { text: 'Lectures', internal: '/lectures/index.html' },
17
+ { text: 'Labs', internal: '/labs/index.html' },
18
+ ],
19
+ },
20
+ ];
21
+ expect(validateNavLinks(navData, validTargets)).toEqual([]);
22
+ });
23
+
24
+ test('reports broken internal links', () => {
25
+ const validTargets = new Set(['/about.html']);
26
+ const navData = [
27
+ {
28
+ title: 'Menu',
29
+ links: [{ text: 'Missing', internal: '/missing.html' }],
30
+ },
31
+ ];
32
+ const errors = validateNavLinks(navData, validTargets);
33
+ expect(errors).toHaveLength(1);
34
+ expect(errors[0]).toContain('/missing.html');
35
+ expect(errors[0]).toContain('nav.json');
36
+ });
37
+
38
+ test('reports multiple broken links', () => {
39
+ const validTargets = new Set<string>();
40
+ const navData = [
41
+ {
42
+ title: 'Menu',
43
+ links: [
44
+ { text: 'A', internal: '/a.html' },
45
+ { text: 'B', internal: '/b.html' },
46
+ ],
47
+ },
48
+ ];
49
+ const errors = validateNavLinks(navData, validTargets);
50
+ expect(errors).toHaveLength(2);
51
+ });
52
+
53
+ test('skips disabled links', () => {
54
+ const validTargets = new Set<string>();
55
+ const navData = [
56
+ {
57
+ title: 'Menu',
58
+ links: [
59
+ { text: 'Coming Soon', internal: '/coming.html', disabled: true },
60
+ ],
61
+ },
62
+ ];
63
+ expect(validateNavLinks(navData, validTargets)).toEqual([]);
64
+ });
65
+
66
+ test('skips external links', () => {
67
+ const validTargets = new Set<string>();
68
+ const navData = [
69
+ {
70
+ title: 'External',
71
+ links: [{ text: 'Google', external: 'https://google.com' }],
72
+ },
73
+ ];
74
+ expect(validateNavLinks(navData, validTargets)).toEqual([]);
75
+ });
76
+
77
+ test('normalizes paths before checking', () => {
78
+ const validTargets = new Set(['/docs/guide.html']);
79
+ const navData = [
80
+ {
81
+ title: 'Menu',
82
+ links: [{ text: 'Guide', internal: '/docs/../docs/guide.html' }],
83
+ },
84
+ ];
85
+ expect(validateNavLinks(navData, validTargets)).toEqual([]);
86
+ });
87
+
88
+ test('returns empty array for empty nav data', () => {
89
+ expect(validateNavLinks([], new Set())).toEqual([]);
90
+ });
91
+
92
+ test('returns empty array for null/undefined nav data', () => {
93
+ expect(validateNavLinks(null, new Set())).toEqual([]);
94
+ expect(validateNavLinks(undefined, new Set())).toEqual([]);
95
+ });
96
+ });
97
+
98
+ describe('validateAuthorLinks', () => {
99
+ test('passes for valid author url and avatar', () => {
100
+ const validTargets = new Set(['/about/alex.html', '/avatars/alex.jpg']);
101
+ const authorsData = {
102
+ alex: {
103
+ name: 'Alex',
104
+ avatar: '/avatars/alex.jpg',
105
+ url: '/about/alex.html',
106
+ },
107
+ };
108
+ expect(validateAuthorLinks(authorsData, validTargets)).toEqual([]);
109
+ });
110
+
111
+ test('reports broken author url', () => {
112
+ const validTargets = new Set(['/avatars/alex.jpg']);
113
+ const authorsData = {
114
+ alex: { name: 'Alex', avatar: '/avatars/alex.jpg', url: '/missing.html' },
115
+ };
116
+ const errors = validateAuthorLinks(authorsData, validTargets);
117
+ expect(errors).toHaveLength(1);
118
+ expect(errors[0]).toContain('/missing.html');
119
+ expect(errors[0]).toContain('authors.json');
120
+ expect(errors[0]).toContain('alex');
121
+ });
122
+
123
+ test('reports broken avatar path', () => {
124
+ const validTargets = new Set<string>();
125
+ const authorsData = {
126
+ alex: { name: 'Alex', avatar: '/avatars/missing.jpg' },
127
+ };
128
+ const errors = validateAuthorLinks(authorsData, validTargets);
129
+ expect(errors).toHaveLength(1);
130
+ expect(errors[0]).toContain('/avatars/missing.jpg');
131
+ expect(errors[0]).toContain('avatar');
132
+ });
133
+
134
+ test('reports both broken url and avatar', () => {
135
+ const validTargets = new Set<string>();
136
+ const authorsData = {
137
+ alex: {
138
+ name: 'Alex',
139
+ avatar: '/avatars/missing.jpg',
140
+ url: '/missing.html',
141
+ },
142
+ };
143
+ const errors = validateAuthorLinks(authorsData, validTargets);
144
+ expect(errors).toHaveLength(2);
145
+ });
146
+
147
+ test('skips author without url field', () => {
148
+ const validTargets = new Set(['/avatars/alex.jpg']);
149
+ const authorsData = { alex: { name: 'Alex', avatar: '/avatars/alex.jpg' } };
150
+ expect(validateAuthorLinks(authorsData, validTargets)).toEqual([]);
151
+ });
152
+
153
+ test('returns empty array for null/undefined authors data', () => {
154
+ expect(validateAuthorLinks(null, new Set())).toEqual([]);
155
+ expect(validateAuthorLinks(undefined, new Set())).toEqual([]);
156
+ });
157
+
158
+ test('validates multiple authors independently', () => {
159
+ const validTargets = new Set(['/avatars/alex.jpg']);
160
+ const authorsData = {
161
+ alex: { name: 'Alex', avatar: '/avatars/alex.jpg' },
162
+ bob: { name: 'Bob', avatar: '/avatars/bob.jpg' },
163
+ };
164
+ const errors = validateAuthorLinks(authorsData, validTargets);
165
+ expect(errors).toHaveLength(1);
166
+ expect(errors[0]).toContain('bob');
167
+ });
168
+ });
169
+
170
+ describe('validateConfigLinks', () => {
171
+ test('combines nav and author errors', () => {
172
+ const validTargets = new Set(['/avatars/alex.jpg']);
173
+ const navData = [
174
+ {
175
+ title: 'Menu',
176
+ links: [{ text: 'Missing', internal: '/missing.html' }],
177
+ },
178
+ ];
179
+ const authorsData = {
180
+ alex: {
181
+ name: 'Alex',
182
+ avatar: '/avatars/alex.jpg',
183
+ url: '/also-missing.html',
184
+ },
185
+ };
186
+ const errors = validateConfigLinks(validTargets, navData, authorsData);
187
+ expect(errors).toHaveLength(2);
188
+ });
189
+
190
+ test('returns empty array when everything is valid', () => {
191
+ const validTargets = new Set(['/about.html', '/avatars/a.jpg']);
192
+ const navData = [
193
+ { title: 'Menu', links: [{ text: 'About', internal: '/about.html' }] },
194
+ ];
195
+ const authorsData = { a: { name: 'A', avatar: '/avatars/a.jpg' } };
196
+ expect(validateConfigLinks(validTargets, navData, authorsData)).toEqual([]);
197
+ });
198
+
199
+ test('handles missing authors.json gracefully', () => {
200
+ const validTargets = new Set(['/about.html']);
201
+ const navData = [
202
+ { title: 'Menu', links: [{ text: 'About', internal: '/about.html' }] },
203
+ ];
204
+ expect(validateConfigLinks(validTargets, navData, undefined)).toEqual([]);
205
+ });
206
+ });
207
+
208
+ describe('validateParentLink', () => {
209
+ test('returns null for valid parent link', () => {
210
+ const validTargets = new Set(['/lectures/index.html']);
211
+ expect(
212
+ validateParentLink('/lectures/index.html', 'test.md', validTargets),
213
+ ).toBeNull();
214
+ });
215
+
216
+ test('returns error for broken parent link', () => {
217
+ const validTargets = new Set<string>();
218
+ const error = validateParentLink(
219
+ '/missing/index.html',
220
+ 'content/page.md',
221
+ validTargets,
222
+ );
223
+ expect(error).not.toBeNull();
224
+ expect(error).toContain('/missing/index.html');
225
+ expect(error).toContain('content/page.md');
226
+ expect(error).toContain('parent');
227
+ });
228
+
229
+ test('returns null when parent is undefined', () => {
230
+ expect(validateParentLink(undefined, 'test.md', new Set())).toBeNull();
231
+ });
232
+
233
+ test('normalizes parent path before checking', () => {
234
+ const validTargets = new Set(['/docs/index.html']);
235
+ expect(
236
+ validateParentLink('/docs/../docs/index.html', 'test.md', validTargets),
237
+ ).toBeNull();
238
+ });
239
+ });
@@ -0,0 +1,105 @@
1
+ import { normalizeOutputPath } from './utils/paths';
2
+
3
+ interface NavLink {
4
+ text: string;
5
+ internal?: string;
6
+ external?: string;
7
+ disabled?: boolean;
8
+ }
9
+
10
+ interface NavSection {
11
+ title: string;
12
+ links: NavLink[];
13
+ }
14
+
15
+ export function validateNavLinks(
16
+ navData: unknown,
17
+ validTargets: Set<string>,
18
+ ): string[] {
19
+ if (!Array.isArray(navData)) {
20
+ return [];
21
+ }
22
+
23
+ const errors: string[] = [];
24
+
25
+ for (const section of navData as NavSection[]) {
26
+ for (const link of section.links) {
27
+ if (link.disabled || !link.internal) {
28
+ continue;
29
+ }
30
+
31
+ const normalized = normalizeOutputPath(link.internal);
32
+ if (!validTargets.has(normalized)) {
33
+ errors.push(
34
+ `nav.json: broken internal link in section "${section.title}": "${link.internal}"`,
35
+ );
36
+ }
37
+ }
38
+ }
39
+
40
+ return errors;
41
+ }
42
+
43
+ interface AuthorEntry {
44
+ name: string;
45
+ avatar: string;
46
+ url?: string;
47
+ }
48
+
49
+ export function validateAuthorLinks(
50
+ authorsData: unknown,
51
+ validTargets: Set<string>,
52
+ ): string[] {
53
+ if (!authorsData || typeof authorsData !== 'object') {
54
+ return [];
55
+ }
56
+
57
+ const errors: string[] = [];
58
+ const authors = authorsData as Record<string, AuthorEntry>;
59
+
60
+ for (const [key, author] of Object.entries(authors)) {
61
+ const avatarPath = normalizeOutputPath(author.avatar);
62
+ if (!validTargets.has(avatarPath)) {
63
+ errors.push(
64
+ `authors.json: broken avatar path for "${key}": "${author.avatar}"`,
65
+ );
66
+ }
67
+
68
+ if (author.url) {
69
+ const urlPath = normalizeOutputPath(author.url);
70
+ if (!validTargets.has(urlPath)) {
71
+ errors.push(`authors.json: broken url for "${key}": "${author.url}"`);
72
+ }
73
+ }
74
+ }
75
+
76
+ return errors;
77
+ }
78
+
79
+ export function validateConfigLinks(
80
+ validTargets: Set<string>,
81
+ navData: unknown,
82
+ authorsData: unknown,
83
+ ): string[] {
84
+ return [
85
+ ...validateNavLinks(navData, validTargets),
86
+ ...validateAuthorLinks(authorsData, validTargets),
87
+ ];
88
+ }
89
+
90
+ export function validateParentLink(
91
+ parent: unknown,
92
+ filePath: string,
93
+ validTargets: Set<string>,
94
+ ): string | null {
95
+ if (!parent || typeof parent !== 'string') {
96
+ return null;
97
+ }
98
+
99
+ const normalized = normalizeOutputPath(parent);
100
+ if (!validTargets.has(normalized)) {
101
+ return `${filePath}: broken parent link: "${parent}"`;
102
+ }
103
+
104
+ return null;
105
+ }
package/build/watch.ts CHANGED
@@ -20,7 +20,7 @@ import { bundle } from './bundle';
20
20
  import { copyFonts } from './generate-fonts';
21
21
  import { copyKatexAssets } from './generate-katex-assets';
22
22
  import { generateFavicons } from './generate-favicon';
23
- import { generateManifest } from './generate-manifest';
23
+ import { generateWebAppManifest } from './generate-web-app-manifest';
24
24
  import {
25
25
  copyPublicFiles,
26
26
  copyContentAssets,
@@ -239,7 +239,7 @@ async function initialBuild(): Promise<void> {
239
239
 
240
240
  if (isFeatureEnabled(siteVariables, 'favicon')) {
241
241
  await generateFavicons(siteVariables, distDir);
242
- generateManifest(siteVariables, distDir);
242
+ generateWebAppManifest(siteVariables, distDir);
243
243
  }
244
244
 
245
245
  publicRelPaths = copyPublicFiles(publicDir, distDir);
@@ -341,7 +341,7 @@ async function rebuild(): Promise<void> {
341
341
 
342
342
  if (isFeatureEnabled(siteVariables, 'favicon')) {
343
343
  await generateFavicons(siteVariables, distDir);
344
- generateManifest(siteVariables, distDir);
344
+ generateWebAppManifest(siteVariables, distDir);
345
345
  }
346
346
 
347
347
  const result = contentRenderer.processContent({ distDir, assetFiles });
@@ -524,6 +524,7 @@ initialBuild()
524
524
  const configWatcher = chokidar.watch(jsonDataDir, {
525
525
  ignoreInitial: true,
526
526
  depth: 0,
527
+ awaitWriteFinish: { stabilityThreshold: 100 },
527
528
  });
528
529
  const onConfigFileChange = (filePath: string) => {
529
530
  if (configFilePaths.has(path.resolve(filePath))) {
@@ -61,46 +61,45 @@ from the standard in, counting how many characters are vowels. When there are
61
61
  no more characters in the stream, the program prints the total number of vowels.
62
62
 
63
63
  <!---
64
- Hiding the method signature for main() keeps us focused on the program's logic,
64
+ Hiding the first few lines keeps us focused on the program's logic,
65
65
  and avoids discussion of IOException.
66
66
  ```
67
- void main() throws Exception {
67
+ public class VowelCounter {
68
+ public static void main(String[] args) throws Exception {
68
69
  ```
69
70
  -->
70
71
 
71
72
  We'll start the program with a `numVowels` counter initialized to zero,
72
- the additive identity, and a second integer variable `ch` used to store the
73
- return value of the `read()` method:
73
+ the additive identity.
74
74
 
75
75
  ```
76
- int numVowels = 0;
77
- int ch;
76
+ int numVowels = 0;
78
77
  ```
79
78
 
80
79
  Using an indefinite loop (since we don't know in advance how many characters
81
80
  we'll get), we call the `read()` method and check if it returned the sentinel:
82
81
 
83
82
  ```
84
- while (true) {
85
- ch = System.in.read();
86
- if (ch == -1) {
87
- break;
88
- }
83
+ while (true) {
84
+ int ch = System.in.read();
85
+ if (ch == -1) {
86
+ break;
87
+ }
89
88
  ```
90
89
 
91
90
  If `read()` returns the sentinel, we stop the loop with `break`. Otherwise,
92
91
  we test whether `ch` is a vowel:
93
92
 
94
93
  ```
95
- if ("aeiouAEIOU".indexOf(ch) >= 0) {
96
- numVowels++;
97
- }
94
+ if ("aeiouAEIOU".indexOf(ch) >= 0) {
95
+ numVowels++;
96
+ }
98
97
  ```
99
98
 
100
99
  <!---
101
100
  Hide the closing brace of the `while` loop to avoid indenting the conditional.
102
101
  ```
103
- }
102
+ }
104
103
  ```
105
104
  -->
106
105
 
@@ -118,12 +117,13 @@ hello, world!
118
117
  Then the last line of the program, which prints the total count, will output 3:
119
118
 
120
119
  ```
121
- IO.println("vowels: " + numVowels);
120
+ System.out.println("vowels: " + numVowels);
122
121
  ```
123
122
 
124
123
  <!---
125
- Hiding the closing brace for main().
124
+ Hiding the closing braces.
126
125
  ```
126
+ }
127
127
  }
128
128
  ```
129
129
  -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
@@ -40,7 +40,6 @@
40
40
  "linux"
41
41
  ],
42
42
  "dependencies": {
43
- "@tsconfig/node23": "^23.0.2",
44
43
  "@vscode/markdown-it-katex": "^1.1.2",
45
44
  "ajv": "^8.17.1",
46
45
  "chokidar": "^4.0.0",
@@ -72,7 +71,6 @@
72
71
  },
73
72
  "devDependencies": {
74
73
  "@eslint/js": "^10.0.1",
75
- "@playwright/test": "^1.43.0",
76
74
  "@types/bun": "^1.3.10",
77
75
  "@types/culori": "^4.0.1",
78
76
  "@types/fontkit": "^2.0.8",
@@ -84,7 +82,6 @@
84
82
  "lint-staged": "^16.4.0",
85
83
  "prettier": "^3.6.2",
86
84
  "prettier-plugin-curly": "^0.4.1",
87
- "rimraf": "^6.1.3",
88
85
  "typescript": "^5.4.5",
89
86
  "typescript-eslint": "^8.57.1"
90
87
  }
@@ -97,6 +97,7 @@ header summary .to-top-container {
97
97
  height: var(--header-collapsed-height);
98
98
  pointer-events: none;
99
99
  white-space: nowrap;
100
+ padding: 0 var(--gap) 0 0;
100
101
 
101
102
  &:has(.is-visible) {
102
103
  display: flex;
@@ -116,7 +117,7 @@ header summary .search-controls {
116
117
  display: flex;
117
118
  align-items: center;
118
119
  height: var(--header-collapsed-height);
119
- padding: 0 var(--gap);
120
+ padding: 0 var(--gap) 0 0;
120
121
  min-width: 0;
121
122
 
122
123
  input.search {
@@ -10,10 +10,6 @@
10
10
  background: none !important;
11
11
  }
12
12
 
13
- main {
14
- padding-top: 0;
15
- }
16
-
17
13
  hr {
18
14
  height: 0;
19
15
  border-bottom: 2px solid gray;
@@ -27,11 +23,8 @@
27
23
  display: none !important;
28
24
  }
29
25
 
30
- main {
31
- max-width: none;
32
- width: 100%;
33
- margin: 0;
34
- padding: 0;
26
+ body > .container {
27
+ margin-top: 0;
35
28
  }
36
29
 
37
30
  body {
@@ -14,3 +14,22 @@ span.next-prev-day {
14
14
  align-items: center;
15
15
  gap: 4px;
16
16
  }
17
+
18
+ @media print {
19
+ .timezone-wrapper .icon-button {
20
+ display: none;
21
+ }
22
+
23
+ select.time-zone {
24
+ appearance: none;
25
+ border: none;
26
+ background: none;
27
+ padding: 0;
28
+ font: inherit;
29
+ color: inherit;
30
+
31
+ option {
32
+ display: none;
33
+ }
34
+ }
35
+ }