@abreen/tada 1.11.3 → 1.13.4

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 (85) hide show
  1. package/README.md +65 -64
  2. package/bin/tada.ts +61 -66
  3. package/build/build-manifest.ts +14 -3
  4. package/build/bundle.ts +0 -1
  5. package/build/config-files.ts +183 -0
  6. package/build/config-loader.ts +159 -0
  7. package/build/copy.ts +0 -83
  8. package/build/generate-content-assets.ts +75 -160
  9. package/build/globals.ts +48 -89
  10. package/build/log.ts +27 -18
  11. package/build/pagefind.ts +33 -26
  12. package/build/pipeline.ts +98 -79
  13. package/build/reachability.ts +51 -248
  14. package/build/serve.ts +35 -6
  15. package/build/site-variables.ts +20 -11
  16. package/build/source-model.ts +400 -0
  17. package/build/{watch/records.ts → source-records.ts} +111 -49
  18. package/build/template-globals.ts +91 -0
  19. package/build/templates.ts +50 -40
  20. package/build/test-helpers.ts +15 -0
  21. package/build/toc-plugin.ts +5 -1
  22. package/build/types.d.ts +9 -1
  23. package/build/util.ts +8 -0
  24. package/build/utils/code.ts +6 -16
  25. package/build/utils/content-files.ts +57 -126
  26. package/build/utils/final-html.ts +239 -0
  27. package/build/utils/markdown.ts +237 -17
  28. package/build/utils/paths.ts +2 -1
  29. package/build/utils/render.ts +108 -99
  30. package/build/utils/trace-core.ts +3 -7
  31. package/build/validate-config-links.ts +91 -14
  32. package/build/watch/assets.ts +0 -2
  33. package/build/watch/build-full.ts +14 -15
  34. package/build/watch/build-incremental.ts +15 -8
  35. package/build/watch/compiler-types.d.ts +6 -1
  36. package/build/watch/compiler.ts +27 -15
  37. package/build/watch/index.ts +2 -7
  38. package/build/watch/planner.ts +32 -16
  39. package/build/watch/reload.ts +4 -0
  40. package/build/watch/runtime.ts +29 -45
  41. package/build/watch/snapshot.ts +50 -402
  42. package/build/watch/validation.ts +6 -3
  43. package/build/watch-reachability-state.ts +16 -23
  44. package/build/watch-reload-client.ts +11 -4
  45. package/init/content/index.md +1 -1
  46. package/init/content/labs/02/index.md +61 -0
  47. package/init/content/labs/02/selection_sort.py +23 -0
  48. package/init/content/labs/index.md +4 -2
  49. package/init/content/lectures/01/index.md +1 -1
  50. package/init/content/markdown.md +1 -1
  51. package/package.json +3 -4
  52. package/src/_base.scss +67 -0
  53. package/src/anchor/README.md +9 -0
  54. package/src/anchor/index.ts +115 -16
  55. package/src/anchor/style.scss +70 -10
  56. package/src/code/index.ts +46 -18
  57. package/src/code.scss +2 -1
  58. package/src/globals.ts +47 -0
  59. package/src/index.ts +1 -0
  60. package/src/navigate/index.ts +2 -5
  61. package/src/navigate/lifecycle.ts +3 -1
  62. package/src/navigate/runtime.ts +6 -5
  63. package/src/page-update/index.ts +20 -16
  64. package/src/print/style.scss +0 -4
  65. package/src/search/index.ts +53 -25
  66. package/src/slides/index.ts +481 -0
  67. package/src/slides/style.scss +104 -0
  68. package/src/style.scss +8 -3
  69. package/src/timezone/index.ts +16 -20
  70. package/src/top/index.ts +2 -4
  71. package/src/trace/index.ts +116 -14
  72. package/src/trace/style.scss +1 -1
  73. package/src/util.ts +6 -2
  74. package/templates/_author.html +2 -2
  75. package/templates/_heading.html +1 -1
  76. package/templates/_nav.html +2 -2
  77. package/templates/_theme.scss +4 -3
  78. package/templates/_top.html +14 -14
  79. package/templates/default.html +9 -0
  80. package/types/dev.d.ts +1 -1
  81. package/types/site-variables.d.ts +4 -5
  82. package/watch/fs-commit.ts +63 -10
  83. package/build/apply-base-path-plugin.ts +0 -186
  84. package/build/content-watch.ts +0 -104
  85. package/build/validate-internal-links-plugin.ts +0 -207
