@abreen/tada 1.0.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -48
- package/bin/tada.ts +621 -0
- package/bin/validators.test.ts +205 -0
- package/bin/validators.ts +85 -0
- package/{webpack/apply-base-path-plugin.js → build/apply-base-path-plugin.ts} +37 -17
- package/build/build-manifest.test.ts +349 -0
- package/build/build-manifest.ts +165 -0
- package/build/bundle.ts +116 -0
- package/{webpack/code.test.js → build/code.test.ts} +10 -7
- package/build/colors.ts +25 -0
- package/build/content-watch.ts +107 -0
- package/build/copy.ts +118 -0
- package/{webpack/deflist-id-plugin.js → build/deflist-id-plugin.ts} +7 -6
- package/{webpack/external-links-plugin.js → build/external-links-plugin.ts} +14 -5
- package/build/features.ts +11 -0
- package/build/generate-content-assets.ts +320 -0
- package/build/generate-favicon.ts +172 -0
- package/build/generate-fonts.ts +31 -0
- package/build/generate-katex-assets.ts +35 -0
- package/{webpack/generate-manifest-plugin.js → build/generate-manifest.ts} +29 -36
- package/build/globals.test.ts +99 -0
- package/build/globals.ts +88 -0
- package/{webpack/heading-subtitle-plugin.js → build/heading-subtitle-plugin.ts} +4 -2
- package/build/json-schema.test.ts +57 -0
- package/build/json-schema.ts +33 -0
- package/build/log.test.ts +111 -0
- package/build/log.ts +167 -0
- package/build/markdown-plugins.test.ts +396 -0
- package/{webpack/pagefind-plugin.test.js → build/pagefind.test.ts} +74 -13
- package/build/pagefind.ts +339 -0
- package/build/pdf-text.test.ts +52 -0
- package/{webpack/pdf-text.js → build/pdf-text.ts} +47 -27
- package/build/pipeline.ts +120 -0
- package/{webpack/reachability.test.js → build/reachability.test.ts} +3 -3
- package/{webpack/reachability.js → build/reachability.ts} +77 -34
- package/build/serve.ts +112 -0
- package/build/site-variables.ts +68 -0
- package/{webpack/templates.js → build/templates.ts} +38 -35
- package/{webpack/text-to-id.js → build/text-to-id.ts} +2 -2
- package/build/toc-plugin.test.ts +105 -0
- package/{webpack/toc-plugin.js → build/toc-plugin.ts} +32 -13
- package/build/types.ts +181 -0
- package/build/util.ts +26 -0
- package/{webpack/utils/code.js → build/utils/code.ts} +119 -60
- package/{webpack/utils/content-files.js → build/utils/content-files.ts} +40 -35
- package/build/utils/derive-theme.test.ts +111 -0
- package/build/utils/derive-theme.ts +85 -0
- package/build/utils/file-types.test.ts +61 -0
- package/build/utils/file-types.ts +13 -0
- package/build/utils/front-matter.test.ts +77 -0
- package/{webpack/utils/front-matter.js → build/utils/front-matter.ts} +22 -9
- package/{webpack → build}/utils/jdi-runner/LiterateRunner.java +1 -1
- package/{webpack/utils/literate-java.js → build/utils/literate-java.ts} +68 -38
- package/{webpack/utils/markdown.js → build/utils/markdown.ts} +126 -49
- package/build/utils/paths.test.ts +91 -0
- package/{webpack/utils/paths.js → build/utils/paths.ts} +18 -22
- package/{webpack/utils/render.js → build/utils/render.ts} +208 -130
- package/build/utils/shiki-highlighter.ts +29 -0
- package/build/validate-internal-links-plugin.test.ts +106 -0
- package/{webpack/validate-internal-links-plugin.js → build/validate-internal-links-plugin.ts} +47 -20
- package/{webpack/watch-reachability-state.test.js → build/watch-reachability-state.test.ts} +8 -8
- package/{webpack/watch-reachability-state.js → build/watch-reachability-state.ts} +63 -24
- package/{webpack/watch-reload-client.js → build/watch-reload-client.ts} +3 -1
- package/build/watch.ts +549 -0
- package/fonts/google-sans-code/woff2/GoogleSansCodeVariable-Italic.woff2 +0 -0
- package/fonts/google-sans-code/woff2/GoogleSansCodeVariable.woff2 +0 -0
- package/fonts/inter/woff2/InterVariable-Italic.woff2 +0 -0
- package/fonts/inter/woff2/InterVariable.woff2 +0 -0
- package/{content → init/content}/index.md +9 -3
- package/{content → init/content}/lectures/index.md +1 -1
- package/{content → init/content}/markdown.md +28 -8
- package/init/content/problem_sets/index.html +14 -0
- package/package.json +33 -22
- package/{templates → schema}/nav.schema.json +1 -2
- package/{webpack → schema}/site.schema.json +4 -12
- package/src/_alerts.scss +96 -0
- package/src/_base.scss +145 -0
- package/src/{layout.scss → _layout.scss} +36 -1
- package/src/anchor/index.ts +0 -1
- package/src/anchor/style.scss +1 -9
- package/src/code/index.ts +3 -3
- package/src/code.scss +1 -1
- package/src/critical.scss +5 -0
- package/src/header/_base.scss +129 -0
- package/src/header/style.scss +3 -131
- package/src/index.ts +4 -5
- package/src/question/style.scss +1 -1
- package/src/search/index.test.ts +203 -0
- package/src/search/index.ts +38 -93
- package/src/search/pdf-utils.ts +79 -0
- package/src/search/style.scss +8 -28
- package/src/style.scss +15 -326
- package/src/timezone/index.ts +5 -6
- package/src/toc/style.scss +5 -39
- package/src/util.ts +9 -6
- package/templates/_theme.scss +38 -14
- package/templates/_top.html +1 -1
- package/tsconfig.json +10 -6
- package/types/dev.d.ts +1 -0
- package/types/file-system-access.d.ts +5 -0
- package/types/markdown-it-plugins.d.ts +11 -0
- package/types/site-variables.d.ts +6 -10
- package/types/untyped-modules.d.ts +47 -0
- package/bin/tada.js +0 -361
- package/config/authors.json +0 -1
- package/config/nav.json +0 -28
- package/content/problem_sets/index.md +0 -6
- package/types/dev.ts +0 -3
- package/webpack/build-state.js +0 -97
- package/webpack/colors.js +0 -15
- package/webpack/config.base.js +0 -151
- package/webpack/config.dev.js +0 -23
- package/webpack/config.prod.js +0 -32
- package/webpack/content-watch-plugin.js +0 -153
- package/webpack/features.js +0 -5
- package/webpack/generate-content-assets-plugin.js +0 -308
- package/webpack/generate-favicon-plugin.js +0 -198
- package/webpack/generate-fonts-plugin.js +0 -69
- package/webpack/globals.js +0 -74
- package/webpack/json-schema.js +0 -19
- package/webpack/log.js +0 -143
- package/webpack/markdown-plugins.test.js +0 -203
- package/webpack/pagefind-plugin.js +0 -379
- package/webpack/print-flair-plugin.js +0 -22
- package/webpack/serve.js +0 -104
- package/webpack/site-variables.js +0 -53
- package/webpack/util.js +0 -49
- package/webpack/utils/define-plugin.js +0 -20
- package/webpack/utils/file-types.js +0 -26
- package/webpack/utils/parse-hsl.js +0 -8
- package/webpack/utils/shiki-highlighter.js +0 -26
- package/webpack/watch.js +0 -166
- /package/{webpack → build}/flair.json +0 -0
- /package/{webpack → build}/utils/jdi-runner/LiterateRunner.class +0 -0
- /package/fonts/google-sans-code/{GoogleSansCodeVariable-Italic.ttf → ttf/GoogleSansCodeVariable-Italic.ttf} +0 -0
- /package/fonts/google-sans-code/{GoogleSansCodeVariable.ttf → ttf/GoogleSansCodeVariable.ttf} +0 -0
- /package/fonts/inter/{InterVariable-Italic.ttf → ttf/InterVariable-Italic.ttf} +0 -0
- /package/fonts/inter/{InterVariable.ttf → ttf/InterVariable.ttf} +0 -0
- /package/{content → init/content}/lectures/01/Pair.java.md +0 -0
- /package/{content → init/content}/lectures/01/Rectangle.java +0 -0
- /package/{content → init/content}/lectures/01/demo.py +0 -0
- /package/{content → init/content}/lectures/01/index.md +0 -0
- /package/{content → init/content}/lectures/01/lecture1.pdf +0 -0
- /package/{public → init/public}/avatars/alex.jpg +0 -0
- /package/{public → init/public}/test.txt +0 -0
- /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
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import _ from 'lodash';
|
|
5
|
+
import * as sass from 'sass';
|
|
6
|
+
import { getPackageDir, getProjectDir, getDistDir } from './utils/paths';
|
|
7
|
+
import { deriveTheme } from './utils/derive-theme';
|
|
8
|
+
import type { PluginBuilder } from 'bun';
|
|
9
|
+
import type { SiteVariables } from './types';
|
|
10
|
+
import timezones from '../src/timezone/timezones.json' with { type: 'json' };
|
|
11
|
+
|
|
12
|
+
function renderThemeScss(siteVariables: SiteVariables): string {
|
|
13
|
+
const templatePath = path.join(getPackageDir(), 'templates/_theme.scss');
|
|
14
|
+
const template = fs.readFileSync(templatePath, 'utf-8');
|
|
15
|
+
const theme = deriveTheme(siteVariables.themeColor);
|
|
16
|
+
const tintHue = siteVariables.tintHue ?? 20;
|
|
17
|
+
const tintAmount = siteVariables.tintAmount ?? 100;
|
|
18
|
+
|
|
19
|
+
const iconColor = `hsl(${tintHue}deg ${(8 * tintAmount) / 100}% 8%)`;
|
|
20
|
+
const iconColorHover = `hsl(${tintHue}deg ${(6 * tintAmount) / 100}% 45%)`;
|
|
21
|
+
const iconColorDark = `hsl(${tintHue}deg ${(20 * tintAmount) / 100}% 90%)`;
|
|
22
|
+
const iconColorHoverDark = `hsl(${tintHue}deg ${(6 * tintAmount) / 100}% 60%)`;
|
|
23
|
+
const iconColorTranslucentDark = `hsl(${tintHue}deg ${(85 * tintAmount) / 100}% 90%)`;
|
|
24
|
+
|
|
25
|
+
const rendered = _.template(template)({
|
|
26
|
+
...theme,
|
|
27
|
+
tintHue,
|
|
28
|
+
tintAmount,
|
|
29
|
+
iconColor,
|
|
30
|
+
iconColorHover,
|
|
31
|
+
iconColorDark,
|
|
32
|
+
iconColorHoverDark,
|
|
33
|
+
iconColorTranslucentDark,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-'));
|
|
37
|
+
const configDir = path.join(tmpDir, 'config');
|
|
38
|
+
fs.mkdirSync(configDir);
|
|
39
|
+
fs.writeFileSync(path.join(configDir, '_theme.scss'), rendered);
|
|
40
|
+
|
|
41
|
+
return tmpDir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createDefine(
|
|
45
|
+
siteVariables: SiteVariables,
|
|
46
|
+
isDev = false,
|
|
47
|
+
): Record<string, string> {
|
|
48
|
+
return {
|
|
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),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createScssPlugin(siteVariables: SiteVariables) {
|
|
59
|
+
const themeDir = renderThemeScss(siteVariables);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name: 'scss',
|
|
63
|
+
setup(build: PluginBuilder) {
|
|
64
|
+
build.onLoad({ filter: /\.scss$/ }, args => {
|
|
65
|
+
const result = sass.compile(args.path, {
|
|
66
|
+
loadPaths: [themeDir, getProjectDir()],
|
|
67
|
+
});
|
|
68
|
+
return { contents: result.css, loader: 'css' as const };
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function bundle(
|
|
75
|
+
siteVariables: SiteVariables,
|
|
76
|
+
{ mode = 'development', distDir }: { mode?: string; distDir?: string } = {},
|
|
77
|
+
): Promise<string[]> {
|
|
78
|
+
const packageDir = getPackageDir();
|
|
79
|
+
const resolvedDistDir = distDir ?? getDistDir();
|
|
80
|
+
const isDev = mode === 'development';
|
|
81
|
+
|
|
82
|
+
const entrypoints = [
|
|
83
|
+
path.resolve(packageDir, 'src/index.ts'),
|
|
84
|
+
path.resolve(packageDir, 'src/critical.scss'),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const result = await Bun.build({
|
|
88
|
+
entrypoints,
|
|
89
|
+
outdir: resolvedDistDir,
|
|
90
|
+
naming: '[name].bundle.[ext]',
|
|
91
|
+
minify: mode === 'production',
|
|
92
|
+
sourcemap: isDev ? 'inline' : 'none',
|
|
93
|
+
define: createDefine(siteVariables, isDev),
|
|
94
|
+
external: ['*.woff2'],
|
|
95
|
+
plugins: [createScssPlugin(siteVariables)],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!result.success) {
|
|
99
|
+
const messages = result.logs
|
|
100
|
+
.filter(log => log.level === 'error')
|
|
101
|
+
.map(log => log.message || String(log));
|
|
102
|
+
throw new Error(`Bundle failed:\n${messages.join('\n')}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Return the output filenames for asset tag injection
|
|
106
|
+
const assetFiles = result.outputs.map(output =>
|
|
107
|
+
path
|
|
108
|
+
.relative(resolvedDistDir, output.path)
|
|
109
|
+
.split(path.sep)
|
|
110
|
+
.join(path.posix.sep),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return assetFiles;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { renderThemeScss, createDefine };
|
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
extractJavaMethodToc,
|
|
6
|
-
} = require('./utils/code');
|
|
1
|
+
import { describe, expect, test, beforeAll } from 'bun:test';
|
|
2
|
+
import { initHighlighter } from './utils/shiki-highlighter';
|
|
3
|
+
import { renderCodeWithComments, extractJavaMethodToc } from './utils/code';
|
|
4
|
+
import type { SiteVariables } from './types';
|
|
7
5
|
|
|
8
6
|
beforeAll(async () => {
|
|
9
7
|
await initHighlighter(['java', 'text', 'plaintext']);
|
|
@@ -148,9 +146,14 @@ public interface Greeter {
|
|
|
148
146
|
describe('renderCodeWithComments', () => {
|
|
149
147
|
test('renders build-time line rows for code segments', () => {
|
|
150
148
|
const html = renderCodeWithComments('alpha\n\nbeta\n', 'java', {
|
|
149
|
+
base: '',
|
|
151
150
|
basePath: '/',
|
|
152
151
|
internalDomains: [],
|
|
153
|
-
|
|
152
|
+
title: 'Test',
|
|
153
|
+
titlePostfix: ' - Test',
|
|
154
|
+
themeColor: 'steelblue',
|
|
155
|
+
defaultTimeZone: 'America/New_York',
|
|
156
|
+
} as SiteVariables);
|
|
154
157
|
|
|
155
158
|
expect(html).toContain('<span class="code-row">');
|
|
156
159
|
expect(html).toContain('id="L1" href="#L1"');
|
package/build/colors.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import util from 'util';
|
|
2
|
+
|
|
3
|
+
export const R = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
4
|
+
util.styleText(['red'], String.raw(str, ...args));
|
|
5
|
+
export const G = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
6
|
+
util.styleText(['green'], String.raw(str, ...args));
|
|
7
|
+
export const B = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
8
|
+
util.styleText(['blue'], String.raw(str, ...args));
|
|
9
|
+
export const Y = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
10
|
+
util.styleText(['yellow'], String.raw(str, ...args));
|
|
11
|
+
export const L = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
12
|
+
util.styleText(['blackBright'], String.raw(str, ...args));
|
|
13
|
+
export const P = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
14
|
+
util.styleText(['magenta'], String.raw(str, ...args));
|
|
15
|
+
export const I = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
16
|
+
util.styleText(['italic', 'bold'], String.raw(str, ...args));
|
|
17
|
+
|
|
18
|
+
export const Ri = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
19
|
+
util.styleText(['inverse', 'red'], String.raw(str, ...args));
|
|
20
|
+
export const Gi = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
21
|
+
util.styleText(['inverse', 'green'], String.raw(str, ...args));
|
|
22
|
+
export const Yi = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
23
|
+
util.styleText(['inverse', 'yellow'], String.raw(str, ...args));
|
|
24
|
+
export const Li = (str: TemplateStringsArray, ...args: unknown[]): string =>
|
|
25
|
+
util.styleText(['inverse', 'blackBright'], String.raw(str, ...args));
|