@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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/error/boundary.d.ts +36 -0
  4. package/dist/error/boundary.d.ts.map +1 -0
  5. package/dist/error/boundary.js +60 -0
  6. package/dist/error/boundary.js.map +1 -0
  7. package/dist/error/error-page.d.ts +7 -0
  8. package/dist/error/error-page.d.ts.map +1 -0
  9. package/dist/error/error-page.js +132 -0
  10. package/dist/error/error-page.js.map +1 -0
  11. package/dist/error/types.d.ts +7 -0
  12. package/dist/error/types.d.ts.map +1 -0
  13. package/dist/error/types.js +2 -0
  14. package/dist/error/types.js.map +1 -0
  15. package/dist/html/escape.d.ts +19 -0
  16. package/dist/html/escape.d.ts.map +1 -0
  17. package/dist/html/escape.js +85 -0
  18. package/dist/html/escape.js.map +1 -0
  19. package/dist/html/serializer.d.ts +20 -0
  20. package/dist/html/serializer.d.ts.map +1 -0
  21. package/dist/html/serializer.js +132 -0
  22. package/dist/html/serializer.js.map +1 -0
  23. package/dist/index.d.ts +12 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +7 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/jsx/jsx-runtime.d.ts +213 -0
  28. package/dist/jsx/jsx-runtime.d.ts.map +1 -0
  29. package/dist/jsx/jsx-runtime.js +32 -0
  30. package/dist/jsx/jsx-runtime.js.map +1 -0
  31. package/dist/pipeline/context.d.ts +44 -0
  32. package/dist/pipeline/context.d.ts.map +1 -0
  33. package/dist/pipeline/context.js +2 -0
  34. package/dist/pipeline/context.js.map +1 -0
  35. package/dist/pipeline/renderer.d.ts +40 -0
  36. package/dist/pipeline/renderer.d.ts.map +1 -0
  37. package/dist/pipeline/renderer.js +95 -0
  38. package/dist/pipeline/renderer.js.map +1 -0
  39. package/package.json +38 -0
  40. package/src/error/boundary.ts +80 -0
  41. package/src/error/error-page.ts +137 -0
  42. package/src/error/types.ts +6 -0
  43. package/src/html/escape.ts +90 -0
  44. package/src/html/serializer.ts +161 -0
  45. package/src/index.ts +33 -0
  46. package/src/jsx/jsx-runtime.ts +247 -0
  47. package/src/pipeline/context.ts +45 -0
  48. package/src/pipeline/renderer.ts +138 -0
  49. package/test/unit/error/boundary.test.ts +78 -0
  50. package/test/unit/error/error-page.test.ts +63 -0
  51. package/test/unit/html/escape.test.ts +34 -0
  52. package/test/unit/html/renderer.test.ts +93 -0
  53. package/test/unit/html/serializer.test.ts +141 -0
  54. package/tsconfig.json +15 -0
  55. 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, "&amp;")
133
+ .replace(/</g, "&lt;")
134
+ .replace(/>/g, "&gt;")
135
+ .replace(/"/g, "&quot;")
136
+ .replace(/'/g, "&#039;");
137
+ }
@@ -0,0 +1,6 @@
1
+ export interface RenderError {
2
+ statusCode: number;
3
+ message: string;
4
+ stack?: string | undefined;
5
+ path?: string | undefined;
6
+ }
@@ -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 += "&amp;";
16
+ break;
17
+ case "<":
18
+ result += "&lt;";
19
+ break;
20
+ case ">":
21
+ result += "&gt;";
22
+ break;
23
+ case '"':
24
+ result += "&quot;";
25
+ break;
26
+ case "'":
27
+ result += "&#39;";
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";