pulse-js-framework 1.7.9 → 1.7.10
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/cli/lint.js +442 -3
- package/compiler/lexer.js +6 -0
- package/compiler/parser.js +144 -1
- package/compiler/transformer/imports.js +15 -0
- package/compiler/transformer/index.js +46 -0
- package/compiler/transformer/view.js +180 -5
- package/package.json +9 -2
- package/runtime/a11y.js +1005 -0
- package/runtime/devtools/a11y-audit.js +442 -0
- package/runtime/devtools/diagnostics.js +403 -0
- package/runtime/devtools/index.js +53 -0
- package/runtime/devtools/time-travel.js +189 -0
- package/runtime/devtools.js +138 -497
- package/runtime/dom-binding.js +7 -4
- package/runtime/dom-element.js +192 -1
- package/runtime/dom.js +8 -2
- package/runtime/index.js +2 -0
- package/runtime/native.js +2 -2
- package/runtime/security.js +461 -0
- package/runtime/utils.js +37 -16
- package/types/a11y.d.ts +336 -0
package/runtime/dom-binding.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
import { effect, onCleanup } from './pulse.js';
|
|
9
9
|
import { sanitizeUrl, safeSetStyle } from './utils.js';
|
|
10
10
|
import { getAdapter } from './dom-adapter.js';
|
|
11
|
+
import { loggers } from './logger.js';
|
|
12
|
+
|
|
13
|
+
const log = loggers.dom;
|
|
11
14
|
|
|
12
15
|
// =============================================================================
|
|
13
16
|
// URL ATTRIBUTES (XSS Protection)
|
|
@@ -53,8 +56,8 @@ export function bind(element, attr, getValue) {
|
|
|
53
56
|
if (isUrlAttr) {
|
|
54
57
|
const sanitized = sanitizeUrl(String(value));
|
|
55
58
|
if (sanitized === null) {
|
|
56
|
-
|
|
57
|
-
`[
|
|
59
|
+
log.warn(
|
|
60
|
+
`[Security] Dangerous URL blocked in bind() for ${attr}: "${String(value).slice(0, 50)}"`
|
|
58
61
|
);
|
|
59
62
|
dom.removeAttribute(element, attr);
|
|
60
63
|
return;
|
|
@@ -70,8 +73,8 @@ export function bind(element, attr, getValue) {
|
|
|
70
73
|
if (isUrlAttr) {
|
|
71
74
|
const sanitized = sanitizeUrl(String(getValue));
|
|
72
75
|
if (sanitized === null) {
|
|
73
|
-
|
|
74
|
-
`[
|
|
76
|
+
log.warn(
|
|
77
|
+
`[Security] Dangerous URL blocked in bind() for ${attr}: "${String(getValue).slice(0, 50)}"`
|
|
75
78
|
);
|
|
76
79
|
return element;
|
|
77
80
|
}
|
package/runtime/dom-element.js
CHANGED
|
@@ -17,6 +17,193 @@ const log = loggers.dom;
|
|
|
17
17
|
// ELEMENT CREATION
|
|
18
18
|
// =============================================================================
|
|
19
19
|
|
|
20
|
+
// A11y configuration
|
|
21
|
+
let a11yConfig = {
|
|
22
|
+
enabled: true,
|
|
23
|
+
autoAria: true,
|
|
24
|
+
warnMissingAlt: true,
|
|
25
|
+
warnMissingLabel: true
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Configure accessibility features
|
|
30
|
+
* @param {object} config - A11y configuration
|
|
31
|
+
*/
|
|
32
|
+
export function configureA11y(config) {
|
|
33
|
+
a11yConfig = { ...a11yConfig, ...config };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Apply automatic ARIA attributes based on element type
|
|
38
|
+
* @private
|
|
39
|
+
*/
|
|
40
|
+
function applyAutoAria(element, tag, attrs, dom) {
|
|
41
|
+
if (!a11yConfig.enabled || !a11yConfig.autoAria) return;
|
|
42
|
+
|
|
43
|
+
const hasAttr = (name) => attrs[name] !== undefined || dom.getAttribute?.(element, name);
|
|
44
|
+
|
|
45
|
+
switch (tag) {
|
|
46
|
+
case 'dialog':
|
|
47
|
+
// Dialogs should have role and modal indication
|
|
48
|
+
if (!hasAttr('role')) {
|
|
49
|
+
safeSetAttribute(element, 'role', 'dialog', {}, dom);
|
|
50
|
+
}
|
|
51
|
+
if (!hasAttr('aria-modal')) {
|
|
52
|
+
safeSetAttribute(element, 'aria-modal', 'true', {}, dom);
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
|
|
56
|
+
case 'nav':
|
|
57
|
+
// Navigation landmarks benefit from labels
|
|
58
|
+
if (!hasAttr('aria-label') && !hasAttr('aria-labelledby') && a11yConfig.warnMissingLabel) {
|
|
59
|
+
log.warn('A11y: <nav> element should have aria-label or aria-labelledby for accessibility');
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
|
|
63
|
+
case 'main':
|
|
64
|
+
case 'header':
|
|
65
|
+
case 'footer':
|
|
66
|
+
case 'aside':
|
|
67
|
+
// Landmark roles - warn if multiple without labels
|
|
68
|
+
if (!hasAttr('aria-label') && !hasAttr('aria-labelledby')) {
|
|
69
|
+
// These are valid as landmarks, just note for multiple instances
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
|
|
73
|
+
case 'img':
|
|
74
|
+
// Images must have alt
|
|
75
|
+
if (!hasAttr('alt') && !hasAttr('aria-label') && !hasAttr('aria-hidden') && a11yConfig.warnMissingAlt) {
|
|
76
|
+
log.warn('A11y: <img> element missing alt attribute. Add alt="" for decorative images.');
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
79
|
+
|
|
80
|
+
case 'input':
|
|
81
|
+
case 'textarea':
|
|
82
|
+
case 'select':
|
|
83
|
+
// Form controls need labels
|
|
84
|
+
if (!hasAttr('aria-label') && !hasAttr('aria-labelledby') && !hasAttr('id') && a11yConfig.warnMissingLabel) {
|
|
85
|
+
const inputType = attrs.type || 'text';
|
|
86
|
+
if (!['hidden', 'submit', 'button', 'reset', 'image'].includes(inputType)) {
|
|
87
|
+
log.warn(`A11y: <${tag}> element should have aria-label, aria-labelledby, or an associated <label>`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
|
|
92
|
+
case 'button':
|
|
93
|
+
// Buttons are already focusable, ensure type if not specified
|
|
94
|
+
if (!hasAttr('type')) {
|
|
95
|
+
safeSetAttribute(element, 'type', 'button', {}, dom);
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
case 'a':
|
|
100
|
+
// Links without href should have role="button" if interactive
|
|
101
|
+
if (!hasAttr('href') && !hasAttr('role')) {
|
|
102
|
+
safeSetAttribute(element, 'role', 'button', {}, dom);
|
|
103
|
+
if (!hasAttr('tabindex')) {
|
|
104
|
+
safeSetAttribute(element, 'tabindex', '0', {}, dom);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
|
|
109
|
+
case 'ul':
|
|
110
|
+
case 'ol':
|
|
111
|
+
// Lists with role="menu" or "listbox" need special handling
|
|
112
|
+
if (attrs.role === 'menu' || attrs.role === 'listbox') {
|
|
113
|
+
if (!hasAttr('aria-label') && !hasAttr('aria-labelledby')) {
|
|
114
|
+
log.warn(`A11y: <${tag} role="${attrs.role}"> should have aria-label or aria-labelledby`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
|
|
119
|
+
case 'table':
|
|
120
|
+
// Tables benefit from captions or aria-label
|
|
121
|
+
if (!hasAttr('aria-label') && !hasAttr('aria-labelledby')) {
|
|
122
|
+
// Will be checked if no <caption> child is found later
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
|
|
126
|
+
case 'progress':
|
|
127
|
+
// Progress should have accessible name
|
|
128
|
+
if (!hasAttr('aria-label') && !hasAttr('aria-labelledby')) {
|
|
129
|
+
log.warn('A11y: <progress> element should have aria-label or aria-labelledby');
|
|
130
|
+
}
|
|
131
|
+
break;
|
|
132
|
+
|
|
133
|
+
case 'meter':
|
|
134
|
+
// Meter should have accessible name
|
|
135
|
+
if (!hasAttr('aria-label') && !hasAttr('aria-labelledby')) {
|
|
136
|
+
log.warn('A11y: <meter> element should have aria-label or aria-labelledby');
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Handle role-specific requirements
|
|
142
|
+
const role = attrs.role;
|
|
143
|
+
if (role) {
|
|
144
|
+
applyRoleRequirements(element, role, attrs, dom);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Apply ARIA requirements for specific roles
|
|
150
|
+
* @private
|
|
151
|
+
*/
|
|
152
|
+
function applyRoleRequirements(element, role, attrs, dom) {
|
|
153
|
+
const hasAttr = (name) => attrs[name] !== undefined;
|
|
154
|
+
|
|
155
|
+
switch (role) {
|
|
156
|
+
case 'checkbox':
|
|
157
|
+
case 'radio':
|
|
158
|
+
case 'switch':
|
|
159
|
+
if (!hasAttr('aria-checked')) {
|
|
160
|
+
safeSetAttribute(element, 'aria-checked', 'false', {}, dom);
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
|
|
164
|
+
case 'slider':
|
|
165
|
+
case 'spinbutton':
|
|
166
|
+
case 'progressbar':
|
|
167
|
+
if (!hasAttr('aria-valuenow')) {
|
|
168
|
+
safeSetAttribute(element, 'aria-valuenow', '0', {}, dom);
|
|
169
|
+
}
|
|
170
|
+
if (!hasAttr('aria-valuemin')) {
|
|
171
|
+
safeSetAttribute(element, 'aria-valuemin', '0', {}, dom);
|
|
172
|
+
}
|
|
173
|
+
if (!hasAttr('aria-valuemax')) {
|
|
174
|
+
safeSetAttribute(element, 'aria-valuemax', '100', {}, dom);
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
|
|
178
|
+
case 'combobox':
|
|
179
|
+
if (!hasAttr('aria-expanded')) {
|
|
180
|
+
safeSetAttribute(element, 'aria-expanded', 'false', {}, dom);
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
|
|
184
|
+
case 'tablist':
|
|
185
|
+
if (!hasAttr('aria-orientation')) {
|
|
186
|
+
safeSetAttribute(element, 'aria-orientation', 'horizontal', {}, dom);
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
|
|
190
|
+
case 'tab':
|
|
191
|
+
if (!hasAttr('aria-selected')) {
|
|
192
|
+
safeSetAttribute(element, 'aria-selected', 'false', {}, dom);
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
|
|
196
|
+
case 'button':
|
|
197
|
+
case 'link':
|
|
198
|
+
case 'menuitem':
|
|
199
|
+
// Ensure focusability for interactive roles on non-focusable elements
|
|
200
|
+
if (!hasAttr('tabindex')) {
|
|
201
|
+
safeSetAttribute(element, 'tabindex', '0', {}, dom);
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
20
207
|
/**
|
|
21
208
|
* Create a DOM element from a CSS selector-like string
|
|
22
209
|
*
|
|
@@ -42,6 +229,9 @@ export function el(selector, ...children) {
|
|
|
42
229
|
safeSetAttribute(element, key, value, {}, dom);
|
|
43
230
|
}
|
|
44
231
|
|
|
232
|
+
// Apply automatic ARIA attributes based on element type
|
|
233
|
+
applyAutoAria(element, config.tag, config.attrs, dom);
|
|
234
|
+
|
|
45
235
|
// Process children
|
|
46
236
|
for (const child of children) {
|
|
47
237
|
appendChild(element, child);
|
|
@@ -138,5 +328,6 @@ export function text(getValue) {
|
|
|
138
328
|
|
|
139
329
|
export default {
|
|
140
330
|
el,
|
|
141
|
-
text
|
|
331
|
+
text,
|
|
332
|
+
configureA11y
|
|
142
333
|
};
|
package/runtime/dom.js
CHANGED
|
@@ -38,8 +38,8 @@ import {
|
|
|
38
38
|
resetCacheMetrics
|
|
39
39
|
} from './dom-selector.js';
|
|
40
40
|
|
|
41
|
-
// Core element creation
|
|
42
|
-
import { el, text } from './dom-element.js';
|
|
41
|
+
// Core element creation and a11y configuration
|
|
42
|
+
import { el, text, configureA11y } from './dom-element.js';
|
|
43
43
|
|
|
44
44
|
// Reactive bindings
|
|
45
45
|
import { bind, prop, cls, style, on, model } from './dom-binding.js';
|
|
@@ -99,6 +99,9 @@ export {
|
|
|
99
99
|
el,
|
|
100
100
|
text,
|
|
101
101
|
|
|
102
|
+
// Accessibility
|
|
103
|
+
configureA11y,
|
|
104
|
+
|
|
102
105
|
// Reactive bindings
|
|
103
106
|
bind,
|
|
104
107
|
prop,
|
|
@@ -140,6 +143,9 @@ export default {
|
|
|
140
143
|
el,
|
|
141
144
|
text,
|
|
142
145
|
|
|
146
|
+
// Accessibility
|
|
147
|
+
configureA11y,
|
|
148
|
+
|
|
143
149
|
// Reactive bindings
|
|
144
150
|
bind,
|
|
145
151
|
prop,
|
package/runtime/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export * from './router.js';
|
|
|
8
8
|
export * from './store.js';
|
|
9
9
|
export * from './native.js';
|
|
10
10
|
export * from './logger.js';
|
|
11
|
+
export * from './a11y.js';
|
|
11
12
|
|
|
12
13
|
export { default as PulseCore } from './pulse.js';
|
|
13
14
|
export { default as PulseDOM } from './dom.js';
|
|
@@ -15,3 +16,4 @@ export { default as PulseRouter } from './router.js';
|
|
|
15
16
|
export { default as PulseStore } from './store.js';
|
|
16
17
|
export { default as PulseNative } from './native.js';
|
|
17
18
|
export { default as PulseLogger } from './logger.js';
|
|
19
|
+
export { default as PulseA11y } from './a11y.js';
|
package/runtime/native.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { pulse, effect, batch } from './pulse.js';
|
|
11
11
|
import { loggers } from './logger.js';
|
|
12
|
+
import { DANGEROUS_KEYS } from './security.js';
|
|
12
13
|
|
|
13
14
|
const log = loggers.native;
|
|
14
15
|
|
|
@@ -205,8 +206,7 @@ function _validateBridge(bridge) {
|
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
// 7. Check for suspicious properties (potential tampering)
|
|
208
|
-
const
|
|
209
|
-
for (const prop of suspiciousProps) {
|
|
209
|
+
for (const prop of DANGEROUS_KEYS) {
|
|
210
210
|
if (prop in bridge && typeof bridge[prop] === 'function') {
|
|
211
211
|
warnings.push(`Suspicious property detected on bridge: ${prop}`);
|
|
212
212
|
}
|