@abreen/tada 1.15.8 → 1.15.10

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.
@@ -130,6 +130,14 @@ function getLocationKey(window: Window): string {
130
130
  );
131
131
  }
132
132
 
133
+ function isAbortError(err: unknown): boolean {
134
+ return err instanceof Error && err.name === 'AbortError';
135
+ }
136
+
137
+ function isActiveNavigation(controller: AbortController): boolean {
138
+ return currentAbortController === controller && !controller.signal.aborted;
139
+ }
140
+
133
141
  export function initNavigation(window: Window): void {
134
142
  currentAbortController = null;
135
143
  historyIndex = 0;
@@ -191,6 +199,8 @@ export async function navigateToUrl(
191
199
 
192
200
  const controller = new AbortController();
193
201
  currentAbortController = controller;
202
+ document.documentElement.classList.remove('nav-forward', 'nav-back');
203
+ cleanupViewTransitionNames(document);
194
204
 
195
205
  const header = document.querySelector('header');
196
206
  header?.classList.add('loading');
@@ -199,21 +209,46 @@ export async function navigateToUrl(
199
209
  try {
200
210
  response = await globals.fetch(url, { signal: controller.signal });
201
211
  } catch (err: unknown) {
212
+ if (!isActiveNavigation(controller)) {
213
+ return;
214
+ }
202
215
  header?.classList.remove('loading');
203
- if (err instanceof Error && err.name === 'AbortError') {
216
+ if (isAbortError(err)) {
204
217
  return;
205
218
  }
206
219
  globals.setLocationHref(window, url);
207
220
  return;
208
221
  }
209
222
 
223
+ if (!isActiveNavigation(controller)) {
224
+ return;
225
+ }
226
+
210
227
  if (!response.ok) {
211
228
  header?.classList.remove('loading');
212
229
  globals.setLocationHref(window, url);
213
230
  return;
214
231
  }
215
232
 
216
- const html = await response.text();
233
+ let html: string;
234
+ try {
235
+ html = await response.text();
236
+ } catch (err: unknown) {
237
+ if (!isActiveNavigation(controller)) {
238
+ return;
239
+ }
240
+ header?.classList.remove('loading');
241
+ if (isAbortError(err)) {
242
+ return;
243
+ }
244
+ globals.setLocationHref(window, url);
245
+ return;
246
+ }
247
+
248
+ if (!isActiveNavigation(controller)) {
249
+ return;
250
+ }
251
+
217
252
  header?.classList.remove('loading');
218
253
 
219
254
  const DOMParserCtor = (window as unknown as { DOMParser: typeof DOMParser })
@@ -233,7 +268,11 @@ export async function navigateToUrl(
233
268
 
234
269
  const parsed = new URL(url);
235
270
 
236
- const doSwap = () => {
271
+ const doSwap = (): boolean => {
272
+ if (!isActiveNavigation(controller)) {
273
+ return false;
274
+ }
275
+
237
276
  teardownPerPageComponents();
238
277
  swapContent(document, newDoc);
239
278
  updateHead(document, newDoc);
@@ -257,6 +296,7 @@ export async function navigateToUrl(
257
296
 
258
297
  scrollByIndex.set(historyIndex, window.scrollY);
259
298
  scrollByLocation.set(getLocationKey(window), window.scrollY);
299
+ return true;
260
300
  };
261
301
 
262
302
  const transitionDoc = document as Document & {
@@ -295,7 +335,9 @@ export async function navigateToUrl(
295
335
  );
296
336
 
297
337
  const transition = transitionDoc.startViewTransition(() => {
298
- doSwap();
338
+ if (!doSwap()) {
339
+ return;
340
+ }
299
341
 
300
342
  const newTitleEl = document.querySelector('.title-and-info');
301
343
  const newHeaderHeight =
@@ -323,13 +365,23 @@ export async function navigateToUrl(
323
365
  });
324
366
 
325
367
  await transition.finished;
368
+ if (!isActiveNavigation(controller)) {
369
+ return;
370
+ }
371
+
326
372
  document.documentElement.classList.remove('nav-forward', 'nav-back');
327
373
  cleanupViewTransitionNames(document);
328
374
  } else {
329
- doSwap();
375
+ if (!doSwap()) {
376
+ return;
377
+ }
330
378
  }
331
379
 
332
380
  await mountPerPageComponents(window);
381
+ if (!isActiveNavigation(controller)) {
382
+ return;
383
+ }
384
+
333
385
  currentAbortController = null;
334
386
  dispatchNavigationEvent(window);
335
387
  }
@@ -10,8 +10,9 @@ import { NAVIGATION_EVENT, refreshCurrentPage } from '../navigate/runtime';
10
10
  import { globals } from '../globals';
11
11
 
12
12
  const POLL_INTERVAL_MS = 600_000;
13
+ export const PAGE_UPDATE_REFRESH_EVENT = 'tada:page-update-refresh';
13
14
 
14
- export interface PageUpdateOptions {
15
+ interface PageUpdateOptions {
15
16
  pollIntervalMs?: number;
16
17
  refreshPage?: (window: Window) => Promise<void>;
17
18
  }
@@ -247,7 +248,10 @@ export function mountPageUpdate(
247
248
  }
248
249
 
249
250
  function handleReloadClick() {
250
- void refreshPage(window);
251
+ void refreshPage(window).then(() => {
252
+ const EventCtor = (window as unknown as { Event: typeof Event }).Event;
253
+ window.dispatchEvent(new EventCtor(PAGE_UPDATE_REFRESH_EVENT));
254
+ });
251
255
  }
252
256
 
253
257
  reloadButton.addEventListener('click', handleReloadClick);
@@ -1,15 +1,9 @@
1
1
  import { getElement, applyBasePath } from '../util';
2
2
  import { getPdfPageNumber, groupPdfResults } from './pdf-utils';
3
3
  import type { Result } from './pdf-utils';
4
- import {
5
- getResponseValidators,
6
- hasResponseValidatorsChanged,
7
- type ResponseValidators,
8
- } from '../validators';
4
+ import { PAGE_UPDATE_REFRESH_EVENT } from '../page-update';
9
5
  import { globals } from '../globals';
10
6
 
11
- const MAX_RESULTS = 24;
12
-
13
7
  interface PagefindSubResult {
14
8
  title?: string;
15
9
  url: string;
@@ -34,17 +28,14 @@ interface PagefindSearchResult {
34
28
  }
35
29
 
36
30
  interface Pagefind {
31
+ destroy(): Promise<void>;
37
32
  init(): Promise<void>;
33
+ options(options: { basePath: string }): Promise<void>;
38
34
  search(query: string): Promise<{ results: PagefindSearchResult[] }>;
39
35
  }
40
36
 
41
37
  let pagefind: Pagefind | null = null;
42
38
 
43
- // Unit tests need to clear the module-scoped Pagefind instance between cases.
44
- export function resetPagefindForTest() {
45
- pagefind = null;
46
- }
47
-
48
39
  type SubResult = { title: string; url: string; excerpt: string };
49
40
 
50
41
  type State = {
@@ -66,8 +57,7 @@ async function doSearch(query: string, window: Window): Promise<SearchState> {
66
57
  }
67
58
 
68
59
  const search = await pagefind.search(query);
69
- const slice = search.results.slice(0, MAX_RESULTS);
70
- const data = await Promise.all(slice.map(r => r.data()));
60
+ const data = await Promise.all(search.results.map(r => r.data()));
71
61
 
72
62
  const titlePostfix = __SITE_TITLE_POSTFIX__;
73
63
  const results: Result[] = data.map((d: PagefindResult) => {
@@ -144,9 +134,9 @@ function render(
144
134
  ol.tabIndex = -1;
145
135
  ol.setAttribute('aria-label', 'Search results');
146
136
 
147
- const totalVisible = Math.min(state.results.length, MAX_RESULTS);
137
+ const totalVisible = state.results.length;
148
138
 
149
- state.results.slice(0, MAX_RESULTS).forEach((result, i) => {
139
+ state.results.forEach((result, i) => {
150
140
  const a = doc.createElement('a');
151
141
  a.id = `result-${i}`;
152
142
  a.className = 'result';
@@ -180,7 +170,7 @@ function render(
180
170
  li.setAttribute('aria-labelledby', `title-${i}`);
181
171
  li.appendChild(a);
182
172
 
183
- const subsToShow = result.subResults.slice(0, 5);
173
+ const subsToShow = result.subResults;
184
174
  if (subsToShow.length > 0) {
185
175
  const subList = doc.createElement('ul');
186
176
  subList.className = 'sub-results';
@@ -255,10 +245,8 @@ function render(
255
245
  countSpan.textContent = 'No results';
256
246
  } else if (n === 1) {
257
247
  countSpan.textContent = 'One result';
258
- } else if (n <= MAX_RESULTS) {
259
- countSpan.textContent = `${n} results`;
260
248
  } else {
261
- countSpan.textContent = `Showing first ${MAX_RESULTS} results`;
249
+ countSpan.textContent = `${n} results`;
262
250
  }
263
251
  }
264
252
 
@@ -291,64 +279,56 @@ export default (window: Window) => {
291
279
  // Unhide (hidden via inline style in template to prevent FOUC)
292
280
  resultsDiv.style.display = '';
293
281
 
294
- let entryValidators: ResponseValidators = { etag: null, lastModified: null };
295
- let lastIndexCheck = 0;
296
- let indexCheckInFlight = false;
297
-
298
- async function loadPagefind() {
299
- if (pagefind) {
300
- return;
301
- }
302
-
303
- pagefind = (await globals.importModule(
304
- applyBasePath('/pagefind/pagefind.js'),
305
- )) as Pagefind;
306
-
307
- await pagefind.init();
308
-
309
- const res = await globals.fetch(
310
- applyBasePath('/pagefind/pagefind-entry.json'),
311
- { cache: 'no-cache' },
312
- );
282
+ let pagefindLoadPromise: Promise<boolean> | null = null;
283
+ let pagefindLoadGeneration = 0;
313
284
 
314
- if (res.ok) {
315
- entryValidators = getResponseValidators(res);
285
+ function pagefindModulePath(generation: number) {
286
+ const path = applyBasePath('/pagefind/pagefind.js');
287
+ if (generation === 0) {
288
+ return path;
316
289
  }
290
+ return `${path}?tada-pagefind-refresh=${generation}`;
317
291
  }
318
292
 
319
- async function checkForIndexUpdate() {
320
- if (indexCheckInFlight || globals.now() - lastIndexCheck < 3000) {
321
- return;
293
+ async function loadPagefind(): Promise<boolean> {
294
+ if (pagefind) {
295
+ return true;
322
296
  }
323
- lastIndexCheck = globals.now();
324
- indexCheckInFlight = true;
325
- try {
326
- const res = await globals.fetch(
327
- applyBasePath('/pagefind/pagefind-entry.json'),
328
- { method: 'HEAD', cache: 'no-cache' },
329
- );
330
- if (!res.ok) {
331
- return;
297
+
298
+ const generation = pagefindLoadGeneration;
299
+ pagefindLoadPromise ??= (async () => {
300
+ const loadedPagefind = (await globals.importModule(
301
+ pagefindModulePath(generation),
302
+ )) as Pagefind;
303
+
304
+ if (generation !== pagefindLoadGeneration) {
305
+ return false;
306
+ }
307
+ if (generation > 0) {
308
+ await loadedPagefind.options({ basePath: applyBasePath('/pagefind/') });
309
+ }
310
+ if (generation !== pagefindLoadGeneration) {
311
+ return false;
332
312
  }
333
- const newValidators = getResponseValidators(res);
334
- if (hasResponseValidatorsChanged(entryValidators, newValidators)) {
335
- invalidateUpdates();
336
- pagefind = null;
337
- await loadPagefind();
338
- const updateId = ++latestUpdateId;
339
- await update(updateId);
313
+ await loadedPagefind.init();
314
+ if (generation !== pagefindLoadGeneration) {
315
+ await loadedPagefind.destroy();
316
+ return false;
340
317
  }
341
- } catch {
342
- // best-effort
318
+ pagefind = loadedPagefind;
319
+ return true;
320
+ })();
321
+
322
+ const promise = pagefindLoadPromise;
323
+ try {
324
+ return await promise;
343
325
  } finally {
344
- indexCheckInFlight = false;
326
+ if (pagefindLoadPromise === promise) {
327
+ pagefindLoadPromise = null;
328
+ }
345
329
  }
346
330
  }
347
331
 
348
- loadPagefind().catch(err => {
349
- console.log(`failed to load Pagefind: ${err}`);
350
- });
351
-
352
332
  const state: State = {
353
333
  value: '',
354
334
  showResults: false,
@@ -367,10 +347,11 @@ export default (window: Window) => {
367
347
  if (updateId !== latestUpdateId) {
368
348
  return;
369
349
  }
370
- if (nextState) {
371
- state.results = nextState.results;
372
- state.totalResults = nextState.totalResults;
350
+ if (!nextState) {
351
+ return;
373
352
  }
353
+ state.results = nextState.results;
354
+ state.totalResults = nextState.totalResults;
374
355
  if (!state.showResults) {
375
356
  return;
376
357
  }
@@ -386,6 +367,55 @@ export default (window: Window) => {
386
367
  latestUpdateId += 1;
387
368
  }
388
369
 
370
+ function loadPagefindAndUpdate() {
371
+ loadPagefind()
372
+ .then(loadedCurrentPagefind => {
373
+ if (loadedCurrentPagefind && state.showResults) {
374
+ queueUpdate();
375
+ }
376
+ })
377
+ .catch(err => {
378
+ console.log(`failed to load Pagefind: ${err}`);
379
+ if (state.showResults) {
380
+ render(input!, resultsContainer, state, false);
381
+ }
382
+ });
383
+ }
384
+
385
+ function invalidatePagefind() {
386
+ pagefindLoadGeneration += 1;
387
+ pagefind = null;
388
+ pagefindLoadPromise = null;
389
+ invalidateUpdates();
390
+ }
391
+
392
+ async function reloadPagefindAfterPageUpdateRefresh() {
393
+ const stalePagefind = pagefind;
394
+ invalidatePagefind();
395
+ if (state.showResults) {
396
+ render(input!, resultsContainer, state, true);
397
+ }
398
+ if (stalePagefind) {
399
+ try {
400
+ await stalePagefind.destroy();
401
+ } catch (err) {
402
+ console.log(`failed to destroy Pagefind: ${err}`);
403
+ }
404
+ }
405
+ loadPagefindAndUpdate();
406
+ }
407
+
408
+ function handlePageUpdateRefresh() {
409
+ reloadPagefindAfterPageUpdateRefresh().catch(err => {
410
+ console.log(`failed to reload Pagefind: ${err}`);
411
+ if (state.showResults) {
412
+ render(input!, resultsContainer, state, false);
413
+ }
414
+ });
415
+ }
416
+
417
+ loadPagefindAndUpdate();
418
+
389
419
  function hide() {
390
420
  if (!state.showResults) {
391
421
  return;
@@ -422,7 +452,6 @@ export default (window: Window) => {
422
452
  if (previous && !resultsContainer.contains(previous)) {
423
453
  previousFocus = previous;
424
454
  }
425
- checkForIndexUpdate().catch(() => {});
426
455
  if (!state.showResults) {
427
456
  state.showResults = true;
428
457
  queueUpdate();
@@ -568,8 +597,13 @@ export default (window: Window) => {
568
597
  window.addEventListener('pointerdown', handleWindowPointerDown);
569
598
  window.addEventListener('pointermove', handleWindowPointerMove);
570
599
  window.addEventListener('keydown', handleWindowKeyDown);
600
+ window.addEventListener(PAGE_UPDATE_REFRESH_EVENT, handlePageUpdateRefresh);
571
601
 
572
602
  return () => {
603
+ window.removeEventListener(
604
+ PAGE_UPDATE_REFRESH_EVENT,
605
+ handlePageUpdateRefresh,
606
+ );
573
607
  window.removeEventListener('keydown', handleWindowKeyDown);
574
608
  window.removeEventListener('pointermove', handleWindowPointerMove);
575
609
  window.removeEventListener('pointerdown', handleWindowPointerDown);
@@ -1,4 +1,4 @@
1
- export type SubResult = { title: string; url: string; excerpt: string };
1
+ type SubResult = { title: string; url: string; excerpt: string };
2
2
  export type Result = {
3
3
  title: string;
4
4
  titleHtml: string | null;
@@ -1,5 +1,5 @@
1
1
  interface TraceToolbarState {
2
- hidden: boolean;
2
+ hidden: HTMLElement['hidden'];
3
3
  ariaHidden: string | null;
4
4
  }
5
5
 
@@ -1,50 +1,14 @@
1
+ import {
2
+ DEFAULT_PERIOD_STYLE,
3
+ detectPeriodStyle,
4
+ normalizeHM,
5
+ to12Hour,
6
+ type PeriodStyle,
7
+ } from './time-format';
8
+
1
9
  const STORAGE_KEY = 'timezoneSelection';
2
10
  const TIMEZONE_SELECT_LABEL = 'Time zone';
3
11
 
4
- type PeriodStyle = [am: string, pm: string];
5
-
6
- const DEFAULT_PERIOD_STYLE: PeriodStyle = ['a.m.', 'p.m.'];
7
-
8
- const PERIOD_PATTERNS: { pattern: RegExp; style: PeriodStyle }[] = [
9
- { pattern: /[AP]\.M\./, style: ['A.M.', 'P.M.'] },
10
- { pattern: /[AP]M/, style: ['AM', 'PM'] },
11
- { pattern: /[ap]\.m\./, style: ['a.m.', 'p.m.'] },
12
- { pattern: /[ap]m/, style: ['am', 'pm'] },
13
- ];
14
-
15
- export function detectPeriodStyle(text: string): PeriodStyle | null {
16
- for (const { pattern, style } of PERIOD_PATTERNS) {
17
- if (pattern.test(text)) {
18
- return style;
19
- }
20
- }
21
- return null;
22
- }
23
-
24
- function pad(n: number | string) {
25
- return String(n).padStart(2, '0');
26
- }
27
-
28
- export function to12Hour(
29
- h: number,
30
- m: number,
31
- style: PeriodStyle | null = DEFAULT_PERIOD_STYLE,
32
- ) {
33
- const hour12 = ((h + 11) % 12) + 1;
34
- if (!style) {
35
- return `${hour12}:${pad(m)}`;
36
- }
37
- const period = h >= 12 ? style[1] : style[0];
38
- return `${hour12}:${pad(m)} ${period}`;
39
- }
40
-
41
- export function normalizeHM(totalMinutes: number) {
42
- const mins = ((totalMinutes % 1440) + 1440) % 1440;
43
- const h = Math.floor(mins / 60);
44
- const m = mins % 60;
45
- return [h, m] as const;
46
- }
47
-
48
12
  function getDayShift(totalMinutes: number) {
49
13
  // Negative partial days like -30 minutes should still count as the previous day.
50
14
  return Math.floor(totalMinutes / 1440);
@@ -0,0 +1,43 @@
1
+ export type PeriodStyle = [am: string, pm: string];
2
+
3
+ export const DEFAULT_PERIOD_STYLE: PeriodStyle = ['a.m.', 'p.m.'];
4
+
5
+ const PERIOD_PATTERNS: { pattern: RegExp; style: PeriodStyle }[] = [
6
+ { pattern: /[AP]\.M\./, style: ['A.M.', 'P.M.'] },
7
+ { pattern: /[AP]M/, style: ['AM', 'PM'] },
8
+ { pattern: /[ap]\.m\./, style: ['a.m.', 'p.m.'] },
9
+ { pattern: /[ap]m/, style: ['am', 'pm'] },
10
+ ];
11
+
12
+ export function detectPeriodStyle(text: string): PeriodStyle | null {
13
+ for (const { pattern, style } of PERIOD_PATTERNS) {
14
+ if (pattern.test(text)) {
15
+ return style;
16
+ }
17
+ }
18
+ return null;
19
+ }
20
+
21
+ function pad(n: number | string) {
22
+ return String(n).padStart(2, '0');
23
+ }
24
+
25
+ export function to12Hour(
26
+ h: number,
27
+ m: number,
28
+ style: PeriodStyle | null = DEFAULT_PERIOD_STYLE,
29
+ ) {
30
+ const hour12 = ((h + 11) % 12) + 1;
31
+ if (!style) {
32
+ return `${hour12}:${pad(m)}`;
33
+ }
34
+ const period = h >= 12 ? style[1] : style[0];
35
+ return `${hour12}:${pad(m)} ${period}`;
36
+ }
37
+
38
+ export function normalizeHM(totalMinutes: number) {
39
+ const mins = ((totalMinutes % 1440) + 1440) % 1440;
40
+ const h = Math.floor(mins / 60);
41
+ const m = mins % 60;
42
+ return [h, m] as const;
43
+ }
package/src/toc/index.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import { debounce } from '../util';
2
+ import {
3
+ alertToTableItem,
4
+ getHighlightIndexes,
5
+ headingToTableItem,
6
+ switchCurrent,
7
+ type Dinkus,
8
+ } from './model';
2
9
 
3
10
  const LATENCY_MS = 50;
4
11
 
5
- export type HeadingLevel = '1' | '2' | '3' | '4' | '5' | '6';
6
- export type AlertType = 'warning' | 'note';
7
-
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
-
12
12
  function getContainer(parent: HTMLElement): HTMLElement | null {
13
13
  return parent.querySelector('nav.toc');
14
14
  }
@@ -40,27 +40,6 @@ function scrollIfNeeded(element: HTMLElement, doc: Document) {
40
40
  container.scrollTo({ top: desiredScrollTop });
41
41
  }
42
42
 
43
- export function getHighlightIndexes(items: (Heading | Alert | Dinkus)[]) {
44
- const indexes: (number | null)[] = [];
45
- let currentHeadingIndex: number | null = null;
46
- let tocIndex = 0;
47
-
48
- items.forEach(item => {
49
- if (!('level' in item) && item.type === 'dinkus') {
50
- return;
51
- }
52
-
53
- if ('level' in item) {
54
- currentHeadingIndex = tocIndex;
55
- }
56
-
57
- indexes.push(currentHeadingIndex ?? tocIndex);
58
- tocIndex++;
59
- });
60
-
61
- return indexes;
62
- }
63
-
64
43
  function getHeadingsAndAlerts(
65
44
  parent: HTMLElement,
66
45
  ): (HTMLHeadingElement | HTMLDivElement)[] {
@@ -97,57 +76,6 @@ function getViewportActivationPoint(win: Window, doc: Document) {
97
76
  return headerOffset + (win.innerHeight - headerOffset) / 3;
98
77
  }
99
78
 
100
- export function headingToTableItem(el: HTMLHeadingElement): Heading {
101
- const level = el.tagName[1] as HeadingLevel;
102
-
103
- const subtitle = el.querySelector('.heading-subtitle');
104
- const subtitleText = subtitle?.textContent || '';
105
- let mainText = el.textContent || '';
106
-
107
- if (mainText.length > 0 && subtitleText.length > 0) {
108
- mainText = mainText.replace(subtitleText, '').trim();
109
- return {
110
- level,
111
- id: el.id,
112
- innerHtml: `${mainText}: <span class="heading-subtitle">${subtitleText}</span>`,
113
- };
114
- }
115
- return { level, id: el.id, innerHtml: el.innerHTML };
116
- }
117
-
118
- export function alertToTableItem(el: HTMLElement): Alert | null {
119
- const classes = el.className
120
- .split(' ')
121
- .map(cl => cl.trim())
122
- .filter(cl => cl != 'alert');
123
-
124
- const firstClass = classes[0];
125
- if (firstClass === 'warning' || firstClass === 'note') {
126
- let title = el.querySelector('.title')?.innerHTML;
127
- if (!title) {
128
- if (firstClass === 'warning') {
129
- title = 'Warning';
130
- } else {
131
- title = 'Note';
132
- }
133
- }
134
-
135
- return { type: firstClass, title };
136
- }
137
-
138
- return null;
139
- }
140
-
141
- export function switchCurrent(
142
- oldCurrent: HTMLElement | null,
143
- newCurrent: HTMLElement,
144
- ) {
145
- if (oldCurrent) {
146
- oldCurrent.classList.remove('current');
147
- }
148
- newCurrent.classList.add('current');
149
- }
150
-
151
79
  export default (window: Window) => {
152
80
  const toc = window.document.querySelector('nav.toc') as HTMLElement;
153
81
  if (toc == null) {