@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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// ── TIPOS DEL ÁRBOL JSX ───────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type JamxNode =
|
|
4
|
+
| string
|
|
5
|
+
| number
|
|
6
|
+
| boolean
|
|
7
|
+
| null
|
|
8
|
+
| undefined
|
|
9
|
+
| JamxElement
|
|
10
|
+
| JamxNode[];
|
|
11
|
+
|
|
12
|
+
export interface JamxElement {
|
|
13
|
+
type: string | ComponentFn;
|
|
14
|
+
props: Props;
|
|
15
|
+
key: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Props = Record<string, unknown> & {
|
|
19
|
+
children?: JamxNode | JamxNode[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ComponentFn = (props: Props) => JamxNode;
|
|
23
|
+
|
|
24
|
+
// ── JSX RUNTIME ───────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Función principal del JSX runtime.
|
|
28
|
+
* TypeScript llama esto cuando encuentra <Tag prop="value" />
|
|
29
|
+
*
|
|
30
|
+
* En lugar de React.createElement, JAMX usa esta función
|
|
31
|
+
* para construir el árbol de nodos que luego serializa a HTML.
|
|
32
|
+
*/
|
|
33
|
+
export function jsx(
|
|
34
|
+
type: string | ComponentFn,
|
|
35
|
+
props: Props,
|
|
36
|
+
key?: string,
|
|
37
|
+
): JamxElement {
|
|
38
|
+
return {
|
|
39
|
+
type,
|
|
40
|
+
props: props ?? {},
|
|
41
|
+
key: key ?? null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Versión para elementos con múltiples hijos.
|
|
47
|
+
* TypeScript la usa cuando hay más de un hijo.
|
|
48
|
+
*/
|
|
49
|
+
export const jsxs = jsx;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Versión dev con información adicional para debugging.
|
|
53
|
+
* En producción es igual a jsx.
|
|
54
|
+
*/
|
|
55
|
+
export const jsxDEV = jsx;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fragment — permite retornar múltiples elementos sin wrapper.
|
|
59
|
+
* Uso: <> <div/> <div/> </>
|
|
60
|
+
*/
|
|
61
|
+
export const Fragment = "jamx.fragment" as const;
|
|
62
|
+
|
|
63
|
+
// ── TIPOS GLOBALES JSX ────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Declaraciones de tipos para que TypeScript entienda el JSX de JAMX.
|
|
67
|
+
* Estas interfaces definen qué props acepta cada elemento HTML.
|
|
68
|
+
*/
|
|
69
|
+
export namespace JSX {
|
|
70
|
+
export type Element = JamxElement;
|
|
71
|
+
|
|
72
|
+
export interface ElementChildrenAttribute {
|
|
73
|
+
children: JamxNode | JamxNode[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface IntrinsicElements {
|
|
77
|
+
// Document
|
|
78
|
+
html: HtmlAttributes & { lang?: string };
|
|
79
|
+
head: HtmlAttributes;
|
|
80
|
+
body: HtmlAttributes;
|
|
81
|
+
title: HtmlAttributes;
|
|
82
|
+
meta: MetaAttributes;
|
|
83
|
+
link: LinkAttributes;
|
|
84
|
+
script: ScriptAttributes;
|
|
85
|
+
style: HtmlAttributes;
|
|
86
|
+
// Sections
|
|
87
|
+
main: HtmlAttributes;
|
|
88
|
+
header: HtmlAttributes;
|
|
89
|
+
footer: HtmlAttributes;
|
|
90
|
+
nav: HtmlAttributes;
|
|
91
|
+
section: HtmlAttributes;
|
|
92
|
+
article: HtmlAttributes;
|
|
93
|
+
aside: HtmlAttributes;
|
|
94
|
+
// Headings
|
|
95
|
+
h1: HtmlAttributes;
|
|
96
|
+
h2: HtmlAttributes;
|
|
97
|
+
h3: HtmlAttributes;
|
|
98
|
+
h4: HtmlAttributes;
|
|
99
|
+
h5: HtmlAttributes;
|
|
100
|
+
h6: HtmlAttributes;
|
|
101
|
+
// Text
|
|
102
|
+
p: HtmlAttributes;
|
|
103
|
+
span: HtmlAttributes;
|
|
104
|
+
strong: HtmlAttributes;
|
|
105
|
+
em: HtmlAttributes;
|
|
106
|
+
small: HtmlAttributes;
|
|
107
|
+
code: HtmlAttributes;
|
|
108
|
+
pre: HtmlAttributes;
|
|
109
|
+
// Lists
|
|
110
|
+
ul: HtmlAttributes;
|
|
111
|
+
ol: HtmlAttributes;
|
|
112
|
+
li: HtmlAttributes;
|
|
113
|
+
// Interactive
|
|
114
|
+
a: AnchorAttributes;
|
|
115
|
+
button: ButtonAttributes;
|
|
116
|
+
input: InputAttributes;
|
|
117
|
+
textarea: TextareaAttributes;
|
|
118
|
+
select: HtmlAttributes;
|
|
119
|
+
option: HtmlAttributes & { value?: string; selected?: boolean };
|
|
120
|
+
form: FormAttributes;
|
|
121
|
+
label: HtmlAttributes & { for?: string; htmlFor?: string };
|
|
122
|
+
// Media
|
|
123
|
+
img: ImgAttributes;
|
|
124
|
+
video: HtmlAttributes & {
|
|
125
|
+
src?: string;
|
|
126
|
+
controls?: boolean;
|
|
127
|
+
autoplay?: boolean;
|
|
128
|
+
};
|
|
129
|
+
audio: HtmlAttributes & { src?: string; controls?: boolean };
|
|
130
|
+
canvas: HtmlAttributes & { width?: number; height?: number };
|
|
131
|
+
// Layout
|
|
132
|
+
div: HtmlAttributes;
|
|
133
|
+
table: HtmlAttributes;
|
|
134
|
+
thead: HtmlAttributes;
|
|
135
|
+
tbody: HtmlAttributes;
|
|
136
|
+
tr: HtmlAttributes;
|
|
137
|
+
th: HtmlAttributes & { colSpan?: number; rowSpan?: number };
|
|
138
|
+
td: HtmlAttributes & { colSpan?: number; rowSpan?: number };
|
|
139
|
+
// Misc
|
|
140
|
+
br: HtmlAttributes;
|
|
141
|
+
hr: HtmlAttributes;
|
|
142
|
+
slot: HtmlAttributes & { name?: string };
|
|
143
|
+
// Generic fallback
|
|
144
|
+
[tag: string]: HtmlAttributes;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── TIPOS DE ATRIBUTOS ────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
export interface HtmlAttributes {
|
|
151
|
+
id?: string;
|
|
152
|
+
class?: string;
|
|
153
|
+
className?: string;
|
|
154
|
+
style?: string | Record<string, string>;
|
|
155
|
+
children?: JamxNode | JamxNode[];
|
|
156
|
+
// Data attributes
|
|
157
|
+
[key: `data-${string}`]: string | undefined;
|
|
158
|
+
// ARIA
|
|
159
|
+
role?: string;
|
|
160
|
+
"aria-label"?: string;
|
|
161
|
+
"aria-hidden"?: boolean;
|
|
162
|
+
"aria-expanded"?: boolean;
|
|
163
|
+
"aria-disabled"?: boolean;
|
|
164
|
+
"aria-selected"?: boolean;
|
|
165
|
+
"aria-controls"?: string;
|
|
166
|
+
"aria-describedby"?: string;
|
|
167
|
+
tabIndex?: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface AnchorAttributes extends HtmlAttributes {
|
|
171
|
+
href?: string;
|
|
172
|
+
target?: "_blank" | "_self" | "_parent" | "_top";
|
|
173
|
+
rel?: string;
|
|
174
|
+
download?: boolean | string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface ButtonAttributes extends HtmlAttributes {
|
|
178
|
+
type?: "button" | "submit" | "reset";
|
|
179
|
+
disabled?: boolean;
|
|
180
|
+
name?: string;
|
|
181
|
+
value?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface InputAttributes extends HtmlAttributes {
|
|
185
|
+
type?: string;
|
|
186
|
+
name?: string;
|
|
187
|
+
value?: string | number;
|
|
188
|
+
placeholder?: string;
|
|
189
|
+
required?: boolean;
|
|
190
|
+
disabled?: boolean;
|
|
191
|
+
readonly?: boolean;
|
|
192
|
+
checked?: boolean;
|
|
193
|
+
min?: string | number;
|
|
194
|
+
max?: string | number;
|
|
195
|
+
step?: string | number;
|
|
196
|
+
pattern?: string;
|
|
197
|
+
autocomplete?: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface TextareaAttributes extends HtmlAttributes {
|
|
201
|
+
name?: string;
|
|
202
|
+
rows?: number;
|
|
203
|
+
cols?: number;
|
|
204
|
+
placeholder?: string;
|
|
205
|
+
required?: boolean;
|
|
206
|
+
disabled?: boolean;
|
|
207
|
+
readonly?: boolean;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface FormAttributes extends HtmlAttributes {
|
|
211
|
+
action?: string;
|
|
212
|
+
method?: "get" | "post";
|
|
213
|
+
enctype?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface ImgAttributes extends HtmlAttributes {
|
|
217
|
+
src?: string;
|
|
218
|
+
alt?: string;
|
|
219
|
+
width?: number | string;
|
|
220
|
+
height?: number | string;
|
|
221
|
+
loading?: "lazy" | "eager";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface MetaAttributes extends HtmlAttributes {
|
|
225
|
+
name?: string;
|
|
226
|
+
content?: string;
|
|
227
|
+
charset?: string;
|
|
228
|
+
httpEquiv?: string;
|
|
229
|
+
property?: string;
|
|
230
|
+
"http-equiv"?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface LinkAttributes extends HtmlAttributes {
|
|
234
|
+
rel?: string;
|
|
235
|
+
href?: string;
|
|
236
|
+
type?: string;
|
|
237
|
+
media?: string;
|
|
238
|
+
as?: string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface ScriptAttributes extends HtmlAttributes {
|
|
242
|
+
src?: string;
|
|
243
|
+
type?: string;
|
|
244
|
+
async?: boolean;
|
|
245
|
+
defer?: boolean;
|
|
246
|
+
module?: boolean;
|
|
247
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
});
|