@@ -0,0 +1,481 @@
1
+ interface TraceToolbarState {
2
+ hidden: boolean;
3
+ ariaHidden: string | null;
4
+ }
5
+
6
+ type PresentationMode = 'normal' | 'fullscreen';
7
+
8
+ interface SlidesPresentDetail {
9
+ slideIndex?: number;
10
+ }
11
+
12
+ function isInteractive(
13
+ view: Window & typeof globalThis,
14
+ target: EventTarget | null,
15
+ ): boolean {
16
+ if (!(target instanceof view.Node)) {
17
+ return false;
18
+ }
19
+
20
+ const element =
21
+ target instanceof view.Element ? target : target.parentElement;
22
+ return Boolean(
23
+ element?.closest(
24
+ 'a, button, input, select, textarea, summary, label, [role="button"], [contenteditable=""], [contenteditable="true"], .question-a-body',
25
+ ),
26
+ );
27
+ }
28
+
29
+ function updateActiveSlide(
30
+ slides: HTMLElement[],
31
+ activeIndex: number,
32
+ isPresenting: boolean,
33
+ ): void {
34
+ for (const [index, slide] of slides.entries()) {
35
+ slide.classList.toggle('is-active', isPresenting && index === activeIndex);
36
+ }
37
+ }
38
+
39
+ function isTraceReady(widget: HTMLElement): boolean {
40
+ return (
41
+ widget.querySelector('.trace-line-active') !== null ||
42
+ widget.querySelector('.trace-diagram > *') !== null
43
+ );
44
+ }
45
+
46
+ export default function mountSlides(window: Window): void | (() => void) {
47
+ const { document } = window;
48
+ const view = document.defaultView;
49
+ const presentButton = document.querySelector(
50
+ '[data-slides-present]',
51
+ ) as HTMLButtonElement | null;
52
+ const fullscreenCheckbox = document.querySelector(
53
+ '[data-slides-fullscreen]',
54
+ ) as HTMLInputElement | null;
55
+ const rootScopeButton = presentButton ?? fullscreenCheckbox;
56
+ const scopedRoot = rootScopeButton
57
+ ?.closest('main')
58
+ ?.querySelector('[data-slides-root]');
59
+ const root = (scopedRoot ??
60
+ document.querySelector('[data-slides-root]')) as HTMLElement | null;
61
+
62
+ if (!view || !root) {
63
+ return;
64
+ }
65
+
66
+ const domView = view;
67
+ const domDocument = document as Document & {
68
+ fullscreenElement?: Element | null;
69
+ exitFullscreen?: () => Promise<void>;
70
+ };
71
+ const fullscreenTarget = document.documentElement as HTMLElement & {
72
+ requestFullscreen?: () => Promise<void>;
73
+ };
74
+ const slidesRoot = root;
75
+ const slides = Array.from(
76
+ slidesRoot.querySelectorAll('[data-slide-index]'),
77
+ ) as HTMLElement[];
78
+ if (slides.length === 0) {
79
+ return;
80
+ }
81
+
82
+ if (presentButton) {
83
+ presentButton.disabled = false;
84
+ }
85
+ if (fullscreenCheckbox) {
86
+ fullscreenCheckbox.disabled = false;
87
+ }
88
+
89
+ const overlay = document.createElement('div');
90
+ overlay.className = 'slides-toolbar';
91
+ overlay.hidden = true;
92
+ overlay.dataset.slidesOverlay = '';
93
+
94
+ const closeButton = document.createElement('button');
95
+ closeButton.type = 'button';
96
+ closeButton.textContent = 'Close';
97
+ closeButton.dataset.slidesClose = '';
98
+
99
+ overlay.append(closeButton);
100
+ document.body.appendChild(overlay);
101
+
102
+ let isPresenting = false;
103
+ let activeIndex = -1;
104
+ let presentationMode: PresentationMode | null = null;
105
+ let isToolbarPinnedVisible = false;
106
+
107
+ const toolbarStates = new WeakMap<HTMLElement, TraceToolbarState>();
108
+ const slideTabIndexes = new WeakMap<HTMLElement, string | null>();
109
+
110
+ function hidePresentationCursor(): void {
111
+ document.body.classList.add('is-presentation-cursor-hidden');
112
+ }
113
+
114
+ function showPresentationCursor(): void {
115
+ document.body.classList.remove('is-presentation-cursor-hidden');
116
+ }
117
+
118
+ function hidePresentationControls(resetPinned = true): void {
119
+ if (resetPinned) {
120
+ isToolbarPinnedVisible = false;
121
+ }
122
+ overlay.hidden = true;
123
+ }
124
+
125
+ function showPresentationControls(): void {
126
+ if (!isPresenting || presentationMode === 'fullscreen') {
127
+ return;
128
+ }
129
+
130
+ overlay.hidden = false;
131
+ }
132
+
133
+ function pinPresentationControlsVisible(): void {
134
+ if (!isPresenting || presentationMode === 'fullscreen') {
135
+ return;
136
+ }
137
+
138
+ isToolbarPinnedVisible = true;
139
+ overlay.hidden = false;
140
+ }
141
+
142
+ function getToolbarRevealBottom(): number {
143
+ const wasHidden = overlay.hidden;
144
+ if (wasHidden) {
145
+ overlay.hidden = false;
146
+ }
147
+
148
+ const bottom = closeButton.getBoundingClientRect().bottom;
149
+
150
+ if (wasHidden && !isToolbarPinnedVisible) {
151
+ overlay.hidden = true;
152
+ }
153
+
154
+ return bottom;
155
+ }
156
+
157
+ function setTraceToolbarVisibility(hidden: boolean): void {
158
+ const toolbars = slidesRoot.querySelectorAll('.trace-toolbar');
159
+ for (const toolbar of toolbars) {
160
+ const element = toolbar as HTMLElement;
161
+
162
+ if (hidden) {
163
+ if (!toolbarStates.has(element)) {
164
+ toolbarStates.set(element, {
165
+ hidden: element.hidden,
166
+ ariaHidden: element.getAttribute('aria-hidden'),
167
+ });
168
+ }
169
+ element.hidden = true;
170
+ element.setAttribute('aria-hidden', 'true');
171
+ continue;
172
+ }
173
+
174
+ const previous = toolbarStates.get(element);
175
+ element.hidden = previous?.hidden ?? false;
176
+ if (previous?.ariaHidden == null) {
177
+ element.removeAttribute('aria-hidden');
178
+ } else {
179
+ element.setAttribute('aria-hidden', previous.ariaHidden);
180
+ }
181
+ }
182
+ }
183
+
184
+ function setActiveSlide(index: number): void {
185
+ activeIndex = Math.max(0, Math.min(index, slides.length - 1));
186
+ updateActiveSlide(slides, activeIndex, isPresenting);
187
+ }
188
+
189
+ function focusSlide(slide: HTMLElement | undefined): void {
190
+ if (!slide) {
191
+ return;
192
+ }
193
+
194
+ if (!slideTabIndexes.has(slide)) {
195
+ slideTabIndexes.set(slide, slide.getAttribute('tabindex'));
196
+ }
197
+ slide.tabIndex = -1;
198
+ slide.focus();
199
+ }
200
+
201
+ function restoreSlideFocusability(): void {
202
+ for (const slide of slides) {
203
+ const prior = slideTabIndexes.get(slide);
204
+ if (prior === undefined) {
205
+ continue;
206
+ }
207
+ if (prior == null) {
208
+ slide.removeAttribute('tabindex');
209
+ } else {
210
+ slide.setAttribute('tabindex', prior);
211
+ }
212
+ }
213
+ }
214
+
215
+ function exitPresentation(): void {
216
+ if (!isPresenting) {
217
+ return;
218
+ }
219
+
220
+ const shouldExitFullscreen =
221
+ presentationMode === 'fullscreen' &&
222
+ domDocument.fullscreenElement != null &&
223
+ typeof domDocument.exitFullscreen === 'function';
224
+
225
+ isPresenting = false;
226
+ activeIndex = -1;
227
+ presentationMode = null;
228
+ hidePresentationControls();
229
+ showPresentationCursor();
230
+ document.body.classList.remove('is-presenting');
231
+ updateActiveSlide(slides, activeIndex, isPresenting);
232
+ restoreSlideFocusability();
233
+ setTraceToolbarVisibility(false);
234
+
235
+ if (shouldExitFullscreen) {
236
+ void domDocument.exitFullscreen?.();
237
+ }
238
+ }
239
+
240
+ function resetReadyTraces(): void {
241
+ const widgets = slidesRoot.querySelectorAll(
242
+ '.trace-widget:not(.trace-disabled)',
243
+ );
244
+
245
+ for (const widget of widgets) {
246
+ const widgetEl = widget as HTMLElement;
247
+ if (!isTraceReady(widgetEl)) {
248
+ continue;
249
+ }
250
+
251
+ const firstButton = widgetEl.querySelector(
252
+ '.trace-first:not([disabled])',
253
+ ) as HTMLButtonElement | null;
254
+
255
+ firstButton?.click();
256
+ }
257
+ }
258
+
259
+ function enterPresentation(mode: PresentationMode, slideIndex = 0): void {
260
+ isPresenting = true;
261
+ presentationMode = mode;
262
+ hidePresentationControls();
263
+ showPresentationCursor();
264
+ document.body.classList.add('is-presenting');
265
+ setTraceToolbarVisibility(true);
266
+ resetReadyTraces();
267
+ setActiveSlide(slideIndex);
268
+ focusSlide(slides[activeIndex]);
269
+ }
270
+
271
+ function driveTrace(delta: 1 | -1): boolean {
272
+ if (activeIndex < 0) {
273
+ return false;
274
+ }
275
+
276
+ const slide = slides[activeIndex];
277
+ const selector =
278
+ delta === 1
279
+ ? '.trace-next:not([disabled])'
280
+ : '.trace-prev:not([disabled])';
281
+ const widgets = slide.querySelectorAll(
282
+ '.trace-widget:not(.trace-disabled)',
283
+ );
284
+
285
+ for (const widget of widgets) {
286
+ const widgetEl = widget as HTMLElement;
287
+ if (!isTraceReady(widgetEl)) {
288
+ continue;
289
+ }
290
+
291
+ const button = widgetEl.querySelector(
292
+ selector,
293
+ ) as HTMLButtonElement | null;
294
+ if (!button) {
295
+ continue;
296
+ }
297
+
298
+ button.click();
299
+ return true;
300
+ }
301
+
302
+ return false;
303
+ }
304
+
305
+ function move(delta: 1 | -1): void {
306
+ if (!isPresenting) {
307
+ return;
308
+ }
309
+
310
+ hidePresentationCursor();
311
+
312
+ if (delta === -1) {
313
+ hidePresentationControls();
314
+ }
315
+
316
+ if (driveTrace(delta)) {
317
+ if (delta === 1) {
318
+ hidePresentationControls();
319
+ }
320
+ return;
321
+ }
322
+
323
+ if (delta === 1 && activeIndex >= slides.length - 1) {
324
+ pinPresentationControlsVisible();
325
+ return;
326
+ }
327
+
328
+ if (delta === 1) {
329
+ hidePresentationControls();
330
+ }
331
+
332
+ setActiveSlide(activeIndex + delta);
333
+ focusSlide(slides[activeIndex]);
334
+ }
335
+
336
+ async function handlePresentFullscreenClick(slideIndex = 0): Promise<void> {
337
+ enterPresentation('fullscreen', slideIndex);
338
+
339
+ if (typeof fullscreenTarget.requestFullscreen !== 'function') {
340
+ presentationMode = 'normal';
341
+ return;
342
+ }
343
+
344
+ try {
345
+ await fullscreenTarget.requestFullscreen();
346
+ } catch {
347
+ presentationMode = 'normal';
348
+ }
349
+ }
350
+
351
+ function startPresentation(slideIndex = 0): void {
352
+ if (fullscreenCheckbox?.checked === true) {
353
+ void handlePresentFullscreenClick(slideIndex);
354
+ return;
355
+ }
356
+
357
+ enterPresentation('normal', slideIndex);
358
+ }
359
+
360
+ function handlePresentClick(): void {
361
+ startPresentation(0);
362
+ }
363
+
364
+ function handleCloseClick(): void {
365
+ exitPresentation();
366
+ }
367
+
368
+ function handleFullscreenChange(): void {
369
+ if (
370
+ isPresenting &&
371
+ presentationMode === 'fullscreen' &&
372
+ domDocument.fullscreenElement == null
373
+ ) {
374
+ exitPresentation();
375
+ }
376
+ }
377
+
378
+ function handleKeydown(event: KeyboardEvent): void {
379
+ if (!isPresenting) {
380
+ return;
381
+ }
382
+
383
+ if (
384
+ (event.key === 'ArrowRight' ||
385
+ event.key === 'ArrowLeft' ||
386
+ event.key === ' ' ||
387
+ event.key === 'Spacebar') &&
388
+ isInteractive(domView, document.activeElement)
389
+ ) {
390
+ return;
391
+ }
392
+
393
+ if (event.key === 'ArrowRight') {
394
+ event.preventDefault();
395
+ move(1);
396
+ } else if (event.key === 'ArrowLeft') {
397
+ event.preventDefault();
398
+ move(-1);
399
+ } else if (event.key === ' ' || event.key === 'Spacebar') {
400
+ event.preventDefault();
401
+ move(1);
402
+ } else if (event.key === 'Escape') {
403
+ event.preventDefault();
404
+ exitPresentation();
405
+ }
406
+ }
407
+
408
+ function handleSlideClick(event: MouseEvent): void {
409
+ if (!isPresenting || isInteractive(domView, event.target)) {
410
+ return;
411
+ }
412
+
413
+ const activeSlide = slides[activeIndex];
414
+ if (!activeSlide) {
415
+ return;
416
+ }
417
+
418
+ if (
419
+ !(event.target instanceof domView.Node) ||
420
+ !activeSlide.contains(event.target)
421
+ ) {
422
+ return;
423
+ }
424
+
425
+ move(1);
426
+ }
427
+
428
+ function handleMouseMove(event: MouseEvent): void {
429
+ if (!isPresenting) {
430
+ return;
431
+ }
432
+
433
+ showPresentationCursor();
434
+
435
+ if (presentationMode === 'fullscreen') {
436
+ return;
437
+ }
438
+
439
+ if (isToolbarPinnedVisible) {
440
+ showPresentationControls();
441
+ return;
442
+ }
443
+
444
+ if (event.clientY <= getToolbarRevealBottom()) {
445
+ showPresentationControls();
446
+ } else {
447
+ hidePresentationControls(false);
448
+ }
449
+ }
450
+
451
+ function handleSlidesPresent(event: Event): void {
452
+ const detail = (event as CustomEvent<SlidesPresentDetail>).detail ?? {};
453
+ const slideIndex =
454
+ typeof detail.slideIndex === 'number' &&
455
+ Number.isFinite(detail.slideIndex)
456
+ ? detail.slideIndex
457
+ : 0;
458
+
459
+ startPresentation(slideIndex);
460
+ }
461
+
462
+ presentButton?.addEventListener('click', handlePresentClick);
463
+ closeButton.addEventListener('click', handleCloseClick);
464
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
465
+ window.addEventListener('keydown', handleKeydown);
466
+ window.addEventListener('mousemove', handleMouseMove);
467
+ slidesRoot.addEventListener('click', handleSlideClick);
468
+ slidesRoot.addEventListener('tada:slides-present', handleSlidesPresent);
469
+
470
+ return () => {
471
+ exitPresentation();
472
+ presentButton?.removeEventListener('click', handlePresentClick);
473
+ closeButton.removeEventListener('click', handleCloseClick);
474
+ document.removeEventListener('fullscreenchange', handleFullscreenChange);
475
+ window.removeEventListener('keydown', handleKeydown);
476
+ window.removeEventListener('mousemove', handleMouseMove);
477
+ slidesRoot.removeEventListener('click', handleSlideClick);
478
+ slidesRoot.removeEventListener('tada:slides-present', handleSlidesPresent);
479
+ overlay.remove();
480
+ };
481
+ }
@@ -0,0 +1,104 @@
1
+ @use '../mixins' as *;
2
+
3
+ .slide-deck,
4
+ .slide {
5
+ display: block;
6
+ }
7
+
8
+ body.is-presenting {
9
+ --slides-scale: clamp(150%, 3.6vmin, 250%);
10
+
11
+ overflow: hidden;
12
+ }
13
+
14
+ body.is-presenting.is-presentation-cursor-hidden,
15
+ body.is-presenting.is-presentation-cursor-hidden * {
16
+ cursor: none !important;
17
+ }
18
+
19
+ body.is-presenting header,
20
+ body.is-presenting .title-and-info,
21
+ body.is-presenting nav.toc,
22
+ body.is-presenting footer,
23
+ body.is-presenting .slides-header,
24
+ body.is-presenting .trace-toolbar {
25
+ display: none;
26
+ }
27
+
28
+ @media (hover: none) {
29
+ .slides-header {
30
+ display: none;
31
+ }
32
+ }
33
+
34
+ body.is-presenting [data-slides-close] {
35
+ position: fixed;
36
+ top: var(--gap);
37
+ right: var(--gap);
38
+ z-index: 1001;
39
+ }
40
+
41
+ @media print {
42
+ [data-slides-close] {
43
+ display: none;
44
+ }
45
+ }
46
+
47
+ body.is-presenting .slide-deck {
48
+ position: fixed;
49
+ inset: 0;
50
+ z-index: 999;
51
+ background: var(--bg-color);
52
+ }
53
+
54
+ body.is-presenting .slide {
55
+ @include user-select(none);
56
+
57
+ position: fixed;
58
+ top: 50%;
59
+ left: 50%;
60
+ z-index: 1000;
61
+ box-sizing: border-box;
62
+ display: none;
63
+ width: min(100vw, calc(100vh * 4 / 3));
64
+ height: min(100vh, calc(100vw * 3 / 4));
65
+ padding: calc(3 * var(--gap));
66
+ overflow: auto;
67
+ font-size: var(--slides-scale);
68
+ letter-spacing: 0.2px;
69
+ outline: none;
70
+ background: var(--bg-color);
71
+ transform: translate(-50%, -50%);
72
+
73
+ > :first-child {
74
+ margin-top: 0;
75
+ }
76
+
77
+ > h2:first-child {
78
+ text-align: center;
79
+
80
+ a.heading-anchor {
81
+ color: var(--theme-color-text);
82
+ }
83
+ }
84
+ }
85
+
86
+ body.is-presenting .slide.is-active {
87
+ display: block;
88
+ }
89
+
90
+ body.is-presenting .slide.is-active:has(> .trace-widget) {
91
+ display: flex;
92
+ flex-direction: column;
93
+ }
94
+
95
+ body.is-presenting .slide:has(> .trace-widget) > .trace-widget {
96
+ flex: 1 1 auto;
97
+ min-height: 0;
98
+ font-size: 66.6%;
99
+ }
100
+
101
+ body.is-presenting .slide:has(> .trace-widget) > .trace-widget > .trace-body {
102
+ height: 100%;
103
+ min-height: 0;
104
+ }
package/src/style.scss CHANGED
@@ -235,12 +235,17 @@ ul.styled-list {
235
235
 
236
236
  ul.styled-list {
237
237
  // Make bullet points slightly bigger
238
- font-size: 1.2rem;
238
+ font-size: 1.2em;
239
+ }
240
+
241
+ ol li .styled-list-item {
242
+ font-size: 1em;
243
+ font-weight: var(--font-weight);
244
+ color: var(--fg-color);
239
245
  }
240
246
 
241
- ol li .styled-list-item,
242
247
  ul li .styled-list-item {
243
- font-size: 1rem;
248
+ font-size: 0.8333em;
244
249
  font-weight: var(--font-weight);
245
250
  color: var(--fg-color);
246
251
  }
@@ -45,6 +45,11 @@ export function normalizeHM(totalMinutes: number) {
45
45
  return [h, m] as const;
46
46
  }
47
47
 
48
+ function getDayShift(totalMinutes: number) {
49
+ // Negative partial days like -30 minutes should still count as the previous day.
50
+ return Math.floor(totalMinutes / 1440);
51
+ }
52
+
48
53
  function parseHHMM(hhmm: string) {
49
54
  const [h, m] = hhmm.split(':');
50
55
  if (h == null || m == null) {
@@ -85,26 +90,16 @@ function getOffsetMinutes(tz: string, date: Date): number {
85
90
  return (utcTs - date.getTime()) / 60000;
86
91
  }
87
92
 
88
- // Read lazily so tests can set __SITE_TIMEZONES__ before the first call.
89
- // Cache the result so mutations (like computeOffsets adding offsetMinutes)
90
- // persist across calls. The bundler inlines __SITE_TIMEZONES__ as a JSON
91
- // literal, so each raw access would create a fresh array.
92
- let _timezones: TimeZone[] | null = null;
93
93
  function getTimezones(): TimeZone[] {
94
- if (_timezones === null) {
95
- _timezones =
96
- typeof __SITE_TIMEZONES__ !== 'undefined' ? __SITE_TIMEZONES__ : [];
97
- }
98
- return _timezones;
94
+ return __SITE_TIMEZONES__.map(tz => ({ ...tz }));
99
95
  }
100
96
 
101
- function getDefaultTimezone() {
102
- const value = __SITE_DEFAULT_TIMEZONE__;
103
- return getTimezones().find(tz => tz.value === value)!;
97
+ function getDefaultTimezone(timezones: TimeZone[]): TimeZone {
98
+ return timezones.find(tz => tz.value === __SITE_DEFAULT_TIMEZONE__)!;
104
99
  }
105
100
 
106
- function computeOffsets(baseDate: Date) {
107
- getTimezones().forEach(tz => {
101
+ function computeOffsets(timezones: TimeZone[], baseDate: Date) {
102
+ timezones.forEach(tz => {
108
103
  tz.offsetMinutes = getOffsetMinutes(tz.value, baseDate);
109
104
  });
110
105
  }
@@ -143,14 +138,15 @@ function dominantPeriodStyle(
143
138
  }
144
139
 
145
140
  export default (window: Window) => {
146
- const defaultTz = getDefaultTimezone();
141
+ const timezones = getTimezones();
142
+ const defaultTz = getDefaultTimezone(timezones);
147
143
  const resetTitle = `Reset time zone to ${defaultTz.abbreviation} (default)`;
148
144
 
149
145
  // determine initial time zone (from storage or default)
150
146
  let initialTz = defaultTz.value;
151
147
  try {
152
148
  const stored = window.localStorage.getItem(STORAGE_KEY);
153
- if (stored && getTimezones().some(t => t.value === stored)) {
149
+ if (stored && timezones.some(t => t.value === stored)) {
154
150
  initialTz = stored;
155
151
  }
156
152
  } catch {
@@ -158,7 +154,7 @@ export default (window: Window) => {
158
154
  }
159
155
 
160
156
  const now = new Date();
161
- computeOffsets(now);
157
+ computeOffsets(timezones, now);
162
158
  const defaultOffset = defaultTz.offsetMinutes ?? 0;
163
159
 
164
160
  // Snapshot the original AM/PM style from each <time> element's text
@@ -173,7 +169,7 @@ export default (window: Window) => {
173
169
  const pagePeriodStyle = dominantPeriodStyle(periodStyles);
174
170
 
175
171
  function updateTimes(targetTz: string) {
176
- const target = getTimezones().find(t => t.value === targetTz) || defaultTz;
172
+ const target = timezones.find(t => t.value === targetTz) || defaultTz;
177
173
  const targetOffset = target.offsetMinutes ?? 0;
178
174
  const deltaMinutes = targetOffset - defaultOffset;
179
175
 
@@ -196,7 +192,7 @@ export default (window: Window) => {
196
192
 
197
193
  // raw (can be < 0 or > 1439)
198
194
  const rawMinutes = baseMinutes + deltaMinutes;
199
- const dayShift = Math.floor(rawMinutes / 1440) - (rawMinutes < 0 ? 1 : 0);
195
+ const dayShift = getDayShift(rawMinutes);
200
196
  // Normalize after computing dayShift
201
197
  const [h, m] = normalizeHM(rawMinutes);
202
198
 
package/src/top/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { debounce, removeClass } from '../util';
2
+ import { globals } from '../globals';
2
3
 
3
4
  /** Show the "Back to top" button once the user is past this scroll position */
4
5
  const SHOW_THRESHOLD_PX = 250;
@@ -30,10 +31,7 @@ export default (window: Window) => {
30
31
  e.preventDefault();
31
32
  const cleanUrl = window.location.pathname + window.location.search;
32
33
  if (window.location.hash) {
33
- // Clear the hash via fragment navigation so :target clears and
34
- // hashchange fires. location.hash = '' may leave a trailing '#';
35
- // strip it with replaceState.
36
- window.location.hash = '';
34
+ globals.setLocationHash(window, '');
37
35
  window.history.replaceState(null, '', cleanUrl);
38
36
  } else {
39
37
  window.history.replaceState(null, '', cleanUrl);