@abreen/tada 1.0.2 → 1.2.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 +96 -48
- package/bin/tada.ts +621 -0
- package/bin/validators.test.ts +205 -0
- package/bin/validators.ts +85 -0
- package/{webpack/apply-base-path-plugin.js → build/apply-base-path-plugin.ts} +37 -17
- package/build/build-manifest.test.ts +349 -0
- package/build/build-manifest.ts +165 -0
- package/build/bundle.ts +116 -0
- package/{webpack/code.test.js → build/code.test.ts} +10 -7
- package/build/colors.ts +25 -0
- package/build/content-watch.ts +107 -0
- package/build/copy.ts +118 -0
- package/{webpack/deflist-id-plugin.js → build/deflist-id-plugin.ts} +7 -6
- package/{webpack/external-links-plugin.js → build/external-links-plugin.ts} +14 -5
- package/build/features.ts +11 -0
- package/build/generate-content-assets.ts +320 -0
- package/build/generate-favicon.ts +172 -0
- package/build/generate-fonts.ts +31 -0
- package/build/generate-katex-assets.ts +35 -0
- package/{webpack/generate-manifest-plugin.js → build/generate-manifest.ts} +29 -36
- package/build/globals.test.ts +99 -0
- package/build/globals.ts +88 -0
- package/{webpack/heading-subtitle-plugin.js → build/heading-subtitle-plugin.ts} +4 -2
- package/build/json-schema.test.ts +57 -0
- package/build/json-schema.ts +33 -0
- package/build/log.test.ts +111 -0
- package/build/log.ts +167 -0
- package/build/markdown-plugins.test.ts +396 -0
- package/{webpack/pagefind-plugin.test.js → build/pagefind.test.ts} +74 -13
- package/build/pagefind.ts +339 -0
- package/build/pdf-text.test.ts +52 -0
- package/{webpack/pdf-text.js → build/pdf-text.ts} +47 -27
- package/build/pipeline.ts +120 -0
- package/{webpack/reachability.test.js → build/reachability.test.ts} +3 -3
- package/{webpack/reachability.js → build/reachability.ts} +77 -34
- package/build/serve.ts +112 -0
- package/build/site-variables.ts +68 -0
- package/{webpack/templates.js → build/templates.ts} +38 -35
- package/{webpack/text-to-id.js → build/text-to-id.ts} +2 -2
- package/build/toc-plugin.test.ts +105 -0
- package/{webpack/toc-plugin.js → build/toc-plugin.ts} +32 -13
- package/build/types.ts +181 -0
- package/build/util.ts +26 -0
- package/{webpack/utils/code.js → build/utils/code.ts} +119 -60
- package/{webpack/utils/content-files.js → build/utils/content-files.ts} +40 -35
- package/build/utils/derive-theme.test.ts +111 -0
- package/build/utils/derive-theme.ts +85 -0
- package/build/utils/file-types.test.ts +61 -0
- package/build/utils/file-types.ts +13 -0
- package/build/utils/front-matter.test.ts +77 -0
- package/{webpack/utils/front-matter.js → build/utils/front-matter.ts} +22 -9
- package/{webpack → build}/utils/jdi-runner/LiterateRunner.java +1 -1
- package/{webpack/utils/literate-java.js → build/utils/literate-java.ts} +68 -38
- package/{webpack/utils/markdown.js → build/utils/markdown.ts} +126 -49
- package/build/utils/paths.test.ts +91 -0
- package/{webpack/utils/paths.js → build/utils/paths.ts} +18 -22
- package/{webpack/utils/render.js → build/utils/render.ts} +208 -130
- package/build/utils/shiki-highlighter.ts +29 -0
- package/build/validate-internal-links-plugin.test.ts +106 -0
- package/{webpack/validate-internal-links-plugin.js → build/validate-internal-links-plugin.ts} +47 -20
- package/{webpack/watch-reachability-state.test.js → build/watch-reachability-state.test.ts} +8 -8
- package/{webpack/watch-reachability-state.js → build/watch-reachability-state.ts} +63 -24
- package/{webpack/watch-reload-client.js → build/watch-reload-client.ts} +3 -1
- package/build/watch.ts +549 -0
- package/fonts/google-sans-code/woff2/GoogleSansCodeVariable-Italic.woff2 +0 -0
- package/fonts/google-sans-code/woff2/GoogleSansCodeVariable.woff2 +0 -0
- package/fonts/inter/woff2/InterVariable-Italic.woff2 +0 -0
- package/fonts/inter/woff2/InterVariable.woff2 +0 -0
- package/{content → init/content}/index.md +9 -3
- package/{content → init/content}/lectures/index.md +1 -1
- package/{content → init/content}/markdown.md +28 -8
- package/init/content/problem_sets/index.html +14 -0
- package/package.json +33 -22
- package/{templates → schema}/nav.schema.json +1 -2
- package/{webpack → schema}/site.schema.json +4 -12
- package/src/_alerts.scss +96 -0
- package/src/_base.scss +145 -0
- package/src/{layout.scss → _layout.scss} +36 -1
- package/src/anchor/index.ts +0 -1
- package/src/anchor/style.scss +1 -9
- package/src/code/index.ts +3 -3
- package/src/code.scss +1 -1
- package/src/critical.scss +5 -0
- package/src/header/_base.scss +129 -0
- package/src/header/style.scss +3 -131
- package/src/index.ts +4 -5
- package/src/question/style.scss +1 -1
- package/src/search/index.test.ts +203 -0
- package/src/search/index.ts +38 -93
- package/src/search/pdf-utils.ts +79 -0
- package/src/search/style.scss +8 -28
- package/src/style.scss +15 -326
- package/src/timezone/index.ts +5 -6
- package/src/toc/style.scss +5 -39
- package/src/util.ts +9 -6
- package/templates/_theme.scss +38 -14
- package/templates/_top.html +1 -1
- package/tsconfig.json +10 -6
- package/types/dev.d.ts +1 -0
- package/types/file-system-access.d.ts +5 -0
- package/types/markdown-it-plugins.d.ts +11 -0
- package/types/site-variables.d.ts +6 -10
- package/types/untyped-modules.d.ts +47 -0
- package/bin/tada.js +0 -361
- package/config/authors.json +0 -1
- package/config/nav.json +0 -28
- package/content/problem_sets/index.md +0 -6
- package/types/dev.ts +0 -3
- package/webpack/build-state.js +0 -97
- package/webpack/colors.js +0 -15
- package/webpack/config.base.js +0 -151
- package/webpack/config.dev.js +0 -23
- package/webpack/config.prod.js +0 -32
- package/webpack/content-watch-plugin.js +0 -153
- package/webpack/features.js +0 -5
- package/webpack/generate-content-assets-plugin.js +0 -308
- package/webpack/generate-favicon-plugin.js +0 -198
- package/webpack/generate-fonts-plugin.js +0 -69
- package/webpack/globals.js +0 -74
- package/webpack/json-schema.js +0 -19
- package/webpack/log.js +0 -143
- package/webpack/markdown-plugins.test.js +0 -203
- package/webpack/pagefind-plugin.js +0 -379
- package/webpack/print-flair-plugin.js +0 -22
- package/webpack/serve.js +0 -104
- package/webpack/site-variables.js +0 -53
- package/webpack/util.js +0 -49
- package/webpack/utils/define-plugin.js +0 -20
- package/webpack/utils/file-types.js +0 -26
- package/webpack/utils/parse-hsl.js +0 -8
- package/webpack/utils/shiki-highlighter.js +0 -26
- package/webpack/watch.js +0 -166
- /package/{webpack → build}/flair.json +0 -0
- /package/{webpack → build}/utils/jdi-runner/LiterateRunner.class +0 -0
- /package/fonts/google-sans-code/{GoogleSansCodeVariable-Italic.ttf → ttf/GoogleSansCodeVariable-Italic.ttf} +0 -0
- /package/fonts/google-sans-code/{GoogleSansCodeVariable.ttf → ttf/GoogleSansCodeVariable.ttf} +0 -0
- /package/fonts/inter/{InterVariable-Italic.ttf → ttf/InterVariable-Italic.ttf} +0 -0
- /package/fonts/inter/{InterVariable.ttf → ttf/InterVariable.ttf} +0 -0
- /package/{content → init/content}/lectures/01/Pair.java.md +0 -0
- /package/{content → init/content}/lectures/01/Rectangle.java +0 -0
- /package/{content → init/content}/lectures/01/demo.py +0 -0
- /package/{content → init/content}/lectures/01/index.md +0 -0
- /package/{content → init/content}/lectures/01/lecture1.pdf +0 -0
- /package/{public → init/public}/avatars/alex.jpg +0 -0
- /package/{public → init/public}/test.txt +0 -0
- /package/{templates → schema}/authors.schema.json +0 -0
package/src/header/style.scss
CHANGED
|
@@ -1,45 +1,5 @@
|
|
|
1
|
-
@
|
|
2
|
-
|
|
3
|
-
:root {
|
|
4
|
-
--header-collapsed-height: 45px;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
header {
|
|
8
|
-
backdrop-filter: var(--backdrop-filter);
|
|
9
|
-
background: var(--bg-color-translucent);
|
|
10
|
-
|
|
11
|
-
width: 100%;
|
|
12
|
-
position: fixed;
|
|
13
|
-
top: 0;
|
|
14
|
-
|
|
15
|
-
z-index: 1000;
|
|
16
|
-
|
|
17
|
-
display: grid;
|
|
18
|
-
grid-template-columns: 1fr;
|
|
19
|
-
|
|
20
|
-
view-transition-name: site-header;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
header details summary {
|
|
24
|
-
display: flex;
|
|
25
|
-
align-items: center;
|
|
26
|
-
height: var(--header-collapsed-height);
|
|
27
|
-
padding: 0;
|
|
28
|
-
list-style-type: none;
|
|
29
|
-
cursor: pointer;
|
|
30
|
-
position: relative;
|
|
31
|
-
container-type: inline-size;
|
|
32
|
-
|
|
33
|
-
@media (hover: hover) {
|
|
34
|
-
&:hover {
|
|
35
|
-
color: inherit;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
&:focus-visible {
|
|
40
|
-
outline-offset: -2px;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
1
|
+
@use '../mixins' as *;
|
|
2
|
+
@use 'base';
|
|
43
3
|
|
|
44
4
|
header nav {
|
|
45
5
|
display: flex;
|
|
@@ -99,28 +59,6 @@ header nav div > p {
|
|
|
99
59
|
padding-bottom: 2px;
|
|
100
60
|
}
|
|
101
61
|
|
|
102
|
-
header details summary::-webkit-details-marker {
|
|
103
|
-
display: none;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
header details summary::marker {
|
|
107
|
-
content: '';
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
header summary::after {
|
|
111
|
-
order: -1;
|
|
112
|
-
height: 24px;
|
|
113
|
-
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><polyline points='9 18 15 12 9 6' stroke='hsl(20deg 8% 8%)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
|
114
|
-
margin-left: var(--gap);
|
|
115
|
-
margin-right: 8px;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
@media (prefers-color-scheme: dark) {
|
|
119
|
-
header summary::after {
|
|
120
|
-
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><polyline points='9 18 15 12 9 6' stroke='hsl(20deg 20% 90%)' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
62
|
@media (hover: hover) {
|
|
125
63
|
header details summary:hover:not(:has(.results-container:hover)) {
|
|
126
64
|
background: var(--bg2-color);
|
|
@@ -131,74 +69,8 @@ header summary::after {
|
|
|
131
69
|
}
|
|
132
70
|
}
|
|
133
71
|
|
|
134
|
-
header details[open] summary::after {
|
|
135
|
-
transform: rotate(90deg);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
header .content {
|
|
139
|
-
position: relative;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
header summary .logo {
|
|
143
|
-
flex: 0 0 auto;
|
|
144
|
-
color: #fff;
|
|
145
|
-
background: var(--theme-color);
|
|
146
|
-
padding: 1px 6px;
|
|
147
|
-
margin: 0 8px 0 0;
|
|
148
|
-
font-size: var(--font-size-smaller);
|
|
149
|
-
font-weight: 600;
|
|
150
|
-
pointer-events: none;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
header summary .site-title {
|
|
154
|
-
flex: 1 1 0;
|
|
155
|
-
overflow: hidden;
|
|
156
|
-
text-overflow: ellipsis;
|
|
157
|
-
white-space: nowrap;
|
|
158
|
-
min-width: 0;
|
|
159
|
-
font-weight: 500;
|
|
160
|
-
pointer-events: none;
|
|
161
|
-
margin: 0 8px 0 0;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
@container (max-width: 520px) {
|
|
165
|
-
header summary .site-title {
|
|
166
|
-
display: none;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
header summary .to-top-container {
|
|
171
|
-
display: none;
|
|
172
|
-
align-items: center;
|
|
173
|
-
height: var(--header-collapsed-height);
|
|
174
|
-
pointer-events: none;
|
|
175
|
-
white-space: nowrap;
|
|
176
|
-
|
|
177
|
-
&:has(.is-visible) {
|
|
178
|
-
display: flex;
|
|
179
|
-
pointer-events: auto;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
header summary .search-controls {
|
|
184
|
-
margin-left: auto;
|
|
185
|
-
pointer-events: auto;
|
|
186
|
-
display: flex;
|
|
187
|
-
align-items: center;
|
|
188
|
-
height: var(--header-collapsed-height);
|
|
189
|
-
padding: 0 var(--gap);
|
|
190
|
-
min-width: 0;
|
|
191
|
-
|
|
192
|
-
input.search {
|
|
193
|
-
width: 180px;
|
|
194
|
-
|
|
195
|
-
@container (max-width: 280px) {
|
|
196
|
-
display: none;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
72
|
header .results-container {
|
|
73
|
+
display: block;
|
|
202
74
|
position: absolute;
|
|
203
75
|
top: var(--header-collapsed-height);
|
|
204
76
|
left: 0;
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import './style.scss';
|
|
2
|
-
import './layout.scss';
|
|
3
2
|
|
|
4
3
|
import './anchor/style.scss';
|
|
5
4
|
import './code.scss';
|
|
@@ -19,7 +18,7 @@ import mountPrint from './print';
|
|
|
19
18
|
import mountTop from './top';
|
|
20
19
|
import mountAnchor from './anchor';
|
|
21
20
|
import mountQuestion from './question';
|
|
22
|
-
import
|
|
21
|
+
import mountTimeZone from './timezone';
|
|
23
22
|
import mountCode from './code';
|
|
24
23
|
|
|
25
24
|
import { scheduleTask, formatDuration } from './util';
|
|
@@ -32,7 +31,7 @@ const COMPONENTS = {
|
|
|
32
31
|
top: mountTop,
|
|
33
32
|
anchor: mountAnchor,
|
|
34
33
|
question: mountQuestion,
|
|
35
|
-
|
|
34
|
+
timeZone: mountTimeZone,
|
|
36
35
|
code: mountCode,
|
|
37
36
|
};
|
|
38
37
|
|
|
@@ -43,7 +42,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
43
42
|
|
|
44
43
|
const entries = Object.entries(COMPONENTS);
|
|
45
44
|
|
|
46
|
-
const failed: Record<string,
|
|
45
|
+
const failed: Record<string, string> = {};
|
|
47
46
|
|
|
48
47
|
const mountPromises = entries.map(([name, mount]) => {
|
|
49
48
|
return new Promise<void>((resolve, reject) => {
|
|
@@ -66,7 +65,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
66
65
|
console.error(`Failed to mount ${name} component:`, reason);
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
if (
|
|
68
|
+
if (__IS_DEV__) {
|
|
70
69
|
const diff = window.performance.now() - startTime;
|
|
71
70
|
console.info(`Components mounted in ${formatDuration(diff)}`);
|
|
72
71
|
}
|
package/src/question/style.scss
CHANGED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { getPdfPageNumber, getPdfBaseUrl, groupPdfResults } from './pdf-utils';
|
|
3
|
+
import type { Result } from './pdf-utils';
|
|
4
|
+
|
|
5
|
+
describe('getPdfPageNumber', () => {
|
|
6
|
+
test('returns page number from meta string', () => {
|
|
7
|
+
expect(getPdfPageNumber('/doc.pdf#page=3', '3')).toBe(3);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('prefers meta over URL hash', () => {
|
|
11
|
+
expect(getPdfPageNumber('/doc.pdf#page=5', '2')).toBe(2);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('falls back to URL hash when meta is undefined', () => {
|
|
15
|
+
expect(getPdfPageNumber('/doc.pdf#page=7', undefined)).toBe(7);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('falls back to URL hash when meta is empty', () => {
|
|
19
|
+
expect(getPdfPageNumber('/doc.pdf#page=4', '')).toBe(4);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('falls back to URL hash when meta is not a number', () => {
|
|
23
|
+
expect(getPdfPageNumber('/doc.pdf#page=4', 'abc')).toBe(4);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('returns null when neither meta nor hash has a page', () => {
|
|
27
|
+
expect(getPdfPageNumber('/doc.pdf', undefined)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('returns null for non-positive meta page number', () => {
|
|
31
|
+
expect(getPdfPageNumber('/doc.pdf', '0')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('returns null for negative meta page number', () => {
|
|
35
|
+
expect(getPdfPageNumber('/doc.pdf', '-1')).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('returns null for hash page=0', () => {
|
|
39
|
+
expect(getPdfPageNumber('/doc.pdf#page=0', undefined)).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('parses page from hash with other params', () => {
|
|
43
|
+
expect(getPdfPageNumber('/doc.pdf#zoom=100&page=12', undefined)).toBe(12);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('handles case-insensitive page hash', () => {
|
|
47
|
+
expect(getPdfPageNumber('/doc.pdf#Page=9', undefined)).toBe(9);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('getPdfBaseUrl', () => {
|
|
52
|
+
test('returns base URL for a PDF with hash', () => {
|
|
53
|
+
expect(getPdfBaseUrl('/docs/guide.pdf#page=3')).toBe('/docs/guide.pdf');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('returns URL unchanged when no hash present', () => {
|
|
57
|
+
expect(getPdfBaseUrl('/docs/guide.pdf')).toBe('/docs/guide.pdf');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('returns null for non-PDF URL', () => {
|
|
61
|
+
expect(getPdfBaseUrl('/about/')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('returns null for non-PDF URL with hash', () => {
|
|
65
|
+
expect(getPdfBaseUrl('/about/#section')).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('handles .PDF extension case-insensitively', () => {
|
|
69
|
+
expect(getPdfBaseUrl('/docs/GUIDE.PDF#page=1')).toBe('/docs/GUIDE.PDF');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('groupPdfResults', () => {
|
|
74
|
+
function makeResult(overrides: Partial<Result>): Result {
|
|
75
|
+
return {
|
|
76
|
+
title: 'doc.pdf',
|
|
77
|
+
url: '/doc.pdf',
|
|
78
|
+
excerpt: '',
|
|
79
|
+
score: 1,
|
|
80
|
+
subResults: [],
|
|
81
|
+
pageNumber: null,
|
|
82
|
+
...overrides,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test('passes through non-PDF results unchanged', () => {
|
|
87
|
+
const results: Result[] = [
|
|
88
|
+
makeResult({ title: 'About', url: '/about/', score: 5 }),
|
|
89
|
+
makeResult({ title: 'Home', url: '/', score: 3 }),
|
|
90
|
+
];
|
|
91
|
+
const grouped = groupPdfResults(results);
|
|
92
|
+
expect(grouped).toEqual(results);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('groups multiple PDF page results into one result with sub-results', () => {
|
|
96
|
+
const results: Result[] = [
|
|
97
|
+
makeResult({
|
|
98
|
+
url: '/doc.pdf#page=3',
|
|
99
|
+
score: 10,
|
|
100
|
+
pageNumber: 3,
|
|
101
|
+
excerpt: 'page 3 text',
|
|
102
|
+
}),
|
|
103
|
+
makeResult({
|
|
104
|
+
url: '/doc.pdf#page=1',
|
|
105
|
+
score: 5,
|
|
106
|
+
pageNumber: 1,
|
|
107
|
+
excerpt: 'page 1 text',
|
|
108
|
+
}),
|
|
109
|
+
];
|
|
110
|
+
const grouped = groupPdfResults(results);
|
|
111
|
+
expect(grouped).toHaveLength(1);
|
|
112
|
+
expect(grouped[0].url).toBe('/doc.pdf');
|
|
113
|
+
expect(grouped[0].score).toBe(10);
|
|
114
|
+
expect(grouped[0].subResults).toEqual([
|
|
115
|
+
{ title: 'Page 1', url: '/doc.pdf#page=1', excerpt: 'page 1 text' },
|
|
116
|
+
{ title: 'Page 3', url: '/doc.pdf#page=3', excerpt: 'page 3 text' },
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('sub-results are sorted by page number', () => {
|
|
121
|
+
const results: Result[] = [
|
|
122
|
+
makeResult({ url: '/doc.pdf#page=5', score: 1, pageNumber: 5 }),
|
|
123
|
+
makeResult({ url: '/doc.pdf#page=2', score: 8, pageNumber: 2 }),
|
|
124
|
+
makeResult({ url: '/doc.pdf#page=9', score: 3, pageNumber: 9 }),
|
|
125
|
+
];
|
|
126
|
+
const grouped = groupPdfResults(results);
|
|
127
|
+
expect(grouped[0].subResults.map(s => s.title)).toEqual([
|
|
128
|
+
'Page 2',
|
|
129
|
+
'Page 5',
|
|
130
|
+
'Page 9',
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('primary result uses highest-scoring page', () => {
|
|
135
|
+
const results: Result[] = [
|
|
136
|
+
makeResult({
|
|
137
|
+
url: '/doc.pdf#page=1',
|
|
138
|
+
score: 2,
|
|
139
|
+
pageNumber: 1,
|
|
140
|
+
excerpt: 'low',
|
|
141
|
+
}),
|
|
142
|
+
makeResult({
|
|
143
|
+
url: '/doc.pdf#page=4',
|
|
144
|
+
score: 9,
|
|
145
|
+
pageNumber: 4,
|
|
146
|
+
excerpt: 'high',
|
|
147
|
+
}),
|
|
148
|
+
];
|
|
149
|
+
const grouped = groupPdfResults(results);
|
|
150
|
+
expect(grouped[0].excerpt).toBe('high');
|
|
151
|
+
expect(grouped[0].score).toBe(9);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('mixes non-PDF and grouped PDF results sorted by score', () => {
|
|
155
|
+
const results: Result[] = [
|
|
156
|
+
makeResult({
|
|
157
|
+
title: 'About',
|
|
158
|
+
url: '/about/',
|
|
159
|
+
score: 7,
|
|
160
|
+
pageNumber: null,
|
|
161
|
+
}),
|
|
162
|
+
makeResult({ url: '/doc.pdf#page=1', score: 10, pageNumber: 1 }),
|
|
163
|
+
makeResult({ url: '/doc.pdf#page=2', score: 3, pageNumber: 2 }),
|
|
164
|
+
];
|
|
165
|
+
const grouped = groupPdfResults(results);
|
|
166
|
+
expect(grouped).toHaveLength(2);
|
|
167
|
+
expect(grouped[0].url).toBe('/doc.pdf');
|
|
168
|
+
expect(grouped[0].score).toBe(10);
|
|
169
|
+
expect(grouped[1].url).toBe('/about/');
|
|
170
|
+
expect(grouped[1].score).toBe(7);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('does not group PDF result without a page number', () => {
|
|
174
|
+
const results: Result[] = [
|
|
175
|
+
makeResult({ url: '/doc.pdf', score: 5, pageNumber: null }),
|
|
176
|
+
];
|
|
177
|
+
const grouped = groupPdfResults(results);
|
|
178
|
+
expect(grouped).toHaveLength(1);
|
|
179
|
+
expect(grouped[0].url).toBe('/doc.pdf');
|
|
180
|
+
expect(grouped[0].subResults).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('groups results from different PDFs separately', () => {
|
|
184
|
+
const results: Result[] = [
|
|
185
|
+
makeResult({
|
|
186
|
+
title: 'a.pdf',
|
|
187
|
+
url: '/a.pdf#page=1',
|
|
188
|
+
score: 5,
|
|
189
|
+
pageNumber: 1,
|
|
190
|
+
}),
|
|
191
|
+
makeResult({
|
|
192
|
+
title: 'b.pdf',
|
|
193
|
+
url: '/b.pdf#page=1',
|
|
194
|
+
score: 8,
|
|
195
|
+
pageNumber: 1,
|
|
196
|
+
}),
|
|
197
|
+
];
|
|
198
|
+
const grouped = groupPdfResults(results);
|
|
199
|
+
expect(grouped).toHaveLength(2);
|
|
200
|
+
const urls = grouped.map(r => r.url).sort();
|
|
201
|
+
expect(urls).toEqual(['/a.pdf', '/b.pdf']);
|
|
202
|
+
});
|
|
203
|
+
});
|
package/src/search/index.ts
CHANGED
|
@@ -1,96 +1,42 @@
|
|
|
1
1
|
import { getElement, applyBasePath } from '../util';
|
|
2
|
+
import { getPdfPageNumber, groupPdfResults } from './pdf-utils';
|
|
3
|
+
import type { Result } from './pdf-utils';
|
|
2
4
|
|
|
3
5
|
const MAX_RESULTS = 24;
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
interface PagefindSubResult {
|
|
8
|
+
title?: string;
|
|
9
|
+
url: string;
|
|
10
|
+
excerpt?: string;
|
|
11
|
+
}
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
title: string;
|
|
13
|
+
interface PagefindResult {
|
|
14
|
+
meta?: { title?: string; page?: string };
|
|
11
15
|
url: string;
|
|
12
|
-
excerpt
|
|
16
|
+
excerpt?: string;
|
|
13
17
|
score: number;
|
|
14
|
-
|
|
15
|
-
pageNumber: number | null;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
type State = {
|
|
19
|
-
value: string;
|
|
20
|
-
showResults: boolean;
|
|
21
|
-
results: Result[];
|
|
22
|
-
totalResults: number;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
function getPdfPageNumber(
|
|
26
|
-
url: string,
|
|
27
|
-
pageMeta: string | undefined,
|
|
28
|
-
): number | null {
|
|
29
|
-
const fromMeta = Number.parseInt(pageMeta ?? '', 10);
|
|
30
|
-
if (Number.isInteger(fromMeta) && fromMeta > 0) {
|
|
31
|
-
return fromMeta;
|
|
32
|
-
}
|
|
33
|
-
const match = url.match(/#(?:.*&)?page=(\d+)\b/i);
|
|
34
|
-
if (!match) {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
const fromUrl = Number.parseInt(match[1], 10);
|
|
38
|
-
return Number.isInteger(fromUrl) && fromUrl >= 1 ? fromUrl : null;
|
|
18
|
+
sub_results?: PagefindSubResult[];
|
|
39
19
|
}
|
|
40
20
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
|
44
|
-
return baseUrl.toLowerCase().endsWith('.pdf') ? baseUrl : null;
|
|
21
|
+
interface PagefindSearchResult {
|
|
22
|
+
data(): Promise<PagefindResult>;
|
|
45
23
|
}
|
|
46
24
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
for (const result of results) {
|
|
52
|
-
const baseUrl = getPdfBaseUrl(result.url);
|
|
53
|
-
if (!baseUrl || result.pageNumber == null) {
|
|
54
|
-
other.push(result);
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
if (!pdfGroups.has(baseUrl)) {
|
|
58
|
-
pdfGroups.set(baseUrl, []);
|
|
59
|
-
}
|
|
60
|
-
pdfGroups.get(baseUrl)!.push(result);
|
|
61
|
-
}
|
|
25
|
+
interface Pagefind {
|
|
26
|
+
init(): Promise<void>;
|
|
27
|
+
search(query: string): Promise<{ results: PagefindSearchResult[] }>;
|
|
28
|
+
}
|
|
62
29
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
pdfGroups.forEach((pages, baseUrl) => {
|
|
66
|
-
const primary = pages
|
|
67
|
-
.slice()
|
|
68
|
-
.sort(
|
|
69
|
-
(a, b) =>
|
|
70
|
-
b.score - a.score || (a.pageNumber ?? 0) - (b.pageNumber ?? 0),
|
|
71
|
-
)[0];
|
|
72
|
-
const subResults = pages
|
|
73
|
-
.slice()
|
|
74
|
-
.sort((a, b) => (a.pageNumber ?? 0) - (b.pageNumber ?? 0))
|
|
75
|
-
.map(page => ({
|
|
76
|
-
title: `Page ${page.pageNumber ?? '?'}`,
|
|
77
|
-
url: page.url,
|
|
78
|
-
excerpt: page.excerpt,
|
|
79
|
-
}));
|
|
30
|
+
let pagefind: Pagefind | null = null;
|
|
80
31
|
|
|
81
|
-
|
|
82
|
-
title: primary.title,
|
|
83
|
-
url: baseUrl,
|
|
84
|
-
excerpt: primary.excerpt,
|
|
85
|
-
score: primary.score,
|
|
86
|
-
subResults,
|
|
87
|
-
pageNumber: null,
|
|
88
|
-
});
|
|
89
|
-
});
|
|
32
|
+
type SubResult = { title: string; url: string; excerpt: string };
|
|
90
33
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
34
|
+
type State = {
|
|
35
|
+
value: string;
|
|
36
|
+
showResults: boolean;
|
|
37
|
+
results: Result[];
|
|
38
|
+
totalResults: number;
|
|
39
|
+
};
|
|
94
40
|
|
|
95
41
|
async function doSearch(state: State) {
|
|
96
42
|
if (pagefind == null) {
|
|
@@ -105,27 +51,23 @@ async function doSearch(state: State) {
|
|
|
105
51
|
|
|
106
52
|
const search = await pagefind.search(state.value);
|
|
107
53
|
const slice = search.results.slice(0, MAX_RESULTS);
|
|
108
|
-
|
|
109
|
-
const data = await Promise.all(slice.map((r: any) => r.data()));
|
|
54
|
+
const data = await Promise.all(slice.map(r => r.data()));
|
|
110
55
|
|
|
111
|
-
const titlePostfix =
|
|
112
|
-
|
|
113
|
-
const results: Result[] = data.map((d: any) => {
|
|
56
|
+
const titlePostfix = __SITE_TITLE_POSTFIX__;
|
|
57
|
+
const results: Result[] = data.map((d: PagefindResult) => {
|
|
114
58
|
let title: string = d.meta?.title ?? d.url;
|
|
115
59
|
if (titlePostfix && title.endsWith(titlePostfix)) {
|
|
116
60
|
title = title.slice(0, -titlePostfix.length);
|
|
117
61
|
}
|
|
118
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
62
|
const subResults: SubResult[] = (d.sub_results ?? [])
|
|
120
|
-
.filter((s:
|
|
63
|
+
.filter((s: PagefindSubResult) => {
|
|
121
64
|
try {
|
|
122
65
|
return new URL(s.url, window.location.href).hash !== '';
|
|
123
66
|
} catch {
|
|
124
67
|
return s.url !== d.url;
|
|
125
68
|
}
|
|
126
69
|
})
|
|
127
|
-
|
|
128
|
-
.map((s: any) => ({
|
|
70
|
+
.map((s: PagefindSubResult) => ({
|
|
129
71
|
title: s.title ?? '',
|
|
130
72
|
url: s.url,
|
|
131
73
|
excerpt: s.excerpt ?? '',
|
|
@@ -133,7 +75,7 @@ async function doSearch(state: State) {
|
|
|
133
75
|
return {
|
|
134
76
|
title,
|
|
135
77
|
url: d.url,
|
|
136
|
-
excerpt: d.excerpt,
|
|
78
|
+
excerpt: d.excerpt ?? '',
|
|
137
79
|
score: d.score ?? 0,
|
|
138
80
|
subResults,
|
|
139
81
|
pageNumber: getPdfPageNumber(d.url, d.meta?.page),
|
|
@@ -324,14 +266,17 @@ export default (window: Window) => {
|
|
|
324
266
|
if (pagefind) {
|
|
325
267
|
return;
|
|
326
268
|
}
|
|
327
|
-
|
|
328
|
-
pagefind = await import(
|
|
329
|
-
|
|
330
|
-
);
|
|
269
|
+
|
|
270
|
+
pagefind = (await import(
|
|
271
|
+
applyBasePath('/pagefind/pagefind.js')
|
|
272
|
+
)) as Pagefind;
|
|
273
|
+
|
|
331
274
|
await pagefind.init();
|
|
275
|
+
|
|
332
276
|
const res = await fetch(applyBasePath('/pagefind/pagefind-entry.json'), {
|
|
333
277
|
cache: 'no-cache',
|
|
334
278
|
});
|
|
279
|
+
|
|
335
280
|
if (res.ok) {
|
|
336
281
|
entryETag = res.headers.get('ETag');
|
|
337
282
|
entryLastModified = res.headers.get('Last-Modified');
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export type SubResult = { title: string; url: string; excerpt: string };
|
|
2
|
+
export type Result = {
|
|
3
|
+
title: string;
|
|
4
|
+
url: string;
|
|
5
|
+
excerpt: string;
|
|
6
|
+
score: number;
|
|
7
|
+
subResults: SubResult[];
|
|
8
|
+
pageNumber: number | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function getPdfPageNumber(
|
|
12
|
+
url: string,
|
|
13
|
+
pageMeta: string | undefined,
|
|
14
|
+
): number | null {
|
|
15
|
+
const fromMeta = Number.parseInt(pageMeta ?? '', 10);
|
|
16
|
+
if (Number.isInteger(fromMeta) && fromMeta > 0) {
|
|
17
|
+
return fromMeta;
|
|
18
|
+
}
|
|
19
|
+
const match = url.match(/#(?:.*&)?page=(\d+)\b/i);
|
|
20
|
+
if (!match) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const fromUrl = Number.parseInt(match[1], 10);
|
|
24
|
+
return Number.isInteger(fromUrl) && fromUrl >= 1 ? fromUrl : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getPdfBaseUrl(url: string): string | null {
|
|
28
|
+
const hashIndex = url.indexOf('#');
|
|
29
|
+
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
|
30
|
+
return baseUrl.toLowerCase().endsWith('.pdf') ? baseUrl : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function groupPdfResults(results: Result[]): Result[] {
|
|
34
|
+
const pdfGroups = new Map<string, Result[]>();
|
|
35
|
+
const other: Result[] = [];
|
|
36
|
+
|
|
37
|
+
for (const result of results) {
|
|
38
|
+
const baseUrl = getPdfBaseUrl(result.url);
|
|
39
|
+
if (!baseUrl || result.pageNumber == null) {
|
|
40
|
+
other.push(result);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!pdfGroups.has(baseUrl)) {
|
|
44
|
+
pdfGroups.set(baseUrl, []);
|
|
45
|
+
}
|
|
46
|
+
pdfGroups.get(baseUrl)!.push(result);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const grouped: Result[] = [...other];
|
|
50
|
+
|
|
51
|
+
pdfGroups.forEach((pages, baseUrl) => {
|
|
52
|
+
const primary = pages
|
|
53
|
+
.slice()
|
|
54
|
+
.sort(
|
|
55
|
+
(a, b) =>
|
|
56
|
+
b.score - a.score || (a.pageNumber ?? 0) - (b.pageNumber ?? 0),
|
|
57
|
+
)[0];
|
|
58
|
+
const subResults = pages
|
|
59
|
+
.slice()
|
|
60
|
+
.sort((a, b) => (a.pageNumber ?? 0) - (b.pageNumber ?? 0))
|
|
61
|
+
.map(page => ({
|
|
62
|
+
title: `Page ${page.pageNumber ?? '?'}`,
|
|
63
|
+
url: page.url,
|
|
64
|
+
excerpt: page.excerpt,
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
grouped.push({
|
|
68
|
+
title: primary.title,
|
|
69
|
+
url: baseUrl,
|
|
70
|
+
excerpt: primary.excerpt,
|
|
71
|
+
score: primary.score,
|
|
72
|
+
subResults,
|
|
73
|
+
pageNumber: null,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
grouped.sort((a, b) => b.score - a.score);
|
|
78
|
+
return grouped;
|
|
79
|
+
}
|