lume-js 2.1.0 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lume-js",
3
- "version": "2.1.0",
3
+ "version": "2.2.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",
@@ -28,15 +28,19 @@
28
28
  "dev": "vite",
29
29
  "dev:site": "node scripts/build-site-assets.js && vite -c vite.site.config.js",
30
30
  "build:site": "node scripts/build-site-assets.js && vite build -c vite.site.config.js",
31
- "preview:site": "node scripts/build-site-assets.js && vite build -c vite.site.config.js --base / && vite preview -c vite.site.config.js",
31
+ "preview:site": "node scripts/build-site-assets.js && vite build -c vite.site.config.js --base / && vite preview -c vite.site.config.js --base /",
32
32
  "build": "node scripts/build.js",
33
- "size": "node scripts/check-size.js",
33
+ "size": "npm run build && node scripts/check-size.js",
34
+ "size:ci": "node scripts/check-size.js",
35
+ "bench": "npm run build && node scripts/bench-core.js",
36
+ "complexity": "node scripts/complexity.js",
37
+ "lint": "eslint src/",
34
38
  "test": "vitest run",
35
39
  "test:watch": "vitest",
36
40
  "coverage": "vitest run --coverage",
37
41
  "typecheck": "tsc --noEmit",
38
- "validate": "npm run size && npm run typecheck && npm test",
39
- "prepublishOnly": "npm run build && npm run validate"
42
+ "validate": "npm run build && node scripts/check-size.js && node scripts/complexity.js && npm run lint && npm run typecheck && npm run coverage",
43
+ "prepublishOnly": "npm run validate"
40
44
  },
41
45
  "files": [
42
46
  "dist",
@@ -78,6 +82,8 @@
78
82
  "@tailwindcss/typography": "^0.5.19",
79
83
  "@vitest/coverage-v8": "^2.1.4",
80
84
  "autoprefixer": "^10.4.22",
85
+ "eslint": "^10.3.0",
86
+ "eslint-plugin-sonarjs": "^4.0.3",
81
87
  "highlight.js": "^11.11.1",
82
88
  "jsdom": "^25.0.1",
83
89
  "marked": "^17.0.1",
@@ -91,4 +97,4 @@
91
97
  "engines": {
92
98
  "node": ">=20.19.0"
93
99
  }
94
- }
100
+ }
@@ -33,6 +33,11 @@ import { logError } from '../utils/log.js';
33
33
  * mutates a state property it depends on, the flush triggered by that
34
34
  * mutation is skipped to prevent an infinite microtask loop.
35
35
  *
36
+ * @security After each computation, a re-entry guard stays active until the
37
+ * next microtask. If a dependency changes synchronously during this window,
38
+ * the computed will not recompute until a subsequent microtask. This is
39
+ * intentional — it prevents infinite loops from self-mutating computeds.
40
+ *
36
41
  * @param {function} fn - Function that computes the value
37
42
  * @returns {object} Object with .value property and methods
38
43
  *
@@ -2,8 +2,16 @@
2
2
  * Reads initial state from a `<script type="application/json">` element
3
3
  * embedded in the server-rendered HTML. Useful for SSR / hydration patterns.
4
4
  *
5
+ * @security Hydration trusts the DOM. An attacker who can inject HTML before
6
+ * the legitimate script (DOM clobbering) can control the parsed data. The
7
+ * element must be a real `<script type="application/json">` tag; non-script
8
+ * elements are rejected. Use the optional `validate` parameter to enforce a
9
+ * schema (e.g., whitelist allowed keys) before passing to `state()`.
10
+ *
5
11
  * @param {string} [selector='#__LUME_DATA__'] - CSS selector for the script element
6
- * @returns {object} Parsed JSON object, or empty object if not found / invalid
12
+ * @param {function} [validate] - Optional validator: (data) => boolean. If it
13
+ * returns false, hydrateState returns {} instead of the parsed data.
14
+ * @returns {object} Parsed JSON object, or empty object if not found / invalid / rejected
7
15
  *
8
16
  * @example
