cssgrep 1.2.0 → 1.4.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 CHANGED
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0] - 2026-07-07
11
+
12
+ ### Fixed
13
+ - Large result sets no longer crash with `RangeError: Invalid string length`
14
+ (exit 1, zero output): output is written in byte-bounded chunks instead of
15
+ one giant join. Observed with 40k matches on an 8 MB minified line.
16
+ - Emitting many matches on one physical line was quadratic (two O(line)
17
+ scans per match); the same 40k-locator case dropped from 9.4 s to 0.4 s.
18
+ Found by the new `npm run bench` harness.
19
+
20
+ ### Changed
21
+ - grep parity: without `-n`, a matching line now prints once, however many
22
+ matches sit on it. With `-n` there is still one record per match, each
23
+ with its own `line:col` locator.
24
+ - Startup no longer loads the pretty-printing dependencies unless `-p` is
25
+ used (43.4 → 37.9 ms measured), and zero-match files skip the line index.
26
+
27
+ ## [1.3.0] - 2026-07-07
28
+
29
+ ### Added
30
+ - `--watch`: re-run the search whenever a watched file changes (native
31
+ recursive file events, no polling; debounced; every rerun repeats the full
32
+ walk so new files appear under the same include/ignore rules). Output
33
+ adapts like `--color=auto`: a TTY clears and reprints (`--no-clear` to
34
+ append instead), pipes get `== HH:MM:SS ==` run separators, and `--json`
35
+ becomes an NDJSON stream of `{"event":"run",…}` records followed by
36
+ matches. Ctrl-C exits 0.
37
+ - Rewrite mode: `--add-class`, `--remove-class`, `--set-attr k=v`,
38
+ `--remove-attr` and `--rename-tag` edit the matched elements instead of
39
+ reporting them. Byte-splice fidelity: only the matched tags' bytes change.
40
+ A single input prints the rewritten document to stdout; `--diff` emits a
41
+ git-apply-able unified diff (required for multiple files) — cssgrep never
42
+ writes a file. Ops compose in a fixed order regardless of argv order;
43
+ non-UTF-8 input is refused. Also exposed to library consumers as
44
+ `doc.transform(selector, ops)` returning `{ html, edits }`.
45
+ - `-e`/`--selector [label=]<sel>` (repeatable): search several selectors in
46
+ one pass, each match tagged `[label]` in line mode and carrying a `label`
47
+ field in `--json`. Unlabeled selectors are tagged with their own text.
48
+ Matches merge in document order; `-m`/`-M` cap the merged stream. With
49
+ `-e`, positional arguments are always file paths, like `grep -e`.
50
+ - An "Inverting matches" section in the README and man page: `:not()` (with
51
+ selector lists) and `:has()` recipes covering what grep's `-v` does, pinned
52
+ by tests. Decided against adding a `-v` flag — the selector already names
53
+ the inversion universe; see `ROADMAP.md` Phase 7 for the analysis.
54
+ - `-v`/`--invert-match` now fail with a message pointing at the `:not()`/
55
+ `:has()` recipes (still exit 2), instead of a generic "unknown option".
56
+ - Library API: `require('cssgrep')` now exposes `parse(html)`, which parses
57
+ once (source positions, cached line index) and returns a document handle;
58
+ `doc.search(selector, opts)` runs any number of selectors against the same
59
+ tree — the CLI is built on it, so `-e` never re-parses. Matches are plain
60
+ objects with source positions (`start`/`end` offsets, 1-based `line`,
61
+ byte-accurate `col`, `tag`, `attribs`, `html`, `text` — the last four lazy)
62
+ plus the raw htmlparser2 element as a non-enumerable `node` escape hatch,
63
+ so records `JSON.stringify` cleanly. `opts.parent` mirrors the CLI's
64
+ `--parent`. TypeScript definitions ship as `index.d.ts`.
65
+
66
+ ### Changed
67
+ - The package was split into `lib.js` (library) and `cli.js` (the `cssgrep`
68
+ bin); `index.js` is gone. Requiring the package no longer executes the CLI.
69
+ The CLI itself is unchanged.
70
+
10
71
  ## [1.2.0] - 2026-07-02
