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