@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
@@ -0,0 +1,349 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import {
6
+ diffManifests,
7
+ hashFile,
8
+ walkAndHash,
9
+ getVersions,
10
+ getNextVersion,
11
+ generateBuildManifest,
12
+ loadManifest,
13
+ pruneOldVersions,
14
+ copyChangedFiles,
15
+ } from './build-manifest';
16
+ import type { BuildManifest, ManifestDiff } from './build-manifest';
17
+
18
+ function makeManifest(files: Record<string, string>): BuildManifest {
19
+ return { version: 1, buildTime: '2025-01-01T00:00:00.000Z', files };
20
+ }
21
+
22
+ let tmpDir: string;
23
+
24
+ beforeEach(() => {
25
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-test-'));
26
+ });
27
+
28
+ afterEach(() => {
29
+ fs.rmSync(tmpDir, { recursive: true });
30
+ });
31
+
32
+ describe('diffManifests', () => {
33
+ test('detects added files', () => {
34
+ const prev = makeManifest({ 'a.html': 'hash1' });
35
+ const current = makeManifest({ 'a.html': 'hash1', 'b.html': 'hash2' });
36
+ const diff = diffManifests(prev, current);
37
+ expect(diff.added).toEqual(['b.html']);
38
+ expect(diff.changed).toEqual([]);
39
+ expect(diff.removed).toEqual([]);
40
+ });
41
+
42
+ test('detects changed files', () => {
43
+ const prev = makeManifest({ 'a.html': 'hash1' });
44
+ const current = makeManifest({ 'a.html': 'hash2' });
45
+ const diff = diffManifests(prev, current);
46
+ expect(diff.added).toEqual([]);
47
+ expect(diff.changed).toEqual(['a.html']);
48
+ expect(diff.removed).toEqual([]);
49
+ });
50
+
51
+ test('detects removed files', () => {
52
+ const prev = makeManifest({ 'a.html': 'hash1', 'b.html': 'hash2' });
53
+ const current = makeManifest({ 'a.html': 'hash1' });
54
+ const diff = diffManifests(prev, current);
55
+ expect(diff.added).toEqual([]);
56
+ expect(diff.changed).toEqual([]);
57
+ expect(diff.removed).toEqual(['b.html']);
58
+ });
59
+
60
+ test('detects added, changed, and removed all at once', () => {
61
+ const prev = makeManifest({
62
+ 'unchanged.html': 'same',
63
+ 'changed.html': 'old',
64
+ 'removed.html': 'gone',
65
+ });
66
+ const current = makeManifest({
67
+ 'unchanged.html': 'same',
68
+ 'changed.html': 'new',
69
+ 'added.html': 'fresh',
70
+ });
71
+ const diff = diffManifests(prev, current);
72
+ expect(diff.added).toEqual(['added.html']);
73
+ expect(diff.changed).toEqual(['changed.html']);
74
+ expect(diff.removed).toEqual(['removed.html']);
75
+ });
76
+
77
+ test('returns empty arrays for identical manifests', () => {
78
+ const manifest = makeManifest({ 'a.html': 'hash1', 'b.css': 'hash2' });
79
+ const diff = diffManifests(manifest, manifest);
80
+ expect(diff.added).toEqual([]);
81
+ expect(diff.changed).toEqual([]);
82
+ expect(diff.removed).toEqual([]);
83
+ });
84
+
85
+ test('returns results in alphabetical order', () => {
86
+ const prev = makeManifest({
87
+ 'z.html': 'old',
88
+ 'removed-b.html': 'r',
89
+ 'removed-a.html': 'r',
90
+ });
91
+ const current = makeManifest({
92
+ 'z.html': 'new',
93
+ 'c-added.html': 'c',
94
+ 'a-added.html': 'a',
95
+ });
96
+ const diff = diffManifests(prev, current);
97
+ expect(diff.added).toEqual(['a-added.html', 'c-added.html']);
98
+ expect(diff.changed).toEqual(['z.html']);
99
+ expect(diff.removed).toEqual(['removed-a.html', 'removed-b.html']);
100
+ });
101
+ });
102
+
103
+ describe('hashFile', () => {
104
+ test('produces consistent hashes for the same content', async () => {
105
+ const file = path.join(tmpDir, 'test.txt');
106
+ fs.writeFileSync(file, 'hello world');
107
+ const hash1 = await hashFile(file);
108
+ const hash2 = await hashFile(file);
109
+ expect(hash1).toBe(hash2);
110
+ });
111
+
112
+ test('returns a 64-character hex string', async () => {
113
+ const file = path.join(tmpDir, 'test.txt');
114
+ fs.writeFileSync(file, 'content');
115
+ const hash = await hashFile(file);
116
+ expect(hash).toHaveLength(64);
117
+ expect(hash).toMatch(/^[0-9a-f]+$/);
118
+ });
119
+
120
+ test('different content produces different hashes', async () => {
121
+ const file1 = path.join(tmpDir, 'a.txt');
122
+ const file2 = path.join(tmpDir, 'b.txt');
123
+ fs.writeFileSync(file1, 'content one');
124
+ fs.writeFileSync(file2, 'content two');
125
+ const hash1 = await hashFile(file1);
126
+ const hash2 = await hashFile(file2);
127
+ expect(hash1).not.toBe(hash2);
128
+ });
129
+ });
130
+
131
+ describe('walkAndHash', () => {
132
+ test('returns a file map with relative paths and hashes', async () => {
133
+ fs.writeFileSync(path.join(tmpDir, 'index.html'), '<html></html>');
134
+ fs.writeFileSync(path.join(tmpDir, 'style.css'), 'body {}');
135
+ const result = await walkAndHash(tmpDir);
136
+ expect(Object.keys(result).sort()).toEqual(['index.html', 'style.css']);
137
+ for (const hash of Object.values(result)) {
138
+ expect(hash).toHaveLength(64);
139
+ }
140
+ });
141
+
142
+ test('excludes pagefind/ top-level directory', async () => {
143
+ fs.writeFileSync(path.join(tmpDir, 'index.html'), '<html></html>');
144
+ const pagefindDir = path.join(tmpDir, 'pagefind');
145
+ fs.mkdirSync(pagefindDir);
146
+ fs.writeFileSync(path.join(pagefindDir, 'pagefind.js'), 'code');
147
+ const result = await walkAndHash(tmpDir);
148
+ expect(Object.keys(result)).toEqual(['index.html']);
149
+ expect('pagefind/pagefind.js' in result).toBe(false);
150
+ });
151
+
152
+ test('returns empty object for empty directory', async () => {
153
+ const result = await walkAndHash(tmpDir);
154
+ expect(result).toEqual({});
155
+ });
156
+
157
+ test('uses forward slashes in keys', async () => {
158
+ const subDir = path.join(tmpDir, 'sub', 'nested');
159
+ fs.mkdirSync(subDir, { recursive: true });
160
+ fs.writeFileSync(path.join(subDir, 'file.html'), 'content');
161
+ const result = await walkAndHash(tmpDir);
162
+ const keys = Object.keys(result);
163
+ expect(keys).toEqual(['sub/nested/file.html']);
164
+ for (const key of keys) {
165
+ expect(key).not.toContain('\\');
166
+ }
167
+ });
168
+ });
169
+
170
+ describe('getVersions', () => {
171
+ test('returns sorted version numbers', () => {
172
+ fs.mkdirSync(path.join(tmpDir, 'v3'));
173
+ fs.mkdirSync(path.join(tmpDir, 'v1'));
174
+ fs.mkdirSync(path.join(tmpDir, 'v2'));
175
+ expect(getVersions(tmpDir)).toEqual([1, 2, 3]);
176
+ });
177
+
178
+ test('ignores non-version entries', () => {
179
+ fs.mkdirSync(path.join(tmpDir, 'v1'));
180
+ fs.mkdirSync(path.join(tmpDir, 'other'));
181
+ fs.mkdirSync(path.join(tmpDir, 'v2x'));
182
+ fs.writeFileSync(path.join(tmpDir, 'v3.manifest.json'), '{}');
183
+ expect(getVersions(tmpDir)).toEqual([1]);
184
+ });
185
+
186
+ test('returns empty array for nonexistent directory', () => {
187
+ expect(getVersions(path.join(tmpDir, 'nonexistent'))).toEqual([]);
188
+ });
189
+
190
+ test('returns empty array for empty directory', () => {
191
+ expect(getVersions(tmpDir)).toEqual([]);
192
+ });
193
+ });
194
+
195
+ describe('getNextVersion', () => {
196
+ test('returns 1 for nonexistent directory', () => {
197
+ expect(getNextVersion(path.join(tmpDir, 'nonexistent'))).toBe(1);
198
+ });
199
+
200
+ test('returns 1 for empty directory', () => {
201
+ expect(getNextVersion(tmpDir)).toBe(1);
202
+ });
203
+
204
+ test('returns highest version + 1', () => {
205
+ fs.mkdirSync(path.join(tmpDir, 'v1'));
206
+ fs.mkdirSync(path.join(tmpDir, 'v3'));
207
+ expect(getNextVersion(tmpDir)).toBe(4);
208
+ });
209
+ });
210
+
211
+ describe('generateBuildManifest', () => {
212
+ test('writes valid JSON with version, buildTime, and files', async () => {
213
+ const distDir = path.join(tmpDir, 'dist');
214
+ fs.mkdirSync(distDir);
215
+ fs.writeFileSync(path.join(distDir, 'index.html'), '<html></html>');
216
+
217
+ const manifestPath = path.join(tmpDir, 'manifest.json');
218
+ await generateBuildManifest(distDir, manifestPath);
219
+
220
+ const raw = fs.readFileSync(manifestPath, 'utf8');
221
+ const manifest = JSON.parse(raw) as BuildManifest;
222
+
223
+ expect(manifest.version).toBe(1);
224
+ expect(typeof manifest.buildTime).toBe('string');
225
+ expect(manifest.buildTime).toMatch(/^\d{4}-\d{2}-\d{2}T/);
226
+ expect(typeof manifest.files).toBe('object');
227
+ expect('index.html' in manifest.files).toBe(true);
228
+ });
229
+ });
230
+
231
+ describe('loadManifest', () => {
232
+ test('loads and parses a manifest from a file', () => {
233
+ const manifest = makeManifest({ 'index.html': 'abc123' });
234
+ const filePath = path.join(tmpDir, 'manifest.json');
235
+ fs.writeFileSync(filePath, JSON.stringify(manifest, null, 2) + '\n');
236
+
237
+ const loaded = loadManifest(filePath);
238
+ expect(loaded).toEqual(manifest);
239
+ });
240
+
241
+ test('returns null for a nonexistent file', () => {
242
+ const result = loadManifest(path.join(tmpDir, 'missing.json'));
243
+ expect(result).toBeNull();
244
+ });
245
+ });
246
+
247
+ describe('pruneOldVersions', () => {
248
+ function setupVersions(versions: number[]) {
249
+ for (const v of versions) {
250
+ fs.mkdirSync(path.join(tmpDir, `v${v}`));
251
+ fs.writeFileSync(
252
+ path.join(tmpDir, `v${v}.manifest.json`),
253
+ JSON.stringify(makeManifest({})),
254
+ );
255
+ }
256
+ }
257
+
258
+ test('removes all but the latest 2 version directories', () => {
259
+ setupVersions([1, 2, 3]);
260
+ pruneOldVersions(tmpDir);
261
+ expect(fs.existsSync(path.join(tmpDir, 'v1'))).toBe(false);
262
+ expect(fs.existsSync(path.join(tmpDir, 'v2'))).toBe(true);
263
+ expect(fs.existsSync(path.join(tmpDir, 'v3'))).toBe(true);
264
+ });
265
+
266
+ test('also removes manifest files for pruned versions', () => {
267
+ setupVersions([1, 2, 3]);
268
+ 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);
272
+ });
273
+
274
+ test('keeps all versions when there are 2 or fewer', () => {
275
+ setupVersions([1, 2]);
276
+ pruneOldVersions(tmpDir);
277
+ expect(fs.existsSync(path.join(tmpDir, 'v1'))).toBe(true);
278
+ expect(fs.existsSync(path.join(tmpDir, 'v2'))).toBe(true);
279
+ });
280
+
281
+ test('is a no-op for a nonexistent directory', () => {
282
+ expect(() =>
283
+ pruneOldVersions(path.join(tmpDir, 'nonexistent')),
284
+ ).not.toThrow();
285
+ });
286
+ });
287
+
288
+ describe('copyChangedFiles', () => {
289
+ test('copies added and changed files', () => {
290
+ const distDir = path.join(tmpDir, 'dist');
291
+ const outDir = path.join(tmpDir, 'out');
292
+ fs.mkdirSync(distDir);
293
+ fs.mkdirSync(outDir);
294
+
295
+ fs.writeFileSync(path.join(distDir, 'added.html'), 'new file');
296
+ fs.writeFileSync(path.join(distDir, 'changed.css'), 'updated styles');
297
+
298
+ const diff: ManifestDiff = {
299
+ added: ['added.html'],
300
+ changed: ['changed.css'],
301
+ removed: ['old.html'],
302
+ };
303
+
304
+ copyChangedFiles(diff, distDir, outDir);
305
+
306
+ expect(fs.existsSync(path.join(outDir, 'added.html'))).toBe(true);
307
+ expect(fs.existsSync(path.join(outDir, 'changed.css'))).toBe(true);
308
+ });
309
+
310
+ test('does not copy removed files', () => {
311
+ const distDir = path.join(tmpDir, 'dist');
312
+ const outDir = path.join(tmpDir, 'out');
313
+ fs.mkdirSync(distDir);
314
+ fs.mkdirSync(outDir);
315
+
316
+ const diff: ManifestDiff = {
317
+ added: [],
318
+ changed: [],
319
+ removed: ['removed.html'],
320
+ };
321
+
322
+ copyChangedFiles(diff, distDir, outDir);
323
+
324
+ expect(fs.existsSync(path.join(outDir, 'removed.html'))).toBe(false);
325
+ });
326
+
327
+ test('creates nested directories as needed', () => {
328
+ const distDir = path.join(tmpDir, 'dist');
329
+ const outDir = path.join(tmpDir, 'out');
330
+ fs.mkdirSync(distDir);
331
+ fs.mkdirSync(outDir);
332
+
333
+ const nested = path.join(distDir, 'deep', 'nested');
334
+ fs.mkdirSync(nested, { recursive: true });
335
+ fs.writeFileSync(path.join(nested, 'page.html'), 'content');
336
+
337
+ const diff: ManifestDiff = {
338
+ added: ['deep/nested/page.html'],
339
+ changed: [],
340
+ removed: [],
341
+ };
342
+
343
+ copyChangedFiles(diff, distDir, outDir);
344
+
345
+ expect(
346
+ fs.existsSync(path.join(outDir, 'deep', 'nested', 'page.html')),
347
+ ).toBe(true);
348
+ });
349
+ });
@@ -0,0 +1,165 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const EXCLUDED_DIRS = new Set(['pagefind']);
5
+
6
+ export interface BuildManifest {
7
+ version: 1;
8
+ buildTime: string;
9
+ files: Record<string, string>;
10
+ }
11
+
12
+ export interface ManifestDiff {
13
+ added: string[];
14
+ changed: string[];
15
+ removed: string[];
16
+ }
17
+
18
+ export function diffManifests(
19
+ prev: BuildManifest,
20
+ current: BuildManifest,
21
+ ): ManifestDiff {
22
+ const prevFiles = prev.files;
23
+ const currentFiles = current.files;
24
+
25
+ const added: string[] = [];
26
+ const changed: string[] = [];
27
+ const removed: string[] = [];
28
+
29
+ for (const key of Object.keys(currentFiles)) {
30
+ if (!(key in prevFiles)) {
31
+ added.push(key);
32
+ } else if (prevFiles[key] !== currentFiles[key]) {
33
+ changed.push(key);
34
+ }
35
+ }
36
+
37
+ for (const key of Object.keys(prevFiles)) {
38
+ if (!(key in currentFiles)) {
39
+ removed.push(key);
40
+ }
41
+ }
42
+
43
+ return {
44
+ added: added.sort(),
45
+ changed: changed.sort(),
46
+ removed: removed.sort(),
47
+ };
48
+ }
49
+
50
+ export async function hashFile(filePath: string): Promise<string> {
51
+ const buffer = await Bun.file(filePath).arrayBuffer();
52
+ const hasher = new Bun.CryptoHasher('sha256');
53
+ hasher.update(buffer);
54
+ return hasher.digest('hex');
55
+ }
56
+
57
+ export async function walkAndHash(
58
+ dir: string,
59
+ ): Promise<Record<string, string>> {
60
+ const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: true });
61
+ const result: Record<string, string> = {};
62
+
63
+ for (const entry of entries) {
64
+ if (!entry.isFile()) {
65
+ continue;
66
+ }
67
+
68
+ const abs = path.join(entry.parentPath, entry.name);
69
+ const rel = path.relative(dir, abs).split(path.sep).join(path.posix.sep);
70
+
71
+ const topLevel = rel.split('/')[0];
72
+ if (EXCLUDED_DIRS.has(topLevel)) {
73
+ continue;
74
+ }
75
+
76
+ result[rel] = await hashFile(abs);
77
+ }
78
+
79
+ return result;
80
+ }
81
+
82
+ export function getVersions(prodBaseDir: string): number[] {
83
+ let entries: fs.Dirent[];
84
+ try {
85
+ entries = fs.readdirSync(prodBaseDir, { withFileTypes: true });
86
+ } catch {
87
+ return [];
88
+ }
89
+
90
+ const versions: number[] = [];
91
+ for (const entry of entries) {
92
+ if (!entry.isDirectory()) {
93
+ continue;
94
+ }
95
+ const match = entry.name.match(/^v(\d+)$/);
96
+ if (match) {
97
+ versions.push(parseInt(match[1], 10));
98
+ }
99
+ }
100
+
101
+ return versions.sort((a, b) => a - b);
102
+ }
103
+
104
+ export function getNextVersion(prodBaseDir: string): number {
105
+ const versions = getVersions(prodBaseDir);
106
+ if (versions.length === 0) {
107
+ return 1;
108
+ }
109
+ return Math.max(...versions) + 1;
110
+ }
111
+
112
+ export async function generateBuildManifest(
113
+ distDir: string,
114
+ manifestPath: string,
115
+ ): Promise<void> {
116
+ const files = await walkAndHash(distDir);
117
+ const manifest: BuildManifest = {
118
+ version: 1,
119
+ buildTime: new Date().toISOString(),
120
+ files,
121
+ };
122
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
123
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
124
+ }
125
+
126
+ export function loadManifest(filePath: string): BuildManifest | null {
127
+ try {
128
+ const content = fs.readFileSync(filePath, 'utf8');
129
+ return JSON.parse(content) as BuildManifest;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ export function pruneOldVersions(prodBaseDir: string): number[] {
136
+ const versions = getVersions(prodBaseDir);
137
+ if (versions.length <= 2) {
138
+ return [];
139
+ }
140
+
141
+ const toRemove = versions.slice(0, versions.length - 2);
142
+ for (const v of toRemove) {
143
+ const vDir = path.join(prodBaseDir, `v${v}`);
144
+ 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
+ }
149
+
150
+ return toRemove;
151
+ }
152
+
153
+ export function copyChangedFiles(
154
+ diff: ManifestDiff,
155
+ distDir: string,
156
+ outDir: string,
157
+ ): void {
158
+ const filesToCopy = [...diff.added, ...diff.changed];
159
+ for (const rel of filesToCopy) {
160
+ const src = path.join(distDir, rel);
161
+ const dest = path.join(outDir, rel);
162
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
163
+ fs.copyFileSync(src, dest);
164
+ }
165
+ }
package/build/bundle.ts CHANGED
@@ -3,23 +3,23 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import _ from 'lodash';
5
5
  import * as sass from 'sass';
6
- import { getPackageDir, getProjectDir, getDistDir } from './utils/paths.js';
7
- import { deriveTheme } from './utils/derive-theme.js';
6
+ import { getPackageDir, getProjectDir, getDistDir } from './utils/paths';
7
+ import { deriveTheme } from './utils/derive-theme';
8
8
  import type { PluginBuilder } from 'bun';
9
- import type { SiteVariables } from './types.js';
9
+ import type { SiteVariables } from './types';
10
10
  import timezones from '../src/timezone/timezones.json' with { type: 'json' };
11
11
 
12
12
  function renderThemeScss(siteVariables: SiteVariables): string {
13
13
  const templatePath = path.join(getPackageDir(), 'templates/_theme.scss');
14
14
  const template = fs.readFileSync(templatePath, 'utf-8');
15
- const theme = deriveTheme(siteVariables.themeColor!);
15
+ const theme = deriveTheme(siteVariables.themeColor);
16
16
  const tintHue = siteVariables.tintHue ?? 20;
17
17
  const tintAmount = siteVariables.tintAmount ?? 100;
18
18
 
19
19
  const iconColor = `hsl(${tintHue}deg ${(8 * tintAmount) / 100}% 8%)`;
20
- const iconColorHover = `hsl(${tintHue}deg ${(6 * tintAmount) / 100}% 60%)`;
20
+ const iconColorHover = `hsl(${tintHue}deg ${(6 * tintAmount) / 100}% 45%)`;
21
21
  const iconColorDark = `hsl(${tintHue}deg ${(20 * tintAmount) / 100}% 90%)`;
22
- const iconColorHoverDark = `hsl(${tintHue}deg ${(6 * tintAmount) / 100}% 55%)`;
22
+ const iconColorHoverDark = `hsl(${tintHue}deg ${(6 * tintAmount) / 100}% 60%)`;
23
23
  const iconColorTranslucentDark = `hsl(${tintHue}deg ${(85 * tintAmount) / 100}% 90%)`;
24
24
 
25
25
  const rendered = _.template(template)({
@@ -46,16 +46,12 @@ function createDefine(
46
46
  isDev = false,
47
47
  ): Record<string, string> {
48
48
  return {
49
- 'window.siteVariables.base': JSON.stringify(siteVariables.base),
50
- 'window.siteVariables.basePath': JSON.stringify(siteVariables.basePath),
51
- 'window.siteVariables.titlePostfix': JSON.stringify(
52
- siteVariables.titlePostfix,
53
- ),
54
- 'window.siteVariables.defaultTimeZone': JSON.stringify(
55
- siteVariables.defaultTimeZone,
56
- ),
57
- 'window.siteVariables.timezones': JSON.stringify(timezones),
58
- 'window.IS_DEV': JSON.stringify(isDev),
49
+ __SITE_BASE__: JSON.stringify(siteVariables.base),
50
+ __SITE_BASE_PATH__: JSON.stringify(siteVariables.basePath),
51
+ __SITE_TITLE_POSTFIX__: JSON.stringify(siteVariables.titlePostfix),
52
+ __SITE_DEFAULT_TIMEZONE__: JSON.stringify(siteVariables.defaultTimeZone),
53
+ __SITE_TIMEZONES__: JSON.stringify(timezones),
54
+ __IS_DEV__: JSON.stringify(isDev),
59
55
  };
60
56
  }
61
57
 
@@ -77,10 +73,10 @@ function createScssPlugin(siteVariables: SiteVariables) {
77
73
 
78
74
  export async function bundle(
79
75
  siteVariables: SiteVariables,
80
- { mode = 'development' }: { mode?: string } = {},
76
+ { mode = 'development', distDir }: { mode?: string; distDir?: string } = {},
81
77
  ): Promise<string[]> {
82
78
  const packageDir = getPackageDir();
83
- const distDir = getDistDir();
79
+ const resolvedDistDir = distDir ?? getDistDir();
84
80
  const isDev = mode === 'development';
85
81
 
86
82
  const entrypoints = [
@@ -90,7 +86,7 @@ export async function bundle(
90
86
 
91
87
  const result = await Bun.build({
92
88
  entrypoints,
93
- outdir: distDir,
89
+ outdir: resolvedDistDir,
94
90
  naming: '[name].bundle.[ext]',
95
91
  minify: mode === 'production',
96
92
  sourcemap: isDev ? 'inline' : 'none',
@@ -108,7 +104,10 @@ export async function bundle(
108
104
 
109
105
  // Return the output filenames for asset tag injection
110
106
  const assetFiles = result.outputs.map(output =>
111
- path.relative(distDir, output.path).split(path.sep).join(path.posix.sep),
107
+ path
108
+ .relative(resolvedDistDir, output.path)
109
+ .split(path.sep)
110
+ .join(path.posix.sep),
112
111
  );
113
112
 
114
113
  return assetFiles;
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test, beforeAll } from 'bun:test';
2
- import { initHighlighter } from './utils/shiki-highlighter.js';
3
- import { renderCodeWithComments, extractJavaMethodToc } from './utils/code.js';
4
- import type { SiteVariables } from './types.js';
2
+ import { initHighlighter } from './utils/shiki-highlighter';
3
+ import { renderCodeWithComments, extractJavaMethodToc } from './utils/code';
4
+ import type { SiteVariables } from './types';
5
5
 
6
6
  beforeAll(async () => {
7
7
  await initHighlighter(['java', 'text', 'plaintext']);
@@ -149,6 +149,10 @@ describe('renderCodeWithComments', () => {
149
149
  base: '',
150
150
  basePath: '/',
151
151
  internalDomains: [],
152
+ title: 'Test',
153
+ titlePostfix: ' - Test',
154
+ themeColor: 'steelblue',
155
+ defaultTimeZone: 'America/New_York',
152
156
  } as SiteVariables);
153
157
 
154
158
  expect(html).toContain('<span class="code-row">');
@@ -1,15 +1,15 @@
1
1
  import path from 'path';
2
- import type { SiteVariables } from './types.js';
3
- import { getContentDir, getBuildContentFiles } from './util.js';
2
+ import type { SiteVariables } from './types';
3
+ import { getContentDir, getBuildContentFiles } from './util';
4
4
  import {
5
5
  compileTemplates,
6
6
  getHtmlTemplatesDir,
7
7
  getJsonDataDir,
8
8
  JSON_DATA_FILES,
9
- } from './templates.js';
10
- import { getProjectDir } from './utils/paths.js';
11
- import { B } from './colors.js';
12
- import { makeLogger } from './log.js';
9
+ } from './templates';
10
+ import { getProjectDir } from './utils/paths';
11
+ import { B } from './colors';
12
+ import { makeLogger } from './log';
13
13
 
14
14
  const log = makeLogger(__filename);
15
15
 
package/build/copy.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { makeLogger } from './log.js';
4
- import { B } from './colors.js';
3
+ import { makeLogger } from './log';
4
+ import { B } from './colors';
5
5
 
6
6
  const log = makeLogger(__filename);
7
7
 
@@ -1,5 +1,5 @@
1
1
  import type MarkdownIt from 'markdown-it';
2
- import textToId from './text-to-id.js';
2
+ import textToId from './text-to-id';
3
3
 
4
4
  export default function deflistIdPlugin(md: MarkdownIt): void {
5
5
  md.core.ruler.push('deflist_id_injector', function (state) {
@@ -1,7 +1,7 @@
1
1
  import type MarkdownIt from 'markdown-it';
2
2
  import type Token from 'markdown-it/lib/token.mjs';
3
- import { makeLogger } from './log.js';
4
- import type { SiteVariables } from './types.js';
3
+ import { makeLogger } from './log';
4
+ import type { SiteVariables } from './types';
5
5
 
6
6
  const log = makeLogger(__filename);
7
7
 
package/build/features.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { SiteVariables } from './types.js';
1
+ import type { SiteVariables } from './types';
2
2
 
3
3
  export function isFeatureEnabled(
4
4
  siteVariables: SiteVariables,