@danieltmn/tamnora 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/harness/packages/server/public/app.js +833 -834
  3. package/harness/packages/server/public/appearance.js +4 -0
  4. package/harness/packages/server/public/boot.js +49 -0
  5. package/harness/packages/server/public/chat.js +17 -0
  6. package/harness/packages/server/public/chrome.js +855 -852
  7. package/harness/packages/server/public/commands.js +448 -403
  8. package/harness/packages/server/public/core.js +229 -216
  9. package/harness/packages/server/public/favicon.ico +0 -0
  10. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  11. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  12. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  13. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  14. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  15. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  16. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  17. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  18. package/harness/packages/server/public/icons/logo.png +0 -0
  19. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  20. package/harness/packages/server/public/icons/logo_light.png +0 -0
  21. package/harness/packages/server/public/icons/tamnora.ico +0 -0
  22. package/harness/packages/server/public/index.html +72 -101
  23. package/harness/packages/server/public/landing/boot.js +16 -0
  24. package/harness/packages/server/public/landing/index.html +123 -59
  25. package/harness/packages/server/public/landing/landing.js +11 -9
  26. package/harness/packages/server/public/logo.png +0 -0
  27. package/harness/packages/server/public/markdown.js +1 -1
  28. package/harness/packages/server/public/settings.js +1155 -1080
  29. package/harness/packages/server/public/settingsNav.js +81 -0
  30. package/harness/packages/server/public/styles.css +2818 -2515
  31. package/harness/packages/server/public/system.js +495 -495
  32. package/harness/packages/server/src/lib/appearance.js +4 -0
  33. package/harness/packages/server/src/lib/restart.js +20 -0
  34. package/harness/packages/server/src/lib/securityHeaders.js +53 -0
  35. package/harness/packages/server/src/routes/reload.js +46 -0
  36. package/harness/packages/server/src/server.js +6 -1
  37. package/harness/packages/server/test/accessibility.test.js +72 -0
  38. package/harness/packages/server/test/appearance.test.js +4 -0
  39. package/harness/packages/server/test/consoleModules.test.js +133 -57
  40. package/harness/packages/server/test/consoleTabs.test.js +228 -0
  41. package/harness/packages/server/test/docsSync.test.js +73 -0
  42. package/harness/packages/server/test/landingLogin.test.js +89 -0
  43. package/harness/packages/server/test/markdownSecurity.test.js +80 -0
  44. package/harness/packages/server/test/reload.test.js +95 -0
  45. package/harness/packages/server/test/securityHeaders.test.js +109 -0
  46. package/harness/packages/server/test/settingsNav.test.js +90 -0
  47. package/harness/packages/server/test/themeCss.js +110 -0
  48. package/harness/packages/server/test/themes.test.js +72 -0
  49. package/harness/scripts/restart.mjs +101 -0
  50. package/package.json +1 -1
@@ -11,10 +11,14 @@ export const THEME_IDS = [
11
11
  'solarized-dark',
12
12
  'bosque',
13
13
  'vino',
14
+ 'arena',
15
+ 'hielo',
14
16
  'vercel',
15
17
  'dark-plus',
16
18
  'github',
17
19
  'github-dark',
20
+ 'mocha',
21
+ 'ebano',
18
22
  ];
19
23
 
20
24
  export const FONT_IDS = ['clasico', 'sistema', 'plex', 'inter', 'jetbrains', 'fira', 'source'];
