cssgrep 1.2.0 → 1.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.
@@ -5,6 +5,15 @@
5
5
 
6
6
  _cssgrep() {
7
7
  _arguments -s -S \
8
+ '*'{-e,--selector}'[add a labeled selector (repeatable)]:selector:' \
9
+ '*--add-class[rewrite: add a class to matched elements]:class:' \
10
+ '*--remove-class[rewrite: remove a class]:class:' \
11
+ '*--set-attr[rewrite: set attribute name=value]:name=value:' \
12
+ '*--remove-attr[rewrite: remove an attribute]:attribute:' \
13
+ '--rename-tag[rewrite: rename the element]:tag:' \
14
+ '--watch[re-run the search whenever a watched file changes]' \
15
+ '--no-clear[with --watch on a TTY: append instead of clearing]' \
16
+ '--diff[emit a unified diff instead of the document]' \
8
17
  '(-r --recursive)'{-r,--recursive}'[recurse into directory arguments]' \
9
18
  '--max-depth[limit -r recursion depth (1 = given dir only)]:depth:' \
10
19
  '--ext[extensions to scan with -r]:extensions (comma-separated):' \
@@ -6,7 +6,7 @@ _cssgrep() {
6
6
  cur="${COMP_WORDS[COMP_CWORD]}"
7
7
  prev="${COMP_WORDS[COMP_CWORD-1]}"
8
8
 
9
- opts="-r --recursive --max-depth --ext --include -i --ignore --exclude --ignore-file -S --follow -n --line-number \
9
+ opts="-e --selector --watch --no-clear --add-class --remove-class --set-attr --remove-attr --rename-tag --diff -r --recursive --max-depth --ext --include -i --ignore --exclude --ignore-file -S --follow -n --line-number \
10
10
  -p --print --attr --text --json --parent -w --max-width \
11
11
  -A --after-context -B --before-context -C --context \
12
12
  -m --max-count -M --max-total -c --count \
@@ -23,7 +23,7 @@ _cssgrep() {
23
23
  COMPREPLY=( $(compgen -f -- "$cur") )
24
24
  return 0
25
25
  ;;
26
- --max-depth|--ext|--include|-i|--ignore|--exclude|--attr|--parent|-w|--max-width|-A|--after-context|\
26
+ -e|--selector|--add-class|--remove-class|--set-attr|--remove-attr|--rename-tag|--max-depth|--ext|--include|-i|--ignore|--exclude|--attr|--parent|-w|--max-width|-A|--after-context|\
27
27
  -B|--before-context|-C|--context|-m|--max-count|-M|--max-total)
28
28
  # value-taking flag with no enumerable completion; offer nothing
29
29
  return 0
@@ -1,6 +1,15 @@
1
1
  # fish completion for cssgrep. Keep in sync with the USAGE string in index.js.
2
2
  # Install: place this file in ~/.config/fish/completions/ (named cssgrep.fish).
3
3
 
4
+ complete -c cssgrep -s e -l selector -x -d 'Add a [label=]selector (repeatable)'
5
+ complete -c cssgrep -l add-class -x -d 'Rewrite: add a class to matched elements'
6
+ complete -c cssgrep -l remove-class -x -d 'Rewrite: remove a class (attr dropped when empty)'
7
+ complete -c cssgrep -l set-attr -x -d 'Rewrite: set attribute (name=value)'
8
+ complete -c cssgrep -l remove-attr -x -d 'Rewrite: remove an attribute'
9
+ complete -c cssgrep -l rename-tag -x -d 'Rewrite: rename the element (and closing tag)'
10
+ complete -c cssgrep -l diff -d 'Emit a unified diff instead of the document'
11
+ complete -c cssgrep -l watch -d 'Re-run the search whenever a watched file changes'
12
+ complete -c cssgrep -l no-clear -d 'With --watch on a TTY: append instead of clearing'
4
13
  complete -c cssgrep -s r -l recursive -d 'Recurse into directory arguments'
5
14
  complete -c cssgrep -l max-depth -x -d 'Limit -r recursion depth (1 = given dir only)'
6
15
  complete -c cssgrep -l ext -x -d 'Extensions to scan with -r (comma-separated)'
package/index.d.ts ADDED
@@ -0,0 +1,93 @@
1
+ // Type definitions for cssgrep's library API (lib.js).
2
+ //
3
+ // `parse`, the document's `html`/`search`, and the Match fields below are the
4
+ // stable public surface. `node` is an advanced escape hatch: the raw
5
+ // htmlparser2 element, whose shape belongs to htmlparser2 and may change with
6
+ // it. Other properties reachable on the document object at runtime are
7
+ // internal and unstable.
8
+
9
+ export interface SearchOptions {
10
+ /**
11
+ * Re-target each match to its n-th element ancestor (clamped at the
12
+ * document root), deduplicated by identity — the CLI's --parent.
13
+ */
14
+ parent?: number;
15
+ }
16
+
17
+ export interface Match {
18
+ /** 0-based offset of the node's opening `<` in the input string. */
19
+ start: number;
20
+ /** 0-based exclusive end offset: `doc.html.slice(start, end)` is the node. */
21
+ end: number;
22
+ /** 1-based line of the opening `<` (computed lazily on first read). */
23
+ line: number;
24
+ /** 1-based *byte* column of the opening `<` (what vim's grepformat %c expects). */
25
+ col: number;
26
+ /** Lowercased tag name. */
27
+ tag: string;
28
+ /** The node's attributes (names lowercased by the parser). */
29
+ attribs: Record<string, string>;
30
+ /** The exact source slice of the node (computed lazily on first read). */
31
+ html: string;
32
+ /** Text content, whitespace collapsed (computed lazily on first read). */
33
+ text: string;
34
+ /** The raw htmlparser2 element (advanced/unstable escape hatch). */
35
+ node: unknown;
36
+ }
37
+
38
+ export interface TransformOps {
39
+ /** Rename each matched element (its closing tag too, when one exists). */
40
+ renameTag?: string;
41
+ /** Attribute name(s) to remove (all source occurrences). */
42
+ removeAttr?: string | string[];
43
+ /** Attributes to set: value replaced or attribute added; values escaped. */
44
+ setAttr?: Record<string, string>;
45
+ /** Class token(s) to remove; the attribute is dropped when emptied. */
46
+ removeClass?: string | string[];
47
+ /** Class token(s) to add (deduplicated; attribute created if missing). */
48
+ addClass?: string | string[];
49
+ /** Re-target each match to its n-th element ancestor first (--parent). */
50
+ parent?: number;
51
+ }
52
+
53
+ export interface Edit {
54
+ /** 0-based offset in the original html where this splice starts. */
55
+ start: number;
56
+ /** 0-based exclusive end offset in the original html. */
57
+ end: number;
58
+ /** The replaced source text (`doc.html.slice(start, end)`). */
59
+ before: string;
60
+ /** The replacement text. */
61
+ after: string;
62
+ }
63
+
64
+ export interface TransformResult {
65
+ /** The rewritten document; bytes outside the edits are untouched. */
66
+ html: string;
67
+ /** One record per splice, in document order. Empty when nothing matched. */
68
+ edits: Edit[];
69
+ }
70
+
71
+ export interface Document {
72
+ /** The source string this document was parsed from. */
73
+ html: string;
74
+ /**
75
+ * Run a CSS selector against the parsed tree; callable any number of times
76
+ * per document. Returns one Match per hit in DOM order. Throws on a
77
+ * selector css-select cannot parse.
78
+ */
79
+ search(selector: string, opts?: SearchOptions): Match[];
80
+ /**
81
+ * Rewrite the matched elements. Ops compose in a fixed pipeline order
82
+ * (rename → remove-attr → set-attr → remove-class → add-class) regardless
83
+ * of key order; matching runs once against the original tree. Throws on an
84
+ * invalid selector, invalid op names, or an empty ops object.
85
+ */
86
+ transform(selector: string, ops: TransformOps): TransformResult;
87
+ }
88
+
89
+ /**
90
+ * Parse an HTML string once (tracking source positions) and return a document
91
+ * handle to query with `search`. Throws a TypeError on non-string input.
92
+ */
93
+ export function parse(html: string): Document;
package/lib.js ADDED
@@ -0,0 +1,403 @@
1
+ 'use strict';
2
+
3
+ // cssgrep's engine as a library. The public API is `parse(html)`: parse an
4
+ // HTML string once (with source positions) and get back a document handle
5
+ // whose `search(selector, opts)` can run any number of selectors against the
6
+ // same tree — parse once, query many. The CLI (cli.js) is a consumer of this
7
+ // API; everything here is string-in/data-out — file discovery, output
8
+ // formatting and process concerns live in the CLI.
9
+ //
10
+ // Stability contract: `parse`, the document's `html`/`search`, and the match
11
+ // fields are the public surface. Each match also carries `node`, the raw
12
+ // htmlparser2 element, as an advanced escape hatch — its shape belongs to
13
+ // htmlparser2, not to cssgrep. The document's `dom`/`lineStarts`/`position`
14
+ // and the other module exports are internals shared with the CLI.
15
+
16
+ const { parseDocument } = require('htmlparser2');
17
+ const { selectAll } = require('css-select');
18
+
19
+ // Precompute the offset at which each line starts, so offset->line/col is a
20
+ // binary search rather than a re-scan per match.
21
+ function lineIndex(src) {
22
+ const starts = [0];
23
+ for (let i = 0; i < src.length; i++) {
24
+ if (src.charCodeAt(i) === 10 /* \n */) starts.push(i + 1);
25
+ }
26
+ return starts;
27
+ }
28
+
29
+ function offsetToPosition(starts, src, offset) {
30
+ // binary search for the greatest line start <= offset
31
+ let lo = 0, hi = starts.length - 1;
32
+ while (lo < hi) {
33
+ const mid = (lo + hi + 1) >> 1;
34
+ if (starts[mid] <= offset) lo = mid;
35
+ else hi = mid - 1;
36
+ }
37
+ const lineStart = starts[lo];
38
+ let lineEnd = src.indexOf('\n', lineStart);
39
+ if (lineEnd === -1) lineEnd = src.length;
40
+ // strip a trailing \r so CRLF files render cleanly
41
+ let text = src.slice(lineStart, lineEnd);
42
+ if (text.endsWith('\r')) text = text.slice(0, -1);
43
+ return {
44
+ line: lo + 1, // 1-based
45
+ // col in UTF-16 code units: a JS string index into `text`, used by the
46
+ // highlight math. bcol in bytes: what gets printed — vim's grepformat %c
47
+ // and terminals count bytes, so non-ASCII text before the match would
48
+ // otherwise land the cursor short.
49
+ col: offset - lineStart + 1, // 1-based
50
+ bcol: Buffer.byteLength(src.slice(lineStart, offset), 'utf8') + 1, // 1-based
51
+ text,
52
+ };
53
+ }
54
+
55
+ // Text of a 1-based line number, with the trailing \r stripped (CRLF), plus the
56
+ // line's byte start (so a node's offset maps to a column within it).
57
+ function lineTextAt(starts, src, lineNo) {
58
+ const lineStart = starts[lineNo - 1];
59
+ let lineEnd = src.indexOf('\n', lineStart);
60
+ if (lineEnd === -1) lineEnd = src.length;
61
+ let text = src.slice(lineStart, lineEnd);
62
+ if (text.endsWith('\r')) text = text.slice(0, -1);
63
+ return { lineStart, text };
64
+ }
65
+
66
+ // Concatenate the text of a node and all its descendants (dependency-free,
67
+ // rather than pulling in domutils).
68
+ function textOf(node) {
69
+ if (node.type === 'text') return node.data || '';
70
+ if (!node.children) return '';
71
+ let s = '';
72
+ for (const child of node.children) s += textOf(child);
73
+ return s;
74
+ }
75
+
76
+ const collapseWs = s => s.replace(/\s+/g, ' ').trim();
77
+
78
+ const isElement = n => n && (n.type === 'tag' || n.type === 'script' || n.type === 'style');
79
+
80
+ // Climb n element levels from el, clamping at the document root.
81
+ function ancestor(el, n) {
82
+ let node = el;
83
+ for (let k = 0; k < n; k++) {
84
+ if (!isElement(node.parent)) break;
85
+ node = node.parent;
86
+ }
87
+ return node;
88
+ }
89
+
90
+ // `parent` re-points each match to its n-th ancestor; dedup by identity so a
91
+ // shared container is reported once, preserving first-seen order.
92
+ function retarget(nodes, opts) {
93
+ if (!opts.parent) return nodes;
94
+ const seen = new Set();
95
+ const result = [];
96
+ for (const el of nodes) {
97
+ const a = ancestor(el, opts.parent);
98
+ if (!seen.has(a)) { seen.add(a); result.push(a); }
99
+ }
100
+ return result;
101
+ }
102
+
103
+ // --- rewrite machinery --------------------------------------------------------
104
+
105
+ // Lex one opening tag starting at `start` (which must point at its `<`).
106
+ // htmlparser2 records node offsets but not attribute offsets, so this tiny
107
+ // lexer recovers the spans transform() needs. Quoted attribute values may
108
+ // contain `>`; `/` acts as whitespace (self-closing slash, sloppy `a/b`).
109
+ // Returns { nameStart, nameEnd, attrs, end } — offsets into `src`, `end` just
110
+ // past the closing `>`; each attr is { name, start, end, nameEnd, vStart,
111
+ // vEnd } (vStart -1 for valueless attributes; value offsets exclude quotes).
112
+ function lexOpenTag(src, start) {
113
+ let i = start + 1;
114
+ const nameStart = i;
115
+ while (i < src.length && !/[\s/>]/.test(src[i])) i++;
116
+ const nameEnd = i;
117
+ const attrs = [];
118
+ for (;;) {
119
+ while (i < src.length && /[\s/]/.test(src[i])) i++;
120
+ if (i >= src.length || src[i] === '>') break;
121
+ const aStart = i;
122
+ while (i < src.length && !/[\s=/>]/.test(src[i])) i++;
123
+ const aNameEnd = i;
124
+ let vStart = -1, vEnd = -1;
125
+ let j = i;
126
+ while (j < src.length && /\s/.test(src[j])) j++;
127
+ if (src[j] === '=') {
128
+ j++;
129
+ while (j < src.length && /\s/.test(src[j])) j++;
130
+ if (src[j] === '"' || src[j] === "'") {
131
+ const quote = src[j];
132
+ vStart = ++j;
133
+ while (j < src.length && src[j] !== quote) j++;
134
+ vEnd = j;
135
+ if (j < src.length) j++; // past the closing quote
136
+ } else {
137
+ vStart = j;
138
+ while (j < src.length && !/[\s>]/.test(src[j])) j++;
139
+ vEnd = j;
140
+ }
141
+ i = j;
142
+ }
143
+ attrs.push({
144
+ name: src.slice(aStart, aNameEnd),
145
+ start: aStart,
146
+ end: vStart === -1 ? aNameEnd : i,
147
+ nameEnd: aNameEnd,
148
+ vStart,
149
+ vEnd,
150
+ });
151
+ }
152
+ return { nameStart, nameEnd, attrs, end: i + 1 };
153
+ }
154
+
155
+ const escAttr = v => v.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
156
+
157
+ // Each helper takes the opening tag's source text and returns it edited (or
158
+ // unchanged). They re-lex per call — the string is one tag, so that's cheap —
159
+ // which lets ops compose sequentially without span bookkeeping.
160
+
161
+ function removeAttrFromTag(t, name) {
162
+ const lc = name.toLowerCase();
163
+ for (;;) { // all occurrences (source may dupe)
164
+ const a = lexOpenTag(t, 0).attrs.find(a => a.name.toLowerCase() === lc);
165
+ if (!a) return t;
166
+ let ws = a.start;
167
+ while (ws > 0 && /\s/.test(t[ws - 1])) ws--;
168
+ t = t.slice(0, ws) + t.slice(a.end);
169
+ }
170
+ }
171
+
172
+ function setAttrInTag(t, name, value) {
173
+ const lc = name.toLowerCase();
174
+ const a = lexOpenTag(t, 0).attrs.find(a => a.name.toLowerCase() === lc);
175
+ const rendered = `${name}="${escAttr(value)}"`;
176
+ if (a) return t.slice(0, a.start) + rendered + t.slice(a.end);
177
+ const tail = /\s*\/?>$/.exec(t);
178
+ return t.slice(0, tail.index) + ' ' + rendered + t.slice(tail.index);
179
+ }
180
+
181
+ // Class edits rewrite the whole class attribute (original name case kept,
182
+ // value re-quoted with `"`) — the one attribute being edited is normalized,
183
+ // every other byte of the tag stays put.
184
+ function classEdit(t, mutate) {
185
+ const a = lexOpenTag(t, 0).attrs.find(a => a.name.toLowerCase() === 'class');
186
+ const tokens = a && a.vStart >= 0
187
+ ? t.slice(a.vStart, a.vEnd).split(/\s+/).filter(Boolean)
188
+ : [];
189
+ const next = mutate(tokens);
190
+ if (next === null) return t; // no change
191
+ if (!a) return setAttrInTag(t, 'class', next.join(' '));
192
+ if (next.length === 0) return removeAttrFromTag(t, t.slice(a.start, a.nameEnd));
193
+ return t.slice(0, a.start)
194
+ + `${t.slice(a.start, a.nameEnd)}="${next.join(' ')}"`
195
+ + t.slice(a.end);
196
+ }
197
+
198
+ const addClassToTag = (t, cls) =>
199
+ classEdit(t, tokens => (tokens.includes(cls) ? null : [...tokens, cls]));
200
+
201
+ const removeClassFromTag = (t, cls) =>
202
+ classEdit(t, tokens => (tokens.includes(cls) ? tokens.filter(c => c !== cls) : null));
203
+
204
+ // Validate and normalize a transform ops object. Names must not be able to
205
+ // break out of the tag or its quoting; values are escaped at render time.
206
+ const OP_KEYS = ['renameTag', 'removeAttr', 'setAttr', 'removeClass', 'addClass', 'parent'];
207
+ const TAG_NAME = /^[a-zA-Z][^\s/>'"=]*$/;
208
+ const ATTR_NAME = /^[^\s/>'"=]+$/;
209
+ const CLASS_TOKEN = /^[^\s'"&<>]+$/;
210
+
211
+ function normalizeOps(ops) {
212
+ for (const k of Object.keys(ops)) {
213
+ if (!OP_KEYS.includes(k)) throw new TypeError(`unknown transform op: ${k}`);
214
+ }
215
+ const toArr = v => (v == null ? [] : Array.isArray(v) ? v : [v]).map(String);
216
+ const norm = {
217
+ renameTag: ops.renameTag != null ? String(ops.renameTag) : null,
218
+ removeAttr: toArr(ops.removeAttr),
219
+ setAttr: {},
220
+ removeClass: toArr(ops.removeClass),
221
+ addClass: toArr(ops.addClass),
222
+ parent: ops.parent || 0,
223
+ };
224
+ if (ops.parent != null && (!Number.isInteger(ops.parent) || ops.parent < 0)) {
225
+ throw new TypeError('parent must be a non-negative integer');
226
+ }
227
+ if (norm.renameTag !== null && !TAG_NAME.test(norm.renameTag)) {
228
+ throw new TypeError(`invalid tag name: ${norm.renameTag}`);
229
+ }
230
+ for (const k of norm.removeAttr) {
231
+ if (!ATTR_NAME.test(k)) throw new TypeError(`invalid attribute name: ${k}`);
232
+ }
233
+ if (ops.setAttr != null) {
234
+ if (typeof ops.setAttr !== 'object' || Array.isArray(ops.setAttr)) {
235
+ throw new TypeError('setAttr must be an object of name: value');
236
+ }
237
+ for (const [k, v] of Object.entries(ops.setAttr)) {
238
+ if (!ATTR_NAME.test(k)) throw new TypeError(`invalid attribute name: ${k}`);
239
+ norm.setAttr[k] = String(v);
240
+ }
241
+ }
242
+ for (const c of [...norm.removeClass, ...norm.addClass]) {
243
+ if (!CLASS_TOKEN.test(c)) throw new TypeError(`invalid class name: ${c}`);
244
+ }
245
+ const count = (norm.renameTag ? 1 : 0) + norm.removeAttr.length
246
+ + Object.keys(norm.setAttr).length + norm.removeClass.length + norm.addClass.length;
247
+ if (count === 0) throw new TypeError('no transform operations given');
248
+ return norm;
249
+ }
250
+
251
+ // Ops compose in a fixed documented pipeline order — deterministic no matter
252
+ // how the caller (or argv) ordered them: rename → remove-attr → set-attr →
253
+ // remove-class → add-class.
254
+ function applyOpsToOpenTag(t, ops) {
255
+ if (ops.renameTag) {
256
+ const lx = lexOpenTag(t, 0);
257
+ t = t.slice(0, lx.nameStart) + ops.renameTag + t.slice(lx.nameEnd);
258
+ }
259
+ for (const k of ops.removeAttr) t = removeAttrFromTag(t, k);
260
+ for (const [k, v] of Object.entries(ops.setAttr)) t = setAttrInTag(t, k, v);
261
+ for (const c of ops.removeClass) t = removeClassFromTag(t, c);
262
+ for (const c of ops.addClass) t = addClassToTag(t, c);
263
+ return t;
264
+ }
265
+
266
+ // Parse an HTML string once and return a document handle. `search(selector,
267
+ // opts)` can then run any number of selectors against the same tree — the
268
+ // parse and the line index are paid once per document, not per query.
269
+ //
270
+ // Each search returns one match object per hit, in DOM order:
271
+ //
272
+ // { start, end, line, col, tag, attribs, html, text, node }
273
+ //
274
+ // - start/end: 0-based offsets of the node in `html` (end exclusive), so
275
+ // `html.slice(start, end)` is the exact source of the match.
276
+ // - line/col: 1-based position of the opening `<`; col counts *bytes* (what
277
+ // vim's grepformat %c expects), matching the CLI's -n output.
278
+ // - tag: lowercased tag name; attribs: a fresh plain object copy.
279
+ // - html: the exact source slice; text: collapsed text content.
280
+ // - node: the raw htmlparser2 element (advanced/unstable escape hatch).
281
+ //
282
+ // line/col/html/text are lazy getters — a match costs nothing for fields
283
+ // never read (aggregate-style consumers skip the position math entirely),
284
+ // while JSON.stringify still serializes complete records.
285
+ //
286
+ // opts.parent (n >= 1) re-targets each match to its n-th element ancestor,
287
+ // deduplicated by identity — the CLI's --parent.
288
+ //
289
+ // parse throws on non-string input; search throws on selectors css-select
290
+ // cannot parse.
291
+ function parse(html) {
292
+ if (typeof html !== 'string') throw new TypeError('html must be a string');
293
+ const dom = parseDocument(html, {
294
+ withStartIndices: true,
295
+ withEndIndices: true,
296
+ });
297
+ let starts = null;
298
+ const lineStarts = () => starts || (starts = lineIndex(html));
299
+ const position = offset => offsetToPosition(lineStarts(), html, offset);
300
+ return {
301
+ html,
302
+ // Internal (unstable): the raw htmlparser2 document and the cached
303
+ // line-index helpers, shared with the CLI.
304
+ dom,
305
+ lineStarts,
306
+ position,
307
+ search(selector, opts = {}) {
308
+ if (typeof selector !== 'string' || selector.trim() === '') {
309
+ throw new TypeError('selector must be a non-empty string');
310
+ }
311
+ if (opts.parent != null && (!Number.isInteger(opts.parent) || opts.parent < 0)) {
312
+ throw new TypeError('opts.parent must be a non-negative integer');
313
+ }
314
+ const targets = retarget(selectAll(selector, dom), { parent: opts.parent || 0 });
315
+ return targets.map(el => {
316
+ const start = el.startIndex == null ? 0 : el.startIndex;
317
+ const end = (el.endIndex == null ? start : el.endIndex) + 1; // exclusive
318
+ const match = {
319
+ start,
320
+ end,
321
+ tag: el.name,
322
+ attribs: el.attribs ? { ...el.attribs } : {},
323
+ get line() { return position(start).line; },
324
+ get col() { return position(start).bcol; },
325
+ get html() { return html.slice(start, end); },
326
+ get text() { return collapseWs(textOf(el)); },
327
+ };
328
+ // The escape hatch is a reference, not data: htmlparser2 nodes link
329
+ // parent/children circularly, so keep `node` non-enumerable to let
330
+ // JSON.stringify serialize matches cleanly.
331
+ Object.defineProperty(match, 'node', { value: el });
332
+ return match;
333
+ });
334
+ },
335
+ // Rewrite the elements matched by `selector` and return { html, edits }:
336
+ // the edited document plus one splice record per changed span
337
+ // ({ start, end, before, after }, offsets into the ORIGINAL html). Only
338
+ // the matched tags' bytes change — everything else passes through
339
+ // untouched (the fidelity contract; see ROADMAP Phase 9).
340
+ //
341
+ // ops: { renameTag, removeAttr, setAttr, removeClass, addClass, parent }.
342
+ // Scalars or arrays where it makes sense; ops compose in the fixed
343
+ // pipeline order rename → remove-attr → set-attr → remove-class →
344
+ // add-class regardless of key order. The selector runs once against the
345
+ // parsed tree; edits never re-match. Throws on invalid selectors, op
346
+ // names, or an empty ops object.
347
+ transform(selector, ops = {}) {
348
+ if (typeof selector !== 'string' || selector.trim() === '') {
349
+ throw new TypeError('selector must be a non-empty string');
350
+ }
351
+ const norm = normalizeOps(ops);
352
+ const targets = retarget(selectAll(selector, dom), { parent: norm.parent });
353
+ const edits = [];
354
+ for (const el of targets) {
355
+ if (el.startIndex == null) continue;
356
+ const start = el.startIndex;
357
+ const open = lexOpenTag(html, start);
358
+ const before = html.slice(start, open.end);
359
+ const after = applyOpsToOpenTag(before, norm);
360
+ if (after !== before) edits.push({ start, end: open.end, before, after });
361
+ if (norm.renameTag) {
362
+ // Rename the closing tag too — but only when one explicitly exists:
363
+ // voids (<img>), self-closing (<x/>) and parser-implied closes
364
+ // (<li> without </li>) end without their own </name>.
365
+ const nodeEnd = (el.endIndex == null ? start : el.endIndex) + 1;
366
+ const m = /<\/([^\s>]+)(\s*)>$/.exec(html.slice(start, nodeEnd));
367
+ if (m && m[1].toLowerCase() === el.name) {
368
+ edits.push({
369
+ start: start + m.index,
370
+ end: nodeEnd,
371
+ before: m[0],
372
+ after: `</${norm.renameTag}${m[2]}>`,
373
+ });
374
+ }
375
+ }
376
+ }
377
+ edits.sort((a, b) => a.start - b.start);
378
+ for (let i = 1; i < edits.length; i++) {
379
+ if (edits[i].start < edits[i - 1].end) {
380
+ throw new Error('internal error: overlapping edits');
381
+ }
382
+ }
383
+ let out = html;
384
+ for (let i = edits.length - 1; i >= 0; i--) {
385
+ out = out.slice(0, edits[i].start) + edits[i].after + out.slice(edits[i].end);
386
+ }
387
+ return { html: out, edits };
388
+ },
389
+ };
390
+ }
391
+
392
+ module.exports = {
393
+ parse,
394
+ // Internal helpers, shared with cli.js; not part of the stable API.
395
+ lineIndex,
396
+ offsetToPosition,
397
+ lineTextAt,
398
+ textOf,
399
+ collapseWs,
400
+ isElement,
401
+ ancestor,
402
+ retarget,
403
+ };