tosijs-ui 1.16.17 → 1.16.19

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/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.19
4
+
5
+ - **`iconSvg()` resolves the built-in icons that rotate or flip another.** 27 built-in
6
+ names are redirects with a rotation or flip (`chevronDown` is `chevronRight90r`; also
7
+ `chevronLeft`/`Up`, `arrowDown`/`Left`/`Up`, `cornerDownLeft`, `rotateCcw`, `thumbsDown`
8
+ and others), and `iconSvg()` returned `undefined` for every one of them, silently. They
9
+ now return markup with the transform applied about the centre of the viewBox. A rotation
10
+ or flip written inline (`iconSvg('chevronRight90r')`) and other inline names returned
11
+ undefined silently too: a rotation or flip now resolves, and anything else throws. A Playwright
12
+ test draws each one both ways, through `iconSvg` and through `icons.<name>()`, and
13
+ finds no pixel that differs by more than antialiasing, in all three browsers.
14
+ - **`iconSvg()` throws for a name it cannot turn into one SVG.** A stack
15
+ (`cloudSync: 'spin120Loader40s_30x$cloud'`, or `tosi$hat` written inline), a rule
16
+ (`unLock`, `spin90Loader`), a style suffix other than rotation or flip (`check50o`), a
17
+ redirect to a name that is not an icon, or a redirect cycle all returned `undefined`,
18
+ the same answer as a name that does not exist, so a build script rendered nothing and
19
+ said nothing. Each now throws, saying which it is and, where the icon renderer would draw
20
+ it, that `icons.<name>()` does so where there is a DOM. **Code that relied on `undefined`
21
+ for those names now gets an exception;** a name nothing would draw still returns
22
+ `undefined`, as documented. This is the follow-up to #224, for the DOM-free path.
23
+ - `iconSvg()` reads suffixes with the same grammar as the icon renderer, now shared
24
+ between them: `edit2_90r` is `edit2` turned 90°, not -90°.
25
+
26
+ ## 1.16.18
27
+
28
+ - **A `defineIcons` name for a stacked composition draws the stack.** `defineIcons({
29
+ cloudSync: 'spin120Loader40s_30x$cloud' })` made `icons.cloudSync()` draw the placeholder
30
+ square and log "icon cloudSync does not exist", although the same string used directly as
31
+ an icon name worked. The icons page documents exactly this pattern for building an app's
32
+ icon vocabulary, so every such name was silently a square, in every version since
33
+ composites shipped. A redirect's target now goes through the whole icon pipeline, stacking
34
+ included. closes #224, reported by tosijs-virta.
35
+ - **A named composite answers to its name.** Its `data-icon` is the name you defined
36
+ (`cloudSync`), not the composition it stands for, and suffixes and rules apply to it as
37
+ to any icon (`cloudSync50o`, `spin90CloudSync`).
38
+ - **A redirect cycle says so.** `a: 'b'` with `b: 'a'` now logs "icon a redirects in a
39
+ cycle" and draws the placeholder, instead of claiming the icon does not exist; a
40
+ composite that names itself (`a: 'a$check'`) draws the placeholder in place of the
41
+ cycling layer. A long redirect chain that ends in a drawing is no longer cut off at ten
42
+ hops.
43
+ - The named-composition example on the icons page is now a live example with a test, so
44
+ the documented pattern is checked on every build.
45
+
3
46
  ## 1.16.17
4
47
 
5
48
  - **The pinned tjs-lang is 0.14.0-rc.5; it was 0.13.13.** That is the devDependency this
