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 +47 -0
- package/bin/format.ts +12 -6
- package/dist/caption-icon.d.ts +2 -0
- package/dist/caption-icon.js +12 -0
- package/dist/doc-system/site/dev-auth.d.ts +38 -0
- package/dist/doc-system/site/dev-auth.js +51 -0
- package/dist/doc-system/site/dev-server.js +52 -24
- package/dist/doc-system/site/site-config.d.ts +3 -2
- package/dist/icon-rules.d.ts +11 -3
- package/dist/icon-rules.js +38 -6
- package/dist/icon-svg.js +4 -1
- package/dist/icons.js +28 -4
- package/dist/iife.js +93 -93
- package/dist/iife.js.map +12 -10
- package/dist/live-example/component.js +13 -3
- package/dist/live-example/source-access.d.ts +12 -0
- package/dist/live-example/source-access.js +46 -0
- package/dist/segmented.js +3 -4
- package/dist/select.js +2 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
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,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,
|
|
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: {
|
|
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 +
|
|
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
|
|
1895
|
-
const authorized = mayWriteSource({
|
|
1903
|
+
const admission = sourceRequestAdmission(request, {
|
|
1896
1904
|
viaTunnel,
|
|
1897
1905
|
peer,
|
|
1898
|
-
|
|
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
|
-
|
|
1904
|
-
})
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
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 === '
|
|
1924
|
-
|
|
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
|
|
618
|
-
*
|
|
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
|
package/dist/icon-rules.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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;
|
package/dist/icon-rules.js
CHANGED
|
@@ -55,8 +55,27 @@ export const iconRules = [
|
|
|
55
55
|
apply: (baseName) => `search80s30x30y$${baseName}50o`,
|
|
56
56
|
},
|
|
57
57
|
];
|
|
58
|
-
/**
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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,
|
|
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 || !
|
|
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
|
|
769
|
-
|
|
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')
|