cssgrep 1.0.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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 matias
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # cssgrep
2
+
3
+ Search HTML by CSS selector and print each match, grep-style — add `-n` to
4
+ report it as `file:line:col`.
5
+
6
+ Unlike most HTML query tools, `cssgrep` tracks the **source position** of every
7
+ matched node — so it works even on minified, single-line HTML, and (with `-n`)
8
+ its output plugs straight into grep-aware editors.
9
+
10
+ ## Install
11
+
12
+ From npm (requires Node ≥ 20.19):
13
+
14
+ ```sh
15
+ npm install -g cssgrep
16
+ ```
17
+
18
+ Or from a clone, for development:
19
+
20
+ ```sh
21
+ npm install # install deps
22
+ npm link # put `cssgrep` on your PATH (optional)
23
+ ```
24
+
25
+ Check the installed version with `cssgrep --version`. A global install also
26
+ puts a man page on your `MANPATH`, so `man cssgrep` works; from a clone, read it
27
+ with `man ./man/cssgrep.1`.
28
+
29
+ ### Standalone binary (no Node required)
30
+
31
+ Each release ships self-contained executables — download the one for your
32
+ platform, `chmod +x`, and run it; no Node install needed. To build them yourself
33
+ you need [Bun](https://bun.sh) (used only as a build tool — the project still
34
+ runs on plain Node):
35
+
36
+ ```sh
37
+ bun --version # https://bun.sh
38
+ npm run build:binaries # writes dist/cssgrep-{linux,darwin}-{x64,arm64} and -windows-x64.exe
39
+ ```
40
+
41
+ Bun cross-compiles all targets from one machine, so this is what CI uses to
42
+ produce release artifacts. `npm run build:linux-x64` (etc.) builds a single
43
+ target.
44
+
45
+ If you'd rather not install Bun, `npm run build:sea` produces a binary using
46
+ [Node's built-in SEA](https://nodejs.org/api/single-executable-applications.html)
47
+ (needs the `esbuild`/`postject` devDependencies). The catch: Node SEA **cannot
48
+ cross-compile** — it embeds the node you run it with, so you only get a binary
49
+ for the current OS and architecture. Bun is the better choice for multi-platform
50
+ releases.
51
+
52
+ ## Usage
53
+
54
+ ```sh
55
+ cssgrep <selector> [file ...] # search the given files
56
+ cssgrep <selector> -r <dir ...> # recurse directories
57
+ cat page.html | cssgrep <selector> # read from stdin
58
+ ```
59
+
60
+ Output, one line per match. Like `grep`, the matched line is printed on its own;
61
+ a `file:` prefix is added when searching multiple files, and the `line:col`
62
+ locator appears only with `-n`:
63
+
64
+ ```
65
+ {line contents} # default; stdin or single file
66
+ {file}:{line contents} # default; multiple files
67
+ {line}:{col} {line contents} # -n; stdin or single file
68
+ {file}:{line}:{col} {line contents} # -n; multiple files
69
+ ```
70
+
71
+ ### Options
72
+
73
+ | Flag | Description |
74
+ |------|-------------|
75
+ | `-r`, `--recursive` | Recurse into directory arguments (defaults to `.` if none given). |
76
+ | `--ext <list>` | Extensions to scan with `-r` (default `html,htm`). Value attaches with `=`: `--ext htm` or `--ext=htm`. |
77
+ | `-i`, `--ignore <glob>` | Skip files/dirs matching `<glob>` while recursing (repeatable). `node_modules`, `*.min.html`, `build/` (dir-only), or path globs like `src/vendor/**`. |
78
+ | `--ignore-file <path>` | Load ignore globs from a file, one per line (`#` comments and blank lines ignored) — like a `.gitignore`. |
79
+ | `-n`, `--line-number` | Prefix each match with its `line:col` locator. Mutually exclusive with `-c` and `-p`. |
80
+ | `-p`, `--print` | Pretty-print the matched node's HTML, re-indented from scratch (works on minified input). No `line:col` locator is shown. |
81
+ | `--attr <name>` | Print the value of attribute `<name>` for each match (nodes without it are skipped). Honors `-n` and `-w`. |
82
+ | `--text` | Print the matched node's text content, whitespace collapsed. Honors `-n` and `-w`. |
83
+ | `--json` | Print one JSON object per match (NDJSON), with `file`, `line`, `col`, `html`, `text`. |
84
+ | `--parent <n>` | Report the `n`-th element ancestor of each match instead of the match itself (de-duplicated). Pairs well with `-p`. |
85
+ | `-w`, `--max-width <n>` | Truncate the shown line to `n` columns (adds `…`). Value attaches or follows: `-w100`, `-w 100`, `--max-width=100`. |
86
+ | `-A`, `--after-context <n>` | Print `n` source lines after each match. |
87
+ | `-B`, `--before-context <n>` | Print `n` source lines before each match. |
88
+ | `-C`, `--context <n>` | Print `n` source lines before and after each match. |
89
+ | `-m`, `--max-count <n>` | Stop after `n` matches per file (caps `-c` too). |
90
+ | `-M`, `--max-total <n>` | Stop after `n` matches in total across all files. |
91
+ | `-c`, `--count` | Print only the match count (per file when relevant). |
92
+ | `-l`, `--files-with-matches` | Print only the names of files that contain a match. |
93
+ | `-L`, `--files-without-match` | Print only the names of files with no match. |
94
+ | `-q`, `--quiet` | Print nothing; exit `0` on the first match, `1` if none. Stops early. |
95
+ | `-0`, `--null` | Output a NUL after each file name instead of `:` (or, with `-l`/`-L`, instead of the newline) — pipe to `xargs -0`. |
96
+ | `--color[=<when>]` | Colorize output: `auto` (default — color only when stdout is a terminal), `always`, or `never`. A bare `--color` means `always`. |
97
+ | `-h`, `--help` | Show help. |
98
+ | `-V`, `--version` | Print the version and exit. |
99
+
100
+ Boolean short flags can be combined into one token (`-rn` is `-r -n`), and a
101
+ value-taking flag may close such a cluster (`-rnw100`).
102
+
103
+ When coloring is on, the matched node is highlighted within its line (grep's
104
+ bold-red); the `file:` prefix and `line:col` locator get their own colors
105
+ (magenta and green, like grep). Plain `-p` prints no color (the whole block is
106
+ the match), but `-p --parent <n>` highlights the original matched node inside
107
+ the printed container, so you can see what matched within its surroundings.
108
+
109
+ Exit status: `0` if any match was found, `1` if none, `2` on error — same
110
+ convention as `grep`.
111
+
112
+ The selector and paths can appear in any order — `cssgrep -r src 'div.a'` and
113
+ `cssgrep 'div.a' -r src` are equivalent. The selector is whichever argument
114
+ doesn't name an existing file or directory.
115
+
116
+ ### Examples
117
+
118
+ ```sh
119
+ cssgrep 'a[href^="https"]' testdata/links.html
120
+ cssgrep 'div.card > h2' -r src/ -w 100
121
+ cssgrep 'form input[required]' templates/*.html -c
122
+ curl -s https://example.com | cssgrep 'p a'
123
+
124
+ # extraction — print attribute values or text instead of source lines
125
+ cssgrep 'a' --attr href testdata/links.html # every link target
126
+ cssgrep 'h1, h2' --text -r src/ # all heading text
127
+ cssgrep 'img' -l -r . # files that contain an <img>
128
+
129
+ # structural context — show the container the match lives in
130
+ cssgrep '.price' -p --parent 1 testdata/cards.html # pretty-print each price's card
131
+
132
+ # ignore noise while recursing
133
+ cssgrep 'script' -r . -i node_modules -i '*.min.html'
134
+ cssgrep 'a' -r . --ignore-file .gitignore
135
+ ```
136
+
137
+ ### Structural context (`--parent`)
138
+
139
+ `--parent <n>` re-targets each match to its `n`-th element ancestor before
140
+ printing — structural context that line-based `-A`/`-B` can't express. Shared
141
+ ancestors are de-duplicated, so `cssgrep '.price' --parent 1 -p` prints each
142
+ containing card once. It composes with every print mode (`-p`, `--attr`,
143
+ `--text`, `--json`, or the default line output).
144
+
145
+ ## Vim / Neovim integration
146
+
147
+ The `file:line:col text` format produced by `-n` is `:grep`-compatible. Point
148
+ `grepprg` at `cssgrep -n -r` and hits land in the quickfix list:
149
+
150
+ ```vim
151
+ set grepprg=cssgrep\ -n\ -r
152
+ set grepformat=%f:%l:%c\ %m
153
+
154
+ " :grep 'div.card' src/ then :copen
155
+ ```
156
+
157
+ For a single buffer's HTML:
158
+
159
+ ```vim
160
+ :cexpr system('cssgrep -n ' . shellescape(input('selector> ')) . ' ' . shellescape(expand('%')))
161
+ ```
162
+
163
+ ## Shell completions
164
+
165
+ Completions for every flag ship in [`completions/`](completions/). Install the
166
+ one for your shell (a global install puts them under
167
+ `"$(npm root -g)/cssgrep/completions/"`):
168
+
169
+ ```sh
170
+ # bash — source it, or copy to your bash-completion completions dir as `cssgrep`
171
+ source completions/cssgrep.bash
172
+
173
+ # zsh — drop `_cssgrep` onto your $fpath, then run compinit
174
+ cp completions/_cssgrep ~/.zsh/completions/ # a dir on your $fpath
175
+
176
+ # fish
177
+ cp completions/cssgrep.fish ~/.config/fish/completions/
178
+ ```
179
+
180
+ (The completions are hand-maintained; keep them in step with `--help` if you add
181
+ a flag.)
182
+
183
+ ## How it works
184
+
185
+ - [`htmlparser2`](https://github.com/fb55/htmlparser2) parses with
186
+ `withStartIndices`, recording each node's byte offset into the source.
187
+ - [`css-select`](https://github.com/fb55/css-select) matches the selector
188
+ against that DOM (the same engine cheerio uses).
189
+ - Offsets are converted to 1-based `line:col` via a precomputed line index;
190
+ `col` points at the opening `<` of the matched tag.
@@ -0,0 +1,35 @@
1
+ #compdef cssgrep
2
+ # zsh completion for cssgrep. Keep in sync with the USAGE string in index.js.
3
+ # Install: place this file (named _cssgrep) in a directory on your $fpath, then
4
+ # ensure `autoload -U compinit && compinit` runs in your ~/.zshrc.
5
+
6
+ _cssgrep() {
7
+ _arguments -s -S \
8
+ '(-r --recursive)'{-r,--recursive}'[recurse into directory arguments]' \
9
+ '--ext[extensions to scan with -r]:extensions (comma-separated):' \
10
+ '*'{-i,--ignore}'[skip files/dirs matching glob while recursing]:glob:' \
11
+ '--ignore-file[read ignore globs from a file]:file:_files' \
12
+ '(-n --line-number)'{-n,--line-number}'[prefix each match with line:col]' \
13
+ '(-p --print)'{-p,--print}'[pretty-print the matched node HTML]' \
14
+ '--attr[print the value of an attribute]:attribute name:' \
15
+ '--text[print the matched node text content]' \
16
+ '--json[print one JSON object per match (NDJSON)]' \
17
+ '--parent[report the n-th ancestor of each match]:depth:' \
18
+ '(-w --max-width)'{-w,--max-width}'[truncate shown line to n columns]:columns:' \
19
+ '(-A --after-context)'{-A,--after-context}'[print n lines after each match]:lines:' \
20
+ '(-B --before-context)'{-B,--before-context}'[print n lines before each match]:lines:' \
21
+ '(-C --context)'{-C,--context}'[print n lines around each match]:lines:' \
22
+ '(-m --max-count)'{-m,--max-count}'[stop after n matches per file]:count:' \
23
+ '(-M --max-total)'{-M,--max-total}'[stop after n matches in total]:count:' \
24
+ '(-c --count)'{-c,--count}'[print only a count of matches]' \
25
+ '(-l --files-with-matches)'{-l,--files-with-matches}'[print only names of files with a match]' \
26
+ '(-L --files-without-match)'{-L,--files-without-match}'[print only names of files without a match]' \
27
+ '(-q --quiet)'{-q,--quiet}'[print nothing; exit on first match]' \
28
+ '(-0 --null)'{-0,--null}'[separate the file name with a NUL byte]' \
29
+ '--color=-[colorize output]::when:(auto always never)' \
30
+ '(-h --help)'{-h,--help}'[show help and exit]' \
31
+ '(-V --version)'{-V,--version}'[show version and exit]' \
32
+ '*:file:_files'
33
+ }
34
+
35
+ _cssgrep "$@"
@@ -0,0 +1,40 @@
1
+ # bash completion for cssgrep. Keep the flag list in sync with the USAGE string
2
+ # in index.js. Install: `source` this file from your ~/.bashrc, or drop it into
3
+ # your bash-completion completions directory renamed to `cssgrep`.
4
+ _cssgrep() {
5
+ local cur prev opts
6
+ cur="${COMP_WORDS[COMP_CWORD]}"
7
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
8
+
9
+ opts="-r --recursive --ext -i --ignore --ignore-file -n --line-number \
10
+ -p --print --attr --text --json --parent -w --max-width \
11
+ -A --after-context -B --before-context -C --context \
12
+ -m --max-count -M --max-total -c --count \
13
+ -l --files-with-matches -L --files-without-match -q --quiet -0 --null \
14
+ --color -h --help -V --version"
15
+
16
+ case "$prev" in
17
+ --color)
18
+ COMPREPLY=( $(compgen -W "auto always never" -- "$cur") )
19
+ return 0
20
+ ;;
21
+ --ignore-file)
22
+ COMPREPLY=( $(compgen -f -- "$cur") )
23
+ return 0
24
+ ;;
25
+ --ext|-i|--ignore|--attr|--parent|-w|--max-width|-A|--after-context|\
26
+ -B|--before-context|-C|--context|-m|--max-count|-M|--max-total)
27
+ # value-taking flag with no enumerable completion; offer nothing
28
+ return 0
29
+ ;;
30
+ esac
31
+
32
+ if [[ "$cur" == -* ]]; then
33
+ COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
34
+ return 0
35
+ fi
36
+
37
+ COMPREPLY=( $(compgen -f -- "$cur") )
38
+ return 0
39
+ }
40
+ complete -F _cssgrep cssgrep
@@ -0,0 +1,27 @@
1
+ # fish completion for cssgrep. Keep in sync with the USAGE string in index.js.
2
+ # Install: place this file in ~/.config/fish/completions/ (named cssgrep.fish).
3
+
4
+ complete -c cssgrep -s r -l recursive -d 'Recurse into directory arguments'
5
+ complete -c cssgrep -l ext -x -d 'Extensions to scan with -r (comma-separated)'
6
+ complete -c cssgrep -s i -l ignore -x -d 'Skip files/dirs matching glob while recursing'
7
+ complete -c cssgrep -l ignore-file -r -F -d 'Read ignore globs from a file'
8
+ complete -c cssgrep -s n -l line-number -d 'Prefix each match with line:col'
9
+ complete -c cssgrep -s p -l print -d 'Pretty-print the matched node HTML'
10
+ complete -c cssgrep -l attr -x -d 'Print the value of an attribute'
11
+ complete -c cssgrep -l text -d 'Print the matched node text content'
12
+ complete -c cssgrep -l json -d 'Print one JSON object per match (NDJSON)'
13
+ complete -c cssgrep -l parent -x -d 'Report the n-th ancestor of each match'
14
+ complete -c cssgrep -s w -l max-width -x -d 'Truncate shown line to n columns'
15
+ complete -c cssgrep -s A -l after-context -x -d 'Print n lines after each match'
16
+ complete -c cssgrep -s B -l before-context -x -d 'Print n lines before each match'
17
+ complete -c cssgrep -s C -l context -x -d 'Print n lines around each match'
18
+ complete -c cssgrep -s m -l max-count -x -d 'Stop after n matches per file'
19
+ complete -c cssgrep -s M -l max-total -x -d 'Stop after n matches in total'
20
+ complete -c cssgrep -s c -l count -d 'Print only a count of matches'
21
+ complete -c cssgrep -s l -l files-with-matches -d 'Print only names of files with a match'
22
+ complete -c cssgrep -s L -l files-without-match -d 'Print only names of files without a match'
23
+ complete -c cssgrep -s q -l quiet -d 'Print nothing; exit on first match'
24
+ complete -c cssgrep -s 0 -l null -d 'Separate the file name with a NUL byte'
25
+ complete -c cssgrep -l color -x -a 'auto always never' -d 'Colorize output'
26
+ complete -c cssgrep -s h -l help -d 'Show help and exit'
27
+ complete -c cssgrep -s V -l version -d 'Show version and exit'
package/index.js ADDED
@@ -0,0 +1,738 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { parseDocument } = require('htmlparser2');
7
+ const { selectAll } = require('css-select');
8
+ const render = require('dom-serializer').default;
9
+ const { html: beautify } = require('js-beautify');
10
+
11
+ // Single source of truth for the version. A constant rather than a read of
12
+ // package.json, so it survives compilation into a standalone binary (Bun
13
+ // --compile / Node SEA), where package.json won't sit next to the executable.
14
+ // Keep in sync with package.json on release.
15
+ const VERSION = '1.0.0';
16
+
17
+ const USAGE = `cssgrep - search HTML by CSS selector, grep-style.
18
+
19
+ Usage:
20
+ cssgrep <selector> [file ...]
21
+ cssgrep <selector> -r <dir ...>
22
+ cat file.html | cssgrep <selector>
23
+
24
+ Output (one line per match):
25
+ {line contents} (default; stdin or single file)
26
+ {file}:{line contents} (default; multiple files)
27
+ {line}:{col} {line contents} (with -n; stdin or single file)
28
+ {file}:{line}:{col} {line contents} (with -n; multiple files)
29
+
30
+ Options:
31
+ -r, --recursive Recurse into directory arguments.
32
+ --ext <list> Comma-separated extensions for -r (default: html,htm).
33
+ -i, --ignore <glob> Skip files/dirs matching <glob> when recursing (repeatable).
34
+ --ignore-file <path> Read ignore globs from <path> (one per line, # comments).
35
+ -n, --line-number Prefix each match with its line:col (excludes -c, -p).
36
+ -p, --print Pretty-print the matched node's HTML above its location.
37
+ --attr <name> Print the value of attribute <name> (skips nodes without it).
38
+ --text Print the matched node's text content (whitespace collapsed).
39
+ --json Print one JSON record per match (NDJSON: file,line,col,html,text).
40
+ --parent <n> Report the n-th ancestor of each match instead (dedup'd).
41
+ -w, --max-width <n> Truncate the shown line to <n> columns (ellipsis added).
42
+ -A, --after-context <n> Print <n> source lines after each match.
43
+ -B, --before-context <n> Print <n> source lines before each match.
44
+ -C, --context <n> Print <n> source lines before and after each match.
45
+ -m, --max-count <n> Stop after <n> matches per file.
46
+ -M, --max-total <n> Stop after <n> matches in total (across all files).
47
+ -c, --count Print only a count of matches (per file when relevant).
48
+ -l, --files-with-matches Print only the names of files that have a match.
49
+ -L, --files-without-match Print only the names of files with no match.
50
+ -q, --quiet Print nothing; exit 0 on first match, 1 if none.
51
+ -0, --null Separate the file name with a NUL byte (for xargs -0).
52
+ --color[=<when>] Colorize output: auto (default), always or never.
53
+ -h, --help Show this help.
54
+ -V, --version Show version and exit.
55
+
56
+ Short flags combine (-rn) and a value attaches to its flag (-w100) or follows it
57
+ (-w 100); a value-taking flag may close a cluster (-rnw100). Long options take a
58
+ value with = or as the next word (--max-width=100, --ext htm).
59
+
60
+ Exit status: 0 if any match was found, 1 if none, 2 on error.`;
61
+
62
+ function fail(msg) {
63
+ process.stderr.write(`cssgrep: ${msg}\n`);
64
+ process.exit(2);
65
+ }
66
+
67
+ // ANSI SGR codes matching grep's default scheme: bold-red match, magenta
68
+ // filename, green line/col numbers, cyan separators.
69
+ const COLORS = {
70
+ match: '1;31',
71
+ file: '35',
72
+ line: '32',
73
+ sep: '36',
74
+ };
75
+
76
+ function paint(code, str) {
77
+ return `\x1b[${code}m${str}\x1b[0m`;
78
+ }
79
+
80
+ function parseArgs(argv) {
81
+ const opts = {
82
+ selector: null,
83
+ positionals: [],
84
+ paths: [],
85
+ recursive: false,
86
+ exts: ['html', 'htm'],
87
+ lineNumber: false,
88
+ print: false,
89
+ maxWidth: 0,
90
+ maxCount: 0,
91
+ maxTotal: 0,
92
+ count: false,
93
+ filesWithMatches: false,
94
+ filesWithoutMatch: false,
95
+ quiet: false,
96
+ attr: null,
97
+ text: false,
98
+ json: false,
99
+ nul: false,
100
+ parent: 0,
101
+ before: 0,
102
+ after: 0,
103
+ ignore: [],
104
+ color: 'auto',
105
+ };
106
+ const setExts = v => {
107
+ opts.exts = (v || '').split(',').map(s => s.trim().replace(/^\./, '')).filter(Boolean);
108
+ };
109
+ const boundedInt = (v, what, min) => {
110
+ const n = parseInt(v, 10);
111
+ if (!Number.isFinite(n) || n < min) fail(`invalid ${what} value`);
112
+ return n;
113
+ };
114
+ const positiveInt = (v, what) => boundedInt(v, what, 1);
115
+ const setMaxWidth = v => { opts.maxWidth = positiveInt(v, '--max-width'); };
116
+ const setMaxCount = v => { opts.maxCount = positiveInt(v, '--max-count'); };
117
+ const setMaxTotal = v => { opts.maxTotal = positiveInt(v, '--max-total'); };
118
+ const setParent = v => { opts.parent = positiveInt(v, '--parent'); };
119
+ const setAfter = v => { opts.after = boundedInt(v, '--after-context', 0); };
120
+ const setBefore = v => { opts.before = boundedInt(v, '--before-context', 0); };
121
+ const setContext = v => { opts.after = opts.before = boundedInt(v, '--context', 0); };
122
+ const addIgnore = v => { const c = compileIgnore(v); if (c) opts.ignore.push(c); };
123
+ const addIgnoreFile = v => {
124
+ let content;
125
+ try { content = fs.readFileSync(v, 'utf8'); }
126
+ catch (e) { fail(`cannot read ignore file: ${v}`); }
127
+ for (const line of content.split('\n')) { const c = compileIgnore(line); if (c) opts.ignore.push(c); }
128
+ };
129
+ // Short flags that take a value (rest of the cluster, or the next argument).
130
+ const shortValueFlags = {
131
+ w: setMaxWidth, m: setMaxCount, M: setMaxTotal, A: setAfter, B: setBefore, C: setContext,
132
+ i: addIgnore,
133
+ };
134
+ for (let i = 0; i < argv.length; i++) {
135
+ const a = argv[i];
136
+
137
+ // Long options: --name or --name=value. A missing inline value is taken
138
+ // from the next argument (except --color, where a bare flag means "always").
139
+ if (a.startsWith('--') && a.length > 2) {
140
+ const eq = a.indexOf('=');
141
+ const name = eq === -1 ? a : a.slice(0, eq);
142
+ const inline = eq === -1 ? null : a.slice(eq + 1);
143
+ const value = () => (inline != null ? inline : argv[++i]);
144
+ switch (name) {
145
+ case '--help': process.stdout.write(USAGE + '\n'); process.exit(0); break;
146
+ case '--version': process.stdout.write(`cssgrep ${VERSION}\n`); process.exit(0); break;
147
+ case '--recursive': opts.recursive = true; break;
148
+ case '--line-number': opts.lineNumber = true; break;
149
+ case '--print': opts.print = true; break;
150
+ case '--count': opts.count = true; break;
151
+ case '--files-with-matches': opts.filesWithMatches = true; break;
152
+ case '--files-without-match': opts.filesWithoutMatch = true; break;
153
+ case '--quiet': case '--silent': opts.quiet = true; break;
154
+ case '--null': opts.nul = true; break;
155
+ case '--ext': setExts(value()); break;
156
+ case '--ignore': addIgnore(value()); break;
157
+ case '--ignore-file': addIgnoreFile(value()); break;
158
+ case '--max-width': setMaxWidth(value()); break;
159
+ case '--max-count': setMaxCount(value()); break;
160
+ case '--max-total': setMaxTotal(value()); break;
161
+ case '--attr': opts.attr = value(); break;
162
+ case '--text': opts.text = true; break;
163
+ case '--json': opts.json = true; break;
164
+ case '--parent': setParent(value()); break;
165
+ case '--after-context': setAfter(value()); break;
166
+ case '--before-context': setBefore(value()); break;
167
+ case '--context': setContext(value()); break;
168
+ case '--color': case '--colour': opts.color = inline != null ? inline : 'always'; break;
169
+ default: fail(`unknown option: ${name}`);
170
+ }
171
+ continue;
172
+ }
173
+
174
+ // Short options, getopt-style: flags cluster (`-rn`) and a value-taking
175
+ // option consumes the rest of the cluster (`-w100`) or the next argument
176
+ // (`-w 100`), which means it must come last in a cluster (`-rnw100`).
177
+ if (a.startsWith('-') && a !== '-') {
178
+ for (let j = 1; j < a.length; j++) {
179
+ const ch = a[j];
180
+ if (shortValueFlags[ch]) {
181
+ const rest = a.slice(j + 1); // attached value, if any
182
+ shortValueFlags[ch](rest !== '' ? rest : argv[++i]);
183
+ break; // value swallowed the cluster tail
184
+ }
185
+ switch (ch) {
186
+ case 'h': process.stdout.write(USAGE + '\n'); process.exit(0); break;
187
+ case 'V': process.stdout.write(`cssgrep ${VERSION}\n`); process.exit(0); break;
188
+ case 'r': opts.recursive = true; break;
189
+ case 'n': opts.lineNumber = true; break;
190
+ case 'p': opts.print = true; break;
191
+ case 'c': opts.count = true; break;
192
+ case 'l': opts.filesWithMatches = true; break;
193
+ case 'L': opts.filesWithoutMatch = true; break;
194
+ case 'q': opts.quiet = true; break;
195
+ case '0': case 'Z': opts.nul = true; break;
196
+ default: fail(`unknown option: -${ch}`);
197
+ }
198
+ }
199
+ continue;
200
+ }
201
+
202
+ opts.positionals.push(a);
203
+ }
204
+ if (opts.positionals.length === 0) fail('no selector given (try --help)');
205
+ // Aggregate modes each suppress per-match output, so at most one may apply.
206
+ const aggregates = [opts.count, opts.filesWithMatches, opts.filesWithoutMatch, opts.quiet]
207
+ .filter(Boolean).length;
208
+ if (aggregates > 1) fail('only one of -c, -l, -L, -q may be given');
209
+ // Per-match print modes choose what is printed for each match; only one.
210
+ const printModes = [opts.print, opts.attr != null, opts.text, opts.json].filter(Boolean).length;
211
+ if (printModes > 1) fail('only one of -p, --attr, --text, --json may be given');
212
+ // Context surrounds source lines, so it only makes sense for the default line
213
+ // output — not the print modes or the content-suppressing aggregate modes.
214
+ if (opts.before > 0 || opts.after > 0) {
215
+ if (printModes > 0) fail('context (-A/-B/-C) cannot be combined with -p/--attr/--text/--json');
216
+ if (aggregates > 0) fail('context (-A/-B/-C) cannot be combined with -c/-l/-L/-q');
217
+ }
218
+ if (opts.lineNumber && opts.count) fail('-n cannot be combined with -c');
219
+ if (opts.lineNumber && opts.print) fail('-n cannot be combined with -p');
220
+ if (!['auto', 'always', 'never'].includes(opts.color)) {
221
+ fail(`invalid --color value: ${opts.color} (expected auto, always or never)`);
222
+ }
223
+ // Resolve the tri-state into a single boolean: color only when forced on, or
224
+ // 'auto' and stdout is an interactive terminal. Plain -p still prints no
225
+ // color (it has nothing to highlight) — that's gated where it prints, not
226
+ // here, so --parent -p can highlight the matched node inside the container.
227
+ opts.colorOn = opts.color === 'always' || (opts.color === 'auto' && Boolean(process.stdout.isTTY));
228
+ return opts;
229
+ }
230
+
231
+ // The selector and the path list share the same positional slots, so their
232
+ // order on the command line is ambiguous (`<selector> <dir>` vs `<dir>
233
+ // <selector>`). Resolve it by what actually exists on disk: paths are the
234
+ // positionals that name a real file/dir, and the selector is the one that
235
+ // doesn't. This makes argument order not matter (important for vim's
236
+ // `grepprg`, which appends args in its own order). Fall back to
237
+ // "selector is the first positional" when the split is unclear.
238
+ function resolveSelectorAndPaths(opts) {
239
+ const pos = opts.positionals;
240
+ const onDisk = [];
241
+ const notOnDisk = [];
242
+ for (const p of pos) {
243
+ if (fs.statSync(p, { throwIfNoEntry: false })) onDisk.push(p);
244
+ else notOnDisk.push(p);
245
+ }
246
+ if (notOnDisk.length === 1) {
247
+ opts.selector = notOnDisk[0];
248
+ opts.paths = onDisk;
249
+ } else {
250
+ // 0 non-existent (selector also happens to name a file) or several
251
+ // (a mistyped path, reported later): keep positional order.
252
+ opts.selector = pos[0];
253
+ opts.paths = pos.slice(1);
254
+ }
255
+ }
256
+
257
+ // Precompute the byte offset at which each line starts, so offset->line/col
258
+ // is a binary search rather than a re-scan per match.
259
+ function lineIndex(src) {
260
+ const starts = [0];
261
+ for (let i = 0; i < src.length; i++) {
262
+ if (src.charCodeAt(i) === 10 /* \n */) starts.push(i + 1);
263
+ }
264
+ return starts;
265
+ }
266
+
267
+ function offsetToPosition(starts, src, offset) {
268
+ // binary search for the greatest line start <= offset
269
+ let lo = 0, hi = starts.length - 1;
270
+ while (lo < hi) {
271
+ const mid = (lo + hi + 1) >> 1;
272
+ if (starts[mid] <= offset) lo = mid;
273
+ else hi = mid - 1;
274
+ }
275
+ const lineStart = starts[lo];
276
+ let lineEnd = src.indexOf('\n', lineStart);
277
+ if (lineEnd === -1) lineEnd = src.length;
278
+ // strip a trailing \r so CRLF files render cleanly
279
+ let text = src.slice(lineStart, lineEnd);
280
+ if (text.endsWith('\r')) text = text.slice(0, -1);
281
+ return {
282
+ line: lo + 1, // 1-based
283
+ col: offset - lineStart + 1, // 1-based
284
+ text,
285
+ };
286
+ }
287
+
288
+ // Text of a 1-based line number, with the trailing \r stripped (CRLF), plus the
289
+ // line's byte start (so a node's offset maps to a column within it).
290
+ function lineTextAt(starts, src, lineNo) {
291
+ const lineStart = starts[lineNo - 1];
292
+ let lineEnd = src.indexOf('\n', lineStart);
293
+ if (lineEnd === -1) lineEnd = src.length;
294
+ let text = src.slice(lineStart, lineEnd);
295
+ if (text.endsWith('\r')) text = text.slice(0, -1);
296
+ return { lineStart, text };
297
+ }
298
+
299
+ function truncate(text, maxWidth) {
300
+ if (!maxWidth || text.length <= maxWidth) return text;
301
+ if (maxWidth <= 1) return text.slice(0, maxWidth);
302
+ return text.slice(0, maxWidth - 1) + '…'; // …
303
+ }
304
+
305
+ // Produce the visible match text: truncate first (so --max-width still measures
306
+ // real columns, never escape sequences), then — when coloring — wrap the part
307
+ // of the matched node that falls on this line in the match color. A node can
308
+ // span multiple lines while we only print its opening line, so the highlight is
309
+ // clamped to what's visible; a match scrolled past the truncation point isn't
310
+ // highlighted at all (and the trailing ellipsis is never colored).
311
+ function renderText(pos, off, nodeEnd, opts) {
312
+ const vis = truncate(pos.text, opts.maxWidth);
313
+ if (!opts.colorOn) return vis;
314
+ const truncated = opts.maxWidth && pos.text.length > opts.maxWidth;
315
+ const effLen = truncated ? Math.max(0, opts.maxWidth - 1) : vis.length;
316
+ let s = pos.col - 1; // match start within the line (0-based)
317
+ let e = s + (nodeEnd - off); // match end within the line
318
+ s = Math.max(0, Math.min(s, effLen));
319
+ e = Math.max(s, Math.min(e, effLen));
320
+ if (e <= s) return vis; // nothing of the match is visible
321
+ return vis.slice(0, s) + paint(COLORS.match, vis.slice(s, e)) + vis.slice(e);
322
+ }
323
+
324
+ // Concatenate the text of a node and all its descendants (dependency-free,
325
+ // rather than pulling in domutils). Used by --text.
326
+ function textOf(node) {
327
+ if (node.type === 'text') return node.data || '';
328
+ if (!node.children) return '';
329
+ let s = '';
330
+ for (const child of node.children) s += textOf(child);
331
+ return s;
332
+ }
333
+
334
+ const collapseWs = s => s.replace(/\s+/g, ' ').trim();
335
+
336
+ const isElement = n => n && (n.type === 'tag' || n.type === 'script' || n.type === 'style');
337
+
338
+ // Climb n element levels from el, clamping at the document root.
339
+ function ancestor(el, n) {
340
+ let node = el;
341
+ for (let k = 0; k < n; k++) {
342
+ if (!isElement(node.parent)) break;
343
+ node = node.parent;
344
+ }
345
+ return node;
346
+ }
347
+
348
+ // --parent re-points each match to its n-th ancestor; dedup by identity so a
349
+ // shared container is reported once, preserving first-seen order.
350
+ function retarget(nodes, opts) {
351
+ if (!opts.parent) return nodes;
352
+ const seen = new Set();
353
+ const result = [];
354
+ for (const el of nodes) {
355
+ const a = ancestor(el, opts.parent);
356
+ if (!seen.has(a)) { seen.add(a); result.push(a); }
357
+ }
358
+ return result;
359
+ }
360
+
361
+ // Sentinels marking where a highlighted node begins/ends. They are injected as
362
+ // HTML *comment* nodes (not text) so js-beautify keeps the surrounding block
363
+ // layout — text markers would make adjacent block elements collapse inline.
364
+ // The private-use payload can't occur in real content, so it never collides.
365
+ // After beautifying, each `<!--…-->` marker is swapped for an ANSI code.
366
+ const HL_START = '';
367
+ const HL_END = '';
368
+
369
+ // Re-indent a matched node's HTML from scratch (so minified input still comes
370
+ // out readable). dom-serializer turns the parsed node back into a string;
371
+ // js-beautify does the formatting. When `origins` (descendant nodes to
372
+ // highlight) is given and coloring is on, those nodes are wrapped in the match
373
+ // color within the printed block.
374
+ function prettyPrint(el, origins, opts) {
375
+ const highlight = origins && origins.length && opts && opts.colorOn;
376
+ const inserted = [];
377
+ if (highlight) {
378
+ // Bracket each origin with sentinel comment nodes among its siblings.
379
+ for (const m of origins) {
380
+ if (!m.parent) continue;
381
+ const kids = m.parent.children;
382
+ const i = kids.indexOf(m);
383
+ if (i < 0) continue;
384
+ const start = { type: 'comment', data: HL_START };
385
+ const end = { type: 'comment', data: HL_END };
386
+ kids.splice(i, 0, start);
387
+ kids.splice(i + 2, 0, end);
388
+ inserted.push({ kids, start, end });
389
+ }
390
+ }
391
+ let html;
392
+ try {
393
+ html = beautify(render(el, { encodeEntities: false }), {
394
+ indent_size: 2,
395
+ wrap_line_length: 0, // never wrap long lines (e.g. long text/attributes)
396
+ preserve_newlines: false,
397
+ });
398
+ } finally {
399
+ // Always restore the DOM so the markers don't leak into later matches.
400
+ for (const { kids, start, end } of inserted) {
401
+ let k = kids.indexOf(start); if (k >= 0) kids.splice(k, 1);
402
+ k = kids.indexOf(end); if (k >= 0) kids.splice(k, 1);
403
+ }
404
+ }
405
+ if (!highlight) return html;
406
+ const startCode = `\x1b[${COLORS.match}m`;
407
+ const resetCode = '\x1b[0m';
408
+ html = html
409
+ .replace(new RegExp(`<!--\\s*${HL_START}\\s*-->`, 'g'), startCode)
410
+ .replace(new RegExp(`<!--\\s*${HL_END}\\s*-->`, 'g'), resetCode);
411
+ return foldStandaloneCodes(html, startCode, resetCode);
412
+ }
413
+
414
+ // beautify sometimes parks a marker comment on its own line (e.g. when the
415
+ // matched node followed source whitespace), leaving a line of just indent + a
416
+ // zero-width ANSI code — a spurious blank line. Fold such a code onto the
417
+ // adjacent content line: a start code onto the next line, a reset onto the
418
+ // previous one.
419
+ function foldStandaloneCodes(html, startCode, resetCode) {
420
+ const lines = html.split('\n');
421
+ const out = [];
422
+ for (let i = 0; i < lines.length; i++) {
423
+ const m = lines[i].match(/^([ \t]*)(\x1b\[[0-9;]*m)[ \t]*$/);
424
+ if (m) {
425
+ const code = m[2];
426
+ if (code === startCode && i + 1 < lines.length) {
427
+ lines[i + 1] = lines[i + 1].replace(/^[ \t]*/, ws => ws + startCode);
428
+ continue; // drop the standalone line
429
+ }
430
+ if (code === resetCode && out.length) {
431
+ out[out.length - 1] += resetCode;
432
+ continue;
433
+ }
434
+ }
435
+ out.push(lines[i]);
436
+ }
437
+ return out.join('\n');
438
+ }
439
+
440
+ // -A/-B/-C: emit each match line plus its surrounding context lines, grep-style.
441
+ // Match lines use `:` field separators and keep the match-node highlight;
442
+ // context lines use `-` and no highlight. Overlapping context windows merge,
443
+ // and `--` separates non-contiguous groups.
444
+ function emitContext(src, starts, name, showLabel, targets, opts, out) {
445
+ const label = showLabel ? name : null;
446
+ const c = opts.colorOn ? paint : (_, s) => s;
447
+ const lineCount = src.length === 0 ? 0 : (src.endsWith('\n') ? starts.length - 1 : starts.length);
448
+
449
+ // Map each match line to a representative node span (the first match on it),
450
+ // which drives the in-line highlight when coloring.
451
+ const info = new Map();
452
+ for (const el of targets) {
453
+ const off = el.startIndex == null ? 0 : el.startIndex;
454
+ const pos = offsetToPosition(starts, src, off);
455
+ if (info.has(pos.line)) continue;
456
+ const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
457
+ info.set(pos.line, { off, nodeEnd, pos });
458
+ }
459
+
460
+ // Expand match lines to [L-before, L+after] windows and merge adjacent ones.
461
+ const ranges = [];
462
+ for (const L of [...info.keys()].sort((a, b) => a - b)) {
463
+ const lo = Math.max(1, L - opts.before);
464
+ const hi = Math.min(lineCount, L + opts.after);
465
+ const last = ranges[ranges.length - 1];
466
+ if (last && lo <= last.hi + 1) last.hi = Math.max(last.hi, hi);
467
+ else ranges.push({ lo, hi });
468
+ }
469
+
470
+ let firstGroup = true;
471
+ for (const { lo, hi } of ranges) {
472
+ if (!firstGroup) out.push('--');
473
+ firstGroup = false;
474
+ for (let L = lo; L <= hi; L++) {
475
+ const m = info.get(L);
476
+ const sepColored = c(COLORS.sep, m ? ':' : '-');
477
+ let prefix = '';
478
+ if (label) prefix += c(COLORS.file, label) + (opts.nul ? '\0' : sepColored);
479
+ if (opts.lineNumber) {
480
+ prefix += c(COLORS.line, String(L));
481
+ prefix += m ? c(COLORS.sep, ':') + c(COLORS.line, String(m.pos.col)) + ' '
482
+ : c(COLORS.sep, '-');
483
+ }
484
+ const body = m
485
+ ? renderText(m.pos, m.off, m.nodeEnd, opts) // highlight + truncate
486
+ : truncate(lineTextAt(starts, src, L).text, opts.maxWidth);
487
+ out.push(prefix + body);
488
+ }
489
+ }
490
+ }
491
+
492
+ // `name` is the source's display name (file path, or "(standard input)"); it is
493
+ // used by the aggregate modes (-l/-L/-c). `showLabel` decides whether per-match
494
+ // lines carry a `file:` prefix (only when more than one file is searched).
495
+ function searchSource(src, name, showLabel, opts, out, limit = Infinity) {
496
+ const dom = parseDocument(src, {
497
+ withStartIndices: true,
498
+ withEndIndices: true,
499
+ });
500
+ const matches = selectAll(opts.selector, dom);
501
+ const found = matches.length;
502
+ // Aggregate modes suppress per-match output entirely.
503
+ if (opts.quiet) return found; // status only
504
+ if (opts.filesWithMatches) { if (found) out.push(name); return found; }
505
+ if (opts.filesWithoutMatch) { if (!found) out.push(name); return found; }
506
+
507
+ const label = showLabel ? name : null;
508
+ // -m/--max-count caps matches per source; `limit` is the remaining global
509
+ // budget from -M/--max-total (Infinity when neither applies).
510
+ const cap = Math.min(opts.maxCount || Infinity, limit);
511
+ const limited = Number.isFinite(cap) ? matches.slice(0, cap) : matches;
512
+ if (opts.count) {
513
+ if (limited.length) {
514
+ const fileSep = opts.nul ? '\0' : ':';
515
+ out.push(label ? `${label}${fileSep}${limited.length}` : String(limited.length));
516
+ }
517
+ return limited.length;
518
+ }
519
+ const starts = opts.print ? null : lineIndex(src);
520
+ // --parent re-targets matches to ancestors (no-op without it). Aggregate
521
+ // modes above operate on the raw matches; targeting only affects what prints.
522
+ const targets = retarget(limited, opts);
523
+ // For --parent + -p, remember which original matches sit under each ancestor
524
+ // so they can be highlighted inside the printed container.
525
+ const originsByTarget = new Map();
526
+ if (opts.parent && opts.print) {
527
+ for (const el of limited) {
528
+ const a = ancestor(el, opts.parent);
529
+ if (!originsByTarget.has(a)) originsByTarget.set(a, []);
530
+ originsByTarget.get(a).push(el);
531
+ }
532
+ }
533
+ if (opts.before > 0 || opts.after > 0) {
534
+ emitContext(src, starts, name, showLabel, targets, opts, out);
535
+ return limited.length;
536
+ }
537
+ if (opts.json) {
538
+ // NDJSON: one self-contained record per match. `html` is the exact source
539
+ // slice; newlines are escaped by JSON.stringify, so each record stays on
540
+ // one line. Ignores --color and -n (line/col are always present).
541
+ for (const el of targets) {
542
+ const off = el.startIndex == null ? 0 : el.startIndex;
543
+ const pos = offsetToPosition(starts, src, off);
544
+ const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1;
545
+ out.push(JSON.stringify({
546
+ file: name,
547
+ line: pos.line,
548
+ col: pos.col,
549
+ html: src.slice(off, nodeEnd),
550
+ text: collapseWs(textOf(el)),
551
+ }));
552
+ }
553
+ return limited.length;
554
+ }
555
+ for (const el of targets) {
556
+ if (opts.print) {
557
+ // -p shows the re-indented node only; no line:col locator. With --parent,
558
+ // the original matched descendants are highlighted inside the container.
559
+ out.push(prettyPrint(el, originsByTarget.get(el), opts), ''); // blank separator
560
+ continue;
561
+ }
562
+ const off = el.startIndex == null ? 0 : el.startIndex;
563
+ const pos = offsetToPosition(starts, src, off);
564
+ const c = opts.colorOn ? paint : (_, s) => s;
565
+
566
+ // Choose the content printed for this match. --attr/--text replace the
567
+ // source line with the extracted value (whole value highlighted as the
568
+ // match); both honor -w truncation. Nodes lacking the attribute are skipped.
569
+ let text;
570
+ if (opts.attr != null) {
571
+ if (!el.attribs || !(opts.attr in el.attribs)) continue;
572
+ text = c(COLORS.match, truncate(el.attribs[opts.attr], opts.maxWidth));
573
+ } else if (opts.text) {
574
+ text = c(COLORS.match, truncate(collapseWs(textOf(el)), opts.maxWidth));
575
+ } else {
576
+ const nodeEnd = (el.endIndex == null ? off : el.endIndex) + 1; // exclusive
577
+ text = renderText(pos, off, nodeEnd, opts);
578
+ }
579
+ // grep-style: a `file:` prefix appears with multiple files; the line:col
580
+ // locator only with -n. The locator is separated from the text by a space
581
+ // so the output stays :grep-compatible (grepformat %f:%l:%c %m).
582
+ const sep = c(COLORS.sep, ':');
583
+ // -0/--null: the char after the file name becomes NUL (grep -Z), for
584
+ // unambiguous machine parsing (e.g. xargs -0).
585
+ const fileSep = opts.nul ? '\0' : sep;
586
+ let prefix = '';
587
+ if (label) prefix += c(COLORS.file, label) + fileSep;
588
+ if (opts.lineNumber) {
589
+ prefix += c(COLORS.line, String(pos.line)) + sep + c(COLORS.line, String(pos.col)) + ' ';
590
+ }
591
+ out.push(prefix + text);
592
+ }
593
+ return limited.length;
594
+ }
595
+
596
+ // Translate a glob to a regex body. `*` matches within a path segment, `**`
597
+ // across segments, `?` a single non-slash char; everything else is literal.
598
+ function globToRegex(glob) {
599
+ let re = '';
600
+ for (let i = 0; i < glob.length; i++) {
601
+ const c = glob[i];
602
+ if (c === '*') {
603
+ if (glob[i + 1] === '*') { // ** (crosses /)
604
+ re += '.*';
605
+ i++;
606
+ if (glob[i + 1] === '/') i++; // consume the slash in **/
607
+ } else {
608
+ re += '[^/]*';
609
+ }
610
+ } else if (c === '?') {
611
+ re += '[^/]';
612
+ } else if (/[.+^${}()|[\]\\]/.test(c)) {
613
+ re += '\\' + c;
614
+ } else {
615
+ re += c;
616
+ }
617
+ }
618
+ return re;
619
+ }
620
+
621
+ // Compile one ignore pattern (gitignore-flavored). A trailing `/` makes it
622
+ // match directories only; a pattern containing `/` matches against the path
623
+ // (anchored at a segment boundary), otherwise against the basename.
624
+ function compileIgnore(pattern) {
625
+ const p = pattern.trim().replace(/\/$/, '');
626
+ if (!p || pattern.trim().startsWith('#')) return null; // blank / comment line
627
+ const dirOnly = /\/$/.test(pattern.trim());
628
+ const hasSlash = p.includes('/');
629
+ const body = globToRegex(p);
630
+ const re = hasSlash ? new RegExp('(^|/)' + body + '$') : new RegExp('^' + body + '$');
631
+ return { re, dirOnly, hasSlash };
632
+ }
633
+
634
+ function isIgnored(name, full, isDir, matchers) {
635
+ for (const m of matchers) {
636
+ if (m.dirOnly && !isDir) continue;
637
+ if (m.re.test(m.hasSlash ? full : name)) return true;
638
+ }
639
+ return false;
640
+ }
641
+
642
+ function* walk(dir, opts) {
643
+ let entries;
644
+ try {
645
+ entries = fs.readdirSync(dir, { withFileTypes: true });
646
+ } catch (e) {
647
+ process.stderr.write(`cssgrep: ${dir}: ${e.code || e.message}\n`);
648
+ return;
649
+ }
650
+ for (const e of entries) {
651
+ const full = path.join(dir, e.name);
652
+ const isDir = e.isDirectory();
653
+ if (opts.ignore.length && isIgnored(e.name, full, isDir, opts.ignore)) continue;
654
+ if (isDir) {
655
+ yield* walk(full, opts);
656
+ } else if (e.isFile()) {
657
+ const ext = path.extname(e.name).slice(1).toLowerCase();
658
+ if (opts.exts.includes(ext)) yield full;
659
+ }
660
+ }
661
+ }
662
+
663
+ function readStdin() {
664
+ return fs.readFileSync(0, 'utf8');
665
+ }
666
+
667
+ function main() {
668
+ const opts = parseArgs(process.argv.slice(2));
669
+ resolveSelectorAndPaths(opts);
670
+
671
+ // Resolve the list of files to search.
672
+ let files = [];
673
+ let useStdin = false;
674
+ if (opts.recursive) {
675
+ // No path given with -r means "search here", like ripgrep.
676
+ const targets = opts.paths.length ? opts.paths : ['.'];
677
+ for (const p of targets) {
678
+ const st = fs.statSync(p, { throwIfNoEntry: false });
679
+ if (!st) { process.stderr.write(`cssgrep: ${p}: no such file or directory\n`); continue; }
680
+ if (st.isDirectory()) files.push(...walk(p, opts));
681
+ else files.push(p);
682
+ }
683
+ } else if (opts.paths.length > 0) {
684
+ files = opts.paths;
685
+ } else {
686
+ useStdin = true;
687
+ }
688
+
689
+ // A label (file prefix) is shown when searching more than one file.
690
+ const showLabel = !useStdin && files.length > 1;
691
+
692
+ const out = [];
693
+ let total = 0;
694
+
695
+ // -M/--max-total: remaining matches allowed across all files (Infinity = off).
696
+ const room = () => (opts.maxTotal ? Math.max(0, opts.maxTotal - total) : Infinity);
697
+
698
+ try {
699
+ if (useStdin) {
700
+ total += searchSource(readStdin(), '(standard input)', false, opts, out, room());
701
+ } else {
702
+ for (const f of files) {
703
+ if (room() <= 0) break; // -M: global budget exhausted
704
+ let src;
705
+ try {
706
+ src = fs.readFileSync(f, 'utf8');
707
+ } catch (e) {
708
+ process.stderr.write(`cssgrep: ${f}: ${e.code || e.message}\n`);
709
+ continue;
710
+ }
711
+ total += searchSource(src, f, showLabel, opts, out, room());
712
+ if (opts.quiet && total > 0) break; // -q: first match decides the status
713
+ }
714
+ }
715
+ } catch (e) {
716
+ if (e && e.message && /selector|tokeniz|parse/i.test(e.message)) {
717
+ fail(`invalid selector: ${opts.selector}`);
718
+ }
719
+ fail(e.message);
720
+ }
721
+
722
+ if (out.length) {
723
+ // For -l/-L, -0 NUL-terminates each file name (no newline) so the list is
724
+ // safe for `xargs -0`. Other modes keep newline-separated records (with -0
725
+ // the NUL appears only as the in-record file-name separator).
726
+ if (opts.nul && (opts.filesWithMatches || opts.filesWithoutMatch)) {
727
+ process.stdout.write(out.map(s => s + '\0').join(''));
728
+ } else {
729
+ process.stdout.write(out.join('\n') + '\n');
730
+ }
731
+ }
732
+ // Normally success means "a match was found". With -L it means "a file
733
+ // without a match was printed", which is decoupled from the match total.
734
+ const success = opts.filesWithoutMatch ? out.length > 0 : total > 0;
735
+ process.exit(success ? 0 : 1);
736
+ }
737
+
738
+ main();
package/man/cssgrep.1 ADDED
@@ -0,0 +1,217 @@
1
+ .\" Man page for cssgrep. Keep the OPTIONS section in sync with the USAGE
2
+ .\" string in index.js.
3
+ .TH CSSGREP 1 "2026-06-29" "cssgrep 1.0.0" "User Commands"
4
+ .SH NAME
5
+ cssgrep \- search HTML by CSS selector, grep-style
6
+ .SH SYNOPSIS
7
+ .B cssgrep
8
+ .I selector
9
+ .RI [ file ...]
10
+ .br
11
+ .B cssgrep
12
+ .I selector
13
+ .B \-r
14
+ .IR dir ...
15
+ .br
16
+ .B cat
17
+ .I file.html
18
+ | \fBcssgrep\fR \fIselector\fR
19
+ .SH DESCRIPTION
20
+ .B cssgrep
21
+ matches a CSS
22
+ .I selector
23
+ against parsed HTML and prints each hit grep-style. Unlike most HTML query
24
+ tools it tracks the source byte offset of every matched node, so
25
+ .B \-n
26
+ reports an accurate
27
+ .IR line : col
28
+ even on minified, single-line HTML, and the output plugs straight into
29
+ grep-aware editors (vim's
30
+ .BR grepprg ).
31
+ .PP
32
+ Input is read from the
33
+ .I file
34
+ arguments, recursively from directories with
35
+ .BR \-r ,
36
+ or from standard input when no path is given. One line is printed per match.
37
+ As with
38
+ .BR grep ,
39
+ the matched line is printed on its own; a
40
+ .RI " file :"
41
+ prefix is added when searching multiple files, and the
42
+ .IR line : col
43
+ locator appears only with
44
+ .BR \-n .
45
+ .SH OPTIONS
46
+ .TP
47
+ .BR \-r ", " \-\-recursive
48
+ Recurse into directory arguments (defaults to the current directory if none
49
+ are given).
50
+ .TP
51
+ .BI \-\-ext " list"
52
+ Comma-separated extensions to scan with
53
+ .B \-r
54
+ (default:
55
+ .IR html,htm ).
56
+ .TP
57
+ .BR \-i ", " \-\-ignore " \fIglob\fR"
58
+ Skip files/dirs matching
59
+ .I glob
60
+ while recursing (repeatable).
61
+ .TP
62
+ .BI \-\-ignore\-file " path"
63
+ Read ignore globs from
64
+ .IR path ,
65
+ one per line
66
+ .RB ( #
67
+ comments and blank lines ignored), like a
68
+ .IR .gitignore .
69
+ .TP
70
+ .BR \-n ", " \-\-line\-number
71
+ Prefix each match with its
72
+ .IR line : col
73
+ locator. Excludes
74
+ .B \-c
75
+ and
76
+ .BR \-p .
77
+ .TP
78
+ .BR \-p ", " \-\-print
79
+ Pretty-print the matched node's HTML, re-indented from scratch. No
80
+ .IR line : col
81
+ locator is shown.
82
+ .TP
83
+ .BI \-\-attr " name"
84
+ Print the value of attribute
85
+ .I name
86
+ for each match (nodes without it are skipped).
87
+ .TP
88
+ .B \-\-text
89
+ Print the matched node's text content, whitespace collapsed.
90
+ .TP
91
+ .B \-\-json
92
+ Print one JSON object per match (NDJSON) with
93
+ .IR file ", " line ", " col ", " html ", " text .
94
+ .TP
95
+ .BI \-\-parent " n"
96
+ Report the
97
+ .IR n -th
98
+ element ancestor of each match instead of the match itself (de-duplicated).
99
+ .TP
100
+ .BR \-w ", " \-\-max\-width " \fIn\fR"
101
+ Truncate the shown line to
102
+ .I n
103
+ columns (adds an ellipsis).
104
+ .TP
105
+ .BR \-A ", " \-\-after\-context " \fIn\fR"
106
+ Print
107
+ .I n
108
+ source lines after each match.
109
+ .TP
110
+ .BR \-B ", " \-\-before\-context " \fIn\fR"
111
+ Print
112
+ .I n
113
+ source lines before each match.
114
+ .TP
115
+ .BR \-C ", " \-\-context " \fIn\fR"
116
+ Print
117
+ .I n
118
+ source lines before and after each match.
119
+ .TP
120
+ .BR \-m ", " \-\-max\-count " \fIn\fR"
121
+ Stop after
122
+ .I n
123
+ matches per file.
124
+ .TP
125
+ .BR \-M ", " \-\-max\-total " \fIn\fR"
126
+ Stop after
127
+ .I n
128
+ matches in total across all files.
129
+ .TP
130
+ .BR \-c ", " \-\-count
131
+ Print only a count of matches (per file when relevant).
132
+ .TP
133
+ .BR \-l ", " \-\-files\-with\-matches
134
+ Print only the names of files that have a match.
135
+ .TP
136
+ .BR \-L ", " \-\-files\-without\-match
137
+ Print only the names of files with no match.
138
+ .TP
139
+ .BR \-q ", " \-\-quiet
140
+ Print nothing; exit 0 on the first match, 1 if none.
141
+ .TP
142
+ .BR \-0 ", " \-\-null
143
+ Separate the file name with a NUL byte (for
144
+ .BR "xargs \-0" ).
145
+ .TP
146
+ .BR \-\-color [ =\fIwhen\fR ]
147
+ Colorize output:
148
+ .I auto
149
+ (default),
150
+ .IR always ", or " never .
151
+ A bare
152
+ .B \-\-color
153
+ means
154
+ .IR always .
155
+ .TP
156
+ .BR \-h ", " \-\-help
157
+ Show help and exit.
158
+ .TP
159
+ .BR \-V ", " \-\-version
160
+ Show version and exit.
161
+ .PP
162
+ Boolean short flags combine
163
+ .RB ( \-rn
164
+ is
165
+ .BR "\-r \-n" ),
166
+ and a value-taking flag may close such a cluster
167
+ .RB ( \-rnw100 ).
168
+ Long options take a value with
169
+ .B =
170
+ or as the next word
171
+ .RB ( \-\-max\-width=100 ", " "\-\-ext htm" ).
172
+ .SH EXIT STATUS
173
+ .TP
174
+ .B 0
175
+ One or more matches were found.
176
+ .TP
177
+ .B 1
178
+ No matches were found.
179
+ .TP
180
+ .B 2
181
+ An error occurred (bad option, unreadable file, invalid selector).
182
+ .SH EXAMPLES
183
+ .TP
184
+ Find every link in a page:
185
+ .B cssgrep a page.html
186
+ .TP
187
+ Locate inputs with a locator, even in minified HTML:
188
+ .B cssgrep \-n "input[name]" min.html
189
+ .TP
190
+ Recurse a tree, skipping vendored files:
191
+ .B cssgrep \-rn .error \-i node_modules src/
192
+ .TP
193
+ Extract attribute values for scripting:
194
+ .B cssgrep \-\-attr href "a.external" page.html
195
+ .SH VIM INTEGRATION
196
+ Wire
197
+ .B cssgrep
198
+ into vim's
199
+ .B :grep
200
+ by pointing
201
+ .B grepprg
202
+ at it and teaching
203
+ .B grepformat
204
+ the
205
+ .IR file : line : col
206
+ locator:
207
+ .PP
208
+ .RS
209
+ .nf
210
+ set grepprg=cssgrep\e \-n\e \-r
211
+ set grepformat=%f:%l:%c\e %m
212
+ .fi
213
+ .RE
214
+ .SH SEE ALSO
215
+ .BR grep (1)
216
+ .SH AUTHOR
217
+ matias
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "cssgrep",
3
+ "version": "1.0.0",
4
+ "description": "Search HTML by CSS selector and print matches grep-style (file:line:col with -n).",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "cssgrep": "index.js"
8
+ },
9
+ "man": "./man/cssgrep.1",
10
+ "files": [
11
+ "index.js",
12
+ "README.md",
13
+ "LICENSE",
14
+ "man/cssgrep.1",
15
+ "completions/"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20.19.0"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/msbatarce/cssgrep.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/msbatarce/cssgrep/issues"
26
+ },
27
+ "homepage": "https://github.com/msbatarce/cssgrep#readme",
28
+ "scripts": {
29
+ "test": "node test.js",
30
+ "build:linux-x64": "bun build ./index.js --compile --target=bun-linux-x64 --outfile dist/cssgrep-linux-x64",
31
+ "build:linux-arm64": "bun build ./index.js --compile --target=bun-linux-arm64 --outfile dist/cssgrep-linux-arm64",
32
+ "build:darwin-x64": "bun build ./index.js --compile --target=bun-darwin-x64 --outfile dist/cssgrep-darwin-x64",
33
+ "build:darwin-arm64": "bun build ./index.js --compile --target=bun-darwin-arm64 --outfile dist/cssgrep-darwin-arm64",
34
+ "build:windows-x64": "bun build ./index.js --compile --target=bun-windows-x64 --outfile dist/cssgrep-windows-x64.exe",
35
+ "build:binaries": "npm run build:linux-x64 && npm run build:linux-arm64 && npm run build:darwin-x64 && npm run build:darwin-arm64 && npm run build:windows-x64",
36
+ "build:sea": "node scripts/build-sea.js"
37
+ },
38
+ "keywords": [
39
+ "css",
40
+ "selector",
41
+ "html",
42
+ "grep",
43
+ "vim",
44
+ "cli"
45
+ ],
46
+ "author": "matias",
47
+ "license": "ISC",
48
+ "dependencies": {
49
+ "css-select": "^7.0.0",
50
+ "dom-serializer": "^3.1.1",
51
+ "htmlparser2": "^12.0.0",
52
+ "js-beautify": "^1.15.4"
53
+ },
54
+ "devDependencies": {
55
+ "esbuild": "^0.25.0",
56
+ "postject": "^1.0.0-alpha.6"
57
+ }
58
+ }