tosijs-ui 1.16.22 → 1.16.24

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,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.24
4
+
5
+ `dist/iife.js` grows by about 0.2 kB gzip.
6
+
7
+ - **Composition rules chain.** A rule may apply to another rule, so `un:check:square` slashes
8
+ the checked square, and `unCheckLock` draws instead of the placeholder. `iconSvg` reports a
9
+ chained rule as needing the renderer, as it does any rule. Chains stop at four rules deep,
10
+ which keeps a hostile icon name cheap to resolve, and a custom rule whose prefix can match
11
+ nothing no longer recurses. `defineIcons` may now warn about a new name that reads as a
12
+ chain (`unCheckFoo`); it is a warning, and your definition still wins.
13
+ - **The icon composition demo shows the bracket form.** It printed `icons.check:square()`,
14
+ which is not JavaScript; a name with a `:` now shows as `icons['check:square']()`.
15
+ - **Prettier never formats SVG files.** `*.svg` is in `.prettierignore` and a `requirePragma`
16
+ override, and `tosijs-format` excludes it on the command line as it does markdown. Prettier
17
+ has no SVG parser of its own, but an editor association or a plugin such as
18
+ `prettier-plugin-xml` would reflow a drawing tool's export. Adopters using `tosijs-format`
19
+ get the command-line exclusion. To cover editors too, add `*.svg` to `.prettierignore` and
20
+ `{ "files": ["*.svg"], "options": { "requirePragma": true } }` to your Prettier `overrides`.
21
+
22
+ ### Dev-server access
23
+
24
+ - **A live example's "Save to source" is offered wherever the dev server admits the page**:
25
+ a LAN device under `trustLan`, or a tunnel visitor with an edit session, not only
26
+ `localhost`. The dev server marks the pages it serves with `editableSources` on, and an
27
+ example on such a page asks with a `HEAD` request when it connects, once per source file; it
28
+ believes only an answer carrying the dev server's own header. A deployed static site is never
29
+ asked.
30
+ - **Every source write admitted by `trustLan` is logged** with the device's address, the file
31
+ (quoted, so a crafted name cannot forge a log line) and whether it was saved.
32
+ - **The dev server suggests `trustLan` only to a refused device on a private network**, the
33
+ one case it would admit; anyone else is pointed at an edit link.
34
+
35
+ ## 1.16.23
36
+
37
+ - **`rule:icon` applies an icon composition rule explicitly.** A `:` marks the boundary
38
+ between a rule's prefix and the icon it applies to. `checkSquare` is a registered icon, so
39
+ it draws that icon; `check:square` is the `check` rule over `square`. The explicit form
40
+ always applies the rule, needs no capital after the prefix (`check:Square` is the same), and
41
+ takes suffixes and stacking as usual (`spin90:loader40s`, `un:lock$shield`). A `:` is not
42
+ valid in an identifier, so write `icons['check:square']()` or
43
+ `<tosi-icon icon="check:square">`, and it works in the `caption:icon` option strings of
44
+ `<tosi-select>` and `<tosi-segmented>` (`done=Done:check:square`), whose caption now ends at
45
+ the first colon. Names without a `:` resolve exactly as before. `defineIcons` warns when
46
+ it defines a `rule:icon` name, since the definition then wins over the rule.
47
+ - **The kitchen-sink style testbed is no longer bound into the ePub or print.** It stays on
48
+ the site; it is a component showcase, not reading. Docs-only: no API or rendering moved.
49
+
3
50
  ## 1.16.22
4
51
 
5
52
  Three site options asked for by adopters, and a review pass over the documentation.
