tosijs-ui 1.16.23 → 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,37 @@
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
+
3
35
  ## 1.16.23
4
36
 
5
37
  - **`rule:icon` applies an icon composition rule explicitly.** A `:` marks the boundary
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
@@ -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
@@ -32,5 +32,7 @@ export declare function matchRule(rule: IconRule, name: string): {
32
32
  baseName: string;
33
33
  match: RegExpMatchArray | string;
34
34
  } | null;
35
- /** Whether some rule would draw `name`: it matches, and the icon it applies to exists. */
36
- 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;
@@ -92,8 +92,21 @@ export function matchRule(rule, name) {
92
92
  match: rule.prefix,
93
93
  };
94
94
  }
95
- /** Whether some rule would draw `name`: it matches, and the icon it applies to exists. */
96
- export const ruleDraws = (name) => iconRules.some((rule) => {
97
- const matched = matchRule(rule, name);
98
- return matched != null && canResolve(matched.baseName);
99
- });
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/icons.js CHANGED
@@ -450,7 +450,9 @@ icon. Put a `:` at the boundary to ask for the rule: `check:square`,
450
450
  `un:lock`, `spin90:loader`. The explicit form applies the rule (unless
451
451
  you `defineIcons` that exact name, which warns), needs no capital letter
452
452
  after the prefix, and takes suffixes and stacking
453
- as usual (`spin90:loader40s`, `un:lock$shield`). A `:` is not valid in a
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
454
456
  JavaScript identifier, so write `icons['check:square']()`, or
455
457
  `<tosi-icon icon="check:square">`.
456
458
 
@@ -590,7 +592,7 @@ copies or substantial portions of the Software.
590
592
  import { elements, svgElements, Color, vars, varDefault, withAttributes, } from 'tosijs';
591
593
  import iconData from './icon-data.js';
592
594
  import { splitSuffixes, suffixNumber } from './icon-suffixes.js';
593
- import { iconRules, spinRule, matchRule, canResolve, suffixedIcon, } from './icon-rules.js';
595
+ import { iconRules, spinRule, matchRule, drawable, suffixedIcon, } from './icon-rules.js';
594
596
  export { iconRules } from './icon-rules.js';
595
597
  export const defineIcons = (newIcons) => {
596
598
  const data = iconData;
@@ -608,7 +610,7 @@ export const defineIcons = (newIcons) => {
608
610
  for (const name of isNew) {
609
611
  const readsAsRule = iconRules.some((rule) => {
610
612
  const matched = matchRule(rule, name);
611
- if (matched == null || !canResolve(matched.baseName))
613
+ if (matched == null || !drawable(matched.baseName))
612
614
  return false;
613
615
  return (typeof rule.prefix !== 'string' ||
614
616
  name.includes(':') ||
@@ -784,8 +786,11 @@ export function wrapIcon(prop, parts, ...children) {
784
786
  function composeIcon(prop, parts) {
785
787
  for (const rule of iconRules) {
786
788
  const matched = matchRule(rule, prop);
787
- // Only apply if baseName can actually resolve to an icon
788
- 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))
789
794
  continue;
790
795
  const result = rule.apply(safeIconSuffix(matched.baseName), matched.match, parts);
791
796
  if (typeof result === 'string')