@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.
- 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 +48 -6
- 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} +88 -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 +102 -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 +382 -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 +21 -4
- 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 +554 -0
- package/src/trace/index.ts +258 -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
package/src/header/_base.scss
CHANGED
|
@@ -22,7 +22,7 @@ header details summary {
|
|
|
22
22
|
display: flex;
|
|
23
23
|
align-items: center;
|
|
24
24
|
height: var(--header-collapsed-height);
|
|
25
|
-
padding: 0;
|
|
25
|
+
padding: 0 200px 0 0;
|
|
26
26
|
list-style-type: none;
|
|
27
27
|
cursor: pointer;
|
|
28
28
|
position: relative;
|
|
@@ -39,6 +39,10 @@ header details summary {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
header:has(.to-top-container .is-visible) details summary {
|
|
43
|
+
padding-right: 320px;
|
|
44
|
+
}
|
|
45
|
+
|
|
42
46
|
header details summary::-webkit-details-marker {
|
|
43
47
|
display: none;
|
|
44
48
|
}
|
|
@@ -85,13 +89,24 @@ header summary .site-title {
|
|
|
85
89
|
margin: 0 8px 0 0;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
@container (max-width:
|
|
92
|
+
@container (max-width: 200px) {
|
|
89
93
|
header summary .site-title {
|
|
90
94
|
display: none;
|
|
91
95
|
}
|
|
92
96
|
}
|
|
93
97
|
|
|
94
|
-
header
|
|
98
|
+
header > .header-controls {
|
|
99
|
+
position: absolute;
|
|
100
|
+
top: 0;
|
|
101
|
+
right: 0;
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
height: var(--header-collapsed-height);
|
|
105
|
+
padding: 0 var(--gap) 0 0;
|
|
106
|
+
z-index: 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
header .to-top-container {
|
|
95
110
|
display: none;
|
|
96
111
|
align-items: center;
|
|
97
112
|
height: var(--header-collapsed-height);
|
|
@@ -111,20 +126,44 @@ header .results-container {
|
|
|
111
126
|
display: none;
|
|
112
127
|
}
|
|
113
128
|
|
|
114
|
-
header
|
|
115
|
-
margin-left: auto;
|
|
116
|
-
pointer-events: auto;
|
|
129
|
+
header .search-controls {
|
|
117
130
|
display: flex;
|
|
118
131
|
align-items: center;
|
|
119
132
|
height: var(--header-collapsed-height);
|
|
120
|
-
padding: 0 var(--gap) 0 0;
|
|
121
|
-
min-width: 0;
|
|
122
133
|
|
|
123
134
|
input.search {
|
|
124
135
|
width: 180px;
|
|
125
136
|
|
|
126
|
-
@
|
|
137
|
+
@media (max-width: 400px) {
|
|
127
138
|
display: none;
|
|
128
139
|
}
|
|
129
140
|
}
|
|
130
141
|
}
|
|
142
|
+
|
|
143
|
+
@keyframes shimmer {
|
|
144
|
+
0%,
|
|
145
|
+
15% {
|
|
146
|
+
background-position: 200% 0;
|
|
147
|
+
}
|
|
148
|
+
100% {
|
|
149
|
+
background-position: -200% 0;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
header.loading::after {
|
|
154
|
+
content: '';
|
|
155
|
+
position: absolute;
|
|
156
|
+
bottom: 0;
|
|
157
|
+
left: 0;
|
|
158
|
+
right: 0;
|
|
159
|
+
height: 3px;
|
|
160
|
+
background-image: linear-gradient(
|
|
161
|
+
90deg,
|
|
162
|
+
transparent 25%,
|
|
163
|
+
var(--theme-color) 50%,
|
|
164
|
+
transparent 75%
|
|
165
|
+
);
|
|
166
|
+
background-size: 200% 100%;
|
|
167
|
+
background-repeat: no-repeat;
|
|
168
|
+
animation: shimmer 3s ease-in-out infinite;
|
|
169
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
import mount from './index';
|
|
4
|
+
|
|
5
|
+
function create(open = false) {
|
|
6
|
+
const dom = new JSDOM(
|
|
7
|
+
`<body><header><details${open ? ' open' : ''}><summary>Menu</summary><nav>Links</nav></details></header></body>`,
|
|
8
|
+
);
|
|
9
|
+
return dom.window;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('header', () => {
|
|
13
|
+
test('closes open details on outside click', () => {
|
|
14
|
+
const win = create(true);
|
|
15
|
+
mount(win);
|
|
16
|
+
|
|
17
|
+
const details = win.document.querySelector('details') as HTMLDetailsElement;
|
|
18
|
+
expect(details.open).toBe(true);
|
|
19
|
+
|
|
20
|
+
win.document.body.dispatchEvent(
|
|
21
|
+
new win.MouseEvent('click', { bubbles: true }),
|
|
22
|
+
);
|
|
23
|
+
expect(details.open).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('does not close details when clicking inside', () => {
|
|
27
|
+
const win = create(true);
|
|
28
|
+
mount(win);
|
|
29
|
+
|
|
30
|
+
const details = win.document.querySelector('details') as HTMLDetailsElement;
|
|
31
|
+
details.dispatchEvent(new win.MouseEvent('click', { bubbles: false }));
|
|
32
|
+
expect(details.open).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('closes on Escape key when details is open', () => {
|
|
36
|
+
const win = create(true);
|
|
37
|
+
mount(win);
|
|
38
|
+
|
|
39
|
+
const details = win.document.querySelector('details') as HTMLDetailsElement;
|
|
40
|
+
const summary = win.document.querySelector('summary')!;
|
|
41
|
+
|
|
42
|
+
// Focus the summary so activeElement is inside details
|
|
43
|
+
summary.focus();
|
|
44
|
+
|
|
45
|
+
win.dispatchEvent(
|
|
46
|
+
new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
|
47
|
+
);
|
|
48
|
+
expect(details.open).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('ignores Escape when details is closed', () => {
|
|
52
|
+
const win = create(false);
|
|
53
|
+
mount(win);
|
|
54
|
+
|
|
55
|
+
const details = win.document.querySelector('details') as HTMLDetailsElement;
|
|
56
|
+
win.dispatchEvent(
|
|
57
|
+
new win.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
|
58
|
+
);
|
|
59
|
+
expect(details.open).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('ignores other keys', () => {
|
|
63
|
+
const win = create(true);
|
|
64
|
+
mount(win);
|
|
65
|
+
|
|
66
|
+
const details = win.document.querySelector('details') as HTMLDetailsElement;
|
|
67
|
+
const summary = win.document.querySelector('summary')!;
|
|
68
|
+
summary.focus();
|
|
69
|
+
|
|
70
|
+
win.dispatchEvent(
|
|
71
|
+
new win.KeyboardEvent('keydown', { key: 'Tab', bubbles: true }),
|
|
72
|
+
);
|
|
73
|
+
expect(details.open).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('cleanup removes event listeners', () => {
|
|
77
|
+
const win = create(true);
|
|
78
|
+
const cleanup = mount(win);
|
|
79
|
+
|
|
80
|
+
cleanup!();
|
|
81
|
+
|
|
82
|
+
win.document.body.dispatchEvent(
|
|
83
|
+
new win.MouseEvent('click', { bubbles: true }),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const details = win.document.querySelector('details') as HTMLDetailsElement;
|
|
87
|
+
expect(details.open).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
package/src/header/index.ts
CHANGED
package/src/header/style.scss
CHANGED
|
@@ -60,7 +60,7 @@ header nav div > p {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
@media (hover: hover) {
|
|
63
|
-
header details summary:hover
|
|
63
|
+
header details summary:hover {
|
|
64
64
|
background: var(--bg2-color);
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -81,10 +81,6 @@ header .results-container {
|
|
|
81
81
|
padding: var(--gap);
|
|
82
82
|
pointer-events: none;
|
|
83
83
|
z-index: 1000;
|
|
84
|
-
|
|
85
|
-
@container (max-width: 260px) {
|
|
86
|
-
display: none;
|
|
87
|
-
}
|
|
88
84
|
}
|
|
89
85
|
|
|
90
86
|
header ol {
|
package/src/index.ts
CHANGED
|
@@ -10,29 +10,25 @@ import './question/style.scss';
|
|
|
10
10
|
import './search/style.scss';
|
|
11
11
|
import './timezone/style.scss';
|
|
12
12
|
import './toc/style.scss';
|
|
13
|
+
import './trace/style.scss';
|
|
14
|
+
import './navigate/style.scss';
|
|
13
15
|
|
|
14
|
-
import mountTableOfContents from './toc';
|
|
15
16
|
import mountSearch from './search';
|
|
16
17
|
import mountHeader from './header';
|
|
17
|
-
import mountPrint from './print';
|
|
18
18
|
import mountTop from './top';
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import mountTimeZone from './timezone';
|
|
22
|
-
import mountCode from './code';
|
|
19
|
+
import mountNavigate from './navigate';
|
|
20
|
+
import { mountPerPageComponents } from './navigate/lifecycle';
|
|
23
21
|
|
|
24
22
|
import { scheduleTask, formatDuration } from './util';
|
|
25
23
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
const PERSISTENT_COMPONENTS: Record<
|
|
25
|
+
string,
|
|
26
|
+
(w: Window) => void | (() => void)
|
|
27
|
+
> = {
|
|
29
28
|
header: mountHeader,
|
|
30
|
-
|
|
29
|
+
search: mountSearch,
|
|
31
30
|
top: mountTop,
|
|
32
|
-
|
|
33
|
-
question: mountQuestion,
|
|
34
|
-
timeZone: mountTimeZone,
|
|
35
|
-
code: mountCode,
|
|
31
|
+
navigate: mountNavigate,
|
|
36
32
|
};
|
|
37
33
|
|
|
38
34
|
let startTime = -1;
|
|
@@ -40,26 +36,28 @@ let startTime = -1;
|
|
|
40
36
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
41
37
|
startTime = window.performance.now();
|
|
42
38
|
|
|
43
|
-
const entries = Object.entries(COMPONENTS);
|
|
44
|
-
|
|
45
39
|
const failed: Record<string, string> = {};
|
|
46
40
|
|
|
47
|
-
|
|
41
|
+
// Mount persistent components
|
|
42
|
+
const persistentEntries = Object.entries(PERSISTENT_COMPONENTS);
|
|
43
|
+
const persistentPromises = persistentEntries.map(([name, mount]) => {
|
|
48
44
|
return new Promise<void>((resolve, reject) => {
|
|
49
45
|
scheduleTask(async () => {
|
|
50
46
|
try {
|
|
51
47
|
mount(window);
|
|
52
48
|
resolve();
|
|
53
|
-
return;
|
|
54
49
|
} catch (err) {
|
|
55
50
|
failed[name] = String(err);
|
|
51
|
+
reject();
|
|
56
52
|
}
|
|
57
|
-
reject();
|
|
58
53
|
});
|
|
59
54
|
});
|
|
60
55
|
});
|
|
61
56
|
|
|
62
|
-
await Promise.allSettled(
|
|
57
|
+
await Promise.allSettled(persistentPromises);
|
|
58
|
+
|
|
59
|
+
// Mount per-page components
|
|
60
|
+
await mountPerPageComponents(window);
|
|
63
61
|
|
|
64
62
|
for (const [name, reason] of Object.entries(failed)) {
|
|
65
63
|
console.error(`Failed to mount ${name} component:`, reason);
|
package/src/literate/style.scss
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
body.literate {
|
|
2
|
+
--code-gutter-width: 4rem;
|
|
3
|
+
}
|
|
4
|
+
|
|
1
5
|
body.literate .body pre:has(.code-row) {
|
|
2
6
|
background: var(--bg2-color);
|
|
3
7
|
border-radius: var(--mono-border-radius);
|
|
@@ -11,13 +15,17 @@ body.literate .body pre:has(.code-row) {
|
|
|
11
15
|
padding-right: var(--gap);
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
*:first-child {
|
|
19
|
+
.line-number,
|
|
20
|
+
code {
|
|
21
|
+
padding-top: 0.5rem;
|
|
17
22
|
}
|
|
23
|
+
}
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
*:last-child {
|
|
26
|
+
.line-number,
|
|
27
|
+
code {
|
|
28
|
+
padding-bottom: 0.5rem;
|
|
21
29
|
}
|
|
22
30
|
}
|
|
23
31
|
}
|
|
@@ -36,7 +44,7 @@ body.literate .literate-code-output {
|
|
|
36
44
|
white-space: pre-wrap;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
@media (
|
|
47
|
+
@media (width < 924px) {
|
|
40
48
|
> pre:first-child {
|
|
41
49
|
margin-bottom: 0;
|
|
42
50
|
}
|
|
@@ -50,7 +58,7 @@ body.literate .literate-code-output {
|
|
|
50
58
|
}
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
@media (
|
|
61
|
+
@media (width >= 924px) {
|
|
54
62
|
grid-template-columns: 1fr 1fr;
|
|
55
63
|
|
|
56
64
|
> pre:last-child {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { isEligibleLink } from './eligible';
|
|
3
|
+
|
|
4
|
+
describe('isEligibleLink', () => {
|
|
5
|
+
const origin = 'http://localhost';
|
|
6
|
+
const basePath = '/';
|
|
7
|
+
|
|
8
|
+
test('eligible: same-origin absolute path', () => {
|
|
9
|
+
expect(
|
|
10
|
+
isEligibleLink('http://localhost/about.html', origin, basePath),
|
|
11
|
+
).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('eligible: path without extension', () => {
|
|
15
|
+
expect(isEligibleLink('http://localhost/about', origin, basePath)).toBe(
|
|
16
|
+
true,
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('eligible: path ending in .html', () => {
|
|
21
|
+
expect(isEligibleLink('http://localhost/page.html', origin, basePath)).toBe(
|
|
22
|
+
true,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('eligible: root path', () => {
|
|
27
|
+
expect(isEligibleLink('http://localhost/', origin, basePath)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('eligible: path with hash', () => {
|
|
31
|
+
expect(
|
|
32
|
+
isEligibleLink('http://localhost/page.html#section', origin, basePath),
|
|
33
|
+
).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('ineligible: different origin', () => {
|
|
37
|
+
expect(
|
|
38
|
+
isEligibleLink('https://example.com/page.html', origin, basePath),
|
|
39
|
+
).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('ineligible: PDF file', () => {
|
|
43
|
+
expect(isEligibleLink('http://localhost/doc.pdf', origin, basePath)).toBe(
|
|
44
|
+
false,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('ineligible: Java file', () => {
|
|
49
|
+
expect(isEligibleLink('http://localhost/Main.java', origin, basePath)).toBe(
|
|
50
|
+
false,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('ineligible: Python file', () => {
|
|
55
|
+
expect(isEligibleLink('http://localhost/script.py', origin, basePath)).toBe(
|
|
56
|
+
false,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('ineligible: PNG image', () => {
|
|
61
|
+
expect(isEligibleLink('http://localhost/image.png', origin, basePath)).toBe(
|
|
62
|
+
false,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('ineligible: ZIP file', () => {
|
|
67
|
+
expect(
|
|
68
|
+
isEligibleLink('http://localhost/archive.zip', origin, basePath),
|
|
69
|
+
).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('ineligible: JPG image', () => {
|
|
73
|
+
expect(isEligibleLink('http://localhost/photo.jpg', origin, basePath)).toBe(
|
|
74
|
+
false,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('ineligible: JSON file', () => {
|
|
79
|
+
expect(isEligibleLink('http://localhost/data.json', origin, basePath)).toBe(
|
|
80
|
+
false,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('eligible: link under non-root base path', () => {
|
|
85
|
+
expect(
|
|
86
|
+
isEligibleLink('http://localhost/course1/page.html', origin, '/course1/'),
|
|
87
|
+
).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('ineligible: link outside base path', () => {
|
|
91
|
+
expect(
|
|
92
|
+
isEligibleLink('http://localhost/course2/page.html', origin, '/course1/'),
|
|
93
|
+
).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('ineligible: link to root when base path is subdir', () => {
|
|
97
|
+
expect(isEligibleLink('http://localhost/', origin, '/course1/')).toBe(
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const NON_HTML_EXTENSIONS = new Set([
|
|
2
|
+
'.pdf',
|
|
3
|
+
'.java',
|
|
4
|
+
'.py',
|
|
5
|
+
'.png',
|
|
6
|
+
'.jpg',
|
|
7
|
+
'.jpeg',
|
|
8
|
+
'.gif',
|
|
9
|
+
'.svg',
|
|
10
|
+
'.zip',
|
|
11
|
+
'.tar',
|
|
12
|
+
'.gz',
|
|
13
|
+
'.json',
|
|
14
|
+
'.xml',
|
|
15
|
+
'.css',
|
|
16
|
+
'.js',
|
|
17
|
+
'.ts',
|
|
18
|
+
'.c',
|
|
19
|
+
'.h',
|
|
20
|
+
'.cpp',
|
|
21
|
+
'.rb',
|
|
22
|
+
'.go',
|
|
23
|
+
'.rs',
|
|
24
|
+
'.txt',
|
|
25
|
+
'.csv',
|
|
26
|
+
'.ico',
|
|
27
|
+
'.woff2',
|
|
28
|
+
'.mp4',
|
|
29
|
+
'.mp3',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export function isEligibleLink(
|
|
33
|
+
href: string,
|
|
34
|
+
origin: string,
|
|
35
|
+
basePath: string,
|
|
36
|
+
): boolean {
|
|
37
|
+
let url: URL;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(href);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (url.origin !== origin) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!url.pathname.startsWith(basePath)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const path = url.pathname;
|
|
53
|
+
const dotIndex = path.lastIndexOf('.');
|
|
54
|
+
if (dotIndex !== -1) {
|
|
55
|
+
const ext = path.slice(dotIndex).toLowerCase();
|
|
56
|
+
if (NON_HTML_EXTENSIONS.has(ext)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test, beforeEach, mock } from 'bun:test';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
3
|
+
|
|
4
|
+
// Mock the lifecycle module so we can track calls
|
|
5
|
+
const mockTeardown = mock(() => {});
|
|
6
|
+
const mockMount = mock(async () => mockTeardown);
|
|
7
|
+
mock.module('./lifecycle', () => ({
|
|
8
|
+
mountPerPageComponents: mockMount,
|
|
9
|
+
teardownPerPageComponents: mockTeardown,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import mount from './index';
|
|
13
|
+
|
|
14
|
+
function createDOM(bodyContent = '') {
|
|
15
|
+
const html = `<html><head><title>Page One</title><meta name="description" content="desc one"></head><body class="default toc-is-active"><header><details><summary>Menu</summary><nav></nav></details></header><div class="container">${bodyContent}</div></body></html>`;
|
|
16
|
+
const dom = new JSDOM(html, {
|
|
17
|
+
url: 'http://localhost/',
|
|
18
|
+
pretendToBeVisual: true,
|
|
19
|
+
});
|
|
20
|
+
return dom.window;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('navigate', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockTeardown.mockClear();
|
|
26
|
+
mockMount.mockClear();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('mount returns a cleanup function', () => {
|
|
30
|
+
const win = createDOM();
|
|
31
|
+
const cleanup = mount(win);
|
|
32
|
+
expect(typeof cleanup).toBe('function');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('cleanup removes event listeners without throwing', () => {
|
|
36
|
+
const win = createDOM();
|
|
37
|
+
const cleanup = mount(win);
|
|
38
|
+
cleanup!();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('ignores clicks with modifier keys', () => {
|
|
42
|
+
const win = createDOM('<a href="http://localhost/other.html">Link</a>');
|
|
43
|
+
mount(win);
|
|
44
|
+
|
|
45
|
+
const link = win.document.querySelector('a')!;
|
|
46
|
+
const event = new win.MouseEvent('click', { bubbles: true, ctrlKey: true });
|
|
47
|
+
const prevented = !link.dispatchEvent(event);
|
|
48
|
+
expect(prevented).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('ignores links with target attribute', () => {
|
|
52
|
+
const win = createDOM(
|
|
53
|
+
'<a href="http://localhost/other.html" target="_blank">Link</a>',
|
|
54
|
+
);
|
|
55
|
+
mount(win);
|
|
56
|
+
|
|
57
|
+
const link = win.document.querySelector('a')!;
|
|
58
|
+
const event = new win.MouseEvent('click', { bubbles: true });
|
|
59
|
+
const prevented = !link.dispatchEvent(event);
|
|
60
|
+
expect(prevented).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('ignores external links', () => {
|
|
64
|
+
const win = createDOM('<a href="https://example.com/page">Link</a>');
|
|
65
|
+
mount(win);
|
|
66
|
+
|
|
67
|
+
const link = win.document.querySelector('a')!;
|
|
68
|
+
const event = new win.MouseEvent('click', { bubbles: true });
|
|
69
|
+
const prevented = !link.dispatchEvent(event);
|
|
70
|
+
expect(prevented).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('ignores non-HTML resource links', () => {
|
|
74
|
+
const win = createDOM('<a href="http://localhost/file.pdf">PDF</a>');
|
|
75
|
+
mount(win);
|
|
76
|
+
|
|
77
|
+
const link = win.document.querySelector('a')!;
|
|
78
|
+
const event = new win.MouseEvent('click', { bubbles: true });
|
|
79
|
+
const prevented = !link.dispatchEvent(event);
|
|
80
|
+
expect(prevented).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|