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 CHANGED
@@ -7,6 +7,80 @@ 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
+
54
+ ## [1.2.0] - 2026-07-02
55
+
56
+ ### Added
57
+ - `-S`/`--follow` to follow symbolic links while recursing with `-r`
58
+ (skipped by default). Cycle-safe: each physical directory is visited once.
59
+
60
+ ### Fixed
61
+ - Large result sets are no longer truncated when stdout is a pipe (the process
62
+ exited before async pipe writes had flushed).
63
+ - Columns (`-n`, `-A`/`-B`/`-C`, `--json`) now count bytes — what vim's
64
+ `grepformat %c` expects — instead of UTF-16 code units, so non-ASCII text
65
+ before a match no longer skews the locator.
66
+ - A value-taking option with no value (e.g. a trailing `--ignore` or `--attr`)
67
+ now fails cleanly with exit 2 instead of crashing or being silently ignored.
68
+ - `**/` in globs stops at path-segment boundaries: `--include '**/foo.html'`
69
+ no longer matches `barfoo.html`.
70
+ - `--attr` matches attribute names case-insensitively (the parser lowercases
71
+ them), and exits 1 when every match was skipped for lacking the attribute.
72
+ - `-w`/`--max-width` no longer cuts an astral character in half.
73
+ - Binary detection now also applies to standard input.
74
+ - Slash-containing globs (e.g. `--include 'src/*.html'`) now match on Windows:
75
+ paths are normalized to `/` separators before glob matching.
76
+ - Recursive walks visit directory entries in sorted order, so output order is
77
+ deterministic across platforms.
78
+
79
+ ### Changed
80
+ - Bare `--color` now means `auto`, matching GNU grep (was `always`).
81
+ - `-c` prints zero counts (`file:0`, or a lone `0` for a single input),
82
+ matching grep.
83
+
10
84
  ## [1.1.0] - 2026-07-02
11
85
 
12
86
  ### Added
@@ -34,6 +108,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
34
108
  - `-0`/`--null`, `--color`, `-w`/`--max-width`, `-V`/`--version`.
35
109
  - Standalone binaries (Bun, Node SEA), shell completions, and a man page.
36
110
 
37
- [Unreleased]: https://github.com/msbatarce/cssgrep/compare/v1.1.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
113
+ [1.2.0]: https://github.com/msbatarce/cssgrep/compare/v1.1.0...v1.2.0
38
114
  [1.1.0]: https://github.com/msbatarce/cssgrep/compare/v1.0.0...v1.1.0
