@abreen/tada 1.19.2 → 1.19.3

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.
@@ -22,6 +22,10 @@ export function runWatchEngine<Meta>(
22
22
  const debounceMs = options.debounceMs ?? 300;
23
23
  const watchers: ReturnType<typeof chokidar.watch>[] = [];
24
24
  const pending = new Set<string>();
25
+ const removed = new Map<
26
+ string,
27
+ { watcher: ReturnType<typeof chokidar.watch>; root: string }
28
+ >();
25
29
  let uncommitted = new Set<string>();
26
30
  let closed = false;
27
31
  let fatal: { error: unknown } | undefined;
@@ -33,6 +37,7 @@ export function runWatchEngine<Meta>(
33
37
  function stop(): void {
34
38
  closed = true;
35
39
  pending.clear();
40
+ removed.clear();
36
41
  if (timer !== undefined) {
37
42
  clock.clearTimeout(timer);
38
43
  }
@@ -78,6 +83,52 @@ export function runWatchEngine<Meta>(
78
83
  }
79
84
  }
80
85
 
86
+ function isFile(filePath: string): boolean {
87
+ try {
88
+ return dependencies.stat(filePath)?.isFile() ?? false;
89
+ } catch (error) {
90
+ if ((error as NodeJS.ErrnoException).code === 'ENOTDIR') {
91
+ return false;
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ function isInside(root: string, filePath: string): boolean {
98
+ const relative = path.relative(root, filePath);
99
+ return (
100
+ relative !== '' &&
101
+ relative !== '..' &&
102
+ !relative.startsWith(`..${path.sep}`) &&
103
+ !path.isAbsolute(relative)
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Chokidar tracks entries by name, so a directory replaced by a file keeps
109
+ * its directory subscription and edits to the file are never reported. When
110
+ * a poll misses the moment between the two, the only events are removals of
111
+ * what was inside the directory. Once changes have settled, resubscribe any
112
+ * parent of a removed path that is now a file.
113
+ */
114
+ function resubscribeReplacedDirectories(): void {
115
+ const checked = new Set<string>();
116
+ for (const [removedPath, { watcher, root }] of removed) {
117
+ for (
118
+ let dir = path.dirname(removedPath);
119
+ isInside(root, dir) && !checked.has(dir);
120
+ dir = path.dirname(dir)
121
+ ) {
122
+ checked.add(dir);
123
+ if (isFile(dir)) {
124
+ watcher.unwatch(dir);
125
+ watcher.add(dir);
126
+ }
127
+ }
128
+ }
129
+ removed.clear();
130
+ }
131
+
81
132
  async function emit(event: WatchLifecycleEvent<Meta>): Promise<void> {
82
133
  if (!closed) {
83
134
  await options.onEvent?.(event);
@@ -139,7 +190,13 @@ export function runWatchEngine<Meta>(
139
190
  fail(error);
140
191
  }
141
192
  };
142
- watcher.on('add', included).on('unlink', included);
193
+ const removedPath = (filePath: string) => {
194
+ if (!closed) {
195
+ removed.set(filePath, { watcher, root: target.path });
196
+ }
197
+ included(filePath);
198
+ };
199
+ watcher.on('add', included).on('unlink', removedPath);
143
200
  watcher.on('change', (filePath, stats) => {
144
201
  if (closed) {
145
202
  return;
@@ -156,20 +213,18 @@ export function runWatchEngine<Meta>(
156
213
  });
157
214
  watcher.on('addDir', included);
158
215
  watcher.on('unlinkDir', filePath => {
159
- included(filePath);
216
+ removedPath(filePath);
160
217
  // Wait until Chokidar finishes closing the old directory subscription.
161
218
  queueMicrotask(() => {
162
219
  if (closed) {
163
220
  return;
164
221
  }
165
222
  try {
166
- if (dependencies.stat(filePath)?.isFile()) {
223
+ if (isFile(filePath)) {
167
224
  watcher.add(filePath);
168
225
  }
169
226
  } catch (error) {
170
- if ((error as NodeJS.ErrnoException).code !== 'ENOTDIR') {
171
- fail(error);
172
- }
227
+ fail(error);
173
228
  }
174
229
  });
175
230
  });
@@ -197,6 +252,7 @@ export function runWatchEngine<Meta>(
197
252
  while (!closed && pending.size) {
198
253
  const paths = new Set([...uncommitted, ...pending]);
199
254
  pending.clear();
255
+ resubscribeReplacedDirectories();
200
256
  const outcome = await build(paths);
201
257
  if (!outcome?.ok) {
202
258
  uncommitted = paths;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.19.2",
3
+ "version": "1.19.3",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
@@ -11,6 +11,13 @@ import { swapHeaderTitle } from '../header';
11
11
  export const NAVIGATION_EVENT = 'tada:navigation';
12
12
 
13
13
  const LOADING_CURSOR_DELAY = 400;
14
+ const HOLD_SCROLL_FRAMES = 6;
15
+ const HOLD_SCROLL_RELEASE_EVENTS = [
16
+ 'wheel',
17
+ 'touchstart',
18
+ 'keydown',
19
+ 'pointerdown',
20
+ ] as const;
14
21
 
15
22
  let currentAbortController: AbortController | null = null;
16
23
  let historyIndex = 0;
@@ -263,33 +270,49 @@ export function isApplyingFragment(): boolean {
263
270
  // WebKit defers fragment scrolling until pending stylesheets load, which would
264
271
  // later move the page away from the restored scroll position. Wait for them
265
272
  // first, and skip the update if another request or navigation replaced it.
266
- export function applyFragmentTarget(window: Window): void {
273
+ //
274
+ // That deferred scroll can also set :target before the wait ends, which would
275
+ // leave nothing to replace. WebKit then scrolls to the target again on the next
276
+ // frame, undoing a scroll back to the saved position. A fragment navigation of
277
+ // our own does not have that problem, so after waiting, repeat it even when
278
+ // :target matches, restoring the saved position the caller passed in. When the
279
+ // last stylesheet finishes loading just after the content swap, WebKit can
280
+ // still scroll to the target in the next rendering update, after our restore,
281
+ // so hold that position for a few frames.
282
+ export function applyFragmentTarget(
283
+ window: Window,
284
+ restoredTop?: number,
285
+ ): void {
267
286
  const request = ++fragmentTargetRequest;
268
287
  const { href } = window.location;
288
+ let waited = false;
269
289
  const apply = (): void => {
270
290
  if (request !== fragmentTargetRequest || window.location.href !== href) {
271
291
  return;
272
292
  }
273
293
  const pending = Array.from(pendingStylesheets.values());
274
294
  if (pending.length > 0) {
295
+ waited = true;
275
296
  void Promise.all(pending).then(apply);
276
297
  return;
277
298
  }
278
- replaceFragmentTarget(window);
299
+ replaceFragmentTarget(window, waited ? restoredTop : undefined);
279
300
  };
280
301
  apply();
281
302
  }
282
303
 
283
- function replaceFragmentTarget(window: Window): void {
304
+ function replaceFragmentTarget(window: Window, forcedTop?: number): void {
284
305
  const { document, history, location } = window;
285
306
  if (
307
+ forcedTop === undefined &&
286
308
  document.querySelector(':target') === getHashTarget(document, location.hash)
287
309
  ) {
288
310
  return;
289
311
  }
290
312
  const { href } = location;
291
313
  const state = history.state;
292
- const { scrollX, scrollY } = window;
314
+ const { scrollX } = window;
315
+ const scrollY = forcedTop ?? window.scrollY;
293
316
  applyingFragment = true;
294
317
  try {
295
318
  globals.replaceLocation(
@@ -301,6 +324,41 @@ function replaceFragmentTarget(window: Window): void {
301
324
  }
302
325
  history.replaceState(state, '', href);
303
326
  window.scrollTo({ left: scrollX, top: scrollY });
327
+ if (forcedTop !== undefined) {
328
+ holdScroll(window, scrollX, scrollY);
329
+ }
330
+ }
331
+
332
+ // Scroll back to the given position on each of the next few frames if the
333
+ // browser moved the page, until the visitor scrolls or the URL changes
334
+ function holdScroll(window: Window, left: number, top: number): void {
335
+ const { href } = window.location;
336
+ let held = true;
337
+ const release = (): void => {
338
+ held = false;
339
+ for (const type of HOLD_SCROLL_RELEASE_EVENTS) {
340
+ window.removeEventListener(type, release);
341
+ }
342
+ };
343
+ for (const type of HOLD_SCROLL_RELEASE_EVENTS) {
344
+ window.addEventListener(type, release, { passive: true });
345
+ }
346
+ let frames = 0;
347
+ const tick = (): void => {
348
+ if (!held || window.location.href !== href) {
349
+ release();
350
+ return;
351
+ }
352
+ if (Math.abs(window.scrollY - top) > 1) {
353
+ window.scrollTo({ left, top });
354
+ }
355
+ if (++frames < HOLD_SCROLL_FRAMES) {
356
+ window.requestAnimationFrame(tick);
357
+ } else {
358
+ release();
359
+ }
360
+ };
361
+ window.requestAnimationFrame(tick);
304
362
  }
305
363
 
306
364
  export async function navigateToUrl(
@@ -416,7 +474,10 @@ export async function navigateToUrl(
416
474
  if (!pushHistory) {
417
475
  // Restore the entry's :target before updateHead adds stylesheets, so it
418
476
  // applies right away unless an earlier stylesheet is still loading
419
- applyFragmentTarget(window);
477
+ applyFragmentTarget(
478
+ window,
479
+ typeof scrollTarget === 'number' ? scrollTarget : undefined,
480
+ );
420
481
  }
421
482
  updateHead(document, newDoc);
422
483
  mountAppearancePickerForPage(window);
@@ -149,10 +149,15 @@ body.code .toc ol a {
149
149
  width: var(--toc-alert-icon-size);
150
150
  height: var(--toc-alert-icon-size);
151
151
  margin-right: 4px;
152
- vertical-align: sub;
152
+ vertical-align: middle;
153
153
  content: '';
154
154
 
155
155
  @include contained-mask(var(--toc-alert-icon));
156
+
157
+ // Center the icon on the cap height, which scales with each level's font
158
+ @supports (width: 1cap) {
159
+ vertical-align: calc(0.5cap - var(--toc-alert-icon-size) / 2);
160
+ }
156
161
  }
157
162
 
158
163
  .toc ol li.label {