cssgrep 1.1.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.
- package/CHANGELOG.md +77 -1
- package/README.md +182 -7
- package/{index.js → cli.js} +593 -162
- package/completions/_cssgrep +10 -0
- package/completions/cssgrep.bash +2 -2
- package/completions/cssgrep.fish +10 -0
- package/index.d.ts +93 -0
- package/lib.js +403 -0
- package/man/cssgrep.1 +181 -12
- package/package.json +12 -9
package/completions/_cssgrep
CHANGED
|
@@ -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):' \
|
|
@@ -12,6 +21,7 @@ _cssgrep() {
|
|
|
12
21
|
'*'{-i,--ignore}'[skip files/dirs matching glob while recursing]:glob:' \
|
|
13
22
|
'*--exclude[alias for --ignore]:glob:' \
|
|
14
23
|
'--ignore-file[read ignore globs from a file]:file:_files' \
|
|
24
|
+
'(-S --follow)'{-S,--follow}'[follow symbolic links while recursing]' \
|
|
15
25
|
'(-n --line-number)'{-n,--line-number}'[prefix each match with line:col]' \
|
|
16
26
|
'(-p --print)'{-p,--print}'[pretty-print the matched node HTML]' \
|
|
17
27
|
'--attr[print the value of an attribute]:attribute name:' \
|
package/completions/cssgrep.bash
CHANGED
|
@@ -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 -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
|
-
|
|
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
|
package/completions/cssgrep.fish
CHANGED
|
@@ -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)'
|
|
@@ -8,6 +17,7 @@ complete -c cssgrep -l include -x -d 'Only search files matching glob (replaces
|
|
|
8
17
|
complete -c cssgrep -s i -l ignore -x -d 'Skip files/dirs matching glob while recursing'
|
|
9
18
|
complete -c cssgrep -l exclude -x -d 'Alias for --ignore'
|
|
10
19
|
complete -c cssgrep -l ignore-file -r -F -d 'Read ignore globs from a file'
|
|
20
|
+
complete -c cssgrep -s S -l follow -d 'Follow symbolic links while recursing'
|
|
11
21
|
complete -c cssgrep -s n -l line-number -d 'Prefix each match with line:col'
|
|
12
22
|
complete -c cssgrep -s p -l print -d 'Pretty-print the matched node HTML'
|
|
13
23
|
complete -c cssgrep -l attr -x -d 'Print the value of an attribute'
|
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, '&').replace(/"/g, '"');
|
|
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
|
+
};
|