@brust/runtime-dom 2.0.0-alpha.19 → 2.0.0-alpha.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brust/runtime-dom",
3
- "version": "2.0.0-alpha.19",
3
+ "version": "2.0.0-alpha.20",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -1,10 +1,35 @@
1
1
  import { BINDERS, parsed, read, warnOnce } from '../core-api'
2
2
  import { BOOLEAN, OVERLOADED, STRINGIFY } from '../attr-kinds'
3
+ import type { Instance } from '../instance'
3
4
 
4
5
  // Attributes that execute or load code from a string; a bound value must never reach them as-is.
5
6
  const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'data', 'xlink:href', 'ping'])
6
7
  // Scheme allowlist on the parsed URL (relative URLs resolve to http): new URL() applies the browser's tab/newline/control-char stripping.
7
8
  const safeUrl = (v: string): boolean => { try { return ['http:', 'https:', 'mailto:', 'tel:'].includes(new URL(v, 'http://x').protocol) } catch { return false } }
9
+ /** Writes one bound attribute value with React's rules (shared by `x-bind-*` and `x-prop-*`, A2: one print path). */
10
+ function writeAttr(inst: Instance, attr: string, el: Element, name: string, v: unknown): void {
11
+ // setAttribute: `className` is a getter-only SVGAnimatedString on SVG elements (F96a).
12
+ // F311: undefined/null removes `class` (React does; `className=""` keeps an empty one), and an empty `style` is no attribute (the first paint omits it)
13
+ if (name === 'class') { if (v == null) el.removeAttribute('class'); else el.setAttribute('class', `${v}`); return }
14
+ if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
15
+ // React passes every attribute of a custom element (a tag with `-`) through as given: `true` is ``, `false` omits it.
16
+ const custom = el.localName.includes('-')
17
+ if (!custom && BOOLEAN.has(name)) {
18
+ const on = Boolean(v)
19
+ if (name in el) (el as unknown as Record<string, unknown>)[name] = on
20
+ if (on) el.setAttribute(name, '') ; else el.removeAttribute(name)
21
+ return
22
+ }
23
+ // aria-*, data-* and the booleanish strings print `true`/`false`; download/capture print `true` as ``; on any other attribute React omits a boolean.
24
+ if (!custom && (name.startsWith('aria-') || name.startsWith('data-') || STRINGIFY.has(name))) { if (v == null) el.removeAttribute(name); else el.setAttribute(name, String(v)); return }
25
+ if ((custom || OVERLOADED.has(name)) && typeof v === 'boolean') { if (v) el.setAttribute(name, ''); else el.removeAttribute(name); return }
26
+ if (v == null || typeof v === 'boolean' || (name === 'style' && !v)) { el.removeAttribute(name); return }
27
+ // React writes no `src=""` / `href=""` (it would load the page again); an anchor keeps its empty href.
28
+ if (v === '' && (name === 'src' || name === 'href') && el.localName !== 'a' && !custom) { el.removeAttribute(name); return }
29
+ if (URL_ATTRS.has(name) && !safeUrl(String(v))) { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name}: unsafe URL refused`); return }
30
+ el.setAttribute(name, String(v))
31
+ }
32
+
8
33
  BINDERS.push({
9
34
  match: (a) => a.startsWith('x-bind-'),
10
35
  bind: ({ inst, el, attr, raw, scope }) => {
@@ -13,27 +38,25 @@ BINDERS.push({
13
38
  const p = parsed(inst, attr, raw); if (!p) return
14
39
  inst.effect(() => {
15
40
  const r = read(inst, p, scope, attr); if (!r.ok) return
16
- const v = r.value
17
- // setAttribute: `className` is a getter-only SVGAnimatedString on SVG elements (F96a).
18
- // F311: undefined/null removes `class` (React does; `className=""` keeps an empty one), and an empty `style` is no attribute (the first paint omits it)
19
- if (name === 'class') { if (v == null) el.removeAttribute('class'); else el.setAttribute('class', `${v}`); return }
20
- if (name === 'value' && 'value' in el) { (el as HTMLInputElement).value = v == null ? '' : String(v); return }
21
- // React passes every attribute of a custom element (a tag with `-`) through as given: `true` is ``, `false` omits it.
22
- const custom = el.localName.includes('-')
23
- if (!custom && BOOLEAN.has(name)) {
24
- const on = Boolean(v)
25
- if (name in el) (el as unknown as Record<string, unknown>)[name] = on
26
- if (on) el.setAttribute(name, '') ; else el.removeAttribute(name)
27
- return
28
- }
29
- // aria-*, data-* and the booleanish strings print `true`/`false`; download/capture print `true` as ``; on any other attribute React omits a boolean.
30
- if (!custom && (name.startsWith('aria-') || name.startsWith('data-') || STRINGIFY.has(name))) { if (v == null) el.removeAttribute(name); else el.setAttribute(name, String(v)); return }
31
- if ((custom || OVERLOADED.has(name)) && typeof v === 'boolean') { if (v) el.setAttribute(name, ''); else el.removeAttribute(name); return }
32
- if (v == null || typeof v === 'boolean' || (name === 'style' && !v)) { el.removeAttribute(name); return }
33
- // React writes no `src=""` / `href=""` (it would load the page again); an anchor keeps its empty href.
34
- if (v === '' && (name === 'src' || name === 'href') && el.localName !== 'a' && !custom) { el.removeAttribute(name); return }
35
- if (URL_ATTRS.has(name) && !safeUrl(String(v))) { warnOnce(`b:${inst.name}:${name}`, `${attr} in ${inst.name}: unsafe URL refused`); return }
36
- el.setAttribute(name, String(v))
41
+ writeAttr(inst, attr, el, name, r.value)
37
42
  })
38
43
  },
39
44
  })
45
+
46
+ // F329b: `x-prop-<attribute>="<prop key>"` binds the attribute to a prop of THIS instance (`inst.props()`), the key being
47
+ // the VALUE (a prop key is not a member path: `aria-checked`, `tabIndex`). The key set is a property of one call site, so it is
48
+ // printed in the template, never as a chunk member (the chunk stays shared). The attribute name is the lowercase DOM name the
49
+ // server print used (the shared React -> attribute table), so both ends agree.
50
+ // Refused, as `x-bind-*` refuses them and more: event handlers, `srcdoc`, `x-*`, and the keys the server print never
51
+ // binds through a spread (class, style, value, checked, selected, URL-bearing attributes).
52
+ const PROP_REFUSED = new Set(['srcdoc', 'class', 'style', 'value', 'checked', 'selected', ...URL_ATTRS])
53
+ BINDERS.push({
54
+ match: (a) => a.startsWith('x-prop-'),
55
+ bind: ({ inst, el, attr, raw }) => {
56
+ const name = attr.slice('x-prop-'.length)
57
+ if (!name || !raw || name.startsWith('on') || name.startsWith('x-') || PROP_REFUSED.has(name)) {
58
+ warnOnce(`pb:${inst.name}:${name}`, `${attr} in ${inst.name} refused`); return
59
+ }
60
+ inst.effect(() => { const ps = inst.props(); writeAttr(inst, attr, el, name, Object.hasOwn(ps, raw) ? ps[raw] : undefined) })
61
+ },
62
+ })