@axzydev/axzy_ui_system 1.7.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 +1738 -308
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.cts +739 -3
- package/dist/index.d.ts +739 -3
- package/dist/index.js +1629 -233
- package/dist/index.js.map +1 -1
- package/dist/layered.css +1 -1
- package/package.json +14 -7
- package/src/App.tsx +60 -2
- package/src/components/atoms/image/image.props.ts +2 -0
- package/src/components/atoms/image/image.tsx +3 -2
- package/src/components/organisms/dialog/dialog.props.ts +2 -0
- package/src/components/organisms/dialog/dialog.tsx +4 -3
- package/src/dev.css +6 -0
- package/src/index.ts +90 -1
- 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/axzyConfig.test.ts +40 -0
- package/src/sandbox/axzyConfig.ts +77 -0
- package/src/sandbox/i18n.ts +218 -0
- package/src/sandbox/navigation.ts +1 -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/JsonScreenShowcase.tsx +341 -0
- 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,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser tolerante de esquemas a medio llegar.
|
|
3
|
+
*
|
|
4
|
+
* Es la pieza que hace posible el streaming: en vez de esperar a que el JSON esté
|
|
5
|
+
* completo (y fallar mientras tanto), se construye el árbol **hasta donde es
|
|
6
|
+
* válido** y se marcan como `pending` los nodos que aún no están cerrados.
|
|
7
|
+
*
|
|
8
|
+
* No pretende ser un parser JSON general: entiende la gramática de un esquema
|
|
9
|
+
* (objetos, arrays, cadenas, números, booleanos y `null`) y, cuando algo no
|
|
10
|
+
* cierra, devuelve el punto exacto donde se quedó para poder seguir desde ahí.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const { tree, status } = parsePartialSchema('{"version":1,"content":[{"type":"ITCard"');
|
|
14
|
+
* // tree.content[0].$status === "pending"
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { NodeSchema, NodeStatus, ScreenSchema } from "../kernel/types";
|
|
18
|
+
|
|
19
|
+
/** Resultado de parsear un texto posiblemente incompleto. */
|
|
20
|
+
export interface PartialParseResult {
|
|
21
|
+
/** Árbol resuelto hasta donde el texto es válido. `null` si aún no hay nada. */
|
|
22
|
+
tree: ScreenSchema | null;
|
|
23
|
+
/** `complete` si el documento está entero; `pending` si falta cerrar algo. */
|
|
24
|
+
status: NodeStatus;
|
|
25
|
+
/** Offset donde terminó el consumo (punto de continuación). */
|
|
26
|
+
offset: number;
|
|
27
|
+
/** `true` cuando el texto no es recuperable (basura al principio). */
|
|
28
|
+
broken: boolean;
|
|
29
|
+
/** Cuántos nodos quedaron pendientes. */
|
|
30
|
+
pendingNodes: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Valor intermedio durante el parseo, con la marca de pendiente. */
|
|
34
|
+
type Pending = { $pending: true };
|
|
35
|
+
|
|
36
|
+
/** Comprueba si un valor intermedio es un nodo aún sin cerrar. */
|
|
37
|
+
function isPending(value: unknown): value is Pending {
|
|
38
|
+
return typeof value === "object" && value !== null && (value as Pending).$pending === true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Contenedores que se cerraron por falta de texto y no por su delimitador.
|
|
43
|
+
* Se guardan aparte (WeakSet) para no ensuciar el árbol con claves internas.
|
|
44
|
+
*/
|
|
45
|
+
const TRUNCATED_CONTAINERS = new WeakSet<object>();
|
|
46
|
+
|
|
47
|
+
/** Sustituye las marcas de pendiente por `$status: "pending"` en el árbol final. */
|
|
48
|
+
function finalize(value: unknown, counter: { pending: number }): unknown {
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
// Un elemento pendiente en un array completo no se descarta sin más: se
|
|
51
|
+
// quita del resultado, pero el array conserva los elementos ya cerrados para
|
|
52
|
+
// que el render progresivo no pierda lo que ya había llegado.
|
|
53
|
+
return value
|
|
54
|
+
.map((item) => finalize(item, counter))
|
|
55
|
+
.filter((item) => item !== undefined);
|
|
56
|
+
}
|
|
57
|
+
if (value && typeof value === "object") {
|
|
58
|
+
if (isPending(value)) {
|
|
59
|
+
counter.pending += 1;
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
// Un valor pendiente en un objeto completo NO invalida el objeto: el nodo ya
|
|
63
|
+
// llegó y solo le falta una parte. Se conserva y se marca como incompleto,
|
|
64
|
+
// porque perderlo rompería el render progresivo (un nodo cerrado desaparecía
|
|
65
|
+
// al llegar el siguiente a medias).
|
|
66
|
+
const out: Record<string, unknown> = { ...(value as Record<string, unknown>) };
|
|
67
|
+
let hasPendingChild = false;
|
|
68
|
+
for (const [key, nested] of Object.entries(out)) {
|
|
69
|
+
const resolved = finalize(nested, counter);
|
|
70
|
+
if (resolved === undefined && isPending(nested)) {
|
|
71
|
+
hasPendingChild = true;
|
|
72
|
+
delete out[key];
|
|
73
|
+
} else {
|
|
74
|
+
out[key] = resolved;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Un nodo está incompleto si le falta alguna parte **o** si su propio
|
|
78
|
+
// contenedor no llegó a cerrarse (el stream se cortó dentro de él).
|
|
79
|
+
const truncated = TRUNCATED_CONTAINERS.has(value as object);
|
|
80
|
+
if ((hasPendingChild || truncated) && "type" in out) {
|
|
81
|
+
out.$status = "pending" satisfies NodeStatus;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const WHITESPACE = new Set([" ", "\t", "\n", "\r"]);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parser de descenso recursivo que nunca lanza.
|
|
92
|
+
*
|
|
93
|
+
* Devuelve el valor y el offset donde se detuvo; si se detuvo por falta de texto
|
|
94
|
+
* (no por un error), el valor se marca como pendiente.
|
|
95
|
+
*/
|
|
96
|
+
class TolerantParser {
|
|
97
|
+
private index = 0;
|
|
98
|
+
/** `true` cuando se detuvo porque el texto se acabó (y no por un error). */
|
|
99
|
+
private truncated = false;
|
|
100
|
+
/** `true` cuando encontró algo que no puede interpretar. */
|
|
101
|
+
broken = false;
|
|
102
|
+
|
|
103
|
+
constructor(private readonly text: string) {}
|
|
104
|
+
|
|
105
|
+
/** Offset actual. */
|
|
106
|
+
get offset(): number {
|
|
107
|
+
return this.index;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `true` si el consumo se detuvo por falta de texto. */
|
|
111
|
+
get isTruncated(): boolean {
|
|
112
|
+
return this.truncated;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Salta el espacio en blanco. */
|
|
116
|
+
private skip(): void {
|
|
117
|
+
while (this.index < this.text.length && WHITESPACE.has(this.text[this.index])) this.index += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Carácter actual, o `undefined` si se acabó. */
|
|
121
|
+
private peek(): string | undefined {
|
|
122
|
+
return this.text[this.index];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Intenta consumir un literal (`true`, `false`, `null`). */
|
|
126
|
+
private tryLiteral(literal: string): boolean | null | Pending {
|
|
127
|
+
if (!this.text.startsWith(literal, this.index)) return undefined as unknown as null;
|
|
128
|
+
const end = this.index + literal.length;
|
|
129
|
+
// Si el literal está a medias (`tru`), se considera pendiente.
|
|
130
|
+
if (end > this.text.length) {
|
|
131
|
+
this.index = this.text.length;
|
|
132
|
+
this.truncated = true;
|
|
133
|
+
return { $pending: true };
|
|
134
|
+
}
|
|
135
|
+
// Un literal a medias al final del texto no es un literal completo.
|
|
136
|
+
if (this.text.slice(this.index, end) !== literal) {
|
|
137
|
+
const prefix = this.text.slice(this.index);
|
|
138
|
+
if (literal.startsWith(prefix)) {
|
|
139
|
+
this.index = this.text.length;
|
|
140
|
+
this.truncated = true;
|
|
141
|
+
return { $pending: true };
|
|
142
|
+
}
|
|
143
|
+
return undefined as unknown as null;
|
|
144
|
+
}
|
|
145
|
+
this.index = end;
|
|
146
|
+
if (literal === "true") return true;
|
|
147
|
+
if (literal === "false") return false;
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Lee una cadena. Marca pendiente si no encuentra la comilla de cierre. */
|
|
152
|
+
private readString(): string | Pending {
|
|
153
|
+
this.index += 1; // comilla de apertura
|
|
154
|
+
let out = "";
|
|
155
|
+
while (this.index < this.text.length) {
|
|
156
|
+
const char = this.text[this.index];
|
|
157
|
+
if (char === "\\") {
|
|
158
|
+
const next = this.text[this.index + 1];
|
|
159
|
+
if (next === undefined) {
|
|
160
|
+
this.index = this.text.length;
|
|
161
|
+
this.truncated = true;
|
|
162
|
+
return { $pending: true };
|
|
163
|
+
}
|
|
164
|
+
out += unescapeChar(next);
|
|
165
|
+
this.index += 2;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (char === '"') {
|
|
169
|
+
this.index += 1;
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
out += char;
|
|
173
|
+
this.index += 1;
|
|
174
|
+
}
|
|
175
|
+
this.truncated = true;
|
|
176
|
+
return { $pending: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Lee un número. Devuelve el texto crudo como número, o pendiente. */
|
|
180
|
+
private readNumber(): number | Pending {
|
|
181
|
+
const start = this.index;
|
|
182
|
+
if (this.peek() === "-") this.index += 1;
|
|
183
|
+
while (this.index < this.text.length && /[0-9.eE+-]/.test(this.text[this.index])) this.index += 1;
|
|
184
|
+
const raw = this.text.slice(start, this.index);
|
|
185
|
+
if (raw === "" || raw === "-") {
|
|
186
|
+
this.index = this.text.length;
|
|
187
|
+
this.truncated = true;
|
|
188
|
+
return { $pending: true };
|
|
189
|
+
}
|
|
190
|
+
const value = Number(raw);
|
|
191
|
+
if (Number.isNaN(value)) {
|
|
192
|
+
this.broken = true;
|
|
193
|
+
return { $pending: true };
|
|
194
|
+
}
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Marca un contenedor como truncado (se cerró por falta de texto). */
|
|
199
|
+
private markTruncated(container: object): void {
|
|
200
|
+
this.truncated = true;
|
|
201
|
+
TRUNCATED_CONTAINERS.add(container);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Lee un array. */
|
|
205
|
+
private readArray(): unknown[] | Pending {
|
|
206
|
+
this.index += 1; // "["
|
|
207
|
+
const items: unknown[] = [];
|
|
208
|
+
this.skip();
|
|
209
|
+
if (this.peek() === "]") {
|
|
210
|
+
this.index += 1;
|
|
211
|
+
return items;
|
|
212
|
+
}
|
|
213
|
+
for (;;) {
|
|
214
|
+
this.skip();
|
|
215
|
+
if (this.peek() === undefined) {
|
|
216
|
+
if (items.length === 0) {
|
|
217
|
+
this.truncated = true;
|
|
218
|
+
return { $pending: true };
|
|
219
|
+
}
|
|
220
|
+
this.markTruncated(items);
|
|
221
|
+
return items;
|
|
222
|
+
}
|
|
223
|
+
const value = this.readValue();
|
|
224
|
+
if (isPending(value)) {
|
|
225
|
+
// El elemento a medias no se descarta si el array ya tiene contenido:
|
|
226
|
+
// se devuelve lo que había y se sigue en el próximo fragmento.
|
|
227
|
+
if (items.length === 0) {
|
|
228
|
+
this.truncated = true;
|
|
229
|
+
return { $pending: true };
|
|
230
|
+
}
|
|
231
|
+
this.markTruncated(items);
|
|
232
|
+
return items;
|
|
233
|
+
}
|
|
234
|
+
items.push(value);
|
|
235
|
+
this.skip();
|
|
236
|
+
const char = this.peek();
|
|
237
|
+
if (char === ",") {
|
|
238
|
+
this.index += 1;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (char === "]") {
|
|
242
|
+
this.index += 1;
|
|
243
|
+
return items;
|
|
244
|
+
}
|
|
245
|
+
if (char === undefined) {
|
|
246
|
+
this.markTruncated(items);
|
|
247
|
+
return items;
|
|
248
|
+
}
|
|
249
|
+
this.broken = true;
|
|
250
|
+
return items;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
/** Lee un objeto. */
|
|
256
|
+
private readObject(): Record<string, unknown> | Pending {
|
|
257
|
+
this.index += 1; // "{"
|
|
258
|
+
const out: Record<string, unknown> = {};
|
|
259
|
+
this.skip();
|
|
260
|
+
if (this.peek() === "}") {
|
|
261
|
+
this.index += 1;
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
for (;;) {
|
|
265
|
+
this.skip();
|
|
266
|
+
const char = this.peek();
|
|
267
|
+
if (char === undefined) {
|
|
268
|
+
if (!Object.keys(out).length) {
|
|
269
|
+
this.truncated = true;
|
|
270
|
+
return { $pending: true };
|
|
271
|
+
}
|
|
272
|
+
this.markTruncated(out);
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
if (char !== '"') {
|
|
276
|
+
this.broken = true;
|
|
277
|
+
if (!Object.keys(out).length) return { $pending: true };
|
|
278
|
+
this.markTruncated(out);
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
const key = this.readString();
|
|
282
|
+
if (isPending(key)) {
|
|
283
|
+
if (!Object.keys(out).length) {
|
|
284
|
+
this.truncated = true;
|
|
285
|
+
return { $pending: true };
|
|
286
|
+
}
|
|
287
|
+
this.markTruncated(out);
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
this.skip();
|
|
291
|
+
if (this.peek() !== ":") {
|
|
292
|
+
this.broken = this.peek() !== undefined;
|
|
293
|
+
if (!Object.keys(out).length) {
|
|
294
|
+
this.truncated = true;
|
|
295
|
+
return { $pending: true };
|
|
296
|
+
}
|
|
297
|
+
this.markTruncated(out);
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
this.index += 1; // ":"
|
|
301
|
+
this.skip();
|
|
302
|
+
const value = this.readValue();
|
|
303
|
+
if (isPending(value)) {
|
|
304
|
+
// Clave conocida con valor a medias: el objeto está incompleto, pero se
|
|
305
|
+
// puede seguir pintando con lo que ya había.
|
|
306
|
+
if (!Object.keys(out).length) {
|
|
307
|
+
this.truncated = true;
|
|
308
|
+
return { $pending: true };
|
|
309
|
+
}
|
|
310
|
+
this.markTruncated(out);
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
out[key] = value;
|
|
314
|
+
this.skip();
|
|
315
|
+
const separator = this.peek();
|
|
316
|
+
if (separator === ",") {
|
|
317
|
+
this.index += 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (separator === "}") {
|
|
321
|
+
this.index += 1;
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
if (separator === undefined) {
|
|
325
|
+
this.markTruncated(out);
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
this.broken = true;
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Lee cualquier valor. */
|
|
334
|
+
readValue(): unknown {
|
|
335
|
+
this.skip();
|
|
336
|
+
const char = this.peek();
|
|
337
|
+
if (char === undefined) {
|
|
338
|
+
this.truncated = true;
|
|
339
|
+
return { $pending: true };
|
|
340
|
+
}
|
|
341
|
+
if (char === "{") return this.readObject();
|
|
342
|
+
if (char === "[") return this.readArray();
|
|
343
|
+
if (char === '"') {
|
|
344
|
+
const value = this.readString();
|
|
345
|
+
// Una cadena sin cerrar puede seguir creciendo: es un valor pendiente.
|
|
346
|
+
return isPending(value) ? { $pending: true } : value;
|
|
347
|
+
}
|
|
348
|
+
if (char === "t" || char === "f" || char === "n") {
|
|
349
|
+
const literal = char === "t" ? this.tryLiteral("true") : char === "f" ? this.tryLiteral("false") : this.tryLiteral("null");
|
|
350
|
+
return literal === undefined ? ({ $pending: true } as Pending) : literal;
|
|
351
|
+
}
|
|
352
|
+
if (char === "-" || /[0-9]/.test(char)) return this.readNumber();
|
|
353
|
+
this.broken = true;
|
|
354
|
+
this.index = this.text.length;
|
|
355
|
+
return { $pending: true };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Parsea el documento completo (o hasta donde llegue). */
|
|
359
|
+
parse(): { value: unknown; truncated: boolean; broken: boolean } {
|
|
360
|
+
this.skip();
|
|
361
|
+
if (this.peek() === undefined) {
|
|
362
|
+
return { value: undefined, truncated: true, broken: false };
|
|
363
|
+
}
|
|
364
|
+
const value = this.readValue();
|
|
365
|
+
// El documento está completo solo si el valor se cerró y no queda nada por
|
|
366
|
+
// consumir (aparte de espacio en blanco). Es la única fuente de verdad del
|
|
367
|
+
// truncado: marcar y desmarcar por el camino daba falsos positivos.
|
|
368
|
+
this.skip();
|
|
369
|
+
const truncated = this.truncated || this.index < this.text.length;
|
|
370
|
+
return { value, truncated, broken: this.broken };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Traduce una secuencia de escape a su carácter. */
|
|
375
|
+
function unescapeChar(char: string): string {
|
|
376
|
+
switch (char) {
|
|
377
|
+
case "n":
|
|
378
|
+
return "\n";
|
|
379
|
+
case "t":
|
|
380
|
+
return "\t";
|
|
381
|
+
case "r":
|
|
382
|
+
return "\r";
|
|
383
|
+
case "b":
|
|
384
|
+
return "\b";
|
|
385
|
+
case "f":
|
|
386
|
+
return "\f";
|
|
387
|
+
default:
|
|
388
|
+
return char;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Parsea un esquema que puede estar incompleto.
|
|
394
|
+
*
|
|
395
|
+
* Nunca lanza: si el texto no es recuperable devuelve `broken: true` y un árbol
|
|
396
|
+
* vacío, para que el motor pueda avisar (`JR080`) sin romper la pantalla.
|
|
397
|
+
*
|
|
398
|
+
* @example
|
|
399
|
+
* parsePartialSchema('{"version":1,"content":[]}').status; // "complete"
|
|
400
|
+
*/
|
|
401
|
+
export function parsePartialSchema(text: string): PartialParseResult {
|
|
402
|
+
const parser = new TolerantParser(text);
|
|
403
|
+
const { value, truncated, broken } = parser.parse();
|
|
404
|
+
|
|
405
|
+
if (value === undefined) {
|
|
406
|
+
return { tree: null, status: "pending", offset: parser.offset, broken, pendingNodes: 0 };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const counter = { pending: 0 };
|
|
410
|
+
const finalized = finalize(value, counter);
|
|
411
|
+
|
|
412
|
+
if (finalized === undefined || finalized === null) {
|
|
413
|
+
return { tree: null, status: "pending", offset: parser.offset, broken, pendingNodes: counter.pending };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const tree = finalized as ScreenSchema;
|
|
417
|
+
return {
|
|
418
|
+
tree,
|
|
419
|
+
status: truncated || counter.pending > 0 ? "pending" : "complete",
|
|
420
|
+
offset: parser.offset,
|
|
421
|
+
broken,
|
|
422
|
+
pendingNodes: counter.pending,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Estado del stream de un esquema, listo para pintar.
|
|
428
|
+
*
|
|
429
|
+
* @example
|
|
430
|
+
* const state = useSchemaStream(tokens);
|
|
431
|
+
* state.tree; // ScreenSchema | null
|
|
432
|
+
*/
|
|
433
|
+
export interface StreamState {
|
|
434
|
+
/** Árbol pintable (parcial mientras llega). */
|
|
435
|
+
tree: ScreenSchema | null;
|
|
436
|
+
/** `pending` mientras el emisor sigue escribiendo. */
|
|
437
|
+
status: NodeStatus;
|
|
438
|
+
/** `true` cuando el texto no es recuperable. */
|
|
439
|
+
broken: boolean;
|
|
440
|
+
/** Nodos que aún no están cerrados. */
|
|
441
|
+
pendingNodes: number;
|
|
442
|
+
/** `true` cuando el emisor está activo. */
|
|
443
|
+
streaming: boolean;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** `true` cuando un nodo está pendiente según el parser. */
|
|
447
|
+
export function isNodePending(node: NodeSchema | undefined): boolean {
|
|
448
|
+
return node?.$status === "pending";
|
|
449
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preparación común de las pruebas del motor `ITJsonRenderer`.
|
|
3
|
+
*
|
|
4
|
+
* jsdom no implementa `ResizeObserver` (lo usan componentes como `ITJSONEditor`)
|
|
5
|
+
* ni `matchMedia` (lo usa `ITThemeProvider`). Se dejan disponibles para que los
|
|
6
|
+
* fixtures puedan montar cualquier componente del catálogo sin parches por test.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
class ResizeObserverStub {
|
|
10
|
+
observe() {}
|
|
11
|
+
unobserve() {}
|
|
12
|
+
disconnect() {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!("ResizeObserver" in globalThis)) {
|
|
16
|
+
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!window.matchMedia) {
|
|
20
|
+
window.matchMedia = ((query: string) => ({
|
|
21
|
+
matches: false,
|
|
22
|
+
media: query,
|
|
23
|
+
onchange: null,
|
|
24
|
+
addListener: () => undefined,
|
|
25
|
+
removeListener: () => undefined,
|
|
26
|
+
addEventListener: () => undefined,
|
|
27
|
+
removeEventListener: () => undefined,
|
|
28
|
+
dispatchEvent: () => false,
|
|
29
|
+
})) as unknown as typeof window.matchMedia;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `ITJSONEditor` usa `document.fonts`; jsdom no lo trae.
|
|
33
|
+
if (!document.fonts) {
|
|
34
|
+
Object.defineProperty(document, "fonts", {
|
|
35
|
+
configurable: true,
|
|
36
|
+
value: { ready: Promise.resolve(), check: () => true },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versionado y migraciones del formato de pantalla.
|
|
3
|
+
*
|
|
4
|
+
* Un esquema declara `version`. Cuando el formato cambia, el motor aplica la
|
|
5
|
+
* migración registrada para cada salto; si falta, avisa (`JR070`) en lugar de
|
|
6
|
+
* intentar adivinar. Sin este mecanismo, actualizar el motor rompería esquemas
|
|
7
|
+
* guardados por el host.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { issue, type JsonEngineIssue } from "../shared/errors";
|
|
11
|
+
import type { ScreenSchema } from "../kernel/types";
|
|
12
|
+
|
|
13
|
+
/** Migra un esquema de una versión a la siguiente. */
|
|
14
|
+
export type SchemaMigration = (schema: ScreenSchema) => ScreenSchema;
|
|
15
|
+
|
|
16
|
+
/** Resultado de migrar un esquema. */
|
|
17
|
+
export interface MigrationResult {
|
|
18
|
+
/** Esquema migrado (o el original si ya estaba al día). */
|
|
19
|
+
schema: ScreenSchema;
|
|
20
|
+
/** `true` cuando se aplicó alguna migración. */
|
|
21
|
+
migrated: boolean;
|
|
22
|
+
/** Versión de origen. */
|
|
23
|
+
from: number;
|
|
24
|
+
/** Versión de destino. */
|
|
25
|
+
to: number;
|
|
26
|
+
/** Avisos, incluido el de migración ausente. */
|
|
27
|
+
issues: JsonEngineIssue[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Migraciones registradas, indexadas por versión de origen. */
|
|
31
|
+
export type MigrationRegistry = Record<number, SchemaMigration>;
|
|
32
|
+
|
|
33
|
+
/** Registro por defecto: vacío (solo existe la versión 1). */
|
|
34
|
+
export const DEFAULT_MIGRATIONS: MigrationRegistry = {};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Lleva un esquema hasta la versión objetivo aplicando migraciones en cadena.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* migrateSchema({ version: 1, id: "x", content: [] }, 1); // migrated: false
|
|
41
|
+
*/
|
|
42
|
+
export function migrateSchema(
|
|
43
|
+
schema: ScreenSchema,
|
|
44
|
+
targetVersion: number,
|
|
45
|
+
migrations: MigrationRegistry = DEFAULT_MIGRATIONS
|
|
46
|
+
): MigrationResult {
|
|
47
|
+
const from = typeof schema.version === "number" ? schema.version : 0;
|
|
48
|
+
const issues: JsonEngineIssue[] = [];
|
|
49
|
+
|
|
50
|
+
if (from === targetVersion) {
|
|
51
|
+
return { schema, migrated: false, from, to: targetVersion, issues };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (from > targetVersion) {
|
|
55
|
+
issues.push(
|
|
56
|
+
issue("JR020", `El esquema es de una versión posterior (${from}) a la del motor (${targetVersion}).`, "version", {
|
|
57
|
+
severity: "error",
|
|
58
|
+
hint: "Actualiza el motor; no se puede degradar un esquema.",
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
return { schema, migrated: false, from, to: targetVersion, issues };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let current = schema;
|
|
65
|
+
let version = from;
|
|
66
|
+
|
|
67
|
+
while (version < targetVersion) {
|
|
68
|
+
const migration = migrations[version];
|
|
69
|
+
if (!migration) {
|
|
70
|
+
issues.push(
|
|
71
|
+
issue("JR070", `No hay migración registrada de la versión ${version} a la ${version + 1}.`, "version", {
|
|
72
|
+
severity: "error",
|
|
73
|
+
hint: "Registra la migración en `migrations` o guarda el esquema en la versión actual.",
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
return { schema: current, migrated: false, from, to: version, issues };
|
|
77
|
+
}
|
|
78
|
+
current = { ...migration(current), version: version + 1 };
|
|
79
|
+
version += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { schema: current, migrated: version !== from, from, to: version, issues };
|
|
83
|
+
}
|