@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 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.4.0`, a pre-1.0 release (1.0.0 will be the first
16
- > stable release). Under 0.x, a new minor (`0.5.0`) may contain breaking
17
- > changes, so pin the exact version or use `^0.4.0`, which accepts only 0.4.x.
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` |
@@ -14,16 +14,62 @@ export const MARKDOWN_CODEC_LIMITS = Object.freeze({
14
14
  /** Nested emphasis and links. */
15
15
  inlineDepth: 6,
16
16
  });
17
- const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([a-z][a-z0-9+-]{0,31})?\s*$/i;
18
- const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$/;
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) => FENCE.test(line) ||
37
- HEADING.test(line) ||
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 = FENCE.exec(line);
133
+ const fence = readFence(line);
88
134
  if (fence) {
89
- const marker = fence[1] ?? '```';
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[2]?.toLowerCase();
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 = HEADING.exec(line);
152
+ const heading = readHeading(line);
107
153
  if (heading) {
108
- const level = (heading[1] ?? '#').length;
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((heading[2] ?? '').trim())),
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
- if (/ {2,}$/.test(buffer)) {
360
- buffer = buffer.replace(/ +$/, '');
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
- const target = /^\(\s*<?((?:[^\s()<>]|\([^\s()<>]*\))*)>?(?:\s+"[^"\n]*")?\s*\)/.exec(source.slice(labelEnd + 1, labelEnd + 1 + 2_100));
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
- labelStart: at + 1,
461
- labelEnd,
462
- href: target[1] ?? '',
463
- end: labelEnd + 1 + target[0].length,
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
- let left = k - 1;
504
- while (chars[left] === null)
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, index) => (node.type === 'text' ? [{ node, index }] : []));
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
- /** Where the span of `mark` starting at text `t` ends (marks that end later nest outside). */
543
- const spanEnd = (t, mark) => {
544
- let end = t;
545
- while (texts[end + 1]?.node.marks.some((m) => markKey(m) === markKey(mark)))
546
- end += 1;
547
- return end;
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, index) => {
707
+ content.forEach((node) => {
579
708
  if (node.type === 'break') {
580
709
  // Marks the next text keeps stay open across the break.
581
- const next = texts.findIndex((entry) => entry.index > index);
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";
@@ -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
- return typeof Element !== 'undefined' && value instanceof Element;
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
@@ -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';
@@ -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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@texuli/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Texels: framework-free behavior and policy that every Texuli component is built from.",
5
5
  "keywords": [
6
6
  "svelte",