@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,717 @@
1
+ /**
2
+ * Núcleo del `ITJSONEditor`: tokenizador y ayudantes de JSON.
3
+ *
4
+ * Todo aquí es puro (sin React) para que sea testeable y barato de memoizar.
5
+ * El tokenizador no usa `JSON.parse`: recorre el texto con una regex anclada
6
+ * (`y`) para poder colorear **también** mientras el JSON está a medias.
7
+ */
8
+
9
+ /** Categoría léxica de un fragmento de código, usada para colorearlo. */
10
+ export type ITCodeTokenType =
11
+ | "punctuation"
12
+ | "key"
13
+ | "string"
14
+ | "number"
15
+ | "keyword"
16
+ | "whitespace"
17
+ | "plain";
18
+
19
+ /** Fragmento de código con su categoría léxica. */
20
+ export interface ITCodeToken {
21
+ /** Texto literal del fragmento (nunca contiene saltos de línea). */
22
+ text: string;
23
+ /** Categoría léxica para el coloreado. */
24
+ type: ITCodeTokenType;
25
+ }
26
+
27
+ /** Una línea física del documento, ya troceada para pintar. */
28
+ export interface ITCodeLine {
29
+ /** Número de línea, empezando en 1. */
30
+ number: number;
31
+ /** Texto completo de la línea, sin el salto final. */
32
+ text: string;
33
+ /** Fragmentos a pintar, en orden. */
34
+ tokens: ITCodeToken[];
35
+ /** Profundidad de anidación al empezar la línea. */
36
+ depth: number;
37
+ /** `true` cuando la línea arranca con `}` o `]`. */
38
+ closesBlock: boolean;
39
+ }
40
+
41
+ /** Nodo localizado dentro del documento (para el árbol y el salto a línea). */
42
+ export interface ITCodeNodeEntry {
43
+ /** Ruta desde la raíz, con la clave de cada nivel y el índice de cada array. */
44
+ path: (string | number)[];
45
+ /** Número de línea (1-based) donde vive la clave o el valor. */
46
+ line: number;
47
+ /** Tipo del valor apuntado. */
48
+ kind: "object" | "array" | "string" | "number" | "boolean" | "null";
49
+ }
50
+
51
+ /** Resultado del análisis léxico de un documento. */
52
+ export interface ITCodeAnalysis {
53
+ /** Líneas del documento, ya listas para pintar. */
54
+ lines: ITCodeLine[];
55
+ /** Nodos localizados por ruta (`"a.0.b"`), para el árbol y el salto a línea. */
56
+ nodeLines: Map<string, ITCodeNodeEntry>;
57
+ /** Número total de líneas. */
58
+ lineCount: number;
59
+ /** Offset de inicio de cada línea (`lineStarts[i]` → línea `i + 1`). */
60
+ lineStarts: number[];
61
+ }
62
+
63
+ /** Documento vacío (una línea en blanco), usado como valor por defecto. */
64
+ export const EMPTY_ANALYSIS: ITCodeAnalysis = {
65
+ lines: [{ number: 1, text: "", tokens: [], depth: 0, closesBlock: false }],
66
+ nodeLines: new Map(),
67
+ lineCount: 1,
68
+ lineStarts: [0],
69
+ };
70
+
71
+ /* ------------------------------------------------------------------ regexes */
72
+
73
+ /**
74
+ * Tokenizador JSON anclado. Se compila **una vez** y se reutiliza moviendo
75
+ * `lastIndex`, que es la forma segura de usar `y` sin recompilar por token.
76
+ *
77
+ * El orden de alternativas es la prioridad: una cadena no puede partirse por la
78
+ * mitad y `true|false|null` no puede capturarse como número.
79
+ *
80
+ * El grupo `whitespace` no es cosmético: con una regex anclada, un carácter que
81
+ * no case con ninguna alternativa **corta el bucle** (`exec` devuelve `null`).
82
+ * Al cubrir también el espacio y el salto de línea, el recorrido es continuo.
83
+ * Ojo con `[^"\\\n]`: una cadena JSON nunca contiene un salto real, y si no se
84
+ * excluye, una comilla sin cerrar se traga el resto del documento.
85
+ */
86
+ const JSON_TOKEN_RE =
87
+ /(?<string>"(?:\\[\s\S]|[^"\\\n])*")|(?<number>-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(?<keyword>true|false|null)|(?<punctuation>[{}[\],:])|(?<whitespace>[ \t\r\n]+)/y;
88
+
89
+ /**
90
+ * Paracaídas de un carácter. Con una regex anclada, un carácter que no case con
91
+ * **ninguna** alternativa devuelve `null` y corta el bucle: sin esto, una comilla
92
+ * sin cerrar dejaría el resto del documento sin colorear. Aquí ese carácter se
93
+ * emite como `plain` y el recorrido continúa.
94
+ */
95
+ const JSON_CHAR_RE = /[\s\S]/y;
96
+
97
+ const OPENERS = "{[";
98
+ const CLOSERS = "}]";
99
+
100
+ /* -------------------------------------------------------------- line helpers */
101
+
102
+ /** Busca la línea (1-based) y la columna (1-based) de un offset del documento. */
103
+ export function offsetToPosition(
104
+ text: string,
105
+ offset: number,
106
+ lineStarts: number[]
107
+ ): { line: number; column: number } {
108
+ const safe = Math.max(0, Math.min(offset, text.length));
109
+ let low = 0;
110
+ let high = Math.max(0, lineStarts.length - 1);
111
+ while (low < high) {
112
+ const mid = (low + high + 1) >> 1;
113
+ if (lineStarts[mid] <= safe) low = mid;
114
+ else high = mid - 1;
115
+ }
116
+ return { line: low + 1, column: safe - lineStarts[low] + 1 };
117
+ }
118
+
119
+ /** Calcula los offsets donde empieza cada línea del documento. */
120
+ export function computeLineStarts(lines: string[]): number[] {
121
+ const starts: number[] = [];
122
+ let offset = 0;
123
+ for (const line of lines) {
124
+ starts.push(offset);
125
+ offset += line.length + 1; // + "\n"
126
+ }
127
+ return starts.length ? starts : [0];
128
+ }
129
+
130
+ /* ------------------------------------------------------------- tokenizador */
131
+
132
+ /** Acumula texto + tipo, fusionando con el fragmento anterior si coincide. */
133
+ function pushToken(chunks: ITCodeToken[], text: string, type: ITCodeTokenType) {
134
+ if (!text) return;
135
+ const last = chunks[chunks.length - 1];
136
+ if (last && last.type === type) last.text += text;
137
+ else chunks.push({ text, type });
138
+ }
139
+
140
+ /**
141
+ * Marca como `key` las cadenas que preceden a un `:`.
142
+ *
143
+ * En el recorrido lineal una clave y un valor de cadena son idénticos hasta que
144
+ * aparece el `:` (que puede caer en la línea siguiente), así que se resuelve en
145
+ * esta pasada sobre las líneas ya construidas.
146
+ */
147
+ function classifyKeys(lines: ITCodeLine[]): void {
148
+ for (let i = 0; i < lines.length; i++) {
149
+ const tokens = lines[i].tokens;
150
+ for (let j = 0; j < tokens.length; j++) {
151
+ if (tokens[j].type !== "string") continue;
152
+ let found = false;
153
+ // Se ignora el espacio en blanco, incluso entre líneas: `"a" :` y una
154
+ // clave con el `:` en la línea siguiente también son claves.
155
+ scan: for (let lineCursor = i; lineCursor < lines.length; lineCursor++) {
156
+ const active = lines[lineCursor].tokens;
157
+ for (let m = lineCursor === i ? j + 1 : 0; m < active.length; m++) {
158
+ const token = active[m];
159
+ if (token.type === "whitespace") continue;
160
+ found = token.type === "punctuation" && token.text.startsWith(":");
161
+ break scan;
162
+ }
163
+ }
164
+ if (found) tokens[j] = { text: tokens[j].text, type: "key" };
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Analiza un documento JSON (válido o a medias) y devuelve sus líneas
171
+ * coloreables, la profundidad de cada una y la línea de cada nodo por ruta.
172
+ *
173
+ * @example
174
+ * const { lines } = analyzeCode('{\n "a": 1\n}');
175
+ * lines[1].tokens; // [{ text: ' ', type: "plain" }, { text: '"a"', type: "key" }, …]
176
+ */
177
+ export function analyzeCode(text: string): ITCodeAnalysis {
178
+ const rawLines = text.split("\n");
179
+ const lineStarts = computeLineStarts(rawLines);
180
+ const lines: ITCodeLine[] = [];
181
+ const nodeLines = new Map<string, ITCodeNodeEntry>();
182
+
183
+ /** Línea que se está construyendo ahora mismo. */
184
+ let lineIndex = 0;
185
+ let currentChunks: ITCodeToken[] = [];
186
+
187
+ /**
188
+ * Pila de contenedores abiertos. `key` es la clave del objeto al que pertenece
189
+ * el contenedor (o `undefined` si cuelga de un array o es la raíz), y
190
+ * `index` el último índice visto dentro de un array.
191
+ */
192
+ const stack: { kind: "object" | "array"; key?: string; index: number }[] = [];
193
+ /** Clave leída cuyo `:` todavía no ha llegado (aún no se sabe si es clave). */
194
+ let pendingColon: string | null = null;
195
+ /** `true` justo después de un `:`: el próximo valor es el de esa clave. */
196
+ let awaitingValue = false;
197
+
198
+ const flushLine = () => {
199
+ lines.push({
200
+ number: lineIndex + 1,
201
+ text: rawLines[lineIndex] ?? "",
202
+ tokens: currentChunks,
203
+ depth: 0, // se calcula al final, en una segunda pasada
204
+ closesBlock: false,
205
+ });
206
+ };
207
+
208
+ /** Avanza a la línea siguiente, cerrando la actual. */
209
+ const breakLine = () => {
210
+ flushLine();
211
+ lineIndex += 1;
212
+ currentChunks = [];
213
+ };
214
+
215
+ /**
216
+ * Ruta del contenedor abierto más su clave o índice vigente: la que están
217
+ * esperando el próximo valor o el próximo bloque.
218
+ */
219
+ const buildPath = (): (string | number)[] => {
220
+ const result: (string | number)[] = [];
221
+ for (const frame of stack) {
222
+ if (frame.kind === "array") result.push(frame.index);
223
+ else if (frame.key !== undefined) result.push(frame.key);
224
+ }
225
+ return result;
226
+ };
227
+
228
+ const recordNode = (path: (string | number)[], kind: ITCodeNodeEntry["kind"]) => {
229
+ if (!path.length) return;
230
+ nodeLines.set(pathKey(path), { path: [...path], line: lineIndex + 1, kind });
231
+ };
232
+
233
+ JSON_TOKEN_RE.lastIndex = 0;
234
+ JSON_CHAR_RE.lastIndex = 0;
235
+ let consumed = 0;
236
+ let match: RegExpExecArray | null;
237
+
238
+ for (;;) {
239
+ JSON_TOKEN_RE.lastIndex = consumed;
240
+ match = JSON_TOKEN_RE.exec(text);
241
+ if (!match) {
242
+ if (consumed >= text.length) break;
243
+ // Carácter sin alternativa (por ejemplo una comilla sin cerrar): se pinta
244
+ // como texto plano y se sigue, en lugar de abortar el análisis.
245
+ JSON_CHAR_RE.lastIndex = consumed;
246
+ const fallback = JSON_CHAR_RE.exec(text);
247
+ if (!fallback) break;
248
+ const char = fallback[0];
249
+ if (char === "\n") breakLine();
250
+ else pushToken(currentChunks, char, "plain");
251
+ consumed += 1;
252
+ continue;
253
+ }
254
+ // Hueco entre el token anterior y este: comillas sin cerrar, comentarios o
255
+ // basura. Se pinta tal cual; sin esto el coloreado dejaría agujeros.
256
+ if (match.index > consumed) {
257
+ const gapLines = text.slice(consumed, match.index).split("\n");
258
+ gapLines.forEach((piece, i) => {
259
+ if (i > 0) breakLine();
260
+ pushToken(currentChunks, piece, "plain");
261
+ });
262
+ }
263
+
264
+ const [full] = match;
265
+ const groups = match.groups ?? {};
266
+ let type: ITCodeTokenType = "punctuation";
267
+ if (groups.string !== undefined) type = "string";
268
+ else if (groups.number !== undefined) type = "number";
269
+ else if (groups.keyword !== undefined) type = "keyword";
270
+ else if (groups.whitespace !== undefined) type = "whitespace";
271
+
272
+ if (type === "punctuation" && OPENERS.includes(full)) {
273
+ // La ruta esperada (clave del objeto o índice del array, que ya está
274
+ // posicionado por la apertura o la última coma) es la del bloque.
275
+ const path = buildPath();
276
+ pendingColon = null;
277
+ awaitingValue = false;
278
+ const isArray = full === "[";
279
+ stack.push({
280
+ kind: isArray ? "array" : "object",
281
+ key: isArray ? undefined : (path[path.length - 1] as string),
282
+ index: 0,
283
+ });
284
+ recordNode(path, isArray ? "array" : "object");
285
+ } else if (type === "punctuation" && CLOSERS.includes(full)) {
286
+ stack.pop();
287
+ pendingColon = null;
288
+ awaitingValue = false;
289
+ } else if (type === "punctuation" && full === ",") {
290
+ const parent = stack[stack.length - 1];
291
+ if (parent?.kind === "array") parent.index += 1;
292
+ awaitingValue = false;
293
+ } else if (type === "punctuation" && full === ":") {
294
+ if (pendingColon !== null) {
295
+ const open = stack[stack.length - 1];
296
+ if (open?.kind === "object") open.key = pendingColon;
297
+ awaitingValue = true;
298
+ pendingColon = null;
299
+ }
300
+ } else if (type === "string") {
301
+ const decoded = safeDecodeString(full);
302
+ const parent = stack[stack.length - 1];
303
+ if (parent?.kind === "object" && !awaitingValue) {
304
+ // Todavía no se sabe si es clave: lo confirma el `:`.
305
+ pendingColon = decoded;
306
+ } else {
307
+ // Es el valor de la clave anterior (o un elemento de array, o la raíz).
308
+ if (parent?.kind === "object" && awaitingValue) parent.key = pendingColon ?? parent.key;
309
+ awaitingValue = false;
310
+ recordNode(buildPath(), "string");
311
+ }
312
+ } else if (type === "number" || type === "keyword") {
313
+ const parent = stack[stack.length - 1];
314
+ if (parent?.kind === "object" && awaitingValue) parent.key = pendingColon ?? parent.key;
315
+ awaitingValue = false;
316
+ recordNode(buildPath(), type === "number" ? "number" : full === "null" ? "null" : "boolean");
317
+ }
318
+
319
+ // Reparte el token por las líneas del documento.
320
+ const segments = full.split("\n");
321
+ segments.forEach((segment, i) => {
322
+ if (i > 0) breakLine();
323
+ pushToken(currentChunks, segment, type);
324
+ });
325
+
326
+ consumed = match.index + full.length;
327
+ JSON_TOKEN_RE.lastIndex = consumed;
328
+ }
329
+
330
+ // Resto no reconocido al final del documento.
331
+ if (consumed < text.length) {
332
+ text
333
+ .slice(consumed)
334
+ .split("\n")
335
+ .forEach((piece, i) => {
336
+ if (i > 0) breakLine();
337
+ pushToken(currentChunks, piece, "plain");
338
+ });
339
+ }
340
+
341
+ flushLine();
342
+
343
+ // Pasada final: claves (`"a":`) y, por línea, profundidad y "cierra bloque".
344
+ // Ambas cosas se derivan de la estructura, no de cómo se troceó el texto.
345
+ classifyKeys(lines);
346
+
347
+ let depthCursor = 0;
348
+ for (const line of lines) {
349
+ const trimmed = line.text.trimStart();
350
+ line.closesBlock = trimmed.startsWith("}") || trimmed.startsWith("]");
351
+ line.depth = line.closesBlock ? Math.max(0, depthCursor - 1) : depthCursor;
352
+ for (const char of line.text) {
353
+ if (char === "{" || char === "[") depthCursor += 1;
354
+ else if (char === "}" || char === "]") depthCursor = Math.max(0, depthCursor - 1);
355
+ }
356
+ }
357
+
358
+ return { lines, nodeLines, lineCount: lines.length, lineStarts };
359
+ }
360
+
361
+ /** Serializa una ruta a clave estable (`a.0.b`). */
362
+ export function pathKey(path: (string | number)[]): string {
363
+ return path.map((part) => String(part)).join(".");
364
+ }
365
+
366
+ /** Decodifica una cadena JSON; si está a medias, devuelve el texto sin comillas. */
367
+ export function safeDecodeString(raw: string): string {
368
+ if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
369
+ try {
370
+ return JSON.parse(raw) as string;
371
+ } catch {
372
+ return raw.slice(1, -1);
373
+ }
374
+ }
375
+ return raw.replace(/^"|"$/g, "");
376
+ }
377
+
378
+ /* ------------------------------------------------------------------ plegado */
379
+
380
+ /** Región plegable detectada en el documento. */
381
+ export interface ITFoldRegion {
382
+ /** Línea (1-based) donde abre el bloque. */
383
+ startLine: number;
384
+ /** Línea (1-based) donde cierra el bloque. */
385
+ endLine: number;
386
+ /** Profundidad del bloque (0 = raíz), usada para el tamaño del control. */
387
+ depth: number;
388
+ }
389
+
390
+ /**
391
+ * Detecta los bloques `{…}` / `[…]` que ocupan más de una línea, es decir, los
392
+ * candidatos a plegar desde el gutter. Ignora los que están dentro de cadenas.
393
+ */
394
+ export function findFoldRegions(text: string): ITFoldRegion[] {
395
+ const regions: ITFoldRegion[] = [];
396
+ const stack: { line: number; depth: number }[] = [];
397
+ const lineStarts = computeLineStarts(text.split("\n"));
398
+
399
+ let inString = false;
400
+ let escaped = false;
401
+
402
+ for (let i = 0; i < text.length; i++) {
403
+ const char = text[i];
404
+ if (inString) {
405
+ if (escaped) escaped = false;
406
+ else if (char === "\\") escaped = true;
407
+ else if (char === '"') inString = false;
408
+ continue;
409
+ }
410
+ if (char === '"') {
411
+ inString = true;
412
+ continue;
413
+ }
414
+ if (OPENERS.includes(char)) {
415
+ stack.push({ line: offsetToPosition(text, i, lineStarts).line, depth: stack.length });
416
+ } else if (CLOSERS.includes(char)) {
417
+ const open = stack.pop();
418
+ const closeLine = offsetToPosition(text, i, lineStarts).line;
419
+ if (open && closeLine > open.line) {
420
+ regions.push({ startLine: open.line, endLine: closeLine, depth: open.depth });
421
+ }
422
+ }
423
+ }
424
+
425
+ return regions;
426
+ }
427
+
428
+ /* ---------------------------------------------------------------- errores */
429
+
430
+ /** Error de parseo con la posición exacta dentro del documento. */
431
+ export interface ITJsonParseError {
432
+ /** Mensaje del motor, limpio de la coletilla «at position N». */
433
+ message: string;
434
+ /** Línea (1-based) del fallo. */
435
+ line: number;
436
+ /** Columna (1-based) del fallo. */
437
+ column: number;
438
+ /** Offset absoluto del fallo. */
439
+ offset: number;
440
+ }
441
+
442
+ /**
443
+ * Recorredor JSON que **siempre** sabe en qué offset se rompió el documento.
444
+ *
445
+ * Hace falta porque el mensaje de `JSON.parse` depende del motor: V8 a veces
446
+ * informa de la posición y a veces no (en Node 20, `{"a":1,` produce
447
+ * «Unexpected token... is not valid JSON», sin offset). Sin esto, el editor no
448
+ * podría señalar la línea y la columna del error.
449
+ */
450
+ class JsonScanner {
451
+ private index = 0;
452
+
453
+ constructor(
454
+ private readonly text: string,
455
+ private readonly lineStarts: number[]
456
+ ) {}
457
+
458
+ /** Valida el documento completo. `null` cuando es correcto. */
459
+ validate(): ITJsonParseError | null {
460
+ const text = this.text;
461
+ if (!text.trim()) return null;
462
+
463
+ this.skipWhitespace();
464
+ const failure = this.readValue("");
465
+ if (failure) return failure;
466
+
467
+ this.skipWhitespace();
468
+ if (this.index < text.length) return this.fail("There is trailing content after the JSON value");
469
+ return null;
470
+ }
471
+
472
+ /** Avanza mientras haya espacio en blanco JSON. */
473
+ private skipWhitespace() {
474
+ while (this.index < this.text.length && " \t\n\r".includes(this.text[this.index])) this.index += 1;
475
+ }
476
+
477
+ /** Construye el error apuntando al offset actual. */
478
+ private fail(message: string, offset = this.index): ITJsonParseError {
479
+ const position = offsetToPosition(this.text, offset, this.lineStarts);
480
+ return {
481
+ message,
482
+ line: position.line,
483
+ column: position.column,
484
+ offset: Math.max(0, Math.min(offset, this.text.length)),
485
+ };
486
+ }
487
+
488
+ /** Consume un trozo literal; si no coincide, explica qué se esperaba. */
489
+ private expect(literal: string, context: string): ITJsonParseError | null {
490
+ if (this.text.startsWith(literal, this.index)) {
491
+ this.index += literal.length;
492
+ return null;
493
+ }
494
+ return this.fail(`Expected ${JSON.stringify(literal)}${context}`);
495
+ }
496
+
497
+ /** Lee cualquier valor JSON: objeto, array, cadena, número, booleano o null. */
498
+ private readValue(context: string): ITJsonParseError | null {
499
+ this.skipWhitespace();
500
+ const char = this.text[this.index];
501
+
502
+ if (char === undefined) return this.fail(`Unexpected end of input${context}`);
503
+ if (char === "{") return this.readObject();
504
+ if (char === "[") return this.readArray();
505
+ if (char === '"') return this.readString();
506
+ if (char === "t") return this.expect("true", context);
507
+ if (char === "f") return this.expect("false", context);
508
+ if (char === "n") return this.expect("null", context);
509
+ if (char === "-" || (char >= "0" && char <= "9")) return this.readNumber();
510
+ return this.fail(`Unexpected character ${JSON.stringify(char)}${context}`);
511
+ }
512
+
513
+ /** Objeto: `{ "clave": valor, … }`. */
514
+ private readObject(): ITJsonParseError | null {
515
+ this.index += 1; // "{"
516
+ this.skipWhitespace();
517
+ if (this.text[this.index] === "}") {
518
+ this.index += 1;
519
+ return null;
520
+ }
521
+ for (;;) {
522
+ this.skipWhitespace();
523
+ if (this.text[this.index] !== '"') {
524
+ return this.fail('Expected a double-quoted property name (keys must be strings in JSON)');
525
+ }
526
+ const stringFailure = this.readString();
527
+ if (stringFailure) return stringFailure;
528
+
529
+ this.skipWhitespace();
530
+ const colonFailure = this.expect(":", " after the property name");
531
+ if (colonFailure) return colonFailure;
532
+
533
+ const valueFailure = this.readValue(" as the property value");
534
+ if (valueFailure) return valueFailure;
535
+
536
+ this.skipWhitespace();
537
+ const separator = this.text[this.index];
538
+ if (separator === ",") {
539
+ this.index += 1;
540
+ continue;
541
+ }
542
+ if (separator === "}") {
543
+ this.index += 1;
544
+ return null;
545
+ }
546
+ if (separator === undefined) return this.fail("Unexpected end of input inside the object (missing })");
547
+ return this.fail('Expected "," or "}" after the property value');
548
+ }
549
+ }
550
+
551
+ /** Array: `[valor, …]`. */
552
+ private readArray(): ITJsonParseError | null {
553
+ this.index += 1; // "["
554
+ this.skipWhitespace();
555
+ if (this.text[this.index] === "]") {
556
+ this.index += 1;
557
+ return null;
558
+ }
559
+ for (;;) {
560
+ const valueFailure = this.readValue(" as an array element");
561
+ if (valueFailure) return valueFailure;
562
+
563
+ this.skipWhitespace();
564
+ const separator = this.text[this.index];
565
+ if (separator === ",") {
566
+ this.index += 1;
567
+ continue;
568
+ }
569
+ if (separator === "]") {
570
+ this.index += 1;
571
+ return null;
572
+ }
573
+ if (separator === undefined) return this.fail("Unexpected end of input inside the array (missing ])");
574
+ return this.fail('Expected "," or "]" after the array element');
575
+ }
576
+ }
577
+
578
+ /** Cadena entre comillas dobles, sin saltos de línea reales. */
579
+ private readString(): ITJsonParseError | null {
580
+ const start = this.index;
581
+ this.index += 1; // comilla de apertura
582
+ while (this.index < this.text.length) {
583
+ const char = this.text[this.index];
584
+ if (char === "\\") {
585
+ this.index += 2; // escape: se salta el carácter siguiente
586
+ continue;
587
+ }
588
+ if (char === '"') {
589
+ this.index += 1;
590
+ return null;
591
+ }
592
+ if (char === "\n") return this.fail("Unterminated string: JSON strings cannot span lines", start);
593
+ this.index += 1;
594
+ }
595
+ return this.fail("Unterminated string (missing closing quote)", start);
596
+ }
597
+
598
+ /** Número JSON: `-?d+(.d+)?([eE][+-]?d+)?`. */
599
+ private readNumber(): ITJsonParseError | null {
600
+ const start = this.index;
601
+ const match = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
602
+ match.lastIndex = this.index;
603
+ const found = match.exec(this.text);
604
+ if (!found || found.index !== this.index || found[0].length === 0) {
605
+ return this.fail("Invalid number");
606
+ }
607
+ this.index = start + found[0].length;
608
+ return null;
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Traduce el error de `JSON.parse` a línea/columna.
614
+ *
615
+ * Se usa el validador propio, que siempre conoce el offset exacto: el mensaje
616
+ * del motor solo entra como respaldo cuando el validador no encuentra el fallo.
617
+ * Los mensajes de V8 («Unexpected token ',', ...fragmento... is not valid
618
+ * JSON») arrastran un trozo del documento y son peores para mostrar en la barra.
619
+ */
620
+ export function parseJsonError(
621
+ error: unknown,
622
+ text: string,
623
+ lineStarts: number[]
624
+ ): ITJsonParseError {
625
+ const scanner = new JsonScanner(text, lineStarts).validate();
626
+ if (scanner) return scanner;
627
+
628
+ const raw = error instanceof Error ? error.message : String(error);
629
+ const positionMatch = raw.match(/at position (\d+)/);
630
+ const lineColumnMatch = raw.match(/\(line (\d+) column (\d+)\)/);
631
+ const offset = Math.min(
632
+ text.length,
633
+ positionMatch ? Number(positionMatch[1]) : text.trimEnd().length
634
+ );
635
+ const computed = offsetToPosition(text, offset, lineStarts);
636
+ const message = raw
637
+ .replace(/\s*in JSON at position \d+/, "")
638
+ .replace(/\s*\(line \d+ column \d+\)/, "")
639
+ .replace(/^Unexpected token.*?is not valid JSON/, "Invalid JSON syntax")
640
+ .trim();
641
+
642
+ return {
643
+ message: message || raw,
644
+ line: lineColumnMatch ? Number(lineColumnMatch[1]) : computed.line,
645
+ column: lineColumnMatch ? Number(lineColumnMatch[2]) : computed.column,
646
+ offset,
647
+ };
648
+ }
649
+
650
+ /* --------------------------------------------------------- transformaciones */
651
+
652
+ /** Transformaciones aplicables al documento desde la barra de acciones. */
653
+ export type ITJsonTransform = "format" | "minify" | "sortAsc" | "sortDesc" | "escape" | "unescape";
654
+
655
+ /** Recorre un valor y reordena alfabéticamente las claves de todos los objetos. */
656
+ function sortKeysDeep(value: unknown, direction: "asc" | "desc"): unknown {
657
+ if (Array.isArray(value)) return value.map((item) => sortKeysDeep(item, direction));
658
+ if (value && typeof value === "object") {
659
+ const entries = Object.entries(value as Record<string, unknown>);
660
+ entries.sort(([a], [b]) => (direction === "asc" ? a.localeCompare(b) : b.localeCompare(a)));
661
+ const result: Record<string, unknown> = {};
662
+ for (const [key, nested] of entries) result[key] = sortKeysDeep(nested, direction);
663
+ return result;
664
+ }
665
+ return value;
666
+ }
667
+
668
+ /**
669
+ * Aplica una transformación sobre un valor ya parseado y devuelve el texto.
670
+ *
671
+ * @throws Si la transformación no aplica al valor (por ejemplo, `unescape`
672
+ * sobre un valor que no es una cadena).
673
+ */
674
+ export function transformValue(value: unknown, transform: ITJsonTransform, indent: number): string {
675
+ switch (transform) {
676
+ case "format":
677
+ return JSON.stringify(value, null, indent);
678
+ case "minify":
679
+ return JSON.stringify(value);
680
+ case "sortAsc":
681
+ return JSON.stringify(sortKeysDeep(value, "asc"), null, indent);
682
+ case "sortDesc":
683
+ return JSON.stringify(sortKeysDeep(value, "desc"), null, indent);
684
+ case "escape":
685
+ return JSON.stringify(JSON.stringify(value, null, indent));
686
+ case "unescape": {
687
+ if (typeof value !== "string") {
688
+ throw new Error("unescape expects a string value");
689
+ }
690
+ // La cadena contiene JSON serializado; se reindenta al gusto actual.
691
+ const inner: unknown = JSON.parse(value);
692
+ return typeof inner === "string" ? JSON.stringify(inner) : JSON.stringify(inner, null, indent);
693
+ }
694
+ default:
695
+ return JSON.stringify(value, null, indent);
696
+ }
697
+ }
698
+
699
+ /** Clasifica un valor para el árbol y las insignias. */
700
+ export function valueKind(value: unknown): ITCodeNodeEntry["kind"] {
701
+ if (value === null) return "null";
702
+ if (Array.isArray(value)) return "array";
703
+ const type = typeof value;
704
+ if (type === "object") return "object";
705
+ if (type === "number") return "number";
706
+ if (type === "boolean") return "boolean";
707
+ return "string";
708
+ }
709
+
710
+ /** Resumen corto de un valor primitivo, para pintarlo en el árbol. */
711
+ export function previewValue(value: unknown): string {
712
+ const kind = valueKind(value);
713
+ if (kind === "string") return JSON.stringify(value);
714
+ if (kind === "null") return "null";
715
+ if (kind === "number" || kind === "boolean") return String(value);
716
+ return "";
717
+ }