@jamx-framework/renderer 0.1.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +560 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/html/escape.d.ts.map +1 -1
- package/dist/html/escape.js +8 -4
- package/dist/html/escape.js.map +1 -1
- package/dist/html/serializer.d.ts.map +1 -1
- package/dist/html/serializer.js +2 -0
- package/dist/html/serializer.js.map +1 -1
- package/dist/jsx/jsx-runtime.d.ts +2 -2
- package/dist/jsx/jsx-runtime.d.ts.map +1 -1
- package/dist/jsx/jsx-runtime.js +5 -1
- package/dist/jsx/jsx-runtime.js.map +1 -1
- package/package.json +37 -12
- package/.turbo/turbo-build.log +0 -4
- package/src/error/boundary.ts +0 -80
- package/src/error/error-page.ts +0 -137
- package/src/error/types.ts +0 -6
- package/src/html/escape.ts +0 -90
- package/src/html/serializer.ts +0 -161
- package/src/index.ts +0 -33
- package/src/jsx/jsx-runtime.ts +0 -247
- package/src/pipeline/context.ts +0 -45
- package/src/pipeline/renderer.ts +0 -138
- package/test/unit/error/boundary.test.ts +0 -78
- package/test/unit/error/error-page.test.ts +0 -63
- package/test/unit/html/escape.test.ts +0 -34
- package/test/unit/html/renderer.test.ts +0 -93
- package/test/unit/html/serializer.test.ts +0 -141
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -4
package/src/pipeline/context.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Contexto disponible durante el render de una página.
|
|
3
|
-
* Se pasa al componente para que pueda acceder a datos
|
|
4
|
-
* de la request sin importar directamente @jamx-framework/server.
|
|
5
|
-
*/
|
|
6
|
-
export interface RenderContext {
|
|
7
|
-
/** Path de la ruta actual. Ej: /users/123 */
|
|
8
|
-
path: string;
|
|
9
|
-
/** Params de la ruta. Ej: { id: '123' } */
|
|
10
|
-
params: Record<string, string>;
|
|
11
|
-
/** Query string parseado */
|
|
12
|
-
query: Record<string, string | string[]>;
|
|
13
|
-
/** Headers de la request */
|
|
14
|
-
headers: Record<string, string | string[] | undefined>;
|
|
15
|
-
/** Entorno actual */
|
|
16
|
-
env: "development" | "production" | "test";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Resultado del render de una página.
|
|
21
|
-
*/
|
|
22
|
-
export interface RenderResult {
|
|
23
|
-
/** HTML completo listo para enviar */
|
|
24
|
-
html: string;
|
|
25
|
-
/** Status HTTP a usar en la response */
|
|
26
|
-
statusCode: number;
|
|
27
|
-
/** Headers adicionales para la response */
|
|
28
|
-
headers: Record<string, string>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Metadatos de una página para el <head>.
|
|
33
|
-
*/
|
|
34
|
-
export interface PageHead {
|
|
35
|
-
title?: string;
|
|
36
|
-
description?: string;
|
|
37
|
-
canonical?: string;
|
|
38
|
-
og?: {
|
|
39
|
-
title?: string;
|
|
40
|
-
description?: string;
|
|
41
|
-
image?: string;
|
|
42
|
-
type?: string;
|
|
43
|
-
};
|
|
44
|
-
[key: string]: unknown;
|
|
45
|
-
}
|
package/src/pipeline/renderer.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { HtmlSerializer } from "../html/serializer.js";
|
|
2
|
-
import type { RenderContext, RenderResult, PageHead } from "./context.js";
|
|
3
|
-
import type { JamxNode } from "../jsx/jsx-runtime.js";
|
|
4
|
-
|
|
5
|
-
export interface PageComponentLike {
|
|
6
|
-
render: (ctx: RenderContext) => JamxNode;
|
|
7
|
-
meta?: (ctx: RenderContext) => PageHead;
|
|
8
|
-
layout?: LayoutComponentLike;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface LayoutComponentLike {
|
|
12
|
-
render: (props: { children: JamxNode; ctx: RenderContext }) => JamxNode;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Renderer SSR de JAMX.
|
|
17
|
-
*
|
|
18
|
-
* Convierte un componente de página en un string HTML completo,
|
|
19
|
-
* listo para ser enviado al cliente.
|
|
20
|
-
*
|
|
21
|
-
* Flujo:
|
|
22
|
-
* 1. Llamar page.meta() para obtener los metadatos del <head>
|
|
23
|
-
* 2. Llamar page.render() para obtener el árbol JSX del contenido
|
|
24
|
-
* 3. Si hay layout, envolver el contenido en él
|
|
25
|
-
* 4. Serializar el árbol completo a HTML string
|
|
26
|
-
* 5. Envolver en el documento HTML base
|
|
27
|
-
*/
|
|
28
|
-
export class SSRRenderer {
|
|
29
|
-
private readonly serializer: HtmlSerializer;
|
|
30
|
-
|
|
31
|
-
constructor() {
|
|
32
|
-
this.serializer = new HtmlSerializer();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Renderiza una página completa a HTML.
|
|
37
|
-
*/
|
|
38
|
-
async render(
|
|
39
|
-
page: PageComponentLike,
|
|
40
|
-
ctx: RenderContext,
|
|
41
|
-
): Promise<RenderResult> {
|
|
42
|
-
// 1. Obtener metadatos
|
|
43
|
-
const head = page.meta?.(ctx) ?? {};
|
|
44
|
-
|
|
45
|
-
// 2. Renderizar el contenido de la página
|
|
46
|
-
const contentNode = page.render(ctx);
|
|
47
|
-
|
|
48
|
-
// 3. Aplicar layout si existe
|
|
49
|
-
const bodyNode = page.layout
|
|
50
|
-
? page.layout.render({ children: contentNode, ctx })
|
|
51
|
-
: contentNode;
|
|
52
|
-
|
|
53
|
-
// 4. Serializar a string
|
|
54
|
-
const bodyHtml = this.serializer.serialize(bodyNode);
|
|
55
|
-
|
|
56
|
-
// 5. Construir el documento HTML completo
|
|
57
|
-
const html = this.buildDocument(bodyHtml, head, ctx);
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
html,
|
|
61
|
-
statusCode: 200,
|
|
62
|
-
headers: {
|
|
63
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Construye el documento HTML completo con el <head> y el <body>.
|
|
70
|
-
*/
|
|
71
|
-
private buildDocument(
|
|
72
|
-
bodyHtml: string,
|
|
73
|
-
head: PageHead,
|
|
74
|
-
ctx: RenderContext,
|
|
75
|
-
): string {
|
|
76
|
-
const title = head.title ?? "JAMX App";
|
|
77
|
-
const description = head.description ?? "";
|
|
78
|
-
const isDev = ctx.env === "development";
|
|
79
|
-
|
|
80
|
-
const metaTags = this.buildMetaTags(head);
|
|
81
|
-
|
|
82
|
-
return `<!DOCTYPE html>
|
|
83
|
-
<html lang="en">
|
|
84
|
-
<head>
|
|
85
|
-
<meta charset="UTF-8" />
|
|
86
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
87
|
-
<title>${escapeTitle(title)}</title>
|
|
88
|
-
${description ? `<meta name="description" content="${escapeAttr(description)}" />` : ""}
|
|
89
|
-
${metaTags}
|
|
90
|
-
<link rel="stylesheet" href="/__jamx/styles.css" />
|
|
91
|
-
</head>
|
|
92
|
-
<body>
|
|
93
|
-
<div id="__jamx_root__" data-route="${escapeAttr(ctx.path)}">${bodyHtml}</div>
|
|
94
|
-
<script type="module" src="/__jamx/client.js"${isDev ? ' data-dev="true"' : ""}></script>
|
|
95
|
-
</body>
|
|
96
|
-
</html>`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
private buildMetaTags(head: PageHead): string {
|
|
100
|
-
const tags: string[] = [];
|
|
101
|
-
|
|
102
|
-
if (head.og) {
|
|
103
|
-
const og = head.og;
|
|
104
|
-
if (og.title)
|
|
105
|
-
tags.push(
|
|
106
|
-
`<meta property="og:title" content="${escapeAttr(og.title)}" />`,
|
|
107
|
-
);
|
|
108
|
-
if (og.description)
|
|
109
|
-
tags.push(
|
|
110
|
-
`<meta property="og:description" content="${escapeAttr(og.description)}" />`,
|
|
111
|
-
);
|
|
112
|
-
if (og.image)
|
|
113
|
-
tags.push(
|
|
114
|
-
`<meta property="og:image" content="${escapeAttr(og.image)}" />`,
|
|
115
|
-
);
|
|
116
|
-
if (og.type)
|
|
117
|
-
tags.push(
|
|
118
|
-
`<meta property="og:type" content="${escapeAttr(og.type)}" />`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return tags.join("\n ");
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ── HELPERS ───────────────────────────────────────────────────────────────
|
|
127
|
-
|
|
128
|
-
function escapeTitle(str: string): string {
|
|
129
|
-
return str.replace(/</g, "<").replace(/>/g, ">");
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function escapeAttr(str: string): string {
|
|
133
|
-
return str
|
|
134
|
-
.replace(/&/g, "&")
|
|
135
|
-
.replace(/"/g, """)
|
|
136
|
-
.replace(/</g, "<")
|
|
137
|
-
.replace(/>/g, ">");
|
|
138
|
-
}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { ErrorBoundary } from "../../../src/error/boundary.js";
|
|
3
|
-
import { HttpException } from "@jamx-framework/core";
|
|
4
|
-
|
|
5
|
-
describe("ErrorBoundary", () => {
|
|
6
|
-
it("retorna caught: false si no hay error", async () => {
|
|
7
|
-
const result = await ErrorBoundary.wrap(async () => "hello", {
|
|
8
|
-
isDev: false,
|
|
9
|
-
});
|
|
10
|
-
expect(result.caught).toBe(false);
|
|
11
|
-
if (!result.caught) expect(result.value).toBe("hello");
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("retorna caught: true con HTML si hay error", async () => {
|
|
15
|
-
const result = await ErrorBoundary.wrap(
|
|
16
|
-
async () => {
|
|
17
|
-
throw new Error("Something broke");
|
|
18
|
-
},
|
|
19
|
-
{ isDev: false },
|
|
20
|
-
);
|
|
21
|
-
expect(result.caught).toBe(true);
|
|
22
|
-
if (result.caught) {
|
|
23
|
-
expect(result.statusCode).toBe(500);
|
|
24
|
-
expect(result.html).toContain("500");
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("usa el statusCode de HttpException", async () => {
|
|
29
|
-
const result = await ErrorBoundary.wrap(
|
|
30
|
-
async () => {
|
|
31
|
-
throw new HttpException(
|
|
32
|
-
404,
|
|
33
|
-
"Not Found",
|
|
34
|
-
"Page not found",
|
|
35
|
-
"NOT_FOUND",
|
|
36
|
-
);
|
|
37
|
-
},
|
|
38
|
-
{ isDev: false },
|
|
39
|
-
);
|
|
40
|
-
expect(result.caught).toBe(true);
|
|
41
|
-
if (result.caught) expect(result.statusCode).toBe(404);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("incluye stack trace en dev", async () => {
|
|
45
|
-
const result = await ErrorBoundary.wrap(
|
|
46
|
-
async () => {
|
|
47
|
-
throw new Error("Dev error");
|
|
48
|
-
},
|
|
49
|
-
{ isDev: true },
|
|
50
|
-
);
|
|
51
|
-
expect(result.caught).toBe(true);
|
|
52
|
-
if (result.caught) {
|
|
53
|
-
expect(result.html).toContain("Dev error");
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("toRenderError convierte Error genérico a statusCode 500", () => {
|
|
58
|
-
const error = ErrorBoundary.toRenderError(new Error("boom"));
|
|
59
|
-
expect(error.statusCode).toBe(500);
|
|
60
|
-
expect(error.message).toBe("boom");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("toRenderError maneja valores no-Error", () => {
|
|
64
|
-
const error = ErrorBoundary.toRenderError("string error");
|
|
65
|
-
expect(error.statusCode).toBe(500);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("incluye el path en el error", async () => {
|
|
69
|
-
const result = await ErrorBoundary.wrap(
|
|
70
|
-
async () => {
|
|
71
|
-
throw new Error("Page error");
|
|
72
|
-
},
|
|
73
|
-
{ isDev: false, path: "/about" },
|
|
74
|
-
);
|
|
75
|
-
expect(result.caught).toBe(true);
|
|
76
|
-
if (result.caught) expect(result.error.path).toBe("/about");
|
|
77
|
-
});
|
|
78
|
-
});
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { renderErrorPage } from "../../../src/error/error-page.js";
|
|
3
|
-
|
|
4
|
-
describe("renderErrorPage", () => {
|
|
5
|
-
it("incluye el status code en el HTML", () => {
|
|
6
|
-
const html = renderErrorPage(
|
|
7
|
-
{ statusCode: 404, message: "Not Found" },
|
|
8
|
-
false,
|
|
9
|
-
);
|
|
10
|
-
expect(html).toContain("404");
|
|
11
|
-
expect(html).toContain("Not Found");
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("incluye el mensaje de error", () => {
|
|
15
|
-
const html = renderErrorPage(
|
|
16
|
-
{ statusCode: 500, message: "Something went wrong" },
|
|
17
|
-
false,
|
|
18
|
-
);
|
|
19
|
-
expect(html).toContain("Something went wrong");
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("en dev muestra el stack trace", () => {
|
|
23
|
-
const html = renderErrorPage(
|
|
24
|
-
{
|
|
25
|
-
statusCode: 500,
|
|
26
|
-
message: "Error",
|
|
27
|
-
stack: "Error: test\n at foo.ts:1:1",
|
|
28
|
-
},
|
|
29
|
-
true,
|
|
30
|
-
);
|
|
31
|
-
expect(html).toContain("at foo.ts:1:1");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("en producción NO muestra el stack trace", () => {
|
|
35
|
-
const html = renderErrorPage(
|
|
36
|
-
{
|
|
37
|
-
statusCode: 500,
|
|
38
|
-
message: "Error",
|
|
39
|
-
stack: "Error: secret stack",
|
|
40
|
-
},
|
|
41
|
-
false,
|
|
42
|
-
);
|
|
43
|
-
expect(html).not.toContain("secret stack");
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("escapa HTML en el mensaje", () => {
|
|
47
|
-
const html = renderErrorPage(
|
|
48
|
-
{ statusCode: 400, message: '<script>alert("xss")</script>' },
|
|
49
|
-
false,
|
|
50
|
-
);
|
|
51
|
-
expect(html).not.toContain("<script>");
|
|
52
|
-
expect(html).toContain("<script>");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("genera HTML válido con DOCTYPE", () => {
|
|
56
|
-
const html = renderErrorPage(
|
|
57
|
-
{ statusCode: 404, message: "Not Found" },
|
|
58
|
-
false,
|
|
59
|
-
);
|
|
60
|
-
expect(html).toMatch(/^<!DOCTYPE html>/);
|
|
61
|
-
expect(html).toContain("</html>");
|
|
62
|
-
});
|
|
63
|
-
});
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { escapeHtml } from "../../../src/html/escape.js";
|
|
3
|
-
|
|
4
|
-
describe("escapeHtml", () => {
|
|
5
|
-
it("escapa &", () => {
|
|
6
|
-
expect(escapeHtml("a & b")).toBe("a & b");
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
it("escapa <", () => {
|
|
10
|
-
expect(escapeHtml("<div>")).toBe("<div>");
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('escapa "', () => {
|
|
14
|
-
expect(escapeHtml('"quoted"')).toBe(""quoted"");
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("escapa '", () => {
|
|
18
|
-
expect(escapeHtml("it's")).toBe("it's");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("escapa múltiples caracteres", () => {
|
|
22
|
-
expect(escapeHtml('<script>alert("xss")</script>')).toBe(
|
|
23
|
-
"<script>alert("xss")</script>",
|
|
24
|
-
);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("no modifica texto sin caracteres especiales", () => {
|
|
28
|
-
expect(escapeHtml("hello world")).toBe("hello world");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("convierte números a string", () => {
|
|
32
|
-
expect(escapeHtml(42)).toBe("42");
|
|
33
|
-
});
|
|
34
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { SSRRenderer } from "../../../src/pipeline/renderer.js";
|
|
3
|
-
import { jsx } from "../../../src/jsx/jsx-runtime.js";
|
|
4
|
-
import type { RenderContext } from "../../../src/pipeline/context.js";
|
|
5
|
-
|
|
6
|
-
const renderer = new SSRRenderer();
|
|
7
|
-
|
|
8
|
-
const ctx: RenderContext = {
|
|
9
|
-
path: "/test",
|
|
10
|
-
params: {},
|
|
11
|
-
query: {},
|
|
12
|
-
headers: {},
|
|
13
|
-
env: "test",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
describe("SSRRenderer", () => {
|
|
17
|
-
it("renderiza una página simple a HTML", async () => {
|
|
18
|
-
const page = {
|
|
19
|
-
render: () => jsx("h1", { children: "Hello World" }),
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const result = await renderer.render(page, ctx);
|
|
23
|
-
|
|
24
|
-
expect(result.statusCode).toBe(200);
|
|
25
|
-
expect(result.html).toContain("<!DOCTYPE html>");
|
|
26
|
-
expect(result.html).toContain("<h1>Hello World</h1>");
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("incluye el title de los metadatos", async () => {
|
|
30
|
-
const page = {
|
|
31
|
-
render: () => jsx("div", { children: "content" }),
|
|
32
|
-
meta: () => ({ title: "My Page Title" }),
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const result = await renderer.render(page, ctx);
|
|
36
|
-
expect(result.html).toContain("<title>My Page Title</title>");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("incluye la meta description", async () => {
|
|
40
|
-
const page = {
|
|
41
|
-
render: () => jsx("div", {}),
|
|
42
|
-
meta: () => ({ description: "Page description here" }),
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const result = await renderer.render(page, ctx);
|
|
46
|
-
expect(result.html).toContain("Page description here");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("incluye el __jamx_root__ con data-route", async () => {
|
|
50
|
-
const page = {
|
|
51
|
-
render: () => jsx("span", { children: "content" }),
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const result = await renderer.render(page, {
|
|
55
|
-
...ctx,
|
|
56
|
-
path: "/users/123",
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
expect(result.html).toContain('id="__jamx_root__"');
|
|
60
|
-
expect(result.html).toContain('data-route="/users/123"');
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("aplica el layout si está definido", async () => {
|
|
64
|
-
const page = {
|
|
65
|
-
render: () => jsx("main", { children: "Page content" }),
|
|
66
|
-
layout: {
|
|
67
|
-
render: ({ children }: { children: unknown }) =>
|
|
68
|
-
jsx("div", { className: "layout", children: children as never }),
|
|
69
|
-
},
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const result = await renderer.render(page, ctx);
|
|
73
|
-
expect(result.html).toContain('<div class="layout">');
|
|
74
|
-
expect(result.html).toContain("<main>Page content</main>");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("escapa el title para prevenir XSS", async () => {
|
|
78
|
-
const page = {
|
|
79
|
-
render: () => jsx("div", {}),
|
|
80
|
-
meta: () => ({ title: '<script>alert("xss")</script>' }),
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
const result = await renderer.render(page, ctx);
|
|
84
|
-
expect(result.html).not.toContain("<script>alert");
|
|
85
|
-
expect(result.html).toContain("<script>");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("retorna Content-Type correcto en headers", async () => {
|
|
89
|
-
const page = { render: () => jsx("div", {}) };
|
|
90
|
-
const result = await renderer.render(page, ctx);
|
|
91
|
-
expect(result.headers["Content-Type"]).toBe("text/html; charset=utf-8");
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
// packages/renderer/tests/unit/html/serializer.test.ts
|
|
2
|
-
|
|
3
|
-
import { describe, it, expect } from "vitest";
|
|
4
|
-
import { HtmlSerializer } from "../../../src/html/serializer.js";
|
|
5
|
-
import { jsx, Fragment } from "../../../src/jsx/jsx-runtime.js";
|
|
6
|
-
import type { JamxNode } from "../../../src/jsx/jsx-runtime.js";
|
|
7
|
-
|
|
8
|
-
const s = new HtmlSerializer();
|
|
9
|
-
|
|
10
|
-
describe("HtmlSerializer", () => {
|
|
11
|
-
describe("nodos primitivos", () => {
|
|
12
|
-
it("serializa strings con escape", () => {
|
|
13
|
-
expect(s.serialize("hello <world>")).toBe("hello <world>");
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("serializa números", () => {
|
|
17
|
-
expect(s.serialize(42)).toBe("42");
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("ignora null, undefined, false", () => {
|
|
21
|
-
expect(s.serialize(null)).toBe("");
|
|
22
|
-
expect(s.serialize(undefined)).toBe("");
|
|
23
|
-
expect(s.serialize(false)).toBe("");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("ignora true", () => {
|
|
27
|
-
expect(s.serialize(true)).toBe("");
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("elementos HTML", () => {
|
|
32
|
-
it("serializa un div simple", () => {
|
|
33
|
-
const el = jsx("div", { children: "hello" });
|
|
34
|
-
expect(s.serialize(el)).toBe("<div>hello</div>");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("serializa atributos", () => {
|
|
38
|
-
const el = jsx("div", { id: "app", class: "container" });
|
|
39
|
-
expect(s.serialize(el)).toContain('id="app"');
|
|
40
|
-
expect(s.serialize(el)).toContain('class="container"');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("convierte className a class", () => {
|
|
44
|
-
const el = jsx("div", { className: "my-class" });
|
|
45
|
-
expect(s.serialize(el)).toBe('<div class="my-class"></div>');
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("serializa elementos void sin closing tag", () => {
|
|
49
|
-
const el = jsx("br", {});
|
|
50
|
-
expect(s.serialize(el)).toBe("<br>");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("serializa input void con atributos", () => {
|
|
54
|
-
const el = jsx("input", { type: "text", placeholder: "Enter text" });
|
|
55
|
-
const html = s.serialize(el);
|
|
56
|
-
expect(html).toContain('type="text"');
|
|
57
|
-
expect(html).not.toContain("</input>");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("serializa atributos booleanos correctamente", () => {
|
|
61
|
-
const el = jsx("input", {
|
|
62
|
-
type: "checkbox",
|
|
63
|
-
disabled: true,
|
|
64
|
-
checked: true,
|
|
65
|
-
});
|
|
66
|
-
const html = s.serialize(el);
|
|
67
|
-
expect(html).toContain("disabled");
|
|
68
|
-
expect(html).toContain("checked");
|
|
69
|
-
expect(html).not.toContain('disabled="true"');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("omite atributos booleanos false", () => {
|
|
73
|
-
const el = jsx("input", { type: "checkbox", disabled: false });
|
|
74
|
-
expect(s.serialize(el)).not.toContain("disabled");
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("serializa style como objeto", () => {
|
|
78
|
-
const el = jsx("div", {
|
|
79
|
-
style: { backgroundColor: "red", fontSize: "16px" },
|
|
80
|
-
});
|
|
81
|
-
const html = s.serialize(el);
|
|
82
|
-
expect(html).toContain("background-color: red");
|
|
83
|
-
expect(html).toContain("font-size: 16px");
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("escapa el contenido de texto", () => {
|
|
87
|
-
const el = jsx("p", { children: '<script>alert("xss")</script>' });
|
|
88
|
-
const html = s.serialize(el);
|
|
89
|
-
expect(html).not.toContain("<script>");
|
|
90
|
-
expect(html).toContain("<script>");
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe("componentes funcionales", () => {
|
|
95
|
-
it("serializa componentes funcionales simples", () => {
|
|
96
|
-
const Greeting = ({ name }: { name: string }) =>
|
|
97
|
-
jsx("p", { children: `Hello, ${name}!` });
|
|
98
|
-
|
|
99
|
-
const el = jsx(Greeting as never, { name: "James" });
|
|
100
|
-
expect(s.serialize(el)).toBe("<p>Hello, James!</p>");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("serializa componentes anidados", () => {
|
|
104
|
-
const Card = ({ children }: { children: JamxNode }) =>
|
|
105
|
-
jsx("div", { className: "card", children });
|
|
106
|
-
|
|
107
|
-
const el = jsx(Card as never, {
|
|
108
|
-
children: jsx("p", { children: "Content" }),
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
expect(s.serialize(el)).toBe('<div class="card"><p>Content</p></div>');
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
describe("Fragment", () => {
|
|
116
|
-
it("serializa Fragment sin wrapper", () => {
|
|
117
|
-
const el = jsx(Fragment as never, {
|
|
118
|
-
children: [
|
|
119
|
-
jsx("li", { children: "Item 1" }),
|
|
120
|
-
jsx("li", { children: "Item 2" }),
|
|
121
|
-
],
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
expect(s.serialize(el)).toBe("<li>Item 1</li><li>Item 2</li>");
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
describe("arrays de children", () => {
|
|
129
|
-
it("serializa múltiples children", () => {
|
|
130
|
-
const el = jsx("ul", {
|
|
131
|
-
children: [
|
|
132
|
-
jsx("li", { children: "A" }),
|
|
133
|
-
jsx("li", { children: "B" }),
|
|
134
|
-
jsx("li", { children: "C" }),
|
|
135
|
-
],
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
expect(s.serialize(el)).toBe("<ul><li>A</li><li>B</li><li>C</li></ul>");
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "../../tsconfig.base.json",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"rootDir": "src",
|
|
5
|
-
"outDir": "dist",
|
|
6
|
-
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
|
7
|
-
"jsx": "react-jsx",
|
|
8
|
-
"jsxImportSource": "@jamx-framework/renderer"
|
|
9
|
-
},
|
|
10
|
-
"include": ["src/**/*"],
|
|
11
|
-
"references": [
|
|
12
|
-
{ "path": "../core" }
|
|
13
|
-
],
|
|
14
|
-
"exclude": ["node_modules", "dist", "tests"]
|
|
15
|
-
}
|
package/vitest.config.ts
DELETED