11
72
 
12
73
  ### Added
@@ -64,7 +125,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
64
125
  - `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
65
126
  - Standalone binaries (Bun, Node SEA), shell completions, and a man page.
66
127
 
67
- [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.2.0...HEAD
128
+ [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.4.0...HEAD
129
+ [1.4.0]: https://github.com/msbatarce/cssgrep/compare/v1.3.0...v1.4.0
130
+ [1.3.0]: https://github.com/msbatarce/cssgrep/compare/v1.2.0...v1.3.0
68
131
  [1.2.0]: https://github.com/msbatarce/cssgrep/compare/v1.1.0...v1.2.0
69
132
  [1.1.0]: https://github.com/msbatarce/cssgrep/compare/v1.0.0...v1.1.0
70
133
  [1.0.0]: https://github.com/msbatarce/cssgrep/releases/tag/v1.0.0
package/README.md CHANGED
@@ -61,9 +61,10 @@ cssgrep <selector> -r <dir ...> # recurse directories
61
61
  cat page.html | cssgrep <selector> # read from stdin
62
62
  ```
63
63
 
64
- Output, one line per match. Like `grep`, the matched line is printed on its own;
65
- a `file:` prefix is added when searching multiple files, and the `line:col`
66
- locator appears only with `-n`:
64
+ Like `grep`, each matching line is printed on its own, once — however many
65
+ matches sit on it. With `-n` there is one record *per match*, each with its
66
+ own `line:col` locator (that per-match precision is the point of the tool). A
67
+ `file:` prefix is added when searching multiple files:
67
68
 
