vanilla-builder 0.0.0-stage → 0.1.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/html.js ADDED
@@ -0,0 +1,446 @@
1
+ // HTML: extrae referencias con su posición exacta, aplica ediciones puntuales y minifica con verificación.
2
+ import { parse } from 'parse5';
3
+
4
+ const JS_TYPES = new Set([
5
+ '',
6
+ 'text/javascript',
7
+ 'application/javascript',
8
+ 'text/ecmascript',
9
+ 'application/ecmascript',
10
+ 'application/x-javascript',
11
+ 'application/x-ecmascript',
12
+ 'text/x-javascript',
13
+ 'text/x-ecmascript',
14
+ 'text/jscript',
15
+ 'text/livescript',
16
+ 'text/javascript1.0',
17
+ 'text/javascript1.1',
18
+ 'text/javascript1.2',
19
+ 'text/javascript1.3',
20
+ 'text/javascript1.4',
21
+ 'text/javascript1.5',
22
+ ]);
23
+ const JSON_TYPES = new Set(['application/json', 'application/ld+json', 'speculationrules']);
24
+
25
+ export function scriptKind(typeAttr) {
26
+ const t = (typeAttr ?? '').trim().toLowerCase().split(';')[0].trim();
27
+ if (JS_TYPES.has(t)) return 'classic';
28
+ if (t === 'module') return 'module';
29
+ if (t === 'importmap') return 'importmap';
30
+ if (JSON_TYPES.has(t)) return 'json';
31
+ return 'other';
32
+ }
33
+
34
+ // Atributos con URLs a recursos. "page": puede ser otra página; "soft": no rompe la página si falta.
35
+ const ELEMENT_ATTRS = {
36
+ img: [['src'], ['srcset', 'srcset']],
37
+ source: [['src'], ['srcset', 'srcset']],
38
+ video: [['src'], ['poster']],
39
+ audio: [['src']],
40
+ track: [['src']],
41
+ embed: [['src']],
42
+ object: [['data']],
43
+ input: [['src']],
44
+ iframe: [['src', 'page']],
45
+ frame: [['src', 'page']],
46
+ a: [['href', 'soft-page']],
47
+ area: [['href', 'soft-page']],
48
+ image: [['href'], ['xlink:href']],
49
+ use: [['href'], ['xlink:href']],
50
+ feimage: [['href'], ['xlink:href']],
51
+ body: [['background']],
52
+ table: [['background']],
53
+ td: [['background']],
54
+ th: [['background']],
55
+ html: [['manifest']],
56
+ };
57
+ const SOFT_DATA_ATTRS = new Set(['data-src', 'data-srcset', 'data-bg', 'data-background', 'data-background-image', 'data-poster', 'data-href']);
58
+ const META_IMAGE =
59
+ /^(og:(image|video|audio)(:url|:secure_url)?|twitter:(image|player)(:src)?|msapplication-(tileimage|config|square\d+x\d+logo|wide\d+x\d+logo))$/i;
60
+ const LINK_ASSET_RELS = new Set([
61
+ 'icon',
62
+ 'shortcut',
63
+ 'apple-touch-icon',
64
+ 'apple-touch-icon-precomposed',
65
+ 'apple-touch-startup-image',
66
+ 'mask-icon',
67
+ 'manifest',
68
+ 'image_src',
69
+ 'fluid-icon',
70
+ ]);
71
+ const LINK_PAGE_RELS = new Set(['canonical', 'alternate', 'next', 'prev', 'prerender', 'help', 'license', 'author']);
72
+
73
+ const attrName = (a) => (a.prefix ? `${a.prefix}:${a.name}` : a.name);
74
+
75
+ function getAttrs(el) {
76
+ const map = new Map();
77
+ for (const a of el.attrs ?? []) {
78
+ const name = attrName(a);
79
+ if (!map.has(name)) map.set(name, a.value);
80
+ }
81
+ return map;
82
+ }
83
+
84
+ function attrLoc(el, name) {
85
+ const l = el.sourceCodeLocation?.attrs?.[name];
86
+ return l ? { start: l.startOffset, end: l.endOffset } : null;
87
+ }
88
+
89
+ const hasLoc = (el) => !!el?.sourceCodeLocation?.startTag;
90
+
91
+ /**
92
+ * Analiza un HTML y devuelve sus elementos relevantes en orden de documento.
93
+ */
94
+ export function parseHtml(text) {
95
+ const doc = parse(text, { sourceCodeLocationInfo: true, scriptingEnabled: false });
96
+ const htmlEl = doc.childNodes.find((n) => n.nodeName === 'html');
97
+ const head = htmlEl?.childNodes.find((n) => n.nodeName === 'head');
98
+ const body = htmlEl?.childNodes.find((n) => n.nodeName === 'body');
99
+ const isDocument = doc.childNodes.some((n) => n.nodeName === '#documentType') || hasLoc(htmlEl) || hasLoc(head) || hasLoc(body);
100
+ const model = { isDocument, base: null, refs: [], scripts: [], styles: [], links: [], styleAttrs: [], importMaps: [] };
101
+
102
+ const visit = (node, parent, index, inTemplate) => {
103
+ if (node.nodeName === '#text' || node.nodeName === '#comment' || node.nodeName === '#documentType') return;
104
+ const tag = node.tagName?.toLowerCase();
105
+ if (tag) handleElement(node, tag, parent, index, inTemplate);
106
+ const kids = node.nodeName === 'template' && node.content ? node.content.childNodes : (node.childNodes ?? []);
107
+ const childInTemplate = inTemplate || node.nodeName === 'template';
108
+ for (let i = 0; i < kids.length; i++) visit(kids[i], node.nodeName === 'template' ? node.content : node, i, childInTemplate);
109
+ };
110
+
111
+ const pushRef = (el, name, value, kind, extra = {}) => {
112
+ const loc = attrLoc(el, name);
113
+ if (!loc) return;
114
+ model.refs.push({ el, attr: name, value, kind, ...loc, ...extra });
115
+ };
116
+
117
+ function handleElement(el, tag, parent, index, inTemplate) {
118
+ const loc = el.sourceCodeLocation;
119
+ if (!loc) return;
120
+ const attrs = getAttrs(el);
121
+ if (attrs.has('style') && attrs.get('style').trim()) {
122
+ const l = attrLoc(el, 'style');
123
+ if (l) model.styleAttrs.push({ el, value: attrs.get('style'), ...l });
124
+ }
125
+ for (const [name, value] of attrs) {
126
+ if (SOFT_DATA_ATTRS.has(name)) pushRef(el, name, value, name.endsWith('srcset') ? 'srcset' : 'asset', { soft: true });
127
+ }
128
+ if (tag === 'base' && attrs.has('href') && !model.base && !inTemplate) {
129
+ model.base = attrs.get('href');
130
+ return;
131
+ }
132
+ if (tag === 'script') {
133
+ const content =
134
+ loc.startTag && loc.endTag
135
+ ? { start: loc.startTag.endOffset, end: loc.endTag.startOffset, code: text.slice(loc.startTag.endOffset, loc.endTag.startOffset) }
136
+ : null;
137
+ const kind = scriptKind(attrs.get('type'));
138
+ const item = { el, attrs, kind, parent, index, inTemplate, start: loc.startOffset, end: loc.endOffset, src: null, inline: null };
139
+ if (attrs.has('src')) item.src = { value: attrs.get('src'), ...attrLoc(el, 'src') };
140
+ else if (content) item.inline = content;
141
+ if (kind === 'importmap' && item.inline) model.importMaps.push(item);
142
+ model.scripts.push(item);
143
+ return;
144
+ }
145
+ if (tag === 'style') {
146
+ const t = (attrs.get('type') ?? '').trim().toLowerCase();
147
+ if ((t === '' || t === 'text/css') && loc.startTag && loc.endTag)
148
+ model.styles.push({
149
+ el,
150
+ attrs,
151
+ start: loc.startTag.endOffset,
152
+ end: loc.endTag.startOffset,
153
+ code: text.slice(loc.startTag.endOffset, loc.endTag.startOffset),
154
+ });
155
+ return;
156
+ }
157
+ if (tag === 'link') {
158
+ const rels = (attrs.get('rel') ?? '').toLowerCase().split(/\s+/).filter(Boolean);
159
+ if (!attrs.has('href')) return;
160
+ const href = { value: attrs.get('href'), ...attrLoc(el, 'href') };
161
+ const item = { el, attrs, rels, href, start: loc.startOffset, end: loc.endOffset, inTemplate };
162
+ if (rels.includes('stylesheet')) item.type = 'stylesheet';
163
+ else if (rels.some((r) => r === 'modulepreload' || r === 'preload' || r === 'prefetch')) item.type = 'preload';
164
+ else if (rels.some((r) => LINK_ASSET_RELS.has(r))) item.type = 'asset';
165
+ else if (rels.some((r) => LINK_PAGE_RELS.has(r))) item.type = 'page';
166
+ else item.type = 'other';
167
+ model.links.push(item);
168
+ if (attrs.has('imagesrcset')) pushRef(el, 'imagesrcset', attrs.get('imagesrcset'), 'srcset', { soft: true });
169
+ return;
170
+ }
171
+ if (tag === 'meta') {
172
+ const key = attrs.get('property') ?? attrs.get('name') ?? '';
173
+ if (META_IMAGE.test(key) && attrs.has('content')) pushRef(el, 'content', attrs.get('content'), 'asset', { soft: true });
174
+ const equiv = (attrs.get('http-equiv') ?? '').toLowerCase();
175
+ if (equiv === 'refresh' && attrs.has('content')) {
176
+ const m = /url\s*=\s*['"]?([^'"]+)/i.exec(attrs.get('content'));
177
+ if (m) model.refs.push({ el, attr: 'content', value: m[1].trim(), kind: 'page', soft: true, ...attrLoc(el, 'content') });
178
+ }
179
+ return;
180
+ }
181
+ for (const [name, mode] of ELEMENT_ATTRS[tag] ?? []) {
182
+ if (!attrs.has(name)) continue;
183
+ const value = attrs.get(name);
184
+ if (mode === 'srcset') pushRef(el, name, value, 'srcset');
185
+ else if (mode === 'page') pushRef(el, name, value, 'page');
186
+ else if (mode === 'soft-page') pushRef(el, name, value, 'page', { soft: true });
187
+ else pushRef(el, name, value, 'asset');
188
+ }
189
+ }
190
+
191
+ for (let i = 0; i < doc.childNodes.length; i++) visit(doc.childNodes[i], doc, i, false);
192
+ return model;
193
+ }
194
+
195
+ /** ¿Entre dos hermanos solo hay espacios en blanco o comentarios? */
196
+ export function adjacentSiblings(a, b) {
197
+ if (!a || !b || a.parent !== b.parent || a.index >= b.index) return false;
198
+ const kids = a.parent.childNodes ?? [];
199
+ for (let i = a.index + 1; i < b.index; i++) {
200
+ const n = kids[i];
201
+ if (n.nodeName === '#comment') continue;
202
+ if (n.nodeName === '#text' && !n.value.trim()) continue;
203
+ return false;
204
+ }
205
+ return true;
206
+ }
207
+
208
+ export const escapeAttr = (v) => v.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
209
+
210
+ export const attrText = (name, value) => (value == null ? '' : `${name}="${escapeAttr(value)}"`);
211
+
212
+ /** Aplica ediciones {start, end, text} sobre el texto original. */
213
+ export function applyEdits(text, edits) {
214
+ const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
215
+ let out = text;
216
+ let limit = Infinity;
217
+ for (const e of sorted) {
218
+ if (e.end > limit) throw new Error(`Ediciones superpuestas en la posición ${e.start}`);
219
+ out = out.slice(0, e.start) + e.text + out.slice(e.end);
220
+ limit = e.start;
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /** Evita que el contenido de un <script> inline cierre la etiqueta antes de tiempo. */
226
+ export function escapeInlineScript(code) {
227
+ return code.replace(/<\/(script)/gi, '<\\/$1').replace(/<!--/g, '<\\!--');
228
+ }
229
+
230
+ export function escapeInlineStyle(code) {
231
+ return code.replace(/<\/(style)/gi, '<\\/$1');
232
+ }
233
+
234
+ const WS = /^[ \t\n\r\f]+|[ \t\n\r\f]+$/g;
235
+ const INVISIBLE = new Set(['script', 'style', 'link', 'meta']);
236
+ const BOOLEAN_ATTRS = new Set(
237
+ 'allowfullscreen async autofocus autoplay checked compact controls declare default defaultchecked defaultmuted defaultselected defer disabled enabled formnovalidate hidden indeterminate inert ismap itemscope loop multiple muted nohref noresize noshade novalidate nowrap open pauseonexit readonly required reversed scoped seamless selected sortable truespeed typemustmatch visible'.split(
238
+ ' ',
239
+ ),
240
+ );
241
+
242
+ /** Normaliza solo las diferencias de atributos que el estándar considera equivalentes. */
243
+ function normalizeAttr(tag, attrs, name, value) {
244
+ let v = value.replace(WS, '');
245
+ // Atributo booleano: "" y su propio nombre son los dos valores válidos y equivalentes.
246
+ if (BOOLEAN_ATTRS.has(name) && (v === '' || v.toLowerCase() === name)) return '';
247
+ if (name === 'class') return v.split(/[ \t\n\r\f]+/).join(' ');
248
+ if (name.startsWith('on')) return v.replace(/^javascript:\s*/i, '');
249
+ if (name === 'style') return v.replace(/\s*;$/, ';');
250
+ if (name === 'srcset' || name === 'imagesrcset') {
251
+ return v
252
+ .split(/\s+,\s*|\s*,\s+/)
253
+ .map((c) => {
254
+ const m = c.match(/\s+([1-9][0-9]*w|[0-9]+(?:\.[0-9]+)?x)$/);
255
+ if (!m) return c;
256
+ const num = +m[1].slice(0, -1);
257
+ const suffix = m[1].slice(-1);
258
+ return c.slice(0, -m[0].length) + (num !== 1 || suffix !== 'x' ? ` ${num}${suffix}` : '');
259
+ })
260
+ .join(', ');
261
+ }
262
+ if (tag === 'meta' && name === 'content') {
263
+ const get = (n) => attrs.find((a) => a.name === n)?.value?.toLowerCase();
264
+ if (get('name') === 'viewport') return v.replace(/\s+/g, '').replace(/[0-9]+\.[0-9]+/g, (n) => String(+n));
265
+ if (get('http-equiv') === 'content-security-policy') return v.replace(/\s+/g, ' ');
266
+ }
267
+ if (tag === 'script' && name === 'type') v = v.replace(/\s*;\s*/g, ';');
268
+ return v;
269
+ }
270
+
271
+ /** Firma estructural: elementos + atributos + texto normalizado. Sirve para comprobar que minificar no perdió nada. */
272
+ export function htmlSignature(text) {
273
+ const doc = parse(text, { scriptingEnabled: false });
274
+ const parts = [];
275
+ let textBuf = '';
276
+ // El texto se acumula a través de comentarios (al quitarlos se unen los textos vecinos).
277
+ const flush = (parentTag, raw) => {
278
+ if (!textBuf) return;
279
+ if (raw) parts.push(`#${textBuf}`);
280
+ else {
281
+ const t = textBuf.replace(/[ \t\n\r\f]+/g, ' ');
282
+ // Espacios entre elementos de <head>/<html> no se muestran.
283
+ if (t.trim() || !(parentTag === 'head' || parentTag === 'html' || parentTag === '#document')) parts.push(`#${t}`);
284
+ }
285
+ textBuf = '';
286
+ };
287
+ const visit = (n, raw, parentTag) => {
288
+ if (n.nodeName === '#text') {
289
+ textBuf += n.value;
290
+ return;
291
+ }
292
+ if (n.nodeName === '#comment' || n.nodeName === '#documentType') return;
293
+ const tag = n.tagName;
294
+ const attrs = n.attrs ?? [];
295
+ const open = `<${tag}${attrs.map((a) => ` ${attrName(a)}=${JSON.stringify(normalizeAttr(tag, attrs, attrName(a), a.value))}`).join('')}>`;
296
+ if (!raw && INVISIBLE.has(tag)) {
297
+ // No se muestran: los espacios de ambos lados se funden en uno, igual que en el navegador.
298
+ parts.push(`${open}${(n.childNodes ?? []).map((k) => k.value ?? '').join('')}</${tag}>`);
299
+ return;
300
+ }
301
+ flush(parentTag, raw);
302
+ parts.push(open);
303
+ const kids = tag === 'template' && n.content ? n.content.childNodes : (n.childNodes ?? []);
304
+ const keepRaw = raw || tag === 'pre' || tag === 'textarea' || tag === 'script' || tag === 'style' || tag === 'xmp' || tag === 'plaintext';
305
+ for (const k of kids) visit(k, keepRaw, tag);
306
+ flush(tag, keepRaw);
307
+ parts.push(`</${tag}>`);
308
+ };
309
+ for (const n of doc.childNodes) visit(n, false, '#document');
310
+ flush('#document', false);
311
+ return parts.join('');
312
+ }
313
+
314
+ export function detectPreWhitespace(cssText) {
315
+ return /white-space\s*:\s*(pre|pre-wrap|pre-line|break-spaces)\b|white-space-collapse\s*:\s*(preserve|break-spaces)/i.test(cssText);
316
+ }
317
+
318
+ /**
319
+ * Minifica HTML de forma conservadora y verifica que el resultado tenga exactamente
320
+ * los mismos elementos, atributos y texto visible. Si no, devuelve el original.
321
+ */
322
+ export async function minifyHtml(text, { removeComments, collapseWhitespace }) {
323
+ let out;
324
+ try {
325
+ out = applyEdits(text, minifyEdits(text, { removeComments, collapseWhitespace }));
326
+ } catch (err) {
327
+ return { code: text, note: `no se pudo minificar el HTML (${err.message})` };
328
+ }
329
+ const before = htmlSignature(text);
330
+ const after = htmlSignature(out);
331
+ if (before !== after) {
332
+ let i = 0;
333
+ while (i < before.length && before[i] === after[i]) i++;
334
+ const near = before.slice(Math.max(0, i - 40), i + 40).replace(/\s+/g, ' ');
335
+ return { code: text, note: `la minificación cambiaba la estructura cerca de «${near}»; se deja sin minificar` };
336
+ }
337
+ return { code: out };
338
+ }
339
+
340
+ // Comentarios con significado: importantes (<!--!), SSI, condicionales de IE, Knockout, ESI...
341
+ const KEEP_COMMENT = /^(?:!|\s*#|\[if\s|<!\[endif\]|\[endif\]|\s*\/?ko\b|\s*esi\b|\s*google_ad_section|\s*(?:build|endbuild)\b)/i;
342
+ // Su contenido se muestra (o se interpreta) tal cual: nunca se tocan sus espacios.
343
+ const RAW_PARENTS = new Set(['pre', 'textarea', 'script', 'style', 'xmp', 'plaintext', 'listing', 'iframe', 'noembed', 'noframes']);
344
+ const HTML_NS = 'http://www.w3.org/1999/xhtml';
345
+ const UNQUOTED_OK = /^[^ \t\n\f\r"'`=<>]+$/;
346
+ const ATTR_RE = /^([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))$/s;
347
+
348
+ /** Calcula las ediciones de minificación sobre el texto original (posiciones de parse5). */
349
+ function minifyEdits(text, { removeComments, collapseWhitespace }) {
350
+ const doc = parse(text, { sourceCodeLocationInfo: true, scriptingEnabled: false });
351
+ const edits = [];
352
+
353
+ const rebuildStartTag = (el) => {
354
+ const loc = el.sourceCodeLocation;
355
+ const st = loc.startTag;
356
+ const raw = text.slice(st.startOffset, st.endOffset);
357
+ const nameRaw = raw.slice(1).match(/^[^\s/>]+/)?.[0];
358
+ if (!nameRaw) return;
359
+ const attrLocs = Object.values(st.attrs ?? {}).sort((a, b) => a.startOffset - b.startOffset);
360
+ // Si el texto de la etiqueta tiene algo que parse5 no registró (atributos duplicados), no se toca.
361
+ let check = st.startOffset + 1 + nameRaw.length;
362
+ for (const a of attrLocs) {
363
+ if (text.slice(check, a.startOffset).trim()) return;
364
+ check = a.endOffset;
365
+ }
366
+ const tail = text.slice(check, st.endOffset).replace(/\s+/g, '');
367
+ if (tail !== '>' && tail !== '/>') return;
368
+ const isHtml = el.namespaceURI === HTML_NS;
369
+ let out = `<${nameRaw}`;
370
+ let lastUnquoted = false;
371
+ for (const a of attrLocs) {
372
+ const src = text.slice(a.startOffset, a.endOffset);
373
+ const m = ATTR_RE.exec(src);
374
+ lastUnquoted = false;
375
+ if (!m) {
376
+ out += ` ${src}`; // atributo sin valor
377
+ continue;
378
+ }
379
+ const [, name, dq, sq, uq] = m;
380
+ const value = dq ?? sq ?? uq;
381
+ const lower = name.toLowerCase();
382
+ if (isHtml && BOOLEAN_ATTRS.has(lower) && (value === '' || value.toLowerCase() === lower)) out += ` ${name}`;
383
+ else if (value !== '' && UNQUOTED_OK.test(value)) {
384
+ out += ` ${name}=${value}`;
385
+ lastUnquoted = true;
386
+ } else out += ` ${name}=${dq != null || uq != null ? `"${value}"` : `'${value}'`}`;
387
+ }
388
+ out += tail === '/>' ? (lastUnquoted ? ' />' : '/>') : '>';
389
+ if (out !== raw) edits.push({ start: st.startOffset, end: st.endOffset, text: out });
390
+ const et = loc.endTag;
391
+ if (et) {
392
+ const end = text.slice(et.startOffset, et.endOffset);
393
+ const m = /^<\/([^\s>]+)\s*>$/.exec(end);
394
+ if (m && end !== `</${m[1]}>`) edits.push({ start: et.startOffset, end: et.endOffset, text: `</${m[1]}>` });
395
+ }
396
+ };
397
+
398
+ const visit = (node, raw) => {
399
+ const kids = node.nodeName === 'template' && node.content ? node.content.childNodes : (node.childNodes ?? []);
400
+ const parentTag = node.nodeName;
401
+ // ¿Lo último que queda en la salida termina en espacio? (los comentarios quitados no cuentan)
402
+ let prevSpace = false;
403
+ for (const k of kids) {
404
+ const loc = k.sourceCodeLocation;
405
+ if (k.nodeName === '#comment') {
406
+ if (loc && removeComments && !KEEP_COMMENT.test(k.data)) {
407
+ edits.push({ start: loc.startOffset, end: loc.endOffset, text: '' });
408
+ continue;
409
+ }
410
+ prevSpace = false;
411
+ continue;
412
+ }
413
+ if (k.nodeName === '#text') {
414
+ if (!loc || raw || !collapseWhitespace) {
415
+ prevSpace = false;
416
+ continue;
417
+ }
418
+ const src = text.slice(loc.startOffset, loc.endOffset);
419
+ let t = src.replace(/[ \t\n\r\f]+/g, ' ');
420
+ if (!t.trim() && (parentTag === 'html' || parentTag === 'head' || parentTag === '#document')) t = '';
421
+ else if (prevSpace && t.startsWith(' ')) t = t.slice(1);
422
+ if (t !== src) edits.push({ start: loc.startOffset, end: loc.endOffset, text: t });
423
+ prevSpace = t ? t.endsWith(' ') : prevSpace;
424
+ continue;
425
+ }
426
+ prevSpace = false;
427
+ if (k.nodeName === '#documentType' || !k.tagName) continue;
428
+ if (loc?.startTag) rebuildStartTag(k);
429
+ visit(k, raw || RAW_PARENTS.has(k.tagName));
430
+ }
431
+ };
432
+ visit(doc, false);
433
+ // Espacios antes de <html> y entre <html> y <head>: el parser los descarta (no son nodos).
434
+ if (collapseWhitespace) {
435
+ const dt = doc.childNodes.find((n) => n.nodeName === '#documentType')?.sourceCodeLocation;
436
+ const html = doc.childNodes.find((n) => n.nodeName === 'html');
437
+ const head = html?.childNodes.find((n) => n.nodeName === 'head');
438
+ const gap = (start, end) => {
439
+ if (start != null && end != null && end > start && !text.slice(start, end).trim()) edits.push({ start, end, text: '' });
440
+ };
441
+ if (dt && html?.sourceCodeLocation?.startTag) gap(dt.endOffset, html.sourceCodeLocation.startTag.startOffset);
442
+ if (html?.sourceCodeLocation?.startTag && head?.sourceCodeLocation?.startTag)
443
+ gap(html.sourceCodeLocation.startTag.endOffset, head.sourceCodeLocation.startTag.startOffset);
444
+ }
445
+ return edits;
446
+ }
package/src/index.js ADDED
@@ -0,0 +1,22 @@
1
+ // API programática.
2
+ import { loadConfig } from './config.js';
3
+ import { runPipeline, VERSION } from './builder.js';
4
+
5
+ /**
6
+ * Compila un proyecto.
7
+ * @param {{dir?: string, config?: string, overrides?: object, force?: boolean, keepFailed?: boolean, log?: Function}} [opts]
8
+ */
9
+ export async function build(opts = {}) {
10
+ const cfg = await loadConfig(opts);
11
+ return runPipeline(cfg, { mode: 'build', force: opts.force, keepFailed: opts.keepFailed, log: opts.log });
12
+ }
13
+
14
+ /** Calcula el plan (dependencias, salida prevista, avisos) sin escribir nada. */
15
+ export async function analyze(opts = {}) {
16
+ const cfg = await loadConfig(opts);
17
+ return runPipeline(cfg, { mode: 'analyze', log: opts.log });
18
+ }
19
+
20
+ export { loadConfig, VERSION };
21
+ export { BuildError } from './util.js';
22
+ export { startServer } from './server.js';