@texuli/core 0.4.0 → 0.5.0
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 +4 -4
- package/dist/editor/markdown.js +168 -38
- package/dist/focus-scope/focus-scope.d.ts +7 -0
- package/dist/focus-scope/focus-scope.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inert/inert.js +8 -1
- package/dist/overlay/core.js +2 -0
- package/dist/url-policy/filter-image-srcset.d.ts +11 -0
- package/dist/url-policy/filter-image-srcset.js +60 -0
- package/dist/url-policy/index.d.ts +1 -0
- package/dist/url-policy/index.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ when you need Texuli's URL policy to sanitize links and media from agents
|
|
|
12
12
|
or users. It has no runtime dependencies and imports no UI framework.
|
|
13
13
|
|
|
14
14
|
> **Status: 0.x release.** Texuli is in development. The packages are
|
|
15
|
-
> published to npm as `0.
|
|
16
|
-
> stable release). Under 0.x, a new minor (`0.
|
|
17
|
-
> changes, so pin the exact version or use `^0.
|
|
15
|
+
> published to npm as `0.5.0`, a pre-1.0 release (1.0.0 will be the first
|
|
16
|
+
> stable release). Under 0.x, a new minor (`0.6.0`) may contain breaking
|
|
17
|
+
> changes, so pin the exact version or use `^0.5.0`, which accepts only 0.5.x.
|
|
18
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
@@ -66,7 +66,7 @@ keep bundles narrow.
|
|
|
66
66
|
|
|
67
67
|
| Texel | Subpath | Headline exports |
|
|
68
68
|
| --- | --- | --- |
|
|
69
|
-
| URL policy | `url-policy` | `safeHref`, `safeImageSrc`, `safeImageSrcset`, `safeMediaSrc` |
|
|
69
|
+
| URL policy | `url-policy` | `safeHref`, `safeImageSrc`, `safeImageSrcset`, `filterImageSrcset`, `safeMediaSrc` |
|
|
70
70
|
| Ids | `ids` | `createIdScope`, `createIdFamily`, `ariaLabelledBy`, `ariaDescribedBy`, `ensureElementId` |
|
|
71
71
|
| Keyboard | `keyboard` | `parseShortcut`, `matchesShortcut`, `formatShortcut`, `createShortcutMap`, `getNavigationIntent` |
|
|
72
72
|
| Roving focus and typeahead | `roving` | `createRovingFocus`, `createTypeahead`, `setRovingTabIndex` |
|
package/dist/editor/markdown.js
CHANGED
|
@@ -14,16 +14,62 @@ export const MARKDOWN_CODEC_LIMITS = Object.freeze({
|
|
|
14
14
|
/** Nested emphasis and links. */
|
|
15
15
|
inlineDepth: 6,
|
|
16
16
|
});
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Line patterns stay linear on hostile lines: no two neighboring quantifiers
|
|
18
|
+
// can match the same characters, and `.` uses the `s` flag so U+2028 and
|
|
19
|
+
// U+2029 inside a line never make a `(.*)$` tail backtrack. The fence info
|
|
20
|
+
// string and the heading closing sequence are read by hand (their regex
|
|
21
|
+
// forms backtracked quadratically, and cubically, on runs of spaces).
|
|
22
|
+
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
|
|
23
|
+
const FENCE_LANG = /^[a-z][a-z0-9+-]{0,31}$/i;
|
|
24
|
+
const HEADING_OPEN = /^ {0,3}(#+)/;
|
|
19
25
|
const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
20
|
-
const QUOTE = /^ {0,3}> ?(.*)
|
|
21
|
-
const BULLET = /^( {0,3})([-*+])[ \t]+(.*)
|
|
22
|
-
const ORDERED = /^( {0,3})(\d{1,9})[.)][ \t]+(.*)
|
|
26
|
+
const QUOTE = /^ {0,3}> ?(.*)$/s;
|
|
27
|
+
const BULLET = /^( {0,3})([-*+])[ \t]+(.*)$/s;
|
|
28
|
+
const ORDERED = /^( {0,3})(\d{1,9})[.)][ \t]+(.*)$/s;
|
|
23
29
|
const TASK = /^\[([ xX])\][ \t]+/;
|
|
24
30
|
const IMAGE_ONLY = /^!\[((?:[^\]\\\n]|\\.)*)\]\(\s*<?([^\s()<>]+)>?\s*\)$/;
|
|
25
31
|
const ESCAPABLE = /[\\`*_{}[\]()#+\-.!~<>|]/;
|
|
26
32
|
const isBlank = (line) => line.trim() === '';
|
|
33
|
+
const isSpace = (char) => char === ' ' || char === '\t';
|
|
34
|
+
/** A fence opening line: its marker and the optional language. */
|
|
35
|
+
function readFence(line) {
|
|
36
|
+
const open = FENCE_OPEN.exec(line);
|
|
37
|
+
if (!open)
|
|
38
|
+
return null;
|
|
39
|
+
const info = line.slice(open[0].length).trim();
|
|
40
|
+
if (info !== '' && !FENCE_LANG.test(info))
|
|
41
|
+
return null;
|
|
42
|
+
return info
|
|
43
|
+
? { marker: open[1], lang: info.toLowerCase() }
|
|
44
|
+
: { marker: open[1] };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* An ATX heading line: up to 6 `#` (a 7th or later reads as part of the
|
|
48
|
+
* closing sequence), then text after a space or tab, with the closing
|
|
49
|
+
* sequence of spaces, `#` and spaces dropped from the end.
|
|
50
|
+
*/
|
|
51
|
+
function readHeading(line) {
|
|
52
|
+
const open = HEADING_OPEN.exec(line);
|
|
53
|
+
if (!open)
|
|
54
|
+
return null;
|
|
55
|
+
const hashes = open[1];
|
|
56
|
+
const level = Math.min(hashes.length, 6);
|
|
57
|
+
const rest = line.slice(open[0].length - hashes.length + level);
|
|
58
|
+
let start = 0;
|
|
59
|
+
while (isSpace(rest[start]))
|
|
60
|
+
start += 1;
|
|
61
|
+
let end = rest.length;
|
|
62
|
+
while (end > start && isSpace(rest[end - 1]))
|
|
63
|
+
end -= 1;
|
|
64
|
+
while (end > start && rest[end - 1] === '#')
|
|
65
|
+
end -= 1;
|
|
66
|
+
while (end > start && isSpace(rest[end - 1]))
|
|
67
|
+
end -= 1;
|
|
68
|
+
// Without a space or tab after the opening, only a closing sequence may follow.
|
|
69
|
+
if (start === 0 && end > 0)
|
|
70
|
+
return null;
|
|
71
|
+
return { level, text: rest.slice(start, end) };
|
|
72
|
+
}
|
|
27
73
|
function listMarker(line) {
|
|
28
74
|
const bullet = BULLET.exec(line);
|
|
29
75
|
if (bullet)
|
|
@@ -33,8 +79,8 @@ function listMarker(line) {
|
|
|
33
79
|
return { ordered: true, start: Number(ordered[2]), content: ordered[3] ?? '' };
|
|
34
80
|
return null;
|
|
35
81
|
}
|
|
36
|
-
const startsBlock = (line) =>
|
|
37
|
-
|
|
82
|
+
const startsBlock = (line) => readFence(line) !== null ||
|
|
83
|
+
readHeading(line) !== null ||
|
|
38
84
|
RULE.test(line) ||
|
|
39
85
|
QUOTE.test(line) ||
|
|
40
86
|
listMarker(line) !== null;
|
|
@@ -84,9 +130,9 @@ class Reader {
|
|
|
84
130
|
i += 1;
|
|
85
131
|
continue;
|
|
86
132
|
}
|
|
87
|
-
const fence =
|
|
133
|
+
const fence = readFence(line);
|
|
88
134
|
if (fence) {
|
|
89
|
-
const marker = fence
|
|
135
|
+
const marker = fence.marker;
|
|
90
136
|
const body = [];
|
|
91
137
|
i += 1;
|
|
92
138
|
while (i < lines.length && !(lines[i] ?? '').trim().startsWith(marker)) {
|
|
@@ -94,7 +140,7 @@ class Reader {
|
|
|
94
140
|
i += 1;
|
|
95
141
|
}
|
|
96
142
|
i += 1;
|
|
97
|
-
const lang = fence
|
|
143
|
+
const lang = fence.lang;
|
|
98
144
|
push(() => {
|
|
99
145
|
const code = this.budget.text(this.budget.clean(body.join('\n'), '\n\t'));
|
|
100
146
|
return lang
|
|
@@ -103,16 +149,16 @@ class Reader {
|
|
|
103
149
|
});
|
|
104
150
|
continue;
|
|
105
151
|
}
|
|
106
|
-
const heading =
|
|
152
|
+
const heading = readHeading(line);
|
|
107
153
|
if (heading) {
|
|
108
|
-
const level =
|
|
154
|
+
const level = heading.level;
|
|
109
155
|
if (level > 4)
|
|
110
156
|
this.budget.report('heading-clamped', 'Headings deeper than level 4 became level 4.');
|
|
111
157
|
push(() => ({
|
|
112
158
|
id: this.budget.newId(),
|
|
113
159
|
type: 'heading',
|
|
114
160
|
level: Math.min(level, 4),
|
|
115
|
-
content: trimEdges(this.inline(
|
|
161
|
+
content: trimEdges(this.inline(heading.text.trim())),
|
|
116
162
|
}));
|
|
117
163
|
i += 1;
|
|
118
164
|
continue;
|
|
@@ -356,8 +402,13 @@ function parseInline(source, depth, marks, inLink, out, budget) {
|
|
|
356
402
|
}
|
|
357
403
|
}
|
|
358
404
|
if (ch === '\n') {
|
|
359
|
-
|
|
360
|
-
|
|
405
|
+
// Count trailing spaces by hand: an unanchored `/ {2,}$/` rescans the
|
|
406
|
+
// whole buffer on every soft line break (quadratic in a long paragraph).
|
|
407
|
+
let spaces = 0;
|
|
408
|
+
while (buffer.charCodeAt(buffer.length - 1 - spaces) === 32)
|
|
409
|
+
spaces += 1;
|
|
410
|
+
if (spaces >= 2) {
|
|
411
|
+
buffer = buffer.slice(0, buffer.length - spaces);
|
|
361
412
|
flush();
|
|
362
413
|
out.push({ type: 'break' });
|
|
363
414
|
budget.chars += 1;
|
|
@@ -453,15 +504,79 @@ function matchLink(scan, at) {
|
|
|
453
504
|
const labelEnd = scan.bracket(at);
|
|
454
505
|
if (labelEnd === -1 || source[labelEnd + 1] !== '(')
|
|
455
506
|
return null;
|
|
456
|
-
|
|
507
|
+
// A target is read from at most 2,100 characters after the label.
|
|
508
|
+
const target = readLinkTarget(source.slice(labelEnd + 1, labelEnd + 1 + 2_100), 0);
|
|
457
509
|
if (!target)
|
|
458
510
|
return null;
|
|
459
|
-
return {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
511
|
+
return { labelStart: at + 1, labelEnd, href: target.href, end: labelEnd + 1 + target.end };
|
|
512
|
+
}
|
|
513
|
+
const isWhitespace = (char) => char !== undefined && /\s/.test(char);
|
|
514
|
+
/** A target character: not whitespace, a parenthesis or an angle bracket. */
|
|
515
|
+
const isTargetChar = (char) => char !== undefined && !/[\s()<>]/.test(char);
|
|
516
|
+
/**
|
|
517
|
+
* Reads a link target `(<href> "title")` whose `(` is at `open`, by hand.
|
|
518
|
+
* It returns what `/\(\s*<?((?:[^\s()<>]|\([^\s()<>]*\))*)>?(?:\s+"[^"\n]*")?\s*\)/y`
|
|
519
|
+
* returned (the href and the index after `)`), but in linear time: in that
|
|
520
|
+
* regex `\(\s*`, `\s+"` and `\s*\)` competed for one whitespace run, so
|
|
521
|
+
* `[a](` + 3,990 spaces + `x` took about 250 ms.
|
|
522
|
+
*/
|
|
523
|
+
function readLinkTarget(source, open) {
|
|
524
|
+
// The optional title and the closing parenthesis from `from`: the index
|
|
525
|
+
// after `)`, or -1.
|
|
526
|
+
const close = (from) => {
|
|
527
|
+
let at = from;
|
|
528
|
+
while (isWhitespace(source[at]))
|
|
529
|
+
at += 1;
|
|
530
|
+
if (source[at] === ')')
|
|
531
|
+
return at + 1;
|
|
532
|
+
if (at === from || source[at] !== '"')
|
|
533
|
+
return -1;
|
|
534
|
+
at += 1;
|
|
535
|
+
while (at < source.length && source[at] !== '"' && source[at] !== '\n')
|
|
536
|
+
at += 1;
|
|
537
|
+
if (source[at] !== '"')
|
|
538
|
+
return -1;
|
|
539
|
+
at += 1;
|
|
540
|
+
while (isWhitespace(source[at]))
|
|
541
|
+
at += 1;
|
|
542
|
+
return source[at] === ')' ? at + 1 : -1;
|
|
464
543
|
};
|
|
544
|
+
let at = open + 1;
|
|
545
|
+
while (isWhitespace(source[at]))
|
|
546
|
+
at += 1;
|
|
547
|
+
const afterSpace = at;
|
|
548
|
+
if (source[at] === '<')
|
|
549
|
+
at += 1;
|
|
550
|
+
const hrefStart = at;
|
|
551
|
+
// Plain characters and balanced `(…)` groups without whitespace inside.
|
|
552
|
+
for (;;) {
|
|
553
|
+
if (isTargetChar(source[at]))
|
|
554
|
+
at += 1;
|
|
555
|
+
else if (source[at] === '(') {
|
|
556
|
+
let inner = at + 1;
|
|
557
|
+
while (isTargetChar(source[inner]))
|
|
558
|
+
inner += 1;
|
|
559
|
+
if (source[inner] !== ')')
|
|
560
|
+
break;
|
|
561
|
+
at = inner + 1;
|
|
562
|
+
}
|
|
563
|
+
else
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
const href = source.slice(hrefStart, at);
|
|
567
|
+
if (source[at] === '>')
|
|
568
|
+
at += 1;
|
|
569
|
+
const end = close(at);
|
|
570
|
+
if (end !== -1)
|
|
571
|
+
return { href, end };
|
|
572
|
+
// The regex backtracked into the leading whitespace: `( "title")` is an
|
|
573
|
+
// empty href with a title.
|
|
574
|
+
if (afterSpace > open + 1) {
|
|
575
|
+
const titled = close(open + 1);
|
|
576
|
+
if (titled !== -1)
|
|
577
|
+
return { href: '', end: titled };
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
465
580
|
}
|
|
466
581
|
// ---------------------------------------------------------------------------
|
|
467
582
|
// Writing
|
|
@@ -493,6 +608,20 @@ function prepareMarks(content) {
|
|
|
493
608
|
for (const ch of node.text)
|
|
494
609
|
chars.push({ ch, marks: node.marks });
|
|
495
610
|
}
|
|
611
|
+
// The nearest character on each side of every position, so a run of
|
|
612
|
+
// breaks is crossed in constant time (linear, not quadratic, in breaks).
|
|
613
|
+
const previous = new Array(chars.length);
|
|
614
|
+
const next = new Array(chars.length);
|
|
615
|
+
for (let k = 0, last = -1; k < chars.length; k += 1) {
|
|
616
|
+
previous[k] = last;
|
|
617
|
+
if (chars[k] !== null)
|
|
618
|
+
last = k;
|
|
619
|
+
}
|
|
620
|
+
for (let k = chars.length - 1, last = chars.length; k >= 0; k -= 1) {
|
|
621
|
+
next[k] = last;
|
|
622
|
+
if (chars[k] !== null)
|
|
623
|
+
last = k;
|
|
624
|
+
}
|
|
496
625
|
for (const type of ['strong', 'em', 'strike']) {
|
|
497
626
|
let i = 0;
|
|
498
627
|
const marked = (c) => c?.marks.some((m) => m.type === type) === true;
|
|
@@ -500,13 +629,8 @@ function prepareMarks(content) {
|
|
|
500
629
|
const has = (k) => {
|
|
501
630
|
if (chars[k] !== null)
|
|
502
631
|
return marked(chars[k]);
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
left -= 1;
|
|
506
|
-
let right = k + 1;
|
|
507
|
-
while (chars[right] === null)
|
|
508
|
-
right += 1;
|
|
509
|
-
return left >= 0 && marked(chars[left]) && marked(chars[right]);
|
|
632
|
+
const left = previous[k];
|
|
633
|
+
return left >= 0 && marked(chars[left]) && marked(chars[next[k]]);
|
|
510
634
|
};
|
|
511
635
|
while (i < chars.length) {
|
|
512
636
|
if (!has(i)) {
|
|
@@ -536,16 +660,21 @@ function prepareMarks(content) {
|
|
|
536
660
|
}
|
|
537
661
|
function writeInline(content, em) {
|
|
538
662
|
const open = { strong: '**', em, strike: '~~' };
|
|
539
|
-
const texts = content.flatMap((node
|
|
663
|
+
const texts = content.flatMap((node) => (node.type === 'text' ? [{ node }] : []));
|
|
540
664
|
const stack = [];
|
|
541
665
|
const outer = (marks) => marks.filter((m) => m.type !== 'code');
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
666
|
+
// Where the span of each mark starting at text `t` ends (marks that end
|
|
667
|
+
// later nest outside), computed once from the end so long spans stay linear.
|
|
668
|
+
const ends = new Array(texts.length);
|
|
669
|
+
for (let t = texts.length - 1; t >= 0; t -= 1) {
|
|
670
|
+
const here = new Map();
|
|
671
|
+
for (const mark of texts[t].node.marks) {
|
|
672
|
+
const key = markKey(mark);
|
|
673
|
+
here.set(key, ends[t + 1]?.get(key) ?? t);
|
|
674
|
+
}
|
|
675
|
+
ends[t] = here;
|
|
676
|
+
}
|
|
677
|
+
const spanEnd = (t, mark) => ends[t]?.get(markKey(mark)) ?? t;
|
|
549
678
|
const ordered = (t) => {
|
|
550
679
|
const marks = outer(texts[t].node.marks);
|
|
551
680
|
const opened = (mark) => {
|
|
@@ -575,10 +704,11 @@ function writeInline(content, em) {
|
|
|
575
704
|
return common;
|
|
576
705
|
};
|
|
577
706
|
let t = 0;
|
|
578
|
-
content.forEach((node
|
|
707
|
+
content.forEach((node) => {
|
|
579
708
|
if (node.type === 'break') {
|
|
580
709
|
// Marks the next text keeps stay open across the break.
|
|
581
|
-
|
|
710
|
+
// `t` texts are written, so the next one is `texts[t]`.
|
|
711
|
+
const next = t < texts.length ? t : -1;
|
|
582
712
|
keepCommon(next === -1 ? [] : ordered(next).filter((m) => stack.some((s) => markKey(s) === markKey(m))));
|
|
583
713
|
out += '\\\n';
|
|
584
714
|
return;
|
|
@@ -20,6 +20,13 @@ export interface FocusScopeOptions {
|
|
|
20
20
|
cycleTabNavigation?: boolean;
|
|
21
21
|
/** Called before focus returns to the opener; call `preventDefault()` to cancel. */
|
|
22
22
|
onBeforeRestoreFocus?: (event: FocusRestoreEvent) => void;
|
|
23
|
+
/**
|
|
24
|
+
* Focus-exempt regions outside the container (an undo toaster, the live
|
|
25
|
+
* announcer). While focus is inside one, Tab and Shift+Tab move natively
|
|
26
|
+
* between the region's own stops; past its last (or before its first) stop,
|
|
27
|
+
* focus wraps back into the scope. Queried on every Tab.
|
|
28
|
+
*/
|
|
29
|
+
exemptRegions?: () => readonly Element[];
|
|
23
30
|
}
|
|
24
31
|
/** An activated, framework-free keyboard focus scope. */
|
|
25
32
|
export interface FocusScope {
|
|
@@ -44,11 +44,35 @@ export function createFocusScope(container, options = {}) {
|
|
|
44
44
|
return;
|
|
45
45
|
focus(resolveInitialTarget(container, options), true);
|
|
46
46
|
};
|
|
47
|
+
/** The exempt region holding `node`, when it sits outside the container. */
|
|
48
|
+
const exemptRegionOf = (node) => {
|
|
49
|
+
if (!node || container.contains(node))
|
|
50
|
+
return null;
|
|
51
|
+
for (const region of options.exemptRegions?.() ?? []) {
|
|
52
|
+
if (region.contains(node))
|
|
53
|
+
return region;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
};
|
|
47
57
|
const onKeyDown = (event) => {
|
|
48
58
|
if (!active || !isTopScope(scope, document) || event.key !== 'Tab')
|
|
49
59
|
return;
|
|
50
60
|
const tabbable = getTabbableElements(container);
|
|
51
61
|
const current = document.activeElement;
|
|
62
|
+
const region = exemptRegionOf(current);
|
|
63
|
+
if (region) {
|
|
64
|
+
// Inside a focus-exempt region: native order between its own stops,
|
|
65
|
+
// then back into the scope at either end.
|
|
66
|
+
const stops = getTabbableElements(region);
|
|
67
|
+
const at = stops.indexOf(current);
|
|
68
|
+
const leaving = event.shiftKey ? at <= 0 : at < 0 || at === stops.length - 1;
|
|
69
|
+
if (!leaving)
|
|
70
|
+
return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
const target = event.shiftKey ? tabbable[tabbable.length - 1] : tabbable[0];
|
|
73
|
+
focus(target ?? container, !target);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
52
76
|
const index = tabbable.indexOf(current);
|
|
53
77
|
const cycle = options.cycleTabNavigation === true;
|
|
54
78
|
if (!cycle && !(event.shiftKey ? index <= 0 : index < 0 || index === tabbable.length - 1)) {
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export { lockScroll } from './scroll-lock/scroll-lock.js';
|
|
|
19
19
|
export type { TabbableOptions } from './tabbable/index.js';
|
|
20
20
|
export { getFocusableElements, getTabbableElements, isFocusable, isTabbable, } from './tabbable/index.js';
|
|
21
21
|
export { type HrefTrust, safeHref } from './url/safe-href.js';
|
|
22
|
+
export { filterImageSrcset } from './url-policy/filter-image-srcset.js';
|
|
22
23
|
export { safeImageSrc } from './url-policy/safe-image-src.js';
|
|
23
24
|
export { safeImageSrcset } from './url-policy/safe-image-srcset.js';
|
|
24
25
|
export { type SafeMediaSrcOptions, safeMediaSrc } from './url-policy/safe-media-src.js';
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export { createRovingFocus, createTypeahead, indexOfSelected, isItemDisabled, se
|
|
|
18
18
|
export { lockScroll } from "./scroll-lock/scroll-lock.js";
|
|
19
19
|
export { getFocusableElements, getTabbableElements, isFocusable, isTabbable, } from "./tabbable/index.js";
|
|
20
20
|
export { safeHref } from "./url/safe-href.js";
|
|
21
|
+
export { filterImageSrcset } from "./url-policy/filter-image-srcset.js";
|
|
21
22
|
export { safeImageSrc } from "./url-policy/safe-image-src.js";
|
|
22
23
|
export { safeImageSrcset } from "./url-policy/safe-image-srcset.js";
|
|
23
24
|
export { safeMediaSrc } from "./url-policy/safe-media-src.js";
|
package/dist/inert/inert.js
CHANGED
|
@@ -30,8 +30,15 @@ const SKIPPED_TAGS = new Set([
|
|
|
30
30
|
'TEMPLATE',
|
|
31
31
|
'TITLE',
|
|
32
32
|
]);
|
|
33
|
+
// Realm-independent: an element from another window, or one whose timer runs
|
|
34
|
+
// after the global Element binding is gone, is still an element.
|
|
33
35
|
function isElement(value) {
|
|
34
|
-
|
|
36
|
+
if (typeof value !== 'object' || value === null)
|
|
37
|
+
return false;
|
|
38
|
+
const node = value;
|
|
39
|
+
return (node.nodeType === 1 &&
|
|
40
|
+
typeof node.setAttribute === 'function' &&
|
|
41
|
+
typeof node.contains === 'function');
|
|
35
42
|
}
|
|
36
43
|
/**
|
|
37
44
|
* The elements to make inert so that only `keep` (and their descendants) stay
|
package/dist/overlay/core.js
CHANGED
|
@@ -245,6 +245,8 @@ export function createOverlayCore(options, features = {}) {
|
|
|
245
245
|
focusScope = features.focusScope(element, {
|
|
246
246
|
...(options.initialFocus ? { initialFocus: options.initialFocus } : {}),
|
|
247
247
|
...(options.cycleTabNavigation ? { cycleTabNavigation: true } : {}),
|
|
248
|
+
// Focus-exempt regions (the toast region) keep their own Tab order.
|
|
249
|
+
exemptRegions: exemptElements,
|
|
248
250
|
onBeforeRestoreFocus: (event) => {
|
|
249
251
|
if (options.restoreFocus === false)
|
|
250
252
|
event.preventDefault();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HrefTrust } from '../url/safe-href.js';
|
|
2
|
+
/**
|
|
3
|
+
* Filter an image `srcset` candidate by candidate, where `safeImageSrcset`
|
|
4
|
+
* refuses the whole value. Every candidate URL goes through the image URL
|
|
5
|
+
* policy (`safeImageSrc`) at `trust`; a refused or malformed candidate (or one
|
|
6
|
+
* whose URL holds a comma) is dropped and the rest are kept. Candidates keep
|
|
7
|
+
* the descriptor kind of the first kept one (`x` or `w`), since a srcset
|
|
8
|
+
* cannot mix them. Returns `undefined` when nothing is left, so a caller can
|
|
9
|
+
* drop `sizes` with it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function filterImageSrcset(value: unknown, trust?: HrefTrust): string | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { safeImageSrcset } from "./safe-image-srcset.js";
|
|
2
|
+
/**
|
|
3
|
+
* Split a `srcset` into candidates the way the HTML parser reads it: a URL is
|
|
4
|
+
* a run of non-whitespace (so a `data:` URL may hold commas), trailing commas
|
|
5
|
+
* end it, and descriptors run to the next comma.
|
|
6
|
+
*/
|
|
7
|
+
function candidates(value) {
|
|
8
|
+
const out = [];
|
|
9
|
+
let index = 0;
|
|
10
|
+
while (index < value.length) {
|
|
11
|
+
while (index < value.length && /[\s,]/.test(value[index] ?? ''))
|
|
12
|
+
index += 1;
|
|
13
|
+
if (index >= value.length)
|
|
14
|
+
break;
|
|
15
|
+
let end = index;
|
|
16
|
+
while (end < value.length && !/\s/.test(value[end] ?? ''))
|
|
17
|
+
end += 1;
|
|
18
|
+
let url = value.slice(index, end);
|
|
19
|
+
index = end;
|
|
20
|
+
if (url.endsWith(',')) {
|
|
21
|
+
url = url.replace(/,+$/, '');
|
|
22
|
+
out.push({ url, descriptors: '' });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
let close = index;
|
|
26
|
+
while (close < value.length && value[close] !== ',')
|
|
27
|
+
close += 1;
|
|
28
|
+
out.push({ url, descriptors: value.slice(index, close).trim() });
|
|
29
|
+
index = close + 1;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Filter an image `srcset` candidate by candidate, where `safeImageSrcset`
|
|
35
|
+
* refuses the whole value. Every candidate URL goes through the image URL
|
|
36
|
+
* policy (`safeImageSrc`) at `trust`; a refused or malformed candidate (or one
|
|
37
|
+
* whose URL holds a comma) is dropped and the rest are kept. Candidates keep
|
|
38
|
+
* the descriptor kind of the first kept one (`x` or `w`), since a srcset
|
|
39
|
+
* cannot mix them. Returns `undefined` when nothing is left, so a caller can
|
|
40
|
+
* drop `sizes` with it.
|
|
41
|
+
*/
|
|
42
|
+
export function filterImageSrcset(value, trust = 'untrusted') {
|
|
43
|
+
if (typeof value !== 'string' || value.trim() === '' || value.length > 16_384)
|
|
44
|
+
return undefined;
|
|
45
|
+
const kept = [];
|
|
46
|
+
let kind;
|
|
47
|
+
for (const { url, descriptors } of candidates(value)) {
|
|
48
|
+
if (url === '' || url.includes(','))
|
|
49
|
+
continue;
|
|
50
|
+
const candidate = safeImageSrcset(descriptors ? `${url} ${descriptors}` : url, trust);
|
|
51
|
+
if (candidate === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
const candidateKind = descriptors.endsWith('w') ? 'width' : 'density';
|
|
54
|
+
kind ??= candidateKind;
|
|
55
|
+
if (candidateKind !== kind)
|
|
56
|
+
continue;
|
|
57
|
+
kept.push(candidate);
|
|
58
|
+
}
|
|
59
|
+
return kept.length > 0 ? safeImageSrcset(kept.join(', '), trust) : undefined;
|
|
60
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { type HrefTrust, safeHref } from '../url/safe-href.js';
|
|
2
|
+
export { filterImageSrcset } from './filter-image-srcset.js';
|
|
2
3
|
export { safeImageSrc } from './safe-image-src.js';
|
|
3
4
|
export { safeImageSrcset } from './safe-image-srcset.js';
|
|
4
5
|
export { type SafeMediaSrcOptions, safeMediaSrc } from './safe-media-src.js';
|
package/dist/url-policy/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { safeHref } from "../url/safe-href.js";
|
|
2
|
+
export { filterImageSrcset } from "./filter-image-srcset.js";
|
|
2
3
|
export { safeImageSrc } from "./safe-image-src.js";
|
|
3
4
|
export { safeImageSrcset } from "./safe-image-srcset.js";
|
|
4
5
|
export { safeMediaSrc } from "./safe-media-src.js";
|