@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.
Files changed (146) hide show
  1. package/README.md +96 -48
  2. package/bin/tada.ts +621 -0
  3. package/bin/validators.test.ts +205 -0
  4. package/bin/validators.ts +85 -0
  5. package/{webpack/apply-base-path-plugin.js → build/apply-base-path-plugin.ts} +37 -17
  6. package/build/build-manifest.test.ts +349 -0
  7. package/build/build-manifest.ts +165 -0
  8. package/build/bundle.ts +116 -0
  9. package/{webpack/code.test.js → build/code.test.ts} +10 -7
  10. package/build/colors.ts +25 -0
  11. package/build/content-watch.ts +107 -0
  12. package/build/copy.ts +118 -0
  13. package/{webpack/deflist-id-plugin.js → build/deflist-id-plugin.ts} +7 -6
  14. package/{webpack/external-links-plugin.js → build/external-links-plugin.ts} +14 -5
  15. package/build/features.ts +11 -0
  16. package/build/generate-content-assets.ts +320 -0
  17. package/build/generate-favicon.ts +172 -0
  18. package/build/generate-fonts.ts +31 -0
  19. package/build/generate-katex-assets.ts +35 -0
  20. package/{webpack/generate-manifest-plugin.js → build/generate-manifest.ts} +29 -36
  21. package/build/globals.test.ts +99 -0
  22. package/build/globals.ts +88 -0
  23. package/{webpack/heading-subtitle-plugin.js → build/heading-subtitle-plugin.ts} +4 -2
  24. package/build/json-schema.test.ts +57 -0
  25. package/build/json-schema.ts +33 -0
  26. package/build/log.test.ts +111 -0
  27. package/build/log.ts +167 -0
  28. package/build/markdown-plugins.test.ts +396 -0
  29. package/{webpack/pagefind-plugin.test.js → build/pagefind.test.ts} +74 -13
  30. package/build/pagefind.ts +339 -0
  31. package/build/pdf-text.test.ts +52 -0
  32. package/{webpack/pdf-text.js → build/pdf-text.ts} +47 -27
  33. package/build/pipeline.ts +120 -0
  34. package/{webpack/reachability.test.js → build/reachability.test.ts} +3 -3
  35. package/{webpack/reachability.js → build/reachability.ts} +77 -34
  36. package/build/serve.ts +112 -0
  37. package/build/site-variables.ts +68 -0
  38. package/{webpack/templates.js → build/templates.ts} +38 -35
  39. package/{webpack/text-to-id.js → build/text-to-id.ts} +2 -2
  40. package/build/toc-plugin.test.ts +105 -0
  41. package/{webpack/toc-plugin.js → build/toc-plugin.ts} +32 -13
  42. package/build/types.ts +181 -0
  43. package/build/util.ts +26 -0
  44. package/{webpack/utils/code.js → build/utils/code.ts} +119 -60
  45. package/{webpack/utils/content-files.js → build/utils/content-files.ts} +40 -35
  46. package/build/utils/derive-theme.test.ts +111 -0
  47. package/build/utils/derive-theme.ts +85 -0
  48. package/build/utils/file-types.test.ts +61 -0
  49. package/build/utils/file-types.ts +13 -0
  50. package/build/utils/front-matter.test.ts +77 -0
  51. package/{webpack/utils/front-matter.js → build/utils/front-matter.ts} +22 -9
  52. package/{webpack → build}/utils/jdi-runner/LiterateRunner.java +1 -1
  53. package/{webpack/utils/literate-java.js → build/utils/literate-java.ts} +68 -38
  54. package/{webpack/utils/markdown.js → build/utils/markdown.ts} +126 -49
  55. package/build/utils/paths.test.ts +91 -0
  56. package/{webpack/utils/paths.js → build/utils/paths.ts} +18 -22
  57. package/{webpack/utils/render.js → build/utils/render.ts} +208 -130
  58. package/build/utils/shiki-highlighter.ts +29 -0
  59. package/build/validate-internal-links-plugin.test.ts +106 -0
  60. package/{webpack/validate-internal-links-plugin.js → build/validate-internal-links-plugin.ts} +47 -20
  61. package/{webpack/watch-reachability-state.test.js → build/watch-reachability-state.test.ts} +8 -8
  62. package/{webpack/watch-reachability-state.js → build/watch-reachability-state.ts} +63 -24
  63. package/{webpack/watch-reload-client.js → build/watch-reload-client.ts} +3 -1
  64. package/build/watch.ts +549 -0
  65. package/fonts/google-sans-code/woff2/GoogleSansCodeVariable-Italic.woff2 +0 -0
  66. package/fonts/google-sans-code/woff2/GoogleSansCodeVariable.woff2 +0 -0
  67. package/fonts/inter/woff2/InterVariable-Italic.woff2 +0 -0
  68. package/fonts/inter/woff2/InterVariable.woff2 +0 -0
  69. package/{content → init/content}/index.md +9 -3
  70. package/{content → init/content}/lectures/index.md +1 -1
  71. package/{content → init/content}/markdown.md +28 -8
  72. package/init/content/problem_sets/index.html +14 -0
  73. package/package.json +33 -22
  74. package/{templates → schema}/nav.schema.json +1 -2
  75. package/{webpack → schema}/site.schema.json +4 -12
  76. package/src/_alerts.scss +96 -0
  77. package/src/_base.scss +145 -0
  78. package/src/{layout.scss → _layout.scss} +36 -1
  79. package/src/anchor/index.ts +0 -1
  80. package/src/anchor/style.scss +1 -9
  81. package/src/code/index.ts +3 -3
  82. package/src/code.scss +1 -1
  83. package/src/critical.scss +5 -0
  84. package/src/header/_base.scss +129 -0
  85. package/src/header/style.scss +3 -131
  86. package/src/index.ts +4 -5
  87. package/src/question/style.scss +1 -1
  88. package/src/search/index.test.ts +203 -0
  89. package/src/search/index.ts +38 -93
  90. package/src/search/pdf-utils.ts +79 -0
  91. package/src/search/style.scss +8 -28
  92. package/src/style.scss +15 -326
  93. package/src/timezone/index.ts +5 -6
  94. package/src/toc/style.scss +5 -39
  95. package/src/util.ts +9 -6
  96. package/templates/_theme.scss +38 -14
  97. package/templates/_top.html +1 -1
  98. package/tsconfig.json +10 -6
  99. package/types/dev.d.ts +1 -0
  100. package/types/file-system-access.d.ts +5 -0
  101. package/types/markdown-it-plugins.d.ts +11 -0
  102. package/types/site-variables.d.ts +6 -10
  103. package/types/untyped-modules.d.ts +47 -0
  104. package/bin/tada.js +0 -361
  105. package/config/authors.json +0 -1
  106. package/config/nav.json +0 -28
  107. package/content/problem_sets/index.md +0 -6
  108. package/types/dev.ts +0 -3
  109. package/webpack/build-state.js +0 -97
  110. package/webpack/colors.js +0 -15
  111. package/webpack/config.base.js +0 -151
  112. package/webpack/config.dev.js +0 -23
  113. package/webpack/config.prod.js +0 -32
  114. package/webpack/content-watch-plugin.js +0 -153
  115. package/webpack/features.js +0 -5
  116. package/webpack/generate-content-assets-plugin.js +0 -308
  117. package/webpack/generate-favicon-plugin.js +0 -198
  118. package/webpack/generate-fonts-plugin.js +0 -69
  119. package/webpack/globals.js +0 -74
  120. package/webpack/json-schema.js +0 -19
  121. package/webpack/log.js +0 -143
  122. package/webpack/markdown-plugins.test.js +0 -203
  123. package/webpack/pagefind-plugin.js +0 -379
  124. package/webpack/print-flair-plugin.js +0 -22
  125. package/webpack/serve.js +0 -104
  126. package/webpack/site-variables.js +0 -53
  127. package/webpack/util.js +0 -49
  128. package/webpack/utils/define-plugin.js +0 -20
  129. package/webpack/utils/file-types.js +0 -26
  130. package/webpack/utils/parse-hsl.js +0 -8
  131. package/webpack/utils/shiki-highlighter.js +0 -26
  132. package/webpack/watch.js +0 -166
  133. /package/{webpack → build}/flair.json +0 -0
  134. /package/{webpack → build}/utils/jdi-runner/LiterateRunner.class +0 -0
  135. /package/fonts/google-sans-code/{GoogleSansCodeVariable-Italic.ttf → ttf/GoogleSansCodeVariable-Italic.ttf} +0 -0
  136. /package/fonts/google-sans-code/{GoogleSansCodeVariable.ttf → ttf/GoogleSansCodeVariable.ttf} +0 -0
  137. /package/fonts/inter/{InterVariable-Italic.ttf → ttf/InterVariable-Italic.ttf} +0 -0
  138. /package/fonts/inter/{InterVariable.ttf → ttf/InterVariable.ttf} +0 -0
  139. /package/{content → init/content}/lectures/01/Pair.java.md +0 -0
  140. /package/{content → init/content}/lectures/01/Rectangle.java +0 -0
  141. /package/{content → init/content}/lectures/01/demo.py +0 -0
  142. /package/{content → init/content}/lectures/01/index.md +0 -0
  143. /package/{content → init/content}/lectures/01/lecture1.pdf +0 -0
  144. /package/{public → init/public}/avatars/alex.jpg +0 -0
  145. /package/{public → init/public}/test.txt +0 -0
  146. /package/{templates → schema}/authors.schema.json +0 -0