@@ -0,0 +1,20 @@
1
+ // Lanza el script detached que espera la muerte de este server y lo relanza con
2
+ // el mismo comando con que arranco. Devolver rapido es el punto: el server tiene
3
+ // que soltar el puerto antes de que el nuevo intente entrar.
4
+ import { spawn } from 'node:child_process';
5
+ import path from 'node:path';
6
+ import { packageRoot, homeDir } from './core.js';
7
+
8
+ export function spawnRestart({ pid = process.pid, argv = process.argv.slice(1), home } = {}) {
9
+ const root = packageRoot();
10
+ if (!root) return { ok: false, error: 'no encontre el paquete del harness' };
11
+ const script = path.join(root, 'harness', 'scripts', 'restart.mjs');
12
+ const args = [script, '--pid', String(pid), '--argv', JSON.stringify(argv), '--home', home || homeDir()];
13
+ try {
14
+ const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', windowsHide: true });
15
+ child.unref();
16
+ return { ok: true, pid: child.pid };
17
+ } catch (error) {
18
+ return { ok: false, error: String(error?.message ?? error) };
19
+ }
20
+ }
@@ -0,0 +1,53 @@
1
+ // Cabeceras de seguridad de la consola. La consola es una SPA sin build: sus
2
+ // scripts son modulos propios, asi que `script-src 'self'` alcanza y un XSS por
3
+ // un hueco del renderer de markdown no puede cargar ni ejecutar codigo ajeno.
4
+
5
+ const FONTS_CSS = 'https://fonts.googleapis.com';
6
+ const FONTS_FILES = 'https://fonts.gstatic.com';
7
+
8
+ export const CSP_DIRECTIVES = {
9
+ 'default-src': ["'self'"],
10
+ 'script-src': ["'self'"],
11
+ 'style-src': ["'self'", "'unsafe-inline'", FONTS_CSS],
12
+ 'font-src': ["'self'", FONTS_FILES, 'data:'],
13
+ 'img-src': ["'self'", 'data:', 'blob:'],
14
+ 'media-src': ["'self'", 'blob:', 'data:'],
15
+ 'connect-src': ["'self'"],
16
+ // La pestaña preview embebe el dev server del usuario (local o por tunel).
17
+ 'frame-src': ['http:', 'https:'],
18
+ 'frame-ancestors': ["'self'"],
19
+ 'manifest-src': ["'self'"],
20
+ 'worker-src': ["'self'", 'blob:'],
21
+ 'object-src': ["'none'"],
22
+ 'base-uri': ["'self'"],
23
+ 'form-action': ["'self'"],
24
+ };
25
+
26
+ export function buildCsp(directives = CSP_DIRECTIVES) {
27
+ return Object.entries(directives)
28
+ .map(([name, sources]) => `${name} ${sources.join(' ')}`)
29
+ .join('; ');
30
+ }
31
+
32
+ const CSP_VALUE = buildCsp();
33
+
34
+ export const CSP_MODES = ['enforce', 'report-only', 'off'];
35
+
36
+ export function cspMode(config) {
37
+ const mode = config?.server?.csp;
38
+ return CSP_MODES.includes(mode) ? mode : 'enforce';
39
+ }
40
+
41
+ /**
42
+ * Setea las cabeceras en `res` antes de `writeHead` (que las mezcla). La CSP solo
43
+ * va en lo que no es API: en un JSON o un SSE no protege nada.
44
+ */
45
+ export function applySecurityHeaders(res, { api = false, csp = 'enforce' } = {}) {
46
+ res.setHeader('X-Content-Type-Options', 'nosniff');
47
+ res.setHeader('Referrer-Policy', 'same-origin');
48
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
49
+ res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), payment=(), usb=()');
50
+ if (api || csp === 'off') return;
51
+ const name = csp === 'report-only' ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy';
52
+ res.setHeader(name, CSP_VALUE);
53
+ }
@@ -0,0 +1,46 @@
1
+ // Recargas operativas desde la consola: reiniciar el server y (re)abrir el
2
+ // puente remoto (tailscale). Son privilegiadas, igual que update/shell/git:
3
+ // solo conexion local. En ambos casos la respuesta vuelve rapido y la accion
4
+ // corre aparte (script detached / `tailscale serve --bg`).
5
+ import { sendJson, sendError } from '../lib/http.js';
6
+ import { requireLocal } from '../lib/workspace.js';
7
+ import { spawnRestart } from '../lib/restart.js';
8
+ import { startTailscaleServe, stopTailscaleServe } from '../lib/core.js';
9
+
10
+ export function registerReload(route, ctx) {
11
+ route('POST', '/api/server/restart', ({ req, res }) => {
12
+ if (!requireLocal(req, res, 'Reiniciar el servidor solo esta permitido en la conexion local.')) return;
13
+
14
+ const spawnRestarter = ctx.spawnRestarter ?? spawnRestart;
15
+ const started = spawnRestarter({
16
+ pid: process.pid,
17
+ argv: process.argv.slice(1),
18
+ });
19
+ if (!started.ok) return sendError(res, 500, started.error);
20
+
21
+ sendJson(res, 202, { ok: true, restarting: true });
22
+ // Soltar el puerto antes de que el server nuevo intente entrar.
23
+ setTimeout(() => {
24
+ Promise.resolve(ctx.shutdown?.()).catch(() => process.exit(0));
25
+ }, 800);
26
+ });
27
+
28
+ route('POST', '/api/bridge/reload', async ({ req, res }) => {
29
+ if (!requireLocal(req, res, 'Abrir el puente solo esta permitido en la conexion local.')) return;
30
+
31
+ const port = ctx.serverInfo?.port;
32
+ if (!port) return sendError(res, 500, 'No se en que puerto corre el server.');
33
+
34
+ // Reinicio puntual: apagar el handler de la consola (443) y volver a
35
+ // publicarlo. Un `serve reset` borraria tambien el preview en 8443.
36
+ const stop = ctx.tunnelStop ?? stopTailscaleServe;
37
+ const start = ctx.tunnelStart ?? startTailscaleServe;
38
+ try {
39
+ await stop().catch(() => {});
40
+ const tun = await start(port);
41
+ sendJson(res, 200, { ok: true, url: tun.url, provider: tun.provider ?? 'tailscale' });
42
+ } catch (error) {
43
+ sendError(res, 502, String(error?.message ?? error));
44
+ }
45
+ });
46
+ }
@@ -20,6 +20,7 @@ import {
20
20
  } from './lib/core.js';
