@abreen/tada 1.4.1 → 1.5.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.
Files changed (103) hide show
  1. package/README.md +4 -2
  2. package/bin/tada.ts +81 -57
  3. package/build/apply-base-path-plugin.ts +22 -6
  4. package/build/code.test.ts +108 -1
  5. package/build/colors.test.ts +66 -0
  6. package/build/columns-plugin.ts +116 -0
  7. package/build/content-watch.ts +30 -21
  8. package/build/copy.ts +1 -1
  9. package/build/external-links-plugin.ts +1 -1
  10. package/build/generate-content-assets.ts +46 -4
  11. package/build/generate-favicon.ts +1 -1
  12. package/build/generate-fonts.ts +1 -1
  13. package/build/generate-katex-assets.ts +1 -1
  14. package/build/log.test.ts +7 -1
  15. package/build/log.ts +4 -1
  16. package/build/markdown-plugins.test.ts +65 -3
  17. package/build/pagefind.ts +1 -1
  18. package/build/pipeline.ts +4 -20
  19. package/build/serve.test.ts +59 -0
  20. package/build/serve.ts +29 -63
  21. package/build/templates.ts +2 -2
  22. package/build/toc-plugin.test.ts +40 -25
  23. package/build/toc-plugin.ts +9 -2
  24. package/build/{types.ts → types.d.ts} +87 -1
  25. package/build/util.ts +1 -1
  26. package/build/utils/code.ts +76 -3
  27. package/build/utils/content-files.test.ts +56 -0
  28. package/build/utils/content-files.ts +15 -11
  29. package/build/utils/file-types.test.ts +18 -0
  30. package/build/utils/file-types.ts +4 -0
  31. package/build/utils/include.test.ts +117 -0
  32. package/build/utils/include.ts +53 -0
  33. package/build/utils/jdi-runner/TraceRunner.java +416 -0
  34. package/build/utils/literate-java.ts +2 -2
  35. package/build/utils/markdown.ts +32 -14
  36. package/build/utils/paths.ts +1 -1
  37. package/build/utils/render.ts +97 -11
  38. package/build/utils/shiki-highlighter.ts +1 -1
  39. package/build/utils/trace-layout.test.ts +570 -0
  40. package/build/utils/trace-layout.ts +848 -0
  41. package/build/utils/trace-svg.test.ts +425 -0
  42. package/build/utils/trace-svg.ts +905 -0
  43. package/build/utils/trace.test.ts +169 -0
  44. package/build/utils/trace.ts +334 -0
  45. package/build/validate-internal-links-plugin.test.ts +30 -2
  46. package/build/validate-internal-links-plugin.ts +3 -3
  47. package/build/watch-reload-client.ts +4 -20
  48. package/build/watch.ts +436 -448
  49. package/init/content/index.md +10 -5
  50. package/init/content/labs/00/index.md +1 -1
  51. package/init/content/labs/01/SearchTreeDemo.java +31 -0
  52. package/init/content/labs/01/TraceDemo.java +22 -0
  53. package/init/content/labs/01/index.md +27 -0
  54. package/init/content/labs/index.md +2 -0
  55. package/init/content/lectures/01/Rectangle.java +2 -0
  56. package/init/content/lectures/01/index.md +2 -1
  57. package/init/content/lectures/01/rectangle.py +45 -0
  58. package/init/content/lectures/02/_pr1.md +5 -0
  59. package/init/content/lectures/02/index.md +11 -0
  60. package/init/content/lectures/02/subdir/_foobar.html +3 -0
  61. package/init/content/lectures/02/subdir/_pr2.md +6 -0
  62. package/init/content/lectures/index.md +3 -0
  63. package/init/content/markdown.md +30 -0
  64. package/package.json +20 -3
  65. package/src/_base.scss +13 -2
  66. package/src/_layout.scss +6 -5
  67. package/src/anchor/index.test.ts +64 -0
  68. package/src/code/index.test.ts +71 -0
  69. package/src/code/style.scss +23 -0
  70. package/src/code.scss +5 -3
  71. package/src/header/_base.scss +48 -9
  72. package/src/header/index.test.ts +89 -0
  73. package/src/header/index.ts +1 -1
  74. package/src/header/style.scss +1 -5
  75. package/src/index.ts +18 -20
  76. package/src/literate/style.scss +15 -7
  77. package/src/navigate/eligible.test.ts +101 -0
  78. package/src/navigate/eligible.ts +62 -0
  79. package/src/navigate/index.test.ts +82 -0
  80. package/src/navigate/index.ts +365 -0
  81. package/src/navigate/lifecycle.test.ts +26 -0
  82. package/src/navigate/lifecycle.ts +65 -0
  83. package/src/navigate/style.scss +61 -0
  84. package/src/print/index.test.ts +71 -0
  85. package/src/question/index.test.ts +82 -0
  86. package/src/question/style.scss +12 -0
  87. package/src/search/index.ts +38 -14
  88. package/src/search/style.scss +23 -14
  89. package/src/style.scss +25 -12
  90. package/src/timezone/index.test.ts +301 -1
  91. package/src/timezone/index.ts +68 -56
  92. package/src/toc/index.test.ts +237 -0
  93. package/src/toc/index.ts +29 -69
  94. package/src/toc/style.scss +17 -0
  95. package/src/top/index.test.ts +46 -0
  96. package/src/top/index.ts +2 -2
  97. package/src/trace/index.test.ts +528 -0
  98. package/src/trace/index.ts +250 -0
  99. package/src/trace/style.scss +210 -0
  100. package/src/trace/types.d.ts +47 -0
  101. package/src/util.test.ts +135 -0
  102. package/templates/_top.html +23 -22
  103. package/types/jsdom.d.ts +9 -0
