@jamx-framework/renderer 0.1.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.
- package/.turbo/turbo-build.log +4 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/error/boundary.d.ts +36 -0
- package/dist/error/boundary.d.ts.map +1 -0
- package/dist/error/boundary.js +60 -0
- package/dist/error/boundary.js.map +1 -0
- package/dist/error/error-page.d.ts +7 -0
- package/dist/error/error-page.d.ts.map +1 -0
- package/dist/error/error-page.js +132 -0
- package/dist/error/error-page.js.map +1 -0
- package/dist/error/types.d.ts +7 -0
- package/dist/error/types.d.ts.map +1 -0
- package/dist/error/types.js +2 -0
- package/dist/error/types.js.map +1 -0
- package/dist/html/escape.d.ts +19 -0
- package/dist/html/escape.d.ts.map +1 -0
- package/dist/html/escape.js +85 -0
- package/dist/html/escape.js.map +1 -0
- package/dist/html/serializer.d.ts +20 -0
- package/dist/html/serializer.d.ts.map +1 -0
- package/dist/html/serializer.js +132 -0
- package/dist/html/serializer.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/jsx/jsx-runtime.d.ts +213 -0
- package/dist/jsx/jsx-runtime.d.ts.map +1 -0
- package/dist/jsx/jsx-runtime.js +32 -0
- package/dist/jsx/jsx-runtime.js.map +1 -0
- package/dist/pipeline/context.d.ts +44 -0
- package/dist/pipeline/context.d.ts.map +1 -0
- package/dist/pipeline/context.js +2 -0
- package/dist/pipeline/context.js.map +1 -0
- package/dist/pipeline/renderer.d.ts +40 -0
- package/dist/pipeline/renderer.d.ts.map +1 -0
- package/dist/pipeline/renderer.js +95 -0
- package/dist/pipeline/renderer.js.map +1 -0
- package/package.json +38 -0
- package/src/error/boundary.ts +80 -0
- package/src/error/error-page.ts +137 -0
- package/src/error/types.ts +6 -0
- package/src/html/escape.ts +90 -0
- package/src/html/serializer.ts +161 -0
- package/src/index.ts +33 -0
- package/src/jsx/jsx-runtime.ts +247 -0
- package/src/pipeline/context.ts +45 -0
- package/src/pipeline/renderer.ts +138 -0
- package/test/unit/error/boundary.test.ts +78 -0
- package/test/unit/error/error-page.test.ts +63 -0
- package/test/unit/html/escape.test.ts +34 -0
- package/test/unit/html/renderer.test.ts +93 -0
- package/test/unit/html/serializer.test.ts +141 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jamx-framework/renderer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JAMX Framework — SSR Renderer",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./jsx-runtime": {
|
|
14
|
+
"import": "./dist/jsx/jsx-runtime.js",
|
|
15
|
+
"types": "./dist/jsx/jsx-runtime.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"./jsx-dev-runtime": {
|
|
18
|
+
"import": "./dist/jsx/jsx-runtime.js",
|
|
19
|
+
"types": "./dist/jsx/jsx-runtime.d.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@jamx-framework/core": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.0.0",
|
|
27
|
+
"rimraf": "^6.0.0",
|
|
28
|
+
"vitest": "^2.1.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc --project tsconfig.json",
|
|
32
|
+
"dev": "tsc --project tsconfig.json --watch",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest",
|
|
35
|
+
"type-check": "tsc --noEmit",
|
|
36
|
+
"clean": "rimraf dist *.tsbuildinfo"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { HttpException } from "@jamx-framework/core";
|
|
2
|
+
import { renderErrorPage } from "./error-page.js";
|
|
3
|
+
import type { RenderError } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export interface BoundaryOptions {
|
|
6
|
+
isDev: boolean;
|
|
7
|
+
path?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BoundaryResult {
|
|
11
|
+
html: string;
|
|
12
|
+
statusCode: number;
|
|
13
|
+
error: RenderError;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Captura errores durante el render SSR y genera una página de error.
|
|
18
|
+
*
|
|
19
|
+
* Uso:
|
|
20
|
+
* const result = await ErrorBoundary.wrap(
|
|
21
|
+
* () => renderer.render(page, props),
|
|
22
|
+
* { isDev: true, path: '/about' }
|
|
23
|
+
* )
|
|
24
|
+
*
|
|
25
|
+
* if (result.caught) {
|
|
26
|
+
* res.html(result.html, result.statusCode)
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
export class ErrorBoundary {
|
|
30
|
+
static async wrap<T>(
|
|
31
|
+
fn: () => Promise<T>,
|
|
32
|
+
options: BoundaryOptions,
|
|
33
|
+
): Promise<
|
|
34
|
+
{ caught: false; value: T } | ({ caught: true } & BoundaryResult)
|
|
35
|
+
> {
|
|
36
|
+
try {
|
|
37
|
+
const value = await fn();
|
|
38
|
+
return { caught: false, value };
|
|
39
|
+
} catch (err) {
|
|
40
|
+
const renderError = ErrorBoundary.toRenderError(err, options.path);
|
|
41
|
+
const html = renderErrorPage(renderError, options.isDev);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
caught: true,
|
|
45
|
+
html,
|
|
46
|
+
statusCode: renderError.statusCode,
|
|
47
|
+
error: renderError,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Convierte cualquier error a RenderError.
|
|
54
|
+
*/
|
|
55
|
+
static toRenderError(err: unknown, path?: string): RenderError {
|
|
56
|
+
if (err instanceof HttpException) {
|
|
57
|
+
return {
|
|
58
|
+
statusCode: err.statusCode,
|
|
59
|
+
message: err.message,
|
|
60
|
+
stack: err.stack,
|
|
61
|
+
path,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (err instanceof Error) {
|
|
66
|
+
return {
|
|
67
|
+
statusCode: 500,
|
|
68
|
+
message: err.message,
|
|
69
|
+
stack: err.stack,
|
|
70
|
+
path,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
statusCode: 500,
|
|
76
|
+
message: "An unexpected error occurred",
|
|
77
|
+
path,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { RenderError } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renderiza una página de error HTML.
|
|
5
|
+
* En producción no muestra el stack trace.
|
|
6
|
+
*/
|
|
7
|
+
export function renderErrorPage(error: RenderError, isDev: boolean): string {
|
|
8
|
+
const title = errorTitle(error.statusCode);
|
|
9
|
+
const stack =
|
|
10
|
+
isDev && error.stack
|
|
11
|
+
? `<pre class="stack">${escapeHtml(error.stack)}</pre>`
|
|
12
|
+
: "";
|
|
13
|
+
|
|
14
|
+
return `<!DOCTYPE html>
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="UTF-8">
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
19
|
+
<title>${error.statusCode} — ${title}</title>
|
|
20
|
+
<style>
|
|
21
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0 }
|
|
22
|
+
|
|
23
|
+
body {
|
|
24
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
25
|
+
background: #0f0f0f;
|
|
26
|
+
color: #e5e5e5;
|
|
27
|
+
min-height: 100vh;
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: center;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.container {
|
|
34
|
+
max-width: 640px;
|
|
35
|
+
width: 100%;
|
|
36
|
+
padding: 2rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.code {
|
|
40
|
+
font-size: 6rem;
|
|
41
|
+
font-weight: 800;
|
|
42
|
+
color: #3b82f6;
|
|
43
|
+
line-height: 1;
|
|
44
|
+
letter-spacing: -4px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.title {
|
|
48
|
+
font-size: 1.5rem;
|
|
49
|
+
font-weight: 600;
|
|
50
|
+
margin-top: 0.5rem;
|
|
51
|
+
color: #f5f5f5;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.message {
|
|
55
|
+
margin-top: 1rem;
|
|
56
|
+
color: #a3a3a3;
|
|
57
|
+
font-size: 0.95rem;
|
|
58
|
+
line-height: 1.6;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.path {
|
|
62
|
+
margin-top: 0.5rem;
|
|
63
|
+
font-size: 0.8rem;
|
|
64
|
+
color: #525252;
|
|
65
|
+
font-family: monospace;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.stack {
|
|
69
|
+
margin-top: 1.5rem;
|
|
70
|
+
padding: 1rem;
|
|
71
|
+
background: #171717;
|
|
72
|
+
border: 1px solid #262626;
|
|
73
|
+
border-radius: 8px;
|
|
74
|
+
font-size: 0.75rem;
|
|
75
|
+
color: #f87171;
|
|
76
|
+
overflow-x: auto;
|
|
77
|
+
white-space: pre-wrap;
|
|
78
|
+
word-break: break-word;
|
|
79
|
+
line-height: 1.6;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.divider {
|
|
83
|
+
margin-top: 2rem;
|
|
84
|
+
border: none;
|
|
85
|
+
border-top: 1px solid #262626;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.footer {
|
|
89
|
+
margin-top: 1rem;
|
|
90
|
+
font-size: 0.75rem;
|
|
91
|
+
color: #404040;
|
|
92
|
+
}
|
|
93
|
+
</style>
|
|
94
|
+
</head>
|
|
95
|
+
<body>
|
|
96
|
+
<div class="container">
|
|
97
|
+
<div class="code">${error.statusCode}</div>
|
|
98
|
+
<div class="title">${escapeHtml(title)}</div>
|
|
99
|
+
<div class="message">${escapeHtml(error.message)}</div>
|
|
100
|
+
${error.path ? `<div class="path">${escapeHtml(error.path)}</div>` : ""}
|
|
101
|
+
${stack}
|
|
102
|
+
<hr class="divider">
|
|
103
|
+
<div class="footer">JAMX Framework</div>
|
|
104
|
+
</div>
|
|
105
|
+
</body>
|
|
106
|
+
</html>`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function errorTitle(statusCode: number): string {
|
|
110
|
+
switch (statusCode) {
|
|
111
|
+
case 400:
|
|
112
|
+
return "Bad Request";
|
|
113
|
+
case 401:
|
|
114
|
+
return "Unauthorized";
|
|
115
|
+
case 403:
|
|
116
|
+
return "Forbidden";
|
|
117
|
+
case 404:
|
|
118
|
+
return "Not Found";
|
|
119
|
+
case 422:
|
|
120
|
+
return "Unprocessable Entity";
|
|
121
|
+
case 500:
|
|
122
|
+
return "Internal Server Error";
|
|
123
|
+
case 503:
|
|
124
|
+
return "Service Unavailable";
|
|
125
|
+
default:
|
|
126
|
+
return statusCode >= 500 ? "Server Error" : "Client Error";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function escapeHtml(str: string): string {
|
|
131
|
+
return str
|
|
132
|
+
.replace(/&/g, "&")
|
|
133
|
+
.replace(/</g, "<")
|
|
134
|
+
.replace(/>/g, ">")
|
|
135
|
+
.replace(/"/g, """)
|
|
136
|
+
.replace(/'/g, "'");
|
|
137
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// packages/renderer/src/html/escape.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Escapa caracteres especiales HTML para prevenir XSS.
|
|
5
|
+
* Siempre usar esto al renderizar contenido de usuario.
|
|
6
|
+
*/
|
|
7
|
+
export function escapeHtml(value: unknown): string {
|
|
8
|
+
const str = String(value);
|
|
9
|
+
let result = "";
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < str.length; i++) {
|
|
12
|
+
const char = str[i];
|
|
13
|
+
switch (char) {
|
|
14
|
+
case "&":
|
|
15
|
+
result += "&";
|
|
16
|
+
break;
|
|
17
|
+
case "<":
|
|
18
|
+
result += "<";
|
|
19
|
+
break;
|
|
20
|
+
case ">":
|
|
21
|
+
result += ">";
|
|
22
|
+
break;
|
|
23
|
+
case '"':
|
|
24
|
+
result += """;
|
|
25
|
+
break;
|
|
26
|
+
case "'":
|
|
27
|
+
result += "'";
|
|
28
|
+
break;
|
|
29
|
+
default:
|
|
30
|
+
result += char;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Tags HTML que no tienen closing tag.
|
|
39
|
+
*/
|
|
40
|
+
export const VOID_ELEMENTS = new Set([
|
|
41
|
+
"area",
|
|
42
|
+
"base",
|
|
43
|
+
"br",
|
|
44
|
+
"col",
|
|
45
|
+
"embed",
|
|
46
|
+
"hr",
|
|
47
|
+
"img",
|
|
48
|
+
"input",
|
|
49
|
+
"link",
|
|
50
|
+
"meta",
|
|
51
|
+
"param",
|
|
52
|
+
"source",
|
|
53
|
+
"track",
|
|
54
|
+
"wbr",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Tags cuyo contenido se renderiza tal cual (sin escapar).
|
|
59
|
+
*/
|
|
60
|
+
export const RAW_TEXT_ELEMENTS = new Set(["script", "style"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Atributos booleanos — se renderizan sin valor si son true.
|
|
64
|
+
* Ej: <input disabled /> en lugar de <input disabled="true" />
|
|
65
|
+
*/
|
|
66
|
+
export const BOOLEAN_ATTRIBUTES = new Set([
|
|
67
|
+
"allowfullscreen",
|
|
68
|
+
"async",
|
|
69
|
+
"autofocus",
|
|
70
|
+
"autoplay",
|
|
71
|
+
"checked",
|
|
72
|
+
"controls",
|
|
73
|
+
"default",
|
|
74
|
+
"defer",
|
|
75
|
+
"disabled",
|
|
76
|
+
"formnovalidate",
|
|
77
|
+
"hidden",
|
|
78
|
+
"ismap",
|
|
79
|
+
"loop",
|
|
80
|
+
"multiple",
|
|
81
|
+
"muted",
|
|
82
|
+
"nomodule",
|
|
83
|
+
"novalidate",
|
|
84
|
+
"open",
|
|
85
|
+
"readonly",
|
|
86
|
+
"required",
|
|
87
|
+
"reversed",
|
|
88
|
+
"selected",
|
|
89
|
+
"typemustmatch",
|
|
90
|
+
]);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
escapeHtml,
|
|
3
|
+
VOID_ELEMENTS,
|
|
4
|
+
RAW_TEXT_ELEMENTS,
|
|
5
|
+
BOOLEAN_ATTRIBUTES,
|
|
6
|
+
} from "./escape.js";
|
|
7
|
+
import { Fragment } from "../jsx/jsx-runtime.js";
|
|
8
|
+
import type {
|
|
9
|
+
JamxNode,
|
|
10
|
+
JamxElement,
|
|
11
|
+
ComponentFn,
|
|
12
|
+
Props,
|
|
13
|
+
} from "../jsx/jsx-runtime.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Serializa un árbol JSX a una string HTML.
|
|
17
|
+
*
|
|
18
|
+
* Proceso:
|
|
19
|
+
* 1. Si el nodo es un string/number → escapar y retornar
|
|
20
|
+
* 2. Si el nodo es un componente (función) → llamarlo y serializar el resultado
|
|
21
|
+
* 3. Si el nodo es un elemento HTML → serializar tag + props + children
|
|
22
|
+
* 4. Si el nodo es un Fragment → serializar solo los children
|
|
23
|
+
*/
|
|
24
|
+
export class HtmlSerializer {
|
|
25
|
+
serialize(node: JamxNode): string {
|
|
26
|
+
// Nodos primitivos
|
|
27
|
+
if (node === null || node === undefined || node === false) return "";
|
|
28
|
+
if (node === true) return "";
|
|
29
|
+
if (typeof node === "number") return String(node);
|
|
30
|
+
if (typeof node === "string") return escapeHtml(node);
|
|
31
|
+
|
|
32
|
+
// Arrays
|
|
33
|
+
if (Array.isArray(node)) {
|
|
34
|
+
return node.map((child) => this.serialize(child)).join("");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Elemento JSX
|
|
38
|
+
return this.serializeElement(node);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private serializeElement(element: JamxElement): string {
|
|
42
|
+
const { type, props } = element;
|
|
43
|
+
|
|
44
|
+
// Componente funcional — ejecutar y serializar el resultado
|
|
45
|
+
if (typeof type === "function") {
|
|
46
|
+
return this.serializeComponent(type, props);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Fragment — solo los hijos
|
|
50
|
+
if (type === Fragment) {
|
|
51
|
+
return this.serializeChildren(props.children);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Elemento HTML
|
|
55
|
+
return this.serializeHtmlElement(type, props);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private serializeComponent(fn: ComponentFn, props: Props): string {
|
|
59
|
+
try {
|
|
60
|
+
const result = fn(props);
|
|
61
|
+
return this.serialize(result);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Error rendering component '${fn.name || "anonymous"}': ${String(err)}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private serializeHtmlElement(tag: string, props: Props): string {
|
|
70
|
+
const { children, ...attrs } = props;
|
|
71
|
+
|
|
72
|
+
const attrString = this.serializeAttributes(attrs);
|
|
73
|
+
const openTag = attrString ? `<${tag} ${attrString}>` : `<${tag}>`;
|
|
74
|
+
|
|
75
|
+
// Elementos void — no tienen closing tag ni children
|
|
76
|
+
if (VOID_ELEMENTS.has(tag)) {
|
|
77
|
+
return openTag;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Elementos con contenido raw (script, style)
|
|
81
|
+
if (RAW_TEXT_ELEMENTS.has(tag)) {
|
|
82
|
+
const rawContent = typeof children === "string" ? children : "";
|
|
83
|
+
return `${openTag}${rawContent}</${tag}>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const childrenHtml = this.serializeChildren(children);
|
|
87
|
+
return `${openTag}${childrenHtml}</${tag}>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private serializeAttributes(attrs: Record<string, unknown>): string {
|
|
91
|
+
const parts: string[] = [];
|
|
92
|
+
|
|
93
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
94
|
+
// Ignorar atributos internos y undefined/null
|
|
95
|
+
if (key === "key" || value === undefined || value === null) continue;
|
|
96
|
+
|
|
97
|
+
// className → class
|
|
98
|
+
const attrName =
|
|
99
|
+
key === "className"
|
|
100
|
+
? "class"
|
|
101
|
+
: key === "htmlFor"
|
|
102
|
+
? "for"
|
|
103
|
+
: key === "tabIndex"
|
|
104
|
+
? "tabindex"
|
|
105
|
+
: camelToKebab(key);
|
|
106
|
+
|
|
107
|
+
// Atributos booleanos
|
|
108
|
+
if (BOOLEAN_ATTRIBUTES.has(attrName)) {
|
|
109
|
+
if (value === true) parts.push(attrName);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// style como objeto
|
|
114
|
+
if (key === "style" && typeof value === "object" && value !== null) {
|
|
115
|
+
const styleStr = this.serializeStyle(value as Record<string, string>);
|
|
116
|
+
if (styleStr) parts.push(`style="${escapeHtml(styleStr)}"`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// false → omitir el atributo
|
|
121
|
+
if (value === false) continue;
|
|
122
|
+
|
|
123
|
+
// true → atributo sin valor (no booleano, ej: data-active)
|
|
124
|
+
if (value === true) {
|
|
125
|
+
parts.push(attrName);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
parts.push(`${attrName}="${escapeHtml(value)}"`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return parts.join(" ");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private serializeStyle(style: Record<string, string>): string {
|
|
136
|
+
return Object.entries(style)
|
|
137
|
+
.map(([prop, value]) => `${camelToKebab(prop)}: ${value}`)
|
|
138
|
+
.join("; ");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private serializeChildren(children: unknown): string {
|
|
142
|
+
if (children === undefined || children === null) return "";
|
|
143
|
+
if (Array.isArray(children)) {
|
|
144
|
+
return children
|
|
145
|
+
.map((child) => this.serialize(child as JamxNode))
|
|
146
|
+
.join("");
|
|
147
|
+
}
|
|
148
|
+
return this.serialize(children as JamxNode);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── HELPERS ───────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Convierte camelCase a kebab-case para atributos HTML.
|
|
156
|
+
* backgroundColor → background-color
|
|
157
|
+
* dataTestId → data-test-id (los data- ya vienen bien)
|
|
158
|
+
*/
|
|
159
|
+
function camelToKebab(str: string): string {
|
|
160
|
+
return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
161
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export { SSRRenderer } from "./pipeline/renderer.js";
|
|
2
|
+
export { HtmlSerializer } from "./html/serializer.js";
|
|
3
|
+
export {
|
|
4
|
+
escapeHtml,
|
|
5
|
+
VOID_ELEMENTS,
|
|
6
|
+
BOOLEAN_ATTRIBUTES,
|
|
7
|
+
} from "./html/escape.js";
|
|
8
|
+
export { jsx, jsxs, jsxDEV, Fragment } from "./jsx/jsx-runtime.js";
|
|
9
|
+
|
|
10
|
+
export type {
|
|
11
|
+
JamxNode,
|
|
12
|
+
JamxElement,
|
|
13
|
+
ComponentFn,
|
|
14
|
+
Props,
|
|
15
|
+
JSX,
|
|
16
|
+
HtmlAttributes,
|
|
17
|
+
} from "./jsx/jsx-runtime.js";
|
|
18
|
+
|
|
19
|
+
export type {
|
|
20
|
+
RenderContext,
|
|
21
|
+
RenderResult,
|
|
22
|
+
PageHead,
|
|
23
|
+
} from "./pipeline/context.js";
|
|
24
|
+
|
|
25
|
+
export type {
|
|
26
|
+
PageComponentLike,
|
|
27
|
+
LayoutComponentLike,
|
|
28
|
+
} from "./pipeline/renderer.js";
|
|
29
|
+
|
|
30
|
+
export { ErrorBoundary } from "./error/boundary.js";
|
|
31
|
+
export { renderErrorPage } from "./error/error-page.js";
|
|
32
|
+
export type { RenderError } from "./error/types.js";
|
|
33
|
+
export type { BoundaryOptions, BoundaryResult } from "./error/boundary.js";
|