@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,250 @@
|
|
|
1
|
+
import type { TraceManifest, TraceChunkEntry } from './types';
|
|
2
|
+
|
|
3
|
+
interface WidgetState {
|
|
4
|
+
manifest: TraceManifest;
|
|
5
|
+
chunks: Map<number, TraceChunkEntry[]>;
|
|
6
|
+
currentStep: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface WidgetElements {
|
|
10
|
+
root: HTMLElement;
|
|
11
|
+
source: HTMLElement;
|
|
12
|
+
controls: HTMLElement;
|
|
13
|
+
output: HTMLElement;
|
|
14
|
+
diagram: HTMLElement;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getStep(state: WidgetState): TraceChunkEntry {
|
|
18
|
+
const chunkIndex = Math.floor(state.currentStep / state.manifest.chunkSize);
|
|
19
|
+
const offset = state.currentStep % state.manifest.chunkSize;
|
|
20
|
+
return state.chunks.get(chunkIndex)![offset];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function chunkUrlFromManifest(manifestUrl: string, chunkIndex: number): string {
|
|
24
|
+
return manifestUrl.replace('manifest.json', `chunk-${chunkIndex}.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function loadChunk(
|
|
28
|
+
state: WidgetState,
|
|
29
|
+
manifestUrl: string,
|
|
30
|
+
chunkIndex: number,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
if (state.chunks.has(chunkIndex)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const url = chunkUrlFromManifest(manifestUrl, chunkIndex);
|
|
36
|
+
const res = await fetch(url);
|
|
37
|
+
const chunk: TraceChunkEntry[] = await res.json();
|
|
38
|
+
state.chunks.set(chunkIndex, chunk);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function goToStep(
|
|
42
|
+
state: WidgetState,
|
|
43
|
+
elements: WidgetElements,
|
|
44
|
+
manifestUrl: string,
|
|
45
|
+
stepIndex: number,
|
|
46
|
+
doc: Document,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
stepIndex = Math.max(0, Math.min(stepIndex, state.manifest.totalSteps - 1));
|
|
49
|
+
const lastChunk = Math.floor(stepIndex / state.manifest.chunkSize);
|
|
50
|
+
const loads: Promise<void>[] = [];
|
|
51
|
+
for (let i = 0; i <= lastChunk; i++) {
|
|
52
|
+
loads.push(loadChunk(state, manifestUrl, i));
|
|
53
|
+
}
|
|
54
|
+
await Promise.all(loads);
|
|
55
|
+
state.currentStep = stepIndex;
|
|
56
|
+
renderWidgetState(state, elements, doc);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function updateSourceHighlight(panel: HTMLElement, currentLine: number): void {
|
|
60
|
+
panel.querySelectorAll('.line-number').forEach(ln => {
|
|
61
|
+
ln.classList.remove('trace-line-active');
|
|
62
|
+
});
|
|
63
|
+
const el = panel.querySelector(`.line-number[data-line="${currentLine}"]`);
|
|
64
|
+
if (el) {
|
|
65
|
+
el.classList.add('trace-line-active');
|
|
66
|
+
const elRect = el.getBoundingClientRect();
|
|
67
|
+
const panelRect = panel.getBoundingClientRect();
|
|
68
|
+
panel.scrollTop += elRect.top - panelRect.top - panel.clientHeight / 2;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function updateStepControls(
|
|
73
|
+
container: HTMLElement,
|
|
74
|
+
currentStep: number,
|
|
75
|
+
totalSteps: number,
|
|
76
|
+
doc: Document,
|
|
77
|
+
): void {
|
|
78
|
+
const counter = container.querySelector(
|
|
79
|
+
'.trace-step-counter',
|
|
80
|
+
) as HTMLElement | null;
|
|
81
|
+
if (counter) {
|
|
82
|
+
counter.textContent = `${currentStep + 1}/${totalSteps}`;
|
|
83
|
+
const digits = String(totalSteps).length;
|
|
84
|
+
counter.style.minWidth = `${digits * 2 + 1}ch`;
|
|
85
|
+
}
|
|
86
|
+
const first = container.querySelector('.trace-first') as HTMLButtonElement;
|
|
87
|
+
const prev = container.querySelector('.trace-prev') as HTMLButtonElement;
|
|
88
|
+
const next = container.querySelector('.trace-next') as HTMLButtonElement;
|
|
89
|
+
const last = container.querySelector('.trace-last') as HTMLButtonElement;
|
|
90
|
+
const btns = [first, prev, next, last];
|
|
91
|
+
|
|
92
|
+
const focused = doc.activeElement;
|
|
93
|
+
|
|
94
|
+
if (first) {
|
|
95
|
+
first.disabled = currentStep === 0;
|
|
96
|
+
}
|
|
97
|
+
if (prev) {
|
|
98
|
+
prev.disabled = currentStep === 0;
|
|
99
|
+
}
|
|
100
|
+
if (next) {
|
|
101
|
+
next.disabled = currentStep >= totalSteps - 1;
|
|
102
|
+
}
|
|
103
|
+
if (last) {
|
|
104
|
+
last.disabled = currentStep >= totalSteps - 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// When a clicked button becomes disabled, the browser removes focus.
|
|
108
|
+
// Move focus to a sensible sibling so the outline stays visible.
|
|
109
|
+
if (
|
|
110
|
+
focused &&
|
|
111
|
+
'disabled' in focused &&
|
|
112
|
+
(focused as HTMLButtonElement).disabled
|
|
113
|
+
) {
|
|
114
|
+
if (focused === first || focused === prev) {
|
|
115
|
+
next?.focus();
|
|
116
|
+
} else if (focused === next || focused === last) {
|
|
117
|
+
prev?.focus();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Roving tabindex: the focused button is the Tab stop.
|
|
122
|
+
const currentFocus = doc.activeElement;
|
|
123
|
+
const enabled = btns.filter(b => b && !b.disabled);
|
|
124
|
+
const focusedBtn = enabled.find(b => b === currentFocus);
|
|
125
|
+
for (const b of enabled) {
|
|
126
|
+
b.tabIndex = b === focusedBtn ? 0 : -1;
|
|
127
|
+
}
|
|
128
|
+
if (!focusedBtn && enabled.length > 0) {
|
|
129
|
+
enabled[0].tabIndex = 0;
|
|
130
|
+
}
|
|
131
|
+
for (const b of btns) {
|
|
132
|
+
if (b && b.disabled) {
|
|
133
|
+
b.tabIndex = -1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderWidgetState(
|
|
139
|
+
state: WidgetState,
|
|
140
|
+
elements: WidgetElements,
|
|
141
|
+
doc: Document,
|
|
142
|
+
): void {
|
|
143
|
+
const entry = getStep(state);
|
|
144
|
+
updateSourceHighlight(elements.source, entry.line);
|
|
145
|
+
updateStepControls(
|
|
146
|
+
elements.controls,
|
|
147
|
+
state.currentStep,
|
|
148
|
+
state.manifest.totalSteps,
|
|
149
|
+
doc,
|
|
150
|
+
);
|
|
151
|
+
let output = '';
|
|
152
|
+
for (let i = 0; i <= state.currentStep; i++) {
|
|
153
|
+
const ci = Math.floor(i / state.manifest.chunkSize);
|
|
154
|
+
const off = i % state.manifest.chunkSize;
|
|
155
|
+
const s = state.chunks.get(ci)![off];
|
|
156
|
+
if (s.stdout) {
|
|
157
|
+
output += s.stdout;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
elements.output.textContent = output;
|
|
161
|
+
|
|
162
|
+
const prevHeight = elements.diagram.scrollHeight;
|
|
163
|
+
elements.diagram.innerHTML = entry.svg;
|
|
164
|
+
|
|
165
|
+
// If the diagram grew past the visible area, scroll to show the new content
|
|
166
|
+
if (elements.diagram.scrollHeight > prevHeight) {
|
|
167
|
+
elements.diagram.scrollTop =
|
|
168
|
+
elements.diagram.scrollHeight - elements.diagram.clientHeight;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function initWidget(root: HTMLElement, doc: Document): Promise<void> {
|
|
173
|
+
const manifestUrl = root.dataset.traceManifest;
|
|
174
|
+
if (!manifestUrl) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const res = await fetch(manifestUrl);
|
|
179
|
+
const manifest: TraceManifest = await res.json();
|
|
180
|
+
|
|
181
|
+
const state: WidgetState = { manifest, chunks: new Map(), currentStep: 0 };
|
|
182
|
+
|
|
183
|
+
await loadChunk(state, manifestUrl, 0);
|
|
184
|
+
|
|
185
|
+
const entry = getStep(state);
|
|
186
|
+
|
|
187
|
+
const source = root.querySelector('.trace-source') as HTMLElement;
|
|
188
|
+
const diagram = root.querySelector('.trace-diagram') as HTMLElement;
|
|
189
|
+
const output = root.querySelector('.trace-output') as HTMLPreElement;
|
|
190
|
+
output.textContent = entry.stdout || '';
|
|
191
|
+
|
|
192
|
+
const controls = root.querySelector('.trace-controls') as HTMLElement;
|
|
193
|
+
const firstBtn = controls.querySelector('.trace-first') as HTMLButtonElement;
|
|
194
|
+
const prevBtn = controls.querySelector('.trace-prev') as HTMLButtonElement;
|
|
195
|
+
const nextBtn = controls.querySelector('.trace-next') as HTMLButtonElement;
|
|
196
|
+
const lastBtn = controls.querySelector('.trace-last') as HTMLButtonElement;
|
|
197
|
+
firstBtn.addEventListener('click', () =>
|
|
198
|
+
goToStep(state, elements, manifestUrl, 0, doc),
|
|
199
|
+
);
|
|
200
|
+
prevBtn.addEventListener('click', () =>
|
|
201
|
+
goToStep(state, elements, manifestUrl, state.currentStep - 1, doc),
|
|
202
|
+
);
|
|
203
|
+
nextBtn.addEventListener('click', () =>
|
|
204
|
+
goToStep(state, elements, manifestUrl, state.currentStep + 1, doc),
|
|
205
|
+
);
|
|
206
|
+
lastBtn.addEventListener('click', () =>
|
|
207
|
+
goToStep(state, elements, manifestUrl, manifest.totalSteps - 1, doc),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const btns = [firstBtn, prevBtn, nextBtn, lastBtn];
|
|
211
|
+
controls.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
212
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const enabled = btns.filter(b => !b.disabled);
|
|
216
|
+
const idx = enabled.indexOf(doc.activeElement as HTMLButtonElement);
|
|
217
|
+
if (idx < 0) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
e.preventDefault();
|
|
221
|
+
const next =
|
|
222
|
+
e.key === 'ArrowRight'
|
|
223
|
+
? enabled[(idx + 1) % enabled.length]
|
|
224
|
+
: enabled[(idx - 1 + enabled.length) % enabled.length];
|
|
225
|
+
for (const b of enabled) {
|
|
226
|
+
b.tabIndex = -1;
|
|
227
|
+
}
|
|
228
|
+
next.tabIndex = 0;
|
|
229
|
+
next.focus();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const elements: WidgetElements = { root, source, controls, output, diagram };
|
|
233
|
+
|
|
234
|
+
updateStepControls(controls, state.currentStep, manifest.totalSteps, doc);
|
|
235
|
+
|
|
236
|
+
diagram.innerHTML = entry.svg;
|
|
237
|
+
updateSourceHighlight(source, entry.line);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export default function mountTrace(window: Window): void {
|
|
241
|
+
const widgets = window.document.querySelectorAll('.trace-widget');
|
|
242
|
+
|
|
243
|
+
if (widgets.length === 0) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const widget of widgets) {
|
|
248
|
+
initWidget(widget as HTMLElement, window.document);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
.trace-body {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
height: calc(100dvh - var(--header-collapsed-height) - 3 * var(--gap));
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
gap: var(--gap);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.trace-content {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
flex: 1;
|
|
13
|
+
min-height: 0;
|
|
14
|
+
gap: var(--gap);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.trace-content > .trace-diagram {
|
|
18
|
+
flex: 1 1 100%;
|
|
19
|
+
min-height: 8rem;
|
|
20
|
+
border: 1px solid var(--fg2-color);
|
|
21
|
+
border-radius: var(--border-radius);
|
|
22
|
+
overflow: auto;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.trace-source-wrapper {
|
|
26
|
+
min-height: calc(
|
|
27
|
+
3 * var(--font-size-smaller) * var(--mono-line-height) + 1em
|
|
28
|
+
);
|
|
29
|
+
max-height: calc(
|
|
30
|
+
5 * var(--font-size-smaller) * var(--mono-line-height) + 1em
|
|
31
|
+
);
|
|
32
|
+
position: relative;
|
|
33
|
+
|
|
34
|
+
&:has(:focus-visible)::after {
|
|
35
|
+
content: '';
|
|
36
|
+
position: absolute;
|
|
37
|
+
inset: 0;
|
|
38
|
+
border: 2px solid var(--theme-color);
|
|
39
|
+
border-radius: var(--mono-border-radius);
|
|
40
|
+
pointer-events: none;
|
|
41
|
+
z-index: 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
& > .trace-source {
|
|
45
|
+
height: 100%;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.trace-toolbar {
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
justify-content: center;
|
|
53
|
+
flex-shrink: 0;
|
|
54
|
+
padding: 3px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.trace-controls {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: 0.5rem;
|
|
61
|
+
padding: 6px;
|
|
62
|
+
border-radius: calc(var(--border-radius) + 4px);
|
|
63
|
+
background: var(--bg2-color);
|
|
64
|
+
|
|
65
|
+
&:focus-within {
|
|
66
|
+
outline: 2px solid var(--fg-color);
|
|
67
|
+
outline-offset: 1px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
width: 100%;
|
|
71
|
+
max-width: 386px;
|
|
72
|
+
|
|
73
|
+
@media (max-width: 410px) {
|
|
74
|
+
max-width: none;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.trace-btn {
|
|
79
|
+
display: inline-flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
justify-content: center;
|
|
82
|
+
flex: 1;
|
|
83
|
+
min-width: 2.5em;
|
|
84
|
+
padding: 0.5em 0.75em;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.trace-step-counter {
|
|
88
|
+
text-align: center;
|
|
89
|
+
font-variant-numeric: tabular-nums;
|
|
90
|
+
font-size: var(--font-size-smaller);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.trace-source {
|
|
94
|
+
--code-gutter-width: 4rem;
|
|
95
|
+
overflow: auto;
|
|
96
|
+
clip-path: inset(0 round var(--mono-border-radius));
|
|
97
|
+
scroll-behavior: smooth;
|
|
98
|
+
|
|
99
|
+
&:focus-visible {
|
|
100
|
+
outline: none;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
& > pre {
|
|
104
|
+
margin: 0;
|
|
105
|
+
padding: 0;
|
|
106
|
+
overflow: visible;
|
|
107
|
+
width: max-content;
|
|
108
|
+
min-width: 100%;
|
|
109
|
+
|
|
110
|
+
code {
|
|
111
|
+
padding-right: var(--gap);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.line-number.trace-line-active {
|
|
116
|
+
font-weight: bold;
|
|
117
|
+
color: var(--fg-color);
|
|
118
|
+
|
|
119
|
+
&::before {
|
|
120
|
+
content: '\25B6';
|
|
121
|
+
position: absolute;
|
|
122
|
+
margin-right: 0.25em;
|
|
123
|
+
left: 0;
|
|
124
|
+
line-height: 1.3;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.trace-output {
|
|
130
|
+
flex-shrink: 0;
|
|
131
|
+
min-height: calc(var(--font-size-smaller) * var(--mono-line-height) + 1em);
|
|
132
|
+
margin: 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.trace-output::before {
|
|
136
|
+
content: 'Output';
|
|
137
|
+
display: block;
|
|
138
|
+
font-size: var(--font-size-small);
|
|
139
|
+
font-style: italic;
|
|
140
|
+
color: var(--fg2-color);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.trace-memory {
|
|
144
|
+
font-family: var(--mono-font, monospace);
|
|
145
|
+
font-size: var(--font-size-smaller, 15px);
|
|
146
|
+
max-width: 100%;
|
|
147
|
+
height: auto;
|
|
148
|
+
|
|
149
|
+
.trace-separator {
|
|
150
|
+
stroke: var(--fg2-color);
|
|
151
|
+
stroke-width: 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.trace-frame-title {
|
|
155
|
+
font-weight: bold;
|
|
156
|
+
fill: var(--fg-color);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.trace-var-name,
|
|
160
|
+
.trace-field-name {
|
|
161
|
+
fill: var(--fg-color);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.trace-var-box {
|
|
165
|
+
fill: none;
|
|
166
|
+
stroke: var(--fg2-color);
|
|
167
|
+
stroke-width: 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.trace-obj-border {
|
|
171
|
+
fill: none;
|
|
172
|
+
stroke: var(--fg2-color);
|
|
173
|
+
stroke-width: 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.trace-val {
|
|
177
|
+
fill: var(--fg-color);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.trace-null {
|
|
181
|
+
fill: var(--fg2-color);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.trace-arrow {
|
|
185
|
+
fill: none;
|
|
186
|
+
stroke: var(--fg-color);
|
|
187
|
+
stroke-width: 1.5;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.trace-arrow-dim {
|
|
191
|
+
fill: none;
|
|
192
|
+
stroke: var(--fg2-color);
|
|
193
|
+
stroke-width: 1.5;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.trace-arrow-heap {
|
|
197
|
+
fill: none;
|
|
198
|
+
stroke: var(--fg2-color);
|
|
199
|
+
stroke-width: 1.5;
|
|
200
|
+
stroke-dasharray: 4 3;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.trace-arrowhead {
|
|
204
|
+
fill: var(--fg-color);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.trace-arrowhead-dim {
|
|
208
|
+
fill: var(--fg2-color);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface TraceManifest {
|
|
2
|
+
totalSteps: number;
|
|
3
|
+
chunkSize: number;
|
|
4
|
+
sourceFile: string;
|
|
5
|
+
source: string;
|
|
6
|
+
lineToSteps: Record<number, number[]>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TraceStep {
|
|
10
|
+
line: number;
|
|
11
|
+
file: string;
|
|
12
|
+
stack: TraceStackFrame[];
|
|
13
|
+
heap: Record<string, TraceHeapObject>;
|
|
14
|
+
stdout: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TraceStackFrame {
|
|
18
|
+
method: string;
|
|
19
|
+
class: string;
|
|
20
|
+
line: number;
|
|
21
|
+
locals: Record<string, TraceValue>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type TraceValue =
|
|
25
|
+
| {
|
|
26
|
+
type: 'int' | 'long' | 'short' | 'byte' | 'float' | 'double';
|
|
27
|
+
value: number;
|
|
28
|
+
}
|
|
29
|
+
| { type: 'boolean'; value: boolean }
|
|
30
|
+
| { type: 'char' | 'String'; value: string }
|
|
31
|
+
| { type: 'null' }
|
|
32
|
+
| { type: 'ref'; id: string }
|
|
33
|
+
| { type: 'unknown' }
|
|
34
|
+
| { type: 'uninitialized' }
|
|
35
|
+
| { type: 'truncated'; remaining: number };
|
|
36
|
+
|
|
37
|
+
export type TraceHeapObject =
|
|
38
|
+
| { type: string; elements: TraceValue[] }
|
|
39
|
+
| { type: string; fields: Record<string, TraceValue> }
|
|
40
|
+
| { type: string; value: string };
|
|
41
|
+
|
|
42
|
+
/** A single entry in a chunk file (new format with precomputed SVG). */
|
|
43
|
+
export interface TraceChunkEntry {
|
|
44
|
+
line: number;
|
|
45
|
+
stdout: string;
|
|
46
|
+
svg: string;
|
|
47
|
+
}
|
package/src/util.test.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
import { formatDuration, removeClass, getElement, applyBasePath } from './util';
|
|
4
|
+
|
|
5
|
+
function create(html: string) {
|
|
6
|
+
const dom = new JSDOM(`<body>${html}</body>`);
|
|
7
|
+
return dom.window;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('formatDuration', () => {
|
|
11
|
+
test('formats sub-millisecond values', () => {
|
|
12
|
+
expect(formatDuration(0.5)).toBe('0.5000ms');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('formats small millisecond values', () => {
|
|
16
|
+
expect(formatDuration(3.14159)).toBe('3.1416ms');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('formats tens of milliseconds', () => {
|
|
20
|
+
expect(formatDuration(42.7)).toBe('42.700ms');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('formats hundreds of milliseconds', () => {
|
|
24
|
+
expect(formatDuration(456.12)).toBe('456.12ms');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('formats exactly 1 second', () => {
|
|
28
|
+
expect(formatDuration(1000)).toBe('1.00000s');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('formats seconds under 10', () => {
|
|
32
|
+
expect(formatDuration(5432)).toBe('5.43200s');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('formats seconds 10 and above', () => {
|
|
36
|
+
expect(formatDuration(12345)).toBe('12.3450s');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('formats exactly 1 minute', () => {
|
|
40
|
+
expect(formatDuration(60000)).toBe('1m0.000s');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('formats minutes with seconds', () => {
|
|
44
|
+
expect(formatDuration(90000)).toBe('1m30.00s');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('formats large durations', () => {
|
|
48
|
+
expect(formatDuration(600000)).toBe('10m0.00s');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('formats zero', () => {
|
|
52
|
+
expect(formatDuration(0)).toBe('0.0000ms');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('formats negative values with sign', () => {
|
|
56
|
+
expect(formatDuration(-500)).toBe('-500.00ms');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('formats negative seconds', () => {
|
|
60
|
+
expect(formatDuration(-5000)).toBe('-5.00000s');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('removeClass', () => {
|
|
65
|
+
test('removes a class from an element', () => {
|
|
66
|
+
const win = create('<div class="foo bar">X</div>');
|
|
67
|
+
const el = win.document.querySelector('div') as HTMLElement;
|
|
68
|
+
removeClass(el, 'foo');
|
|
69
|
+
expect(el.classList.contains('foo')).toBe(false);
|
|
70
|
+
expect(el.classList.contains('bar')).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('removes the class attribute when no classes remain', () => {
|
|
74
|
+
const win = create('<div class="only">X</div>');
|
|
75
|
+
const el = win.document.querySelector('div') as HTMLElement;
|
|
76
|
+
removeClass(el, 'only');
|
|
77
|
+
expect(el.hasAttribute('class')).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('keeps class attribute when other classes remain', () => {
|
|
81
|
+
const win = create('<div class="a b">X</div>');
|
|
82
|
+
const el = win.document.querySelector('div') as HTMLElement;
|
|
83
|
+
removeClass(el, 'a');
|
|
84
|
+
expect(el.hasAttribute('class')).toBe(true);
|
|
85
|
+
expect(el.className).toBe('b');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('getElement', () => {
|
|
90
|
+
test('returns the matching element', () => {
|
|
91
|
+
const win = create('<div id="target">Hello</div>');
|
|
92
|
+
const el = getElement(win.document, '#target');
|
|
93
|
+
expect(el.textContent).toBe('Hello');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('throws when no element matches', () => {
|
|
97
|
+
const win = create('<div>No match</div>');
|
|
98
|
+
expect(() => getElement(win.document, '#missing')).toThrow(
|
|
99
|
+
'no element matching "#missing"',
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('works with a parent element scope', () => {
|
|
104
|
+
const win = create(
|
|
105
|
+
'<div id="a"><span class="inner">A</span></div><div id="b"><span class="inner">B</span></div>',
|
|
106
|
+
);
|
|
107
|
+
const parent = win.document.getElementById('b')!;
|
|
108
|
+
const el = getElement(parent, '.inner');
|
|
109
|
+
expect(el.textContent).toBe('B');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('applyBasePath', () => {
|
|
114
|
+
test('prepends the base path to a subpath', () => {
|
|
115
|
+
(globalThis as Record<string, unknown>).__SITE_BASE_PATH__ = '/docs/';
|
|
116
|
+
expect(applyBasePath('/page.html')).toBe('/docs/page.html');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('handles base path without trailing slash', () => {
|
|
120
|
+
(globalThis as Record<string, unknown>).__SITE_BASE_PATH__ = '/docs';
|
|
121
|
+
expect(applyBasePath('/page.html')).toBe('/docs/page.html');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('handles root base path', () => {
|
|
125
|
+
(globalThis as Record<string, unknown>).__SITE_BASE_PATH__ = '/';
|
|
126
|
+
expect(applyBasePath('/page.html')).toBe('/page.html');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('throws for subpath not starting with /', () => {
|
|
130
|
+
(globalThis as Record<string, unknown>).__SITE_BASE_PATH__ = '/docs';
|
|
131
|
+
expect(() => applyBasePath('page.html')).toThrow(
|
|
132
|
+
'invalid internal path, must start with "/"',
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/templates/_top.html
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
<meta name="apple-mobile-web-app-title" content="<%= site.symbol %>">
|
|
19
19
|
<meta name="mobile-web-app-capable" content="yes">
|
|
20
20
|
<% } %>
|
|
21
|
+
<meta name="generator" content="Tada" />
|
|
21
22
|
<meta charset="UTF-8" />
|
|
22
23
|
<meta http-equiv="cache-control" content="no-cache" />
|
|
23
24
|
<meta http-equiv="expires" content="0" />
|
|
@@ -53,32 +54,32 @@
|
|
|
53
54
|
|
|
54
55
|
<body class="<%= page.template %><% if (page.tocHtml) { %> toc-is-active<% } %>">
|
|
55
56
|
<header data-pagefind-ignore>
|
|
57
|
+
<div class="header-controls">
|
|
58
|
+
<div id="to-top-container" class="to-top-container"></div>
|
|
59
|
+
<% if (site.features.search) { %>
|
|
60
|
+
<div class="search-controls">
|
|
61
|
+
<input type="search" class="search quick-search"
|
|
62
|
+
autocomplete="off" name="quick-search"
|
|
63
|
+
placeholder="Search"
|
|
64
|
+
role="combobox"
|
|
65
|
+
aria-haspopup="listbox"
|
|
66
|
+
aria-autocomplete="list"
|
|
67
|
+
aria-expanded="false"
|
|
68
|
+
aria-controls="quick-search-results"
|
|
69
|
+
disabled />
|
|
70
|
+
<script>document.currentScript.previousElementSibling.disabled=false;</script>
|
|
71
|
+
</div>
|
|
72
|
+
<% } %>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="results-container" role="region"
|
|
75
|
+
aria-label="Search results"
|
|
76
|
+
aria-live="polite" aria-hidden="true" inert>
|
|
77
|
+
<div class="results" style="display:none"></div>
|
|
78
|
+
</div>
|
|
56
79
|
<details>
|
|
57
80
|
<summary aria-label="Toggle site navigation">
|
|
58
81
|
<% if (site.symbol) { %><span class="logo" aria-hidden="true"><%= site.symbol %></span><% } %>
|
|
59
82
|
<span class="site-title" aria-hidden="true"><%= site.title %></span>
|
|
60
|
-
<div id="to-top-container" class="to-top-container"></div>
|
|
61
|
-
<% if (site.features.search) { %>
|
|
62
|
-
<div class="search-controls">
|
|
63
|
-
<input type="search" class="search quick-search"
|
|
64
|
-
autocomplete="off" name="quick-search"
|
|
65
|
-
placeholder="Search"
|
|
66
|
-
role="combobox"
|
|
67
|
-
aria-haspopup="listbox"
|
|
68
|
-
aria-autocomplete="list"
|
|
69
|
-
aria-expanded="false"
|
|
70
|
-
aria-controls="quick-search-results"
|
|
71
|
-
disabled />
|
|
72
|
-
<script>document.currentScript.previousElementSibling.disabled=false;</script>
|
|
73
|
-
<% if (site.features.search) { %>
|
|
74
|
-
<div class="results-container" role="region"
|
|
75
|
-
aria-label="Search results"
|
|
76
|
-
aria-live="polite" aria-hidden="true" inert>
|
|
77
|
-
<div class="results" style="display:none"></div>
|
|
78
|
-
</div>
|
|
79
|
-
<% } %>
|
|
80
|
-
</div>
|
|
81
|
-
<% } %>
|
|
82
83
|
</summary>
|
|
83
84
|
<div class="content">
|
|
84
85
|
<%= render('_nav.html') %>
|