@abreen/tada 1.4.1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +48 -6
  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} +88 -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 +102 -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 +382 -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 +21 -4
  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 +554 -0
  98. package/src/trace/index.ts +258 -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
@@ -84,18 +84,26 @@ function getOffsetMinutes(tz: string, date: Date): number {
84
84
  return (utcTs - date.getTime()) / 60000;
85
85
  }
86
86
 
87
- // typeof guard prevents ReferenceError when tests import this module
88
- // without going through the bundler (which substitutes __SITE_TIMEZONES__).
89
- const TIMEZONES: TimeZone[] =
90
- typeof __SITE_TIMEZONES__ !== 'undefined' ? __SITE_TIMEZONES__ : [];
87
+ // Read lazily so tests can set __SITE_TIMEZONES__ before the first call.
88
+ // Cache the result so mutations (like computeOffsets adding offsetMinutes)
89
+ // persist across calls. The bundler inlines __SITE_TIMEZONES__ as a JSON
90
+ // literal, so each raw access would create a fresh array.
91
+ let _timezones: TimeZone[] | null = null;
92
+ function getTimezones(): TimeZone[] {
93
+ if (_timezones === null) {
94
+ _timezones =
95
+ typeof __SITE_TIMEZONES__ !== 'undefined' ? __SITE_TIMEZONES__ : [];
96
+ }
97
+ return _timezones;
98
+ }
91
99
 
92
100
  function getDefaultTimezone() {
93
101
  const value = __SITE_DEFAULT_TIMEZONE__;
94
- return TIMEZONES.find(tz => tz.value === value)!;
102
+ return getTimezones().find(tz => tz.value === value)!;
95
103
  }
96
104
 
