vanilla-builder 0.1.0 → 0.3.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/src/jsinfo.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Análisis estático de JavaScript con acorn: nivel de sintaxis, globales, workers y rutas.
2
2
  import * as acorn from 'acorn';
3
3
  import * as walk from 'acorn-walk';
4
+ import { L } from './i18n.js';
4
5
 
5
6
  const VERSIONS = [5, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026];
6
7
  const LATEST = 'latest';
@@ -124,7 +125,6 @@ export function analyzeJs(code, opts = {}) {
124
125
  const info = {
125
126
  ok: true,
126
127
  isModule,
127
- level: detectLevel(code, sourceType),
128
128
  strict,
129
129
  top: isModule ? new Map() : globalBindings(ast, strict),
130
130
  imports: [],
@@ -139,6 +139,12 @@ export function analyzeJs(code, opts = {}) {
139
139
  hasImportMeta: false,
140
140
  hasModuleSyntax: false,
141
141
  };
142
+ // El nivel ES cuesta varios parseos: solo se calcula si alguien lo pide (la mayoría de análisis no lo necesitan).
143
+ let level;
144
+ Object.defineProperty(info, 'level', {
145
+ enumerable: true,
146
+ get: () => (level === undefined ? (level = detectLevel(code, sourceType)) : level),
147
+ });
142
148
  const addWorker = (arg, kind, optsNode) => {
143
149
  if (!arg) return;
144
150
  let spec = null;
@@ -244,20 +250,89 @@ export function checkSyntax(code, isModule) {
244
250
  }
245
251
  }
246
252
 
253
+ // Nombres que esbuild crea al adaptar sintaxis a navegadores antiguos: temporales (_a, _b2) y ayudantes (__async).
254
+ const ESBUILD_TEMP = /^(_[a-z]\d*|__[A-Za-z]+\d*)$/;
255
+
256
+ /**
257
+ * Al transpilar un script clásico, esbuild puede declarar temporales en el ámbito global (var _a).
258
+ * Si TODAS las globales nuevas son de ese tipo, se renombran en todo el archivo a `${prefix}nombre`
259
+ * (único por archivo), así no pueden chocar con otros scripts. Devuelve el código nuevo, o null si no aplica.
260
+ * El renombrado es consistente en todos los ámbitos, así que no cambia el significado del código.
261
+ */
262
+ export function isolateTempGlobals(code, before, prefix) {
263
+ let ast;
264
+ try {
265
+ ast = parse(code, 'script', LATEST);
266
+ } catch {
267
+ return null;
268
+ }
269
+ const top = globalBindings(
270
+ ast,
271
+ ast.body.some((s) => s.directive === 'use strict'),
272
+ );
273
+ const fresh = [...top.keys()].filter((n) => !before.has(n));
274
+ if (!fresh.length || !fresh.every((n) => ESBUILD_TEMP.test(n))) return null;
275
+ const rename = new Map(fresh.map((n) => [n, `${prefix}${n}`]));
276
+ const edits = new Map();
277
+ walk.fullAncestor(ast, (node, _state, ancestors) => {
278
+ if (node.type !== 'Identifier' || !rename.has(node.name)) return;
279
+ const p = ancestors[ancestors.length - 2];
280
+ const to = rename.get(node.name);
281
+ if (p) {
282
+ if (p.type === 'MemberExpression' && p.property === node && !p.computed) return;
283
+ if (p.type === 'Property' && p.shorthand) {
284
+ // { _a } → { _a: nuevo }: la clave se conserva, el valor se renombra.
285
+ edits.set(node.start, { start: node.start, end: node.end, text: `${node.name}: ${to}` });
286
+ return;
287
+ }
288
+ if ((p.type === 'Property' || p.type === 'MethodDefinition' || p.type === 'PropertyDefinition') && p.key === node && !p.computed) return;
289
+ if (p.type === 'LabeledStatement' || p.type === 'BreakStatement' || p.type === 'ContinueStatement') return;
290
+ }
291
+ if (!edits.has(node.start)) edits.set(node.start, { start: node.start, end: node.end, text: to });
292
+ });
293
+ let out = code;
294
+ for (const e of [...edits.values()].sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end);
295
+ return out;
296
+ }
297
+
247
298
  /**
248
299
  * Compara las globales de un script antes y después de transformarlo.
249
300
  * `allowPrefix` permite nombres nuevos con ese prefijo (los que añade el ofuscador).
250
301
  */
251
302
  export function compareGlobals(before, afterCode, allowPrefix = null) {
252
- const after = analyzeJs(afterCode, { module: false });
253
- if (!after.ok) return { ok: false, reason: `el resultado no es JS válido (${after.error})` };
303
+ // Solo hacen falta las globales: un parseo y un recorrido, sin el análisis completo.
304
+ let ast;
305
+ try {
306
+ ast = parse(afterCode, 'script', LATEST);
307
+ } catch (err) {
308
+ return { ok: false, reason: L(`el resultado no es JS válido (${err.message})`, `the result is not valid JS (${err.message})`) };
309
+ }
310
+ const after = {
311
+ top: globalBindings(
312
+ ast,
313
+ ast.body.some((s) => s.directive === 'use strict'),
314
+ ),
315
+ };
254
316
  for (const [name, kind] of before) {
255
- if (!after.top.has(name)) return { ok: false, reason: `desaparece la global "${name}"` };
256
- if (after.top.get(name) !== kind) return { ok: false, reason: `la global "${name}" cambia de tipo (${kind} → ${after.top.get(name)})` };
317
+ if (!after.top.has(name)) return { ok: false, reason: L(`desaparece la global "${name}"`, `the global "${name}" disappears`) };
318
+ if (after.top.get(name) !== kind)
319
+ return {
320
+ ok: false,
321
+ reason: L(
322
+ `la global "${name}" cambia de tipo (${kind} → ${after.top.get(name)})`,
323
+ `the global "${name}" changes kind (${kind} → ${after.top.get(name)})`,
324
+ ),
325
+ };
257
326
  }
258
327
  for (const name of after.top.keys()) {
259
328
  if (!before.has(name) && !(allowPrefix && name.startsWith(allowPrefix)))
260
- return { ok: false, reason: `aparece una global nueva "${name}" que podría chocar con otros scripts` };
329
+ return {
330
+ ok: false,
331
+ reason: L(
332
+ `aparece una global nueva "${name}" que podría chocar con otros scripts`,
333
+ `a new global "${name}" appears that could clash with other scripts`,
334
+ ),
335
+ };
261
336
  }
262
337
  return { ok: true };
263
338
  }
package/src/obfuscate.js CHANGED
@@ -3,6 +3,7 @@ import * as acorn from 'acorn';
3
3
  import { shortHash } from './util.js';
4
4
  import { compareGlobals, moduleShape, checkSyntax } from './jsinfo.js';
5
5
  import { importOptional } from './optional.js';
6
+ import { L } from './i18n.js';
6
7
 
7
8
  // Nunca se activan solos: cambian el comportamiento o rompen código que comparte globales.
8
9
  const FORCED_SAFE = { renameGlobals: false, renameProperties: false, sourceMap: false, target: 'browser' };
@@ -101,7 +102,7 @@ let engine = null;
101
102
  /** Carga javascript-obfuscator (instalado junto a Builder o en el proyecto). */
102
103
  export async function ensureObfuscator(projectRoot) {
103
104
  if (!engine) {
104
- const mod = await importOptional('javascript-obfuscator', projectRoot, 'para ofuscar el JavaScript');
105
+ const mod = await importOptional('javascript-obfuscator', projectRoot, L('para ofuscar el JavaScript', 'to obfuscate JavaScript'));
105
106
  engine = mod.default ?? mod;
106
107
  }
107
108
  return engine;
@@ -143,15 +144,18 @@ export function obfuscateSafe(code, { isModule, options, id, globals }) {
143
144
  try {
144
145
  out = engine.obfuscate(body, opts).getObfuscatedCode();
145
146
  } catch (err) {
146
- return { code: null, reason: `el ofuscador falló (${String(err.message).split('\n')[0]})` };
147
+ return {
148
+ code: null,
149
+ reason: L(`el ofuscador falló (${String(err.message).split('\n')[0]})`, `the obfuscator failed (${String(err.message).split('\n')[0]})`),
150
+ };
147
151
  }
148
152
  if (isModule) {
149
153
  const err = checkSyntax(out, true);
150
- if (err) return { code: null, reason: `resultado inválido (${err})` };
154
+ if (err) return { code: null, reason: L(`resultado inválido (${err})`, `invalid result (${err})`) };
151
155
  const a = moduleShape(body);
152
156
  const b = moduleShape(out);
153
157
  if (!a || !b || a.exports !== b.exports || a.imports !== b.imports)
154
- return { code: null, reason: 'cambiaron las exportaciones o importaciones del módulo' };
158
+ return { code: null, reason: L('cambiaron las exportaciones o importaciones del módulo', 'the module exports or imports changed') };
155
159
  } else {
156
160
  const cmp = compareGlobals(globals, out, prefix);
157
161
  if (!cmp.ok) return { code: null, reason: cmp.reason };
package/src/optional.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { BuildError } from './util.js';
6
+ import { L } from './i18n.js';
6
7
 
7
8
  const cache = new Map();
8
9
 
@@ -25,9 +26,12 @@ export async function importOptional(name, projectRoot, purpose) {
25
26
  }
26
27
  }
27
28
  if (!mod)
28
- throw new BuildError(`Falta el paquete opcional "${name}" ${purpose}.`, [
29
- `Instálalo una vez con: npm install --save-dev ${name}`,
30
- 'Builder lo carga solo cuando hace falta, por eso no viene incluido (así la instalación base es ligera).',
29
+ throw new BuildError(L(`Falta el paquete opcional "${name}" ${purpose}.`, `Missing optional package "${name}" ${purpose}.`), [
30
+ L(`Instálalo una vez con: npm install --save-dev ${name}`, `Install it once with: npm install --save-dev ${name}`),
31
+ L(
32
+ 'Builder lo carga solo cuando hace falta, por eso no viene incluido (así la instalación base es ligera).',
33
+ 'Builder loads it only when needed, so it is not bundled (this keeps the base install small).',
34
+ ),
31
35
  ]);
32
36
  cache.set(name, mod);
33
37
  return mod;
package/src/output.js CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { BuildError, isInside, retryFs, copyDir } from './util.js';
6
+ import { L } from './i18n.js';
6
7
 
7
8
  export const MANIFEST = '.builder-manifest.json';
8
9
 
@@ -12,18 +13,40 @@ export function assertSafeOutDir(cfg, { force = false } = {}) {
12
13
  const root = path.resolve(cfg.root);
13
14
  const parsed = path.parse(out);
14
15
  const same = (a, b) => path.relative(a, b) === '';
15
- if (same(out, root)) throw new BuildError('"outDir" no puede ser la propia carpeta del proyecto: se borraría el código fuente.');
16
- if (isInside(out, root)) throw new BuildError(`"outDir" (${out}) contiene al proyecto; se borraría el código fuente.`);
17
- if (same(out, parsed.root)) throw new BuildError('"outDir" no puede ser la raíz de una unidad.');
18
- if (same(out, os.homedir())) throw new BuildError('"outDir" no puede ser tu carpeta de usuario.');
16
+ if (same(out, root))
17
+ throw new BuildError(
18
+ L(
19
+ '"outDir" no puede ser la propia carpeta del proyecto: se borraría el código fuente.',
20
+ '"outDir" cannot be the project folder itself: the source code would be deleted.',
21
+ ),
22
+ );
23
+ if (isInside(out, root))
24
+ throw new BuildError(
25
+ L(
26
+ `"outDir" (${out}) contiene al proyecto; se borraría el código fuente.`,
27
+ `"outDir" (${out}) contains the project; the source code would be deleted.`,
28
+ ),
29
+ );
30
+ if (same(out, parsed.root)) throw new BuildError(L('"outDir" no puede ser la raíz de una unidad.', '"outDir" cannot be the root of a drive.'));
31
+ if (same(out, os.homedir())) throw new BuildError(L('"outDir" no puede ser tu carpeta de usuario.', '"outDir" cannot be your home folder.'));
19
32
  if (!cfg.clean || force) return;
20
33
  if (fs.existsSync(out)) {
21
- if (!fs.statSync(out).isDirectory()) throw new BuildError(`"outDir" existe y no es una carpeta: ${out}`);
34
+ if (!fs.statSync(out).isDirectory())
35
+ throw new BuildError(L(`"outDir" existe y no es una carpeta: ${out}`, `"outDir" exists and is not a folder: ${out}`));
22
36
  const entries = fs.readdirSync(out);
23
37
  if (entries.length && !entries.includes(MANIFEST))
24
- throw new BuildError(`La carpeta de salida ${out} ya tiene archivos y no la creó Builder. No la borro para no perder datos.`, [
25
- 'Elige otra carpeta con --out, vacíala tú mismo o usa --force si estás seguro.',
26
- ]);
38
+ throw new BuildError(
39
+ L(
40
+ `La carpeta de salida ${out} ya tiene archivos y no la creó Builder. No la borro para no perder datos.`,
41
+ `The output folder ${out} already has files and was not created by Builder. Not deleting it, to avoid losing data.`,
42
+ ),
43
+ [
44
+ L(
45
+ 'Elige otra carpeta con --out, vacíala tú mismo o usa --force si estás seguro.',
46
+ 'Choose another folder with --out, empty it yourself, or use --force if you are sure.',
47
+ ),
48
+ ],
49
+ );
27
50
  }
28
51
  }
29
52
 
@@ -89,7 +112,12 @@ export async function swapIntoPlace(cfg, staging) {
89
112
  await rmrf(staging);
90
113
  } catch (copyErr) {
91
114
  if (exists) await retryFs(() => fs.promises.rename(old, out)).catch(() => {});
92
- throw new BuildError(`No pude colocar la salida en ${out}: ${copyErr.message ?? err.message}`);
115
+ throw new BuildError(
116
+ L(
117
+ `No pude colocar la salida en ${out}: ${copyErr.message ?? err.message}`,
118
+ `Could not place the output in ${out}: ${copyErr.message ?? err.message}`,
119
+ ),
120
+ );
93
121
  }
94
122
  }
95
123
  if (exists) await rmrf(old).catch(() => {});
package/src/plugins.js ADDED
@@ -0,0 +1,126 @@
1
+ // Plugins: ganchos sencillos para casos que Builder no cubre.
2
+ //
3
+ // export default {
4
+ // plugins: [
5
+ // {
6
+ // name: 'version',
7
+ // filter: /\.(html|js)$/, // opcional: a qué archivos se aplica transform
8
+ // transform: ({ path, code }) => code.replaceAll('__VERSION__', '1.2.3'),
9
+ // buildStart: ({ config, mode }) => {},
10
+ // buildEnd: ({ result, outDir }) => {},
11
+ // },
12
+ // ],
13
+ // };
14
+ //
15
+ // En builder.config.json se nombran paquetes: "plugins": ["mi-plugin", ["otro-plugin", { "opcion": 1 }]].
16
+ // Un paquete exporta el objeto plugin o una función (opciones) => plugin.
17
+ //
18
+ // transform cambia el CÓDIGO FUENTE (HTML, CSS, JS, TS) antes de procesarlo: así el hash, el SRI
19
+ // y la verificación final se calculan sobre el resultado del plugin.
20
+ import path from 'node:path';
21
+ import { createRequire } from 'node:module';
22
+ import { pathToFileURL } from 'node:url';
23
+ import { BuildError } from './util.js';
24
+ import { L } from './i18n.js';
25
+
26
+ export const TRANSFORM_EXT = new Set(['.html', '.htm', '.css', '.js', '.mjs', '.cjs', '.ts', '.mts', '.cts']);
27
+ const HOOKS = ['transform', 'buildStart', 'buildEnd'];
28
+
29
+ /** Errores de validación de la opción "plugins" (antes de cargar paquetes). */
30
+ export function validatePlugins(list) {
31
+ if (!Array.isArray(list)) return [L('"plugins" debe ser una lista.', '"plugins" must be a list.')];
32
+ const errors = [];
33
+ list.forEach((p, i) => {
34
+ const ok =
35
+ typeof p === 'string' ||
36
+ (Array.isArray(p) && typeof p[0] === 'string' && p.length <= 2) ||
37
+ (p && typeof p === 'object' && !Array.isArray(p) && typeof p.name === 'string');
38
+ if (!ok)
39
+ errors.push(
40
+ L(
41
+ `"plugins[${i}]" debe ser un nombre de paquete, [nombre, opciones] o un objeto con "name".`,
42
+ `"plugins[${i}]" must be a package name, [name, options] or an object with "name".`,
43
+ ),
44
+ );
45
+ else if (p && typeof p === 'object' && !Array.isArray(p)) {
46
+ for (const k of Object.keys(p))
47
+ if (!['name', 'filter', ...HOOKS].includes(k))
48
+ errors.push(
49
+ L(
50
+ `"plugins[${i}].${k}" no es un gancho conocido (${HOOKS.join(', ')}).`,
51
+ `"plugins[${i}].${k}" is not a known hook (${HOOKS.join(', ')}).`,
52
+ ),
53
+ );
54
+ }
55
+ });
56
+ return errors;
57
+ }
58
+
59
+ /** Carga los plugins nombrados como paquete (desde el proyecto) y devuelve objetos plugin. */
60
+ export async function loadPlugins(list, root) {
61
+ const out = [];
62
+ for (const entry of list) {
63
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
64
+ out.push(entry);
65
+ continue;
66
+ }
67
+ const [name, options] = Array.isArray(entry) ? entry : [entry, undefined];
68
+ let mod;
69
+ try {
70
+ const req = createRequire(path.join(root, 'package.json'));
71
+ mod = await import(pathToFileURL(req.resolve(name)).href);
72
+ } catch (err) {
73
+ throw new BuildError(L(`No se pudo cargar el plugin "${name}": ${err.message}`, `Could not load the plugin "${name}": ${err.message}`), [
74
+ L(`Instálalo en el proyecto: npm install --save-dev ${name}`, `Install it in the project: npm install --save-dev ${name}`),
75
+ ]);
76
+ }
77
+ const exported = mod.default ?? mod;
78
+ const plugin = typeof exported === 'function' ? await exported(options ?? {}) : exported;
79
+ if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string')
80
+ throw new BuildError(
81
+ L(
82
+ `El plugin "${name}" debe exportar un objeto con "name" o una función que lo devuelva.`,
83
+ `The plugin "${name}" must export an object with "name" or a function that returns one.`,
84
+ ),
85
+ );
86
+ out.push(plugin);
87
+ }
88
+ return out;
89
+ }
90
+
91
+ async function call(plugin, hook, arg, where = '') {
92
+ try {
93
+ return await plugin[hook](arg);
94
+ } catch (err) {
95
+ throw new BuildError(
96
+ L(
97
+ `El plugin "${plugin.name}" falló en ${hook}${where}: ${err.message}`,
98
+ `The plugin "${plugin.name}" failed in ${hook}${where}: ${err.message}`,
99
+ ),
100
+ );
101
+ }
102
+ }
103
+
104
+ /** Ejecuta un gancho sin valor de retorno (buildStart, buildEnd) en todos los plugins, en orden. */
105
+ export async function runHook(plugins, hook, arg) {
106
+ for (const p of plugins) if (typeof p[hook] === 'function') await call(p, hook, arg);
107
+ }
108
+
109
+ /** Pasa el código de un archivo por los transform de todos los plugins, en orden. */
110
+ export async function runTransform(plugins, rel, code) {
111
+ let current = code;
112
+ for (const p of plugins) {
113
+ if (typeof p.transform !== 'function') continue;
114
+ if (p.filter instanceof RegExp && !p.filter.test(rel)) continue;
115
+ const r = await call(p, 'transform', { path: rel, code: current }, ` (${rel})`);
116
+ if (typeof r === 'string') current = r;
117
+ else if (r != null)
118
+ throw new BuildError(
119
+ L(
120
+ `El plugin "${p.name}" devolvió algo que no es texto en transform (${rel}).`,
121
+ `The plugin "${p.name}" returned something that is not text from transform (${rel}).`,
122
+ ),
123
+ );
124
+ }
125
+ return current;
126
+ }