@axzydev/axzy_ui_system 1.6.0 → 1.7.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.
Files changed (71) hide show
  1. package/dist/index.cjs +7842 -4676
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +883 -3
  6. package/dist/index.d.ts +883 -3
  7. package/dist/index.js +9118 -5971
  8. package/dist/index.js.map +1 -1
  9. package/dist/layered.css +1 -1
  10. package/package.json +14 -7
  11. package/src/App.tsx +70 -2
  12. package/src/components/atoms/image/image.props.ts +2 -0
  13. package/src/components/atoms/image/image.tsx +3 -2
  14. package/src/components/molecules/json-editor/json-editor-tree.tsx +235 -0
  15. package/src/components/molecules/json-editor/json-editor.index.ts +2 -0
  16. package/src/components/molecules/json-editor/json-editor.props.ts +82 -0
  17. package/src/components/molecules/json-editor/json-editor.tsx +972 -0
  18. package/src/components/molecules/json-editor/json-editor.utils.ts +717 -0
  19. package/src/components/molecules/json-editor/useJsonEditorKeyboard.ts +256 -0
  20. package/src/components/molecules/json-renderer/json-renderer.index.ts +2 -0
  21. package/src/components/molecules/json-renderer/json-renderer.props.ts +31 -0
  22. package/src/components/molecules/json-renderer/json-renderer.tsx +79 -0
  23. package/src/components/organisms/dialog/dialog.props.ts +2 -0
  24. package/src/components/organisms/dialog/dialog.tsx +4 -3
  25. package/src/dev.css +30 -1
  26. package/src/hooks/useJsonRender.tsx +9 -0
  27. package/src/i18n/locales/en.ts +54 -0
  28. package/src/i18n/locales/es.ts +54 -0
  29. package/src/index.css +414 -0
  30. package/src/index.ts +99 -2
  31. package/src/json-renderer/catalog/axzyCatalog.ts +362 -0
  32. package/src/json-renderer/catalog/catalog.props.ts +128 -0
  33. package/src/json-renderer/catalog/resolve.ts +225 -0
  34. package/src/json-renderer/index.ts +70 -0
  35. package/src/json-renderer/kernel/NodeBoundary.tsx +53 -0
  36. package/src/json-renderer/kernel/SchemaRenderer.tsx +197 -0
  37. package/src/json-renderer/kernel/kernel.test.tsx +341 -0
  38. package/src/json-renderer/kernel/partial.ts +92 -0
  39. package/src/json-renderer/kernel/renderNode.tsx +113 -0
  40. package/src/json-renderer/kernel/schemaRenderer.props.ts +42 -0
  41. package/src/json-renderer/kernel/types.ts +159 -0
  42. package/src/json-renderer/shared/errors.ts +92 -0
  43. package/src/json-renderer/shared/limits.ts +87 -0
  44. package/src/json-renderer/shared/paths.ts +66 -0
  45. package/src/json-renderer/shared/shared.test.ts +108 -0
  46. package/src/json-renderer/stream/tolerantParse.test.ts +152 -0
  47. package/src/json-renderer/stream/tolerantParse.ts +449 -0
  48. package/src/json-renderer/test/setup.ts +38 -0
  49. package/src/json-renderer/validation/migrate.ts +83 -0
  50. package/src/json-renderer/validation/validate.ts +161 -0
  51. package/src/json-renderer/validation/validation.test.ts +165 -0
  52. package/src/json-renderer/vocabulary/buildContract.ts +158 -0
  53. package/src/sandbox/AxzyLogo.tsx +139 -0
  54. package/src/sandbox/axzyConfig.test.ts +40 -0
  55. package/src/sandbox/axzyConfig.ts +77 -0
  56. package/src/sandbox/i18n.ts +220 -0
  57. package/src/sandbox/navigation.ts +3 -0
  58. package/src/sandbox/useHashRoute.ts +39 -9
  59. package/src/showcases/DemoJsonDashboard.tsx +489 -0
  60. package/src/showcases/DemoJsonDocs.tsx +339 -0
  61. package/src/showcases/DemoJsonPortfolio.tsx +257 -0
  62. package/src/showcases/FeedbackShowcases.tsx +16 -5
  63. package/src/showcases/HomeShowcase.tsx +16 -9
  64. package/src/showcases/JsonEditorShowcase.tsx +150 -0
  65. package/src/showcases/JsonRendererShowcase.tsx +70 -0
  66. package/src/showcases/JsonScreenShowcase.tsx +341 -0
  67. package/src/showcases/StructureShowcases.tsx +6 -1
  68. package/src/showcases/demoApp/demoData.ts +181 -0
  69. package/src/showcases/demoApp/nodeTree.test.ts +39 -0
  70. package/src/showcases/demoApp/nodeTree.ts +35 -0
  71. package/src/showcases/demoApp/schemas.test.ts +54 -0