9
17
  * ```html
@@ -16,15 +24,35 @@
16
24
  * import { state } from 'lume-js';
17
25
  * import { hydrateState } from 'lume-js/addons';
18
26
  *
19
- * const store = state(hydrateState());
27
+ * // With optional schema validation
28
+ * const data = hydrateState('#__LUME_DATA__', d =>
29
+ * typeof d.title === 'string' && typeof d.count === 'number'
30
+ * );
31
+ * const store = state(data);
20
32
  * ```
21
33
  */
22
- export function hydrateState(selector = '#__LUME_DATA__') {
34
+ export function hydrateState(selector = '#__LUME_DATA__', validate) {
23
35
  const el = typeof document !== 'undefined' ? document.querySelector(selector) : null;
24
36
  if (!el) return {};
37
+
38
+ // Reject non-script elements or scripts without the correct type.
39
+ // This mitigates DOM clobbering where an attacker injects a matching
40
+ // element with a different tag name (e.g., a div or a script with
41
+ // a different type that would still match querySelector by id).
42
+ if (el.tagName !== 'SCRIPT' || el.type !== 'application/json') {
43
+ return {};
44
+ }
45
+
46
+ let data;
25
47
  try {
26
- return JSON.parse(el.textContent);
48
+ data = JSON.parse(el.textContent);
27
49
  } catch {
28
50
  return {};
29
51
  }
52
+
53
+ if (typeof validate === 'function' && !validate(data)) {
54
+ return {};
55
+ }
56
+
57
+ return data;
30
58
  }
@@ -56,26 +56,44 @@ export interface Computed<T> {
56
56
  */
57
57
  export function computed<T>(fn: () => T): Computed<T>;
58
58
 
59
+ export interface WatchOptions {
60
+ /**
61
+ * Whether to call the callback immediately with the current value (default: true).
62
+ * Set to false to skip the initial call and only react to future changes.
63
+ */
64
+ immediate?: boolean;
65
+ }
66
+
59
67
  /**
60
68
  * Watch a single key on a reactive state object; convenience wrapper around $subscribe.
61
- *
69
+ *
70
+ * By default the callback fires immediately with the current value, then on every change.
71
+ * Pass `{ immediate: false }` to skip the initial call and only react to future changes.
72
+ *
62
73
  * @param store - Reactive state object created with state().
63
74
  * @param key - Property key to observe.
64
- * @param callback - Invoked immediately and on subsequent changes.
75
+ * @param callback - Called with new value on change (and immediately unless immediate=false).
76
+ * @param options - Watch options
65
77
  * @returns Unsubscribe function for cleanup
66
78
  * @throws {Error} If store is not a reactive state object
67
- *
79
+ *
68
80
  * @example
69
81
  * ```typescript
70
82
  * import { state } from 'lume-js';
71
83
  * import { watch } from 'lume-js/addons';
72
- *
84
+ *
73
85
  * const store = state({ count: 0 });
74
- *
86
+ *
87
+ * // Fires immediately with 0, then on every change
75
88
  * const unwatch = watch(store, 'count', (value) => {
76
89
  * console.log('Count is now:', value);
77
90
  * });
78
- *
91
+ *
92
+ * // Only fires on future changes (not the initial value)
93
+ * watch(store, 'count', (value) => {
94
+ * console.log('Count changed to:', value);
95
+ * }, { immediate: false });
96
+ *
79
97
  * // Cleanup
80
98
  * unwatch();
81
99
  * ```
@@ -83,7 +101,8 @@ export function computed<T>(fn: () => T): Computed<T>;
83
101
  export function watch<T extends object, K extends keyof T>(
84
102
  store: ReactiveState<T>,
85
103
  key: K,
86
- callback: Subscriber<T[K]>
104
+ callback: Subscriber<T[K]>,
105
+ options?: WatchOptions
87
106
  ): Unsubscribe;
88
107
 
89
108
  /**
@@ -259,6 +259,7 @@ export function repeat(container, store, arrayKey, options) {
259
259
  if (restoreScroll) restoreScroll();
260
260
  }
261
261
 
262
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- keyed DOM reconciliation: create/reuse/remove nodes, key dedup, scroll/focus preservation
262
263
  function updateList() {
263
264
  const items = store[arrayKey];
264
265
 
@@ -332,6 +333,7 @@ export function repeat(container, store, arrayKey, options) {
332
333
  nextEls.push(el);
333
334
  }
334
335
 
336
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- DOM cleanup pass: remove stale nodes, call per-item cleanup callbacks, update maps
335
337
  applyPreservation(containerEl, () => {
336
338
  reconcileDOM(containerEl, nextEls);
337
339
 
@@ -3,11 +3,20 @@
3
3
  * @param {Object} store - reactive store created with state()
4
4
  * @param {string} key - key in store to watch
5
5
  * @param {Function} callback - called with new value
6
+ * @param {Object} [options]
7
+ * @param {boolean} [options.immediate=true] - call callback immediately with current value
6
8
  * @returns {Function} unsubscribe function
7
9
  */
8
- export function watch(store, key, callback) {
10
+ export function watch(store, key, callback, { immediate = true } = {}) {
9
11
  if (!store.$subscribe) {
10
12
  throw new Error("store must be created with state()");
11
13
  }
14
+ if (!immediate) {
15
+ let skipped = false;
16
+ return store.$subscribe(key, (val) => {
17
+ if (!skipped) { skipped = true; return; }
18
+ callback(val);
19
+ });
20
+ }
12
21
  return store.$subscribe(key, callback);
13
22
  }
@@ -24,6 +24,10 @@
24
24
  * onNotify(key, value) — called before subscribers are notified
25
25
  * onSubscribe(key) — called when $subscribe is invoked
26
26
  *
27
+ * @security Plugins run with full application privilege. A plugin can read
28
+ * all state, alter any write, or suppress mutations. Only pass trusted objects.
29
+ * Plugin objects are frozen after registration to prevent post-init mutation.
30
+ *
27
31
  * @param {object} store - A reactive proxy from state()
28
32
  * @param {Array<object>} plugins - Array of plugin objects
29
33
  * @returns {Proxy} A new proxy wrapping the store with plugin behavior
@@ -33,13 +37,15 @@ import { logError } from '../utils/log.js';
33
37
  export function withPlugins(store, plugins = []) {
34
38
  if (!plugins.length) return store;
35
39
 
36
- // Call onInit hooks once at wrap time
40
+ // Call onInit hooks once at wrap time, then freeze each plugin to prevent
41
+ // post-registration mutation of its hooks (defense-in-depth).
37
42
  for (const p of plugins) {
38
43
  try {
39
44
  p.onInit?.();
40
45
  } catch (e) {
41
46
  logError(`[Lume.js] Plugin "${p.name}" error in onInit:`, e);
42
47
  }
48
+ Object.freeze(p);
43
49
  }
44
50
 
45
51
  // Track pending notifications for onNotify hooks.
@@ -24,6 +24,11 @@
24
24
  * Usage:
25
25
  * import { bindDom } from "lume-js";
26
26
  * const cleanup = bindDom(document.body, store);
27
+ *
28
+ * @security `data-bind` attribute values are resolved once at bind time and
29
+ * trusted as state path expressions. If an attacker can inject `data-bind`
30
+ * attributes into the DOM, they can subscribe to any reachable reactive state.
31
+ * Ensure your HTML is trusted or sanitize it before calling bindDom().
27
32
  */
28
33
 
29
34
  import { logWarn } from '../utils/log.js';
@@ -58,6 +58,7 @@ let currentEffect = null;
58
58
  * analytics.log(store.count); // Won't track store.count automatically
59
59
  * }, [[store, 'count']]); // Explicit: only re-run on store.count
60
60
  */
61
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- handles both auto-tracking and explicit-deps modes with cleanup; splitting would require exporting internal state
61
62
  export function effect(fn, deps) {
62
63
  if (typeof fn !== 'function') {
63
64
  throw new Error('effect() requires a function');
package/src/core/state.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * unsub(); // cleanup
23
23
  */
24
24
 
25
- import { logError } from '../utils/log.js';
25
+ import { logError, logWarn } from '../utils/log.js';
26
26
 
27
27
  // Per-state batching – each state object maintains its own microtask flush.
28
28
  // This keeps effects simple and aligned with Lume's minimal philosophy.
@@ -55,6 +55,12 @@ const readers = new Set();
55
55
  *
56
56
  * Internal API — used by effect.js for auto-tracking. May be stabilized
57
57
  * for third-party addons in a future release.
58
+ *
59
+ * @security The observer sees reads from ALL state instances within the same
60
+ * module instance, including nested scopes. Only pass trusted observer functions.
61
+ * A future scoped variant (e.g., scopedReadObserver(store, fn)) may limit
62
+ * observation to a single state instance.
63
+ *
58
64
  * @param {function} onRead - Called on each property access inside fn
59
65
  * @param {function} fn - The function to run under observation
60
66
  */
@@ -100,6 +106,7 @@ export function state(obj) {
100
106
  if (flushScheduled) return;
101
107
 
102
108
  flushScheduled = true;
109
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- single-pass flush loop: hooks → subscribers → effects → cycle detection; must stay atomic
103
110
  queueMicrotask(() => {
104
111
  let iterations = 0;
105
112
  const MAX_ITERATIONS = 100;
@@ -190,6 +197,8 @@ export function state(obj) {
190
197
  };
191
198
  };
192
199
 
200
+ const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
201
+
193
202
  const proxy = new Proxy(obj, {
194
203
  get(target, key) {
195
204
  // Skip effect tracking for internal meta methods (e.g. $subscribe)
@@ -210,6 +219,11 @@ export function state(obj) {
210
219
  },
211
220
 
212
221
  set(target, key, value) {
222
+ if (typeof key === 'string' && BLOCKED_KEYS.has(key)) {
223
+ logWarn(`[Lume.js state] Blocked write to reserved key "${key}"`);
224
+ return true;
225
+ }
226
+
213
227
  const oldValue = target[key];
214
228
 
215
229
  // Skip update if value unchanged - Object.is() handles NaN and -0 correctly
@@ -255,12 +269,18 @@ export function state(obj) {
255
269
  };
256
270
  };
257
271
 
272
+ const MAX_SUBSCRIBERS = 1000;
273
+
258
274
  obj.$subscribe = (key, fn) => {
259
275
  if (typeof fn !== 'function') {
260
276
  throw new Error('Subscriber must be a function');
261
277
  }
262
278
 
263
279
  if (!listeners[key]) listeners[key] = [];
280
+ if (listeners[key].length >= MAX_SUBSCRIBERS) {
281
+ logWarn(`[Lume.js state] Subscriber limit (${MAX_SUBSCRIBERS}) reached for key "${key}". New subscriber ignored.`);
282
+ return () => {};
283
+ }
264
284
  listeners[key].push(fn);
265
285
 
266
286
  // Call immediately with current value (NOT batched)
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create a handler for an ARIA attribute.
3
+ * Coerces value to "true"/"false" string — use stringAttr("aria-X") for token/string ARIA attrs.
4
+ *
5
+ * @param {string} name - ARIA name, with or without "aria-" prefix
6
+ * @returns {{ attr: string, apply: function }}
7
+ */
8
+ export function ariaAttr(name) {
9
+ const fullName = name.startsWith('aria-') ? name : `aria-${name}`;
10
+ return {
11
+ attr: `data-${fullName}`,
12
+ apply(el, val) { el.setAttribute(fullName, val ? 'true' : 'false'); }
13
+ };
14
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create a handler for any HTML boolean attribute.
3
+ * Uses toggleAttribute() — works correctly with any attribute name
4
+ * (readonly, contenteditable, etc.) without worrying about camelCase property names.
5
+ *
6
+ * @param {string} name - Attribute name (e.g., 'readonly', 'open', 'contenteditable')
7
+ * @returns {{ attr: string, apply: function }}
8
+ */
9
+ export function boolAttr(name) {
10
+ return {
11
+ attr: `data-${name}`,
12
+ apply(el, val) { el.toggleAttribute(name, Boolean(val)); }
13
+ };
14
+ }
@@ -0,0 +1,5 @@
1
+ /** data-classname="key" → el.className = val || '' */
2
+ export const className = {
3
+ attr: 'data-classname',
4
+ apply(el, val) { el.className = val || ''; }
5
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Create handlers for CSS class toggling.
3
+ * Each name creates a handler: data-class-{name}="key" → el.classList.toggle(name, Boolean(val))
4
+ * Returns an array — pass directly to handlers (auto-flattened by bindDom).
5
+ *
6
+ * @param {...string} names - CSS class names to create handlers for
7
+ * @returns {Array<{ attr: string, apply: function }>}
8
+ */
9
+ export function classToggle(...names) {
10
+ return names.map(name => ({
11
+ attr: `data-class-${name}`,
12
+ apply(el, val) { el.classList.toggle(name, Boolean(val)); }
13
+ }));
14
+ }
@@ -0,0 +1,57 @@
1
+ import { show } from './show.js';
2
+ import { boolAttr } from './boolAttr.js';
3
+ import { ariaAttr } from './ariaAttr.js';
4
+ import { stringAttr } from './stringAttr.js';
5
+
6
+ /** @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes */
7
+ const BOOL_ATTRS = [
8
+ 'readonly', 'open', 'novalidate', 'formnovalidate', 'multiple',
9
+ 'autofocus', 'autoplay', 'controls', 'loop', 'muted', 'defer',
10
+ 'async', 'reversed', 'selected', 'inert', 'allowfullscreen',
11
+ ];
12
+
13
+ /** @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes */
14
+ const STRING_ATTRS = [
15
+ 'href', 'src', 'alt', 'title', 'placeholder', 'action', 'method',
16
+ 'target', 'rel', 'type', 'name', 'role', 'lang', 'tabindex',
17
+ 'pattern', 'min', 'max', 'step', 'minlength', 'maxlength',
18
+ 'width', 'height', 'for', 'form', 'accept', 'autocomplete',
19
+ 'loading', 'decoding', 'inputmode', 'enterkeyhint', 'draggable',
20
+ 'contenteditable', 'spellcheck', 'translate', 'dir', 'id',
21
+ 'poster', 'preload', 'download', 'media', 'sizes', 'srcset',
22
+ 'colspan', 'rowspan', 'scope', 'headers', 'wrap', 'sandbox',
23
+ ];
24
+
25
+ /** ARIA boolean state attributes — coerced to "true"/"false" string. */
26
+ const ARIA_BOOL_ATTRS = [
27
+ 'pressed', 'selected', 'disabled', 'checked', 'invalid', 'required',
28
+ 'busy', 'modal', 'multiselectable', 'multiline', 'readonly', 'atomic',
29
+ ];
30
+
31
+ /** ARIA string/token/numeric attributes — value passed through as-is. */
32
+ const ARIA_STRING_ATTRS = [
33
+ 'current', 'live', 'relevant', 'haspopup',
34
+ 'sort', 'autocomplete', 'orientation',
35
+ 'label', 'describedby', 'labelledby', 'controls', 'owns',
36
+ 'activedescendant', 'errormessage', 'details', 'flowto',
37
+ 'valuenow', 'valuemin', 'valuemax', 'valuetext',
38
+ 'colcount', 'colindex', 'colspan', 'rowcount', 'rowindex', 'rowspan',
39
+ 'level', 'setsize', 'posinset', 'placeholder', 'roledescription',
40
+ 'keyshortcuts', 'braillelabel', 'brailleroledescription',
41
+ ];
42
+
43
+ /**
44
+ * One-import preset that enables all standard HTML attributes as reactive handlers.
45
+ * Returns a flat array — pass directly to handlers option.
46
+ *
47
+ * @returns {Array<{ attr: string, apply: function }>}
48
+ */
49
+ export function htmlAttrs() {
50
+ return [
51
+ show,
52
+ ...BOOL_ATTRS.map(name => boolAttr(name)),
53
+ ...STRING_ATTRS.map(name => stringAttr(name)),
54
+ ...ARIA_BOOL_ATTRS.map(name => ariaAttr(name)),
55
+ ...ARIA_STRING_ATTRS.map(name => stringAttr(`aria-${name}`)),
56
+ ];
57
+ }
@@ -14,6 +14,19 @@ import type { Handler } from '../index.js';
14
14
  */
15
15
  export const show: Handler;
16
16
 
17
+ /**
18
+ * data-classname="key" → el.className = val (replaces full class string)
19
+ * Use classToggle() for toggling individual classes.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * bindDom(root, store, { handlers: [className] });
24
+ * // <div data-classname="cardClass">...</div>
25
+ * // store.cardClass = 'card card--error card--large';
26
+ * ```
27
+ */
28
+ export const className: Handler;
29
+
17
30
  /**
18
31
  * Create a handler for any HTML boolean property.
19
32
  * Use for properties beyond the built-in hidden/disabled/checked/required.