@livenetworks/ashlar 2.0.2 → 2.0.3

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.
@@ -1,273 +1,273 @@
1
- // ─── Typed Live Attribute Readers ──────────────────────────
2
- //
3
- // Scanner constraint: attribute names passed into these readers, and into a
4
- // `defineAttrs` spec, must always be inline string literals or a `const`
5
- // bound to a literal — never concatenated or templated.
6
- // `scripts/sync-ln-schemas.mjs:22-23` is a raw regex over file text; a
7
- // constructed name is invisible to it and the CI docs gate breaks silently
8
- // (this already happened once — commit `3495690`).
9
-
10
- export function attrStr(el, name, fallback) {
11
- const raw = el.getAttribute(name);
12
- return raw === null ? fallback : raw;
13
- }
14
-
15
- // `0` stays `0` — unlike the `parseInt(...) || 1000` idiom this replaces.
16
- export function attrInt(el, name, fallback) {
17
- const parsed = parseInt(el.getAttribute(name), 10);
18
- return isNaN(parsed) ? fallback : parsed;
19
- }
20
-
21
- export function attrBool(el, name, fallback = false) {
22
- const raw = el.getAttribute(name);
23
- if (raw === null) return !!fallback;
24
- const val = raw.trim().toLowerCase();
25
- if (val === 'false' || val === '0') return false;
26
- return true;
27
- }
28
-
29
- // Allocates a new array per read — do not call inside a per-row loop.
30
- export function attrList(el, name) {
31
- return (el.getAttribute(name) || '').split(',').map(s => s.trim()).filter(Boolean);
32
- }
33
-
34
- const _enumReaderCache = new Map();
35
-
36
- /**
37
- * Cached reader factory for enum attributes.
38
- * @param {string[]} values - allowed values
39
- * @param {*} fallback - default value
40
- * @returns {function(Element, string): *}
41
- */
42
- export function attrEnum(values, fallback) {
43
- const key = (values ? values.join('|') : '') + '::' + fallback;
44
- if (_enumReaderCache.has(key)) return _enumReaderCache.get(key);
45
- const valSet = new Set(values || []);
46
- const reader = function (el, name) {
47
- const raw = el.getAttribute(name);
48
- return (raw !== null && valSet.has(raw)) ? raw : fallback;
49
- };
50
- _enumReaderCache.set(key, reader);
51
- return reader;
52
- }
53
-
54
- export function attrFloat(el, name, fallback) {
55
- const parsed = parseFloat(el.getAttribute(name));
56
- return isNaN(parsed) ? fallback : parsed;
57
- }
58
-
59
- export function attrJson(el, name, fallback) {
60
- const raw = el.getAttribute(name);
61
- if (!raw) return fallback;
62
- try {
63
- return JSON.parse(raw);
64
- } catch (_) {
65
- return fallback;
66
- }
67
- }
68
-
69
- /**
70
- * Validates a DOM attribute value against its declared schema entry.
71
- * Logs a console.warn (with [componentTag] prefix) if the value is invalid.
72
- *
73
- * @param {Object} entry - { type, values, min, max, fallback, ... }
74
- * @param {string|null} value - raw DOM attribute value (null if absent)
75
- * @param {string} attrName - attribute name (e.g. 'data-ln-modal')
76
- * @param {string} componentTag - component tag (e.g. 'ln-modal')
77
- * @param {Element} [el] - optional DOM element carrying the attribute
78
- * @returns {boolean} true if valid, false if invalid
79
- */
80
- export function validateAttrValue(entry, value, attrName, componentTag, el) {
81
- if (!entry || value === null) return true;
82
- const tag = componentTag || 'ln-component';
83
- const type = entry.type;
84
-
85
- // valueless / unconstrained types
86
- if (type === 'trigger' || type === 'marker' || type === 'string' || type === 'list' || !type) {
87
- return true;
88
- }
89
-
90
- // Bare attributes (presence-only in HTML) with a declared fallback resolve to fallback in readers
91
- if (value === '' && entry.fallback !== undefined) {
92
- return true;
93
- }
94
-
95
- const logWarn = (msg) => {
96
- if (el) console.warn(msg, el);
97
- else console.warn(msg);
98
- };
99
-
100
- if (type === 'boolean') {
101
- const lower = value.trim().toLowerCase();
102
- if (lower !== '' && lower !== 'true' && lower !== 'false' && lower !== '1' && lower !== '0') {
103
- logWarn(`[${tag}] Invalid value "${value}" for boolean attribute "${attrName}". Allowed: "true", "false", or presence-only.`);
104
- return false;
105
- }
106
- return true;
107
- }
108
-
109
- if (type === 'enum') {
110
- const allowed = entry.values || [];
111
- if (!allowed.includes(value)) {
112
- logWarn(`[${tag}] Invalid value "${value}" for attribute "${attrName}". Allowed: ${allowed.join(', ')}. Fallback: "${entry.fallback}".`);
113
- return false;
114
- }
115
- return true;
116
- }
117
-
118
- if (type === 'integer') {
119
- if (!/^-?\d+$/.test(value)) {
120
- logWarn(`[${tag}] Invalid integer "${value}" for attribute "${attrName}". Fallback: ${entry.fallback}.`);
121
- return false;
122
- }
123
- const num = parseInt(value, 10);
124
- if (entry.min !== undefined && num < entry.min) {
125
- logWarn(`[${tag}] Value ${num} for attribute "${attrName}" is less than min (${entry.min}).`);
126
- return false;
127
- }
128
- if (entry.max !== undefined && num > entry.max) {
129
- logWarn(`[${tag}] Value ${num} for attribute "${attrName}" is greater than max (${entry.max}).`);
130
- return false;
131
- }
132
- return true;
133
- }
134
-
135
- if (type === 'float') {
136
- if (isNaN(Number(value))) {
137
- logWarn(`[${tag}] Invalid float "${value}" for attribute "${attrName}". Fallback: ${entry.fallback}.`);
138
- return false;
139
- }
140
- return true;
141
- }
142
-
143
- if (type === 'json') {
144
- try {
145
- JSON.parse(value);
146
- return true;
147
- } catch (err) {
148
- logWarn(`[${tag}] Invalid JSON for attribute "${attrName}": ${err.message}. Fallback: ${entry.fallback}.`);
149
- return false;
150
- }
151
- }
152
-
153
- return true;
154
- }
155
-
156
- /**
157
- * Define live getter-only properties on `instance`, each reading `dom` on
158
- * every access. Nothing is copied into instance state, so there is no state
159
- * to invalidate and no sync step.
160
- *
161
- * @param {Object} instance
162
- * @param {Element} dom
163
- * @param {Object} spec - { propName: [reader, attributeName, fallback] }
164
- * @returns {Object} instance
165
- */
166
- export function defineAttrs(instance, dom, spec) {
167
- for (const propName in spec) {
168
- const [reader, attributeName, fallback] = spec[propName];
169
- Object.defineProperty(instance, propName, {
170
- // Getter only, no setter — assignment throws in strict mode (ES
171
- // modules are strict). Deliberate: it forbids a drifting copy.
172
- get: function () {
173
- return reader(dom, attributeName, fallback);
174
- },
175
- enumerable: true,
176
- configurable: true
177
- });
178
- }
179
- return instance;
180
- }
181
-
182
- /**
183
- * One table per component is the single source of truth for its HOST
184
- * attributes. Derivations below read it; nothing else declares an
185
- * attribute name.
186
- *
187
- * Table entry shape:
188
- * 'data-ln-x': { prop, read, type, values, fallback, min, max, effect, description } — any subset
189
- *
190
- * The scanner constraint at the top of this file applies to table KEYS:
191
- * literal strings only. A computed key is invisible to
192
- * scripts/sync-ln-schemas.mjs.
193
- */
194
- export function attrSpec(table) {
195
- const spec = {};
196
- for (const name in table) {
197
- const entry = table[name];
198
- if (!entry || !entry.prop) continue;
199
- let reader = entry.read;
200
- if (!reader && entry.type) {
201
- switch (entry.type) {
202
- case 'enum':
203
- reader = attrEnum(entry.values, entry.fallback);
204
- break;
205
- case 'integer':
206
- reader = attrInt;
207
- break;
208
- case 'float':
209
- reader = attrFloat;
210
- break;
211
- case 'boolean':
212
- reader = attrBool;
213
- break;
214
- case 'list':
215
- reader = attrList;
216
- break;
217
- case 'json':
218
- reader = attrJson;
219
- break;
220
- case 'string':
221
- default:
222
- reader = attrStr;
223
- break;
224
- }
225
- }
226
- if (!reader) reader = attrStr;
227
- spec[entry.prop] = [reader, name, entry.fallback];
228
- }
229
- return spec;
230
- }
231
-
232
- export function attrEffects(table) {
233
- const effects = {};
234
- for (const name in table) {
235
- const entry = table[name];
236
- if (!entry) continue;
237
- if (entry.effect) effects[name] = entry.effect;
238
- }
239
- // null, not {} — `_registerAttrEntry` tests `entry.onAttrChange || entry.effects`,
240
- // and an empty object is truthy. A table of pure getters would otherwise be
241
- // pushed into `registry.reactive` and iterated on every data-ln-* mutation
242
- // on the page while having nothing to run.
243
- return Object.keys(effects).length ? effects : null;
244
- }
245
-
246
- /**
247
- * The attribute names a registry entry should be indexed under, so the shared
248
- * observer can reach it by name instead of walking every reactive entry.
249
- *
250
- * Note this reads `entry.effects` — the DERIVED table produced by
251
- * `attrEffects` above — not the raw component table. An entry is indexed
252
- * under every name it declares, which for a component like `ln-list` means
253
- * the suffixed names (`data-ln-list-window`) and not only the selector.
254
- *
255
- * Internal to ln-core, and deliberately NOT re-exported from index.js: unlike
256
- * `attrSpec` / `attrEffects`, which take the attribute table a component author
257
- * writes, this takes a registry entry — a shape no author ever constructs.
258
- * Publishing it would make the entry shape semver-load-bearing for no caller.
259
- *
260
- * @param {Object} entry - a registry entry: { effects, onAttrChange, declared }
261
- * @returns {Set<string>|null} names to index under, or null for a wildcard —
262
- * `onAttrChange` without `declared` asks for EVERY data-ln-* mutation and
263
- * by definition cannot be keyed by name.
264
- */
265
- export function reactiveNames(entry) {
266
- if (entry.onAttrChange && !entry.declared) return null;
267
-
268
- const names = new Set();
269
- if (entry.effects) for (const n in entry.effects) names.add(n);
270
- if (entry.onAttrChange && entry.declared) for (const n of entry.declared) names.add(n);
271
- return names;
272
- }
273
-
1
+ // ─── Typed Live Attribute Readers ──────────────────────────
2
+ //
3
+ // Scanner constraint: attribute names passed into these readers, and into a
4
+ // `defineAttrs` spec, must always be inline string literals or a `const`
5
+ // bound to a literal — never concatenated or templated.
6
+ // `scripts/sync-ln-schemas.mjs:22-23` is a raw regex over file text; a
7
+ // constructed name is invisible to it and the CI docs gate breaks silently
8
+ // (this already happened once — commit `3495690`).
9
+
10
+ export function attrStr(el, name, fallback) {
11
+ const raw = el.getAttribute(name);
12
+ return raw === null ? fallback : raw;
13
+ }
14
+
15
+ // `0` stays `0` — unlike the `parseInt(...) || 1000` idiom this replaces.
16
+ export function attrInt(el, name, fallback) {
17
+ const parsed = parseInt(el.getAttribute(name), 10);
18
+ return isNaN(parsed) ? fallback : parsed;
19
+ }
20
+
21
+ export function attrBool(el, name, fallback = false) {
22
+ const raw = el.getAttribute(name);
23
+ if (raw === null) return !!fallback;
24
+ const val = raw.trim().toLowerCase();
25
+ if (val === 'false' || val === '0') return false;
26
+ return true;
27
+ }
28
+
29
+ // Allocates a new array per read — do not call inside a per-row loop.
30
+ export function attrList(el, name) {
31
+ return (el.getAttribute(name) || '').split(',').map(s => s.trim()).filter(Boolean);
32
+ }
33
+
34
+ const _enumReaderCache = new Map();
35
+
36
+ /**
37
+ * Cached reader factory for enum attributes.
38
+ * @param {string[]} values - allowed values
39
+ * @param {*} fallback - default value
40
+ * @returns {function(Element, string): *}
41
+ */
42
+ export function attrEnum(values, fallback) {
43
+ const key = (values ? values.join('|') : '') + '::' + fallback;
44
+ if (_enumReaderCache.has(key)) return _enumReaderCache.get(key);
45
+ const valSet = new Set(values || []);
46
+ const reader = function (el, name) {
47
+ const raw = el.getAttribute(name);
48
+ return (raw !== null && valSet.has(raw)) ? raw : fallback;
49
+ };
50
+ _enumReaderCache.set(key, reader);
51
+ return reader;
52
+ }
53
+
54
+ export function attrFloat(el, name, fallback) {
55
+ const parsed = parseFloat(el.getAttribute(name));
56
+ return isNaN(parsed) ? fallback : parsed;
57
+ }
58
+
59
+ export function attrJson(el, name, fallback) {
60
+ const raw = el.getAttribute(name);
61
+ if (!raw) return fallback;
62
+ try {
63
+ return JSON.parse(raw);
64
+ } catch (_) {
65
+ return fallback;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Validates a DOM attribute value against its declared schema entry.
71
+ * Logs a console.warn (with [componentTag] prefix) if the value is invalid.
72
+ *
73
+ * @param {Object} entry - { type, values, min, max, fallback, ... }
74
+ * @param {string|null} value - raw DOM attribute value (null if absent)
75
+ * @param {string} attrName - attribute name (e.g. 'data-ln-modal')
76
+ * @param {string} componentTag - component tag (e.g. 'ln-modal')
77
+ * @param {Element} [el] - optional DOM element carrying the attribute
78
+ * @returns {boolean} true if valid, false if invalid
79
+ */
80
+ export function validateAttrValue(entry, value, attrName, componentTag, el) {
81
+ if (!entry || value === null) return true;
82
+ const tag = componentTag || 'ln-component';
83
+ const type = entry.type;
84
+
85
+ // valueless / unconstrained types
86
+ if (type === 'trigger' || type === 'marker' || type === 'string' || type === 'list' || !type) {
87
+ return true;
88
+ }
89
+
90
+ // Bare attributes (presence-only in HTML) with a declared fallback resolve to fallback in readers
91
+ if (value === '' && entry.fallback !== undefined) {
92
+ return true;
93
+ }
94
+
95
+ const logWarn = (msg) => {
96
+ if (el) console.warn(msg, el);
97
+ else console.warn(msg);
98
+ };
99
+
100
+ if (type === 'boolean') {
101
+ const lower = value.trim().toLowerCase();
102
+ if (lower !== '' && lower !== 'true' && lower !== 'false' && lower !== '1' && lower !== '0') {
103
+ logWarn(`[${tag}] Invalid value "${value}" for boolean attribute "${attrName}". Allowed: "true", "false", or presence-only.`);
104
+ return false;
105
+ }
106
+ return true;
107
+ }
108
+
109
+ if (type === 'enum') {
110
+ const allowed = entry.values || [];
111
+ if (!allowed.includes(value)) {
112
+ logWarn(`[${tag}] Invalid value "${value}" for attribute "${attrName}". Allowed: ${allowed.join(', ')}. Fallback: "${entry.fallback}".`);
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+
118
+ if (type === 'integer') {
119
+ if (!/^-?\d+$/.test(value)) {
120
+ logWarn(`[${tag}] Invalid integer "${value}" for attribute "${attrName}". Fallback: ${entry.fallback}.`);
121
+ return false;
122
+ }
123
+ const num = parseInt(value, 10);
124
+ if (entry.min !== undefined && num < entry.min) {
125
+ logWarn(`[${tag}] Value ${num} for attribute "${attrName}" is less than min (${entry.min}).`);
126
+ return false;
127
+ }
128
+ if (entry.max !== undefined && num > entry.max) {
129
+ logWarn(`[${tag}] Value ${num} for attribute "${attrName}" is greater than max (${entry.max}).`);
130
+ return false;
131
+ }
132
+ return true;
133
+ }
134
+
135
+ if (type === 'float') {
136
+ if (isNaN(Number(value))) {
137
+ logWarn(`[${tag}] Invalid float "${value}" for attribute "${attrName}". Fallback: ${entry.fallback}.`);
138
+ return false;
139
+ }
140
+ return true;
141
+ }
142
+
143
+ if (type === 'json') {
144
+ try {
145
+ JSON.parse(value);
146
+ return true;
147
+ } catch (err) {
148
+ logWarn(`[${tag}] Invalid JSON for attribute "${attrName}": ${err.message}. Fallback: ${entry.fallback}.`);
149
+ return false;
150
+ }
151
+ }
152
+
153
+ return true;
154
+ }
155
+
156
+ /**
157
+ * Define live getter-only properties on `instance`, each reading `dom` on
158
+ * every access. Nothing is copied into instance state, so there is no state
159
+ * to invalidate and no sync step.
160
+ *
161
+ * @param {Object} instance
162
+ * @param {Element} dom
163
+ * @param {Object} spec - { propName: [reader, attributeName, fallback] }
164
+ * @returns {Object} instance
165
+ */
166
+ export function defineAttrs(instance, dom, spec) {
167
+ for (const propName in spec) {
168
+ const [reader, attributeName, fallback] = spec[propName];
169
+ Object.defineProperty(instance, propName, {
170
+ // Getter only, no setter — assignment throws in strict mode (ES
171
+ // modules are strict). Deliberate: it forbids a drifting copy.
172
+ get: function () {
173
+ return reader(dom, attributeName, fallback);
174
+ },
175
+ enumerable: true,
176
+ configurable: true
177
+ });
178
+ }
179
+ return instance;
180
+ }
181
+
182
+ /**
183
+ * One table per component is the single source of truth for its HOST
184
+ * attributes. Derivations below read it; nothing else declares an
185
+ * attribute name.
186
+ *
187
+ * Table entry shape:
188
+ * 'data-ln-x': { prop, read, type, values, fallback, min, max, effect, description } — any subset
189
+ *
190
+ * The scanner constraint at the top of this file applies to table KEYS:
191
+ * literal strings only. A computed key is invisible to
192
+ * scripts/sync-ln-schemas.mjs.
193
+ */
194
+ export function attrSpec(table) {
195
+ const spec = {};
196
+ for (const name in table) {
197
+ const entry = table[name];
198
+ if (!entry || !entry.prop) continue;
199
+ let reader = entry.read;
200
+ if (!reader && entry.type) {
201
+ switch (entry.type) {
202
+ case 'enum':
203
+ reader = attrEnum(entry.values, entry.fallback);
204
+ break;
205
+ case 'integer':
206
+ reader = attrInt;
207
+ break;
208
+ case 'float':
209
+ reader = attrFloat;
210
+ break;
211
+ case 'boolean':
212
+ reader = attrBool;
213
+ break;
214
+ case 'list':
215
+ reader = attrList;
216
+ break;
217
+ case 'json':
218
+ reader = attrJson;
219
+ break;
220
+ case 'string':
221
+ default:
222
+ reader = attrStr;
223
+ break;
224
+ }
225
+ }
226
+ if (!reader) reader = attrStr;
227
+ spec[entry.prop] = [reader, name, entry.fallback];
228
+ }
229
+ return spec;
230
+ }
231
+
232
+ export function attrEffects(table) {
233
+ const effects = {};
234
+ for (const name in table) {
235
+ const entry = table[name];
236
+ if (!entry) continue;
237
+ if (entry.effect) effects[name] = entry.effect;
238
+ }
239
+ // null, not {} — `_registerAttrEntry` tests `entry.onAttrChange || entry.effects`,
240
+ // and an empty object is truthy. A table of pure getters would otherwise be
241
+ // pushed into `registry.reactive` and iterated on every data-ln-* mutation
242
+ // on the page while having nothing to run.
243
+ return Object.keys(effects).length ? effects : null;
244
+ }
245
+
246
+ /**
247
+ * The attribute names a registry entry should be indexed under, so the shared
248
+ * observer can reach it by name instead of walking every reactive entry.
249
+ *
250
+ * Note this reads `entry.effects` — the DERIVED table produced by
251
+ * `attrEffects` above — not the raw component table. An entry is indexed
252
+ * under every name it declares, which for a component like `ln-list` means
253
+ * the suffixed names (`data-ln-list-window`) and not only the selector.
254
+ *
255
+ * Internal to ln-core, and deliberately NOT re-exported from index.js: unlike
256
+ * `attrSpec` / `attrEffects`, which take the attribute table a component author
257
+ * writes, this takes a registry entry — a shape no author ever constructs.
258
+ * Publishing it would make the entry shape semver-load-bearing for no caller.
259
+ *
260
+ * @param {Object} entry - a registry entry: { effects, onAttrChange, declared }
261
+ * @returns {Set<string>|null} names to index under, or null for a wildcard —
262
+ * `onAttrChange` without `declared` asks for EVERY data-ln-* mutation and
263
+ * by definition cannot be keyed by name.
264
+ */
265
+ export function reactiveNames(entry) {
266
+ if (entry.onAttrChange && !entry.declared) return null;
267
+
268
+ const names = new Set();
269
+ if (entry.effects) for (const n in entry.effects) names.add(n);
270
+ if (entry.onAttrChange && entry.declared) for (const n of entry.declared) names.add(n);
271
+ return names;
272
+ }
273
+