68
69
  ```
69
70
  {line contents} # default; stdin or single file
@@ -76,6 +77,7 @@ locator appears only with `-n`:
76
77
 
77
78
  | Flag | Description |
78
79
  |------|-------------|
80
+ | `-e`, `--selector <[label=]sel>` | Add a selector (repeatable). Matches from all `-e` selectors merge in document order, each tagged `[label]` (default label: the selector text). With `-e`, every positional argument is a file path, like `grep -e`. See [Multiple selectors](#multiple-selectors--e). |
79
81
  | `-r`, `--recursive` | Recurse into directory arguments (defaults to `.` if none given). |
80
82
  | `--max-depth <n>` | Limit `-r` recursion depth (`1` = the given directory only, no subdirectories). |
81
83
  | `--ext <list>` | Extensions to scan with `-r` (default `html,htm`). Value attaches with `=`: `--ext htm` or `--ext=htm`. |
@@ -88,7 +90,7 @@ locator appears only with `-n`:
88
90
  | `-p`, `--print` | Pretty-print the matched node's HTML, re-indented from scratch (works on minified input). No `line:col` locator is shown. |
89
91
  | `--attr <name>` | Print the value of attribute `<name>` for each match (nodes without it are skipped; if every match is skipped the exit status is 1). The name is matched case-insensitively. Honors `-n` and `-w`. |
90
92
  | `--text` | Print the matched node's text content, whitespace collapsed. Honors `-n` and `-w`. |
91
- | `--json` | Print one JSON object per match (NDJSON), with `file`, `line`, `col`, `html`, `text`. |
93
+ | `--json` | Print one JSON object per match (NDJSON), with `file`, `line`, `col`, `html`, `text` — plus `label` when `-e` is used. |
92
94
  | `--parent <n>` | Report the `n`-th element ancestor of each match instead of the match itself (de-duplicated). Pairs well with `-p`. |
93
95
  | `-w`, `--max-width <n>` | Truncate the shown line to `n` columns (adds `…`). Value attaches or follows: `-w100`, `-w 100`, `--max-width=100`. |
94
96
  | `-A`, `--after-context <n>` | Print `n` source lines after each match. |
@@ -105,6 +107,14 @@ locator appears only with `-n`:
105
107
  | `-H`, `--with-filename` | Always print the `file:` prefix, even for a single file or stdin. |
106
108
  | `--no-filename` | Never print the `file:` prefix, even when searching multiple files. |
107
109
  | `--color[=<when>]` | Colorize output: `auto` (default — color only when stdout is a terminal), `always`, or `never`. A bare `--color` means `auto`, like grep; use `--color=always` to force color into pipes. |
110
+ | `--watch` | Re-run the search whenever a watched file changes. TTY: clear + reprint; pipe: append with `== HH:MM:SS ==` separators; `--json`: NDJSON `{"event":"run",…}` per rerun. Ctrl-C exits 0. See [Watch mode](#watch-mode---watch). |
111
+ | `--no-clear` | With `--watch` on a TTY: append instead of clearing the screen. |
112
+ | `--add-class <c>` | *Rewrite:* add a class to each matched element. See [Rewriting HTML](#rewriting-html-refactor-ops). |
113
+ | `--remove-class <c>` | *Rewrite:* remove a class (the attribute is dropped when emptied). |
114
+ | `--set-attr <k=v>` | *Rewrite:* set attribute `k` to `v` (added if missing; value escaped). |
115
+ | `--remove-attr <k>` | *Rewrite:* remove attribute `k` (all source occurrences). |
116
+ | `--rename-tag <t>` | *Rewrite:* rename the element — its closing tag too, when one exists. |
117
+ | `--diff` | Emit a unified diff instead of the rewritten document; required for multiple files. Apply with `git apply` or `patch`. |
108
118
  | `-h`, `--help` | Show help. |
109
119
  | `-V`, `--version` | Print the version and exit. |
110
120
 
@@ -146,6 +156,9 @@ cssgrep 'a' --attr href testdata/links.html # every link target
146
156
  cssgrep 'h1, h2' --text -r src/ # all heading text
147
157
  cssgrep 'img' -l -r . # files that contain an <img>
148
158
 
159
+ # multiple labeled selectors — scrape several fields in one pass
160
+ cssgrep -e 'title=h1' -e 'price=.card .price' --json page.html
161
+
149
162
  # structural context — show the container the match lives in
150
163
  cssgrep '.price' -p --parent 1 testdata/cards.html # pretty-print each price's card
151
164
 
@@ -162,6 +175,119 @@ ancestors are de-duplicated, so `cssgrep '.price' --parent 1 -p` prints each
162
175
  containing card once. It composes with every print mode (`-p`, `--attr`,
163
176
  `--text`, `--json`, or the default line output).
164
177
 
178
+ ### Multiple selectors (`-e`)
179
+
180
+ Repeatable `-e [label=]<selector>` searches several selectors in one parse
181
+ pass and tags each match with which one hit — one command turns a page into
182
+ labeled fields:
183
+
184
+ ```sh
185
+ $ cssgrep -n -e 'title=h1' -e 'price=.card .price' page.html
186
+ 3:5 [title] <h1>Widget</h1>
187
+ 9:12 [price] <span class="price">$4.99</span>
188
+
189
+ $ cssgrep --json -e 'title=h1' -e 'price=.card .price' page.html
190
+ {"file":"page.html","line":3,"col":5,"label":"title","html":"<h1>Widget</h1>","text":"Widget"}
191
+ {"file":"page.html","line":9,"col":12,"label":"price","html":"...","text":"$4.99"}
192
+ ```
193
+
194
+ The label is anything matching `[A-Za-z_][A-Za-z0-9_-]*` before a `=`; since a
195
+ bare `=` is never valid CSS outside `[...]`, there's no ambiguity — `-e
196
+ '[href=x]'` is a plain selector. An unlabeled `-e` is tagged with its own
197
+ selector text.
198
+
199
+ Matches from all selectors merge into one document-order stream (a node hit by
200
+ two selectors is reported once per selector, in `-e` order), and `-m`/`-M`
201
+ budgets cap that merged stream, exactly like grep caps lines regardless of
202
+ which pattern matched. Print modes (`--text`, `--attr`, `-p`, `--json`) apply
203
+ globally to every selector. With `-e`, positional arguments are always file
204
+ paths — like `grep -e`, a mistyped path is reported as unreadable rather than
205
+ re-guessed as a selector.
206
+
207
+ ### Watch mode (`--watch`)
208
+
209
+ `--watch` keeps the search running and re-runs it whenever a watched file
210
+ changes — for keeping an eye on generated HTML, or feeding a tool a live
211
+ stream of matches:
212
+
213
+ ```sh
214
+ cssgrep '.error' --watch -rn build/ # live view: clears and reprints
215
+ cssgrep '.error' --watch -r --json build/ | your-tool # NDJSON event feed
216
+ ```
217
+
218
+ Output adapts to where it goes, like `--color=auto`: on a terminal each run
219
+ clears the screen and reprints (pass `--no-clear` to append instead — handy
220
+ in tmux scrollback); piped output never contains escape codes and appends
221
+ each run after a `== HH:MM:SS <changed file> ==` separator; with `--json`,
222
+ each rerun emits `{"event":"run","changed":…,"matches":n}` followed by the
223
+ usual match records.
224
+
225
+ Every rerun repeats the full directory walk, so newly created files are
226
+ picked up (and deleted ones dropped) under exactly the same
227
+ `--include`/`--ignore`/`--ext` rules as a fresh invocation. Change bursts are
228
+ debounced. Watching uses the OS's native file events (no polling), which can
229
+ be unreliable on network or virtual filesystems. `--watch` needs file or
230
+ directory arguments (stdin can't be watched), can't be combined with `-q` or
231
+ the rewrite ops, and runs until Ctrl-C (exit status 0, like `watch(1)`).
232
+
233
+ ### Rewriting HTML (refactor ops)
234
+
235
+ The same selector engine can *edit* what it matches. Five ops — repeatable and
236
+ freely combined — rewrite each matched element's tag in place:
237
+
238
+ ```sh
239
+ $ cssgrep '.old' --remove-class old --add-class fresh page.html # rewritten doc → stdout
240
+ $ cssgrep 'b' --rename-tag strong -r src/ --diff # unified diff for many files
241
+ $ cssgrep 'b' --rename-tag strong -r src/ --diff | git apply # …review, then apply
242
+ ```
243
+
244
+ The fidelity contract: **only the matched tags' bytes change.** Matching runs
245
+ once against the original document, then edits are byte-splices — quoting,
246
+ entities, whitespace and formatting everywhere else pass through untouched
247
+ (the edited attribute itself is normalized to `name="value"`). Ops compose in
248
+ a fixed order — rename → remove-attr → set-attr → remove-class → add-class —
249
+ so results never depend on argument order. `--rename-tag` edits the closing
250
+ tag only when one explicitly exists (voids like `<img>`, self-closing `<x/>`
251
+ and implied closes like `<li>` are handled). `--parent` composes: `.price
252
+ --add-class sale --parent 1` tags the container.
253
+
254
+ cssgrep never writes a file: a single input prints the rewritten document to
255
+ stdout; `--diff` (required for multiple files) emits a git-apply-able unified
256
+ diff, so applying is a reviewable, revertable `git apply`/`patch` step. Input
257
+ that isn't valid UTF-8 is refused (exit 2) — a rewriter must never corrupt
258
+ bytes it didn't edit. Exit status: `0` if anything was edited, `1` if nothing
259
+ matched, `2` on error.
260
+
261
+ Rewrite is its own mode: it can't be combined with the print
262
+ (`-n`/`-p`/`--attr`/`--text`/`--json`), aggregate (`-c`/`-l`/`-L`/`-q`),
263
+ context, `-m`/`-M`, `-w`, `-0` or `-e` flags.
264
+
265
+ The same operation is available to library consumers as
266
+ `doc.transform(selector, ops)` — see [Library usage](#library-usage).
267
+
268
+ ### Inverting matches (why there's no `-v`)
269
+
270
+ grep needs `-v` because a regex can't say "lines *not* matching". CSS can:
271
+ `:not()` — including full complex selectors and selector lists, plus `:has()`
272
+ for descendant conditions — expresses every inversion directly in the
273
+ selector, scoped to the elements you actually care about:
274
+
275
+ ```sh
276
+ cssgrep 'img:not([alt])' -rn . # accessibility: images missing alt text
277
+ cssgrep 'div.card:not(:has(a))' page.html # cards that contain no link
278
+ cssgrep 'a:not(nav a, footer a)' page.html # links outside chrome
279
+ cssgrep 'input:not([type=hidden])' -c form.html # count the visible inputs
280
+ ```
281
+
282
+ A flag-level `-v` would need its own answer to "*which* non-matching
283
+ elements?" — bare inversion (`:not(a)`) matches nearly every element in the
284
+ document — so the selector, which already names the universe on the left of
285
+ `:not()`, is both shorter and standard CSS. For whole files without a match,
286
+ use `-L`.
287
+
288
+ Typing `-v` or `--invert-match` out of grep habit fails with exit 2 and a
289
+ message pointing back to these recipes, rather than a bare "unknown option".
290
+
165
291
  ## Vim / Neovim integration
166
292
 
167
293
  The `file:line:col text` format produced by `-n` is `:grep`-compatible. Point
@@ -200,6 +326,53 @@ cp completions/cssgrep.fish ~/.config/fish/completions/
200
326
  (The completions are hand-maintained; keep them in step with `--help` if you add
201
327
  a flag.)
202
328
 
329
+ ## Library usage
330
+
331
+ The engine is also a programmatic API — `require('cssgrep')` gives you
332
+ `parse()` (the CLI is a separate entry point built on it, so requiring the
333
+ package never runs it):
334
+
335
+ ```js
336
+ const { parse } = require('cssgrep');
337
+
338
+ const doc = parse('<div class="card"><a href="/x">go</a></div>'); // parse once
339
+ doc.search('.card a'); // …query many times
340
+ // [{
341
+ // start: 18, end: 39, // offsets into the input: doc.html.slice(start, end)
342
+ // line: 1, col: 19, // 1-based; col counts bytes, like the CLI's -n
343
+ // tag: 'a',
344
+ // attribs: { href: '/x' },
345
+ // html: '<a href="/x">go</a>',
346
+ // text: 'go',
347
+ // node: [Element], // raw htmlparser2 element (advanced, unstable)
348
+ // }]
349
+ doc.search('a', { parent: 1 }); // same tree, no re-parse
350
+ ```
351
+
352
+ `parse(html)` takes an HTML **string** (file discovery, stdin and binary
353
+ detection are CLI concerns) and pays the parse and line index once; each
354
+ `doc.search(selector, opts)` is then just a selector run over the same tree —
355
+ this is what the CLI's `-e` uses, so multiple selectors never re-parse.
356
+ Matches come back in DOM order; `opts.parent` re-targets each to its n-th
357
+ element ancestor, deduplicated — the CLI's `--parent`. `line`/`col`/`html`/
358
+ `text` are lazy (computed on first read, and by `JSON.stringify`; the circular
359
+ `node` reference stays out of serialization). `parse` throws on non-string
360
+ input, `search` on a selector [css-select](https://github.com/fb55/css-select)
361
+ cannot parse. TypeScript types ship with the package (`index.d.ts`).
362
+
363
+ Every match's `html` is the exact source slice, so offsets stay byte-faithful
364
+ even on minified input — the same position tracking the CLI uses.
365
+
366
+ The rewrite mode is exposed as `doc.transform(selector, ops)`:
367
+
368
+ ```js
369
+ const { html, edits } = doc.transform('.old', {
370
+ removeClass: 'old', addClass: 'fresh', // also: renameTag, setAttr,
371
+ }); // removeAttr, parent
372
+ // html — the rewritten document (bytes outside the edits untouched)
373
+ // edits — [{ start, end, before, after }] splice records, document order
374
+ ```
375
+
203
376
  ## How it works
204
377
 
205
378
  - [`htmlparser2`](https://github.com/fb55/htmlparser2) parses with