21
21
  import { sendJson, sendError } from './lib/http.js';
22
22
  import { requireTrustedHost, requireSameOrigin } from './lib/workspace.js';
23
+ import { applySecurityHeaders, cspMode } from './lib/securityHeaders.js';
23
24
  import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
24
25
  import { profilePrompt, resolveProfile } from './lib/profile.js';
25
26
  import { skillsBlock } from './lib/skills.js';
@@ -47,6 +48,7 @@ import { registerProfile } from './routes/profile.js';
47
48
  import { registerTts } from './routes/tts.js';
48
49
  import { registerTranscribe } from './routes/transcribe.js';
49
50
  import { registerUpdate } from './routes/update.js';
51
+ import { registerReload } from './routes/reload.js';
50
52
  import { registerAdvisor } from './routes/advisor.js';
51
53
  import { registerProcs } from './routes/procs.js';
52
54
  import { registerShell } from './routes/shell.js';
@@ -331,6 +333,7 @@ export async function createTamnoraServer({
331
333
  registerTranscribe(route, ctx);
332
334
  registerAdvisor(route, ctx);
333
335
  registerUpdate(route, ctx);
336
+ registerReload(route, ctx);
334
337
  registerProcs(route, ctx);
335
338
  registerShell(route, ctx);
336
339
  const preview = registerPreview(route, ctx);
@@ -376,8 +379,10 @@ export async function createTamnoraServer({
376
379
  const sockets = new Set();
377
380
  const server = http.createServer(async (req, res) => {
378
381
  const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
382
+ const isApi = url.pathname.startsWith('/api/');
383
+ applySecurityHeaders(res, { api: isApi, csp: cspMode(config) });
379
384
 
380
- if (url.pathname.startsWith('/api/')) {
385
+ if (isApi) {
381
386
  const matched = matchRoute(req.method, url.pathname);
382
387
  if (!matched) return sendError(res, 404, `Ruta no encontrada: ${req.method} ${url.pathname}`);
383
388
  // Guardas de request SIEMPRE activas (con o sin auth): DNS-rebinding y
@@ -0,0 +1,72 @@
1
+ // Accesibilidad de la consola que se verifica sobre el texto de los archivos.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { readdirSync, readFileSync } from 'node:fs';
5
+ import { readStyles } from './themeCss.js';
6
+
7
+ const PUBLIC = new URL('../public/', import.meta.url);
8
+ const css = readStyles();
9
+
10
+ /** Contenido del bloque `@media (...)` que empieza en `inicio`. */
11
+ function bloqueMedia(inicio) {
12
+ const abre = css.indexOf('{', inicio);
13
+ let prof = 0;
14
+ for (let i = abre; i < css.length; i += 1) {
15
+ if (css[i] === '{') prof += 1;
16
+ else if (css[i] === '}') {
17
+ prof -= 1;
18
+ if (prof === 0) return css.slice(abre + 1, i);
19
+ }
20
+ }
21
+ throw new Error('bloque sin cerrar');
22
+ }
23
+
24
+ test('prefers-reduced-motion apaga transiciones y los pulsos decorativos', () => {
25
+ const inicio = css.indexOf('@media (prefers-reduced-motion: reduce)');
26
+ assert.ok(inicio >= 0, 'falta el bloque de movimiento reducido');
27
+ const bloque = bloqueMedia(inicio);
28
+ assert.match(bloque, /transition-duration:\s*0\.01ms/);
29
+ assert.match(bloque, /scroll-behavior:\s*auto/);
30
+ assert.match(bloque, /animation:\s*none/);
31
+ });
32
+
33
+ test('toda animacion infinita que no es un indicador de progreso se apaga con movimiento reducido', () => {
34
+ // Quedan fuera los loaders y el spinner: indican que el agente esta trabajando.
35
+ const PROGRESO = new Set(['loader-thinking', 'loader-working', 'spin']);
36
+ const bloque = bloqueMedia(css.indexOf('@media (prefers-reduced-motion: reduce)'));
37
+
38
+ const reglas = [...css.matchAll(/([^{}]+)\{([^{}]*animation:\s*([a-z-]+)[^;}]*infinite[^;}]*;?[^{}]*)\}/g)];
39
+ assert.ok(reglas.length >= 5, 'no se leyeron las animaciones');
40
+ for (const [, selectores, , nombre] of reglas) {
41
+ if (PROGRESO.has(nombre)) continue;
42
+ for (const selector of selectores.split(',').map((s) => s.trim()).filter(Boolean)) {
43
+ const limpio = selector.split('\n').pop().trim();
44
+ assert.ok(bloque.includes(limpio), `"${limpio}" anima (${nombre}) y no se apaga con movimiento reducido`);
45
+ }
46
+ }
47
+ });
48
+
49
+ test('el scroll animado de JS respeta el movimiento reducido', () => {
50
+ for (const archivo of readdirSync(PUBLIC).filter((f) => f.endsWith('.js'))) {
51
+ const src = readFileSync(new URL(archivo, PUBLIC), 'utf8');
52
+ assert.ok(!/behavior:\s*['"]smooth['"]/.test(src), `${archivo} fuerza scroll animado: usar scrollBehavior()`);
53
+ }
54
+ });
55
+
56
+ test('en pantallas tactiles los campos van a 16px (iOS hace zoom con menos)', () => {
57
+ const inicio = css.indexOf('@media (pointer: coarse), (max-width: 480px)');
58
+ assert.ok(inicio >= 0, 'falta el bloque tactil');
59
+ const bloque = bloqueMedia(inicio);
60
+ assert.match(bloque, /select,[\s\S]*font-size:\s*16px/);
61
+ assert.match(bloque, /min-height:\s*40px/);
62
+ });
63
+
64
+ test('el foco de teclado es visible en botones, enlaces y elementos enfocables', () => {
65
+ assert.match(css, /button:focus-visible, a:focus-visible, summary:focus-visible, \[tabindex\]:focus-visible \{\s*outline: 2px solid var\(--accent\)/);
66
+ });
67
+
68
+ test('las regiones que cambian solas se anuncian a lectores de pantalla', () => {
69
+ const html = readFileSync(new URL('index.html', PUBLIC), 'utf8');
70
+ assert.ok(/id="toasts"[^>]*aria-live="polite"/.test(html), 'los avisos (toasts) deberian ser aria-live');
71
+ assert.ok(/id="sb-activity"[^>]*(role="status"|aria-live)/.test(html), 'la actividad del agente deberia ser un status');
72
+ });
@@ -79,10 +79,14 @@ test('la validacion del server coincide con el registro del cliente', () => {
79
79
  'solarized-dark',
80
80
  'bosque',
81
81
  'vino',
82
+ 'arena',
83
+ 'hielo',
82
84
  'vercel',
83
85
  'dark-plus',
84
86
  'github',
85
87
  'github-dark',
88
+ 'mocha',
89
+ 'ebano',
86
90
  ].sort()
87
91
  );
88
92
  assert.equal(resolveThemeId('solarized-dark'), 'solarized-dark');
@@ -53,34 +53,147 @@ function importsDe(archivo) {
53
53
  const exportsPorModulo = new Map(modulos.map((m) => [m, exportsDe(m)]));
54
54
  const importsPorModulo = new Map(modulos.map((m) => [m, importsDe(m)]));
55
55
 
56
+ /**
57
+ * Copia del source con cadenas, comentarios y literales regex **vaciados**
58
+ * (mismos largos y saltos de linea). Contar llaves/parentesis o buscar llamadas
59
+ * sobre esta copia no se confunde con un `}` de un string, un `(` de un
60
+ * comentario ni con los grupos de un regex. Los templates se vacian enteros
61
+ * salvo sus interpolaciones (`${...}`, que es codigo real): un barrido naive
62
+ * cierra en el backtick del template anidado (`${x.map((v) => `${v}`)}`) y
63
+ * desbalancea todo lo que viene despues.
64
+ */
65
+ function sinCadenas(src) {
66
+ const out = [...src];
67
+ const vaciar = (i) => {
68
+ if (out[i] !== '\n') out[i] = ' ';
69
+ return i + 1;
70
+ };
71
+ // Un `/` abre regex salvo que venga de un valor (division): `a / b` vs
72
+ // `return /re/`, `if (/re/)`, `x = /re/`.
73
+ const TRAS_REGEX = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'delete', 'void', 'new', 'do', 'else', 'yield', 'await', 'case']);
74
+ const PUNCT_REGEX = new Set(['(', '[', '{', '=', ':', ',', ';', '!', '?', '&', '|', '+', '-', '*', '%', '^', '~', '<', '>', '']);
75
+ let ultimaPalabra = '';
76
+ let ultimoChar = '';
77
+
78
+ function plantilla(i) {
79
+ while (i < src.length) {
80
+ const c = src[i];
81
+ if (c === '\\') i = vaciar(vaciar(i));
82
+ else if (c === '`') return vaciar(i);
83
+ else if (c === '$' && src[i + 1] === '{') {
84
+ i = vaciar(vaciar(i));
85
+ i = codigo(i, true);
86
+ if (i < src.length) i = vaciar(i);
87
+ } else i = vaciar(i);
88
+ }
89
+ return i;
90
+ }
91
+
92
+ function literarRegex(i) {
93
+ let enClase = false;
94
+ i = vaciar(i);
95
+ while (i < src.length && src[i] !== '\n') {
96
+ const c = src[i];
97
+ if (c === '\\') {
98
+ i = vaciar(vaciar(i));
99
+ continue;
100
+ }
101
+ if (c === '[') enClase = true;
102
+ else if (c === ']') enClase = false;
103
+ else if (c === '/' && !enClase) return vaciar(i) + saltarFlags(i + 1);
104
+ i = vaciar(i);
105
+ }
106
+ return i + saltarFlags(i);
107
+ }
108
+
109
+ function saltarFlags(i) {
110
+ let j = i;
111
+ while (j < src.length && /[a-z]/i.test(src[j])) j = vaciar(j);
112
+ return j - i;
113
+ }
114
+
115
+ function codigo(i, cierraInterp) {
116
+ let prof = 0;
117
+ while (i < src.length) {
118
+ const c = src[i];
119
+ const dos = src[i + 1];
120
+ if (c === '/' && dos === '/') {
121
+ while (i < src.length && src[i] !== '\n') i = vaciar(i);
122
+ continue;
123
+ }
124
+ if (c === '/' && dos === '*') {
125
+ i = vaciar(vaciar(i));
126
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i = vaciar(i);
127
+ if (i < src.length) i = vaciar(vaciar(i));
128
+ continue;
129
+ }
130
+ if (c === '"' || c === "'") {
131
+ i = vaciar(i);
132
+ while (i < src.length && src[i] !== c) i = src[i] === '\\' ? vaciar(vaciar(i)) : vaciar(i);
133
+ if (i < src.length) i = vaciar(i);
134
+ ultimaPalabra = '';
135
+ ultimoChar = ')';
136
+ continue;
137
+ }
138
+ if (c === '`') {
139
+ i = plantilla(vaciar(i));
140
+ ultimaPalabra = '';
141
+ ultimoChar = ')';
142
+ continue;
143
+ }
144
+ const trasPalabra = ultimaPalabra && ultimoChar === ultimaPalabra[ultimaPalabra.length - 1];
145
+ const abreRegex = trasPalabra ? TRAS_REGEX.has(ultimaPalabra) : PUNCT_REGEX.has(ultimoChar);
146
+ if (c === '/' && abreRegex) {
147
+ i = literarRegex(i);
148
+ ultimaPalabra = '';
149
+ ultimoChar = ')';
150
+ continue;
151
+ }
152
+ if (cierraInterp) {
153
+ if (c === '{') prof += 1;
154
+ else if (c === '}') {
155
+ if (prof === 0) return i; // el `}` de cierre lo vacia el llamador
156
+ prof -= 1;
157
+ }
158
+ }
159
+ if (/[A-Za-z0-9_$]/.test(c)) {
160
+ ultimaPalabra += c;
161
+ ultimoChar = c;
162
+ } else if (!/\s/.test(c)) {
163
+ ultimaPalabra = '';
164
+ ultimoChar = c;
165
+ }
166
+ i += 1;
167
+ }
168
+ return i;
169
+ }
170
+
171
+ codigo(0, false);
172
+ return out.join('');
173
+ }
174
+
56
175
  /**
57
176
  * Statements de nivel superior de un modulo, agrupando por balance de llaves y
58
177
  * parentesis. Asi `const x = f();` sale como un statement, y un
59
- * `addEventListener(...)` con un callback multilinea tambien.
178
+ * `addEventListener(...)` con un callback multilinea tambien. El texto se
179
+ * devuelve crudo (para los mensajes de error) pero el balance se cuenta sobre
180
+ * `sinCadenas`, que preserva largos y offsets.
60
181
  */
61
182
  function statementsTopLevel(src) {
183
+ const limpio = sinCadenas(src);
62
184
  const stmts = [];
63
185
  let actual = '';
64
186
  let inicio = 0;
65
187
  let llaves = 0;
66
188
  let corchetes = 0;
67
189
  let parentesis = 0;
68
- let enCadena = '';
69
190
  let pos = 0;
70
- for (const linea of src.split(/\r?\n/)) {
191
+ for (const linea of src.matchAll(/[^\n]*(?:\r?\n|$)/g)) {
192
+ if (!linea[0]) continue; // match vacio al final
193
+ const texto = linea[0].replace(/\r?\n$/, '');
71
194
  if (!actual) inicio = pos;
72
- actual += `${actual ? '\n' : ''}${linea}`;
73
- for (let i = 0; i < linea.length; i += 1) {
74
- const c = linea[i];
75
- const previo = linea[i - 1];
76
- if (enCadena) {
77
- if (c === enCadena && previo !== '\\') enCadena = '';
78
- continue;
79
- }
80
- if (c === '"' || c === "'" || c === '`') {
81
- enCadena = c;
82
- continue;
83
- }
195
+ actual += `${actual ? '\n' : ''}${texto}`;
196
+ for (const c of limpio.slice(pos, pos + texto.length)) {
84
197
  if (c === '{') llaves += 1;
85
198
  else if (c === '}') llaves -= 1;
86
199
  else if (c === '[') corchetes += 1;
@@ -88,7 +201,7 @@ function statementsTopLevel(src) {
88
201
  else if (c === '(') parentesis += 1;
89
202
  else if (c === ')') parentesis -= 1;
90
203
  }
91
- pos += linea.length + 1;
204
+ pos += linea[0].length;
92
205
  if (llaves <= 0 && corchetes <= 0 && parentesis <= 0) {
93
206
  if (actual.trim()) stmts.push({ texto: actual, inicio });
94
207
  actual = '';
@@ -106,54 +219,28 @@ function statementsTopLevel(src) {
106
219
  * callbacks (`=> {...}`, `=> expr`, `function () {...}`) y literales de datos
107
220
  * (`= [...]`, `= {...}`), que a lo sumo guardan referencias. Lo que queda es lo
108
221
  * que se evalua al cargar el modulo.
109
- *
110
- * Recorre con estado de cadenas: un `=>` dentro de un template no es un arrow.
111
222
  */
112
223
  function sinDiferidos(stmt) {
113
- const s = stmt.replace(/\r?\n/g, ' ');
224
+ const s = sinCadenas(stmt).replace(/\r?\n/g, ' ');
114
225
  let out = '';
115
226
  let i = 0;
116
- let cadena = '';
117
227
  const cortarBloque = (desde) => {
118
228
  // Corta hasta el cierre balanceado de `{`/`[` abierto en `desde - 1`.
119
229
  let prof = 1;
120
230
  let j = desde;
121
- let cad = '';
122
- for (; j < s.length; j += 1) {
231
+ while (j < s.length) {
123
232
  const c = s[j];
124
- const previo = s[j - 1];
125
- if (cad) {
126
- if (c === cad && previo !== '\\') cad = '';
127
- continue;
128
- }
129
- if (c === '"' || c === "'" || c === '`') {
130
- cad = c;
131
- continue;
132
- }
133
233
  if (c === '{' || c === '[') prof += 1;
134
234
  else if (c === '}' || c === ']') {
135
235
  prof -= 1;
136
236
  if (prof === 0) return j + 1;
137
237
  }
238
+ j += 1;
138
239
  }
139
240
  return j;
140
241
  };
141
-
142
242
  while (i < s.length) {
143
243
  const c = s[i];
144
- const previo = s[i - 1];
145
- if (cadena) {
146
- out += c;
147
- if (c === cadena && previo !== '\\') cadena = '';
148
- i += 1;
149
- continue;
150
- }
151
- if (c === '"' || c === "'" || c === '`') {
152
- cadena = c;
153
- out += c;
154
- i += 1;
155
- continue;
156
- }
157
244
  const resto = s.slice(i);
158
245
  if (c === '=' && s[i + 1] === '>') {
159
246
  const despues = s.slice(i + 2).match(/^\s*([[{])/);
@@ -186,18 +273,7 @@ function sinDiferidos(stmt) {
186
273
  /** Profundidad de llaves antes del caracter `hasta` (para saber si es nivel 0). */
187
274
  function profundidadEn(src, hasta) {
188
275
  let prof = 0;
189
- let cadena = '';
190
- for (let i = 0; i < hasta; i += 1) {
191
- const c = src[i];
192
- const previo = src[i - 1];
193
- if (cadena) {
194
- if (c === cadena && previo !== '\\') cadena = '';
195
- continue;
196
- }
197
- if (c === '"' || c === "'" || c === '`') {
198
- cadena = c;
199
- continue;
200
- }
276
+ for (const c of sinCadenas(src).slice(0, hasta)) {
201
277
  if (c === '{') prof += 1;
202
278
  else if (c === '}') prof -= 1;
203
279
  }