lume-js 2.3.0 → 2.3.1

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/llms.txt ADDED
@@ -0,0 +1,89 @@
1
+ # Lume.js
2
+
3
+ > Minimal reactive state management using only standard JavaScript and HTML —
4
+ > no custom syntax, no build step, no framework lock-in. 1.46 KB universal
5
+ > core (state/batch), 2.66 KB with DOM binding (bindDom/effect). Reactivity
6
+ > that follows web standards: stores are Proxies over plain objects, DOM
7
+ > binding is declarative via valid data-* attributes, updates are
8
+ > microtask-batched per store with an explicit batch() escape hatch for
9
+ > cross-store writes.
10
+
11
+ Version: 2.3.1. Install: `npm install lume-js` or import from
12
+ `https://cdn.jsdelivr.net/npm/lume-js/dist/index.min.mjs`.
13
+
14
+ Key facts agents get wrong without reading the docs: arrays and nested
15
+ objects must be REPLACED, never mutated in place (reference equality);
16
+ nested objects need explicit state() wrapping; updates flush on the next
17
+ microtask, not synchronously; effects only track keys read synchronously;
18
+ data-* attribute values are state keys, never expressions.
19
+
20
+ ## Start here
21
+
22
+ - [AGENT_GUIDE.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/AGENT_GUIDE.md): Distilled rules, pitfalls, and patterns for coding agents
23
+ - [README.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/README.md): Pitch, installation, quick start, API overview
24
+
25
+ ## Guides
26
+
27
+ - [docs/guides/installation.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/installation.md): CDN, npm, and import options
28
+ - [docs/guides/quick-start.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/quick-start.md): First app in five minutes
29
+ - [docs/guides/core-concepts.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/core-concepts.md): Stores, effects, bindings
30
+ - [docs/guides/reactivity.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/reactivity.md): How the Proxy, tracking, and microtask flush work
31
+ - [docs/guides/choosing-reactive-primitives.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/choosing-reactive-primitives.md): effect vs computed vs watch
32
+ - [docs/guides/two-way-binding.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/two-way-binding.md): Inputs and data-bind
33
+ - [docs/guides/lists.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/lists.md): Rendering arrays
34
+ - [docs/guides/forms.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/forms.md): Form patterns
35
+ - [docs/guides/handlers.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/handlers.md): Extending bindDom with new data-* attributes
36
+ - [docs/guides/cleanup-and-dispose.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/cleanup-and-dispose.md): Tearing down effects and bindings
37
+ - [docs/guides/performance.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/performance.md): Batching and update costs
38
+ - [docs/guides/animations.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/animations.md): Animating on state change
39
+ - [docs/guides/routing.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/routing.md): Client-side routing patterns
40
+ - [docs/guides/ssr-hydration.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/ssr-hydration.md): Server-rendered HTML with reactive hydration
41
+ - [docs/guides/universal-core.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/universal-core.md): Using lume-js/state without a DOM
42
+ - [docs/guides/testing.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/testing.md): Testing stores and bindings
43
+ - [docs/guides/migration.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/migration.md): Migrating between versions
44
+ - [docs/guides/faq.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/guides/faq.md): Frequently asked questions
45
+
46
+ ## Tutorials
47
+
48
+ - [docs/tutorials/build-todo-app.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/tutorials/build-todo-app.md): Todo app walkthrough
49
+ - [docs/tutorials/working-with-arrays.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/tutorials/working-with-arrays.md): Immutable array updates — the golden rule
50
+ - [docs/tutorials/build-tic-tac-toe.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/tutorials/build-tic-tac-toe.md): Game with history and computed winner
51
+
52
+ ## API reference — core
53
+
54
+ - [docs/api/core/state.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/core/state.md): state() reactive stores
55
+ - [docs/api/core/effect.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/core/effect.md): effect() auto-tracking and explicit deps
56
+ - [docs/api/core/bindDom.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/core/bindDom.md): bindDom() DOM binding
57
+ - [docs/api/core/batch.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/core/batch.md): batch() cross-store write grouping
58
+ - [docs/api/core/handlers.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/core/handlers.md): Handler system — the { attr, apply } extension contract
59
+
60
+ ## API reference — handlers
61
+
62
+ - [docs/api/handlers/show.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/show.md): Conditional visibility
63
+ - [docs/api/handlers/className.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/className.md): Full class attribute from state
64
+ - [docs/api/handlers/boolAttr.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/boolAttr.md): Boolean attributes (disabled, hidden, …)
65
+ - [docs/api/handlers/ariaAttr.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/ariaAttr.md): ARIA attributes from state
66
+ - [docs/api/handlers/classToggle.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/classToggle.md): Toggle a single class
67
+ - [docs/api/handlers/stringAttr.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/stringAttr.md): String attributes (href, src, title, …)
68
+ - [docs/api/handlers/on.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/on.md): Declarative event wiring via data-on*
69
+ - [docs/api/handlers/htmlAttrs.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/handlers/htmlAttrs.md): Preset bundle of common attributes
70
+
71
+ ## API reference — addons
72
+
73
+ - [docs/api/addons/computed.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/computed.md): Derived read-only values
74
+ - [docs/api/addons/watch.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/watch.md): Single-key observation
75
+ - [docs/api/addons/repeat.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/repeat.md): Keyed list rendering
76
+ - [docs/api/addons/persist.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/persist.md): localStorage/sessionStorage sync
77
+ - [docs/api/addons/hydrateState.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/hydrateState.md): SSR hydration
78
+ - [docs/api/addons/createCleanupGroup.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/createCleanupGroup.md): Grouped disposal
79
+ - [docs/api/addons/debug.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/debug.md): Write/flush logging
80
+ - [docs/api/addons/withPlugins.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/withPlugins.md): State extension system
81
+ - [docs/api/addons/isReactive.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/api/addons/isReactive.md): Reactive brand detection
82
+
83
+ ## Design
84
+
85
+ - [docs/design/design-decisions.md](https://raw.githubusercontent.com/sathvikc/lume-js/main/docs/design/design-decisions.md): Ratified decisions and their reasoning
86
+
87
+ ## Optional
88
+
89
+ - [llms-full.txt](https://raw.githubusercontent.com/sathvikc/lume-js/main/llms-full.txt): every file above concatenated into one document
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lume-js",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "Minimal reactive state management using only standard JavaScript and HTML - no custom syntax, no build step required",
5
5
  "main": "dist/index.mjs",
6
6
  "module": "dist/index.mjs",
@@ -43,13 +43,18 @@
43
43
  "test:watch": "vitest",
44
44
  "coverage": "vitest run --coverage",
45
45
  "typecheck": "tsc --noEmit",
46
- "validate": "npm run build && node scripts/check-size.js && node scripts/complexity.js && npm run lint && npm run typecheck && npm run coverage",
46
+ "llms": "node scripts/build-llms.js",
47
+ "llms:check": "node scripts/build-llms.js --check",
48
+ "validate": "npm run build && node scripts/check-size.js && node scripts/complexity.js && npm run lint && npm run typecheck && npm run coverage && node scripts/build-llms.js --check",
47
49
  "prepublishOnly": "npm run validate"
48
50
  },
49
51
  "files": [
50
52
  "dist",
51
53
  "src",
52
54
  "README.md",
55
+ "AGENT_GUIDE.md",
56
+ "llms.txt",
57
+ "llms-full.txt",
53
58
  "LICENSE"
54
59
  ],
55
60
  "keywords": [
@@ -153,9 +153,11 @@ export interface RepeatOptions<T> {
153
153
 
154
154
  /**
155
155
  * Function called ONCE when element is created (for DOM structure setup)
156
- * Recommended for event listeners and innerHTML setup
156
+ * Recommended for event listeners and innerHTML setup.
157
+ * May return a cleanup function — it is called automatically when the
158
+ * element is removed (by list update or full cleanup).
157
159
  */
158
- create?: (item: T, element: HTMLElement, index: number) => void;
160
+ create?: (item: T, element: HTMLElement, index: number) => void | (() => void);
159
161
 
160
162
  /**
161
163
  * Function called on every update for data binding
@@ -163,6 +165,13 @@ export interface RepeatOptions<T> {
163
165
  */
164
166
  update?: (item: T, element: HTMLElement, index: number, context: UpdateContext) => void;
165
167
 
168
+ /**
169
+ * Additional cleanup when an element is removed. Called after any cleanup
170
+ * function returned by create(). Prefer returning a cleanup from create()
171
+ * for automatic lifecycle management.
172
+ */
173
+ remove?: (item: T, element: HTMLElement) => void;
174
+
166
175
  /**
167
176
  * Declarative item structure from a standard <template> element.
168
177
  * - true: use the first <template> inside the container
@@ -458,6 +467,9 @@ export interface Plugin {
458
467
  * Wrap a reactive state proxy with plugin hooks.
459
468
  * Plugins can intercept get/set/notify/subscribe operations.
460
469
  *
470
+ * The returned proxy additionally exposes $dispose(), which removes the
471
+ * plugin layer's flush hook and clears its pending notifications.
472
+ *
461
473
  * @param store - A reactive proxy from state()
462
474
  * @param plugins - Array of plugin objects
463
475
  * @returns A new proxy wrapping the store with plugin behavior
@@ -468,9 +480,13 @@ export interface Plugin {
468
480
  * import { withPlugins, createDebugPlugin } from 'lume-js/addons';
469
481
  *
470
482
  * const store = withPlugins(state({ count: 0 }), [createDebugPlugin({ label: 'counter' })]);
483
+ * store.$dispose(); // detach the plugin layer when done
471
484
  * ```
472
485
  */
473
- export function withPlugins<T extends object>(store: ReactiveState<T>, plugins: Plugin[]): ReactiveState<T>;
486
+ export function withPlugins<T extends object>(
487
+ store: ReactiveState<T>,
488
+ plugins: Plugin[]
489
+ ): ReactiveState<T> & { $dispose(): void };
474
490
 
475
491
  /**
476
492
  * A group that collects cleanup/unsubscribe functions and can dispose them all at once.
@@ -508,7 +524,9 @@ export function createCleanupGroup(): CleanupGroup;
508
524
  * embedded in the server-rendered HTML.
509
525
  *
510
526
  * @param selector - CSS selector for the script element (default: '#__LUME_DATA__')
511
- * @returns Parsed JSON object, or empty object if not found or invalid
527
+ * @param validate - Optional validator: (data) => boolean. If it returns
528
+ * false, hydrateState returns {} instead of the parsed data.
529
+ * @returns Parsed JSON object, or empty object if not found, invalid, or rejected
512
530
  *
513
531
  * @example
514
532
  * ```typescript
@@ -516,9 +534,14 @@ export function createCleanupGroup(): CleanupGroup;
516
534
  * import { hydrateState } from 'lume-js/addons';
517
535
  *
518
536
  * const store = state(hydrateState());
537
+ *
538
+ * // With schema validation
539
+ * const data = hydrateState('#__LUME_DATA__', d =>
540
+ * typeof (d as any).title === 'string'
541
+ * );
519
542
  * ```
520
543
  */
521
- export function hydrateState(selector?: string): object;
544
+ export function hydrateState(selector?: string, validate?: (data: unknown) => boolean): object;
522
545
 
523
546
  /**
524
547
  * Options for persist()
@@ -526,6 +549,7 @@ export function hydrateState(selector?: string): object;
526
549
  export interface PersistOptions {
527
550
  /**
528
551
  * Keys to persist. Default: all own non-$ keys of the store at call time.
552
+ * An explicit empty array is respected: persists and hydrates nothing.
529
553
  */
530
554
  keys?: string[];
531
555
  /**
@@ -53,6 +53,25 @@ function serializeKeys(store, watched) {
53
53
  return JSON.stringify(out);
54
54
  }
55
55
 
56
+ // storage object → Set of storage keys currently managed by a persist()
57
+ // instance. Two instances on one entry silently overwrite each other's
58
+ // subsets (each serializes only its own watched keys over the whole blob),
59
+ // so the second registration gets a loud warning.
60
+ const activeEntries = new WeakMap();
61
+
62
+ /**
63
+ * Default storage resolution. Wrapped because accessing localStorage can
64
+ * THROW (SecurityError) in cookie-blocked iframes and some privacy modes —
65
+ * persist() must degrade to a warning, never crash the app at setup.
66
+ */
67
+ function defaultStorage() {
68
+ try {
69
+ return typeof localStorage !== 'undefined' ? localStorage : undefined;
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
56
75
  /**
57
76
  * Sync store keys with a Storage object.
58
77
  *
@@ -75,17 +94,35 @@ export function persist(store, storageKey, options = {}) {
75
94
 
76
95
  const storage = options.storage !== undefined
77
96
  ? options.storage
78
- : globalThis.localStorage;
97
+ : defaultStorage();
79
98
 
80
99
  if (!storage || typeof storage.getItem !== 'function') {
81
100
  logWarn('[Lume.js] persist(): no storage available — persistence disabled');
82
101
  return () => {};
83
102
  }
84
103
 
85
- const watched = Array.isArray(options.keys) && options.keys.length > 0
104
+ // An explicit array is respected as-is — including [] (persist nothing).
105
+ // Only an absent/non-array option falls back to all own non-$ keys.
106
+ const watched = Array.isArray(options.keys)
86
107
  ? options.keys.slice()
87
108
  : Object.keys(store).filter(k => !k.startsWith('$'));
88
109
 
110
+ // Warn when another live persist() already manages this storage entry
111
+ let entrySet = activeEntries.get(storage);
112
+ if (!entrySet) {
113
+ entrySet = new Set();
114
+ activeEntries.set(storage, entrySet);
115
+ }
116
+ const ownsEntry = !entrySet.has(storageKey);
117
+ if (ownsEntry) {
118
+ entrySet.add(storageKey);
119
+ } else {
120
+ logWarn(
121
+ `[Lume.js] persist(): "${storageKey}" is already managed by another persist() on this storage — ` +
122
+ 'instances will overwrite each other\'s data. Use a distinct key per store.'
123
+ );
124
+ }
125
+
89
126
  // ── Hydrate ────────────────────────────────────────────────────────────
90
127
  // Only watched keys are assigned — stale storage can't inject others.
91
128
  const stored = readStored(storage, storageKey);
@@ -147,6 +184,7 @@ export function persist(store, storageKey, options = {}) {
147
184
 
148
185
  return () => {
149
186
  disposed = true;
187
+ if (ownsEntry) entrySet.delete(storageKey);
150
188
  while (unsubs.length) unsubs.pop()();
151
189
  };
152
190
  }
@@ -114,11 +114,11 @@ import { logWarn, logError } from '../utils/log.js';
114
114
  import { applyBindValue } from '../core/bindDom.js';
115
115
 
116
116
  /**
117
- * Resolve the template option to its single root element.
117
+ * Resolve the template option to the <template> element.
118
118
  * Accepts true (first <template> inside the container), a CSS selector,
119
119
  * or an HTMLTemplateElement directly.
120
120
  */
121
- function resolveTemplateRoot(template, containerEl) {
121
+ function resolveTemplateEl(template, containerEl) {
122
122
  let templateEl = template;
123
123
  if (template === true) {
124
124
  templateEl = containerEl.querySelector('template');
@@ -131,7 +131,22 @@ function resolveTemplateRoot(template, containerEl) {
131
131
  if (templateEl.content.children.length !== 1) {
132
132
  throw new Error('[Lume.js] repeat(): template must contain exactly one root element');
133
133
  }
134
- return templateEl.content.firstElementChild;
134
+ return templateEl;
135
+ }
136
+
137
+ /**
138
+ * Resolve the template option to its clone root, plus the container child
139
+ * that is (or contains) the source <template>. Reconciliation and cleanup
140
+ * must leave that child alone — it's the user's markup, and removing it
141
+ * would break re-binding with `template: true` after cleanup. keepEl is
142
+ * null when there is no template or it lives outside the container.
143
+ */
144
+ function resolveTemplate(template, containerEl) {
145
+ if (!template) return { templateRoot: null, keepEl: null };
146
+ const templateEl = resolveTemplateEl(template, containerEl);
147
+ let keepEl = templateEl;
148
+ while (keepEl && keepEl.parentNode !== containerEl) keepEl = keepEl.parentNode;
149
+ return { templateRoot: templateEl.content.firstElementChild, keepEl };
135
150
  }
136
151
 
137
152
  /**
@@ -300,7 +315,7 @@ export function repeat(container, store, arrayKey, options) {
300
315
 
301
316
  // Template mode: structure and data binding come from the <template>;
302
317
  // render/create/update are all optional on top of it.
303
- const templateRoot = template ? resolveTemplateRoot(template, containerEl) : null;
318
+ const { templateRoot, keepEl } = resolveTemplate(template, containerEl);
304
319
 
305
320
  if (templateRoot && typeof render === 'function') {
306
321
  logWarn('[Lume.js] repeat(): options.render is ignored when options.template is set — use create/update instead');
@@ -329,10 +344,29 @@ export function repeat(container, store, arrayKey, options) {
329
344
  : document.createElement(element);
330
345
  }
331
346
 
347
+ /**
348
+ * Empty the container, preserving the in-container source template.
349
+ * Manual removal (not replaceChildren) for two reasons: replaceChildren
350
+ * is Chrome 86+/Safari 14+ — above the documented ES2020-era browser
351
+ * floor — and re-inserting keepEl would re-parent a template the user
352
+ * has since moved (or resurrect one they deleted).
353
+ */
354
+ function clearContainer() {
355
+ let node = containerEl.firstChild;
356
+ while (node) {
357
+ const next = node.nextSibling;
358
+ if (node !== keepEl) containerEl.removeChild(node);
359
+ node = next;
360
+ }
361
+ }
362
+
332
363
  function reconcileDOM(container, nextEls) {
333
364
  let ptr = container.firstChild;
334
365
 
335
366
  for (let i = 0; i < nextEls.length; i++) {
367
+ // Never treat the source template (or its wrapper) as a list row
368
+ if (keepEl && ptr === keepEl) ptr = ptr.nextSibling;
369
+
336
370
  const desired = nextEls[i];
337
371
 
338
372
  if (ptr === desired) {
@@ -343,10 +377,10 @@ export function repeat(container, store, arrayKey, options) {
343
377
  container.insertBefore(desired, ptr);
344
378
  }
345
379
 
346
- // Remove leftover children not in nextEls
380
+ // Remove leftover children not in nextEls (preserving the template)
347
381
  while (ptr) {
348
382
  const next = ptr.nextSibling;
349
- container.removeChild(ptr);
383
+ if (ptr !== keepEl) container.removeChild(ptr);
350
384
  ptr = next;
351
385
  }
352
386
  }
@@ -510,7 +544,7 @@ export function repeat(container, store, arrayKey, options) {
510
544
  remove(prevItem, el);
511
545
  }
512
546
  }
513
- containerEl.replaceChildren();
547
+ clearContainer();
514
548
  elementsByKey.clear();
515
549
  prevItemsByKey.clear();
516
550
  prevIndexByKey.clear();
@@ -539,8 +573,8 @@ export function repeat(container, store, arrayKey, options) {
539
573
  remove(prevItem, el);
540
574
  }
541
575
  }
542
- // Clear DOM elements (replaceChildren is faster than loop)
543
- containerEl.replaceChildren();
576
+ // Clear DOM elements (preserving the source template, if any)
577
+ clearContainer();
544
578
  elementsByKey.clear();
545
579
  prevItemsByKey.clear();
546
580
  prevIndexByKey.clear();
package/src/core/state.js CHANGED
@@ -121,7 +121,16 @@ export function state(obj) {
121
121
  }
122
122
 
123
123
  function notifySubscribers() {
124
- for (const [key, value] of pendingNotifications) {
124
+ // Drain BEFORE delivering. Iterating the live Map is wrong twice over:
125
+ // a subscriber writing back to an already-delivered key would update
126
+ // the in-flight entry (never revisited) and the trailing clear() would
127
+ // destroy it — a silently lost update; and a subscriber opening a
128
+ // batch() would re-deliver every in-flight entry through the wave's
129
+ // re-entrant call. Draining first means write-backs land in the now-
130
+ // empty live Map and are delivered by the next flush iteration/wave.
131
+ const entries = Array.from(pendingNotifications);
132
+ pendingNotifications.clear();
133
+ for (const [key, value] of entries) {
125
134
  if (listeners[key]) {
126
135
  const subs = listeners[key];
127
136
  let i = 0;
@@ -137,7 +146,6 @@ export function state(obj) {
137
146
  }
138
147
  }
139
148
  }
140
- pendingNotifications.clear();
141
149
  }
142
150
 
143
151
  /** Drain queued effects (Set deduplicates) into an array. */
@@ -6,7 +6,13 @@
6
6
  * @returns {{ attr: string, apply: function }}
7
7
  */
8
8
 
9
- const DANGEROUS_SCHEME = /^(javascript|vbscript|data\s*:\s*text\/html)/i;
9
+ // Matched against a normalized copy of the value (C0 controls, space, and
10
+ // DEL stripped; lowercased) because the browser URL parser ignores those
11
+ // characters when determining the scheme — "java\tscript:alert(1)" and
12
+ // " javascript:alert(1)" both execute. The colon is required so legitimate
13
+ // relative URLs like "javascript-tutorial.html" are not blocked.
14
+ const DANGEROUS_SCHEME = /^(?:javascript:|vbscript:|data:text\/html)/;
15
+ const IGNORED_URL_CHARS = /[\u0000-\u0020\u007F]/g;
10
16
  const URI_ATTRS = new Set(['href', 'src', 'action', 'srcset', 'poster', 'formaction']);
11
17
 
12
18
  export function stringAttr(name) {
@@ -18,7 +24,8 @@ export function stringAttr(name) {
18
24
  return;
19
25
  }
20
26
  const strVal = String(val);
21
- if (URI_ATTRS.has(name) && DANGEROUS_SCHEME.test(strVal)) {
27
+ if (URI_ATTRS.has(name) &&
28
+ DANGEROUS_SCHEME.test(strVal.replace(IGNORED_URL_CHARS, '').toLowerCase())) {
22
29
  el.removeAttribute(name);
23
30
  return;
24
31
  }