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 +15 -0
- package/README.md +190 -0
- package/completions/_cssgrep +35 -0
- package/completions/cssgrep.bash +40 -0
- package/completions/cssgrep.fish +27 -0
- package/index.js +738 -0
- package/man/cssgrep.1 +217 -0
- package/package.json +58 -0
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
|
+
}
|