@abreen/tada 1.19.1 → 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.1",
3
+ "version": "1.19.3",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
@@ -5,6 +5,9 @@
5
5
  * Tada also allows raw HTML, so an author can create a decoded ID like
6
6
  * <h2 id="hello world">Hello</h2>. A link to that target becomes
7
7
  * #hello%20world, and getElementById('hello%20world') would not find it.
8
+ *
9
+ * Like the browser, fall back to a legacy <a name="..."> anchor when no
10
+ * element has the ID.
8
11
  */
9
12
  export function getHashTarget(
10
13
  document: Document,
@@ -15,15 +18,31 @@ export function getHashTarget(
15
18
  return null;
16
19
  }
17
20
 
18
- const rawTarget = document.getElementById(id);
21
+ const rawTarget = findIndicatedElement(document, id);
19
22
  if (rawTarget) {
20
23
  return rawTarget;
21
24
  }
22
25
 
23
26
  try {
24
27
  const decodedId = decodeURIComponent(id);
25
- return decodedId === id ? null : document.getElementById(decodedId);
28
+ return decodedId === id ? null : findIndicatedElement(document, decodedId);
26
29
  } catch {
27
30
  return null;
28
31
  }
29
32
  }
33
+
34
+ function findIndicatedElement(
35
+ document: Document,
36
+ fragment: string,
37
+ ): HTMLElement | null {
38
+ const byId = document.getElementById(fragment);
39
+ if (byId) {
40
+ return byId;
41
+ }
42
+ for (const element of document.getElementsByName(fragment)) {
43
+ if (element.localName === 'a') {
44
+ return element;
45
+ }
46
+ }
47
+ return null;
48
+ }
@@ -105,12 +105,10 @@ html.navigation-loading * {
105
105
  mix-blend-mode: normal;
106
106
  }
107
107
 
108
- // The footer and appearance controls get their own stationary layer. Show only
109
- // the incoming snapshot so disabled defaults and stale hover states cannot blend.
110
- .page-bottom {
111
- view-transition-name: page-bottom;
112
- }
113
-
108
+ // The footer and appearance controls get their own layer while they are in the
109
+ // viewport. view-transition-name is set via inline style by the navigator. On
110
+ // screen on both pages, the layer stays put and shows only the incoming
111
+ // snapshot so disabled defaults and stale hover states cannot blend.
114
112
  ::view-transition-group(page-bottom),
115
113
  ::view-transition-old(page-bottom),
