@danieltmn/tamnora 0.24.2 → 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.
- package/CHANGELOG.md +14 -0
- package/harness/packages/server/public/app.js +831 -834
- package/harness/packages/server/public/appearance.js +4 -0
- package/harness/packages/server/public/boot.js +49 -0
- package/harness/packages/server/public/chrome.js +853 -852
- package/harness/packages/server/public/commands.js +448 -405
- package/harness/packages/server/public/core.js +229 -216
- package/harness/packages/server/public/favicon.ico +0 -0
- package/harness/packages/server/public/icons/icon-128x128.png +0 -0
- package/harness/packages/server/public/icons/icon-144x144.png +0 -0
- package/harness/packages/server/public/icons/icon-152x152.png +0 -0
- package/harness/packages/server/public/icons/icon-192x192.png +0 -0
- package/harness/packages/server/public/icons/icon-384x384.png +0 -0
- package/harness/packages/server/public/icons/icon-512x512.png +0 -0
- package/harness/packages/server/public/icons/icon-72x72.png +0 -0
- package/harness/packages/server/public/icons/icon-96x96.png +0 -0
- package/harness/packages/server/public/icons/logo.png +0 -0
- package/harness/packages/server/public/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/icons/tamnora.ico +0 -0
- package/harness/packages/server/public/index.html +72 -101
- package/harness/packages/server/public/landing/boot.js +16 -0
- package/harness/packages/server/public/landing/index.html +123 -59
- package/harness/packages/server/public/landing/landing.js +11 -9
- package/harness/packages/server/public/logo.png +0 -0
- package/harness/packages/server/public/markdown.js +1 -1
- package/harness/packages/server/public/settings.js +1155 -1080
- package/harness/packages/server/public/settingsNav.js +81 -0
- package/harness/packages/server/public/styles.css +2807 -2525
- package/harness/packages/server/public/system.js +2 -2
- package/harness/packages/server/src/lib/appearance.js +4 -0
- package/harness/packages/server/src/lib/restart.js +20 -0
- package/harness/packages/server/src/lib/securityHeaders.js +53 -0
- package/harness/packages/server/src/routes/reload.js +46 -0
- package/harness/packages/server/src/server.js +6 -1
- package/harness/packages/server/test/accessibility.test.js +72 -0
- package/harness/packages/server/test/appearance.test.js +4 -0
- package/harness/packages/server/test/consoleModules.test.js +133 -57
- package/harness/packages/server/test/consoleTabs.test.js +228 -0
- package/harness/packages/server/test/docsSync.test.js +73 -0
- package/harness/packages/server/test/landingLogin.test.js +89 -0
- package/harness/packages/server/test/markdownSecurity.test.js +80 -0
- package/harness/packages/server/test/reload.test.js +95 -0
- package/harness/packages/server/test/securityHeaders.test.js +109 -0
- package/harness/packages/server/test/settingsNav.test.js +90 -0
- package/harness/packages/server/test/themeCss.js +110 -0
- package/harness/packages/server/test/themes.test.js +72 -0
- package/harness/scripts/restart.mjs +101 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { livePermission, reloadPane, renderSessions, resolvePermissionCard, sendPresence } from './chat.js';
|
|
2
2
|
import { providerSelect, setSummaryText, yesInput } from './chrome.js';
|
|
3
3
|
import { toast } from './commands.js';
|
|
4
|
-
import { $, api, clear, el, openStream, showConfirm, state, storage, streamPost } from './core.js';
|
|
4
|
+
import { $, api, clear, el, emptyState, openStream, showConfirm, state, storage, streamPost } from './core.js';
|
|
5
5
|
import { diffState, localState, pollDelayMs } from './reconcile.js';
|
|
6
6
|
import { followRun } from './runStream.js';
|
|
7
7
|
import { closeTab, renderTabs, showStandalonePermission } from './sessions.js';
|
|
@@ -116,7 +116,7 @@ export async function refreshRuns() {
|
|
|
116
116
|
});
|
|
117
117
|
runsList.appendChild(li);
|
|
118
118
|
}
|
|
119
|
-
if (!data.runs.length) runsList.appendChild(
|
|
119
|
+
if (!data.runs.length) runsList.appendChild(emptyState('Sin runs todavía', 'Las evaluaciones que corras desde la pestaña eval quedan guardadas acá.'));
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
export async function openRun(id) {
|
|
@@ -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 (
|
|
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.
|
|
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' : ''}${
|
|
73
|
-
for (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|