@abreen/tada 1.3.2 → 1.4.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.
- package/README.md +1 -0
- package/bin/tada.ts +23 -18
- package/build/build-manifest.test.ts +35 -15
- package/build/build-manifest.ts +9 -5
- package/build/generate-content-assets.ts +11 -0
- package/build/{generate-manifest.ts → generate-web-app-manifest.ts} +1 -1
- package/build/pipeline.ts +4 -5
- package/build/utils/literate-java.ts +19 -15
- package/build/utils/render.ts +10 -0
- package/build/validate-config-links.test.ts +239 -0
- package/build/validate-config-links.ts +105 -0
- package/build/watch.ts +4 -3
- package/init/content/labs/00/VowelCounter.java.md +17 -17
- package/package.json +1 -4
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 {
|
|
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(
|
|
154
|
+
function run(args: string[]): void {
|
|
155
155
|
try {
|
|
156
|
-
|
|
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(
|
|
510
|
-
|
|
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(
|
|
576
|
-
fs.copyFileSync(
|
|
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(
|
|
615
|
+
run([path.join(packageDir, 'build/pipeline.ts'), 'dev']);
|
|
605
616
|
break;
|
|
606
617
|
|
|
607
618
|
case 'prod':
|
|
608
619
|
requireSiteConfig('prod');
|
|
609
|
-
run(
|
|
620
|
+
run([path.join(packageDir, 'build/pipeline.ts'), 'prod']);
|
|
610
621
|
break;
|
|
611
622
|
|
|
612
623
|
case 'watch': {
|
|
613
624
|
requireSiteConfig('dev');
|
|
614
|
-
|
|
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
|
-
|
|
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(
|
|
19
|
-
|
|
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, '
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
263
|
+
const vDir = path.join(tmpDir, `v${v}`);
|
|
264
|
+
fs.mkdirSync(vDir);
|
|
251
265
|
fs.writeFileSync(
|
|
252
|
-
path.join(
|
|
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('
|
|
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(
|
|
270
|
-
|
|
271
|
-
|
|
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', () => {
|
package/build/build-manifest.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
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 {
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
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
|
-
|
|
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', ['
|
|
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 =
|
|
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);
|
package/build/utils/render.ts
CHANGED
|
@@ -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,
|
|
@@ -348,6 +349,15 @@ function renderPlainTextContent(
|
|
|
348
349
|
|
|
349
350
|
resolveAuthor(pageVariablesProcessed, filePath);
|
|
350
351
|
|
|
352
|
+
const parentError = validateParentLink(
|
|
353
|
+
pageVariablesProcessed.parent,
|
|
354
|
+
filePath,
|
|
355
|
+
validInternalTargets,
|
|
356
|
+
);
|
|
357
|
+
if (parentError) {
|
|
358
|
+
throw new Error(parentError);
|
|
359
|
+
}
|
|
360
|
+
|
|
351
361
|
const strippedContent = stripHtmlComments(content);
|
|
352
362
|
|
|
353
363
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
+
Hiding the first few lines keeps us focused on the program's logic,
|
|
65
65
|
and avoids discussion of IOException.
|
|
66
66
|
```
|
|
67
|
-
|
|
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
|
|
73
|
-
return value of the `read()` method:
|
|
73
|
+
the additive identity.
|
|
74
74
|
|
|
75
75
|
```
|
|
76
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
120
|
+
System.out.println("vowels: " + numVowels);
|
|
122
121
|
```
|
|
123
122
|
|
|
124
123
|
<!---
|
|
125
|
-
Hiding the closing
|
|
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
|
+
"version": "1.4.0",
|
|
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
|
}
|