39
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`. |
@@ -83,19 +84,20 @@ locator appears only with `-n`:
83
84
  | `-i`, `--ignore <glob>` | Skip files/dirs matching `<glob>` while recursing (repeatable). `node_modules`, `*.min.html`, `build/` (dir-only), or path globs like `src/vendor/**`. |
84
85
  | `--exclude <glob>` | Alias for `--ignore` (grep's name for the same thing). |
85
86
  | `--ignore-file <path>` | Load ignore globs from a file, one per line (`#` comments and blank lines ignored) — like a `.gitignore`. |
87
+ | `-S`, `--follow` | Follow symbolic links while recursing with `-r` (they are skipped by default). Each physical directory is visited once, so symlink cycles are safe. |
86
88
  | `-n`, `--line-number` | Prefix each match with its `line:col` locator. Mutually exclusive with `-c` and `-p`. |
87
89
  | `-p`, `--print` | Pretty-print the matched node's HTML, re-indented from scratch (works on minified input). No `line:col` locator is shown. |
88
- | `--attr <name>` | Print the value of attribute `<name>` for each match (nodes without it are skipped). Honors `-n` and `-w`. |
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`. |
89
91
  | `--text` | Print the matched node's text content, whitespace collapsed. Honors `-n` and `-w`. |
90
- | `--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. |
91
93
  | `--parent <n>` | Report the `n`-th element ancestor of each match instead of the match itself (de-duplicated). Pairs well with `-p`. |
92
94
  | `-w`, `--max-width <n>` | Truncate the shown line to `n` columns (adds `…`). Value attaches or follows: `-w100`, `-w 100`, `--max-width=100`. |
93
95
  | `-A`, `--after-context <n>` | Print `n` source lines after each match. |
94
96
  | `-B`, `--before-context <n>` | Print `n` source lines before each match. |
95
97
  | `-C`, `--context <n>` | Print `n` source lines before and after each match. |
96
98
  | `-m`, `--max-count <n>` | Stop after `n` matches per file (caps `-c` too). |
97
- | `-M`, `--max-total <n>` | Stop after `n` matches in total across all files. |
98
- | `-c`, `--count` | Print only the match count (per file when relevant). |
99
+ | `-M`, `--max-total <n>` | Stop after `n` matches in total across all files. The budget counts matches, not files — combined with `-l`/`-L`, scanning stops once `n` matches have been seen, which can cut the file list short. |
100
+ | `-c`, `--count` | Print only the match count (per file when relevant, zeros included — `file:0`, like grep). |
99
101
  | `-l`, `--files-with-matches` | Print only the names of files that contain a match. |
100
102
  | `-L`, `--files-without-match` | Print only the names of files with no match. |
101
103
  | `-q`, `--quiet` | Print nothing; exit `0` on the first match, `1` if none. Stops early. |
@@ -103,7 +105,15 @@ locator appears only with `-n`:
103
105
  | `-0`, `--null` | Output a NUL after each file name instead of `:` (or, with `-l`/`-L`, instead of the newline) — pipe to `xargs -0`. |
104
106
  | `-H`, `--with-filename` | Always print the `file:` prefix, even for a single file or stdin. |
105
107
  | `--no-filename` | Never print the `file:` prefix, even when searching multiple files. |
106
- | `--color[=<when>]` | Colorize output: `auto` (default — color only when stdout is a terminal), `always`, or `never`. A bare `--color` means `always`. |
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`. |
107
117
  | `-h`, `--help` | Show help. |
108
118
  | `-V`, `--version` | Print the version and exit. |
109
119
 
@@ -115,7 +125,7 @@ segment), `**` (across `/`), `?` (one non-slash char), and brace alternation
115
125
  like `*.{html,htm}`. A trailing `/` matches directories only; a pattern with a
116
126
  `/` matches against the path, otherwise the basename — gitignore-flavored.
117
127
 
118
- Binary files are detected (a NUL byte or a high ratio of control bytes in the
128
+ Binary input is detected (a NUL byte or a high ratio of control bytes in the
119
129
  first 8 KB) and skipped with a note on stderr — parsing them as HTML is never
120
130
  useful. Suppress the note with `-s` (or `-q`).
121
131
 
@@ -145,6 +155,9 @@ cssgrep 'a' --attr href testdata/links.html # every link target
145
155
  cssgrep 'h1, h2' --text -r src/ # all heading text
146
156
  cssgrep 'img' -l -r . # files that contain an <img>
147
157
 
158
+ # multiple labeled selectors — scrape several fields in one pass
159
+ cssgrep -e 'title=h1' -e 'price=.card .price' --json page.html
160
+
148
161
  # structural context — show the container the match lives in
149
162
  cssgrep '.price' -p --parent 1 testdata/cards.html # pretty-print each price's card
150
163
 
@@ -161,6 +174,119 @@ ancestors are de-duplicated, so `cssgrep '.price' --parent 1 -p` prints each
161
174
  containing card once. It composes with every print mode (`-p`, `--attr`,
162
175
  `--text`, `--json`, or the default line output).
163
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
+
164
290
  ## Vim / Neovim integration
165
291
 
166
292
  The `file:line:col text` format produced by `-n` is `:grep`-compatible. Point
@@ -199,6 +325,53 @@ cp completions/cssgrep.fish ~/.config/fish/completions/
199
325
  (The completions are hand-maintained; keep them in step with `--help` if you add
200
326
  a flag.)
201
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
+
202
375
  ## How it works
203
376
 
204
377
  - [`htmlparser2`](https://github.com/fb55/htmlparser2) parses with
@@ -206,4 +379,6 @@ a flag.)
206
379
  - [`css-select`](https://github.com/fb55/css-select) matches the selector
207
380
  against that DOM (the same engine cheerio uses).
208
381
  - Offsets are converted to 1-based `line:col` via a precomputed line index;
209
- `col` points at the opening `<` of the matched tag.
382
+ `col` points at the opening `<` of the matched tag and counts *bytes* — what
383
+ vim's `grepformat` `%c` expects — so multibyte UTF-8 text earlier on the line
384
+ doesn't skew the cursor.