@@ -0,0 +1,169 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { chunkTraceOutput, parseIgnoreFields } from './trace';
6
+ import type { TraceManifest } from '../types';
7
+
8
+ describe('chunkTraceOutput', () => {
9
+ let tempDir: string;
10
+
11
+ beforeEach(() => {
12
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-trace-test-'));
13
+ });
14
+
15
+ afterEach(() => {
16
+ fs.rmSync(tempDir, { recursive: true, force: true });
17
+ });
18
+
19
+ test('chunks JSONL output into files and writes manifest', () => {
20
+ const lines = [];
21
+ for (let i = 0; i < 5; i++) {
22
+ lines.push(
23
+ JSON.stringify({
24
+ line: i + 1,
25
+ file: 'Test.java',
26
+ stack: [{ method: 'main', class: 'Test', locals: {} }],
27
+ heap: {},
28
+ stdout: '',
29
+ }),
30
+ );
31
+ }
32
+ const output = lines.join('\n') + '\n';
33
+ const outputDir = path.join(tempDir, '_traces', 'Test');
34
+
35
+ const manifest = chunkTraceOutput(
36
+ output,
37
+ outputDir,
38
+ 'Test.java',
39
+ 'public class Test {}',
40
+ 3,
41
+ );
42
+
43
+ // Should create 2 chunks (3 + 2)
44
+ const chunk0 = JSON.parse(
45
+ fs.readFileSync(path.join(outputDir, 'chunk-0.json'), 'utf-8'),
46
+ );
47
+ const chunk1 = JSON.parse(
48
+ fs.readFileSync(path.join(outputDir, 'chunk-1.json'), 'utf-8'),
49
+ );
50
+ expect(chunk0).toHaveLength(3);
51
+ expect(chunk1).toHaveLength(2);
52
+
53
+ // Chunks now contain TraceChunkEntry objects with precomputed SVG
54
+ expect(chunk0[0].svg).toContain('<svg');
55
+ expect(chunk0[0]).toHaveProperty('line');
56
+ expect(chunk0[0]).toHaveProperty('stdout');
57
+
58
+ // Manifest (returned value and file on disk should both be correct)
59
+ const manifestFile = JSON.parse(
60
+ fs.readFileSync(path.join(outputDir, 'manifest.json'), 'utf-8'),
61
+ ) as TraceManifest;
62
+ expect(manifest.totalSteps).toBe(5);
63
+ expect(manifest.chunkSize).toBe(3);
64
+ expect(manifestFile.sourceFile).toBe('Test.java');
65
+ expect(manifestFile.source).toBe('public class Test {}');
66
+ expect(manifest.lineToSteps[1]).toEqual([0]);
67
+ expect(manifest.lineToSteps[5]).toEqual([4]);
68
+ });
69
+
70
+ test('handles single chunk', () => {
71
+ const line = JSON.stringify({
72
+ line: 1,
73
+ file: 'Test.java',
74
+ stack: [],
75
+ heap: {},
76
+ stdout: 'hello\n',
77
+ });
78
+ const outputDir = path.join(tempDir, '_traces', 'Test');
79
+
80
+ const manifest = chunkTraceOutput(
81
+ line,
82
+ outputDir,
83
+ 'Test.java',
84
+ 'class Test {}',
85
+ 50,
86
+ );
87
+
88
+ expect(manifest.totalSteps).toBe(1);
89
+ const chunk0 = JSON.parse(
90
+ fs.readFileSync(path.join(outputDir, 'chunk-0.json'), 'utf-8'),
91
+ );
92
+ expect(chunk0).toHaveLength(1);
93
+ expect(chunk0[0].stdout).toBe('hello\n');
94
+ });
95
+
96
+ test('passes ignoreFields from source to layout', () => {
97
+ const source = `class Node {
98
+ int value;
99
+ Node left;
100
+ Node right;
101
+ Node parent; // @trace-ignore
102
+ }
103
+ public class Test {
104
+ public static void main(String[] args) {}
105
+ }`;
106
+ const fields = parseIgnoreFields(source);
107
+ expect(fields).toEqual({ Node: ['parent'] });
108
+ });
109
+
110
+ test('parseIgnoreFields handles multiple classes', () => {
111
+ const source = `class A {
112
+ A prev; // @trace-ignore
113
+ A next;
114
+ }
115
+ class B {
116
+ B up; // @trace-ignore
117
+ B down; // @trace-ignore
118
+ }`;
119
+ const fields = parseIgnoreFields(source);
120
+ expect(fields).toEqual({ A: ['prev'], B: ['up', 'down'] });
121
+ });
122
+
123
+ test('parseIgnoreFields handles inner classes with $ separator', () => {
124
+ const source = `public class SearchTreeDemo {
125
+ static class Node {
126
+ String data;
127
+ Node left;
128
+ Node right;
129
+ Node parent; // @trace-ignore
130
+ }
131
+
132
+ public static void main(String[] args) {}
133
+ }`;
134
+ const fields = parseIgnoreFields(source);
135
+ expect(fields).toEqual({ SearchTreeDemo$Node: ['parent'] });
136
+ });
137
+
138
+ test('parseIgnoreFields returns empty for no annotations', () => {
139
+ const source = `class Node { int x; }`;
140
+ expect(parseIgnoreFields(source)).toEqual({});
141
+ });
142
+
143
+ test('maps repeated lines to multiple step indices', () => {
144
+ const lines = [];
145
+ // Simulate a loop: line 3 executes at steps 0, 1, 2
146
+ for (let i = 0; i < 3; i++) {
147
+ lines.push(
148
+ JSON.stringify({
149
+ line: 3,
150
+ file: 'Test.java',
151
+ stack: [],
152
+ heap: {},
153
+ stdout: '',
154
+ }),
155
+ );
156
+ }
157
+ const outputDir = path.join(tempDir, '_traces', 'Test');
158
+
159
+ const manifest = chunkTraceOutput(
160
+ lines.join('\n'),
161
+ outputDir,
162
+ 'Test.java',
163
+ '',
164
+ 50,
165
+ );
166
+
167
+ expect(manifest.lineToSteps[3]).toEqual([0, 1, 2]);
168
+ });
169
+ });
@@ -0,0 +1,334 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { execFileSync } from 'child_process';
5
+ import { makeLogger } from '../log';
6
+ import { B } from '../colors';
7
+ import { normalizeOutputPath } from './paths';
8
+ import { hasMainMethod } from './literate-java';
9
+ import { renderCodeSegment } from './code';
10
+ import { computeLayout } from './trace-layout';
11
+ import { generateStepSvg } from './trace-svg';
12
+ import type { TraceStep, TraceManifest, TraceChunkEntry } from '../types';
13
+
14
+ const log = makeLogger(import.meta.url);
15
+
16
+ const DEFAULT_CHUNK_SIZE = 50;
17
+
18
+ /**
19
+ * Parse // @trace-ignore comments from Java source code.
20
+ * Returns a map of className -> fieldName[] for fields that should be
21
+ * excluded from layout consideration.
22
+ */
23
+ export function parseIgnoreFields(source: string): Record<string, string[]> {
24
+ const result: Record<string, string[]> = {};
25
+ const lines = source.split('\n');
26
+ // Track nesting: Java inner classes are reported as Outer$Inner by the tracer.
27
+ // Each entry is [className, braceDepthWhenOpened].
28
+ const classStack: { name: string; depth: number }[] = [];
29
+ let braceDepth = 0;
30
+
31
+ for (const line of lines) {
32
+ // Strip strings and comments (except @trace-ignore) to avoid false braces
33
+ const stripped = line
34
+ .replace(/"(?:[^"\\]|\\.)*"/g, '')
35
+ .replace(/\/\/(?!.*@trace-ignore).*/g, '');
36
+
37
+ const classMatch = stripped.match(/\bclass\s+(\w+)/);
38
+ if (classMatch) {
39
+ classStack.push({ name: classMatch[1], depth: braceDepth });
40
+ }
41
+
42
+ for (const ch of stripped) {
43
+ if (ch === '{') {
44
+ braceDepth++;
45
+ }
46
+ if (ch === '}') {
47
+ braceDepth--;
48
+ // Pop classes whose scope has ended
49
+ while (
50
+ classStack.length > 0 &&
51
+ braceDepth <= classStack[classStack.length - 1].depth
52
+ ) {
53
+ classStack.pop();
54
+ }
55
+ }
56
+ }
57
+
58
+ const currentClass =
59
+ classStack.length > 0 ? classStack.map(c => c.name).join('$') : null;
60
+
61
+ if (currentClass && line.includes('// @trace-ignore')) {
62
+ const before = line.split('//')[0].trim();
63
+ const withoutSemicolon = before.endsWith(';')
64
+ ? before.slice(0, -1).trim()
65
+ : before;
66
+ const parts = withoutSemicolon.split(/\s+/);
67
+ if (parts.length >= 2) {
68
+ const fieldName = parts[parts.length - 1];
69
+ if (!result[currentClass]) {
70
+ result[currentClass] = [];
71
+ }
72
+ result[currentClass].push(fieldName);
73
+ }
74
+ }
75
+ }
76
+
77
+ return result;
78
+ }
79
+
80
+ let tracerClassDir: string | null = null;
81
+
82
+ function ensureTracerCompiled(): string {
83
+ if (tracerClassDir) {
84
+ return tracerClassDir;
85
+ }
86
+
87
+ const sourceDir = path.join(import.meta.dir, 'jdi-runner');
88
+ const sourceFile = path.join(sourceDir, 'TraceRunner.java');
89
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-tracer-'));
90
+
91
+ log.debug`Compiling TraceRunner.java into ${tempDir}`;
92
+
93
+ try {
94
+ execFileSync('javac', ['-d', tempDir, sourceFile], {
95
+ timeout: 30000,
96
+ stdio: ['pipe', 'pipe', 'pipe'],
97
+ });
98
+ } catch (err: unknown) {
99
+ const execErr = err as { stderr?: Buffer; message: string };
100
+ const stderr = execErr.stderr ? execErr.stderr.toString() : execErr.message;
101
+ fs.rmSync(tempDir, { recursive: true, force: true });
102
+ throw new Error(`Failed to compile TraceRunner.java:\n${stderr}`, {
103
+ cause: err,
104
+ });
105
+ }
106
+
107
+ tracerClassDir = tempDir;
108
+ return tempDir;
109
+ }
110
+
111
+ function compileTargetFile(javaFilePath: string): string {
112
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-trace-target-'));
113
+
114
+ try {
115
+ execFileSync(
116
+ 'javac',
117
+ [
118
+ '-g',
119
+ '-d',
120
+ tempDir,
121
+ '-sourcepath',
122
+ path.dirname(javaFilePath),
123
+ javaFilePath,
124
+ ],
125
+ { timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] },
126
+ );
127
+ } catch (err: unknown) {
128
+ const execErr = err as { stderr?: Buffer; message: string };
129
+ const stderr = execErr.stderr ? execErr.stderr.toString() : execErr.message;
130
+ fs.rmSync(tempDir, { recursive: true, force: true });
131
+ throw new Error(`Compilation failed for ${javaFilePath}:\n${stderr}`, {
132
+ cause: err,
133
+ });
134
+ }
135
+
136
+ return tempDir;
137
+ }
138
+
139
+ export function chunkTraceOutput(
140
+ output: string,
141
+ traceOutputDir: string,
142
+ sourceFile: string,
143
+ source: string,
144
+ chunkSize: number = DEFAULT_CHUNK_SIZE,
145
+ ): TraceManifest {
146
+ fs.mkdirSync(traceOutputDir, { recursive: true });
147
+
148
+ const lines = output
149
+ .trim()
150
+ .split('\n')
151
+ .filter(l => l.length > 0);
152
+
153
+ // Parse all steps first (needed for layout pass)
154
+ const allSteps: TraceStep[] = lines.map(l => JSON.parse(l));
155
+
156
+ const lineToSteps: Record<number, number[]> = {};
157
+ for (let i = 0; i < allSteps.length; i++) {
158
+ const step = allSteps[i];
159
+ if (!lineToSteps[step.line]) {
160
+ lineToSteps[step.line] = [];
161
+ }
162
+ lineToSteps[step.line].push(i);
163
+ }
164
+
165
+ // Parse @trace-ignore comments from source
166
+ const ignoreFields = parseIgnoreFields(source);
167
+
168
+ // Compute stable layout across all steps, then generate SVG per step
169
+ const layout = computeLayout(allSteps, ignoreFields);
170
+
171
+ let chunkEntries: TraceChunkEntry[] = [];
172
+ let chunkIndex = 0;
173
+
174
+ for (const step of allSteps) {
175
+ const svg = generateStepSvg(step, layout);
176
+ chunkEntries.push({ line: step.line, stdout: step.stdout, svg });
177
+
178
+ if (chunkEntries.length >= chunkSize) {
179
+ fs.writeFileSync(
180
+ path.join(traceOutputDir, `chunk-${chunkIndex}.json`),
181
+ JSON.stringify(chunkEntries),
182
+ );
183
+ chunkEntries = [];
184
+ chunkIndex++;
185
+ }
186
+ }
187
+
188
+ if (chunkEntries.length > 0) {
189
+ fs.writeFileSync(
190
+ path.join(traceOutputDir, `chunk-${chunkIndex}.json`),
191
+ JSON.stringify(chunkEntries),
192
+ );
193
+ }
194
+
195
+ const manifest: TraceManifest = {
196
+ totalSteps: allSteps.length,
197
+ chunkSize,
198
+ sourceFile,
199
+ source,
200
+ lineToSteps,
201
+ };
202
+
203
+ fs.writeFileSync(
204
+ path.join(traceOutputDir, 'manifest.json'),
205
+ JSON.stringify(manifest),
206
+ );
207
+
208
+ return manifest;
209
+ }
210
+
211
+ export interface TraceContext {
212
+ filePath: string;
213
+ contentDir: string;
214
+ distDir: string;
215
+ applyBasePath: (subPath: string) => string;
216
+ cache: Map<string, TraceResult>;
217
+ }
218
+
219
+ export interface TraceResult {
220
+ manifestUrl: string;
221
+ highlightedSource: string;
222
+ totalSteps: number;
223
+ mtime: number;
224
+ }
225
+
226
+ export function createTraceHelpers(context: TraceContext): {
227
+ renderTrace: (javaFile: string) => string;
228
+ } {
229
+ const { filePath, contentDir, distDir, applyBasePath, cache } = context;
230
+ const pageDir = path.dirname(filePath);
231
+
232
+ function getOrRunTrace(javaFile: string): TraceResult {
233
+ const javaFilePath = path.resolve(pageDir, javaFile);
234
+
235
+ const cached = cache.get(javaFilePath);
236
+ if (cached) {
237
+ const mtime = fs.statSync(javaFilePath, {
238
+ throwIfNoEntry: false,
239
+ })?.mtimeMs;
240
+ if (mtime !== undefined && mtime === cached.mtime) {
241
+ return cached;
242
+ }
243
+ }
244
+
245
+ if (!fs.existsSync(javaFilePath)) {
246
+ throw new Error(`Trace target not found: ${javaFilePath}`);
247
+ }
248
+
249
+ const source = fs.readFileSync(javaFilePath, 'utf-8');
250
+ if (!hasMainMethod(source)) {
251
+ throw new Error(
252
+ `${javaFilePath}: no main() method found (required for tracing)`,
253
+ );
254
+ }
255
+
256
+ const className = path.parse(javaFile).name;
257
+ log.info`Tracing ${B`${javaFile}`}`;
258
+
259
+ const targetClassDir = compileTargetFile(javaFilePath);
260
+
261
+ try {
262
+ const tracerDir = ensureTracerCompiled();
263
+ const output = execFileSync(
264
+ 'java',
265
+ ['-cp', tracerDir, 'TraceRunner', className, targetClassDir],
266
+ { timeout: 60000, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 },
267
+ );
268
+
269
+ const sourceLines = source.split('\n');
270
+ if (sourceLines[sourceLines.length - 1] === '') {
271
+ sourceLines.pop();
272
+ }
273
+ const ext = path.extname(javaFile).slice(1).toLowerCase();
274
+ const lang = ext === 'java' ? 'java' : 'text';
275
+ const highlightedSource = renderCodeSegment(sourceLines, 1, lang, {
276
+ linkLineNumbers: false,
277
+ });
278
+
279
+ const relDir = path
280
+ .relative(contentDir, pageDir)
281
+ .split(path.sep)
282
+ .join(path.posix.sep);
283
+ const traceOutputDir = path.join(distDir, relDir, '_traces', className);
284
+
285
+ const manifest = chunkTraceOutput(
286
+ output,
287
+ traceOutputDir,
288
+ `${className}.java`,
289
+ source,
290
+ DEFAULT_CHUNK_SIZE,
291
+ );
292
+
293
+ const manifestUrl = applyBasePath(
294
+ normalizeOutputPath(
295
+ '/' +
296
+ [relDir, '_traces', className, 'manifest.json']
297
+ .filter(Boolean)
298
+ .join('/'),
299
+ ),
300
+ );
301
+
302
+ const totalSteps = manifest.totalSteps;
303
+ const mtime = fs.statSync(javaFilePath).mtimeMs;
304
+ cache.set(javaFilePath, {
305
+ manifestUrl,
306
+ highlightedSource,
307
+ totalSteps,
308
+ mtime,
309
+ });
310
+ return { manifestUrl, highlightedSource, totalSteps, mtime };
311
+ } finally {
312
+ fs.rmSync(targetClassDir, { recursive: true, force: true });
313
+ }
314
+ }
315
+
316
+ return {
317
+ renderTrace: (javaFile: string): string => {
318
+ const { manifestUrl, highlightedSource, totalSteps } =
319
+ getOrRunTrace(javaFile);
320
+ const svgAttrs = `aria-hidden='true' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'`;
321
+ const iconFirst = `<svg xmlns='http://www.w3.org/2000/svg' ${svgAttrs}><line x1='2' y1='6' x2='2' y2='18'/><polyline points='10 6 4 12 10 18'/><line x1='4' y1='12' x2='22' y2='12'/></svg>`;
322
+ const iconPrev = `<svg xmlns='http://www.w3.org/2000/svg' ${svgAttrs}><polyline points='10 6 4 12 10 18'/><line x1='4' y1='12' x2='22' y2='12'/></svg>`;
323
+ const iconNext = `<svg xmlns='http://www.w3.org/2000/svg' ${svgAttrs}><line x1='2' y1='12' x2='20' y2='12'/><polyline points='14 6 20 12 14 18'/></svg>`;
324
+ const iconLast = `<svg xmlns='http://www.w3.org/2000/svg' ${svgAttrs}><line x1='2' y1='12' x2='20' y2='12'/><polyline points='14 6 20 12 14 18'/><line x1='22' y1='6' x2='22' y2='18'/></svg>`;
325
+ const controls =
326
+ `<button class="trace-btn trace-first" disabled tabindex="-1" aria-label="First step" title="First step">${iconFirst}</button>` +
327
+ `<button class="trace-btn trace-prev" disabled tabindex="-1" aria-label="Previous step" title="Previous step">${iconPrev}</button>` +
328
+ `<span class="trace-step-counter" style="min-width: ${String(totalSteps).length * 2 + 1}ch">1/${totalSteps}</span>` +
329
+ `<button class="trace-btn trace-next" aria-label="Next step" title="Next step">${iconNext}</button>` +
330
+ `<button class="trace-btn trace-last" tabindex="-1" aria-label="Last step" title="Last step">${iconLast}</button>`;
331
+ return `<div class="trace-widget" data-trace-manifest="${manifestUrl}"><noscript><p>This interactive trace requires JavaScript.</p></noscript><div class="trace-body"><div class="trace-toolbar"><div class="trace-controls" role="toolbar" aria-label="Trace navigation">${controls}</div></div><div class="trace-content"><div class="trace-diagram"></div><div class="trace-source-wrapper"><div class="trace-source">${highlightedSource}</div></div><pre class="trace-output"></pre></div></div></div>`;
332
+ },
333
+ };
334
+ }
@@ -80,11 +80,18 @@ describe('validateInternalLinks', () => {
80
80
  );
81
81
  });
