vanduo-framework 1.1.8 → 1.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/README.md +42 -31
- package/dist/build-info.json +3 -3
- package/dist/vanduo.cjs.js +720 -111
- package/dist/vanduo.cjs.js.map +3 -3
- package/dist/vanduo.cjs.min.js +11 -11
- package/dist/vanduo.cjs.min.js.map +4 -4
- package/dist/vanduo.css +285 -1
- package/dist/vanduo.css.map +1 -1
- package/dist/vanduo.esm.js +720 -111
- package/dist/vanduo.esm.js.map +3 -3
- package/dist/vanduo.esm.min.js +11 -11
- package/dist/vanduo.esm.min.js.map +4 -4
- package/dist/vanduo.js +720 -111
- package/dist/vanduo.js.map +3 -3
- package/dist/vanduo.min.css +1 -1
- package/dist/vanduo.min.js +11 -11
- package/dist/vanduo.min.js.map +4 -4
- package/js/components/code-snippet.js +5 -3
- package/js/components/doc-search.js +90 -73
- package/js/components/grid.js +22 -22
- package/js/components/lazy-load.js +353 -0
- package/js/components/theme-customizer.js +20 -4
- package/js/components/tooltips.js +1 -1
- package/js/index.js +1 -0
- package/js/utils/helpers.js +24 -12
- package/js/vanduo.js +14 -14
- package/package.json +3 -3
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanduo Framework – LazyLoad Component
|
|
3
|
+
* v1.2.1
|
|
4
|
+
*
|
|
5
|
+
* Provides two levels of API:
|
|
6
|
+
*
|
|
7
|
+
* LOW-LEVEL (generic IntersectionObserver wrapper)
|
|
8
|
+
* VanduoLazyLoad.observe(element, callback, options?)
|
|
9
|
+
* VanduoLazyLoad.unobserve(element)
|
|
10
|
+
* VanduoLazyLoad.unobserveAll()
|
|
11
|
+
*
|
|
12
|
+
* HIGH-LEVEL (HTML-section fetcher, mirrors vanduo-docs SPA behaviour)
|
|
13
|
+
* VanduoLazyLoad.loadSection(url, containerEl, options?)
|
|
14
|
+
* options: { placeholder, threshold, rootMargin, onLoaded, onError }
|
|
15
|
+
* placeholder: 'skeleton' | 'spinner' | HTMLString (default: 'skeleton')
|
|
16
|
+
*
|
|
17
|
+
* ATTRIBUTE-DRIVEN (zero-JS usage, wired by init())
|
|
18
|
+
* <div data-vd-lazy="./path/to/section.html"
|
|
19
|
+
* data-vd-lazy-placeholder="skeleton|spinner">…</div>
|
|
20
|
+
*
|
|
21
|
+
* EVENTS dispatched on the host element:
|
|
22
|
+
* lazysection:loading — fetch started
|
|
23
|
+
* lazysection:loaded — content injected
|
|
24
|
+
* lazysection:error — fetch failed
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
(function () {
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
/* ── Private state ────────────────────────────────── */
|
|
31
|
+
|
|
32
|
+
/** @type {Map<Element, IntersectionObserver>} */
|
|
33
|
+
const _observerMap = new Map();
|
|
34
|
+
|
|
35
|
+
/* ── Security helpers ─────────────────────────────── */
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns true only if `url` resolves to the same origin as the page
|
|
39
|
+
* (relative paths and same-origin absolute URLs are allowed).
|
|
40
|
+
* Cross-origin URLs are rejected to prevent SSRF-style fetch abuse.
|
|
41
|
+
* @param {string} url
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function _isSafeUrl(url) {
|
|
45
|
+
try {
|
|
46
|
+
// Relative URLs (no origin) are always safe
|
|
47
|
+
const resolved = new URL(url, window.location.href);
|
|
48
|
+
return resolved.origin === window.location.origin;
|
|
49
|
+
} catch (_) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Safely inject fetched HTML into a container by parsing it with
|
|
56
|
+
* DOMParser (avoids script execution) and replacing children via
|
|
57
|
+
* standard DOM APIs instead of raw innerHTML assignment.
|
|
58
|
+
* @param {Element} containerEl
|
|
59
|
+
* @param {string} html
|
|
60
|
+
*/
|
|
61
|
+
function _safeInjectHtml(containerEl, html) {
|
|
62
|
+
const parser = new DOMParser();
|
|
63
|
+
const doc = parser.parseFromString(html.trim(), 'text/html'); // CodeQL [js/xss-through-dom] Intentional: parsed nodes are sanitized below before adoption into live DOM
|
|
64
|
+
|
|
65
|
+
const DANGEROUS_TAGS = ['SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'BASE', 'LINK', 'META', 'STYLE'];
|
|
66
|
+
for (const tag of DANGEROUS_TAGS) {
|
|
67
|
+
const els = doc.querySelectorAll(tag);
|
|
68
|
+
for (let i = els.length - 1; i >= 0; i--) {
|
|
69
|
+
els[i].parentNode.removeChild(els[i]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Strip dangerous attributes (e.g. onerror, javascript: urls) from all elements
|
|
74
|
+
function _sanitizeNode(node) {
|
|
75
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
76
|
+
const attrs = node.attributes;
|
|
77
|
+
for (let i = attrs.length - 1; i >= 0; i--) {
|
|
78
|
+
const attrName = attrs[i].name.toLowerCase();
|
|
79
|
+
const attrValue = attrs[i].value.toLowerCase();
|
|
80
|
+
const trimmedValue = attrValue.trim();
|
|
81
|
+
if (
|
|
82
|
+
attrName.startsWith('on') ||
|
|
83
|
+
trimmedValue.startsWith('javascript:') ||
|
|
84
|
+
trimmedValue.startsWith('data:') ||
|
|
85
|
+
trimmedValue.startsWith('vbscript:')
|
|
86
|
+
) {
|
|
87
|
+
node.removeAttribute(attrs[i].name);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const children = node.childNodes;
|
|
91
|
+
for (let i = 0; i < children.length; i++) {
|
|
92
|
+
_sanitizeNode(children[i]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
_sanitizeNode(doc.body);
|
|
97
|
+
|
|
98
|
+
// Collect all top-level body children
|
|
99
|
+
const nodes = Array.from(doc.body.childNodes);
|
|
100
|
+
// Clear container and append parsed nodes
|
|
101
|
+
while (containerEl.firstChild) {
|
|
102
|
+
containerEl.removeChild(containerEl.firstChild);
|
|
103
|
+
}
|
|
104
|
+
nodes.forEach(function (node) {
|
|
105
|
+
containerEl.appendChild(document.adoptNode(node));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ── Placeholder HTML ─────────────────────────────── */
|
|
110
|
+
|
|
111
|
+
function _skeletonHtml() {
|
|
112
|
+
return '<div class="vd-skeleton-card" style="position:relative;min-height:200px;padding:2rem;overflow:hidden;">'
|
|
113
|
+
+ '<div class="vd-skeleton vd-skeleton-heading-lg" style="margin-bottom:1.5rem;"></div>'
|
|
114
|
+
+ '<div class="vd-skeleton vd-skeleton-paragraph">'
|
|
115
|
+
+ '<div class="vd-skeleton vd-skeleton-text"></div>'
|
|
116
|
+
+ '<div class="vd-skeleton vd-skeleton-text"></div>'
|
|
117
|
+
+ '<div class="vd-skeleton vd-skeleton-text"></div></div>'
|
|
118
|
+
+ '<div class="vd-dynamic-loader" style="position:absolute;inset:0;">'
|
|
119
|
+
+ '<div class="vd-dynamic-loader-grid">'
|
|
120
|
+
+ '<div class="vd-spinner vd-spinner-sm vd-spinner-success" style="animation-delay:0s;"></div>'
|
|
121
|
+
+ '<div class="vd-spinner vd-spinner-sm vd-spinner-warning" style="animation-delay:-0.15s;"></div>'
|
|
122
|
+
+ '<div class="vd-spinner vd-spinner-sm vd-spinner-error" style="animation-delay:-0.3s;"></div>'
|
|
123
|
+
+ '<div class="vd-spinner vd-spinner-sm vd-spinner-info" style="animation-delay:-0.45s;"></div></div>'
|
|
124
|
+
+ '<span class="vd-dynamic-loader-text">Loading…</span></div></div>';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _spinnerHtml() {
|
|
128
|
+
return '<div class="vd-dynamic-loader" style="min-height:180px;display:flex;align-items:center;justify-content:center;">'
|
|
129
|
+
+ '<div class="vd-dynamic-loader-grid">'
|
|
130
|
+
+ '<div class="vd-spinner vd-spinner-sm vd-spinner-success" style="animation-delay:0s;"></div>'
|
|
131
|
+
+ '<div class="vd-spinner vd-spinner-sm vd-spinner-warning" style="animation-delay:-0.15s;"></div>'
|
|
132
|
+
+ '<div class="vd-spinner vd-spinner-sm vd-spinner-error" style="animation-delay:-0.3s;"></div>'
|
|
133
|
+
+ '<div class="vd-spinner vd-spinner-sm vd-spinner-info" style="animation-delay:-0.45s;"></div></div>'
|
|
134
|
+
+ '<span class="vd-dynamic-loader-text">Loading…</span></div>';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _resolvePlaceholder(placeholder) {
|
|
138
|
+
if (!placeholder || placeholder === 'skeleton') return _skeletonHtml();
|
|
139
|
+
if (placeholder === 'spinner') return _spinnerHtml();
|
|
140
|
+
// Caller-supplied HTML string
|
|
141
|
+
return placeholder;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ── Dispatch helper ──────────────────────────────── */
|
|
145
|
+
|
|
146
|
+
function _dispatch(el, eventName, detail) {
|
|
147
|
+
el.dispatchEvent(new CustomEvent(eventName, { bubbles: true, detail: detail || {} }));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ── VanduoLazyLoad ──────────────────────────────── */
|
|
151
|
+
|
|
152
|
+
const VanduoLazyLoad = {
|
|
153
|
+
|
|
154
|
+
/* ─────────────────────────────────────────────────
|
|
155
|
+
* LOW-LEVEL API
|
|
156
|
+
* ───────────────────────────────────────────────── */
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Observe an element. `callback` is invoked once when the element
|
|
160
|
+
* enters the viewport, then the element is automatically unobserved.
|
|
161
|
+
*
|
|
162
|
+
* @param {Element} element
|
|
163
|
+
* @param {function(Element): void} callback
|
|
164
|
+
* @param {{ threshold?: number, rootMargin?: string }} [options]
|
|
165
|
+
*/
|
|
166
|
+
observe: function (element, callback, options) {
|
|
167
|
+
if (!(element instanceof Element)) {
|
|
168
|
+
console.warn('[VanduoLazyLoad] observe() requires a DOM Element.');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (typeof callback !== 'function') {
|
|
172
|
+
console.warn('[VanduoLazyLoad] observe() requires a callback function.');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Already observed — ignore
|
|
176
|
+
if (_observerMap.has(element)) return;
|
|
177
|
+
|
|
178
|
+
const threshold = (options && options.threshold != null) ? options.threshold : 0;
|
|
179
|
+
const rootMargin = (options && options.rootMargin) ? options.rootMargin : '0px';
|
|
180
|
+
|
|
181
|
+
const observer = new IntersectionObserver(function (entries, obs) {
|
|
182
|
+
entries.forEach(function (entry) {
|
|
183
|
+
if (entry.isIntersecting) {
|
|
184
|
+
obs.unobserve(entry.target);
|
|
185
|
+
_observerMap.delete(entry.target);
|
|
186
|
+
try {
|
|
187
|
+
callback(entry.target);
|
|
188
|
+
} catch (e) {
|
|
189
|
+
console.error('[VanduoLazyLoad] Callback threw:', e);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}, { threshold: threshold, rootMargin: rootMargin });
|
|
194
|
+
|
|
195
|
+
_observerMap.set(element, observer);
|
|
196
|
+
observer.observe(element);
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Stop observing an element that was previously passed to observe().
|
|
201
|
+
* @param {Element} element
|
|
202
|
+
*/
|
|
203
|
+
unobserve: function (element) {
|
|
204
|
+
const observer = _observerMap.get(element);
|
|
205
|
+
if (observer) {
|
|
206
|
+
observer.unobserve(element);
|
|
207
|
+
_observerMap.delete(element);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Stop observing ALL currently observed elements.
|
|
213
|
+
*/
|
|
214
|
+
unobserveAll: function () {
|
|
215
|
+
_observerMap.forEach(function (observer, element) {
|
|
216
|
+
observer.unobserve(element);
|
|
217
|
+
});
|
|
218
|
+
_observerMap.clear();
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
/* ─────────────────────────────────────────────────
|
|
222
|
+
* HIGH-LEVEL API
|
|
223
|
+
* ───────────────────────────────────────────────── */
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Fetch an HTML partial and inject it into `containerEl` when the
|
|
227
|
+
* container enters the viewport. A placeholder is shown immediately.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} url URL of the HTML partial to fetch
|
|
230
|
+
* @param {Element} containerEl Target element whose content will be replaced
|
|
231
|
+
* @param {{
|
|
232
|
+
* placeholder?: 'skeleton'|'spinner'|string,
|
|
233
|
+
* threshold?: number,
|
|
234
|
+
* rootMargin?: string,
|
|
235
|
+
* onLoaded?: function(Element): void,
|
|
236
|
+
* onError?: function(Error): void
|
|
237
|
+
* }} [options]
|
|
238
|
+
*/
|
|
239
|
+
loadSection: function (url, containerEl, options) {
|
|
240
|
+
if (typeof url !== 'string' || !url) {
|
|
241
|
+
console.warn('[VanduoLazyLoad] loadSection() requires a non-empty URL string.');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (!(containerEl instanceof Element)) {
|
|
245
|
+
console.warn('[VanduoLazyLoad] loadSection() requires a DOM Element as containerEl.');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Reject cross-origin URLs to prevent SSRF-style fetch abuse
|
|
249
|
+
if (!_isSafeUrl(url)) {
|
|
250
|
+
console.error('[VanduoLazyLoad] loadSection() blocked cross-origin URL:', url);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const opts = options || {};
|
|
255
|
+
// Placeholders are known-safe static HTML strings built internally
|
|
256
|
+
const placeholderHtml = _resolvePlaceholder(opts.placeholder);
|
|
257
|
+
|
|
258
|
+
// Use _safeInjectHtml instead of innerHTML to prevent XSS from caller-supplied placeholders
|
|
259
|
+
_safeInjectHtml(containerEl, placeholderHtml);
|
|
260
|
+
_dispatch(containerEl, 'lazysection:loading', { url: url });
|
|
261
|
+
|
|
262
|
+
// Fetch when visible
|
|
263
|
+
this.observe(containerEl, function () {
|
|
264
|
+
const controller = new window.AbortController();
|
|
265
|
+
const timeoutId = setTimeout(function () { controller.abort(); }, 10000);
|
|
266
|
+
|
|
267
|
+
window.fetch(url, { signal: controller.signal })
|
|
268
|
+
.then(function (res) {
|
|
269
|
+
clearTimeout(timeoutId);
|
|
270
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
271
|
+
return res.text();
|
|
272
|
+
})
|
|
273
|
+
.then(function (html) {
|
|
274
|
+
// Use DOMParser to parse fetched content safely, avoiding
|
|
275
|
+
// raw innerHTML assignment of externally-sourced strings
|
|
276
|
+
_safeInjectHtml(containerEl, html);
|
|
277
|
+
_dispatch(containerEl, 'lazysection:loaded', { url: url });
|
|
278
|
+
// Re-init Vanduo components inside the new content
|
|
279
|
+
if (typeof window.Vanduo !== 'undefined') {
|
|
280
|
+
window.Vanduo.init();
|
|
281
|
+
}
|
|
282
|
+
if (typeof opts.onLoaded === 'function') {
|
|
283
|
+
opts.onLoaded(containerEl);
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
.catch(function (err) {
|
|
287
|
+
// Build error node via DOM APIs — no dynamic HTML strings
|
|
288
|
+
const alertEl = document.createElement('div');
|
|
289
|
+
alertEl.className = 'vd-alert vd-alert-error';
|
|
290
|
+
alertEl.setAttribute('role', 'alert');
|
|
291
|
+
const msgEl = document.createElement('span');
|
|
292
|
+
msgEl.textContent = 'Failed to load content. ';
|
|
293
|
+
const detailEl = document.createElement('small');
|
|
294
|
+
detailEl.style.opacity = '0.7';
|
|
295
|
+
detailEl.textContent = err.message;
|
|
296
|
+
alertEl.appendChild(msgEl);
|
|
297
|
+
alertEl.appendChild(detailEl);
|
|
298
|
+
while (containerEl.firstChild) {
|
|
299
|
+
containerEl.removeChild(containerEl.firstChild);
|
|
300
|
+
}
|
|
301
|
+
containerEl.appendChild(alertEl);
|
|
302
|
+
_dispatch(containerEl, 'lazysection:error', { url: url, error: err });
|
|
303
|
+
console.error('[VanduoLazyLoad] loadSection failed:', err);
|
|
304
|
+
if (typeof opts.onError === 'function') {
|
|
305
|
+
opts.onError(err);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
}, { threshold: opts.threshold, rootMargin: opts.rootMargin });
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
/* ─────────────────────────────────────────────────
|
|
312
|
+
* ATTRIBUTE-DRIVEN INIT
|
|
313
|
+
* ───────────────────────────────────────────────── */
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Scan the DOM for [data-vd-lazy] elements and wire them up.
|
|
317
|
+
* Safe to call multiple times — already-observed elements are skipped.
|
|
318
|
+
*/
|
|
319
|
+
init: function () {
|
|
320
|
+
const self = this;
|
|
321
|
+
const elements = document.querySelectorAll('[data-vd-lazy]');
|
|
322
|
+
elements.forEach(function (el) {
|
|
323
|
+
// Skip already-observed or already-loaded elements
|
|
324
|
+
if (_observerMap.has(el) || el.dataset.vdLazyState === 'loading' || el.dataset.vdLazyState === 'loaded') return;
|
|
325
|
+
|
|
326
|
+
const url = el.getAttribute('data-vd-lazy');
|
|
327
|
+
if (!url) return;
|
|
328
|
+
|
|
329
|
+
el.dataset.vdLazyState = 'loading';
|
|
330
|
+
const placeholder = el.getAttribute('data-vd-lazy-placeholder') || 'skeleton';
|
|
331
|
+
|
|
332
|
+
self.loadSection(url, el, {
|
|
333
|
+
placeholder: placeholder,
|
|
334
|
+
onLoaded: function () {
|
|
335
|
+
el.dataset.vdLazyState = 'loaded';
|
|
336
|
+
},
|
|
337
|
+
onError: function () {
|
|
338
|
+
el.dataset.vdLazyState = 'error';
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/* ── Register with Vanduo ─────────────────────────── */
|
|
346
|
+
if (typeof window.Vanduo !== 'undefined') {
|
|
347
|
+
window.Vanduo.register('LazyLoad', VanduoLazyLoad);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* ── Global convenience alias ─────────────────────── */
|
|
351
|
+
window.VanduoLazyLoad = VanduoLazyLoad;
|
|
352
|
+
|
|
353
|
+
})();
|
|
@@ -483,28 +483,44 @@
|
|
|
483
483
|
* Generate panel HTML
|
|
484
484
|
*/
|
|
485
485
|
getPanelHTML: function () {
|
|
486
|
+
const esc = typeof escapeHtml === 'function'
|
|
487
|
+
? escapeHtml
|
|
488
|
+
: function (text) {
|
|
489
|
+
const div = document.createElement('div');
|
|
490
|
+
div.textContent = String(text ?? '');
|
|
491
|
+
return div.innerHTML;
|
|
492
|
+
};
|
|
493
|
+
const safeColor = function (value) {
|
|
494
|
+
const normalized = String(value ?? '').trim();
|
|
495
|
+
// Allow common color formats used by palette values.
|
|
496
|
+
if (/^(#[0-9a-fA-F]{3,8}|rgb[a]?\([^)]{1,60}\)|hsl[a]?\([^)]{1,60}\)|var\(--[a-zA-Z0-9_-]{1,40}\))$/.test(normalized)) {
|
|
497
|
+
return normalized;
|
|
498
|
+
}
|
|
499
|
+
return '#000000';
|
|
500
|
+
};
|
|
501
|
+
|
|
486
502
|
// Generate primary color swatches
|
|
487
503
|
let primarySwatches = '';
|
|
488
504
|
for (const [key, value] of Object.entries(this.PRIMARY_COLORS)) {
|
|
489
|
-
primarySwatches += `<button class="tc-color-swatch${key === this.state.primary ? ' is-active' : ''}" data-color="${key}" style="--swatch-color: ${value.color}" title="${value.name}"></button>`;
|
|
505
|
+
primarySwatches += `<button class="tc-color-swatch${key === this.state.primary ? ' is-active' : ''}" data-color="${esc(key)}" style="--swatch-color: ${safeColor(value.color)}" title="${esc(value.name)}"></button>`;
|
|
490
506
|
}
|
|
491
507
|
|
|
492
508
|
// Generate neutral color swatches
|
|
493
509
|
let neutralSwatches = '';
|
|
494
510
|
for (const [key, value] of Object.entries(this.NEUTRAL_COLORS)) {
|
|
495
|
-
neutralSwatches += `<button class="tc-neutral-swatch${key === this.state.neutral ? ' is-active' : ''}" data-neutral="${key}" style="--swatch-color: ${value.color}" title="${value.name}"><span>${value.name}</span></button>`;
|
|
511
|
+
neutralSwatches += `<button class="tc-neutral-swatch${key === this.state.neutral ? ' is-active' : ''}" data-neutral="${esc(key)}" style="--swatch-color: ${safeColor(value.color)}" title="${esc(value.name)}"><span>${esc(value.name)}</span></button>`;
|
|
496
512
|
}
|
|
497
513
|
|
|
498
514
|
// Generate radius buttons
|
|
499
515
|
let radiusButtons = '';
|
|
500
516
|
this.RADIUS_OPTIONS.forEach(r => {
|
|
501
|
-
radiusButtons += `<button class="tc-radius-btn${r === this.state.radius ? ' is-active' : ''}" data-radius="${r}">${r}</button>`;
|
|
517
|
+
radiusButtons += `<button class="tc-radius-btn${r === this.state.radius ? ' is-active' : ''}" data-radius="${esc(r)}">${esc(r)}</button>`;
|
|
502
518
|
});
|
|
503
519
|
|
|
504
520
|
// Generate font options
|
|
505
521
|
let fontOptions = '';
|
|
506
522
|
for (const [key, value] of Object.entries(this.FONT_OPTIONS)) {
|
|
507
|
-
fontOptions += `<option value="${key}"${key === this.state.font ? ' selected' : ''}>${value.name}</option>`;
|
|
523
|
+
fontOptions += `<option value="${esc(key)}"${key === this.state.font ? ' selected' : ''}>${esc(value.name)}</option>`;
|
|
508
524
|
}
|
|
509
525
|
|
|
510
526
|
// Generate mode buttons
|
package/js/index.js
CHANGED
|
@@ -45,6 +45,7 @@ import './components/toast.js';
|
|
|
45
45
|
import './components/tooltips.js';
|
|
46
46
|
import './components/doc-search.js';
|
|
47
47
|
import './components/draggable.js';
|
|
48
|
+
import './components/lazy-load.js';
|
|
48
49
|
|
|
49
50
|
// Re-export for ESM / CJS consumers
|
|
50
51
|
const Vanduo = window.Vanduo;
|
package/js/utils/helpers.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Vanduo Framework - Utility Helpers
|
|
3
5
|
* Common utility functions used across the framework
|
|
@@ -86,7 +88,11 @@ function on(target, event, handlerOrSelector, handler) {
|
|
|
86
88
|
element.addEventListener(event, function (e) {
|
|
87
89
|
const delegateTarget = e.target.closest(handlerOrSelector);
|
|
88
90
|
if (delegateTarget && element.contains(delegateTarget)) {
|
|
89
|
-
|
|
91
|
+
try {
|
|
92
|
+
handler.call(delegateTarget, e);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.warn('[Vanduo Helpers] Delegated handler error:', error);
|
|
95
|
+
}
|
|
90
96
|
}
|
|
91
97
|
});
|
|
92
98
|
}
|
|
@@ -238,7 +244,7 @@ function getPosition(element) {
|
|
|
238
244
|
*/
|
|
239
245
|
function escapeHtml(str) {
|
|
240
246
|
if (!str) return '';
|
|
241
|
-
|
|
247
|
+
const div = document.createElement('div');
|
|
242
248
|
div.appendChild(document.createTextNode(str));
|
|
243
249
|
return div.innerHTML;
|
|
244
250
|
}
|
|
@@ -252,24 +258,30 @@ function escapeHtml(str) {
|
|
|
252
258
|
*/
|
|
253
259
|
function sanitizeHtml(input) {
|
|
254
260
|
if (!input) return '';
|
|
255
|
-
|
|
256
|
-
|
|
261
|
+
let doc;
|
|
262
|
+
try {
|
|
263
|
+
doc = new DOMParser().parseFromString(input, 'text/html');
|
|
264
|
+
} catch (_error) {
|
|
265
|
+
// Fail closed to plain escaped text if parser is unavailable/fails.
|
|
266
|
+
return escapeHtml(input);
|
|
267
|
+
}
|
|
268
|
+
const allowed = ['B', 'STRONG', 'I', 'EM', 'BR', 'A', 'SPAN', 'U', 'SVG', 'PATH', 'LINE', 'CIRCLE', 'POLYLINE', 'RECT', 'G'];
|
|
257
269
|
|
|
258
|
-
|
|
259
|
-
|
|
270
|
+
const sanitizeNode = function (node) {
|
|
271
|
+
const children = Array.from(node.childNodes);
|
|
260
272
|
children.forEach(function (child) {
|
|
261
273
|
if (child.nodeType === Node.TEXT_NODE) return;
|
|
262
274
|
|
|
263
275
|
if (!allowed.includes(child.nodeName)) {
|
|
264
|
-
|
|
276
|
+
const text = document.createTextNode(child.textContent);
|
|
265
277
|
node.replaceChild(text, child);
|
|
266
278
|
return;
|
|
267
279
|
}
|
|
268
280
|
|
|
269
281
|
if (child.nodeName === 'A') {
|
|
270
|
-
|
|
282
|
+
const href = child.getAttribute('href') || '';
|
|
271
283
|
try {
|
|
272
|
-
|
|
284
|
+
const url = new URL(href, location.href);
|
|
273
285
|
if (!['http:', 'https:', 'mailto:'].includes(url.protocol)) {
|
|
274
286
|
child.removeAttribute('href');
|
|
275
287
|
}
|
|
@@ -280,17 +292,17 @@ function sanitizeHtml(input) {
|
|
|
280
292
|
child.removeAttribute('rel');
|
|
281
293
|
} else if (child.nodeName === 'SVG' || child.closest && child.closest('svg')) {
|
|
282
294
|
// Allow safe SVG presentation attributes only
|
|
283
|
-
|
|
295
|
+
const safeSvgAttrs = ['xmlns', 'width', 'height', 'viewBox', 'fill', 'stroke', 'stroke-width',
|
|
284
296
|
'stroke-linecap', 'stroke-linejoin', 'd', 'cx', 'cy', 'r', 'x1', 'y1', 'x2', 'y2', 'points',
|
|
285
297
|
'transform', 'class'];
|
|
286
|
-
|
|
298
|
+
const attrs = Array.from(child.attributes || []);
|
|
287
299
|
attrs.forEach(function (a) {
|
|
288
300
|
if (!safeSvgAttrs.includes(a.name)) {
|
|
289
301
|
child.removeAttribute(a.name);
|
|
290
302
|
}
|
|
291
303
|
});
|
|
292
304
|
} else {
|
|
293
|
-
|
|
305
|
+
const otherAttrs = Array.from(child.attributes || []);
|
|
294
306
|
otherAttrs.forEach(function (a) { child.removeAttribute(a.name); });
|
|
295
307
|
}
|
|
296
308
|
|
package/js/vanduo.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vanduo Framework - Main JavaScript File
|
|
3
|
-
* v1.1
|
|
3
|
+
* v1.2.1
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
(function() {
|
|
6
|
+
(function () {
|
|
7
7
|
'use strict';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Vanduo Framework Object
|
|
11
11
|
*/
|
|
12
12
|
const Vanduo = {
|
|
13
|
-
version: '1.1
|
|
13
|
+
version: '1.2.1',
|
|
14
14
|
components: {},
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Initialize framework
|
|
18
18
|
* Call this after DOM is ready and all components are loaded
|
|
19
19
|
*/
|
|
20
|
-
init: function() {
|
|
20
|
+
init: function () {
|
|
21
21
|
// Initialize components when DOM is ready
|
|
22
22
|
if (typeof ready !== 'undefined') {
|
|
23
23
|
ready(() => {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
/**
|
|
39
39
|
* Initialize all components
|
|
40
40
|
*/
|
|
41
|
-
initComponents: function() {
|
|
41
|
+
initComponents: function () {
|
|
42
42
|
// Initialize all registered components
|
|
43
43
|
Object.keys(this.components).forEach((name) => {
|
|
44
44
|
const component = this.components[name];
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
}
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
console.log('Vanduo Framework v1.1
|
|
54
|
+
console.log('Vanduo Framework v1.2.1 initialized');
|
|
55
55
|
},
|
|
56
56
|
|
|
57
57
|
/**
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
* @param {string} name - Component name
|
|
60
60
|
* @param {Object} component - Component object with init method
|
|
61
61
|
*/
|
|
62
|
-
register: function(name, component) {
|
|
62
|
+
register: function (name, component) {
|
|
63
63
|
this.components[name] = component;
|
|
64
64
|
// Note: Components are NOT auto-initialized on registration
|
|
65
65
|
// Call Vanduo.init() explicitly after all components are registered
|
|
@@ -69,8 +69,8 @@
|
|
|
69
69
|
* Re-initialize a component (useful after dynamic DOM changes)
|
|
70
70
|
* @param {string} name - Component name
|
|
71
71
|
*/
|
|
72
|
-
reinit: function(name) {
|
|
73
|
-
|
|
72
|
+
reinit: function (name) {
|
|
73
|
+
const component = this.components[name];
|
|
74
74
|
if (component && component.init && typeof component.init === 'function') {
|
|
75
75
|
try {
|
|
76
76
|
component.init();
|
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
* Destroy all component instances and clean up event listeners
|
|
85
85
|
* Uses lifecycle manager for memory leak prevention
|
|
86
86
|
*/
|
|
87
|
-
destroyAll: function() {
|
|
87
|
+
destroyAll: function () {
|
|
88
88
|
// First, destroy components that have their own destroyAll
|
|
89
|
-
|
|
90
|
-
for (
|
|
91
|
-
|
|
89
|
+
const names = Object.keys(this.components);
|
|
90
|
+
for (let i = 0; i < names.length; i++) {
|
|
91
|
+
const component = this.components[names[i]];
|
|
92
92
|
if (component && component.destroyAll && typeof component.destroyAll === 'function') {
|
|
93
93
|
try {
|
|
94
94
|
component.destroyAll();
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
* @param {string} name - Component name
|
|
110
110
|
* @returns {Object|null}
|
|
111
111
|
*/
|
|
112
|
-
getComponent: function(name) {
|
|
112
|
+
getComponent: function (name) {
|
|
113
113
|
return this.components[name] || null;
|
|
114
114
|
}
|
|
115
115
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vanduo-framework",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Zero-dependency CSS/JS framework built on Fibonacci/Golden Ratio design system with Open Color integration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"css",
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
"@eslint/js": "^10.0.1",
|
|
43
43
|
"@playwright/test": "^1.58.2",
|
|
44
44
|
"esbuild": "^0.27.3",
|
|
45
|
-
"eslint": "^10.0.
|
|
45
|
+
"eslint": "^10.0.2",
|
|
46
46
|
"husky": "^9.1.7",
|
|
47
47
|
"lightningcss": "^1.31.1",
|
|
48
|
-
"stylelint": "^17.
|
|
48
|
+
"stylelint": "^17.4.0",
|
|
49
49
|
"stylelint-config-standard": "^40.0.0"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|