@@ -0,0 +1,20 @@
1
+ import type { ElementPart } from 'tosijs';
2
+ export interface IconRule {
3
+ prefix: string | RegExp;
4
+ apply: (baseName: string, match: RegExpMatchArray | string, parts: ElementPart[]) => Element | string | null;
5
+ }
6
+ /** A registered icon, or style suffixes on one (`chevronRight50o`). */
7
+ export declare function canResolve(name: string): boolean;
8
+ /**
9
+ * `spinN<Icon>` animates, so it is drawn by the DOM renderer, which installs `apply` when
10
+ * it loads. Its prefix is here so that every module knows the rule exists.
11
+ */
12
+ export declare const spinRule: IconRule;
13
+ export declare const iconRules: IconRule[];
14
+ /** The icon a rule would apply to in `name`, or null if the rule does not match it. */
15
+ export declare function matchRule(rule: IconRule, name: string): {
16
+ baseName: string;
17
+ match: RegExpMatchArray | string;
18
+ } | null;
19
+ /** Whether some rule would draw `name`: it matches, and the icon it applies to exists. */
20
+ export declare const ruleDraws: (name: string) => boolean;
@@ -0,0 +1,72 @@
1
+ /*
2
+ Composition rules (`spinNLoader`, `unLock`, `checkFile`, …) and how a name is matched to
3
+ one, in ONE place. `icons.ts` (the DOM renderer) draws through them; `icon-svg.ts` (markup
4
+ without a DOM) asks the same question, "would the renderer draw this name?", so it can tell
5
+ a rule it cannot render from a name nothing would draw. It once guessed rules from the shape
6
+ of a name instead, and threw for `redLock`. DOM-free, so `icon-svg.ts` can import it.
7
+
8
+ Rules an app adds with `iconRules.push()` are seen by both, as long as they are pushed
9
+ before the name is asked for.
10
+ */
11
+ import iconData from './icon-data.js';
12
+ import { splitSuffixes } from './icon-suffixes.js';
13
+ /** A registered icon, or style suffixes on one (`chevronRight50o`). */
14
+ export function canResolve(name) {
15
+ const data = iconData;
16
+ if (data[name])
17
+ return true;
18
+ const split = splitSuffixes(name);
19
+ return split != null && !!data[split.baseName];
20
+ }
21
+ /**
22
+ * `spinN<Icon>` animates, so it is drawn by the DOM renderer, which installs `apply` when
23
+ * it loads. Its prefix is here so that every module knows the rule exists.
24
+ */
25
+ export const spinRule = {
26
+ prefix: /^spin(_?\d+)/,
27
+ apply() {
28
+ throw new Error('spin icons are drawn by the icon renderer (tosijs-ui/icons)');
29
+ },
30
+ };
31
+ export const iconRules = [
32
+ spinRule,
33
+ {
34
+ prefix: 'un',
35
+ apply: (baseName) => `slash25o$${baseName}75s75o`,
36
+ },
37
+ {
38
+ prefix: 'check',
39
+ apply: (baseName) => `check75o_00aa00S$${baseName}75s50o`,
40
+ },
41
+ {
42
+ prefix: 'cancel',
43
+ apply: (baseName) => `x75o_cc0000S$${baseName}75s50o`,
44
+ },
45
+ {
46
+ prefix: 'search',
47
+ apply: (baseName) => `search80s30x30y$${baseName}50o`,
48
+ },
49
+ ];
50
+ /** The icon a rule would apply to in `name`, or null if the rule does not match it. */
51
+ export function matchRule(rule, name) {
52
+ if (rule.prefix instanceof RegExp) {
53
+ const m = name.match(new RegExp(rule.prefix.source + '(.+)$'));
54
+ if (!m)
55
+ return null;
56
+ const tail = m[m.length - 1];
57
+ return { baseName: tail[0].toLowerCase() + tail.slice(1), match: m };
58
+ }
59
+ if (!name.startsWith(rule.prefix) || name.length <= rule.prefix.length) {
60
+ return null;
61
+ }
62
+ return {
63
+ baseName: name[rule.prefix.length].toLowerCase() +
64
+ name.slice(rule.prefix.length + 1),
65
+ match: rule.prefix,
66
+ };
67
+ }
68
+ /** Whether some rule would draw `name`: it matches, and the icon it applies to exists. */
69
+ export const ruleDraws = (name) => iconRules.some((rule) => {
70
+ const matched = matchRule(rule, name);
71
+ return matched != null && canResolve(matched.baseName);
72
+ });
@@ -0,0 +1,8 @@
1
+ export declare const SUFFIX_RE: RegExp;
2
+ /** A name split into its base and its suffix tokens, in order, or null if it has none. */
3
+ export declare function splitSuffixes(name: string): {
4
+ baseName: string;
5
+ tokens: string[];
6
+ } | null;
7
+ /** The signed number in a numeric token: `_45r` is -45. */
8
+ export declare const suffixNumber: (token: string) => number;
@@ -0,0 +1,31 @@
1
+ /*
2
+ The icon style-suffix grammar, in ONE place. `icons.ts` (the DOM renderer) and
3
+ `icon-svg.ts` (markup without a DOM) both read names through it. `icon-svg.ts` kept its own
4
+ copy for one release candidate, and the two disagreed within a day: it read the `_` in
5
+ `edit2_90r` as a minus sign and rotated the wrong way. DOM-free, so `icon-svg.ts` can
6
+ import it.
7
+
8
+ Suffixes are a value then a letter code:
9
+ 50o (opacity), 75s (scale), 20x (translateX%), _10y (translateY%)
10
+ 90r (rotate 90°), _45r (rotate -45°), 0f (flipH), 1f (flipV)
11
+ _FF0000F (fill hex), _f00S (stroke hex), 3W (stroke-width)
12
+ _brandColorF (fill var), _accentS (stroke var)
13
+ Icon names ending in digits need a _ separator: edit2_50o
14
+ */
15
+ export const SUFFIX_RE = /(?:(?<=[a-zA-Z_])(?:_?\d+[osxyr]|[01]f|\d+W)|_[a-zA-Z0-9]+[FS])+$/;
16
+ const SUFFIX_TOKEN_RE = /_?\d+[osxyr]|[01]f|_[a-zA-Z0-9]+[FS]|\d+W/g;
17
+ /** A name split into its base and its suffix tokens, in order, or null if it has none. */
18
+ export function splitSuffixes(name) {
19
+ const match = name.match(SUFFIX_RE);
20
+ if (!match)
21
+ return null;
22
+ let baseName = name.slice(0, match.index);
23
+ // Strip the trailing _ separator (for digit-ending names: edit2_50o)
24
+ if (baseName.endsWith('_'))
25
+ baseName = baseName.slice(0, -1);
26
+ if (!baseName)
27
+ return null;
28
+ return { baseName, tokens: match[0].match(SUFFIX_TOKEN_RE) };
29
+ }
30
+ /** The signed number in a numeric token: `_45r` is -45. */
31
+ export const suffixNumber = (token) => parseInt(token.replace('_', '-'));
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Raw SVG markup for an icon, or `undefined` when nothing by that name would draw.
3
+ *
4
+ * Throws when the name would draw but cannot be one SVG string: a stack (`a$b`), a rule
5
+ * (`spin…`, `un…`), a style suffix other than rotation or flip, a redirect to a name that
6
+ * is not an icon, or a redirect cycle. Those need `icons.<name>()` where there is a DOM.
7
+ */
1
8
  export declare function iconSvg(name: string): string | undefined;
