@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
@@ -12,6 +12,7 @@ import {
12
12
  setCurrentPath,
13
13
  setHistoryIndex,
14
14
  } from './runtime';
15
+ import { globals } from '../globals';
15
16
 
16
17
  function findAnchor(event: MouseEvent): HTMLAnchorElement | null {
17
18
  const target = event.target as HTMLElement | null;
@@ -77,11 +78,7 @@ export default function mountNavigate(window: Window): () => void {
77
78
  clearSearch(window.document);
78
79
  closeHeaderDetails(window.document);
79
80
  if (url.hash) {
80
- // location.hash assignment performs a real fragment navigation:
81
- // updates :target, fires hashchange (which the TOC listens for),
82
- // creates a new history entry, and scrolls to the element.
83
- // pushState would do none of those things.
84
- window.location.hash = url.hash.slice(1);
81
+ globals.setLocationHash(window, url.hash);
85
82
  }
86
83
  return;
87
84
  }
@@ -5,6 +5,7 @@ import mountTimeZone from '../timezone';
5
5
  import mountCode from '../code';
6
6
  import mountTrace from '../trace';
7
7
  import mountPrint from '../print';
8
+ import mountSlides from '../slides';
8
9
  import { scheduleTask } from '../util';
9
10
 
10
11
  const PER_PAGE_COMPONENTS: Record<
@@ -17,6 +18,7 @@ const PER_PAGE_COMPONENTS: Record<
17
18
  timeZone: mountTimeZone,
18
19
  code: mountCode,
19
20
  trace: mountTrace,
21
+ slides: mountSlides,
20
22
  print: mountPrint,
21
23
  };
22
24
 
@@ -39,7 +41,7 @@ export async function mountPerPageComponents(
39
41
  }
40
42
  resolve();
