@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.
- package/dist/index.cjs +7842 -4676
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +883 -3
- package/dist/index.d.ts +883 -3
- package/dist/index.js +9118 -5971
- package/dist/index.js.map +1 -1
- package/dist/layered.css +1 -1
- package/package.json +14 -7
- package/src/App.tsx +70 -2
- package/src/components/atoms/image/image.props.ts +2 -0
- package/src/components/atoms/image/image.tsx +3 -2
- package/src/components/molecules/json-editor/json-editor-tree.tsx +235 -0
- package/src/components/molecules/json-editor/json-editor.index.ts +2 -0
- package/src/components/molecules/json-editor/json-editor.props.ts +82 -0
- package/src/components/molecules/json-editor/json-editor.tsx +972 -0
- package/src/components/molecules/json-editor/json-editor.utils.ts +717 -0
- package/src/components/molecules/json-editor/useJsonEditorKeyboard.ts +256 -0
- package/src/components/molecules/json-renderer/json-renderer.index.ts +2 -0
- package/src/components/molecules/json-renderer/json-renderer.props.ts +31 -0
- package/src/components/molecules/json-renderer/json-renderer.tsx +79 -0
- package/src/components/organisms/dialog/dialog.props.ts +2 -0
- package/src/components/organisms/dialog/dialog.tsx +4 -3
- package/src/dev.css +30 -1
- package/src/hooks/useJsonRender.tsx +9 -0
- package/src/i18n/locales/en.ts +54 -0
- package/src/i18n/locales/es.ts +54 -0
- package/src/index.css +414 -0
- package/src/index.ts +99 -2
- package/src/json-renderer/catalog/axzyCatalog.ts +362 -0
- package/src/json-renderer/catalog/catalog.props.ts +128 -0
- package/src/json-renderer/catalog/resolve.ts +225 -0
- package/src/json-renderer/index.ts +70 -0
- package/src/json-renderer/kernel/NodeBoundary.tsx +53 -0
- package/src/json-renderer/kernel/SchemaRenderer.tsx +197 -0
- package/src/json-renderer/kernel/kernel.test.tsx +341 -0
- package/src/json-renderer/kernel/partial.ts +92 -0
- package/src/json-renderer/kernel/renderNode.tsx +113 -0
- package/src/json-renderer/kernel/schemaRenderer.props.ts +42 -0
- package/src/json-renderer/kernel/types.ts +159 -0
- package/src/json-renderer/shared/errors.ts +92 -0
- package/src/json-renderer/shared/limits.ts +87 -0
- package/src/json-renderer/shared/paths.ts +66 -0
- package/src/json-renderer/shared/shared.test.ts +108 -0
- package/src/json-renderer/stream/tolerantParse.test.ts +152 -0
- package/src/json-renderer/stream/tolerantParse.ts +449 -0
- package/src/json-renderer/test/setup.ts +38 -0
- package/src/json-renderer/validation/migrate.ts +83 -0
- package/src/json-renderer/validation/validate.ts +161 -0
- package/src/json-renderer/validation/validation.test.ts +165 -0
- package/src/json-renderer/vocabulary/buildContract.ts +158 -0
- package/src/sandbox/AxzyLogo.tsx +139 -0
- package/src/sandbox/axzyConfig.test.ts +40 -0
- package/src/sandbox/axzyConfig.ts +77 -0
- package/src/sandbox/i18n.ts +220 -0
- package/src/sandbox/navigation.ts +3 -0
- package/src/sandbox/useHashRoute.ts +39 -9
- package/src/showcases/DemoJsonDashboard.tsx +489 -0
- package/src/showcases/DemoJsonDocs.tsx +339 -0
- package/src/showcases/DemoJsonPortfolio.tsx +257 -0
- package/src/showcases/FeedbackShowcases.tsx +16 -5
- package/src/showcases/HomeShowcase.tsx +16 -9
- package/src/showcases/JsonEditorShowcase.tsx +150 -0
- package/src/showcases/JsonRendererShowcase.tsx +70 -0
- package/src/showcases/JsonScreenShowcase.tsx +341 -0
- package/src/showcases/StructureShowcases.tsx +6 -1
- package/src/showcases/demoApp/demoData.ts +181 -0
- package/src/showcases/demoApp/nodeTree.test.ts +39 -0
- package/src/showcases/demoApp/nodeTree.ts +35 -0
- package/src/showcases/demoApp/schemas.test.ts +54 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datos simulados del dashboard de demostración.
|
|
3
|
+
*
|
|
4
|
+
* Hace el papel del backend: el motor **nunca** hace red, así que el host expone
|
|
5
|
+
* un `fetcher` por nombre de fuente (`usuarios`, `pedidos`) y el esquema solo
|
|
6
|
+
* escribe `{ "$source": "usuarios" }`. Es exactamente el contrato de la fase 4:
|
|
7
|
+
* el esquema nombra, el host resuelve.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ITDataTableFetchParams, ITDataTableResponse } from "../../index";
|
|
11
|
+
|
|
12
|
+
/** Un usuario del sistema. */
|
|
13
|
+
export interface DemoUsuario extends Record<string, unknown> {
|
|
14
|
+
id: number;
|
|
15
|
+
nombre: string;
|
|
16
|
+
correo: string;
|
|
17
|
+
rol: string;
|
|
18
|
+
estado: string;
|
|
19
|
+
alta: string;
|
|
20
|
+
edad: number;
|
|
21
|
+
empresa: string;
|
|
22
|
+
turno: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Un pedido del sistema. */
|
|
26
|
+
export interface DemoPedido extends Record<string, unknown> {
|
|
27
|
+
folio: string;
|
|
28
|
+
cliente: string;
|
|
29
|
+
total: number;
|
|
30
|
+
estado: string;
|
|
31
|
+
fecha: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fuentes disponibles, por nombre. */
|
|
35
|
+
export type DemoSource = "usuarios" | "pedidos" | "productos";
|
|
36
|
+
|
|
37
|
+
/** Usuarios de ejemplo. */
|
|
38
|
+
export const USUARIOS: DemoUsuario[] = [
|
|
39
|
+
"Ana Ruiz", "Luis Paredes", "Marcela Ortiz", "Jorge Cano", "Sofía Lara",
|
|
40
|
+
"Diego Núñez", "Paula Ferrer", "Iván Robles", "Nora Salas", "Emilio Tapia",
|
|
41
|
+
"Clara Vidal", "Hugo Medina", "Rita Campos", "Abel Cordero", "Lucía Ponce",
|
|
42
|
+
"Mateo Barrios", "Elena Ríos", "Tomás Ávila", "Inés Lozano", "Bruno Cárdenas",
|
|
43
|
+
"Alba Solís", "Ciro Mena", "Dora Ibáñez", "Fabián Correa", "Gala Nieves",
|
|
44
|
+
].map((nombre, index) => ({
|
|
45
|
+
id: index + 1,
|
|
46
|
+
nombre,
|
|
47
|
+
correo: `${nombre.toLowerCase().replace(/[^a-z]+/g, ".")}@axzy.dev`,
|
|
48
|
+
rol: ["Admin", "Operador", "Auditor", "Soporte"][index % 4],
|
|
49
|
+
estado: index % 7 === 0 ? "Inactivo" : "Activo",
|
|
50
|
+
alta: `2026-0${(index % 9) + 1}-${String((index % 27) + 1).padStart(2, "0")}`,
|
|
51
|
+
edad: 22 + (index % 30),
|
|
52
|
+
empresa: ["CYC ERP", "Puerto Nuevo", "CheckApp", "Grupo Delta", "Nova Salud"][index % 5],
|
|
53
|
+
turno: ["08:00", "09:30", "14:00", "22:00"][index % 4],
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const CLIENTES = ["CYC ERP", "Puerto Nuevo", "CheckApp", "Grupo Delta", "Nova Salud", "Ferretería Sur"];
|
|
57
|
+
const ESTADOS = ["Pagado", "Pendiente", "Enviado", "Cancelado"];
|
|
58
|
+
|
|
59
|
+
/** Pedidos de ejemplo. */
|
|
60
|
+
export const PEDIDOS: DemoPedido[] = Array.from({ length: 42 }, (_, index) => ({
|
|
61
|
+
folio: `PED-${String(1042 + index).padStart(4, "0")}`,
|
|
62
|
+
cliente: CLIENTES[index % CLIENTES.length],
|
|
63
|
+
total: Math.round((180 + ((index * 137) % 2400)) * 100) / 100,
|
|
64
|
+
estado: ESTADOS[index % ESTADOS.length],
|
|
65
|
+
fecha: `2026-${String((index % 9) + 1).padStart(2, "0")}-${String((index % 27) + 1).padStart(2, "0")}`,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
/** Borrador de usuario que envía el formulario del host. */
|
|
69
|
+
export interface DemoUsuarioInput {
|
|
70
|
+
nombre: string;
|
|
71
|
+
correo: string;
|
|
72
|
+
rol: string;
|
|
73
|
+
estado: string;
|
|
74
|
+
edad: number;
|
|
75
|
+
empresa: string;
|
|
76
|
+
turno: string;
|
|
77
|
+
alta: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Crea un usuario y devuelve su id. */
|
|
81
|
+
export function crearUsuario(input: DemoUsuarioInput): number {
|
|
82
|
+
const id = USUARIOS.reduce((max, usuario) => Math.max(max, usuario.id), 0) + 1;
|
|
83
|
+
USUARIOS.unshift({ id, ...input, alta: new Date().toISOString().slice(0, 10) });
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Actualiza un usuario por id. */
|
|
88
|
+
export function actualizarUsuario(id: number, input: DemoUsuarioInput): void {
|
|
89
|
+
const indice = USUARIOS.findIndex((usuario) => usuario.id === id);
|
|
90
|
+
if (indice >= 0) USUARIOS[indice] = { ...USUARIOS[indice], ...input };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Elimina un usuario por id. */
|
|
94
|
+
export function eliminarUsuario(id: number): void {
|
|
95
|
+
const indice = USUARIOS.findIndex((usuario) => usuario.id === id);
|
|
96
|
+
if (indice >= 0) USUARIOS.splice(indice, 1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Busca un usuario por id. */
|
|
100
|
+
export function obtenerUsuario(id: number): DemoUsuario | undefined {
|
|
101
|
+
return USUARIOS.find((usuario) => usuario.id === id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Productos de ejemplo. */
|
|
105
|
+
export const PRODUCTOS_DEMO = [
|
|
106
|
+
["AX-1001", "Teclado mecánico", 1290, 24, "Periféricos"],
|
|
107
|
+
["AX-1002", "Monitor 27\"", 6890, 8, "Pantallas"],
|
|
108
|
+
["AX-1003", "Mouse inalámbrico", 590, 61, "Periféricos"],
|
|
109
|
+
["AX-1004", "Docking station", 2450, 12, "Conectividad"],
|
|
110
|
+
["AX-1005", "Webcam 4K", 1890, 0, "Video"],
|
|
111
|
+
["AX-1006", "Audífonos", 990, 37, "Audio"],
|
|
112
|
+
["AX-1007", "Hub USB-C", 790, 44, "Conectividad"],
|
|
113
|
+
["AX-1008", "Silla ergonómica", 5490, 5, "Mobiliario"],
|
|
114
|
+
["AX-1009", "Escritorio eléctrico", 8990, 3, "Mobiliario"],
|
|
115
|
+
["AX-1010", "Lámpara de escritorio", 690, 28, "Iluminación"],
|
|
116
|
+
["AX-1011", "Base para laptop", 450, 52, "Accesorios"],
|
|
117
|
+
["AX-1012", "Cable HDMI 2.1", 320, 90, "Cables"],
|
|
118
|
+
].map(([sku, nombre, precio, stock, categoria]) => ({ sku, nombre, precio, stock, categoria }));
|
|
119
|
+
|
|
120
|
+
/** Colecciones por fuente. */
|
|
121
|
+
const COLECCIONES: Record<DemoSource, Record<string, unknown>[]> = {
|
|
122
|
+
usuarios: USUARIOS as unknown as Record<string, unknown>[],
|
|
123
|
+
pedidos: PEDIDOS as unknown as Record<string, unknown>[],
|
|
124
|
+
productos: PRODUCTOS_DEMO as unknown as Record<string, unknown>[],
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** `true` cuando el nombre es una fuente registrada. */
|
|
128
|
+
export function isDemoSource(name: string): name is DemoSource {
|
|
129
|
+
return name in COLECCIONES;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Filtro de estado activo (lo escribe el host). */
|
|
133
|
+
let estadoFiltro: string | null = null;
|
|
134
|
+
|
|
135
|
+
/** Fija el filtro de estado global; `null`/`Todos` lo desactiva. */
|
|
136
|
+
export function setEstadoFiltro(estado: string | null): void {
|
|
137
|
+
estadoFiltro = estado && estado !== "Todos" ? estado : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Compara dos valores para ordenar. */
|
|
141
|
+
function compare(a: unknown, b: unknown): number {
|
|
142
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
143
|
+
return String(a).localeCompare(String(b), "es");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Crea el `fetchData` de una fuente: aplica filtros, orden y paginación.
|
|
148
|
+
*
|
|
149
|
+
* El retardo simula la red para que se vean los estados de carga del motor.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* const fetcher = createDemoFetcher("usuarios");
|
|
153
|
+
* await fetcher({ page: 1, limit: 5, filters: {} });
|
|
154
|
+
*/
|
|
155
|
+
export function createDemoFetcher(source: DemoSource) {
|
|
156
|
+
return async (params: ITDataTableFetchParams): Promise<ITDataTableResponse<Record<string, unknown>>> => {
|
|
157
|
+
// Latencia simulada: hace visible el estado de carga de la tabla.
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 260));
|
|
159
|
+
|
|
160
|
+
let rows = [...COLECCIONES[source]];
|
|
161
|
+
|
|
162
|
+
// Filtros por columna: igualdad para catálogo, subcadena para texto.
|
|
163
|
+
for (const [key, value] of Object.entries(params.filters ?? {})) {
|
|
164
|
+
if (value === undefined || value === null || value === "") continue;
|
|
165
|
+
const needle = String(value).toLowerCase();
|
|
166
|
+
rows = rows.filter((row) => String(row[key] ?? "").toLowerCase().includes(needle));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Filtro triple del host (persistido en la URL).
|
|
170
|
+
if (estadoFiltro) rows = rows.filter((row) => String(row.estado ?? "") === estadoFiltro);
|
|
171
|
+
|
|
172
|
+
if (params.sort) {
|
|
173
|
+
const { key, direction } = params.sort;
|
|
174
|
+
rows.sort((a, b) => (direction === "asc" ? compare(a[key], b[key]) : compare(b[key], a[key])));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const total = rows.length;
|
|
178
|
+
const start = (params.page - 1) * params.limit;
|
|
179
|
+
return { data: rows.slice(start, start + params.limit), total };
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { leerNodo, listarNodos } from "./nodeTree";
|
|
3
|
+
|
|
4
|
+
const esquema = {
|
|
5
|
+
version: 1,
|
|
6
|
+
id: "demo",
|
|
7
|
+
content: [
|
|
8
|
+
{ type: "ITStack", id: "raiz", children: [
|
|
9
|
+
{ type: "ITText", text: "hola" },
|
|
10
|
+
{ type: "ITButton", id: "cta", props: { label: "Ir" } },
|
|
11
|
+
{ type: "ITCard", children: [{ type: "ITChip", props: { label: "x" } }] },
|
|
12
|
+
] },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("inspector de nodos", () => {
|
|
17
|
+
it("lista los nodos con su ruta numérica", () => {
|
|
18
|
+
const nodos = listarNodos(esquema);
|
|
19
|
+
expect(nodos.map((n) => n.tipo)).toEqual(["ITStack", "ITText", "ITButton", "ITCard", "ITChip"]);
|
|
20
|
+
expect(nodos[2].ruta).toEqual([0, 1]);
|
|
21
|
+
expect(nodos[2].etiqueta).toContain("#cta");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("devuelve el subárbol de una ruta", () => {
|
|
25
|
+
expect(leerNodo(esquema, [0, 2])).toMatchObject({ type: "ITCard" });
|
|
26
|
+
expect(leerNodo(esquema, [0, 1])).toMatchObject({ type: "ITButton", id: "cta" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("la ruta vacía es el esquema completo", () => {
|
|
30
|
+
expect(leerNodo(esquema, [])).toBe(esquema);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("corta la lista a 40 nodos y sobrevive a basura", () => {
|
|
34
|
+
const grande = { content: Array.from({ length: 60 }, () => ({ type: "ITText", text: "x" })) };
|
|
35
|
+
expect(listarNodos(grande)).toHaveLength(40);
|
|
36
|
+
expect(listarNodos(null)).toEqual([]);
|
|
37
|
+
expect(leerNodo(esquema, [9, 9])).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Helpers del inspector de nodos de la documentación (puros y testeables). */
|
|
2
|
+
|
|
3
|
+
/** Un nodo seleccionable del árbol, con su ruta numérica. */
|
|
4
|
+
interface NodoRuta {
|
|
5
|
+
ruta: number[];
|
|
6
|
+
etiqueta: string;
|
|
7
|
+
tipo: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Recorre el árbol y lista los nodos seleccionables (hasta 40). */
|
|
11
|
+
export function listarNodos(nodo: unknown, ruta: number[] = [], salida: NodoRuta[] = []): NodoRuta[] {
|
|
12
|
+
if (!nodo || typeof nodo !== "object" || salida.length >= 40) return salida;
|
|
13
|
+
const objeto = nodo as { type?: string; id?: string; children?: unknown; content?: unknown };
|
|
14
|
+
if (typeof objeto.type === "string") {
|
|
15
|
+
salida.push({
|
|
16
|
+
ruta,
|
|
17
|
+
tipo: objeto.type,
|
|
18
|
+
etiqueta: `${objeto.type}${objeto.id ? ` #${objeto.id}` : ""} · ${ruta.join(".")}`,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
const hijos = objeto.children ?? objeto.content;
|
|
22
|
+
const lista = Array.isArray(hijos) ? hijos : hijos ? [hijos] : [];
|
|
23
|
+
lista.forEach((hijo, indice) => listarNodos(hijo, [...ruta, indice], salida));
|
|
24
|
+
return salida;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Devuelve el subárbol de una ruta numérica. */
|
|
28
|
+
export function leerNodo(nodo: unknown, ruta: number[]): unknown {
|
|
29
|
+
return ruta.reduce((actual: unknown, indice) => {
|
|
30
|
+
const objeto = actual as { children?: unknown; content?: unknown } | null;
|
|
31
|
+
const hijos = objeto?.children ?? objeto?.content;
|
|
32
|
+
const lista = Array.isArray(hijos) ? hijos : hijos ? [hijos] : [];
|
|
33
|
+
return lista[indice];
|
|
34
|
+
}, nodo);
|
|
35
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { validateSchema } from "../../json-renderer/validation/validate";
|
|
5
|
+
|
|
6
|
+
const DIR = join(process.cwd(), "public/demo/app");
|
|
7
|
+
|
|
8
|
+
/** Lee y parsea un archivo del demo. */
|
|
9
|
+
function leerJson(nombre: string): unknown {
|
|
10
|
+
return JSON.parse(readFileSync(join(DIR, nombre), "utf8"));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const paginas = readdirSync(DIR).filter((archivo) => archivo.endsWith(".json") && archivo !== "layout.json");
|
|
14
|
+
|
|
15
|
+
describe("JSON del demo", () => {
|
|
16
|
+
it("hay páginas y un layout", () => {
|
|
17
|
+
expect(paginas.length).toBeGreaterThanOrEqual(5);
|
|
18
|
+
expect(() => leerJson("layout.json")).not.toThrow();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each(paginas)("%s es un esquema válido", (archivo) => {
|
|
22
|
+
const informe = validateSchema(leerJson(archivo));
|
|
23
|
+
expect(informe.errors, JSON.stringify(informe.errors)).toHaveLength(0);
|
|
24
|
+
expect(informe.verdict).toBe("valid");
|
|
25
|
+
expect(informe.pendingNodes).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("el layout declara marca, módulos y páginas existentes", () => {
|
|
29
|
+
const layout = leerJson("layout.json") as {
|
|
30
|
+
layout: { brand?: { text?: string }; modules: { id: string; page: string; icon?: string }[] };
|
|
31
|
+
};
|
|
32
|
+
expect(layout.layout.brand?.text).toBeTruthy();
|
|
33
|
+
expect(layout.layout.modules.length).toBeGreaterThanOrEqual(6);
|
|
34
|
+
for (const modulo of layout.layout.modules) {
|
|
35
|
+
expect(modulo.id, "cada módulo necesita id").toBeTruthy();
|
|
36
|
+
expect(modulo.page, `${modulo.id} necesita page`).toBeTruthy();
|
|
37
|
+
}
|
|
38
|
+
// Todo módulo apunta a una página que existe (docs no tiene archivo propio).
|
|
39
|
+
const ids = new Set(layout.layout.modules.map((modulo) => modulo.page));
|
|
40
|
+
expect(ids.has("docs")).toBe(true);
|
|
41
|
+
for (const pagina of ids) {
|
|
42
|
+
if (pagina === "docs") continue;
|
|
43
|
+
expect(paginas, `falta ${pagina}.json`).toContain(`${pagina}.json`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("las páginas declaran título y descripción traducibles", () => {
|
|
48
|
+
for (const archivo of paginas) {
|
|
49
|
+
const esquema = leerJson(archivo) as { title?: { i18n?: string }; description?: { i18n?: string } };
|
|
50
|
+
expect(esquema.title?.i18n, `${archivo} sin title.i18n`).toBeTruthy();
|
|
51
|
+
expect(esquema.description?.i18n, `${archivo} sin description.i18n`).toBeTruthy();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|