2
9
  /** Every icon name currently registered, including any added via defineIcons. */
3
10
  export declare function iconNames(): string[];
package/dist/icon-svg.js CHANGED
@@ -5,7 +5,7 @@
5
5
  ```js
6
6
  import { iconSvg, iconNames } from 'tosijs-ui'
7
7
 
8
- const markup = iconSvg('tosi') // '<svg …>…</svg>' | undefined
8
+ const markup = iconSvg('tosi') // '<svg …>…</svg>', or undefined for an unknown name
9
9
  preview.innerHTML =
10
10
  `<p>${iconNames().length} icons registered.</p>` +
11
11
  `<div style="width:48px">${markup}</div>`
@@ -19,6 +19,14 @@ what the live-example runner can rewrite.)
19
19
  is registered. Neither touches the DOM, so both work in a build script, a server-rendered
20
20
  template, or an ePub pass.
21
21
 
22
+ Redirects are followed, including the built-in ones that rotate or flip another icon
23
+ (`chevronDown` is `chevronRight90r`), and a rotation or flip written inline works too
24
+ (`iconSvg('chevronRight90r')`). A name the icon renderer would draw but that cannot be one
25
+ SVG string **throws**, saying why: a stack (`a$b`), a rule (`spin90Loader`, `unLock`), any
26
+ other style suffix (`check50o`), a redirect to a name that is not an icon, or a redirect
27
+ cycle. Render those with `icons.<name>()` where there is a DOM; the suffixes and rules are
28
+ described on the [[icons]] page. Only a name that nothing would draw returns `undefined`.
29
+
22
30
  Deliberately a separate module from `icons.ts`: that one imports tosijs to build
23
31
  elements, so importing it drags in code that needs `HTMLElement` and throws in a build
24
32
  script — the exact context that wanted the markup in the first place. This module
@@ -32,18 +40,80 @@ resorted to when putting its own logo on a generated page. There is nothing to p
32
40
  the map ships in the bundle either way.
33
41
  */
34
42
  import iconData from './icon-data.js';
43
+ import { splitSuffixes, suffixNumber } from './icon-suffixes.js';
44
+ import { canResolve, ruleDraws } from './icon-rules.js';
45
+ const data = iconData;
46
+ // Rotation and flip, the only suffixes the built-in redirects use (`chevronDown` is
47
+ // `chevronRight90r`). Anything else needs the DOM renderer.
48
+ const isGeometry = (token) => /^(_?\d+r|[01]f)$/.test(token);
49
+ const stripEscape = (name) => name.endsWith('_') ? name.slice(0, -1) : name;
50
+ // Whether the icon renderer would draw something for a name: a registered icon, suffixes on
51
+ // one (`chevronRight50o`), a stack (`a$b`), or a rule applied to one (`spin90Loader`,
52
+ // `unLock`). The rules and suffixes are the renderer's own (icon-rules, icon-suffixes).
53
+ const isComposition = (name) => name.includes('$') || canResolve(name) || ruleDraws(name);
54
+ /**
55
+ * Raw SVG markup for an icon, or `undefined` when nothing by that name would draw.
56
+ *
57
+ * Throws when the name would draw but cannot be one SVG string: a stack (`a$b`), a rule
58
+ * (`spin…`, `un…`), a style suffix other than rotation or flip, a redirect to a name that
59
+ * is not an icon, or a redirect cycle. Those need `icons.<name>()` where there is a DOM.
60
+ */
35
61
  export function iconSvg(name) {
36
- const data = iconData;
37
- let key = name.endsWith('_') ? name.slice(0, -1) : name;
38
- for (let i = 0; i < 10; i++) {
39
- const value = data[key];
40
- if (value === undefined)
41
- return undefined;
62
+ const key = stripEscape(name);
63
+ if (!isComposition(key))
64
+ return undefined;
65
+ return resolve(name, key, new Set());
66
+ }
67
+ function resolve(asked, name, seen) {
68
+ const key = stripEscape(name);
69
+ const value = data[key];
70
+ if (value !== undefined) {
42
71
  if (value.startsWith('<'))
43
72
  return value;
44
- key = value; // a redirect to another icon's name
73
+ if (seen.has(key)) {
74
+ throw new Error(`iconSvg("${asked}"): the icon redirects in a cycle`);
75
+ }
76
+ seen.add(key);
77
+ return resolve(asked, value, seen);
78
+ }
79
+ // The renderer's order for a name that is not registered: a stack, then a rule, then
80
+ // suffixes. So `checkCircle90r` is the `check` rule over `circle90r`, not `checkCircle`
81
+ // turned, and only a name no rule claims gets the transform.
82
+ const split = key.includes('$') || ruleDraws(key) ? null : splitSuffixes(key);
83
+ if (split &&
84
+ data[split.baseName] !== undefined &&
85
+ split.tokens.every(isGeometry)) {
86
+ return transformed(asked, resolve(asked, split.baseName, seen), split.tokens);
87
+ }
88
+ const what = key === stripEscape(asked) ? `"${key}"` : `it resolves to "${key}", which`;
89
+ if (isComposition(key)) {
90
+ throw new Error(`iconSvg("${asked}"): ${what} is not a single SVG (a stack, a rule, or a style ` +
91
+ `suffix other than rotation or flip). Render it with icons.${stripEscape(asked)}() ` +
92
+ `where there is a DOM.`);
93
+ }
94
+ throw new Error(`iconSvg("${asked}"): it redirects to "${key}", which is not a registered icon`);
95
+ }
96
+ // Apply rotate/flip suffixes about the centre of the viewBox, as the DOM renderer does
97
+ // with `transform-origin: 50% 50%`.
98
+ function transformed(asked, svg, tokens) {
99
+ const parts = svg.match(/^(\s*<svg\b[^>]*>)([\s\S]*)(<\/svg>\s*)$/);
100
+ const viewBox = parts?.[1]
101
+ .match(/viewBox="([^"]+)"/)?.[1]
102
+ .split(/[\s,]+/)
103
+ .map(Number);
104
+ if (!parts || !viewBox || viewBox.length !== 4 || viewBox.some(isNaN)) {
105
+ throw new Error(`iconSvg("${asked}"): cannot transform an icon without a viewBox`);
45
106
  }
46
- return undefined; // redirect loop — treat as missing rather than hang
107
+ const [x, y, width, height] = viewBox;
108
+ const cx = x + width / 2;
109
+ const cy = y + height / 2;
110
+ const ops = tokens.map((token) => token.endsWith('r')
111
+ ? `rotate(${suffixNumber(token)})`
112
+ : token === '0f'
113
+ ? 'scale(-1 1)'
114
+ : 'scale(1 -1)');
115
+ const transform = `translate(${cx} ${cy}) ${ops.join(' ')} translate(${-cx} ${-cy})`;
116
+ return `${parts[1]}<g transform="${transform}">${parts[2]}</g>${parts[3]}`;
47
117
  }
48
118
  /** Every icon name currently registered, including any added via defineIcons. */
49
119
  export function iconNames() {
package/dist/icons.d.ts CHANGED
@@ -1,16 +1,12 @@
1
1
  import { ElementCreator, ElementPart } from 'tosijs';
2
2
  import { SVGIconMap } from './icon-types.js';
3
+ export { iconRules, type IconRule } from './icon-rules.js';
3
4
  export declare const defineIcons: (newIcons: {
4
5
  [key: string]: string;
5
6
  }) => void;
6
7
  export { iconSvg, iconNames } from './icon-svg.js';
7
8
  export type { IconElement, IconName, SVGIconMap } from './icon-types.js';
8
9
  export declare const svg2DataUrl: (icon: Element, fill?: string, stroke?: string, strokeWidth?: number) => string;
9
- export interface IconRule {
10
- prefix: string | RegExp;
11
- apply: (baseName: string, match: RegExpMatchArray | string, parts: ElementPart[]) => Element | string | null;
12
- }
13
- export declare const iconRules: IconRule[];
14
10
  /** Wrap icon elements in a composite container (for custom function rules) */
15
11
  export declare function wrapIcon(prop: string, parts: ElementPart[], ...children: Element[]): HTMLSpanElement;
16
12
  /** Resolve an icon name through the full pipeline (redirects, suffixes, rules, stacking) */
package/dist/icons.js CHANGED
@@ -485,16 +485,36 @@ Now `removeUser`, `removeFile`, `removeProject` all just work — and
485
485
  they're consistent. If you redesign the trash icon or change
486
486
  `--action-color`, every "remove" icon updates automatically.
487
487
 
488
- You can also use `defineIcons` for one-off named compositions:
488
+ You can also use `defineIcons` for one-off named compositions. Then just
489
+ use `icons.cloudSync()` or `<tosi-icon icon="cloudSync">`: the composition
490
+ is invisible to consumers of the icon.
489
491
 
490
- defineIcons({
491
- cloudSync: 'spin120Loader40s_30x$cloud',
492
- secureShield: 'lock50s75o_10y$shield',
493
- newFile: 'plus75o60s25x25y$file',
494
- })
492
+ ```js
493
+ import { defineIcons, icons } from 'tosijs-ui'
495
494
 