@@ -1,45 +1,5 @@
1
- @import '../_mixins.scss';
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 mountTimezone from './timezone';
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
- timezone: mountTimezone,
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, any> = {};
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 (window.IS_DEV) {
68
+ if (__IS_DEV__) {
70
69
  const diff = window.performance.now() - startTime;
71
70
  console.info(`Components mounted in ${formatDuration(diff)}`);
72
71
  }
@@ -1,4 +1,4 @@
1
- @import '../_mixins.scss';
1
+ @use '../mixins' as *;
2
2
 
3
3
  .question {
4
4
  margin-bottom: 2rem;
@@ -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
+ });
@@ -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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- let pagefind: any = null;
7
+ interface PagefindSubResult {
8
+ title?: string;
9
+ url: string;
10
+ excerpt?: string;
11
+ }
7
12
 
8
- type SubResult = { title: string; url: string; excerpt: string };
9
- type Result = {
10
- title: string;
13
+ interface PagefindResult {
14
+ meta?: { title?: string; page?: string };
11
15
  url: string;
12
- excerpt: string;
16
+ excerpt?: string;
13
17
  score: number;
14
- subResults: SubResult[];
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
- function getPdfBaseUrl(url: string): string | null {
42
- const hashIndex = url.indexOf('#');
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
- function groupPdfResults(results: Result[]): Result[] {
48
- const pdfGroups = new Map<string, Result[]>();
49
- const other: Result[] = [];
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
- const grouped: Result[] = [...other];
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
- grouped.push({
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
- grouped.sort((a, b) => b.score - a.score);
92
- return grouped;
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
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 = window.siteVariables.titlePostfix ?? '';
112
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
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: any) => {
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
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
- // @ts-ignore pagefind.js is generated post-build, not resolvable at compile time
328
- pagefind = await import(
329
- /* webpackIgnore: true */ applyBasePath('/pagefind/pagefind.js')
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
+ }