@abreen/tada 1.19.2 → 1.19.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.
package/README.md CHANGED
@@ -68,6 +68,7 @@ A static site generator. The successor to Presto.
68
68
  * `??? question ... ???` renders a Q&A section; answer is hidden until click
69
69
  - To display clickable multiple choice options, use a [task list][task-list]
70
70
  * `+++ ... +++ ... +++` renders a two-column layout
71
+ * `[Title][Description](url)` renders a two-line superlink
71
72
  * Special heading subtitles with `## Heading # A subtitle here`
72
73
  * `{{{ _partial.md }}}` syntax for including partials
73
74
 
@@ -5,16 +5,23 @@ import type { SiteVariables } from './types';
5
5
 
6
6
  const log = makeLogger(import.meta.url);
7
7
 
8
+ export function isExternalHref(
9
+ href: string,
10
+ siteVariables: SiteVariables,
11
+ ): boolean {
12
+ if (!href.match(/^https?:\/\/.*$/)) {
13
+ return false;
14
+ }
15
+ const url = new URL(href);
16
+ return !siteVariables.internalDomains?.includes(url.host);
17
+ }
18
+
8
19
  export default function externalLinks(
9
20
  md: MarkdownIt,
10
21
  siteVariables: SiteVariables,
11
22
  ): void {
12
23
  function isExternal(href: string): boolean {
13
- if (!href.match(/^https?:\/\/.*$/)) {
14
- return false;
15
- }
16
- const url = new URL(href);
17
- return !siteVariables.internalDomains?.includes(url.host);
24
+ return isExternalHref(href, siteVariables);
18
25
  }
19
26
 
20
27
  function findMatchingClose(children: Token[], openIdx: number): number {
@@ -0,0 +1,146 @@
1
+ import type MarkdownIt from 'markdown-it';
2
+ import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs';
3
+ import { isExternalHref } from './external-links-plugin';
4
+ import type { SiteVariables } from './types';
5
+
6
+ const OPEN_BRACKET = 0x5b;
7
+ const CARET = 0x5e;
8
+ const OPEN_PAREN = 0x28;
9
+ const CLOSE_PAREN = 0x29;
10
+ const NEWLINE = 0x0a;
11
+
12
+ // Renders `[Title][Description](/destination.html)` as a two-line superlink.
13
+ // It must run before the built-in `link` rule, which would otherwise
14
+ // treat `[Title][Description]` as a reference link.
15
+ export default function superlinkPlugin(
16
+ md: MarkdownIt,
17
+ siteVariables: SiteVariables,
18
+ ): void {
19
+ function skipSpaces(src: string, pos: number, max: number): number {
20
+ while (pos < max) {
21
+ const code = src.charCodeAt(pos);
22
+ if (!md.utils.isSpace(code) && code !== NEWLINE) {
23
+ break;
24
+ }
25
+ pos++;
26
+ }
27
+ return pos;
28
+ }
29
+
30
+ function pushPart(
31
+ state: StateInline,
32
+ name: 'title' | 'description',
33
+ start: number,
34
+ end: number,
35
+ ): void {
36
+ const open = state.push(`superlink_${name}_open`, 'span', 1);
37
+ open.attrs = [['class', `superlink-${name}`]];
38
+
39
+ const oldPos = state.pos;
40
+ const oldMax = state.posMax;
41
+ state.pos = start;
42
+ state.posMax = end;
43
+ state.md.inline.tokenize(state);
44
+ state.pos = oldPos;
45
+ state.posMax = oldMax;
46
+
47
+ state.push(`superlink_${name}_close`, 'span', -1);
48
+ }
49
+
50
+ function superlinkRule(state: StateInline, silent: boolean): boolean {
51
+ const src = state.src;
52
+ const max = state.posMax;
53
+
54
+ if (src.charCodeAt(state.pos) !== OPEN_BRACKET) {
55
+ return false;
56
+ }
57
+
58
+ // Leave a footnote reference such as `[^1][source](url)` to the footnote
59
+ // plugin, whose rule would otherwise never see it.
60
+ if (src.charCodeAt(state.pos + 1) === CARET) {
61
+ return false;
62
+ }
63
+
64
+ const titleStart = state.pos + 1;
65
+ const titleEnd = state.md.helpers.parseLinkLabel(state, state.pos, true);
66
+ if (titleEnd < 0) {
67
+ return false;
68
+ }
69
+
70
+ // The description label must follow the title label immediately.
71
+ const descriptionOpen = titleEnd + 1;
72
+ if (
73
+ descriptionOpen >= max ||
74
+ src.charCodeAt(descriptionOpen) !== OPEN_BRACKET
75
+ ) {
76
+ return false;
77
+ }
78
+ const descriptionStart = descriptionOpen + 1;
79
+ const descriptionEnd = state.md.helpers.parseLinkLabel(
80
+ state,
81
+ descriptionOpen,
82
+ true,
83
+ );
84
+ if (descriptionEnd < 0) {
85
+ return false;
86
+ }
87
+
88
+ if (
89
+ src.slice(titleStart, titleEnd).trim() === '' ||
90
+ src.slice(descriptionStart, descriptionEnd).trim() === ''
91
+ ) {
92
+ return false;
93
+ }
94
+
95
+ let pos = descriptionEnd + 1;
96
+ if (pos >= max || src.charCodeAt(pos) !== OPEN_PAREN) {
97
+ return false;
98
+ }
99
+ pos = skipSpaces(src, pos + 1, max);
100
+ if (pos >= max) {
101
+ return false;
102
+ }
103
+
104
+ const destination = state.md.helpers.parseLinkDestination(src, pos, max);
105
+ if (!destination.ok) {
106
+ return false;
107
+ }
108
+ const href = state.md.normalizeLink(destination.str);
109
+ if (!state.md.validateLink(href)) {
110
+ return false;
111
+ }
112
+
113
+ pos = skipSpaces(src, destination.pos, max);
114
+ if (pos >= max || src.charCodeAt(pos) !== CLOSE_PAREN) {
115
+ return false;
116
+ }
117
+
118
+ if (!silent) {
119
+ const external = isExternalHref(href, siteVariables);
120
+ const open = state.push('superlink_open', 'a', 1);
121
+ open.attrs = [
122
+ ['href', href],
123
+ ['class', external ? 'button superlink external' : 'button superlink'],
124
+ ];
125
+ if (external) {
126
+ open.attrs.push(['target', '_blank'], ['rel', 'noopener noreferrer']);
127
+ }
128
+
129
+ pushPart(state, 'title', titleStart, titleEnd);
130
+
131
+ // The spans are blocks, so this collapses visually, but it keeps text
132
+ // extraction (search indexing, copy and paste) from gluing the last word
133
+ // of the title to the first word of the description.
134
+ state.push('text', '', 0).content = '\n';
135
+
136
+ pushPart(state, 'description', descriptionStart, descriptionEnd);
137
+
138
+ state.push('superlink_close', 'a', -1);
139
+ }
140
+
141
+ state.pos = pos + 1;
142
+ return true;
143
+ }
144
+
145
+ md.inline.ruler.before('link', 'superlink', superlinkRule);
146
+ }
@@ -12,6 +12,7 @@ import { isBundledLanguage, isPlainTextLanguage } from '../site-variables';
12
12
  import headingSubtitlePlugin from '../heading-subtitle-plugin';
13
13
  import deflistIdPlugin from '../deflist-id-plugin';
14
14
  import externalLinksPlugin from '../external-links-plugin';
15
+ import superlinkPlugin from '../superlink-plugin';
15
16
  import { tocPlugin } from '../toc-plugin';
16
17
  import columnsPlugin from '../columns-plugin';
17
18
  import katexPlugin from './katex';
@@ -61,6 +62,7 @@ export function createMarkdown(
61
62
  .use(markdownItDeflist)
62
63
  .use(deflistIdPlugin)
63
64
  .use(externalLinksPlugin, siteVariables)
65
+ .use(superlinkPlugin, siteVariables)
64
66
  .use(tocPlugin)
65
67
  .use(columnsPlugin)
66
68
  .use(katexPlugin)
@@ -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;
@@ -373,6 +373,27 @@ static boolean isOdd(int n) {
373
373
  ```
374
374
  +++
375
375
 
376
+ ### Superlinks
377
+
378
+ Use
379
+
380
+ ```
381
+ [Home][Back to the front page](index.html)
382
+ [Markdown][You are here](markdown.html)
383
+ [KaTeX][The library behind math support](https://katex.org/)
384
+ ```
385
+
386
+ to create a link with a title and a description:
387
+
388
+ [Home][Back to the front page](index.html)
389
+ [Markdown][You are here](markdown.html)
390
+ [KaTeX][The library behind math support](https://katex.org/)
391
+
392
+ Superlinks are rendered as block elements, so don't use them inline, in the
393
+ middle of a paragraph, or in a heading. Put them on their own lines, as above.
394
+ A superlink to another website, like the last one above, opens in a new tab and
395
+ shows an external link icon in place of the chevron.
396
+
376
397
  ---
377
398
 
378
399
  ## Additional features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.19.2",
3
+ "version": "1.19.4",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
package/src/_alerts.scss CHANGED
@@ -35,7 +35,7 @@
35
35
  background: inherit;
36
36
  }
37
37
 
38
- a {
38
+ a:not(.superlink) {
39
39
  @include link-underline;
40
40
 
41
41
  color: var(--fg-color);
package/src/_content.scss CHANGED
@@ -164,6 +164,60 @@ select:focus {
164
164
  border-color: var(--fg-color);
165
165
  }
166
166
 
167
+ a.superlink {
168
+ position: relative;
169
+ display: block;
170
+ padding-block: calc(var(--gap) / 2);
171
+ padding-inline: var(--gap)
172
+ calc(var(--gap) * 2 + var(--icon-breadcrumb-separator-size));
173
+ font-size: inherit;
174
+ text-align: left;
175
+
176
+ + a.superlink {
177
+ margin-top: var(--gap);
178
+ }
179
+
180
+ .superlink-title {
181
+ display: block;
182
+ font-weight: 600;
183
+ }
184
+
185
+ .superlink-description {
186
+ display: block;
187
+ font-weight: normal;
188
+ color: var(--fg2-color);
189
+ }
190
+
191
+ &::after {
192
+ @include contained-mask(var(--icon-breadcrumb-separator));
193
+
194
+ position: absolute;
195
+ top: 50%;
196
+ right: var(--gap);
197
+ width: var(--icon-breadcrumb-separator-size);
198
+ height: var(--icon-breadcrumb-separator-size);
199
+ content: '';
200
+ background-color: var(--fg2-color);
201
+ transform: translateY(-50%);
202
+ }
203
+
204
+ // External superlinks get the external-link icon instead of the chevron from
205
+ // the shared `a.external` rules above, which win on mask, size and content.
206
+ // They also set currentcolor, so restore the subdued color. The icon's box
207
+ // is smaller than the chevron's, so shift it left to share the chevron's
208
+ // center, which keeps the two optically aligned.
209
+ &.external::after {
210
+ right: calc(
211
+ var(--gap) +
212
+ (
213
+ var(--icon-breadcrumb-separator-size) - var(--icon-external-link-size)
214
+ ) /
215
+ 2
216
+ );
217
+ background-color: var(--fg2-color);
218
+ }
219
+ }
220
+
167
221
  button.icon-button {
168
222
  display: inline-flex;
169
223
  align-items: center;
@@ -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 {