82
82
 
83
- test('rewrites code extensions to .html', () => {
84
- const md = createMd(['/src/App.html'], { codeExtensions: ['java'] });
83
+ test('resolves code file links to .ext.html pages', () => {
84
+ const md = createMd(['/src/App.java.html'], { codeExtensions: ['java'] });
85
85
  expect(() => md.render('[App](/src/App.java)')).not.toThrow();
86
86
  });
87
87
 
88
+ test('throws for link missing the code extension', () => {
89
+ const md = createMd(['/src/App.java.html'], { codeExtensions: ['java'] });
90
+ expect(() => md.render('[App](/src/App.html)')).toThrow(
91
+ 'broken internal link',
92
+ );
93
+ });
94
+
88
95
  test('detects directory links that should reference index.html', () => {
89
96
  const md = createMd(['/docs/index.html']);
90
97
  // The plugin logs the "directory link" message and throws a generic
@@ -103,4 +110,25 @@ describe('validateInternalLinks', () => {
103
110
  const md = createMd([]);
104
111
  expect(() => md.render('[CDN](//cdn.example.com/lib.js)')).not.toThrow();
105
112
  });
113
+
114
+ test('throws for .java.html link when code feature is disabled', () => {
115
+ // When features.code is false, codeExtensions is [] and no code pages
116
+ // are generated. Only the raw .java file exists as a valid target.
117
+ const md = createMd(['/src/Rectangle.java'], { codeExtensions: [] });
118
+ expect(() => md.render('[Rect](/src/Rectangle.java.html)')).toThrow(
119
+ 'broken internal link',
120
+ );
121
+ });
122
+
123
+ test('throws for .py.html link when code feature is disabled', () => {
124
+ const md = createMd(['/src/demo.py'], { codeExtensions: [] });
125
+ expect(() => md.render('[Demo](/src/demo.py.html)')).toThrow(
126
+ 'broken internal link',
127
+ );
128
+ });
129
+
130
+ test('allows .java link to raw file when code feature is disabled', () => {
131
+ const md = createMd(['/src/Rectangle.java'], { codeExtensions: [] });
132
+ expect(() => md.render('[Rect](/src/Rectangle.java)')).not.toThrow();
133
+ });
106
134
  });
@@ -3,7 +3,7 @@ import type Token from 'markdown-it/lib/token.mjs';
3
3
  import path from 'path';
4
4
  import { makeLogger } from './log';
5
5
 
6
- const log = makeLogger(__filename);
6
+ const log = makeLogger(import.meta.url);
7
7
 
8
8
  interface ValidateInternalLinksOptions {
9
9
  enabled?: boolean;
@@ -47,11 +47,11 @@ function rewriteCodeLink(
47
47
  pathname: string,
48
48
  codeExtPattern: RegExp | null,
49
49
  ): string {
50
- if (!codeExtPattern) {
50
+ if (!codeExtPattern || !codeExtPattern.test(pathname)) {
51
51
  return pathname;
52
52
  }
53
53
 
54
- return pathname.replace(codeExtPattern, '.html');
54
+ return `${pathname}.html`;
55
55
  }
56
56
 
57
57
  function resolveLinkPath(
@@ -3,24 +3,8 @@ declare const __WEBSOCKET_PORT__: number;
3
3
  (function () {
4
4
  const style = document.createElement('style');
5
5
  style.textContent = [
6
- '@keyframes tada-shimmer {',
7
- ' 0%, 25% { background-position: 200% 0; }',
8
- ' 100% { background-position: -200% 0; }',
9
- '}',
10
- 'header.tada-rebuilding {',
11
- ' background-image: linear-gradient(',
12
- ' 90deg,',
13
- ' transparent 25%,',
14
- ' var(--bg2-color) 50%,',
15
- ' transparent 75%',
16
- ' ) !important;',
17
- ' background-color: var(--bg-color-translucent) !important;',
18
- ' background-size: 200% 100% !important;',
19
- ' background-repeat: no-repeat !important;',
20
- ' animation: tada-shimmer 2s ease-in-out infinite !important;',
21
- '}',
22
- 'body.tada-rebuilding-cursor,',
23
- 'body.tada-rebuilding-cursor * {',
6
+ 'body.loading-cursor,',
7
+ 'body.loading-cursor * {',
24
8
  ' cursor: wait !important;',
25
9
  '}',
26
10
  ].join('\n');
@@ -37,9 +21,9 @@ declare const __WEBSOCKET_PORT__: number;
37
21
  console.log('[watch-reload] Rebuilding...');
38
22
  const header = document.querySelector('header');
39
23
  if (header) {
40
- header.classList.add('tada-rebuilding');
24
+ header.classList.add('loading');
41
25
  }
42
- document.body.classList.add('tada-rebuilding-cursor');
26
+ document.body.classList.add('loading-cursor');
43
27
  } else if (event.data === 'reload') {
44
28
  console.log('[watch-reload] Reloading page...');
45
29
  window.location.reload();