package/bin/format.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  /*
3
3
  tosijs-format — the house format step, shipped as a command (#187).
4
4
 
5
- tosijs-format eslint --fix, then prettier --write (markdown excluded)
6
- tosijs-format --check eslint, then prettier --check (markdown excluded); exit 1 on any finding
5
+ tosijs-format eslint --fix, then prettier --write (markdown and SVG excluded)
6
+ tosijs-format --check eslint, then prettier --check (markdown and SVG excluded); exit 1 on any finding
7
7
 
8
8
  **Prettier never touches markdown** in this stack. The rule itself lives in the Prettier
9
9
  CONFIG, where every entry point reads it (editors, lint-staged, a bare `prettier --write`):
@@ -31,10 +31,11 @@ import { parseArgv } from './resolve-site-config.ts'
31
31
 
32
32
  const { has } = parseArgv(process.argv.slice(2), {
33
33
  bin: 'tosijs-format',
34
- summary: 'the house format step: eslint, then prettier (never markdown)',
34
+ summary:
35
+ 'the house format step: eslint, then prettier (never markdown or SVG)',
35
36
  flags: ['check'],
36
37
  usage:
37
- ` tosijs-format eslint --fix ., then prettier --write . (markdown excluded)\n` +
38
+ ` tosijs-format eslint --fix ., then prettier --write . (markdown and SVG excluded)\n` +
38
39
  ` tosijs-format --check check only: exit 1 on anything unformatted or any lint error\n\n` +
39
40
  `Uses the project's own prettier and eslint (node_modules/.bin). ESLint runs only when the\n` +
40
41
  `project has an eslint.config.* file.`,
@@ -81,13 +82,18 @@ if (hasEslintConfig) {
81
82
  }
82
83
  }
83
84
 
84
- // The exclusion is the point of this command: prettier never touches markdown.
85
+ /*
86
+ The exclusion is the point of this command: prettier never touches markdown, or SVG.
87
+ Prettier has no SVG parser of its own, but an editor association or a plugin
88
+ (prettier-plugin-xml) gives it one, and an SVG is a drawing tool's export, not source to
89
+ reflow.
90
+ */
85
91
  if (
86
92
  run([
87
93
  bin('prettier'),
88
94
  check ? '--check' : '--write',
89
95
  '.',
90
- '!**/*.{md,markdown,mdx}',
96
+ '!**/*.{md,markdown,mdx,svg}',
91
97
  ]) !== 0
92
98
  ) {
93
99
  failed = true
@@ -0,0 +1,2 @@
1
+ /** `caption:icon` split at the first colon, both trimmed; the icon is '' when absent. */
2
+ export declare function splitCaptionIcon(text: string): [string, string];
@@ -0,0 +1,12 @@
1
+ /*
2
+ `caption:icon` in the option strings of `<tosi-select>` and `<tosi-segmented>`, split in ONE
3
+ place. The caption ends at the FIRST colon, because an icon name may hold one itself: an
4
+ explicit composition rule (`check:square`) is still an icon name.
5
+ */
6
+ /** `caption:icon` split at the first colon, both trimmed; the icon is '' when absent. */
7
+ export function splitCaptionIcon(text) {
8
+ const colon = text.indexOf(':');
9
+ return colon === -1
10
+ ? [text.trim(), '']
11
+ : [text.slice(0, colon).trim(), text.slice(colon + 1).trim()];
12
+ }
@@ -295,6 +295,44 @@ export declare function mayWriteSource(opts: {
295
295
  /** did the request carry forwarding headers? A proxied request is never LAN trust */
296
296
  proxied?: boolean;
297
297
  }): boolean;
298
+ /**
299
+ * Who a `/__docstore/source` request is admitted as, or null to refuse it.
300
+ *
301
+ * The WHOLE gate for that endpoint, taking the real `Request`: `mayWriteSource` on the peer and
302
+ * listener, `trustLan` refused through a proxy, and the same-origin check (#90) on the direct
303
+ * path. It was inline in the server closure, where the 1.16.22 review could only verify LAN
304
+ * trust by hand (#3346); here it is tested with the headers a browser actually sends.
305
+ *
306
+ * `'lan'` is a write admitted only because the project trusts its LAN, which the server logs.
307
+ */
308
+ export declare function sourceRequestAdmission(request: {
309
+ url?: string;
310
+ headers: {
311
+ get(name: string): string | null;
312
+ };
313
+ }, opts: {
314
+ viaTunnel: boolean;
315
+ peer?: string | null;
316
+ auth: AuthState;
317
+ trustLan?: boolean;
318
+ now: number;
319
+ }): 'loopback' | 'lan' | 'session' | null;
320
+ /**
321
+ * Whether to tell a refused source request about `trustLan`: only a direct, unproxied device on
322
+ * a private network, the one case turning it on would admit, and only while it is off. Anyone
323
+ * else is pointed at an edit link, which is what would help them.
324
+ */
325
+ export declare function suggestsTrustLan(opts: {
326
+ viaTunnel: boolean;
327
+ peer?: string | null;
328
+ proxied: boolean;
329
+ trustLan: boolean;
330
+ }): boolean;
331
+ /**
332
+ * The log line for a source write admitted only by `trustLan`. The file name is the client's,
333
+ * so it is quoted as JSON: a newline or an escape sequence in it cannot forge a log line.
334
+ */
335
+ export declare function trustLanWriteRecord(peer: string | null | undefined, file: unknown, status: number): string;
298
336
  /**
299
337
  * May the holder of this session token write?
300
338
  *
@@ -658,6 +658,57 @@ export function mayWriteSource(opts) {
658
658
  opts.proxied !== true &&
659
659
  isPrivateNetworkAddress(opts.peer));
660
660
  }
661
+ /**
662
+ * Who a `/__docstore/source` request is admitted as, or null to refuse it.
663
+ *
664
+ * The WHOLE gate for that endpoint, taking the real `Request`: `mayWriteSource` on the peer and
665
+ * listener, `trustLan` refused through a proxy, and the same-origin check (#90) on the direct
666
+ * path. It was inline in the server closure, where the 1.16.22 review could only verify LAN
667
+ * trust by hand (#3346); here it is tested with the headers a browser actually sends.
668
+ *
669
+ * `'lan'` is a write admitted only because the project trusts its LAN, which the server logs.
670
+ */
671
+ export function sourceRequestAdmission(request, opts) {
672
+ const session = readCookie(request.headers.get('cookie'), SESSION_COOKIE);
673
+ const allowed = mayWriteSource({
674
+ viaTunnel: opts.viaTunnel,
675
+ peer: opts.peer,
676
+ hasValidSession: validSessionCookie(opts.auth, session, opts.now),
677
+ // A share link's session may browse and may not save (asked AFTER validity, which is
678
+ // the call-order `cookieMayWrite` documents).
679
+ sessionMayWrite: cookieMayWrite(opts.auth, session),
680
+ trustLan: opts.trustLan === true,
681
+ proxied: isProxiedRequest(request.headers),
682
+ }) &&
683
+ // CSRF (#90): the direct path is peer-address-only. The tunnel path is legitimately
684
+ // cross-site and is gated by the session cookie instead.
685
+ (opts.viaTunnel || isSameOriginRequest(request));
686
+ if (!allowed)
687
+ return null;
688
+ if (opts.viaTunnel)
689
+ return 'session';
690
+ return isLoopbackAddressForAuth(opts.peer) ? 'loopback' : 'lan';
691
+ }
692
+ /**
693
+ * Whether to tell a refused source request about `trustLan`: only a direct, unproxied device on
694
+ * a private network, the one case turning it on would admit, and only while it is off. Anyone
695
+ * else is pointed at an edit link, which is what would help them.
696
+ */
697
+ export function suggestsTrustLan(opts) {
698
+ return (!opts.trustLan &&
699
+ !opts.viaTunnel &&
700
+ !opts.proxied &&
701
+ isPrivateNetworkAddress(opts.peer));
702
+ }
703
+ /**
704
+ * The log line for a source write admitted only by `trustLan`. The file name is the client's,
705
+ * so it is quoted as JSON: a newline or an escape sequence in it cannot forge a log line.
706
+ */
707
+ export function trustLanWriteRecord(peer, file, status) {
708
+ const name = typeof file === 'string' ? JSON.stringify(file) : '(no file)';
709
+ const outcome = status >= 200 && status < 300 ? 'saved' : `refused (${status})`;
710
+ return `✎ trustLan: ${peer ?? 'unknown'} ${outcome} ${name}`;
711
+ }
661
712
  /**
662
713
  * May the holder of this session token write?
663
714
  *
@@ -22,7 +22,8 @@ import { resolveTunnelLocalPort } from './site-config.js';
22
22
  import { acquireBuildLock, describeHolder } from './build-lock.js';
23
23
  import { sinkPathFor, appendToSink } from './debug-sink.js';
24
24
  import { invitePageHtml } from './invite-page.js';
25
- import { TUNNEL_LINK_CMD, resolveLinkArrival, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, resolveShareTtlMs, cookieMayWrite, readCookie, redeemThroughGate, createRedemptionGate, resolveLinkSettings, sessionCookie, urlWithoutToken, mayWriteSource, isSameOriginRequest, validSessionCookie, sessionRejection, mayDriveWithAgent, isProxiedRequest, SESSION_COOKIE, } from './dev-auth.js';
25
+ import { TUNNEL_LINK_CMD, resolveLinkArrival, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, resolveShareTtlMs, readCookie, redeemThroughGate, createRedemptionGate, resolveLinkSettings, sessionCookie, urlWithoutToken, isSameOriginRequest, validSessionCookie, sessionRejection, mayDriveWithAgent, isProxiedRequest, sourceRequestAdmission, suggestsTrustLan, trustLanWriteRecord, SESSION_COOKIE, } from './dev-auth.js';
26
+ import { SOURCE_ACCESS_HEADER, SOURCE_ENDPOINT_SNIPPET, } from '../../live-example/source-access.js';
26
27
  /**
27
28
  * Every path the dev server watches for changes.
28
29
  *
@@ -842,7 +843,11 @@ export async function devServer(config, opts = {}) {
842
843
  if (!(await file.exists()))
843
844
  return new Response('not found', { status: 404 });
844
845
  return new Response(await file.text(), {
845
- headers: { 'Content-Type': 'text/plain; charset=utf-8' },
846
+ headers: {
847
+ 'Content-Type': 'text/plain; charset=utf-8',
848
+ // Lets a live example off localhost offer Save to source (source-access.ts).
849
+ [SOURCE_ACCESS_HEADER]: 'editable',
850
+ },
846
851
  });
847
852
  }
848
853
  /*
@@ -1067,7 +1072,11 @@ export async function devServer(config, opts = {}) {
1067
1072
  ? HALTIJA_TUNNEL_SNIPPET
1068
1073
  : ''
1069
1074
  : HALTIJA_SNIPPET;
1070
- const extras = haltijaExtra + (testMode ? '' : statusSnippet(request, viaTunnel));
1075
+ const extras = haltijaExtra +
1076
+ (testMode ? '' : statusSnippet(request, viaTunnel)) +
1077
+ // Tells a live example there is a source endpoint to ask (source-access.ts), so a
1078
+ // deployed static site, which has none, is never asked.
1079
+ (config.editableSources ? SOURCE_ENDPOINT_SNIPPET : '');
1071
1080
  const encoding = negotiateEncoding(request?.headers.get('accept-encoding') ?? null);
1072
1081
  if (extras && filePath.endsWith('.html')) {
1073
1082
  const html = await Bun.file(filePath).text();
@@ -1891,28 +1900,27 @@ export async function devServer(config, opts = {}) {
1891
1900
  "looks local" is precisely what a tunnel counterfeits.
1892
1901
  */
1893
1902
  const peer = srv?.requestIP?.(request)?.address;
1894
- const session = readCookie(request.headers.get('cookie'), SESSION_COOKIE);
1895
- const authorized = mayWriteSource({
1903
+ const admission = sourceRequestAdmission(request, {
1896
1904
  viaTunnel,
1897
1905
  peer,
1898
- hasValidSession: validSessionCookie(auth, session, Date.now()),
1899
- // A share link's session may browse and may not save (asked AFTER validity, which
1900
- // is the call-order `cookieMayWrite` documents).
1901
- sessionMayWrite: cookieMayWrite(auth, session),
1906
+ auth,
1902
1907
  trustLan: config.trustLan === true,
1903
- proxied: isProxiedRequest(request.headers),
1904
- }) &&
1905
- /*
1906
- CSRF (#90). The direct path is peer-address-only, so without this any page the
1907
- developer visits can write any file under the repo root — `.git/hooks/*` included.
1908
- Non-tunnel only: the tunnel path is legitimately cross-site and is gated by the
1909
- session cookie instead.
1910
- */
1911
- csrfOk(request, viaTunnel);
1912
- if (!authorized) {
1913
- console.warn(`⚠️ refused ${request.method} /__docstore/source from ${peer ?? 'unknown'} — ` +
1914
- `no session (use \`${TUNNEL_LINK_CMD}\` for an edit link` +
1915
- `${config.trustLan ? '' : ', or set trustLan: true for LAN devices'}).`);
1908
+ now: Date.now(),
1909
+ });
1910
+ if (admission === null) {
1911
+ // A HEAD is a page asking whether it may offer Save to source; a no is not news.
1912
+ if (request.method !== 'HEAD') {
1913
+ console.warn(`⚠️ refused ${request.method} /__docstore/source from ${peer ?? 'unknown'} — ` +
1914
+ `no session (use \`${TUNNEL_LINK_CMD}\` for an edit link` +
1915
+ `${suggestsTrustLan({
1916
+ viaTunnel,
1917
+ peer,
1918
+ proxied: isProxiedRequest(request.headers),
1919
+ trustLan: config.trustLan === true,
1920
+ })
1921
+ ? '; trustLan: true in the site config admits devices on this network'
1922
+ : ''}).`);
1923
+ }
1916
1924
  return new Response('not authorized to edit source', {
1917
1925
  status: 403,
1918
1926
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
@@ -1920,8 +1928,28 @@ export async function devServer(config, opts = {}) {
1920
1928
  }
1921
1929
  if (request.method === 'GET')
1922
1930
  return handleReadSource(request);
1923
- if (request.method === 'POST')
1924
- return handleWriteSource(request);
1931
+ if (request.method === 'HEAD') {
1932
+ const read = await handleReadSource(request);
1933
+ return new Response(null, {
1934
+ status: read.status,
1935
+ headers: read.headers,
1936
+ });
1937
+ }
1938
+ if (request.method === 'POST') {
1939
+ // A write admitted only by LAN trust is recorded with its outcome (#3346).
1940
+ const file = admission === 'lan'
1941
+ ? await request
1942
+ .clone()
1943
+ .json()
1944
+ .then((body) => body?.file)
1945
+ .catch(() => undefined)
1946
+ : undefined;
1947
+ const response = await handleWriteSource(request);
1948
+ if (admission === 'lan') {
1949
+ console.log(trustLanWriteRecord(peer, file, response.status));
1950
+ }
1951
+ return response;
1952
+ }
1925
1953
  return new Response('method not allowed', { status: 405 });
1926
1954
  }
1927
1955
  /*
@@ -614,8 +614,9 @@ export interface SiteConfig {
614
614
  * Trust the local network for reading and editing source (#227). With `editableSources` on,
615
615
  * a browser on another device on your LAN (a phone, a headset) can open and save source
616
616
  * directly, as one at this keyboard can, instead of needing the tunnel and an invite link.
617
- * That is "Edit page source"; a live example's "Save to source" is still offered only on
618
- * `localhost`.
617
+ * That covers "Edit page source" and a live example's "Save to source", which is offered
618
+ * wherever the server admits the page. Every write admitted this way is logged with the
619
+ * device's address and the file.
619
620
  *
620
621
  * Trusted: a direct peer at a private address (RFC 1918 IPv4, IPv6 unique-local or
621
622
  * link-local). Never trusted: tunnel traffic (it needs a session as before) or a request
@@ -21,10 +21,18 @@ export declare function canResolve(name: string): boolean;
21
21
  */
22
22
  export declare const spinRule: IconRule;
23
23
  export declare const iconRules: IconRule[];
24
- /** The icon a rule would apply to in `name`, or null if the rule does not match it. */
24
+ /**
25
+ * The icon a rule would apply to in `name`, or null if the rule does not match it.
26
+ *
27
+ * A `:` makes the boundary explicit: `check:box` is the `check` rule applied to `box`, with
28
+ * no camelCase guess, so it never reads as an icon called `checkBox`. Everything before the
29
+ * first `:` must be the whole prefix (`spin90:loader`, `un:lock`).
30
+ */
25
31
  export declare function matchRule(rule: IconRule, name: string): {
26
32
  baseName: string;
27
33
  match: RegExpMatchArray | string;
28
34
  } | null;
29
- /** Whether some rule would draw `name`: it matches, and the icon it applies to exists. */
30
- export declare const ruleDraws: (name: string) => boolean;
35
+ /** Whether some rule would draw `name`: it matches, and what it applies to draws (rules chain). */
36
+ export declare const ruleDraws: (name: string, depth?: number) => boolean;
37
+ /** A registered icon, suffixes on one, or a rule that draws (`un:check:square`, `unCheckLock`). */
38
+ export declare const drawable: (name: string, depth?: number) => boolean;
@@ -55,8 +55,27 @@ export const iconRules = [
55
55
  apply: (baseName) => `search80s30x30y$${baseName}50o`,
56
56
  },
57
57
  ];
58
- /** The icon a rule would apply to in `name`, or null if the rule does not match it. */
58
+ /**
59
+ * The icon a rule would apply to in `name`, or null if the rule does not match it.
60
+ *
61
+ * A `:` makes the boundary explicit: `check:box` is the `check` rule applied to `box`, with
62
+ * no camelCase guess, so it never reads as an icon called `checkBox`. Everything before the
63
+ * first `:` must be the whole prefix (`spin90:loader`, `un:lock`).
64
+ */
59
65
  export function matchRule(rule, name) {
66
+ const colon = name.indexOf(':');
67
+ if (colon !== -1) {
68
+ const head = name.slice(0, colon);
69
+ const tail = name.slice(colon + 1);
70
+ if (!tail)
71
+ return null;
72
+ const baseName = tail[0].toLowerCase() + tail.slice(1);
73
+ if (rule.prefix instanceof RegExp) {
74
+ const m = head.match(new RegExp(rule.prefix.source + '$'));
75
+ return m && m.index === 0 ? { baseName, match: m } : null;
76
+ }
77
+ return head === rule.prefix ? { baseName, match: rule.prefix } : null;
78
+ }
60
79
  if (rule.prefix instanceof RegExp) {
61
80
  const m = name.match(new RegExp(rule.prefix.source + '(.+)$'));
62
81
  if (!m)
@@ -73,8 +92,21 @@ export function matchRule(rule, name) {
73
92
  match: rule.prefix,
74
93
  };
75
94
  }
76
- /** Whether some rule would draw `name`: it matches, and the icon it applies to exists. */
77
- export const ruleDraws = (name) => iconRules.some((rule) => {
78
- const matched = matchRule(rule, name);
79
- return matched != null && canResolve(matched.baseName);
80
- });
95
+ /*
96
+ How deep rules may chain. Real composites need two (`un:check:square`); the bound is what keeps a
97
+ hostile name cheap. Unbounded, `'un'.repeat(400) + 'Lock'` took over a second, because every
98
+ level asks every rule again (1.16.24 review), and a custom prefix that can match nothing
99
+ (`/^(?:big)?/`) never shortened the name and overflowed the stack.
100
+ */
101
+ const MAX_RULE_DEPTH = 4;
102
+ /** Whether some rule would draw `name`: it matches, and what it applies to draws (rules chain). */
103
+ export const ruleDraws = (name, depth = 0) => depth < MAX_RULE_DEPTH &&
104
+ iconRules.some((rule) => {
105
+ const matched = matchRule(rule, name);
106
+ return (matched != null &&
107
+ // A rule must shorten the name, or it is not applying to anything.
108
+ matched.baseName.length < name.length &&
109
+ drawable(matched.baseName, depth + 1));
110
+ });
111
+ /** A registered icon, suffixes on one, or a rule that draws (`un:check:square`, `unCheckLock`). */
112
+ export const drawable = (name, depth = 0) => canResolve(name) || ruleDraws(name, depth);
package/dist/icon-svg.js CHANGED
@@ -60,6 +60,9 @@ export function iconSvg(name) {
60
60
  return undefined;
61
61
  return resolve(name, key, new Set());
62
62
  }
63
+ // How to call the creator for a name: `icons.lock()`, or `icons['check:box']()` for a name
64
+ // that is not an identifier.
65
+ const creatorCall = (name) => /^[A-Za-z_$][\w$]*$/.test(name) ? `icons.${name}()` : `icons['${name}']()`;
63
66
  function resolve(asked, name, seen) {
64
67
  const key = stripEscape(name);
65
68
  const value = data[key];
@@ -81,7 +84,7 @@ function resolve(asked, name, seen) {
81
84
  const what = key === stripEscape(asked) ? `"${key}"` : `it resolves to "${key}", which`;
82
85
  if (isComposition(key)) {
83
86
  throw new Error(`iconSvg("${asked}"): ${what} is not a single SVG (a stack, a rule, or a style ` +
84
- `suffix other than rotation or flip). Render it with icons.${stripEscape(asked)}() ` +
87
+ `suffix other than rotation or flip). Render it with ${creatorCall(stripEscape(asked))} ` +
85
88
  `where there is a DOM.`);
86
89
  }
87
90
  throw new Error(`iconSvg("${asked}"): it redirects to "${key}", which is not a registered icon`);
package/dist/icons.js CHANGED
@@ -442,6 +442,26 @@ The overlay rules are just string rewrites — for example, `unFoo`
442
442
  becomes `slash25o$foo75s75o`. **Overlay icons should have a square
443
443
  viewBox** for best results on non-square base icons.
444
444
 
445
+ #### Explicit rules: `rule:icon`
446
+
447
+ `checkSquare` is ambiguous: it is a registered icon, and it also reads as
448
+ the `check` rule over `square`. A registered icon wins, so it draws the
449
+ icon. Put a `:` at the boundary to ask for the rule: `check:square`,
450
+ `un:lock`, `spin90:loader`. The explicit form applies the rule (unless
451
+ you `defineIcons` that exact name, which warns), needs no capital letter
452
+ after the prefix, and takes suffixes and stacking
453
+ as usual (`spin90:loader40s`, `un:lock$shield`). Rules chain: a rule
454
+ may apply to another rule, so `un:check:square` slashes the checked square
455
+ (and `unCheckLock` works too). A `:` is not valid in a
456
+ JavaScript identifier, so write `icons['check:square']()`, or
457
+ `<tosi-icon icon="check:square">`.
458
+
459
+ ```html
460
+ <tosi-icon icon="checkSquare" size="48"></tosi-icon>
461
+ <tosi-icon icon="check:square" size="48"></tosi-icon>
462
+ <tosi-icon icon="un:lock" size="48"></tosi-icon>
463
+ ```
464
+
445
465
  ### Custom rules
446
466
 
447
467
  `iconRules` is a mutable array. Each rule has a `prefix` (string or
@@ -572,7 +592,7 @@ copies or substantial portions of the Software.
572
592
  import { elements, svgElements, Color, vars, varDefault, withAttributes, } from 'tosijs';
573
593
  import iconData from './icon-data.js';
574
594
  import { splitSuffixes, suffixNumber } from './icon-suffixes.js';
575
- import { iconRules, spinRule, matchRule, canResolve, suffixedIcon, } from './icon-rules.js';
595
+ import { iconRules, spinRule, matchRule, drawable, suffixedIcon, } from './icon-rules.js';
576
596
  export { iconRules } from './icon-rules.js';
577
597
  export const defineIcons = (newIcons) => {
578
598
  const data = iconData;
@@ -590,9 +610,10 @@ export const defineIcons = (newIcons) => {
590
610
  for (const name of isNew) {
591
611
  const readsAsRule = iconRules.some((rule) => {
592
612
  const matched = matchRule(rule, name);
593
- if (matched == null || !canResolve(matched.baseName))
613
+ if (matched == null || !drawable(matched.baseName))
594
614
  return false;
595
615
  return (typeof rule.prefix !== 'string' ||
616
+ name.includes(':') ||
596
617
  /[A-Z]/.test(name[rule.prefix.length]));
597
618
  });
598
619
  if (readsAsRule) {
@@ -765,8 +786,11 @@ export function wrapIcon(prop, parts, ...children) {
765
786
  function composeIcon(prop, parts) {
766
787
  for (const rule of iconRules) {
767
788
  const matched = matchRule(rule, prop);
768
- // Only apply if baseName can actually resolve to an icon
769
- if (!matched || !canResolve(matched.baseName))
789
+ // Only apply if baseName draws (an icon, or another rule: `un:check:square`), and the
790
+ // rule shortened the name: a prefix that matched nothing is not applying to anything.
791
+ if (!matched ||
792
+ matched.baseName.length >= prop.length ||
793
+ !drawable(matched.baseName))
770
794
  continue;
771
795
  const result = rule.apply(safeIconSuffix(matched.baseName), matched.match, parts);
772
796
  if (typeof result === 'string')