@trebor/buildhtml 1.0.0 → 1.0.2

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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +699 -203
  3. package/index.js +1309 -201
  4. package/package.json +38 -37
  5. package/LICENSE.txt +0 -15
package/index.js CHANGED
@@ -1,18 +1,67 @@
1
- // ======================================================
2
- // ULTRA-PERFORMANCE SSR BUILDER v1.0.1 (FIXED)
3
- // ======================================================
4
-
5
1
  'use strict';
6
2
 
7
3
  /* ---------------- CONFIG ---------------- */
8
- const CONFIG = {
4
+ const CONFIG = Object.freeze({
9
5
  mode: process.env.NODE_ENV === "production" ? "prod" : "dev",
10
6
  poolSize: 150,
11
7
  cacheLimit: 2000,
12
- maxCssCache: 1000,
13
8
  maxKebabCache: 500,
14
- compression: true
15
- };
9
+ compression: true,
10
+ enableMetrics: process.env.ENABLE_METRICS === 'true',
11
+ cspNonce: process.env.CSP_NONCE_ENABLED === 'true',
12
+ maxComputedFnSize: 10000,
13
+ maxEventFnSize: 5000,
14
+ sanitizeCss: true
15
+ });
16
+
17
+ /* ---------------- METRICS (Optional) ---------------- */
18
+ class Metrics {
19
+ constructor() {
20
+ this.enabled = CONFIG.enableMetrics;
21
+ this.counters = new Map();
22
+ this.timings = new Map();
23
+ }
24
+
25
+ increment(key, value = 1) {
26
+ if (!this.enabled) return;
27
+ this.counters.set(key, (this.counters.get(key) || 0) + value);
28
+ }
29
+
30
+ timing(key, duration) {
31
+ if (!this.enabled) return;
32
+ if (!this.timings.has(key)) this.timings.set(key, []);
33
+ this.timings.get(key).push(duration);
34
+ }
35
+
36
+ getStats() {
37
+ const stats = { counters: {}, timings: {} };
38
+
39
+ for (const [key, value] of this.counters) {
40
+ stats.counters[key] = value;
41
+ }
42
+
43
+ for (const [key, values] of this.timings) {
44
+ const sorted = values.sort((a, b) => a - b);
45
+ const len = sorted.length;
46
+ stats.timings[key] = {
47
+ count: len,
48
+ avg: values.reduce((a, b) => a + b, 0) / len,
49
+ p50: sorted[Math.floor(len * 0.5)],
50
+ p95: sorted[Math.floor(len * 0.95)],
51
+ p99: sorted[Math.floor(len * 0.99)]
52
+ };
53
+ }
54
+
55
+ return stats;
56
+ }
57
+
58
+ reset() {
59
+ this.counters.clear();
60
+ this.timings.clear();
61
+ }
62
+ }
63
+
64
+ const metrics = new Metrics();
16
65
 
17
66
  /* ---------------- OBJECT POOLS ---------------- */