41
43
  } catch (err) {
42
- if (typeof __IS_DEV__ !== 'undefined' && __IS_DEV__) {
44
+ if (__IS_DEV__) {
43
45
  console.error(`Failed to mount ${name} component:`, String(err));
44
46
  }
45
47
  reject();
@@ -1,4 +1,5 @@
1
1
  import { mountPerPageComponents, teardownPerPageComponents } from './lifecycle';
2
+ import { globals } from '../globals';
2
3
 
3
4
  export const NAVIGATION_EVENT = 'tada:navigation';
4
5
 
@@ -196,19 +197,19 @@ export async function navigateToUrl(
196
197
 
197
198
  let response: Response;
198
199
  try {
199
- response = await fetch(url, { signal: controller.signal });
200
+ response = await globals.fetch(url, { signal: controller.signal });
200
201
  } catch (err: unknown) {
201
202
  header?.classList.remove('loading');
202
203
  if (err instanceof Error && err.name === 'AbortError') {
203
204
  return;
204
205
  }
205
- window.location.href = url;
206
+ globals.setLocationHref(window, url);
206
207
  return;
207
208
  }
208
209
 
209
210
  if (!response.ok) {
210
211
  header?.classList.remove('loading');
211
- window.location.href = url;
212
+ globals.setLocationHref(window, url);
212
213
  return;
213
214
  }
214
215
 
@@ -226,7 +227,7 @@ export async function navigateToUrl(
226
227
  .querySelector('meta[name="generator"]')
227
228
  ?.getAttribute('content');
228
229
  if (!currentGenerator || !newGenerator || newGenerator !== currentGenerator) {
229
- window.location.href = url;
230
+ globals.setLocationHref(window, url);
230
231
  return;
231
232
  }
232
233
 
@@ -246,7 +247,7 @@ export async function navigateToUrl(
246
247
 
247
248
  if (typeof scrollTarget === 'string') {
248
249
  const urlWithHash = parsed.pathname + parsed.search + '#' + scrollTarget;
249
- window.location.replace(urlWithHash);
250
+ globals.replaceLocation(window, urlWithHash);
250
251
  window.history.replaceState({ navIndex: historyIndex }, '', urlWithHash);
251
252
  } else if (typeof scrollTarget === 'number') {
252
253
  window.scrollTo({ top: scrollTarget });
@@ -7,6 +7,7 @@ import {
7
7
  type ResponseValidators,
8
8
  } from '../validators';
9
9
  import { NAVIGATION_EVENT, refreshCurrentPage } from '../navigate/runtime';
10
+ import { globals } from '../globals';
10
11
 
11
12
  const POLL_INTERVAL_MS = 600_000;
12
13
 
@@ -96,7 +97,7 @@ export function mountPageUpdate(
96
97
 
97
98
  function scheduleNextCheck() {
98
99
  clearTimer();
99
- if (document.hidden) {
100
+ if (globals.isDocumentHidden(document)) {
100
101
  return;
101
102
  }
102
103
  timer = window.setTimeout(() => {
@@ -107,17 +108,20 @@ export function mountPageUpdate(
107
108
  async function fetchValidators(
108
109
  path: string,
109
110
  ): Promise<ResponseValidators | null> {
110
- if (typeof __IS_DEV__ !== 'undefined' && __IS_DEV__) {
111
+ if (__IS_DEV__) {
111
112
  console.log(
112
- `[page-update] HEAD ${path} visibility=${document.visibilityState} focus=${document.hasFocus()}`,
113
+ `[page-update] HEAD ${path} hidden=${globals.isDocumentHidden(document)} visibility=${document.visibilityState} focus=${document.hasFocus()}`,
113
114
  );
114
115
  }
115
116
 
116
- const res = await fetch(path, { method: 'HEAD', cache: 'no-cache' });
117
+ const res = await globals.fetch(path, {
118
+ method: 'HEAD',
119
+ cache: 'no-cache',
120
+ });
117
121
 
118
- if (typeof __IS_DEV__ !== 'undefined' && __IS_DEV__) {
122
+ if (__IS_DEV__) {
119
123
  console.log(
120
- `[page-update] HEAD ${path} -> ${res.status} etag=${res.headers.get('ETag')} last-modified=${res.headers.get('Last-Modified')} visibility=${document.visibilityState} focus=${document.hasFocus()}`,
124
+ `[page-update] HEAD ${path} -> ${res.status} etag=${res.headers.get('ETag')} last-modified=${res.headers.get('Last-Modified')} hidden=${globals.isDocumentHidden(document)} visibility=${document.visibilityState} focus=${document.hasFocus()}`,
121
125
  );
122
126
  }
123
127
 
@@ -170,7 +174,7 @@ export function mountPageUpdate(
170
174
  // best-effort
171
175
  } finally {
172
176
  checkInFlight = false;
173
- if (rerunAfterCurrentCheck && !document.hidden) {
177
+ if (rerunAfterCurrentCheck && !globals.isDocumentHidden(document)) {
174
178
  rerunAfterCurrentCheck = false;
175
179
  shouldRerun = true;
176
180
  } else {
@@ -191,20 +195,20 @@ export function mountPageUpdate(
191
195
  hasBaseline = false;
192
196
  hideToast();
193
197
  clearTimer();
194
- if (!document.hidden) {
198
+ if (!globals.isDocumentHidden(document)) {
195
199
  void checkForUpdate();
196
200
  }
197
201
  }
198
202
 
199
203
  function handleVisibilityChange() {
200
- if (typeof __IS_DEV__ !== 'undefined' && __IS_DEV__) {
204
+ if (__IS_DEV__) {
201
205
  console.log(
202
- `[page-update] visibilitychange hidden=${document.hidden} visibility=${document.visibilityState} focus=${document.hasFocus()}`,
206
+ `[page-update] visibilitychange hidden=${globals.isDocumentHidden(document)} visibility=${document.visibilityState} focus=${document.hasFocus()}`,
203
207
  );
204
208
  }
205
209
 
206
210
  clearTimer();
207
- if (document.hidden) {
211
+ if (globals.isDocumentHidden(document)) {
208
212
  return;
209
213
  }
210
214
 
@@ -212,7 +216,7 @@ export function mountPageUpdate(
212
216
  }
213
217
 
214
218
  function handleNavigation() {
215
- if (typeof __IS_DEV__ !== 'undefined' && __IS_DEV__) {
219
+ if (__IS_DEV__) {
216
220
  console.log(
217
221
  `[page-update] navigation path=${window.location.pathname} visibility=${document.visibilityState} focus=${document.hasFocus()}`,
218
222
  );
@@ -227,7 +231,7 @@ export function mountPageUpdate(
227
231
  }
228
232
 
229
233
  function handleWindowFocus() {
230
- if (typeof __IS_DEV__ !== 'undefined' && __IS_DEV__) {
234
+ if (__IS_DEV__) {
231
235
  console.log(
232
236
  `[page-update] window focus visibility=${document.visibilityState} focus=${document.hasFocus()}`,
233
237
  );
@@ -235,7 +239,7 @@ export function mountPageUpdate(
235
239
  }
236
240
 
237
241
  function handleWindowBlur() {
238
- if (typeof __IS_DEV__ !== 'undefined' && __IS_DEV__) {
242
+ if (__IS_DEV__) {
239
243
  console.log(
240
244
  `[page-update] window blur visibility=${document.visibilityState} focus=${document.hasFocus()}`,
241
245
  );
@@ -253,8 +257,8 @@ export function mountPageUpdate(
253
257
  window.addEventListener('blur', handleWindowBlur);
254
258
  window.addEventListener(NAVIGATION_EVENT, handleNavigation);
255
259
 
256
- if (!document.hidden) {
257
- if (typeof __IS_DEV__ !== 'undefined' && __IS_DEV__) {
260
+ if (!globals.isDocumentHidden(document)) {
261
+ if (__IS_DEV__) {
258
262
  console.log(
259
263
  `[page-update] mount path=${currentPath} interval=${pollIntervalMs} visibility=${document.visibilityState} focus=${document.hasFocus()}`,
260
264
  );
@@ -82,10 +82,6 @@
82
82
  border: none;
83
83
  }
84
84
 
85
- a.heading-anchor::after {
86
- display: none;
87
- }
88
-
89
85
  .external-link-tail::after,
90
86
  a.external:not(:has(.external-link-tail))::after {
91
87
  content: none;
@@ -6,6 +6,7 @@ import {
6
6
  hasResponseValidatorsChanged,
7
7
  type ResponseValidators,
8
8
  } from '../validators';
9
+ import { globals } from '../globals';
9
10
 
10
11
  const MAX_RESULTS = 24;
11
12
 
@@ -39,6 +40,11 @@ interface Pagefind {
39
40
 
40
41
  let pagefind: Pagefind | null = null;
41
42
 
43
+ // Unit tests need to clear the module-scoped Pagefind instance between cases.
44
+ export function resetPagefindForTest() {
45
+ pagefind = null;
46
+ }
47
+
42
48
  type SubResult = { title: string; url: string; excerpt: string };
43
49
 
44
50
  type State = {
@@ -48,18 +54,18 @@ type State = {
48
54
  totalResults: number;
49
55
  };
50
56
 
51
- async function doSearch(state: State) {
57
+ type SearchState = Pick<State, 'results' | 'totalResults'> | null;
58
+
59
+ async function doSearch(query: string, window: Window): Promise<SearchState> {
52
60
  if (pagefind == null) {
53
- return;
61
+ return null;
54
62
  }
55
63
 
56
- if (!state.value) {
57
- state.results = [];
58
- state.totalResults = 0;
59
- return;
64
+ if (!query) {
65
+ return { results: [], totalResults: 0 };
60
66
  }
61
67
 
62
- const search = await pagefind.search(state.value);
68
+ const search = await pagefind.search(query);
63
69
  const slice = search.results.slice(0, MAX_RESULTS);
64
70
  const data = await Promise.all(slice.map(r => r.data()));
65
71
 
@@ -95,8 +101,7 @@ async function doSearch(state: State) {
95
101
  });
96
102
 
97
103
  const grouped = groupPdfResults(results);
98
- state.totalResults = grouped.length;
99
- state.results = grouped;
104
+ return { totalResults: grouped.length, results: grouped };
100
105
  }
101
106
 
102
107
  function applyHighlight(
@@ -295,15 +300,16 @@ export default (window: Window) => {
295
300
  return;
296
301
  }
297
302
 
298
- pagefind = (await import(
299
- applyBasePath('/pagefind/pagefind.js')
303
+ pagefind = (await globals.importModule(
304
+ applyBasePath('/pagefind/pagefind.js'),
300
305
  )) as Pagefind;
301
306
 
302
307
  await pagefind.init();
303
308
 
304
- const res = await fetch(applyBasePath('/pagefind/pagefind-entry.json'), {
305
- cache: 'no-cache',
306
- });
309
+ const res = await globals.fetch(
310
+ applyBasePath('/pagefind/pagefind-entry.json'),
311
+ { cache: 'no-cache' },
312
+ );
307
313
 
308
314
  if (res.ok) {
309
315
  entryValidators = getResponseValidators(res);
@@ -311,24 +317,26 @@ export default (window: Window) => {
311
317
  }
312
318
 
313
319
  async function checkForIndexUpdate() {
314
- if (indexCheckInFlight || Date.now() - lastIndexCheck < 3000) {
320
+ if (indexCheckInFlight || globals.now() - lastIndexCheck < 3000) {
315
321
  return;
316
322
  }
317
- lastIndexCheck = Date.now();
323
+ lastIndexCheck = globals.now();
318
324
  indexCheckInFlight = true;
319
325
  try {
320
- const res = await fetch(applyBasePath('/pagefind/pagefind-entry.json'), {
321
- method: 'HEAD',
322
- cache: 'no-cache',
323
- });
326
+ const res = await globals.fetch(
327
+ applyBasePath('/pagefind/pagefind-entry.json'),
328
+ { method: 'HEAD', cache: 'no-cache' },
329
+ );
324
330
  if (!res.ok) {
325
331
  return;
326
332
  }
327
333
  const newValidators = getResponseValidators(res);
328
334
  if (hasResponseValidatorsChanged(entryValidators, newValidators)) {
335
+ invalidateUpdates();
329
336
  pagefind = null;
330
337
  await loadPagefind();
331
- await update();
338
+ const updateId = ++latestUpdateId;
339
+ await update(updateId);
332
340
  }
333
341
  } catch {
334
342
  // best-effort
@@ -348,17 +356,36 @@ export default (window: Window) => {
348
356
  totalResults: 0,
349
357
  };
350
358
 
351
- async function update() {
359
+ let latestUpdateId = 0;
360
+
361
+ async function update(updateId: number) {
352
362
  if (state.showResults) {
353
363
  render(input!, resultsContainer, state, true);
354
364
  }
355
- await doSearch(state);
365
+ const query = state.value;
366
+ const nextState = await doSearch(query, window);
367
+ if (updateId !== latestUpdateId) {
368
+ return;
369
+ }
370
+ if (nextState) {
371
+ state.results = nextState.results;
372
+ state.totalResults = nextState.totalResults;
373
+ }
356
374
  if (!state.showResults) {
357
375
  return;
358
376
  }
359
377
  render(input!, resultsContainer, state, false);
360
378
  }
361
379
 
380
+ function queueUpdate() {
381
+ const updateId = ++latestUpdateId;
382
+ update(updateId).catch(() => {});
383
+ }
384
+
385
+ function invalidateUpdates() {
386
+ latestUpdateId += 1;
387
+ }
388
+
362
389
  function hide() {
363
390
  if (!state.showResults) {
364
391
  return;
@@ -374,13 +401,14 @@ export default (window: Window) => {
374
401
  }
375
402
  state.value = value;
376
403
  if (value.length === 0) {
404
+ invalidateUpdates();
377
405
  hide();
378
406
  state.results = [];
379
407
  state.totalResults = 0;
380
408
  return;
381
409
  }
382
410
  state.showResults = true;
383
- update().catch(() => {});
411
+ queueUpdate();
384
412
  }
385
413
 
386
414
  function handleWindowPointerMove() {
@@ -397,7 +425,7 @@ export default (window: Window) => {
397
425
  checkForIndexUpdate().catch(() => {});
398
426
  if (!state.showResults) {
399
427
  state.showResults = true;
400
- update().catch(() => {});
428
+ queueUpdate();
401
429
  }
402
430
  }
403
431