@trebor/buildhtml 1.0.0

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 (4) hide show
  1. package/LICENSE.txt +15 -0
  2. package/README.md +329 -0
  3. package/index.js +718 -0
  4. package/package.json +37 -0
package/LICENSE.txt ADDED
@@ -0,0 +1,15 @@
1
+ Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
2
+
3
+ Copyright 2026 0trebor0
4
+
5
+ You are free to:
6
+
7
+ - Share — copy and redistribute the material in any medium or format
8
+ - Adapt — remix, transform, and build upon the material
9
+
10
+ Under the following terms:
11
+
12
+ - Attribution — You must give appropriate credit to 0trebor0, provide a link to the license, and indicate if changes were made.
13
+ - NonCommercial — You may not use the material for commercial purposes.
14
+
15
+ Full license text: https://creativecommons.org/licenses/by-nc/4.0/
package/README.md ADDED
@@ -0,0 +1,329 @@
1
+ ````markdown
2
+ # BuildHTML
3
+
4
+ **Zero-dependency, ultra-fast server-side rendering (SSR) compiler.**
5
+ *“Compile your HTML at lightning speed, without the bloat.”*
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ BuildHTML is a lightweight SSR compiler for Node.js. It allows you to build HTML on the server with minimal memory usage and blazing-fast performance—without relying on heavy frameworks. Perfect for custom Node servers, static site generators, or optimized Express apps.
12
+
13
+ - **Zero dependencies** – Only Node.js required.
14
+ - **Ultra-fast** – Optimized rendering and memory reuse.
15
+ - **SSR-ready** – Easily manage state, computed values, and client-side hydration.
16
+ - **Customizable** – Create elements, set styles, attach events, and more.
17
+
18
+ ---
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install @trebor/buildhtml
24
+ ````
25
+
26
+ or via GitHub:
27
+
28
+ ```bash
29
+ npm install github:0trebor0/buildhtml
30
+ ```
31
+
32
+ ---
33
+
34
+ ## Quick Start
35
+
36
+ ```javascript
37
+ const { Document } = require('@trebor/buildhtml');
38
+
39
+ // Create a new document
40
+ const doc = new Document();
41
+
42
+ // Add elements (use __STATE_ID__ + bindState so the handler works on the client)
43
+ const counter = doc.create('div').text('0').state(0);
44
+ const button = doc.create('button').text('Increment');
45
+ button.bindState(counter, 'click', function() {
46
+ const id = '__STATE_ID__';
47
+ const el = document.getElementById(id);
48
+ const val = parseInt(window.state[id] || 0, 10) + 1;
49
+ window.state[id] = val;
50
+ el.textContent = val;
51
+ });
52
+
53
+ // Add to document
54
+ doc.use(counter).use(button);
55
+
56
+ // Render HTML
57
+ const html = doc.render();
58
+ console.log(html);
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Features
64
+
65
+ * **Element creation**: `doc.create('div')` or `doc.createElement('div')`, `text()`, `append()`, `css()`.
66
+ * **Composition**: `doc.useFragment(fn)` – `fn(doc)` returns one or more elements to reuse layouts/headers/footers.
67
+ * **State management**: `.state(value)` for server-generated state (hydrates `textContent` on normal elements, `value` on `<input>`/`<textarea>`).
68
+ * **Computed values**: `.computed(fn)` for dynamic content.
69
+ * **Event binding**: `.on(event, fn)` and `.bindState(target, event, fn)`.
70
+ * **Optimized rendering**: Object pools, LRU caching, in-flight deduplication for cached routes, minified output in production.
71
+ * **Client-side hydration**: Automatically generates JS to update states and events in the browser.
72
+
73
+ ---
74
+
75
+ ## API Guide
76
+
77
+ ### Exports
78
+
79
+ ```javascript
80
+ const {
81
+ Document,
82
+ Element,
83
+ Head,
84
+ CONFIG,
85
+ createCachedRenderer,
86
+ clearCache,
87
+ enableCompression,
88
+ responseCache,
89
+ warmupCache,
90
+ getCacheStats
91
+ } = require('@trebor/buildhtml');
92
+ ```
93
+
94
+ ---
95
+
96
+ ### Document
97
+
98
+ Root HTML builder. Create with `new Document(options)`.
99
+
100
+ | Method | Description |
101
+ |--------|-------------|
102
+ | `title(t)` | Set page title (escaped). Returns `this`. |
103
+ | `addMeta(m)` | Add meta tag; `m` is an object, e.g. `{ name: 'description', content: '...' }`. |
104
+ | `addLink(href)` | Add `<link rel="stylesheet" href="...">` (deduplicated). |
105
+ | `addStyle(css)` | Add inline CSS string to `<head>`. |
106
+ | `addScript(src)` | Add `<script src="...">` to `<head>`. |
107
+ | `use(el)` | Append one element to the body. Returns `this`. |
108
+ | `useFragment(fn)` | Append multiple elements; `fn(doc)` returns a single `Element` or an array of `Element`s. Ignores `null`/non-Element. Returns `this`. |
109
+ | `create(tag)` | Create a pooled element (e.g. `'div'`, `'input'`). Tag is normalized to kebab-case. Shorthand for `createElement(tag)`. |
110
+ | `createElement(tag)` | Same as `create(tag)`. |
111
+ | `clear()` | Clear body and state store; recycle elements. |
112
+ | `render()` | Return full HTML string. If cache options are set, may return cached result or populate cache. Clears the document after render. |
113
+
114
+ **Constructor options**
115
+
116
+ | Option | Type | Description |
117
+ |--------|------|-------------|
118
+ | `cache` | `boolean` | If `true`, use response cache when `cacheKey` is set (e.g. by `createCachedRenderer`). |
119
+ | `cacheKey` | `string` | Key for response cache. |
120
+
121
+ **Example**
122
+
123
+ ```javascript
124
+ const doc = new Document({ cache: true, cacheKey: 'home' });
125
+ doc.title('Home').addMeta({ charset: 'UTF-8' });
126
+ doc.use(doc.create('main').append(doc.create('p').text('Hello')));
127
+ const html = doc.render();
128
+ ```
129
+
130
+ ---
131
+
132
+ ### Element
133
+
134
+ Created with `doc.create(tag)` or `doc.createElement(tag)`. All mutating methods return `this` for chaining.
135
+
136
+ | Method | Description |
137
+ |--------|-------------|
138
+ | `id(v?)` | Set `id` attribute. If `v` is omitted, assigns a unique id (required for state/events/computed). |
139
+ | `text(c)` | Append escaped text as a child. |
140
+ | `append(c)` | Append child: `Element` (rendered as subtree) or string (escaped). |
141
+ | `css(styles)` | Add scoped inline styles; `styles` is an object, e.g. `{ marginTop: '10px' }`. Keys are kebab-cased. Adds a hashed class and injects a `<style>` block. |
142
+ | `state(v)` | Set initial state for hydration. Element gets an id if missing. Hydration sets `textContent` (or `value` for `input`/`textarea`). |
143
+ | `computed(fn)` | Hydrate with `fn(window.state)`; `fn` is serialized and run in the browser. Element gets an id if missing. |
144
+ | `on(ev, fn)` | Attach client-side event; `ev` is event name (e.g. `'click'`). `fn` is serialized and run in the browser. Element gets an id if missing. |
145
+ | `bindState(target, ev, fn)` | Like `on(ev, fn)` but for updating another element’s state. In `fn`, use the literal string `'__STATE_ID__'` where you need the target’s id; it is replaced at compile time with `target`’s id. |
146
+
147
+ **Read-only**
148
+
149
+ - `el.attrs` – Object of attributes (e.g. `el.attrs.id`, `el.attrs.class`).
150
+ - `el.tag` – Normalized tag name (kebab-case).
151
+
152
+ **Example**
153
+
154
+ ```javascript
155
+ const div = doc.create('div').id('root').css({ padding: '1rem' }).text('Hello');
156
+ const btn = doc.create('button').text('Click').on('click', function() { console.log('ok'); });
157
+ div.append(btn);
158
+ ```
159
+
160
+ ---
161
+
162
+ ### Head
163
+
164
+ Accessed as `doc.head`. Usually configured via `Document` methods (`title`, `addMeta`, etc.). Advanced usage:
165
+
166
+ | Method | Description |
167
+ |--------|-------------|
168
+ | `setTitle(t)` | Set title (escaped). |
169
+ | `addMeta(m)` | Add one meta object. |
170
+ | `addLink(href)` | Add stylesheet link (no duplicates). |
171
+ | `addStyle(css)` | Add raw CSS string. |
172
+ | `addScript(src)` | Add script tag src. |
173
+ | `globalCss(selector, rules)` | Add a rule like `selector { ... }`; `rules` is an object (keys kebab-cased). |
174
+ | `addClass(name, rules)` | Define a class `.name` with `rules` object. |
175
+
176
+ ---
177
+
178
+ ### CONFIG
179
+
180
+ Global config object (read/write). Used by the compiler and caches.
181
+
182
+ | Property | Default | Description |
183
+ |----------|---------|-------------|
184
+ | `mode` | `'prod'` if `NODE_ENV=== 'production'`, else `'dev'` | `'prod'` minifies HTML. |
185
+ | `poolSize` | `150` | Max pooled items per type (elements, arrays, objects). |
186
+ | `cacheLimit` | `2000` | Max entries in response LRU cache. |
187
+ | `maxCssCache` | `1000` | Reserved. |
188
+ | `maxKebabCache` | `500` | Max kebab-case conversions cached. |
189
+ | `compression` | `true` | Used by `enableCompression` middleware. |
190
+
191
+ ---
192
+
193
+ ### createCachedRenderer(builderFn, cacheKeyOrFn)
194
+
195
+ Returns a middleware function `(req, res, next) => ...`.
196
+
197
+ - **builderFn**: `(req) => Document`. Must return a `Document` instance. The middleware sets cache options on it and calls `doc.render()`.
198
+ - **cacheKeyOrFn**: `string` or `(req) => string`. Cache key for the response. If it is `null`/`undefined`/`''`, caching is skipped and the document is built and sent once per request.
199
+ - Concurrent requests with the same key share one render (in-flight deduplication); the result is cached and sent to all waiters.
200
+
201
+ ---
202
+
203
+ ### clearCache(pattern?)
204
+
205
+ - **No argument**: Clears entire response cache and all in-flight keys.
206
+ - **`pattern` (string)**: Deletes every cache key that includes `pattern` (response cache and in-flight).
207
+
208
+ ---
209
+
210
+ ### enableCompression()
211
+
212
+ Returns a middleware that, when `Accept-Encoding` includes `gzip`, patches `res.send` to gzip string bodies longer than 1024 bytes. Call `next()` so the route still runs.
213
+
214
+ ---
215
+
216
+ ### responseCache
217
+
218
+ LRU cache instance used for full HTML responses. Methods: `get(key)`, `set(key, value)`, `has(key)`, `delete(key)`, `clear()`. Property `responseCache.cache` is the underlying `Map` (for iteration).
219
+
220
+ ---
221
+
222
+ ### warmupCache(routes)
223
+
224
+ Pre-renders routes to fill the response cache.
225
+
226
+ - **routes**: `Array<{ key: string, builder: () => Document }>`. `builder()` is called with no arguments and must return a `Document`.
227
+ - **Returns**: `Array<{ key, success: boolean, size?: number, error?: string }>`.
228
+
229
+ ---
230
+
231
+ ### getCacheStats()
232
+
233
+ Returns an object: `{ size, limit, usage, keys, poolStats }` for the response cache and pool counts.
234
+
235
+ ---
236
+
237
+ ## Quick BuildHTML Examples
238
+
239
+ Here’s how to use **BuildHTML** in an Express server for fast SSR.
240
+
241
+ ```javascript
242
+ const express = require('express');
243
+ const { Document, createCachedRenderer, clearCache, enableCompression } = require('@trebor/buildhtml');
244
+
245
+ const app = express();
246
+
247
+ // -----------------------------------------------------------------------------
248
+ // 1️⃣ Basic Route (No Cache)
249
+ // -----------------------------------------------------------------------------
250
+ app.get('/', (req, res) => {
251
+ const doc = new Document();
252
+ doc.title('Home Page');
253
+
254
+ const heading = doc.create('h1').text('Welcome to BuildHTML!');
255
+ const subtitle = doc.create('p').text('Ultra-fast server-side rendering');
256
+
257
+ doc.use(heading).use(subtitle);
258
+ res.send(doc.render());
259
+ });
260
+
261
+ // -----------------------------------------------------------------------------
262
+ // 2️⃣ Cached Static Route
263
+ // -----------------------------------------------------------------------------
264
+ app.get('/about', createCachedRenderer(() => {
265
+ const doc = new Document({ cache: true, cacheKey: 'about' });
266
+ doc.title('About Us');
267
+
268
+ const content = doc.create('p').text('This page is cached for performance.');
269
+ doc.use(content);
270
+ return doc;
271
+ }, 'about'));
272
+
273
+ // -----------------------------------------------------------------------------
274
+ // 3️⃣ Dynamic Route with Params (Cached per User)
275
+ // -----------------------------------------------------------------------------
276
+ app.get('/user/:name', createCachedRenderer((req) => {
277
+ const doc = new Document({ cache: true, cacheKey: `user-${req.params.name}` });
278
+ doc.title(`Profile - ${req.params.name}`);
279
+
280
+ const greeting = doc.create('h1').text(`Hello, ${req.params.name}!`);
281
+ doc.use(greeting);
282
+
283
+ return doc;
284
+ }, (req) => `user-${req.params.name}`));
285
+
286
+ // -----------------------------------------------------------------------------
287
+ // 4️⃣ Interactive Counter (No Cache, With State)
288
+ // -----------------------------------------------------------------------------
289
+ app.get('/counter', (req, res) => {
290
+ const doc = new Document();
291
+ doc.title('Counter App');
292
+
293
+ const counter = doc.create('div').state(0);
294
+ const incBtn = doc.create('button').text('+');
295
+ incBtn.bindState(counter, 'click', function() {
296
+ const id = '__STATE_ID__';
297
+ window.state[id] = parseInt(window.state[id]) + 1;
298
+ document.getElementById(id).textContent = window.state[id];
299
+ });
300
+ doc.use(counter).use(incBtn);
301
+
302
+ res.send(doc.render());
303
+ });
304
+
305
+ // Start server
306
+ app.listen(3000, () => console.log('Server running on http://localhost:3000'));
307
+ ````
308
+
309
+ ### Key Points
310
+
311
+ * **`Document`** – Core HTML builder.
312
+ * **`createCachedRenderer`** – Cache static or dynamic pages for ultra-fast responses.
313
+ * **Stateful elements** – `.state()` allows dynamic, interactive content.
314
+ * **Express-friendly** – Integrates with your server routes seamlessly.
315
+ * **`clearCache()`** – Manually clear cached pages when content changes.
316
+
317
+ ### Limitations
318
+
319
+ * **Hydration** – Event and computed handlers are serialized with `.toString()` and run in the browser. Don’t rely on closures over server-side variables; use `__STATE_ID__` with `bindState()` for target elements.
320
+ * **State display** – `.state()` hydrates `textContent` on normal elements and `value` on `<input>`/`<textarea>`. For form inputs, use `.value` in your event handler when updating from user input.
321
+ * **Caching** – Concurrent requests for the same key share one render (in-flight deduplication); `clearCache()` also clears in-flight entries.
322
+
323
+ ---
324
+
325
+ ## License
326
+
327
+ **[CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/)** – You are free to use and modify BuildHTML **for non-commercial projects**, as long as you give credit to the original author (0trebor0).
328
+
329
+ ---
package/index.js ADDED
@@ -0,0 +1,718 @@
1
+ // ======================================================
2
+ // ULTRA-PERFORMANCE SSR BUILDER v1.0.1 (FIXED)
3
+ // ======================================================
4
+
5
+ 'use strict';
6
+
7
+ /* ---------------- CONFIG ---------------- */
8
+ const CONFIG = {
9
+ mode: process.env.NODE_ENV === "production" ? "prod" : "dev",
10
+ poolSize: 150,
11
+ cacheLimit: 2000,
12
+ maxCssCache: 1000,
13
+ maxKebabCache: 500,
14
+ compression: true
15
+ };
16
+
17
+ /* ---------------- OBJECT POOLS ---------------- */
18
+ const pools = {
19
+ elements: [],
20
+ arrays: [],
21
+ objects: []
22
+ };
23
+
24
+ function getPooled(type, ...args) {
25
+ const pool = pools[type];
26
+ if (pool && pool.length > 0) {
27
+ const item = pool.pop();
28
+ if (type === 'elements') {
29
+ resetElement(item, ...args);
30
+ } else if (type === 'arrays') {
31
+ item.length = 0;
32
+ }
33
+ return item;
34
+ }
35
+
36
+ switch (type) {
37
+ case 'elements': return new Element(...args);
38
+ case 'arrays': return [];
39
+ case 'objects': return {};
40
+ default: return null;
41
+ }
42
+ }
43
+
44
+ function recycle(type, item) {
45
+ const pool = pools[type];
46
+ if (!pool || pool.length >= CONFIG.poolSize) return;
47
+
48
+ if (type === 'elements' && item instanceof Element) {
49
+ // FIX: Recursive recycling to prevent memory leaks and orphaned children
50
+ for (let i = 0; i < item.children.length; i++) {
51
+ const child = item.children[i];
52
+ if (child instanceof Element) {
53
+ recycle('elements', child);
54
+ }
55
+ }
56
+ pool.push(item);
57
+ } else if (type === 'arrays' && Array.isArray(item)) {
58
+ item.length = 0;
59
+ pool.push(item);
60
+ } else if (type === 'objects' && typeof item === 'object') {
61
+ for (const key in item) delete item[key];
62
+ pool.push(item);
63
+ }
64
+ }
65
+
66
+ function resetElement(el, tag, ridGen, stateStore) {
67
+ el.tag = toKebab(tag);
68
+
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
73
+ if (!el.children) el.children = [];
74
+ else el.children.length = 0;
75
+
76
+ if (!el.events) el.events = [];
77
+ else el.events.length = 0;
78
+
79
+ el.cssText = "";
80
+ el._state = null;
81
+ el.hydrate = false;
82
+ el._computed = null;
83
+ el._ridGen = ridGen;
84
+ el._stateStore = stateStore;
85
+ }
86
+
87
+ /* ---------------- UTILITIES ---------------- */
88
+ let ridCounter = 0;
89
+ const ridPrefix = Date.now().toString(36);
90
+
91
+ const createRidGenerator = () => () => `id-${ridPrefix}${(++ridCounter).toString(36)}`;
92
+
93
+ // Simple 32-bit hash (for scoped class names)
94
+ const hash = (str) => {
95
+ let h = 2166136261;
96
+ const len = str.length;
97
+ for (let i = 0; i < len; i++) {
98
+ h ^= str.charCodeAt(i);
99
+ h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
100
+ }
101
+ return (h >>> 0).toString(36);
102
+ };
103
+
104
+ // Kebab-case conversion with LRU cache
105
+ const kebabCache = new Map();
106
+ const kebabRegex = /[A-Z]/g;
107
+
108
+ function toKebab(str) {
109
+ if (!str || typeof str !== 'string') return "";
110
+
111
+ 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);
119
+ }
120
+
121
+ kebabCache.set(str, result);
122
+ return result;
123
+ }
124
+
125
+ // HTML minification
126
+ const minHTML = (html) => html.replace(/>\s+</g, "><").replace(/\s{2,}/g, " ").trim();
127
+
128
+ // XSS protection
129
+ const escapeMap = Object.freeze({
130
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
131
+ });
132
+ const escapeRegex = /[&<>"']/g;
133
+ const escapeHtml = (text) => String(text).replace(escapeRegex, m => escapeMap[m]);
134
+
135
+ /* ---------------- ELEMENT ---------------- */
136
+ class Element {
137
+ constructor(tag, ridGen, stateStore) {
138
+ this.tag = toKebab(tag);
139
+ this.attrs = {};
140
+ this.children = [];
141
+ this.events = [];
142
+ this.cssText = "";
143
+ this._state = null;
144
+ this.hydrate = false;
145
+ this._computed = null;
146
+ this._ridGen = ridGen;
147
+ this._stateStore = stateStore;
148
+ }
149
+
150
+ id(v) {
151
+ this.attrs.id = v || this._ridGen();
152
+ return this;
153
+ }
154
+
155
+ text(c) {
156
+ this.children.push(escapeHtml(c));
157
+ return this;
158
+ }
159
+
160
+ append(c) {
161
+ this.children.push(c instanceof Element ? c : escapeHtml(c));
162
+ return this;
163
+ }
164
+
165
+ css(s) {
166
+ let cssStr = "";
167
+ for (const k in s) {
168
+ cssStr += toKebab(k) + ":" + s[k] + ";";
169
+ }
170
+
171
+ const sc = "c" + hash(cssStr);
172
+ this.attrs.class = this.attrs.class ? `${this.attrs.class} ${sc}` : sc;
173
+ this.cssText += `.${sc}{${cssStr}}`;
174
+ return this;
175
+ }
176
+
177
+ state(v) {
178
+ if (!this.attrs.id) this.id();
179
+ this._state = v;
180
+ this._stateStore[this.attrs.id] = v;
181
+ this.hydrate = true;
182
+ return this;
183
+ }
184
+
185
+ computed(fn) {
186
+ this._computed = fn;
187
+ if (!this.attrs.id) this.id();
188
+ this.hydrate = true;
189
+ return this;
190
+ }
191
+
192
+ 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;
196
+ return this;
197
+ }
198
+
199
+ 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;
204
+ return this;
205
+ }
206
+ }
207
+
208
+ /* ---------------- HEAD ---------------- */
209
+ class Head {
210
+ constructor() {
211
+ this.title = "Document";
212
+ this.metas = [];
213
+ this.links = [];
214
+ this.styles = [];
215
+ this.scripts = [];
216
+ this.globalStyles = [];
217
+ this.classStyles = {};
218
+ }
219
+
220
+ setTitle(t) {
221
+ this.title = escapeHtml(t);
222
+ return this;
223
+ }
224
+
225
+ addMeta(m) {
226
+ this.metas.push(m);
227
+ return this;
228
+ }
229
+
230
+ addLink(l) {
231
+ if (!this.links.includes(l)) this.links.push(l);
232
+ return this;
233
+ }
234
+
235
+ addStyle(s) {
236
+ this.styles.push(s);
237
+ return this;
238
+ }
239
+
240
+ addScript(s) {
241
+ this.scripts.push(s);
242
+ return this;
243
+ }
244
+
245
+ globalCss(selector, rules) {
246
+ let cssStr = selector + "{";
247
+ for (const k in rules) {
248
+ cssStr += toKebab(k) + ":" + rules[k] + ";";
249
+ }
250
+ cssStr += "}";
251
+ this.globalStyles.push(cssStr);
252
+ return this;
253
+ }
254
+
255
+ addClass(name, rules) {
256
+ let cssStr = "";
257
+ for (const k in rules) {
258
+ cssStr += toKebab(k) + ":" + rules[k] + ";";
259
+ }
260
+ this.classStyles[name] = cssStr;
261
+ return this;
262
+ }
263
+
264
+ render() {
265
+ const parts = ['<meta charset="UTF-8"><title>', this.title, '</title>'];
266
+
267
+ // Meta tags
268
+ const metaLen = this.metas.length;
269
+ for (let i = 0; i < metaLen; i++) {
270
+ const m = this.metas[i];
271
+ parts.push('<meta ');
272
+ for (const k in m) {
273
+ parts.push(toKebab(k), '="', escapeHtml(m[k]), '" ');
274
+ }
275
+ parts.push('>');
276
+ }
277
+
278
+ // Links
279
+ const linkLen = this.links.length;
280
+ for (let i = 0; i < linkLen; i++) {
281
+ parts.push('<link rel="stylesheet" href="', escapeHtml(this.links[i]), '">');
282
+ }
283
+
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]);
298
+ }
299
+
300
+ parts.push('</style>');
301
+
302
+ // Scripts
303
+ const scriptLen = this.scripts.length;
304
+ for (let i = 0; i < scriptLen; i++) {
305
+ parts.push('<script src="', escapeHtml(this.scripts[i]), '"></script>');
306
+ }
307
+
308
+ return parts.join('');
309
+ }
310
+ }
311
+
312
+ /* ---------------- LRU CACHE ---------------- */
313
+ class LRUCache {
314
+ constructor(limit) {
315
+ this.limit = limit;
316
+ this.cache = new Map();
317
+ }
318
+
319
+ get(key) {
320
+ if (!this.cache.has(key)) return null;
321
+ const value = this.cache.get(key);
322
+ this.cache.delete(key);
323
+ this.cache.set(key, value);
324
+ return value;
325
+ }
326
+
327
+ set(key, value) {
328
+ if (this.cache.has(key)) {
329
+ this.cache.delete(key);
330
+ } else if (this.cache.size >= this.limit) {
331
+ const firstKey = this.cache.keys().next().value;
332
+ this.cache.delete(firstKey);
333
+ }
334
+ this.cache.set(key, value);
335
+ }
336
+
337
+ clear() {
338
+ this.cache.clear();
339
+ }
340
+
341
+ delete(key) {
342
+ this.cache.delete(key);
343
+ }
344
+
345
+ has(key) {
346
+ return this.cache.has(key);
347
+ }
348
+ }
349
+
350
+ const responseCache = new LRUCache(CONFIG.cacheLimit);
351
+ const inFlightCache = new Map();
352
+
353
+ /* ---------------- DOCUMENT ---------------- */
354
+ class Document {
355
+ constructor(options = {}) {
356
+ this.body = [];
357
+ this.head = new Head();
358
+ this._ridGen = createRidGenerator();
359
+ this._stateStore = {};
360
+ this._useResponseCache = options.cache ?? false;
361
+ this._cacheKey = options.cacheKey || null;
362
+ }
363
+
364
+ title(t) {
365
+ this.head.setTitle(t);
366
+ return this;
367
+ }
368
+
369
+ addMeta(m) {
370
+ this.head.addMeta(m);
371
+ return this;
372
+ }
373
+
374
+ addLink(l) {
375
+ this.head.addLink(l);
376
+ return this;
377
+ }
378
+
379
+ addStyle(s) {
380
+ this.head.addStyle(s);
381
+ return this;
382
+ }
383
+
384
+ addScript(s) {
385
+ this.head.addScript(s);
386
+ return this;
387
+ }
388
+
389
+ use(el) {
390
+ this.body.push(el);
391
+ return this;
392
+ }
393
+
394
+ /** Add multiple elements from a function (for composition/layouts). fn(doc) returns Element or Element[]. */
395
+ 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);
401
+ }
402
+ return this;
403
+ }
404
+
405
+ createElement(tag) {
406
+ return getPooled('elements', tag, this._ridGen, this._stateStore);
407
+ }
408
+
409
+ /** Shorthand for createElement(tag). */
410
+ create(tag) {
411
+ return this.createElement(tag);
412
+ }
413
+
414
+ clear() {
415
+ const bodyLen = this.body.length;
416
+ for (let i = 0; i < bodyLen; i++) {
417
+ if (this.body[i] instanceof Element) {
418
+ recycle('elements', this.body[i]);
419
+ }
420
+ }
421
+ this.body.length = 0;
422
+ this._stateStore = {};
423
+ }
424
+
425
+ render() {
426
+ if (this._useResponseCache && this._cacheKey) {
427
+ const cached = responseCache.get(this._cacheKey);
428
+ if (cached) {
429
+ this.clear();
430
+ return cached;
431
+ }
432
+ }
433
+
434
+ const ctx = {
435
+ events: getPooled('arrays'),
436
+ states: getPooled('arrays'),
437
+ styles: [],
438
+ computed: getPooled('arrays')
439
+ };
440
+
441
+ const bodyParts = [];
442
+ const bodyLen = this.body.length;
443
+ for (let i = 0; i < bodyLen; i++) {
444
+ bodyParts.push(renderNode(this.body[i], ctx));
445
+ }
446
+
447
+ const bodyHTML = bodyParts.join('');
448
+ const headHTML = this.head.render();
449
+ const stylesHTML = ctx.styles.length > 0 ? '<style>' + ctx.styles.join('') + '</style>' : '';
450
+ const clientJS = compileClient(ctx);
451
+
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>`;
454
+
455
+ recycle('arrays', ctx.events);
456
+ recycle('arrays', ctx.states);
457
+ recycle('arrays', ctx.computed);
458
+
459
+ const result = CONFIG.mode === "prod" ? minHTML(html) : html;
460
+
461
+ if (this._useResponseCache && this._cacheKey) {
462
+ responseCache.set(this._cacheKey, result);
463
+ }
464
+
465
+ this.clear();
466
+ return result;
467
+ }
468
+ }
469
+
470
+ /* ---------------- RENDERER ---------------- */
471
+ const voidElements = new Set([
472
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
473
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'
474
+ ]);
475
+
476
+ function renderNode(n, ctx) {
477
+ if (n == null) return '';
478
+ if (!(n instanceof Element)) return n;
479
+
480
+ const parts = ['<', n.tag];
481
+
482
+ for (const k in n.attrs) {
483
+ parts.push(' ', toKebab(k), '="', escapeHtml(n.attrs[k]), '"');
484
+ }
485
+
486
+ parts.push('>');
487
+
488
+ if (n.cssText) ctx.styles.push(n.cssText);
489
+
490
+ if (n._state !== null) {
491
+ ctx.states.push({ id: n.attrs.id, value: n._state, tag: n.tag });
492
+ }
493
+
494
+ if (n._computed) {
495
+ ctx.computed.push({ id: n.attrs.id, fn: n._computed.toString() });
496
+ }
497
+
498
+ // FIX: Properly skip children for void elements to avoid illegal HTML
499
+ if (!voidElements.has(n.tag)) {
500
+ const childLen = n.children.length;
501
+ for (let i = 0; i < childLen; i++) {
502
+ parts.push(renderNode(n.children[i], ctx));
503
+ }
504
+ parts.push('</', n.tag, '>');
505
+ }
506
+
507
+ const eventLen = n.events.length;
508
+ for (let i = 0; i < eventLen; i++) {
509
+ ctx.events.push(n.events[i]);
510
+ }
511
+
512
+ return parts.join('');
513
+ }
514
+
515
+ /* ---------------- CLIENT ---------------- */
516
+ function compileClient(ctx) {
517
+ const hasStates = ctx.states.length > 0;
518
+ const hasComputed = ctx.computed.length > 0;
519
+ const hasEvents = ctx.events.length > 0;
520
+
521
+ if (!hasStates && !hasComputed && !hasEvents) {
522
+ return '';
523
+ }
524
+
525
+ // FIX: Moved helper functions inside the scope to prevent global pollution
526
+ const parts = [
527
+ 'window.state={};',
528
+ 'document.addEventListener("DOMContentLoaded",function(){',
529
+ 'const getById=id=>document.getElementById(id);'
530
+ ];
531
+
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 || '');
538
+ 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, '"];})();'
541
+ );
542
+ }
543
+
544
+ // Computed
545
+ 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);})();');
549
+ }
550
+
551
+ // Events
552
+ 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);
558
+ }
559
+ parts.push('(function(){var _el=getById("', e.id, '");if(_el)_el.addEventListener("', e.event, '",', f, ');})();');
560
+ }
561
+
562
+ parts.push('});');
563
+ return parts.join('');
564
+ }
565
+
566
+ /* ---------------- MIDDLEWARE HELPERS ---------------- */
567
+ function createCachedRenderer(builderFn, cacheKeyOrFn) {
568
+ return (req, res, next) => {
569
+ const key = typeof cacheKeyOrFn === 'function' ? cacheKeyOrFn(req) : cacheKeyOrFn;
570
+
571
+ if (key == null || key === '') {
572
+ try {
573
+ const doc = builderFn(req);
574
+ if (!doc || !(doc instanceof Document)) {
575
+ return res.status(500).send('Internal Server Error');
576
+ }
577
+ return res.send(doc.render());
578
+ } catch (err) {
579
+ return next(err);
580
+ }
581
+ }
582
+
583
+ const cached = responseCache.get(key);
584
+ if (cached) {
585
+ return res.send(cached);
586
+ }
587
+
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
+ }
608
+
609
+ promise.then((html) => res.send(html)).catch((err) => {
610
+ if (err.status === 500) {
611
+ res.status(500).send('Internal Server Error');
612
+ } else {
613
+ next(err);
614
+ }
615
+ });
616
+ };
617
+ }
618
+
619
+ function clearCache(pattern) {
620
+ if (!pattern) {
621
+ responseCache.clear();
622
+ inFlightCache.clear();
623
+ return;
624
+ }
625
+
626
+ const keysToDelete = [];
627
+ for (const [key] of responseCache.cache) {
628
+ if (key.includes(pattern)) keysToDelete.push(key);
629
+ }
630
+ for (const key of inFlightCache.keys()) {
631
+ if (key.includes(pattern) && !keysToDelete.includes(key)) keysToDelete.push(key);
632
+ }
633
+ const len = keysToDelete.length;
634
+ for (let i = 0; i < len; i++) {
635
+ responseCache.delete(keysToDelete[i]);
636
+ inFlightCache.delete(keysToDelete[i]);
637
+ }
638
+ }
639
+
640
+ // Compression middleware
641
+ function enableCompression() {
642
+ return (req, res, next) => {
643
+ const acceptEncoding = req.headers['accept-encoding'];
644
+
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);
652
+ this.setHeader('Content-Encoding', 'gzip');
653
+ this.setHeader('Content-Length', compressed.length);
654
+ return originalSend.call(this, compressed);
655
+ } catch (err) {
656
+ return originalSend.call(this, data);
657
+ }
658
+ }
659
+ return originalSend.call(this, data);
660
+ };
661
+ }
662
+
663
+ next();
664
+ };
665
+ }
666
+
667
+ // Warmup cache helper
668
+ function warmupCache(routes) {
669
+ const results = [];
670
+
671
+ for (const route of routes) {
672
+ const { key, builder } = route;
673
+ try {
674
+ const doc = builder();
675
+ if (!doc || !(doc instanceof Document)) {
676
+ results.push({ key, error: 'Builder must return a Document instance', success: false });
677
+ continue;
678
+ }
679
+ doc._useResponseCache = true;
680
+ doc._cacheKey = key;
681
+ const html = doc.render();
682
+ results.push({ key, size: html.length, success: true });
683
+ } catch (err) {
684
+ results.push({ key, error: err.message, success: false });
685
+ }
686
+ }
687
+
688
+ return results;
689
+ }
690
+
691
+ // Stats helper
692
+ function getCacheStats() {
693
+ 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: {
699
+ elements: pools.elements.length,
700
+ arrays: pools.arrays.length,
701
+ objects: pools.objects.length
702
+ }
703
+ };
704
+ }
705
+
706
+ /* ---------------- EXPORTS ---------------- */
707
+ module.exports = {
708
+ Document,
709
+ Element,
710
+ Head,
711
+ CONFIG,
712
+ createCachedRenderer,
713
+ clearCache,
714
+ enableCompression,
715
+ responseCache,
716
+ warmupCache,
717
+ getCacheStats
718
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@trebor/buildhtml",
3
+ "version": "1.0.0",
4
+ "description": "Zero-dependency, ultra-fast HTML builder for server-side rendering (SSR).",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "README.md",
9
+ "LICENSE.txt"
10
+ ],
11
+ "scripts": {
12
+ "start": "node example/server.js",
13
+ "test": "node test-debug.js"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/0trebor0/buildhtml.git"
18
+ },
19
+ "keywords": [
20
+ "ssr",
21
+ "server-side-rendering",
22
+ "javascript",
23
+ "nodejs",
24
+ "html-builder",
25
+ "buildhtml",
26
+ "html-builder"
27
+ ],
28
+ "author": "0trebor0 <webdevme@outlook.com>",
29
+ "license": "CC-BY-NC-4.0",
30
+ "engines": {
31
+ "node": ">=16.0.0"
32
+ },
33
+ "bugs": {
34
+ "url": "https://github.com/0trebor0/buildhtml/issues"
35
+ },
36
+ "homepage": "https://github.com/0trebor0/buildhtml#readme"
37
+ }