18
67
  const pools = {
@@ -25,6 +74,8 @@ function getPooled(type, ...args) {
25
74
  const pool = pools[type];
26
75
  if (pool && pool.length > 0) {
27
76
  const item = pool.pop();
77
+ metrics.increment('pool.reuse.' + type);
78
+
28
79
  if (type === 'elements') {
29
80
  resetElement(item, ...args);
30
81
  } else if (type === 'arrays') {
@@ -33,8 +84,15 @@ function getPooled(type, ...args) {
33
84
  return item;
34
85
  }
35
86
 
87
+ metrics.increment('pool.new.' + type);
88
+
36
89
  switch (type) {
37
- case 'elements': return new Element(...args);
90
+ case 'elements': {
91
+ const [tag, ridGen, stateStore, document] = args;
92
+ const el = new Element(tag, ridGen, stateStore);
93
+ el._document = document;
94
+ return el;
95
+ }
38
96
  case 'arrays': return [];
39
97
  case 'objects': return {};
40
98
  default: return null;
@@ -43,54 +101,62 @@ function getPooled(type, ...args) {
43
101
 
44
102
  function recycle(type, item) {
45
103
  const pool = pools[type];
46
- if (!pool || pool.length >= CONFIG.poolSize) return;
104
+ if (!pool || pool.length >= CONFIG.poolSize) {
105
+ metrics.increment('pool.overflow.' + type);
106
+ return;
107
+ }
47
108
 
48
109
  if (type === 'elements' && item instanceof Element) {
49
- // FIX: Recursive recycling to prevent memory leaks and orphaned children
50
110
  for (let i = 0; i < item.children.length; i++) {
51
111
  const child = item.children[i];
52
112
  if (child instanceof Element) {
53
113
  recycle('elements', child);
54
114
  }
55
115
  }
116
+
117
+ item.children.length = 0;
118
+ item.events.length = 0;
119
+ item._stateBindings.length = 0;
120
+ item.cssText = "";
121
+ item._state = null;
122
+ item._computed = null;
123
+
124
+ for (const key in item.attrs) delete item.attrs[key];
125
+
56
126
  pool.push(item);
127
+ metrics.increment('pool.recycled.elements');
128
+
57
129
  } else if (type === 'arrays' && Array.isArray(item)) {
58
130
  item.length = 0;
59
131
  pool.push(item);
60
- } else if (type === 'objects' && typeof item === 'object') {
61
- for (const key in item) delete item[key];
62
- pool.push(item);
132
+ metrics.increment('pool.recycled.arrays');
63
133
  }
64
134
  }
65
135
 
66
- function resetElement(el, tag, ridGen, stateStore) {
136
+ function resetElement(el, tag, ridGen, stateStore, document) {
67
137
  el.tag = toKebab(tag);
138
+ el._ridGen = ridGen;
139
+ el._stateStore = stateStore;
140
+ el._document = document;
68
141
 
69
- // Clear attrs object properties instead of replacing to reduce GC
70
- for (const key in el.attrs) delete el.attrs[key];
71
-
72
- // Reset arrays
142
+ if (!el.attrs) el.attrs = {};
73
143
  if (!el.children) el.children = [];
74
- else el.children.length = 0;
75
-
76
144
  if (!el.events) el.events = [];
77
- else el.events.length = 0;
145
+ if (!el._stateBindings) el._stateBindings = [];
78
146
 
79
147
  el.cssText = "";
80
148
  el._state = null;
81
149
  el.hydrate = false;
82
150
  el._computed = null;
83
- el._ridGen = ridGen;
84
- el._stateStore = stateStore;
85
151
  }
86
152
 
87
153
  /* ---------------- UTILITIES ---------------- */
88
154
  let ridCounter = 0;
89
- const ridPrefix = Date.now().toString(36);
155
+ const ridPrefix = Date.now().toString(36) + Math.random().toString(36).substring(2, 7);
90
156
 
91
157
  const createRidGenerator = () => () => `id-${ridPrefix}${(++ridCounter).toString(36)}`;
92
158
 
93
- // Simple 32-bit hash (for scoped class names)
159
+ // FNV-1a hash
94
160
  const hash = (str) => {
95
161
  let h = 2166136261;
96
162
  const len = str.length;
@@ -101,36 +167,120 @@ const hash = (str) => {
101
167
  return (h >>> 0).toString(36);
102
168
  };
103
169
 
104
- // Kebab-case conversion with LRU cache
105
- const kebabCache = new Map();
170
+ // LRU Kebab-case conversion cache
171
+ class KebabCache {
172
+ constructor(maxSize) {
173
+ this.maxSize = maxSize;
174
+ this.cache = new Map();
175
+ }
176
+
177
+ get(key) {
178
+ if (!this.cache.has(key)) return null;
179
+ const value = this.cache.get(key);
180
+ this.cache.delete(key);
181
+ this.cache.set(key, value);
182
+ return value;
183
+ }
184
+
185
+ set(key, value) {
186
+ if (this.cache.has(key)) {
187
+ this.cache.delete(key);
188
+ } else if (this.cache.size >= this.maxSize) {
189
+ const firstKey = this.cache.keys().next().value;
190
+ this.cache.delete(firstKey);
191
+ }
192
+ this.cache.set(key, value);
193
+ }
194
+ }
195
+
196
+ const kebabCache = new KebabCache(CONFIG.maxKebabCache);
106
197
  const kebabRegex = /[A-Z]/g;
107
198
 
108
199
  function toKebab(str) {
109
200
  if (!str || typeof str !== 'string') return "";
110
201
 
111
202
  const cached = kebabCache.get(str);
112
- if (cached) return cached;
113
-
114
- const result = str.replace(kebabRegex, m => "-" + m.toLowerCase());
115
-
116
- if (kebabCache.size >= CONFIG.maxKebabCache) {
117
- const firstKey = kebabCache.keys().next().value;
118
- kebabCache.delete(firstKey);
203
+ if (cached) {
204
+ metrics.increment('kebab.cache.hit');
205
+ return cached;
119
206
  }
120
207
 
208
+ metrics.increment('kebab.cache.miss');
209
+ const result = str.replace(kebabRegex, m => "-" + m.toLowerCase());
121
210
  kebabCache.set(str, result);
122
211
  return result;
123
212
  }
124
213
 
125
- // HTML minification
126
- const minHTML = (html) => html.replace(/>\s+</g, "><").replace(/\s{2,}/g, " ").trim();
214
+ // HTML minification (Safer version to preserve inline layouts)
215
+ const minHTML = (html) => {
216
+ return html
217
+ // Only remove whitespace if it includes a newline or is more than 3 spaces
218
+ .replace(/>\s+<|>\n+</g, (m) => {
219
+ return m.includes('\n') || m.length > 3 ? "><" : " > <";
220
+ })
221
+ .replace(/\s{2,}/g, " ")
222
+ .trim();
223
+ };
127
224
 
128
225
  // XSS protection
129
226
  const escapeMap = Object.freeze({
130
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
227
+ '&': '&amp;',
228
+ '<': '&lt;',
229
+ '>': '&gt;',
230
+ '"': '&quot;',
231
+ "'": '&#x27;',
232
+ '/': '&#x2F;'
131
233
  });
132
- const escapeRegex = /[&<>"']/g;
133
- const escapeHtml = (text) => String(text).replace(escapeRegex, m => escapeMap[m]);
234
+ const escapeRegex = /[&<>"'\/]/g;
235
+ const escapeHtml = (text) => {
236
+ if (text == null) return '';
237
+ return String(text).replace(escapeRegex, m => escapeMap[m]);
238
+ };
239
+
240
+ // HTML Entity Unescaping (For JSON transport optimization)
241
+ const unescapeMap = Object.freeze({
242
+ '&amp;': '&',
243
+ '&lt;': '<',
244
+ '&gt;': '>',
245
+ '&quot;': '"',
246
+ '&#x27;': "'",
247
+ '&#x2F;': '/'
248
+ });
249
+ const unescapeRegex = /&(?:amp|lt|gt|quot|#x27|#x2F);/g;
250
+ const unescapeHtml = (text) => {
251
+ if (text == null) return '';
252
+ return String(text).replace(unescapeRegex, m => unescapeMap[m]);
253
+ };
254
+
255
+ // CSS value sanitization
256
+ const cssValueRegex = /[<>"'{}()]/g;
257
+ const sanitizeCssValue = (value) => {
258
+ if (!CONFIG.sanitizeCss) return value;
259
+ return String(value)
260
+ .replace(cssValueRegex, '')
261
+ .replace(/\/\*/g, '')
262
+ .replace(/\*\//g, '')
263
+ .substring(0, 1000);
264
+ };
265
+
266
+ // Validate function source
267
+ const sanitizeFunctionSource = (fn, maxSize) => {
268
+ if (typeof fn !== 'function') {
269
+ throw new TypeError('Expected a function');
270
+ }
271
+
272
+ const source = fn.toString();
273
+
274
+ if (source.length > maxSize) {
275
+ throw new Error(`Function source too large: ${source.length} > ${maxSize}`);
276
+ }
277
+
278
+ if (source.includes('</script>') || source.includes('<script')) {
279
+ throw new Error('Function contains potentially malicious script tags');
280
+ }
281
+
282
+ return source;
283
+ };
134
284
 
135
285
  /* ---------------- ELEMENT ---------------- */
136
286
  class Element {
@@ -145,6 +295,31 @@ class Element {
145
295
  this._computed = null;
146
296
  this._ridGen = ridGen;
147
297
  this._stateStore = stateStore;
298
+ this._document = null;
299
+ this._stateBindings = [];
300
+ }
301
+
302
+ child(tag) {
303
+ if (!this._document) {
304
+ throw new Error('[Element] Cannot create child: element not associated with a document');
305
+ }
306
+ const childElement = getPooled('elements', tag, this._ridGen, this._stateStore, this._document);
307
+ // Automatically add to parent's children array
308
+ this.children.push(childElement);
309
+ return childElement;
310
+ }
311
+
312
+ create(tag) {
313
+ return this.child(tag);
314
+ }
315
+
316
+ attr(key, value) {
317
+ this.attrs[toKebab(key)] = value;
318
+ return this;
319
+ }
320
+
321
+ attribute(key, value) {
322
+ return this.attr(key, value);
148
323
  }
149
324
 
150
325
  id(v) {
@@ -153,24 +328,97 @@ class Element {
153
328
  }
154
329
 
155
330
  text(c) {
156
- this.children.push(escapeHtml(c));
331
+ if (c != null) {
332
+ this.children.push(escapeHtml(c));
333
+ }
157
334
  return this;
158
335
  }
159
336
 
337
+ // Allow text to be set as a property
338
+ set textContent(c) {
339
+ if (c != null) {
340
+ this.children = [escapeHtml(c)];
341
+ }
342
+ }
343
+
160
344
  append(c) {
345
+ if (c == null) return this;
161
346
  this.children.push(c instanceof Element ? c : escapeHtml(c));
162
347
  return this;
163
348
  }
164
349
 
350
+ appendUnsafe(html) {
351
+ if (html != null) {
352
+ this.children.push(String(html));
353
+ }
354
+ return this;
355
+ }
356
+
165
357
  css(s) {
166
- let cssStr = "";
358
+ if (!s || typeof s !== 'object') return this;
359
+
360
+ const cssRules = [];
167
361
  for (const k in s) {
168
- cssStr += toKebab(k) + ":" + s[k] + ";";
362
+ const key = toKebab(k);
363
+ const value = sanitizeCssValue(s[k]);
364
+ cssRules.push(`${key}:${value}`);
169
365
  }
170
366
 
367
+ if (cssRules.length === 0) return this;
368
+
369
+ const cssStr = cssRules.join(';') + ';';
171
370
  const sc = "c" + hash(cssStr);
371
+
172
372
  this.attrs.class = this.attrs.class ? `${this.attrs.class} ${sc}` : sc;
173
373
  this.cssText += `.${sc}{${cssStr}}`;
374
+
375
+ return this;
376
+ }
377
+
378
+ uniqueClass(rules) {
379
+ if (!rules || typeof rules !== 'object') return this;
380
+
381
+ const cssRules = [];
382
+ for (const k in rules) {
383
+ const key = toKebab(k);
384
+ const value = sanitizeCssValue(rules[k]);
385
+ cssRules.push(`${key}:${value}`);
386
+ }
387
+
388
+ if (cssRules.length === 0) return this;
389
+
390
+ const cssStr = cssRules.join(';') + ';';
391
+ const uniqueName = "_u" + Math.random().toString(36).substring(2, 9);
392
+
393
+ this.attrs.class = this.attrs.class ? `${this.attrs.class} ${uniqueName}` : uniqueName;
394
+ this.cssText += `.${uniqueName}{${cssStr}}`;
395
+
396
+ return this;
397
+ }
398
+
399
+ bind(stateKey, templateFn = (val) => val) {
400
+ if (!this.attrs.id) this.id();
401
+
402
+ try {
403
+ const fnSource = typeof templateFn === 'function'
404
+ ? sanitizeFunctionSource(templateFn, CONFIG.maxComputedFnSize)
405
+ : '(val) => val';
406
+
407
+ // Store binding info for client-side rendering
408
+ if (!this._stateBindings) this._stateBindings = [];
409
+ this._stateBindings.push({
410
+ stateKey,
411
+ id: this.attrs.id,
412
+ templateFn: fnSource
413
+ });
414
+
415
+ this.hydrate = true;
416
+ } catch (err) {
417
+ if (CONFIG.mode === 'dev') {
418
+ console.error('[Element] Invalid bind function:', err.message);
419
+ }
420
+ }
421
+
174
422
  return this;
175
423
  }
176
424
 
@@ -183,24 +431,64 @@ class Element {
183
431
  }
184
432
 
185
433
  computed(fn) {
186
- this._computed = fn;
187
- if (!this.attrs.id) this.id();
188
- this.hydrate = true;
434
+ try {
435
+ sanitizeFunctionSource(fn, CONFIG.maxComputedFnSize);
436
+ this._computed = fn;
437
+ if (!this.attrs.id) this.id();
438
+ this.hydrate = true;
439
+ } catch (err) {
440
+ if (CONFIG.mode === 'dev') {
441
+ console.error('Invalid computed function:', err);
442
+ }
443
+ }
189
444
  return this;
190
445
  }
191
446
 
192
447
  on(ev, fn) {
193
- if (!this.attrs.id) this.id();
194
- this.events.push({ event: ev, id: this.attrs.id, fn });
195
- this.hydrate = true;
448
+ try {
449
+ const fnSource = sanitizeFunctionSource(fn, CONFIG.maxEventFnSize);
450
+
451
+ // Warn about closures in dev mode
452
+ if (CONFIG.mode === 'dev') {
453
+ // Check for potential closure usage
454
+ const hasClosureRisk = /(?:let|const|var)\s+\w+/.test(fnSource) === false &&
455
+ /\w+\s*[\+\-\*\/\=]/.test(fnSource) &&
456
+ !fnSource.includes('State.');
457
+
458
+ if (hasClosureRisk) {
459
+ console.warn('[Sculptor] Warning: Event handler may use closures. ' +
460
+ 'Closures won\'t work after serialization. Use State instead.');
461
+ }
462
+ }
463
+
464
+ if (!this.attrs.id) this.id();
465
+ this.events.push({ event: ev, id: this.attrs.id, fn });
466
+ this.hydrate = true;
467
+ } catch (err) {
468
+ if (CONFIG.mode === 'dev') {
469
+ console.error('Invalid event handler:', err.message);
470
+ }
471
+ }
196
472
  return this;
197
473
  }
198
474
 
199
475
  bindState(target, ev, fn) {
200
- if (!this.attrs.id) this.id();
201
- if (!target.attrs.id) target.id();
202
- this.events.push({ event: ev, id: this.attrs.id, targetId: target.attrs.id, fn });
203
- this.hydrate = true;
476
+ try {
477
+ sanitizeFunctionSource(fn, CONFIG.maxEventFnSize);
478
+ if (!this.attrs.id) this.id();
479
+ if (!target.attrs.id) target.id();
480
+ this.events.push({
481
+ event: ev,
482
+ id: this.attrs.id,
483
+ targetId: target.attrs.id,
484
+ fn
485
+ });
486
+ this.hydrate = true;
487
+ } catch (err) {
488
+ if (CONFIG.mode === 'dev') {
489
+ console.error('Invalid state binding:', err.message);
490
+ }
491
+ }
204
492
  return this;
205
493
  }
206
494
  }
@@ -215,6 +503,12 @@ class Head {
215
503
  this.scripts = [];
216
504
  this.globalStyles = [];
217
505
  this.classStyles = {};
506
+ this.nonce = null;
507
+ }
508
+
509
+ setNonce(nonce) {
510
+ this.nonce = nonce;
511
+ return this;
218
512
  }
219
513
 
220
514
  setTitle(t) {
@@ -223,48 +517,75 @@ class Head {
223
517
  }
224
518
 
225
519
  addMeta(m) {
226
- this.metas.push(m);
520
+ if (m && typeof m === 'object') {
521
+ this.metas.push(m);
522
+ }
227
523
  return this;
228
524
  }
229
525
 
230
526
  addLink(l) {
231
- if (!this.links.includes(l)) this.links.push(l);
527
+ if (l && typeof l === 'string' && !this.links.includes(l)) {
528
+ this.links.push(l);
529
+ }
232
530
  return this;
233
531
  }
234
532
 
235
533
  addStyle(s) {
236
- this.styles.push(s);
534
+ if (s && typeof s === 'string') {
535
+ this.styles.push(s);
536
+ }
237
537
  return this;
238
538
  }
239
539
 
240
540
  addScript(s) {
241
- this.scripts.push(s);
541
+ if (s && typeof s === 'string') {
542
+ this.scripts.push(s);
543
+ }
242
544
  return this;
243
545
  }
244
546
 
245
547
  globalCss(selector, rules) {
246
- let cssStr = selector + "{";
548
+ if (!selector || !rules || typeof rules !== 'object') return this;
549
+
550
+ const cssRules = [];
247
551
  for (const k in rules) {
248
- cssStr += toKebab(k) + ":" + rules[k] + ";";
552
+ const key = toKebab(k);
553
+ const value = sanitizeCssValue(rules[k]);
554
+ cssRules.push(`${key}:${value}`);
555
+ }
556
+
557
+ if (cssRules.length > 0) {
558
+ const cssStr = `${selector}{${cssRules.join(';')};}`;
559
+ this.globalStyles.push(cssStr);
249
560
  }
250
- cssStr += "}";
251
- this.globalStyles.push(cssStr);
561
+
252
562
  return this;
253
563
  }
254
564
 
255
565
  addClass(name, rules) {
256
- let cssStr = "";
566
+ if (!name || !rules || typeof rules !== 'object') return this;
567
+
568
+ const cssRules = [];
257
569
  for (const k in rules) {
258
- cssStr += toKebab(k) + ":" + rules[k] + ";";
570
+ const key = toKebab(k);
571
+ const value = sanitizeCssValue(rules[k]);
572
+ cssRules.push(`${key}:${value}`);
573
+ }
574
+
575
+ if (cssRules.length > 0) {
576
+ this.classStyles[name] = cssRules.join(';') + ';';
259
577
  }
260
- this.classStyles[name] = cssStr;
578
+
261
579
  return this;
262
580
  }
263
581
 
264
582
  render() {
265
- const parts = ['<meta charset="UTF-8"><title>', this.title, '</title>'];
583
+ const parts = [];
584
+ const nonceAttr = this.nonce ? ` nonce="${escapeHtml(this.nonce)}"` : '';
585
+
586
+ parts.push('<meta charset="UTF-8">');
587
+ parts.push('<title>', this.title, '</title>');
266
588
 
267
- // Meta tags
268
589
  const metaLen = this.metas.length;
269
590
  for (let i = 0; i < metaLen; i++) {
270
591
  const m = this.metas[i];
@@ -275,38 +596,44 @@ class Head {
275
596
  parts.push('>');
276
597
  }
277
598
 
278
- // Links
279
599
  const linkLen = this.links.length;
280
600
  for (let i = 0; i < linkLen; i++) {
281
601
  parts.push('<link rel="stylesheet" href="', escapeHtml(this.links[i]), '">');
282
602
  }
283
603
 
284
- // Styles
285
- parts.push('<style>');
286
- for (const name in this.classStyles) {
287
- parts.push('.', toKebab(name), '{', this.classStyles[name], '}');
288
- }
289
-
290
- const globalLen = this.globalStyles.length;
291
- for (let i = 0; i < globalLen; i++) {
292
- parts.push(this.globalStyles[i]);
293
- }
294
-
295
- const styleLen = this.styles.length;
296
- for (let i = 0; i < styleLen; i++) {
297
- parts.push(this.styles[i]);
604
+ if (this.hasStyles()) {
605
+ parts.push('<style', nonceAttr, '>');
606
+
607
+ for (const name in this.classStyles) {
608
+ parts.push('.', toKebab(name), '{', this.classStyles[name], '}');
609
+ }
610
+
611
+ const globalLen = this.globalStyles.length;
612
+ for (let i = 0; i < globalLen; i++) {
613
+ parts.push(this.globalStyles[i]);
614
+ }
615
+
616
+ const styleLen = this.styles.length;
617
+ for (let i = 0; i < styleLen; i++) {
618
+ parts.push(this.styles[i]);
619
+ }
620
+
621
+ parts.push('</style>');
298
622
  }
299
623
 
300
- parts.push('</style>');
301
-
302
- // Scripts
303
624
  const scriptLen = this.scripts.length;
304
625
  for (let i = 0; i < scriptLen; i++) {
305
- parts.push('<script src="', escapeHtml(this.scripts[i]), '"></script>');
626
+ parts.push('<script', nonceAttr, ' src="', escapeHtml(this.scripts[i]), '"></script>');
306
627
  }
307
628
 
308
629
  return parts.join('');
309
630
  }
631
+
632
+ hasStyles() {
633
+ return Object.keys(this.classStyles).length > 0 ||
634
+ this.globalStyles.length > 0 ||
635
+ this.styles.length > 0;
636
+ }
310
637
  }
311
638
 
312
639
  /* ---------------- LRU CACHE ---------------- */
@@ -317,7 +644,12 @@ class LRUCache {
317
644
  }
318
645
 
319
646
  get(key) {
320
- if (!this.cache.has(key)) return null;
647
+ if (!this.cache.has(key)) {
648
+ metrics.increment('cache.miss');
649
+ return null;
650
+ }
651
+
652
+ metrics.increment('cache.hit');
321
653
  const value = this.cache.get(key);
322
654
  this.cache.delete(key);
323
655
  this.cache.set(key, value);
@@ -330,8 +662,10 @@ class LRUCache {
330
662
  } else if (this.cache.size >= this.limit) {
331
663
  const firstKey = this.cache.keys().next().value;
332
664
  this.cache.delete(firstKey);
665
+ metrics.increment('cache.eviction');
333
666
  }
334
667
  this.cache.set(key, value);
668
+ metrics.increment('cache.set');
335
669
  }
336
670
 
337
671
  clear() {
@@ -339,17 +673,43 @@ class LRUCache {
339
673
  }
340
674
 
341
675
  delete(key) {
342
- this.cache.delete(key);
676
+ return this.cache.delete(key);
343
677
  }
344
678
 
345
679
  has(key) {
346
680
  return this.cache.has(key);
347
681
  }
682
+
683
+ get size() {
684
+ return this.cache.size;
685
+ }
348
686
  }
349
687
 
350
688
  const responseCache = new LRUCache(CONFIG.cacheLimit);
351
689
  const inFlightCache = new Map();
352
690
 
691
+ /* ---------------- ENCRYPTION ---------------- */
692
+ // Simple Base64 obfuscation for JSON data
693
+ // NOTE: This is obfuscation, NOT cryptographic security!
694
+ function generateEncryptionKey() {
695
+ // Not needed for Base64, but kept for API compatibility
696
+ return 1;
697
+ }
698
+
699
+ function encryptString(str, key) {
700
+ // Simple Base64 is fast and sufficient for obfuscation.
701
+ // Reversing large strings is too expensive for the client.
702
+ if (typeof Buffer !== 'undefined') {
703
+ return Buffer.from(str, 'utf-8').toString('base64');
704
+ }
705
+ return btoa(unescape(encodeURIComponent(str)));
706
+ }
707
+
708
+ function getDecryptScript() {
709
+ // Optimized decoder: Just decode Base64 (O(1) complexity relative to string ops)
710
+ return `var _d=function(e){return decodeURIComponent(escape(atob(e)));};`;
711
+ }
712
+
353
713
  /* ---------------- DOCUMENT ---------------- */
354
714
  class Document {
355
715
  constructor(options = {}) {
@@ -357,8 +717,21 @@ class Document {
357
717
  this.head = new Head();
358
718
  this._ridGen = createRidGenerator();
359
719
  this._stateStore = {};
720
+ this._globalState = {};
360
721
  this._useResponseCache = options.cache ?? false;
361
722
  this._cacheKey = options.cacheKey || null;
723
+ this._nonce = options.nonce || null;
724
+ this._oncreateCallbacks = [];
725
+ this._lastRendered = "";
726
+
727
+ if (this._nonce) {
728
+ this.head.setNonce(this._nonce);
729
+ }
730
+ }
731
+
732
+ state(key, value) {
733
+ this._globalState[key] = value;
734
+ return this;
362
735
  }
363
736
 
364
737
  title(t) {
@@ -386,31 +759,107 @@ class Document {
386
759
  return this;
387
760
  }
388
761
 
389
- use(el) {
390
- this.body.push(el);
762
+ globalStyle(selector, rules) {
763
+ this.head.globalCss(selector, rules);
764
+ return this;
765
+ }
766
+
767
+ sharedClass(name, rules) {
768
+ this.head.addClass(name, rules);
769
+ return this;
770
+ }
771
+
772
+ defineClass(selector, rules, isRawSelector = false) {
773
+ if (!rules || typeof rules !== 'object') return this;
774
+
775
+ const cssRules = [];
776
+ for (const prop in rules) {
777
+ const key = toKebab(prop);
778
+ const val = sanitizeCssValue(rules[prop]);
779
+ cssRules.push(`${key}:${val}`);
780
+ }
781
+
782
+ if (cssRules.length > 0) {
783
+ const cssString = cssRules.join(';') + ';';
784
+ const finalSelector = isRawSelector ? selector : `.${selector}`;
785
+
786
+ if (isRawSelector) {
787
+ this.head.globalStyles.push(`${finalSelector}{${cssString}}`);
788
+ } else {
789
+ this.head.classStyles[selector] = cssString;
790
+ }
791
+ }
792
+
793
+ return this;
794
+ }
795
+
796
+ oncreate(fn) {
797
+ if (typeof fn !== 'function') {
798
+ throw new Error('[Document] .oncreate() expects a function.');
799
+ }
800
+
801
+ try {
802
+ sanitizeFunctionSource(fn, CONFIG.maxEventFnSize);
803
+ this._oncreateCallbacks.push(fn);
804
+ } catch (err) {
805
+ if (CONFIG.mode === 'dev') {
806
+ console.error('[Document] Invalid oncreate function:', err.message);
807
+ }
808
+ }
809
+
391
810
  return this;
392
811
  }
393
812
 
394
- /** Add multiple elements from a function (for composition/layouts). fn(doc) returns Element or Element[]. */
395
813
  useFragment(fn) {
396
- const els = fn(this);
397
- const arr = Array.isArray(els) ? els : [els];
398
- for (let i = 0; i < arr.length; i++) {
399
- const el = arr[i];
400
- if (el != null && el instanceof Element) this.use(el);
814
+ if (typeof fn !== 'function') return this;
815
+
816
+ try {
817
+ const els = fn(this);
818
+ const arr = Array.isArray(els) ? els : [els];
819
+
820
+ for (let i = 0; i < arr.length; i++) {
821
+ const el = arr[i];
822
+ if (el != null && el instanceof Element) {
823
+ this.body.push(el);
824
+ }
825
+ }
826
+ } catch (err) {
827
+ if (CONFIG.mode === 'dev') {
828
+ console.error('Fragment function error:', err);
829
+ }
401
830
  }
831
+
402
832
  return this;
403
833
  }
404
834
 
405
835
  createElement(tag) {
406
- return getPooled('elements', tag, this._ridGen, this._stateStore);
836
+ if (!tag || typeof tag !== 'string') {
837
+ throw new TypeError('Element tag must be a non-empty string');
838
+ }
839
+ const element = getPooled('elements', tag, this._ridGen, this._stateStore, this);
840
+ // Automatically add to document body when created from document
841
+ this.body.push(element);
842
+ return element;
407
843
  }
408
844
 
409
- /** Shorthand for createElement(tag). */
410
845
  create(tag) {
411
846
  return this.createElement(tag);
412
847
  }
413
848
 
849
+ child(tag) {
850
+ return this.createElement(tag);
851
+ }
852
+
853
+ output() {
854
+ return this._lastRendered;
855
+ }
856
+
857
+ save(path) {
858
+ const fs = require('fs');
859
+ fs.writeFileSync(path, this._lastRendered);
860
+ return this;
861
+ }
862
+
414
863
  clear() {
415
864
  const bodyLen = this.body.length;
416
865
  for (let i = 0; i < bodyLen; i++) {
@@ -419,14 +868,210 @@ class Document {
419
868
  }
420
869
  }
421
870
  this.body.length = 0;
422
- this._stateStore = {};
871
+
872
+ for (const key in this._stateStore) {
873
+ delete this._stateStore[key];
874
+ }
875
+ }
876
+
877
+ toJSON() {
878
+ const serializeElement = (el) => {
879
+ if (!(el instanceof Element)) {
880
+ // FIX: Unescape text here so client gets raw chars
881
+ return { type: 'text', content: unescapeHtml(String(el)) };
882
+ }
883
+
884
+ const serialized = {
885
+ type: 'element',
886
+ tag: el.tag,
887
+ attrs: { ...el.attrs },
888
+ children: el.children.map(serializeElement),
889
+ cssText: el.cssText,
890
+ hydrate: el.hydrate
891
+ };
892
+
893
+ if (el._state !== null) {
894
+ serialized.state = el._state;
895
+ }
896
+
897
+ if (el._stateBindings && el._stateBindings.length > 0) {
898
+ serialized.stateBindings = el._stateBindings;
899
+ }
900
+
901
+ if (el.events && el.events.length > 0) {
902
+ serialized.events = el.events.map(e => ({
903
+ event: e.event,
904
+ id: e.id,
905
+ targetId: e.targetId,
906
+ fn: e.fn.toString()
907
+ }));
908
+ }
909
+
910
+ if (el._computed) {
911
+ serialized.computed = el._computed.toString();
912
+ }
913
+
914
+ return serialized;
915
+ };
916
+
917
+ return {
918
+ version: '1.0',
919
+ title: this.head.title,
920
+ metas: this.head.metas,
921
+ links: this.head.links,
922
+ styles: this.head.styles,
923
+ scripts: this.head.scripts,
924
+ globalStyles: this.head.globalStyles,
925
+ classStyles: this.head.classStyles,
926
+ globalState: this._globalState,
927
+ oncreateCallbacks: this._oncreateCallbacks.map(fn => fn.toString()),
928
+ body: this.body.map(serializeElement)
929
+ };
930
+ }
931
+
932
+ static fromJSON(json) {
933
+ const doc = new Document();
934
+
935
+ if (typeof json === 'string') {
936
+ try {
937
+ json = JSON.parse(json);
938
+ } catch (e) {
939
+ throw new Error('[Sculptor] Invalid JSON string provided to fromJSON');
940
+ }
941
+ }
942
+
943
+ if (!json || typeof json !== 'object') {
944
+ throw new Error('[Sculptor] fromJSON requires a valid JSON object');
945
+ }
946
+
947
+ // Validate minimum required fields
948
+ if (!json.version) {
949
+ throw new Error('[Sculptor] Invalid JSON: missing version field');
950
+ }
951
+
952
+ if (!Array.isArray(json.body)) {
953
+ throw new Error('[Sculptor] Invalid JSON: body must be an array');
954
+ }
955
+
956
+ // Restore head
957
+ doc.head.title = json.title || 'Document';
958
+ doc.head.metas = json.metas || [];
959
+ doc.head.links = json.links || [];
960
+ doc.head.styles = json.styles || [];
961
+ doc.head.scripts = json.scripts || [];
962
+ doc.head.globalStyles = json.globalStyles || [];
963
+ doc.head.classStyles = json.classStyles || {};
964
+
965
+ // Restore global state
966
+ doc._globalState = json.globalState || {};
967
+
968
+ // Restore oncreate callbacks
969
+ if (json.oncreateCallbacks && Array.isArray(json.oncreateCallbacks)) {
970
+ for (const fnStr of json.oncreateCallbacks) {
971
+ try {
972
+ // eslint-disable-next-line no-eval
973
+ const fn = eval(`(${fnStr})`);
974
+ doc._oncreateCallbacks.push(fn);
975
+ } catch (err) {
976
+ if (CONFIG.mode === 'dev') {
977
+ console.error('Failed to restore oncreate callback:', err);
978
+ }
979
+ }
980
+ }
981
+ }
982
+
983
+ // Restore body
984
+ const deserializeElement = (node) => {
985
+ if (node.type === 'text') {
986
+ return node.content;
987
+ }
988
+
989
+ const el = getPooled('elements', node.tag, doc._ridGen, doc._stateStore, doc);
990
+
991
+ if (node.attrs) {
992
+ for (const key in node.attrs) {
993
+ el.attrs[key] = node.attrs[key];
994
+ }
995
+ }
996
+
997
+ if (node.cssText) {
998
+ el.cssText = node.cssText;
999
+ }
1000
+
1001
+ if (node.state !== undefined) {
1002
+ el._state = node.state;
1003
+ if (el.attrs.id) {
1004
+ doc._stateStore[el.attrs.id] = node.state;
1005
+ }
1006
+ }
1007
+
1008
+ if (node.stateBindings) {
1009
+ el._stateBindings = node.stateBindings;
1010
+ }
1011
+
1012
+ if (node.computed) {
1013
+ try {
1014
+ // eslint-disable-next-line no-eval
1015
+ el._computed = eval(`(${node.computed})`);
1016
+ } catch (err) {
1017
+ if (CONFIG.mode === 'dev') {
1018
+ console.error('Failed to restore computed function:', err);
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ if (node.events && Array.isArray(node.events)) {
1024
+ for (const evt of node.events) {
1025
+ try {
1026
+ // eslint-disable-next-line no-eval
1027
+ const fn = eval(`(${evt.fn})`);
1028
+ el.events.push({
1029
+ event: evt.event,
1030
+ id: evt.id,
1031
+ targetId: evt.targetId,
1032
+ fn: fn
1033
+ });
1034
+ } catch (err) {
1035
+ if (CONFIG.mode === 'dev') {
1036
+ console.error('Failed to restore event handler:', err);
1037
+ }
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ el.hydrate = node.hydrate || false;
1043
+
1044
+ if (node.children && Array.isArray(node.children)) {
1045
+ for (const child of node.children) {
1046
+ const childEl = deserializeElement(child);
1047
+ el.children.push(childEl);
1048
+ }
1049
+ }
1050
+
1051
+ return el;
1052
+ };
1053
+
1054
+ if (json.body && Array.isArray(json.body)) {
1055
+ for (const node of json.body) {
1056
+ const el = deserializeElement(node);
1057
+ doc.body.push(el);
1058
+ }
1059
+ }
1060
+
1061
+ return doc;
423
1062
  }
424
1063
 
425
1064
  render() {
1065
+ const startTime = CONFIG.enableMetrics ? Date.now() : 0;
1066
+
426
1067
  if (this._useResponseCache && this._cacheKey) {
427
1068
  const cached = responseCache.get(this._cacheKey);
428
1069
  if (cached) {
429
1070
  this.clear();
1071
+ if (CONFIG.enableMetrics) {
1072
+ metrics.timing('render.cached', Date.now() - startTime);
1073
+ }
1074
+ this._lastRendered = cached;
430
1075
  return cached;
431
1076
  }
432
1077
  }
@@ -435,34 +1080,242 @@ class Document {
435
1080
  events: getPooled('arrays'),
436
1081
  states: getPooled('arrays'),
437
1082
  styles: [],
438
- computed: getPooled('arrays')
1083
+ computed: getPooled('arrays'),
1084
+ stateBindings: getPooled('arrays'),
1085
+ oncreates: this._oncreateCallbacks,
1086
+ globalState: this._globalState,
1087
+ nonce: this._nonce
439
1088
  };
440
1089
 
441
1090
  const bodyParts = [];
442
1091
  const bodyLen = this.body.length;
443
1092
  for (let i = 0; i < bodyLen; i++) {
444
- bodyParts.push(renderNode(this.body[i], ctx));
1093
+ const rendered = renderNode(this.body[i], ctx);
1094
+ if (rendered) bodyParts.push(rendered);
445
1095
  }
446
1096
 
447
1097
  const bodyHTML = bodyParts.join('');
448
1098
  const headHTML = this.head.render();
449
- const stylesHTML = ctx.styles.length > 0 ? '<style>' + ctx.styles.join('') + '</style>' : '';
1099
+ const stylesHTML = ctx.styles.length > 0 ?
1100
+ `<style${this._nonce ? ` nonce="${escapeHtml(this._nonce)}"` : ''}>${ctx.styles.join('')}</style>` :
1101
+ '';
450
1102
  const clientJS = compileClient(ctx);
451
1103
 
452
- // FIX: clientJS is injected inside body, maintaining requirement.
453
- const html = `<!DOCTYPE html><html lang="en"><head>${headHTML}${stylesHTML}</head><body>${bodyHTML}${clientJS ? '<script>' + clientJS + '</script>' : ''}</body></html>`;
1104
+ const html = [
1105
+ '<!DOCTYPE html><html lang="en"><head>',
1106
+ headHTML,
1107
+ stylesHTML,
1108
+ '</head><body>',
1109
+ bodyHTML,
1110
+ clientJS ? `<script${this._nonce ? ` nonce="${escapeHtml(this._nonce)}"` : ''}>${clientJS}</script>` : '',
1111
+ '</body></html>'
1112
+ ].join('');
454
1113
 
455
1114
  recycle('arrays', ctx.events);
456
1115
  recycle('arrays', ctx.states);
457
1116
  recycle('arrays', ctx.computed);
1117
+ recycle('arrays', ctx.stateBindings);
1118
+
1119
+ const result = CONFIG.mode === "prod" ? minHTML(html) : html;
1120
+
1121
+ if (this._useResponseCache && this._cacheKey) {
1122
+ responseCache.set(this._cacheKey, result);
1123
+ }
1124
+
1125
+ this._lastRendered = result;
1126
+ this.clear();
1127
+
1128
+ if (CONFIG.enableMetrics) {
1129
+ metrics.timing('render.total', Date.now() - startTime);
1130
+ metrics.increment('render.count');
1131
+ }
1132
+
1133
+ return result;
1134
+ }
1135
+
1136
+ renderJSON(varNameOrOptions, options) {
1137
+ let jsonVarName = '__SCULPTOR_DATA__';
1138
+ let opts = {};
1139
+
1140
+ if (typeof varNameOrOptions === 'string') {
1141
+ jsonVarName = varNameOrOptions;
1142
+ opts = options || {};
1143
+ } else if (typeof varNameOrOptions === 'object' && varNameOrOptions !== null) {
1144
+ opts = varNameOrOptions;
1145
+ jsonVarName = opts.varName || '__SCULPTOR_DATA__';
1146
+ }
1147
+
1148
+ const startTime = CONFIG.enableMetrics ? Date.now() : 0;
1149
+
1150
+ // Check Cache
1151
+ if (this._useResponseCache && this._cacheKey) {
1152
+ const cached = responseCache.get(this._cacheKey);
1153
+ if (cached) {
1154
+ this.clear();
1155
+ if (CONFIG.enableMetrics) metrics.timing('render.cached', Date.now() - startTime);
1156
+ this._lastRendered = cached;
1157
+ return cached;
1158
+ }
1159
+ }
1160
+
1161
+ const jsonData = this.toJSON();
1162
+ const eventHandlers = {};
1163
+
1164
+ // Extract Event Handlers (Deduplication)
1165
+ const extractHandlers = (node) => {
1166
+ if (node.type === 'element') {
1167
+ if (node.events && Array.isArray(node.events)) {
1168
+ node.events = node.events.map(evt => {
1169
+ const handlerId = '_h' + hash(evt.fn.toString() + evt.id + evt.event);
1170
+ eventHandlers[handlerId] = evt.fn.toString();
1171
+ return {
1172
+ event: evt.event,
1173
+ id: evt.id,
1174
+ targetId: evt.targetId,
1175
+ handlerId: handlerId
1176
+ };
1177
+ });
1178
+ }
1179
+ if (node.children) node.children.forEach(child => extractHandlers(child));
1180
+ }
1181
+ };
1182
+
1183
+ jsonData.body.forEach(node => extractHandlers(node));
1184
+
1185
+ const jsonStr = JSON.stringify(jsonData);
1186
+ const obfuscate = opts.obfuscate === true;
1187
+ const headHTML = this.head.render();
1188
+
1189
+ const parts = [
1190
+ '<!DOCTYPE html><html lang="en"><head>',
1191
+ headHTML,
1192
+ `<script${this._nonce ? ` nonce="${escapeHtml(this._nonce)}"` : ''}>`,
1193
+ ];
1194
+
1195
+ // Inject Event Handlers (Safe Scope)
1196
+ if (Object.keys(eventHandlers).length > 0) {
1197
+ parts.push('window.__HANDLERS__={');
1198
+ const handlerEntries = Object.entries(eventHandlers);
1199
+ for (let i = 0; i < handlerEntries.length; i++) {
1200
+ const [id, fnStr] = handlerEntries[i];
1201
+ parts.push(`"${id}":${fnStr}`);
1202
+ if (i < handlerEntries.length - 1) parts.push(',');
1203
+ }
1204
+ parts.push('};');
1205
+ }
1206
+
1207
+ // Inject JSON Data (Fast Decode)
1208
+ if (obfuscate) {
1209
+ const obfuscated = encryptString(jsonStr, true);
1210
+ parts.push(getDecryptScript(), `window.${jsonVarName}=JSON.parse(_d("${obfuscated}"));`);
1211
+ } else {
1212
+ parts.push(`window.${jsonVarName}=${jsonStr};`);
1213
+ }
1214
+
1215
+ parts.push('</script></head><body>');
1216
+
1217
+ // ---------------- HYDRATION ENGINE (Client Side) ---------------- //
1218
+ parts.push(
1219
+ `<script${this._nonce ? ` nonce="${escapeHtml(this._nonce)}"` : ''}>`,
1220
+ `(function(){`,
1221
+ `var data=window.${jsonVarName};`,
1222
+ `if(!data)return;`,
1223
+
1224
+ // HELPER: Compiler (Performance: Compiles string -> Function ONCE)
1225
+ `var compile=function(s){try{return new Function('return ('+s+')')();}catch(e){return function(){};}};`,
1226
+
1227
+ // 1. INJECT STYLES
1228
+ `var css='';`,
1229
+ `if(data.classStyles){for(var n in data.classStyles)css+='.'+n+'{'+data.classStyles[n]+'}';}`,
1230
+ `if(data.globalStyles){data.globalStyles.forEach(function(s){css+=s;});}`,
1231
+ `if(css){var s=document.createElement('style');s.textContent=css;document.head.appendChild(s);}`,
1232
+
1233
+ // 2. DOM BUILDER (Recursion)
1234
+ `var dynamicCss = '';`,
1235
+ `function buildEl(node){`,
1236
+ `if(node.type==='text') return document.createTextNode(node.content);`, // Safe: createTextNode handles escaping
1237
+ `var el=document.createElement(node.tag);`,
1238
+ `if(node.attrs){for(var k in node.attrs)el.setAttribute(k,node.attrs[k]);}`,
1239
+ `if(node.cssText){ dynamicCss += node.cssText; }`,
1240
+ `if(node.children){for(var i=0;i<node.children.length;i++){`,
1241
+ `el.appendChild(buildEl(node.children[i]));`,
1242
+ `}}`,
1243
+ `return el;`,
1244
+ `}`,
1245
+
1246
+ // 3. REACTIVE STATE PROXY
1247
+ `if(data.globalState){`,
1248
+ `var _cbs={};`,
1249
+ `window.watchState=function(k,f){(_cbs[k]=_cbs[k]||[]).push(f);};`,
1250
+ `window.State=new Proxy(data.globalState,{`,
1251
+ `set:function(t,k,v){if(t[k]===v)return true;t[k]=v;if(_cbs[k])_cbs[k].forEach(function(f){f(v);});return true;}`,
1252
+ `});}`,
1253
+
1254
+ // 4. RENDER BODY
1255
+ `if(data.body){`,
1256
+ `for(var i=0;i<data.body.length;i++){document.body.appendChild(buildEl(data.body[i]));}`,
1257
+ `}`,
1258
+
1259
+ // 5. INJECT DYNAMIC CSS (Buffered)
1260
+ `if(dynamicCss){var s=document.createElement('style');s.textContent=dynamicCss;document.head.appendChild(s);}`,
1261
+
1262
+ // 6. HYDRATION (Events & Bindings)
1263
+ `function hydrateNode(node){`,
1264
+ `if(node.type!=='element')return;`,
1265
+ `var el=node.attrs&&node.attrs.id?document.getElementById(node.attrs.id):null;`,
1266
+ `if(el){`,
1267
+
1268
+ // State Bindings (Optimized: Compile Once, Run Many)
1269
+ `if(node.stateBindings){`,
1270
+ `node.stateBindings.forEach(function(b){`,
1271
+ `var fn=compile(b.templateFn);`, // Compile here
1272
+ `window.watchState(b.stateKey,function(val){try{el.textContent=fn(val);}catch(e){}});`,
1273
+ `if(window.State[b.stateKey]!==undefined){try{el.textContent=fn(window.State[b.stateKey]);}catch(e){}}`,
1274
+ `});`,
1275
+ `}`,
1276
+ `}`,
1277
+
1278
+ // Events (Delegated via Handlers)
1279
+ `if(node.events){`,
1280
+ `node.events.forEach(function(evt){`,
1281
+ `var tid=evt.targetId||evt.id;`,
1282
+ `var te=document.getElementById(tid);`,
1283
+ `if(te&&evt.handlerId&&window.__HANDLERS__[evt.handlerId]){`,
1284
+ `try{te.addEventListener(evt.event,window.__HANDLERS__[evt.handlerId]);}catch(e){}`,
1285
+ `}`,
1286
+ `});`,
1287
+ `}`,
1288
+
1289
+ // Recurse Children
1290
+ `if(node.children){node.children.forEach(function(c){hydrateNode(c);});}`,
1291
+ `}`,
1292
+
1293
+ `if(data.body){data.body.forEach(function(n){hydrateNode(n);});}`,
1294
+
1295
+ // OnCreate Callbacks
1296
+ `if(data.oncreateCallbacks){`,
1297
+ `data.oncreateCallbacks.forEach(function(f){try{compile(f)();}catch(e){console.error(e);}});`,
1298
+ `}`,
1299
+ `})();`,
1300
+ '</script></body></html>'
1301
+ );
458
1302
 
1303
+ const html = parts.join('');
1304
+ // Use safer minification
459
1305
  const result = CONFIG.mode === "prod" ? minHTML(html) : html;
460
1306
 
461
1307
  if (this._useResponseCache && this._cacheKey) {
462
1308
  responseCache.set(this._cacheKey, result);
463
1309
  }
464
1310
 
1311
+ this._lastRendered = result;
465
1312
  this.clear();
1313
+
1314
+ if (CONFIG.enableMetrics) {
1315
+ metrics.timing('render.total', Date.now() - startTime);
1316
+ metrics.increment('render.count');
1317
+ }
1318
+
466
1319
  return result;
467
1320
  }
468
1321
  }
@@ -475,12 +1328,15 @@ const voidElements = new Set([
475
1328
 
476
1329
  function renderNode(n, ctx) {
477
1330
  if (n == null) return '';
478
- if (!(n instanceof Element)) return n;
1331
+ if (!(n instanceof Element)) return String(n);
479
1332
 
480
1333
  const parts = ['<', n.tag];
481
1334
 
482
1335
  for (const k in n.attrs) {
483
- parts.push(' ', toKebab(k), '="', escapeHtml(n.attrs[k]), '"');
1336
+ const value = n.attrs[k];
1337
+ if (value != null) {
1338
+ parts.push(' ', toKebab(k), '="', escapeHtml(value), '"');
1339
+ }
484
1340
  }
485
1341
 
486
1342
  parts.push('>');
@@ -492,14 +1348,26 @@ function renderNode(n, ctx) {
492
1348
  }
493
1349
 
494
1350
  if (n._computed) {
495
- ctx.computed.push({ id: n.attrs.id, fn: n._computed.toString() });
1351
+ try {
1352
+ const fnSource = sanitizeFunctionSource(n._computed, CONFIG.maxComputedFnSize);
1353
+ ctx.computed.push({ id: n.attrs.id, fn: fnSource });
1354
+ } catch (err) {
1355
+ if (CONFIG.mode === 'dev') {
1356
+ console.error('Computed function validation failed:', err);
1357
+ }
1358
+ }
1359
+ }
1360
+
1361
+ // Collect state bindings
1362
+ if (n._stateBindings && n._stateBindings.length > 0) {
1363
+ ctx.stateBindings.push(...n._stateBindings);
496
1364
  }
497
1365
 
498
- // FIX: Properly skip children for void elements to avoid illegal HTML
499
1366
  if (!voidElements.has(n.tag)) {
500
1367
  const childLen = n.children.length;
501
1368
  for (let i = 0; i < childLen; i++) {
502
- parts.push(renderNode(n.children[i], ctx));
1369
+ const rendered = renderNode(n.children[i], ctx);
1370
+ if (rendered) parts.push(rendered);
503
1371
  }
504
1372
  parts.push('</', n.tag, '>');
505
1373
  }
@@ -517,102 +1385,247 @@ function compileClient(ctx) {
517
1385
  const hasStates = ctx.states.length > 0;
518
1386
  const hasComputed = ctx.computed.length > 0;
519
1387
  const hasEvents = ctx.events.length > 0;
1388
+ const hasOncreates = ctx.oncreates && ctx.oncreates.length > 0;
1389
+ const hasGlobalState = ctx.globalState && Object.keys(ctx.globalState).length > 0;
1390
+ const hasStateBindings = ctx.stateBindings && ctx.stateBindings.length > 0;
520
1391
 
521
- if (!hasStates && !hasComputed && !hasEvents) {
1392
+ if (!hasStates && !hasComputed && !hasEvents && !hasOncreates && !hasGlobalState && !hasStateBindings) {
522
1393
  return '';
523
1394
  }
524
1395
 
525
- // FIX: Moved helper functions inside the scope to prevent global pollution
1396
+ const namespace = '_ssr' + Date.now().toString(36);
1397
+
526
1398
  const parts = [
527
- 'window.state={};',
528
- 'document.addEventListener("DOMContentLoaded",function(){',
529
- 'const getById=id=>document.getElementById(id);'
1399
+ '(function(){',
1400
+ `var ${namespace}={state:{}};`,
1401
+ 'var getById=function(id){return document.getElementById(id);};'
530
1402
  ];
531
1403
 
532
- // States (use .value for input/textarea, .textContent for others)
533
- const stateLen = ctx.states.length;
534
- const valueProp = (tag) => (tag === 'input' || tag === 'textarea' ? 'value' : 'textContent');
535
- for (let i = 0; i < stateLen; i++) {
536
- const s = ctx.states[i];
537
- const prop = valueProp(s.tag || '');
1404
+ // Initialize global reactive state with callbacks
1405
+ if (hasGlobalState || hasStateBindings) {
538
1406
  parts.push(
539
- 'window.state["', s.id, '"]=', JSON.stringify(s.value), ';',
540
- '(function(){var _el=getById("', s.id, '");if(_el)_el.', prop, '=window.state["', s.id, '"];})();'
1407
+ 'var _cbs={};',
1408
+ 'window.watchState=function(k,f){(_cbs[k]=_cbs[k]||[]).push(f);};',
1409
+ 'window.State=new Proxy(' + JSON.stringify(ctx.globalState || {}) + ',{',
1410
+ 'set:function(t,k,v){',
1411
+ 'if(t[k]===v)return true;',
1412
+ 't[k]=v;',
1413
+ 'if(_cbs[k])_cbs[k].forEach(function(f){f(v);});',
1414
+ 'return true;',
1415
+ '}',
1416
+ '});'
541
1417
  );
542
1418
  }
543
1419
 
544
- // Computed
1420
+ const stateLen = ctx.states.length;
1421
+ if (stateLen > 0) {
1422
+ parts.push('var initStates=function(){');
1423
+
1424
+ for (let i = 0; i < stateLen; i++) {
1425
+ const s = ctx.states[i];
1426
+ const prop = (s.tag === 'input' || s.tag === 'textarea') ? 'value' : 'textContent';
1427
+ const safeValue = JSON.stringify(s.value);
1428
+
1429
+ parts.push(
1430
+ `${namespace}.state["${s.id}"]=${safeValue};`,
1431
+ `(function(){var el=getById("${s.id}");if(el)el.${prop}=${namespace}.state["${s.id}"];})();`
1432
+ );
1433
+ }
1434
+
1435
+ parts.push('};');
1436
+ }
1437
+
545
1438
  const computedLen = ctx.computed.length;
546
- for (let i = 0; i < computedLen; i++) {
547
- const c = ctx.computed[i];
548
- parts.push('(function(){var _el=getById("', c.id, '");if(_el)_el.textContent=(', c.fn, ')(window.state);})();');
1439
+ if (computedLen > 0) {
1440
+ parts.push('var initComputed=function(){');
1441
+
1442
+ for (let i = 0; i < computedLen; i++) {
1443
+ const c = ctx.computed[i];
1444
+ parts.push(
1445
+ `(function(){var el=getById("${c.id}");`,
1446
+ `if(el)try{el.textContent=(${c.fn})(${namespace}.state);}catch(e){console.error("Computed error:",e);}`,
1447
+ '})();'
1448
+ );
1449
+ }
1450
+
1451
+ parts.push('};');
1452
+ }
1453
+
1454
+ // State bindings - use watchState
1455
+ if (hasStateBindings) {
1456
+ parts.push('var initBindings=function(){');
1457
+
1458
+ for (let i = 0; i < ctx.stateBindings.length; i++) {
1459
+ const b = ctx.stateBindings[i];
1460
+ parts.push(
1461
+ `window.watchState('${b.stateKey}',function(val){`,
1462
+ `var el=getById('${b.id}');`,
1463
+ `if(el)try{el.textContent=(${b.templateFn})(val);}catch(e){console.error('Binding error:',e);}`,
1464
+ '});'
1465
+ );
1466
+
1467
+ // Initialize with current value
1468
+ parts.push(
1469
+ `(function(){`,
1470
+ `var el=getById('${b.id}');`,
1471
+ `if(el&&window.State['${b.stateKey}']!==undefined)`,
1472
+ `try{el.textContent=(${b.templateFn})(window.State['${b.stateKey}']);}catch(e){}`,
1473
+ '})();'
1474
+ );
1475
+ }
1476
+
1477
+ parts.push('};');
549
1478
  }
550
1479
 
551
- // Events
552
1480
  const eventLen = ctx.events.length;
553
- for (let i = 0; i < eventLen; i++) {
554
- const e = ctx.events[i];
555
- let f = e.fn.toString();
556
- if (e.targetId) {
557
- f = f.replace(/__STATE_ID__/g, e.targetId);
1481
+ if (eventLen > 0) {
1482
+ parts.push('var initEvents=function(){');
1483
+
1484
+ for (let i = 0; i < eventLen; i++) {
1485
+ const e = ctx.events[i];
1486
+ let fnSource = e.fn.toString();
1487
+
1488
+ if (e.targetId) {
1489
+ fnSource = fnSource.replace(/__STATE_ID__/g, e.targetId);
1490
+ }
1491
+
1492
+ parts.push(
1493
+ `(function(){var el=getById("${e.id}");`,
1494
+ `if(el)try{el.addEventListener("${e.event}",${fnSource});}catch(err){console.error("Event handler error:",err);}`,
1495
+ '})();'
1496
+ );
558
1497
  }
559
- parts.push('(function(){var _el=getById("', e.id, '");if(_el)_el.addEventListener("', e.event, '",', f, ');})();');
1498
+
1499
+ parts.push('};');
560
1500
  }
561
1501
 
562
- parts.push('});');
1502
+ // Add oncreate callbacks
1503
+ if (hasOncreates) {
1504
+ parts.push('var initOncreate=function(){');
1505
+
1506
+ for (let i = 0; i < ctx.oncreates.length; i++) {
1507
+ const fn = ctx.oncreates[i];
1508
+ try {
1509
+ const fnSource = sanitizeFunctionSource(fn, CONFIG.maxEventFnSize);
1510
+ parts.push(`(${fnSource})();`);
1511
+ } catch (err) {
1512
+ if (CONFIG.mode === 'dev') {
1513
+ console.error('Oncreate validation failed:', err);
1514
+ }
1515
+ }
1516
+ }
1517
+
1518
+ parts.push('};');
1519
+ }
1520
+
1521
+ parts.push(
1522
+ 'if(document.readyState==="loading"){',
1523
+ 'document.addEventListener("DOMContentLoaded",function(){'
1524
+ );
1525
+
1526
+ if (stateLen > 0) parts.push('initStates();');
1527
+ if (computedLen > 0) parts.push('initComputed();');
1528
+ if (hasStateBindings) parts.push('initBindings();');
1529
+ if (eventLen > 0) parts.push('initEvents();');
1530
+ if (hasOncreates) parts.push('initOncreate();');
1531
+
1532
+ parts.push(
1533
+ '});',
1534
+ '}else{'
1535
+ );
1536
+
1537
+ if (stateLen > 0) parts.push('initStates();');
1538
+ if (computedLen > 0) parts.push('initComputed();');
1539
+ if (hasStateBindings) parts.push('initBindings();');
1540
+ if (eventLen > 0) parts.push('initEvents();');
1541
+ if (hasOncreates) parts.push('initOncreate();');
1542
+
1543
+ parts.push('}');
1544
+ parts.push(`window.${namespace}=${namespace};`);
1545
+ parts.push('})();');
1546
+
563
1547
  return parts.join('');
564
1548
  }
565
1549
 
566
1550
  /* ---------------- MIDDLEWARE HELPERS ---------------- */
567
- function createCachedRenderer(builderFn, cacheKeyOrFn) {
568
- return (req, res, next) => {
569
- const key = typeof cacheKeyOrFn === 'function' ? cacheKeyOrFn(req) : cacheKeyOrFn;
1551
+ function createCachedRenderer(builderFn, cacheKeyOrFn, options = {}) {
1552
+ if (typeof builderFn !== 'function') {
1553
+ throw new TypeError('Builder function is required');
1554
+ }
570
1555
 
571
- if (key == null || key === '') {
572
- try {
573
- const doc = builderFn(req);
1556
+ return async (req, res, next) => {
1557
+ try {
1558
+ const key = typeof cacheKeyOrFn === 'function' ? cacheKeyOrFn(req) : cacheKeyOrFn;
1559
+
1560
+ if (key == null || key === '') {
1561
+ const doc = await Promise.resolve(builderFn(req));
1562
+
574
1563
  if (!doc || !(doc instanceof Document)) {
575
1564
  return res.status(500).send('Internal Server Error');
576
1565
  }
1566
+
577
1567
  return res.send(doc.render());
578
- } catch (err) {
579
- return next(err);
580
1568
  }
581
- }
582
1569
 
583
- const cached = responseCache.get(key);
584
- if (cached) {
585
- return res.send(cached);
586
- }
1570
+ const cached = responseCache.get(key);
1571
+ if (cached) {
1572
+ metrics.increment('middleware.cache.hit');
1573
+ return res.send(cached);
1574
+ }
587
1575
 
588
- let promise = inFlightCache.get(key);
589
- if (!promise) {
590
- promise = Promise.resolve().then(() => {
591
- const doc = builderFn(req);
592
- if (!doc || !(doc instanceof Document)) {
593
- const err = new Error('Builder function must return a Document instance');
594
- err.status = 500;
595
- throw err;
596
- }
597
- doc._useResponseCache = true;
598
- doc._cacheKey = key;
599
- return doc.render();
600
- });
601
- inFlightCache.set(key, promise);
602
- promise.then((html) => {
603
- responseCache.set(key, html);
604
- }).finally(() => {
605
- inFlightCache.delete(key);
606
- });
607
- }
1576
+ metrics.increment('middleware.cache.miss');
1577
+
1578
+ let promise = inFlightCache.get(key);
1579
+
1580
+ if (!promise) {
1581
+ promise = Promise.resolve()
1582
+ .then(() => builderFn(req))
1583
+ .then((doc) => {
1584
+ if (!doc || !(doc instanceof Document)) {
1585
+ const err = new Error('Builder function must return a Document instance');
1586
+ err.status = 500;
1587
+ throw err;
1588
+ }
1589
+
1590
+ doc._useResponseCache = true;
1591
+ doc._cacheKey = key;
1592
+
1593
+ if (options.nonce && typeof options.nonce === 'function') {
1594
+ doc._nonce = options.nonce(req);
1595
+ }
1596
+
1597
+ return doc.render();
1598
+ });
1599
+
1600
+ inFlightCache.set(key, promise);
1601
+
1602
+ promise
1603
+ .then((html) => {
1604
+ responseCache.set(key, html);
1605
+ metrics.increment('middleware.cache.set');
1606
+ })
1607
+ .catch((err) => {
1608
+ if (CONFIG.mode === 'dev') {
1609
+ console.error('Render error:', err);
1610
+ }
1611
+ })
1612
+ .finally(() => {
1613
+ inFlightCache.delete(key);
1614
+ });
1615
+ } else {
1616
+ metrics.increment('middleware.stampede.prevented');
1617
+ }
608
1618
 
609
- promise.then((html) => res.send(html)).catch((err) => {
1619
+ const html = await promise;
1620
+ res.send(html);
1621
+
1622
+ } catch (err) {
610
1623
  if (err.status === 500) {
611
1624
  res.status(500).send('Internal Server Error');
612
1625
  } else {
613
1626
  next(err);
614
1627
  }
615
- });
1628
+ }
616
1629
  };
617
1630
  }
618
1631
 
@@ -620,66 +1633,132 @@ function clearCache(pattern) {
620
1633
  if (!pattern) {
621
1634
  responseCache.clear();
622
1635
  inFlightCache.clear();
1636
+ metrics.increment('cache.clear.all');
623
1637
  return;
624
1638
  }
625
1639
 
626
- const keysToDelete = [];
1640
+ const keysToDelete = new Set();
1641
+
627
1642
  for (const [key] of responseCache.cache) {
628
- if (key.includes(pattern)) keysToDelete.push(key);
1643
+ if (key.includes(pattern)) {
1644
+ keysToDelete.add(key);
1645
+ }
629
1646
  }
1647
+
630
1648
  for (const key of inFlightCache.keys()) {
631
- if (key.includes(pattern) && !keysToDelete.includes(key)) keysToDelete.push(key);
1649
+ if (key.includes(pattern)) {
1650
+ keysToDelete.add(key);
1651
+ }
632
1652
  }
633
- const len = keysToDelete.length;
634
- for (let i = 0; i < len; i++) {
635
- responseCache.delete(keysToDelete[i]);
636
- inFlightCache.delete(keysToDelete[i]);
1653
+
1654
+ for (const key of keysToDelete) {
1655
+ responseCache.delete(key);
1656
+ inFlightCache.delete(key);
637
1657
  }
1658
+
1659
+ metrics.increment('cache.clear.pattern', keysToDelete.size);
638
1660
  }
639
1661
 
640
- // Compression middleware
641
1662
  function enableCompression() {
642
1663
  return (req, res, next) => {
643
1664
  const acceptEncoding = req.headers['accept-encoding'];
644
1665
 
645
- if (acceptEncoding && acceptEncoding.includes('gzip')) {
646
- const originalSend = res.send;
647
- res.send = function(data) {
648
- if (typeof data === 'string' && data.length > 1024) {
649
- try {
650
- const zlib = require('zlib');
651
- const compressed = zlib.gzipSync(data);
1666
+ if (!acceptEncoding || !acceptEncoding.includes('gzip')) {
1667
+ return next();
1668
+ }
1669
+
1670
+ const originalSend = res.send;
1671
+
1672
+ res.send = function(data) {
1673
+ res.send = originalSend;
1674
+
1675
+ const contentType = this.getHeader('Content-Type') || '';
1676
+ const isCompressible =
1677
+ contentType.includes('text/html') ||
1678
+ contentType.includes('text/css') ||
1679
+ contentType.includes('text/javascript') ||
1680
+ contentType.includes('application/javascript') ||
1681
+ contentType.includes('application/json');
1682
+
1683
+ const shouldCompress =
1684
+ typeof data === 'string' &&
1685
+ data.length > 1024 &&
1686
+ isCompressible;
1687
+
1688
+ if (shouldCompress) {
1689
+ try {
1690
+ const zlib = require('zlib');
1691
+
1692
+ zlib.gzip(data, (err, compressed) => {
1693
+ if (err) {
1694
+ metrics.increment('compression.error');
1695
+ return originalSend.call(this, data);
1696
+ }
1697
+
652
1698
  this.setHeader('Content-Encoding', 'gzip');
653
1699
  this.setHeader('Content-Length', compressed.length);
1700
+ this.setHeader('Vary', 'Accept-Encoding');
1701
+ metrics.increment('compression.success');
1702
+ metrics.increment('compression.bytes.saved', data.length - compressed.length);
1703
+
654
1704
  return originalSend.call(this, compressed);
655
- } catch (err) {
656
- return originalSend.call(this, data);
657
- }
1705
+ });
1706
+
1707
+ return;
1708
+
1709
+ } catch (err) {
1710
+ metrics.increment('compression.error');
1711
+ return originalSend.call(this, data);
658
1712
  }
659
- return originalSend.call(this, data);
660
- };
661
- }
1713
+ }
1714
+
1715
+ return originalSend.call(this, data);
1716
+ };
662
1717
 
663
1718
  next();
664
1719
  };
665
1720
  }
666
1721
 
667
- // Warmup cache helper
668
- function warmupCache(routes) {
1722
+ async function warmupCache(routes) {
1723
+ if (!Array.isArray(routes)) {
1724
+ throw new TypeError('Routes must be an array');
1725
+ }
1726
+
669
1727
  const results = [];
670
1728
 
671
1729
  for (const route of routes) {
1730
+ if (!route || typeof route !== 'object') {
1731
+ results.push({ error: 'Invalid route object', success: false });
1732
+ continue;
1733
+ }
1734
+
672
1735
  const { key, builder } = route;
1736
+
1737
+ if (!key || !builder) {
1738
+ results.push({ key, error: 'Missing key or builder', success: false });
1739
+ continue;
1740
+ }
1741
+
673
1742
  try {
674
- const doc = builder();
1743
+ const doc = await Promise.resolve(builder());
1744
+
675
1745
  if (!doc || !(doc instanceof Document)) {
676
1746
  results.push({ key, error: 'Builder must return a Document instance', success: false });
677
1747
  continue;
678
1748
  }
1749
+
679
1750
  doc._useResponseCache = true;
680
1751
  doc._cacheKey = key;
1752
+
681
1753
  const html = doc.render();
682
- results.push({ key, size: html.length, success: true });
1754
+
1755
+ results.push({
1756
+ key,
1757
+ size: html.length,
1758
+ compressed: Math.floor(html.length * 0.3),
1759
+ success: true
1760
+ });
1761
+
683
1762
  } catch (err) {
684
1763
  results.push({ key, error: err.message, success: false });
685
1764
  }
@@ -688,18 +1767,44 @@ function warmupCache(routes) {
688
1767
  return results;
689
1768
  }
690
1769
 
691
- // Stats helper
692
1770
  function getCacheStats() {
693
1771
  return {
694
- size: responseCache.cache.size,
695
- limit: CONFIG.cacheLimit,
696
- usage: ((responseCache.cache.size / CONFIG.cacheLimit) * 100).toFixed(2) + '%',
697
- keys: Array.from(responseCache.cache.keys()),
698
- poolStats: {
1772
+ cache: {
1773
+ size: responseCache.size,
1774
+ limit: CONFIG.cacheLimit,
1775
+ usage: ((responseCache.size / CONFIG.cacheLimit) * 100).toFixed(2) + '%',
1776
+ keys: Array.from(responseCache.cache.keys())
1777
+ },
1778
+ inFlight: {
1779
+ size: inFlightCache.size,
1780
+ keys: Array.from(inFlightCache.keys())
1781
+ },
1782
+ pools: {
699
1783
  elements: pools.elements.length,
700
1784
  arrays: pools.arrays.length,
701
1785
  objects: pools.objects.length
702
- }
1786
+ },
1787
+ metrics: CONFIG.enableMetrics ? metrics.getStats() : null
1788
+ };
1789
+ }
1790
+
1791
+ function resetPools() {
1792
+ pools.elements.length = 0;
1793
+ pools.arrays.length = 0;
1794
+ pools.objects.length = 0;
1795
+ metrics.increment('pools.reset');
1796
+ }
1797
+
1798
+ function healthCheck() {
1799
+ return {
1800
+ status: 'ok',
1801
+ timestamp: Date.now(),
1802
+ config: {
1803
+ mode: CONFIG.mode,
1804
+ poolSize: CONFIG.poolSize,
1805
+ cacheLimit: CONFIG.cacheLimit
1806
+ },
1807
+ stats: getCacheStats()
703
1808
  };
704
1809
  }
705
1810
 
@@ -714,5 +1819,8 @@ module.exports = {
714
1819
  enableCompression,
715
1820
  responseCache,
716
1821
  warmupCache,
717
- getCacheStats
1822
+ getCacheStats,
1823
+ resetPools,
1824
+ healthCheck,
1825
+ metrics
718
1826
  };