@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.
- package/README.md +4 -2
- package/bin/tada.ts +81 -57
- package/build/apply-base-path-plugin.ts +22 -6
- package/build/code.test.ts +108 -1
- package/build/colors.test.ts +66 -0
- package/build/columns-plugin.ts +116 -0
- package/build/content-watch.ts +30 -21
- package/build/copy.ts +1 -1
- package/build/external-links-plugin.ts +1 -1
- package/build/generate-content-assets.ts +46 -4
- package/build/generate-favicon.ts +1 -1
- package/build/generate-fonts.ts +1 -1
- package/build/generate-katex-assets.ts +1 -1
- package/build/log.test.ts +7 -1
- package/build/log.ts +4 -1
- package/build/markdown-plugins.test.ts +65 -3
- package/build/pagefind.ts +1 -1
- package/build/pipeline.ts +4 -20
- package/build/serve.test.ts +59 -0
- package/build/serve.ts +29 -63
- package/build/templates.ts +2 -2
- package/build/toc-plugin.test.ts +40 -25
- package/build/toc-plugin.ts +9 -2
- package/build/{types.ts → types.d.ts} +87 -1
- package/build/util.ts +1 -1
- package/build/utils/code.ts +76 -3
- package/build/utils/content-files.test.ts +56 -0
- package/build/utils/content-files.ts +15 -11
- package/build/utils/file-types.test.ts +18 -0
- package/build/utils/file-types.ts +4 -0
- package/build/utils/include.test.ts +117 -0
- package/build/utils/include.ts +53 -0
- package/build/utils/jdi-runner/TraceRunner.java +416 -0
- package/build/utils/literate-java.ts +2 -2
- package/build/utils/markdown.ts +32 -14
- package/build/utils/paths.ts +1 -1
- package/build/utils/render.ts +97 -11
- package/build/utils/shiki-highlighter.ts +1 -1
- package/build/utils/trace-layout.test.ts +570 -0
- package/build/utils/trace-layout.ts +848 -0
- package/build/utils/trace-svg.test.ts +425 -0
- package/build/utils/trace-svg.ts +905 -0
- package/build/utils/trace.test.ts +169 -0
- package/build/utils/trace.ts +334 -0
- package/build/validate-internal-links-plugin.test.ts +30 -2
- package/build/validate-internal-links-plugin.ts +3 -3
- package/build/watch-reload-client.ts +4 -20
- package/build/watch.ts +436 -448
- package/init/content/index.md +10 -5
- package/init/content/labs/00/index.md +1 -1
- package/init/content/labs/01/SearchTreeDemo.java +31 -0
- package/init/content/labs/01/TraceDemo.java +22 -0
- package/init/content/labs/01/index.md +27 -0
- package/init/content/labs/index.md +2 -0
- package/init/content/lectures/01/Rectangle.java +2 -0
- package/init/content/lectures/01/index.md +2 -1
- package/init/content/lectures/01/rectangle.py +45 -0
- package/init/content/lectures/02/_pr1.md +5 -0
- package/init/content/lectures/02/index.md +11 -0
- package/init/content/lectures/02/subdir/_foobar.html +3 -0
- package/init/content/lectures/02/subdir/_pr2.md +6 -0
- package/init/content/lectures/index.md +3 -0
- package/init/content/markdown.md +30 -0
- package/package.json +20 -3
- package/src/_base.scss +13 -2
- package/src/_layout.scss +6 -5
- package/src/anchor/index.test.ts +64 -0
- package/src/code/index.test.ts +71 -0
- package/src/code/style.scss +23 -0
- package/src/code.scss +5 -3
- package/src/header/_base.scss +48 -9
- package/src/header/index.test.ts +89 -0
- package/src/header/index.ts +1 -1
- package/src/header/style.scss +1 -5
- package/src/index.ts +18 -20
- package/src/literate/style.scss +15 -7
- package/src/navigate/eligible.test.ts +101 -0
- package/src/navigate/eligible.ts +62 -0
- package/src/navigate/index.test.ts +82 -0
- package/src/navigate/index.ts +365 -0
- package/src/navigate/lifecycle.test.ts +26 -0
- package/src/navigate/lifecycle.ts +65 -0
- package/src/navigate/style.scss +61 -0
- package/src/print/index.test.ts +71 -0
- package/src/question/index.test.ts +82 -0
- package/src/question/style.scss +12 -0
- package/src/search/index.ts +38 -14
- package/src/search/style.scss +23 -14
- package/src/style.scss +25 -12
- package/src/timezone/index.test.ts +301 -1
- package/src/timezone/index.ts +68 -56
- package/src/toc/index.test.ts +237 -0
- package/src/toc/index.ts +29 -69
- package/src/toc/style.scss +17 -0
- package/src/top/index.test.ts +46 -0
- package/src/top/index.ts +2 -2
- package/src/trace/index.test.ts +528 -0
- package/src/trace/index.ts +250 -0
- package/src/trace/style.scss +210 -0
- package/src/trace/types.d.ts +47 -0
- package/src/util.test.ts +135 -0
- package/templates/_top.html +23 -22
- 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('
|
|
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(
|
|
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.
|
|
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
|
-
'
|
|
7
|
-
'
|
|
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('
|
|
24
|
+
header.classList.add('loading');
|
|
41
25
|
}
|
|
42
|
-
document.body.classList.add('
|
|
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();
|