@@ -0,0 +1,341 @@
1
+ import React from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { render, screen as testingScreen } from "@testing-library/react";
4
+ import { axzyCatalog } from "../catalog/axzyCatalog";
5
+ import { resolveNode } from "../catalog/resolve";
6
+ import { parsePartialSchema } from "../stream/tolerantParse";
7
+ import type { JsonEngineIssue } from "../shared/errors";
8
+ import { auditTree } from "./partial";
9
+ import { resolveLimits } from "../shared/limits";
10
+ import { ITSchemaRenderer } from "./SchemaRenderer";
11
+ import type { NodeSchema, ScreenSchema } from "./types";
12
+
13
+ /** Esquema mínimo válido. */
14
+ const screen = (content: NodeSchema | NodeSchema[]): ScreenSchema => ({
15
+ version: 1,
16
+ id: "test",
17
+ content,
18
+ });
19
+
20
+ /** Acumula los avisos que emite el renderizador. */
21
+ function collectIssues() {
22
+ const issues: JsonEngineIssue[] = [];
23
+ return { issues, onIssue: (issue: JsonEngineIssue) => issues.push(issue) };
24
+ }
25
+
26
+ describe("resolveNode", () => {
27
+ it("resuelve el tipo y aplica los defaults del catálogo", () => {
28
+ const result = resolveNode({ type: "ITButton", props: { label: "Guardar" } }, axzyCatalog, ["content", 0]);
29
+ expect(result.ok).toBe(true);
30
+ if (!result.ok) return;
31
+ expect(result.props).toMatchObject({ label: "Guardar", color: "primary", size: "md", variant: "filled" });
32
+ expect(result.issues).toHaveLength(0);
33
+ });
34
+
35
+ it("ignora una prop no declarada y avisa con su ruta (JR011)", () => {
36
+ const result = resolveNode({ type: "ITButton", props: { label: "Ir", inexistente: 1 } }, axzyCatalog, ["content", 2]);
37
+ expect(result.ok).toBe(true);
38
+ if (!result.ok) return;
39
+ expect(result.props).not.toHaveProperty("inexistente");
40
+ expect(result.issues[0]?.code).toBe("JR011");
41
+ expect(result.issues[0]?.path).toBe("content[2].props.inexistente");
42
+ });
43
+
44
+ it("rechaza props prohibidas (nodos React disfrazados)", () => {
45
+ const result = resolveNode(
46
+ { type: "ITAlert", props: { variant: "info", icon: "no", dangerouslySetInnerHTML: { __html: "<b>x</b>" } } },
47
+ axzyCatalog,
48
+ ["content", 0]
49
+ );
50
+ expect(result.ok).toBe(true);
51
+ if (!result.ok) return;
52
+ expect(result.props).not.toHaveProperty("icon");
53
+ expect(result.props).not.toHaveProperty("dangerouslySetInnerHTML");
54
+ expect(result.issues.map((i) => i.code)).toEqual(["JR011", "JR011"]);
55
+ });
56
+
57
+ it("separa las acciones del resto de props", () => {
58
+ const result = resolveNode(
59
+ { type: "ITButton", props: { label: "Ir", onClick: { action: "ir" } } },
60
+ axzyCatalog,
61
+ ["content", 0]
62
+ );
63
+ expect(result.ok).toBe(true);
64
+ if (!result.ok) return;
65
+ expect(result.actions.onClick).toEqual({ action: "ir" });
66
+ expect(result.props).not.toHaveProperty("onClick");
67
+ });
68
+
69
+ it("rechaza valores fuera del enum", () => {
70
+ const result = resolveNode({ type: "ITAlert", props: { variant: "fucsia" } }, axzyCatalog, ["content", 0]);
71
+ expect(result.ok).toBe(true);
72
+ if (!result.ok) return;
73
+ expect(result.props.variant).toBe("info"); // se queda el default
74
+ expect(result.issues[0]?.code).toBe("JR011");
75
+ expect(result.issues[0]?.hint).toContain("info");
76
+ });
77
+
78
+ it("coacciona números y booleanos desde cadena", () => {
79
+ const result = resolveNode(
80
+ { type: "ITProgress", props: { value: "42", max: "100" } },
81
+ axzyCatalog,
82
+ ["content", 0]
83
+ );
84
+ expect(result.ok).toBe(true);
85
+ if (!result.ok) return;
86
+ expect(result.props.value).toBe(42);
87
+ expect(result.props.max).toBe(100);
88
+ });
89
+
90
+ it("falla con el tipo fuera del catálogo (JR010)", () => {
91
+ const result = resolveNode({ type: "ITInexistente" } as NodeSchema, axzyCatalog, ["content", 1]);
92
+ expect(result.ok).toBe(false);
93
+ expect(result.issues[0]?.code).toBe("JR010");
94
+ expect(result.issues[0]?.path).toBe("content[1]");
95
+ });
96
+
97
+ it("falla con un tipo excluido y explica el motivo", () => {
98
+ const result = resolveNode({ type: "ITWysiwyg" }, axzyCatalog, ["content", 0]);
99
+ expect(result.ok).toBe(false);
100
+ expect(result.issues[0]?.code).toBe("JR010");
101
+ expect(result.issues[0]?.hint).toContain("HTML");
102
+ });
103
+
104
+ it("no coacciona las referencias: las deja para que el host las resuelva", () => {
105
+ const result = resolveNode(
106
+ {
107
+ type: "ITStatCard",
108
+ props: { label: { i18n: "demo.label" }, value: { i18n: "demo.value" } },
109
+ },
110
+ axzyCatalog,
111
+ ["content", 0]
112
+ );
113
+ expect(result.ok).toBe(true);
114
+ if (!result.ok) return;
115
+ // El valor i18n sobrevive intacto y no cuenta como prop obligatoria ausente.
116
+ expect(result.props.label).toEqual({ i18n: "demo.label" });
117
+ expect(result.props.value).toEqual({ i18n: "demo.value" });
118
+ expect(result.issues).toHaveLength(0);
119
+ });
120
+
121
+ it("deja pasar las referencias de datos ($state, $source)", () => {
122
+ const result = resolveNode(
123
+ { type: "ITText", props: { as: "p" }, text: { i18n: "demo.text" } },
124
+ axzyCatalog,
125
+ ["content", 0]
126
+ );
127
+ expect(result.ok).toBe(true);
128
+ expect(result.issues).toHaveLength(0);
129
+ });
130
+
131
+ it("avisa si faltan props obligatorias", () => {
132
+ const result = resolveNode({ type: "ITImage" }, axzyCatalog, ["content", 0]);
133
+ expect(result.ok).toBe(true);
134
+ if (!result.ok) return;
135
+ expect(result.issues.some((i) => i.code === "JR011" && i.path.endsWith("props.src"))).toBe(true);
136
+ });
137
+
138
+ it("avisa si el componente no acepta children", () => {
139
+ const result = resolveNode(
140
+ { type: "ITDivider", children: [{ type: "ITText", text: "x" }] },
141
+ axzyCatalog,
142
+ ["content", 0]
143
+ );
144
+ expect(result.ok).toBe(true);
145
+ if (!result.ok) return;
146
+ expect(result.issues[0]?.path).toBe("content[0].children");
147
+ });
148
+ });
149
+
150
+ describe("auditTree", () => {
151
+ it("cuenta nodos, profundidad y pendientes", () => {
152
+ const audit = auditTree(
153
+ screen([
154
+ { type: "ITCard", children: [{ type: "ITText", text: "a" }, { type: "ITText", text: "b" }] },
155
+ { type: "ITText", text: "c" },
156
+ ]),
157
+ resolveLimits()
158
+ );
159
+ expect(audit.nodes).toBe(4);
160
+ expect(audit.depth).toBe(1);
161
+ expect(audit.pending).toBe(0);
162
+ expect(audit.exceeded).toBe(false);
163
+ });
164
+
165
+ it("recorta y avisa al superar el presupuesto (JR013)", () => {
166
+ const content: NodeSchema[] = Array.from({ length: 20 }, (_, i) => ({ type: "ITText", text: `t${i}` }));
167
+ const audit = auditTree(screen(content), resolveLimits({ maxNodes: 5 }));
168
+ expect(audit.exceeded).toBe(true);
169
+ expect(audit.nodes).toBe(5);
170
+ expect(audit.issues[0]?.code).toBe("JR013");
171
+ });
172
+
173
+ it("cuenta los nodos pendientes del stream", () => {
174
+ const { tree } = parsePartialSchema('{"version":1,"id":"x","content":[{"type":"ITCard","children":[{"type":"ITText"');
175
+ expect(tree).not.toBeNull();
176
+ const audit = auditTree(tree as ScreenSchema, resolveLimits());
177
+ expect(audit.pending).toBeGreaterThan(0);
178
+ });
179
+ });
180
+
181
+ describe("ITSchemaRenderer", () => {
182
+ it("pinta un esquema mínimo", () => {
183
+ render(<ITSchemaRenderer schema={screen({ type: "ITText", text: "Hola mundo" })} />);
184
+ expect(testingScreen.getByText("Hola mundo")).toBeTruthy();
185
+ });
186
+
187
+ it("resuelve el atajo `text` cuando es una referencia i18n", () => {
188
+ const { tree } = parsePartialSchema(
189
+ '{"version":1,"id":"x","content":[{"type":"ITText","text":{"i18n":"saludo"}}]}'
190
+ );
191
+ render(
192
+ <ITSchemaRenderer
193
+ schema={tree}
194
+ resolveValue={(value) =>
195
+ value && typeof value === "object" && "i18n" in (value as object)
196
+ ? "Hola traducido"
197
+ : value
198
+ }
199
+ />
200
+ );
201
+ expect(testingScreen.getByText("Hola traducido")).toBeTruthy();
202
+ });
203
+
204
+ it("refleja el `id` del nodo en el DOM (anclas)", () => {
205
+ const { container } = render(
206
+ <ITSchemaRenderer schema={screen({ type: "ITText", id: "ancla", text: "x" })} />
207
+ );
208
+ expect(container.querySelector("#ancla")).toBeTruthy();
209
+ });
210
+
211
+ it("pinta nodos anidados y aplica props del catálogo", () => {
212
+ render(
213
+ <ITSchemaRenderer
214
+ schema={screen({
215
+ type: "ITCard",
216
+ props: { title: "Mi tarjeta" },
217
+ children: [{ type: "ITText", text: "Dentro" }],
218
+ })}
219
+ />
220
+ );
221
+ expect(testingScreen.getByText("Mi tarjeta")).toBeTruthy();
222
+ expect(testingScreen.getByText("Dentro")).toBeTruthy();
223
+ });
224
+
225
+ it("no pinta nada y avisa con un tipo desconocido", () => {
226
+ const { issues, onIssue } = collectIssues();
227
+ const { container } = render(
228
+ <ITSchemaRenderer schema={screen({ type: "ITNope" })} onIssue={onIssue} />
229
+ );
230
+ expect(container.textContent).toBe("");
231
+ expect(issues.some((i) => i.code === "JR010")).toBe(true);
232
+ });
233
+
234
+ it("aísla el fallo de un nodo y mantiene el resto vivo (JR012)", () => {
235
+ const Boom = () => {
236
+ throw new Error("boom");
237
+ };
238
+ const catalog = {
239
+ entries: {
240
+ Boom: { type: "Boom", component: Boom },
241
+ ITText: axzyCatalog.entries.ITText,
242
+ },
243
+ excluded: {},
244
+ };
245
+ const spy = vi.spyOn(console, "error").mockImplementation(() => undefined);
246
+ const { issues, onIssue } = collectIssues();
247
+
248
+ render(
249
+ <ITSchemaRenderer
250
+ catalog={catalog}
251
+ onIssue={onIssue}
252
+ schema={screen([{ type: "Boom" }, { type: "ITText", text: "sigo vivo" }])}
253
+ />
254
+ );
255
+
256
+ expect(testingScreen.getByText("sigo vivo")).toBeTruthy();
257
+ expect(issues.some((i) => i.code === "JR012")).toBe(true);
258
+ spy.mockRestore();
259
+ });
260
+
261
+ it("un nodo pendiente no se pinta a medias", () => {
262
+ const { tree } = parsePartialSchema('{"version":1,"id":"x","content":[{"type":"ITCard","props":{"title":"Tar');
263
+ render(<ITSchemaRenderer schema={tree} pendingFallback={<span data-testid="pendiente" />} />);
264
+ // El nodo pendiente se sustituye por el marcador, no por una tarjeta a medias.
265
+ expect(testingScreen.queryByText("Tar")).toBeNull();
266
+ });
267
+
268
+ it("pinta progresivamente un esquema que llega por trozos", () => {
269
+ const full = '{"version":1,"id":"x","content":[{"type":"ITText","text":"Primero"},{"type":"ITText","text":"Segundo"}]}';
270
+ // Corte tras cerrar el primer nodo.
271
+ const cut = full.indexOf('},{"type":"ITText","text":"Segundo"');
272
+ const partial = parsePartialSchema(full.slice(0, cut + 1));
273
+
274
+ const { rerender } = render(<ITSchemaRenderer schema={partial.tree} />);
275
+ expect(testingScreen.getByText("Primero")).toBeTruthy();
276
+ expect(testingScreen.queryByText("Segundo")).toBeNull();
277
+
278
+ // Llega el resto: el árbol final pinta los dos.
279
+ const complete = parsePartialSchema(full);
280
+ rerender(<ITSchemaRenderer schema={complete.tree} />);
281
+ expect(testingScreen.getByText("Primero")).toBeTruthy();
282
+ expect(testingScreen.getByText("Segundo")).toBeTruthy();
283
+ });
284
+
285
+ it("no se cae con un esquema parcial sin content (streaming al inicio)", () => {
286
+ // Corte justo después de `id`, antes de que `content` exista.
287
+ const { tree } = parsePartialSchema('{"version":1,"id":"x"');
288
+ expect(tree).not.toBeNull();
289
+ expect(() => render(<ITSchemaRenderer schema={tree} />)).not.toThrow();
290
+ });
291
+
292
+ it("no se cae con content nulo o vacío", () => {
293
+ const { container } = render(<ITSchemaRenderer schema={{ version: 1, id: "x", content: [] }} />);
294
+ expect(container.textContent).toBe("");
295
+ expect(() =>
296
+ render(<ITSchemaRenderer schema={{ version: 1, id: "x", content: undefined as never }} />)
297
+ ).not.toThrow();
298
+ });
299
+
300
+ it("no pinta nada sin esquema", () => {
301
+ const { container } = render(<ITSchemaRenderer schema={null} />);
302
+ expect(container.textContent).toBe("");
303
+ });
304
+
305
+ it("entrega el aviso de presupuesto una vez", () => {
306
+ const { issues, onIssue } = collectIssues();
307
+ const content: NodeSchema[] = Array.from({ length: 10 }, (_, i) => ({ type: "ITText", text: `n${i}` }));
308
+ render(<ITSchemaRenderer schema={screen(content)} limits={{ maxNodes: 3 }} onIssue={onIssue} />);
309
+ expect(issues.filter((i) => i.code === "JR013")).toHaveLength(1);
310
+ });
311
+
312
+ it("avisa con JR014 si el id del nodo no llega al DOM", async () => {
313
+ // Componente que NO reenvía props al DOM (simula un contenedor cerrado).
314
+ const SinProps = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>;
315
+ const catalog = {
316
+ entries: {
317
+ SinProps: { type: "SinProps", component: SinProps, children: true },
318
+ ITText: axzyCatalog.entries.ITText,
319
+ },
320
+ excluded: {},
321
+ };
322
+ const { issues, onIssue } = collectIssues();
323
+ render(
324
+ <ITSchemaRenderer
325
+ catalog={catalog}
326
+ onIssue={onIssue}
327
+ schema={screen([{ type: "SinProps", id: "ancla-rota" }, { type: "ITText", id: "ancla-ok", text: "x" }])}
328
+ />
329
+ );
330
+ await Promise.resolve();
331
+ expect(issues.some((i) => i.code === "JR014" && i.path === "ancla-rota")).toBe(true);
332
+ expect(issues.some((i) => i.code === "JR014" && i.path === "ancla-ok")).toBe(false);
333
+ });
334
+
335
+ it("envuelve la salida cuando se pasa className", () => {
336
+ const { container } = render(
337
+ <ITSchemaRenderer schema={screen({ type: "ITText", text: "x" })} className="mi-clase" />
338
+ );
339
+ expect(container.querySelector(".mi-clase")).toBeTruthy();
340
+ });
341
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Presupuesto y nodos pendientes.
3
+ *
4
+ * Dos responsabilidades que van juntas porque las dos protegen el render:
5
+ * contar lo que se va a pintar (para no colgar el hilo con un esquema enorme) y
6
+ * saber cuánto falta por llegar (para no pintar nodos a medias).
7
+ */
8
+
9
+ import type { JsonEngineIssue } from "../shared/errors";
10
+ import { issue } from "../shared/errors";
11
+ import { createLimitTracker, type EngineLimits } from "../shared/limits";
12
+ import { isNodePending } from "../stream/tolerantParse";
13
+ import { childList } from "./renderNode";
14
+ import type { NodeSchema, ScreenSchema } from "./types";
15
+
16
+ /** Recuento de un esquema antes de pintarlo. */
17
+ export interface TreeAudit {
18
+ /** Nodos con `type` contados. */
19
+ nodes: number;
20
+ /** Profundidad máxima alcanzada. */
21
+ depth: number;
22
+ /** Nodos marcados como pendientes por el stream. */
23
+ pending: number;
24
+ /** `true` cuando se recortó por presupuesto. */
25
+ exceeded: boolean;
26
+ /** Avisos derivados del recuento. */
27
+ issues: JsonEngineIssue[];
28
+ }
29
+
30
+ /**
31
+ * Recorre el esquema sin pintarlo para saber su tamaño.
32
+ *
33
+ * Se hace antes del render a propósito: así el motor puede decidir recortar y
34
+ * avisar sin que React haya empezado a montar nada.
35
+ *
36
+ * @example
37
+ * auditTree({ version: 1, id: "x", content: [] }, resolveLimits()).nodes; // 0
38
+ */
39
+ export function auditTree(schema: ScreenSchema, limits: EngineLimits): TreeAudit {
40
+ const tracker = createLimitTracker(limits);
41
+ const issues: JsonEngineIssue[] = [];
42
+ let pending = 0;
43
+ let depth = 0;
44
+
45
+ const walk = (node: NodeSchema, path: string, level: number): void => {
46
+ if (!node || typeof node !== "object") return;
47
+ if (!tracker.visit(level)) return;
48
+ if (level > depth) depth = level;
49
+ if (isNodePending(node)) pending += 1;
50
+
51
+ const children = childList(node);
52
+ children.forEach((child, index) => {
53
+ walk(child, `${path}[${index}]`, level + 1);
54
+ });
55
+
56
+ // Un nodo con `fallback` añade un subárbol más al presupuesto.
57
+ if (node.fallback) walk(node.fallback, `${path}.fallback`, level + 1);
58
+ };
59
+
60
+ const content = Array.isArray(schema.content) ? schema.content : [schema.content];
61
+ content.forEach((node, index) => walk(node, `content[${index}]`, 0));
62
+
63
+ if (tracker.exceeded) {
64
+ issues.push(
65
+ issue(
66
+ "JR013",
67
+ `El esquema supera el presupuesto (${tracker.nodes} nodos, profundidad ${tracker.deepest}).`,
68
+ "content",
69
+ {
70
+ severity: "warning",
71
+ hint: `Máximo configurado: ${limits.maxNodes} nodos y profundidad ${limits.maxDepth}. La parte que sobra no se pinta.`,
72
+ }
73
+ )
74
+ );
75
+ }
76
+
77
+ return { nodes: tracker.nodes, depth: tracker.deepest, pending, exceeded: tracker.exceeded, issues };
78
+ }
79
+
80
+ /** Cuenta los nodos pendientes de un esquema ya parseado. */
81
+ export function countPendingNodes(schema: ScreenSchema | null): number {
82
+ if (!schema) return 0;
83
+ let total = 0;
84
+ const walk = (node: NodeSchema): void => {
85
+ if (!node || typeof node !== "object") return;
86
+ if (isNodePending(node)) total += 1;
87
+ for (const child of childList(node)) walk(child);
88
+ };
89
+ const content = Array.isArray(schema.content) ? schema.content : [schema.content];
90
+ for (const node of content) walk(node);
91
+ return total;
92
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Renderizado de un nodo del esquema.
3
+ *
4
+ * Toma un `NodeSchema`, lo resuelve contra el catálogo y devuelve el elemento
5
+ * React. Todo lo que no puede resolverse se convierte en avisos, nunca en una
6
+ * excepción: el motor tiene que poder pintar un esquema a medias sin caerse.
7
+ */
8
+
9
+ import React from "react";
10
+ import type { Catalog } from "../catalog/catalog.props";
11
+ import { resolveNode } from "../catalog/resolve";
12
+ import type { JsonEngineIssue } from "../shared/errors";
13
+ import type { SchemaPath } from "../shared/paths";
14
+ import { isNodePending } from "../stream/tolerantParse";
15
+ import type { NodeSchema } from "./types";
16
+
17
+ /** Opciones de render de un nodo. */
18
+ export interface RenderNodeOptions {
19
+ /** Catálogo contra el que se resuelve el tipo. */
20
+ catalog: Catalog;
21
+ /** Ruta del nodo dentro del esquema (para avisos). */
22
+ path: SchemaPath;
23
+ /** Acumulador de avisos del render en curso. */
24
+ issues: JsonEngineIssue[];
25
+ /** Resuelve un valor del esquema (binding). Por defecto, identidad. */
26
+ resolveValue?: (value: unknown, path: SchemaPath) => unknown;
27
+ /** Resuelve el handler de una acción declarada. */
28
+ resolveAction?: (
29
+ node: NodeSchema,
30
+ prop: string,
31
+ action: unknown,
32
+ path: SchemaPath
33
+ ) => ((...args: unknown[]) => void) | undefined;
34
+ /** Renderiza los hijos de un nodo (lo aporta el recorrido del árbol). */
35
+ renderChildren?: (node: NodeSchema, path: SchemaPath) => React.ReactNode;
36
+ /** Qué pintar mientras un nodo está pendiente (streaming). */
37
+ pendingFallback?: React.ReactNode;
38
+ /** Componente para envolver cada nodo. Por defecto, `NodeBoundary`. */
39
+ wrap?: (node: NodeSchema, path: SchemaPath, content: React.ReactNode) => React.ReactNode;
40
+ }
41
+
42
+ /** `true` cuando el nodo tiene `children` declarados. */
43
+ export function hasChildren(node: NodeSchema | null | undefined): boolean {
44
+ return Boolean(node) && node!.children !== undefined && node!.children !== null;
45
+ }
46
+
47
+ /**
48
+ * Convierte los hijos declarados en una lista, sin importar su forma.
49
+ *
50
+ * Tolera un nodo ausente a propósito: un esquema parcial puede no tener todavía
51
+ * `content`, y el kernel no debe caerse por eso.
52
+ */
53
+ export function childList(node: NodeSchema | null | undefined): NodeSchema[] {
54
+ if (!node || node.children === undefined || node.children === null) return [];
55
+ return Array.isArray(node.children) ? node.children : [node.children];
56
+ }
57
+
58
+ /**
59
+ * Pinta un nodo del esquema.
60
+ *
61
+ * @example
62
+ * renderNode({ type: "ITText", text: "Hola" }, { catalog: axzyCatalog, path: ["content", 0], issues: [] });
63
+ */
64
+ export function renderNode(node: NodeSchema, options: RenderNodeOptions): React.ReactNode {
65
+ const { catalog, path, issues, renderChildren } = options;
66
+
67
+ if (!node || typeof node !== "object") {
68
+ return null;
69
+ }
70
+
71
+ // Un nodo que aún no ha llegado por el stream no se pinta a medias.
72
+ if (isNodePending(node)) {
73
+ return options.pendingFallback ?? null;
74
+ }
75
+
76
+ const resolved = resolveNode(node, catalog, path);
77
+ issues.push(...resolved.issues);
78
+
79
+ if (!resolved.ok) {
80
+ return null;
81
+ }
82
+
83
+ const { entry, props, actions } = resolved;
84
+
85
+ // Props declarativas que necesitan resolución (binding, i18n, acciones).
86
+ const finalProps: Record<string, unknown> = {};
87
+ for (const [name, value] of Object.entries(props)) {
88
+ finalProps[name] = options.resolveValue ? options.resolveValue(value, path) : value;
89
+ }
90
+
91
+ // Las acciones se traducen a handlers del host; las no registradas se avisan.
92
+ for (const [name, action] of Object.entries(actions)) {
93
+ const handler = options.resolveAction?.(node, name, action, path);
94
+ if (typeof handler === "function") finalProps[name] = handler;
95
+ }
96
+
97
+ // Contenido: `children` declarados y, si el componente acepta texto, el atajo.
98
+ // El atajo también pasa por `resolveValue` para poder ser una clave i18n.
99
+ const content = renderChildren ? renderChildren(node, path) : null;
100
+ const rawText = entry.text && node.text !== undefined ? node.text : undefined;
101
+ const text = rawText === undefined ? undefined : options.resolveValue ? options.resolveValue(rawText, path) : rawText;
102
+ const children = (content ?? text) as React.ReactNode;
103
+
104
+ // El `id` del nodo se refleja en el DOM: sirve de ancla para `ir-seccion`,
105
+ // para enlazar a una parte de la pantalla y para localizarla en pruebas.
106
+ const element = React.createElement(
107
+ entry.component,
108
+ { ...finalProps, key: node.id ?? path.join("-"), id: node.id },
109
+ children
110
+ );
111
+
112
+ return options.wrap ? options.wrap(node, path, element) : element;
113
+ }
@@ -0,0 +1,42 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Catalog } from "../catalog/catalog.props";
3
+ import type { JsonEngineIssue } from "../shared/errors";
4
+ import type { EngineLimits } from "../shared/limits";
5
+ import type { SchemaPath } from "../shared/paths";
6
+ import type { NodeSchema, ScreenSchema } from "./types";
7
+
8
+ /**
9
+ * Props de `ITSchemaRenderer`.
10
+ *
11
+ * El renderizador es el núcleo del motor: pinta un esquema contra un catálogo y
12
+ * entrega los avisos sin lanzar nunca por culpa del esquema.
13
+ */
14
+ export interface ITJsonRenderEngineProps {
15
+ /** Esquema de pantalla a pintar. Admite un árbol parcial (streaming). */
16
+ schema: ScreenSchema | null | undefined;
17
+ /** Catálogo de componentes permitidos. Por defecto, el de AXZY. */
18
+ catalog?: Catalog;
19
+ /** Presupuesto de nodos, profundidad y tiempos. */
20
+ limits?: Partial<EngineLimits>;
21
+ /**
22
+ * Resuelve un valor declarativo del esquema (`$state`, `$param`, `$source`…) al
23
+ * valor real. Sin esta prop, los valores se pasan tal cual (útil para pruebas).
24
+ */
25
+ resolveValue?: (value: unknown, path: SchemaPath) => unknown;
26
+ /**
27
+ * Traduce una acción declarada a un handler ejecutable. Sin esta prop, las
28
+ * acciones se ignoran y se avisa una sola vez por nodo.
29
+ */
30
+ resolveAction?: (
31
+ node: NodeSchema,
32
+ prop: string,
33
+ action: unknown,
34
+ path: SchemaPath
35
+ ) => ((...args: unknown[]) => void) | undefined;
36
+ /** Qué pintar mientras un nodo está pendiente (streaming). */
37
+ pendingFallback?: ReactNode;
38
+ /** Se llama por cada aviso del motor, con código y ruta. */
39
+ onIssue?: (issue: JsonEngineIssue) => void;
40
+ /** Clases del contenedor raíz. */
41
+ className?: string;
42
+ }