97
105
  function computeOffsets(baseDate: Date) {
98
- TIMEZONES.forEach(tz => {
106
+ getTimezones().forEach(tz => {
99
107
  tz.offsetMinutes = getOffsetMinutes(tz.value, baseDate);
100
108
  });
101
109
  }
@@ -134,7 +142,7 @@ export default (window: Window) => {
134
142
  let initialTz = defaultTz.value;
135
143
  try {
136
144
  const stored = window.localStorage.getItem(STORAGE_KEY);
137
- if (stored && TIMEZONES.some(t => t.value === stored)) {
145
+ if (stored && getTimezones().some(t => t.value === stored)) {
138
146
  initialTz = stored;
139
147
  }
140
148
  } catch {
@@ -147,66 +155,69 @@ export default (window: Window) => {
147
155
 
148
156
  // Snapshot the original AM/PM style from each <time> element's text
149
157
  const periodStyles = new Map<HTMLTimeElement, PeriodStyle | null>();
150
- Array.from(window.document.querySelectorAll('time[datetime]'))
151
- .filter((el): el is HTMLTimeElement => el instanceof HTMLTimeElement)
152
- .forEach(el => {
153
- periodStyles.set(el, detectPeriodStyle(el.textContent ?? ''));
154
- });
158
+ (
159
+ Array.from(
160
+ window.document.querySelectorAll('time[datetime]'),
161
+ ) as HTMLTimeElement[]
162
+ ).forEach(el => {
163
+ periodStyles.set(el, detectPeriodStyle(el.textContent ?? ''));
164
+ });
155
165
  const pagePeriodStyle = dominantPeriodStyle(periodStyles);
156
166
 
157
167
  function updateTimes(targetTz: string) {
158
- const target = TIMEZONES.find(t => t.value === targetTz) || defaultTz;
168
+ const target = getTimezones().find(t => t.value === targetTz) || defaultTz;
159
169
  const targetOffset = target.offsetMinutes ?? 0;
160
170
  const deltaMinutes = targetOffset - defaultOffset;
161
171
 
162
- Array.from(window.document.querySelectorAll('time[datetime]'))
163
- .filter(el => el instanceof HTMLTimeElement)
164
- .forEach(el => {
165
- const datetime = el.getAttribute('datetime');
166
- if (!datetime) {
167
- return;
168
- }
172
+ (
173
+ Array.from(
174
+ window.document.querySelectorAll('time[datetime]'),
175
+ ) as HTMLTimeElement[]
176
+ ).forEach(el => {
177
+ const datetime = el.getAttribute('datetime');
178
+ if (!datetime) {
179
+ return;
180
+ }
169
181
 
170
- const isDefault = target.value === defaultTz.value;
182
+ const isDefault = target.value === defaultTz.value;
171
183
 
172
- const baseMinutes = parseHHMM(datetime);
173
- if (isNaN(baseMinutes)) {
174
- return;
175
- }
184
+ const baseMinutes = parseHHMM(datetime);
185
+ if (isNaN(baseMinutes)) {
186
+ return;
187
+ }
176
188
 
177
- // raw (can be < 0 or > 1439)
178
- const rawMinutes = baseMinutes + deltaMinutes;
179
- const dayShift =
180
- Math.floor(rawMinutes / 1440) - (rawMinutes < 0 ? 1 : 0);
181
- // Normalize after computing dayShift
182
- const [h, m] = normalizeHM(rawMinutes);
183
-
184
- let suffix = '';
185
- if (!isDefault) {
186
- if (dayShift === 1) {
187
- suffix = ' <span class="next-prev-day">(next day)</span>';
188
- } else if (dayShift === -1) {
189
- suffix = ' <span class="next-prev-day">(prev. day)</span>';
190
- }
189
+ // raw (can be < 0 or > 1439)
190
+ const rawMinutes = baseMinutes + deltaMinutes;
191
+ const dayShift = Math.floor(rawMinutes / 1440) - (rawMinutes < 0 ? 1 : 0);
192
+ // Normalize after computing dayShift
193
+ const [h, m] = normalizeHM(rawMinutes);
194
+
195
+ let suffix = '';
196
+ if (!isDefault) {
197
+ if (dayShift === 1) {
198
+ suffix = ' <span class="next-prev-day">(next day)</span>';
199
+ } else if (dayShift === -1) {
200
+ suffix = ' <span class="next-prev-day">(prev. day)</span>';
191
201
  }
202
+ }
192
203
 
193
- const originalStyle = periodStyles.get(el) ?? null;
194
- const originalIsPm = Math.floor(baseMinutes / 60) >= 12;
195
- const periodChanged = originalIsPm !== h >= 12 || dayShift !== 0;
196
- const style =
197
- originalStyle === null && periodChanged
198
- ? pagePeriodStyle
199
- : originalStyle;
200
- el.innerHTML = to12Hour(h, m, style) + suffix;
201
-
202
- if (isDefault) {
203
- el.classList.remove('is-modified');
204
- el.title = '';
205
- } else {
206
- el.classList.add('is-modified');
207
- el.title = `${to12Hour(...normalizeHM(baseMinutes), style ?? pagePeriodStyle)} ${defaultTz.abbreviation}`;
208
- }
209
- });
204
+ const originalStyle = periodStyles.get(el) ?? null;
205
+ const originalIsPm = Math.floor(baseMinutes / 60) >= 12;
206
+ const periodChanged = originalIsPm !== h >= 12 || dayShift !== 0;
207
+ const style =
208
+ originalStyle === null && periodChanged
209
+ ? pagePeriodStyle
210
+ : originalStyle;
211
+ el.innerHTML = to12Hour(h, m, style) + suffix;
212
+
213
+ if (isDefault) {
214
+ el.classList.remove('is-modified');
215
+ el.title = '';
216
+ } else {
217
+ el.classList.add('is-modified');
218
+ el.title = `${to12Hour(...normalizeHM(baseMinutes), style ?? pagePeriodStyle)} ${defaultTz.abbreviation}`;
219
+ }
220
+ });
210
221
  }
211
222
 
212
223
  window.document
@@ -272,6 +283,7 @@ export default (window: Window) => {
272
283
  }
273
284
  syncAll(defaultTz.value);
274
285
  updateTimes(defaultTz.value);
286
+ selectEl.focus();
275
287
  });
276
288
  });
277
289
 
@@ -0,0 +1,237 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { JSDOM } from 'jsdom';
3
+ import mount, {
4
+ getHighlightIndexes,
5
+ headingToTableItem,
6
+ alertToTableItem,
7
+ switchCurrent,
8
+ type Heading,
9
+ type Alert,
10
+ type Dinkus,
11
+ } from './index';
12
+
13
+ function dom(html: string) {
14
+ return new JSDOM(`<body>${html}</body>`);
15
+ }
16
+
17
+ describe('getHighlightIndexes', () => {
18
+ test('maps headings to their own index', () => {
19
+ const items: Heading[] = [
20
+ { level: '1', id: 'a', innerHtml: 'A' },
21
+ { level: '2', id: 'b', innerHtml: 'B' },
22
+ ];
23
+ expect(getHighlightIndexes(items)).toEqual([0, 1]);
24
+ });
25
+
26
+ test('maps alerts to the preceding heading index', () => {
27
+ const items: (Heading | Alert)[] = [
28
+ { level: '1', id: 'a', innerHtml: 'A' },
29
+ { type: 'warning', title: 'Watch out' },
30
+ { level: '2', id: 'b', innerHtml: 'B' },
31
+ ];
32
+ expect(getHighlightIndexes(items)).toEqual([0, 0, 2]);
33
+ });
34
+
35
+ test('alert before any heading gets its own index', () => {
36
+ const items: (Heading | Alert)[] = [
37
+ { type: 'note', title: 'FYI' },
38
+ { level: '1', id: 'a', innerHtml: 'A' },
39
+ ];
40
+ expect(getHighlightIndexes(items)).toEqual([0, 1]);
41
+ });
42
+
43
+ test('skips dinkus items entirely', () => {
44
+ const items: (Heading | Dinkus)[] = [
45
+ { level: '1', id: 'a', innerHtml: 'A' },
46
+ { type: 'dinkus' },
47
+ { level: '2', id: 'b', innerHtml: 'B' },
48
+ ];
49
+ expect(getHighlightIndexes(items)).toEqual([0, 1]);
50
+ });
51
+
52
+ test('returns empty array for empty input', () => {
53
+ expect(getHighlightIndexes([])).toEqual([]);
54
+ });
55
+ });
56
+
57
+ describe('headingToTableItem', () => {
58
+ test('extracts level and innerHTML from a heading', () => {
59
+ const { document } = dom('<h2 id="intro">Introduction</h2>').window;
60
+ const el = document.querySelector('h2') as HTMLHeadingElement;
61
+ const result = headingToTableItem(el);
62
+ expect(result).toEqual({
63
+ level: '2',
64
+ id: 'intro',
65
+ innerHtml: 'Introduction',
66
+ });
67
+ });
68
+
69
+ test('separates subtitle from main text', () => {
70
+ const { document } = dom(
71
+ '<h3 id="s"><span class="heading-subtitle">v2</span>Setup</h3>',
72
+ ).window;
73
+ const el = document.querySelector('h3') as HTMLHeadingElement;
74
+ const result = headingToTableItem(el);
75
+ expect(result.level).toBe('3');
76
+ expect(result.id).toBe('s');
77
+ expect(result.innerHtml).toContain('Setup');
78
+ expect(result.innerHtml).toContain('heading-subtitle');
79
+ });
80
+
81
+ test('uses innerHTML when no subtitle', () => {
82
+ const { document } = dom('<h1 id="top"><em>Bold</em> title</h1>').window;
83
+ const el = document.querySelector('h1') as HTMLHeadingElement;
84
+ const result = headingToTableItem(el);
85
+ expect(result.innerHtml).toBe('<em>Bold</em> title');
86
+ });
87
+ });
88
+
89
+ describe('alertToTableItem', () => {
90
+ test('returns warning alert with custom title', () => {
91
+ const { document } = dom(
92
+ '<div class="alert warning"><span class="title">Danger</span></div>',
93
+ ).window;
94
+ const el = document.querySelector('.alert') as HTMLElement;
95
+ expect(alertToTableItem(el)).toEqual({ type: 'warning', title: 'Danger' });
96
+ });
97
+
98
+ test('returns note alert with default title', () => {
99
+ const { document } = dom('<div class="alert note">Some note</div>').window;
100
+ const el = document.querySelector('.alert') as HTMLElement;
101
+ expect(alertToTableItem(el)).toEqual({ type: 'note', title: 'Note' });
102
+ });
103
+
104
+ test('returns default Warning title when no .title element', () => {
105
+ const { document } = dom(
106
+ '<div class="alert warning">Text only</div>',
107
+ ).window;
108
+ const el = document.querySelector('.alert') as HTMLElement;
109
+ expect(alertToTableItem(el)).toEqual({ type: 'warning', title: 'Warning' });
110
+ });
111
+
112
+ test('returns null for non-warning/note alert types', () => {
113
+ const { document } = dom('<div class="alert info">Some info</div>').window;
114
+ const el = document.querySelector('.alert') as HTMLElement;
115
+ expect(alertToTableItem(el)).toBeNull();
116
+ });
117
+ });
118
+
119
+ describe('switchCurrent', () => {
120
+ test('adds current class to new element', () => {
121
+ const { document } = dom('<li>A</li><li>B</li>').window;
122
+ const items = document.querySelectorAll('li');
123
+ switchCurrent(null, items[0] as HTMLElement);
124
+ expect(items[0].classList.contains('current')).toBe(true);
125
+ });
126
+
127
+ test('removes current from old and adds to new', () => {
128
+ const { document } = dom('<li class="current">A</li><li>B</li>').window;
129
+ const items = document.querySelectorAll('li');
130
+ switchCurrent(items[0] as HTMLElement, items[1] as HTMLElement);
131
+ expect(items[0].classList.contains('current')).toBe(false);
132
+ expect(items[1].classList.contains('current')).toBe(true);
133
+ });
134
+ });
135
+
136
+ describe('toc mount', () => {
137
+ test('returns early when no nav.toc exists', () => {
138
+ const win = dom('<div>No toc</div>').window;
139
+ const cleanup = mount(win);
140
+ expect(cleanup).toBeUndefined();
141
+ });
142
+
143
+ test('returns early when toc has no links', () => {
144
+ const win = dom('<nav class="toc"><ol></ol></nav>').window;
145
+ const cleanup = mount(win);
146
+ expect(cleanup).toBeUndefined();
147
+ });
148
+
149
+ describe('code page', () => {
150
+ function createCodePage(hash = '') {
151
+ const html =
152
+ '<nav class="toc"><ol>' +
153
+ '<li><a href="#L1">main</a></li>' +
154
+ '<li><a href="#L10">helper</a></li>' +
155
+ '<li><a href="#L20">util</a></li>' +
156
+ '</ol></nav>' +
157
+ '<div class="body"></div>';
158
+ return new JSDOM(`<body class="code">${html}</body>`, {
159
+ url: `http://localhost/${hash}`,
160
+ }).window;
161
+ }
162
+
163
+ test('highlights the matching toc entry for the current hash', () => {
164
+ const win = createCodePage('#L10');
165
+ mount(win);
166
+
167
+ const items = win.document.querySelectorAll('nav.toc ol li');
168
+ expect(items[1].classList.contains('current')).toBe(true);
169
+ });
170
+
171
+ test('highlights nearest entry when hash is between line ranges', () => {
172
+ const win = createCodePage('#L15');
173
+ mount(win);
174
+
175
+ const items = win.document.querySelectorAll('nav.toc ol li');
176
+ expect(items[1].classList.contains('current')).toBe(true);
177
+ });
178
+
179
+ test('parses range hashes like #L5-L10', () => {
180
+ const win = createCodePage('#L5-L10');
181
+ mount(win);
182
+
183
+ const items = win.document.querySelectorAll('nav.toc ol li');
184
+ expect(items[0].classList.contains('current')).toBe(true);
185
+ });
186
+
187
+ test('no current item when hash has no line number', () => {
188
+ const win = createCodePage('');
189
+ mount(win);
190
+
191
+ const current = win.document.querySelector('nav.toc .current');
192
+ expect(current).toBeNull();
193
+ });
194
+
195
+ test('cleanup removes hashchange listener', () => {
196
+ const win = createCodePage('#L1');
197
+ const cleanup = mount(win);
198
+
199
+ expect(typeof cleanup).toBe('function');
200
+ cleanup!();
201
+ });
202
+ });
203
+
204
+ describe('regular page', () => {
205
+ function createRegularPage() {
206
+ const html =
207
+ '<nav class="toc"><ol>' +
208
+ '<li><a href="#intro">Introduction</a></li>' +
209
+ '<li><a href="#setup">Setup</a></li>' +
210
+ '</ol></nav>' +
211
+ '<div class="body">' +
212
+ '<h2 id="intro">Introduction</h2>' +
213
+ '<p>Content</p>' +
214
+ '<h2 id="setup">Setup</h2>' +
215
+ '<p>More content</p>' +
216
+ '</div>';
217
+ return new JSDOM(`<body>${html}</body>`, { url: 'http://localhost/' })
218
+ .window;
219
+ }
220
+
221
+ test('sets a current item on mount', () => {
222
+ const win = createRegularPage();
223
+ mount(win);
224
+
225
+ const current = win.document.querySelector('nav.toc .current');
226
+ expect(current).not.toBeNull();
227
+ });
228
+
229
+ test('cleanup removes scroll listener', () => {
230
+ const win = createRegularPage();
231
+ const cleanup = mount(win);
232
+
233
+ expect(typeof cleanup).toBe('function');
234
+ cleanup!();
235
+ });
236
+ });
237
+ });
package/src/toc/index.ts CHANGED
@@ -2,12 +2,12 @@ import { debounce } from '../util';
2
2
 
3
3
  const LATENCY_MS = 50;
4
4
 
5
- type HeadingLevel = '1' | '2' | '3' | '4' | '5' | '6';
6
- type AlertType = 'warning' | 'note';
5
+ export type HeadingLevel = '1' | '2' | '3' | '4' | '5' | '6';
6
+ export type AlertType = 'warning' | 'note';
7
7
 
8
- type Alert = { type: AlertType; title: string };
9
- type Heading = { level: HeadingLevel; innerHtml: string; id: string };
10
- type Dinkus = { type: 'dinkus' };
8
+ export type Alert = { type: AlertType; title: string };
9
+ export type Heading = { level: HeadingLevel; innerHtml: string; id: string };
10
+ export type Dinkus = { type: 'dinkus' };
11
11
 
12
12
  function getContainer(parent: HTMLElement): HTMLElement | null {
13
13
  return parent.querySelector('nav.toc');
@@ -17,8 +17,8 @@ function getCurrentListItem(parent: HTMLElement): HTMLLIElement | null {
17
17
  return parent.querySelector('nav.toc .current');
18
18
  }
19
19
 
20
- function scrollIfNeeded(element: HTMLElement) {
21
- const container = getContainer(document.body);
20
+ function scrollIfNeeded(element: HTMLElement, doc: Document) {
21
+ const container = getContainer(doc.body as HTMLElement);
22
22
  if (container == null) {
23
23
  return;
24
24
  }
@@ -37,10 +37,10 @@ function scrollIfNeeded(element: HTMLElement) {
37
37
  elementRect.height / 2;
38
38
  const desiredScrollTop = elementCenter - container.clientHeight / 2;
39
39
 
40
- container.scrollTop = desiredScrollTop;
40
+ container.scrollTo({ top: desiredScrollTop, behavior: 'smooth' });
41
41
  }
42
42
 
43
- function getHighlightIndexes(items: (Heading | Alert | Dinkus)[]) {
43
+ export function getHighlightIndexes(items: (Heading | Alert | Dinkus)[]) {
44
44
  const indexes: (number | null)[] = [];
45
45
  let currentHeadingIndex: number | null = null;
46
46
  let tocIndex = 0;
@@ -82,8 +82,8 @@ function getTocElements(
82
82
  }
83
83
 
84
84
  /* Calculate how much to offset scroll calculations based on floating header */
85
- function getHeaderOffset() {
86
- const element = document.querySelector('header details summary');
85
+ function getHeaderOffset(doc: Document) {
86
+ const element = doc.querySelector('header details summary');
87
87
  if (!element) {
88
88
  return 0;
89
89
  }
@@ -91,13 +91,13 @@ function getHeaderOffset() {
91
91
  return element.getBoundingClientRect().height;
92
92
  }
93
93
 
94
- function getViewportActivationPoint() {
95
- const headerOffset = getHeaderOffset();
94
+ function getViewportActivationPoint(win: Window, doc: Document) {
95
+ const headerOffset = getHeaderOffset(doc);
96
96
 
97
- return headerOffset + (window.innerHeight - headerOffset) / 3;
97
+ return headerOffset + (win.innerHeight - headerOffset) / 3;
98
98
  }
99
99
 
100
- function headingToTableItem(el: HTMLHeadingElement): Heading {
100
+ export function headingToTableItem(el: HTMLHeadingElement): Heading {
101
101
  const level = el.tagName[1] as HeadingLevel;
102
102
 
103
103
  const subtitle = el.querySelector('.heading-subtitle');
@@ -115,7 +115,7 @@ function headingToTableItem(el: HTMLHeadingElement): Heading {
115
115
  return { level, id: el.id, innerHtml: el.innerHTML };
116
116
  }
117
117
 
118
- function alertToTableItem(el: HTMLElement): Alert | null {
118
+ export function alertToTableItem(el: HTMLElement): Alert | null {
119
119
  const classes = el.className
120
120
  .split(' ')
121
121
  .map(cl => cl.trim())
@@ -138,7 +138,7 @@ function alertToTableItem(el: HTMLElement): Alert | null {
138
138
  return null;
139
139
  }
140
140
 
141
- function switchCurrent(
141
+ export function switchCurrent(
142
142
  oldCurrent: HTMLElement | null,
143
143
  newCurrent: HTMLElement,
144
144
  ) {
@@ -148,50 +148,6 @@ function switchCurrent(
148
148
  newCurrent.classList.add('current');
149
149
  }
150
150
 
151
- function wireAlertClickHandlers(
152
- toc: HTMLElement,
153
- headingsAndAlerts: (HTMLHeadingElement | HTMLDivElement)[],
154
- items: (Heading | Alert | Dinkus)[],
155
- ) {
156
- const alertLinks = toc.querySelectorAll('li.alert-item a');
157
- let alertIdx = 0;
158
- let scrollIdx = 0;
159
-
160
- items.forEach(item => {
161
- if (!('level' in item) && (item as Alert | Dinkus).type === 'dinkus') {
162
- return;
163
- }
164
-
165
- if (!('level' in item)) {
166
- // This is an alert item
167
- const scrollEl = headingsAndAlerts[scrollIdx];
168
- const link = alertLinks[alertIdx] as HTMLAnchorElement | undefined;
169
- if (link && scrollEl) {
170
- link.onclick = (e: MouseEvent) => {
171
- e.preventDefault();
172
-
173
- const titleElement = scrollEl.querySelector('.title');
174
- const titleId = titleElement?.id || null;
175
-
176
- if (titleId) {
177
- history.replaceState(
178
- null,
179
- document.title,
180
- `${window.location.pathname}#${titleId}`,
181
- );
182
- }
183
-
184
- scrollEl.scrollIntoView();
185
- scrollEl.focus();
186
- };
187
- }
188
- alertIdx++;
189
- }
190
-
191
- scrollIdx++;
192
- });
193
- }
194
-
195
151
  export default (window: Window) => {
196
152
  const toc = window.document.querySelector('nav.toc') as HTMLElement;
197
153
  if (toc == null) {
@@ -227,7 +183,9 @@ export default (window: Window) => {
227
183
 
228
184
  const updateFromHash = () => {
229
185
  const line = parseHash(window.location.hash);
230
- const existingItem = getCurrentListItem(document.body);
186
+ const existingItem = getCurrentListItem(
187
+ window.document.body as HTMLElement,
188
+ );
231
189
 
232
190
  if (line == null) {
233
191
  if (existingItem) {
@@ -248,7 +206,7 @@ export default (window: Window) => {
248
206
  const nextItem = elements[best]?.parentElement ?? null;
249
207
  if (nextItem != null && nextItem !== existingItem) {
250
208
  switchCurrent(existingItem, nextItem);
251
- scrollIfNeeded(nextItem);
209
+ scrollIfNeeded(nextItem, window.document);
252
210
  }
253
211
  };
254
212
 
@@ -284,11 +242,11 @@ export default (window: Window) => {
284
242
 
285
243
  const highlightIndexes = getHighlightIndexes(items);
286
244
 
287
- // Wire up click handlers for alert items
288
- wireAlertClickHandlers(toc, headingsAndAlerts, items);
289
-
290
245
  function handleScroll() {
291
- const viewportActivationPoint = getViewportActivationPoint();
246
+ const viewportActivationPoint = getViewportActivationPoint(
247
+ window,
248
+ window.document,
249
+ );
292
250
 
293
251
  let i = 0;
294
252
  for (let idx = 0; idx < headingsAndAlerts.length; idx++) {
@@ -300,14 +258,16 @@ export default (window: Window) => {
300
258
  }
301
259
  }
302
260
 
303
- const existingItem = getCurrentListItem(document.body);
261
+ const existingItem = getCurrentListItem(
262
+ window.document.body as HTMLElement,
263
+ );
304
264
  const highlightIndex = highlightIndexes[i];
305
265
  const nextItem =
306
266
  highlightIndex == null ? null : elements[highlightIndex]?.parentElement;
307
267
 
308
268
  if (nextItem != null && nextItem !== existingItem) {
309
269
  switchCurrent(existingItem, nextItem);
310
- scrollIfNeeded(nextItem);
270
+ scrollIfNeeded(nextItem, window.document);
311
271
  }
312
272
  }
313
273
  const debounced = debounce(handleScroll, LATENCY_MS);
@@ -127,6 +127,23 @@ body.code .toc ol a {
127
127
  position: relative;
128
128
  }
129
129
 
130
+ // Preload hover icon variants so the browser decodes them before first hover
131
+ .toc ol li.alert-item.warning::after {
132
+ content: var(--icon-warning-sm-hover);
133
+ position: absolute;
134
+ width: 0;
135
+ height: 0;
136
+ overflow: hidden;
137
+ }
138
+
139
+ .toc ol li.alert-item.note::after {
140
+ content: var(--icon-note-sm-hover);
141
+ position: absolute;
142
+ width: 0;
143
+ height: 0;
144
+ overflow: hidden;
145
+ }
146
+
130
147
  .toc ol li.alert-item.warning a::before,
131
148
  .toc ol li.alert-item.note a::before {
132
149
  display: inline-block;
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { JSDOM } from 'jsdom';
3
+ import mount from './index';
4
+
5
+ function create(html = '') {
6
+ const dom = new JSDOM(`<body>${html}</body>`, { url: 'http://localhost/' });
7
+ return dom.window;
8
+ }
9
+
10
+ describe('top', () => {
11
+ test('creates a back-to-top link in the body', () => {
12
+ const win = create();
13
+ mount(win);
14
+
15
+ const link = win.document.querySelector('a.button');
16
+ expect(link).not.toBeNull();
17
+ expect(link!.getAttribute('href')).toBe('#');
18
+ });
19
+
20
+ test('mounts link inside #to-top-container when present', () => {
21
+ const win = create('<div id="to-top-container"></div>');
22
+ mount(win);
23
+
24
+ const container = win.document.getElementById('to-top-container')!;
25
+ const link = container.querySelector('a.button');
26
+ expect(link).not.toBeNull();
27
+ });
28
+
29
+ test('link starts hidden (no is-visible class)', () => {
30
+ const win = create();
31
+ mount(win);
32
+
33
+ const link = win.document.querySelector('a.button')!;
34
+ expect(link.classList.contains('is-visible')).toBe(false);
35
+ expect(link.getAttribute('tabindex')).toBe('-1');
36
+ });
37
+
38
+ test('cleanup removes scroll listener', () => {
39
+ const win = create();
40
+ const cleanup = mount(win);
41
+
42
+ expect(typeof cleanup).toBe('function');
43
+ // Should not throw
44
+ cleanup!();
45
+ });
46
+ });