496
- Then just use `icons.cloudSync()` or `<tosi-icon icon="cloudSync">` —
497
- the composition is invisible to consumers of the icon.
495
+ defineIcons({
496
+ cloudSync: 'spin120Loader40s_30x$cloud',
497
+ secureShield: 'lock50s75o_10y$shield',
498
+ newFile: 'plus75o60s25x25y$file',
499
+ })
500
+
501
+ preview.style.setProperty('--tosi-icon-size', '48px')
502
+ preview.append(icons.cloudSync(), icons.secureShield(), icons.newFile())
503
+ ```
504
+ ```test
505
+ test('named compositions draw their composites, not the placeholder', () => {
506
+ // A spin layer is a composite of its own, so count only the outer ones.
507
+ const composites = preview.querySelectorAll(':scope > .tosi-icon-composite')
508
+ expect([...composites].map((icon) => icon.dataset.icon)).toEqual([
509
+ 'cloudSync',
510
+ 'secureShield',
511
+ 'newFile',
512
+ ])
513
+ for (const composite of composites) {
514
+ expect(composite.querySelectorAll('svg').length).toBeGreaterThan(1)
515
+ }
516
+ })
517
+ ```
498
518
 
499
519
  ### Composites and `svg2DataUrl`
500
520
 
@@ -544,6 +564,9 @@ copies or substantial portions of the Software.
544
564
  */
545
565
  import { elements, svgElements, Color, vars, varDefault, withAttributes, } from 'tosijs';
546
566
  import iconData from './icon-data.js';
567
+ import { splitSuffixes, suffixNumber } from './icon-suffixes.js';
568
+ import { iconRules, spinRule, matchRule, canResolve } from './icon-rules.js';
569
+ export { iconRules } from './icon-rules.js';
547
570
  export const defineIcons = (newIcons) => {
548
571
  Object.assign(iconData, newIcons);
549
572
  };
@@ -609,47 +632,28 @@ export const svg2DataUrl = (icon, fill, stroke, strokeWidth) => {
609
632
  };
610
633
  // Appends _ to names ending in digits to prevent suffix ambiguity
611
634
  const safeIconSuffix = (name) => /\d$/.test(name) ? name + '_' : name;
612
- export const iconRules = [
613
- {
614
- prefix: /^spin(_?\d+)/,
615
- apply(baseName, match, parts) {
616
- const dps = match[1].replace('_', '-');
617
- const duration = 360 / Math.abs(parseFloat(dps));
618
- const direction = dps.startsWith('-') ? 'reverse' : 'normal';
619
- // Resolve baseName with suffixes intact — they apply to the icon
620
- const icon = resolveIcon(baseName, []);
621
- const el = icon;
622
- if (el.animate) {
623
- const base = el.style.transform || '';
624
- el.animate([
625
- { transform: `${base} rotate(0deg)` },
626
- { transform: `${base} rotate(360deg)` },
627
- ], {
628
- duration: duration * 1000,
629
- iterations: Infinity,
630
- direction,
631
- });
632
- }
633
- return wrapIcon(baseName, parts, icon);
634
- },
635
- },
636
- {
637
- prefix: 'un',
638
- apply: (baseName) => `slash25o$${baseName}75s75o`,
639
- },
640
- {
641
- prefix: 'check',
642
- apply: (baseName) => `check75o_00aa00S$${baseName}75s50o`,
643
- },
644
- {
645
- prefix: 'cancel',
646
- apply: (baseName) => `x75o_cc0000S$${baseName}75s50o`,
647
- },
648
- {
649
- prefix: 'search',
650
- apply: (baseName) => `search80s30x30y$${baseName}50o`,
651
- },
652
- ];
635
+ // The rule list and how names match it live in icon-rules.ts, shared with iconSvg. Spin
636
+ // animates, so its renderer is installed here.
637
+ spinRule.apply = (baseName, match, parts) => {
638
+ const dps = match[1].replace('_', '-');
639
+ const duration = 360 / Math.abs(parseFloat(dps));
640
+ const direction = dps.startsWith('-') ? 'reverse' : 'normal';
641
+ // Resolve baseName with suffixes intact — they apply to the icon
642
+ const icon = resolveIcon(baseName, []);
643
+ const el = icon;
644
+ if (el.animate) {
645
+ const base = el.style.transform || '';
646
+ el.animate([
647
+ { transform: `${base} rotate(0deg)` },
648
+ { transform: `${base} rotate(360deg)` },
649
+ ], {
650
+ duration: duration * 1000,
651
+ iterations: Infinity,
652
+ direction,
653
+ });
654
+ }
655
+ return wrapIcon(baseName, parts, icon);
656
+ };
653
657
  let iconIdCounter = 0;
654
658
  function makeIcon(spec, parts) {
655
659
  const div = elements.div();
@@ -728,38 +732,13 @@ export function wrapIcon(prop, parts, ...children) {
728
732
  }
729
733
  return wrapper;
730
734
  }
731
- function canResolve(name) {
732
- const data = iconData;
733
- if (data[name])
734
- return true;
735
- const parsed = parseStyleSuffixes(name);
736
- return parsed != null && !!data[parsed.baseName];
737
- }
738
735
  function composeIcon(prop, parts) {
739
736
  for (const rule of iconRules) {
740
- let baseName;
741
- let match;
742
- if (rule.prefix instanceof RegExp) {
743
- const re = new RegExp(rule.prefix.source + '(.+)$');
744
- const m = prop.match(re);
745
- if (!m)
746
- continue;
747
- baseName = m[m.length - 1];
748
- baseName = baseName[0].toLowerCase() + baseName.slice(1);
749
- match = m;
750
- }
751
- else {
752
- if (!prop.startsWith(rule.prefix) || prop.length <= rule.prefix.length)
753
- continue;
754
- baseName =
755
- prop[rule.prefix.length].toLowerCase() +
756
- prop.slice(rule.prefix.length + 1);
757
- match = rule.prefix;
758
- }
737
+ const matched = matchRule(rule, prop);
759
738
  // Only apply if baseName can actually resolve to an icon
760
- if (!canResolve(baseName))
739
+ if (!matched || !canResolve(matched.baseName))
761
740
  continue;
762
- const result = rule.apply(safeIconSuffix(baseName), match, parts);
741
+ const result = rule.apply(safeIconSuffix(matched.baseName), matched.match, parts);
763
742
  if (typeof result === 'string')
764
743
  return resolveIcon(result, parts);
765
744
  if (result)
@@ -767,42 +746,22 @@ function composeIcon(prop, parts) {
767
746
  }
768
747
  return null;
769
748
  }
770
- const MAX_REDIRECTS = 10;
771
- // Style suffixes — value then letter code:
772
- // 50o (opacity), 75s (scale), 20x (translateX%), _10y (translateY%)
773
- // 90r (rotate 90°), _45r (rotate -45°), 0f (flipH), 1f (flipV)
774
- // _FF0000F (fill hex), _f00S (stroke hex), 3W (stroke-width)
775
- // _brandColorF (fill var), _accentS (stroke var)
776
- // Icon names ending in digits need _ separator: edit2_50o
777
- const SUFFIX_RE = /(?:(?<=[a-zA-Z_])(?:_?\d+[osxyr]|[01]f|\d+W)|_[a-zA-Z0-9]+[FS])+$/;
749
+ // Redirect names being resolved right now, so a redirect cycle ends instead of recursing.
750
+ const expanding = new Set();
751
+ // The suffix grammar lives in icon-suffixes.ts, shared with iconSvg.
778
752
  function parseStyleSuffixes(name) {
779
- const match = name.match(SUFFIX_RE);
780
- if (!match)
781
- return null;
782
- let baseName = name.slice(0, match.index);
783
- if (!baseName)
784
- return null;
785
- // Strip trailing _ separator (for digit-ending names: edit2_50o)
786
- if (baseName.endsWith('_'))
787
- baseName = baseName.slice(0, -1);
788
- if (!baseName)
753
+ const split = splitSuffixes(name);
754
+ if (!split)
789
755
  return null;
756
+ const { baseName } = split;
790
757
  const data = iconData;
791
758
  // Accept if baseName is in iconData, or matches a composition rule prefix
792
759
  if (!data[baseName]) {
793
- const matchesRule = iconRules.some((rule) => {
794
- if (rule.prefix instanceof RegExp) {
795
- return rule.prefix.test(baseName);
796
- }
797
- return (baseName.startsWith(rule.prefix) && baseName.length > rule.prefix.length);
798
- });
799
- if (!matchesRule)
760
+ if (!iconRules.some((rule) => matchRule(rule, baseName)))
800
761
  return null;
801
762
  }
802
763
  const style = {};
803
- const suffixes = match[0].match(/_?\d+[osxyr]|[01]f|_[a-zA-Z0-9]+[FS]|\d+W/g);
804
- if (!suffixes)
805
- return null;
764
+ const suffixes = split.tokens;
806
765
  const transforms = [];
807
766
  for (const s of suffixes) {
808
767
  const code = s[s.length - 1];
@@ -821,7 +780,7 @@ function parseStyleSuffixes(name) {
821
780
  style.strokeWidth = s.slice(0, -1);
822
781
  }
823
782
  else {
824
- const val = parseInt(s.replace('_', '-'));
783
+ const val = suffixNumber(s);
825
784
  switch (code) {
826
785
  case 'o':
827
786
  style.opacity = String(val / 100);
@@ -857,24 +816,41 @@ export function resolveIcon(prop, parts) {
857
816
  const data = iconData;
858
817
  // Direct match or redirect chain
859
818
  let name = prop;
860
- for (let i = 0; i < MAX_REDIRECTS; i++) {
861
- const spec = data[name];
862
- if (!spec)
863
- break;
819
+ const seen = new Set();
820
+ for (let spec = data[name]; spec; spec = data[name]) {
864
821
  if (spec.startsWith('<'))
865
822
  return makeIcon(spec, parts);
823
+ seen.add(name);
866
824
  name = spec;
825
+ if (seen.has(name)) {
826
+ console.warn(`icon ${prop} redirects in a cycle`);
827
+ return makeIcon(iconData.square, parts);
828
+ }
867
829
  }
868
830
  if (name !== prop) {
869
- const composed = composeIcon(name, parts);
870
- if (composed)
871
- return composed;
872
- const parsed = parseStyleSuffixes(name);
873
- if (parsed) {
874
- const icon = resolveIcon(parsed.baseName, parts);
875
- Object.assign(icon.style, parsed.style);
876
- return icon;
831
+ /*
832
+ A redirect's target goes through the WHOLE pipeline, stacking included. Handling rules
833
+ and suffixes here and leaving `$` to the checks below tested `prop`, which has no `$`, so
834
+ every named composite drew the placeholder (#224). `expanding` stops a redirect that
835
+ names itself (`a: 'a$b'`) from recursing forever.
836
+ */
837
+ if (!expanding.has(prop)) {
838
+ expanding.add(prop);
839
+ try {
840
+ const icon = resolveIcon(name, parts);
841
+ // A named composite answers to its name, not to the composition it stands for.
842
+ if (icon.classList.contains('tosi-icon-composite')) {
843
+ ;
844
+ icon.dataset.icon = prop;
845
+ }
846
+ return icon;
847
+ }
848
+ finally {
849
+ expanding.delete(prop);
850
+ }
877
851
  }
852
+ console.warn(`icon ${prop} redirects in a cycle`);
853
+ return makeIcon(iconData.square, parts);
878
854
  }
879
855
  // Stack: foo$bar$baz → baz on bottom, bar on top, foo on top
880
856
  if (prop.includes('$')) {