116
114
  ::view-transition-new(page-bottom) {
@@ -125,6 +123,19 @@ html.navigation-loading * {
125
123
  mix-blend-mode: normal;
126
124
  }
127
125
 
126
+ // On screen on only one page, such as between a short page and the top of a
127
+ // long page: fade out or in where they are, in step with the page. Fill both
128
+ // ways, like the UA default, so the faded-out snapshot cannot reappear in the
129
+ // transition's last frame.
130
+ ::view-transition-old(page-bottom):only-child {
131
+ display: block;
132
+ animation: page-fade-out $duration ease-in both;
133
+ }
134
+
135
+ ::view-transition-new(page-bottom):only-child {
136
+ animation: page-fade-in $duration ease-out both;
137
+ }
138
+
128
139
  // Forward navigation: old content slides left, new content slides in from right
129
140
  .nav-forward::view-transition-old(root) {
130
141
  animation:
@@ -1,5 +1,6 @@
1
1
  import { isEligibleLink } from './eligible';
2
2
  import {
3
+ applyFragmentTarget,
3
4
  clearSearch,
4
5
  closeHeaderDetails,
5
6
  getCurrentPath,
@@ -7,6 +8,7 @@ import {
7
8
  getHistoryIndex,
8
9
  getSavedScroll,
9
10
  initNavigation,
11
+ isApplyingFragment,
10
12
  navigateToUrl,
11
13
  saveScrollPosition,
12
14
  setCurrentPath,
@@ -134,6 +136,10 @@ export default function mountNavigate(window: Window): () => void {
134
136
  }
135
137
 
136
138
  function handlePopState(event: PopStateEvent) {
139
+ if (isApplyingFragment()) {
140
+ return;
141
+ }
142
+
137
143
  if (clearingFragment) {
138
144
  clearingFragment = false;
139
145
  if (!window.location.hash) {
@@ -145,19 +151,24 @@ export default function mountNavigate(window: Window): () => void {
145
151
  if (newPath === getCurrentPath()) {
146
152
  const locationKey = newPath + window.location.hash;
147
153
  const savedY = getSavedLocationScroll(locationKey);
154
+ // Fragment traversal can update :target and apply its native target
155
+ // scroll after popstate. Correct both in the next task so the URL and our
156
+ // saved position win. Capture savedY now because the native scroll event
157
+ // can update the location map first.
158
+ window.setTimeout(() => {
159
+ const currentLocationKey =
160
+ window.location.pathname +
161
+ window.location.search +
162
+ window.location.hash;
163
+ if (currentLocationKey !== locationKey) {
164
+ return;
165
+ }
166
+ applyFragmentTarget(window);
167
+ if (typeof savedY === 'number') {
168
+ window.scrollTo({ top: savedY });
169
+ }
170
+ }, 0);
148
171
  if (typeof savedY === 'number') {
149
- // Fragment traversal can apply its native target scroll after popstate.
150
- // Restore in the next task so our saved position wins. Capture savedY
151
- // now because the native scroll event can update the location map first.
152
- window.setTimeout(() => {
153
- const currentLocationKey =
154
- window.location.pathname +
155
- window.location.search +
156
- window.location.hash;
157
- if (currentLocationKey === locationKey) {
158
- window.scrollTo({ top: savedY });
159
- }
160
- }, 0);
161
172
  return;
162
173
  }
163
174
 
@@ -5,18 +5,31 @@ import {
5
5
  teardownPerPageComponents,
6
6
  } from './lifecycle';
7
7
  import { globals } from '../globals';
8
+ import { getHashTarget } from '../hash-target';
8
9
  import { swapHeaderTitle } from '../header';
9
10
 
10
11
  export const NAVIGATION_EVENT = 'tada:navigation';
11
12
 
12
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;
13
21
 
14
22
  let currentAbortController: AbortController | null = null;
15
23
  let historyIndex = 0;
16
24
  let currentPath = '';
25
+ let applyingFragment = false;
26
+ let fragmentTargetRequest = 0;
17
27
 
18
28
  const scrollByIndex = new Map<number, number>();
19
29
  const scrollByLocation = new Map<string, number>();
30
+ // Stylesheets the navigator added that have not loaded or failed yet. Those in
31
+ // the initial document have settled before the deferred client script runs.
32
+ const pendingStylesheets = new Map<Element, Promise<void>>();
20
33
 
21
34
  type Direction = 'forward' | 'back';
22
35
 
@@ -65,7 +78,19 @@ function updateHead(document: Document, newDoc: Document): void {
65
78
 
66
79
  for (const link of newDoc.querySelectorAll('link[rel="stylesheet"]')) {
67
80
  if (!existingHrefs.has(link.getAttribute('href'))) {
68
- document.head.appendChild(link.cloneNode(true));
81
+ const clone = link.cloneNode(true) as Element;
82
+ pendingStylesheets.set(
83
+ clone,
84
+ new Promise(resolve => {
85
+ const settle = () => {
86
+ pendingStylesheets.delete(clone);
87
+ resolve();
88
+ };
89
+ clone.addEventListener('load', settle, { once: true });
90
+ clone.addEventListener('error', settle, { once: true });
91
+ }),
92
+ );
93
+ document.head.appendChild(clone);
69
94
  }
70
95
  }
71
96
  }
@@ -138,11 +163,29 @@ function setTitleTransitionNames(titleEl: Element, enabled: boolean): void {
138
163
  }
139
164
  }
140
165
 
166
+ // The footer and appearance pickers get their own layer only while they are in
167
+ // the viewport, so the layer stays put when they are on screen on both pages
168
+ // and fades on its own when they are on screen on only one.
169
+ function setPageBottomTransitionName(window: Window): void {
170
+ const bottom = window.document.querySelector<HTMLElement>('.page-bottom');
171
+ if (!bottom) {
172
+ return;
173
+ }
174
+ const rect = bottom.getBoundingClientRect();
175
+ if (rect.bottom > 0 && rect.top < window.innerHeight) {
176
+ bottom.style.viewTransitionName = 'page-bottom';
177
+ }
178
+ }
179
+
141
180
  function cleanupViewTransitionNames(document: Document): void {
142
181
  const titleEl = document.querySelector('.title-and-info');
143
182
  if (titleEl) {
144
183
  setTitleTransitionNames(titleEl, false);
145
184
  }
185
+ const bottom = document.querySelector<HTMLElement>('.page-bottom');
186
+ if (bottom) {
187
+ bottom.style.viewTransitionName = '';
188
+ }
146
189
  }
147
190
 
148
191
  function getLocationKey(window: Window): string {
@@ -213,6 +256,111 @@ export function getSavedLocationScroll(
213
256
  return scrollByLocation.get(locationKey);
214
257
  }
215
258
 
259
+ // Browsers fire popstate synchronously during applyFragmentTarget
260
+ export function isApplyingFragment(): boolean {
261
+ return applyingFragment;
262
+ }
263
+
264
+ // Restoring a history entry can leave :target stale. With manual scroll
265
+ // restoration, WebKit skips fragment processing on Back/Forward, and swapped
266
+ // content loses it in every browser. A fragment navigation sets it in every
267
+ // browser, but it can reset the entry's history state and scroll position, so
268
+ // restore both.
269
+ //
270
+ // WebKit defers fragment scrolling until pending stylesheets load, which would
271
+ // later move the page away from the restored scroll position. Wait for them
272
+ // first, and skip the update if another request or navigation replaced it.
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 {
286
+ const request = ++fragmentTargetRequest;
287
+ const { href } = window.location;
288
+ let waited = false;
289
+ const apply = (): void => {
290
+ if (request !== fragmentTargetRequest || window.location.href !== href) {
291
+ return;
292
+ }
293
+ const pending = Array.from(pendingStylesheets.values());
294
+ if (pending.length > 0) {
295
+ waited = true;
296
+ void Promise.all(pending).then(apply);
297
+ return;
298
+ }
299
+ replaceFragmentTarget(window, waited ? restoredTop : undefined);
300
+ };
301
+ apply();
302
+ }
303
+
304
+ function replaceFragmentTarget(window: Window, forcedTop?: number): void {
305
+ const { document, history, location } = window;
306
+ if (
307
+ forcedTop === undefined &&
308
+ document.querySelector(':target') === getHashTarget(document, location.hash)
309
+ ) {
310
+ return;
311
+ }
312
+ const { href } = location;
313
+ const state = history.state;
314
+ const { scrollX } = window;
315
+ const scrollY = forcedTop ?? window.scrollY;
316
+ applyingFragment = true;
317
+ try {
318
+ globals.replaceLocation(
319
+ window,
320
+ location.pathname + location.search + (location.hash || '#'),
321
+ );
322
+ } finally {
323
+ applyingFragment = false;
324
+ }
325
+ history.replaceState(state, '', href);
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);
362
+ }
363
+
216
364
  export async function navigateToUrl(
217
365
  window: Window,
218
366
  options: NavigationOptions,
@@ -323,6 +471,14 @@ export async function navigateToUrl(
323
471
 
324
472
  teardownPerPageComponents();
325
473
  swapContent(document, newDoc);
474
+ if (!pushHistory) {
475
+ // Restore the entry's :target before updateHead adds stylesheets, so it
476
+ // applies right away unless an earlier stylesheet is still loading
477
+ applyFragmentTarget(
478
+ window,
479
+ typeof scrollTarget === 'number' ? scrollTarget : undefined,
480
+ );
481
+ }
326
482
  updateHead(document, newDoc);
327
483
  mountAppearancePickerForPage(window);
328
484
  currentPath = parsed.pathname + parsed.search;
@@ -369,6 +525,7 @@ export async function navigateToUrl(
369
525
  if (titleVisible) {
370
526
  setTitleTransitionNames(titleEl, true);
371
527
  }
528
+ setPageBottomTransitionName(window);
372
529
 
373
530
  document.documentElement.classList.add(
374
531
  direction === 'forward' ? 'nav-forward' : 'nav-back',
@@ -389,6 +546,7 @@ export async function navigateToUrl(
389
546
  if (newTitleVisible && newTitleEl) {
390
547
  setTitleTransitionNames(newTitleEl, true);
391
548
  }
549
+ setPageBottomTransitionName(window);
392
550
  });
393
551
 
394
552
  await transition.finished;
@@ -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 {
@@ -54,7 +54,7 @@
54
54
  'Segoe UI Symbol';
55
55
  --sans-mono-font: 'Google Sans Code', 'Consolas', 'Courier', monospace;
56
56
  --serif-font: <%= serifFontStack %>;
57
- --serif-preview-font: 'Times New Roman', 'Times', serif;
57
+ --serif-preview-font: 'Georgia', 'Times New Roman', 'Times', serif;
58
58
  --serif-mono-font: <%= serifMonoFontStack %>;
59
59
  --sans-font-feature-settings:
60
60
  'liga', 'calt', 'ss07', 'ss08', 'cv01', 'cv08', 'cv10';